mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-16 13:53:04 +08:00
728 lines
23 KiB
JavaScript
728 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
const {
|
|
emptyDiscussionSummary,
|
|
fetchDiscussionSummary,
|
|
} = require('./lib/github-discussions');
|
|
|
|
const SCHEMA_VERSION = 'ecc.platform-audit.v1';
|
|
const DEFAULT_REPOS = Object.freeze([
|
|
'affaan-m/everything-claude-code',
|
|
'affaan-m/agentshield',
|
|
'affaan-m/JARVIS',
|
|
'ECC-Tools/ECC-Tools',
|
|
'ECC-Tools/ECC-website',
|
|
]);
|
|
const DEFAULT_THRESHOLDS = Object.freeze({
|
|
maxOpenPrs: 20,
|
|
maxOpenIssues: 20,
|
|
maxDirtyFiles: 0,
|
|
});
|
|
function usage() {
|
|
console.log([
|
|
'Usage: node scripts/platform-audit.js [options]',
|
|
'',
|
|
'Operator readiness audit for ECC queue, discussion, roadmap, release, and security evidence.',
|
|
'',
|
|
'Options:',
|
|
' --format <text|json|markdown>',
|
|
' Output format (default: text)',
|
|
' --json Alias for --format json',
|
|
' --markdown Alias for --format markdown',
|
|
' --write <path> Write json or markdown output to a file',
|
|
' --root <dir> Repository root to inspect (default: cwd)',
|
|
' --repo <owner/repo> GitHub repo to inspect; repeatable',
|
|
' --skip-github Skip live GitHub queue/discussion checks',
|
|
' --max-open-prs <n> Fail when open PR count is above n (default: 20)',
|
|
' --max-open-issues <n> Fail when open issue count is above n (default: 20)',
|
|
' --max-dirty-files <n> Fail when blocking dirty file count is above n (default: 0)',
|
|
' --allow-untracked <path> Ignore untracked files under path; repeatable',
|
|
' --use-env-github-token Keep GITHUB_TOKEN when invoking gh',
|
|
' --exit-code Return 2 when the audit is not ready',
|
|
' --help, -h Show this help',
|
|
].join('\n'));
|
|
}
|
|
|
|
function readValue(args, index, flagName) {
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`${flagName} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseIntegerFlag(value, flagName) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
throw new Error(`Invalid ${flagName}: ${value}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = argv.slice(2);
|
|
const parsed = {
|
|
allowUntracked: [],
|
|
exitCode: false,
|
|
format: 'text',
|
|
help: false,
|
|
repos: [],
|
|
root: path.resolve(process.cwd()),
|
|
skipGithub: false,
|
|
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
useEnvGithubToken: false,
|
|
writePath: null,
|
|
};
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
|
|
if (arg === '--help' || arg === '-h') {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--format') {
|
|
parsed.format = readValue(args, index, arg).toLowerCase();
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--format=')) {
|
|
parsed.format = arg.slice('--format='.length).toLowerCase();
|
|
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;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--root=')) {
|
|
parsed.root = path.resolve(arg.slice('--root='.length));
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--repo') {
|
|
parsed.repos.push(readValue(args, index, arg));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--repo=')) {
|
|
parsed.repos.push(arg.slice('--repo='.length));
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--skip-github') {
|
|
parsed.skipGithub = true;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--allow-untracked') {
|
|
parsed.allowUntracked.push(readValue(args, index, arg));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--allow-untracked=')) {
|
|
parsed.allowUntracked.push(arg.slice('--allow-untracked='.length));
|
|
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;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--max-open-prs=')) {
|
|
parsed.thresholds.maxOpenPrs = parseIntegerFlag(arg.slice('--max-open-prs='.length), '--max-open-prs');
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--max-open-issues') {
|
|
parsed.thresholds.maxOpenIssues = parseIntegerFlag(readValue(args, index, arg), arg);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--max-open-issues=')) {
|
|
parsed.thresholds.maxOpenIssues = parseIntegerFlag(arg.slice('--max-open-issues='.length), '--max-open-issues');
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--max-dirty-files') {
|
|
parsed.thresholds.maxDirtyFiles = parseIntegerFlag(readValue(args, index, arg), arg);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--max-dirty-files=')) {
|
|
parsed.thresholds.maxDirtyFiles = parseIntegerFlag(arg.slice('--max-dirty-files='.length), '--max-dirty-files');
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--use-env-github-token') {
|
|
parsed.useEnvGithubToken = true;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--exit-code') {
|
|
parsed.exitCode = true;
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
|
|
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);
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeRelativePrefix(value) {
|
|
return String(value || '')
|
|
.replace(/\\/g, '/')
|
|
.replace(/^\.\/+/, '')
|
|
.replace(/\/+$/, '') + (String(value || '').endsWith('/') ? '/' : '');
|
|
}
|
|
|
|
function runCommand(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd,
|
|
env: options.env || process.env,
|
|
encoding: 'utf8',
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
|
|
}
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
|
|
}
|
|
|
|
return result.stdout || '';
|
|
}
|
|
|
|
function runGhJson(args, options = {}) {
|
|
const shimPath = process.env.ECC_GH_SHIM;
|
|
const command = shimPath ? process.execPath : 'gh';
|
|
const commandArgs = shimPath ? [shimPath, ...args] : args;
|
|
const env = { ...process.env };
|
|
|
|
if (!options.useEnvGithubToken) {
|
|
delete env.GITHUB_TOKEN;
|
|
}
|
|
|
|
const stdout = runCommand(command, commandArgs, { env });
|
|
try {
|
|
return JSON.parse(stdout || 'null');
|
|
} catch (error) {
|
|
throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function readText(rootDir, relativePath) {
|
|
try {
|
|
return fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
|
|
} catch (_error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function safeParseJson(text) {
|
|
if (!text || !text.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function includesAll(text, needles) {
|
|
return needles.every(needle => text.includes(needle));
|
|
}
|
|
|
|
function buildCheck(id, status, summary, details = {}) {
|
|
return { id, status, summary, ...details };
|
|
}
|
|
|
|
function parseGitStatus(output) {
|
|
const lines = output.split(/\r?\n/).filter(Boolean);
|
|
const branchLine = lines[0] || '';
|
|
const dirtyLines = lines.slice(1);
|
|
return {
|
|
branch: branchLine.replace(/^##\s*/, '') || null,
|
|
dirtyLines,
|
|
};
|
|
}
|
|
|
|
function isAllowedUntracked(statusLine, allowUntracked) {
|
|
if (!statusLine.startsWith('?? ')) {
|
|
return false;
|
|
}
|
|
|
|
const relativePath = statusLine.slice(3).replace(/\\/g, '/');
|
|
return allowUntracked.some(prefix => relativePath === prefix || relativePath.startsWith(prefix));
|
|
}
|
|
|
|
function inspectGit(rootDir, options) {
|
|
try {
|
|
const parsed = parseGitStatus(runCommand('git', ['status', '--short', '--branch'], { cwd: rootDir }));
|
|
const ignoredDirty = parsed.dirtyLines.filter(line => isAllowedUntracked(line, options.allowUntracked));
|
|
const blockingDirty = parsed.dirtyLines.filter(line => !isAllowedUntracked(line, options.allowUntracked));
|
|
|
|
return {
|
|
available: true,
|
|
branch: parsed.branch,
|
|
dirtyLines: parsed.dirtyLines,
|
|
ignoredDirty,
|
|
blockingDirty,
|
|
blockingDirtyCount: blockingDirty.length,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
available: false,
|
|
error: error.message,
|
|
branch: null,
|
|
dirtyLines: [],
|
|
ignoredDirty: [],
|
|
blockingDirty: [],
|
|
blockingDirtyCount: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
function fetchGithubRepo(repo, options) {
|
|
const prs = runGhJson([
|
|
'pr',
|
|
'list',
|
|
'--repo',
|
|
repo,
|
|
'--state',
|
|
'open',
|
|
'--json',
|
|
'number,title,isDraft,mergeStateStatus,updatedAt,url,author',
|
|
], options);
|
|
const issues = runGhJson([
|
|
'issue',
|
|
'list',
|
|
'--repo',
|
|
repo,
|
|
'--state',
|
|
'open',
|
|
'--json',
|
|
'number,title,updatedAt,url,author,labels',
|
|
], options);
|
|
const discussionSummary = fetchDiscussionSummary(repo, options);
|
|
|
|
return {
|
|
repo,
|
|
openPrs: Array.isArray(prs) ? prs.length : 0,
|
|
openIssues: Array.isArray(issues) ? issues.length : 0,
|
|
discussions: discussionSummary,
|
|
dirtyPrs: (Array.isArray(prs) ? prs : []).filter(pr => pr.mergeStateStatus === 'DIRTY').map(pr => ({
|
|
number: pr.number,
|
|
title: pr.title,
|
|
url: pr.url,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function buildGithubReport(options) {
|
|
const repos = options.repos.length > 0 ? options.repos : DEFAULT_REPOS;
|
|
|
|
if (options.skipGithub) {
|
|
return {
|
|
skipped: true,
|
|
repos: repos.map(repo => ({ repo, skipped: true })),
|
|
totals: {
|
|
openPrs: 0,
|
|
openIssues: 0,
|
|
discussionsNeedingMaintainerTouch: 0,
|
|
discussionsMissingAcceptedAnswer: 0,
|
|
dirtyPrs: 0,
|
|
errors: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
const repoReports = repos.map(repo => {
|
|
try {
|
|
return fetchGithubRepo(repo, options);
|
|
} catch (error) {
|
|
return {
|
|
repo,
|
|
error: error.message,
|
|
openPrs: 0,
|
|
openIssues: 0,
|
|
discussions: emptyDiscussionSummary(),
|
|
dirtyPrs: [],
|
|
};
|
|
}
|
|
});
|
|
|
|
return {
|
|
skipped: false,
|
|
repos: repoReports,
|
|
totals: {
|
|
openPrs: repoReports.reduce((sum, repo) => sum + repo.openPrs, 0),
|
|
openIssues: repoReports.reduce((sum, repo) => sum + repo.openIssues, 0),
|
|
discussionsNeedingMaintainerTouch: repoReports.reduce((sum, repo) => sum + repo.discussions.needingMaintainerTouch.length, 0),
|
|
discussionsMissingAcceptedAnswer: repoReports.reduce((sum, repo) => sum + repo.discussions.answerableWithoutAcceptedAnswer.length, 0),
|
|
dirtyPrs: repoReports.reduce((sum, repo) => sum + repo.dirtyPrs.length, 0),
|
|
errors: repoReports.filter(repo => repo.error).length,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildLocalEvidenceChecks(rootDir) {
|
|
const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {};
|
|
const packageScripts = packageJson.scripts || {};
|
|
const roadmap = readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md');
|
|
const progressSync = readText(rootDir, 'docs/architecture/progress-sync-contract.md');
|
|
const supplyChain = readText(rootDir, 'docs/security/supply-chain-incident-response.md');
|
|
const evidence = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md');
|
|
const operatorDashboard = readText(rootDir, 'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md');
|
|
|
|
return [
|
|
buildCheck(
|
|
'platform-audit-cli-surface',
|
|
packageScripts['platform:audit'] === 'node scripts/platform-audit.js'
|
|
&& packageScripts['discussion:audit'] === 'node scripts/discussion-audit.js'
|
|
? 'pass'
|
|
: 'fail',
|
|
'package.json exposes the platform and discussion audit commands',
|
|
{ fix: 'Add platform:audit and discussion:audit commands to package.json.' }
|
|
),
|
|
buildCheck(
|
|
'roadmap-linear-mirror',
|
|
includesAll(roadmap, ['linear.app/itomarkets/project/ecc-platform-roadmap', 'ITO-44', 'ITO-59']) ? 'pass' : 'fail',
|
|
'repo roadmap mirrors the Linear roadmap and security/operator lanes',
|
|
{ path: 'docs/ECC-2.0-GA-ROADMAP.md' }
|
|
),
|
|
buildCheck(
|
|
'progress-sync-contract',
|
|
includesAll(progressSync, ['GitHub PRs/issues/discussions', 'Linear project', 'local handoff', 'repo roadmap', 'scripts/work-items.js']) ? 'pass' : 'fail',
|
|
'progress sync contract names GitHub, Linear, handoff, roadmap, and work-items surfaces',
|
|
{ path: 'docs/architecture/progress-sync-contract.md' }
|
|
),
|
|
buildCheck(
|
|
'supply-chain-runbook',
|
|
includesAll(supplyChain, ['TanStack', 'Mini Shai-Hulud', 'node-ipc', 'scan-supply-chain-iocs.js']) ? 'pass' : 'fail',
|
|
'supply-chain runbook covers the current TanStack/Mini Shai-Hulud/node-ipc scanner lane',
|
|
{ path: 'docs/security/supply-chain-incident-response.md' }
|
|
),
|
|
buildCheck(
|
|
'release-evidence-current',
|
|
includesAll(evidence, ['TanStack', 'Mini Shai-Hulud', 'Node IPC follow-up', 'node-ipc', 'IOC scan']) ? 'pass' : 'fail',
|
|
'rc.1 evidence includes current supply-chain verification artifacts',
|
|
{ path: 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md' }
|
|
),
|
|
buildCheck(
|
|
'operator-readiness-dashboard',
|
|
includesAll(operatorDashboard, ['Prompt-To-Artifact Checklist', 'ITO-44', 'ITO-59', 'PR queue', 'Not complete']) ? 'pass' : 'fail',
|
|
'operator dashboard maps macro-goal requirements to current evidence and open gaps',
|
|
{ path: 'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md' }
|
|
),
|
|
];
|
|
}
|
|
|
|
function buildReport(options) {
|
|
const rootDir = path.resolve(options.root);
|
|
const git = inspectGit(rootDir, options);
|
|
const github = buildGithubReport(options);
|
|
const checks = [];
|
|
|
|
checks.push(buildCheck(
|
|
'git-worktree-blockers',
|
|
!git.available ? 'warn' : (git.blockingDirtyCount <= options.thresholds.maxDirtyFiles ? 'pass' : 'fail'),
|
|
!git.available
|
|
? 'git status is unavailable for this root'
|
|
: `blocking dirty files: ${git.blockingDirtyCount}`,
|
|
{
|
|
branch: git.branch,
|
|
ignoredDirtyCount: git.ignoredDirty.length,
|
|
blockingDirty: git.blockingDirty,
|
|
fix: 'Commit, stash, or explicitly allow unrelated untracked files before claiming release readiness.',
|
|
}
|
|
));
|
|
|
|
checks.push(buildCheck(
|
|
'github-fetch',
|
|
github.skipped ? 'warn' : (github.totals.errors === 0 ? 'pass' : 'fail'),
|
|
github.skipped ? 'live GitHub checks skipped' : `GitHub fetch errors: ${github.totals.errors}`,
|
|
{ fix: 'Re-run with working gh authentication or ECC_GH_SHIM for deterministic tests.' }
|
|
));
|
|
|
|
checks.push(buildCheck(
|
|
'github-open-pr-budget',
|
|
github.totals.openPrs <= options.thresholds.maxOpenPrs ? 'pass' : 'fail',
|
|
`open PRs: ${github.totals.openPrs}/${options.thresholds.maxOpenPrs}`,
|
|
{ fix: 'Triage, merge, close, or attach open PRs to roadmap issues until under budget.' }
|
|
));
|
|
|
|
checks.push(buildCheck(
|
|
'github-open-issue-budget',
|
|
github.totals.openIssues <= options.thresholds.maxOpenIssues ? 'pass' : 'fail',
|
|
`open issues: ${github.totals.openIssues}/${options.thresholds.maxOpenIssues}`,
|
|
{ fix: 'Triage, close, or attach open issues to Linear/project lanes until under budget.' }
|
|
));
|
|
|
|
checks.push(buildCheck(
|
|
'github-discussion-touch',
|
|
github.totals.discussionsNeedingMaintainerTouch === 0 ? 'pass' : 'fail',
|
|
`discussions needing maintainer touch: ${github.totals.discussionsNeedingMaintainerTouch}`,
|
|
{ fix: 'Respond to or route discussions without maintainer touch before marking the queue current.' }
|
|
));
|
|
|
|
checks.push(buildCheck(
|
|
'github-discussion-answers',
|
|
github.totals.discussionsMissingAcceptedAnswer === 0 ? 'pass' : 'fail',
|
|
`answerable discussions missing accepted answer: ${github.totals.discussionsMissingAcceptedAnswer}`,
|
|
{ fix: 'Mark an accepted answer or route Q&A discussions that still need resolution.' }
|
|
));
|
|
|
|
checks.push(buildCheck(
|
|
'github-conflict-queue',
|
|
github.totals.dirtyPrs === 0 ? 'pass' : 'fail',
|
|
`conflicting open PRs: ${github.totals.dirtyPrs}`,
|
|
{ fix: 'Update, rebase, salvage, or close conflicting open PRs.' }
|
|
));
|
|
|
|
checks.push(...buildLocalEvidenceChecks(rootDir));
|
|
|
|
const topActions = checks
|
|
.filter(check => check.status === 'fail')
|
|
.map(check => ({
|
|
id: check.id,
|
|
summary: check.summary,
|
|
fix: check.fix || 'Review and remediate this failed check.',
|
|
}));
|
|
|
|
return {
|
|
schema_version: SCHEMA_VERSION,
|
|
generatedAt: new Date().toISOString(),
|
|
root: rootDir,
|
|
ready: topActions.length === 0,
|
|
thresholds: options.thresholds,
|
|
git,
|
|
github,
|
|
checks,
|
|
top_actions: topActions,
|
|
};
|
|
}
|
|
|
|
function renderText(report) {
|
|
const lines = [
|
|
`ECC Platform Audit: ${report.ready ? 'ready' : 'attention required'}`,
|
|
`Generated: ${report.generatedAt}`,
|
|
`Root: ${report.root}`,
|
|
'',
|
|
`Git: ${report.git.available ? report.git.branch : 'unavailable'}`,
|
|
`Blocking dirty files: ${report.git.blockingDirtyCount}`,
|
|
`Ignored dirty files: ${report.git.ignoredDirty.length}`,
|
|
'',
|
|
`GitHub skipped: ${report.github.skipped ? 'yes' : 'no'}`,
|
|
`Open PRs: ${report.github.totals.openPrs}/${report.thresholds.maxOpenPrs}`,
|
|
`Open issues: ${report.github.totals.openIssues}/${report.thresholds.maxOpenIssues}`,
|
|
`Discussions needing maintainer touch: ${report.github.totals.discussionsNeedingMaintainerTouch}`,
|
|
`Answerable discussions missing accepted answer: ${report.github.totals.discussionsMissingAcceptedAnswer}`,
|
|
`Conflicting open PRs: ${report.github.totals.dirtyPrs}`,
|
|
'',
|
|
'Checks:',
|
|
];
|
|
|
|
for (const check of report.checks) {
|
|
lines.push(` ${check.status.toUpperCase()} ${check.id}: ${check.summary}`);
|
|
}
|
|
|
|
lines.push('', 'Top actions:');
|
|
if (report.top_actions.length === 0) {
|
|
lines.push(' none');
|
|
} else {
|
|
for (const action of report.top_actions) {
|
|
lines.push(` - ${action.id}: ${action.fix}`);
|
|
}
|
|
}
|
|
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
function markdownEscape(value) {
|
|
return String(value === undefined || value === null ? '' : value)
|
|
.replace(/\|/g, '\\|')
|
|
.replace(/\r?\n/g, '<br>');
|
|
}
|
|
|
|
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'} |`,
|
|
`| Answerable discussions missing accepted answer | ${report.github.totals.discussionsMissingAcceptedAnswer} | 0 | ${report.github.totals.discussionsMissingAcceptedAnswer === 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 | Missing answers | 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.discussions ? repo.discussions.answerableWithoutAcceptedAnswer.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);
|
|
if (options.help) {
|
|
usage();
|
|
return;
|
|
}
|
|
|
|
const report = buildReport(options);
|
|
const output = options.format === 'json'
|
|
? `${JSON.stringify(report, null, 2)}\n`
|
|
: options.format === 'markdown'
|
|
? renderMarkdown(report)
|
|
: renderText(report);
|
|
if (options.writePath) {
|
|
writeOutput(options.writePath, output);
|
|
}
|
|
process.stdout.write(output);
|
|
|
|
if (options.exitCode && !report.ready) {
|
|
process.exitCode = 2;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = {
|
|
buildReport,
|
|
parseArgs,
|
|
renderMarkdown,
|
|
renderText,
|
|
runGhJson,
|
|
};
|