@smartmemory/compose 0.1.26-beta → 0.1.28-beta
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.
|
@@ -182,6 +182,7 @@ Present all proposals, trade-offs, your recommendation. User picks. Write to `do
|
|
|
182
182
|
- Read every critical file, note patterns with line references
|
|
183
183
|
- Build corrections table (spec assumption vs reality)
|
|
184
184
|
- Write to `docs/features/<feature-code>/blueprint.md`
|
|
185
|
+
- **Boundary Map (when feature has 2+ work units):** append a `## Boundary Map` section per `.claude/skills/compose/templates/boundary-map.md`. Each entry must name a concrete code symbol with a kind in `{interface, type, function, class, const, hook, component}`. Endpoints, event payloads, file formats, and invariants belong in prose, not in Boundary Map entries.
|
|
185
186
|
|
|
186
187
|
**Gate:** Corrections table empty or all corrections documented.
|
|
187
188
|
|
|
@@ -194,11 +195,13 @@ For every file:line reference in the blueprint:
|
|
|
194
195
|
4. Confirm imports/exports
|
|
195
196
|
5. Flag stale references
|
|
196
197
|
|
|
198
|
+
If the blueprint contains a `## Boundary Map` section, run `validateBoundaryMap` from `lib/boundary-map.js` against the blueprint. Append every violation as a row in the Verification Table; warnings render as informational rows but do not block the gate. The four checks are: File-Plan-or-disk, symbol presence (untouched dependencies only), topology (every `from S##` references an earlier slice), producer/consumer match.
|
|
199
|
+
|
|
197
200
|
Produce a verification table, append to `blueprint.md`. If any stale/wrong, loop back to Phase 4.
|
|
198
201
|
|
|
199
|
-
**Gate:** All file:line references verified. Zero stale entries.
|
|
202
|
+
**Gate:** All file:line references verified. Zero stale entries. Zero Boundary Map violations (warnings allowed).
|
|
200
203
|
|
|
201
|
-
**Skip when:** Blueprint written in the same session immediately after reading all referenced files.
|
|
204
|
+
**Skip when:** Blueprint written in the same session immediately after reading all referenced files **and** the blueprint has no Boundary Map.
|
|
202
205
|
|
|
203
206
|
## Fix Mode Phases (F1–F3)
|
|
204
207
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Boundary Map — Authoring Template
|
|
2
|
+
|
|
3
|
+
A **Boundary Map** is an opt-in `## Boundary Map` section inside `blueprint.md` that declares, at file→symbol granularity, what each slice (sub-feature, parallel task, or work unit) **produces** and **consumes**. The Phase 5 verifier runs `validateBoundaryMap` from `lib/boundary-map.js` against any Boundary Map present.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to author
|
|
8
|
+
|
|
9
|
+
If your feature has **2+ work units** (slices, sub-features, or parallel tasks under STRAT-PAR), append a `## Boundary Map` section **after `## File Plan`** and **before `## Verification Table`**.
|
|
10
|
+
|
|
11
|
+
Single-unit features have no boundaries to map — skip it.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Format spec
|
|
16
|
+
|
|
17
|
+
```markdown
|
|
18
|
+
## Boundary Map
|
|
19
|
+
|
|
20
|
+
### S01: auth primitives
|
|
21
|
+
Produces:
|
|
22
|
+
src/lib/auth/types.ts → User, Session, AuthToken (interface)
|
|
23
|
+
src/lib/auth/tokens.ts → generateToken, verifyToken, refreshToken (function)
|
|
24
|
+
|
|
25
|
+
Consumes: nothing (leaf node)
|
|
26
|
+
|
|
27
|
+
### S02: HTTP layer
|
|
28
|
+
Produces:
|
|
29
|
+
src/server/api/auth/login.ts → loginHandler (function)
|
|
30
|
+
src/server/middleware/auth.ts → authMiddleware (function)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Consumes:
|
|
35
|
+
from S01: src/lib/auth/tokens.ts → generateToken, verifyToken
|
|
36
|
+
|
|
37
|
+
### S03: client integration
|
|
38
|
+
Produces:
|
|
39
|
+
src/client/auth/useAuth.ts → useAuth (hook)
|
|
40
|
+
|
|
41
|
+
Consumes:
|
|
42
|
+
from S01: src/lib/auth/types.ts → User, Session
|
|
43
|
+
from S02: src/server/api/auth/login.ts → loginHandler
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Grammar
|
|
49
|
+
|
|
50
|
+
- **Slice headings:** `### S## [: <human name>]`. The `S##` id is mandatory and must be unique within the blueprint. Names are human guidance, not parsed.
|
|
51
|
+
- **Produces entries:** `<file-path> → <symbol>[, <symbol>...] (<kind>)`. The `(<kind>)` parenthetical is **mandatory** on every Produces line.
|
|
52
|
+
- **Consumes entries:** `from S##: <file-path> → <symbol>[, <symbol>...]`. The trailing `(<kind>)` is optional and ignored — the kind already lives on the matching upstream Produces entry.
|
|
53
|
+
- **Leaf slices** (no upstream dependencies) use the literal form `Consumes: nothing`. An optional trailing parenthetical comment such as `(leaf node)` is ignored.
|
|
54
|
+
- **Sink slices** (no downstream-visible exports — integration-only slices) use the literal form `Produces: nothing`. An optional trailing parenthetical comment such as `(integration only)` is ignored.
|
|
55
|
+
- **Arrows:** both `→` (U+2192, preferred) and ASCII `->` are accepted.
|
|
56
|
+
- **File paths** are repo-relative.
|
|
57
|
+
- **Symbols** are identifiers expected to appear (or eventually appear) in the named file.
|
|
58
|
+
|
|
59
|
+
### Kind allow-list
|
|
60
|
+
|
|
61
|
+
`<kind>` must be one of:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
interface, type, function, class, const, hook, component
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Symbol-only restriction
|
|
68
|
+
|
|
69
|
+
Boundary Map entries name **code symbols only**. Endpoints, event payloads, file formats, and invariants belong in the surrounding blueprint prose — they are not grep-checkable identifiers and are out of scope for the v1 validator.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## What the validator checks
|
|
74
|
+
|
|
75
|
+
Phase 5 runs `validateBoundaryMap({ blueprintText, blueprintPath, repoRoot })` and applies four checks in order:
|
|
76
|
+
|
|
77
|
+
1. **File-Plan-or-disk.** Every referenced file must either appear in the blueprint's File Plan with an allow-listed write action (`new`, `create`, `add`, `edit`, `modify`, `update`, `refactor`, `replace`) **or** exist on disk.
|
|
78
|
+
2. **Symbol presence.** For files **not** marked as planned writes that exist on disk, each declared symbol must appear (substring match) somewhere in the file. Files in the File Plan as planned writes are skipped — the symbol may legitimately not exist yet.
|
|
79
|
+
3. **Topology.** Every `from S##` reference must point to a slice that appears earlier in the map (backward edges only; the consumes-graph is acyclic by construction).
|
|
80
|
+
4. **Producer/consumer match.** Every consumed `(file, symbol)` must be declared as a Produces entry on the named upstream slice with a matching file and a superset of symbols.
|
|
81
|
+
|
|
82
|
+
Violations block the Phase 5 gate; warnings (`no_file_plan`, `unknown_action`) render as informational rows but do not block.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Worked example: 3-slice auth feature
|
|
87
|
+
|
|
88
|
+
The example above (S01: auth primitives → S02: HTTP layer → S03: client integration) demonstrates:
|
|
89
|
+
|
|
90
|
+
- A **leaf slice** (S01) using `Consumes: nothing (leaf node)`.
|
|
91
|
+
- **Multiple kinds** in a single slice (S01 has both `interface` and `function` Produces).
|
|
92
|
+
- **Multi-symbol** Produces and Consumes entries (S01 produces three tokens; S02 consumes two of them).
|
|
93
|
+
- **Cross-slice fan-in** (S03 consumes from both S01 and S02).
|
|
94
|
+
- **Backward-only edges** — every `from S##` references an earlier slice.
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// lib/boundary-map.js
|
|
2
|
+
//
|
|
3
|
+
// parseBoundaryMap(blueprintText) -> { slices: Slice[], parseViolations: Violation[] }
|
|
4
|
+
// validateBoundaryMap({ blueprintText, blueprintPath, repoRoot }) ->
|
|
5
|
+
// { ok: bool, violations: Violation[], warnings: Warning[] }
|
|
6
|
+
//
|
|
7
|
+
// See docs/features/COMP-GSD-1/{design.md, blueprint.md}.
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const KIND_ALLOWLIST = new Set([
|
|
13
|
+
'interface',
|
|
14
|
+
'type',
|
|
15
|
+
'function',
|
|
16
|
+
'class',
|
|
17
|
+
'const',
|
|
18
|
+
'hook',
|
|
19
|
+
'component',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const WRITE_ACTIONS = new Set([
|
|
23
|
+
'new',
|
|
24
|
+
'create',
|
|
25
|
+
'add',
|
|
26
|
+
'edit',
|
|
27
|
+
'modify',
|
|
28
|
+
'update',
|
|
29
|
+
'refactor',
|
|
30
|
+
'replace',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const FILE_PLAN_ALIASES = ['## File Plan', '## Files', '## File-by-File Plan'];
|
|
34
|
+
|
|
35
|
+
const PRODUCES_RE =
|
|
36
|
+
/^\s+(\S+)\s*(?:→|->)\s*([^()]+?)\s*\((interface|type|function|class|const|hook|component)\)\s*$/;
|
|
37
|
+
const PRODUCES_NO_KIND_RE = /^\s+(\S+)\s*(?:→|->)\s*([^()\s][^()]*?)\s*$/;
|
|
38
|
+
const CONSUMES_RE =
|
|
39
|
+
/^\s+from\s+(S\d{2,})\s*:\s*(\S+)\s*(?:→|->)\s*([^()]+?)(?:\s*\([^)]*\))?\s*$/;
|
|
40
|
+
|
|
41
|
+
export function parseBoundaryMap(blueprintText) {
|
|
42
|
+
const lines = blueprintText.split(/\r?\n/);
|
|
43
|
+
// Locate ## Boundary Map
|
|
44
|
+
let start = -1;
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
if (/^## Boundary Map\s*$/.test(lines[i])) {
|
|
47
|
+
start = i + 1;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (start === -1) return { slices: [], parseViolations: [] };
|
|
52
|
+
|
|
53
|
+
// End at next ## heading or EOF
|
|
54
|
+
let end = lines.length;
|
|
55
|
+
for (let i = start; i < lines.length; i++) {
|
|
56
|
+
if (/^## /.test(lines[i])) {
|
|
57
|
+
end = i;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const slices = [];
|
|
63
|
+
const parseViolations = [];
|
|
64
|
+
const seenIds = new Map(); // id -> first slice index
|
|
65
|
+
|
|
66
|
+
let cur = null;
|
|
67
|
+
let block = null; // 'produces' | 'consumes' | null
|
|
68
|
+
let nothingSentinel = null; // 'produces' | 'consumes' | null — set after a `nothing` literal until cleared
|
|
69
|
+
|
|
70
|
+
function pushViolation(v) {
|
|
71
|
+
parseViolations.push(v);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (let i = start; i < end; i++) {
|
|
75
|
+
const line = lines[i];
|
|
76
|
+
const lineNo = i + 1;
|
|
77
|
+
|
|
78
|
+
// Slice heading
|
|
79
|
+
const sliceM = line.match(/^### (S\d{2,})(?::\s*(.*))?\s*$/);
|
|
80
|
+
if (sliceM) {
|
|
81
|
+
const id = sliceM[1];
|
|
82
|
+
const name = sliceM[2] || undefined;
|
|
83
|
+
if (seenIds.has(id)) {
|
|
84
|
+
pushViolation({
|
|
85
|
+
kind: 'duplicate_slice_id',
|
|
86
|
+
scope: 'parse',
|
|
87
|
+
slice: id,
|
|
88
|
+
message: `Duplicate slice id ${id} at line ${lineNo}; first occurrence wins`,
|
|
89
|
+
});
|
|
90
|
+
// Still create a "shadow" slice so we keep parsing for further error reporting,
|
|
91
|
+
// but mark it as duplicate so downstream checks can ignore it.
|
|
92
|
+
cur = { id, name, produces: [], consumes: [], leaf: false, sink: false, line: lineNo, _duplicate: true };
|
|
93
|
+
slices.push(cur);
|
|
94
|
+
} else {
|
|
95
|
+
cur = { id, name, produces: [], consumes: [], leaf: false, sink: false, line: lineNo };
|
|
96
|
+
slices.push(cur);
|
|
97
|
+
seenIds.set(id, slices.length - 1);
|
|
98
|
+
}
|
|
99
|
+
block = null;
|
|
100
|
+
nothingSentinel = null;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Block headers
|
|
105
|
+
const prodHdr = line.match(/^Produces:\s*(nothing\b.*)?\s*$/);
|
|
106
|
+
if (prodHdr && cur) {
|
|
107
|
+
block = 'produces';
|
|
108
|
+
nothingSentinel = null;
|
|
109
|
+
if (prodHdr[1]) {
|
|
110
|
+
cur.sink = true;
|
|
111
|
+
block = null; // no entries expected
|
|
112
|
+
nothingSentinel = 'produces';
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const consHdr = line.match(/^Consumes:\s*(nothing\b.*)?\s*$/);
|
|
117
|
+
if (consHdr && cur) {
|
|
118
|
+
block = 'consumes';
|
|
119
|
+
nothingSentinel = null;
|
|
120
|
+
if (consHdr[1]) {
|
|
121
|
+
cur.leaf = true;
|
|
122
|
+
block = null;
|
|
123
|
+
nothingSentinel = 'consumes';
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Blank line clears nothing; entries are indented under the block
|
|
129
|
+
if (/^\s*$/.test(line)) continue;
|
|
130
|
+
|
|
131
|
+
// Entries
|
|
132
|
+
if (block === 'produces' && cur) {
|
|
133
|
+
const m = line.match(PRODUCES_RE);
|
|
134
|
+
if (m) {
|
|
135
|
+
const [, file, symbolStr, kind] = m;
|
|
136
|
+
const symbols = symbolStr.split(',').map((s) => s.trim()).filter(Boolean);
|
|
137
|
+
if (symbols.length === 0) {
|
|
138
|
+
pushViolation({
|
|
139
|
+
kind: 'malformed_entry',
|
|
140
|
+
scope: 'parse',
|
|
141
|
+
slice: cur.id,
|
|
142
|
+
message: `Empty symbol list at line ${lineNo}`,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
cur.produces.push({ file, symbols, kind, line: lineNo });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// Try no-kind regex
|
|
150
|
+
if (PRODUCES_NO_KIND_RE.test(line)) {
|
|
151
|
+
pushViolation({
|
|
152
|
+
kind: 'missing_kind',
|
|
153
|
+
scope: 'parse',
|
|
154
|
+
slice: cur.id,
|
|
155
|
+
message: `Produces entry at line ${lineNo} missing required (<kind>) parenthetical`,
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
pushViolation({
|
|
160
|
+
kind: 'malformed_produces',
|
|
161
|
+
scope: 'parse',
|
|
162
|
+
slice: cur.id,
|
|
163
|
+
message: `Malformed Produces entry at line ${lineNo}: ${line}`,
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (block === 'consumes' && cur) {
|
|
169
|
+
const m = line.match(CONSUMES_RE);
|
|
170
|
+
if (m) {
|
|
171
|
+
const [, from, file, symbolStr] = m;
|
|
172
|
+
const symbols = symbolStr.split(',').map((s) => s.trim()).filter(Boolean);
|
|
173
|
+
if (symbols.length === 0) {
|
|
174
|
+
pushViolation({
|
|
175
|
+
kind: 'malformed_entry',
|
|
176
|
+
scope: 'parse',
|
|
177
|
+
slice: cur.id,
|
|
178
|
+
message: `Empty consume symbol list at line ${lineNo}`,
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
cur.consumes.push({ from, file, symbols, line: lineNo });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
pushViolation({
|
|
186
|
+
kind: 'malformed_consumes',
|
|
187
|
+
scope: 'parse',
|
|
188
|
+
slice: cur.id,
|
|
189
|
+
message: `Malformed Consumes entry at line ${lineNo}: ${line}`,
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Non-blank line outside any block.
|
|
195
|
+
// After a `nothing` sentinel, an indented entry-shaped line is a parse error
|
|
196
|
+
// (the only valid zero-entry forms are the sentinels themselves).
|
|
197
|
+
if (nothingSentinel && cur && /^\s+\S/.test(line)) {
|
|
198
|
+
const looksLikeEntry =
|
|
199
|
+
PRODUCES_RE.test(line) ||
|
|
200
|
+
PRODUCES_NO_KIND_RE.test(line) ||
|
|
201
|
+
CONSUMES_RE.test(line) ||
|
|
202
|
+
/^\s+from\s+S\d{2,}\s*:/.test(line) ||
|
|
203
|
+
/(?:→|->)/.test(line);
|
|
204
|
+
if (looksLikeEntry) {
|
|
205
|
+
pushViolation({
|
|
206
|
+
kind: 'malformed_after_nothing',
|
|
207
|
+
scope: 'parse',
|
|
208
|
+
slice: cur.id,
|
|
209
|
+
message: `Slice ${cur.id} has an entry-shaped line at line ${lineNo} after a "${nothingSentinel}: nothing" sentinel; "nothing" forbids further entries`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Otherwise ignore (could be prose between slices).
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { slices, parseViolations };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------- File Plan parsing ----------
|
|
220
|
+
|
|
221
|
+
function parseFilePlan(blueprintText) {
|
|
222
|
+
const lines = blueprintText.split(/\r?\n/);
|
|
223
|
+
let aliasIdx = -1;
|
|
224
|
+
let aliasUsed = null;
|
|
225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
226
|
+
for (const alias of FILE_PLAN_ALIASES) {
|
|
227
|
+
if (lines[i].trim() === alias) {
|
|
228
|
+
aliasIdx = i;
|
|
229
|
+
aliasUsed = alias;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (aliasIdx !== -1) break;
|
|
234
|
+
}
|
|
235
|
+
if (aliasIdx === -1) return { found: false, entries: [] };
|
|
236
|
+
|
|
237
|
+
// Walk until next ## heading or EOF
|
|
238
|
+
let end = lines.length;
|
|
239
|
+
for (let i = aliasIdx + 1; i < lines.length; i++) {
|
|
240
|
+
if (/^## /.test(lines[i])) {
|
|
241
|
+
end = i;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const entries = [];
|
|
247
|
+
for (let i = aliasIdx + 1; i < end; i++) {
|
|
248
|
+
const line = lines[i];
|
|
249
|
+
// Markdown table row: starts with | and has at least 2 pipes; skip header & sep
|
|
250
|
+
if (!/^\s*\|/.test(line)) continue;
|
|
251
|
+
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
|
|
252
|
+
if (cells.length < 2) continue;
|
|
253
|
+
// Skip header row "File | Action | ..."
|
|
254
|
+
if (/^file$/i.test(cells[0]) && /^action$/i.test(cells[1])) continue;
|
|
255
|
+
// Skip separator row "---|---|..."
|
|
256
|
+
if (cells.every((c) => /^:?-+:?$/.test(c))) continue;
|
|
257
|
+
const fileRaw = cells[0];
|
|
258
|
+
const actionRaw = cells[1];
|
|
259
|
+
const file = fileRaw.replace(/`/g, '').trim();
|
|
260
|
+
if (!file) continue;
|
|
261
|
+
entries.push({ file, action: actionRaw, line: i + 1 });
|
|
262
|
+
}
|
|
263
|
+
return { found: true, alias: aliasUsed, entries };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeAction(actionRaw) {
|
|
267
|
+
const trimmed = actionRaw.trim();
|
|
268
|
+
if (!trimmed) return '';
|
|
269
|
+
const firstTok = trimmed.split(/\s+/)[0];
|
|
270
|
+
return firstTok.toLowerCase().replace(/[.,;:]+$/, '');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------- Validator ----------
|
|
274
|
+
|
|
275
|
+
export function validateBoundaryMap({ blueprintText, blueprintPath, repoRoot }) {
|
|
276
|
+
const violations = [];
|
|
277
|
+
const warnings = [];
|
|
278
|
+
|
|
279
|
+
const { slices, parseViolations } = parseBoundaryMap(blueprintText);
|
|
280
|
+
for (const v of parseViolations) violations.push(v);
|
|
281
|
+
|
|
282
|
+
if (slices.length === 0 && parseViolations.length === 0) {
|
|
283
|
+
return { ok: true, violations: [], warnings: [] };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Filter out duplicate-shadow slices for downstream checks
|
|
287
|
+
const liveSlices = slices.filter((s) => !s._duplicate);
|
|
288
|
+
|
|
289
|
+
// Build File Plan index — a file may appear in multiple rows; isPlannedWrite
|
|
290
|
+
// is true if ANY row has an allow-listed write action.
|
|
291
|
+
const filePlan = parseFilePlan(blueprintText);
|
|
292
|
+
const filePlanIndex = new Map(); // file -> { rows: [{action, normalized}], isPlannedWrite: bool }
|
|
293
|
+
const unknownActionWarned = new Set();
|
|
294
|
+
|
|
295
|
+
if (!filePlan.found) {
|
|
296
|
+
warnings.push({
|
|
297
|
+
kind: 'no_file_plan',
|
|
298
|
+
scope: 'blueprint',
|
|
299
|
+
message: 'Blueprint has no recognized File Plan heading',
|
|
300
|
+
});
|
|
301
|
+
} else {
|
|
302
|
+
for (let rowIdx = 0; rowIdx < filePlan.entries.length; rowIdx++) {
|
|
303
|
+
const row = filePlan.entries[rowIdx];
|
|
304
|
+
const norm = normalizeAction(row.action);
|
|
305
|
+
const isWrite = WRITE_ACTIONS.has(norm);
|
|
306
|
+
let entry = filePlanIndex.get(row.file);
|
|
307
|
+
if (!entry) {
|
|
308
|
+
entry = { rows: [], isPlannedWrite: false };
|
|
309
|
+
filePlanIndex.set(row.file, entry);
|
|
310
|
+
}
|
|
311
|
+
entry.rows.push({ action: row.action, normalized: norm });
|
|
312
|
+
if (isWrite) entry.isPlannedWrite = true;
|
|
313
|
+
const dedupKey = `${rowIdx}${row.file}${norm}`;
|
|
314
|
+
if (!isWrite && !unknownActionWarned.has(dedupKey)) {
|
|
315
|
+
unknownActionWarned.add(dedupKey);
|
|
316
|
+
warnings.push({
|
|
317
|
+
kind: 'unknown_action',
|
|
318
|
+
scope: 'file-plan',
|
|
319
|
+
file: row.file,
|
|
320
|
+
message: `File Plan action "${row.action}" for ${row.file} is not in the recognized write-action allow-list`,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isPlannedWrite(file) {
|
|
327
|
+
const e = filePlanIndex.get(file);
|
|
328
|
+
return !!(e && e.isPlannedWrite);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function fileExists(file) {
|
|
332
|
+
if (!repoRoot) return false;
|
|
333
|
+
try {
|
|
334
|
+
return existsSync(join(repoRoot, file));
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 1. File-Plan-or-disk check — emit one violation per (slice, file, symbol)
|
|
341
|
+
for (const slice of liveSlices) {
|
|
342
|
+
const entries = [
|
|
343
|
+
...slice.produces.map((p) => ({ file: p.file, symbols: p.symbols })),
|
|
344
|
+
...slice.consumes.map((c) => ({ file: c.file, symbols: c.symbols })),
|
|
345
|
+
];
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (isPlannedWrite(entry.file)) continue;
|
|
348
|
+
if (fileExists(entry.file)) continue;
|
|
349
|
+
for (const sym of entry.symbols) {
|
|
350
|
+
violations.push({
|
|
351
|
+
kind: 'missing_file',
|
|
352
|
+
scope: 'entry',
|
|
353
|
+
slice: slice.id,
|
|
354
|
+
file: entry.file,
|
|
355
|
+
symbol: sym,
|
|
356
|
+
message: `File ${entry.file} referenced in slice ${slice.id} (symbol ${sym}) is not in File Plan and does not exist on disk`,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 2. Symbol presence check
|
|
363
|
+
for (const slice of liveSlices) {
|
|
364
|
+
const entries = [
|
|
365
|
+
...slice.produces.map((p) => ({ file: p.file, symbols: p.symbols })),
|
|
366
|
+
...slice.consumes.map((c) => ({ file: c.file, symbols: c.symbols })),
|
|
367
|
+
];
|
|
368
|
+
for (const entry of entries) {
|
|
369
|
+
if (isPlannedWrite(entry.file)) continue;
|
|
370
|
+
if (!fileExists(entry.file)) continue; // covered by missing_file or no repoRoot
|
|
371
|
+
let content;
|
|
372
|
+
try {
|
|
373
|
+
content = readFileSync(join(repoRoot, entry.file), 'utf8');
|
|
374
|
+
} catch {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
for (const sym of entry.symbols) {
|
|
378
|
+
if (!content.includes(sym)) {
|
|
379
|
+
violations.push({
|
|
380
|
+
kind: 'missing_symbol',
|
|
381
|
+
scope: 'entry',
|
|
382
|
+
slice: slice.id,
|
|
383
|
+
file: entry.file,
|
|
384
|
+
symbol: sym,
|
|
385
|
+
message: `Symbol ${sym} not found in ${entry.file} (slice ${slice.id})`,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 3. Topology check
|
|
393
|
+
const sliceOrder = new Map();
|
|
394
|
+
liveSlices.forEach((s, i) => sliceOrder.set(s.id, i));
|
|
395
|
+
for (let i = 0; i < liveSlices.length; i++) {
|
|
396
|
+
const slice = liveSlices[i];
|
|
397
|
+
for (const c of slice.consumes) {
|
|
398
|
+
if (!sliceOrder.has(c.from)) {
|
|
399
|
+
for (const sym of c.symbols) {
|
|
400
|
+
violations.push({
|
|
401
|
+
kind: 'dangling_consume',
|
|
402
|
+
scope: 'entry',
|
|
403
|
+
slice: slice.id,
|
|
404
|
+
file: c.file,
|
|
405
|
+
symbol: sym,
|
|
406
|
+
message: `Slice ${slice.id} consumes ${sym} from ${c.from}:${c.file}, but ${c.from} has no heading in this map`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const targetIdx = sliceOrder.get(c.from);
|
|
412
|
+
if (targetIdx >= i) {
|
|
413
|
+
for (const sym of c.symbols) {
|
|
414
|
+
violations.push({
|
|
415
|
+
kind: 'forward_reference',
|
|
416
|
+
scope: 'entry',
|
|
417
|
+
slice: slice.id,
|
|
418
|
+
file: c.file,
|
|
419
|
+
symbol: sym,
|
|
420
|
+
message: `Slice ${slice.id} consumes ${sym} from ${c.from}:${c.file}, which appears at or after ${slice.id} in document order`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 4. Producer/consumer match
|
|
428
|
+
for (const slice of liveSlices) {
|
|
429
|
+
for (const c of slice.consumes) {
|
|
430
|
+
const producerIdx = sliceOrder.get(c.from);
|
|
431
|
+
if (producerIdx === undefined) continue; // dangling already flagged
|
|
432
|
+
const producer = liveSlices[producerIdx];
|
|
433
|
+
// Find matching produces entries for the file
|
|
434
|
+
const producesForFile = producer.produces.filter((p) => p.file === c.file);
|
|
435
|
+
if (producesForFile.length === 0) {
|
|
436
|
+
for (const sym of c.symbols) {
|
|
437
|
+
violations.push({
|
|
438
|
+
kind: 'producer_consumer_mismatch',
|
|
439
|
+
scope: 'entry',
|
|
440
|
+
slice: slice.id,
|
|
441
|
+
file: c.file,
|
|
442
|
+
symbol: sym,
|
|
443
|
+
message: `Slice ${slice.id} consumes ${sym} from ${c.from}:${c.file}, but ${c.from} does not produce that file`,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const producedSymbols = new Set();
|
|
449
|
+
for (const p of producesForFile) for (const s of p.symbols) producedSymbols.add(s);
|
|
450
|
+
for (const sym of c.symbols) {
|
|
451
|
+
if (!producedSymbols.has(sym)) {
|
|
452
|
+
violations.push({
|
|
453
|
+
kind: 'producer_consumer_mismatch',
|
|
454
|
+
scope: 'entry',
|
|
455
|
+
slice: slice.id,
|
|
456
|
+
file: c.file,
|
|
457
|
+
symbol: sym,
|
|
458
|
+
message: `Slice ${slice.id} consumes ${sym} from ${c.from}:${c.file}, but ${c.from} does not produce ${sym} in that file`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return { ok: violations.length === 0, violations, warnings };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------- CLI for manual dogfood ----------
|
|
469
|
+
|
|
470
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
471
|
+
const path = process.argv[2];
|
|
472
|
+
if (!path) {
|
|
473
|
+
console.error('Usage: node lib/boundary-map.js <blueprint-path> [repoRoot]');
|
|
474
|
+
process.exit(2);
|
|
475
|
+
}
|
|
476
|
+
const repoRoot = process.argv[3] || process.cwd();
|
|
477
|
+
const blueprintText = readFileSync(path, 'utf8');
|
|
478
|
+
const r = validateBoundaryMap({ blueprintText, blueprintPath: path, repoRoot });
|
|
479
|
+
console.log(JSON.stringify(r, null, 2));
|
|
480
|
+
process.exit(r.ok ? 0 : 1);
|
|
481
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28-beta",
|
|
4
4
|
"description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -278,8 +278,13 @@ flows:
|
|
|
278
278
|
agent: claude
|
|
279
279
|
intent: >
|
|
280
280
|
Verify every file:line reference in the blueprint against the actual codebase.
|
|
281
|
-
Flag stale or incorrect references.
|
|
282
|
-
|
|
281
|
+
Flag stale or incorrect references. If the blueprint contains a `## Boundary Map`
|
|
282
|
+
section, additionally invoke `validateBoundaryMap` from `lib/boundary-map.js` and
|
|
283
|
+
treat its violations as stale references; treat its warnings as informational.
|
|
284
|
+
Summarize Boundary Map results (counts of violations and warnings, plus any
|
|
285
|
+
must-fix detail) inside the existing `summary` string field of the PhaseResult —
|
|
286
|
+
do not add new top-level fields. The phase outcome is `complete` only when every
|
|
287
|
+
file:line reference is valid AND the Boundary Map (if present) has zero violations.
|
|
283
288
|
inputs:
|
|
284
289
|
featureCode: "$.input.featureCode"
|
|
285
290
|
description: "$.input.description"
|