shapeup-sdlc 3.7.9 → 3.7.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/sandbox-guard.mjs +9 -0
- package/kernel/compile.mjs +67 -15
- package/kernel/probe/eval.mjs +19 -5
- package/kernel/schemas/domain.schema.json +8 -1
- package/kernel/verify/env.mjs +23 -6
- package/kernel/verify/spec.mjs +70 -0
- package/kernel/verify/t0.mjs +12 -3
- package/package.json +1 -1
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +5 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shapeup-sdlc-plugin",
|
|
3
3
|
"displayName": "ShapeUp SDLC Plugin",
|
|
4
|
-
"version": "3.7.
|
|
4
|
+
"version": "3.7.12",
|
|
5
5
|
"description": "Shape Up SDLC harness for Claude Code: shaping, intake, orient, scope-mapping, building (T0-verified, sandboxed, scope-contracted), evaluation and QA skills orchestrated by a tech-lead.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Liberty Nguyen",
|
package/hooks/sandbox-guard.mjs
CHANGED
|
@@ -329,6 +329,11 @@ async function main() {
|
|
|
329
329
|
allowed: [...(o.substrate.allowed || []), ...(o.substrate.shared || [])],
|
|
330
330
|
appendOnly: o.substrate.append_only || [],
|
|
331
331
|
frozen: o.substrate.frozen || [],
|
|
332
|
+
// The one exception to "frozen outranks everything", and it is the compiler's to grant, never
|
|
333
|
+
// the worker's to request: the paths THIS order authors, named from its own identity. A build
|
|
334
|
+
// leg's substrate freezes the whole run trace, so without this its own WorkResult — its
|
|
335
|
+
// documented last step — would be denied along with every channel it must not touch.
|
|
336
|
+
own: o.substrate.own || [],
|
|
332
337
|
})).filter((c) => c.allowed.length || c.appendOnly.length || c.frozen.length);
|
|
333
338
|
|
|
334
339
|
if (contracts.length === 0) defer("no live order declares write/append/frozen boundaries", "no-whitelist");
|
|
@@ -363,6 +368,10 @@ async function main() {
|
|
|
363
368
|
// a planner is graded against — so the compiler emitted a declaration with no enforcer, which is
|
|
364
369
|
// the exact state this hook exists to end. A path a live contract freezes is a violation
|
|
365
370
|
// wherever it lives.
|
|
371
|
+
// `own` first, and only against the contract that declared it: another live order's exception
|
|
372
|
+
// never licenses this write. A path no contract claims as its own falls through to the freeze.
|
|
373
|
+
if (contracts.some((c) => matchesAny(rel, c.own, fold))) continue;
|
|
374
|
+
|
|
366
375
|
const freezer = contracts.find((c) => matchesAny(rel, c.frozen, fold));
|
|
367
376
|
if (freezer) {
|
|
368
377
|
violations.push(rel);
|
package/kernel/compile.mjs
CHANGED
|
@@ -211,13 +211,37 @@ export const OP_OWNER = {
|
|
|
211
211
|
* operation, so mode/flag differences are enforced by the sandbox hook reading the order's substrate, not trusted to prose.
|
|
212
212
|
* @param {string} operation - The order's operation (execute|fix|spike|analyze|reconcile|
|
|
213
213
|
* retrofit-surface|coverage|map-scopes|wire|evaluate|orient|hunt|translate|hammer|coach|scan|research).
|
|
214
|
-
* @param {{slug?:string, specDir?:string, scope?:object}} [ctx] - slug (names
|
|
215
|
-
* specDir (overrides the default spec path), scope (contract supplying
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
214
|
+
* @param {{slug?:string, specDir?:string, scope?:object, ownStem?:string}} [ctx] - slug (names
|
|
215
|
+
* LOCAL/SHARED roots), specDir (overrides the default spec path), scope (contract supplying
|
|
216
|
+
* allowed/shared substrates), ownStem (this order's own file stem, so a build leg may write its
|
|
217
|
+
* own WorkResult and no one else's).
|
|
218
|
+
* @returns {{allowed:string[], shared?:string[], frozen?:string[], append_only?:string[], own?:string[]}}
|
|
219
|
+
* The substrate contract: globs the worker may write (`allowed`), shared-write globs, read-only
|
|
220
|
+
* `frozen` globs, `append_only` globs, and `own` — the paths this order may write DESPITE a
|
|
221
|
+
* broader freeze, derived by the compiler from the order's own identity and never requested.
|
|
222
|
+
* An unknown operation returns a LOCAL-only default.
|
|
219
223
|
*/
|
|
220
|
-
export function substrateFor(operation,
|
|
224
|
+
export function substrateFor(operation, ctx = {}) {
|
|
225
|
+
// EVERY ORDER NAMES THE RESULT IT ANSWERS WITH. A worker used to infer that path from its order's
|
|
226
|
+
// own filename — the one thing in the envelope that was convention rather than contract — so the
|
|
227
|
+
// one file every dispatch must write was the one the order did not mention. It is `own` for every
|
|
228
|
+
// operation now: the compiler derives it from the order's identity, which is also what keeps a leg
|
|
229
|
+
// from writing somebody else's.
|
|
230
|
+
const base = substrateTemplate(operation, ctx);
|
|
231
|
+
if (!ctx.ownStem) return base;
|
|
232
|
+
const ownResult = `${globLocal(ctx.slug)}/results/${ctx.ownStem}.json`;
|
|
233
|
+
return { ...base, own: [...new Set([...(base.own || []), ownResult])] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The per-operation template {@link substrateFor} builds on.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} operation - The order's operation.
|
|
240
|
+
* @param {object} [ctx] - As {@link substrateFor}: slug, specDir, scope, ownStem.
|
|
241
|
+
* @returns {{allowed:string[], shared?:string[], frozen?:string[], append_only?:string[], own?:string[]}}
|
|
242
|
+
* The operation's write contract before the result path every order names is merged in.
|
|
243
|
+
*/
|
|
244
|
+
function substrateTemplate(operation, { slug, specDir, scope, ownStem = null } = {}) {
|
|
221
245
|
const local = globLocal(slug);
|
|
222
246
|
const spec = specDir || globShared(slug, "spec");
|
|
223
247
|
const scopesDir = globShared(slug, "scopes");
|
|
@@ -266,15 +290,30 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
|
|
|
266
290
|
const FROZEN_ATTESTATION = [`${local}/receipts/**`, `${local}/legs.jsonl`, `${local}/t0/verdicts/**`, `${local}/tasks/_index.md`];
|
|
267
291
|
switch (operation) {
|
|
268
292
|
case "execute": case "fix": case "spike":
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
293
|
+
// THE RUN TRACE IS THE KERNEL'S, EXCEPT WHAT THIS LEG AUTHORS. The freeze used to be a list of
|
|
294
|
+
// channels a defect had named — the staged pitch, the receipts, the leg ledger, the T0
|
|
295
|
+
// verdicts, the board index — and each review found more of the same class: the trial ledger,
|
|
296
|
+
// the gate ledger, the round build gates, the graph, the run args, the run ledger itself. A
|
|
297
|
+
// list that grows one defect at a time is not a boundary. So the boundary is inverted here:
|
|
298
|
+
// everything under the run trace is frozen for a build leg, and `own` carries the short,
|
|
299
|
+
// derived list of what such a leg actually writes. Everything else under there is written by
|
|
300
|
+
// the kernel in its own process or by the hook layer, and neither goes through this guard —
|
|
301
|
+
// so freezing it costs no legitimate write. `${local}/**` subsumes FROZEN_INTAKE and
|
|
302
|
+
// FROZEN_ATTESTATION for this operation, and a check asserts that rather than restating them.
|
|
274
303
|
return {
|
|
275
304
|
allowed: [...(scope?.allowed_file_substrate || []), `${local}/spikes/**`],
|
|
276
305
|
shared: scope?.shared_substrate || [],
|
|
277
|
-
frozen: [
|
|
306
|
+
frozen: [`${local}/**`],
|
|
307
|
+
own: [
|
|
308
|
+
// The result this order answers with, and no sibling's: a leg that can write another
|
|
309
|
+
// leg's WorkResult can report work nobody did, and ingest would have no way to tell.
|
|
310
|
+
// With no stem the caller is asking about the operation in general, not about one order,
|
|
311
|
+
// and the honest answer is the whole directory rather than a guess at which file.
|
|
312
|
+
...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`]),
|
|
313
|
+
`${local}/tasks/TASK-*.md`,
|
|
314
|
+
`${local}/discovery/**`,
|
|
315
|
+
`${local}/spikes/**`,
|
|
316
|
+
],
|
|
278
317
|
};
|
|
279
318
|
case "analyze":
|
|
280
319
|
return { allowed: [`${spec}/**`, `${local}/**`], frozen: [...FROZEN_INTAKE] };
|
|
@@ -315,9 +354,22 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
|
|
|
315
354
|
frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE, `${scopesDir}/**`, globShared(slug, "project-profile.md")],
|
|
316
355
|
};
|
|
317
356
|
case "evaluate":
|
|
318
|
-
|
|
357
|
+
// THE JUDGE MAY NOT WRITE THE EVIDENCE IT CITES. Its substrate froze the spec, the pitch and
|
|
358
|
+
// the board — and not `t0/verdicts/**`, so a judge could write a green verdict artifact into
|
|
359
|
+
// the canonical directory under the run-trace carve-out and then cite it, correctly hashed.
|
|
360
|
+
// Same inversion as a build leg: the run trace is the kernel's, and `own` names what the
|
|
361
|
+
// judge authors — its report and the envelope that answers its order.
|
|
362
|
+
return {
|
|
363
|
+
allowed: [],
|
|
364
|
+
frozen: [`${spec}/**`, `${local}/**`],
|
|
365
|
+
own: [`${local}/evaluation/**`, ...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`])],
|
|
366
|
+
};
|
|
319
367
|
case "hunt":
|
|
320
|
-
return {
|
|
368
|
+
return {
|
|
369
|
+
allowed: [],
|
|
370
|
+
frozen: [`${spec}/**`, `${local}/**`],
|
|
371
|
+
own: [`${local}/qa/**`, ...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`])],
|
|
372
|
+
};
|
|
321
373
|
case "orient":
|
|
322
374
|
return { allowed: [`${local}/orient/**`], frozen: [`${spec}/**`] };
|
|
323
375
|
case "translate":
|
|
@@ -782,7 +834,7 @@ export function compileOrder({
|
|
|
782
834
|
mode,
|
|
783
835
|
...(operation ? { operation } : {}),
|
|
784
836
|
...(interaction ? { interaction } : {}),
|
|
785
|
-
substrate: substrateFor(operation, { slug, specDir, scope }),
|
|
837
|
+
substrate: substrateFor(operation, { slug, specDir, scope, ownStem: suffix }),
|
|
786
838
|
payload: {
|
|
787
839
|
...(scope ? { scope_contract: scope } : {}),
|
|
788
840
|
...(tasks?.length ? { tasks } : {}),
|
package/kernel/probe/eval.mjs
CHANGED
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
// cause as its first deviation. A bare `ok: false` reached the operator as a sub-agent that died
|
|
32
32
|
// after retries, while the one sentence naming the actual cause sat in a file nobody was pointed at.
|
|
33
33
|
|
|
34
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
35
|
-
import { join, resolve } from "node:path";
|
|
34
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
35
|
+
import { join, resolve, resolve as resolvePath, sep } from "node:path";
|
|
36
36
|
import { createHash } from "node:crypto";
|
|
37
37
|
import { runArgs } from "../lib/argv.mjs";
|
|
38
|
-
import { resultsDir, scopesDir, readRunId } from "../lib/paths.mjs";
|
|
38
|
+
import { resultsDir, scopesDir, readRunId, verdictsDir } from "../lib/paths.mjs";
|
|
39
39
|
|
|
40
40
|
/** Longest `reason` reported. A deviation is prose written by a worker and can run to paragraphs. */
|
|
41
41
|
const REASON_MAX = 400;
|
|
@@ -89,7 +89,7 @@ export function isScoped(cwd, slug) {
|
|
|
89
89
|
* @returns {(string|null)} A reason phrased for an operator, or null when the file at `path` exists,
|
|
90
90
|
* hashes to the cited `sha256`, and its own `overall` reads "green".
|
|
91
91
|
*/
|
|
92
|
-
function unresolvedCitation(cwd, citation, { round = null, runId = null } = {}) {
|
|
92
|
+
function unresolvedCitation(cwd, citation, { round = null, runId = null, verdicts = null } = {}) {
|
|
93
93
|
const rel = typeof citation?.path === "string" ? citation.path : "";
|
|
94
94
|
if (!rel) return "names no artifact path";
|
|
95
95
|
let text;
|
|
@@ -109,6 +109,19 @@ function unresolvedCitation(cwd, citation, { round = null, runId = null } = {})
|
|
|
109
109
|
try { body = JSON.parse(text); }
|
|
110
110
|
catch { return `cites ${rel}, whose bytes match the hash but do not read as a T0 verdict`; }
|
|
111
111
|
if (body?.overall !== "green") return `cites ${rel}, whose own verdict is "${body?.overall ?? "unknown"}", not green`;
|
|
112
|
+
// INSIDE THIS RUN'S OWN VERDICTS DIRECTORY, resolved — asked of a file that exists, so "does not
|
|
113
|
+
// exist" and "resolves somewhere else" stay different answers. Accepted before this check: a
|
|
114
|
+
// green artifact in the source tree, one outside the project reached by `../`, one by absolute
|
|
115
|
+
// path, and a symlink in the verdicts directory pointing at a forged file. Every one hashed
|
|
116
|
+
// correctly, because a digest says the bytes are the file's and nothing about which file it
|
|
117
|
+
// should have been.
|
|
118
|
+
if (verdicts) {
|
|
119
|
+
const real = (() => { try { return realpathSync.native(resolvePath(cwd, rel)); } catch { return resolvePath(cwd, rel); } })();
|
|
120
|
+
const home = (() => { try { return realpathSync.native(verdicts); } catch { return verdicts; } })();
|
|
121
|
+
if (!real.startsWith(home.endsWith(sep) ? home : home + sep)) {
|
|
122
|
+
return `cites ${rel}, which resolves outside this run's own verdicts directory (${home}) — a verdict cites what this run's own verifier wrote, not a file the judge can reach`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
112
125
|
// THE ARTIFACT HAS TO BE THE ONE THE CITATION SAYS IT IS. A re-hash proves the bytes are the
|
|
113
126
|
// file's; it says nothing about whose verdict the file holds. A PASS citing scope alpha's green
|
|
114
127
|
// artifact while declaring scope beta, or a prior round's, or a prior run's over the same slug,
|
|
@@ -198,8 +211,9 @@ export function citationProblem(cwd, slug, verdict, { round = null } = {}) {
|
|
|
198
211
|
"cite the T0 verdict it re-hashed (the order lists them under payload.t0_artifacts)";
|
|
199
212
|
}
|
|
200
213
|
const runId = readRunId(cwd, slug);
|
|
214
|
+
const verdicts = verdictsDir(cwd, slug);
|
|
201
215
|
for (const citation of verdict.t0_citations) {
|
|
202
|
-
const reason = unresolvedCitation(cwd, citation, { round, runId });
|
|
216
|
+
const reason = unresolvedCitation(cwd, citation, { round, runId, verdicts });
|
|
203
217
|
if (reason) return `the ${verdict.overall} verdict ${reason} — a T0 citation is re-hashed from disk, never taken on the handed word`;
|
|
204
218
|
}
|
|
205
219
|
return null;
|
|
@@ -542,7 +542,14 @@
|
|
|
542
542
|
"items": {
|
|
543
543
|
"type": "string"
|
|
544
544
|
},
|
|
545
|
-
"description": "Explicitly untouchable paths (spec core: domain-model, UC Steps, contracts, ux-behavior)."
|
|
545
|
+
"description": "Explicitly untouchable paths (spec core: domain-model, UC Steps, contracts, ux-behavior). A build leg's order freezes the whole run trace here and carves out what it authors through `own`: a freeze that lists the channels a defect happened to name grows one defect at a time and is not a boundary."
|
|
546
|
+
},
|
|
547
|
+
"own": {
|
|
548
|
+
"type": "array",
|
|
549
|
+
"items": {
|
|
550
|
+
"type": "string"
|
|
551
|
+
},
|
|
552
|
+
"description": "Paths this order may write DESPITE a broader freeze — the compiler's grant, derived from the order's own identity and never requested by a worker. For a build leg: its own WorkResult (no sibling's — a leg that can write another's can report work nobody did), its task files, the discovery ledger, its spikes. Checked BEFORE `frozen`, and only against the contract that declared it, so another live order's exception never licenses this write."
|
|
546
553
|
}
|
|
547
554
|
}
|
|
548
555
|
},
|
package/kernel/verify/env.mjs
CHANGED
|
@@ -52,6 +52,16 @@ const UNSET = "<unset>";
|
|
|
52
52
|
* @param {string} cmd - A shell command line.
|
|
53
53
|
* @returns {string[]} Invoked tokens, in order, without duplicates.
|
|
54
54
|
*/
|
|
55
|
+
export function cdTargets(cmd) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const seg of String(cmd || "").split(/&&|;|\|\|/).map((s) => s.trim()).filter(Boolean)) {
|
|
58
|
+
const m = seg.match(/^cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
|
59
|
+
const dir = m && (m[1] || m[2] || m[3]);
|
|
60
|
+
if (dir && !dir.startsWith("-") && !out.includes(dir)) out.push(dir);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
export function invokedTokens(cmd) {
|
|
56
66
|
const out = [];
|
|
57
67
|
for (const seg of String(cmd || "").split(/&&|;|\|\|/).map((s) => s.trim()).filter(Boolean)) {
|
|
@@ -162,12 +172,19 @@ export function environmentFingerprint(rawCwd, { commands = [], profilePath = nu
|
|
|
162
172
|
cwd,
|
|
163
173
|
tree: treeState(cwd),
|
|
164
174
|
toolchain: tokens.map((bin) => ({ bin, path: resolveBin(bin, cwd) })),
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
// The root AND wherever the commands actually run. Measured on a consumer whose fixtures are
|
|
176
|
+
// `cd app && …`: the lockfile that decides what the build resolves lives in `app/`, and a scan
|
|
177
|
+
// of the project root alone recorded an empty list beside a build whose dependencies were the
|
|
178
|
+
// whole question.
|
|
179
|
+
lockfiles: [...new Set(["", ...commands.flatMap((c) => cdTargets(c))])]
|
|
180
|
+
.flatMap((sub) => LOCKFILES
|
|
181
|
+
.map((f) => (sub ? `${sub.replace(/\/+$/, "")}/${f}` : f))
|
|
182
|
+
.filter((rel) => existsSync(join(cwd, rel)))
|
|
183
|
+
.map((rel) => {
|
|
184
|
+
try { return { file: rel, sha256: sha256(readFileSync(join(cwd, rel))) }; }
|
|
185
|
+
catch { return { file: rel, sha256: null }; }
|
|
186
|
+
}))
|
|
187
|
+
.filter((l, i, all) => all.findIndex((x) => x.file === l.file) === i),
|
|
171
188
|
caches: declaredCaches(profilePath),
|
|
172
189
|
env: {
|
|
173
190
|
allowlist: ENV_ALLOWLIST,
|
package/kernel/verify/spec.mjs
CHANGED
|
@@ -422,6 +422,9 @@ export function lintScopeAnchors({ scopes, specDir: specRoot, reqIds = null, tas
|
|
|
422
422
|
return findings;
|
|
423
423
|
}
|
|
424
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
|
+
|
|
425
428
|
/**
|
|
426
429
|
* REQ-UNCOVERED — a live requirement that nothing in the plan reaches.
|
|
427
430
|
*
|
|
@@ -451,6 +454,57 @@ export function lintScopeAnchors({ scopes, specDir: specRoot, reqIds = null, tas
|
|
|
451
454
|
* @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} One red per
|
|
452
455
|
* uncovered live requirement; [] when every one is graded, claimed or cut.
|
|
453
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
|
+
*/
|
|
454
508
|
export function lintRequirementCoverage({ clauses = [], board = [], scopes = [] }) {
|
|
455
509
|
const findings = [];
|
|
456
510
|
const graded = coveredReqIds(board);
|
|
@@ -459,6 +513,21 @@ export function lintRequirementCoverage({ clauses = [], board = [], scopes = []
|
|
|
459
513
|
const claimed = new Set();
|
|
460
514
|
for (const s of scopes) for (const r of s.covers || []) claimed.add(reqId(r).toUpperCase());
|
|
461
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
|
+
}
|
|
462
531
|
if (c.status !== "covered") continue; // CUT (PO-approved) — an answer on the record, not a gap
|
|
463
532
|
const id = c.id.toUpperCase();
|
|
464
533
|
if (graded.has(c.id) || claimed.has(id)) continue;
|
|
@@ -851,6 +920,7 @@ export function lint({ cwd, slug }) {
|
|
|
851
920
|
// `readBoard`, not the `tasks` above: only the compile-order parser carries acceptance_criteria.
|
|
852
921
|
...lintRequirementCoverage({ clauses: reqClauses, board: readBoard(cwd, slug), scopes }),
|
|
853
922
|
...lintCommittedTier({ cwd, slug }),
|
|
923
|
+
...lintNarratedCoverage({ cwd, slug }),
|
|
854
924
|
...lintStructure({ specDir: specRoot, tasks, intakeContent }),
|
|
855
925
|
...(() => {
|
|
856
926
|
const bbText = runBreadboard(cwd, slug, intakeContent);
|
package/kernel/verify/t0.mjs
CHANGED
|
@@ -194,13 +194,17 @@ export function runDbProbe(dbProbeCmd, cwd) {
|
|
|
194
194
|
*/
|
|
195
195
|
export function seesawCheck(registryPath, cwd) {
|
|
196
196
|
if (!registryPath || !existsSync(registryPath)) {
|
|
197
|
-
|
|
197
|
+
// `pass: null`, not `pass: true`: a check that did not run has no result, and recording one as
|
|
198
|
+
// clean is how "not asked" came to read as "nothing regressed". The verdict below still treats
|
|
199
|
+
// an absent seesaw as non-blocking — that part is deliberate while the arm is unwired — but the
|
|
200
|
+
// artifact now says which of the two it was.
|
|
201
|
+
return { ran: false, pass: null, scopes_checked: [], failing: [] };
|
|
198
202
|
}
|
|
199
203
|
let registry;
|
|
200
204
|
try {
|
|
201
205
|
registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
202
206
|
} catch {
|
|
203
|
-
return { ran: false, pass:
|
|
207
|
+
return { ran: false, pass: null, scopes_checked: [], failing: [], error: "registry unparsable" };
|
|
204
208
|
}
|
|
205
209
|
const scopes = registry.scopes || [];
|
|
206
210
|
const failing = [];
|
|
@@ -222,7 +226,12 @@ export function seesawCheck(registryPath, cwd) {
|
|
|
222
226
|
export function computeVerdict({ fixtures, dbProbe, seesaw }) {
|
|
223
227
|
const fixturesGreen = fixtures.pass;
|
|
224
228
|
const dbGreen = dbProbe === null || dbProbe.pass;
|
|
225
|
-
|
|
229
|
+
// A seesaw that did not run does not hold the verdict red — the arm is declared and unwired, and
|
|
230
|
+
// blocking every build on it would be a different defect. It does not make it green either: the
|
|
231
|
+
// hill requires `ran && pass` before a scope may reach FINISHED, and `seesaw_green` here means
|
|
232
|
+
// "nothing this check found is wrong", which is true of a check that found nothing because it
|
|
233
|
+
// never looked.
|
|
234
|
+
const seesawGreen = seesaw.ran ? seesaw.pass === true : true;
|
|
226
235
|
return {
|
|
227
236
|
fixtures_green: fixturesGreen,
|
|
228
237
|
db_probe_green: dbGreen,
|
package/package.json
CHANGED
|
@@ -26,6 +26,11 @@ depends_on:
|
|
|
26
26
|
|
|
27
27
|
| Indicator | Status | Signal |
|
|
28
28
|
|-----------|--------|--------|
|
|
29
|
+
<!-- Coverage here is USE CASES × TASKS, derived by inverting each task's use_case_refs over the
|
|
30
|
+
local board. It is NOT requirement coverage: whether every REQ-id reaches an acceptance
|
|
31
|
+
criterion that a judge graded is a projection derived per run (`probe requirements`), and a
|
|
32
|
+
committed file that states it is corroborating something no run measured. Name REQ ids in this
|
|
33
|
+
row and spec-lint reds it (REQ-NARRATED). Count use cases and tasks; say nothing about REQ. -->
|
|
29
34
|
| Coverage | COVERAGE_STATUS | COVERAGE_SIGNAL |
|
|
30
35
|
| Risk | RISK_STATUS | RISK_SIGNAL |
|
|
31
36
|
| Dependency | DEPENDENCY_STATUS | DEPENDENCY_SIGNAL |
|