@polymorphism-tech/morph-spec 7.1.1 → 7.3.0
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 +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +4 -2
- package/framework/MORPH.md +3 -3
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-proposal.md +1 -1
- package/framework/hooks/README.md +1 -1
- package/framework/hooks/claude-code/core/protect-spec-files.js +193 -72
- package/framework/hooks/claude-code/core/validate-completion.js +136 -136
- package/framework/hooks/shared/phase-utils.js +10 -0
- package/framework/phases.json +1 -1
- package/framework/rules/morph-workflow.md +16 -9
- package/framework/skills/README.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -0
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -0
- package/framework/skills/core/morph-eval/SKILL.md +1 -0
- package/framework/skills/core/morph-implement/SKILL.md +7 -0
- package/framework/skills/core/morph-plan/SKILL.md +1 -0
- package/framework/skills/core/morph-proposal/SKILL.md +1 -0
- package/framework/skills/core/morph-review/SKILL.md +1 -0
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -0
- package/framework/skills/core/morph-standards/SKILL.md +1 -0
- package/framework/templates/README.md +708 -709
- package/framework/templates/REGISTRY.json +1 -20
- package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
- package/framework/templates/docs/proposal.md +171 -171
- package/framework/templates/feature/recap.md +145 -145
- package/package.json +1 -1
- package/src/commands/project/doctor.js +941 -946
- package/src/commands/project/status.js +373 -376
- package/src/commands/validation/validate-feature.js +315 -287
- package/src/core/paths/output-schema.js +18 -1
- package/src/core/state/state-manager.js +900 -934
- package/src/dashboard/app/App.jsx +29 -31
- package/src/dashboard/app/components/Sidebar.jsx +44 -45
- package/src/dashboard/scanner/index.js +20 -21
- package/src/lib/context/context-builder.js +3 -3
- package/src/lib/generators/context-generator.js +2 -3
- package/src/lib/generators/mandate-generator.js +48 -92
- package/src/lib/generators/metadata-extractor.js +385 -386
- package/src/lib/generators/recap-generator.js +285 -285
- package/src/lib/validators/content/content-validator.js +89 -286
- package/src/lib/validators/content/index.js +0 -1
- package/framework/templates/feature/tasks.md +0 -199
- package/framework/workflows/README.md +0 -1041
- package/framework/workflows/configs/fast-track.json +0 -46
- package/framework/workflows/configs/full.json +0 -64
- package/framework/workflows/configs/quick.json +0 -32
- package/framework/workflows/configs/standard.json +0 -57
- package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +0 -392
- package/framework/workflows/docs/design-impl.md +0 -37
- package/framework/workflows/docs/enforcement-pipeline.md +0 -668
- package/framework/workflows/docs/fast-track.md +0 -29
- package/framework/workflows/docs/full-morph.md +0 -69
- package/framework/workflows/docs/standard.md +0 -49
- package/framework/workflows/docs/ui-refresh.md +0 -39
- package/src/commands/state/index.js +0 -7
- package/src/core/workflows/workflow-detector.js +0 -586
- package/src/core/workflows/workflow-router.js +0 -204
- package/src/dashboard/app/pages/Workflows.jsx +0 -346
- package/src/dashboard/scanner/workflows-scanner.js +0 -18
- package/src/lib/phase-chain/phase-validator.js +0 -359
- package/src/lib/validators/content/cross-phase-validator.js +0 -281
- package/src/lib/validators/spec-validator.js +0 -312
|
@@ -1,359 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase Validator — centralized phase validation and eligibility checking.
|
|
3
|
-
*
|
|
4
|
-
* Exports: PHASES, validatePhase, computePassRate, checkPhaseEligibility
|
|
5
|
-
*
|
|
6
|
-
* Callers:
|
|
7
|
-
* - src/commands/state/advance-phase.js
|
|
8
|
-
* - src/commands/state/phase-runner.js
|
|
9
|
-
* - src/commands/project/status.js
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import fs from 'fs';
|
|
13
|
-
import path, { join } from 'path';
|
|
14
|
-
import { loadState, derivePhase, getFeature } from '../../core/state/state-manager.js';
|
|
15
|
-
import { getWorkflowConfig } from '../../core/workflows/workflow-detector.js';
|
|
16
|
-
|
|
17
|
-
// v5: trust-manager removed. Auto-approval is no longer a CLI feature —
|
|
18
|
-
// approval gates are handled by Claude presenting the gate via skill template
|
|
19
|
-
// and the user responding. Stub to keep legacy callers happy.
|
|
20
|
-
const shouldAutoApprove = () => false;
|
|
21
|
-
|
|
22
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
-
// Phase definitions (from validate-phase.js)
|
|
24
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
export const PHASES = {
|
|
27
|
-
'proposal': {
|
|
28
|
-
order: 0,
|
|
29
|
-
name: 'FASE 0: PROPOSAL',
|
|
30
|
-
requiredOutputs: [],
|
|
31
|
-
description: 'Initial proposal and agent detection'
|
|
32
|
-
},
|
|
33
|
-
'uiux': {
|
|
34
|
-
order: 1,
|
|
35
|
-
name: 'FASE 1: UI/UX DESIGN',
|
|
36
|
-
requiredOutputs: ['proposal.md'],
|
|
37
|
-
optionalOutputs: ['design-system.md', 'mockups.md', 'components.md', 'flows.md'],
|
|
38
|
-
description: 'UI/UX design (conditional - only if frontend)',
|
|
39
|
-
optional: true
|
|
40
|
-
},
|
|
41
|
-
'plan': {
|
|
42
|
-
order: 2,
|
|
43
|
-
name: 'FASE 2: PLAN',
|
|
44
|
-
requiredOutputs: ['proposal.md'],
|
|
45
|
-
description: 'Technical specification, implementation plan, task breakdown, and mandate'
|
|
46
|
-
},
|
|
47
|
-
'implement': {
|
|
48
|
-
order: 3,
|
|
49
|
-
name: 'FASE 3: IMPLEMENT',
|
|
50
|
-
requiredOutputs: ['proposal.md', 'spec.md', 'tasks.md'],
|
|
51
|
-
description: 'Execute tasks and implement code'
|
|
52
|
-
},
|
|
53
|
-
'review': {
|
|
54
|
-
order: 4,
|
|
55
|
-
name: 'FASE 4: REVIEW',
|
|
56
|
-
requiredOutputs: ['proposal.md', 'spec.md', 'mandate.md', 'tasks.md', 'recap.md', 'review-report.md'],
|
|
57
|
-
description: 'Post-implementation quality review against mandate'
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
// Map output filenames to their phase subfolders (v5)
|
|
62
|
-
const OUTPUT_PHASE_MAP = {
|
|
63
|
-
'proposal.md': '0-proposal',
|
|
64
|
-
'stack-scan.md': '0-proposal',
|
|
65
|
-
'migration-plan.md': '0-proposal',
|
|
66
|
-
'spec.md': '2-plan',
|
|
67
|
-
'mandate.md': '2-plan',
|
|
68
|
-
'decisions.md': '2-plan',
|
|
69
|
-
'design-system.md': '1-design',
|
|
70
|
-
'mockups.md': '1-design',
|
|
71
|
-
'components.md': '1-design',
|
|
72
|
-
'flows.md': '1-design',
|
|
73
|
-
'tasks.md': '2-plan',
|
|
74
|
-
'recap.md': '3-implement',
|
|
75
|
-
'review-report.md': '4-review'
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
function checkOutput(featurePath, outputFile) {
|
|
79
|
-
const phaseDir = OUTPUT_PHASE_MAP[outputFile] || '';
|
|
80
|
-
const filePath = path.join(featurePath, phaseDir, outputFile);
|
|
81
|
-
if (fs.existsSync(filePath)) return true;
|
|
82
|
-
// Fallback: check feature root (some generators place files there instead of phase subdirs)
|
|
83
|
-
if (phaseDir) {
|
|
84
|
-
return fs.existsSync(path.join(featurePath, outputFile));
|
|
85
|
-
}
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Validate phase prerequisites for a feature.
|
|
91
|
-
* Used by advance-phase.js before advancing.
|
|
92
|
-
*/
|
|
93
|
-
export function validatePhase(featureName, targetPhase) {
|
|
94
|
-
const featurePath = path.join(process.cwd(), '.morph/features', featureName);
|
|
95
|
-
|
|
96
|
-
if (!fs.existsSync(featurePath)) {
|
|
97
|
-
return {
|
|
98
|
-
valid: false,
|
|
99
|
-
error: `Feature directory not found: ${featurePath}`,
|
|
100
|
-
suggestion: `Run 'morph-spec state init ${featureName}' to start`,
|
|
101
|
-
missingOutputs: [],
|
|
102
|
-
phase: null
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const phaseDefinition = PHASES[targetPhase];
|
|
107
|
-
if (!phaseDefinition) {
|
|
108
|
-
return {
|
|
109
|
-
valid: false,
|
|
110
|
-
error: `Unknown phase: ${targetPhase}`,
|
|
111
|
-
validPhases: Object.keys(PHASES),
|
|
112
|
-
missingOutputs: [],
|
|
113
|
-
phase: null
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Workflow-aware: only require outputs that the workflow declares as required.
|
|
118
|
-
// Fast-track skips design/plan, so spec.md and plan.md won't exist.
|
|
119
|
-
let requiredOutputs = phaseDefinition.requiredOutputs;
|
|
120
|
-
try {
|
|
121
|
-
const feature = getFeature(featureName);
|
|
122
|
-
if (feature?.workflow && feature.workflow !== 'auto') {
|
|
123
|
-
const wfConfig = getWorkflowConfig(feature.workflow);
|
|
124
|
-
if (wfConfig?.outputs?.required) {
|
|
125
|
-
const workflowRequired = new Set(wfConfig.outputs.required);
|
|
126
|
-
requiredOutputs = requiredOutputs.filter(o => workflowRequired.has(o));
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
} catch {
|
|
130
|
-
// Fall through to default requiredOutputs
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const missingOutputs = [];
|
|
134
|
-
for (const output of requiredOutputs) {
|
|
135
|
-
if (!checkOutput(featurePath, output)) {
|
|
136
|
-
missingOutputs.push(output);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Special validation: implement phase requires tasks.md to have actual tasks
|
|
141
|
-
if (targetPhase === 'implement') {
|
|
142
|
-
const tasksFilePath = path.join(featurePath, '2-plan', 'tasks.md');
|
|
143
|
-
if (fs.existsSync(tasksFilePath)) {
|
|
144
|
-
const content = fs.readFileSync(tasksFilePath, 'utf-8');
|
|
145
|
-
const taskCount = (content.match(/^###\s+T\d+/gm) || []).length;
|
|
146
|
-
if (taskCount === 0) {
|
|
147
|
-
missingOutputs.push(`tasks.md exists but has no task entries — add ### T001: Task title entries`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Warn if skipping phases
|
|
153
|
-
let stateWarning = null;
|
|
154
|
-
try {
|
|
155
|
-
const feature = getFeature(featureName);
|
|
156
|
-
if (feature) {
|
|
157
|
-
const currentPhaseOrder = PHASES[feature.phase]?.order ?? -1;
|
|
158
|
-
const targetPhaseOrder = phaseDefinition.order;
|
|
159
|
-
if (targetPhaseOrder > currentPhaseOrder + 1) {
|
|
160
|
-
stateWarning = `Skipping phases: current is '${feature.phase}' (order ${currentPhaseOrder}), target is '${targetPhase}' (order ${targetPhaseOrder})`;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
} catch {
|
|
164
|
-
// State file may not exist
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
valid: missingOutputs.length === 0,
|
|
169
|
-
error: null,
|
|
170
|
-
missingOutputs,
|
|
171
|
-
phase: phaseDefinition,
|
|
172
|
-
featurePath,
|
|
173
|
-
stateWarning
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
-
// Phase eligibility
|
|
179
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
const PHASE_ORDER = ['proposal', 'uiux', 'plan', 'implement', 'review'];
|
|
182
|
-
|
|
183
|
-
function getNextPhase(currentPhase, featureName) {
|
|
184
|
-
const idx = PHASE_ORDER.indexOf(currentPhase);
|
|
185
|
-
if (idx === -1 || idx >= PHASE_ORDER.length - 1) return null;
|
|
186
|
-
|
|
187
|
-
// Workflow-aware: skip phases not in the workflow's run list
|
|
188
|
-
let workflowConfig = null;
|
|
189
|
-
if (featureName) {
|
|
190
|
-
try {
|
|
191
|
-
const feature = getFeature(featureName);
|
|
192
|
-
if (feature?.workflow && feature.workflow !== 'auto') {
|
|
193
|
-
workflowConfig = getWorkflowConfig(feature.workflow);
|
|
194
|
-
}
|
|
195
|
-
} catch { /* fall through */ }
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
for (let i = idx + 1; i < PHASE_ORDER.length; i++) {
|
|
199
|
-
const candidate = PHASE_ORDER[i];
|
|
200
|
-
if (workflowConfig?.phases?.run && !workflowConfig.phases.run.includes(candidate)) {
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
return candidate;
|
|
204
|
-
}
|
|
205
|
-
return null;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Compute overall validation pass rate from validationHistory.
|
|
210
|
-
* Returns null when no history exists.
|
|
211
|
-
*/
|
|
212
|
-
export function computePassRate(validationHistory) {
|
|
213
|
-
if (!validationHistory || Object.keys(validationHistory).length === 0) return null;
|
|
214
|
-
const entries = Object.values(validationHistory);
|
|
215
|
-
const passed = entries.filter(e => e.status === 'passed').length;
|
|
216
|
-
return entries.length > 0 ? passed / entries.length : null;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Map output type names to file paths for eligibility checks (v5)
|
|
220
|
-
const OUTPUT_PATH_MAP = {
|
|
221
|
-
'proposal': '0-proposal/proposal.md',
|
|
222
|
-
'stackScan': '0-proposal/stack-scan.md',
|
|
223
|
-
'migrationPlan': '0-proposal/migration-plan.md',
|
|
224
|
-
'spec': '2-plan/spec.md',
|
|
225
|
-
'mandate': '2-plan/mandate.md',
|
|
226
|
-
'tasks': '2-plan/tasks.md',
|
|
227
|
-
'decisions': '2-plan/decisions.md',
|
|
228
|
-
'recap': '3-implement/recap.md',
|
|
229
|
-
'reviewReport': '4-review/review-report.md',
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
function getMissingRequiredOutputs(featureName, phase, projectPath) {
|
|
233
|
-
const phaseDef = PHASES[phase];
|
|
234
|
-
if (!phaseDef?.requiredOutputs) return [];
|
|
235
|
-
|
|
236
|
-
const missing = [];
|
|
237
|
-
const featureBase = join(projectPath, '.morph', 'features', featureName);
|
|
238
|
-
|
|
239
|
-
for (const output of phaseDef.requiredOutputs) {
|
|
240
|
-
const relPath = OUTPUT_PATH_MAP[output];
|
|
241
|
-
if (relPath && !fs.existsSync(join(featureBase, relPath))) {
|
|
242
|
-
missing.push(output);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return missing;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function getBlockedTasks(feature) {
|
|
250
|
-
const history = feature.validationHistory || {};
|
|
251
|
-
return Object.entries(history)
|
|
252
|
-
.filter(([, entry]) => entry.status === 'blocked')
|
|
253
|
-
.map(([taskId]) => taskId);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Check whether a feature is eligible to auto-advance to the next phase.
|
|
258
|
-
* Used by phase-runner.js.
|
|
259
|
-
*/
|
|
260
|
-
export function checkPhaseEligibility(featureName, opts = {}) {
|
|
261
|
-
const projectPath = opts.projectPath || process.cwd();
|
|
262
|
-
|
|
263
|
-
const state = loadState(false);
|
|
264
|
-
if (!state) {
|
|
265
|
-
return {
|
|
266
|
-
eligible: false,
|
|
267
|
-
blockers: [{ type: 'state_error', items: ['state.json not found'] }],
|
|
268
|
-
currentPhase: 'unknown',
|
|
269
|
-
nextPhase: null,
|
|
270
|
-
passRate: null,
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const feature = state.features?.[featureName];
|
|
275
|
-
if (!feature) {
|
|
276
|
-
return {
|
|
277
|
-
eligible: false,
|
|
278
|
-
blockers: [{ type: 'state_error', items: [`Feature '${featureName}' not found in state.json`] }],
|
|
279
|
-
currentPhase: 'unknown',
|
|
280
|
-
nextPhase: null,
|
|
281
|
-
passRate: null,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const featureFolderPath = join(projectPath, '.morph', 'features', featureName);
|
|
286
|
-
const currentPhase = feature.phase || derivePhase(featureFolderPath);
|
|
287
|
-
const nextPhase = getNextPhase(currentPhase, featureName);
|
|
288
|
-
|
|
289
|
-
if (!nextPhase) {
|
|
290
|
-
return {
|
|
291
|
-
eligible: false,
|
|
292
|
-
blockers: [{ type: 'state_error', items: ['Feature is at the final phase'] }],
|
|
293
|
-
currentPhase,
|
|
294
|
-
nextPhase: null,
|
|
295
|
-
passRate: null,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
let workflowConfig = null;
|
|
300
|
-
if (feature.workflow && feature.workflow !== 'auto') {
|
|
301
|
-
try {
|
|
302
|
-
workflowConfig = getWorkflowConfig(feature.workflow);
|
|
303
|
-
} catch {
|
|
304
|
-
// Non-blocking: fall through to defaults
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const minPassRate = workflowConfig?.phaseChain?.pauseOn
|
|
309
|
-
? 0.80
|
|
310
|
-
: workflowConfig?.eligibility?.minPassRate ?? 0.80;
|
|
311
|
-
|
|
312
|
-
const blockers = [];
|
|
313
|
-
|
|
314
|
-
const missingOutputs = getMissingRequiredOutputs(featureName, currentPhase, projectPath);
|
|
315
|
-
if (missingOutputs.length > 0) {
|
|
316
|
-
blockers.push({ type: 'missing_outputs', items: missingOutputs });
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const blockedTasks = getBlockedTasks(feature);
|
|
320
|
-
if (blockedTasks.length > 0) {
|
|
321
|
-
blockers.push({ type: 'blocked_tasks', items: blockedTasks });
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const passRate = computePassRate(feature.validationHistory);
|
|
325
|
-
if (passRate !== null && passRate < minPassRate) {
|
|
326
|
-
blockers.push({
|
|
327
|
-
type: 'low_pass_rate',
|
|
328
|
-
current: passRate,
|
|
329
|
-
required: minPassRate,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
try {
|
|
334
|
-
const requiredGateMap = { plan: 'plan', uiux: 'uiux', review: 'review' };
|
|
335
|
-
const gate = requiredGateMap[currentPhase];
|
|
336
|
-
if (gate) {
|
|
337
|
-
const trustResult = shouldAutoApprove(featureName, gate);
|
|
338
|
-
if (!trustResult.autoApprove) {
|
|
339
|
-
const gateStatus = feature.approvalGates?.[gate];
|
|
340
|
-
if (!gateStatus?.approved) {
|
|
341
|
-
blockers.push({
|
|
342
|
-
type: 'trust_too_low',
|
|
343
|
-
items: [`Gate '${gate}' requires trust level ${trustResult.level || 'medium+'}`],
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
} catch {
|
|
349
|
-
// Trust manager unavailable — non-blocking
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return {
|
|
353
|
-
eligible: blockers.length === 0,
|
|
354
|
-
blockers,
|
|
355
|
-
currentPhase,
|
|
356
|
-
nextPhase,
|
|
357
|
-
passRate,
|
|
358
|
-
};
|
|
359
|
-
}
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cross-Phase Validator
|
|
3
|
-
*
|
|
4
|
-
* Validates consistency across morph-spec phase outputs:
|
|
5
|
-
* - FR coverage: every spec FR is referenced by at least one task
|
|
6
|
-
* - Mandate integrity: mandate includes all task IDs with acceptance criteria
|
|
7
|
-
* - Contract alignment: entities in spec Data Model appear in contracts
|
|
8
|
-
* - Dependency ordering: task dependencies are topologically valid
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { readFileSync, existsSync } from 'fs';
|
|
12
|
-
import { join } from 'path';
|
|
13
|
-
import { parseFRSections, parseContracts, extractTaskIds } from '../../generators/mandate-generator.js';
|
|
14
|
-
import { getAbsoluteOutputPath } from '../../../core/paths/output-schema.js';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* v5: parseTaskFromMarkdown lived in sprint-contract-generator. Inlined here as
|
|
18
|
-
* a minimal parser that extracts FR references from a task block.
|
|
19
|
-
*/
|
|
20
|
-
function parseTaskFromMarkdown(tasksContent, taskId) {
|
|
21
|
-
const headingRe = new RegExp(`^### ${taskId}\\b[^\\n]*\\n([\\s\\S]*?)(?=^### |\\Z)`, 'm');
|
|
22
|
-
const match = tasksContent.match(headingRe);
|
|
23
|
-
if (!match) return null;
|
|
24
|
-
const block = match[1];
|
|
25
|
-
const frRefs = [...block.matchAll(/\bFR-?\d+\b/g)].map(m => m[0]);
|
|
26
|
-
return { id: taskId, frs: [...new Set(frRefs)], block };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Compute FR coverage — how well tasks.md covers spec.md FRs.
|
|
31
|
-
*
|
|
32
|
-
* @param {string} specContent - Full spec.md content
|
|
33
|
-
* @param {string} tasksContent - Full tasks.md content
|
|
34
|
-
* @returns {{ ratio: number, coveredFRs: string[], uncoveredFRs: string[], taskFRRefs: Map<string, string[]> }}
|
|
35
|
-
*/
|
|
36
|
-
export function computeFRCoverage(specContent, tasksContent) {
|
|
37
|
-
const frSections = parseFRSections(specContent);
|
|
38
|
-
const taskIds = extractTaskIds(tasksContent);
|
|
39
|
-
|
|
40
|
-
// Collect all FR references from tasks
|
|
41
|
-
const allReferencedFRs = new Set();
|
|
42
|
-
const taskFRRefs = new Map();
|
|
43
|
-
|
|
44
|
-
for (const taskId of taskIds) {
|
|
45
|
-
const task = parseTaskFromMarkdown(tasksContent, taskId);
|
|
46
|
-
if (task && task.frs.length > 0) {
|
|
47
|
-
taskFRRefs.set(taskId, task.frs);
|
|
48
|
-
for (const fr of task.frs) {
|
|
49
|
-
allReferencedFRs.add(fr);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const specFRIds = [...frSections.keys()];
|
|
55
|
-
const coveredFRs = specFRIds.filter(fr => allReferencedFRs.has(fr));
|
|
56
|
-
const uncoveredFRs = specFRIds.filter(fr => !allReferencedFRs.has(fr));
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
ratio: specFRIds.length > 0 ? coveredFRs.length / specFRIds.length : 1,
|
|
60
|
-
coveredFRs,
|
|
61
|
-
uncoveredFRs,
|
|
62
|
-
taskFRRefs,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Check mandate integrity — does the mandate include all tasks with acceptance criteria?
|
|
68
|
-
*
|
|
69
|
-
* @param {string} mandateContent - Full mandate.md content
|
|
70
|
-
* @param {string} tasksContent - Full tasks.md content
|
|
71
|
-
* @returns {{ complete: boolean, ratio: number, missingTasks: string[], tasksWithCriteria: string[], tasksWithoutCriteria: string[] }}
|
|
72
|
-
*/
|
|
73
|
-
export function checkMandateIntegrity(mandateContent, tasksContent) {
|
|
74
|
-
const taskIds = extractTaskIds(tasksContent);
|
|
75
|
-
const missingTasks = [];
|
|
76
|
-
const tasksWithCriteria = [];
|
|
77
|
-
const tasksWithoutCriteria = [];
|
|
78
|
-
|
|
79
|
-
for (const taskId of taskIds) {
|
|
80
|
-
const taskPattern = new RegExp(`^## ${taskId}:`, 'm');
|
|
81
|
-
if (!taskPattern.test(mandateContent)) {
|
|
82
|
-
missingTasks.push(taskId);
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Check if this task section has acceptance criteria
|
|
87
|
-
const sectionStart = mandateContent.indexOf(`## ${taskId}:`);
|
|
88
|
-
const nextSection = mandateContent.slice(sectionStart + 1).search(/\n## /);
|
|
89
|
-
const sectionEnd = nextSection === -1 ? mandateContent.length : sectionStart + 1 + nextSection;
|
|
90
|
-
const section = mandateContent.slice(sectionStart, sectionEnd);
|
|
91
|
-
|
|
92
|
-
if (/### Acceptance Criteria/i.test(section) && /- \[[ x]\]/.test(section)) {
|
|
93
|
-
tasksWithCriteria.push(taskId);
|
|
94
|
-
} else {
|
|
95
|
-
tasksWithoutCriteria.push(taskId);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const includedCount = taskIds.length - missingTasks.length;
|
|
100
|
-
return {
|
|
101
|
-
complete: missingTasks.length === 0,
|
|
102
|
-
ratio: taskIds.length > 0 ? includedCount / taskIds.length : 1,
|
|
103
|
-
missingTasks,
|
|
104
|
-
tasksWithCriteria,
|
|
105
|
-
tasksWithoutCriteria,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Check contract alignment — do entities in spec Data Model appear in contracts?
|
|
111
|
-
*
|
|
112
|
-
* @param {string} specContent - Full spec.md content
|
|
113
|
-
* @param {string} contractsContent - Full contracts file content (CS or TS)
|
|
114
|
-
* @returns {{ aligned: boolean, ratio: number, matchedEntities: string[], unmatchedEntities: string[] }}
|
|
115
|
-
*/
|
|
116
|
-
export function checkContractAlignment(specContent, contractsContent) {
|
|
117
|
-
// Extract entity names from Data Model section in spec
|
|
118
|
-
const dataModelMatch = specContent.match(
|
|
119
|
-
/^##\s+(?:\d+\.?\s+)?Data Model\s*\n([\s\S]*?)(?=\n##\s|$)/im
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
if (!dataModelMatch) {
|
|
123
|
-
return { aligned: true, ratio: 1, matchedEntities: [], unmatchedEntities: [] };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const dataModelSection = dataModelMatch[1];
|
|
127
|
-
// Look for entity names: typically bold, backticked, or in headings
|
|
128
|
-
const entityPatterns = [
|
|
129
|
-
/\*\*(\w+)\*\*/g, // **Entity**
|
|
130
|
-
/`(\w+)`/g, // `Entity`
|
|
131
|
-
/^###\s+(\w+)/gm, // ### Entity
|
|
132
|
-
];
|
|
133
|
-
|
|
134
|
-
const entities = new Set();
|
|
135
|
-
for (const pattern of entityPatterns) {
|
|
136
|
-
let match;
|
|
137
|
-
while ((match = pattern.exec(dataModelSection)) !== null) {
|
|
138
|
-
const name = match[1];
|
|
139
|
-
// Filter out common non-entity words
|
|
140
|
-
if (name.length > 2 && !/^(The|This|Each|All|and|for|with|from|that|has|can|will|not|are)$/i.test(name)) {
|
|
141
|
-
entities.add(name);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (entities.size === 0) {
|
|
147
|
-
return { aligned: true, ratio: 1, matchedEntities: [], unmatchedEntities: [] };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const contracts = parseContracts(contractsContent);
|
|
151
|
-
const contractNames = [...contracts.keys()];
|
|
152
|
-
const contractNamesLower = contractNames.map(n => n.toLowerCase());
|
|
153
|
-
|
|
154
|
-
const matchedEntities = [];
|
|
155
|
-
const unmatchedEntities = [];
|
|
156
|
-
|
|
157
|
-
for (const entity of entities) {
|
|
158
|
-
const entityLower = entity.toLowerCase();
|
|
159
|
-
// Check if entity appears in any contract name (partial match)
|
|
160
|
-
const found = contractNamesLower.some(cn => cn.includes(entityLower) || entityLower.includes(cn));
|
|
161
|
-
if (found) {
|
|
162
|
-
matchedEntities.push(entity);
|
|
163
|
-
} else {
|
|
164
|
-
unmatchedEntities.push(entity);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return {
|
|
169
|
-
aligned: unmatchedEntities.length === 0,
|
|
170
|
-
ratio: entities.size > 0 ? matchedEntities.length / entities.size : 1,
|
|
171
|
-
matchedEntities,
|
|
172
|
-
unmatchedEntities,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Check dependency ordering — are task dependencies topologically valid?
|
|
178
|
-
*
|
|
179
|
-
* @param {string} tasksContent - Full tasks.md content
|
|
180
|
-
* @returns {{ valid: boolean, errors: string[] }}
|
|
181
|
-
*/
|
|
182
|
-
export function checkDependencyOrdering(tasksContent) {
|
|
183
|
-
const taskIds = extractTaskIds(tasksContent);
|
|
184
|
-
const errors = [];
|
|
185
|
-
|
|
186
|
-
// Build dependency graph
|
|
187
|
-
const depPattern = /\*\*Dependenc(?:y|ies):\*\*\s*([T\d, ]+)/gi;
|
|
188
|
-
const taskIdSet = new Set(taskIds);
|
|
189
|
-
const taskOrder = new Map(taskIds.map((id, i) => [id, i]));
|
|
190
|
-
|
|
191
|
-
// Parse each task's dependencies
|
|
192
|
-
for (const taskId of taskIds) {
|
|
193
|
-
const task = parseTaskFromMarkdown(tasksContent, taskId);
|
|
194
|
-
if (!task) continue;
|
|
195
|
-
|
|
196
|
-
// Extract dependencies from task description
|
|
197
|
-
const depMatches = [...task.description.matchAll(depPattern)];
|
|
198
|
-
for (const depMatch of depMatches) {
|
|
199
|
-
const deps = depMatch[1].split(/[,\s]+/).filter(s => /^T\d{3}$/.test(s));
|
|
200
|
-
for (const dep of deps) {
|
|
201
|
-
if (!taskIdSet.has(dep)) {
|
|
202
|
-
errors.push(`${taskId} depends on ${dep} which does not exist`);
|
|
203
|
-
} else if (taskOrder.get(dep) >= taskOrder.get(taskId)) {
|
|
204
|
-
errors.push(`${taskId} depends on ${dep} which comes after it (ordering issue)`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
valid: errors.length === 0,
|
|
212
|
-
errors,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Run full cross-phase validation on a feature's outputs.
|
|
218
|
-
*
|
|
219
|
-
* @param {string} projectPath - Project root
|
|
220
|
-
* @param {string} featureName - Feature name
|
|
221
|
-
* @returns {Object} Full validation result with scores per dimension
|
|
222
|
-
*/
|
|
223
|
-
export function validateCrossPhase(projectPath, featureName) {
|
|
224
|
-
const specPath = getAbsoluteOutputPath(projectPath, featureName, 'spec');
|
|
225
|
-
const tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
|
|
226
|
-
|
|
227
|
-
const result = {
|
|
228
|
-
valid: true,
|
|
229
|
-
errors: [],
|
|
230
|
-
warnings: [],
|
|
231
|
-
dimensions: {},
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
// Require at minimum spec and tasks
|
|
235
|
-
if (!existsSync(specPath) || !existsSync(tasksPath)) {
|
|
236
|
-
result.valid = false;
|
|
237
|
-
if (!existsSync(specPath)) result.errors.push('spec.md not found');
|
|
238
|
-
if (!existsSync(tasksPath)) result.errors.push('tasks.md not found');
|
|
239
|
-
return result;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const specContent = readFileSync(specPath, 'utf-8');
|
|
243
|
-
const tasksContent = readFileSync(tasksPath, 'utf-8');
|
|
244
|
-
|
|
245
|
-
// 1. FR Coverage
|
|
246
|
-
const frCoverage = computeFRCoverage(specContent, tasksContent);
|
|
247
|
-
result.dimensions.frCoverage = frCoverage;
|
|
248
|
-
if (frCoverage.uncoveredFRs.length > 0) {
|
|
249
|
-
result.warnings.push(`Uncovered FRs: ${frCoverage.uncoveredFRs.join(', ')}`);
|
|
250
|
-
}
|
|
251
|
-
if (frCoverage.ratio < 0.9) {
|
|
252
|
-
result.errors.push(`FR coverage too low: ${(frCoverage.ratio * 100).toFixed(0)}% (threshold: 90%)`);
|
|
253
|
-
result.valid = false;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 2. Mandate integrity (if mandate exists)
|
|
257
|
-
const mandatePath = getAbsoluteOutputPath(projectPath, featureName, 'mandate');
|
|
258
|
-
if (existsSync(mandatePath)) {
|
|
259
|
-
const mandateContent = readFileSync(mandatePath, 'utf-8');
|
|
260
|
-
const mandateIntegrity = checkMandateIntegrity(mandateContent, tasksContent);
|
|
261
|
-
result.dimensions.mandateIntegrity = mandateIntegrity;
|
|
262
|
-
if (mandateIntegrity.missingTasks.length > 0) {
|
|
263
|
-
result.errors.push(`Mandate missing tasks: ${mandateIntegrity.missingTasks.join(', ')}`);
|
|
264
|
-
result.valid = false;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// 3. Contract alignment removed in v5 — contracts live in spec.md (NLH model).
|
|
269
|
-
// See morph-implement skill for source-level contract checks.
|
|
270
|
-
|
|
271
|
-
// 4. Dependency ordering
|
|
272
|
-
const depOrdering = checkDependencyOrdering(tasksContent);
|
|
273
|
-
result.dimensions.dependencyOrdering = depOrdering;
|
|
274
|
-
if (!depOrdering.valid) {
|
|
275
|
-
for (const err of depOrdering.errors) {
|
|
276
|
-
result.warnings.push(`Dependency issue: ${err}`);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
return result;
|
|
281
|
-
}
|