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/gh.ts ADDED
@@ -0,0 +1,214 @@
1
+ // Optional GitHub issue creation for `prd --gh-issues`. Shells out to the `gh`
2
+ // CLI (which handles its own auth) — NO npm dependency, no tokens in ultra11y.
3
+ // Everything is best-effort: if gh is absent/unauthenticated, the caller still
4
+ // wrote the markdown and we just report that issues were skipped. De-dupes by
5
+ // issue title so re-running never creates duplicates.
6
+ import { execFileSync } from "node:child_process";
7
+ import type { Lang, Severity } from "./types.js";
8
+ import type { PrdUnit } from "./prd.js";
9
+ import { type StandardId, isCore, loadPack } from "./standards/index.js";
10
+ import { renderAuditorUnit } from "./auditor.js";
11
+ import { resolveMessage, resolveRemediation } from "./messages.js";
12
+
13
+ // Issue-body shape: `audit` (DEFAULT) = the auditor conformance block; `remediation` = the
14
+ // legacy dev body (WCAG refs / Fix / Occurrences). Mirrors `prd --format`.
15
+ export type IssueFormat = "audit" | "remediation";
16
+
17
+ const SEV_ORDER: Severity[] = ["bloquant", "majeur", "mineur"];
18
+ const SEV_RANK: Record<Severity, number> = { bloquant: 0, majeur: 1, mineur: 2 };
19
+ const ICON: Record<Severity, string> = { bloquant: "🔴", majeur: "🟠", mineur: "🟡" };
20
+ const SEV_LABEL: Record<Lang, Record<Severity, string>> = {
21
+ fr: { bloquant: "Bloquant", majeur: "Majeur", mineur: "Mineur" },
22
+ en: { bloquant: "Blocking", majeur: "Major", mineur: "Minor" },
23
+ };
24
+
25
+ /** Display label + issue tag for the active standard ("WCAG"/"wcag" or a pack's). */
26
+ function standardTag(standard: StandardId): { label: string; tag: string } {
27
+ return isCore(standard)
28
+ ? { label: "WCAG", tag: "wcag" }
29
+ : (() => {
30
+ const p = loadPack(standard);
31
+ return { label: p.name, tag: p.key };
32
+ })();
33
+ }
34
+
35
+ function gh(args: string[], input?: string): string {
36
+ // Capture stderr (pipe, not ignore) so a failed `gh` call carries a surfaceable reason
37
+ // on the thrown error instead of vanishing.
38
+ return execFileSync("gh", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...(input !== undefined ? { input } : {}) });
39
+ }
40
+
41
+ /** Extract a concise, single-line failure reason from a thrown `gh` error — the first
42
+ * meaningful stderr line (where `gh` prints the API/auth error), else the error message. */
43
+ function ghErrorReason(err: unknown): string | undefined {
44
+ if (!err || typeof err !== "object") return undefined;
45
+ const e = err as { stderr?: unknown; message?: unknown };
46
+ const stderr = typeof e.stderr === "string" ? e.stderr : Buffer.isBuffer(e.stderr) ? e.stderr.toString("utf8") : "";
47
+ const line = stderr
48
+ .split("\n")
49
+ .map((l) => l.trim())
50
+ .find(Boolean);
51
+ if (line) return line;
52
+ if (typeof e.message === "string" && e.message) return e.message.split("\n")[0]!.trim() || undefined;
53
+ return undefined;
54
+ }
55
+
56
+ /** Is the `gh` CLI installed AND authenticated here? */
57
+ export function ghAvailable(): boolean {
58
+ try {
59
+ execFileSync("gh", ["auth", "status"], { stdio: "ignore" });
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ // Stable, language-neutral suffix marking a non-normative recommendation issue apart
67
+ // from a non-conformity one. Part of the de-dupe grain, so it must never drift.
68
+ export const RECOMMENDATION_SUFFIX = " (recommendation)";
69
+
70
+ /** One issue per criterion (stable de-dupe grain, regardless of --split). The label
71
+ * is the active standard ("WCAG" for the core, else the pack name e.g. "RGAA"). An
72
+ * advisory unit gets a stable suffix so its issue is never confused with an NC one. */
73
+ export function issueTitle(unit: PrdUnit, label = "WCAG"): string {
74
+ return `[a11y] ${label} ${unit.criteriaId} — ${unit.title}${unit.advisory ? RECOMMENDATION_SUFFIX : ""}`;
75
+ }
76
+
77
+ /** Titles of all existing issues (open + closed), for de-duplication. Empty on any failure. */
78
+ export function existingIssueTitles(): Set<string> {
79
+ try {
80
+ const raw = gh(["issue", "list", "--state", "all", "--limit", "1000", "--json", "title"]);
81
+ const arr = JSON.parse(raw) as Array<{ title?: string }>;
82
+ return new Set(arr.map((i) => i.title ?? "").filter(Boolean));
83
+ } catch {
84
+ return new Set();
85
+ }
86
+ }
87
+
88
+ export function issueBody(unit: PrdUnit, lang: Lang, standard: StandardId = "wcag", format: IssueFormat = "audit"): string {
89
+ if (format === "audit") return renderAuditorUnit(unit, standard, lang).join("\n").trimEnd();
90
+ const t = lang === "fr" ? { fix: "Correction", occ: "Occurrence(s)", def: "↳ définition" } : { fix: "Fix", occ: "Occurrence(s)", def: "↳ definition" };
91
+ const lines: string[] = [];
92
+ if (unit.refs.length) lines.push(`**WCAG** : ${unit.refs.join(", ")}`, "");
93
+ for (const fx of [...new Set(unit.findings.map((f) => resolveRemediation(f, lang)))]) lines.push(`**${t.fix}** : ${fx}`);
94
+ lines.push("", `**${t.occ} (${unit.findings.length})**`, "");
95
+ for (const f of unit.findings) {
96
+ lines.push(`- [ ] \`${f.file}:${f.line}\` (\`${f.selectorHint}\`) — ${resolveMessage(f, lang)}`);
97
+ if (f.related) lines.push(` - ${t.def} : \`${f.related.file}:${f.related.line}\` (\`${f.related.selectorHint}\`)`);
98
+ }
99
+ return lines.join("\n");
100
+ }
101
+
102
+ export interface CreateResult {
103
+ ok: boolean;
104
+ /** Concise `gh` stderr reason when the creation failed (both attempts). */
105
+ reason?: string;
106
+ }
107
+
108
+ /** Create one issue. Retries without labels if the labelled call fails (labels may
109
+ * not exist in the repo). On failure, carries `gh`'s stderr reason so the caller can
110
+ * surface WHY it failed instead of silently swallowing it. */
111
+ export function createIssue(title: string, body: string, labels: string[]): CreateResult {
112
+ const base = ["issue", "create", "--title", title, "--body-file", "-"];
113
+ try {
114
+ gh([...base, "--label", labels.join(",")], body);
115
+ return { ok: true };
116
+ } catch (labelledErr) {
117
+ try {
118
+ gh(base, body);
119
+ return { ok: true };
120
+ } catch (err) {
121
+ // Prefer the un-labelled attempt's reason (the real one); fall back to the labelled one.
122
+ return { ok: false, reason: ghErrorReason(err) ?? ghErrorReason(labelledErr) };
123
+ }
124
+ }
125
+ }
126
+
127
+ export interface PushResult {
128
+ created: number;
129
+ skipped: number; // already existed (by title)
130
+ failed: number;
131
+ createdTitles: string[];
132
+ /** Concise, de-duplicated `gh` failure reasons (empty when nothing failed). */
133
+ errors: string[];
134
+ }
135
+
136
+ /** Record a creation failure, keeping distinct `gh` reasons (deduped) for surfacing. */
137
+ function recordFailure(result: PushResult, reason?: string): void {
138
+ result.failed++;
139
+ if (reason && !result.errors.includes(reason)) result.errors.push(reason);
140
+ }
141
+
142
+ /** Create a GitHub issue per unit, skipping titles that already exist. */
143
+ export function pushIssues(units: PrdUnit[], lang: Lang, standard: StandardId = "wcag", format: IssueFormat = "audit"): PushResult {
144
+ const { label, tag } = standardTag(standard);
145
+ const existing = existingIssueTitles();
146
+ const result: PushResult = { created: 0, skipped: 0, failed: 0, createdTitles: [], errors: [] };
147
+ for (const u of units) {
148
+ const title = issueTitle(u, label);
149
+ if (existing.has(title)) {
150
+ result.skipped++;
151
+ continue;
152
+ }
153
+ // Advisory units get the `recommendation` label so they can be triaged apart from
154
+ // real non-conformities (which they are not).
155
+ const labels = u.advisory ? ["accessibility", tag, "recommendation", u.severity] : ["accessibility", tag, u.severity];
156
+ const r = createIssue(title, issueBody(u, lang, standard, format), labels);
157
+ if (r.ok) {
158
+ result.created++;
159
+ result.createdTitles.push(title);
160
+ existing.add(title); // guard against duplicate units in one run
161
+ } else {
162
+ recordFailure(result, r.reason);
163
+ }
164
+ }
165
+ return result;
166
+ }
167
+
168
+ /** Stable title for the single consolidated audit issue. No counts/date so re-runs de-dupe. */
169
+ export function singleIssueTitle(label = "WCAG"): string {
170
+ return `[a11y] ${label} — Accessibility audit`;
171
+ }
172
+
173
+ /** One consolidated issue body: every criterion, sectioned by severity (blocking → minor),
174
+ * each criterion reusing the same fix + occurrences block as the per-criterion issues. */
175
+ export function singleIssueBody(units: PrdUnit[], lang: Lang, standard: StandardId = "wcag", format: IssueFormat = "audit"): string {
176
+ const { label } = standardTag(standard);
177
+ const intro =
178
+ lang === "fr"
179
+ ? `Audit d'accessibilité ${label} — ${units.length} critère(s) non conforme(s).`
180
+ : `${label} accessibility audit — ${units.length} non-conforming criteria.`;
181
+ const lines: string[] = [intro, ""];
182
+ for (const sev of SEV_ORDER) {
183
+ const group = units.filter((u) => u.severity === sev);
184
+ if (!group.length) continue;
185
+ lines.push(`## ${ICON[sev]} ${SEV_LABEL[lang][sev]} (${group.length})`, "");
186
+ for (const u of group) {
187
+ if (format === "audit") lines.push(...renderAuditorUnit(u, standard, lang, { heading: "###" }));
188
+ else lines.push(`### ${u.label}`, "", issueBody(u, lang, standard, format), "");
189
+ }
190
+ }
191
+ return lines.join("\n");
192
+ }
193
+
194
+ /** Create ONE consolidated issue for the whole audit, de-duped by its stable title and
195
+ * labelled by the most severe criterion. Mirrors pushIssues' best-effort/skip semantics. */
196
+ export function pushSingleIssue(units: PrdUnit[], lang: Lang, standard: StandardId = "wcag", format: IssueFormat = "audit"): PushResult {
197
+ const { label, tag } = standardTag(standard);
198
+ const result: PushResult = { created: 0, skipped: 0, failed: 0, createdTitles: [], errors: [] };
199
+ if (!units.length) return result;
200
+ const title = singleIssueTitle(label);
201
+ if (existingIssueTitles().has(title)) {
202
+ result.skipped = 1;
203
+ return result;
204
+ }
205
+ const severity = [...units].sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity])[0]!.severity;
206
+ const r = createIssue(title, singleIssueBody(units, lang, standard, format), ["accessibility", tag, severity]);
207
+ if (r.ok) {
208
+ result.created = 1;
209
+ result.createdTitles.push(title);
210
+ } else {
211
+ recordFailure(result, r.reason);
212
+ }
213
+ return result;
214
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,206 @@
1
+ // Zero-dependency input expansion for `audit`: turn file paths, directories and
2
+ // globs into a concrete file list, then apply --include/--exclude predicates.
3
+ // The generic machinery (directory walking, glob → RegExp) is the vendored
4
+ // codeindex engine's; everything ultra11y-specific (markup extension allowlist,
5
+ // test-artifact policy, audit-output skips, input-scope matching) stays here.
6
+ import { existsSync, statSync } from "node:fs";
7
+ import { join, sep } from "node:path";
8
+ import { ext } from "./util.js";
9
+ import { compileGlobs as engineCompileGlobs, walk as engineWalk } from "./vendor/codeindex-engine.mjs";
10
+
11
+ // HTML + JSX/TSX + the HTML-shaped single-file component formats (Vue/Svelte/Astro
12
+ // templates parse cleanly through the HTML path). Server templates (.twig/.erb/.hbs/
13
+ // .liquid/.njk…) are opt-in via `--ext` since their extensions are ambiguous.
14
+ const DEFAULT_EXT = new Set([".html", ".htm", ".xhtml", ".jsx", ".tsx", ".vue", ".svelte", ".astro"]);
15
+
16
+ // GRAPH-ONLY extensions: plain TS/JS modules are never markup, so they never enter
17
+ // the AUDIT allowlist above (`DEFAULT_EXT`) — but a barrel (`components/index.ts`
18
+ // re-exporting `.tsx` components) or a plain-JS component definition is real
19
+ // cross-file structure the dependency graph (`audit --graph`) needs to resolve
20
+ // imports/re-exports/definitions through. Fed to `discover`'s `ext` for the graph's
21
+ // OWN file discovery pass only (see `runAudit`'s graph branch in src/audit.ts) —
22
+ // these files are parsed for their imports/exports and never run through the rule
23
+ // engine themselves.
24
+ export const GRAPH_ONLY_EXT = [".ts", ".js", ".mjs", ".cjs"];
25
+
26
+ /** Normalise extensions (`twig` → `.twig`, lowercased) into a lookup set merged
27
+ * with the built-in defaults. */
28
+ function extSet(extra: string[] | undefined): Set<string> {
29
+ const set = new Set(DEFAULT_EXT);
30
+ for (const raw of extra ?? []) {
31
+ for (const e of raw.split(",")) {
32
+ const t = e.trim().toLowerCase();
33
+ if (t) set.add(t.startsWith(".") ? t : `.${t}`);
34
+ }
35
+ }
36
+ return set;
37
+ }
38
+ // Directories the engine's walk does not know about but ultra11y must never
39
+ // auto-walk: `audits/` holds generated reports, and `.ultra11y/` (captures +
40
+ // generated setup) is ingested only via the explicit `.ultra11y/captures` input
41
+ // the CLI appends (so `--no-captures` genuinely opts out) or a direct
42
+ // `--captures <dir>` / coverage scan of that dir. Like the old SKIP_DIR, these
43
+ // prune DESCENDANTS of a walked root — naming such a dir directly as the walk
44
+ // root still descends into it. The rest of the old SKIP_DIR (node_modules,
45
+ // .git, dist, build, coverage, .next, out) is covered by the engine's own
46
+ // ignore universe (a superset), and the engine walk additionally honours
47
+ // .gitignore files under the walked root.
48
+ const SKIP_DIR = new Set(["audits", ".ultra11y"]);
49
+
50
+ // Test / spec / story / mock markup is bad-by-design (intentionally non-conformant
51
+ // fixtures, never shipped UI). Excluded by default to keep the audit focused on real
52
+ // product code; re-admit by naming the file/dir directly, with an `--include` glob, or
53
+ // `--no-default-excludes`. NOT in SKIP_DIR (which prunes the walk unconditionally) so
54
+ // the opt-outs can still reach these files.
55
+ const TEST_DIR = new Set(["__tests__", "__mocks__", ".storybook"]);
56
+ const TEST_FILE = /\.(test|spec|stories|story|cy)\.[^./]+$/i;
57
+ export function isTestArtifact(rel: string): boolean {
58
+ if (TEST_FILE.test(rel)) return true;
59
+ return rel.split("/").some((seg) => TEST_DIR.has(seg));
60
+ }
61
+
62
+ // Glob matching is the engine's (same dialect as before: `**` crosses `/`,
63
+ // `**/` makes the segment optional, `*` runs within a segment, `?` is one
64
+ // non-`/` char). ultra11y's flag semantics on top: each --include/--exclude
65
+ // value may carry a comma-separated LIST of globs, split and trimmed here.
66
+ export function compileGlobs(globs: string[] | undefined): ((rel: string) => boolean) | null {
67
+ if (!globs || globs.length === 0) return null;
68
+ const list = globs
69
+ .flatMap((g) => g.split(","))
70
+ .map((g) => g.trim())
71
+ .filter(Boolean);
72
+ // A non-empty flag that splits to nothing (e.g. ",") historically compiled to
73
+ // a match-nothing predicate — keep that, never silently widen to "no filter".
74
+ return engineCompileGlobs(list) ?? (() => false);
75
+ }
76
+
77
+ export const toPosix = (p: string): string => p.split(sep).join("/");
78
+ export const hasGlob = (s: string): boolean => /[*?]/.test(s);
79
+
80
+ /** A reusable file predicate (extension + include/exclude) for callers that
81
+ * already have a file list (e.g. git-changed discovery) and skip the walk. */
82
+ export function makeFilter(opts: ExpandOpts = {}): (file: string) => boolean {
83
+ const include = compileGlobs(opts.include);
84
+ const exclude = compileGlobs(opts.exclude);
85
+ const exts = extSet(opts.ext);
86
+ return (file: string): boolean => {
87
+ if (!exts.has(ext(file))) return false;
88
+ const rel = toPosix(file);
89
+ if (include && !include(rel)) return false;
90
+ if (exclude?.(rel)) return false;
91
+ // Default test-artifact exclusion (re-admitted by an explicit --include match).
92
+ if (!opts.noDefaultExcludes && isTestArtifact(rel) && !(include && include(rel))) return false;
93
+ return true;
94
+ };
95
+ }
96
+
97
+ /** Match a file against explicit input scopes (globs OR directory prefixes).
98
+ * Used by `--changed` to keep only git-changed files inside the audited scope.
99
+ * Returns null when inputs impose no scope (only "." / "-"). */
100
+ export function inScopeMatcher(inputs: string[]): ((file: string) => boolean) | null {
101
+ const scopes = inputs.filter((i) => i !== "-" && i !== ".");
102
+ if (!scopes.length) return null;
103
+ const globMatch = compileGlobs(scopes.filter(hasGlob));
104
+ const prefixes = scopes.filter((i) => !hasGlob(i)).map((p) => toPosix(p).replace(/\/+$/, ""));
105
+ return (file: string): boolean => {
106
+ const rel = toPosix(file);
107
+ if (globMatch?.(rel)) return true;
108
+ return prefixes.some((p) => rel === p || rel.startsWith(`${p}/`));
109
+ };
110
+ }
111
+
112
+ // Walk a directory with the engine (gitignore honoured, dependency/build/VCS
113
+ // dirs, lockfiles, binaries and >1 MiB files skipped), returning full paths the
114
+ // way the old recursive walker did (the input dir joined back onto each hit).
115
+ // No maxFiles cap here: the audit's own --max-files budget applies downstream,
116
+ // and a silent walk truncation must never masquerade as a clean "0 findings".
117
+ function walk(dir: string, acc: string[]): void {
118
+ for (const f of engineWalk(dir, { maxFiles: Number.MAX_SAFE_INTEGER }).files) {
119
+ // Engine rel paths are posix; prune ultra11y's own output dirs at any depth
120
+ // below the walked root (the root itself may be named directly, as before).
121
+ if (f.rel.split("/").some((seg) => SKIP_DIR.has(seg))) continue;
122
+ acc.push(join(dir, f.rel));
123
+ }
124
+ }
125
+
126
+ function staticBase(glob: string): string {
127
+ const idx = glob.search(/[*?]/);
128
+ const head = idx === -1 ? glob : glob.slice(0, idx);
129
+ const slash = head.lastIndexOf("/");
130
+ return slash === -1 ? "." : head.slice(0, slash) || ".";
131
+ }
132
+
133
+ export interface ExpandOpts {
134
+ include?: string[];
135
+ exclude?: string[];
136
+ ext?: string[];
137
+ noDefaultExcludes?: boolean; // audit test/spec/story/__tests__ markup too
138
+ onWarn?: (msg: string) => void;
139
+ // Memo of directory walks, shared by the calls that expand the SAME inputs with a
140
+ // different extension allowlist — `audit --graph` discovers twice (markup, then
141
+ // markup + .ts/.js for the dependency graph) and the tree walk is identical both
142
+ // times because the extension filter is applied AFTER it. Caller-owned and
143
+ // per-run on purpose: a module-level cache would go stale across audits (a
144
+ // `fix --iterate` re-audit must always see the current tree).
145
+ walkCache?: Map<string, string[]>;
146
+ }
147
+
148
+ /** Walk `dir` once per ExpandOpts.walkCache. Without a cache, walks every time. */
149
+ function walkCached(dir: string, opts: ExpandOpts): string[] {
150
+ const hit = opts.walkCache?.get(dir);
151
+ if (hit) return hit;
152
+ const acc: string[] = [];
153
+ walk(dir, acc);
154
+ opts.walkCache?.set(dir, acc);
155
+ return acc;
156
+ }
157
+
158
+ /** Expand inputs (paths/dirs/globs) into a sorted, de-duplicated file list. */
159
+ export function expandInputs(inputs: string[], opts: ExpandOpts = {}): string[] {
160
+ const include = compileGlobs(opts.include);
161
+ const exclude = compileGlobs(opts.exclude);
162
+ const exts = extSet(opts.ext);
163
+ const files = new Set<string>();
164
+ const explicit = new Set<string>(); // files named directly — never auto-excluded
165
+
166
+ for (const input of inputs) {
167
+ if (input === "-") continue; // stdin handled by the caller
168
+ if (hasGlob(input)) {
169
+ // A positional glob input is ONE glob (no comma-splitting — that is an
170
+ // --include/--exclude flag affordance), compiled by the engine.
171
+ const match = engineCompileGlobs([input])!;
172
+ const acc = walkCached(staticBase(input), opts);
173
+ // Honour the markup allowlist for globs too — a broad `src/**` must not pull
174
+ // in .js/.css/.json and parse them as HTML (engine scope is markup only).
175
+ for (const f of acc) if (match(toPosix(f)) && exts.has(ext(f))) files.add(f);
176
+ } else if (existsSync(input)) {
177
+ if (statSync(input).isDirectory()) {
178
+ const acc = walkCached(input, opts);
179
+ for (const f of acc) if (exts.has(ext(f))) files.add(f);
180
+ } else if (exts.has(ext(input))) {
181
+ files.add(input);
182
+ explicit.add(input);
183
+ }
184
+ } else if (input !== ".") {
185
+ // A non-glob path that doesn't exist (a typo, a not-yet-built dist/…) — warn so a
186
+ // gate never reads "0 files · 100%" on a silent miss. A glob matching nothing is fine.
187
+ opts.onWarn?.(`ultra11y: input not found: ${input}`);
188
+ }
189
+ }
190
+
191
+ let list = [...files];
192
+ if (include) list = list.filter((f) => include(toPosix(f)));
193
+ if (exclude) list = list.filter((f) => !exclude(toPosix(f)));
194
+ // Default test-artifact exclusion — re-admitted when the file was named directly or
195
+ // matched by an explicit --include. Logged (never a silent drop).
196
+ if (!opts.noDefaultExcludes) {
197
+ const before = list.length;
198
+ list = list.filter((f) => {
199
+ const rel = toPosix(f);
200
+ return !isTestArtifact(rel) || explicit.has(f) || (include != null && include(rel));
201
+ });
202
+ const dropped = before - list.length;
203
+ if (dropped) opts.onWarn?.(`ultra11y: skipped ${dropped} test/spec/story file(s) — pass --no-default-excludes to audit them.`);
204
+ }
205
+ return list.sort();
206
+ }
@@ -0,0 +1,114 @@
1
+ // Pass 1 of `audit --graph`: stream every discovered file, parse it, extract a
2
+ // compact FileGraphNode, and DISCARD the AST/Doc — so only O(file count) small
3
+ // records are ever held, never whole-repo source. Then assemble the DepGraph.
4
+ import { readText, ext } from "../util.js";
5
+ import { GRAPH_ONLY_EXT } from "../glob.js";
6
+ import { detectKind, splitAstroFrontmatter, parseSource, parseSourceWithAst } from "../parse/source.js";
7
+ import { parseJsxAst } from "../parse/jsx-ast.js";
8
+ import type { Doc } from "../parse/html.js";
9
+ import { dirname } from "node:path";
10
+ import { extractGraphNode, type FileGraphNode } from "./imports.js";
11
+ import { buildGraph, type DepGraph } from "./graph.js";
12
+ import { readTsAliases } from "./tsconfig.js";
13
+
14
+ const GRAPH_ONLY = new Set(GRAPH_ONLY_EXT);
15
+
16
+ // A plain TS/JS module has no markup — an empty Doc keeps extractGraphNode from
17
+ // reading nothing but its (non-existent) elements, while still handing the real
18
+ // source's AST to the import/export extraction below. Never fed through `parseHtml`
19
+ // on the real TS/JS text: generics like `Array<string>` would look like tags to it.
20
+ function emptyDoc(file: string, source: string): Doc {
21
+ return { file, source, lossy: false, kind: "html", roots: [], elements: [], byId: new Map(), lineStarts: [0] };
22
+ }
23
+
24
+ // .vue/.svelte's own <script>/<script setup>…</script> block — the only part of an
25
+ // SFC that's real JS/TS. Astro's frontmatter fence is handled separately (it isn't
26
+ // a <script> tag) via splitAstroFrontmatter.
27
+ const SCRIPT_BLOCK_RE = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
28
+
29
+ // The graph-relevant script source of an SFC: Astro's `---…---` frontmatter, or a
30
+ // .vue/.svelte file's <script> block(s). A Vue SFC commonly has BOTH `<script>` and
31
+ // `<script setup>` — concatenate ALL blocks so imports/exports from either are seen.
32
+ // "" when there is none to parse (template-only SFC) — extractGraphNode still
33
+ // synthesizes the self component def either way.
34
+ function sfcScriptSource(content: string, file: string): string {
35
+ if (/\.astro$/i.test(file)) return splitAstroFrontmatter(content).frontmatter;
36
+ return [...content.matchAll(SCRIPT_BLOCK_RE)].map((m) => m[1] ?? "").join("\n");
37
+ }
38
+
39
+ // How much of the graph pass's parse work may be carried over to the audit loop.
40
+ // The graph pass already reads and parses every markup file; without a hand-off the
41
+ // audit loop reads and parses each of them a SECOND time. Handing the Docs over is a
42
+ // pure win — they are the very same objects `parseSource` would rebuild — but holding
43
+ // them all would break the streaming design's bounded-memory promise on a huge repo.
44
+ // So the hand-off is budgeted: files are cached in discovery order (deterministic)
45
+ // until either ceiling is hit, after which the audit loop simply re-parses as before.
46
+ const CARRY_MAX_BYTES = 12 * 1024 * 1024;
47
+ const CARRY_MAX_ELEMENTS = 300_000;
48
+
49
+ export interface GraphBuild {
50
+ graph: DepGraph;
51
+ /** Markup Docs already parsed by this pass, for the audit loop to reuse. Bounded —
52
+ * a miss is not an error, it just means "parse it yourself". Never holds the
53
+ * synthetic empty Docs of plain .ts/.js modules (no markup to audit). */
54
+ docs: Map<string, Doc>;
55
+ }
56
+
57
+ /** The graph alone — the shape every caller but the audit loop wants. */
58
+ export function buildGraphStreaming(files: string[]): DepGraph {
59
+ return buildGraphAndDocs(files).graph;
60
+ }
61
+
62
+ export function buildGraphAndDocs(files: string[], opts: { carryDocs?: boolean; carryBudget?: { bytes: number; elements: number } } = {}): GraphBuild {
63
+ const budget = opts.carryBudget ?? { bytes: CARRY_MAX_BYTES, elements: CARRY_MAX_ELEMENTS };
64
+ const nodes: FileGraphNode[] = [];
65
+ const docs = new Map<string, Doc>();
66
+ let carriedBytes = 0;
67
+ let carriedElements = 0;
68
+ for (const file of files) {
69
+ let content: string;
70
+ try {
71
+ content = readText(file);
72
+ } catch {
73
+ continue; // unreadable / vanished — skip, like the audit loop does
74
+ }
75
+ let ast = null;
76
+ let doc: Doc;
77
+ let sfc = false;
78
+ let auditable = false;
79
+ if (GRAPH_ONLY.has(ext(file))) {
80
+ // Plain .ts/.js/.mjs/.cjs: never an audit target (see GRAPH_ONLY_EXT), but real
81
+ // cross-file structure (barrel re-exports, plain-JS component definitions) the
82
+ // graph needs. Babel's typescript+jsx plugins parse pure TS/JS fine.
83
+ ast = parseJsxAst(content);
84
+ doc = emptyDoc(file, content);
85
+ } else if (detectKind(file) === "sfc") {
86
+ // The script/frontmatter AST is parsed SEPARATELY from the template doc (see
87
+ // sfcScriptSource) purely for imports/re-exports; extractGraphNode also
88
+ // synthesizes a self component def (opts.sfc) so cross-file resolution and
89
+ // capture coverage see the SFC itself.
90
+ sfc = true;
91
+ auditable = true;
92
+ doc = parseSource(content, file);
93
+ const scriptSrc = sfcScriptSource(content, file);
94
+ if (scriptSrc) ast = parseJsxAst(scriptSrc);
95
+ } else {
96
+ // HTML and JSX/TSX go through the audit's OWN parser, so the Doc handed to the
97
+ // audit loop below is byte-for-byte the one it would have built itself.
98
+ auditable = true;
99
+ ({ doc, ast } = parseSourceWithAst(content, file));
100
+ }
101
+ if (opts.carryDocs && auditable && carriedBytes + content.length <= budget.bytes && carriedElements + doc.elements.length <= budget.elements) {
102
+ docs.set(file, doc);
103
+ carriedBytes += content.length;
104
+ carriedElements += doc.elements.length;
105
+ }
106
+ nodes.push(extractGraphNode(ast, doc, file, { sfc }));
107
+ }
108
+ // tsconfig-paths anchoring, walking up from the first file's dir as before. The
109
+ // engine resolver reads the tsconfig chain itself (via `startDir`); the parsed
110
+ // cwd-relative alias map only feeds the fallback resolver. Empty when there is
111
+ // no tsconfig.
112
+ const startDir = files[0] ? dirname(files[0]) : process.cwd();
113
+ return { graph: buildGraph(nodes, readTsAliases(startDir), startDir), docs };
114
+ }
Binary file
@@ -0,0 +1,126 @@
1
+ // The cross-file dependency + component graph: a map of compact per-file nodes
2
+ // plus the queries the cross-file rules need. Built once (pass 1) over the full
3
+ // discovered scope; consumed (read-only) during the audit pass.
4
+ import type { FileGraphNode, ComponentDef } from "./imports.js";
5
+ import { makeSpecResolver, type SpecResolver } from "./engine-resolve.js";
6
+ import type { AliasMap } from "./tsconfig.js";
7
+ import { toPosix } from "../glob.js";
8
+
9
+ export interface DepGraph {
10
+ nodes: Map<string, FileGraphNode>; // posix file -> node
11
+ known: ReadonlySet<string>; // all discovered files (posix), for specifier resolution
12
+ allIds: ReadonlySet<string>; // every literal id defined anywhere (global skip-link lookup)
13
+ // Engine-backed specifier resolution (relative / ESM ".js" / tsconfig-alias
14
+ // imports) bounded to `known`, with the original resolver as fallback.
15
+ resolveSpec: SpecResolver;
16
+ // Memo for `resolveUsage` top-level lookups (`file#name` -> def, null = "no such
17
+ // component"). Read-only graph, so a hit is always correct; see resolveUsage.
18
+ usageCache: Map<string, ComponentDef | null>;
19
+ }
20
+
21
+ export function buildGraph(nodes: FileGraphNode[], aliases: AliasMap = [], tsconfigStartDir?: string): DepGraph {
22
+ const map = new Map<string, FileGraphNode>();
23
+ const known = new Set<string>();
24
+ const allIds = new Set<string>();
25
+ // Deterministic: index in sorted-path order so re-runs are byte-identical.
26
+ for (const n of [...nodes].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))) {
27
+ map.set(n.file, n);
28
+ known.add(n.file);
29
+ for (const id of n.definesIds) allIds.add(id);
30
+ }
31
+ return { nodes: map, known, allIds, resolveSpec: makeSpecResolver(known, aliases, tsconfigStartDir), usageCache: new Map() };
32
+ }
33
+
34
+ /** Resolve a component used as `<localName/>` in `file` to its definition, following the
35
+ * import binding across files (relative or aliased), a namespace member (`<UI.Button/>`),
36
+ * a local definition, or a chain of barrel re-exports (`export { X } from "./x"`, modeled
37
+ * as an import-like binding by extractGraphNode) — however many barrels deep. `seen`
38
+ * guards against a re-export cycle (each file+name hop visited at most once). */
39
+ export function resolveUsage(graph: DepGraph, file: string, localName: string, seen: Set<string> = new Set()): ComponentDef | undefined {
40
+ const posix = toPosix(file);
41
+ const visitKey = `${posix}#${localName}`;
42
+ // Top-level lookups are memoised: the graph is read-only for the whole audit, so
43
+ // `(file, name)` always resolves the same way, and several cross rules walk the very
44
+ // same component usages independently. Recursive hops keep their `seen` set and are
45
+ // NOT cached — their result is relative to the path already taken.
46
+ const memoized = seen.size === 0;
47
+ if (memoized) {
48
+ const hit = graph.usageCache.get(visitKey);
49
+ if (hit !== undefined) return hit ?? undefined;
50
+ }
51
+ if (seen.has(visitKey)) return undefined;
52
+ seen.add(visitKey);
53
+ if (memoized) {
54
+ const out = resolveUsageUncached(graph, posix, localName, seen);
55
+ graph.usageCache.set(visitKey, out ?? null);
56
+ return out;
57
+ }
58
+ return resolveUsageUncached(graph, posix, localName, seen);
59
+ }
60
+
61
+ function resolveUsageUncached(graph: DepGraph, posix: string, localName: string, seen: Set<string>): ComponentDef | undefined {
62
+ const node = graph.nodes.get(posix);
63
+ if (!node) return undefined;
64
+ // Namespaced usage `<UI.Button/>` → namespace import `import * as UI` + member Button.
65
+ if (localName.includes(".")) {
66
+ const dot = localName.indexOf(".");
67
+ const ns = localName.slice(0, dot);
68
+ const member = localName.slice(dot + 1);
69
+ const nsImp = node.imports.find((i) => i.imported === "*" && i.local === ns);
70
+ if (!nsImp) return undefined;
71
+ const target = graph.resolveSpec(posix, nsImp.source);
72
+ if (!target) return undefined;
73
+ // The namespace target may be a BARREL: `<UI.Button/>` where UI is `import * as UI from
74
+ // "./components"` and components re-exports Button. If it isn't a direct component there,
75
+ // follow the barrel's re-export bindings one more hop.
76
+ return graph.nodes.get(target)?.components.get(member) ?? resolveUsage(graph, target, member, seen);
77
+ }
78
+ const imp = node.imports.find((i) => i.local === localName);
79
+ if (imp) {
80
+ if (imp.imported === "*") return undefined; // bare namespace binding used without a member
81
+ const target = graph.resolveSpec(posix, imp.source);
82
+ if (!target) return undefined;
83
+ const direct = graph.nodes.get(target)?.components.get(imp.imported);
84
+ if (direct) return direct;
85
+ // The target doesn't define it directly — it may itself re-export it from
86
+ // elsewhere (a barrel of barrels). Follow one more hop.
87
+ return resolveUsage(graph, target, imp.imported, seen);
88
+ }
89
+ const local = node.components.get(localName);
90
+ if (local) return local; // locally-defined component
91
+ // `export * from "./x"` wildcard barrel: the name may be defined in any starred source.
92
+ for (const src of node.starReexports) {
93
+ const target = graph.resolveSpec(posix, src);
94
+ if (!target) continue;
95
+ const hit = resolveUsage(graph, target, localName, seen);
96
+ if (hit) return hit;
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ /** Is `id` defined as a literal id by any file in the graph? */
102
+ export function idDefinedAnywhere(graph: DepGraph, id: string): boolean {
103
+ return graph.allIds.has(id);
104
+ }
105
+
106
+ /** Does any file `file` transitively imports declare an <html lang> (wrapper/layout
107
+ * pattern)? Used to suppress a missing-lang false positive on a wrapped document. */
108
+ export function htmlLangProvidedFor(graph: DepGraph, file: string): boolean {
109
+ const start = toPosix(file);
110
+ const seen = new Set<string>([start]);
111
+ const queue: string[] = [start];
112
+ while (queue.length) {
113
+ const cur = queue.shift() as string;
114
+ const node = graph.nodes.get(cur);
115
+ if (!node) continue;
116
+ if (cur !== start && node.providesHtmlLang) return true;
117
+ for (const imp of node.imports) {
118
+ const target = graph.resolveSpec(cur, imp.source);
119
+ if (target && !seen.has(target)) {
120
+ seen.add(target);
121
+ queue.push(target);
122
+ }
123
+ }
124
+ }
125
+ return false;
126
+ }