@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/render.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render.ts — pure selection and rendering helpers for the specflow panel and
|
|
3
|
+
* document viewer. Declared fork of speclet-tui/src/render.ts (AC7): the panel
|
|
4
|
+
* is phase-oriented rather than task-oriented, so StyleKind, the widget layout,
|
|
5
|
+
* and the listing differ. Generic text primitives come from ./shared.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { padVisible, stripControlSequences, visibleLen } from "./shared.js";
|
|
10
|
+
import { dependencyGraph, nextTask, traceCriteria } from "./trace.js";
|
|
11
|
+
import type { SpecflowPhase, SpecflowSpec, SpecflowStatus, SpecflowTask } from "./parse.js";
|
|
12
|
+
|
|
13
|
+
export type Truncate = (line: string, width: number) => string;
|
|
14
|
+
|
|
15
|
+
/** Panel budget (AC1): rule + heading + phase rail, far under the cap. */
|
|
16
|
+
export const DEFAULT_MAX_LINES = 6;
|
|
17
|
+
|
|
18
|
+
/** Semantic parts of the panel, each styleable independently. */
|
|
19
|
+
export type StyleKind =
|
|
20
|
+
| "heading" // "Specflow: {name}"
|
|
21
|
+
| "meta" // " · {status} · {done}/{total}"
|
|
22
|
+
| "phase" // "Phase 3/4 Tasks"
|
|
23
|
+
| "gate" // " · awaiting your review"
|
|
24
|
+
| "next" // "Next: 2.1 Wire the Fastify hook"
|
|
25
|
+
| "warn" // "⚠ 1 unclaimed AC"
|
|
26
|
+
| "body" // unstyled popup body
|
|
27
|
+
| "more" // popup overflow indicator
|
|
28
|
+
| "rule"; // separator line opening the panel
|
|
29
|
+
|
|
30
|
+
export type Styler = (text: string, kind: StyleKind) => string;
|
|
31
|
+
|
|
32
|
+
export const plainStyler: Styler = (text) => text;
|
|
33
|
+
|
|
34
|
+
const INDENT = " "; // left margin so the panel reads as its own block
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Selection rank, lowest wins (AC1): gate-paused specs first, then in-progress
|
|
38
|
+
* (a spec with unknown status ranks as in-progress), completed, archived.
|
|
39
|
+
*/
|
|
40
|
+
const STATUS_RANK: Record<SpecflowStatus, number> = {
|
|
41
|
+
active: 1,
|
|
42
|
+
unknown: 1,
|
|
43
|
+
completed: 2,
|
|
44
|
+
archived: 3,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function rank(spec: SpecflowSpec): number {
|
|
48
|
+
return spec.gate !== null ? 0 : STATUS_RANK[spec.status];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pick the spec the panel shows. A pinned directory wins while it still
|
|
53
|
+
* exists; otherwise rank by gate/status, then newest document mtime, then name
|
|
54
|
+
* ascending.
|
|
55
|
+
*/
|
|
56
|
+
export function selectActive(specs: SpecflowSpec[], pinned?: string): SpecflowSpec | undefined {
|
|
57
|
+
if (pinned !== undefined) {
|
|
58
|
+
const hit = specs.find((s) => s.dir === pinned);
|
|
59
|
+
if (hit) return hit;
|
|
60
|
+
}
|
|
61
|
+
const sorted = [...specs].sort((a, b) => {
|
|
62
|
+
const r = rank(a) - rank(b);
|
|
63
|
+
if (r !== 0) return r;
|
|
64
|
+
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
|
65
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
66
|
+
});
|
|
67
|
+
return sorted[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Human label for the inferred phase; the gate badge never replaces it (AC1). */
|
|
71
|
+
export function phaseLabel(phase: SpecflowPhase): string {
|
|
72
|
+
switch (phase) {
|
|
73
|
+
case 1:
|
|
74
|
+
return "Phase 1/4 Requirements";
|
|
75
|
+
case 2:
|
|
76
|
+
return "Phase 2/4 Design";
|
|
77
|
+
case 3:
|
|
78
|
+
return "Phase 3/4 Tasks";
|
|
79
|
+
case 4:
|
|
80
|
+
return "Phase 4/4 Execution";
|
|
81
|
+
case "done":
|
|
82
|
+
return "Done";
|
|
83
|
+
case "archived":
|
|
84
|
+
return "Archived";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** " · {done}/{total}" when tasks exist, plus the read-failure marker. */
|
|
89
|
+
function metaSuffix(spec: SpecflowSpec): string {
|
|
90
|
+
const count = spec.total > 0 ? ` · ${spec.done}/${spec.total}` : "";
|
|
91
|
+
return `${count}${spec.error ? " · unreadable" : ""}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The next-action row (AC4): the task to run now, why nothing can run, or that
|
|
96
|
+
* work is finished. Undefined when the spec declares no tasks (requirements-only
|
|
97
|
+
* specs have nothing actionable to report).
|
|
98
|
+
*/
|
|
99
|
+
export function nextActionLine(spec: SpecflowSpec): string | undefined {
|
|
100
|
+
if (spec.tasks.length === 0) return undefined;
|
|
101
|
+
if (spec.tasks.every((t) => t.done)) return "All tasks done";
|
|
102
|
+
|
|
103
|
+
const next = nextTask(spec);
|
|
104
|
+
if (next) return `Next: ${next.id} ${next.title}`;
|
|
105
|
+
|
|
106
|
+
const deps = dependencyGraph(spec);
|
|
107
|
+
const blocked = deps.blocked[0];
|
|
108
|
+
if (blocked) {
|
|
109
|
+
const title = spec.tasks.find((t) => t.id === blocked.id)?.title;
|
|
110
|
+
return `Waiting: ${blocked.id}${title ? ` ${title}` : ""} (blocked by ${blocked.by.join(", ")})`;
|
|
111
|
+
}
|
|
112
|
+
const dangling = deps.dangling[0];
|
|
113
|
+
if (dangling) {
|
|
114
|
+
return `Waiting: ${dangling.id} (unknown dependency ${dangling.missing.join(", ")})`;
|
|
115
|
+
}
|
|
116
|
+
return "No runnable task";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The single traceability warning row (AC5): unclaimed requirements, orphaned
|
|
121
|
+
* citations, and dangling dependencies, or undefined when the spec is consistent.
|
|
122
|
+
*/
|
|
123
|
+
export function traceWarningLine(spec: SpecflowSpec): string | undefined {
|
|
124
|
+
const trace = traceCriteria(spec);
|
|
125
|
+
const deps = dependencyGraph(spec);
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
if (trace.unclaimed.length > 0) parts.push(`${trace.unclaimed.length} unclaimed AC`);
|
|
128
|
+
if (trace.orphan.length > 0) {
|
|
129
|
+
parts.push(`${trace.orphan.length} orphan ${trace.orphan.length === 1 ? "criterion" : "criteria"}`);
|
|
130
|
+
}
|
|
131
|
+
if (deps.dangling.length > 0) parts.push(`${deps.dangling.length} dangling dep`);
|
|
132
|
+
return parts.length > 0 ? `⚠ ${parts.join(" · ")}` : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Render the panel lines for `spec` within `width`: an opening rule, the
|
|
137
|
+
* heading (name, frontmatter status, task count), the phase rail with the
|
|
138
|
+
* optional review-gate badge, the next-action row, and at most one traceability
|
|
139
|
+
* warning row (AC1, AC4, AC5). `truncate` must be width-aware for the final
|
|
140
|
+
* text; `maxLines` caps the total returned rows.
|
|
141
|
+
*/
|
|
142
|
+
export function renderWidgetLines(
|
|
143
|
+
spec: SpecflowSpec,
|
|
144
|
+
width: number,
|
|
145
|
+
maxLines: number = DEFAULT_MAX_LINES,
|
|
146
|
+
truncate: Truncate,
|
|
147
|
+
styler: Styler = plainStyler,
|
|
148
|
+
): string[] {
|
|
149
|
+
const rule = truncate(styler(` ${"─".repeat(Math.max(0, width - 2))}`, "rule"), width);
|
|
150
|
+
const heading =
|
|
151
|
+
styler(`${INDENT}Specflow: ${spec.name}`, "heading") + styler(` · ${spec.status}${metaSuffix(spec)}`, "meta");
|
|
152
|
+
const rail =
|
|
153
|
+
`${INDENT}${styler(phaseLabel(spec.phase), "phase")}` +
|
|
154
|
+
(spec.gate !== null ? styler(" · awaiting your review", "gate") : "");
|
|
155
|
+
|
|
156
|
+
const lines = [rule, truncate(heading, width), truncate(rail, width)];
|
|
157
|
+
const next = nextActionLine(spec);
|
|
158
|
+
if (next) lines.push(truncate(styler(`${INDENT}${next}`, "next"), width));
|
|
159
|
+
const warning = traceWarningLine(spec);
|
|
160
|
+
if (warning) lines.push(truncate(styler(`${INDENT}${warning}`, "warn"), width));
|
|
161
|
+
return lines.slice(0, Math.max(1, maxLines));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Wrap speclet's popup options verbatim so the popup code stays parallel. */export interface DetailsRenderOptions {
|
|
165
|
+
/** Left padding for every content line (default: none). */
|
|
166
|
+
indent?: string;
|
|
167
|
+
/** One track character per visible body row, appended when content overflows. */
|
|
168
|
+
scrollbar?: string[];
|
|
169
|
+
/** Skip styling of body lines (for pre-rendered markdown output). */
|
|
170
|
+
plainBody?: boolean;
|
|
171
|
+
/** Header line arrives already styled — pass it through verbatim. */
|
|
172
|
+
headerPreStyled?: boolean;
|
|
173
|
+
/** Frame the popup in a terminal-style box (content truncated to the inner width). */
|
|
174
|
+
border?: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Render a document popup within `width` and `height` rows: heading, one blank
|
|
179
|
+
* separator, a scroll window over `bodyLines` at `scrollOffset` (clamped,
|
|
180
|
+
* optionally with a scrollbar column and left indent), and a dim indicator
|
|
181
|
+
* line when content overflows. Declared fork of speclet's renderer.
|
|
182
|
+
*/
|
|
183
|
+
export function renderDetailsLines(
|
|
184
|
+
header: string,
|
|
185
|
+
bodyLines: string[],
|
|
186
|
+
width: number,
|
|
187
|
+
height: number,
|
|
188
|
+
scrollOffset: number,
|
|
189
|
+
truncate: Truncate,
|
|
190
|
+
styler: Styler = plainStyler,
|
|
191
|
+
opts: DetailsRenderOptions = {},
|
|
192
|
+
): string[] {
|
|
193
|
+
const indent = opts.indent ?? "";
|
|
194
|
+
const visibleRows = Math.max(1, height - 3); // header + blank separator + indicator
|
|
195
|
+
const maxOffset = Math.max(0, bodyLines.length - visibleRows);
|
|
196
|
+
const offset = Math.max(0, Math.min(scrollOffset, maxOffset));
|
|
197
|
+
const window = bodyLines.slice(offset, offset + visibleRows);
|
|
198
|
+
const headerLine = opts.headerPreStyled ? indent + header : styler(indent + header, "heading");
|
|
199
|
+
const lines: string[] = [truncate(headerLine, width), ""];
|
|
200
|
+
const bodyStyle = (l: string) => (opts.plainBody ? l : styler(l, "body"));
|
|
201
|
+
|
|
202
|
+
if (bodyLines.length > visibleRows) {
|
|
203
|
+
const bar = opts.scrollbar;
|
|
204
|
+
window.forEach((l, i) => {
|
|
205
|
+
const base = truncate(bodyStyle(indent + l), Math.max(1, width - indent.length - 2));
|
|
206
|
+
lines.push(bar ? `${base} ${bar[offset + i] ?? " "}` : base);
|
|
207
|
+
});
|
|
208
|
+
const from = offset + 1;
|
|
209
|
+
const to = Math.min(bodyLines.length, offset + visibleRows);
|
|
210
|
+
lines.push(truncate(styler(`${indent}lines ${from}–${to} of ${bodyLines.length} · ↑↓ scroll · esc close`, "more"), width));
|
|
211
|
+
} else {
|
|
212
|
+
for (const l of window) lines.push(truncate(bodyStyle(indent + l), width - indent.length));
|
|
213
|
+
}
|
|
214
|
+
if (opts.border) {
|
|
215
|
+
const inner = width - 4; // "│ " + content + " │"
|
|
216
|
+
const framed = lines.map((l) => {
|
|
217
|
+
const clipped = visibleLen(l) > inner ? truncate(l, inner) : l;
|
|
218
|
+
return `│ ${padVisible(clipped, inner)} │`;
|
|
219
|
+
});
|
|
220
|
+
const edge = "─".repeat(inner + 2);
|
|
221
|
+
return [`┌${edge}┐`, ...framed, `└${edge}┘`];
|
|
222
|
+
}
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Textual listing used by /specflow outside interactive sessions (AC3). Labels
|
|
228
|
+
* are disambiguated by path when two specs share a name (F2).
|
|
229
|
+
*/
|
|
230
|
+
export function listText(specs: SpecflowSpec[]): string {
|
|
231
|
+
const names = displayNames(specs);
|
|
232
|
+
return specs
|
|
233
|
+
.map((s, i) => {
|
|
234
|
+
const count = s.total > 0 ? ` · ${s.done}/${s.total}` : "";
|
|
235
|
+
const gate = s.gate !== null ? " · awaiting review" : "";
|
|
236
|
+
const err = s.error ? " (unreadable)" : "";
|
|
237
|
+
return `${names[i]} · ${s.status}${count}${gate}${err}`;
|
|
238
|
+
})
|
|
239
|
+
.join("\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Display names for a whole discovery set: identical basenames (a flat spec next
|
|
244
|
+
* to a legacy one, or under two intermediate directories) are disambiguated by
|
|
245
|
+
* appending the shortest unique parent-path chain — the skill's own rule for
|
|
246
|
+
* duplicates ("use actual paths to distinguish duplicates") — so a picker label
|
|
247
|
+
* always maps back to exactly one spec directory.
|
|
248
|
+
*/
|
|
249
|
+
function displayNames(specs: SpecflowSpec[]): string[] {
|
|
250
|
+
const names = specs.map((s) => stripControlSequences(s.name));
|
|
251
|
+
const groups = new Map<string, number[]>();
|
|
252
|
+
names.forEach((n, i) => groups.set(n, [...(groups.get(n) ?? []), i]));
|
|
253
|
+
for (const [, idxs] of groups) {
|
|
254
|
+
if (idxs.length < 2) continue;
|
|
255
|
+
const parts = idxs.map((i) => specs[i].dir.split("/").filter(Boolean));
|
|
256
|
+
for (let depth = 1; depth <= 8; depth++) {
|
|
257
|
+
const chains = parts.map((p) => p.slice(Math.max(0, p.length - 1 - depth), p.length - 1).join("/"));
|
|
258
|
+
if (new Set(chains).size === idxs.length) {
|
|
259
|
+
idxs.forEach((i, k) => (names[i] = chains[k] ? `${names[i]} (${chains[k]})` : names[i]));
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return names;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Picker entries for /specflow: display label (sanitized, path-disambiguated
|
|
269
|
+
* when duplicated) mapped to the raw spec directory, so selection never depends
|
|
270
|
+
* on parsing presentation text — labels round-trip through ctx.ui.select even
|
|
271
|
+
* when a name contains " · ".
|
|
272
|
+
*/
|
|
273
|
+
export function pickerOptions(specs: SpecflowSpec[]): { label: string; dir: string }[] {
|
|
274
|
+
const names = displayNames(specs);
|
|
275
|
+
return specs.map((s, i) => {
|
|
276
|
+
const count = s.total > 0 ? ` · ${s.done}/${s.total}` : "";
|
|
277
|
+
const gate = s.gate !== null ? " · awaiting review" : "";
|
|
278
|
+
return {
|
|
279
|
+
label: `${names[i]} · ${s.status}${count}${gate}`,
|
|
280
|
+
dir: s.dir, // raw — used for lookup/pinning, never rendered
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Documents the viewer offers (AC4): the three spec documents plus the
|
|
287
|
+
* project context file. Every candidate is listed whether or not it exists, so
|
|
288
|
+
* opening a missing one reports the failure instead of hiding the option.
|
|
289
|
+
*/
|
|
290
|
+
export function documentOptions(spec: SpecflowSpec, projectFile: string): { label: string; path: string }[] {
|
|
291
|
+
return [
|
|
292
|
+
{ label: "requirements.md", path: spec.docs.requirements ?? join(spec.dir, "requirements.md") },
|
|
293
|
+
{ label: "design.md", path: spec.docs.design ?? join(spec.dir, "design.md") },
|
|
294
|
+
{ label: "tasks.md", path: spec.docs.tasks ?? join(spec.dir, "tasks.md") },
|
|
295
|
+
{ label: "project.md", path: projectFile },
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** One executable task in the /specflow task chooser (AC6, AC7). */
|
|
300
|
+
export interface TaskOption {
|
|
301
|
+
label: string;
|
|
302
|
+
task: SpecflowTask;
|
|
303
|
+
ready: boolean;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Unfinished tasks for the execution chooser, marked ▶ ready or ⏸ blocked with
|
|
308
|
+
* the ids they wait on (AC7). Blocked tasks stay selectable — the agent decides
|
|
309
|
+
* whether to run them — but the mark must not overstate what is runnable.
|
|
310
|
+
*/
|
|
311
|
+
export function taskOptions(spec: SpecflowSpec): TaskOption[] {
|
|
312
|
+
const deps = dependencyGraph(spec);
|
|
313
|
+
const ready = new Set(deps.ready);
|
|
314
|
+
const blockedBy = new Map(deps.blocked.map((b) => [b.id, b.by]));
|
|
315
|
+
return spec.tasks
|
|
316
|
+
.filter((t) => !t.done)
|
|
317
|
+
.map((task) => {
|
|
318
|
+
const isReady = ready.has(task.id);
|
|
319
|
+
const waiting = blockedBy.get(task.id);
|
|
320
|
+
const why = isReady ? "" : ` (blocked by ${waiting && waiting.length > 0 ? waiting.join(", ") : "unknown dep"})`;
|
|
321
|
+
return {
|
|
322
|
+
label: `${isReady ? "▶" : "⏸"} ${stripControlSequences(task.id)} ${stripControlSequences(task.title)}${why}`,
|
|
323
|
+
task,
|
|
324
|
+
ready: isReady,
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The action a /specflow menu entry performs (AC6). */
|
|
330
|
+
export type CockpitAction = "execute" | "approve" | "validate" | "document" | "toggle";
|
|
331
|
+
|
|
332
|
+
export interface ActionOption {
|
|
333
|
+
label: string;
|
|
334
|
+
action: CockpitAction;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Actions offered for the active spec, in the order they are useful: run work,
|
|
339
|
+
* clear a pending gate, validate, read a document, toggle the panel. Context-
|
|
340
|
+
* sensitive entries (execute, approve) appear only when they can do something.
|
|
341
|
+
*/
|
|
342
|
+
export function actionOptions(spec: SpecflowSpec, hidden: boolean): ActionOption[] {
|
|
343
|
+
const actions: ActionOption[] = [];
|
|
344
|
+
if (spec.tasks.some((t) => !t.done)) actions.push({ label: "Execute a task…", action: "execute" });
|
|
345
|
+
if (spec.gate !== null) actions.push({ label: "Approve gate and resume", action: "approve" });
|
|
346
|
+
if (spec.tasks.length > 0) actions.push({ label: "Validate implementation", action: "validate" });
|
|
347
|
+
actions.push({ label: "Open document…", action: "document" });
|
|
348
|
+
actions.push({ label: hidden ? "Show panel" : "Hide panel", action: "toggle" });
|
|
349
|
+
return actions;
|
|
350
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared.ts — generic text/terminal primitives with no domain types and no
|
|
3
|
+
* imports. This file is kept byte-identical between speclet-tui and
|
|
4
|
+
* specflow-pi (the package's prepublish check enforces it), so it must stay
|
|
5
|
+
* free of speclet-specific concepts: `StyleKind`/`Styler` belong to each
|
|
6
|
+
* extension's render module, not here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Remove ANSI escape sequences and other control characters. */
|
|
10
|
+
export function stripControlSequences(text: string): string {
|
|
11
|
+
return text
|
|
12
|
+
// CSI sequences (incl. SGR), OSC sequences, and two-char C1 escapes
|
|
13
|
+
.replace(/\x1b(?:\[[0-9;:?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g, "")
|
|
14
|
+
// remaining C0 controls except tab (collapsed below), plus DEL
|
|
15
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "")
|
|
16
|
+
.replace(/\t/g, " ");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Visible length of a line: ANSI escapes stripped, code points counted as one column. */
|
|
20
|
+
export function visibleLen(line: string): number {
|
|
21
|
+
return [...line.replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")].length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Right-pad a (possibly styled) line with spaces to the visible width. */
|
|
25
|
+
export function padVisible(line: string, width: number): string {
|
|
26
|
+
return line + " ".repeat(Math.max(0, width - visibleLen(line)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Word-wrap a line to the visible width: continuation lines get two-space
|
|
31
|
+
* indent (original leading indent preserved on the first line), overlong
|
|
32
|
+
* words are hard-broken. ANSI escapes survive inside their chunk; styled
|
|
33
|
+
* spans broken across lines lose styling on the continuation (accepted).
|
|
34
|
+
*/
|
|
35
|
+
export function wrapText(line: string, width: number): string[] {
|
|
36
|
+
if (width <= 0 || visibleLen(line) <= width) return [line];
|
|
37
|
+
const firstIndent = (line.match(/^\s*/) ?? [""])[0];
|
|
38
|
+
const contIndent = firstIndent + " ";
|
|
39
|
+
const usable = Math.max(1, width - contIndent.length);
|
|
40
|
+
const out: string[] = [];
|
|
41
|
+
let cur = firstIndent;
|
|
42
|
+
let curLen = visibleLen(cur);
|
|
43
|
+
let empty = cur.trim() === "";
|
|
44
|
+
const newline = () => {
|
|
45
|
+
cur = contIndent;
|
|
46
|
+
curLen = visibleLen(cur);
|
|
47
|
+
empty = true;
|
|
48
|
+
};
|
|
49
|
+
const emit = (s: string) => {
|
|
50
|
+
out.push(s);
|
|
51
|
+
newline();
|
|
52
|
+
};
|
|
53
|
+
for (const rawWord of line.slice(firstIndent.length).split(" ")) {
|
|
54
|
+
if (rawWord === "") continue;
|
|
55
|
+
let word = rawWord;
|
|
56
|
+
while (visibleLen(word) > usable) {
|
|
57
|
+
if (!empty) emit(cur);
|
|
58
|
+
let cut = "";
|
|
59
|
+
let w = 0;
|
|
60
|
+
for (const ch of word) {
|
|
61
|
+
const cw = visibleLen(ch);
|
|
62
|
+
if (w + cw > usable) break;
|
|
63
|
+
cut += ch;
|
|
64
|
+
w += cw;
|
|
65
|
+
}
|
|
66
|
+
emit(cur + cut);
|
|
67
|
+
word = word.slice(cut.length);
|
|
68
|
+
}
|
|
69
|
+
if (word === "") continue;
|
|
70
|
+
const sep = empty ? 0 : 1;
|
|
71
|
+
if (curLen + sep + visibleLen(word) <= width) {
|
|
72
|
+
cur = empty ? cur + word : `${cur} ${word}`;
|
|
73
|
+
curLen += sep + visibleLen(word);
|
|
74
|
+
empty = false;
|
|
75
|
+
} else {
|
|
76
|
+
if (!empty) emit(cur);
|
|
77
|
+
emit(cur + word);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!empty) out.push(cur);
|
|
81
|
+
else if (out.length === 0) out.push(cur);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One scrollbar track character per visible row: proportional thumb over a dim track. */
|
|
86
|
+
export function renderScrollbar(total: number, visible: number, offset: number): string[] {
|
|
87
|
+
if (total <= 0 || visible <= 0) return [];
|
|
88
|
+
if (total <= visible) return Array.from({ length: visible }, () => " ");
|
|
89
|
+
const thumbSize = Math.max(1, Math.round((visible * visible) / total));
|
|
90
|
+
const denom = total - visible;
|
|
91
|
+
const thumbStart = Math.round((denom > 0 ? offset / denom : 0) * (visible - thumbSize));
|
|
92
|
+
return Array.from({ length: visible }, (_, i) => (i >= thumbStart && i < thumbStart + thumbSize ? "█" : "░"));
|
|
93
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
criteriaIdsOf,
|
|
4
|
+
dependsOn,
|
|
5
|
+
dependencyGraph,
|
|
6
|
+
nextTask,
|
|
7
|
+
traceCriteria,
|
|
8
|
+
} from "./trace.js";
|
|
9
|
+
import type { SpecflowSpec, SpecflowTask } from "./parse.js";
|
|
10
|
+
|
|
11
|
+
function task(id: string, done: boolean, details: string[] = []): SpecflowTask {
|
|
12
|
+
return { id, title: `Task ${id}`, done, details };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function specOf(tasks: SpecflowTask[], criteria: string[] = []): SpecflowSpec {
|
|
16
|
+
return {
|
|
17
|
+
dir: "/specs/demo",
|
|
18
|
+
name: "demo",
|
|
19
|
+
status: "active",
|
|
20
|
+
statusSource: "frontmatter",
|
|
21
|
+
legacy: false,
|
|
22
|
+
docs: { tasks: "/specs/demo/tasks.md" },
|
|
23
|
+
criteria,
|
|
24
|
+
tasks,
|
|
25
|
+
done: tasks.filter((t) => t.done).length,
|
|
26
|
+
total: tasks.length,
|
|
27
|
+
phase: 4,
|
|
28
|
+
gate: null,
|
|
29
|
+
mtimeMs: 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("criteriaIdsOf", () => {
|
|
34
|
+
test("parses the exact row formats", () => {
|
|
35
|
+
expect(criteriaIdsOf(task("1.1", false, ["- Criteria: AC1"]))).toEqual(["AC1"]);
|
|
36
|
+
expect(criteriaIdsOf(task("1.1", false, ["- Criteria: AC1, AC2"]))).toEqual(["AC1", "AC2"]);
|
|
37
|
+
expect(criteriaIdsOf(task("1.1", false, ["- Depends on: 1.1"]))).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
test("tolerates optional bullet, case-insensitive label, comma/space separation", () => {
|
|
40
|
+
expect(criteriaIdsOf(task("1", false, ["criteria: ac1, ac2"]))).toEqual(["ac1", "ac2"]);
|
|
41
|
+
expect(criteriaIdsOf(task("1", false, ["* CRITERIA: AC1 AC2"]))).toEqual(["AC1", "AC2"]);
|
|
42
|
+
expect(criteriaIdsOf(task("1", false, ["- Criteria: AC1; AC2"]))).toEqual(["AC1", "AC2"]);
|
|
43
|
+
});
|
|
44
|
+
test("ignores unrelated rows and empty details", () => {
|
|
45
|
+
expect(criteriaIdsOf(task("1", false, ["- Do the thing", "- Depends on: 2.1"]))).toEqual([]);
|
|
46
|
+
expect(criteriaIdsOf(task("1", false))).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("dependsOn", () => {
|
|
51
|
+
test("parses dotted ids from the exact row format", () => {
|
|
52
|
+
expect(dependsOn(task("2.1", false, ["- Depends on: 1.1"]))).toEqual(["1.1"]);
|
|
53
|
+
expect(dependsOn(task("2.1", false, ["- Depends on: 1.1, 1.2"]))).toEqual(["1.1", "1.2"]);
|
|
54
|
+
expect(dependsOn(task("2.1", false, ["depends on: 1.1 1.3"]))).toEqual(["1.1", "1.3"]);
|
|
55
|
+
expect(dependsOn(task("2.1", false, ["- Criteria: AC1"]))).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("traceCriteria", () => {
|
|
60
|
+
test("groups claimed criteria, lists unclaimed, and flags orphans", () => {
|
|
61
|
+
const spec = specOf(
|
|
62
|
+
[
|
|
63
|
+
task("1.1", false, ["- Criteria: AC1"]),
|
|
64
|
+
task("1.2", false, ["- Criteria: AC1, AC3"]),
|
|
65
|
+
task("2.1", false, ["- Criteria: AC9"]),
|
|
66
|
+
],
|
|
67
|
+
["AC1", "AC2", "AC3"],
|
|
68
|
+
);
|
|
69
|
+
const t = traceCriteria(spec);
|
|
70
|
+
expect(t.defined).toEqual(["AC1", "AC2", "AC3"]);
|
|
71
|
+
expect(t.claimed).toEqual([
|
|
72
|
+
{ id: "AC1", tasks: ["1.1", "1.2"] },
|
|
73
|
+
{ id: "AC3", tasks: ["1.2"] },
|
|
74
|
+
]);
|
|
75
|
+
expect(t.unclaimed).toEqual(["AC2"]);
|
|
76
|
+
expect(t.orphan).toEqual([{ id: "AC9", taskId: "2.1" }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("no criteria declared but tasks citing them => everything is orphan", () => {
|
|
80
|
+
const spec = specOf([task("1.1", false, ["- Criteria: AC1, AC2"])]);
|
|
81
|
+
const t = traceCriteria(spec);
|
|
82
|
+
expect(t.defined).toEqual([]);
|
|
83
|
+
expect(t.claimed).toEqual([]);
|
|
84
|
+
expect(t.unclaimed).toEqual([]);
|
|
85
|
+
expect(t.orphan).toEqual([
|
|
86
|
+
{ id: "AC1", taskId: "1.1" },
|
|
87
|
+
{ id: "AC2", taskId: "1.1" },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a spec where every AC is claimable has empty unclaimed and orphan", () => {
|
|
92
|
+
const spec = specOf(
|
|
93
|
+
[task("1.1", false, ["- Criteria: AC2"]), task("1.2", false, ["- Criteria: AC1"])],
|
|
94
|
+
["AC1", "AC2"],
|
|
95
|
+
);
|
|
96
|
+
const t = traceCriteria(spec);
|
|
97
|
+
expect(t.unclaimed).toEqual([]);
|
|
98
|
+
expect(t.orphan).toEqual([]);
|
|
99
|
+
expect(t.claimed.map((c) => c.id)).toEqual(["AC1", "AC2"]); // defined order, not citation order
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("dependencyGraph", () => {
|
|
104
|
+
test("chain 1.1 -> 2.1 -> 2.2: ready, blocked naming unfinished deps", () => {
|
|
105
|
+
const spec = specOf([
|
|
106
|
+
task("1.1", true), // done
|
|
107
|
+
task("2.1", false, ["- Depends on: 1.1"]), // dep done => ready
|
|
108
|
+
task("2.2", false, ["- Depends on: 2.1"]), // dep unfinished => blocked
|
|
109
|
+
]);
|
|
110
|
+
const g = dependencyGraph(spec);
|
|
111
|
+
expect(g.ready).toEqual(["2.1"]);
|
|
112
|
+
expect(g.blocked).toEqual([{ id: "2.2", by: ["2.1"] }]);
|
|
113
|
+
expect(g.dangling).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("dangling: missing ids never count as satisfied", () => {
|
|
117
|
+
const spec = specOf([task("1.1", false, ["- Depends on: 9.9"])]);
|
|
118
|
+
const g = dependencyGraph(spec);
|
|
119
|
+
expect(g.dangling).toEqual([{ id: "1.1", missing: ["9.9"] }]);
|
|
120
|
+
expect(g.ready).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("done tasks are excluded from all lists", () => {
|
|
124
|
+
const spec = specOf([
|
|
125
|
+
task("1.1", true, ["- Depends on: 9.9"]), // done + dangling => nowhere
|
|
126
|
+
task("1.2", true), // done, no deps => nowhere
|
|
127
|
+
]);
|
|
128
|
+
const g = dependencyGraph(spec);
|
|
129
|
+
expect(g.ready).toEqual([]);
|
|
130
|
+
expect(g.blocked).toEqual([]);
|
|
131
|
+
expect(g.dangling).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a task with both a missing and an unfinished dependency is dangling (priority)", () => {
|
|
135
|
+
const spec = specOf([
|
|
136
|
+
task("1.1", false),
|
|
137
|
+
task("1.2", false, ["- Depends on: 1.1, 8.8"]),
|
|
138
|
+
]);
|
|
139
|
+
const g = dependencyGraph(spec);
|
|
140
|
+
expect(g.dangling).toEqual([{ id: "1.2", missing: ["8.8"] }]);
|
|
141
|
+
expect(g.blocked).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a task depending on ITSELF is blocked-on-itself, never dangling", () => {
|
|
145
|
+
const g = dependencyGraph(specOf([task("1.1", false, ["- Depends on: 1.1"])]));
|
|
146
|
+
expect(g.blocked).toEqual([{ id: "1.1", by: ["1.1"] }]);
|
|
147
|
+
expect(g.dangling).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("zero tasks => every list empty", () => {
|
|
151
|
+
const g = dependencyGraph(specOf([]));
|
|
152
|
+
expect(g.ready).toEqual([]);
|
|
153
|
+
expect(g.blocked).toEqual([]);
|
|
154
|
+
expect(g.dangling).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("nextTask", () => {
|
|
159
|
+
test("returns the first READY unfinished task in document order", () => {
|
|
160
|
+
const spec = specOf([
|
|
161
|
+
task("1.1", true),
|
|
162
|
+
task("2.2", false, ["- Depends on: 2.1"]), // blocked
|
|
163
|
+
task("1.2", false), // ready, earlier than 2.1
|
|
164
|
+
task("2.1", false, ["- Depends on: 1.1"]), // ready
|
|
165
|
+
]);
|
|
166
|
+
expect(nextTask(spec)!.id).toBe("1.2");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("skips done and blocked tasks; undefined when nothing is ready", () => {
|
|
170
|
+
const spec = specOf([
|
|
171
|
+
task("1.1", true), // done => never next
|
|
172
|
+
task("1.2", false, ["- Depends on: 9.9"]), // dangling => not ready
|
|
173
|
+
]);
|
|
174
|
+
expect(nextTask(spec)).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("done-only and blocked-only specs have no next task", () => {
|
|
178
|
+
expect(nextTask(specOf([task("1.1", true)]))).toBeUndefined();
|
|
179
|
+
expect(nextTask(specOf([task("1.1", false, ["- Depends on: 1.2"])]))).toBeUndefined();
|
|
180
|
+
expect(nextTask(specOf([]))).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
});
|