tickmarkr 2.7.6 → 2.7.8

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.
@@ -179,12 +179,14 @@ export const COLLECTABLE_TESTS = "tests/**/*.test.ts";
179
179
  export const LEGACY_PREFIX = ["dro", "vr"].join("");
180
180
  export const TICKMARKR_NATIVE_MARKER = /^<!--\s*tickmarkr:spec(?:\s+v1)?\s*-->\s*$/m;
181
181
  export const NATIVE_MARKER = new RegExp(`^<!--\\s*(?:tickmarkr|${LEGACY_PREFIX}):spec(?:\\s+v1)?\\s*-->\\s*$`, "m");
182
- const HEAD_RE = /^## (T\d+):\s*(.+)$/;
183
- const FIELD_RE = /^- (\w+):\s*(.*)$/;
184
- const NESTED_RE = /^\s+- (.+)$/;
182
+ // Queue row 130: the rest of a spec line is [^\r\n], never `.` — `.` misses U+2028/U+2029, and an acceptance item holding
183
+ // one was glued onto its neighbour as a plain judge string, losing its own test: oracle (PRD and Spec Kit dropped it).
184
+ const HEAD_RE = /^## (T\d+):\s*([^\r\n]+)$/;
185
+ const FIELD_RE = /^- (\w+):\s*([^\r\n]*)$/;
186
+ const NESTED_RE = /^\s+- ([^\r\n]+)$/;
185
187
  // v1.19: a typed acceptance oracle line — "command: ...", "test: ...", or "judge: ...". Anything
186
188
  // without one of these prefixes is a plain-string judge criterion (compat path, emits a warning).
187
- const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*(.*)$`);
189
+ const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*([^\\r\\n]*)$`);
188
190
  const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout", "pins", "outofscope"]);
189
191
  const listItems = (draft) => draft.list === "acceptance" ? draft.acceptanceRaw
190
192
  : draft.list === "pins" ? draft.pinsRaw
@@ -199,7 +201,7 @@ const PIN_GLOB_SEP = "| glob:";
199
201
  const LANDING_SEP = "| suite:";
