@usefragments/core 1.0.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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/dist/chunk-243QYRUF.js +2145 -0
  3. package/dist/chunk-243QYRUF.js.map +1 -0
  4. package/dist/chunk-3LLRNCPX.js +111 -0
  5. package/dist/chunk-3LLRNCPX.js.map +1 -0
  6. package/dist/chunk-EIYNNS77.js +48 -0
  7. package/dist/chunk-EIYNNS77.js.map +1 -0
  8. package/dist/chunk-JNBFJ34I.js +326 -0
  9. package/dist/chunk-JNBFJ34I.js.map +1 -0
  10. package/dist/chunk-R3X7UBHU.js +228 -0
  11. package/dist/chunk-R3X7UBHU.js.map +1 -0
  12. package/dist/chunk-SH4KPIYH.js +393 -0
  13. package/dist/chunk-SH4KPIYH.js.map +1 -0
  14. package/dist/chunk-V4VQB57N.js +56 -0
  15. package/dist/chunk-V4VQB57N.js.map +1 -0
  16. package/dist/chunk-X34IA4LR.js +319 -0
  17. package/dist/chunk-X34IA4LR.js.map +1 -0
  18. package/dist/chunk-YF65VYRY.js +493 -0
  19. package/dist/chunk-YF65VYRY.js.map +1 -0
  20. package/dist/codes/index.d.ts +40 -0
  21. package/dist/codes/index.js +23 -0
  22. package/dist/codes/index.js.map +1 -0
  23. package/dist/compiled-types/index.d.ts +5 -0
  24. package/dist/compiled-types/index.js +1 -0
  25. package/dist/compiled-types/index.js.map +1 -0
  26. package/dist/generate/index.d.ts +47 -0
  27. package/dist/generate/index.js +11 -0
  28. package/dist/generate/index.js.map +1 -0
  29. package/dist/governance-BOa3KyiJ.d.ts +3279 -0
  30. package/dist/graph/index.d.ts +66 -0
  31. package/dist/graph/index.js +17 -0
  32. package/dist/graph/index.js.map +1 -0
  33. package/dist/index.d.ts +11388 -0
  34. package/dist/index.js +8711 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/package-identity.d.ts +42 -0
  37. package/dist/package-identity.js +23 -0
  38. package/dist/package-identity.js.map +1 -0
  39. package/dist/preview/index.d.ts +484 -0
  40. package/dist/preview/index.js +1129 -0
  41. package/dist/preview/index.js.map +1 -0
  42. package/dist/react-types.d.ts +22 -0
  43. package/dist/react-types.js +1 -0
  44. package/dist/react-types.js.map +1 -0
  45. package/dist/registry.d.ts +2367 -0
  46. package/dist/registry.js +62 -0
  47. package/dist/registry.js.map +1 -0
  48. package/dist/schemas/index.d.ts +2584 -0
  49. package/dist/schemas/index.js +55 -0
  50. package/dist/schemas/index.js.map +1 -0
  51. package/dist/severity.d.ts +18 -0
  52. package/dist/severity.js +27 -0
  53. package/dist/severity.js.map +1 -0
  54. package/dist/test-utils.d.ts +14 -0
  55. package/dist/test-utils.js +63 -0
  56. package/dist/test-utils.js.map +1 -0
  57. package/dist/topology/index.d.ts +48 -0
  58. package/dist/topology/index.js +7 -0
  59. package/dist/topology/index.js.map +1 -0
  60. package/dist/types-xJ2xyp_G.d.ts +119 -0
  61. package/package.json +120 -0
  62. package/src/__tests__/contract-parser.test.ts +614 -0
  63. package/src/__tests__/dtcg-generators.test.ts +439 -0
  64. package/src/__tests__/dtcg-parser.test.ts +884 -0
  65. package/src/__tests__/preview-runtime.test.tsx +111 -0
  66. package/src/__tests__/schema.test.ts +31 -0
  67. package/src/__tests__/severity.test.ts +34 -0
  68. package/src/__tests__/style-comparison.test.ts +662 -0
  69. package/src/__tests__/tokens-compat.test.ts +51 -0
  70. package/src/agent-format.test.ts +105 -0
  71. package/src/agent-format.ts +141 -0
  72. package/src/bundle.ts +149 -0
  73. package/src/codes/__tests__/codes.test.ts +73 -0
  74. package/src/codes/codes.ts +463 -0
  75. package/src/codes/index.ts +7 -0
  76. package/src/codes/source-finding-bridge.ts +113 -0
  77. package/src/compiled-types/index.ts +293 -0
  78. package/src/component-contract.ts +231 -0
  79. package/src/component-discovery.ts +80 -0
  80. package/src/composition.test.ts +262 -0
  81. package/src/composition.ts +318 -0
  82. package/src/config-paths.test.ts +78 -0
  83. package/src/config-paths.ts +47 -0
  84. package/src/config.ts +18 -0
  85. package/src/conform-prove.test.ts +274 -0
  86. package/src/conform-prove.ts +326 -0
  87. package/src/conform.ts +160 -0
  88. package/src/constants.ts +114 -0
  89. package/src/context.ts +2 -0
  90. package/src/contract/fixture.ts +110 -0
  91. package/src/contract/hash.test.ts +86 -0
  92. package/src/contract/hash.ts +195 -0
  93. package/src/contract/index.ts +43 -0
  94. package/src/contract/preimage.test.ts +254 -0
  95. package/src/contract/preimage.ts +365 -0
  96. package/src/contract/stamp.test.ts +100 -0
  97. package/src/contract/stamp.ts +105 -0
  98. package/src/contract-parser.ts +141 -0
  99. package/src/defineFragment.test.ts +257 -0
  100. package/src/defineFragment.ts +293 -0
  101. package/src/dtcg-generators.ts +324 -0
  102. package/src/dtcg-parser.ts +1 -0
  103. package/src/dtcg.ts +237 -0
  104. package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
  105. package/src/facts/builders.ts +835 -0
  106. package/src/facts/compile.ts +422 -0
  107. package/src/facts/fact-index.ts +394 -0
  108. package/src/facts/facts.test.ts +494 -0
  109. package/src/facts/ids.ts +125 -0
  110. package/src/facts/index.ts +119 -0
  111. package/src/facts/types.ts +614 -0
  112. package/src/figma.ts +263 -0
  113. package/src/fragment-types.ts +214 -0
  114. package/src/generate/generate.test.ts +594 -0
  115. package/src/generate/index.ts +438 -0
  116. package/src/governance-integrity.test.ts +186 -0
  117. package/src/governance-integrity.ts +282 -0
  118. package/src/governance.test.ts +300 -0
  119. package/src/governance.ts +605 -0
  120. package/src/governance.type-test.ts +75 -0
  121. package/src/graph/engine.ts +446 -0
  122. package/src/graph/graph.test.ts +746 -0
  123. package/src/graph/index.ts +32 -0
  124. package/src/graph/serialization.ts +55 -0
  125. package/src/graph/types.ts +178 -0
  126. package/src/index.ts +891 -0
  127. package/src/local-canonical.ts +102 -0
  128. package/src/mcp-snapshot.ts +222 -0
  129. package/src/package-identity-match.ts +33 -0
  130. package/src/package-identity.generated.ts +233 -0
  131. package/src/package-identity.test.ts +99 -0
  132. package/src/package-identity.ts +157 -0
  133. package/src/performance-presets.ts +142 -0
  134. package/src/preview/comparison-artifact.test.ts +228 -0
  135. package/src/preview/comparison-artifact.ts +181 -0
  136. package/src/preview/comparison-context.ts +93 -0
  137. package/src/preview/comparison-evidence.ts +112 -0
  138. package/src/preview/comparison-format.ts +21 -0
  139. package/src/preview/comparison-presentation.ts +187 -0
  140. package/src/preview/comparison-references.test.ts +430 -0
  141. package/src/preview/comparison-references.ts +72 -0
  142. package/src/preview/comparison-score.ts +59 -0
  143. package/src/preview/comparison-styles.ts +197 -0
  144. package/src/preview/comparison.test.ts +559 -0
  145. package/src/preview/comparison.ts +488 -0
  146. package/src/preview/index.ts +10 -0
  147. package/src/preview/types.ts +309 -0
  148. package/src/preview/validation.test.ts +290 -0
  149. package/src/preview/validation.ts +356 -0
  150. package/src/preview-runtime.tsx +144 -0
  151. package/src/raw-html-canonical.test.ts +41 -0
  152. package/src/raw-html-canonical.ts +179 -0
  153. package/src/react-types.ts +21 -0
  154. package/src/registry-install-plan.ts +344 -0
  155. package/src/registry.test.ts +245 -0
  156. package/src/registry.ts +369 -0
  157. package/src/rules/a11y-required-accessible-name.ts +93 -0
  158. package/src/rules/color-math.test.ts +72 -0
  159. package/src/rules/color-math.ts +192 -0
  160. package/src/rules/components-forbidden-prop-value.ts +108 -0
  161. package/src/rules/components-prefer-library.ts +943 -0
  162. package/src/rules/components-unknown-prop.ts +94 -0
  163. package/src/rules/composition-pattern-schema.ts +125 -0
  164. package/src/rules/composition-pattern.test.ts +320 -0
  165. package/src/rules/composition-pattern.ts +271 -0
  166. package/src/rules/emit-gate.test.ts +42 -0
  167. package/src/rules/emit-gate.ts +60 -0
  168. package/src/rules/finding.ts +54 -0
  169. package/src/rules/index.ts +235 -0
  170. package/src/rules/jsx-preferred-component.ts +68 -0
  171. package/src/rules/jsx-preferred-import-path.ts +67 -0
  172. package/src/rules/presets.ts +57 -0
  173. package/src/rules/props-invalid-value.ts +115 -0
  174. package/src/rules/rules.test.ts +3636 -0
  175. package/src/rules/spacing-resolution.ts +184 -0
  176. package/src/rules/styles-no-raw-color.ts +329 -0
  177. package/src/rules/styles-no-raw-dimensions.ts +357 -0
  178. package/src/rules/styles-no-raw-spacing.ts +236 -0
  179. package/src/rules/styles-no-raw-typography.ts +237 -0
  180. package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
  181. package/src/rules/tailwind-arbitrary-color.ts +93 -0
  182. package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
  183. package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
  184. package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
  185. package/src/rules/tailwind-forbidden-palette.ts +107 -0
  186. package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
  187. package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
  188. package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
  189. package/src/rules/tailwind-raw-color-via-token.ts +82 -0
  190. package/src/rules/tailwind-rule-utils.ts +106 -0
  191. package/src/rules/tailwind-unknown-class.test.ts +157 -0
  192. package/src/rules/tailwind-unknown-class.ts +121 -0
  193. package/src/rules/taxonomy.test.ts +238 -0
  194. package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
  195. package/src/rules/tiers.ts +111 -0
  196. package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
  197. package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
  198. package/src/rules/tokens-require-dual-fallback.ts +78 -0
  199. package/src/rules/types.ts +27 -0
  200. package/src/rules/utils.test.ts +110 -0
  201. package/src/rules/utils.ts +599 -0
  202. package/src/schema.ts +427 -0
  203. package/src/schemas/index.ts +275 -0
  204. package/src/schemas/schemas.test.ts +124 -0
  205. package/src/severity.ts +54 -0
  206. package/src/storyAdapter.test.ts +571 -0
  207. package/src/storyAdapter.ts +761 -0
  208. package/src/storyFilters.test.ts +350 -0
  209. package/src/storyFilters.ts +253 -0
  210. package/src/storybook-csf.ts +11 -0
  211. package/src/style-comparison.ts +423 -0
  212. package/src/test-utils.ts +68 -0
  213. package/src/token-parser.ts +2 -0
  214. package/src/token-types.ts +287 -0
  215. package/src/tokens/categories.ts +131 -0
  216. package/src/tokens/color.ts +193 -0
  217. package/src/tokens/design-token-parser.test.ts +159 -0
  218. package/src/tokens/design-token-parser.ts +402 -0
  219. package/src/tokens/dtcg.ts +428 -0
  220. package/src/tokens/index.ts +130 -0
  221. package/src/tokens/lookup.ts +19 -0
  222. package/src/tokens/scss.ts +171 -0
  223. package/src/tokens/tailwind-v4.ts +187 -0
  224. package/src/tokens/types.ts +43 -0
  225. package/src/topology/index.ts +9 -0
  226. package/src/topology/resolve-area.test.ts +174 -0
  227. package/src/topology/resolve-area.ts +215 -0
  228. package/src/types.ts +1057 -0
