@wcag-audit/cli 1.0.0-alpha.6 → 1.0.0-alpha.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wcag-audit/cli",
3
- "version": "1.0.0-alpha.6",
3
+ "version": "1.0.0-alpha.8",
4
4
  "description": "Project-aware WCAG 2.1/2.2 auditor with AI-ready fix prompts for vibe-coding tools (Cursor, Claude Code, Windsurf).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { runInit } from "./commands/init.js";
10
10
  import { runScan } from "./commands/scan.js";
11
11
  import { runCi, FAIL_ON_LEVELS } from "./commands/ci.js";
12
12
  import { runDoctor } from "./commands/doctor.js";
13
+ import { runWatch } from "./commands/watch.js";
13
14
  import { readGlobalConfig } from "./config/global.js";
14
15
  import { runAudit } from "./audit.js";
15
16
 
@@ -18,7 +19,7 @@ const program = new Command();
18
19
  program
19
20
  .name("wcag-audit")
20
21
  .description("WCAG 2.1/2.2 auditor for web projects — with AI-ready fixes for Cursor / Claude Code / Windsurf.")
21
- .version("1.0.0-alpha.6");
22
+ .version("1.0.0-alpha.8");
22
23
 
23
24
  // ── init ─────────────────────────────────────────────────────────
24
25
  program
@@ -71,6 +72,18 @@ program
71
72
  console.log(JSON.stringify(redacted, null, 2));
72
73
  });
73
74
 
75
+ // ── watch ────────────────────────────────────────────────────────
76
+ program
77
+ .command("watch")
78
+ .description("Watch source files and rescan on every change (debounced 2s).")
79
+ .option("--debounce <ms>", "Debounce interval between rescans", "2000")
80
+ .action(async (opts) => {
81
+ const result = await runWatch({
82
+ debounceMs: parseInt(opts.debounce, 10) || 2000,
83
+ });
84
+ if (!result.ok) process.exit(1);
85
+ });
86
+
74
87
  // ── ci ───────────────────────────────────────────────────────────
75
88
  program
76
89
  .command("ci")
@@ -5,7 +5,7 @@ import { readGlobalConfig } from "../config/global.js";
5
5
  import { validateLicense } from "../license/validate.js";
6
6
  import { detectFramework } from "../discovery/registry.js";
7
7
 
8
- const CLI_VERSION = "1.0.0-alpha.6";
8
+ const CLI_VERSION = "1.0.0-alpha.8";
9
9
 
