@usefragments/core 1.4.0 → 1.5.1

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 (72) hide show
  1. package/dist/{chunk-CBSNXFOD.js → chunk-AOG4FTV6.js} +146 -6
  2. package/dist/chunk-AOG4FTV6.js.map +1 -0
  3. package/dist/{chunk-57QDBEHQ.js → chunk-ZHS52OT4.js} +3 -9
  4. package/dist/chunk-ZHS52OT4.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +2 -2
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/generate/index.d.ts +1 -1
  9. package/dist/{governance-DxdJV6lx.d.ts → governance-B88uR3Zq.d.ts} +229 -1
  10. package/dist/{index-0lmh0Lbo.d.ts → index-DbkPE46t.d.ts} +48 -8
  11. package/dist/index.d.ts +779 -158
  12. package/dist/index.js +674 -127
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +18 -18
  16. package/dist/schemas/index.d.ts +1 -1
  17. package/dist/schemas/index.js +1 -1
  18. package/dist/test-utils.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/agent-format.test.ts +1 -0
  21. package/src/canonical-bridge.ts +46 -0
  22. package/src/canonical-direction.ts +118 -0
  23. package/src/codes/codes.ts +18 -0
  24. package/src/conform.ts +5 -0
  25. package/src/contract/preimage.test.ts +40 -0
  26. package/src/contract/preimage.ts +20 -0
  27. package/src/effective-governance-inputs.test.ts +52 -0
  28. package/src/effective-governance-inputs.ts +106 -0
  29. package/src/facts/builders.ts +70 -2
  30. package/src/facts/compile.ts +20 -2
  31. package/src/facts/fact-index.ts +4 -0
  32. package/src/facts/facts.test.ts +30 -0
  33. package/src/facts/index.ts +6 -0
  34. package/src/facts/types.ts +86 -7
  35. package/src/governance-integrity.test.ts +22 -0
  36. package/src/governance-integrity.ts +10 -4
  37. package/src/governance.test.ts +93 -0
  38. package/src/governance.ts +76 -0
  39. package/src/identity/classify.test.ts +185 -0
  40. package/src/identity/classify.ts +257 -0
  41. package/src/identity/component-key.test.ts +32 -0
  42. package/src/identity/component-key.ts +35 -0
  43. package/src/identity/usage.ts +39 -0
  44. package/src/index.ts +48 -0
  45. package/src/rules/__tests__/fix-emission-invariant.test.ts +35 -4
  46. package/src/rules/components-prefer-library.test.ts +151 -0
  47. package/src/rules/components-prefer-library.ts +46 -4
  48. package/src/rules/components-shadow-component.test.ts +145 -0
  49. package/src/rules/components-shadow-component.ts +81 -0
  50. package/src/rules/fix-availability.ts +2 -0
  51. package/src/rules/index.ts +14 -0
  52. package/src/rules/jsx-preferred-import-path.ts +45 -9
  53. package/src/rules/rules.test.ts +202 -6
  54. package/src/rules/spacing-resolution.ts +2 -2
  55. package/src/rules/styles-no-raw-color.test.ts +6 -1
  56. package/src/rules/styles-no-raw-color.ts +3 -2
  57. package/src/rules/styles-no-raw-dimensions.ts +5 -7
  58. package/src/rules/styles-no-raw-spacing.ts +5 -2
  59. package/src/rules/styles-no-raw-typography.ts +7 -4
  60. package/src/rules/taxonomy.test.ts +19 -1
  61. package/src/rules/tiers.ts +9 -5
  62. package/src/rules/token-candidates.ts +36 -0
  63. package/src/rules/tokens-require-dual-fallback.ts +2 -1
  64. package/src/rules/tokens-upstream-drift.test.ts +111 -0
  65. package/src/rules/tokens-upstream-drift.ts +57 -0
  66. package/src/rules/utils.ts +22 -14
  67. package/src/schema.ts +28 -0
  68. package/src/schemas/index.ts +5 -8
  69. package/src/token-types.ts +71 -1
  70. package/src/types.ts +18 -0
  71. package/dist/chunk-57QDBEHQ.js.map +0 -1
  72. package/dist/chunk-CBSNXFOD.js.map +0 -1
