@smartmemory/compose 0.1.6-beta → 0.1.8-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/README.md +32 -5
- package/bin/compose.js +353 -60
- 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/dist/assets/{_baseUniq-D-avYfn5.js → _baseUniq-3jW4HAOf.js} +1 -1
- package/dist/assets/{arc-BC4dfQ-X.js → arc-DzzDimyd.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BZmFXnGI.js → architectureDiagram-Q4EWVU46-CtAgwORz.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DlfWSuux.js → blockDiagram-DXYQGD6D-Bryby0c_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Y__uJrRx.js → c4Diagram-AHTNJAMY-C7N9RTJ8.js} +1 -1
- package/dist/assets/channel-DDkv7DUd.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BfMePfTp.js → chunk-4BX2VUAB-wijkFgZY.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BdlMSdEA.js → chunk-4TB4RGXK-zdSZGRS2.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-vrQHZTdv.js → chunk-55IACEB6-6zqzTZQQ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-B8wioVlW.js → chunk-EDXVE4YY-frd1Vwf-.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Cd6Hrux2.js → chunk-FMBD7UC4-CdkRK5Hx.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CfrhdQXY.js → chunk-OYMX7WX6-C6bMB0cf.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B9JQerOU.js → chunk-QZHKN3VN-4vsxN3jq.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DFN9X99H.js → chunk-YZCP3GAM-DbNARKip.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-J6ZTeCbW.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-J6ZTeCbW.js +1 -0
- package/dist/assets/clone-5MVZ89iV.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BAn0ap_E.js → cose-bilkent-S5V4N54A-BpXeV7Vj.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DyxnVq1g.js → dagre-KV5264BT-DQLu_W8r.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-XCrzqski.js → diagram-5BDNPKRD-skaOoe5A.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-MBCAXft_.js → diagram-G4DWMVQ6-DezlfFH4.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DbtB2yS6.js → diagram-MMDJMWI5-BUu-v-wT.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Bb5NzX61.js → diagram-TYMM5635-CziQ6LPs.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpIeCOh2.js → erDiagram-SMLLAGMA-BsAyOVTI.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CHyoKnhW.js → flowDiagram-DWJPFMVM-CbYWJOLq.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-DErKteO_.js → ganttDiagram-T4ZO3ILL-CAwgDkLl.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-KFVAtj2F.js → gitGraphDiagram-UUTBAWPF-DK4RlkjO.js} +1 -1
- package/dist/assets/{graph-CRnO_ifT.js → graph-orv1XHGx.js} +1 -1
- package/dist/assets/{index-DkRKLuNr.js → index-Ceywghsu.js} +143 -143
- package/dist/assets/{infoDiagram-42DDH7IO-BZFnuSp5.js → infoDiagram-42DDH7IO-DQyA75sK.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-4Xe2Szde.js → ishikawaDiagram-UXIWVN3A-C-F_5q4k.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CZRByfS-.js → journeyDiagram-VCZTEJTY-Bj8UIvK-.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-B95sk6Fk.js → kanban-definition-6JOO6SKY-DZYr8Dp1.js} +1 -1
- package/dist/assets/{layout-BqNQzxWT.js → layout-CBaTKjpX.js} +1 -1
- package/dist/assets/{linear-CUh7qb64.js → linear-j1sI_SiN.js} +1 -1
- package/dist/assets/{min-wXgOS3ig.js → min-DtJISjld.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-DB6iaAbO.js → mindmap-definition-QFDTVHPH-Bulb64RS.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CHkZHrTW.js → pieDiagram-DEJITSTG-D11keQxr.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DoTEO8e3.js → quadrantDiagram-34T5L4WZ-BEcWQiEG.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Dn8peXYp.js → requirementDiagram-MS252O5E-Cbp23uDf.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DRXs6Ipb.js → sankeyDiagram-XADWPNL6-Dae1hMc5.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-wBBYZ0aq.js → sequenceDiagram-FGHM5R23-C16abORi.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DPlBNGmf.js → stateDiagram-FHFEXIEX-CbEtfhbx.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-CyY84hEA.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-CbbyTlHk.js → timeline-definition-GMOUNBTQ-BV7JTNMI.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-Bj4GaFfj.js → vennDiagram-DHZGUBPP-DBZiT48j.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-RtNzq8KU.js → wardley-RL74JXVD-Cc8uoiL3.js} +37 -37
- package/dist/assets/{wardleyDiagram-NUSXRM2D-CDfE3zSj.js → wardleyDiagram-NUSXRM2D-DEYcWGo5.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CZXHHYD5.js → xychartDiagram-5P7HB3ND-bFhLXv2b.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build.js +193 -19
- package/lib/completion-writer.js +8 -6
- package/lib/deps.js +17 -6
- package/lib/feature-code.js +29 -0
- package/lib/feature-events.js +3 -0
- package/lib/feature-validator.js +629 -0
- package/lib/feature-writer.js +35 -23
- package/lib/followup-writer.js +556 -0
- package/lib/journal-writer.js +1 -1
- package/lib/mcp-enforcement.js +173 -0
- package/lib/migrate-roadmap.js +4 -1
- package/lib/project-paths.js +36 -0
- package/lib/review-lenses.js +23 -8
- package/lib/review-normalize.js +42 -3
- package/lib/roadmap-drift.js +54 -0
- package/lib/roadmap-gen.js +297 -27
- package/lib/roadmap-preservers.js +353 -0
- package/lib/step-prompt.js +15 -0
- package/lib/triage.js +2 -1
- package/lib/version-check.js +110 -0
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +34 -2
- package/server/compose-mcp.js +52 -1
- package/server/schema-validator.js +50 -9
- package/server/vision-routes.js +51 -2
- package/templates/ROADMAP.md +6 -0
- package/dist/assets/channel-LRG9kHqJ.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +0 -1
- package/dist/assets/clone-dRxgFrBv.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +0 -1
|
@@ -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
|
+
}
|
package/lib/feature-events.js
CHANGED
|
@@ -52,6 +52,9 @@ export function appendEvent(cwd, event) {
|
|
|
52
52
|
const row = {
|
|
53
53
|
ts: new Date().toISOString(),
|
|
54
54
|
actor: actor(),
|
|
55
|
+
// COMP-MCP-MIGRATION-1: stamp build correlation ID when running inside a
|
|
56
|
+
// build runner. Null outside of a build (manual CLI/MCP invocations).
|
|
57
|
+
build_id: process.env.COMPOSE_BUILD_ID || null,
|
|
55
58
|
...event,
|
|
56
59
|
};
|
|
57
60
|
appendFileSync(path, JSON.stringify(row) + '\n');
|
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-artifact feature validator.
|
|
3
|
+
*
|
|
4
|
+
* Composes ROADMAP.md row, vision-state.json item, feature.json, feature folder
|
|
5
|
+
* contents, linked artifacts, and cross-feature references. Returns structured
|
|
6
|
+
* findings with severity. Hookable from pre-push.
|
|
7
|
+
*
|
|
8
|
+
* COMP-MCP-VALIDATE — sub-ticket #7 of COMP-MCP-FEATURE-MGMT.
|
|
9
|
+
*
|
|
10
|
+
* Public exports:
|
|
11
|
+
* validateFeature(cwd, code, options?) → { scope, feature_code, validated_at, findings: [...] }
|
|
12
|
+
* validateProject(cwd, options?) → { scope, validated_at, findings: [...] }
|
|
13
|
+
*
|
|
14
|
+
* Each finding: { severity: 'error'|'warning'|'info', kind, feature_code?, detail, source? }.
|
|
15
|
+
*
|
|
16
|
+
* Catalog (27 kinds): see docs/features/COMP-MCP-VALIDATE/design.md.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { FEATURE_CODE_RE_STRICT, validateCode } from './feature-code.js';
|
|
23
|
+
import { parseRoadmap } from './roadmap-parser.js';
|
|
24
|
+
import { listFeatures, readFeature } from './feature-json.js';
|
|
25
|
+
import { ArtifactManager } from '../server/artifact-manager.js';
|
|
26
|
+
import { SchemaValidator } from '../server/schema-validator.js';
|
|
27
|
+
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
29
|
+
const __dirname = path.dirname(__filename);
|
|
30
|
+
|
|
31
|
+
const FEATURE_JSON_SCHEMA = path.resolve(__dirname, '../contracts/feature-json.schema.json');
|
|
32
|
+
const VISION_STATE_SCHEMA = path.resolve(__dirname, '../contracts/vision-state.schema.json');
|
|
33
|
+
const ROADMAP_ROW_SCHEMA = path.resolve(__dirname, '../contracts/roadmap-row.schema.json');
|
|
34
|
+
|
|
35
|
+
const DEFAULT_PATHS = { docs: 'docs', features: 'docs/features', journal: 'docs/journal' };
|
|
36
|
+
|
|
37
|
+
const TERMINAL_STATUSES = new Set(['KILLED', 'SUPERSEDED']);
|
|
38
|
+
const VALID_STATUSES = new Set(['PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE', 'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED']);
|
|
39
|
+
|
|
40
|
+
const _validatorCache = {};
|
|
41
|
+
function getValidator(schemaPath) {
|
|
42
|
+
if (!_validatorCache[schemaPath]) _validatorCache[schemaPath] = new SchemaValidator(schemaPath);
|
|
43
|
+
return _validatorCache[schemaPath];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nowIso() { return new Date().toISOString(); }
|
|
47
|
+
|
|
48
|
+
function readProjectConfig(cwd) {
|
|
49
|
+
const configPath = path.join(cwd, '.compose', 'compose.json');
|
|
50
|
+
try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveProjectPaths(cwd) {
|
|
54
|
+
const cfg = readProjectConfig(cwd);
|
|
55
|
+
const paths = (cfg && cfg.paths) || DEFAULT_PATHS;
|
|
56
|
+
return {
|
|
57
|
+
roadmap: path.join(cwd, 'ROADMAP.md'),
|
|
58
|
+
visionState: path.join(cwd, '.compose', 'data', 'vision-state.json'),
|
|
59
|
+
features: path.join(cwd, paths.features || DEFAULT_PATHS.features),
|
|
60
|
+
journal: path.join(cwd, paths.journal || DEFAULT_PATHS.journal),
|
|
61
|
+
changelog: path.join(cwd, 'CHANGELOG.md'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Context loading
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function loadValidationContext(cwd, options = {}) {
|
|
70
|
+
const paths = resolveProjectPaths(cwd);
|
|
71
|
+
|
|
72
|
+
// ROADMAP — direct table-row scan. Validator can't depend on parseRoadmap()
|
|
73
|
+
// alone because lib/roadmap-parser.js:15 requires codes to end in -\d+
|
|
74
|
+
// (STRAT-1, COMP-UI-3). Codes like COMP-MCP-PUBLISH end with non-numeric
|
|
75
|
+
// suffixes and become _anon_*.
|
|
76
|
+
//
|
|
77
|
+
// Column-aware: parse the header row of each table to lock column indices
|
|
78
|
+
// for "Feature" (code) and "Status". This avoids false positives where
|
|
79
|
+
// status values (PARTIAL, COMPLETE) match the strict code regex, or where
|
|
80
|
+
// descriptions contain code-like uppercase tokens.
|
|
81
|
+
let roadmapRows = [];
|
|
82
|
+
try {
|
|
83
|
+
const text = fs.readFileSync(options.roadmapPath || paths.roadmap, 'utf8');
|
|
84
|
+
let phaseId = '';
|
|
85
|
+
let position = 0;
|
|
86
|
+
let codeIdx = -1, statusIdx = -1, descIdx = -1;
|
|
87
|
+
let inTable = false;
|
|
88
|
+
let sawSeparator = false;
|
|
89
|
+
|
|
90
|
+
for (const rawLine of text.split('\n')) {
|
|
91
|
+
const phaseMatch = rawLine.match(/^##\s+(.+?)(?:\s+—\s+.+)?$/);
|
|
92
|
+
if (phaseMatch) {
|
|
93
|
+
phaseId = phaseMatch[1].trim();
|
|
94
|
+
inTable = false; sawSeparator = false;
|
|
95
|
+
codeIdx = statusIdx = descIdx = -1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const rowMatch = rawLine.match(/^\|(.+)\|\s*$/);
|
|
99
|
+
if (!rowMatch) { inTable = false; sawSeparator = false; continue; }
|
|
100
|
+
|
|
101
|
+
const cols = rowMatch[1].split('|').map((c) => c.trim());
|
|
102
|
+
|
|
103
|
+
// Detect header row by column names. Recognize common column-name variants
|
|
104
|
+
// (feature/code/item/name) and (status/state) so non-canonical tables that
|
|
105
|
+
// still follow the convention are picked up (per Codex iter 1).
|
|
106
|
+
const lower = cols.map((c) => c.toLowerCase());
|
|
107
|
+
const featureColIdx = lower.findIndex((c) => ['feature', 'code', 'item', 'name'].includes(c));
|
|
108
|
+
const statusColIdx = lower.findIndex((c) => ['status', 'state'].includes(c));
|
|
109
|
+
if (featureColIdx >= 0 && statusColIdx >= 0) {
|
|
110
|
+
codeIdx = featureColIdx;
|
|
111
|
+
statusIdx = statusColIdx;
|
|
112
|
+
descIdx = lower.findIndex((c) => ['description', 'desc'].includes(c));
|
|
113
|
+
inTable = true; sawSeparator = false;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// Separator row (---|---|---)
|
|
117
|
+
if (cols.every((c) => /^[-:]+$/.test(c))) {
|
|
118
|
+
if (inTable) sawSeparator = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Data rows only after we've seen header + separator and have valid indices.
|
|
122
|
+
if (!inTable || !sawSeparator || codeIdx < 0 || statusIdx < 0) continue;
|
|
123
|
+
if (codeIdx >= cols.length || statusIdx >= cols.length) continue;
|
|
124
|
+
|
|
125
|
+
const codeRaw = cols[codeIdx].replace(/\*/g, '').replace(/`/g, '').trim();
|
|
126
|
+
if (!FEATURE_CODE_RE_STRICT.test(codeRaw)) continue;
|
|
127
|
+
const status = cols[statusIdx].replace(/\*/g, '').trim();
|
|
128
|
+
const description = descIdx >= 0 && descIdx < cols.length ? cols[descIdx] : '';
|
|
129
|
+
position += 1;
|
|
130
|
+
roadmapRows.push({ code: codeRaw, description, status, phaseId, position });
|
|
131
|
+
}
|
|
132
|
+
} catch (err) { /* ROADMAP missing — handled per-feature */ }
|
|
133
|
+
|
|
134
|
+
const roadmapByCode = new Map(roadmapRows.map((r) => [r.code, r]));
|
|
135
|
+
|
|
136
|
+
// Vision state
|
|
137
|
+
let visionItems = [];
|
|
138
|
+
let visionStateRaw = null;
|
|
139
|
+
try {
|
|
140
|
+
visionStateRaw = JSON.parse(fs.readFileSync(paths.visionState, 'utf8'));
|
|
141
|
+
visionItems = Array.isArray(visionStateRaw.items) ? visionStateRaw.items : [];
|
|
142
|
+
} catch { /* missing — handled per-feature */ }
|
|
143
|
+
|
|
144
|
+
const visionByCode = new Map();
|
|
145
|
+
for (const item of visionItems) {
|
|
146
|
+
const code = item.lifecycle?.featureCode || item.featureCode;
|
|
147
|
+
if (code && FEATURE_CODE_RE_STRICT.test(code)) visionByCode.set(code, item);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Feature folders
|
|
151
|
+
const foldersByCode = new Map();
|
|
152
|
+
if (fs.existsSync(paths.features)) {
|
|
153
|
+
for (const dirent of fs.readdirSync(paths.features, { withFileTypes: true })) {
|
|
154
|
+
if (!dirent.isDirectory()) continue;
|
|
155
|
+
const code = dirent.name;
|
|
156
|
+
if (!FEATURE_CODE_RE_STRICT.test(code)) continue;
|
|
157
|
+
const dir = path.join(paths.features, code);
|
|
158
|
+
const files = new Set();
|
|
159
|
+
try { for (const e of fs.readdirSync(dir, { withFileTypes: true })) files.add(e.name); } catch {}
|
|
160
|
+
const stat = fs.statSync(dir);
|
|
161
|
+
foldersByCode.set(code, {
|
|
162
|
+
dir,
|
|
163
|
+
files,
|
|
164
|
+
hasFeatureJson: files.has('feature.json'),
|
|
165
|
+
hasKilled: files.has('killed.md'),
|
|
166
|
+
mtime: stat.mtimeMs,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
cwd,
|
|
173
|
+
paths,
|
|
174
|
+
options,
|
|
175
|
+
roadmapByCode,
|
|
176
|
+
visionByCode,
|
|
177
|
+
visionItems,
|
|
178
|
+
visionStateRaw,
|
|
179
|
+
foldersByCode,
|
|
180
|
+
externalPrefixes: options.externalPrefixes || [],
|
|
181
|
+
featureJsonMode: options.featureJsonMode !== false,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function loadFeatureContext(cwd, code, ctx) {
|
|
186
|
+
const folder = ctx.foldersByCode.get(code);
|
|
187
|
+
let featureJson = null;
|
|
188
|
+
if (folder?.hasFeatureJson && ctx.featureJsonMode) {
|
|
189
|
+
// Read directly using the configured features path. readFeature() in
|
|
190
|
+
// lib/feature-json.js hardcodes docs/features and would miss configured
|
|
191
|
+
// overrides — fixed per Codex iter 1.
|
|
192
|
+
try {
|
|
193
|
+
const txt = fs.readFileSync(path.join(folder.dir, 'feature.json'), 'utf8');
|
|
194
|
+
featureJson = JSON.parse(txt);
|
|
195
|
+
} catch { featureJson = null; }
|
|
196
|
+
}
|
|
197
|
+
const roadmap = ctx.roadmapByCode.get(code);
|
|
198
|
+
const vision = ctx.visionByCode.get(code);
|
|
199
|
+
const killed = !!folder?.hasKilled;
|
|
200
|
+
return { code, folder, featureJson, roadmap, vision, killed };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Finding factory
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function finding(severity, kind, code, detail, source) {
|
|
208
|
+
const f = { severity, kind, detail };
|
|
209
|
+
if (code) f.feature_code = code;
|
|
210
|
+
if (source) f.source = source;
|
|
211
|
+
return f;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Per-feature checks
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function runKilledModeChecks(fctx, findings) {
|
|
219
|
+
const { code, folder, roadmap, vision, featureJson } = fctx;
|
|
220
|
+
// KILLED_STATUS_NOT_TERMINAL
|
|
221
|
+
const statuses = [];
|
|
222
|
+
if (roadmap?.status) statuses.push({ src: 'roadmap', val: String(roadmap.status).toUpperCase() });
|
|
223
|
+
if (featureJson?.status) statuses.push({ src: 'feature.json', val: String(featureJson.status).toUpperCase() });
|
|
224
|
+
if (vision?.status) statuses.push({ src: 'vision-state', val: String(vision.status).toUpperCase() });
|
|
225
|
+
for (const s of statuses) {
|
|
226
|
+
if (!TERMINAL_STATUSES.has(s.val)) {
|
|
227
|
+
findings.push(finding('error', 'KILLED_STATUS_NOT_TERMINAL', code,
|
|
228
|
+
`${s.src} status is ${s.val} but killed.md is present; expected KILLED or SUPERSEDED`));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// KILLED_SUCCESSOR_NOT_LINKED
|
|
232
|
+
try {
|
|
233
|
+
const killedText = fs.readFileSync(path.join(folder.dir, 'killed.md'), 'utf8');
|
|
234
|
+
const candidateCodes = new Set();
|
|
235
|
+
const re = /\b([A-Z][A-Z0-9-]*[A-Z0-9])\b/g;
|
|
236
|
+
let m;
|
|
237
|
+
while ((m = re.exec(killedText))) {
|
|
238
|
+
const candidate = m[1];
|
|
239
|
+
if (candidate !== code && FEATURE_CODE_RE_STRICT.test(candidate)) candidateCodes.add(candidate);
|
|
240
|
+
}
|
|
241
|
+
if (candidateCodes.size > 0) {
|
|
242
|
+
const links = featureJson?.links || [];
|
|
243
|
+
const supersedes = new Set(links.filter((l) => l.kind === 'supersedes').map((l) => l.to_code));
|
|
244
|
+
const matched = [...candidateCodes].some((c) => supersedes.has(c));
|
|
245
|
+
if (!matched) {
|
|
246
|
+
findings.push(finding('warning', 'KILLED_SUCCESSOR_NOT_LINKED', code,
|
|
247
|
+
`killed.md mentions ${[...candidateCodes].join(', ')} but no link with kind 'supersedes' targets any of them`));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch { /* killed.md not readable — skip */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function runSchemaChecks(fctx, ctx, findings) {
|
|
254
|
+
const { code, featureJson, vision, roadmap } = fctx;
|
|
255
|
+
if (featureJson) {
|
|
256
|
+
const v = getValidator(FEATURE_JSON_SCHEMA);
|
|
257
|
+
const r = v.validateRoot(featureJson);
|
|
258
|
+
if (!r.valid) {
|
|
259
|
+
for (const e of r.errors) {
|
|
260
|
+
findings.push(finding('error', 'FEATURE_JSON_SCHEMA_VIOLATION', code,
|
|
261
|
+
`${e.instancePath || '/'}: ${e.message}`));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (vision) {
|
|
266
|
+
// Validate the vision-state item against the Item subschema. Compose Ajv-friendly
|
|
267
|
+
// ref against the loaded schema's $defs.
|
|
268
|
+
const v = getValidator(VISION_STATE_SCHEMA);
|
|
269
|
+
if (!v._itemValidator) {
|
|
270
|
+
try {
|
|
271
|
+
v._itemValidator = v.ajv.compile({ $ref: `${v.schema.$id}#/definitions/Item` });
|
|
272
|
+
} catch { v._itemValidator = null; }
|
|
273
|
+
}
|
|
274
|
+
if (v._itemValidator) {
|
|
275
|
+
const ok = v._itemValidator(vision);
|
|
276
|
+
if (!ok) {
|
|
277
|
+
for (const e of (v._itemValidator.errors || [])) {
|
|
278
|
+
findings.push(finding('error', 'VISION_STATE_SCHEMA_VIOLATION', code,
|
|
279
|
+
`${e.instancePath || '/'}: ${e.message}`));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (roadmap) {
|
|
285
|
+
const v = getValidator(ROADMAP_ROW_SCHEMA);
|
|
286
|
+
const r = v.validateRoot(roadmap);
|
|
287
|
+
if (!r.valid) {
|
|
288
|
+
for (const e of r.errors) {
|
|
289
|
+
findings.push(finding('warning', 'ROADMAP_ROW_SCHEMA_VIOLATION', code,
|
|
290
|
+
`${e.instancePath || '/'}: ${e.message}`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function normalizeStatus(s) {
|
|
297
|
+
if (!s) return null;
|
|
298
|
+
return String(s).toUpperCase();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function runStateMismatchChecks(fctx, findings) {
|
|
302
|
+
const { code, roadmap, vision, featureJson } = fctx;
|
|
303
|
+
const rStatus = normalizeStatus(roadmap?.status);
|
|
304
|
+
const fStatus = normalizeStatus(featureJson?.status);
|
|
305
|
+
const vStatus = normalizeStatus(vision?.status);
|
|
306
|
+
// Status mismatches: error when both sources are post-PLANNED (real drift in
|
|
307
|
+
// active or shipped work). Warning when one side is PLANNED (commonly stale
|
|
308
|
+
// vision-state for shipped features — migration debt).
|
|
309
|
+
function statusSeverity(a, b) {
|
|
310
|
+
return (a === 'PLANNED' || b === 'PLANNED') ? 'warning' : 'error';
|
|
311
|
+
}
|
|
312
|
+
if (rStatus && fStatus && rStatus !== fStatus) {
|
|
313
|
+
findings.push(finding(statusSeverity(rStatus, fStatus),
|
|
314
|
+
'STATUS_MISMATCH_ROADMAP_VS_FEATUREJSON', code,
|
|
315
|
+
`ROADMAP says ${rStatus}, feature.json says ${fStatus}`));
|
|
316
|
+
}
|
|
317
|
+
if (rStatus && vStatus && rStatus !== vStatus) {
|
|
318
|
+
findings.push(finding(statusSeverity(rStatus, vStatus),
|
|
319
|
+
'STATUS_MISMATCH_ROADMAP_VS_VISION_STATE', code,
|
|
320
|
+
`ROADMAP says ${rStatus}, vision-state says ${vStatus}`));
|
|
321
|
+
}
|
|
322
|
+
if (fStatus && vStatus && fStatus !== vStatus) {
|
|
323
|
+
findings.push(finding(statusSeverity(fStatus, vStatus),
|
|
324
|
+
'STATUS_MISMATCH_FEATUREJSON_VS_VISION_STATE', code,
|
|
325
|
+
`feature.json says ${fStatus}, vision-state says ${vStatus}`));
|
|
326
|
+
}
|
|
327
|
+
// CONTRADICTORY_PHASE_CLAIM
|
|
328
|
+
const fPhase = featureJson?.phase || featureJson?.lifecycle?.currentPhase;
|
|
329
|
+
const vPhase = vision?.phase || vision?.lifecycle?.currentPhase;
|
|
330
|
+
if (fPhase && vPhase && fPhase !== vPhase) {
|
|
331
|
+
findings.push(finding('error', 'CONTRADICTORY_PHASE_CLAIM', code,
|
|
332
|
+
`feature.json phase '${fPhase}' vs vision-state phase '${vPhase}'`));
|
|
333
|
+
}
|
|
334
|
+
// COMPLEXITY_OR_DESCRIPTION_DRIFT
|
|
335
|
+
if (roadmap && featureJson) {
|
|
336
|
+
if (roadmap.description && featureJson.description &&
|
|
337
|
+
roadmap.description.trim() !== featureJson.description.trim() &&
|
|
338
|
+
// Tolerate ROADMAP descriptions being truncated with "..."
|
|
339
|
+
!roadmap.description.endsWith('...')) {
|
|
340
|
+
findings.push(finding('warning', 'COMPLEXITY_OR_DESCRIPTION_DRIFT', code,
|
|
341
|
+
`ROADMAP description differs from feature.json description`));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function runFolderRoadmapLinkageChecks(fctx, ctx, findings) {
|
|
347
|
+
const { code, folder, roadmap, vision, featureJson } = fctx;
|
|
348
|
+
const rStatus = normalizeStatus(roadmap?.status);
|
|
349
|
+
|
|
350
|
+
if (roadmap && !folder) {
|
|
351
|
+
// Severity model for missing folder:
|
|
352
|
+
// IN_PROGRESS → error (active work without a tracking artifact)
|
|
353
|
+
// PARTIAL / BLOCKED → warning (sub-tickets may live elsewhere; partial progress)
|
|
354
|
+
// PLANNED → warning (un-started work)
|
|
355
|
+
// COMPLETE / SUPERSEDED / KILLED / PARKED / unknown → warning (historical baseline)
|
|
356
|
+
if (rStatus === 'IN_PROGRESS') {
|
|
357
|
+
findings.push(finding('error', 'ROADMAP_ROW_WITHOUT_FOLDER', code,
|
|
358
|
+
`ROADMAP row status is IN_PROGRESS (active work) but no folder exists`));
|
|
359
|
+
} else {
|
|
360
|
+
findings.push(finding('warning', 'ROADMAP_ROW_WITHOUT_FOLDER', code,
|
|
361
|
+
`ROADMAP row status is ${rStatus || 'unknown'} but no folder (legacy/partial/planned baseline)`));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (folder && !roadmap) {
|
|
365
|
+
findings.push(finding('warning', 'FOLDER_WITHOUT_ROADMAP_ROW', code,
|
|
366
|
+
`Feature folder exists but no row in ROADMAP.md`));
|
|
367
|
+
}
|
|
368
|
+
if (folder && !folder.hasFeatureJson && ctx.featureJsonMode) {
|
|
369
|
+
findings.push(finding('info', 'FOLDER_WITHOUT_FEATURE_JSON', code,
|
|
370
|
+
`Feature folder exists but no feature.json`));
|
|
371
|
+
}
|
|
372
|
+
// EMPTY_FEATURE_FOLDER — folder has no design/plan/blueprint and no killed.md.
|
|
373
|
+
// Silent exemption for folders younger than 24h (mtime).
|
|
374
|
+
if (folder && !folder.hasKilled) {
|
|
375
|
+
const hasContent = ['design.md', 'plan.md', 'blueprint.md', 'prd.md', 'architecture.md', 'report.md']
|
|
376
|
+
.some((f) => folder.files.has(f));
|
|
377
|
+
if (!hasContent) {
|
|
378
|
+
const ageMs = Date.now() - folder.mtime;
|
|
379
|
+
if (ageMs > 24 * 60 * 60 * 1000) {
|
|
380
|
+
findings.push(finding('warning', 'EMPTY_FEATURE_FOLDER', code,
|
|
381
|
+
`Feature folder has no canonical artifacts (design/plan/blueprint/prd/architecture/report)`));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function runArtifactLinkChecks(fctx, ctx, findings) {
|
|
388
|
+
const { code, folder, featureJson } = fctx;
|
|
389
|
+
if (!folder) return;
|
|
390
|
+
const featureRootArg = ctx.paths.features;
|
|
391
|
+
const am = new ArtifactManager(featureRootArg);
|
|
392
|
+
let assessment = null;
|
|
393
|
+
try { assessment = am.assess(code); } catch { /* fall through */ }
|
|
394
|
+
|
|
395
|
+
// MISSING_DESIGN_ARTIFACT — error for active work, warning for COMPLETE (legacy may not have design.md).
|
|
396
|
+
const status = normalizeStatus(featureJson?.status) || normalizeStatus(fctx.roadmap?.status);
|
|
397
|
+
const ACTIVE_STATUSES = new Set(['IN_PROGRESS', 'PARTIAL', 'BLOCKED']);
|
|
398
|
+
if (status && !folder.files.has('design.md')) {
|
|
399
|
+
if (ACTIVE_STATUSES.has(status)) {
|
|
400
|
+
findings.push(finding('error', 'MISSING_DESIGN_ARTIFACT', code,
|
|
401
|
+
`Feature is ${status} (active work) but design.md is missing`));
|
|
402
|
+
} else if (status === 'COMPLETE') {
|
|
403
|
+
findings.push(finding('warning', 'MISSING_DESIGN_ARTIFACT', code,
|
|
404
|
+
`Feature is COMPLETE but design.md is missing (legacy migration debt; current writers do not enforce this retroactively)`));
|
|
405
|
+
}
|
|
406
|
+
// PLANNED / SUPERSEDED / KILLED / PARKED — no finding (expected baseline)
|
|
407
|
+
}
|
|
408
|
+
// MISSING_COMPLETION_REPORT (warning, COMPLETE)
|
|
409
|
+
if (status === 'COMPLETE' && !folder.files.has('report.md')) {
|
|
410
|
+
findings.push(finding('warning', 'MISSING_COMPLETION_REPORT', code,
|
|
411
|
+
`Feature is COMPLETE but report.md is missing (current writers do not enforce this; warning only)`));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// DANGLING_ARTIFACT_LINK + ARTIFACT_OUTSIDE_FEATURE_FOLDER
|
|
415
|
+
const links = featureJson?.artifacts || [];
|
|
416
|
+
for (const a of links) {
|
|
417
|
+
const artPath = a.path && path.isAbsolute(a.path) ? a.path : path.join(ctx.cwd, a.path || '');
|
|
418
|
+
if (a.path && !fs.existsSync(artPath)) {
|
|
419
|
+
findings.push(finding('error', 'DANGLING_ARTIFACT_LINK', code,
|
|
420
|
+
`Linked ${a.type || 'artifact'} path does not exist: ${a.path}`));
|
|
421
|
+
}
|
|
422
|
+
if (a.path && a.type !== 'journal' && a.type !== 'snapshot') {
|
|
423
|
+
// Boundary-aware check with .. normalization: paths like
|
|
424
|
+
// /root/docs/features/FEAT-1/../FEAT-2/plan.md resolve outside FEAT-1
|
|
425
|
+
// even though they syntactically start with the folder path. Resolve
|
|
426
|
+
// both sides before comparing (per Codex iter 2).
|
|
427
|
+
const normArt = path.resolve(artPath);
|
|
428
|
+
const normFolder = path.resolve(folder.dir);
|
|
429
|
+
const inFolder = normArt === normFolder || normArt.startsWith(normFolder + path.sep);
|
|
430
|
+
if (!inFolder) {
|
|
431
|
+
findings.push(finding('error', 'ARTIFACT_OUTSIDE_FEATURE_FOLDER', code,
|
|
432
|
+
`Linked ${a.type} ${a.path} is not under ${path.relative(ctx.cwd, folder.dir)}`));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function runCrossFeatureRefChecks(fctx, ctx, findings) {
|
|
439
|
+
const { code, featureJson } = fctx;
|
|
440
|
+
if (!featureJson?.links) return;
|
|
441
|
+
for (const link of featureJson.links) {
|
|
442
|
+
if (!link.to_code) continue;
|
|
443
|
+
const target = ctx.foldersByCode.has(link.to_code) ||
|
|
444
|
+
ctx.roadmapByCode.has(link.to_code) ||
|
|
445
|
+
ctx.visionByCode.has(link.to_code);
|
|
446
|
+
if (!target) {
|
|
447
|
+
findings.push(finding('error', 'DANGLING_LINK_FEATURES_TARGET', code,
|
|
448
|
+
`link kind=${link.kind} → ${link.to_code} does not exist in any source`));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// UNREFERENCED_FOLLOWUP — design.md mentions a code as parent but no typed link
|
|
452
|
+
const design = path.join(fctx.folder?.dir || '', 'design.md');
|
|
453
|
+
if (fs.existsSync(design)) {
|
|
454
|
+
let text = '';
|
|
455
|
+
try { text = fs.readFileSync(design, 'utf8'); } catch {}
|
|
456
|
+
const linkedTo = new Set((featureJson.links || []).map((l) => l.to_code));
|
|
457
|
+
const matches = text.match(/\bparent[^.]*?([A-Z][A-Z0-9-]*[A-Z0-9])/i);
|
|
458
|
+
if (matches && matches[1] && matches[1] !== code && !linkedTo.has(matches[1])) {
|
|
459
|
+
findings.push(finding('info', 'UNREFERENCED_FOLLOWUP', code,
|
|
460
|
+
`design.md mentions parent ${matches[1]} but no typed link references it`));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// SUPERSEDED_WITHOUT_LINK — non-killed feature in SUPERSEDED status without a
|
|
464
|
+
// typed `supersedes` link to the successor.
|
|
465
|
+
const status = normalizeStatus(featureJson?.status) || normalizeStatus(fctx.roadmap?.status);
|
|
466
|
+
if (status === 'SUPERSEDED' && !fctx.killed) {
|
|
467
|
+
const links = featureJson?.links || [];
|
|
468
|
+
const hasSupersedes = links.some((l) => l.kind === 'supersedes' && l.to_code);
|
|
469
|
+
if (!hasSupersedes) {
|
|
470
|
+
findings.push(finding('info', 'SUPERSEDED_WITHOUT_LINK', code,
|
|
471
|
+
`Feature is SUPERSEDED but has no link with kind 'supersedes' identifying the successor`));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function runCoherenceChecks(fctx, ctx, findings) {
|
|
477
|
+
const { code, featureJson } = fctx;
|
|
478
|
+
// COMPLETION_WITHOUT_CHANGELOG — check at project level, but per-feature too
|
|
479
|
+
const status = normalizeStatus(featureJson?.status) || normalizeStatus(fctx.roadmap?.status);
|
|
480
|
+
if (status === 'COMPLETE' || status === 'PARTIAL') {
|
|
481
|
+
let changelog = '';
|
|
482
|
+
try { changelog = fs.readFileSync(ctx.paths.changelog, 'utf8'); } catch {}
|
|
483
|
+
const headerRe = new RegExp(`^###\\s+${code.replace(/[-/\\^$*+?.()|[\\]{}]/g, '\\$&')}\\b`, 'm');
|
|
484
|
+
if (changelog && !headerRe.test(changelog)) {
|
|
485
|
+
findings.push(finding('warning', 'COMPLETION_WITHOUT_CHANGELOG', code,
|
|
486
|
+
`Feature is ${status} but no CHANGELOG.md entry references the code`));
|
|
487
|
+
}
|
|
488
|
+
// MISSING_COMPLETION_JOURNAL — heuristic: any journal file mentions the code
|
|
489
|
+
if (status === 'COMPLETE') {
|
|
490
|
+
const journalDir = ctx.paths.journal;
|
|
491
|
+
let mentioned = false;
|
|
492
|
+
try {
|
|
493
|
+
for (const f of fs.readdirSync(journalDir)) {
|
|
494
|
+
if (!f.endsWith('.md') || f === 'README.md') continue;
|
|
495
|
+
try {
|
|
496
|
+
const text = fs.readFileSync(path.join(journalDir, f), 'utf8');
|
|
497
|
+
if (text.includes(code)) { mentioned = true; break; }
|
|
498
|
+
} catch {}
|
|
499
|
+
}
|
|
500
|
+
} catch {}
|
|
501
|
+
if (!mentioned) {
|
|
502
|
+
findings.push(finding('warning', 'MISSING_COMPLETION_JOURNAL', code,
|
|
503
|
+
`Feature is COMPLETE but no journal entry references the code`));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Project-level cross-cutting checks
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
function runOrphanFolderCheck(ctx, findings) {
|
|
514
|
+
for (const [code, folder] of ctx.foldersByCode) {
|
|
515
|
+
const inRoadmap = ctx.roadmapByCode.has(code);
|
|
516
|
+
const inVision = ctx.visionByCode.has(code);
|
|
517
|
+
if (!inRoadmap && !inVision && !folder.hasKilled) {
|
|
518
|
+
// Downgrade to info for folders matching externalPrefixes
|
|
519
|
+
const isExternal = ctx.externalPrefixes.some((p) => code.startsWith(p));
|
|
520
|
+
const sev = isExternal ? 'info' : 'warning';
|
|
521
|
+
findings.push(finding(sev, 'ORPHAN_FOLDER', code,
|
|
522
|
+
`Feature folder exists but code is in neither ROADMAP nor vision-state`));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function runChangelogReferenceCheck(ctx, findings) {
|
|
528
|
+
let text = '';
|
|
529
|
+
try { text = fs.readFileSync(ctx.paths.changelog, 'utf8'); } catch { return; }
|
|
530
|
+
const headerRe = /^###\s+([A-Z][A-Z0-9-]*[A-Z0-9])(?:\s|$)/gm;
|
|
531
|
+
let m;
|
|
532
|
+
while ((m = headerRe.exec(text))) {
|
|
533
|
+
const code = m[1];
|
|
534
|
+
if (!ctx.roadmapByCode.has(code) && !ctx.foldersByCode.has(code) && !ctx.visionByCode.has(code)) {
|
|
535
|
+
// Downgraded from error: many shipped features have CHANGELOG entries without
|
|
536
|
+
// ROADMAP rows or vision-state items (legacy pattern where CHANGELOG is the
|
|
537
|
+
// source of truth for what shipped). Until COMP-FEATURE-FOLDER-BASELINE-CLEANUP
|
|
538
|
+
// lands, this is migration debt, not regression.
|
|
539
|
+
findings.push(finding('warning', 'CHANGELOG_MENTIONS_MISSING_FEATURE', code,
|
|
540
|
+
`CHANGELOG entry header references feature code with no ROADMAP/vision-state/folder (legacy pattern)`));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function runJournalIndexDriftCheck(ctx, findings) {
|
|
546
|
+
const indexPath = path.join(ctx.paths.journal, 'README.md');
|
|
547
|
+
let indexText = '';
|
|
548
|
+
try { indexText = fs.readFileSync(indexPath, 'utf8'); } catch { return; }
|
|
549
|
+
const indexedFiles = new Set();
|
|
550
|
+
const linkRe = /\(([\d-]+-session-\d+-[^)]+\.md)\)/g;
|
|
551
|
+
let m;
|
|
552
|
+
while ((m = linkRe.exec(indexText))) indexedFiles.add(m[1]);
|
|
553
|
+
let actualFiles = new Set();
|
|
554
|
+
try {
|
|
555
|
+
for (const f of fs.readdirSync(ctx.paths.journal)) {
|
|
556
|
+
if (f === 'README.md' || !f.endsWith('.md')) continue;
|
|
557
|
+
actualFiles.add(f);
|
|
558
|
+
}
|
|
559
|
+
} catch { return; }
|
|
560
|
+
for (const f of indexedFiles) {
|
|
561
|
+
if (!actualFiles.has(f)) {
|
|
562
|
+
findings.push(finding('error', 'JOURNAL_INDEX_VS_FILES_DRIFT', null,
|
|
563
|
+
`Journal index references ${f} but the file does not exist`));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
for (const f of actualFiles) {
|
|
567
|
+
if (!indexedFiles.has(f)) {
|
|
568
|
+
findings.push(finding('error', 'JOURNAL_INDEX_VS_FILES_DRIFT', null,
|
|
569
|
+
`Journal file ${f} exists but is not in the index`));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// Public API
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
export async function validateFeature(cwd, code, options = {}) {
|
|
579
|
+
validateCode(code);
|
|
580
|
+
const ctx = loadValidationContext(cwd, options);
|
|
581
|
+
const findings = [];
|
|
582
|
+
|
|
583
|
+
// FEATURE_NOT_FOUND — uniform shape rather than throw.
|
|
584
|
+
const exists = ctx.foldersByCode.has(code) || ctx.roadmapByCode.has(code) || ctx.visionByCode.has(code);
|
|
585
|
+
if (!exists) {
|
|
586
|
+
findings.push(finding('error', 'FEATURE_NOT_FOUND', code,
|
|
587
|
+
`Feature code is strict-regex-valid but exists in no source (no folder, no ROADMAP row, no vision-state item)`));
|
|
588
|
+
return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const fctx = loadFeatureContext(cwd, code, ctx);
|
|
592
|
+
|
|
593
|
+
if (fctx.killed) {
|
|
594
|
+
runKilledModeChecks(fctx, findings);
|
|
595
|
+
return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
runSchemaChecks(fctx, ctx, findings);
|
|
599
|
+
runStateMismatchChecks(fctx, findings);
|
|
600
|
+
runFolderRoadmapLinkageChecks(fctx, ctx, findings);
|
|
601
|
+
runArtifactLinkChecks(fctx, ctx, findings);
|
|
602
|
+
runCrossFeatureRefChecks(fctx, ctx, findings);
|
|
603
|
+
runCoherenceChecks(fctx, ctx, findings);
|
|
604
|
+
|
|
605
|
+
return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export async function validateProject(cwd, options = {}) {
|
|
609
|
+
const ctx = loadValidationContext(cwd, options);
|
|
610
|
+
const findings = [];
|
|
611
|
+
|
|
612
|
+
const allCodes = new Set([
|
|
613
|
+
...ctx.roadmapByCode.keys(),
|
|
614
|
+
...ctx.visionByCode.keys(),
|
|
615
|
+
...ctx.foldersByCode.keys(),
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
for (const code of allCodes) {
|
|
619
|
+
if (!FEATURE_CODE_RE_STRICT.test(code)) continue;
|
|
620
|
+
const result = await validateFeature(cwd, code, options);
|
|
621
|
+
findings.push(...result.findings);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
runOrphanFolderCheck(ctx, findings);
|
|
625
|
+
runChangelogReferenceCheck(ctx, findings);
|
|
626
|
+
runJournalIndexDriftCheck(ctx, findings);
|
|
627
|
+
|
|
628
|
+
return { scope: 'project', validated_at: nowIso(), findings };
|
|
629
|
+
}
|