shapeup-sdlc 1.6.3 → 3.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 +5 -5
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +54 -107
- package/README.md +165 -151
- package/SECURITY.md +49 -27
- package/bin/init.mjs +93 -108
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +20 -0
- package/commands/eval.md +5 -4
- package/commands/scopes.md +5 -4
- package/commands/shape.md +1 -1
- package/commands/ship.md +53 -7
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +16 -17
- package/hooks/gate-zerowork.mjs +107 -25
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +38 -19
- package/hooks/safety-spine.mjs +4 -4
- package/hooks/sandbox-guard.mjs +130 -50
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +344 -55
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +69 -26
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +25 -22
- package/kernel/init/run.mjs +489 -0
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +261 -17
- package/kernel/lib/paths.mjs +491 -0
- package/kernel/probe/concurrency.mjs +510 -0
- package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +10 -11
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/kernel/probe/resume.mjs +528 -0
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +102 -21
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +104 -28
- package/kernel/reduce/graph.mjs +437 -0
- package/kernel/reduce/hill.mjs +152 -0
- package/kernel/reduce/ingest.mjs +633 -0
- package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
- package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +93 -14
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +22 -21
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +14 -8
- package/kernel/report/export.mjs +325 -0
- package/kernel/report/facts.mjs +347 -0
- package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +22 -25
- package/kernel/verify/dispatch.mjs +114 -0
- package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
- package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
- package/kernel/verify/skills.mjs +125 -0
- package/kernel/verify/spec.mjs +559 -0
- package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +138 -28
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +28 -13
- package/oracles/_shared.mjs +1 -1
- package/oracles/http-oracle.mjs +2 -2
- package/oracles/index.mjs +1 -1
- package/oracles/process-oracle.mjs +2 -2
- package/oracles/snapshot-oracle.mjs +2 -2
- package/oracles/test-oracle.mjs +2 -2
- package/package.json +11 -13
- package/skills/ba-pitch-analyzer/SKILL.md +24 -19
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +3 -1
- package/skills/ba-pitch-analyzer/references/task-generation.md +8 -8
- package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
- package/skills/coach/SKILL.md +3 -3
- package/skills/orient/SKILL.md +2 -1
- package/skills/qa-edge-hunter/SKILL.md +15 -41
- package/skills/scope-architect/SKILL.md +57 -18
- package/skills/scope-hammer/SKILL.md +5 -5
- package/skills/shapeup/SKILL.md +3 -3
- package/skills/shapeup/resources/context-compaction.md +4 -3
- package/skills/solution-architect/SKILL.md +37 -15
- package/skills/spec-evaluator/SKILL.md +24 -7
- package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
- package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
- package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
- package/skills/spec-evaluator/references/probing.md +42 -23
- package/skills/spec-evaluator/references/report-schema.md +2 -2
- package/skills/task-executor/SKILL.md +32 -18
- package/skills/tech-lead/SKILL.md +127 -438
- package/skills/tech-lead/references/gates.md +140 -49
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +645 -198
- package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +56 -18
- package/skills/tech-lead/workflows/shapeup-run.js +1474 -0
- package/skills/translator/SKILL.md +2 -2
- package/hooks/anti-rationalization.mjs +0 -244
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -151
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -109
- package/skills/advisor-protocol/SKILL.md +0 -171
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
- package/skills/spec-evaluator/README.md +0 -93
- package/skills/tech-lead/README.md +0 -71
- package/skills/tech-lead/references/delegation.md +0 -254
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -214
- package/skills/tech-lead/references/round-protocol.md +0 -184
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -270
- package/skills/tech-lead/scripts/init-run.mjs +0 -326
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -82
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -278
- package/skills/translator/README.md +0 -66
|
@@ -0,0 +1,559 @@
|
|
|
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
|
+
// SCOPE-PARTITION a task claimed by more than one scope. The UC anchor is a SPEC link, not an
|
|
38
|
+
// assignment: one use case is routinely implemented by several scopes, so on a
|
|
39
|
+
// four-scope/one-UC cut every scope claimed every task and would build all of them.
|
|
40
|
+
// Resolved by a `scope_id:` on the task (LOCAL→SHARED) or by re-cutting
|
|
41
|
+
// INV-FLOOR the raw idea (intake.md) names explicit constraints (a No-gos/Constraints/
|
|
42
|
+
// Edge-cases heading with real content under it) but no usecases/UC-*.md declares
|
|
43
|
+
// a single [INV-NN] anywhere — a criteria-count check can't tell a healthy small
|
|
44
|
+
// tree from one that silently derived nothing from the pitch
|
|
45
|
+
//
|
|
46
|
+
// Zero dependencies (glob matcher inlined from hooks/sandbox-guard.mjs). Judgment stays in the skill
|
|
47
|
+
// (gap severity, lens choice); this script only reports facts.
|
|
48
|
+
//
|
|
49
|
+
// Usage: node kernel/harness.mjs verify spec --slug <slug> [--cwd <dir>]
|
|
50
|
+
// Prints a JSON report. Exit 0 = no red findings, 1 = at least one red.
|
|
51
|
+
|
|
52
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
53
|
+
import { resolve, join, relative } from "node:path";
|
|
54
|
+
import { parseBoard, deriveUnlocks } from "../reduce/board.mjs";
|
|
55
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
56
|
+
import { LOCAL } from "../lib/paths.mjs";
|
|
57
|
+
import { specDir, scopesDir, tasksDir, intake, sharedRoot, requirements } from "../lib/paths.mjs";
|
|
58
|
+
import { readAllContracts, unreadableReason, ucId, scopePartitionConflicts, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
59
|
+
|
|
60
|
+
// Inlined from hooks/sandbox-guard.mjs so this skill ships self-contained (a skill's scripts
|
|
61
|
+
// must not reach outside its own folder — channels that copy only skills/ would dangle).
|
|
62
|
+
/**
|
|
63
|
+
* Compile a substrate glob into an anchored RegExp (inlined from sandbox-guard so the skill ships
|
|
64
|
+
* self-contained). Supports single-star, double-star, and double-star-slash segment wildcards.
|
|
65
|
+
* @param {string} glob - The glob pattern.
|
|
66
|
+
* @returns {RegExp} A full-string (`^…$`) matcher for repo-relative paths.
|
|
67
|
+
*/
|
|
68
|
+
export function globToRegExp(glob) {
|
|
69
|
+
let re = "";
|
|
70
|
+
for (let i = 0; i < glob.length; i++) {
|
|
71
|
+
const c = glob[i];
|
|
72
|
+
if (c === "*") {
|
|
73
|
+
if (glob[i + 1] === "*") {
|
|
74
|
+
re += glob[i + 2] === "/" ? "(?:[^/]+/)*" : ".*";
|
|
75
|
+
i += glob[i + 2] === "/" ? 2 : 1;
|
|
76
|
+
} else re += "[^/]*";
|
|
77
|
+
} else if ("\\^$.|?+()[]{}".includes(c)) re += "\\" + c;
|
|
78
|
+
else re += c;
|
|
79
|
+
}
|
|
80
|
+
return new RegExp(`^${re}$`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const SIZE_CAP = 15;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Recursively list repo-relative file paths under a root, skipping .git/node_modules/.shapeup.
|
|
87
|
+
* @param {string} root - The base the results are made relative to.
|
|
88
|
+
* @param {string} [dir=root] - Current directory being walked (callers omit it).
|
|
89
|
+
* @param {string[]} [acc=[]] - Accumulator (callers omit it).
|
|
90
|
+
* @returns {string[]} Repo-relative paths of every file found.
|
|
91
|
+
*/
|
|
92
|
+
function walkFiles(root, dir = root, acc = []) {
|
|
93
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
94
|
+
if (e.name === ".git" || e.name === "node_modules" || e.name === LOCAL) continue;
|
|
95
|
+
const p = join(dir, e.name);
|
|
96
|
+
if (e.isDirectory()) walkFiles(root, p, acc);
|
|
97
|
+
else acc.push(relative(root, p));
|
|
98
|
+
}
|
|
99
|
+
return acc;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Lint scope contracts for PA1 (directory-thinking), PA2 (size cap), and DISJOINT substrate overlap.
|
|
104
|
+
* @param {Array<{scope_id:string, topology_type?:string, allowed_file_substrate?:string[],
|
|
105
|
+
* shared_substrate?:string[]}>} scopes - The scope contracts.
|
|
106
|
+
* @param {string[]} repoFiles - Repo-relative file list the substrate globs resolve against.
|
|
107
|
+
* @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} Findings; [] when clean.
|
|
108
|
+
*/
|
|
109
|
+
export function lintScopes(scopes, repoFiles) {
|
|
110
|
+
const findings = [];
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a list of substrate globs to the repo files they match.
|
|
113
|
+
* @param {(string[]|undefined)} globs - Glob patterns (undefined → none).
|
|
114
|
+
* @returns {string[]} The repo-relative files matched by any glob (deduplication is the caller's).
|
|
115
|
+
*/
|
|
116
|
+
const resolveGlobs = (globs) => {
|
|
117
|
+
const res = (globs || []).map((g) => globToRegExp(g));
|
|
118
|
+
return repoFiles.filter((f) => res.some((r) => r.test(f)));
|
|
119
|
+
};
|
|
120
|
+
for (const s of scopes) {
|
|
121
|
+
const allowed = s.allowed_file_substrate || [];
|
|
122
|
+
// PA1 — directory-thinking: every glob confined to ONE layer directory (e.g. all of it
|
|
123
|
+
// under apps/web/). A flow slice crosses layers (apps/web/cart + apps/api/cart passes).
|
|
124
|
+
const layers = new Set(allowed.map((g) => g.split("/").slice(0, 2).join("/")));
|
|
125
|
+
if (allowed.length && layers.size === 1 && s.topology_type !== "CHOWDER") {
|
|
126
|
+
findings.push({ rule: "PA1", level: "red", scope: s.scope_id, detail: `substrate aligns 1:1 with '${[...layers][0]}/' — slice by flow, not by directory` });
|
|
127
|
+
}
|
|
128
|
+
// PA2 — resolved file count over the cap (chowder absorbs true strays).
|
|
129
|
+
const files = resolveGlobs(allowed);
|
|
130
|
+
if (files.length > SIZE_CAP && s.topology_type !== "CHOWDER") {
|
|
131
|
+
findings.push({ rule: "PA2", level: "warn", scope: s.scope_id, detail: `substrate resolves to ${files.length} files (cap ~${SIZE_CAP}) — consider splitting` });
|
|
132
|
+
}
|
|
133
|
+
// T0-UNVERIFIABLE — a scope with no fixtures the parser can see.
|
|
134
|
+
//
|
|
135
|
+
// RED, not warn, and it is the most important line in this file. `runFixtures` used to answer
|
|
136
|
+
// `pass: true` for an empty list (`[].every(…)` is `true`), so a scope whose fixtures did not
|
|
137
|
+
// PARSE was certified T0-green having executed nothing — "measured, not claimed" inverted into
|
|
138
|
+
// "nothing measured, therefore green", in the one layer the evaluator must cite.
|
|
139
|
+
//
|
|
140
|
+
// It is not hypothetical and it is not an authoring slip in the usual sense: the architect wrote
|
|
141
|
+
// GOOD fixtures (`node --test test/…`) as a markdown `## e2e_verification_fixtures` section
|
|
142
|
+
// instead of a frontmatter key. The substrate list beside it, written as a frontmatter block
|
|
143
|
+
// list, parsed perfectly. One field silently reached the scorer as `undefined` and the scope
|
|
144
|
+
// went green on zero evidence.
|
|
145
|
+
//
|
|
146
|
+
// `verify t0` now refuses to call an unrun scope green, so the failure is loud either way. This
|
|
147
|
+
// catches it one gate earlier, where the fix is editing a contract rather than burning a round.
|
|
148
|
+
if (!(s.e2e_verification_fixtures || []).length) {
|
|
149
|
+
findings.push({
|
|
150
|
+
rule: "T0-UNVERIFIABLE", level: "red", scope: s.scope_id,
|
|
151
|
+
detail: "no e2e_verification_fixtures the parser can see — T0 has nothing to run, so this scope " +
|
|
152
|
+
"cannot be verified. Declare them as a FRONTMATTER key (a `- ` block list or a [a, b] inline " +
|
|
153
|
+
"list); a `## e2e_verification_fixtures` markdown section is not read, and a scope whose " +
|
|
154
|
+
"fixtures silently vanish is a scope certified on no evidence.",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// T0-UNPASSABLE — a fixture whose own comment declares a non-zero exit.
|
|
158
|
+
//
|
|
159
|
+
// `verify t0` scores a fixture as passing iff it exits 0, and that rule lived only in the
|
|
160
|
+
// scorer. Handed a contract that said "commands that drive this scope end-to-end", an architect
|
|
161
|
+
// wrote the error paths as bare invocations — `todo done abc # E_INVALID_INDEX, exit 1` — which
|
|
162
|
+
// cannot pass by construction. Four of six scopes then burned their attempt budget on code that
|
|
163
|
+
// was already correct, and the run reported them as hard scopes.
|
|
164
|
+
//
|
|
165
|
+
// Caught here because the cost of finding it late is measured in whole attempts: this is the
|
|
166
|
+
// last gate before BUILD spends anything. Matched on the author's OWN declaration of a non-zero
|
|
167
|
+
// exit, not on guessing what a command does — a fixture that merely mentions "exit" in another
|
|
168
|
+
// sense does not match, and a warn never blocks a run.
|
|
169
|
+
for (const fx of s.e2e_verification_fixtures || []) {
|
|
170
|
+
const m = String(fx).match(/#[^#]*\bexit\s+([1-9]\d*)\b/i);
|
|
171
|
+
if (m) {
|
|
172
|
+
findings.push({
|
|
173
|
+
rule: "T0-UNPASSABLE", level: "warn", scope: s.scope_id,
|
|
174
|
+
detail: `fixture declares "exit ${m[1]}" — T0 passes a fixture only on exit 0, so this scope cannot go green. ` +
|
|
175
|
+
`Assert the error path inside a test file that itself exits 0: ${String(fx).slice(0, 60)}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// DISJOINT — pairwise overlap not covered by BOTH scopes' shared_substrate.
|
|
181
|
+
for (let i = 0; i < scopes.length; i++) {
|
|
182
|
+
for (let j = i + 1; j < scopes.length; j++) {
|
|
183
|
+
const a = scopes[i], b = scopes[j];
|
|
184
|
+
const filesA = new Set(resolveGlobs(a.allowed_file_substrate));
|
|
185
|
+
const overlap = resolveGlobs(b.allowed_file_substrate).filter((f) => filesA.has(f));
|
|
186
|
+
const sharedA = (a.shared_substrate || []).map(globToRegExp);
|
|
187
|
+
const sharedB = (b.shared_substrate || []).map(globToRegExp);
|
|
188
|
+
for (const f of overlap) {
|
|
189
|
+
const declared = sharedA.some((r) => r.test(f)) && sharedB.some((r) => r.test(f));
|
|
190
|
+
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` });
|
|
191
|
+
// DECLARING IT SHARED MAKES IT LEGAL, NOT SAFE, and those are different claims.
|
|
192
|
+
//
|
|
193
|
+
// `shared` is the escape hatch from DISJOINT: two scopes may both write an entry point when
|
|
194
|
+
// both say so. That is right for the WRITE PERMISSION — `sandbox-guard` permits a path any
|
|
195
|
+
// live order covers — and it says nothing about the two scopes running at the SAME TIME.
|
|
196
|
+
// Measured on a shared entry point with three concurrent writers: every trial lost work,
|
|
197
|
+
// because an edit is read-modify-write and the last writer wins. Every layer was individually
|
|
198
|
+
// correct — the lint permitted the overlap, the waves co-scheduled the scopes, the guard
|
|
199
|
+
// allowed both writes — and the join silently dropped a scope's registration.
|
|
200
|
+
//
|
|
201
|
+
// Reported rather than blocked: refusing a legal contract would break the case the escape
|
|
202
|
+
// hatch exists for. What the scheduler needs is the FACT, so it can put two scopes sharing a
|
|
203
|
+
// writable path in different waves instead of discovering the collision in the file.
|
|
204
|
+
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` });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return findings;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Text forms worth scanning; anything else in a committed tree is not a reference carrier. */
|
|
212
|
+
const SCANNED = /\.(md|markdown|yml|yaml|json|txt)$/i;
|
|
213
|
+
|
|
214
|
+
/** A machine-local board id. Strict on purpose: a committed tree has no reason to carry one at all. */
|
|
215
|
+
const TASK_ID = /\bTASK-[A-Za-z0-9][\w.-]*/;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Lint the WHOLE committed tree for references into the gitignored tier.
|
|
219
|
+
*
|
|
220
|
+
* WHY THIS IS NOT THE SAME RULE AS TIER-DIRECTION ABOVE. That one walks wikilinks inside `spec/`
|
|
221
|
+
* and one frontmatter key in `scopes/`. Neither of those is the form the violation actually takes.
|
|
222
|
+
* Measured across nine completed runs, the leak is a bare `TASK-004` in a table cell or a sentence,
|
|
223
|
+
* in seven committed artifact types — 264 of them in `spec/synthesis.md` alone, 183 in
|
|
224
|
+
* `scope-summary.md`, 136 in `scope-board.md` — plus paths into `.shapeup/` in nine more files.
|
|
225
|
+
* A rule that inspects two corners of the tree for two syntactic forms reported all of it clean.
|
|
226
|
+
*
|
|
227
|
+
* The template that motivates the strictness states the rule and then breaks it: `synthesis.tmpl.md`
|
|
228
|
+
* says "Record only the count + status — never task ids … spec-lint flags [[tasks/...]] here as a
|
|
229
|
+
* red TIER-DIRECTION finding" and then prints a dependency chain, a wave table and a critical path
|
|
230
|
+
* entirely in `TASK-NNN` ids, 110 lines later, in cells no wikilink check can see.
|
|
231
|
+
*
|
|
232
|
+
* SCOPE IS THE SLUG'S TREE. `shapeup/knowledge-base/` is a sibling of `shapeup/<slug>/`, not a
|
|
233
|
+
* child, so it is outside this walk by construction — which is right: those files are instructions
|
|
234
|
+
* telling a worker what to do at runtime, not references a reader is expected to resolve.
|
|
235
|
+
*
|
|
236
|
+
* @param {{cwd:string, slug:string}} opts - Working root and feature slug.
|
|
237
|
+
* @returns {Array<{rule:string, level:"red", detail:string}>} One finding per offending line; [] when clean.
|
|
238
|
+
*/
|
|
239
|
+
export function lintCommittedTier({ cwd, slug }) {
|
|
240
|
+
const root = sharedRoot(cwd, slug);
|
|
241
|
+
if (!existsSync(root)) return [];
|
|
242
|
+
// Built from the LOCAL constant, never a literal — the storage roots have exactly one home.
|
|
243
|
+
const localPath = new RegExp(`${LOCAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/\\S`);
|
|
244
|
+
const findings = [];
|
|
245
|
+
for (const rel of walkFiles(root)) {
|
|
246
|
+
if (!SCANNED.test(rel)) continue;
|
|
247
|
+
let lines;
|
|
248
|
+
try { lines = readFileSync(join(root, rel), "utf8").split(/\r?\n/); } catch { continue; }
|
|
249
|
+
lines.forEach((line, i) => {
|
|
250
|
+
const at = `${relative(cwd, join(root, rel))}:${i + 1}`;
|
|
251
|
+
const task = line.match(TASK_ID);
|
|
252
|
+
if (task) {
|
|
253
|
+
findings.push({ rule: "TIER-DIRECTION", level: "red", detail:
|
|
254
|
+
`${at} names ${task[0]} — a committed file cannot carry a board id. Boards live in ${LOCAL}/ ` +
|
|
255
|
+
"(gitignored) and renumber on every regeneration, so this resolves on the machine that wrote it " +
|
|
256
|
+
"and nowhere else. Cite the use case or the scope_id, which are stable." });
|
|
257
|
+
}
|
|
258
|
+
if (localPath.test(line)) {
|
|
259
|
+
findings.push({ rule: "TIER-DIRECTION", level: "red", detail:
|
|
260
|
+
`${at} points into ${LOCAL}/ — a committed file cannot reference the gitignored tier; the path ` +
|
|
261
|
+
"dangles on every other clone. Name the committed artifact, or describe the tier without a path." });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return findings;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Lint the scope contract's anchor into the COMMITTED spec — the direction persisted links flow in.
|
|
270
|
+
*
|
|
271
|
+
* THE MIRROR OF UC-ANCHOR, for the other artifact that has to name what it builds. UC-ANCHOR makes
|
|
272
|
+
* every LOCAL task name a committed use case; nothing made the COMMITTED scope contract name one,
|
|
273
|
+
* and the field it carried instead was a list of LOCAL task ids. That is the exact shape
|
|
274
|
+
* TIER-DIRECTION reds a spec doc for — except TIER-DIRECTION walks wikilinks inside `spec/`, and a
|
|
275
|
+
* contract lives in `scopes/` and holds its pointer in frontmatter, so neither half of the existing
|
|
276
|
+
* rule could see it. Measured before this rule existed: a contract naming `TASK-004`, with no board
|
|
277
|
+
* anywhere in the tree, linted 0 red / 0 warn, and `compile` then wrote a build order carrying no
|
|
278
|
+
* tasks at all and exited 0.
|
|
279
|
+
*
|
|
280
|
+
* `tasks` is red rather than a warn because there is no reading of it that is safe to carry: on the
|
|
281
|
+
* machine that authored it the ids resolve and the contract looks correct, and on every other one
|
|
282
|
+
* they resolve to nothing without a single check going red. A field that is only wrong somewhere
|
|
283
|
+
* else is the kind this repo has been bitten by twice.
|
|
284
|
+
*
|
|
285
|
+
* @param {{scopes:Array<object>, specDir:string}} input - The parsed contracts and the SHARED spec dir.
|
|
286
|
+
* @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} Findings; [] when clean.
|
|
287
|
+
*/
|
|
288
|
+
export function lintScopeAnchors({ scopes, specDir: specRoot, reqIds = null, tasks = [] }) {
|
|
289
|
+
const findings = [];
|
|
290
|
+
// SCOPE-PARTITION — dispatch has to assign each task to exactly ONE scope.
|
|
291
|
+
//
|
|
292
|
+
// The UC anchor is a spec link, not an assignment: a use case is routinely implemented by several
|
|
293
|
+
// scopes, which is what a vertical slice IS. On the corpus's four-scope / one-use-case cut every
|
|
294
|
+
// scope claimed every task, so each would build all four and be denied by the sandbox on three of
|
|
295
|
+
// them. Red rather than warn: a dispatch that is not a partition burns the attempt budget of every
|
|
296
|
+
// scope in the cut. The fix is a `scope_id:` on the task (LOCAL naming SHARED, the sanctioned
|
|
297
|
+
// direction) or a re-cut that gives each scope its own use cases.
|
|
298
|
+
for (const c of scopePartitionConflicts(tasks, scopes)) {
|
|
299
|
+
findings.push({ rule: "SCOPE-PARTITION", level: "red", scope: c.scopes.join("+"), detail:
|
|
300
|
+
`${c.task_id} is claimed by ${c.scopes.length} scopes (${c.scopes.join(", ")}) — they share a use case, so the ` +
|
|
301
|
+
"UC anchor cannot say who builds it. Stamp `scope_id:` on the task, or re-cut so each scope owns its own use cases." });
|
|
302
|
+
}
|
|
303
|
+
const ucDir = join(specRoot, "usecases");
|
|
304
|
+
const ucIds = new Set(
|
|
305
|
+
(existsSync(ucDir) ? readdirSync(ucDir) : []).filter((f) => /^UC-.*\.md$/.test(f)).map((f) => f.replace(/\.md$/, "")),
|
|
306
|
+
);
|
|
307
|
+
const ids = new Set(scopes.map((s) => s.scope_id).filter(Boolean));
|
|
308
|
+
for (const s of scopes) {
|
|
309
|
+
const where = s.scope_id || "(unnamed scope)";
|
|
310
|
+
if (Array.isArray(s.tasks) && s.tasks.length) {
|
|
311
|
+
findings.push({
|
|
312
|
+
rule: "TIER-DIRECTION", level: "red", scope: where,
|
|
313
|
+
detail: `contract names LOCAL task ids [${s.tasks.join(", ")}] — a committed contract cannot point into ` +
|
|
314
|
+
`.shapeup/ (gitignored, and boards renumber per machine), so these dangle on every other clone. ` +
|
|
315
|
+
"Anchor with use_cases: [UC-…] instead; the scope's tasks are re-derived from the board's own use_case_refs.",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const anchors = (s.use_cases || []).map(ucId).filter(Boolean);
|
|
319
|
+
if (!anchors.length) {
|
|
320
|
+
findings.push({
|
|
321
|
+
rule: "SCOPE-ANCHOR", level: "red", scope: where,
|
|
322
|
+
detail: "empty use_cases — every scope must anchor into the committed spec (LOCAL→SHARED, the same " +
|
|
323
|
+
"single-anchor rule tasks follow). Without it nothing can say which tasks, requirements or " +
|
|
324
|
+
"affordances this scope is answerable for.",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
for (const uc of anchors) {
|
|
328
|
+
if (!ucIds.has(uc)) findings.push({ rule: "SCOPE-ANCHOR", level: "red", scope: where, detail: `use_cases "${uc}" does not resolve to usecases/${uc}.md` });
|
|
329
|
+
}
|
|
330
|
+
// `depends_on` carries the build ORDER now that task ids no longer do, so a dangling id is a
|
|
331
|
+
// silently-dropped edge in the scheduler (which fails open by design) — reported here instead.
|
|
332
|
+
for (const d of s.depends_on || []) {
|
|
333
|
+
const id = String(d).trim();
|
|
334
|
+
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` });
|
|
335
|
+
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` });
|
|
336
|
+
}
|
|
337
|
+
for (const r of s.covers || []) {
|
|
338
|
+
const req = String(r).trim();
|
|
339
|
+
if (!/^REQ-[A-Z0-9-]+$/i.test(req)) {
|
|
340
|
+
findings.push({ rule: "SCOPE-COVERS", level: "warn", scope: where, detail: `covers "${r}" is not a REQ-id — the requirement edge will not resolve` });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
// CLOSURE, not just shape. Validating the format alone let a scope claim a requirement that
|
|
344
|
+
// does not exist — the field read as traceability while tracing to nothing. Checked only when
|
|
345
|
+
// a registry is on disk, so a pre-spine spec is unaffected (absent artifact ⇒ arm skipped).
|
|
346
|
+
if (reqIds && !reqIds.has(req.toUpperCase())) {
|
|
347
|
+
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` });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// SCOPE-DEPS cycles. `scopeWaves` guards a cycle by dumping the remainder into ONE wave and
|
|
353
|
+
// reporting nothing, so a cyclic cut silently degrades to the unscheduled fan-out the scheduler
|
|
354
|
+
// exists to replace. Now that build order lives on the contract, the cycle has to be reported
|
|
355
|
+
// where it can still be fixed.
|
|
356
|
+
for (const cyc of depCycles(scopes)) {
|
|
357
|
+
findings.push({ rule: "SCOPE-DEPS", level: "red", scope: cyc[0], detail:
|
|
358
|
+
`depends_on cycle: ${cyc.join(" → ")} → ${cyc[0]} — no build order satisfies it, so the scheduler drops to a single unordered wave` });
|
|
359
|
+
}
|
|
360
|
+
return findings;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Every dependency cycle among the scopes, each reported once from its lowest-sorting member.
|
|
365
|
+
* @param {Array<{scope_id:string, depends_on?:string[]}>} scopes - The contracts.
|
|
366
|
+
* @returns {string[][]} One id path per distinct cycle; [] when the relation is acyclic.
|
|
367
|
+
*/
|
|
368
|
+
function depCycles(scopes) {
|
|
369
|
+
const deps = new Map(scopes.map((s) => [s.scope_id, (s.depends_on || []).map((d) => String(d).trim())]));
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
const cycles = [];
|
|
372
|
+
for (const start of deps.keys()) {
|
|
373
|
+
const stack = [];
|
|
374
|
+
/**
|
|
375
|
+
* Depth-first walk recording any cycle reached from `start`.
|
|
376
|
+
* @param {string} id - The scope currently being entered.
|
|
377
|
+
* @returns {void}
|
|
378
|
+
*/
|
|
379
|
+
const walk = (id) => {
|
|
380
|
+
const at = stack.indexOf(id);
|
|
381
|
+
if (at !== -1) {
|
|
382
|
+
const cyc = stack.slice(at);
|
|
383
|
+
const key = [...cyc].sort().join("|");
|
|
384
|
+
if (!seen.has(key)) { seen.add(key); cycles.push(cyc); }
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (!deps.has(id)) return;
|
|
388
|
+
stack.push(id);
|
|
389
|
+
for (const d of deps.get(id)) walk(d);
|
|
390
|
+
stack.pop();
|
|
391
|
+
};
|
|
392
|
+
walk(start);
|
|
393
|
+
}
|
|
394
|
+
return cycles;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Match a heading-like line naming No-gos/Constraints/Edge-cases anywhere in the free-form raw
|
|
398
|
+
// idea, any markdown heading level, case-insensitive.
|
|
399
|
+
const CONSTRAINT_HEADING = /^#{1,6}\s*(no-?gos|constraints|edge[\s-]?cases)\b/im;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Does the raw idea (intake.md, verbatim and free-form) name an explicit constraints section
|
|
403
|
+
* with real content under it — as opposed to naming the heading and leaving it empty? Only a
|
|
404
|
+
* pitch that actually named constraints obligates the derived spec tree to have derived at
|
|
405
|
+
* least one invariant from them (INV-FLOOR below); a pitch that never raised the topic is not
|
|
406
|
+
* evidence of a thinned tree.
|
|
407
|
+
* @param {string} intakeContent - The raw idea, verbatim (may be "" when no intake.md exists).
|
|
408
|
+
* @returns {boolean} True when a No-gos/Constraints/Edge-cases heading is followed by non-blank,
|
|
409
|
+
* non-comment content before the next heading (or end of file).
|
|
410
|
+
*/
|
|
411
|
+
export function intakeNamesConstraints(intakeContent) {
|
|
412
|
+
if (!intakeContent) return false;
|
|
413
|
+
const lines = intakeContent.split(/\r?\n/);
|
|
414
|
+
for (let i = 0; i < lines.length; i++) {
|
|
415
|
+
if (!CONSTRAINT_HEADING.test(lines[i])) continue;
|
|
416
|
+
let body = "";
|
|
417
|
+
for (let j = i + 1; j < lines.length && !/^#{1,6}\s/.test(lines[j]); j++) body += lines[j] + "\n";
|
|
418
|
+
if (body.replace(/<!--[\s\S]*?-->/g, "").trim().length > 0) return true;
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Lint spec-tree completeness, wikilink resolution, tier-direction, task frontmatter/graph
|
|
425
|
+
* integrity (edge symmetry, dependency existence), UC-anchor completeness, and the invariant
|
|
426
|
+
* floor against a pitch that named constraints.
|
|
427
|
+
* @param {{specDir:string, tasks:Array<object>, intakeContent?:string}} input - The SHARED spec
|
|
428
|
+
* dir, the parsed board, and the raw idea's verbatim text (intake.md; "" when absent — a run
|
|
429
|
+
* with no intake on disk cannot be checked against it, so INV-FLOOR simply cannot fire).
|
|
430
|
+
* @returns {Array<{rule:string, level:("red"|"warn"), detail:string}>} Findings; [] when clean.
|
|
431
|
+
*/
|
|
432
|
+
export function lintStructure({ specDir, tasks, intakeContent = "" }) {
|
|
433
|
+
const findings = [];
|
|
434
|
+
const ucDir = join(specDir, "usecases");
|
|
435
|
+
if (!existsSync(join(specDir, "domain-model.md"))) findings.push({ rule: "STRUCTURE", level: "red", detail: "domain-model.md missing" });
|
|
436
|
+
const ucs = existsSync(ucDir) ? readdirSync(ucDir).filter((f) => /^UC-.*\.md$/.test(f)) : [];
|
|
437
|
+
if (!ucs.length) findings.push({ rule: "STRUCTURE", level: "red", detail: "usecases/ has no UC-*.md — nothing to build or grade against" });
|
|
438
|
+
let anyInvariant = false;
|
|
439
|
+
for (const f of ucs) {
|
|
440
|
+
const body = readFileSync(join(ucDir, f), "utf8");
|
|
441
|
+
if (!/^##\s+Steps/m.test(body)) findings.push({ rule: "STRUCTURE", level: "warn", detail: `${f} has no ## Steps section` });
|
|
442
|
+
if (/\[INV-\d+\]/.test(body)) anyInvariant = true;
|
|
443
|
+
}
|
|
444
|
+
// INV-FLOOR — a criteria-count check can't tell a healthy small tree from one that derived
|
|
445
|
+
// nothing from the pitch. Only fire when the pitch itself named constraints: a pitch that
|
|
446
|
+
// never raised the topic is not evidence of a thinned tree.
|
|
447
|
+
if (!anyInvariant && intakeNamesConstraints(intakeContent)) {
|
|
448
|
+
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" });
|
|
449
|
+
}
|
|
450
|
+
// Wikilinks in spec docs must resolve within the spec dir — and never cross the tier
|
|
451
|
+
// boundary: a SHARED doc linking the LOCAL board is the wrong direction by construction.
|
|
452
|
+
const specFiles = existsSync(specDir) ? walkFiles(specDir) : [];
|
|
453
|
+
const names = new Set(specFiles.map((f) => f.replace(/\.md$/, "")));
|
|
454
|
+
for (const f of specFiles.filter((x) => x.endsWith(".md"))) {
|
|
455
|
+
const body = readFileSync(join(specDir, f), "utf8");
|
|
456
|
+
for (const m of body.matchAll(/\[\[([^\]#|]+)/g)) {
|
|
457
|
+
const target = m[1].trim().replace(/\.md$/, "");
|
|
458
|
+
if (target.startsWith("tasks/")) {
|
|
459
|
+
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)` });
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (!names.has(target) && ![...names].some((n) => n.endsWith(`/${target}`) || n === target)) {
|
|
463
|
+
findings.push({ rule: "WIKILINK", level: "warn", detail: `${f} → [[${m[1].trim()}]] unresolved in spec dir` });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Task frontmatter + graph integrity (edge symmetry — the hand-authored-unlocks drift, mechanized).
|
|
468
|
+
const ids = new Set(tasks.map((t) => t.id));
|
|
469
|
+
const derived = deriveUnlocks(tasks);
|
|
470
|
+
for (const t of tasks) {
|
|
471
|
+
for (const k of ["id", "status"]) if (!t[k] || t[k] === "unknown") findings.push({ rule: "TASK", level: "red", detail: `${t.file} missing frontmatter ${k}` });
|
|
472
|
+
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` });
|
|
473
|
+
if (JSON.stringify([...t.unlocks].sort()) !== JSON.stringify(derived[t.id] || [])) {
|
|
474
|
+
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` });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// UC-ANCHOR — the LOCAL→SHARED anchor must be complete: every implementation task names
|
|
478
|
+
// ≥1 UC (single-anchor rule, task-generation.md) and each named UC exists on disk.
|
|
479
|
+
// SPIKE/CHORE/DOCS/MIGRATION tasks anchor elsewhere (api_ref / linked_docs) — exempt.
|
|
480
|
+
const ucIds = new Set(ucs.map((f) => f.replace(/\.md$/, "")));
|
|
481
|
+
const anchorExempt = new Set(["spike", "chore", "docs", "migration"]);
|
|
482
|
+
for (const t of tasks) {
|
|
483
|
+
if (anchorExempt.has((t.type || "").toLowerCase())) continue;
|
|
484
|
+
const refs = t.use_case_refs || [];
|
|
485
|
+
if (!refs.length) {
|
|
486
|
+
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)` });
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
for (const r of refs) {
|
|
490
|
+
const uc = ucId(r);
|
|
491
|
+
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` });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return findings;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Run the full spec lint (scopes + structure) for a slug.
|
|
499
|
+
* @param {{cwd:string, slug:string}} opts - Working root and feature slug.
|
|
500
|
+
* @returns {{slug:string, scopes:number, tasks:number, red:number, warn:number,
|
|
501
|
+
* findings:Array<object>}} Counts and the combined findings from {@link lintScopes} and
|
|
502
|
+
* {@link lintStructure}.
|
|
503
|
+
*/
|
|
504
|
+
export function lint({ cwd, slug }) {
|
|
505
|
+
const specRoot = specDir(cwd, slug);
|
|
506
|
+
const contracts = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT);
|
|
507
|
+
const scopes = contracts.map((c) => c.contract);
|
|
508
|
+
const tasks = parseBoard(tasksDir(cwd, slug));
|
|
509
|
+
const intakePath = intake(cwd, slug);
|
|
510
|
+
const intakeContent = existsSync(intakePath) ? readFileSync(intakePath, "utf8") : "";
|
|
511
|
+
// The REQ registry, when the tree has one — absent means covers-closure simply cannot apply.
|
|
512
|
+
const reqFile = requirements(cwd, slug);
|
|
513
|
+
const reqIds = existsSync(reqFile)
|
|
514
|
+
? new Set([...readFileSync(reqFile, "utf8").matchAll(/\bREQ-[A-Z0-9-]+/gi)].map((m) => m[0].toUpperCase()))
|
|
515
|
+
: null;
|
|
516
|
+
const repoFiles = walkFiles(cwd);
|
|
517
|
+
const findings = [
|
|
518
|
+
// A contract whose table this parser cannot see reads as a contract that declared no
|
|
519
|
+
// table, and every rule below then passes for the part it could not read. Loud, not empty.
|
|
520
|
+
...contracts
|
|
521
|
+
.map(({ contract, path }) => ({ reason: unreadableReason(contract), scope: contract.scope_id || path }))
|
|
522
|
+
.filter((x) => x.reason)
|
|
523
|
+
.map((x) => ({ rule: "CONTRACT-UNREADABLE", level: "red", scope: x.scope, detail: `${x.reason} — the rules below could not check what they could not read` })),
|
|
524
|
+
...lintScopes(scopes, repoFiles),
|
|
525
|
+
...lintScopeAnchors({ scopes, specDir: specRoot, reqIds, tasks }),
|
|
526
|
+
...lintCommittedTier({ cwd, slug }),
|
|
527
|
+
...lintStructure({ specDir: specRoot, tasks, intakeContent }),
|
|
528
|
+
];
|
|
529
|
+
return {
|
|
530
|
+
slug,
|
|
531
|
+
scopes: scopes.length,
|
|
532
|
+
tasks: tasks.length,
|
|
533
|
+
red: findings.filter((f) => f.level === "red").length,
|
|
534
|
+
warn: findings.filter((f) => f.level === "warn").length,
|
|
535
|
+
findings,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The typed argv contract (see `kernel/lib/argv.mjs`). */
|
|
540
|
+
export const ARGV_SPEC = {
|
|
541
|
+
usage: "harness.mjs verify spec --slug <slug> [--cwd <dir>]",
|
|
542
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
543
|
+
slug: { type: "str", required: true },
|
|
544
|
+
cwd: { type: "path" },
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Lint the committed spec tree against the board it must stay in step with.
|
|
549
|
+
*
|
|
550
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
551
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
552
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
553
|
+
*/
|
|
554
|
+
export async function cli(rawArgv) {
|
|
555
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
556
|
+
const report = lint({ cwd: resolve(args.cwd || process.cwd()), slug: args.slug });
|
|
557
|
+
console.log(JSON.stringify(report, null, 2));
|
|
558
|
+
process.exit(report.red > 0 ? 1 : 0);
|
|
559
|
+
}
|