@usefragments/core 1.5.0 → 1.5.1
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-HXGE3O2F.js → chunk-AOG4FTV6.js} +108 -6
- package/dist/chunk-AOG4FTV6.js.map +1 -0
- package/dist/{chunk-57QDBEHQ.js → chunk-ZHS52OT4.js} +3 -9
- package/dist/chunk-ZHS52OT4.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-BLsyk56o.d.ts → governance-B88uR3Zq.d.ts} +218 -1
- package/dist/{index-h_yWj15D.d.ts → index-DbkPE46t.d.ts} +40 -0
- package/dist/index.d.ts +429 -28
- package/dist/index.js +326 -33
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent-format.test.ts +1 -0
- package/src/canonical-bridge.ts +46 -0
- package/src/canonical-direction.ts +118 -0
- package/src/codes/codes.ts +9 -0
- package/src/contract/preimage.test.ts +40 -0
- package/src/contract/preimage.ts +20 -0
- package/src/effective-governance-inputs.test.ts +52 -0
- package/src/effective-governance-inputs.ts +106 -0
- package/src/facts/builders.ts +25 -1
- package/src/facts/compile.ts +20 -2
- package/src/facts/fact-index.ts +4 -0
- package/src/facts/types.ts +25 -6
- package/src/governance-integrity.test.ts +22 -0
- package/src/governance-integrity.ts +10 -4
- package/src/governance.test.ts +67 -0
- package/src/governance.ts +76 -0
- package/src/index.ts +27 -4
- package/src/rules/__tests__/fix-emission-invariant.test.ts +35 -4
- package/src/rules/components-prefer-library.test.ts +103 -0
- package/src/rules/components-prefer-library.ts +30 -4
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +7 -0
- package/src/rules/jsx-preferred-import-path.ts +45 -9
- package/src/rules/rules.test.ts +202 -6
- package/src/rules/spacing-resolution.ts +2 -2
- package/src/rules/styles-no-raw-color.test.ts +6 -1
- package/src/rules/styles-no-raw-color.ts +3 -2
- package/src/rules/styles-no-raw-dimensions.ts +5 -7
- package/src/rules/styles-no-raw-spacing.ts +5 -2
- package/src/rules/styles-no-raw-typography.ts +7 -4
- package/src/rules/taxonomy.test.ts +3 -0
- package/src/rules/tiers.ts +2 -0
- package/src/rules/token-candidates.ts +36 -0
- package/src/rules/tokens-require-dual-fallback.ts +2 -1
- package/src/rules/tokens-upstream-drift.test.ts +111 -0
- package/src/rules/tokens-upstream-drift.ts +57 -0
- package/src/rules/utils.ts +22 -14
- package/src/schema.ts +13 -0
- package/src/schemas/index.ts +5 -8
- package/src/token-types.ts +71 -1
- package/src/types.ts +6 -0
- package/dist/chunk-57QDBEHQ.js.map +0 -1
- package/dist/chunk-HXGE3O2F.js.map +0 -1
|
@@ -139,6 +139,28 @@ describe("evaluateGovernanceIntegrity", () => {
|
|
|
139
139
|
expect(verdict.status).toBe("healthy");
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
it("6b. a confirmed local bridge arms exact component governance", () => {
|
|
143
|
+
const policy: GovernanceConfig = {
|
|
144
|
+
canonicalBridges: [
|
|
145
|
+
{
|
|
146
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
147
|
+
local: {
|
|
148
|
+
componentKey: "src/components/Button.tsx#Button",
|
|
149
|
+
moduleSpecifier: "@/components/Button",
|
|
150
|
+
exportName: "Button",
|
|
151
|
+
implementationFiles: ["src/components/Button.tsx"],
|
|
152
|
+
},
|
|
153
|
+
decision: { state: "confirmed", source: "authored" },
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
const verdict = evaluate({ policy, policySource: "config", declared: true });
|
|
158
|
+
const components = verdict.families.find((family) => family.id === "components");
|
|
159
|
+
expect(components).toMatchObject({ armed: true });
|
|
160
|
+
expect(components?.rules).toContain("imports/preferred-path");
|
|
161
|
+
expect(verdict.status).toBe("healthy");
|
|
162
|
+
});
|
|
163
|
+
|
|
142
164
|
it("7. css-vars active + token vocabulary → tokens armed → healthy", () => {
|
|
143
165
|
const policy: GovernanceConfig = {
|
|
144
166
|
styles: [{ kind: "style.cssVars.mustBeDefined", severity: "warn" }],
|
|
@@ -166,23 +166,29 @@ export function evaluateGovernanceIntegrity(
|
|
|
166
166
|
|
|
167
167
|
// --- components family ----------------------------------------------------
|
|
168
168
|
const componentsConfig = configs.get("components/prefer-library");
|
|
169
|
+
const confirmedBridges = input.policy?.canonicalBridges ?? [];
|
|
169
170
|
const componentsArmed =
|
|
170
|
-
|
|
171
|
+
confirmedBridges.length > 0 ||
|
|
172
|
+
(componentsConfig?.enabled === true &&
|
|
173
|
+
hasEffectiveComponentVocabulary(componentsConfig.options));
|
|
171
174
|
const componentsFamily: GovernanceIntegrityFamily = {
|
|
172
175
|
id: "components",
|
|
173
176
|
armed: componentsArmed,
|
|
174
|
-
rules: [
|
|
177
|
+
rules: [
|
|
178
|
+
...(confirmedBridges.length > 0 ? ["imports/preferred-path"] : []),
|
|
179
|
+
...(componentsConfig?.enabled === true ? ["components/prefer-library"] : []),
|
|
180
|
+
],
|
|
175
181
|
};
|
|
176
182
|
if (!componentsArmed) {
|
|
177
183
|
if (componentsConfig?.enabled === true) {
|
|
178
184
|
componentsFamily.reason =
|
|
179
185
|
"components/prefer-library enabled but no effective canonical source";
|
|
180
186
|
componentsFamily.remediation =
|
|
181
|
-
"add govern.
|
|
187
|
+
"add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
|
|
182
188
|
} else {
|
|
183
189
|
componentsFamily.reason = "components/prefer-library not enabled";
|
|
184
190
|
componentsFamily.remediation =
|
|
185
|
-
"add govern.
|
|
191
|
+
"add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
|
|
186
192
|
}
|
|
187
193
|
}
|
|
188
194
|
|
package/src/governance.test.ts
CHANGED
|
@@ -139,6 +139,73 @@ describe("governance DSL", () => {
|
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
it("accepts one confirmed package-to-local canonical bridge", () => {
|
|
143
|
+
const config = defineConfig({
|
|
144
|
+
govern: {
|
|
145
|
+
canonicalBridges: [
|
|
146
|
+
{
|
|
147
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
148
|
+
local: {
|
|
149
|
+
componentKey: "src/components/Button.tsx#Button",
|
|
150
|
+
moduleSpecifier: "@/components",
|
|
151
|
+
exportName: "Button",
|
|
152
|
+
implementationFiles: ["src/components/Button.tsx"],
|
|
153
|
+
},
|
|
154
|
+
decision: { state: "confirmed", source: "authored" },
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(config.govern?.canonicalBridges?.[0]).toMatchObject({
|
|
161
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
162
|
+
local: { moduleSpecifier: "@/components", exportName: "Button" },
|
|
163
|
+
decision: { state: "confirmed", source: "authored" },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects ambiguous or unsafe canonical bridge declarations", () => {
|
|
168
|
+
const bridge = {
|
|
169
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
170
|
+
local: {
|
|
171
|
+
componentKey: "src/components/Button.tsx#Button",
|
|
172
|
+
moduleSpecifier: "@/components",
|
|
173
|
+
exportName: "Button",
|
|
174
|
+
implementationFiles: ["src/components/Button.tsx"],
|
|
175
|
+
},
|
|
176
|
+
decision: { state: "confirmed" as const, source: "authored" as const },
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
expect(() => defineConfig({ govern: { canonicalBridges: [bridge, bridge] } })).toThrow(
|
|
180
|
+
"Invalid fragments config"
|
|
181
|
+
);
|
|
182
|
+
expect(() =>
|
|
183
|
+
defineConfig({
|
|
184
|
+
govern: {
|
|
185
|
+
canonicalBridges: [
|
|
186
|
+
bridge,
|
|
187
|
+
{
|
|
188
|
+
...bridge,
|
|
189
|
+
underlying: { packageName: "@radix-ui/react-slot", exportName: "Slot" },
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
).toThrow("Invalid fragments config");
|
|
195
|
+
expect(() =>
|
|
196
|
+
defineConfig({
|
|
197
|
+
govern: {
|
|
198
|
+
canonicalBridges: [
|
|
199
|
+
{
|
|
200
|
+
...bridge,
|
|
201
|
+
local: { ...bridge.local, moduleSpecifier: "../components" },
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
).toThrow("Invalid fragments config");
|
|
207
|
+
});
|
|
208
|
+
|
|
142
209
|
it("preserves top-level component identity decisions", () => {
|
|
143
210
|
const config = defineConfig({
|
|
144
211
|
identity: {
|
package/src/governance.ts
CHANGED
|
@@ -192,6 +192,79 @@ const canonicalSourcePathSchema = z
|
|
|
192
192
|
|
|
193
193
|
const canonicalSourceRegistryHashSchema = z.string().regex(/^[0-9a-f]{64}$/);
|
|
194
194
|
|
|
195
|
+
const canonicalBridgeModuleSpecifierSchema = z
|
|
196
|
+
.string()
|
|
197
|
+
.min(1)
|
|
198
|
+
.superRefine((value, ctx) => {
|
|
199
|
+
const normalized = value.replace(/\\/gu, "/");
|
|
200
|
+
if (normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) {
|
|
201
|
+
ctx.addIssue({
|
|
202
|
+
code: z.ZodIssueCode.custom,
|
|
203
|
+
message: "Canonical bridge moduleSpecifier must be a portable import specifier",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (normalized.split("/").includes("..")) {
|
|
207
|
+
ctx.addIssue({
|
|
208
|
+
code: z.ZodIssueCode.custom,
|
|
209
|
+
message: "Canonical bridge moduleSpecifier must not traverse outside the project",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
export const canonicalBridgeV1Schema = z
|
|
215
|
+
.object({
|
|
216
|
+
underlying: z
|
|
217
|
+
.object({
|
|
218
|
+
packageName: z.string().min(1),
|
|
219
|
+
exportName: z.string().min(1),
|
|
220
|
+
})
|
|
221
|
+
.strict(),
|
|
222
|
+
local: z
|
|
223
|
+
.object({
|
|
224
|
+
componentKey: z.string().min(1),
|
|
225
|
+
moduleSpecifier: canonicalBridgeModuleSpecifierSchema,
|
|
226
|
+
exportName: z.string().min(1),
|
|
227
|
+
implementationFiles: z.array(canonicalSourcePathSchema).min(1),
|
|
228
|
+
})
|
|
229
|
+
.strict(),
|
|
230
|
+
decision: z
|
|
231
|
+
.object({
|
|
232
|
+
state: z.literal("confirmed"),
|
|
233
|
+
source: z.enum(["authored", "migration"]),
|
|
234
|
+
})
|
|
235
|
+
.strict(),
|
|
236
|
+
})
|
|
237
|
+
.strict();
|
|
238
|
+
|
|
239
|
+
const canonicalBridgesV1Schema = z.array(canonicalBridgeV1Schema).superRefine((bridges, ctx) => {
|
|
240
|
+
const underlying = new Map<string, number>();
|
|
241
|
+
const componentKeys = new Map<string, number>();
|
|
242
|
+
for (const [index, bridge] of bridges.entries()) {
|
|
243
|
+
const underlyingKey = `${bridge.underlying.packageName}\u0000${bridge.underlying.exportName}`;
|
|
244
|
+
const previousUnderlying = underlying.get(underlyingKey);
|
|
245
|
+
if (previousUnderlying !== undefined) {
|
|
246
|
+
ctx.addIssue({
|
|
247
|
+
code: z.ZodIssueCode.custom,
|
|
248
|
+
message: `Duplicate confirmed bridge target for ${bridge.underlying.packageName}#${bridge.underlying.exportName} (also at canonicalBridges.${previousUnderlying})`,
|
|
249
|
+
path: [index, "underlying"],
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
underlying.set(underlyingKey, index);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const previousComponent = componentKeys.get(bridge.local.componentKey);
|
|
256
|
+
if (previousComponent !== undefined) {
|
|
257
|
+
ctx.addIssue({
|
|
258
|
+
code: z.ZodIssueCode.custom,
|
|
259
|
+
message: `Local component key overlaps canonicalBridges.${previousComponent}`,
|
|
260
|
+
path: [index, "local", "componentKey"],
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
componentKeys.set(bridge.local.componentKey, index);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
195
268
|
const canonicalSourceSchema = z.discriminatedUnion("kind", [
|
|
196
269
|
z.object({
|
|
197
270
|
kind: z.literal("npm"),
|
|
@@ -237,6 +310,7 @@ export const governanceConfigSchema = z
|
|
|
237
310
|
audit: z.object({}).passthrough().optional(),
|
|
238
311
|
runners: z.record(z.string(), z.object({}).passthrough()).optional(),
|
|
239
312
|
canonicalSources: z.array(canonicalSourceSchema).optional(),
|
|
313
|
+
canonicalBridges: canonicalBridgesV1Schema.optional(),
|
|
240
314
|
presets: z.array(z.string()).optional(),
|
|
241
315
|
scales: z.record(z.string(), scaleGovernanceRecordSchema).optional(),
|
|
242
316
|
styles: z.array(globalStyleGovernanceRecordSchema).optional(),
|
|
@@ -278,6 +352,7 @@ export type ComponentPolicyMatch = z.infer<typeof componentPolicyMatchSchema>;
|
|
|
278
352
|
export type ComponentPolicyRecord = z.infer<typeof componentPolicyRecordSchema>;
|
|
279
353
|
export type ComponentPolicyOverride = z.infer<typeof componentPolicyOverrideSchema>;
|
|
280
354
|
export type CanonicalSource = z.infer<typeof canonicalSourceSchema>;
|
|
355
|
+
export type CanonicalBridgeV1 = z.infer<typeof canonicalBridgeV1Schema>;
|
|
281
356
|
|
|
282
357
|
export interface GovernanceConfig {
|
|
283
358
|
extends?: string[];
|
|
@@ -287,6 +362,7 @@ export interface GovernanceConfig {
|
|
|
287
362
|
audit?: Record<string, unknown>;
|
|
288
363
|
runners?: Record<string, Record<string, unknown>>;
|
|
289
364
|
canonicalSources?: CanonicalSource[];
|
|
365
|
+
canonicalBridges?: CanonicalBridgeV1[];
|
|
290
366
|
presets?: string[];
|
|
291
367
|
scales?: Record<string, ScaleGovernanceRecord>;
|
|
292
368
|
styles?: GlobalStyleGovernanceRecord[];
|
package/src/index.ts
CHANGED
|
@@ -227,6 +227,12 @@ export type {
|
|
|
227
227
|
// Token types
|
|
228
228
|
export type {
|
|
229
229
|
TokenCategory,
|
|
230
|
+
TokenCatalog,
|
|
231
|
+
TokenCatalogDefinition,
|
|
232
|
+
TokenCatalogDiagnostic,
|
|
233
|
+
TokenCatalogLocation,
|
|
234
|
+
TokenCatalogReference,
|
|
235
|
+
TokenCatalogSourceState,
|
|
230
236
|
DesignToken,
|
|
231
237
|
TokenRegistry,
|
|
232
238
|
TokenRegistryMeta,
|
|
@@ -435,6 +441,7 @@ export type { InferProps } from "./defineFragment.js";
|
|
|
435
441
|
export {
|
|
436
442
|
componentGovernanceRecordSchema,
|
|
437
443
|
componentGovernanceRecordsSchema,
|
|
444
|
+
canonicalBridgeV1Schema,
|
|
438
445
|
g,
|
|
439
446
|
globalGovernanceRecordSchema,
|
|
440
447
|
globalJsxGovernanceRecordSchema,
|
|
@@ -446,6 +453,7 @@ export {
|
|
|
446
453
|
} from "./governance.js";
|
|
447
454
|
export type {
|
|
448
455
|
AccessibilityGovernanceBuilder,
|
|
456
|
+
CanonicalBridgeV1,
|
|
449
457
|
CanonicalSource,
|
|
450
458
|
ComponentGovernanceBuilder,
|
|
451
459
|
ComponentGovernanceRecord,
|
|
@@ -645,10 +653,7 @@ export {
|
|
|
645
653
|
ruleTokensRequireDualFallback,
|
|
646
654
|
ruleA11yRequiredAccessibleName,
|
|
647
655
|
} from "./rules/index.js";
|
|
648
|
-
export {
|
|
649
|
-
indexComponentIdentityUsage,
|
|
650
|
-
type ComponentIdentityUsage,
|
|
651
|
-
} from "./identity/usage.js";
|
|
656
|
+
export { indexComponentIdentityUsage, type ComponentIdentityUsage } from "./identity/usage.js";
|
|
652
657
|
export { nearestSignedScaleValue } from "./rules/utils.js";
|
|
653
658
|
export {
|
|
654
659
|
buildSpacingTokenLookup,
|
|
@@ -720,6 +725,24 @@ export type {
|
|
|
720
725
|
GovernanceIntegrityVerdict,
|
|
721
726
|
} from "./governance-integrity.js";
|
|
722
727
|
|
|
728
|
+
export {
|
|
729
|
+
canonicalDirectionConflictsTarget,
|
|
730
|
+
projectCanonicalDirectionConflicts,
|
|
731
|
+
} from "./canonical-direction.js";
|
|
732
|
+
export type { CanonicalDirectionConflict } from "./canonical-direction.js";
|
|
733
|
+
|
|
734
|
+
export { canonicalBridgeContractMappings } from "./canonical-bridge.js";
|
|
735
|
+
|
|
736
|
+
export {
|
|
737
|
+
compileEffectiveGovernanceInputs,
|
|
738
|
+
EFFECTIVE_GOVERNANCE_INPUTS_SCHEMA,
|
|
739
|
+
} from "./effective-governance-inputs.js";
|
|
740
|
+
export type {
|
|
741
|
+
CompileEffectiveGovernanceInputsOptions,
|
|
742
|
+
EffectiveGovernanceInputs,
|
|
743
|
+
EffectiveGovernanceInputsSourceState,
|
|
744
|
+
} from "./effective-governance-inputs.js";
|
|
745
|
+
|
|
723
746
|
// Fact IR + index (Phase 3 of the governance compiler refactor)
|
|
724
747
|
export {
|
|
725
748
|
componentId,
|
|
@@ -65,6 +65,34 @@ describe("proof-carrying fix invariant", () => {
|
|
|
65
65
|
"token reference is not directly applicable"
|
|
66
66
|
);
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
it("keeps JavaScript member tokens advisory without inventing a CSS variable", () => {
|
|
70
|
+
const ix = buildFixtureCorpus("js-member", "radii.md");
|
|
71
|
+
const dimension = RULES.find((rule) => rule.id === "styles/no-raw-dimensions")
|
|
72
|
+
?.run(ix)
|
|
73
|
+
.find((finding) => finding.fix);
|
|
74
|
+
|
|
75
|
+
expect(dimension?.fix).toMatchObject({
|
|
76
|
+
value: "radii.md",
|
|
77
|
+
deterministic: false,
|
|
78
|
+
});
|
|
79
|
+
expect(JSON.stringify(dimension?.fix)).not.toContain("var(--radii-md)");
|
|
80
|
+
expect(dimension?.attributes?.downgradeReason).toContain(
|
|
81
|
+
"token reference is not directly applicable"
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("fails closed when a legacy token fact has no authored reference format", () => {
|
|
86
|
+
const ix = buildFixtureCorpus("unknown");
|
|
87
|
+
const dimension = RULES.find((rule) => rule.id === "styles/no-raw-dimensions")
|
|
88
|
+
?.run(ix)
|
|
89
|
+
.find((finding) => finding.fix);
|
|
90
|
+
|
|
91
|
+
expect(dimension?.fix?.deterministic).toBe(false);
|
|
92
|
+
expect(dimension?.attributes?.downgradeReason).toContain(
|
|
93
|
+
"token reference is not directly applicable"
|
|
94
|
+
);
|
|
95
|
+
});
|
|
68
96
|
});
|
|
69
97
|
|
|
70
98
|
function runEveryRule(ix: FactIndex): Finding[] {
|
|
@@ -98,7 +126,10 @@ function withoutDeterminism(fix: FindingFix): Omit<FindingFix, "deterministic">
|
|
|
98
126
|
return rest;
|
|
99
127
|
}
|
|
100
128
|
|
|
101
|
-
function buildFixtureCorpus(
|
|
129
|
+
function buildFixtureCorpus(
|
|
130
|
+
radiusReferenceFormat: "css-var" | "dtcg" | "js-member" | "unknown" = "css-var",
|
|
131
|
+
radiusName = "--proof-radius"
|
|
132
|
+
): FactIndex {
|
|
102
133
|
const ix = new FactIndex();
|
|
103
134
|
ix.addMany([
|
|
104
135
|
makeTokenDefinitionFact({
|
|
@@ -116,11 +147,11 @@ function buildFixtureCorpus(radiusReferenceFormat: "css-var" | "dtcg" = "css-var
|
|
|
116
147
|
sourceNames: ["--proof-space"],
|
|
117
148
|
}),
|
|
118
149
|
makeTokenDefinitionFact({
|
|
119
|
-
name:
|
|
150
|
+
name: radiusName,
|
|
120
151
|
value: "4px",
|
|
121
152
|
category: "radius",
|
|
122
|
-
referenceFormat: radiusReferenceFormat,
|
|
123
|
-
sourceNames: [
|
|
153
|
+
referenceFormat: radiusReferenceFormat === "unknown" ? undefined : radiusReferenceFormat,
|
|
154
|
+
sourceNames: [radiusName],
|
|
124
155
|
}),
|
|
125
156
|
makeTokenDefinitionFact({
|
|
126
157
|
name: "--proof-font",
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
asComponentId,
|
|
5
5
|
compileGlobalGovernanceFacts,
|
|
6
6
|
FactIndex,
|
|
7
|
+
makeComponentDefinitionFact,
|
|
7
8
|
makeComponentIdentityFact,
|
|
8
9
|
makeUsageComponentFact,
|
|
9
10
|
makeUsageImportFact,
|
|
@@ -11,6 +12,108 @@ import {
|
|
|
11
12
|
ruleComponentsPreferLibrary,
|
|
12
13
|
} from "../index.js";
|
|
13
14
|
|
|
15
|
+
describe("components/prefer-library — canonical direction containment", () => {
|
|
16
|
+
const componentKey = "src/components/Button.tsx#Button";
|
|
17
|
+
|
|
18
|
+
function indexFor(decision?: "sanction" | "reject", exported = true) {
|
|
19
|
+
const ix = new FactIndex();
|
|
20
|
+
ix.addMany(
|
|
21
|
+
compileGlobalGovernanceFacts({
|
|
22
|
+
rules: {
|
|
23
|
+
"components/prefer-library": {
|
|
24
|
+
enabled: true,
|
|
25
|
+
severity: "warning",
|
|
26
|
+
options: {
|
|
27
|
+
canonicalSources: [
|
|
28
|
+
{ kind: "npm", specifier: "@mui/material", include: ["Button", "Stepper"] },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
const definition = makeComponentDefinitionFact({
|
|
36
|
+
file: "src/components/Button.tsx",
|
|
37
|
+
exportName: "Button",
|
|
38
|
+
exported,
|
|
39
|
+
componentKey,
|
|
40
|
+
renderRoot: {
|
|
41
|
+
resolution: "canonical",
|
|
42
|
+
canonical: "@mui/material#Button",
|
|
43
|
+
importSource: "@mui/material",
|
|
44
|
+
},
|
|
45
|
+
propSurface: [],
|
|
46
|
+
});
|
|
47
|
+
ix.add(definition);
|
|
48
|
+
ix.add(
|
|
49
|
+
makeComponentIdentityFact({
|
|
50
|
+
componentKey,
|
|
51
|
+
state: decision === "reject" ? "shadow" : "variant",
|
|
52
|
+
confidence: decision ? "confirmed" : "review",
|
|
53
|
+
canonicalTarget: "Button",
|
|
54
|
+
...(decision ? { decisionId: `local:${decision}:${componentKey}` } : {}),
|
|
55
|
+
evidence: [definition.id],
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
return ix;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function addLocalUsage(ix: FactIndex, name: "Button" | "Stepper") {
|
|
62
|
+
ix.add(
|
|
63
|
+
makeUsageNodeFact({
|
|
64
|
+
file: "src/App.tsx",
|
|
65
|
+
nodePath: `0:${name}`,
|
|
66
|
+
element: name,
|
|
67
|
+
location: { file: "src/App.tsx", line: 3, column: 2 },
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
ix.add(
|
|
71
|
+
makeUsageImportFact({
|
|
72
|
+
file: "src/App.tsx",
|
|
73
|
+
local: name,
|
|
74
|
+
imported: name,
|
|
75
|
+
source: `./components/${name}`,
|
|
76
|
+
location: { file: "src/App.tsx", line: 1, column: 0 },
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
it("suppresses only the exact conflicted npm export when direction is unresolved", () => {
|
|
82
|
+
const ix = indexFor();
|
|
83
|
+
addLocalUsage(ix, "Button");
|
|
84
|
+
addLocalUsage(ix, "Stepper");
|
|
85
|
+
ix.add(
|
|
86
|
+
makeUsageNodeFact({
|
|
87
|
+
file: "src/App.tsx",
|
|
88
|
+
nodePath: "0:raw-button",
|
|
89
|
+
element: "button",
|
|
90
|
+
interactive: true,
|
|
91
|
+
location: { file: "src/App.tsx", line: 4, column: 2 },
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const findings = ruleComponentsPreferLibrary(ix);
|
|
96
|
+
expect(findings).toHaveLength(1);
|
|
97
|
+
expect(findings[0]?.attributes?.suggestedComponent).toBe("Stepper");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("keeps a sanctioned wrapper exempt and restores npm guidance after explicit rejection", () => {
|
|
101
|
+
const sanctioned = indexFor("sanction");
|
|
102
|
+
addLocalUsage(sanctioned, "Button");
|
|
103
|
+
expect(ruleComponentsPreferLibrary(sanctioned)).toHaveLength(0);
|
|
104
|
+
|
|
105
|
+
const rejected = indexFor("reject");
|
|
106
|
+
addLocalUsage(rejected, "Button");
|
|
107
|
+
expect(ruleComponentsPreferLibrary(rejected)).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not let a private helper deactivate canonical policy", () => {
|
|
111
|
+
const ix = indexFor(undefined, false);
|
|
112
|
+
addLocalUsage(ix, "Button");
|
|
113
|
+
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
14
117
|
// #9e — import-path identity for a DIRECTORY canonical source. A directory
|
|
15
118
|
// source (e.g. `src/components/ui`) declares its canonical component NAMES via an
|
|
16
119
|
// include list. The rule must:
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
type RawHtmlPrecisionTier,
|
|
16
16
|
} from "../raw-html-canonical.js";
|
|
17
17
|
import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
|
|
18
|
+
import {
|
|
19
|
+
canonicalDirectionConflictsTarget,
|
|
20
|
+
projectCanonicalDirectionConflicts,
|
|
21
|
+
type CanonicalDirectionConflict,
|
|
22
|
+
} from "../canonical-direction.js";
|
|
18
23
|
|
|
19
24
|
import { makeFinding } from "./finding.js";
|
|
20
25
|
import type { Finding } from "./types.js";
|
|
@@ -254,8 +259,12 @@ function findClassNameReimpl(
|
|
|
254
259
|
export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
255
260
|
const policy = ix.policy.ruleConfig(RULE_ID);
|
|
256
261
|
if (!policy?.enabled) return [];
|
|
257
|
-
const
|
|
258
|
-
const
|
|
262
|
+
const configuredSources = canonicalSourcesFromPolicy(policy.options?.canonicalSources);
|
|
263
|
+
const conflicts = projectCanonicalDirectionConflicts(ix, configuredSources);
|
|
264
|
+
const sources = configuredSources.map((source) => suppressConflictedExports(source, conflicts));
|
|
265
|
+
const mappings = canonicalMappingsFromPolicy(policy.options?.canonicalMappings).filter(
|
|
266
|
+
(mapping) => !canonicalDirectionConflictsTarget(conflicts, mapping.importPath, mapping.name)
|
|
267
|
+
);
|
|
259
268
|
if (sources.length === 0 && mappings.length === 0) return [];
|
|
260
269
|
|
|
261
270
|
const importsByFileAndLocal = indexImportsByFileAndLocal(ix);
|
|
@@ -605,6 +614,22 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
605
614
|
return findings;
|
|
606
615
|
}
|
|
607
616
|
|
|
617
|
+
function suppressConflictedExports(
|
|
618
|
+
source: CanonicalSource,
|
|
619
|
+
conflicts: readonly CanonicalDirectionConflict[]
|
|
620
|
+
): CanonicalSource {
|
|
621
|
+
if (source.kind !== "npm") return source;
|
|
622
|
+
const names = conflicts
|
|
623
|
+
.filter((conflict) => ownedImportMatchesRoot(source.specifier, conflict.packageName))
|
|
624
|
+
.map((conflict) => conflict.exportName);
|
|
625
|
+
if (names.length === 0) return source;
|
|
626
|
+
const blocked = new Set(names);
|
|
627
|
+
if (source.include) {
|
|
628
|
+
return { ...source, include: source.include.filter((name) => !blocked.has(name)) };
|
|
629
|
+
}
|
|
630
|
+
return { ...source, exclude: [...new Set([...(source.exclude ?? []), ...names])].sort() };
|
|
631
|
+
}
|
|
632
|
+
|
|
608
633
|
function indexShadowRenderRootNodeIds(ix: FactIndex): Set<FactId> {
|
|
609
634
|
const identities = ix.byKind("component_identity");
|
|
610
635
|
if (identities.length === 0) return new Set();
|
|
@@ -1113,8 +1138,9 @@ function indexCanonicalDirectoryResolvedNodes(
|
|
|
1113
1138
|
): Set<FactId> {
|
|
1114
1139
|
const out = new Set<FactId>();
|
|
1115
1140
|
const directoryPaths = sources
|
|
1116
|
-
.filter(
|
|
1117
|
-
source
|
|
1141
|
+
.filter(
|
|
1142
|
+
(source): source is Extract<CanonicalSource, { kind: "directory" }> =>
|
|
1143
|
+
source.kind === "directory"
|
|
1118
1144
|
)
|
|
1119
1145
|
.map((source) => normalizePath(source.path));
|
|
1120
1146
|
if (directoryPaths.length === 0) return out;
|
|
@@ -25,6 +25,7 @@ export const RULE_FIX_AVAILABLE = {
|
|
|
25
25
|
"tailwind/unknown-class": false,
|
|
26
26
|
"tokens/require-dual-fallback": true,
|
|
27
27
|
"tokens/css-vars-must-be-defined": false,
|
|
28
|
+
"tokens/upstream-drift": false,
|
|
28
29
|
"theme/no-theme-coupled-literal": false,
|
|
29
30
|
"a11y/required-accessible-name": false,
|
|
30
31
|
"composition/cardinality": false,
|
package/src/rules/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { ruleTailwindRawColorViaToken } from "./tailwind-raw-color-via-token.js"
|
|
|
31
31
|
import { ruleTailwindUnknownClass } from "./tailwind-unknown-class.js";
|
|
32
32
|
import { ruleTokensRequireDualFallback } from "./tokens-require-dual-fallback.js";
|
|
33
33
|
import { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-defined.js";
|
|
34
|
+
import { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
|
|
34
35
|
import type { Finding } from "./types.js";
|
|
35
36
|
|
|
36
37
|
export type RuleFn = (ix: FactIndex) => Finding[];
|
|
@@ -137,6 +138,11 @@ export const RULES: readonly Rule[] = [
|
|
|
137
138
|
version: "1",
|
|
138
139
|
run: ruleTokensCssVarsMustBeDefined,
|
|
139
140
|
},
|
|
141
|
+
{
|
|
142
|
+
id: "tokens/upstream-drift",
|
|
143
|
+
version: "1",
|
|
144
|
+
run: ruleTokensUpstreamDrift,
|
|
145
|
+
},
|
|
140
146
|
{
|
|
141
147
|
id: "theme/no-theme-coupled-literal",
|
|
142
148
|
version: "1",
|
|
@@ -211,6 +217,7 @@ export { ruleTailwindRawColorViaToken } from "./tailwind-raw-color-via-token.js"
|
|
|
211
217
|
export { ruleTailwindUnknownClass } from "./tailwind-unknown-class.js";
|
|
212
218
|
export { ruleTokensRequireDualFallback } from "./tokens-require-dual-fallback.js";
|
|
213
219
|
export { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-defined.js";
|
|
220
|
+
export { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
|
|
214
221
|
export { ruleThemeNoThemeCoupledLiteral } from "./theme-no-theme-coupled-literal.js";
|
|
215
222
|
export { ruleA11yRequiredAccessibleName } from "./a11y-required-accessible-name.js";
|
|
216
223
|
export { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FactIndex } from "../facts/index.js";
|
|
2
|
-
import { ownedImportsEqual } from "../package-identity-match.js";
|
|
2
|
+
import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
|
|
3
3
|
|
|
4
4
|
import { makeFinding } from "./finding.js";
|
|
5
5
|
import type { Finding } from "./types.js";
|
|
@@ -16,6 +16,21 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
|
|
|
16
16
|
|
|
17
17
|
for (const usage of ix.byKind("usage_import")) {
|
|
18
18
|
const matches = policies.filter((policy) => {
|
|
19
|
+
if (policy.bridge) {
|
|
20
|
+
if (policy.bridge.implementationFiles.some((file) => sameRepoPath(file, usage.file))) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (!ownedImportMatchesRoot(usage.source, policy.from)) return false;
|
|
24
|
+
if (ownedImportsEqual(usage.source, policy.from)) {
|
|
25
|
+
return usage.imported === policy.bridge.underlyingExportName;
|
|
26
|
+
}
|
|
27
|
+
const subpath = usage.source.slice(policy.from.length + 1);
|
|
28
|
+
const leaf = subpath.split("/").at(-1);
|
|
29
|
+
return (
|
|
30
|
+
leaf === policy.bridge.underlyingExportName &&
|
|
31
|
+
(usage.imported === "default" || usage.imported === policy.bridge.underlyingExportName)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
19
34
|
if (!ownedImportsEqual(policy.from, usage.source)) return false;
|
|
20
35
|
if (policy.imported !== undefined && policy.imported !== usage.imported) return false;
|
|
21
36
|
return true;
|
|
@@ -33,7 +48,9 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
|
|
|
33
48
|
ruleId: RULE_ID,
|
|
34
49
|
ruleVersion: RULE_VERSION,
|
|
35
50
|
severity: policy.severity,
|
|
36
|
-
message:
|
|
51
|
+
message: policy.bridge
|
|
52
|
+
? `Import ${policy.bridge.underlyingExportName} through the confirmed local ${policy.bridge.localExportName} wrapper from "${policy.to}".`
|
|
53
|
+
: `Import from "${usage.source}" should use "${policy.to}".`,
|
|
37
54
|
location: usage.location,
|
|
38
55
|
evidence: ix.evidence([usage.id, policy.id]),
|
|
39
56
|
fingerprintIdentity: {
|
|
@@ -44,19 +61,30 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
|
|
|
44
61
|
line: usage.location.line,
|
|
45
62
|
column: usage.location.column,
|
|
46
63
|
},
|
|
47
|
-
fix:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
fix: policy.bridge
|
|
65
|
+
? undefined
|
|
66
|
+
: {
|
|
67
|
+
kind: "replaceImport",
|
|
68
|
+
title: policy.because ?? `Replace import path with "${policy.to}"`,
|
|
69
|
+
from: usage.source,
|
|
70
|
+
to: policy.to,
|
|
71
|
+
deterministic: true,
|
|
72
|
+
},
|
|
54
73
|
attributes: {
|
|
55
74
|
importPath: usage.source,
|
|
56
75
|
suggestedImport: policy.to,
|
|
57
76
|
imported: usage.imported,
|
|
58
77
|
local: usage.local,
|
|
59
78
|
rawValue: usage.source,
|
|
79
|
+
...(policy.bridge
|
|
80
|
+
? {
|
|
81
|
+
canonicalBridge: true,
|
|
82
|
+
canonicalTarget: policy.bridge.componentKey,
|
|
83
|
+
suggestedComponent: policy.bridge.localExportName,
|
|
84
|
+
implementationFiles: policy.bridge.implementationFiles,
|
|
85
|
+
decisionSource: policy.bridge.decisionSource,
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
60
88
|
},
|
|
61
89
|
})
|
|
62
90
|
);
|
|
@@ -65,3 +93,11 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
|
|
|
65
93
|
|
|
66
94
|
return findings;
|
|
67
95
|
}
|
|
96
|
+
|
|
97
|
+
function sameRepoPath(left: string, right: string): boolean {
|
|
98
|
+
return normalizeRepoPath(left) === normalizeRepoPath(right);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeRepoPath(path: string): string {
|
|
102
|
+
return path.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
103
|
+
}
|