@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
|
@@ -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
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { createPublishCommand } from './commands/publish.js';
|
|
|
18
18
|
import { createForkCommand } from './commands/fork.js';
|
|
19
19
|
import { createReportCommand } from './commands/report.js';
|
|
20
20
|
import { createConfigCommand } from './commands/config.js';
|
|
21
|
+
import { createIssuesCommand } from './commands/issues.js';
|
|
22
|
+
import { createCommentCommand } from './commands/comment.js';
|
|
21
23
|
import { createRequire } from 'module';
|
|
22
24
|
|
|
23
25
|
// Dynamically read version from package.json so it stays in sync with npm publishes
|
|
@@ -62,6 +64,10 @@ program.addCommand(createInfoCommand());
|
|
|
62
64
|
program.addCommand(createPublishCommand());
|
|
63
65
|
program.addCommand(createForkCommand());
|
|
64
66
|
|
|
67
|
+
// Collaboration
|
|
68
|
+
program.addCommand(createIssuesCommand());
|
|
69
|
+
program.addCommand(createCommentCommand());
|
|
70
|
+
|
|
65
71
|
// Reporting & config
|
|
66
72
|
program.addCommand(createReportCommand());
|
|
67
73
|
program.addCommand(createConfigCommand());
|
package/src/lib/convex-client.ts
CHANGED
|
@@ -5,8 +5,6 @@ import createDebug from 'debug';
|
|
|
5
5
|
|
|
6
6
|
const debug = createDebug('specmarket:convex');
|
|
7
7
|
|
|
8
|
-
let _client: ConvexHttpClient | null = null;
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* Returns a ConvexHttpClient configured with the deployment URL and optional auth token.
|
|
12
10
|
* The URL is resolved from (in priority order):
|
|
@@ -23,6 +21,12 @@ export async function getConvexClient(token?: string): Promise<ConvexHttpClient>
|
|
|
23
21
|
config.convexUrl ??
|
|
24
22
|
DEFAULT_CONVEX_URL;
|
|
25
23
|
|
|
24
|
+
if (url.includes('placeholder.convex.cloud')) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'CONVEX_URL is not configured. Set the CONVEX_URL environment variable or run `specmarket config set convexUrl <url>`.'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
26
30
|
debug('Creating Convex client for URL: %s', url);
|
|
27
31
|
const client = new ConvexHttpClient(url);
|
|
28
32
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile, rm } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import {
|
|
7
|
+
detectSpecFormat,
|
|
8
|
+
fileExists,
|
|
9
|
+
directoryExists,
|
|
10
|
+
hasStoryFiles,
|
|
11
|
+
hasMarkdownFiles,
|
|
12
|
+
tryReadSidecar,
|
|
13
|
+
} from './format-detection.js';
|
|
14
|
+
|
|
15
|
+
describe('format-detection', () => {
|
|
16
|
+
let tmpDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
tmpDir = join(tmpdir(), `format-det-${randomUUID()}`);
|
|
20
|
+
await mkdir(tmpDir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('fileExists', () => {
|
|
28
|
+
it('returns true when file exists', async () => {
|
|
29
|
+
await writeFile(join(tmpDir, 'foo.txt'), 'hi');
|
|
30
|
+
expect(await fileExists(join(tmpDir, 'foo.txt'))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
it('returns false when file does not exist', async () => {
|
|
33
|
+
expect(await fileExists(join(tmpDir, 'nonexistent.txt'))).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('directoryExists', () => {
|
|
38
|
+
it('returns true when directory exists', async () => {
|
|
39
|
+
expect(await directoryExists(tmpDir)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('returns false when path does not exist', async () => {
|
|
42
|
+
expect(await directoryExists(join(tmpDir, 'nope'))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('hasStoryFiles', () => {
|
|
47
|
+
it('returns true when story-*.md exists', async () => {
|
|
48
|
+
await writeFile(join(tmpDir, 'story-1.md'), '# Story 1');
|
|
49
|
+
expect(await hasStoryFiles(tmpDir)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('returns false when no story files', async () => {
|
|
52
|
+
await writeFile(join(tmpDir, 'other.md'), 'x');
|
|
53
|
+
expect(await hasStoryFiles(tmpDir)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('hasMarkdownFiles', () => {
|
|
58
|
+
it('returns true when .md file in root', async () => {
|
|
59
|
+
await writeFile(join(tmpDir, 'readme.md'), 'x');
|
|
60
|
+
expect(await hasMarkdownFiles(tmpDir)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('returns true when .md in subdir', async () => {
|
|
63
|
+
await mkdir(join(tmpDir, 'docs'), { recursive: true });
|
|
64
|
+
await writeFile(join(tmpDir, 'docs', 'a.md'), 'x');
|
|
65
|
+
expect(await hasMarkdownFiles(tmpDir)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
it('returns false when no .md files', async () => {
|
|
68
|
+
await writeFile(join(tmpDir, 'file.txt'), 'x');
|
|
69
|
+
expect(await hasMarkdownFiles(tmpDir)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('tryReadSidecar', () => {
|
|
74
|
+
it('returns spec_format when specmarket.yaml is valid', async () => {
|
|
75
|
+
await writeFile(
|
|
76
|
+
join(tmpDir, 'specmarket.yaml'),
|
|
77
|
+
'spec_format: speckit\ndisplay_name: Test\ndescription: A long enough description here.'
|
|
78
|
+
);
|
|
79
|
+
const result = await tryReadSidecar(tmpDir);
|
|
80
|
+
expect(result).toEqual({ spec_format: 'speckit' });
|
|
81
|
+
});
|
|
82
|
+
it('returns null when file does not exist', async () => {
|
|
83
|
+
expect(await tryReadSidecar(tmpDir)).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
it('returns null when spec_format missing', async () => {
|
|
86
|
+
await writeFile(join(tmpDir, 'specmarket.yaml'), 'display_name: X\ndescription: Long desc.');
|
|
87
|
+
expect(await tryReadSidecar(tmpDir)).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('detectSpecFormat', () => {
|
|
92
|
+
it('empty dir → custom, low confidence', async () => {
|
|
93
|
+
const result = await detectSpecFormat(tmpDir);
|
|
94
|
+
expect(result.format).toBe('custom');
|
|
95
|
+
expect(result.confidence).toBe('low');
|
|
96
|
+
expect(result.detectedBy).toBe('heuristic');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('legacy dir (spec.yaml + PROMPT.md + SUCCESS_CRITERIA.md) → specmarket-legacy, high', async () => {
|
|
100
|
+
await writeFile(join(tmpDir, 'spec.yaml'), 'name: x');
|
|
101
|
+
await writeFile(join(tmpDir, 'PROMPT.md'), '# P');
|
|
102
|
+
await writeFile(join(tmpDir, 'SUCCESS_CRITERIA.md'), '- [ ] C');
|
|
103
|
+
const result = await detectSpecFormat(tmpDir);
|
|
104
|
+
expect(result.format).toBe('specmarket-legacy');
|
|
105
|
+
expect(result.confidence).toBe('high');
|
|
106
|
+
expect(result.detectedBy).toBe('heuristic');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('speckit dir (spec.md + tasks.md + .specify/) → speckit, high', async () => {
|
|
110
|
+
await writeFile(join(tmpDir, 'spec.md'), '# Spec');
|
|
111
|
+
await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
|
|
112
|
+
await mkdir(join(tmpDir, '.specify'), { recursive: true });
|
|
113
|
+
const result = await detectSpecFormat(tmpDir);
|
|
114
|
+
expect(result.format).toBe('speckit');
|
|
115
|
+
expect(result.confidence).toBe('high');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('speckit dir (spec.md + plan.md) without .specify → speckit, high', async () => {
|
|
119
|
+
await writeFile(join(tmpDir, 'spec.md'), '# Spec');
|
|
120
|
+
await writeFile(join(tmpDir, 'plan.md'), '# Plan');
|
|
121
|
+
const result = await detectSpecFormat(tmpDir);
|
|
122
|
+
expect(result.format).toBe('speckit');
|
|
123
|
+
expect(result.confidence).toBe('high');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('bmad dir (prd.md + architecture.md) → bmad, high', async () => {
|
|
127
|
+
await writeFile(join(tmpDir, 'prd.md'), '# PRD');
|
|
128
|
+
await writeFile(join(tmpDir, 'architecture.md'), '# Arch');
|
|
129
|
+
const result = await detectSpecFormat(tmpDir);
|
|
130
|
+
expect(result.format).toBe('bmad');
|
|
131
|
+
expect(result.confidence).toBe('high');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('bmad dir (prd.md + story-1.md) → bmad, high', async () => {
|
|
135
|
+
await writeFile(join(tmpDir, 'prd.md'), '# PRD');
|
|
136
|
+
await writeFile(join(tmpDir, 'story-1.md'), '# Story');
|
|
137
|
+
const result = await detectSpecFormat(tmpDir);
|
|
138
|
+
expect(result.format).toBe('bmad');
|
|
139
|
+
expect(result.confidence).toBe('high');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('ralph dir (prd.json with userStories) → ralph, high', async () => {
|
|
143
|
+
await writeFile(
|
|
144
|
+
join(tmpDir, 'prd.json'),
|
|
145
|
+
JSON.stringify({ userStories: [{ title: 'As a user I want X' }] })
|
|
146
|
+
);
|
|
147
|
+
const result = await detectSpecFormat(tmpDir);
|
|
148
|
+
expect(result.format).toBe('ralph');
|
|
149
|
+
expect(result.confidence).toBe('high');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('ralph prd.json with empty userStories still detects ralph', async () => {
|
|
153
|
+
await writeFile(join(tmpDir, 'prd.json'), JSON.stringify({ userStories: [] }));
|
|
154
|
+
const result = await detectSpecFormat(tmpDir);
|
|
155
|
+
expect(result.format).toBe('ralph');
|
|
156
|
+
expect(result.confidence).toBe('high');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('sidecar override: specmarket.yaml with spec_format → sidecar value, high', async () => {
|
|
160
|
+
await writeFile(join(tmpDir, 'spec.yaml'), 'x');
|
|
161
|
+
await writeFile(join(tmpDir, 'PROMPT.md'), 'x');
|
|
162
|
+
await writeFile(join(tmpDir, 'SUCCESS_CRITERIA.md'), 'x');
|
|
163
|
+
await writeFile(
|
|
164
|
+
join(tmpDir, 'specmarket.yaml'),
|
|
165
|
+
'spec_format: my-custom-format\ndisplay_name: X\ndescription: Long enough description.'
|
|
166
|
+
);
|
|
167
|
+
const result = await detectSpecFormat(tmpDir);
|
|
168
|
+
expect(result.format).toBe('my-custom-format');
|
|
169
|
+
expect(result.detectedBy).toBe('sidecar');
|
|
170
|
+
expect(result.confidence).toBe('high');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('sidecar with invalid format string still returned as format', async () => {
|
|
174
|
+
await writeFile(
|
|
175
|
+
join(tmpDir, 'specmarket.yaml'),
|
|
176
|
+
'spec_format: unknown-format\ndisplay_name: X\ndescription: Long enough description.'
|
|
177
|
+
);
|
|
178
|
+
const result = await detectSpecFormat(tmpDir);
|
|
179
|
+
expect(result.format).toBe('unknown-format');
|
|
180
|
+
expect(result.detectedBy).toBe('sidecar');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('mixed signals: sidecar takes priority over heuristic', async () => {
|
|
184
|
+
await writeFile(join(tmpDir, 'spec.yaml'), 'x');
|
|
185
|
+
await writeFile(join(tmpDir, 'PROMPT.md'), 'x');
|
|
186
|
+
await writeFile(join(tmpDir, 'SUCCESS_CRITERIA.md'), 'x');
|
|
187
|
+
await writeFile(
|
|
188
|
+
join(tmpDir, 'specmarket.yaml'),
|
|
189
|
+
'spec_format: speckit\ndisplay_name: X\ndescription: Long enough description.'
|
|
190
|
+
);
|
|
191
|
+
const result = await detectSpecFormat(tmpDir);
|
|
192
|
+
expect(result.format).toBe('speckit');
|
|
193
|
+
expect(result.detectedBy).toBe('sidecar');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('custom dir (random .md files) → custom, low', async () => {
|
|
197
|
+
await writeFile(join(tmpDir, 'readme.md'), 'x');
|
|
198
|
+
const result = await detectSpecFormat(tmpDir);
|
|
199
|
+
expect(result.format).toBe('custom');
|
|
200
|
+
expect(result.confidence).toBe('low');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('prd.json with valid JSON object detects as ralph (validation checks userStories)', async () => {
|
|
204
|
+
await writeFile(join(tmpDir, 'prd.json'), JSON.stringify({ other: true }));
|
|
205
|
+
const result = await detectSpecFormat(tmpDir);
|
|
206
|
+
expect(result.format).toBe('ralph');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('prd.json invalid JSON does not detect as ralph', async () => {
|
|
210
|
+
await writeFile(join(tmpDir, 'prd.json'), 'not json');
|
|
211
|
+
await writeFile(join(tmpDir, 'readme.md'), 'x');
|
|
212
|
+
const result = await detectSpecFormat(tmpDir);
|
|
213
|
+
expect(result.format).toBe('custom');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('legacy requires all three files', async () => {
|
|
217
|
+
await writeFile(join(tmpDir, 'spec.yaml'), 'x');
|
|
218
|
+
await writeFile(join(tmpDir, 'PROMPT.md'), 'x');
|
|
219
|
+
const result = await detectSpecFormat(tmpDir);
|
|
220
|
+
expect(result.format).not.toBe('specmarket-legacy');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFile, readdir, access } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { SIDECAR_FILENAME } from '@specmarket/shared';
|
|
5
|
+
import type { FormatDetectionResult } from '@specmarket/shared';
|
|
6
|
+
|
|
7
|
+
/** Check if a file exists and is readable. */
|
|
8
|
+
export async function fileExists(filePath: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
await access(filePath);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Check if a directory exists and is readable. */
|
|
18
|
+
export async function directoryExists(dirPath: string): Promise<boolean> {
|
|
19
|
+
try {
|
|
20
|
+
await access(dirPath);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Check if directory contains any story-*.md files (BMAD pattern). */
|
|
28
|
+
export async function hasStoryFiles(dir: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
31
|
+
return entries.some(
|
|
32
|
+
(e) => e.isFile() && e.name.startsWith('story-') && e.name.endsWith('.md')
|
|
33
|
+
);
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Check if directory (non-recursive) or subdirs contain any .md files. */
|
|
40
|
+
export async function hasMarkdownFiles(dir: string): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.isFile() && entry.name.endsWith('.md')) return true;
|
|
45
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
46
|
+
const found = await hasMarkdownFiles(join(dir, entry.name));
|
|
47
|
+
if (found) return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read and parse specmarket.yaml; return spec_format if valid, else null. */
|
|
57
|
+
export async function tryReadSidecar(
|
|
58
|
+
dir: string
|
|
59
|
+
): Promise<{ spec_format: string } | null> {
|
|
60
|
+
const path = join(dir, SIDECAR_FILENAME);
|
|
61
|
+
if (!(await fileExists(path))) return null;
|
|
62
|
+
try {
|
|
63
|
+
const raw = await readFile(path, 'utf-8');
|
|
64
|
+
const parsed = parseYaml(raw) as unknown;
|
|
65
|
+
if (parsed && typeof parsed === 'object' && 'spec_format' in parsed) {
|
|
66
|
+
const fmt = (parsed as { spec_format: unknown }).spec_format;
|
|
67
|
+
if (typeof fmt === 'string' && fmt.length > 0) return { spec_format: fmt };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detect spec format for a directory. Priority:
|
|
77
|
+
* 1. Sidecar specmarket.yaml with spec_format → use that value, high confidence
|
|
78
|
+
* 2. specmarket-legacy: spec.yaml + PROMPT.md + SUCCESS_CRITERIA.md
|
|
79
|
+
* 3. speckit: spec.md + (plan.md | tasks.md) + .specify/ directory
|
|
80
|
+
* 4. bmad: prd.md + (architecture.md | story-*.md)
|
|
81
|
+
* 5. ralph: prd.json with userStories[] array
|
|
82
|
+
* 6. custom: any .md files → low confidence
|
|
83
|
+
* 7. Fallback: custom, low confidence
|
|
84
|
+
*/
|
|
85
|
+
export async function detectSpecFormat(dir: string): Promise<FormatDetectionResult> {
|
|
86
|
+
const sidecar = await tryReadSidecar(dir);
|
|
87
|
+
if (sidecar) {
|
|
88
|
+
return {
|
|
89
|
+
format: sidecar.spec_format,
|
|
90
|
+
detectedBy: 'sidecar',
|
|
91
|
+
confidence: 'high',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const hasSpecYaml = await fileExists(join(dir, 'spec.yaml'));
|
|
96
|
+
const hasPromptMd = await fileExists(join(dir, 'PROMPT.md'));
|
|
97
|
+
const hasSuccessCriteria = await fileExists(join(dir, 'SUCCESS_CRITERIA.md'));
|
|
98
|
+
if (hasSpecYaml && hasPromptMd && hasSuccessCriteria) {
|
|
99
|
+
return {
|
|
100
|
+
format: 'specmarket-legacy',
|
|
101
|
+
detectedBy: 'heuristic',
|
|
102
|
+
confidence: 'high',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (hasPromptMd && hasSuccessCriteria) {
|
|
106
|
+
return {
|
|
107
|
+
format: 'specmarket-legacy',
|
|
108
|
+
detectedBy: 'heuristic',
|
|
109
|
+
confidence: 'high',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hasSpecMd = await fileExists(join(dir, 'spec.md'));
|
|
114
|
+
const hasPlanMd = await fileExists(join(dir, 'plan.md'));
|
|
115
|
+
const hasTasksMd = await fileExists(join(dir, 'tasks.md'));
|
|
116
|
+
if (hasSpecMd && (hasPlanMd || hasTasksMd)) {
|
|
117
|
+
return {
|
|
118
|
+
format: 'speckit',
|
|
119
|
+
detectedBy: 'heuristic',
|
|
120
|
+
confidence: 'high',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (hasSpecMd) {
|
|
124
|
+
return {
|
|
125
|
+
format: 'speckit',
|
|
126
|
+
detectedBy: 'heuristic',
|
|
127
|
+
confidence: 'high',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const hasPrdMd = await fileExists(join(dir, 'prd.md'));
|
|
132
|
+
const hasArchitectureMd = await fileExists(join(dir, 'architecture.md'));
|
|
133
|
+
const storyFiles = await hasStoryFiles(dir);
|
|
134
|
+
if (hasPrdMd && (hasArchitectureMd || storyFiles)) {
|
|
135
|
+
return {
|
|
136
|
+
format: 'bmad',
|
|
137
|
+
detectedBy: 'heuristic',
|
|
138
|
+
confidence: 'high',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const prdJsonPath = join(dir, 'prd.json');
|
|
143
|
+
if (await fileExists(prdJsonPath)) {
|
|
144
|
+
try {
|
|
145
|
+
const raw = await readFile(prdJsonPath, 'utf-8');
|
|
146
|
+
const data = JSON.parse(raw) as unknown;
|
|
147
|
+
if (data && typeof data === 'object') {
|
|
148
|
+
return {
|
|
149
|
+
format: 'ralph',
|
|
150
|
+
detectedBy: 'heuristic',
|
|
151
|
+
confidence: 'high',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// not valid JSON
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (await hasMarkdownFiles(dir)) {
|
|
160
|
+
return {
|
|
161
|
+
format: 'custom',
|
|
162
|
+
detectedBy: 'heuristic',
|
|
163
|
+
confidence: 'low',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
format: 'custom',
|
|
169
|
+
detectedBy: 'heuristic',
|
|
170
|
+
confidence: 'low',
|
|
171
|
+
};
|
|
172
|
+
}
|