@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.26-beta",
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. Return { verified: true } only if all
282
- references are valid. Return { verified: false, staleRefs: [...] } otherwise.
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"