@usefragments/core 1.5.2 → 1.7.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-RANPUC6C.js +72 -0
- package/dist/chunk-RANPUC6C.js.map +1 -0
- package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
- package/dist/chunk-WNMWKUYG.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/compiled-types/index.js +8 -0
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
- package/dist/index.d.ts +800 -179
- package/dist/index.js +1003 -237
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +36 -36
- package/dist/schemas/index.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +2 -1
- package/src/__tests__/policy-exclude.test.ts +180 -0
- package/src/agent-format.test.ts +13 -0
- package/src/agent-format.ts +9 -3
- package/src/canonical-bridge.ts +69 -1
- package/src/canonical-direction.test.ts +118 -0
- package/src/canonical-direction.ts +43 -2
- package/src/codes/__tests__/codes.test.ts +14 -2
- package/src/codes/codes.ts +41 -2
- package/src/compiled-types/index.ts +81 -0
- package/src/compiled-types/parse.test.ts +47 -0
- package/src/facts/builders.ts +48 -0
- package/src/facts/compile.ts +148 -34
- package/src/facts/fact-index.ts +15 -2
- package/src/facts/facts.test.ts +44 -3
- package/src/facts/index.ts +11 -6
- package/src/facts/types.ts +60 -9
- package/src/governance-integrity.test.ts +272 -4
- package/src/governance-integrity.ts +345 -25
- package/src/governance.ts +87 -1
- package/src/index.ts +46 -1
- package/src/policy-exclude.ts +113 -0
- package/src/rules/a11y-required-accessible-name.ts +175 -28
- package/src/rules/a11y-standard.ts +102 -0
- package/src/rules/a11y-utils.ts +7 -0
- package/src/rules/components-prefer-library.test.ts +75 -28
- package/src/rules/components-prefer-library.ts +35 -15
- package/src/rules/components-shadow-component.test.ts +21 -9
- package/src/rules/emit-gate.test.ts +74 -4
- package/src/rules/emit-gate.ts +24 -9
- package/src/rules/families.test.ts +69 -0
- package/src/rules/families.ts +52 -0
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +18 -2
- package/src/rules/jsx-preferred-import-path.ts +29 -11
- package/src/rules/rules.test.ts +188 -8
- package/src/rules/styles-no-raw-color.ts +13 -4
- package/src/rules/styles-no-raw-dimensions.ts +13 -4
- package/src/rules/styles-no-raw-spacing.ts +12 -4
- package/src/rules/styles-no-raw-typography.ts +13 -4
- package/src/rules/tiers.ts +1 -0
- package/src/rules/utils.ts +39 -0
- package/src/tokens/design-token-parser.test.ts +131 -0
- package/src/tokens/design-token-parser.ts +362 -49
- package/src/types.ts +2 -2
- package/dist/chunk-BAHCOAVG.js.map +0 -1
- package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
package/src/canonical-bridge.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ContractCanonicalMappingInput } from "./contract/preimage.js";
|
|
2
|
-
import type { CanonicalBridgeV1 } from "./governance.js";
|
|
2
|
+
import type { CanonicalBridgeV1, CanonicalSource } from "./governance.js";
|
|
3
3
|
import { ownedImportMatchesRoot } from "./package-identity-match.js";
|
|
4
4
|
|
|
5
5
|
export function canonicalBridgeUnderlyingKey(input: {
|
|
@@ -28,6 +28,74 @@ export function canonicalBridgeContractMappings(
|
|
|
28
28
|
}));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* One authored bridge, projected into the two facts a consumer needs to honor
|
|
33
|
+
* it WITHOUT widening it: the npm canonical source its underlying export
|
|
34
|
+
* implies, and the local component the user actually sanctioned.
|
|
35
|
+
*
|
|
36
|
+
* The pairing is the point. A bridge says "`src/components/Button.tsx#Button`
|
|
37
|
+
* wraps `@mui/material#Button`" — a fact about ONE component. Reading only the
|
|
38
|
+
* source half turns it into "`@mui/material#Button` is canonical in this repo",
|
|
39
|
+
* which is a strictly wider claim: every component in the tree that happens to
|
|
40
|
+
* render that export inherits the sanction it was never given.
|
|
41
|
+
*/
|
|
42
|
+
export interface CanonicalBridgeIdentityBinding {
|
|
43
|
+
/** The npm canonical source this bridge's underlying export implies. */
|
|
44
|
+
source: Extract<CanonicalSource, { kind: "npm" }>;
|
|
45
|
+
/** The sanctioned local component — `<repo-relative-file>#<exportName>`. */
|
|
46
|
+
localComponentKey: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The per-component bindings an authored bridge set implies — one definition,
|
|
51
|
+
* because every consumer of "which components did the user sanction, against
|
|
52
|
+
* which package export" must see the same set.
|
|
53
|
+
*
|
|
54
|
+
* Consumed by `classify`'s RENDER_ROOT_CANONICAL context, which gates the
|
|
55
|
+
* signal on `localComponentKey`: only the component the bridge names may claim
|
|
56
|
+
* the bridge's canonical, so a sanction stays as narrow as the user authored it.
|
|
57
|
+
*/
|
|
58
|
+
export function canonicalBridgeIdentityBindings(
|
|
59
|
+
bridges: readonly CanonicalBridgeV1[] | undefined
|
|
60
|
+
): CanonicalBridgeIdentityBinding[] {
|
|
61
|
+
return (bridges ?? []).map((bridge) => ({
|
|
62
|
+
source: {
|
|
63
|
+
kind: "npm",
|
|
64
|
+
specifier: bridge.underlying.packageName,
|
|
65
|
+
include: [bridge.underlying.exportName],
|
|
66
|
+
},
|
|
67
|
+
localComponentKey: bridge.local.componentKey,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The npm canonical sources an authored bridge set implies — the source half of
|
|
73
|
+
* `canonicalBridgeIdentityBindings`, for consumers that resolve the whole tree
|
|
74
|
+
* at once and scope the result themselves.
|
|
75
|
+
*
|
|
76
|
+
* A bridge states that `underlying.packageName#exportName` IS the canonical
|
|
77
|
+
* thing a local component wraps, so identity resolution treats that export as
|
|
78
|
+
* declared, exactly as an explicit `govern.canonicalSources` npm entry would.
|
|
79
|
+
* Before this projection was shared, a repo whose decision lived only in
|
|
80
|
+
* `canonicalBridges` (hand-authored, or written by `identity sanction`)
|
|
81
|
+
* resolved render roots at `canonical` grade in the pipeline while the
|
|
82
|
+
* classifier saw no declaration at all — the authored fact reached one consumer
|
|
83
|
+
* and silently missed the other.
|
|
84
|
+
*
|
|
85
|
+
* Consumer: the scan pipeline's identity input
|
|
86
|
+
* (`buildCanonicalDirectionIdentityInput`), which resolves render roots for the
|
|
87
|
+
* whole definition set and reports the grade rather than acting on it.
|
|
88
|
+
* Anything that turns the grade into an AUTHORED decision must use
|
|
89
|
+
* `canonicalBridgeIdentityBindings` instead — this projection deliberately
|
|
90
|
+
* drops `local.componentKey`, so on its own it cannot tell a sanctioned wrapper
|
|
91
|
+
* from any other component that renders the same export.
|
|
92
|
+
*/
|
|
93
|
+
export function canonicalBridgeIdentitySources(
|
|
94
|
+
bridges: readonly CanonicalBridgeV1[] | undefined
|
|
95
|
+
): Array<Extract<CanonicalSource, { kind: "npm" }>> {
|
|
96
|
+
return canonicalBridgeIdentityBindings(bridges).map((binding) => binding.source);
|
|
97
|
+
}
|
|
98
|
+
|
|
31
99
|
export function canonicalBridgeMatchesUnderlyingImport(
|
|
32
100
|
bridge: CanonicalBridgeV1,
|
|
33
101
|
source: string,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { FactIndex, makeComponentDefinitionFact } from "./facts/index.js";
|
|
4
|
+
import type { ComponentDefinitionRenderRoot } from "./facts/index.js";
|
|
5
|
+
import { projectCanonicalDirectionConflicts } from "./canonical-direction.js";
|
|
6
|
+
|
|
7
|
+
const NPM_SOURCE = {
|
|
8
|
+
kind: "npm",
|
|
9
|
+
specifier: "@mui/material",
|
|
10
|
+
include: ["Button", "TextField"],
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
function wrapperDefinition(input: {
|
|
14
|
+
file: string;
|
|
15
|
+
exportName: string;
|
|
16
|
+
renderRoot: ComponentDefinitionRenderRoot;
|
|
17
|
+
exported?: boolean;
|
|
18
|
+
}) {
|
|
19
|
+
return makeComponentDefinitionFact({
|
|
20
|
+
file: input.file,
|
|
21
|
+
exportName: input.exportName,
|
|
22
|
+
exported: input.exported ?? true,
|
|
23
|
+
componentKey: `${input.file}#${input.exportName}`,
|
|
24
|
+
renderRoot: input.renderRoot,
|
|
25
|
+
propSurface: [],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("projectCanonicalDirectionConflicts", () => {
|
|
30
|
+
it("projects a conflict from a package-identity canonical key", () => {
|
|
31
|
+
const ix = new FactIndex();
|
|
32
|
+
ix.add(
|
|
33
|
+
wrapperDefinition({
|
|
34
|
+
file: "src/components/Button.tsx",
|
|
35
|
+
exportName: "Button",
|
|
36
|
+
renderRoot: {
|
|
37
|
+
resolution: "canonical",
|
|
38
|
+
canonical: "@mui/material#Button",
|
|
39
|
+
importSource: "@mui/material",
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(projectCanonicalDirectionConflicts(ix, [NPM_SOURCE])).toMatchObject([
|
|
45
|
+
{
|
|
46
|
+
packageName: "@mui/material",
|
|
47
|
+
exportName: "Button",
|
|
48
|
+
wrapperComponentKey: "src/components/Button.tsx#Button",
|
|
49
|
+
state: "unresolved",
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// A canonical package vendored inside the repo (a workspace package or the
|
|
55
|
+
// committed archetype fixture's fake-packages/) resolves the wrapper's render
|
|
56
|
+
// root to its local file identity. The original import specifier is still
|
|
57
|
+
// exact and source-backed — the conflict must not vanish when resolution
|
|
58
|
+
// goes local, or init detects an ambiguity the scan can never see.
|
|
59
|
+
it("projects a conflict through the import source when the canonical key resolved locally", () => {
|
|
60
|
+
const ix = new FactIndex();
|
|
61
|
+
ix.add(
|
|
62
|
+
wrapperDefinition({
|
|
63
|
+
file: "src/components/Button.tsx",
|
|
64
|
+
exportName: "Button",
|
|
65
|
+
renderRoot: {
|
|
66
|
+
resolution: "canonical",
|
|
67
|
+
canonical: "fake-packages/mui-material/index.js#Button",
|
|
68
|
+
importSource: "@mui/material",
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(projectCanonicalDirectionConflicts(ix, [NPM_SOURCE])).toMatchObject([
|
|
74
|
+
{
|
|
75
|
+
packageName: "@mui/material",
|
|
76
|
+
exportName: "Button",
|
|
77
|
+
wrapperComponentKey: "src/components/Button.tsx#Button",
|
|
78
|
+
wrapperFile: "src/components/Button.tsx",
|
|
79
|
+
wrapperExportName: "Button",
|
|
80
|
+
state: "unresolved",
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("ignores locally-resolved roots whose import source is not a canonical package", () => {
|
|
86
|
+
const ix = new FactIndex();
|
|
87
|
+
ix.add(
|
|
88
|
+
wrapperDefinition({
|
|
89
|
+
file: "src/components/Button.tsx",
|
|
90
|
+
exportName: "Button",
|
|
91
|
+
renderRoot: {
|
|
92
|
+
resolution: "canonical",
|
|
93
|
+
canonical: "packages/other-kit/index.ts#Button",
|
|
94
|
+
importSource: "@acme/other-kit",
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(projectCanonicalDirectionConflicts(ix, [NPM_SOURCE])).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("honors the include list on the import-source fallback", () => {
|
|
103
|
+
const ix = new FactIndex();
|
|
104
|
+
ix.add(
|
|
105
|
+
wrapperDefinition({
|
|
106
|
+
file: "src/components/Chip.tsx",
|
|
107
|
+
exportName: "Chip",
|
|
108
|
+
renderRoot: {
|
|
109
|
+
resolution: "canonical",
|
|
110
|
+
canonical: "fake-packages/mui-material/index.js#Chip",
|
|
111
|
+
importSource: "@mui/material",
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(projectCanonicalDirectionConflicts(ix, [NPM_SOURCE])).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { CanonicalSource } from "./governance.js";
|
|
2
|
-
import type { FactIndex } from "./facts/index.js";
|
|
2
|
+
import type { ComponentDefinitionRenderRoot, FactIndex } from "./facts/index.js";
|
|
3
3
|
import { ownedImportMatchesRoot } from "./package-identity-match.js";
|
|
4
4
|
|
|
5
|
+
type CanonicalRenderRoot = Extract<ComponentDefinitionRenderRoot, { resolution: "canonical" }>;
|
|
6
|
+
|
|
5
7
|
export interface CanonicalDirectionConflict {
|
|
6
8
|
packageName: string;
|
|
7
9
|
exportName: string;
|
|
@@ -32,7 +34,7 @@ export function projectCanonicalDirectionConflicts(
|
|
|
32
34
|
|
|
33
35
|
for (const definition of ix.byKind("component_definition")) {
|
|
34
36
|
if (!definition.exported || definition.renderRoot.resolution !== "canonical") continue;
|
|
35
|
-
const target = npmTarget(definition.renderRoot
|
|
37
|
+
const target = npmTarget(definition.renderRoot, npmSources);
|
|
36
38
|
if (!target) continue;
|
|
37
39
|
|
|
38
40
|
const identity = identityByComponent.get(definition.componentKey);
|
|
@@ -83,6 +85,16 @@ export function canonicalDirectionConflictsTarget(
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
function npmTarget(
|
|
88
|
+
renderRoot: CanonicalRenderRoot,
|
|
89
|
+
sources: readonly Extract<CanonicalSource, { kind: "npm" }>[]
|
|
90
|
+
): { packageName: string; exportName: string } | null {
|
|
91
|
+
return (
|
|
92
|
+
npmTargetFromCanonicalKey(renderRoot.canonical, sources) ??
|
|
93
|
+
npmTargetFromImportSource(renderRoot, sources)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function npmTargetFromCanonicalKey(
|
|
86
98
|
canonicalTarget: string,
|
|
87
99
|
sources: readonly Extract<CanonicalSource, { kind: "npm" }>[]
|
|
88
100
|
): { packageName: string; exportName: string } | null {
|
|
@@ -108,6 +120,35 @@ function npmTarget(
|
|
|
108
120
|
return null;
|
|
109
121
|
}
|
|
110
122
|
|
|
123
|
+
/**
|
|
124
|
+
* A canonical package that also lives in the repo (a vendored fixture package
|
|
125
|
+
* or a workspace package whose package.json name matches the specifier)
|
|
126
|
+
* resolves to its local file identity, so the canonical key carries no package
|
|
127
|
+
* name. The original import specifier still does — and it is just as exact and
|
|
128
|
+
* source-backed, so the conflict must not vanish when resolution goes local
|
|
129
|
+
* (init's detector sees it; the scan must see the same set).
|
|
130
|
+
*/
|
|
131
|
+
function npmTargetFromImportSource(
|
|
132
|
+
renderRoot: CanonicalRenderRoot,
|
|
133
|
+
sources: readonly Extract<CanonicalSource, { kind: "npm" }>[]
|
|
134
|
+
): { packageName: string; exportName: string } | null {
|
|
135
|
+
const separator = renderRoot.canonical.lastIndexOf("#");
|
|
136
|
+
if (separator <= 0 || separator === renderRoot.canonical.length - 1) return null;
|
|
137
|
+
const exportName = renderRoot.canonical
|
|
138
|
+
.slice(separator + 1)
|
|
139
|
+
.split(".")
|
|
140
|
+
.at(-1);
|
|
141
|
+
if (!exportName || exportName === "default") return null;
|
|
142
|
+
|
|
143
|
+
for (const source of sources) {
|
|
144
|
+
if (!ownedImportMatchesRoot(renderRoot.importSource, source.specifier)) continue;
|
|
145
|
+
if (source.exclude?.includes(exportName)) continue;
|
|
146
|
+
if (source.include && !source.include.includes(exportName)) continue;
|
|
147
|
+
return { packageName: source.specifier, exportName };
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
111
152
|
function packageSubpath(importPath: string, packageName: string): string {
|
|
112
153
|
return importPath === packageName ? "" : importPath.slice(packageName.length + 1);
|
|
113
154
|
}
|
|
@@ -11,7 +11,20 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
11
11
|
const __dirname = path.dirname(__filename);
|
|
12
12
|
const registryPath = path.resolve(__dirname, "../../../../../docs/codes/registry.json");
|
|
13
13
|
|
|
14
|
-
const SUBSYSTEM_EMITTED_CODES = [
|
|
14
|
+
const SUBSYSTEM_EMITTED_CODES = [
|
|
15
|
+
"FUI9001",
|
|
16
|
+
"FUI9002",
|
|
17
|
+
"FUI9003",
|
|
18
|
+
"FUI9004",
|
|
19
|
+
"FUI9005",
|
|
20
|
+
"FUI9006",
|
|
21
|
+
"FUI9007",
|
|
22
|
+
"FUI9008",
|
|
23
|
+
// Emitted by the CLI scan layer, not the rule engine: the contract's token
|
|
24
|
+
// sources are read before any rule runs, and an unreadable one aborts the
|
|
25
|
+
// scan rather than producing a finding.
|
|
26
|
+
"FUI9009",
|
|
27
|
+
] as const;
|
|
15
28
|
|
|
16
29
|
const RESERVED_CODES = {
|
|
17
30
|
FUI1001: "Reserved for canonical primitive findings; no core rule emits it.",
|
|
@@ -20,7 +33,6 @@ const RESERVED_CODES = {
|
|
|
20
33
|
FUI2001: "Reserved for generic design-token requirements; no core rule emits it.",
|
|
21
34
|
FUI2002: "Reserved for token-prefix policy; no core rule emits it.",
|
|
22
35
|
FUI2013: "Reserved for brand-seed enforcement; no core rule emits it.",
|
|
23
|
-
FUI3002: "Reserved for general accessibility standards; no core rule emits it.",
|
|
24
36
|
FUI5001: "Reserved for required-child composition; no core rule emits it.",
|
|
25
37
|
FUI5002: "Deprecated canonical-composition alias; no active core rule emits it.",
|
|
26
38
|
FUI6001: "Reserved for generic unknown props; no core rule emits it.",
|
package/src/codes/codes.ts
CHANGED
|
@@ -282,14 +282,13 @@ export const CODES = [
|
|
|
282
282
|
lifecycle: "experimental",
|
|
283
283
|
evidenceRequired: true,
|
|
284
284
|
}),
|
|
285
|
-
|
|
285
|
+
ruleCode({
|
|
286
286
|
code: "FUI3002",
|
|
287
287
|
ruleId: "a11y/standard",
|
|
288
288
|
category: "a11y",
|
|
289
289
|
defaultSeverity: "serious",
|
|
290
290
|
title: "Accessibility standard failed",
|
|
291
291
|
lifecycle: "experimental",
|
|
292
|
-
fixAvailable: false,
|
|
293
292
|
evidenceRequired: true,
|
|
294
293
|
}),
|
|
295
294
|
code({
|
|
@@ -488,6 +487,46 @@ export const CODES = [
|
|
|
488
487
|
fixAvailable: false,
|
|
489
488
|
evidenceRequired: false,
|
|
490
489
|
}),
|
|
490
|
+
code({
|
|
491
|
+
code: "FUI9006",
|
|
492
|
+
ruleId: "config/unmatched-exclude",
|
|
493
|
+
category: "system",
|
|
494
|
+
defaultSeverity: "moderate",
|
|
495
|
+
title: "Rule exclude matched no scanned file",
|
|
496
|
+
lifecycle: "experimental",
|
|
497
|
+
fixAvailable: false,
|
|
498
|
+
evidenceRequired: false,
|
|
499
|
+
}),
|
|
500
|
+
code({
|
|
501
|
+
code: "FUI9007",
|
|
502
|
+
ruleId: "config/colliding-record",
|
|
503
|
+
category: "system",
|
|
504
|
+
defaultSeverity: "moderate",
|
|
505
|
+
title: "Governance record was dropped as a duplicate",
|
|
506
|
+
lifecycle: "experimental",
|
|
507
|
+
fixAvailable: false,
|
|
508
|
+
evidenceRequired: false,
|
|
509
|
+
}),
|
|
510
|
+
code({
|
|
511
|
+
code: "FUI9008",
|
|
512
|
+
ruleId: "config/overridden-record-severity",
|
|
513
|
+
category: "system",
|
|
514
|
+
defaultSeverity: "moderate",
|
|
515
|
+
title: "Rule override outranks a record's authored severity",
|
|
516
|
+
lifecycle: "experimental",
|
|
517
|
+
fixAvailable: false,
|
|
518
|
+
evidenceRequired: false,
|
|
519
|
+
}),
|
|
520
|
+
code({
|
|
521
|
+
code: "FUI9009",
|
|
522
|
+
ruleId: "contract/unreadable-token-source",
|
|
523
|
+
category: "system",
|
|
524
|
+
defaultSeverity: "critical",
|
|
525
|
+
title: "Contract token source cannot be read",
|
|
526
|
+
lifecycle: "experimental",
|
|
527
|
+
fixAvailable: false,
|
|
528
|
+
evidenceRequired: false,
|
|
529
|
+
}),
|
|
491
530
|
] as const satisfies readonly FuiCode[];
|
|
492
531
|
|
|
493
532
|
export const byCode: ReadonlyMap<string, FuiCode> = new Map(
|
|
@@ -267,6 +267,87 @@ export interface CompiledFragmentsFile {
|
|
|
267
267
|
recipes?: Record<string, CompiledBlock>;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
export class CompiledFragmentsFileValidationError extends Error {
|
|
271
|
+
constructor(
|
|
272
|
+
readonly source: string,
|
|
273
|
+
readonly issues: string[]
|
|
274
|
+
) {
|
|
275
|
+
super(`Invalid compiled Fragments catalog at ${source}: ${issues.join("; ")}`);
|
|
276
|
+
this.name = "CompiledFragmentsFileValidationError";
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function parseCompiledFragmentsFile(
|
|
281
|
+
input: string | unknown,
|
|
282
|
+
source = "fragments.json"
|
|
283
|
+
): CompiledFragmentsFile {
|
|
284
|
+
let value: unknown = input;
|
|
285
|
+
if (typeof input === "string") {
|
|
286
|
+
try {
|
|
287
|
+
value = JSON.parse(input);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
throw new CompiledFragmentsFileValidationError(source, [
|
|
290
|
+
`invalid JSON (${error instanceof Error ? error.message : String(error)})`,
|
|
291
|
+
]);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const issues: string[] = [];
|
|
296
|
+
if (!isRecord(value)) {
|
|
297
|
+
throw new CompiledFragmentsFileValidationError(source, ["root must be an object"]);
|
|
298
|
+
}
|
|
299
|
+
if (typeof value.version !== "string" || value.version.length === 0) {
|
|
300
|
+
issues.push("version must be a non-empty string");
|
|
301
|
+
}
|
|
302
|
+
if (typeof value.generatedAt !== "string" || value.generatedAt.length === 0) {
|
|
303
|
+
issues.push("generatedAt must be a non-empty string");
|
|
304
|
+
}
|
|
305
|
+
if (!isRecord(value.fragments)) {
|
|
306
|
+
issues.push("fragments must be an object");
|
|
307
|
+
} else {
|
|
308
|
+
for (const [key, fragment] of Object.entries(value.fragments)) {
|
|
309
|
+
validateCompiledFragment(key, fragment, issues);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (value.graph !== undefined) {
|
|
313
|
+
if (
|
|
314
|
+
!isRecord(value.graph) ||
|
|
315
|
+
!Array.isArray(value.graph.nodes) ||
|
|
316
|
+
!Array.isArray(value.graph.edges) ||
|
|
317
|
+
!isRecord(value.graph.health)
|
|
318
|
+
) {
|
|
319
|
+
issues.push("graph must contain nodes[], edges[], and health");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (issues.length > 0) throw new CompiledFragmentsFileValidationError(source, issues);
|
|
323
|
+
return value as unknown as CompiledFragmentsFile;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function validateCompiledFragment(key: string, value: unknown, issues: string[]): void {
|
|
327
|
+
const path = `fragments.${key}`;
|
|
328
|
+
if (!isRecord(value)) {
|
|
329
|
+
issues.push(`${path} must be an object`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (typeof value.filePath !== "string") issues.push(`${path}.filePath must be a string`);
|
|
333
|
+
if (!isRecord(value.meta) || typeof value.meta.name !== "string") {
|
|
334
|
+
issues.push(`${path}.meta.name must be a string`);
|
|
335
|
+
}
|
|
336
|
+
if (!isRecord(value.props)) issues.push(`${path}.props must be an object`);
|
|
337
|
+
if (!Array.isArray(value.variants)) issues.push(`${path}.variants must be an array`);
|
|
338
|
+
if (
|
|
339
|
+
!isRecord(value.usage) ||
|
|
340
|
+
!Array.isArray(value.usage.when) ||
|
|
341
|
+
!Array.isArray(value.usage.whenNot)
|
|
342
|
+
) {
|
|
343
|
+
issues.push(`${path}.usage must contain when[] and whenNot[]`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
348
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
349
|
+
}
|
|
350
|
+
|
|
270
351
|
/**
|
|
271
352
|
* Theme identifier
|
|
272
353
|
*/
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CompiledFragmentsFileValidationError,
|
|
5
|
+
parseCompiledFragmentsFile,
|
|
6
|
+
} from "./index.js";
|
|
7
|
+
|
|
8
|
+
const validCatalog = {
|
|
9
|
+
version: "1.0.0",
|
|
10
|
+
generatedAt: "2026-07-28T00:00:00.000Z",
|
|
11
|
+
fragments: {
|
|
12
|
+
Button: {
|
|
13
|
+
filePath: "src/Button.fragment.ts",
|
|
14
|
+
meta: { name: "Button", description: "Action", category: "Actions" },
|
|
15
|
+
usage: { when: ["Actions"], whenNot: [] },
|
|
16
|
+
props: {},
|
|
17
|
+
variants: [],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("parseCompiledFragmentsFile", () => {
|
|
23
|
+
it("accepts a compiled catalog with the command-critical shape", () => {
|
|
24
|
+
expect(parseCompiledFragmentsFile(JSON.stringify(validCatalog))).toMatchObject(validCatalog);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("reports invalid fragment paths instead of allowing command-specific crashes", () => {
|
|
28
|
+
expect(() =>
|
|
29
|
+
parseCompiledFragmentsFile(
|
|
30
|
+
{
|
|
31
|
+
...validCatalog,
|
|
32
|
+
fragments: { Button: { meta: {}, variants: "bad" } },
|
|
33
|
+
},
|
|
34
|
+
"/repo/fragments.json"
|
|
35
|
+
)
|
|
36
|
+
).toThrow(CompiledFragmentsFileValidationError);
|
|
37
|
+
expect(() =>
|
|
38
|
+
parseCompiledFragmentsFile(
|
|
39
|
+
{
|
|
40
|
+
...validCatalog,
|
|
41
|
+
fragments: { Button: { meta: {}, variants: "bad" } },
|
|
42
|
+
},
|
|
43
|
+
"/repo/fragments.json"
|
|
44
|
+
)
|
|
45
|
+
).toThrow("fragments.Button.meta.name");
|
|
46
|
+
});
|
|
47
|
+
});
|