@@ -0,0 +1,943 @@
1
+ import type { CanonicalSource, GovernanceSeverity } from "../governance.js";
2
+ import type {
3
+ FactIndex,
4
+ UsageImportFact,
5
+ UsageNodeFact,
6
+ UsagePropResolvedFact,
7
+ UsageTextChildFact,
8
+ } from "../facts/index.js";
9
+ import {
10
+ isEnforceableHtmlEquivalent,
11
+ isRawHtmlAdvisoryTier,
12
+ resolveCanonicalForAriaRole,
13
+ resolveCanonicalForRawHtml,
14
+ type RawHtmlPrecisionTier,
15
+ } from "../raw-html-canonical.js";
16
+ import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
17
+
18
+ import { makeFinding } from "./finding.js";
19
+ import type { Finding } from "./types.js";
20
+ import { indexPropsByNodeId, indexTextChildrenByNodeId } from "./utils.js";
21
+
22
+ export const RULE_ID = "components/prefer-library";
23
+ export const RULE_VERSION = "1";
24
+
25
+ interface CanonicalMappingOption {
26
+ name: string;
27
+ canonical?: string;
28
+ htmlEquivalent?: string;
29
+ /**
30
+ * ARIA role of the canonical primitive this mapping points at (e.g. `status`
31
+ * for Toast, `alert` for Alert). Lets the rule key on element *semantics*
32
+ * rather than the raw tag name, so a bespoke `<div role="status">` re-impl is
33
+ * flagged structurally — not just literal tag/import matches.
34
+ */
35
+ ariaRole?: string;
36
+ /**
37
+ * Intrinsic input `type` of the canonical primitive this mapping points at
38
+ * (e.g. `checkbox` for Checkbox, `radio` for RadioGroup). Only meaningful when
39
+ * `htmlEquivalent === "input"`: it discriminates one raw `<input>` from
40
+ * another so a library can map `<input type="checkbox">` → Checkbox and
41
+ * `<input type="radio">` → RadioGroup independently. Unset → matches any
42
+ * `<input>` regardless of type (backward-compatible tag-level match).
43
+ */
44
+ htmlType?: string;
45
+ importPath?: string;
46
+ propMapping?: Array<{
47
+ rawProp: string;
48
+ canonicalProp: string;
49
+ valueMap?: Record<string, string>;
50
+ }>;
51
+ confidence?: number;
52
+ /**
53
+ * The `htmlEquivalent` was EXPLICITLY declared by the project (provenance:
54
+ * `localCanonical.resolves`), not inferred. Lets the enforceability gate fire
55
+ * a specific interactive tag (button/a) against any canonical NAME — the
56
+ * library-agnostic path — without re-opening the inferred-htmlEquivalent flood.
57
+ */
58
+ declaredHtmlEquivalent?: boolean;
59
+ }
60
+
61
+ function isExplicitTagDeclaration(mapping: CanonicalMappingOption): boolean {
62
+ return mapping.declaredHtmlEquivalent === true;
63
+ }
64
+
65
+ /** Advisory findings never exceed `warn`, whatever the project configured. */
66
+ function capAdvisorySeverity(configured: GovernanceSeverity | undefined): GovernanceSeverity {
67
+ const base = configured ?? "warn";
68
+ return base === "error" ? "warn" : base;
69
+ }
70
+
71
+ /**
72
+ * Advisory-precision matches (role-reimpl, interactive-nonsemantic, html-advisory,
73
+ * icon-only) are lower-confidence and never carry a deterministic fix, so they must
74
+ * never hard-block a write or hard-fail CI. Cap their severity at `warn` even when a
75
+ * project raises `components/prefer-library` to `error` — only the confident
76
+ * exact-html / input-type tiers inherit the escalated severity. (The blocking hook's
77
+ * `isDenyEligible` also refuses to deny `attributes.advisory` findings; this keeps
78
+ * the CI severity honest to the same confidence line.)
79
+ */
80
+ function severityForTier(
81
+ configured: GovernanceSeverity | undefined,
82
+ tier: RawHtmlPrecisionTier
83
+ ): GovernanceSeverity {
84
+ return isRawHtmlAdvisoryTier(tier) ? capAdvisorySeverity(configured) : (configured ?? "warn");
85
+ }
86
+
87
+ /**
88
+ * Generic structural / typography / primitive class names that routinely appear
89
+ * as plain layout classes even in fully-canonical codebases. Even when one equals
90
+ * a canonical component name (Box, Grid, Stack, List, Text…), a `<div class="box">`
91
+ * is almost never a hand-rolled component — so they are excluded from the
92
+ * className-reimplementation heuristic to keep it low-noise. Distinctive component
93
+ * names (card, badge, alert, dialog, tooltip, drawer, accordion, chip…) are NOT
94
+ * listed here, so they still flag.
95
+ */
96
+ const GENERIC_CLASSNAME_DENYLIST: ReadonlySet<string> = new Set([
97
+ "box",
98
+ "grid",
99
+ "stack",
100
+ "row",
101
+ "col",
102
+ "cols",
103
+ "column",
104
+ "columns",
105
+ "flex",
106
+ "wrap",
107
+ "wrapper",
108
+ "container",
109
+ "content",
110
+ "inner",
111
+ "outer",
112
+ "main",
113
+ "root",
114
+ "layout",
115
+ "panel",
116
+ "group",
117
+ "area",
118
+ "block",
119
+ "section",
120
+ "item",
121
+ "items",
122
+ "entry",
123
+ "list",
124
+ "header",
125
+ "heading",
126
+ "footer",
127
+ "nav",
128
+ "navbar",
129
+ "bar",
130
+ "toolbar",
131
+ "sidebar",
132
+ "aside",
133
+ "body",
134
+ "cell",
135
+ "title",
136
+ "subtitle",
137
+ "label",
138
+ "caption",
139
+ "text",
140
+ "copy",
141
+ "icon",
142
+ "img",
143
+ "image",
144
+ "media",
145
+ "link",
146
+ "links",
147
+ "button",
148
+ "btn",
149
+ "input",
150
+ "field",
151
+ "fields",
152
+ "form",
153
+ "control",
154
+ "controls",
155
+ "menu",
156
+ "dropdown",
157
+ "table",
158
+ "page",
159
+ "view",
160
+ "screen",
161
+ "overlay",
162
+ "backdrop",
163
+ "spacer",
164
+ "divider",
165
+ "separator",
166
+ "line",
167
+ "dot",
168
+ "tabs",
169
+ ]);
170
+
171
+ interface ReimplTarget {
172
+ name: string;
173
+ import?: string;
174
+ }
175
+
176
+ /** lowercased class token → the canonical component it likely reimplements. */
177
+ function canonicalReimplTargets(
178
+ sources: readonly CanonicalSource[],
179
+ mappings: readonly CanonicalMappingOption[]
180
+ ): Map<string, ReimplTarget> {
181
+ const out = new Map<string, ReimplTarget>();
182
+ const add = (name: string | undefined, importPath: string | undefined) => {
183
+ if (!name || !isIdentifier(name)) return;
184
+ const key = name.toLowerCase();
185
+ if (GENERIC_CLASSNAME_DENYLIST.has(key) || out.has(key)) return;
186
+ out.set(key, { name, ...(importPath ? { import: importPath } : {}) });
187
+ };
188
+ for (const mapping of mappings) add(mapping.name, mapping.importPath);
189
+ for (const source of sources) {
190
+ const label = sourceLabel(source);
191
+ for (const name of source.include ?? []) add(name, label);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /** The CSS-module member of a `styles.card` / `styles["card"]` snippet, or null. */
197
+ function classMemberFromSnippet(snippet: string): string | null {
198
+ const bracket = /\[\s*["'`]([A-Za-z0-9_-]+)["'`]\s*\]\s*$/.exec(snippet);
199
+ if (bracket) return bracket[1];
200
+ const dot = /\.([A-Za-z_$][\w$]*)\s*$/.exec(snippet);
201
+ return dot ? dot[1] : null;
202
+ }
203
+
204
+ /** Candidate class-name tokens per node: literal classes + resolvable module members. */
205
+ function indexClassTokensByNode(ix: FactIndex): Map<string, string[]> {
206
+ const out = new Map<string, string[]>();
207
+ const push = (nodeId: string, token: string | null) => {
208
+ if (!token) return;
209
+ const list = out.get(nodeId);
210
+ if (list) list.push(token);
211
+ else out.set(nodeId, [token]);
212
+ };
213
+ for (const fact of ix.byKind("classname_literal")) {
214
+ for (const cls of fact.classes) push(fact.nodeId, cls);
215
+ }
216
+ for (const fact of ix.byKind("classname_dynamic")) {
217
+ // Only the `styles.card` identifier form yields a clean member name; clsx()/
218
+ // template/spread forms are too fuzzy to key a suggestion on.
219
+ if (fact.reason !== "identifier") continue;
220
+ push(fact.nodeId, classMemberFromSnippet(fact.snippet));
221
+ }
222
+ return out;
223
+ }
224
+
225
+ interface ClassNameReimplMatch {
226
+ canonical: string;
227
+ matchedClass: string;
228
+ import?: string;
229
+ }
230
+
231
+ function findClassNameReimpl(
232
+ node: UsageNodeFact,
233
+ classTokensByNode: Map<string, string[]>,
234
+ targets: Map<string, ReimplTarget>
235
+ ): ClassNameReimplMatch | null {
236
+ const tokens = classTokensByNode.get(node.id);
237
+ if (!tokens) return null;
238
+ for (const token of tokens) {
239
+ const target = targets.get(token.toLowerCase());
240
+ if (target) {
241
+ return {
242
+ canonical: target.name,
243
+ matchedClass: token,
244
+ ...(target.import ? { import: target.import } : {}),
245
+ };
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+
251
+ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
252
+ const policy = ix.policy.ruleConfig(RULE_ID);
253
+ if (!policy?.enabled) return [];
254
+ const sources = canonicalSourcesFromPolicy(policy.options?.canonicalSources);
255
+ const mappings = canonicalMappingsFromPolicy(policy.options?.canonicalMappings);
256
+ if (sources.length === 0 && mappings.length === 0) return [];
257
+
258
+ const importsByFileAndLocal = indexImportsByFileAndLocal(ix);
259
+ const propsByNode = indexPropsByNodeId(ix);
260
+ const textByNode = indexTextChildrenByNodeId(ix);
261
+ const classTokensByNode = indexClassTokensByNode(ix);
262
+ const reimplTargets = canonicalReimplTargets(sources, mappings);
263
+ const findings: Finding[] = [];
264
+ const seenImportFixes = new Set<string>();
265
+
266
+ for (const node of ix.byKind("usage_node")) {
267
+ if (isCanonicalSourceImplementationFile(node.file, sources)) continue;
268
+ if (node.element.includes(".")) continue;
269
+ const props = propsByNode.get(node.id) ?? [];
270
+ const textChildren = textByNode.get(node.id) ?? [];
271
+ const imported = importsByFileAndLocal.get(node.file)?.get(node.element);
272
+ const nodeInputType =
273
+ node.element === "input" ? readStaticStringProp(props, "type") : undefined;
274
+ const mapped = findMapping(node, nodeInputType, mappings);
275
+ if (mapped && imported && isMappedCanonicalImport(imported, mapped)) continue;
276
+ if (imported && isCanonicalImport(imported, node.element, sources)) continue;
277
+
278
+ if (mapped) {
279
+ if (!shouldSuggestMapped(node, nodeInputType, imported, mapped)) continue;
280
+ const importPath = mapped.importPath;
281
+ const suggestedComponent = mapped.name;
282
+ const suggestedImport = importPath ?? "the canonical library";
283
+ const propMapping = mapped.propMapping ?? [];
284
+ const roleMatch = isRoleMatch(node, mapped);
285
+ const precisionTier: RawHtmlPrecisionTier = roleMatch ? "role-reimpl" : "exact-html";
286
+
287
+ if (
288
+ imported &&
289
+ importPath &&
290
+ !ownedImportsEqual(imported.source, importPath) &&
291
+ node.element === suggestedComponent
292
+ ) {
293
+ const key = `${imported.file}\0${imported.local}\0${imported.source}\0${importPath}`;
294
+ if (seenImportFixes.has(key)) continue;
295
+ seenImportFixes.add(key);
296
+ findings.push(
297
+ makeFinding({
298
+ ruleId: RULE_ID,
299
+ ruleVersion: RULE_VERSION,
300
+ severity: policy.severity ?? "warn",
301
+ message: `<${node.element}> should import from ${importPath}.`,
302
+ location: imported.location,
303
+ evidence: ix.evidence([node.id, imported.id, policy.id]),
304
+ fingerprintIdentity: {
305
+ file: imported.file,
306
+ local: imported.local,
307
+ from: imported.source,
308
+ to: importPath,
309
+ },
310
+ fix: {
311
+ kind: "replaceImport",
312
+ title: `Replace import path with "${importPath}"`,
313
+ from: imported.source,
314
+ to: importPath,
315
+ deterministic: true,
316
+ },
317
+ attributes: {
318
+ rawValue: imported.source,
319
+ suggestedComponent,
320
+ suggestedImport: importPath,
321
+ canonical: mapped.canonical,
322
+ confidence: mapped.confidence,
323
+ },
324
+ })
325
+ );
326
+ continue;
327
+ }
328
+
329
+ // No-op guard: the element already IS the suggested canonical (any
330
+ // wrong-import-path fix was handled just above). "Replace <X> with <X>"
331
+ // is not drift — skip it. This kills the icon self-mappings
332
+ // (e.g. <GitBranch> → <GitBranch>) that floated up as noise.
333
+ if (node.element === suggestedComponent) continue;
334
+
335
+ findings.push(
336
+ makeFinding({
337
+ ruleId: RULE_ID,
338
+ ruleVersion: RULE_VERSION,
339
+ severity: severityForTier(policy.severity, precisionTier),
340
+ message:
341
+ node.element === "button"
342
+ ? `Bespoke <button> has a library equivalent. Swap to <${suggestedComponent}> from ${suggestedImport}.`
343
+ : roleMatch
344
+ ? `Custom <${node.element} role="${node.role}"> reimplements a primitive. Use <${suggestedComponent}> from ${suggestedImport}.`
345
+ : `Bespoke <${node.element}> should use <${suggestedComponent}> from ${suggestedImport}.`,
346
+ location: node.location,
347
+ evidence: ix.evidence(
348
+ imported ? [node.id, imported.id, policy.id] : [node.id, policy.id]
349
+ ),
350
+ fingerprintIdentity: {
351
+ file: node.file,
352
+ nodePath: node.nodePath,
353
+ element: node.element,
354
+ suggestedComponent,
355
+ suggestedImport,
356
+ },
357
+ ...(!isRawHtmlAdvisoryTier(precisionTier) &&
358
+ isIdentifier(node.element) &&
359
+ isIdentifier(suggestedComponent)
360
+ ? {
361
+ fix: {
362
+ kind: "replaceComponent" as const,
363
+ title: `Replace <${node.element}> with <${suggestedComponent}>`,
364
+ from: node.element,
365
+ to: suggestedComponent,
366
+ deterministic: true,
367
+ },
368
+ }
369
+ : {}),
370
+ attributes: {
371
+ rawValue: node.element,
372
+ suggestedComponent,
373
+ suggestedImport,
374
+ canonical: mapped.canonical,
375
+ confidence: mapped.confidence,
376
+ propMapping,
377
+ precisionTier,
378
+ ...(roleMatch ? { matchedRole: node.role } : {}),
379
+ },
380
+ })
381
+ );
382
+ continue;
383
+ }
384
+
385
+ // #2 — className reimplementation heuristic (advisory-only). A plain
386
+ // <div>/<span> with no ARIA role and no handler whose class name matches a
387
+ // canonical component name is very likely a hand-rolled reimplementation
388
+ // (`<div className={styles.card}>` for a Card). class-name↔component is a
389
+ // naming-intent signal, not structural proof — so this is advisory, capped at
390
+ // warn, carries no auto-fix, and never blocks a write (see emit-gate).
391
+ if (
392
+ (node.element === "div" || node.element === "span") &&
393
+ node.role === undefined &&
394
+ node.interactive !== true &&
395
+ !hasEventHandler(props)
396
+ ) {
397
+ const reimpl = findClassNameReimpl(node, classTokensByNode, reimplTargets);
398
+ if (reimpl) {
399
+ const suggestedImport = reimpl.import ?? "the canonical library";
400
+ findings.push(
401
+ makeFinding({
402
+ ruleId: RULE_ID,
403
+ ruleVersion: RULE_VERSION,
404
+ severity: capAdvisorySeverity(policy.severity),
405
+ message: `<${node.element} class="${reimpl.matchedClass}"> looks like a hand-rolled <${reimpl.canonical}>. Use <${reimpl.canonical}> from ${suggestedImport} instead of restyling a <${node.element}>.`,
406
+ location: node.location,
407
+ evidence: ix.evidence([node.id, policy.id]),
408
+ fingerprintIdentity: {
409
+ file: node.file,
410
+ nodePath: node.nodePath,
411
+ element: node.element,
412
+ suggestedComponent: reimpl.canonical,
413
+ matchedClass: reimpl.matchedClass,
414
+ },
415
+ attributes: {
416
+ rawValue: node.element,
417
+ suggestedComponent: reimpl.canonical,
418
+ ...(reimpl.import ? { suggestedImport: reimpl.import } : {}),
419
+ precisionTier: "classname-reimpl",
420
+ matchedClass: reimpl.matchedClass,
421
+ advisory: true,
422
+ },
423
+ })
424
+ );
425
+ continue;
426
+ }
427
+ }
428
+
429
+ const builtIn = findBuiltInRawHtmlMatch(node, props, textChildren, nodeInputType);
430
+ const suggestedComponent =
431
+ builtIn?.canonical ?? (node.element === "button" ? "Button" : node.element);
432
+ const suggestion = findSuggestionSource(suggestedComponent, sources);
433
+ if (!suggestion) continue;
434
+ if (!builtIn && !shouldSuggest(node, imported)) continue;
435
+ // Directory canonical sources are typically derived without an `include`
436
+ // filter (cloud pushes `{ kind: "directory", path }` for every component
437
+ // folder), so `sourceIncludesComponent` matches EVERY name. Without the same
438
+ // guards #224 added to the mapped path, that turns every imported component
439
+ // — Phosphor icons included — into a no-op `<X> → <X>` finding, and lets a
440
+ // non-enforceable raw-tag match (`<button>` → a non-Button canonical) slip
441
+ // through. Hold the directory path to the semantic-only contract:
442
+ // (i) never a no-op (element already IS the suggestion),
443
+ // (ii) only raw lowercase HTML tags are targets (an already-capitalized
444
+ // component is not a prefer-library candidate for a directory source),
445
+ // (iii) only when the raw tag → canonical mapping is enforceable
446
+ // (isEnforceableHtmlEquivalent) — so `<button>` → Button fires but a
447
+ // disagreeing `<button>` → Chip does not.
448
+ // npm/registry sources are curated (specifier + usual include lists) and keep
449
+ // their import-redirect behavior, so this gate is directory-scoped only.
450
+ if (suggestion.kind === "directory") {
451
+ if (node.element === suggestedComponent) continue;
452
+ if (!builtIn) continue;
453
+ if (isIdentifier(node.element) && /^[A-Z]/.test(node.element)) continue;
454
+ if (
455
+ !isEnforceableHtmlEquivalent({
456
+ tag: node.element,
457
+ canonical: suggestedComponent,
458
+ inputType: nodeInputType,
459
+ })
460
+ ) {
461
+ continue;
462
+ }
463
+ }
464
+ const suggestedImport = sourceLabel(suggestion);
465
+ const precisionTier = builtIn?.tier ?? "exact-html";
466
+ const rawElement = formatRawElement(node, props);
467
+
468
+ findings.push(
469
+ makeFinding({
470
+ ruleId: RULE_ID,
471
+ ruleVersion: RULE_VERSION,
472
+ severity: severityForTier(policy.severity, precisionTier),
473
+ message: messageForBuiltInMatch({
474
+ node,
475
+ rawElement,
476
+ suggestedComponent,
477
+ suggestedImport,
478
+ precisionTier,
479
+ }),
480
+ location: node.location,
481
+ evidence: ix.evidence(imported ? [node.id, imported.id, policy.id] : [node.id, policy.id]),
482
+ fingerprintIdentity: {
483
+ file: node.file,
484
+ nodePath: node.nodePath,
485
+ element: node.element,
486
+ suggestedComponent,
487
+ suggestedImport,
488
+ },
489
+ ...(!isRawHtmlAdvisoryTier(precisionTier) &&
490
+ isIdentifier(node.element) &&
491
+ isIdentifier(suggestedComponent)
492
+ ? {
493
+ fix: {
494
+ kind: "replaceComponent" as const,
495
+ title: `Replace ${rawElement} with <${suggestedComponent}>`,
496
+ from: node.element,
497
+ to: suggestedComponent,
498
+ deterministic: true,
499
+ },
500
+ }
501
+ : {}),
502
+ attributes: {
503
+ rawValue: node.element,
504
+ suggestedComponent,
505
+ suggestedImport,
506
+ suggestedImportSourceKind: suggestion.kind,
507
+ precisionTier,
508
+ ...(builtIn?.matchedRole ? { matchedRole: builtIn.matchedRole } : {}),
509
+ ...(builtIn?.inputType ? { matchedInputType: builtIn.inputType } : {}),
510
+ ...(isRawHtmlAdvisoryTier(precisionTier) ? { advisory: true } : {}),
511
+ },
512
+ })
513
+ );
514
+ }
515
+
516
+ return findings;
517
+ }
518
+
519
+ interface BuiltInRawHtmlMatch {
520
+ canonical: string;
521
+ tier: RawHtmlPrecisionTier;
522
+ matchedRole?: string;
523
+ inputType?: string;
524
+ }
525
+
526
+ function findBuiltInRawHtmlMatch(
527
+ node: UsageNodeFact,
528
+ props: readonly UsagePropResolvedFact[],
529
+ textChildren: readonly UsageTextChildFact[],
530
+ inputType: string | undefined
531
+ ): BuiltInRawHtmlMatch | null {
532
+ if (isIconOnlyButton(node, props, textChildren)) {
533
+ return { canonical: "IconButton", tier: "icon-only" };
534
+ }
535
+
536
+ const rawMatch = resolveCanonicalForRawHtml(node.element, inputType);
537
+ if (rawMatch) {
538
+ return {
539
+ ...rawMatch,
540
+ ...(inputType ? { inputType } : {}),
541
+ };
542
+ }
543
+
544
+ if (node.role && isRoleReimplementationHost(node.element)) {
545
+ const roleMatch = resolveCanonicalForAriaRole(node.role);
546
+ if (roleMatch) {
547
+ return {
548
+ ...roleMatch,
549
+ matchedRole: node.role,
550
+ };
551
+ }
552
+ }
553
+
554
+ if (
555
+ (node.element === "div" || node.element === "span") &&
556
+ node.role === undefined &&
557
+ node.interactive === true &&
558
+ hasEventHandler(props)
559
+ ) {
560
+ return { canonical: "Button", tier: "interactive-nonsemantic" };
561
+ }
562
+
563
+ return null;
564
+ }
565
+
566
+ function isIconOnlyButton(
567
+ node: UsageNodeFact,
568
+ props: readonly UsagePropResolvedFact[],
569
+ textChildren: readonly UsageTextChildFact[]
570
+ ): boolean {
571
+ if (node.element !== "button") return false;
572
+ if (node.interactive !== true && !hasEventHandler(props)) return false;
573
+ if (!hasExplicitAriaName(props)) return false;
574
+ return !hasVisibleText(textChildren);
575
+ }
576
+
577
+ function isRoleReimplementationHost(element: string): boolean {
578
+ return element === "div" || element === "span" || element === "a";
579
+ }
580
+
581
+ function hasEventHandler(props: readonly UsagePropResolvedFact[]): boolean {
582
+ return props.some((prop) => /^on[A-Z]/.test(prop.prop));
583
+ }
584
+
585
+ function hasExplicitAriaName(props: readonly UsagePropResolvedFact[]): boolean {
586
+ return props.some(
587
+ (prop) =>
588
+ (prop.prop === "aria-label" || prop.prop === "aria-labelledby") &&
589
+ prop.resolution === "static" &&
590
+ typeof prop.value === "string" &&
591
+ prop.value.trim().length > 0
592
+ );
593
+ }
594
+
595
+ function hasVisibleText(textChildren: readonly UsageTextChildFact[]): boolean {
596
+ return textChildren.some((child) => child.text.trim().length > 0);
597
+ }
598
+
599
+ function readStaticStringProp(
600
+ props: readonly UsagePropResolvedFact[],
601
+ propName: string
602
+ ): string | undefined {
603
+ const prop = props.find((candidate) => candidate.prop === propName);
604
+ if (!prop || prop.resolution !== "static") return undefined;
605
+ return typeof prop.value === "string" ? prop.value : undefined;
606
+ }
607
+
608
+ function formatRawElement(node: UsageNodeFact, props: readonly UsagePropResolvedFact[]): string {
609
+ if (node.element === "input") {
610
+ const inputType = readStaticStringProp(props, "type");
611
+ if (inputType) return `<input type="${inputType}">`;
612
+ }
613
+ if (node.role) return `<${node.element} role="${node.role}">`;
614
+ return `<${node.element}>`;
615
+ }
616
+
617
+ function messageForBuiltInMatch({
618
+ node,
619
+ rawElement,
620
+ suggestedComponent,
621
+ suggestedImport,
622
+ precisionTier,
623
+ }: {
624
+ node: UsageNodeFact;
625
+ rawElement: string;
626
+ suggestedComponent: string;
627
+ suggestedImport: string;
628
+ precisionTier: RawHtmlPrecisionTier;
629
+ }) {
630
+ if (precisionTier === "icon-only") {
631
+ return `Icon-only ${rawElement} should use <${suggestedComponent}> from ${suggestedImport}.`;
632
+ }
633
+ if (precisionTier === "role-reimpl") {
634
+ return `Custom ${rawElement} reimplements a primitive. Use <${suggestedComponent}> from ${suggestedImport}.`;
635
+ }
636
+ if (precisionTier === "interactive-nonsemantic") {
637
+ return `Interactive <${node.element}> reimplements a Button. Use <${suggestedComponent}> from ${suggestedImport}.`;
638
+ }
639
+ if (node.element === "button") {
640
+ return `Bespoke <button> has a library equivalent. Swap to <Button> from ${suggestedImport}.`;
641
+ }
642
+ return `Bespoke ${rawElement} should use <${suggestedComponent}> from ${suggestedImport}.`;
643
+ }
644
+
645
+ function shouldSuggestMapped(
646
+ node: UsageNodeFact,
647
+ nodeInputType: string | undefined,
648
+ imported: UsageImportFact | undefined,
649
+ mapping: CanonicalMappingOption
650
+ ): boolean {
651
+ if (
652
+ sameHtmlTag(mapping.htmlEquivalent, node.element) &&
653
+ // Enforceability is checked against `mapping.name` — the component this rule
654
+ // actually EMITS as `suggestedComponent` — not `mapping.canonical`. For
655
+ // ambiguous/common tags (button, a) `isEnforceableHtmlEquivalent` requires
656
+ // agreement with the curated canonical, so a Chip whose `canonical: "Button"`
657
+ // but `name: "Chip"` no longer leaks `suggestedComponent: "Chip"` while the
658
+ // canonical reads "Button". `<button>` → Button (name === canonical) still
659
+ // fires; semantic tags (textarea → Textarea) are unaffected (always
660
+ // enforceable regardless of name).
661
+ isEnforceableHtmlEquivalent({
662
+ tag: node.element,
663
+ canonical: mapping.name,
664
+ inputType: nodeInputType,
665
+ explicit: isExplicitTagDeclaration(mapping),
666
+ })
667
+ ) {
668
+ return true;
669
+ }
670
+ if (node.role !== undefined && node.role === mapping.ariaRole) return true;
671
+ // Only a literal use of the component's own NAME (e.g. <Modal> for name
672
+ // "Modal") is a candidate here — resolved as an import-redirect / no-op by the
673
+ // caller. We deliberately do NOT match on `mapping.canonical` (the vocabulary
674
+ // id): when name === canonical this clause would be redundant (the htmlEquivalent
675
+ // / name paths already cover it), and when they diverge (name "Modal", canonical
676
+ // "Dialog") matching `<Dialog>` only ever produced a self-contradicting swap
677
+ // (rawValue === canonical, suggestion is the renamed wrapper) and let any common
678
+ // vocab id (Dialog/Tooltip/Card) hijack an unrelated third-party usage. Same
679
+ // divergence principle as the button→Chip fix (#229) — audit #1/#2.
680
+ if (node.element === mapping.name) {
681
+ return imported !== undefined;
682
+ }
683
+ return false;
684
+ }
685
+
686
+ function shouldSuggest(node: UsageNodeFact, imported: UsageImportFact | undefined): boolean {
687
+ if (node.element === "button") return true;
688
+ if (!/^[A-Z]/.test(node.element)) return false;
689
+ return imported !== undefined;
690
+ }
691
+
692
+ /**
693
+ * Case-INSENSITIVE equality for the `htmlEquivalent` ↔ raw-tag comparison.
694
+ * Raw HTML tag names are case-insensitive, but a catalog/author can store a
695
+ * mis-cased `htmlEquivalent` ("Button", "INPUT"). The enforceability gate
696
+ * already lowercases internally, but the OUTER comparison gated whether the
697
+ * gate ran at all — so a mis-cased equivalent silently made a valid mapping
698
+ * inert (audit #29). Component NAME matches stay case-sensitive; only the
699
+ * raw-tag side is normalized here.
700
+ */
701
+ function sameHtmlTag(htmlEquivalent: string | undefined, element: string): boolean {
702
+ return htmlEquivalent !== undefined && htmlEquivalent.toLowerCase() === element.toLowerCase();
703
+ }
704
+
705
+ function indexImportsByFileAndLocal(ix: FactIndex): Map<string, Map<string, UsageImportFact>> {
706
+ const out = new Map<string, Map<string, UsageImportFact>>();
707
+ for (const usage of ix.byKind("usage_import")) {
708
+ const importsForFile = out.get(usage.file) ?? new Map<string, UsageImportFact>();
709
+ importsForFile.set(usage.local, usage);
710
+ out.set(usage.file, importsForFile);
711
+ }
712
+ return out;
713
+ }
714
+
715
+ function canonicalSourcesFromPolicy(value: unknown): CanonicalSource[] {
716
+ if (!Array.isArray(value)) return [];
717
+ return value.filter(isCanonicalSource);
718
+ }
719
+
720
+ function canonicalMappingsFromPolicy(value: unknown): CanonicalMappingOption[] {
721
+ if (!Array.isArray(value)) return [];
722
+ return value.filter(isCanonicalMappingOption);
723
+ }
724
+
725
+ function isCanonicalMappingOption(value: unknown): value is CanonicalMappingOption {
726
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
727
+ const record = value as Record<string, unknown>;
728
+ if (typeof record.name !== "string" || !isIdentifier(record.name)) return false;
729
+ if (record.canonical !== undefined && typeof record.canonical !== "string") return false;
730
+ if (record.htmlEquivalent !== undefined && typeof record.htmlEquivalent !== "string") {
731
+ return false;
732
+ }
733
+ if (record.ariaRole !== undefined && typeof record.ariaRole !== "string") return false;
734
+ if (record.htmlType !== undefined && typeof record.htmlType !== "string") return false;
735
+ if (record.importPath !== undefined && typeof record.importPath !== "string") return false;
736
+ if (record.confidence !== undefined && typeof record.confidence !== "number") return false;
737
+ if (
738
+ record.declaredHtmlEquivalent !== undefined &&
739
+ typeof record.declaredHtmlEquivalent !== "boolean"
740
+ ) {
741
+ return false;
742
+ }
743
+ if (record.propMapping !== undefined) {
744
+ if (!Array.isArray(record.propMapping)) return false;
745
+ if (!record.propMapping.every(isPropMappingOption)) return false;
746
+ }
747
+ return true;
748
+ }
749
+
750
+ function isPropMappingOption(
751
+ value: unknown
752
+ ): value is NonNullable<CanonicalMappingOption["propMapping"]>[number] {
753
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
754
+ const record = value as Record<string, unknown>;
755
+ return (
756
+ typeof record.rawProp === "string" &&
757
+ typeof record.canonicalProp === "string" &&
758
+ (record.valueMap === undefined || isStringRecord(record.valueMap))
759
+ );
760
+ }
761
+
762
+ function isStringRecord(value: unknown): value is Record<string, string> {
763
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
764
+ return Object.values(value as Record<string, unknown>).every(
765
+ (entry) => typeof entry === "string"
766
+ );
767
+ }
768
+
769
+ function isCanonicalSource(value: unknown): value is CanonicalSource {
770
+ if (!value || typeof value !== "object") return false;
771
+ const record = value as Record<string, unknown>;
772
+ if (!hasValidComponentList(record.include)) return false;
773
+ if (!hasValidComponentList(record.exclude)) return false;
774
+ if (record.implementationPath !== undefined && typeof record.implementationPath !== "string") {
775
+ return false;
776
+ }
777
+ if (record.kind === "npm") return typeof record.specifier === "string";
778
+ if (record.kind === "directory") return typeof record.path === "string";
779
+ if (record.kind === "registry") {
780
+ return typeof record.registryId === "string" && typeof record.installPath === "string";
781
+ }
782
+ return false;
783
+ }
784
+
785
+ function hasValidComponentList(value: unknown): boolean {
786
+ return (
787
+ value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string"))
788
+ );
789
+ }
790
+
791
+ function findMapping(
792
+ node: UsageNodeFact,
793
+ nodeInputType: string | undefined,
794
+ mappings: readonly CanonicalMappingOption[]
795
+ ): CanonicalMappingOption | undefined {
796
+ // A type-specific input mapping wins over a generic tag-level one, so a
797
+ // library mapping `<input type="checkbox">` → Checkbox is never shadowed by a
798
+ // catch-all `<input>` → TextField entry. Normalized to lower-case to mirror
799
+ // the resolved `type` prop value the extractor records.
800
+ if (node.element === "input" && nodeInputType) {
801
+ const typed = mappings.find(
802
+ (mapping) =>
803
+ sameHtmlTag(mapping.htmlEquivalent, "input") &&
804
+ mapping.htmlType !== undefined &&
805
+ mapping.htmlType.toLowerCase() === nodeInputType.toLowerCase()
806
+ );
807
+ if (typed) return typed;
808
+ }
809
+
810
+ return mappings.find((mapping) => {
811
+ if (
812
+ sameHtmlTag(mapping.htmlEquivalent, node.element) &&
813
+ // Match enforceability against the EMITTED `mapping.name` (see
814
+ // shouldSuggestMapped): for ambiguous tags this requires the suggested
815
+ // component to BE the tag's curated canonical, so a disagreeing
816
+ // `<button>` → Chip mapping never matches in the first place.
817
+ isEnforceableHtmlEquivalent({
818
+ tag: node.element,
819
+ canonical: mapping.name,
820
+ inputType: nodeInputType,
821
+ explicit: isExplicitTagDeclaration(mapping),
822
+ })
823
+ ) {
824
+ // A type-constrained mapping only matches the matching input type; an
825
+ // unconstrained one matches any element with that tag (back-compat).
826
+ // Gated so an inferred `div`/`span` primitive (or a `button` → specialized
827
+ // component) never blanket-flags every container — see
828
+ // isEnforceableHtmlEquivalent.
829
+ return mapping.htmlType === undefined || matchesInputType(mapping, nodeInputType);
830
+ }
831
+ if (mapping.name === node.element) return true;
832
+ // Deliberately NOT matching on `mapping.canonical` (the vocabulary id): see
833
+ // shouldSuggestMapped. Selecting a mapping purely because the element name
834
+ // equals a canonical vocab id (Dialog/Tooltip/Card…) produced divergent swaps
835
+ // and cross-mapping hijacking (audit #1/#2). A real name match (above), an
836
+ // enforceable htmlEquivalent (above), or an ARIA-role match (below) remain.
837
+ // Semantic match: a bespoke element whose declared ARIA role equals the
838
+ // canonical primitive's role (e.g. `<div role="status">` → Toast). Keyed on
839
+ // normalized `usage_node.role`, so it is framework-agnostic.
840
+ return node.role !== undefined && mapping.ariaRole === node.role;
841
+ });
842
+ }
843
+
844
+ function matchesInputType(
845
+ mapping: CanonicalMappingOption,
846
+ nodeInputType: string | undefined
847
+ ): boolean {
848
+ if (mapping.htmlType === undefined) return true;
849
+ if (nodeInputType === undefined) return false;
850
+ return mapping.htmlType.toLowerCase() === nodeInputType.toLowerCase();
851
+ }
852
+
853
+ /** True when `mapped` was selected for `node` purely on an ARIA-role match. */
854
+ function isRoleMatch(node: UsageNodeFact, mapping: CanonicalMappingOption): boolean {
855
+ return (
856
+ node.role !== undefined &&
857
+ node.role === mapping.ariaRole &&
858
+ node.element !== mapping.htmlEquivalent &&
859
+ node.element !== mapping.name &&
860
+ node.element !== mapping.canonical
861
+ );
862
+ }
863
+
864
+ function isMappedCanonicalImport(usage: UsageImportFact, mapping: CanonicalMappingOption): boolean {
865
+ return Boolean(mapping.importPath && ownedImportsEqual(usage.source, mapping.importPath));
866
+ }
867
+
868
+ function isCanonicalImport(
869
+ usage: UsageImportFact,
870
+ componentName: string,
871
+ sources: readonly CanonicalSource[]
872
+ ): boolean {
873
+ return sources.some((source) => {
874
+ if (!sourceIncludesComponent(source, componentName)) return false;
875
+ if (source.exclude?.includes(componentName)) return false;
876
+ if (source.kind === "npm") {
877
+ return ownedImportMatchesRoot(usage.source, source.specifier);
878
+ }
879
+ if (source.kind === "registry") {
880
+ if (!source.importPath) return false;
881
+ return ownedImportMatchesRoot(usage.source, source.importPath);
882
+ }
883
+ return normalizePath(usage.source).startsWith(normalizePath(source.path));
884
+ });
885
+ }
886
+
887
+ function findSuggestionSource(
888
+ componentName: string,
889
+ sources: readonly CanonicalSource[]
890
+ ): CanonicalSource | undefined {
891
+ return sources.find(
892
+ (source) =>
893
+ sourceIncludesComponent(source, componentName) && !source.exclude?.includes(componentName)
894
+ );
895
+ }
896
+
897
+ function sourceIncludesComponent(source: CanonicalSource, componentName: string): boolean {
898
+ if (source.include) return source.include.includes(componentName);
899
+ // Package-like canonical sources must be backed by an explicit export
900
+ // inventory before they can suggest arbitrary component names. Without this,
901
+ // a bare `{ kind: "npm", specifier: "@acme/ui" }` treats every imported
902
+ // PascalCase symbol as canonical and emits self-referential/noisy swaps.
903
+ if (source.kind === "npm" || source.kind === "registry") return false;
904
+ return true;
905
+ }
906
+
907
+ function sourceLabel(source: CanonicalSource): string {
908
+ if (source.kind === "npm") return source.specifier;
909
+ if (source.kind === "registry") return source.importPath ?? source.installPath;
910
+ return source.path;
911
+ }
912
+
913
+ function isCanonicalSourceImplementationFile(
914
+ file: string,
915
+ sources: readonly CanonicalSource[]
916
+ ): boolean {
917
+ const normalizedFile = normalizePath(file);
918
+ return sources.some((source) => {
919
+ if (source.kind === "npm" && source.implementationPath) {
920
+ return isPathInSource(normalizedFile, source.implementationPath);
921
+ }
922
+ if (source.kind === "directory") {
923
+ return isPathInSource(normalizedFile, source.path);
924
+ }
925
+ if (source.kind === "registry") {
926
+ return isPathInSource(normalizedFile, source.installPath);
927
+ }
928
+ return false;
929
+ });
930
+ }
931
+
932
+ function isPathInSource(file: string, sourcePath: string): boolean {
933
+ const normalizedSource = normalizePath(sourcePath).replace(/\/$/, "");
934
+ return file === normalizedSource || file.startsWith(`${normalizedSource}/`);
935
+ }
936
+
937
+ function normalizePath(path: string): string {
938
+ return path.replace(/\\/g, "/").replace(/^\.\//, "");
939
+ }
940
+
941
+ function isIdentifier(value: string): boolean {
942
+ return /^[A-Za-z_$][\w$]*$/.test(value);
943
+ }