@usefragments/core 1.0.0 → 1.2.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 (42) hide show
  1. package/dist/{chunk-R3X7UBHU.js → chunk-BQQTMGLA.js} +24 -2
  2. package/dist/chunk-BQQTMGLA.js.map +1 -0
  3. package/dist/{chunk-243QYRUF.js → chunk-DBVTSUMT.js} +71 -44
  4. package/dist/chunk-DBVTSUMT.js.map +1 -0
  5. package/dist/codes/index.d.ts +1 -1
  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.d.ts +8 -6
  11. package/dist/index.js +156 -19
  12. package/dist/index.js.map +1 -1
  13. package/dist/react-types.d.ts +1 -1
  14. package/dist/schemas/index.d.ts +797 -4
  15. package/dist/schemas/index.js +9 -1
  16. package/dist/test-utils.d.ts +1 -1
  17. package/package.json +1 -1
  18. package/src/agent-format.test.ts +72 -2
  19. package/src/agent-format.ts +10 -5
  20. package/src/codes/__tests__/codes.test.ts +57 -7
  21. package/src/codes/codes.ts +44 -42
  22. package/src/conform-prove.ts +4 -8
  23. package/src/contract/index.ts +3 -0
  24. package/src/contract/preimage.test.ts +50 -0
  25. package/src/contract/preimage.ts +56 -0
  26. package/src/facts/builders.ts +2 -0
  27. package/src/facts/fact-index.ts +4 -0
  28. package/src/facts/facts.test.ts +49 -0
  29. package/src/facts/types.ts +1 -0
  30. package/src/governance-integrity.test.ts +2 -0
  31. package/src/governance-integrity.ts +7 -1
  32. package/src/index.ts +9 -0
  33. package/src/rules/components-prefer-library.ts +165 -7
  34. package/src/rules/emit-gate.test.ts +24 -2
  35. package/src/rules/emit-gate.ts +15 -7
  36. package/src/rules/fix-availability.ts +33 -0
  37. package/src/rules/rules.test.ts +178 -23
  38. package/src/rules/taxonomy.test.ts +6 -3
  39. package/src/rules/tiers.ts +1 -1
  40. package/src/schemas/index.ts +25 -1
  41. package/dist/chunk-243QYRUF.js.map +0 -1
  42. package/dist/chunk-R3X7UBHU.js.map +0 -1
