shapeup-sdlc 3.18.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.claude/settings.local.example.json +2 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +115 -56
  4. package/README.md +101 -143
  5. package/SECURITY.md +16 -25
  6. package/agents/orient.md +14 -0
  7. package/agents/qa-edge-hunter.md +13 -0
  8. package/agents/spec-evaluator.md +14 -0
  9. package/agents/task-executor.md +14 -0
  10. package/bin/init.mjs +13 -16
  11. package/bin/lib/grant.mjs +8 -19
  12. package/commands/build.md +9 -14
  13. package/commands/eval.md +3 -3
  14. package/commands/hammer.md +9 -5
  15. package/commands/retro.md +12 -11
  16. package/commands/scopes.md +1 -12
  17. package/commands/ship.md +45 -88
  18. package/commands/wire.md +1 -8
  19. package/hooks/dispatch-receipt.mjs +94 -8
  20. package/hooks/hooks.json +0 -41
  21. package/hooks/lib/decision.mjs +6 -9
  22. package/hooks/lib/tier.mjs +50 -0
  23. package/hooks/sandbox-guard.mjs +140 -64
  24. package/kernel/gate.mjs +203 -22
  25. package/kernel/harness.mjs +20 -67
  26. package/kernel/init/run.mjs +175 -523
  27. package/kernel/lib/contract.mjs +9 -93
  28. package/kernel/lib/floor.mjs +64 -0
  29. package/kernel/lib/paths.mjs +39 -112
  30. package/kernel/order.mjs +157 -0
  31. package/kernel/probe/digest.mjs +2 -38
  32. package/kernel/schemas/domain.schema.json +146 -1125
  33. package/kernel/schemas/work-order.schema.json +8 -4
  34. package/kernel/schemas/work-result.schema.json +1 -1
  35. package/kernel/verify/envelope.mjs +2 -2
  36. package/kernel/verify/skills.mjs +27 -8
  37. package/kernel/verify/t0.mjs +2 -2
  38. package/package.json +4 -2
  39. package/skills/building/SKILL.md +144 -0
  40. package/skills/building/resources/gates.md +76 -0
  41. package/skills/building/resources/guidance.md +23 -0
  42. package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
  43. package/skills/building/resources/intake.md +157 -0
  44. package/skills/building/resources/orders.md +69 -0
  45. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
  46. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
  47. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
  48. package/skills/building/resources/plan-scopes.md +95 -0
  49. package/skills/building/resources/plan-spec.md +102 -0
  50. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
  51. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
  52. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
  53. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
  54. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
  55. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
  56. package/skills/building/resources/plan-wire.md +82 -0
  57. package/skills/building/resources/plan.md +98 -0
  58. package/skills/building/resources/retro-research.md +60 -0
  59. package/skills/building/resources/retro-scan.md +49 -0
  60. package/skills/building/resources/retro.md +225 -0
  61. package/skills/building/resources/rounds.md +78 -0
  62. package/skills/building/resources/stop.md +127 -0
  63. package/skills/orient/SKILL.md +37 -37
  64. package/skills/qa-edge-hunter/SKILL.md +14 -14
  65. package/skills/shapeup/SKILL.md +5 -5
  66. package/skills/shapeup/resources/breadboarding.md +1 -1
  67. package/skills/shapeup/resources/context-compaction.md +5 -5
  68. package/skills/shapeup/resources/shaping.md +1 -1
  69. package/skills/spec-evaluator/SKILL.md +17 -21
  70. package/skills/spec-evaluator/references/dimension-contract.md +1 -1
  71. package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
  72. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
  73. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
  74. package/skills/task-executor/SKILL.md +11 -15
  75. package/commands/hill.md +0 -10
  76. package/hooks/gate-intake.mjs +0 -109
  77. package/hooks/gate-zerowork.mjs +0 -349
  78. package/hooks/tier-guard.mjs +0 -162
  79. package/kernel/compile.mjs +0 -1419
  80. package/kernel/init/fit.mjs +0 -199
  81. package/kernel/init/run-args.mjs +0 -206
  82. package/kernel/lib/breadboard.mjs +0 -165
  83. package/kernel/probe/attempts.mjs +0 -161
  84. package/kernel/probe/concurrency.mjs +0 -535
  85. package/kernel/probe/eval.mjs +0 -405
  86. package/kernel/probe/hunt.mjs +0 -104
  87. package/kernel/probe/leg.mjs +0 -229
  88. package/kernel/probe/owner.mjs +0 -142
  89. package/kernel/probe/requirements.mjs +0 -328
  90. package/kernel/probe/resume.mjs +0 -1110
  91. package/kernel/probe/rounds.mjs +0 -115
  92. package/kernel/probe/staged.mjs +0 -83
  93. package/kernel/probe/stats.mjs +0 -477
  94. package/kernel/probe/t0.mjs +0 -115
  95. package/kernel/reduce/board.mjs +0 -350
  96. package/kernel/reduce/graph.mjs +0 -482
  97. package/kernel/reduce/hill.mjs +0 -322
  98. package/kernel/reduce/ingest.mjs +0 -767
  99. package/kernel/reduce/leftovers.mjs +0 -159
  100. package/kernel/reduce/ship.mjs +0 -529
  101. package/kernel/reduce/snapshot.mjs +0 -291
  102. package/kernel/reduce/verdict.mjs +0 -172
  103. package/kernel/report/export.mjs +0 -384
  104. package/kernel/report/facts.mjs +0 -234
  105. package/kernel/verify/budget.mjs +0 -153
  106. package/kernel/verify/build.mjs +0 -428
  107. package/kernel/verify/dispatch.mjs +0 -114
  108. package/kernel/verify/spec.mjs +0 -975
  109. package/kernel/verify/trace.mjs +0 -604
  110. package/skills/ba-pitch-analyzer/SKILL.md +0 -204
  111. package/skills/coach/SKILL.md +0 -397
  112. package/skills/hill-chart/SKILL.md +0 -154
  113. package/skills/hill-chart/assets/dashboard.template.html +0 -1432
  114. package/skills/scope-architect/SKILL.md +0 -174
  115. package/skills/scope-hammer/SKILL.md +0 -231
  116. package/skills/solution-architect/SKILL.md +0 -158
  117. package/skills/tech-lead/SKILL.md +0 -151
  118. package/skills/tech-lead/references/gates.md +0 -605
  119. package/skills/tech-lead/references/protocol.md +0 -889
  120. package/skills/tech-lead/references/tiny-lane.md +0 -53
  121. package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
  122. package/skills/translator/SKILL.md +0 -258
  123. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
  124. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
  125. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
  126. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
  127. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
  128. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
  129. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
  130. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
  131. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
  132. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
  133. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
  134. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
  135. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
  136. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
  137. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
  138. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
  139. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
  140. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
  141. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
