@usefragments/core 1.5.1 → 1.5.2

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-B88uR3Zq.js';
2
+ export { F as FragmentDefinition, a as FragmentDefinitionV2 } from './governance-pKrfh517.js';
3
3
  import 'zod';
4
4
  import './topology/index.js';
5
5
  import './types-xJ2xyp_G.js';
@@ -1,4 +1,4 @@
1
- import { C as CompiledBlock, b as CompiledFragment } from './governance-B88uR3Zq.js';
1
+ import { C as CompiledBlock, b as CompiledFragment } from './governance-pKrfh517.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.5.1",
3
+ "version": "1.5.2",
4
4
  "license": "MIT",
5
5
  "description": "Core types, schemas, and runtime API for Fragments component definitions",
6
6
  "author": "Conan McNicholl",
@@ -11,7 +11,7 @@ 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 = ["FUI9001", "FUI9002", "FUI9003"] as const;
14
+ const SUBSYSTEM_EMITTED_CODES = ["FUI9001", "FUI9002", "FUI9003", "FUI9004", "FUI9005"] as const;
15
15
 
16
16
  const RESERVED_CODES = {
17
17
  FUI1001: "Reserved for canonical primitive findings; no core rule emits it.",
@@ -468,6 +468,26 @@ export const CODES = [
468
468
  fixAvailable: true,
469
469
  evidenceRequired: true,
470
470
  }),
471
+ code({
472
+ code: "FUI9004",
473
+ ruleId: "config/unconsumed-key",
474
+ category: "system",
475
+ defaultSeverity: "moderate",
476
+ title: "Config key is not consumed",
477
+ lifecycle: "experimental",
478
+ fixAvailable: false,
479
+ evidenceRequired: false,
480
+ }),
481
+ code({
482
+ code: "FUI9005",
483
+ ruleId: "config/orphan-scale",
484
+ category: "system",
485
+ defaultSeverity: "moderate",
486
+ title: "Governance scale is not referenced",
487
+ lifecycle: "experimental",
488
+ fixAvailable: false,
489
+ evidenceRequired: false,
490
+ }),
471
491
  ] as const satisfies readonly FuiCode[];
472
492
 
473
493
  export const byCode: ReadonlyMap<string, FuiCode> = new Map(
package/src/config.ts CHANGED
@@ -2,10 +2,24 @@ import { fragmentsConfigSchema } from "./schema.js";
2
2
  import type { FragmentsConfig } from "./types.js";
3
3
  import { normalizeGovernanceConfig } from "./governance.js";
4
4
 
5
+ const RAW_CONFIG_DECLARATION = Symbol.for("@usefragments/core/raw-config-declaration");
6
+
5
7
  function formatZodErrors(errors: Array<{ path: (string | number)[]; message: string }>) {
6
8
  return errors.map((error) => ` - ${error.path.join(".")}: ${error.message}`).join("\n");
7
9
  }
8
10
 
11
+ /**
12
+ * Recover the authored declaration before Zod stripped unknown keys.
13
+ *
14
+ * `defineConfig` still returns the schema-parsed config so its runtime semantics
15
+ * do not change. The non-enumerable source declaration exists only long enough
16
+ * for config loaders to diagnose keys that validated but would otherwise vanish.
17
+ */
18
+ export function configDeclarationForDiagnostics(config: unknown): unknown {
19
+ if (!config || typeof config !== "object") return config;
20
+ return (config as Record<PropertyKey, unknown>)[RAW_CONFIG_DECLARATION] ?? config;
21
+ }
22
+
9
23
  export function defineConfig<TConfig extends FragmentsConfig>(config: TConfig): TConfig {
10
24
  const normalized = normalizeGovernanceConfig(config);
11
25
  const result = fragmentsConfigSchema.safeParse(normalized);
@@ -14,5 +28,11 @@ export function defineConfig<TConfig extends FragmentsConfig>(config: TConfig):
14
28
  throw new Error(`Invalid fragments config:\n${formatZodErrors(result.error.errors)}`);
15
29
  }
16
30
 
31
+ Object.defineProperty(result.data, RAW_CONFIG_DECLARATION, {
32
+ value: normalized,
33
+ enumerable: false,
34
+ configurable: false,
35
+ writable: false,
36
+ });
17
37
  return result.data as TConfig;
18
38
  }
@@ -45,6 +45,11 @@ export interface FactEvidence {
45
45
  fact: Fact;
46
46
  }
47
47
 
48
+ export interface FactIndexOptions {
49
+ /** Optional internal diagnostic route. Product output is quiet by default. */
50
+ onConflict?: (message: string) => void;
51
+ }
52
+
48
53
  interface FactWithComponent {
49
54
  componentId: ComponentId;
50
55
  }
