@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.
@@ -1,5 +1,5 @@
1
1
  import { ComponentType, ReactNode, JSX } from 'react';
2
- export { F as FragmentDefinition, a as FragmentDefinitionV2 } from './governance-DYSirwJu.js';
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';
@@ -216,7 +216,7 @@ declare const registryComponentSchema: z.ZodObject<{
216
216
  componentId: string;
217
217
  name: string;
218
218
  publicRef: string;
219
- tier: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
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: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
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: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
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: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
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: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
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: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
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: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
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: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
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: "core" | "custom" | "utility" | "composition";
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?: "core" | "custom" | "utility" | "composition" | undefined;
2095
+ tier?: "custom" | "utility" | "composition" | "core" | undefined;
2096
2096
  examples?: unknown[] | undefined;
2097
2097
  styleFiles?: string[] | undefined;
2098
2098
  tokenDependencies?: string[] | undefined;
@@ -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-h_yWj15D.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-0lmh0Lbo.js';
@@ -1,4 +1,4 @@
1
- import { C as CompiledBlock, b as CompiledFragment } from './governance-DYSirwJu.js';
1
+ import { C as CompiledBlock, b as CompiledFragment } from './governance-DxdJV6lx.js';
2
2
  import 'zod';
3
3
  import 'react';
4
4
  import './topology/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefragments/core",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Core types, schemas, and runtime API for Fragments component definitions",
6
6
  "author": "Conan McNicholl",
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
 
@@ -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 {
@@ -310,7 +310,20 @@ export interface UsageInlineStyleFact extends BaseFact {
310
310
  kind: "usage_inline_style";
311
311
  nodeId: FactId;
312
312
  property: string;
313
- valueKind: "static" | "number" | "css-variable";
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
- return undefined;
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
+ });