@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.
@@ -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
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
391
- if (isDirectRun) {
392
- const r = main(process.argv.slice(2));
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
  }
@@ -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. Gate `cmd` lines are BASH command
22
- // lines (brace/glob expansion); a host without bash gets the loud exit-6 preflight error,
23
- // never a silent reinterpretation under another shell.
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. Dependency-free, Node >= 18. No side effects on import.
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
- export const spawnGateViaBash = (cmd, cwd) =>
162
- spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
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 results = runGates(selected, { cwd: projectDir, spawn, log, now });
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}`);
@@ -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
+ };