@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.
@@ -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:');
@@ -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 = 'Copying Claude commands and skills...';
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
- await copyDirectory(claudeSrc, claudeDest);
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, skills)`);
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) {
@@ -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.bright(name)}`);
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
+ }