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
@@ -0,0 +1,359 @@
1
+ // Extract a compact, bounded per-file record for the cross-file dependency graph.
2
+ // We keep ONLY what cross-file rules need — import bindings, the components a file
3
+ // defines and the accessibility-relevant signals of the control each renders, the
4
+ // ids it defines, and whether it declares <html lang>. The parsed AST/Doc are
5
+ // discarded after extraction, so memory stays O(file count), never whole-repo.
6
+ import { type AstNode, type AstFile, asNode, asNodes, walkAst } from "../parse/jsx-ast.js";
7
+ import { jsxName, isIntrinsic, normAttrName } from "../parse/jsx-bridge.js";
8
+ import type { Doc } from "../parse/html.js";
9
+ import { hasAttr, attr } from "../parse/html.js";
10
+ import { toPosix } from "../glob.js";
11
+
12
+ export interface ImportRec {
13
+ source: string; // raw specifier, e.g. "./IconButton"
14
+ local: string; // local binding name
15
+ imported: string; // "default" | "*" | the named export
16
+ }
17
+
18
+ export interface ComponentDef {
19
+ name: string; // declared/export name ("default" for the default export)
20
+ file: string; // posix path of the defining file
21
+ line: number; // 1-based position of the control (or the component) in the file
22
+ col: number;
23
+ // Signals about the primary control (<button>/<a href>) the component renders.
24
+ hasControl: boolean; // the component renders a nameable control (<button>/<a href>)
25
+ rendersIconOnlyControl: boolean; // icon child, no literal text, no literal aria-name
26
+ acceptsName: boolean; // a name could be injected from props (spread / aria expr / {children})
27
+ controlHasName: boolean; // control already bears a literal name (text or literal aria-label) — a passed name prop is dead, not lost
28
+ spreadsProps: boolean; // control has {...props}
29
+ forwardsAria: boolean; // control has aria-label/labelledby set to an expression
30
+ }
31
+
32
+ export interface FileGraphNode {
33
+ file: string; // posix
34
+ imports: ImportRec[];
35
+ components: Map<string, ComponentDef>; // by local/export name, plus a "default" alias
36
+ definesIds: string[]; // literal id="…" anywhere in the file
37
+ providesHtmlLang: boolean; // an <html> with a lang attribute (literal or expression)
38
+ starReexports: string[]; // `export * from "./x"` sources — a name may be defined in any of them
39
+ // Component-LIBRARY specifiers this file renders (e.g. "@codegouvfr/react-dsfr/Button").
40
+ // A non-empty list marks the file as a rendered-output BLIND SPOT for coverage: the
41
+ // real HTML lives in node_modules, so its components need a rendered capture to audit.
42
+ opaqueComponents: string[];
43
+ }
44
+
45
+ const NAME_PROPS = new Set(["aria-label", "aria-labelledby", "title", "label", "alt"]);
46
+ const ICON_TAGS = new Set(["svg", "path", "use", "img", "i"]);
47
+
48
+ function opening(jsxEl: AstNode): AstNode | undefined {
49
+ return asNode(jsxEl.openingElement);
50
+ }
51
+
52
+ function attrName(a: AstNode): string {
53
+ return normAttrName(jsxName(asNode(a.name)));
54
+ }
55
+
56
+ function attrIsExpr(a: AstNode): boolean {
57
+ return asNode(a.value)?.type === "JSXExpressionContainer";
58
+ }
59
+
60
+ function attrIsLiteral(a: AstNode): boolean {
61
+ return asNode(a.value)?.type === "StringLiteral";
62
+ }
63
+
64
+ function controlAttrs(jsxEl: AstNode): AstNode[] {
65
+ const op = opening(jsxEl);
66
+ return op ? asNodes(op.attributes).filter((a) => a.type === "JSXAttribute") : [];
67
+ }
68
+
69
+ function hasSpread(jsxEl: AstNode): boolean {
70
+ const op = opening(jsxEl);
71
+ return op ? asNodes(op.attributes).some((a) => a.type === "JSXSpreadAttribute") : false;
72
+ }
73
+
74
+ function tagOf(jsxEl: AstNode): string {
75
+ const op = opening(jsxEl);
76
+ return op ? jsxName(asNode(op.name)) : "";
77
+ }
78
+
79
+ // Does this JSX element subtree contain a non-whitespace literal text node?
80
+ function hasLiteralText(jsxEl: AstNode): boolean {
81
+ let found = false;
82
+ walkAst(jsxEl, (n) => {
83
+ if (n.type === "JSXText" && typeof n.value === "string" && n.value.trim() !== "") found = true;
84
+ });
85
+ return found;
86
+ }
87
+
88
+ // Does the subtree render an icon-ish element (svg/img/i/… or a *Icon component)?
89
+ function hasIconChild(jsxEl: AstNode): boolean {
90
+ let found = false;
91
+ walkAst(jsxEl, (n) => {
92
+ if (n.type !== "JSXElement" || n === jsxEl) return;
93
+ const name = tagOf(n);
94
+ const low = name.toLowerCase();
95
+ if (ICON_TAGS.has(low) || /icon|glyph/i.test(name)) found = true;
96
+ });
97
+ return found;
98
+ }
99
+
100
+ // A direct expression child like {children}/{label}/{title} that could carry a name.
101
+ function rendersNameExpr(jsxEl: AstNode): boolean {
102
+ for (const c of asNodes(jsxEl.children)) {
103
+ if (c.type !== "JSXExpressionContainer") continue;
104
+ const expr = asNode(c.expression);
105
+ const id = expr && (expr.type === "Identifier" || expr.type === "JSXIdentifier") ? String(expr.name ?? "") : "";
106
+ if (/children|label|title|text|name/i.test(id)) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ // Find the component's primary control: the first intrinsic <button> or <a href>
112
+ // anywhere in the function body's JSX.
113
+ function findControl(fnNode: AstNode): AstNode | undefined {
114
+ let control: AstNode | undefined;
115
+ walkAst(fnNode, (n) => {
116
+ if (control || n.type !== "JSXElement") return;
117
+ const tag = tagOf(n);
118
+ if (!isIntrinsic(tag)) return;
119
+ const low = tag.toLowerCase();
120
+ if (low === "button") control = n;
121
+ else if (low === "a" && controlAttrs(n).some((a) => attrName(a) === "href")) control = n;
122
+ });
123
+ return control;
124
+ }
125
+
126
+ function posOf(node: AstNode): { line: number; col: number } {
127
+ return { line: node.loc?.start.line ?? 1, col: (node.loc?.start.column ?? 0) + 1 };
128
+ }
129
+
130
+ function analyzeComponent(name: string, file: string, fnNode: AstNode): ComponentDef {
131
+ const control = findControl(fnNode);
132
+ if (!control) {
133
+ const { line, col } = posOf(fnNode);
134
+ return {
135
+ name,
136
+ file,
137
+ line,
138
+ col,
139
+ hasControl: false,
140
+ rendersIconOnlyControl: false,
141
+ acceptsName: false,
142
+ controlHasName: false,
143
+ spreadsProps: false,
144
+ forwardsAria: false,
145
+ };
146
+ }
147
+ const { line, col } = posOf(control);
148
+ const attrs = controlAttrs(control);
149
+ const spreadsProps = hasSpread(control);
150
+ const forwardsAria = attrs.some((a) => (attrName(a) === "aria-label" || attrName(a) === "aria-labelledby") && attrIsExpr(a));
151
+ const literalAriaName = attrs.some((a) => NAME_PROPS.has(attrName(a)) && attrIsLiteral(a));
152
+ const acceptsNameViaChildren = rendersNameExpr(control);
153
+ const acceptsName = spreadsProps || forwardsAria || acceptsNameViaChildren;
154
+ // forwardsAria = the control carries aria-label/aria-labelledby={expr}; literalAriaName =
155
+ // a literal name. Either way the control IS named (the value may be dynamic but is
156
+ // present), so it is NOT an unnamed icon-only control — avoids flagging a self-named
157
+ // icon button like `aria-label={isPlus ? "…" : "…"}`.
158
+ const controlHasName = literalAriaName || forwardsAria || hasLiteralText(control);
159
+ const rendersIconOnlyControl = hasIconChild(control) && !hasLiteralText(control) && !literalAriaName && !forwardsAria;
160
+ return { name, file, line, col, hasControl: true, rendersIconOnlyControl, acceptsName, controlHasName, spreadsProps, forwardsAria };
161
+ }
162
+
163
+ // A .vue/.svelte/.astro SFC has no top-level JS function to declare "the component"
164
+ // the way JSX does — the file itself IS the component. Basename → PascalCase, so
165
+ // `Comp.vue`/`my-button.svelte` register as `Comp`/`MyButton`, matching how it would
166
+ // be imported (`import Comp from "./Comp.vue"`).
167
+ function pascalCaseBasename(posix: string): string {
168
+ const base = (posix.split("/").pop() ?? posix).replace(/\.[^.]+$/, "");
169
+ const name = base
170
+ .split(/[^A-Za-z0-9]+/)
171
+ .filter(Boolean)
172
+ .map((seg) => seg[0]!.toUpperCase() + seg.slice(1))
173
+ .join("");
174
+ return name || "Component";
175
+ }
176
+
177
+ function isCapitalized(name: string): boolean {
178
+ const head = name.split(".")[0] ?? name;
179
+ const first = head[0] ?? "";
180
+ return first !== "" && first === first.toUpperCase() && first !== first.toLowerCase();
181
+ }
182
+
183
+ function isComponentFn(init: AstNode | undefined): boolean {
184
+ return !!init && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression" || init.type === "FunctionDeclaration");
185
+ }
186
+
187
+ // Collect component definitions from a top-level declaration node (already unwrapped
188
+ // from export wrappers). Returns the entries to register.
189
+ function declComponents(decl: AstNode, file: string): ComponentDef[] {
190
+ const out: ComponentDef[] = [];
191
+ if (decl.type === "FunctionDeclaration") {
192
+ const id = asNode(decl.id);
193
+ const name = id && typeof id.name === "string" ? id.name : "";
194
+ if (name && isCapitalized(name)) out.push(analyzeComponent(name, file, decl));
195
+ } else if (decl.type === "VariableDeclaration") {
196
+ for (const d of asNodes(decl.declarations)) {
197
+ const id = asNode(d.id);
198
+ const init = asNode(d.init);
199
+ const name = id && typeof id.name === "string" ? id.name : "";
200
+ if (name && isCapitalized(name) && isComponentFn(init)) out.push(analyzeComponent(name, file, init as AstNode));
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+
206
+ export interface ExtractOpts {
207
+ // .vue/.svelte/.astro: `ast` (when supplied) is the file's <script>/frontmatter
208
+ // block only, parsed just for its imports/re-exports — never a component
209
+ // definition (SFCs don't declare themselves as a JS function). Synthesizes ONE
210
+ // self component def (PascalCase basename) so cross-file resolution AND capture
211
+ // coverage see the SFC, same as a JSX component would be seen.
212
+ sfc?: boolean;
213
+ }
214
+
215
+ export function extractGraphNode(ast: AstFile | null, doc: Doc, file: string, opts: ExtractOpts = {}): FileGraphNode {
216
+ const posix = toPosix(file);
217
+ const imports: ImportRec[] = [];
218
+ const starReexports: string[] = [];
219
+ const components = new Map<string, ComponentDef>();
220
+
221
+ // Top-level `const X = "literal"` string bindings, so a dynamic id={X} (common for
222
+ // shared modal/dialog ids) can be resolved to its literal and counted as a defined id
223
+ // — otherwise a cross-file aria-controls="X" reference looks dangling (false NC).
224
+ const constStrings = new Map<string, string>();
225
+ if (ast) {
226
+ for (const stmt of asNodes((asNode(ast.program) ?? ast).body)) {
227
+ const vd = stmt.type === "VariableDeclaration" ? stmt : stmt.type === "ExportNamedDeclaration" ? asNode(stmt.declaration) : undefined;
228
+ if (!vd || vd.type !== "VariableDeclaration") continue;
229
+ for (const d of asNodes(vd.declarations)) {
230
+ const idn = asNode(d.id);
231
+ const init = asNode(d.init);
232
+ if (idn?.type === "Identifier" && typeof idn.name === "string" && init?.type === "StringLiteral" && typeof init.value === "string") {
233
+ constStrings.set(idn.name, init.value);
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ // ids + html-lang come from the Doc (works for HTML and JSX alike).
240
+ const definesIds: string[] = [];
241
+ let providesHtmlLang = false;
242
+ for (const el of doc.elements) {
243
+ const id = el.attribs.id;
244
+ if (id && !id.startsWith("{")) definesIds.push(id);
245
+ else if (id) {
246
+ // id={X} → resolve X via a top-level const string binding (conservative).
247
+ const m = /^\{\s*([A-Za-z_$][\w$]*)\s*\}$/.exec(id);
248
+ const resolved = m ? constStrings.get(m[1] as string) : undefined;
249
+ if (resolved) definesIds.push(resolved);
250
+ }
251
+ if (el.tag === "html" && hasAttr(el, "lang") && (attr(el, "lang") ?? "") !== "") providesHtmlLang = true;
252
+ }
253
+
254
+ const opaqueComponents = doc.opaqueComponents ?? [];
255
+ const finish = (): FileGraphNode => {
256
+ if (opts.sfc) {
257
+ // The SFC IS the component (no self-declaring JS function to find). `hasControl:
258
+ // true` puts it in the capture-coverage universe unconditionally: its template is
259
+ // never source-analysed for a real control (that would need a Vue/Svelte/Astro
260
+ // template parser, out of scope here), so — like every other preliminary
261
+ // SFC-source verdict — it stays a claimed blind spot until a rendered capture
262
+ // proves otherwise, rather than silently invisible to coverage.
263
+ const name = pascalCaseBasename(posix);
264
+ if (!components.has(name)) {
265
+ const def: ComponentDef = {
266
+ name,
267
+ file: posix,
268
+ line: 1,
269
+ col: 1,
270
+ hasControl: true,
271
+ rendersIconOnlyControl: false,
272
+ acceptsName: false,
273
+ controlHasName: false,
274
+ spreadsProps: false,
275
+ forwardsAria: false,
276
+ };
277
+ components.set(name, def);
278
+ if (!components.has("default")) components.set("default", def);
279
+ }
280
+ }
281
+ return { file: posix, imports, components, definesIds, providesHtmlLang, starReexports, opaqueComponents };
282
+ };
283
+ if (!ast) return finish();
284
+
285
+ const body = asNodes((asNode(ast.program) ?? ast).body);
286
+ const register = (def: ComponentDef): void => {
287
+ if (!components.has(def.name)) components.set(def.name, def);
288
+ };
289
+
290
+ for (const stmt of body) {
291
+ if (stmt.type === "ImportDeclaration") {
292
+ const source = asNode(stmt.source);
293
+ const spec = source && typeof source.value === "string" ? source.value : "";
294
+ for (const s of asNodes(stmt.specifiers)) {
295
+ const local = asNode(s.local);
296
+ const localName = local && typeof local.name === "string" ? local.name : "";
297
+ if (!localName) continue;
298
+ if (s.type === "ImportDefaultSpecifier") imports.push({ source: spec, local: localName, imported: "default" });
299
+ else if (s.type === "ImportNamespaceSpecifier") imports.push({ source: spec, local: localName, imported: "*" });
300
+ else if (s.type === "ImportSpecifier") {
301
+ const imp = asNode(s.imported);
302
+ const impName = imp && typeof imp.name === "string" ? imp.name : localName;
303
+ imports.push({ source: spec, local: localName, imported: impName });
304
+ }
305
+ }
306
+ } else if (stmt.type === "FunctionDeclaration" || stmt.type === "VariableDeclaration") {
307
+ for (const def of declComponents(stmt, posix)) register(def);
308
+ } else if (stmt.type === "ExportNamedDeclaration") {
309
+ const decl = asNode(stmt.declaration);
310
+ if (decl) for (const def of declComponents(decl, posix)) register(def);
311
+ const src = asNode(stmt.source);
312
+ const reExportSpec = src && typeof src.value === "string" ? src.value : undefined;
313
+ for (const s of asNodes(stmt.specifiers)) {
314
+ const localN = asNode(s.local);
315
+ const exportedN = asNode(s.exported);
316
+ const localName = localN && typeof localN.name === "string" ? localN.name : "";
317
+ const exportedName = exportedN && typeof exportedN.name === "string" ? exportedN.name : "";
318
+ if (reExportSpec) {
319
+ // Barrel re-export: `export { Button } from "./Button"` (optionally aliased).
320
+ // Modeled as an import-like binding under the EXPORTED name — the same shape
321
+ // `resolveUsage` already follows for a real import — so a barrel consumer's
322
+ // `import { Button } from "./components"` resolves one more hop to the real
323
+ // definition, however deep the barrel chain (see resolveUsage's recursion).
324
+ if (exportedName) imports.push({ source: reExportSpec, local: exportedName, imported: localName || exportedName });
325
+ continue;
326
+ }
327
+ // export { Foo as default } / export { Foo } — alias names to existing defs.
328
+ const def = components.get(localName);
329
+ if (def && exportedName && !components.has(exportedName)) components.set(exportedName, def);
330
+ }
331
+ } else if (stmt.type === "ExportAllDeclaration") {
332
+ // `export * from "./x"` — a wildcard barrel. We can't enumerate the re-exported names
333
+ // statically, so record the source; resolveUsage follows each star source when a name
334
+ // is not bound directly (see graph.ts). `export * as NS from …` is a namespace re-export,
335
+ // not a flat wildcard, so it is skipped here.
336
+ const src = asNode(stmt.source);
337
+ const spec = src && typeof src.value === "string" ? src.value : "";
338
+ if (spec && !asNode(stmt.exported)) starReexports.push(spec);
339
+ } else if (stmt.type === "ExportDefaultDeclaration") {
340
+ const decl = asNode(stmt.declaration);
341
+ if (!decl) continue;
342
+ if (decl.type === "FunctionDeclaration") {
343
+ const id = asNode(decl.id);
344
+ const name = id && typeof id.name === "string" ? id.name : "default";
345
+ const def = analyzeComponent(name === "default" ? "default" : name, posix, decl);
346
+ register(def);
347
+ if (!components.has("default")) components.set("default", def);
348
+ } else if (decl.type === "ArrowFunctionExpression" || decl.type === "FunctionExpression") {
349
+ const def = analyzeComponent("default", posix, decl);
350
+ if (!components.has("default")) components.set("default", def);
351
+ } else if (decl.type === "Identifier") {
352
+ const ref = typeof decl.name === "string" ? components.get(decl.name) : undefined;
353
+ if (ref && !components.has("default")) components.set("default", ref);
354
+ }
355
+ }
356
+ }
357
+
358
+ return finish();
359
+ }
@@ -0,0 +1,73 @@
1
+ // Resolve an import specifier to a discovered file, the way a bundler would but
2
+ // only against the set of files the audit already walked (no extra fs probing —
3
+ // keeps the graph bounded and deterministic). RELATIVE specifiers resolve directly;
4
+ // ALIAS specifiers ("@/…") resolve through the tsconfig-paths map (src/graph/tsconfig.ts);
5
+ // bare node_modules specifiers ("react") stay out of scope and return null.
6
+ import { dirname, join } from "node:path";
7
+ import { toPosix } from "../glob.js";
8
+ import type { AliasMap } from "./tsconfig.js";
9
+
10
+ // Try the specifier verbatim, then with each source extension, then as a
11
+ // directory index. Order matches typical TS/bundler resolution.
12
+ //
13
+ // No .vue/.svelte/.astro/.html/.htm here: the vendored codeindex engine's own
14
+ // candidate probe (JS_EXT_PROBES) covers SFC/HTML extensions since v2.10.0 —
15
+ // verified via engine-resolve.ts's makeSpecResolver, which always tries the
16
+ // engine first (see tests/golden-graph.test.ts and tests/graph-sfc.test.ts).
17
+ // This file is the fallback for whatever the engine still doesn't cover.
18
+ const EXT_ORDER = [".tsx", ".jsx", ".ts", ".js"];
19
+
20
+ function candidates(base: string): string[] {
21
+ const out = [base];
22
+ // An ESM/NodeNext specifier writes the OUTPUT extension ("./Icon.js") even though the
23
+ // source is "./Icon.ts"/".tsx"; try the base with a trailing js-family ext swapped for
24
+ // each source ext, before the plain append/index candidates.
25
+ const jsExt = /\.(?:js|jsx|mjs|cjs)$/.exec(base);
26
+ if (jsExt) {
27
+ const stripped = base.slice(0, -jsExt[0].length);
28
+ for (const e of EXT_ORDER) out.push(stripped + e);
29
+ }
30
+ for (const e of EXT_ORDER) out.push(base + e);
31
+ for (const e of EXT_ORDER) out.push(join(base, `index${e}`));
32
+ return out;
33
+ }
34
+
35
+ function matchKnown(base: string, known: ReadonlySet<string>): string | null {
36
+ for (const c of candidates(base)) {
37
+ const key = toPosix(c);
38
+ if (known.has(key)) return key;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * @param fromFile the importing file (posix or native; normalised internally)
45
+ * @param spec the import specifier as written
46
+ * @param known set of POSIX-normalised paths of all discovered files
47
+ * @param aliases tsconfig-paths alias map (optional); enables "@/…" resolution
48
+ * @returns the matched known file (posix), or null if unresolved/out-of-scope
49
+ */
50
+ export function resolveSpecifier(fromFile: string, spec: string, known: ReadonlySet<string>, aliases?: AliasMap): string | null {
51
+ if (spec.startsWith(".")) {
52
+ return matchKnown(join(dirname(toPosix(fromFile)), spec), known);
53
+ }
54
+ // Alias specifier via tsconfig paths (e.g. "@/components/Icon").
55
+ if (aliases?.length) {
56
+ for (const rule of aliases) {
57
+ if (rule.wildcard) {
58
+ if (!spec.startsWith(rule.prefix)) continue;
59
+ const rest = spec.slice(rule.prefix.length);
60
+ for (const base of rule.bases) {
61
+ const hit = matchKnown(join(base, rest), known);
62
+ if (hit) return hit;
63
+ }
64
+ } else if (spec === rule.prefix) {
65
+ for (const base of rule.bases) {
66
+ const hit = matchKnown(base, known);
67
+ if (hit) return hit;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return null; // bare node_modules specifier — out of scope
73
+ }
@@ -0,0 +1,113 @@
1
+ // Read tsconfig.json `compilerOptions.baseUrl` + `paths` into a compact alias map so the
2
+ // graph resolver can follow alias imports like "@/components/Icon". Walks up from a start
3
+ // dir to the nearest tsconfig.json (bounded) and follows a single `extends` hop for
4
+ // baseUrl/paths defaults. No new dependency, no glob — explicit file reads only. Bare
5
+ // node_modules specifiers stay out of scope (the graph resolves only against discovered
6
+ // files). Bases are emitted RELATIVE to `cwd` so they match the discovered file paths
7
+ // (which are relative to the audit's working dir).
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { dirname, join, resolve, relative } from "node:path";
10
+ import { toPosix } from "../glob.js";
11
+
12
+ export interface AliasRule {
13
+ prefix: string; // pattern text before '*' (or the whole pattern when exact)
14
+ wildcard: boolean;
15
+ bases: string[]; // posix base paths (baseUrl-resolved, cwd-relative), target text before '*'
16
+ }
17
+ export type AliasMap = AliasRule[];
18
+
19
+ // Tolerate the // and /* */ comments and trailing commas common in tsconfig.json.
20
+ function readJsonish(path: string): Record<string, unknown> | null {
21
+ try {
22
+ const raw = readFileSync(path, "utf8")
23
+ .replace(/\/\*[\s\S]*?\*\//g, "")
24
+ .replace(/(^|[^:])\/\/[^\n]*/g, "$1")
25
+ .replace(/,(\s*[}\]])/g, "$1");
26
+ return JSON.parse(raw) as Record<string, unknown>;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function findTsconfig(startDir: string): string | null {
33
+ let dir = resolve(startDir);
34
+ for (let i = 0; i < 30; i++) {
35
+ const p = join(dir, "tsconfig.json");
36
+ if (existsSync(p)) return p;
37
+ const parent = dirname(dir);
38
+ if (parent === dir) break;
39
+ dir = parent;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** The nearest tsconfig.json above `startDir` plus its transitive relative
45
+ * `extends` chain (absolute paths, existing files only, cycle-guarded). The
46
+ * engine's resolve context only sees files it is handed — the graph's file set
47
+ * never contains a tsconfig — so the whole chain is injected explicitly (the
48
+ * engine follows `extends` itself, but only through files present in its set).
49
+ * Bare package specifiers (e.g. "@tsconfig/strictest") stay out, as before. */
50
+ export function tsconfigChain(startDir: string): string[] {
51
+ const first = findTsconfig(startDir);
52
+ if (!first) return [];
53
+ const out: string[] = [];
54
+ const seen = new Set<string>();
55
+ const queue = [resolve(first)];
56
+ while (queue.length) {
57
+ const p = queue.shift() as string;
58
+ if (seen.has(p)) continue;
59
+ seen.add(p);
60
+ if (!existsSync(p)) continue;
61
+ out.push(p);
62
+ const cfg = readJsonish(p);
63
+ const exts = Array.isArray(cfg?.extends) ? cfg.extends : typeof cfg?.extends === "string" ? [cfg.extends] : [];
64
+ const dir = dirname(p);
65
+ for (const e of exts) {
66
+ if (typeof e !== "string") continue;
67
+ // Mirror the engine's `extends` candidates: "<e>" when it names a .json
68
+ // file, else "<e>.json" then "<e>/tsconfig.json".
69
+ const cands = e.endsWith(".json") ? [resolve(dir, e)] : [resolve(dir, `${e}.json`), resolve(dir, e, "tsconfig.json")];
70
+ const hit = cands.find((c) => existsSync(c));
71
+ if (hit) queue.push(hit);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** Build the alias map from the nearest tsconfig.json above `startDir` (or empty). */
78
+ export function readTsAliases(startDir: string, cwd: string = process.cwd()): AliasMap {
79
+ const tsconfigPath = findTsconfig(startDir);
80
+ if (!tsconfigPath) return [];
81
+ const root = dirname(tsconfigPath);
82
+ const cfg = readJsonish(tsconfigPath);
83
+ if (!cfg) return [];
84
+ let co = (cfg.compilerOptions ?? {}) as Record<string, unknown>;
85
+ // `extends` may be a string or (TS 5+) an array, merged in order with the child winning.
86
+ // Package specifiers (e.g. "@tsconfig/strictest") aren't node-resolved here — they fall
87
+ // through to a missing path and are skipped (documented bound; relative bases are honored).
88
+ const exts = Array.isArray(cfg.extends) ? cfg.extends : typeof cfg.extends === "string" ? [cfg.extends] : [];
89
+ for (const e of exts) {
90
+ if (typeof e !== "string") continue;
91
+ const ext = e.endsWith(".json") ? e : `${e}.json`;
92
+ const base = readJsonish(resolve(root, ext));
93
+ if (base?.compilerOptions) co = { ...(base.compilerOptions as Record<string, unknown>), ...co };
94
+ }
95
+ const baseUrl = typeof co.baseUrl === "string" ? co.baseUrl : ".";
96
+ const baseAbs = resolve(root, baseUrl);
97
+ const paths = (co.paths ?? {}) as Record<string, unknown>;
98
+ const out: AliasMap = [];
99
+ for (const [pattern, targetsRaw] of Object.entries(paths)) {
100
+ if (!Array.isArray(targetsRaw)) continue;
101
+ const targets = targetsRaw.filter((t): t is string => typeof t === "string");
102
+ if (!targets.length) continue;
103
+ const wildcard = pattern.includes("*");
104
+ const prefix = wildcard ? pattern.slice(0, pattern.indexOf("*")) : pattern;
105
+ const bases = targets.map((t) => {
106
+ const star = t.indexOf("*");
107
+ const tp = wildcard && star >= 0 ? t.slice(0, star) : t;
108
+ return toPosix(relative(cwd, join(baseAbs, tp))) || ".";
109
+ });
110
+ out.push({ prefix, wildcard, bases });
111
+ }
112
+ return out;
113
+ }
@@ -0,0 +1,119 @@
1
+ // Content-level grounding — re-open the CITED source and confirm the citation's content
2
+ // is actually there, instead of trusting that a `file:line` merely parses. This is the
3
+ // family-wide "resolvable-but-not-actually-supported" hole: a verdict that says
4
+ // `supported` while pointing at a line whose content changed (or never existed) must
5
+ // fail the gate, not sail through. Snippet match is whitespace-normalized; a snippet
6
+ // found in the file but OUTSIDE the cited-line window is accepted but reported as
7
+ // `moved` (the escape hatch for code that drifted since the audit).
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+
11
+ export interface GroundingInput {
12
+ file: string;
13
+ line: number;
14
+ snippet?: string;
15
+ selector?: string;
16
+ }
17
+
18
+ export interface GroundingVerdict {
19
+ ok: boolean;
20
+ moved: boolean;
21
+ issue?: string;
22
+ }
23
+
24
+ export interface GroundingSummary {
25
+ grounded: number;
26
+ moved: number;
27
+ failed: number;
28
+ issues: string[];
29
+ }
30
+
31
+ /** Lines on each side of the cited line the snippet may sit in and still count as "at
32
+ * the citation" (report line ↔ element line drift across renderers is expected). */
33
+ const WINDOW = 10;
34
+
35
+ const norm = (s: string) => s.replace(/\s+/g, " ").trim();
36
+
37
+ /** Streams/aggregates have no on-disk source to re-open. */
38
+ const isUnresolvable = (file: string) => !file || file === "-" || file === "<stdin>" || file === "stdin";
39
+
40
+ /** A markup token a selector implies. `ci` = case-INSENSITIVE (HTML tag & attribute
41
+ * names, which the HTML spec treats case-insensitively — `<IMG>` ≡ `<img>`); `cs` =
42
+ * case-SENSITIVE (CSS ids & classes, which ARE case-sensitive). `text` is already
43
+ * lowercased for ci probes so they match a lowercased haystack. */
44
+ interface Probe {
45
+ text: string;
46
+ ci: boolean;
47
+ }
48
+
49
+ /** Tokens a CSS selector implies about the markup: tags probe as `<tag` (case-insensitive),
50
+ * attribute names as bare names (case-insensitive), ids/classes as bare names (case-SENSITIVE).
51
+ * Combinators and pseudo-classes are ignored — this is a fuzzy anchor, not a DOM query. */
52
+ function selectorProbes(selector: string): Probe[] {
53
+ const probes: Probe[] = [];
54
+ for (const simple of selector.split(/[\s>+~,]+/)) {
55
+ if (!simple || simple === "—") continue;
56
+ const tag = /^([a-zA-Z][\w-]*)/.exec(simple);
57
+ if (tag) probes.push({ text: `<${tag[1]!.toLowerCase()}`, ci: true }); // HTML tag name
58
+ for (const m of simple.matchAll(/[#.]([\w-]+)/g)) probes.push({ text: m[1]!, ci: false }); // CSS id/class — case-sensitive
59
+ for (const m of simple.matchAll(/\[([\w-]+)/g)) probes.push({ text: m[1]!.toLowerCase(), ci: true }); // HTML attribute name
60
+ }
61
+ return probes;
62
+ }
63
+
64
+ export function groundFinding(g: GroundingInput, opts: { cwd?: string } = {}): GroundingVerdict {
65
+ if (isUnresolvable(g.file)) return { ok: true, moved: false };
66
+ const path = resolve(opts.cwd ?? process.cwd(), g.file);
67
+ if (!existsSync(path)) return { ok: false, moved: false, issue: `cited file not found: ${g.file}` };
68
+ let text: string;
69
+ try {
70
+ text = readFileSync(path, "utf8");
71
+ } catch {
72
+ return { ok: false, moved: false, issue: `cited file unreadable: ${g.file}` };
73
+ }
74
+ const lines = text.split("\n");
75
+ if (g.line > lines.length) return { ok: false, moved: false, issue: `cited line out of range: ${g.file}:${g.line} (${lines.length} lines)` };
76
+
77
+ // line < 1 = "no line anchor" (e.g. a dynamic finding not yet resolved to source):
78
+ // the whole file is the window, so a hit can never be classified as `moved`.
79
+ const hasLine = g.line >= 1;
80
+ const lo = hasLine ? Math.max(0, g.line - 1 - WINDOW) : 0;
81
+ const hi = hasLine ? Math.min(lines.length, g.line + WINDOW) : lines.length;
82
+ const windowText = norm(lines.slice(lo, hi).join("\n"));
83
+ const fileText = hasLine ? norm(text) : windowText;
84
+
85
+ const snippet = g.snippet ? norm(g.snippet) : "";
86
+ if (snippet) {
87
+ if (windowText.includes(snippet)) return { ok: true, moved: false };
88
+ if (fileText.includes(snippet)) return { ok: true, moved: true };
89
+ return { ok: false, moved: false, issue: `cited snippet not found in ${g.file}:${g.line}` };
90
+ }
91
+
92
+ const probes = selectorProbes(g.selector ?? "");
93
+ if (!probes.length) return { ok: true, moved: false }; // nothing beyond file+line is checkable
94
+ // ci probes (HTML tag/attribute names) test a lowercased haystack — `<img` matches `<IMG`
95
+ // — while cs probes (CSS ids/classes) test the verbatim haystack, so a wrong-case id
96
+ // (`#Foo` vs source `#foo`) is NOT loosened into a false match.
97
+ const hit = (hay: string) => {
98
+ const hayLc = hay.toLowerCase();
99
+ return probes.some((p) => (p.ci ? hayLc : hay).includes(p.text));
100
+ };
101
+ if (hit(windowText)) return { ok: true, moved: false };
102
+ if (hit(fileText)) return { ok: true, moved: true };
103
+ return { ok: false, moved: false, issue: `cited selector "${g.selector}" not found in ${g.file}:${g.line}` };
104
+ }
105
+
106
+ export function groundItems(items: GroundingInput[], opts: { cwd?: string } = {}): GroundingSummary {
107
+ const s: GroundingSummary = { grounded: 0, moved: 0, failed: 0, issues: [] };
108
+ for (const it of items) {
109
+ const r = groundFinding(it, opts);
110
+ if (r.ok) {
111
+ s.grounded++;
112
+ if (r.moved) s.moved++;
113
+ } else {
114
+ s.failed++;
115
+ if (r.issue) s.issues.push(r.issue);
116
+ }
117
+ }
118
+ return s;
119
+ }