tickmarkr 2.7.8 → 2.8.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.
@@ -14,21 +14,44 @@ export const EVIDENCE_RUN_QUOTA_BYTES = DEFAULT_EVIDENCE_QUOTA_BYTES;
14
14
  /** Benign environment locations substituted by name; every other redaction is material. */
15
15
  const BENIGN_ENV_KEYS = ["HOME", "TMPDIR"];
16
16
  const REDACTION_CATEGORIES = ["token", "assignment", "secretEnv", "benignEnv"];
17
+ /** Keep inherited, evidence, and inline values as separate pairs, including overridden values. */
18
+ function gateEvidencePairs(env, command, inheritedEnv) {
19
+ const pairs = [...Object.entries(inheritedEnv), ...Object.entries(env)]
20
+ .filter((entry) => entry[1] !== undefined);
21
+ for (const match of command.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z_0-9]*)=(?:'([^']*)'|"([^"$]*)"|([^\s;&|]+))/g)) {
22
+ pairs.push([match[1], match[2] ?? match[3] ?? match[4]]);
23
+ }
24
+ return pairs;
25
+ }
17
26
  /**
18
27
  * OBS-1139: every category scans the ORIGINAL text on its own; overlapping spans then merge into one
19
28
  * span counted once under its highest-priority member — token, assignment, secret environment, benign
20
29
  * HOME/TMPDIR — so precedence never depends on which match starts first. Counts only: never a value.
21
30
  */
