tickmarkr 1.89.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/cli/commands/doctor.d.ts +16 -0
- package/dist/cli/commands/doctor.js +52 -0
- package/dist/cli/commands/init.js +1 -0
- package/dist/cli/commands/verify.d.ts +9 -0
- package/dist/cli/commands/verify.js +177 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/compile/collateral.js +7 -7
- package/dist/compile/common.js +2 -2
- package/dist/compile/native.d.ts +15 -1
- package/dist/compile/native.js +209 -3
- package/dist/config/config.js +2 -2
- package/dist/gates/baseline.d.ts +39 -5
- package/dist/gates/baseline.js +67 -8
- 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/run/daemon.d.ts +2 -0
- package/dist/run/daemon.js +14 -7
- package/dist/run/git.d.ts +3 -0
- package/dist/run/git.js +6 -3
- package/dist/run/merge.d.ts +4 -1
- package/dist/run/merge.js +26 -7
- 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/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +5 -0
- package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +4 -1
- package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +18 -0
package/dist/compile/native.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { basename, dirname } from "node:path";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { filesGlob } from "../graph/files-glob.js";
|
|
4
5
|
import picomatch from "picomatch";
|
|
5
|
-
import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
|
|
6
|
+
import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, renderAcceptanceItem, SHAPES, TIERS, validateGraph, } from "../graph/schema.js";
|
|
6
7
|
import { CompileError, inferShape, sha256 } from "./common.js";
|
|
7
8
|
// OBS-170/OBS-184: `context:` is a promise to the worker, and nothing ever checked it could be kept.
|
|
8
9
|
// Workers run in `git worktree add <baseRef>` (run/git.ts:169-176), which materialises the base
|
|
@@ -71,6 +72,202 @@ const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity
|
|
|
71
72
|
function invalid(task, field, detail) {
|
|
72
73
|
throw new CompileError(`Task ${task} field "${field}" ${detail}`);
|
|
73
74
|
}
|
|
75
|
+
export const AUTHORING_LINT_CODES = [
|
|
76
|
+
"criterion-scope",
|
|
77
|
+
"dependency-closure",
|
|
78
|
+
"dependency-coupling",
|
|
79
|
+
"proxy-metric",
|
|
80
|
+
"external-referent",
|
|
81
|
+
"closed-enumeration",
|
|
82
|
+
"one-behavior",
|
|
83
|
+
"concern-bundle",
|
|
84
|
+
"seam-exists",
|
|
85
|
+
];
|
|
86
|
+
// The authoring frame's observable oracle is the branch-tip test corpus, never the mutable index or
|
|
87
|
+
// an author's untracked checkout. Load it once per repository: one bounded git read replaces a grep
|
|
88
|
+
// subprocess per token and preserves the base-only native compile's zero-Git property (the index is
|
|
89
|
+
// requested only when a criterion actually carries a rendered observable or a test basename).
|
|
90
|
+
const headTestCache = new Map();
|
|
91
|
+
function repositoryRoot(file) {
|
|
92
|
+
let current = resolve(dirname(file));
|
|
93
|
+
for (;;) {
|
|
94
|
+
if (existsSync(join(current, ".git")))
|
|
95
|
+
return current;
|
|
96
|
+
const parent = dirname(current);
|
|
97
|
+
if (parent === current)
|
|
98
|
+
return undefined;
|
|
99
|
+
current = parent;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function testsAtHead(root) {
|
|
103
|
+
const cached = headTestCache.get(root);
|
|
104
|
+
if (cached)
|
|
105
|
+
return cached;
|
|
106
|
+
const grep = spawnSync("git", ["-C", root, "grep", "-I", "-n", "-e", "", "HEAD", "--", "tests/*.test.ts", "tests/**/*.test.ts"], { encoding: "utf8", maxBuffer: 1 << 25 });
|
|
107
|
+
if (grep.status !== 0 || typeof grep.stdout !== "string") {
|
|
108
|
+
headTestCache.set(root, []);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const bodies = new Map();
|
|
112
|
+
for (const line of grep.stdout.split("\n")) {
|
|
113
|
+
const match = line.match(/^HEAD:(tests\/.*\.test\.ts):\d+:(.*)$/);
|
|
114
|
+
if (!match)
|
|
115
|
+
continue;
|
|
116
|
+
const lines = bodies.get(match[1]) ?? [];
|
|
117
|
+
lines.push(match[2]);
|
|
118
|
+
bodies.set(match[1], lines);
|
|
119
|
+
}
|
|
120
|
+
const tests = [...bodies].map(([path, lines]) => ({ path, text: lines.join("\n") }));
|
|
121
|
+
headTestCache.set(root, tests);
|
|
122
|
+
return tests;
|
|
123
|
+
}
|
|
124
|
+
function criterionTexts(task) {
|
|
125
|
+
return task.acceptance.map(renderAcceptanceItem);
|
|
126
|
+
}
|
|
127
|
+
function fixtureId(file) {
|
|
128
|
+
return basename(file).replace(/(?:\.spec)?\.md$/, "");
|
|
129
|
+
}
|
|
130
|
+
function strictNegativeCount(text) {
|
|
131
|
+
return (text.match(/\b(?:never|neither|cannot)\b|\bno\s+[a-z][a-z-]*(?:\s+[a-z][a-z-]*){0,3}\s+(?:may|can|supplies|becomes)\b/gi) ?? []).length;
|
|
132
|
+
}
|
|
133
|
+
function namedCriterionPaths(text, tests) {
|
|
134
|
+
const paths = new Set();
|
|
135
|
+
for (const match of text.matchAll(/(?:^|[\s("'`])((?:src|tests|fixtures|scripts)\/[A-Za-z0-9_@{}*?.,/+-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|txt))/g)) {
|
|
136
|
+
paths.add(match[1]);
|
|
137
|
+
}
|
|
138
|
+
for (const match of text.matchAll(/\b([A-Za-z0-9_.-]+\.test\.ts)\b/g)) {
|
|
139
|
+
for (const test of tests)
|
|
140
|
+
if (basename(test.path) === match[1])
|
|
141
|
+
paths.add(test.path);
|
|
142
|
+
}
|
|
143
|
+
return [...paths];
|
|
144
|
+
}
|
|
145
|
+
function renderedObservables(text) {
|
|
146
|
+
const exact = new Set();
|
|
147
|
+
const denominators = new Set();
|
|
148
|
+
for (const match of text.matchAll(/`([^`\n]{2,120})`/g)) {
|
|
149
|
+
const token = match[1].trim();
|
|
150
|
+
// Plain vocabulary in backticks is documentation, not a concrete rendered value. Digit-bearing,
|
|
151
|
+
// path/punctuation-shaped, snake/camel-case tokens are the branch-tip grep substrate from C1.
|
|
152
|
+
if (/\d|[_./:%-]|[a-z][A-Z]/.test(token))
|
|
153
|
+
exact.add(token);
|
|
154
|
+
}
|
|
155
|
+
for (const match of text.matchAll(/\b(\d+)\s*(?:\/|of)\s*(\d+)\b/gi)) {
|
|
156
|
+
exact.add(`${match[1]}/${match[2]}`);
|
|
157
|
+
denominators.add(match[2]);
|
|
158
|
+
}
|
|
159
|
+
return { exact: [...exact], denominators: [...denominators] };
|
|
160
|
+
}
|
|
161
|
+
function criterionScopeFinding(task, text, criterion, id, tests) {
|
|
162
|
+
if (task.files.length === 0)
|
|
163
|
+
return undefined; // empty files[] is deliberately unrestricted
|
|
164
|
+
const inScope = filesGlob(task.files.map((entry) => entry.replace(/^\.\//, ""))); // Q120s shared matcher
|
|
165
|
+
const named = namedCriterionPaths(text, tests);
|
|
166
|
+
const { exact, denominators } = renderedObservables(text);
|
|
167
|
+
const asserting = tests.filter((test) => exact.some((token) => test.text.includes(token))
|
|
168
|
+
|| denominators.some((denominator) => new RegExp(`(?:^|\\D)\\d+/${denominator}(?:\\D|$)`).test(test.text))).map((test) => test.path);
|
|
169
|
+
const missing = [...new Set([...named, ...asserting])].filter((path) => !inScope(path));
|
|
170
|
+
if (missing.length === 0)
|
|
171
|
+
return undefined;
|
|
172
|
+
return {
|
|
173
|
+
code: "criterion-scope",
|
|
174
|
+
fixtureId: id,
|
|
175
|
+
taskId: task.id,
|
|
176
|
+
criterion,
|
|
177
|
+
detail: `criterion names asserting file${missing.length === 1 ? "" : "s"} outside expanded files[]: ${missing.join(", ")}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function exportedIdentifier(root, files, identifier) {
|
|
181
|
+
const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
182
|
+
const declaration = new RegExp(`\\bexport\\s+(?:(?:declare|default|async)\\s+)*(?:function|class|const|let|var|interface|type)\\s+${escaped}\\b|\\bexport\\s*\\{[^}]*\\b${escaped}\\b`);
|
|
183
|
+
return files.some((file) => {
|
|
184
|
+
if (/[*?{[]/.test(file))
|
|
185
|
+
return false;
|
|
186
|
+
try {
|
|
187
|
+
return declaration.test(readFileSync(join(root, file), "utf8"));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Frame-v2 authoring checks over the compiled native task shape. Criterion-scope is blocking at the
|
|
196
|
+
* compile boundary; the remaining checks are review findings, emitted by compileNative below.
|
|
197
|
+
*/
|
|
198
|
+
export function authoringLintFindings(tasks, file) {
|
|
199
|
+
const id = fixtureId(file);
|
|
200
|
+
const root = repositoryRoot(file);
|
|
201
|
+
let indexedTests;
|
|
202
|
+
const testIndex = () => indexedTests ??= root ? testsAtHead(root) : [];
|
|
203
|
+
const findings = [];
|
|
204
|
+
for (const task of tasks) {
|
|
205
|
+
const criteria = criterionTexts(task);
|
|
206
|
+
for (const [index, text] of criteria.entries()) {
|
|
207
|
+
const needsTestIndex = /`[^`\n]{2,120}`|\b\d+\s*(?:\/|of)\s*\d+\b|\b[A-Za-z0-9_.-]+\.test\.ts\b/.test(text);
|
|
208
|
+
const scope = criterionScopeFinding(task, text, index + 1, id, needsTestIndex ? testIndex() : []);
|
|
209
|
+
if (scope)
|
|
210
|
+
findings.push(scope);
|
|
211
|
+
if (/line[- ]count|physical line|not greater than (?:the|\d)|no larger than|at most \d+ (?:lines|bytes)/i.test(text)) {
|
|
212
|
+
findings.push({ code: "proxy-metric", fixtureId: id, taskId: task.id, criterion: index + 1, detail: "criterion uses a proxy size metric; state the structural intent instead" });
|
|
213
|
+
}
|
|
214
|
+
if (/\bthe (?:audit|ruling|sweep|standby|decision|analysis|handoff)\b|\bper the\b|\bat \.planning\//i.test(text)) {
|
|
215
|
+
findings.push({ code: "external-referent", fixtureId: id, taskId: task.id, criterion: index + 1, detail: "criterion depends on an external governance referent; inline the deciding fact" });
|
|
216
|
+
}
|
|
217
|
+
const separators = text.match(/\band\b|\bwhile\b|\bthen\b|\bplus\b|\bwith\b|,|—/gi) ?? [];
|
|
218
|
+
if (separators.length >= 4) {
|
|
219
|
+
findings.push({ code: "one-behavior", fixtureId: id, taskId: task.id, criterion: index + 1, detail: `${separators.length} independently-failable separators; split unless one leg alone reds this title` });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const prose = [task.goal, ...criteria].join("\n");
|
|
223
|
+
const dependencyText = prose
|
|
224
|
+
.replace(/`[^`]*`/g, "")
|
|
225
|
+
.split("\n")
|
|
226
|
+
.filter((line) => !/\b(?:accepts?|rejects?)\b/i.test(line))
|
|
227
|
+
.join("\n");
|
|
228
|
+
const referenced = [...new Set(dependencyText.match(/\bT\d+\b/g) ?? [])]
|
|
229
|
+
.filter((dep) => dep !== task.id && !task.deps.includes(dep));
|
|
230
|
+
if (referenced.length > 0) {
|
|
231
|
+
findings.push({ code: "dependency-closure", fixtureId: id, taskId: task.id, detail: `references ${referenced.join(", ")} outside deps[]` });
|
|
232
|
+
}
|
|
233
|
+
if (/\bwhose independent [^.\n]{0,80}(?:classification|result|decision|output|evidence)\b|\b(?:from|through|provided by|owned by) (?:a |the )?(?:successor|downstream|later|sibling)\b/i.test(dependencyText)) {
|
|
234
|
+
findings.push({ code: "dependency-coupling", fixtureId: id, taskId: task.id, detail: "de-numbered cross-task coupling needs a dependency or a declared consumes contract" });
|
|
235
|
+
}
|
|
236
|
+
const negatives = strictNegativeCount(prose);
|
|
237
|
+
if (negatives >= 2 && !/enumerat|closed (?:input )?list|closed \w* table|STANDBY/i.test(prose)) {
|
|
238
|
+
findings.push({ code: "closed-enumeration", fixtureId: id, taskId: task.id, detail: `${negatives} strict prohibitions have no closed enumeration pointer` });
|
|
239
|
+
}
|
|
240
|
+
const concerns = [...new Set(task.goal.match(/\bQ\d+\b/g) ?? [])];
|
|
241
|
+
if (concerns.length > 1) {
|
|
242
|
+
findings.push({ code: "concern-bundle", fixtureId: id, taskId: task.id, detail: `goal bundles ${concerns.join(", ")}` });
|
|
243
|
+
}
|
|
244
|
+
if (root && negatives > 0) {
|
|
245
|
+
const monoliths = task.files.filter((entry) => {
|
|
246
|
+
if (!entry.startsWith("src/") || /[*?{[]/.test(entry))
|
|
247
|
+
return false;
|
|
248
|
+
try {
|
|
249
|
+
return readFileSync(join(root, entry), "utf8").split("\n").length > 1500;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
if (monoliths.length > 0) {
|
|
256
|
+
const identifiers = [...prose.matchAll(/`([A-Za-z_$][A-Za-z0-9_$]*)`/g)].map((match) => match[1]);
|
|
257
|
+
const seamExists = identifiers.some((identifier) => exportedIdentifier(root, task.files, identifier));
|
|
258
|
+
const firstCreatesSeam = /\b(?:add|create|extract|introduce|ship)\b[^.\n]{0,100}\b(?:injectable|seam|clock|snapshot|evidence)\b/i.test(criteria[0] ?? "");
|
|
259
|
+
if (!seamExists && !firstCreatesSeam) {
|
|
260
|
+
findings.push({ code: "seam-exists", fixtureId: id, taskId: task.id, detail: `law-shaped task touches ${monoliths.join(", ")} but names no exported seam and does not create one first` });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return findings;
|
|
266
|
+
}
|
|
267
|
+
function renderAuthoringFinding(finding) {
|
|
268
|
+
const criterion = finding.criterion === undefined ? "" : ` criterion ${finding.criterion}`;
|
|
269
|
+
return `tickmarkr: authoring-lint[${finding.code}] fixture ${finding.fixtureId} task ${finding.taskId}${criterion}: ${finding.detail}`;
|
|
270
|
+
}
|
|
74
271
|
export function compileNative(file) {
|
|
75
272
|
if (!existsSync(file))
|
|
76
273
|
throw new CompileError(`no such native spec file: ${file}`);
|
|
@@ -328,7 +525,7 @@ export function compileNative(file) {
|
|
|
328
525
|
return [probe, ...variants];
|
|
329
526
|
};
|
|
330
527
|
const canHostTest = (entry) => {
|
|
331
|
-
const self =
|
|
528
|
+
const self = filesGlob(entry); // Q120s shared matcher — paren dirs stay literal in the self-probe
|
|
332
529
|
return expandBraces(entry)
|
|
333
530
|
.flatMap((branch) => PROBE_TOKENS.map((token) => branch.replace(/\*+/g, token)))
|
|
334
531
|
.flatMap(qmarkVariants)
|
|
@@ -400,6 +597,15 @@ export function compileNative(file) {
|
|
|
400
597
|
},
|
|
401
598
|
tasks,
|
|
402
599
|
});
|
|
600
|
+
const authoringFindings = authoringLintFindings(result.tasks, file);
|
|
601
|
+
for (const finding of authoringFindings.filter(({ code }) => code !== "criterion-scope")) {
|
|
602
|
+
console.warn(renderAuthoringFinding(finding));
|
|
603
|
+
}
|
|
604
|
+
const scopeErrors = authoringFindings.filter(({ code }) => code === "criterion-scope");
|
|
605
|
+
if (scopeErrors.length > 0) {
|
|
606
|
+
throw new CompileError(`${file} violates the criterion-scope authoring lint (${scopeErrors.length} error${scopeErrors.length === 1 ? "" : "s"}):\n`
|
|
607
|
+
+ scopeErrors.map((finding) => ` - ${renderAuthoringFinding(finding)}`).join("\n"));
|
|
608
|
+
}
|
|
403
609
|
// v1.19 read-old/write-new: a plain-string acceptance item compiles as a judge oracle. This is the
|
|
404
610
|
// one-time nudge toward typed oracles (command/test/judge); PRD/Spec Kit/GSD stay silent (untouched).
|
|
405
611
|
if (plainCount > 0) {
|
package/dist/config/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import
|
|
4
|
+
import { filesGlob } from "../graph/files-glob.js";
|
|
5
5
|
import { Document, parse } from "yaml";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { CITED_MODEL_WINDOWS } from "../adapters/model-windows.js";
|
|
@@ -223,7 +223,7 @@ export function criticalPathHits(files, criticalPaths = []) {
|
|
|
223
223
|
const critical = normPath(rawCritical);
|
|
224
224
|
if (!critical)
|
|
225
225
|
continue;
|
|
226
|
-
if (f === critical ||
|
|
226
|
+
if (f === critical || filesGlob(critical)(f) || filesGlob(f)(critical) // Q120s shared matcher
|
|
227
227
|
|| (GLOB_META_RE.test(f) && GLOB_META_RE.test(critical) && globsMayIntersect(f, critical))) {
|
|
228
228
|
hits.add(f);
|
|
229
229
|
}
|
package/dist/gates/baseline.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { TickmarkrConfig } from "../config/config.js";
|
|
2
2
|
import type { AcceptanceItem } from "../graph/schema.js";
|
|
3
|
+
import { type ShResult } from "../run/git.js";
|
|
3
4
|
import type { GateResult } from "./types.js";
|
|
5
|
+
export interface BaselineCommand {
|
|
6
|
+
exitCode: number;
|
|
7
|
+
fingerprints: string[];
|
|
8
|
+
missingCommand?: boolean;
|
|
9
|
+
/** What this command actually took at capture, on a pristine tree. Absent in pre-v1.90 baselines. */
|
|
10
|
+
durationMs?: number;
|
|
11
|
+
/** The ceiling that measurement implies, persisted so every later battery uses the same number. */
|
|
12
|
+
ceilingMs?: number;
|
|
13
|
+
}
|
|
4
14
|
export interface Baseline {
|
|
5
|
-
commands: Record<string,
|
|
6
|
-
exitCode: number;
|
|
7
|
-
fingerprints: string[];
|
|
8
|
-
missingCommand?: boolean;
|
|
9
|
-
}>;
|
|
15
|
+
commands: Record<string, BaselineCommand>;
|
|
10
16
|
warnings?: BaselineWarning[];
|
|
11
17
|
}
|
|
12
18
|
export interface BaselineWarning {
|
|
@@ -22,7 +28,35 @@ export type FailureClassification = "regression" | "infra";
|
|
|
22
28
|
export declare function classifyFailureOutput(output: string): FailureClassification | undefined;
|
|
23
29
|
export declare const UNRECOGNIZED_FAILURE = "<unrecognized failure output>";
|
|
24
30
|
export declare function fingerprint(output: string): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Q121s: the battery's forgiveness math, exported so tip-verify applies the IDENTICAL rule.
|
|
33
|
+
* Returns the failure fingerprints of `raw` that are NOT in the baseline entry (fresh), and
|
|
34
|
+
* whether the output carried no recognizable failure shape at all.
|
|
35
|
+
*/
|
|
36
|
+
export declare function freshFailures(entry: BaselineCommand | undefined, raw: string): {
|
|
37
|
+
failing: string[];
|
|
38
|
+
unreadable: boolean;
|
|
39
|
+
};
|
|
25
40
|
export declare function detectGateCommands(repoRoot: string, cfg: TickmarkrConfig): Record<string, string>;
|
|
41
|
+
/**
|
|
42
|
+
* The ceiling a battery runs under: whichever is larger of the shipped constant and the slack applied
|
|
43
|
+
* to what this command actually measured. A pre-v1.90 baseline (no measurement) gets exactly the
|
|
44
|
+
* shipped constant, so nothing regresses on an existing on-disk baseline.json — the same read-old
|
|
45
|
+
* compat rule the fingerprint renormalizer follows.
|
|
46
|
+
*/
|
|
47
|
+
export declare const effectiveCeilingMs: (entry?: Pick<BaselineCommand, "durationMs" | "ceilingMs">) => number;
|
|
48
|
+
/**
|
|
49
|
+
* The ONLY producer of a `ceiling-kill` gate result. It is gated on `timedOut`, which git.ts sets in
|
|
50
|
+
* exactly one place — inside the timer callback that issues the SIGKILL — so a process that exited on
|
|
51
|
+
* its own can never reach this text however it exited: a slow red suite, an unreadable runner, exit
|
|
52
|
+
* 137 from a kill somebody ELSE sent. Returning `undefined` rather than a result keeps that structural:
|
|
53
|
+
* every caller must go on to the ordinary verdict path, it cannot fall through into this one.
|
|
54
|
+
*
|
|
55
|
+
* A kill is not a verdict. The battery was still running when the machine took it away, so its exit
|
|
56
|
+
* code is evidence about the ceiling and nothing about the work — which is why this reports `infra`
|
|
57
|
+
* and never enters baseline forgiveness (there is no runner output to forgive).
|
|
58
|
+
*/
|
|
59
|
+
export declare function ceilingKillResult(gate: string, r: ShResult, ceilingMs: number): GateResult | undefined;
|
|
26
60
|
export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;
|
|
27
61
|
export interface VacuousOracleWarning {
|
|
28
62
|
kind: "vacuous-oracle";
|
package/dist/gates/baseline.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { sh } from "../run/git.js";
|
|
3
|
+
import { DEFAULT_SHELL_TIMEOUT_MS, sh } from "../run/git.js";
|
|
4
4
|
// incident #2 (run-20260709-104447): a vitest ✓ PASS line with "error" in the test NAME, wrapped in ANSI
|
|
5
5
|
// codes that varied between baseline and worktree runs, was reported as a "new failure". Strip ANSI first;
|
|
6
6
|
// a pass-marker line is never a failure. [\d;#] covers raw ANSI and digit-normalized ANSI ("\x1b[#m") from
|
|
@@ -111,6 +111,18 @@ const unrecognizedEvidence = (raw) => raw
|
|
|
111
111
|
// stored baselines may predate ANSI/pass-marker hardening — renormalize at compare time so existing
|
|
112
112
|
// on-disk baseline.json files stay comparable without recapture (compat invariant, CLAUDE.md)
|
|
113
113
|
const renormalize = (fp) => normalizeLine(fp.replace(ANSI_RE, ""));
|
|
114
|
+
/**
|
|
115
|
+
* Q121s: the battery's forgiveness math, exported so tip-verify applies the IDENTICAL rule.
|
|
116
|
+
* Returns the failure fingerprints of `raw` that are NOT in the baseline entry (fresh), and
|
|
117
|
+
* whether the output carried no recognizable failure shape at all.
|
|
118
|
+
*/
|
|
119
|
+
export function freshFailures(entry, raw) {
|
|
120
|
+
const known = new Set((entry?.fingerprints ?? []).map(renormalize));
|
|
121
|
+
// OBS-42: diagnostic headings enrich fingerprints but cannot invalidate legacy baselines.
|
|
122
|
+
const current = fingerprint(raw);
|
|
123
|
+
const fresh = current.filter((f) => !known.has(f) && (!FAIL_ANCHOR_RE.test(f) || f.startsWith("FAIL ")));
|
|
124
|
+
return { failing: fresh.filter((f) => f !== UNRECOGNIZED_FAILURE), unreadable: current.includes(UNRECOGNIZED_FAILURE) };
|
|
125
|
+
}
|
|
114
126
|
export function detectGateCommands(repoRoot, cfg) {
|
|
115
127
|
const out = {};
|
|
116
128
|
const pkgPath = join(repoRoot, "package.json");
|
|
@@ -145,17 +157,59 @@ function missingConfiguredCommand(cmd, result) {
|
|
|
145
157
|
const output = `${result.stdout}\n${result.stderr}`;
|
|
146
158
|
return new RegExp(`(?:^|[:\\s])${reEscape(token)}:\\s+(?:command not found|No such file or directory)`, "i").test(output);
|
|
147
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* How much longer than the baseline a battery may legitimately take. Gate batteries run inside a
|
|
162
|
+
* worktree beside up to `concurrency` siblings, so the same suite is genuinely slower than its
|
|
163
|
+
* pristine-tree capture — 3× is the headroom, not a performance budget.
|
|
164
|
+
*/
|
|
165
|
+
const CEILING_SLACK = 3;
|
|
166
|
+
/**
|
|
167
|
+
* The ceiling a battery runs under: whichever is larger of the shipped constant and the slack applied
|
|
168
|
+
* to what this command actually measured. A pre-v1.90 baseline (no measurement) gets exactly the
|
|
169
|
+
* shipped constant, so nothing regresses on an existing on-disk baseline.json — the same read-old
|
|
170
|
+
* compat rule the fingerprint renormalizer follows.
|
|
171
|
+
*/
|
|
172
|
+
export const effectiveCeilingMs = (entry) => entry?.ceilingMs ?? Math.max(DEFAULT_SHELL_TIMEOUT_MS, CEILING_SLACK * (entry?.durationMs ?? 0));
|
|
173
|
+
/**
|
|
174
|
+
* The ONLY producer of a `ceiling-kill` gate result. It is gated on `timedOut`, which git.ts sets in
|
|
175
|
+
* exactly one place — inside the timer callback that issues the SIGKILL — so a process that exited on
|
|
176
|
+
* its own can never reach this text however it exited: a slow red suite, an unreadable runner, exit
|
|
177
|
+
* 137 from a kill somebody ELSE sent. Returning `undefined` rather than a result keeps that structural:
|
|
178
|
+
* every caller must go on to the ordinary verdict path, it cannot fall through into this one.
|
|
179
|
+
*
|
|
180
|
+
* A kill is not a verdict. The battery was still running when the machine took it away, so its exit
|
|
181
|
+
* code is evidence about the ceiling and nothing about the work — which is why this reports `infra`
|
|
182
|
+
* and never enters baseline forgiveness (there is no runner output to forgive).
|
|
183
|
+
*/
|
|
184
|
+
export function ceilingKillResult(gate, r, ceilingMs) {
|
|
185
|
+
if (r.timedOut !== true)
|
|
186
|
+
return undefined;
|
|
187
|
+
// no measurement only when a caller synthesized the result; a real kill fires AT the ceiling
|
|
188
|
+
const durationMs = r.durationMs ?? ceilingMs;
|
|
189
|
+
return {
|
|
190
|
+
gate,
|
|
191
|
+
pass: false,
|
|
192
|
+
details: `ceiling-kill: SIGKILLed after ${durationMs}ms at the configured ${ceilingMs}ms ceiling — `
|
|
193
|
+
+ `the battery never returned a verdict, so this gate verified nothing (exit ${r.code} is the kill, not a test result)`,
|
|
194
|
+
meta: { classification: "infra", infra: true, kind: "ceiling-kill", durationMs, ceilingMs },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
148
197
|
export async function captureBaseline(cwd, commands) {
|
|
149
198
|
const base = { commands: {} };
|
|
150
199
|
for (const [name, cmd] of Object.entries(commands)) {
|
|
151
200
|
const r = await sh(cmd, cwd);
|
|
152
201
|
// ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
|
|
202
|
+
// ponytail: a capture that was itself killed records the ceiling as its "measurement", which
|
|
203
|
+
// scales the next ceiling up — the right direction for a suite that never finished once.
|
|
204
|
+
const durationMs = r.durationMs ?? 0;
|
|
153
205
|
base.commands[name] = {
|
|
154
206
|
exitCode: r.code,
|
|
155
207
|
// a command that exits 0 has no failures to fingerprint — recording any would be a lie the
|
|
156
208
|
// compare step then has to forgive
|
|
157
209
|
fingerprints: r.code === 0 ? [] : fingerprint((r.stdout + "\n" + r.stderr).split(cwd).join("")),
|
|
158
210
|
missingCommand: missingConfiguredCommand(cmd, r),
|
|
211
|
+
durationMs,
|
|
212
|
+
ceilingMs: effectiveCeilingMs({ durationMs }),
|
|
159
213
|
};
|
|
160
214
|
}
|
|
161
215
|
const names = Object.keys(commands);
|
|
@@ -223,7 +277,17 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
|
|
|
223
277
|
results.push({ gate: name, pass: true, details: `no ${name} command detected — skipped`, meta: { skipped: true } });
|
|
224
278
|
continue;
|
|
225
279
|
}
|
|
226
|
-
const
|
|
280
|
+
const ceilingMs = effectiveCeilingMs(baseline.commands[name]);
|
|
281
|
+
const r = await sh(cmd, cwd, ceilingMs);
|
|
282
|
+
// Q24: the kill is read BEFORE the exit code is interpreted at all. A SIGKILLed battery has
|
|
283
|
+
// whatever partial output it had flushed — typically no failure shape — so every path below
|
|
284
|
+
// would otherwise turn a timeout into a claim about the work: "no recognizable failure lines"
|
|
285
|
+
// when the baseline was green, or a forgiven pre-existing red when it was not. Neither is true.
|
|
286
|
+
const killed = ceilingKillResult(name, r, ceilingMs);
|
|
287
|
+
if (killed) {
|
|
288
|
+
results.push(killed);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
227
291
|
if (r.code === 0) {
|
|
228
292
|
results.push({ gate: name, pass: true, details: "exit 0" });
|
|
229
293
|
continue;
|
|
@@ -244,10 +308,6 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
|
|
|
244
308
|
});
|
|
245
309
|
continue;
|
|
246
310
|
}
|
|
247
|
-
const known = new Set((baseline.commands[name]?.fingerprints ?? []).map(renormalize));
|
|
248
|
-
// OBS-42: diagnostic headings enrich fingerprints but cannot invalidate legacy baselines.
|
|
249
|
-
const current = fingerprint(raw);
|
|
250
|
-
const fresh = current.filter((f) => !known.has(f) && (!FAIL_ANCHOR_RE.test(f) || f.startsWith("FAIL ")));
|
|
251
311
|
// OBS-278: only a failure SHAPE is a verdict — everything fingerprint() keeps is one, except the
|
|
252
312
|
// unrecognized-output marker, which is evidence for the operator and never grounds to reject.
|
|
253
313
|
// ponytail: ceiling — a runner whose failure output holds no shape above and whose baseline is
|
|
@@ -255,8 +315,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
|
|
|
255
315
|
// below rather than reading as a verified green. Raise the ceiling by teaching isFailureShaped
|
|
256
316
|
// that runner's position rule (leading verdict + identifier, or identifier + separator + trailing
|
|
257
317
|
// verdict); loosening back to vocabulary re-opens OBS-278.
|
|
258
|
-
const unreadable =
|
|
259
|
-
const failing = fresh.filter((f) => f !== UNRECOGNIZED_FAILURE);
|
|
318
|
+
const { failing, unreadable } = freshFailures(baseline.commands[name], raw);
|
|
260
319
|
if (!failing.length && (baseline.commands[name]?.exitCode ?? 1) === 0) {
|
|
261
320
|
const closed = `command was green at baseline but now exits ${r.code} with no recognizable failure lines — failing closed`;
|
|
262
321
|
const evidence = unrecognizedEvidence(raw);
|
package/dist/gates/scope.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { filesGlob } from "../graph/files-glob.js";
|
|
2
2
|
import { shGitOk } from "../run/git.js";
|
|
3
3
|
// OBS-61: the live integration tip can advance between attempts (sibling merge) while a resumed
|
|
4
4
|
// worktree stays parented on the old base — merge-base(tip, HEAD) pins the diff to the worktree's
|
|
@@ -11,7 +11,7 @@ export async function scopeDiffBase(worktree, integrationTip) {
|
|
|
11
11
|
}
|
|
12
12
|
/** Pure offender split — corpus-replay oracle exercises this exact decision logic. */
|
|
13
13
|
export function dispositionOffenders(offenders, allowDeviations) {
|
|
14
|
-
const allowedMatch = allowDeviations.length ?
|
|
14
|
+
const allowedMatch = allowDeviations.length ? filesGlob(allowDeviations) : () => false;
|
|
15
15
|
const allowed = [];
|
|
16
16
|
const hard = [];
|
|
17
17
|
for (const f of offenders) {
|
|
@@ -27,7 +27,7 @@ export async function scopeGate(worktree, integrationTip, files, result, allowDe
|
|
|
27
27
|
return { gate: "scope", pass: true, details: "no file scope declared — unrestricted" };
|
|
28
28
|
const baseRef = await scopeDiffBase(worktree, integrationTip);
|
|
29
29
|
const changed = (await shGitOk(`git diff --name-only '${baseRef}..HEAD'`, worktree)).trim().split("\n").filter(Boolean);
|
|
30
|
-
const inScope =
|
|
30
|
+
const inScope = filesGlob(files); // the ONE files[] matcher — src/graph/files-glob.ts (Q120s)
|
|
31
31
|
const offenders = changed.filter((f) => !inScope(f));
|
|
32
32
|
if (!offenders.length)
|
|
33
33
|
return { gate: "scope", pass: true, details: `all ${changed.length} changed files in scope` };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE matcher for files[] / scope.allowDeviations semantics (Q120s, TRIAL T-OBS-4).
|
|
3
|
+
*
|
|
4
|
+
* Parens in a files[] entry are LITERAL path characters. Expo Router and Next.js name
|
|
5
|
+
* group directories `(app)`, `(marketing)` — but picomatch compiles a bare `(app)` to a
|
|
6
|
+
* regex CAPTURE GROUP matching `app`, so a pattern naming such a path can never match it
|
|
7
|
+
* (SentioQ run-1: T3 burned 3 dispatches on a scope red unwinnable by construction).
|
|
8
|
+
* Brackets need no help: this picomatch already compiles `[id]` to the alternation
|
|
9
|
+
* `(?:\[id\]|[id])`, literal-or-class.
|
|
10
|
+
*
|
|
11
|
+
* The price is extglobs (`@(a|b)` etc.) become literal text in files[] entries — they
|
|
12
|
+
* have no recorded use in any spec, and a scope allowlist wants boring, predictable
|
|
13
|
+
* matching over regex power. Every consumer of files[]-shaped patterns MUST match
|
|
14
|
+
* through this module; a second picomatch call with hand-rolled options is how the
|
|
15
|
+
* gate and the compiler drift apart.
|
|
16
|
+
*/
|
|
17
|
+
export declare const literalParens: (pattern: string) => string;
|
|
18
|
+
export declare function filesGlob(patterns: string | string[]): (path: string) => boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import picomatch from "picomatch";
|
|
2
|
+
/**
|
|
3
|
+
* The ONE matcher for files[] / scope.allowDeviations semantics (Q120s, TRIAL T-OBS-4).
|
|
4
|
+
*
|
|
5
|
+
* Parens in a files[] entry are LITERAL path characters. Expo Router and Next.js name
|
|
6
|
+
* group directories `(app)`, `(marketing)` — but picomatch compiles a bare `(app)` to a
|
|
7
|
+
* regex CAPTURE GROUP matching `app`, so a pattern naming such a path can never match it
|
|
8
|
+
* (SentioQ run-1: T3 burned 3 dispatches on a scope red unwinnable by construction).
|
|
9
|
+
* Brackets need no help: this picomatch already compiles `[id]` to the alternation
|
|
10
|
+
* `(?:\[id\]|[id])`, literal-or-class.
|
|
11
|
+
*
|
|
12
|
+
* The price is extglobs (`@(a|b)` etc.) become literal text in files[] entries — they
|
|
13
|
+
* have no recorded use in any spec, and a scope allowlist wants boring, predictable
|
|
14
|
+
* matching over regex power. Every consumer of files[]-shaped patterns MUST match
|
|
15
|
+
* through this module; a second picomatch call with hand-rolled options is how the
|
|
16
|
+
* gate and the compiler drift apart.
|
|
17
|
+
*/
|
|
18
|
+
export const literalParens = (pattern) => pattern.replace(/\\?[()]/g, (m) => (m.length === 2 ? m : `\\${m}`));
|
|
19
|
+
export function filesGlob(patterns) {
|
|
20
|
+
const list = (Array.isArray(patterns) ? patterns : [patterns]).map(literalParens);
|
|
21
|
+
return picomatch(list, { dot: true });
|
|
22
|
+
}
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type WorkerAdapter } from "../adapters/types.js";
|
|
2
2
|
import { type ModeResolution, type RoutingMode, type TickmarkrConfig } from "../config/config.js";
|
|
3
3
|
import { type ExecutorDriver } from "../drivers/types.js";
|
|
4
|
+
import { type Baseline } from "../gates/baseline.js";
|
|
4
5
|
import type { GateResult } from "../gates/types.js";
|
|
5
6
|
import { Journal, type JournalEvent } from "./journal.js";
|
|
6
7
|
export interface RunOptions {
|
|
@@ -131,6 +132,7 @@ export declare function commandsHash(commands: Record<string, string>): string;
|
|
|
131
132
|
*/
|
|
132
133
|
export declare function verifyIntegrationTipCached(intWt: string, commands: Record<string, string>, journal: Journal, opts?: {
|
|
133
134
|
lastMergedTask?: string;
|
|
135
|
+
baseline?: Baseline;
|
|
134
136
|
}): Promise<boolean>;
|
|
135
137
|
export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
|
|
136
138
|
ms: number;
|
package/dist/run/daemon.js
CHANGED
|
@@ -414,7 +414,7 @@ function lastVerifyCycle(events) {
|
|
|
414
414
|
if (e.event === "tip-verify-start") {
|
|
415
415
|
const { tip, cmdHash } = e.data;
|
|
416
416
|
cur = typeof tip === "string" && typeof cmdHash === "string"
|
|
417
|
-
? { tip, cmdHash, gates: new Set(), failed: false }
|
|
417
|
+
? { tip, cmdHash, gates: new Set(), failed: false, forgiven: false }
|
|
418
418
|
: undefined;
|
|
419
419
|
afterRunEnd = false;
|
|
420
420
|
continue;
|
|
@@ -432,13 +432,19 @@ function lastVerifyCycle(events) {
|
|
|
432
432
|
continue;
|
|
433
433
|
}
|
|
434
434
|
if (!cur || afterRunEnd || cur.tip !== tip || cur.cmdHash !== cmdHash) {
|
|
435
|
-
cur = { tip, cmdHash, gates: new Set(), failed: false };
|
|
435
|
+
cur = { tip, cmdHash, gates: new Set(), failed: false, forgiven: false };
|
|
436
436
|
}
|
|
437
437
|
afterRunEnd = false;
|
|
438
438
|
if (e.event === "tip-verify-failed")
|
|
439
439
|
cur.failed = true;
|
|
440
|
-
else
|
|
440
|
+
else {
|
|
441
441
|
cur.gates.add(gate);
|
|
442
|
+
// Q121s review M1: a forgiven green depends on the baseline that forgave it. Baselines are not
|
|
443
|
+
// part of the cache key, so a forgiven cycle is NEVER cache-eligible — an unchanged tip whose
|
|
444
|
+
// baseline later vanishes or changes must re-run the full verify, not inherit the green.
|
|
445
|
+
if (e.data.forgiven === true)
|
|
446
|
+
cur.forgiven = true;
|
|
447
|
+
}
|
|
442
448
|
}
|
|
443
449
|
return cur;
|
|
444
450
|
}
|
|
@@ -459,7 +465,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
|
|
|
459
465
|
const porcelain = await shGit("git status --porcelain", intWt);
|
|
460
466
|
const clean = porcelain.code === 0 && porcelain.stdout.trim() === "";
|
|
461
467
|
const last = lastVerifyCycle(journal.read());
|
|
462
|
-
const cached = last !== undefined && !last.failed && last.tip === tip && last.cmdHash === cmdHash
|
|
468
|
+
const cached = last !== undefined && !last.failed && !last.forgiven && last.tip === tip && last.cmdHash === cmdHash
|
|
463
469
|
&& Object.keys(commands).every((g) => last.gates.has(g));
|
|
464
470
|
// A pair can be verified red and then green without either SHA or command hash changing (for
|
|
465
471
|
// example, an external service or ignored fixture recovers). Delimit attempts explicitly so that
|
|
@@ -477,9 +483,10 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
|
|
|
477
483
|
return false;
|
|
478
484
|
}
|
|
479
485
|
let tipFailed = false;
|
|
480
|
-
for (const r of await verifyIntegrationTip(intWt, commands, journal.dir)) {
|
|
486
|
+
for (const r of await verifyIntegrationTip(intWt, commands, journal.dir, opts.baseline)) {
|
|
481
487
|
if (r.pass) {
|
|
482
|
-
|
|
488
|
+
// Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
|
|
489
|
+
journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash });
|
|
483
490
|
}
|
|
484
491
|
else {
|
|
485
492
|
journal.append("tip-verify-failed", undefined, {
|
|
@@ -3095,7 +3102,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3095
3102
|
// OBS-34: post-merge integration-tip verify — strict exit codes, no baseline forgiveness.
|
|
3096
3103
|
const lastMergedTask = [...journal.read()].reverse().find((e) => e.event === "merge" && e.taskId)?.taskId;
|
|
3097
3104
|
if (summary.done.length > 0 && Object.keys(commands).length > 0) {
|
|
3098
|
-
const tipFailed = await verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask });
|
|
3105
|
+
const tipFailed = await verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline });
|
|
3099
3106
|
summary.tipVerify = tipFailed ? "failed" : "passed";
|
|
3100
3107
|
if (tipFailed && lastMergedTask)
|
|
3101
3108
|
summary.lastMergedTask = lastMergedTask;
|
package/dist/run/git.d.ts
CHANGED
|
@@ -14,11 +14,14 @@ export declare const deriveForkCap: (concurrency: number, cores?: number) => num
|
|
|
14
14
|
export declare const runWithForkBudget: <T>(concurrency: number, fn: () => Promise<T>) => Promise<T>;
|
|
15
15
|
/** The cap owned by the run on this async context; the standalone default outside one. */
|
|
16
16
|
export declare const resolvedForkCap: () => string;
|
|
17
|
+
/** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
|
|
18
|
+
export declare const DEFAULT_SHELL_TIMEOUT_MS = 600000;
|
|
17
19
|
export interface ShResult {
|
|
18
20
|
code: number;
|
|
19
21
|
stdout: string;
|
|
20
22
|
stderr: string;
|
|
21
23
|
timedOut?: boolean;
|
|
24
|
+
durationMs?: number;
|
|
22
25
|
}
|
|
23
26
|
export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
|
|
24
27
|
export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
|
package/dist/run/git.js
CHANGED
|
@@ -43,6 +43,8 @@ export const deriveForkCap = (concurrency, cores = availableParallelism()) => Ma
|
|
|
43
43
|
export const runWithForkBudget = (concurrency, fn) => forkBudget.run(String(deriveForkCap(concurrency)), fn);
|
|
44
44
|
/** The cap owned by the run on this async context; the standalone default outside one. */
|
|
45
45
|
export const resolvedForkCap = () => forkBudget.getStore() ?? DEFAULT_FORK_CAP;
|
|
46
|
+
/** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
|
|
47
|
+
export const DEFAULT_SHELL_TIMEOUT_MS = 600000;
|
|
46
48
|
// stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
|
|
47
49
|
// (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
|
|
48
50
|
function shell(cmd, cwd, timeoutMs, login) {
|
|
@@ -57,6 +59,7 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
57
59
|
if (!(FORK_CAP_ENV in env))
|
|
58
60
|
env[FORK_CAP_ENV] = resolvedForkCap();
|
|
59
61
|
return new Promise((resolve) => {
|
|
62
|
+
const startedAt = Date.now();
|
|
60
63
|
// detached: bash gets its own process group so a timeout can kill the whole tree —
|
|
61
64
|
// SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
|
|
62
65
|
// open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
|
|
@@ -68,7 +71,7 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
68
71
|
return;
|
|
69
72
|
done = true;
|
|
70
73
|
clearTimeout(timer);
|
|
71
|
-
resolve({ code, stdout, stderr: err ?? stderr, timedOut });
|
|
74
|
+
resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt });
|
|
72
75
|
};
|
|
73
76
|
const timer = setTimeout(() => {
|
|
74
77
|
timedOut = true;
|
|
@@ -88,11 +91,11 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
88
91
|
finish(code ?? 1); });
|
|
89
92
|
});
|
|
90
93
|
}
|
|
91
|
-
export function sh(cmd, cwd, timeoutMs =
|
|
94
|
+
export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
|
|
92
95
|
return shell(cmd, cwd, timeoutMs, true);
|
|
93
96
|
}
|
|
94
97
|
// Git plumbing never needs an operator profile; skip login-shell startup and its side effects.
|
|
95
|
-
export function shGit(cmd, cwd, timeoutMs =
|
|
98
|
+
export function shGit(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
|
|
96
99
|
return shell(cmd, cwd, timeoutMs, false);
|
|
97
100
|
}
|
|
98
101
|
export async function shOk(cmd, cwd) {
|