@specmarket/cli 0.0.3 → 0.0.5

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/README.md +1 -1
  3. package/dist/api-GIDUNUXG.js +0 -0
  4. package/dist/{chunk-MS2DYACY.js → chunk-DLEMNRTH.js} +19 -2
  5. package/dist/chunk-DLEMNRTH.js.map +1 -0
  6. package/dist/chunk-JEUDDJP7.js +0 -0
  7. package/dist/{config-R5KWZSJP.js → config-OAU6SJLC.js} +2 -2
  8. package/dist/exec-K3BOXX3C.js +0 -0
  9. package/dist/index.js +980 -181
  10. package/dist/index.js.map +1 -1
  11. package/package.json +21 -15
  12. package/src/commands/comment.test.ts +211 -0
  13. package/src/commands/comment.ts +176 -0
  14. package/src/commands/fork.test.ts +163 -0
  15. package/src/commands/info.test.ts +192 -0
  16. package/src/commands/info.ts +66 -2
  17. package/src/commands/init.test.ts +106 -0
  18. package/src/commands/init.ts +12 -10
  19. package/src/commands/issues.test.ts +377 -0
  20. package/src/commands/issues.ts +443 -0
  21. package/src/commands/login.test.ts +99 -0
  22. package/src/commands/logout.test.ts +54 -0
  23. package/src/commands/publish.test.ts +146 -0
  24. package/src/commands/report.test.ts +181 -0
  25. package/src/commands/run.test.ts +213 -0
  26. package/src/commands/run.ts +10 -2
  27. package/src/commands/search.test.ts +147 -0
  28. package/src/commands/validate.test.ts +129 -2
  29. package/src/commands/validate.ts +333 -192
  30. package/src/commands/whoami.test.ts +106 -0
  31. package/src/index.ts +6 -0
  32. package/src/lib/convex-client.ts +6 -2
  33. package/src/lib/format-detection.test.ts +223 -0
  34. package/src/lib/format-detection.ts +172 -0
  35. package/src/lib/ralph-loop.ts +49 -20
  36. package/src/lib/telemetry.ts +2 -1
  37. package/dist/chunk-MS2DYACY.js.map +0 -1
  38. /package/dist/{config-R5KWZSJP.js.map → config-OAU6SJLC.js.map} +0 -0
@@ -1,189 +1,62 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { readFile, readdir, access, stat } from 'fs/promises';
3
+ import { readFile, readdir, access } from 'fs/promises';
4
4
  import { join, resolve, relative, normalize } from 'path';
5
5
  import { parse as parseYaml } from 'yaml';
6
6
  import {
7
7
  specYamlSchema,
8
+ specmarketSidecarSchema,
8
9
  EXIT_CODES,
9
10
  REQUIRED_SPEC_FILES,
10
11
  REQUIRED_STDLIB_FILES,
12
+ SIDECAR_FILENAME,
11
13
  } from '@specmarket/shared';
12
14
  import type { ValidationResult } from '@specmarket/shared';
15
+ import {
16
+ detectSpecFormat,
17
+ fileExists,
18
+ directoryExists,
19
+ hasStoryFiles,
20
+ hasMarkdownFiles,
21
+ } from '../lib/format-detection.js';
13
22
 
14
23
  /**
15
- * Validates a spec directory for completeness and schema compliance.
16
- *
17
- * Checks (errors):
18
- * - spec.yaml parses and conforms to Zod schema
19
- * - Required files exist and are non-empty: PROMPT.md, SPEC.md, SUCCESS_CRITERIA.md
20
- * - stdlib/ directory with STACK.md
21
- * - SUCCESS_CRITERIA.md has at least one criterion
22
- * - No circular references between spec files (Markdown link cycles)
23
- * - Infrastructure block validates (if present)
24
- *
25
- * Checks (warnings):
26
- * - Web-app/api-service/mobile-app with no infrastructure services
27
- * - Missing setup_time_minutes
28
- * - Token/cost/time estimates seem unreasonably low or high
29
- *
30
- * Exported as a testable function separate from the CLI command wrapper.
24
+ * Recursively collects file paths relative to baseDir, filtered by extension.
31
25
  */
