@anselmdk/feature-spec-md 0.8.2 → 0.9.0-rc.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 +48 -1
- package/SPEC_FORMAT.md +89 -11
- package/dist/cli.js +16 -8
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +97 -0
- package/dist/config.js.map +1 -0
- package/dist/featureSpecs.d.ts +2 -0
- package/dist/featureSpecs.d.ts.map +1 -1
- package/dist/featureSpecs.js +86 -1
- package/dist/featureSpecs.js.map +1 -1
- package/dist/githubActionDiffReport.d.ts +5 -0
- package/dist/githubActionDiffReport.d.ts.map +1 -1
- package/dist/githubActionDiffReport.js +154 -46
- package/dist/githubActionDiffReport.js.map +1 -1
- package/dist/githubActionFtp.d.ts.map +1 -1
- package/dist/githubActionFtp.js +14 -2
- package/dist/githubActionFtp.js.map +1 -1
- package/dist/githubActionOutput.d.ts.map +1 -1
- package/dist/githubActionOutput.js.map +1 -1
- package/dist/githubReportMetadata.js +14 -5
- package/dist/githubReportMetadata.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mockReports.d.ts.map +1 -1
- package/dist/mockReports.js +41 -8
- package/dist/mockReports.js.map +1 -1
- package/dist/mocks/current/tests/supportDesk.spec.d.ts.map +1 -1
- package/dist/mocks/current/tests/supportDesk.spec.js +15 -3
- package/dist/mocks/current/tests/supportDesk.spec.js.map +1 -1
- package/dist/mocks/previous/tests/supportDesk.spec.d.ts.map +1 -1
- package/dist/mocks/previous/tests/supportDesk.spec.js +14 -2
- package/dist/mocks/previous/tests/supportDesk.spec.js.map +1 -1
- package/dist/playwright.d.ts.map +1 -1
- package/dist/playwright.js +3 -1
- package/dist/playwright.js.map +1 -1
- package/dist/reportArtifacts.d.ts.map +1 -1
- package/dist/reportArtifacts.js.map +1 -1
- package/dist/reportHtml.d.ts.map +1 -1
- package/dist/reportHtml.js +75 -2
- package/dist/reportHtml.js.map +1 -1
- package/dist/reportTemplate.d.ts +4 -1
- package/dist/reportTemplate.d.ts.map +1 -1
- package/dist/reportTemplate.js +290 -53
- package/dist/reportTemplate.js.map +1 -1
- package/dist/reportTemplates/shared.d.ts.map +1 -1
- package/dist/reportTemplates/shared.js.map +1 -1
- package/dist/screenshots.d.ts.map +1 -1
- package/dist/screenshots.js +3 -1
- package/dist/screenshots.js.map +1 -1
- package/dist/specDocuments.d.ts +3 -1
- package/dist/specDocuments.d.ts.map +1 -1
- package/dist/specDocuments.js +12 -1
- package/dist/specDocuments.js.map +1 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/evidence-policy.md +1 -0
- package/docs/report-development.md +9 -9
- package/docs/spec-driven-flow.md +1 -0
- package/package.json +1 -1
- package/src/mocks/current/specs/support-desk.design.md +1 -0
- package/src/mocks/current/specs/support-desk.model.md +1 -0
- package/src/mocks/current/specs/support-platform.stack.md +1 -0
- package/src/mocks/current/specs/ticket-inbox.feature.md +1 -0
- package/src/mocks/current/specs/ticket-reply.feature.md +1 -0
- package/src/mocks/current/tests/supportDesk.spec.ts +19 -3
- package/src/mocks/previous/specs/support-desk.model.md +1 -0
- package/src/mocks/previous/specs/ticket-inbox.feature.md +1 -0
- package/src/mocks/previous/specs/ticket-reply.feature.md +1 -0
- package/src/mocks/previous/tests/supportDesk.spec.ts +14 -2
package/dist/reportTemplate.js
CHANGED
|
@@ -26,10 +26,19 @@ export function renderHtmlReport(specs, options = {}) {
|
|
|
26
26
|
}
|
|
27
27
|
function featureReportBody({ specs, options, evidence, sourceLinks, title, }) {
|
|
28
28
|
const documents = allReportDocuments(specs, options);
|
|
29
|
+
if (options.layers?.length) {
|
|
30
|
+
return `
|
|
31
|
+
<h1>${renderReportTitle(title, options.repositoryUrl)}</h1>
|
|
32
|
+
<p>Generated ${html(formatGeneratedAt(options.generatedAt))}.</p>
|
|
33
|
+
${renderIssues(options.validationIssues ?? [])}
|
|
34
|
+
${renderLayeredDocuments(documents, options, evidence, sourceLinks)}
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
29
37
|
return `
|
|
30
38
|
<h1>${renderReportTitle(title, options.repositoryUrl)}</h1>
|
|
31
39
|
<p>Generated ${html(formatGeneratedAt(options.generatedAt))}.</p>
|
|
32
40
|
${renderOpenQuestionsAndAssumptions(documents, sourceLinks)}
|
|
41
|
+
${renderJourneyOverview(specs, options.coverage, sourceLinks)}
|
|
33
42
|
${renderIssues(options.validationIssues ?? [])}
|
|
34
43
|
${renderModels(options.models ?? [], options.coverage, sourceLinks)}
|
|
35
44
|
${renderStacks(options.stacks ?? [], options.coverage, sourceLinks)}
|
|
@@ -37,52 +46,76 @@ ${renderDesigns(options.designs ?? [], options.coverage, sourceLinks)}
|
|
|
37
46
|
${specs.map((spec) => renderSpec(spec, options.coverage, evidence, sourceLinks)).join("\n")}
|
|
38
47
|
`;
|
|
39
48
|
}
|
|
49
|
+
function renderJourneyOverview(specs, coverage, sourceLinks) {
|
|
50
|
+
const journeys = specs.flatMap((spec) => spec.scenarios
|
|
51
|
+
.filter((scenario) => scenario.journey)
|
|
52
|
+
.map((scenario) => ({ spec, scenario })));
|
|
53
|
+
if (!journeys.length)
|
|
54
|
+
return "";
|
|
55
|
+
return `<details class="panel report-section" open>
|
|
56
|
+
<summary class="report-section-summary"><h2>End-to-end journeys</h2><span class="badge">${journeys.length}</span></summary>
|
|
57
|
+
<div class="report-section-body"><ul>${journeys
|
|
58
|
+
.map(({ scenario }) => {
|
|
59
|
+
const item = coverage?.scenarioCoverage.find((candidate) => candidate.id === scenario.id);
|
|
60
|
+
const metadata = scenario.journey;
|
|
61
|
+
const systems = metadata.systems.length
|
|
62
|
+
? ` <span class="muted">${html(metadata.systems.join(" → "))}</span>`
|
|
63
|
+
: "";
|
|
64
|
+
return `<li><a href="#${html(scenario.id.toLowerCase())}"><code>${html(scenario.id)}</code>: ${html(scenario.title)}</a> <span class="badge">${html(metadata.path)}</span>${metadata.critical ? ' <span class="badge">critical</span>' : ""}${systems} ${coverageBadge(item?.covered, [], item, sourceLinks)}</li>`;
|
|
65
|
+
})
|
|
66
|
+
.join("")}</ul></div>
|
|
67
|
+
</details>`;
|
|
68
|
+
}
|
|
40
69
|
function featureReportStyles() {
|
|
41
|
-
return `.panel{border:1px solid
|
|
70
|
+
return `.panel{border:1px solid var(--border);border-radius:8px;margin:18px 0;overflow:hidden;background:var(--surface)}
|
|
71
|
+
.report-layers{margin-top:18px}.report-layers>.details-section-header{margin-bottom:8px}.layer-section{border:1px solid var(--border);border-radius:10px;margin:12px 0;background:var(--surface);overflow:hidden}
|
|
72
|
+
.layer-summary{cursor:pointer;display:flex;gap:12px;align-items:center;padding:18px 20px;list-style:none}.layer-summary::-webkit-details-marker{display:none}.layer-summary::before{content:"▶";color:var(--muted);font-size:12px;transition:transform .15s ease}.layer-section[open]>.layer-summary::before{transform:rotate(90deg)}
|
|
73
|
+
.layer-summary-copy{flex:1}.layer-summary h2{font-size:1.35em;margin:0}.layer-summary p{color:var(--muted);margin:3px 0 0}.layer-badges{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.layer-body{padding:0 18px 18px}.layer-body>.details-section-header{margin:0 0 10px}.document-section{margin:12px 0}.document-kind{color:var(--muted)}
|
|
42
74
|
.report-section-summary{cursor:pointer;display:flex;gap:12px;align-items:center;padding:20px;list-style:none}.report-section-summary::-webkit-details-marker{display:none}
|
|
43
|
-
.report-section-summary::before{content:"▶";color
|
|
75
|
+
.report-section-summary::before{content:"▶";color:var(--muted);font-size:12px;transition:transform .15s ease}.report-section[open]>.report-section-summary::before{transform:rotate(90deg)}
|
|
44
76
|
.report-section-summary h2{font-size:1.5em;margin:0;flex:1}.report-section-body{padding:0 20px 20px}
|
|
45
|
-
.ok{color
|
|
46
|
-
.badge{border:1px solid
|
|
77
|
+
.ok{color:var(--success)}.missing,.error{color:var(--danger)}.warning{color:var(--warning)}.muted{color:var(--muted)}
|
|
78
|
+
.badge{border:1px solid var(--border);border-radius:999px;padding:2px 8px;font-size:12px;white-space:nowrap}
|
|
47
79
|
.feature-header{display:flex;gap:12px;align-items:center;justify-content:space-between}
|
|
48
80
|
.details-section-header{display:flex;gap:12px;align-items:center;justify-content:space-between}
|
|
49
81
|
.details-section-header h2,.details-section-header h3{margin-bottom:0}
|
|
50
|
-
.details-toggle-button{appearance:none;border:1px solid
|
|
51
|
-
.details-toggle-button:hover{background
|
|
82
|
+
.details-toggle-button{appearance:none;border:1px solid var(--border);border-radius:6px;background:var(--surface-muted);color:var(--fg);cursor:pointer;font:inherit;font-size:13px;font-weight:600;padding:5px 12px;white-space:nowrap}
|
|
83
|
+
.details-toggle-button:hover{background:var(--surface-hover)}.details-toggle-button:focus-visible{outline:2px solid var(--link);outline-offset:2px}
|
|
52
84
|
.feature-policy{display:flex;gap:8px;flex-wrap:wrap;margin:-4px 0 12px}.feature-policy .badge{display:inline-flex;gap:5px;align-items:center}
|
|
53
|
-
.scenario{border:1px solid
|
|
85
|
+
.scenario{border:1px solid var(--border);border-radius:8px;margin:12px 0;background:var(--surface)}
|
|
54
86
|
.scenario summary{cursor:pointer;padding:14px 16px;font-weight:600}
|
|
55
87
|
.scenario-body{padding:0 16px 16px}
|
|
56
88
|
.scenario-body.compact-steps .step{margin:4px 0}.scenario-body.compact-steps .step p{margin:2px 0}
|
|
57
|
-
.model-item{border:1px solid
|
|
89
|
+
.model-item{border:1px solid var(--border);border-radius:8px;margin:12px 0;background:var(--surface)}
|
|
58
90
|
.model-item summary{cursor:pointer;padding:14px 16px;font-weight:600}
|
|
59
91
|
.model-item-body{padding:0 16px 16px}.model-item-body p{margin:8px 0}
|
|
60
92
|
.model-entry{padding:0 0 12px}.model-entry h4{font-size:14px;margin:14px 0 8px}
|
|
61
93
|
.table-wrap{overflow-x:auto;margin:12px 0}table{border-collapse:collapse;width:100%;font-size:14px}
|
|
62
|
-
th,td{border:1px solid
|
|
63
|
-
h1 a{color
|
|
64
|
-
.step{border-left:3px solid
|
|
94
|
+
th,td{border:1px solid var(--border);padding:6px 8px;text-align:left;vertical-align:top}th{background:var(--surface-muted)}
|
|
95
|
+
h1 a{color:var(--link);text-decoration:underline;text-underline-offset:3px}h1 a:hover{text-decoration-thickness:2px}
|
|
96
|
+
.step{border-left:3px solid var(--border);margin:12px 0;padding:2px 0 2px 12px}.step p{margin:8px 0}
|
|
65
97
|
.screenshots{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px;margin:10px 0 14px}
|
|
66
|
-
.screenshot{border:1px solid
|
|
67
|
-
.screenshot img{display:block;width:100%;height:auto}.screenshot figcaption{font-size:12px;padding:8px;color
|
|
98
|
+
.screenshot{border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--surface-muted)}
|
|
99
|
+
.screenshot img{display:block;width:100%;height:auto}.screenshot figcaption{font-size:12px;padding:8px;color:var(--muted)}
|
|
68
100
|
.coverage-refs{display:inline-flex;gap:2px;margin-left:4px}.coverage-ref{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
|
69
101
|
.line-link{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
|
70
102
|
.flag-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}
|
|
71
|
-
.flag-card{border:1px solid
|
|
103
|
+
.flag-card{border:1px solid var(--border);border-left:4px solid var(--border);border-radius:8px;padding:14px;background:var(--surface)}
|
|
72
104
|
.flag-card h3{font-size:16px;margin:0 0 8px}.flag-card p{margin:8px 0}
|
|
73
|
-
.flag-item-link{color:inherit;text-decoration:underline;text-decoration-color
|
|
74
|
-
.flag-card.openQuestions{border-left-color
|
|
75
|
-
.extension-section{border:1px solid
|
|
105
|
+
.flag-item-link{color:inherit;text-decoration:underline;text-decoration-color:var(--muted);text-underline-offset:2px}
|
|
106
|
+
.flag-card.openQuestions{border-left-color:var(--warning)}.flag-card.assumptions{border-left-color:var(--muted)}
|
|
107
|
+
.extension-section{border:1px solid var(--border);border-radius:8px;padding:14px;margin:12px 0;background:var(--surface)}
|
|
76
108
|
.extension-section h4{margin:0 0 8px}.extension-section p{margin:8px 0}
|
|
77
|
-
.mermaid-wrap{overflow-x:auto;margin:12px 0;padding:12px;border:1px solid
|
|
109
|
+
.mermaid-wrap{overflow-x:auto;margin:12px 0;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--surface)}
|
|
78
110
|
.mermaid{min-width:max-content;text-align:center}.mermaid svg{display:block;max-width:none;height:auto;margin:0 auto}
|
|
79
|
-
.mermaid-error{color
|
|
80
|
-
@media(max-width:720px){.flag-grid{grid-template-columns:1fr}}`;
|
|
111
|
+
.mermaid-error{color:var(--danger);text-align:left;white-space:pre-wrap}
|
|
112
|
+
@media(max-width:720px){.flag-grid{grid-template-columns:1fr}.layer-summary{align-items:flex-start;flex-wrap:wrap}.layer-badges{justify-content:flex-start;width:100%}}`;
|
|
81
113
|
}
|
|
82
114
|
function featureReportScripts() {
|
|
83
115
|
const openTag = "<" + "script>";
|
|
84
116
|
const closeTag = "<" + "/script>";
|
|
85
|
-
const mermaidOpenTag = "<" +
|
|
117
|
+
const mermaidOpenTag = "<" +
|
|
118
|
+
'script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" crossorigin="anonymous">';
|
|
86
119
|
return `${openTag}
|
|
87
120
|
const bulkToggledDetails = new WeakSet();
|
|
88
121
|
|
|
@@ -133,10 +166,24 @@ document.addEventListener("toggle", (event) => {
|
|
|
133
166
|
window.scrollBy(0, topAfter - topBefore);
|
|
134
167
|
});
|
|
135
168
|
}, true);
|
|
169
|
+
|
|
170
|
+
function revealHashTarget() {
|
|
171
|
+
const id = decodeURIComponent(window.location.hash.slice(1));
|
|
172
|
+
if (!id) return;
|
|
173
|
+
const target = document.getElementById(id);
|
|
174
|
+
if (!target) return;
|
|
175
|
+
let ancestor = target instanceof HTMLDetailsElement ? target : target.closest("details");
|
|
176
|
+
while (ancestor) {
|
|
177
|
+
ancestor.open = true;
|
|
178
|
+
ancestor = ancestor.parentElement?.closest("details");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
window.addEventListener("hashchange", revealHashTarget);
|
|
182
|
+
document.addEventListener("DOMContentLoaded", revealHashTarget);
|
|
136
183
|
${closeTag}
|
|
137
184
|
${mermaidOpenTag}${closeTag}
|
|
138
185
|
${openTag}
|
|
139
|
-
|
|
186
|
+
async function renderMermaidDiagrams() {
|
|
140
187
|
const diagrams = document.querySelectorAll(".mermaid");
|
|
141
188
|
if (!diagrams.length) return;
|
|
142
189
|
if (!window.mermaid) {
|
|
@@ -144,13 +191,20 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|
|
144
191
|
return;
|
|
145
192
|
}
|
|
146
193
|
try {
|
|
147
|
-
|
|
194
|
+
diagrams.forEach((diagram) => {
|
|
195
|
+
diagram.dataset.mermaidSource ||= diagram.textContent || "";
|
|
196
|
+
diagram.textContent = diagram.dataset.mermaidSource;
|
|
197
|
+
diagram.removeAttribute("data-processed");
|
|
198
|
+
});
|
|
199
|
+
window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: document.documentElement.dataset.theme === "dark" ? "dark" : "default" });
|
|
148
200
|
await window.mermaid.run({ nodes: diagrams });
|
|
149
201
|
} catch (error) {
|
|
150
202
|
diagrams.forEach((diagram) => diagram.classList.add("mermaid-error"));
|
|
151
203
|
console.error("Unable to render Mermaid diagram", error);
|
|
152
204
|
}
|
|
153
|
-
}
|
|
205
|
+
}
|
|
206
|
+
document.addEventListener("DOMContentLoaded", renderMermaidDiagrams);
|
|
207
|
+
document.addEventListener("feature-spec-theme-change", renderMermaidDiagrams);
|
|
154
208
|
${closeTag}`;
|
|
155
209
|
}
|
|
156
210
|
function renderReportTitle(title, repositoryUrl) {
|
|
@@ -168,7 +222,20 @@ function formatGeneratedAt(value) {
|
|
|
168
222
|
if (Number.isNaN(date.getTime()))
|
|
169
223
|
return value ?? "";
|
|
170
224
|
const day = date.getDate();
|
|
171
|
-
const month = [
|
|
225
|
+
const month = [
|
|
226
|
+
"January",
|
|
227
|
+
"February",
|
|
228
|
+
"March",
|
|
229
|
+
"April",
|
|
230
|
+
"May",
|
|
231
|
+
"June",
|
|
232
|
+
"July",
|
|
233
|
+
"August",
|
|
234
|
+
"September",
|
|
235
|
+
"October",
|
|
236
|
+
"November",
|
|
237
|
+
"December",
|
|
238
|
+
][date.getMonth()];
|
|
172
239
|
const hours = String(date.getHours()).padStart(2, "0");
|
|
173
240
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
174
241
|
return `${day}${ordinalSuffix(day)} ${month} ${date.getFullYear()} at ${hours}:${minutes}`;
|
|
@@ -184,8 +251,117 @@ function ordinalSuffix(day) {
|
|
|
184
251
|
return "rd";
|
|
185
252
|
return "th";
|
|
186
253
|
}
|
|
254
|
+
function renderLayeredDocuments(documents, options, evidence, sourceLinks) {
|
|
255
|
+
const configured = options.layers ?? [];
|
|
256
|
+
const configuredIds = new Set(configured.map((layer) => layer.id));
|
|
257
|
+
const layers = configured.map((layer) => ({
|
|
258
|
+
...layer,
|
|
259
|
+
documents: documents.filter((document) => document.frontmatter.layer === layer.id),
|
|
260
|
+
}));
|
|
261
|
+
const otherDocuments = documents.filter((document) => !document.frontmatter.layer ||
|
|
262
|
+
!configuredIds.has(document.frontmatter.layer));
|
|
263
|
+
if (otherDocuments.length) {
|
|
264
|
+
layers.push({
|
|
265
|
+
id: "other",
|
|
266
|
+
title: "Other",
|
|
267
|
+
description: "Documents without a configured report layer",
|
|
268
|
+
documents: otherDocuments,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return `<section class="report-layers" data-details-section>
|
|
272
|
+
<div class="details-section-header"><h2>Specification layers</h2>${renderDetailsToggleButton("details.layer-section", "layers")}</div>
|
|
273
|
+
${layers
|
|
274
|
+
.map((layer) => renderLayer(layer, options, evidence, sourceLinks, options.layersDefaultOpen ?? false, options.documentsDefaultOpen ?? false))
|
|
275
|
+
.join("\n")}
|
|
276
|
+
</section>`;
|
|
277
|
+
}
|
|
278
|
+
function renderLayer(layer, options, evidence, sourceLinks, open, documentsOpen) {
|
|
279
|
+
const metrics = layerMetrics(layer.documents, options);
|
|
280
|
+
const description = layer.description
|
|
281
|
+
? `<p>${html(layer.description)}</p>`
|
|
282
|
+
: "";
|
|
283
|
+
return `<details id="layer-${html(layer.id)}" class="layer-section" data-details-section${openAttribute(open)}>
|
|
284
|
+
<summary class="layer-summary">
|
|
285
|
+
<div class="layer-summary-copy"><h2>${html(layer.title)}</h2>${description}</div>
|
|
286
|
+
<span class="layer-badges"><span class="badge">${layer.documents.length} document${layer.documents.length === 1 ? "" : "s"}</span>${metrics.coverageTotal ? `<span class="badge ${metrics.coverageMissing ? "warning" : "ok"}">${metrics.coverageTotal - metrics.coverageMissing}/${metrics.coverageTotal} covered</span>` : ""}${metrics.issues ? `<span class="badge ${metrics.errors ? "error" : "warning"}">${metrics.issues} issue${metrics.issues === 1 ? "" : "s"}</span>` : ""}${metrics.flags ? `<span class="badge muted">${metrics.flags} open item${metrics.flags === 1 ? "" : "s"}</span>` : ""}</span>
|
|
287
|
+
</summary>
|
|
288
|
+
<div class="layer-body">
|
|
289
|
+
<div class="details-section-header">${renderDetailsToggleButton(".layer-body > details.document-section", "documents")}</div>
|
|
290
|
+
${layer.documents.map((document) => renderLayerDocument(document, options, evidence, sourceLinks, documentsOpen)).join("\n")}
|
|
291
|
+
</div>
|
|
292
|
+
</details>`;
|
|
293
|
+
}
|
|
294
|
+
function renderLayerDocument(document, options, evidence, sourceLinks, open) {
|
|
295
|
+
if (document.kind === "model")
|
|
296
|
+
return renderModelDocument(document, options.coverage, sourceLinks, open);
|
|
297
|
+
if (document.kind === "stack")
|
|
298
|
+
return renderContextDocument(document, "Stack", [
|
|
299
|
+
["Stack", document.stack],
|
|
300
|
+
["Context", document.context],
|
|
301
|
+
["Rationale", document.rationale],
|
|
302
|
+
["Consequences", document.consequences],
|
|
303
|
+
], options.coverage, sourceLinks, open, true);
|
|
304
|
+
if (document.kind === "design")
|
|
305
|
+
return renderContextDocument(document, "Design", [
|
|
306
|
+
["Design", document.design],
|
|
307
|
+
["Principles", document.principles],
|
|
308
|
+
["Layout", document.layout],
|
|
309
|
+
["Interaction", document.interaction],
|
|
310
|
+
["Visual style", document.visualStyle],
|
|
311
|
+
], options.coverage, sourceLinks, open, true);
|
|
312
|
+
return renderSpec(document, options.coverage, evidence, sourceLinks, open, true);
|
|
313
|
+
}
|
|
314
|
+
function renderModelDocument(model, coverage, sourceLinks, open) {
|
|
315
|
+
const ruleCoverage = coverage?.ruleCoverage ?? [];
|
|
316
|
+
const scenarioCoverage = coverage?.scenarioCoverage ?? [];
|
|
317
|
+
return `<details id="${html(model.frontmatter.id.toLowerCase())}" class="panel report-section document-section" data-details-section${openAttribute(open)}>
|
|
318
|
+
<summary class="report-section-summary"><h2>${html(model.title)}</h2><span class="badge document-kind">Model</span><span class="badge">${html(model.frontmatter.status ?? "draft")}</span></summary>
|
|
319
|
+
<div class="report-section-body">
|
|
320
|
+
<div class="details-section-header">${renderDetailsToggleButton("details.model-item", "model items")}</div>
|
|
321
|
+
${renderModel(model, coverage?.modelCoverage ?? [], ruleCoverage, buildRuleScenarioLinks(ruleCoverage, scenarioCoverage), sourceLinks)}
|
|
322
|
+
</div>
|
|
323
|
+
</details>`;
|
|
324
|
+
}
|
|
325
|
+
function layerMetrics(documents, options) {
|
|
326
|
+
const filePaths = new Set(documents.map((document) => document.filePath));
|
|
327
|
+
const ids = new Set(documents.flatMap((document) => [
|
|
328
|
+
...(document.kind === "model"
|
|
329
|
+
? document.modelItems.map((item) => item.id)
|
|
330
|
+
: []),
|
|
331
|
+
...document.rules.map((rule) => rule.id),
|
|
332
|
+
...(document.kind === "feature" || document.kind === undefined
|
|
333
|
+
? document.scenarios.map((scenario) => scenario.id)
|
|
334
|
+
: []),
|
|
335
|
+
]));
|
|
336
|
+
const coverageItems = [
|
|
337
|
+
...(options.coverage?.modelCoverage ?? []),
|
|
338
|
+
...(options.coverage?.ruleCoverage ?? []),
|
|
339
|
+
...(options.coverage?.scenarioCoverage ?? []),
|
|
340
|
+
].filter((item) => ids.has(item.id));
|
|
341
|
+
const issues = (options.validationIssues ?? []).filter((issue) => !issue.filePath || filePaths.has(issue.filePath));
|
|
342
|
+
const flags = documents.reduce((sum, document) => sum +
|
|
343
|
+
(document.source.match(/^## (?:Open Questions|Assumptions)$/gim)
|
|
344
|
+
?.length ?? 0), 0);
|
|
345
|
+
return {
|
|
346
|
+
coverageTotal: coverageItems.length,
|
|
347
|
+
coverageMissing: coverageItems.filter((item) => !item.covered).length,
|
|
348
|
+
issues: issues.length,
|
|
349
|
+
errors: issues.filter((issue) => issue.severity === "error").length,
|
|
350
|
+
flags,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function openAttribute(open) {
|
|
354
|
+
return open ? " open" : "";
|
|
355
|
+
}
|
|
356
|
+
function documentDetailsAttributes(id, open, nested) {
|
|
357
|
+
return nested
|
|
358
|
+
? ` id="${html(id.toLowerCase())}" class="panel report-section document-section"${openAttribute(open)}`
|
|
359
|
+
: ` class="panel report-section"${openAttribute(open)}`;
|
|
360
|
+
}
|
|
187
361
|
function renderOpenQuestionsAndAssumptions(documents, sourceLinks) {
|
|
188
|
-
const sections = documents
|
|
362
|
+
const sections = documents
|
|
363
|
+
.flatMap((document) => extensionSections(document))
|
|
364
|
+
.filter((section) => section.kind === "openQuestions" || section.kind === "assumptions");
|
|
189
365
|
if (!sections.length)
|
|
190
366
|
return "";
|
|
191
367
|
const openQuestionCount = sections.filter((section) => section.kind === "openQuestions").length;
|
|
@@ -232,7 +408,8 @@ function flaggedSectionItems(section) {
|
|
|
232
408
|
}
|
|
233
409
|
function flaggedItemAnchor(section, item) {
|
|
234
410
|
const stableId = item.text.match(/^([A-Z][A-Z0-9-]*-(?:Q|A)\d{3}):/)?.[1];
|
|
235
|
-
return (stableId ??
|
|
411
|
+
return (stableId ??
|
|
412
|
+
`${section.document.frontmatter.id}-${section.kind}-${item.line}`).toLowerCase();
|
|
236
413
|
}
|
|
237
414
|
function renderIssues(issues) {
|
|
238
415
|
if (!issues.length)
|
|
@@ -290,26 +467,30 @@ function renderModelRules(model, ruleCoverage, ruleScenarioLinks, sourceLinks) {
|
|
|
290
467
|
.join("")}</ul>`;
|
|
291
468
|
}
|
|
292
469
|
function renderStacks(stacks, coverage, sourceLinks = {}) {
|
|
293
|
-
return stacks
|
|
470
|
+
return stacks
|
|
471
|
+
.map((stack) => renderContextDocument(stack, "Stack", [
|
|
294
472
|
["Stack", stack.stack],
|
|
295
473
|
["Context", stack.context],
|
|
296
474
|
["Rationale", stack.rationale],
|
|
297
475
|
["Consequences", stack.consequences],
|
|
298
|
-
], coverage, sourceLinks))
|
|
476
|
+
], coverage, sourceLinks))
|
|
477
|
+
.join("\n");
|
|
299
478
|
}
|
|
300
479
|
function renderDesigns(designs, coverage, sourceLinks = {}) {
|
|
301
|
-
return designs
|
|
480
|
+
return designs
|
|
481
|
+
.map((design) => renderContextDocument(design, "Design", [
|
|
302
482
|
["Design", design.design],
|
|
303
483
|
["Principles", design.principles],
|
|
304
484
|
["Layout", design.layout],
|
|
305
485
|
["Interaction", design.interaction],
|
|
306
486
|
["Visual style", design.visualStyle],
|
|
307
|
-
], coverage, sourceLinks))
|
|
487
|
+
], coverage, sourceLinks))
|
|
488
|
+
.join("\n");
|
|
308
489
|
}
|
|
309
|
-
function renderContextDocument(document, kindLabel, sections, coverage, sourceLinks) {
|
|
490
|
+
function renderContextDocument(document, kindLabel, sections, coverage, sourceLinks, open = true, nested = false) {
|
|
310
491
|
const ruleCoverage = coverage?.ruleCoverage ?? [];
|
|
311
492
|
const ruleScenarioLinks = buildRuleScenarioLinks(ruleCoverage, coverage?.scenarioCoverage ?? []);
|
|
312
|
-
return `<details
|
|
493
|
+
return `<details${documentDetailsAttributes(document.frontmatter.id, open, nested)}>
|
|
313
494
|
<summary class="report-section-summary">
|
|
314
495
|
<h2>${html(document.title)}</h2>
|
|
315
496
|
<span class="badge">${html(kindLabel)}</span>
|
|
@@ -317,7 +498,10 @@ function renderContextDocument(document, kindLabel, sections, coverage, sourceLi
|
|
|
317
498
|
</summary>
|
|
318
499
|
<div class="report-section-body">
|
|
319
500
|
<p>${html(document.purpose)}</p>
|
|
320
|
-
${sections
|
|
501
|
+
${sections
|
|
502
|
+
.filter(([, body]) => body)
|
|
503
|
+
.map(([title, body]) => `<section><h3>${html(title)}</h3>${renderMarkdownBlock(body, 1)}</section>`)
|
|
504
|
+
.join("")}
|
|
321
505
|
${renderContextRules(document.rules, ruleCoverage, ruleScenarioLinks, sourceLinks)}
|
|
322
506
|
${renderDocumentExtensionSections(document, sourceLinks)}
|
|
323
507
|
</div>
|
|
@@ -326,17 +510,19 @@ function renderContextDocument(document, kindLabel, sections, coverage, sourceLi
|
|
|
326
510
|
function renderContextRules(rules, ruleCoverage, ruleScenarioLinks, sourceLinks) {
|
|
327
511
|
if (!rules.length)
|
|
328
512
|
return "";
|
|
329
|
-
return `<h3>Rules</h3><ul>${rules
|
|
513
|
+
return `<h3>Rules</h3><ul>${rules
|
|
514
|
+
.map((rule) => {
|
|
330
515
|
const item = ruleCoverage.find((coverageItem) => coverageItem.id === rule.id);
|
|
331
516
|
return `<li><code>${html(rule.id)}</code>: ${html(rule.text)} ${ruleCoverageBadge(item, ruleScenarioIds(rule.id, ruleScenarioLinks), sourceLinks)}</li>`;
|
|
332
|
-
})
|
|
517
|
+
})
|
|
518
|
+
.join("")}</ul>`;
|
|
333
519
|
}
|
|
334
|
-
function renderSpec(spec, coverage, evidence = [], sourceLinks = {}) {
|
|
520
|
+
function renderSpec(spec, coverage, evidence = [], sourceLinks = {}, open = true, nested = false) {
|
|
335
521
|
const evidenceByLine = groupEvidenceByLine(evidence);
|
|
336
522
|
const ruleCoverage = coverage?.ruleCoverage ?? [];
|
|
337
523
|
const scenarioCoverage = coverage?.scenarioCoverage ?? [];
|
|
338
524
|
const ruleScenarioLinks = buildRuleScenarioLinks(ruleCoverage, scenarioCoverage);
|
|
339
|
-
return `<details
|
|
525
|
+
return `<details${documentDetailsAttributes(spec.frontmatter.id, open, nested)}>
|
|
340
526
|
<summary class="report-section-summary">
|
|
341
527
|
<h2>${html(spec.title)}</h2>
|
|
342
528
|
<span class="badge">${html(spec.frontmatter.status ?? "draft")}</span>
|
|
@@ -360,7 +546,16 @@ function renderSpec(spec, coverage, evidence = [], sourceLinks = {}) {
|
|
|
360
546
|
function renderFeaturePolicy(spec) {
|
|
361
547
|
const items = [
|
|
362
548
|
spec.frontmatter.test ? ["test", spec.frontmatter.test] : undefined,
|
|
363
|
-
spec.frontmatter.screenshots
|
|
549
|
+
spec.frontmatter.screenshots
|
|
550
|
+
? ["screenshots", spec.frontmatter.screenshots]
|
|
551
|
+
: undefined,
|
|
552
|
+
spec.frontmatter.journey
|
|
553
|
+
? ["journey", spec.frontmatter.journey]
|
|
554
|
+
: undefined,
|
|
555
|
+
spec.frontmatter.path ? ["path", spec.frontmatter.path] : undefined,
|
|
556
|
+
spec.frontmatter.critical !== undefined
|
|
557
|
+
? ["critical", String(spec.frontmatter.critical)]
|
|
558
|
+
: undefined,
|
|
364
559
|
].filter((item) => Boolean(item));
|
|
365
560
|
if (!items.length)
|
|
366
561
|
return "";
|
|
@@ -377,11 +572,24 @@ function renderScenario(spec, scenario, scenarioCoverage, ruleScenarioLinks, evi
|
|
|
377
572
|
const changedCount = scenarioEvidence.filter((entry) => entry.changed && entry.path).length;
|
|
378
573
|
const scenarioRuleIds = ruleIdsForScenario(scenario.id, spec.rules.map((rule) => rule.id), ruleScenarioLinks);
|
|
379
574
|
const scenarioCoverageItem = scenarioCoverage.find((item) => item.id === scenario.id);
|
|
380
|
-
return `<details class="scenario" data-has-images="${changedCount > 0 ? "true" : "false"}">
|
|
575
|
+
return `<details id="${html(scenario.id.toLowerCase())}" class="scenario" data-has-images="${changedCount > 0 ? "true" : "false"}">
|
|
381
576
|
<summary><code>${html(scenario.id)}</code>: ${html(scenario.title)} ${coverageBadge(scenarioCoverageItem?.covered, [], scenarioCoverageItem, sourceLinks)}</summary>
|
|
382
|
-
<div class="scenario-body${changedCount === 0 ? " compact-steps" : ""}">${renderScenarioRuleCoverage(scenarioRuleIds)}${scenario.steps.map((step) => renderStep(spec, step, evidenceByLine, sourceLinks, scenario.evidence.screenshots)).join("")}</div>
|
|
577
|
+
<div class="scenario-body${changedCount === 0 ? " compact-steps" : ""}">${renderJourneyMetadata(scenario)}${renderScenarioRuleCoverage(scenarioRuleIds)}${scenario.steps.map((step) => renderStep(spec, step, evidenceByLine, sourceLinks, scenario.evidence.screenshots)).join("")}</div>
|
|
383
578
|
</details>`;
|
|
384
579
|
}
|
|
580
|
+
function renderJourneyMetadata(scenario) {
|
|
581
|
+
if (!scenario.journey)
|
|
582
|
+
return "";
|
|
583
|
+
const items = [
|
|
584
|
+
["journey", scenario.journey.scope],
|
|
585
|
+
["path", scenario.journey.path],
|
|
586
|
+
scenario.journey.critical ? ["critical", "true"] : undefined,
|
|
587
|
+
scenario.journey.systems.length
|
|
588
|
+
? ["systems", scenario.journey.systems.join(", ")]
|
|
589
|
+
: undefined,
|
|
590
|
+
].filter((item) => Boolean(item));
|
|
591
|
+
return `<div class="feature-policy">${items.map(([label, value]) => `<span class="badge"><span class="muted">${html(label)}</span> <code>${html(value)}</code></span>`).join("")}</div>`;
|
|
592
|
+
}
|
|
385
593
|
function renderScenarioRuleCoverage(ruleIds) {
|
|
386
594
|
if (!ruleIds.length)
|
|
387
595
|
return `<p><strong>Rules covered by this scenario:</strong> <span class="muted">none referenced</span></p>`;
|
|
@@ -496,20 +704,30 @@ function renderCodeBlock(language, source) {
|
|
|
496
704
|
function renderInlineMarkdown(source) {
|
|
497
705
|
return source
|
|
498
706
|
.split(/(`[^`]+`)/g)
|
|
499
|
-
.map((part) => part.startsWith("`") && part.endsWith("`")
|
|
707
|
+
.map((part) => part.startsWith("`") && part.endsWith("`")
|
|
708
|
+
? `<code>${html(part.slice(1, -1))}</code>`
|
|
709
|
+
: html(part))
|
|
500
710
|
.join("");
|
|
501
711
|
}
|
|
502
712
|
function isTableStart(lines, index) {
|
|
503
|
-
return isPipeTableRow(lines[index]) &&
|
|
713
|
+
return (isPipeTableRow(lines[index]) &&
|
|
714
|
+
index + 1 < lines.length &&
|
|
715
|
+
isTableSeparator(lines[index + 1]));
|
|
504
716
|
}
|
|
505
717
|
function isPipeTableRow(line) {
|
|
506
718
|
return line.trim().startsWith("|") && line.trim().endsWith("|");
|
|
507
719
|
}
|
|
508
720
|
function isTableSeparator(line) {
|
|
509
|
-
return isPipeTableRow(line) &&
|
|
721
|
+
return (isPipeTableRow(line) &&
|
|
722
|
+
splitTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell)));
|
|
510
723
|
}
|
|
511
724
|
function splitTableRow(line) {
|
|
512
|
-
return line
|
|
725
|
+
return line
|
|
726
|
+
.trim()
|
|
727
|
+
.replace(/^\|/, "")
|
|
728
|
+
.replace(/\|$/, "")
|
|
729
|
+
.split("|")
|
|
730
|
+
.map((cell) => cell.trim());
|
|
513
731
|
}
|
|
514
732
|
function renderTable(lines) {
|
|
515
733
|
const headers = splitTableRow(lines[0]);
|
|
@@ -530,7 +748,9 @@ function renderStep(spec, step, evidenceByLine, sourceLinks, screenshotPolicy) {
|
|
|
530
748
|
return `<div class="step"><p><strong>${html(step.keyword)}</strong> ${html(step.text)} ${renderLineBadge(spec.filePath, step.line, sourceLinks)} ${evidenceBadge}</p>${renderScreenshots(screenshots)}</div>`;
|
|
531
749
|
}
|
|
532
750
|
function renderComparedWith(entry) {
|
|
533
|
-
return entry.comparedWithLine
|
|
751
|
+
return entry.comparedWithLine
|
|
752
|
+
? ` as line ${html(String(entry.comparedWithLine))}`
|
|
753
|
+
: " as previous screen";
|
|
534
754
|
}
|
|
535
755
|
function renderLineBadge(filePath, line, sourceLinks) {
|
|
536
756
|
const label = `line ${line}`;
|
|
@@ -542,10 +762,14 @@ function renderLineBadge(filePath, line, sourceLinks) {
|
|
|
542
762
|
function renderScreenshots(screenshots) {
|
|
543
763
|
if (!screenshots.length)
|
|
544
764
|
return "";
|
|
545
|
-
return `<div class="screenshots">${screenshots.map((screenshot) => `<figure class="screenshot"><img src="${html(screenshot.path ?? "")}" alt="${html(screenshot.title ?? `Screenshot for ${screenshot.specPath}:${screenshot.line}`)}"><figcaption>${html(screenshot.title ?? `${screenshot.specPath}:${screenshot.line}`)}</figcaption></figure>`).join("")}</div>`;
|
|
765
|
+
return `<div class="screenshots">${screenshots.map((screenshot) => `<figure class="screenshot"><img src="${html(screenshot.path ?? "")}" alt="${html(screenshot.title ?? `Screenshot for ${screenshot.specPath}:${screenshot.line}`)}" data-lightbox tabindex="0"><figcaption>${html(screenshot.title ?? `${screenshot.specPath}:${screenshot.line}`)}</figcaption></figure>`).join("")}</div>`;
|
|
546
766
|
}
|
|
547
767
|
function coverageBadge(covered, suffixes = [], item, sourceLinks = {}) {
|
|
548
|
-
return covered === undefined
|
|
768
|
+
return covered === undefined
|
|
769
|
+
? ""
|
|
770
|
+
: covered
|
|
771
|
+
? `<span class="badge ok">covered${suffixes.length ? ` by ${suffixes.map(html).join(" ")}` : ""}${renderCoverageReferenceMarkers(item, sourceLinks)}</span>`
|
|
772
|
+
: `<span class="badge missing">missing coverage</span>`;
|
|
549
773
|
}
|
|
550
774
|
function ruleCoverageBadge(ruleCoverage, scenarioIds, sourceLinks) {
|
|
551
775
|
if (ruleCoverage?.covered && !scenarioIds.length)
|
|
@@ -591,7 +815,10 @@ function sourceLineUrl(filePath, line, sourceLinks) {
|
|
|
591
815
|
return undefined;
|
|
592
816
|
const baseUrl = sourceLinks.githubBaseUrl.replace(/\/$/, "");
|
|
593
817
|
const ref = encodeURIComponent(sourceLinks.githubRef);
|
|
594
|
-
const encodedFilePath = filePath
|
|
818
|
+
const encodedFilePath = filePath
|
|
819
|
+
.split("/")
|
|
820
|
+
.map((segment) => encodeURIComponent(segment))
|
|
821
|
+
.join("/");
|
|
595
822
|
return `${baseUrl}/blob/${ref}/${encodedFilePath}#L${line}`;
|
|
596
823
|
}
|
|
597
824
|
function allReportDocuments(specs, options) {
|
|
@@ -639,14 +866,22 @@ function trimSectionLines(lines) {
|
|
|
639
866
|
return lines.slice(start, end);
|
|
640
867
|
}
|
|
641
868
|
function ruleScenarioIds(ruleId, ruleScenarioLinks) {
|
|
642
|
-
return Array.from(new Set(ruleScenarioLinks
|
|
869
|
+
return Array.from(new Set(ruleScenarioLinks
|
|
870
|
+
.filter((link) => link.ruleId === ruleId)
|
|
871
|
+
.map((link) => link.scenarioId))).sort();
|
|
643
872
|
}
|
|
644
873
|
function ruleIdsForScenario(scenarioId, specRuleIds, ruleScenarioLinks) {
|
|
645
|
-
const ruleIds = new Set(ruleScenarioLinks
|
|
874
|
+
const ruleIds = new Set(ruleScenarioLinks
|
|
875
|
+
.filter((link) => link.scenarioId === scenarioId)
|
|
876
|
+
.map((link) => link.ruleId));
|
|
646
877
|
return specRuleIds.filter((ruleId) => ruleIds.has(ruleId));
|
|
647
878
|
}
|
|
648
879
|
function buildRuleScenarioLinks(ruleCoverage, scenarioCoverage) {
|
|
649
|
-
const scenarioReferences = scenarioCoverage.flatMap((scenario) => scenario.references.map((reference) => ({
|
|
880
|
+
const scenarioReferences = scenarioCoverage.flatMap((scenario) => scenario.references.map((reference) => ({
|
|
881
|
+
scenarioId: scenario.id,
|
|
882
|
+
filePath: reference.filePath,
|
|
883
|
+
line: reference.line,
|
|
884
|
+
})));
|
|
650
885
|
const links = [];
|
|
651
886
|
const seen = new Set();
|
|
652
887
|
for (const rule of ruleCoverage) {
|
|
@@ -666,7 +901,9 @@ function buildRuleScenarioLinks(ruleCoverage, scenarioCoverage) {
|
|
|
666
901
|
function nearestScenarioReference(filePath, line, scenarioReferences) {
|
|
667
902
|
const maxLineDistance = 8;
|
|
668
903
|
return scenarioReferences
|
|
669
|
-
.filter((reference) => reference.filePath === filePath &&
|
|
904
|
+
.filter((reference) => reference.filePath === filePath &&
|
|
905
|
+
reference.line <= line &&
|
|
906
|
+
line - reference.line <= maxLineDistance)
|
|
670
907
|
.sort((left, right) => right.line - left.line)[0];
|
|
671
908
|
}
|
|
672
909
|
function groupEvidenceByLine(evidence) {
|