@@ -65,6 +65,34 @@ describe("proof-carrying fix invariant", () => {
65
65
  "token reference is not directly applicable"
66
66
  );
67
67
  });
68
+
69
+ it("keeps JavaScript member tokens advisory without inventing a CSS variable", () => {
70
+ const ix = buildFixtureCorpus("js-member", "radii.md");
71
+ const dimension = RULES.find((rule) => rule.id === "styles/no-raw-dimensions")
72
+ ?.run(ix)
73
+ .find((finding) => finding.fix);
74
+
75
+ expect(dimension?.fix).toMatchObject({
76
+ value: "radii.md",
77
+ deterministic: false,
78
+ });
79
+ expect(JSON.stringify(dimension?.fix)).not.toContain("var(--radii-md)");
80
+ expect(dimension?.attributes?.downgradeReason).toContain(
81
+ "token reference is not directly applicable"
82
+ );
83
+ });
84
+
85
+ it("fails closed when a legacy token fact has no authored reference format", () => {
86
+ const ix = buildFixtureCorpus("unknown");
87
+ const dimension = RULES.find((rule) => rule.id === "styles/no-raw-dimensions")
88
+ ?.run(ix)
89
+ .find((finding) => finding.fix);
90
+
91
+ expect(dimension?.fix?.deterministic).toBe(false);
92
+ expect(dimension?.attributes?.downgradeReason).toContain(
93
+ "token reference is not directly applicable"
94
+ );
95
+ });
68
96
  });
69
97
 
70
98
  function runEveryRule(ix: FactIndex): Finding[] {
@@ -98,7 +126,10 @@ function withoutDeterminism(fix: FindingFix): Omit<FindingFix, "deterministic">
98
126
  return rest;
99
127
  }
100
128
 
