@polymorphism-tech/morph-spec 10.1.1 → 10.2.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 +3 -7
- package/bin/morph-spec.js +1 -41
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +0 -1
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-preflight.md +3 -4
- package/framework/docs/CLI.md +23 -20
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/core/validate-completion.js +0 -35
- package/framework/hooks/shared/e2e-check.js +44 -7
- package/framework/hooks/shared/heavy-command.js +12 -1
- package/framework/hooks/shared/next-step.js +2 -2
- package/framework/phases.json +4 -4
- package/framework/schemas/config.schema.json +20 -1
- package/framework/schemas/feature.schema.json +1 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +5 -5
- package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
- package/framework/skills/core/morph-apply/references/review.md +5 -5
- package/framework/skills/core/morph-plan/SKILL.md +4 -4
- package/framework/skills/core/morph-uiux/SKILL.md +2 -2
- package/framework/templates/examples/design-system-examples.md +0 -3
- package/package.json +1 -1
- package/src/commands/project/doctor.js +0 -1
- package/src/commands/state/approve.js +9 -109
- package/src/commands/state/report-check.js +44 -2
- package/src/commands/validation/verify.js +7 -3
- package/src/core/state/state-manager.js +0 -24
- package/src/lib/heavy/cleanup.js +8 -2
- package/src/lib/plan/critique.js +23 -3
- package/src/lib/review-confirmation.js +67 -0
- package/src/lib/review-reports.js +1 -0
- package/src/lib/risk-detector.js +2 -2
- package/src/lib/tasks/test-nodes.js +5 -0
- package/src/lib/tasks/test-runner.js +37 -43
- package/src/lib/telemetry/telemetry-reader.js +1 -2
- package/src/lib/telemetry/verify-event.js +7 -0
- package/src/lib/validators/verify-cache.js +63 -0
- package/src/lib/validators/verify-runner.js +98 -23
- package/src/utils/agents-installer.js +2 -2
- package/src/utils/hooks-installer.js +0 -20
- package/bin/validate.js +0 -212
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/src/commands/observability/telemetry.js +0 -60
- package/src/commands/validation/validate-feature.js +0 -337
|
@@ -6,7 +6,6 @@ import { realEffects } from '../lib/installers/effects.js';
|
|
|
6
6
|
|
|
7
7
|
const HOOKS_VERSION = '3.17.0';
|
|
8
8
|
|
|
9
|
-
const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
|
|
10
9
|
|
|
11
10
|
// `Edit(path)` only: an Edit rule also covers Write, while a `Write(path)` rule
|
|
12
11
|
// matches nothing and makes Claude Code warn on every session start.
|
|
@@ -195,7 +194,6 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
|
|
|
195
194
|
const managed = settings._morph_managed || {};
|
|
196
195
|
const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
|
|
197
196
|
|
|
198
|
-
removeOldDispatch(settings);
|
|
199
197
|
removeMorphHooks(settings);
|
|
200
198
|
|
|
201
199
|
let installed = 0;
|
|
@@ -250,8 +248,6 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
|
|
|
250
248
|
const STALE_ALLOW = [
|
|
251
249
|
'Write(.morph/features/**)',
|
|
252
250
|
'Write(.morph/context/**)',
|
|
253
|
-
'Bash(morph-spec mutate:*)',
|
|
254
|
-
'Bash(npx morph-spec mutate:*)',
|
|
255
251
|
];
|
|
256
252
|
settings.permissions.allow = settings.permissions.allow.filter(
|
|
257
253
|
p => !MORPH_ALLOW.includes(p) && !STALE_ALLOW.includes(p)
|
|
@@ -319,22 +315,6 @@ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOve
|
|
|
319
315
|
return { installed: true, globalDir };
|
|
320
316
|
}
|
|
321
317
|
|
|
322
|
-
function removeOldDispatch(settings) {
|
|
323
|
-
if (!settings.hooks?.PostToolUse) return;
|
|
324
|
-
|
|
325
|
-
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
|
|
326
|
-
if (!Array.isArray(entry.hooks)) return true;
|
|
327
|
-
return !entry.hooks.some(h =>
|
|
328
|
-
h.command === OLD_DISPATCH_COMMAND ||
|
|
329
|
-
(typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
|
|
330
|
-
);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
if (settings.hooks.PostToolUse.length === 0) {
|
|
334
|
-
delete settings.hooks.PostToolUse;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
318
|
function removeMorphHooks(settings) {
|
|
339
319
|
const morphPathPattern = /framework\/hooks\/claude-code\//;
|
|
340
320
|
|
package/bin/validate.js
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import { getStackProfile } from '../src/lib/stack/stack-profile.js';
|
|
5
|
-
|
|
6
|
-
async function buildValidators(stack) {
|
|
7
|
-
const validators = {};
|
|
8
|
-
|
|
9
|
-
const isDotnet = !stack || ['dotnet', 'blazor', 'aspnet', 'dotnet-blazor'].includes(stack.toLowerCase());
|
|
10
|
-
const isNextjs = stack && ['nextjs', 'next', 'next.js'].includes(stack.toLowerCase());
|
|
11
|
-
|
|
12
|
-
if (isDotnet) {
|
|
13
|
-
const { validatePackages } = await import('../src/lib/validators/packages/package-validator.js');
|
|
14
|
-
|
|
15
|
-
validators.packages = {
|
|
16
|
-
name: 'Package Compatibility',
|
|
17
|
-
description: 'Validates NuGet package versions against .NET compatibility matrix',
|
|
18
|
-
run: validatePackages
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (isNextjs) {
|
|
23
|
-
const { validateNextComponentFiles } = await import('../src/lib/validators/nextjs/next-component-validator.js');
|
|
24
|
-
validators['next-components'] = {
|
|
25
|
-
name: 'Next.js Components',
|
|
26
|
-
description: "Validates 'use client' directives, hook usage, and file naming conventions",
|
|
27
|
-
run: (projectPath, opts) => validateNextComponentFiles(projectPath, opts)
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return validators;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function validateCommand(args = []) {
|
|
35
|
-
const validatorName = args[0] || 'all';
|
|
36
|
-
const options = parseOptions(args);
|
|
37
|
-
|
|
38
|
-
const { stack } = getStackProfile();
|
|
39
|
-
const VALIDATORS = await buildValidators(stack);
|
|
40
|
-
|
|
41
|
-
console.log(chalk.bold.cyan('\n🔍 MORPH-SPEC Validator\n'));
|
|
42
|
-
if (stack) {
|
|
43
|
-
console.log(chalk.gray(`Stack: ${stack}\n`));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (validatorName === 'all') {
|
|
47
|
-
await runAllValidators(VALIDATORS, options);
|
|
48
|
-
} else if (VALIDATORS[validatorName]) {
|
|
49
|
-
await runValidator(VALIDATORS, validatorName, options);
|
|
50
|
-
} else {
|
|
51
|
-
console.error(chalk.red(`❌ Unknown validator: ${validatorName}`));
|
|
52
|
-
console.log(chalk.gray('\nAvailable validators:'));
|
|
53
|
-
for (const [name, validator] of Object.entries(VALIDATORS)) {
|
|
54
|
-
console.log(chalk.gray(` - ${name}: ${validator.description}`));
|
|
55
|
-
}
|
|
56
|
-
console.log(chalk.gray(' - all: Run all validators'));
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function runAllValidators(VALIDATORS, options) {
|
|
62
|
-
const results = {};
|
|
63
|
-
let totalErrors = 0;
|
|
64
|
-
let totalWarnings = 0;
|
|
65
|
-
|
|
66
|
-
console.log(chalk.gray('Running all validators...\n'));
|
|
67
|
-
|
|
68
|
-
for (const [name, validator] of Object.entries(VALIDATORS)) {
|
|
69
|
-
console.log(chalk.cyan(`\n▸ ${validator.name}`));
|
|
70
|
-
console.log(chalk.gray(` ${validator.description}\n`));
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const result = await validator.run('.', {
|
|
74
|
-
verbose: options.verbose,
|
|
75
|
-
autoFix: options.autoFix
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
results[name] = result;
|
|
79
|
-
|
|
80
|
-
if (result.errors !== undefined) {
|
|
81
|
-
totalErrors += result.errors;
|
|
82
|
-
totalWarnings += result.warnings || 0;
|
|
83
|
-
} else if (result.results) {
|
|
84
|
-
const issues = result.results.flatMap(r => r.issues || []);
|
|
85
|
-
totalErrors += issues.filter(i => i.level === 'error').length;
|
|
86
|
-
totalWarnings += issues.filter(i => i.level === 'warning').length;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
} catch (error) {
|
|
90
|
-
console.error(chalk.red(` ❌ Failed: ${error.message}`));
|
|
91
|
-
results[name] = { status: 'error', error: error.message };
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
console.log(chalk.bold.cyan('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
|
|
96
|
-
console.log(chalk.bold('📊 Validation Summary\n'));
|
|
97
|
-
|
|
98
|
-
if (totalErrors === 0 && totalWarnings === 0) {
|
|
99
|
-
console.log(chalk.green('✅ All validations passed!'));
|
|
100
|
-
} else {
|
|
101
|
-
if (totalErrors > 0) {
|
|
102
|
-
console.log(chalk.red(`❌ ${totalErrors} error(s) found`));
|
|
103
|
-
}
|
|
104
|
-
if (totalWarnings > 0) {
|
|
105
|
-
console.log(chalk.yellow(`⚠️ ${totalWarnings} warning(s) found`));
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
console.log('');
|
|
110
|
-
|
|
111
|
-
if (totalErrors > 0 && !options.noFail) {
|
|
112
|
-
process.exit(1);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function runValidator(VALIDATORS, name, options) {
|
|
117
|
-
const validator = VALIDATORS[name];
|
|
118
|
-
|
|
119
|
-
console.log(chalk.cyan(`▸ ${validator.name}`));
|
|
120
|
-
console.log(chalk.gray(` ${validator.description}\n`));
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const result = await validator.run('.', {
|
|
124
|
-
verbose: true,
|
|
125
|
-
autoFix: options.autoFix
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
if (result.status === 'ok') {
|
|
129
|
-
console.log(chalk.green('\n✅ Validation passed!\n'));
|
|
130
|
-
} else {
|
|
131
|
-
console.log(chalk.yellow('\n⚠️ Validation completed with issues\n'));
|
|
132
|
-
|
|
133
|
-
if (result.errors > 0 && !options.noFail) {
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
} catch (error) {
|
|
139
|
-
console.error(chalk.red(`\n❌ Validation failed: ${error.message}\n`));
|
|
140
|
-
if (!options.noFail) {
|
|
141
|
-
process.exit(1);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function parseOptions(args) {
|
|
147
|
-
const options = {
|
|
148
|
-
verbose: false,
|
|
149
|
-
autoFix: false,
|
|
150
|
-
noFail: false
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
for (const arg of args) {
|
|
154
|
-
if (arg === '--verbose' || arg === '-v') {
|
|
155
|
-
options.verbose = true;
|
|
156
|
-
} else if (arg === '--auto-fix' || arg === '--fix') {
|
|
157
|
-
options.autoFix = true;
|
|
158
|
-
} else if (arg === '--no-fail') {
|
|
159
|
-
options.noFail = true;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return options;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function showValidateHelp() {
|
|
167
|
-
console.log(`
|
|
168
|
-
${chalk.bold.cyan('morph-spec validate')} - Run project validations
|
|
169
|
-
|
|
170
|
-
${chalk.bold('USAGE')}
|
|
171
|
-
morph-spec validate [validator] [options]
|
|
172
|
-
|
|
173
|
-
${chalk.bold('VALIDATORS')}
|
|
174
|
-
all Run all validators (default)
|
|
175
|
-
packages Validate NuGet package compatibility (.NET 10)
|
|
176
|
-
|
|
177
|
-
${chalk.bold('OPTIONS')}
|
|
178
|
-
--verbose, -v Show detailed output
|
|
179
|
-
--auto-fix, --fix Auto-fix issues where possible
|
|
180
|
-
--no-fail Don't exit with error code
|
|
181
|
-
|
|
182
|
-
${chalk.bold('EXAMPLES')}
|
|
183
|
-
morph-spec validate # Run all validators
|
|
184
|
-
morph-spec validate packages --fix # Validate packages and auto-fix
|
|
185
|
-
morph-spec validate all # Run all validators
|
|
186
|
-
|
|
187
|
-
${chalk.bold('EXIT CODES')}
|
|
188
|
-
0 All validations passed
|
|
189
|
-
1 Validation errors found (unless --no-fail)
|
|
190
|
-
|
|
191
|
-
${chalk.bold('INTEGRATION')}
|
|
192
|
-
Add to pre-commit hook:
|
|
193
|
-
morph-spec validate --no-fail
|
|
194
|
-
|
|
195
|
-
CI/CD pipeline:
|
|
196
|
-
morph-spec validate || exit 1
|
|
197
|
-
`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
201
|
-
const args = process.argv.slice(2);
|
|
202
|
-
|
|
203
|
-
if (args.includes('--help') || args.includes('-h')) {
|
|
204
|
-
showValidateHelp();
|
|
205
|
-
process.exit(0);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
validateCommand(args).catch(error => {
|
|
209
|
-
console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
210
|
-
process.exit(1);
|
|
211
|
-
});
|
|
212
|
-
}
|
|
Binary file
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import { loadTelemetry, aggregateTelemetry } from '../../lib/telemetry/telemetry-reader.js';
|
|
3
|
-
|
|
4
|
-
// A rule injects its standards' digests through Claude Code's rules system,
|
|
5
|
-
// not the Read tool, so no hook sees them: a low count means "few full-text reads".
|
|
6
|
-
const STANDARDS_CAVEAT =
|
|
7
|
-
'Só reads explícitos (via Read) são observáveis; o resumo que uma rule injeta ao tocar arquivo casado por `paths:` não passa por tool call: não observável aqui.';
|
|
8
|
-
|
|
9
|
-
function section(title, rows, fmt) {
|
|
10
|
-
if (!rows.length) return;
|
|
11
|
-
console.log('\n' + chalk.white.bold(title));
|
|
12
|
-
for (const r of rows) console.log(' ' + fmt(r));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Read-only and always exits 0: an empty stream is an answer.
|
|
16
|
-
export function telemetryCommand(options = {}) {
|
|
17
|
-
const events = loadTelemetry(options.cwd || process.cwd());
|
|
18
|
-
const summary = aggregateTelemetry(events, { feature: options.feature });
|
|
19
|
-
|
|
20
|
-
if (options.json) {
|
|
21
|
-
console.log(JSON.stringify({ ...summary, notes: { standards: STANDARDS_CAVEAT } }));
|
|
22
|
-
process.exit(0);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (summary.total === 0) {
|
|
26
|
-
const scope = options.feature ? ` para a feature "${options.feature}"` : '';
|
|
27
|
-
console.log(chalk.gray(`Telemetria vazia${scope} — nenhum evento capturado ainda em .morph/logs/events.jsonl.`));
|
|
28
|
-
process.exit(0);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const scope = options.feature ? chalk.cyan(` · feature ${options.feature}`) : '';
|
|
32
|
-
console.log(chalk.white.bold(`Telemetria do harness — ${summary.total} eventos${scope}`));
|
|
33
|
-
if (summary.features.length) {
|
|
34
|
-
console.log(chalk.gray(`Features: ${summary.features.join(', ')}`));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
section('Skills', summary.skills, s => `${chalk.green(s.name.padEnd(24))} ${s.count}`);
|
|
38
|
-
section('Sub-agents despachados', summary.personas, p => `${chalk.magenta(p.name.padEnd(24))} ${p.count}`);
|
|
39
|
-
section('Base de conhecimento (standards lidos explicitamente)', summary.standards,
|
|
40
|
-
s => `${chalk.yellow(String(s.count).padStart(3))}× ${s.path}`);
|
|
41
|
-
if (summary.standards.length || summary.tools.some(t => t.name === 'Read')) {
|
|
42
|
-
console.log(chalk.gray(` ⚠ ${STANDARDS_CAVEAT}`));
|
|
43
|
-
}
|
|
44
|
-
section('Mix de tools', summary.tools, t => `${t.name.padEnd(16)} ${t.count}`);
|
|
45
|
-
|
|
46
|
-
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 10;
|
|
47
|
-
const prompts = summary.prompts.slice(-limit);
|
|
48
|
-
if (prompts.length) {
|
|
49
|
-
console.log('\n' + chalk.white.bold(`Prompts (últimos ${prompts.length})`));
|
|
50
|
-
for (const p of prompts) {
|
|
51
|
-
const tag = p.source === 'user' ? chalk.blue('user ') : chalk.magenta('agent');
|
|
52
|
-
const where = p.feature ? chalk.gray(` [${p.feature}/${p.phase || '-'}]`) : '';
|
|
53
|
-
const oneLine = String(p.text || '').replace(/\s+/g, ' ').trim();
|
|
54
|
-
const preview = oneLine.length > 120 ? oneLine.slice(0, 117) + '...' : oneLine;
|
|
55
|
-
console.log(` ${tag}${where} ${preview}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
process.exit(0);
|
|
60
|
-
}
|
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import { parsePlan } from '../../lib/plan/plan-parser.js';
|
|
5
|
-
import { planHash, planRuleOptions, validatePlan } from '../../lib/plan/checks.js';
|
|
6
|
-
|
|
7
|
-
// Matched against section markers (headings and line-start bold labels), never whole-file
|
|
8
|
-
// substrings. required → ERROR, recommended → WARNING, optional → never penalized; an array is any-of.
|
|
9
|
-
const OUTPUT_SECTIONS = {
|
|
10
|
-
'verifier.md': {
|
|
11
|
-
required: [
|
|
12
|
-
['Verdict', 'Veredito'],
|
|
13
|
-
],
|
|
14
|
-
recommended: [
|
|
15
|
-
['Bloqueadores', 'Bloqueantes', 'Blockers'],
|
|
16
|
-
'Checks',
|
|
17
|
-
],
|
|
18
|
-
optional: [],
|
|
19
|
-
},
|
|
20
|
-
'reviewer.md': {
|
|
21
|
-
required: [
|
|
22
|
-
['Verdict', 'Veredito'],
|
|
23
|
-
],
|
|
24
|
-
recommended: [
|
|
25
|
-
['Bloqueadores', 'Bloqueantes', 'Blockers'],
|
|
26
|
-
],
|
|
27
|
-
optional: [],
|
|
28
|
-
},
|
|
29
|
-
'security.md': {
|
|
30
|
-
required: [
|
|
31
|
-
['Verdict', 'Veredito'],
|
|
32
|
-
],
|
|
33
|
-
recommended: [
|
|
34
|
-
['Bloqueadores', 'Bloqueantes', 'Blockers'],
|
|
35
|
-
],
|
|
36
|
-
optional: [],
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const PHASE_OUTPUTS = {
|
|
41
|
-
plan: ['plan.md', 'checks.json'],
|
|
42
|
-
uiux: ['design-system.md', 'mockups.md', 'components.md', 'flows.md'],
|
|
43
|
-
implement: [],
|
|
44
|
-
review: ['verifier.md', 'reviewer.md'],
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const PHASE_FOLDERS = {
|
|
48
|
-
plan: '2-plan',
|
|
49
|
-
uiux: '1-design',
|
|
50
|
-
implement: '3-implement',
|
|
51
|
-
review: '4-review',
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
// Line-start bold labels count: reports write `**Verdict:**` as a label, not a heading.
|
|
55
|
-
function extractSectionMarkers(content) {
|
|
56
|
-
const markers = [];
|
|
57
|
-
for (const rawLine of content.split('\n')) {
|
|
58
|
-
const line = rawLine.trim();
|
|
59
|
-
const heading = /^#{1,6}\s+(.+)$/.exec(line);
|
|
60
|
-
if (heading) {
|
|
61
|
-
markers.push(heading[1].toLowerCase());
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const bold = /^\*\*(.+?)\*\*/.exec(line);
|
|
65
|
-
if (bold) {
|
|
66
|
-
markers.push(bold[1].toLowerCase());
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return markers;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function validateOutput(featurePath, outputFile) {
|
|
73
|
-
const filePath = path.join(featurePath, outputFile);
|
|
74
|
-
const result = {
|
|
75
|
-
file: outputFile,
|
|
76
|
-
exists: false,
|
|
77
|
-
sections: { found: [], missing: [], optional: [] },
|
|
78
|
-
warnings: [],
|
|
79
|
-
errors: [],
|
|
80
|
-
lineCount: 0,
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
if (!fs.existsSync(filePath)) {
|
|
84
|
-
result.errors.push(`File not found: ${outputFile}`);
|
|
85
|
-
return result;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
result.exists = true;
|
|
89
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
90
|
-
result.lineCount = content.split('\n').length;
|
|
91
|
-
|
|
92
|
-
if (outputFile === 'plan.md') {
|
|
93
|
-
validatePlanOutput(content, featurePath, result);
|
|
94
|
-
return result;
|
|
95
|
-
}
|
|
96
|
-
if (outputFile === 'checks.json') {
|
|
97
|
-
validateChecksOutput(content, featurePath, result);
|
|
98
|
-
return result;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const sections = OUTPUT_SECTIONS[outputFile];
|
|
102
|
-
if (!sections) {
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
validateMarkdownSections(content, sections, result);
|
|
107
|
-
|
|
108
|
-
if (result.lineCount < 10) {
|
|
109
|
-
result.warnings.push(`File is very short (${result.lineCount} lines) — may be incomplete`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return result;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// `planDir` is `.morph/features/{f}/2-plan`, so the project is four levels up.
|
|
116
|
-
function validatePlanOutput(content, planDir, result) {
|
|
117
|
-
const plan = parsePlan(content);
|
|
118
|
-
const root = path.resolve(planDir, '..', '..', '..', '..');
|
|
119
|
-
const feature = path.basename(path.resolve(planDir, '..'));
|
|
120
|
-
const { errors, warnings } = validatePlan(plan, { ...planRuleOptions(root, feature), text: content });
|
|
121
|
-
result.errors.push(...errors);
|
|
122
|
-
result.warnings.push(...warnings);
|
|
123
|
-
result.sections.found.push(...plan.sections.filter((x) => x.key).map((x) => x.name));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function validateChecksOutput(content, planDir, result) {
|
|
127
|
-
let doc;
|
|
128
|
-
try {
|
|
129
|
-
doc = JSON.parse(content);
|
|
130
|
-
} catch (err) {
|
|
131
|
-
result.errors.push(`checks.json is not valid JSON: ${err.message}`);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const planPath = path.join(planDir, 'plan.md');
|
|
135
|
-
if (!fs.existsSync(planPath)) return;
|
|
136
|
-
if (doc?.planHash !== planHash(fs.readFileSync(planPath, 'utf8'))) {
|
|
137
|
-
result.errors.push('checks.json is stale — plan.md changed after it was generated; run `morph-spec plan-check`');
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
result.sections.found.push(`${doc.checks?.length ?? 0} check(s)`);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function validateMarkdownSections(content, sections, result) {
|
|
144
|
-
const markers = extractSectionMarkers(content);
|
|
145
|
-
const match = (entry) => {
|
|
146
|
-
const candidates = Array.isArray(entry) ? entry : [entry];
|
|
147
|
-
const hit = candidates.find(c => markers.some(m => m.includes(c.toLowerCase())));
|
|
148
|
-
return { hit, label: candidates[0] };
|
|
149
|
-
};
|
|
150
|
-
const describe = (entry) =>
|
|
151
|
-
Array.isArray(entry) ? entry.map(s => `"${s}"`).join(' or ') : `"${entry}"`;
|
|
152
|
-
|
|
153
|
-
for (const entry of (sections.required || [])) {
|
|
154
|
-
const { hit, label } = match(entry);
|
|
155
|
-
if (hit) {
|
|
156
|
-
result.sections.found.push(hit);
|
|
157
|
-
} else {
|
|
158
|
-
result.sections.missing.push(label);
|
|
159
|
-
result.errors.push(`Missing required section: ${describe(entry)}`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
for (const entry of (sections.recommended || [])) {
|
|
164
|
-
const { hit } = match(entry);
|
|
165
|
-
if (hit) {
|
|
166
|
-
result.sections.found.push(hit);
|
|
167
|
-
} else {
|
|
168
|
-
result.warnings.push(`Missing recommended section: ${describe(entry)}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
for (const entry of (sections.optional || [])) {
|
|
173
|
-
const { hit } = match(entry);
|
|
174
|
-
if (hit) result.sections.optional.push(hit);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function resolveOutputFiles(featurePath, phase) {
|
|
179
|
-
if (phase) {
|
|
180
|
-
const files = PHASE_OUTPUTS[phase];
|
|
181
|
-
if (!files) return { error: `Unknown phase: ${phase}` };
|
|
182
|
-
const phaseFolder = PHASE_FOLDERS[phase];
|
|
183
|
-
const searchPath = phaseFolder ? path.join(featurePath, phaseFolder) : featurePath;
|
|
184
|
-
return { outputFiles: files.map(file => ({ file, dir: searchPath })) };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const outputFiles = [];
|
|
188
|
-
const isOutput = name => name.endsWith('.md') || name.endsWith('.cs') || name === 'checks.json';
|
|
189
|
-
const entries = fs.readdirSync(featurePath, { withFileTypes: true });
|
|
190
|
-
for (const entry of entries) {
|
|
191
|
-
if (entry.isDirectory()) {
|
|
192
|
-
const subDir = path.join(featurePath, entry.name);
|
|
193
|
-
for (const f of fs.readdirSync(subDir).filter(isOutput)) {
|
|
194
|
-
outputFiles.push({ file: f, dir: subDir });
|
|
195
|
-
}
|
|
196
|
-
} else if (isOutput(entry.name)) {
|
|
197
|
-
outputFiles.push({ file: entry.name, dir: featurePath });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
return { outputFiles };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Pure core: no console output, no process.exit. */
|
|
204
|
-
export function runFeatureValidation(featurePath, options = {}) {
|
|
205
|
-
const phase = options.phase || null;
|
|
206
|
-
const feature = path.basename(featurePath);
|
|
207
|
-
|
|
208
|
-
const { outputFiles, error } = resolveOutputFiles(featurePath, phase);
|
|
209
|
-
if (error) {
|
|
210
|
-
return {
|
|
211
|
-
feature,
|
|
212
|
-
phase: phase || 'all',
|
|
213
|
-
valid: false,
|
|
214
|
-
error,
|
|
215
|
-
totalErrors: 1,
|
|
216
|
-
totalWarnings: 0,
|
|
217
|
-
totalFound: 0,
|
|
218
|
-
results: [],
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const results = outputFiles.map(({ file, dir }) => validateOutput(dir, file));
|
|
223
|
-
const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0);
|
|
224
|
-
const totalWarnings = results.reduce((sum, r) => sum + r.warnings.length, 0);
|
|
225
|
-
const totalFound = results.reduce((sum, r) => sum + r.sections.found.length, 0);
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
feature,
|
|
229
|
-
phase: phase || 'all',
|
|
230
|
-
valid: totalErrors === 0,
|
|
231
|
-
totalErrors,
|
|
232
|
-
totalWarnings,
|
|
233
|
-
totalFound,
|
|
234
|
-
results,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export async function validateFeatureCommand(featureName, options = {}) {
|
|
239
|
-
if (!featureName) {
|
|
240
|
-
console.error(chalk.red('Error: Feature name required'));
|
|
241
|
-
console.error(chalk.gray('Usage: morph-spec validate <feature-name> [--phase <phase>]'));
|
|
242
|
-
process.exit(1);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const featurePath = path.join(process.cwd(), '.morph/features', featureName);
|
|
246
|
-
|
|
247
|
-
if (!fs.existsSync(featurePath)) {
|
|
248
|
-
console.error(chalk.red(`Error: Feature directory not found: ${featurePath}`));
|
|
249
|
-
process.exit(1);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const phase = options.phase || null;
|
|
253
|
-
if (phase && !PHASE_OUTPUTS[phase]) {
|
|
254
|
-
console.error(chalk.red(`Unknown phase: ${phase}`));
|
|
255
|
-
console.error(chalk.gray(`Valid phases: ${Object.keys(PHASE_OUTPUTS).join(', ')}`));
|
|
256
|
-
process.exit(1);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const validation = runFeatureValidation(featurePath, { phase });
|
|
260
|
-
const { results, totalErrors, totalWarnings, totalFound } = validation;
|
|
261
|
-
const schemaWarnings = [];
|
|
262
|
-
|
|
263
|
-
if (options.json) {
|
|
264
|
-
const output = {
|
|
265
|
-
feature: featureName,
|
|
266
|
-
phase: validation.phase,
|
|
267
|
-
valid: validation.valid,
|
|
268
|
-
summary: {
|
|
269
|
-
files: results.length,
|
|
270
|
-
errors: totalErrors,
|
|
271
|
-
warnings: totalWarnings + schemaWarnings.length,
|
|
272
|
-
sectionsFound: totalFound,
|
|
273
|
-
},
|
|
274
|
-
results: results.map(r => ({
|
|
275
|
-
file: r.file,
|
|
276
|
-
exists: r.exists,
|
|
277
|
-
lineCount: r.lineCount,
|
|
278
|
-
errors: r.errors,
|
|
279
|
-
warnings: r.warnings,
|
|
280
|
-
sectionsFound: r.sections.found,
|
|
281
|
-
sectionsMissing: r.sections.missing,
|
|
282
|
-
})),
|
|
283
|
-
schemaWarnings,
|
|
284
|
-
};
|
|
285
|
-
console.log(JSON.stringify(output, null, 2));
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
console.log(chalk.cyan('\n┌────────────────────────────────────────────────────────────┐'));
|
|
290
|
-
console.log(chalk.cyan('│ MORPH-SPEC FEATURE VALIDATOR │'));
|
|
291
|
-
console.log(chalk.cyan('└────────────────────────────────────────────────────────────┘\n'));
|
|
292
|
-
|
|
293
|
-
console.log(chalk.white.bold(`Feature: ${featureName}`));
|
|
294
|
-
if (phase) console.log(chalk.gray(`Phase: ${phase}`));
|
|
295
|
-
console.log('');
|
|
296
|
-
|
|
297
|
-
for (const result of results) {
|
|
298
|
-
const symbol = result.exists
|
|
299
|
-
? (result.errors.length === 0 ? '✅' : '❌')
|
|
300
|
-
: '⬜';
|
|
301
|
-
const name = result.exists
|
|
302
|
-
? (result.errors.length === 0 ? chalk.green(result.file) : chalk.red(result.file))
|
|
303
|
-
: chalk.gray(result.file);
|
|
304
|
-
|
|
305
|
-
console.log(`${symbol} ${name}${result.exists ? chalk.gray(` (${result.lineCount} lines)`) : ''}`);
|
|
306
|
-
|
|
307
|
-
if (result.sections.found.length > 0 && (options.verbose || result.errors.length > 0)) {
|
|
308
|
-
result.sections.found.forEach(s => {
|
|
309
|
-
console.log(chalk.green(` ✓ ${s}`));
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
result.errors.forEach(err => {
|
|
314
|
-
console.log(chalk.red(` ✗ ${err}`));
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
result.warnings.forEach(warn => {
|
|
318
|
-
console.log(chalk.yellow(` ⚠ ${warn}`));
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
console.log('');
|
|
323
|
-
console.log(chalk.gray('─'.repeat(60)));
|
|
324
|
-
|
|
325
|
-
if (totalErrors === 0) {
|
|
326
|
-
console.log(chalk.green(`\n✓ VALIDATION PASSED — ${results.filter(r => r.exists).length} files validated`));
|
|
327
|
-
if (totalWarnings > 0) {
|
|
328
|
-
console.log(chalk.yellow(` ${totalWarnings} warning(s) — review recommended`));
|
|
329
|
-
}
|
|
330
|
-
} else {
|
|
331
|
-
console.log(chalk.red(`\n✗ VALIDATION FAILED — ${totalErrors} error(s), ${totalWarnings} warning(s)`));
|
|
332
|
-
console.log(chalk.yellow(' Fix errors before advancing to the next phase'));
|
|
333
|
-
}
|
|
334
|
-
console.log('');
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
export default validateFeatureCommand;
|