@smartmemory/compose 0.2.16-beta → 0.2.18-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.
package/bin/compose.js CHANGED
@@ -2328,14 +2328,16 @@ if (cmd === 'build') {
2328
2328
  const featuresRel = ibConfig.paths?.features || 'docs/features'
2329
2329
  const featuresDir = join(ibCwd, featuresRel, resolvedCode)
2330
2330
  if (!existsSync(featuresDir)) {
2331
- mkdirSync(featuresDir, { recursive: true })
2332
- writeFileSync(join(featuresDir, 'feature.json'), JSON.stringify({
2331
+ // COMP-MCP-VALIDATE-1: route through the validated writer (schema-guarded)
2332
+ // instead of a raw writeFileSync.
2333
+ const { writeFeature } = await import('../lib/feature-json.js')
2334
+ writeFeature(ibCwd, {
2333
2335
  code: resolvedCode,
2334
2336
  description: idea.title,
2335
2337
  status: 'PLANNED',
2336
2338
  promotedFrom: ideaId,
2337
2339
  createdAt: new Date().toISOString(),
2338
- }, null, 2))
2340
+ }, featuresRel)
2339
2341
  console.log(`Created feature folder: ${featuresRel}/${resolvedCode}/`)
2340
2342
  }
2341
2343
 
@@ -9,6 +9,7 @@
9
9
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
10
10
  import { join, basename } from 'path';
11
11
  import { readdirSync } from 'fs';
12
+ import { assertValidLinkShape, assertLinkTargetsExist } from './feature-write-guard.js';
12
13
 
13
14
  /**
14
15
  * @typedef {object} FeatureJson
@@ -48,8 +49,23 @@ export function readFeature(cwd, code, featuresDir = 'docs/features') {
48
49
  * @param {string} cwd - Project root
49
50
  * @param {FeatureJson} feature
50
51
  * @param {string} [featuresDir]
52
+ * @param {{validate?: boolean, allowForwardRefs?: boolean}} [opts] - Write-time
53
+ * validation (COMP-MCP-VALIDATE-1). Validates by default; `validate: false`
54
+ * skips all guarding (migration/back-fill tooling only); `allowForwardRefs`
55
+ * permits a known-good forward-reference link target.
51
56
  */
52
- export function writeFeature(cwd, feature, featuresDir = 'docs/features') {
57
+ export function writeFeature(cwd, feature, featuresDir = 'docs/features', opts = {}) {
58
+ if (opts.validate !== false) {
59
+ assertValidLinkShape(feature);
60
+ // Pass the on-disk version's links so existence is checked only for links
61
+ // this write INTRODUCES (delta-aware) — a forced forward-ref persisted
62
+ // earlier must not make later writes throw. Read prior only when there are
63
+ // same-project targets to check (keeps the common write zero-I/O).
64
+ const hasTargets = Array.isArray(feature.links)
65
+ && feature.links.some((l) => l && l.kind !== 'external' && typeof l.to_code === 'string');
66
+ const priorLinks = hasTargets ? (readFeature(cwd, feature.code, featuresDir)?.links) : undefined;
67
+ assertLinkTargetsExist(cwd, feature, { allowForwardRefs: opts.allowForwardRefs, priorLinks });
68
+ }
53
69
  const dir = join(cwd, featuresDir, feature.code);
54
70
  mkdirSync(dir, { recursive: true });
55
71
  const path = join(dir, 'feature.json');
@@ -300,6 +300,15 @@ function finding(severity, kind, code, detail, source) {
300
300
  return f;
301
301
  }
302
302
 
303
+ // A code owned by another repo (matches a declared externalPrefix, e.g. STRAT-*)
304
+ // is a cross-repo REFERENCE. Compose can validate that the reference resolves, but
305
+ // not the external feature's local artifacts, completion records, folder layout,
306
+ // row-schema, or authoritative status — all of which live in the owning repo. The
307
+ // per-feature local-correspondence checks consult this and skip such codes.
308
+ function isExternalCode(code, ctx) {
309
+ return !!code && (ctx?.externalPrefixes || []).some((p) => code.startsWith(p));
310
+ }
311
+
303
312
  // ---------------------------------------------------------------------------
304
313
  // Per-feature checks
305
314
  // ---------------------------------------------------------------------------
@@ -373,7 +382,7 @@ function runSchemaChecks(fctx, ctx, findings) {
373
382
  }
374
383
  }
375
384
  }
376
- if (roadmap) {
385
+ if (roadmap && !isExternalCode(code, ctx)) {
377
386
  const v = getValidator(ROADMAP_ROW_SCHEMA);
378
387
  const r = v.validateRoot(roadmap);
379
388
  if (!r.valid) {
@@ -401,8 +410,11 @@ function projectToVisionStatus(s) {
401
410
  return s === 'PARTIAL' ? 'IN_PROGRESS' : s;
402
411
  }
403
412
 
404
- function runStateMismatchChecks(fctx, findings) {
413
+ function runStateMismatchChecks(fctx, ctx, findings) {
405
414
  const { code, roadmap, vision, featureJson } = fctx;
415
+ // Compose is not the authority on an external feature's status — its ROADMAP/
416
+ // vision rows are cross-repo mirrors that may legitimately lag the owning repo.
417
+ if (isExternalCode(code, ctx)) return;
406
418
  const rStatus = normalizeStatus(roadmap?.status);
407
419
  const fStatus = normalizeStatus(featureJson?.status);
408
420
  const vStatus = normalizeStatus(vision?.status);
@@ -471,21 +483,18 @@ function runFolderRoadmapLinkageChecks(fctx, ctx, findings) {
471
483
  const { code, folder, roadmap, vision, featureJson } = fctx;
472
484
  const rStatus = normalizeStatus(roadmap?.status);
473
485
 
486
+ // Externally-owned codes (e.g. STRAT-*) are cross-repo references whose folder,
487
+ // artifacts, and row layout live in the owning project — folder-linkage does not
488
+ // apply, so skip the whole check (row↔folder, folder↔row, empty-folder).
489
+ if (isExternalCode(code, ctx)) return;
490
+
474
491
  if (roadmap && !folder) {
475
- // Externally-owned rows (code matches an externalPrefix, e.g. STRAT-*) are
476
- // cross-repo references whose folder lives in the owning project — folder
477
- // linkage does not apply, so emit nothing. (Asymmetric with the external
478
- // *folder* case below, which downgrades to info: an external folder present
479
- // locally is unusual; an external row without a local folder is the norm.)
480
- const isExternal = ctx.externalPrefixes.some((p) => code.startsWith(p));
481
- if (isExternal) {
482
- // no finding — out of scope for folder-linkage validation
483
- } else if (rStatus === 'IN_PROGRESS') {
484
- // Severity model for missing folder:
485
- // IN_PROGRESS → error (active work without a tracking artifact)
486
- // PARTIAL / BLOCKED → warning (sub-tickets may live elsewhere; partial progress)
487
- // PLANNED → warning (un-started work)
488
- // COMPLETE / SUPERSEDED / KILLED / PARKED / unknown → warning (historical baseline)
492
+ // Severity model for missing folder:
493
+ // IN_PROGRESS → error (active work without a tracking artifact)
494
+ // PARTIAL / BLOCKED → warning (sub-tickets may live elsewhere; partial progress)
495
+ // PLANNED → warning (un-started work)
496
+ // COMPLETE / SUPERSEDED / KILLED / PARKED / unknown warning (historical baseline)
497
+ if (rStatus === 'IN_PROGRESS') {
489
498
  findings.push(finding('error', 'ROADMAP_ROW_WITHOUT_FOLDER', code,
490
499
  `ROADMAP row status is IN_PROGRESS (active work) but no folder exists`));
491
500
  } else {
@@ -519,6 +528,8 @@ function runFolderRoadmapLinkageChecks(fctx, ctx, findings) {
519
528
  function runArtifactLinkChecks(fctx, ctx, findings) {
520
529
  const { code, folder, featureJson } = fctx;
521
530
  if (!folder) return;
531
+ // External features keep their design/report/artifacts in the owning repo.
532
+ if (isExternalCode(code, ctx)) return;
522
533
  const featureRootArg = ctx.paths.features;
523
534
  const am = new ArtifactManager(featureRootArg);
524
535
  let assessment = null;
@@ -607,6 +618,9 @@ function runCrossFeatureRefChecks(fctx, ctx, findings) {
607
618
 
608
619
  function runCoherenceChecks(fctx, ctx, findings) {
609
620
  const { code, featureJson } = fctx;
621
+ // External features record their changelog/journal in the owning repo, so
622
+ // compose can't (and shouldn't) assert those entries exist here.
623
+ if (isExternalCode(code, ctx)) return;
610
624
  // COMPLETION_WITHOUT_CHANGELOG — check at project level, but per-feature too
611
625
  const status = effectiveStatus(fctx, ctx);
612
626
  if (status === 'COMPLETE' || status === 'PARTIAL') {
@@ -663,6 +677,9 @@ function runChangelogReferenceCheck(ctx, findings) {
663
677
  let m;
664
678
  while ((m = headerRe.exec(text))) {
665
679
  const code = m[1];
680
+ // External codes (e.g. STRAT-*) are legitimately referenced in compose's
681
+ // CHANGELOG without a local folder/row — their home is the owning repo.
682
+ if (isExternalCode(code, ctx)) continue;
666
683
  if (!ctx.roadmapByCode.has(code) && !ctx.foldersByCode.has(code) && !ctx.visionByCode.has(code)) {
667
684
  // Downgraded from error: many shipped features have CHANGELOG entries without
668
685
  // ROADMAP rows or vision-state items (legacy pattern where CHANGELOG is the
@@ -729,7 +746,7 @@ export async function validateFeature(cwd, code, options = {}) {
729
746
  }
730
747
 
731
748
  runSchemaChecks(fctx, ctx, findings);
732
- runStateMismatchChecks(fctx, findings);
749
+ runStateMismatchChecks(fctx, ctx, findings);
733
750
  runFolderRoadmapLinkageChecks(fctx, ctx, findings);
734
751
  runArtifactLinkChecks(fctx, ctx, findings);
735
752
  runCrossFeatureRefChecks(fctx, ctx, findings);
@@ -0,0 +1,201 @@
1
+ /**
2
+ * feature-write-guard.js — Write-time feature.json validation (COMP-MCP-VALIDATE-1).
3
+ *
4
+ * The feature.json schema (contracts/feature-json.schema.json) and the
5
+ * cross-reference existence rule were historically enforced ONLY on read, by
6
+ * lib/feature-validator.js. This module enforces the SAME rules at write time so
7
+ * malformed shape / invalid link kind / dangling to_code is rejected before
8
+ * commit — closing the source of FEATURE_JSON_SCHEMA_VIOLATION and
9
+ * DANGLING_LINK_FEATURES_TARGET.
10
+ *
11
+ * Layering: imports only the Ajv SchemaValidator (server/) and the feature-code
12
+ * regex. It does NOT import feature-json.js, feature-validator.js, or
13
+ * feature-writer.js — those import this module, so this stays a leaf to keep the
14
+ * graph acyclic.
15
+ */
16
+
17
+ import { readFileSync, readdirSync, existsSync } from 'fs';
18
+ import { join, resolve, dirname } from 'path';
19
+ import { fileURLToPath } from 'url';
20
+ import { SchemaValidator } from '../server/schema-validator.js';
21
+ import { FEATURE_CODE_RE_STRICT } from './feature-code.js';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const SCHEMA_PATH = resolve(__dirname, '../contracts/feature-json.schema.json');
25
+
26
+ // Memoize the compiled validator (the schema is static). This is the only thing
27
+ // safe to cache — code-existence sources change at runtime and are read fresh.
28
+ let _validator = null;
29
+ function validator() {
30
+ if (!_validator) _validator = new SchemaValidator(SCHEMA_PATH);
31
+ return _validator;
32
+ }
33
+
34
+ /**
35
+ * Thrown when a feature.json write would persist invalid data.
36
+ * `kind` mirrors the read validator's finding kinds so callers can branch.
37
+ */
38
+ export class FeatureWriteValidationError extends Error {
39
+ /**
40
+ * @param {'FEATURE_JSON_SCHEMA_VIOLATION'|'DANGLING_LINK_FEATURES_TARGET'} kind
41
+ * @param {string[]} violations
42
+ */
43
+ constructor(kind, violations) {
44
+ super(`${kind}: ${violations.join('; ')}`);
45
+ this.name = 'FeatureWriteValidationError';
46
+ this.kind = kind;
47
+ this.violations = violations;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Validate a feature's `links[]` against the canonical JSON schema. Throws
53
+ * FeatureWriteValidationError('FEATURE_JSON_SCHEMA_VIOLATION') for any link-shape
54
+ * violation (bad `kind` enum, missing `to_code` on a non-external link, malformed
55
+ * external-link provider fields, …).
56
+ *
57
+ * SCOPE (COMP-MCP-VALIDATE-1): only `/links/*` violations are enforced at write
58
+ * time. Whole-object schema tightening — `complexity` enum convergence, the
59
+ * `artifacts[].type` enum, `additionalProperties` — is **deliberately deferred**
60
+ * to COMP-MCP-VALIDATE-SCHEMA-TIGHTEN (see contracts/feature-json.schema.json
61
+ * field comments), and the writers legitimately produce values that pass only
62
+ * the permissive read schema today. -1 closes the link-kind / link-shape source
63
+ * named in its charter, nothing wider.
64
+ *
65
+ * @param {object} feature
66
+ */
67
+ export function assertValidLinkShape(feature) {
68
+ const { valid, errors } = validator().validateRoot(feature);
69
+ if (valid) return;
70
+ const linkErrors = (errors || []).filter((e) => (e.instancePath || '').startsWith('/links'));
71
+ if (linkErrors.length === 0) return;
72
+ throw new FeatureWriteValidationError(
73
+ 'FEATURE_JSON_SCHEMA_VIOLATION',
74
+ linkErrors.map((e) => `${e.instancePath}: ${e.message}`),
75
+ );
76
+ }
77
+
78
+ function resolvePaths(cwd) {
79
+ let featuresRel = 'docs/features';
80
+ try {
81
+ const cfg = JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8'));
82
+ if (cfg?.paths?.features) featuresRel = cfg.paths.features;
83
+ } catch { /* no config — use defaults (mirrors resolveProjectPaths) */ }
84
+ return {
85
+ features: join(cwd, featuresRel),
86
+ roadmap: join(cwd, 'ROADMAP.md'),
87
+ visionState: join(cwd, '.compose', 'data', 'vision-state.json'),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Strict feature codes present in a ROADMAP.md table. Self-contained, lean
93
+ * mirror of the validator's column-aware scan (lib/feature-validator.js:144-202)
94
+ * — NOT extracted from it, because that loop is dual-purpose (it also builds
95
+ * citationRows for XREF parsing). We need only the code set.
96
+ *
97
+ * @param {string} roadmapPath
98
+ * @returns {string[]}
99
+ */
100
+ export function scanRoadmapRows(roadmapPath) {
101
+ const codes = [];
102
+ let text;
103
+ try { text = readFileSync(roadmapPath, 'utf8'); } catch { return codes; }
104
+
105
+ let codeIdx = -1, statusIdx = -1;
106
+ let inTable = false, sawSeparator = false;
107
+ for (const rawLine of text.split('\n')) {
108
+ if (/^##\s+/.test(rawLine)) { inTable = false; sawSeparator = false; codeIdx = statusIdx = -1; continue; }
109
+ const rowMatch = rawLine.match(/^\|(.+)\|\s*$/);
110
+ if (!rowMatch) { inTable = false; sawSeparator = false; continue; }
111
+ const cols = rowMatch[1].split('|').map((c) => c.trim());
112
+ const lower = cols.map((c) => c.toLowerCase());
113
+ const featureColIdx = lower.findIndex((c) => ['feature', 'code', 'item', 'name'].includes(c));
114
+ const statusColIdx = lower.findIndex((c) => ['status', 'state'].includes(c));
115
+ if (featureColIdx >= 0 && statusColIdx >= 0) {
116
+ codeIdx = featureColIdx; statusIdx = statusColIdx; inTable = true; sawSeparator = false; continue;
117
+ }
118
+ if (cols.every((c) => /^[-:]+$/.test(c))) { if (inTable) sawSeparator = true; continue; }
119
+ if (!inTable || !sawSeparator || codeIdx < 0 || codeIdx >= cols.length) continue;
120
+ const codeRaw = cols[codeIdx].replace(/\*/g, '').replace(/`/g, '').trim();
121
+ if (FEATURE_CODE_RE_STRICT.test(codeRaw)) codes.push(codeRaw);
122
+ }
123
+ return codes;
124
+ }
125
+
126
+ /**
127
+ * The set of feature codes that "exist" in any authoritative source — feature
128
+ * folders, ROADMAP rows, or vision-state items. Mirrors the union the read
129
+ * validator's dangling-link check consults (foldersByCode ∪ roadmapByCode ∪
130
+ * visionByCode). Read fresh every call (no memo): ROADMAP and vision-state
131
+ * change independently of feature writes in long-lived processes.
132
+ *
133
+ * @param {string} cwd
134
+ * @returns {Set<string>}
135
+ */
136
+ export function knownFeatureCodes(cwd) {
137
+ const paths = resolvePaths(cwd);
138
+ const codes = new Set();
139
+
140
+ // Feature folders
141
+ if (existsSync(paths.features)) {
142
+ for (const dirent of readdirSync(paths.features, { withFileTypes: true })) {
143
+ if (dirent.isDirectory() && FEATURE_CODE_RE_STRICT.test(dirent.name)) codes.add(dirent.name);
144
+ }
145
+ }
146
+
147
+ // ROADMAP rows
148
+ for (const code of scanRoadmapRows(paths.roadmap)) codes.add(code);
149
+
150
+ // Vision-state items
151
+ try {
152
+ const vs = JSON.parse(readFileSync(paths.visionState, 'utf8'));
153
+ for (const item of (Array.isArray(vs.items) ? vs.items : [])) {
154
+ const code = item?.lifecycle?.featureCode || item?.featureCode;
155
+ if (code && FEATURE_CODE_RE_STRICT.test(code)) codes.add(code);
156
+ }
157
+ } catch { /* missing vision-state — folders/roadmap still apply */ }
158
+
159
+ return codes;
160
+ }
161
+
162
+ /**
163
+ * Assert every same-project link target (non-external to_code) that this write
164
+ * INTRODUCES exists. No-op (zero I/O) when the feature carries no such links —
165
+ * the common write.
166
+ *
167
+ * Delta-aware: only links not already present in `opts.priorLinks` (the on-disk
168
+ * version) are checked. This is the correct write-time semantic — reject new
169
+ * drift, not pre-existing state — and it makes a legitimately forced
170
+ * forward-reference durable: once persisted, later unrelated writes (status,
171
+ * completions, build lifecycle) re-run this guard but skip the now-existing
172
+ * link, so they don't spuriously throw `DANGLING_LINK_FEATURES_TARGET`.
173
+ *
174
+ * Throws FeatureWriteValidationError('DANGLING_LINK_FEATURES_TARGET') for any
175
+ * newly-introduced missing target unless opts.allowForwardRefs is set (the
176
+ * explicit force path that introduces the forward-reference in the first place).
177
+ *
178
+ * @param {string} cwd
179
+ * @param {object} feature
180
+ * @param {{allowForwardRefs?: boolean, priorLinks?: Array}} [opts]
181
+ */
182
+ export function assertLinkTargetsExist(cwd, feature, opts = {}) {
183
+ if (opts.allowForwardRefs) return;
184
+ const links = Array.isArray(feature?.links) ? feature.links : [];
185
+ const prior = Array.isArray(opts.priorLinks) ? opts.priorLinks : [];
186
+ const isPrior = (l) => prior.some((p) => p.kind === l.kind && p.to_code === l.to_code);
187
+ const targets = links
188
+ .filter((l) => l && l.kind !== 'external' && typeof l.to_code === 'string')
189
+ .filter((l) => !isPrior(l)) // only newly-introduced links
190
+ .map((l) => l.to_code);
191
+ if (targets.length === 0) return; // cheap path — no scan
192
+
193
+ const known = knownFeatureCodes(cwd);
194
+ const missing = targets.filter((code) => code !== feature.code && !known.has(code));
195
+ if (missing.length > 0) {
196
+ throw new FeatureWriteValidationError(
197
+ 'DANGLING_LINK_FEATURES_TARGET',
198
+ missing.map((code) => `${code} does not exist in any source`),
199
+ );
200
+ }
201
+ }
@@ -24,6 +24,7 @@ import { checkOrInsert } from './idempotency.js';
24
24
  import { loadFeaturesDir } from './project-paths.js';
25
25
  import { checkRoundtrip } from './roadmap-roundtrip.js';
26
26
  import { isNarrativeOwned, narrativeOwnedMessage } from './roadmap-config.js';
27
+ import { knownFeatureCodes, FeatureWriteValidationError } from './feature-write-guard.js';
27
28
 
28
29
  // providerFor is imported lazily (inside each function) to break the
29
30
  // module-load-time cycle: factory.js → local-provider.js → feature-writer.js.
@@ -580,17 +581,34 @@ export async function linkFeatures(cwd, args) {
580
581
  l => l.kind === args.kind && l.to_code === args.to_code
581
582
  );
582
583
 
584
+ // Re-issuing an existing link without force is a no-op — it introduces no
585
+ // new state, so it short-circuits BEFORE the dangling guard (otherwise an
586
+ // idempotent retry of a previously-forced forward-ref would wrongly throw).
583
587
  if (matchIdx !== -1 && !args.force) {
584
588
  return { from_code: args.from_code, to_code: args.to_code, kind: args.kind, noop: true };
585
589
  }
586
590
 
591
+ // COMP-MCP-VALIDATE-1: reject a dangling target for genuinely new/updated
592
+ // links. Checked after the source-existence guard (a missing source is the
593
+ // more fundamental error). `force` overrides for intentional forward-refs
594
+ // (link A→B before B is scaffolded).
595
+ const targetMissing = !knownFeatureCodes(cwd).has(args.to_code);
596
+ if (targetMissing && !args.force) {
597
+ throw new FeatureWriteValidationError(
598
+ 'DANGLING_LINK_FEATURES_TARGET',
599
+ [`${args.to_code} does not exist in any source (pass force to override)`],
600
+ );
601
+ }
602
+
587
603
  const entry = { kind: args.kind, to_code: args.to_code };
588
604
  if (args.note) entry.note = args.note;
589
605
 
590
606
  if (matchIdx !== -1) links[matchIdx] = entry;
591
607
  else links.push(entry);
592
608
 
593
- await provider.putFeature(args.from_code, { ...feature, links });
609
+ // allowForwardRefs only matters when the target is missing (force path);
610
+ // when it exists the chokepoint existence re-check passes anyway.
611
+ await provider.putFeature(args.from_code, { ...feature, links }, { allowForwardRefs: targetMissing });
594
612
 
595
613
  await safeAppendEvent(cwd, {
596
614
  tool: 'link_features',
@@ -599,6 +617,7 @@ export async function linkFeatures(cwd, args) {
599
617
  kind: args.kind,
600
618
  note: args.note,
601
619
  forced: matchIdx !== -1 ? true : undefined,
620
+ forced_dangling: (args.force && targetMissing) ? true : undefined,
602
621
  idempotency_key: args.idempotency_key,
603
622
  });
604
623
 
@@ -5,6 +5,7 @@ import { GitHubApi } from './github-api.js';
5
5
  import { OpLog, Cache, ConflictLedger, Reconciler } from './sync-engine.js';
6
6
  import { generateRoadmapFromBase } from '../roadmap-gen.js';
7
7
  import { spliceChangelog } from '../changelog-writer.js';
8
+ import { assertValidLinkShape } from '../feature-write-guard.js';
8
9
 
9
10
  const META_RE = /<!--compose-feature\n([\s\S]*?)\n-->/;
10
11
  function encodeBody(obj) {
@@ -107,7 +108,8 @@ export class GitHubProvider extends TrackerProvider {
107
108
  );
108
109
  }
109
110
 
110
- async createFeature(code, obj) {
111
+ async createFeature(code, obj, opts = {}) {
112
+ if (opts.validate !== false) assertValidLinkShape(obj);
111
113
  return this._lock(code, async () => {
112
114
  // Idempotent: if already in cache, return it.
113
115
  const existing = await this.cache.get(code);
@@ -120,7 +122,11 @@ export class GitHubProvider extends TrackerProvider {
120
122
  });
121
123
  }
122
124
 
123
- async putFeature(code, obj) {
125
+ async putFeature(code, obj, opts = {}) {
126
+ // COMP-MCP-VALIDATE-1: enforce link-shape on the GitHub backend too (same
127
+ // rule as the local chokepoint). Existence is local-folder semantics and is
128
+ // not applied here. `validate:false` opts out (migration/fixture planting).
129
+ if (opts.validate !== false) assertValidLinkShape(obj);
124
130
  return this._lock(code, async () => {
125
131
  const cur = await this.cache.get(code);
126
132
  if (cur && obj.status && obj.status !== cur.status) {
@@ -140,7 +146,8 @@ export class GitHubProvider extends TrackerProvider {
140
146
  }
141
147
 
142
148
  // Raw write that allows status change (used by setStatus / policy layers).
143
- async persistFeatureRaw(code, obj) {
149
+ async persistFeatureRaw(code, obj, opts = {}) {
150
+ if (opts.validate !== false) assertValidLinkShape(obj);
144
151
  return this._lock(code, async () => {
145
152
  await this.cache.put(code, obj, { pending: true });
146
153
  await this.cache.markPending(code);
@@ -62,12 +62,12 @@ export class LocalFileProvider extends TrackerProvider {
62
62
  return readFeature(this.cwd, code, this.featuresDir);
63
63
  }
64
64
 
65
- async putFeature(code, obj) {
65
+ async putFeature(code, obj, opts = {}) {
66
66
  const cur = readFeature(this.cwd, code, this.featuresDir);
67
67
  if (cur && Object.prototype.hasOwnProperty.call(obj, 'status') && obj.status !== cur.status) {
68
68
  throw new Error(`putFeature: status delta (${cur.status}->${obj.status}) not allowed; use setStatus`);
69
69
  }
70
- writeFeature(this.cwd, { ...obj, code }, this.featuresDir);
70
+ writeFeature(this.cwd, { ...obj, code }, this.featuresDir, opts);
71
71
  return readFeature(this.cwd, code, this.featuresDir);
72
72
  }
73
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.16-beta",
3
+ "version": "0.2.18-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",
@@ -16,6 +16,7 @@ import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
 
18
18
  import { getTargetRoot, resolveProjectPath } from './project-root.js';
19
+ import { assertValidLinkShape } from '../lib/feature-write-guard.js';
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // Metadata extraction
@@ -395,6 +396,18 @@ export function writeFeatureGroupToDisk(item, newGroup, featuresDir) {
395
396
  spec.updated = new Date().toISOString().slice(0, 10);
396
397
  }
397
398
 
399
+ // COMP-MCP-VALIDATE-1: never persist a malformed link shape via the vision
400
+ // route. Existence (DANGLING) is intentionally NOT checked here: this path
401
+ // only mutates `group`, so it cannot introduce a new dangling link, and
402
+ // re-validating existence would wrongly block a group rename on a feature that
403
+ // already carries a (possibly legitimately forced) forward-ref.
404
+ try {
405
+ assertValidLinkShape(spec);
406
+ } catch (err) {
407
+ console.warn(`[feature-scan] writeFeatureGroupToDisk: invalid feature.json at ${specPath}, not writing: ${err.message}`);
408
+ return false;
409
+ }
410
+
398
411
  try {
399
412
  const tmp = path.join(featureDir, `feature.json.tmp.${Date.now()}.${process.pid}`);
400
413
  fs.writeFileSync(tmp, JSON.stringify(spec, null, 2) + '\n', 'utf-8');
@@ -23,6 +23,7 @@ import {
23
23
  updateIdea,
24
24
  addDiscussion,
25
25
  } from '../lib/ideabox.js'
26
+ import { writeFeature } from '../lib/feature-json.js'
26
27
  import { IdeaboxCache } from './ideabox-cache.js'
27
28
 
28
29
  /**
@@ -182,14 +183,15 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
182
183
  }
183
184
  const featuresDir = path.join(projectRoot, featuresRel, resolvedCode)
184
185
  if (!fs.existsSync(featuresDir)) {
185
- fs.mkdirSync(featuresDir, { recursive: true })
186
- fs.writeFileSync(path.join(featuresDir, 'feature.json'), JSON.stringify({
186
+ // COMP-MCP-VALIDATE-1: route through the validated writer instead of a
187
+ // raw fs.writeFileSync so the promoted feature.json is schema-guarded.
188
+ writeFeature(projectRoot, {
187
189
  code: resolvedCode,
188
190
  description: sourceIdea.title,
189
191
  status: 'PLANNED',
190
192
  promotedFrom: sourceIdea.id,
191
193
  createdAt: new Date().toISOString(),
192
- }, null, 2))
194
+ }, featuresRel)
193
195
  }
194
196
 
195
197
  promoteIdea(parsed, id, resolvedCode)