@usefragments/core 1.5.2 → 1.7.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-RANPUC6C.js +72 -0
- package/dist/chunk-RANPUC6C.js.map +1 -0
- package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
- package/dist/chunk-WNMWKUYG.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/compiled-types/index.js +8 -0
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
- package/dist/index.d.ts +800 -179
- package/dist/index.js +1003 -237
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +36 -36
- package/dist/schemas/index.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +2 -1
- package/src/__tests__/policy-exclude.test.ts +180 -0
- package/src/agent-format.test.ts +13 -0
- package/src/agent-format.ts +9 -3
- package/src/canonical-bridge.ts +69 -1
- package/src/canonical-direction.test.ts +118 -0
- package/src/canonical-direction.ts +43 -2
- package/src/codes/__tests__/codes.test.ts +14 -2
- package/src/codes/codes.ts +41 -2
- package/src/compiled-types/index.ts +81 -0
- package/src/compiled-types/parse.test.ts +47 -0
- package/src/facts/builders.ts +48 -0
- package/src/facts/compile.ts +148 -34
- package/src/facts/fact-index.ts +15 -2
- package/src/facts/facts.test.ts +44 -3
- package/src/facts/index.ts +11 -6
- package/src/facts/types.ts +60 -9
- package/src/governance-integrity.test.ts +272 -4
- package/src/governance-integrity.ts +345 -25
- package/src/governance.ts +87 -1
- package/src/index.ts +46 -1
- package/src/policy-exclude.ts +113 -0
- package/src/rules/a11y-required-accessible-name.ts +175 -28
- package/src/rules/a11y-standard.ts +102 -0
- package/src/rules/a11y-utils.ts +7 -0
- package/src/rules/components-prefer-library.test.ts +75 -28
- package/src/rules/components-prefer-library.ts +35 -15
- package/src/rules/components-shadow-component.test.ts +21 -9
- package/src/rules/emit-gate.test.ts +74 -4
- package/src/rules/emit-gate.ts +24 -9
- package/src/rules/families.test.ts +69 -0
- package/src/rules/families.ts +52 -0
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +18 -2
- package/src/rules/jsx-preferred-import-path.ts +29 -11
- package/src/rules/rules.test.ts +188 -8
- package/src/rules/styles-no-raw-color.ts +13 -4
- package/src/rules/styles-no-raw-dimensions.ts +13 -4
- package/src/rules/styles-no-raw-spacing.ts +12 -4
- package/src/rules/styles-no-raw-typography.ts +13 -4
- package/src/rules/tiers.ts +1 -0
- package/src/rules/utils.ts +39 -0
- package/src/tokens/design-token-parser.test.ts +131 -0
- package/src/tokens/design-token-parser.ts +362 -49
- package/src/types.ts +2 -2
- package/dist/chunk-BAHCOAVG.js.map +0 -1
- package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
|
@@ -125,20 +125,19 @@ describe("components/prefer-library — canonical direction containment", () =>
|
|
|
125
125
|
describe("components/prefer-library — #9e directory import-path identity", () => {
|
|
126
126
|
const CANONICAL_DIR = "src/components/ui";
|
|
127
127
|
|
|
128
|
-
function directoryIndex() {
|
|
128
|
+
function directoryIndex(options: { shadowEnabled?: boolean } = {}) {
|
|
129
129
|
const ix = new FactIndex();
|
|
130
130
|
ix.addMany(
|
|
131
131
|
compileGlobalGovernanceFacts({
|
|
132
|
+
canonicalSources: [
|
|
133
|
+
{ kind: "directory", path: CANONICAL_DIR, include: ["Button", "Input"] },
|
|
134
|
+
],
|
|
132
135
|
rules: {
|
|
133
136
|
"components/prefer-library": {
|
|
134
137
|
enabled: true,
|
|
135
138
|
severity: "warning",
|
|
136
|
-
options: {
|
|
137
|
-
canonicalSources: [
|
|
138
|
-
{ kind: "directory", path: CANONICAL_DIR, include: ["Button", "Input"] },
|
|
139
|
-
],
|
|
140
|
-
},
|
|
141
139
|
},
|
|
140
|
+
...(options.shadowEnabled === false ? { "components/shadow-component": false } : {}),
|
|
142
141
|
},
|
|
143
142
|
})
|
|
144
143
|
);
|
|
@@ -184,31 +183,12 @@ describe("components/prefer-library — #9e directory import-path identity", ()
|
|
|
184
183
|
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
|
|
185
184
|
});
|
|
186
185
|
|
|
187
|
-
it("
|
|
186
|
+
it("does not advertise a directory path as an import redirect for a local impostor", () => {
|
|
188
187
|
const ix = directoryIndex();
|
|
189
188
|
// No usage_component fact: the impostor's import resolved OUTSIDE the dir.
|
|
190
189
|
addButtonUsage(ix, { source: "../features/FakeButton" });
|
|
191
190
|
|
|
192
|
-
|
|
193
|
-
expect(findings).toHaveLength(1);
|
|
194
|
-
expect(findings[0]).toMatchObject({
|
|
195
|
-
ruleId: "components/prefer-library",
|
|
196
|
-
code: "FUI1004",
|
|
197
|
-
fix: {
|
|
198
|
-
kind: "replaceImport",
|
|
199
|
-
from: "../features/FakeButton",
|
|
200
|
-
to: CANONICAL_DIR,
|
|
201
|
-
deterministic: false,
|
|
202
|
-
},
|
|
203
|
-
attributes: {
|
|
204
|
-
suggestedComponent: "Button",
|
|
205
|
-
suggestedImport: CANONICAL_DIR,
|
|
206
|
-
advisory: true,
|
|
207
|
-
contractProof: "missing",
|
|
208
|
-
},
|
|
209
|
-
});
|
|
210
|
-
// Advisory only — never a hard error/block.
|
|
211
|
-
expect(findings[0].level).toBe("warn");
|
|
191
|
+
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
|
|
212
192
|
});
|
|
213
193
|
|
|
214
194
|
it("does not flag a same-name Button imported from a third-party package (collision guard)", () => {
|
|
@@ -282,13 +262,22 @@ describe("components/prefer-library — #9e directory import-path identity", ()
|
|
|
282
262
|
location: { file: "src/App.tsx", line: 9, column: 4 },
|
|
283
263
|
});
|
|
284
264
|
ix.addMany([shadowRoot, unrelated]);
|
|
265
|
+
ix.add(
|
|
266
|
+
makeComponentDefinitionFact({
|
|
267
|
+
file: "src/components/MyButton.tsx",
|
|
268
|
+
exportName: "MyButton",
|
|
269
|
+
componentKey: "src/components/MyButton.tsx#MyButton",
|
|
270
|
+
renderRoot: { resolution: "intrinsic", tag: "button", interactive: true },
|
|
271
|
+
propSurface: [],
|
|
272
|
+
})
|
|
273
|
+
);
|
|
285
274
|
ix.add(
|
|
286
275
|
makeComponentIdentityFact({
|
|
287
276
|
componentKey: "src/components/MyButton.tsx#MyButton",
|
|
288
277
|
state: "shadow",
|
|
289
278
|
confidence: "confirmed",
|
|
290
279
|
canonicalTarget: "Button",
|
|
291
|
-
evidence: [shadowRoot.id],
|
|
280
|
+
evidence: [shadowRoot.id, unrelated.id],
|
|
292
281
|
})
|
|
293
282
|
);
|
|
294
283
|
|
|
@@ -296,4 +285,62 @@ describe("components/prefer-library — #9e directory import-path identity", ()
|
|
|
296
285
|
expect(findings).toHaveLength(1);
|
|
297
286
|
expect(findings[0]?.location.file).toBe("src/App.tsx");
|
|
298
287
|
});
|
|
288
|
+
|
|
289
|
+
it("keeps the usage-level finding when shadow-component is explicitly disabled", () => {
|
|
290
|
+
const ix = directoryIndex({ shadowEnabled: false });
|
|
291
|
+
const shadowRoot = makeUsageNodeFact({
|
|
292
|
+
file: "src/components/MyButton.tsx",
|
|
293
|
+
nodePath: "0:0",
|
|
294
|
+
element: "button",
|
|
295
|
+
interactive: true,
|
|
296
|
+
location: { file: "src/components/MyButton.tsx", line: 5, column: 2 },
|
|
297
|
+
});
|
|
298
|
+
ix.addMany([
|
|
299
|
+
shadowRoot,
|
|
300
|
+
makeComponentDefinitionFact({
|
|
301
|
+
file: "src/components/MyButton.tsx",
|
|
302
|
+
exportName: "MyButton",
|
|
303
|
+
componentKey: "src/components/MyButton.tsx#MyButton",
|
|
304
|
+
renderRoot: { resolution: "intrinsic", tag: "button", interactive: true },
|
|
305
|
+
propSurface: [],
|
|
306
|
+
}),
|
|
307
|
+
makeComponentIdentityFact({
|
|
308
|
+
componentKey: "src/components/MyButton.tsx#MyButton",
|
|
309
|
+
state: "shadow",
|
|
310
|
+
confidence: "confirmed",
|
|
311
|
+
canonicalTarget: "Button",
|
|
312
|
+
evidence: [shadowRoot.id],
|
|
313
|
+
}),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
expect(ruleComponentsPreferLibrary(ix)).toEqual([
|
|
317
|
+
expect.objectContaining({
|
|
318
|
+
ruleId: "components/prefer-library",
|
|
319
|
+
location: expect.objectContaining({ file: "src/components/MyButton.tsx" }),
|
|
320
|
+
}),
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("keeps the usage-level finding for an orphan shadow identity that cannot emit", () => {
|
|
325
|
+
const ix = directoryIndex();
|
|
326
|
+
const shadowRoot = makeUsageNodeFact({
|
|
327
|
+
file: "src/components/MyButton.tsx",
|
|
328
|
+
nodePath: "0:0",
|
|
329
|
+
element: "button",
|
|
330
|
+
interactive: true,
|
|
331
|
+
location: { file: "src/components/MyButton.tsx", line: 5, column: 2 },
|
|
332
|
+
});
|
|
333
|
+
ix.addMany([
|
|
334
|
+
shadowRoot,
|
|
335
|
+
makeComponentIdentityFact({
|
|
336
|
+
componentKey: "src/components/MyButton.tsx#MyButton",
|
|
337
|
+
state: "shadow",
|
|
338
|
+
confidence: "confirmed",
|
|
339
|
+
canonicalTarget: "Button",
|
|
340
|
+
evidence: [shadowRoot.id],
|
|
341
|
+
}),
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(1);
|
|
345
|
+
});
|
|
299
346
|
});
|
|
@@ -279,7 +279,10 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
279
279
|
// comparison of the raw specifier could never recognize them, so the resolved
|
|
280
280
|
// fact is the identity of record. Never flag them as import impostors.
|
|
281
281
|
const canonicalDirectoryResolvedNodes = indexCanonicalDirectoryResolvedNodes(ix, sources);
|
|
282
|
-
const shadowRenderRootNodeIds =
|
|
282
|
+
const shadowRenderRootNodeIds =
|
|
283
|
+
ix.policy.ruleConfig("components/shadow-component")?.enabled === true
|
|
284
|
+
? indexShadowRenderRootNodeIds(ix)
|
|
285
|
+
: new Set<FactId>();
|
|
283
286
|
const findings: Finding[] = [];
|
|
284
287
|
const seenImportFixes = new Set<string>();
|
|
285
288
|
|
|
@@ -555,7 +558,10 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
555
558
|
continue;
|
|
556
559
|
}
|
|
557
560
|
}
|
|
558
|
-
const
|
|
561
|
+
const importReady = suggestion.kind !== "directory";
|
|
562
|
+
const suggestedImport = importReady
|
|
563
|
+
? sourceLabel(suggestion)
|
|
564
|
+
: "the canonical component directory (resolve its configured import alias)";
|
|
559
565
|
const precisionTier = builtIn?.tier ?? "exact-html";
|
|
560
566
|
const rawElement = formatRawElement(node, props);
|
|
561
567
|
|
|
@@ -563,7 +569,9 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
563
569
|
makeFinding({
|
|
564
570
|
ruleId: RULE_ID,
|
|
565
571
|
ruleVersion: RULE_VERSION,
|
|
566
|
-
severity:
|
|
572
|
+
severity: importReady
|
|
573
|
+
? severityForTier(policy.severity, precisionTier)
|
|
574
|
+
: capAdvisorySeverity(policy.severity),
|
|
567
575
|
message: messageForBuiltInMatch({
|
|
568
576
|
node,
|
|
569
577
|
rawElement,
|
|
@@ -580,7 +588,8 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
580
588
|
suggestedComponent,
|
|
581
589
|
suggestedImport,
|
|
582
590
|
},
|
|
583
|
-
...(
|
|
591
|
+
...(importReady &&
|
|
592
|
+
!isRawHtmlAdvisoryTier(precisionTier) &&
|
|
584
593
|
isIdentifier(node.element) &&
|
|
585
594
|
isIdentifier(suggestedComponent)
|
|
586
595
|
? {
|
|
@@ -599,13 +608,15 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
599
608
|
attributes: {
|
|
600
609
|
rawValue: node.element,
|
|
601
610
|
suggestedComponent,
|
|
602
|
-
suggestedImport,
|
|
603
611
|
suggestedImportSourceKind: suggestion.kind,
|
|
612
|
+
...(importReady
|
|
613
|
+
? { suggestedImport }
|
|
614
|
+
: { canonicalDirectory: suggestion.path, importReady: false }),
|
|
604
615
|
precisionTier,
|
|
605
616
|
propCompatibility: "unknown",
|
|
606
617
|
...(builtIn?.matchedRole ? { matchedRole: builtIn.matchedRole } : {}),
|
|
607
618
|
...(builtIn?.inputType ? { matchedInputType: builtIn.inputType } : {}),
|
|
608
|
-
...(isRawHtmlAdvisoryTier(precisionTier) ? { advisory: true } : {}),
|
|
619
|
+
...(isRawHtmlAdvisoryTier(precisionTier) || !importReady ? { advisory: true } : {}),
|
|
609
620
|
},
|
|
610
621
|
})
|
|
611
622
|
);
|
|
@@ -634,11 +645,24 @@ function indexShadowRenderRootNodeIds(ix: FactIndex): Set<FactId> {
|
|
|
634
645
|
const identities = ix.byKind("component_identity");
|
|
635
646
|
if (identities.length === 0) return new Set();
|
|
636
647
|
|
|
648
|
+
const definitions = new Map(
|
|
649
|
+
ix
|
|
650
|
+
.byKind("component_definition")
|
|
651
|
+
.map((definition) => [definition.componentKey, definition] as const)
|
|
652
|
+
);
|
|
637
653
|
const nodeIds = new Set<FactId>();
|
|
638
654
|
for (const identity of identities) {
|
|
639
655
|
if (identity.state !== "shadow") continue;
|
|
656
|
+
// Suppress the usage-level duplicate only when the definition-level shadow
|
|
657
|
+
// rule can actually emit. An orphan identity fact must not turn governance
|
|
658
|
+
// into a no-op by hiding the only actionable finding.
|
|
659
|
+
const definition = definitions.get(identity.componentKey);
|
|
660
|
+
if (!definition) continue;
|
|
640
661
|
for (const evidenceId of identity.evidence) {
|
|
641
|
-
|
|
662
|
+
const evidence = ix.get(evidenceId);
|
|
663
|
+
if (evidence?.kind === "usage_node" && evidence.file === definition.file) {
|
|
664
|
+
nodeIds.add(evidenceId);
|
|
665
|
+
}
|
|
642
666
|
}
|
|
643
667
|
}
|
|
644
668
|
return nodeIds;
|
|
@@ -1115,14 +1139,10 @@ function sourceLabel(source: CanonicalSource): string {
|
|
|
1115
1139
|
function canonicalImportPath(source: CanonicalSource): string | undefined {
|
|
1116
1140
|
if (source.kind === "npm") return source.specifier;
|
|
1117
1141
|
if (source.kind === "registry") return source.importPath;
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
// include list: without curated NAMES, `sourceIncludesComponent` matches every
|
|
1123
|
-
// PascalCase import, so a redirect target here would blanket-flag them all
|
|
1124
|
-
// (#224). Include-less directory sources keep the old no-target behavior.
|
|
1125
|
-
return source.include?.length ? source.path : undefined;
|
|
1142
|
+
// A filesystem directory proves ownership but is not necessarily a valid
|
|
1143
|
+
// module specifier. Only an explicit mapping/registry importPath can produce
|
|
1144
|
+
// an import redirect.
|
|
1145
|
+
return undefined;
|
|
1126
1146
|
}
|
|
1127
1147
|
|
|
1128
1148
|
/**
|
|
@@ -54,24 +54,36 @@ describe("components/shadow-component", () => {
|
|
|
54
54
|
expect(isDenyEligible(finding!, true)).toBe(false);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
it("
|
|
57
|
+
it("allows a confirmed error-severity shadow to enter the shared CI/write deny-set", () => {
|
|
58
58
|
const finding = ruleComponentsShadowComponent(shadowIndex({ severity: "error" }))[0];
|
|
59
59
|
|
|
60
60
|
expect(finding?.level).toBe("error");
|
|
61
61
|
expect(finding?.attributes).not.toHaveProperty("advisory");
|
|
62
|
-
expect(BLOCKING_RULE_ALLOWLIST.has("components/shadow-component")).toBe(
|
|
63
|
-
expect(isDenyEligible(finding!, false)).toBe(
|
|
62
|
+
expect(BLOCKING_RULE_ALLOWLIST.has("components/shadow-component")).toBe(true);
|
|
63
|
+
expect(isDenyEligible(finding!, false)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("never denies a likely shadow classification, even when CI severity is error", () => {
|
|
67
|
+
const finding = ruleComponentsShadowComponent(
|
|
68
|
+
shadowIndex({ confidence: "likely", severity: "error" })
|
|
69
|
+
)[0];
|
|
70
|
+
|
|
71
|
+
expect(finding).toMatchObject({
|
|
72
|
+
level: "error",
|
|
73
|
+
attributes: {
|
|
74
|
+
state: "shadow",
|
|
75
|
+
confidence: "likely",
|
|
76
|
+
canonicalTarget: "Button",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(isDenyEligible(finding!, true)).toBe(false);
|
|
64
80
|
});
|
|
65
81
|
|
|
66
82
|
it("throws rather than emitting an evidence-free finding", () => {
|
|
67
83
|
const ix = shadowIndex();
|
|
68
84
|
const identity = ix.byKind("component_identity")[0]!;
|
|
69
85
|
const empty = new FactIndex();
|
|
70
|
-
empty.addMany(
|
|
71
|
-
ix
|
|
72
|
-
.all()
|
|
73
|
-
.filter((fact) => fact.id !== identity.id)
|
|
74
|
-
);
|
|
86
|
+
empty.addMany(ix.all().filter((fact) => fact.id !== identity.id));
|
|
75
87
|
empty.add(makeComponentIdentityFact({ ...identity, evidence: [] }));
|
|
76
88
|
|
|
77
89
|
expect(() => ruleComponentsShadowComponent(empty)).toThrow(
|
|
@@ -83,7 +95,7 @@ describe("components/shadow-component", () => {
|
|
|
83
95
|
function shadowIndex(
|
|
84
96
|
options: {
|
|
85
97
|
state?: "shadow" | "variant";
|
|
86
|
-
confidence?: "confirmed" | "review";
|
|
98
|
+
confidence?: "confirmed" | "likely" | "review";
|
|
87
99
|
severity?: "warn" | "error";
|
|
88
100
|
definitionLine?: number;
|
|
89
101
|
} = {}
|
|
@@ -11,6 +11,18 @@ const base = {
|
|
|
11
11
|
fix: { deterministic: true },
|
|
12
12
|
} as unknown as Finding;
|
|
13
13
|
|
|
14
|
+
const confirmedShadowAttributes = {
|
|
15
|
+
state: "shadow",
|
|
16
|
+
confidence: "confirmed",
|
|
17
|
+
canonicalTarget: "Button",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const confirmedShadow = {
|
|
21
|
+
ruleId: "components/shadow-component",
|
|
22
|
+
severity: "serious",
|
|
23
|
+
attributes: confirmedShadowAttributes,
|
|
24
|
+
} as unknown as Finding;
|
|
25
|
+
|
|
14
26
|
describe("isDenyEligible", () => {
|
|
15
27
|
it("denies a confident, allowlisted, CI-gating, deterministic finding", () => {
|
|
16
28
|
expect(isDenyEligible(base, false)).toBe(true);
|
|
@@ -35,6 +47,15 @@ describe("isDenyEligible", () => {
|
|
|
35
47
|
expect(isDenyEligible(warn, true)).toBe(true);
|
|
36
48
|
});
|
|
37
49
|
|
|
50
|
+
it("denies a source-backed off-contract token at error severity", () => {
|
|
51
|
+
const tokenDrift = {
|
|
52
|
+
ruleId: "tokens/css-vars-must-be-defined",
|
|
53
|
+
severity: "serious",
|
|
54
|
+
evidenceGrade: "source_backed",
|
|
55
|
+
} as unknown as Finding;
|
|
56
|
+
expect(isDenyEligible(tokenDrift, false)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
38
59
|
it.each(["none", "runtime_advisory", "provenance_bound"] as const)(
|
|
39
60
|
"never gates or denies %s evidence",
|
|
40
61
|
(evidenceGrade) => {
|
|
@@ -64,17 +85,66 @@ describe("isDenyEligible", () => {
|
|
|
64
85
|
const exactComponent = {
|
|
65
86
|
...base,
|
|
66
87
|
fix: { deterministic: false },
|
|
67
|
-
attributes: {
|
|
88
|
+
attributes: {
|
|
89
|
+
precisionTier: "exact-html",
|
|
90
|
+
propCompatibility: "observed-complete",
|
|
91
|
+
suggestedComponent: "Button",
|
|
92
|
+
},
|
|
68
93
|
} as unknown as Finding;
|
|
69
94
|
expect(isDenyEligible(exactComponent, false)).toBe(true);
|
|
70
95
|
});
|
|
71
96
|
|
|
72
|
-
it("
|
|
97
|
+
it("denies an exact canonical bypass even when no deterministic replacement is available", () => {
|
|
73
98
|
const incompleteComponent = {
|
|
74
99
|
...base,
|
|
75
100
|
fix: { deterministic: false },
|
|
76
|
-
attributes: {
|
|
101
|
+
attributes: {
|
|
102
|
+
precisionTier: "exact-html",
|
|
103
|
+
propCompatibility: "unknown",
|
|
104
|
+
suggestedComponent: "Button",
|
|
105
|
+
suggestedImportSourceKind: "directory",
|
|
106
|
+
},
|
|
77
107
|
} as unknown as Finding;
|
|
78
|
-
expect(isDenyEligible(incompleteComponent, false)).toBe(
|
|
108
|
+
expect(isDenyEligible(incompleteComponent, false)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("denies a confirmed, non-advisory shadow that gates CI", () => {
|
|
112
|
+
expect(isDenyEligible(confirmedShadow, false)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does not deny a non-confirmed or malformed shadow classification", () => {
|
|
116
|
+
expect(
|
|
117
|
+
isDenyEligible(
|
|
118
|
+
{
|
|
119
|
+
...confirmedShadow,
|
|
120
|
+
attributes: { ...confirmedShadowAttributes, confidence: "likely" },
|
|
121
|
+
} as unknown as Finding,
|
|
122
|
+
false
|
|
123
|
+
)
|
|
124
|
+
).toBe(false);
|
|
125
|
+
expect(
|
|
126
|
+
isDenyEligible(
|
|
127
|
+
{
|
|
128
|
+
...confirmedShadow,
|
|
129
|
+
attributes: { ...confirmedShadowAttributes, state: "variant" },
|
|
130
|
+
} as unknown as Finding,
|
|
131
|
+
false
|
|
132
|
+
)
|
|
133
|
+
).toBe(false);
|
|
134
|
+
expect(
|
|
135
|
+
isDenyEligible(
|
|
136
|
+
{
|
|
137
|
+
...confirmedShadow,
|
|
138
|
+
attributes: { state: "shadow", confidence: "confirmed" },
|
|
139
|
+
} as unknown as Finding,
|
|
140
|
+
false
|
|
141
|
+
)
|
|
142
|
+
).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("only denies a warn-level confirmed shadow under failOnWarnings", () => {
|
|
146
|
+
const warn = { ...confirmedShadow, severity: "moderate" } as unknown as Finding;
|
|
147
|
+
expect(isDenyEligible(warn, false)).toBe(false);
|
|
148
|
+
expect(isDenyEligible(warn, true)).toBe(true);
|
|
79
149
|
});
|
|
80
150
|
});
|
package/src/rules/emit-gate.ts
CHANGED
|
@@ -27,7 +27,9 @@ export const BLOCKING_RULE_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
|
27
27
|
"styles/no-raw-spacing", // off-scale / untokenized spacing literal
|
|
28
28
|
"tailwind/off-scale-spacing-token", // off-scale Tailwind spacing utility
|
|
29
29
|
"components/prefer-library", // raw element bypasses a canonical component
|
|
30
|
+
"components/shadow-component", // confirmed local semantic control shadows a canonical
|
|
30
31
|
"components/preferred-component", // imported component bypasses its canonical
|
|
32
|
+
"tokens/css-vars-must-be-defined", // off-contract custom property reference
|
|
31
33
|
]);
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -52,18 +54,31 @@ export function isDenyEligible(finding: Finding, failOnWarnings: boolean): boole
|
|
|
52
54
|
// their severity or sets failOnWarnings. Same "only deny when the engine is
|
|
53
55
|
// confident" principle as the non-deterministic-fix guard below.
|
|
54
56
|
if (finding.attributes?.advisory === true) return false;
|
|
57
|
+
// A shadow classification is deterministic enough to deny only when the
|
|
58
|
+
// identity engine confirmed both the shadow state and its canonical target.
|
|
59
|
+
// Likely/review identities remain reportable, but the write gate stays a
|
|
60
|
+
// strict high-confidence subset of CI.
|
|
61
|
+
if (
|
|
62
|
+
finding.ruleId === "components/shadow-component" &&
|
|
63
|
+
(finding.attributes?.state !== "shadow" ||
|
|
64
|
+
finding.attributes?.confidence !== "confirmed" ||
|
|
65
|
+
typeof finding.attributes?.canonicalTarget !== "string")
|
|
66
|
+
) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
55
69
|
if (!gatesCi(finding, failOnWarnings)) return false;
|
|
56
70
|
// Most explicitly non-deterministic fixes are the engine's own low-confidence
|
|
57
|
-
// signal (for example, several tokens share a value).
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
71
|
+
// signal (for example, several tokens share a value). Exact raw-HTML bypasses
|
|
72
|
+
// are different: the declared canonical source proves the policy violation
|
|
73
|
+
// even when the engine cannot safely rewrite every prop. Enforcement and
|
|
74
|
+
// autofix confidence are intentionally separate; advisory precision tiers
|
|
75
|
+
// were already rejected above.
|
|
62
76
|
if (finding.fix?.deterministic === false) {
|
|
63
|
-
|
|
64
|
-
finding.ruleId
|
|
65
|
-
finding.attributes?.
|
|
66
|
-
|
|
77
|
+
const exactCanonicalBypass =
|
|
78
|
+
finding.ruleId === "components/prefer-library" &&
|
|
79
|
+
finding.attributes?.precisionTier === "exact-html" &&
|
|
80
|
+
typeof finding.attributes?.suggestedComponent === "string";
|
|
81
|
+
if (!exactCanonicalBypass) {
|
|
67
82
|
return false;
|
|
68
83
|
}
|
|
69
84
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
RULE_FAMILY_IDS,
|
|
5
|
+
RULE_FAMILY_MEMBERS,
|
|
6
|
+
isRuleFamilyId,
|
|
7
|
+
ruleFamilyMembers,
|
|
8
|
+
} from "./families.js";
|
|
9
|
+
import { compileGlobalGovernanceFacts } from "../facts/index.js";
|
|
10
|
+
import { RULES } from "./index.js";
|
|
11
|
+
import { RULE_TIER } from "./tiers.js";
|
|
12
|
+
|
|
13
|
+
// The defect this file locks down: the family expansion used to be written out three
|
|
14
|
+
// times (engine finding-overrides, fact compiler, integrity roster) and the three
|
|
15
|
+
// disagreed — `styles/no-raw-typography` was a member in one and absent from another,
|
|
16
|
+
// with nothing asserting they matched. There is one definition now; these tests are what
|
|
17
|
+
// keeps a fourth copy from being worth writing.
|
|
18
|
+
describe("rule families", () => {
|
|
19
|
+
it("expands only to rule ids that are actually registered", () => {
|
|
20
|
+
const registered = new Set(RULES.map((rule) => rule.id));
|
|
21
|
+
const unregistered = Object.values(RULE_FAMILY_MEMBERS)
|
|
22
|
+
.flat()
|
|
23
|
+
.filter((ruleId) => !registered.has(ruleId));
|
|
24
|
+
|
|
25
|
+
expect(unregistered).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("classifies every member in the tier map", () => {
|
|
29
|
+
const unclassified = Object.values(RULE_FAMILY_MEMBERS)
|
|
30
|
+
.flat()
|
|
31
|
+
.filter((ruleId) => !(ruleId in RULE_TIER));
|
|
32
|
+
|
|
33
|
+
expect(unclassified).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// A family id is a config vocabulary word, not a rule: nothing runs under it. If one
|
|
37
|
+
// ever collided with a real rule id, `ruleFamilyMembers` would silently expand that
|
|
38
|
+
// rule into a different set.
|
|
39
|
+
it("keeps family ids disjoint from rule ids", () => {
|
|
40
|
+
const registered = new Set(RULES.map((rule) => rule.id));
|
|
41
|
+
expect([...RULE_FAMILY_IDS].filter((id) => registered.has(id))).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The member whose absence caused the original divergence. Asserting the definition is
|
|
45
|
+
// not enough — this pins the BEHAVIOR the consolidation changed: a family entry now
|
|
46
|
+
// compiles an effective rule config for typography like it always did for color.
|
|
47
|
+
it("compiles a rule config for every member of an authored family entry", () => {
|
|
48
|
+
const compiled = compileGlobalGovernanceFacts({
|
|
49
|
+
rules: { "tokens/hardcoded-values": { enabled: true, severity: "warn" } },
|
|
50
|
+
})
|
|
51
|
+
.filter((fact) => fact.kind === "governance_rule_config")
|
|
52
|
+
.map((fact) => fact.ruleId)
|
|
53
|
+
.sort();
|
|
54
|
+
|
|
55
|
+
// A superset check: the family id keeps its own passthrough entry alongside the
|
|
56
|
+
// expansion. What matters is that no member is missing.
|
|
57
|
+
for (const member of RULE_FAMILY_MEMBERS["tokens/hardcoded-values"]!) {
|
|
58
|
+
expect(compiled).toContain(member);
|
|
59
|
+
}
|
|
60
|
+
expect(compiled).toContain("styles/no-raw-typography");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("expands a family and passes a concrete rule id through unchanged", () => {
|
|
64
|
+
expect(ruleFamilyMembers("tokens/hardcoded-values")).toContain("styles/no-raw-typography");
|
|
65
|
+
expect(ruleFamilyMembers("styles/no-raw-color")).toEqual(["styles/no-raw-color"]);
|
|
66
|
+
expect(isRuleFamilyId("tokens/hardcoded-values")).toBe(true);
|
|
67
|
+
expect(isRuleFamilyId("styles/no-raw-color")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule families — the single source of truth for the broad ids a config may name
|
|
3
|
+
* instead of a concrete rule.
|
|
4
|
+
*
|
|
5
|
+
* `govern.rules` accepts two vocabularies: a concrete rule id (`styles/no-raw-color`)
|
|
6
|
+
* and a **family** id (`tokens/hardcoded-values`) that stands for several rules at once.
|
|
7
|
+
* Presets and Cloud-served policies author the family form, so this expansion decides
|
|
8
|
+
* what a preset or a served policy actually turns on.
|
|
9
|
+
*
|
|
10
|
+
* It used to be written out three times — the engine's finding-override pass, the fact
|
|
11
|
+
* compiler, and the governance-integrity roster — and the three disagreed:
|
|
12
|
+
* `styles/no-raw-typography` was a member of `tokens/hardcoded-values` in the engine and
|
|
13
|
+
* absent from the compiler, so one config produced two different answers about the same
|
|
14
|
+
* rule and nothing asserted they matched. One definition, one expansion, one coverage
|
|
15
|
+
* test (`families.test.ts` checks every member is a registered rule id).
|
|
16
|
+
*
|
|
17
|
+
* Family ids are NOT rule ids: nothing runs under `tokens/hardcoded-values`, it only
|
|
18
|
+
* expands. Adding a rule to a family is a behavior change for every config that names
|
|
19
|
+
* the family — classify new rules here in the same change that registers them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const RULE_FAMILY_MEMBERS: Readonly<Record<string, readonly string[]>> = {
|
|
23
|
+
"tokens/hardcoded-values": [
|
|
24
|
+
"styles/no-raw-color",
|
|
25
|
+
"styles/no-raw-spacing",
|
|
26
|
+
"styles/no-raw-typography",
|
|
27
|
+
"tokens/require-dual-fallback",
|
|
28
|
+
"theme/no-theme-coupled-literal",
|
|
29
|
+
],
|
|
30
|
+
"components/usage": [
|
|
31
|
+
"components/forbidden-prop-value",
|
|
32
|
+
"components/preferred-component",
|
|
33
|
+
"components/unknown-prop",
|
|
34
|
+
"props/invalid-value",
|
|
35
|
+
],
|
|
36
|
+
"a11y/wcag": ["a11y/required-accessible-name", "a11y/standard"],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The family ids themselves — recognized in `govern.rules`, never executed. */
|
|
40
|
+
export const RULE_FAMILY_IDS: ReadonlySet<string> = new Set(Object.keys(RULE_FAMILY_MEMBERS));
|
|
41
|
+
|
|
42
|
+
export function isRuleFamilyId(ruleId: string): boolean {
|
|
43
|
+
return RULE_FAMILY_IDS.has(ruleId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The concrete rule ids a `govern.rules` key applies to: a family expands, anything
|
|
48
|
+
* else is itself. Every consumer of the family vocabulary goes through this.
|
|
49
|
+
*/
|
|
50
|
+
export function ruleFamilyMembers(ruleId: string): readonly string[] {
|
|
51
|
+
return RULE_FAMILY_MEMBERS[ruleId] ?? [ruleId];
|
|
52
|
+
}
|
|
@@ -28,6 +28,7 @@ export const RULE_FIX_AVAILABLE = {
|
|
|
28
28
|
"tokens/upstream-drift": false,
|
|
29
29
|
"theme/no-theme-coupled-literal": false,
|
|
30
30
|
"a11y/required-accessible-name": false,
|
|
31
|
+
"a11y/standard": false,
|
|
31
32
|
"composition/cardinality": false,
|
|
32
33
|
"composition/co-occurrence": false,
|
|
33
34
|
} as const satisfies Readonly<Record<string, boolean>>;
|
package/src/rules/index.ts
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import type { FactIndex } from "../facts/index.js";
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
RULE_VERSION as A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION,
|
|
14
|
+
ruleA11yRequiredAccessibleName,
|
|
15
|
+
} from "./a11y-required-accessible-name.js";
|
|
16
|
+
import { ruleA11yStandard } from "./a11y-standard.js";
|
|
13
17
|
import { ruleComponentsPreferLibrary } from "./components-prefer-library.js";
|
|
14
18
|
import { ruleComponentsShadowComponent } from "./components-shadow-component.js";
|
|
15
19
|
import { ruleComponentsForbiddenPropValue } from "./components-forbidden-prop-value.js";
|
|
@@ -150,9 +154,14 @@ export const RULES: readonly Rule[] = [
|
|
|
150
154
|
},
|
|
151
155
|
{
|
|
152
156
|
id: "a11y/required-accessible-name",
|
|
153
|
-
version:
|
|
157
|
+
version: A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION,
|
|
154
158
|
run: ruleA11yRequiredAccessibleName,
|
|
155
159
|
},
|
|
160
|
+
{
|
|
161
|
+
id: "a11y/standard",
|
|
162
|
+
version: "1",
|
|
163
|
+
run: ruleA11yStandard,
|
|
164
|
+
},
|
|
156
165
|
{
|
|
157
166
|
id: "composition/cardinality",
|
|
158
167
|
version: "1",
|
|
@@ -220,6 +229,7 @@ export { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-define
|
|
|
220
229
|
export { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
|
|
221
230
|
export { ruleThemeNoThemeCoupledLiteral } from "./theme-no-theme-coupled-literal.js";
|
|
222
231
|
export { ruleA11yRequiredAccessibleName } from "./a11y-required-accessible-name.js";
|
|
232
|
+
export { ruleA11yStandard } from "./a11y-standard.js";
|
|
223
233
|
export { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js";
|
|
224
234
|
export {
|
|
225
235
|
RULE_TIER,
|
|
@@ -232,6 +242,12 @@ export {
|
|
|
232
242
|
compareByVocabularyRank,
|
|
233
243
|
} from "./tiers.js";
|
|
234
244
|
export type { RuleTier } from "./tiers.js";
|
|
245
|
+
export {
|
|
246
|
+
RULE_FAMILY_IDS,
|
|
247
|
+
RULE_FAMILY_MEMBERS,
|
|
248
|
+
isRuleFamilyId,
|
|
249
|
+
ruleFamilyMembers,
|
|
250
|
+
} from "./families.js";
|
|
235
251
|
export { customerDefaultRuleStates, fragmentsPresetRuleStates } from "./presets.js";
|
|
236
252
|
export type { PresetRuleState } from "./presets.js";
|
|
237
253
|
export {
|