@claude-flow/cli 3.12.0 → 3.12.2
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/.claude/helpers/auto-memory-hook.mjs +34 -4
- package/.claude/helpers/hook-handler.cjs +17 -13
- package/.claude/helpers/intelligence.cjs +16 -2
- package/README.md +7 -4
- package/dist/src/commands/daemon.d.ts.map +1 -1
- package/dist/src/commands/daemon.js +76 -7
- package/dist/src/commands/daemon.js.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +24 -3
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/security.d.ts.map +1 -1
- package/dist/src/commands/security.js +70 -12
- package/dist/src/commands/security.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/plugins/ruflo-metaharness/.claude-flow/data/pending-insights.jsonl +1 -0
- package/plugins/ruflo-metaharness/.claude-flow/neural/stats.json +6 -0
- package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +32 -0
- package/plugins/ruflo-metaharness/README.md +64 -0
- package/plugins/ruflo-metaharness/agents/metaharness-architect.md +58 -0
- package/plugins/ruflo-metaharness/commands/ruflo-metaharness.md +46 -0
- package/plugins/ruflo-metaharness/scripts/_harness.mjs +261 -0
- package/plugins/ruflo-metaharness/scripts/_similarity.mjs +161 -0
- package/plugins/ruflo-metaharness/scripts/_spike-similarity.mjs +223 -0
- package/plugins/ruflo-metaharness/scripts/audit-list.mjs +158 -0
- package/plugins/ruflo-metaharness/scripts/audit-trend.mjs +272 -0
- package/plugins/ruflo-metaharness/scripts/bench-parse-mcp-scan.mjs +146 -0
- package/plugins/ruflo-metaharness/scripts/bench-recordpair-overhead.mjs +186 -0
- package/plugins/ruflo-metaharness/scripts/bench-similarity.mjs +177 -0
- package/plugins/ruflo-metaharness/scripts/drift-from-history.mjs +363 -0
- package/plugins/ruflo-metaharness/scripts/genome.mjs +80 -0
- package/plugins/ruflo-metaharness/scripts/mcp-scan.mjs +111 -0
- package/plugins/ruflo-metaharness/scripts/mint.mjs +119 -0
- package/plugins/ruflo-metaharness/scripts/oia-audit.mjs +228 -0
- package/plugins/ruflo-metaharness/scripts/router-parallel-analyze.mjs +250 -0
- package/plugins/ruflo-metaharness/scripts/score.mjs +92 -0
- package/plugins/ruflo-metaharness/scripts/similarity.mjs +158 -0
- package/plugins/ruflo-metaharness/scripts/smoke.sh +2268 -0
- package/plugins/ruflo-metaharness/scripts/test-graceful-degradation.mjs +141 -0
- package/plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs +437 -0
- package/plugins/ruflo-metaharness/scripts/test-parallel-pipeline.mjs +204 -0
- package/plugins/ruflo-metaharness/scripts/test-pipeline-roundtrip.mjs +586 -0
- package/plugins/ruflo-metaharness/scripts/test-similarity.mjs +334 -0
- package/plugins/ruflo-metaharness/scripts/test-with-openrouter.mjs +229 -0
- package/plugins/ruflo-metaharness/scripts/threat-model.mjs +59 -0
- package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +65 -0
- package/plugins/ruflo-metaharness/skills/harness-genome/SKILL.md +54 -0
- package/plugins/ruflo-metaharness/skills/harness-mcp-scan/SKILL.md +47 -0
- package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +72 -0
- package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +79 -0
- package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +66 -0
- package/plugins/ruflo-metaharness/skills/harness-similarity/SKILL.md +67 -0
- package/plugins/ruflo-metaharness/skills/harness-threat-model/SKILL.md +39 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-pipeline-roundtrip.mjs — end-to-end test of the ADR-152 §3.1
|
|
3
|
+
// cross-pipeline integration shipped iters 36→46.
|
|
4
|
+
//
|
|
5
|
+
// THE GAP THIS CLOSES
|
|
6
|
+
// Every prior iter test exercised ONE surface in isolation:
|
|
7
|
+
// iter 35: spike — synthetic LEGAL/SUPPORT/DEVOPS fixtures
|
|
8
|
+
// iter 37: MCP runtime test — fake mem keys
|
|
9
|
+
// iter 38: audit-trend — hand-written audit JSON
|
|
10
|
+
// iter 39: unit tests — pure-function arithmetic
|
|
11
|
+
// iter 41: bench — synthetic payloads
|
|
12
|
+
// iter 43: Phase 4 — file-path fixtures
|
|
13
|
+
// iter 46: audit_trend file inputs — same fixtures
|
|
14
|
+
//
|
|
15
|
+
// None of these proved the FULL chain works end-to-end with no
|
|
16
|
+
// synthetic data. This test does.
|
|
17
|
+
//
|
|
18
|
+
// THE PIPELINE BEING TESTED
|
|
19
|
+
// 1. oia-audit --dry-run --path . (real run against ruflo)
|
|
20
|
+
// 2. The audit record's fingerprint{score,genome}
|
|
21
|
+
// 3. audit-trend --baseline {same-record} --current {same-record}
|
|
22
|
+
// 4. delta.structuralDistance.verdict === 'near-identical'
|
|
23
|
+
// 5. delta.structuralDistance.overall === 1
|
|
24
|
+
//
|
|
25
|
+
// USAGE
|
|
26
|
+
// node scripts/test-pipeline-roundtrip.mjs
|
|
27
|
+
// node scripts/test-pipeline-roundtrip.mjs --format json
|
|
28
|
+
//
|
|
29
|
+
// EXIT CODES
|
|
30
|
+
// 0 full chain works
|
|
31
|
+
// 1 some assertion failed
|
|
32
|
+
// 2 oia-audit degraded (upstream metaharness absent — test cannot run)
|
|
33
|
+
//
|
|
34
|
+
// ADR-150 ARCHITECTURAL CONSTRAINT BEHAVIOR
|
|
35
|
+
// If oia-audit returns {degraded:true} (no upstream metaharness), the
|
|
36
|
+
// test exits 2 with a clear message — this is the "test cannot run"
|
|
37
|
+
// case, NOT a "test failed" case. CI infrastructure can distinguish.
|
|
38
|
+
|
|
39
|
+
import { spawnSync } from 'node:child_process';
|
|
40
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
41
|
+
import { tmpdir } from 'node:os';
|
|
42
|
+
import { join, dirname } from 'node:path';
|
|
43
|
+
import { fileURLToPath } from 'node:url';
|
|
44
|
+
|
|
45
|
+
const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const REPO_ROOT = dirname(dirname(dirname(SCRIPTS_DIR))); // up out of plugins/ruflo-metaharness/scripts
|
|
47
|
+
|
|
48
|
+
const ARGS = (() => {
|
|
49
|
+
const a = { format: 'table' };
|
|
50
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
51
|
+
if (process.argv[i] === '--format') a.format = process.argv[++i];
|
|
52
|
+
}
|
|
53
|
+
return a;
|
|
54
|
+
})();
|
|
55
|
+
|
|
56
|
+
let passed = 0, failed = 0;
|
|
57
|
+
const failures = [];
|
|
58
|
+
function assert(cond, label) {
|
|
59
|
+
if (cond) { console.log(` ✓ ${label}`); passed++; }
|
|
60
|
+
else { console.log(` ✗ ${label}`); failures.push(label); failed++; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runNode(scriptName, args, timeoutMs = 60_000) {
|
|
64
|
+
const p = spawnSync('node', [join(SCRIPTS_DIR, scriptName), ...args], {
|
|
65
|
+
encoding: 'utf-8',
|
|
66
|
+
timeout: timeoutMs,
|
|
67
|
+
cwd: REPO_ROOT,
|
|
68
|
+
});
|
|
69
|
+
return { stdout: p.stdout || '', stderr: p.stderr || '', status: p.status ?? -1 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ──────────────────────────────────────────────────────────────────
|
|
73
|
+
console.log(`# test-pipeline-roundtrip — ADR-152 §3.1 end-to-end (iter 47)\n`);
|
|
74
|
+
|
|
75
|
+
const tmp = mkdtempSync(join(tmpdir(), 'pipeline-roundtrip-'));
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// ──────────────────────────────────────────────────────────────
|
|
79
|
+
// STAGE 1: oia-audit --dry-run against ruflo repo itself
|
|
80
|
+
// ──────────────────────────────────────────────────────────────
|
|
81
|
+
console.log('Stage 1 — oia-audit --dry-run against ruflo repo');
|
|
82
|
+
const auditRun = runNode('oia-audit.mjs', ['--path', REPO_ROOT, '--dry-run', '--format', 'json'], 90_000);
|
|
83
|
+
|
|
84
|
+
// Extract the JSON object from stdout (script may emit some prelude)
|
|
85
|
+
const auditMatch = /\{[\s\S]*\}/.exec(auditRun.stdout);
|
|
86
|
+
if (!auditMatch) {
|
|
87
|
+
console.log(` ✗ oia-audit produced no JSON; stderr:\n${auditRun.stderr.slice(0, 500)}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const audit = JSON.parse(auditMatch[0]);
|
|
91
|
+
|
|
92
|
+
// If oia-audit reports degraded (no upstream metaharness installed), skip
|
|
93
|
+
// — this test cannot run without real score/genome output.
|
|
94
|
+
if (audit.degraded === true) {
|
|
95
|
+
console.log(` ⊘ oia-audit reports degraded — upstream metaharness absent`);
|
|
96
|
+
console.log(` This test exercises real metaharness output; cannot run.`);
|
|
97
|
+
console.log(` reason: ${audit.reason}`);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
assert(typeof audit === 'object' && audit !== null,
|
|
102
|
+
'oia-audit produced a JSON object');
|
|
103
|
+
assert(typeof audit.composite === 'object',
|
|
104
|
+
'oia-audit has composite worst-severity');
|
|
105
|
+
assert(typeof audit.components === 'object',
|
|
106
|
+
'oia-audit has components bundle');
|
|
107
|
+
|
|
108
|
+
// iter 38 — fingerprint must be present
|
|
109
|
+
assert(typeof audit.fingerprint === 'object' && audit.fingerprint !== null,
|
|
110
|
+
'oia-audit emits fingerprint field (iter 38)');
|
|
111
|
+
if (!audit.fingerprint?.score || !audit.fingerprint?.genome) {
|
|
112
|
+
console.log(` ⊘ fingerprint partial; score+genome may have degraded individually`);
|
|
113
|
+
console.log(` fingerprint: ${JSON.stringify(audit.fingerprint).slice(0, 200)}`);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
assert(typeof audit.fingerprint.score?.harnessFit === 'number',
|
|
117
|
+
'fingerprint.score has harnessFit');
|
|
118
|
+
assert(Array.isArray(audit.fingerprint.genome?.agent_topology),
|
|
119
|
+
'fingerprint.genome has agent_topology array');
|
|
120
|
+
|
|
121
|
+
// iter 49 — schema contracts beyond similarity (the OTHER class of
|
|
122
|
+
// silent drift). audit-trend reads specific upstream fields:
|
|
123
|
+
// - composite.worst (severity rollup)
|
|
124
|
+
// - components.threatModel.json.worst (audit-trend.mjs:96-100)
|
|
125
|
+
// - components.mcpScan.json.findings (audit-trend.mjs:124-127)
|
|
126
|
+
// If upstream renames any of these, audit-trend silently breaks.
|
|
127
|
+
// Gating them here forces a CI failure on the next PR after upstream
|
|
128
|
+
// schema drift, instead of months later when someone notices the
|
|
129
|
+
// severity verdict has been wrong all along.
|
|
130
|
+
assert(typeof audit.composite?.worst === 'string',
|
|
131
|
+
'iter 49 — audit.composite.worst is a string');
|
|
132
|
+
assert(['clean', 'low', 'medium', 'high'].includes(audit.composite.worst),
|
|
133
|
+
`iter 49 — composite.worst in valid severity vocab (got ${audit.composite.worst})`);
|
|
134
|
+
// threatModel.json.worst is the upstream field audit-trend reads
|
|
135
|
+
if (audit.components?.threatModel && !audit.components.threatModel.degraded) {
|
|
136
|
+
assert(typeof audit.components.threatModel.json?.worst === 'string',
|
|
137
|
+
'iter 49 — components.threatModel.json.worst is a string (audit-trend dep)');
|
|
138
|
+
}
|
|
139
|
+
// mcp-scan.mjs currently emits {rawStdout, durationMs, alert} —
|
|
140
|
+
// no structured `findings` array. audit-trend.mjs reads
|
|
141
|
+
// `json.findings` (guarded with Array.isArray fallback to []) so it
|
|
142
|
+
// doesn't crash, but the introduced/cleared logic is effectively
|
|
143
|
+
// a no-op on real output. iter 49 documents this gap rather than
|
|
144
|
+
// asserting a contract that doesn't yet hold. If a future iter
|
|
145
|
+
// promotes mcp-scan.mjs to emit structured findings, this assertion
|
|
146
|
+
// should become a real type-check.
|
|
147
|
+
if (audit.components?.mcpScan?.json?.findings !== undefined) {
|
|
148
|
+
assert(Array.isArray(audit.components.mcpScan.json.findings),
|
|
149
|
+
'iter 49 — IF mcpScan.json.findings exists THEN it must be an array');
|
|
150
|
+
} else {
|
|
151
|
+
console.log(` ⊘ mcpScan.json.findings absent (mcp-scan.mjs currently text-only — known iter-49 gap)`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ──────────────────────────────────────────────────────────────
|
|
155
|
+
// STAGE 2: persist the audit record as both baseline and current
|
|
156
|
+
// ──────────────────────────────────────────────────────────────
|
|
157
|
+
console.log('\nStage 2 — write audit record to baseline + current paths');
|
|
158
|
+
const basePath = join(tmp, 'baseline.json');
|
|
159
|
+
const currPath = join(tmp, 'current.json');
|
|
160
|
+
writeFileSync(basePath, JSON.stringify(audit));
|
|
161
|
+
writeFileSync(currPath, JSON.stringify(audit));
|
|
162
|
+
assert(readFileSync(basePath, 'utf-8').length > 100, 'baseline file written');
|
|
163
|
+
assert(readFileSync(currPath, 'utf-8').length > 100, 'current file written');
|
|
164
|
+
|
|
165
|
+
// ──────────────────────────────────────────────────────────────
|
|
166
|
+
// STAGE 3: audit-trend reading the same audit twice
|
|
167
|
+
// ──────────────────────────────────────────────────────────────
|
|
168
|
+
console.log('\nStage 3 — audit-trend against self (must report near-identical)');
|
|
169
|
+
const trendRun = runNode('audit-trend.mjs', [
|
|
170
|
+
'--baseline', basePath,
|
|
171
|
+
'--current', currPath,
|
|
172
|
+
'--format', 'json',
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
const trendMatch = /\{[\s\S]*\}/.exec(trendRun.stdout);
|
|
176
|
+
assert(trendMatch !== null, 'audit-trend produced JSON');
|
|
177
|
+
const trend = JSON.parse(trendMatch[0]);
|
|
178
|
+
|
|
179
|
+
assert(typeof trend.delta?.structuralDistance === 'object',
|
|
180
|
+
'trend exposes delta.structuralDistance');
|
|
181
|
+
const sd = trend.delta.structuralDistance;
|
|
182
|
+
assert(sd.verdict === 'near-identical',
|
|
183
|
+
`self-roundtrip verdict === near-identical (got ${sd.verdict})`);
|
|
184
|
+
assert(sd.overall === 1,
|
|
185
|
+
`self-roundtrip overall === 1 (got ${sd.overall})`);
|
|
186
|
+
assert(sd.distance === 0,
|
|
187
|
+
`self-roundtrip distance === 0 (got ${sd.distance})`);
|
|
188
|
+
|
|
189
|
+
// ──────────────────────────────────────────────────────────────
|
|
190
|
+
// STAGE 4: alert-on-distance-below should NOT trigger on self-match
|
|
191
|
+
// ──────────────────────────────────────────────────────────────
|
|
192
|
+
console.log('\nStage 4 — distance alert does not trigger on self-match');
|
|
193
|
+
const noAlertRun = runNode('audit-trend.mjs', [
|
|
194
|
+
'--baseline', basePath,
|
|
195
|
+
'--current', currPath,
|
|
196
|
+
'--alert-on-distance-below', '0.5',
|
|
197
|
+
'--format', 'json',
|
|
198
|
+
]);
|
|
199
|
+
assert(noAlertRun.status === 0,
|
|
200
|
+
`alert at threshold 0.5 does NOT fire on self-match (exit 0, got ${noAlertRun.status})`);
|
|
201
|
+
|
|
202
|
+
// Now flip: any threshold above 1 must trigger
|
|
203
|
+
console.log('\nStage 5 — distance alert triggers when threshold > self-match');
|
|
204
|
+
const alertRun = runNode('audit-trend.mjs', [
|
|
205
|
+
'--baseline', basePath,
|
|
206
|
+
'--current', currPath,
|
|
207
|
+
'--alert-on-distance-below', '1.01',
|
|
208
|
+
'--format', 'json',
|
|
209
|
+
]);
|
|
210
|
+
assert(alertRun.status === 1,
|
|
211
|
+
`alert at threshold 1.01 fires on self-match (exit 1, got ${alertRun.status})`);
|
|
212
|
+
|
|
213
|
+
// ──────────────────────────────────────────────────────────────
|
|
214
|
+
// STAGE 6 (iter 49) — non-similarity schema contracts in the trend
|
|
215
|
+
//
|
|
216
|
+
// audit-trend reads `composite.worst`, `components.threatModel.json.worst`,
|
|
217
|
+
// and `components.mcpScan.json.findings`. The trend output's
|
|
218
|
+
// delta.worst.verdict on a self-match must be 'unchanged'. If upstream
|
|
219
|
+
// renames any of these fields, the verdict silently becomes 'missing'
|
|
220
|
+
// or undefined — this assertion catches that BEFORE shipping.
|
|
221
|
+
// ──────────────────────────────────────────────────────────────
|
|
222
|
+
// ──────────────────────────────────────────────────────────────
|
|
223
|
+
// STAGE 7 (iter 51) — drift detection actually FIRES on mutated input
|
|
224
|
+
//
|
|
225
|
+
// Every prior stage uses the same audit record twice (self-match).
|
|
226
|
+
// That proves the chain doesn't FALSELY report drift on identical
|
|
227
|
+
// input, but doesn't prove the chain catches REAL drift. This stage
|
|
228
|
+
// takes the audit record and synthetically mutates fields the
|
|
229
|
+
// similarity module reads, then asserts the chain detects the
|
|
230
|
+
// difference.
|
|
231
|
+
//
|
|
232
|
+
// Mutations applied (all measurable by similarity):
|
|
233
|
+
// - harnessFit: unchanged → -20 (cosine signal)
|
|
234
|
+
// - taskCoverage: unchanged → -15 (cosine signal)
|
|
235
|
+
// - agent_topology: add one → jaccard divergence
|
|
236
|
+
//
|
|
237
|
+
// Expected outcome:
|
|
238
|
+
// - structuralDistance.verdict !== 'near-identical'
|
|
239
|
+
// - structuralDistance.overall < 1
|
|
240
|
+
// - --alert-on-distance-below 0.95 should fire
|
|
241
|
+
// ──────────────────────────────────────────────────────────────
|
|
242
|
+
console.log('\nStage 7 — drift detection on mutated audit (iter 51)');
|
|
243
|
+
const mutatedPath = join(tmp, 'mutated.json');
|
|
244
|
+
const mutated = JSON.parse(JSON.stringify(audit));
|
|
245
|
+
// Modify fields the similarity module actually reads. The mutation has
|
|
246
|
+
// to be large enough to cross the verdict-bucket threshold (0.95 →
|
|
247
|
+
// 'near-identical' boundary). The 9-dim cosine vector is dominated by
|
|
248
|
+
// values already clustered around the same range, so single-field
|
|
249
|
+
// changes don't move overall much. Mutate across all 3 components:
|
|
250
|
+
// - cosine: drop 4 scorecard fields by 25-40 points
|
|
251
|
+
// - categorical: swap archetype + template (2 of 4 enums diverge)
|
|
252
|
+
// - jaccard: rebuild agent_topology with a different set
|
|
253
|
+
mutated.fingerprint.score.harnessFit = Math.max(0, audit.fingerprint.score.harnessFit - 40);
|
|
254
|
+
mutated.fingerprint.score.taskCoverage = Math.max(0, audit.fingerprint.score.taskCoverage - 35);
|
|
255
|
+
mutated.fingerprint.score.toolSafety = Math.max(0, audit.fingerprint.score.toolSafety - 30);
|
|
256
|
+
mutated.fingerprint.score.compileConfidence = Math.max(0, audit.fingerprint.score.compileConfidence - 25);
|
|
257
|
+
mutated.fingerprint.score.archetype = 'iter-51-synthetic-archetype';
|
|
258
|
+
mutated.fingerprint.score.template = 'iter-51-synthetic-template';
|
|
259
|
+
mutated.fingerprint.genome.agent_topology = ['iter-51-marker-a', 'iter-51-marker-b'];
|
|
260
|
+
writeFileSync(mutatedPath, JSON.stringify(mutated));
|
|
261
|
+
|
|
262
|
+
const driftRun = runNode('audit-trend.mjs', [
|
|
263
|
+
'--baseline', basePath,
|
|
264
|
+
'--current', mutatedPath,
|
|
265
|
+
'--format', 'json',
|
|
266
|
+
]);
|
|
267
|
+
const driftMatch = /\{[\s\S]*\}/.exec(driftRun.stdout);
|
|
268
|
+
assert(driftMatch !== null, 'drift run produced JSON');
|
|
269
|
+
const driftTrend = JSON.parse(driftMatch[0]);
|
|
270
|
+
const driftSd = driftTrend.delta?.structuralDistance;
|
|
271
|
+
assert(typeof driftSd === 'object', 'drift trend exposes structuralDistance');
|
|
272
|
+
assert(driftSd.verdict !== 'near-identical',
|
|
273
|
+
`drift detected — verdict !== near-identical (got ${driftSd?.verdict})`);
|
|
274
|
+
assert(driftSd.overall < 1,
|
|
275
|
+
`drift detected — overall < 1 (got ${driftSd?.overall})`);
|
|
276
|
+
assert(driftSd.distance > 0,
|
|
277
|
+
`drift detected — distance > 0 (got ${driftSd?.distance})`);
|
|
278
|
+
|
|
279
|
+
// Alert chain: --alert-on-distance-below 0.95 must fire IF drift > 0.05
|
|
280
|
+
// If the synthetic mutation produced overall ≥ 0.95, lower the threshold.
|
|
281
|
+
const threshold = driftSd.overall < 0.95 ? '0.95' : String(driftSd.overall + 0.01);
|
|
282
|
+
const alertDriftRun = runNode('audit-trend.mjs', [
|
|
283
|
+
'--baseline', basePath,
|
|
284
|
+
'--current', mutatedPath,
|
|
285
|
+
'--alert-on-distance-below', threshold,
|
|
286
|
+
'--format', 'json',
|
|
287
|
+
]);
|
|
288
|
+
assert(alertDriftRun.status === 1,
|
|
289
|
+
`drift alert at threshold ${threshold} fires on mutated audit (exit 1, got ${alertDriftRun.status})`);
|
|
290
|
+
|
|
291
|
+
// ──────────────────────────────────────────────────────────────
|
|
292
|
+
console.log('\nStage 6 — non-similarity schema contracts (iter 49)');
|
|
293
|
+
assert(typeof trend.delta?.worst === 'object',
|
|
294
|
+
'trend exposes delta.worst (severity-rollup)');
|
|
295
|
+
assert(trend.delta.worst.verdict === 'unchanged',
|
|
296
|
+
`self-roundtrip severity-verdict === unchanged (got ${trend.delta.worst.verdict})`);
|
|
297
|
+
assert(trend.delta.worst.baseline === trend.delta.worst.current,
|
|
298
|
+
'self-roundtrip baseline === current severity');
|
|
299
|
+
assert(trend.delta.worst.rankDelta === 0,
|
|
300
|
+
`self-roundtrip rankDelta === 0 (got ${trend.delta.worst.rankDelta})`);
|
|
301
|
+
// Findings arrays: introduced/cleared must both be 0 on self-match
|
|
302
|
+
assert(trend.delta?.findings?.introducedCount === 0,
|
|
303
|
+
`self-roundtrip introducedCount === 0 (got ${trend.delta?.findings?.introducedCount})`);
|
|
304
|
+
assert(trend.delta?.findings?.clearedCount === 0,
|
|
305
|
+
`self-roundtrip clearedCount === 0 (got ${trend.delta?.findings?.clearedCount})`);
|
|
306
|
+
|
|
307
|
+
// ──────────────────────────────────────────────────────────────
|
|
308
|
+
// STAGE 8 (iter 68) — drift-from-history end-to-end via --baseline-file
|
|
309
|
+
//
|
|
310
|
+
// Every prior stage tested audit-trend (the underlying primitive).
|
|
311
|
+
// None tested drift-from-history (the iter-53 user-facing wrapper).
|
|
312
|
+
// This stage runs the iter-67 fastest path: baseline from a file,
|
|
313
|
+
// skipping audit-list AND memory roundtrip entirely.
|
|
314
|
+
// ──────────────────────────────────────────────────────────────
|
|
315
|
+
console.log('\nStage 8 — drift-from-history end-to-end (iter 68)');
|
|
316
|
+
const driftFromHistoryRun = runNode('drift-from-history.mjs', [
|
|
317
|
+
'--baseline-file', basePath,
|
|
318
|
+
'--dry-run',
|
|
319
|
+
'--format', 'json',
|
|
320
|
+
], 120_000);
|
|
321
|
+
const driftFromHistoryMatch = /\{[\s\S]*\}/.exec(driftFromHistoryRun.stdout);
|
|
322
|
+
assert(driftFromHistoryMatch !== null, 'drift-from-history produced JSON');
|
|
323
|
+
const dfh = JSON.parse(driftFromHistoryMatch[0]);
|
|
324
|
+
|
|
325
|
+
// Iter-66/iter-67 contract: the fastest-path flags are surfaced in timing
|
|
326
|
+
assert(dfh.timing?.skippedAuditList === true,
|
|
327
|
+
'drift-from-history --baseline-file → skippedAuditList === true (iter 66)');
|
|
328
|
+
assert(dfh.timing?.usedBaselineFile === true,
|
|
329
|
+
'drift-from-history --baseline-file → usedBaselineFile === true (iter 67)');
|
|
330
|
+
// Wall-clock fastpath proof: must be under 30s with file inputs
|
|
331
|
+
// (vs ~26s minimum for the audit-list-loading slow path)
|
|
332
|
+
assert(typeof dfh.timing?.parallelWallMs === 'number' && dfh.timing.parallelWallMs < 30000,
|
|
333
|
+
`drift-from-history fastpath wall < 30s (got ${dfh.timing?.parallelWallMs}ms)`);
|
|
334
|
+
// Same-file self-match → similarity 1.0
|
|
335
|
+
assert(dfh.drift?.structuralDistance?.overall === 1,
|
|
336
|
+
`drift-from-history self-match overall === 1 (got ${dfh.drift?.structuralDistance?.overall})`);
|
|
337
|
+
assert(dfh.drift?.structuralDistance?.verdict === 'near-identical',
|
|
338
|
+
`drift-from-history self-match verdict === near-identical (got ${dfh.drift?.structuralDistance?.verdict})`);
|
|
339
|
+
// Alert chain end-to-end
|
|
340
|
+
assert(dfh.alert?.triggered === false,
|
|
341
|
+
'drift-from-history self-match alert NOT triggered at default threshold (0.95)');
|
|
342
|
+
|
|
343
|
+
// ──────────────────────────────────────────────────────────────
|
|
344
|
+
// STAGE 9 (iter 75) — drift-from-history detects drift via fast path
|
|
345
|
+
//
|
|
346
|
+
// Stage 8 proved the fast path correctly identifies self-match.
|
|
347
|
+
// This stage proves it also CATCHES drift — feeds a mutated baseline
|
|
348
|
+
// file and asserts the verdict flips. Without this, a regression
|
|
349
|
+
// that breaks the fast path's similarity computation would still
|
|
350
|
+
// pass Stage 8 (which only checks the identity case).
|
|
351
|
+
//
|
|
352
|
+
// Mutation: re-uses Stage 7's mutated audit shape, written to a
|
|
353
|
+
// separate baseline file. drift-from-history runs fresh oia-audit
|
|
354
|
+
// against current state and diffs against the mutated baseline.
|
|
355
|
+
// ──────────────────────────────────────────────────────────────
|
|
356
|
+
console.log('\nStage 9 — drift-from-history fastpath catches mutation (iter 75)');
|
|
357
|
+
// mutatedPath was already populated in Stage 7. Re-use as baseline-file.
|
|
358
|
+
const dfhDriftRun = runNode('drift-from-history.mjs', [
|
|
359
|
+
'--baseline-file', mutatedPath,
|
|
360
|
+
'--dry-run',
|
|
361
|
+
'--threshold', '0.95',
|
|
362
|
+
'--format', 'json',
|
|
363
|
+
], 120_000);
|
|
364
|
+
const dfhDriftMatch = /\{[\s\S]*\}/.exec(dfhDriftRun.stdout);
|
|
365
|
+
assert(dfhDriftMatch !== null, 'drift-from-history with mutated baseline produced JSON');
|
|
366
|
+
const dfhDrift = JSON.parse(dfhDriftMatch[0]);
|
|
367
|
+
|
|
368
|
+
// Fast path still fired
|
|
369
|
+
assert(dfhDrift.timing?.usedBaselineFile === true,
|
|
370
|
+
'Stage 9 fastpath: usedBaselineFile === true');
|
|
371
|
+
|
|
372
|
+
// Drift detected — same invariants as Stage 7 but via the wrapper
|
|
373
|
+
const dfhSd = dfhDrift.drift?.structuralDistance;
|
|
374
|
+
assert(typeof dfhSd === 'object', 'Stage 9: structuralDistance present');
|
|
375
|
+
assert(dfhSd.verdict !== 'near-identical',
|
|
376
|
+
`Stage 9: verdict !== near-identical (got ${dfhSd?.verdict}) — fastpath catches drift`);
|
|
377
|
+
assert(dfhSd.overall < 1,
|
|
378
|
+
`Stage 9: overall < 1 (got ${dfhSd?.overall})`);
|
|
379
|
+
assert(dfhSd.distance > 0,
|
|
380
|
+
`Stage 9: distance > 0 (got ${dfhSd?.distance})`);
|
|
381
|
+
|
|
382
|
+
// Alert end-to-end via the fast path
|
|
383
|
+
// (mutation pushes overall ~0.7, below default 0.95 threshold → triggered)
|
|
384
|
+
assert(dfhDrift.alert?.triggered === true,
|
|
385
|
+
'Stage 9: --threshold 0.95 fires on mutated baseline via fastpath');
|
|
386
|
+
// Exit code reflects alert
|
|
387
|
+
assert(dfhDriftRun.status === 1,
|
|
388
|
+
`Stage 9: drift-from-history exit=1 when alert triggered (got ${dfhDriftRun.status})`);
|
|
389
|
+
|
|
390
|
+
// ──────────────────────────────────────────────────────────────
|
|
391
|
+
// STAGE 10 (iter 76) — introduced/cleared findings diff actually works
|
|
392
|
+
//
|
|
393
|
+
// Iter 49 documented that mcp-scan emitted text only → audit-trend's
|
|
394
|
+
// findings.introducedCount was dead code. Iter 50 added the parser
|
|
395
|
+
// so findings ARE populated. But no test verified the diff on a
|
|
396
|
+
// REAL finding-set change — Stage 7 mutates only the fingerprint
|
|
397
|
+
// (score/genome), leaving mcpScan.json.findings unchanged.
|
|
398
|
+
//
|
|
399
|
+
// This stage takes the real audit (Stage 1) and mutates ONLY
|
|
400
|
+
// mcpScan.json.findings — adds a synthetic finding to the baseline.
|
|
401
|
+
// audit-trend should report clearedCount === 1 (the synthetic
|
|
402
|
+
// disappeared in the current audit) with severity preserved.
|
|
403
|
+
// ──────────────────────────────────────────────────────────────
|
|
404
|
+
console.log('\nStage 10 — introduced/cleared findings diff (iter 76 — locks iter-50 fix)');
|
|
405
|
+
const findingsBasePath = join(tmp, 'findings-base.json');
|
|
406
|
+
const findingsMutated = JSON.parse(JSON.stringify(audit));
|
|
407
|
+
// Ensure mcpScan.json.findings exists (it should post-iter-50)
|
|
408
|
+
if (!findingsMutated.components?.mcpScan?.json?.findings) {
|
|
409
|
+
findingsMutated.components.mcpScan = findingsMutated.components.mcpScan ?? {};
|
|
410
|
+
findingsMutated.components.mcpScan.json = findingsMutated.components.mcpScan.json ?? {};
|
|
411
|
+
findingsMutated.components.mcpScan.json.findings = [];
|
|
412
|
+
}
|
|
413
|
+
// Append a synthetic finding that wouldn't appear in a fresh audit.
|
|
414
|
+
findingsMutated.components.mcpScan.json.findings.push({
|
|
415
|
+
severity: 'medium',
|
|
416
|
+
id: 'iter-76-synthetic-finding',
|
|
417
|
+
server: 'synthetic-server',
|
|
418
|
+
tool: 'synthetic-tool',
|
|
419
|
+
message: 'Stage 10 synthetic finding for clearedCount verification',
|
|
420
|
+
});
|
|
421
|
+
writeFileSync(findingsBasePath, JSON.stringify(findingsMutated));
|
|
422
|
+
|
|
423
|
+
// Diff: baseline has the synthetic, current (real audit) doesn't →
|
|
424
|
+
// expect clearedCount === 1, introducedCount === 0.
|
|
425
|
+
const findingsTrendRun = runNode('audit-trend.mjs', [
|
|
426
|
+
'--baseline', findingsBasePath,
|
|
427
|
+
'--current', basePath, // basePath = unmodified audit
|
|
428
|
+
'--format', 'json',
|
|
429
|
+
], 60_000);
|
|
430
|
+
const findingsTrendMatch = /\{[\s\S]*\}/.exec(findingsTrendRun.stdout);
|
|
431
|
+
assert(findingsTrendMatch !== null, 'Stage 10: audit-trend produced JSON');
|
|
432
|
+
const findingsTrend = JSON.parse(findingsTrendMatch[0]);
|
|
433
|
+
|
|
434
|
+
assert(findingsTrend.delta?.findings?.clearedCount === 1,
|
|
435
|
+
`Stage 10: clearedCount === 1 (got ${findingsTrend.delta?.findings?.clearedCount}) — iter-50 fix functional`);
|
|
436
|
+
assert(findingsTrend.delta?.findings?.introducedCount === 0,
|
|
437
|
+
`Stage 10: introducedCount === 0 (got ${findingsTrend.delta?.findings?.introducedCount})`);
|
|
438
|
+
// Cleared finding's severity preserved through the diff
|
|
439
|
+
const cleared = findingsTrend.delta?.findings?.cleared?.[0];
|
|
440
|
+
assert(cleared?.severity === 'medium',
|
|
441
|
+
`Stage 10: cleared finding severity preserved (got ${cleared?.severity})`);
|
|
442
|
+
assert(cleared?.id === 'iter-76-synthetic-finding',
|
|
443
|
+
'Stage 10: cleared finding id preserved');
|
|
444
|
+
|
|
445
|
+
// ──────────────────────────────────────────────────────────────
|
|
446
|
+
// STAGE 11 (iter 77) — introducedCount symmetry + dedup discrimination
|
|
447
|
+
//
|
|
448
|
+
// Stage 10 proved the CLEARED side. Three more cases to lock the
|
|
449
|
+
// diff logic completely:
|
|
450
|
+
//
|
|
451
|
+
// 11a. introducedCount: baseline clean, current has synthetic
|
|
452
|
+
// 11b. both-different: baseline has finding A, current has finding B
|
|
453
|
+
// → introduced=1, cleared=1
|
|
454
|
+
// 11c. identical: baseline + current have SAME finding
|
|
455
|
+
// → both counters stay at 0 (dedup works)
|
|
456
|
+
// ──────────────────────────────────────────────────────────────
|
|
457
|
+
console.log('\nStage 11 — introduced/cleared symmetric + dedup (iter 77)');
|
|
458
|
+
const findingA = {
|
|
459
|
+
severity: 'medium',
|
|
460
|
+
id: 'iter-77-finding-A',
|
|
461
|
+
server: 'server-A',
|
|
462
|
+
tool: 'tool-A',
|
|
463
|
+
message: 'finding A',
|
|
464
|
+
};
|
|
465
|
+
const findingB = {
|
|
466
|
+
severity: 'high',
|
|
467
|
+
id: 'iter-77-finding-B',
|
|
468
|
+
server: 'server-B',
|
|
469
|
+
tool: 'tool-B',
|
|
470
|
+
message: 'finding B',
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// 11a — introduced
|
|
474
|
+
const baseClean = JSON.parse(JSON.stringify(audit));
|
|
475
|
+
baseClean.components.mcpScan.json.findings = [];
|
|
476
|
+
const currentWithA = JSON.parse(JSON.stringify(audit));
|
|
477
|
+
currentWithA.components.mcpScan.json.findings = [findingA];
|
|
478
|
+
const baseCleanPath = join(tmp, 'base-clean.json');
|
|
479
|
+
const currentWithAPath = join(tmp, 'current-A.json');
|
|
480
|
+
writeFileSync(baseCleanPath, JSON.stringify(baseClean));
|
|
481
|
+
writeFileSync(currentWithAPath, JSON.stringify(currentWithA));
|
|
482
|
+
const trendRun11a = runNode('audit-trend.mjs', [
|
|
483
|
+
'--baseline', baseCleanPath, '--current', currentWithAPath, '--format', 'json',
|
|
484
|
+
], 60_000);
|
|
485
|
+
const m11a = /\{[\s\S]*\}/.exec(trendRun11a.stdout);
|
|
486
|
+
const trend11a = JSON.parse(m11a[0]);
|
|
487
|
+
assert(trend11a.delta?.findings?.introducedCount === 1,
|
|
488
|
+
`Stage 11a: introducedCount === 1 (got ${trend11a.delta?.findings?.introducedCount})`);
|
|
489
|
+
assert(trend11a.delta?.findings?.clearedCount === 0,
|
|
490
|
+
`Stage 11a: clearedCount === 0 (got ${trend11a.delta?.findings?.clearedCount})`);
|
|
491
|
+
assert(trend11a.delta?.findings?.introduced?.[0]?.id === 'iter-77-finding-A',
|
|
492
|
+
'Stage 11a: introduced finding id preserved');
|
|
493
|
+
|
|
494
|
+
// 11b — both different
|
|
495
|
+
const baselineWithB = JSON.parse(JSON.stringify(audit));
|
|
496
|
+
baselineWithB.components.mcpScan.json.findings = [findingB];
|
|
497
|
+
const baselineWithBPath = join(tmp, 'baseline-B.json');
|
|
498
|
+
writeFileSync(baselineWithBPath, JSON.stringify(baselineWithB));
|
|
499
|
+
const trendRun11b = runNode('audit-trend.mjs', [
|
|
500
|
+
'--baseline', baselineWithBPath, '--current', currentWithAPath, '--format', 'json',
|
|
501
|
+
], 60_000);
|
|
502
|
+
const m11b = /\{[\s\S]*\}/.exec(trendRun11b.stdout);
|
|
503
|
+
const trend11b = JSON.parse(m11b[0]);
|
|
504
|
+
assert(trend11b.delta?.findings?.introducedCount === 1,
|
|
505
|
+
`Stage 11b: introducedCount === 1 (got ${trend11b.delta?.findings?.introducedCount})`);
|
|
506
|
+
assert(trend11b.delta?.findings?.clearedCount === 1,
|
|
507
|
+
`Stage 11b: clearedCount === 1 (got ${trend11b.delta?.findings?.clearedCount})`);
|
|
508
|
+
// Dedup discriminates: B was cleared, A was introduced
|
|
509
|
+
assert(trend11b.delta?.findings?.cleared?.[0]?.id === 'iter-77-finding-B',
|
|
510
|
+
'Stage 11b: dedup correctly identifies B as cleared');
|
|
511
|
+
assert(trend11b.delta?.findings?.introduced?.[0]?.id === 'iter-77-finding-A',
|
|
512
|
+
'Stage 11b: dedup correctly identifies A as introduced');
|
|
513
|
+
|
|
514
|
+
// 11c — identical findings on both sides
|
|
515
|
+
const trendRun11c = runNode('audit-trend.mjs', [
|
|
516
|
+
'--baseline', currentWithAPath, '--current', currentWithAPath, '--format', 'json',
|
|
517
|
+
], 60_000);
|
|
518
|
+
const m11c = /\{[\s\S]*\}/.exec(trendRun11c.stdout);
|
|
519
|
+
const trend11c = JSON.parse(m11c[0]);
|
|
520
|
+
assert(trend11c.delta?.findings?.introducedCount === 0,
|
|
521
|
+
`Stage 11c: identical findings → introducedCount === 0 (dedup works)`);
|
|
522
|
+
assert(trend11c.delta?.findings?.clearedCount === 0,
|
|
523
|
+
`Stage 11c: identical findings → clearedCount === 0`);
|
|
524
|
+
|
|
525
|
+
// ──────────────────────────────────────────────────────────────
|
|
526
|
+
// STAGE 12 (iter 79) — --alert-on-new-severity orthogonal gate
|
|
527
|
+
//
|
|
528
|
+
// Iter 78 added the gate. Iter 79 wires it into the weekly cron.
|
|
529
|
+
// This stage verifies the gate fires correctly through the
|
|
530
|
+
// drift-from-history wrapper:
|
|
531
|
+
// - baseline has no findings + current has 1 medium finding
|
|
532
|
+
// - --threshold 0.5 (similarity stays above)
|
|
533
|
+
// - --alert-on-new-severity medium should trigger
|
|
534
|
+
//
|
|
535
|
+
// Catches the "structural similarity high but new security
|
|
536
|
+
// finding" case that iter-78 closed.
|
|
537
|
+
// ──────────────────────────────────────────────────────────────
|
|
538
|
+
console.log('\nStage 12 — --alert-on-new-severity orthogonal gate (iter 79)');
|
|
539
|
+
|
|
540
|
+
// Re-use baseClean (no findings) from Stage 11
|
|
541
|
+
// Run drift-from-history with --alert-on-new-severity info
|
|
542
|
+
// (real ruflo audit has 1 INFO finding → introduced=1)
|
|
543
|
+
const stage12Run = runNode('drift-from-history.mjs', [
|
|
544
|
+
'--baseline-file', baseCleanPath,
|
|
545
|
+
'--dry-run',
|
|
546
|
+
'--threshold', '0.5',
|
|
547
|
+
'--alert-on-new-severity', 'info',
|
|
548
|
+
'--format', 'json',
|
|
549
|
+
], 120_000);
|
|
550
|
+
const m12 = /\{[\s\S]*\}/.exec(stage12Run.stdout);
|
|
551
|
+
assert(m12 !== null, 'Stage 12: drift-from-history produced JSON');
|
|
552
|
+
const stage12 = JSON.parse(m12[0]);
|
|
553
|
+
|
|
554
|
+
// The orthogonal gate fires
|
|
555
|
+
assert(stage12.alert?.triggered === true,
|
|
556
|
+
'Stage 12: --alert-on-new-severity info triggers on ruflo current INFO finding');
|
|
557
|
+
// Reason mentions severity, not similarity (structural is fine at threshold 0.5)
|
|
558
|
+
const reasons = stage12.alert?.reasons ?? [];
|
|
559
|
+
const hasSeverityReason = reasons.some((r) => /finding\(s\) at or above/.test(r));
|
|
560
|
+
assert(hasSeverityReason,
|
|
561
|
+
`Stage 12: reasons mention new-finding severity (got ${JSON.stringify(reasons)})`);
|
|
562
|
+
// elevatedFindings populated
|
|
563
|
+
assert(Array.isArray(stage12.alert?.elevatedFindings) && stage12.alert.elevatedFindings.length >= 1,
|
|
564
|
+
`Stage 12: elevatedFindings non-empty (got ${stage12.alert?.elevatedFindings?.length})`);
|
|
565
|
+
// CLI exit reflects the alert
|
|
566
|
+
assert(stage12Run.status === 1,
|
|
567
|
+
`Stage 12: drift-from-history exit=1 on severity alert (got ${stage12Run.status})`);
|
|
568
|
+
// newSeverityThreshold echoed in payload
|
|
569
|
+
assert(stage12.alert?.newSeverityThreshold === 'info',
|
|
570
|
+
'Stage 12: alert.newSeverityThreshold echoed');
|
|
571
|
+
|
|
572
|
+
} finally {
|
|
573
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ──────────────────────────────────────────────────────────────────
|
|
577
|
+
const summary = { passed, failed, total: passed + failed, failures };
|
|
578
|
+
|
|
579
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
580
|
+
if (ARGS.format === 'json') console.log(JSON.stringify(summary, null, 2));
|
|
581
|
+
if (failed > 0) {
|
|
582
|
+
console.log('\nFailures:');
|
|
583
|
+
for (const f of failures) console.log(` - ${f}`);
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
console.log('\n✓ Full ADR-152 §3.1 pipeline works end-to-end with real metaharness output.');
|