22
- export function classifyGateOutput(text, env) {
23
- // HOME/TMPDIR substitute by name at any length; "/" alone would rewrite every path separator.
24
- const benign = BENIGN_ENV_KEYS.flatMap(key => { const value = env[key]; return value && value.length > 1 ? [{ value, label: `$${key}` }] : []; });
25
- // Short operational values (0, true, vi, test) occur throughout ordinary diagnostics.
26
- // Only secret-named keys justify redacting short values. A secret-named value that happens to equal
27
- // HOME/TMPDIR stays a secret candidate: precedence decides the span, never candidate pruning.
28
- const secrets = [...new Set(Object.entries(env)
29
- .filter(([key, value]) => !!value && !BENIGN_ENV_KEYS.includes(key)
30
- && (value.length >= 8 || /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(key)))
31
- .map(([, value]) => value))];
31
+ export function classifyGateOutput(text, env, command = "", inheritedEnv = process.env) {
32
+ const benign = [];
33
+ const secrets = new Set();
34
+ // Closed pair rule, first match wins: HOME/TMPDIR is benign when longer than one; empty is nothing;
35
+ // length >= 8 is secret; a secret name with a flag literal (0, 1, true, false, yes, no, on, off)
36
+ // or all ASCII digits is nothing; another short secret-named value is secret; other short values
37
+ // are nothing. Cost: short all-digit secrets and flag literals are no longer withheld. Span
38
+ // precedence still makes a secret equal to HOME/TMPDIR win over the benign location label.
39
+ for (const [name, value] of gateEvidencePairs(env, command, inheritedEnv)) {
40
+ if (BENIGN_ENV_KEYS.includes(name)) {
41
+ if (value.length > 1)
42
+ benign.push({ value, label: `$${name}` });
43
+ }
44
+ else if (!value) {
45
+ continue;
46
+ }
47
+ else if (value.length >= 8) {
48
+ secrets.add(value);
49
+ }
50
+ else if (/KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(name)
51
+ && !/^(?:0|1|true|false|yes|no|on|off)$/i.test(value) && !/^[0-9]+$/.test(value)) {
52
+ secrets.add(value);
53
+ }
54
+ }
32
55
  const literal = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
56
  const tokens = String.raw `\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b`;
34
57
  const assignments = String.raw `(?:authorization\s*:\s*bearer|(?:api[_-]?key|token|password|secret)\s*[:=])\s*[^\s"']+`;
@@ -37,7 +60,7 @@ export function classifyGateOutput(text, env) {
37
60
  const scan = (source, rank, label) => [...text.matchAll(new RegExp(source, "gi"))].map(m => ({ start: m.index, end: m.index + m[0].length, rank, label }));
38
61
  const spans = [
39
62
  ...scan(tokens, 0), ...scan(assignments, 1),
40
- ...secrets.flatMap(v => scan(literal(v), 2)),
63
+ ...[...secrets].flatMap(v => scan(literal(v), 2)),
41
64
  ...benign.flatMap(b => scan(literal(b.value), 3, b.label)),
42
65
  ].sort((a, b) => a.start - b.start || b.end - a.end);
43
66
  const merged = [];
@@ -64,8 +87,8 @@ export function classifyGateOutput(text, env) {
64
87
  }
65
88
  return { text: out + text.slice(at), counts };
66
89
  }
67
- export function redactGateOutput(text, env) {
68
- return classifyGateOutput(text, env).text;
90
+ export function redactGateOutput(text, env, command = "") {
91
+ return classifyGateOutput(text, env, command).text;
69
92
  }
70
93
  /** Material means bytes were withheld; a benign location substitution withholds nothing. */
71
94
  export const redactionMaterial = (c) => c.token + c.assignment + c.secretEnv > 0;
@@ -103,10 +126,8 @@ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
103
126
  catch { /* unknown subject */ }
104
127
  }
105
128
  const root = opts.artifactDir ?? join(tmpdir(), "tickmarkr-gate-evidence", createHash("sha256").update(cwd).digest("hex"));
106
- const env = { ...process.env, ...opts.env };
107
- for (const match of command.matchAll(/(?:^|\s)[A-Za-z_][A-Za-z_0-9]*=(?:'([^']*)'|"([^"$]*)"|([^\s;&|]+))/g)) {
108
- env[`assignment_${match.index}`] = match[1] ?? match[2] ?? match[3];
109
- }
129
+ const inheritedEnv = { ...process.env };
130
+ const evidenceEnv = { ...opts.env };
110
131
  let observed;
111
132
  let pendingId;
112
133
  const history = [];
@@ -131,7 +152,7 @@ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
131
152
  exitCode: observed?.exitCode ?? null, signal: observed?.signal ?? null,
132
153
  timedOut: observed ? observed.outcome === "timed-out" : null,
133
154
  };
134
- const classified = [classifyGateOutput(stdout, env), classifyGateOutput(stderr, env)];
155
+ const classified = [classifyGateOutput(stdout, evidenceEnv, command, inheritedEnv), classifyGateOutput(stderr, evidenceEnv, command, inheritedEnv)];
135
156
  const clean = classified.map(c => c.text);
136
157
  const counts = { token: 0, assignment: 0, secretEnv: 0, benignEnv: 0 };
137
158
  for (const c of classified)
@@ -366,6 +387,17 @@ const namesRegression = (l) => (isFailureShaped(l) || ERROR_CLASS_RE.test(l)) &&
366
387
  // This does not make the vocabulary open-ended: INFRA_RE is still the one closed list, and
367
388
  // classifyFailureOutput's ERROR_CLASS_RE/namesFailure vetoes still decide mixed lines.
368
389
  const isFingerprintShaped = (l) => isFailureShaped(l) || INFRA_RE.test(l);
390
+ /** D1's signal vocabulary, one copy for the gate battery and the tip (the daemon keeps its own). */
391
+ export const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+code)?\s+(?:137|143))\b/i;
392
+ /**
393
+ * S19m4: the first kill line of in-memory runner output — outside Vitest echo blocks, ANSI-stripped,
394
+ * read in its turbo-stripped form: it names a signal exit, is not failure-shaped, is neither a pass nor an
395
+ * operator line, and is not a script echo (`> ` — npm and pnpm print one before every script they run,
396
+ * nested ones included, and the echo is the command text, never kill text).
397
+ */
398
+ const firstKillLine = (output) => withoutVitestEchoBlocks(output)
399
+ .map((l) => { const clean = l.replace(ANSI_RE, ""); return stripTurboPrefix(clean) ?? clean; })
400
+ .find((l) => SIGNAL_EXIT_RE.test(l) && !isFingerprintShaped(l) && !PASS_LINE_RE.test(l) && !OPERATOR_LINE_RE.test(l) && !l.startsWith("> "));
369
401
  /**
370
402
  * What a nonzero runner exit is evidence OF. `undefined` when the output names neither — the
371
403
  * unreadable-runner case the existing fail-closed path already owns.
@@ -494,6 +526,102 @@ export function fingerprint(output) {
494
526
  return lines.some((l) => l.trim()) ? [UNRECOGNIZED_FAILURE] : [];
495
527
  return [...new Set(shaped.map(normalizeLine))];
496
528
  }
529
+ // S22 table T (ANL-021): the ONLY whole-token shapes whose digits a v2 fingerprint canonicalizes, first
530
+ // match wins. Every other token keeps its digits verbatim — TestParse1, TS2304, 1ms.js, my/no-1ms-timeout.
531
+ const T4_PATH_POSITION_RE = /^(.*[^\d:])((?::\d+){1,2})$/;
532
+ const T5_TSC_POSITION_RE = /^(.*)\((\d+),(\d+)\)$/;
533
+ const TABLE_T = [
534
+ [/^(?:\d+h)?(?:\d+m(?!s))?(?:\d*\.)?\d+(?:ns|ps|us|µs|μs|ms|s|m|h)$/, () => "<dur>"], // T1 duration
535
+ [/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?%?$/, () => "#"], // T2 number
536
+ [/^\d+(?:[:.]\d+)+$/, (m) => m[0].replace(/\d+/g, "#")], // T3 position, clock, version
537
+ [T4_PATH_POSITION_RE, (m) => m[1] + m[2].replace(/\d+/g, "#")], // T4 path with position
538
+ [T5_TSC_POSITION_RE, (m) => `${m[1]}(#,#)`], // T5 tsc position
539
+ [/^\d+\/\d+$/, () => "#/#"], // T6 ratio
540
+ [/^\d{4}-\d{2}-\d{2}(?:[T ]?\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?(?:Z|[+-]\d{2}:?\d{2})?$/, () => "<time>"], // T7 ISO date-time
541
+ [/^([A-Za-z_][\w.-]*)=([+-]?\d+(?:\.\d+)?)$/, (m) => `${m[1]}=#`], // T8 key=number
542
+ [/^0x[0-9a-fA-F]+$/, () => "0x#"], // T9 hex address
543
+ ];
544
+ const canonical = (core, rows) => {
545
+ for (const [re, to] of rows) {
546
+ const m = re.exec(core);
547
+ if (m)
548
+ return to(m);
549
+ }
550
+ return undefined;
551
+ };
552
+ const canonicalToken = (token) => {
553
+ const trail = /[,;:.!?]+$/.exec(token)?.[0] ?? "";
554
+ const rest = token.slice(0, token.length - trail.length);
555
+ const positioned = canonical(rest, [TABLE_T[4], TABLE_T[3]]);
556
+ if (positioned !== undefined)
557
+ return positioned + trail;
558
+ const lead = /^[([{"']/.exec(rest)?.[0] ?? "";
559
+ const close = /[)\]}"']+$/.exec(rest.slice(lead.length))?.[0] ?? "";
560
+ const core = rest.slice(lead.length, rest.length - close.length);
561
+ const out = canonical(core, TABLE_T);
562
+ return out === undefined ? token : lead + out + close + trail;
563
+ };
564
+ /** A v2 line: every whitespace token canonicalized by table T, joined with one space. */
565
+ const normalizeV2 = (l) => l.split(/\s+/).filter(Boolean).map(canonicalToken).join(" ");
566
+ // S22 layout L: when a stylish header is PROVEN for an error row. A stream is keyed by its turbo-style
567
+ // label (CONTENT keeps the runner's own indentation, unlike TURBO_PREFIX_RE); unlabelled output is one stream.
568
+ const L_LABEL_RE = /^([\w@./-]+(?::[A-Za-z_][\w.-]*)+):(?: |$)/;
569
+ const L_ROW_RE = /^\s+\d+:\d+\s+(?:error|warning)\s+\S/;
570
+ const splitLabel = (l) => {
571
+ const m = L_LABEL_RE.exec(l);
572
+ return m ? { label: m[1], content: l.slice(m[0].length) } : { label: "", content: l };
573
+ };
574
+ /** Line index → proven header of every ROW inside a BLOCK (a header plus the maximal run of rows after it). */
575
+ function stylishHeaders(lines) {
576
+ const streams = new Map();
577
+ lines.forEach((line, index) => {
578
+ const { label, content } = splitLabel(line);
579
+ const stream = streams.get(label) ?? [];
580
+ stream.push({ index, content });
581
+ streams.set(label, stream);
582
+ });
583
+ const headers = new Map();
584
+ for (const stream of streams.values()) {
585
+ stream.forEach(({ content }, i) => {
586
+ const proven = /^\S/.test(content) && !L_ROW_RE.test(content) && !isFingerprintShaped(content)
587
+ && (i === 0 || stream[i - 1].content.trim() === "") && L_ROW_RE.test(stream[i + 1]?.content ?? "");
588
+ for (let j = i + 1; proven && j < stream.length && L_ROW_RE.test(stream[j].content); j++)
589
+ headers.set(stream[j].index, content);
590
+ });
591
+ }
592
+ return headers;
593
+ }
594
+ /**
595
+ * S22: one identity per failure OCCURRENCE (never deduplicated), each kept beside the original line it came
596
+ * from. Same filters as `fingerprint`; a stylish error ROW is emitted once, as its layout-L tuple.
597
+ */
598
+ function fingerprintOccurrences(output) {
599
+ const all = withoutVitestEchoBlocks(output).map((l) => l.replace(ANSI_RE, ""));
600
+ const headers = stylishHeaders(all);
601
+ const occurrences = [];
602
+ let printed = false;
603
+ all.forEach((l, index) => {
604
+ if (PASS_LINE_RE.test(l) || OPERATOR_LINE_RE.test(l))
605
+ return;
606
+ printed ||= l.trim() !== "";
607
+ const stripped = stripTurboPrefix(l);
608
+ if (stripped !== undefined && OPERATOR_LINE_RE.test(stripped))
609
+ return; // OBS-888: operator prose under a turbo prefix
610
+ const { label, content } = splitLabel(l);
611
+ if (L_ROW_RE.test(content) && LINTER_ERROR_RE.test(content)) {
612
+ const header = headers.get(index) ?? null;
613
+ occurrences.push({ id: JSON.stringify(["stylish", label, header, normalizeV2(content)]), line: l, ...(header === null ? { unattributed: true } : {}) });
614
+ return;
615
+ }
616
+ if (isFingerprintShaped(l))
617
+ occurrences.push({ id: normalizeV2(l), line: l });
618
+ if (stripped !== undefined && !PASS_LINE_RE.test(stripped) && isFingerprintShaped(stripped))
619
+ occurrences.push({ id: normalizeV2(stripped), line: stripped });
620
+ });
621
+ if (!occurrences.length && printed)
622
+ return [{ id: UNRECOGNIZED_FAILURE, line: UNRECOGNIZED_FAILURE }];
623
+ return occurrences;
624
+ }
497
625
  // The diagnostic channel for a runner we cannot read: raw output lines, never fingerprints, never a
