@smartmemory/compose 0.2.17-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');
@@ -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.17-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)