@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.
Files changed (64) hide show
  1. package/dist/chunk-RANPUC6C.js +72 -0
  2. package/dist/chunk-RANPUC6C.js.map +1 -0
  3. package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
  4. package/dist/chunk-WNMWKUYG.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +1 -1
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/compiled-types/index.js +8 -0
  9. package/dist/generate/index.d.ts +1 -1
  10. package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
  11. package/dist/index.d.ts +800 -179
  12. package/dist/index.js +1003 -237
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +36 -36
  16. package/dist/schemas/index.d.ts +1 -1
  17. package/dist/test-utils.d.ts +1 -1
  18. package/package.json +2 -1
  19. package/src/__tests__/policy-exclude.test.ts +180 -0
  20. package/src/agent-format.test.ts +13 -0
  21. package/src/agent-format.ts +9 -3
  22. package/src/canonical-bridge.ts +69 -1
  23. package/src/canonical-direction.test.ts +118 -0
  24. package/src/canonical-direction.ts +43 -2
  25. package/src/codes/__tests__/codes.test.ts +14 -2
  26. package/src/codes/codes.ts +41 -2
  27. package/src/compiled-types/index.ts +81 -0
  28. package/src/compiled-types/parse.test.ts +47 -0
  29. package/src/facts/builders.ts +48 -0
  30. package/src/facts/compile.ts +148 -34
  31. package/src/facts/fact-index.ts +15 -2
  32. package/src/facts/facts.test.ts +44 -3
  33. package/src/facts/index.ts +11 -6
  34. package/src/facts/types.ts +60 -9
  35. package/src/governance-integrity.test.ts +272 -4
  36. package/src/governance-integrity.ts +345 -25
  37. package/src/governance.ts +87 -1
  38. package/src/index.ts +46 -1
  39. package/src/policy-exclude.ts +113 -0
  40. package/src/rules/a11y-required-accessible-name.ts +175 -28
  41. package/src/rules/a11y-standard.ts +102 -0
  42. package/src/rules/a11y-utils.ts +7 -0
  43. package/src/rules/components-prefer-library.test.ts +75 -28
  44. package/src/rules/components-prefer-library.ts +35 -15
  45. package/src/rules/components-shadow-component.test.ts +21 -9
  46. package/src/rules/emit-gate.test.ts +74 -4
  47. package/src/rules/emit-gate.ts +24 -9
  48. package/src/rules/families.test.ts +69 -0
  49. package/src/rules/families.ts +52 -0
  50. package/src/rules/fix-availability.ts +1 -0
  51. package/src/rules/index.ts +18 -2
  52. package/src/rules/jsx-preferred-import-path.ts +29 -11
  53. package/src/rules/rules.test.ts +188 -8
  54. package/src/rules/styles-no-raw-color.ts +13 -4
  55. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  56. package/src/rules/styles-no-raw-spacing.ts +12 -4
  57. package/src/rules/styles-no-raw-typography.ts +13 -4
  58. package/src/rules/tiers.ts +1 -0
  59. package/src/rules/utils.ts +39 -0
  60. package/src/tokens/design-token-parser.test.ts +131 -0
  61. package/src/tokens/design-token-parser.ts +362 -49
  62. package/src/types.ts +2 -2
  63. package/dist/chunk-BAHCOAVG.js.map +0 -1
  64. package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
@@ -14,6 +14,7 @@
14
14
 
15
15
  import type { GovernanceSeverity } from "../governance.js";
16
16
  import type { IdentityConfidence, IdentityState } from "../identity/classify.js";
17
+ import type { PolicyExclude } from "../policy-exclude.js";
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Branded IDs
@@ -121,34 +122,42 @@ export interface ScaleValueFact extends BaseFact {
121
122
  value: number;
122
123
  }
123
124
 