498
626
  // verdict. They exist so the operator sees SOMETHING when a green command turns red unrecognizably.
499
627
  const unrecognizedEvidence = (raw) => raw
@@ -505,23 +633,81 @@ const unrecognizedEvidence = (raw) => raw
505
633
  // stored baselines may predate ANSI/pass-marker hardening — renormalize at compare time so existing
506
634
  // on-disk baseline.json files stay comparable without recapture (compat invariant, CLAUDE.md)
507
635
  const renormalize = (fp) => normalizeLine(fp.replace(ANSI_RE, ""));
636
+ const entryState = (entry) => {
637
+ if (entry === undefined)
638
+ return { state: "missing" };
639
+ if (entry === null || typeof entry !== "object")
640
+ return { state: "unreadable", why: "entry is not an object" };
641
+ const version = entry.fingerprintVersion;
642
+ if (version !== undefined && version !== 2)
643
+ return { state: "unreadable", why: `fingerprintVersion ${JSON.stringify(version)} is not 2` };
644
+ const fps = entry.fingerprints;
645
+ // ponytail: an absent list keeps 2.7.8's `?? []` for legacy entries; a present one must be all strings
646
+ if ((fps !== undefined || version === 2) && !(Array.isArray(fps) && fps.every((f) => typeof f === "string"))) {
647
+ return { state: "unreadable", why: "fingerprints is not an array of strings" };
648
+ }
649
+ if (entry.infra === true)
650
+ return { state: "no-verdict" };
651
+ return { state: version === 2 ? "v2" : "legacy" };
652
+ };
653
+ /** The red reason a missing or unreadable entry owes in BOTH readers; undefined for every other state. */
654
+ export function baselineEntryRefusal(gate, verdict) {
655
+ if (verdict.state === "missing")
656
+ return `no baseline entry for ${gate}`;
657
+ if (verdict.state === "unreadable")
658
+ return `baseline entry for ${gate} is unreadable (${verdict.why})`;
659
+ return undefined;
660
+ }
661
+ // OBS-42: diagnostic headings enrich fingerprints but cannot reject on their own.
662
+ const rejects = (f) => !FAIL_ANCHOR_RE.test(f) || f.startsWith("FAIL ");
508
663
  /**
509
- * Q121s: the battery's forgiveness math, exported so tip-verify applies the IDENTICAL rule.
510
- * Returns the failure fingerprints of `raw` that are NOT in the baseline entry (fresh), and
511
- * whether the output carried no recognizable failure shape at all.
664
+ * Q121s / S22: the battery's forgiveness math, exported so tip verify applies the IDENTICAL rule, and
665
+ * the one place an entry's state is decided — each reader renders its reason, neither re-derives it.
666
+ *
667
+ * A LEGACY entry (no fingerprintVersion) compares exactly as 2.7.8 did: set membership over lines whose
668
+ * every digit run is masked. A v2 entry is a MULTISET under two closed grammars (ANL-021 S22):
669
+ * - table T: only whole tokens of a closed volatile shape (durations, numbers, positions, ratios, ISO
670
+ * times, key=number, hex addresses) lose their digits; every other token keeps them. Declared cost: a
671
+ * volatile token outside T stays verbatim, so its change reads fresh (a closed false red, not a defect).
672
+ * - layout L: a stylish error row is keyed by its stream label and the header layout L proves (blank or
673
+ * nothing before it, rows right after it). Declared costs: a row outside every proven block is never
674
+ * forgiven, and a header printed under a realpath variant of the cwd compares as a different header.
675
+ * f is fresh when the candidate repeats it more often than the capture recorded it.
512
676
  */
