@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/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
+ }