@sabaiway/agent-workflow-kit 14.4.1 → 14.4.3

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.
@@ -0,0 +1,137 @@
1
+ // core-evidence-store.mjs — the append half of the evidence store: the one append and the two verbs that append; the contract is the header of core-evidence.mjs.
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import { writeContainedFileAtomic } from './atomic-write.mjs';
6
+ import { parsePositiveIntKnob, probeVerdict } from './changed-surface.mjs';
7
+ import { computeTreeFingerprint, gitLine, resolveBase } from './core-evidence-tree.mjs';
8
+ import { EVIDENCE_SCHEMA_VERSION, isWellFormedTestId, parseEvidenceText, resolveEvidencePath, validateEvidenceRecord } from './core-evidence-store-read.mjs';
9
+ import { hashFileBytes, probeBound, resolveTestFile } from './core-evidence-red-proof.mjs';
10
+
11
+ export const CORE_EVIDENCE_STOP = 'CORE_EVIDENCE_STOP';
12
+ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'CoreEvidenceStop', code: CORE_EVIDENCE_STOP });
13
+ export const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
14
+
15
+ const isoNow = () => new Date().toISOString();
16
+
17
+ // ── the ONE append (validated, duplicate-refusing, atomic) ────────────────────────────────────────
18
+
19
+ export const appendEvidenceRecord = ({ path, record }) => {
20
+ const v = validateEvidenceRecord(record);
21
+ if (!v.ok) throw stop(`refusing to write a malformed evidence record: ${v.reason}`);
22
+ const line = JSON.stringify(record);
23
+ let existing = '';
24
+ try {
25
+ existing = readFileSync(path, 'utf8');
26
+ } catch (err) {
27
+ if (err && err.code === 'ENOENT') existing = '';
28
+ else throw stop(`cannot read the evidence store before appending (${(err && err.code) || (err && err.message) || err}) — refusing to overwrite it (fail closed)`);
29
+ }
30
+ // Write-side D6a fail-closed: never extend a store whose existing lines are unreadable — the
31
+ // SAME captured bytes are validated here and appended below (one snapshot, no second read).
32
+ const existingIssues = parseEvidenceText(existing).malformedReasons;
33
+ if (existingIssues.length > 0) {
34
+ throw stop(`refusing to append to an evidence store carrying ${existingIssues.length} malformed line(s) (${existingIssues[0]}) — inspect ${path}; nothing was written (fail closed)`);
35
+ }
36
+ if (existing.split('\n').some((l) => l === line)) {
37
+ throw stop(`refusing a byte-identical replayed line (duplicate) — a genuine re-observation carries new content or timestamp; nothing was written`);
38
+ }
39
+ const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
40
+ writeContainedFileAtomic(dirname(path), path, `${prefix}${line}\n`, {}, { stop, label: path });
41
+ return { writtenPath: path, record };
42
+ };
43
+
44
+ // N and the per-run timeout — pinned from the old runner's own constants (AW_FOLD_RERUNS default 3,
45
+ // AW_FOLD_PROBE_TIMEOUT_S default 120); zero/negative/fractional/non-numeric refused by name.
46
+ export const probeKnobsFromEnv = (env = process.env) => ({
47
+ reruns: parsePositiveIntKnob(env, 'AW_CORE_EVIDENCE_RERUNS', 3, stop),
48
+ timeoutS: parsePositiveIntKnob(env, 'AW_CORE_EVIDENCE_TIMEOUT_S', 120, stop),
49
+ });
50
+
51
+ // ── the red-proof verb (D3(c)): observed red or nothing ───────────────────────────────────────────
52
+
53
+ // runRedProof({ cwd, env, testId }) → { writtenPath, record }. Observes `testId` on the CURRENT
54
+ // (pre-fix) tree: N/N red → appends the declaration; anything else is a DISTINGUISHED refusal and
55
+ // NOTHING is written.
56
+ export const runRedProof = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
57
+ if (!isWellFormedTestId(testId)) {
58
+ throw usageFail(`red-proof needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
59
+ }
60
+ const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
61
+ if (rootTop == null) throw stop('not a git work tree — nothing to observe');
62
+ const { reruns, timeoutS } = probeKnobsFromEnv(env);
63
+ // Pre-run capture: the record's base/fingerprint and the custody hash must attest ONE tree —
64
+ // captured BEFORE the runs and re-checked after them; any drift refuses below.
65
+ const base = resolveBase(cwd);
66
+ const fingerprint = computeTreeFingerprint(cwd);
67
+ if (fingerprint == null) throw stop('cannot compute the tree fingerprint — the git location is not a work tree; nothing was observed, no runs were spent');
68
+ const { entry, file, resolveReason } = probeBound({ testId, rootTop, env, reruns, timeoutS });
69
+ const verdict = probeVerdict(entry);
70
+ const counts = `${entry.greens} green / ${entry.reds} red / ${entry.timeouts} timed out / ${entry.runs - entry.greens - entry.reds - entry.timeouts} unresolved of ${entry.runs} run(s)`;
71
+ if (verdict === 'unresolvable') {
72
+ throw stop(
73
+ `red-proof refused for "${testId}": unresolvable — ${resolveReason ?? 'the pattern selects no test'} (${counts}). ` +
74
+ `If the test cannot even LOAD pre-fix (it imports an export the fix introduces), author it with a dynamic import() so it loads and FAILS pre-fix. Nothing was recorded.`,
75
+ );
76
+ }
77
+ if (verdict === 'green') {
78
+ throw stop(
79
+ `red-proof refused for "${testId}": observed GREEN on ${entry.greens}/${entry.runs} runs — the test does not fail on the current (pre-fix) tree, so it proves nothing about the fix. ` +
80
+ `Write a test that FAILS before the fix is applied, then record the red-proof BEFORE folding the fix. Nothing was recorded.`,
81
+ );
82
+ }
83
+ if (verdict === 'quarantine') {
84
+ const flavor = entry.timeouts > 0
85
+ ? `${entry.timeouts} of ${entry.runs} probe run(s) timed out (AW_CORE_EVIDENCE_TIMEOUT_S=${timeoutS}) — a timed-out run is neither red nor green`
86
+ : `mixed outcomes (${counts}) — a flaky test can launder a fake red`;
87
+ throw stop(
88
+ `red-proof refused for "${testId}": QUARANTINE — ${flavor}. QUARANTINE never converts and has no override lane: ` +
89
+ `${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
90
+ );
91
+ }
92
+ // Post-run drift recheck: fingerprint, base, and the test-file bytes must all equal the
93
+ // pre-run capture — a mutating test / parallel edit / commit would otherwise mint a record
94
+ // whose fields attest different trees.
95
+ const resolvedAfter = resolveTestFile(rootTop, file);
96
+ const hashAfter = resolvedAfter.ok ? hashFileBytes(resolvedAfter.abs) : null;
97
+ if (computeTreeFingerprint(cwd) !== fingerprint || resolveBase(cwd) !== base || hashAfter !== entry.fileHash) {
98
+ throw stop(
99
+ `red-proof refused for "${testId}": the tree moved during the observation (fingerprint, base, or test-file bytes drifted between the pre-run capture and the post-run recheck) — a record would bind fields from different trees. Re-observe on a quiescent tree. Nothing was recorded.`,
100
+ );
101
+ }
102
+ const record = {
103
+ schema: EVIDENCE_SCHEMA_VERSION,
104
+ kind: 'red-proof',
105
+ testId,
106
+ file,
107
+ fileHash: entry.fileHash,
108
+ runs: entry.runs,
109
+ reds: entry.reds,
110
+ base,
111
+ fingerprint,
112
+ timestamp: isoNow(),
113
+ };
114
+ const path = resolveEvidencePath(cwd, env);
115
+ if (path == null) throw stop('cannot resolve the evidence-store path — not a git work tree and AW_CORE_EVIDENCE is unset');
116
+ return appendEvidenceRecord({ path, record });
117
+ };
118
+
119
+ // ── the degrade verb (D3(b)): the explicit per-backend, per-tree escape ───────────────────────────
120
+
121
+ export const runDegrade = ({ cwd = process.cwd(), env = process.env, backend, reason } = {}) => {
122
+ if (typeof backend !== 'string' || backend.trim() === '') throw usageFail('degrade needs --backend <name> — a missing or whitespace-only backend is refused (the gate matches records by backend name)');
123
+ if (typeof reason !== 'string' || reason.trim() === '') throw usageFail('degrade needs a non-empty --reason "<why this backend cannot review this tree>" — a reasonless degrade is refused');
124
+ const fingerprint = computeTreeFingerprint(cwd);
125
+ if (fingerprint == null) throw stop('not a git work tree — a degrade attests a specific tree fingerprint');
126
+ const record = {
127
+ schema: EVIDENCE_SCHEMA_VERSION,
128
+ kind: 'degrade',
129
+ backend: backend.trim(),
130
+ reason: reason.trim(),
131
+ fingerprint,
132
+ timestamp: isoNow(),
133
+ };
134
+ const path = resolveEvidencePath(cwd, env);
135
+ if (path == null) throw stop('cannot resolve the evidence-store path — not a git work tree and AW_CORE_EVIDENCE is unset');
136
+ return appendEvidenceRecord({ path, record });
137
+ };
@@ -0,0 +1,139 @@
1
+ // core-evidence-summary.mjs — the summary verb: red-proof currency, the summary state and its render; the contract is the header of core-evidence.mjs.
2
+
3
+ import { COVERAGE } from './coverage-state.mjs';
4
+ import { computeTreeFingerprint, gitLine, resolveBase } from './core-evidence-tree.mjs';
5
+ import { authoritativeOfKind, readEvidence, resolveEvidencePath } from './core-evidence-store-read.mjs';
6
+ import { readReceipts, resolveReceiptsPath, summarizeReviewReceiptsForTree } from './core-evidence-receipts.mjs';
7
+ import { hashFileBytes, resolveTestFile } from './core-evidence-red-proof.mjs';
8
+
9
+ // ── the summary verb (D6): ONE stateless render — receipts + evidence store, no ledger ────────────
10
+
11
+ // A red-proof binds the BYTES of the test file it was observed on, and the final-run checker refuses
12
+ // when those bytes have moved (coverage-check.mjs, the hash-mismatch arm). This render used to print a
13
+ // stale record byte-identically to a live one, so the only place staleness surfaced was that gate —
14
+ // after both council dispatches, at the commit boundary, which is the most expensive discovery point
15
+ // in the loop. The currency is therefore computed HERE, from the SAME two helpers the checker uses, so
16
+ // the render and the gate can never disagree about what stale means.
17
+ //
18
+ // An unresolvable or unreadable file is STALE, not unknown: a proof nobody can re-check is not a proof
19
+ // anyone should read as current.
20
+ export const redProofCurrency = (rootTop, record) => {
21
+ if (rootTop == null) return { state: 'stale', detail: 'the work-tree root is not resolvable' };
22
+ const resolved = resolveTestFile(rootTop, record.file);
23
+ if (!resolved.ok) return { state: 'stale', detail: resolved.reason };
24
+ const current = hashFileBytes(resolved.abs);
25
+ if (current == null) return { state: 'stale', detail: `cannot read "${record.file}"` };
26
+ return current === record.fileHash
27
+ ? { state: 'current' }
28
+ : { state: 'stale', detail: 'the bound test file changed after the mint' };
29
+ };
30
+
31
+ export const buildSummaryState = ({ cwd = process.cwd(), env = process.env } = {}) => {
32
+ const fingerprint = computeTreeFingerprint(cwd);
33
+ const base = resolveBase(cwd);
34
+ const storePath = resolveEvidencePath(cwd, env);
35
+ const store = storePath ? readEvidence(storePath) : { records: [], malformed: 0, malformedReasons: [] };
36
+ const receiptsPath = resolveReceiptsPath(cwd, env);
37
+ const receiptsRead = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
38
+ const { receipts, malformed: receiptsMalformed } = receiptsRead;
39
+ const receiptsReadError = receiptsRead.readError ?? null;
40
+ const backends = [...new Set(receipts.map((r) => r.backend))].sort();
41
+ const verdicts = backends.map((b) => ({
42
+ backend: b,
43
+ summary: summarizeReviewReceiptsForTree(receipts.filter((r) => r.backend === b), fingerprint),
44
+ }));
45
+ const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
46
+ const redProofs = authoritativeOfKind(store.records, 'red-proof')
47
+ .filter((r) => r.base === base)
48
+ .map((r) => ({ ...r, currency: redProofCurrency(rootTop, r) }));
49
+ const degrades = authoritativeOfKind(store.records, 'degrade').filter((r) => r.fingerprint === fingerprint);
50
+ const finalRun = authoritativeOfKind(store.records, 'final').find((r) => r.fingerprintBefore === fingerprint) ?? null;
51
+ // A malformed/unreadable store makes the AUTHORITATIVE selection untrustworthy (a dropped later
52
+ // line could resurrect a superseded record — or hide a newer verdict) — the summary WITHHOLDS
53
+ // the affected sections and exits non-zero instead of rendering a lie. Both stores get the same
54
+ // posture: the evidence store and the receipts store.
55
+ const evidenceUnavailable = store.malformed > 0 || store.readError != null;
56
+ const receiptsUnavailable = receiptsMalformed > 0 || receiptsReadError != null;
57
+ return {
58
+ fingerprint, base, storePath,
59
+ storeRecords: store.records.length, storeMalformed: store.malformed, storeReadError: store.readError ?? null,
60
+ evidenceUnavailable, receiptsUnavailable, receiptsReadError,
61
+ receiptsPath, receiptsMalformed, verdicts, redProofs, degrades, finalRun,
62
+ };
63
+ };
64
+
65
+ const verdictLine = ({ backend, summary }) => {
66
+ if (summary.state === 'current') return `${backend}: ${summary.receipt.verdict ?? 'unknown'} (attesting, ${summary.receipt.timestamp ?? '?'})`;
67
+ if (summary.state === 'ungrounded') return `${backend}: ${summary.receipt.verdict ?? 'unknown'} (ungrounded — never attests)`;
68
+ if (summary.state === 'unrecognized-verdict') return `${backend}: unrecognized verdict (${JSON.stringify(summary.receipt?.verdict ?? null)}) — never attests (fail-closed)`;
69
+ if (summary.state === 'probe') return `${backend}: probe receipts only for the current tree (never attest)`;
70
+ if (summary.state === 'rejected') return `${backend}: current-tree receipts rejected (untrustworthy probe marker)`;
71
+ return `${backend}: no attesting receipt for the current tree (stale or missing)`;
72
+ };
73
+
74
+ // Why the run issued no verdict, keyed by the token IT recorded. `certified` maps to nothing —
75
+ // there is no absence to name. A legacy receipt predates the token: the only honest thing to say
76
+ // is what the RECORD carries, which is true by construction. Exported as a test seam (keyFor).
77
+ const COVERAGE_QUALIFIER = Object.freeze({
78
+ [COVERAGE.notRun]: 'the recorded run issued no coverage verdict',
79
+ [COVERAGE.unknown]: 'the recorded run produced no readable coverage signal',
80
+ });
81
+ const LEGACY_COVERAGE_QUALIFIER = `coverage=${COVERAGE.unknown}: this legacy receipt carries no coverage token and binds no lcov digest`;
82
+
83
+ export const coverageQualifierFor = (finalRun) => {
84
+ if (!finalRun) return '';
85
+ if (typeof finalRun.coverage === 'string') {
86
+ const why = COVERAGE_QUALIFIER[finalRun.coverage];
87
+ return why ? ` — coverage=${finalRun.coverage}: ${why}` : '';
88
+ }
89
+ return finalRun.lcovSha256 == null ? ` — ${LEGACY_COVERAGE_QUALIFIER}` : '';
90
+ };
91
+
92
+ export const renderSummary = (s) => {
93
+ const short = (hex) => (typeof hex === 'string' ? `${hex.slice(0, 12)}…` : String(hex));
94
+ const evidenceSections = s.evidenceUnavailable
95
+ ? [` evidence sections WITHHELD — the store is unavailable (${s.storeMalformed} malformed line(s)${s.storeReadError ? `, read error: ${s.storeReadError}` : ''}); a dropped line could resurrect a superseded record — inspect ${s.storePath}`]
96
+ : [
97
+ ` red-proof records (current base): ${s.redProofs.length ? '' : '(none)'}`,
98
+ ...s.redProofs.map((r) => {
99
+ const stale = r.currency?.state === 'stale';
100
+ const mark = stale ? `STALE (${r.currency.detail})` : 'CURRENT';
101
+ return ` ${r.testId} — ${mark} · ${r.reds}/${r.runs} red, hash ${short(r.fileHash)}, pre-fix fingerprint ${short(r.fingerprint)}`;
102
+ }),
103
+ // The recovery, printed once and only when it is needed — it was written down nowhere, and
104
+ // the loop paid for that three times.
105
+ ...(s.redProofs.some((r) => r.currency?.state === 'stale')
106
+ ? [' ↳ a STALE proof is refused by run-gates --final: park the fix so the bound test fails again, re-observe it with `core-evidence red-proof "<testId>"`, then restore the fix. Edit test files FIRST and re-observe ONCE per park — every proof bound to an edited file goes stale together.']
107
+ : []),
108
+ ` degrade records (current tree): ${s.degrades.length ? '' : '(none)'}`,
109
+ ...s.degrades.map((d) => ` ${d.backend} — ${d.reason} (${d.timestamp})`),
110
+ ];
111
+ const verdictsSection = s.receiptsUnavailable
112
+ ? [` review verdicts WITHHELD — the receipts store is unavailable (${s.receiptsMalformed} malformed line(s)${s.receiptsReadError ? `, read error: ${s.receiptsReadError}` : ''}); a dropped line could hide a newer verdict — inspect ${s.receiptsPath}`]
113
+ : [
114
+ ' review verdicts (current tree):',
115
+ ...(s.verdicts.length ? s.verdicts.map((v) => ` ${verdictLine(v)}`) : [' (no receipts)']),
116
+ ];
117
+ // The withheld coverage verdict travels here too: an unqualified GREEN repeats, one surface
118
+ // further on, the false reassurance the checker's own attested=no exists to close. It is read
119
+ // from the token the RUN recorded — `lcovSha256` says what the receipt binds, never whether a
120
+ // verdict was issued — and it rides RED as well: an absent verdict is a property of the run, not
121
+ // of its colour. A LEGACY receipt (no token) is named as exactly that, never as a claim about
122
+ // what it read. DETAIL beside an unchanged status word.
123
+ const coverageQualifier = coverageQualifierFor(s.finalRun);
124
+ const finalLine = s.evidenceUnavailable
125
+ ? null
126
+ : s.finalRun
127
+ ? ` final gate run: ${s.finalRun.status === 'green' ? 'GREEN' : 'RED'}${coverageQualifier} (${s.finalRun.results.filter((r) => r.ok).length}/${s.finalRun.results.length} gates, ${s.finalRun.timestamp})`
128
+ : ' final gate run: (none recorded for the current tree)';
129
+ const lines = [
130
+ 'core-evidence summary — stateless render (review receipts + evidence store; no ledger, no rounds)',
131
+ ` tree fingerprint: ${s.fingerprint ?? '(not a git work tree)'}`,
132
+ ` base: ${s.base ?? '(unborn branch)'}`,
133
+ ...(finalLine ? [finalLine] : []),
134
+ ...verdictsSection,
135
+ ...evidenceSections,
136
+ ` evidence store: ${s.storePath ?? '(unresolvable — no git dir)'} (${s.storeRecords} record(s)${s.storeMalformed ? `, ${s.storeMalformed} malformed — inspect the file` : ''}${s.storeReadError ? `, read error: ${s.storeReadError}` : ''})`,
137
+ ];
138
+ return lines.join('\n');
139
+ };
@@ -0,0 +1,354 @@
1
+ // core-evidence-tree.mjs — the git runners, the base, the tree fingerprint and the working state; the contract is the header of core-evidence.mjs.
2
+
3
+ import { readFileSync, lstatSync, readlinkSync, openSync, readSync, closeSync } from 'node:fs';
4
+ import { join, sep } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { GIT_MAX_BUFFER, resolveGitLocation, withGitPath } from './git-env.mjs';
8
+
9
+ const gitRaw = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
10
+
11
+ // Exported so a consumer computing over the SAME domain (the exec metric producer) reads git through
12
+ // this one runner instead of growing a second spawnSync wrapper with its own buffer bound.
13
+ export const gitBuf = (args, cwd) => {
14
+ const r = gitRaw(args, cwd);
15
+ if (r.error || r.status !== 0) return null;
16
+ return r.stdout;
17
+ };
18
+
19
+ export const gitLine = (args, cwd) => {
20
+ const buf = gitBuf(args, cwd);
21
+ return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
22
+ };
23
+
24
+ // resolveBase(cwd) → the current HEAD commit sha, or null on an unborn branch / outside a git tree.
25
+ export const resolveBase = (cwd) => gitLine(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
26
+
27
+ // First 8 KiB contain a NUL byte → binary (git's own heuristic; mirrors the wrappers' is_binary
28
+ // bash twin). An unreadable path reads as text (the fail-safe arm — the payload walk then surfaces
29
+ // its own read failure).
30
+ export const isBinaryFile = (path) => {
31
+ let fd;
32
+ try {
33
+ fd = openSync(path, 'r');
34
+ const buf = Buffer.alloc(8192);
35
+ const n = readSync(fd, buf, 0, 8192, 0);
36
+ return buf.subarray(0, n).includes(0);
37
+ } catch {
38
+ return false;
39
+ } finally {
40
+ if (fd !== undefined) closeSync(fd);
41
+ }
42
+ };
43
+
44
+ // The never-committable untracked stat classes: character/block devices, FIFOs, sockets — excluded
45
+ // from the ENTIRE review domain (fingerprint payload, clean check); lstat-keyed because a lying
46
+ // dirent is exactly how sandbox masks surface.
47
+ export const isNeverCommittableStat = (stat) =>
48
+ stat != null &&
49
+ (stat.isCharacterDevice() || stat.isBlockDevice() || stat.isFIFO() || stat.isSocket());
50
+
51
+ // The git runner seam the tree computations share — (args, dir, input, env) — and its guard: a
52
+ // runner that THROWS answers as a failed spawn (measured: a raw stack out of a fingerprint).
53
+ const defaultRunGit = (args, dir, input, env) => spawnSync('git', args, { cwd: dir, input, env: withGitPath(env ?? process.env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
54
+ const guardedRunner = (runGit, env) => (args, dir, input, override) => {
55
+ try { return runGit(args, dir, input, override ?? env); } catch (err) { return { error: err, status: null, signal: null, stdout: Buffer.alloc(0), stderr: '' }; }
56
+ };
57
+ const stdoutOf = (r) => (r.error || r.status !== 0 ? null : r.stdout);
58
+ // Only a work-tree location is computed over (git-env's table under the runner's own env) — a redirecting GIT_DIR never hashes another repository in silence.
59
+ const locateWorkTree = (cwd, run, env) => {
60
+ const location = resolveGitLocation(cwd, { spawn: (cmd, args, opts) => run(args, opts.cwd, undefined, opts.env), env });
61
+ return location.state === 'work-tree' ? location.top : null;
62
+ };
63
+
64
+ // The canonical payload bytes: staged diff + unstaged diff + the untracked-not-ignored section —
65
+ // byte-identical to the wrappers' emit_fingerprint_payload, emitted from the work-tree ROOT. Null on
66
+ // every location but a work tree. lstat is injectable ONLY for the never-committable filter tests.
67
+ export const FINGERPRINT_CACHED_DIFF_ARGV = Object.freeze(['diff', '--cached', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none']);
68
+ export const FINGERPRINT_UNSTAGED_DIFF_ARGV = Object.freeze(['diff', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none']);
69
+ export const computeFingerprintPayload = (cwd, { lstat = lstatSync, runGit = null, env = process.env } = {}) => {
70
+ const run = guardedRunner(runGit ?? defaultRunGit, env);
71
+ const top = locateWorkTree(cwd, run, env);
72
+ if (top == null) return null;
73
+ const buf = (args) => stdoutOf(run(args, top));
74
+ const staged = buf(FINGERPRINT_CACHED_DIFF_ARGV);
75
+ const unstaged = buf(FINGERPRINT_UNSTAGED_DIFF_ARGV);
76
+ const untrackedZ = buf(['ls-files', '--others', '--exclude-standard', '-z']);
77
+ if (staged == null || unstaged == null || untrackedZ == null) return null;
78
+ const chunks = [staged, unstaged];
79
+ for (const rel of untrackedZ.toString('utf8').split('\0').filter(Boolean)) {
80
+ const full = join(top, rel);
81
+ let stat = null;
82
+ try {
83
+ stat = lstat(full);
84
+ } catch {
85
+ stat = null;
86
+ }
87
+ if (isNeverCommittableStat(stat)) continue;
88
+ if (stat?.isSymbolicLink()) {
89
+ let target = '?';
90
+ try {
91
+ target = readlinkSync(full);
92
+ } catch {
93
+ target = '?';
94
+ }
95
+ chunks.push(Buffer.from(`untracked-symlink:${rel} -> ${target}\n`));
96
+ } else if (!stat?.isFile()) {
97
+ chunks.push(Buffer.from(`untracked-nonregular:${rel}\n`));
98
+ } else if (isBinaryFile(full)) {
99
+ chunks.push(Buffer.from(`untracked-binary:${rel}\n`));
100
+ } else {
101
+ chunks.push(Buffer.from(`untracked:${rel}\n`));
102
+ chunks.push(readFileSync(full));
103
+ }
104
+ }
105
+ return Buffer.concat(chunks);
106
+ };
107
+
108
+ // sha256 hex of the canonical payload, or null outside a git work tree.
109
+ export const computeTreeFingerprint = (cwd, fsx) => {
110
+ const payload = computeFingerprintPayload(cwd, fsx);
111
+ return payload == null ? null : createHash('sha256').update(payload).digest('hex');
112
+ };
113
+
114
+ // The fingerprint of a CONTENT-FREE payload — a clean work tree emits no bytes at all, so this ONE
115
+ // value is shared by every clean moment of every repository. It therefore identifies no working
116
+ // state and correlates to no base: evidence found at it was minted by some other clean moment,
117
+ // possibly at another base, and can decide nothing in either direction. Two situations reach it,
118
+ // and only the INDEX tells them apart (never the payload): an empty commit, where the index equals
119
+ // HEAD and no byte enters the repository, and staged content the payload cannot see — a gitlink
120
+ // hidden from `git diff` by an ignore configuration. Read by the consumers that correlate a
121
+ // fingerprint to a base (flow-check-rungs.mjs #65) and by commit-guard's two content-free lanes.
122
+ export const CONTENT_FREE_FINGERPRINT = createHash('sha256').update(Buffer.alloc(0)).digest('hex');
123
+
124
+ // The index↔worktree split the fingerprint deliberately CANNOT see: the payload above concatenates
125
+ // the staged and unstaged diffs, so against an otherwise-empty index a hunk moving into the index
126
+ // leaves it byte-identical — while `git commit` builds the commit from the INDEX alone. This is the
127
+ // ONE computation of that split; isTreeClean and the commit guard's index-lag arm both read it, so
128
+ // they can never disagree about what "the index carries the verified tree" means. Submodule paths
129
+ // are separated because a root-level `git add -A` cannot reach a submodule's own worktree, so they
130
+ // need their own recovery. Anchored at the work-tree ROOT (ls-files is cwd-scoped); null when not
131
+ // decidable — every consumer treats that as fail-closed, never as clean.
132
+ const LS_FILES_TAG_SKIP_WORKTREE = 'S';
133
+ const GITLINK_MODE = '160000';
134
+ const SYMLINK_MODE = '120000';
135
+ const EXECUTABLE_MODE = '100755';
136
+ const OWNER_EXECUTE_BIT = 0o100;
137
+ // `ls-files -v` lowercases the tag for assume-unchanged; skip-worktree is `S`, and an entry
138
+ // carrying BOTH bits prints lowercase `s` (live-pinned by test). The skip test is therefore
139
+ // case-INSENSITIVE — a case-sensitive one would lose skip-worktree on such an entry and turn a
140
+ // legitimate sparse checkout into an endless refusal.
141
+ const isAssumeUnchangedTag = (tag) => tag >= 'a' && tag <= 'z';
142
+ const isSkipWorktreeTag = (tag) => tag.toUpperCase() === LS_FILES_TAG_SKIP_WORKTREE;
143
+
144
+ // `git diff` SKIPS index entries carrying skip-worktree or assume-unchanged, so such a path can
145
+ // hold worktree bytes the gates read while `git commit` takes the stale INDEX blob — the same
146
+ // capture blindness one layer down, and invisible to the plain probe. Those entries are therefore
147
+ // compared DIRECTLY against the worktree. A MISSING skip-worktree path is an ordinary sparse
148
+ // checkout and never a lag; a missing assume-unchanged path is one. Gitlinks belong to the
149
+ // submodule lane. Any probe that cannot answer counts the path as lagging (fail-safe).
150
+ const flaggedIndexLag = (top, runGit, lstat, readlink) => {
151
+ const buf = (args) => {
152
+ const r = runGit(args, top);
153
+ return r.error || r.status !== 0 ? null : r.stdout;
154
+ };
155
+ const splitZ = (b) => b.toString('utf8').split('\0').filter(Boolean);
156
+ // Git emits raw path BYTES. Decoding to UTF-8 first turns a name carrying invalid bytes into a
157
+ // DIFFERENT path, whose lstat then answers ENOENT — which for a skip-worktree entry reads as a
158
+ // de-materialised sparse path and lets a stale index walk through. So each record keeps its
159
+ // original slice and a name that does not survive a byte round-trip is lagging by construction.
160
+ const splitZBytes = (b) => {
161
+ const out = [];
162
+ let start = 0;
163
+ for (let i = 0; i < b.length; i += 1) {
164
+ if (b[i] !== 0) continue;
165
+ if (i > start) out.push(b.subarray(start, i));
166
+ start = i + 1;
167
+ }
168
+ if (start < b.length) out.push(b.subarray(start));
169
+ return out;
170
+ };
171
+ const decodesExactly = (slice) => Buffer.from(slice.toString('utf8'), 'utf8').equals(slice);
172
+ const taggedZ = buf(['ls-files', '-v', '-z']);
173
+ if (taggedZ == null) return null;
174
+ // The two bits are INDEPENDENT — an entry can carry both (which is what lowercases the `S`), so
175
+ // they travel as a pair and the recovery clears whichever are actually set.
176
+ const flagged = splitZBytes(taggedZ)
177
+ .map((record) => {
178
+ const tag = record.subarray(0, 1).toString('utf8');
179
+ const pathBytes = record.subarray(2);
180
+ return {
181
+ rel: pathBytes.toString('utf8'),
182
+ pathBytes,
183
+ exactName: decodesExactly(pathBytes),
184
+ skipWorktree: isSkipWorktreeTag(tag),
185
+ assumeUnchanged: isAssumeUnchangedTag(tag),
186
+ };
187
+ })
188
+ .filter(({ skipWorktree, assumeUnchanged }) => skipWorktree || assumeUnchanged);
189
+ if (flagged.length === 0) return { paths: [], submodules: [], flagged: [] };
190
+ const stagedZ = buf(['ls-files', '-s', '-z']);
191
+ if (stagedZ == null) return null;
192
+ const entries = new Map();
193
+ for (const line of splitZ(stagedZ)) {
194
+ const tab = line.indexOf('\t');
195
+ if (tab === -1) continue;
196
+ const [mode, oid] = line.slice(0, tab).split(' ');
197
+ entries.set(line.slice(tab + 1), { mode, oid });
198
+ }
199
+ // `git diff` honours core.fileMode; mirroring it keeps a false-mode host (WSL, network mounts)
200
+ // from reading every executable bit as a lag. Exit 1 is the only "unset" (git's default is true);
201
+ // any other failure leaves the comparison undecidable rather than guessing.
202
+ const boolConfig = (key) => {
203
+ const r = runGit(['config', '--type=bool', '--get', key], top);
204
+ if (r.error || (r.status !== 0 && r.status !== 1)) return null; // undecidable — never guessed
205
+ return r.status === 1 || r.stdout.toString('utf8').trim() !== 'false'; // exit 1 = unset = git's default true
206
+ };
207
+ const honoursFileMode = boolConfig('core.fileMode');
208
+ // On a host without symlink support git materialises a symlink as a REGULAR FILE holding the
209
+ // target bytes; demanding a real link there would refuse forever.
210
+ const materialisesSymlinks = boolConfig('core.symlinks');
211
+ if (honoursFileMode === null || materialisesSymlinks === null) return null;
212
+ const lagging = [];
213
+ const laggingSubmodules = [];
214
+ const laggingFlags = [];
215
+ for (const { rel, pathBytes, exactName, skipWorktree, assumeUnchanged } of flagged) {
216
+ const entry = entries.get(rel);
217
+ if (entry === undefined) continue;
218
+ const isGitlink = entry.mode === GITLINK_MODE;
219
+ // Every path this loop reports needs its index bits cleared before ANY staging command can
220
+ // pick it up — `git add -A` alone is a recovery that silently does nothing here.
221
+ const lag = () => {
222
+ (isGitlink ? laggingSubmodules : lagging).push(rel);
223
+ laggingFlags.push({ rel, skipWorktree, assumeUnchanged, exactName });
224
+ };
225
+ // Probed by RAW BYTES, never by the decoded name: a lossy decode addresses a DIFFERENT path,
226
+ // and answering ENOENT for it would either wave a stale index through or — worse — call a
227
+ // legitimately absent sparse entry "lagging", whose prescribed bit-clear plus `git add -A`
228
+ // would then stage its DELETION.
229
+ const absPath = Buffer.concat([Buffer.from(top), Buffer.from(sep), pathBytes]);
230
+ let stat = null;
231
+ let absent = false;
232
+ try {
233
+ stat = lstat(absPath);
234
+ } catch (err) {
235
+ // ONLY a genuine absence is the sparse-checkout case; EACCES / EIO leave the path unproven,
236
+ // and an unproven path can never be waved through as "not materialised".
237
+ absent = err != null && err.code === 'ENOENT';
238
+ }
239
+ if (stat === null) {
240
+ if (!absent || !skipWorktree) lag();
241
+ continue;
242
+ }
243
+ if (!exactName) {
244
+ // Materialised, but the name cannot be addressed through an argv string, so it can never be
245
+ // PROVEN current — fail-safe. Absence was already decided above, on the real bytes.
246
+ lag();
247
+ continue;
248
+ }
249
+ // A flagged GITLINK is hidden from `git diff` too, so the submodule lane would never see it —
250
+ // and it is deliberately NOT proven current here. Three consecutive review rounds each found a
251
+ // new way for a nested probe to answer "clean" wrongly (inherited superproject GIT_*, status
252
+ // config blindness, the submodule's OWN flagged entries, a symlink standing in for the
253
+ // directory). The set was not shrinking, so the verdict is REDUCTION rather than another patch:
254
+ // a materialised flagged gitlink LAGS by construction. It is a refusal, never an endless one —
255
+ // the printed recovery (clear the bit, then git add -A) converges, and an UNflagged submodule
256
+ // is unaffected because the ordinary --ignore-submodules=none probe still judges it.
257
+ if (isGitlink) {
258
+ lag();
259
+ continue;
260
+ }
261
+ if (entry.mode === SYMLINK_MODE && !stat.isSymbolicLink() && !materialisesSymlinks) {
262
+ // The placeholder file's RAW bytes are the stored target — no filters ever apply to a link.
263
+ const placeholder = runGit(['hash-object', '--no-filters', '-t', 'blob', '--', join(top, rel)], top);
264
+ if (placeholder.error || placeholder.status !== 0 || placeholder.stdout.toString('utf8').trim() !== entry.oid) lag();
265
+ continue;
266
+ }
267
+ if (entry.mode === SYMLINK_MODE || stat.isSymbolicLink()) {
268
+ if (entry.mode !== SYMLINK_MODE || !stat.isSymbolicLink()) {
269
+ lag();
270
+ continue;
271
+ }
272
+ let target = null;
273
+ try {
274
+ target = readlink(join(top, rel));
275
+ } catch {
276
+ target = null;
277
+ }
278
+ if (target === null) {
279
+ lag(); // an unreadable link can never be proven current — fail-safe
280
+ continue;
281
+ }
282
+ const hashed = runGit(['hash-object', '-t', 'blob', '--stdin'], top, target);
283
+ if (hashed.error || hashed.status !== 0 || hashed.stdout.toString('utf8').trim() !== entry.oid) lag();
284
+ continue;
285
+ }
286
+ if (!stat.isFile()) {
287
+ lag();
288
+ continue;
289
+ }
290
+ // Git canonicalises the executable mode on the OWNER bit alone — a file with only group/other
291
+ // exec set is still `100644` to git, so testing 0o111 would call a stale index current.
292
+ if (honoursFileMode && (entry.mode === EXECUTABLE_MODE) !== ((stat.mode & OWNER_EXECUTE_BIT) !== 0)) {
293
+ lag();
294
+ continue;
295
+ }
296
+ const hashed = runGit(['hash-object', '--path', rel, '--', join(top, rel)], top);
297
+ if (hashed.error || hashed.status !== 0 || hashed.stdout.toString('utf8').trim() !== entry.oid) lag();
298
+ }
299
+ return { paths: lagging, submodules: laggingSubmodules, flagged: laggingFlags };
300
+ };
301
+
302
+ export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlinkSync, runGit = null, env = process.env } = {}) => {
303
+ const run = guardedRunner(runGit ?? defaultRunGit, env);
304
+ const top = locateWorkTree(cwd, run, env);
305
+ if (top == null) return null;
306
+ // `--ignore-submodules=none` on the staged probe too: a config-hidden STAGED gitlink would
307
+ // otherwise make stagedDirty false, and isTreeClean would call such a tree clean.
308
+ const staged = run(['diff', '--cached', '--quiet', '--ignore-submodules=none'], top);
309
+ // ONLY 0 or 1 is a usable answer: a signal-killed probe reports status null, which the old
310
+ // `> 1` guard let through as "nothing staged" — a fail-OPEN the whole arm cannot afford.
311
+ if (staged.error || (staged.status !== 0 && staged.status !== 1)) return null;
312
+ const buf = (args) => stdoutOf(run(args, top));
313
+ // The FULL probe forces submodules back in: diff.ignoreSubmodules / submodule.<n>.ignore would
314
+ // otherwise erase a dirty submodule from the comparison entirely.
315
+ const changedZ = buf(['diff', '--name-only', '-z', '--ignore-submodules=none']);
316
+ const plainZ = buf(['diff', '--name-only', '-z', '--ignore-submodules=all']);
317
+ const untrackedZ = buf(['ls-files', '--others', '--exclude-standard', '-z']);
318
+ if (changedZ == null || plainZ == null || untrackedZ == null) return null;
319
+ const split = (b) => b.toString('utf8').split('\0').filter(Boolean);
320
+ const changed = [...new Set(split(changedZ))]; // an unmerged path is listed once per conflict side
321
+ const plain = new Set(split(plainZ));
322
+ const flagged = flaggedIndexLag(top, run, lstat, readlink);
323
+ if (flagged == null) return null;
324
+ const unstaged = changed.filter((rel) => plain.has(rel));
325
+ const submodules = changed.filter((rel) => !plain.has(rel));
326
+ return {
327
+ stagedDirty: staged.status === 1,
328
+ unstagedPaths: [...unstaged, ...flagged.paths.filter((rel) => !unstaged.includes(rel))],
329
+ unstagedSubmodulePaths: [...submodules, ...flagged.submodules.filter((rel) => !submodules.includes(rel))],
330
+ // Which lagging paths carry index bits, and which — `git add -A` cannot restage these at all.
331
+ flaggedPaths: flagged.flagged,
332
+ untrackedPaths: split(untrackedZ).filter((rel) => {
333
+ try {
334
+ return !isNeverCommittableStat(lstat(join(top, rel)));
335
+ } catch {
336
+ return true;
337
+ }
338
+ }),
339
+ };
340
+ };
341
+
342
+ // Clean = nothing staged, nothing unstaged, no REVIEWABLE untracked-not-ignored paths — the same
343
+ // never-committable filter as the fingerprint, so the two can never disagree about a masks-only
344
+ // tree. Null when not decidable.
345
+ export const isTreeClean = (cwd, fsx) => {
346
+ const state = computeWorkingState(cwd, fsx);
347
+ if (state == null) return null;
348
+ return (
349
+ !state.stagedDirty &&
350
+ state.unstagedPaths.length === 0 &&
351
+ state.unstagedSubmodulePaths.length === 0 &&
352
+ state.untrackedPaths.length === 0
353
+ );
354
+ };