@usefragments/core 1.4.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.
- package/dist/{chunk-CBSNXFOD.js → chunk-HXGE3O2F.js} +39 -1
- package/dist/chunk-HXGE3O2F.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-DxdJV6lx.d.ts → governance-BLsyk56o.d.ts} +11 -0
- package/dist/index.d.ts +359 -139
- package/dist/index.js +349 -95
- 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/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/codes/codes.ts +9 -0
- package/src/conform.ts +5 -0
- package/src/facts/builders.ts +45 -1
- package/src/facts/facts.test.ts +30 -0
- package/src/facts/index.ts +6 -0
- package/src/facts/types.ts +61 -1
- package/src/governance.test.ts +26 -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 +25 -0
- package/src/rules/components-prefer-library.test.ts +48 -0
- package/src/rules/components-prefer-library.ts +16 -0
- 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 +1 -0
- package/src/rules/index.ts +7 -0
- package/src/rules/taxonomy.test.ts +16 -1
- package/src/rules/tiers.ts +7 -5
- package/src/schema.ts +15 -0
- package/src/types.ts +12 -0
- package/dist/chunk-CBSNXFOD.js.map +0 -1
- package/dist/{index-0lmh0Lbo.d.ts → index-h_yWj15D.d.ts} +8 -8
|
@@ -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,
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
asComponentId,
|
|
5
5
|
compileGlobalGovernanceFacts,
|
|
6
6
|
FactIndex,
|
|
7
|
+
makeComponentIdentityFact,
|
|
7
8
|
makeUsageComponentFact,
|
|
8
9
|
makeUsageImportFact,
|
|
9
10
|
makeUsageNodeFact,
|
|
@@ -145,4 +146,51 @@ describe("components/prefer-library — #9e directory import-path identity", ()
|
|
|
145
146
|
|
|
146
147
|
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
|
|
147
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
|
+
});
|
|
148
196
|
});
|
|
@@ -270,6 +270,7 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
270
270
|
// comparison of the raw specifier could never recognize them, so the resolved
|
|
271
271
|
// fact is the identity of record. Never flag them as import impostors.
|
|
272
272
|
const canonicalDirectoryResolvedNodes = indexCanonicalDirectoryResolvedNodes(ix, sources);
|
|
273
|
+
const shadowRenderRootNodeIds = indexShadowRenderRootNodeIds(ix);
|
|
273
274
|
const findings: Finding[] = [];
|
|
274
275
|
const seenImportFixes = new Set<string>();
|
|
275
276
|
|
|
@@ -277,6 +278,7 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
277
278
|
if (isCanonicalSourceImplementationFile(node.file, sources)) continue;
|
|
278
279
|
if (node.element.includes(".")) continue;
|
|
279
280
|
if (canonicalDirectoryResolvedNodes.has(node.id)) continue;
|
|
281
|
+
if (shadowRenderRootNodeIds.has(node.id)) continue;
|
|
280
282
|
const props = propsByNode.get(node.id) ?? [];
|
|
281
283
|
const textChildren = textByNode.get(node.id) ?? [];
|
|
282
284
|
const importsForFile = importsByFileAndLocal.get(node.file);
|
|
@@ -603,6 +605,20 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
603
605
|
return findings;
|
|
604
606
|
}
|
|
605
607
|
|
|
608
|
+
function indexShadowRenderRootNodeIds(ix: FactIndex): Set<FactId> {
|
|
609
|
+
const identities = ix.byKind("component_identity");
|
|
610
|
+
if (identities.length === 0) return new Set();
|
|
611
|
+
|
|
612
|
+
const nodeIds = new Set<FactId>();
|
|
613
|
+
for (const identity of identities) {
|
|
614
|
+
if (identity.state !== "shadow") continue;
|
|
615
|
+
for (const evidenceId of identity.evidence) {
|
|
616
|
+
if (ix.get(evidenceId)?.kind === "usage_node") nodeIds.add(evidenceId);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return nodeIds;
|
|
620
|
+
}
|
|
621
|
+
|
|
606
622
|
interface BuiltInRawHtmlMatch {
|
|
607
623
|
canonical: string;
|
|
608
624
|
tier: RawHtmlPrecisionTier;
|
|
@@ -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
|
+
}
|