@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.
Files changed (59) hide show
  1. package/dist/{chunk-R3X7UBHU.js → chunk-57QDBEHQ.js} +101 -6
  2. package/dist/chunk-57QDBEHQ.js.map +1 -0
  3. package/dist/{chunk-243QYRUF.js → chunk-MLRDNSSA.js} +88 -47
  4. package/dist/chunk-MLRDNSSA.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +2 -2
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/generate/index.d.ts +1 -1
  9. package/dist/{governance-BOa3KyiJ.d.ts → governance-DYSirwJu.d.ts} +22 -1
  10. package/dist/index-h_yWj15D.d.ts +6981 -0
  11. package/dist/index.d.ts +113 -97
  12. package/dist/index.js +387 -113
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +36 -36
  16. package/dist/schemas/index.d.ts +3 -2584
  17. package/dist/schemas/index.js +9 -1
  18. package/dist/test-utils.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/agent-format.test.ts +144 -2
  21. package/src/agent-format.ts +34 -7
  22. package/src/codes/__tests__/codes.test.ts +57 -7
  23. package/src/codes/codes.ts +44 -42
  24. package/src/conform-prove.ts +4 -8
  25. package/src/contract/index.ts +3 -0
  26. package/src/contract/preimage.test.ts +50 -0
  27. package/src/contract/preimage.ts +56 -0
  28. package/src/evidence.ts +27 -0
  29. package/src/facts/builders.ts +4 -0
  30. package/src/facts/fact-index.ts +25 -1
  31. package/src/facts/facts.test.ts +49 -0
  32. package/src/facts/types.ts +3 -0
  33. package/src/governance-integrity.test.ts +2 -0
  34. package/src/governance-integrity.ts +7 -1
  35. package/src/index.ts +13 -0
  36. package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
  37. package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
  38. package/src/rules/components-prefer-library.ts +165 -7
  39. package/src/rules/emit-gate.test.ts +40 -2
  40. package/src/rules/emit-gate.ts +21 -10
  41. package/src/rules/finding.ts +3 -0
  42. package/src/rules/fix-availability.ts +33 -0
  43. package/src/rules/rules.test.ts +180 -24
  44. package/src/rules/spacing-resolution.ts +5 -8
  45. package/src/rules/styles-no-raw-color.ts +32 -23
  46. package/src/rules/styles-no-raw-dimensions.ts +31 -12
  47. package/src/rules/styles-no-raw-spacing.ts +33 -11
  48. package/src/rules/styles-no-raw-typography.ts +37 -15
  49. package/src/rules/taxonomy.test.ts +19 -3
  50. package/src/rules/tiers.ts +1 -1
  51. package/src/rules/tokens-require-dual-fallback.ts +54 -17
  52. package/src/rules/utils.ts +51 -0
  53. package/src/schemas/index.ts +106 -4
  54. package/src/token-types.ts +6 -0
  55. package/src/tokens/__tests__/source-names.test.ts +42 -0
  56. package/src/tokens/design-token-parser.test.ts +24 -5
  57. package/src/tokens/design-token-parser.ts +40 -8
  58. package/dist/chunk-243QYRUF.js.map +0 -1
  59. 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 imported = importsByFileAndLocal.get(node.file)?.get(node.element);
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 ?? "warn",
301
- message: `<${node.element}> should import from ${importPath}.`,
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: true,
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
- deterministic: true,
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
- deterministic: true,
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("does not deny an explicitly non-deterministic (heuristic) fix", () => {
39
- const heuristic = { ...base, fix: { deterministic: false } } as unknown as Finding;
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
  });
@@ -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
- * Mirrors `scanWithFacts`'s own CI exit logic: a finding gates CI when it is
34
- * error-severity, or warn-severity under `ci.failOnWarnings`. Gating on exactly
35
- * this set is what makes a local/emit deny imply a CI failure.
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 a flagged-heuristic fix. */
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
- // An explicitly non-deterministic fix is the engine's own "low confidence /
54
- // ambiguous" signal (e.g. several tokens share a value, or a heuristic swap).
55
- // Those stay advisory only deny when the engine is confident. A finding with
56
- // no fix at all (e.g. a raw color with no matching token) still gates CI, so it
57
- // remains deny-eligible.
58
- if (finding.fix?.deterministic === false) return false;
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
  }
@@ -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;