513
- export function freshFailures(entry, raw) {
677
+ export function freshFailures(entry, raw, readKillText = false) {
678
+ // S19m4: kill text is read here, before table T, from the in-memory output — never the redacted tail.
679
+ const killLine = readKillText ? firstKillLine(raw) : undefined;
680
+ const kill = killLine !== undefined ? { killLine } : {};
681
+ const { state, why } = entryState(entry);
682
+ const baselineRed = (state === "legacy" || state === "v2") && (entry.exitCode ?? 1) !== 0;
683
+ if (state === "v2") {
684
+ const recorded = new Map();
685
+ for (const f of entry.fingerprints)
686
+ recorded.set(f, (recorded.get(f) ?? 0) + 1);
687
+ const occurrences = fingerprintOccurrences(raw);
688
+ const seen = new Map();
689
+ for (const o of occurrences)
690
+ seen.set(o.id, (seen.get(o.id) ?? 0) + 1);
691
+ const isFresh = (o) => o.id !== UNRECOGNIZED_FAILURE && rejects(o.id)
692
+ && (o.unattributed === true || seen.get(o.id) > (recorded.get(o.id) ?? 0));
693
+ const fresh = occurrences.filter(isFresh);
694
+ return {
695
+ failing: [...new Set(fresh.map((o) => o.id))],
696
+ unreadable: seen.has(UNRECOGNIZED_FAILURE),
697
+ forgiven: [...new Set(occurrences.filter((o) => o.unattributed !== true && seen.get(o.id) <= (recorded.get(o.id) ?? 0)).map((o) => o.id))],
698
+ failingLines: fresh.map((o) => o.line),
699
+ state, baselineRed, ...kill,
700
+ };
701
+ }
514
702
  // OBS-534 (T2): an infra-recorded entry is a KILL, not a verdict — whatever fingerprints it carries
515
703
  // came from output flushed before the kill, over a suite that never finished. Nothing there is
516
- // "pre-existing", so it forgives nothing. The rule lives here rather than in either caller, so the
517
- // battery and tip verify inherit it from the one helper they already share.
518
- const known = new Set(entry?.infra === true ? [] : (entry?.fingerprints ?? []).map(renormalize));
519
- // OBS-42: diagnostic headings enrich fingerprints but cannot invalidate legacy baselines.
704
+ // "pre-existing", so it forgives nothing; neither does a missing or unreadable entry.
705
+ const known = new Set(state === "legacy" ? (entry.fingerprints ?? []).map(renormalize) : []);
520
706
  const current = fingerprint(raw);
521
- const fresh = current.filter((f) => !known.has(f) && (!FAIL_ANCHOR_RE.test(f) || f.startsWith("FAIL ")));
707
+ const fresh = current.filter((f) => !known.has(f) && rejects(f) && f !== UNRECOGNIZED_FAILURE);
522
708
  // OBS-1123: the baseline-recorded half, named so a reader never has to infer it from the fresh half.
523
- return { failing: fresh.filter((f) => f !== UNRECOGNIZED_FAILURE), unreadable: current.includes(UNRECOGNIZED_FAILURE),
524
- forgiven: current.filter((f) => known.has(f)) };
709
+ return { failing: fresh, unreadable: current.includes(UNRECOGNIZED_FAILURE),
710
+ forgiven: current.filter((f) => known.has(f)), failingLines: fresh, state, ...(why ? { why } : {}), baselineRed, ...kill };
525
711
  }
526
712
  /**
527
713
  * Operator directive 2026-08-12 (D-OBS-10): tickmarkr is package-manager agnostic.
@@ -785,8 +971,9 @@ export async function captureBaseline(cwd, commands, opts = {}) {
785
971
  exitCode: runnerVerdict === "green-teardown" ? 0 : r.code,
786
972
  ...(runnerVerdict === "green-teardown" ? { teardownFingerprint: true } : {}),
787
973
  // a command that exits 0 has no failures to fingerprint — recording any would be a lie the
788
- // compare step then has to forgive
789
- fingerprints: r.code === 0 || runnerVerdict === "green-teardown" ? [] : fingerprint(raw),
974
+ // compare step then has to forgive. S22: one v2 identity per occurrence, sorted.
975
+ fingerprints: r.code === 0 || runnerVerdict === "green-teardown" ? [] : fingerprintOccurrences(raw).map((o) => o.id).sort(),
976
+ fingerprintVersion: 2,
790
977
  missingCommand: missingConfiguredCommand(cmd, r),
791
978
  durationMs,
792
979
  ...fileTiming(raw, durationMs),
@@ -840,15 +1027,20 @@ export async function detectVacuousOracles(cwd, tasks) {
840
1027
  // drop a FAIL line that fingerprint-collides with baseline noise). No headline anchors → byte-identical
841
1028
  // fallback to today's text (non-vitest runners lose nothing). RED-pinned by tests/gates/baseline.test.ts
842
1029
  // "HYG-08: details headlines the failing test, not the noise".
843
- function headlineDetails(raw, fresh) {
1030
+ function headlineDetails(raw, fresh, freshLines) {
844
1031
  const headlines = raw
845
1032
  .split("\n")
846
1033
  .map((l) => l.replace(ANSI_RE, ""))
847
1034
  .filter((l) => namesFailureEitherForm(l) || SUMMARY_FAIL_RE.test(l));
1035
+ // S22: the ORIGINAL runner lines behind v2 comparison keys are the diagnostic; the keys are secondary.
1036
+ // A legacy entry's lines ARE its keys, so its details stay byte-identical.
1037
+ const lines = [...new Set(freshLines)].join("\n");
1038
+ const keys = `new failure fingerprints vs baseline (secondary):\n${fresh.join("\n")}`;
1039
+ const same = lines === fresh.join("\n");
848
1040
  if (!headlines.length)
849
- return { details: `new failures vs baseline:\n${fresh.join("\n")}` };
1041
+ return { details: same ? `new failures vs baseline:\n${lines}` : `new failures vs baseline:\n${lines}\n\n${keys}` };
850
1042
  return {
851
- details: `failing tests:\n${headlines.join("\n")}\n\nnew failure fingerprints vs baseline (secondary):\n${fresh.join("\n")}`,
1043
+ details: `failing tests:\n${headlines.join("\n")}\n\n${same ? "" : `new failure lines vs baseline:\n${lines}\n\n`}${keys}`,
852
1044
  meta: { failingTests: headlines.filter(namesFailureEitherForm) },
853
1045
  };
854
1046
  }
@@ -871,6 +1063,8 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
871
1063
  continue;
872
1064
  }
873
1065
  const entry = baseline.commands[name];
1066
+ const entryRead = entryState(entry);
1067
+ const refusal = baselineEntryRefusal(name, entryRead);
874
1068
  const ceilingMs = effectiveCeilingMs(entry);
875
1069
  const evidence = beginGateEvidence(cwd, name, cmd, opts.evidence);
876
1070
  let r;
@@ -902,6 +1096,12 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
902
1096
  let recoveryBlocked;
903
1097
  const record = (g) => {
904
1098
  g = { ...g, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] };
1099
+ // S22: every red that read this command's output names a missing or unreadable entry, once, beside
1100
+ // its own cause (infra, deficit, fresh). A ceiling kill is read before the output and forgives
1101
+ // nothing either way; the tip's kill clause is queue row 19's, so neither reader appends it there.
1102
+ if (!g.pass && refusal && g.meta?.kind !== "ceiling-kill") {
1103
+ g = { ...g, details: g.details.includes(refusal) ? g.details : `${g.details}\n\n${refusal}`, meta: { ...g.meta, baselineEntry: entryRead.state } };
1104
+ }
905
1105
  if (recoveryBlocked)
906
1106
  g = { ...g, meta: { ...g.meta, recoveryBlocked } };
907
1107
  const withReap = r.reapedGroup ? { ...g, meta: { ...g.meta, reapedGroup: true } } : g;
@@ -977,7 +1177,8 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
977
1177
  // below rather than reading as a verified green. Raise the ceiling by teaching isFailureShaped
978
1178
  // that runner's position rule (leading verdict + identifier, or identifier + separator + trailing
979
1179
  // verdict); loosening back to vocabulary re-opens OBS-278.
980
- const { failing, unreadable, forgiven } = freshFailures(entry, raw);
1180
+ const verdict = freshFailures(entry, raw, name === "test");
1181
+ const { failing, unreadable, forgiven, failingLines, baselineRed, killLine } = verdict;
981
1182
  // OBS-1123: which baseline-recorded fingerprints this verdict carried, and the capture that
982
1183
  // recorded them. Observational only — nothing below reads it to decide a verdict.
983
1184
  const provenance = baseline.provenance ? { baselineProvenance: baseline.provenance } : {};
@@ -1004,7 +1205,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
1004
1205
  // OBS-896: every fresh failure must be timeout-class, and the suite must take at least twice its
1005
1206
  // own baseline measurement. The first read buys one calm rerun here, never a worker repair.
1006
1207
  if (name === "test" && classification !== "infra" && failing.length && !rerunOf && !opts.infraRerun
1007
- && hostStarved(failing.join("\n"), r.durationMs ?? 0, entry?.durationMs)) {
1208
+ && hostStarved(failingLines.join("\n"), r.durationMs ?? 0, entry?.durationMs)) {
1008
1209
  const waitedMs = await waitForCalmWindow(executionSignal());
1009
1210
  if (opts.authorizeRetry && !calmWindowReady()) {
1010
1211
  recoveryBlocked = "calm window unavailable within the existing wait ceiling";
@@ -1021,8 +1222,8 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
1021
1222
  }
1022
1223
  }
1023
1224
  if (classification === "infra") {
1024
- const evidence = failing.length
1025
- ? failing.slice(0, 10).join("\n")
1225
+ const evidence = failingLines.length
1226
+ ? failingLines.slice(0, 10).join("\n")
1026
1227
  : unrecognizedEvidence(raw) || raw.trim().split("\n").slice(0, 10).join("\n");
1027
1228
  record({
1028
1229
  gate: name,
@@ -1035,17 +1236,19 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
1035
1236
  // OBS-534 (T2): only a recorded VERDICT can be forgiven. A green baseline has no red to forgive,
1036
1237
  // and neither has a capture that was killed at its ceiling — it recorded a cause instead, so it
1037
1238
  // fails closed on the same branch rather than reading as "only pre-existing failures". Legacy
1038
- // entries with no exitCode keep the `?? 1` red default both readers share (merge.ts:131).
1039
- const baselineRed = entry?.infra !== true && (entry?.exitCode ?? 1) !== 0;
1239
+ // entries with no exitCode keep the `?? 1` red default both readers share (freshFailures'
1240
+ // baselineRed); S22: a missing or unreadable entry forgives nothing and says so.
1040
1241
  if (!failing.length && !baselineRed) {
1041
1242
  const recordedCause = entry?.invalidCause === "infra"
1042
1243
  ? "was invalidated by its recorded runner-infrastructure cause"
1043
1244
  : entry?.invalidCause === "resource-exhaustion" || entry?.invalidatingLines?.length
1044
1245
  ? `was invalidated by its recorded process/resource-exhaustion cause${entry.invalidatingLines?.[0] ? ` (${entry.invalidatingLines[0]})` : ""}`
1045
1246
  : "was killed at its ceiling";
1046
- const closed = entry?.infra === true
1047
- ? `the baseline capture for this command ${recordedCause} and recorded no verdict, so nothing here is forgivable — it now exits ${r.code} with no recognizable failure lines — failing closed`
1048
- : `command was green at baseline but now exits ${r.code} with no recognizable failure lines — failing closed`;
1247
+ const closed = refusal
1248
+ ? `exit ${r.code}; ${refusal} — nothing is forgiven, failing closed`
1249
+ : entry?.infra === true
1250
+ ? `the baseline capture for this command ${recordedCause} and recorded no verdict, so nothing here is forgivable — it now exits ${r.code} with no recognizable failure lines — failing closed`
1251
+ : `command was green at baseline but now exits ${r.code} with no recognizable failure lines — failing closed`;
1049
1252
  const evidence = unrecognizedEvidence(raw);
1050
1253
  record({
1051
1254
  gate: name,
@@ -1056,7 +1259,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
1056
1259
  continue;
1057
1260
  }
1058
1261
  if (failing.length) {
1059
- const headlined = headlineDetails(raw, failing);
1262
+ const headlined = headlineDetails(raw, failing, failingLines);
1060
1263
  // A fresh red beside baseline-recorded ones names both halves, so no reader shows the new one as forgiven.
1061
1264
  const meta = { ...headlined.meta, ...(classification ? { classification } : {}),
1062
1265
  ...(forgiven.length ? { freshFingerprints: failing, forgivenFingerprints: forgiven, ...provenance } : {}) };
@@ -1079,11 +1282,22 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
1079
1282
  });
1080
1283
  continue;
1081
1284
  }
1285
+ // S19m4: a killed test runner completed no suite, so its familiar failures are no verdict to forgive.
1286
+ if (killLine !== undefined) {
1287
+ record({
1288
+ gate: name,
1289
+ pass: false,
1290
+ details: `infra; exit ${r.code}; every failure is recorded in the baseline, but the runner printed kill text — `
1291
+ + `it never completed a suite, so nothing is forgiven: ${killLine}`,
1292
+ meta: { classification: "infra", infra: true, retryable: false, kind: "signal-exit", forgivenFingerprints: forgiven, ...provenance },
1293
+ });
1294
+ continue;
1295
+ }
1082
1296
  record({
1083
1297
  gate: name,
1084
1298
  pass: true,
1085
1299
  details: `exit ${r.code} but only pre-existing failures (forgiven)${unreadable ? " — no failure shape recognized in this output, so a new failure from this runner is invisible to the baseline gate" : ""}`,
1086
- meta: { ...(classification ? { classification } : {}), forgivenFingerprints: forgiven, ...provenance },
1300
+ meta: { ...(classification ? { classification } : {}), forgivenFingerprints: forgiven, ...provenance, ...(name === "test" ? { killTextRead: true } : {}) },
1087
1301
  });
1088
1302
  }
1089
1303
  return results;
@@ -1171,7 +1385,7 @@ export function fileCountDeficit(entry, raw, opts) {
1171
1385
  export function classifyFreshRunnerOutput(entry, raw, code) {
1172
1386
  if (classifyRunnerOutput(raw, code) === "green-teardown")
1173
1387
  return undefined;
1174
- const { failing } = freshFailures(entry, raw);
1175
- const verdict = classifyRunnerOutput(failing.length ? failing.join("\n") : raw, code);
1388
+ const { failingLines } = freshFailures(entry, raw);
1389
+ const verdict = classifyRunnerOutput(failingLines.length ? failingLines.join("\n") : raw, code);
1176
1390
  return verdict === "infra" || verdict === "regression" ? verdict : undefined;
1177
1391
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Queue row 142 (ANL-021 P7, limited to three facts; D-1843, D-1844): a diff made only of a pure rename, an empty new
3
+ * file or a mode-only change carries no changed hunk, so the acceptance judge had nothing to cite. These facts come
4
+ * from TYPED git measurement, read beside the patch text and never from it: no diff --git, rename, mode or new file
5
+ * header line of the patch is ever an authority here, so a file whose content spells those headers creates nothing.
6
+ *
7
+ * Measurement: the worktree HEAD is resolved to one commit, then tickmarkr's own-git runner reads
8
+ * `git diff --raw -z --no-abbrev -M <baseRef>..<commit>` with stdout captured as BYTES. -M forces rename detection
9
+ * whatever diff.renames says in repository, global or inherited (GIT_CONFIG_*) configuration, and no copy option is
10
+ * passed; nothing else is configured. -z makes git print every path verbatim (never C-quoted, so core.quotePath is
11
+ * irrelevant), and the bytes are split on NUL before any decoding, so no name can move a record boundary.
12
+ *
13
+ * Closed record grammar, every field NUL-terminated:
14
+ * ':' oldmode SP newmode SP oldhash SP newhash SP status NUL path NUL [path NUL — exactly when status is R or C]
15
+ * modes are six octal digits, hashes 40 or 64 lowercase hex, status one of A C D M R T U X, and R and C (only they)
16
+ * carry exactly three score digits. A header outside the grammar, an empty path or a truncated record ends the read
17
+ * with NO facts at all. Each path is decoded by a FATAL UTF-8 decoder (BOM kept): a name that is not valid UTF-8
18
+ * makes its record no fact, so a lossy U+FFFD rendering can never authorize a valid U+FFFD-named file beside it.
19
+ *
20
+ * The closed fact table — each fact is line 0 of its exact path:
21
+ * pure rename R100, equal blob ids and equal modes; the fact names the destination (never the source)
22
+ * empty add A whose blob id equals this repository's empty blob (git hash-object -t blob /dev/null)
23
+ * mode-only change M, equal blob ids, modes 100644 and 100755 exchanged
24
+ * Every other record is no fact: binary adds and modifications (git's binary decision follows worker-writable
25
+ * attributes, and the judge cannot read the content), deletions (an empty file's included), renames below 100 or with
26
+ * a mode change, copies, type changes and every other status. A non-zero or killed read gives no facts, and so does a
27
+ * HEAD that resolves to another commit after the judged diff was read (operationFactsAtHead).
28
+ */
29
+ export type OperationFactKind = "pure rename" | "empty add" | "mode-only change";
30
+ export interface OperationFact {
31
+ kind: OperationFactKind;
32
+ path: string;
33
+ }
34
+ export type OperationFactsRead = {
35
+ ok: true;
36
+ commit: string;
37
+ facts: OperationFact[];
38
+ } | {
39
+ ok: false;
40
+ error: string;
41
+ };
42
+ /** The facts in raw `-z` output, or null when any record falls outside the closed grammar. */
43
+ export declare function parseOperationFacts(raw: Buffer, emptyBlob: string): OperationFact[] | null;
44
+ /** Reads the typed operation facts of `<baseRef>..HEAD`, bound to the commit HEAD resolved to. Never throws. */
45
+ export declare function readOperationFacts(worktree: string, baseRef: string): Promise<OperationFactsRead>;
46
+ /** The read's facts only while HEAD still resolves to the commit they measured; call it after the judged diff is read. */
47
+ export declare function operationFactsAtHead(worktree: string, read: OperationFactsRead): Promise<OperationFact[]>;
@@ -0,0 +1,84 @@
1
+ import { shq } from "../adapters/types.js";
2
+ import { shGit } from "../run/git.js";
3
+ const RECORD_HEADER = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) ([RC][0-9]{3}|[ADMTUX])$/;
4
+ const EXECUTABLE_EXCHANGE = new Set(["100644", "100755"]);
5
+ function decodeName(bytes) {
6
+ try {
7
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ /** The facts in raw `-z` output, or null when any record falls outside the closed grammar. */
14
+ export function parseOperationFacts(raw, emptyBlob) {
15
+ const fields = [];
16
+ for (let start = 0; start < raw.length;) {
17
+ const end = raw.indexOf(0, start);
18
+ if (end < 0)
19
+ return null; // a field git did not terminate: truncated output
20
+ fields.push(raw.subarray(start, end));
21
+ start = end + 1;
22
+ }
23
+ const facts = [];
24
+ for (let i = 0; i < fields.length;) {
25
+ // latin1 maps each byte to one code point, so a non-ASCII byte can never satisfy the ASCII header grammar
26
+ const header = RECORD_HEADER.exec(fields[i].toString("latin1"));
27
+ if (!header)
28
+ return null;
29
+ const [, oldMode, newMode, oldId, newId, status] = header;
30
+ const count = status[0] === "R" || status[0] === "C" ? 2 : 1;
31
+ const paths = fields.slice(i + 1, i + 1 + count);
32
+ if (paths.length !== count || paths.some((path) => path.length === 0))
33
+ return null;
34
+ i += 1 + count;
35
+ const names = paths.map(decodeName);
36
+ if (names.some((name) => name === null))
37
+ continue;
38
+ const path = names[count - 1];
39
+ if (status === "R100" && oldId === newId && oldMode === newMode)
40
+ facts.push({ kind: "pure rename", path });
41
+ else if (status === "A" && newId === emptyBlob)
42
+ facts.push({ kind: "empty add", path });
43
+ else if (status === "M" && oldId === newId && oldMode !== newMode && EXECUTABLE_EXCHANGE.has(oldMode) && EXECUTABLE_EXCHANGE.has(newMode)) {
44
+ facts.push({ kind: "mode-only change", path });
45
+ }
46
+ }
47
+ return facts;
48
+ }
49
+ async function resolveHead(worktree) {
50
+ const head = await shGit("git rev-parse --verify --end-of-options 'HEAD^{commit}'", worktree);
51
+ return head.code === 0 && !head.timedOut ? head.stdout.trim() : null;
52
+ }
53
+ /** Reads the typed operation facts of `<baseRef>..HEAD`, bound to the commit HEAD resolved to. Never throws. */
54
+ export async function readOperationFacts(worktree, baseRef) {
55
+ try {
56
+ const commit = await resolveHead(worktree);
57
+ if (!commit)
58
+ return { ok: false, error: "HEAD did not resolve to a commit" };
59
+ // --no-filters: the empty blob is git's, never a clean filter's output under a worker-written attribute
60
+ const empty = await shGit("git hash-object -t blob --no-filters /dev/null", worktree);
61
+ if (empty.code !== 0 || empty.timedOut)
62
+ return { ok: false, error: `git hash-object exited ${empty.code}` };
63
+ const raw = await shGit(`git diff --raw -z --no-abbrev -M ${shq(`${baseRef}..${commit}`)} --`, worktree, undefined, {}, { bytes: true });
64
+ if (raw.code !== 0 || raw.signalExit || raw.timedOut || !raw.stdoutBytes) {
65
+ return { ok: false, error: `git diff --raw exited ${raw.code}${raw.signalExit ? " (killed)" : ""}` };
66
+ }
67
+ const facts = parseOperationFacts(raw.stdoutBytes, empty.stdout.trim());
68
+ return facts ? { ok: true, commit, facts } : { ok: false, error: "a raw diff record is outside the closed grammar" };
69
+ }
70
+ catch (error) {
71
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
72
+ }
73
+ }
74
+ /** The read's facts only while HEAD still resolves to the commit they measured; call it after the judged diff is read. */
75
+ export async function operationFactsAtHead(worktree, read) {
76
+ if (!read.ok)
77
+ return [];
78
+ try {
79
+ return (await resolveHead(worktree)) === read.commit ? read.facts : [];
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }