import fs from "node:fs"; import path from "node:path"; const TOOL_VERSION = "0.1.0"; /** * Reads and parses JSON from a file path. * * Returns null when parsing fails so the caller can provide user-facing * guidance without crashing. * * @param {string} filePath Absolute path to a JSON file. * @returns {any|null} Parsed JSON object. */ function readJsonSafe(filePath) { try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return null; } } /** * Reads a UTF-8 text file. * * Returns null when reading fails so callers can surface missing configs * without crashing. * * @param {string} filePath Absolute path to a text file. * @returns {string|null} File contents. */ function readTextSafe(filePath) { try { return fs.readFileSync(filePath, "utf8"); } catch { return null; } } /** * Checks whether a path exists and is a regular file. * * @param {string} filePath Absolute or relative file path. * @returns {boolean} True when the path exists and is a file. */ function isFile(filePath) { try { return fs.statSync(filePath).isFile(); } catch { return false; } } /** * Normalizes Composer script entries into a flat list of commands. * * Composer allows scripts to be strings or arrays. This helper provides a * consistent format for analysis. * * @param {unknown} value Composer script value. * @returns {string[]} Command list. */ function normalizeComposerScript(value) { if (typeof value === "string") return [value]; if (Array.isArray(value)) return value.filter((x) => typeof x === "string"); return []; } /** * Detects which Composer scripts invoke PHPStan. * * This helps the agent prefer the repo's own invocation (memory limits, * config, bootstrap files) instead of guessing. * * @param {Record} scripts Composer scripts block. * @returns {Array<{name: string, commands: string[]}>} Matching script entries. */ function findPhpstanScripts(scripts) { if (!scripts || typeof scripts !== "object") return []; const matches = []; for (const [name, raw] of Object.entries(scripts)) { const commands = normalizeComposerScript(raw); const invokesPhpstan = commands.some((cmd) => { if (typeof cmd !== "string") return false; return cmd.includes("phpstan") || cmd.includes("vendor/bin/phpstan"); }); if (!invokesPhpstan) continue; matches.push({ name, commands }); } return matches; } /** * Chooses a recommended command for running PHPStan in the current repo. * * The intent is to prefer an existing Composer script (often has correct * config, bootstrap, and memory limits), falling back to vendor binaries. * * @param {Array<{name: string, commands: string[]}>} phpstanScripts Matching Composer scripts. * @param {{binaryRelPath: string|null, configRelPath: string|null}} fallbackInfo Fallback discovery. * @returns {{command: string|null, rationale: string}} Suggested command and why. */ function suggestCommand(phpstanScripts, fallbackInfo) { const preferred = phpstanScripts.find((s) => s.name === "phpstan"); if (preferred) { return { command: `composer run ${preferred.name}`, rationale: "Uses the repo's Composer script (preferred for consistent config).", }; } if (phpstanScripts.length > 0) { return { command: `composer run ${phpstanScripts[0].name}`, rationale: "Uses the repo's Composer script that invokes PHPStan.", }; } if (!fallbackInfo.binaryRelPath) { return { command: null, rationale: "No PHPStan binary detected under vendor/bin and no Composer script found.", }; } const configArg = fallbackInfo.configRelPath ? ` -c ${fallbackInfo.configRelPath}` : ""; return { command: `${fallbackInfo.binaryRelPath} analyse${configArg}`, rationale: "Falls back to vendor/bin/phpstan with an explicit config when needed.", }; } /** * Extracts lightweight hints from a phpstan.neon config. * * This does not parse NEON. It only checks for common directive tokens so the * agent can quickly see whether scan directives are in use. * * @param {string} configText Raw phpstan config contents. * @returns {{mentionsScanDirectories: boolean, mentionsScanFiles: boolean}} Hints. */ function buildConfigHints(configText) { const t = configText.toLowerCase(); return { mentionsScanDirectories: t.includes("scandirectories"), mentionsScanFiles: t.includes("scanfiles"), }; } /** * Extracts stub-like package references from a PHPStan config. * * The PHPStan config usually references stubs via vendor paths (for example, * "vendor/php-stubs/wordpress-stubs"), so this helper focuses on composer-style * "vendor/package" tokens containing "stubs". * * @param {string} configText Raw phpstan config contents. * @returns {string[]} Unique, lowercased composer-style package references. */ function extractStubPackageReferences(configText) { const matches = configText .toLowerCase() .match(/\b[a-z0-9_.-]+\/[a-z0-9_.-]*stubs[a-z0-9_.-]*\b/g); if (!matches) return []; return [...new Set(matches)].sort(); } /** * Builds a JSON report describing the current repository's PHPStan setup. * * @returns {object} A stable, machine-readable inspection report. */ function buildReport() { const repoRoot = process.cwd(); const composerPath = path.join(repoRoot, "composer.json"); const composer = isFile(composerPath) ? readJsonSafe(composerPath) : null; const phpstanConfigFiles = ["phpstan.neon", "phpstan.neon.dist"].filter((f) => isFile(path.join(repoRoot, f)) ); const phpstanBaselineFiles = ["phpstan-baseline.neon", "phpstan-baseline.neon.dist"].filter((f) => isFile(path.join(repoRoot, f)) ); let configRelPath = null; if (phpstanConfigFiles.includes("phpstan.neon")) configRelPath = "phpstan.neon"; else if (phpstanConfigFiles.includes("phpstan.neon.dist")) configRelPath = "phpstan.neon.dist"; const configAbsPath = configRelPath ? path.join(repoRoot, configRelPath) : null; const configText = configAbsPath ? readTextSafe(configAbsPath) : null; const binaryRelPath = isFile(path.join(repoRoot, "vendor", "bin", "phpstan")) ? "vendor/bin/phpstan" : null; const composerScripts = composer?.scripts && typeof composer.scripts === "object" ? composer.scripts : null; const phpstanScripts = composerScripts ? findPhpstanScripts(composerScripts) : []; const composerDependencies = [ ...Object.keys(composer?.require ?? {}), ...Object.keys(composer?.["require-dev"] ?? {}), ].sort(); const referencedDependencies = configText ? extractStubPackageReferences(configText) : []; const configHints = configText ? buildConfigHints(configText) : null; const suggested = suggestCommand(phpstanScripts, { binaryRelPath, configRelPath: configRelPath === "phpstan.neon" ? null : configRelPath, }); const notes = []; if (!composer) notes.push("No composer.json found; PHPStan is usually installed via Composer."); if (phpstanConfigFiles.length === 0) notes.push("No phpstan.neon or phpstan.neon.dist found at repo root."); if (!binaryRelPath && phpstanScripts.length === 0) notes.push("No PHPStan entrypoint detected (Composer script or vendor/bin/phpstan)."); return { tool: { name: "phpstan_inspect", version: TOOL_VERSION }, repoRoot, composer: { exists: Boolean(composer), path: isFile(composerPath) ? "composer.json" : null, phpstanScripts, dependencies: composerDependencies, }, phpstan: { configFiles: phpstanConfigFiles, baselineFiles: phpstanBaselineFiles, config: { primary: configRelPath, hints: configHints, referencedDependencies, }, binary: { vendorBin: binaryRelPath, }, }, suggested, notes, }; } /** * CLI entrypoint for printing the inspection report. */ function main() { const report = buildReport(); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } main();