executable-stories-formatters 0.14.0 → 0.15.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/dist/cli.js +346 -167
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +8 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/schemas/README.md +1 -1
- package/templates/astro-starlight/astro.config.mjs +57 -0
- package/templates/astro-starlight/gitignore +14 -0
- package/templates/astro-starlight/package.json +20 -0
- package/templates/astro-starlight/public/stories/assets/.gitkeep +0 -0
- package/templates/astro-starlight/public/stories/notes-index.json +4 -0
- package/templates/astro-starlight/public/stories/story-report.json +17 -0
- package/templates/astro-starlight/src/components/ApiOperations.astro +366 -0
- package/templates/astro-starlight/src/components/Checklist.astro +15 -0
- package/templates/astro-starlight/src/components/HealthDashboard.astro +171 -0
- package/templates/astro-starlight/src/components/PageTitle.astro +53 -0
- package/templates/astro-starlight/src/components/VerifiedBy.astro +281 -0
- package/templates/astro-starlight/src/components/VerifiedStep.astro +91 -0
- package/templates/astro-starlight/src/content/docs/examples/example-adr.mdx +45 -0
- package/templates/astro-starlight/src/content/docs/guides/behavior-portal.mdx +41 -0
- package/templates/astro-starlight/src/content/docs/guides/writing-docs.mdx +49 -0
- package/templates/astro-starlight/src/content/docs/index.mdx +49 -0
- package/templates/astro-starlight/src/content/docs/stories/.gitkeep +0 -0
- package/templates/astro-starlight/src/content.config.ts +18 -0
- package/templates/astro-starlight/src/lib/config.ts +50 -0
- package/templates/astro-starlight/src/lib/render-doc-entry.ts +154 -0
- package/templates/astro-starlight/src/lib/report-health.ts +61 -0
- package/templates/astro-starlight/src/lib/verification.ts +247 -0
- package/templates/astro-starlight/src/pages/explorer/explorer.css +729 -0
- package/templates/astro-starlight/src/pages/explorer/index.astro +404 -0
- package/templates/astro-starlight/src/styles/global.css +293 -0
- package/templates/astro-starlight/src/styles/themes/corporate.css +83 -0
- package/templates/astro-starlight/src/styles/themes/dashboard.css +76 -0
- package/templates/astro-starlight/src/styles/themes/default.css +86 -0
- package/templates/astro-starlight/src/styles/themes/minimal.css +87 -0
- package/templates/astro-starlight/src/styles/themes/playful.css +77 -0
- package/templates/astro-starlight/src/styles/themes/terminal.css +77 -0
- package/templates/astro-starlight/tsconfig.json +13 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Writing hand-written docs
|
|
3
|
+
description: How prose pages live alongside generated story docs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
This page is hand-written — proof that any author can add prose to the same site
|
|
7
|
+
as the generated story docs, with the same navigation, search, and theming.
|
|
8
|
+
|
|
9
|
+
## Where pages live
|
|
10
|
+
|
|
11
|
+
- **Generated** story pages are written to `src/content/docs/stories/` by
|
|
12
|
+
`executable-stories build-docs reports/raw-run.json --site-dir .`. Don't edit
|
|
13
|
+
these by hand — they are regenerated from tests.
|
|
14
|
+
- **Hand-written** pages (guides, ADRs, runbooks, incidents, API docs) live in
|
|
15
|
+
their own folders under `src/content/docs/`. They are never overwritten by a
|
|
16
|
+
regenerate.
|
|
17
|
+
- **Scenario notes** live in `src/content/docs/notes/` and add stakeholder
|
|
18
|
+
context next to a specific scenario without editing the generated page. After
|
|
19
|
+
`build-docs`, the matching story page, the Explorer, and the `/stories/`
|
|
20
|
+
overview each link to the note automatically — no manual cross-linking.
|
|
21
|
+
|
|
22
|
+
Both kinds share one sidebar, one search index, and one set of themes.
|
|
23
|
+
|
|
24
|
+
If you scaffolded the site with `init-astro`, the template `.gitignore` already
|
|
25
|
+
ignores the generated machine zone (`stories/` and `public/stories/*`) so git
|
|
26
|
+
tracks your hand-written content by default, not the latest test run output.
|
|
27
|
+
|
|
28
|
+
## Keep prose honest, too
|
|
29
|
+
|
|
30
|
+
Hand-written pages can link to the tests that back them. Add `verifiedBy` to the
|
|
31
|
+
frontmatter and a live badge appears under the title:
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
---
|
|
35
|
+
title: How transfers are priced
|
|
36
|
+
verifiedBy: [pricing, transfer-fee]
|
|
37
|
+
---
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If a linked story starts failing, the badge turns red — so even your prose
|
|
41
|
+
can't quietly drift away from the code.
|
|
42
|
+
|
|
43
|
+
## Start from a template
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
executable-stories new adr "Cap combined discount at 30%"
|
|
47
|
+
executable-stories new runbook "Restart the payments service"
|
|
48
|
+
executable-stories new scenario-note "Checkout happy path" --scenario-id feature-checkout--happy-path
|
|
49
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Story Docs
|
|
3
|
+
description: Living documentation generated from your executable stories.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
import HealthDashboard from '../../components/HealthDashboard.astro';
|
|
7
|
+
|
|
8
|
+
<HealthDashboard />
|
|
9
|
+
|
|
10
|
+
Living documentation for your whole team — product owners, testers, and
|
|
11
|
+
engineers — generated from your executable stories and kept honest by the tests
|
|
12
|
+
themselves. Works the same whether your stories are written in TypeScript,
|
|
13
|
+
Python, Go, Ruby, Java/Kotlin, Rust, or C#: it all flows through one report.
|
|
14
|
+
|
|
15
|
+
## Trust model
|
|
16
|
+
|
|
17
|
+
- **Proven** — generated story pages and Explorer data come from the latest test run.
|
|
18
|
+
- **Commentary** — guides, ADRs, runbooks, incidents, and notes are hand-written context.
|
|
19
|
+
- **Linked** — add `verifiedBy` to commentary pages to show which stories still prove them.
|
|
20
|
+
- **Stale** — failing or missing proof shows up as a badge; it is surfaced, not hidden.
|
|
21
|
+
|
|
22
|
+
## What you can do here
|
|
23
|
+
|
|
24
|
+
- **Browse stories** — the [Scenario Explorer](/explorer/) is a searchable, filterable
|
|
25
|
+
view of every executable story.
|
|
26
|
+
- **Verify any page** — add `verifiedBy: [story-id]` to a page's frontmatter and a
|
|
27
|
+
live badge shows whether the tests that prove it currently pass.
|
|
28
|
+
- **Start from a template** — `executable-stories new adr "…"` (also `runbook`,
|
|
29
|
+
`decision-log`, `incident`, `scenario-note`), each pre-wired to verifying stories.
|
|
30
|
+
- **Document your API** — `executable-stories import-openapi spec.json --run story-report.json`
|
|
31
|
+
generates endpoint pages with a coverage badge per route.
|
|
32
|
+
- **Keep links honest** — `executable-stories check-links src/content/docs` fails CI
|
|
33
|
+
on broken links.
|
|
34
|
+
|
|
35
|
+
## Getting started
|
|
36
|
+
|
|
37
|
+
Generate the behavior portal from any test run:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# run your tests first (StoryReporter writes reports/raw-run.json), then:
|
|
41
|
+
executable-stories build-docs reports/raw-run.json --site-dir .
|
|
42
|
+
# add --audience-split to split pages into business vs technical URLs
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then start the dev server:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm run dev
|
|
49
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineCollection } from 'astro:content';
|
|
2
|
+
import { z } from 'astro:content';
|
|
3
|
+
import { docsLoader } from '@astrojs/starlight/loaders';
|
|
4
|
+
import { docsSchema } from '@astrojs/starlight/schema';
|
|
5
|
+
|
|
6
|
+
export const collections = {
|
|
7
|
+
docs: defineCollection({
|
|
8
|
+
loader: docsLoader(),
|
|
9
|
+
// `verifiedBy` links a page to the stories that prove it is still true.
|
|
10
|
+
// The PageTitle override renders a live verification badge from it.
|
|
11
|
+
schema: docsSchema({
|
|
12
|
+
extend: z.object({
|
|
13
|
+
verifiedBy: z.union([z.string(), z.array(z.string())]).optional(),
|
|
14
|
+
scenarioId: z.string().optional(),
|
|
15
|
+
}),
|
|
16
|
+
}),
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site configuration — the one place to point at your test-run data.
|
|
3
|
+
*
|
|
4
|
+
* The verification badges, runbook checklists, and health dashboard read the
|
|
5
|
+
* latest run at build time (`report`); the Scenario Explorer fetches the same
|
|
6
|
+
* file in the browser (`REPORT_URL`). If you move the report, change it here —
|
|
7
|
+
* nowhere else references the path.
|
|
8
|
+
*
|
|
9
|
+
* Generate the report with (writes public/stories/story-report.json for you):
|
|
10
|
+
* executable-stories build-docs reports/raw-run.json --site-dir .
|
|
11
|
+
*
|
|
12
|
+
* `report` (build-time import) and `REPORT_URL` (runtime fetch) must point at
|
|
13
|
+
* the same file — one is a filesystem path, the other the URL it's served at.
|
|
14
|
+
*/
|
|
15
|
+
import reportData from "../../public/stories/story-report.json";
|
|
16
|
+
import type { StoryReportLike } from "./verification";
|
|
17
|
+
|
|
18
|
+
/** The latest test run, imported at build time (badges, checklist, dashboard). */
|
|
19
|
+
export const report = reportData as StoryReportLike;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Public URL the Scenario Explorer fetches the run from at runtime.
|
|
23
|
+
* Override per-environment with `PUBLIC_STORY_REPORT_URL` in a `.env` file.
|
|
24
|
+
*/
|
|
25
|
+
export const REPORT_URL: string =
|
|
26
|
+
import.meta.env.PUBLIC_STORY_REPORT_URL ?? "/stories/story-report.json";
|
|
27
|
+
|
|
28
|
+
/** Public URL the Explorer fetches the scenario-note index from at runtime. */
|
|
29
|
+
export const NOTES_INDEX_URL: string =
|
|
30
|
+
import.meta.env.PUBLIC_NOTES_INDEX_URL ?? "/stories/notes-index.json";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Base URL of the source repository (e.g. https://github.com/acme/app/blob/main).
|
|
34
|
+
* When set, the Scenario Explorer turns each scenario's file path into a link to
|
|
35
|
+
* the test on your git host. Leave unset to fall back to an in-page anchor.
|
|
36
|
+
* Override with `PUBLIC_SOURCE_BASE_URL` in a `.env` file.
|
|
37
|
+
*/
|
|
38
|
+
export const SOURCE_BASE_URL: string =
|
|
39
|
+
import.meta.env.PUBLIC_SOURCE_BASE_URL ?? "";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Deep-link to a single story inside the Scenario Explorer. Used by the
|
|
43
|
+
* verification badge and the API coverage table so "verified by X" always
|
|
44
|
+
* takes you to X. Respects Astro's configured `base`.
|
|
45
|
+
*/
|
|
46
|
+
export function explorerUrl(storyId: string): string {
|
|
47
|
+
const base = import.meta.env.BASE_URL ?? "/";
|
|
48
|
+
const prefix = base.endsWith("/") ? base : `${base}/`;
|
|
49
|
+
return `${prefix}explorer/#${encodeURIComponent(storyId)}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure doc-entry → HTML renderer for the Scenario Explorer.
|
|
3
|
+
*
|
|
4
|
+
* The Explorer builds its detail pane from the story report at runtime; this is
|
|
5
|
+
* the string-building logic behind it, kept DOM-free so it can be unit-tested
|
|
6
|
+
* and so the page file stays a thin shell. Markdown/diagrams/code are emitted as
|
|
7
|
+
* placeholder markup (`data-md`, `.mermaid`, `language-*`) that the Explorer
|
|
8
|
+
* hydrates with the bundled marked/mermaid/highlight.js libraries.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the doc kinds in the formatter's HTML renderer
|
|
11
|
+
* (src/formatters/html/renderers/doc-entries.ts) but emits the Explorer's own
|
|
12
|
+
* card markup. `tag` is intentionally skipped — tags render as header pills.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Loose structural shape of a story-report doc entry (template is decoupled from the package types). */
|
|
16
|
+
export interface DocEntry {
|
|
17
|
+
kind: string;
|
|
18
|
+
text?: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
value?: unknown;
|
|
21
|
+
lang?: string;
|
|
22
|
+
content?: string;
|
|
23
|
+
columns?: string[];
|
|
24
|
+
rows?: string[][];
|
|
25
|
+
url?: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
markdown?: string;
|
|
28
|
+
code?: string;
|
|
29
|
+
path?: string;
|
|
30
|
+
alt?: string;
|
|
31
|
+
poster?: string;
|
|
32
|
+
caption?: string;
|
|
33
|
+
type?: string;
|
|
34
|
+
data?: unknown;
|
|
35
|
+
children?: DocEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function escapeHtml(value: unknown): string {
|
|
39
|
+
return String(value ?? "")
|
|
40
|
+
.replaceAll("&", "&")
|
|
41
|
+
.replaceAll("<", "<")
|
|
42
|
+
.replaceAll(">", ">")
|
|
43
|
+
.replaceAll('"', """);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isRemote = (p: string): boolean => /^(?:https?:|data:)/i.test(p);
|
|
47
|
+
const isWindowsFsPath = (p: string): boolean => /^(?:[A-Za-z]:[\\/]|\\\\)/.test(p);
|
|
48
|
+
|
|
49
|
+
const mediaCaption = (text: string | undefined): string =>
|
|
50
|
+
text ? `<div class="doc-media__caption">${escapeHtml(text)}</div>` : "";
|
|
51
|
+
|
|
52
|
+
const broken = (kind: string, p: string): string =>
|
|
53
|
+
`<div class="doc-media doc-media--broken">${kind} unavailable<code>${escapeHtml(p)}</code></div>`;
|
|
54
|
+
|
|
55
|
+
const renderScreenshot = (entry: DocEntry): string => {
|
|
56
|
+
const path = entry.path ?? "";
|
|
57
|
+
if (isWindowsFsPath(path)) return broken("Screenshot", path);
|
|
58
|
+
return `<div class="doc-media"><img src="${escapeHtml(path)}" alt="${escapeHtml(
|
|
59
|
+
entry.alt ?? "Screenshot",
|
|
60
|
+
)}" loading="lazy" />${mediaCaption(entry.alt)}</div>`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const renderVideo = (entry: DocEntry): string => {
|
|
64
|
+
const path = entry.path ?? "";
|
|
65
|
+
if (!isRemote(path) && isWindowsFsPath(path)) return broken("Video", path);
|
|
66
|
+
const poster = entry.poster ? ` poster="${escapeHtml(entry.poster)}"` : "";
|
|
67
|
+
return `<div class="doc-media"><video controls preload="metadata"${poster} src="${escapeHtml(
|
|
68
|
+
path,
|
|
69
|
+
)}">Your browser cannot play this video.</video>${mediaCaption(entry.caption)}</div>`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A labelled card wrapper used by most doc kinds. */
|
|
73
|
+
const labelled = (label: string, bodyHtml: string): string =>
|
|
74
|
+
`<div class="doc-entry"><div class="doc-entry__label">${escapeHtml(
|
|
75
|
+
label,
|
|
76
|
+
)}</div><div class="doc-entry__body">${bodyHtml}</div></div>`;
|
|
77
|
+
|
|
78
|
+
const renderVisualCustom = (data: Record<string, unknown>): string => {
|
|
79
|
+
const img = (src: unknown, label: string): string =>
|
|
80
|
+
typeof src === "string"
|
|
81
|
+
? `<div class="doc-visual-item"><div class="doc-visual-label">${escapeHtml(
|
|
82
|
+
label,
|
|
83
|
+
)}</div><img src="${escapeHtml(src)}" alt="${escapeHtml(label)}" /></div>`
|
|
84
|
+
: "";
|
|
85
|
+
return labelled(
|
|
86
|
+
`Visual check · ${escapeHtml(typeof data.status === "string" ? data.status : "unknown")}`,
|
|
87
|
+
`<div class="doc-visual-grid">${img(data.baseline, "Baseline")}${img(data.actual, "Actual")}${img(
|
|
88
|
+
data.diff,
|
|
89
|
+
"Diff",
|
|
90
|
+
)}</div>`,
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const RENDERERS: Record<string, (doc: DocEntry) => string> = {
|
|
95
|
+
// Tags already render as pills in the header — skip the noise.
|
|
96
|
+
tag: () => "",
|
|
97
|
+
note: (doc) => `<div class="doc-entry"><div class="doc-note">${escapeHtml(doc.text)}</div></div>`,
|
|
98
|
+
kv: (doc) => {
|
|
99
|
+
const value = typeof doc.value === "string" ? doc.value : JSON.stringify(doc.value, null, 2);
|
|
100
|
+
return `<div class="doc-entry"><div class="doc-kv"><span class="doc-kv__label">${escapeHtml(
|
|
101
|
+
doc.label,
|
|
102
|
+
)}</span><span class="doc-kv__value">${escapeHtml(value)}</span></div></div>`;
|
|
103
|
+
},
|
|
104
|
+
code: (doc) => {
|
|
105
|
+
const langClass = doc.lang ? ` class="language-${escapeHtml(doc.lang)}"` : "";
|
|
106
|
+
return labelled(
|
|
107
|
+
doc.label || doc.lang || "code",
|
|
108
|
+
`<pre><code${langClass}>${escapeHtml(doc.content)}</code></pre>`,
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
table: (doc) => {
|
|
112
|
+
const head = (doc.columns ?? []).map((c) => `<th>${escapeHtml(c)}</th>`).join("");
|
|
113
|
+
const body = (doc.rows ?? [])
|
|
114
|
+
.map((r) => `<tr>${r.map((c) => `<td>${escapeHtml(c)}</td>`).join("")}</tr>`)
|
|
115
|
+
.join("");
|
|
116
|
+
return labelled(
|
|
117
|
+
doc.label || "table",
|
|
118
|
+
`<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`,
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
link: (doc) =>
|
|
122
|
+
`<div class="doc-entry"><a class="doc-link-a" href="${escapeHtml(
|
|
123
|
+
doc.url,
|
|
124
|
+
)}" target="_blank" rel="noopener noreferrer">${escapeHtml(doc.label || doc.url)}</a></div>`,
|
|
125
|
+
section: (doc) =>
|
|
126
|
+
labelled(
|
|
127
|
+
doc.title || "section",
|
|
128
|
+
`<div class="doc-section-content" data-md="${escapeHtml(
|
|
129
|
+
encodeURIComponent(doc.markdown ?? ""),
|
|
130
|
+
)}"><pre>${escapeHtml(doc.markdown ?? "")}</pre></div>`,
|
|
131
|
+
),
|
|
132
|
+
mermaid: (doc) =>
|
|
133
|
+
labelled(doc.title || "diagram", `<pre class="mermaid">${escapeHtml(doc.code ?? "")}</pre>`),
|
|
134
|
+
screenshot: (doc) => `<div class="doc-entry"><div class="doc-entry__body">${renderScreenshot(doc)}</div></div>`,
|
|
135
|
+
video: (doc) => `<div class="doc-entry"><div class="doc-entry__body">${renderVideo(doc)}</div></div>`,
|
|
136
|
+
custom: (doc) =>
|
|
137
|
+
doc.type === "visual" && doc.data && typeof doc.data === "object"
|
|
138
|
+
? renderVisualCustom(doc.data as Record<string, unknown>)
|
|
139
|
+
: labelled(doc.type || "custom", `<pre><code>${escapeHtml(JSON.stringify(doc.data, null, 2))}</code></pre>`),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export function renderDocEntry(doc: DocEntry): string {
|
|
143
|
+
let html = RENDERERS[doc.kind]?.(doc) ?? "";
|
|
144
|
+
if (html && doc.children && doc.children.length > 0) {
|
|
145
|
+
const childHtml = doc.children.map(renderDocEntry).join("");
|
|
146
|
+
if (childHtml) html += `<div class="doc-children">${childHtml}</div>`;
|
|
147
|
+
}
|
|
148
|
+
return html;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function renderDocs(docs: DocEntry[] | undefined): string {
|
|
152
|
+
if (!docs?.length) return '<p class="meta">No docs attached.</p>';
|
|
153
|
+
return docs.map(renderDocEntry).join("") || '<p class="meta">No docs attached.</p>';
|
|
154
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health summary for the dashboard landing page.
|
|
3
|
+
*
|
|
4
|
+
* Confluence dashboards are the first thing a team sees and they are static.
|
|
5
|
+
* This one is computed from the latest test run, so the home page always
|
|
6
|
+
* reflects whether the documented system is actually green right now.
|
|
7
|
+
*
|
|
8
|
+
* Pure (no Astro imports) so it can be unit-tested.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { flattenReport, type StoryReportLike, type ScenarioLike } from "./verification";
|
|
12
|
+
|
|
13
|
+
export interface FailingScenario {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
feature?: string;
|
|
17
|
+
sourceFile?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HealthSummary {
|
|
21
|
+
total: number;
|
|
22
|
+
passed: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
skipped: number;
|
|
25
|
+
pending: number;
|
|
26
|
+
/** Passed / total, 0..1 (0 when there are no scenarios). */
|
|
27
|
+
passRate: number;
|
|
28
|
+
lastRunMs?: number;
|
|
29
|
+
runId?: string;
|
|
30
|
+
failing: FailingScenario[];
|
|
31
|
+
/** True when no run has populated the report yet. */
|
|
32
|
+
empty: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function summarizeHealth(report: StoryReportLike): HealthSummary {
|
|
36
|
+
const scenarios: ScenarioLike[] = flattenReport(report);
|
|
37
|
+
const count = (status: string) => scenarios.filter((s) => s.status === status).length;
|
|
38
|
+
|
|
39
|
+
const passed = count("passed");
|
|
40
|
+
const failed = count("failed");
|
|
41
|
+
const skipped = count("skipped");
|
|
42
|
+
const pending = count("pending");
|
|
43
|
+
const total = scenarios.length;
|
|
44
|
+
|
|
45
|
+
const failing: FailingScenario[] = scenarios
|
|
46
|
+
.filter((s) => s.status === "failed")
|
|
47
|
+
.map((s) => ({ id: s.id, title: s.title, feature: s.feature, sourceFile: s.sourceFile }));
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
total,
|
|
51
|
+
passed,
|
|
52
|
+
failed,
|
|
53
|
+
skipped,
|
|
54
|
+
pending,
|
|
55
|
+
passRate: total > 0 ? passed / total : 0,
|
|
56
|
+
lastRunMs: report.finishedAtMs && report.finishedAtMs > 0 ? report.finishedAtMs : undefined,
|
|
57
|
+
runId: report.runId && report.runId.length > 0 ? report.runId : undefined,
|
|
58
|
+
failing,
|
|
59
|
+
empty: total === 0,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verified-by resolver — the core of living documentation.
|
|
3
|
+
*
|
|
4
|
+
* A docs page declares which stories prove it is still true:
|
|
5
|
+
*
|
|
6
|
+
* ---
|
|
7
|
+
* title: ADR 0007 — Cap combined discount at 30%
|
|
8
|
+
* verifiedBy: [checkout--caps-the-discount-at-30-percent, pricing]
|
|
9
|
+
* ---
|
|
10
|
+
*
|
|
11
|
+
* At build time each reference is resolved against the latest test run
|
|
12
|
+
* (story-report.json) so the page renders a live status badge. If the
|
|
13
|
+
* referenced story is missing or failing, the page says so loudly — that is
|
|
14
|
+
* the mechanism that stops documentation from rotting.
|
|
15
|
+
*
|
|
16
|
+
* This module is intentionally free of any Astro/runtime imports so it can be
|
|
17
|
+
* unit-tested in isolation and reused anywhere.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ScenarioStatus = "passed" | "failed" | "skipped" | "pending";
|
|
21
|
+
|
|
22
|
+
/** Overall verification verdict for a set of references. */
|
|
23
|
+
export type VerificationStatus =
|
|
24
|
+
/** Every matched story passed in the last run. */
|
|
25
|
+
| "verified"
|
|
26
|
+
/** At least one matched story failed. */
|
|
27
|
+
| "failing"
|
|
28
|
+
/** Stories matched but some were skipped/pending — not actually exercised. */
|
|
29
|
+
| "not-run"
|
|
30
|
+
/** No story matched — the page references something that doesn't exist. */
|
|
31
|
+
| "unverified";
|
|
32
|
+
|
|
33
|
+
export interface ScenarioLike {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
status: ScenarioStatus;
|
|
37
|
+
tags?: string[];
|
|
38
|
+
tickets?: Array<{ id: string; url?: string }>;
|
|
39
|
+
/** Feature title (filled in when flattening a report). */
|
|
40
|
+
feature?: string;
|
|
41
|
+
sourceFile?: string;
|
|
42
|
+
sourceLine?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StoryReportLike {
|
|
46
|
+
runId?: string;
|
|
47
|
+
finishedAtMs?: number;
|
|
48
|
+
features?: Array<{
|
|
49
|
+
title?: string;
|
|
50
|
+
sourceFile?: string;
|
|
51
|
+
scenarios?: ScenarioLike[];
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Per-reference resolution: which scenarios a single `verifiedBy` entry matched. */
|
|
56
|
+
export interface RefResolution {
|
|
57
|
+
ref: string;
|
|
58
|
+
matched: ScenarioLike[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface VerificationResult {
|
|
62
|
+
status: VerificationStatus;
|
|
63
|
+
/** Run finish time, used as the "last verified" timestamp. */
|
|
64
|
+
lastVerifiedMs?: number;
|
|
65
|
+
runId?: string;
|
|
66
|
+
/** Number of distinct scenarios matched across all references. */
|
|
67
|
+
total: number;
|
|
68
|
+
passed: number;
|
|
69
|
+
failed: number;
|
|
70
|
+
/** skipped + pending. */
|
|
71
|
+
notRun: number;
|
|
72
|
+
refs: RefResolution[];
|
|
73
|
+
/** References that matched no scenario at all. */
|
|
74
|
+
missingRefs: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Flatten a story report into a single list of scenarios with feature context. */
|
|
78
|
+
export function flattenReport(report: StoryReportLike): ScenarioLike[] {
|
|
79
|
+
const features = report.features ?? [];
|
|
80
|
+
return features.flatMap((feature) =>
|
|
81
|
+
(feature.scenarios ?? []).map((scenario) => ({
|
|
82
|
+
...scenario,
|
|
83
|
+
feature: scenario.feature ?? feature.title,
|
|
84
|
+
sourceFile: scenario.sourceFile ?? feature.sourceFile,
|
|
85
|
+
})),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve a single scenario id against the latest report. */
|
|
90
|
+
export function findScenarioById(
|
|
91
|
+
report: StoryReportLike,
|
|
92
|
+
scenarioId: string,
|
|
93
|
+
): ScenarioLike | undefined {
|
|
94
|
+
return flattenReport(report).find((scenario) => scenario.id === scenarioId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Convenience check for human-authored pages that point at one scenario id. */
|
|
98
|
+
export function hasScenarioId(report: StoryReportLike, scenarioId: string): boolean {
|
|
99
|
+
return findScenarioById(report, scenarioId) !== undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Does a single reference identify this scenario? id, tag, ticket id, or exact title. */
|
|
103
|
+
function scenarioMatchesRef(scenario: ScenarioLike, ref: string): boolean {
|
|
104
|
+
if (scenario.id === ref) return true;
|
|
105
|
+
if (scenario.title === ref) return true;
|
|
106
|
+
if ((scenario.tags ?? []).includes(ref)) return true;
|
|
107
|
+
if ((scenario.tickets ?? []).some((ticket) => ticket.id === ref)) return true;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toRefArray(refs: string | string[]): string[] {
|
|
112
|
+
const list = Array.isArray(refs) ? refs : [refs];
|
|
113
|
+
// Trim, drop empties, de-duplicate while preserving order.
|
|
114
|
+
const seen = new Set<string>();
|
|
115
|
+
const out: string[] = [];
|
|
116
|
+
for (const raw of list) {
|
|
117
|
+
const ref = raw.trim();
|
|
118
|
+
if (ref && !seen.has(ref)) {
|
|
119
|
+
seen.add(ref);
|
|
120
|
+
out.push(ref);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve `verifiedBy` references against a story report.
|
|
128
|
+
*
|
|
129
|
+
* Status semantics (deliberately strict — a doc is only "verified" when the
|
|
130
|
+
* evidence actually passed):
|
|
131
|
+
* - any matched scenario failed → "failing"
|
|
132
|
+
* - every matched scenario passed → "verified"
|
|
133
|
+
* - matched but some skipped/pending → "not-run"
|
|
134
|
+
* - nothing matched → "unverified"
|
|
135
|
+
*/
|
|
136
|
+
export function resolveVerification(
|
|
137
|
+
refs: string | string[],
|
|
138
|
+
report: StoryReportLike,
|
|
139
|
+
): VerificationResult {
|
|
140
|
+
const refList = toRefArray(refs);
|
|
141
|
+
const scenarios = flattenReport(report);
|
|
142
|
+
|
|
143
|
+
const refResolutions: RefResolution[] = refList.map((ref) => ({
|
|
144
|
+
ref,
|
|
145
|
+
matched: scenarios.filter((scenario) => scenarioMatchesRef(scenario, ref)),
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
// De-duplicate matched scenarios across references (a tag + id can overlap).
|
|
149
|
+
const matchedById = new Map<string, ScenarioLike>();
|
|
150
|
+
for (const resolution of refResolutions) {
|
|
151
|
+
for (const scenario of resolution.matched) {
|
|
152
|
+
matchedById.set(scenario.id, scenario);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const matched = [...matchedById.values()];
|
|
156
|
+
|
|
157
|
+
const passed = matched.filter((s) => s.status === "passed").length;
|
|
158
|
+
const failed = matched.filter((s) => s.status === "failed").length;
|
|
159
|
+
const notRun = matched.filter(
|
|
160
|
+
(s) => s.status === "skipped" || s.status === "pending",
|
|
161
|
+
).length;
|
|
162
|
+
|
|
163
|
+
let status: VerificationStatus;
|
|
164
|
+
if (matched.length === 0) {
|
|
165
|
+
status = "unverified";
|
|
166
|
+
} else if (failed > 0) {
|
|
167
|
+
status = "failing";
|
|
168
|
+
} else if (notRun > 0) {
|
|
169
|
+
status = "not-run";
|
|
170
|
+
} else {
|
|
171
|
+
status = "verified";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
status,
|
|
176
|
+
lastVerifiedMs: report.finishedAtMs && report.finishedAtMs > 0 ? report.finishedAtMs : undefined,
|
|
177
|
+
runId: report.runId && report.runId.length > 0 ? report.runId : undefined,
|
|
178
|
+
total: matched.length,
|
|
179
|
+
passed,
|
|
180
|
+
failed,
|
|
181
|
+
notRun,
|
|
182
|
+
refs: refResolutions,
|
|
183
|
+
missingRefs: refResolutions.filter((r) => r.matched.length === 0).map((r) => r.ref),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface StatusPresentation {
|
|
188
|
+
label: string;
|
|
189
|
+
icon: string;
|
|
190
|
+
/** Short human sentence describing the verdict. */
|
|
191
|
+
summary: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whole days since the verification run completed, if known. */
|
|
195
|
+
export function verificationAgeDays(
|
|
196
|
+
result: VerificationResult,
|
|
197
|
+
nowMs = Date.now(),
|
|
198
|
+
): number | undefined {
|
|
199
|
+
if (!result.lastVerifiedMs) return undefined;
|
|
200
|
+
return Math.floor(Math.max(0, nowMs - result.lastVerifiedMs) / 86_400_000);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Simple freshness heuristic for portal UI warnings. */
|
|
204
|
+
export function isVerificationStale(
|
|
205
|
+
result: VerificationResult,
|
|
206
|
+
staleAfterDays: number,
|
|
207
|
+
nowMs = Date.now(),
|
|
208
|
+
): boolean {
|
|
209
|
+
const ageDays = verificationAgeDays(result, nowMs);
|
|
210
|
+
return ageDays !== undefined && ageDays >= staleAfterDays;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Presentation metadata for each status — kept here so badge + tests agree. */
|
|
214
|
+
export function presentStatus(result: VerificationResult): StatusPresentation {
|
|
215
|
+
switch (result.status) {
|
|
216
|
+
case "verified":
|
|
217
|
+
return {
|
|
218
|
+
label: "Verified",
|
|
219
|
+
icon: "✓",
|
|
220
|
+
summary:
|
|
221
|
+
result.total === 1
|
|
222
|
+
? "Verified by a passing story"
|
|
223
|
+
: `Verified by ${result.total} passing stories`,
|
|
224
|
+
};
|
|
225
|
+
case "failing":
|
|
226
|
+
return {
|
|
227
|
+
label: "Failing",
|
|
228
|
+
icon: "✕",
|
|
229
|
+
summary: `${result.failed} of ${result.total} verifying stories are failing`,
|
|
230
|
+
};
|
|
231
|
+
case "not-run":
|
|
232
|
+
return {
|
|
233
|
+
label: "Not run",
|
|
234
|
+
icon: "⏳",
|
|
235
|
+
summary: "Verifying stories exist but were not exercised in the last run",
|
|
236
|
+
};
|
|
237
|
+
case "unverified":
|
|
238
|
+
return {
|
|
239
|
+
label: "Unverified",
|
|
240
|
+
icon: "⚠",
|
|
241
|
+
summary:
|
|
242
|
+
result.missingRefs.length > 0
|
|
243
|
+
? `No story matches: ${result.missingRefs.join(", ")}`
|
|
244
|
+
: "No verifying story is linked",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|