@usefragments/core 1.3.0 → 1.5.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 (44) hide show
  1. package/dist/{chunk-MLRDNSSA.js → chunk-HXGE3O2F.js} +39 -1
  2. package/dist/chunk-HXGE3O2F.js.map +1 -0
  3. package/dist/codes/index.d.ts +1 -1
  4. package/dist/codes/index.js +1 -1
  5. package/dist/compiled-types/index.d.ts +1 -1
  6. package/dist/generate/index.d.ts +1 -1
  7. package/dist/{governance-DYSirwJu.d.ts → governance-BLsyk56o.d.ts} +21 -0
  8. package/dist/index.d.ts +324 -82
  9. package/dist/index.js +507 -120
  10. package/dist/index.js.map +1 -1
  11. package/dist/react-types.d.ts +1 -1
  12. package/dist/test-utils.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/src/codes/codes.ts +9 -0
  15. package/src/conform.ts +5 -0
  16. package/src/constants.ts +3 -0
  17. package/src/facts/builders.ts +46 -2
  18. package/src/facts/facts.test.ts +30 -0
  19. package/src/facts/index.ts +6 -0
  20. package/src/facts/types.ts +75 -2
  21. package/src/governance.test.ts +26 -0
  22. package/src/identity/classify.test.ts +185 -0
  23. package/src/identity/classify.ts +257 -0
  24. package/src/identity/component-key.test.ts +32 -0
  25. package/src/identity/component-key.ts +35 -0
  26. package/src/identity/usage.ts +39 -0
  27. package/src/index.ts +25 -0
  28. package/src/rules/components-prefer-library.test.ts +196 -0
  29. package/src/rules/components-prefer-library.ts +68 -1
  30. package/src/rules/components-shadow-component.test.ts +145 -0
  31. package/src/rules/components-shadow-component.ts +81 -0
  32. package/src/rules/fix-availability.ts +1 -0
  33. package/src/rules/index.ts +7 -0
  34. package/src/rules/styles-no-raw-color.test.ts +213 -0
  35. package/src/rules/styles-no-raw-color.ts +125 -0
  36. package/src/rules/taxonomy.test.ts +16 -1
  37. package/src/rules/tiers.ts +7 -5
  38. package/src/schema.ts +21 -2
  39. package/src/token-types.ts +12 -0
  40. package/src/tokens/categories.ts +54 -24
  41. package/src/tokens/scss.test.ts +94 -0
  42. package/src/tokens/scss.ts +11 -4
  43. package/src/types.ts +23 -0
  44. package/dist/chunk-MLRDNSSA.js.map +0 -1
