shapeup-sdlc 3.7.12 → 3.9.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.
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // T0 mechanical verification layer.
3
3
  //
4
- // Runs a scope's e2e fixtures + DB probe (zero LLM tokens), then — on green — the seesaw
5
- // regression check (re-runs every FINISHED scope's fixtures from the registry). Writes one
4
+ // Runs a scope's e2e fixtures and its DB probe (zero LLM tokens). Writes one
6
5
  // verdict artifact per attempt that spec-evaluator (T1) must cite; a verdict without it is
7
6
  // structurally invalid. No agent can fabricate this file's contents
8
7
  // because it is produced by actually running the commands.
@@ -30,7 +29,7 @@
30
29
  //
31
30
  // Usage:
32
31
  // node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" verify t0 <scope-contract.json> \
33
- // --round N --attempt M [--cwd <dir>] [--out <dir>] [--seesaw-registry <path>] [--no-seesaw]
32
+ // --round N --attempt M [--cwd <dir>] [--out <dir>]
34
33
  // [--no-ratchet]
35
34
  //
36
35
  // Exit code: 0 = overall green, 1 = overall red (mirrors the oracle convention), 2 = bad argv.
@@ -185,80 +184,45 @@ export function runDbProbe(dbProbeCmd, cwd) {
185
184
  }
186
185
 
187
186
  /**
188
- * Re-run every FINISHED scope's fixtures from the seesaw registry (regression guard).
189
- * @param {(string|null)} registryPath - Path to the seesaw registry JSON (absent/unreadable → skipped).
190
- * @param {string} cwd - Working directory.
191
- * @returns {{ran:boolean, pass:boolean, scopes_checked:string[], failing:string[], error?:string}}
192
- * ran=false/pass=true when skipped; otherwise pass=true iff no prior scope regressed, with the
193
- * scope ids checked and those now failing.
194
- */
195
- export function seesawCheck(registryPath, cwd) {
196
- if (!registryPath || !existsSync(registryPath)) {
197
- // `pass: null`, not `pass: true`: a check that did not run has no result, and recording one as
198
- // clean is how "not asked" came to read as "nothing regressed". The verdict below still treats
199
- // an absent seesaw as non-blocking — that part is deliberate while the arm is unwired — but the
200
- // artifact now says which of the two it was.
201
- return { ran: false, pass: null, scopes_checked: [], failing: [] };
202
- }
203
- let registry;
204
- try {
205
- registry = JSON.parse(readFileSync(registryPath, "utf8"));
206
- } catch {
207
- return { ran: false, pass: null, scopes_checked: [], failing: [], error: "registry unparsable" };
208
- }
209
- const scopes = registry.scopes || [];
210
- const failing = [];
211
- for (const s of scopes) {
212
- const { pass } = runFixtures(s.fixtures, cwd);
213
- if (!pass) failing.push(s.scope_id);
214
- }
215
- return { ran: true, pass: failing.length === 0, scopes_checked: scopes.map((s) => s.scope_id), failing };
216
- }
217
-
218
- /**
219
- * Combine fixtures + DB probe + seesaw into the overall T0 verdict.
220
- * @param {{fixtures:{pass:boolean}, dbProbe:({pass:boolean}|null),
221
- * seesaw:{ran:boolean,pass:boolean}}} parts - The three sub-results.
222
- * @returns {{fixtures_green:boolean, db_probe_green:boolean, seesaw_green:boolean,
223
- * overall:("green"|"red"), regression:boolean}} Per-arm greens, the overall verdict (green iff
224
- * all three), and `regression` = fixtures+db green but seesaw red (the rollback-and-retry case).
187
+ * Combine the fixtures and the DB probe into the overall T0 verdict.
188
+ *
189
+ * THE SEESAW ARM IS GONE (3.8.0), by a Betting Table decision rather than by neglect. It was
190
+ * declared in the schema, the docs and this function, and nothing ever wrote the registry it read,
191
+ * so it never ran once in any recorded run — while its absence held the hill's top phase shut:
192
+ * FINISHED required `seesaw.ran && seesaw.pass`, and the 38 committed hill shards across the live
193
+ * consumer's features contain no FINISHED at all. A cross-scope regression is still caught by the
194
+ * round build gate, which builds and launches the whole feature once per round; what the arm would
195
+ * have added is attribution and an earlier signal, at the price of re-running every finished
196
+ * scope's fixtures on every attempt — minutes per attempt on an eighteen-scope feature.
197
+ *
198
+ * @param {{fixtures:{pass:boolean}, dbProbe:({pass:boolean}|null)}} parts - The two sub-results.
199
+ * @returns {{fixtures_green:boolean, db_probe_green:boolean, overall:("green"|"red")}} Per-arm
200
+ * greens and the overall verdict, green iff both.
225
201
  */
