shapeup-sdlc 3.18.2 → 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.
- package/.claude/settings.local.example.json +2 -3
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +115 -56
- package/README.md +101 -143
- package/SECURITY.md +16 -25
- package/agents/orient.md +14 -0
- package/agents/qa-edge-hunter.md +13 -0
- package/agents/spec-evaluator.md +14 -0
- package/agents/task-executor.md +14 -0
- package/bin/init.mjs +13 -16
- package/bin/lib/grant.mjs +8 -19
- package/commands/build.md +9 -14
- package/commands/eval.md +3 -3
- package/commands/hammer.md +9 -5
- package/commands/retro.md +12 -11
- package/commands/scopes.md +1 -12
- package/commands/ship.md +45 -88
- package/commands/wire.md +1 -8
- package/hooks/dispatch-receipt.mjs +94 -8
- package/hooks/hooks.json +0 -41
- package/hooks/lib/decision.mjs +6 -9
- package/hooks/lib/tier.mjs +50 -0
- package/hooks/sandbox-guard.mjs +140 -64
- package/kernel/gate.mjs +203 -22
- package/kernel/harness.mjs +20 -67
- package/kernel/init/run.mjs +175 -523
- package/kernel/lib/contract.mjs +9 -93
- package/kernel/lib/floor.mjs +64 -0
- package/kernel/lib/paths.mjs +39 -112
- package/kernel/order.mjs +157 -0
- package/kernel/probe/digest.mjs +2 -38
- package/kernel/schemas/domain.schema.json +146 -1125
- package/kernel/schemas/work-order.schema.json +8 -4
- package/kernel/schemas/work-result.schema.json +1 -1
- package/kernel/verify/envelope.mjs +2 -2
- package/kernel/verify/skills.mjs +27 -8
- package/kernel/verify/t0.mjs +2 -2
- package/package.json +4 -2
- package/skills/building/SKILL.md +144 -0
- package/skills/building/resources/gates.md +76 -0
- package/skills/building/resources/guidance.md +23 -0
- package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
- package/skills/building/resources/intake.md +157 -0
- package/skills/building/resources/orders.md +69 -0
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
- package/skills/building/resources/plan-scopes.md +95 -0
- package/skills/building/resources/plan-spec.md +102 -0
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
- package/skills/building/resources/plan-wire.md +82 -0
- package/skills/building/resources/plan.md +98 -0
- package/skills/building/resources/retro-research.md +60 -0
- package/skills/building/resources/retro-scan.md +49 -0
- package/skills/building/resources/retro.md +225 -0
- package/skills/building/resources/rounds.md +78 -0
- package/skills/building/resources/stop.md +127 -0
- package/skills/orient/SKILL.md +37 -37
- package/skills/qa-edge-hunter/SKILL.md +29 -14
- package/skills/shapeup/SKILL.md +5 -5
- package/skills/shapeup/resources/breadboarding.md +1 -1
- package/skills/shapeup/resources/context-compaction.md +5 -5
- package/skills/shapeup/resources/shaping.md +1 -1
- package/skills/spec-evaluator/SKILL.md +17 -21
- package/skills/spec-evaluator/references/dimension-contract.md +1 -1
- package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
- package/skills/task-executor/SKILL.md +11 -15
- package/commands/hill.md +0 -10
- package/hooks/gate-intake.mjs +0 -109
- package/hooks/gate-zerowork.mjs +0 -349
- package/hooks/tier-guard.mjs +0 -162
- package/kernel/compile.mjs +0 -1419
- package/kernel/init/fit.mjs +0 -199
- package/kernel/init/run-args.mjs +0 -206
- package/kernel/lib/breadboard.mjs +0 -165
- package/kernel/probe/attempts.mjs +0 -161
- package/kernel/probe/concurrency.mjs +0 -535
- package/kernel/probe/eval.mjs +0 -405
- package/kernel/probe/hunt.mjs +0 -104
- package/kernel/probe/leg.mjs +0 -229
- package/kernel/probe/owner.mjs +0 -142
- package/kernel/probe/requirements.mjs +0 -328
- package/kernel/probe/resume.mjs +0 -1110
- package/kernel/probe/rounds.mjs +0 -115
- package/kernel/probe/staged.mjs +0 -83
- package/kernel/probe/stats.mjs +0 -477
- package/kernel/probe/t0.mjs +0 -115
- package/kernel/reduce/board.mjs +0 -350
- package/kernel/reduce/graph.mjs +0 -482
- package/kernel/reduce/hill.mjs +0 -322
- package/kernel/reduce/ingest.mjs +0 -767
- package/kernel/reduce/leftovers.mjs +0 -159
- package/kernel/reduce/ship.mjs +0 -529
- package/kernel/reduce/snapshot.mjs +0 -291
- package/kernel/reduce/verdict.mjs +0 -172
- package/kernel/report/export.mjs +0 -384
- package/kernel/report/facts.mjs +0 -234
- package/kernel/verify/budget.mjs +0 -153
- package/kernel/verify/build.mjs +0 -428
- package/kernel/verify/dispatch.mjs +0 -114
- package/kernel/verify/spec.mjs +0 -975
- package/kernel/verify/trace.mjs +0 -604
- package/skills/ba-pitch-analyzer/SKILL.md +0 -204
- package/skills/coach/SKILL.md +0 -397
- package/skills/hill-chart/SKILL.md +0 -154
- package/skills/hill-chart/assets/dashboard.template.html +0 -1432
- package/skills/scope-architect/SKILL.md +0 -174
- package/skills/scope-hammer/SKILL.md +0 -231
- package/skills/solution-architect/SKILL.md +0 -158
- package/skills/tech-lead/SKILL.md +0 -151
- package/skills/tech-lead/references/gates.md +0 -605
- package/skills/tech-lead/references/protocol.md +0 -889
- package/skills/tech-lead/references/tiny-lane.md +0 -53
- package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
- package/skills/translator/SKILL.md +0 -258
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
package/kernel/verify/spec.mjs
DELETED
|
@@ -1,975 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Spec lint — the checks that must hold before a board becomes work.
|
|
3
|
-
//
|
|
4
|
-
// The mechanical half of the old ba-pitch-analyzer Phase 7a self-audit + Phase 7c parse steps
|
|
5
|
-
// + Phase 6b PA1/PA2 lints — checkbox walking and glob checks a model should never grade on
|
|
6
|
-
// its own output (a worker grading itself was always a judge-purity smell):
|
|
7
|
-
//
|
|
8
|
-
// PA1 a scope substrate aligned 1:1 with a single top-level directory (directory-thinking)
|
|
9
|
-
// PA2 a scope substrate resolving to more than the size cap (~15 files)
|
|
10
|
-
// DISJOINT a path matched by two scopes' allowed_file_substrate without BOTH declaring it
|
|
11
|
-
// in shared_substrate
|
|
12
|
-
// STRUCTURE spec tree completeness (usecases/ ≥1 UC, domain-model, UC ## Steps),
|
|
13
|
-
// unresolved wikilinks, task frontmatter completeness, unlocks edge-symmetry,
|
|
14
|
-
// depends_on referencing unknown tasks
|
|
15
|
-
// TIER-DIRECTION ANY committed (SHARED) file referencing the LOCAL tier: a `TASK-` id in
|
|
16
|
-
// prose, a table cell or frontmatter, or a path into .shapeup/. Scans the whole
|
|
17
|
-
// shapeup/<slug>/ tree, because the leak was never confined to the two corners the
|
|
18
|
-
// narrower checks watched — measured across nine runs it was 264 ids in
|
|
19
|
-
// spec/synthesis.md, 183 in scope-summary.md, 136 in scope-board.md, all in cells
|
|
20
|
-
// and sentences no wikilink check can see. Persisted links flow LOCAL→SHARED only:
|
|
21
|
-
// board ids renumber per machine and .shapeup/ is gitignored, so a committed
|
|
22
|
-
// reference resolves for its author and nobody else. Cite the UC or scope_id.
|
|
23
|
-
// (shapeup/knowledge-base/ is a sibling of the slug tree, so it is outside this
|
|
24
|
-
// walk by construction — those files instruct workers, they do not cite artifacts.)
|
|
25
|
-
// UC-ANCHOR a task whose use_case_refs is empty or names a UC with no usecases/UC-*.md —
|
|
26
|
-
// the LOCAL→SHARED anchor must be complete (single-anchor rule; SPIKE/CHORE/
|
|
27
|
-
// DOCS/MIGRATION tasks anchor elsewhere and are exempt)
|
|
28
|
-
// SCOPE-ANCHOR the same rule for the other direction's artifact: a scope contract whose
|
|
29
|
-
// use_cases is empty or names a UC with no usecases/UC-*.md. The contract is
|
|
30
|
-
// committed, so this anchor is what the scope↔task join is re-derived through
|
|
31
|
-
// SCOPE-DEPS a contract's depends_on naming itself, a scope not in this run, or a CYCLE —
|
|
32
|
-
// build ORDER lives in the committed tier now, and the scheduler answers a cycle by
|
|
33
|
-
// dumping every remaining scope into one unordered wave without reporting it
|
|
34
|
-
// SCOPE-COVERS a contract's covers entry that is not a REQ-id (warn), or names a REQ that
|
|
35
|
-
// is not in requirements.md (red, when a registry exists) — shape alone let a scope
|
|
36
|
-
// claim coverage of a requirement that does not exist
|
|
37
|
-
// REQ-UNCOVERED the other direction of the same edge: a registered requirement still marked
|
|
38
|
-
// covered that NO acceptance criterion grades and NO scope claims. SCOPE-COVERS asks
|
|
39
|
-
// whether a link resolves; this asks whether a requirement has one at all. Red here
|
|
40
|
-
// and only advisory in trace-lint, because a requirement nothing reaches is a plan
|
|
41
|
-
// defect the PO can still answer at L1b — cover it, or cut it on the record
|
|
42
|
-
// SCOPE-PARTITION a task claimed by more than one scope. The UC anchor is a SPEC link, not an
|
|
43
|
-
// assignment: one use case is routinely implemented by several scopes, so on a
|
|
44
|
-
// four-scope/one-UC cut every scope claimed every task and would build all of them.
|
|
45
|
-
// Resolved by a `scope_id:` on the task (LOCAL→SHARED) or by re-cutting
|
|
46
|
-
// INV-FLOOR the raw idea (intake.md) names explicit constraints (a No-gos/Constraints/
|
|
47
|
-
// Edge-cases heading with real content under it) but no usecases/UC-*.md declares
|
|
48
|
-
// a single [INV-NN] anywhere — a criteria-count check can't tell a healthy small
|
|
49
|
-
// tree from one that silently derived nothing from the pitch
|
|
50
|
-
// BREADBOARD-PLACE (red) a breadboard Place that owns UI affordances has no ux-behavior.md
|
|
51
|
-
// `## Screen: … (P#)` section and is not under `## Deferred Places`
|
|
52
|
-
// BREADBOARD-UI (red) a UI affordance (U#) not cited inside the screen section of any Place the
|
|
53
|
-
// breadboard puts it in. CITING IS NOT PLACING: a U# specified under another Place's
|
|
54
|
-
// screen passes a presence check and is exactly the defect this rule exists for
|
|
55
|
-
// BREADBOARD-TRACE (warn) N#/S# cited nowhere in the spec, V# slices no scope board records,
|
|
56
|
-
// U# the spec places that no manifest entry names as its `source`
|
|
57
|
-
// BREADBOARD-UNPARSED (warn) a staged breadboard this reader finds no ids in — a layout it
|
|
58
|
-
// cannot read must never become a hard stop
|
|
59
|
-
// All four are silent when the run has no breadboard (staged, or inline in the intake).
|
|
60
|
-
//
|
|
61
|
-
// Zero dependencies (glob matcher inlined from hooks/sandbox-guard.mjs). Judgment stays in the skill
|
|
62
|
-
// (gap severity, lens choice); this script only reports facts.
|
|
63
|
-
//
|
|
64
|
-
// Usage: node kernel/harness.mjs verify spec --slug <slug> [--cwd <dir>]
|
|
65
|
-
// Prints a JSON report. Exit 0 = no red findings, 1 = at least one red.
|
|
66
|
-
|
|
67
|
-
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
68
|
-
import { resolve, join, relative } from "node:path";
|
|
69
|
-
import { parseBoard, deriveUnlocks } from "../reduce/board.mjs";
|
|
70
|
-
import { runArgs } from "../lib/argv.mjs";
|
|
71
|
-
import { LOCAL } from "../lib/paths.mjs";
|
|
72
|
-
import { specDir, scopesDir, tasksDir, intake, sharedRoot, requirements } from "../lib/paths.mjs";
|
|
73
|
-
import { readAllContracts, unreadableReason, ucId, reqId, scopePartitionConflicts, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
74
|
-
import { UNREADABLE, LEGACY_LAYOUT } from "../lib/contract.mjs";
|
|
75
|
-
import { validate as validateAgainstSchema, SCHEMAS_DIR } from "./envelope.mjs";
|
|
76
|
-
import { breadboard as stagedBreadboard } from "../lib/paths.mjs";
|
|
77
|
-
import { parseBreadboard, hasBreadboardTables, idCounts } from "../lib/breadboard.mjs";
|
|
78
|
-
// ONE implementation of covers-closure, two reporters: trace-lint narrates it, spec-lint gates it.
|
|
79
|
-
// Re-deriving either here is how the advisory report and the gate start disagreeing about which
|
|
80
|
-
// requirement is covered. This closes the import ring spec → trace → compile → probe/resume → spec,
|
|
81
|
-
// which holds only while no module in it dereferences an imported binding at module-evaluation
|
|
82
|
-
// time — do NOT add a top-level `const x = someImportedFn()` to any of the four.
|
|
83
|
-
import { parseRequirements, coveredReqIds } from "./trace.mjs";
|
|
84
|
-
import { readBoard } from "../compile.mjs";
|
|
85
|
-
|
|
86
|
-
// Inlined from hooks/sandbox-guard.mjs so this skill ships self-contained (a skill's scripts
|
|
87
|
-
// must not reach outside its own folder — channels that copy only skills/ would dangle).
|
|
88
|
-
/**
|
|
89
|
-
* Compile a substrate glob into an anchored RegExp (inlined from sandbox-guard so the skill ships
|
|
90
|
-
* self-contained). Supports single-star, double-star, and double-star-slash segment wildcards.
|
|
91
|
-
* @param {string} glob - The glob pattern.
|
|
92
|
-
* @returns {RegExp} A full-string (`^…$`) matcher for repo-relative paths.
|
|
93
|
-
*/
|
|
94
|
-
export function globToRegExp(glob) {
|
|
95
|
-
let re = "";
|
|
96
|
-
for (let i = 0; i < glob.length; i++) {
|
|
97
|
-
const c = glob[i];
|
|
98
|
-
if (c === "*") {
|
|
99
|
-
if (glob[i + 1] === "*") {
|
|
100
|
-
re += glob[i + 2] === "/" ? "(?:[^/]+/)*" : ".*";
|
|
101
|
-
i += glob[i + 2] === "/" ? 2 : 1;
|
|
102
|
-
} else re += "[^/]*";
|
|
103
|
-
} else if ("\\^$.|?+()[]{}".includes(c)) re += "\\" + c;
|
|
104
|
-
else re += c;
|
|
105
|
-
}
|
|
106
|
-
return new RegExp(`^${re}$`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const SIZE_CAP = 15;
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Recursively list repo-relative file paths under a root, skipping .git/node_modules/.shapeup.
|
|
113
|
-
* @param {string} root - The base the results are made relative to.
|
|
114
|
-
* @param {string} [dir=root] - Current directory being walked (callers omit it).
|
|
115
|
-
* @param {string[]} [acc=[]] - Accumulator (callers omit it).
|
|
116
|
-
* @returns {string[]} Repo-relative paths of every file found.
|
|
117
|
-
*/
|
|
118
|
-
function walkFiles(root, dir = root, acc = []) {
|
|
119
|
-
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
120
|
-
if (e.name === ".git" || e.name === "node_modules" || e.name === LOCAL) continue;
|
|
121
|
-
const p = join(dir, e.name);
|
|
122
|
-
if (e.isDirectory()) walkFiles(root, p, acc);
|
|
123
|
-
else acc.push(relative(root, p));
|
|
124
|
-
}
|
|
125
|
-
return acc;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Lint scope contracts for PA1 (directory-thinking), PA2 (size cap), and DISJOINT substrate overlap.
|
|
130
|
-
* @param {Array<{scope_id:string, topology_type?:string, allowed_file_substrate?:string[],
|
|
131
|
-
* shared_substrate?:string[]}>} scopes - The scope contracts.
|
|
132
|
-
* @param {string[]} repoFiles - Repo-relative file list the substrate globs resolve against.
|
|
133
|
-
* @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} Findings; [] when clean.
|
|
134
|
-
*/
|
|
135
|
-
export function lintScopes(scopes, repoFiles) {
|
|
136
|
-
const findings = [];
|
|
137
|
-
/**
|
|
138
|
-
* Resolve a list of substrate globs to the repo files they match.
|
|
139
|
-
* @param {(string[]|undefined)} globs - Glob patterns (undefined → none).
|
|
140
|
-
* @returns {string[]} The repo-relative files matched by any glob (deduplication is the caller's).
|
|
141
|
-
*/
|
|
142
|
-
const resolveGlobs = (globs) => {
|
|
143
|
-
const res = (globs || []).map((g) => globToRegExp(g));
|
|
144
|
-
return repoFiles.filter((f) => res.some((r) => r.test(f)));
|
|
145
|
-
};
|
|
146
|
-
for (const s of scopes) {
|
|
147
|
-
const allowed = s.allowed_file_substrate || [];
|
|
148
|
-
// PA1 — directory-thinking: every glob confined to ONE layer directory (e.g. all of it
|
|
149
|
-
// under apps/web/). A flow slice crosses layers (apps/web/cart + apps/api/cart passes).
|
|
150
|
-
const layers = new Set(allowed.map((g) => g.split("/").slice(0, 2).join("/")));
|
|
151
|
-
if (allowed.length && layers.size === 1 && s.topology_type !== "CHOWDER") {
|
|
152
|
-
findings.push({ rule: "PA1", level: "red", scope: s.scope_id, detail: `substrate aligns 1:1 with '${[...layers][0]}/' — slice by flow, not by directory` });
|
|
153
|
-
}
|
|
154
|
-
// PA2 — resolved file count over the cap (chowder absorbs true strays).
|
|
155
|
-
const files = resolveGlobs(allowed);
|
|
156
|
-
if (files.length > SIZE_CAP && s.topology_type !== "CHOWDER") {
|
|
157
|
-
findings.push({ rule: "PA2", level: "warn", scope: s.scope_id, detail: `substrate resolves to ${files.length} files (cap ~${SIZE_CAP}) — consider splitting` });
|
|
158
|
-
}
|
|
159
|
-
// T0-UNVERIFIABLE — a scope with no fixtures the parser can see.
|
|
160
|
-
//
|
|
161
|
-
// RED, not warn, and it is the most important line in this file. `runFixtures` used to answer
|
|
162
|
-
// `pass: true` for an empty list (`[].every(…)` is `true`), so a scope whose fixtures did not
|
|
163
|
-
// PARSE was certified T0-green having executed nothing — "measured, not claimed" inverted into
|
|
164
|
-
// "nothing measured, therefore green", in the one layer the evaluator must cite.
|
|
165
|
-
//
|
|
166
|
-
// It is not hypothetical and it is not an authoring slip in the usual sense: the architect wrote
|
|
167
|
-
// GOOD fixtures (`node --test test/…`) as a markdown `## e2e_verification_fixtures` section
|
|
168
|
-
// instead of a frontmatter key. The substrate list beside it, written as a frontmatter block
|
|
169
|
-
// list, parsed perfectly. One field silently reached the scorer as `undefined` and the scope
|
|
170
|
-
// went green on zero evidence.
|
|
171
|
-
//
|
|
172
|
-
// `verify t0` now refuses to call an unrun scope green, so the failure is loud either way. This
|
|
173
|
-
// catches it one gate earlier, where the fix is editing a contract rather than burning a round.
|
|
174
|
-
if (!(s.e2e_verification_fixtures || []).length) {
|
|
175
|
-
findings.push({
|
|
176
|
-
rule: "T0-UNVERIFIABLE", level: "red", scope: s.scope_id,
|
|
177
|
-
detail: "no e2e_verification_fixtures the parser can see — T0 has nothing to run, so this scope " +
|
|
178
|
-
"cannot be verified. Declare them as a FRONTMATTER key (a `- ` block list or a [a, b] inline " +
|
|
179
|
-
"list); a `## e2e_verification_fixtures` markdown section is not read, and a scope whose " +
|
|
180
|
-
"fixtures silently vanish is a scope certified on no evidence.",
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
// T0-UNPASSABLE — a fixture whose own comment declares a non-zero exit.
|
|
184
|
-
//
|
|
185
|
-
// `verify t0` scores a fixture as passing iff it exits 0, and that rule lived only in the
|
|
186
|
-
// scorer. Handed a contract that said "commands that drive this scope end-to-end", an architect
|
|
187
|
-
// wrote the error paths as bare invocations — `todo done abc # E_INVALID_INDEX, exit 1` — which
|
|
188
|
-
// cannot pass by construction. Four of six scopes then burned their attempt budget on code that
|
|
189
|
-
// was already correct, and the run reported them as hard scopes.
|
|
190
|
-
//
|
|
191
|
-
// Caught here because the cost of finding it late is measured in whole attempts: this is the
|
|
192
|
-
// last gate before BUILD spends anything. Matched on the author's OWN declaration of a non-zero
|
|
193
|
-
// exit, not on guessing what a command does — a fixture that merely mentions "exit" in another
|
|
194
|
-
// sense does not match, and a warn never blocks a run.
|
|
195
|
-
for (const fx of s.e2e_verification_fixtures || []) {
|
|
196
|
-
const m = String(fx).match(/#[^#]*\bexit\s+([1-9]\d*)\b/i);
|
|
197
|
-
if (m) {
|
|
198
|
-
findings.push({
|
|
199
|
-
rule: "T0-UNPASSABLE", level: "warn", scope: s.scope_id,
|
|
200
|
-
detail: `fixture declares "exit ${m[1]}" — T0 passes a fixture only on exit 0, so this scope cannot go green. ` +
|
|
201
|
-
`Assert the error path inside a test file that itself exits 0: ${String(fx).slice(0, 60)}`,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
// DISJOINT — pairwise overlap not covered by BOTH scopes' shared_substrate.
|
|
207
|
-
for (let i = 0; i < scopes.length; i++) {
|
|
208
|
-
for (let j = i + 1; j < scopes.length; j++) {
|
|
209
|
-
const a = scopes[i], b = scopes[j];
|
|
210
|
-
const filesA = new Set(resolveGlobs(a.allowed_file_substrate));
|
|
211
|
-
const overlap = resolveGlobs(b.allowed_file_substrate).filter((f) => filesA.has(f));
|
|
212
|
-
const sharedA = (a.shared_substrate || []).map(globToRegExp);
|
|
213
|
-
const sharedB = (b.shared_substrate || []).map(globToRegExp);
|
|
214
|
-
for (const f of overlap) {
|
|
215
|
-
const declared = sharedA.some((r) => r.test(f)) && sharedB.some((r) => r.test(f));
|
|
216
|
-
if (!declared) findings.push({ rule: "DISJOINT", level: "red", scope: `${a.scope_id}+${b.scope_id}`, detail: `${f} is in both substrates but not in both shared_substrate lists — PA3 waiting to happen` });
|
|
217
|
-
// DECLARING IT SHARED MAKES IT LEGAL, NOT SAFE, and those are different claims.
|
|
218
|
-
//
|
|
219
|
-
// `shared` is the escape hatch from DISJOINT: two scopes may both write an entry point when
|
|
220
|
-
// both say so. That is right for the WRITE PERMISSION — `sandbox-guard` permits a path any
|
|
221
|
-
// live order covers — and it says nothing about the two scopes running at the SAME TIME.
|
|
222
|
-
// Measured on a shared entry point with three concurrent writers: every trial lost work,
|
|
223
|
-
// because an edit is read-modify-write and the last writer wins. Every layer was individually
|
|
224
|
-
// correct — the lint permitted the overlap, the waves co-scheduled the scopes, the guard
|
|
225
|
-
// allowed both writes — and the join silently dropped a scope's registration.
|
|
226
|
-
//
|
|
227
|
-
// Reported rather than blocked: refusing a legal contract would break the case the escape
|
|
228
|
-
// hatch exists for. What the scheduler needs is the FACT, so it can put two scopes sharing a
|
|
229
|
-
// writable path in different waves instead of discovering the collision in the file.
|
|
230
|
-
else findings.push({ rule: "SHARED-CONCURRENT", level: "warn", scope: `${a.scope_id}+${b.scope_id}`, detail: `${f} is writable by both scopes — legal, but they must not build concurrently: an edit is read-modify-write and the later writer silently drops the earlier one` });
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
return findings;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Text forms worth scanning; anything else in a committed tree is not a reference carrier. */
|
|
238
|
-
export const SCANNED = /\.(md|markdown|yml|yaml|json|txt)$/i;
|
|
239
|
-
|
|
240
|
-
/** A machine-local board id. Strict on purpose: a committed tree has no reason to carry one at all. */
|
|
241
|
-
const TASK_ID = /\bTASK-[A-Za-z0-9][\w.-]*/;
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* The tier-direction violations one piece of text carries — the whole rule, on a string.
|
|
245
|
-
*
|
|
246
|
-
* EXPORTED BECAUSE IT HAS TWO ENFORCEMENT POINTS NOW, and they must not be allowed to drift.
|
|
247
|
-
* `lintCommittedTier` below walks files at GATE L1b; `hooks/tier-guard.mjs` refuses the same text
|
|
248
|
-
* at the moment a tool writes it, minutes earlier, while the writer still holds the context needed
|
|
249
|
-
* to rephrase. Four producers wrote committed files this rule reds and none of them learned from
|
|
250
|
-
* the lint, because by the time it speaks the dispatch that wrote the line is over. A second
|
|
251
|
-
* enforcement point is only worth having if it enforces the SAME predicate, so both call this.
|
|
252
|
-
*
|
|
253
|
-
* The detail strings are the message the writer reads, so they carry the remedy, not just the
|
|
254
|
-
* verdict: a board id resolves on the machine that wrote it and nowhere else, and a path into the
|
|
255
|
-
* gitignored tier dangles on every clone.
|
|
256
|
-
*
|
|
257
|
-
* @param {string} text - The file body, or the fragment a tool is about to write.
|
|
258
|
-
* @returns {Array<{kind:("board-id"|"local-path"), token:string, line:number, detail:string}>}
|
|
259
|
-
* One entry per offending line and form, in file order; [] when clean.
|
|
260
|
-
*/
|
|
261
|
-
export function tierLeaks(text) {
|
|
262
|
-
// Built from the LOCAL constant, never a literal — the storage roots have exactly one home.
|
|
263
|
-
const esc = LOCAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
264
|
-
// `\S+` where the walk's own rule is `\S`: the same LINES match either way (a path with one
|
|
265
|
-
// non-space character after the slash has at least one), and the longer form yields the token to
|
|
266
|
-
// quote back at the writer. Trailing punctuation the prose wrapped it in is trimmed off the
|
|
267
|
-
// quote only — never off the test.
|
|
268
|
-
const localPath = new RegExp(`${esc}/\\S+`);
|
|
269
|
-
const leaks = [];
|
|
270
|
-
String(text ?? "").split(/\r?\n/).forEach((line, i) => {
|
|
271
|
-
const task = line.match(TASK_ID);
|
|
272
|
-
if (task) {
|
|
273
|
-
leaks.push({ kind: "board-id", token: task[0], line: i + 1, detail:
|
|
274
|
-
`names ${task[0]} — a committed file cannot carry a board id. Boards live in ${LOCAL}/ ` +
|
|
275
|
-
"(gitignored) and renumber on every regeneration, so this resolves on the machine that wrote it " +
|
|
276
|
-
"and nowhere else. Cite the use case or the scope_id, which are stable." });
|
|
277
|
-
}
|
|
278
|
-
const path = line.match(localPath);
|
|
279
|
-
if (path) {
|
|
280
|
-
leaks.push({ kind: "local-path", token: path[0].replace(/[`)\]},.;:'"]+$/, ""), line: i + 1, detail:
|
|
281
|
-
`points into ${LOCAL}/ — a committed file cannot reference the gitignored tier; the path ` +
|
|
282
|
-
"dangles on every other clone. Name the committed artifact, or describe the tier without a path." });
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
return leaks;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Lint the WHOLE committed tree for references into the gitignored tier.
|
|
290
|
-
*
|
|
291
|
-
* WHY THIS IS NOT THE SAME RULE AS TIER-DIRECTION ABOVE. That one walks wikilinks inside `spec/`
|
|
292
|
-
* and one frontmatter key in `scopes/`. Neither of those is the form the violation actually takes.
|
|
293
|
-
* Measured across nine completed runs, the leak is a bare `TASK-004` in a table cell or a sentence,
|
|
294
|
-
* in seven committed artifact types — 264 of them in `spec/synthesis.md` alone, 183 in
|
|
295
|
-
* `scope-summary.md`, 136 in `scope-board.md` — plus paths into `.shapeup/` in nine more files.
|
|
296
|
-
* A rule that inspects two corners of the tree for two syntactic forms reported all of it clean.
|
|
297
|
-
*
|
|
298
|
-
* The template that motivates the strictness states the rule and then breaks it: `synthesis.tmpl.md`
|
|
299
|
-
* says "Record only the count + status — never task ids … spec-lint flags [[tasks/...]] here as a
|
|
300
|
-
* red TIER-DIRECTION finding" and then prints a dependency chain, a wave table and a critical path
|
|
301
|
-
* entirely in `TASK-NNN` ids, 110 lines later, in cells no wikilink check can see.
|
|
302
|
-
*
|
|
303
|
-
* SCOPE IS THE SLUG'S TREE. `shapeup/knowledge-base/` is a sibling of `shapeup/<slug>/`, not a
|
|
304
|
-
* child, so it is outside this walk by construction — which is right: those files are instructions
|
|
305
|
-
* telling a worker what to do at runtime, not references a reader is expected to resolve.
|
|
306
|
-
*
|
|
307
|
-
* @param {{cwd:string, slug:string}} opts - Working root and feature slug.
|
|
308
|
-
* @returns {Array<{rule:string, level:"red", detail:string}>} One finding per offending line; [] when clean.
|
|
309
|
-
*/
|
|
310
|
-
export function lintCommittedTier({ cwd, slug }) {
|
|
311
|
-
const root = sharedRoot(cwd, slug);
|
|
312
|
-
if (!existsSync(root)) return [];
|
|
313
|
-
const findings = [];
|
|
314
|
-
for (const rel of walkFiles(root)) {
|
|
315
|
-
if (!SCANNED.test(rel)) continue;
|
|
316
|
-
let text;
|
|
317
|
-
try { text = readFileSync(join(root, rel), "utf8"); } catch { continue; }
|
|
318
|
-
for (const leak of tierLeaks(text)) {
|
|
319
|
-
findings.push({ rule: "TIER-DIRECTION", level: "red",
|
|
320
|
-
detail: `${relative(cwd, join(root, rel))}:${leak.line} ${leak.detail}` });
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return findings;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Lint the scope contract's anchor into the COMMITTED spec — the direction persisted links flow in.
|
|
328
|
-
*
|
|
329
|
-
* THE MIRROR OF UC-ANCHOR, for the other artifact that has to name what it builds. UC-ANCHOR makes
|
|
330
|
-
* every LOCAL task name a committed use case; nothing made the COMMITTED scope contract name one,
|
|
331
|
-
* and the field it carried instead was a list of LOCAL task ids. That is the exact shape
|
|
332
|
-
* TIER-DIRECTION reds a spec doc for — except TIER-DIRECTION walks wikilinks inside `spec/`, and a
|
|
333
|
-
* contract lives in `scopes/` and holds its pointer in frontmatter, so neither half of the existing
|
|
334
|
-
* rule could see it. Measured before this rule existed: a contract naming `TASK-004`, with no board
|
|
335
|
-
* anywhere in the tree, linted 0 red / 0 warn, and `compile` then wrote a build order carrying no
|
|
336
|
-
* tasks at all and exited 0.
|
|
337
|
-
*
|
|
338
|
-
* `tasks` is red rather than a warn because there is no reading of it that is safe to carry: on the
|
|
339
|
-
* machine that authored it the ids resolve and the contract looks correct, and on every other one
|
|
340
|
-
* they resolve to nothing without a single check going red. A field that is only wrong somewhere
|
|
341
|
-
* else is the kind this repo has been bitten by twice.
|
|
342
|
-
*
|
|
343
|
-
* @param {{scopes:Array<object>, specDir:string}} input - The parsed contracts and the SHARED spec dir.
|
|
344
|
-
* @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} Findings; [] when clean.
|
|
345
|
-
*/
|
|
346
|
-
export function lintScopeAnchors({ scopes, specDir: specRoot, reqIds = null, tasks = [] }) {
|
|
347
|
-
const findings = [];
|
|
348
|
-
// SCOPE-PARTITION — dispatch has to assign each task to exactly ONE scope.
|
|
349
|
-
//
|
|
350
|
-
// The UC anchor is a spec link, not an assignment: a use case is routinely implemented by several
|
|
351
|
-
// scopes, which is what a vertical slice IS. On the corpus's four-scope / one-use-case cut every
|
|
352
|
-
// scope claimed every task, so each would build all four and be denied by the sandbox on three of
|
|
353
|
-
// them. Red rather than warn: a dispatch that is not a partition burns the attempt budget of every
|
|
354
|
-
// scope in the cut. The fix is a `scope_id:` on the task (LOCAL naming SHARED, the sanctioned
|
|
355
|
-
// direction) or a re-cut that gives each scope its own use cases.
|
|
356
|
-
for (const c of scopePartitionConflicts(tasks, scopes)) {
|
|
357
|
-
findings.push({ rule: "SCOPE-PARTITION", level: "red", scope: c.scopes.join("+"), detail:
|
|
358
|
-
`${c.task_id} is claimed by ${c.scopes.length} scopes (${c.scopes.join(", ")}) — they share a use case, so the ` +
|
|
359
|
-
"UC anchor cannot say who builds it. Stamp `scope_id:` on the task, or re-cut so each scope owns its own use cases." });
|
|
360
|
-
}
|
|
361
|
-
const ucDir = join(specRoot, "usecases");
|
|
362
|
-
const ucIds = new Set(
|
|
363
|
-
(existsSync(ucDir) ? readdirSync(ucDir) : []).filter((f) => /^UC-.*\.md$/.test(f)).map((f) => f.replace(/\.md$/, "")),
|
|
364
|
-
);
|
|
365
|
-
const ids = new Set(scopes.map((s) => s.scope_id).filter(Boolean));
|
|
366
|
-
for (const s of scopes) {
|
|
367
|
-
const where = s.scope_id || "(unnamed scope)";
|
|
368
|
-
if (Array.isArray(s.tasks) && s.tasks.length) {
|
|
369
|
-
findings.push({
|
|
370
|
-
rule: "TIER-DIRECTION", level: "red", scope: where,
|
|
371
|
-
detail: `contract names LOCAL task ids [${s.tasks.join(", ")}] — a committed contract cannot point into ` +
|
|
372
|
-
`.shapeup/ (gitignored, and boards renumber per machine), so these dangle on every other clone. ` +
|
|
373
|
-
"Anchor with use_cases: [UC-…] instead; the scope's tasks are re-derived from the board's own use_case_refs.",
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
const anchors = (s.use_cases || []).map(ucId).filter(Boolean);
|
|
377
|
-
if (!anchors.length) {
|
|
378
|
-
findings.push({
|
|
379
|
-
rule: "SCOPE-ANCHOR", level: "red", scope: where,
|
|
380
|
-
detail: "empty use_cases — every scope must anchor into the committed spec (LOCAL→SHARED, the same " +
|
|
381
|
-
"single-anchor rule tasks follow). Without it nothing can say which tasks, requirements or " +
|
|
382
|
-
"affordances this scope is answerable for.",
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
for (const uc of anchors) {
|
|
386
|
-
if (!ucIds.has(uc)) findings.push({ rule: "SCOPE-ANCHOR", level: "red", scope: where, detail: `use_cases "${uc}" does not resolve to usecases/${uc}.md` });
|
|
387
|
-
}
|
|
388
|
-
// `depends_on` carries the build ORDER now that task ids no longer do, so a dangling id is a
|
|
389
|
-
// silently-dropped edge in the scheduler (which fails open by design) — reported here instead.
|
|
390
|
-
for (const d of s.depends_on || []) {
|
|
391
|
-
const id = String(d).trim();
|
|
392
|
-
if (id === s.scope_id) findings.push({ rule: "SCOPE-DEPS", level: "red", scope: where, detail: `depends_on names itself — a scope cannot wait for its own completion` });
|
|
393
|
-
else if (id && !ids.has(id)) findings.push({ rule: "SCOPE-DEPS", level: "red", scope: where, detail: `depends_on "${id}" is not a scope in this run — the scheduler drops the edge, so this scope may build before its dependency` });
|
|
394
|
-
}
|
|
395
|
-
for (const r of s.covers || []) {
|
|
396
|
-
// ONE KEY SPACE. A pitch numbers its requirements `R<n>` and the registry keys off
|
|
397
|
-
// `REQ-<n>`; `reqId` maps the first onto the second BEFORE the pattern below, so a link the
|
|
398
|
-
// planner actually wrote resolves instead of reading as a shape warning nobody can act on.
|
|
399
|
-
// A reference neither space recognises comes back verbatim and still fails the pattern.
|
|
400
|
-
const req = reqId(r);
|
|
401
|
-
if (!/^REQ-[A-Z0-9-]+$/i.test(req)) {
|
|
402
|
-
findings.push({ rule: "SCOPE-COVERS", level: "warn", scope: where, detail: `covers "${r}" is not a REQ-id — the requirement edge will not resolve` });
|
|
403
|
-
continue;
|
|
404
|
-
}
|
|
405
|
-
// CLOSURE, not just shape. Validating the format alone let a scope claim a requirement that
|
|
406
|
-
// does not exist — the field read as traceability while tracing to nothing. Checked only when
|
|
407
|
-
// a registry is on disk, so a pre-spine spec is unaffected (absent artifact ⇒ arm skipped).
|
|
408
|
-
if (reqIds && !reqIds.has(req.toUpperCase())) {
|
|
409
|
-
findings.push({ rule: "SCOPE-COVERS", level: "red", scope: where, detail: `covers "${req}" is not in requirements.md — a covers: link must resolve to a registered REQ, or the scope claims coverage of nothing` });
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// SCOPE-DEPS cycles. `scopeWaves` guards a cycle by dumping the remainder into ONE wave and
|
|
415
|
-
// reporting nothing, so a cyclic cut silently degrades to the unscheduled fan-out the scheduler
|
|
416
|
-
// exists to replace. Now that build order lives on the contract, the cycle has to be reported
|
|
417
|
-
// where it can still be fixed.
|
|
418
|
-
for (const cyc of depCycles(scopes)) {
|
|
419
|
-
findings.push({ rule: "SCOPE-DEPS", level: "red", scope: cyc[0], detail:
|
|
420
|
-
`depends_on cycle: ${cyc.join(" → ")} → ${cyc[0]} — no build order satisfies it, so the scheduler drops to a single unordered wave` });
|
|
421
|
-
}
|
|
422
|
-
return findings;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/** Registry sources that name the pitch's own out-of-scope section, in the spellings pitches use. */
|
|
426
|
-
const NOGO_SOURCE = /\bno[-\s]?gos?\b|\bnon[-\s]?goals?\b|\bout[-\s]of[-\s]scope\b|\bwill not build\b/i;
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* REQ-UNCOVERED — a live requirement that nothing in the plan reaches.
|
|
430
|
-
*
|
|
431
|
-
* THE OTHER DIRECTION OF THE COVERS EDGE. `SCOPE-COVERS` walks the links that exist and asks
|
|
432
|
-
* whether each one resolves; a requirement with no link at all satisfies it perfectly. Measured on
|
|
433
|
-
* a full run of one pitch: twenty-one requirements, every one of them with an acceptance criterion
|
|
434
|
-
* somewhere, and only eleven reaching a criterion the judge grades — the board is the last place a
|
|
435
|
-
* requirement can be dropped without anything going red, because after L1b nobody re-reads the
|
|
436
|
-
* pitch.
|
|
437
|
-
*
|
|
438
|
-
* WHY THE BOARD HERE IS `readBoard`, NOT `lint()`'s `tasks`. `parseBoard` (`kernel/reduce/board.mjs`)
|
|
439
|
-
* builds the scheduling view and its records carry no `acceptance_criteria` field at all, while
|
|
440
|
-
* `coveredReqIds` reads exactly that field — feed it the wrong board and the covered set is empty
|
|
441
|
-
* and EVERY requirement reds on EVERY run. `readBoard` (`kernel/compile.mjs`) is the parser that
|
|
442
|
-
* carries the criteria, and it is the only other one there may be: a second parser of the task file
|
|
443
|
-
* is explicitly ruled out where the first one lives.
|
|
444
|
-
*
|
|
445
|
-
* A SCOPE'S CLAIM COUNTS. The arm is about requirements nothing reaches, not about which layer
|
|
446
|
-
* reaches them: a clause claimed by a contract's `covers:` has an owner who answers for it at L1b,
|
|
447
|
-
* even before the criterion that grades it is written. `CUT (PO-approved)` is likewise an answer
|
|
448
|
-
* already given, not a defect — which is why `status` is read rather than assumed.
|
|
449
|
-
*
|
|
450
|
-
* @param {{clauses:Array<{id:string, clause:string, source:string, status:string}>,
|
|
451
|
-
* board:Array<object>, scopes:Array<{covers?:string[]}>}} input - The registry clauses
|
|
452
|
-
* (`parseRequirements`), the board `readBoard` parsed, and the scope contracts. An empty
|
|
453
|
-
* `clauses` (no registry on disk) yields no findings — absent artifact ⇒ arm skipped.
|
|
454
|
-
* @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} One red per
|
|
455
|
-
* uncovered live requirement; [] when every one is graded, claimed or cut.
|
|
456
|
-
*/
|
|
457
|
-
/**
|
|
458
|
-
* REQ-NARRATED — a committed spec file stating the requirement-coverage verdict as fact.
|
|
459
|
-
*
|
|
460
|
-
* The Health Dashboard's `Coverage` row is about USE CASES and tasks, derived by inverting each
|
|
461
|
-
* task's `use_case_refs` over the local board. Measured on a consumer, a worker filled its Signal
|
|
462
|
-
* cell with a different claim entirely — *"every registered non-CUT REQ-id (REQ-1 … REQ-7) reaches
|
|
463
|
-
* an AC carrying `(covers: REQ-…)`"*, with a 🟢 beside it — while a grep for `covers:` across the
|
|
464
|
-
* whole spec folder returned that sentence and nothing else. Not one acceptance criterion carried
|
|
465
|
-
* the clause, and the run's own derived report said `0/11 PASS`.
|
|
466
|
-
*
|
|
467
|
-
* `AGENTS.md` names the invariant this breaks: the requirements matrix is a projection, never a
|
|
468
|
-
* verdict, derived from files for one named run and never narrated. The rule is the narrow,
|
|
469
|
-
* checkable form of it — a dashboard Coverage row in a committed file may not name a REQ id — and
|
|
470
|
-
* it cannot fire on the legitimate signal, which counts use cases and tasks.
|
|
471
|
-
*
|
|
472
|
-
* @param {{cwd:string, slug:string}} opts - Working root and feature slug.
|
|
473
|
-
* @returns {object[]} Findings, one per offending line.
|
|
474
|
-
*/
|
|
475
|
-
export function lintNarratedCoverage({ cwd, slug }) {
|
|
476
|
-
const findings = [];
|
|
477
|
-
const dir = join(sharedRoot(cwd, slug), "spec");
|
|
478
|
-
let files;
|
|
479
|
-
try { files = readdirSync(dir).filter((f) => f.endsWith(".md")); } catch { return findings; }
|
|
480
|
-
for (const f of files) {
|
|
481
|
-
let lines;
|
|
482
|
-
try { lines = readFileSync(join(dir, f), "utf8").split(/\r?\n/); } catch { continue; }
|
|
483
|
-
lines.forEach((line, i) => {
|
|
484
|
-
if (!/^\|\s*Coverage\s*\|/i.test(line.trim())) return;
|
|
485
|
-
const named = [...line.matchAll(/\bREQ-\d+/g)].map((m) => m[0]);
|
|
486
|
-
if (!named.length) return;
|
|
487
|
-
findings.push({ rule: "REQ-NARRATED", level: "red", scope: `${f}:${i + 1}`, detail:
|
|
488
|
-
`${f}:${i + 1} states the requirement-coverage verdict in a committed file, naming ${named.slice(0, 3).join(", ")}` +
|
|
489
|
-
`${named.length > 3 ? ` (+${named.length - 3})` : ""}. That row is the UC × Task indicator; the ` +
|
|
490
|
-
"REQ → AC → criterion → verdict state is a projection derived per run (probe requirements), " +
|
|
491
|
-
"never a claim a committed artifact may make — a reader who checks the file finds corroboration " +
|
|
492
|
-
"for something no run measured. Say what the use cases and tasks show, and leave the requirement " +
|
|
493
|
-
"matrix to the run that derives it." });
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
return findings;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* REQ-UNCOVERED and REQ-NOGO — the registry's two ways of being wrong about what ships.
|
|
501
|
-
*
|
|
502
|
-
* @param {{clauses:object[], board:object[], scopes:object[]}} opts - The parsed registry, the
|
|
503
|
-
* board `readBoard` produced (its acceptance criteria carry the covers clauses), and the scope
|
|
504
|
-
* contracts.
|
|
505
|
-
* @returns {object[]} Findings, most specific first: a no-go registered as covered is reported as
|
|
506
|
-
* itself rather than as the coverage gap it inevitably becomes.
|
|
507
|
-
*/
|
|
508
|
-
export function lintRequirementCoverage({ clauses = [], board = [], scopes = [] }) {
|
|
509
|
-
const findings = [];
|
|
510
|
-
const graded = coveredReqIds(board);
|
|
511
|
-
// The contracts speak the pitch's numbering as readily as the registry's; `reqId` lands both in
|
|
512
|
-
// the one key space before the comparison, exactly as SCOPE-COVERS does above.
|
|
513
|
-
const claimed = new Set();
|
|
514
|
-
for (const s of scopes) for (const r of s.covers || []) claimed.add(reqId(r).toUpperCase());
|
|
515
|
-
for (const c of clauses) {
|
|
516
|
-
// A NO-GO IS A CONSTRAINT, NOT A DELIVERABLE, and marking one `covered` asserts something that
|
|
517
|
-
// cannot be true: nothing grades "do not build a settings screen". Measured on a consumer — a
|
|
518
|
-
// coverage dispatch lifted seven clauses out of the pitch's No-gos section, registered each as
|
|
519
|
-
// covered, and L1b then refused the run with seven REQ-UNCOVERED findings, correctly and
|
|
520
|
-
// unavoidably. Reported here as itself, so the operator reads one cause instead of seven
|
|
521
|
-
// symptoms, and named before REQ-UNCOVERED can fire on the same row.
|
|
522
|
-
if (c.status === "covered" && NOGO_SOURCE.test(c.source || "")) {
|
|
523
|
-
findings.push({ rule: "REQ-NOGO", level: "red", scope: c.id, detail:
|
|
524
|
-
`${c.id} ← ${c.source} registers a NO-GO as a covered requirement — "${(c.clause || "").slice(0, 60)}". ` +
|
|
525
|
-
"A no-go is a constraint the shape deliberately does not build, so no acceptance criterion can " +
|
|
526
|
-
"grade it and nothing downstream can ever turn it green. Mark it CUT (PO-approved) in " +
|
|
527
|
-
"requirements.md — the family that already means deliberately-not-built — or drop the row and give " +
|
|
528
|
-
"the breach a Test Surface row (TS-NOGO-NN) instead, which is the channel that does grade one." });
|
|
529
|
-
continue;
|
|
530
|
-
}
|
|
531
|
-
if (c.status !== "covered") continue; // CUT (PO-approved) — an answer on the record, not a gap
|
|
532
|
-
const id = c.id.toUpperCase();
|
|
533
|
-
if (graded.has(c.id) || claimed.has(id)) continue;
|
|
534
|
-
const from = c.source ? ` ← ${c.source}` : "";
|
|
535
|
-
findings.push({ rule: "REQ-UNCOVERED", level: "red", scope: c.id, detail:
|
|
536
|
-
`${c.id}${from} is graded by no acceptance criterion and claimed by no scope — "${(c.clause || "").slice(0, 60)}" ` +
|
|
537
|
-
"would ship unverified and nothing downstream would say so. Cover it with an AC carrying " +
|
|
538
|
-
`(covers: ${c.id}), or mark it CUT (PO-approved) in requirements.md.` });
|
|
539
|
-
}
|
|
540
|
-
return findings;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Every dependency cycle among the scopes, each reported once from its lowest-sorting member.
|
|
545
|
-
* @param {Array<{scope_id:string, depends_on?:string[]}>} scopes - The contracts.
|
|
546
|
-
* @returns {string[][]} One id path per distinct cycle; [] when the relation is acyclic.
|
|
547
|
-
*/
|
|
548
|
-
function depCycles(scopes) {
|
|
549
|
-
const deps = new Map(scopes.map((s) => [s.scope_id, (s.depends_on || []).map((d) => String(d).trim())]));
|
|
550
|
-
const seen = new Set();
|
|
551
|
-
const cycles = [];
|
|
552
|
-
for (const start of deps.keys()) {
|
|
553
|
-
const stack = [];
|
|
554
|
-
/**
|
|
555
|
-
* Depth-first walk recording any cycle reached from `start`.
|
|
556
|
-
* @param {string} id - The scope currently being entered.
|
|
557
|
-
* @returns {void}
|
|
558
|
-
*/
|
|
559
|
-
const walk = (id) => {
|
|
560
|
-
const at = stack.indexOf(id);
|
|
561
|
-
if (at !== -1) {
|
|
562
|
-
const cyc = stack.slice(at);
|
|
563
|
-
const key = [...cyc].sort().join("|");
|
|
564
|
-
if (!seen.has(key)) { seen.add(key); cycles.push(cyc); }
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
if (!deps.has(id)) return;
|
|
568
|
-
stack.push(id);
|
|
569
|
-
for (const d of deps.get(id)) walk(d);
|
|
570
|
-
stack.pop();
|
|
571
|
-
};
|
|
572
|
-
walk(start);
|
|
573
|
-
}
|
|
574
|
-
return cycles;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Match a heading-like line naming No-gos/Constraints/Edge-cases anywhere in the free-form raw
|
|
578
|
-
// idea, any markdown heading level, case-insensitive.
|
|
579
|
-
const CONSTRAINT_HEADING = /^#{1,6}\s*(no-?gos|constraints|edge[\s-]?cases)\b/im;
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Does the raw idea (intake.md, verbatim and free-form) name an explicit constraints section
|
|
583
|
-
* with real content under it — as opposed to naming the heading and leaving it empty? Only a
|
|
584
|
-
* pitch that actually named constraints obligates the derived spec tree to have derived at
|
|
585
|
-
* least one invariant from them (INV-FLOOR below); a pitch that never raised the topic is not
|
|
586
|
-
* evidence of a thinned tree.
|
|
587
|
-
* @param {string} intakeContent - The raw idea, verbatim (may be "" when no intake.md exists).
|
|
588
|
-
* @returns {boolean} True when a No-gos/Constraints/Edge-cases heading is followed by non-blank,
|
|
589
|
-
* non-comment content before the next heading (or end of file).
|
|
590
|
-
*/
|
|
591
|
-
export function intakeNamesConstraints(intakeContent) {
|
|
592
|
-
if (!intakeContent) return false;
|
|
593
|
-
const lines = intakeContent.split(/\r?\n/);
|
|
594
|
-
for (let i = 0; i < lines.length; i++) {
|
|
595
|
-
if (!CONSTRAINT_HEADING.test(lines[i])) continue;
|
|
596
|
-
let body = "";
|
|
597
|
-
for (let j = i + 1; j < lines.length && !/^#{1,6}\s/.test(lines[j]); j++) body += lines[j] + "\n";
|
|
598
|
-
if (body.replace(/<!--[\s\S]*?-->/g, "").trim().length > 0) return true;
|
|
599
|
-
}
|
|
600
|
-
return false;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Lint spec-tree completeness, wikilink resolution, tier-direction, task frontmatter/graph
|
|
605
|
-
* integrity (edge symmetry, dependency existence), UC-anchor completeness, and the invariant
|
|
606
|
-
* floor against a pitch that named constraints.
|
|
607
|
-
* @param {{specDir:string, tasks:Array<object>, intakeContent?:string}} input - The SHARED spec
|
|
608
|
-
* dir, the parsed board, and the raw idea's verbatim text (intake.md; "" when absent — a run
|
|
609
|
-
* with no intake on disk cannot be checked against it, so INV-FLOOR simply cannot fire).
|
|
610
|
-
* @returns {Array<{rule:string, level:("red"|"warn"), detail:string}>} Findings; [] when clean.
|
|
611
|
-
*/
|
|
612
|
-
export function lintStructure({ specDir, tasks, intakeContent = "" }) {
|
|
613
|
-
const findings = [];
|
|
614
|
-
const ucDir = join(specDir, "usecases");
|
|
615
|
-
if (!existsSync(join(specDir, "domain-model.md"))) findings.push({ rule: "STRUCTURE", level: "red", detail: "domain-model.md missing" });
|
|
616
|
-
const ucs = existsSync(ucDir) ? readdirSync(ucDir).filter((f) => /^UC-.*\.md$/.test(f)) : [];
|
|
617
|
-
if (!ucs.length) findings.push({ rule: "STRUCTURE", level: "red", detail: "usecases/ has no UC-*.md — nothing to build or grade against" });
|
|
618
|
-
let anyInvariant = false;
|
|
619
|
-
for (const f of ucs) {
|
|
620
|
-
const body = readFileSync(join(ucDir, f), "utf8");
|
|
621
|
-
if (!/^##\s+Steps/m.test(body)) findings.push({ rule: "STRUCTURE", level: "warn", detail: `${f} has no ## Steps section` });
|
|
622
|
-
if (/\[INV-\d+\]/.test(body)) anyInvariant = true;
|
|
623
|
-
}
|
|
624
|
-
// INV-FLOOR — a criteria-count check can't tell a healthy small tree from one that derived
|
|
625
|
-
// nothing from the pitch. Only fire when the pitch itself named constraints: a pitch that
|
|
626
|
-
// never raised the topic is not evidence of a thinned tree.
|
|
627
|
-
if (!anyInvariant && intakeNamesConstraints(intakeContent)) {
|
|
628
|
-
findings.push({ rule: "INV-FLOOR", level: "red", detail: "intake.md names explicit constraints (a No-gos/Constraints/Edge-cases section with content) but no usecases/UC-*.md declares a single [INV-NN] anywhere — the spec tree derived nothing from the pitch's own constraints" });
|
|
629
|
-
}
|
|
630
|
-
// Wikilinks in spec docs must resolve within the spec dir — and never cross the tier
|
|
631
|
-
// boundary: a SHARED doc linking the LOCAL board is the wrong direction by construction.
|
|
632
|
-
const specFiles = existsSync(specDir) ? walkFiles(specDir) : [];
|
|
633
|
-
const names = new Set(specFiles.map((f) => f.replace(/\.md$/, "")));
|
|
634
|
-
for (const f of specFiles.filter((x) => x.endsWith(".md"))) {
|
|
635
|
-
const body = readFileSync(join(specDir, f), "utf8");
|
|
636
|
-
for (const m of body.matchAll(/\[\[([^\]#|]+)/g)) {
|
|
637
|
-
const target = m[1].trim().replace(/\.md$/, "");
|
|
638
|
-
if (target.startsWith("tasks/")) {
|
|
639
|
-
findings.push({ rule: "TIER-DIRECTION", level: "red", detail: `${f} → [[${m[1].trim()}]] links the LOCAL board from a committed doc — links flow LOCAL→SHARED only; cite the UC or scope_id instead (task ids renumber per machine)` });
|
|
640
|
-
continue;
|
|
641
|
-
}
|
|
642
|
-
if (!names.has(target) && ![...names].some((n) => n.endsWith(`/${target}`) || n === target)) {
|
|
643
|
-
findings.push({ rule: "WIKILINK", level: "warn", detail: `${f} → [[${m[1].trim()}]] unresolved in spec dir` });
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
// Task frontmatter + graph integrity (edge symmetry — the hand-authored-unlocks drift, mechanized).
|
|
648
|
-
const ids = new Set(tasks.map((t) => t.id));
|
|
649
|
-
const derived = deriveUnlocks(tasks);
|
|
650
|
-
for (const t of tasks) {
|
|
651
|
-
for (const k of ["id", "status"]) if (!t[k] || t[k] === "unknown") findings.push({ rule: "TASK", level: "red", detail: `${t.file} missing frontmatter ${k}` });
|
|
652
|
-
for (const d of t.depends_on) if (!ids.has(d)) findings.push({ rule: "TASK", level: "red", detail: `${t.id} depends_on ${d} which does not exist` });
|
|
653
|
-
if (JSON.stringify([...t.unlocks].sort()) !== JSON.stringify(derived[t.id] || [])) {
|
|
654
|
-
findings.push({ rule: "EDGE-SYMMETRY", level: "red", detail: `${t.id} unlocks ${JSON.stringify(t.unlocks)} ≠ derived inverse ${JSON.stringify(derived[t.id])} — run harness reduce board --write` });
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
// UC-ANCHOR — the LOCAL→SHARED anchor must be complete: every implementation task names
|
|
658
|
-
// ≥1 UC (single-anchor rule, task-generation.md) and each named UC exists on disk.
|
|
659
|
-
// SPIKE/CHORE/DOCS/MIGRATION tasks anchor elsewhere (api_ref / linked_docs) — exempt.
|
|
660
|
-
const ucIds = new Set(ucs.map((f) => f.replace(/\.md$/, "")));
|
|
661
|
-
const anchorExempt = new Set(["spike", "chore", "docs", "migration"]);
|
|
662
|
-
for (const t of tasks) {
|
|
663
|
-
if (anchorExempt.has((t.type || "").toLowerCase())) continue;
|
|
664
|
-
const refs = t.use_case_refs || [];
|
|
665
|
-
if (!refs.length) {
|
|
666
|
-
findings.push({ rule: "UC-ANCHOR", level: "red", detail: `${t.id} has empty use_case_refs — every task must anchor into the committed spec (LOCAL→SHARED, single-anchor rule)` });
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
|
-
for (const r of refs) {
|
|
670
|
-
const uc = ucId(r);
|
|
671
|
-
if (!ucIds.has(uc)) findings.push({ rule: "UC-ANCHOR", level: "red", detail: `${t.id} use_case_refs "${r}" does not resolve to usecases/${uc}.md` });
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
return findings;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/** Every Place id inside a heading's parentheses — `## Screen: Sheet (P2)`, `(P1, P3)`. */
|
|
678
|
-
const PLACE_IN_PARENS = /\(([^)]*)\)/g;
|
|
679
|
-
const PLACE_ID = /\bP\d+(?:\.\d+)*\b/g;
|
|
680
|
-
|
|
681
|
-
/**
|
|
682
|
-
* Cut ux-behavior.md into the screen sections a breadboard's Places are checked against.
|
|
683
|
-
*
|
|
684
|
-
* A section runs from its `Screen:` heading to the next heading at the same level or higher, so
|
|
685
|
-
* a screen's `### States` table and behavior rules belong to it. Its Places are the P# ids in the
|
|
686
|
-
* heading's parentheses; a screen with none is kept (its citations are still "somewhere") but can
|
|
687
|
-
* place nothing.
|
|
688
|
-
*
|
|
689
|
-
* @param {string} uxText - ux-behavior.md, verbatim ("" when absent).
|
|
690
|
-
* @returns {{screens: {heading: string, places: string[], body: string}[], deferred: Set<string>}}
|
|
691
|
-
* The screen sections, and the Place ids listed first-cell under `## Deferred Places`.
|
|
692
|
-
*/
|
|
693
|
-
export function uxScreens(uxText) {
|
|
694
|
-
const lines = String(uxText ?? "").split(/\r?\n/);
|
|
695
|
-
const screens = [];
|
|
696
|
-
const deferred = new Set();
|
|
697
|
-
let cur = null; // { level, heading, places, body[] } for a screen, or { level, deferred: true }
|
|
698
|
-
for (const line of lines) {
|
|
699
|
-
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
700
|
-
if (h) {
|
|
701
|
-
const level = h[1].length;
|
|
702
|
-
if (cur && level <= cur.level) cur = null;
|
|
703
|
-
if (!cur) {
|
|
704
|
-
const text = h[2].trim();
|
|
705
|
-
if (/^screen\s*:/i.test(text)) {
|
|
706
|
-
const places = [...text.matchAll(PLACE_IN_PARENS)].flatMap((m) => m[1].match(PLACE_ID) ?? []);
|
|
707
|
-
cur = { level, heading: text, places, body: [] };
|
|
708
|
-
screens.push(cur);
|
|
709
|
-
continue;
|
|
710
|
-
}
|
|
711
|
-
if (/^deferred places\b/i.test(text)) { cur = { level, deferred: true }; continue; }
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
if (!cur) continue;
|
|
715
|
-
if (cur.deferred) {
|
|
716
|
-
const row = line.match(/^\s*\|\s*([^|]*)\|/);
|
|
717
|
-
const id = row ? row[1].replace(/[*`\[\]]/g, "").match(/^\s*(P\d+(?:\.\d+)*)\b/) : null;
|
|
718
|
-
if (id) deferred.add(id[1]);
|
|
719
|
-
} else cur.body.push(line);
|
|
720
|
-
}
|
|
721
|
-
return {
|
|
722
|
-
screens: screens.map((s) => ({ heading: s.heading, places: s.places, body: s.body.join("\n") })),
|
|
723
|
-
deferred,
|
|
724
|
-
};
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Lint the spec against the pitch's breadboard: every Place with UI affordances has a screen, and
|
|
729
|
-
* every UI affordance is specified on a screen of a Place the breadboard puts it in.
|
|
730
|
-
*
|
|
731
|
-
* PLACEMENT, NOT CITATION. The loss this exists for did not drop a new sheet's affordances — they
|
|
732
|
-
* were all in the spec, inside the composer's state table. What was lost was the Place. A rule that
|
|
733
|
-
* only asks "is U2 cited?" passes that spec; this one asks "is U2 cited under P2?". It checks WHICH
|
|
734
|
-
* screen, never where on the screen: layout inside a Place stays the designer's.
|
|
735
|
-
*
|
|
736
|
-
* Absent breadboard ⇒ zero findings, the same "absent artifact ⇒ arm skipped" rule INV-FLOOR and
|
|
737
|
-
* SCOPE-COVERS follow — every pre-breadboard spec and every run without one is untouched.
|
|
738
|
-
*
|
|
739
|
-
* @param {object} input - What to lint (destructured).
|
|
740
|
-
* @param {string} input.uxText - ux-behavior.md, verbatim ("" when absent).
|
|
741
|
-
* @param {string} input.specText - Every markdown file under the spec tree, concatenated.
|
|
742
|
-
* @param {Array<object>} [input.scopes] - Parsed scope contracts ([] before MAP SCOPES).
|
|
743
|
-
* @param {(string|null)} [input.scopeSummaryText] - scope-summary.md, or null when absent.
|
|
744
|
-
* @param {(string|null)} [input.scopeBoardText] - scope-board.md, or null when absent — the scope
|
|
745
|
-
* architect's own write surface, where it records which scopes deliver each slice.
|
|
746
|
-
* @param {(string|null)} input.bbText - The breadboard, or null when the run has none.
|
|
747
|
-
* @returns {Array<{rule:string, level:("red"|"warn"), detail:string}>} Findings; [] when clean or
|
|
748
|
-
* when there is no breadboard.
|
|
749
|
-
*/
|
|
750
|
-
export function lintBreadboard({ uxText = "", specText = "", scopes = [], scopeSummaryText = null, scopeBoardText = null, bbText = null }) {
|
|
751
|
-
if (!bbText) return [];
|
|
752
|
-
const findings = [];
|
|
753
|
-
const bb = parseBreadboard(bbText);
|
|
754
|
-
const counts = idCounts(bb);
|
|
755
|
-
if (Object.values(counts).every((n) => n === 0)) {
|
|
756
|
-
findings.push({ rule: "BREADBOARD-UNPARSED", level: "warn", detail: "the run has a breadboard but no P#/U#/N#/S#/V# ids could be read from its tables — placement was not checked. A `#` or `ID` first column holding the id, and a `Place` column on affordance rows, is the layout this reads" });
|
|
757
|
-
return findings;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
const { screens, deferred } = uxScreens(uxText);
|
|
761
|
-
/**
|
|
762
|
-
* A Place as a finding names it — id and breadboard name.
|
|
763
|
-
* @param {string} p - Place id.
|
|
764
|
-
* @returns {string} e.g. `P2 Payment Sheet`.
|
|
765
|
-
*/
|
|
766
|
-
const name = (p) => {
|
|
767
|
-
const n = bb.places.find((x) => x.id === p)?.name;
|
|
768
|
-
return n ? `${p} ${n}` : p;
|
|
769
|
-
};
|
|
770
|
-
/**
|
|
771
|
-
* Does the text cite this exact id — `U2` but not `U21` or `U2a`?
|
|
772
|
-
* @param {string} text - Markdown to search.
|
|
773
|
-
* @param {string} id - A breadboard id.
|
|
774
|
-
* @returns {boolean} True when the id appears as a whole word.
|
|
775
|
-
*/
|
|
776
|
-
const cites = (text, id) => new RegExp(`\\b${id.replace(/\./g, "\\.")}\\b`).test(text);
|
|
777
|
-
|
|
778
|
-
// BREADBOARD-PLACE — a Place with something to place needs a screen of its own.
|
|
779
|
-
const owners = new Map(); // Place → the U# it owns
|
|
780
|
-
for (const u of bb.ui) for (const p of u.places) (owners.get(p) ?? owners.set(p, []).get(p)).push(u.id);
|
|
781
|
-
for (const [p, us] of owners) {
|
|
782
|
-
if (deferred.has(p)) continue;
|
|
783
|
-
if (screens.some((s) => s.places.includes(p))) continue;
|
|
784
|
-
findings.push({ rule: "BREADBOARD-PLACE", level: "red", detail: `${name(p)} owns ${us.join(", ")} but ux-behavior.md has no "## Screen: … (${p})" section — add the screen or defer the Place under "## Deferred Places"; never fold it into another screen` });
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// BREADBOARD-UI — each U# is specified on a screen of a Place the breadboard puts it in.
|
|
788
|
-
const unplaceable = [];
|
|
789
|
-
for (const u of bb.ui) {
|
|
790
|
-
if (!u.places.length) { unplaceable.push(u.id); continue; }
|
|
791
|
-
const live = u.places.filter((p) => !deferred.has(p));
|
|
792
|
-
if (!live.length) continue;
|
|
793
|
-
if (screens.some((s) => s.places.some((p) => live.includes(p)) && cites(s.body, u.id))) continue;
|
|
794
|
-
const elsewhere = [...new Set(screens.filter((s) => cites(s.body, u.id)).flatMap((s) => s.places.length ? s.places : [`"${s.heading}"`]))];
|
|
795
|
-
const where = elsewhere.length
|
|
796
|
-
? `is cited only under ${elsewhere.join(", ")}`
|
|
797
|
-
: cites(uxText, u.id) ? "is cited in ux-behavior.md but on no screen" : "is cited on no screen";
|
|
798
|
-
findings.push({ rule: "BREADBOARD-UI", level: "red", detail: `${u.id} (${live.map(name).join(" / ")}) ${where} — specify it in the screen section of its own Place` });
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// BREADBOARD-TRACE — the rest of the breadboard, reported and never blocking.
|
|
802
|
-
const trace = [];
|
|
803
|
-
const lost = [...bb.code, ...bb.stores].map((x) => x.id).filter((id) => !cites(specText, id));
|
|
804
|
-
if (lost.length) trace.push(`N#/S# cited nowhere in the spec: ${lost.join(", ")}`);
|
|
805
|
-
const slicesText = [scopeSummaryText, scopeBoardText].filter((t) => t !== null && t !== undefined).join("\n");
|
|
806
|
-
if (scopeSummaryText !== null || scopeBoardText !== null) {
|
|
807
|
-
const unsliced = bb.slices.map((v) => v.id).filter((id) => !cites(slicesText, id));
|
|
808
|
-
if (unsliced.length) trace.push(`V# slices no scope board or scope summary records: ${unsliced.join(", ")}`);
|
|
809
|
-
}
|
|
810
|
-
if (scopes.length) {
|
|
811
|
-
const sourced = new Set(scopes.flatMap((s) => (s.affordance_manifest ?? []).map((a) => String(a?.source ?? "").trim())));
|
|
812
|
-
const unsourced = bb.ui.map((u) => u.id).filter((id) => cites(uxText, id) && !sourced.has(id));
|
|
813
|
-
if (unsourced.length) trace.push(`U# the spec places but no manifest entry names as its source: ${unsourced.join(", ")}`);
|
|
814
|
-
}
|
|
815
|
-
if (unplaceable.length) trace.push(`U# with no Place column to check placement against: ${unplaceable.join(", ")}`);
|
|
816
|
-
if (trace.length) findings.push({ rule: "BREADBOARD-TRACE", level: "warn", detail: trace.join("; ") });
|
|
817
|
-
return findings;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* The breadboard a run's spec is linted against: the staged copy, else the intake when it carries
|
|
822
|
-
* one inline, else null — and null switches every BREADBOARD-* rule off.
|
|
823
|
-
* @param {string} cwd - Project root.
|
|
824
|
-
* @param {string} slug - Feature slug.
|
|
825
|
-
* @param {string} intakeContent - The run's intake, verbatim ("" when absent).
|
|
826
|
-
* @returns {(string|null)} The breadboard text, or null when the run has none.
|
|
827
|
-
*/
|
|
828
|
-
export function runBreadboard(cwd, slug, intakeContent) {
|
|
829
|
-
const p = stagedBreadboard(cwd, slug);
|
|
830
|
-
if (existsSync(p)) return readFileSync(p, "utf8");
|
|
831
|
-
return hasBreadboardTables(intakeContent) ? intakeContent : null;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/**
|
|
835
|
-
* Every scope contract whose PARSED shape fails `$defs/ScopeContract`.
|
|
836
|
-
*
|
|
837
|
-
* `kernel/lib/contract.mjs`'s own banner promised this check — "spec-lint re-validates every parsed
|
|
838
|
-
* contract against domain.schema.json, so a hand-edit that breaks the shape fails loudly instead of
|
|
839
|
-
* silently widening a sandbox" — and it did not exist. `compile` validated, spec-lint did not, so a
|
|
840
|
-
* contract could pass GATE L1b green and then be refused at dispatch by the one reader that checked.
|
|
841
|
-
*
|
|
842
|
-
* Measured 2026-09-19 on a real run: a planner wrote every `required_states` table cell bare
|
|
843
|
-
* (`loading, error, ready`) where the dialect wants `[loading, error, ready]`, so all 32 manifest
|
|
844
|
-
* rows across the six UI scopes parsed as strings. `verify spec` reported `red=0`; `compile` then
|
|
845
|
-
* refused all six with `expected array, got string`, and those scopes were never dispatched — no
|
|
846
|
-
* order, no leg, no T0 trial. The round reached EVAL with six of eighteen scopes missing and the
|
|
847
|
-
* evaluator escalated rather than grading. This arm turns that into a red at the gate, naming the
|
|
848
|
-
* scope and the field, with the message the compiler would otherwise produce an hour later.
|
|
849
|
-
*
|
|
850
|
-
* The validator is the one `compile` already uses; there is no second implementation here.
|
|
851
|
-
*
|
|
852
|
-
* @param {Array<{contract:object, path:string}>} contracts - Parsed contracts with their paths.
|
|
853
|
-
* @param {object} domainSchema - The parsed `domain.schema.json`.
|
|
854
|
-
* @returns {Array<{rule:string, level:string, scope:string, detail:string}>} One red per invalid
|
|
855
|
-
* contract; [] when the schema cannot be read (absent artifact ⇒ arm skipped).
|
|
856
|
-
*/
|
|
857
|
-
export function lintContractSchema(contracts, domainSchema) {
|
|
858
|
-
const def = domainSchema?.$defs?.ScopeContract;
|
|
859
|
-
if (!def) return [];
|
|
860
|
-
const schema = { ...def, $defs: domainSchema.$defs };
|
|
861
|
-
const out = [];
|
|
862
|
-
for (const { contract, path } of contracts) {
|
|
863
|
-
const c = { ...contract };
|
|
864
|
-
delete c[UNREADABLE];
|
|
865
|
-
delete c[LEGACY_LAYOUT];
|
|
866
|
-
let res;
|
|
867
|
-
try { res = validateAgainstSchema(c, schema); } catch { continue; } // fail open, never closed
|
|
868
|
-
if (res?.valid) continue;
|
|
869
|
-
out.push({
|
|
870
|
-
rule: "CONTRACT-SCHEMA", level: "red", scope: contract.scope_id || path,
|
|
871
|
-
detail: `the contract parses, but not into the shape a WorkOrder carries — ${(res.errors || [])[0] || "schema validation failed"}. ` +
|
|
872
|
-
`compile refuses an order that fails its own schema, so as written this scope would be silently undispatched. ` +
|
|
873
|
-
`A list in a table cell is written [a, b], brackets and all.`,
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
return out;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* Run the full spec lint (scopes + structure) for a slug.
|
|
881
|
-
* @param {{cwd:string, slug:string}} opts - Working root and feature slug.
|
|
882
|
-
* @returns {{slug:string, scopes:number, tasks:number, red:number, warn:number,
|
|
883
|
-
* findings:Array<object>}} Counts and the combined findings from {@link lintScopes},
|
|
884
|
-
* {@link lintStructure} and, when the run has a breadboard, {@link lintBreadboard}.
|
|
885
|
-
*/
|
|
886
|
-
export function lint({ cwd, slug }) {
|
|
887
|
-
const specRoot = specDir(cwd, slug);
|
|
888
|
-
const contracts = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT);
|
|
889
|
-
const scopes = contracts.map((c) => c.contract);
|
|
890
|
-
const tasks = parseBoard(tasksDir(cwd, slug));
|
|
891
|
-
const intakePath = intake(cwd, slug);
|
|
892
|
-
const intakeContent = existsSync(intakePath) ? readFileSync(intakePath, "utf8") : "";
|
|
893
|
-
// The REQ registry, when the tree has one — absent means covers-closure simply cannot apply.
|
|
894
|
-
const reqFile = requirements(cwd, slug);
|
|
895
|
-
const reqText = existsSync(reqFile) ? readFileSync(reqFile, "utf8") : null;
|
|
896
|
-
const reqIds = reqText !== null
|
|
897
|
-
? new Set([...reqText.matchAll(/\bREQ-[A-Z0-9-]+/gi)].map((m) => m[0].toUpperCase()))
|
|
898
|
-
: null;
|
|
899
|
-
// Table rows only, and with the status/source cells REQ-UNCOVERED reports from — the id set
|
|
900
|
-
// above is deliberately looser (it also sees ids named in the registry's prose) and stays that
|
|
901
|
-
// way, because the two arms ask different questions of the same file.
|
|
902
|
-
const reqClauses = reqText !== null ? parseRequirements(reqText) : [];
|
|
903
|
-
const repoFiles = walkFiles(cwd);
|
|
904
|
-
// Loaded HERE, not at module scope. `spec → trace → compile → probe/resume → spec` is a live
|
|
905
|
-
// import ring, and a top-level dereference of an imported binding is what would break it.
|
|
906
|
-
// Unreadable schema ⇒ the arm skips itself, like every other absent-artifact arm.
|
|
907
|
-
let domainSchema = null;
|
|
908
|
-
try { domainSchema = JSON.parse(readFileSync(join(SCHEMAS_DIR, "domain.schema.json"), "utf8")); } catch { /* arm skipped */ }
|
|
909
|
-
|
|
910
|
-
const findings = [
|
|
911
|
-
// A contract whose table this parser cannot see reads as a contract that declared no
|
|
912
|
-
// table, and every rule below then passes for the part it could not read. Loud, not empty.
|
|
913
|
-
...contracts
|
|
914
|
-
.map(({ contract, path }) => ({ reason: unreadableReason(contract), scope: contract.scope_id || path }))
|
|
915
|
-
.filter((x) => x.reason)
|
|
916
|
-
.map((x) => ({ rule: "CONTRACT-UNREADABLE", level: "red", scope: x.scope, detail: `${x.reason} — the rules below could not check what they could not read` })),
|
|
917
|
-
...lintContractSchema(contracts, domainSchema),
|
|
918
|
-
...lintScopes(scopes, repoFiles),
|
|
919
|
-
...lintScopeAnchors({ scopes, specDir: specRoot, reqIds, tasks }),
|
|
920
|
-
// `readBoard`, not the `tasks` above: only the compile-order parser carries acceptance_criteria.
|
|
921
|
-
...lintRequirementCoverage({ clauses: reqClauses, board: readBoard(cwd, slug), scopes }),
|
|
922
|
-
...lintCommittedTier({ cwd, slug }),
|
|
923
|
-
...lintNarratedCoverage({ cwd, slug }),
|
|
924
|
-
...lintStructure({ specDir: specRoot, tasks, intakeContent }),
|
|
925
|
-
...(() => {
|
|
926
|
-
const bbText = runBreadboard(cwd, slug, intakeContent);
|
|
927
|
-
if (!bbText) return [];
|
|
928
|
-
/**
|
|
929
|
-
* A file's text, or null when it does not exist.
|
|
930
|
-
* @param {string} p - Absolute path.
|
|
931
|
-
* @returns {(string|null)} The contents, or null.
|
|
932
|
-
*/
|
|
933
|
-
const readOr = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
|
|
934
|
-
const specFiles = existsSync(specRoot) ? walkFiles(specRoot).filter((f) => f.endsWith(".md")) : [];
|
|
935
|
-
return lintBreadboard({
|
|
936
|
-
uxText: readOr(join(specRoot, "ux-behavior.md")) ?? "",
|
|
937
|
-
specText: specFiles.map((f) => readFileSync(join(specRoot, f), "utf8")).join("\n"),
|
|
938
|
-
scopes,
|
|
939
|
-
scopeSummaryText: readOr(join(specRoot, "scope-summary.md")),
|
|
940
|
-
scopeBoardText: readOr(join(sharedRoot(cwd, slug), "scope-board.md")),
|
|
941
|
-
bbText,
|
|
942
|
-
});
|
|
943
|
-
})(),
|
|
944
|
-
];
|
|
945
|
-
return {
|
|
946
|
-
slug,
|
|
947
|
-
scopes: scopes.length,
|
|
948
|
-
tasks: tasks.length,
|
|
949
|
-
red: findings.filter((f) => f.level === "red").length,
|
|
950
|
-
warn: findings.filter((f) => f.level === "warn").length,
|
|
951
|
-
findings,
|
|
952
|
-
};
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/** The typed argv contract (see `kernel/lib/argv.mjs`). */
|
|
956
|
-
export const ARGV_SPEC = {
|
|
957
|
-
usage: "harness.mjs verify spec --slug <slug> [--cwd <dir>]",
|
|
958
|
-
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
959
|
-
slug: { type: "str", required: true },
|
|
960
|
-
cwd: { type: "path" },
|
|
961
|
-
};
|
|
962
|
-
|
|
963
|
-
/**
|
|
964
|
-
* Lint the committed spec tree against the board it must stay in step with.
|
|
965
|
-
*
|
|
966
|
-
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
967
|
-
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
968
|
-
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
969
|
-
*/
|
|
970
|
-
export async function cli(rawArgv) {
|
|
971
|
-
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
972
|
-
const report = lint({ cwd: resolve(args.cwd || process.cwd()), slug: args.slug });
|
|
973
|
-
console.log(JSON.stringify(report, null, 2));
|
|
974
|
-
process.exit(report.red > 0 ? 1 : 0);
|
|
975
|
-
}
|