Setting the file. One moment.
Phpstan Inspect · Wp Phpstan · WordPress/agent-skills · Skills Docs
ContentsBack to the top of the page
4 of 4 scripts/ phpstan_inspect.mjs
JavaScript · 263 lines · 8 KB
filePath
Absolute path to a JSON file.
13 * @returns {any|null} Parsed JSON object.
14 */
15 function readJsonSafe ( filePath ) {
16 try {
17 return JSON . parse (fs. readFileSync (filePath, "utf8" ));
18 } catch {
19 return null ;
20 }
21 }
22
23 /**
24 * Reads a UTF-8 text file.
25 *
26 * Returns null when reading fails so callers can surface missing configs
27 * without crashing.
28 *
29 * @param {string} filePath Absolute path to a text file.
30 * @returns {string|null} File contents.
31 */
32 function readTextSafe ( filePath ) {
33 try {
34 return fs. readFileSync (filePath, "utf8" );
35 } catch {
36 return null ;
37 }
38 }
39
40 /**
41 * Checks whether a path exists and is a regular file.
42 *
43 * @param {string} filePath Absolute or relative file path.
44 * @returns {boolean} True when the path exists and is a file.
45 */
46 function isFile ( filePath ) {
47 try {
48 return fs. statSync (filePath). isFile ();
49 } catch {
50 return false ;
51 }
52 }
53
54 /**
55 * Normalizes Composer script entries into a flat list of commands.
56 *
57 * Composer allows scripts to be strings or arrays. This helper provides a
58 * consistent format for analysis.
59 *
60 * @param {unknown} value Composer script value.
61 * @returns {string[]} Command list.
62 */
63 function normalizeComposerScript ( value ) {
64 if ( typeof value === "string" ) return [value];
65 if (Array. isArray (value)) return value. filter (( x ) => typeof x === "string" );
66 return [];
67 }
68
69 /**
70 * Detects which Composer scripts invoke PHPStan.
71 *
72 * This helps the agent prefer the repo's own invocation (memory limits,
73 * config, bootstrap files) instead of guessing.
74 *
75 * @param {Record<string, unknown>} scripts Composer scripts block.
76 * @returns {Array<{name: string, commands: string[]}>} Matching script entries.
77 */
78 function findPhpstanScripts ( scripts ) {
79 if ( ! scripts || typeof scripts !== "object" ) return [];
80
81 const matches = [];
82
83 for ( const [ name , raw ] of Object. entries (scripts)) {
84 const commands = normalizeComposerScript (raw);
85
86 const invokesPhpstan = commands. some (( cmd ) => {
87 if ( typeof cmd !== "string" ) return false ;
88 return cmd. includes ( "phpstan" ) || cmd. includes ( "vendor/bin/phpstan" );
89 });
90
91 if ( ! invokesPhpstan) continue ;
92
93 matches. push ({ name, commands });
94 }
95
96 return matches;
97 }
98
99 /**
100 * Chooses a recommended command for running PHPStan in the current repo.
101 *
102 * The intent is to prefer an existing Composer script (often has correct
103 * config, bootstrap, and memory limits), falling back to vendor binaries.
104 *
105 * @param {Array<{name: string, commands: string[]}>} phpstanScripts Matching Composer scripts.
106 * @param {{binaryRelPath: string|null, configRelPath: string|null}} fallbackInfo Fallback discovery.
107 * @returns {{command: string|null, rationale: string}} Suggested command and why.
108 */
109 function suggestCommand ( phpstanScripts , fallbackInfo ) {
110 const preferred = phpstanScripts. find (( s ) => s.name === "phpstan" );
111 if (preferred) {
112 return {
113 command: `composer run ${ preferred . name }` ,
114 rationale: "Uses the repo's Composer script (preferred for consistent config)." ,
115 };
116 }
117
118 if (phpstanScripts. length > 0 ) {
119 return {
120 command: `composer run ${ phpstanScripts [ 0 ]. name }` ,
121 rationale: "Uses the repo's Composer script that invokes PHPStan." ,
122 };
123 }
124
125 if ( ! fallbackInfo.binaryRelPath) {
126 return {
127 command: null ,
128 rationale: "No PHPStan binary detected under vendor/bin and no Composer script found." ,
129 };
130 }
131
132 const configArg = fallbackInfo.configRelPath ? ` -c ${ fallbackInfo . configRelPath }` : "" ;
133
134 return {
135 command: `${ fallbackInfo . binaryRelPath } analyse${ configArg }` ,
136 rationale: "Falls back to vendor/bin/phpstan with an explicit config when needed." ,
137 };
138 }
139
140 /**
141 * Extracts lightweight hints from a phpstan.neon config.
142 *
143 * This does not parse NEON. It only checks for common directive tokens so the
144 * agent can quickly see whether scan directives are in use.
145 *
146 * @param {string} configText Raw phpstan config contents.
147 * @returns {{mentionsScanDirectories: boolean, mentionsScanFiles: boolean}} Hints.
148 */
149 function buildConfigHints ( configText ) {
150 const t = configText. toLowerCase ();
151
152 return {
153 mentionsScanDirectories: t. includes ( "scandirectories" ),
154 mentionsScanFiles: t. includes ( "scanfiles" ),
155 };
156 }
157
158 /**
159 * Extracts stub-like package references from a PHPStan config.
160 *
161 * The PHPStan config usually references stubs via vendor paths (for example,
162 * "vendor/php-stubs/wordpress-stubs"), so this helper focuses on composer-style
163 * "vendor/package" tokens containing "stubs".
164 *
165 * @param {string} configText Raw phpstan config contents.
166 * @returns {string[]} Unique, lowercased composer-style package references.
167 */
168 function extractStubPackageReferences ( configText ) {
169 const matches = configText
170 . toLowerCase ()
171 . match ( / \b [a-z0-9_.-] + \/ [a-z0-9_.-] * stubs [a-z0-9_.-] *\b / g );
172
173 if ( ! matches) return [];
174
175 return [ ...new Set (matches)]. sort ();
176 }
177
178 /**
179 * Builds a JSON report describing the current repository's PHPStan setup.
180 *
181 * @returns {object} A stable, machine-readable inspection report.
182 */
183 function buildReport () {
184 const repoRoot = process. cwd ();
185
186 const composerPath = path. join (repoRoot, "composer.json" );
187 const composer = isFile (composerPath) ? readJsonSafe (composerPath) : null ;
188
189 const phpstanConfigFiles = [ "phpstan.neon" , "phpstan.neon.dist" ]. filter (( f ) =>
190 isFile (path. join (repoRoot, f))
191 );
192 const phpstanBaselineFiles = [ "phpstan-baseline.neon" , "phpstan-baseline.neon.dist" ]. filter (( f ) =>
193 isFile (path. join (repoRoot, f))
194 );
195
196 let configRelPath = null ;
197 if (phpstanConfigFiles. includes ( "phpstan.neon" )) configRelPath = "phpstan.neon" ;
198 else if (phpstanConfigFiles. includes ( "phpstan.neon.dist" )) configRelPath = "phpstan.neon.dist" ;
199
200 const configAbsPath = configRelPath ? path. join (repoRoot, configRelPath) : null ;
201 const configText = configAbsPath ? readTextSafe (configAbsPath) : null ;
202
203 const binaryRelPath = isFile (path. join (repoRoot, "vendor" , "bin" , "phpstan" )) ? "vendor/bin/phpstan" : null ;
204
205 const composerScripts = composer?.scripts && typeof composer.scripts === "object" ? composer.scripts : null ;
206 const phpstanScripts = composerScripts ? findPhpstanScripts (composerScripts) : [];
207
208 const composerDependencies = [
209 ... Object. keys (composer?.require ?? {}),
210 ... Object. keys (composer?.[ "require-dev" ] ?? {}),
211 ]. sort ();
212 const referencedDependencies = configText ? extractStubPackageReferences (configText) : [];
213
214 const configHints = configText ? buildConfigHints (configText) : null ;
215
216 const suggested = suggestCommand (phpstanScripts, {
217 binaryRelPath,
218 configRelPath: configRelPath === "phpstan.neon" ? null : configRelPath,
219 });
220
221 const notes = [];
222
223 if ( ! composer) notes. push ( "No composer.json found; PHPStan is usually installed via Composer." );
224 if (phpstanConfigFiles. length === 0 ) notes. push ( "No phpstan.neon or phpstan.neon.dist found at repo root." );
225 if ( ! binaryRelPath && phpstanScripts. length === 0 ) notes. push ( "No PHPStan entrypoint detected (Composer script or vendor/bin/phpstan)." );
226
227
228
229 return {
230 tool: { name: "phpstan_inspect" , version: TOOL_VERSION },
231 repoRoot,
232 composer: {
233 exists: Boolean (composer),
234 path: isFile (composerPath) ? "composer.json" : null ,
235 phpstanScripts,
236 dependencies: composerDependencies,
237 },
238 phpstan: {
239 configFiles: phpstanConfigFiles,
240 baselineFiles: phpstanBaselineFiles,
241 config: {
242 primary: configRelPath,
243 hints: configHints,
244 referencedDependencies,
245 },
246 binary: {
247 vendorBin: binaryRelPath,
248 },
249 },
250 suggested,
251 notes,
252 };
253 }
254
255 /**
256 * CLI entrypoint for printing the inspection report.
257 */
258 function main () {
259 const report = buildReport ();
260 process.stdout. write ( `${ JSON . stringify ( report , null , 2 ) } \n ` );
261 }
262
263 main ();