124
- export interface StyleRawColorForbiddenFact extends BaseFact {
125
+ /**
126
+ * Path excludes carried by a compiled policy fact. Present only when the authoring
127
+ * record declared them, so fact shapes are byte-identical for configs without
128
+ * excludes. Never part of the fact's `factId` inputs: scoping a policy off a path
129
+ * must not move fact ids or the finding fingerprints Cloud and baselines dedupe on.
130
+ */
131
+ type PolicyFactExcludes = { exclude?: PolicyExclude[] };
132
+
133
+ export interface StyleRawColorForbiddenFact extends BaseFact, PolicyFactExcludes {
125
134
  kind: "style_raw_color_forbidden";
126
135
  except: string[];
127
136
  prefer: "token" | "css-variable";
128
137
  severity: GovernanceSeverity;
129
138
  }
130
139
 
131
- export interface StyleRawDimensionForbiddenFact extends BaseFact {
140
+ export interface StyleRawDimensionForbiddenFact extends BaseFact, PolicyFactExcludes {
132
141
  kind: "style_raw_dimension_forbidden";
133
142
  appliesTo: string[];
134
143
  prefer: "token" | "css-variable";
135
144
  severity: GovernanceSeverity;
136
145
  }
137
146
 
138
- export interface StylePropertyScaleFact extends BaseFact {
147
+ export interface StylePropertyScaleFact extends BaseFact, PolicyFactExcludes {
139
148
  kind: "style_property_scale";
140
149
  property: string;
141
150
  scale: string;
142
151
  severity: GovernanceSeverity;
143
152
  }
144
153
 
145
- export interface StyleFontSizeScaleFact extends BaseFact {
154
+ export interface StyleFontSizeScaleFact extends BaseFact, PolicyFactExcludes {
146
155
  kind: "style_font_size_scale";
147
156
  scale: string;
148
157
  severity: GovernanceSeverity;
149
158
  }
150
159
 
151
- export interface StyleCssVarsMustBeDefinedFact extends BaseFact {
160
+ export interface StyleCssVarsMustBeDefinedFact extends BaseFact, PolicyFactExcludes {
152
161
  kind: "style_css_vars_must_be_defined";
153
162
  severity: GovernanceSeverity;
154
163
  }
@@ -166,18 +175,18 @@ export interface ContractTokenFact extends BaseFact {
166
175
  name: string;
167
176
  }
168
177
 
169
- export interface JsxUnknownPropsForbiddenFact extends BaseFact {
178
+ export interface JsxUnknownPropsForbiddenFact extends BaseFact, PolicyFactExcludes {
170
179
  kind: "jsx_unknown_props_forbidden";
171
180
  severity: GovernanceSeverity;
172
181
  }
173
182
 
174
- export interface JsxInlineStyleForbiddenRawFact extends BaseFact {
183
+ export interface JsxInlineStyleForbiddenRawFact extends BaseFact, PolicyFactExcludes {
175
184
  kind: "jsx_inline_style_forbidden_raw";
176
185
  property: string;
177
186
  severity: GovernanceSeverity;
178
187
  }
179
188
 
180
- export interface JsxImportPathPreferredFact extends BaseFact {
189
+ export interface JsxImportPathPreferredFact extends BaseFact, PolicyFactExcludes {
181
190
  kind: "jsx_import_path_preferred";
182
191
  from: string;
183
192
  to: string;
@@ -194,7 +203,7 @@ export interface JsxImportPathPreferredFact extends BaseFact {
194
203
  };
195
204
  }
196
205
 
197
- export interface JsxComponentPreferredFact extends BaseFact {
206
+ export interface JsxComponentPreferredFact extends BaseFact, PolicyFactExcludes {
198
207
  kind: "jsx_component_preferred";
199
208
  from: ComponentId;
200
209
  to: ComponentId;
@@ -343,6 +352,9 @@ export interface UsageInlineStyleFact extends BaseFact {
343
352
  */
344
353
  valueKind: "static" | "number" | "css-variable" | "dynamic-raw";
345
354
  value: string;
355
+ /** Set when `value` was resolved through a hop rather than authored inline.
356
+ * Additive — the fact's identity key is unchanged. */
357
+ valueFrom?: StyleValueProvenance;
346
358
  }
347
359
 
348
360
  export interface UsageTextChildFact extends BaseFact {
@@ -352,6 +364,20 @@ export interface UsageTextChildFact extends BaseFact {
352
364
  index: number;
353
365
  }
354
366
 
367
+ /**
368
+ * Framework-neutral summary of the rendered child content for one usage node.
369
+ *
370
+ * An adapter that supports semantic child analysis preserves its existing prop
371
+ * facts and additionally emits exactly one summary per usage node, so rules do
372
+ * not have to interpret React's `children`, Vue slots, or Svelte snippets
373
+ * themselves. Adapters may add support independently.
374
+ */
375
+ export interface UsageChildContentFact extends BaseFact {
376
+ kind: "usage_child_content";
377
+ nodeId: FactId;
378
+ content: "static-text" | "runtime" | "structural" | "empty";
379
+ }
380
+
355
381
  // ---------------------------------------------------------------------------
356
382
  // Tailwind className facts.
357
383
  //
@@ -511,6 +537,27 @@ export interface TailwindTokenResolvedFact extends BaseFact {
511
537
  * selector chain. Identity is `{ file, selector, declarationPath, property }`
512
538
  * so the ID stays stable when other lines move.
513
539
  */
540
+ /**
541
+ * How a style value reached the fact when the authored text at the use site was
542
+ * not the value itself. Present only for `const-binding` today: the extractor
543
+ * followed exactly one hop to a same-file, immutable, statically-initialized
544
+ * `const` (`const c = '#FFC107'; sx={{ color: c }}`).
545
+ *
546
+ * Two consequences for rules, both load-bearing:
547
+ * - The declaration line is the evidence a reader needs — the use site only
548
+ * shows an identifier.
549
+ * - **No deterministic fix may be attached.** The authored text at the finding's
550
+ * location is the identifier, so any value replacement would be an edit the
551
+ * user never wrote.
552
+ */
553
+ export interface StyleValueProvenance {
554
+ kind: "const-binding";
555
+ /** Identifier the use site referenced. */
556
+ name: string;
557
+ /** Where the binding was declared. */
558
+ location: FactLocation;
559
+ }
560
+
514
561
  export interface StyleDeclarationFact extends BaseFact {
515
562
  kind: "style_declaration";
516
563
  file: string;
@@ -527,6 +574,9 @@ export interface StyleDeclarationFact extends BaseFact {
527
574
  * flagging ad hoc custom properties in product code.
528
575
  */
529
576
  declaredTokenSource?: boolean;
577
+ /** Set when `value` was resolved through a hop rather than authored inline.
578
+ * Additive — the fact's identity key is unchanged. */
579
+ valueFrom?: StyleValueProvenance;
530
580
  }
531
581
 
532
582
  export type UnsupportedStyleReason =
@@ -686,6 +736,7 @@ export type UsageFact =
686
736
  | UsagePropResolvedFact
687
737
  | UsageInlineStyleFact
688
738
  | UsageTextChildFact
739
+ | UsageChildContentFact
689
740
  | StyleDeclarationFact
690
741
  | StyleUnsupportedFact
691
742
  | ClassNameLiteralFact
@@ -1,7 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import type { GovernanceConfig } from "./governance.js";
3
+ import type { GovernanceConfig, GovernanceSeverity } from "./governance.js";
4
4
  import {
5
+ collidingRecordDiagnostic,
6
+ collidingRecordDiagnostics,
7
+ configRecordShape,
8
+ overriddenRecordSeverityDiagnostic,
5
9
  detectOrphanGovernanceScales,
6
10
  detectUnconsumedConfigKeys,
7
11
  evaluateGovernanceIntegrity,
@@ -9,6 +13,12 @@ import {
9
13
  isEffectiveCanonicalSource,
10
14
  type GovernanceIntegrityInput,
11
15
  } from "./governance-integrity.js";
16
+ import {
17
+ makeStyleFontSizeScaleFact,
18
+ makeStylePropertyScaleFact,
19
+ makeStyleRawColorForbiddenFact,
20
+ makeTokenDefinitionFact,
21
+ } from "./facts/index.js";
12
22
  import { customerDefaultRuleStates } from "./rules/presets.js";
13
23
  import { fragmentsConfigSchema } from "./schema.js";
14
24
 
@@ -80,7 +90,7 @@ describe("inert config diagnostics", () => {
80
90
  space: { kind: "scale", unit: "px", values: [0, 4, 8], source: "legacy" },
81
91
  },
82
92
  rules: {
83
- "styles/no-raw-color": { enabled: true, exclude: ["vendor/**"] },
93
+ "styles/no-raw-color": { enabled: true, exclude: ["vendor/**"], scope: "app" },
84
94
  "styles/not-a-real-rule": { enabled: true },
85
95
  },
86
96
  },
@@ -88,7 +98,7 @@ describe("inert config diagnostics", () => {
88
98
  const parsed = fragmentsConfigSchema.parse(authored);
89
99
 
90
100
  expect(detectUnconsumedConfigKeys(authored, parsed)).toMatchObject([
91
- { code: "FUI9004", path: "govern.rules.styles/no-raw-color.exclude" },
101
+ { code: "FUI9004", path: "govern.rules.styles/no-raw-color.scope" },
92
102
  { code: "FUI9004", path: "govern.rules.styles/not-a-real-rule" },
93
103
  { code: "FUI9004", path: "govern.scales.space.source" },
94
104
  { code: "FUI9004", path: "screenshots.renderer" },
@@ -97,6 +107,17 @@ describe("inert config diagnostics", () => {
97
107
  ]);
98
108
  });
99
109
 
110
+ it("does not diagnose a rule-keyed exclude, which the scan consumes", () => {
111
+ const authored = {
112
+ govern: {
113
+ rules: { "styles/no-raw-color": { exclude: ["src/legacy/vendor/**"] } },
114
+ },
115
+ };
116
+ const parsed = fragmentsConfigSchema.parse(authored);
117
+
118
+ expect(detectUnconsumedConfigKeys(authored, parsed)).toEqual([]);
119
+ });
120
+
100
121
  it("reports an authored spacing scale that no effective property policy references", () => {
101
122
  const declared: GovernanceConfig = {
102
123
  scales: {
@@ -145,6 +166,156 @@ describe("inert config diagnostics", () => {
145
166
 
146
167
  expect(detectOrphanGovernanceScales(policy, policy)).toEqual([]);
147
168
  });
169
+
170
+ it("accepts govern.ci.failOnInert as a consumed key", () => {
171
+ const authored = { govern: { ci: { failOnInert: true } } };
172
+
173
+ expect(detectUnconsumedConfigKeys(authored, fragmentsConfigSchema.parse(authored))).toEqual([]);
174
+ });
175
+ });
176
+
177
+ describe("collidingRecordDiagnostic", () => {
178
+ const scaleFact = (property: string, scale: string, severity: GovernanceSeverity) =>
179
+ makeStylePropertyScaleFact({ property, scale, severity });
180
+
181
+ it("names the enforced and dropped settings of a colliding config record", () => {
182
+ const kept = scaleFact("padding", "space", "warn");
183
+ const skipped = scaleFact("padding", "myScale", "error");
184
+
185
+ expect(collidingRecordDiagnostic({ kept, skipped })).toEqual({
186
+ code: "FUI9007",
187
+ kind: "colliding-record",
188
+ severity: "warn",
189
+ path: "govern.styles[style.rawSpacing.mustMatchScale property=padding]",
190
+ message: expect.stringContaining("scale=space") as unknown as string,
191
+ });
192
+ expect(collidingRecordDiagnostic({ kept, skipped })?.message).toContain("scale=myScale");
193
+ });
194
+
195
+ it("names the colliding record kind for singleton records", () => {
196
+ expect(
197
+ collidingRecordDiagnostic({
198
+ kept: makeStyleRawColorForbiddenFact({ except: [], prefer: "token", severity: "warn" }),
199
+ skipped: makeStyleRawColorForbiddenFact({
200
+ except: [],
201
+ prefer: "css-variable",
202
+ severity: "error",
203
+ }),
204
+ })?.path
205
+ ).toBe("govern.styles[style.rawColors.forbid]");
206
+ });
207
+
208
+ // Two singleton-keyed kinds live in one section. If their diagnostics share a path,
209
+ // the (code, path) dedupe every consumer runs discards one collision entirely — the
210
+ // silent drop this code exists to end, reintroduced one layer up.
211
+ it("gives two different singleton kinds in one section distinct identities", () => {
212
+ const rawColor = collidingRecordDiagnostic({
213
+ kept: makeStyleRawColorForbiddenFact({ except: [], prefer: "token", severity: "warn" }),
214
+ skipped: makeStyleRawColorForbiddenFact({
215
+ except: [],
216
+ prefer: "css-variable",
217
+ severity: "error",
218
+ }),
219
+ });
220
+ const fontSize = collidingRecordDiagnostic({
221
+ kept: makeStyleFontSizeScaleFact({ scale: "type", severity: "warn" }),
222
+ skipped: makeStyleFontSizeScaleFact({ scale: "legacyType", severity: "error" }),
223
+ });
224
+
225
+ expect(rawColor?.path).not.toBe(fontSize?.path);
226
+ expect(fontSize?.path).toBe("govern.styles[style.fontSize.mustMatchScale]");
227
+ expect(fontSize?.message).toContain("style.fontSize.mustMatchScale");
228
+ });
229
+
230
+ it("leaves token-channel collisions to the token diagnostics, not config truth", () => {
231
+ const token = (value: string) =>
232
+ makeTokenDefinitionFact({
233
+ name: "--fui-color-accent",
234
+ value,
235
+ location: { file: "tokens/base.css", line: 1, column: 1 },
236
+ });
237
+
238
+ expect(collidingRecordDiagnostic({ kept: token("#000"), skipped: token("#fff") })).toBeNull();
239
+ });
240
+
241
+ it("ignores collisions between different fact kinds", () => {
242
+ expect(
243
+ collidingRecordDiagnostic({
244
+ kept: scaleFact("padding", "space", "warn"),
245
+ skipped: makeStyleFontSizeScaleFact({ scale: "type", severity: "warn" }),
246
+ })
247
+ ).toBeNull();
248
+ });
249
+
250
+ // Three records on one fact id produce TWO conflicts that render the same path. Emitted
251
+ // separately, the (code, path) dedupe every consumer runs kept only the last pair and
252
+ // the intermediate record vanished — the silence this code exists to end, one layer up.
253
+ it("aggregates a three-way collision into one diagnostic that names every drop", () => {
254
+ const diagnostics = collidingRecordDiagnostics([
255
+ {
256
+ kept: scaleFact("padding", "space", "warn"),
257
+ skipped: scaleFact("padding", "myScale", "error"),
258
+ },
259
+ {
260
+ kept: scaleFact("padding", "space", "warn"),
261
+ skipped: scaleFact("padding", "legacy", "info"),
262
+ },
263
+ ]);
264
+
265
+ expect(diagnostics).toHaveLength(1);
266
+ expect(diagnostics[0]?.path).toBe(
267
+ "govern.styles[style.rawSpacing.mustMatchScale property=padding]"
268
+ );
269
+ expect(diagnostics[0]?.message).toContain("has 3 `style.rawSpacing.mustMatchScale` records");
270
+ expect(diagnostics[0]?.message).toContain("scale=myScale");
271
+ expect(diagnostics[0]?.message).toContain("scale=legacy");
272
+ });
273
+
274
+ it("keeps distinct fact ids in distinct diagnostics", () => {
275
+ expect(
276
+ collidingRecordDiagnostics([
277
+ {
278
+ kept: scaleFact("padding", "space", "warn"),
279
+ skipped: scaleFact("padding", "myScale", "error"),
280
+ },
281
+ { kept: scaleFact("gap", "space", "warn"), skipped: scaleFact("gap", "myScale", "error") },
282
+ ]).map((diagnostic) => diagnostic.path)
283
+ ).toEqual([
284
+ "govern.styles[style.rawSpacing.mustMatchScale property=padding]",
285
+ "govern.styles[style.rawSpacing.mustMatchScale property=gap]",
286
+ ]);
287
+ });
288
+ });
289
+
290
+ describe("overriddenRecordSeverityDiagnostic", () => {
291
+ it("names the record, the override that outranked it, and both severities", () => {
292
+ expect(
293
+ overriddenRecordSeverityDiagnostic({
294
+ ruleId: "styles/no-raw-color",
295
+ source: "govern.rules[tokens/hardcoded-values]",
296
+ section: "govern.styles",
297
+ record: "style.rawColors.forbid",
298
+ authored: "serious",
299
+ enforced: "moderate",
300
+ })
301
+ ).toEqual({
302
+ code: "FUI9008",
303
+ kind: "overridden-record-severity",
304
+ severity: "warn",
305
+ path: "govern.styles[style.rawColors.forbid]",
306
+ message: expect.stringContaining(
307
+ "govern.rules[tokens/hardcoded-values]"
308
+ ) as unknown as string,
309
+ });
310
+ });
311
+
312
+ it("reads the authoring record off the one config-record table", () => {
313
+ expect(configRecordShape("style_raw_color_forbidden")).toEqual({
314
+ section: "govern.styles",
315
+ record: "style.rawColors.forbid",
316
+ });
317
+ expect(configRecordShape("token_definition")).toBeNull();
318
+ });
148
319
  });
149
320
 
150
321
  describe("evaluateGovernanceIntegrity", () => {
@@ -172,7 +343,10 @@ describe("evaluateGovernanceIntegrity", () => {
172
343
  canonicalSources: [{ kind: "directory", path: "ui" }],
173
344
  };
174
345
  const verdict = evaluate({ policy, policySource: "config", declared: true });
175
- expect(verdict.families.find((f) => f.id === "components")?.armed).toBe(true);
346
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
347
+ armed: true,
348
+ rules: ["components/prefer-library", "components/shadow-component"],
349
+ });
176
350
  expect(verdict.status).toBe("healthy");
177
351
  expect(verdict.blockingCapable).toBe(false);
178
352
  expect(verdict.armed).toContain("components");
@@ -201,6 +375,83 @@ describe("evaluateGovernanceIntegrity", () => {
201
375
  expect(verdict.blockingCapable).toBe(true);
202
376
  });
203
377
 
378
+ it("4c. prefer-library disabled + canonical source leaves shadow governance armed", () => {
379
+ const policy: GovernanceConfig = {
380
+ severity: "error",
381
+ canonicalSources: [{ kind: "directory", path: "ui" }],
382
+ rules: { "components/prefer-library": false },
383
+ };
384
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
385
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
386
+ armed: true,
387
+ rules: ["components/shadow-component"],
388
+ });
389
+ expect(verdict.families.find((f) => f.id === "blocking-deny")?.rules).toEqual([
390
+ "components/shadow-component",
391
+ ]);
392
+ expect(verdict.status).toBe("healthy");
393
+ expect(verdict.blockingCapable).toBe(true);
394
+ });
395
+
396
+ it("4d. shadow-component disabled + canonical source leaves prefer-library armed", () => {
397
+ const policy: GovernanceConfig = {
398
+ severity: "error",
399
+ canonicalSources: [{ kind: "directory", path: "ui" }],
400
+ rules: { "components/shadow-component": false },
401
+ };
402
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
403
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
404
+ armed: true,
405
+ rules: ["components/prefer-library"],
406
+ });
407
+ expect(verdict.families.find((f) => f.id === "blocking-deny")?.rules).toEqual([
408
+ "components/prefer-library",
409
+ ]);
410
+ });
411
+
412
+ it("4e. explicitly disabling both canonical-component rules leaves the policy inert", () => {
413
+ const policy: GovernanceConfig = {
414
+ severity: "error",
415
+ canonicalSources: [{ kind: "directory", path: "ui" }],
416
+ rules: {
417
+ "components/prefer-library": false,
418
+ "components/shadow-component": false,
419
+ },
420
+ };
421
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
422
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
423
+ armed: false,
424
+ rules: [],
425
+ reason: "canonical-component rules not enabled",
426
+ });
427
+ expect(verdict.status).toBe("inert");
428
+ expect(verdict.blockingCapable).toBe(false);
429
+ });
430
+
431
+ it("4f. vocabulary on a disabled sibling rule cannot arm shadow governance", () => {
432
+ const policy: GovernanceConfig = {
433
+ rules: {
434
+ "components/prefer-library": {
435
+ enabled: false,
436
+ options: {
437
+ canonicalMappings: [
438
+ { name: "Button", htmlEquivalent: "button", importPath: "@acme/ui" },
439
+ ],
440
+ },
441
+ },
442
+ "components/shadow-component": { enabled: true, severity: "error" },
443
+ },
444
+ };
445
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
446
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
447
+ armed: false,
448
+ rules: ["components/shadow-component"],
449
+ reason: "component governance enabled but no effective canonical source",
450
+ });
451
+ expect(verdict.status).toBe("inert");
452
+ expect(verdict.blockingCapable).toBe(false);
453
+ });
454
+
204
455
  it("5. npm source with NO include → components NOT armed → inert (blocker-#2 lock)", () => {
205
456
  const policy: GovernanceConfig = {
206
457
  canonicalSources: [{ kind: "npm", specifier: "@acme/ui" }],
@@ -274,6 +525,23 @@ describe("evaluateGovernanceIntegrity", () => {
274
525
  expect(tokens?.reason).toBe("no token vocabulary");
275
526
  });
276
527
 
528
+ it("7c. error-level token drift with a vocabulary is blocking-capable", () => {
529
+ const policy: GovernanceConfig = {
530
+ styles: [{ kind: "style.cssVars.mustBeDefined", severity: "error" }],
531
+ };
532
+ const verdict = evaluate({
533
+ policy,
534
+ policySource: "config",
535
+ declared: true,
536
+ cssVarsActive: true,
537
+ tokenVocabularySize: 5,
538
+ });
539
+ expect(verdict.families.find((family) => family.id === "blocking-deny")?.rules).toContain(
540
+ "tokens/css-vars-must-be-defined"
541
+ );
542
+ expect(verdict.blockingCapable).toBe(true);
543
+ });
544
+
277
545
  it("8. hygiene-only policy (raw-style rule enabled, no contract) → degraded", () => {
278
546
  const policy: GovernanceConfig = {
279
547
  rules: { "styles/no-raw-color": { enabled: true, severity: "warn" } },