@bongos/core 1.19.256 → 1.19.258
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +86 -26
- package/docs/adr/0022-secrets-policy.md +16 -12
- package/docs/adr/0180-dependency-intake-and-drift-policy.md +36 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +1 -0
- package/docs/handoff-template.md +1 -1
- package/docs/recipes/README.md +1 -1
- package/docs/recipes/redteam-patrol.md +48 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-gate.js +121 -0
- package/scripts/gds/control-manifest.js +123 -0
- package/scripts/gds/fitness-ratchets.js +328 -0
- package/scripts/gds/fitness.js +4 -4
- package/scripts/gds/gen-session-index.js +8 -1
- package/scripts/gds/redteam-patrol.js +308 -0
- package/src/bongos/logger.js +118 -0
- package/src/bongos/middleware/audit.js +4 -3
- package/src/bongos/middleware/error-envelope.js +5 -4
- package/src/bongos/module-scope-map.js +2 -1
- package/src/bongos/serve-internal.js +2 -1
- package/src/module-api.js +5 -1
- package/src/platform-server.js +9 -2
- package/tests/audit_gate.mjs +89 -0
- package/tests/auth_resolve_failure.mjs +1 -1
- package/tests/claude_materialize.mjs +28 -11
- package/tests/control_manifest.mjs +60 -0
- package/tests/fitness_ratchets.mjs +91 -0
- package/tests/logger.mjs +89 -0
- package/tests/publish_reconciler.mjs +142 -0
- package/tests/redteam_patrol.mjs +67 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/audit-gate.js — the dependency vulnerability gate (task 1003127,
|
|
3
|
+
// goal 1000076; ADR 0180, docs/audits/ai-dev-redteam-baseline.md §3).
|
|
4
|
+
//
|
|
5
|
+
// Ratchet semantics, matching the goal's other budgets: a committed BASELINE
|
|
6
|
+
// (config/audit-baseline.json) records the advisories that predate the gate.
|
|
7
|
+
// A HIGH or CRITICAL advisory NOT in the baseline fails CI; anything below
|
|
8
|
+
// high warns; a baseline entry that no longer reproduces prints a shrink nudge
|
|
9
|
+
// (removing it is an ordinary PR). Fixes are explicit tasks — a blanket
|
|
10
|
+
// `npm audit fix` does not even run against this tree (workspace: protocol).
|
|
11
|
+
//
|
|
12
|
+
// Fail contract: CLOSED on findings, OPEN on infrastructure — a registry
|
|
13
|
+
// outage or unparseable audit output warns and exits 0 rather than bricking
|
|
14
|
+
// every PR (the gate's job is catching new advisories, not proving uptime).
|
|
15
|
+
//
|
|
16
|
+
// Run: node scripts/gds/audit-gate.js
|
|
17
|
+
// Refresh floor: node scripts/gds/audit-gate.js --update-baseline
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { execSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
26
|
+
const BASELINE_PATH = path.join(ROOT, 'config', 'audit-baseline.json');
|
|
27
|
+
const GATE_SEVERITIES = new Set(['high', 'critical']);
|
|
28
|
+
|
|
29
|
+
// npm audit JSON → flat advisory findings, one per (advisory, vulnerable pkg).
|
|
30
|
+
// Identity is the advisory's numeric `source` id + the package it hits — stable
|
|
31
|
+
// across runs, unlike the parent-package rollups npm prints.
|
|
32
|
+
function extractFindings(auditJson) {
|
|
33
|
+
const out = new Map();
|
|
34
|
+
const vulns = (auditJson && auditJson.vulnerabilities) || {};
|
|
35
|
+
for (const entry of Object.values(vulns)) {
|
|
36
|
+
for (const via of entry.via || []) {
|
|
37
|
+
if (typeof via !== 'object' || via === null || via.source == null) continue;
|
|
38
|
+
const key = `${via.source}:${via.name}`;
|
|
39
|
+
if (!out.has(key)) {
|
|
40
|
+
out.set(key, {
|
|
41
|
+
id: String(via.source),
|
|
42
|
+
name: via.name,
|
|
43
|
+
severity: String(via.severity || entry.severity || 'unknown').toLowerCase(),
|
|
44
|
+
title: via.title || '',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...out.values()].sort((a, b) => (a.name + a.id).localeCompare(b.name + b.id));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Pure gate decision — exported for the hermetic test.
|
|
53
|
+
function compare(currentFindings, baselineFindings) {
|
|
54
|
+
const baseKeys = new Set((baselineFindings || []).map((f) => `${f.id}:${f.name}`));
|
|
55
|
+
const curKeys = new Set(currentFindings.map((f) => `${f.id}:${f.name}`));
|
|
56
|
+
const fresh = currentFindings.filter((f) => !baseKeys.has(`${f.id}:${f.name}`));
|
|
57
|
+
return {
|
|
58
|
+
newGating: fresh.filter((f) => GATE_SEVERITIES.has(f.severity)),
|
|
59
|
+
newAdvisory: fresh.filter((f) => !GATE_SEVERITIES.has(f.severity)),
|
|
60
|
+
resolved: (baselineFindings || []).filter((f) => !curKeys.has(`${f.id}:${f.name}`)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readBaseline() {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')).findings || [];
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function runAudit() {
|
|
73
|
+
try {
|
|
74
|
+
return execSync('npm audit --omit=dev --json', { cwd: ROOT, maxBuffer: 32 * 1024 * 1024 }).toString();
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// npm audit exits 1 when advisories exist — that IS the payload.
|
|
77
|
+
if (e && e.stdout) return e.stdout.toString();
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function main() {
|
|
83
|
+
const update = process.argv.includes('--update-baseline');
|
|
84
|
+
let findings;
|
|
85
|
+
try {
|
|
86
|
+
findings = extractFindings(JSON.parse(runAudit()));
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.warn(`audit-gate: WARN — npm audit did not produce parseable JSON (${e.message}); failing OPEN (infra, not findings).`);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (update) {
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
BASELINE_PATH,
|
|
95
|
+
JSON.stringify({ _comment: 'audit-gate baseline (ADR 0180): pre-existing advisories the gate tolerates. Shrink-only — remove entries as fix tasks land; never add by hand to silence a NEW advisory.', updated: null, findings }, null, 2) + '\n',
|
|
96
|
+
);
|
|
97
|
+
console.log(`audit-gate: baseline updated — ${findings.length} finding(s) recorded at ${path.relative(ROOT, BASELINE_PATH)}.`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const verdict = compare(findings, readBaseline());
|
|
102
|
+
for (const f of verdict.resolved) {
|
|
103
|
+
console.log(`audit-gate: ratchet-down available — baseline entry no longer reproduces: [${f.severity}] ${f.name} (${f.id}) ${f.title}`);
|
|
104
|
+
}
|
|
105
|
+
for (const f of verdict.newAdvisory) {
|
|
106
|
+
console.warn(`audit-gate: WARN new ${f.severity} advisory (below the gate): ${f.name} (${f.id}) ${f.title}`);
|
|
107
|
+
}
|
|
108
|
+
if (verdict.newGating.length) {
|
|
109
|
+
console.error('audit-gate: FAIL — new high/critical advisories not in the baseline:');
|
|
110
|
+
for (const f of verdict.newGating) {
|
|
111
|
+
console.error(` [${f.severity}] ${f.name} (advisory ${f.id}) ${f.title}`);
|
|
112
|
+
}
|
|
113
|
+
console.error('Fix or pin the dependency. The baseline only records PRE-GATE debt; do not add new entries to silence this.');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
console.log(`audit-gate: PASS — ${findings.length} known finding(s), 0 new at high/critical.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (require.main === module) main();
|
|
120
|
+
|
|
121
|
+
module.exports = { extractFindings, compare, GATE_SEVERITIES, BASELINE_PATH };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/control-manifest.js — the assurance-of-assurance check (task
|
|
3
|
+
// 1003131, goal 1000076; docs/audits/ai-dev-redteam-baseline.md §0).
|
|
4
|
+
//
|
|
5
|
+
// The audit's HEADLINE finding: ADR 0022 named three enforcement controls
|
|
6
|
+
// (a pre-commit secret hook, a secrets-scan CI workflow, a security-baseline
|
|
7
|
+
// doc) that DID NOT EXIST in this repo — they never survived the core
|
|
8
|
+
// extraction, and nothing noticed for releases. A control that can silently
|
|
9
|
+
// stop existing is not a control.
|
|
10
|
+
//
|
|
11
|
+
// This module is the manifest of controls goal 1000076 shipped, plus the
|
|
12
|
+
// predicate each must satisfy. It runs as a fitness check (fitness.js): delete
|
|
13
|
+
// or unwire any control and the merge gate goes red. The manifest is the ONE
|
|
14
|
+
// list — a control added later without a manifest row, or a manifest row whose
|
|
15
|
+
// file is gone, both fail.
|
|
16
|
+
//
|
|
17
|
+
// Predicates are deliberately shallow (existence + one wiring substring), not
|
|
18
|
+
// deep behavior tests — the control's own unit test proves behavior; THIS proves
|
|
19
|
+
// the control is still present and connected. Cheap, hermetic (fs only), and
|
|
20
|
+
// exactly the check whose absence the audit flagged.
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
28
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
29
|
+
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
|
|
30
|
+
|
|
31
|
+
// Each control: a file that must exist, and optionally a `wiredIn` (file, needle)
|
|
32
|
+
// proving it is CONNECTED, not just present on disk. `label` names it in failures.
|
|
33
|
+
const CONTROLS = [
|
|
34
|
+
{
|
|
35
|
+
label: 'prevent: secret-gate PreToolUse hook',
|
|
36
|
+
file: '.claude/hooks/secret-gate.js',
|
|
37
|
+
wiredIn: { file: '.claude/settings.json', needle: 'secret-gate.js' },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'prevent: settings deny-rules for env/key lanes',
|
|
41
|
+
file: '.claude/settings.json',
|
|
42
|
+
wiredIn: { file: '.claude/settings.json', needle: '"deny"' },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'gate: gitleaks secret scan in the unit workflow',
|
|
46
|
+
file: '.gitleaks.toml',
|
|
47
|
+
wiredIn: { file: '.github/workflows/unit.yml', needle: 'gitleaks' },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: 'gate: dependency audit gate + baseline',
|
|
51
|
+
file: 'scripts/gds/audit-gate.js',
|
|
52
|
+
wiredIn: { file: '.github/workflows/unit.yml', needle: 'audit-gate.js' },
|
|
53
|
+
alsoExists: ['config/audit-baseline.json'],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
label: 'gate: install-lifecycle scripts disabled',
|
|
57
|
+
file: '.npmrc',
|
|
58
|
+
wiredIn: { file: '.npmrc', needle: 'ignore-scripts=true' },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
label: 'gate: quality ratchets registered in fitness',
|
|
62
|
+
file: 'scripts/gds/fitness-ratchets.js',
|
|
63
|
+
wiredIn: { file: 'scripts/gds/fitness.js', needle: 'fitness-ratchets' },
|
|
64
|
+
alsoExists: ['config/fitness-baselines.json'],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
label: 'gate: knip dead-code ratchet step in the unit workflow',
|
|
68
|
+
file: 'scripts/gds/fitness-ratchets.js',
|
|
69
|
+
wiredIn: { file: '.github/workflows/unit.yml', needle: '--knip' },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: 'patrol: weekly red-team routine wired to its scanner',
|
|
73
|
+
file: '.claude/scheduled-tasks/redteam-weekly/SKILL.md',
|
|
74
|
+
wiredIn: { file: '.claude/scheduled-tasks/redteam-weekly/SKILL.md', needle: 'redteam-patrol.js' },
|
|
75
|
+
alsoExists: ['scripts/gds/redteam-patrol.js'],
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// Pure evaluation — exported for the test. deps.exists/deps.read override fs.
|
|
80
|
+
function evaluate(controls = CONTROLS, deps = {}) {
|
|
81
|
+
const ex = deps.exists || exists;
|
|
82
|
+
const rd = deps.read || read;
|
|
83
|
+
const missing = [];
|
|
84
|
+
for (const c of controls) {
|
|
85
|
+
if (!ex(c.file)) { missing.push(`${c.label}: control file missing (${c.file})`); continue; }
|
|
86
|
+
for (const extra of c.alsoExists || []) {
|
|
87
|
+
if (!ex(extra)) missing.push(`${c.label}: companion file missing (${extra})`);
|
|
88
|
+
}
|
|
89
|
+
if (c.wiredIn) {
|
|
90
|
+
let hay = '';
|
|
91
|
+
try { hay = ex(c.wiredIn.file) ? rd(c.wiredIn.file) : ''; } catch { hay = ''; }
|
|
92
|
+
if (!hay.includes(c.wiredIn.needle)) {
|
|
93
|
+
missing.push(`${c.label}: not wired in — "${c.wiredIn.needle}" absent from ${c.wiredIn.file}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return missing;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The fitness.js check.
|
|
101
|
+
function checkControlsExist() {
|
|
102
|
+
const violations = evaluate();
|
|
103
|
+
return {
|
|
104
|
+
name: 'controls assert their own existence — goal 1000076 enforcement manifest (task 1003131)',
|
|
105
|
+
hardFail: violations.length > 0,
|
|
106
|
+
violations,
|
|
107
|
+
warnings: [],
|
|
108
|
+
note: violations.length
|
|
109
|
+
? 'a shipped security/quality control disappeared or came unwired — restore it or update control-manifest.js in the same change'
|
|
110
|
+
: `all ${CONTROLS.length} goal-1000076 controls present and wired (the assurance-of-assurance the ADR 0022 audit found missing)`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function main() {
|
|
115
|
+
const r = checkControlsExist();
|
|
116
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
117
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
118
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (require.main === module) main();
|
|
122
|
+
|
|
123
|
+
module.exports = { checkControlsExist, evaluate, CONTROLS };
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/fitness-ratchets.js — quality budgets that only tighten (task
|
|
3
|
+
// 1003129, goal 1000076; docs/audits/ai-dev-redteam-baseline.md §4).
|
|
4
|
+
//
|
|
5
|
+
// The audit's modularity finding was measurement without thresholds: sizes,
|
|
6
|
+
// dead code, and doc entropy were all TRACKED and nothing failed when they
|
|
7
|
+
// grew. This module gives each metric a committed BASELINE
|
|
8
|
+
// (config/fitness-baselines.json) with RATCHET semantics:
|
|
9
|
+
// value > baseline → violation (fitness hardFail — the number got worse)
|
|
10
|
+
// value < baseline → warning nudge to lower the baseline (an ordinary PR)
|
|
11
|
+
// GitClear's measured trend (AI-assisted code duplicates instead of reusing)
|
|
12
|
+
// is the reason the gate exists; the baseline records today's debt without
|
|
13
|
+
// bricking today's tree.
|
|
14
|
+
//
|
|
15
|
+
// Metrics (all hermetic — git + fs only, no network):
|
|
16
|
+
// oversized_file_count / excess_lines_total — tracked first-party .js files
|
|
17
|
+
// over OVERSIZE_LINES lines, and their total excess (growth anywhere over
|
|
18
|
+
// the threshold trips the sum even when the count is flat)
|
|
19
|
+
// duplicate_window_count — normalized WINDOW-line blocks appearing in 2+
|
|
20
|
+
// distinct files (the in-house jscpd: cross-file copy/paste)
|
|
21
|
+
// require_cycle_count — strongly-connected components (size >1) in the
|
|
22
|
+
// relative-require graph (the in-house madge)
|
|
23
|
+
// console_call_count — console.* call sites in SERVER paths (src/ + modules/
|
|
24
|
+
// minus their browser public/ trees); task 1003130 converts the core path
|
|
25
|
+
// to structured logging and shrinks this baseline
|
|
26
|
+
// doc_stale_links / doc_broken_anchors / doc_duplicate_prose — the
|
|
27
|
+
// docs-entropy scanner's three counts, reused as a library
|
|
28
|
+
//
|
|
29
|
+
// The knip dead-code ratchet needs node_modules + ~seconds, so it is NOT part
|
|
30
|
+
// of the in-suite check: CI runs `node scripts/gds/fitness-ratchets.js --knip`
|
|
31
|
+
// as its own step (fail-open when knip cannot run, fail-closed on regression).
|
|
32
|
+
//
|
|
33
|
+
// Refresh floors: node scripts/gds/fitness-ratchets.js --update-baselines
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const crypto = require('crypto');
|
|
40
|
+
const { execFileSync, execSync } = require('child_process');
|
|
41
|
+
|
|
42
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
43
|
+
const BASELINE_PATH = path.join(ROOT, 'config', 'fitness-baselines.json');
|
|
44
|
+
|
|
45
|
+
const OVERSIZE_LINES = 1500;
|
|
46
|
+
const WINDOW = 30; // normalized lines per duplication window
|
|
47
|
+
|
|
48
|
+
// Vendored / generated / third-party JS the budgets must not count.
|
|
49
|
+
const EXCLUDE_RE = /(^|\/)(node_modules|public-docs)\/|\.min\.js$|redoc\.standalone\.js$|(^|\/)vendor\//;
|
|
50
|
+
|
|
51
|
+
function trackedJsFiles() {
|
|
52
|
+
const out = execFileSync('git', ['ls-files', '*.js'], { cwd: ROOT, encoding: 'utf8' });
|
|
53
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean).filter((f) => !EXCLUDE_RE.test(f));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readRepoFile(rel) {
|
|
57
|
+
return fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------- size ----------
|
|
61
|
+
|
|
62
|
+
function sizeMetrics(files, read = readRepoFile) {
|
|
63
|
+
let count = 0;
|
|
64
|
+
let excess = 0;
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
const lines = read(f).split('\n').length;
|
|
67
|
+
if (lines > OVERSIZE_LINES) {
|
|
68
|
+
count++;
|
|
69
|
+
excess += lines - OVERSIZE_LINES;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { oversized_file_count: count, excess_lines_total: excess };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------- duplication (in-house jscpd) ----------
|
|
76
|
+
|
|
77
|
+
function normalizeLines(text) {
|
|
78
|
+
return text
|
|
79
|
+
.split('\n')
|
|
80
|
+
.map((l) => l.trim())
|
|
81
|
+
.filter((l) => l.length > 0 && !l.startsWith('//') && !l.startsWith('*') && !l.startsWith('/*'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// contentsByFile: Map(rel → text). Counts window-hashes seen in ≥2 DISTINCT files.
|
|
85
|
+
function duplicateWindowCount(contentsByFile, window = WINDOW) {
|
|
86
|
+
const filesByHash = new Map();
|
|
87
|
+
for (const [file, text] of contentsByFile) {
|
|
88
|
+
const lines = normalizeLines(text);
|
|
89
|
+
for (let i = 0; i + window <= lines.length; i++) {
|
|
90
|
+
const h = crypto.createHash('sha1').update(lines.slice(i, i + window).join('\n')).digest('base64');
|
|
91
|
+
let set = filesByHash.get(h);
|
|
92
|
+
if (!set) filesByHash.set(h, (set = new Set()));
|
|
93
|
+
set.add(file);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let dupes = 0;
|
|
97
|
+
for (const set of filesByHash.values()) if (set.size >= 2) dupes++;
|
|
98
|
+
return dupes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------- require cycles (in-house madge) ----------
|
|
102
|
+
|
|
103
|
+
const REQUIRE_RE = /require\(\s*(['"])(\.{1,2}\/[^'"]+)\1\s*\)/g;
|
|
104
|
+
|
|
105
|
+
function resolveRelative(fromRel, spec, exists) {
|
|
106
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), spec));
|
|
107
|
+
for (const cand of [base, `${base}.js`, `${base}/index.js`]) {
|
|
108
|
+
if (exists(cand)) return cand;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// edges: Map(rel → [rel...]). Returns the number of SCCs with size > 1 (Tarjan).
|
|
114
|
+
function countCycles(edges) {
|
|
115
|
+
let index = 0;
|
|
116
|
+
const idx = new Map();
|
|
117
|
+
const low = new Map();
|
|
118
|
+
const onStack = new Set();
|
|
119
|
+
const stack = [];
|
|
120
|
+
let cycles = 0;
|
|
121
|
+
|
|
122
|
+
function strongconnect(v) {
|
|
123
|
+
idx.set(v, index); low.set(v, index); index++;
|
|
124
|
+
stack.push(v); onStack.add(v);
|
|
125
|
+
for (const w of edges.get(v) || []) {
|
|
126
|
+
if (!edges.has(w)) continue;
|
|
127
|
+
if (!idx.has(w)) { strongconnect(w); low.set(v, Math.min(low.get(v), low.get(w))); }
|
|
128
|
+
else if (onStack.has(w)) low.set(v, Math.min(low.get(v), idx.get(w)));
|
|
129
|
+
}
|
|
130
|
+
if (low.get(v) === idx.get(v)) {
|
|
131
|
+
let size = 0; let w;
|
|
132
|
+
do { w = stack.pop(); onStack.delete(w); size++; } while (w !== v);
|
|
133
|
+
if (size > 1) cycles++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const v of edges.keys()) if (!idx.has(v)) strongconnect(v);
|
|
137
|
+
return cycles;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function requireGraph(contentsByFile) {
|
|
141
|
+
const known = new Set(contentsByFile.keys());
|
|
142
|
+
const exists = (rel) => known.has(rel);
|
|
143
|
+
const edges = new Map();
|
|
144
|
+
for (const [file, text] of contentsByFile) {
|
|
145
|
+
const deps = [];
|
|
146
|
+
for (const m of text.matchAll(REQUIRE_RE)) {
|
|
147
|
+
const target = resolveRelative(file, m[2], exists);
|
|
148
|
+
if (target && target !== file) deps.push(target);
|
|
149
|
+
}
|
|
150
|
+
edges.set(file, deps);
|
|
151
|
+
}
|
|
152
|
+
return edges;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------- console.* in server paths ----------
|
|
156
|
+
|
|
157
|
+
const CONSOLE_RE = /console\.(log|error|warn|info|debug)\s*\(/g;
|
|
158
|
+
|
|
159
|
+
function isServerPath(rel) {
|
|
160
|
+
if (!(rel.startsWith('src/') || rel.startsWith('modules/') || rel === 'server.js')) return false;
|
|
161
|
+
if (rel.includes('/public/')) return false; // browser surfaces are a different concern
|
|
162
|
+
if (rel.includes('/test')) return false;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function consoleCallCount(contentsByFile) {
|
|
167
|
+
let n = 0;
|
|
168
|
+
for (const [file, text] of contentsByFile) {
|
|
169
|
+
if (!isServerPath(file)) continue;
|
|
170
|
+
n += (text.match(CONSOLE_RE) || []).length;
|
|
171
|
+
}
|
|
172
|
+
return n;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- doc entropy (reuse the scanner as a library) ----------
|
|
176
|
+
|
|
177
|
+
function docMetrics() {
|
|
178
|
+
const de = require('./docs-entropy.js');
|
|
179
|
+
const relFiles = de.collectMarkdownFiles();
|
|
180
|
+
const linkFindings = de.scanStaleLinks(relFiles);
|
|
181
|
+
return {
|
|
182
|
+
doc_stale_links: linkFindings.filter((f) => f.issue === 'missing_file').length,
|
|
183
|
+
doc_broken_anchors: linkFindings.filter((f) => f.issue === 'missing_anchor').length,
|
|
184
|
+
doc_duplicate_prose: de.scanDuplicateProse(relFiles).length,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------- knip (CI-only, fail-open on infra) ----------
|
|
189
|
+
|
|
190
|
+
function knipIssueCount() {
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = execSync('npx --no-install knip --reporter json --no-exit-code', {
|
|
194
|
+
cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
195
|
+
});
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (e && e.stdout) raw = e.stdout.toString(); else return null; // knip unavailable → skip
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const j = JSON.parse(raw);
|
|
201
|
+
// knip's json reporter: { files: [...], issues: [{ file, owners, exports:[], types:[], ... }] }
|
|
202
|
+
const files = Array.isArray(j.files) ? j.files.length : 0;
|
|
203
|
+
let items = 0;
|
|
204
|
+
for (const issue of j.issues || []) {
|
|
205
|
+
for (const key of ['dependencies', 'devDependencies', 'optionalPeerDependencies', 'unlisted', 'binaries', 'unresolved', 'exports', 'types', 'nsExports', 'nsTypes', 'duplicates']) {
|
|
206
|
+
const v = issue[key];
|
|
207
|
+
items += Array.isArray(v) ? v.length : 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return files + items;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------- baseline + ratchet ----------
|
|
217
|
+
|
|
218
|
+
function readBaselines() {
|
|
219
|
+
try {
|
|
220
|
+
return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')).metrics || {};
|
|
221
|
+
} catch {
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Pure ratchet verdict — exported for the hermetic test.
|
|
227
|
+
function compareToBaselines(metrics, baselines) {
|
|
228
|
+
const violations = [];
|
|
229
|
+
const warnings = [];
|
|
230
|
+
for (const [name, value] of Object.entries(metrics)) {
|
|
231
|
+
if (value === null || value === undefined) continue; // metric skipped (e.g. knip off-CI)
|
|
232
|
+
const floor = baselines[name];
|
|
233
|
+
if (floor === undefined) {
|
|
234
|
+
warnings.push(`no baseline for "${name}" (value ${value}) — run --update-baselines`);
|
|
235
|
+
} else if (value > floor) {
|
|
236
|
+
violations.push(`${name} regressed: ${value} > baseline ${floor} — fix the regression; never raise the baseline to pass`);
|
|
237
|
+
} else if (value < floor) {
|
|
238
|
+
warnings.push(`ratchet-down available: ${name} improved to ${value} (baseline ${floor}) — lower the baseline in config/fitness-baselines.json`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { violations, warnings };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function computeMetrics({ withDocs = true } = {}) {
|
|
245
|
+
const files = trackedJsFiles();
|
|
246
|
+
const contents = new Map(files.map((f) => [f, readRepoFile(f)]));
|
|
247
|
+
return {
|
|
248
|
+
...sizeMetrics(files, (f) => contents.get(f)),
|
|
249
|
+
duplicate_window_count: duplicateWindowCount(contents),
|
|
250
|
+
require_cycle_count: countCycles(requireGraph(contents)),
|
|
251
|
+
console_call_count: consoleCallCount(contents),
|
|
252
|
+
...(withDocs ? docMetrics() : {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The fitness.js check (in-suite, hermetic — everything except knip).
|
|
257
|
+
function checkQualityRatchets() {
|
|
258
|
+
const metrics = computeMetrics();
|
|
259
|
+
const { violations, warnings } = compareToBaselines(metrics, readBaselines());
|
|
260
|
+
return {
|
|
261
|
+
name: 'quality ratchets — size, duplication, cycles, console, doc entropy only tighten (task 1003129)',
|
|
262
|
+
hardFail: violations.length > 0,
|
|
263
|
+
violations,
|
|
264
|
+
warnings,
|
|
265
|
+
note: `budgets: ${Object.entries(metrics).map(([k, v]) => `${k}=${v}`).join(' · ')} vs config/fitness-baselines.json (knip runs as its own CI step)`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function main() {
|
|
270
|
+
const update = process.argv.includes('--update-baselines');
|
|
271
|
+
const knipOnly = process.argv.includes('--knip');
|
|
272
|
+
|
|
273
|
+
if (update) {
|
|
274
|
+
const metrics = computeMetrics();
|
|
275
|
+
const knip = knipIssueCount();
|
|
276
|
+
if (knip !== null) metrics.knip_issue_count = knip;
|
|
277
|
+
const existing = readBaselines();
|
|
278
|
+
if (knip === null && existing.knip_issue_count !== undefined) metrics.knip_issue_count = existing.knip_issue_count;
|
|
279
|
+
fs.writeFileSync(
|
|
280
|
+
BASELINE_PATH,
|
|
281
|
+
JSON.stringify({ _comment: 'fitness-ratchets baselines (task 1003129): quality debt recorded at gate-adoption. Shrink-only — lower a value when the metric improves; raising one to pass CI is out of policy.', metrics }, null, 2) + '\n',
|
|
282
|
+
);
|
|
283
|
+
console.log(`fitness-ratchets: baselines written — ${Object.entries(metrics).map(([k, v]) => `${k}=${v}`).join(' · ')}`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (knipOnly) {
|
|
288
|
+
const value = knipIssueCount();
|
|
289
|
+
if (value === null) {
|
|
290
|
+
console.warn('fitness-ratchets: WARN — knip not runnable here (no node_modules / npx failure); failing OPEN.');
|
|
291
|
+
process.exit(0);
|
|
292
|
+
}
|
|
293
|
+
const { violations, warnings } = compareToBaselines({ knip_issue_count: value }, readBaselines());
|
|
294
|
+
for (const w of warnings) console.warn(`fitness-ratchets: ${w}`);
|
|
295
|
+
if (violations.length) {
|
|
296
|
+
for (const v of violations) console.error(`fitness-ratchets: FAIL — ${v}`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
console.log(`fitness-ratchets: PASS — knip_issue_count=${value}.`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const r = checkQualityRatchets();
|
|
304
|
+
for (const w of r.warnings) console.warn(`! ${w}`);
|
|
305
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
306
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
307
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (require.main === module) main();
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
checkQualityRatchets,
|
|
314
|
+
computeMetrics,
|
|
315
|
+
compareToBaselines,
|
|
316
|
+
duplicateWindowCount,
|
|
317
|
+
normalizeLines,
|
|
318
|
+
countCycles,
|
|
319
|
+
requireGraph,
|
|
320
|
+
resolveRelative,
|
|
321
|
+
sizeMetrics,
|
|
322
|
+
consoleCallCount,
|
|
323
|
+
isServerPath,
|
|
324
|
+
knipIssueCount,
|
|
325
|
+
OVERSIZE_LINES,
|
|
326
|
+
WINDOW,
|
|
327
|
+
BASELINE_PATH,
|
|
328
|
+
};
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1881,6 +1881,8 @@ const CHECKS = [
|
|
|
1881
1881
|
checkVocabularyWall, // Check 19 — ADR 0174 / goal 1000068 R07: "role" is a craft, never a rank
|
|
1882
1882
|
checkGoalMembershipVocabulary, // Check 19b — ADR 0177 / task 1003013: the FOURTH "role" (goal membership)
|
|
1883
1883
|
checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
|
|
1884
|
+
require('./fitness-ratchets.js').checkQualityRatchets, // Check 20 — task 1003129: quality budgets only tighten (mechanics + knip CI step live in fitness-ratchets.js)
|
|
1885
|
+
require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
|
|
1884
1886
|
];
|
|
1885
1887
|
|
|
1886
1888
|
function runAll() {
|
|
@@ -1909,10 +1911,8 @@ function main() {
|
|
|
1909
1911
|
if (require.main === module) main();
|
|
1910
1912
|
|
|
1911
1913
|
module.exports = {
|
|
1912
|
-
checkVocabularyWall,
|
|
1913
|
-
|
|
1914
|
-
checkGoalMembershipVocabulary,
|
|
1915
|
-
GOAL_VOCAB_SURFACE,
|
|
1914
|
+
checkVocabularyWall, VOCAB_ALLOW,
|
|
1915
|
+
checkGoalMembershipVocabulary, GOAL_VOCAB_SURFACE,
|
|
1916
1916
|
runAll,
|
|
1917
1917
|
resolvesToHostGame,
|
|
1918
1918
|
checkCoreHostBoundary,
|
|
@@ -128,7 +128,14 @@ function renderSnippet(entries, count = SNIPPET_COUNT) {
|
|
|
128
128
|
const recent = entries.slice(0, count);
|
|
129
129
|
const L = [];
|
|
130
130
|
L.push(BEGIN);
|
|
131
|
-
|
|
131
|
+
// NOT A MARKDOWN LINK, deliberately. docs/session-log-index.md is GITIGNORED
|
|
132
|
+
// (ADR 0110 — deploy-regenerated, never committed), so a repo-relative link to
|
|
133
|
+
// it dangles in every fresh checkout. This snippet is written into the TRACKED
|
|
134
|
+
// CLAUDE.md, so linking it here minted a stale link in CI on every run: that is
|
|
135
|
+
// what pushed doc_stale_links past the fitness ratchet baseline and turned the
|
|
136
|
+
// required `unit` check red on main, blocking all PR merges and (via publish.yml's
|
|
137
|
+
// workflow_run trigger) all releases. Name the file in a code span; never re-link it.
|
|
138
|
+
L.push(`**Recent sessions** (newest ${recent.length} — full history in \`docs/session-log-index.md\`):`);
|
|
132
139
|
L.push('');
|
|
133
140
|
for (const e of recent) L.push(line(e, 'docs/session-logs/'));
|
|
134
141
|
L.push(END);
|