@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,33 @@
|
|
|
1
|
+
# Phase 1: Requirements
|
|
2
|
+
|
|
3
|
+
Use project context from `.specflow/project.md`; if missing, follow [project setup](project-setup.md). Create or update `requirements.md` in the active feature directory defined in [SKILL.md](../SKILL.md#directory-layout). Consult related completed specs when relevant, resolving status through [Lifecycle](lifecycle-phase.md#status-and-legacy-layout). Link to their actual paths (`../{feature-name}/` in the flat layout).
|
|
4
|
+
|
|
5
|
+
Generate an initial draft from the idea without a preliminary interview unless grilling is requested. Identify assumptions and unresolved information rather than silently inventing scope.
|
|
6
|
+
|
|
7
|
+
## Grilling (Optional)
|
|
8
|
+
|
|
9
|
+
When requested, surface contextual questions whose answers would materially change the spec: ambiguities, concurrency, edge cases, hidden decisions, or integration constraints. Present one block, each question paired with a recommendation and brief reasoning. Wait for answers before drafting; use the confirmed intent.
|
|
10
|
+
|
|
11
|
+
## Document Content
|
|
12
|
+
|
|
13
|
+
- Introduction: what, why, intended users, and scope.
|
|
14
|
+
- Numbered requirements, each with a descriptive name, a user story (As a role, I want a capability, so that a benefit), and numbered acceptance criteria. Optional notes capture constraints or dependencies.
|
|
15
|
+
- Out of Scope, Assumptions, and Dependencies sections.
|
|
16
|
+
|
|
17
|
+
Use stable references: `1` identifies Requirement 1; `1.2` identifies its second acceptance criterion. Preserve existing IDs when revising. Design and tasks use these same references.
|
|
18
|
+
|
|
19
|
+
## EARS Acceptance Criteria
|
|
20
|
+
|
|
21
|
+
Write specific, measurable outcomes with one behavior per criterion and unambiguous conditions.
|
|
22
|
+
|
|
23
|
+
| Pattern | Form |
|
|
24
|
+
|---------|------|
|
|
25
|
+
| Ubiquitous | The system SHALL [behavior] |
|
|
26
|
+
| Event-driven | WHEN [event] THEN the system SHALL [behavior] |
|
|
27
|
+
| Conditional | IF [condition] THEN the system SHALL [behavior] |
|
|
28
|
+
| Combined | WHEN [event] AND IF [condition] THEN the system SHALL [behavior] |
|
|
29
|
+
| Optional | WHERE [feature included] the system SHALL [behavior] |
|
|
30
|
+
|
|
31
|
+
Cover functional behavior, relevant performance/security/accessibility constraints, error and boundary cases, and success criteria. Keep scope explicit; propose a separate spec for unrelated expansion.
|
|
32
|
+
|
|
33
|
+
Present the draft with a coverage summary and apply the [planning approval gate](../SKILL.md#core-contract).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Phase 3: Tasks
|
|
2
|
+
|
|
3
|
+
Use approved requirements and design to create or update `tasks.md` in the active feature directory. Follow the [planning approval gate](../SKILL.md#core-contract).
|
|
4
|
+
|
|
5
|
+
Initialize or preserve [lifecycle metadata](lifecycle-phase.md#metadata) when writing the plan.
|
|
6
|
+
|
|
7
|
+
## Plan Contract
|
|
8
|
+
|
|
9
|
+
Include a short implementation overview and groups of incremental coding tasks. Use at most two hierarchy levels: top-level groups and decimal task IDs such as `1.1`, `1.2`, `2.1`.
|
|
10
|
+
|
|
11
|
+
Each task has a checkbox, unique number, action title, scope sub-bullets, and explicit [requirement/criterion references](requirements-phase.md#document-content). Enumerate IDs instead of using `All`, so task context and validation are bounded. Include relevant design section names in the scope when they are not evident from traceability.
|
|
12
|
+
|
|
13
|
+
```markdown
|
|
14
|
+
## 1. [Work group]
|
|
15
|
+
|
|
16
|
+
- [ ] 1.1 [One logical implementation outcome]
|
|
17
|
+
- Depends on: none
|
|
18
|
+
- [Code to create or modify and integration points]
|
|
19
|
+
- [Tests or verification required by the design]
|
|
20
|
+
- _Requirements: 1.1, 1.2_
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Tasks must be specific, testable, traceable, and small enough to validate individually. Order dependencies before dependent work, integrate code rather than leaving orphaned components, and include testing with test-driven development where appropriate.
|
|
24
|
+
|
|
25
|
+
## Dependency Flow
|
|
26
|
+
|
|
27
|
+
Every task declares `Depends on: none` or explicit prerequisite task IDs. These edges define the execution flow; list prerequisites before dependents and reject missing IDs, self-dependencies, and cycles before seeking plan approval. Dependencies refer to validated, integrated outcomes, not merely work started.
|
|
28
|
+
|
|
29
|
+
For example, `1.1: none`, `1.2: 1.1`, `1.3: 1.1`, `1.4: 1.2, 1.3` means 1.1 first, then 1.2 and 1.3, then 1.4. Tasks 1.2 and 1.3 are dependency-independent and candidates for parallel execution under the [execution rules](execution-phase.md#parallel-execution-optional). Avoid maintaining a second ordering diagram.
|
|
30
|
+
|
|
31
|
+
For older plans without dependency fields, retain listed sequential order and infer prerequisites conservatively from scope; missing fields do not mean independence. Clarify uncertainty before execution and add explicit dependencies through plan review before parallelizing.
|
|
32
|
+
|
|
33
|
+
Exclude deployment, human acceptance/manual E2E testing, post-deployment metrics gathering, training, business-process changes, and marketing. Documentation tasks are allowed only when explicitly requested.
|
|
34
|
+
|
|
35
|
+
Present the plan with its groups and task count for review. After approval, route execution requests to [Phase 4](execution-phase.md); execution scope and stopping rules live there and in the root contract.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Project Context
|
|
2
|
+
|
|
3
|
+
## Tech Stack
|
|
4
|
+
|
|
5
|
+
- **Language:** [e.g., TypeScript, Python, Java]
|
|
6
|
+
- **Framework:** [e.g., Next.js, FastAPI, Spring Boot]
|
|
7
|
+
- **Database:** [e.g., PostgreSQL, MongoDB, MySQL]
|
|
8
|
+
- **Testing:** [e.g., Jest, pytest, JUnit]
|
|
9
|
+
- **Build Tools:** [e.g., Vite, Webpack, Maven]
|
|
10
|
+
|
|
11
|
+
## Coding Conventions
|
|
12
|
+
|
|
13
|
+
- **Naming:** [e.g., camelCase for variables, PascalCase for classes]
|
|
14
|
+
- **File Organization:** [e.g., feature-based folders, separation of concerns]
|
|
15
|
+
- **Code Style:** [e.g., ESLint config, Prettier settings, PEP 8]
|
|
16
|
+
- **Comments:** [e.g., JSDoc for public APIs, inline for complex logic]
|
|
17
|
+
- **Error Handling:** [e.g., custom error classes, consistent error responses]
|
|
18
|
+
|
|
19
|
+
## Architectural Patterns
|
|
20
|
+
|
|
21
|
+
- **Architecture Style:** [e.g., Layered, Clean Architecture, Microservices]
|
|
22
|
+
- **Design Patterns:** [e.g., Repository pattern, Factory pattern, Observer]
|
|
23
|
+
- **State Management:** [e.g., Redux, Context API, Vuex]
|
|
24
|
+
- **API Design:** [e.g., RESTful, GraphQL, gRPC]
|
|
25
|
+
- **Data Flow:** [e.g., unidirectional data flow, event-driven]
|
|
26
|
+
|
|
27
|
+
## Testing Standards
|
|
28
|
+
|
|
29
|
+
- **Unit Tests:** [e.g., minimum 80% coverage, test pure functions]
|
|
30
|
+
- **Integration Tests:** [e.g., test API endpoints, database interactions]
|
|
31
|
+
- **Property-Based Tests:** [e.g., use fast-check/Hypothesis for core logic]
|
|
32
|
+
- **Test Organization:** [e.g., co-located with source, separate test directory]
|
|
33
|
+
- **Mocking Strategy:** [e.g., mock external services, use test databases]
|
|
34
|
+
|
|
35
|
+
## Security Practices
|
|
36
|
+
|
|
37
|
+
- **Authentication:** [e.g., JWT, OAuth 2.0, session-based]
|
|
38
|
+
- **Authorization:** [e.g., RBAC, ABAC, permission middleware]
|
|
39
|
+
- **Data Validation:** [e.g., input sanitization, schema validation]
|
|
40
|
+
- **Secrets Management:** [e.g., environment variables, vault services]
|
|
41
|
+
|
|
42
|
+
## Performance Guidelines
|
|
43
|
+
|
|
44
|
+
- **Optimization:** [e.g., lazy loading, code splitting, caching strategies]
|
|
45
|
+
- **Database:** [e.g., indexing strategy, query optimization, connection pooling]
|
|
46
|
+
- **Monitoring:** [e.g., logging standards, metrics collection, alerting]
|
|
47
|
+
|
|
48
|
+
## Related Documentation
|
|
49
|
+
|
|
50
|
+
- [Link to architecture docs]
|
|
51
|
+
- [Link to API documentation]
|
|
52
|
+
- [Link to deployment guides]
|
|
53
|
+
- [Link to team wiki]
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
**Instructions:** Fill in the sections above with your project-specific details. This file will be read by the AI assistant when creating and executing specs to ensure consistency across all features.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm, rename, chmod } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { SpecflowController } from "./controller.js";
|
|
6
|
+
|
|
7
|
+
let root: string;
|
|
8
|
+
let dir: string;
|
|
9
|
+
|
|
10
|
+
/** Fixture writer: specs/<feature>/<file> relative to the specflow dir. */
|
|
11
|
+
async function writeSpec(rel: string, content: string): Promise<string> {
|
|
12
|
+
const path = join(dir, rel);
|
|
13
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
14
|
+
await writeFile(path, content);
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TASKS_ACTIVE = ["---", "status: active", "---", "", "## Tasks", "", "- [ ] 1. Do the thing", " - Criteria: AC1", "- [x] 2. Done thing", ""].join("\n");
|
|
19
|
+
const REQUIREMENTS = "# Feature\n\n## Requirements\n\n- AC1: it works\n";
|
|
20
|
+
|
|
21
|
+
function harness(intervalMs = 25) {
|
|
22
|
+
const updates: number[] = [];
|
|
23
|
+
const diagnostics: string[] = [];
|
|
24
|
+
const controller = new SpecflowController(dir, {
|
|
25
|
+
onUpdate: () => updates.push(updates.length + 1),
|
|
26
|
+
onDiagnostic: (m) => diagnostics.push(m),
|
|
27
|
+
}, intervalMs);
|
|
28
|
+
return { controller, updates, diagnostics };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
root = await mkdtemp(join(tmpdir(), "specflow-ctl-"));
|
|
35
|
+
dir = join(root, ".specflow");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
await rm(root, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("SpecflowController.scan", () => {
|
|
43
|
+
test("a missing .specflow directory is not an error and yields no specs", async () => {
|
|
44
|
+
const { controller, diagnostics } = harness();
|
|
45
|
+
await controller.scan();
|
|
46
|
+
expect(controller.specs).toEqual([]);
|
|
47
|
+
expect(controller.active()).toBeUndefined();
|
|
48
|
+
expect(diagnostics).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("discovers flat and legacy specs and exposes the ranking winner", async () => {
|
|
52
|
+
await writeSpec("specs/active-flat/tasks.md", TASKS_ACTIVE);
|
|
53
|
+
// legacy layout: no frontmatter, status inferred from the containing directory
|
|
54
|
+
await writeSpec("specs/archived/older/tasks.md", "# Old\n");
|
|
55
|
+
const { controller } = harness();
|
|
56
|
+
await controller.scan();
|
|
57
|
+
expect(controller.specs.map((s) => s.name).sort()).toEqual(["active-flat", "older"]);
|
|
58
|
+
expect(controller.specs.find((s) => s.name === "older")?.status).toBe("archived");
|
|
59
|
+
// archived ranks below in-progress even though it is the newest on disk
|
|
60
|
+
expect(controller.active()?.name).toBe("active-flat");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a requirements-only spec is eligible", async () => {
|
|
64
|
+
await writeSpec("specs/req-only/requirements.md", REQUIREMENTS);
|
|
65
|
+
const { controller } = harness();
|
|
66
|
+
await controller.scan();
|
|
67
|
+
expect(controller.specs.map((s) => s.name)).toEqual(["req-only"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an unreadable tasks.md is reported once as a diagnostic, never thrown", async () => {
|
|
71
|
+
const path = await writeSpec("specs/broken/tasks.md", TASKS_ACTIVE);
|
|
72
|
+
await chmod(path, 0o000);
|
|
73
|
+
const { controller, diagnostics } = harness();
|
|
74
|
+
await controller.scan();
|
|
75
|
+
await controller.scan();
|
|
76
|
+
expect(controller.specs.map((s) => s.name)).toEqual(["broken"]);
|
|
77
|
+
expect(diagnostics.length).toBe(1); // reported once, not once per scan
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an unreadable non-task document still lists the spec without a diagnostic", async () => {
|
|
81
|
+
// Discovery only needs tasks.md's content; requirements.md is read by the
|
|
82
|
+
// viewer, which reports the failure on open (AC4) instead of on every poll.
|
|
83
|
+
const path = await writeSpec("specs/quiet/requirements.md", REQUIREMENTS);
|
|
84
|
+
await writeSpec("specs/quiet/tasks.md", TASKS_ACTIVE);
|
|
85
|
+
await chmod(path, 0o000);
|
|
86
|
+
const { controller, diagnostics } = harness();
|
|
87
|
+
await controller.scan();
|
|
88
|
+
expect(controller.specs.map((s) => s.name)).toEqual(["quiet"]);
|
|
89
|
+
expect(diagnostics).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("SpecflowController pinning", () => {
|
|
94
|
+
test("a pin survives refreshes but clears when the spec disappears", async () => {
|
|
95
|
+
await writeSpec("specs/one/tasks.md", TASKS_ACTIVE);
|
|
96
|
+
await writeSpec("specs/two/tasks.md", TASKS_ACTIVE);
|
|
97
|
+
const { controller } = harness();
|
|
98
|
+
await controller.scan();
|
|
99
|
+
const two = controller.specs.find((s) => s.name === "two");
|
|
100
|
+
controller.pin(two!.dir);
|
|
101
|
+
await controller.scan();
|
|
102
|
+
expect(controller.active()?.name).toBe("two");
|
|
103
|
+
|
|
104
|
+
await rm(join(dir, "specs/two"), { recursive: true, force: true });
|
|
105
|
+
await controller.scan();
|
|
106
|
+
expect(controller.pinned).toBeUndefined();
|
|
107
|
+
expect(controller.active()?.name).toBe("one");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("hide and show are session-scoped state changes", async () => {
|
|
111
|
+
await writeSpec("specs/one/tasks.md", TASKS_ACTIVE);
|
|
112
|
+
const { controller } = harness();
|
|
113
|
+
await controller.scan();
|
|
114
|
+
expect(controller.hidden).toBe(false);
|
|
115
|
+
controller.hide();
|
|
116
|
+
expect(controller.hidden).toBe(true);
|
|
117
|
+
controller.show();
|
|
118
|
+
expect(controller.hidden).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("SpecflowController.onUpdate", () => {
|
|
123
|
+
test("fires only when the rendered content changes", async () => {
|
|
124
|
+
await writeSpec("specs/one/tasks.md", TASKS_ACTIVE);
|
|
125
|
+
const { controller, updates } = harness();
|
|
126
|
+
await controller.scan();
|
|
127
|
+
const afterFirst = updates.length;
|
|
128
|
+
await controller.scan(); // identical content
|
|
129
|
+
expect(updates.length).toBe(afterFirst);
|
|
130
|
+
controller.hide(); // visibility is part of the signature
|
|
131
|
+
expect(updates.length).toBe(afterFirst + 1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("fires when only the gate changes", async () => {
|
|
135
|
+
await writeSpec("specs/one/tasks.md", TASKS_ACTIVE);
|
|
136
|
+
const { controller, updates } = harness();
|
|
137
|
+
await controller.scan();
|
|
138
|
+
const before = updates.length;
|
|
139
|
+
await writeSpec("specs/one/tasks.md", TASKS_ACTIVE.replace("status: active", "status: active\ngate: review"));
|
|
140
|
+
await controller.scan();
|
|
141
|
+
expect(controller.active()?.gate).toBe("review");
|
|
142
|
+
expect(updates.length).toBe(before + 1);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("SpecflowController polling (AC6)", () => {
|
|
147
|
+
test("reflects a created spec, an atomic replace, and a deletion within a second", async () => {
|
|
148
|
+
const { controller } = harness(25);
|
|
149
|
+
await controller.scan();
|
|
150
|
+
controller.start();
|
|
151
|
+
try {
|
|
152
|
+
await writeSpec("specs/new/tasks.md", TASKS_ACTIVE);
|
|
153
|
+
await wait(400);
|
|
154
|
+
expect(controller.specs.map((s) => s.name)).toEqual(["new"]);
|
|
155
|
+
|
|
156
|
+
// atomic replace (write temp + rename over the target)
|
|
157
|
+
const target = join(dir, "specs/new/tasks.md");
|
|
158
|
+
const tmp = `${target}.tmp`;
|
|
159
|
+
await writeFile(tmp, TASKS_ACTIVE.replace("- [ ] 1. Do the thing", "- [x] 1. Do the thing"));
|
|
160
|
+
await rename(tmp, target);
|
|
161
|
+
await wait(400);
|
|
162
|
+
expect(controller.active()?.done).toBe(2);
|
|
163
|
+
|
|
164
|
+
await rm(join(dir, "specs/new/tasks.md"), { force: true });
|
|
165
|
+
await controller.scan();
|
|
166
|
+
expect(controller.specs).toEqual([]);
|
|
167
|
+
} finally {
|
|
168
|
+
controller.stop();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("stop() halts polling and clears session state", async () => {
|
|
173
|
+
await writeSpec("specs/one/tasks.md", TASKS_ACTIVE);
|
|
174
|
+
const { controller } = harness(25);
|
|
175
|
+
await controller.scan();
|
|
176
|
+
controller.start();
|
|
177
|
+
await wait(60);
|
|
178
|
+
controller.stop();
|
|
179
|
+
expect(controller.specs).toEqual([]);
|
|
180
|
+
expect(controller.pinned).toBeUndefined();
|
|
181
|
+
|
|
182
|
+
await writeSpec("specs/late/tasks.md", TASKS_ACTIVE);
|
|
183
|
+
await wait(200);
|
|
184
|
+
expect(controller.specs).toEqual([]); // no ticks after stop
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a scan started before stop() cannot apply its result", async () => {
|
|
188
|
+
await writeSpec("specs/one/tasks.md", TASKS_ACTIVE);
|
|
189
|
+
const { controller } = harness();
|
|
190
|
+
const pending = controller.scan();
|
|
191
|
+
controller.stop();
|
|
192
|
+
await pending;
|
|
193
|
+
expect(controller.specs).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* controller.ts — session-scoped state for the specflow panel: discovery
|
|
3
|
+
* polling, pinned selection, change/diagnostic dedup, and shutdown
|
|
4
|
+
* invalidation. Declared fork of speclet-tui/src/controller.ts (AC7);
|
|
5
|
+
* filesystem-free except through discoverSpecflows.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { discoverSpecflows, type SpecflowSpec } from "./parse.js";
|
|
9
|
+
import { selectActive } from "./render.js";
|
|
10
|
+
|
|
11
|
+
export interface ControllerHooks {
|
|
12
|
+
/** The rendered content of the active spec changed (or appeared/disappeared). */
|
|
13
|
+
onUpdate(): void;
|
|
14
|
+
/** A deduplicated diagnostic to surface (directory or read failure). */
|
|
15
|
+
onDiagnostic(message: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class SpecflowController {
|
|
19
|
+
specs: SpecflowSpec[] = [];
|
|
20
|
+
/** Spec directory pinned via /specflow; cleared automatically when it disappears. */
|
|
21
|
+
pinned: string | undefined;
|
|
22
|
+
/** Panel visibility, toggled from the /specflow picker. Session-scoped. */
|
|
23
|
+
hidden = false;
|
|
24
|
+
|
|
25
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
private generation = 0;
|
|
27
|
+
private lastSignature: string | undefined;
|
|
28
|
+
private lastDiagnostic: string | undefined;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly specflowDir: string,
|
|
32
|
+
private readonly hooks: ControllerHooks,
|
|
33
|
+
private readonly intervalMs = 500,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
active(): SpecflowSpec | undefined {
|
|
37
|
+
return selectActive(this.specs, this.pinned);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pin(dir: string | undefined): void {
|
|
41
|
+
this.pinned = dir;
|
|
42
|
+
this.refreshSnapshot();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
hide(): void {
|
|
46
|
+
this.hidden = true;
|
|
47
|
+
this.refreshSnapshot();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
show(): void {
|
|
51
|
+
this.hidden = false;
|
|
52
|
+
this.refreshSnapshot();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Scan once now; safe to call directly (used by tests and start()). */
|
|
56
|
+
async scan(): Promise<void> {
|
|
57
|
+
const gen = this.generation;
|
|
58
|
+
const { specs, dirError } = await discoverSpecflows(this.specflowDir);
|
|
59
|
+
if (gen !== this.generation) return; // stale scan after shutdown
|
|
60
|
+
|
|
61
|
+
if (this.pinned !== undefined && !specs.some((s) => s.dir === this.pinned)) {
|
|
62
|
+
this.pinned = undefined; // pinned spec disappeared — back to ranking (AC1)
|
|
63
|
+
}
|
|
64
|
+
this.specs = specs;
|
|
65
|
+
|
|
66
|
+
const diagnostic =
|
|
67
|
+
dirError ?? (specs.filter((s) => s.error).map((s) => `${s.name}: ${s.error}`).join("; ") || undefined);
|
|
68
|
+
if (diagnostic !== this.lastDiagnostic) {
|
|
69
|
+
this.lastDiagnostic = diagnostic;
|
|
70
|
+
if (diagnostic) this.hooks.onDiagnostic(diagnostic);
|
|
71
|
+
}
|
|
72
|
+
this.refreshSnapshot();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Start the non-overlapping poll loop (AC6). */
|
|
76
|
+
start(): void {
|
|
77
|
+
void this.loop();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
stop(): void {
|
|
81
|
+
this.generation++; // invalidates in-flight scans and scheduled ticks
|
|
82
|
+
if (this.timer) clearTimeout(this.timer);
|
|
83
|
+
this.timer = undefined;
|
|
84
|
+
this.specs = [];
|
|
85
|
+
this.pinned = undefined;
|
|
86
|
+
this.hidden = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async loop(): Promise<void> {
|
|
90
|
+
const gen = this.generation;
|
|
91
|
+
await this.scan();
|
|
92
|
+
if (gen !== this.generation) return;
|
|
93
|
+
this.timer = setTimeout(() => void this.loop(), this.intervalMs);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fire onUpdate only when the rendered content of the active spec changed. */
|
|
97
|
+
private refreshSnapshot(): void {
|
|
98
|
+
const active = this.active();
|
|
99
|
+
const signature = JSON.stringify([
|
|
100
|
+
this.hidden,
|
|
101
|
+
active
|
|
102
|
+
? [
|
|
103
|
+
active.dir,
|
|
104
|
+
active.name,
|
|
105
|
+
active.status,
|
|
106
|
+
active.phase,
|
|
107
|
+
active.done,
|
|
108
|
+
active.total,
|
|
109
|
+
active.gate,
|
|
110
|
+
active.error ?? null,
|
|
111
|
+
]
|
|
112
|
+
: null,
|
|
113
|
+
]);
|
|
114
|
+
if (signature !== this.lastSignature) {
|
|
115
|
+
this.lastSignature = signature;
|
|
116
|
+
this.hooks.onUpdate();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|