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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// contract
|
|
1
|
+
// contract — read and write the committed contracts as markdown.
|
|
2
2
|
//
|
|
3
3
|
// WHY (ADR-0001). Scope contracts, the wiring map and the project profile are *low-level design*:
|
|
4
4
|
// which files a slice may touch, which seam each use case attaches to, where the app starts. A
|
|
@@ -38,7 +38,7 @@ import { join } from "node:path";
|
|
|
38
38
|
// Per-type layout: which array-of-objects field lives under which heading.
|
|
39
39
|
//
|
|
40
40
|
// `signatures` names the columns that identify a table as THAT field's, and it exists because of
|
|
41
|
-
//
|
|
41
|
+
// The heading match is exact, so a table written under `# Wiring map — <slug>` instead of
|
|
42
42
|
// `## Wiring` parsed as ABSENT, and every reader downstream treats absent as "none declared". The
|
|
43
43
|
// observed consequence was `trace-lint` reporting `🟢 green · 0/0 engines reach src/cli/main.js`
|
|
44
44
|
// for a committed map holding six correct rows — the gate whose whole purpose is that no engine
|
|
@@ -56,10 +56,30 @@ export const SCOPE_CONTRACT = {
|
|
|
56
56
|
signatures: { affordance_manifest: ["test_id", "role"] },
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* `WiringMap` — per use case: engine → seam → entry-point call site → affordance.
|
|
61
|
+
*
|
|
62
|
+
* `perSection` is a MIGRATION READER, and it exists because the canonical shape was never written.
|
|
63
|
+
* `solution-architect` was told to produce a `{schema_version, feature, entry_point, entries[]}`
|
|
64
|
+
* OBJECT and never told the markdown layout, so every run authored its own: a `## Entries` section
|
|
65
|
+
* with one `### UC-xx — title` subsection per use case, each holding a vertical `| Field | Value |`
|
|
66
|
+
* table. Under this spec that parses as no table at all — and the vertical layout carries no
|
|
67
|
+
* `use_case` header cell, so the `signatures` detector below cannot see it either and the contract
|
|
68
|
+
* reports as perfectly readable while yielding nothing.
|
|
69
|
+
*
|
|
70
|
+
* Measured across every completed run in the corpus: 9 of 9 committed wiring maps parsed to ZERO
|
|
71
|
+
* entries with `unreadableReason() === null`, and `trace-lint` certified `🟢 green · 0/0 engines
|
|
72
|
+
* reach <entry point>` against a deliverable whose engines were on disk — the exact failure quoted
|
|
73
|
+
* in the banner above, recurred in a shape its own fix could not detect.
|
|
74
|
+
*
|
|
75
|
+
* The horizontal `## Wiring` table stays canonical: it is what `renderContract` emits, so a map
|
|
76
|
+
* regenerated by any later `wire` converges on it without a migration step. This reader only keeps
|
|
77
|
+
* the maps already committed from resolving to nothing.
|
|
78
|
+
*/
|
|
60
79
|
export const WIRING_MAP = {
|
|
61
80
|
tables: { entries: "Wiring" },
|
|
62
81
|
signatures: { entries: ["use_case", "engine"] },
|
|
82
|
+
perSection: { field: "entries", key: "use_case", idPattern: /^(UC-[A-Za-z0-9_.-]+)/ },
|
|
63
83
|
};
|
|
64
84
|
|
|
65
85
|
/** `ProjectProfile` — archetype + entry point. All scalars; no tables. */
|
|
@@ -74,10 +94,52 @@ export const PROJECT_PROFILE = { tables: {} };
|
|
|
74
94
|
*/
|
|
75
95
|
export const UNREADABLE = "$unreadable_tables";
|
|
76
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Marks a contract that parsed only via a MIGRATION reader — readable, but not in the canonical
|
|
99
|
+
* shape. Without it the fallback is permanent by silence: the file works, nothing says it is the
|
|
100
|
+
* old form, and one artifact quietly keeps two formats forever. Readers surface it as a warn so a
|
|
101
|
+
* regeneration converges the file instead of the reader carrying it indefinitely.
|
|
102
|
+
*/
|
|
103
|
+
export const LEGACY_LAYOUT = "$legacy_layout";
|
|
104
|
+
|
|
77
105
|
// ---------------------------------------------------------------------------
|
|
78
106
|
// Scalars
|
|
79
107
|
// ---------------------------------------------------------------------------
|
|
80
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Remove ONE matching pair of surrounding quotes, and unescape what was inside them.
|
|
111
|
+
*
|
|
112
|
+
* The pairing check is the whole point. Stripping a leading or trailing quote INDEPENDENTLY eats a
|
|
113
|
+
* character from any value that merely ends in one — a shell fixture like
|
|
114
|
+
* `export STORE="$T/s.json"` came back missing its final quote and was handed to `bash` as a
|
|
115
|
+
* syntax error. Nothing reports that as a parse failure: the verification step simply scores the
|
|
116
|
+
* implementation red, attempt after attempt, against code that was correct the whole time. A lossy
|
|
117
|
+
* reader is indistinguishable from a builder that cannot make progress, which is what makes this
|
|
118
|
+
* expensive rather than merely wrong.
|
|
119
|
+
*
|
|
120
|
+
* A double-quoted scalar that parses as JSON is read as JSON, because that is the escaping
|
|
121
|
+
* convention {@link uncoerce} emits — the two are exact inverses, and the round trip is asserted.
|
|
122
|
+
*
|
|
123
|
+
* EVERYTHING ELSE IS UNWRAPPED BUT NOT UNESCAPED, deliberately. A backslash inside a scalar that
|
|
124
|
+
* is not valid JSON belongs to whatever will consume the value, not to this dialect: shell
|
|
125
|
+
* verification fixtures are the common case, and there `echo "[{\"text\": \"ok\"}]"` needs its
|
|
126
|
+
* backslashes delivered intact. Unescaping on this path would corrupt them exactly as surely as
|
|
127
|
+
* the missing pair check corrupted the values that end in a quote — the same defect, from the
|
|
128
|
+
* other side. So the escaping convention applies only where it is unambiguous.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} s - The trimmed raw text.
|
|
131
|
+
* @returns {string} The value with its surrounding quotes removed, or `s` unchanged.
|
|
132
|
+
*/
|
|
133
|
+
function unquote(s) {
|
|
134
|
+
if (s.length < 2) return s;
|
|
135
|
+
const q = s[0];
|
|
136
|
+
if ((q !== '"' && q !== "'") || s[s.length - 1] !== q) return s;
|
|
137
|
+
if (q === '"') {
|
|
138
|
+
try { return JSON.parse(s); } catch { /* not JSON — unwrap only, see above */ }
|
|
139
|
+
}
|
|
140
|
+
return s.slice(1, -1);
|
|
141
|
+
}
|
|
142
|
+
|
|
81
143
|
/**
|
|
82
144
|
* Coerce one frontmatter or table-cell value.
|
|
83
145
|
*
|
|
@@ -91,7 +153,7 @@ export const UNREADABLE = "$unreadable_tables";
|
|
|
91
153
|
*/
|
|
92
154
|
export function coerce(raw) {
|
|
93
155
|
let trimmed = String(raw ?? "").trim();
|
|
94
|
-
//
|
|
156
|
+
// A MARKDOWN CODE SPAN IS FORMATTING, NOT PART OF THE VALUE.
|
|
95
157
|
//
|
|
96
158
|
// These contracts are MARKDOWN on disk, and a code span is the idiomatic way to write a path or
|
|
97
159
|
// an identifier in one; this repo's own prose backticks every path it names. Read literally,
|
|
@@ -99,7 +161,7 @@ export function coerce(raw) {
|
|
|
99
161
|
// anywhere — so `trace-lint` reported "engine file not on disk" and then "reachability is not
|
|
100
162
|
// demonstrated" for a wiring map whose engines all resolve AND all reach the entry point. The
|
|
101
163
|
// gate that exists so no engine ships orphaned failing CLOSED, on a correct map, is the same
|
|
102
|
-
// silent-format family as
|
|
164
|
+
// silent-format family as the parser defects above, arriving from the other direction.
|
|
103
165
|
//
|
|
104
166
|
// Stripped ONLY when the whole value is a single span: a cell like
|
|
105
167
|
// "Registered as the `add` entry in `TABLE`" is prose that happens to contain spans, and its
|
|
@@ -110,7 +172,7 @@ export function coerce(raw) {
|
|
|
110
172
|
// A LIST IS TESTED BEFORE THE QUOTES ARE STRIPPED. `"[a, b]"` is a quoted STRING; stripping first
|
|
111
173
|
// would turn it into a list and change its type on a round-trip.
|
|
112
174
|
if (/^\[.*\]$/.test(trimmed)) return splitList(trimmed.slice(1, -1));
|
|
113
|
-
const v = trimmed
|
|
175
|
+
const v = unquote(trimmed);
|
|
114
176
|
if (v === "true") return true;
|
|
115
177
|
if (v === "false") return false;
|
|
116
178
|
if (v === "~" || v === "null" || v === "") return null;
|
|
@@ -125,17 +187,22 @@ export function coerce(raw) {
|
|
|
125
187
|
*/
|
|
126
188
|
export function uncoerce(v) {
|
|
127
189
|
if (v === null || v === undefined) return "~";
|
|
128
|
-
//
|
|
190
|
+
// The other half of the comma defect. A member containing the delimiter must go back out QUOTED, or the round
|
|
129
191
|
// trip that wrote it re-reads as several members — the same shredding, arriving from the writer's
|
|
130
192
|
// side instead of the reader's.
|
|
131
193
|
if (Array.isArray(v)) return `[${v.map((x) => (/[,"]/.test(String(x)) ? JSON.stringify(String(x)) : String(x))).join(", ")}]`;
|
|
132
|
-
|
|
194
|
+
const s = String(v);
|
|
195
|
+
// A scalar that itself begins AND ends with a quote is indistinguishable, once written, from a
|
|
196
|
+
// quoted scalar — so emit it in the JSON form the reader unwraps exactly. Without this the round
|
|
197
|
+
// trip loses the value's own outer quotes, which is the same shredding as the reader's half.
|
|
198
|
+
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[s.length - 1] === s[0]) return JSON.stringify(s);
|
|
199
|
+
return s;
|
|
133
200
|
}
|
|
134
201
|
|
|
135
202
|
/**
|
|
136
203
|
* Split a `[a, b]` list body on commas that are NOT inside quotes.
|
|
137
204
|
*
|
|
138
|
-
*
|
|
205
|
+
* The old implementation was `body.split(",")`, and it shredded any member carrying a
|
|
139
206
|
* comma — even a correctly quoted one. Measured: a `scope-architect` run probed the running CLI,
|
|
140
207
|
* confirmed `tag` was unimplemented, and wrote the honest entry its own SKILL.md asks for —
|
|
141
208
|
* ["TBD — `tag` is not in dispatch.js's TABLE (exits 1, confirmed against the running CLI). A
|
|
@@ -191,12 +258,12 @@ export function splitFrontmatter(md) {
|
|
|
191
258
|
const key = line.slice(0, c).trim();
|
|
192
259
|
const inline = line.slice(c + 1).trim();
|
|
193
260
|
|
|
194
|
-
//
|
|
261
|
+
// An indented run beneath a key is a YAML BLOCK SEQUENCE, and it used to be skipped
|
|
195
262
|
// entirely — so `e2e_verification_fixtures:` followed by two `- "node …"` lines parsed to
|
|
196
263
|
// null, the members vanished, and no reader could tell "declared nothing" from "declared
|
|
197
264
|
// something I discarded". Measured: a scope-architect run wrote three scopes of researched
|
|
198
265
|
// fixtures in this form and every one evaporated. It is ACCEPTED now, because it is the form a
|
|
199
|
-
// model reaches for by default and the one that carries prose members without
|
|
266
|
+
// model reaches for by default and the one that carries prose members without the quoting
|
|
200
267
|
// problem — and anything indented that is NOT a block sequence is reported rather than dropped.
|
|
201
268
|
const block = [];
|
|
202
269
|
let stray = 0;
|
|
@@ -287,6 +354,36 @@ export function parseTables(body) {
|
|
|
287
354
|
return out;
|
|
288
355
|
}
|
|
289
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Fold per-entity `| Field | Value |` sections into the row array a horizontal table would give.
|
|
359
|
+
*
|
|
360
|
+
* The legacy wiring-map layout (see {@link WIRING_MAP}): one `### UC-xx — title` subsection per use
|
|
361
|
+
* case, each holding a vertical two-column table. The entity id comes from the HEADING, the fields
|
|
362
|
+
* from the rows. Field names are unwrapped from backticks and snake-cased so they land on the same
|
|
363
|
+
* keys the canonical horizontal table produces — the two layouts must yield identical objects or
|
|
364
|
+
* this is a second format rather than a second spelling of one.
|
|
365
|
+
*
|
|
366
|
+
* @param {Object<string, Array<Object>>} tables - `parseTables` output, keyed by heading.
|
|
367
|
+
* @param {{field:string, key:string, idPattern:RegExp}} desc - The `perSection` descriptor.
|
|
368
|
+
* @returns {Array<Object>} One row per matching section, in document order; [] when none match.
|
|
369
|
+
*/
|
|
370
|
+
export function perSectionRows(tables, desc) {
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const [heading, rows] of Object.entries(tables)) {
|
|
373
|
+
const id = heading.match(desc.idPattern)?.[1];
|
|
374
|
+
if (!id || !rows.length) continue;
|
|
375
|
+
const cols = new Set(Object.keys(rows[0]));
|
|
376
|
+
if (!cols.has("field") || !cols.has("value")) continue; // not a Field/Value table — prose, skip
|
|
377
|
+
const entry = { [desc.key]: id };
|
|
378
|
+
for (const r of rows) {
|
|
379
|
+
const k = String(r.field ?? "").replace(/`/g, "").trim().toLowerCase().replace(/\s+/g, "_");
|
|
380
|
+
if (k && r.value !== undefined) entry[k] = r.value;
|
|
381
|
+
}
|
|
382
|
+
out.push(entry);
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
290
387
|
/**
|
|
291
388
|
* Render rows as a markdown table.
|
|
292
389
|
* @param {Array<Object>} rows - Row objects.
|
|
@@ -322,13 +419,13 @@ export function parseContract(md, spec = SCOPE_CONTRACT) {
|
|
|
322
419
|
const { meta, body } = splitFrontmatter(md);
|
|
323
420
|
const tables = parseTables(body);
|
|
324
421
|
const out = { ...meta };
|
|
325
|
-
// Frontmatter-level diagnostics
|
|
422
|
+
// Frontmatter-level diagnostics and table-level ones share one channel, so a
|
|
326
423
|
// reader asks `unreadableReason()` once and cannot check for one while missing the other.
|
|
327
424
|
const unreadable = [...(meta[UNREADABLE] || [])];
|
|
328
425
|
delete out[UNREADABLE];
|
|
329
426
|
for (const [field, heading] of Object.entries(spec.tables || {})) {
|
|
330
427
|
if (tables[heading]) { out[field] = tables[heading]; continue; }
|
|
331
|
-
//
|
|
428
|
+
// The field is absent — but is it absent because nobody declared it, or because the
|
|
332
429
|
// author declared it somewhere this parser does not look? Those are opposite facts and the
|
|
333
430
|
// old code returned the same thing for both. A table carrying this field's signature columns,
|
|
334
431
|
// under a heading the spec does not claim, is the second case.
|
|
@@ -343,6 +440,57 @@ export function parseContract(md, spec = SCOPE_CONTRACT) {
|
|
|
343
440
|
}
|
|
344
441
|
}
|
|
345
442
|
}
|
|
443
|
+
// THE PER-SECTION LAYOUT, read only when the canonical table is genuinely absent.
|
|
444
|
+
//
|
|
445
|
+
// Ordering matters: the canonical `## Wiring` table always wins, so a map carrying both shapes
|
|
446
|
+
// never has the migration reader override the current one. If neither yields rows but the body
|
|
447
|
+
// DOES carry sections this descriptor recognises, that is a parse failure and not an empty
|
|
448
|
+
// field — the whole point of the banner above — so it is reported rather than returned as
|
|
449
|
+
// silence. That case is what let 9 of 9 committed maps read as clean while holding nothing.
|
|
450
|
+
const ps = spec.perSection;
|
|
451
|
+
if (ps && !(ps.field in out)) {
|
|
452
|
+
const rows = perSectionRows(tables, ps);
|
|
453
|
+
if (rows.length) { out[ps.field] = rows; out[LEGACY_LAYOUT] = true; }
|
|
454
|
+
// The headings come from the BODY, not from `tables` — `parseTables` only records a heading
|
|
455
|
+
// that has a table under it, so a section whose table is missing or malformed leaves no trace
|
|
456
|
+
// there. Looking for the evidence in the parsed output is looking where the failure already
|
|
457
|
+
// erased it, which is the same mistake this whole diagnostic exists to correct.
|
|
458
|
+
else if ([...String(body).matchAll(/^#{1,6}\s+(.*)$/gm)].some((m) => ps.idPattern.test(m[1].trim()))) {
|
|
459
|
+
unreadable.push({
|
|
460
|
+
field: ps.field, expected_heading: Object.entries(spec.tables || {}).find(([f]) => f === ps.field)?.[1] || ps.field,
|
|
461
|
+
found_under: "per-entity sections whose tables this dialect could not read", rows: 0,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// A NON-TABLE FIELD WRITTEN AS A `## SECTION` VANISHES, and the loop above cannot see it.
|
|
467
|
+
//
|
|
468
|
+
// Everything above answers "was a declared TABLE written under the wrong heading". It says
|
|
469
|
+
// nothing about the other half of the contract — the scalars and string lists that live in
|
|
470
|
+
// frontmatter — because the parser has no heading to expect for them. So an author who writes
|
|
471
|
+
//
|
|
472
|
+
// ## e2e_verification_fixtures
|
|
473
|
+
// - `node --test test/store.test.js` — round-trips load()/save()
|
|
474
|
+
//
|
|
475
|
+
// instead of a frontmatter key produces a contract where that field is simply `undefined`, and
|
|
476
|
+
// every reader downstream treats it as "not declared". Measured: an architect did exactly this,
|
|
477
|
+
// the fixtures reached `verify t0` as `undefined`, and six scopes were certified T0-green having
|
|
478
|
+
// executed nothing — while the substrate list beside it, written as a frontmatter block list,
|
|
479
|
+
// parsed perfectly. One field silently vanished between the writer and the reader.
|
|
480
|
+
//
|
|
481
|
+
// The detector is deliberately narrow: a heading whose text is a bare snake_case identifier is
|
|
482
|
+
// an author naming a FIELD, not writing prose — `## Why this slice` and `## Affordances` cannot
|
|
483
|
+
// match. Reported through the same channel, so `unreadableReason()` covers both halves and a
|
|
484
|
+
// reader still asks once.
|
|
485
|
+
for (const m of String(body).matchAll(/^##\s+([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\s*$/gm)) {
|
|
486
|
+
const field = m[1];
|
|
487
|
+
if (field in out) continue; // also present in frontmatter — fine
|
|
488
|
+
if (Object.prototype.hasOwnProperty.call(spec.tables || {}, field)) continue; // handled above
|
|
489
|
+
unreadable.push({
|
|
490
|
+
field, expected_heading: field, rows: 0,
|
|
491
|
+
found_under: "a `## " + field + "` markdown section, which this dialect reads as prose",
|
|
492
|
+
});
|
|
493
|
+
}
|
|
346
494
|
if (unreadable.length) out[UNREADABLE] = unreadable;
|
|
347
495
|
return out;
|
|
348
496
|
}
|
|
@@ -351,7 +499,7 @@ export function parseContract(md, spec = SCOPE_CONTRACT) {
|
|
|
351
499
|
* The one-line reason a contract could not be read, or null when it read cleanly.
|
|
352
500
|
*
|
|
353
501
|
* Exported so every consumer asks the same question the same way. A reader that skips this is
|
|
354
|
-
* back to treating "I could not see your table" as "you declared no table"
|
|
502
|
+
* back to treating "I could not see your table" as "you declared no table" — the whole defect class.
|
|
355
503
|
* @param {Object|null} contract - A parsed contract (or a `readContract()` result's `.contract`).
|
|
356
504
|
* @returns {string|null} A human-readable failure, or null if the contract parsed cleanly.
|
|
357
505
|
*/
|
|
@@ -359,9 +507,19 @@ export function unreadableReason(contract) {
|
|
|
359
507
|
const u = contract && contract[UNREADABLE];
|
|
360
508
|
if (!u || !u.length) return null;
|
|
361
509
|
return u
|
|
362
|
-
.map((x) =>
|
|
363
|
-
|
|
364
|
-
|
|
510
|
+
.map((x) => {
|
|
511
|
+
if (x.found_under === "an indented block this dialect cannot read" || x.found_under === "both") {
|
|
512
|
+
return `\`${x.field}\` was written as \`${x.expected_heading}\` but ${x.rows} indented line(s) beneath it could not be read, so the value parsed as ABSENT`;
|
|
513
|
+
}
|
|
514
|
+
// A frontmatter field written as a prose section. Phrased as its own case because telling an
|
|
515
|
+
// author to "use a table heading" when the fix is "put it in frontmatter" sends them the
|
|
516
|
+
// wrong way — and this is the field whose silent absence certified six scopes on no evidence.
|
|
517
|
+
if (x.rows === 0 && String(x.found_under).includes("markdown section")) {
|
|
518
|
+
return `\`${x.field}\` was written as a \`## ${x.field}\` markdown section, which this dialect reads as prose — ` +
|
|
519
|
+
`it must be a FRONTMATTER key (a \`- \` block list or an inline [a, b] list), so as written the field parsed as ABSENT`;
|
|
520
|
+
}
|
|
521
|
+
return `\`${x.field}\` must be a table under a \`## ${x.expected_heading}\` heading; found ${x.rows} matching row(s) under "${x.found_under}" instead, so the field parsed as ABSENT`;
|
|
522
|
+
})
|
|
365
523
|
.join("; ");
|
|
366
524
|
}
|
|
367
525
|
|
|
@@ -460,6 +618,92 @@ export function readAllContracts(dir, spec = SCOPE_CONTRACT) {
|
|
|
460
618
|
return out;
|
|
461
619
|
}
|
|
462
620
|
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// The scope ↔ spec join
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Normalise one use-case reference to a bare `UC-*` id.
|
|
627
|
+
*
|
|
628
|
+
* The same reference is written three ways across the tree — `UC-AddTodo`, `[[UC-AddTodo]]` and
|
|
629
|
+
* `[[usecases/UC-AddTodo]]` all name one file — so every reader must strip identically or the two
|
|
630
|
+
* sides of a join disagree about a UC they both hold.
|
|
631
|
+
*
|
|
632
|
+
* @param {string} ref - A use-case reference in any of the accepted forms.
|
|
633
|
+
* @returns {string} The bare id (`UC-AddTodo`), trimmed.
|
|
634
|
+
*/
|
|
635
|
+
export function ucId(ref) {
|
|
636
|
+
return String(ref ?? "").trim().replace(/^\[\[|\]\]$/g, "").replace(/^usecases\//, "").replace(/\.md$/, "").trim();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The tasks on a LOCAL board that belong to a scope, joined through the COMMITTED spec.
|
|
641
|
+
*
|
|
642
|
+
* WHY THE JOIN GOES THROUGH THE USE CASE. A scope contract is committed and a board is not, so a
|
|
643
|
+
* contract that named its task ids directly held a pointer into a gitignored, per-machine tier:
|
|
644
|
+
* on a fresh clone the ids resolved to nothing and every reader silently read "no tasks" rather
|
|
645
|
+
* than "the board is missing". Measured on a committed contract naming `TASK-004` with no board on
|
|
646
|
+
* disk: `compile` exited 0 and wrote a build order carrying no work at all. `use_cases[]` is the
|
|
647
|
+
* same LOCAL→SHARED direction the board's own `use_case_refs` already flows in, so the join is
|
|
648
|
+
* re-derived from two artifacts that are each valid on their own.
|
|
649
|
+
*
|
|
650
|
+
* @param {Array<{use_case_refs?:string[]}>} tasks - The parsed board.
|
|
651
|
+
* @param {{use_cases?:string[]}} contract - The scope contract.
|
|
652
|
+
* @returns {Array<object>} The board tasks anchored to ≥1 of this scope's use cases, in board
|
|
653
|
+
* order; [] when the scope names no use cases (the caller owns that fallback, because "this
|
|
654
|
+
* scope has no anchor" and "this scope has no tasks" are different questions).
|
|
655
|
+
*/
|
|
656
|
+
export function tasksForScope(tasks, contract) {
|
|
657
|
+
const id = contract?.scope_id;
|
|
658
|
+
// AN EXPLICIT ASSIGNMENT WINS, and it is the only thing that makes this a PARTITION.
|
|
659
|
+
//
|
|
660
|
+
// The UC join alone is not one. A use case is routinely implemented by several scopes — that is
|
|
661
|
+
// what a vertical slice IS — so on a cut like the corpus's four scopes over a single UC, every
|
|
662
|
+
// scope matched every task. The previous `tasks[]` field partitioned correctly and paid for it by
|
|
663
|
+
// putting a machine-local id in a committed file; this restores the partition in the sanctioned
|
|
664
|
+
// direction instead, with the LOCAL task naming the committed scope. Absent, the UC join stands —
|
|
665
|
+
// which is right whenever the cut gives each scope its own use cases, and spec-lint's
|
|
666
|
+
// SCOPE-PARTITION reds the case where it does not.
|
|
667
|
+
const claimed = (tasks || []).filter((t) => t.scope_id);
|
|
668
|
+
if (id && claimed.length) {
|
|
669
|
+
const mine = claimed.filter((t) => String(t.scope_id).trim() === id);
|
|
670
|
+
// A board only partly stamped still resolves: assigned tasks go to their scope, and anything
|
|
671
|
+
// unassigned falls back to the UC join rather than vanishing mid-migration.
|
|
672
|
+
const rest = (tasks || []).filter((t) => !t.scope_id);
|
|
673
|
+
return [...mine, ...byUseCase(rest, contract)];
|
|
674
|
+
}
|
|
675
|
+
return byUseCase(tasks, contract);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** The UC-overlap half of {@link tasksForScope}. @param {Array<object>} tasks Board. @param {object} contract Scope. @returns {Array<object>} Matches. */
|
|
679
|
+
function byUseCase(tasks, contract) {
|
|
680
|
+
const mine = new Set((contract?.use_cases || []).map(ucId).filter(Boolean));
|
|
681
|
+
if (!mine.size) return [];
|
|
682
|
+
return (tasks || []).filter((t) => (t.use_case_refs || []).some((r) => mine.has(ucId(r))));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Tasks that more than one scope would build — the ambiguity {@link tasksForScope} cannot resolve.
|
|
687
|
+
*
|
|
688
|
+
* Dispatch has to be a partition: a task built by two scopes is built twice, and the second builder
|
|
689
|
+
* is denied by the sandbox for writing outside its own substrate, so the cost lands as burnt
|
|
690
|
+
* attempts and escalations rather than as corruption. Measured on the corpus's four-scope,
|
|
691
|
+
* one-use-case cut: every task claimed by every scope.
|
|
692
|
+
*
|
|
693
|
+
* @param {Array<{id:string, scope_id?:string, use_case_refs?:string[]}>} tasks - The parsed board.
|
|
694
|
+
* @param {Array<{scope_id:string, use_cases?:string[]}>} contracts - Every scope in the run.
|
|
695
|
+
* @returns {Array<{task_id:string, scopes:string[]}>} One entry per contested task; [] when the
|
|
696
|
+
* assignment is already a partition.
|
|
697
|
+
*/
|
|
698
|
+
export function scopePartitionConflicts(tasks, contracts) {
|
|
699
|
+
const out = [];
|
|
700
|
+
for (const t of tasks || []) {
|
|
701
|
+
const owners = (contracts || []).filter((c) => tasksForScope([t], c).length).map((c) => c.scope_id);
|
|
702
|
+
if (owners.length > 1) out.push({ task_id: t.id, scopes: owners });
|
|
703
|
+
}
|
|
704
|
+
return out;
|
|
705
|
+
}
|
|
706
|
+
|
|
463
707
|
/**
|
|
464
708
|
* Extract the prose a regeneration must not destroy — everything in the body except the sections
|
|
465
709
|
* this spec owns.
|