@specmarket/cli 0.0.4 → 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.
- package/README.md +1 -1
- package/dist/{chunk-MS2DYACY.js → chunk-OTXWWFAO.js} +42 -3
- package/dist/chunk-OTXWWFAO.js.map +1 -0
- package/dist/{config-R5KWZSJP.js → config-5JMI3YAR.js} +2 -2
- package/dist/index.js +1945 -252
- 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 +245 -0
- package/src/commands/init.ts +359 -25
- package/src/commands/issues.test.ts +382 -0
- package/src/commands/issues.ts +436 -0
- package/src/commands/login.test.ts +99 -0
- package/src/commands/login.ts +2 -6
- package/src/commands/logout.test.ts +54 -0
- package/src/commands/publish.test.ts +159 -0
- package/src/commands/publish.ts +1 -0
- package/src/commands/report.test.ts +181 -0
- package/src/commands/run.test.ts +419 -0
- package/src/commands/run.ts +71 -3
- package/src/commands/search.test.ts +147 -0
- package/src/commands/validate.test.ts +206 -2
- package/src/commands/validate.ts +315 -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/meta-instructions.test.ts +340 -0
- package/src/lib/meta-instructions.ts +562 -0
- package/src/lib/ralph-loop.test.ts +404 -0
- package/src/lib/ralph-loop.ts +501 -95
- package/src/lib/telemetry.ts +7 -1
- package/dist/chunk-MS2DYACY.js.map +0 -1
- /package/dist/{config-R5KWZSJP.js.map → config-5JMI3YAR.js.map} +0 -0
package/src/commands/validate.ts
CHANGED
|
@@ -1,189 +1,61 @@
|
|
|
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
|
+
fileExists,
|
|
17
|
+
directoryExists,
|
|
18
|
+
hasStoryFiles,
|
|
19
|
+
hasMarkdownFiles,
|
|
20
|
+
} from '../lib/format-detection.js';
|
|
13
21
|
|
|
14
22
|
/**
|
|
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.
|
|
23
|
+
* Recursively collects file paths relative to baseDir, filtered by extension.
|
|
31
24
|
*/
|
|
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');
|
|
25
|
+
async function collectFiles(
|
|
26
|
+
currentDir: string,
|
|
27
|
+
baseDir: string,
|
|
28
|
+
extensions: Set<string>
|
|
29
|
+
): Promise<string[]> {
|
|
30
|
+
const results: string[] = [];
|
|
69
31
|
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
|
-
}
|
|
32
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const fullPath = join(currentDir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
37
|
+
const subFiles = await collectFiles(fullPath, baseDir, extensions);
|
|
38
|
+
results.push(...subFiles);
|
|
39
|
+
} else if (entry.isFile()) {
|
|
40
|
+
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop()! : '';
|
|
41
|
+
if (extensions.has(ext)) {
|
|
42
|
+
results.push(relative(baseDir, fullPath));
|
|
134
43
|
}
|
|
135
44
|
}
|
|
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
45
|
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Directory unreadable
|
|
165
48
|
}
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
valid: errors.length === 0,
|
|
169
|
-
errors,
|
|
170
|
-
warnings,
|
|
171
|
-
};
|
|
49
|
+
return results;
|
|
172
50
|
}
|
|
173
51
|
|
|
174
52
|
/**
|
|
175
53
|
* Scans all Markdown/YAML files in the spec directory for relative file
|
|
176
54
|
* 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
55
|
*/
|
|
181
56
|
export async function detectCircularReferences(dir: string): Promise<string[]> {
|
|
182
|
-
// Collect all text files in the spec directory (md, yaml, yml)
|
|
183
57
|
const textExtensions = new Set(['.md', '.yaml', '.yml']);
|
|
184
58
|
const files = await collectFiles(dir, dir, textExtensions);
|
|
185
|
-
|
|
186
|
-
// Build adjacency list: file -> files it references via Markdown links
|
|
187
59
|
const graph = new Map<string, Set<string>>();
|
|
188
60
|
const linkPattern = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
|
|
189
61
|
|
|
@@ -194,32 +66,31 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
|
|
|
194
66
|
let match: RegExpExecArray | null;
|
|
195
67
|
while ((match = linkPattern.exec(content)) !== null) {
|
|
196
68
|
const target = match[1]!;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
69
|
+
if (
|
|
70
|
+
target.startsWith('http://') ||
|
|
71
|
+
target.startsWith('https://') ||
|
|
72
|
+
target.startsWith('#') ||
|
|
73
|
+
target.startsWith('mailto:')
|
|
74
|
+
) {
|
|
200
75
|
continue;
|
|
201
76
|
}
|
|
202
|
-
// Strip anchor fragments (e.g., "./SPEC.md#section" -> "./SPEC.md")
|
|
203
77
|
const targetPath = target.split('#')[0]!;
|
|
204
78
|
if (!targetPath) continue;
|
|
205
|
-
|
|
206
|
-
// Resolve relative to the file's directory
|
|
207
79
|
const fileDir = join(dir, file, '..');
|
|
208
80
|
const resolvedTarget = normalize(relative(dir, resolve(fileDir, targetPath)));
|
|
209
|
-
|
|
210
|
-
// Only track references to files within the spec directory
|
|
211
81
|
if (!resolvedTarget.startsWith('..') && files.includes(resolvedTarget)) {
|
|
212
82
|
refs.add(resolvedTarget);
|
|
213
83
|
}
|
|
214
84
|
}
|
|
215
85
|
} catch {
|
|
216
|
-
//
|
|
86
|
+
// skip
|
|
217
87
|
}
|
|
218
88
|
graph.set(file, refs);
|
|
219
89
|
}
|
|
220
90
|
|
|
221
|
-
|
|
222
|
-
|
|
91
|
+
const WHITE = 0,
|
|
92
|
+
GRAY = 1,
|
|
93
|
+
BLACK = 2;
|
|
223
94
|
const color = new Map<string, number>();
|
|
224
95
|
const parent = new Map<string, string | null>();
|
|
225
96
|
const cycles: string[] = [];
|
|
@@ -233,7 +104,6 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
|
|
|
233
104
|
const neighbors = graph.get(node) ?? new Set();
|
|
234
105
|
for (const neighbor of neighbors) {
|
|
235
106
|
if (color.get(neighbor) === GRAY) {
|
|
236
|
-
// Found a cycle — reconstruct the path
|
|
237
107
|
const cyclePath: string[] = [neighbor, node];
|
|
238
108
|
let curr = node;
|
|
239
109
|
while (curr !== neighbor) {
|
|
@@ -263,44 +133,297 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
|
|
|
263
133
|
}
|
|
264
134
|
|
|
265
135
|
/**
|
|
266
|
-
*
|
|
136
|
+
* Content validation when spec_format is "specmarket" (spec.yaml + PROMPT.md + SPEC.md + SUCCESS_CRITERIA.md + stdlib).
|
|
137
|
+
* Pushes to errors/warnings.
|
|
267
138
|
*/
|
|
268
|
-
async function
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
): Promise<
|
|
273
|
-
const
|
|
139
|
+
async function validateSpecmarketContent(
|
|
140
|
+
dir: string,
|
|
141
|
+
errors: string[],
|
|
142
|
+
warnings: string[]
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
for (const file of REQUIRED_SPEC_FILES) {
|
|
145
|
+
const filePath = join(dir, file);
|
|
146
|
+
try {
|
|
147
|
+
await access(filePath);
|
|
148
|
+
const content = await readFile(filePath, 'utf-8');
|
|
149
|
+
if (content.trim().length === 0) {
|
|
150
|
+
errors.push(`${file} exists but is empty`);
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
errors.push(`Required file missing: ${file}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const stdlibDir = join(dir, 'stdlib');
|
|
158
|
+
for (const file of REQUIRED_STDLIB_FILES) {
|
|
159
|
+
const filePath = join(stdlibDir, file);
|
|
160
|
+
try {
|
|
161
|
+
await access(filePath);
|
|
162
|
+
const content = await readFile(filePath, 'utf-8');
|
|
163
|
+
if (content.trim().length === 0) {
|
|
164
|
+
errors.push(`stdlib/${file} exists but is empty`);
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
errors.push(`Required file missing: stdlib/${file}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let specYaml: unknown = null;
|
|
172
|
+
const specYamlPath = join(dir, 'spec.yaml');
|
|
274
173
|
try {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
174
|
+
const raw = await readFile(specYamlPath, 'utf-8');
|
|
175
|
+
specYaml = parseYaml(raw);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
errors.push(`spec.yaml: Failed to parse YAML: ${(err as Error).message}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const parseResult = specYamlSchema.safeParse(specYaml);
|
|
182
|
+
if (!parseResult.success) {
|
|
183
|
+
for (const issue of parseResult.error.issues) {
|
|
184
|
+
errors.push(`spec.yaml: ${issue.path.join('.')} — ${issue.message}`);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const parsed = parseResult.data;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const criteriaContent = await readFile(join(dir, 'SUCCESS_CRITERIA.md'), 'utf-8');
|
|
193
|
+
const hasCriterion = /^- \[[ x]\]/m.test(criteriaContent);
|
|
194
|
+
if (!hasCriterion) {
|
|
195
|
+
errors.push(
|
|
196
|
+
'SUCCESS_CRITERIA.md: Must contain at least one criterion in format: - [ ] criterion text'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Already caught in required files check
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const cycles = await detectCircularReferences(dir);
|
|
204
|
+
for (const cycle of cycles) {
|
|
205
|
+
errors.push(`Circular reference detected: ${cycle}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (parsed.infrastructure) {
|
|
209
|
+
const infra = parsed.infrastructure;
|
|
210
|
+
if (
|
|
211
|
+
['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type) &&
|
|
212
|
+
infra.services.length === 0
|
|
213
|
+
) {
|
|
214
|
+
warnings.push(
|
|
215
|
+
`${parsed.output_type} should typically define infrastructure services (database, auth, etc.)`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (!infra.setup_time_minutes) {
|
|
219
|
+
warnings.push('infrastructure.setup_time_minutes is not set');
|
|
220
|
+
}
|
|
221
|
+
for (const service of infra.services) {
|
|
222
|
+
if (service.default_provider) {
|
|
223
|
+
const providerNames = service.providers.map((p) => p.name);
|
|
224
|
+
if (!providerNames.includes(service.default_provider)) {
|
|
225
|
+
errors.push(
|
|
226
|
+
`infrastructure.services[${service.name}].default_provider "${service.default_provider}" ` +
|
|
227
|
+
`does not match any defined provider (${providerNames.join(', ')})`
|
|
228
|
+
);
|
|
287
229
|
}
|
|
288
230
|
}
|
|
289
231
|
}
|
|
232
|
+
} else {
|
|
233
|
+
if (['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type)) {
|
|
234
|
+
warnings.push(
|
|
235
|
+
'No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc.'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (parsed.estimated_tokens < 1000) {
|
|
241
|
+
warnings.push(
|
|
242
|
+
`estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (parsed.estimated_tokens > 10_000_000) {
|
|
246
|
+
warnings.push(`estimated_tokens (${parsed.estimated_tokens}) seems very high.`);
|
|
247
|
+
}
|
|
248
|
+
if (parsed.estimated_cost_usd < 0.01) {
|
|
249
|
+
warnings.push(
|
|
250
|
+
`estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (parsed.estimated_time_minutes < 1) {
|
|
254
|
+
warnings.push(
|
|
255
|
+
`estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
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.
|
|
263
|
+
*/
|
|
264
|
+
export async function validateSpec(specPath: string): Promise<ValidationResult> {
|
|
265
|
+
const dir = resolve(specPath);
|
|
266
|
+
const errors: string[] = [];
|
|
267
|
+
const warnings: string[] = [];
|
|
268
|
+
let format: string | undefined;
|
|
269
|
+
let formatDetectedBy: 'sidecar' | 'heuristic' | undefined = 'sidecar';
|
|
270
|
+
|
|
271
|
+
// Universal: directory non-empty
|
|
272
|
+
try {
|
|
273
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
274
|
+
const hasAnyFile = entries.some((e) => e.isFile());
|
|
275
|
+
if (!hasAnyFile) {
|
|
276
|
+
errors.push('Directory is empty or has no readable files');
|
|
277
|
+
}
|
|
290
278
|
} catch {
|
|
291
|
-
|
|
279
|
+
errors.push('Directory is empty or unreadable');
|
|
292
280
|
}
|
|
293
|
-
|
|
281
|
+
|
|
282
|
+
const sidecarPath = join(dir, SIDECAR_FILENAME);
|
|
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 {
|
|
288
|
+
try {
|
|
289
|
+
const raw = await readFile(sidecarPath, 'utf-8');
|
|
290
|
+
const parsed = parseYaml(raw) as unknown;
|
|
291
|
+
const sidecarResult = specmarketSidecarSchema.safeParse(parsed);
|
|
292
|
+
if (!sidecarResult.success) {
|
|
293
|
+
for (const issue of sidecarResult.error.issues) {
|
|
294
|
+
errors.push(
|
|
295
|
+
`${SIDECAR_FILENAME}: ${issue.path.join('.')} — ${issue.message}`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
const sidecar = sidecarResult.data;
|
|
300
|
+
format = sidecar.spec_format;
|
|
301
|
+
|
|
302
|
+
if (sidecar.estimated_tokens !== undefined) {
|
|
303
|
+
if (sidecar.estimated_tokens < 1000) {
|
|
304
|
+
warnings.push(
|
|
305
|
+
`estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (sidecar.estimated_tokens > 10_000_000) {
|
|
309
|
+
warnings.push(`estimated_tokens (${sidecar.estimated_tokens}) seems very high.`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (sidecar.estimated_cost_usd !== undefined && sidecar.estimated_cost_usd < 0.01) {
|
|
313
|
+
warnings.push(
|
|
314
|
+
`estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
sidecar.estimated_time_minutes !== undefined &&
|
|
319
|
+
sidecar.estimated_time_minutes < 1
|
|
320
|
+
) {
|
|
321
|
+
warnings.push(
|
|
322
|
+
`estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
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
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} catch (err) {
|
|
400
|
+
errors.push(
|
|
401
|
+
`${SIDECAR_FILENAME}: Failed to read or parse: ${(err as Error).message}`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
valid: errors.length === 0,
|
|
408
|
+
errors,
|
|
409
|
+
warnings,
|
|
410
|
+
format,
|
|
411
|
+
formatDetectedBy,
|
|
412
|
+
};
|
|
294
413
|
}
|
|
295
414
|
|
|
296
415
|
export function createValidateCommand(): Command {
|
|
297
416
|
return new Command('validate')
|
|
298
417
|
.description('Validate a spec directory for completeness and schema compliance')
|
|
299
|
-
.argument('
|
|
418
|
+
.argument('[path]', 'Path to the spec directory (defaults to current directory)', '.')
|
|
300
419
|
.action(async (specPath: string) => {
|
|
301
420
|
try {
|
|
302
421
|
const result = await validateSpec(specPath);
|
|
303
422
|
|
|
423
|
+
if (result.format !== undefined) {
|
|
424
|
+
console.log(chalk.gray(`Format: ${result.format}`));
|
|
425
|
+
}
|
|
426
|
+
|
|
304
427
|
if (result.warnings.length > 0) {
|
|
305
428
|
console.log(chalk.yellow('\nWarnings:'));
|
|
306
429
|
for (const warning of result.warnings) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// --- Hoisted mocks ---
|
|
4
|
+
|
|
5
|
+
const { mockQuery, mockClient, mockLoadCreds } = vi.hoisted(() => {
|
|
6
|
+
const mockQuery = vi.fn();
|
|
7
|
+
const mockClient = { query: mockQuery };
|
|
8
|
+
const mockLoadCreds = vi.fn();
|
|
9
|
+
return { mockQuery, mockClient, mockLoadCreds };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
vi.mock('../lib/convex-client.js', () => ({
|
|
13
|
+
getConvexClient: vi.fn().mockResolvedValue(mockClient),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../lib/auth.js', () => ({
|
|
17
|
+
loadCredentials: mockLoadCreds,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@specmarket/convex/api', () => ({
|
|
21
|
+
api: {
|
|
22
|
+
users: { getMe: 'users.getMe' },
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
27
|
+
|
|
28
|
+
import { handleWhoami } from './whoami.js';
|
|
29
|
+
|
|
30
|
+
describe('handleWhoami', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shows not-logged-in message when no credentials exist', async () => {
|
|
36
|
+
mockLoadCreds.mockResolvedValue(null);
|
|
37
|
+
|
|
38
|
+
await handleWhoami();
|
|
39
|
+
|
|
40
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
41
|
+
expect.stringContaining('Not logged in')
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('displays full profile from Convex when available', async () => {
|
|
46
|
+
mockLoadCreds.mockResolvedValue({
|
|
47
|
+
token: 'test-token',
|
|
48
|
+
username: 'alice',
|
|
49
|
+
expiresAt: Date.now() + 30 * 24 * 3600_000,
|
|
50
|
+
});
|
|
51
|
+
mockQuery.mockResolvedValue({
|
|
52
|
+
username: 'alice',
|
|
53
|
+
displayName: 'Alice Smith',
|
|
54
|
+
role: 'user',
|
|
55
|
+
totalSpecsPublished: 5,
|
|
56
|
+
reputationScore: 120,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await handleWhoami();
|
|
60
|
+
|
|
61
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
62
|
+
expect.stringContaining('Authenticated as')
|
|
63
|
+
);
|
|
64
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
65
|
+
expect.stringContaining('@alice')
|
|
66
|
+
);
|
|
67
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
68
|
+
expect.stringContaining('Alice Smith')
|
|
69
|
+
);
|
|
70
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
71
|
+
expect.stringContaining('120')
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('falls back to cached credentials when Convex is unreachable', async () => {
|
|
76
|
+
mockLoadCreds.mockResolvedValue({
|
|
77
|
+
token: 'test-token',
|
|
78
|
+
username: 'bob',
|
|
79
|
+
expiresAt: Date.now() + 30 * 24 * 3600_000,
|
|
80
|
+
});
|
|
81
|
+
mockQuery.mockRejectedValue(new Error('Network error'));
|
|
82
|
+
|
|
83
|
+
await handleWhoami();
|
|
84
|
+
|
|
85
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
86
|
+
expect.stringContaining('Authenticated (cached)')
|
|
87
|
+
);
|
|
88
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
89
|
+
expect.stringContaining('@bob')
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('shows "unknown" when cached credentials have no username', async () => {
|
|
94
|
+
mockLoadCreds.mockResolvedValue({
|
|
95
|
+
token: 'test-token',
|
|
96
|
+
expiresAt: Date.now() + 3600_000,
|
|
97
|
+
});
|
|
98
|
+
mockQuery.mockRejectedValue(new Error('Network error'));
|
|
99
|
+
|
|
100
|
+
await handleWhoami();
|
|
101
|
+
|
|
102
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
103
|
+
expect.stringContaining('@unknown')
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|