200
202
  function parsePin(task, raw, index, pathSet) {
201
203
  const bad = (detail) => invalid(task, "pins", `item ${index + 1} (${JSON.stringify(raw)}) ${detail}`);
202
- const typed = raw.match(/^(\w+):\s*(.*)$/);
204
+ const typed = raw.match(/^(\w+):\s*([^\r\n]*)$/);
203
205
  if (typed?.[1] === "fixture") {
204
206
  const paths = pathSet(typed[2]);
205
207
  if (!paths.length)
@@ -275,7 +277,7 @@ function testsAtHead(root) {
275
277
  }
276
278
  const bodies = new Map();
277
279
  for (const line of grep.stdout.split("\n")) {
278
- const match = line.match(/^HEAD:(tests\/.*\.test\.ts):\d+:(.*)$/);
280
+ const match = line.match(/^HEAD:(tests\/[^\r\n]*\.test\.ts):\d+:([^\r\n]*)$/);
279
281
  if (!match)
280
282
  continue;
281
283
  const lines = bodies.get(match[1]) ?? [];
@@ -544,7 +546,7 @@ export function compileNative(file, options = {}) {
544
546
  }
545
547
  const draft = drafts.at(-1);
546
548
  if (!draft) {
547
- const base = line.match(/^base:\s*(.*)$/);
549
+ const base = line.match(/^base:\s*([^\r\n]*)$/);
548
550
  if (base) {
549
551
  const value = base[1].trim();
550
552
  if (!value) {
@@ -1,9 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { validateGraph } from "../graph/schema.js";
3
3
  import { CompileError, inferShape, sha256 } from "./common.js";
4
- const HEAD_RE = /^## (T\d+):\s*(.+)$/;
5
- const FIELD_RE = /^- (\w+):\s*(.*)$/;
6
- const NESTED_RE = /^\s+- (.+)$/;
4
+ // Queue row 130: [^\r\n], never `.` (U+2028/U+2029 inside a line).
5
+ const HEAD_RE = /^## (T\d+):\s*([^\r\n]+)$/;
6
+ const FIELD_RE = /^- (\w+):\s*([^\r\n]*)$/;
7
+ const NESTED_RE = /^\s+- ([^\r\n]+)$/;
7
8
  export function compilePrd(file) {
8
9
  if (!existsSync(file))
9
10
  throw new CompileError(`no such PRD file: ${file}`);
@@ -2,8 +2,9 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { validateGraph } from "../graph/schema.js";
4
4
  import { CompileError, inferShape, sha256 } from "./common.js";
5
- const TASK_RE = /^- \[( |x)\] (T\d+)\s*(\[P\])?\s*(.+)$/;
6
- const SUB_RE = /^\s+- (\w+):\s*(.+)$/;
5
+ // Queue row 130: [^\r\n], never `.` (U+2028/U+2029 inside a line).
6
+ const TASK_RE = /^- \[( |x)\] (T\d+)\s*(\[P\])?\s*([^\r\n]+)$/;
7
+ const SUB_RE = /^\s+- (\w+):\s*([^\r\n]+)$/;
7
8
  export function compileSpecKit(dir) {
8
9
  const path = join(dir, "tasks.md");
9
10
  if (!existsSync(path)) {
@@ -307,13 +307,14 @@ function compressLines(lines) {
307
307
  }
308
308
  return parts.join(", ");
309
309
  }
310
- const DIFF_SECTIONS = /(?=^diff --git )/m;
310
+ // Queue rows 128/129: "\n"-only line semantics — the m flag would also split a line at U+2028/U+2029.
311
+ const DIFF_SECTIONS = /(?<=^|\n)(?=diff --git )/;
311
312
  // the a-side path of a whole-file deletion section, or null if this section is not one.
312
313
  function deletedPath(section) {
313
- if (!/^deleted file mode /m.test(section))
314
+ if (!/(?<=^|\n)deleted file mode /.test(section))
314
315
  return null;
315
- const oldPath = /^--- (.+)$/m.exec(section)?.[1]
316
- ?? /^Binary files (.+) and \/dev\/null differ$/m.exec(section)?.[1];
316
+ const oldPath = /(?<=^|\n)--- ([^\r\n]+)/.exec(section)?.[1]
317
+ ?? /(?<=^|\n)Binary files ([^\r\n]+) and \/dev\/null differ(?=[\r\n]|$)/.exec(section)?.[1];
317
318
  if (!oldPath || oldPath === "/dev/null")
318
319
  return null;
319
320
  const unquoted = oldPath.startsWith('"') && oldPath.endsWith('"') ? oldPath.slice(1, -1) : oldPath;
@@ -3,7 +3,7 @@
3
3
  * path receives capture accounting only when its manifest row names a
4
4
  * registered producer and repeats that producer's current provenance exactly.
5
5
  */
6
- import { diffSidePath, gitHeaderPaths, unquoteGitPath } from "./git-paths.js";
6
+ import { diffSidePath, gitHeaderPaths, quoteGitPath, unquoteGitPath } from "./git-paths.js";
7
7
  const GOLDEN_PROVENANCE = {
8
8
  source: "src/tui/cockpit/capture.ts",
9
9
  entrypoint: "regenerateGoldenFrames",
@@ -191,17 +191,21 @@ export function classifyArtifactPath(path, manifest = CAPTURE_ARTIFACT_MANIFEST)
191
191
  provenance: producer.provenance,
192
192
  };
193
193
  }
194
- const SET_ASIDE_RECEIPT = /^set aside: regenerable capture (.+?) — \d+ bytes withheld\b/m;
194
+ // Queue rows 128/129: patch text is split into lines at "\n" only. Under the m flag JS `^` and `$` also break at
195
+ // U+2028/U+2029 (and `.` never matches them), so an added line holding one could forge a header line; these patterns
196
+ // anchor on "\n" instead, and a name written back into the text is quoted (quoteGitPath) so it cannot end a line.
197
+ const SET_ASIDE_RECEIPT = /(?<=^|\n)set aside: regenerable capture ([^\r\n]+?) — \d+ bytes withheld\b/;
195
198
  /** The path named by a citable capture receipt, or null when there is none. */
196
199
  export function setAsideReceiptPath(section) {
197
- return SET_ASIDE_RECEIPT.exec(section)?.[1] ?? null;
200
+ const path = SET_ASIDE_RECEIPT.exec(section)?.[1];
201
+ return path === undefined ? null : unquoteGitPath(path);
198
202
  }
199
- const DIFF_SECTIONS = /(?=^diff --git )/m;
203
+ const DIFF_SECTIONS = /(?<=^|\n)(?=diff --git )/;
200
204
  function deletedPath(section) {
201
- if (!/^deleted file mode /m.test(section))
205
+ if (!/(?<=^|\n)deleted file mode /.test(section))
202
206
  return null;
203
- const oldPath = /^--- (.+)$/m.exec(section)?.[1]
204
- ?? /^Binary files (.+) and \/dev\/null differ$/m.exec(section)?.[1];
207
+ const oldPath = /(?<=^|\n)--- ([^\r\n]+)/.exec(section)?.[1]
208
+ ?? /(?<=^|\n)Binary files ([^\r\n]+) and \/dev\/null differ(?=[\r\n]|$)/.exec(section)?.[1];
205
209
  return oldPath ? diffSidePath(oldPath) : null;
206
210
  }
207
211
  /**
@@ -216,7 +220,7 @@ export function reviewableLogicDiff(diff) {
216
220
  const path = deletedPath(section);
217
221
  if (!path || isProtectedEvidence(path))
218
222
  return section;
219
- return `deleted file: ${path}\n`;
223
+ return `deleted file: ${quoteGitPath(path)}\n`;
220
224
  }).join("");
221
225
  }
222
226
  function parseContentSection(section) {
@@ -240,12 +244,12 @@ function parseContentSection(section) {
240
244
  function sectionPaths(section, parsed) {
241
245
  if (parsed)
242
246
  return [...new Set(parsed.sides.filter((path) => path !== null))];
243
- const renamedFrom = /^rename from (.+)$/m.exec(section)?.[1];
244
- const renamedTo = /^rename to (.+)$/m.exec(section)?.[1];
247
+ const renamedFrom = /(?<=^|\n)rename from ([^\r\n]+)/.exec(section)?.[1];
248
+ const renamedTo = /(?<=^|\n)rename to ([^\r\n]+)/.exec(section)?.[1];
245
249
  if (renamedFrom || renamedTo) {
246
250
  return [...new Set([renamedFrom, renamedTo].filter((path) => path !== undefined).map(unquoteGitPath))];
247
251
  }
248
- const header = /^diff --git (.+)$/m.exec(section)?.[1];
252
+ const header = /(?<=^|\n)diff --git ([^\r\n]+)/.exec(section)?.[1];
249
253
  return header ? gitHeaderPaths(header) : [];
250
254
  }
251
255
  function hunkPayload(body, sign) {
@@ -293,7 +297,7 @@ export function measureArtifactDiff(diff, manifest = CAPTURE_ARTIFACT_MANIFEST)
293
297
  sections: [],
294
298
  };
295
299
  }
296
- const rawSections = diff.split(/(?=^diff --git )/m);
300
+ const rawSections = diff.split(DIFF_SECTIONS);
297
301
  const kindOnly = kindOnlyPaths(rawSections);
298
302
  const rendered = [];
299
303
  const measurements = [];
@@ -321,7 +325,7 @@ export function measureArtifactDiff(diff, manifest = CAPTURE_ARTIFACT_MANIFEST)
321
325
  const withheld = parsed.lines.slice(parsed.minus).join("\n");
322
326
  const captureBytes = Buffer.byteLength(withheld, "utf8");
323
327
  const receiptPath = paths.at(-1);
324
- const receipt = `set aside: regenerable capture ${receiptPath} — ${captureBytes} bytes withheld (producer ${producer.producer}; provenance ${producer.provenance.source}#${producer.provenance.entrypoint}@${producer.provenance.revision})`;
328
+ const receipt = `set aside: regenerable capture ${quoteGitPath(receiptPath)} — ${captureBytes} bytes withheld (producer ${producer.producer}; provenance ${producer.provenance.source}#${producer.provenance.entrypoint}@${producer.provenance.revision})`;
325
329
  const compact = `${parsed.lines.slice(0, parsed.minus).join("\n")}\n${receipt}\n`;
326
330
  rendered.push(compact);
327
331
  measurements.push({
@@ -1,5 +1,11 @@
1
1
  /** A path exactly as git printed it, quoted or not, decoded to the name on disk. Unquoted input is returned as is. */
2
2
  export declare function unquoteGitPath(raw: string): string;
3
+ /**
4
+ * Queue rows 128/129: the inverse of unquoteGitPath, for a name tickmarkr writes back into patch text it parses again.
5
+ * A name holding a control character, U+2028/U+2029, a double quote or a backslash is C-quoted (those characters
6
+ * escaped, every other character kept), so no name can end a line; any other name is returned as is.
7
+ */
8
+ export declare function quoteGitPath(path: string): string;
3
9
  /**
4
10
  * One side of a `--- ` / `+++ ` line, after its four-character marker: null for /dev/null, else the path without its
5
11
  * a/ or b/ prefix. git ends a side holding a space with ONE tab (quoted or not, measured on git 2.54) — exactly that tab
@@ -34,6 +34,28 @@ export function unquoteGitPath(raw) {
34
34
  }
35
35
  return Buffer.from(bytes).toString("utf8");
36
36
  }
37
+ const NAMED = Object.fromEntries(Object.entries(ESCAPES).map(([name, code]) => [code, name]));
38
+ /**
39
+ * Queue rows 128/129: the inverse of unquoteGitPath, for a name tickmarkr writes back into patch text it parses again.
40
+ * A name holding a control character, U+2028/U+2029, a double quote or a backslash is C-quoted (those characters
41
+ * escaped, every other character kept), so no name can end a line; any other name is returned as is.
42
+ */
43
+ export function quoteGitPath(path) {
44
+ if (!/[\x00-\x1f\x7f"\\\u2028\u2029]/.test(path))
45
+ return path;
46
+ let quoted = "";
47
+ for (const ch of path) {
48
+ const code = ch.codePointAt(0);
49
+ if (NAMED[code] !== undefined)
50
+ quoted += `\\${NAMED[code]}`;
51
+ else if (code < 0x20 || code === 0x7f || code === 0x2028 || code === 0x2029) {
52
+ quoted += [...Buffer.from(ch, "utf8")].map((byte) => `\\${byte.toString(8).padStart(3, "0")}`).join("");
53
+ }
54
+ else
55
+ quoted += ch;
56
+ }
57
+ return `"${quoted}"`;
58
+ }
37
59
  /**
38
60
  * One side of a `--- ` / `+++ ` line, after its four-character marker: null for /dev/null, else the path without its
39
61
  * a/ or b/ prefix. git ends a side holding a space with ONE tab (quoted or not, measured on git 2.54) — exactly that tab
@@ -104,7 +104,7 @@ export function readProcessIdentity(pid, host = {}) {
104
104
  const ps = spawnSync("ps", ["-ww", "-o", "pgid=", "-o", "stat=", "-o", "lstart=", "-o", "command=", "-p", String(pid)], {
105
105
  encoding: "utf8", env: { ...process.env, LC_ALL: "C" },
106
106
  });
107
- const row = /^\s*(\d+)\s+(\S+)\s+(\w{3} \w{3} [ \d]\d \d\d:\d\d:\d\d \d{4})\s+(.*\S)\s*$/.exec(ps.stdout ?? "");
107
+ const row = /^\s*(\d+)\s+(\S+)\s+(\w{3} \w{3} [ \d]\d \d\d:\d\d:\d\d \d{4})\s+(.*\S)\s*$/s.exec(ps.stdout ?? "");
108
108
  if (ps.status !== 0 || !row)
109
109
  return confirmedDead(pid) ? "DEAD" : "UNKNOWN";
110
110
  if (row[2].startsWith("Z"))
@@ -1244,7 +1244,7 @@ export async function observeWorkerProcessTree(marker, cwd) {
1244
1244
  return "unmeasurable";
1245
1245
  const rows = [];
1246
1246
  for (const line of snapshot.stdout.split("\n")) {
1247
- const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
1247
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/s.exec(line);
1248
1248
  if (match)
1249
1249
  rows.push({ pid: match[1], ppid: match[2], command: match[3] });
1250
1250
  }
@@ -1272,7 +1272,7 @@ const pathAtOrBelow = (root, candidate) => {
1272
1272
  export function countLiveSuites(snapshot, repoRoot, daemonPid, cwdForPid, suiteParentForPid) {
1273
1273
  const rows = [];
1274
1274
  for (const line of snapshot.split("\n")) {
1275
- const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
1275
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/s.exec(line);
1276
1276
  if (match && !match[3].startsWith("Z")) {
1277
1277
  rows.push({ pid: Number(match[1]), ppid: Number(match[2]), command: match[4] });
1278
1278
  }
@@ -14,7 +14,7 @@ import { basename } from "node:path";
14
14
  export const LAUNCHSERVICES_PROBE_CEILING_MS = 5_000;
15
15
  // lstart in the C locale: "Wed Sep 30 21:54:33 2026"; comm is the executable path on Darwin.
16
16
  const PS_ARGS = ["-A", "-o", "pid=,lstart=,comm="];
17
- const PS_LINE = /^\s*(\d+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+?)\s*$/;
17
+ const PS_LINE = /^\s*(\d+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+?)\s*$/s;
18
18
  const MAX_PS_BYTES = 4 * 1024 * 1024;
19
19
  /** Exactly one launchservicesd row, or null (absent, ambiguous or malformed output is unknown). */
20
20
  export function parsePsIdentity(stdout) {
package/dist/run/lease.js CHANGED
@@ -330,7 +330,7 @@ export async function describeLeaseBlockers(pids) {
330
330
  done("");
331
331
  }
332
332
  });
333
- const executables = new Map(rows.split("\n").flatMap((line) => { const row = /^\s*(\d+)\s+(.*)$/.exec(line); return row ? [[Number(row[1]), row[2].trim()]] : []; }));
333
+ const executables = new Map(rows.split("\n").flatMap((line) => { const row = /^\s*(\d+)\s+(.*)$/s.exec(line); return row ? [[Number(row[1]), row[2].trim()]] : []; }));
334
334
  const cwds = await import("./suite-census.js").then(({ batchedProcessCwds }) => batchedProcessCwds(pids)).catch(() => undefined);
335
335
  return pids.map((pid) => {
336
336
  const cwd = cwds?.get(pid);
@@ -363,7 +363,7 @@ export async function protectedProcesses(holder, tokenEnv = REPOSITORY_LEASE_TOK
363
363
  return !leader || sameProcessBirth(processBirth(pid), holder.rootBirths?.[pid]);
364
364
  }));
365
365
  const parsed = lines.flatMap(line => {
366
- const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
366
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/s.exec(line);
367
367
  return match ? [{ pid: Number(match[1]), parent: Number(match[2]), group: Number(match[3]), stat: match[4], command: match[5] }] : [];
368
368
  });
369
369
  const parents = new Map(parsed.map(row => [row.pid, row.parent]));
package/dist/run/stall.js CHANGED
@@ -118,7 +118,8 @@ export const workerReapHost = {
118
118
  if (!lines.length || lines.length > 20_000)
119
119
  return undefined;
120
120
  for (const line of lines) {
121
- const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s+(.*)$/.exec(line);
121
+ // Queue row 127: the s flag — ps prints an argv's U+2028/U+2029 raw, and `.` alone never matches them.
122
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s+(.*)$/s.exec(line);
122
123
  if (!m)
123
124
  return undefined;
124
125
  if (m[5].startsWith("Z"))
@@ -418,7 +419,7 @@ async function workerTreeCpuSnapshot(marker, cwd, group, identities) {
418
419
  continue;
419
420
  if (group !== undefined && Number(ids[3]) === group)
420
421
  grouped.push(ids[1]);
421
- const m = /^\s*(\d+)\s+(\d+)\s+\d+\s+(\S+)\s+(.*)$/.exec(line);
422
+ const m = /^\s*(\d+)\s+(\d+)\s+\d+\s+(\S+)\s+(.*)$/s.exec(line);
422
423
  if (!m)
423
424
  continue;
424
425
  const cpu = parsePsCpu(m[3]);
@@ -56,7 +56,7 @@ export async function batchedProcessCwds(pids, exec = probeExec, proc = procRead
56
56
  export function runnerPids(snapshot) {
57
57
  const pids = [];
58
58
  for (const line of snapshot.split("\n")) {
59
- const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
59
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/s.exec(line);
60
60
  if (match && !match[3].startsWith("Z") && isRunnerCommand(match[4]))
61
61
  pids.push(Number(match[1]));
62
62
  }
@@ -95,7 +95,7 @@ export async function batchedProcessProbes(pids, exec = probeExec, proc = procRe
95
95
  if (rest.length === 0)
96
96
  return;
97
97
  for (const line of (await ask("ps", ["eww", "-p", rest.join(","), "-o", "pid=,command="])).split("\n")) {
98
- const row = /^\s*(\d+)\s+(.*)$/.exec(line);
98
+ const row = /^\s*(\d+)\s+(.*)$/s.exec(line);
99
99
  const value = row ? parentIn(row[2]) : undefined;
100
100
  if (row && value)
101
101
  parents.set(Number(row[1]), Number(value));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.7.6",
3
+ "version": "2.7.8",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -94,6 +94,7 @@
94
94
  "skills/tickmarkr-auto/",
95
95
  "skills/tickmarkr-loop/",
96
96
  "skills/tickmarkr-overseer/",
97
+ "scripts/ci/",
97
98
  "src/",
98
99
  "tests/"
99
100
  ]
@@ -1601,9 +1601,12 @@ Create specialist seats using `orca terminal create --worktree path:<repo> --com
1601
1601
 
1602
1602
  - **GRADE PUBLIC CI FROM JOB LOGS AT RUN-END ONLY (law 48).** A badge is not the grade, and
1603
1603
  `gh run view --job --log` is empty while the run is in progress. At run-end execute
1604
- `scripts/grade-ci.sh <run-id> <expected-count> <tag>` and require GREEN from both jobs; its
1605
- UNREADABLE state is a hard stop, not a green default. Derive the expected glob pathspec count with
1606
- `git ls-files ':(glob)tests/**/*.test.ts'` (including the `.tsx` sibling where present), never a bare
1604
+ `scripts/grade-ci.sh <run-id> <expected-count> <tag>` and require GREEN from every job of the
1605
+ declared inventory it prints first — serial `test` and `test-macos`, or sharded, per OS, the four
1606
+ `suite <os> k/4` shards, `single-fork <os>` and `aggregate <os>`; a missing, duplicated or unknown
1607
+ job reads UNREADABLE, never a skip. Its UNREADABLE state is a hard stop, not a green default.
1608
+ Derive the expected glob pathspec count with `git ls-files ':(glob)tests/**/*.test.ts'`
1609
+ (including the `.tsx` sibling where present), never a bare
1607
1610
  `tests/**/*.test.ts`: bare `**` is not a git glob (law 49).
1608
1611
 
1609
1612
  - **RE-READ THE REGISTRY BEFORE RULING (law 51).** A successful Release run and the first
@@ -1,9 +1,36 @@
1
1
  #!/bin/bash
2
- # grade-ci.sh <run-id> <expected-count> [tag] — grade both public CI jobs from their JOB LOGS.
3
- # Grade only at run-end. A missing/in-progress/empty log is UNREADABLE, never evidence of green.
2
+ # grade-ci.sh <run-id> <expected-count> [tag] — grade EVERY job of a public CI run's closed inventory from
3
+ # its JOB LOG. Grade only at run-end. A missing/in-progress/empty log is UNREADABLE, never evidence of green.
4
4
  # Exit 0 GREEN, 1 RED, 2 UNREADABLE, 3 INFRA_KILLED: a job the GitHub runner shut down or cancelled with
5
5
  # no failed or timed-out test (the classifier's class, D-897) — never green, never a pass, only unproven.
6
6
  # UNREADABLE dominates a mixed result, then RED: a failure anywhere dominates a kill.
7
+ #
8
+ # Two CLOSED inventories (v2.7.8 T4; the workflow renders these names): serial {test, test-macos}; sharded,
9
+ # for os in ubuntu-latest and macos-latest, `suite <os> k/4` for k 1..4, `single-fork <os>`, `aggregate <os>`.
10
+ # A run holding any sharded name is graded against the sharded inventory, else the serial one. The first
11
+ # line is `INVENTORY form=<serial|sharded> declared=<n> observed=<n>`; each missing, duplicated or unknown
12
+ # name prints `INVENTORY_DEFECT kind=<missing|duplicate|unknown> job=<json-quoted name>` and makes the run
13
+ # UNREADABLE: a job the inventory does not name is never skipped.
14
+ #
15
+ # A verdict line is read only from the log lines whose gh step column equals its declared step, exactly
16
+ # once, else UNREADABLE — a line a test or any other step prints, before or after the real one, never decides:
17
+ # COUNT_ORACLE <GREEN|RED> expected=<n|UNREADABLE> actual=<n|UNREADABLE> step `Assert collected test-file count`
18
+ # COVERAGE_FLOORS verdict=… floors=… failing=… (T1's final floors line) step `Recheck coverage floors`
19
+ # SET_ORACLE verdict=… expected=… actual=… defects=… (T3's set oracle) step `Assert collected test-file set`
20
+ # ROLES — the shared classifier (classify-vitest-log.sh) stays the one log verdict:
21
+ # serial the base rule (count oracle GREEN at the expected count, the classifier given that count
22
+ # reading CLEAN or RPC_ONLY, passed+skipped equal to it) AND exactly one COVERAGE_FLOORS
23
+ # verdict=GREEN, so a dropped floors step reads UNREADABLE.
24
+ # shard, single-fork
25
+ # GREEN only on conclusion success whose whole log the classifier, given no count, reads CLEAN
26
+ # or RPC_ONLY: their wrapper already applied the shard contract in-job.
27
+ # aggregate GREEN only on conclusion success with exactly one COVERAGE_FLOORS verdict=GREEN, exactly one
28
+ # `SET_ORACLE verdict=GREEN expected=N actual=N defects=0`, and the lines of its step
29
+ # `Merge coverage (one threshold check per OS)` classified CLEAN or RPC_ONLY.
30
+ # Exit annotations: SERIAL keeps the one exception run-ci-vitest.sh grants (a failure explained ONLY by steps
31
+ # that exited exactly 1 on a log of their own the classifier reads RPC_ONLY); any other failing annotation is
32
+ # never GREEN. Shard, single-fork and aggregate admit none — their wrapper applied the exception in-job.
33
+ # INFRA_KILLED is the base rule for every role.
7
34
  set -u
8
35
 
9
36
  run=${1:?run id required}
@@ -14,34 +41,96 @@ repo=${TKR_GRADE_CI_REPO:-alzahrani-khalid/tickmarkr}
14
41
  out_dir=${TKR_GRADE_CI_DIR:-${TKR_STATE_DIR:-.tickmarkr}/overseer/diag}
15
42
  mkdir -p "$out_dir" || { echo "UNREADABLE: cannot create log directory $out_dir"; exit 2; }
16
43
 
44
+ serial_inventory=$'test\ntest-macos'
45
+ sharded_inventory=$(for os in ubuntu-latest macos-latest; do
46
+ for k in 1 2 3 4; do echo "suite $os $k/4"; done
47
+ echo "single-fork $os"
48
+ echo "aggregate $os"
49
+ done)
50
+ COUNT_STEP="Assert collected test-file count"
51
+ FLOORS_STEP="Recheck coverage floors"
52
+ SET_STEP="Assert collected test-file set"
53
+ MERGE_STEP="Merge coverage (one threshold check per OS)"
54
+ COUNT_FORMAT='COUNT_ORACLE (GREEN|RED) expected=([0-9]+|UNREADABLE) actual=([0-9]+|UNREADABLE)'
55
+ FLOORS_FORMAT='COVERAGE_FLOORS verdict=(GREEN|RED|UNREADABLE) floors=([0-9]+|UNREADABLE) failing=([0-9]+|UNREADABLE)'
56
+ SET_FORMAT='SET_ORACLE verdict=(GREEN|RED|UNREADABLE) expected=([0-9]+|UNREADABLE) actual=([0-9]+|UNREADABLE) defects=[0-9]+'
57
+
17
58
  verdict=0
18
59
  mark_unreadable() { verdict=2; }
19
60
  mark_red() { [ "$verdict" -eq 2 ] || verdict=1; }
20
61
  mark_killed() { [ "$verdict" -ne 0 ] || verdict=3; }
21
62
  field() { printf '%s\n' "$classified" | sed -n "s/^VITEST_LOG.* $1=\([^ ]*\).*/\1/p"; }
63
+ count() { printf '%s' "$1" | grep -c ''; }
64
+ # json <name> — one JSON string; awk -v decodes gh's @tsv escapes (\t \n \r \\) first
65
+ json() {
66
+ LC_ALL=C awk -v s="$1" 'BEGIN {
67
+ for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
68
+ esc["\""] = "\\\""; esc["\\"] = "\\\\"
69
+ for (i = 1; i <= length(s); i++) { c = substr(s, i, 1); out = out (c in esc ? esc[c] : c) }
70
+ print "\"" out "\""
71
+ }'
72
+ }
73
+ defect() { echo "INVENTORY_DEFECT kind=$1 job=$(json "$2")"; mark_unreadable; }
74
+ # step_log <log> <step> — the job-log lines gh's step column attributes to <step>, as gh printed them
75
+ step_log() { awk -F'\t' -v s="$2" 'NF >= 3 && $2 == s' "$1"; }
76
+ # verdict_line <log> <step> <family> <format> — the ONE <family> line <step> printed, in <format>;
77
+ # else MISSING, MULTIPLE(n) or MALFORMED, each UNREADABLE
78
+ verdict_line() {
79
+ local lines
80
+ lines=$(step_log "$1" "$2" | awk -v esc="$(printf '\033')" '{
81
+ gsub(/\^\[\[[0-9;]*m/, ""); gsub(esc "\\[[0-9;]*m", "")
82
+ sub(/^[^\t]*\t[^\t]*\t[0-9T:.-]+Z ?/, ""); sub(/[ \t\r]+$/, ""); print
83
+ }' | grep -E "^$3( |\$)")
84
+ case $(count "$lines") in
85
+ 0) echo MISSING ;;
86
+ 1) if printf '%s\n' "$lines" | grep -qxE "$4"; then printf '%s\n' "$lines"; else echo MALFORMED; fi ;;
87
+ *) echo "MULTIPLE($(count "$lines"))" ;;
88
+ esac
89
+ }
90
+ readable() { case $1 in MISSING|MULTIPLE*|MALFORMED) return 1 ;; esac; }
91
+ floors_green() { printf '%s\n' "$1" | grep -qxE 'COVERAGE_FLOORS verdict=GREEN floors=[0-9]+ failing=0'; }
22
92
 
23
93
  jobs=$(gh run view "$run" --repo "$repo" --json jobs \
24
94
  --jq '.jobs[] | [.databaseId, .name, .status, (.conclusion // "")] | @tsv') \
25
95
  || { echo "UNREADABLE: job list"; exit 2; }
26
- [ -n "$jobs" ] || { echo "UNREADABLE: empty job list"; exit 2; }
96
+ # an empty job list is an inventory like any other: serial, observed=0, every declared job missing
97
+
98
+ names=$(printf '%s\n' "$jobs" | cut -f2)
99
+ if printf '%s\n' "$names" | grep -qxF -f <(printf '%s\n' "$sharded_inventory"); then
100
+ form=sharded; declared=$sharded_inventory
101
+ else
102
+ form=serial; declared=$serial_inventory
103
+ fi
104
+ echo "INVENTORY form=$form declared=$(count "$declared") observed=$(count "$names")"
105
+ while IFS= read -r name; do
106
+ case $(printf '%s\n' "$names" | grep -cxF -- "$name") in
107
+ 0) defect missing "$name" ;;
108
+ 1) ;;
109
+ *) defect duplicate "$name" ;;
110
+ esac
111
+ done <<< "$declared"
112
+ while IFS= read -r name; do
113
+ printf '%s\n' "$declared" | grep -qxF -- "$name" || defect unknown "$name"
114
+ done < <([ -z "$jobs" ] || printf '%s\n' "$names" | awk '!seen[$0]++')
27
115
 
28
- seen_test=0
29
- seen_macos=0
30
- while IFS=$'\t' read -r id name status conclusion; do
116
+ [ -z "$jobs" ] || while IFS=$'\t' read -r id name status conclusion; do
117
+ # an unknown job has no role to grade: its INVENTORY_DEFECT already made the run UNREADABLE
118
+ printf '%s\n' "$declared" | grep -qxF -- "$name" || continue
31
119
  case "$name" in
32
- test) seen_test=1 ;;
33
- test-macos) seen_macos=1 ;;
34
- *) continue ;;
120
+ test|test-macos) role=serial ;;
121
+ suite\ *) role=shard ;;
122
+ single-fork\ *) role=single-fork ;;
123
+ *) role=aggregate ;;
35
124
  esac
36
125
 
37
- echo "$name: status=$status conclusion=${conclusion:-none} job=$id"
126
+ echo "$name: role=$role status=$status conclusion=${conclusion:-none} job=$id"
38
127
  if [ "$status" != "completed" ]; then
39
128
  echo "$name: UNREADABLE (job has not reached run-end)"
40
129
  mark_unreadable
41
130
  continue
42
131
  fi
43
132
 
44
- log="$out_dir/CI-$tag-$name.log"
133
+ log="$out_dir/CI-$tag-$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-').log"
45
134
  gh run view --repo "$repo" --job "$id" --log > "$log" 2>/dev/null
46
135
  if [ ! -s "$log" ]; then
47
136
  echo "$name: UNREADABLE (empty log)"
@@ -49,24 +138,28 @@ while IFS=$'\t' read -r id name status conclusion; do
49
138
  continue
50
139
  fi
51
140
 
52
- oracle=$(grep -oE 'COUNT_ORACLE [A-Z]+ expected=[0-9A-Z]+ actual=[0-9A-Z]+' "$log" | tail -1)
53
141
  files=$(grep -oE 'Test Files .*' "$log" | sed 's/[[:space:]]*$//')
54
142
  # OBS-1184: the log verdict comes from the ONE classifier the public CI wrapper also uses
55
143
  # (scripts/run-ci-vitest.sh), so the badge and this grade cannot disagree. Its rules — complete
56
144
  # summaries, failed/timed-out tests, coverage-threshold misses, and the RPC-timeout-only exception
57
145
  # to "any unhandled error is RED" (OBS-1058) — live in classify-vitest-log.sh, stated once there.
58
- # The expected count is the job's declared file contract: both steps' summaries must collect it all.
59
- classified=$(bash "$here/classify-vitest-log.sh" "$log" "$expected" 2>&1)
146
+ # A serial job's expected count is its declared file contract: both steps' summaries must collect it all.
147
+ # A shard or single-fork job carries no count: its wrapper held it to its plan in-job.
148
+ if [ "$role" = serial ]; then
149
+ classified=$(bash "$here/classify-vitest-log.sh" "$log" "$expected" 2>&1)
150
+ else
151
+ classified=$(bash "$here/classify-vitest-log.sh" "$log" 2>&1)
152
+ fi
60
153
  log_verdict=$(field verdict)
61
154
  passed=$(field passed); skipped=$(field skipped); failed=$(field failed); timedout=$(field timedout)
62
155
  unhandled=$(field unhandled); rpc=$(field runner_rpc_timeouts); coverage=$(field coverage_misses)
63
156
  kills=$(field runner_kills)
64
157
  errors=$(grep -oE '##\[error\].*' "$log" | sort | uniq -c | sed 's/^ *//' | tr '\n' ';')
65
158
  # The commands' own outcomes stand, as in the wrapper: GREEN needs a success conclusion with no step
66
- # exit annotation, or a failure explained ONLY by steps that exited exactly 1 on a log of their own
67
- # (gh's step column) that the classifier calls RPC_ONLY — the one exception run-ci-vitest.sh grants
68
- # (a pre-wrapper run concludes failure on it). Any other code (2, a signal's 137), an unexplained
69
- # failure, or a cancelled, timed-out or missing conclusion is RED.
159
+ # exit annotation, or — SERIAL only — a failure explained ONLY by steps that exited exactly 1 on a log
160
+ # of their own (gh's step column) that the classifier calls RPC_ONLY. Any other code (2, a signal's
161
+ # 137), any annotation on another role, an unexplained failure, or a cancelled, timed-out or missing
162
+ # conclusion is RED.
70
163
  exits=$(grep -E '##\[error\]Process completed with exit code [0-9]+' "$log")
71
164
  case "$conclusion" in
72
165
  success) outcome=ok ;;
@@ -76,9 +169,9 @@ while IFS=$'\t' read -r id name status conclusion; do
76
169
  if [ -n "$exits" ]; then
77
170
  while IFS= read -r exit_line; do
78
171
  code=$(printf '%s\n' "$exit_line" | sed -E 's/.*Process completed with exit code ([0-9]+).*/\1/')
79
- if [ "$code" != 1 ]; then outcome="exit-$code"; break; fi
172
+ if [ "$code" != 1 ] || [ "$role" != serial ]; then outcome="exit-$code"; break; fi
80
173
  step=$(printf '%s\n' "$exit_line" | awk -F'\t' 'NF >= 3 { print $2 }')
81
- awk -F'\t' -v s="$step" 'NF >= 3 && $2 == s' "$log" > "$log.step"
174
+ step_log "$log" "$step" > "$log.step"
82
175
  case $(bash "$here/classify-vitest-log.sh" "$log.step" 2>&1) in
83
176
  "VITEST_LOG verdict=RPC_ONLY "*) ;;
84
177
  *) outcome="exit-1-not-rpc-only"; break ;;
@@ -86,36 +179,60 @@ while IFS=$'\t' read -r id name status conclusion; do
86
179
  done <<< "$exits"
87
180
  fi
88
181
 
89
- echo "$name: oracle=[${oracle:-MISSING}] files=[$(printf '%s' "$files" | tr '\n' '|')] passed=$passed skipped=$skipped failed=$failed timedout=$timedout unhandled=$unhandled runner_rpc_timeouts=$rpc coverage_misses=$coverage runner_kills=${kills:-?} log=[$(field reason)] outcome=[$outcome] errors=[$errors]"
90
- # A killed runner skips the count step, so its missing oracle is the kill's, not an unreadable log:
182
+ oracles=
183
+ case $role in
184
+ serial)
185
+ oracle=$(verdict_line "$log" "$COUNT_STEP" COUNT_ORACLE "$COUNT_FORMAT")
186
+ floors=$(verdict_line "$log" "$FLOORS_STEP" COVERAGE_FLOORS "$FLOORS_FORMAT")
187
+ oracles="oracle=[$oracle] floors=[$floors] " ;;
188
+ aggregate)
189
+ floors=$(verdict_line "$log" "$FLOORS_STEP" COVERAGE_FLOORS "$FLOORS_FORMAT")
190
+ set_oracle=$(verdict_line "$log" "$SET_STEP" SET_ORACLE "$SET_FORMAT")
191
+ step_log "$log" "$MERGE_STEP" > "$log.merge"
192
+ merge=$(bash "$here/classify-vitest-log.sh" "$log.merge" 2>&1 | sed -n 's/^VITEST_LOG verdict=\([^ ]*\) reason=\([^ ]*\) .*/\1 \2/p')
193
+ oracles="floors=[$floors] set=[$set_oracle] merge=[$merge] " ;;
194
+ esac
195
+
196
+ echo "$name: ${oracles}files=[$(printf '%s' "$files" | tr '\n' '|')] passed=$passed skipped=$skipped failed=$failed timedout=$timedout unhandled=$unhandled runner_rpc_timeouts=$rpc coverage_misses=$coverage runner_kills=${kills:-?} log=[$(field reason)] outcome=[$outcome] errors=[$errors]"
197
+ # A killed runner skips the oracle steps, so a missing verdict line is the kill's, not an unreadable log:
91
198
  # INFRA_KILLED unless a step's own failing exit or a failed/timed-out test dominates it (then RED).
92
199
  if [ "$log_verdict" = INFRA_KILLED ] && [ "${outcome#exit-}" = "$outcome" ]; then
93
200
  echo "$name: INFRA_KILLED"
94
201
  mark_killed
202
+ continue
95
203
  elif [ "${kills:-0}" -gt 0 ] 2>/dev/null; then
96
- echo "$name: RED"
97
- mark_red
98
- elif [ -z "$oracle" ] || [ -z "$files" ] || [ -z "$log_verdict" ]; then
99
- echo "$name: UNREADABLE"
100
- mark_unreadable
101
- elif [ "$oracle" = "COUNT_ORACLE GREEN expected=$expected actual=$expected" ] \
102
- && { [ "$log_verdict" = CLEAN ] || [ "$log_verdict" = RPC_ONLY ]; } && [ "$outcome" = ok ] \
103
- && [ $((passed + skipped)) -eq "$expected" ]; then
104
- echo "$name: GREEN"
204
+ grade=RED
205
+ elif [ -z "$log_verdict" ]; then
206
+ grade=UNREADABLE
105
207
  else
106
- echo "$name: RED"
107
- mark_red
208
+ case $log_verdict in CLEAN|RPC_ONLY) log_ok=1 ;; *) log_ok= ;; esac
209
+ grade=RED
210
+ case $role in
211
+ serial)
212
+ if ! readable "$oracle" || ! readable "$floors" || [ -z "$files" ]; then
213
+ grade=UNREADABLE
214
+ elif [ "$oracle" = "COUNT_ORACLE GREEN expected=$expected actual=$expected" ] && floors_green "$floors" \
215
+ && [ -n "$log_ok" ] && [ "$outcome" = ok ] && [ $((passed + skipped)) -eq "$expected" ]; then
216
+ grade=GREEN
217
+ fi ;;
218
+ shard|single-fork)
219
+ if [ "$outcome" = ok ] && [ "$conclusion" = success ] && [ -n "$log_ok" ]; then grade=GREEN; fi ;;
220
+ aggregate)
221
+ if ! readable "$floors" || ! readable "$set_oracle"; then
222
+ grade=UNREADABLE
223
+ elif [ "$outcome" = ok ] && [ "$conclusion" = success ] && floors_green "$floors" \
224
+ && [ "$set_oracle" = "SET_ORACLE verdict=GREEN expected=$expected actual=$expected defects=0" ] \
225
+ && { [ "${merge%% *}" = CLEAN ] || [ "${merge%% *}" = RPC_ONLY ]; }; then
226
+ grade=GREEN
227
+ fi ;;
228
+ esac
108
229
  fi
230
+ echo "$name: $grade"
231
+ case $grade in
232
+ RED) mark_red ;;
233
+ UNREADABLE) mark_unreadable ;;
234
+ esac
109
235
  done <<< "$jobs"
110
236
 
111
- if [ "$seen_test" -ne 1 ]; then
112
- echo "test: UNREADABLE (job missing from run)"
113
- mark_unreadable
114
- fi
115
- if [ "$seen_macos" -ne 1 ]; then
116
- echo "test-macos: UNREADABLE (job missing from run)"
117
- mark_unreadable
118
- fi
119
-
120
237
  echo "VERDICT rc=$verdict (0=all GREEN 1=RED 2=UNREADABLE 3=INFRA_KILLED)"
121
238
  exit "$verdict"