101
- function buildFixtureCorpus(radiusReferenceFormat: "css-var" | "dtcg" = "css-var"): FactIndex {
129
+ function buildFixtureCorpus(
130
+ radiusReferenceFormat: "css-var" | "dtcg" | "js-member" | "unknown" = "css-var",
131
+ radiusName = "--proof-radius"
132
+ ): FactIndex {
102
133
  const ix = new FactIndex();
103
134
  ix.addMany([
104
135
  makeTokenDefinitionFact({
@@ -116,11 +147,11 @@ function buildFixtureCorpus(radiusReferenceFormat: "css-var" | "dtcg" = "css-var
116
147
  sourceNames: ["--proof-space"],
117
148
  }),
118
149
  makeTokenDefinitionFact({
119
- name: "--proof-radius",
150
+ name: radiusName,
120
151
  value: "4px",
121
152
  category: "radius",
122
- referenceFormat: radiusReferenceFormat,
123
- sourceNames: ["--proof-radius"],
153
+ referenceFormat: radiusReferenceFormat === "unknown" ? undefined : radiusReferenceFormat,
154
+ sourceNames: [radiusName],
124
155
  }),
125
156
  makeTokenDefinitionFact({
126
157
  name: "--proof-font",
@@ -4,12 +4,116 @@ import {
4
4
  asComponentId,
5
5
  compileGlobalGovernanceFacts,
6
6
  FactIndex,
7
+ makeComponentDefinitionFact,
8
+ makeComponentIdentityFact,
7
9
  makeUsageComponentFact,
8
10
  makeUsageImportFact,
9
11
  makeUsageNodeFact,
10
12
  ruleComponentsPreferLibrary,
11
13
  } from "../index.js";
12
14
 
15
+ describe("components/prefer-library — canonical direction containment", () => {
16
+ const componentKey = "src/components/Button.tsx#Button";
17
+
18
+ function indexFor(decision?: "sanction" | "reject", exported = true) {
19
+ const ix = new FactIndex();
20
+ ix.addMany(
21
+ compileGlobalGovernanceFacts({
22
+ rules: {
23
+ "components/prefer-library": {
24
+ enabled: true,
25
+ severity: "warning",
26
+ options: {
27
+ canonicalSources: [
28
+ { kind: "npm", specifier: "@mui/material", include: ["Button", "Stepper"] },
29
+ ],
30
+ },
31
+ },
32
+ },
33
+ })
34
+ );
35
+ const definition = makeComponentDefinitionFact({
36
+ file: "src/components/Button.tsx",
37
+ exportName: "Button",
38
+ exported,
39
+ componentKey,
40
+ renderRoot: {
41
+ resolution: "canonical",
42
+ canonical: "@mui/material#Button",
43
+ importSource: "@mui/material",
44
+ },
45
+ propSurface: [],
46
+ });
47
+ ix.add(definition);
48
+ ix.add(
49
+ makeComponentIdentityFact({
50
+ componentKey,
51
+ state: decision === "reject" ? "shadow" : "variant",
52
+ confidence: decision ? "confirmed" : "review",
53
+ canonicalTarget: "Button",
54
+ ...(decision ? { decisionId: `local:${decision}:${componentKey}` } : {}),
55
+ evidence: [definition.id],
56
+ })
57
+ );
58
+ return ix;
59
+ }
60
+
61
+ function addLocalUsage(ix: FactIndex, name: "Button" | "Stepper") {
62
+ ix.add(
63
+ makeUsageNodeFact({
64
+ file: "src/App.tsx",
65
+ nodePath: `0:${name}`,
66
+ element: name,
67
+ location: { file: "src/App.tsx", line: 3, column: 2 },
68
+ })
69
+ );
70
+ ix.add(
71
+ makeUsageImportFact({
72
+ file: "src/App.tsx",
73
+ local: name,
74
+ imported: name,
75
+ source: `./components/${name}`,
76
+ location: { file: "src/App.tsx", line: 1, column: 0 },
77
+ })
78
+ );
79
+ }
80
+
81
+ it("suppresses only the exact conflicted npm export when direction is unresolved", () => {
82
+ const ix = indexFor();
83
+ addLocalUsage(ix, "Button");
84
+ addLocalUsage(ix, "Stepper");
85
+ ix.add(
86
+ makeUsageNodeFact({
87
+ file: "src/App.tsx",
88
+ nodePath: "0:raw-button",
89
+ element: "button",
90
+ interactive: true,
91
+ location: { file: "src/App.tsx", line: 4, column: 2 },
92
+ })
93
+ );
94
+
95
+ const findings = ruleComponentsPreferLibrary(ix);
96
+ expect(findings).toHaveLength(1);
97
+ expect(findings[0]?.attributes?.suggestedComponent).toBe("Stepper");
98
+ });
99
+
100
+ it("keeps a sanctioned wrapper exempt and restores npm guidance after explicit rejection", () => {
101
+ const sanctioned = indexFor("sanction");
102
+ addLocalUsage(sanctioned, "Button");
103
+ expect(ruleComponentsPreferLibrary(sanctioned)).toHaveLength(0);
104
+
105
+ const rejected = indexFor("reject");
106
+ addLocalUsage(rejected, "Button");
107
+ expect(ruleComponentsPreferLibrary(rejected)).toHaveLength(1);
108
+ });
109
+
110
+ it("does not let a private helper deactivate canonical policy", () => {
111
+ const ix = indexFor(undefined, false);
112
+ addLocalUsage(ix, "Button");
113
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(1);
114
+ });
115
+ });
116
+
13
117
  // #9e — import-path identity for a DIRECTORY canonical source. A directory
14
118
  // source (e.g. `src/components/ui`) declares its canonical component NAMES via an
15
119
  // include list. The rule must:
@@ -145,4 +249,51 @@ describe("components/prefer-library — #9e directory import-path identity", ()
145
249
 
146
250
  expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
147
251
  });
252
+
253
+ it("keeps flagging raw render roots when component identity facts are absent", () => {
254
+ const ix = directoryIndex();
255
+ ix.add(
256
+ makeUsageNodeFact({
257
+ file: "src/components/MyButton.tsx",
258
+ nodePath: "0:0",
259
+ element: "button",
260
+ interactive: true,
261
+ location: { file: "src/components/MyButton.tsx", line: 5, column: 2 },
262
+ })
263
+ );
264
+
265
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(1);
266
+ });
267
+
268
+ it("suppresses only the render-root node proven to belong to a shadow definition", () => {
269
+ const ix = directoryIndex();
270
+ const shadowRoot = makeUsageNodeFact({
271
+ file: "src/components/MyButton.tsx",
272
+ nodePath: "0:0",
273
+ element: "button",
274
+ interactive: true,
275
+ location: { file: "src/components/MyButton.tsx", line: 5, column: 2 },
276
+ });
277
+ const unrelated = makeUsageNodeFact({
278
+ file: "src/App.tsx",
279
+ nodePath: "0:0",
280
+ element: "button",
281
+ interactive: true,
282
+ location: { file: "src/App.tsx", line: 9, column: 4 },
283
+ });
284
+ ix.addMany([shadowRoot, unrelated]);
285
+ ix.add(
286
+ makeComponentIdentityFact({
287
+ componentKey: "src/components/MyButton.tsx#MyButton",
288
+ state: "shadow",
289
+ confidence: "confirmed",
290
+ canonicalTarget: "Button",
291
+ evidence: [shadowRoot.id],
292
+ })
293
+ );
294
+
295
+ const findings = ruleComponentsPreferLibrary(ix);
296
+ expect(findings).toHaveLength(1);
297
+ expect(findings[0]?.location.file).toBe("src/App.tsx");
298
+ });
148
299
  });
@@ -15,6 +15,11 @@ import {
15
15
  type RawHtmlPrecisionTier,
16
16
  } from "../raw-html-canonical.js";
17
17
  import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
18
+ import {
19
+ canonicalDirectionConflictsTarget,
20
+ projectCanonicalDirectionConflicts,
21
+ type CanonicalDirectionConflict,
22
+ } from "../canonical-direction.js";
18
23
 
19
24
  import { makeFinding } from "./finding.js";
20
25
  import type { Finding } from "./types.js";
@@ -254,8 +259,12 @@ function findClassNameReimpl(
254
259
  export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
255
260
  const policy = ix.policy.ruleConfig(RULE_ID);
256
261
  if (!policy?.enabled) return [];
257
- const sources = canonicalSourcesFromPolicy(policy.options?.canonicalSources);
258
- const mappings = canonicalMappingsFromPolicy(policy.options?.canonicalMappings);
262
+ const configuredSources = canonicalSourcesFromPolicy(policy.options?.canonicalSources);
263
+ const conflicts = projectCanonicalDirectionConflicts(ix, configuredSources);
264
+ const sources = configuredSources.map((source) => suppressConflictedExports(source, conflicts));
265
+ const mappings = canonicalMappingsFromPolicy(policy.options?.canonicalMappings).filter(
266
+ (mapping) => !canonicalDirectionConflictsTarget(conflicts, mapping.importPath, mapping.name)
267
+ );
259
268
  if (sources.length === 0 && mappings.length === 0) return [];
260
269
 
261
270
  const importsByFileAndLocal = indexImportsByFileAndLocal(ix);
@@ -270,6 +279,7 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
270
279
  // comparison of the raw specifier could never recognize them, so the resolved
271
280
  // fact is the identity of record. Never flag them as import impostors.
272
281
  const canonicalDirectoryResolvedNodes = indexCanonicalDirectoryResolvedNodes(ix, sources);
282
+ const shadowRenderRootNodeIds = indexShadowRenderRootNodeIds(ix);
273
283
  const findings: Finding[] = [];
274
284
  const seenImportFixes = new Set<string>();
275
285
 
@@ -277,6 +287,7 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
277
287
  if (isCanonicalSourceImplementationFile(node.file, sources)) continue;
278
288
  if (node.element.includes(".")) continue;
279
289
  if (canonicalDirectoryResolvedNodes.has(node.id)) continue;
290
+ if (shadowRenderRootNodeIds.has(node.id)) continue;
280
291
  const props = propsByNode.get(node.id) ?? [];
281
292
  const textChildren = textByNode.get(node.id) ?? [];
282
293
  const importsForFile = importsByFileAndLocal.get(node.file);
@@ -603,6 +614,36 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
603
614
  return findings;
604
615
  }
605
616
 
617
+ function suppressConflictedExports(
618
+ source: CanonicalSource,
619
+ conflicts: readonly CanonicalDirectionConflict[]
620
+ ): CanonicalSource {
621
+ if (source.kind !== "npm") return source;
622
+ const names = conflicts
623
+ .filter((conflict) => ownedImportMatchesRoot(source.specifier, conflict.packageName))
624
+ .map((conflict) => conflict.exportName);
625
+ if (names.length === 0) return source;
626
+ const blocked = new Set(names);
627
+ if (source.include) {
628
+ return { ...source, include: source.include.filter((name) => !blocked.has(name)) };
629
+ }
630
+ return { ...source, exclude: [...new Set([...(source.exclude ?? []), ...names])].sort() };
631
+ }
632
+
633
+ function indexShadowRenderRootNodeIds(ix: FactIndex): Set<FactId> {
634
+ const identities = ix.byKind("component_identity");
635
+ if (identities.length === 0) return new Set();
636
+
637
+ const nodeIds = new Set<FactId>();
638
+ for (const identity of identities) {
639
+ if (identity.state !== "shadow") continue;
640
+ for (const evidenceId of identity.evidence) {
641
+ if (ix.get(evidenceId)?.kind === "usage_node") nodeIds.add(evidenceId);
642
+ }
643
+ }
644
+ return nodeIds;
645
+ }
646
+
606
647
  interface BuiltInRawHtmlMatch {
607
648
  canonical: string;
608
649
  tier: RawHtmlPrecisionTier;
@@ -1097,8 +1138,9 @@ function indexCanonicalDirectoryResolvedNodes(
1097
1138
  ): Set<FactId> {
1098
1139
  const out = new Set<FactId>();
1099
1140
  const directoryPaths = sources
1100
- .filter((source): source is Extract<CanonicalSource, { kind: "directory" }> =>
1101
- source.kind === "directory"
1141
+ .filter(
1142
+ (source): source is Extract<CanonicalSource, { kind: "directory" }> =>
1143
+ source.kind === "directory"
1102
1144
  )
1103
1145
  .map((source) => normalizePath(source.path));
1104
1146
  if (directoryPaths.length === 0) return out;
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ BLOCKING_RULE_ALLOWLIST,
5
+ compileGlobalGovernanceFacts,
6
+ FactIndex,
7
+ isDenyEligible,
8
+ makeComponentDefinitionFact,
9
+ makeComponentIdentityFact,
10
+ makeUsageComponentFact,
11
+ makeUsageNodeFact,
12
+ ruleComponentsShadowComponent,
13
+ } from "../index.js";
14
+
15
+ describe("components/shadow-component", () => {
16
+ it("emits one definition finding with usage count, blast radius, and a stable fingerprint", () => {
17
+ const first = shadowIndex({ definitionLine: 8 });
18
+ const moved = shadowIndex({ definitionLine: 80 });
19
+
20
+ const finding = ruleComponentsShadowComponent(first)[0];
21
+ const movedFinding = ruleComponentsShadowComponent(moved)[0];
22
+
23
+ expect(finding).toMatchObject({
24
+ ruleId: "components/shadow-component",
25
+ level: "warn",
26
+ location: { file: "src/components/MyButton.tsx", line: 8, column: 2 },
27
+ attributes: {
28
+ canonicalTarget: "Button",
29
+ usageCount: 3,
30
+ blastRadius: 2,
31
+ confidence: "confirmed",
32
+ state: "shadow",
33
+ },
34
+ });
35
+ expect(finding?.attributes).not.toHaveProperty("advisory");
36
+ expect(finding?.fingerprint).toBe(movedFinding?.fingerprint);
37
+ });
38
+
39
+ it("keeps review-tier variants permanently advisory at info severity", () => {
40
+ const ix = shadowIndex({ state: "variant", confidence: "review", severity: "error" });
41
+
42
+ const finding = ruleComponentsShadowComponent(ix)[0];
43
+
44
+ expect(finding).toMatchObject({
45
+ severity: "minor",
46
+ level: "warn",
47
+ attributes: {
48
+ canonicalTarget: "Button",
49
+ confidence: "review",
50
+ state: "variant",
51
+ advisory: true,
52
+ },
53
+ });
54
+ expect(isDenyEligible(finding!, true)).toBe(false);
55
+ });
56
+
57
+ it("honors an error severity for a confirmed shadow but never enters the write deny-set", () => {
58
+ const finding = ruleComponentsShadowComponent(shadowIndex({ severity: "error" }))[0];
59
+
60
+ expect(finding?.level).toBe("error");
61
+ expect(finding?.attributes).not.toHaveProperty("advisory");
62
+ expect(BLOCKING_RULE_ALLOWLIST.has("components/shadow-component")).toBe(false);
63
+ expect(isDenyEligible(finding!, false)).toBe(false);
64
+ });
65
+
66
+ it("throws rather than emitting an evidence-free finding", () => {
67
+ const ix = shadowIndex();
68
+ const identity = ix.byKind("component_identity")[0]!;
69
+ const empty = new FactIndex();
70
+ empty.addMany(
71
+ ix
72
+ .all()
73
+ .filter((fact) => fact.id !== identity.id)
74
+ );
75
+ empty.add(makeComponentIdentityFact({ ...identity, evidence: [] }));
76
+
77
+ expect(() => ruleComponentsShadowComponent(empty)).toThrow(
78
+ "findings must carry at least one evidence fact"
79
+ );
80
+ });
81
+ });
82
+
83
+ function shadowIndex(
84
+ options: {
85
+ state?: "shadow" | "variant";
86
+ confidence?: "confirmed" | "review";
87
+ severity?: "warn" | "error";
88
+ definitionLine?: number;
89
+ } = {}
90
+ ): FactIndex {
91
+ const componentKey = "src/components/MyButton.tsx#MyButton";
92
+ const ix = new FactIndex();
93
+ ix.addMany(
94
+ compileGlobalGovernanceFacts({
95
+ rules: {
96
+ "components/shadow-component": {
97
+ enabled: true,
98
+ severity: options.severity ?? "warn",
99
+ },
100
+ },
101
+ })
102
+ );
103
+ const definition = makeComponentDefinitionFact({
104
+ file: "src/components/MyButton.tsx",
105
+ exportName: "MyButton",
106
+ componentKey,
107
+ renderRoot: { resolution: "intrinsic", tag: "button", interactive: true },
108
+ propSurface: ["children", "disabled"],
109
+ });
110
+ const renderRoot = makeUsageNodeFact({
111
+ file: definition.file,
112
+ nodePath: "0:0",
113
+ element: "button",
114
+ interactive: true,
115
+ location: {
116
+ file: definition.file,
117
+ line: options.definitionLine ?? 8,
118
+ column: 2,
119
+ },
120
+ });
121
+ ix.addMany([definition, renderRoot]);
122
+ ix.add(
123
+ makeComponentIdentityFact({
124
+ componentKey,
125
+ state: options.state ?? "shadow",
126
+ confidence: options.confidence ?? "confirmed",
127
+ canonicalTarget: "Button",
128
+ evidence: [definition.id, renderRoot.id],
129
+ })
130
+ );
131
+
132
+ for (const [index, file] of ["src/App.tsx", "src/App.tsx", "src/Checkout.tsx"].entries()) {
133
+ const node = makeUsageNodeFact({
134
+ file,
135
+ nodePath: `0:${index}`,
136
+ element: "MyButton",
137
+ location: { file, line: index + 1, column: 0 },
138
+ });
139
+ ix.addMany([
140
+ node,
141
+ makeUsageComponentFact({ nodeId: node.id, componentId: definition.componentId }),
142
+ ]);
143
+ }
144
+ return ix;
145
+ }
@@ -0,0 +1,81 @@
1
+ import type {
2
+ ComponentDefinitionFact,
3
+ ComponentIdentityFact,
4
+ FactIndex,
5
+ FactLocation,
6
+ } from "../facts/index.js";
7
+ import { indexComponentIdentityUsage } from "../identity/usage.js";
8
+
9
+ import { makeFinding } from "./finding.js";
10
+ import type { Finding } from "./types.js";
11
+
12
+ export const RULE_ID = "components/shadow-component";
13
+ export const RULE_VERSION = "1";
14
+
15
+ export function ruleComponentsShadowComponent(ix: FactIndex): Finding[] {
16
+ const policy = ix.policy.ruleConfig(RULE_ID);
17
+ if (!policy?.enabled) return [];
18
+
19
+ const definitions = new Map(
20
+ ix.byKind("component_definition").map((definition) => [definition.componentKey, definition])
21
+ );
22
+ const usageByComponent = indexComponentIdentityUsage(ix);
23
+ const findings: Finding[] = [];
24
+
25
+ for (const identity of ix.byKind("component_identity")) {
26
+ const reviewVariant = identity.state === "variant" && identity.confidence === "review";
27
+ if (identity.state !== "shadow" && !reviewVariant) continue;
28
+
29
+ const definition = definitions.get(identity.componentKey);
30
+ if (!definition) continue;
31
+ const usage = usageByComponent.get(identity.componentKey);
32
+ const usageCount = usage?.usageCount ?? 0;
33
+ const blastRadius = usage?.blastRadius ?? 0;
34
+
35
+ findings.push(
36
+ makeFinding({
37
+ ruleId: RULE_ID,
38
+ ruleVersion: RULE_VERSION,
39
+ severity: reviewVariant ? "info" : (policy.severity ?? "warn"),
40
+ message: reviewVariant
41
+ ? `${definition.exportName} wraps ${identity.canonicalTarget ?? "a canonical component"}; sanction it as a variant or consolidate its usages.`
42
+ : `${definition.exportName} shadows ${identity.canonicalTarget ?? "a canonical component"} across ${usageCount} usage${usageCount === 1 ? "" : "s"}.`,
43
+ location: definitionLocation(ix, definition, identity),
44
+ evidence: ix.evidence(identity.evidence),
45
+ fingerprintIdentity: {
46
+ componentKey: identity.componentKey,
47
+ canonicalTarget: identity.canonicalTarget,
48
+ },
49
+ attributes: {
50
+ canonicalTarget: identity.canonicalTarget,
51
+ usageCount,
52
+ blastRadius,
53
+ confidence: identity.confidence,
54
+ state: identity.state,
55
+ ...(reviewVariant ? { advisory: true } : {}),
56
+ },
57
+ })
58
+ );
59
+ }
60
+
61
+ return findings;
62
+ }
63
+
64
+ function definitionLocation(
65
+ ix: FactIndex,
66
+ definition: ComponentDefinitionFact,
67
+ identity: ComponentIdentityFact
68
+ ): FactLocation {
69
+ const evidence = ix.evidence(identity.evidence).map(({ fact }) => fact);
70
+ const inDefinitionFile = evidence.find(
71
+ (fact) => "location" in fact && fact.location?.file === definition.file
72
+ );
73
+ if (inDefinitionFile && "location" in inDefinitionFile && inDefinitionFile.location) {
74
+ return inDefinitionFile.location;
75
+ }
76
+ const sourceLocated = evidence.find((fact) => "location" in fact && fact.location);
77
+ if (sourceLocated && "location" in sourceLocated && sourceLocated.location) {
78
+ return sourceLocated.location;
79
+ }
80
+ return { file: definition.file, line: 1, column: 0 };
81
+ }
@@ -12,6 +12,7 @@ export const RULE_FIX_AVAILABLE = {
12
12
  "imports/preferred-path": true,
13
13
  "components/preferred-component": true,
14
14
  "components/prefer-library": true,
15
+ "components/shadow-component": false,
15
16
  "styles/no-raw-color": true,
16
17
  "styles/no-raw-dimensions": true,
17
18
  "styles/no-raw-spacing": true,
@@ -24,6 +25,7 @@ export const RULE_FIX_AVAILABLE = {
24
25
  "tailwind/unknown-class": false,
25
26
  "tokens/require-dual-fallback": true,
26
27
  "tokens/css-vars-must-be-defined": false,
28
+ "tokens/upstream-drift": false,
27
29
  "theme/no-theme-coupled-literal": false,
28
30
  "a11y/required-accessible-name": false,
29
31
  "composition/cardinality": false,
@@ -11,6 +11,7 @@ import type { FactIndex } from "../facts/index.js";
11
11
 
12
12
  import { ruleA11yRequiredAccessibleName } from "./a11y-required-accessible-name.js";
13
13
  import { ruleComponentsPreferLibrary } from "./components-prefer-library.js";
14
+ import { ruleComponentsShadowComponent } from "./components-shadow-component.js";
14
15
  import { ruleComponentsForbiddenPropValue } from "./components-forbidden-prop-value.js";
15
16
  import { ruleComponentsUnknownProp } from "./components-unknown-prop.js";
16
17
  import { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js";
@@ -30,6 +31,7 @@ import { ruleTailwindRawColorViaToken } from "./tailwind-raw-color-via-token.js"
30
31
  import { ruleTailwindUnknownClass } from "./tailwind-unknown-class.js";
31
32
  import { ruleTokensRequireDualFallback } from "./tokens-require-dual-fallback.js";
32
33
  import { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-defined.js";
34
+ import { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
33
35
  import type { Finding } from "./types.js";
34
36
 
35
37
  export type RuleFn = (ix: FactIndex) => Finding[];
@@ -71,6 +73,11 @@ export const RULES: readonly Rule[] = [
71
73
  version: "1",
72
74
  run: ruleComponentsPreferLibrary,
73
75
  },
76
+ {
77
+ id: "components/shadow-component",
78
+ version: "1",
79
+ run: ruleComponentsShadowComponent,
80
+ },
74
81
  {
75
82
  id: "styles/no-raw-color",
76
83
  version: "1",
@@ -131,6 +138,11 @@ export const RULES: readonly Rule[] = [
131
138
  version: "1",
132
139
  run: ruleTokensCssVarsMustBeDefined,
133
140
  },
141
+ {
142
+ id: "tokens/upstream-drift",
143
+ version: "1",
144
+ run: ruleTokensUpstreamDrift,
145
+ },
134
146
  {
135
147
  id: "theme/no-theme-coupled-literal",
136
148
  version: "1",
@@ -189,6 +201,7 @@ export { BLOCKING_RULE_ALLOWLIST, gatesCi, isDenyEligible } from "./emit-gate.js
189
201
  export { ruleComponentsUnknownProp } from "./components-unknown-prop.js";
190
202
  export { ruleComponentsForbiddenPropValue } from "./components-forbidden-prop-value.js";
191
203
  export { ruleComponentsPreferLibrary } from "./components-prefer-library.js";
204
+ export { ruleComponentsShadowComponent } from "./components-shadow-component.js";
192
205
  export { rulePropsInvalidValue } from "./props-invalid-value.js";
193
206
  export { ruleJsxPreferredImportPath } from "./jsx-preferred-import-path.js";
194
207
  export { ruleJsxPreferredComponent } from "./jsx-preferred-component.js";
@@ -204,6 +217,7 @@ export { ruleTailwindRawColorViaToken } from "./tailwind-raw-color-via-token.js"
204
217
  export { ruleTailwindUnknownClass } from "./tailwind-unknown-class.js";
205
218
  export { ruleTokensRequireDualFallback } from "./tokens-require-dual-fallback.js";
206
219
  export { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-defined.js";
220
+ export { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
207
221
  export { ruleThemeNoThemeCoupledLiteral } from "./theme-no-theme-coupled-literal.js";
208
222
  export { ruleA11yRequiredAccessibleName } from "./a11y-required-accessible-name.js";
209
223
  export { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js";