@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.
- package/dist/{chunk-R3X7UBHU.js → chunk-BQQTMGLA.js} +24 -2
- package/dist/chunk-BQQTMGLA.js.map +1 -0
- package/dist/{chunk-243QYRUF.js → chunk-DBVTSUMT.js} +71 -44
- package/dist/chunk-DBVTSUMT.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- 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.d.ts +8 -6
- package/dist/index.js +156 -19
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/schemas/index.d.ts +797 -4
- 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 +72 -2
- package/src/agent-format.ts +10 -5
- 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/facts/builders.ts +2 -0
- package/src/facts/fact-index.ts +4 -0
- package/src/facts/facts.test.ts +49 -0
- package/src/facts/types.ts +1 -0
- package/src/governance-integrity.test.ts +2 -0
- package/src/governance-integrity.ts +7 -1
- package/src/index.ts +9 -0
- package/src/rules/components-prefer-library.ts +165 -7
- package/src/rules/emit-gate.test.ts +24 -2
- package/src/rules/emit-gate.ts +15 -7
- package/src/rules/fix-availability.ts +33 -0
- package/src/rules/rules.test.ts +178 -23
- package/src/rules/taxonomy.test.ts +6 -3
- package/src/rules/tiers.ts +1 -1
- package/src/schemas/index.ts +25 -1
- package/dist/chunk-243QYRUF.js.map +0 -1
- package/dist/chunk-R3X7UBHU.js.map +0 -1
package/src/contract/preimage.ts
CHANGED
|
@@ -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.
|
package/src/facts/builders.ts
CHANGED
|
@@ -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
|
};
|
package/src/facts/fact-index.ts
CHANGED
|
@@ -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,
|
package/src/facts/facts.test.ts
CHANGED
|
@@ -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");
|
package/src/facts/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,30 @@ describe("isDenyEligible", () => {
|
|
|
35
35
|
expect(isDenyEligible(warn, true)).toBe(true);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
it("does not deny an explicitly non-deterministic
|
|
39
|
-
const heuristic = {
|
|
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
|
});
|
package/src/rules/emit-gate.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
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;
|