@praxisflux/gates 0.60.0 → 0.61.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/package.json +1 -1
- package/spec-bridge/gates/bridge.mjs +256 -37
- package/spec-bridge/gates/cli.mjs +4 -1
package/package.json
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
// derivation-stage ladder against the board's own status names. Absent that config, every
|
|
19
19
|
// path below behaves exactly as described above.
|
|
20
20
|
|
|
21
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { existsSync, readFileSync, appendFileSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
24
25
|
import { deriveSpecState, STATUS, STAGE, STAGES } from "../lib/spec-derive.mjs";
|
|
25
26
|
import { hasAnyChild, findRootsDownwards } from "../lib/project-root.mjs";
|
|
26
27
|
import { parseLinkedTask, findLinkedTasks, readMirror, providers, mirrorStaleness } from "../lib/board-mirror.mjs";
|
|
@@ -165,8 +166,14 @@ export const GATE_TIMEOUT_MS = 120000;
|
|
|
165
166
|
* is the gate-runner contract applied one level down — a crash is a blocking problem, not a
|
|
166
167
|
* silent pass). Sets SPEC_BRIDGE_GATE_ACTIVE on the child env so a declared command that itself
|
|
167
168
|
* invokes the bridge short-circuits instead of recursing (see checkBridge/verifyBridge).
|
|
169
|
+
*
|
|
170
|
+
* `trace` (spec 061 R4) is an optional callback invoked with the RAW outcome — { command, cwd,
|
|
171
|
+
* status, signal, stdout, stderr, error } — before it's classified into the shape above. It
|
|
172
|
+
* exists solely for opt-in instrumentation (see `bridgeGate.check`); omitted (the default for
|
|
173
|
+
* every existing caller and every test), it costs one falsy check, never a syscall. A throwing
|
|
174
|
+
* `trace` is swallowed here too — instrumentation must never affect this function's verdict.
|
|
168
175
|
*/
|
|
169
|
-
export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spawn = spawnSync } = {}) {
|
|
176
|
+
export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spawn = spawnSync, trace } = {}) {
|
|
170
177
|
let res;
|
|
171
178
|
try {
|
|
172
179
|
res = spawn(command[0], command.slice(1), {
|
|
@@ -174,8 +181,19 @@ export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spaw
|
|
|
174
181
|
env: { ...process.env, SPEC_BRIDGE_GATE_ACTIVE: "1" },
|
|
175
182
|
});
|
|
176
183
|
} catch (e) {
|
|
184
|
+
if (trace) try { trace({ command, cwd, status: null, signal: null, stdout: "", stderr: "", error: e.code || e.message }); } catch { /* swallowed */ }
|
|
177
185
|
return { ok: false, kind: "error", reason: e.code || e.message };
|
|
178
186
|
}
|
|
187
|
+
if (trace) {
|
|
188
|
+
try {
|
|
189
|
+
trace({
|
|
190
|
+
command, cwd,
|
|
191
|
+
status: res.status ?? null, signal: res.signal ?? null,
|
|
192
|
+
stdout: res.stdout || "", stderr: res.stderr || "",
|
|
193
|
+
error: res.error ? (res.error.code || res.error.message) : null,
|
|
194
|
+
});
|
|
195
|
+
} catch { /* swallowed: instrumentation must never affect the verdict */ }
|
|
196
|
+
}
|
|
179
197
|
if (res.error) {
|
|
180
198
|
if (res.error.code === "ETIMEDOUT") return { ok: false, kind: "timeout", timeoutMs };
|
|
181
199
|
return { ok: false, kind: "error", reason: res.error.code || res.error.message };
|
|
@@ -185,6 +203,92 @@ export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spaw
|
|
|
185
203
|
return { ok: false, kind: "red", reason: `exited ${res.status}` };
|
|
186
204
|
}
|
|
187
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Whether <root>'s working tree has uncommitted changes (spec 061 R2), via `git status
|
|
208
|
+
* --porcelain` — non-empty stdout = dirty. **Fail closed**: any failure to determine (nonzero
|
|
209
|
+
* exit, spawn error, git absent) => `false` (treated clean, gate keeps blocking) — an
|
|
210
|
+
* undeterminable condition must never silently disarm the gate, the same posture
|
|
211
|
+
* `runGateCommand` takes for a command that cannot run. Argv only, shell:false, cwd = root —
|
|
212
|
+
* this reads the same tree a gate command would run against, never the CWD it happened to be
|
|
213
|
+
* invoked from.
|
|
214
|
+
*/
|
|
215
|
+
export function isTreeDirty(root, { spawn = spawnSync } = {}) {
|
|
216
|
+
let res;
|
|
217
|
+
try {
|
|
218
|
+
res = spawn("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8", shell: false });
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (res.error || res.status !== 0) return false;
|
|
223
|
+
return typeof res.stdout === "string" && res.stdout.trim().length > 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Clause appended to a collapsed gate finding sampled against a dirty tree (spec 061 R2): the
|
|
228
|
+
* mirror image of this repo's own F6 finding ("a gate run against a dirty working tree proves
|
|
229
|
+
* nothing about the commit"), recorded there for false greens and here producing false reds.
|
|
230
|
+
* Rounds 1/2/4 of this repo's own fan-out chase were exactly this — an implementer's
|
|
231
|
+
* uncommitted mid-dispatch edits, sampled and misread as a broken commit. The verdict is not
|
|
232
|
+
* dropped (silence would hide a genuine red) but it must not block.
|
|
233
|
+
*/
|
|
234
|
+
const DIRTY_TREE_CLAUSE =
|
|
235
|
+
" This verdict was sampled against a DIRTY working tree, not a commit — it proves nothing " +
|
|
236
|
+
"about the commit and must not block. Commit or stash the tree, then re-run for a real verdict.";
|
|
237
|
+
|
|
238
|
+
/* ── R4 instrumentation: opt-in record of what a Stop invocation actually saw ────────────────
|
|
239
|
+
*
|
|
240
|
+
* Rounds 5-7 of this repo's own fan-out chase were unreproduced (see the card's elimination
|
|
241
|
+
* list). Round 7's candidate mechanism — an orphaned worktree tree resolving as a SECOND root —
|
|
242
|
+
* can only be settled by seeing, from a real Stop invocation, exactly which roots resolveRoots
|
|
243
|
+
* returned and what each gate command actually did. This is that instrumentation:
|
|
244
|
+
*
|
|
245
|
+
* - OFF by default: `SPEC_BRIDGE_GATE_TRACE` unset ⇒ `tracePath()` returns null ⇒ every call
|
|
246
|
+
* site below is a single falsy check, never a write, never a spawn.
|
|
247
|
+
* - Append-only JSONL, OUTSIDE the tracked tree: `$CLAUDE_JOB_DIR` if set (this repo's own
|
|
248
|
+
* scratch convention), else the OS temp dir — never inside the repo, or a Stop hook writing
|
|
249
|
+
* it would dirty the very tree Phase 2's dirty-tree check reads.
|
|
250
|
+
* - Verdict-neutral: every write is wrapped in try/catch. A trace failure is swallowed and
|
|
251
|
+
* can never turn a green gate red or vice versa.
|
|
252
|
+
*
|
|
253
|
+
* Wired ONLY into `bridgeGate.check` (the real Stop-hook path) — not into `checkBridge` callers
|
|
254
|
+
* generally and not into `verifyBridge` — because R4 is about diagnosing Stop-time firings, and
|
|
255
|
+
* an injected `run` (every test) never touches `runGateCommand`, so tests are unaffected whether
|
|
256
|
+
* or not the env var happens to be set.
|
|
257
|
+
*/
|
|
258
|
+
|
|
259
|
+
/** Truthy env var → the JSONL path to append to. `"1"`/`"true"` mean "use the default scratch
|
|
260
|
+
* location"; any other value is used as an explicit path override. Unset ⇒ null (off). */
|
|
261
|
+
function tracePath() {
|
|
262
|
+
const v = process.env.SPEC_BRIDGE_GATE_TRACE;
|
|
263
|
+
if (!v) return null;
|
|
264
|
+
if (v === "1" || v === "true") return join(process.env.CLAUDE_JOB_DIR || tmpdir(), "spec-bridge-gate-trace.jsonl");
|
|
265
|
+
return v;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const TRACE_CAP = 4000; // bound stdout/stderr — the `tests` gate's own output is large
|
|
269
|
+
|
|
270
|
+
/** Bound a captured stream to TRACE_CAP chars, noting how much was cut. */
|
|
271
|
+
function capTrace(s) {
|
|
272
|
+
if (typeof s !== "string" || s.length <= TRACE_CAP) return s;
|
|
273
|
+
return s.slice(0, TRACE_CAP) + `…[${s.length - TRACE_CAP} more bytes truncated]`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Append one JSONL record for this bridgeGate.check() invocation. Never throws — a write
|
|
277
|
+
* failure (unwritable path, ENOENT, disk full) is swallowed, never affecting the gate verdict. */
|
|
278
|
+
function traceGateRun(root, roots, commandRecords) {
|
|
279
|
+
const path = tracePath();
|
|
280
|
+
if (!path) return;
|
|
281
|
+
try {
|
|
282
|
+
const record = {
|
|
283
|
+
ts: new Date().toISOString(),
|
|
284
|
+
root,
|
|
285
|
+
roots,
|
|
286
|
+
commands: commandRecords.map((r) => ({ ...r, stdout: capTrace(r.stdout), stderr: capTrace(r.stderr) })),
|
|
287
|
+
};
|
|
288
|
+
appendFileSync(path, JSON.stringify(record) + "\n");
|
|
289
|
+
} catch { /* verdict-neutral: instrumentation failure is swallowed */ }
|
|
290
|
+
}
|
|
291
|
+
|
|
188
292
|
/** The last ticked box in document order — the tick that (in sequence) claimed the most, and so
|
|
189
293
|
* the box a red gate most directly stands over. Null when nothing is ticked. */
|
|
190
294
|
function lastTickedBox(phaseBoxes) {
|
|
@@ -228,14 +332,16 @@ function gateReason(result) {
|
|
|
228
332
|
return `is red (${result.reason})`;
|
|
229
333
|
}
|
|
230
334
|
|
|
335
|
+
/** Shared tail clause for every project-gate finding, per-spec or collapsed alike. */
|
|
336
|
+
const CANT_OUTRUN = "A ticked tasks.md checkbox cannot outrun a red project gate — make the gate pass or set the box back.";
|
|
337
|
+
|
|
231
338
|
/** The blocking finding: names the phase, the box, and the failing gate (spec 050 AC #1). */
|
|
232
339
|
function projectGateProblem({ id, specDir, witness, gate, result }) {
|
|
233
340
|
const where = witness
|
|
234
341
|
? `phase "${witness.phase}", box "${witness.box}" is ticked, but `
|
|
235
342
|
: "a ticked box stands over a gate that ";
|
|
236
343
|
const label = gate.bucket === "redByConstruction" ? "red-by-construction gate" : "required gate";
|
|
237
|
-
return `[spec-bridge] ${id} · ${specDir}: ${where}the ${label} "${gate.name}" ${gateReason(result)}.
|
|
238
|
-
`A ticked tasks.md checkbox cannot outrun a red project gate — make the gate pass or set the box back.`;
|
|
344
|
+
return `[spec-bridge] ${id} · ${specDir}: ${where}the ${label} "${gate.name}" ${gateReason(result)}. ${CANT_OUTRUN}`;
|
|
239
345
|
}
|
|
240
346
|
|
|
241
347
|
/**
|
|
@@ -244,6 +350,12 @@ function projectGateProblem({ id, specDir, witness, gate, result }) {
|
|
|
244
350
|
* gate list (the caller picks the buckets — the two entry points differ there); `run` executes one
|
|
245
351
|
* command and returns runGateCommand's shape (injected so tests need no subprocess). The witness box
|
|
246
352
|
* is computed once from phaseBoxes so every finding names it.
|
|
353
|
+
*
|
|
354
|
+
* NOTE (spec 061 R1): `checkBridge`/`verifyBridge` no longer call this directly — one red project
|
|
355
|
+
* gate now yields ONE collapsed finding per invocation naming the gate and the affected count
|
|
356
|
+
* (see `collapsedGateProblems` below), not one finding per linked spec. This function remains the
|
|
357
|
+
* pure per-spec evaluator: exported for direct testing of the per-gate/per-spec decision logic,
|
|
358
|
+
* and available to any caller that wants the per-spec witness view (e.g. `cli.mjs state <specDir>`).
|
|
247
359
|
*/
|
|
248
360
|
export function evaluateProjectGates({ id, specDir, phaseBoxes }, gates, run) {
|
|
249
361
|
const witness = lastTickedBox(phaseBoxes);
|
|
@@ -256,6 +368,31 @@ export function evaluateProjectGates({ id, specDir, phaseBoxes }, gates, run) {
|
|
|
256
368
|
return problems;
|
|
257
369
|
}
|
|
258
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Collapsed evaluator (spec 061 R1): run each distinct gate ONCE (via the memoized `runOne`) and
|
|
373
|
+
* return at most one finding per non-green gate, naming the gate + bucket + `gateReason` + how
|
|
374
|
+
* many qualifying specs are affected — never enumerating them. `counts` is `{ required,
|
|
375
|
+
* redByConstruction }`, the number of specs the caller has already determined are held to each
|
|
376
|
+
* bucket (checkBridge: every Done-eligible spec, held to both; verifyBridge: every ticked spec
|
|
377
|
+
* for `required`, only the Done-eligible ones for `redByConstruction` — the bucket asymmetry).
|
|
378
|
+
* A gate whose bucket count is 0 is never even run — no spec is holding it, so nothing changes
|
|
379
|
+
* about WHICH gates run for WHICH specs, only how a non-green one is reported.
|
|
380
|
+
*/
|
|
381
|
+
function collapsedGateProblems(gates, counts, runOne) {
|
|
382
|
+
const problems = [];
|
|
383
|
+
for (const gate of gates) {
|
|
384
|
+
const count = counts[gate.bucket] ?? 0;
|
|
385
|
+
if (count === 0) continue;
|
|
386
|
+
const result = runOne(gate.command);
|
|
387
|
+
if (result.ok) continue;
|
|
388
|
+
const label = gate.bucket === "redByConstruction" ? "red-by-construction gate" : "required gate";
|
|
389
|
+
problems.push(
|
|
390
|
+
`[spec-bridge] the ${label} "${gate.name}" ${gateReason(result)} — ${count} linked spec${count === 1 ? "" : "s"} affected. ${CANT_OUTRUN}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return problems;
|
|
394
|
+
}
|
|
395
|
+
|
|
259
396
|
const RANK = { "to do": 0, "in progress": 1, done: 2 };
|
|
260
397
|
const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_ELIGIBLE]: 2 };
|
|
261
398
|
|
|
@@ -343,10 +480,30 @@ function shortfall(root, specDir, derived) {
|
|
|
343
480
|
/**
|
|
344
481
|
* Judge every linked task under <root>. Returns:
|
|
345
482
|
* links — [{ id, status, specDir, derived, verdict }]
|
|
346
|
-
* problems — blocking messages, one per "exceeds"
|
|
347
|
-
*
|
|
483
|
+
* problems — blocking messages, one per "exceeds", plus one per non-green declared project
|
|
484
|
+
* gate (spec 061 R1) — UNLESS the tree is dirty, in which case that gate finding
|
|
485
|
+
* moves to `warnings`, labeled (spec 061 R2): a verdict sampled against an
|
|
486
|
+
* uncommitted tree proves nothing about the commit and must not block.
|
|
487
|
+
* warnings — non-blocking messages: one per "lags", plus any dirty-tree-labeled gate finding.
|
|
488
|
+
* `isDirty(root)` (default `isTreeDirty`) is injectable for tests, same pattern as `run`.
|
|
489
|
+
* `trace` (spec 061 R4, opt-in instrumentation): a callback forwarded to the DEFAULT gate
|
|
490
|
+
* runner's `runGateCommand` calls only — an injected `run` (every test) never reaches it, so
|
|
491
|
+
* tests are unaffected whether or not it's provided.
|
|
492
|
+
*
|
|
493
|
+
* `gateActive` (spec 061 Phase 3b, T027): the reentrancy signal itself, injectable with a real
|
|
494
|
+
* default — `process.env.SPEC_BRIDGE_GATE_ACTIVE === "1"` — same shape as `run`/`isDirty`. Every
|
|
495
|
+
* real caller (cli.mjs, bridgeGate's production wiring) leaves it at the default, so the actual
|
|
496
|
+
* env is still what decides for them; only a caller that explicitly passes `gateActive: false`
|
|
497
|
+
* (a test-owned invocation, e.g. `bridgeGate.check(root, { gateActive: false })`) can make this
|
|
498
|
+
* DEFAULT runner execute gates while an ambient SPEC_BRIDGE_GATE_ACTIVE=1 is set. This is what
|
|
499
|
+
* lets `bridgeGate` tests (below) stay honest when this repo's own dogfood `tests` gate — bare
|
|
500
|
+
* `node --test` — runs the whole suite as a child of a real gate spawn: those tests opt out of
|
|
501
|
+
* the flag explicitly; nothing else does, so real recursive spawning is stopped exactly as before.
|
|
348
502
|
*/
|
|
349
|
-
export function checkBridge(root, {
|
|
503
|
+
export function checkBridge(root, {
|
|
504
|
+
runGates = true, run, isDirty = isTreeDirty, trace,
|
|
505
|
+
gateActive = process.env.SPEC_BRIDGE_GATE_ACTIVE === "1",
|
|
506
|
+
} = {}) {
|
|
350
507
|
const links = [];
|
|
351
508
|
const problems = [];
|
|
352
509
|
const warnings = [];
|
|
@@ -403,8 +560,9 @@ export function checkBridge(root, { runGates = true, run } = {}) {
|
|
|
403
560
|
// Without this bypass the bridge's own dogfood reddens its `tests` gate: `node --test` runs the
|
|
404
561
|
// Phase-3 suite with the flag set, and every injected-run test there fail-closes to [].
|
|
405
562
|
const injected = run !== undefined;
|
|
406
|
-
const execGates = runGates && !!gatesProfile && (injected ||
|
|
407
|
-
const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root })));
|
|
563
|
+
const execGates = runGates && !!gatesProfile && (injected || !gateActive);
|
|
564
|
+
const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root, trace })));
|
|
565
|
+
let doneEligibleCount = 0; // spec 061 R1: gate findings collapse to one-per-gate after the loop
|
|
408
566
|
for (const task of boardLinks(root)) {
|
|
409
567
|
const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
|
|
410
568
|
// Opted-in boards are judged on the stage ladder against their own status names;
|
|
@@ -442,18 +600,26 @@ export function checkBridge(root, { runGates = true, run } = {}) {
|
|
|
442
600
|
);
|
|
443
601
|
}
|
|
444
602
|
|
|
445
|
-
// Project-gate check (spec 050 R4):
|
|
603
|
+
// Project-gate check (spec 050 R4): a spec is held to its declared gates ONLY when
|
|
446
604
|
// Done-eligible — the one bounded moment a red gate under a ticked box changes an outcome,
|
|
447
605
|
// so ordinary turns pay zero subprocess cost. At Done-eligible the mid-PR window has closed
|
|
448
606
|
// (every box, including the re-pin box, is ticked), so BOTH buckets must be green: required,
|
|
449
607
|
// AND redByConstruction — its "allowed red mid-PR" license has expired now that its re-pin
|
|
450
|
-
// was claimed done.
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
608
|
+
// was claimed done. Tally the count here; the gates themselves run once, after the loop
|
|
609
|
+
// (spec 061 R1: one finding per red gate, not one per linked spec).
|
|
610
|
+
if (execGates && derived.status === STATUS.DONE_ELIGIBLE) doneEligibleCount++;
|
|
611
|
+
}
|
|
612
|
+
if (doneEligibleCount > 0) {
|
|
613
|
+
const gates = gatesFor(gatesProfile, ["required", "redByConstruction"]);
|
|
614
|
+
const counts = { required: doneEligibleCount, redByConstruction: doneEligibleCount };
|
|
615
|
+
const found = collapsedGateProblems(gates, counts, runOne);
|
|
616
|
+
if (found.length > 0) {
|
|
617
|
+
// spec 061 R2: dirtiness is computed ONCE per invocation (T009) — only reached at all
|
|
618
|
+
// when there is at least one non-green gate to label. A dirty tree routes the finding
|
|
619
|
+
// into `warnings` (non-blocking, labeled); clean or undeterminable (fail-closed) keeps
|
|
620
|
+
// it in `problems`, unchanged from today.
|
|
621
|
+
if (isDirty(root)) warnings.push(...found.map((p) => p + DIRTY_TREE_CLAUSE));
|
|
622
|
+
else problems.push(...found);
|
|
457
623
|
}
|
|
458
624
|
}
|
|
459
625
|
return { links, problems, warnings };
|
|
@@ -464,37 +630,48 @@ export function checkBridge(root, { runGates = true, run } = {}) {
|
|
|
464
630
|
* linked spec that has at least one ticked box — a box claiming greenness — run the declared
|
|
465
631
|
* gates and return the blocking findings. A Done-eligible spec is held to BOTH buckets (as the
|
|
466
632
|
* Stop hook does); a mid-PR spec to `required` only, because redByConstruction gates are
|
|
467
|
-
* legitimately red between a source edit and its re-pin commit. Shares
|
|
468
|
-
* it and the Stop hook agree by construction. Read-only like the rest of
|
|
469
|
-
* host's declared subprocesses but writes nothing itself. Injectable `run`
|
|
633
|
+
* legitimately red between a source edit and its re-pin commit. Shares collapsedGateProblems
|
|
634
|
+
* (spec 061 R1), so it and the Stop hook agree by construction. Read-only like the rest of
|
|
635
|
+
* gates/: it runs the host's declared subprocesses but writes nothing itself. Injectable `run`
|
|
636
|
+
* for tests.
|
|
637
|
+
*
|
|
638
|
+
* Returns `{ problems, warnings }` (spec 061 T018a — this used to be a flat `problems` array;
|
|
639
|
+
* `verify` is the mid-PR entry point, exactly the window where a working tree is dirtiest, so
|
|
640
|
+
* R2's "a non-green project gate must not block on a dirty-tree sample" applies here too, not
|
|
641
|
+
* only to `checkBridge`. A dirty-tree gate finding is never dropped — it moves to `warnings`,
|
|
642
|
+
* still labeled, never silently disappearing. `isDirty(root)` (default `isTreeDirty`) is
|
|
643
|
+
* injectable, same pattern as `checkBridge`.
|
|
470
644
|
*/
|
|
471
|
-
export function verifyBridge(root, { run } = {}) {
|
|
472
|
-
const problems = [];
|
|
645
|
+
export function verifyBridge(root, { run, isDirty = isTreeDirty } = {}) {
|
|
473
646
|
const config = loadBridgeConfig(root);
|
|
474
647
|
const gatesProfile = projectGatesProfile(config);
|
|
475
|
-
if (!gatesProfile) return problems; // no opt-in → nothing to do
|
|
648
|
+
if (!gatesProfile) return { problems: [], warnings: [] }; // no opt-in → nothing to do
|
|
476
649
|
// Reentrancy guard (spec 050 defect 1, Phase 5): a spawned gate command that re-invokes the
|
|
477
650
|
// bridge with the DEFAULT runner short-circuits so it can't fork forever; an injected `run` is
|
|
478
651
|
// a test double that spawns nothing, so it bypasses the guard.
|
|
479
652
|
const injected = run !== undefined;
|
|
480
|
-
if (!injected && process.env.SPEC_BRIDGE_GATE_ACTIVE === "1") return problems;
|
|
653
|
+
if (!injected && process.env.SPEC_BRIDGE_GATE_ACTIVE === "1") return { problems: [], warnings: [] };
|
|
481
654
|
const requireAnalysis = config.strictDone === true;
|
|
482
655
|
// Share each distinct gate result across every spec this invocation checks (spec 050 defect 2).
|
|
483
656
|
const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root })));
|
|
657
|
+
// Tally counts per bucket (spec 061 R1/R4): `required` covers every ticked spec; `redByConstruction`
|
|
658
|
+
// only the ticked specs that are ALSO Done-eligible — the bucket asymmetry, preserved exactly as
|
|
659
|
+
// before (which gates run for which specs is unchanged; only the reporting collapses).
|
|
660
|
+
const counts = { required: 0, redByConstruction: 0 };
|
|
484
661
|
for (const task of boardLinks(root)) {
|
|
485
662
|
const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
|
|
486
663
|
const anyTicked = (derived.phaseBoxes || []).some((p) => (p.boxes || []).some((b) => b.checked));
|
|
487
664
|
if (!anyTicked) continue; // nothing claims greenness yet — no tick to outrun a gate
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
: ["required"];
|
|
491
|
-
const gates = gatesFor(gatesProfile, buckets);
|
|
492
|
-
problems.push(
|
|
493
|
-
...evaluateProjectGates(
|
|
494
|
-
{ id: task.id, specDir: task.specDir, phaseBoxes: derived.phaseBoxes }, gates, runOne)
|
|
495
|
-
);
|
|
665
|
+
counts.required += 1;
|
|
666
|
+
if (derived.status === STATUS.DONE_ELIGIBLE) counts.redByConstruction += 1;
|
|
496
667
|
}
|
|
497
|
-
|
|
668
|
+
const gates = gatesFor(gatesProfile, ["required", "redByConstruction"]);
|
|
669
|
+
const found = collapsedGateProblems(gates, counts, runOne);
|
|
670
|
+
if (found.length === 0) return { problems: [], warnings: [] };
|
|
671
|
+
// spec 061 T018a: same dirty-tree routing as checkBridge — labeled and non-blocking, never
|
|
672
|
+
// silently dropped. Computed only once found.length > 0, matching checkBridge's shape.
|
|
673
|
+
if (isDirty(root)) return { problems: [], warnings: found.map((p) => p + DIRTY_TREE_CLAUSE) };
|
|
674
|
+
return { problems: found, warnings: [] };
|
|
498
675
|
}
|
|
499
676
|
|
|
500
677
|
/* ── plan: reconciliation intents, and the Backlog renderer for them ───── */
|
|
@@ -656,17 +833,59 @@ export function planBridge(root) {
|
|
|
656
833
|
};
|
|
657
834
|
}
|
|
658
835
|
|
|
836
|
+
// spec 061 R2/T011: `bridgeGate.warn` deliberately calls `checkBridge(root, { runGates: false })`
|
|
837
|
+
// so a Stop pays for the gate subprocesses once, not twice (the cost regression spec 050
|
|
838
|
+
// fixed). A dirty-tree gate warning is only knowable from the runGates:true pass, so `check`
|
|
839
|
+
// stashes it here, keyed by root, for `warn` to read-and-clear on the very next call for that
|
|
840
|
+
// same root (lib/gate-runner.mjs calls check(root) then warn(root) in that order, per root, in
|
|
841
|
+
// one evaluate() pass — see gate-runner.mjs's evaluate loop). No second gate run, ever.
|
|
842
|
+
const dirtyTreeGateWarningsByRoot = new Map();
|
|
843
|
+
|
|
844
|
+
// spec 061 R4: the full roots list from the MOST RECENT resolveRoots() call, read (not
|
|
845
|
+
// consumed) by check() so its trace record can show what resolveRoots returned this
|
|
846
|
+
// invocation — round 7's candidate mechanism (an orphaned worktree resolving as a second
|
|
847
|
+
// root) is only visible in that full list, not in the single root check() is handed.
|
|
848
|
+
let lastResolvedRootsForTrace = null;
|
|
849
|
+
|
|
659
850
|
/**
|
|
660
851
|
* The Stop-hook gate, in gate-runner shape. Roots are directories holding a backlog/ dir;
|
|
661
852
|
* a root with no linked tasks yields no problems, so the gate is a natural no-op outside
|
|
662
853
|
* bridged projects. "exceeds" blocks; "lags" only warns.
|
|
854
|
+
*
|
|
855
|
+
* `check`/`warn` take an optional second arg beyond gate-runner's own `(root, ctx)` call —
|
|
856
|
+
* gate-runner's `ctx` is always `{ sessionId, input }` (spec 061 Phase 3b: neither key is
|
|
857
|
+
* named `gateActive`), so a real invocation's `opts.gateActive` is always `undefined` and
|
|
858
|
+
* `checkBridge`'s own default (the real env read) decides, exactly as before. Only a test that
|
|
859
|
+
* explicitly calls e.g. `bridgeGate.check(root, { gateActive: false })` can force the DEFAULT
|
|
860
|
+
* runner to execute under an ambient SPEC_BRIDGE_GATE_ACTIVE=1 — see `checkBridge`'s `gateActive`
|
|
861
|
+
* doc above for why that can't be reached by anything spawned as a real gate command.
|
|
663
862
|
*/
|
|
664
863
|
export const bridgeGate = {
|
|
665
864
|
name: "spec-bridge",
|
|
666
|
-
resolveRoots: (startDir) =>
|
|
865
|
+
resolveRoots: (startDir) => {
|
|
866
|
+
const roots = findRootsDownwards(startDir, hasAnyChild(".board", "backlog"));
|
|
867
|
+
lastResolvedRootsForTrace = roots;
|
|
868
|
+
return roots;
|
|
869
|
+
},
|
|
667
870
|
// The runner calls check() then warn() per root; run the (possibly costly) project-gate
|
|
668
871
|
// commands only in check so a Stop pays for them once, not twice. Warnings never depend on
|
|
669
|
-
// gate execution, so runGates:false loses nothing
|
|
670
|
-
|
|
671
|
-
|
|
872
|
+
// gate execution, so runGates:false loses nothing — except the dirty-tree gate label (spec
|
|
873
|
+
// 061 R2), which check() computed for free as part of running the gates and stashes below.
|
|
874
|
+
check: (root, opts = {}) => {
|
|
875
|
+
const path = tracePath(); // spec 061 R4: null unless SPEC_BRIDGE_GATE_TRACE is set
|
|
876
|
+
const commandRecords = path ? [] : null;
|
|
877
|
+
const { problems, warnings } = checkBridge(root, {
|
|
878
|
+
runGates: true,
|
|
879
|
+
trace: commandRecords ? (rec) => commandRecords.push(rec) : undefined,
|
|
880
|
+
gateActive: opts.gateActive,
|
|
881
|
+
});
|
|
882
|
+
dirtyTreeGateWarningsByRoot.set(root, warnings.filter((w) => w.includes(DIRTY_TREE_CLAUSE)));
|
|
883
|
+
if (commandRecords) traceGateRun(root, lastResolvedRootsForTrace, commandRecords);
|
|
884
|
+
return problems;
|
|
885
|
+
},
|
|
886
|
+
warn: (root, opts = {}) => {
|
|
887
|
+
const stashed = dirtyTreeGateWarningsByRoot.get(root) || [];
|
|
888
|
+
dirtyTreeGateWarningsByRoot.delete(root);
|
|
889
|
+
return [...checkBridge(root, { runGates: false, gateActive: opts.gateActive }).warnings, ...stashed];
|
|
890
|
+
},
|
|
672
891
|
};
|
|
@@ -41,7 +41,10 @@ if (cmd === "state") {
|
|
|
41
41
|
}
|
|
42
42
|
console.log(`spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts`);
|
|
43
43
|
} else if (cmd === "verify") {
|
|
44
|
-
|
|
44
|
+
// spec 061 T018a: verifyBridge now returns { problems, warnings } — a dirty-tree gate finding
|
|
45
|
+
// is never dropped, only routed to warnings (printed, non-blocking) instead of problems.
|
|
46
|
+
const { problems, warnings } = verifyBridge(target);
|
|
47
|
+
for (const w of warnings) console.log(`warn: ${w}`);
|
|
45
48
|
if (problems.length) {
|
|
46
49
|
console.log(`\nGATE FAILED (${problems.length} issue(s)):`);
|
|
47
50
|
for (const p of problems) console.log(` - ${p}`);
|