mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-18 21:31:15 +08:00
feat: add platform and supply-chain audit commands (#1926)
This commit is contained in:
parent
ee85e1482e
commit
13585f1092
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@ -29,6 +29,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci --ignore-scripts
|
||||||
|
|
||||||
|
- name: Run supply-chain IOC scan
|
||||||
|
run: npm run security:ioc-scan
|
||||||
|
|
||||||
- name: Verify OpenCode package payload
|
- name: Verify OpenCode package payload
|
||||||
run: node tests/scripts/build-opencode.test.js
|
run: node tests/scripts/build-opencode.test.js
|
||||||
|
|
||||||
|
|||||||
3
.github/workflows/reusable-release.yml
vendored
3
.github/workflows/reusable-release.yml
vendored
@ -53,6 +53,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci --ignore-scripts
|
||||||
|
|
||||||
|
- name: Run supply-chain IOC scan
|
||||||
|
run: npm run security:ioc-scan
|
||||||
|
|
||||||
- name: Verify OpenCode package payload
|
- name: Verify OpenCode package payload
|
||||||
run: node tests/scripts/build-opencode.test.js
|
run: node tests/scripts/build-opencode.test.js
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,7 @@
|
|||||||
"rules/",
|
"rules/",
|
||||||
"schemas/",
|
"schemas/",
|
||||||
"scripts/catalog.js",
|
"scripts/catalog.js",
|
||||||
|
"scripts/ci/scan-supply-chain-iocs.js",
|
||||||
"scripts/consult.js",
|
"scripts/consult.js",
|
||||||
"scripts/auto-update.js",
|
"scripts/auto-update.js",
|
||||||
"scripts/claw.js",
|
"scripts/claw.js",
|
||||||
@ -74,6 +75,7 @@
|
|||||||
"scripts/harness-adapter-compliance.js",
|
"scripts/harness-adapter-compliance.js",
|
||||||
"scripts/harness-audit.js",
|
"scripts/harness-audit.js",
|
||||||
"scripts/observability-readiness.js",
|
"scripts/observability-readiness.js",
|
||||||
|
"scripts/platform-audit.js",
|
||||||
"scripts/hooks/",
|
"scripts/hooks/",
|
||||||
"scripts/install-apply.js",
|
"scripts/install-apply.js",
|
||||||
"scripts/install-plan.js",
|
"scripts/install-plan.js",
|
||||||
@ -293,6 +295,7 @@
|
|||||||
"harness:adapters": "node scripts/harness-adapter-compliance.js",
|
"harness:adapters": "node scripts/harness-adapter-compliance.js",
|
||||||
"harness:audit": "node scripts/harness-audit.js",
|
"harness:audit": "node scripts/harness-audit.js",
|
||||||
"observability:ready": "node scripts/observability-readiness.js",
|
"observability:ready": "node scripts/observability-readiness.js",
|
||||||
|
"platform:audit": "node scripts/platform-audit.js",
|
||||||
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
|
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
|
||||||
"claw": "node scripts/claw.js",
|
"claw": "node scripts/claw.js",
|
||||||
"orchestrate:status": "node scripts/orchestration-status.js",
|
"orchestrate:status": "node scripts/orchestration-status.js",
|
||||||
|
|||||||
@ -253,7 +253,6 @@ const CRITICAL_TEXT_INDICATORS = [
|
|||||||
'seed2.getsession.org',
|
'seed2.getsession.org',
|
||||||
'seed3.getsession.org',
|
'seed3.getsession.org',
|
||||||
'signalservice',
|
'signalservice',
|
||||||
'snode',
|
|
||||||
'git-tanstack.com',
|
'git-tanstack.com',
|
||||||
'litter.catbox.moe/h8nc9u.js',
|
'litter.catbox.moe/h8nc9u.js',
|
||||||
'litter.catbox.moe/7rrc6l.mjs',
|
'litter.catbox.moe/7rrc6l.mjs',
|
||||||
@ -620,7 +619,9 @@ function parseArgs(argv) {
|
|||||||
const options = {};
|
const options = {};
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
if (arg === '--root') {
|
if (arg === '--help' || arg === '-h') {
|
||||||
|
options.help = true;
|
||||||
|
} else if (arg === '--root') {
|
||||||
options.rootDir = argv[++i];
|
options.rootDir = argv[++i];
|
||||||
} else if (arg === '--home') {
|
} else if (arg === '--home') {
|
||||||
options.home = true;
|
options.home = true;
|
||||||
@ -636,6 +637,26 @@ function parseArgs(argv) {
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`Usage: node scripts/ci/scan-supply-chain-iocs.js [options]
|
||||||
|
|
||||||
|
Scan dependency manifests, lockfiles, installed package payloads, and AI-tool
|
||||||
|
persistence paths for active supply-chain IOC markers.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--root <dir> Directory to scan (default: repo root)
|
||||||
|
--home Also scan user-level Claude, VS Code, LaunchAgent, systemd,
|
||||||
|
and /tmp persistence targets
|
||||||
|
--home-dir <dir> Home directory to use with --home
|
||||||
|
--json Emit JSON instead of text
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
node scripts/ci/scan-supply-chain-iocs.js --home
|
||||||
|
node scripts/ci/scan-supply-chain-iocs.js --root /path/to/project --json
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
function printReport(result, json = false) {
|
function printReport(result, json = false) {
|
||||||
if (json) {
|
if (json) {
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
@ -658,6 +679,10 @@ function printReport(result, json = false) {
|
|||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
try {
|
try {
|
||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
if (options.help) {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
const result = scanSupplyChainIocs(options);
|
const result = scanSupplyChainIocs(options);
|
||||||
printReport(result, options.json);
|
printReport(result, options.json);
|
||||||
process.exit(result.findings.length > 0 ? 1 : 0);
|
process.exit(result.findings.length > 0 ? 1 : 0);
|
||||||
|
|||||||
@ -45,6 +45,14 @@ const COMMANDS = {
|
|||||||
script: 'status.js',
|
script: 'status.js',
|
||||||
description: 'Query the ECC SQLite state store status summary',
|
description: 'Query the ECC SQLite state store status summary',
|
||||||
},
|
},
|
||||||
|
'platform-audit': {
|
||||||
|
script: 'platform-audit.js',
|
||||||
|
description: 'Audit GitHub queues, discussions, roadmap, release, and security evidence',
|
||||||
|
},
|
||||||
|
'security-ioc-scan': {
|
||||||
|
script: 'ci/scan-supply-chain-iocs.js',
|
||||||
|
description: 'Scan dependency and AI-tool persistence surfaces for active supply-chain IOCs',
|
||||||
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
script: 'sessions-cli.js',
|
script: 'sessions-cli.js',
|
||||||
description: 'List or inspect ECC sessions from the SQLite state store',
|
description: 'List or inspect ECC sessions from the SQLite state store',
|
||||||
@ -77,6 +85,8 @@ const PRIMARY_COMMANDS = [
|
|||||||
'repair',
|
'repair',
|
||||||
'auto-update',
|
'auto-update',
|
||||||
'status',
|
'status',
|
||||||
|
'platform-audit',
|
||||||
|
'security-ioc-scan',
|
||||||
'sessions',
|
'sessions',
|
||||||
'work-items',
|
'work-items',
|
||||||
'session-inspect',
|
'session-inspect',
|
||||||
@ -115,6 +125,8 @@ Examples:
|
|||||||
ecc status --json
|
ecc status --json
|
||||||
ecc status --exit-code
|
ecc status --exit-code
|
||||||
ecc status --markdown --write status.md
|
ecc status --markdown --write status.md
|
||||||
|
ecc platform-audit --json --allow-untracked docs/drafts/
|
||||||
|
ecc security-ioc-scan --home
|
||||||
ecc sessions
|
ecc sessions
|
||||||
ecc sessions session-active --json
|
ecc sessions session-active --json
|
||||||
ecc work-items upsert linear-ecc-20 --source linear --source-id ECC-20 --title "Review control-plane contract" --status blocked
|
ecc work-items upsert linear-ecc-20 --source linear --source-id ECC-20 --title "Review control-plane contract" --status blocked
|
||||||
|
|||||||
630
scripts/platform-audit.js
Normal file
630
scripts/platform-audit.js
Normal file
@ -0,0 +1,630 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||||
|
const DISCUSSION_QUERY = 'query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }';
|
||||||
|
|
||||||
|
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> Output format (default: text)',
|
||||||
|
' --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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 === '--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 === '--exit-code') {
|
||||||
|
parsed.exitCode = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['text', 'json'].includes(parsed.format)) {
|
||||||
|
throw new Error(`Invalid format: ${parsed.format}. Use text or json.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 discussionNeedsMaintainerTouch(discussion) {
|
||||||
|
if (MAINTAINER_ASSOCIATIONS.has(discussion.authorAssociation)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = discussion.comments && Array.isArray(discussion.comments.nodes)
|
||||||
|
? discussion.comments.nodes
|
||||||
|
: [];
|
||||||
|
return !comments.some(comment => MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRepo(repo) {
|
||||||
|
const [owner, name] = String(repo || '').split('/');
|
||||||
|
if (!owner || !name) {
|
||||||
|
throw new Error(`Invalid repo: ${repo}`);
|
||||||
|
}
|
||||||
|
return { owner, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchDiscussionSummary(repo, options) {
|
||||||
|
const { owner, name } = splitRepo(repo);
|
||||||
|
const payload = runGhJson([
|
||||||
|
'api',
|
||||||
|
'graphql',
|
||||||
|
'-f',
|
||||||
|
`owner=${owner}`,
|
||||||
|
'-f',
|
||||||
|
`name=${name}`,
|
||||||
|
'-F',
|
||||||
|
'first=100',
|
||||||
|
'-f',
|
||||||
|
`query=${DISCUSSION_QUERY}`,
|
||||||
|
], options);
|
||||||
|
const repository = payload && payload.data && payload.data.repository;
|
||||||
|
const discussions = repository && repository.discussions;
|
||||||
|
const nodes = discussions && Array.isArray(discussions.nodes) ? discussions.nodes : [];
|
||||||
|
const needingTouch = nodes.filter(discussionNeedsMaintainerTouch);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: Boolean(repository && repository.hasDiscussionsEnabled),
|
||||||
|
totalCount: discussions && Number.isFinite(discussions.totalCount) ? discussions.totalCount : 0,
|
||||||
|
sampledCount: nodes.length,
|
||||||
|
needingMaintainerTouch: needingTouch.map(discussion => ({
|
||||||
|
number: discussion.number,
|
||||||
|
title: discussion.title,
|
||||||
|
url: discussion.url,
|
||||||
|
updatedAt: discussion.updatedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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: {
|
||||||
|
enabled: false,
|
||||||
|
totalCount: 0,
|
||||||
|
sampledCount: 0,
|
||||||
|
needingMaintainerTouch: [],
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
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');
|
||||||
|
|
||||||
|
return [
|
||||||
|
buildCheck(
|
||||||
|
'platform-audit-cli-surface',
|
||||||
|
packageScripts['platform:audit'] === 'node scripts/platform-audit.js' ? 'pass' : 'fail',
|
||||||
|
'package.json exposes the platform audit command',
|
||||||
|
{ fix: 'Add "platform:audit": "node scripts/platform-audit.js" 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' }
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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-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}`,
|
||||||
|
`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 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`
|
||||||
|
: renderText(report);
|
||||||
|
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,
|
||||||
|
renderText,
|
||||||
|
runGhJson,
|
||||||
|
};
|
||||||
@ -154,6 +154,21 @@ function run() {
|
|||||||
});
|
});
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('does not flag benign substrings in clean package scripts', () => {
|
||||||
|
withFixture({
|
||||||
|
'node_modules/uuid/package.json': JSON.stringify({
|
||||||
|
name: 'uuid',
|
||||||
|
version: '9.0.1',
|
||||||
|
scripts: {
|
||||||
|
test: 'BABEL_ENV=commonjsNode node --throw-deprecation node_modules/.bin/jest test/unit/',
|
||||||
|
},
|
||||||
|
}, null, 2),
|
||||||
|
}, rootDir => {
|
||||||
|
const result = scanSupplyChainIocs({ rootDir });
|
||||||
|
assert.deepStrictEqual(result.findings, []);
|
||||||
|
});
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('rejects malicious optional dependency markers', () => {
|
if (test('rejects malicious optional dependency markers', () => {
|
||||||
withFixture({
|
withFixture({
|
||||||
'package-lock.json': JSON.stringify({
|
'package-lock.json': JSON.stringify({
|
||||||
@ -241,7 +256,6 @@ function run() {
|
|||||||
assert.ok(indicators.includes('claude@users.noreply.github.com'));
|
assert.ok(indicators.includes('claude@users.noreply.github.com'));
|
||||||
assert.ok(indicators.includes('dependabout/'));
|
assert.ok(indicators.includes('dependabout/'));
|
||||||
assert.ok(indicators.includes('signalservice'));
|
assert.ok(indicators.includes('signalservice'));
|
||||||
assert.ok(indicators.includes('snode'));
|
|
||||||
});
|
});
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,8 @@ function main() {
|
|||||||
assert.match(result.stdout, /consult/);
|
assert.match(result.stdout, /consult/);
|
||||||
assert.match(result.stdout, /loop-status/);
|
assert.match(result.stdout, /loop-status/);
|
||||||
assert.match(result.stdout, /work-items/);
|
assert.match(result.stdout, /work-items/);
|
||||||
|
assert.match(result.stdout, /platform-audit/);
|
||||||
|
assert.match(result.stdout, /security-ioc-scan/);
|
||||||
}],
|
}],
|
||||||
['delegates explicit install command', () => {
|
['delegates explicit install command', () => {
|
||||||
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
|
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
|
||||||
@ -207,6 +209,28 @@ function main() {
|
|||||||
assert.strictEqual(result.status, 0, result.stderr);
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
assert.match(result.stdout, /node scripts\/work-items\.js upsert/);
|
assert.match(result.stdout, /node scripts\/work-items\.js upsert/);
|
||||||
}],
|
}],
|
||||||
|
['supports help for the platform-audit subcommand', () => {
|
||||||
|
const result = runCli(['help', 'platform-audit']);
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /Usage: node scripts\/platform-audit\.js/);
|
||||||
|
}],
|
||||||
|
['supports help for the security-ioc-scan subcommand', () => {
|
||||||
|
const result = runCli(['help', 'security-ioc-scan']);
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /Usage: node scripts\/ci\/scan-supply-chain-iocs\.js/);
|
||||||
|
}],
|
||||||
|
['delegates security-ioc-scan command', () => {
|
||||||
|
const projectRoot = createTempDir('ecc-cli-ioc-scan-');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ dependencies: { leftpad: '1.0.0' } }, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runCli(['security-ioc-scan', '--root', projectRoot, '--json']);
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
const payload = parseJson(result.stdout);
|
||||||
|
assert.deepStrictEqual(payload.findings, []);
|
||||||
|
}],
|
||||||
['fails on unknown commands instead of treating them as installs', () => {
|
['fails on unknown commands instead of treating them as installs', () => {
|
||||||
const result = runCli(['bogus']);
|
const result = runCli(['bogus']);
|
||||||
assert.strictEqual(result.status, 1);
|
assert.strictEqual(result.status, 1);
|
||||||
|
|||||||
@ -43,6 +43,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
|||||||
"manifests",
|
"manifests",
|
||||||
"scripts/ecc.js",
|
"scripts/ecc.js",
|
||||||
"scripts/catalog.js",
|
"scripts/catalog.js",
|
||||||
|
"scripts/ci/scan-supply-chain-iocs.js",
|
||||||
"scripts/consult.js",
|
"scripts/consult.js",
|
||||||
"scripts/claw.js",
|
"scripts/claw.js",
|
||||||
"scripts/doctor.js",
|
"scripts/doctor.js",
|
||||||
@ -54,6 +55,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
|||||||
"scripts/list-installed.js",
|
"scripts/list-installed.js",
|
||||||
"scripts/loop-status.js",
|
"scripts/loop-status.js",
|
||||||
"scripts/observability-readiness.js",
|
"scripts/observability-readiness.js",
|
||||||
|
"scripts/platform-audit.js",
|
||||||
"scripts/skill-create-output.js",
|
"scripts/skill-create-output.js",
|
||||||
"scripts/repair.js",
|
"scripts/repair.js",
|
||||||
"scripts/harness-adapter-compliance.js",
|
"scripts/harness-adapter-compliance.js",
|
||||||
@ -119,8 +121,10 @@ function main() {
|
|||||||
|
|
||||||
for (const requiredPath of [
|
for (const requiredPath of [
|
||||||
"scripts/catalog.js",
|
"scripts/catalog.js",
|
||||||
|
"scripts/ci/scan-supply-chain-iocs.js",
|
||||||
"scripts/consult.js",
|
"scripts/consult.js",
|
||||||
"scripts/work-items.js",
|
"scripts/work-items.js",
|
||||||
|
"scripts/platform-audit.js",
|
||||||
".gemini/GEMINI.md",
|
".gemini/GEMINI.md",
|
||||||
".qwen/QWEN.md",
|
".qwen/QWEN.md",
|
||||||
".claude-plugin/plugin.json",
|
".claude-plugin/plugin.json",
|
||||||
|
|||||||
328
tests/scripts/platform-audit.test.js
Normal file
328
tests/scripts/platform-audit.test.js
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Tests for scripts/platform-audit.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', 'platform-audit.js');
|
||||||
|
|
||||||
|
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',
|
||||||
|
scripts: {
|
||||||
|
'platform:audit': 'node scripts/platform-audit.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'
|
||||||
|
}
|
||||||
|
}, null, 2),
|
||||||
|
'docs/ECC-2.0-GA-ROADMAP.md': [
|
||||||
|
'ECC Platform Roadmap',
|
||||||
|
'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1',
|
||||||
|
'ITO-44',
|
||||||
|
'ITO-59'
|
||||||
|
].join('\n'),
|
||||||
|
'docs/architecture/progress-sync-contract.md': [
|
||||||
|
'GitHub PRs/issues/discussions',
|
||||||
|
'Linear project',
|
||||||
|
'local handoff',
|
||||||
|
'repo roadmap',
|
||||||
|
'scripts/work-items.js'
|
||||||
|
].join('\n'),
|
||||||
|
'docs/security/supply-chain-incident-response.md': [
|
||||||
|
'TanStack',
|
||||||
|
'Mini Shai-Hulud',
|
||||||
|
'node-ipc',
|
||||||
|
'scan-supply-chain-iocs.js'
|
||||||
|
].join('\n'),
|
||||||
|
'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'
|
||||||
|
].join('\n')
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||||
|
if (content === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writeFile(rootDir, relativePath, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeGhShim(rootDir, responses) {
|
||||||
|
const shimPath = path.join(rootDir, 'gh-shim.js');
|
||||||
|
fs.writeFileSync(shimPath, `
|
||||||
|
const responses = ${JSON.stringify(responses)};
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const key = args.join(' ');
|
||||||
|
if (process.env.GITHUB_TOKEN) {
|
||||||
|
console.error('GITHUB_TOKEN should be unset by default');
|
||||||
|
process.exit(42);
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||||
|
console.error('Unexpected gh args: ' + key);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify(responses[key]));
|
||||||
|
`);
|
||||||
|
return shimPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(args = [], options = {}) {
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
...(options.env || {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return execFileSync('node', [SCRIPT, ...args], {
|
||||||
|
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||||
|
env,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProcess(args = [], options = {}) {
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
...(options.env || {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return spawnSync('node', [SCRIPT, ...args], {
|
||||||
|
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||||
|
env,
|
||||||
|
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 platform-audit.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
if (test('parseArgs accepts supported flags and rejects invalid values', () => {
|
||||||
|
const { parseArgs } = require(SCRIPT);
|
||||||
|
const rootDir = createTempDir('platform-audit-args-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseArgs([
|
||||||
|
'node',
|
||||||
|
'script',
|
||||||
|
'--format=json',
|
||||||
|
`--root=${rootDir}`,
|
||||||
|
'--repo',
|
||||||
|
'affaan-m/everything-claude-code',
|
||||||
|
'--max-open-prs',
|
||||||
|
'5',
|
||||||
|
'--max-open-issues',
|
||||||
|
'6',
|
||||||
|
'--allow-untracked',
|
||||||
|
'docs/drafts/'
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.format, 'json');
|
||||||
|
assert.strictEqual(parsed.root, path.resolve(rootDir));
|
||||||
|
assert.deepStrictEqual(parsed.repos, ['affaan-m/everything-claude-code']);
|
||||||
|
assert.strictEqual(parsed.thresholds.maxOpenPrs, 5);
|
||||||
|
assert.strictEqual(parsed.thresholds.maxOpenIssues, 6);
|
||||||
|
assert.deepStrictEqual(parsed.allowUntracked, ['docs/drafts/']);
|
||||||
|
|
||||||
|
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
|
||||||
|
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/);
|
||||||
|
} finally {
|
||||||
|
cleanup(rootDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('skip-github report checks local release and security evidence', () => {
|
||||||
|
const projectRoot = createTempDir('platform-audit-local-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
seedRepo(projectRoot);
|
||||||
|
const parsed = JSON.parse(run(['--format=json', `--root=${projectRoot}`, '--skip-github'], { cwd: projectRoot }));
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.schema_version, 'ecc.platform-audit.v1');
|
||||||
|
assert.strictEqual(parsed.ready, true);
|
||||||
|
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.deepStrictEqual(parsed.top_actions, []);
|
||||||
|
} finally {
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('github queue and discussion budgets pass with maintainer touch', () => {
|
||||||
|
const projectRoot = createTempDir('platform-audit-github-pass-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
seedRepo(projectRoot);
|
||||||
|
const shimPath = writeGhShim(projectRoot, {
|
||||||
|
'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': [],
|
||||||
|
'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [],
|
||||||
|
'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': {
|
||||||
|
data: {
|
||||||
|
repository: {
|
||||||
|
hasDiscussionsEnabled: true,
|
||||||
|
discussions: {
|
||||||
|
totalCount: 1,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
number: 73,
|
||||||
|
title: 'Compacting during workflow',
|
||||||
|
url: 'https://github.com/example/discussions/73',
|
||||||
|
updatedAt: '2026-05-15T00:00:00Z',
|
||||||
|
authorAssociation: 'NONE',
|
||||||
|
comments: { nodes: [{ authorAssociation: 'OWNER' }] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(run([
|
||||||
|
'--format=json',
|
||||||
|
`--root=${projectRoot}`,
|
||||||
|
'--repo',
|
||||||
|
'affaan-m/everything-claude-code'
|
||||||
|
], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: {
|
||||||
|
ECC_GH_SHIM: shimPath,
|
||||||
|
GITHUB_TOKEN: 'must-be-removed'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.ready, true);
|
||||||
|
assert.strictEqual(parsed.github.totals.openPrs, 0);
|
||||||
|
assert.strictEqual(parsed.github.totals.openIssues, 0);
|
||||||
|
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 0);
|
||||||
|
assert.ok(parsed.checks.some(check => check.id === 'github-discussion-touch' && check.status === 'pass'));
|
||||||
|
} finally {
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('threshold failures and untouched discussions become top actions', () => {
|
||||||
|
const projectRoot = createTempDir('platform-audit-github-fail-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
seedRepo(projectRoot);
|
||||||
|
const prs = Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
number: index + 1,
|
||||||
|
title: `PR ${index + 1}`,
|
||||||
|
isDraft: false,
|
||||||
|
mergeStateStatus: 'CLEAN',
|
||||||
|
updatedAt: '2026-05-15T00:00:00Z',
|
||||||
|
url: `https://github.com/example/pull/${index + 1}`,
|
||||||
|
author: { login: 'contributor' }
|
||||||
|
}));
|
||||||
|
const shimPath = writeGhShim(projectRoot, {
|
||||||
|
'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': prs,
|
||||||
|
'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [],
|
||||||
|
'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': {
|
||||||
|
data: {
|
||||||
|
repository: {
|
||||||
|
hasDiscussionsEnabled: true,
|
||||||
|
discussions: {
|
||||||
|
totalCount: 1,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
number: 1239,
|
||||||
|
title: 'Losing context',
|
||||||
|
url: 'https://github.com/example/discussions/1239',
|
||||||
|
updatedAt: '2026-05-15T00:00:00Z',
|
||||||
|
authorAssociation: 'NONE',
|
||||||
|
comments: { nodes: [] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(run([
|
||||||
|
'--format=json',
|
||||||
|
`--root=${projectRoot}`,
|
||||||
|
'--repo',
|
||||||
|
'affaan-m/everything-claude-code',
|
||||||
|
'--max-open-prs',
|
||||||
|
'2'
|
||||||
|
], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: { ECC_GH_SHIM: shimPath }
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.strictEqual(parsed.ready, false);
|
||||||
|
assert.ok(parsed.top_actions.some(action => action.id === 'github-open-pr-budget'));
|
||||||
|
assert.ok(parsed.top_actions.some(action => action.id === 'github-discussion-touch'));
|
||||||
|
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 1);
|
||||||
|
} finally {
|
||||||
|
cleanup(projectRoot);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('cli help and invalid args exit cleanly', () => {
|
||||||
|
const help = runProcess(['--help']);
|
||||||
|
assert.strictEqual(help.status, 0);
|
||||||
|
assert.ok(help.stdout.includes('Usage: node scripts/platform-audit.js'));
|
||||||
|
|
||||||
|
const invalid = runProcess(['--format', 'xml']);
|
||||||
|
assert.strictEqual(invalid.status, 1);
|
||||||
|
assert.ok(invalid.stderr.includes('Invalid format'));
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
console.log(`\nPassed: ${passed}`);
|
||||||
|
console.log(`Failed: ${failed}`);
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
runTests();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user