@smartmemory/compose 0.1.5-beta → 0.1.7-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 +410 -0
- package/bin/git-hooks/post-commit.template +61 -0
- package/bin/git-hooks/pre-push.template +26 -0
- package/contracts/feature-json.schema.json +115 -0
- package/contracts/roadmap-row.schema.json +23 -0
- package/contracts/vision-state.schema.json +64 -0
- package/lib/changelog-writer.js +647 -0
- package/lib/completion-writer.js +464 -0
- package/lib/feature-code.js +29 -0
- package/lib/feature-validator.js +629 -0
- package/lib/feature-writer.js +325 -5
- package/lib/journal-writer.js +928 -0
- package/package.json +5 -1
- package/server/compose-mcp-tools.js +80 -0
- package/server/compose-mcp.js +244 -1
- package/server/schema-validator.js +50 -9
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* completion-writer.js — typed writer for per-feature completion records.
|
|
3
|
+
*
|
|
4
|
+
* Sub-ticket #5 of COMP-MCP-FEATURE-MGMT (COMP-MCP-COMPLETION).
|
|
5
|
+
*
|
|
6
|
+
* Two operations:
|
|
7
|
+
* recordCompletion(cwd, args) — append (or replace) a completion record on feature.json
|
|
8
|
+
* getCompletions(cwd, opts) — read + filter completion records across features
|
|
9
|
+
*
|
|
10
|
+
* Storage: feature.json completions[] (append-mostly, oldest-first on disk).
|
|
11
|
+
* Idempotency: storage-level dedup on completion_id = <feature_code>:<commit_sha>;
|
|
12
|
+
* optional caller-supplied idempotency_key via checkOrInsert.
|
|
13
|
+
* Concurrency: per-feature advisory lock at
|
|
14
|
+
* <cwd>/.compose/data/locks/feature-<feature_code>.lock
|
|
15
|
+
* (Decision 10; mirrors acquireLock pattern from lib/idempotency.js:42).
|
|
16
|
+
*
|
|
17
|
+
* setFeatureStatus is lazy-imported inside the writer to avoid circular load.
|
|
18
|
+
* (feature-writer.js must NOT import completion-writer.js — that would create
|
|
19
|
+
* a cycle. The lazy import here is intentional and must stay inside the function.)
|
|
20
|
+
*
|
|
21
|
+
* No HTTP, no transport awareness.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { mkdirSync, rmSync, statSync } from 'fs';
|
|
25
|
+
import { join, dirname, posix } from 'path';
|
|
26
|
+
|
|
27
|
+
import { readFeature, updateFeature, listFeatures } from './feature-json.js';
|
|
28
|
+
import { appendEvent, normalizeSince } from './feature-events.js';
|
|
29
|
+
import { checkOrInsert } from './idempotency.js';
|
|
30
|
+
import { FEATURE_CODE_RE_STRICT as FEATURE_CODE_RE } from './feature-code.js';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Constants + regexes
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const SHA_RE = /^[0-9a-f]{40}$/i; // FULL SHA only (Decision 9). Case-insensitive input; normalize to lowercase.
|
|
36
|
+
const SHORT_LEN = 8; // Display only — never the dedup key.
|
|
37
|
+
const DEFAULT_LIMIT = 50;
|
|
38
|
+
const MAX_LIMIT = 500;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Lock helpers (mirrors idempotency.js:42)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
45
|
+
const LOCK_RETRY_MS = 25;
|
|
46
|
+
|
|
47
|
+
function featureLockFile(cwd, featureCode) {
|
|
48
|
+
return join(cwd, '.compose', 'data', 'locks', `feature-${featureCode}.lock`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ensureLockDir(lockPath) {
|
|
52
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function acquireFeatureLock(cwd, featureCode) {
|
|
56
|
+
const lockPath = featureLockFile(cwd, featureCode);
|
|
57
|
+
ensureLockDir(lockPath);
|
|
58
|
+
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
while (true) {
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(lockPath);
|
|
63
|
+
return () => {
|
|
64
|
+
try { rmSync(lockPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
65
|
+
};
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.code !== 'EEXIST') throw err;
|
|
68
|
+
// Stale lock recovery
|
|
69
|
+
try {
|
|
70
|
+
const st = statSync(lockPath);
|
|
71
|
+
if (Date.now() - st.mtimeMs > LOCK_TIMEOUT_MS) {
|
|
72
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
} catch { /* stat raced; loop and retry */ }
|
|
76
|
+
|
|
77
|
+
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
|
78
|
+
throw new Error(`completion-writer: feature lock timeout after ${LOCK_TIMEOUT_MS}ms: ${lockPath}`);
|
|
79
|
+
}
|
|
80
|
+
await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Typed-error helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function inputError(message) {
|
|
90
|
+
const e = new Error(message);
|
|
91
|
+
e.code = 'INVALID_INPUT';
|
|
92
|
+
return e;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function notFoundError(code) {
|
|
96
|
+
const e = new Error(`completion-writer: feature "${code}" not found`);
|
|
97
|
+
e.code = 'FEATURE_NOT_FOUND';
|
|
98
|
+
return e;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function statusFlipError(message, cause) {
|
|
102
|
+
const e = new Error(message);
|
|
103
|
+
e.code = 'STATUS_FLIP_AFTER_COMPLETION_RECORDED';
|
|
104
|
+
if (cause) e.cause = cause;
|
|
105
|
+
return e;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Path validation
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function validateRepoRelativePath(p) {
|
|
113
|
+
if (typeof p !== 'string' || p.length === 0) {
|
|
114
|
+
throw inputError('files_changed: each entry must be a non-empty string');
|
|
115
|
+
}
|
|
116
|
+
if (p.includes('\0')) {
|
|
117
|
+
throw inputError(`files_changed: entry contains NUL byte: "${p}"`);
|
|
118
|
+
}
|
|
119
|
+
if (p.startsWith('/')) {
|
|
120
|
+
throw inputError(`files_changed: absolute paths not allowed: "${p}"`);
|
|
121
|
+
}
|
|
122
|
+
if (p.includes('\\')) {
|
|
123
|
+
throw inputError(`files_changed: use POSIX separators (no backslashes): "${p}"`);
|
|
124
|
+
}
|
|
125
|
+
const normalized = posix.normalize(p);
|
|
126
|
+
if (normalized !== p) {
|
|
127
|
+
throw inputError(`files_changed: path "${p}" must already be normalized (got "${normalized}")`);
|
|
128
|
+
}
|
|
129
|
+
if (normalized.startsWith('../') || normalized === '..') {
|
|
130
|
+
throw inputError(`files_changed: ".." escape rejected: "${p}"`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Validation
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function validate(args) {
|
|
139
|
+
// feature_code
|
|
140
|
+
if (typeof args.feature_code !== 'string' || !FEATURE_CODE_RE.test(args.feature_code)) {
|
|
141
|
+
throw inputError(
|
|
142
|
+
`completion-writer: invalid feature_code "${args.feature_code}" — must match ${FEATURE_CODE_RE}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// commit_sha — full 40-char hex required (Decision 9)
|
|
147
|
+
if (typeof args.commit_sha !== 'string' || args.commit_sha.trim().length === 0) {
|
|
148
|
+
throw inputError('completion-writer: commit_sha is required (non-empty string)');
|
|
149
|
+
}
|
|
150
|
+
const trimmedSha = args.commit_sha.trim();
|
|
151
|
+
if (!SHA_RE.test(trimmedSha)) {
|
|
152
|
+
throw inputError(
|
|
153
|
+
`completion-writer: commit_sha must be a full 40-char hex SHA (Decision 9). ` +
|
|
154
|
+
`Got "${trimmedSha}" (length ${trimmedSha.length}). Short prefixes are rejected on write.`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// tests_pass — strict boolean
|
|
159
|
+
if (typeof args.tests_pass !== 'boolean') {
|
|
160
|
+
throw inputError(
|
|
161
|
+
`completion-writer: tests_pass must be a boolean, got ${typeof args.tests_pass} "${args.tests_pass}"`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// files_changed — array of normalized repo-relative paths
|
|
166
|
+
if (!Array.isArray(args.files_changed)) {
|
|
167
|
+
throw inputError('completion-writer: files_changed must be an array');
|
|
168
|
+
}
|
|
169
|
+
for (const p of args.files_changed) {
|
|
170
|
+
validateRepoRelativePath(p);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// notes — if present, non-empty string, no NUL
|
|
174
|
+
if (args.notes !== undefined && args.notes !== null) {
|
|
175
|
+
if (typeof args.notes !== 'string') {
|
|
176
|
+
throw inputError('completion-writer: notes must be a string');
|
|
177
|
+
}
|
|
178
|
+
if (args.notes.includes('\0')) {
|
|
179
|
+
throw inputError('completion-writer: notes must not contain NUL bytes');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// set_status — boolean if present
|
|
184
|
+
if (args.set_status !== undefined && typeof args.set_status !== 'boolean') {
|
|
185
|
+
throw inputError('completion-writer: set_status must be a boolean');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// force — boolean if present
|
|
189
|
+
if (args.force !== undefined && typeof args.force !== 'boolean') {
|
|
190
|
+
throw inputError('completion-writer: force must be a boolean');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// maybeIdempotent helper (mirrors feature-writer.js pattern)
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function maybeIdempotent(args, fn) {
|
|
199
|
+
if (args.idempotency_key) {
|
|
200
|
+
return checkOrInsert(args.cwd, args.idempotency_key, fn).then(({ result }) => result);
|
|
201
|
+
}
|
|
202
|
+
return Promise.resolve().then(fn);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// safeAppendEvent — best-effort; failed append must NOT roll back a committed
|
|
207
|
+
// mutation (per sibling-writer convention).
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
function safeAppendEvent(cwd, event) {
|
|
211
|
+
try {
|
|
212
|
+
appendEvent(cwd, event);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
// eslint-disable-next-line no-console
|
|
215
|
+
console.warn(
|
|
216
|
+
`[completion-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// recordCompletion
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Record a completion bound to a commit SHA on feature.json.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} cwd
|
|
229
|
+
* @param {object} args
|
|
230
|
+
* @param {string} args.feature_code
|
|
231
|
+
* @param {string} args.commit_sha Full 40-char hex SHA (Decision 9)
|
|
232
|
+
* @param {boolean} args.tests_pass
|
|
233
|
+
* @param {string[]} args.files_changed Repo-relative normalized POSIX paths
|
|
234
|
+
* @param {string} [args.notes]
|
|
235
|
+
* @param {boolean} [args.set_status] Default true — flip status to COMPLETE
|
|
236
|
+
* @param {boolean} [args.force] Replace existing same-(code,sha) record
|
|
237
|
+
* @param {string} [args.idempotency_key]
|
|
238
|
+
*
|
|
239
|
+
* @returns {{ feature_code, completion_id, commit_sha, commit_sha_short,
|
|
240
|
+
* status_changed, status_flip_partial, idempotent, recorded_at }}
|
|
241
|
+
*/
|
|
242
|
+
export async function recordCompletion(cwd, args) {
|
|
243
|
+
// 1. Validate
|
|
244
|
+
validate(args);
|
|
245
|
+
|
|
246
|
+
// 2. Normalize SHA
|
|
247
|
+
const commit_sha = args.commit_sha.trim().toLowerCase();
|
|
248
|
+
const commit_sha_short = commit_sha.slice(0, SHORT_LEN);
|
|
249
|
+
const feature_code = args.feature_code;
|
|
250
|
+
|
|
251
|
+
// 3. Compute completion_id
|
|
252
|
+
const completion_id = `${feature_code}:${commit_sha}`;
|
|
253
|
+
|
|
254
|
+
// 4. Wrap in maybeIdempotent for caller-key path
|
|
255
|
+
return maybeIdempotent({ ...args, cwd }, async () => {
|
|
256
|
+
// 5a. Acquire per-feature advisory lock (Decision 10)
|
|
257
|
+
const release = await acquireFeatureLock(cwd, feature_code);
|
|
258
|
+
try {
|
|
259
|
+
// 5b. Read feature
|
|
260
|
+
const feature = readFeature(cwd, feature_code);
|
|
261
|
+
if (!feature) throw notFoundError(feature_code);
|
|
262
|
+
|
|
263
|
+
// 5c. Snapshot completions array
|
|
264
|
+
const completions = Array.isArray(feature.completions) ? [...feature.completions] : [];
|
|
265
|
+
|
|
266
|
+
// 5d. Find existing index
|
|
267
|
+
const idx = completions.findIndex(c => c.completion_id === completion_id);
|
|
268
|
+
|
|
269
|
+
// 5e. Idempotent no-op
|
|
270
|
+
if (idx !== -1 && !args.force) {
|
|
271
|
+
return {
|
|
272
|
+
feature_code,
|
|
273
|
+
completion_id,
|
|
274
|
+
commit_sha,
|
|
275
|
+
commit_sha_short,
|
|
276
|
+
status_changed: null,
|
|
277
|
+
status_flip_partial: false,
|
|
278
|
+
idempotent: true,
|
|
279
|
+
recorded_at: completions[idx].recorded_at,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 5f. Build record
|
|
284
|
+
const record = {
|
|
285
|
+
completion_id,
|
|
286
|
+
feature_code, // stamped at write time (Decision 11)
|
|
287
|
+
commit_sha,
|
|
288
|
+
commit_sha_short,
|
|
289
|
+
tests_pass: args.tests_pass,
|
|
290
|
+
files_changed: [...args.files_changed],
|
|
291
|
+
recorded_at: new Date().toISOString(),
|
|
292
|
+
recorded_by: process.env.COMPOSE_ACTOR || 'mcp:agent',
|
|
293
|
+
};
|
|
294
|
+
if (args.notes) record.notes = args.notes;
|
|
295
|
+
|
|
296
|
+
// 5g. Replace or append
|
|
297
|
+
if (idx !== -1) {
|
|
298
|
+
completions[idx] = record;
|
|
299
|
+
} else {
|
|
300
|
+
completions.push(record);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 5h. Persist completion record BEFORE status flip (so flip failure doesn't lose the record)
|
|
304
|
+
updateFeature(cwd, feature_code, { completions });
|
|
305
|
+
|
|
306
|
+
// 5i. Status flip (default on)
|
|
307
|
+
const set_status = args.set_status !== false;
|
|
308
|
+
let status_changed = null;
|
|
309
|
+
|
|
310
|
+
if (set_status && feature.status !== 'COMPLETE') {
|
|
311
|
+
const fromStatus = feature.status;
|
|
312
|
+
// Terminal states (KILLED, SUPERSEDED) have no valid outgoing transitions.
|
|
313
|
+
// We deliberately do NOT force for terminal states so that the transition
|
|
314
|
+
// policy enforcement fires and produces the STATUS_FLIP_AFTER_COMPLETION_RECORDED
|
|
315
|
+
// error (test #11 / Decision 4). For non-terminal states we pass force: true
|
|
316
|
+
// so that intermediate-state features (e.g. PLANNED → COMPLETE) succeed without
|
|
317
|
+
// requiring callers to manually walk through IN_PROGRESS first.
|
|
318
|
+
const TERMINAL_STATES = new Set(['KILLED', 'SUPERSEDED']);
|
|
319
|
+
const flipForce = !TERMINAL_STATES.has(fromStatus);
|
|
320
|
+
try {
|
|
321
|
+
// Lazy-import setFeatureStatus to avoid circular load
|
|
322
|
+
// (completion-writer.js must not be statically imported by feature-writer.js)
|
|
323
|
+
const { setFeatureStatus } = await import('./feature-writer.js');
|
|
324
|
+
await setFeatureStatus(cwd, {
|
|
325
|
+
code: feature_code,
|
|
326
|
+
status: 'COMPLETE',
|
|
327
|
+
commit_sha,
|
|
328
|
+
reason: 'record_completion',
|
|
329
|
+
force: flipForce,
|
|
330
|
+
});
|
|
331
|
+
status_changed = { from: fromStatus, to: 'COMPLETE' };
|
|
332
|
+
} catch (flipErr) {
|
|
333
|
+
// Both failure sub-cases (transition rejected AND ROADMAP_PARTIAL_WRITE) rethrow
|
|
334
|
+
// as STATUS_FLIP_AFTER_COMPLETION_RECORDED. The completion record IS persisted (step h).
|
|
335
|
+
throw statusFlipError(
|
|
336
|
+
flipErr.code === 'ROADMAP_PARTIAL_WRITE'
|
|
337
|
+
? `completion-writer: completion recorded for "${feature_code}" but ROADMAP regen failed after status flip. ` +
|
|
338
|
+
`This is the ROADMAP_PARTIAL_WRITE subcase (Decision 4). ` +
|
|
339
|
+
`Recover with \`compose roadmap generate\`.`
|
|
340
|
+
: `completion-writer: completion recorded for "${feature_code}" but status flip to COMPLETE failed. ` +
|
|
341
|
+
`err.cause carries the underlying transition error.`,
|
|
342
|
+
flipErr,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 5j. Audit event (not appended for idempotent no-ops — they return early at 5e)
|
|
348
|
+
const auditEvent = {
|
|
349
|
+
tool: 'record_completion',
|
|
350
|
+
code: feature_code,
|
|
351
|
+
completion_id,
|
|
352
|
+
commit_sha,
|
|
353
|
+
tests_pass: args.tests_pass,
|
|
354
|
+
set_status,
|
|
355
|
+
};
|
|
356
|
+
if (args.force) auditEvent.force = args.force;
|
|
357
|
+
if (args.idempotency_key) auditEvent.idempotency_key = args.idempotency_key;
|
|
358
|
+
safeAppendEvent(cwd, auditEvent);
|
|
359
|
+
|
|
360
|
+
// 5l. Return
|
|
361
|
+
return {
|
|
362
|
+
feature_code,
|
|
363
|
+
completion_id,
|
|
364
|
+
commit_sha,
|
|
365
|
+
commit_sha_short,
|
|
366
|
+
status_changed,
|
|
367
|
+
status_flip_partial: false,
|
|
368
|
+
idempotent: false,
|
|
369
|
+
recorded_at: record.recorded_at,
|
|
370
|
+
};
|
|
371
|
+
} finally {
|
|
372
|
+
release();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// getCompletions
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Read completion records across features, with optional filtering.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} cwd
|
|
385
|
+
* @param {object} [opts]
|
|
386
|
+
* @param {string} [opts.feature_code] Exact feature code filter
|
|
387
|
+
* @param {string} [opts.commit_sha] Full or short-prefix SHA filter (permissive at read time)
|
|
388
|
+
* @param {string} [opts.since] Shorthand "7d"/"24h" or ISO date
|
|
389
|
+
* @param {number} [opts.limit] Default 50, max 500
|
|
390
|
+
*
|
|
391
|
+
* @returns {{ completions: Array, count: number }}
|
|
392
|
+
*/
|
|
393
|
+
export function getCompletions(cwd, opts = {}) {
|
|
394
|
+
// 1. Parse filters
|
|
395
|
+
const sinceMs = opts.since ? normalizeSince(opts.since) : null;
|
|
396
|
+
let limit = typeof opts.limit === 'number' ? opts.limit : DEFAULT_LIMIT;
|
|
397
|
+
if (limit < 0) limit = 0;
|
|
398
|
+
if (limit > MAX_LIMIT) limit = MAX_LIMIT;
|
|
399
|
+
|
|
400
|
+
// 2. Gather candidate features
|
|
401
|
+
let features;
|
|
402
|
+
if (opts.feature_code) {
|
|
403
|
+
const f = readFeature(cwd, opts.feature_code);
|
|
404
|
+
features = f ? [f] : [];
|
|
405
|
+
} else {
|
|
406
|
+
features = listFeatures(cwd);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 3. Flatten all completions[] arrays
|
|
410
|
+
// Normalize always-present nullable fields so readers get a uniform shape
|
|
411
|
+
// regardless of hand-edited or legacy records (Decision 11).
|
|
412
|
+
const all = [];
|
|
413
|
+
for (const feature of features) {
|
|
414
|
+
if (!Array.isArray(feature.completions)) continue;
|
|
415
|
+
for (const rec of feature.completions) {
|
|
416
|
+
const normalized = { ...rec };
|
|
417
|
+
if (!Object.prototype.hasOwnProperty.call(normalized, 'feature_code')) {
|
|
418
|
+
normalized.feature_code = null;
|
|
419
|
+
}
|
|
420
|
+
if (!Object.prototype.hasOwnProperty.call(normalized, 'commit_sha_short')) {
|
|
421
|
+
normalized.commit_sha_short = null;
|
|
422
|
+
}
|
|
423
|
+
all.push(normalized);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 4. Filter
|
|
428
|
+
let filtered = all;
|
|
429
|
+
|
|
430
|
+
if (opts.commit_sha) {
|
|
431
|
+
const filterSha = opts.commit_sha.trim().toLowerCase();
|
|
432
|
+
if (filterSha.length > 0 && filterSha.length < 4) {
|
|
433
|
+
const err = new Error(
|
|
434
|
+
`commit_sha prefix too short: got ${filterSha.length} char(s), minimum is 4`
|
|
435
|
+
);
|
|
436
|
+
err.code = 'INVALID_INPUT';
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
filtered = filtered.filter(rec => {
|
|
440
|
+
if (typeof rec.commit_sha !== 'string') return false;
|
|
441
|
+
return rec.commit_sha === filterSha || rec.commit_sha.startsWith(filterSha);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (sinceMs !== null) {
|
|
446
|
+
filtered = filtered.filter(rec => {
|
|
447
|
+
const ms = Date.parse(rec.recorded_at);
|
|
448
|
+
return !isNaN(ms) && ms >= sinceMs;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 5. Sort desc by recorded_at
|
|
453
|
+
filtered.sort((a, b) => {
|
|
454
|
+
const ta = Date.parse(a.recorded_at) || 0;
|
|
455
|
+
const tb = Date.parse(b.recorded_at) || 0;
|
|
456
|
+
return tb - ta;
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// 6. Truncate
|
|
460
|
+
const completions = filtered.slice(0, limit);
|
|
461
|
+
|
|
462
|
+
// 7. Return — feature_code from the record itself (Decision 11; null if absent)
|
|
463
|
+
return { completions, count: completions.length };
|
|
464
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict feature-code regex used by every typed writer to validate input.
|
|
3
|
+
* Contract: starts with uppercase letter, contains uppercase/digits/hyphens,
|
|
4
|
+
* ends in uppercase or digit (no trailing hyphen, no leading hyphen).
|
|
5
|
+
*
|
|
6
|
+
* Three writer sites import from here: feature-writer, completion-writer,
|
|
7
|
+
* journal-writer. The roadmap parser deliberately uses a looser regex
|
|
8
|
+
* (`/^[A-Z][\w-]*-\d+/`) to match anonymous/legacy table rows and is exempt
|
|
9
|
+
* from this extraction.
|
|
10
|
+
*
|
|
11
|
+
* Introduced by COMP-MCP-VALIDATE.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const FEATURE_CODE_RE_STRICT = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Throws an Error with `code: 'INVALID_INPUT'` if `code` is not a strict
|
|
18
|
+
* feature code. Otherwise returns silently.
|
|
19
|
+
*
|
|
20
|
+
* @param {unknown} code
|
|
21
|
+
* @throws {Error & { code: 'INVALID_INPUT' }}
|
|
22
|
+
*/
|
|
23
|
+
export function validateCode(code) {
|
|
24
|
+
if (typeof code !== 'string' || !FEATURE_CODE_RE_STRICT.test(code)) {
|
|
25
|
+
const err = new Error(`Invalid feature code: ${JSON.stringify(code)}`);
|
|
26
|
+
err.code = 'INVALID_INPUT';
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|