@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.
- package/LICENSE +21 -0
- package/dist/chunk-243QYRUF.js +2145 -0
- package/dist/chunk-243QYRUF.js.map +1 -0
- package/dist/chunk-3LLRNCPX.js +111 -0
- package/dist/chunk-3LLRNCPX.js.map +1 -0
- package/dist/chunk-EIYNNS77.js +48 -0
- package/dist/chunk-EIYNNS77.js.map +1 -0
- package/dist/chunk-JNBFJ34I.js +326 -0
- package/dist/chunk-JNBFJ34I.js.map +1 -0
- package/dist/chunk-R3X7UBHU.js +228 -0
- package/dist/chunk-R3X7UBHU.js.map +1 -0
- package/dist/chunk-SH4KPIYH.js +393 -0
- package/dist/chunk-SH4KPIYH.js.map +1 -0
- package/dist/chunk-V4VQB57N.js +56 -0
- package/dist/chunk-V4VQB57N.js.map +1 -0
- package/dist/chunk-X34IA4LR.js +319 -0
- package/dist/chunk-X34IA4LR.js.map +1 -0
- package/dist/chunk-YF65VYRY.js +493 -0
- package/dist/chunk-YF65VYRY.js.map +1 -0
- package/dist/codes/index.d.ts +40 -0
- package/dist/codes/index.js +23 -0
- package/dist/codes/index.js.map +1 -0
- package/dist/compiled-types/index.d.ts +5 -0
- package/dist/compiled-types/index.js +1 -0
- package/dist/compiled-types/index.js.map +1 -0
- package/dist/generate/index.d.ts +47 -0
- package/dist/generate/index.js +11 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/governance-BOa3KyiJ.d.ts +3279 -0
- package/dist/graph/index.d.ts +66 -0
- package/dist/graph/index.js +17 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +11388 -0
- package/dist/index.js +8711 -0
- package/dist/index.js.map +1 -0
- package/dist/package-identity.d.ts +42 -0
- package/dist/package-identity.js +23 -0
- package/dist/package-identity.js.map +1 -0
- package/dist/preview/index.d.ts +484 -0
- package/dist/preview/index.js +1129 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/react-types.d.ts +22 -0
- package/dist/react-types.js +1 -0
- package/dist/react-types.js.map +1 -0
- package/dist/registry.d.ts +2367 -0
- package/dist/registry.js +62 -0
- package/dist/registry.js.map +1 -0
- package/dist/schemas/index.d.ts +2584 -0
- package/dist/schemas/index.js +55 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/severity.d.ts +18 -0
- package/dist/severity.js +27 -0
- package/dist/severity.js.map +1 -0
- package/dist/test-utils.d.ts +14 -0
- package/dist/test-utils.js +63 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/topology/index.d.ts +48 -0
- package/dist/topology/index.js +7 -0
- package/dist/topology/index.js.map +1 -0
- package/dist/types-xJ2xyp_G.d.ts +119 -0
- package/package.json +120 -0
- package/src/__tests__/contract-parser.test.ts +614 -0
- package/src/__tests__/dtcg-generators.test.ts +439 -0
- package/src/__tests__/dtcg-parser.test.ts +884 -0
- package/src/__tests__/preview-runtime.test.tsx +111 -0
- package/src/__tests__/schema.test.ts +31 -0
- package/src/__tests__/severity.test.ts +34 -0
- package/src/__tests__/style-comparison.test.ts +662 -0
- package/src/__tests__/tokens-compat.test.ts +51 -0
- package/src/agent-format.test.ts +105 -0
- package/src/agent-format.ts +141 -0
- package/src/bundle.ts +149 -0
- package/src/codes/__tests__/codes.test.ts +73 -0
- package/src/codes/codes.ts +463 -0
- package/src/codes/index.ts +7 -0
- package/src/codes/source-finding-bridge.ts +113 -0
- package/src/compiled-types/index.ts +293 -0
- package/src/component-contract.ts +231 -0
- package/src/component-discovery.ts +80 -0
- package/src/composition.test.ts +262 -0
- package/src/composition.ts +318 -0
- package/src/config-paths.test.ts +78 -0
- package/src/config-paths.ts +47 -0
- package/src/config.ts +18 -0
- package/src/conform-prove.test.ts +274 -0
- package/src/conform-prove.ts +326 -0
- package/src/conform.ts +160 -0
- package/src/constants.ts +114 -0
- package/src/context.ts +2 -0
- package/src/contract/fixture.ts +110 -0
- package/src/contract/hash.test.ts +86 -0
- package/src/contract/hash.ts +195 -0
- package/src/contract/index.ts +43 -0
- package/src/contract/preimage.test.ts +254 -0
- package/src/contract/preimage.ts +365 -0
- package/src/contract/stamp.test.ts +100 -0
- package/src/contract/stamp.ts +105 -0
- package/src/contract-parser.ts +141 -0
- package/src/defineFragment.test.ts +257 -0
- package/src/defineFragment.ts +293 -0
- package/src/dtcg-generators.ts +324 -0
- package/src/dtcg-parser.ts +1 -0
- package/src/dtcg.ts +237 -0
- package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
- package/src/facts/builders.ts +835 -0
- package/src/facts/compile.ts +422 -0
- package/src/facts/fact-index.ts +394 -0
- package/src/facts/facts.test.ts +494 -0
- package/src/facts/ids.ts +125 -0
- package/src/facts/index.ts +119 -0
- package/src/facts/types.ts +614 -0
- package/src/figma.ts +263 -0
- package/src/fragment-types.ts +214 -0
- package/src/generate/generate.test.ts +594 -0
- package/src/generate/index.ts +438 -0
- package/src/governance-integrity.test.ts +186 -0
- package/src/governance-integrity.ts +282 -0
- package/src/governance.test.ts +300 -0
- package/src/governance.ts +605 -0
- package/src/governance.type-test.ts +75 -0
- package/src/graph/engine.ts +446 -0
- package/src/graph/graph.test.ts +746 -0
- package/src/graph/index.ts +32 -0
- package/src/graph/serialization.ts +55 -0
- package/src/graph/types.ts +178 -0
- package/src/index.ts +891 -0
- package/src/local-canonical.ts +102 -0
- package/src/mcp-snapshot.ts +222 -0
- package/src/package-identity-match.ts +33 -0
- package/src/package-identity.generated.ts +233 -0
- package/src/package-identity.test.ts +99 -0
- package/src/package-identity.ts +157 -0
- package/src/performance-presets.ts +142 -0
- package/src/preview/comparison-artifact.test.ts +228 -0
- package/src/preview/comparison-artifact.ts +181 -0
- package/src/preview/comparison-context.ts +93 -0
- package/src/preview/comparison-evidence.ts +112 -0
- package/src/preview/comparison-format.ts +21 -0
- package/src/preview/comparison-presentation.ts +187 -0
- package/src/preview/comparison-references.test.ts +430 -0
- package/src/preview/comparison-references.ts +72 -0
- package/src/preview/comparison-score.ts +59 -0
- package/src/preview/comparison-styles.ts +197 -0
- package/src/preview/comparison.test.ts +559 -0
- package/src/preview/comparison.ts +488 -0
- package/src/preview/index.ts +10 -0
- package/src/preview/types.ts +309 -0
- package/src/preview/validation.test.ts +290 -0
- package/src/preview/validation.ts +356 -0
- package/src/preview-runtime.tsx +144 -0
- package/src/raw-html-canonical.test.ts +41 -0
- package/src/raw-html-canonical.ts +179 -0
- package/src/react-types.ts +21 -0
- package/src/registry-install-plan.ts +344 -0
- package/src/registry.test.ts +245 -0
- package/src/registry.ts +369 -0
- package/src/rules/a11y-required-accessible-name.ts +93 -0
- package/src/rules/color-math.test.ts +72 -0
- package/src/rules/color-math.ts +192 -0
- package/src/rules/components-forbidden-prop-value.ts +108 -0
- package/src/rules/components-prefer-library.ts +943 -0
- package/src/rules/components-unknown-prop.ts +94 -0
- package/src/rules/composition-pattern-schema.ts +125 -0
- package/src/rules/composition-pattern.test.ts +320 -0
- package/src/rules/composition-pattern.ts +271 -0
- package/src/rules/emit-gate.test.ts +42 -0
- package/src/rules/emit-gate.ts +60 -0
- package/src/rules/finding.ts +54 -0
- package/src/rules/index.ts +235 -0
- package/src/rules/jsx-preferred-component.ts +68 -0
- package/src/rules/jsx-preferred-import-path.ts +67 -0
- package/src/rules/presets.ts +57 -0
- package/src/rules/props-invalid-value.ts +115 -0
- package/src/rules/rules.test.ts +3636 -0
- package/src/rules/spacing-resolution.ts +184 -0
- package/src/rules/styles-no-raw-color.ts +329 -0
- package/src/rules/styles-no-raw-dimensions.ts +357 -0
- package/src/rules/styles-no-raw-spacing.ts +236 -0
- package/src/rules/styles-no-raw-typography.ts +237 -0
- package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
- package/src/rules/tailwind-arbitrary-color.ts +93 -0
- package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
- package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
- package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
- package/src/rules/tailwind-forbidden-palette.ts +107 -0
- package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
- package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
- package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
- package/src/rules/tailwind-raw-color-via-token.ts +82 -0
- package/src/rules/tailwind-rule-utils.ts +106 -0
- package/src/rules/tailwind-unknown-class.test.ts +157 -0
- package/src/rules/tailwind-unknown-class.ts +121 -0
- package/src/rules/taxonomy.test.ts +238 -0
- package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
- package/src/rules/tiers.ts +111 -0
- package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
- package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
- package/src/rules/tokens-require-dual-fallback.ts +78 -0
- package/src/rules/types.ts +27 -0
- package/src/rules/utils.test.ts +110 -0
- package/src/rules/utils.ts +599 -0
- package/src/schema.ts +427 -0
- package/src/schemas/index.ts +275 -0
- package/src/schemas/schemas.test.ts +124 -0
- package/src/severity.ts +54 -0
- package/src/storyAdapter.test.ts +571 -0
- package/src/storyAdapter.ts +761 -0
- package/src/storyFilters.test.ts +350 -0
- package/src/storyFilters.ts +253 -0
- package/src/storybook-csf.ts +11 -0
- package/src/style-comparison.ts +423 -0
- package/src/test-utils.ts +68 -0
- package/src/token-parser.ts +2 -0
- package/src/token-types.ts +287 -0
- package/src/tokens/categories.ts +131 -0
- package/src/tokens/color.ts +193 -0
- package/src/tokens/design-token-parser.test.ts +159 -0
- package/src/tokens/design-token-parser.ts +402 -0
- package/src/tokens/dtcg.ts +428 -0
- package/src/tokens/index.ts +130 -0
- package/src/tokens/lookup.ts +19 -0
- package/src/tokens/scss.ts +171 -0
- package/src/tokens/tailwind-v4.ts +187 -0
- package/src/tokens/types.ts +43 -0
- package/src/topology/index.ts +9 -0
- package/src/topology/resolve-area.test.ts +174 -0
- package/src/topology/resolve-area.ts +215 -0
- 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
|
+
}
|