10
10
  export async function runDoctor({ cwd = process.cwd(), log = console.log } = {}) {
11
11
  const checks = [];
@@ -4,7 +4,7 @@ import { validateLicense } from "../license/validate.js";
4
4
  import { requestFreeLicense } from "../license/request-free.js";
5
5
  import { writeGlobalConfig } from "../config/global.js";
6
6
 
7
- const CLI_VERSION = "1.0.0-alpha.6";
7
+ const CLI_VERSION = "1.0.0-alpha.8";
8
8
 
9
9
  // runInit can be called two ways:
10
10
  // 1. Interactive (no answers) — uses enquirer to prompt
@@ -15,9 +15,10 @@ import { startDevServer, detectDevCommand } from "../devserver/spawn.js";
15
15
  import { renderFindingsMarkdown } from "../output/markdown.js";
16
16
  import { renderCursorRules } from "../output/cursor-rules.js";
17
17
  import { upsertAgentsMd } from "../output/agents-md.js";
18
+ import { writeProjectReport } from "../output/excel-project.js";
18
19
  import { hashContent, readCacheEntry, writeCacheEntry } from "../cache/route-cache.js";
19
20
 
20
- const CLI_VERSION = "1.0.0-alpha.6";
21
+ const CLI_VERSION = "1.0.0-alpha.8";
21
22
 
22
23
  export async function runScan({
23
24
  cwd = process.cwd(),
@@ -240,6 +241,31 @@ export async function runScan({
240
241
  log(`✓ WCAG_FIXES.md (${allFindings.length} findings)`);
241
242
  }
242
243
 
244
+ if (outputs.has("json")) {
245
+ // Structured JSON output for programmatic consumers (custom
246
+ // dashboards, CI integrations, historical trend tracking).
247
+ const json = {
248
+ generator: `@wcag-audit/cli v${CLI_VERSION}`,
249
+ generatedAt: new Date().toISOString(),
250
+ projectName,
251
+ framework,
252
+ strategy,
253
+ wcagVersion: globalCfg.defaults.wcagVersion,
254
+ levels: globalCfg.defaults.conformanceLevel,
255
+ totalPages: routes.length,
256
+ newPagesCount,
257
+ cachedPagesCount,
258
+ totalIssues: allFindings.length,
259
+ findings: allFindings,
260
+ };
261
+ await writeFile(
262
+ resolve(cwd, "wcag-report.json"),
263
+ JSON.stringify(json, null, 2),
264
+ "utf8",
265
+ );
266
+ log(`✓ wcag-report.json`);
267
+ }
268
+
243
269
  if (outputs.has("cursor-rules")) {
244
270
  const mdc = renderCursorRules({ projectName, findings: allFindings });
245
271
  const dir = resolve(cwd, ".cursor/rules");
@@ -276,11 +302,23 @@ export async function runScan({
276
302
  log(`✓ AGENTS.md WCAG section updated`);
277
303
  }
278
304
 
279
- // Note: multi-page Excel is not yet supported. report.js is
280
- // per-page only (expects meta.url, meta.startedAt, meta.scopeCriteria).
281
- // Phase 1 ships markdown only; aggregate Excel comes in a later phase.
282
305
  if (outputs.has("excel")) {
283
- log("! Excel output skipped in Phase 1 — use `wcag-audit audit <url>` for per-page Excel.");
306
+ await writeProjectReport({
307
+ findings: allFindings,
308
+ meta: {
309
+ projectName,
310
+ generator: `@wcag-audit/cli v${CLI_VERSION}`,
311
+ framework,
312
+ strategy,
313
+ wcagVersion: globalCfg.defaults.wcagVersion,
314
+ levels: globalCfg.defaults.conformanceLevel,
315
+ totalPages: routes.length,
316
+ newPagesCount,
317
+ cachedPagesCount,
318
+ },
319
+ outPath: resolve(cwd, "wcag-report.xlsx"),
320
+ });
321
+ log(`✓ wcag-report.xlsx`);
284
322
  }
285
323
 
286
324
  // 9. Log usage for billing
@@ -0,0 +1,89 @@
1
+ import { watch } from "fs";
2
+ import { join } from "path";
3
+ import { runScan } from "./scan.js";
4
+
5
+ // Watch mode: rescan every time a source file changes. Debounced so a
6
+ // burst of saves (prettier-on-save, hot reload) only triggers one scan.
7
+ //
8
+ // The watcher only fires on changes to /src, /app, /pages, /components
9
+ // — not node_modules or .next. Ignores .wcag-audit/cache so cache
10
+ // writes don't re-trigger scans.
11
+ export async function runWatch({
12
+ cwd = process.cwd(),
13
+ debounceMs = 2000,
14
+ log = console.log,
15
+ } = {}) {
16
+ const watchDirs = ["src", "app", "pages", "components"]
17
+ .map((d) => join(cwd, d));
18
+
19
+ log("");
20
+ log("wcag-audit watch — rescanning on source changes");
21
+ log(`Watched dirs: ${watchDirs.map((d) => d.replace(cwd + "/", "")).join(", ")}`);
22
+ log("Press Ctrl+C to stop.");
23
+ log("");
24
+
25
+ // Run once on startup so the first report lands before any edits.
26
+ await runScan({ cwd, log });
27
+
28
+ let pending = null;
29
+ let running = false;
30
+ let needsRerun = false;
31
+
32
+ const trigger = (reason) => {
33
+ if (pending) clearTimeout(pending);
34
+ pending = setTimeout(async () => {
35
+ pending = null;
36
+ if (running) {
37
+ needsRerun = true;
38
+ return;
39
+ }
40
+ running = true;
41
+ log("");
42
+ log(`─── change detected (${reason}) — rescanning ───`);
43
+ try {
44
+ await runScan({ cwd, log });
45
+ } catch (err) {
46
+ log(`✗ Scan failed: ${err.message}`);
47
+ }
48
+ running = false;
49
+ if (needsRerun) {
50
+ needsRerun = false;
51
+ trigger("queued changes");
52
+ }
53
+ }, debounceMs);
54
+ };
55
+
56
+ const watchers = [];
57
+ for (const dir of watchDirs) {
58
+ try {
59
+ const w = watch(dir, { recursive: true }, (_event, filename) => {
60
+ if (!filename) return;
61
+ // Ignore cache writes so our own output doesn't loop
62
+ if (filename.startsWith(".wcag-audit")) return;
63
+ if (filename.includes("node_modules")) return;
64
+ trigger(filename);
65
+ });
66
+ watchers.push(w);
67
+ } catch {
68
+ // Directory doesn't exist — skip silently
69
+ }
70
+ }
71
+
72
+ if (watchers.length === 0) {
73
+ log("! No source directories found to watch. Tried: " + watchDirs.join(", "));
74
+ return { ok: false, error: "No source directories to watch" };
75
+ }
76
+
77
+ // Keep the process alive until SIGINT
78
+ return new Promise((resolve) => {
79
+ const cleanup = () => {
80
+ for (const w of watchers) w.close();
81
+ if (pending) clearTimeout(pending);
82
+ log("");
83
+ log("Watcher stopped.");
84
+ resolve({ ok: true });
85
+ };
86
+ process.on("SIGINT", cleanup);
87
+ process.on("SIGTERM", cleanup);
88
+ });
89
+ }
@@ -5,8 +5,8 @@ export const DEFAULT_PROJECT_CONFIG = {
5
5
  routes: "auto",
6
6
  excludePaths: [],
7
7
  failOn: "critical",
8
- // Valid values: "excel" (deferred to later phase), "markdown",
9
- // "cursor-rules", "agents-md". Unknown values are ignored.
8
+ // Valid values: "excel" (deferred), "markdown", "cursor-rules",
9
+ // "agents-md", "json". Unknown values are ignored.
10
10
  outputs: ["excel", "markdown"],
11
11
  dynamicRouteSamples: {},
12
12
  devServer: {
@@ -0,0 +1,212 @@
1
+ // Multi-page (project-wide) Excel report writer.
2
+ //
3
+ // Unlike src/report.js (which writes one Excel per page audit with
4
+ // embedded screenshots), this writer aggregates findings across all
5
+ // routes into three sheets: Summary, Issues, Coverage.
6
+ //
7
+ // No embedded screenshots — a 50-route project would produce a 500MB+
8
+ // file. The markdown output already has the text evidence, and users
9
+ // can run `wcag-audit audit <url>` for a single-page Excel with
10
+ // screenshots if they need them.
11
+
12
+ import ExcelJS from "exceljs";
13
+
14
+ const IMPACT_ORDER = ["critical", "serious", "moderate", "minor"];
15
+
16
+ export async function writeProjectReport({
17
+ findings,
18
+ meta,
19
+ outPath,
20
+ }) {
21
+ const wb = new ExcelJS.Workbook();
22
+ wb.creator = meta.generator || "@wcag-audit/cli";
23
+ wb.created = new Date();
24
+
25
+ // ── Summary sheet ─────────────────────────────────────────────
26
+ const sum = wb.addWorksheet("Summary");
27
+ sum.columns = [
28
+ { header: "", key: "k", width: 28 },
29
+ { header: "", key: "v", width: 60 },
30
+ ];
31
+
32
+ const impactCounts = countByImpact(findings);
33
+
34
+ const rows = [
35
+ ["WCAG Audit — Project Report"],
36
+ [],
37
+ ["Project", meta.projectName],
38
+ ["Generator", meta.generator],
39
+ ["Generated", new Date().toISOString()],
40
+ [],
41
+ ["Framework", meta.framework || "—"],
42
+ ["Strategy", meta.strategy || "—"],
43
+ ["WCAG version", meta.wcagVersion],
44
+ ["Levels", (meta.levels || []).join(", ")],
45
+ [],
46
+ ["Total pages audited", String(meta.totalPages)],
47
+ ["Pages with new audits", String(meta.newPagesCount ?? meta.totalPages)],
48
+ ["Pages served from cache", String(meta.cachedPagesCount ?? 0)],
49
+ [],
50
+ ["Total issues", String(findings.length)],
51
+ [" Critical", String(impactCounts.critical)],
52
+ [" Serious", String(impactCounts.serious)],
53
+ [" Moderate", String(impactCounts.moderate)],
54
+ [" Minor", String(impactCounts.minor)],
55
+ ];
56
+ rows.forEach((r) => sum.addRow(r));
57
+ sum.getRow(1).font = { bold: true, size: 14, color: { argb: "FF0969DA" } };
58
+
59
+ // ── Issues sheet ──────────────────────────────────────────────
60
+ const issues = wb.addWorksheet("Issues");
61
+ issues.columns = [
62
+ { header: "Route", key: "route", width: 28 },
63
+ { header: "File", key: "sourceFile", width: 38 },
64
+ { header: "Criterion", key: "criteria", width: 14 },
65
+ { header: "Level", key: "level", width: 8 },
66
+ { header: "Impact", key: "impact", width: 12 },
67
+ { header: "Rule", key: "ruleId", width: 24 },
68
+ { header: "Description", key: "description", width: 50 },
69
+ { header: "Fix", key: "help", width: 50 },
70
+ { header: "Detected by", key: "source", width: 14 },
71
+ { header: "Selector", key: "selector", width: 30 },
72
+ { header: "Evidence", key: "evidence", width: 40 },
73
+ { header: "Reference", key: "helpUrl", width: 40 },
74
+ ];
75
+ issues.getRow(1).font = { bold: true };
76
+ issues.getRow(1).fill = {
77
+ type: "pattern",
78
+ pattern: "solid",
79
+ fgColor: { argb: "FFF1F5F9" },
80
+ };
81
+
82
+ // Sort by impact desc, then by route, so critical issues surface first
83
+ const sorted = [...findings].sort((a, b) => {
84
+ const ai = IMPACT_ORDER.indexOf(a.impact);
85
+ const bi = IMPACT_ORDER.indexOf(b.impact);
86
+ const aImp = ai === -1 ? 4 : ai;
87
+ const bImp = bi === -1 ? 4 : bi;
88
+ if (aImp !== bImp) return aImp - bImp;
89
+ return String(a.route || "").localeCompare(String(b.route || ""));
90
+ });
91
+
92
+ for (const f of sorted) {
93
+ const criteria = Array.isArray(f.criteria) ? f.criteria.join(", ") : f.criteria || "";
94
+ issues.addRow({
95
+ route: f.route || "",
96
+ sourceFile: f.sourceFile || "",
97
+ criteria,
98
+ level: f.level || "",
99
+ impact: f.impact || "",
100
+ ruleId: f.ruleId || "",
101
+ description: truncate(f.description, 400),
102
+ help: truncate(f.help, 400),
103
+ source: f.source || "",
104
+ selector: truncate(f.selector, 200),
105
+ evidence: truncate(asText(f.evidence), 400),
106
+ helpUrl: f.helpUrl || "",
107
+ });
108
+ }
109
+
110
+ // Color-code impact column
111
+ issues.eachRow({ includeEmpty: false }, (row, rowNum) => {
112
+ if (rowNum === 1) return;
113
+ const impact = row.getCell("impact").value;
114
+ const fill = impactFill(impact);
115
+ if (fill) row.getCell("impact").fill = fill;
116
+ });
117
+
118
+ // Freeze header row
119
+ issues.views = [{ state: "frozen", ySplit: 1 }];
120
+ // Enable autofilter
121
+ issues.autoFilter = {
122
+ from: { row: 1, column: 1 },
123
+ to: { row: 1, column: issues.columns.length },
124
+ };
125
+
126
+ // ── Coverage sheet ────────────────────────────────────────────
127
+ // Groups findings by criterion so compliance auditors can see
128
+ // which criteria were violated and how many times.
129
+ const cov = wb.addWorksheet("Coverage");
130
+ cov.columns = [
131
+ { header: "Criterion", key: "id", width: 14 },
132
+ { header: "Level", key: "level", width: 8 },
133
+ { header: "Violations", key: "count", width: 12 },
134
+ { header: "Routes affected", key: "routes", width: 28 },
135
+ { header: "Rules triggered", key: "rules", width: 40 },
136
+ ];
137
+ cov.getRow(1).font = { bold: true };
138
+ cov.getRow(1).fill = {
139
+ type: "pattern",
140
+ pattern: "solid",
141
+ fgColor: { argb: "FFF1F5F9" },
142
+ };
143
+
144
+ const byCriterion = new Map();
145
+ for (const f of findings) {
146
+ const critList = Array.isArray(f.criteria) ? f.criteria : f.criteria ? [f.criteria] : ["?"];
147
+ for (const c of critList) {
148
+ if (!byCriterion.has(c)) {
149
+ byCriterion.set(c, { level: f.level || "", routes: new Set(), rules: new Set(), count: 0 });
150
+ }
151
+ const entry = byCriterion.get(c);
152
+ entry.count++;
153
+ if (f.route) entry.routes.add(f.route);
154
+ if (f.ruleId) entry.rules.add(f.ruleId);
155
+ }
156
+ }
157
+
158
+ const sortedCriteria = [...byCriterion.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
159
+ for (const [id, e] of sortedCriteria) {
160
+ cov.addRow({
161
+ id,
162
+ level: e.level,
163
+ count: e.count,
164
+ routes: [...e.routes].sort().join(", "),
165
+ rules: [...e.rules].sort().join(", "),
166
+ });
167
+ }
168
+
169
+ cov.views = [{ state: "frozen", ySplit: 1 }];
170
+
171
+ await wb.xlsx.writeFile(outPath);
172
+ }
173
+
174
+ function countByImpact(findings) {
175
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
176
+ for (const f of findings) {
177
+ const key = IMPACT_ORDER.includes(f.impact) ? f.impact : "minor";
178
+ counts[key]++;
179
+ }
180
+ return counts;
181
+ }
182
+
183
+ function impactFill(impact) {
184
+ switch (impact) {
185
+ case "critical":
186
+ return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFEE2E2" } };
187
+ case "serious":
188
+ return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFFE4CC" } };
189
+ case "moderate":
190
+ return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFEF3C7" } };
191
+ case "minor":
192
+ return { type: "pattern", pattern: "solid", fgColor: { argb: "FFE0E7FF" } };
193
+ default:
194
+ return null;
195
+ }
196
+ }
197
+
198
+ function truncate(value, max) {
199
+ const s = asText(value);
200
+ if (s.length <= max) return s;
201
+ return s.slice(0, max - 1) + "…";
202
+ }
203
+
204
+ function asText(value) {
205
+ if (value == null) return "";
206
+ if (typeof value === "string") return value;
207
+ try {
208
+ return JSON.stringify(value);
209
+ } catch {
210
+ return String(value);
211
+ }
212
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import ExcelJS from "exceljs";
6
+ import { writeProjectReport } from "./excel-project.js";
7
+
8
+ let dir;
9
+ beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "excel-")); });
10
+ afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
11
+
12
+ async function loadWorkbook(path) {
13
+ const wb = new ExcelJS.Workbook();
14
+ await wb.xlsx.readFile(path);
15
+ return wb;
16
+ }
17
+
18
+ const sampleMeta = {
19
+ projectName: "my-nextjs-app",
20
+ generator: "@wcag-audit/cli v1.0.0-alpha.8",
21
+ framework: "nextjs-app",
22
+ strategy: "source-walk",
23
+ wcagVersion: "2.2",
24
+ levels: ["A", "AA"],
25
+ totalPages: 3,
26
+ newPagesCount: 3,
27
+ cachedPagesCount: 0,
28
+ };
29
+
30
+ const sampleFindings = [
31
+ {
32
+ source: "axe", ruleId: "image-alt", criteria: ["1.1.1"], level: "A",
33
+ impact: "critical", description: "Images must have alt text",
34
+ help: "Add an alt attribute", helpUrl: "https://example/1",
35
+ selector: "img.hero", evidence: "<img>", route: "/", sourceFile: "app/page.tsx",
36
+ },
37
+ {
38
+ source: "playwright", ruleId: "kbd/focus", criteria: "2.4.7", level: "AA",
39
+ impact: "serious", description: "Focus indicator missing",
40
+ help: "Add :focus-visible", helpUrl: "https://example/2",
41
+ selector: "button", evidence: "<button>", route: "/about", sourceFile: "app/about/page.tsx",
42
+ },
43
+ {
44
+ source: "axe", ruleId: "color-contrast", criteria: "1.4.3", level: "AA",
45
+ impact: "minor", description: "Low contrast",
46
+ help: "Increase contrast", helpUrl: "https://example/3",
47
+ selector: ".muted", evidence: "rgb(150,150,150) on rgb(200,200,200)",
48
+ route: "/pricing", sourceFile: "app/pricing/page.tsx",
49
+ },
50
+ ];
51
+
52
+ describe("writeProjectReport", () => {
53
+ it("creates a workbook with 3 sheets: Summary, Issues, Coverage", async () => {
54
+ const outPath = join(dir, "report.xlsx");
55
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
56
+ const wb = await loadWorkbook(outPath);
57
+ const names = wb.worksheets.map((ws) => ws.name);
58
+ expect(names).toEqual(["Summary", "Issues", "Coverage"]);
59
+ });
60
+
61
+ it("Summary sheet includes project metadata + impact counts", async () => {
62
+ const outPath = join(dir, "report.xlsx");
63
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
64
+ const wb = await loadWorkbook(outPath);
65
+ const sum = wb.getWorksheet("Summary");
66
+ const cells = [];
67
+ sum.eachRow((row) => row.eachCell((c) => cells.push(String(c.value ?? ""))));
68
+ const joined = cells.join(" | ");
69
+ expect(joined).toContain("my-nextjs-app");
70
+ expect(joined).toContain("nextjs-app");
71
+ expect(joined).toContain("source-walk");
72
+ expect(joined).toContain("2.2");
73
+ // Impact counts: 1 critical, 1 serious, 0 moderate, 1 minor
74
+ expect(joined).toMatch(/Critical.*1/);
75
+ expect(joined).toMatch(/Serious.*1/);
76
+ expect(joined).toMatch(/Minor.*1/);
77
+ });
78
+
79
+ it("Issues sheet has one row per finding (+ header)", async () => {
80
+ const outPath = join(dir, "report.xlsx");
81
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
82
+ const wb = await loadWorkbook(outPath);
83
+ const issues = wb.getWorksheet("Issues");
84
+ // 1 header row + 3 findings
85
+ expect(issues.rowCount).toBe(4);
86
+ });
87
+
88
+ it("Issues sheet sorts by impact severity (critical first)", async () => {
89
+ const outPath = join(dir, "report.xlsx");
90
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
91
+ const wb = await loadWorkbook(outPath);
92
+ const issues = wb.getWorksheet("Issues");
93
+ // Columns: 1=route, 2=sourceFile, 3=criteria, 4=level, 5=impact
94
+ // Row 2 (first data row) should be critical
95
+ expect(String(issues.getRow(2).getCell(5).value)).toBe("critical");
96
+ expect(String(issues.getRow(3).getCell(5).value)).toBe("serious");
97
+ expect(String(issues.getRow(4).getCell(5).value)).toBe("minor");
98
+ });
99
+
100
+ it("Coverage sheet groups findings by criterion", async () => {
101
+ const outPath = join(dir, "report.xlsx");
102
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
103
+ const wb = await loadWorkbook(outPath);
104
+ const cov = wb.getWorksheet("Coverage");
105
+ // 1 header + 3 criteria (1.1.1, 1.4.3, 2.4.7)
106
+ expect(cov.rowCount).toBe(4);
107
+ const ids = [];
108
+ cov.eachRow((row, i) => {
109
+ if (i === 1) return;
110
+ // Columns: 1=id, 2=level, 3=count
111
+ ids.push(String(row.getCell(1).value));
112
+ });
113
+ expect(ids.sort()).toEqual(["1.1.1", "1.4.3", "2.4.7"]);
114
+ });
115
+
116
+ it("handles zero findings", async () => {
117
+ const outPath = join(dir, "report.xlsx");
118
+ await writeProjectReport({ findings: [], meta: sampleMeta, outPath });
119
+ const wb = await loadWorkbook(outPath);
120
+ const issues = wb.getWorksheet("Issues");
121
+ // Just the header row
122
+ expect(issues.rowCount).toBe(1);
123
+ const cov = wb.getWorksheet("Coverage");
124
+ expect(cov.rowCount).toBe(1);
125
+ });
126
+
127
+ it("handles criteria as string or array", async () => {
128
+ const outPath = join(dir, "report.xlsx");
129
+ const mixed = [
130
+ { impact: "critical", criteria: ["1.1.1", "4.1.2"], ruleId: "a", route: "/" },
131
+ { impact: "minor", criteria: "2.4.3", ruleId: "b", route: "/x" },
132
+ ];
133
+ await writeProjectReport({ findings: mixed, meta: sampleMeta, outPath });
134
+ const wb = await loadWorkbook(outPath);
135
+ const issues = wb.getWorksheet("Issues");
136
+ // Column 3 = criteria. Impact sort: critical first, then minor.
137
+ expect(String(issues.getRow(2).getCell(3).value)).toBe("1.1.1, 4.1.2");
138
+ expect(String(issues.getRow(3).getCell(3).value)).toBe("2.4.3");
139
+ });
140
+ });