shapeup-sdlc 3.7.12 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -2
- package/README.md +13 -11
- package/kernel/lib/argv.mjs +4 -4
- package/kernel/lib/paths.mjs +0 -2
- package/kernel/reduce/hill.mjs +17 -39
- package/kernel/report/export.mjs +0 -3
- package/kernel/schemas/domain.schema.json +13 -92
- package/kernel/verify/env.mjs +2 -1
- package/kernel/verify/ratchet-tree.mjs +1 -1
- package/kernel/verify/t0.mjs +35 -87
- package/kernel/verify/trace.mjs +230 -30
- package/package.json +1 -1
- package/skills/ba-pitch-analyzer/references/task-generation.md +1 -1
- package/skills/hill-chart/assets/dashboard.template.html +1 -1
- package/skills/scope-architect/SKILL.md +3 -3
- package/skills/tech-lead/references/gates.md +20 -6
- package/skills/tech-lead/references/protocol.md +6 -8
package/kernel/verify/t0.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// T0 mechanical verification layer.
|
|
3
3
|
//
|
|
4
|
-
// Runs a scope's e2e fixtures
|
|
5
|
-
// regression check (re-runs every FINISHED scope's fixtures from the registry). Writes one
|
|
4
|
+
// Runs a scope's e2e fixtures and its DB probe (zero LLM tokens). Writes one
|
|
6
5
|
// verdict artifact per attempt that spec-evaluator (T1) must cite; a verdict without it is
|
|
7
6
|
// structurally invalid. No agent can fabricate this file's contents
|
|
8
7
|
// because it is produced by actually running the commands.
|
|
@@ -30,7 +29,7 @@
|
|
|
30
29
|
//
|
|
31
30
|
// Usage:
|
|
32
31
|
// node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" verify t0 <scope-contract.json> \
|
|
33
|
-
// --round N --attempt M [--cwd <dir>] [--out <dir>]
|
|
32
|
+
// --round N --attempt M [--cwd <dir>] [--out <dir>]
|
|
34
33
|
// [--no-ratchet]
|
|
35
34
|
//
|
|
36
35
|
// Exit code: 0 = overall green, 1 = overall red (mirrors the oracle convention), 2 = bad argv.
|
|
@@ -185,80 +184,45 @@ export function runDbProbe(dbProbeCmd, cwd) {
|
|
|
185
184
|
}
|
|
186
185
|
|
|
187
186
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
let registry;
|
|
204
|
-
try {
|
|
205
|
-
registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
206
|
-
} catch {
|
|
207
|
-
return { ran: false, pass: null, scopes_checked: [], failing: [], error: "registry unparsable" };
|
|
208
|
-
}
|
|
209
|
-
const scopes = registry.scopes || [];
|
|
210
|
-
const failing = [];
|
|
211
|
-
for (const s of scopes) {
|
|
212
|
-
const { pass } = runFixtures(s.fixtures, cwd);
|
|
213
|
-
if (!pass) failing.push(s.scope_id);
|
|
214
|
-
}
|
|
215
|
-
return { ran: true, pass: failing.length === 0, scopes_checked: scopes.map((s) => s.scope_id), failing };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Combine fixtures + DB probe + seesaw into the overall T0 verdict.
|
|
220
|
-
* @param {{fixtures:{pass:boolean}, dbProbe:({pass:boolean}|null),
|
|
221
|
-
* seesaw:{ran:boolean,pass:boolean}}} parts - The three sub-results.
|
|
222
|
-
* @returns {{fixtures_green:boolean, db_probe_green:boolean, seesaw_green:boolean,
|
|
223
|
-
* overall:("green"|"red"), regression:boolean}} Per-arm greens, the overall verdict (green iff
|
|
224
|
-
* all three), and `regression` = fixtures+db green but seesaw red (the rollback-and-retry case).
|
|
187
|
+
* Combine the fixtures and the DB probe into the overall T0 verdict.
|
|
188
|
+
*
|
|
189
|
+
* THE SEESAW ARM IS GONE (3.8.0), by a Betting Table decision rather than by neglect. It was
|
|
190
|
+
* declared in the schema, the docs and this function, and nothing ever wrote the registry it read,
|
|
191
|
+
* so it never ran once in any recorded run — while its absence held the hill's top phase shut:
|
|
192
|
+
* FINISHED required `seesaw.ran && seesaw.pass`, and the 38 committed hill shards across the live
|
|
193
|
+
* consumer's features contain no FINISHED at all. A cross-scope regression is still caught by the
|
|
194
|
+
* round build gate, which builds and launches the whole feature once per round; what the arm would
|
|
195
|
+
* have added is attribution and an earlier signal, at the price of re-running every finished
|
|
196
|
+
* scope's fixtures on every attempt — minutes per attempt on an eighteen-scope feature.
|
|
197
|
+
*
|
|
198
|
+
* @param {{fixtures:{pass:boolean}, dbProbe:({pass:boolean}|null)}} parts - The two sub-results.
|
|
199
|
+
* @returns {{fixtures_green:boolean, db_probe_green:boolean, overall:("green"|"red")}} Per-arm
|
|
200
|
+
* greens and the overall verdict, green iff both.
|
|
225
201
|
*/
|
|
226
|
-
export function computeVerdict({ fixtures, dbProbe
|
|
202
|
+
export function computeVerdict({ fixtures, dbProbe }) {
|
|
227
203
|
const fixturesGreen = fixtures.pass;
|
|
228
204
|
const dbGreen = dbProbe === null || dbProbe.pass;
|
|
229
|
-
// A seesaw that did not run does not hold the verdict red — the arm is declared and unwired, and
|
|
230
|
-
// blocking every build on it would be a different defect. It does not make it green either: the
|
|
231
|
-
// hill requires `ran && pass` before a scope may reach FINISHED, and `seesaw_green` here means
|
|
232
|
-
// "nothing this check found is wrong", which is true of a check that found nothing because it
|
|
233
|
-
// never looked.
|
|
234
|
-
const seesawGreen = seesaw.ran ? seesaw.pass === true : true;
|
|
235
205
|
return {
|
|
236
206
|
fixtures_green: fixturesGreen,
|
|
237
207
|
db_probe_green: dbGreen,
|
|
238
|
-
|
|
239
|
-
overall: fixturesGreen && dbGreen && seesawGreen ? "green" : "red",
|
|
240
|
-
// A regression is specifically fixtures/db green but seesaw red — the case that should
|
|
241
|
-
// trigger rollback+retry (spec §3.5) rather than "go fix the new scope's own bug".
|
|
242
|
-
regression: fixturesGreen && dbGreen && !seesawGreen,
|
|
208
|
+
overall: fixturesGreen && dbGreen ? "green" : "red",
|
|
243
209
|
};
|
|
244
210
|
}
|
|
245
211
|
|
|
246
212
|
/**
|
|
247
|
-
* The comparable T0 outcome — a VECTOR, not a float, because the
|
|
213
|
+
* The comparable T0 outcome — a VECTOR, not a float, because the arms are not fungible.
|
|
248
214
|
*
|
|
249
215
|
* Every number here is a reduce over data `writeArtifact` already persists (`fixtures:
|
|
250
216
|
* [{cmd, exit, pass}]`). Nothing new is measured; a number that has always been on disk is
|
|
251
217
|
* finally counted.
|
|
252
218
|
*
|
|
253
|
-
* @param {{fixtures:{results:Array<{pass:boolean}>}, dbProbe:({pass:boolean}|null)
|
|
254
|
-
*
|
|
255
|
-
* @returns {{
|
|
256
|
-
*
|
|
257
|
-
* is never a failure — only an absence.
|
|
219
|
+
* @param {{fixtures:{results:Array<{pass:boolean}>}, dbProbe:({pass:boolean}|null)}} parts - The
|
|
220
|
+
* two T0 sub-results.
|
|
221
|
+
* @returns {{fixtures_passed:number, fixtures_total:number, db_probe:(0|1|null)}} The score vector.
|
|
222
|
+
* `db_probe` is null when no probe is declared, which is never a failure — only an absence.
|
|
258
223
|
*/
|
|
259
|
-
export function score({ fixtures, dbProbe
|
|
224
|
+
export function score({ fixtures, dbProbe }) {
|
|
260
225
|
return {
|
|
261
|
-
regressions: seesaw?.ran ? (seesaw.failing || []).length : 0,
|
|
262
226
|
fixtures_passed: fixtures.results.filter((r) => r.pass).length,
|
|
263
227
|
fixtures_total: fixtures.results.length,
|
|
264
228
|
db_probe: dbProbe === null || dbProbe === undefined ? null : (dbProbe.pass ? 1 : 0),
|
|
@@ -271,23 +235,19 @@ export function score({ fixtures, dbProbe, seesaw }) {
|
|
|
271
235
|
* Three decisions worth defending:
|
|
272
236
|
* • A TIE IS NOT BETTER. A tie that counted as an improvement would make a sawtooth look like a
|
|
273
237
|
* ratchet, and the whole point of the Day-1 measurement is to tell those two apart.
|
|
274
|
-
* • REGRESSIONS DOMINATE. Breaking a previously-finished scope is never an improvement, whatever
|
|
275
|
-
* the new scope's fixtures did. This is what lets the old seesaw branch collapse into the
|
|
276
|
-
* general rule rather than needing a special case.
|
|
277
238
|
* • DIFFERENT `fixtures_total` IS INCOMPARABLE, not worse. A re-slice changes the
|
|
278
239
|
* denominator; comparing across it is a category error, so the ratchet treats it as a baseline
|
|
279
240
|
* reset (`rebased`) rather than issuing a false verdict.
|
|
280
241
|
*
|
|
281
|
-
* @param {{
|
|
242
|
+
* @param {{fixtures_passed:number, fixtures_total:number,
|
|
282
243
|
* db_probe:(0|1|null)}} next - The candidate score.
|
|
283
|
-
* @param {({
|
|
244
|
+
* @param {({fixtures_passed:number, fixtures_total:number,
|
|
284
245
|
* db_probe:(0|1|null)}|null)} current - The incumbent score, or null for the first trial.
|
|
285
246
|
* @returns {(boolean|null)} true = strictly better · false = not better · null = incomparable.
|
|
286
247
|
*/
|
|
287
248
|
export function better(next, current) {
|
|
288
249
|
if (current === null || current === undefined) return true; // baseline
|
|
289
250
|
if (next.fixtures_total !== current.fixtures_total) return null; // the contract changed
|
|
290
|
-
if (next.regressions !== current.regressions) return next.regressions < current.regressions;
|
|
291
251
|
if (next.fixtures_passed !== current.fixtures_passed) return next.fixtures_passed > current.fixtures_passed;
|
|
292
252
|
if (next.db_probe !== current.db_probe) return (next.db_probe ?? 0) > (current.db_probe ?? 0);
|
|
293
253
|
// EVERY COMPONENT TIES. What that means depends entirely on whether the incumbent was green.
|
|
@@ -314,17 +274,17 @@ export function better(next, current) {
|
|
|
314
274
|
}
|
|
315
275
|
|
|
316
276
|
/**
|
|
317
|
-
* Is this score a clean pass — every fixture passing, none of them absent
|
|
277
|
+
* Is this score a clean pass — every fixture passing, and none of them absent?
|
|
318
278
|
*
|
|
319
279
|
* `fixtures_total > 0` is load-bearing: a scope with no fixtures has nothing to be green ABOUT, and
|
|
320
280
|
* treating its empty score as a pass is the same absence-reads-as-success mistake `runFixtures`
|
|
321
281
|
* made one function above.
|
|
322
282
|
*
|
|
323
|
-
* @param {{
|
|
283
|
+
* @param {{fixtures_passed:number, fixtures_total:number}} s - A trial score.
|
|
324
284
|
* @returns {boolean} True when the score represents a real, complete pass.
|
|
325
285
|
*/
|
|
326
286
|
function isGreenScore(s) {
|
|
327
|
-
return s.
|
|
287
|
+
return s.fixtures_total > 0 && s.fixtures_passed === s.fixtures_total;
|
|
328
288
|
}
|
|
329
289
|
|
|
330
290
|
/**
|
|
@@ -352,7 +312,7 @@ export function decideStatus(verdict, crashed) {
|
|
|
352
312
|
* Human-readable one-line summary of a score change, for the trial row's `delta` field.
|
|
353
313
|
* @param {object} next - The candidate score.
|
|
354
314
|
* @param {(object|null)} current - The incumbent score, or null.
|
|
355
|
-
* @returns {string} e.g. "+2 fixtures", "
|
|
315
|
+
* @returns {string} e.g. "+2 fixtures", "-1 db_probe", "baseline", "no change".
|
|
356
316
|
*/
|
|
357
317
|
export function describeDelta(next, current) {
|
|
358
318
|
if (!current) return "baseline";
|
|
@@ -360,10 +320,8 @@ export function describeDelta(next, current) {
|
|
|
360
320
|
return `denominator ${current.fixtures_total} → ${next.fixtures_total}`;
|
|
361
321
|
}
|
|
362
322
|
const parts = [];
|
|
363
|
-
const dr = next.regressions - current.regressions;
|
|
364
323
|
const df = next.fixtures_passed - current.fixtures_passed;
|
|
365
324
|
const dp = (next.db_probe ?? 0) - (current.db_probe ?? 0);
|
|
366
|
-
if (dr) parts.push(`${dr > 0 ? "+" : ""}${dr} regression${Math.abs(dr) === 1 ? "" : "s"}`);
|
|
367
325
|
if (df) parts.push(`${df > 0 ? "+" : ""}${df} fixture${Math.abs(df) === 1 ? "" : "s"}`);
|
|
368
326
|
if (dp) parts.push(`${dp > 0 ? "+" : ""}${dp} db_probe`);
|
|
369
327
|
return parts.length ? parts.join(", ") : "no change";
|
|
@@ -458,7 +416,7 @@ function sha256(text) {
|
|
|
458
416
|
* every superseded object remains addressable).
|
|
459
417
|
*
|
|
460
418
|
* WHAT THIS REPLACED, and why the remedy is `wx` rather than a guard. The address used to be
|
|
461
|
-
* `r<round>-a<attempt>.json`, written with a bare `writeFileSync` — and on a
|
|
419
|
+
* `r<round>-a<attempt>.json`, written with a bare `writeFileSync` — and on a revert-and-retry the
|
|
462
420
|
* protocol says stash, then RETRY THIS ATTEMPT, same attempt number. The address had no term for
|
|
463
421
|
* the retry, so the artifact recording the regression was silently replaced by the one recording
|
|
464
422
|
* the recovery, at the same path. Reproduced against the shipped script: two runs at
|
|
@@ -506,14 +464,12 @@ export function writeArtifact(outDir, round, attempt, verdictBody) {
|
|
|
506
464
|
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
507
465
|
export const ARGV_SPEC = {
|
|
508
466
|
usage: "harness.mjs verify t0 <scope-contract.json> --round N --attempt M [--cwd <dir>] [--out <dir>] " +
|
|
509
|
-
"[--
|
|
467
|
+
"[--no-ratchet]",
|
|
510
468
|
_: { arity: 1, max: 1, name: "scope-contract.json" },
|
|
511
469
|
round: { type: "int", min: 1, required: true },
|
|
512
470
|
attempt: { type: "int", min: 1, required: true },
|
|
513
471
|
cwd: { type: "path" },
|
|
514
472
|
out: { type: "path" },
|
|
515
|
-
"seesaw-registry": { type: "path" },
|
|
516
|
-
"no-seesaw": { type: "flag" },
|
|
517
473
|
"no-ratchet": { type: "flag" },
|
|
518
474
|
};
|
|
519
475
|
|
|
@@ -556,14 +512,7 @@ export async function cli(rawArgv) {
|
|
|
556
512
|
|
|
557
513
|
const fixtures = runFixtures(contract.e2e_verification_fixtures, cwd);
|
|
558
514
|
const dbProbe = runDbProbe(contract.db_probe, cwd);
|
|
559
|
-
|
|
560
|
-
// standalone CLI use without it simply skips the seesaw check rather than guessing a path.
|
|
561
|
-
const seesawRegistry = args.noSeesaw ? null : args.seesawRegistry || null;
|
|
562
|
-
const seesaw = args.noSeesaw || fixtures.pass === false
|
|
563
|
-
? { ran: false, pass: true, scopes_checked: [], failing: [] } // don't seesaw on an already-red attempt
|
|
564
|
-
: seesawCheck(seesawRegistry, cwd);
|
|
565
|
-
|
|
566
|
-
const verdict = computeVerdict({ fixtures, dbProbe, seesaw });
|
|
515
|
+
const verdict = computeVerdict({ fixtures, dbProbe });
|
|
567
516
|
const discovered = verdict.overall === "red" ? digestFailures({ fixtures, dbProbe }) : [];
|
|
568
517
|
|
|
569
518
|
// ---- the ratchet ---------------------------------------------------------------------
|
|
@@ -573,7 +522,7 @@ export async function cli(rawArgv) {
|
|
|
573
522
|
const trialsPath = join(outDir, "t0", "trials.jsonl");
|
|
574
523
|
const priorTrials = readTrials(trialsPath).filter((t) => t.scope_id === contract.scope_id);
|
|
575
524
|
const baseline = [...priorTrials].reverse().find((t) => t.status === "kept" || t.status === "rebased") || null;
|
|
576
|
-
const s = score({ fixtures, dbProbe
|
|
525
|
+
const s = score({ fixtures, dbProbe });
|
|
577
526
|
const verdictBetter = better(s, baseline ? baseline.score : null);
|
|
578
527
|
const crashed = fixtures.results.some((r) => r.error) || !!dbProbe?.error;
|
|
579
528
|
const { status, action } = decideStatus(verdictBetter, crashed);
|
|
@@ -598,7 +547,6 @@ export async function cli(rawArgv) {
|
|
|
598
547
|
// could not tell apart, and why `exit` still reads the way it always did.
|
|
599
548
|
fixtures: fixtures.results.map((r) => commandEvidence(r)),
|
|
600
549
|
db_probe: commandEvidence(dbProbe),
|
|
601
|
-
seesaw,
|
|
602
550
|
...verdict,
|
|
603
551
|
score: s,
|
|
604
552
|
discovered_tasks: discovered,
|
|
@@ -654,7 +602,7 @@ export async function cli(rawArgv) {
|
|
|
654
602
|
appendTrial(trialsPath, row);
|
|
655
603
|
|
|
656
604
|
console.log(JSON.stringify({
|
|
657
|
-
path, sha256: hash, trial, overall: verdict.overall,
|
|
605
|
+
path, sha256: hash, trial, overall: verdict.overall,
|
|
658
606
|
score: s, status, baseline_trial: row.baseline_trial, delta: row.delta,
|
|
659
607
|
tree_ref: row.tree_ref ?? keptRef(contract.scope_id),
|
|
660
608
|
}, null, 2));
|
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,81 @@ 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
|
+
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
|
-
* @
|
|
109
|
-
*
|
|
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
|
|
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, ...
|
|
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, ...
|
|
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
|
-
* @
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
161
|
-
|
|
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 {
|
|
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
|
|
319
|
-
const engResolved = resolveFile(e.engine, cwd);
|
|
320
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
@@ -614,7 +614,7 @@ or entirely `apps/api/**` with no cross-layer flow is the PA1 failure mode — r
|
|
|
614
614
|
}
|
|
615
615
|
```
|
|
616
616
|
`hill_phase` is always written `UPHILL_UNKNOWN` at generation time — it is derived later from
|
|
617
|
-
mechanical T0/T1
|
|
617
|
+
mechanical T0/T1 facts, never declared by `ba`. `superseded_by` stays
|
|
618
618
|
`null` until a scope-architect `map-scopes` order retires this contract in favor of its replacements.
|
|
619
619
|
|
|
620
620
|
**PA2 size lint:** a scope whose `allowed_file_substrate` glob set resolves to more than ~15
|