@usefragments/core 1.0.0 → 1.3.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-R3X7UBHU.js → chunk-57QDBEHQ.js} +101 -6
- package/dist/chunk-57QDBEHQ.js.map +1 -0
- package/dist/{chunk-243QYRUF.js → chunk-MLRDNSSA.js} +88 -47
- package/dist/chunk-MLRDNSSA.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-BOa3KyiJ.d.ts → governance-DYSirwJu.d.ts} +22 -1
- package/dist/index-h_yWj15D.d.ts +6981 -0
- package/dist/index.d.ts +113 -97
- package/dist/index.js +387 -113
- 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 +3 -2584
- package/dist/schemas/index.js +9 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent-format.test.ts +144 -2
- package/src/agent-format.ts +34 -7
- package/src/codes/__tests__/codes.test.ts +57 -7
- package/src/codes/codes.ts +44 -42
- package/src/conform-prove.ts +4 -8
- package/src/contract/index.ts +3 -0
- package/src/contract/preimage.test.ts +50 -0
- package/src/contract/preimage.ts +56 -0
- package/src/evidence.ts +27 -0
- package/src/facts/builders.ts +4 -0
- package/src/facts/fact-index.ts +25 -1
- package/src/facts/facts.test.ts +49 -0
- package/src/facts/types.ts +3 -0
- package/src/governance-integrity.test.ts +2 -0
- package/src/governance-integrity.ts +7 -1
- package/src/index.ts +13 -0
- package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
- package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
- package/src/rules/components-prefer-library.ts +165 -7
- package/src/rules/emit-gate.test.ts +40 -2
- package/src/rules/emit-gate.ts +21 -10
- package/src/rules/finding.ts +3 -0
- package/src/rules/fix-availability.ts +33 -0
- package/src/rules/rules.test.ts +180 -24
- package/src/rules/spacing-resolution.ts +5 -8
- package/src/rules/styles-no-raw-color.ts +32 -23
- package/src/rules/styles-no-raw-dimensions.ts +31 -12
- package/src/rules/styles-no-raw-spacing.ts +33 -11
- package/src/rules/styles-no-raw-typography.ts +37 -15
- package/src/rules/taxonomy.test.ts +19 -3
- package/src/rules/tiers.ts +1 -1
- package/src/rules/tokens-require-dual-fallback.ts +54 -17
- package/src/rules/utils.ts +51 -0
- package/src/schemas/index.ts +106 -4
- package/src/token-types.ts +6 -0
- package/src/tokens/__tests__/source-names.test.ts +42 -0
- package/src/tokens/design-token-parser.test.ts +24 -5
- package/src/tokens/design-token-parser.ts +40 -8
- package/dist/chunk-243QYRUF.js.map +0 -1
- package/dist/chunk-R3X7UBHU.js.map +0 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compileGlobalGovernanceFacts,
|
|
5
|
+
FactIndex,
|
|
6
|
+
makeContractTokenFact,
|
|
7
|
+
makeStyleCssVarsMustBeDefinedFact,
|
|
8
|
+
makeStyleDeclarationFact,
|
|
9
|
+
makeTokenDefinitionFact,
|
|
10
|
+
} from "../../index.js";
|
|
11
|
+
import { ruleTokensCssVarsMustBeDefined } from "../tokens-css-vars-must-be-defined.js";
|
|
12
|
+
import { ruleTokensRequireDualFallback } from "../tokens-require-dual-fallback.js";
|
|
13
|
+
|
|
14
|
+
function declaration(value: string) {
|
|
15
|
+
return makeStyleDeclarationFact({
|
|
16
|
+
file: "src/app.scss",
|
|
17
|
+
selector: ".app",
|
|
18
|
+
declarationPath: "0",
|
|
19
|
+
property: "padding",
|
|
20
|
+
value,
|
|
21
|
+
location: { file: "src/app.scss", line: 1, column: 0 },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function index(...facts: Parameters<FactIndex["addMany"]>[0][]): FactIndex {
|
|
26
|
+
const ix = new FactIndex();
|
|
27
|
+
ix.addMany(
|
|
28
|
+
compileGlobalGovernanceFacts({
|
|
29
|
+
rules: { "tokens/require-dual-fallback": { enabled: true, severity: "error" } },
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
for (const group of facts) ix.addMany(group);
|
|
33
|
+
return ix;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("tokens/require-dual-fallback trust floor", () => {
|
|
37
|
+
it("stays silent for an unregistered token and never invents its Sass symbol (§1)", () => {
|
|
38
|
+
const ix = index([
|
|
39
|
+
makeStyleCssVarsMustBeDefinedFact({ severity: "warn" }),
|
|
40
|
+
makeContractTokenFact({ name: "--fui-space-2" }),
|
|
41
|
+
declaration("var(--fui-space-9)"),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(ruleTokensRequireDualFallback(ix)).toEqual([]);
|
|
45
|
+
const undefinedFindings = ruleTokensCssVarsMustBeDefined(ix);
|
|
46
|
+
expect(undefinedFindings).toHaveLength(1);
|
|
47
|
+
expect(JSON.stringify(undefinedFindings)).not.toContain("$fui-space-9");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("emits a deterministic fix only for a registered Sass variable and rescans clean (§2)", () => {
|
|
51
|
+
const token = makeTokenDefinitionFact({
|
|
52
|
+
name: "$fui-space-2",
|
|
53
|
+
value: "8px",
|
|
54
|
+
category: "spacing",
|
|
55
|
+
referenceFormat: "scss-var",
|
|
56
|
+
});
|
|
57
|
+
const ix = index([token, declaration("var(--fui-space-2)")]);
|
|
58
|
+
|
|
59
|
+
expect(ruleTokensRequireDualFallback(ix)).toMatchObject([
|
|
60
|
+
{
|
|
61
|
+
fix: {
|
|
62
|
+
kind: "replaceStyleValue",
|
|
63
|
+
value: "var(--fui-space-2, $fui-space-2)",
|
|
64
|
+
deterministic: true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const rescanned = index([token, declaration("var(--fui-space-2, $fui-space-2)")]);
|
|
70
|
+
expect(ruleTokensRequireDualFallback(rescanned)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps the finding but emits no fix for a CSS-only token (§2)", () => {
|
|
74
|
+
const ix = index([
|
|
75
|
+
makeTokenDefinitionFact({
|
|
76
|
+
name: "--fui-color-accent",
|
|
77
|
+
value: "#2563eb",
|
|
78
|
+
category: "color",
|
|
79
|
+
referenceFormat: "css-var",
|
|
80
|
+
}),
|
|
81
|
+
declaration("var(--fui-color-accent)"),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const findings = ruleTokensRequireDualFallback(ix);
|
|
85
|
+
expect(findings).toHaveLength(1);
|
|
86
|
+
expect(findings[0].fix).toBeUndefined();
|
|
87
|
+
expect(findings[0].message).toContain("No matching SCSS variable was found");
|
|
88
|
+
expect(JSON.stringify(findings[0])).not.toContain("$fui-color-accent)");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -24,6 +24,8 @@ export const RULE_VERSION = "1";
|
|
|
24
24
|
|
|
25
25
|
interface CanonicalMappingOption {
|
|
26
26
|
name: string;
|
|
27
|
+
/** Internal conform-boundary assertion; only the conform policy builder sets it. */
|
|
28
|
+
conformStatus?: "confirmed";
|
|
27
29
|
canonical?: string;
|
|
28
30
|
htmlEquivalent?: string;
|
|
29
31
|
/**
|
|
@@ -268,7 +270,8 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
268
270
|
if (node.element.includes(".")) continue;
|
|
269
271
|
const props = propsByNode.get(node.id) ?? [];
|
|
270
272
|
const textChildren = textByNode.get(node.id) ?? [];
|
|
271
|
-
const
|
|
273
|
+
const importsForFile = importsByFileAndLocal.get(node.file);
|
|
274
|
+
const imported = importsForFile?.get(node.element);
|
|
272
275
|
const nodeInputType =
|
|
273
276
|
node.element === "input" ? readStaticStringProp(props, "type") : undefined;
|
|
274
277
|
const mapped = findMapping(node, nodeInputType, mappings);
|
|
@@ -281,6 +284,7 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
281
284
|
const suggestedComponent = mapped.name;
|
|
282
285
|
const suggestedImport = importPath ?? "the canonical library";
|
|
283
286
|
const propMapping = mapped.propMapping ?? [];
|
|
287
|
+
const propCompatibility = assessMappedPropCompatibility(props, propMapping);
|
|
284
288
|
const roleMatch = isRoleMatch(node, mapped);
|
|
285
289
|
const precisionTier: RawHtmlPrecisionTier = roleMatch ? "role-reimpl" : "exact-html";
|
|
286
290
|
|
|
@@ -290,6 +294,12 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
290
294
|
!ownedImportsEqual(imported.source, importPath) &&
|
|
291
295
|
node.element === suggestedComponent
|
|
292
296
|
) {
|
|
297
|
+
// A same-name package import is not proof that the package implements
|
|
298
|
+
// our canonical primitive. For example, TanStack Router's `Link` and a
|
|
299
|
+
// design-system `Link` share a local JSX name but have different
|
|
300
|
+
// contracts. Only an unaliased local import is a useful guided
|
|
301
|
+
// candidate; package migrations need an explicit import policy.
|
|
302
|
+
if (!isLocalExactComponentImportRedirectCandidate(imported, suggestedComponent)) continue;
|
|
293
303
|
const key = `${imported.file}\0${imported.local}\0${imported.source}\0${importPath}`;
|
|
294
304
|
if (seenImportFixes.has(key)) continue;
|
|
295
305
|
seenImportFixes.add(key);
|
|
@@ -297,8 +307,8 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
297
307
|
makeFinding({
|
|
298
308
|
ruleId: RULE_ID,
|
|
299
309
|
ruleVersion: RULE_VERSION,
|
|
300
|
-
severity: policy.severity
|
|
301
|
-
message: `<${node.element}>
|
|
310
|
+
severity: capAdvisorySeverity(policy.severity),
|
|
311
|
+
message: `<${node.element}> may need to import from ${importPath}; verify that the local component contract is compatible first.`,
|
|
302
312
|
location: imported.location,
|
|
303
313
|
evidence: ix.evidence([node.id, imported.id, policy.id]),
|
|
304
314
|
fingerprintIdentity: {
|
|
@@ -312,7 +322,7 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
312
322
|
title: `Replace import path with "${importPath}"`,
|
|
313
323
|
from: imported.source,
|
|
314
324
|
to: importPath,
|
|
315
|
-
deterministic:
|
|
325
|
+
deterministic: false,
|
|
316
326
|
},
|
|
317
327
|
attributes: {
|
|
318
328
|
rawValue: imported.source,
|
|
@@ -320,6 +330,8 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
320
330
|
suggestedImport: importPath,
|
|
321
331
|
canonical: mapped.canonical,
|
|
322
332
|
confidence: mapped.confidence,
|
|
333
|
+
advisory: true,
|
|
334
|
+
contractProof: "missing",
|
|
323
335
|
},
|
|
324
336
|
})
|
|
325
337
|
);
|
|
@@ -363,7 +375,11 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
363
375
|
title: `Replace <${node.element}> with <${suggestedComponent}>`,
|
|
364
376
|
from: node.element,
|
|
365
377
|
to: suggestedComponent,
|
|
366
|
-
|
|
378
|
+
// The fact IR cannot prove that it observed every JSX
|
|
379
|
+
// attribute shape (notably dynamic inline styles). The
|
|
380
|
+
// conform AST preflight may promote this guided fix only
|
|
381
|
+
// after it proves the complete prop surface directly.
|
|
382
|
+
deterministic: false,
|
|
367
383
|
},
|
|
368
384
|
}
|
|
369
385
|
: {}),
|
|
@@ -374,8 +390,14 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
374
390
|
canonical: mapped.canonical,
|
|
375
391
|
confidence: mapped.confidence,
|
|
376
392
|
propMapping,
|
|
393
|
+
propCompatibility: propCompatibility.complete ? "observed-complete" : "incomplete",
|
|
394
|
+
...(mapped.conformStatus === "confirmed" ? { canonicalMappingConfirmed: true } : {}),
|
|
395
|
+
...(propCompatibility.unprovenProps.length > 0
|
|
396
|
+
? { unprovenProps: propCompatibility.unprovenProps }
|
|
397
|
+
: {}),
|
|
377
398
|
precisionTier,
|
|
378
399
|
...(roleMatch ? { matchedRole: node.role } : {}),
|
|
400
|
+
...(isRawHtmlAdvisoryTier(precisionTier) ? { advisory: true } : {}),
|
|
379
401
|
},
|
|
380
402
|
})
|
|
381
403
|
);
|
|
@@ -387,7 +409,8 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
387
409
|
// canonical component name is very likely a hand-rolled reimplementation
|
|
388
410
|
// (`<div className={styles.card}>` for a Card). class-name↔component is a
|
|
389
411
|
// naming-intent signal, not structural proof — so this is advisory, capped at
|
|
390
|
-
// warn, carries no auto-fix, and never blocks a write (see emit-gate).
|
|
412
|
+
// warn, carries no auto-fix, and never blocks a write (see emit-gate). DOG-03
|
|
413
|
+
// backlog: deterministic canonical-shell signatures may promote proven matches.
|
|
391
414
|
if (
|
|
392
415
|
(node.element === "div" || node.element === "span") &&
|
|
393
416
|
node.role === undefined &&
|
|
@@ -432,6 +455,57 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
432
455
|
const suggestion = findSuggestionSource(suggestedComponent, sources);
|
|
433
456
|
if (!suggestion) continue;
|
|
434
457
|
if (!builtIn && !shouldSuggest(node, imported)) continue;
|
|
458
|
+
|
|
459
|
+
// An exact component-name match is either already canonical (handled near
|
|
460
|
+
// the top of the loop), a safely redirectable local component import, or an
|
|
461
|
+
// unrelated component that happens to share a name. Never report a
|
|
462
|
+
// replaceComponent X -> X no-op, and never infer that one package's `Link`
|
|
463
|
+
// is interchangeable with another package's `Link`.
|
|
464
|
+
if (node.element === suggestedComponent) {
|
|
465
|
+
const importPath = canonicalImportPath(suggestion);
|
|
466
|
+
if (
|
|
467
|
+
!imported ||
|
|
468
|
+
!importPath ||
|
|
469
|
+
!isLocalExactComponentImportRedirectCandidate(imported, suggestedComponent)
|
|
470
|
+
) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const key = `${imported.file}\0${imported.local}\0${imported.source}\0${importPath}`;
|
|
474
|
+
if (seenImportFixes.has(key)) continue;
|
|
475
|
+
seenImportFixes.add(key);
|
|
476
|
+
findings.push(
|
|
477
|
+
makeFinding({
|
|
478
|
+
ruleId: RULE_ID,
|
|
479
|
+
ruleVersion: RULE_VERSION,
|
|
480
|
+
severity: capAdvisorySeverity(policy.severity),
|
|
481
|
+
message: `<${node.element}> may need to import from ${importPath}; verify that the local component contract is compatible first.`,
|
|
482
|
+
location: imported.location,
|
|
483
|
+
evidence: ix.evidence([node.id, imported.id, policy.id]),
|
|
484
|
+
fingerprintIdentity: {
|
|
485
|
+
file: imported.file,
|
|
486
|
+
local: imported.local,
|
|
487
|
+
from: imported.source,
|
|
488
|
+
to: importPath,
|
|
489
|
+
},
|
|
490
|
+
fix: {
|
|
491
|
+
kind: "replaceImport",
|
|
492
|
+
title: `Replace import path with "${importPath}"`,
|
|
493
|
+
from: imported.source,
|
|
494
|
+
to: importPath,
|
|
495
|
+
deterministic: false,
|
|
496
|
+
},
|
|
497
|
+
attributes: {
|
|
498
|
+
rawValue: imported.source,
|
|
499
|
+
suggestedComponent,
|
|
500
|
+
suggestedImport: importPath,
|
|
501
|
+
suggestedImportSourceKind: suggestion.kind,
|
|
502
|
+
advisory: true,
|
|
503
|
+
contractProof: "missing",
|
|
504
|
+
},
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
435
509
|
// Directory canonical sources are typically derived without an `include`
|
|
436
510
|
// filter (cloud pushes `{ kind: "directory", path }` for every component
|
|
437
511
|
// folder), so `sourceIncludesComponent` matches EVERY name. Without the same
|
|
@@ -495,7 +569,10 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
495
569
|
title: `Replace ${rawElement} with <${suggestedComponent}>`,
|
|
496
570
|
from: node.element,
|
|
497
571
|
to: suggestedComponent,
|
|
498
|
-
|
|
572
|
+
// A canonical source proves that the target exists, not that
|
|
573
|
+
// this native element's complete prop contract is compatible.
|
|
574
|
+
// Only explicit canonical mappings carry that proof.
|
|
575
|
+
deterministic: false,
|
|
499
576
|
},
|
|
500
577
|
}
|
|
501
578
|
: {}),
|
|
@@ -505,6 +582,7 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
505
582
|
suggestedImport,
|
|
506
583
|
suggestedImportSourceKind: suggestion.kind,
|
|
507
584
|
precisionTier,
|
|
585
|
+
propCompatibility: "unknown",
|
|
508
586
|
...(builtIn?.matchedRole ? { matchedRole: builtIn.matchedRole } : {}),
|
|
509
587
|
...(builtIn?.inputType ? { matchedInputType: builtIn.inputType } : {}),
|
|
510
588
|
...(isRawHtmlAdvisoryTier(precisionTier) ? { advisory: true } : {}),
|
|
@@ -689,6 +767,79 @@ function shouldSuggest(node: UsageNodeFact, imported: UsageImportFact | undefine
|
|
|
689
767
|
return imported !== undefined;
|
|
690
768
|
}
|
|
691
769
|
|
|
770
|
+
interface PropCompatibilityAssessment {
|
|
771
|
+
complete: boolean;
|
|
772
|
+
unprovenProps: string[];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Prove that every observed prop can survive the component replacement.
|
|
777
|
+
*
|
|
778
|
+
* An explicit pass-through (`rawProp === canonicalProp`) is proof too. Spreads,
|
|
779
|
+
* missing entries, and value translations that cannot be applied exactly stay
|
|
780
|
+
* unresolved: the canonical mapping proves the target component, but it does
|
|
781
|
+
* not imply that the target forwards the native element's entire prop surface.
|
|
782
|
+
*/
|
|
783
|
+
function assessMappedPropCompatibility(
|
|
784
|
+
props: readonly UsagePropResolvedFact[],
|
|
785
|
+
mappings: ReadonlyArray<NonNullable<CanonicalMappingOption["propMapping"]>[number]>
|
|
786
|
+
): PropCompatibilityAssessment {
|
|
787
|
+
const byRawProp = new Map(mappings.map((mapping) => [mapping.rawProp, mapping]));
|
|
788
|
+
const unprovenProps = props.flatMap((prop) => {
|
|
789
|
+
if (prop.resolution === "spread") return [prop.prop];
|
|
790
|
+
const mapping = byRawProp.get(prop.prop);
|
|
791
|
+
if (!mapping || !mappingCanPreserveProp(mapping, prop)) return [prop.prop];
|
|
792
|
+
return [];
|
|
793
|
+
});
|
|
794
|
+
return { complete: unprovenProps.length === 0, unprovenProps };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function mappingCanPreserveProp(
|
|
798
|
+
mapping: NonNullable<CanonicalMappingOption["propMapping"]>[number],
|
|
799
|
+
prop: UsagePropResolvedFact
|
|
800
|
+
): boolean {
|
|
801
|
+
const renames = mapping.rawProp !== mapping.canonicalProp;
|
|
802
|
+
const valueMap = mapping.valueMap;
|
|
803
|
+
const translatesValue = valueMap !== undefined && Object.keys(valueMap).length > 0;
|
|
804
|
+
|
|
805
|
+
// The conform editor can only rename simple JSX identifiers. A pass-through
|
|
806
|
+
// needs no edit and can therefore retain valid JSX names such as aria-label.
|
|
807
|
+
if (renames && (!isIdentifier(mapping.rawProp) || !isIdentifier(mapping.canonicalProp))) {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
if (!translatesValue) return true;
|
|
811
|
+
if (!isIdentifier(mapping.rawProp)) return false;
|
|
812
|
+
return (
|
|
813
|
+
prop.resolution === "static" &&
|
|
814
|
+
typeof prop.value === "string" &&
|
|
815
|
+
Object.prototype.hasOwnProperty.call(valueMap, prop.value)
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function isLocalExactComponentImportRedirectCandidate(
|
|
820
|
+
usage: UsageImportFact,
|
|
821
|
+
componentName: string
|
|
822
|
+
): boolean {
|
|
823
|
+
return isUnaliasedNamedImport(usage, componentName) && isProjectLocalImportSource(usage.source);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function isUnaliasedNamedImport(usage: UsageImportFact, componentName: string): boolean {
|
|
827
|
+
return isSimpleComponentBinding(usage, componentName) && usage.imported === componentName;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function isSimpleComponentBinding(usage: UsageImportFact, componentName: string): boolean {
|
|
831
|
+
return usage.namespace !== true && usage.local === componentName;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function isProjectLocalImportSource(source: string): boolean {
|
|
835
|
+
return (
|
|
836
|
+
source.startsWith(".") ||
|
|
837
|
+
source.startsWith("/") ||
|
|
838
|
+
source.startsWith("@/") ||
|
|
839
|
+
source.startsWith("~/")
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
692
843
|
/**
|
|
693
844
|
* Case-INSENSITIVE equality for the `htmlEquivalent` ↔ raw-tag comparison.
|
|
694
845
|
* Raw HTML tag names are case-insensitive, but a catalog/author can store a
|
|
@@ -734,6 +885,7 @@ function isCanonicalMappingOption(value: unknown): value is CanonicalMappingOpti
|
|
|
734
885
|
if (record.htmlType !== undefined && typeof record.htmlType !== "string") return false;
|
|
735
886
|
if (record.importPath !== undefined && typeof record.importPath !== "string") return false;
|
|
736
887
|
if (record.confidence !== undefined && typeof record.confidence !== "number") return false;
|
|
888
|
+
if (record.conformStatus !== undefined && record.conformStatus !== "confirmed") return false;
|
|
737
889
|
if (
|
|
738
890
|
record.declaredHtmlEquivalent !== undefined &&
|
|
739
891
|
typeof record.declaredHtmlEquivalent !== "boolean"
|
|
@@ -910,6 +1062,12 @@ function sourceLabel(source: CanonicalSource): string {
|
|
|
910
1062
|
return source.path;
|
|
911
1063
|
}
|
|
912
1064
|
|
|
1065
|
+
function canonicalImportPath(source: CanonicalSource): string | undefined {
|
|
1066
|
+
if (source.kind === "npm") return source.specifier;
|
|
1067
|
+
if (source.kind === "registry") return source.importPath;
|
|
1068
|
+
return undefined;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
913
1071
|
function isCanonicalSourceImplementationFile(
|
|
914
1072
|
file: string,
|
|
915
1073
|
sources: readonly CanonicalSource[]
|
|
@@ -35,8 +35,46 @@ describe("isDenyEligible", () => {
|
|
|
35
35
|
expect(isDenyEligible(warn, true)).toBe(true);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
it("
|
|
39
|
-
|
|
38
|
+
it.each(["none", "runtime_advisory", "provenance_bound"] as const)(
|
|
39
|
+
"never gates or denies %s evidence",
|
|
40
|
+
(evidenceGrade) => {
|
|
41
|
+
const lowEvidence = { ...base, evidenceGrade } as unknown as Finding;
|
|
42
|
+
expect(isDenyEligible(lowEvidence, false)).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it.each(["source_backed", "receipt_backed"] as const)(
|
|
47
|
+
"allows %s evidence to reach the remaining deny checks",
|
|
48
|
+
(evidenceGrade) => {
|
|
49
|
+
const proven = { ...base, evidenceGrade } as unknown as Finding;
|
|
50
|
+
expect(isDenyEligible(proven, false)).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
it("does not deny an explicitly non-deterministic token heuristic", () => {
|
|
55
|
+
const heuristic = {
|
|
56
|
+
...base,
|
|
57
|
+
ruleId: "styles/no-raw-color",
|
|
58
|
+
fix: { deterministic: false },
|
|
59
|
+
} as unknown as Finding;
|
|
40
60
|
expect(isDenyEligible(heuristic, false)).toBe(false);
|
|
41
61
|
});
|
|
62
|
+
|
|
63
|
+
it("denies an exact canonical-component violation while withholding its generic fix", () => {
|
|
64
|
+
const exactComponent = {
|
|
65
|
+
...base,
|
|
66
|
+
fix: { deterministic: false },
|
|
67
|
+
attributes: { precisionTier: "exact-html", propCompatibility: "observed-complete" },
|
|
68
|
+
} as unknown as Finding;
|
|
69
|
+
expect(isDenyEligible(exactComponent, false)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("keeps a component replacement advisory when observed props are not proven compatible", () => {
|
|
73
|
+
const incompleteComponent = {
|
|
74
|
+
...base,
|
|
75
|
+
fix: { deterministic: false },
|
|
76
|
+
attributes: { precisionTier: "exact-html", propCompatibility: "incomplete" },
|
|
77
|
+
} as unknown as Finding;
|
|
78
|
+
expect(isDenyEligible(incompleteComponent, false)).toBe(false);
|
|
79
|
+
});
|
|
42
80
|
});
|
package/src/rules/emit-gate.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `fix` shape — no Node APIs, no config off disk, no clock. The CLI hook
|
|
11
11
|
* re-exports `isDenyEligible` from here.
|
|
12
12
|
*/
|
|
13
|
+
import { canBlock } from "../evidence.js";
|
|
13
14
|
import { severityLevel } from "../severity.js";
|
|
14
15
|
import type { Finding } from "./types.js";
|
|
15
16
|
|
|
@@ -30,16 +31,18 @@ export const BLOCKING_RULE_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
|
30
31
|
]);
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* A finding gates CI only when its evidence is source-backed or stronger, it is
|
|
35
|
+
* not explicitly advisory, and its severity is configured to gate. Missing
|
|
36
|
+
* grades are treated as source-backed for compatibility with older reports.
|
|
36
37
|
*/
|
|
37
38
|
export function gatesCi(finding: Finding, failOnWarnings: boolean): boolean {
|
|
39
|
+
if (!canBlock(finding.evidenceGrade ?? "source_backed")) return false;
|
|
40
|
+
if (finding.attributes?.advisory === true) return false;
|
|
38
41
|
const level = severityLevel(finding.severity);
|
|
39
42
|
return level === "error" || (failOnWarnings && level === "warn");
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
/** A finding may deny iff it is allowlisted, gates CI, and is not
|
|
45
|
+
/** A finding may deny iff it is allowlisted, gates CI, and is not heuristic. */
|
|
43
46
|
export function isDenyEligible(finding: Finding, failOnWarnings: boolean): boolean {
|
|
44
47
|
if (!BLOCKING_RULE_ALLOWLIST.has(finding.ruleId)) return false;
|
|
45
48
|
// Advisory findings — the low-confidence precision tiers a rule flags with
|
|
@@ -50,11 +53,19 @@ export function isDenyEligible(finding: Finding, failOnWarnings: boolean): boole
|
|
|
50
53
|
// confident" principle as the non-deterministic-fix guard below.
|
|
51
54
|
if (finding.attributes?.advisory === true) return false;
|
|
52
55
|
if (!gatesCi(finding, failOnWarnings)) return false;
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
56
|
+
// Most explicitly non-deterministic fixes are the engine's own low-confidence
|
|
57
|
+
// signal (for example, several tokens share a value). A prefer-library
|
|
58
|
+
// finding may still be an exact policy violation, but hard-denying it is only
|
|
59
|
+
// DX-safe when the configured mapping proves that every observed prop can be
|
|
60
|
+
// preserved. The conform AST preflight independently checks the source and
|
|
61
|
+
// binding before any edit is applied.
|
|
62
|
+
if (finding.fix?.deterministic === false) {
|
|
63
|
+
if (
|
|
64
|
+
finding.ruleId !== "components/prefer-library" ||
|
|
65
|
+
finding.attributes?.propCompatibility !== "observed-complete"
|
|
66
|
+
) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
59
70
|
return true;
|
|
60
71
|
}
|
package/src/rules/finding.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { canonicalJson, hash64Hex } from "../facts/ids.js";
|
|
|
15
15
|
import { byRuleId } from "../codes/index.js";
|
|
16
16
|
import { normalizeFinding, normalizeSeverity } from "../schemas/index.js";
|
|
17
17
|
import type { Severity } from "../schemas/index.js";
|
|
18
|
+
import type { EvidenceGrade } from "../evidence.js";
|
|
18
19
|
|
|
19
20
|
import type { Finding, FindingFix } from "./types.js";
|
|
20
21
|
|
|
@@ -25,6 +26,7 @@ export interface MakeFindingInput {
|
|
|
25
26
|
message: string;
|
|
26
27
|
location: FactLocation;
|
|
27
28
|
evidence: FactEvidence[];
|
|
29
|
+
evidenceGrade?: EvidenceGrade;
|
|
28
30
|
fingerprintIdentity: Record<string, unknown>;
|
|
29
31
|
fix?: FindingFix;
|
|
30
32
|
attributes?: Record<string, unknown>;
|
|
@@ -48,6 +50,7 @@ export function makeFinding(input: MakeFindingInput): Finding {
|
|
|
48
50
|
fingerprint,
|
|
49
51
|
location: input.location,
|
|
50
52
|
evidence: input.evidence,
|
|
53
|
+
evidenceGrade: input.evidenceGrade ?? "source_backed",
|
|
51
54
|
fix: input.fix,
|
|
52
55
|
attributes: input.attributes,
|
|
53
56
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether each implemented rule can emit a fix on at least one finding.
|
|
3
|
+
*
|
|
4
|
+
* Rule-backed FUI registry entries derive their `fixAvailable` value from this
|
|
5
|
+
* map. A parity test keeps the keys in lock-step with `RULES`, so capability is
|
|
6
|
+
* declared once instead of being repeated in rule and code registries.
|
|
7
|
+
*/
|
|
8
|
+
export const RULE_FIX_AVAILABLE = {
|
|
9
|
+
"components/unknown-prop": false,
|
|
10
|
+
"components/forbidden-prop-value": true,
|
|
11
|
+
"props/invalid-value": true,
|
|
12
|
+
"imports/preferred-path": true,
|
|
13
|
+
"components/preferred-component": true,
|
|
14
|
+
"components/prefer-library": true,
|
|
15
|
+
"styles/no-raw-color": true,
|
|
16
|
+
"styles/no-raw-dimensions": true,
|
|
17
|
+
"styles/no-raw-spacing": true,
|
|
18
|
+
"styles/no-raw-typography": true,
|
|
19
|
+
"tailwind/arbitrary-color": false,
|
|
20
|
+
"tailwind/arbitrary-spacing": true,
|
|
21
|
+
"tailwind/forbidden-palette": true,
|
|
22
|
+
"tailwind/off-scale-spacing-token": true,
|
|
23
|
+
"tailwind/raw-color-via-token": false,
|
|
24
|
+
"tailwind/unknown-class": false,
|
|
25
|
+
"tokens/require-dual-fallback": true,
|
|
26
|
+
"tokens/css-vars-must-be-defined": false,
|
|
27
|
+
"theme/no-theme-coupled-literal": false,
|
|
28
|
+
"a11y/required-accessible-name": false,
|
|
29
|
+
"composition/cardinality": false,
|
|
30
|
+
"composition/co-occurrence": false,
|
|
31
|
+
} as const satisfies Readonly<Record<string, boolean>>;
|
|
32
|
+
|
|
33
|
+
export type RuleWithFixAvailability = keyof typeof RULE_FIX_AVAILABLE;
|