@polymorphism-tech/morph-spec 2.1.2 → 2.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/CLAUDE.md +389 -40
- package/bin/morph-spec.js +121 -0
- package/bin/task-manager.js +368 -0
- package/bin/validate-agents-skills.js +17 -5
- package/bin/validate.js +268 -0
- package/content/.claude/skills/specialists/ef-modeler.md +11 -0
- package/content/.claude/skills/specialists/hangfire-orchestrator.md +10 -0
- package/content/.claude/skills/specialists/ui-ux-designer.md +40 -0
- package/content/.claude/skills/stacks/dotnet-blazor.md +18 -0
- package/content/.morph/examples/state-v3.json +188 -0
- package/detectors/structure-detector.js +32 -3
- package/package.json +1 -1
- package/src/commands/create-story.js +68 -0
- package/src/commands/init.js +59 -5
- package/src/commands/state.js +1 -1
- package/src/commands/task.js +75 -0
- package/src/lib/continuous-validator.js +440 -0
- package/src/lib/learning-system.js +520 -0
- package/src/lib/mockup-generator.js +366 -0
- package/src/lib/ui-detector.js +350 -0
- package/src/lib/validators/architecture-validator.js +387 -0
- package/src/lib/validators/package-validator.js +360 -0
- package/src/lib/validators/ui-contrast-validator.js +422 -0
- package/src/utils/file-copier.js +26 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
|
+
import yaml from 'yaml';
|
|
9
10
|
import ora from 'ora';
|
|
10
11
|
import chalk from 'chalk';
|
|
11
12
|
import { logger } from '../utils/logger.js';
|
|
@@ -257,10 +258,77 @@ export async function createStoryCommand(feature, storyId, options) {
|
|
|
257
258
|
// Write file
|
|
258
259
|
await writeFile(outputPath, renderedStory);
|
|
259
260
|
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// Update sprint-status.yaml (create if doesn't exist)
|
|
263
|
+
// ============================================================================
|
|
264
|
+
const sprintStatusPath = path.join(process.cwd(), `.morph/project/outputs/${feature}/sprint-status.yaml`);
|
|
265
|
+
let sprintStatus;
|
|
266
|
+
|
|
267
|
+
if (fs.existsSync(sprintStatusPath)) {
|
|
268
|
+
// Load existing
|
|
269
|
+
const content = fs.readFileSync(sprintStatusPath, 'utf-8');
|
|
270
|
+
sprintStatus = yaml.parse(content);
|
|
271
|
+
} else {
|
|
272
|
+
// Create new
|
|
273
|
+
sprintStatus = {
|
|
274
|
+
feature: feature,
|
|
275
|
+
epic: options.epic || toTitleCase(feature),
|
|
276
|
+
created: new Date().toISOString().split('T')[0],
|
|
277
|
+
updated: new Date().toISOString().split('T')[0],
|
|
278
|
+
stories: [],
|
|
279
|
+
metrics: {
|
|
280
|
+
total_stories: 0,
|
|
281
|
+
ready: 0,
|
|
282
|
+
in_progress: 0,
|
|
283
|
+
ready_for_qa: 0,
|
|
284
|
+
done: 0,
|
|
285
|
+
completion_percent: 0
|
|
286
|
+
},
|
|
287
|
+
current: null,
|
|
288
|
+
next: null
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Add new story to sprint-status
|
|
293
|
+
sprintStatus.stories.push({
|
|
294
|
+
id: storyId,
|
|
295
|
+
title: storyData.storyTitle,
|
|
296
|
+
file: `stories/${storyId}.md`,
|
|
297
|
+
status: 'ready',
|
|
298
|
+
created: new Date().toISOString().split('T')[0],
|
|
299
|
+
assigned: null,
|
|
300
|
+
started: null,
|
|
301
|
+
completed: null
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Update metrics
|
|
305
|
+
sprintStatus.metrics.total_stories = sprintStatus.stories.length;
|
|
306
|
+
sprintStatus.metrics.ready = sprintStatus.stories.filter(s => s.status === 'ready').length;
|
|
307
|
+
sprintStatus.metrics.in_progress = sprintStatus.stories.filter(s => s.status === 'in_progress').length;
|
|
308
|
+
sprintStatus.metrics.ready_for_qa = sprintStatus.stories.filter(s => s.status === 'ready_for_qa').length;
|
|
309
|
+
sprintStatus.metrics.done = sprintStatus.stories.filter(s => s.status === 'done').length;
|
|
310
|
+
sprintStatus.metrics.completion_percent = sprintStatus.stories.length > 0
|
|
311
|
+
? Math.round((sprintStatus.metrics.done / sprintStatus.stories.length) * 100)
|
|
312
|
+
: 0;
|
|
313
|
+
sprintStatus.updated = new Date().toISOString().split('T')[0];
|
|
314
|
+
|
|
315
|
+
// Set next story if null
|
|
316
|
+
if (!sprintStatus.next) {
|
|
317
|
+
sprintStatus.next = {
|
|
318
|
+
story_id: storyId,
|
|
319
|
+
recommendation: `Story ${storyId} is ready for development`
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Write sprint-status.yaml
|
|
324
|
+
const yamlContent = yaml.stringify(sprintStatus);
|
|
325
|
+
fs.writeFileSync(sprintStatusPath, yamlContent);
|
|
326
|
+
|
|
260
327
|
spinner.succeed('Story created!');
|
|
261
328
|
logger.blank();
|
|
262
329
|
|
|
263
330
|
logger.success(`Story file: ${chalk.cyan(outputPath)}`);
|
|
331
|
+
logger.success(`Updated: ${chalk.cyan('sprint-status.yaml')}`);
|
|
264
332
|
logger.blank();
|
|
265
333
|
|
|
266
334
|
logger.header('Dev Notes Auto-Injected:');
|
package/src/commands/init.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
2
3
|
import ora from 'ora';
|
|
3
4
|
import chalk from 'chalk';
|
|
4
5
|
import { logger } from '../utils/logger.js';
|
|
@@ -12,7 +13,8 @@ import {
|
|
|
12
13
|
ensureDir,
|
|
13
14
|
writeFile,
|
|
14
15
|
readFile,
|
|
15
|
-
updateGitignore
|
|
16
|
+
updateGitignore,
|
|
17
|
+
createSymlink
|
|
16
18
|
} from '../utils/file-copier.js';
|
|
17
19
|
import { saveProjectMorphVersion, getInstalledCLIVersion } from '../utils/version-checker.js';
|
|
18
20
|
|
|
@@ -139,12 +141,56 @@ Run \`morph-spec detect\` to analyze your project.
|
|
|
139
141
|
await copyFile(pricingSchemaSrc, pricingSchemaDest);
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
// 9. Copy .claude commands and skills
|
|
143
|
-
spinner.text = '
|
|
144
|
+
// 9. Copy .claude commands and create symlinks for skills
|
|
145
|
+
spinner.text = 'Setting up Claude Code integration...';
|
|
144
146
|
const claudeSrc = join(contentDir, '.claude');
|
|
145
147
|
const claudeDest = join(targetPath, '.claude');
|
|
148
|
+
|
|
149
|
+
let symlinkCount = 0;
|
|
150
|
+
let copyCount = 0;
|
|
151
|
+
|
|
146
152
|
if (await pathExists(claudeSrc)) {
|
|
147
|
-
|
|
153
|
+
// Copy commands directory (slash commands)
|
|
154
|
+
const commandsSrc = join(claudeSrc, 'commands');
|
|
155
|
+
const commandsDest = join(claudeDest, 'commands');
|
|
156
|
+
if (await pathExists(commandsSrc)) {
|
|
157
|
+
await copyDirectory(commandsSrc, commandsDest);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Create symlinks for skills (or copy if symlink fails)
|
|
161
|
+
const skillsSrc = join(claudeSrc, 'skills');
|
|
162
|
+
const skillsDest = join(claudeDest, 'skills');
|
|
163
|
+
|
|
164
|
+
if (await pathExists(skillsSrc)) {
|
|
165
|
+
// Ensure skills destination directory exists
|
|
166
|
+
await ensureDir(skillsDest);
|
|
167
|
+
|
|
168
|
+
// Recursively walk through skills directory
|
|
169
|
+
const walkDir = async (dir, basePath) => {
|
|
170
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
171
|
+
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const srcPath = join(dir, entry.name);
|
|
174
|
+
const relPath = join(basePath, entry.name);
|
|
175
|
+
const destPath = join(skillsDest, relPath);
|
|
176
|
+
|
|
177
|
+
if (entry.isDirectory()) {
|
|
178
|
+
await ensureDir(destPath);
|
|
179
|
+
await walkDir(srcPath, relPath);
|
|
180
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
181
|
+
// Create symlink for skill files
|
|
182
|
+
const result = await createSymlink(srcPath, destPath, 'file');
|
|
183
|
+
if (result === 'symlink') {
|
|
184
|
+
symlinkCount++;
|
|
185
|
+
} else {
|
|
186
|
+
copyCount++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
await walkDir(skillsSrc, '');
|
|
193
|
+
}
|
|
148
194
|
}
|
|
149
195
|
|
|
150
196
|
// 10. Save version info
|
|
@@ -180,7 +226,15 @@ Run \`morph-spec detect\` to analyze your project.
|
|
|
180
226
|
logger.dim(` ✓ .morph/standards/ (coding.md, architecture.md, azure.md, ...)`);
|
|
181
227
|
logger.dim(` ✓ .morph/templates/ (Bicep, integrations, saas, ...)`);
|
|
182
228
|
logger.dim(` ✓ .morph/project/ (context, standards, outputs)`);
|
|
183
|
-
logger.dim(` ✓ .claude/ (commands
|
|
229
|
+
logger.dim(` ✓ .claude/commands/ (slash commands)`);
|
|
230
|
+
|
|
231
|
+
if (symlinkCount > 0) {
|
|
232
|
+
logger.dim(` ✓ .claude/skills/ (${symlinkCount} symlinked)`);
|
|
233
|
+
} else if (copyCount > 0) {
|
|
234
|
+
logger.dim(` ✓ .claude/skills/ (${copyCount} copied)`);
|
|
235
|
+
logger.warn(` ⚠ Symlinks not supported (copied instead). Skills won't auto-update.`);
|
|
236
|
+
}
|
|
237
|
+
|
|
184
238
|
logger.blank();
|
|
185
239
|
|
|
186
240
|
} catch (error) {
|
package/src/commands/state.js
CHANGED
|
@@ -236,7 +236,7 @@ async function listCommand(options) {
|
|
|
236
236
|
? `${feature.tasks.completed}/${feature.tasks.total}`
|
|
237
237
|
: '0/0';
|
|
238
238
|
|
|
239
|
-
logger.info(`${statusEmoji} ${chalk.
|
|
239
|
+
logger.info(`${statusEmoji} ${chalk.bold(name)}`);
|
|
240
240
|
logger.dim(` Phase: ${feature.phase.padEnd(16)} │ Tasks: ${progress}`);
|
|
241
241
|
logger.dim(` Agents: ${feature.activeAgents.slice(0, 5).join(', ') || 'None'}`);
|
|
242
242
|
logger.blank();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task management commands
|
|
3
|
+
* Wrapper for bin/task-manager.js (CommonJS) to ESM
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const taskManagerPath = join(__dirname, '..', '..', 'bin', 'task-manager.js');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Execute task-manager.js with given arguments
|
|
16
|
+
*/
|
|
17
|
+
function executeTaskManager(args) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const child = spawn('node', [taskManagerPath, ...args], {
|
|
20
|
+
stdio: 'inherit',
|
|
21
|
+
shell: true
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
child.on('close', (code) => {
|
|
25
|
+
if (code === 0) {
|
|
26
|
+
resolve();
|
|
27
|
+
} else {
|
|
28
|
+
reject(new Error(`Task manager exited with code ${code}`));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
child.on('error', (error) => {
|
|
33
|
+
reject(error);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Complete tasks command
|
|
40
|
+
*/
|
|
41
|
+
export async function taskDoneCommand(featureName, taskIds, options) {
|
|
42
|
+
try {
|
|
43
|
+
const args = ['done', featureName, ...taskIds];
|
|
44
|
+
await executeTaskManager(args);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start task command
|
|
53
|
+
*/
|
|
54
|
+
export async function taskStartCommand(featureName, taskId, options) {
|
|
55
|
+
try {
|
|
56
|
+
const args = ['start', featureName, taskId];
|
|
57
|
+
await executeTaskManager(args);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get next task command
|
|
66
|
+
*/
|
|
67
|
+
export async function taskNextCommand(featureName, options) {
|
|
68
|
+
try {
|
|
69
|
+
const args = ['next', featureName];
|
|
70
|
+
await executeTaskManager(args);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continuous Validator
|
|
3
|
+
*
|
|
4
|
+
* Background watcher that validates project in real-time.
|
|
5
|
+
* Detects issues before they become problems.
|
|
6
|
+
*
|
|
7
|
+
* MORPH-SPEC 3.0 - Sprint 4
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, watch } from 'fs';
|
|
11
|
+
import { glob } from 'glob';
|
|
12
|
+
import { join, dirname } from 'path';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
|
|
15
|
+
export class ContinuousValidator {
|
|
16
|
+
constructor(projectPath = '.') {
|
|
17
|
+
this.projectPath = projectPath;
|
|
18
|
+
this.watchers = [];
|
|
19
|
+
this.validationInterval = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Start continuous validation
|
|
24
|
+
*/
|
|
25
|
+
async start(options = {}) {
|
|
26
|
+
const { watchMode = true, interval = 30000 } = options;
|
|
27
|
+
|
|
28
|
+
console.log(chalk.cyan('🔍 Starting continuous validation...'));
|
|
29
|
+
|
|
30
|
+
// Initial validation
|
|
31
|
+
await this.runAllValidations();
|
|
32
|
+
|
|
33
|
+
if (watchMode) {
|
|
34
|
+
// Watch for file changes
|
|
35
|
+
this.setupFileWatchers();
|
|
36
|
+
|
|
37
|
+
// Periodic validation
|
|
38
|
+
this.validationInterval = setInterval(() => {
|
|
39
|
+
this.runAllValidations();
|
|
40
|
+
}, interval);
|
|
41
|
+
|
|
42
|
+
console.log(chalk.gray(`Watching for changes (interval: ${interval/1000}s)...`));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Stop continuous validation
|
|
48
|
+
*/
|
|
49
|
+
stop() {
|
|
50
|
+
// Stop file watchers
|
|
51
|
+
this.watchers.forEach(watcher => watcher.close());
|
|
52
|
+
this.watchers = [];
|
|
53
|
+
|
|
54
|
+
// Stop periodic validation
|
|
55
|
+
if (this.validationInterval) {
|
|
56
|
+
clearInterval(this.validationInterval);
|
|
57
|
+
this.validationInterval = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(chalk.gray('Stopped continuous validation'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Setup file watchers
|
|
65
|
+
*/
|
|
66
|
+
setupFileWatchers() {
|
|
67
|
+
const watchPaths = [
|
|
68
|
+
'**/*.csproj',
|
|
69
|
+
'**/Program.cs',
|
|
70
|
+
'wwwroot/css/**/*.css',
|
|
71
|
+
'infra/**/*.bicep'
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const pattern of watchPaths) {
|
|
75
|
+
const watcher = watch(pattern, { persistent: false }, async (eventType, filename) => {
|
|
76
|
+
if (filename) {
|
|
77
|
+
console.log(chalk.gray(`File changed: ${filename}`));
|
|
78
|
+
await this.runAllValidations();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.watchers.push(watcher);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run all validations
|
|
88
|
+
*/
|
|
89
|
+
async runAllValidations() {
|
|
90
|
+
const results = await Promise.all([
|
|
91
|
+
this.validatePackageCompatibility(),
|
|
92
|
+
this.validateArchitecturePatterns(),
|
|
93
|
+
this.validateProgramCs(),
|
|
94
|
+
this.validateUIContrast(),
|
|
95
|
+
this.validateCosts()
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const errors = results.filter(r => r.level === 'error');
|
|
99
|
+
const warnings = results.filter(r => r.level === 'warning');
|
|
100
|
+
const infos = results.filter(r => r.level === 'info');
|
|
101
|
+
|
|
102
|
+
// Display summary
|
|
103
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
104
|
+
console.log(chalk.green('✅ All validations passed'));
|
|
105
|
+
} else {
|
|
106
|
+
if (errors.length > 0) {
|
|
107
|
+
console.log(chalk.red(`\n❌ ${errors.length} error(s) found:`));
|
|
108
|
+
errors.forEach(e => console.log(chalk.red(` - ${e.message}`)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (warnings.length > 0) {
|
|
112
|
+
console.log(chalk.yellow(`\n⚠️ ${warnings.length} warning(s):`));
|
|
113
|
+
warnings.forEach(w => console.log(chalk.yellow(` - ${w.message}`)));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Auto-fix errors if possible
|
|
118
|
+
const fixableErrors = errors.filter(e => e.autoFix);
|
|
119
|
+
if (fixableErrors.length > 0) {
|
|
120
|
+
console.log(chalk.cyan(`\n🔧 Auto-fixing ${fixableErrors.length} issue(s)...`));
|
|
121
|
+
for (const error of fixableErrors) {
|
|
122
|
+
try {
|
|
123
|
+
await error.autoFix();
|
|
124
|
+
console.log(chalk.green(` ✅ Fixed: ${error.message}`));
|
|
125
|
+
} catch (ex) {
|
|
126
|
+
console.log(chalk.red(` ❌ Failed to fix: ${error.message}`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { errors, warnings, infos };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate package compatibility (.NET 10)
|
|
136
|
+
*/
|
|
137
|
+
async validatePackageCompatibility() {
|
|
138
|
+
try {
|
|
139
|
+
const csprojFiles = await glob('**/*.csproj', { ignore: '**/obj/**' });
|
|
140
|
+
|
|
141
|
+
for (const file of csprojFiles) {
|
|
142
|
+
const content = readFileSync(file, 'utf-8');
|
|
143
|
+
|
|
144
|
+
// Extract .NET version
|
|
145
|
+
const tfmMatch = content.match(/<TargetFramework>(.*?)<\/TargetFramework>/);
|
|
146
|
+
if (!tfmMatch) continue;
|
|
147
|
+
|
|
148
|
+
const targetFramework = tfmMatch[1];
|
|
149
|
+
const dotnetVersion = parseInt(targetFramework.replace('net', ''));
|
|
150
|
+
|
|
151
|
+
if (dotnetVersion < 10) {
|
|
152
|
+
return {
|
|
153
|
+
level: 'info',
|
|
154
|
+
message: `${file}: Using .NET ${dotnetVersion} (consider upgrading to .NET 10)`
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Package compatibility matrix
|
|
159
|
+
const incompatiblePackages = this.checkPackageCompatibility(content, dotnetVersion);
|
|
160
|
+
|
|
161
|
+
if (incompatiblePackages.length > 0) {
|
|
162
|
+
return {
|
|
163
|
+
level: 'error',
|
|
164
|
+
message: `${file}: Incompatible packages with .NET ${dotnetVersion}:\n` +
|
|
165
|
+
incompatiblePackages.map(p => ` - ${p.name} ${p.version} → upgrade to ${p.requiredVersion}`).join('\n'),
|
|
166
|
+
packages: incompatiblePackages
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { level: 'ok' };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return { level: 'error', message: `Package validation failed: ${error.message}` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check package compatibility
|
|
179
|
+
*/
|
|
180
|
+
checkPackageCompatibility(csprojContent, dotnetVersion) {
|
|
181
|
+
const incompatible = [];
|
|
182
|
+
|
|
183
|
+
// Compatibility matrix (from dotnet10-compatibility.md)
|
|
184
|
+
const matrix = {
|
|
185
|
+
'MudBlazor': {
|
|
186
|
+
10: '8.15.0',
|
|
187
|
+
pattern: /<PackageReference Include="MudBlazor" Version="(.*?)"/
|
|
188
|
+
},
|
|
189
|
+
'Microsoft.FluentUI.AspNetCore.Components': {
|
|
190
|
+
10: '5.0.0',
|
|
191
|
+
pattern: /<PackageReference Include="Microsoft\.FluentUI\.AspNetCore\.Components" Version="(.*?)"/
|
|
192
|
+
},
|
|
193
|
+
'Hangfire.AspNetCore': {
|
|
194
|
+
10: '1.8.22',
|
|
195
|
+
pattern: /<PackageReference Include="Hangfire\.AspNetCore" Version="(.*?)"/
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
for (const [pkg, config] of Object.entries(matrix)) {
|
|
200
|
+
const match = csprojContent.match(config.pattern);
|
|
201
|
+
if (match) {
|
|
202
|
+
const installedVersion = match[1];
|
|
203
|
+
const requiredVersion = config[dotnetVersion];
|
|
204
|
+
|
|
205
|
+
if (requiredVersion && this.compareVersions(installedVersion, requiredVersion) < 0) {
|
|
206
|
+
incompatible.push({
|
|
207
|
+
name: pkg,
|
|
208
|
+
version: installedVersion,
|
|
209
|
+
requiredVersion
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return incompatible;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Compare semantic versions
|
|
220
|
+
*/
|
|
221
|
+
compareVersions(v1, v2) {
|
|
222
|
+
const parts1 = v1.split('.').map(Number);
|
|
223
|
+
const parts2 = v2.split('.').map(Number);
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
|
226
|
+
const p1 = parts1[i] || 0;
|
|
227
|
+
const p2 = parts2[i] || 0;
|
|
228
|
+
if (p1 < p2) return -1;
|
|
229
|
+
if (p1 > p2) return 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Validate architecture patterns
|
|
237
|
+
*/
|
|
238
|
+
async validateArchitecturePatterns() {
|
|
239
|
+
try {
|
|
240
|
+
const blazorFiles = await glob('**/*.razor.cs', { ignore: '**/obj/**' });
|
|
241
|
+
|
|
242
|
+
for (const file of blazorFiles) {
|
|
243
|
+
const content = readFileSync(file, 'utf-8');
|
|
244
|
+
|
|
245
|
+
// Check for Application layer injection (anti-pattern)
|
|
246
|
+
if (/\[Inject\].*I\w+Command/.test(content)) {
|
|
247
|
+
return {
|
|
248
|
+
level: 'error',
|
|
249
|
+
message: `${file}: Blazor component injecting Application layer directly (use HttpClient pattern)`,
|
|
250
|
+
reference: 'framework/standards/blazor-pitfalls.md'
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Check for DbContext injection (anti-pattern)
|
|
255
|
+
if (/\[Inject\].*DbContext/.test(content)) {
|
|
256
|
+
return {
|
|
257
|
+
level: 'error',
|
|
258
|
+
message: `${file}: Blazor component injecting DbContext directly (use HttpClient → API → Handler)`,
|
|
259
|
+
reference: 'framework/standards/blazor-pitfalls.md'
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Check for JSRuntime in OnInitialized (pitfall)
|
|
264
|
+
if (/OnInitialized.*JSRuntime/.test(content.replace(/\s+/g, ''))) {
|
|
265
|
+
return {
|
|
266
|
+
level: 'warning',
|
|
267
|
+
message: `${file}: Possible JSRuntime call in OnInitialized (use OnAfterRenderAsync)`,
|
|
268
|
+
reference: 'framework/standards/blazor-lifecycle.md'
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { level: 'ok' };
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return { level: 'error', message: `Architecture validation failed: ${error.message}` };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Validate Program.cs setup
|
|
281
|
+
*/
|
|
282
|
+
async validateProgramCs() {
|
|
283
|
+
try {
|
|
284
|
+
const programCs = 'Program.cs';
|
|
285
|
+
if (!existsSync(programCs)) {
|
|
286
|
+
return { level: 'ok' }; // Not a Blazor project
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const content = readFileSync(programCs, 'utf-8');
|
|
290
|
+
|
|
291
|
+
const checks = [
|
|
292
|
+
{ pattern: /app\.UseStaticFiles\(\)/, message: 'app.UseStaticFiles() is missing', critical: true },
|
|
293
|
+
{ pattern: /AddHttpClient\(\)/, message: 'AddHttpClient() is missing', critical: true },
|
|
294
|
+
{ pattern: /AddHttpContextAccessor\(\)/, message: 'AddHttpContextAccessor() is missing', critical: true }
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
for (const check of checks) {
|
|
298
|
+
if (!check.pattern.test(content)) {
|
|
299
|
+
return {
|
|
300
|
+
level: check.critical ? 'error' : 'warning',
|
|
301
|
+
message: `Program.cs: ${check.message}`,
|
|
302
|
+
reference: 'framework/standards/program-cs-checklist.md'
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Check middleware order (UseStaticFiles before UseAntiforgery)
|
|
308
|
+
const staticFilesIndex = content.indexOf('UseStaticFiles');
|
|
309
|
+
const antiforgeryIndex = content.indexOf('UseAntiforgery');
|
|
310
|
+
|
|
311
|
+
if (staticFilesIndex > 0 && antiforgeryIndex > 0 && staticFilesIndex > antiforgeryIndex) {
|
|
312
|
+
return {
|
|
313
|
+
level: 'error',
|
|
314
|
+
message: 'Program.cs: app.UseStaticFiles() must come BEFORE app.UseAntiforgery()',
|
|
315
|
+
reference: 'framework/standards/program-cs-checklist.md'
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { level: 'ok' };
|
|
320
|
+
} catch (error) {
|
|
321
|
+
return { level: 'error', message: `Program.cs validation failed: ${error.message}` };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Validate UI contrast (WCAG 2.1 AA)
|
|
327
|
+
*/
|
|
328
|
+
async validateUIContrast() {
|
|
329
|
+
try {
|
|
330
|
+
const cssFiles = await glob('wwwroot/css/**/*.css', { ignore: '**/node_modules/**' });
|
|
331
|
+
|
|
332
|
+
for (const file of cssFiles) {
|
|
333
|
+
const content = readFileSync(file, 'utf-8');
|
|
334
|
+
|
|
335
|
+
// Extract color variables
|
|
336
|
+
const colors = this.extractColors(content);
|
|
337
|
+
|
|
338
|
+
// Check common color pairs
|
|
339
|
+
const issues = [];
|
|
340
|
+
const bgColors = colors.filter(c => c.name.includes('background') || c.name.includes('bg'));
|
|
341
|
+
const textColors = colors.filter(c => c.name.includes('text') || c.name.includes('color'));
|
|
342
|
+
|
|
343
|
+
for (const bg of bgColors) {
|
|
344
|
+
for (const text of textColors) {
|
|
345
|
+
const ratio = this.calculateContrastRatio(bg.value, text.value);
|
|
346
|
+
if (ratio < 4.5) { // WCAG AA minimum for text
|
|
347
|
+
issues.push({ bg: bg.name, text: text.name, ratio: ratio.toFixed(2) });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (issues.length > 0) {
|
|
353
|
+
return {
|
|
354
|
+
level: 'warning',
|
|
355
|
+
message: `${file}: Low contrast detected (WCAG AA requires 4.5:1):\n` +
|
|
356
|
+
issues.map(i => ` - ${i.text} on ${i.bg}: ${i.ratio}:1`).join('\n')
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { level: 'ok' };
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return { level: 'ok' }; // CSS parsing is optional
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Extract colors from CSS
|
|
369
|
+
*/
|
|
370
|
+
extractColors(css) {
|
|
371
|
+
const colors = [];
|
|
372
|
+
const hexPattern = /--([\w-]+):\s*(#[0-9a-fA-F]{3,6})/g;
|
|
373
|
+
let match;
|
|
374
|
+
|
|
375
|
+
while ((match = hexPattern.exec(css)) !== null) {
|
|
376
|
+
colors.push({ name: match[1], value: match[2] });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return colors;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Calculate contrast ratio (WCAG formula)
|
|
384
|
+
*/
|
|
385
|
+
calculateContrastRatio(color1, color2) {
|
|
386
|
+
const l1 = this.getLuminance(color1);
|
|
387
|
+
const l2 = this.getLuminance(color2);
|
|
388
|
+
|
|
389
|
+
const lighter = Math.max(l1, l2);
|
|
390
|
+
const darker = Math.min(l1, l2);
|
|
391
|
+
|
|
392
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Get relative luminance
|
|
397
|
+
*/
|
|
398
|
+
getLuminance(hex) {
|
|
399
|
+
const rgb = this.hexToRgb(hex);
|
|
400
|
+
if (!rgb) return 0;
|
|
401
|
+
|
|
402
|
+
const [r, g, b] = rgb.map(val => {
|
|
403
|
+
val = val / 255;
|
|
404
|
+
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Convert hex to RGB
|
|
412
|
+
*/
|
|
413
|
+
hexToRgb(hex) {
|
|
414
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
415
|
+
return result ? [
|
|
416
|
+
parseInt(result[1], 16),
|
|
417
|
+
parseInt(result[2], 16),
|
|
418
|
+
parseInt(result[3], 16)
|
|
419
|
+
] : null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Validate costs (Bicep files)
|
|
424
|
+
*/
|
|
425
|
+
async validateCosts() {
|
|
426
|
+
try {
|
|
427
|
+
const bicepFiles = await glob('infra/**/*.bicep');
|
|
428
|
+
|
|
429
|
+
if (bicepFiles.length === 0) {
|
|
430
|
+
return { level: 'ok' }; // No infrastructure
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// This would integrate with cost-calculator.js
|
|
434
|
+
// For now, just check if files exist
|
|
435
|
+
return { level: 'info', message: `Found ${bicepFiles.length} Bicep file(s) - run 'npx morph-spec cost' to estimate costs` };
|
|
436
|
+
} catch (error) {
|
|
437
|
+
return { level: 'ok' };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|