From 50f375bc2c3a088cc029b29833f8b023826e630b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 15 May 2026 17:55:43 -0400 Subject: [PATCH] Add repeatable operator readiness dashboard --- package.json | 2 + scripts/operator-readiness-dashboard.js | 655 ++++++++++++++++++ scripts/platform-audit.js | 18 +- .../operator-readiness-dashboard.test.js | 250 +++++++ tests/scripts/platform-audit.test.js | 9 +- 5 files changed, 930 insertions(+), 4 deletions(-) create mode 100644 scripts/operator-readiness-dashboard.js create mode 100644 tests/scripts/operator-readiness-dashboard.test.js diff --git a/package.json b/package.json index a3df59dd..57db6a89 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "scripts/harness-adapter-compliance.js", "scripts/harness-audit.js", "scripts/observability-readiness.js", + "scripts/operator-readiness-dashboard.js", "scripts/platform-audit.js", "scripts/hooks/", "scripts/install-apply.js", @@ -296,6 +297,7 @@ "harness:adapters": "node scripts/harness-adapter-compliance.js", "harness:audit": "node scripts/harness-audit.js", "observability:ready": "node scripts/observability-readiness.js", + "operator:dashboard": "node scripts/operator-readiness-dashboard.js", "platform:audit": "node scripts/platform-audit.js", "discussion:audit": "node scripts/discussion-audit.js", "security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js", diff --git a/scripts/operator-readiness-dashboard.js b/scripts/operator-readiness-dashboard.js new file mode 100644 index 00000000..28c15234 --- /dev/null +++ b/scripts/operator-readiness-dashboard.js @@ -0,0 +1,655 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { buildReport: buildPlatformReport } = require('./platform-audit'); + +const SCHEMA_VERSION = 'ecc.operator-readiness-dashboard.v1'; +const DEFAULT_THRESHOLDS = Object.freeze({ + maxOpenPrs: 20, + maxOpenIssues: 20, + maxDirtyFiles: 0, +}); + +function usage() { + console.log([ + 'Usage: node scripts/operator-readiness-dashboard.js [options]', + '', + 'Generate the ECC operator readiness dashboard and prompt-to-artifact audit.', + '', + 'Options:', + ' --format ', + ' Output format (default: markdown)', + ' --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', + ' --max-open-prs PR budget passed through to platform:audit', + ' --max-open-issues Issue budget passed through to platform:audit', + ' --max-dirty-files Dirty-file budget passed through to platform:audit', + ' --allow-untracked Ignore untracked files under path; repeatable', + ' --use-env-github-token Keep GITHUB_TOKEN when invoking gh', + ' --generated-at Override generatedAt for deterministic tests', + ' --exit-code Return 2 when the objective 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 normalizeRelativePrefix(value) { + const normalized = String(value || '') + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/\/+$/, ''); + return normalized ? `${normalized}/` : ''; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + allowUntracked: [], + exitCode: false, + format: 'markdown', + generatedAt: null, + 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 === '--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 === '--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 === '--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 === '--generated-at') { + parsed.generatedAt = readValue(args, index, arg); + index += 1; + continue; + } + + if (arg.startsWith('--generated-at=')) { + parsed.generatedAt = arg.slice('--generated-at='.length); + 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).filter(Boolean); + + return parsed; +} + +function readText(rootDir, relativePath) { + try { + return fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); + } catch (_error) { + return ''; + } +} + +function fileExists(rootDir, relativePath) { + return fs.existsSync(path.join(rootDir, relativePath)); +} + +function includesAll(text, needles) { + return needles.every(needle => text.includes(needle)); +} + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error || result.status !== 0) { + return null; + } + + return (result.stdout || '').trim(); +} + +function readPackage(rootDir) { + const text = readText(rootDir, 'package.json'); + if (!text.trim()) { + return {}; + } + + try { + return JSON.parse(text); + } catch (_error) { + return {}; + } +} + +function buildRequirement(id, requirement, artifact, status, evidence, gap) { + return { id, requirement, artifact, status, evidence, gap }; +} + +function isCurrentOrComplete(status) { + return status === 'current' || status === 'complete'; +} + +function buildRequirements(rootDir, platformReport) { + const roadmap = readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md'); + const publicationReadiness = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-readiness.md'); + const namingMatrix = readText(rootDir, 'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md'); + const previewManifest = readText(rootDir, 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md'); + const progressSync = readText(rootDir, 'docs/architecture/progress-sync-contract.md'); + const observabilityReadiness = readText(rootDir, 'docs/architecture/observability-readiness.md'); + const stalePrSalvage = readText(rootDir, 'docs/stale-pr-salvage-ledger.md'); + const supplyChainRunbook = readText(rootDir, 'docs/security/supply-chain-incident-response.md'); + const packageJson = readPackage(rootDir); + const scripts = packageJson.scripts || {}; + + const githubLive = !platformReport.github.skipped && platformReport.github.totals.errors === 0; + const queuesCurrent = githubLive + && platformReport.github.totals.openPrs <= platformReport.thresholds.maxOpenPrs + && platformReport.github.totals.openIssues <= platformReport.thresholds.maxOpenIssues; + const discussionsCurrent = githubLive + && platformReport.github.totals.discussionsNeedingMaintainerTouch === 0 + && platformReport.github.totals.discussionsMissingAcceptedAnswer === 0; + + return [ + buildRequirement( + 'public-pr-budget', + 'Keep public PRs below 20', + 'scripts/platform-audit.js live GitHub sweep', + queuesCurrent ? 'current' : 'in_progress', + githubLive + ? `${platformReport.github.totals.openPrs} open PRs across ${platformReport.github.repos.length} tracked repos` + : 'live GitHub queue readback was skipped or failed', + queuesCurrent ? 'repeat before release' : 'run live platform:audit and drain PR queue' + ), + buildRequirement( + 'public-issue-budget', + 'Keep public issues below 20', + 'scripts/platform-audit.js live GitHub sweep', + queuesCurrent ? 'current' : 'in_progress', + githubLive + ? `${platformReport.github.totals.openIssues} open issues across ${platformReport.github.repos.length} tracked repos` + : 'live GitHub queue readback was skipped or failed', + queuesCurrent ? 'repeat before release' : 'run live platform:audit and drain issue queue' + ), + buildRequirement( + 'repository-discussions', + 'Respond and manage repository discussions', + 'scripts/platform-audit.js discussion summary', + discussionsCurrent ? 'current' : 'in_progress', + githubLive + ? `${platformReport.github.totals.discussionsNeedingMaintainerTouch} need maintainer touch; ${platformReport.github.totals.discussionsMissingAcceptedAnswer} answerable discussions missing accepted answer` + : 'live discussion readback was skipped or failed', + discussionsCurrent ? 'repeat before release' : 'respond, answer, or route remaining discussions' + ), + buildRequirement( + 'completion-dashboard', + 'Build ITO-44 completion dashboard into a repeatable command', + 'npm run operator:dashboard', + scripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js' + && fileExists(rootDir, 'scripts/operator-readiness-dashboard.js') + ? 'complete' + : 'in_progress', + scripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js' + ? 'operator:dashboard package script exists' + : 'operator:dashboard package script missing', + 'keep generated dashboard attached to publication evidence' + ), + buildRequirement( + 'ecc-preview-pack', + 'ECC 2.0 preview pack ready', + 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md', + includesAll(previewManifest, ['publication-readiness.md', 'release-notes.md', 'quickstart.md']) ? 'in_progress' : 'not_complete', + includesAll(previewManifest, ['publication-readiness.md', 'release-notes.md', 'quickstart.md']) + ? 'preview pack manifest is in-tree' + : 'preview pack manifest is incomplete', + 'final clean-checkout release approval and publish evidence still pending' + ), + buildRequirement( + 'hermes-specialized-skills', + 'Include Hermes specialized skills safely', + 'docs/HERMES-SETUP.md and skills/hermes-imports/SKILL.md', + fileExists(rootDir, 'docs/HERMES-SETUP.md') && fileExists(rootDir, 'skills/hermes-imports/SKILL.md') + ? 'in_progress' + : 'not_complete', + fileExists(rootDir, 'docs/HERMES-SETUP.md') && fileExists(rootDir, 'skills/hermes-imports/SKILL.md') + ? 'Hermes setup and import skill are present' + : 'Hermes setup/import artifacts missing', + 'final preview-pack smoke and release review pending' + ), + buildRequirement( + 'naming-and-plugin-publication', + 'Prepare name-change, Claude plugin, and Codex plugin paths', + 'naming-and-publication-matrix plus publication-readiness', + includesAll(namingMatrix, ['Claude plugin', 'Codex plugin', 'npm package', 'Publication Paths']) + && includesAll(publicationReadiness, ['Claude plugin', 'Codex plugin']) + ? 'in_progress' + : 'not_complete', + 'naming matrix and plugin readiness gates exist', + 'real tag/push, marketplace submission, and final channel choice remain approval-gated' + ), + buildRequirement( + 'release-notes-and-notifications', + 'Prepare release notes, articles, tweets, and push notifications', + 'docs/releases/2.0.0-rc.1 social and release-copy files', + fileExists(rootDir, 'docs/releases/2.0.0-rc.1/release-notes.md') + && fileExists(rootDir, 'docs/releases/2.0.0-rc.1/x-thread.md') + && fileExists(rootDir, 'docs/releases/2.0.0-rc.1/linkedin-post.md') + ? 'in_progress' + : 'not_complete', + 'release notes, X thread, and LinkedIn draft are present', + 'URL-backed refresh and publish approval still pending' + ), + buildRequirement( + 'agentshield-enterprise-iteration', + 'Advance AgentShield enterprise iteration', + 'AgentShield PR evidence plus enterprise roadmap', + roadmap.includes('AgentShield Enterprise Iteration') && roadmap.includes('#78-#86') + ? 'in_progress' + : 'not_complete', + 'AgentShield enterprise PR evidence is mirrored in the GA roadmap', + 'live IOC update loop and cross-harness depth remain pending' + ), + buildRequirement( + 'ecc-tools-next-level', + 'Advance ECC Tools native payments and AI-native harness-agnostic app', + 'ECC Tools PR evidence, billing gate, hosted analysis lanes', + includesAll(roadmap, ['ECC-Tools PR #73', 'hosted promotion', 'announcementGate']) + ? 'in_progress' + : 'not_complete', + 'billing announcement gate and hosted analysis lanes are mirrored in the GA roadmap', + 'live Marketplace test-account readback, hosted promotion telemetry, and operator review UX pending' + ), + buildRequirement( + 'legacy-salvage', + 'Audit, prune, or attach legacy work', + 'docs/stale-pr-salvage-ledger.md and legacy inventory', + stalePrSalvage.includes('Manual review tail') || roadmap.includes('ITO-55') + ? 'in_progress' + : 'not_complete', + 'legacy salvage ledger and ITO-55 tracking are present', + 'final translation/manual-review tail remains' + ), + buildRequirement( + 'linear-roadmap-and-progress', + 'Keep Linear roadmap detailed and progress tracking synchronized', + 'Linear project mirror plus progress-sync contract', + includesAll(roadmap, ['ITO-44', 'ITO-59', 'Linear']) && includesAll(progressSync, ['GitHub', 'Linear', 'handoff', 'repo roadmap']) + ? 'in_progress' + : 'not_complete', + 'repo mirror and progress-sync contract are present', + 'recurring Linear status sync and productized realtime sync remain pending' + ), + buildRequirement( + 'observability-for-self-use', + 'Provide ECC 2.0 observability for self-use', + 'observability readiness gate', + scripts['observability:ready'] === 'node scripts/observability-readiness.js' + && includesAll(observabilityReadiness, ['observability-readiness.js']) + ? 'complete' + : 'in_progress', + scripts['observability:ready'] === 'node scripts/observability-readiness.js' + ? 'observability:ready command and readiness doc exist' + : 'observability readiness command missing', + 'runtime/dashboard implementation can continue after release gates' + ), + buildRequirement( + 'supply-chain-local-protection', + 'Keep Mini Shai-Hulud/TanStack protection loop current', + 'supply-chain watch plus runbook', + includesAll(supplyChainRunbook, ['TanStack', 'Mini Shai-Hulud', 'scan-supply-chain-iocs.js']) + && fileExists(rootDir, '.github/workflows/supply-chain-watch.yml') + ? 'current' + : 'in_progress', + 'scheduled supply-chain watch and runbook are present', + 'advisory-source refresh automation and Linear status synchronization remain ITO-57 follow-up' + ), + ]; +} + +function buildReport(options) { + const rootDir = path.resolve(options.root); + const generatedAt = options.generatedAt || new Date().toISOString(); + const platformReport = buildPlatformReport({ + allowUntracked: options.allowUntracked, + exitCode: false, + format: 'json', + help: false, + repos: options.repos, + root: rootDir, + skipGithub: options.skipGithub, + thresholds: options.thresholds, + useEnvGithubToken: options.useEnvGithubToken, + writePath: null, + }); + const requirements = buildRequirements(rootDir, platformReport); + const incompleteRequirements = requirements.filter(item => !isCurrentOrComplete(item.status)); + const topActions = incompleteRequirements.map(item => ({ + id: item.id, + summary: item.requirement, + fix: item.gap, + })); + const head = runCommand('git', ['rev-parse', 'HEAD'], { cwd: rootDir }); + + return { + schema_version: SCHEMA_VERSION, + generatedAt, + root: rootDir, + head, + ready: incompleteRequirements.length === 0, + dashboardReady: platformReport.ready, + publicationReady: false, + platform: { + ready: platformReport.ready, + branch: platformReport.git.branch, + blockingDirtyCount: platformReport.git.blockingDirtyCount, + ignoredDirtyCount: platformReport.git.ignoredDirty.length, + openPrs: platformReport.github.totals.openPrs, + openIssues: platformReport.github.totals.openIssues, + discussionsNeedingMaintainerTouch: platformReport.github.totals.discussionsNeedingMaintainerTouch, + discussionsMissingAcceptedAnswer: platformReport.github.totals.discussionsMissingAcceptedAnswer, + githubErrors: platformReport.github.totals.errors, + githubSkipped: platformReport.github.skipped, + }, + requirements, + top_actions: topActions, + next_work_order: [ + 'Regenerate this dashboard from the final release commit before publication evidence is recorded.', + 'Continue ITO-57 with advisory-source refresh automation and Linear status synchronization for the scheduled supply-chain watch.', + 'Advance ECC Tools live Marketplace test-account readback before publishing native-payments announcement copy.', + 'Resume ITO-45, ITO-46, and ITO-56 only after the generated dashboard and final release gates are refreshed.', + ], + }; +} + +function markdownEscape(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, '
'); +} + +function renderText(report) { + const lines = [ + `ECC Operator Readiness Dashboard: ${report.ready ? 'objective ready' : 'work remaining'}`, + `Generated: ${report.generatedAt}`, + `Commit: ${report.head || 'unknown'}`, + `Dashboard ready: ${report.dashboardReady}`, + `Publication ready: ${report.publicationReady}`, + '', + 'Platform:', + ` PRs: ${report.platform.openPrs}`, + ` Issues: ${report.platform.openIssues}`, + ` Discussions needing touch: ${report.platform.discussionsNeedingMaintainerTouch}`, + ` Missing accepted answers: ${report.platform.discussionsMissingAcceptedAnswer}`, + ` Blocking dirty files: ${report.platform.blockingDirtyCount}`, + '', + 'Requirements:', + ]; + + for (const item of report.requirements) { + lines.push(` ${item.status.toUpperCase()} ${item.id}: ${item.requirement}`); + } + + 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 renderMarkdown(report) { + const lines = [ + '# ECC Operator Readiness Dashboard', + '', + 'This dashboard is generated by `npm run operator:dashboard`. It is an operator snapshot, not release approval.', + '', + `Generated: ${report.generatedAt}`, + `Commit: ${report.head || 'unknown'}`, + `Status: ${report.ready ? 'objective ready' : 'work remaining'}`, + '', + '## Current Status', + '', + '| Area | Status | Evidence |', + '| --- | --- | --- |', + `| PR queue | ${report.platform.openPrs < 20 && !report.platform.githubSkipped ? 'Current' : 'Needs work'} | ${report.platform.openPrs} open PRs across tracked repos |`, + `| Issue queue | ${report.platform.openIssues < 20 && !report.platform.githubSkipped ? 'Current' : 'Needs work'} | ${report.platform.openIssues} open issues across tracked repos |`, + `| Discussions | ${report.platform.discussionsNeedingMaintainerTouch === 0 && report.platform.discussionsMissingAcceptedAnswer === 0 && !report.platform.githubSkipped ? 'Current' : 'Needs work'} | ${report.platform.discussionsNeedingMaintainerTouch} need maintainer touch; ${report.platform.discussionsMissingAcceptedAnswer} missing accepted answer |`, + `| Local worktree | ${report.platform.blockingDirtyCount === 0 ? 'Current' : 'Needs work'} | ${report.platform.blockingDirtyCount} blocking dirty files; ${report.platform.ignoredDirtyCount} ignored dirty entries |`, + `| Dashboard generation | ${report.dashboardReady ? 'Current' : 'Needs work'} | platform audit ready: ${report.platform.ready}; GitHub skipped: ${report.platform.githubSkipped} |`, + `| Publication | ${report.publicationReady ? 'Ready' : 'Not complete'} | release, npm, plugin, billing, and announcement gates are tracked below |`, + '', + '## Prompt-To-Artifact Checklist', + '', + '| Objective requirement | Artifact or gate | Status | Evidence | Gap |', + '| --- | --- | --- | --- | --- |', + ]; + + for (const item of report.requirements) { + lines.push(`| ${markdownEscape(item.requirement)} | ${markdownEscape(item.artifact)} | ${markdownEscape(item.status)} | ${markdownEscape(item.evidence)} | ${markdownEscape(item.gap)} |`); + } + + 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('', '## Next Work Order', ''); + report.next_work_order.forEach((item, index) => { + lines.push(`${index + 1}. ${item}`); + }); + + return `${lines.join('\n')}\n`; +} + +function renderReport(report, format) { + if (format === 'json') { + return `${JSON.stringify(report, null, 2)}\n`; + } + + if (format === 'text') { + return renderText(report); + } + + return renderMarkdown(report); +} + +function writeOutput(writePath, output) { + fs.mkdirSync(path.dirname(writePath), { recursive: true }); + fs.writeFileSync(writePath, output, 'utf8'); +} + +function main() { + let options; + try { + options = parseArgs(process.argv); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } + + if (options.help) { + usage(); + return; + } + + const report = buildReport(options); + const output = renderReport(report, options.format); + + if (options.writePath) { + writeOutput(options.writePath, output); + } + + process.stdout.write(output); + + if (options.exitCode && !report.ready) { + process.exit(2); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildReport, + parseArgs, + renderMarkdown, + renderReport, + renderText, +}; diff --git a/scripts/platform-audit.js b/scripts/platform-audit.js index 5a3e9ca7..6603d263 100644 --- a/scripts/platform-audit.js +++ b/scripts/platform-audit.js @@ -268,6 +268,10 @@ function readText(rootDir, relativePath) { } } +function fileExists(rootDir, relativePath) { + return fs.existsSync(path.join(rootDir, relativePath)); +} + function safeParseJson(text) { if (!text || !text.trim()) { return null; @@ -431,10 +435,20 @@ function buildLocalEvidenceChecks(rootDir) { 'platform-audit-cli-surface', packageScripts['platform:audit'] === 'node scripts/platform-audit.js' && packageScripts['discussion:audit'] === 'node scripts/discussion-audit.js' + && packageScripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js' ? 'pass' : 'fail', - 'package.json exposes the platform and discussion audit commands', - { fix: 'Add platform:audit and discussion:audit commands to package.json.' } + 'package.json exposes platform, discussion, and operator dashboard audit commands', + { fix: 'Add platform:audit, discussion:audit, and operator:dashboard commands to package.json.' } + ), + buildCheck( + 'operator-dashboard-command', + fileExists(rootDir, 'scripts/operator-readiness-dashboard.js') + && packageScripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js' + ? 'pass' + : 'fail', + 'operator dashboard is generated by the repeatable ITO-44 command', + { path: 'scripts/operator-readiness-dashboard.js' } ), buildCheck( 'roadmap-linear-mirror', diff --git a/tests/scripts/operator-readiness-dashboard.test.js b/tests/scripts/operator-readiness-dashboard.test.js new file mode 100644 index 00000000..d9e9ebb7 --- /dev/null +++ b/tests/scripts/operator-readiness-dashboard.test.js @@ -0,0 +1,250 @@ +/** + * Tests for scripts/operator-readiness-dashboard.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'operator-readiness-dashboard.js'); +const { buildReport, parseArgs } = require(SCRIPT); + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function writeFile(rootDir, relativePath, content) { + const targetPath = path.join(rootDir, relativePath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content); +} + +function seedRepo(rootDir, overrides = {}) { + const files = { + 'package.json': JSON.stringify({ + name: 'everything-claude-code', + files: [ + 'scripts/observability-readiness.js', + 'scripts/operator-readiness-dashboard.js', + 'scripts/platform-audit.js' + ], + scripts: { + 'discussion:audit': 'node scripts/discussion-audit.js', + 'observability:ready': 'node scripts/observability-readiness.js', + 'operator:dashboard': 'node scripts/operator-readiness-dashboard.js', + 'platform:audit': 'node scripts/platform-audit.js', + 'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js' + } + }, null, 2), + 'scripts/operator-readiness-dashboard.js': 'operator dashboard generator', + 'docs/ECC-2.0-GA-ROADMAP.md': [ + 'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1', + 'Linear ITO-44 ITO-59', + 'AgentShield PR #86 #78-#86', + 'AgentShield Enterprise Iteration', + 'ECC-Tools PR #73', + 'hosted promotion', + 'announcementGate', + 'ITO-55' + ].join('\n'), + 'docs/releases/2.0.0-rc.1/publication-readiness.md': 'Claude plugin Codex plugin', + 'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md': 'Claude plugin Codex plugin npm package Publication Paths', + 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md': 'publication-readiness.md release-notes.md quickstart.md', + 'docs/releases/2.0.0-rc.1/release-notes.md': 'release notes', + 'docs/releases/2.0.0-rc.1/x-thread.md': 'x thread', + 'docs/releases/2.0.0-rc.1/linkedin-post.md': 'linkedin post', + 'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md': [ + 'operator:dashboard', + 'Prompt-To-Artifact Checklist', + 'Next Work Order', + 'ITO-44', + 'ITO-59', + 'PR queue', + 'Not complete' + ].join('\n'), + 'docs/HERMES-SETUP.md': 'Hermes setup', + 'skills/hermes-imports/SKILL.md': 'Hermes imports', + 'docs/stale-pr-salvage-ledger.md': 'Manual review tail', + 'docs/architecture/progress-sync-contract.md': 'GitHub PRs/issues/discussions Linear project local handoff repo roadmap scripts/work-items.js', + 'docs/architecture/observability-readiness.md': 'observability-readiness.js', + 'docs/security/supply-chain-incident-response.md': 'TanStack Mini Shai-Hulud node-ipc scan-supply-chain-iocs.js', + 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md': 'TanStack Mini Shai-Hulud Node IPC follow-up node-ipc IOC scan', + '.github/workflows/supply-chain-watch.yml': 'name: Supply-Chain Watch' + }; + + for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) { + if (content === null) { + continue; + } + writeFile(rootDir, relativePath, content); + } +} + +function run(args = [], options = {}) { + return execFileSync('node', [SCRIPT, ...args], { + cwd: options.cwd || path.join(__dirname, '..', '..'), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); +} + +function runProcess(args = [], options = {}) { + return spawnSync('node', [SCRIPT, ...args], { + cwd: options.cwd || path.join(__dirname, '..', '..'), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); +} + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing operator-readiness-dashboard.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('parseArgs accepts dashboard flags and rejects invalid values', () => { + const rootDir = createTempDir('operator-dashboard-args-'); + + try { + const parsed = parseArgs([ + 'node', + 'script', + '--format=json', + `--root=${rootDir}`, + '--skip-github', + '--allow-untracked', + 'docs/drafts/', + '--repo', + 'affaan-m/everything-claude-code', + '--generated-at', + '2026-05-15T00:00:00.000Z' + ]); + + assert.strictEqual(parsed.format, 'json'); + assert.strictEqual(parsed.root, path.resolve(rootDir)); + assert.strictEqual(parsed.skipGithub, true); + assert.deepStrictEqual(parsed.allowUntracked, ['docs/drafts/']); + assert.deepStrictEqual(parsed.repos, ['affaan-m/everything-claude-code']); + assert.strictEqual(parsed.generatedAt, '2026-05-15T00:00:00.000Z'); + + assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/); + assert.throws(() => parseArgs(['node', 'script', '--write', 'dashboard.md', '--format', 'text']), /--write requires/); + assert.throws(() => parseArgs(['node', 'script', '--max-open-prs', 'x']), /Invalid --max-open-prs/); + assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (test('seeded repo emits an objective audit with remaining work', () => { + const rootDir = createTempDir('operator-dashboard-report-'); + + try { + seedRepo(rootDir); + const report = buildReport({ + allowUntracked: [], + exitCode: false, + format: 'json', + generatedAt: '2026-05-15T00:00:00.000Z', + help: false, + repos: [], + root: rootDir, + skipGithub: true, + thresholds: { maxOpenPrs: 20, maxOpenIssues: 20, maxDirtyFiles: 0 }, + useEnvGithubToken: false, + writePath: null + }); + + assert.strictEqual(report.schema_version, 'ecc.operator-readiness-dashboard.v1'); + assert.strictEqual(report.generatedAt, '2026-05-15T00:00:00.000Z'); + assert.strictEqual(report.dashboardReady, true); + assert.strictEqual(report.ready, false); + assert.strictEqual(report.publicationReady, false); + assert.ok(report.requirements.some(item => item.id === 'completion-dashboard' && item.status === 'complete')); + assert.ok(report.requirements.some(item => item.id === 'ecc-tools-next-level' && item.status === 'in_progress')); + assert.ok(report.top_actions.some(item => item.id === 'naming-and-plugin-publication')); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (test('markdown output can be written as the dashboard artifact', () => { + const rootDir = createTempDir('operator-dashboard-markdown-'); + const outputPath = path.join(rootDir, 'artifacts', 'dashboard.md'); + + try { + seedRepo(rootDir); + const stdout = run([ + '--markdown', + '--skip-github', + `--root=${rootDir}`, + '--generated-at=2026-05-15T00:00:00.000Z', + '--write', + outputPath + ], { cwd: rootDir }); + const written = fs.readFileSync(outputPath, 'utf8'); + + assert.strictEqual(stdout, written); + assert.ok(written.includes('# ECC Operator Readiness Dashboard')); + assert.ok(written.includes('Generated: 2026-05-15T00:00:00.000Z')); + assert.ok(written.includes('## Prompt-To-Artifact Checklist')); + assert.ok(written.includes('Build ITO-44 completion dashboard into a repeatable command')); + assert.ok(written.includes('## Next Work Order')); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + if (test('exit-code mode fails closed while macro objective has gaps', () => { + const rootDir = createTempDir('operator-dashboard-exit-'); + + try { + seedRepo(rootDir); + const result = runProcess([ + '--json', + '--skip-github', + `--root=${rootDir}`, + '--generated-at=2026-05-15T00:00:00.000Z', + '--exit-code' + ], { cwd: rootDir }); + + assert.strictEqual(result.status, 2); + assert.strictEqual(result.stderr, ''); + assert.ok(result.stdout.includes('"ready": false')); + assert.ok(result.stdout.includes('"publicationReady": false')); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + + if (failed > 0) { + process.exit(1); + } +} + +if (require.main === module) { + runTests(); +} diff --git a/tests/scripts/platform-audit.test.js b/tests/scripts/platform-audit.test.js index 350b7cd6..41b416f1 100644 --- a/tests/scripts/platform-audit.test.js +++ b/tests/scripts/platform-audit.test.js @@ -32,6 +32,7 @@ function seedRepo(rootDir, overrides = {}) { scripts: { 'platform:audit': 'node scripts/platform-audit.js', 'discussion:audit': 'node scripts/discussion-audit.js', + 'operator:dashboard': 'node scripts/operator-readiness-dashboard.js', 'observability:ready': 'node scripts/observability-readiness.js', 'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js', 'harness:audit': 'node scripts/harness-audit.js' @@ -68,8 +69,11 @@ function seedRepo(rootDir, overrides = {}) { 'ITO-44', 'ITO-59', 'PR queue', - 'Not complete' - ].join('\n') + 'Not complete', + 'operator:dashboard', + 'Next Work Order' + ].join('\n'), + 'scripts/operator-readiness-dashboard.js': 'operator dashboard generator' }; for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) { @@ -201,6 +205,7 @@ function runTests() { assert.strictEqual(parsed.github.skipped, true); assert.ok(parsed.checks.some(check => check.id === 'roadmap-linear-mirror' && check.status === 'pass')); assert.ok(parsed.checks.some(check => check.id === 'supply-chain-runbook' && check.status === 'pass')); + assert.ok(parsed.checks.some(check => check.id === 'operator-dashboard-command' && check.status === 'pass')); assert.ok(parsed.checks.some(check => check.id === 'operator-readiness-dashboard' && check.status === 'pass')); assert.deepStrictEqual(parsed.top_actions, []); } finally {