@anselmdk/feature-spec-md 0.7.0 → 0.8.1

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/README.md CHANGED
@@ -7,9 +7,19 @@ Markdown specs for AI-assisted, testable spec-driven development.
7
7
  ```txt
8
8
  model + features + stack + design
9
9
  -> AI-written executable tests that reference stable spec IDs
10
- -> validation, coverage, screenshots, and reports
10
+ -> validation, coverage, screenshots, reports, and PR diffs
11
11
  ```
12
12
 
13
+ ## See the workflow in action
14
+
15
+ The clearest example of what this library can deliver is the demo feature PR workflow:
16
+
17
+ - **Feature PR:** <https://github.com/anselmdk/feature-spec-md-demo/pull/16>
18
+ - **Feature spec report:** <https://feature-spec-md.anselm.dk/demo/build/269/>
19
+ - **Feature spec diff:** <https://feature-spec-md.anselm.dk/demo/pr/16/269/>
20
+
21
+ PRs like this are the essence of `feature-spec-md`: a feature branch can carry the product/spec/code change, a published feature spec report can show the full implementation state, and a PR diff report can show exactly what changed in the specs, rendered report, and screenshot evidence.
22
+
13
23
  ## What the tool does
14
24
 
15
25
  The shortest version is: `feature-spec-md` turns Markdown specifications into a testable contract.
@@ -21,21 +31,23 @@ It gives you:
21
31
  - **Validation** that checks frontmatter, headings, IDs, cross-document references, and test coverage expectations.
22
32
  - **Coverage reporting** that shows which model items, rules, and scenarios are implemented by tests.
23
33
  - **Scenario evidence policy** so specs can declare whether behavior should be tested by unit, integration, Playwright, manual, or no executable tests.
24
- - **HTML reports** that are useful for reviews, CI artifacts, or published build reports.
34
+ - **HTML reports** that combine specs, coverage, validation status, source links, GitHub/build metadata, and screenshot evidence.
35
+ - **PR diff reports** that compare a current published build with a base or previous build and highlight changed report files, changed spec sections, and screenshot evidence.
25
36
  - **Screenshot evidence** for scenarios when using the Playwright helper.
26
- - **A library API** for projects that want to parse specs, check coverage, collect screenshots, or render reports from their own tooling.
37
+ - **GitHub Actions publishing helpers** for job summaries, FTP-published build reports, FTP-published PR diff reports, and PR comments.
38
+ - **A library API** for projects that want to parse specs, check coverage, collect screenshots, render reports, or render diff reports from their own tooling.
27
39
 
28
40
  The specs are meant to be written with an AI before implementation. The tests are meant to be written with an AI from those specs. `feature-spec-md` then checks that the Markdown stays structured and that executable tests still cover the model items, rules, and scenarios the specs define.
29
41
 
30
42
  ## Demo project
31
43
 
