@abianbiya/specflow 0.1.0
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/LICENSE +21 -0
- package/README.md +46 -0
- package/extensions/index.ts +323 -0
- package/package.json +37 -0
- package/skills/specflow/SKILL.md +48 -0
- package/skills/specflow/references/design-phase.md +27 -0
- package/skills/specflow/references/execution-phase.md +55 -0
- package/skills/specflow/references/lifecycle-phase.md +57 -0
- package/skills/specflow/references/project-setup.md +9 -0
- package/skills/specflow/references/requirements-phase.md +33 -0
- package/skills/specflow/references/tasks-phase.md +35 -0
- package/skills/specflow/templates/project.md +57 -0
- package/src/controller.test.ts +195 -0
- package/src/controller.ts +119 -0
- package/src/parse.test.ts +487 -0
- package/src/parse.ts +389 -0
- package/src/render.test.ts +395 -0
- package/src/render.ts +350 -0
- package/src/shared.ts +93 -0
- package/src/trace.test.ts +182 -0
- package/src/trace.ts +135 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
discoverSpecflows,
|
|
7
|
+
inferPhase,
|
|
8
|
+
parseGate,
|
|
9
|
+
parseStatus,
|
|
10
|
+
parseTasks,
|
|
11
|
+
splitFrontmatter,
|
|
12
|
+
type Gate,
|
|
13
|
+
type PhaseInput,
|
|
14
|
+
type SpecflowStatus,
|
|
15
|
+
} from "./parse.js";
|
|
16
|
+
|
|
17
|
+
let root: string;
|
|
18
|
+
let specflowDir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
root = await mkdtemp(join(tmpdir(), "specflow-pi-"));
|
|
22
|
+
specflowDir = join(root, ".specflow");
|
|
23
|
+
await mkdir(join(specflowDir, "specs"), { recursive: true });
|
|
24
|
+
});
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await rm(root, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
async function writeSpec(
|
|
30
|
+
dir: string,
|
|
31
|
+
files: { requirements?: string; design?: string; tasks?: string },
|
|
32
|
+
): Promise<string> {
|
|
33
|
+
await mkdir(dir, { recursive: true });
|
|
34
|
+
if (files.requirements !== undefined) await writeFile(join(dir, "requirements.md"), files.requirements);
|
|
35
|
+
if (files.design !== undefined) await writeFile(join(dir, "design.md"), files.design);
|
|
36
|
+
if (files.tasks !== undefined) await writeFile(join(dir, "tasks.md"), files.tasks);
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function flatSpec(name: string): string {
|
|
41
|
+
return join(specflowDir, "specs", name);
|
|
42
|
+
}
|
|
43
|
+
function legacySpec(kind: "active" | "completed" | "archived", name: string): string {
|
|
44
|
+
return join(specflowDir, "specs", kind, name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const REQUIREMENTS = "---\nstatus: draft\n---\n\n# Requirements\n\n- AC1: WHEN x, THE SYSTEM SHALL y.\n";
|
|
48
|
+
const TASKS_BODY = [
|
|
49
|
+
"# Implementation Plan",
|
|
50
|
+
"",
|
|
51
|
+
"## Overview",
|
|
52
|
+
"",
|
|
53
|
+
"- [ ] Unnumbered prerequisite checkbox is not a task",
|
|
54
|
+
"",
|
|
55
|
+
"## 1. Work group",
|
|
56
|
+
"",
|
|
57
|
+
"- [x] 1.1 First executable task",
|
|
58
|
+
" - Do the thing",
|
|
59
|
+
" - *Requirements: 1.1, 1.2*",
|
|
60
|
+
"",
|
|
61
|
+
"- [ ] 1.2 Second executable task",
|
|
62
|
+
" - Depends on: 1.1",
|
|
63
|
+
"",
|
|
64
|
+
"```",
|
|
65
|
+
"- [x] 9.9 This checkbox lives inside a fenced block",
|
|
66
|
+
"```",
|
|
67
|
+
"",
|
|
68
|
+
" - [x] 3.3 Indented sub-row is a detail, not a task",
|
|
69
|
+
"",
|
|
70
|
+
"## 2. Another group",
|
|
71
|
+
"",
|
|
72
|
+
"- [x] 2.1 Top-level in a later group",
|
|
73
|
+
].join("\n");
|
|
74
|
+
const TASKS_WITH_FRONTMATTER = `---\nstatus: active\ngate: review\n---\n\n${TASKS_BODY}`;
|
|
75
|
+
|
|
76
|
+
describe("splitFrontmatter", () => {
|
|
77
|
+
test("splits closed frontmatter from body", () => {
|
|
78
|
+
const r = splitFrontmatter("---\nstatus: active\n---\n\n# Body\n");
|
|
79
|
+
expect(r.frontmatter).toBe("status: active");
|
|
80
|
+
expect(r.body).toBe("\n# Body\n");
|
|
81
|
+
});
|
|
82
|
+
test("keeps whole content as body when frontmatter is absent or unclosed", () => {
|
|
83
|
+
expect(splitFrontmatter("# No fm\n").frontmatter).toBe("");
|
|
84
|
+
expect(splitFrontmatter("# No fm\n").body).toBe("# No fm\n");
|
|
85
|
+
const unclosed = splitFrontmatter("---\nstatus: active\n\n# Body\n");
|
|
86
|
+
expect(unclosed.frontmatter).toBe("");
|
|
87
|
+
expect(unclosed.body).toBe("---\nstatus: active\n\n# Body\n");
|
|
88
|
+
});
|
|
89
|
+
test("handles CRLF line endings", () => {
|
|
90
|
+
const r = splitFrontmatter("---\r\nstatus: active\r\n---\r\nbody");
|
|
91
|
+
expect(r.frontmatter).toBe("status: active");
|
|
92
|
+
expect(r.body).toBe("body");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("parseStatus", () => {
|
|
97
|
+
test("valid statuses pass through", () => {
|
|
98
|
+
expect(parseStatus("status: active")).toBe("active");
|
|
99
|
+
expect(parseStatus("status: completed")).toBe("completed");
|
|
100
|
+
expect(parseStatus("status: archived")).toBe("archived");
|
|
101
|
+
});
|
|
102
|
+
test("quoted values are unwrapped", () => {
|
|
103
|
+
expect(parseStatus('status: "active"')).toBe("active");
|
|
104
|
+
expect(parseStatus("status: 'archived'")).toBe("archived");
|
|
105
|
+
});
|
|
106
|
+
test("invalid values and missing keys are distinguished", () => {
|
|
107
|
+
expect(parseStatus("status: draft")).toBe("unknown");
|
|
108
|
+
expect(parseStatus("gate: review")).toBeUndefined();
|
|
109
|
+
expect(parseStatus("")).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("parseGate", () => {
|
|
114
|
+
test("review is the only defined value", () => {
|
|
115
|
+
expect(parseGate("gate: review")).toBe("review");
|
|
116
|
+
expect(parseGate('gate: "review"')).toBe("review");
|
|
117
|
+
});
|
|
118
|
+
test("absent and unknown values yield null", () => {
|
|
119
|
+
expect(parseGate("")).toBeNull();
|
|
120
|
+
expect(parseGate("status: active")).toBeNull();
|
|
121
|
+
expect(parseGate("gate: approved")).toBeNull();
|
|
122
|
+
expect(parseGate("gate: REVIEW")).toBeNull(); // case-sensitive scalar
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("parseTasks", () => {
|
|
127
|
+
test("parses dotted ids, done state, and details across work groups", () => {
|
|
128
|
+
const tasks = parseTasks(TASKS_BODY);
|
|
129
|
+
expect(tasks.map((t) => t.id)).toEqual(["1.1", "1.2", "2.1"]);
|
|
130
|
+
expect(tasks[0]!.done).toBe(true);
|
|
131
|
+
expect(tasks[1]!.done).toBe(false);
|
|
132
|
+
expect(tasks[0]!.title).toBe("First executable task");
|
|
133
|
+
expect(tasks[0]!.details).toEqual(["- Do the thing", "- *Requirements: 1.1, 1.2*"]);
|
|
134
|
+
expect(tasks[1]!.details).toEqual(["- Depends on: 1.1"]);
|
|
135
|
+
});
|
|
136
|
+
test("ignores unnumbered checkboxes and fenced checkbox-looking lines", () => {
|
|
137
|
+
const tasks = parseTasks(TASKS_BODY);
|
|
138
|
+
expect(tasks.some((t) => t.title.includes("prerequisite"))).toBe(false);
|
|
139
|
+
expect(tasks.some((t) => t.id === "9.9")).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
test("indented rows are details, never tasks", () => {
|
|
142
|
+
const tasks = parseTasks(TASKS_BODY);
|
|
143
|
+
expect(tasks.some((t) => t.id === "3.3")).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
test("accepts speclet-style separators and returns [] without tasks", () => {
|
|
146
|
+
expect(parseTasks("- [x] 1. Plain integer task\n").map((t) => t.id)).toEqual(["1"]);
|
|
147
|
+
expect(parseTasks("- [x] 2) Paren task\n").map((t) => t.id)).toEqual(["2"]);
|
|
148
|
+
expect(parseTasks("# no tasks here\n")).toEqual([]);
|
|
149
|
+
expect(parseTasks("")).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("inferPhase", () => {
|
|
154
|
+
const base: PhaseInput = {
|
|
155
|
+
status: "active",
|
|
156
|
+
hasRequirements: true,
|
|
157
|
+
hasDesign: true,
|
|
158
|
+
hasTasks: true,
|
|
159
|
+
total: 3,
|
|
160
|
+
gate: null,
|
|
161
|
+
};
|
|
162
|
+
const phase = (over: Partial<PhaseInput>) => inferPhase({ ...base, ...over });
|
|
163
|
+
|
|
164
|
+
test("archived and completed win outright", () => {
|
|
165
|
+
expect(phase({ status: "archived" })).toBe("archived");
|
|
166
|
+
expect(phase({ status: "completed" })).toBe("done");
|
|
167
|
+
});
|
|
168
|
+
test("tasks: gate set => 3, cleared => 4", () => {
|
|
169
|
+
expect(phase({ gate: "review" as Gate })).toBe(3);
|
|
170
|
+
expect(phase({ gate: null })).toBe(4);
|
|
171
|
+
});
|
|
172
|
+
test("metadata-only tasks.md (total 0) never reads as 3/4", () => {
|
|
173
|
+
expect(phase({ total: 0, gate: "review" as Gate })).toBe(2);
|
|
174
|
+
expect(phase({ total: 0 })).toBe(2);
|
|
175
|
+
});
|
|
176
|
+
test("design => 2, requirements-only => 1, regardless of requirements presence", () => {
|
|
177
|
+
expect(phase({ hasTasks: false, hasDesign: true })).toBe(2);
|
|
178
|
+
expect(phase({ hasTasks: false, hasDesign: false })).toBe(1);
|
|
179
|
+
expect(phase({ hasTasks: false, hasDesign: false, hasRequirements: false })).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("discoverSpecflows", () => {
|
|
184
|
+
test("missing .specflow directory is not an error", async () => {
|
|
185
|
+
const r = await discoverSpecflows(join(root, "absent"));
|
|
186
|
+
expect(r.specs).toEqual([]);
|
|
187
|
+
expect(r.dirError).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("flat layout: full spec with frontmatter status and gate", async () => {
|
|
191
|
+
await writeSpec(flatSpec("alpha"), {
|
|
192
|
+
requirements: REQUIREMENTS,
|
|
193
|
+
design: "# Design\n",
|
|
194
|
+
tasks: TASKS_WITH_FRONTMATTER,
|
|
195
|
+
});
|
|
196
|
+
const r = await discoverSpecflows(specflowDir);
|
|
197
|
+
expect(r.dirError).toBeUndefined();
|
|
198
|
+
expect(r.specs).toHaveLength(1);
|
|
199
|
+
const s = r.specs[0]!;
|
|
200
|
+
expect(s.name).toBe("alpha");
|
|
201
|
+
expect(s.legacy).toBe(false);
|
|
202
|
+
expect(s.status).toBe("active");
|
|
203
|
+
expect(s.statusSource).toBe("frontmatter");
|
|
204
|
+
expect(s.gate).toBe("review");
|
|
205
|
+
expect(s.total).toBe(3);
|
|
206
|
+
expect(s.done).toBe(2);
|
|
207
|
+
expect(s.phase).toBe(3); // gate set while tasks exist
|
|
208
|
+
expect(s.docs.requirements).toBe(join(flatSpec("alpha"), "requirements.md"));
|
|
209
|
+
expect(s.mtimeMs).toBeGreaterThan(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("gate cleared during execution infers Phase 4", async () => {
|
|
213
|
+
await writeSpec(flatSpec("executing"), {
|
|
214
|
+
requirements: REQUIREMENTS,
|
|
215
|
+
design: "# Design\n",
|
|
216
|
+
tasks: "---\nstatus: active\n---\n\n- [x] 1.1 Done task\n- [ ] 1.2 Open task\n",
|
|
217
|
+
});
|
|
218
|
+
const r = await discoverSpecflows(specflowDir);
|
|
219
|
+
expect(r.specs[0]!.phase).toBe(4);
|
|
220
|
+
expect(r.specs[0]!.gate).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("legacy layout without frontmatter infers status from the parent directory", async () => {
|
|
224
|
+
await writeSpec(legacySpec("active", "legacy-active"), {
|
|
225
|
+
requirements: "# Requirements\n",
|
|
226
|
+
design: "# Design\n",
|
|
227
|
+
tasks: "# Plan\n\n- [ ] 1.1 Task one\n",
|
|
228
|
+
});
|
|
229
|
+
await writeSpec(legacySpec("completed", "legacy-done"), { tasks: "# Plan\n" });
|
|
230
|
+
await writeSpec(legacySpec("archived", "legacy-arch"), { tasks: "# Plan\n" });
|
|
231
|
+
const r = await discoverSpecflows(specflowDir);
|
|
232
|
+
const byName = new Map(r.specs.map((s) => [s.name, s]));
|
|
233
|
+
const a = byName.get("legacy-active")!;
|
|
234
|
+
expect(a.legacy).toBe(true);
|
|
235
|
+
expect(a.status).toBe("active");
|
|
236
|
+
expect(a.statusSource).toBe("directory");
|
|
237
|
+
expect(a.phase).toBe(4);
|
|
238
|
+
expect(byName.get("legacy-done")!.status).toBe("completed");
|
|
239
|
+
expect(byName.get("legacy-done")!.phase).toBe("done");
|
|
240
|
+
expect(byName.get("legacy-arch")!.status).toBe("archived");
|
|
241
|
+
expect(byName.get("legacy-arch")!.phase).toBe("archived");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("legacy metadata takes precedence over the containing directory", async () => {
|
|
245
|
+
await writeSpec(legacySpec("active", "mixed"), {
|
|
246
|
+
tasks: "---\nstatus: archived\n---\n\n# Plan\n",
|
|
247
|
+
});
|
|
248
|
+
const r = await discoverSpecflows(specflowDir);
|
|
249
|
+
expect(r.specs[0]!.status).toBe("archived");
|
|
250
|
+
expect(r.specs[0]!.statusSource).toBe("frontmatter");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("requirements-only spec is eligible and reads as Phase 1", async () => {
|
|
254
|
+
await writeSpec(flatSpec("early"), { requirements: "# Requirements\n" });
|
|
255
|
+
const r = await discoverSpecflows(specflowDir);
|
|
256
|
+
const s = r.specs[0]!;
|
|
257
|
+
expect(s.phase).toBe(1);
|
|
258
|
+
expect(s.status).toBe("unknown");
|
|
259
|
+
expect(s.statusSource).toBe("none");
|
|
260
|
+
expect(s.total).toBe(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("design-only spec reads as Phase 2", async () => {
|
|
264
|
+
await writeSpec(flatSpec("design-only"), { design: "# Design\n" });
|
|
265
|
+
const r = await discoverSpecflows(specflowDir);
|
|
266
|
+
expect(r.specs[0]!.phase).toBe(2);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("flat spec with missing status is unknown and never guessed", async () => {
|
|
270
|
+
await writeSpec(flatSpec("no-status"), {
|
|
271
|
+
requirements: "# R\n",
|
|
272
|
+
design: "# D\n",
|
|
273
|
+
tasks: "# Plan\n\n- [ ] 1.1 Task\n",
|
|
274
|
+
});
|
|
275
|
+
const r = await discoverSpecflows(specflowDir);
|
|
276
|
+
expect(r.specs[0]!.status).toBe("unknown");
|
|
277
|
+
expect(r.specs[0]!.statusSource).toBe("none");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("flat spec with invalid status value is unknown (frontmatter source)", async () => {
|
|
281
|
+
await writeSpec(flatSpec("bad-status"), {
|
|
282
|
+
tasks: "---\nstatus: draft\n---\n\n- [ ] 1.1 Task\n",
|
|
283
|
+
});
|
|
284
|
+
const r = await discoverSpecflows(specflowDir);
|
|
285
|
+
expect(r.specs[0]!.status).toBe("unknown");
|
|
286
|
+
expect(r.specs[0]!.statusSource).toBe("frontmatter");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("metadata-only tasks.md: listed, zero tasks, not Phase 3/4", async () => {
|
|
290
|
+
await writeSpec(flatSpec("meta-only"), {
|
|
291
|
+
requirements: "# R\n",
|
|
292
|
+
design: "# D\n",
|
|
293
|
+
tasks: "---\nstatus: active\ngate: review\n---\n",
|
|
294
|
+
});
|
|
295
|
+
const r = await discoverSpecflows(specflowDir);
|
|
296
|
+
const s = r.specs[0]!;
|
|
297
|
+
expect(s.tasks).toEqual([]);
|
|
298
|
+
expect(s.total).toBe(0);
|
|
299
|
+
expect(s.phase).toBe(2);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("unknown gate value yields null gate", async () => {
|
|
303
|
+
await writeSpec(flatSpec("odd-gate"), {
|
|
304
|
+
requirements: "# R\n",
|
|
305
|
+
design: "# D\n",
|
|
306
|
+
tasks: "---\nstatus: active\ngate: waiting\n---\n\n- [ ] 1.1 Task\n",
|
|
307
|
+
});
|
|
308
|
+
const r = await discoverSpecflows(specflowDir);
|
|
309
|
+
expect(r.specs[0]!.gate).toBeNull();
|
|
310
|
+
expect(r.specs[0]!.phase).toBe(4);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("legacy status regression: specs/archived/older infers from parent, not own name", async () => {
|
|
314
|
+
// Regression for the visit() parentName bug: the spec's own basename ("older")
|
|
315
|
+
// is not a legacy kind; only the CONTAINING directory ("archived") is.
|
|
316
|
+
await writeSpec(legacySpec("archived", "older"), { tasks: "# Plan\n" });
|
|
317
|
+
const r = await discoverSpecflows(specflowDir);
|
|
318
|
+
expect(r.specs).toHaveLength(1);
|
|
319
|
+
expect(r.specs[0]!.name).toBe("older");
|
|
320
|
+
expect(r.specs[0]!.legacy).toBe(true);
|
|
321
|
+
expect(r.specs[0]!.status).toBe("archived");
|
|
322
|
+
expect(r.specs[0]!.statusSource).toBe("directory");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("flat spec's containing directory (specs) is never a legacy kind", async () => {
|
|
326
|
+
await writeSpec(flatSpec("plain"), { tasks: "# Plan\n" });
|
|
327
|
+
const r = await discoverSpecflows(specflowDir);
|
|
328
|
+
expect(r.specs[0]!.legacy).toBe(false);
|
|
329
|
+
expect(r.specs[0]!.status).toBe("unknown");
|
|
330
|
+
expect(r.specs[0]!.statusSource).toBe("none");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("a directory named requirements.md does not create a phantom spec", async () => {
|
|
334
|
+
await mkdir(join(specflowDir, "specs", "phantom", "requirements.md"), { recursive: true });
|
|
335
|
+
const r = await discoverSpecflows(specflowDir);
|
|
336
|
+
expect(r.specs).toEqual([]);
|
|
337
|
+
expect(r.dirError).toBeUndefined();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("non-spec directories are walked through; spec dirs are not descended into", async () => {
|
|
341
|
+
await writeSpec(flatSpec("outer"), { requirements: "# R\n" });
|
|
342
|
+
// A decoy nested INSIDE the spec dir must not create a second spec.
|
|
343
|
+
await writeSpec(join(flatSpec("outer"), "notes", "deep"), { design: "# D\n" });
|
|
344
|
+
// An empty directory chain yields nothing.
|
|
345
|
+
await mkdir(join(specflowDir, "specs", "empty-feature"), { recursive: true });
|
|
346
|
+
const r = await discoverSpecflows(specflowDir);
|
|
347
|
+
expect(r.specs).toHaveLength(1);
|
|
348
|
+
expect(r.specs[0]!.name).toBe("outer");
|
|
349
|
+
expect(r.dirError).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("results are sorted by name", async () => {
|
|
353
|
+
await writeSpec(flatSpec("zeta"), { requirements: "# R\n" });
|
|
354
|
+
await writeSpec(flatSpec("alpha"), { requirements: "# R\n" });
|
|
355
|
+
await writeSpec(flatSpec("mid"), { requirements: "# R\n" });
|
|
356
|
+
const r = await discoverSpecflows(specflowDir);
|
|
357
|
+
expect(r.specs.map((s) => s.name)).toEqual(["alpha", "mid", "zeta"]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("unreadable .specflow directory reports dirError without crashing", async () => {
|
|
361
|
+
await chmod(specflowDir, 0o000);
|
|
362
|
+
try {
|
|
363
|
+
const r = await discoverSpecflows(specflowDir);
|
|
364
|
+
expect(r.specs).toEqual([]);
|
|
365
|
+
expect(r.dirError).toBeDefined();
|
|
366
|
+
} finally {
|
|
367
|
+
await chmod(specflowDir, 0o755);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("unreadable tasks.md lists the spec with an error and unknown status", async () => {
|
|
372
|
+
const dir = flatSpec("locked");
|
|
373
|
+
await writeSpec(dir, { requirements: "# R\n", tasks: "---\nstatus: active\n---\n\n- [ ] 1.1 T\n" });
|
|
374
|
+
await chmod(join(dir, "tasks.md"), 0o000);
|
|
375
|
+
try {
|
|
376
|
+
const r = await discoverSpecflows(specflowDir);
|
|
377
|
+
expect(r.specs).toHaveLength(1);
|
|
378
|
+
expect(r.specs[0]!.error).toBeDefined();
|
|
379
|
+
expect(r.specs[0]!.status).toBe("unknown");
|
|
380
|
+
expect(r.specs[0]!.total).toBe(0);
|
|
381
|
+
} finally {
|
|
382
|
+
await chmod(join(dir, "tasks.md"), 0o644);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("symlinked directories and documents are skipped", async () => {
|
|
387
|
+
const outside = await mkdtemp(join(tmpdir(), "specflow-out-"));
|
|
388
|
+
try {
|
|
389
|
+
await writeSpec(join(outside, "linked-spec"), { requirements: "# R\n" });
|
|
390
|
+
await mkdir(join(specflowDir, "specs"), { recursive: true });
|
|
391
|
+
await symlink(join(outside, "linked-spec"), flatSpec("linked"));
|
|
392
|
+
const r = await discoverSpecflows(specflowDir);
|
|
393
|
+
expect(r.specs).toEqual([]);
|
|
394
|
+
} finally {
|
|
395
|
+
await rm(outside, { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe("spec.criteria (defined AC ids)", () => {
|
|
401
|
+
const REQUIREMENTS_FULL = [
|
|
402
|
+
"# Requirements",
|
|
403
|
+
"",
|
|
404
|
+
"- AC1: WHEN triggered, THE SYSTEM SHALL respond.",
|
|
405
|
+
"* **AC2** WHEN disconnected, THE SYSTEM SHALL retry.",
|
|
406
|
+
"**AC3** WHEN the session ends, THE SYSTEM SHALL clean up.",
|
|
407
|
+
"AC4: Fallback behavior is defined on this line.",
|
|
408
|
+
"",
|
|
409
|
+
"Prose mentions never define: see AC1 above, and (AC2) mid-sentence.",
|
|
410
|
+
"- see AC3 in the list prose below the marker",
|
|
411
|
+
"",
|
|
412
|
+
"```",
|
|
413
|
+
"- AC5: example inside a fenced block is not a definition",
|
|
414
|
+
"```",
|
|
415
|
+
"",
|
|
416
|
+
"- AC1: restated later — deduplicated, first position kept",
|
|
417
|
+
].join("\n");
|
|
418
|
+
|
|
419
|
+
test("extracts the three definition shapes, in order, deduplicated", async () => {
|
|
420
|
+
await writeSpec(flatSpec("crit"), { requirements: REQUIREMENTS_FULL, tasks: "# Plan\n" });
|
|
421
|
+
const r = await discoverSpecflows(specflowDir);
|
|
422
|
+
expect(r.specs[0]!.criteria).toEqual(["AC1", "AC2", "AC3", "AC4"]);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("missing, unreadable, or directory requirements.md yield [] without crash", async () => {
|
|
426
|
+
await writeSpec(flatSpec("no-crit"), { tasks: "# Plan\n" });
|
|
427
|
+
{
|
|
428
|
+
const r = await discoverSpecflows(specflowDir);
|
|
429
|
+
expect(r.specs[0]!.criteria).toEqual([]);
|
|
430
|
+
}
|
|
431
|
+
const dir2 = flatSpec("locked-crit");
|
|
432
|
+
await writeSpec(dir2, { requirements: "- AC1: hidden\n", tasks: "# Plan\n" });
|
|
433
|
+
await chmod(join(dir2, "requirements.md"), 0o000);
|
|
434
|
+
try {
|
|
435
|
+
const r2 = await discoverSpecflows(specflowDir);
|
|
436
|
+
const s = r2.specs.find((x) => x.name === "locked-crit")!;
|
|
437
|
+
expect(s.criteria).toEqual([]);
|
|
438
|
+
expect(s.error).toBeUndefined(); // unreadable requirements is silent, per contract
|
|
439
|
+
} finally {
|
|
440
|
+
await chmod(join(dir2, "requirements.md"), 0o644);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("indented definitions match; prose still never does", async () => {
|
|
445
|
+
// Regression: the anchor used to reject leading whitespace, silently
|
|
446
|
+
// under-reporting criteria and manufacturing false orphan warnings.
|
|
447
|
+
const indented = [
|
|
448
|
+
"# Requirements",
|
|
449
|
+
"",
|
|
450
|
+
" - AC5: indented bullet",
|
|
451
|
+
" AC6: indented bare",
|
|
452
|
+
"\t- AC7: deeply indented bullet",
|
|
453
|
+
" **AC8** indented bold",
|
|
454
|
+
"",
|
|
455
|
+
" prose that merely cites AC5 stays excluded",
|
|
456
|
+
" - prose after a bullet citing AC6 is excluded too",
|
|
457
|
+
"\t(AC7) mid-sentence excluded",
|
|
458
|
+
].join("\n");
|
|
459
|
+
await writeSpec(flatSpec("indented"), { requirements: indented, tasks: "# Plan\n" });
|
|
460
|
+
const r = await discoverSpecflows(specflowDir);
|
|
461
|
+
expect(r.specs[0]!.criteria).toEqual(["AC5", "AC6", "AC7", "AC8"]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("a requirements.md directory is not a document (no phantom criteria)", async () => {
|
|
465
|
+
await mkdir(join(specflowDir, "specs", "dir-doc", "requirements.md"), { recursive: true });
|
|
466
|
+
await writeSpec(join(specflowDir, "specs", "dir-doc"), { tasks: "# Plan\n" });
|
|
467
|
+
const r = await discoverSpecflows(specflowDir);
|
|
468
|
+
expect(r.specs[0]!.criteria).toEqual([]);
|
|
469
|
+
expect(r.specs[0]!.docs.requirements).toBeUndefined();
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe("status type sanity", () => {
|
|
474
|
+
test("SpecflowStatus set is closed", () => {
|
|
475
|
+
const all: SpecflowStatus[] = ["active", "completed", "archived", "unknown"];
|
|
476
|
+
expect(all).toHaveLength(4);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("name sanitization (F1)", () => {
|
|
481
|
+
test("a directory name carrying control sequences is stripped at the source", async () => {
|
|
482
|
+
await writeSpec(flatSpec("evil\u001b[31mname"), { requirements: "# R\n" });
|
|
483
|
+
const r = await discoverSpecflows(specflowDir);
|
|
484
|
+
expect(r.specs.map((s) => s.name)).toEqual(["evilname"]);
|
|
485
|
+
expect(r.specs[0].name).not.toContain("\u001b");
|
|
486
|
+
});
|
|
487
|
+
});
|