@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
package/src/parse.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse.ts — discovery and parsing of `.specflow/**` specs (pure where
|
|
3
|
+
* possible; only discoverSpecflows touches the filesystem).
|
|
4
|
+
*
|
|
5
|
+
* Ground truth (specflow/references/tasks-phase.md:14-19 and live specs):
|
|
6
|
+
* - tasks.md has NO `## Tasks` section; executable tasks (`- [x] 1.1 Title`)
|
|
7
|
+
* live under h2 work-group headings (`## 1. [Work group]`, `## Phase N: ...`).
|
|
8
|
+
* - Task ids are dotted and hierarchical (`N`, `N.M`, `N.M.K`); unnumbered
|
|
9
|
+
* checkbox rows (prerequisites, parent groups) are NOT executable tasks.
|
|
10
|
+
* - Status and gate are frontmatter scalars in tasks.md (`status:`,
|
|
11
|
+
* `gate: review`); legacy specs infer status from specs/active|completed|archived/.
|
|
12
|
+
*
|
|
13
|
+
* Forked from speclet-tui/src/speclet.ts (fence masking, frontmatter scalar
|
|
14
|
+
* parsing, task row/detail capture); diverged for the multi-document spec model.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
18
|
+
import { basename, dirname, join } from "node:path";
|
|
19
|
+
import { stripControlSequences } from "./shared.js";
|
|
20
|
+
|
|
21
|
+
export type SpecflowStatus = "active" | "completed" | "archived" | "unknown";
|
|
22
|
+
export type SpecflowPhase = 1 | 2 | 3 | 4 | "done" | "archived";
|
|
23
|
+
export type Gate = "review" | null;
|
|
24
|
+
|
|
25
|
+
export interface SpecflowTask {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
done: boolean;
|
|
29
|
+
details?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SpecflowSpec {
|
|
33
|
+
/** Absolute path of the spec directory. */
|
|
34
|
+
dir: string;
|
|
35
|
+
/** Directory basename = display name. */
|
|
36
|
+
name: string;
|
|
37
|
+
status: SpecflowStatus;
|
|
38
|
+
statusSource: "frontmatter" | "directory" | "none";
|
|
39
|
+
/** Lives under specs/active|completed|archived/. */
|
|
40
|
+
legacy: boolean;
|
|
41
|
+
/** Absolute paths of present documents (present keys only). */
|
|
42
|
+
docs: { requirements?: string; design?: string; tasks?: string };
|
|
43
|
+
/** AC ids DEFINED in requirements.md (e.g. ["AC1","AC2"]); [] when absent or unreadable. */
|
|
44
|
+
criteria: string[];
|
|
45
|
+
tasks: SpecflowTask[];
|
|
46
|
+
done: number;
|
|
47
|
+
total: number;
|
|
48
|
+
phase: SpecflowPhase;
|
|
49
|
+
gate: Gate;
|
|
50
|
+
/** Newest document mtime. */
|
|
51
|
+
mtimeMs: number;
|
|
52
|
+
/** Read/parse failure: spec is still listed. */
|
|
53
|
+
error?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DiscoveryResult {
|
|
57
|
+
specs: SpecflowSpec[];
|
|
58
|
+
/** Missing .specflow directory is NOT an error (panel hides); other failures are reported here. */
|
|
59
|
+
dirError?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const VALID_STATUSES = ["active", "completed", "archived"] as const;
|
|
63
|
+
const LEGACY_DIRS = ["active", "completed", "archived"] as const;
|
|
64
|
+
const DOC_NAMES = ["requirements.md", "design.md", "tasks.md"] as const;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fence state machine: returns the active fence marker (first char repeated
|
|
68
|
+
* to the opening length) after this line. A closing fence must use the same
|
|
69
|
+
* character type as the opener; opening fence lines are themselves masked.
|
|
70
|
+
*/
|
|
71
|
+
function fenceTransition(line: string, active: string | undefined): string | undefined {
|
|
72
|
+
const m = line.match(/^\s*(`{3,}|~{3,})/);
|
|
73
|
+
if (!m) return active;
|
|
74
|
+
const ch = m[1][0];
|
|
75
|
+
if (active === undefined) return ch.repeat(m[1].length);
|
|
76
|
+
if (ch === active[0] && m[1].length >= active.length) return undefined;
|
|
77
|
+
return active;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True for lines that are inside a fenced block (fence lines included). */
|
|
81
|
+
function fenceMask(lines: string[]): boolean[] {
|
|
82
|
+
const mask: boolean[] = new Array(lines.length).fill(false);
|
|
83
|
+
let active: string | undefined;
|
|
84
|
+
for (let i = 0; i < lines.length; i++) {
|
|
85
|
+
const next = fenceTransition(lines[i], active);
|
|
86
|
+
mask[i] = active !== undefined || next !== undefined;
|
|
87
|
+
active = next;
|
|
88
|
+
}
|
|
89
|
+
return mask;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Split a document into raw frontmatter (between the first `---` line and its
|
|
94
|
+
* closing `---`) and the body after it. Absent or unclosed frontmatter yields
|
|
95
|
+
* an empty frontmatter and keeps the whole content as body.
|
|
96
|
+
*/
|
|
97
|
+
export function splitFrontmatter(content: string): { frontmatter: string; body: string } {
|
|
98
|
+
const lines = content.split(/\r?\n/);
|
|
99
|
+
if ((lines[0]?.trim() ?? "") !== "---") return { frontmatter: "", body: content };
|
|
100
|
+
let close = -1;
|
|
101
|
+
for (let i = 1; i < lines.length; i++) {
|
|
102
|
+
if (lines[i].trim() === "---") {
|
|
103
|
+
close = i;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (close === -1) return { frontmatter: "", body: content };
|
|
108
|
+
return {
|
|
109
|
+
frontmatter: lines.slice(1, close).join("\n"),
|
|
110
|
+
body: lines.slice(close + 1).join("\n"),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function frontmatterScalar(frontmatter: string, key: string): string | undefined {
|
|
115
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
116
|
+
const m = line.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`));
|
|
117
|
+
if (!m) continue;
|
|
118
|
+
let value = m[1];
|
|
119
|
+
const q = value.match(/^(["'])(.*)\1\s*$/);
|
|
120
|
+
if (q) value = q[2];
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The `status:` scalar from tasks.md frontmatter: active|completed|archived,
|
|
128
|
+
* anything else => "unknown", no key => undefined (caller falls back to the
|
|
129
|
+
* legacy parent directory, then "none").
|
|
130
|
+
*/
|
|
131
|
+
export function parseStatus(frontmatter: string): SpecflowStatus | undefined {
|
|
132
|
+
const value = frontmatterScalar(frontmatter, "status");
|
|
133
|
+
if (value === undefined) return undefined;
|
|
134
|
+
return (VALID_STATUSES as readonly string[]).includes(value) ? (value as SpecflowStatus) : "unknown";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The `gate:` scalar from tasks.md frontmatter; `review` is the only defined value, anything else => null. */
|
|
138
|
+
export function parseGate(frontmatter: string): Gate {
|
|
139
|
+
const value = frontmatterScalar(frontmatter, "gate");
|
|
140
|
+
return value === "review" ? "review" : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const TASK_RE = /^- \[([xX ])\] (\d+(?:\.\d+)*)(?:[.)])?[ \t]+(.*)$/;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* AC criteria DEFINITIONS in requirements.md: the `ACn` token must BEGIN a
|
|
147
|
+
* definition position — after optional leading whitespace, an optional list
|
|
148
|
+
* marker (`-`, `*`, `+`), then an optional bold marker. Matches `- AC1: WHEN
|
|
149
|
+
* ...` (list item, at any indentation), `* **AC2** WHEN ...` and `**AC3** WHEN
|
|
150
|
+
* ...` (bold label), and `AC4: text` (line start). A prose mention such as
|
|
151
|
+
* "see AC1 above" or "(AC1)" mid-sentence is NOT a definition and never
|
|
152
|
+
* matches, because the AC token must still come first after the optional
|
|
153
|
+
* bullet/bold. Fenced blocks are ignored.
|
|
154
|
+
*/
|
|
155
|
+
const CRITERIA_DEF_RE = /^\s*(?:[-*+]\s+)?(?:\*\*)?(AC\d+)\b/;
|
|
156
|
+
|
|
157
|
+
/** Defined AC ids in first-appearance order, deduplicated. Never throws. */
|
|
158
|
+
function extractCriteria(content: string): string[] {
|
|
159
|
+
const lines = content.split(/\r?\n/);
|
|
160
|
+
const mask = fenceMask(lines);
|
|
161
|
+
const seen = new Set<string>();
|
|
162
|
+
const out: string[] = [];
|
|
163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
164
|
+
if (mask[i]) continue;
|
|
165
|
+
const m = lines[i].match(CRITERIA_DEF_RE);
|
|
166
|
+
if (m && !seen.has(m[1])) {
|
|
167
|
+
seen.add(m[1]);
|
|
168
|
+
out.push(m[1]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Executable task rows across the WHOLE tasks.md body: top-level (column-0)
|
|
176
|
+
* numbered checkbox rows with a dotted id (`1`, `1.1`, `1.1.2`), optionally
|
|
177
|
+
* closed by `.` or `)`. Unnumbered checkbox rows (prerequisites, parent group
|
|
178
|
+
* boxes) are ignored; fenced blocks are ignored; indented rows directly below
|
|
179
|
+
* a task are captured as its details (description, requirements references).
|
|
180
|
+
*/
|
|
181
|
+
export function parseTasks(body: string): SpecflowTask[] {
|
|
182
|
+
const lines = body.split(/\r?\n/);
|
|
183
|
+
const mask = fenceMask(lines);
|
|
184
|
+
const tasks: SpecflowTask[] = [];
|
|
185
|
+
let current: SpecflowTask | undefined;
|
|
186
|
+
let capturing = false; // details are the indented rows DIRECTLY below a task row
|
|
187
|
+
for (let i = 0; i < lines.length; i++) {
|
|
188
|
+
if (mask[i]) continue;
|
|
189
|
+
if (lines[i].trim() === "") {
|
|
190
|
+
capturing = false;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const m = lines[i].match(TASK_RE);
|
|
194
|
+
if (m) {
|
|
195
|
+
current = {
|
|
196
|
+
id: m[2],
|
|
197
|
+
done: m[1] !== " ",
|
|
198
|
+
title: stripControlSequences(m[3].trim()),
|
|
199
|
+
details: [],
|
|
200
|
+
};
|
|
201
|
+
tasks.push(current);
|
|
202
|
+
capturing = true;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (current && capturing && /^\s+\S/.test(lines[i])) {
|
|
206
|
+
const detail = stripControlSequences(lines[i].trim());
|
|
207
|
+
if (detail) current.details!.push(detail);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return tasks;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface PhaseInput {
|
|
214
|
+
status: SpecflowStatus;
|
|
215
|
+
hasRequirements: boolean;
|
|
216
|
+
hasDesign: boolean;
|
|
217
|
+
hasTasks: boolean;
|
|
218
|
+
total: number;
|
|
219
|
+
gate: Gate;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Workflow phase: archived/completed win outright; a spec with executable
|
|
224
|
+
* tasks is Phase 3 (Tasks) while gated, Phase 4 (Executing) once cleared;
|
|
225
|
+
* otherwise the newest missing document decides (design => 2, else 1). A
|
|
226
|
+
* metadata-only tasks.md never reads as Phase 3/4.
|
|
227
|
+
*/
|
|
228
|
+
export function inferPhase(input: PhaseInput): SpecflowPhase {
|
|
229
|
+
if (input.status === "archived") return "archived";
|
|
230
|
+
if (input.status === "completed") return "done";
|
|
231
|
+
if (input.hasTasks && input.total > 0) return input.gate === "review" ? 3 : 4;
|
|
232
|
+
if (input.hasDesign) return 2;
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function docMtime(path: string): Promise<number> {
|
|
237
|
+
try {
|
|
238
|
+
const st = await lstat(path);
|
|
239
|
+
return st.isFile() ? st.mtimeMs : 0;
|
|
240
|
+
} catch {
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function readDoc(path: string): Promise<{ content: string } | { error: string }> {
|
|
246
|
+
try {
|
|
247
|
+
const st = await lstat(path);
|
|
248
|
+
if (!st.isFile()) return { error: `${basename(path)} is not a regular file` };
|
|
249
|
+
return { content: await readFile(path, "utf8") };
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return { error: "vanished" };
|
|
252
|
+
return { error: String(e) };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function parseSpecDir(dir: string, parentName: string): Promise<SpecflowSpec> {
|
|
257
|
+
const spec: SpecflowSpec = {
|
|
258
|
+
dir,
|
|
259
|
+
// Stripped at the source: `name` reaches the panel heading and popup header,
|
|
260
|
+
// which render it raw, so control sequences must never survive parsing (F1).
|
|
261
|
+
name: stripControlSequences(basename(dir)),
|
|
262
|
+
status: "unknown",
|
|
263
|
+
statusSource: "none",
|
|
264
|
+
legacy: (LEGACY_DIRS as readonly string[]).includes(parentName),
|
|
265
|
+
docs: {},
|
|
266
|
+
criteria: [],
|
|
267
|
+
tasks: [],
|
|
268
|
+
done: 0,
|
|
269
|
+
total: 0,
|
|
270
|
+
phase: 1,
|
|
271
|
+
gate: null,
|
|
272
|
+
mtimeMs: 0,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
let entries: Awaited<ReturnType<typeof readdir>>;
|
|
276
|
+
try {
|
|
277
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
278
|
+
} catch (e) {
|
|
279
|
+
spec.error = String(e);
|
|
280
|
+
return spec;
|
|
281
|
+
}
|
|
282
|
+
const present = new Set<string>();
|
|
283
|
+
for (const entry of entries) {
|
|
284
|
+
if (entry.isFile() && (DOC_NAMES as readonly string[]).includes(entry.name)) {
|
|
285
|
+
present.add(entry.name);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let errors: string[] = [];
|
|
290
|
+
for (const doc of DOC_NAMES) {
|
|
291
|
+
if (!present.has(doc)) continue;
|
|
292
|
+
const path = join(dir, doc);
|
|
293
|
+
const key = doc.replace(/\.md$/, "") as keyof SpecflowSpec["docs"];
|
|
294
|
+
spec.docs[key] = path;
|
|
295
|
+
const mtime = await docMtime(path);
|
|
296
|
+
if (mtime > spec.mtimeMs) spec.mtimeMs = mtime;
|
|
297
|
+
if (doc === "requirements.md") {
|
|
298
|
+
const read = await readDoc(path);
|
|
299
|
+
if ("content" in read) spec.criteria = extractCriteria(read.content);
|
|
300
|
+
// unreadable/missing requirements.md: criteria stays [], no error field
|
|
301
|
+
// (discovery needs nothing else from it; the viewer reports read failures).
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (doc !== "tasks.md") continue;
|
|
305
|
+
const read = await readDoc(path);
|
|
306
|
+
if ("error" in read) {
|
|
307
|
+
if (read.error !== "vanished") errors.push(read.error);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const { frontmatter, body } = splitFrontmatter(read.content);
|
|
311
|
+
const fmStatus = parseStatus(frontmatter);
|
|
312
|
+
if (fmStatus !== undefined) {
|
|
313
|
+
spec.status = fmStatus;
|
|
314
|
+
spec.statusSource = "frontmatter";
|
|
315
|
+
} else if (spec.legacy) {
|
|
316
|
+
spec.status = parentName as SpecflowStatus;
|
|
317
|
+
spec.statusSource = "directory";
|
|
318
|
+
}
|
|
319
|
+
spec.gate = parseGate(frontmatter);
|
|
320
|
+
spec.tasks = parseTasks(body);
|
|
321
|
+
spec.total = spec.tasks.length;
|
|
322
|
+
spec.done = spec.tasks.filter((t) => t.done).length;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (spec.statusSource === "none" && spec.legacy) {
|
|
326
|
+
// Legacy spec whose tasks.md is missing, unreadable, or status-less: infer from the parent directory.
|
|
327
|
+
spec.status = parentName as SpecflowStatus;
|
|
328
|
+
spec.statusSource = "directory";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (errors.length > 0) spec.error = errors.join("; ");
|
|
332
|
+
spec.phase = inferPhase({
|
|
333
|
+
status: spec.status,
|
|
334
|
+
hasRequirements: spec.docs.requirements !== undefined,
|
|
335
|
+
hasDesign: spec.docs.design !== undefined,
|
|
336
|
+
hasTasks: spec.docs.tasks !== undefined,
|
|
337
|
+
total: spec.total,
|
|
338
|
+
gate: spec.gate,
|
|
339
|
+
});
|
|
340
|
+
return spec;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Discover every spec under `<cwd>/.specflow`: recursive walk; a directory is
|
|
345
|
+
* a spec iff it DIRECTLY contains any of requirements.md / design.md /
|
|
346
|
+
* tasks.md (never descended into). Covers flat `specs/{feature}/` and legacy
|
|
347
|
+
* `specs/{active|completed|archived}/{feature}/`. Symlinked directories and
|
|
348
|
+
* documents are skipped (speclet.ts convention). Results are sorted by name.
|
|
349
|
+
*/
|
|
350
|
+
export async function discoverSpecflows(specflowDir: string): Promise<DiscoveryResult> {
|
|
351
|
+
let rootEntries: Awaited<ReturnType<typeof readdir>>;
|
|
352
|
+
try {
|
|
353
|
+
rootEntries = await readdir(specflowDir, { withFileTypes: true });
|
|
354
|
+
} catch (e) {
|
|
355
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return { specs: [] };
|
|
356
|
+
return { specs: [], dirError: `cannot read .specflow directory: ${String(e)}` };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const specs: SpecflowSpec[] = [];
|
|
360
|
+
const dirErrors: string[] = [];
|
|
361
|
+
|
|
362
|
+
async function visit(dir: string): Promise<void> {
|
|
363
|
+
let entries: Awaited<ReturnType<typeof readdir>>;
|
|
364
|
+
try {
|
|
365
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
366
|
+
} catch (e) {
|
|
367
|
+
dirErrors.push(`cannot read ${dir}: ${String(e)}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const subdirs = entries.filter((d) => d.isDirectory() && !d.isSymbolicLink());
|
|
371
|
+
const docNames = entries.filter((d) => d.isFile() && (DOC_NAMES as readonly string[]).includes(d.name));
|
|
372
|
+
if (docNames.length > 0) {
|
|
373
|
+
// The spec's containing directory decides legacy inference: specs/{feature} => "specs",
|
|
374
|
+
// specs/{active|completed|archived}/{feature} => the legacy kind.
|
|
375
|
+
specs.push(await parseSpecDir(dir, basename(dirname(dir))));
|
|
376
|
+
return; // never descend into a spec directory
|
|
377
|
+
}
|
|
378
|
+
for (const subdir of subdirs) await visit(join(dir, subdir.name));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const entry of rootEntries) {
|
|
382
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
383
|
+
await visit(join(specflowDir, entry.name));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
specs.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
388
|
+
return { specs, dirError: dirErrors.length > 0 ? dirErrors.join("; ") : undefined };
|
|
389
|
+
}
|