ultra11y 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
package/src/prd.ts ADDED
@@ -0,0 +1,406 @@
1
+ // `prd` — turn an AuditResult into an actionable "fixes to do" backlog (Markdown),
2
+ // grouped by WCAG success criterion (or, with --standard <pack>, by the pack's
3
+ // criteria projected from the WCAG audit). Default: one combined backlog sectioned
4
+ // by priority. `--split criterion`: one PRD file per criterion. The same per-criterion
5
+ // units feed optional GitHub issue creation (see gh.ts).
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { AuditResult, Finding, Lang, Severity } from "./types.js";
9
+ import { getSC, guidelineTitle, scTitle, techniques as scTechniques } from "./wcag.js";
10
+ import { resolveRemediation } from "./messages.js";
11
+ import {
12
+ type StandardId,
13
+ isCore,
14
+ loadPack,
15
+ derivePackResults,
16
+ packConformancePct,
17
+ standardLabel,
18
+ themeName,
19
+ titlePlain as packTitlePlain,
20
+ } from "./standards/index.js";
21
+ import { guidanceForWcag, guidanceForCriterion } from "./guidance/index.js";
22
+ import type { GuidanceEntry } from "./guidance/types.js";
23
+ import { renderAuditorBacklog, renderAuditorPerCriterion, occurrenceLine, relatedLine } from "./auditor.js";
24
+
25
+ const SEV_ORDER: Severity[] = ["bloquant", "majeur", "mineur"];
26
+ const SEV_RANK: Record<Severity, number> = { bloquant: 0, majeur: 1, mineur: 2 };
27
+ const ICON: Record<Severity, string> = { bloquant: "🔴", majeur: "🟠", mineur: "🟡" };
28
+
29
+ const L = {
30
+ fr: {
31
+ title: (std: string) => `Plan de correction d'accessibilité — ${std}`,
32
+ date: "Date",
33
+ scope: "Périmètre",
34
+ files: "fichier(s)",
35
+ rate: "Taux de réussite automatique",
36
+ note: "Backlog des corrections détectées automatiquement. Les critères « à évaluer » (rendu / jugement) sont adjugés par l'agent IA (`verify --manual`, de façon gatée), le rendu via `scan` (voir le rapport).",
37
+ none: "Aucune correction automatique à faire : le moteur statique n'a relevé aucune non-conformité.",
38
+ sev: { bloquant: "Bloquant", majeur: "Majeur", mineur: "Mineur" } as Record<Severity, string>,
39
+ fix: "Correction",
40
+ affected: "Occurrence(s)",
41
+ effort: "Effort estimé",
42
+ avoid: "À éviter",
43
+ recommended: "Recommandé",
44
+ example: "Exemple",
45
+ prdTitle: (label: string) => `PRD — ${label}`,
46
+ epic: "Épopée",
47
+ story: "User story",
48
+ ac: "Critères d'acceptation (Given/When/Then)",
49
+ tasks: "Tâches",
50
+ asUser: "En tant qu'utilisateur en situation de handicap",
51
+ iNeed: (t: string) => `je dois pouvoir compter sur : ${t}`,
52
+ given: "Étant donné",
53
+ when: "Lorsque",
54
+ then: "Alors",
55
+ acWhen: "un utilisateur de technologie d'assistance y accède",
56
+ givenElements: (sel: string) => `les éléments ${sel} concernés`,
57
+ techniques: "Techniques WCAG",
58
+ docNote:
59
+ "Document d'exigences (PRD) généré depuis l'audit statique : une épopée par thème, une user story par critère, des critères d'acceptation ancrés sur les intitulés WCAG. Adjugez les critères « à évaluer » avec `verify --manual` (agent IA, gaté), le rendu via `scan`.",
60
+ },
61
+ en: {
62
+ title: (std: string) => `Accessibility fix plan — ${std}`,
63
+ date: "Date",
64
+ scope: "Scope",
65
+ files: "file(s)",
66
+ rate: "Automatic static-check pass rate",
67
+ note: "Backlog of automatically-detected fixes. The “to assess” criteria (rendering / judgment) are adjudicated by the AI agent (`verify --manual`, gated), rendering via `scan` (see the report).",
68
+ none: "No automatic fix to do: the static engine found no non-conformity.",
69
+ sev: { bloquant: "Blocking", majeur: "Major", mineur: "Minor" } as Record<Severity, string>,
70
+ fix: "Fix",
71
+ affected: "Occurrence(s)",
72
+ effort: "Estimated effort",
73
+ avoid: "Avoid",
74
+ recommended: "Recommended",
75
+ example: "Example",
76
+ prdTitle: (label: string) => `PRD — ${label}`,
77
+ epic: "Epic",
78
+ story: "User story",
79
+ ac: "Acceptance criteria (Given/When/Then)",
80
+ tasks: "Tasks",
81
+ asUser: "As a user relying on assistive technology",
82
+ iNeed: (t: string) => `I need: ${t}`,
83
+ given: "Given",
84
+ when: "When",
85
+ then: "Then",
86
+ acWhen: "a user of assistive technology reaches them",
87
+ givenElements: (sel: string) => `the affected ${sel} elements`,
88
+ techniques: "WCAG techniques",
89
+ docNote:
90
+ "Product-requirements document generated from the static audit: one epic per theme, one user story per criterion, acceptance criteria anchored to the WCAG success-criterion text. Adjudicate the “to assess” criteria with `verify --manual` (AI agent, gated), rendering via `scan`.",
91
+ },
92
+ } as const;
93
+
94
+ export interface PrdUnit {
95
+ criteriaId: string;
96
+ title: string; // criterion plain title
97
+ label: string; // "<id> — <title>" (pack: "<name> <id> — <title>")
98
+ refs: string[]; // related WCAG SC ids (pack units) — empty for the WCAG core
99
+ severity: Severity; // most severe finding in the group
100
+ findings: Finding[];
101
+ // True when EVERY finding in the group is advisory (non-normative): the unit is a
102
+ // « Recommandation (non normative) », never a non-conformity. A mixed unit (≥1 normative
103
+ // finding) is NC and stays NC — advisory findings then ride along inside it. Renderers
104
+ // route advisory units to the recommendations channel (report §Recommandations, the
105
+ // `recommendation`-labelled GitHub issue), never the NC channel. Optional/additive
106
+ // (absent ⇒ normative) — `prdUnits` always sets it; hand-built units may omit it.
107
+ advisory?: boolean;
108
+ }
109
+
110
+ /** Group findings into actionable units (one backlog item / one GitHub issue),
111
+ * ordered by severity then id. Core groups by WCAG SC; a pack groups by its own
112
+ * criteria projected from the WCAG-keyed audit. */
113
+ export function prdUnits(r: AuditResult, standard: StandardId = "wcag", lang: Lang = "en"): PrdUnit[] {
114
+ const units: PrdUnit[] = [];
115
+ const mostSevere = (fs: Finding[]): Severity => [...fs].sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity])[0]?.severity ?? "mineur";
116
+ const sortFindings = (fs: Finding[]) => [...fs].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
117
+ // All-advisory ⇒ recommendation unit; a single normative finding makes it an NC unit.
118
+ const allAdvisory = (fs: Finding[]) => fs.length > 0 && fs.every((f) => f.advisory);
119
+
120
+ if (isCore(standard)) {
121
+ const byCrit = new Map<string, Finding[]>();
122
+ for (const f of r.findings) (byCrit.get(f.criteriaId) ?? byCrit.set(f.criteriaId, []).get(f.criteriaId)!).push(f);
123
+ for (const [criteriaId, fs] of byCrit) {
124
+ const title = scTitle(criteriaId, lang);
125
+ units.push({
126
+ criteriaId,
127
+ title: title ?? criteriaId,
128
+ label: title ? `${criteriaId} — ${title}` : criteriaId,
129
+ refs: [],
130
+ severity: mostSevere(fs),
131
+ findings: sortFindings(fs),
132
+ advisory: allAdvisory(fs),
133
+ });
134
+ }
135
+ } else {
136
+ const pack = loadPack(standard);
137
+ for (const pr of derivePackResults(r, standard)) {
138
+ if (!pr.findings.length) continue;
139
+ const pc = pack.criteria.find((c) => c.id === pr.id)!;
140
+ const t = packTitlePlain(pack, pc, lang);
141
+ units.push({
142
+ criteriaId: pr.id,
143
+ title: t,
144
+ label: `${pack.name} ${pr.id} — ${t}`,
145
+ refs: pc.wcag,
146
+ severity: mostSevere(pr.findings),
147
+ findings: sortFindings(pr.findings),
148
+ advisory: allAdvisory(pr.findings),
149
+ });
150
+ }
151
+ }
152
+ units.sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.criteriaId.localeCompare(b.criteriaId, undefined, { numeric: true }));
153
+ return units;
154
+ }
155
+
156
+ /** Split PRD units into the non-conformity channel (nc) and the advisory recommendation
157
+ * channel (advisory). The report/GitHub renderers key off this so an advisory unit is
158
+ * NEVER presented among non-conformities. Order within each list is preserved. */
159
+ export function partitionUnits(units: PrdUnit[]): { nc: PrdUnit[]; advisory: PrdUnit[] } {
160
+ const nc: PrdUnit[] = [];
161
+ const advisory: PrdUnit[] = [];
162
+ for (const u of units) (u.advisory ? advisory : nc).push(u);
163
+ return { nc, advisory };
164
+ }
165
+
166
+ const SEV_WEIGHT: Record<Severity, number> = { bloquant: 3, majeur: 2, mineur: 1 };
167
+
168
+ /** Deterministic effort heuristic: Σ severity weights over the occurrences, bucketed.
169
+ * Exported so the auditor block (src/auditor.ts) renders the same t-shirt size + points
170
+ * as the remediation backlog — one heuristic, never two that can drift. */
171
+ export function effortOf(unit: PrdUnit): { bucket: "S" | "M" | "L"; points: number } {
172
+ const points = unit.findings.reduce((sum, f) => sum + SEV_WEIGHT[f.severity], 0);
173
+ return { bucket: points <= 4 ? "S" : points <= 12 ? "M" : "L", points };
174
+ }
175
+
176
+ /** Guidance entries for a unit: by pack criterion (pack view) or WCAG SC (core view),
177
+ * falling back to the unit's WCAG refs so a pack criterion with no direct entry still
178
+ * surfaces its SCs' guidance. Stable order, deduped by id. */
179
+ export function guidanceFor(unit: PrdUnit, standard: StandardId): GuidanceEntry[] {
180
+ const seen = new Set<string>();
181
+ const out: GuidanceEntry[] = [];
182
+ const push = (es: GuidanceEntry[]) => {
183
+ for (const e of es) {
184
+ if (seen.has(e.id)) continue;
185
+ seen.add(e.id);
186
+ out.push(e);
187
+ }
188
+ };
189
+ if (isCore(standard)) push(guidanceForWcag(unit.criteriaId));
190
+ else {
191
+ push(guidanceForCriterion(standard, unit.criteriaId));
192
+ for (const sc of unit.refs) push(guidanceForWcag(sc));
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /** The first guidance example (before/after) for a unit, rendered as fenced code.
198
+ * Exported so the auditor block's "Changement attendu" reuses this exact renderer
199
+ * instead of duplicating the before/after formatting. */
200
+ export function guidanceExampleBlock(entries: GuidanceEntry[], lang: Lang): string[] {
201
+ const s = L[lang];
202
+ for (const e of entries) {
203
+ const ex = (e.examples ?? []).find((x) => x.bad || x.good);
204
+ if (!ex) continue;
205
+ const note = ex.note?.[lang] ?? ex.note?.fr ?? ex.note?.en;
206
+ const out: string[] = [`**${s.example}**${note ? ` — ${note}` : ""}`, ""];
207
+ if (ex.bad) out.push(`_${s.avoid} :_`, "```" + ex.lang, ex.bad, "```", "");
208
+ if (ex.good) out.push(`_${s.recommended} :_`, "```" + ex.lang, ex.good, "```", "");
209
+ return out;
210
+ }
211
+ return [];
212
+ }
213
+
214
+ // One backlog item: criterion heading, fix, effort, a guidance example, and the checklist.
215
+ function unitBlock(unit: PrdUnit, lang: Lang, heading: string, standard: StandardId): string[] {
216
+ const s = L[lang];
217
+ const out: string[] = [];
218
+ const refs = unit.refs.length ? ` · WCAG ${unit.refs.join(", ")}` : "";
219
+ out.push(`${heading} ${ICON[unit.severity]} ${unit.label}${refs}`, "");
220
+ const fixes = [...new Set(unit.findings.map((f) => resolveRemediation(f, lang)))];
221
+ for (const fx of fixes) out.push(`- _${s.fix} :_ ${fx}`);
222
+ const { bucket, points } = effortOf(unit);
223
+ out.push(`- _${s.effort} :_ ${bucket} (${points} pts)`, "");
224
+ out.push(...guidanceExampleBlock(guidanceFor(unit, standard), lang));
225
+ out.push(`**${s.affected} (${unit.findings.length})**`, "");
226
+ for (const f of unit.findings) {
227
+ out.push(occurrenceLine(f, lang, { marker: "checkbox" }));
228
+ if (f.related) out.push(relatedLine(f.related, lang, { selector: true }));
229
+ }
230
+ out.push("");
231
+ return out;
232
+ }
233
+
234
+ // `ratePct`: the pack-projection rate (packConformancePct over that standard's own
235
+ // criteria), passed by pack callers so the PRD header matches the pack report's header
236
+ // (and its own NC table) instead of the core WCAG `conformancePct`. Core callers pass
237
+ // nothing — unchanged behavior.
238
+ function header(r: AuditResult, lang: Lang, title: string, note: string = L[lang].note, ratePct?: number): string[] {
239
+ const s = L[lang];
240
+ return [
241
+ `# ${title}`,
242
+ "",
243
+ `- **${s.date}** : ${r.date}`,
244
+ `- **${s.scope}** : ${r.scope.files} ${s.files} — ${r.scope.inputs.join(", ")}`,
245
+ `- **${s.rate}** : ${ratePct ?? r.conformancePct}%`,
246
+ "",
247
+ `> ${note}`,
248
+ "",
249
+ ];
250
+ }
251
+
252
+ /** A single combined backlog, sectioned by priority (bloquant → majeur → mineur). */
253
+ export function renderBacklog(r: AuditResult, lang: Lang = "en", standard: StandardId = "wcag"): string {
254
+ const s = L[lang];
255
+ const units = prdUnits(r, standard, lang);
256
+ const ratePct = isCore(standard) ? undefined : packConformancePct(derivePackResults(r, standard));
257
+ const out = header(r, lang, s.title(standardLabel(standard)), undefined, ratePct);
258
+ if (!units.length) {
259
+ out.push(s.none, "");
260
+ return out.join("\n");
261
+ }
262
+ for (const sev of SEV_ORDER) {
263
+ const group = units.filter((u) => u.severity === sev);
264
+ if (!group.length) continue;
265
+ out.push(`## ${ICON[sev]} ${s.sev[sev]} (${group.length})`, "");
266
+ for (const u of group) out.push(...unitBlock(u, lang, "###", standard));
267
+ }
268
+ return out.join("\n");
269
+ }
270
+
271
+ export interface PrdFile {
272
+ name: string;
273
+ content: string;
274
+ }
275
+
276
+ /** One standalone PRD document per criterion (for `--split criterion`). */
277
+ export function renderPerCriterion(r: AuditResult, lang: Lang = "en", standard: StandardId = "wcag"): PrdFile[] {
278
+ const s = L[lang];
279
+ const ratePct = isCore(standard) ? undefined : packConformancePct(derivePackResults(r, standard));
280
+ return prdUnits(r, standard, lang).map((u) => {
281
+ const out = header(r, lang, s.prdTitle(u.label), undefined, ratePct);
282
+ out.push(...unitBlock(u, lang, "##", standard));
283
+ return { name: `prd-${u.criteriaId}-${r.date}.md`, content: out.join("\n") };
284
+ });
285
+ }
286
+
287
+ interface DocEpic {
288
+ key: string;
289
+ title: string;
290
+ units: PrdUnit[];
291
+ }
292
+
293
+ /** Group PRD units into epics: by WCAG guideline (core view) or pack theme (pack view). */
294
+ function epicsOf(units: PrdUnit[], standard: StandardId, lang: Lang): DocEpic[] {
295
+ const pack = isCore(standard) ? null : loadPack(standard);
296
+ const groups = new Map<string, DocEpic>();
297
+ for (const u of units) {
298
+ let key: string;
299
+ let title: string;
300
+ if (pack) {
301
+ const themeNum = pack.criteria.find((c) => c.id === u.criteriaId)?.theme ?? 0;
302
+ key = String(themeNum).padStart(3, "0");
303
+ title = (themeNum ? themeName(pack, themeNum, lang) : undefined) ?? `#${themeNum}`;
304
+ } else {
305
+ const g = getSC(u.criteriaId)?.guideline ?? u.criteriaId;
306
+ key = g;
307
+ title = `${g} ${guidelineTitle(g, lang) ?? ""}`.trim();
308
+ }
309
+ let epic = groups.get(key);
310
+ if (!epic) {
311
+ epic = { key, title, units: [] };
312
+ groups.set(key, epic);
313
+ }
314
+ epic.units.push(u);
315
+ }
316
+ return [...groups.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
317
+ }
318
+
319
+ /** The Given/When/Then acceptance-criteria lines for a unit — one per WCAG ref of the
320
+ * criterion (core: the SC itself; pack: its mapped WCAG refs), each anchored to the real
321
+ * WCAG success-criterion title. Shared by `renderPrdDoc` (plain bullets) and the auditor
322
+ * block's technical section (checkbox list via `opts.checkbox`) so the generator lives in
323
+ * exactly one place. */
324
+ export function acceptanceCriteria(unit: PrdUnit, standard: StandardId, lang: Lang, opts: { checkbox?: boolean } = {}): string[] {
325
+ const s = L[lang];
326
+ const prefix = opts.checkbox ? "- [ ] " : "- ";
327
+ const hints = [...new Set(unit.findings.map((f) => `\`${f.selectorHint}\``))].slice(0, 3).join(", ") || "—";
328
+ const scs = isCore(standard) ? [unit.criteriaId] : unit.refs;
329
+ return scs.map((sc) => {
330
+ const req = scTitle(sc, lang) ?? sc;
331
+ return `${prefix}**${s.given}** ${s.givenElements(hints)} · **${s.when}** ${s.acWhen} · **${s.then}** « ${req} » (WCAG ${sc}).`;
332
+ });
333
+ }
334
+
335
+ /** A product-requirements document: epics by theme, one user story per criterion, with
336
+ * Given/When/Then acceptance criteria anchored to the real WCAG SC titles + the task list. */
337
+ export function renderPrdDoc(r: AuditResult, lang: Lang = "en", standard: StandardId = "wcag"): string {
338
+ const s = L[lang];
339
+ const units = prdUnits(r, standard, lang);
340
+ const ratePct = isCore(standard) ? undefined : packConformancePct(derivePackResults(r, standard));
341
+ const out = header(r, lang, s.title(standardLabel(standard)), s.docNote, ratePct);
342
+ if (!units.length) {
343
+ out.push(s.none, "");
344
+ return out.join("\n");
345
+ }
346
+ for (const epic of epicsOf(units, standard, lang)) {
347
+ out.push(`## ${s.epic} — ${epic.title}`, "");
348
+ for (const u of epic.units) {
349
+ const refs = u.refs.length ? ` · WCAG ${u.refs.join(", ")}` : "";
350
+ out.push(`### ${ICON[u.severity]} ${s.story} — ${u.label}${refs}`, "");
351
+ out.push(`> ${s.asUser}, ${s.iNeed(u.title)}.`, "");
352
+ out.push(`**${s.ac}**`, "");
353
+ out.push(...acceptanceCriteria(u, standard, lang));
354
+ const techs = isCore(standard) ? scTechniques(u.criteriaId) : [...new Set(u.refs.flatMap((sc) => scTechniques(sc)))];
355
+ if (techs.length) out.push("", `_${s.techniques} : ${techs.join(", ")}_`);
356
+ out.push("", `**${s.tasks} (${u.findings.length})**`, "");
357
+ for (const f of u.findings) {
358
+ out.push(occurrenceLine(f, lang, { marker: "checkbox" }));
359
+ if (f.related) out.push(relatedLine(f.related, lang, { selector: false }));
360
+ }
361
+ out.push("");
362
+ }
363
+ }
364
+ return out.join("\n");
365
+ }
366
+
367
+ // Output shape: `audit` (DEFAULT) = the auditor conformance block (src/auditor.ts);
368
+ // `remediation` = the dev backlog above (kept for back-compat); `doc` = the user-story PRD.
369
+ export type PrdFormat = "audit" | "remediation" | "doc";
370
+
371
+ export interface PrdOpts {
372
+ out: string;
373
+ lang: Lang;
374
+ split?: "criterion";
375
+ format?: PrdFormat;
376
+ standard: StandardId;
377
+ /** Auditor formats only: emit the technical ticket sections (Partie technique +
378
+ * Contexte de reproduction). Default true; `prd --no-technical` sets it false for a
379
+ * pure-auditor consumption of the block. */
380
+ technical?: boolean;
381
+ }
382
+
383
+ /** Render and write the PRD markdown; returns the written path(s). */
384
+ export function writePrd(r: AuditResult, opts: PrdOpts): string[] {
385
+ mkdirSync(opts.out, { recursive: true });
386
+ if (opts.format === "doc") {
387
+ const p = join(opts.out, `prd-doc-${r.date}.md`);
388
+ writeFileSync(p, renderPrdDoc(r, opts.lang, opts.standard));
389
+ return [p];
390
+ }
391
+ const remediation = opts.format === "remediation";
392
+ const technical = opts.technical ?? true;
393
+ if (opts.split === "criterion") {
394
+ const files = remediation ? renderPerCriterion(r, opts.lang, opts.standard) : renderAuditorPerCriterion(r, opts.lang, opts.standard, { technical });
395
+ const paths: string[] = [];
396
+ for (const f of files) {
397
+ const p = join(opts.out, f.name);
398
+ writeFileSync(p, f.content);
399
+ paths.push(p);
400
+ }
401
+ return paths;
402
+ }
403
+ const p = join(opts.out, `prd-${r.date}.md`);
404
+ writeFileSync(p, remediation ? renderBacklog(r, opts.lang, opts.standard) : renderAuditorBacklog(r, opts.lang, opts.standard, { technical }));
405
+ return [p];
406
+ }
@@ -0,0 +1,42 @@
1
+ // Serialize work that touches one project root.
2
+ //
3
+ // `fix --write` rewrites source files and then RE-AUDITS them to prove it
4
+ // introduced no new non-conformity. An audit running concurrently reads that
5
+ // tree mid-rewrite and reports findings for a state that never existed. The
6
+ // handlers also chdir into the project so globs resolve the way the CLI
7
+ // resolves them, and process.cwd() is per-process, not per-call: two
8
+ // overlapping handlers would fight over it.
9
+ //
10
+ // The CLI never hit either problem because one process runs one command to
11
+ // completion. The MCP server can have several tool calls in flight at once.
12
+ //
13
+ // The fix is a promise chain per project root — the smallest thing that is
14
+ // actually correct. It is deliberately coarse: a `criteria` lookup needs no
15
+ // project at all and is dispatched before the lock, while different projects
16
+ // stay fully parallel.
17
+ const chains = new Map<string, Promise<unknown>>();
18
+
19
+ export function withProjectLock<T>(dir: string, fn: () => Promise<T>): Promise<T> {
20
+ const prev = chains.get(dir) ?? Promise.resolve();
21
+ // Chain off `prev` however it settled: a failed predecessor must not poison
22
+ // every later call for the same repo.
23
+ const next = prev.then(fn, fn);
24
+ // The tail the NEXT caller waits on never rejects, so one thrown tool call
25
+ // can't reject the whole queue behind it.
26
+ const tail = next.then(noop, noop);
27
+ chains.set(dir, tail);
28
+ // Drop the entry once the tail is still us, so a long-lived server doesn't
29
+ // accumulate a settled promise per repo it ever touched.
30
+ tail.then(() => {
31
+ if (chains.get(dir) === tail) chains.delete(dir);
32
+ }, noop);
33
+ return next;
34
+ }
35
+
36
+ function noop(): void {}
37
+
38
+ // Test seam: drop every pending chain. Never call this from product code — an
39
+ // in-flight lock holder would stop serializing against later arrivals.
40
+ export function resetProjectLocks(): void {
41
+ chains.clear();
42
+ }