@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.
- package/dist/{chunk-CBSNXFOD.js → chunk-AOG4FTV6.js} +146 -6
- package/dist/chunk-AOG4FTV6.js.map +1 -0
- package/dist/{chunk-57QDBEHQ.js → chunk-ZHS52OT4.js} +3 -9
- package/dist/chunk-ZHS52OT4.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- package/dist/codes/index.js +2 -2
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-DxdJV6lx.d.ts → governance-B88uR3Zq.d.ts} +229 -1
- package/dist/{index-0lmh0Lbo.d.ts → index-DbkPE46t.d.ts} +48 -8
- package/dist/index.d.ts +779 -158
- package/dist/index.js +674 -127
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +18 -18
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent-format.test.ts +1 -0
- package/src/canonical-bridge.ts +46 -0
- package/src/canonical-direction.ts +118 -0
- package/src/codes/codes.ts +18 -0
- package/src/conform.ts +5 -0
- package/src/contract/preimage.test.ts +40 -0
- package/src/contract/preimage.ts +20 -0
- package/src/effective-governance-inputs.test.ts +52 -0
- package/src/effective-governance-inputs.ts +106 -0
- package/src/facts/builders.ts +70 -2
- package/src/facts/compile.ts +20 -2
- package/src/facts/fact-index.ts +4 -0
- package/src/facts/facts.test.ts +30 -0
- package/src/facts/index.ts +6 -0
- package/src/facts/types.ts +86 -7
- package/src/governance-integrity.test.ts +22 -0
- package/src/governance-integrity.ts +10 -4
- package/src/governance.test.ts +93 -0
- package/src/governance.ts +76 -0
- package/src/identity/classify.test.ts +185 -0
- package/src/identity/classify.ts +257 -0
- package/src/identity/component-key.test.ts +32 -0
- package/src/identity/component-key.ts +35 -0
- package/src/identity/usage.ts +39 -0
- package/src/index.ts +48 -0
- package/src/rules/__tests__/fix-emission-invariant.test.ts +35 -4
- package/src/rules/components-prefer-library.test.ts +151 -0
- package/src/rules/components-prefer-library.ts +46 -4
- package/src/rules/components-shadow-component.test.ts +145 -0
- package/src/rules/components-shadow-component.ts +81 -0
- package/src/rules/fix-availability.ts +2 -0
- package/src/rules/index.ts +14 -0
- package/src/rules/jsx-preferred-import-path.ts +45 -9
- package/src/rules/rules.test.ts +202 -6
- package/src/rules/spacing-resolution.ts +2 -2
- package/src/rules/styles-no-raw-color.test.ts +6 -1
- package/src/rules/styles-no-raw-color.ts +3 -2
- package/src/rules/styles-no-raw-dimensions.ts +5 -7
- package/src/rules/styles-no-raw-spacing.ts +5 -2
- package/src/rules/styles-no-raw-typography.ts +7 -4
- package/src/rules/taxonomy.test.ts +19 -1
- package/src/rules/tiers.ts +9 -5
- package/src/rules/token-candidates.ts +36 -0
- package/src/rules/tokens-require-dual-fallback.ts +2 -1
- package/src/rules/tokens-upstream-drift.test.ts +111 -0
- package/src/rules/tokens-upstream-drift.ts +57 -0
- package/src/rules/utils.ts +22 -14
- package/src/schema.ts +28 -0
- package/src/schemas/index.ts +5 -8
- package/src/token-types.ts +71 -1
- package/src/types.ts +18 -0
- package/dist/chunk-57QDBEHQ.js.map +0 -1
- 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(
|
|
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:
|
|
150
|
+
name: radiusName,
|
|
120
151
|
value: "4px",
|
|
121
152
|
category: "radius",
|
|
122
|
-
referenceFormat: radiusReferenceFormat,
|
|
123
|
-
sourceNames: [
|
|
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
|
|
258
|
-
const
|
|
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(
|
|
1101
|
-
source
|
|
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,
|
package/src/rules/index.ts
CHANGED
|
@@ -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";
|