@specmarket/cli 0.0.5 → 0.0.6

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.
@@ -13,7 +13,6 @@ import {
13
13
  } from '@specmarket/shared';
14
14
  import type { ValidationResult } from '@specmarket/shared';
15
15
  import {
16
- detectSpecFormat,
17
16
  fileExists,
18
17
  directoryExists,
19
18
  hasStoryFiles,
@@ -134,10 +133,10 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
134
133
  }
135
134
 
136
135
  /**
137
- * Format-specific validation for specmarket-legacy. Pushes to errors/warnings.
138
- * Zero behavior change from original validateSpec for legacy dirs.
136
+ * Content validation when spec_format is "specmarket" (spec.yaml + PROMPT.md + SPEC.md + SUCCESS_CRITERIA.md + stdlib).
137
+ * Pushes to errors/warnings.
139
138
  */
140
- async function validateLegacySpec(
139
+ async function validateSpecmarketContent(
141
140
  dir: string,
142
141
  errors: string[],
143
142
  warnings: string[]
@@ -259,18 +258,17 @@ async function validateLegacySpec(
259
258
  }
260
259
 
261
260
  /**
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.
261
+ * Validates a spec directory. Single standard: specmarket.yaml is required for every spec.
262
+ * Format and metadata come from the sidecar; format-specific content checks run based on spec_format.
265
263
  */
266
264
  export async function validateSpec(specPath: string): Promise<ValidationResult> {
267
265
  const dir = resolve(specPath);
268
266
  const errors: string[] = [];
269
267
  const warnings: string[] = [];
268
+ let format: string | undefined;
269
+ let formatDetectedBy: 'sidecar' | 'heuristic' | undefined = 'sidecar';
270
270
 
271
- const detection = await detectSpecFormat(dir);
272
-
273
- // Universal: directory non-empty (at least one readable file)
271
+ // Universal: directory non-empty
274
272
  try {
275
273
  const entries = await readdir(dir, { withFileTypes: true });
276
274
  const hasAnyFile = entries.some((e) => e.isFile());
@@ -281,9 +279,12 @@ export async function validateSpec(specPath: string): Promise<ValidationResult>
281
279
  errors.push('Directory is empty or unreadable');
282
280
  }
283
281
 
284
- // If sidecar exists, validate with specmarketSidecarSchema and sanity-check estimates
285
282
  const sidecarPath = join(dir, SIDECAR_FILENAME);
286
- if (await fileExists(sidecarPath)) {
283
+ const sidecarExists = await fileExists(sidecarPath);
284
+
285
+ if (!sidecarExists) {
286
+ errors.push(`${SIDECAR_FILENAME} is required for all specs (single source of truth for format and metadata)`);
287
+ } else {
287
288
  try {
288
289
  const raw = await readFile(sidecarPath, 'utf-8');
289
290
  const parsed = parseYaml(raw) as unknown;
@@ -296,24 +297,21 @@ export async function validateSpec(specPath: string): Promise<ValidationResult>
296
297
  }
297
298
  } else {
298
299
  const sidecar = sidecarResult.data;
300
+ format = sidecar.spec_format;
301
+
299
302
  if (sidecar.estimated_tokens !== undefined) {
300
303
  if (sidecar.estimated_tokens < 1000) {
301
304
  warnings.push(
302
- `sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
305
+ `estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
303
306
  );
304
307
  }
305
308
  if (sidecar.estimated_tokens > 10_000_000) {
306
- warnings.push(
307
- `sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very high.`
308
- );
309
+ warnings.push(`estimated_tokens (${sidecar.estimated_tokens}) seems very high.`);
309
310
  }
310
311
  }
311
- if (
312
- sidecar.estimated_cost_usd !== undefined &&
313
- sidecar.estimated_cost_usd < 0.01
314
- ) {
312
+ if (sidecar.estimated_cost_usd !== undefined && sidecar.estimated_cost_usd < 0.01) {
315
313
  warnings.push(
316
- `sidecar estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
314
+ `estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
317
315
  );
318
316
  }
319
317
  if (
@@ -321,103 +319,87 @@ export async function validateSpec(specPath: string): Promise<ValidationResult>
321
319
  sidecar.estimated_time_minutes < 1
322
320
  ) {
323
321
  warnings.push(
324
- `sidecar estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
322
+ `estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
325
323
  );
326
324
  }
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
325
 
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;
326
+ // Format-specific content validation (based on sidecar.spec_format)
327
+ switch (format) {
328
+ case 'specmarket':
329
+ await validateSpecmarketContent(dir, errors, warnings);
330
+ break;
331
+ case 'speckit': {
332
+ const hasSpecMd = await fileExists(join(dir, 'spec.md'));
333
+ const hasTasksMd = await fileExists(join(dir, 'tasks.md'));
334
+ const hasPlanMd = await fileExists(join(dir, 'plan.md'));
335
+ const hasSpecifyDir = await directoryExists(join(dir, '.specify'));
336
+ if (!hasSpecMd) errors.push('speckit format requires spec.md');
337
+ if (!hasTasksMd && !hasPlanMd) errors.push('speckit format requires tasks.md or plan.md');
338
+ if (!hasSpecifyDir) warnings.push('speckit format: .specify/ directory is recommended');
339
+ break;
340
+ }
341
+ case 'bmad': {
342
+ const hasPrdMd = await fileExists(join(dir, 'prd.md'));
343
+ const hasStory = await hasStoryFiles(dir);
344
+ if (!hasPrdMd && !hasStory) errors.push('bmad format requires prd.md or story-*.md files');
345
+ const hasArch = await fileExists(join(dir, 'architecture.md'));
346
+ if (!hasArch) warnings.push('bmad format: architecture.md is recommended');
347
+ break;
348
+ }
349
+ case 'ralph': {
350
+ const prdPath = join(dir, 'prd.json');
351
+ if (!(await fileExists(prdPath))) {
352
+ errors.push('ralph format requires prd.json');
353
+ break;
354
+ }
355
+ try {
356
+ const raw = await readFile(prdPath, 'utf-8');
357
+ const data = JSON.parse(raw) as unknown;
358
+ if (
359
+ !data ||
360
+ typeof data !== 'object' ||
361
+ !('userStories' in data) ||
362
+ !Array.isArray((data as { userStories: unknown }).userStories)
363
+ ) {
364
+ errors.push('ralph format: prd.json must have userStories array');
365
+ }
366
+ } catch {
367
+ errors.push('ralph format: prd.json must be valid JSON with userStories array');
368
+ }
369
+ break;
370
+ }
371
+ case 'custom':
372
+ default: {
373
+ const hasMd = await hasMarkdownFiles(dir);
374
+ if (!hasMd) {
375
+ errors.push('custom format requires at least one .md file');
376
+ break;
377
+ }
378
+ const textExtensions = new Set(['.md']);
379
+ const mdFiles = await collectFiles(dir, dir, textExtensions);
380
+ let hasSubstantialMd = false;
381
+ for (const f of mdFiles) {
382
+ try {
383
+ const content = await readFile(join(dir, f), 'utf-8');
384
+ if (content.length > 100) {
385
+ hasSubstantialMd = true;
386
+ break;
387
+ }
388
+ } catch {
389
+ // skip
390
+ }
391
+ }
392
+ if (!hasSubstantialMd) {
393
+ errors.push('custom format requires at least one .md file larger than 100 bytes');
394
+ }
411
395
  break;
412
396
  }
413
- } catch {
414
- // skip
415
397
  }
416
398
  }
417
- if (!hasSubstantialMd) {
418
- errors.push('custom format requires at least one .md file larger than 100 bytes');
419
- }
420
- break;
399
+ } catch (err) {
400
+ errors.push(
401
+ `${SIDECAR_FILENAME}: Failed to read or parse: ${(err as Error).message}`
402
+ );
421
403
  }
422
404
  }
423
405
 
@@ -425,8 +407,8 @@ export async function validateSpec(specPath: string): Promise<ValidationResult>
425
407
  valid: errors.length === 0,
426
408
  errors,
427
409
  warnings,
428
- format: detection.format,
429
- formatDetectedBy: detection.detectedBy,
410
+ format,
411
+ formatDetectedBy,
430
412
  };
431
413
  }
432
414
 
@@ -439,7 +421,7 @@ export function createValidateCommand(): Command {
439
421
  const result = await validateSpec(specPath);
440
422
 
441
423
  if (result.format !== undefined) {
442
- console.log(chalk.gray(`Detected format: ${result.format}`));
424
+ console.log(chalk.gray(`Format: ${result.format}`));
443
425
  }
444
426
 
445
427
  if (result.warnings.length > 0) {
@@ -96,12 +96,12 @@ describe('format-detection', () => {
96
96
  expect(result.detectedBy).toBe('heuristic');
97
97
  });
98
98
 
99
- it('legacy dir (spec.yaml + PROMPT.md + SUCCESS_CRITERIA.md) → specmarket-legacy, high', async () => {
99
+ it('specmarket dir (spec.yaml + PROMPT.md + SUCCESS_CRITERIA.md) → specmarket, high', async () => {
100
100
  await writeFile(join(tmpDir, 'spec.yaml'), 'name: x');
101
101
  await writeFile(join(tmpDir, 'PROMPT.md'), '# P');
102
102
  await writeFile(join(tmpDir, 'SUCCESS_CRITERIA.md'), '- [ ] C');
103
103
  const result = await detectSpecFormat(tmpDir);
104
- expect(result.format).toBe('specmarket-legacy');
104
+ expect(result.format).toBe('specmarket');
105
105
  expect(result.confidence).toBe('high');
106
106
  expect(result.detectedBy).toBe('heuristic');
107
107
  });
@@ -213,11 +213,11 @@ describe('format-detection', () => {
213
213
  expect(result.format).toBe('custom');
214
214
  });
215
215
 
216
- it('legacy requires all three files', async () => {
216
+ it('specmarket requires spec.yaml + PROMPT + SUCCESS_CRITERIA (no spec with only two)', async () => {
217
217
  await writeFile(join(tmpDir, 'spec.yaml'), 'x');
218
218
  await writeFile(join(tmpDir, 'PROMPT.md'), 'x');
219
219
  const result = await detectSpecFormat(tmpDir);
220
- expect(result.format).not.toBe('specmarket-legacy');
220
+ expect(result.format).not.toBe('specmarket');
221
221
  });
222
222
  });
223
223
  });
@@ -75,7 +75,7 @@ export async function tryReadSidecar(
75
75
  /**
76
76
  * Detect spec format for a directory. Priority:
77
77
  * 1. Sidecar specmarket.yaml with spec_format → use that value, high confidence
78
- * 2. specmarket-legacy: spec.yaml + PROMPT.md + SUCCESS_CRITERIA.md
78
+ * 2. specmarket: spec.yaml + PROMPT.md + SUCCESS_CRITERIA.md
79
79
  * 3. speckit: spec.md + (plan.md | tasks.md) + .specify/ directory
80
80
  * 4. bmad: prd.md + (architecture.md | story-*.md)
81
81
  * 5. ralph: prd.json with userStories[] array
@@ -97,14 +97,14 @@ export async function detectSpecFormat(dir: string): Promise<FormatDetectionResu
97
97
  const hasSuccessCriteria = await fileExists(join(dir, 'SUCCESS_CRITERIA.md'));
98
98
  if (hasSpecYaml && hasPromptMd && hasSuccessCriteria) {
99
99
  return {
100
- format: 'specmarket-legacy',
100
+ format: 'specmarket',
101
101
  detectedBy: 'heuristic',
102
102
  confidence: 'high',
103
103
  };
104
104
  }
105
105
  if (hasPromptMd && hasSuccessCriteria) {
106
106
  return {
107
- format: 'specmarket-legacy',
107
+ format: 'specmarket',
108
108
  detectedBy: 'heuristic',
109
109
  confidence: 'high',
110
110
  };