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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- 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
|
+
}
|