@@ -115,6 +120,8 @@ export class FactIndex {
115
120
  private readonly idsByComponent = new Map<ComponentId, Set<FactId>>();
116
121
  private readonly tokenBySymbol = new Map<string, TokenDefinitionFact>();
117
122
 
123
+ constructor(private readonly options: FactIndexOptions = {}) {}
124
+
118
125
  add(fact: Fact): void {
119
126
  if (fact.kind === "token_definition") {
120
127
  this.indexTokenSymbols(fact);
@@ -125,7 +132,7 @@ export class FactIndex {
125
132
  canonicalJson(logicalFactForComparison(existing)) !==
126
133
  canonicalJson(logicalFactForComparison(fact))
127
134
  ) {
128
- console.warn(
135
+ this.options.onConflict?.(
129
136
  `FactIndex: conflicting facts for id ${fact.id} — keeping ${describeFactForConflict(existing)}, skipping ${describeFactForConflict(fact)}`
130
137
  );
131
138
  }
@@ -446,9 +446,9 @@ describe("FactIndex — query layer", () => {
446
446
  expect(() => ix.evidence([componentFact.id, ghost])).toThrow(/missing/i);
447
447
  });
448
448
 
449
- it("warns and keeps the first fact when a conflicting fact reuses an id", () => {
450
- const ix = new FactIndex();
451
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
449
+ it("reports through an opt-in callback and keeps the first fact when an id conflicts", () => {
450
+ const onConflict = vi.fn();
451
+ const ix = new FactIndex({ onConflict });
452
452
  const id = factId("prop_value_forbidden", {
453
453
  componentId: buttonId,
454
454
  prop: "variant",
@@ -469,14 +469,13 @@ describe("FactIndex — query layer", () => {
469
469
  };
470
470
  ix.add(a);
471
471
  expect(() => ix.add(b)).not.toThrow();
472
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("conflicting facts"));
472
+ expect(onConflict).toHaveBeenCalledWith(expect.stringContaining("conflicting facts"));
473
473
  expect(ix.get(id)).toEqual(a);
474
- warn.mockRestore();
475
474
  });
476
475
 
477
476
  it("does not treat token-definition provenance as a logical conflict", () => {
478
- const ix = new FactIndex();
479
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
477
+ const onConflict = vi.fn();
478
+ const ix = new FactIndex({ onConflict });
480
479
  const first = makeTokenDefinitionFact({
481
480
  name: "--fui-color-accent",
482
481
  value: "#2563eb",
@@ -491,14 +490,13 @@ describe("FactIndex — query layer", () => {
491
490
  ix.add(first);
492
491
  ix.add(second);
493
492
 
494
- expect(warn).not.toHaveBeenCalled();
493
+ expect(onConflict).not.toHaveBeenCalled();
495
494
  expect(ix.get(first.id)).toEqual(first);
496
- warn.mockRestore();
497
495
  });
498
496
 
499
- it("includes token-definition provenance in logical conflict warnings", () => {
500
- const ix = new FactIndex();
501
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
497
+ it("includes token-definition provenance in logical conflict reports", () => {
498
+ const onConflict = vi.fn();
499
+ const ix = new FactIndex({ onConflict });
502
500
  const first = makeTokenDefinitionFact({
503
501
  name: "--fui-color-accent",
504
502
  value: "#2563eb",
@@ -513,13 +511,12 @@ describe("FactIndex — query layer", () => {
513
511
  ix.add(first);
514
512
  ix.add(second);
515
513
 
516
- expect(warn).toHaveBeenCalledWith(
514
+ expect(onConflict).toHaveBeenCalledWith(
517
515
  expect.stringMatching(
518
516
  /conflicting facts.*keeping kind=token_definition location=tokens\/base\.css:2:1, skipping kind=token_definition location=tokens\/theme\.css:4:1/
519
517
  )
520
518
  );
521
519
  expect(ix.get(first.id)).toEqual(first);
522
- warn.mockRestore();
523
520
  });
524
521
 
525
522
  it("treats old and current owned component spellings as the same indexed facts", () => {
@@ -527,17 +524,16 @@ describe("FactIndex — query layer", () => {
527
524
  const currentId = asComponentId("@usefragments/ui#Button");
528
525
  const legacy = compileComponentFacts(legacyId, buildSampleFragment());
529
526
  const current = compileComponentFacts(currentId, buildSampleFragment());
530
- const ix = new FactIndex();
531
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
527
+ const onConflict = vi.fn();
528
+ const ix = new FactIndex({ onConflict });
532
529
 
533
530
  ix.addMany(legacy);
534
531
  ix.addMany(current);
535
532
 
536
- expect(warn).not.toHaveBeenCalled();
533
+ expect(onConflict).not.toHaveBeenCalled();
537
534
  expect(ix.size()).toBe(legacy.length);
538
535
  expect(ix.components.byId(legacyId)?.name).toBe("Button");
539
536
  expect(ix.components.byId(currentId)?.name).toBe("Button");
540
- warn.mockRestore();
541
537
  });
542
538
  });
543
539
 
@@ -2,12 +2,15 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import type { GovernanceConfig } from "./governance.js";
4
4
  import {
5
+ detectOrphanGovernanceScales,
6
+ detectUnconsumedConfigKeys,
5
7
  evaluateGovernanceIntegrity,
6
8
  hasEffectiveComponentVocabulary,
7
9
  isEffectiveCanonicalSource,
8
10
  type GovernanceIntegrityInput,
9
11
  } from "./governance-integrity.js";
10
12
  import { customerDefaultRuleStates } from "./rules/presets.js";
13
+ import { fragmentsConfigSchema } from "./schema.js";
11
14
 
12
15
  function evaluate(
13
16
  overrides: Partial<GovernanceIntegrityInput>
@@ -66,6 +69,84 @@ describe("hasEffectiveComponentVocabulary", () => {
66
69
  });
67
70
  });
68
71
 
72
+ describe("inert config diagnostics", () => {
73
+ it("reports stripped, passthrough, unknown-rule, and rule-field keys in stable order", () => {
74
+ const authored = {
75
+ styles: { spacing: true },
76
+ screenshots: { threshold: 0.1, renderer: "chromium" },
77
+ tokens: { include: ["tokens.css"], mystery: true },
78
+ govern: {
79
+ scales: {
80
+ space: { kind: "scale", unit: "px", values: [0, 4, 8], source: "legacy" },
81
+ },
82
+ rules: {
83
+ "styles/no-raw-color": { enabled: true, exclude: ["vendor/**"] },
84
+ "styles/not-a-real-rule": { enabled: true },
85
+ },
86
+ },
87
+ };
88
+ const parsed = fragmentsConfigSchema.parse(authored);
89
+
90
+ expect(detectUnconsumedConfigKeys(authored, parsed)).toMatchObject([
91
+ { code: "FUI9004", path: "govern.rules.styles/no-raw-color.exclude" },
92
+ { code: "FUI9004", path: "govern.rules.styles/not-a-real-rule" },
93
+ { code: "FUI9004", path: "govern.scales.space.source" },
94
+ { code: "FUI9004", path: "screenshots.renderer" },
95
+ { code: "FUI9004", path: "styles" },
96
+ { code: "FUI9004", path: "tokens.mystery" },
97
+ ]);
98
+ });
99
+
100
+ it("reports an authored spacing scale that no effective property policy references", () => {
101
+ const declared: GovernanceConfig = {
102
+ scales: {
103
+ spacing: { kind: "scale", unit: "px", values: [0, 4, 8] },
104
+ },
105
+ };
106
+ const effective: GovernanceConfig = {
107
+ ...declared,
108
+ scales: {
109
+ ...declared.scales,
110
+ space: { kind: "scale", unit: "px", values: [0, 4, 8] },
111
+ },
112
+ styles: [
113
+ {
114
+ kind: "style.rawSpacing.mustMatchScale",
115
+ scale: "space",
116
+ appliesTo: ["margin", "padding"],
117
+ severity: "warn",
118
+ },
119
+ ],
120
+ };
121
+
122
+ expect(detectOrphanGovernanceScales(declared, effective)).toEqual([
123
+ expect.objectContaining({
124
+ code: "FUI9005",
125
+ path: "govern.scales.spacing",
126
+ message: expect.stringContaining('bound to the scale named "space"'),
127
+ }),
128
+ ]);
129
+ });
130
+
131
+ it("does not report a scale referenced by an effective property policy", () => {
132
+ const policy: GovernanceConfig = {
133
+ scales: {
134
+ space: { kind: "scale", unit: "px", values: [0, 4, 8] },
135
+ },
136
+ styles: [
137
+ {
138
+ kind: "style.rawSpacing.mustMatchScale",
139
+ scale: "space",
140
+ appliesTo: ["margin"],
141
+ severity: "warn",
142
+ },
143
+ ],
144
+ };
145
+
146
+ expect(detectOrphanGovernanceScales(policy, policy)).toEqual([]);
147
+ });
148
+ });
149
+
69
150
  describe("evaluateGovernanceIntegrity", () => {
70
151
  it("1. no policy at all → inert, not fatal, not blocking-capable", () => {
71
152
  const verdict = evaluate({ policy: undefined, policySource: "none", declared: false });
@@ -207,4 +288,28 @@ describe("evaluateGovernanceIntegrity", () => {
207
288
  // degraded is enforceable, so not CI-fatal
208
289
  expect(verdict.fatalForCi).toBe(false);
209
290
  });
291
+
292
+ it("includes inert config diagnostics in the doctor roster without changing integrity status", () => {
293
+ const policy: GovernanceConfig = {
294
+ rules: { "styles/no-raw-color": { enabled: true, severity: "warn" } },
295
+ };
296
+ const baseline = evaluate({ policy, policySource: "config", declared: true });
297
+ const withDiagnostic = evaluate({
298
+ policy,
299
+ policySource: "config",
300
+ declared: true,
301
+ configDiagnostics: Array.from({ length: 2 }, () => ({
302
+ code: "FUI9005",
303
+ kind: "orphan-scale",
304
+ severity: "warn",
305
+ path: "govern.scales.spacing",
306
+ message: "orphan",
307
+ })),
308
+ });
309
+
310
+ expect(withDiagnostic.status).toBe(baseline.status);
311
+ expect(withDiagnostic.fatalForCi).toBe(baseline.fatalForCi);
312
+ expect(withDiagnostic.roster).toMatchObject({ active: 1, inert: 1 });
313
+ expect(withDiagnostic.configDiagnostics).toHaveLength(1);
314
+ });
210
315
  });