@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 +5 -3
- package/lib/feature-json.js +17 -1
- package/lib/feature-write-guard.js +201 -0
- package/lib/feature-writer.js +20 -1
- package/lib/tracker/github-provider.js +10 -3
- package/lib/tracker/local-provider.js +2 -2
- package/package.json +1 -1
- package/server/feature-scan.js +13 -0
- package/server/ideabox-routes.js +5 -3
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
|
-
|
|
2332
|
-
|
|
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
|
-
},
|
|
2340
|
+
}, featuresRel)
|
|
2339
2341
|
console.log(`Created feature folder: ${featuresRel}/${resolvedCode}/`)
|
|
2340
2342
|
}
|
|
2341
2343
|
|
package/lib/feature-json.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/feature-writer.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|
package/server/feature-scan.js
CHANGED
|
@@ -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');
|
package/server/ideabox-routes.js
CHANGED
|
@@ -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
|
-
|
|
186
|
-
fs.writeFileSync
|
|
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
|
-
},
|
|
194
|
+
}, featuresRel)
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
promoteIdea(parsed, id, resolvedCode)
|