@usefragments/core 1.3.0 → 1.4.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-MLRDNSSA.js → chunk-CBSNXFOD.js} +1 -1
- package/dist/chunk-CBSNXFOD.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-DYSirwJu.d.ts → governance-DxdJV6lx.d.ts} +10 -0
- package/dist/index.d.ts +97 -75
- package/dist/index.js +163 -30
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +18 -18
- package/dist/schemas/index.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/constants.ts +3 -0
- package/src/facts/builders.ts +1 -1
- package/src/facts/types.ts +14 -1
- package/src/rules/components-prefer-library.test.ts +148 -0
- package/src/rules/components-prefer-library.ts +52 -1
- package/src/rules/styles-no-raw-color.test.ts +213 -0
- package/src/rules/styles-no-raw-color.ts +125 -0
- package/src/schema.ts +6 -2
- package/src/token-types.ts +12 -0
- package/src/tokens/categories.ts +54 -24
- package/src/tokens/scss.test.ts +94 -0
- package/src/tokens/scss.ts +11 -4
- package/src/types.ts +11 -0
- package/dist/chunk-MLRDNSSA.js.map +0 -1
- package/dist/{index-h_yWj15D.d.ts → index-0lmh0Lbo.d.ts} +8 -8
package/dist/react-types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ComponentType, ReactNode, JSX } from 'react';
|
|
2
|
-
export { F as FragmentDefinition, a as FragmentDefinitionV2 } from './governance-
|
|
2
|
+
export { F as FragmentDefinition, a as FragmentDefinitionV2 } from './governance-DxdJV6lx.js';
|
|
3
3
|
import 'zod';
|
|
4
4
|
import './topology/index.js';
|
|
5
5
|
import './types-xJ2xyp_G.js';
|
package/dist/registry.d.ts
CHANGED
|
@@ -216,7 +216,7 @@ declare const registryComponentSchema: z.ZodObject<{
|
|
|
216
216
|
componentId: string;
|
|
217
217
|
name: string;
|
|
218
218
|
publicRef: string;
|
|
219
|
-
tier: "
|
|
219
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
220
220
|
examples: unknown[];
|
|
221
221
|
files: {
|
|
222
222
|
path: string;
|
|
@@ -264,7 +264,7 @@ declare const registryComponentSchema: z.ZodObject<{
|
|
|
264
264
|
provenance?: Record<string, unknown> | undefined;
|
|
265
265
|
category?: string | undefined;
|
|
266
266
|
contract?: unknown;
|
|
267
|
-
tier?: "
|
|
267
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
268
268
|
examples?: unknown[] | undefined;
|
|
269
269
|
styleFiles?: string[] | undefined;
|
|
270
270
|
tokenDependencies?: string[] | undefined;
|
|
@@ -498,7 +498,7 @@ declare const registryManifestDraftSchema: z.ZodObject<{
|
|
|
498
498
|
componentId: string;
|
|
499
499
|
name: string;
|
|
500
500
|
publicRef: string;
|
|
501
|
-
tier: "
|
|
501
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
502
502
|
examples: unknown[];
|
|
503
503
|
files: {
|
|
504
504
|
path: string;
|
|
@@ -546,7 +546,7 @@ declare const registryManifestDraftSchema: z.ZodObject<{
|
|
|
546
546
|
provenance?: Record<string, unknown> | undefined;
|
|
547
547
|
category?: string | undefined;
|
|
548
548
|
contract?: unknown;
|
|
549
|
-
tier?: "
|
|
549
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
550
550
|
examples?: unknown[] | undefined;
|
|
551
551
|
styleFiles?: string[] | undefined;
|
|
552
552
|
tokenDependencies?: string[] | undefined;
|
|
@@ -611,7 +611,7 @@ declare const registryManifestDraftSchema: z.ZodObject<{
|
|
|
611
611
|
componentId: string;
|
|
612
612
|
name: string;
|
|
613
613
|
publicRef: string;
|
|
614
|
-
tier: "
|
|
614
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
615
615
|
examples: unknown[];
|
|
616
616
|
files: {
|
|
617
617
|
path: string;
|
|
@@ -713,7 +713,7 @@ declare const registryManifestDraftSchema: z.ZodObject<{
|
|
|
713
713
|
provenance?: Record<string, unknown> | undefined;
|
|
714
714
|
category?: string | undefined;
|
|
715
715
|
contract?: unknown;
|
|
716
|
-
tier?: "
|
|
716
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
717
717
|
examples?: unknown[] | undefined;
|
|
718
718
|
styleFiles?: string[] | undefined;
|
|
719
719
|
tokenDependencies?: string[] | undefined;
|
|
@@ -971,7 +971,7 @@ declare const registryManifestSchema: z.ZodObject<{
|
|
|
971
971
|
componentId: string;
|
|
972
972
|
name: string;
|
|
973
973
|
publicRef: string;
|
|
974
|
-
tier: "
|
|
974
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
975
975
|
examples: unknown[];
|
|
976
976
|
files: {
|
|
977
977
|
path: string;
|
|
@@ -1019,7 +1019,7 @@ declare const registryManifestSchema: z.ZodObject<{
|
|
|
1019
1019
|
provenance?: Record<string, unknown> | undefined;
|
|
1020
1020
|
category?: string | undefined;
|
|
1021
1021
|
contract?: unknown;
|
|
1022
|
-
tier?: "
|
|
1022
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
1023
1023
|
examples?: unknown[] | undefined;
|
|
1024
1024
|
styleFiles?: string[] | undefined;
|
|
1025
1025
|
tokenDependencies?: string[] | undefined;
|
|
@@ -1086,7 +1086,7 @@ declare const registryManifestSchema: z.ZodObject<{
|
|
|
1086
1086
|
componentId: string;
|
|
1087
1087
|
name: string;
|
|
1088
1088
|
publicRef: string;
|
|
1089
|
-
tier: "
|
|
1089
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
1090
1090
|
examples: unknown[];
|
|
1091
1091
|
files: {
|
|
1092
1092
|
path: string;
|
|
@@ -1189,7 +1189,7 @@ declare const registryManifestSchema: z.ZodObject<{
|
|
|
1189
1189
|
provenance?: Record<string, unknown> | undefined;
|
|
1190
1190
|
category?: string | undefined;
|
|
1191
1191
|
contract?: unknown;
|
|
1192
|
-
tier?: "
|
|
1192
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
1193
1193
|
examples?: unknown[] | undefined;
|
|
1194
1194
|
styleFiles?: string[] | undefined;
|
|
1195
1195
|
tokenDependencies?: string[] | undefined;
|
|
@@ -1450,7 +1450,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
|
|
|
1450
1450
|
componentId: string;
|
|
1451
1451
|
name: string;
|
|
1452
1452
|
publicRef: string;
|
|
1453
|
-
tier: "
|
|
1453
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
1454
1454
|
examples: unknown[];
|
|
1455
1455
|
files: {
|
|
1456
1456
|
path: string;
|
|
@@ -1498,7 +1498,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
|
|
|
1498
1498
|
provenance?: Record<string, unknown> | undefined;
|
|
1499
1499
|
category?: string | undefined;
|
|
1500
1500
|
contract?: unknown;
|
|
1501
|
-
tier?: "
|
|
1501
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
1502
1502
|
examples?: unknown[] | undefined;
|
|
1503
1503
|
styleFiles?: string[] | undefined;
|
|
1504
1504
|
tokenDependencies?: string[] | undefined;
|
|
@@ -1565,7 +1565,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
|
|
|
1565
1565
|
componentId: string;
|
|
1566
1566
|
name: string;
|
|
1567
1567
|
publicRef: string;
|
|
1568
|
-
tier: "
|
|
1568
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
1569
1569
|
examples: unknown[];
|
|
1570
1570
|
files: {
|
|
1571
1571
|
path: string;
|
|
@@ -1668,7 +1668,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
|
|
|
1668
1668
|
provenance?: Record<string, unknown> | undefined;
|
|
1669
1669
|
category?: string | undefined;
|
|
1670
1670
|
contract?: unknown;
|
|
1671
|
-
tier?: "
|
|
1671
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
1672
1672
|
examples?: unknown[] | undefined;
|
|
1673
1673
|
styleFiles?: string[] | undefined;
|
|
1674
1674
|
tokenDependencies?: string[] | undefined;
|
|
@@ -1773,7 +1773,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
|
|
|
1773
1773
|
componentId: string;
|
|
1774
1774
|
name: string;
|
|
1775
1775
|
publicRef: string;
|
|
1776
|
-
tier: "
|
|
1776
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
1777
1777
|
examples: unknown[];
|
|
1778
1778
|
files: {
|
|
1779
1779
|
path: string;
|
|
@@ -1886,7 +1886,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
|
|
|
1886
1886
|
provenance?: Record<string, unknown> | undefined;
|
|
1887
1887
|
category?: string | undefined;
|
|
1888
1888
|
contract?: unknown;
|
|
1889
|
-
tier?: "
|
|
1889
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
1890
1890
|
examples?: unknown[] | undefined;
|
|
1891
1891
|
styleFiles?: string[] | undefined;
|
|
1892
1892
|
tokenDependencies?: string[] | undefined;
|
|
@@ -1979,7 +1979,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
|
|
|
1979
1979
|
componentId: string;
|
|
1980
1980
|
name: string;
|
|
1981
1981
|
publicRef: string;
|
|
1982
|
-
tier: "
|
|
1982
|
+
tier: "custom" | "utility" | "composition" | "core";
|
|
1983
1983
|
examples: unknown[];
|
|
1984
1984
|
files: {
|
|
1985
1985
|
path: string;
|
|
@@ -2092,7 +2092,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
|
|
|
2092
2092
|
provenance?: Record<string, unknown> | undefined;
|
|
2093
2093
|
category?: string | undefined;
|
|
2094
2094
|
contract?: unknown;
|
|
2095
|
-
tier?: "
|
|
2095
|
+
tier?: "custom" | "utility" | "composition" | "core" | undefined;
|
|
2096
2096
|
examples?: unknown[] | undefined;
|
|
2097
2097
|
styleFiles?: string[] | undefined;
|
|
2098
2098
|
tokenDependencies?: string[] | undefined;
|
package/dist/schemas/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import 'zod';
|
|
2
2
|
export { LegacySeverityLevel, Severity, SeverityLevel, legacySeverityLevelSchema, severityLevelSchema, severitySchema } from '../severity.js';
|
|
3
|
-
export { b as AGENT_FORMAT_SCHEMA_VERSION, c as AgentErrorEnvelope, A as AgentFormat, d as AgentOutput, Q as FactEvidence, R as FactLocation, F as Finding, a as FindingFix, f as FindingReplaceClassTokenFix, g as FindingReplaceComponentFix, h as FindingReplaceImportFix, i as FindingReplacePropValueFix, j as FindingReplaceStyleValueFix, k as Fix, G as GovernanceVerdict, l as GovernanceVerdictMetadata, S as SuppressionDirective, V as ValidatorResult, n as Violation, o as agentErrorEnvelopeSchema, T as agentFindingSchema, p as agentFormatSchema, q as agentIntegritySchema, r as agentOutputSchema, U as agentPlanSchema, t as factEvidenceSchema, u as factLocationSchema, v as findingFixSchema, w as findingReplaceClassTokenFixSchema, x as findingReplaceComponentFixSchema, y as findingReplaceImportFixSchema, z as findingReplacePropValueFixSchema, B as findingReplaceStyleValueFixSchema, C as findingSchema, D as fixSchema, H as governanceVerdictMetadataSchema, J as governanceVerdictSchema, K as normalizeFinding, L as normalizeSeverity, M as normalizeViolation, N as suppressionDirectiveSchema, O as validatorResultSchema, P as violationSchema } from '../index-
|
|
3
|
+
export { b as AGENT_FORMAT_SCHEMA_VERSION, c as AgentErrorEnvelope, A as AgentFormat, d as AgentOutput, Q as FactEvidence, R as FactLocation, F as Finding, a as FindingFix, f as FindingReplaceClassTokenFix, g as FindingReplaceComponentFix, h as FindingReplaceImportFix, i as FindingReplacePropValueFix, j as FindingReplaceStyleValueFix, k as Fix, G as GovernanceVerdict, l as GovernanceVerdictMetadata, S as SuppressionDirective, V as ValidatorResult, n as Violation, o as agentErrorEnvelopeSchema, T as agentFindingSchema, p as agentFormatSchema, q as agentIntegritySchema, r as agentOutputSchema, U as agentPlanSchema, t as factEvidenceSchema, u as factLocationSchema, v as findingFixSchema, w as findingReplaceClassTokenFixSchema, x as findingReplaceComponentFixSchema, y as findingReplaceImportFixSchema, z as findingReplacePropValueFixSchema, B as findingReplaceStyleValueFixSchema, C as findingSchema, D as fixSchema, H as governanceVerdictMetadataSchema, J as governanceVerdictSchema, K as normalizeFinding, L as normalizeSeverity, M as normalizeViolation, N as suppressionDirectiveSchema, O as validatorResultSchema, P as violationSchema } from '../index-0lmh0Lbo.js';
|
package/dist/test-utils.d.ts
CHANGED
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -30,6 +30,9 @@ export const BRAND = {
|
|
|
30
30
|
/** CLI command name (e.g., "fragments") */
|
|
31
31
|
cliCommand: "fragments",
|
|
32
32
|
|
|
33
|
+
/** Full user-facing CLI invocation for copy printed to end users (e.g., "npx @usefragments/cli") */
|
|
34
|
+
cliInvocation: "npx @usefragments/cli",
|
|
35
|
+
|
|
33
36
|
/** Directory for storing fragments, registry, and cache */
|
|
34
37
|
dataDir: ".fragments",
|
|
35
38
|
|
package/src/facts/builders.ts
CHANGED
|
@@ -620,7 +620,7 @@ export function makeUsagePropResolvedFact(input: {
|
|
|
620
620
|
export function makeUsageInlineStyleFact(input: {
|
|
621
621
|
nodeId: FactId;
|
|
622
622
|
property: string;
|
|
623
|
-
valueKind: "static" | "number" | "css-variable";
|
|
623
|
+
valueKind: "static" | "number" | "css-variable" | "dynamic-raw";
|
|
624
624
|
value: string;
|
|
625
625
|
}): UsageInlineStyleFact {
|
|
626
626
|
return {
|
package/src/facts/types.ts
CHANGED
|
@@ -310,7 +310,20 @@ export interface UsageInlineStyleFact extends BaseFact {
|
|
|
310
310
|
kind: "usage_inline_style";
|
|
311
311
|
nodeId: FactId;
|
|
312
312
|
property: string;
|
|
313
|
-
|
|
313
|
+
/**
|
|
314
|
+
* How the extractor resolved the value:
|
|
315
|
+
* - `static` — a string literal (`"#f00"`, `"12px"`).
|
|
316
|
+
* - `number` — a numeric literal (React treats it as `px`).
|
|
317
|
+
* - `css-variable` — a `var(--token)` reference; rules treat it as a token.
|
|
318
|
+
* - `dynamic-raw` — a value constructed at runtime whose *static* fragments
|
|
319
|
+
* unambiguously begin a raw color literal (`` `#${red}` ``,
|
|
320
|
+
* `` `rgb(${r},${g},${b})` ``, `"#" + hex`). `value` carries a reconstructed
|
|
321
|
+
* skeleton with each interpolation rendered as `‹expr›`. Detect-only: a rule
|
|
322
|
+
* may flag it as a raw color but must NEVER attach a deterministic fix
|
|
323
|
+
* (the value is partly runtime). Only color-shaped dynamic values ever
|
|
324
|
+
* surface as facts — a non-color dynamic value stays opaque.
|
|
325
|
+
*/
|
|
326
|
+
valueKind: "static" | "number" | "css-variable" | "dynamic-raw";
|
|
314
327
|
value: string;
|
|
315
328
|
}
|
|
316
329
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
asComponentId,
|
|
5
|
+
compileGlobalGovernanceFacts,
|
|
6
|
+
FactIndex,
|
|
7
|
+
makeUsageComponentFact,
|
|
8
|
+
makeUsageImportFact,
|
|
9
|
+
makeUsageNodeFact,
|
|
10
|
+
ruleComponentsPreferLibrary,
|
|
11
|
+
} from "../index.js";
|
|
12
|
+
|
|
13
|
+
// #9e — import-path identity for a DIRECTORY canonical source. A directory
|
|
14
|
+
// source (e.g. `src/components/ui`) declares its canonical component NAMES via an
|
|
15
|
+
// include list. The rule must:
|
|
16
|
+
// (a) leave a genuine `<Button>` alone once the scan resolver proved its import
|
|
17
|
+
// resolves INSIDE the directory (a `usage_component` with a `<dir>#Name` id);
|
|
18
|
+
// (b) flag a local impostor `<Button>` imported from OUTSIDE the directory,
|
|
19
|
+
// redirecting the import at the canonical directory path (advisory / warn);
|
|
20
|
+
// (c) never flag a third-party or aliased same-name import (collision guard).
|
|
21
|
+
describe("components/prefer-library — #9e directory import-path identity", () => {
|
|
22
|
+
const CANONICAL_DIR = "src/components/ui";
|
|
23
|
+
|
|
24
|
+
function directoryIndex() {
|
|
25
|
+
const ix = new FactIndex();
|
|
26
|
+
ix.addMany(
|
|
27
|
+
compileGlobalGovernanceFacts({
|
|
28
|
+
rules: {
|
|
29
|
+
"components/prefer-library": {
|
|
30
|
+
enabled: true,
|
|
31
|
+
severity: "warning",
|
|
32
|
+
options: {
|
|
33
|
+
canonicalSources: [
|
|
34
|
+
{ kind: "directory", path: CANONICAL_DIR, include: ["Button", "Input"] },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
return ix;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function addButtonUsage(
|
|
45
|
+
ix: FactIndex,
|
|
46
|
+
input: { local?: string; imported?: string; source: string }
|
|
47
|
+
) {
|
|
48
|
+
const node = makeUsageNodeFact({
|
|
49
|
+
file: "src/pages/Home.tsx",
|
|
50
|
+
nodePath: "0:0",
|
|
51
|
+
element: "Button",
|
|
52
|
+
location: { file: "src/pages/Home.tsx", line: 5, column: 4 },
|
|
53
|
+
});
|
|
54
|
+
ix.add(node);
|
|
55
|
+
ix.add(
|
|
56
|
+
makeUsageImportFact({
|
|
57
|
+
file: "src/pages/Home.tsx",
|
|
58
|
+
local: input.local ?? "Button",
|
|
59
|
+
imported: input.imported ?? "Button",
|
|
60
|
+
source: input.source,
|
|
61
|
+
location: { file: "src/pages/Home.tsx", line: 1, column: 0 },
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
return node;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("does not flag a Button whose import the scan resolver proved resolves inside the canonical directory", () => {
|
|
68
|
+
const ix = directoryIndex();
|
|
69
|
+
// The genuine primitive, imported through a `@/` alias into the canonical dir.
|
|
70
|
+
const node = addButtonUsage(ix, { source: "@/components/ui/Button" });
|
|
71
|
+
// The extractor's resolveComponentId seam emitted this canonical id because
|
|
72
|
+
// the module resolved INSIDE `src/components/ui`.
|
|
73
|
+
ix.add(
|
|
74
|
+
makeUsageComponentFact({
|
|
75
|
+
nodeId: node.id,
|
|
76
|
+
componentId: asComponentId(`${CANONICAL_DIR}#Button`),
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("flags a local impostor Button imported from outside the canonical directory and redirects to the canonical path", () => {
|
|
84
|
+
const ix = directoryIndex();
|
|
85
|
+
// No usage_component fact: the impostor's import resolved OUTSIDE the dir.
|
|
86
|
+
addButtonUsage(ix, { source: "../features/FakeButton" });
|
|
87
|
+
|
|
88
|
+
const findings = ruleComponentsPreferLibrary(ix);
|
|
89
|
+
expect(findings).toHaveLength(1);
|
|
90
|
+
expect(findings[0]).toMatchObject({
|
|
91
|
+
ruleId: "components/prefer-library",
|
|
92
|
+
code: "FUI1004",
|
|
93
|
+
fix: {
|
|
94
|
+
kind: "replaceImport",
|
|
95
|
+
from: "../features/FakeButton",
|
|
96
|
+
to: CANONICAL_DIR,
|
|
97
|
+
deterministic: false,
|
|
98
|
+
},
|
|
99
|
+
attributes: {
|
|
100
|
+
suggestedComponent: "Button",
|
|
101
|
+
suggestedImport: CANONICAL_DIR,
|
|
102
|
+
advisory: true,
|
|
103
|
+
contractProof: "missing",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
// Advisory only — never a hard error/block.
|
|
107
|
+
expect(findings[0].level).toBe("warn");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not flag a same-name Button imported from a third-party package (collision guard)", () => {
|
|
111
|
+
const ix = directoryIndex();
|
|
112
|
+
addButtonUsage(ix, { source: "@tanstack/react-router" });
|
|
113
|
+
|
|
114
|
+
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not flag an aliased same-name import whose bound export differs (collision guard)", () => {
|
|
118
|
+
const ix = directoryIndex();
|
|
119
|
+
// `import { Foo as Button } from "../x"` — the local JSX name collides, but
|
|
120
|
+
// the imported binding is not the canonical `Button`, so it is not a redirect
|
|
121
|
+
// candidate.
|
|
122
|
+
addButtonUsage(ix, { local: "Button", imported: "Foo", source: "../x" });
|
|
123
|
+
|
|
124
|
+
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("does not blanket-flag local imports for a directory source without an include list (#224 guard)", () => {
|
|
128
|
+
const ix = new FactIndex();
|
|
129
|
+
ix.addMany(
|
|
130
|
+
compileGlobalGovernanceFacts({
|
|
131
|
+
rules: {
|
|
132
|
+
"components/prefer-library": {
|
|
133
|
+
enabled: true,
|
|
134
|
+
severity: "warning",
|
|
135
|
+
options: {
|
|
136
|
+
// No include list → no curated canonical NAME vocabulary, so there
|
|
137
|
+
// is no basis to redirect an arbitrary local component import.
|
|
138
|
+
canonicalSources: [{ kind: "directory", path: CANONICAL_DIR }],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
addButtonUsage(ix, { source: "../features/FakeButton" });
|
|
145
|
+
|
|
146
|
+
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CanonicalSource, GovernanceSeverity } from "../governance.js";
|
|
2
2
|
import type {
|
|
3
|
+
FactId,
|
|
3
4
|
FactIndex,
|
|
4
5
|
UsageImportFact,
|
|
5
6
|
UsageNodeFact,
|
|
@@ -262,12 +263,20 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
262
263
|
const textByNode = indexTextChildrenByNodeId(ix);
|
|
263
264
|
const classTokensByNode = indexClassTokensByNode(ix);
|
|
264
265
|
const reimplTargets = canonicalReimplTargets(sources, mappings);
|
|
266
|
+
// #9e — nodes whose local import a Node-side module resolver already proved
|
|
267
|
+
// resolves INSIDE a directory canonical source (the extractor emitted a
|
|
268
|
+
// `usage_component` whose id points at that directory). These are the genuine
|
|
269
|
+
// canonical primitives imported by a relative/`@/` path; a browser-safe string
|
|
270
|
+
// comparison of the raw specifier could never recognize them, so the resolved
|
|
271
|
+
// fact is the identity of record. Never flag them as import impostors.
|
|
272
|
+
const canonicalDirectoryResolvedNodes = indexCanonicalDirectoryResolvedNodes(ix, sources);
|
|
265
273
|
const findings: Finding[] = [];
|
|
266
274
|
const seenImportFixes = new Set<string>();
|
|
267
275
|
|
|
268
276
|
for (const node of ix.byKind("usage_node")) {
|
|
269
277
|
if (isCanonicalSourceImplementationFile(node.file, sources)) continue;
|
|
270
278
|
if (node.element.includes(".")) continue;
|
|
279
|
+
if (canonicalDirectoryResolvedNodes.has(node.id)) continue;
|
|
271
280
|
const props = propsByNode.get(node.id) ?? [];
|
|
272
281
|
const textChildren = textByNode.get(node.id) ?? [];
|
|
273
282
|
const importsForFile = importsByFileAndLocal.get(node.file);
|
|
@@ -1065,7 +1074,49 @@ function sourceLabel(source: CanonicalSource): string {
|
|
|
1065
1074
|
function canonicalImportPath(source: CanonicalSource): string | undefined {
|
|
1066
1075
|
if (source.kind === "npm") return source.specifier;
|
|
1067
1076
|
if (source.kind === "registry") return source.importPath;
|
|
1068
|
-
|
|
1077
|
+
// #9e — a directory canonical source *does* have a redirect target: its own
|
|
1078
|
+
// directory path. Returning it lets the import-identity branch point a local
|
|
1079
|
+
// impostor (`<Button>` from `../features/FakeButton`) back at the canonical
|
|
1080
|
+
// directory instead of `continue`-ing to zero findings. Gate on an explicit
|
|
1081
|
+
// include list: without curated NAMES, `sourceIncludesComponent` matches every
|
|
1082
|
+
// PascalCase import, so a redirect target here would blanket-flag them all
|
|
1083
|
+
// (#224). Include-less directory sources keep the old no-target behavior.
|
|
1084
|
+
return source.include?.length ? source.path : undefined;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* #9e — index the `usage_node` ids whose import a Node-side module resolver
|
|
1089
|
+
* proved resolves INSIDE a directory canonical source. The scan orchestrator's
|
|
1090
|
+
* `resolveComponentId` seam emits a `usage_component` fact for such imports with
|
|
1091
|
+
* a `<canonicalDirPath>#<Name>` component id; this recovers those node ids so the
|
|
1092
|
+
* rule can treat the genuine local canonical primitive as already-compliant.
|
|
1093
|
+
*/
|
|
1094
|
+
function indexCanonicalDirectoryResolvedNodes(
|
|
1095
|
+
ix: FactIndex,
|
|
1096
|
+
sources: readonly CanonicalSource[]
|
|
1097
|
+
): Set<FactId> {
|
|
1098
|
+
const out = new Set<FactId>();
|
|
1099
|
+
const directoryPaths = sources
|
|
1100
|
+
.filter((source): source is Extract<CanonicalSource, { kind: "directory" }> =>
|
|
1101
|
+
source.kind === "directory"
|
|
1102
|
+
)
|
|
1103
|
+
.map((source) => normalizePath(source.path));
|
|
1104
|
+
if (directoryPaths.length === 0) return out;
|
|
1105
|
+
for (const fact of ix.byKind("usage_component")) {
|
|
1106
|
+
const module = componentIdModule(fact.componentId);
|
|
1107
|
+
if (module === undefined) continue;
|
|
1108
|
+
const normalizedModule = normalizePath(module);
|
|
1109
|
+
if (directoryPaths.some((path) => isPathInSource(normalizedModule, path))) {
|
|
1110
|
+
out.add(fact.nodeId);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return out;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Extract the module portion (`before#`) of a `module#Export` component id. */
|
|
1117
|
+
function componentIdModule(componentId: string): string | undefined {
|
|
1118
|
+
const hash = componentId.indexOf("#");
|
|
1119
|
+
return hash > 0 ? componentId.slice(0, hash) : undefined;
|
|
1069
1120
|
}
|
|
1070
1121
|
|
|
1071
1122
|
function isCanonicalSourceImplementationFile(
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compileGlobalGovernanceFacts,
|
|
5
|
+
FactIndex,
|
|
6
|
+
g,
|
|
7
|
+
makeTokenDefinitionFact,
|
|
8
|
+
makeUsageInlineStyleFact,
|
|
9
|
+
makeUsageNodeFact,
|
|
10
|
+
makeUsagePropResolvedFact,
|
|
11
|
+
ruleStylesNoRawColor,
|
|
12
|
+
} from "../index.js";
|
|
13
|
+
import type { FactId } from "../index.js";
|
|
14
|
+
|
|
15
|
+
const FILE = "src/icon.tsx";
|
|
16
|
+
|
|
17
|
+
function indexWithColorPolicy(severity: "error" | "warn" | "info" = "error"): FactIndex {
|
|
18
|
+
const ix = new FactIndex();
|
|
19
|
+
ix.addMany(
|
|
20
|
+
compileGlobalGovernanceFacts({
|
|
21
|
+
styles: [g.styles.rawColors().forbid({ except: ["transparent"], prefer: "token", severity })],
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
return ix;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function addNode(ix: FactIndex, element: string, nodePath = "0:0"): FactId {
|
|
28
|
+
const node = makeUsageNodeFact({
|
|
29
|
+
file: FILE,
|
|
30
|
+
nodePath,
|
|
31
|
+
element,
|
|
32
|
+
location: { file: FILE, line: 4, column: 6 },
|
|
33
|
+
});
|
|
34
|
+
ix.add(node);
|
|
35
|
+
return node.id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// #9d — SVG paint attributes (fill/stroke/…)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
describe("styles/no-raw-color — #9d SVG paint attributes", () => {
|
|
43
|
+
it("flags a raw hex on a fill attribute", () => {
|
|
44
|
+
const ix = indexWithColorPolicy();
|
|
45
|
+
const nodeId = addNode(ix, "circle");
|
|
46
|
+
ix.add(
|
|
47
|
+
makeUsagePropResolvedFact({
|
|
48
|
+
nodeId,
|
|
49
|
+
prop: "fill",
|
|
50
|
+
resolution: "static",
|
|
51
|
+
value: "#00ff00",
|
|
52
|
+
location: { file: FILE, line: 4, column: 14 },
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
57
|
+
expect(findings).toHaveLength(1);
|
|
58
|
+
expect(findings[0].attributes).toMatchObject({
|
|
59
|
+
property: "fill",
|
|
60
|
+
color: "#00ff00",
|
|
61
|
+
source: "jsx",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("broadened SVG detection ships advisory (warn) even under an error policy", () => {
|
|
66
|
+
const ix = indexWithColorPolicy("error");
|
|
67
|
+
const nodeId = addNode(ix, "path");
|
|
68
|
+
ix.add(
|
|
69
|
+
makeUsagePropResolvedFact({ nodeId, prop: "stroke", resolution: "static", value: "#123456" })
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
73
|
+
expect(findings).toHaveLength(1);
|
|
74
|
+
expect(findings[0].severity).toBe("moderate"); // capped warn (not "serious")
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("emits a deterministic token fix when stroke matches an accent token exactly", () => {
|
|
78
|
+
const ix = indexWithColorPolicy();
|
|
79
|
+
ix.add(
|
|
80
|
+
makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#7c3aed", category: "color" })
|
|
81
|
+
);
|
|
82
|
+
const nodeId = addNode(ix, "path");
|
|
83
|
+
ix.add(
|
|
84
|
+
makeUsagePropResolvedFact({ nodeId, prop: "stroke", resolution: "static", value: "#7c3aed" })
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
88
|
+
expect(findings).toHaveLength(1);
|
|
89
|
+
expect(findings[0].fix).toBeDefined();
|
|
90
|
+
expect(findings[0].fix).toMatchObject({ kind: "replaceStyleValue", deterministic: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("maps React camelCase stopColor to the paint allowlist", () => {
|
|
94
|
+
const ix = indexWithColorPolicy();
|
|
95
|
+
const nodeId = addNode(ix, "stop");
|
|
96
|
+
ix.add(
|
|
97
|
+
makeUsagePropResolvedFact({
|
|
98
|
+
nodeId,
|
|
99
|
+
prop: "stopColor",
|
|
100
|
+
resolution: "static",
|
|
101
|
+
value: "#abcdef",
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
106
|
+
expect(findings).toHaveLength(1);
|
|
107
|
+
expect(findings[0].attributes).toMatchObject({ property: "stopColor" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not fire for fill='none' or fill='currentColor'", () => {
|
|
111
|
+
const ix = indexWithColorPolicy();
|
|
112
|
+
const nodeId = addNode(ix, "circle");
|
|
113
|
+
ix.add(
|
|
114
|
+
makeUsagePropResolvedFact({ nodeId, prop: "fill", resolution: "static", value: "none" })
|
|
115
|
+
);
|
|
116
|
+
ix.add(
|
|
117
|
+
makeUsagePropResolvedFact({
|
|
118
|
+
nodeId,
|
|
119
|
+
prop: "stroke",
|
|
120
|
+
resolution: "static",
|
|
121
|
+
value: "currentColor",
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does not fire for a dynamic fill prop", () => {
|
|
129
|
+
const ix = indexWithColorPolicy();
|
|
130
|
+
const nodeId = addNode(ix, "circle");
|
|
131
|
+
ix.add(makeUsagePropResolvedFact({ nodeId, prop: "fill", resolution: "dynamic" }));
|
|
132
|
+
|
|
133
|
+
expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("does not fire for a non-paint attribute carrying a hex-shaped string (documented data-URI limit)", () => {
|
|
137
|
+
const ix = indexWithColorPolicy();
|
|
138
|
+
const nodeId = addNode(ix, "img");
|
|
139
|
+
// A data-URI color is intentionally out of scope — parsing colors out of
|
|
140
|
+
// encoded url() strings is high-noise and low-value for this release.
|
|
141
|
+
ix.add(
|
|
142
|
+
makeUsagePropResolvedFact({
|
|
143
|
+
nodeId,
|
|
144
|
+
prop: "src",
|
|
145
|
+
resolution: "static",
|
|
146
|
+
value: 'data:image/svg+xml,<svg fill="%2300ff00" />',
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// #11b — runtime-constructed (dynamic-raw) colors
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe("styles/no-raw-color — #11b dynamic-raw colors", () => {
|
|
159
|
+
it("flags a dynamic-raw inline color with no fix object", () => {
|
|
160
|
+
const ix = indexWithColorPolicy();
|
|
161
|
+
const nodeId = addNode(ix, "div");
|
|
162
|
+
ix.add(
|
|
163
|
+
makeUsageInlineStyleFact({
|
|
164
|
+
nodeId,
|
|
165
|
+
property: "background",
|
|
166
|
+
valueKind: "dynamic-raw",
|
|
167
|
+
value: "#‹expr›",
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
172
|
+
expect(findings).toHaveLength(1);
|
|
173
|
+
expect(findings[0].fix).toBeUndefined();
|
|
174
|
+
expect(findings[0].attributes).toMatchObject({
|
|
175
|
+
property: "background",
|
|
176
|
+
dynamic: true,
|
|
177
|
+
source: "jsx",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("caps the dynamic-raw finding to advisory (warn) under an error policy", () => {
|
|
182
|
+
const ix = indexWithColorPolicy("error");
|
|
183
|
+
const nodeId = addNode(ix, "div");
|
|
184
|
+
ix.add(
|
|
185
|
+
makeUsageInlineStyleFact({
|
|
186
|
+
nodeId,
|
|
187
|
+
property: "color",
|
|
188
|
+
valueKind: "dynamic-raw",
|
|
189
|
+
value: "rgb(‹expr›,‹expr›,‹expr›)",
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
194
|
+
expect(findings).toHaveLength(1);
|
|
195
|
+
expect(findings[0].severity).toBe("moderate"); // capped warn (not "serious")
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("keeps a distinct fingerprint source from static inline colors", () => {
|
|
199
|
+
const ix = indexWithColorPolicy();
|
|
200
|
+
const nodeId = addNode(ix, "div");
|
|
201
|
+
ix.add(
|
|
202
|
+
makeUsageInlineStyleFact({
|
|
203
|
+
nodeId,
|
|
204
|
+
property: "background",
|
|
205
|
+
valueKind: "dynamic-raw",
|
|
206
|
+
value: "#‹expr›",
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
211
|
+
expect(findings[0].fingerprint).toEqual(expect.any(String));
|
|
212
|
+
});
|
|
213
|
+
});
|