@@ -1,604 +0,0 @@
1
- #!/usr/bin/env node
2
- // Traceability oracle (spine v1.3).
3
- //
4
- // ONE oracle, TWO mechanically-checkable assertions — nothing that merely *asserts* quality:
5
- //
6
- // 1. Covers-closure (§1) — every SHARED requirement with status `covered` must be named by
7
- // ≥1 acceptance criterion's `(covers: REQ-…)` clause. A REQ that is neither covered nor
8
- // CUT (PO-approved) is RED. This catches the *dropped clause* (a customer requirement that
9
- // silently vanished in translation), not a *contradiction* (§1 honest boundary → §4.2).
10
- //
11
- // 2. Reachability (§2) — a use-case whose engine module does not reach the project profile's
12
- // `entry_point` via the import graph (0 import sites) is RED. This catches the *dead module*
13
- // (631 lines, 26 passing tests, zero call sites), not a *dead data-path* (§2 honest boundary
14
- // → §4.4). Entry point is PROFILE-GATED, never hardcoded (main.js for a game is not the seam
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).
20
- //
21
- // Governing rule: if a script can't check it, it's decoration. This script checks a deletion and
22
- // an orphan — both provable from files, zero LLM tokens. What it deliberately does NOT assert:
23
- // it does not count or grade tests (§4.1 — a green test that asserts nothing real would satisfy
24
- // a "≥1 test" arm, so that arm was cut).
25
- //
26
- // Staged severity (§1.4 / §6): ADVISORY (warn-only, exit 0) by default — it goes ~100% red on a
27
- // board with no covers: yet, and that's the intended demonstration, not a gate. Promote to a
28
- // blocking gate with --gate only once covers: is populated, or it breaks every legacy run.
29
- //
30
- // Reads SHARED shapeup/<slug>/requirements.md (RequirementClause registry)
31
- // SHARED shapeup/<slug>/wiring-map.md (WiringMap — optional)
32
- // SHARED shapeup/<slug>/project-profile.md (ProjectProfile — optional)
33
- // LOCAL .shapeup/<slug>/tasks/TASK-*.md (board AC covers[])
34
- // Writes LOCAL .shapeup/<slug>/trace/report.json (regenerated each run)
35
- // LOCAL .shapeup/<slug>/trace/wiring.mmd (Mermaid view of the checked graph)
36
- //
37
- // Zero dependencies, zero network — same discipline as `harness verify t0` / `harness compile`.
38
- //
39
- // Usage: node kernel/harness.mjs verify trace --slug <slug> [--cwd <dir>] [--gate] [--quiet]
40
- // Exit: advisory (default) → always 0. --gate → 1 when overall is red.
41
-
42
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
43
- import { resolve, join, dirname, relative, isAbsolute } from "node:path";
44
- import { readBoard } from "../compile.mjs";
45
- import { runArgs } from "../lib/argv.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";
49
-
50
- // --- requirements.md registry parser -----------------------------------------
51
- // A committed markdown table: | REQ-id | clause (verbatim) | source | status | note |
52
- // The first two columns are load-bearing; source/note are optional. Status is normalized so
53
- // "CUT (PO-approved)", "cut", "CUT" all read as cut and anything else non-covered stays covered.
54
- /**
55
- * Parse the committed `requirements.md` table into RequirementClause rows.
56
- * @param {string} md - The registry Markdown ("" / null → []). Table columns:
57
- * | REQ-id | clause | source | status | note |.
58
- * @returns {Array<{id:string, clause:string, source:string, status:("covered"|"CUT (PO-approved)"),
59
- * note:string}>} One row per `REQ-\d+`; any status containing "cut" normalizes to CUT, all else
60
- * to covered. Header/separator/prose rows are skipped.
61
- */
62
- export function parseRequirements(md) {
63
- const clauses = [];
64
- if (!md) return clauses;
65
- for (const raw of md.split(/\r?\n/)) {
66
- const line = raw.trim();
67
- if (!line.startsWith("|")) continue;
68
- const cells = line.split("|").slice(1, -1).map((c) => c.trim()); // drop the leading/trailing empties
69
- if (!cells.length) continue;
70
- const id = (cells[0].match(/REQ-\d+/) || [])[0];
71
- if (!id) continue; // header row ("REQ-id"), separator row ("---"), or prose — skip
72
- const statusRaw = (cells[3] || cells[cells.length - 1] || "").toLowerCase();
73
- const status = /\bcut\b/.test(statusRaw) ? "CUT (PO-approved)" : "covered";
74
- clauses.push({
75
- id,
76
- clause: cells[1] || "",
77
- source: cells[2] || "",
78
- status,
79
- note: cells[4] || "",
80
- });
81
- }
82
- return clauses;
83
- }
84
-
85
- /**
86
- * Collect every REQ-id named by a `covers:` clause across the board's acceptance criteria.
87
- * @param {Array<object>} board - Task entries (see compile-order's parseTaskFile).
88
- * @returns {Set<string>} The set of `REQ-\d+` ids referenced by any AC's covers[].
89
- */
90
- export function coveredReqIds(board) {
91
- const covered = new Set();
92
- for (const task of board) {
93
- for (const ac of task.acceptance_criteria || []) {
94
- const covers = typeof ac === "object" && Array.isArray(ac.covers) ? ac.covers : [];
95
- // ONE KEY SPACE. `R-2`, `[[REQ-5]]` and `req-4` are the same clause spelled three ways, and the
96
- // sibling rule accepts all of them; testing the raw string here counted every one as nothing, so
97
- // an author told at L1b to cover a requirement with an AC — which they had — stayed red.
98
- for (const raw of covers) { const id = reqId(raw); if (/^REQ-\d+$/.test(id)) covered.add(id); }
99
- }
100
- }
101
- return covered;
102
- }
103
-
104
- // --- import-graph reachability -----------------------------------------------
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"];
114
- const IMPORT_RE = /(?:\bimport\b[^'"]*?from\s*|\bimport\s*|\bexport\b[^'"]*?from\s*|\brequire\s*\(\s*|\bimport\s*\()\s*['"]([^'"]+)['"]/g;
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
- // One spelling, the one the schema declares. A silent alias is a field nobody documented and
130
- // nobody can be told to write.
131
- const raw = profile?.source_extensions ?? null;
132
- const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[,\s]+/) : [];
133
- const declared = list
134
- .map((e) => String(e).trim())
135
- .filter(Boolean)
136
- .map((e) => (e.startsWith(".") ? e : "." + e));
137
- const m = /(\.[A-Za-z0-9]+)$/.exec(entryPoint || "");
138
- const fromEntry = m ? m[1] : null;
139
- const exts = [...new Set([...DEFAULT_SOURCE_EXTS, ...declared, ...(fromEntry ? [fromEntry] : [])])];
140
- return { exts, declared, from_entry: fromEntry };
141
- }
142
-
143
- /**
144
- * Resolve a relative import specifier to a repo-relative source file.
145
- *
146
- * The three answers are kept apart on purpose. A bare specifier is out of the app graph by design;
147
- * a relative specifier carrying a non-module suffix (`./styles.css`, `./data.json`) is an asset,
148
- * which imports nothing and can never be an engine; and a relative specifier that looks like a
149
- * module but resolves to no file is an edge the walk could not follow — the one case that makes
150
- * the resulting graph incomplete, and the caller has to be able to see it.
151
- *
152
- * @param {string} fromFileAbs - Absolute path of the importing file.
153
- * @param {string} spec - The import specifier string.
154
- * @param {string} cwd - Repo root the result is made relative to.
155
- * @param {string[]} exts - The module extensions of this project (see `sourceExtensions`).
156
- * @returns {{file:(string|null), kind:("bare"|"asset"|"resolved"|"unresolved")}} The repo-relative
157
- * source path when one was found, and which of the four answers this was.
158
- */
159
- function resolveSpecifier(fromFileAbs, spec, cwd, exts) {
160
- if (!spec.startsWith(".")) return { file: null, kind: "bare" }; // node_modules, out of the app graph
161
- const suffix = /(\.[A-Za-z0-9]+)$/.exec(spec);
162
- const baseAbs = resolve(dirname(fromFileAbs), spec);
163
- const candidates = [baseAbs, ...exts.map((e) => baseAbs + e), ...exts.map((e) => join(baseAbs, "index" + e))];
164
- for (const c of candidates) {
165
- if (existsSync(c) && statSync(c).isFile()) return { file: relative(cwd, c).split("\\").join("/"), kind: "resolved" };
166
- }
167
- if (suffix && !exts.includes(suffix[1])) return { file: null, kind: "asset" };
168
- return { file: null, kind: "unresolved" };
169
- }
170
-
171
- /**
172
- * Normalize a declared path (entry_point / engine) to an existing repo-relative source file.
173
- * @param {string} p - The declared path (absolute or cwd-relative).
174
- * @param {string} cwd - Repo root the result is made relative to.
175
- * @param {string[]} [exts] - The module extensions to try (defaults to the JS/TS family).
176
- * @returns {(string|null)} The repo-relative source path (trying source extensions and `/index`),
177
- * or null when nothing on disk matches.
178
- */
179
- function resolveFile(p, cwd, exts = DEFAULT_SOURCE_EXTS) {
180
- const abs = isAbsolute(p) ? p : resolve(cwd, p);
181
- const candidates = [abs, ...exts.map((e) => abs + e), ...exts.map((e) => join(abs, "index" + e))];
182
- for (const c of candidates) {
183
- if (existsSync(c) && statSync(c).isFile()) return relative(cwd, c).split("\\").join("/");
184
- }
185
- return null;
186
- }
187
-
188
- /**
189
- * Extract every import/require/dynamic-import specifier string from a file.
190
- * @param {string} fileAbs - Absolute path of the source file.
191
- * @returns {string[]} The raw specifier strings; [] when the file is unreadable.
192
- */
193
- function importsOf(fileAbs) {
194
- let src;
195
- try { src = readFileSync(fileAbs, "utf8"); } catch { return []; }
196
- const out = [];
197
- for (const m of src.matchAll(IMPORT_RE)) out.push(m[1]);
198
- return out;
199
- }
200
-
201
- /**
202
- * BFS the import graph from an entry point.
203
- *
204
- * Reports the edges it could NOT follow alongside the set it built. "This module is never imported"
205
- * is only a supportable claim over a graph with every edge in it; over a graph missing edges it is
206
- * indistinguishable from "the walker could not read this language", and the second must not be
207
- * published as the first.
208
- *
209
- * @param {string} entryRel - The entry-point path (declared form; resolved on disk).
210
- * @param {string} cwd - Repo root.
211
- * @param {string[]} [exts] - The module extensions of this project (defaults to the JS/TS family).
212
- * @returns {{reachable:Set<string>, entryResolved:(string|null), unresolved:Array<{from:string,
213
- * spec:string}>}} The set of repo-relative files reachable from the entry, the resolved entry
214
- * path (null when the entry is not on disk, in which case `reachable` is empty), and every
215
- * module-shaped relative specifier that resolved to no file.
216
- */
217
- export function reachableFrom(entryRel, cwd, exts = DEFAULT_SOURCE_EXTS) {
218
- const reachable = new Set();
219
- const unresolved = [];
220
- const start = resolveFile(entryRel, cwd, exts);
221
- if (!start) return { reachable, entryResolved: null, unresolved };
222
- const queue = [start];
223
- reachable.add(start);
224
- while (queue.length) {
225
- const cur = queue.shift();
226
- const curAbs = resolve(cwd, cur);
227
- for (const spec of importsOf(curAbs)) {
228
- const { file: dep, kind } = resolveSpecifier(curAbs, spec, cwd, exts);
229
- if (kind === "unresolved") unresolved.push({ from: cur, spec });
230
- if (dep && !reachable.has(dep)) { reachable.add(dep); queue.push(dep); }
231
- }
232
- }
233
- return { reachable, entryResolved: start, unresolved };
234
- }
235
-
236
- // --- Mermaid view (a view of the checked graph, so it cannot drift — §2) ------
237
- /**
238
- * Render a Mermaid flowchart of the wiring map, marking orphaned engines (a view of the checked
239
- * graph, so it cannot drift from the reachability result).
240
- * @param {(object|null)} wiringMap - The WiringMap ({entries:[{use_case,engine,affordance}]}).
241
- * @param {Set<string>} unreachableSet - Use-case ids found unreachable (rendered as dashed/dead).
242
- * @returns {string} The Mermaid `flowchart LR` source.
243
- */
244
- export function wiringMermaid(wiringMap, unreachableSet) {
245
- const lines = ["flowchart LR", " entry([entry point])"];
246
- /**
247
- * Make a Mermaid-safe node id from an arbitrary string.
248
- * @param {*} s - Any value (engine path / use-case id); coerced to string.
249
- * @returns {string} A `n_`-prefixed id with every non-alphanumeric char replaced by `_`.
250
- */
251
- const id = (s) => "n_" + String(s).replace(/[^A-Za-z0-9]/g, "_");
252
- for (const e of wiringMap?.entries || []) {
253
- const dead = unreachableSet.has(e.use_case);
254
- const eng = id(e.engine);
255
- lines.push(` ${eng}["${e.engine}"]${dead ? ":::dead" : ""}`);
256
- lines.push(` entry ${dead ? "-.->|orphan| " : "--> "}${eng}`);
257
- if (e.affordance) lines.push(` ${eng} --> ${id(e.use_case + "_aff")}(["${e.affordance}"])`);
258
- }
259
- lines.push(" classDef dead stroke:#d33,stroke-width:2px,color:#d33;");
260
- return lines.join("\n");
261
- }
262
-
263
- // --- per-scope reachability ---------------------------------------------------
264
- //
265
- // A SCOPE'S BUILD FIXTURE PROVES NOTHING UNTIL THE SCOPE'S CODE IS REACHABLE, and on a toolchain
266
- // that compiles only what the entry point reaches, the two come apart in the worst direction:
267
- // three scopes were T0-green on an assemble fixture while their own files did not compile at all,
268
- // and the errors surfaced only once a fourth scope — one that may not write those files — wired
269
- // the screens in. The fixture was honest about what it ran. Nothing asked whether what it ran
270
- // included the scope's work.
271
- //
272
- // This arm asks, and only ever warns. A scope legitimately owns resources, route maps, manifests,
273
- // tests and files a later scope will wire, so *some* of its substrate sitting outside the import
274
- // graph is the normal case and says nothing. What is worth a word is a scope with source files on
275
- // disk and NOT ONE of them reachable: everything that scope contributes is outside the running
276
- // app, which is the shape the defect had. Red would be wrong even then — the wiring may be the
277
- // next scope's job by design, which is a plan the PO made, not a defect the oracle found.
278
- const SKIP_DIRS = new Set([".git", "node_modules", "oh_modules", ".shapeup", "build", "dist", "out", ".idea", "coverage"]);
279
-
280
- /**
281
- * List the repo-relative source files under a root, by module extension.
282
- * @param {string} root - Repo root; results are relative to it.
283
- * @param {string[]} exts - The module extensions that make a file a source file.
284
- * @returns {string[]} Repo-relative paths, build and dependency directories skipped.
285
- */
286
- function sourceFilesUnder(root, exts) {
287
- const out = [];
288
- /**
289
- * Walk one directory, recursing into its subdirectories.
290
- * @param {string} dir - Absolute directory to walk.
291
- * @returns {void}
292
- */
293
- const walk = (dir) => {
294
- let entries;
295
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
296
- for (const e of entries) {
297
- if (SKIP_DIRS.has(e.name)) continue;
298
- const abs = join(dir, e.name);
299
- if (e.isDirectory()) walk(abs);
300
- else if (exts.some((x) => e.name.endsWith(x))) out.push(relative(root, abs).split("\\").join("/"));
301
- }
302
- };
303
- walk(root);
304
- return out;
305
- }
306
-
307
- /**
308
- * For each scope contract, how much of its own source substrate the app actually reaches.
309
- *
310
- * @param {Array<{id?:string, contract?:object}>} contracts - Scope contracts as `readAllContracts`
311
- * returns them.
312
- * @param {Set<string>} reachable - The repo-relative files reachable from the entry point.
313
- * @param {string[]} sources - Every repo-relative source file on disk.
314
- * @returns {Array<{scope_id:string, source_files:number, reachable_files:number}>} One row per
315
- * scope that owns at least one source file on disk; scopes owning none are left out entirely,
316
- * because a scope with nothing to reach is not a finding.
317
- */
318
- export function scopeReachability(contracts, reachable, sources) {
319
- const rows = [];
320
- for (const found of contracts) {
321
- const c = found?.contract || found || {};
322
- const id = c.scope_id || found?.id;
323
- // A CONTRACT THIS CANNOT READ IS SKIPPED, NEVER THROWN OVER. The whole oracle runs advisory, so
324
- // a throw here takes out covers-closure too and the run gets no report at all — one malformed
325
- // contract silencing every arm. A substrate that is not a list of globs is simply a scope this
326
- // arm has nothing to say about.
327
- const declared = c.allowed_file_substrate;
328
- if (!Array.isArray(declared) || !declared.length) continue;
329
- const globs = declared.filter((g) => typeof g === "string").map(globToRegExp);
330
- if (!globs.length) continue;
331
- const owned = sources.filter((f) => globs.some((r) => r.test(f)));
332
- if (!owned.length) continue;
333
- rows.push({ scope_id: id, source_files: owned.length, reachable_files: owned.filter((f) => reachable.has(f)).length });
334
- }
335
- return rows;
336
- }
337
-
338
- // --- the oracle --------------------------------------------------------------
339
- /**
340
- * Run the covers-closure + reachability oracle for a slug.
341
- * @param {string} slug - Feature slug.
342
- * @param {{cwd:string, gate?:boolean}} opts - cwd (root the SHARED/LOCAL paths resolve against),
343
- * gate (records mode; the CLI, not this function, turns gate+red into a non-zero exit).
344
- * @returns {{report:object, mermaid:(string|null)}} The trace report (covers_closure, reachability,
345
- * scope_reachability, findings[], overall "green"|"red") and a Mermaid view when a wiring map
346
- * exists. Each arm self-skips (checked=false) when its artifact is absent — non-regression on
347
- * pre-spine specs — and reachability also self-skips when it cannot root its walk, which is what
348
- * skips the per-scope arm with it.
349
- */
350
- export function traceLint(slug, { cwd, gate = false }) {
351
- const shared = sharedRoot(cwd, slug);
352
- const findings = [];
353
-
354
- // 1. Covers-closure.
355
- //
356
- // THE WHOLE ARM IS GATED ON THE REGISTRY EXISTING — both halves of it, and the second half is the
357
- // one that was missing. With no `requirements.md` there are no clauses, so `REQ-UNCOVERED` cannot
358
- // fire; but `dangling` is derived from the BOARD, which needs no registry to carry a `covers:`
359
- // clause, so a tree with no registry reported "covers-closure not applicable" in the same breath
360
- // as a red finding for every `covers:` on the board. An arm that reports itself skipped and emits
361
- // findings anyway is not skipped, and the report says the opposite of what the findings do.
362
- const reqPath = join(shared, "requirements.md");
363
- const closureChecked = existsSync(reqPath);
364
- const clauses = closureChecked ? parseRequirements(readFileSync(reqPath, "utf8")) : [];
365
- const board = readBoard(cwd, slug);
366
- const covered = coveredReqIds(board);
367
- const knownIds = new Set(clauses.map((c) => c.id));
368
-
369
- const wantCovered = clauses.filter((c) => c.status === "covered");
370
- const cut = clauses.filter((c) => c.status !== "covered");
371
- const uncovered = wantCovered.filter((c) => !covered.has(c.id)).map((c) => c.id);
372
- const dangling = [...covered].filter((id) => !knownIds.has(id));
373
-
374
- for (const id of uncovered) {
375
- const c = clauses.find((x) => x.id === id);
376
- findings.push({ severity: "red", code: "REQ-UNCOVERED", req: id,
377
- message: `${id} (status: covered) is named by no AC's covers: — the clause "${(c?.clause || "").slice(0, 60)}" would silently vanish. Cover it with an AC, or mark it CUT (PO-approved).` });
378
- }
379
- if (closureChecked) {
380
- for (const id of dangling) {
381
- findings.push({ severity: "red", code: "COVERS-DANGLING", req: id,
382
- message: `an AC declares (covers: ${id}) but ${id} is not in requirements.md — a covers: link must resolve to a registered REQ.` });
383
- }
384
- }
385
-
386
- const coversClosure = {
387
- checked: closureChecked,
388
- requirements_total: clauses.length,
389
- covered_status: wantCovered.length,
390
- cut_status: cut.length,
391
- covered_by_ac: [...covered].filter((id) => knownIds.has(id)).length,
392
- uncovered,
393
- dangling_covers: closureChecked ? dangling : [],
394
- pass: uncovered.length === 0 && (!closureChecked || dangling.length === 0),
395
- skipped_reason: closureChecked ? null : "no requirements.md registry — covers-closure not applicable (non-regression on pre-spine specs).",
396
- };
397
-
398
- // 2. Reachability (profile-gated). Both artifacts are markdown since ADR-0001; `readContract`
399
- // still accepts the legacy `.json` so a project mid-migration is checked rather than skipped.
400
- const wiringPath = join(shared, "wiring-map.md");
401
- const profilePath = join(shared, "project-profile.md");
402
- let reachability = { checked: false, pass: true, unreachable: [], skipped_reason: "no wiring-map — reachability not applicable." };
403
- // Hoisted so the per-scope arm below can reuse the walk this one already paid for. Both stay
404
- // null unless reachability actually ran, which is what gates the second arm on the first.
405
- let reachableSet = null;
406
- let walkExts = null;
407
- let wiringMap = null;
408
-
409
- let wiringFound = null;
410
- try { wiringFound = readContract(wiringPath, WIRING_MAP); }
411
- catch (e) {
412
- findings.push({ severity: "red", code: "WIRING-UNREADABLE", message: `wiring-map is not readable (${e.message}).` });
413
- }
414
- if (wiringFound) wiringMap = wiringFound.contract;
415
-
416
- // A map whose `## Wiring` table is under a heading the parser does not claim reads as
417
- // zero entries, and the loop below then walks nothing and reports `0/0 engines reach <entry>` —
418
- // GREEN, for a committed file holding six correct rows. The gate whose entire purpose is that no
419
- // engine ships orphaned failing open, on a file that looks right to every human who reviews it.
420
- // An unreadable contract is now RED and reachability is not claimed, because none was checked.
421
- const wiringUnreadable = unreadableReason(wiringMap);
422
- if (wiringUnreadable) {
423
- findings.push({ severity: "red", code: "WIRING-UNREADABLE", message: `wiring-map.md could not be read as a WiringMap: ${wiringUnreadable}. Reachability was NOT checked — a map this parser cannot see is not a map with nothing in it.` });
424
- wiringMap = null;
425
- reachability = { checked: false, pass: false, unreachable: [],
426
- skipped_reason: `wiring-map.md is present but unreadable (${wiringUnreadable}) — reachability cannot be claimed.` };
427
- }
428
-
429
- // A map that parsed only through the migration reader still WORKS — but silence about that is
430
- // how a temporary fallback becomes a permanent second format. Warn, so the next `wire` converges
431
- // the file on the canonical table rather than the reader carrying the old shape indefinitely.
432
- if (wiringMap && wiringMap[LEGACY_LAYOUT]) {
433
- findings.push({ severity: "warn", code: "WIRING-LEGACY-LAYOUT", message:
434
- "wiring-map.md is in the pre-canonical per-use-case layout (`### UC-xx` + a `| Field | Value |` table). " +
435
- "It reads correctly through the migration reader, but the canonical form is one `## Wiring` table — " +
436
- "re-run the wire operation to converge it, so this artifact stops having two shapes." });
437
- }
438
-
439
- if (wiringMap) {
440
- let profileFound = null;
441
- try { profileFound = readContract(profilePath, PROJECT_PROFILE); }
442
- catch (e) { findings.push({ severity: "red", code: "PROFILE-UNREADABLE", message: `project-profile is not readable (${e.message}).` }); }
443
- if (!profileFound) {
444
- reachability = { checked: false, pass: true, unreachable: [],
445
- skipped_reason: "wiring-map present but no project-profile — reachability needs an entry_point; declare the profile at L0." };
446
- findings.push({ severity: "warn", code: "PROFILE-MISSING", message: "wiring-map exists but project-profile does not — reachability is skipped until the entry point is declared." });
447
- } else {
448
- const profile = profileFound.contract;
449
- const entryPoint = profile?.entry_point;
450
- if (!entryPoint) {
451
- reachability = { checked: false, pass: true, unreachable: [], skipped_reason: "project-profile has no entry_point." };
452
- } else {
453
- const { exts, declared, from_entry: fromEntry } = sourceExtensions(profile, entryPoint);
454
- const { reachable, entryResolved, unresolved } = reachableFrom(entryPoint, cwd, exts);
455
- if (!entryResolved) {
456
- reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
457
- skipped_reason: `entry_point "${entryPoint}" does not resolve to a source file on disk — reachability skipped.` };
458
- findings.push({ severity: "warn", code: "ENTRY-MISSING", message: `project-profile.md entry_point "${entryPoint}" is not on disk — reachability cannot run.` });
459
- } else if (unresolved.length) {
460
- // AN INCOMPLETE GRAPH GRADES NOTHING. Every module-shaped relative import the walker could
461
- // not follow is a missing edge, and a module is "unreachable" only in the sense that this
462
- // walk did not get there. Publishing that as red produces a check that is red for every
463
- // input on a stack the resolver cannot read, while reporting that it looked.
464
- const sample = unresolved.slice(0, 3).map((u) => `${u.spec} (from ${u.from})`);
465
- reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
466
- entry_resolved: entryResolved, reachable_files: reachable.size,
467
- module_extensions: exts, unresolved_imports: unresolved.length, unresolved_sample: sample,
468
- 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.` };
469
- findings.push({ severity: "warn", code: "GRAPH-INCOMPLETE", message:
470
- `reachability did not run: ${unresolved.length} relative import(s) could not be resolved (e.g. ${sample.join("; ")}). ` +
471
- `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})` : "")}. ` +
472
- "Declare `source_extensions` in project-profile.md to let this arm run." });
473
- } else {
474
- const engines = wiringMap.entries || [];
475
- const unreachable = [];
476
- for (const e of engines) {
477
- const engResolved = resolveFile(e.engine, cwd, exts);
478
- if (!(engResolved && reachable.has(engResolved))) {
479
- unreachable.push({ use_case: e.use_case, engine: e.engine, reason: engResolved ? "not imported from the entry point" : "engine file not on disk" });
480
- }
481
- }
482
- // THE ARM NEEDS ONE POSITIVE CONTROL, AND EVERY-ENGINE-ORPHANED IS NOT ONE. What this
483
- // check was built to catch is the dead module: one engine with no call site among
484
- // siblings that have them. When NO engine is reachable, nothing demonstrates that this
485
- // entry point is the root the app actually runs from — and for whole archetypes it is
486
- // not. A framework that registers screens declaratively reaches them by name at runtime
487
- // (`loadContent("pages/Index")`, a route map, a manifest), so its entry file imports a
488
- // handful of modules and no engine, and the import graph is complete and beside the
489
- // point. "Every engine is dead" and "I am walking the wrong tree" produce identical
490
- // evidence, so the arm reports that it could not check rather than picking one.
491
- //
492
- // THE COST, NAMED: a wiring map with a single engine can no longer red, because its only
493
- // engine being unreachable is exactly the indistinguishable case. A genuinely orphaned
494
- // module in a one-use-case feature is therefore reported as unchecked, not as dead. That
495
- // is the price of never being red for every input on a stack this walk cannot root.
496
- if (engines.length && unreachable.length === engines.length) {
497
- reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
498
- entry_resolved: entryResolved, module_extensions: exts, reachable_files: reachable.size,
499
- engines_total: engines.length, engines_reachable: 0,
500
- 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.` };
501
- findings.push({ severity: "warn", code: "REACH-NO-CONTROL", message:
502
- `reachability did not run: all ${engines.length} engine(s) are unreachable from entry_point "${entryPoint}", which reaches ${reachable.size} file(s). ` +
503
- "Every engine orphaned is the one result this arm cannot distinguish from a wrong root. If some module does compose the app, declare that one as the entry point. " +
504
- "If the screens are registered in a manifest instead — a route map, a plugin table — then no entry point roots this walk and unchecked is the correct end state for this project, not a profile to fix." });
505
- } else {
506
- for (const u of unreachable) {
507
- findings.push({ severity: "red", code: "UC-UNREACHABLE", uc: u.use_case,
508
- 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.` });
509
- }
510
- reachableSet = reachable;
511
- walkExts = exts;
512
- reachability = { checked: true, entry_point: entryPoint, entry_resolved: entryResolved,
513
- module_extensions: exts, reachable_files: reachable.size,
514
- engines_total: engines.length, engines_reachable: engines.length - unreachable.length,
515
- unreachable, pass: unreachable.length === 0 };
516
- }
517
- }
518
- }
519
- }
520
- }
521
-
522
- // --- per-scope reachability, gated on the first arm having actually run ---------------------
523
- let scopeReach = { checked: false, scopes: [], orphaned: [],
524
- skipped_reason: "reachability did not run, so there is no graph to measure a scope against." };
525
- if (reachableSet) {
526
- const contracts = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT);
527
- if (!contracts.length) {
528
- scopeReach = { checked: false, scopes: [], orphaned: [], skipped_reason: "no scope contracts on disk." };
529
- } else {
530
- const rows = scopeReachability(contracts, reachableSet, sourceFilesUnder(cwd, walkExts));
531
- const orphaned = rows.filter((r) => r.reachable_files === 0);
532
- scopeReach = { checked: true, scopes: rows, orphaned: orphaned.map((r) => r.scope_id) };
533
- for (const r of orphaned) {
534
- findings.push({ severity: "warn", code: "SCOPE-UNREACHABLE", scope: r.scope_id, message:
535
- `scope ${r.scope_id}: none of its ${r.source_files} source file(s) is reached from the entry point. ` +
536
- "A build fixture that compiles only what the entry point reaches can be green while this scope's own " +
537
- "code never compiles — the errors then surface in whichever scope wires the screens in. Warn, not red: " +
538
- "the wiring may legitimately be a later scope's job." });
539
- }
540
- }
541
- }
542
-
543
- const overall = findings.some((f) => f.severity === "red") ? "red" : "green";
544
- const report = {
545
- schema_version: 1,
546
- slug,
547
- at: new Date().toISOString(),
548
- mode: gate ? "gate" : "advisory",
549
- advisory: !gate,
550
- covers_closure: coversClosure,
551
- reachability,
552
- scope_reachability: scopeReach,
553
- findings,
554
- overall,
555
- };
556
- const mermaid = wiringMap ? wiringMermaid(wiringMap, new Set((reachability.unreachable || []).map((u) => u.use_case))) : null;
557
- return { report, mermaid };
558
- }
559
-
560
- // ---------------------------------------------------------------------------
561
- /** The typed argv contract (see `./lib/argv.mjs`). */
562
- export const ARGV_SPEC = {
563
- usage: "harness.mjs verify trace --slug <slug> [--cwd <dir>] [--gate] [--quiet]",
564
- _: { arity: 0, max: 0, name: "(no positional operands)" },
565
- slug: { type: "str", required: true },
566
- cwd: { type: "path" },
567
- gate: { type: "flag" },
568
- quiet: { type: "flag" },
569
- };
570
-
571
- /**
572
- * Run the traceability oracle over the spine artifacts and write its report.
573
- *
574
- * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
575
- * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
576
- * call `process.exit()` with the subcommand's documented code rather than returning.
577
- */
578
- export async function cli(rawArgv) {
579
- const args = runArgs(ARGV_SPEC, rawArgv);
580
- const cwd = resolve(args.cwd || process.cwd());
581
- const slug = args.slug;
582
- const gate = !!args.gate;
583
-
584
- const { report, mermaid } = traceLint(slug, { cwd, gate });
585
-
586
- const outDir = traceDir(cwd, slug);
587
- mkdirSync(outDir, { recursive: true });
588
- writeFileSync(join(outDir, "report.json"), JSON.stringify(report, null, 2) + "\n");
589
- if (mermaid) writeFileSync(join(outDir, "wiring.mmd"), mermaid + "\n");
590
-
591
- if (!args.quiet) {
592
- const cc = report.covers_closure, rc = report.reachability;
593
- const badge = report.overall === "green" ? "🟢 green" : (gate ? "🔴 red" : "🟠 red (advisory)");
594
- console.log(`trace-lint ${slug} — ${badge} [${report.mode}]`);
595
- if (cc.checked) console.log(` covers-closure: ${cc.covered_by_ac}/${cc.covered_status} covered · ${cc.cut_status} cut · uncovered [${cc.uncovered.join(", ")}]${cc.dangling_covers.length ? ` · dangling [${cc.dangling_covers.join(", ")}]` : ""}`);
596
- else console.log(` covers-closure: skipped — ${cc.skipped_reason}`);
597
- if (rc.checked) console.log(` reachability: ${rc.engines_total - rc.unreachable.length}/${rc.engines_total} engines reach ${rc.entry_point} · unreachable [${rc.unreachable.map((u) => u.use_case).join(", ")}]`);
598
- else console.log(` reachability: skipped — ${rc.skipped_reason}`);
599
- for (const f of report.findings) console.log(` ${f.severity === "red" ? "✗" : "⚠"} [${f.code}] ${f.message}`);
600
- console.log(` → ${relLocal(slug, "trace", "report.json")}`);
601
- }
602
-
603
- process.exit(gate && report.overall === "red" ? 1 : 0);
604
- }