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.
- package/dist/adapters/codex.d.ts +1 -1
- package/dist/adapters/codex.js +18 -6
- package/dist/adapters/fake.js +42 -4
- package/dist/cli/commands/doctor.js +11 -2
- package/dist/cli/commands/fleet.js +206 -18
- package/dist/cli/commands/verify.js +9 -1
- package/dist/gates/acceptance.d.ts +1 -1
- package/dist/gates/acceptance.js +75 -68
- package/dist/gates/baseline.d.ts +42 -9
- package/dist/gates/baseline.js +261 -47
- package/dist/gates/operation-facts.d.ts +47 -0
- package/dist/gates/operation-facts.js +84 -0
- package/dist/gates/run-gates.js +20 -11
- package/dist/gates/test-manifest.js +2 -2
- package/dist/run/git.d.ts +5 -1
- package/dist/run/git.js +7 -3
- package/dist/run/journal.d.ts +1 -1
- package/dist/run/launchservices-check.d.ts +23 -1
- package/dist/run/launchservices-check.js +88 -3
- package/dist/run/merge.js +26 -12
- package/package.json +1 -1
package/dist/gates/baseline.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
const
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
107
|
-
|
|
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,
|
|
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
|
|
510
|
-
*
|
|
511
|
-
*
|
|
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
|
|
517
|
-
|
|
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) && (
|
|
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
|
|
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" ? [] :
|
|
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${
|
|
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\
|
|
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
|
|
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(
|
|
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 =
|
|
1025
|
-
?
|
|
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 (
|
|
1039
|
-
|
|
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 =
|
|
1047
|
-
? `
|
|
1048
|
-
:
|
|
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 {
|
|
1175
|
-
const verdict = classifyRunnerOutput(
|
|
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
|
+
}
|