From 17e7da59ad9346dfca9dacd954ea95d845929539 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Fri, 10 Jul 2020 10:18:10 +0200 Subject: [PATCH] Add support for running Psalm Add support for Psalm + Psalm Plugin WordPress. This is a bit different to other "linters" as this is more of a typechecker, but I think it fits the general paradigm well, and in project that use Psalm, we can at least run it in those contexts. In the future we might want to expand this to run a default config for things like taint analysis. The major difference with Psalm is we need to install Composer dependencies as those are part of the type flow, and needed for Psalm scanning. At the moment I just installed production dependencies, however I'm not sure on the best route here. It wouldn't be uncommon to include your tests and the like in the Psalm config, in which case you're likely to also need the composer dev deps. I think it would be ok to not run Psalm against tests, but we'd need to somehow exclude them from any Psalm configuration. --- scripts/psalm.js | 12 ++++ src/linters/psalm/index.js | 128 +++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 scripts/psalm.js create mode 100644 src/linters/psalm/index.js diff --git a/scripts/psalm.js b/scripts/psalm.js new file mode 100644 index 0000000..2d65424 --- /dev/null +++ b/scripts/psalm.js @@ -0,0 +1,12 @@ +const psalm = require( '../src/linters/psalm' ); + +const standardDir = process.argv[2]; +const codedir = process.argv[3]; + +psalm( standardDir )( codedir ) + .then( results => { + console.log( results ); + }) + .catch( err => { + console.error( `error: ${err.message}` ) + }) diff --git a/src/linters/psalm/index.js b/src/linters/psalm/index.js new file mode 100644 index 0000000..256382c --- /dev/null +++ b/src/linters/psalm/index.js @@ -0,0 +1,128 @@ +const child_process = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +const CONFIG_NAMES = [ + 'psalm.xml', +]; + +/** + * Convert a Psalm error into formatOutput-style results. + * + * @param {Object} message Issue data from Psalm. + * @returns {Object} + */ +const formatMessage = message => { + const text = `${message.message}`; + + return { + line: message.line_from, + column: 0, + severity: message.severity, + message: message.message, + source: message.type, + }; +}; + +/** + * Convert Psalm results into common output format. + * + * @param {Array} data Warnings and errors from Psalm. + * @param {String} codepath Path to the code getting linted. + * @returns {{files, totals: {warnings: *, errors: *}}} + */ +const formatOutput = ( data, codepath ) => { + const totals = { + errors: 0, + warnings: 0, + }; + const files = {}; + data.forEach( psalmIssue => { + const relPath = path.relative( codepath, psalmIssue.file_path ); + if ( psalmIssue.severity === 'error' ) { + totals.errors++; + } else if ( psalmIssue.severity === 'warning' ) { + totals.warnings++; + } + files[ relPath ] = formatMessage( psalmIssue ) + } ); + + return { totals, files }; +}; + +/** + * Run Psalm typechecking. + * + * @param {String} standardPath Path to custom standard set. + */ +module.exports = standardPath => codepath => { + const psalmPath = path.join( standardPath, 'vendor', 'bin', 'psalm' ); + + const args = [ + psalmPath, + '--output-format=json', + '--no-progress', + ]; + const opts = { + cwd: codepath, + env: process.env, + }; + + return composerInstall( codepath ).then( () => { + return new Promise( ( resolve, reject ) => { + console.log( 'Spawning PHP process', psalmPath, args, opts ); + const proc = child_process.spawn( 'php', args, opts ); + let stdout = ''; + let stderr = ''; + proc.stdout.on( 'data', data => stdout += data ); + proc.stderr.on( 'data', data => stderr += data ); + proc.on( 'error', e => { console.log( e ) } ); + proc.on( 'close', errCode => { + // Error codes: + // 0: no errors found + // 1: errors found or Psalm processing error + let data; + try { + data = JSON.parse( stdout ); + } catch ( e ) { + // Couldn't decode JSON, so likely a human readable error. + console.log( stdout ) + console.log( stderr ) + console.log( e ) + return reject( stdout + stderr ); + } + + resolve( formatOutput( data, codepath ) ); + } ); + } ); + } ); +}; + +function composerInstall( codepath ) { + const opts = { + cwd: codepath, + env: process.env, + }; + return new Promise( ( resolve, reject ) => { + const proc = child_process.spawn( 'composer', [ 'install', '--no-dev' ], opts ); + let stdout = ''; + let stderr = ''; + proc.stdout.on( 'data', data => { + stdout += data; + console.log( data ); + } ); + proc.stderr.on( 'data', data => { + stderr += data; + console.error( String( data ) ); + } ); + proc.on( 'error', e => { console.log( e ) } ); + proc.on( 'close', errCode => { + if ( errCode > 0 ) { + reject( stderr ) + } + + resolve( stdout ); + } ); + } ); +} +