@@ -0,0 +1,257 @@
1
+ import type { RawHtmlPrecisionTier } from "../raw-html-canonical.js";
2
+
3
+ export type IdentityState =
4
+ | "canonical"
5
+ | "variant"
6
+ | "shadow"
7
+ | "composite"
8
+ | "external"
9
+ | "unresolved";
10
+
11
+ export type IdentityConfidence = "confirmed" | "likely" | "review";
12
+
13
+ export interface CanonicalIdentityReference {
14
+ componentKey: string;
15
+ role: string;
16
+ }
17
+
18
+ export interface LocalIdentityReference {
19
+ componentKey: string;
20
+ exportName: string;
21
+ }
22
+
23
+ export interface ExternalIdentityReference {
24
+ localName: string;
25
+ source: string;
26
+ }
27
+
28
+ export interface ResolvedIntrinsicIdentityRoot {
29
+ kind: "intrinsic";
30
+ tag: string;
31
+ hasRole: boolean;
32
+ interactive: boolean;
33
+ role?: string;
34
+ inputType?: string;
35
+ precisionTier?: RawHtmlPrecisionTier;
36
+ canonicalTargets: readonly CanonicalIdentityReference[];
37
+ }
38
+
39
+ export type ResolvedIdentityRenderRoot =
40
+ | {
41
+ kind: "canonical";
42
+ localName: string;
43
+ target: CanonicalIdentityReference;
44
+ }
45
+ | ResolvedIntrinsicIdentityRoot
46
+ | {
47
+ kind: "external";
48
+ localName: string;
49
+ source: string;
50
+ }
51
+ | {
52
+ kind: "mixed";
53
+ canonicalTargets: readonly CanonicalIdentityReference[];
54
+ localTargets: readonly LocalIdentityReference[];
55
+ externalTargets: readonly ExternalIdentityReference[];
56
+ intrinsics: readonly ResolvedIntrinsicIdentityRoot[];
57
+ unresolvedNames: readonly string[];
58
+ }
59
+ | { kind: "unresolved"; reason: string };
60
+
61
+ export interface IdentityDecision {
62
+ componentKey: string;
63
+ decision: "sanctioned-wrapper" | "rejected-wrapper";
64
+ canonicalTarget?: string;
65
+ decisionId?: string;
66
+ }
67
+
68
+ export interface ClassifyIdentityInput {
69
+ definition: {
70
+ componentKey: string;
71
+ exportName: string;
72
+ canonical: boolean;
73
+ };
74
+ renderRoot: ResolvedIdentityRenderRoot;
75
+ propSurfaceOverlap?: number;
76
+ stylingOwnership?: "visual-role" | "layout-only" | "none" | "unknown";
77
+ usageCounts?: {
78
+ importers?: number;
79
+ callSites?: number;
80
+ nestedElements?: number;
81
+ staticTextChildren?: number;
82
+ };
83
+ decisions?: readonly IdentityDecision[];
84
+ nameSimilarityTargets?: readonly string[];
85
+ }
86
+
87
+ export interface IdentityClassification {
88
+ state: IdentityState;
89
+ confidence: IdentityConfidence;
90
+ canonicalTarget?: string;
91
+ }
92
+
93
+ /**
94
+ * Classify already-resolved identity evidence. AST and filesystem concerns stay
95
+ * in the extractor; this function is deterministic and browser-safe.
96
+ */
97
+ export function classifyIdentity(input: ClassifyIdentityInput): IdentityClassification {
98
+ const { definition, renderRoot } = input;
99
+ if (definition.canonical) return { state: "canonical", confidence: "confirmed" };
100
+
101
+ const decision = input.decisions?.find(
102
+ (candidate) => candidate.componentKey === definition.componentKey
103
+ );
104
+ if (decision?.decision === "sanctioned-wrapper") {
105
+ return withTarget("variant", "confirmed", decision.canonicalTarget ?? rootTarget(input));
106
+ }
107
+ if (decision?.decision === "rejected-wrapper") {
108
+ return withTarget("shadow", "confirmed", decision.canonicalTarget ?? shadowTarget(input));
109
+ }
110
+
111
+ if (renderRoot.kind === "canonical") {
112
+ if (
113
+ (input.usageCounts?.nestedElements ?? 0) > 0 ||
114
+ (input.usageCounts?.staticTextChildren ?? 0) > 0
115
+ ) {
116
+ return withTarget("composite", "confirmed", renderRoot.target.componentKey);
117
+ }
118
+ const confidence = strongOverlap(input.propSurfaceOverlap) ? "likely" : "review";
119
+ return withTarget("variant", confidence, renderRoot.target.componentKey);
120
+ }
121
+
122
+ if (renderRoot.kind === "intrinsic") {
123
+ return classifyIntrinsic(input, renderRoot);
124
+ }
125
+
126
+ if (renderRoot.kind === "mixed") {
127
+ if (renderRoot.canonicalTargets.length > 0) {
128
+ const target = bestCanonicalReference(
129
+ definition.exportName,
130
+ renderRoot.canonicalTargets
131
+ )?.componentKey;
132
+ const fullyResolved =
133
+ renderRoot.externalTargets.length === 0 && renderRoot.unresolvedNames.length === 0;
134
+ return withTarget("composite", fullyResolved ? "confirmed" : "likely", target);
135
+ }
136
+
137
+ if (
138
+ renderRoot.externalTargets.length > 0 &&
139
+ renderRoot.localTargets.length === 0 &&
140
+ renderRoot.unresolvedNames.length === 0
141
+ ) {
142
+ return { state: "external", confidence: "likely" };
143
+ }
144
+
145
+ return withTarget("unresolved", "review", rootTarget(input));
146
+ }
147
+
148
+ if (renderRoot.kind === "external") {
149
+ return { state: "external", confidence: "confirmed" };
150
+ }
151
+
152
+ return withTarget("unresolved", "review", rootTarget(input));
153
+ }
154
+
155
+ function classifyIntrinsic(
156
+ input: ClassifyIdentityInput,
157
+ root: ResolvedIntrinsicIdentityRoot
158
+ ): IdentityClassification {
159
+ const target = bestCanonicalReference(input.definition.exportName, root.canonicalTargets);
160
+ if (!target || (!root.interactive && !root.hasRole)) {
161
+ return withTarget("unresolved", "review", rootTarget(input));
162
+ }
163
+
164
+ let confidence: IdentityConfidence =
165
+ root.precisionTier === "exact-html" || root.precisionTier === "role-reimpl"
166
+ ? "confirmed"
167
+ : "likely";
168
+ if (
169
+ confidence === "likely" &&
170
+ input.stylingOwnership !== "layout-only" &&
171
+ (strongOverlap(input.propSurfaceOverlap) || input.stylingOwnership === "visual-role")
172
+ ) {
173
+ confidence = "confirmed";
174
+ }
175
+
176
+ return { state: "shadow", confidence, canonicalTarget: target.role };
177
+ }
178
+
179
+ function rootTarget(input: ClassifyIdentityInput): string | undefined {
180
+ const { renderRoot } = input;
181
+ if (renderRoot.kind === "canonical") return renderRoot.target.componentKey;
182
+ if (renderRoot.kind === "intrinsic") {
183
+ return bestCanonicalReference(input.definition.exportName, renderRoot.canonicalTargets)?.role;
184
+ }
185
+ if (renderRoot.kind === "mixed") {
186
+ return bestCanonicalReference(input.definition.exportName, renderRoot.canonicalTargets)
187
+ ?.componentKey;
188
+ }
189
+ return bestMessageTarget(input.definition.exportName, input.nameSimilarityTargets ?? []);
190
+ }
191
+
192
+ function shadowTarget(input: ClassifyIdentityInput): string | undefined {
193
+ const { renderRoot } = input;
194
+ if (renderRoot.kind === "canonical") return renderRoot.target.role;
195
+ if (renderRoot.kind === "intrinsic") {
196
+ return bestCanonicalReference(input.definition.exportName, renderRoot.canonicalTargets)?.role;
197
+ }
198
+ if (renderRoot.kind === "mixed") {
199
+ return bestCanonicalReference(input.definition.exportName, renderRoot.canonicalTargets)?.role;
200
+ }
201
+ return bestMessageTarget(input.definition.exportName, input.nameSimilarityTargets ?? []);
202
+ }
203
+
204
+ function bestCanonicalReference(
205
+ definitionName: string,
206
+ candidates: readonly CanonicalIdentityReference[]
207
+ ): CanonicalIdentityReference | undefined {
208
+ return [...candidates].sort(
209
+ (left, right) =>
210
+ nameSimilarityScore(definitionName, right.role) -
211
+ nameSimilarityScore(definitionName, left.role) ||
212
+ left.componentKey.localeCompare(right.componentKey)
213
+ )[0];
214
+ }
215
+
216
+ function bestMessageTarget(
217
+ definitionName: string,
218
+ candidates: readonly string[]
219
+ ): string | undefined {
220
+ return [...candidates].sort(
221
+ (left, right) =>
222
+ nameSimilarityScore(definitionName, right) - nameSimilarityScore(definitionName, left) ||
223
+ left.localeCompare(right)
224
+ )[0];
225
+ }
226
+
227
+ function nameSimilarityScore(definitionName: string, target: string): number {
228
+ const definition = normalizeName(definitionName);
229
+ const candidate = normalizeName(target);
230
+ if (!candidate) return 0;
231
+ if (definition === candidate) return 3;
232
+ if (definition.endsWith(candidate) || definition.startsWith(candidate)) return 2;
233
+ if (definition.includes(candidate) || candidate.includes(definition)) return 1;
234
+ return 0;
235
+ }
236
+
237
+ function normalizeName(value: string): string {
238
+ return (
239
+ value
240
+ .split(/[.#/]/)
241
+ .at(-1)
242
+ ?.replace(/[^a-z0-9]/gi, "")
243
+ .toLowerCase() ?? ""
244
+ );
245
+ }
246
+
247
+ function strongOverlap(overlap: number | undefined): boolean {
248
+ return overlap !== undefined && overlap >= 0.7;
249
+ }
250
+
251
+ function withTarget(
252
+ state: IdentityState,
253
+ confidence: IdentityConfidence,
254
+ canonicalTarget: string | undefined
255
+ ): IdentityClassification {
256
+ return canonicalTarget ? { state, confidence, canonicalTarget } : { state, confidence };
257
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildComponentKey } from "./component-key.js";
4
+
5
+ describe("buildComponentKey", () => {
6
+ it("normalizes clone-independent repository paths", () => {
7
+ expect(
8
+ buildComponentKey({
9
+ repoRelativeFile: "./packages/ui/src/../src/Button.tsx",
10
+ exportName: " Button ",
11
+ })
12
+ ).toBe("packages/ui/src/Button.tsx#Button");
13
+ });
14
+
15
+ it("normalizes Windows separators to the same key", () => {
16
+ const posix = buildComponentKey({
17
+ repoRelativeFile: "packages/ui/src/Button.tsx",
18
+ exportName: "Button",
19
+ });
20
+ const windows = buildComponentKey({
21
+ repoRelativeFile: ".\\packages\\ui\\src\\Button.tsx",
22
+ exportName: "Button",
23
+ });
24
+
25
+ expect(windows).toBe(posix);
26
+ });
27
+
28
+ it("rejects empty identity parts", () => {
29
+ expect(() => buildComponentKey({ repoRelativeFile: "", exportName: "Button" })).toThrow();
30
+ expect(() => buildComponentKey({ repoRelativeFile: "Button.tsx", exportName: " " })).toThrow();
31
+ });
32
+ });
@@ -0,0 +1,35 @@
1
+ export interface ComponentKeyInput {
2
+ repoRelativeFile: string;
3
+ exportName: string;
4
+ }
5
+
6
+ /**
7
+ * Mint a clone- and platform-portable identity for one component definition.
8
+ */
9
+ export function buildComponentKey({ repoRelativeFile, exportName }: ComponentKeyInput): string {
10
+ const file = normalizeRepoRelativeFile(repoRelativeFile);
11
+ const name = exportName.trim();
12
+ if (!file) throw new Error("repoRelativeFile must not be empty");
13
+ if (!name) throw new Error("exportName must not be empty");
14
+ return `${file}#${name}`;
15
+ }
16
+
17
+ function normalizeRepoRelativeFile(file: string): string {
18
+ const slashNormalized = file
19
+ .trim()
20
+ .replace(/\\/g, "/")
21
+ .replace(/^\.\/+/, "");
22
+ const absolute = slashNormalized.startsWith("/");
23
+ const parts: string[] = [];
24
+
25
+ for (const part of slashNormalized.split("/")) {
26
+ if (!part || part === ".") continue;
27
+ if (part === ".." && parts.length > 0 && parts.at(-1) !== "..") {
28
+ parts.pop();
29
+ continue;
30
+ }
31
+ parts.push(part);
32
+ }
33
+
34
+ return `${absolute ? "/" : ""}${parts.join("/")}`;
35
+ }
@@ -0,0 +1,39 @@
1
+ import type { FactIndex, UsageNodeFact } from "../facts/index.js";
2
+
3
+ export interface ComponentIdentityUsage {
4
+ usageCount: number;
5
+ /** Number of distinct source files containing a usage. */
6
+ blastRadius: number;
7
+ nodes: UsageNodeFact[];
8
+ }
9
+
10
+ /** Join resolved component usages to their usage nodes once for rules and DX surfaces. */
11
+ export function indexComponentIdentityUsage(
12
+ ix: FactIndex
13
+ ): ReadonlyMap<string, ComponentIdentityUsage> {
14
+ const nodesById = new Map(ix.byKind("usage_node").map((node) => [node.id, node]));
15
+ const nodesByComponent = new Map<string, Map<string, UsageNodeFact>>();
16
+
17
+ for (const usage of ix.byKind("usage_component")) {
18
+ const node = nodesById.get(usage.nodeId);
19
+ if (!node) continue;
20
+ const componentKey = String(usage.componentId);
21
+ const nodes = nodesByComponent.get(componentKey) ?? new Map<string, UsageNodeFact>();
22
+ nodes.set(node.id, node);
23
+ nodesByComponent.set(componentKey, nodes);
24
+ }
25
+
26
+ return new Map(
27
+ [...nodesByComponent].map(([componentKey, nodesByIdForComponent]) => {
28
+ const nodes = [...nodesByIdForComponent.values()];
29
+ return [
30
+ componentKey,
31
+ {
32
+ usageCount: nodes.length,
33
+ blastRadius: new Set(nodes.map((node) => node.file)).size,
34
+ nodes,
35
+ },
36
+ ];
37
+ })
38
+ );
39
+ }
package/src/index.ts CHANGED
@@ -627,6 +627,7 @@ export {
627
627
  ruleComponentsUnknownProp,
628
628
  ruleComponentsForbiddenPropValue,
629
629
  ruleComponentsPreferLibrary,
630
+ ruleComponentsShadowComponent,
630
631
  rulePropsInvalidValue,
631
632
  ruleJsxPreferredImportPath,
632
633
  ruleJsxPreferredComponent,
@@ -644,6 +645,10 @@ export {
644
645
  ruleTokensRequireDualFallback,
645
646
  ruleA11yRequiredAccessibleName,
646
647
  } from "./rules/index.js";
648
+ export {
649
+ indexComponentIdentityUsage,
650
+ type ComponentIdentityUsage,
651
+ } from "./identity/usage.js";
647
652
  export { nearestSignedScaleValue } from "./rules/utils.js";
648
653
  export {
649
654
  buildSpacingTokenLookup,
@@ -726,6 +731,8 @@ export {
726
731
  matchesGlob,
727
732
  makeComponentMetadataFact,
728
733
  makeComponentCapabilityFact,
734
+ makeComponentDefinitionFact,
735
+ makeComponentIdentityFact,
729
736
  makeCanonicalCandidateFact,
730
737
  makeCanonicalMappingFact,
731
738
  makeCanonicalReplacementFact,
@@ -836,6 +843,20 @@ export {
836
843
  type RawHtmlCanonicalMatch,
837
844
  type RawHtmlPrecisionTier,
838
845
  } from "./raw-html-canonical.js";
846
+ export { buildComponentKey, type ComponentKeyInput } from "./identity/component-key.js";
847
+ export {
848
+ classifyIdentity,
849
+ type CanonicalIdentityReference,
850
+ type ClassifyIdentityInput,
851
+ type ExternalIdentityReference,
852
+ type IdentityClassification,
853
+ type IdentityConfidence,
854
+ type IdentityDecision,
855
+ type IdentityState,
856
+ type LocalIdentityReference,
857
+ type ResolvedIdentityRenderRoot,
858
+ type ResolvedIntrinsicIdentityRoot,
859
+ } from "./identity/classify.js";
839
860
  export type {
840
861
  ComponentId,
841
862
  FactId,
@@ -848,6 +869,10 @@ export type {
848
869
  CanonicalMappingFact,
849
870
  CanonicalReplacementFact,
850
871
  ComponentLevelFact,
872
+ ComponentDefinitionFact,
873
+ ComponentDefinitionRenderRoot,
874
+ ComponentIdentityFact,
875
+ IdentityFact,
851
876
  PolicyFact,
852
877
  UsageFact,
853
878
  ComponentMetadataFact,
@@ -0,0 +1,196 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ asComponentId,
5
+ compileGlobalGovernanceFacts,
6
+ FactIndex,
7
+ makeComponentIdentityFact,
8
+ makeUsageComponentFact,
9
+ makeUsageImportFact,
10
+ makeUsageNodeFact,
11
+ ruleComponentsPreferLibrary,
12
+ } from "../index.js";
13
+
14
+ // #9e — import-path identity for a DIRECTORY canonical source. A directory
15
+ // source (e.g. `src/components/ui`) declares its canonical component NAMES via an
16
+ // include list. The rule must:
17
+ // (a) leave a genuine `<Button>` alone once the scan resolver proved its import
18
+ // resolves INSIDE the directory (a `usage_component` with a `<dir>#Name` id);
19
+ // (b) flag a local impostor `<Button>` imported from OUTSIDE the directory,
20
+ // redirecting the import at the canonical directory path (advisory / warn);
21
+ // (c) never flag a third-party or aliased same-name import (collision guard).
22
+ describe("components/prefer-library — #9e directory import-path identity", () => {
23
+ const CANONICAL_DIR = "src/components/ui";
24
+
25
+ function directoryIndex() {
26
+ const ix = new FactIndex();
27
+ ix.addMany(
28
+ compileGlobalGovernanceFacts({
29
+ rules: {
30
+ "components/prefer-library": {
31
+ enabled: true,
32
+ severity: "warning",
33
+ options: {
34
+ canonicalSources: [
35
+ { kind: "directory", path: CANONICAL_DIR, include: ["Button", "Input"] },
36
+ ],
37
+ },
38
+ },
39
+ },
40
+ })
41
+ );
42
+ return ix;
43
+ }
44
+
45
+ function addButtonUsage(
46
+ ix: FactIndex,
47
+ input: { local?: string; imported?: string; source: string }
48
+ ) {
49
+ const node = makeUsageNodeFact({
50
+ file: "src/pages/Home.tsx",
51
+ nodePath: "0:0",
52
+ element: "Button",
53
+ location: { file: "src/pages/Home.tsx", line: 5, column: 4 },
54
+ });
55
+ ix.add(node);
56
+ ix.add(
57
+ makeUsageImportFact({
58
+ file: "src/pages/Home.tsx",
59
+ local: input.local ?? "Button",
60
+ imported: input.imported ?? "Button",
61
+ source: input.source,
62
+ location: { file: "src/pages/Home.tsx", line: 1, column: 0 },
63
+ })
64
+ );
65
+ return node;
66
+ }
67
+
68
+ it("does not flag a Button whose import the scan resolver proved resolves inside the canonical directory", () => {
69
+ const ix = directoryIndex();
70
+ // The genuine primitive, imported through a `@/` alias into the canonical dir.
71
+ const node = addButtonUsage(ix, { source: "@/components/ui/Button" });
72
+ // The extractor's resolveComponentId seam emitted this canonical id because
73
+ // the module resolved INSIDE `src/components/ui`.
74
+ ix.add(
75
+ makeUsageComponentFact({
76
+ nodeId: node.id,
77
+ componentId: asComponentId(`${CANONICAL_DIR}#Button`),
78
+ })
79
+ );
80
+
81
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
82
+ });
83
+
84
+ it("flags a local impostor Button imported from outside the canonical directory and redirects to the canonical path", () => {
85
+ const ix = directoryIndex();
86
+ // No usage_component fact: the impostor's import resolved OUTSIDE the dir.
87
+ addButtonUsage(ix, { source: "../features/FakeButton" });
88
+
89
+ const findings = ruleComponentsPreferLibrary(ix);
90
+ expect(findings).toHaveLength(1);
91
+ expect(findings[0]).toMatchObject({
92
+ ruleId: "components/prefer-library",
93
+ code: "FUI1004",
94
+ fix: {
95
+ kind: "replaceImport",
96
+ from: "../features/FakeButton",
97
+ to: CANONICAL_DIR,
98
+ deterministic: false,
99
+ },
100
+ attributes: {
101
+ suggestedComponent: "Button",
102
+ suggestedImport: CANONICAL_DIR,
103
+ advisory: true,
104
+ contractProof: "missing",
105
+ },
106
+ });
107
+ // Advisory only — never a hard error/block.
108
+ expect(findings[0].level).toBe("warn");
109
+ });
110
+
111
+ it("does not flag a same-name Button imported from a third-party package (collision guard)", () => {
112
+ const ix = directoryIndex();
113
+ addButtonUsage(ix, { source: "@tanstack/react-router" });
114
+
115
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
116
+ });
117
+
118
+ it("does not flag an aliased same-name import whose bound export differs (collision guard)", () => {
119
+ const ix = directoryIndex();
120
+ // `import { Foo as Button } from "../x"` — the local JSX name collides, but
121
+ // the imported binding is not the canonical `Button`, so it is not a redirect
122
+ // candidate.
123
+ addButtonUsage(ix, { local: "Button", imported: "Foo", source: "../x" });
124
+
125
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
126
+ });
127
+
128
+ it("does not blanket-flag local imports for a directory source without an include list (#224 guard)", () => {
129
+ const ix = new FactIndex();
130
+ ix.addMany(
131
+ compileGlobalGovernanceFacts({
132
+ rules: {
133
+ "components/prefer-library": {
134
+ enabled: true,
135
+ severity: "warning",
136
+ options: {
137
+ // No include list → no curated canonical NAME vocabulary, so there
138
+ // is no basis to redirect an arbitrary local component import.
139
+ canonicalSources: [{ kind: "directory", path: CANONICAL_DIR }],
140
+ },
141
+ },
142
+ },
143
+ })
144
+ );
145
+ addButtonUsage(ix, { source: "../features/FakeButton" });
146
+
147
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
148
+ });
149
+
150
+ it("keeps flagging raw render roots when component identity facts are absent", () => {
151
+ const ix = directoryIndex();
152
+ ix.add(
153
+ makeUsageNodeFact({
154
+ file: "src/components/MyButton.tsx",
155
+ nodePath: "0:0",
156
+ element: "button",
157
+ interactive: true,
158
+ location: { file: "src/components/MyButton.tsx", line: 5, column: 2 },
159
+ })
160
+ );
161
+
162
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(1);
163
+ });
164
+
165
+ it("suppresses only the render-root node proven to belong to a shadow definition", () => {
166
+ const ix = directoryIndex();
167
+ const shadowRoot = makeUsageNodeFact({
168
+ file: "src/components/MyButton.tsx",
169
+ nodePath: "0:0",
170
+ element: "button",
171
+ interactive: true,
172
+ location: { file: "src/components/MyButton.tsx", line: 5, column: 2 },
173
+ });
174
+ const unrelated = makeUsageNodeFact({
175
+ file: "src/App.tsx",
176
+ nodePath: "0:0",
177
+ element: "button",
178
+ interactive: true,
179
+ location: { file: "src/App.tsx", line: 9, column: 4 },
180
+ });
181
+ ix.addMany([shadowRoot, unrelated]);
182
+ ix.add(
183
+ makeComponentIdentityFact({
184
+ componentKey: "src/components/MyButton.tsx#MyButton",
185
+ state: "shadow",
186
+ confidence: "confirmed",
187
+ canonicalTarget: "Button",
188
+ evidence: [shadowRoot.id],
189
+ })
190
+ );
191
+
192
+ const findings = ruleComponentsPreferLibrary(ix);
193
+ expect(findings).toHaveLength(1);
194
+ expect(findings[0]?.location.file).toBe("src/App.tsx");
195
+ });
196
+ });