@@ -139,6 +139,8 @@ export interface ContractCatalogInput {
139
139
  // ---------------------------------------------------------------------------
140
140
 
141
141
  export const CONTRACT_PREIMAGE_SCHEMA = "fcid-preimage:v1" as const;
142
+ /** Request header used by clients that can verify and consume this preimage. */
143
+ export const CONTRACT_PREIMAGE_CAPABILITY_HEADER = "X-Fragments-Contract-Preimage" as const;
142
144
 
143
145
  /**
144
146
  * The hashed identity record: four domain sub-hashes plus the projection schema
@@ -150,6 +152,60 @@ export interface ContractPreimage {
150
152
  domains: Record<ContractDomain, string>;
151
153
  }
152
154
 
155
+ /**
156
+ * Wire-compatible identity pin carried by Cloud policy responses. Older
157
+ * contract rows may omit the schema/domains, so every field except the FCID is
158
+ * intentionally nullable at this boundary.
159
+ */
160
+ export interface ContractIdentityPin {
161
+ contractHash: string;
162
+ preimageSchema?: string | null;
163
+ domainHashes?: Readonly<Record<string, string>> | null;
164
+ }
165
+
166
+ const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u;
167
+
168
+ /**
169
+ * Reconstruct and verify the canonical preimage behind a published FCID.
170
+ *
171
+ * This is the only supported bridge from the additive ledger/wire shape
172
+ * (`preimageSchema` + `domainHashes`) back to the hash input. Consumers must
173
+ * not trust `enforcedIdentityComparable` on its own: a malformed, partial, or
174
+ * internally inconsistent pin returns `null` and therefore fails closed.
175
+ */
176
+ export function verifiedContractPreimageFromPin(pin: ContractIdentityPin): ContractPreimage | null {
177
+ if (
178
+ pin.preimageSchema !== CONTRACT_PREIMAGE_SCHEMA ||
179
+ !SHA256_HEX_PATTERN.test(pin.contractHash) ||
180
+ !pin.domainHashes ||
181
+ typeof pin.domainHashes !== "object"
182
+ ) {
183
+ return null;
184
+ }
185
+
186
+ const keys = Object.keys(pin.domainHashes).sort();
187
+ const expectedKeys = [...CONTRACT_DOMAINS].sort();
188
+ if (
189
+ keys.length !== expectedKeys.length ||
190
+ keys.some((key, index) => key !== expectedKeys[index])
191
+ ) {
192
+ return null;
193
+ }
194
+
195
+ const domains = Object.fromEntries(
196
+ CONTRACT_DOMAINS.map((domain) => [domain, pin.domainHashes?.[domain]])
197
+ ) as Record<ContractDomain, string | undefined>;
198
+ if (CONTRACT_DOMAINS.some((domain) => !SHA256_HEX_PATTERN.test(domains[domain] ?? ""))) {
199
+ return null;
200
+ }
201
+
202
+ const preimage: ContractPreimage = {
203
+ schema: CONTRACT_PREIMAGE_SCHEMA,
204
+ domains: domains as Record<ContractDomain, string>,
205
+ };
206
+ return contractHash(preimage) === pin.contractHash ? preimage : null;
207
+ }
208
+
153
209
  /**
154
210
  * Sort projected elements by their canonical serialization: total, deterministic,
155
211
  * and independent of adapter iteration order, without per-type comparators.
@@ -461,6 +461,7 @@ export function makeJsxComponentPreferredFact(input: {
461
461
  export function makeTokenDefinitionFact(input: {
462
462
  name: string;
463
463
  value: string;
464
+ location?: TokenDefinitionFact["location"];
464
465
  category?: TokenDefinitionFact["category"];
465
466
  referenceFormat?: TokenDefinitionFact["referenceFormat"];
466
467
  }): TokenDefinitionFact {
@@ -469,6 +470,7 @@ export function makeTokenDefinitionFact(input: {
469
470
  kind: "token_definition",
470
471
  name: input.name,
471
472
  value: input.value,
473
+ ...(input.location ? { location: input.location } : {}),
472
474
  category: input.category,
473
475
  referenceFormat: input.referenceFormat,
474
476
  };
@@ -58,6 +58,10 @@ function logicalComponentId(componentId: ComponentId): ComponentId {
58
58
  }
59
59
 
60
60
  function logicalFactForComparison(fact: Fact): Record<string, unknown> {
61
+ if (fact.kind === "token_definition") {
62
+ const { location: _location, ...logicalFact } = fact;
63
+ return logicalFact;
64
+ }
61
65
  if (fact.kind === "jsx_import_path_preferred") {
62
66
  return {
63
67
  ...fact,
@@ -9,6 +9,7 @@ import {
9
9
  compileComponentFacts,
10
10
  compileGlobalGovernanceFacts,
11
11
  g,
12
+ makeTokenDefinitionFact,
12
13
  makeUsageNodeFact,
13
14
  } from "../index.js";
14
15
  import type { ComponentId, FactId, PropValueForbiddenFact } from "../index.js";
@@ -443,6 +444,54 @@ describe("FactIndex — query layer", () => {
443
444
  warn.mockRestore();
444
445
  });
445
446
 
447
+ it("does not treat token-definition provenance as a logical conflict", () => {
448
+ const ix = new FactIndex();
449
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
450
+ const first = makeTokenDefinitionFact({
451
+ name: "--fui-color-accent",
452
+ value: "#2563eb",
453
+ location: { file: "tokens/base.css", line: 2, column: 1 },
454
+ });
455
+ const second = makeTokenDefinitionFact({
456
+ name: "--fui-color-accent",
457
+ value: "#2563eb",
458
+ location: { file: "tokens/theme.css", line: 4, column: 1 },
459
+ });
460
+
461
+ ix.add(first);
462
+ ix.add(second);
463
+
464
+ expect(warn).not.toHaveBeenCalled();
465
+ expect(ix.get(first.id)).toEqual(first);
466
+ warn.mockRestore();
467
+ });
468
+
469
+ it("includes token-definition provenance in logical conflict warnings", () => {
470
+ const ix = new FactIndex();
471
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
472
+ const first = makeTokenDefinitionFact({
473
+ name: "--fui-color-accent",
474
+ value: "#2563eb",
475
+ location: { file: "tokens/base.css", line: 2, column: 1 },
476
+ });
477
+ const second = makeTokenDefinitionFact({
478
+ name: "--fui-color-accent",
479
+ value: "#1d4ed8",
480
+ location: { file: "tokens/theme.css", line: 4, column: 1 },
481
+ });
482
+
483
+ ix.add(first);
484
+ ix.add(second);
485
+
486
+ expect(warn).toHaveBeenCalledWith(
487
+ expect.stringMatching(
488
+ /conflicting facts.*kind=token_definition location=tokens\/theme\.css:4:1/
489
+ )
490
+ );
491
+ expect(ix.get(first.id)).toEqual(first);
492
+ warn.mockRestore();
493
+ });
494
+
446
495
  it("treats old and current owned component spellings as the same indexed facts", () => {
447
496
  const legacyId = asComponentId("@fragments-sdk/ui#Button");
448
497
  const currentId = asComponentId("@usefragments/ui#Button");
@@ -200,6 +200,7 @@ export interface JsxComponentPreferredFact extends BaseFact {
200
200
  */
201
201
  export interface TokenDefinitionFact extends BaseFact {
202
202
  kind: "token_definition";
203
+ location?: FactLocation;
203
204
  /** Token identifier, e.g., "fui-color-accent" or "--fui-color-accent". */
204
205
  name: string;
205
206
  /** Resolved value, e.g., "#2563eb". */
@@ -177,8 +177,10 @@ describe("evaluateGovernanceIntegrity", () => {
177
177
  };
178
178
  const verdict = evaluate({ policy, policySource: "config", declared: true });
179
179
  const hygiene = verdict.families.find((f) => f.id === "hygiene");
180
+ const components = verdict.families.find((f) => f.id === "components");
180
181
  expect(hygiene?.armed).toBe(true);
181
182
  expect(hygiene?.rules).toContain("styles/no-raw-color");
183
+ expect(components?.remediation).toContain("govern.canonicalSources");
182
184
  expect(verdict.status).toBe("degraded");
183
185
  // degraded is enforceable, so not CI-fatal
184
186
  expect(verdict.fatalForCi).toBe(false);
@@ -25,7 +25,11 @@ import { BLOCKING_RULE_ALLOWLIST } from "./rules/emit-gate.js";
25
25
  export type GovernanceIntegrityStatus = "healthy" | "degraded" | "inert";
26
26
 
27
27
  export type GovernanceIntegrityFamilyId =
28
- "policy" | "components" | "tokens" | "hygiene" | "blocking-deny";
28
+ | "policy"
29
+ | "components"
30
+ | "tokens"
31
+ | "hygiene"
32
+ | "blocking-deny";
29
33
 
30
34
  export interface GovernanceIntegrityFamily {
31
35
  id: GovernanceIntegrityFamilyId;
@@ -177,6 +181,8 @@ export function evaluateGovernanceIntegrity(
177
181
  "add govern.canonicalSources (a directory source, or npm/registry with a non-empty include) or designSystem.path/packageName";
178
182
  } else {
179
183
  componentsFamily.reason = "components/prefer-library not enabled";
184
+ componentsFamily.remediation =
185
+ "add govern.canonicalSources (a directory source, or npm/registry with a non-empty include) or designSystem.path/packageName";
180
186
  }
181
187
  }
182
188
 
package/src/index.ts CHANGED
@@ -62,7 +62,11 @@ export type { Severity, SeverityLevel } from "./severity.js";
62
62
 
63
63
  // Canonical TypeStyle schemas
64
64
  export {
65
+ AGENT_FORMAT_SCHEMA_VERSION,
66
+ agentErrorEnvelopeSchema,
65
67
  agentFormatSchema,
68
+ agentIntegritySchema,
69
+ agentOutputSchema,
66
70
  factEvidenceSchema,
67
71
  factLocationSchema,
68
72
  findingFixSchema,
@@ -83,7 +87,9 @@ export {
83
87
  violationSchema,
84
88
  } from "./schemas/index.js";
85
89
  export type {
90
+ AgentErrorEnvelope,
86
91
  AgentFormat,
92
+ AgentOutput,
87
93
  Fix,
88
94
  Finding as CanonicalFinding,
89
95
  GovernanceVerdict as CanonicalGovernanceVerdict,
@@ -763,6 +769,7 @@ export {
763
769
  catalogFixtureAContractPayload,
764
770
  catalogFixtureATokensDtcg,
765
771
  CONTRACT_DOMAINS,
772
+ CONTRACT_PREIMAGE_CAPABILITY_HEADER,
766
773
  CONTRACT_PREIMAGE_SCHEMA,
767
774
  contractComponentsFromFragments,
768
775
  contractHash,
@@ -775,6 +782,7 @@ export {
775
782
  projectContractPreimage,
776
783
  serializeContractStamp,
777
784
  sha256Hex,
785
+ verifiedContractPreimageFromPin,
778
786
  } from "./contract/index.js";
779
787
  export {
780
788
  OWNED_PACKAGE_IDENTITY_EPOCH,
@@ -794,6 +802,7 @@ export type {
794
802
  ContractCatalogInput,
795
803
  ContractComponentInput,
796
804
  ContractDomain,
805
+ ContractIdentityPin,
797
806
  ContractPolicyInput,
798
807
  ContractPreimage,
799
808
  ContractPropMappingInput,
@@ -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,30 @@ 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("does not deny an explicitly non-deterministic token heuristic", () => {
39
+ const heuristic = {
40
+ ...base,
41
+ ruleId: "styles/no-raw-color",
42
+ fix: { deterministic: false },
43
+ } as unknown as Finding;
40
44
  expect(isDenyEligible(heuristic, false)).toBe(false);
41
45
  });
46
+
47
+ it("denies an exact canonical-component violation while withholding its generic fix", () => {
48
+ const exactComponent = {
49
+ ...base,
50
+ fix: { deterministic: false },
51
+ attributes: { precisionTier: "exact-html", propCompatibility: "observed-complete" },
52
+ } as unknown as Finding;
53
+ expect(isDenyEligible(exactComponent, false)).toBe(true);
54
+ });
55
+
56
+ it("keeps a component replacement advisory when observed props are not proven compatible", () => {
57
+ const incompleteComponent = {
58
+ ...base,
59
+ fix: { deterministic: false },
60
+ attributes: { precisionTier: "exact-html", propCompatibility: "incomplete" },
61
+ } as unknown as Finding;
62
+ expect(isDenyEligible(incompleteComponent, false)).toBe(false);
63
+ });
42
64
  });
@@ -39,7 +39,7 @@ export function gatesCi(finding: Finding, failOnWarnings: boolean): boolean {
39
39
  return level === "error" || (failOnWarnings && level === "warn");
40
40
  }
41
41
 
42
- /** A finding may deny iff it is allowlisted, gates CI, and is not a flagged-heuristic fix. */
42
+ /** A finding may deny iff it is allowlisted, gates CI, and is not heuristic. */
43
43
  export function isDenyEligible(finding: Finding, failOnWarnings: boolean): boolean {
44
44
  if (!BLOCKING_RULE_ALLOWLIST.has(finding.ruleId)) return false;
45
45
  // Advisory findings — the low-confidence precision tiers a rule flags with
@@ -50,11 +50,19 @@ export function isDenyEligible(finding: Finding, failOnWarnings: boolean): boole
50
50
  // confident" principle as the non-deterministic-fix guard below.
51
51
  if (finding.attributes?.advisory === true) return false;
52
52
  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;
53
+ // Most explicitly non-deterministic fixes are the engine's own low-confidence
54
+ // signal (for example, several tokens share a value). A prefer-library
55
+ // finding may still be an exact policy violation, but hard-denying it is only
56
+ // DX-safe when the configured mapping proves that every observed prop can be
57
+ // preserved. The conform AST preflight independently checks the source and
58
+ // binding before any edit is applied.
59
+ if (finding.fix?.deterministic === false) {
60
+ if (
61
+ finding.ruleId !== "components/prefer-library" ||
62
+ finding.attributes?.propCompatibility !== "observed-complete"
63
+ ) {
64
+ return false;
65
+ }
66
+ }
59
67
  return true;
60
68
  }
@@ -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;