32
- export async function validateSpec(specPath: string): Promise<ValidationResult> {
33
- const dir = resolve(specPath);
34
- const errors: string[] = [];
35
- const warnings: string[] = [];
36
-
37
- // Check required files exist and are non-empty
38
- for (const file of REQUIRED_SPEC_FILES) {
39
- const filePath = join(dir, file);
40
- try {
41
- await access(filePath);
42
- const content = await readFile(filePath, 'utf-8');
43
- if (content.trim().length === 0) {
44
- errors.push(`${file} exists but is empty`);
45
- }
46
- } catch {
47
- errors.push(`Required file missing: ${file}`);
48
- }
49
- }
50
-
51
- // Check stdlib directory
52
- const stdlibDir = join(dir, 'stdlib');
53
- for (const file of REQUIRED_STDLIB_FILES) {
54
- const filePath = join(stdlibDir, file);
55
- try {
56
- await access(filePath);
57
- const content = await readFile(filePath, 'utf-8');
58
- if (content.trim().length === 0) {
59
- errors.push(`stdlib/${file} exists but is empty`);
60
- }
61
- } catch {
62
- errors.push(`Required file missing: stdlib/${file}`);
63
- }
64
- }
65
-
66
- // Parse and validate spec.yaml
67
- let specYaml: unknown = null;
68
- const specYamlPath = join(dir, 'spec.yaml');
26
+ async function collectFiles(
27
+ currentDir: string,
28
+ baseDir: string,
29
+ extensions: Set<string>
30
+ ): Promise<string[]> {
31
+ const results: string[] = [];
69
32
  try {
70
- const raw = await readFile(specYamlPath, 'utf-8');
71
- specYaml = parseYaml(raw);
72
- } catch (err) {
73
- errors.push(`spec.yaml: Failed to parse YAML: ${(err as Error).message}`);
74
- return { valid: false, errors, warnings };
75
- }
76
-
77
- const parseResult = specYamlSchema.safeParse(specYaml);
78
- if (!parseResult.success) {
79
- for (const issue of parseResult.error.issues) {
80
- errors.push(
81
- `spec.yaml: ${issue.path.join('.')} — ${issue.message}`
82
- );
83
- }
84
- } else {
85
- const parsed = parseResult.data;
86
-
87
- // Check SUCCESS_CRITERIA.md has at least one criterion
88
- try {
89
- const criteriaContent = await readFile(join(dir, 'SUCCESS_CRITERIA.md'), 'utf-8');
90
- const hasCriterion = /^- \[[ x]\]/m.test(criteriaContent);
91
- if (!hasCriterion) {
92
- errors.push(
93
- 'SUCCESS_CRITERIA.md: Must contain at least one criterion in format: - [ ] criterion text'
94
- );
95
- }
96
- } catch {
97
- // Already caught in required files check
98
- }
99
-
100
- // Check for circular references between spec files
101
- const cycles = await detectCircularReferences(dir);
102
- for (const cycle of cycles) {
103
- errors.push(`Circular reference detected: ${cycle}`);
104
- }
105
-
106
- // Infrastructure validation warnings
107
- if (parsed.infrastructure) {
108
- const infra = parsed.infrastructure;
109
-
110
- // Warn if web/api/mobile apps have no services
111
- if (
112
- ['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type) &&
113
- infra.services.length === 0
114
- ) {
115
- warnings.push(
116
- `${parsed.output_type} should typically define infrastructure services (database, auth, etc.)`
117
- );
118
- }
119
-
120
- if (!infra.setup_time_minutes) {
121
- warnings.push('infrastructure.setup_time_minutes is not set');
122
- }
123
-
124
- // Validate defaultProvider matches a defined provider name
125
- for (const service of infra.services) {
126
- if (service.default_provider) {
127
- const providerNames = service.providers.map((p) => p.name);
128
- if (!providerNames.includes(service.default_provider)) {
129
- errors.push(
130
- `infrastructure.services[${service.name}].default_provider "${service.default_provider}" ` +
131
- `does not match any defined provider (${providerNames.join(', ')})`
132
- );
133
- }
33
+ const entries = await readdir(currentDir, { withFileTypes: true });
34
+ for (const entry of entries) {
35
+ const fullPath = join(currentDir, entry.name);
36
+ if (entry.isDirectory()) {
37
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
38
+ const subFiles = await collectFiles(fullPath, baseDir, extensions);
39
+ results.push(...subFiles);
40
+ } else if (entry.isFile()) {
41
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop()! : '';
42
+ if (extensions.has(ext)) {
43
+ results.push(relative(baseDir, fullPath));
134
44
  }
135
45
  }
136
- } else {
137
- if (['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type)) {
138
- warnings.push(
139
- 'No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc.'
140
- );
141
- }
142
- }
143
-
144
- // Estimate sanity checks
145
- if (parsed.estimated_tokens < 1000) {
146
- warnings.push(
147
- `estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
148
- );
149
- }
150
- if (parsed.estimated_tokens > 10_000_000) {
151
- warnings.push(
152
- `estimated_tokens (${parsed.estimated_tokens}) seems very high.`
153
- );
154
- }
155
- if (parsed.estimated_cost_usd < 0.01) {
156
- warnings.push(
157
- `estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
158
- );
159
- }
160
- if (parsed.estimated_time_minutes < 1) {
161
- warnings.push(
162
- `estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
163
- );
164
46
  }
47
+ } catch {
48
+ // Directory unreadable
165
49
  }
166
-
167
- return {
168
- valid: errors.length === 0,
169
- errors,
170
- warnings,
171
- };
50
+ return results;
172
51
  }
173
52
 
174
53
  /**
175
54
  * Scans all Markdown/YAML files in the spec directory for relative file
176
55
  * references (Markdown links) and detects cycles in the reference graph.
177
- *
178
- * Returns an array of cycle descriptions (empty if no cycles found).
179
- * Uses DFS with coloring (white/gray/black) for cycle detection.
180
56
  */
181
57
  export async function detectCircularReferences(dir: string): Promise<string[]> {
182
- // Collect all text files in the spec directory (md, yaml, yml)
183
58
  const textExtensions = new Set(['.md', '.yaml', '.yml']);
184
59
  const files = await collectFiles(dir, dir, textExtensions);
185
-
186
- // Build adjacency list: file -> files it references via Markdown links
187
60
  const graph = new Map<string, Set<string>>();
188
61
  const linkPattern = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
189
62
 
@@ -194,32 +67,31 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
194
67
  let match: RegExpExecArray | null;
195
68
  while ((match = linkPattern.exec(content)) !== null) {
196
69
  const target = match[1]!;
197
- // Skip external URLs, anchors, and mailto
198
- if (target.startsWith('http://') || target.startsWith('https://') ||
199
- target.startsWith('#') || target.startsWith('mailto:')) {
70
+ if (
71
+ target.startsWith('http://') ||
72
+ target.startsWith('https://') ||
73
+ target.startsWith('#') ||
74
+ target.startsWith('mailto:')
75
+ ) {
200
76
  continue;
201
77
  }
202
- // Strip anchor fragments (e.g., "./SPEC.md#section" -> "./SPEC.md")
203
78
  const targetPath = target.split('#')[0]!;
204
79
  if (!targetPath) continue;
205
-
206
- // Resolve relative to the file's directory
207
80
  const fileDir = join(dir, file, '..');
208
81
  const resolvedTarget = normalize(relative(dir, resolve(fileDir, targetPath)));
209
-
210
- // Only track references to files within the spec directory
211
82
  if (!resolvedTarget.startsWith('..') && files.includes(resolvedTarget)) {
212
83
  refs.add(resolvedTarget);
213
84
  }
214
85
  }
215
86
  } catch {
216
- // File unreadable — skip
87
+ // skip
217
88
  }
218
89
  graph.set(file, refs);
219
90
  }
220
91
 
221
- // DFS cycle detection
222
- const WHITE = 0, GRAY = 1, BLACK = 2;
92
+ const WHITE = 0,
93
+ GRAY = 1,
94
+ BLACK = 2;
223
95
  const color = new Map<string, number>();
224
96
  const parent = new Map<string, string | null>();
225
97
  const cycles: string[] = [];
@@ -233,7 +105,6 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
233
105
  const neighbors = graph.get(node) ?? new Set();
234
106
  for (const neighbor of neighbors) {
235
107
  if (color.get(neighbor) === GRAY) {
236
- // Found a cycle — reconstruct the path
237
108
  const cyclePath: string[] = [neighbor, node];
238
109
  let curr = node;
239
110
  while (curr !== neighbor) {
@@ -263,44 +134,314 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
263
134
  }
264
135
 
265
136
  /**
266
- * Recursively collects file paths relative to baseDir, filtered by extension.
137
+ * Format-specific validation for specmarket-legacy. Pushes to errors/warnings.
138
+ * Zero behavior change from original validateSpec for legacy dirs.
267
139
  */
268
- async function collectFiles(
269
- currentDir: string,
270
- baseDir: string,
271
- extensions: Set<string>
272
- ): Promise<string[]> {
273
- const results: string[] = [];
140
+ async function validateLegacySpec(
141
+ dir: string,
142
+ errors: string[],
143
+ warnings: string[]
144
+ ): Promise<void> {
145
+ for (const file of REQUIRED_SPEC_FILES) {
146
+ const filePath = join(dir, file);
147
+ try {
148
+ await access(filePath);
149
+ const content = await readFile(filePath, 'utf-8');
150
+ if (content.trim().length === 0) {
151
+ errors.push(`${file} exists but is empty`);
152
+ }
153
+ } catch {
154
+ errors.push(`Required file missing: ${file}`);
155
+ }
156
+ }
157
+
158
+ const stdlibDir = join(dir, 'stdlib');
159
+ for (const file of REQUIRED_STDLIB_FILES) {
160
+ const filePath = join(stdlibDir, file);
161
+ try {
162
+ await access(filePath);
163
+ const content = await readFile(filePath, 'utf-8');
164
+ if (content.trim().length === 0) {
165
+ errors.push(`stdlib/${file} exists but is empty`);
166
+ }
167
+ } catch {
168
+ errors.push(`Required file missing: stdlib/${file}`);
169
+ }
170
+ }
171
+
172
+ let specYaml: unknown = null;
173
+ const specYamlPath = join(dir, 'spec.yaml');
274
174
  try {
275
- const entries = await readdir(currentDir, { withFileTypes: true });
276
- for (const entry of entries) {
277
- const fullPath = join(currentDir, entry.name);
278
- if (entry.isDirectory()) {
279
- // Skip hidden directories and node_modules
280
- if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
281
- const subFiles = await collectFiles(fullPath, baseDir, extensions);
282
- results.push(...subFiles);
283
- } else if (entry.isFile()) {
284
- const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
285
- if (extensions.has(ext)) {
286
- results.push(relative(baseDir, fullPath));
175
+ const raw = await readFile(specYamlPath, 'utf-8');
176
+ specYaml = parseYaml(raw);
177
+ } catch (err) {
178
+ errors.push(`spec.yaml: Failed to parse YAML: ${(err as Error).message}`);
179
+ return;
180
+ }
181
+
182
+ const parseResult = specYamlSchema.safeParse(specYaml);
183
+ if (!parseResult.success) {
184
+ for (const issue of parseResult.error.issues) {
185
+ errors.push(`spec.yaml: ${issue.path.join('.')} — ${issue.message}`);
186
+ }
187
+ return;
188
+ }
189
+
190
+ const parsed = parseResult.data;
191
+
192
+ try {
193
+ const criteriaContent = await readFile(join(dir, 'SUCCESS_CRITERIA.md'), 'utf-8');
194
+ const hasCriterion = /^- \[[ x]\]/m.test(criteriaContent);
195
+ if (!hasCriterion) {
196
+ errors.push(
197
+ 'SUCCESS_CRITERIA.md: Must contain at least one criterion in format: - [ ] criterion text'
198
+ );
199
+ }
200
+ } catch {
201
+ // Already caught in required files check
202
+ }
203
+
204
+ const cycles = await detectCircularReferences(dir);
205
+ for (const cycle of cycles) {
206
+ errors.push(`Circular reference detected: ${cycle}`);
207
+ }
208
+
209
+ if (parsed.infrastructure) {
210
+ const infra = parsed.infrastructure;
211
+ if (
212
+ ['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type) &&
213
+ infra.services.length === 0
214
+ ) {
215
+ warnings.push(
216
+ `${parsed.output_type} should typically define infrastructure services (database, auth, etc.)`
217
+ );
218
+ }
219
+ if (!infra.setup_time_minutes) {
220
+ warnings.push('infrastructure.setup_time_minutes is not set');
221
+ }
222
+ for (const service of infra.services) {
223
+ if (service.default_provider) {
224
+ const providerNames = service.providers.map((p) => p.name);
225
+ if (!providerNames.includes(service.default_provider)) {
226
+ errors.push(
227
+ `infrastructure.services[${service.name}].default_provider "${service.default_provider}" ` +
228
+ `does not match any defined provider (${providerNames.join(', ')})`
229
+ );
287
230
  }
288
231
  }
289
232
  }
233
+ } else {
234
+ if (['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type)) {
235
+ warnings.push(
236
+ 'No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc.'
237
+ );
238
+ }
239
+ }
240
+
241
+ if (parsed.estimated_tokens < 1000) {
242
+ warnings.push(
243
+ `estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
244
+ );
245
+ }
246
+ if (parsed.estimated_tokens > 10_000_000) {
247
+ warnings.push(`estimated_tokens (${parsed.estimated_tokens}) seems very high.`);
248
+ }
249
+ if (parsed.estimated_cost_usd < 0.01) {
250
+ warnings.push(
251
+ `estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
252
+ );
253
+ }
254
+ if (parsed.estimated_time_minutes < 1) {
255
+ warnings.push(
256
+ `estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
257
+ );
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Validates a spec directory. Detects format first, then runs universal checks
263
+ * and format-specific validation. Returns ValidationResult with format and
264
+ * formatDetectedBy when detection ran.
265
+ */
266
+ export async function validateSpec(specPath: string): Promise<ValidationResult> {
267
+ const dir = resolve(specPath);
268
+ const errors: string[] = [];
269
+ const warnings: string[] = [];
270
+
271
+ const detection = await detectSpecFormat(dir);
272
+
273
+ // Universal: directory non-empty (at least one readable file)
274
+ try {
275
+ const entries = await readdir(dir, { withFileTypes: true });
276
+ const hasAnyFile = entries.some((e) => e.isFile());
277
+ if (!hasAnyFile) {
278
+ errors.push('Directory is empty or has no readable files');
279
+ }
290
280
  } catch {
291
- // Directory unreadable
281
+ errors.push('Directory is empty or unreadable');
292
282
  }
293
- return results;
283
+
284
+ // If sidecar exists, validate with specmarketSidecarSchema and sanity-check estimates
285
+ const sidecarPath = join(dir, SIDECAR_FILENAME);
286
+ if (await fileExists(sidecarPath)) {
287
+ try {
288
+ const raw = await readFile(sidecarPath, 'utf-8');
289
+ const parsed = parseYaml(raw) as unknown;
290
+ const sidecarResult = specmarketSidecarSchema.safeParse(parsed);
291
+ if (!sidecarResult.success) {
292
+ for (const issue of sidecarResult.error.issues) {
293
+ errors.push(
294
+ `${SIDECAR_FILENAME}: ${issue.path.join('.')} — ${issue.message}`
295
+ );
296
+ }
297
+ } else {
298
+ const sidecar = sidecarResult.data;
299
+ if (sidecar.estimated_tokens !== undefined) {
300
+ if (sidecar.estimated_tokens < 1000) {
301
+ warnings.push(
302
+ `sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
303
+ );
304
+ }
305
+ if (sidecar.estimated_tokens > 10_000_000) {
306
+ warnings.push(
307
+ `sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very high.`
308
+ );
309
+ }
310
+ }
311
+ if (
312
+ sidecar.estimated_cost_usd !== undefined &&
313
+ sidecar.estimated_cost_usd < 0.01
314
+ ) {
315
+ warnings.push(
316
+ `sidecar estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
317
+ );
318
+ }
319
+ if (
320
+ sidecar.estimated_time_minutes !== undefined &&
321
+ sidecar.estimated_time_minutes < 1
322
+ ) {
323
+ warnings.push(
324
+ `sidecar estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
325
+ );
326
+ }
327
+ }
328
+ } catch (err) {
329
+ errors.push(
330
+ `${SIDECAR_FILENAME}: Failed to read or parse: ${(err as Error).message}`
331
+ );
332
+ }
333
+ }
334
+
335
+ // Format-specific validation. Run legacy validation when spec.yaml exists or
336
+ // when format is legacy (e.g. PROMPT+SUCCESS_CRITERIA but spec.yaml missing).
337
+ const hasSpecYaml = await fileExists(join(dir, 'spec.yaml'));
338
+ if (hasSpecYaml || detection.format === 'specmarket-legacy') {
339
+ await validateLegacySpec(dir, errors, warnings);
340
+ }
341
+
342
+ switch (detection.format) {
343
+ case 'specmarket-legacy':
344
+ break;
345
+ case 'speckit': {
346
+ const hasSpecMd = await fileExists(join(dir, 'spec.md'));
347
+ const hasTasksMd = await fileExists(join(dir, 'tasks.md'));
348
+ const hasPlanMd = await fileExists(join(dir, 'plan.md'));
349
+ const hasSpecifyDir = await directoryExists(join(dir, '.specify'));
350
+ if (!hasSpecMd) {
351
+ errors.push('speckit format requires spec.md');
352
+ }
353
+ if (!hasTasksMd && !hasPlanMd) {
354
+ errors.push('speckit format requires tasks.md or plan.md');
355
+ }
356
+ if (!hasSpecifyDir) {
357
+ warnings.push('speckit format: .specify/ directory is recommended');
358
+ }
359
+ break;
360
+ }
361
+ case 'bmad': {
362
+ const hasPrdMd = await fileExists(join(dir, 'prd.md'));
363
+ const hasStory = await hasStoryFiles(dir);
364
+ if (!hasPrdMd && !hasStory) {
365
+ errors.push('bmad format requires prd.md or story-*.md files');
366
+ }
367
+ const hasArch = await fileExists(join(dir, 'architecture.md'));
368
+ if (!hasArch) {
369
+ warnings.push('bmad format: architecture.md is recommended');
370
+ }
371
+ break;
372
+ }
373
+ case 'ralph': {
374
+ const prdPath = join(dir, 'prd.json');
375
+ if (!(await fileExists(prdPath))) {
376
+ errors.push('ralph format requires prd.json');
377
+ break;
378
+ }
379
+ try {
380
+ const raw = await readFile(prdPath, 'utf-8');
381
+ const data = JSON.parse(raw) as unknown;
382
+ if (
383
+ !data ||
384
+ typeof data !== 'object' ||
385
+ !('userStories' in data) ||
386
+ !Array.isArray((data as { userStories: unknown }).userStories)
387
+ ) {
388
+ errors.push('ralph format: prd.json must have userStories array');
389
+ }
390
+ } catch {
391
+ errors.push('ralph format: prd.json must be valid JSON with userStories array');
392
+ }
393
+ break;
394
+ }
395
+ case 'custom':
396
+ default: {
397
+ const hasMd = await hasMarkdownFiles(dir);
398
+ if (!hasMd) {
399
+ errors.push('custom format requires at least one .md file');
400
+ break;
401
+ }
402
+ // At least one .md file > 100 bytes
403
+ const textExtensions = new Set(['.md']);
404
+ const mdFiles = await collectFiles(dir, dir, textExtensions);
405
+ let hasSubstantialMd = false;
406
+ for (const f of mdFiles) {
407
+ try {
408
+ const content = await readFile(join(dir, f), 'utf-8');
409
+ if (content.length > 100) {
410
+ hasSubstantialMd = true;
411
+ break;
412
+ }
413
+ } catch {
414
+ // skip
415
+ }
416
+ }
417
+ if (!hasSubstantialMd) {
418
+ errors.push('custom format requires at least one .md file larger than 100 bytes');
419
+ }
420
+ break;
421
+ }
422
+ }
423
+
424
+ return {
425
+ valid: errors.length === 0,
426
+ errors,
427
+ warnings,
428
+ format: detection.format,
429
+ formatDetectedBy: detection.detectedBy,
430
+ };
294
431
  }
295
432
 
296
433
  export function createValidateCommand(): Command {
297
434
  return new Command('validate')
298
435
  .description('Validate a spec directory for completeness and schema compliance')
299
- .argument('<path>', 'Path to the spec directory')
436
+ .argument('[path]', 'Path to the spec directory (defaults to current directory)', '.')
300
437
  .action(async (specPath: string) => {
301
438
  try {
302
439
  const result = await validateSpec(specPath);
303
440
 
441
+ if (result.format !== undefined) {
442
+ console.log(chalk.gray(`Detected format: ${result.format}`));
443
+ }
444
+
304
445
  if (result.warnings.length > 0) {
305
446
  console.log(chalk.yellow('\nWarnings:'));
306
447
  for (const warning of result.warnings) {