@specmarket/cli 0.0.4 → 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.
- package/README.md +1 -1
- package/dist/{chunk-MS2DYACY.js → chunk-DLEMNRTH.js} +19 -2
- package/dist/chunk-DLEMNRTH.js.map +1 -0
- package/dist/{config-R5KWZSJP.js → config-OAU6SJLC.js} +2 -2
- package/dist/index.js +980 -181
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/comment.test.ts +211 -0
- package/src/commands/comment.ts +176 -0
- package/src/commands/fork.test.ts +163 -0
- package/src/commands/info.test.ts +192 -0
- package/src/commands/info.ts +66 -2
- package/src/commands/init.test.ts +106 -0
- package/src/commands/init.ts +12 -10
- package/src/commands/issues.test.ts +377 -0
- package/src/commands/issues.ts +443 -0
- package/src/commands/login.test.ts +99 -0
- package/src/commands/logout.test.ts +54 -0
- package/src/commands/publish.test.ts +146 -0
- package/src/commands/report.test.ts +181 -0
- package/src/commands/run.test.ts +213 -0
- package/src/commands/run.ts +10 -2
- package/src/commands/search.test.ts +147 -0
- package/src/commands/validate.test.ts +129 -2
- package/src/commands/validate.ts +333 -192
- package/src/commands/whoami.test.ts +106 -0
- package/src/index.ts +6 -0
- package/src/lib/convex-client.ts +6 -2
- package/src/lib/format-detection.test.ts +223 -0
- package/src/lib/format-detection.ts +172 -0
- package/src/lib/ralph-loop.ts +49 -20
- package/src/lib/telemetry.ts +2 -1
- package/dist/chunk-MS2DYACY.js.map +0 -1
- /package/dist/{config-R5KWZSJP.js.map → config-OAU6SJLC.js.map} +0 -0
package/src/commands/validate.ts
CHANGED
|
@@ -1,189 +1,62 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { readFile, readdir, access
|
|
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
|
-
*
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
//
|
|
87
|
+
// skip
|
|
217
88
|
}
|
|
218
89
|
graph.set(file, refs);
|
|
219
90
|
}
|
|
220
91
|
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
*
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
): Promise<
|
|
273
|
-
const
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
281
|
+
errors.push('Directory is empty or unreadable');
|
|
292
282
|
}
|
|
293
|
-
|
|
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('
|
|
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) {
|