shapeup-sdlc 3.8.0 → 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,7 +1,7 @@
1
1
  {
2
2
  "name": "shapeup-sdlc-plugin",
3
3
  "displayName": "ShapeUp SDLC Plugin",
4
- "version": "3.8.0",
4
+ "version": "3.9.0",
5
5
  "description": "Shape Up SDLC harness for Claude Code: shaping, intake, orient, scope-mapping, building (T0-verified, sandboxed, scope-contracted), evaluation and QA skills orchestrated by a tech-lead.",
6
6
  "author": {
7
7
  "name": "Liberty Nguyen",
package/README.md CHANGED
@@ -139,7 +139,7 @@ rest of this README after this table and nothing will be a surprise.
139
139
  | **hill / hill phase** | How much of a scope is still *unknown* versus merely *unfinished*. Derived from T0 facts — never self-reported. |
140
140
  | **gate (L0–L4)** | A numbered checkpoint in a run. Most pause for you; GATE L2 is the one a hook observes and reports on. |
141
141
  | **covers-closure** | Every requirement clause has at least one task claiming to cover it. Nothing silently drops. |
142
- | **wiring reachability** | Every engine has a call site reachable from the app's real entry point. Catches "built, but never wired up". |
142
+ | **wiring reachability** | Every engine has a call site reachable from the app's real entry point. Catches "built, but never wired up". Reports itself unchecked, with a reason, when the import walk cannot be rooted — an unfollowable import, or no reachable engine to control it. |
143
143
  | **discovery ledger** | The one file everything found mid-run gets written to, so nothing is lost between rounds. |
144
144
 
145
145
  A longer version, including the internals, is in [docs/glossary.md](docs/glossary.md).
@@ -316,7 +316,10 @@ These hold across the harness and are the reason it stays predictable:
316
316
  `harness reduce ingest`; workers return data and never touch shared state.
317
317
  - **Traceability is oracle-checked, opt-in** — `harness verify trace` verifies covers-closure and
318
318
  wiring reachability from the committed spine artifacts; it ships advisory (warn-only) and every
319
- arm is skipped when its artifact is absent, so older specs are non-regressed.
319
+ arm is skipped when its artifact is absent, so older specs are non-regressed. Reachability also
320
+ skips when it cannot root its walk — an import it cannot follow, or no reachable engine to
321
+ control the result — because "every engine is orphaned" and "this is the wrong entry point" are
322
+ the same evidence, and a check that cannot tell them apart must say so rather than pick.
320
323
 
321
324
  ## Known rough edges
322
325
 
@@ -2287,6 +2287,11 @@
2287
2287
  "type": "string",
2288
2288
  "description": "Optional context on the archetype/entry-point choice."
2289
2289
  },
2290
+ "source_extensions": {
2291
+ "type": "array",
2292
+ "items": { "type": "string" },
2293
+ "description": "OPTIONAL — the file extensions this project's modules use, for the import walk that reachability runs. Absent means the JS/TS family plus the entry point's own suffix, which is right for most stacks and wrong for any stack whose engines end in something the entry point does not. Declare it when they differ (e.g. [\".ets\"] for ArkTS, [\".vue\", \".ts\"] for a Vue app): a walk that cannot follow an edge reports itself unchecked rather than reporting every engine orphaned, so the cost of leaving this absent is a skipped arm, never a false red."
2294
+ },
2290
2295
  "build_probe": {
2291
2296
  "type": "string",
2292
2297
  "description": "OPTIONAL — a command that asserts the BUILT ARTIFACT, not the build's exit code, and exits 0 only when it holds. Exists because a green build is not proof the feature compiled: some toolchains compile only the files reachable from an entry point, so a scope's new files can sit outside the compiled set while the build stays green (measured: an app package holding 3 compiled files, 58 errors once the rest became reachable). Archetype-specific by construction — e.g. 'the compiled source map lists every file under each scope's substrate'. Run by harness verify build after run_cmd, once per round before EVAL; absent = no such step, never a failure."
@@ -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.8.0",
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"
@@ -220,10 +220,17 @@ Do NOT enter MAP SCOPES until Orient is accepted.
220
220
 
221
221
  ```
222
222
  1. PROFILE (you write it at L0 — compile-order stays pipeline-blind): SHARED project-profile.md
223
- = {schema_version:1, archetype, entry_point, build_probe?, launch_probe?}. archetype ∈
224
- {client-only-game|web-service|mobile|library|data-pipeline}; entry_point is the reachability
225
- seam (a game's main.js is NOT a service's src/server.ts). Validate the enum — a typo must fail,
226
- not silently disable the check. The two probes feed the round build gate (`harness verify
223
+ = {schema_version:1, archetype, entry_point, source_extensions?, build_probe?, launch_probe?}.
224
+ archetype ∈ {client-only-game|web-service|mobile|library|data-pipeline}; entry_point is the
225
+ reachability seam (a game's main.js is NOT a service's src/server.ts). Validate the enum — a
226
+ typo must fail, not silently disable the check. entry_point is also the root reachability walks
227
+ the import graph from, so pick the module the app's screens hang off, not merely the file the
228
+ platform starts: a framework that registers screens by name (a route map, a manifest, a
229
+ string-loaded page) leaves its start file importing nothing the feature touches, and the arm
230
+ then reports that it could not check rather than calling every engine orphaned.
231
+ source_extensions is optional and only needed when the project's modules end in something the
232
+ entry point does not (e.g. [".ets"] when the entry is a .ts file); leaving it out costs a
233
+ skipped arm, never a false red. The two probes feed the round build gate (`harness verify
227
234
  build`, every round before EVAL): build_probe asserts the BUILT ARTIFACT covers what the run
228
235
  wrote (a green exit code is not proof the feature compiled when the toolchain compiles only what
229
236
  an entry point reaches); launch_probe installs, starts and asserts the first screen. A `mobile`
@@ -241,7 +248,14 @@ Do NOT enter MAP SCOPES until Orient is accepted.
241
248
  requirements.md registry (atomic REQ clauses, frozen ids).
242
249
  4. trace-lint — node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" verify trace --slug <slug>. ADVISORY at L1b:
243
250
  covers-closure (every covered REQ named by ≥1 AC's covers:) + reachability (every UC engine
244
- reaches entry_point). Promote to --gate only once covers: is populated.
251
+ reaches entry_point). Promote to --gate only once covers: is populated. Reachability reports
252
+ checked:false with a reason rather than a verdict in two cases, both warns: an import it could
253
+ not follow (the graph is incomplete) and no engine reachable at all (nothing controls the walk,
254
+ so an orphan and a wrong root look identical). Read either as "re-declare the profile", never
255
+ as a clean arm. When it does run it also reports, per scope, how many of that scope's own source
256
+ files the app reaches, and warns (SCOPE-UNREACHABLE) on a scope it reaches none of — a scope's
257
+ build fixture can be green while its code never compiles, on any toolchain that compiles only
258
+ what the entry point reaches. Warn only: the wiring may be a later scope's job by design.
245
259
  ```
246
260
 
247
261
  ---