226
- export function computeVerdict({ fixtures, dbProbe, seesaw }) {
202
+ export function computeVerdict({ fixtures, dbProbe }) {
227
203
  const fixturesGreen = fixtures.pass;
228
204
  const dbGreen = dbProbe === null || dbProbe.pass;
229
- // A seesaw that did not run does not hold the verdict red — the arm is declared and unwired, and
230
- // blocking every build on it would be a different defect. It does not make it green either: the
231
- // hill requires `ran && pass` before a scope may reach FINISHED, and `seesaw_green` here means
232
- // "nothing this check found is wrong", which is true of a check that found nothing because it
233
- // never looked.
234
- const seesawGreen = seesaw.ran ? seesaw.pass === true : true;
235
205
  return {
236
206
  fixtures_green: fixturesGreen,
237
207
  db_probe_green: dbGreen,
238
- seesaw_green: seesawGreen,
239
- overall: fixturesGreen && dbGreen && seesawGreen ? "green" : "red",
240
- // A regression is specifically fixtures/db green but seesaw red — the case that should
241
- // trigger rollback+retry (spec §3.5) rather than "go fix the new scope's own bug".
242
- regression: fixturesGreen && dbGreen && !seesawGreen,
208
+ overall: fixturesGreen && dbGreen ? "green" : "red",
243
209
  };
244
210
  }
245
211
 
246
212
  /**
247
- * The comparable T0 outcome — a VECTOR, not a float, because the three arms are not fungible.
213
+ * The comparable T0 outcome — a VECTOR, not a float, because the arms are not fungible.
248
214
  *
249
215
  * Every number here is a reduce over data `writeArtifact` already persists (`fixtures:
250
216
  * [{cmd, exit, pass}]`). Nothing new is measured; a number that has always been on disk is
251
217
  * finally counted.
252
218
  *
253
- * @param {{fixtures:{results:Array<{pass:boolean}>}, dbProbe:({pass:boolean}|null),
254
- * seesaw:{ran:boolean, failing:string[]}}} parts - The three T0 sub-results.
255
- * @returns {{regressions:number, fixtures_passed:number, fixtures_total:number,
256
- * db_probe:(0|1|null)}} The score vector. `db_probe` is null when no probe is declared, which
257
- * is never a failure — only an absence.
219
+ * @param {{fixtures:{results:Array<{pass:boolean}>}, dbProbe:({pass:boolean}|null)}} parts - The
220
+ * two T0 sub-results.
221
+ * @returns {{fixtures_passed:number, fixtures_total:number, db_probe:(0|1|null)}} The score vector.
222
+ * `db_probe` is null when no probe is declared, which is never a failure — only an absence.
258
223
  */
259
- export function score({ fixtures, dbProbe, seesaw }) {
224
+ export function score({ fixtures, dbProbe }) {
260
225
  return {
261
- regressions: seesaw?.ran ? (seesaw.failing || []).length : 0,
262
226
  fixtures_passed: fixtures.results.filter((r) => r.pass).length,
263
227
  fixtures_total: fixtures.results.length,
264
228
  db_probe: dbProbe === null || dbProbe === undefined ? null : (dbProbe.pass ? 1 : 0),
@@ -271,23 +235,19 @@ export function score({ fixtures, dbProbe, seesaw }) {
271
235
  * Three decisions worth defending:
272
236
  * • A TIE IS NOT BETTER. A tie that counted as an improvement would make a sawtooth look like a
273
237
  * ratchet, and the whole point of the Day-1 measurement is to tell those two apart.
274
- * • REGRESSIONS DOMINATE. Breaking a previously-finished scope is never an improvement, whatever
275
- * the new scope's fixtures did. This is what lets the old seesaw branch collapse into the
276
- * general rule rather than needing a special case.
277
238
  * • DIFFERENT `fixtures_total` IS INCOMPARABLE, not worse. A re-slice changes the
278
239
  * denominator; comparing across it is a category error, so the ratchet treats it as a baseline
279
240
  * reset (`rebased`) rather than issuing a false verdict.
280
241
  *
281
- * @param {{regressions:number, fixtures_passed:number, fixtures_total:number,
242
+ * @param {{fixtures_passed:number, fixtures_total:number,
282
243
  * db_probe:(0|1|null)}} next - The candidate score.
283
- * @param {({regressions:number, fixtures_passed:number, fixtures_total:number,
244
+ * @param {({fixtures_passed:number, fixtures_total:number,
284
245
  * db_probe:(0|1|null)}|null)} current - The incumbent score, or null for the first trial.
285
246
  * @returns {(boolean|null)} true = strictly better · false = not better · null = incomparable.
286
247
  */
287
248
  export function better(next, current) {
288
249
  if (current === null || current === undefined) return true; // baseline
289
250
  if (next.fixtures_total !== current.fixtures_total) return null; // the contract changed
290
- if (next.regressions !== current.regressions) return next.regressions < current.regressions;
291
251
  if (next.fixtures_passed !== current.fixtures_passed) return next.fixtures_passed > current.fixtures_passed;
292
252
  if (next.db_probe !== current.db_probe) return (next.db_probe ?? 0) > (current.db_probe ?? 0);
293
253
  // EVERY COMPONENT TIES. What that means depends entirely on whether the incumbent was green.
@@ -314,17 +274,17 @@ export function better(next, current) {
314
274
  }
315
275
 
316
276
  /**
317
- * Is this score a clean pass — every fixture passing, none of them absent, no outstanding regression?
277
+ * Is this score a clean pass — every fixture passing, and none of them absent?
318
278
  *
319
279
  * `fixtures_total > 0` is load-bearing: a scope with no fixtures has nothing to be green ABOUT, and
320
280
  * treating its empty score as a pass is the same absence-reads-as-success mistake `runFixtures`
321
281
  * made one function above.
322
282
  *
323
- * @param {{regressions:number, fixtures_passed:number, fixtures_total:number}} s - A trial score.
283
+ * @param {{fixtures_passed:number, fixtures_total:number}} s - A trial score.
324
284
  * @returns {boolean} True when the score represents a real, complete pass.
325
285
  */
326
286
  function isGreenScore(s) {
327
- return s.regressions === 0 && s.fixtures_total > 0 && s.fixtures_passed === s.fixtures_total;
287
+ return s.fixtures_total > 0 && s.fixtures_passed === s.fixtures_total;
328
288
  }
329
289
 
330
290
  /**
@@ -352,7 +312,7 @@ export function decideStatus(verdict, crashed) {
352
312
  * Human-readable one-line summary of a score change, for the trial row's `delta` field.
353
313
  * @param {object} next - The candidate score.
354
314
  * @param {(object|null)} current - The incumbent score, or null.
355
- * @returns {string} e.g. "+2 fixtures", "+1 regression", "baseline", "no change".
315
+ * @returns {string} e.g. "+2 fixtures", "-1 db_probe", "baseline", "no change".
356
316
  */
357
317
  export function describeDelta(next, current) {
358
318
  if (!current) return "baseline";
@@ -360,10 +320,8 @@ export function describeDelta(next, current) {
360
320
  return `denominator ${current.fixtures_total} → ${next.fixtures_total}`;
361
321
  }
362
322
  const parts = [];
363
- const dr = next.regressions - current.regressions;
364
323
  const df = next.fixtures_passed - current.fixtures_passed;
365
324
  const dp = (next.db_probe ?? 0) - (current.db_probe ?? 0);
366
- if (dr) parts.push(`${dr > 0 ? "+" : ""}${dr} regression${Math.abs(dr) === 1 ? "" : "s"}`);
367
325
  if (df) parts.push(`${df > 0 ? "+" : ""}${df} fixture${Math.abs(df) === 1 ? "" : "s"}`);
368
326
  if (dp) parts.push(`${dp > 0 ? "+" : ""}${dp} db_probe`);
369
327
  return parts.length ? parts.join(", ") : "no change";
@@ -458,7 +416,7 @@ function sha256(text) {
458
416
  * every superseded object remains addressable).
459
417
  *
460
418
  * WHAT THIS REPLACED, and why the remedy is `wx` rather than a guard. The address used to be
461
- * `r<round>-a<attempt>.json`, written with a bare `writeFileSync` — and on a seesaw regression the
419
+ * `r<round>-a<attempt>.json`, written with a bare `writeFileSync` — and on a revert-and-retry the
462
420
  * protocol says stash, then RETRY THIS ATTEMPT, same attempt number. The address had no term for
463
421
  * the retry, so the artifact recording the regression was silently replaced by the one recording
464
422
  * the recovery, at the same path. Reproduced against the shipped script: two runs at
@@ -506,14 +464,12 @@ export function writeArtifact(outDir, round, attempt, verdictBody) {
506
464
  /** The typed argv contract (see `./lib/argv.mjs`). */
507
465
  export const ARGV_SPEC = {
508
466
  usage: "harness.mjs verify t0 <scope-contract.json> --round N --attempt M [--cwd <dir>] [--out <dir>] " +
509
- "[--seesaw-registry <path>] [--no-seesaw] [--no-ratchet]",
467
+ "[--no-ratchet]",
510
468
  _: { arity: 1, max: 1, name: "scope-contract.json" },
511
469
  round: { type: "int", min: 1, required: true },
512
470
  attempt: { type: "int", min: 1, required: true },
513
471
  cwd: { type: "path" },
514
472
  out: { type: "path" },
515
- "seesaw-registry": { type: "path" },
516
- "no-seesaw": { type: "flag" },
517
473
  "no-ratchet": { type: "flag" },
518
474
  };
519
475
 
@@ -556,14 +512,7 @@ export async function cli(rawArgv) {
556
512
 
557
513
  const fixtures = runFixtures(contract.e2e_verification_fixtures, cwd);
558
514
  const dbProbe = runDbProbe(contract.db_probe, cwd);
559
- // --seesaw-registry is expected explicitly (tech-lead always passes it, protocol.md 3c);
560
- // standalone CLI use without it simply skips the seesaw check rather than guessing a path.
561
- const seesawRegistry = args.noSeesaw ? null : args.seesawRegistry || null;
562
- const seesaw = args.noSeesaw || fixtures.pass === false
563
- ? { ran: false, pass: true, scopes_checked: [], failing: [] } // don't seesaw on an already-red attempt
564
- : seesawCheck(seesawRegistry, cwd);
565
-
566
- const verdict = computeVerdict({ fixtures, dbProbe, seesaw });
515
+ const verdict = computeVerdict({ fixtures, dbProbe });
567
516
  const discovered = verdict.overall === "red" ? digestFailures({ fixtures, dbProbe }) : [];
568
517
 
569
518
  // ---- the ratchet ---------------------------------------------------------------------
@@ -573,7 +522,7 @@ export async function cli(rawArgv) {
573
522
  const trialsPath = join(outDir, "t0", "trials.jsonl");
574
523
  const priorTrials = readTrials(trialsPath).filter((t) => t.scope_id === contract.scope_id);
575
524
  const baseline = [...priorTrials].reverse().find((t) => t.status === "kept" || t.status === "rebased") || null;
576
- const s = score({ fixtures, dbProbe, seesaw });
525
+ const s = score({ fixtures, dbProbe });
577
526
  const verdictBetter = better(s, baseline ? baseline.score : null);
578
527
  const crashed = fixtures.results.some((r) => r.error) || !!dbProbe?.error;
579
528
  const { status, action } = decideStatus(verdictBetter, crashed);
@@ -598,7 +547,6 @@ export async function cli(rawArgv) {
598
547
  // could not tell apart, and why `exit` still reads the way it always did.
599
548
  fixtures: fixtures.results.map((r) => commandEvidence(r)),
600
549
  db_probe: commandEvidence(dbProbe),
601
- seesaw,
602
550
  ...verdict,
603
551
  score: s,
604
552
  discovered_tasks: discovered,
@@ -654,7 +602,7 @@ export async function cli(rawArgv) {
654
602
  appendTrial(trialsPath, row);
655
603
 
656
604
  console.log(JSON.stringify({
657
- path, sha256: hash, trial, overall: verdict.overall, regression: verdict.regression,
605
+ path, sha256: hash, trial, overall: verdict.overall,
658
606
  score: s, status, baseline_trial: row.baseline_trial, delta: row.delta,
659
607
  tree_ref: row.tree_ref ?? keptRef(contract.scope_id),
660
608
  }, null, 2));
@@ -12,7 +12,11 @@
12
12
  // `entry_point` via the import graph (0 import sites) is RED. This catches the *dead module*
13
13
  // (631 lines, 26 passing tests, zero call sites), not a *dead data-path* (§2 honest boundary
14
14
  // → §4.4). Entry point is PROFILE-GATED, never hardcoded (main.js for a game is not the seam
15
- // for a web-service).
15
+ // for a web-service). It reports `checked: false` with a reason rather than a verdict when it
16
+ // cannot root the walk: an import it could not follow (the graph is missing edges, so nothing
17
+ // about a destination follows from not arriving there), or no reachable engine at all (with
18
+ // no positive control, an orphaned module and a wrong entry point are the same evidence —
19
+ // and a framework that registers screens by name produces the second on every run).
16
20
  //
17
21
  // Governing rule: if a script can't check it, it's decoration. This script checks a deletion and
18
22
  // an orphan — both provable from files, zero LLM tokens. What it deliberately does NOT assert:
@@ -39,8 +43,9 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSy
39
43
  import { resolve, join, dirname, relative, isAbsolute } from "node:path";
40
44
  import { readBoard } from "../compile.mjs";
41
45
  import { runArgs } from "../lib/argv.mjs";
42
- import { sharedRoot, traceDir, relLocal } from "../lib/paths.mjs";
43
- import { readContract, unreadableReason, LEGACY_LAYOUT, WIRING_MAP, PROJECT_PROFILE, reqId } from "../lib/contract.mjs";
46
+ import { sharedRoot, traceDir, relLocal, scopesDir } from "../lib/paths.mjs";
47
+ import { globToRegExp } from "./spec.mjs";
48
+ import { readContract, readAllContracts, unreadableReason, LEGACY_LAYOUT, WIRING_MAP, PROJECT_PROFILE, SCOPE_CONTRACT, reqId } from "../lib/contract.mjs";
44
49
 
45
50
  // --- requirements.md registry parser -----------------------------------------
46
51
  // A committed markdown table: | REQ-id | clause (verbatim) | source | status | note |
@@ -97,37 +102,81 @@ export function coveredReqIds(board) {
97
102
  }
98
103
 
99
104
  // --- import-graph reachability -----------------------------------------------
100
- const SOURCE_EXTS = [".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"];
105
+ //
106
+ // THE RESOLVER KNOWS ONE FAMILY OF LANGUAGES, AND IT MUST SAY SO. This list is the JS/TS family and
107
+ // nothing else, which is correct for the stacks it was written against and silently wrong for any
108
+ // other: a stack whose modules end in something else resolves no relative import at all, the walk
109
+ // stops at the entry file, and every engine then looks "never imported from the entry point" — a
110
+ // red verdict on every input, reported as a check that ran. Two things keep that from happening:
111
+ // the extension set is widened from what the project actually declares (below), and a walk that
112
+ // could not follow an edge reports itself unchecked instead of reporting the destination missing.
113
+ const DEFAULT_SOURCE_EXTS = [".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"];
101
114
  const IMPORT_RE = /(?:\bimport\b[^'"]*?from\s*|\bimport\s*|\bexport\b[^'"]*?from\s*|\brequire\s*\(\s*|\bimport\s*\()\s*['"]([^'"]+)['"]/g;
102
115
 
116
+ /**
117
+ * The module extensions this project's import graph is walked with.
118
+ *
119
+ * Widened two ways, both from declarations rather than from a guess: the project profile may state
120
+ * `source_extensions` outright, and the entry point's own suffix is always a module extension of
121
+ * this project by construction — it is the one file the profile names and the walk starts from.
122
+ *
123
+ * @param {(object|null)} profile - The parsed ProjectProfile, or null.
124
+ * @param {(string|null)} entryPoint - The declared entry-point path, or null.
125
+ * @returns {{exts:string[], declared:string[], from_entry:(string|null)}} The extension set the
126
+ * walk uses, plus what each widening contributed (reported, so a reader can see why it resolved).
127
+ */
128
+ export function sourceExtensions(profile, entryPoint) {
129
+ const raw = profile?.source_extensions ?? profile?.module_extensions ?? null;
130
+ const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[,\s]+/) : [];
131
+ const declared = list
132
+ .map((e) => String(e).trim())
133
+ .filter(Boolean)
134
+ .map((e) => (e.startsWith(".") ? e : "." + e));
135
+ const m = /(\.[A-Za-z0-9]+)$/.exec(entryPoint || "");
136
+ const fromEntry = m ? m[1] : null;
137
+ const exts = [...new Set([...DEFAULT_SOURCE_EXTS, ...declared, ...(fromEntry ? [fromEntry] : [])])];
138
+ return { exts, declared, from_entry: fromEntry };
139
+ }
140
+
103
141
  /**
104
142
  * Resolve a relative import specifier to a repo-relative source file.
143
+ *
144
+ * The three answers are kept apart on purpose. A bare specifier is out of the app graph by design;
145
+ * a relative specifier carrying a non-module suffix (`./styles.css`, `./data.json`) is an asset,
146
+ * which imports nothing and can never be an engine; and a relative specifier that looks like a
147
+ * module but resolves to no file is an edge the walk could not follow — the one case that makes
148
+ * the resulting graph incomplete, and the caller has to be able to see it.
149
+ *
105
150
  * @param {string} fromFileAbs - Absolute path of the importing file.
106
151
  * @param {string} spec - The import specifier string.
107
152
  * @param {string} cwd - Repo root the result is made relative to.
108
- * @returns {(string|null)} The repo-relative source path (trying source extensions and `/index`),
109
- * or null for a bare specifier (node_modules) or an unresolved path.
153
+ * @param {string[]} exts - The module extensions of this project (see `sourceExtensions`).
154
+ * @returns {{file:(string|null), kind:("bare"|"asset"|"resolved"|"unresolved")}} The repo-relative
155
+ * source path when one was found, and which of the four answers this was.
110
156
  */
111
- function resolveSpecifier(fromFileAbs, spec, cwd) {
112
- if (!spec.startsWith(".")) return null; // bare specifier → node_modules, out of the app graph
157
+ function resolveSpecifier(fromFileAbs, spec, cwd, exts) {
158
+ if (!spec.startsWith(".")) return { file: null, kind: "bare" }; // node_modules, out of the app graph
159
+ const suffix = /(\.[A-Za-z0-9]+)$/.exec(spec);
113
160
  const baseAbs = resolve(dirname(fromFileAbs), spec);
114
- const candidates = [baseAbs, ...SOURCE_EXTS.map((e) => baseAbs + e), ...SOURCE_EXTS.map((e) => join(baseAbs, "index" + e))];
161
+ const candidates = [baseAbs, ...exts.map((e) => baseAbs + e), ...exts.map((e) => join(baseAbs, "index" + e))];
115
162
  for (const c of candidates) {
116
- if (existsSync(c) && statSync(c).isFile()) return relative(cwd, c).split("\\").join("/");
163
+ if (existsSync(c) && statSync(c).isFile()) return { file: relative(cwd, c).split("\\").join("/"), kind: "resolved" };
117
164
  }
118
- return null;
165
+ if (suffix && !exts.includes(suffix[1])) return { file: null, kind: "asset" };
166
+ return { file: null, kind: "unresolved" };
119
167
  }
120
168
 
121
169
  /**
122
170
  * Normalize a declared path (entry_point / engine) to an existing repo-relative source file.
123
171
  * @param {string} p - The declared path (absolute or cwd-relative).
124
172
  * @param {string} cwd - Repo root the result is made relative to.
173
+ * @param {string[]} [exts] - The module extensions to try (defaults to the JS/TS family).
125
174
  * @returns {(string|null)} The repo-relative source path (trying source extensions and `/index`),
126
175
  * or null when nothing on disk matches.
127
176
  */
128
- function resolveFile(p, cwd) {
177
+ function resolveFile(p, cwd, exts = DEFAULT_SOURCE_EXTS) {
129
178
  const abs = isAbsolute(p) ? p : resolve(cwd, p);
130
- const candidates = [abs, ...SOURCE_EXTS.map((e) => abs + e), ...SOURCE_EXTS.map((e) => join(abs, "index" + e))];
179
+ const candidates = [abs, ...exts.map((e) => abs + e), ...exts.map((e) => join(abs, "index" + e))];
131
180
  for (const c of candidates) {
132
181
  if (existsSync(c) && statSync(c).isFile()) return relative(cwd, c).split("\\").join("/");
133
182
  }
@@ -149,27 +198,37 @@ function importsOf(fileAbs) {
149
198
 
150
199
  /**
151
200
  * BFS the import graph from an entry point.
201
+ *
202
+ * Reports the edges it could NOT follow alongside the set it built. "This module is never imported"
203
+ * is only a supportable claim over a graph with every edge in it; over a graph missing edges it is
204
+ * indistinguishable from "the walker could not read this language", and the second must not be
205
+ * published as the first.
206
+ *
152
207
  * @param {string} entryRel - The entry-point path (declared form; resolved on disk).
153
208
  * @param {string} cwd - Repo root.
154
- * @returns {{reachable:Set<string>, entryResolved:(string|null)}} The set of repo-relative files
155
- * reachable from the entry, and the resolved entry path (null when the entry is not on disk, in
156
- * which case `reachable` is empty).
209
+ * @param {string[]} [exts] - The module extensions of this project (defaults to the JS/TS family).
210
+ * @returns {{reachable:Set<string>, entryResolved:(string|null), unresolved:Array<{from:string,
211
+ * spec:string}>}} The set of repo-relative files reachable from the entry, the resolved entry
212
+ * path (null when the entry is not on disk, in which case `reachable` is empty), and every
213
+ * module-shaped relative specifier that resolved to no file.
157
214
  */
158
- export function reachableFrom(entryRel, cwd) {
215
+ export function reachableFrom(entryRel, cwd, exts = DEFAULT_SOURCE_EXTS) {
159
216
  const reachable = new Set();
160
- const start = resolveFile(entryRel, cwd);
161
- if (!start) return { reachable, entryResolved: null };
217
+ const unresolved = [];
218
+ const start = resolveFile(entryRel, cwd, exts);
219
+ if (!start) return { reachable, entryResolved: null, unresolved };
162
220
  const queue = [start];
163
221
  reachable.add(start);
164
222
  while (queue.length) {
165
223
  const cur = queue.shift();
166
224
  const curAbs = resolve(cwd, cur);
167
225
  for (const spec of importsOf(curAbs)) {
168
- const dep = resolveSpecifier(curAbs, spec, cwd);
226
+ const { file: dep, kind } = resolveSpecifier(curAbs, spec, cwd, exts);
227
+ if (kind === "unresolved") unresolved.push({ from: cur, spec });
169
228
  if (dep && !reachable.has(dep)) { reachable.add(dep); queue.push(dep); }
170
229
  }
171
230
  }
172
- return { reachable, entryResolved: start };
231
+ return { reachable, entryResolved: start, unresolved };
173
232
  }
174
233
 
175
234
  // --- Mermaid view (a view of the checked graph, so it cannot drift — §2) ------
@@ -199,6 +258,76 @@ export function wiringMermaid(wiringMap, unreachableSet) {
199
258
  return lines.join("\n");
200
259
  }
201
260
 
261
+ // --- per-scope reachability ---------------------------------------------------
262
+ //
263
+ // A SCOPE'S BUILD FIXTURE PROVES NOTHING UNTIL THE SCOPE'S CODE IS REACHABLE, and on a toolchain
264
+ // that compiles only what the entry point reaches, the two come apart in the worst direction:
265
+ // three scopes were T0-green on an assemble fixture while their own files did not compile at all,
266
+ // and the errors surfaced only once a fourth scope — one that may not write those files — wired
267
+ // the screens in. The fixture was honest about what it ran. Nothing asked whether what it ran
268
+ // included the scope's work.
269
+ //
270
+ // This arm asks, and only ever warns. A scope legitimately owns resources, route maps, manifests,
271
+ // tests and files a later scope will wire, so *some* of its substrate sitting outside the import
272
+ // graph is the normal case and says nothing. What is worth a word is a scope with source files on
273
+ // disk and NOT ONE of them reachable: everything that scope contributes is outside the running
274
+ // app, which is the shape the defect had. Red would be wrong even then — the wiring may be the
275
+ // next scope's job by design, which is a plan the PO made, not a defect the oracle found.
276
+ const SKIP_DIRS = new Set([".git", "node_modules", "oh_modules", ".shapeup", "build", "dist", "out", ".idea", "coverage"]);
277
+
278
+ /**
279
+ * List the repo-relative source files under a root, by module extension.
280
+ * @param {string} root - Repo root; results are relative to it.
281
+ * @param {string[]} exts - The module extensions that make a file a source file.
282
+ * @returns {string[]} Repo-relative paths, build and dependency directories skipped.
283
+ */
284
+ function sourceFilesUnder(root, exts) {
285
+ const out = [];
286
+ /**
287
+ * Walk one directory, recursing into its subdirectories.
288
+ * @param {string} dir - Absolute directory to walk.
289
+ * @returns {void}
290
+ */
291
+ const walk = (dir) => {
292
+ let entries;
293
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
294
+ for (const e of entries) {
295
+ if (e.name.startsWith(".") && e.name !== ".") { if (SKIP_DIRS.has(e.name)) continue; }
296
+ if (SKIP_DIRS.has(e.name)) continue;
297
+ const abs = join(dir, e.name);
298
+ if (e.isDirectory()) walk(abs);
299
+ else if (exts.some((x) => e.name.endsWith(x))) out.push(relative(root, abs).split("\\").join("/"));
300
+ }
301
+ };
302
+ walk(root);
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * For each scope contract, how much of its own source substrate the app actually reaches.
308
+ *
309
+ * @param {Array<{id?:string, contract?:object}>} contracts - Scope contracts as `readAllContracts`
310
+ * returns them.
311
+ * @param {Set<string>} reachable - The repo-relative files reachable from the entry point.
312
+ * @param {string[]} sources - Every repo-relative source file on disk.
313
+ * @returns {Array<{scope_id:string, source_files:number, reachable_files:number}>} One row per
314
+ * scope that owns at least one source file on disk; scopes owning none are left out entirely,
315
+ * because a scope with nothing to reach is not a finding.
316
+ */
317
+ export function scopeReachability(contracts, reachable, sources) {
318
+ const rows = [];
319
+ for (const found of contracts) {
320
+ const c = found?.contract || found || {};
321
+ const id = c.scope_id || found?.id;
322
+ const globs = (c.allowed_file_substrate || []).map(globToRegExp);
323
+ if (!globs.length) continue;
324
+ const owned = sources.filter((f) => globs.some((r) => r.test(f)));
325
+ if (!owned.length) continue;
326
+ rows.push({ scope_id: id, source_files: owned.length, reachable_files: owned.filter((f) => reachable.has(f)).length });
327
+ }
328
+ return rows;
329
+ }
330
+
202
331
  // --- the oracle --------------------------------------------------------------
203
332
  /**
204
333
  * Run the covers-closure + reachability oracle for a slug.
@@ -262,6 +391,10 @@ export function traceLint(slug, { cwd, gate = false }) {
262
391
  const wiringPath = join(shared, "wiring-map.md");
263
392
  const profilePath = join(shared, "project-profile.md");
264
393
  let reachability = { checked: false, pass: true, unreachable: [], skipped_reason: "no wiring-map — reachability not applicable." };
394
+ // Hoisted so the per-scope arm below can reuse the walk this one already paid for. Both stay
395
+ // null unless reachability actually ran, which is what gates the second arm on the first.
396
+ let reachableSet = null;
397
+ let walkExts = null;
265
398
  let wiringMap = null;
266
399
 
267
400
  let wiringFound = null;
@@ -308,29 +441,95 @@ export function traceLint(slug, { cwd, gate = false }) {
308
441
  if (!entryPoint) {
309
442
  reachability = { checked: false, pass: true, unreachable: [], skipped_reason: "project-profile has no entry_point." };
310
443
  } else {
311
- const { reachable, entryResolved } = reachableFrom(entryPoint, cwd);
444
+ const { exts, declared, from_entry: fromEntry } = sourceExtensions(profile, entryPoint);
445
+ const { reachable, entryResolved, unresolved } = reachableFrom(entryPoint, cwd, exts);
312
446
  if (!entryResolved) {
313
447
  reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
314
448
  skipped_reason: `entry_point "${entryPoint}" does not resolve to a source file on disk — reachability skipped.` };
315
449
  findings.push({ severity: "warn", code: "ENTRY-MISSING", message: `project-profile.md entry_point "${entryPoint}" is not on disk — reachability cannot run.` });
450
+ } else if (unresolved.length) {
451
+ // AN INCOMPLETE GRAPH GRADES NOTHING. Every module-shaped relative import the walker could
452
+ // not follow is a missing edge, and a module is "unreachable" only in the sense that this
453
+ // walk did not get there. Publishing that as red produces a check that is red for every
454
+ // input on a stack the resolver cannot read, while reporting that it looked.
455
+ const sample = unresolved.slice(0, 3).map((u) => `${u.spec} (from ${u.from})`);
456
+ reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
457
+ entry_resolved: entryResolved, reachable_files: reachable.size,
458
+ module_extensions: exts, unresolved_imports: unresolved.length, unresolved_sample: sample,
459
+ skipped_reason: `${unresolved.length} relative import(s) from the entry point resolve to no file with the extensions this project declares (${exts.join(", ")}) — the import graph is incomplete, so "never imported" is not a claim this walk can support.` };
460
+ findings.push({ severity: "warn", code: "GRAPH-INCOMPLETE", message:
461
+ `reachability did not run: ${unresolved.length} relative import(s) could not be resolved (e.g. ${sample.join("; ")}). ` +
462
+ `The walker tried ${exts.join(", ")}${declared.length ? "" : " — the project profile declares no `source_extensions`, so the set is the JS/TS family plus the entry point's own suffix" + (fromEntry ? ` (${fromEntry})` : "")}. ` +
463
+ "Declare `source_extensions` in project-profile.md to let this arm run." });
316
464
  } else {
465
+ const engines = wiringMap.entries || [];
317
466
  const unreachable = [];
318
- for (const e of wiringMap.entries || []) {
319
- const engResolved = resolveFile(e.engine, cwd);
320
- const ok = engResolved ? reachable.has(engResolved) : false;
321
- if (!ok) {
467
+ for (const e of engines) {
468
+ const engResolved = resolveFile(e.engine, cwd, exts);
469
+ if (!(engResolved && reachable.has(engResolved))) {
322
470
  unreachable.push({ use_case: e.use_case, engine: e.engine, reason: engResolved ? "not imported from the entry point" : "engine file not on disk" });
323
- findings.push({ severity: "red", code: "UC-UNREACHABLE", uc: e.use_case,
324
- message: `${e.use_case}: engine "${e.engine}" is ${engResolved ? "never imported from" : "missing under"} entry_point "${entryPoint}" — the module ships orphaned from the running app.` });
325
471
  }
326
472
  }
327
- reachability = { checked: true, entry_point: entryPoint, entry_resolved: entryResolved,
328
- reachable_files: reachable.size, engines_total: (wiringMap.entries || []).length, unreachable, pass: unreachable.length === 0 };
473
+ // THE ARM NEEDS ONE POSITIVE CONTROL, AND EVERY-ENGINE-ORPHANED IS NOT ONE. What this
474
+ // check was built to catch is the dead module: one engine with no call site among
475
+ // siblings that have them. When NO engine is reachable, nothing demonstrates that this
476
+ // entry point is the root the app actually runs from — and for whole archetypes it is
477
+ // not. A framework that registers screens declaratively reaches them by name at runtime
478
+ // (`loadContent("pages/Index")`, a route map, a manifest), so its entry file imports a
479
+ // handful of modules and no engine, and the import graph is complete and beside the
480
+ // point. "Every engine is dead" and "I am walking the wrong tree" produce identical
481
+ // evidence, so the arm reports that it could not check rather than picking one.
482
+ //
483
+ // THE COST, NAMED: a wiring map with a single engine can no longer red, because its only
484
+ // engine being unreachable is exactly the indistinguishable case. A genuinely orphaned
485
+ // module in a one-use-case feature is therefore reported as unchecked, not as dead. That
486
+ // is the price of never being red for every input on a stack this walk cannot root.
487
+ if (engines.length && unreachable.length === engines.length) {
488
+ reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
489
+ entry_resolved: entryResolved, module_extensions: exts, reachable_files: reachable.size,
490
+ engines_total: engines.length, engines_reachable: 0,
491
+ skipped_reason: `no engine is reachable from entry_point "${entryPoint}", which reaches ${reachable.size} file(s) — with no reachable engine as a control this walk cannot tell an orphaned module from an entry point that is not the runtime root (declarative routing, a manifest, a string-loaded screen). Reachability skipped.` };
492
+ findings.push({ severity: "warn", code: "REACH-NO-CONTROL", message:
493
+ `reachability did not run: all ${engines.length} engine(s) are unreachable from entry_point "${entryPoint}", which reaches ${reachable.size} file(s). ` +
494
+ "Every engine orphaned is the one result this arm cannot distinguish from a wrong root — if this project wires its screens by name rather than by import, declare the module that does the wiring as the entry point." });
495
+ } else {
496
+ for (const u of unreachable) {
497
+ findings.push({ severity: "red", code: "UC-UNREACHABLE", uc: u.use_case,
498
+ message: `${u.use_case}: engine "${u.engine}" is ${u.reason === "engine file not on disk" ? "missing under" : "never imported from"} entry_point "${entryPoint}" — the module ships orphaned from the running app, while ${engines.length - unreachable.length} other engine(s) reach it.` });
499
+ }
500
+ reachableSet = reachable;
501
+ walkExts = exts;
502
+ reachability = { checked: true, entry_point: entryPoint, entry_resolved: entryResolved,
503
+ module_extensions: exts, reachable_files: reachable.size,
504
+ engines_total: engines.length, engines_reachable: engines.length - unreachable.length,
505
+ unreachable, pass: unreachable.length === 0 };
506
+ }
329
507
  }
330
508
  }
331
509
  }
332
510
  }
333
511
 
512
+ // --- per-scope reachability, gated on the first arm having actually run ---------------------
513
+ let scopeReach = { checked: false, scopes: [], orphaned: [],
514
+ skipped_reason: "reachability did not run, so there is no graph to measure a scope against." };
515
+ if (reachableSet) {
516
+ const contracts = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT);
517
+ if (!contracts.length) {
518
+ scopeReach = { checked: false, scopes: [], orphaned: [], skipped_reason: "no scope contracts on disk." };
519
+ } else {
520
+ const rows = scopeReachability(contracts, reachableSet, sourceFilesUnder(cwd, walkExts));
521
+ const orphaned = rows.filter((r) => r.reachable_files === 0);
522
+ scopeReach = { checked: true, scopes: rows, orphaned: orphaned.map((r) => r.scope_id) };
523
+ for (const r of orphaned) {
524
+ findings.push({ severity: "warn", code: "SCOPE-UNREACHABLE", scope: r.scope_id, message:
525
+ `scope ${r.scope_id}: none of its ${r.source_files} source file(s) is reached from the entry point. ` +
526
+ "A build fixture that compiles only what the entry point reaches can be green while this scope's own " +
527
+ "code never compiles — the errors then surface in whichever scope wires the screens in. Warn, not red: " +
528
+ "the wiring may legitimately be a later scope's job." });
529
+ }
530
+ }
531
+ }
532
+
334
533
  const overall = findings.some((f) => f.severity === "red") ? "red" : "green";
335
534
  const report = {
336
535
  schema_version: 1,
@@ -340,6 +539,7 @@ export function traceLint(slug, { cwd, gate = false }) {
340
539
  advisory: !gate,
341
540
  covers_closure: coversClosure,
342
541
  reachability,
542
+ scope_reachability: scopeReach,
343
543
  findings,
344
544
  overall,
345
545
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shapeup-sdlc",
3
- "version": "3.7.12",
3
+ "version": "3.9.0",
4
4
  "description": "Shape Up for coding agents \u2014 with gates the agent can't talk its way past. Harness for Claude Code.",
5
5
  "bin": {
6
6
  "shapeup-sdlc": "bin/init.mjs"
@@ -614,7 +614,7 @@ or entirely `apps/api/**` with no cross-layer flow is the PA1 failure mode — r
614
614
  }
615
615
  ```
616
616
  `hill_phase` is always written `UPHILL_UNKNOWN` at generation time — it is derived later from
617
- mechanical T0/T1/seesaw facts, never declared by `ba`. `superseded_by` stays
617
+ mechanical T0/T1 facts, never declared by `ba`. `superseded_by` stays
618
618
  `null` until a scope-architect `map-scopes` order retires this contract in favor of its replacements.
619
619
 
620
620
  **PA2 size lint:** a scope whose `allowed_file_substrate` glob set resolves to more than ~15