@sabaiway/agent-workflow-kit 1.38.0 → 1.40.0
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/CHANGELOG.md +108 -0
- package/README.md +6 -5
- package/SKILL.md +7 -3
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/doc-parity.md +18 -0
- package/references/modes/fold-completeness.md +10 -6
- package/references/modes/gates.md +5 -3
- package/references/modes/grounding.md +4 -3
- package/references/modes/review-ledger.md +10 -6
- package/references/modes/review-state.md +1 -0
- package/references/modes/upgrade.md +3 -1
- package/references/modes/velocity.md +1 -1
- package/references/scripts/archive-decisions.mjs +39 -2
- package/references/scripts/archive-decisions.test.mjs +118 -1
- package/references/scripts/check-docs-size.mjs +50 -24
- package/references/scripts/check-docs-size.test.mjs +73 -1
- package/references/templates/agent_rules.md +1 -0
- package/references/templates/verification-profile.json +10 -0
- package/tools/changed-surface.mjs +294 -0
- package/tools/commands.mjs +10 -3
- package/tools/doc-parity.mjs +139 -0
- package/tools/fold-completeness-run.mjs +518 -252
- package/tools/fold-completeness.mjs +184 -66
- package/tools/grounding.mjs +92 -5
- package/tools/lcov.mjs +127 -0
- package/tools/procedures.mjs +1 -1
- package/tools/review-ledger-write.mjs +253 -50
- package/tools/review-ledger.mjs +382 -53
- package/tools/review-state.mjs +69 -3
- package/tools/run-gates.mjs +101 -11
- package/tools/sarif.mjs +52 -0
- package/tools/verification-profile.mjs +219 -0
package/tools/review-state.mjs
CHANGED
|
@@ -52,6 +52,11 @@ export const PLANS_REL = 'docs/plans';
|
|
|
52
52
|
const ACTIVITY = 'plan-execution';
|
|
53
53
|
const SLOT = 'review';
|
|
54
54
|
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate silently
|
|
55
|
+
// --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
|
|
56
|
+
// a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
|
|
57
|
+
// injectable clock) so hermetic tests never spend wall-clock.
|
|
58
|
+
export const DEFAULT_AWAIT_TIMEOUT_S = 900;
|
|
59
|
+
export const AWAIT_POLL_MS = 5000;
|
|
55
60
|
|
|
56
61
|
// ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
|
|
57
62
|
|
|
@@ -387,11 +392,72 @@ export const main = (argv, ctx = {}) => {
|
|
|
387
392
|
}
|
|
388
393
|
};
|
|
389
394
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
395
|
+
// ── --await: block until every recipe-named backend has receipted the current tree ─────
|
|
396
|
+
// (BUGFREE-3 / AD-049, item (d)). It waits for ALL recipe-named backends — review-state has NO
|
|
397
|
+
// durable degraded-backend source before a round is recorded (degraded is a review-LEDGER round
|
|
398
|
+
// field, not a review-state input), so "non-degraded" is not knowable here; awaiting a backend the
|
|
399
|
+
// operator KNOWS is degraded is the operator's call (don't --await it). The completion signal is the
|
|
400
|
+
// RECEIPT (i.e. `--check` would PASS), NEVER a process event — a harness "completed" notification
|
|
401
|
+
// fires early and a bridge's output late-flushes, so polling a pid/receipt-file is the durable
|
|
402
|
+
// mechanization of receipts-not-pgrep. Stays read-only (it only re-reads state); the clock is
|
|
403
|
+
// injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests never spend wall-clock.
|
|
404
|
+
|
|
405
|
+
const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
|
|
406
|
+
|
|
407
|
+
const parseAwaitTimeoutS = (argv) => {
|
|
408
|
+
const i = argv.indexOf('--timeout');
|
|
409
|
+
if (i === -1) return DEFAULT_AWAIT_TIMEOUT_S;
|
|
410
|
+
const raw = argv[i + 1];
|
|
411
|
+
if (!raw || !/^\d+$/.test(raw) || Number(raw) < 1) throw fail(2, '--timeout requires a positive integer number of seconds');
|
|
412
|
+
return Number(raw);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
export const mainAwait = async (argv, ctx = {}) => {
|
|
416
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
417
|
+
const env = ctx.env ?? process.env;
|
|
418
|
+
const detect = ctx.detect ?? detectBackends;
|
|
419
|
+
const now = ctx.now ?? (() => Date.now());
|
|
420
|
+
const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
421
|
+
const pollMs = ctx.pollMs ?? AWAIT_POLL_MS;
|
|
422
|
+
try {
|
|
423
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
424
|
+
const a = argv[i];
|
|
425
|
+
if (a === '--timeout') { i += 1; continue; } // its value is consumed by parseAwaitTimeoutS
|
|
426
|
+
if (!AWAIT_ALLOWED_ARGS.has(a)) throw fail(2, `--await accepts only --timeout <s> (got ${a})`);
|
|
427
|
+
}
|
|
428
|
+
const timeoutS = parseAwaitTimeoutS(argv);
|
|
429
|
+
const timeoutMs = timeoutS * 1000;
|
|
430
|
+
const start = now();
|
|
431
|
+
// Poll the SAME normative decision --check computes: ready == `--check` would pass (solo / no
|
|
432
|
+
// plan / a clean tree / not-a-git-tree all resolve instantly — nothing to await). Re-read state
|
|
433
|
+
// every poll so a landed receipt (or a tree edit that re-staled one) is seen fresh. The DEADLINE
|
|
434
|
+
// is checked BEFORE readiness (codex council R2): once elapsed reaches the timeout the await is
|
|
435
|
+
// over, so a receipt that only lands AT/after the deadline never flips it to READY; and each
|
|
436
|
+
// sleep is BOUNDED to the remaining time so a full poll interval can never overshoot the timeout.
|
|
437
|
+
let lastReason = 'no poll completed before the deadline';
|
|
438
|
+
for (;;) {
|
|
439
|
+
const elapsed = now() - start;
|
|
440
|
+
if (elapsed >= timeoutMs) return { code: 1, stdout: '', stderr: `review-state --await: TIMEOUT after ${timeoutS}s — ${lastReason}` };
|
|
441
|
+
const check = decideCheck(buildState({ cwd, env, detect }));
|
|
442
|
+
lastReason = check.reason;
|
|
443
|
+
if (check.code === 0) return { code: 0, stdout: `review-state --await: READY — ${check.reason}`, stderr: '' };
|
|
444
|
+
await sleep(Math.min(pollMs, timeoutMs - elapsed));
|
|
445
|
+
}
|
|
446
|
+
} catch (err) {
|
|
447
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `review-state: ${err.message}` };
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const emitResult = (r) => {
|
|
393
452
|
// Exact writes + a natural exit: process.exit() can truncate unflushed piped stdio (codex R2).
|
|
394
453
|
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
395
454
|
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
396
455
|
process.exitCode = r.code;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
459
|
+
if (isDirectRun) {
|
|
460
|
+
const argv = process.argv.slice(2);
|
|
461
|
+
if (argv.includes('--await')) mainAwait(argv).then(emitResult);
|
|
462
|
+
else emitResult(main(argv));
|
|
397
463
|
}
|
package/tools/run-gates.mjs
CHANGED
|
@@ -18,16 +18,32 @@
|
|
|
18
18
|
// Honest outcomes — each distinct, never a silent green (the exit-code table + the summary-line
|
|
19
19
|
// schema are pinned by run-gates.test.mjs):
|
|
20
20
|
// 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·
|
|
21
|
-
// 5 malformed/invalid declaration · 6 bash unavailable
|
|
22
|
-
//
|
|
23
|
-
// never a silent reinterpretation
|
|
21
|
+
// 5 malformed/invalid declaration · 6 bash unavailable · 7 --record asked but the gate-run
|
|
22
|
+
// record could not be written. Gate `cmd` lines are BASH command lines (brace/glob expansion);
|
|
23
|
+
// a host without bash gets the loud exit-6 preflight error, never a silent reinterpretation
|
|
24
|
+
// under another shell.
|
|
24
25
|
//
|
|
25
|
-
// The runner itself WRITES NOTHING
|
|
26
|
+
// The runner itself WRITES NOTHING by default. `--record` (BUGFREE-2 / AD-048, D5) mints ONE
|
|
27
|
+
// v4 `gate-run` record — the green-baseline receipt the review-ledger writer's D5 tooth consumes —
|
|
28
|
+
// by DELEGATING to the ledger's sole writer (recordGateRun in review-ledger-write.mjs): this
|
|
29
|
+
// runner never opens the ledger file itself (an import/structure pin holds the boundary). The
|
|
30
|
+
// record carries the FULL declaration + exactly what ran (a --only subset records honestly as a
|
|
31
|
+
// subset — it never satisfies quality-green) + the tree fingerprint BEFORE and AFTER the run (a
|
|
32
|
+
// mutating gate attests no particular tree). Dependency-free, Node >= 18. No side effects on import.
|
|
26
33
|
|
|
27
34
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
28
35
|
import { join } from 'node:path';
|
|
29
36
|
import { spawnSync } from 'node:child_process';
|
|
30
37
|
import { pathToFileURL } from 'node:url';
|
|
38
|
+
import { computeTreeFingerprint } from './review-state.mjs';
|
|
39
|
+
import { recordGateRun } from './review-ledger-write.mjs';
|
|
40
|
+
// (a) BUGFREE-3 / AD-049: the one-suite-run credit. The fold runner already spawned the `unit-tests`
|
|
41
|
+
// suite under coverage; --record can CREDIT that gate from the recorded evidence instead of
|
|
42
|
+
// re-spawning it minutes later — read-only (the read core, never the tree-toucher).
|
|
43
|
+
import { foldSuiteCredit } from './fold-completeness.mjs';
|
|
44
|
+
|
|
45
|
+
// The gate id the (a) credit applies to — the SAME command the fold runner resolves as its suite.
|
|
46
|
+
export const UNIT_TESTS_GATE_ID = 'unit-tests';
|
|
31
47
|
|
|
32
48
|
// The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
|
|
33
49
|
// user can open (the orchestration-config CONFIG_REL idiom).
|
|
@@ -42,6 +58,10 @@ export const EXIT = Object.freeze({
|
|
|
42
58
|
empty: 4,
|
|
43
59
|
malformed: 5,
|
|
44
60
|
noBash: 6,
|
|
61
|
+
// --record was asked for but the gate-run record could not be written (no in-flight loop, a
|
|
62
|
+
// malformed ledger, an fs refusal): the invocation's contract included a ledger receipt — not
|
|
63
|
+
// delivering it is its own loud outcome, never folded into ok/fail.
|
|
64
|
+
recordFailed: 7,
|
|
45
65
|
});
|
|
46
66
|
|
|
47
67
|
// A tagged failure carrying its process exit code (the shared orchestration-config idiom).
|
|
@@ -54,12 +74,18 @@ const SPAWN_FAILED_CODE = -1;
|
|
|
54
74
|
const MAX_GATE_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
55
75
|
|
|
56
76
|
const USAGE = [
|
|
57
|
-
'usage: run-gates.mjs [--cwd <dir>] [--only <id>]... [--help]',
|
|
77
|
+
'usage: run-gates.mjs [--cwd <dir>] [--only <id>]... [--record] [--help]',
|
|
58
78
|
'',
|
|
59
79
|
`Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
|
|
60
80
|
'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
|
|
81
|
+
'--record additionally mints ONE v4 gate-run record into the review ledger via its sole writer',
|
|
82
|
+
'(the D5 green-baseline receipt; needs a single in-flight plan): the full declaration + what ran',
|
|
83
|
+
'+ the pre/post tree fingerprints. A red run records honestly; a --only subset records as a subset.',
|
|
84
|
+
'In --record mode the unit-tests gate is CREDITED (not re-spawned) when it is the FIRST declared gate',
|
|
85
|
+
'AND the fold-completeness runner already ran that EXACT command green at the current tree',
|
|
86
|
+
'(fingerprint-bound + exit-0 + cmd-identity); positioned after another gate, it always re-spawns.',
|
|
61
87
|
`Exit codes: 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·`,
|
|
62
|
-
'5 malformed/invalid declaration · 6 bash unavailable.',
|
|
88
|
+
'5 malformed/invalid declaration · 6 bash unavailable · 7 --record asked but the record failed.',
|
|
63
89
|
].join('\n');
|
|
64
90
|
|
|
65
91
|
// ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
|
|
@@ -158,8 +184,16 @@ export const selectGates = (gates, onlyIds) => {
|
|
|
158
184
|
// Spawn one gate cmd via bash from the project root. `cmd` is a BASH command line by contract
|
|
159
185
|
// (the declaration's _README states it): this repo's own gate matrix needs brace+glob expansion,
|
|
160
186
|
// which /bin/sh does not perform — hence bash explicitly, never the platform default shell.
|
|
161
|
-
|
|
162
|
-
|
|
187
|
+
// NODE_TEST_CONTEXT is stripped (mirroring the fold suite's childTestEnv): a `node --test` gate spawned
|
|
188
|
+
// while run-gates is itself running under a parent test context would otherwise inherit it, hit Node's
|
|
189
|
+
// recursive-run guard, silently skip every file, and exit 0 — a vacuous false green. Stripping it also
|
|
190
|
+
// makes the plain gate env-equivalent to the fold suite, so the (a) suite-run credit's exit-0 truthfully
|
|
191
|
+
// predicts a plain-spawn exit-0 (the one remaining, documented residual is NODE_V8_COVERAGE).
|
|
192
|
+
export const spawnGateViaBash = (cmd, cwd) => {
|
|
193
|
+
const env = { ...process.env };
|
|
194
|
+
delete env.NODE_TEST_CONTEXT;
|
|
195
|
+
return spawnSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
|
|
196
|
+
};
|
|
163
197
|
|
|
164
198
|
// The command the bash preflight runs before ANY gate: proves bash itself spawns on this host,
|
|
165
199
|
// so "no bash" is one loud exit-6 error up front — never a per-gate spawn-failure cascade.
|
|
@@ -172,10 +206,19 @@ const trimTrailingNewline = (text) => text.replace(/\n$/, '');
|
|
|
172
206
|
|
|
173
207
|
// Run the selected gates sequentially (declaration order). A green gate logs one PASS line; a
|
|
174
208
|
// failing gate logs FAIL + its captured stdout/stderr VERBATIM (triage without re-running).
|
|
175
|
-
export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now, log = console.log }) => {
|
|
209
|
+
export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now, log = console.log, credit = null }) => {
|
|
176
210
|
const results = [];
|
|
177
211
|
for (const gate of gates) {
|
|
178
212
|
log(`── ${gate.id} — ${gate.title}`);
|
|
213
|
+
// (a) the one-suite-run credit: for the unit-tests gate, if the fold runner already ran this exact
|
|
214
|
+
// command green at the current tree, CREDIT it instead of re-spawning (no quality loss — same
|
|
215
|
+
// execution, same tree, recorded once). Any mismatch → credit is null → the normal spawn below.
|
|
216
|
+
const credited = credit ? credit(gate) : null;
|
|
217
|
+
if (credited) {
|
|
218
|
+
log(' PASS (credited from the fold-completeness suite run — no re-spawn)');
|
|
219
|
+
results.push({ id: gate.id, title: gate.title, ok: true, code: 0, elapsedMs: 0, credited: true });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
179
222
|
const startedAt = now();
|
|
180
223
|
const res = spawn(gate.cmd, cwd);
|
|
181
224
|
const elapsedMs = now() - startedAt;
|
|
@@ -218,7 +261,7 @@ export const composeSummaryLine = ({ status, results = [] }) => {
|
|
|
218
261
|
// ── CLI ───────────────────────────────────────────────────────────────────────────────
|
|
219
262
|
|
|
220
263
|
const parseArgs = (argv) => {
|
|
221
|
-
const opts = { cwd: null, only: [], help: false };
|
|
264
|
+
const opts = { cwd: null, only: [], record: false, help: false };
|
|
222
265
|
for (let i = 0; i < argv.length; i += 1) {
|
|
223
266
|
const arg = argv[i];
|
|
224
267
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -231,6 +274,8 @@ const parseArgs = (argv) => {
|
|
|
231
274
|
i += 1;
|
|
232
275
|
if (argv[i] === undefined) throw fail(EXIT.usage, '--only requires a gate id argument');
|
|
233
276
|
opts.only.push(argv[i]);
|
|
277
|
+
} else if (arg === '--record') {
|
|
278
|
+
opts.record = true;
|
|
234
279
|
} else {
|
|
235
280
|
throw fail(EXIT.usage, `unknown argument "${arg}"\n${USAGE}`);
|
|
236
281
|
}
|
|
@@ -244,12 +289,16 @@ const parseArgs = (argv) => {
|
|
|
244
289
|
export const runCli = (argv, deps = {}) => {
|
|
245
290
|
const {
|
|
246
291
|
cwd = process.cwd(),
|
|
292
|
+
env = process.env,
|
|
247
293
|
log = console.log,
|
|
248
294
|
logError = console.error,
|
|
249
295
|
spawn = spawnGateViaBash,
|
|
250
296
|
readFile,
|
|
251
297
|
lstat,
|
|
252
298
|
now,
|
|
299
|
+
record = recordGateRun,
|
|
300
|
+
fingerprint = computeTreeFingerprint,
|
|
301
|
+
foldCredit = foldSuiteCredit,
|
|
253
302
|
} = deps;
|
|
254
303
|
try {
|
|
255
304
|
const opts = parseArgs(argv);
|
|
@@ -283,10 +332,51 @@ export const runCli = (argv, deps = {}) => {
|
|
|
283
332
|
log(composeSummaryLine({ status: 'no-bash' }));
|
|
284
333
|
return EXIT.noBash;
|
|
285
334
|
}
|
|
286
|
-
const
|
|
335
|
+
const fingerprintBefore = opts.record ? fingerprint(projectDir) : null;
|
|
336
|
+
// (a) the one-suite-run credit — ONLY in --record mode (the fold-loop flow), ONLY the unit-tests
|
|
337
|
+
// gate, ONLY when it is the FIRST selected gate (a gate that ran BEFORE it could have side-effected
|
|
338
|
+
// an ignored/out-of-tree artifact unit-tests depends on WITHOUT moving the fingerprint, so a
|
|
339
|
+
// later-positioned unit-tests must re-spawn — never credit a state the real matrix might fail), and
|
|
340
|
+
// ONLY when the fold evidence is fingerprint-bound + exit-0 (foldCredit) AND its recorded cmd EQUALS
|
|
341
|
+
// this gate's cmd (the --only-subset defense). Any mismatch → credit is null → the normal spawn.
|
|
342
|
+
let credit = null;
|
|
343
|
+
if (opts.record && selected[0]?.id === UNIT_TESTS_GATE_ID) {
|
|
344
|
+
const fold = foldCredit({ cwd: projectDir, env, fingerprint: fingerprintBefore });
|
|
345
|
+
credit = (gate) => (gate.id === UNIT_TESTS_GATE_ID && fold.credited && fold.evidence.cmd === gate.cmd ? fold : null);
|
|
346
|
+
}
|
|
347
|
+
const results = runGates(selected, { cwd: projectDir, spawn, log, now, credit });
|
|
287
348
|
for (const line of formatTable(results)) log(line);
|
|
288
349
|
const allGreen = results.every((result) => result.ok);
|
|
350
|
+
// The gate-run record (D5): minted for green AND red runs alike (an honest red is telemetry
|
|
351
|
+
// fuel), via the ledger's sole writer. Emitted BEFORE the summary line — the machine summary
|
|
352
|
+
// stays the LAST line of every non-usage outcome (pinned).
|
|
353
|
+
let recordError = null;
|
|
354
|
+
if (opts.record) {
|
|
355
|
+
const failing = results.filter((result) => !result.ok);
|
|
356
|
+
try {
|
|
357
|
+
const { writtenPath } = record({
|
|
358
|
+
cwd: projectDir,
|
|
359
|
+
env,
|
|
360
|
+
declared: declaration.gates.map(({ id, cmd }) => ({ id, cmd })),
|
|
361
|
+
results: results.map(({ id, ok, code }) => ({ id, ok, code })),
|
|
362
|
+
summary: {
|
|
363
|
+
status: allGreen ? 'ok' : 'fail',
|
|
364
|
+
gates: results.length,
|
|
365
|
+
passed: results.length - failing.length,
|
|
366
|
+
failed: failing.length,
|
|
367
|
+
failedIds: failing.map((result) => result.id),
|
|
368
|
+
},
|
|
369
|
+
fingerprintBefore,
|
|
370
|
+
fingerprintAfter: fingerprint(projectDir),
|
|
371
|
+
});
|
|
372
|
+
log(`[run-gates] gate-run recorded → ${writtenPath}`);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
recordError = err;
|
|
375
|
+
logError(`[run-gates] --record failed: ${err.message}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
289
378
|
log(composeSummaryLine({ status: allGreen ? 'ok' : 'fail', results }));
|
|
379
|
+
if (recordError) return EXIT.recordFailed;
|
|
290
380
|
return allGreen ? EXIT.ok : EXIT.fail;
|
|
291
381
|
} catch (err) {
|
|
292
382
|
logError(`[run-gates] ${err.message}`);
|
package/tools/sarif.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sarif.mjs — a dependency-free SARIF (2.1.0) reader for the OPTIONAL advisory findings surface
|
|
3
|
+
// (BUGFREE-3, AD-049, step 1.4 — the reviewdog pattern). Findings are ADVISORY ONLY: they are NEVER
|
|
4
|
+
// recorded on a fold run record (SARIF stays entirely out of the fold result schema — no v4 quartet
|
|
5
|
+
// coupling, no sequencing dependency) and NEVER gate-blocking (fold-completeness --check never reads
|
|
6
|
+
// SARIF). A malformed SARIF fails the advisory READ loudly (a nonzero exit on the advisory verb), but
|
|
7
|
+
// the fold gate — fold-completeness --check — is unaffected. No side effects on import; Node >= 18.
|
|
8
|
+
|
|
9
|
+
export const SARIF_STOP = 'SARIF_STOP';
|
|
10
|
+
const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'SarifStop', code: SARIF_STOP });
|
|
11
|
+
|
|
12
|
+
// parseSarif(text) → { findings: [{ ruleId, level, message, file, line }] }. THROWS on a malformed
|
|
13
|
+
// SARIF (not JSON, or no runs[] array) — the advisory read is LOUD, never a silent empty result. A
|
|
14
|
+
// well-formed run with zero results yields an empty findings list (a clean advisory).
|
|
15
|
+
export const parseSarif = (text) => {
|
|
16
|
+
let doc;
|
|
17
|
+
try {
|
|
18
|
+
doc = JSON.parse(text);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw stop(`SARIF is not valid JSON (${err.message})`);
|
|
21
|
+
}
|
|
22
|
+
if (!doc || typeof doc !== 'object' || !Array.isArray(doc.runs)) {
|
|
23
|
+
throw stop('SARIF has no runs[] array — not a SARIF 2.1.0 document');
|
|
24
|
+
}
|
|
25
|
+
const findings = [];
|
|
26
|
+
for (const run of doc.runs) {
|
|
27
|
+
const results = Array.isArray(run?.results) ? run.results : [];
|
|
28
|
+
for (const r of results) {
|
|
29
|
+
const loc = r?.locations?.[0]?.physicalLocation ?? {};
|
|
30
|
+
findings.push({
|
|
31
|
+
ruleId: typeof r?.ruleId === 'string' ? r.ruleId : '(no rule)',
|
|
32
|
+
level: typeof r?.level === 'string' ? r.level : 'warning',
|
|
33
|
+
message: typeof r?.message?.text === 'string' ? r.message.text : '',
|
|
34
|
+
file: typeof loc?.artifactLocation?.uri === 'string' ? loc.artifactLocation.uri : null,
|
|
35
|
+
line: Number.isInteger(loc?.region?.startLine) ? loc.region.startLine : null,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { findings };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// renderSarifFindings(findings) → a human advisory block (one line per finding). Empty → a stated
|
|
43
|
+
// "no findings" note. Always advisory — the caller never blocks a gate on this.
|
|
44
|
+
export const renderSarifFindings = (findings) => {
|
|
45
|
+
if (findings.length === 0) return 'SARIF advisory: no findings (advisory only — never gate-blocking).';
|
|
46
|
+
const lines = [`SARIF advisory: ${findings.length} finding(s) (advisory only — never gate-blocking):`];
|
|
47
|
+
for (const f of findings) {
|
|
48
|
+
const at = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ''}` : '(no location)';
|
|
49
|
+
lines.push(` [${f.level}] ${f.ruleId} — ${at}${f.message ? ` — ${f.message}` : ''}`);
|
|
50
|
+
}
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// verification-profile.mjs — read-only schema/read core for the per-project VERIFICATION PROFILE
|
|
3
|
+
// (docs/ai/verification-profile.json), the language-independence contract (BUGFREE-3, AD-049).
|
|
4
|
+
// Companion read-core to the fold-completeness runner. NO writer — the runner is the sole
|
|
5
|
+
// tree-toucher. An ABSENT profile reproduces today's exact behaviour (V8 coverage + node:test
|
|
6
|
+
// TAP-on-stdout); the profile only generalizes the coverage SOURCE, the single-test RESULT FORMAT,
|
|
7
|
+
// and an optional SARIF path — coverage/probe INPUTS, never the suite command.
|
|
8
|
+
//
|
|
9
|
+
// The suite COMMAND is deliberately NOT a profile field: it stays the docs/ai/gates.json unit-tests
|
|
10
|
+
// cmd so the fold run and the unit-tests gate share command-identity (the (a) suite-evidence credit
|
|
11
|
+
// requires the SAME command).
|
|
12
|
+
//
|
|
13
|
+
// Path safety (Decision 4; grounding.mjs assertScratchDestination model): every profile-declared
|
|
14
|
+
// artifact path MUST be gitignored or outside the work tree, checked on the REALPATH (a symlink leaf
|
|
15
|
+
// is refused). An in-tree, not-ignored file the suite writes would move the review fingerprint the
|
|
16
|
+
// run binds to; a symlinked path could route the write onto a tracked file. validateProfile fails
|
|
17
|
+
// CLOSED on any such path.
|
|
18
|
+
//
|
|
19
|
+
// Dependency-free, Node >= 18. No side effects on import.
|
|
20
|
+
|
|
21
|
+
import { readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
22
|
+
import { join, resolve, relative, isAbsolute, dirname, basename } from 'node:path';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { fail } from './orchestration-config.mjs';
|
|
25
|
+
|
|
26
|
+
// cwd-relative so error prefixes show a path the user can open, never an absolute temp/host path.
|
|
27
|
+
export const PROFILE_REL = 'docs/ai/verification-profile.json';
|
|
28
|
+
export const PROFILE_SCHEMA_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
export const COVERAGE_KINDS = new Set(['v8', 'lcov']);
|
|
31
|
+
export const RESULT_FORMATS = new Set(['tap-stdout', 'tap-file', 'junit-xml']);
|
|
32
|
+
// File-based formats must be TOLD where to write, so their argv carries {resultPath} (the runner
|
|
33
|
+
// substitutes a fresh out-of-tree path per probe and reads THAT back). tap-stdout reads stdout.
|
|
34
|
+
export const FILE_BASED_FORMATS = new Set(['tap-file', 'junit-xml']);
|
|
35
|
+
export const RESULT_PATH_TOKEN = '{resultPath}';
|
|
36
|
+
export const FILE_TOKEN = '{file}';
|
|
37
|
+
export const PATTERN_TOKEN = '{pattern}';
|
|
38
|
+
|
|
39
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
40
|
+
const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
41
|
+
const isNonEmptyStringArray = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'string');
|
|
42
|
+
|
|
43
|
+
// Unknown-key-fails-closed. Returns a reason string or null.
|
|
44
|
+
const unknownKeyReason = (obj, allowed, where) => {
|
|
45
|
+
for (const k of Object.keys(obj)) {
|
|
46
|
+
if (!allowed.has(k)) return `${where}: unknown key "${k}" (allowed: ${[...allowed].join(', ')})`;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const COVERAGE_KEYS = new Set(['kind', 'lcovPath']);
|
|
52
|
+
const SINGLE_TEST_KEYS = new Set(['argv', 'resultFormat']);
|
|
53
|
+
const FINDINGS_KEYS = new Set(['sarifPath']);
|
|
54
|
+
const TOP_KEYS = new Set(['_README', 'schema', 'coverage', 'singleTest', 'findings']);
|
|
55
|
+
|
|
56
|
+
const defaultGitLine = (args, cwd) => {
|
|
57
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
58
|
+
return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Reason string when the declared path is unsafe, else null. Unsafe = a symlink leaf, or an
|
|
62
|
+
// in-work-tree path that is NOT gitignored (writing there moves the review fingerprint; a tracked
|
|
63
|
+
// path reads as not-ignored too). Checked on the REAL destination (parent realpath-resolved) so a
|
|
64
|
+
// symlinked parent cannot route an "outside/ignored" path back onto a tracked file. Outside any git
|
|
65
|
+
// tree → safe. deps.{gitLine,lstat,realpath} are injectable for hermetic tests.
|
|
66
|
+
export const declaredPathUnsafeReason = (label, p, cwd, deps = {}) => {
|
|
67
|
+
const gitLine = deps.gitLine ?? defaultGitLine;
|
|
68
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
69
|
+
const realpath = deps.realpath ?? realpathSync;
|
|
70
|
+
if (!isNonEmptyString(p)) return `${label} must be a non-empty string`;
|
|
71
|
+
const lexical = isAbsolute(p) ? p : resolve(cwd, p);
|
|
72
|
+
let leaf = null;
|
|
73
|
+
try {
|
|
74
|
+
leaf = lstat(lexical);
|
|
75
|
+
} catch {
|
|
76
|
+
leaf = null; // absent → a fresh file; the parent is realpath-checked below
|
|
77
|
+
}
|
|
78
|
+
if (leaf && leaf.isSymbolicLink()) {
|
|
79
|
+
return `${label} "${p}" is a symlink — refused (the write would follow it onto another file; name the real path)`;
|
|
80
|
+
}
|
|
81
|
+
let realParent;
|
|
82
|
+
try {
|
|
83
|
+
realParent = realpath(dirname(lexical));
|
|
84
|
+
} catch {
|
|
85
|
+
return `${label} "${p}" parent directory does not exist — create the (gitignored) output dir first, or fix the path`;
|
|
86
|
+
}
|
|
87
|
+
const full = join(realParent, basename(lexical));
|
|
88
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
89
|
+
if (top == null || top.status !== 0) return null; // not a git work tree → not fingerprint-relevant
|
|
90
|
+
// Realpath the repo root too so both sides of relative() are physical — a checkout opened via a
|
|
91
|
+
// symlink must not misclassify an in-tree path as OUTSIDE. Fail CLOSED if it can't resolve.
|
|
92
|
+
let root;
|
|
93
|
+
try {
|
|
94
|
+
root = realpath(top.stdout.replace(/\r?\n$/, ''));
|
|
95
|
+
} catch {
|
|
96
|
+
return `${label} "${p}" — cannot resolve the repo root's real path (fail closed)`;
|
|
97
|
+
}
|
|
98
|
+
const rel = relative(root, full);
|
|
99
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return null; // outside the work tree → safe
|
|
100
|
+
const ignored = gitLine(['check-ignore', '-q', '--', rel], root);
|
|
101
|
+
if (ignored == null || ignored.status !== 0) {
|
|
102
|
+
return `${label} "${p}" is an in-tree path that is not gitignored — the suite writing there would move the review fingerprint the run binds to; gitignore it (or place it outside the repo)`;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// validateProfile(obj, { cwd, gitLine, lstat, realpath }?) → { ok, reason }. Strict schema first;
|
|
108
|
+
// then, ONLY when `cwd` is supplied, the declared-path safety check (Decision 4). A pure-schema unit
|
|
109
|
+
// test omits `cwd`; loadProfile passes it so malformed OR unsafe both fail closed.
|
|
110
|
+
export const validateProfile = (obj, ctx = {}) => {
|
|
111
|
+
if (!isPlainObject(obj)) return { ok: false, reason: `${PROFILE_REL}: must be a JSON object` };
|
|
112
|
+
const topUnknown = unknownKeyReason(obj, TOP_KEYS, PROFILE_REL);
|
|
113
|
+
if (topUnknown) return { ok: false, reason: topUnknown };
|
|
114
|
+
if (obj.schema !== PROFILE_SCHEMA_VERSION) return { ok: false, reason: `${PROFILE_REL}: schema must be ${PROFILE_SCHEMA_VERSION}` };
|
|
115
|
+
if (obj._README !== undefined && typeof obj._README !== 'string') return { ok: false, reason: `${PROFILE_REL}: "_README" must be a string` };
|
|
116
|
+
|
|
117
|
+
// coverage (optional; absent → V8). lcovPath REQUIRED iff lcov, forbidden otherwise.
|
|
118
|
+
if (obj.coverage !== undefined) {
|
|
119
|
+
if (!isPlainObject(obj.coverage)) return { ok: false, reason: `${PROFILE_REL}: coverage must be an object` };
|
|
120
|
+
const ck = unknownKeyReason(obj.coverage, COVERAGE_KEYS, `${PROFILE_REL}: coverage`);
|
|
121
|
+
if (ck) return { ok: false, reason: ck };
|
|
122
|
+
if (!COVERAGE_KINDS.has(obj.coverage.kind)) return { ok: false, reason: `${PROFILE_REL}: coverage.kind must be one of ${[...COVERAGE_KINDS].join(', ')}` };
|
|
123
|
+
if (obj.coverage.kind === 'lcov') {
|
|
124
|
+
if (!isNonEmptyString(obj.coverage.lcovPath)) return { ok: false, reason: `${PROFILE_REL}: coverage.lcovPath (a non-empty path) is required when coverage.kind is "lcov"` };
|
|
125
|
+
} else if (obj.coverage.lcovPath !== undefined) {
|
|
126
|
+
return { ok: false, reason: `${PROFILE_REL}: coverage.lcovPath is only valid when coverage.kind is "lcov"` };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// singleTest (optional; absent → default node:test argv + tap-stdout). argv must carry {file} +
|
|
131
|
+
// {pattern}; a file-based resultFormat additionally requires {resultPath}.
|
|
132
|
+
if (obj.singleTest !== undefined) {
|
|
133
|
+
if (!isPlainObject(obj.singleTest)) return { ok: false, reason: `${PROFILE_REL}: singleTest must be an object` };
|
|
134
|
+
const sk = unknownKeyReason(obj.singleTest, SINGLE_TEST_KEYS, `${PROFILE_REL}: singleTest`);
|
|
135
|
+
if (sk) return { ok: false, reason: sk };
|
|
136
|
+
if (!isNonEmptyStringArray(obj.singleTest.argv)) return { ok: false, reason: `${PROFILE_REL}: singleTest.argv must be a non-empty array of strings` };
|
|
137
|
+
const argvJoined = obj.singleTest.argv.join(' ');
|
|
138
|
+
if (!argvJoined.includes(FILE_TOKEN)) return { ok: false, reason: `${PROFILE_REL}: singleTest.argv must carry a ${FILE_TOKEN} placeholder (the runner substitutes the test file)` };
|
|
139
|
+
if (!argvJoined.includes(PATTERN_TOKEN)) return { ok: false, reason: `${PROFILE_REL}: singleTest.argv must carry a ${PATTERN_TOKEN} placeholder (the runner substitutes the test-name pattern)` };
|
|
140
|
+
const rf = obj.singleTest.resultFormat;
|
|
141
|
+
if (rf !== undefined && !RESULT_FORMATS.has(rf)) return { ok: false, reason: `${PROFILE_REL}: singleTest.resultFormat must be one of ${[...RESULT_FORMATS].join(', ')}` };
|
|
142
|
+
if (FILE_BASED_FORMATS.has(rf) && !argvJoined.includes(RESULT_PATH_TOKEN)) {
|
|
143
|
+
return { ok: false, reason: `${PROFILE_REL}: singleTest.resultFormat "${rf}" is file-based — singleTest.argv must carry a ${RESULT_PATH_TOKEN} placeholder (the runner substitutes a fresh out-of-tree result path per probe)` };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// findings (optional; an empty object is valid — no SARIF path declared).
|
|
148
|
+
if (obj.findings !== undefined) {
|
|
149
|
+
if (!isPlainObject(obj.findings)) return { ok: false, reason: `${PROFILE_REL}: findings must be an object` };
|
|
150
|
+
const fk = unknownKeyReason(obj.findings, FINDINGS_KEYS, `${PROFILE_REL}: findings`);
|
|
151
|
+
if (fk) return { ok: false, reason: fk };
|
|
152
|
+
if (obj.findings.sarifPath !== undefined && !isNonEmptyString(obj.findings.sarifPath)) {
|
|
153
|
+
return { ok: false, reason: `${PROFILE_REL}: findings.sarifPath must be a non-empty string when present` };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Declared-path safety — only when a cwd is supplied (Decision 4).
|
|
158
|
+
if (ctx.cwd != null) {
|
|
159
|
+
const deps = { gitLine: ctx.gitLine, lstat: ctx.lstat, realpath: ctx.realpath };
|
|
160
|
+
if (obj.coverage?.kind === 'lcov') {
|
|
161
|
+
const r = declaredPathUnsafeReason('coverage.lcovPath', obj.coverage.lcovPath, ctx.cwd, deps);
|
|
162
|
+
if (r) return { ok: false, reason: `${PROFILE_REL}: ${r}` };
|
|
163
|
+
}
|
|
164
|
+
if (obj.findings?.sarifPath !== undefined) {
|
|
165
|
+
const r = declaredPathUnsafeReason('findings.sarifPath', obj.findings.sarifPath, ctx.cwd, deps);
|
|
166
|
+
if (r) return { ok: false, reason: `${PROFILE_REL}: ${r}` };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { ok: true };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// resolvers — env WINS over the profile (ad-hoc override precedence, Decision 3)
|
|
173
|
+
|
|
174
|
+
export const resolveCoverage = (profile) => {
|
|
175
|
+
const kind = profile?.coverage?.kind ?? 'v8';
|
|
176
|
+
return { kind, lcovPath: kind === 'lcov' ? profile.coverage.lcovPath : null };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// argv precedence: AW_FOLD_BOUND_CMD (applied in the runner's resolveBoundArgv, keeping the
|
|
180
|
+
// malformed-override refusal one home) > profile.singleTest.argv > built-in default. This resolver
|
|
181
|
+
// returns the PROFILE view only: argv = the profile template or null → runner default.
|
|
182
|
+
export const resolveSingleTest = (profile) => ({
|
|
183
|
+
argv: profile?.singleTest?.argv ?? null,
|
|
184
|
+
resultFormat: profile?.singleTest?.resultFormat ?? 'tap-stdout',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export const resolveSarifPath = (profile) => profile?.findings?.sarifPath ?? null;
|
|
188
|
+
|
|
189
|
+
// IO — config errors → loud fail(1); an absent FILE → defaults path, NOT an error
|
|
190
|
+
|
|
191
|
+
// loadProfile(cwd, deps?) → { profile, source }. Absent FILE → { profile: null, source: 'none' }.
|
|
192
|
+
// A directory / dangling symlink / permission error is PRESENT-but-unreadable → loud fail(1), never
|
|
193
|
+
// silently treated as absent. Malformed JSON, schema-invalid, or an unsafe declared path → fail(1).
|
|
194
|
+
export const loadProfile = (cwd, deps = {}) => {
|
|
195
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
196
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
197
|
+
const full = join(cwd, PROFILE_REL);
|
|
198
|
+
try {
|
|
199
|
+
lstat(full);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err && err.code === 'ENOENT') return { profile: null, source: 'none' };
|
|
202
|
+
throw fail(1, `${PROFILE_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
203
|
+
}
|
|
204
|
+
let raw;
|
|
205
|
+
try {
|
|
206
|
+
raw = readFile(full, 'utf8');
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw fail(1, `${PROFILE_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
209
|
+
}
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(raw);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
throw fail(1, `${PROFILE_REL}: malformed JSON (${err.message})`);
|
|
215
|
+
}
|
|
216
|
+
const v = validateProfile(parsed, { cwd, gitLine: deps.gitLine, lstat: deps.lstat, realpath: deps.realpath });
|
|
217
|
+
if (!v.ok) throw fail(1, v.reason);
|
|
218
|
+
return { profile: parsed, source: PROFILE_REL };
|
|
219
|
+
};
|