From 553d507ea63bc252e815a924c0d2baea961351a1 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 15 May 2026 13:02:37 -0400 Subject: [PATCH] add platform audit export output Adds JSON/markdown export and write-to-file support for the platform audit operator artifact. --- scripts/platform-audit.js | 139 ++++++++++++++++++++++++++- tests/scripts/platform-audit.test.js | 29 ++++++ 2 files changed, 164 insertions(+), 4 deletions(-) diff --git a/scripts/platform-audit.js b/scripts/platform-audit.js index 1b91e251..84c9404b 100644 --- a/scripts/platform-audit.js +++ b/scripts/platform-audit.js @@ -29,7 +29,11 @@ function usage() { 'Operator readiness audit for ECC queue, discussion, roadmap, release, and security evidence.', '', 'Options:', - ' --format Output format (default: text)', + ' --format ', + ' Output format (default: text)', + ' --json Alias for --format json', + ' --markdown Alias for --format markdown', + ' --write Write json or markdown output to a file', ' --root Repository root to inspect (default: cwd)', ' --repo GitHub repo to inspect; repeatable', ' --skip-github Skip live GitHub queue/discussion checks', @@ -71,6 +75,7 @@ function parseArgs(argv) { skipGithub: false, thresholds: { ...DEFAULT_THRESHOLDS }, useEnvGithubToken: false, + writePath: null, }; for (let index = 0; index < args.length; index += 1) { @@ -92,6 +97,16 @@ function parseArgs(argv) { continue; } + if (arg === '--json') { + parsed.format = 'json'; + continue; + } + + if (arg === '--markdown') { + parsed.format = 'markdown'; + continue; + } + if (arg === '--root') { parsed.root = path.resolve(readValue(args, index, arg)); index += 1; @@ -130,6 +145,17 @@ function parseArgs(argv) { continue; } + if (arg === '--write') { + parsed.writePath = path.resolve(readValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--write=')) { + parsed.writePath = path.resolve(arg.slice('--write='.length)); + continue; + } + if (arg === '--max-open-prs') { parsed.thresholds.maxOpenPrs = parseIntegerFlag(readValue(args, index, arg), arg); index += 1; @@ -176,8 +202,12 @@ function parseArgs(argv) { throw new Error(`Unknown argument: ${arg}`); } - if (!['text', 'json'].includes(parsed.format)) { - throw new Error(`Invalid format: ${parsed.format}. Use text or json.`); + if (!['text', 'json', 'markdown'].includes(parsed.format)) { + throw new Error(`Invalid format: ${parsed.format}. Use text, json, or markdown.`); + } + + if (parsed.writePath && parsed.format === 'text') { + throw new Error('--write requires --json, --markdown, or --format json|markdown'); } parsed.allowUntracked = parsed.allowUntracked.map(normalizeRelativePrefix); @@ -595,6 +625,101 @@ function renderText(report) { return `${lines.join('\n')}\n`; } +function markdownEscape(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, '
'); +} + +function markdownStatus(status) { + switch (status) { + case 'pass': + return 'PASS'; + case 'fail': + return 'FAIL'; + case 'warn': + return 'WARN'; + default: + return String(status || 'UNKNOWN').toUpperCase(); + } +} + +function renderMarkdown(report) { + const lines = [ + '# ECC Platform Audit', + '', + `Generated: ${report.generatedAt}`, + `Status: ${report.ready ? 'ready' : 'attention required'}`, + `Root: \`${report.root}\``, + '', + '## Queue Summary', + '', + '| Surface | Count | Threshold | Status |', + '| --- | ---: | ---: | --- |', + `| Open PRs | ${report.github.totals.openPrs} | ${report.thresholds.maxOpenPrs} | ${report.github.totals.openPrs <= report.thresholds.maxOpenPrs ? 'PASS' : 'FAIL'} |`, + `| Open issues | ${report.github.totals.openIssues} | ${report.thresholds.maxOpenIssues} | ${report.github.totals.openIssues <= report.thresholds.maxOpenIssues ? 'PASS' : 'FAIL'} |`, + `| Discussions needing maintainer touch | ${report.github.totals.discussionsNeedingMaintainerTouch} | 0 | ${report.github.totals.discussionsNeedingMaintainerTouch === 0 ? 'PASS' : 'FAIL'} |`, + `| Conflicting open PRs | ${report.github.totals.dirtyPrs} | 0 | ${report.github.totals.dirtyPrs === 0 ? 'PASS' : 'FAIL'} |`, + `| Blocking dirty files | ${report.git.blockingDirtyCount} | ${report.thresholds.maxDirtyFiles} | ${report.git.blockingDirtyCount <= report.thresholds.maxDirtyFiles ? 'PASS' : 'FAIL'} |`, + '', + '## Repositories', + '', + '| Repository | PRs | Issues | Discussions sampled | Needs maintainer | Dirty PRs |', + '| --- | ---: | ---: | ---: | ---: | ---: |', + ]; + + for (const repo of report.github.repos) { + lines.push( + `| \`${markdownEscape(repo.repo)}\` | ${repo.openPrs || 0} | ${repo.openIssues || 0} | ${repo.discussions ? repo.discussions.sampledCount : 0} | ${repo.discussions ? repo.discussions.needingMaintainerTouch.length : 0} | ${repo.dirtyPrs ? repo.dirtyPrs.length : 0} |` + ); + } + + lines.push( + '', + '## Checks', + '', + '| Status | Check | Summary | Evidence |', + '| --- | --- | --- | --- |' + ); + + for (const check of report.checks) { + lines.push( + `| ${markdownStatus(check.status)} | \`${markdownEscape(check.id)}\` | ${markdownEscape(check.summary)} | ${check.path ? `\`${markdownEscape(check.path)}\`` : ''} |` + ); + } + + lines.push('', '## Top Actions', ''); + if (report.top_actions.length === 0) { + lines.push('- none'); + } else { + for (const action of report.top_actions) { + lines.push(`- \`${markdownEscape(action.id)}\`: ${markdownEscape(action.fix)}`); + } + } + + lines.push('', '## Git State', ''); + lines.push(`- Branch: ${report.git.branch ? `\`${markdownEscape(report.git.branch)}\`` : '(unknown)'}`); + lines.push(`- Ignored dirty files: ${report.git.ignoredDirty.length}`); + if (report.git.ignoredDirty.length > 0) { + for (const line of report.git.ignoredDirty) { + lines.push(` - \`${markdownEscape(line)}\``); + } + } + lines.push(`- Blocking dirty files: ${report.git.blockingDirty.length}`); + if (report.git.blockingDirty.length > 0) { + for (const line of report.git.blockingDirty) { + lines.push(` - \`${markdownEscape(line)}\``); + } + } + + return `${lines.join('\n')}\n`; +} + +function writeOutput(writePath, output) { + fs.mkdirSync(path.dirname(writePath), { recursive: true }); + fs.writeFileSync(writePath, output, 'utf8'); +} + function main() { try { const options = parseArgs(process.argv); @@ -606,7 +731,12 @@ function main() { const report = buildReport(options); const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` - : renderText(report); + : options.format === 'markdown' + ? renderMarkdown(report) + : renderText(report); + if (options.writePath) { + writeOutput(options.writePath, output); + } process.stdout.write(output); if (options.exitCode && !report.ready) { @@ -625,6 +755,7 @@ if (require.main === module) { module.exports = { buildReport, parseArgs, + renderMarkdown, renderText, runGhJson, }; diff --git a/tests/scripts/platform-audit.test.js b/tests/scripts/platform-audit.test.js index 573c2e3d..e7357a20 100644 --- a/tests/scripts/platform-audit.test.js +++ b/tests/scripts/platform-audit.test.js @@ -148,6 +148,7 @@ function runTests() { 'script', '--format=json', `--root=${rootDir}`, + '--json', '--repo', 'affaan-m/everything-claude-code', '--max-open-prs', @@ -166,6 +167,7 @@ function runTests() { assert.deepStrictEqual(parsed.allowUntracked, ['docs/drafts/']); assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/); + assert.throws(() => parseArgs(['node', 'script', '--write', 'audit.md']), /--write requires/); assert.throws(() => parseArgs(['node', 'script', '--repo']), /--repo requires a value/); assert.throws(() => parseArgs(['node', 'script', '--max-open-prs', 'x']), /Invalid --max-open-prs/); assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/); @@ -192,6 +194,33 @@ function runTests() { } })) passed++; else failed++; + if (test('markdown output can be written as an operator artifact', () => { + const projectRoot = createTempDir('platform-audit-markdown-'); + const outputPath = path.join(projectRoot, 'artifacts', 'platform-audit.md'); + + try { + seedRepo(projectRoot); + const stdout = run([ + '--markdown', + '--write', + outputPath, + `--root=${projectRoot}`, + '--skip-github' + ], { cwd: projectRoot }); + const written = fs.readFileSync(outputPath, 'utf8'); + + assert.strictEqual(stdout, written); + assert.ok(written.includes('# ECC Platform Audit')); + assert.ok(written.includes('## Queue Summary')); + assert.ok(written.includes('| Open PRs | 0 | 20 | PASS |')); + assert.ok(written.includes('`roadmap-linear-mirror`')); + assert.ok(written.includes('## Top Actions')); + assert.ok(written.includes('- none')); + } finally { + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('github queue and discussion budgets pass with maintainer touch', () => { const projectRoot = createTempDir('platform-audit-github-pass-');