32
- A complete demo app is available in [`anselmdk/feature-spec-md-demo`](https://github.com/anselmdk/feature-spec-md-demo). It shows a small support-ticket desk built from model, feature, stack, and design specs, with unit tests, Playwright tests, generated coverage, and a published report.
44
+ A complete demo app is available in [`anselmdk/feature-spec-md-demo`](https://github.com/anselmdk/feature-spec-md-demo). It shows a small support-ticket desk built from model, feature, stack, and design specs, with unit tests, Playwright tests, generated coverage, screenshot evidence, a published feature spec report, and a published PR diff report.
33
45
 
34
46
  Demo reports, including scenario screenshots, are available here:
35
47
 
36
48
  <https://feature-spec-md.anselm.dk/demo/>
37
49
 
38
- Use the demo repository when you want to see the expected project shape, script names, screenshot manifest flow, and report output in a real app.
50
+ Use the demo repository when you want to see the expected project shape, script names, screenshot manifest flow, CI publishing setup, PR comments, and report output in a real app.
39
51
 
40
52
  ## What you write
41
53
 
@@ -62,9 +74,9 @@ A person who has completed registration and can request sign-in links.
62
74
 
63
75
  ### Feature specs
64
76
 
65
- Feature specs describe user-facing behavior with rules and scenarios. Rules and scenarios get stable IDs that tests can reference.
77
+ Feature specs describe user-facing behavior with rules and scenarios. Rules and scenarios get stable IDs that tests can reference. Scenario steps are fenced so Markdown previews preserve line breaks without relying on trailing spaces.
66
78
 
67
- ```md
79
+ ````md
68
80
  ---
69
81
  id: ACCOUNT-ACCESS
70
82
  title: Account access
@@ -86,10 +98,12 @@ Allow registered people to access their account securely.
86
98
 
87
99
  ### ACCOUNT-ACCESS-S001: Registered person signs in
88
100
 
101
+ ```
89
102
  Given a registered person is on the sign-in page
90
103
  When they request and open a valid sign-in link
91
104
  Then they are signed in
92
105
  ```
106
+ ````
93
107
 
94
108
  Feature specs can declare the expected test and evidence policy:
95
109
 
@@ -98,17 +112,19 @@ test: unit | integration | playwright | manual | skip
98
112
  screenshots: required | optional | skip
99
113
  ```
100
114
 
101
- If `screenshots` is omitted, Playwright scenarios default to `required`; non-Playwright scenarios default to `skip`. Scenario-level overrides can be written directly below a scenario heading:
115
+ If `screenshots` is omitted, Playwright scenarios default to `required`; non-Playwright scenarios default to `skip`. Scenario-level overrides can be written directly below a scenario heading, before the fenced scenario block:
102
116
 
103
- ```md
117
+ ````md
104
118
  ### ACCOUNT-ACCESS-S002: Link expiry is calculated
105
119
  Test: unit
106
120
  Screenshots: skip
107
121
 
122
+ ```
108
123
  Given a sign-in link was created 31 minutes ago
109
124
  When expiry is calculated
110
125
  Then the link is expired
111
126
  ```
127
+ ````
112
128
 
113
129
  See [SPEC_FORMAT.md](SPEC_FORMAT.md) and [docs/evidence-policy.md](docs/evidence-policy.md) for the exact format.
114
130
 
@@ -155,7 +171,8 @@ npx feature-spec-md init --kind design --dir specs
155
171
  4. Ask an AI to write executable tests from the specs, preserving the relevant `-M001`, `-R001`, and `-S001` IDs in the test source.
156
172
  5. Run `npx feature-spec-md coverage` to see which scenarios, rules, and model items have tests.
157
173
  6. Run `npx feature-spec-md report` to generate an HTML implementation report for review or CI artifacts.
158
- 7. Use `npx feature-spec-md github-report` in GitHub Actions when the report should be linked from the job summary or published.
174
+ 7. Use `npx feature-spec-md github-report` in GitHub Actions when the report should be linked from the job summary, uploaded as an artifact, or published by FTP.
175
+ 8. Use `npx feature-spec-md github-diff-report` in PR builds when a published diff should compare the current report output and screenshots with a base build and provide a PR comment body.
159
176
 
160
177
  The longer flow, including AI prompts and CI setup, is in [docs/spec-driven-flow.md](docs/spec-driven-flow.md). The demo repository also shows the flow in practice: <https://github.com/anselmdk/feature-spec-md-demo>.
161
178
 
@@ -164,8 +181,9 @@ The longer flow, including AI prompts and CI setup, is in [docs/spec-driven-flow
164
181
  ```bash
165
182
  npx feature-spec-md check
166
183
  npx feature-spec-md coverage --fail-on-missing
167
- npx feature-spec-md report --out test-results/feature-spec-report/index.html
168
- npx feature-spec-md github-report --report-dir test-results/spec-report
184
+ npx feature-spec-md report --out test-results/spec-report/index.html
185
+ npx feature-spec-md github-report --report-dir test-results/spec-report --publish ftp
186
+ npx feature-spec-md github-diff-report --publish ftp --pr-number 123
169
187
  ```
170
188
 
171
189
  By default the CLI scans:
@@ -175,7 +193,7 @@ specs/**/*.model.md
175
193
  specs/**/*.feature.md
176
194
  specs/**/*.stack.md
177
195
  specs/**/*.design.md
178
- tests/**/*.spec.ts
196
+ tests/**/*.ts
179
197
  ```
180
198
 
181
199
  Use explicit patterns when your project uses different paths:
@@ -188,13 +206,13 @@ npx feature-spec-md check \
188
206
 
189
207
  ### `check`
190
208
 
191
- Validates the spec set and, by default, requires scenario coverage when tests are scanned.
209
+ Validates the spec set and, by default, requires model, rule, and scenario coverage when tests are scanned.
192
210
 
193
211
  ```bash
194
212
  npx feature-spec-md check
195
213
  ```
196
214
 
197
- `check` validates spec structure, references between documents, and test coverage. Use `--require-scenario-coverage=false` while drafting. Use `--require-rule-coverage` and `--require-model-coverage` when rules and model items must also fail validation if they have no test references.
215
+ `check` validates spec structure, references between documents, and test coverage. Use `--require-scenario-coverage=false`, `--require-rule-coverage=false`, or `--require-model-coverage=false` while drafting.
198
216
 
199
217
  ### `coverage`
200
218
 
@@ -204,7 +222,7 @@ Prints a terminal implementation report showing covered and missing model items,
204
222
  npx feature-spec-md coverage --fail-on-missing
205
223
  ```
206
224
 
207
- Use `--fail-on-missing` when missing model item, rule, or scenario coverage should fail CI.
225
+ Use `--fail-on-missing=false` when missing model item, rule, or scenario coverage should not fail CI.
208
226
 
209
227
  ### `report`
210
228
 
@@ -223,7 +241,7 @@ npx feature-spec-md report \
223
241
  --screenshots "test-results/spec-report/screenshots-*.json"
224
242
  ```
225
243
 
226
- When CI should fail for missing declared screenshot evidence, add `--enforce-evidence`:
244
+ When CI should fail for missing declared screenshot evidence, use `--enforce-evidence`:
227
245
 
228
246
  ```bash
229
247
  npx feature-spec-md report \
@@ -232,7 +250,7 @@ npx feature-spec-md report \
232
250
  --out test-results/spec-report/index.html
233
251
  ```
234
252
 
235
- This gate only fails for scenarios whose resolved screenshot policy is `required`.
253
+ This gate only fails for scenarios whose resolved screenshot policy is `required`. In GitHub Actions, the report also includes source links and report metadata derived from the repository, ref, SHA, run, build number, and pull request context when available.
236
254
 
237
255
  ### `github-report`
238
256
 
@@ -244,6 +262,20 @@ npx feature-spec-md github-report \
244
262
  --publish ftp
245
263
  ```
246
264
 
265
+ With FTP publishing, build reports are published below a `build/<build-number>/` directory. This keeps immutable build outputs available for later PR diff comparisons.
266
+
267
+ ### `github-diff-report`
268
+
269
+ Builds and publishes a PR diff report from already-published feature spec report outputs.
270
+
271
+ ```bash
272
+ npx feature-spec-md github-diff-report \
273
+ --publish ftp \
274
+ --pr-number 123
275
+ ```
276
+
277
+ The diff report lists changed report assets, extracts changed spec sections, groups screenshot changes by spec/scenario, writes a GitHub Actions summary, and exposes a `diff-comment-body` output that a workflow can add to the PR. PR diff reports are published below `pr/<pr-number>/<build-number>/`.
278
+
247
279
  ## Playwright screenshot evidence
248
280
 
249
281
  The package exports a Playwright helper from `@anselmdk/feature-spec-md/playwright`. It maps scenario step text back to the spec line, wraps the implementation in a Playwright `test.step`, captures a screenshot after the step, attaches it to the test, and writes a screenshot manifest such as `test-results/spec-report/screenshots-0.json`.
@@ -252,7 +284,7 @@ That manifest can then be passed to `feature-spec-md report` with `--screenshots
252
284
 
253
285
  ## GitHub Actions report publishing
254
286
 
255
- `feature-spec-md github-report` writes the GitHub Actions job summary and can either prepare outputs for a GitHub artifact upload or publish the generated report to FTP.
287
+ `feature-spec-md github-report` writes the GitHub Actions job summary and can either prepare outputs for a GitHub artifact upload or publish the generated report to FTP. `feature-spec-md github-diff-report` compares published build reports and publishes a PR-specific diff report.
256
288
 
257
289
  For FTP publishing, configure repository secrets in GitHub under **Settings → Secrets and variables → Actions → Repository secrets**.
258
290
 
@@ -271,9 +303,12 @@ Optional values:
271
303
  FEATURE_SPEC_FTP_REMOTE_DIR=/public_html/feature-spec-md
272
304
  FEATURE_SPEC_FTP_PORT=21
273
305
  FEATURE_SPEC_FTP_SECURE=false
306
+ FEATURE_SPEC_BUILD_NUMBER=<build number, defaults to GITHUB_RUN_NUMBER>
307
+ FEATURE_SPEC_PR_NUMBER=<pull request number>
308
+ FEATURE_SPEC_BASE_BUILD_NUMBER=<main/base build number for PR diffs>
274
309
  ```
275
310
 
276
- Example GitHub Actions step:
311
+ Example GitHub Actions step for publishing the current report:
277
312
 
278
313
  ```yaml
279
314
  - name: Publish feature spec report
@@ -286,6 +321,19 @@ Example GitHub Actions step:
286
321
  FEATURE_SPEC_REPORT_BASE_URL: ${{ secrets.FEATURE_SPEC_REPORT_BASE_URL }}
287
322
  ```
288
323
 
324
+ Example step for publishing a PR diff after the current report has been published:
325
+
326
+ ```yaml
327
+ - name: Publish feature spec PR diff
328
+ if: always() && github.event_name == 'pull_request'
329
+ run: npx feature-spec-md github-diff-report --publish ftp --pr-number "${{ github.event.pull_request.number }}"
330
+ env:
331
+ FEATURE_SPEC_FTP_HOST: ${{ secrets.FEATURE_SPEC_FTP_HOST }}
332
+ FEATURE_SPEC_FTP_USER: ${{ secrets.FEATURE_SPEC_FTP_USER }}
333
+ FEATURE_SPEC_FTP_PASSWORD: ${{ secrets.FEATURE_SPEC_FTP_PASSWORD }}
334
+ FEATURE_SPEC_REPORT_BASE_URL: ${{ secrets.FEATURE_SPEC_REPORT_BASE_URL }}
335
+ ```
336
+
289
337
  ## Library API
290
338
 
291
339
  Most integrations can use the top-level document API:
@@ -296,6 +344,7 @@ import {
296
344
  collectSpecScreenshots,
297
345
  parseSpecDocument,
298
346
  renderHtmlReport,
347
+ renderLocalDiffReport,
299
348
  validateScenarioScreenshots,
300
349
  validateSpecDocument,
301
350
  } from "@anselmdk/feature-spec-md";
@@ -309,6 +358,8 @@ Useful exports include:
309
358
  - `buildSpecCoverageSummary` and `collectSpecTestReferences` for custom coverage workflows.
310
359
  - `collectSpecScreenshots` and `validateScenarioScreenshots` for loading and enforcing screenshot evidence.
311
360
  - `renderHtmlReport` for generating the same report UI from your own integration.
361
+ - `renderLocalDiffReport` for generating the same diff-report UI from local report directories.
362
+ - `insertReportMetadata` and `githubReportMetadata` for adding source/build/PR metadata to reports.
312
363
  - `writeTextFile` for small report-writing integrations.
313
364
 
314
365
  Feature-only helpers such as `parseFeatureSpec` and `checkFeatureSpecs` remain available for compatibility.
package/SPEC_FORMAT.md CHANGED
@@ -20,6 +20,8 @@ A spec set can contain four document types:
20
20
  - `*.stack.md` defines technical platform choices and their rationale.
21
21
  - `*.design.md` defines product, UI, layout, visual, and interaction direction.
22
22
 
23
+ For larger products, each document type can also use the optional extension sections described below. These sections are designed for API-first products, SaaS authorization models, stateful domain objects, and e2e test environments without adding new required document suffixes.
24
+
23
25
  ## Frontmatter
24
26
 
25
27
  Every file starts with YAML-like frontmatter:
@@ -73,6 +75,7 @@ Optional sections:
73
75
 
74
76
  ```md
75
77
  ## Rules
78
+ ## Model Diagram
76
79
  ```
77
80
 
78
81
  Model items use stable `-M001` IDs:
@@ -89,6 +92,22 @@ Model rules use stable `-R001` IDs and describe global invariants for the domain
89
92
  - KANBAN-R001: A card MUST have one current workflow state.
90
93
  ```
91
94
 
95
+ ### Model Diagram
96
+
97
+ Use `## Model Diagram` for one or more Mermaid diagrams that give a visual overview of model relationships. Diagrams supplement the stable model-item catalogue; they do not replace `## Model` items or affect coverage.
98
+
99
+ ````md
100
+ ## Model Diagram
101
+
102
+ ```mermaid
103
+ erDiagram
104
+ BOARD ||--o{ CARD : contains
105
+ CARD }o--|| COLUMN : belongs_to
106
+ ```
107
+ ````
108
+
109
+ Generated reports render `mermaid` fenced code blocks as diagrams. If Mermaid cannot load or a diagram is invalid, the escaped diagram source remains visible in the report. Model nodes SHOULD use recognizable names corresponding to the stable model items.
110
+
92
111
  ## Feature Files
93
112
 
94
113
  Feature files use the `.feature.md` suffix.
@@ -111,15 +130,17 @@ Rules use stable `-R001` IDs:
111
130
  - KANBAN-CARD-AUTHORING-R001: A new card MUST start in the To do column.
112
131
  ```
113
132
 
114
- Scenarios use stable `-S001` IDs and Given / When / Then steps:
133
+ Scenarios use stable `-S001` IDs and Given / When / Then steps. The scenario body MUST be written inside a fenced code block so Markdown previews preserve line breaks without relying on trailing spaces:
115
134
 
116
- ```md
135
+ ````md
117
136
  ### KANBAN-CARD-AUTHORING-S001: User creates a card
118
137
 
138
+ ```
119
139
  Given the user is on the board
120
140
  When they create a card with the title "Write release notes"
121
141
  Then the card "Write release notes" is visible in the To do column
122
142
  ```
143
+ ````
123
144
 
124
145
  Allowed step keywords are `Given`, `When`, `Then`, `And`, and `But`.
125
146
 
@@ -140,17 +161,19 @@ screenshots: required
140
161
  ---
141
162
  ```
142
163
 
143
- Scenario-level overrides go directly below a scenario heading:
164
+ Scenario-level overrides go directly below a scenario heading. The Given / When / Then steps remain inside the fenced scenario block:
144
165
 
145
- ```md
166
+ ````md
146
167
  ### KANBAN-CARD-AUTHORING-S002: Card title is normalized
147
168
  Test: unit
148
169
  Screenshots: skip
149
170
 
171
+ ```
150
172
  Given the raw card title contains leading whitespace
151
173
  When the title is normalized
152
174
  Then the stored title has no leading whitespace
153
175
  ```
176
+ ````
154
177
 
155
178
  Supported `test` values are `unit`, `integration`, `playwright`, `manual`, and `skip`.
156
179
 
@@ -196,6 +219,102 @@ Required sections:
196
219
 
197
220
  Optional sections are `## Principles`, `## Layout`, `## Interaction`, and `## Visual style`.
198
221
 
222
+ ## Extension Sections
223
+
224
+ The following optional sections are valid in model, feature, stack, and design files. They are intentionally plain Markdown so teams can adopt them gradually without changing the core scenario coverage model.
225
+
226
+ Mermaid fenced code blocks are supported inside extension sections. Prefer `## Model Diagram` for domain relationship overviews and `## Lifecycle` for state diagrams.
227
+
228
+ ### Open Questions
229
+
230
+ Use `## Open Questions` for unresolved product, technical, or testing choices that block confident implementation.
231
+
232
+ ```md
233
+ ## Open Questions
234
+
235
+ - BOOKING-Q001: Confirm whether attendees can buy individual drop-in classes.
236
+ - BOOKING-Q002: Confirm whether "user" includes public attendees or only staff members.
237
+ ```
238
+
239
+ Open question IDs SHOULD use the document id plus `-Q001`. They are not treated as executable behavior and do not require test coverage.
240
+
241
+ ### Assumptions
242
+
243
+ Use `## Assumptions` for temporary truths the spec relies on until they are confirmed, replaced, or promoted into rules.
244
+
245
+ ```md
246
+ ## Assumptions
247
+
248
+ - BOOKING-A001: MobilePay is the first live payment provider.
249
+ - BOOKING-A002: All initial recurring events repeat weekly.
250
+ ```
251
+
252
+ Assumption IDs SHOULD use the document id plus `-A001`. When an assumption becomes durable product behavior, rewrite it as a rule with a `-R001` id.
253
+
254
+ ### API Contract
255
+
256
+ Use `## API Contract` for API-first features and services. This section SHOULD describe endpoints, auth requirements, request/response shapes, status codes, idempotency expectations, pagination, webhooks, and links to generated OpenAPI or Swagger output.
257
+
258
+ ```md
259
+ ## API Contract
260
+
261
+ | Method | Path | Purpose | Auth |
262
+ | --- | --- | --- | --- |
263
+ | POST | /api/events | Create an event | administrator |
264
+ | GET | /api/events | List visible events | account member |
265
+ ```
266
+
267
+ API contract details can be covered by integration tests that reference the relevant scenario and rule IDs.
268
+
269
+ ### Permissions
270
+
271
+ Use `## Permissions` for role, group, and tenant capability matrices.
272
+
273
+ ```md
274
+ ## Permissions
275
+
276
+ | Capability | Owner | Administrator | Teacher | Attendee |
277
+ | --- | --- | --- | --- | --- |
278
+ | Manage payment connection | yes | no | no | no |
279
+ | Move own class instance | yes | yes | yes | no |
280
+ ```
281
+
282
+ Permissions that must be enforced by the implementation SHOULD also be written as rules so coverage can track them.
283
+
284
+ ### Lifecycle
285
+
286
+ Use `## Lifecycle` for state machines and transition rules for entities such as bookings, payments, subscriptions, passes, and recurring event instances.
287
+
288
+ ```md
289
+ ## Lifecycle
290
+
291
+ Payment states:
292
+
293
+ ```txt
294
+ pending -> paid
295
+ pending -> failed
296
+ paid -> refunded
297
+ ```
298
+
299
+ Invalid transitions MUST be rejected by the API.
300
+ ```
301
+
302
+ Lifecycle behavior that must be executable SHOULD be represented by rules and scenarios in the same feature file.
303
+
304
+ ### Test Environment
305
+
306
+ Use `## Test Environment` for mocks, seed data, fixed-time behavior, provider adapters, and CI/e2e setup.
307
+
308
+ ```md
309
+ ## Test Environment
310
+
311
+ - Email delivery is mocked and exposes the latest magic link to tests.
312
+ - MobilePay runs through a deterministic mock in CI.
313
+ - Time is frozen during recurring-event generation tests.
314
+ ```
315
+
316
+ This section is especially useful for SaaS apps with external dependencies such as payment providers, email, calendars, or third-party APIs.
317
+
199
318
  ## Splitting Guidance
200
319
 
201
320
  Split model files by coherent domain vocabulary, ownership, or lifecycle.
@@ -206,6 +325,8 @@ Use stack files for broad technical choices such as framework, language, testing
206
325
 
207
326
  Use design files for product/UI direction such as layout, interaction, visual style, and design principles.
208
327
 
328
+ Use extension sections when the information belongs with a spec but is not itself a model item, durable rule, or executable scenario. If an extension section becomes large enough to hide the core behavior, split the surrounding model or feature document instead of creating a new document type.
329
+
209
330
  Keep each document small enough for an AI to read, revise, and use as test-writing context without extra explanation.
210
331
 
211
332
  ## Test Coverage Convention
@@ -31,7 +31,7 @@ export async function publishGithubActionDiffReport(options) {
31
31
  await writeGithubSummary([
32
32
  "## Feature Spec PR Diff",
33
33
  "",
34
- `<p><strong>Diff report:</strong> <a href=\"${html(reportUrl)}\">PR #${html(prNumber)} build ${html(currentBuild)}</a></p>`,
34
+ `<p><strong>Diff report:</strong> <a href="${html(reportUrl)}">PR #${html(prNumber)} build ${html(currentBuild)}</a></p>`,
35
35
  summaryComparisonSentence(report),
36
36
  "",
37
37
  ].join("\n"));
@@ -140,12 +140,11 @@ async function compareLocalFiles(previousDir, currentDir) {
140
140
  const previousSet = new Set(previousFiles);
141
141
  const currentSet = new Set(currentFiles);
142
142
  const allPaths = Array.from(new Set([...previousSet, ...currentSet])).sort();
143
- const files = [];
144
- for (const filePath of allPaths) {
143
+ return Promise.all(allPaths.map(async (filePath) => {
145
144
  const previousInfo = previousSet.has(filePath) ? await fileInfo(join(previousDir, filePath)) : undefined;
146
145
  const currentInfo = currentSet.has(filePath) ? await fileInfo(join(currentDir, filePath)) : undefined;
147
146
  const status = !previousInfo ? "added" : !currentInfo ? "removed" : previousInfo.hash === currentInfo.hash ? "unchanged" : "changed";
148
- files.push({
147
+ return {
149
148
  path: filePath,
150
149
  kind: fileKind(filePath),
151
150
  status,
@@ -153,9 +152,8 @@ async function compareLocalFiles(previousDir, currentDir) {
153
152
  currentHash: currentInfo?.hash,
154
153
  previousSize: previousInfo?.size,
155
154
  currentSize: currentInfo?.size,
156
- });
157
- }
158
- return files;
155
+ };
156
+ }));
159
157
  }
160
158
  async function listLocalFilesRecursive(root) {
161
159
  const files = [];
@@ -172,12 +170,10 @@ async function listLocalFilesRecursive(root) {
172
170
  }
173
171
  for (const entry of entries) {
174
172
  const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
175
- if (entry.isDirectory()) {
173
+ if (entry.isDirectory())
176
174
  await visit(relativePath);
177
- }
178
- else if (entry.isFile()) {
175
+ else if (entry.isFile())
179
176
  files.push(relativePath);
180
- }
181
177
  }
182
178
  }
183
179
  }
@@ -190,7 +186,7 @@ async function loadSpecSections(root) {
190
186
  }
191
187
  async function loadPublishedSpecSections(root) {
192
188
  const specRoot = join(root, publishedSpecRoot);
193
- const files = (await listLocalFilesRecursive(specRoot)).filter((file) => file.endsWith(".feature.md"));
189
+ const files = (await listLocalFilesRecursive(specRoot)).filter(isSpecMarkdownFile);
194
190
  const sections = [];
195
191
  for (const file of files) {
196
192
  const source = await readFile(join(specRoot, file), "utf8");
@@ -201,6 +197,9 @@ async function loadPublishedSpecSections(root) {
201
197
  }
202
198
  return sections.sort((a, b) => a.key.localeCompare(b.key));
203
199
  }
200
+ function isSpecMarkdownFile(filePath) {
201
+ return filePath.endsWith(".md");
202
+ }
204
203
  function previousBuildNumber(builds, currentBuild) {
205
204
  const current = Number(currentBuild);
206
205
  return builds.map(Number).filter((build) => Number.isFinite(build) && build < current).sort((a, b) => b - a).at(0)?.toString();
@@ -253,13 +252,7 @@ function extractSpecSections(source) {
253
252
  const scenarioIds = Array.from(fragment.matchAll(/<summary><code>([^<]+)<\/code>/g), (item) => textContent(item[1] ?? ""));
254
253
  const text = specText(fragment);
255
254
  const filePath = filePaths[0];
256
- sections.push({
257
- key: filePath ?? title,
258
- title,
259
- filePath,
260
- scenarioIds,
261
- text,
262
- });
255
+ sections.push({ key: filePath ?? title, title, filePath, scenarioIds, text });
263
256
  }
264
257
  return sections;
265
258
  }
@@ -350,9 +343,8 @@ function compactContext(lines) {
350
343
  function scenarioSpecMap(specs) {
351
344
  const map = new Map();
352
345
  for (const spec of specs) {
353
- for (const scenarioId of spec.scenarioIds) {
346
+ for (const scenarioId of spec.scenarioIds)
354
347
  map.set(scenarioId, spec);
355
- }
356
348
  }
357
349
  return map;
358
350
  }
@@ -384,9 +376,7 @@ function screenshotTitle(filePath, scenarioId) {
384
376
  return scenarioId ? name.replace(`${scenarioId}-`, `${scenarioId} `) : name;
385
377
  }
386
378
  function relativeAssetUrl(prefix, filePath) {
387
- return [prefix.replace(/\/+$/, ""), ...filePath.split("/").filter(Boolean)]
388
- .filter(Boolean)
389
- .join("/");
379
+ return [prefix.replace(/\/+$/, ""), ...filePath.split("/").filter(Boolean)].filter(Boolean).join("/");
390
380
  }
391
381
  function renderDiffReport(report) {
392
382
  const changed = report.files.filter((file) => file.status !== "unchanged" && file.kind !== "report");
@@ -425,8 +415,8 @@ th{background:#f6f8fa}a{color:#0969da}
425
415
  }
426
416
  function renderSpecDiffs(specDiffs) {
427
417
  if (!specDiffs.length)
428
- return `<section class="panel"><h2>Spec changes</h2><p class="muted">No feature spec source changes detected.</p></section>`;
429
- return `<section class="panel"><h2>Spec changes</h2><p class="muted">Diffed from published feature spec source files, not from rendered report HTML.</p>${specDiffs.map(renderSpecDiff).join("\n")}</section>`;
418
+ return `<section class="panel"><h2>Spec changes</h2><p class="muted">No spec source changes detected.</p></section>`;
419
+ return `<section class="panel"><h2>Spec changes</h2><p class="muted">Diffed from published Markdown files under the specs directory, not from rendered report HTML.</p>${specDiffs.map(renderSpecDiff).join("\n")}</section>`;
430
420
  }
431
421
  function renderSpecDiff(diff) {
432
422
  return `<details open><summary><strong>${html(diff.filePath ?? diff.title)}</strong> <span class="badge ${diff.status}">${html(diff.status)}</span></summary><table class="diff"><tbody>${diff.lines.map(renderDiffLine).join("")}</tbody></table></details>`;