tickmarkr 2.5.7 → 2.5.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.
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { userInfo } from "node:os";
4
4
  import { loadConfig } from "../../config/config.js";
5
5
  import { integrationBranch } from "../../run/merge.js";
6
- import { separabilityErrors } from "../../compile/collateral.js";
6
+ import { separabilityErrors, surfaceErrors, taskBudgetErrors } from "../../compile/collateral.js";
7
7
  import { GATE_NAMES } from "../../graph/schema.js";
8
8
  import { applyScopeAmendments, engagementComparable, engagementReleased, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
9
9
  export const APPROVAL_DISPOSITIONS = ["dispatch", "waive-gate", "re-dispatch", "fund-fixed-attempt", "fresh-budget"];
@@ -194,6 +194,13 @@ export async function approve(argv, cwd = process.cwd()) {
194
194
  const conflicts = separabilityErrors(amended.tasks);
195
195
  if (conflicts.length)
196
196
  throw new Error(`refusing scope-request approval for ${taskId}: ${conflicts.join("\n")}`);
197
+ // C4: a live grant may not take a task past the bounds that fail every compile. The exception
198
+ // list is EMPTY on purpose — a recorded historical compile exception confers no live authority.
199
+ const overBound = [...taskBudgetErrors([{ ...task, files: amendedFiles }]), ...surfaceErrors([{ ...task, files: amendedFiles }], [])];
200
+ if (overBound.length) {
201
+ throw new Error(`refusing scope-request approval for ${taskId}: ${overBound.join("\n")}\n`
202
+ + `remedy: close the run, split the task in its spec, recompile, then \`tickmarkr resume ${runId} --graph-changed\``);
203
+ }
197
204
  journal.append("task-approved", taskId, {
198
205
  by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request",
199
206
  amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line, definition: taskDefinitionFingerprint(task) },
@@ -3,6 +3,7 @@ import { parseArgs } from "node:util";
3
3
  import { collateralLints, sourceScopeFindings, sourceScopeLints, } from "../../compile/collateral.js";
4
4
  import { CompileError } from "../../compile/common.js";
5
5
  import { compileSource } from "../../compile/index.js";
6
+ import { retiredLiteralErrors } from "../../compile/retired-literals.js";
6
7
  import { clearCompileRefusal, saveCompileRefusal, saveGraph, stateDirName } from "../../graph/graph.js";
7
8
  import { formatPriorFindingEvidence, readPriorRunEvidence } from "../../run/journal.js";
8
9
  import { shGit } from "../../run/git.js";
@@ -75,6 +76,12 @@ export async function compile(argv, cwd = process.cwd(), harnessFrom = process.a
75
76
  if (unwaived.length > 0) {
76
77
  throw new CompileError(`${src} has unwaived native source-scope authoring errors:\n${unwaived.map((line) => ` - ${line}`).join("\n")}${diagnostics}`);
77
78
  }
79
+ // v2.5.8 T14: a declared pin whose obligated file the declaring task does not own refuses the seal
80
+ // here, before any state write, so a dry run reaches the same refusal. Diagnostic only: never widens scope.
81
+ const uncovered = retiredLiteralErrors(g.tasks, cwd);
82
+ if (uncovered.length > 0) {
83
+ throw new CompileError(`${src} has uncovered declared pin obligations:\n${uncovered.map((line) => ` - ${line}`).join("\n")}${diagnostics}`);
84
+ }
78
85
  // One bounded read supplies both cross-run surfaces: unresolved findings below and merge facts for
79
86
  // the ancestry check. Neither fact mutates the compiled graph; status and every readiness predicate
80
87
  // remain the source compiler's answer.
@@ -1,6 +1,11 @@
1
+ import { type Task } from "../../graph/schema.js";
1
2
  import { type VitestListResult } from "../../gates/acceptance.js";
2
3
  import { type WorkerAdapter } from "../../adapters/types.js";
3
4
  export type PlanOpts = {
4
5
  listTests?: (cwd: string) => Promise<VitestListResult>;
5
6
  };
7
+ export declare function plannedOracleDisposition(task: Pick<Task, "files" | "acceptance">, listing: VitestListResult): {
8
+ refusals: string[];
9
+ advisories: string[];
10
+ };
6
11
  export declare function plan(argv: string[], cwd?: string, adapters?: WorkerAdapter[], harnessFrom?: string | undefined, opts?: PlanOpts): Promise<string>;
@@ -8,6 +8,7 @@ import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
8
8
  import { classifyContextPath } from "../../compile/native.js";
9
9
  import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
10
10
  import { chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
11
+ import { filesGlob } from "../../graph/files-glob.js";
11
12
  import { renderAcceptanceItem } from "../../graph/schema.js";
12
13
  import { resolveRunMode } from "../../run/daemon.js";
13
14
  import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
@@ -47,6 +48,32 @@ const fleetCanCrossVendorReview = (channels) => {
47
48
  return true;
48
49
  return false;
49
50
  };
51
+ // Planning can defer authorship only to this criterion's explicitly owned landing.
52
+ // This advisory does not change runtime oracle execution or name resolution.
53
+ export function plannedOracleDisposition(task, listing) {
54
+ const refusals = [];
55
+ const advisories = [];
56
+ const items = task.acceptance.filter((item) => typeof item === "object" && item.oracle === "test");
57
+ if (listing.status === "failed") {
58
+ if (items.length)
59
+ refusals.push(`acceptance oracle unresolved — runner listing failed: ${listing.error.split("\n")[0]}`);
60
+ return { refusals, advisories };
61
+ }
62
+ const owns = filesGlob(task.files.map((path) => path.replace(/^\.\//, "")));
63
+ for (const item of items) {
64
+ const audit = auditNamedTestOracles([item], listing.tests)[0];
65
+ if (audit.matches.length === 0 && item.landing && owns(item.landing)) {
66
+ advisories.push(`acceptance oracle ${JSON.stringify(audit.criterion)} not yet written (worker authors ${item.landing})`);
67
+ }
68
+ else if (audit.matches.length === 0) {
69
+ refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches zero runner-listed test names`);
70
+ }
71
+ else if (audit.matches.length > 1) {
72
+ refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches ${audit.matches.length} runner-listed test names`);
73
+ }
74
+ }
75
+ return { refusals, advisories };
76
+ }
50
77
  const NAMED_CRITERION_PATH = /(?:^|[\s("'`])((?:src|tests|fixtures|scripts)\/[A-Za-z0-9_@{}*?.,/+-]+\.(?:tsx|ts|jsx|json|js|mjs|cjs|md|txt))(?![A-Za-z0-9])/g;
51
78
  const judgeOracle = (task) => task.acceptance.some((item) => typeof item === "string" || (typeof item === "object" && item.oracle === "judge"));
52
79
  const latestRunStatusLine = (cwd) => {
@@ -166,29 +193,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
166
193
  if (g.tasks.some((task) => task.acceptance.some((item) => typeof item === "object" && item.oracle === "test"))) {
167
194
  const listing = await (opts.listTests ?? listVitestTests)(cwd);
168
195
  for (const task of g.tasks) {
169
- const refusals = [];
170
- const advisories = [];
171
- const authoredTest = task.files
172
- .map((path) => path.replace(/^\.\//, ""))
173
- .find((path) => /^tests\/.+\.test\.ts$/.test(path) && !/[?*{[]/.test(path) && !existsSync(join(cwd, path)));
174
- if (listing.status === "failed") {
175
- if (task.acceptance.some((item) => typeof item === "object" && item.oracle === "test")) {
176
- refusals.push(`acceptance oracle unresolved — runner listing failed: ${listing.error.split("\n")[0]}`);
177
- }
178
- }
179
- else {
180
- for (const audit of auditNamedTestOracles(task.acceptance, listing.tests)) {
181
- if (audit.matches.length === 0 && authoredTest) {
182
- advisories.push(`acceptance oracle ${JSON.stringify(audit.criterion)} not yet written (worker authors ${authoredTest})`);
183
- }
184
- else if (audit.matches.length === 0) {
185
- refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches zero runner-listed test names`);
186
- }
187
- else if (audit.matches.length > 1) {
188
- refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches ${audit.matches.length} runner-listed test names`);
189
- }
190
- }
191
- }
196
+ const { refusals, advisories } = plannedOracleDisposition(task, listing);
192
197
  if (refusals.length)
193
198
  oracleRefusals.set(task.id, refusals);
194
199
  if (advisories.length)
@@ -39,7 +39,7 @@ export async function resume(argv, cwd = process.cwd()) {
39
39
  }
40
40
  await assertRefsWritable(cwd, "resume");
41
41
  const host = classifyHost();
42
- preflightHostDriver(cfg, driverOverride, host);
42
+ await preflightHostDriver(cfg, driverOverride, host, cwd);
43
43
  const narrate = narrationSink(runId);
44
44
  const s = await runDaemon(cwd, {
45
45
  runId,
@@ -511,7 +511,7 @@ export async function run(argv, cwd = process.cwd()) {
511
511
  }
512
512
  await assertRefsWritable(cwd, "run");
513
513
  const host = classifyHost();
514
- preflightHostDriver(cfg, driverOverride, host);
514
+ await preflightHostDriver(cfg, driverOverride, host, cwd);
515
515
  // The run id is minted HERE rather than inside the daemon, because the narration sink has to know
516
516
  // which run it is narrating before the first event arrives (the daemon's `narrate` callback is
517
517
  // handed an event and nothing else, and `run-start` carries no run id). `runDaemon` uses the id
@@ -69,7 +69,51 @@ const NESTED_RE = /^\s+- (.+)$/;
69
69
  // v1.19: a typed acceptance oracle line — "command: ...", "test: ...", or "judge: ...". Anything
70
70
  // without one of these prefixes is a plain-string judge criterion (compat path, emits a warning).
71
71
  const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*(.*)$`);
72
- const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout"]);
72
+ const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout", "pins"]);
73
+ const listItems = (draft) => draft.list === "acceptance" ? draft.acceptanceRaw : draft.list === "pins" ? draft.pinsRaw : draft.gates;
74
+ // v2.5.8 T7: one declared pin item — "literal: <exact text> | glob: <search glob>" (split at the LAST
75
+ // " | glob: " so the text may itself hold a bar) or "fixture: <path set>". A declaration missing the
76
+ // payload its own kind requires refuses the compile naming the task and the item.
77
+ const PIN_GLOB_SEP = "| glob:";
78
+ // v2.5.8 T8 (OBS-1064): "test: <title> | suite: <path>" — split at the LAST " | suite: " so the title
79
+ // stays the verbatim leaf the gate matches and the landing path never enters it.
80
+ const LANDING_SEP = "| suite:";
81
+ function parsePin(task, raw, index, pathSet) {
82
+ const bad = (detail) => invalid(task, "pins", `item ${index + 1} (${JSON.stringify(raw)}) ${detail}`);
83
+ const typed = raw.match(/^(\w+):\s*(.*)$/);
84
+ if (typed?.[1] === "fixture") {
85
+ const paths = pathSet(typed[2]);
86
+ if (!paths.length)
87
+ bad("is a fixture pin missing its path set");
88
+ return { kind: "fixture", paths };
89
+ }
90
+ if (typed?.[1] === "literal") {
91
+ const at = typed[2].lastIndexOf(PIN_GLOB_SEP);
92
+ const text = (at < 0 ? typed[2] : typed[2].slice(0, at)).trim();
93
+ const glob = at < 0 ? "" : typed[2].slice(at + PIN_GLOB_SEP.length).trim();
94
+ if (!text)
95
+ bad("is a literal pin missing its text");
96
+ if (!glob)
97
+ bad(`is a literal pin missing its search glob — write "literal: <text> ${PIN_GLOB_SEP} <glob>"`);
98
+ return { kind: "literal", text, glob };
99
+ }
100
+ return bad('must be "literal: <text> | glob: <glob>" or "fixture: <path set>"');
101
+ }
102
+ function parseTestOracle(task, body, index) {
103
+ const at = body.lastIndexOf(LANDING_SEP);
104
+ if (at < 0)
105
+ return { oracle: "test", test: body.trim() };
106
+ const test = body.slice(0, at).trim();
107
+ const landing = body.slice(at + LANDING_SEP.length).trim();
108
+ const bad = (detail) => invalid(task, "acceptance", `item ${index + 1} (${JSON.stringify(body)}) ${detail}`);
109
+ if (!test)
110
+ bad("is a test oracle missing its title before the landing");
111
+ if (!landing)
112
+ bad(`is a test oracle missing its landing path — write "test: <title> ${LANDING_SEP} <path>"`);
113
+ if (!picomatch(COLLECTABLE_TESTS, { dot: true })(landing))
114
+ bad(`declares landing ${JSON.stringify(landing)} which no runner collects (${COLLECTABLE_TESTS})`);
115
+ return { oracle: "test", test, landing };
116
+ }
73
117
  function invalid(task, field, detail) {
74
118
  throw new CompileError(`Task ${task} field "${field}" ${detail}`);
75
119
  }
@@ -376,7 +420,7 @@ export function compileNative(file, options = {}) {
376
420
  for (const [index, line] of content.split("\n").entries()) {
377
421
  const heading = line.match(HEAD_RE);
378
422
  if (heading) {
379
- drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, list: null, itemOpen: false, continuationField: null });
423
+ drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, pinsRaw: [], list: null, itemOpen: false, continuationField: null });
380
424
  continue;
381
425
  }
382
426
  const draft = drafts.at(-1);
@@ -411,7 +455,7 @@ export function compileNative(file, options = {}) {
411
455
  invalid(draft.id, field[1], "is unknown");
412
456
  const value = field[2].trim();
413
457
  draft.itemOpen = false;
414
- if (name === "acceptance" || name === "gates") {
458
+ if (name === "acceptance" || name === "gates" || name === "pins") {
415
459
  if (value)
416
460
  invalid(draft.id, field[1], "must be a nested list");
417
461
  draft.list = name;
@@ -459,7 +503,7 @@ export function compileNative(file, options = {}) {
459
503
  const value = nested[1].trim();
460
504
  if (!value)
461
505
  invalid(draft.id, draft.list, "must not contain empty entries");
462
- (draft.list === "acceptance" ? draft.acceptanceRaw : draft.gates).push(value);
506
+ listItems(draft).push(value);
463
507
  draft.itemOpen = true;
464
508
  continue;
465
509
  }
@@ -468,7 +512,7 @@ export function compileNative(file, options = {}) {
468
512
  // 1.87.0 dropped these lines silently: 53/78 of run-551's criteria compiled to first-line
469
513
  // stubs and every falsifier tail was invisible to the judge.
470
514
  if (draft.list && draft.itemOpen && (line.startsWith(" ") || line.startsWith("\t")) && line.trim()) {
471
- const items = draft.list === "acceptance" ? draft.acceptanceRaw : draft.gates;
515
+ const items = listItems(draft);
472
516
  items[items.length - 1] += ` ${line.trim()}`;
473
517
  continue;
474
518
  }
@@ -489,14 +533,14 @@ export function compileNative(file, options = {}) {
489
533
  // OBS-488: typed-oracle prefixes parse on the COMPLETE joined item text, never its first
490
534
  // physical line — a wrapped `command:` body or falsifier tail is part of the criterion.
491
535
  for (const draft of drafts) {
492
- for (const raw of draft.acceptanceRaw) {
536
+ for (const [index, raw] of draft.acceptanceRaw.entries()) {
493
537
  const typed = raw.match(ORACLE_RE);
494
538
  if (typed) {
495
539
  const [, kind, body] = typed;
496
540
  if (!body.trim())
497
541
  invalid(draft.id, "acceptance", `${kind} oracle must carry a value`);
498
542
  draft.acceptance.push(kind === "command" ? { oracle: "command", command: body.trim() }
499
- : kind === "test" ? { oracle: "test", test: body.trim() }
543
+ : kind === "test" ? parseTestOracle(draft.id, body, index)
500
544
  : { oracle: "judge", text: body.trim() });
501
545
  }
502
546
  else {
@@ -561,6 +605,9 @@ export function compileNative(file, options = {}) {
561
605
  return trimmed;
562
606
  };
563
607
  const csv = (value) => value && value.toLowerCase() !== "none" ? splitTop(value).map((item) => stripAnnotation(item.trim())).filter(Boolean) : [];
608
+ // A fixture pin's path set is split only — never annotation-stripped: "fixtures/output (old)" is a
609
+ // filename, and csv() would silently retarget the declared obligation to "fixtures/output".
610
+ const pinPaths = (value) => splitTop(value).map((item) => item.trim()).filter(Boolean);
564
611
  // OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
565
612
  // paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
566
613
  // mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
@@ -677,6 +724,7 @@ export function compileNative(file, options = {}) {
677
724
  ...(timeoutMinutes !== undefined ? { timeoutMinutes } : {}),
678
725
  ...(routingHints ? { routingHints } : {}),
679
726
  ...(draft.hasGates ? { gates: draft.gates } : {}),
727
+ ...(draft.pinsRaw.length ? { pins: draft.pinsRaw.map((raw, i) => parsePin(draft.id, raw, i, pinPaths)) } : {}),
680
728
  };
681
729
  });
682
730
  const result = validateGraph({
@@ -780,9 +828,19 @@ acceptance is required on every task (a nested list of observable outcomes).
780
828
  acceptance: nested list (REQUIRED, non-empty). Each item is either a typed oracle or plain text:
781
829
  - command: <shell> (oracle: command — exit code)
782
830
  - test: <name> (oracle: test — named test)
831
+ - test: <name> | suite: <path> (same, plus the tests/**/*.test.ts file it lands in;
832
+ the path lives in the item's landing field, never in the title)
783
833
  - judge: <rubric> (oracle: judge — LLM-judged, free text)
784
834
  A judge criterion carries ONE claim; a semicolon-joined criterion warns — split its clauses.
785
835
  - <plain text> (compat: compiles as judge oracle, warns)
836
+ pins: nested list (optional) of retired-literal and fixture pin obligations:
837
+ - literal: <exact text> | glob: <search glob> (every file matching the glob that holds
838
+ the text is an obligated file)
839
+ - fixture: <comma-separated path set> (every matching file is itself obligated:
840
+ byte-pinned output the change will move;
841
+ it carries no literal text)
842
+ LAW: a pins declaration is a LIMITED AUTHORING CONTRACT, not an assertion analyzer —
843
+ tickmarkr holds you to the obligations you DECLARE; it does not discover the pins you forgot.
786
844
 
787
845
  HARD BOUNDS — these FAIL the compile, they do not warn:
788
846
  - at most 6 acceptance items per task (no exception path)
@@ -799,6 +857,9 @@ acceptance is required on every task (a nested list of observable outcomes).
799
857
  describe() is allowed (OBS-511: the gate matches the criterion as the trailing segment of the
800
858
  runner-visible full name), but the leaf title itself must equal the criterion: a shortened,
801
859
  decorated, or paraphrased leaf selects ZERO tests and the gate parks even though the suite is green.
860
+ To say WHICH suite the test lands in, append " | suite: <tests/**/*.test.ts path>" — the path is
861
+ parsed off into the item's landing field and the title stays the bare leaf; a landing outside the
862
+ collectable glob refuses the compile.
802
863
  Measured before the widening: two parks across two runs, one full attempt lost in each.
803
864
  - NO criterion may be satisfiable by an absence, a rename, a source-text grep, or an empty collection.
804
865
  "no file references X" is not a criterion — it passes in a repo where the feature was never built.
@@ -0,0 +1,22 @@
1
+ import type { Task } from "../graph/schema.js";
2
+ /** The file-system seam: tests substitute it to stage an unreadable file or a truncated read. */
3
+ export interface PinScanIo {
4
+ size(abs: string): number;
5
+ read(abs: string): Buffer;
6
+ /** stat-following; throws like statSync (ENOENT for a broken link). */
7
+ isDirectory(abs: string): boolean;
8
+ /** stat-following: a regular file (or a link resolving to one) — never a FIFO, socket or device. */
9
+ isFile(abs: string): boolean;
10
+ }
11
+ export interface LiteralHit {
12
+ path: string;
13
+ line: number;
14
+ }
15
+ /** Each line of `content` holding `text`, 1-based. */
16
+ export declare function literalHits(path: string, content: string, text: string): LiteralHit[];
17
+ /**
18
+ * One line per uncovered obligation or uncertifiable scan; empty means every declared pin is covered
19
+ * by the task that declares it. Ownership by any OTHER task — merged predecessor or downstream — is
20
+ * named in the diagnostic and is no substitute.
21
+ */
22
+ export declare function retiredLiteralErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "pins">>, repoRoot: string, io?: PinScanIo): string[];
@@ -0,0 +1,271 @@
1
+ import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { filesGlob, literalParens } from "../graph/files-glob.js";
4
+ const NODE_IO = {
5
+ size: (abs) => statSync(abs).size,
6
+ read: (abs) => readFileSync(abs),
7
+ isDirectory: (abs) => statSync(abs).isDirectory(),
8
+ isFile: (abs) => statSync(abs).isFile(),
9
+ };
10
+ const cause = (error) => (error instanceof Error ? error.message : String(error));
11
+ const patternsOf = (pin) => (pin.kind === "literal" ? [pin.glob] : pin.paths);
12
+ /** Leading segments with no glob syntax at all (never the last): every match of the pattern sits under them. */
13
+ const literalBase = (pattern) => {
14
+ const segs = literalParens(pattern).split("/");
15
+ const wild = segs.findIndex((s) => !/^[\w.-]+$/.test(s) || s === "..");
16
+ return segs.slice(0, Math.min(wild < 0 ? segs.length : wild, segs.length - 1)).join("/");
17
+ };
18
+ /**
19
+ * picomatch's brace semantics are expansion (`{a,b/c}/x` ≡ `a/x`, `b/c/x`; `\,` and `\{` are literal):
20
+ * every top-level comma-holding brace is expanded until none remains, so a slash inside a brace is an
21
+ * honest segment boundary. A brace with no top-level comma (`{a}`, `{1..3}`) is left for filesGlob.
22
+ */
23
+ function expandBraces(pattern) {
24
+ let depth = 0;
25
+ let open = -1;
26
+ let alts = [];
27
+ let last = -1;
28
+ for (let i = 0; i < pattern.length; i++) {
29
+ const c = pattern[i];
30
+ if (c === "\\")
31
+ i++;
32
+ else if (c === "{") {
33
+ if (depth++ === 0) {
34
+ open = i;
35
+ last = i + 1;
36
+ alts = [];
37
+ }
38
+ }
39
+ else if (c === "," && depth === 1) {
40
+ alts.push(pattern.slice(last, i));
41
+ last = i + 1;
42
+ }
43
+ else if (c === "}" && depth > 0 && --depth === 0) {
44
+ if (!alts.length)
45
+ continue; // no top-level comma: a literal or range brace, not an alternation
46
+ alts.push(pattern.slice(last, i));
47
+ const head = pattern.slice(0, open);
48
+ const tail = pattern.slice(i + 1);
49
+ return alts.flatMap((a) => expandBraces(head + a + tail));
50
+ }
51
+ }
52
+ return [pattern];
53
+ }
54
+ /** Split on "/" where it is a segment boundary; null when a slash is escaped or sits inside brackets. */
55
+ function segmentsOf(pattern) {
56
+ const out = [""];
57
+ let depth = 0;
58
+ for (let i = 0; i < pattern.length; i++) {
59
+ const c = pattern[i];
60
+ if (c === "\\") {
61
+ if (pattern[i + 1] === "/")
62
+ return null;
63
+ out[out.length - 1] += c + (pattern[++i] ?? "");
64
+ }
65
+ else if (c === "/") {
66
+ if (depth)
67
+ return null;
68
+ out.push("");
69
+ }
70
+ else {
71
+ if (c === "[")
72
+ depth++;
73
+ else if (c === "]" && depth)
74
+ depth--;
75
+ out[out.length - 1] += c;
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * Does `alt` (brace-free) match EVERY path beneath `d`? Only a trailing globstar right after a matched prefix
82
+ * proves it; a globstar before that may swallow any run of `d`'s segments (`**\/cache/**` covers fixtures/cache/loop).
83
+ */
84
+ function coversBeneath(alt, d) {
85
+ const segs = segmentsOf(alt);
86
+ if (!segs)
87
+ return false;
88
+ const covers = (si, di) => {
89
+ if (si >= segs.length)
90
+ return false;
91
+ if (segs[si] === "**") {
92
+ if (si === segs.length - 1)
93
+ return true;
94
+ for (let k = di; k <= d.length; k++)
95
+ if (covers(si + 1, k))
96
+ return true;
97
+ return false;
98
+ }
99
+ if (di >= d.length)
100
+ return false;
101
+ // the prefix is matched as ONE pattern: a standalone `!cache` segment would read as a negation
102
+ return filesGlob(segs.slice(0, si + 1).join("/"))(d.slice(0, di + 1).join("/")) && covers(si + 1, di + 1);
103
+ };
104
+ return covers(0, 0);
105
+ }
106
+ /**
107
+ * Could `pattern` match a path strictly beneath directory `dir`? Matcher-consistent by construction:
108
+ * braces are expanded the way picomatch reads them, then every segment prefix is decided by filesGlob itself,
109
+ * and only an exact `**` segment is a globstar (`**.txt` is one ordinary segment). A leading `!` matches
110
+ * every path its body does not, so it reaches beneath `dir` unless some branch of the body provably covers
111
+ * all of it. What still cannot be split soundly (an escaped or bracketed slash) answers yes rather than certify blind.
112
+ */
113
+ function reachesBeneath(pattern, dir) {
114
+ const p = pattern.replace(/^\.\//, "");
115
+ const d = dir ? dir.split("/") : [];
116
+ if (p.startsWith("!"))
117
+ return !expandBraces(literalParens(p.slice(1))).some((alt) => coversBeneath(alt, d));
118
+ return expandBraces(literalParens(p)).some((alt) => {
119
+ const segs = segmentsOf(alt);
120
+ if (!segs)
121
+ return true;
122
+ for (let i = 0; i < d.length; i++) {
123
+ if (i >= segs.length)
124
+ return false;
125
+ if (segs[i] === "**")
126
+ return true;
127
+ // the prefix is matched as ONE pattern: a standalone `!cache` segment would read as a negation
128
+ if (!filesGlob(segs.slice(0, i + 1).join("/"))(d.slice(0, i + 1).join("/")))
129
+ return false;
130
+ }
131
+ return segs.length > d.length;
132
+ });
133
+ }
134
+ /**
135
+ * Every non-directory path at or under `base`, decided later by filesGlob alone. NOTHING is pruned:
136
+ * not .git, not node_modules, not ignored files, not the state dir — the declared glob is the only
137
+ * narrowing (the literal base is exact: no match can sit outside it). A directory symlink is
138
+ * followed so matches behind it are listed under the symlink's own path; a link back into its own
139
+ * ancestry has no finite listing, so it refuses by name instead of being skipped.
140
+ * ponytail: a rootless glob such as ** / *.txt walks node_modules too; narrow the glob if that is slow.
141
+ */
142
+ function walkAll(repoRoot, base, io) {
143
+ const out = { paths: [], causes: [] };
144
+ const visit = (rel, chain) => {
145
+ const abs = join(repoRoot, rel);
146
+ let isDir;
147
+ try {
148
+ isDir = io.isDirectory(abs);
149
+ }
150
+ catch (error) {
151
+ try {
152
+ // only a confirmed ENOENT is a known non-directory: a broken link stays a candidate whose read names the cause
153
+ if (error.code === "ENOENT") {
154
+ if (lstatSync(abs, { throwIfNoEntry: false }))
155
+ out.paths.push(rel);
156
+ return;
157
+ }
158
+ }
159
+ catch { /* the lstat failed too: fall through to the refusal */ }
160
+ // anything else may be a directory hiding obligations: never a candidate that a glob could filter away
161
+ out.causes.push({ at: rel, why: `unstattable path ${rel || "."} (${cause(error)})`, maybeFile: true });
162
+ return;
163
+ }
164
+ if (!isDir) {
165
+ if (rel)
166
+ out.paths.push(rel);
167
+ return;
168
+ }
169
+ try {
170
+ const real = realpathSync(abs);
171
+ if (chain.includes(real)) {
172
+ out.causes.push({ at: rel, why: `symlink cycle at ${rel} cannot be enumerated`, maybeFile: false });
173
+ return;
174
+ }
175
+ for (const name of readdirSync(abs).sort())
176
+ visit(rel ? `${rel}/${name}` : name, [...chain, real]);
177
+ }
178
+ catch (error) {
179
+ out.causes.push({ at: rel, why: `unlistable directory ${rel || "."} (${cause(error)})`, maybeFile: false });
180
+ }
181
+ };
182
+ visit(base, []);
183
+ return out;
184
+ }
185
+ /** Whole-file read, or a thrown cause: a short read is never evidence of absence. */
186
+ function readWhole(repoRoot, rel, io) {
187
+ const abs = join(repoRoot, rel);
188
+ let size;
189
+ let bytes;
190
+ try {
191
+ // a FIFO, socket or device has no finite whole to read: refuse it by kind before any read could block
192
+ if (!io.isFile(abs))
193
+ throw new Error(`unsupported file ${rel} (not a regular file)`);
194
+ size = io.size(abs);
195
+ bytes = io.read(abs);
196
+ }
197
+ catch (error) {
198
+ throw new Error(`unreadable file ${rel} (${cause(error)})`);
199
+ }
200
+ if (bytes.length !== size)
201
+ throw new Error(`truncated read of ${rel} (${bytes.length} of ${size} bytes)`);
202
+ return bytes.toString("utf8");
203
+ }
204
+ /** Each line of `content` holding `text`, 1-based. */
205
+ export function literalHits(path, content, text) {
206
+ return content.split("\n").flatMap((line, i) => (line.includes(text) ? [{ path, line: i + 1 }] : []));
207
+ }
208
+ const pinName = (pin) => pin.kind === "literal" ? `literal ${JSON.stringify(pin.text)} in ${pin.glob}` : `fixture pin ${pin.paths.join(", ")}`;
209
+ /**
210
+ * One line per uncovered obligation or uncertifiable scan; empty means every declared pin is covered
211
+ * by the task that declares it. Ownership by any OTHER task — merged predecessor or downstream — is
212
+ * named in the diagnostic and is no substitute.
213
+ */
214
+ export function retiredLiteralErrors(tasks, repoRoot, io = NODE_IO) {
215
+ const declaring = tasks.filter((t) => t.pins?.length);
216
+ if (!declaring.length)
217
+ return [];
218
+ const walks = new Map();
219
+ const walked = (base) => walks.get(base) ?? walks.set(base, walkAll(repoRoot, base, io)).get(base);
220
+ const owns = new Map(tasks.map((t) => [t.id, filesGlob(t.files.map((f) => f.replace(/^\.\//, "")))]));
221
+ const errors = [];
222
+ for (const task of declaring) {
223
+ const owner = (path) => {
224
+ const others = tasks.filter((t) => t.id !== task.id && owns.get(t.id)(path)).map((t) => t.id);
225
+ return others.length
226
+ ? `owned by ${others.join(", ")}, which is no substitute: add it to ${task.id}.files[]`
227
+ : `owned by no task: add it to ${task.id}.files[]`;
228
+ };
229
+ for (const pin of task.pins) {
230
+ const matches = filesGlob(patternsOf(pin));
231
+ const found = [...new Set(patternsOf(pin).map((p) => literalBase(p.replace(/^\.\//, ""))))].map(walked);
232
+ // a cause refuses only a pin that could match at or beneath it: a cycle under fixtures/sub never refuses fixtures/*.txt
233
+ const affects = (c) => (c.maybeFile && matches(c.at)) || patternsOf(pin).some((p) => reachesBeneath(p, c.at));
234
+ const causes = [...new Set(found.flatMap((w) => w.causes).filter(affects).map((c) => c.why))];
235
+ for (const why of causes)
236
+ errors.push(`${task.id}: ${pinName(pin)} refuses certification: ${why}`);
237
+ const refused = causes.length > 0;
238
+ const surface = [...new Set(found.flatMap((w) => w.paths))].filter(matches).sort();
239
+ if (!surface.length) {
240
+ if (refused)
241
+ continue; // the refusal above already names why nothing was certified
242
+ errors.push(`${task.id}: ${pinName(pin)} refuses certification: empty search surface (the pattern matches no file)`);
243
+ continue;
244
+ }
245
+ for (const path of surface) {
246
+ const owned = owns.get(task.id)(path);
247
+ // both kinds certify a whole-file read first: an owned fixture nobody could read is not evidence
248
+ let content;
249
+ try {
250
+ content = readWhole(repoRoot, path, io);
251
+ }
252
+ catch (error) {
253
+ errors.push(`${task.id}: ${pinName(pin)} refuses certification: ${cause(error)}`);
254
+ continue;
255
+ }
256
+ if (pin.kind === "fixture") {
257
+ if (!owned)
258
+ errors.push(`${task.id}: ${pinName(pin)} obligates ${path}, outside ${task.id}.files[] (${owner(path)})`);
259
+ continue;
260
+ }
261
+ const hits = literalHits(path, content, pin.text);
262
+ if (owned)
263
+ continue;
264
+ for (const hit of hits) {
265
+ errors.push(`${task.id}: ${pinName(pin)} still appears at ${hit.path}:${hit.line}, outside ${task.id}.files[] (${owner(path)})`);
266
+ }
267
+ }
268
+ }
269
+ }
270
+ return errors;
271
+ }
@@ -13,9 +13,11 @@ export declare function classifyHost(env?: NodeJS.ProcessEnv): ClassifiedHost;
13
13
  /**
14
14
  * A config driver of herdr or orca whose host is not the classified one is refused naming the
15
15
  * detected host, the config line and the --driver remedy. Any explicit --driver value bypasses
16
- * this; auto and subprocess are never refused.
16
+ * that; auto and subprocess are never refused. Then, when the RESOLVED driver is orca (config,
17
+ * auto on an Orca host, or an explicit flag), the run root is admitted by one probe before any run
18
+ * directory, journal row or dispatch exists. Subprocess and herdr selections probe nothing.
17
19
  */
18
- export declare function preflightHostDriver(cfg: TickmarkrConfig, driverOverride: string | undefined, host: ClassifiedHost): void;
20
+ export declare function preflightHostDriver(cfg: TickmarkrConfig, driverOverride: string | undefined, host: ClassifiedHost, runRoot: string, env?: NodeJS.ProcessEnv): Promise<void>;
19
21
  /**
20
22
  * Orca authors both markers on every terminal it creates. Requiring the pair avoids treating an
21
23
  * unrelated TERM_PROGRAM value or a copied terminal handle as host identity. This is deliberately