shapeup-sdlc 3.8.0 → 3.9.1
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.
|
|
4
|
+
"version": "3.9.1",
|
|
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."
|
package/kernel/verify/trace.mjs
CHANGED
|
@@ -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 {
|
|
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,83 @@ export function coveredReqIds(board) {
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
// --- import-graph reachability -----------------------------------------------
|
|
100
|
-
|
|
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
|
+
// 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
|
+
|
|
103
143
|
/**
|
|
104
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
|
+
*
|
|
105
152
|
* @param {string} fromFileAbs - Absolute path of the importing file.
|
|
106
153
|
* @param {string} spec - The import specifier string.
|
|
107
154
|
* @param {string} cwd - Repo root the result is made relative to.
|
|
108
|
-
* @
|
|
109
|
-
*
|
|
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.
|
|
110
158
|
*/
|
|
111
|
-
function resolveSpecifier(fromFileAbs, spec, cwd) {
|
|
112
|
-
if (!spec.startsWith(".")) return null
|
|
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);
|
|
113
162
|
const baseAbs = resolve(dirname(fromFileAbs), spec);
|
|
114
|
-
const candidates = [baseAbs, ...
|
|
163
|
+
const candidates = [baseAbs, ...exts.map((e) => baseAbs + e), ...exts.map((e) => join(baseAbs, "index" + e))];
|
|
115
164
|
for (const c of candidates) {
|
|
116
|
-
if (existsSync(c) && statSync(c).isFile()) return relative(cwd, c).split("\\").join("/");
|
|
165
|
+
if (existsSync(c) && statSync(c).isFile()) return { file: relative(cwd, c).split("\\").join("/"), kind: "resolved" };
|
|
117
166
|
}
|
|
118
|
-
return null;
|
|
167
|
+
if (suffix && !exts.includes(suffix[1])) return { file: null, kind: "asset" };
|
|
168
|
+
return { file: null, kind: "unresolved" };
|
|
119
169
|
}
|
|
120
170
|
|
|
121
171
|
/**
|
|
122
172
|
* Normalize a declared path (entry_point / engine) to an existing repo-relative source file.
|
|
123
173
|
* @param {string} p - The declared path (absolute or cwd-relative).
|
|
124
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).
|
|
125
176
|
* @returns {(string|null)} The repo-relative source path (trying source extensions and `/index`),
|
|
126
177
|
* or null when nothing on disk matches.
|
|
127
178
|
*/
|
|
128
|
-
function resolveFile(p, cwd) {
|
|
179
|
+
function resolveFile(p, cwd, exts = DEFAULT_SOURCE_EXTS) {
|
|
129
180
|
const abs = isAbsolute(p) ? p : resolve(cwd, p);
|
|
130
|
-
const candidates = [abs, ...
|
|
181
|
+
const candidates = [abs, ...exts.map((e) => abs + e), ...exts.map((e) => join(abs, "index" + e))];
|
|
131
182
|
for (const c of candidates) {
|
|
132
183
|
if (existsSync(c) && statSync(c).isFile()) return relative(cwd, c).split("\\").join("/");
|
|
133
184
|
}
|
|
@@ -149,27 +200,37 @@ function importsOf(fileAbs) {
|
|
|
149
200
|
|
|
150
201
|
/**
|
|
151
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
|
+
*
|
|
152
209
|
* @param {string} entryRel - The entry-point path (declared form; resolved on disk).
|
|
153
210
|
* @param {string} cwd - Repo root.
|
|
154
|
-
* @
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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.
|
|
157
216
|
*/
|
|
158
|
-
export function reachableFrom(entryRel, cwd) {
|
|
217
|
+
export function reachableFrom(entryRel, cwd, exts = DEFAULT_SOURCE_EXTS) {
|
|
159
218
|
const reachable = new Set();
|
|
160
|
-
const
|
|
161
|
-
|
|
219
|
+
const unresolved = [];
|
|
220
|
+
const start = resolveFile(entryRel, cwd, exts);
|
|
221
|
+
if (!start) return { reachable, entryResolved: null, unresolved };
|
|
162
222
|
const queue = [start];
|
|
163
223
|
reachable.add(start);
|
|
164
224
|
while (queue.length) {
|
|
165
225
|
const cur = queue.shift();
|
|
166
226
|
const curAbs = resolve(cwd, cur);
|
|
167
227
|
for (const spec of importsOf(curAbs)) {
|
|
168
|
-
const dep = resolveSpecifier(curAbs, spec, cwd);
|
|
228
|
+
const { file: dep, kind } = resolveSpecifier(curAbs, spec, cwd, exts);
|
|
229
|
+
if (kind === "unresolved") unresolved.push({ from: cur, spec });
|
|
169
230
|
if (dep && !reachable.has(dep)) { reachable.add(dep); queue.push(dep); }
|
|
170
231
|
}
|
|
171
232
|
}
|
|
172
|
-
return { reachable, entryResolved: start };
|
|
233
|
+
return { reachable, entryResolved: start, unresolved };
|
|
173
234
|
}
|
|
174
235
|
|
|
175
236
|
// --- Mermaid view (a view of the checked graph, so it cannot drift — §2) ------
|
|
@@ -199,6 +260,75 @@ export function wiringMermaid(wiringMap, unreachableSet) {
|
|
|
199
260
|
return lines.join("\n");
|
|
200
261
|
}
|
|
201
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
|
+
const globs = (c.allowed_file_substrate || []).map(globToRegExp);
|
|
324
|
+
if (!globs.length) continue;
|
|
325
|
+
const owned = sources.filter((f) => globs.some((r) => r.test(f)));
|
|
326
|
+
if (!owned.length) continue;
|
|
327
|
+
rows.push({ scope_id: id, source_files: owned.length, reachable_files: owned.filter((f) => reachable.has(f)).length });
|
|
328
|
+
}
|
|
329
|
+
return rows;
|
|
330
|
+
}
|
|
331
|
+
|
|
202
332
|
// --- the oracle --------------------------------------------------------------
|
|
203
333
|
/**
|
|
204
334
|
* Run the covers-closure + reachability oracle for a slug.
|
|
@@ -206,8 +336,10 @@ export function wiringMermaid(wiringMap, unreachableSet) {
|
|
|
206
336
|
* @param {{cwd:string, gate?:boolean}} opts - cwd (root the SHARED/LOCAL paths resolve against),
|
|
207
337
|
* gate (records mode; the CLI, not this function, turns gate+red into a non-zero exit).
|
|
208
338
|
* @returns {{report:object, mermaid:(string|null)}} The trace report (covers_closure, reachability,
|
|
209
|
-
* findings[], overall "green"|"red") and a Mermaid view when a wiring map
|
|
210
|
-
* self-skips (checked=false) when its artifact is absent — non-regression on
|
|
339
|
+
* scope_reachability, findings[], overall "green"|"red") and a Mermaid view when a wiring map
|
|
340
|
+
* exists. Each arm self-skips (checked=false) when its artifact is absent — non-regression on
|
|
341
|
+
* pre-spine specs — and reachability also self-skips when it cannot root its walk, which is what
|
|
342
|
+
* skips the per-scope arm with it.
|
|
211
343
|
*/
|
|
212
344
|
export function traceLint(slug, { cwd, gate = false }) {
|
|
213
345
|
const shared = sharedRoot(cwd, slug);
|
|
@@ -262,6 +394,10 @@ export function traceLint(slug, { cwd, gate = false }) {
|
|
|
262
394
|
const wiringPath = join(shared, "wiring-map.md");
|
|
263
395
|
const profilePath = join(shared, "project-profile.md");
|
|
264
396
|
let reachability = { checked: false, pass: true, unreachable: [], skipped_reason: "no wiring-map — reachability not applicable." };
|
|
397
|
+
// Hoisted so the per-scope arm below can reuse the walk this one already paid for. Both stay
|
|
398
|
+
// null unless reachability actually ran, which is what gates the second arm on the first.
|
|
399
|
+
let reachableSet = null;
|
|
400
|
+
let walkExts = null;
|
|
265
401
|
let wiringMap = null;
|
|
266
402
|
|
|
267
403
|
let wiringFound = null;
|
|
@@ -308,29 +444,96 @@ export function traceLint(slug, { cwd, gate = false }) {
|
|
|
308
444
|
if (!entryPoint) {
|
|
309
445
|
reachability = { checked: false, pass: true, unreachable: [], skipped_reason: "project-profile has no entry_point." };
|
|
310
446
|
} else {
|
|
311
|
-
const {
|
|
447
|
+
const { exts, declared, from_entry: fromEntry } = sourceExtensions(profile, entryPoint);
|
|
448
|
+
const { reachable, entryResolved, unresolved } = reachableFrom(entryPoint, cwd, exts);
|
|
312
449
|
if (!entryResolved) {
|
|
313
450
|
reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
|
|
314
451
|
skipped_reason: `entry_point "${entryPoint}" does not resolve to a source file on disk — reachability skipped.` };
|
|
315
452
|
findings.push({ severity: "warn", code: "ENTRY-MISSING", message: `project-profile.md entry_point "${entryPoint}" is not on disk — reachability cannot run.` });
|
|
453
|
+
} else if (unresolved.length) {
|
|
454
|
+
// AN INCOMPLETE GRAPH GRADES NOTHING. Every module-shaped relative import the walker could
|
|
455
|
+
// not follow is a missing edge, and a module is "unreachable" only in the sense that this
|
|
456
|
+
// walk did not get there. Publishing that as red produces a check that is red for every
|
|
457
|
+
// input on a stack the resolver cannot read, while reporting that it looked.
|
|
458
|
+
const sample = unresolved.slice(0, 3).map((u) => `${u.spec} (from ${u.from})`);
|
|
459
|
+
reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
|
|
460
|
+
entry_resolved: entryResolved, reachable_files: reachable.size,
|
|
461
|
+
module_extensions: exts, unresolved_imports: unresolved.length, unresolved_sample: sample,
|
|
462
|
+
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.` };
|
|
463
|
+
findings.push({ severity: "warn", code: "GRAPH-INCOMPLETE", message:
|
|
464
|
+
`reachability did not run: ${unresolved.length} relative import(s) could not be resolved (e.g. ${sample.join("; ")}). ` +
|
|
465
|
+
`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})` : "")}. ` +
|
|
466
|
+
"Declare `source_extensions` in project-profile.md to let this arm run." });
|
|
316
467
|
} else {
|
|
468
|
+
const engines = wiringMap.entries || [];
|
|
317
469
|
const unreachable = [];
|
|
318
|
-
for (const e of
|
|
319
|
-
const engResolved = resolveFile(e.engine, cwd);
|
|
320
|
-
|
|
321
|
-
if (!ok) {
|
|
470
|
+
for (const e of engines) {
|
|
471
|
+
const engResolved = resolveFile(e.engine, cwd, exts);
|
|
472
|
+
if (!(engResolved && reachable.has(engResolved))) {
|
|
322
473
|
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
474
|
}
|
|
326
475
|
}
|
|
327
|
-
|
|
328
|
-
|
|
476
|
+
// THE ARM NEEDS ONE POSITIVE CONTROL, AND EVERY-ENGINE-ORPHANED IS NOT ONE. What this
|
|
477
|
+
// check was built to catch is the dead module: one engine with no call site among
|
|
478
|
+
// siblings that have them. When NO engine is reachable, nothing demonstrates that this
|
|
479
|
+
// entry point is the root the app actually runs from — and for whole archetypes it is
|
|
480
|
+
// not. A framework that registers screens declaratively reaches them by name at runtime
|
|
481
|
+
// (`loadContent("pages/Index")`, a route map, a manifest), so its entry file imports a
|
|
482
|
+
// handful of modules and no engine, and the import graph is complete and beside the
|
|
483
|
+
// point. "Every engine is dead" and "I am walking the wrong tree" produce identical
|
|
484
|
+
// evidence, so the arm reports that it could not check rather than picking one.
|
|
485
|
+
//
|
|
486
|
+
// THE COST, NAMED: a wiring map with a single engine can no longer red, because its only
|
|
487
|
+
// engine being unreachable is exactly the indistinguishable case. A genuinely orphaned
|
|
488
|
+
// module in a one-use-case feature is therefore reported as unchecked, not as dead. That
|
|
489
|
+
// is the price of never being red for every input on a stack this walk cannot root.
|
|
490
|
+
if (engines.length && unreachable.length === engines.length) {
|
|
491
|
+
reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
|
|
492
|
+
entry_resolved: entryResolved, module_extensions: exts, reachable_files: reachable.size,
|
|
493
|
+
engines_total: engines.length, engines_reachable: 0,
|
|
494
|
+
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.` };
|
|
495
|
+
findings.push({ severity: "warn", code: "REACH-NO-CONTROL", message:
|
|
496
|
+
`reachability did not run: all ${engines.length} engine(s) are unreachable from entry_point "${entryPoint}", which reaches ${reachable.size} file(s). ` +
|
|
497
|
+
"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. " +
|
|
498
|
+
"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." });
|
|
499
|
+
} else {
|
|
500
|
+
for (const u of unreachable) {
|
|
501
|
+
findings.push({ severity: "red", code: "UC-UNREACHABLE", uc: u.use_case,
|
|
502
|
+
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.` });
|
|
503
|
+
}
|
|
504
|
+
reachableSet = reachable;
|
|
505
|
+
walkExts = exts;
|
|
506
|
+
reachability = { checked: true, entry_point: entryPoint, entry_resolved: entryResolved,
|
|
507
|
+
module_extensions: exts, reachable_files: reachable.size,
|
|
508
|
+
engines_total: engines.length, engines_reachable: engines.length - unreachable.length,
|
|
509
|
+
unreachable, pass: unreachable.length === 0 };
|
|
510
|
+
}
|
|
329
511
|
}
|
|
330
512
|
}
|
|
331
513
|
}
|
|
332
514
|
}
|
|
333
515
|
|
|
516
|
+
// --- per-scope reachability, gated on the first arm having actually run ---------------------
|
|
517
|
+
let scopeReach = { checked: false, scopes: [], orphaned: [],
|
|
518
|
+
skipped_reason: "reachability did not run, so there is no graph to measure a scope against." };
|
|
519
|
+
if (reachableSet) {
|
|
520
|
+
const contracts = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT);
|
|
521
|
+
if (!contracts.length) {
|
|
522
|
+
scopeReach = { checked: false, scopes: [], orphaned: [], skipped_reason: "no scope contracts on disk." };
|
|
523
|
+
} else {
|
|
524
|
+
const rows = scopeReachability(contracts, reachableSet, sourceFilesUnder(cwd, walkExts));
|
|
525
|
+
const orphaned = rows.filter((r) => r.reachable_files === 0);
|
|
526
|
+
scopeReach = { checked: true, scopes: rows, orphaned: orphaned.map((r) => r.scope_id) };
|
|
527
|
+
for (const r of orphaned) {
|
|
528
|
+
findings.push({ severity: "warn", code: "SCOPE-UNREACHABLE", scope: r.scope_id, message:
|
|
529
|
+
`scope ${r.scope_id}: none of its ${r.source_files} source file(s) is reached from the entry point. ` +
|
|
530
|
+
"A build fixture that compiles only what the entry point reaches can be green while this scope's own " +
|
|
531
|
+
"code never compiles — the errors then surface in whichever scope wires the screens in. Warn, not red: " +
|
|
532
|
+
"the wiring may legitimately be a later scope's job." });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
334
537
|
const overall = findings.some((f) => f.severity === "red") ? "red" : "green";
|
|
335
538
|
const report = {
|
|
336
539
|
schema_version: 1,
|
|
@@ -340,6 +543,7 @@ export function traceLint(slug, { cwd, gate = false }) {
|
|
|
340
543
|
advisory: !gate,
|
|
341
544
|
covers_closure: coversClosure,
|
|
342
545
|
reachability,
|
|
546
|
+
scope_reachability: scopeReach,
|
|
343
547
|
findings,
|
|
344
548
|
overall,
|
|
345
549
|
};
|
package/package.json
CHANGED
|
@@ -220,10 +220,19 @@ 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?}.
|
|
224
|
-
{client-only-game|web-service|mobile|library|data-pipeline}; entry_point is the
|
|
225
|
-
seam (a game's main.js is NOT a service's src/server.ts). Validate the enum — a
|
|
226
|
-
not silently disable the check.
|
|
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 where some module does compose the app, name that one rather than the
|
|
228
|
+
file the platform happens to start. Where the screens are registered in a MANIFEST instead (a
|
|
229
|
+
route map, a plugin table, a string-loaded page), no entry point roots an import walk at all —
|
|
230
|
+
measured on a real ArkTS app, where the navigation host imports nothing and every screen arrives
|
|
231
|
+
through route_map.json. The arm then reports that it could not check, and that is the correct
|
|
232
|
+
end state for such a project rather than a profile to keep re-declaring.
|
|
233
|
+
source_extensions is optional and only needed when the project's modules end in something the
|
|
234
|
+
entry point does not (e.g. [".ets"] when the entry is a .ts file); leaving it out costs a
|
|
235
|
+
skipped arm, never a false red. The two probes feed the round build gate (`harness verify
|
|
227
236
|
build`, every round before EVAL): build_probe asserts the BUILT ARTIFACT covers what the run
|
|
228
237
|
wrote (a green exit code is not proof the feature compiled when the toolchain compiles only what
|
|
229
238
|
an entry point reaches); launch_probe installs, starts and asserts the first screen. A `mobile`
|
|
@@ -241,7 +250,14 @@ Do NOT enter MAP SCOPES until Orient is accepted.
|
|
|
241
250
|
requirements.md registry (atomic REQ clauses, frozen ids).
|
|
242
251
|
4. trace-lint — node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" verify trace --slug <slug>. ADVISORY at L1b:
|
|
243
252
|
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.
|
|
253
|
+
reaches entry_point). Promote to --gate only once covers: is populated. Reachability reports
|
|
254
|
+
checked:false with a reason rather than a verdict in two cases, both warns: an import it could
|
|
255
|
+
not follow (the graph is incomplete) and no engine reachable at all (nothing controls the walk,
|
|
256
|
+
so an orphan and a wrong root look identical). Read either as "re-declare the profile", never
|
|
257
|
+
as a clean arm. When it does run it also reports, per scope, how many of that scope's own source
|
|
258
|
+
files the app reaches, and warns (SCOPE-UNREACHABLE) on a scope it reaches none of — a scope's
|
|
259
|
+
build fixture can be green while its code never compiles, on any toolchain that compiles only
|
|
260
|
+
what the entry point reaches. Warn only: the wiring may be a later scope's job by design.
|
|
245
261
|
```
|
|
246
262
|
|
|
247
263
|
---
|
|
@@ -1800,6 +1800,13 @@ while (verdict !== "pass" && round <= maxRounds) {
|
|
|
1800
1800
|
`${gate.detail ? `: ${gate.detail}` : ""}). Treated as undeclared, not as green.`);
|
|
1801
1801
|
}
|
|
1802
1802
|
|
|
1803
|
+
// Trace-lint runs a SECOND time here, and this is the pass that can see anything. At L1b the
|
|
1804
|
+
// scopes' files do not exist yet, so per-scope reachability measures an empty set and its warning
|
|
1805
|
+
// cannot fire. After the round's build gate the code is on disk, which is exactly when "this
|
|
1806
|
+
// scope's fixture is green and none of its code is in the compiled set" becomes a checkable fact
|
|
1807
|
+
// — the shape that once left three scopes green while their files never compiled. Advisory, like
|
|
1808
|
+
// the L1b pass: it overwrites the same projection with a later and better-informed one.
|
|
1809
|
+
await advisory(`verify trace --slug ${slug} --quiet`, "Build", `trace-lint:r${round}`);
|
|
1803
1810
|
await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
|
|
1804
1811
|
{
|
|
1805
1812
|
const g = await crossGate("L2", "Build", ["proceed", "ask", "abort"],
|