@sabaiway/agent-workflow-kit 1.39.0 → 1.41.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 +72 -0
- package/README.md +4 -3
- 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 +8 -4
- package/references/modes/grounding.md +4 -3
- package/references/modes/review-ledger.md +1 -1
- package/references/modes/review-state.md +2 -1
- package/references/modes/upgrade.md +3 -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/commands.mjs +7 -0
- package/tools/doc-parity.mjs +139 -0
- package/tools/fold-completeness-run.mjs +485 -72
- package/tools/fold-completeness.mjs +108 -15
- package/tools/grounding.mjs +92 -5
- package/tools/lcov.mjs +127 -0
- package/tools/review-ledger-core.mjs +354 -0
- package/tools/review-ledger-write.mjs +63 -4
- package/tools/review-ledger.mjs +30 -327
- package/tools/review-state.mjs +164 -15
- package/tools/run-gates.mjs +43 -4
- package/tools/sarif.mjs +52 -0
- package/tools/verification-profile.mjs +219 -0
package/tools/review-state.mjs
CHANGED
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
// EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when the tree is
|
|
16
16
|
// clean (nothing to review); when the cwd is not a git work tree (nothing to fingerprint);
|
|
17
17
|
// and when EVERY recipe-named backend has a current-fingerprint receipt with acceptable
|
|
18
|
-
// grounding (fresh:true, artifact "code", grounded:true)
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
18
|
+
// grounding (fresh:true, artifact "code", grounded:true) OR is degraded-exempt: the current
|
|
19
|
+
// plan-execution SEGMENT's latest review-ledger round records that backend degraded:true at
|
|
20
|
+
// the current tree fingerprint, with >= 1 non-degraded recipe-named backend present with a
|
|
21
|
+
// current grounded receipt and the ledger reading clean (AD-050; MIRRORS review-ledger
|
|
22
|
+
// decideStop's degraded handling — presence, not unanimity, never a 0/0-counts gate).
|
|
23
|
+
// exit 1 when a recipe-named backend has no current-fingerprint receipt AND is not degraded-exempt —
|
|
24
|
+
// including the stale-after-edit case (any tracked/untracked change after the review moves the
|
|
25
|
+
// fingerprint) — or when its only current receipts carry grounded:false AND it is not
|
|
26
|
+
// degraded-exempt (an ungrounded agy review under reviewed/council never satisfies the gate on
|
|
27
|
+
// its own — but a recorded current-tree degrade still exempts it). An unreadable/malformed
|
|
28
|
+
// review-ledger DENIES the degraded exemption (fail-closed) but NEVER fails a tree whose
|
|
29
|
+
// receipts independently satisfy the gate (that stays exit 0, the ledger issue surfaced).
|
|
23
30
|
// Informational receipts NEVER satisfy (nor fail) the tree check: plan/diff-mode receipts
|
|
24
31
|
// (artifact ≠ "code") and continuations (fresh:false — agy --continue/--conversation cannot attest
|
|
25
32
|
// a folded tree; only a fresh grounded re-run mints a gate-satisfying receipt).
|
|
@@ -46,12 +53,20 @@ import { createHash } from 'node:crypto';
|
|
|
46
53
|
import { detectBackends } from './detect-backends.mjs';
|
|
47
54
|
import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
|
|
48
55
|
import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
|
|
56
|
+
// The NEUTRAL ledger read-core (AD-050): review-state reads the review-ledger ONLY for the degraded
|
|
57
|
+
// exemption, through the neutral core — never review-ledger.mjs (which imports THIS module, the cycle).
|
|
58
|
+
import { resolveLedgerPath, resolveBase, readLedger, filterSegmentRecords, roundSequenceIntact } from './review-ledger-core.mjs';
|
|
49
59
|
|
|
50
60
|
export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
|
|
51
61
|
export const PLANS_REL = 'docs/plans';
|
|
52
62
|
const ACTIVITY = 'plan-execution';
|
|
53
63
|
const SLOT = 'review';
|
|
54
64
|
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate silently
|
|
65
|
+
// --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
|
|
66
|
+
// a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
|
|
67
|
+
// injectable clock) so hermetic tests never spend wall-clock.
|
|
68
|
+
export const DEFAULT_AWAIT_TIMEOUT_S = 900;
|
|
69
|
+
export const AWAIT_POLL_MS = 5000;
|
|
55
70
|
|
|
56
71
|
// ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
|
|
57
72
|
|
|
@@ -231,6 +246,45 @@ export const backendReceiptStatus = (receipts, backend, fingerprint) => {
|
|
|
231
246
|
return { state: own.length > 0 ? 'stale' : 'missing', verdict: null, grounded: null, timestamp: null };
|
|
232
247
|
};
|
|
233
248
|
|
|
249
|
+
// ── the degraded exemption (AD-050): read the review-ledger for a recorded current-tree degrade ────
|
|
250
|
+
|
|
251
|
+
// degradedExemptSet(args) → the Set of recipe-named backends EXEMPT from --check because the current
|
|
252
|
+
// segment's LATEST round records them degraded at the current tree fingerprint. It MIRRORS review-ledger
|
|
253
|
+
// decideStop's degraded handling: a backend WITHOUT a current grounded code receipt is exempt IFF
|
|
254
|
+
// (i) exactly one plan is in flight (else the loop is ambiguous — the exempt set is empty, NO fail-closed
|
|
255
|
+
// exit-1 arm) AND the ledger reads clean (a readError / malformed line DENIES the exemption, fail-closed);
|
|
256
|
+
// (ii) the segment (activity=plan-execution, loop, base=resolveBase) has >=1 round with an intact
|
|
257
|
+
// sequence; (iii) its LATEST round records THAT backend degraded; (iv) that round's fingerprint equals
|
|
258
|
+
// the CURRENT tree (the degrade attests THIS tree); (v) >=1 NON-degraded recipe-named backend is present
|
|
259
|
+
// with a current grounded receipt (never everyone degraded). It is VERDICT-BLIND — it mirrors only the
|
|
260
|
+
// PRESENCE half of decideStop (nonDegradedReq >= 1), never its 0/0 counts (Decision 7).
|
|
261
|
+
export const degradedExemptSet = ({ records, readError, malformed, base, plans, currentFingerprint, requiredBackends, backends }) => {
|
|
262
|
+
const empty = new Set();
|
|
263
|
+
if (plans.length !== 1) return empty; // (i) ambiguous loop → exemption suppressed (no fail-closed exit-1 arm)
|
|
264
|
+
if (readError || malformed > 0) return empty; // fail-closed: a corrupt ledger denies the exemption
|
|
265
|
+
if (currentFingerprint == null) return empty;
|
|
266
|
+
const loop = plans[0].replace(/\.md$/, '');
|
|
267
|
+
const rounds = filterSegmentRecords(records, { activity: ACTIVITY, loop, base }).filter((r) => r.kind === 'round');
|
|
268
|
+
if (rounds.length === 0) return empty; // (ii) empty segment → nothing recorded yet
|
|
269
|
+
if (!roundSequenceIntact(rounds)) return empty; // (ii) corrupt sequence → fail closed
|
|
270
|
+
const latest = rounds[rounds.length - 1];
|
|
271
|
+
if (latest.fingerprint !== currentFingerprint) return empty; // (iv) the degrade must attest THIS tree
|
|
272
|
+
// Mirror decideStop's PRESENCE discipline (review-ledger.mjs): EVERY recipe-named backend must be IN
|
|
273
|
+
// the latest round (allPresent) — a backend absent from the round reviewed nothing there, so a stray
|
|
274
|
+
// current receipt for a NON-recorded backend can never justify the exemption (codex R1: else a
|
|
275
|
+
// degrade-only round `[{agy degraded}]` + any current codex receipt would exempt agy, disagreeing
|
|
276
|
+
// with review-ledger, whose decideStop fails allPresent on the absent codex).
|
|
277
|
+
const entryFor = (rb) => latest.backends.find((b) => b.backend === rb);
|
|
278
|
+
if (!requiredBackends.every((rb) => entryFor(rb) !== undefined)) return empty;
|
|
279
|
+
const receiptCurrent = new Set(backends.filter((b) => b.state === 'current').map((b) => b.backend));
|
|
280
|
+
// (v) >=1 non-degraded recipe-named backend PRESENT in the latest round with a current grounded
|
|
281
|
+
// receipt — never all degraded (mirrors decideStop's nonDegradedReq >= 1, plus review-state's own
|
|
282
|
+
// "it really reviewed" = a current receipt).
|
|
283
|
+
if (!requiredBackends.some((rb) => { const e = entryFor(rb); return e && !e.degraded && receiptCurrent.has(rb); })) return empty;
|
|
284
|
+
// (iii) exempt each recipe-named backend the latest round records degraded.
|
|
285
|
+
return new Set(requiredBackends.filter((rb) => { const e = entryFor(rb); return e && e.degraded === true; }));
|
|
286
|
+
};
|
|
287
|
+
|
|
234
288
|
// ── the check + report core ─────────────────────────────────────────────────────────
|
|
235
289
|
|
|
236
290
|
// buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
|
|
@@ -257,6 +311,13 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends } =
|
|
|
257
311
|
const receiptsPath = resolveReceiptsPath(cwd, env);
|
|
258
312
|
const { receipts, malformed } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
|
|
259
313
|
const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
|
|
314
|
+
// The degraded exemption (AD-050): read the review-ledger ONLY here, ONLY for the exemption — the
|
|
315
|
+
// whole gate never depends on the ledger (a corrupt ledger fails the exemption CLOSED, never a tree
|
|
316
|
+
// whose receipts independently satisfy the gate; Decision 3). base/ledger locate the current segment.
|
|
317
|
+
const base = resolveBase(cwd);
|
|
318
|
+
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
319
|
+
const { records, malformed: ledgerMalformed, readError: ledgerReadError } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
|
|
320
|
+
const degradedExempt = [...degradedExemptSet({ records, readError: ledgerReadError, malformed: ledgerMalformed, base, plans, currentFingerprint: fingerprint, requiredBackends, backends })];
|
|
260
321
|
return {
|
|
261
322
|
resolved,
|
|
262
323
|
configSource,
|
|
@@ -268,6 +329,11 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends } =
|
|
|
268
329
|
receiptsPath,
|
|
269
330
|
receiptCount: receipts.length,
|
|
270
331
|
malformed,
|
|
332
|
+
base,
|
|
333
|
+
ledgerPath,
|
|
334
|
+
ledgerMalformed: ledgerMalformed ?? 0,
|
|
335
|
+
ledgerReadError: ledgerReadError ?? null,
|
|
336
|
+
degradedExempt,
|
|
271
337
|
detectionWarning,
|
|
272
338
|
};
|
|
273
339
|
};
|
|
@@ -297,16 +363,31 @@ export const decideCheck = (state) => {
|
|
|
297
363
|
// cannot fail the gate by itself (a forged/corrupt line must not brick commits), but the check
|
|
298
364
|
// line always names it so a PASS over a partially-corrupt file is visible.
|
|
299
365
|
const malformedNote = state.malformed > 0 ? ` — ${state.malformed} malformed receipt line(s) ignored; inspect ${state.receiptsPath}` : '';
|
|
300
|
-
|
|
366
|
+
// The review-ledger is consulted ONLY for the degraded exemption; a corrupt ledger DENIES it
|
|
367
|
+
// (fail-closed) but never fails a tree the receipts independently satisfy — surfaced either way
|
|
368
|
+
// (No-silent-failures; Decision 3).
|
|
369
|
+
const ledgerNote = state.ledgerReadError
|
|
370
|
+
? ` — review ledger unreadable (${state.ledgerReadError}); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
|
|
371
|
+
: state.ledgerMalformed > 0
|
|
372
|
+
? ` — review ledger has ${state.ledgerMalformed} malformed line(s); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
|
|
373
|
+
: '';
|
|
374
|
+
// The degraded exemption (AD-050): a backend recorded degraded for the current tree is excluded from
|
|
375
|
+
// `failing` (it reviewed nothing to receipt — MIRRORS decideStop excluding a degraded backend). It
|
|
376
|
+
// stays verdict-blind: the exemption proves the degrade was RECORDED, never that the tree converged.
|
|
377
|
+
const exempt = new Set(state.degradedExempt);
|
|
378
|
+
const failing = state.backends.filter((b) => b.state !== 'current' && !exempt.has(b.backend));
|
|
301
379
|
if (failing.length === 0) {
|
|
302
|
-
|
|
380
|
+
if (exempt.size === 0) {
|
|
381
|
+
return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${malformedNote}${ledgerNote}` };
|
|
382
|
+
}
|
|
383
|
+
return { code: 0, reason: `every recipe-named backend reviewed the current tree (${state.requiredBackends.join(' + ')}) — degraded-exempt (recorded degraded for the current tree in the review ledger): ${[...exempt].join(', ')}${malformedNote}${ledgerNote}` };
|
|
303
384
|
}
|
|
304
385
|
const parts = failing.map((b) => {
|
|
305
386
|
if (b.state === 'ungrounded') return `${b.backend}: only ungrounded receipts for the current tree — re-run grounded (--facts)`;
|
|
306
387
|
if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
|
|
307
388
|
return `${b.backend}: no receipt — run its review wrapper`;
|
|
308
389
|
});
|
|
309
|
-
return { code: 1, reason: `${parts.join('; ')}${malformedNote}` };
|
|
390
|
+
return { code: 1, reason: `${parts.join('; ')}${malformedNote}${ledgerNote}` };
|
|
310
391
|
};
|
|
311
392
|
|
|
312
393
|
// ── rendering ───────────────────────────────────────────────────────────────────────
|
|
@@ -324,7 +405,11 @@ const formatHuman = (state, check) => {
|
|
|
324
405
|
else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
|
|
325
406
|
else lines.push(` tree fingerprint: ${state.fingerprint}`);
|
|
326
407
|
lines.push(` receipts: ${state.receiptsPath ?? '(unresolvable — no git dir)'} (${state.receiptCount} line(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
|
|
408
|
+
if (state.ledgerReadError) lines.push(` ⚠ review ledger unreadable (${state.ledgerReadError}) — degraded exemption unavailable`);
|
|
409
|
+
else if (state.ledgerMalformed) lines.push(` ⚠ review ledger: ${state.ledgerMalformed} malformed line(s) — degraded exemption unavailable`);
|
|
410
|
+
const exempt = new Set(state.degradedExempt);
|
|
327
411
|
for (const b of state.backends) {
|
|
412
|
+
const exemptTag = exempt.has(b.backend) ? ' — degraded-exempt (recorded degraded in the review ledger for the current tree)' : '';
|
|
328
413
|
const detail =
|
|
329
414
|
b.state === 'current'
|
|
330
415
|
? `current (verdict: ${b.verdict}, grounded, ${b.timestamp ?? '?'})`
|
|
@@ -333,7 +418,7 @@ const formatHuman = (state, check) => {
|
|
|
333
418
|
: b.state === 'stale'
|
|
334
419
|
? 'stale — no receipt matches the current tree (edited after review)'
|
|
335
420
|
: 'missing — no receipt from this backend';
|
|
336
|
-
lines.push(` ${STATE_GLYPH[b.state]} ${b.backend}: ${detail}`);
|
|
421
|
+
lines.push(` ${exempt.has(b.backend) ? '⊘' : STATE_GLYPH[b.state]} ${b.backend}: ${detail}${exemptTag}`);
|
|
337
422
|
}
|
|
338
423
|
lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
|
|
339
424
|
return lines.join('\n');
|
|
@@ -352,9 +437,10 @@ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and
|
|
|
352
437
|
(fresh:false) are informational-only — they never satisfy the tree check.
|
|
353
438
|
|
|
354
439
|
--check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
|
|
355
|
-
a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded
|
|
356
|
-
|
|
357
|
-
|
|
440
|
+
a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded OR
|
|
441
|
+
degraded-exempt (a recorded current-tree degrade in the review-ledger for that backend; AD-050); 1 when
|
|
442
|
+
a recipe-named backend is missing, stale (edited after review), or grounded:false under reviewed/council
|
|
443
|
+
AND is not degraded-exempt. Declare it as a project gate by hand (docs/ai/gates.json) or via the
|
|
358
444
|
explicit-consent seeder (tools/seed-gates.mjs) — never without consent.
|
|
359
445
|
|
|
360
446
|
Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
|
|
@@ -387,11 +473,74 @@ export const main = (argv, ctx = {}) => {
|
|
|
387
473
|
}
|
|
388
474
|
};
|
|
389
475
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
476
|
+
// ── --await: block until every recipe-named backend has receipted the current tree ─────
|
|
477
|
+
// (BUGFREE-3 / AD-049, item (d)). It waits for every recipe-named backend to be SATISFIED — a fresh
|
|
478
|
+
// grounded current-tree receipt, OR (AD-050) the degraded exemption: once a current-tree degrade is
|
|
479
|
+
// RECORDED in the review-ledger, --await stops waiting for that backend and returns READY (before,
|
|
480
|
+
// it waited forever for a receipt that never comes). It inherits the exemption for FREE — it polls
|
|
481
|
+
// the SAME decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT (i.e.
|
|
482
|
+
// `--check` would PASS), NEVER a process event — a harness "completed" notification fires early and a
|
|
483
|
+
// bridge's output late-flushes, so polling a pid/receipt-file is the durable mechanization of
|
|
484
|
+
// receipts-not-pgrep. Stays read-only (it only re-reads state — now the ledger too, a few KB per
|
|
485
|
+
// tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests never spend
|
|
486
|
+
// wall-clock.
|
|
487
|
+
|
|
488
|
+
const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
|
|
489
|
+
|
|
490
|
+
const parseAwaitTimeoutS = (argv) => {
|
|
491
|
+
const i = argv.indexOf('--timeout');
|
|
492
|
+
if (i === -1) return DEFAULT_AWAIT_TIMEOUT_S;
|
|
493
|
+
const raw = argv[i + 1];
|
|
494
|
+
if (!raw || !/^\d+$/.test(raw) || Number(raw) < 1) throw fail(2, '--timeout requires a positive integer number of seconds');
|
|
495
|
+
return Number(raw);
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
export const mainAwait = async (argv, ctx = {}) => {
|
|
499
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
500
|
+
const env = ctx.env ?? process.env;
|
|
501
|
+
const detect = ctx.detect ?? detectBackends;
|
|
502
|
+
const now = ctx.now ?? (() => Date.now());
|
|
503
|
+
const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
504
|
+
const pollMs = ctx.pollMs ?? AWAIT_POLL_MS;
|
|
505
|
+
try {
|
|
506
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
507
|
+
const a = argv[i];
|
|
508
|
+
if (a === '--timeout') { i += 1; continue; } // its value is consumed by parseAwaitTimeoutS
|
|
509
|
+
if (!AWAIT_ALLOWED_ARGS.has(a)) throw fail(2, `--await accepts only --timeout <s> (got ${a})`);
|
|
510
|
+
}
|
|
511
|
+
const timeoutS = parseAwaitTimeoutS(argv);
|
|
512
|
+
const timeoutMs = timeoutS * 1000;
|
|
513
|
+
const start = now();
|
|
514
|
+
// Poll the SAME normative decision --check computes: ready == `--check` would pass (solo / no
|
|
515
|
+
// plan / a clean tree / not-a-git-tree all resolve instantly — nothing to await). Re-read state
|
|
516
|
+
// every poll so a landed receipt (or a tree edit that re-staled one) is seen fresh. The DEADLINE
|
|
517
|
+
// is checked BEFORE readiness (codex council R2): once elapsed reaches the timeout the await is
|
|
518
|
+
// over, so a receipt that only lands AT/after the deadline never flips it to READY; and each
|
|
519
|
+
// sleep is BOUNDED to the remaining time so a full poll interval can never overshoot the timeout.
|
|
520
|
+
let lastReason = 'no poll completed before the deadline';
|
|
521
|
+
for (;;) {
|
|
522
|
+
const elapsed = now() - start;
|
|
523
|
+
if (elapsed >= timeoutMs) return { code: 1, stdout: '', stderr: `review-state --await: TIMEOUT after ${timeoutS}s — ${lastReason}` };
|
|
524
|
+
const check = decideCheck(buildState({ cwd, env, detect }));
|
|
525
|
+
lastReason = check.reason;
|
|
526
|
+
if (check.code === 0) return { code: 0, stdout: `review-state --await: READY — ${check.reason}`, stderr: '' };
|
|
527
|
+
await sleep(Math.min(pollMs, timeoutMs - elapsed));
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `review-state: ${err.message}` };
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const emitResult = (r) => {
|
|
393
535
|
// Exact writes + a natural exit: process.exit() can truncate unflushed piped stdio (codex R2).
|
|
394
536
|
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
395
537
|
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
396
538
|
process.exitCode = r.code;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
542
|
+
if (isDirectRun) {
|
|
543
|
+
const argv = process.argv.slice(2);
|
|
544
|
+
if (argv.includes('--await')) mainAwait(argv).then(emitResult);
|
|
545
|
+
else emitResult(main(argv));
|
|
397
546
|
}
|
package/tools/run-gates.mjs
CHANGED
|
@@ -37,6 +37,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
37
37
|
import { pathToFileURL } from 'node:url';
|
|
38
38
|
import { computeTreeFingerprint } from './review-state.mjs';
|
|
39
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';
|
|
40
47
|
|
|
41
48
|
// The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
|
|
42
49
|
// user can open (the orchestration-config CONFIG_REL idiom).
|
|
@@ -74,6 +81,9 @@ const USAGE = [
|
|
|
74
81
|
'--record additionally mints ONE v4 gate-run record into the review ledger via its sole writer',
|
|
75
82
|
'(the D5 green-baseline receipt; needs a single in-flight plan): the full declaration + what ran',
|
|
76
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.',
|
|
77
87
|
`Exit codes: 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·`,
|
|
78
88
|
'5 malformed/invalid declaration · 6 bash unavailable · 7 --record asked but the record failed.',
|
|
79
89
|
].join('\n');
|
|
@@ -174,8 +184,16 @@ export const selectGates = (gates, onlyIds) => {
|
|
|
174
184
|
// Spawn one gate cmd via bash from the project root. `cmd` is a BASH command line by contract
|
|
175
185
|
// (the declaration's _README states it): this repo's own gate matrix needs brace+glob expansion,
|
|
176
186
|
// which /bin/sh does not perform — hence bash explicitly, never the platform default shell.
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
};
|
|
179
197
|
|
|
180
198
|
// The command the bash preflight runs before ANY gate: proves bash itself spawns on this host,
|
|
181
199
|
// so "no bash" is one loud exit-6 error up front — never a per-gate spawn-failure cascade.
|
|
@@ -188,10 +206,19 @@ const trimTrailingNewline = (text) => text.replace(/\n$/, '');
|
|
|
188
206
|
|
|
189
207
|
// Run the selected gates sequentially (declaration order). A green gate logs one PASS line; a
|
|
190
208
|
// failing gate logs FAIL + its captured stdout/stderr VERBATIM (triage without re-running).
|
|
191
|
-
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 }) => {
|
|
192
210
|
const results = [];
|
|
193
211
|
for (const gate of gates) {
|
|
194
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
|
+
}
|
|
195
222
|
const startedAt = now();
|
|
196
223
|
const res = spawn(gate.cmd, cwd);
|
|
197
224
|
const elapsedMs = now() - startedAt;
|
|
@@ -271,6 +298,7 @@ export const runCli = (argv, deps = {}) => {
|
|
|
271
298
|
now,
|
|
272
299
|
record = recordGateRun,
|
|
273
300
|
fingerprint = computeTreeFingerprint,
|
|
301
|
+
foldCredit = foldSuiteCredit,
|
|
274
302
|
} = deps;
|
|
275
303
|
try {
|
|
276
304
|
const opts = parseArgs(argv);
|
|
@@ -305,7 +333,18 @@ export const runCli = (argv, deps = {}) => {
|
|
|
305
333
|
return EXIT.noBash;
|
|
306
334
|
}
|
|
307
335
|
const fingerprintBefore = opts.record ? fingerprint(projectDir) : null;
|
|
308
|
-
|
|
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 });
|
|
309
348
|
for (const line of formatTable(results)) log(line);
|
|
310
349
|
const allGreen = results.every((result) => result.ok);
|
|
311
350
|
// The gate-run record (D5): minted for green AND red runs alike (an honest red is telemetry
|
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
|
+
};
|