tickmarkr 1.87.0 → 1.90.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/catalog.d.ts +18 -1
- package/dist/adapters/catalog.js +44 -1
- package/dist/adapters/fake.d.ts +2 -1
- package/dist/adapters/fake.js +7 -0
- package/dist/adapters/grok.js +11 -0
- package/dist/adapters/kimi.d.ts +2 -1
- package/dist/adapters/kimi.js +36 -0
- package/dist/adapters/opencode.js +17 -0
- package/dist/adapters/pi.js +11 -0
- package/dist/adapters/prompt.js +8 -1
- package/dist/adapters/registry.js +76 -57
- package/dist/adapters/types.d.ts +34 -3
- package/dist/adapters/types.js +99 -1
- package/dist/cli/commands/approve.d.ts +2 -0
- package/dist/cli/commands/approve.js +104 -84
- package/dist/cli/commands/compile.d.ts +1 -1
- package/dist/cli/commands/compile.js +29 -12
- package/dist/cli/commands/doctor.d.ts +16 -0
- package/dist/cli/commands/doctor.js +52 -0
- package/dist/cli/commands/init.js +2 -1
- package/dist/cli/commands/plan.d.ts +1 -1
- package/dist/cli/commands/plan.js +10 -1
- package/dist/cli/commands/report.js +49 -0
- package/dist/cli/commands/status.js +298 -96
- package/dist/cli/commands/verify.d.ts +9 -0
- package/dist/cli/commands/verify.js +177 -0
- package/dist/cli/harness.d.ts +13 -0
- package/dist/cli/harness.js +50 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/compile/collateral.js +11 -11
- package/dist/compile/common.js +2 -2
- package/dist/compile/index.d.ts +14 -3
- package/dist/compile/index.js +36 -10
- package/dist/compile/native.d.ts +15 -1
- package/dist/compile/native.js +310 -28
- package/dist/config/config.js +2 -2
- package/dist/drivers/subprocess.d.ts +6 -1
- package/dist/drivers/subprocess.js +9 -4
- package/dist/gates/acceptance.d.ts +21 -1
- package/dist/gates/acceptance.js +67 -22
- package/dist/gates/artifact-manifest.d.ts +119 -0
- package/dist/gates/artifact-manifest.js +357 -0
- package/dist/gates/baseline.d.ts +45 -5
- package/dist/gates/baseline.js +119 -15
- package/dist/gates/llm.js +37 -26
- package/dist/gates/review.d.ts +16 -11
- package/dist/gates/review.js +44 -150
- package/dist/gates/run-gates.js +124 -7
- package/dist/gates/scope.js +3 -3
- package/dist/graph/files-glob.d.ts +18 -0
- package/dist/graph/files-glob.js +22 -0
- package/dist/graph/schema.d.ts +3 -1
- package/dist/graph/schema.js +4 -1
- package/dist/run/daemon.d.ts +44 -0
- package/dist/run/daemon.js +2334 -1973
- package/dist/run/git.d.ts +53 -0
- package/dist/run/git.js +119 -5
- package/dist/run/interactive-seed.d.ts +6 -2
- package/dist/run/interactive-seed.js +72 -5
- package/dist/run/journal.d.ts +9 -1
- package/dist/run/journal.js +99 -9
- package/dist/run/lock.d.ts +11 -0
- package/dist/run/lock.js +97 -6
- package/dist/run/merge.d.ts +4 -1
- package/dist/run/merge.js +26 -7
- package/dist/run/outcome.d.ts +50 -0
- package/dist/run/outcome.js +152 -0
- package/dist/run/protocol.d.ts +460 -0
- package/dist/run/protocol.js +433 -0
- package/dist/run/supervision.d.ts +29 -0
- package/dist/run/supervision.js +189 -0
- package/fixtures/authoring-lints/01-awk-range-self-pass.spec.md +12 -0
- package/fixtures/authoring-lints/02-judge-text-key-miss.spec.md +7 -0
- package/fixtures/authoring-lints/03-c1-t41-rendered-observable.spec.md +8 -0
- package/fixtures/authoring-lints/04-c1-t24-named-file.spec.md +8 -0
- package/fixtures/authoring-lints/05-c2-t24-t28-dep-inversion.spec.md +7 -0
- package/fixtures/authoring-lints/06-c2-denumbered-coupling.spec.md +7 -0
- package/fixtures/authoring-lints/07-c3a-t41-line-count-proxy.spec.md +7 -0
- package/fixtures/authoring-lints/08-c3b-t41-governance-referent.spec.md +7 -0
- package/fixtures/authoring-lints/09-c4-universals-without-pointer.spec.md +7 -0
- package/fixtures/authoring-lints/10-c5-t34-conjunct-flood.spec.md +7 -0
- package/fixtures/authoring-lints/11-c6-t34-q3-q9-q20-bundle.spec.md +7 -0
- package/fixtures/authoring-lints/12-c7-t24-prose-seam.spec.md +8 -0
- package/fixtures/wrapped-acceptance.native.md +29 -0
- package/package.json +1 -1
- package/schema/rungraph.schema.json +21 -2
- package/skills/tickmarkr-overseer/SKILL.md +262 -5
- package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +79 -8
- package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +80 -0
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +86 -0
- package/skills/tickmarkr-overseer/scripts/watch-parks.sh +96 -0
- package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +201 -0
package/dist/run/lock.js
CHANGED
|
@@ -17,8 +17,10 @@ export const STALE_MS = 60_000; // 6× heartbeat headroom; PITFALLS floor is ≥
|
|
|
17
17
|
// pid/runId/startedAt are ever read; anything else is ignored (info-disclosure surface).
|
|
18
18
|
const PayloadSchema = z.object({ pid: z.number().int().positive(), runId: z.string(), startedAt: z.number() });
|
|
19
19
|
const lockPath = (repoRoot) => join(tickmarkrDir(repoRoot), "graph.lock");
|
|
20
|
+
const approvalSerializationPath = (repoRoot) => join(tickmarkrDir(repoRoot), "approval.lock");
|
|
20
21
|
let heartbeat;
|
|
21
22
|
let heldPath;
|
|
23
|
+
let approvalSequence = 0;
|
|
22
24
|
// Best-effort release if the daemon exits without hitting its finally (crash mid-body). NO
|
|
23
25
|
// SIGINT/SIGTERM handlers — those change kill semantics and leak listeners across the test
|
|
24
26
|
// suite's many runDaemon calls; signal-death is exactly what stale-reclaim exists for.
|
|
@@ -141,16 +143,105 @@ export function releaseRunLock(repoRoot) {
|
|
|
141
143
|
unlinkIfOurs(lockPath(repoRoot)); // never delete a reclaiming successor's lock — only ours
|
|
142
144
|
heldPath = undefined;
|
|
143
145
|
}
|
|
146
|
+
// T14: task-approved append and the terminal outstandingApprovals sample + run-end append are one
|
|
147
|
+
// cross-process critical section. The daemon keeps this boundary until AFTER it releases graph.lock:
|
|
148
|
+
// an approval wins first and is necessarily in run-end, or run-end wins first and the command cannot
|
|
149
|
+
// append until there is no live owner. There is no interval in which an accepted approval can land
|
|
150
|
+
// behind the completion sample while graph.lock still makes it look deferred to that daemon.
|
|
151
|
+
//
|
|
152
|
+
// The boundary uses the same atomic link(2) idiom and the same inspect() authority as graph.lock.
|
|
153
|
+
// A dead holder is reclaimed with the same inode+mtime guard; a live holder is waited out; garbage
|
|
154
|
+
// fails closed. No caller re-derives pid liveness.
|
|
155
|
+
export async function acquireApprovalSerialization(repoRoot, runId) {
|
|
156
|
+
const p = approvalSerializationPath(repoRoot);
|
|
157
|
+
let contended = false;
|
|
158
|
+
while (true) {
|
|
159
|
+
const tmp = join(tickmarkrDir(repoRoot), `approval.lock.${process.pid}.${approvalSequence++}.tmp`);
|
|
160
|
+
try {
|
|
161
|
+
writeFileSync(tmp, JSON.stringify({ pid: process.pid, runId, startedAt: Date.now() }));
|
|
162
|
+
try {
|
|
163
|
+
linkSync(tmp, p);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
unlinkSync(tmp);
|
|
167
|
+
}
|
|
168
|
+
let released = false;
|
|
169
|
+
return {
|
|
170
|
+
contended,
|
|
171
|
+
release: () => {
|
|
172
|
+
if (released)
|
|
173
|
+
return;
|
|
174
|
+
released = true;
|
|
175
|
+
unlinkIfOurs(p);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
try {
|
|
181
|
+
unlinkSync(tmp);
|
|
182
|
+
}
|
|
183
|
+
catch { /* link loser already cleaned its private temporary */ }
|
|
184
|
+
if (e.code !== "EEXIST")
|
|
185
|
+
throw e;
|
|
186
|
+
contended = true;
|
|
187
|
+
let insp;
|
|
188
|
+
try {
|
|
189
|
+
insp = inspect(p);
|
|
190
|
+
}
|
|
191
|
+
catch (inspectError) {
|
|
192
|
+
if (inspectError.code === "ENOENT")
|
|
193
|
+
continue;
|
|
194
|
+
throw inspectError;
|
|
195
|
+
}
|
|
196
|
+
if (insp.garbage) {
|
|
197
|
+
throw new Error(`${stateDirName(repoRoot)}/approval.lock holds an unreadable/garbage payload — refusing to cross the run-end boundary`);
|
|
198
|
+
}
|
|
199
|
+
if (insp.dead) {
|
|
200
|
+
let st;
|
|
201
|
+
try {
|
|
202
|
+
st = statSync(p);
|
|
203
|
+
}
|
|
204
|
+
catch (statError) {
|
|
205
|
+
if (statError.code === "ENOENT")
|
|
206
|
+
continue;
|
|
207
|
+
throw statError;
|
|
208
|
+
}
|
|
209
|
+
if (st.ino !== insp.ino || st.mtimeMs !== insp.mtimeMs)
|
|
210
|
+
continue;
|
|
211
|
+
try {
|
|
212
|
+
unlinkSync(p);
|
|
213
|
+
}
|
|
214
|
+
catch (e2) {
|
|
215
|
+
if (e2.code !== "ENOENT")
|
|
216
|
+
throw e2;
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// LOCK-04: the owner the decision table sees, read-only, for callers that need the pid as well as
|
|
225
|
+
// the answer. inspect() owns pid-liveness (ESRCH dead / EPERM alive / garbage fail-closed); a second
|
|
226
|
+
// `process.kill(pid, 0)` anywhere else would be a second copy of that rule, free to drift. undefined
|
|
227
|
+
// ⇒ no lock at all — never conflate that with a lock whose recorded owner is dead.
|
|
228
|
+
export function runLockOwner(repoRoot) {
|
|
229
|
+
let insp;
|
|
230
|
+
try {
|
|
231
|
+
insp = inspect(lockPath(repoRoot));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
} // statSync ENOENT ⇒ not held
|
|
236
|
+
// `runId` is carried so a caller can name the run the live owner is actually executing — the lock is
|
|
237
|
+
// REPOSITORY-wide, so a live pid here is not proof it is running the run the caller cares about.
|
|
238
|
+
return { pid: insp.pid, runId: insp.runId, live: shouldRefuse(insp) };
|
|
239
|
+
}
|
|
144
240
|
// Read-only predicate: true iff a lock exists that the decision table would REFUSE on (alive,
|
|
145
241
|
// EPERM, or ANY garbage). A provably-dead holder (ESRCH) reads not-live (LOCK-02/OBS-05). Never
|
|
146
242
|
// mutates the lock. compile now acquires via acquireRunLock; this remains for drift oracles/tests.
|
|
147
243
|
export function isRunLockLive(repoRoot) {
|
|
148
|
-
|
|
149
|
-
return shouldRefuse(inspect(lockPath(repoRoot)));
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
return false;
|
|
153
|
-
} // no lock
|
|
244
|
+
return runLockOwner(repoRoot)?.live ?? false;
|
|
154
245
|
}
|
|
155
246
|
// LOCK-03: operator escape hatch. Liveness-checked delete — removes a dead-holder or garbage lock,
|
|
156
247
|
// REFUSES to remove one whose holder is alive (incl. EPERM = alive-but-not-ours). No --force: the
|
package/dist/run/merge.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TickmarkrConfig } from "../config/config.js";
|
|
2
|
+
import { type Baseline } from "../gates/baseline.js";
|
|
2
3
|
export interface TipVerifyResult {
|
|
3
4
|
gate: string;
|
|
4
5
|
cmd: string;
|
|
@@ -7,6 +8,8 @@ export interface TipVerifyResult {
|
|
|
7
8
|
fingerprints: string[];
|
|
8
9
|
details: string;
|
|
9
10
|
artifact?: string;
|
|
11
|
+
/** Q121s: nonzero exit whose failures are ALL baseline-recorded — forgiven exactly as the battery forgives. */
|
|
12
|
+
forgiven?: boolean;
|
|
10
13
|
}
|
|
11
14
|
export declare function integrationBranch(cfg: TickmarkrConfig, runId: string): string;
|
|
12
15
|
export declare function ensureIntegration(repo: string, branch: string, baseRef: string): Promise<string>;
|
|
@@ -19,4 +22,4 @@ export declare function mergeTask(intWt: string, taskBranch: string, message: st
|
|
|
19
22
|
branchTip: string;
|
|
20
23
|
};
|
|
21
24
|
}>;
|
|
22
|
-
export declare function verifyIntegrationTip(intWt: string, commands: Record<string, string>, runDir: string): Promise<TipVerifyResult[]>;
|
|
25
|
+
export declare function verifyIntegrationTip(intWt: string, commands: Record<string, string>, runDir: string, baseline?: Baseline): Promise<TipVerifyResult[]>;
|
package/dist/run/merge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { shq } from "../adapters/types.js";
|
|
4
|
-
import { fingerprint } from "../gates/baseline.js";
|
|
4
|
+
import { classifyFailureOutput, fingerprint, freshFailures } from "../gates/baseline.js";
|
|
5
5
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
6
6
|
import { gitHead, linkNodeModules, resolveIntegrationBranch, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
|
|
7
7
|
export function integrationBranch(cfg, runId) {
|
|
@@ -44,22 +44,41 @@ export async function mergeTask(intWt, taskBranch, message, gatedCommit) {
|
|
|
44
44
|
await shGit("git merge --abort", intWt);
|
|
45
45
|
return { ok: false, conflict };
|
|
46
46
|
}
|
|
47
|
-
// OBS-34
|
|
48
|
-
|
|
47
|
+
// OBS-34 ruled strict exit-code verify; Q121s (TRIAL T-OBS-1) narrows it: a red whose failure
|
|
48
|
+
// fingerprints are ALL recorded in the run's baseline is forgiven with the battery's own math
|
|
49
|
+
// (freshFailures) — on any repo whose main carries a pre-existing red, strict verify made a green
|
|
50
|
+
// terminus unreachable by construction and misattributed the red to the last merged task.
|
|
51
|
+
// Fail-closed edges kept: no baseline → strict; baseline green for that gate → strict; any fresh
|
|
52
|
+
// fingerprint, or output with no recognizable failure shape, → failed.
|
|
53
|
+
export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
49
54
|
const results = [];
|
|
50
55
|
for (const [gate, cmd] of Object.entries(commands)) {
|
|
51
56
|
const r = await sh(cmd, intWt);
|
|
52
57
|
const raw = r.stdout + "\n" + r.stderr;
|
|
53
|
-
const
|
|
58
|
+
const stripped = raw.split(intWt).join("");
|
|
59
|
+
const entry = baseline?.commands[gate];
|
|
60
|
+
const { failing, unreadable } = freshFailures(entry, stripped);
|
|
61
|
+
// `?? 1` is the battery's own default (baseline.ts compareToBaseline): an exitCode-less legacy
|
|
62
|
+
// entry reads as red-at-baseline there, so it must read the same here or old baselines silently
|
|
63
|
+
// lose forgiveness. Battery parity on the infra rule too (T9): infrastructure-only output means
|
|
64
|
+
// the runner never completed a suite — nothing was verified, so nothing is forgivable, however
|
|
65
|
+
// familiar its fingerprints. Stricter-than-battery edge kept: unreadable output never forgives.
|
|
66
|
+
const forgiven = r.code !== 0 && entry !== undefined && (entry.exitCode ?? 1) !== 0
|
|
67
|
+
&& failing.length === 0 && !unreadable && classifyFailureOutput(stripped) !== "infra";
|
|
68
|
+
const pass = r.code === 0 || forgiven;
|
|
69
|
+
const artifact = pass ? undefined : join(runDir, `tip-verify-${gate}.log`);
|
|
54
70
|
if (artifact)
|
|
55
71
|
writeFileSync(artifact, raw);
|
|
56
72
|
results.push({
|
|
57
73
|
gate,
|
|
58
74
|
cmd,
|
|
59
|
-
pass
|
|
75
|
+
pass,
|
|
60
76
|
exitCode: r.code,
|
|
61
|
-
fingerprints: r.code !== 0 ? fingerprint(
|
|
62
|
-
details: r.code === 0 ? "exit 0"
|
|
77
|
+
fingerprints: r.code !== 0 ? fingerprint(stripped) : [],
|
|
78
|
+
details: r.code === 0 ? "exit 0"
|
|
79
|
+
: forgiven ? `exit ${r.code} but only baseline-recorded failures (forgiven vs baseline)`
|
|
80
|
+
: `exit ${r.code}`,
|
|
81
|
+
...(forgiven ? { forgiven: true } : {}),
|
|
63
82
|
...(artifact ? { artifact } : {}),
|
|
64
83
|
});
|
|
65
84
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a gate phase did, closed. `passed` and `failed` are verdicts and carry nothing else; every
|
|
3
|
+
* other member is a NON-VERDICT and must say why, so no arm of this union can be produced without the
|
|
4
|
+
* reason a reader needs to act on it.
|
|
5
|
+
*/
|
|
6
|
+
export type GateOutcome = {
|
|
7
|
+
kind: "passed";
|
|
8
|
+
} | {
|
|
9
|
+
kind: "failed";
|
|
10
|
+
}
|
|
11
|
+
/** The gate had nothing to run — the repository configures no such command (baseline.ts:262). */
|
|
12
|
+
| {
|
|
13
|
+
kind: "skipped";
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
/** A policy declined to run the gate that was configured (review.ts:411). */
|
|
17
|
+
| {
|
|
18
|
+
kind: "declined";
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
/** A green that is not yet the verdict — a screen the full suite has not superseded. */
|
|
22
|
+
| {
|
|
23
|
+
kind: "held";
|
|
24
|
+
reason: string;
|
|
25
|
+
}
|
|
26
|
+
/** The outcome could not be read at all. Never clean, never a pass. */
|
|
27
|
+
| {
|
|
28
|
+
kind: "unavailable";
|
|
29
|
+
reason: string;
|
|
30
|
+
}
|
|
31
|
+
/** The runner died on the machine, so the gate verified nothing whatever it reported. */
|
|
32
|
+
| {
|
|
33
|
+
kind: "infra";
|
|
34
|
+
reason: string;
|
|
35
|
+
retryable: boolean;
|
|
36
|
+
};
|
|
37
|
+
/** The closed table, as data — a consumer can range over the vocabulary without re-listing it. */
|
|
38
|
+
export declare const GATE_OUTCOME_KINDS: readonly ["passed", "failed", "skipped", "declined", "held", "unavailable", "infra"];
|
|
39
|
+
export type GateOutcomeKind = (typeof GATE_OUTCOME_KINDS)[number];
|
|
40
|
+
type AssertNever<T extends never> = T;
|
|
41
|
+
export type UntabledGateOutcomeKind = AssertNever<Exclude<GateOutcome["kind"], GateOutcomeKind>>;
|
|
42
|
+
/**
|
|
43
|
+
* Read one gate outcome — canonical or legacy — as a member of the closed vocabulary.
|
|
44
|
+
*
|
|
45
|
+
* Takes `unknown` deliberately: the malformed row is a real input class, not a caller error. A journal
|
|
46
|
+
* row's `data` is `Record<string, unknown>` and a `GateResult`'s `{ ...meta }` spread is the same
|
|
47
|
+
* shape, so both pass without a cast and neither needs its own entry point.
|
|
48
|
+
*/
|
|
49
|
+
export declare function normalizeGateOutcome(source: unknown): GateOutcome;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// T31 (F1 + Q18's producer half): ONE vocabulary for what a gate phase actually did, and ONE
|
|
2
|
+
// normalizer that reads every shape the shipped record already writes into it.
|
|
3
|
+
//
|
|
4
|
+
// This module is a PRODUCER ONLY. It is a new file with no imports and no writes: nothing it exports
|
|
5
|
+
// replaces or re-types an existing symbol, so capture.ts, demo.ts, live.ts, run-cockpit.tsx and
|
|
6
|
+
// setup-cockpit.tsx — and every other current caller — compile exactly as before. It adds no general
|
|
7
|
+
// reducer and no dual-write. The read-side wiring of status, the Markdown report, the proof bundle and
|
|
8
|
+
// the retained derive projection is T41's; the typed writer half is T40's.
|
|
9
|
+
//
|
|
10
|
+
// Why a vocabulary at all. The legacy record spells a gate's NON-FAILURE across four disjoint fields —
|
|
11
|
+
// `pass`, `skipped`, `verdict` and `infra` — and a row may state NONE of them: daemon.ts:1376 omits
|
|
12
|
+
// `pass` entirely for a decline, because a review that did not run has no verdict to state. Every
|
|
13
|
+
// consumer therefore re-derived the same question from raw fields, and each one that got it wrong read
|
|
14
|
+
// a non-failure as a green. The union below makes that collapse unrepresentable: a gate that did not
|
|
15
|
+
// run cannot be spelled `passed`, and a row nobody can read cannot be spelled clean.
|
|
16
|
+
/** The closed table, as data — a consumer can range over the vocabulary without re-listing it. */
|
|
17
|
+
export const GATE_OUTCOME_KINDS = [
|
|
18
|
+
"passed", "failed", "skipped", "declined", "held", "unavailable", "infra",
|
|
19
|
+
];
|
|
20
|
+
const stated = (value) => typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Every reason the row states, in the order the record writes them. A declined review populates BOTH
|
|
23
|
+
* fields with different facts — `reason` is the policy's reason, `details` the operator-facing
|
|
24
|
+
* sentence — so preferring either one alone would silently drop the other.
|
|
25
|
+
*/
|
|
26
|
+
function reasonOf(row, fallback) {
|
|
27
|
+
const reasons = [...new Set([stated(row.reason), stated(row.details)])].filter((r) => r !== undefined);
|
|
28
|
+
return reasons.join(" — ") || fallback;
|
|
29
|
+
}
|
|
30
|
+
// Presence, not truthiness, is the boundary: a canonical outcome and any one of the four fields that
|
|
31
|
+
// spell legacy truth would be a dual-write. Treating even a consistent-looking pair as canonical
|
|
32
|
+
// would let a contradictory legacy field become clean as soon as producers drifted apart.
|
|
33
|
+
const LEGACY_OUTCOME_DISCRIMINATORS = ["pass", "skipped", "verdict", "infra"];
|
|
34
|
+
const hasOwn = (row, field) => Object.prototype.hasOwnProperty.call(row, field);
|
|
35
|
+
function legacyDiscriminatorsOf(row) {
|
|
36
|
+
return LEGACY_OUTCOME_DISCRIMINATORS.filter((field) => hasOwn(row, field));
|
|
37
|
+
}
|
|
38
|
+
/** The gates the baseline battery runs (run-gates.ts:203) — the only producer of a command-absent skip. */
|
|
39
|
+
const BASELINE_GATES = new Set(["build", "test", "lint"]);
|
|
40
|
+
/**
|
|
41
|
+
* The oldest declines predate the `skipped` field entirely and state the decline only in the details
|
|
42
|
+
* prefix ("skipped — complexity 4 < threshold 7"). This compatibility branch is limited to that
|
|
43
|
+
* historical shape: a review carrying the old `pass: true` encoding and neither modern decline
|
|
44
|
+
* discriminator. The ^ anchor is load-bearing too: a review that genuinely RAN can mention a skipped
|
|
45
|
+
* something mid-body, and a failed non-review gate can begin its diagnostic with that word.
|
|
46
|
+
*/
|
|
47
|
+
const declinedByDetails = (row) => row.gate === "review"
|
|
48
|
+
&& row.pass === true
|
|
49
|
+
&& !hasOwn(row, "skipped")
|
|
50
|
+
&& !hasOwn(row, "verdict")
|
|
51
|
+
&& /^skipped\b/.test(String(row.details ?? ""));
|
|
52
|
+
/**
|
|
53
|
+
* Already-canonical input, validated arm by arm. This is what makes the normalizer idempotent over the
|
|
54
|
+
* vocabulary: T40 will publish typed outcomes while pre-T40 journals still hold legacy rows, and a
|
|
55
|
+
* projection must be able to call this on either without asking which era it is reading.
|
|
56
|
+
*/
|
|
57
|
+
function isGateOutcome(value) {
|
|
58
|
+
if (typeof value !== "object" || value === null)
|
|
59
|
+
return false;
|
|
60
|
+
const o = value;
|
|
61
|
+
if (legacyDiscriminatorsOf(o).length > 0)
|
|
62
|
+
return false;
|
|
63
|
+
switch (o.kind) {
|
|
64
|
+
case "passed":
|
|
65
|
+
case "failed":
|
|
66
|
+
return true;
|
|
67
|
+
case "skipped":
|
|
68
|
+
case "declined":
|
|
69
|
+
case "held":
|
|
70
|
+
case "unavailable":
|
|
71
|
+
return typeof o.reason === "string";
|
|
72
|
+
case "infra":
|
|
73
|
+
return typeof o.reason === "string" && typeof o.retryable === "boolean";
|
|
74
|
+
default:
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read one gate outcome — canonical or legacy — as a member of the closed vocabulary.
|
|
80
|
+
*
|
|
81
|
+
* Takes `unknown` deliberately: the malformed row is a real input class, not a caller error. A journal
|
|
82
|
+
* row's `data` is `Record<string, unknown>` and a `GateResult`'s `{ ...meta }` spread is the same
|
|
83
|
+
* shape, so both pass without a cast and neither needs its own entry point.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeGateOutcome(source) {
|
|
86
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) {
|
|
87
|
+
const shape = source === null ? "null" : Array.isArray(source) ? "an array" : typeof source;
|
|
88
|
+
return { kind: "unavailable", reason: `malformed gate result: expected an object, read ${shape}` };
|
|
89
|
+
}
|
|
90
|
+
const row = source;
|
|
91
|
+
// `kind` selects the canonical format. It must validate as exactly one canonical arm without any
|
|
92
|
+
// legacy truth discriminator; otherwise continuing through the legacy branches could reinterpret a
|
|
93
|
+
// contradictory dual-write as passed, failed or infra. Mixed and malformed canonical rows stay
|
|
94
|
+
// explicitly unavailable, retaining any reason/details the producer did manage to state.
|
|
95
|
+
if (hasOwn(row, "kind")) {
|
|
96
|
+
if (isGateOutcome(source))
|
|
97
|
+
return source;
|
|
98
|
+
const legacyFields = legacyDiscriminatorsOf(row);
|
|
99
|
+
const fallback = legacyFields.length > 0
|
|
100
|
+
? `malformed gate result: canonical kind cannot carry legacy discriminator(s): ${legacyFields.join(", ")}`
|
|
101
|
+
: "malformed gate result: invalid canonical outcome";
|
|
102
|
+
return { kind: "unavailable", reason: reasonOf(row, fallback) };
|
|
103
|
+
}
|
|
104
|
+
// Infra dominates every other field (daemon.ts:220): a runner that died on the machine verified
|
|
105
|
+
// nothing, so this is checked before any clause that could read the same row as satisfied.
|
|
106
|
+
// `retryable` defaults true because the one shipped producer of these rows (baseline.ts:277) is by
|
|
107
|
+
// construction the retryable class — "the runner never completed a suite" — and a later producer
|
|
108
|
+
// that knows its failure is terminal says so on the row rather than relying on this default.
|
|
109
|
+
if (row.infra === true) {
|
|
110
|
+
return {
|
|
111
|
+
kind: "infra",
|
|
112
|
+
reason: reasonOf(row, "the runner never completed a suite, so this gate verified nothing"),
|
|
113
|
+
retryable: row.retryable !== false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Two different facts wear the same `skipped: true` flag, and telling them apart is most of why this
|
|
117
|
+
// vocabulary exists. The GATE IDENTITY is what separates them, not `pass` and not `verdict`: the
|
|
118
|
+
// command-absent skip has exactly one producer, the baseline battery, which runs build/test/lint
|
|
119
|
+
// only (run-gates.ts:203) and records a gate the repository never configured with `pass: true`
|
|
120
|
+
// beside `skipped: true` (baseline.ts:262). Every OTHER skip is a policy declining a gate that WAS
|
|
121
|
+
// configured. That distinction cannot be read off `pass`, because the review declines persisted in
|
|
122
|
+
// pre-R3 journals carry `pass: true` too — R3 (daemon.ts:1386) only stopped writing a verdict for
|
|
123
|
+
// NEW ones, and the old rows are still on disk. Reading them by field alone files a policy decline
|
|
124
|
+
// as "no command detected", which is the same collapse in a quieter costume.
|
|
125
|
+
if (row.skipped === true || row.verdict === "skipped" || declinedByDetails(row)) {
|
|
126
|
+
const commandAbsent = BASELINE_GATES.has(String(row.gate))
|
|
127
|
+
&& row.pass === true
|
|
128
|
+
&& row.verdict !== "skipped";
|
|
129
|
+
return commandAbsent
|
|
130
|
+
? { kind: "skipped", reason: reasonOf(row, "no command detected for this gate") }
|
|
131
|
+
: { kind: "declined", reason: reasonOf(row, "a policy declined to run this gate") };
|
|
132
|
+
}
|
|
133
|
+
// A selected-test green is a SCREEN and never the merge verdict (journal.ts:1067). It reports
|
|
134
|
+
// `pass: true`, so reading it by that field alone is exactly the collapse of a non-failure into a
|
|
135
|
+
// pass — the full suite has not spoken yet.
|
|
136
|
+
if (row.pass === true && Array.isArray(row.selectedTests) && row.fullSuite !== true) {
|
|
137
|
+
return {
|
|
138
|
+
kind: "held",
|
|
139
|
+
reason: reasonOf(row, `${row.selectedTests.length} selected test(s) screened; the full suite has not spoken`),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (row.pass === true)
|
|
143
|
+
return { kind: "passed" };
|
|
144
|
+
if (row.pass === false)
|
|
145
|
+
return { kind: "failed" };
|
|
146
|
+
// Neither a verdict nor a stated non-run. Unknown is not clean: it stays non-passing and carries
|
|
147
|
+
// whatever the row did say, so a reader can see why it could not be read.
|
|
148
|
+
return {
|
|
149
|
+
kind: "unavailable",
|
|
150
|
+
reason: reasonOf(row, "gate result states no pass, skip, decline or infra verdict"),
|
|
151
|
+
};
|
|
152
|
+
}
|