do-better 1.0.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/src/rail.js ADDED
@@ -0,0 +1,533 @@
1
+ // src/rail.js — D4: preflight env check, characterization rails for Phase-Now
2
+ // behaviors, hollow-test audit gate, rails/manifest.md. See SPEC §2 (D4/D7),
3
+ // blueprint §7 D4.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import {
7
+ OpError, gateError, gitHeadSha, git, readJsonSafe, readPackageFile, writeFileAtomic, sha256Hex,
8
+ } from "./utils.js";
9
+ import { recordPhase, addSpend, setGate, pinSha, recordRoadmapHash } from "./state.js";
10
+ import {
11
+ LAYOUT, ensureLayout, readArtifact, writeArtifact, parseCitations,
12
+ readTickets, writeTickets, validateTicket,
13
+ } from "./artifacts.js";
14
+ import { withFallback } from "./llm.js";
15
+ import { runPreflight, runHollowTest, runColdstart } from "./adlc.js";
16
+
17
+ export const PHASE_ID = "rail";
18
+ const FIX_ROUNDS = 2;
19
+ const SPOT_CHECK_COUNT = 3;
20
+ const RUNNABILITY_TITLE = "Make the environment runnable";
21
+ const HANDOFF_LINE = "Each ticket in .dobetter/backlog/ is ready for ADLC P3/P4 intake.";
22
+
23
+ // ---------- pure helpers ----------
24
+
25
+ function globToRegExp(glob) {
26
+ let re = "";
27
+ for (let i = 0; i < glob.length; i++) {
28
+ const c = glob[i];
29
+ if (c === "*") {
30
+ if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++; }
31
+ else re += "[^/]*";
32
+ } else if (c === "?") re += "[^/]";
33
+ else re += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
34
+ }
35
+ return new RegExp(`^${re}$`);
36
+ }
37
+
38
+ export function globMatch(pattern, file) {
39
+ if (globToRegExp(pattern).test(file)) return true;
40
+ if (!pattern.includes("/")) return globToRegExp(pattern).test(file.split("/").pop());
41
+ return false;
42
+ }
43
+
44
+ export function parseBehaviorInventory(body) {
45
+ const out = [];
46
+ for (const line of String(body ?? "").split("\n")) {
47
+ if (!/^\s*[-*]\s/.test(line)) continue;
48
+ const m = line.match(/\b(B-\d{3,})\b/);
49
+ if (!m) continue;
50
+ const citations = parseCitations(line);
51
+ out.push({
52
+ id: m[1],
53
+ summary: line.replace(/^\s*[-*]\s*/, "").trim(),
54
+ entry: citations[0] ?? null,
55
+ files: [...new Set(citations.map((c) => c.file))],
56
+ });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export function mapBehaviorsToTickets(behaviors, tickets) {
62
+ return behaviors.map((b) => {
63
+ const files = b.files ?? (b.entry ? [b.entry.file] : b.file ? [b.file] : []);
64
+ const ticketIds = tickets
65
+ .filter((t) => (t.scope ?? []).some((g) => files.some((f) => globMatch(g, f))))
66
+ .map((t) => t.id);
67
+ return { behaviorId: b.id, ticketIds };
68
+ });
69
+ }
70
+
71
+ export function basicEnvProbe(root, exec) {
72
+ const checks = [];
73
+ const tryExec = (cmd, args, opts) => {
74
+ try { return exec(cmd, args, opts); } catch { return { status: 1, stdout: "", stderr: "exec failed" }; }
75
+ };
76
+ const gitVersion = tryExec("git", ["--version"], {});
77
+ checks.push({ name: "git-present", status: gitVersion.status === 0 ? "pass" : "fail", detail: gitVersion.stdout.trim() || gitVersion.stderr.trim() });
78
+ const inRepo = tryExec("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root });
79
+ checks.push({ name: "git-repo", status: inRepo.status === 0 ? "pass" : "fail", detail: root });
80
+ const porcelain = tryExec("git", ["status", "--porcelain"], { cwd: root });
81
+ const clean = porcelain.status === 0 && porcelain.stdout.trim() === "";
82
+ checks.push({ name: "repo-clean", status: clean ? "pass" : "warn", detail: clean ? "" : "uncommitted changes present" });
83
+ let writable = true; let detail = "";
84
+ try {
85
+ const tmpDir = path.join(root, ".dobetter", "tmp");
86
+ fs.mkdirSync(tmpDir, { recursive: true });
87
+ const probe = path.join(tmpDir, `probe-${process.pid}`);
88
+ fs.writeFileSync(probe, "ok");
89
+ fs.rmSync(probe);
90
+ } catch (err) { writable = false; detail = err.message; }
91
+ checks.push({ name: "write-test", status: writable ? "pass" : "fail", detail });
92
+ return { checks, verdict: checks.some((c) => c.status === "fail") ? "fail" : "pass" };
93
+ }
94
+
95
+ function detectTestCmd(root) {
96
+ const pkg = readJsonSafe(path.join(root, "package.json"));
97
+ return pkg?.scripts?.test ? "npm test" : "node --test";
98
+ }
99
+
100
+ function detectRailsDir(root) {
101
+ for (const d of ["test", "tests", "spec"]) {
102
+ if (fs.existsSync(path.join(root, d))) return path.posix.join(d, "dobetter-rails");
103
+ }
104
+ return path.posix.join("test", "dobetter-rails");
105
+ }
106
+
107
+ function refText(rel) {
108
+ try { return readPackageFile(`do-better/references/${rel}`); } catch { return ""; }
109
+ }
110
+
111
+ function stripFences(text) {
112
+ let s = String(text ?? "").trim();
113
+ const m = s.match(/^```[a-zA-Z]*\n([\s\S]*?)\n?```\s*$/);
114
+ if (m) s = m[1];
115
+ return s;
116
+ }
117
+
118
+ function gateFail(state, gate, detail) {
119
+ return gateError(gate, detail, state); // H15 — shared, well-formed message
120
+ }
121
+
122
+ function patchPhase(state, patch) {
123
+ return { ...state, phases: { ...state.phases, [PHASE_ID]: { ...state.phases[PHASE_ID], ...patch } } };
124
+ }
125
+
126
+ // ---------- preflight red → Phase-0 injection (D7) ----------
127
+
128
+ function insertPhase0Item(body, line) {
129
+ if (body.includes(RUNNABILITY_TITLE)) return body;
130
+ const lines = body.split("\n");
131
+ const idx = lines.findIndex((l) => /^## Phase 0/.test(l));
132
+ if (idx === -1) return `## Phase 0 — Rails & runnability\n\n${line}\n\n${body}`;
133
+ // drop a "_None._" placeholder directly under the heading
134
+ let insertAt = idx + 1;
135
+ while (insertAt < lines.length && lines[insertAt].trim() === "") insertAt++;
136
+ if (lines[insertAt]?.trim() === "_None._") lines.splice(insertAt, 1);
137
+ lines.splice(idx + 1, 0, "", line);
138
+ return lines.join("\n");
139
+ }
140
+
141
+ function runnabilityTicket(id, preState) {
142
+ const failed = (preState.failedNames ?? preState.checks?.filter((c) => c.status === "fail").map((c) => c.name) ?? []).join(", ");
143
+ return {
144
+ id,
145
+ title: RUNNABILITY_TITLE,
146
+ body: [
147
+ "## Motivation",
148
+ `The environment preflight check is red (failed: ${failed || "unknown"}); no characterization rails can be authored or run until the repo is runnable (SPEC D7).`,
149
+ "",
150
+ "## Acceptance Criteria",
151
+ "- [ ] `do-better rail` preflight reports verdict pass — verification: a command whose output is asserted (preflight exit code 0).",
152
+ "- [ ] The project test command runs to completion — verification: a command whose output is asserted.",
153
+ "",
154
+ "## Partition hints",
155
+ "- Environment/bootstrap work only; no behavior changes.",
156
+ ].join("\n"),
157
+ scope: ["package.json"],
158
+ rails: [],
159
+ edges: [],
160
+ duration: 4,
161
+ category: "operability",
162
+ };
163
+ }
164
+
165
+ async function injectRunnabilityItem(ctx, state, preState, headSha) {
166
+ const { dotdir, log } = ctx;
167
+ const roadmap = readArtifact(dotdir, LAYOUT.roadmap)
168
+ ?? { meta: { generatedAt: ctx.now(), headSha, approved: false }, body: "# Technical Roadmap\n\n## Phase 0 — Rails & runnability\n\n_None._\n" };
169
+ const already = roadmap.body.includes(RUNNABILITY_TITLE);
170
+ const line = `- **${RUNNABILITY_TITLE}** — preflight red; injected by \`do-better rail\` (D7). Risk of inaction: no behavior can be pinned or safely changed.`;
171
+ if (!already) {
172
+ const body = insertPhase0Item(roadmap.body, line);
173
+ writeArtifact(dotdir, LAYOUT.roadmap, { meta: roadmap.meta, body });
174
+ const raw = fs.readFileSync(path.join(dotdir, LAYOUT.roadmap), "utf8");
175
+ state = recordRoadmapHash(state, { sha256: sha256Hex(raw), headSha, now: ctx.now() });
176
+ }
177
+ const tickets = readTickets(dotdir);
178
+ if (!tickets.some((t) => t.title === RUNNABILITY_TITLE)) {
179
+ const counter = (state.counters?.tickets ?? 0) + 1;
180
+ const ticket = runnabilityTicket(`T${counter}`, preState);
181
+ const errs = validateTicket(ticket, [...tickets.map((t) => t.id), ticket.id]);
182
+ if (errs.length) log.warn(`Runnability ticket lint: ${errs.join("; ")}`);
183
+ writeTickets(dotdir, [...tickets, ticket]);
184
+ state = { ...state, counters: { ...state.counters, tickets: counter } };
185
+ // re-test coldstart on just this ticket (declared degradation when absent)
186
+ const cs = runColdstart(ctx.adlc, {
187
+ ticketsPath: path.join(dotdir, LAYOUT.backlogJson),
188
+ all: false, ticketId: ticket.id, cwd: ctx.root, exec: ctx.exec,
189
+ });
190
+ if (cs.skipped) log.warn("coldstart unavailable — runnability ticket not cold-start tested (declared degradation).");
191
+ else if (cs.results?.some((r) => r.pass === false)) log.warn(`Runnability ticket has coldstart gaps: ${JSON.stringify(cs.results)}`);
192
+ }
193
+ return state;
194
+ }
195
+
196
+ // ---------- rail authoring + auditing ----------
197
+
198
+ async function authorRail(ctx, behavior, template, railsDirRel) {
199
+ const prompt = [
200
+ "Author ONE boundary-level characterization rail (golden-master/approval style) for this behavior.",
201
+ "Pin CURRENT actual behavior, even if it looks like a bug (bug-compatible pinning) — comment such",
202
+ "assertions with `// pinned current behavior, possibly a bug`. Use node:test + node:assert/strict.",
203
+ `The file will be written to ${railsDirRel}/${behavior.id}.rail.test.js inside the target repo —`,
204
+ "use relative imports from that location. Return ONLY the JavaScript file content.",
205
+ "Behavior inventory entry (your ONLY view of the system — do not invent internals):",
206
+ behavior.summary,
207
+ behavior.entry ? `Entry point: ${behavior.entry.file}:${behavior.entry.line}` : "",
208
+ ].join("\n");
209
+ const raw = await withFallback(
210
+ ctx.llm,
211
+ { prompt, system: template, tier: "mid", label: `rail:${behavior.id}` },
212
+ () => null,
213
+ );
214
+ if (!raw) return null;
215
+ const content = stripFences(raw).trim();
216
+ if (!content || !/test/.test(content)) return null;
217
+ return `${content}\n`;
218
+ }
219
+
220
+ function runRail(ctx, relFile) {
221
+ // Scrub test-runner context vars: an inherited NODE_TEST_CONTEXT makes the child
222
+ // `node --test` report to a parent runner and exit 0 even when rails are red.
223
+ const env = { ...process.env };
224
+ delete env.NODE_TEST_CONTEXT;
225
+ delete env.NODE_OPTIONS;
226
+ return ctx.exec(process.execPath, ["--test", relFile], { cwd: ctx.root, timeout: 120000, env });
227
+ }
228
+
229
+ async function greenLoop(ctx, behavior, relFile) {
230
+ const abs = path.join(ctx.root, relFile);
231
+ let res = runRail(ctx, relFile);
232
+ for (let round = 1; res.status !== 0 && round <= FIX_ROUNDS; round++) {
233
+ const fixed = await withFallback(ctx.llm, {
234
+ prompt: [
235
+ `This characterization rail is red against CURRENT code. Fix the rail (never the code) so it`,
236
+ "pins current actual behavior. Return ONLY the corrected JavaScript file content.",
237
+ `Rail file (${relFile}):`, fs.readFileSync(abs, "utf8"),
238
+ "Failure output:", `${res.stdout}\n${res.stderr}`.slice(0, 8000),
239
+ ].join("\n"),
240
+ tier: "mid",
241
+ label: "rail-fix",
242
+ }, () => null);
243
+ if (!fixed) break;
244
+ const content = stripFences(fixed).trim();
245
+ if (!content) break;
246
+ writeFileAtomic(abs, `${content}\n`);
247
+ res = runRail(ctx, relFile);
248
+ }
249
+ return res.status === 0;
250
+ }
251
+
252
+ function implicatedRows(mutants, rows) {
253
+ const survivors = (mutants ?? []).filter((m) => m && (m.survived === true || m.status === "survived"));
254
+ return rows.filter((row) => survivors.some((m) => {
255
+ const f = String(m.file ?? "");
256
+ return f && (f === row.file || f.endsWith(`/${path.posix.basename(row.file)}`) || row.file.endsWith(`/${path.posix.basename(f)}`));
257
+ }));
258
+ }
259
+
260
+ function spotCheckRow(ctx, row, behavior) {
261
+ const cit = behavior?.entry;
262
+ if (!cit) return { vacuous: false, audit: "spot-check skipped (no citation)" };
263
+ const abs = path.join(ctx.root, cit.file);
264
+ let original;
265
+ try { original = fs.readFileSync(abs, "utf8"); } catch { return { vacuous: false, audit: "spot-check skipped (cited file unreadable)" }; }
266
+ const lines = original.split("\n");
267
+ if (cit.line < 1 || cit.line > lines.length) return { vacuous: false, audit: "spot-check skipped (citation out of range)" };
268
+ lines[cit.line - 1] = `// dobetter spot-check: ${lines[cit.line - 1]}`;
269
+ try {
270
+ fs.writeFileSync(abs, lines.join("\n"));
271
+ const res = runRail(ctx, row.file);
272
+ return res.status === 0
273
+ ? { vacuous: true, audit: "spot-check: VACUOUS (stayed green with entry deleted)" }
274
+ : { vacuous: false, audit: "spot-check: ok (went red with entry deleted)" };
275
+ } finally {
276
+ fs.writeFileSync(abs, original);
277
+ }
278
+ }
279
+
280
+ function writeManifest(ctx, { rows, gaps, headSha, degradation }) {
281
+ const lines = [
282
+ "# Characterization Rails Manifest", "",
283
+ "Rails are FROZEN once written (ADLC P3 doctrine, F5): they may be deleted with",
284
+ "a recorded reason, never weakened. Rail paths are appended to backlog tickets'",
285
+ "`rails` arrays so the ADLC rails-guard freezes them mechanically.", "",
286
+ "| behavior | rail file | style | pinned-at | audit | frozen |",
287
+ "|---|---|---|---|---|---|",
288
+ ...rows.map((r) => `| ${r.behaviorId} | ${r.file} | ${r.style} | ${String(r.pinnedAt).slice(0, 7)} | ${r.audit} | yes |`),
289
+ "",
290
+ "## Gaps", "",
291
+ ...(gaps.length ? gaps.map((g) => `- ${g.behaviorId}: ${g.reason}`) : ["_None._"]),
292
+ "",
293
+ ...(degradation ? [`> Degradation: ${degradation}`, ""] : []),
294
+ ];
295
+ writeArtifact(ctx.dotdir, LAYOUT.railsManifest, {
296
+ meta: { generatedAt: ctx.now(), headSha },
297
+ body: lines.join("\n"),
298
+ });
299
+ }
300
+
301
+ function updateRailsMap(ctx, mapping, rows, gaps) {
302
+ const art = readArtifact(ctx.dotdir, LAYOUT.comprehension.railsMap);
303
+ if (!art) return;
304
+ const marker = "## Rail coverage (do-better D4)";
305
+ const section = [
306
+ marker, "",
307
+ ...mapping.map((m) => {
308
+ const row = rows.find((r) => r.behaviorId === m.behaviorId);
309
+ const gap = gaps.find((g) => g.behaviorId === m.behaviorId);
310
+ const status = row ? `railed (${row.file})` : gap ? `GAP — ${gap.reason}` : m.ticketIds.length ? "GAP — pending" : "out of roadmap scope (no Now/Next ticket touches it)";
311
+ return `- ${m.behaviorId}: ${status}`;
312
+ }),
313
+ "",
314
+ ].join("\n");
315
+ const base = art.body.includes(marker) ? art.body.slice(0, art.body.indexOf(marker)).replace(/\n+$/, "\n") : `${art.body.replace(/\n+$/, "")}\n`;
316
+ writeArtifact(ctx.dotdir, LAYOUT.comprehension.railsMap, { meta: art.meta, body: `${base}\n${section}` });
317
+ }
318
+
319
+ function finishState(state, ctx, { headSha, status, railsAuthored, behaviorsCovered, behaviorsGapped }) {
320
+ let next = addSpend(state, PHASE_ID, ctx.llm.drainSpend());
321
+ next = recordPhase(next, PHASE_ID, { status, sha: headSha, now: ctx.now() });
322
+ next = patchPhase(next, { railsAuthored, behaviorsCovered, behaviorsGapped });
323
+ if (status === "done") next = pinSha(next, PHASE_ID, headSha);
324
+ return next;
325
+ }
326
+
327
+ // ---------- phase entrypoint ----------
328
+
329
+ export async function run(ctx) {
330
+ const { root, dotdir, log, flags } = ctx;
331
+ let state = ctx.state;
332
+ const gate = state?.gates?.roadmap;
333
+ if (!gate?.approved || !gate?.coldstartClean) {
334
+ throw new OpError("Roadmap is not approved — run `do-better roadmap` and `do-better roadmap --approve` first.");
335
+ }
336
+ ensureLayout(dotdir);
337
+ const headSha = gitHeadSha(root, ctx.exec);
338
+ const testCmd = detectTestCmd(root);
339
+
340
+ // 2. preflight env check FIRST (red does not exit 2 — it becomes a Phase-0 item, D7)
341
+ let preState;
342
+ const pf = runPreflight(ctx.adlc, { testCmd, cwd: root, exec: ctx.exec });
343
+ if (pf.skipped) {
344
+ const probe = basicEnvProbe(root, ctx.exec);
345
+ preState = { degraded: "basic-probe", verdict: probe.verdict, checks: probe.checks, failedNames: probe.checks.filter((c) => c.status === "fail").map((c) => c.name) };
346
+ } else {
347
+ preState = { degraded: null, verdict: pf.ok ? "pass" : "fail", checks: pf.checks, failedNames: pf.failedNames };
348
+ }
349
+ if (preState.verdict !== "pass") {
350
+ state = await injectRunnabilityItem(ctx, state, preState, headSha);
351
+ writeManifest(ctx, {
352
+ rows: [],
353
+ gaps: [{ behaviorId: "*", reason: `environment not runnable — preflight red (${(preState.failedNames ?? []).join(", ") || "see checks"}); "${RUNNABILITY_TITLE}" added to Phase 0` }],
354
+ headSha,
355
+ degradation: preState.degraded ? `preflight degraded to ${preState.degraded}` : null,
356
+ });
357
+ state = setGate(state, "rail", { passed: false, railsGreen: false, hollowAudited: false, hollowSurvivors: 0, preflight: preState });
358
+ state = finishState(state, ctx, { headSha, status: "done", railsAuthored: 0, behaviorsCovered: 0, behaviorsGapped: 0 });
359
+ return {
360
+ state,
361
+ gate: { name: "rail-preflight", passed: false, human: false, detail: `Environment not runnable — "${RUNNABILITY_TITLE}" injected as a Phase 0 roadmap item. Fix it, then re-run \`do-better rail\`.` },
362
+ summary: `Preflight red (${(preState.failedNames ?? []).join(", ") || "env checks failed"}). "${RUNNABILITY_TITLE}" added to ROADMAP Phase 0 and backlog; rails scoped to nothing runnable.`,
363
+ };
364
+ }
365
+
366
+ // 3. roadmap-scoped targets (D7)
367
+ const inv = readArtifact(dotdir, LAYOUT.comprehension.behaviorInventory);
368
+ if (!inv) throw new OpError("behavior-inventory.md missing — run `do-better audit` first.");
369
+ const behaviors = parseBehaviorInventory(inv.body);
370
+ const tickets = readTickets(dotdir);
371
+ const mapping = mapBehaviorsToTickets(behaviors, tickets);
372
+ const targetIds = new Set(mapping.filter((m) => m.ticketIds.length).map((m) => m.behaviorId));
373
+ const targets = behaviors.filter((b) => targetIds.has(b.id));
374
+ const railsDirRel = detectRailsDir(root);
375
+ const template = refText("templates/rail-template.md");
376
+
377
+ // 4-5. author rails (fresh context, mid tier) + rails-green gate with fix loop
378
+ let rows = [];
379
+ const gaps = [];
380
+ for (const behavior of targets) {
381
+ const content = await authorRail(ctx, behavior, template, railsDirRel);
382
+ if (!content) {
383
+ gaps.push({ behaviorId: behavior.id, reason: ctx.llm.offline ? "rail not authored (offline)" : "rail not authored (empty draft)" });
384
+ continue;
385
+ }
386
+ const relFile = path.posix.join(railsDirRel, `${behavior.id}.rail.test.js`);
387
+ writeFileAtomic(path.join(root, relFile), content);
388
+ const green = await greenLoop(ctx, behavior, relFile);
389
+ if (!green) {
390
+ fs.rmSync(path.join(root, relFile), { force: true });
391
+ gaps.push({ behaviorId: behavior.id, reason: `could not pin — rail red after ${FIX_ROUNDS} fix rounds (deleted, fail closed)` });
392
+ continue;
393
+ }
394
+ rows.push({ behaviorId: behavior.id, file: relFile, style: "boundary golden-master", pinnedAt: headSha, audit: "pending" });
395
+ }
396
+ if (targets.length && !rows.length) {
397
+ writeManifest(ctx, { rows, gaps, headSha, degradation: null });
398
+ state = finishState(state, ctx, { headSha, status: "failed", railsAuthored: 0, behaviorsCovered: 0, behaviorsGapped: gaps.length });
399
+ throw gateFail(state, "rail", `no green rails could be authored for ${targets.length} target behaviors`);
400
+ }
401
+
402
+ // 6. commit boundary — only with --yes or interactive confirmation; else rails stay staged
403
+ if (rows.length) {
404
+ git(root, ["add", ...rows.map((r) => r.file)], ctx.exec);
405
+ let doCommit = Boolean(flags?.yes);
406
+ if (!doCommit && ctx.ask) {
407
+ doCommit = /^y/i.test((await ctx.ask("Commit characterization rails now? [y/N] ")).trim());
408
+ }
409
+ if (doCommit) git(root, ["commit", "-m", "test: add do-better characterization rails"], ctx.exec);
410
+ }
411
+
412
+ // 7. hollow-test audit (or 7b native deletion spot-check when absent)
413
+ let hollowAudited = false;
414
+ let hollowSurvivors = 0;
415
+ let degradation = null;
416
+ const base = state.pins?.roadmap ?? headSha;
417
+ const deleteRow = (row, reason) => {
418
+ fs.rmSync(path.join(root, row.file), { force: true });
419
+ rows = rows.filter((r) => r !== row);
420
+ gaps.push({ behaviorId: row.behaviorId, reason });
421
+ };
422
+ // A hollow-test run that errored (exit 1 / unparseable --json output) produced
423
+ // no mutants: it is NOT an audit. Treat it like absence — degrade to the
424
+ // mandatory native deletion spot-check with a declared degradation. Never
425
+ // record "hollow: killed 0/0" for a run that demonstrably did not run (D4/F5).
426
+ const hollowUsable = (r) => !r.skipped && !r.opError;
427
+ if (rows.length) {
428
+ let ht = runHollowTest(ctx.adlc, { testCmd: `node --test ${railsDirRel}`, base, max: 20, cwd: root, exec: ctx.exec });
429
+ if (hollowUsable(ht)) {
430
+ hollowAudited = true;
431
+ if ((ht.summary?.survived ?? 0) > 0) {
432
+ const implicated = implicatedRows(ht.mutants, rows);
433
+ if (!implicated.length) {
434
+ state = finishState(state, ctx, { headSha, status: "failed", railsAuthored: rows.length, behaviorsCovered: rows.length, behaviorsGapped: gaps.length });
435
+ throw gateFail(state, "rail", `hollow-test reported ${ht.summary.survived} survivors that could not be mapped to rails (fail closed)`);
436
+ }
437
+ for (const row of implicated) {
438
+ // one explicit rail-fix round: strengthen the vacuous assertions, then re-verify green
439
+ const abs = path.join(root, row.file);
440
+ const fixed = await withFallback(ctx.llm, {
441
+ prompt: [
442
+ "A hollow-test mutant of this rail SURVIVED — its assertions are vacuous fog.",
443
+ "Strengthen the assertions so any mutation of them turns the rail red, still pinning",
444
+ "current actual behavior. Return ONLY the corrected JavaScript file content.",
445
+ `Rail file (${row.file}):`, fs.readFileSync(abs, "utf8"),
446
+ "Survived mutants:", JSON.stringify(ht.mutants ?? []),
447
+ ].join("\n"),
448
+ tier: "mid",
449
+ label: "rail-fix",
450
+ }, () => null);
451
+ if (fixed) {
452
+ const content = stripFences(fixed).trim();
453
+ if (content) {
454
+ writeFileAtomic(abs, `${content}\n`);
455
+ if (runRail(ctx, row.file).status !== 0) {
456
+ deleteRow(row, "rail red after hollow fix round (deleted, fail closed)");
457
+ }
458
+ }
459
+ }
460
+ }
461
+ if (rows.length) {
462
+ ht = runHollowTest(ctx.adlc, { testCmd: `node --test ${railsDirRel}`, base, max: 20, cwd: root, exec: ctx.exec });
463
+ if (hollowUsable(ht) && (ht.summary?.survived ?? 0) > 0) {
464
+ hollowSurvivors = ht.summary.survived;
465
+ for (const row of implicatedRows(ht.mutants, rows)) {
466
+ deleteRow(row, "hollow-test survivor after fix loop — vacuous assertions (deleted, fail closed)");
467
+ }
468
+ }
469
+ }
470
+ }
471
+ if (hollowUsable(ht)) {
472
+ const k = ht.summary?.killed ?? 0;
473
+ const t = ht.summary?.total ?? 0;
474
+ rows = rows.map((r) => ({ ...r, audit: `hollow: killed ${k}/${t}` }));
475
+ } else {
476
+ // the re-audit after the fix round errored — never claim clean numbers
477
+ rows = rows.map((r) => ({ ...r, audit: "hollow: re-audit failed (operational error)" }));
478
+ }
479
+ } else {
480
+ // 7b. mandatory native deletion spot-check for the top behaviors —
481
+ // applies both when hollow-test is absent and when its run errored.
482
+ degradation = ht.skipped
483
+ ? "hollow-test absent — native deletion spot-check applied to top behaviors"
484
+ : "hollow-test failed to run (operational error) — native deletion spot-check applied to top behaviors";
485
+ const checked = rows.slice(0, SPOT_CHECK_COUNT);
486
+ for (const row of checked) {
487
+ const behavior = targets.find((b) => b.id === row.behaviorId);
488
+ const res = spotCheckRow(ctx, row, behavior);
489
+ if (res.vacuous) deleteRow(row, "vacuous rail — stayed green with cited entry deleted (deleted, fail closed)");
490
+ else row.audit = res.audit;
491
+ }
492
+ const unauditedLabel = ht.skipped ? "unaudited (hollow-test absent)" : "unaudited (hollow-test errored)";
493
+ rows = rows.map((r) => (r.audit === "pending" ? { ...r, audit: unauditedLabel } : r));
494
+ }
495
+ }
496
+ if (targets.length && !rows.length) {
497
+ writeManifest(ctx, { rows, gaps, headSha, degradation });
498
+ state = finishState(state, ctx, { headSha, status: "failed", railsAuthored: 0, behaviorsCovered: 0, behaviorsGapped: gaps.length });
499
+ throw gateFail(state, "rail", "every authored rail failed its audit — no behavior could be pinned");
500
+ }
501
+
502
+ // 8. manifest + freeze: rail paths ride along on tickets for ADLC rails-guard (F5)
503
+ writeManifest(ctx, { rows, gaps, headSha, degradation });
504
+ updateRailsMap(ctx, mapping, rows, gaps);
505
+ if (rows.length) {
506
+ const updated = tickets.map((t) => {
507
+ const railPaths = mapping
508
+ .filter((m) => m.ticketIds.includes(t.id))
509
+ .flatMap((m) => rows.filter((r) => r.behaviorId === m.behaviorId).map((r) => r.file));
510
+ return railPaths.length ? { ...t, rails: [...new Set([...(t.rails ?? []), ...railPaths])] } : t;
511
+ });
512
+ writeTickets(dotdir, updated);
513
+ }
514
+
515
+ // 9. gate
516
+ state = setGate(state, "rail", {
517
+ passed: true,
518
+ railsGreen: rows.length > 0 || targets.length === 0,
519
+ hollowAudited,
520
+ hollowSurvivors,
521
+ preflight: preState,
522
+ });
523
+ state = finishState(state, ctx, {
524
+ headSha, status: "done",
525
+ railsAuthored: rows.length, behaviorsCovered: rows.length, behaviorsGapped: gaps.length,
526
+ });
527
+ const audit = hollowAudited ? "hollow-test audited" : degradation ?? "no rails to audit";
528
+ return {
529
+ state,
530
+ gate: { name: "rail", passed: true, human: false, detail: `rails green; ${audit}` },
531
+ summary: `${rows.length}/${targets.length} target behaviors pinned (${gaps.length} gaps recorded in rails/manifest.md; ${audit}). ${HANDOFF_LINE}`,
532
+ };
533
+ }