@usefragments/core 1.5.1 → 1.6.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-AOG4FTV6.js → chunk-WVFNDPM4.js} +448 -190
- package/dist/chunk-WVFNDPM4.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-B88uR3Zq.d.ts → governance-DxFipN5V.d.ts} +654 -22
- package/dist/index.d.ts +678 -40
- package/dist/index.js +534 -38
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/policy-exclude.test.ts +180 -0
- 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 -1
- package/src/codes/codes.ts +60 -0
- package/src/config.ts +20 -0
- package/src/facts/builders.ts +35 -0
- package/src/facts/compile.ts +135 -21
- package/src/facts/fact-index.ts +22 -2
- package/src/facts/facts.test.ts +19 -19
- package/src/facts/index.ts +9 -6
- package/src/facts/types.ts +45 -9
- package/src/governance-integrity.test.ts +277 -1
- package/src/governance-integrity.ts +616 -0
- package/src/governance.test.ts +20 -1
- package/src/governance.ts +131 -0
- package/src/index.ts +45 -2
- package/src/policy-exclude.ts +113 -0
- package/src/rules/families.test.ts +69 -0
- package/src/rules/families.ts +52 -0
- package/src/rules/index.ts +6 -0
- package/src/rules/jsx-preferred-import-path.ts +29 -11
- package/src/rules/rules.test.ts +125 -1
- 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.test.ts +48 -0
- package/src/rules/styles-no-raw-spacing.ts +15 -7
- package/src/rules/styles-no-raw-typography.ts +13 -4
- package/src/rules/utils.ts +39 -0
- package/src/types.ts +21 -3
- package/dist/chunk-AOG4FTV6.js.map +0 -1
|
@@ -18,12 +18,41 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { CanonicalSource, GovernanceConfig, GovernanceSeverity } from "./governance.js";
|
|
21
|
+
import {
|
|
22
|
+
normalizePolicyExcludes,
|
|
23
|
+
policyExcludeMatchesPath,
|
|
24
|
+
type PolicyExclude,
|
|
25
|
+
} from "./policy-exclude.js";
|
|
21
26
|
import { compileGlobalGovernanceFacts } from "./facts/index.js";
|
|
27
|
+
import type { FactConflict } from "./facts/index.js";
|
|
28
|
+
import { RULE_FAMILY_IDS } from "./rules/families.js";
|
|
22
29
|
import { FRAGMENTS_INTERNAL_RULE_IDS, RULE_TIER } from "./rules/tiers.js";
|
|
23
30
|
import { BLOCKING_RULE_ALLOWLIST } from "./rules/emit-gate.js";
|
|
24
31
|
|
|
25
32
|
export type GovernanceIntegrityStatus = "healthy" | "degraded" | "inert";
|
|
26
33
|
|
|
34
|
+
export type InertConfigDiagnosticKind =
|
|
35
|
+
| "orphan-scale"
|
|
36
|
+
| "unconsumed-key"
|
|
37
|
+
| "unmatched-exclude"
|
|
38
|
+
| "colliding-record"
|
|
39
|
+
| "overridden-record-severity";
|
|
40
|
+
export type InertConfigDiagnosticCode = "FUI9004" | "FUI9005" | "FUI9006" | "FUI9007" | "FUI9008";
|
|
41
|
+
|
|
42
|
+
export interface InertConfigDiagnostic {
|
|
43
|
+
code: InertConfigDiagnosticCode;
|
|
44
|
+
kind: InertConfigDiagnosticKind;
|
|
45
|
+
severity: "warn";
|
|
46
|
+
path: string;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GovernanceIntegrityRoster {
|
|
51
|
+
configured: number;
|
|
52
|
+
active: number;
|
|
53
|
+
inert: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
27
56
|
export type GovernanceIntegrityFamilyId =
|
|
28
57
|
| "policy"
|
|
29
58
|
| "components"
|
|
@@ -53,6 +82,8 @@ export interface GovernanceIntegrityInput {
|
|
|
53
82
|
/** Whether `tokens/css-vars-must-be-defined` is activated (caller supplies). */
|
|
54
83
|
cssVarsActive?: boolean;
|
|
55
84
|
mode?: "scan" | "ci" | "hook" | "doctor" | "setup";
|
|
85
|
+
/** Named, verdict-neutral diagnostics derived from the authored config. */
|
|
86
|
+
configDiagnostics?: readonly InertConfigDiagnostic[];
|
|
56
87
|
}
|
|
57
88
|
|
|
58
89
|
export interface GovernanceIntegrityVerdict {
|
|
@@ -64,6 +95,8 @@ export interface GovernanceIntegrityVerdict {
|
|
|
64
95
|
armed: GovernanceIntegrityFamilyId[];
|
|
65
96
|
summary: string;
|
|
66
97
|
remediations: string[];
|
|
98
|
+
configDiagnostics?: InertConfigDiagnostic[];
|
|
99
|
+
roster?: GovernanceIntegrityRoster;
|
|
67
100
|
}
|
|
68
101
|
|
|
69
102
|
interface EffectiveRuleConfig {
|
|
@@ -131,6 +164,575 @@ function dedupe(values: string[]): string[] {
|
|
|
131
164
|
return [...new Set(values)];
|
|
132
165
|
}
|
|
133
166
|
|
|
167
|
+
const CONSUMED_RULE_IDS = new Set(Object.keys(RULE_TIER));
|
|
168
|
+
const RECOGNIZED_RULE_IDS = new Set([...CONSUMED_RULE_IDS, ...RULE_FAMILY_IDS]);
|
|
169
|
+
// `exclude` is consumed by the scan's finding-override pass (it scopes the rule off
|
|
170
|
+
// the matching paths and reports each drop in the ignored accounting). It was absent
|
|
171
|
+
// from this allow-set, so a working key was diagnosed as inert — the diagnostic was
|
|
172
|
+
// wrong, not the config.
|
|
173
|
+
const RULE_CONFIG_KEYS = new Set(["enabled", "severity", "options", "exclude"]);
|
|
174
|
+
|
|
175
|
+
const PASSTHROUGH_KEYS = [
|
|
176
|
+
{
|
|
177
|
+
path: ["tokens"],
|
|
178
|
+
keys: [
|
|
179
|
+
"include",
|
|
180
|
+
"sources",
|
|
181
|
+
"packages",
|
|
182
|
+
"aliases",
|
|
183
|
+
"upstream",
|
|
184
|
+
"exclude",
|
|
185
|
+
"themeSelectors",
|
|
186
|
+
"enabled",
|
|
187
|
+
"format",
|
|
188
|
+
"namespace",
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
path: ["screenshots"],
|
|
193
|
+
keys: ["viewport", "threshold", "delay", "outputDir", "themes"],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
path: ["service"],
|
|
197
|
+
keys: ["poolSize", "idleTimeout"],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
path: ["registry"],
|
|
201
|
+
keys: ["requireStory", "publicOnly", "categoryDepth", "includeProps", "embedFragments"],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
path: ["govern", "tailwind"],
|
|
205
|
+
keys: ["palette"],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
path: ["govern", "agent"],
|
|
209
|
+
keys: ["repairOrder"],
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
path: ["govern", "ci"],
|
|
213
|
+
keys: ["failOnWarnings", "failOnInert"],
|
|
214
|
+
},
|
|
215
|
+
] as const;
|
|
216
|
+
|
|
217
|
+
function objectRecord(value: unknown): Record<string, unknown> | undefined {
|
|
218
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
219
|
+
return value as Record<string, unknown>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function valueAtPath(value: unknown, path: readonly string[]): unknown {
|
|
223
|
+
let current = value;
|
|
224
|
+
for (const segment of path) {
|
|
225
|
+
const record = objectRecord(current);
|
|
226
|
+
if (!record) return undefined;
|
|
227
|
+
current = record[segment];
|
|
228
|
+
}
|
|
229
|
+
return current;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function configPath(path: readonly (string | number)[]): string {
|
|
233
|
+
return path.reduce<string>(
|
|
234
|
+
(output, segment) =>
|
|
235
|
+
typeof segment === "number"
|
|
236
|
+
? `${output}[${segment}]`
|
|
237
|
+
: output
|
|
238
|
+
? `${output}.${segment}`
|
|
239
|
+
: segment,
|
|
240
|
+
""
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function unconsumedKeyDiagnostic(path: readonly (string | number)[]): InertConfigDiagnostic {
|
|
245
|
+
const renderedPath = configPath(path);
|
|
246
|
+
return {
|
|
247
|
+
code: "FUI9004",
|
|
248
|
+
kind: "unconsumed-key",
|
|
249
|
+
severity: "warn",
|
|
250
|
+
path: renderedPath,
|
|
251
|
+
message: `${renderedPath} is not consumed by Fragments and has no effect. Remove it or use a supported config key.`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Config-record fact kinds: the policy facts that a `govern.*` record compiles into,
|
|
257
|
+
* paired with the config section that authored them, the authored record that produced
|
|
258
|
+
* them, and the identity keys their fact id is built from.
|
|
259
|
+
*
|
|
260
|
+
* `record` is load-bearing, not decoration. Several kinds in one section key on nothing
|
|
261
|
+
* (`style.rawColors.forbid`, `style.cssVars.mustBeDefined`, …) or on the same fields
|
|
262
|
+
* (`jsx.importPath.prefer` and `jsx.component.prefer` both on from/to), so a
|
|
263
|
+
* section-only diagnostic path makes two DIFFERENT collisions look like one — and the
|
|
264
|
+
* (code, path) dedupe every consumer runs then discards all but the last, which is the
|
|
265
|
+
* silence this diagnostic exists to end.
|
|
266
|
+
*
|
|
267
|
+
* Deliberately NOT the whole `PolicyFact` union — `token_definition` and
|
|
268
|
+
* `contract_token` are compiled from token *files*, not authored config records, and
|
|
269
|
+
* their collisions belong to the token-catalog diagnostics channel. Naming a duplicate
|
|
270
|
+
* token as a config defect would misattribute it and drown the real signal.
|
|
271
|
+
*/
|
|
272
|
+
const CONFIG_RECORD_FACT_KINDS: Readonly<
|
|
273
|
+
Record<string, { section: string; record: string; keys: string[] }>
|
|
274
|
+
> = {
|
|
275
|
+
scale: { section: "govern.scales", record: "scale", keys: ["name"] },
|
|
276
|
+
scale_value: { section: "govern.scales", record: "scale.values", keys: ["scale", "value"] },
|
|
277
|
+
style_raw_color_forbidden: {
|
|
278
|
+
section: "govern.styles",
|
|
279
|
+
record: "style.rawColors.forbid",
|
|
280
|
+
keys: [],
|
|
281
|
+
},
|
|
282
|
+
style_raw_dimension_forbidden: {
|
|
283
|
+
section: "govern.styles",
|
|
284
|
+
record: "style.rawDimensions.forbid",
|
|
285
|
+
keys: [],
|
|
286
|
+
},
|
|
287
|
+
style_property_scale: {
|
|
288
|
+
section: "govern.styles",
|
|
289
|
+
record: "style.rawSpacing.mustMatchScale",
|
|
290
|
+
keys: ["property"],
|
|
291
|
+
},
|
|
292
|
+
style_font_size_scale: {
|
|
293
|
+
section: "govern.styles",
|
|
294
|
+
record: "style.fontSize.mustMatchScale",
|
|
295
|
+
keys: [],
|
|
296
|
+
},
|
|
297
|
+
style_css_vars_must_be_defined: {
|
|
298
|
+
section: "govern.styles",
|
|
299
|
+
record: "style.cssVars.mustBeDefined",
|
|
300
|
+
keys: [],
|
|
301
|
+
},
|
|
302
|
+
jsx_unknown_props_forbidden: {
|
|
303
|
+
section: "govern.jsx",
|
|
304
|
+
record: "jsx.unknownProps.forbid",
|
|
305
|
+
keys: [],
|
|
306
|
+
},
|
|
307
|
+
jsx_inline_style_forbidden_raw: {
|
|
308
|
+
section: "govern.jsx",
|
|
309
|
+
record: "jsx.inlineStyle.forbidRaw",
|
|
310
|
+
keys: ["property"],
|
|
311
|
+
},
|
|
312
|
+
jsx_import_path_preferred: {
|
|
313
|
+
section: "govern.jsx",
|
|
314
|
+
record: "jsx.importPath.prefer",
|
|
315
|
+
keys: ["from", "to", "imported"],
|
|
316
|
+
},
|
|
317
|
+
jsx_component_preferred: {
|
|
318
|
+
section: "govern.jsx",
|
|
319
|
+
record: "jsx.component.prefer",
|
|
320
|
+
keys: ["from", "to"],
|
|
321
|
+
},
|
|
322
|
+
prop_value_avoided: {
|
|
323
|
+
section: "govern.components",
|
|
324
|
+
record: "prop.value.avoid",
|
|
325
|
+
keys: ["componentId", "prop", "value"],
|
|
326
|
+
},
|
|
327
|
+
prop_value_forbidden: {
|
|
328
|
+
section: "govern.components",
|
|
329
|
+
record: "prop.value.forbid",
|
|
330
|
+
keys: ["componentId", "prop", "value", "pathPattern"],
|
|
331
|
+
},
|
|
332
|
+
a11y_name_required: {
|
|
333
|
+
section: "govern.components",
|
|
334
|
+
record: "a11y.requireName",
|
|
335
|
+
keys: ["componentId"],
|
|
336
|
+
},
|
|
337
|
+
tailwind_palette_allow: {
|
|
338
|
+
section: "govern.tailwind",
|
|
339
|
+
record: "palette.allow",
|
|
340
|
+
keys: [],
|
|
341
|
+
},
|
|
342
|
+
tailwind_palette_deny: { section: "govern.tailwind", record: "palette.deny", keys: [] },
|
|
343
|
+
tailwind_unknown_class_enabled: {
|
|
344
|
+
section: "govern.tailwind",
|
|
345
|
+
record: "unknownClass",
|
|
346
|
+
keys: [],
|
|
347
|
+
},
|
|
348
|
+
governance_rule_config: { section: "govern.rules", record: "rule", keys: ["ruleId"] },
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** The authored settings a reader needs to see to tell two colliding records apart. */
|
|
352
|
+
const COLLISION_DETAIL_KEYS = [
|
|
353
|
+
"severity",
|
|
354
|
+
"scale",
|
|
355
|
+
"prefer",
|
|
356
|
+
"enabled",
|
|
357
|
+
"appliesTo",
|
|
358
|
+
"except",
|
|
359
|
+
"properties",
|
|
360
|
+
"because",
|
|
361
|
+
] as const;
|
|
362
|
+
|
|
363
|
+
function renderFactFields(fact: Record<string, unknown>, keys: readonly string[]): string {
|
|
364
|
+
return keys
|
|
365
|
+
.filter((key) => fact[key] !== undefined)
|
|
366
|
+
.map((key) => `${key}=${Array.isArray(fact[key]) ? JSON.stringify(fact[key]) : fact[key]}`)
|
|
367
|
+
.join(" ");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Name a config record that the fact index dropped as a first-wins duplicate.
|
|
372
|
+
*
|
|
373
|
+
* This is the Layer-6 backstop, not the primary mechanism: merge-time displacement
|
|
374
|
+
* (`mergeGovernanceConfigs`) already makes the later record win for every shape it can
|
|
375
|
+
* key. What reaches here is a collision displacement could NOT resolve — two records
|
|
376
|
+
* that compile to one fact id from different channels (config vs bridge vs Cloud
|
|
377
|
+
* policy). The invariant it defends: a dropped policy fact is never silent.
|
|
378
|
+
*
|
|
379
|
+
* Returns `null` for non-config facts, so usage/token collisions stay on the internal
|
|
380
|
+
* debug channel.
|
|
381
|
+
*/
|
|
382
|
+
export function collidingRecordDiagnostic(conflict: FactConflict): InertConfigDiagnostic | null {
|
|
383
|
+
return collidingRecordDiagnostics([conflict])[0] ?? null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Aggregate every observed collision into one diagnostic per colliding fact id.
|
|
388
|
+
*
|
|
389
|
+
* Three same-kind records on one fact id produce TWO conflicts (first-wins keeps the
|
|
390
|
+
* first and skips both others), and both render the same path. Emitting them separately
|
|
391
|
+
* meant the `(code, path)` dedupe every consumer runs kept only the last pair, so the
|
|
392
|
+
* intermediate record was dropped in silence — the same failure the per-kind path fixed
|
|
393
|
+
* one layer down. Aggregating names the count and lists every dropped setting, and one
|
|
394
|
+
* user edit still resolves the whole group.
|
|
395
|
+
*/
|
|
396
|
+
export function collidingRecordDiagnostics(
|
|
397
|
+
conflicts: readonly FactConflict[]
|
|
398
|
+
): InertConfigDiagnostic[] {
|
|
399
|
+
const groups = new Map<
|
|
400
|
+
string,
|
|
401
|
+
{ record: string; kept: string; dropped: string[]; keptFallback: string }
|
|
402
|
+
>();
|
|
403
|
+
|
|
404
|
+
for (const conflict of conflicts) {
|
|
405
|
+
const shape = CONFIG_RECORD_FACT_KINDS[conflict.kept.kind];
|
|
406
|
+
if (!shape || conflict.kept.kind !== conflict.skipped.kind) continue;
|
|
407
|
+
|
|
408
|
+
const keptFact = conflict.kept as unknown as Record<string, unknown>;
|
|
409
|
+
const skippedFact = conflict.skipped as unknown as Record<string, unknown>;
|
|
410
|
+
const identity = renderFactFields(keptFact, shape.keys);
|
|
411
|
+
// The record kind is part of the identity, not just the prose: two singleton-keyed
|
|
412
|
+
// kinds in one section would otherwise share a path and collapse under the (code,
|
|
413
|
+
// path) dedupe, dropping one collision silently.
|
|
414
|
+
const path = `${shape.section}[${identity ? `${shape.record} ${identity}` : shape.record}]`;
|
|
415
|
+
const group = groups.get(path) ?? {
|
|
416
|
+
record: shape.record,
|
|
417
|
+
kept: renderFactFields(keptFact, COLLISION_DETAIL_KEYS),
|
|
418
|
+
dropped: [],
|
|
419
|
+
keptFallback: "the first record",
|
|
420
|
+
};
|
|
421
|
+
const dropped = renderFactFields(skippedFact, COLLISION_DETAIL_KEYS);
|
|
422
|
+
group.dropped.push(dropped || `record ${group.dropped.length + 2}`);
|
|
423
|
+
groups.set(path, group);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return [...groups].map(([path, group]) => ({
|
|
427
|
+
code: "FUI9007" as const,
|
|
428
|
+
kind: "colliding-record" as const,
|
|
429
|
+
severity: "warn" as const,
|
|
430
|
+
path,
|
|
431
|
+
message:
|
|
432
|
+
`${path} has ${group.dropped.length + 1} \`${group.record}\` records that compile to the same policy fact — ` +
|
|
433
|
+
`enforcing ${group.kept || group.keptFallback} and dropping ${group.dropped.join("; ")}. ` +
|
|
434
|
+
"Author one record for this policy, or scope them apart, so the enforced setting is the one you can read.",
|
|
435
|
+
}));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The authored config record a policy fact was compiled from, or `null` when the fact
|
|
440
|
+
* did not come from a `govern.*` record (token facts, usage facts). One table, read by
|
|
441
|
+
* both the collision diagnostic and the override backstop below.
|
|
442
|
+
*/
|
|
443
|
+
export function configRecordShape(kind: string): { section: string; record: string } | null {
|
|
444
|
+
const shape = CONFIG_RECORD_FACT_KINDS[kind];
|
|
445
|
+
return shape ? { section: shape.section, record: shape.record } : null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Name a `govern.rules` override that outranks the severity a policy record authored.
|
|
450
|
+
*
|
|
451
|
+
* The backstop for the path provenance cannot reach. Locally, presets are merged here,
|
|
452
|
+
* so a preset's broad rule entry is tagged and treated as a default — a user record's
|
|
453
|
+
* `severity: "error"` wins (report #2 B4). A Cloud-served policy arrives whole: no merge
|
|
454
|
+
* ran, nothing is tagged, and a broad `tokens/hardcoded-values` entry in it still
|
|
455
|
+
* displaces the severity a record next to it authored. That cannot be fixed at this
|
|
456
|
+
* boundary, so it is named instead. Silence is the defect; an unfixable case must at
|
|
457
|
+
* least speak.
|
|
458
|
+
*/
|
|
459
|
+
export function overriddenRecordSeverityDiagnostic(input: {
|
|
460
|
+
ruleId: string;
|
|
461
|
+
/** Config path of the override that won — `govern.rules[...]` or `govern.severity`. */
|
|
462
|
+
source: string;
|
|
463
|
+
section: string;
|
|
464
|
+
record: string;
|
|
465
|
+
authored: string;
|
|
466
|
+
enforced: string;
|
|
467
|
+
}): InertConfigDiagnostic {
|
|
468
|
+
const path = `${input.section}[${input.record}]`;
|
|
469
|
+
return {
|
|
470
|
+
code: "FUI9008",
|
|
471
|
+
kind: "overridden-record-severity",
|
|
472
|
+
severity: "warn",
|
|
473
|
+
path,
|
|
474
|
+
message:
|
|
475
|
+
`${path} authored severity=${input.authored}, but ${input.source} enforces ` +
|
|
476
|
+
`severity=${input.enforced} on \`${input.ruleId}\`. The record's severity is not what ` +
|
|
477
|
+
"gates CI — drop the rule override, or author the record at the severity you want enforced.",
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function collectStrippedKeys(
|
|
482
|
+
authored: unknown,
|
|
483
|
+
parsed: unknown,
|
|
484
|
+
path: readonly (string | number)[],
|
|
485
|
+
diagnostics: InertConfigDiagnostic[]
|
|
486
|
+
): void {
|
|
487
|
+
if (Array.isArray(authored)) {
|
|
488
|
+
if (!Array.isArray(parsed)) return;
|
|
489
|
+
authored.forEach((item, index) => {
|
|
490
|
+
collectStrippedKeys(item, parsed[index], [...path, index], diagnostics);
|
|
491
|
+
});
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const authoredRecord = objectRecord(authored);
|
|
496
|
+
const parsedRecord = objectRecord(parsed);
|
|
497
|
+
if (!authoredRecord || !parsedRecord) return;
|
|
498
|
+
|
|
499
|
+
for (const key of Object.keys(authoredRecord).sort()) {
|
|
500
|
+
if (!Object.prototype.hasOwnProperty.call(parsedRecord, key)) {
|
|
501
|
+
diagnostics.push(unconsumedKeyDiagnostic([...path, key]));
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
collectStrippedKeys(authoredRecord[key], parsedRecord[key], [...path, key], diagnostics);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function collectPassthroughKeys(
|
|
509
|
+
authored: unknown,
|
|
510
|
+
path: readonly string[],
|
|
511
|
+
allowed: ReadonlySet<string>,
|
|
512
|
+
diagnostics: InertConfigDiagnostic[]
|
|
513
|
+
): void {
|
|
514
|
+
const record = objectRecord(valueAtPath(authored, path));
|
|
515
|
+
if (!record) return;
|
|
516
|
+
for (const key of Object.keys(record).sort()) {
|
|
517
|
+
if (!allowed.has(key)) diagnostics.push(unconsumedKeyDiagnostic([...path, key]));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function collectRuleConfigKeys(
|
|
522
|
+
authored: unknown,
|
|
523
|
+
path: readonly string[],
|
|
524
|
+
diagnostics: InertConfigDiagnostic[]
|
|
525
|
+
): void {
|
|
526
|
+
const rules = objectRecord(valueAtPath(authored, path));
|
|
527
|
+
if (!rules) return;
|
|
528
|
+
for (const ruleId of Object.keys(rules).sort()) {
|
|
529
|
+
if (!RECOGNIZED_RULE_IDS.has(ruleId)) {
|
|
530
|
+
diagnostics.push(unconsumedKeyDiagnostic([...path, ruleId]));
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const config = objectRecord(rules[ruleId]);
|
|
534
|
+
if (!config) continue;
|
|
535
|
+
for (const key of Object.keys(config).sort()) {
|
|
536
|
+
if (!RULE_CONFIG_KEYS.has(key)) {
|
|
537
|
+
diagnostics.push(unconsumedKeyDiagnostic([...path, ruleId, key]));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function collectPassthroughRecordValues(
|
|
544
|
+
authored: unknown,
|
|
545
|
+
path: readonly string[],
|
|
546
|
+
allowed: ReadonlySet<string>,
|
|
547
|
+
diagnostics: InertConfigDiagnostic[]
|
|
548
|
+
): void {
|
|
549
|
+
const entries = objectRecord(valueAtPath(authored, path));
|
|
550
|
+
if (!entries) return;
|
|
551
|
+
for (const [entryName, value] of Object.entries(entries).sort(([left], [right]) =>
|
|
552
|
+
left.localeCompare(right)
|
|
553
|
+
)) {
|
|
554
|
+
const record = objectRecord(value);
|
|
555
|
+
if (!record) continue;
|
|
556
|
+
for (const key of Object.keys(record).sort()) {
|
|
557
|
+
if (!allowed.has(key)) {
|
|
558
|
+
diagnostics.push(unconsumedKeyDiagnostic([...path, entryName, key]));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function stableConfigDiagnostics(
|
|
565
|
+
diagnostics: readonly InertConfigDiagnostic[]
|
|
566
|
+
): InertConfigDiagnostic[] {
|
|
567
|
+
const unique = new Map<string, InertConfigDiagnostic>();
|
|
568
|
+
for (const diagnostic of diagnostics) {
|
|
569
|
+
unique.set(`${diagnostic.code}\0${diagnostic.path}`, diagnostic);
|
|
570
|
+
}
|
|
571
|
+
return [...unique.values()].sort(
|
|
572
|
+
(left, right) =>
|
|
573
|
+
left.path.localeCompare(right.path) ||
|
|
574
|
+
left.code.localeCompare(right.code) ||
|
|
575
|
+
left.message.localeCompare(right.message)
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Diagnose config keys that the permissive validation boundary accepts but the
|
|
581
|
+
* runtime cannot consume. The parsed config is the source of truth for stripped
|
|
582
|
+
* keys; explicit allow-sets cover intentional `.passthrough()`/record seams.
|
|
583
|
+
*/
|
|
584
|
+
export function detectUnconsumedConfigKeys(
|
|
585
|
+
authoredConfig: unknown,
|
|
586
|
+
parsedConfig: unknown
|
|
587
|
+
): InertConfigDiagnostic[] {
|
|
588
|
+
const diagnostics: InertConfigDiagnostic[] = [];
|
|
589
|
+
collectStrippedKeys(authoredConfig, parsedConfig, [], diagnostics);
|
|
590
|
+
|
|
591
|
+
for (const boundary of PASSTHROUGH_KEYS) {
|
|
592
|
+
collectPassthroughKeys(
|
|
593
|
+
authoredConfig,
|
|
594
|
+
boundary.path,
|
|
595
|
+
new Set<string>(boundary.keys),
|
|
596
|
+
diagnostics
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
collectRuleConfigKeys(authoredConfig, ["govern", "rules"], diagnostics);
|
|
601
|
+
collectPassthroughRecordValues(
|
|
602
|
+
authoredConfig,
|
|
603
|
+
["govern", "agents"],
|
|
604
|
+
new Set(["rules"]),
|
|
605
|
+
diagnostics
|
|
606
|
+
);
|
|
607
|
+
collectPassthroughKeys(authoredConfig, ["govern", "audit"], new Set(), diagnostics);
|
|
608
|
+
collectPassthroughRecordValues(authoredConfig, ["govern", "runners"], new Set(), diagnostics);
|
|
609
|
+
|
|
610
|
+
return stableConfigDiagnostics(diagnostics);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Every authored exclude in a policy, paired with the config path that declared it. */
|
|
614
|
+
function authoredPolicyExcludes(
|
|
615
|
+
policy: GovernanceConfig | undefined
|
|
616
|
+
): Array<{ path: string; scope: string; exclude: PolicyExclude }> {
|
|
617
|
+
const out: Array<{ path: string; scope: string; exclude: PolicyExclude }> = [];
|
|
618
|
+
const collect = (path: string, scope: string, raw: unknown) => {
|
|
619
|
+
for (const exclude of normalizePolicyExcludes(raw) ?? []) {
|
|
620
|
+
out.push({ path, scope, exclude });
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
(policy?.styles ?? []).forEach((record, index) => {
|
|
625
|
+
collect(`govern.styles[${index}].exclude`, record.kind, record.exclude);
|
|
626
|
+
});
|
|
627
|
+
(policy?.jsx ?? []).forEach((record, index) => {
|
|
628
|
+
collect(`govern.jsx[${index}].exclude`, record.kind, record.exclude);
|
|
629
|
+
});
|
|
630
|
+
for (const ruleId of Object.keys(policy?.rules ?? {}).sort()) {
|
|
631
|
+
const record = objectRecord(policy?.rules?.[ruleId]);
|
|
632
|
+
if (!record) continue;
|
|
633
|
+
collect(`govern.rules.${ruleId}.exclude`, ruleId, record.exclude);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Diagnose excludes that scope nothing: the key is consumed and the glob is valid, but
|
|
641
|
+
* it matched no scanned file, so it silently exempts nothing while reading as active
|
|
642
|
+
* policy. That is the same "declared ≠ armed" failure this module exists to name — a
|
|
643
|
+
* stale path after a refactor is the common cause.
|
|
644
|
+
*
|
|
645
|
+
* `scannedFiles` are repo-relative paths supplied by the caller; core never reads the
|
|
646
|
+
* filesystem. An empty scan (nothing to compare against) yields no diagnostics rather
|
|
647
|
+
* than flagging every exclude.
|
|
648
|
+
*/
|
|
649
|
+
export function detectUnmatchedPolicyExcludes(
|
|
650
|
+
policy: GovernanceConfig | undefined,
|
|
651
|
+
scannedFiles: readonly string[]
|
|
652
|
+
): InertConfigDiagnostic[] {
|
|
653
|
+
if (scannedFiles.length === 0) return [];
|
|
654
|
+
const diagnostics: InertConfigDiagnostic[] = [];
|
|
655
|
+
|
|
656
|
+
for (const { path, scope, exclude } of authoredPolicyExcludes(policy)) {
|
|
657
|
+
if (scannedFiles.some((file) => policyExcludeMatchesPath(exclude, file))) continue;
|
|
658
|
+
diagnostics.push({
|
|
659
|
+
code: "FUI9006",
|
|
660
|
+
kind: "unmatched-exclude",
|
|
661
|
+
severity: "warn",
|
|
662
|
+
path,
|
|
663
|
+
message:
|
|
664
|
+
`${path} pattern "${exclude.glob}" matched no scanned file, so ${scope} is not ` +
|
|
665
|
+
"actually scoped by it. Correct the glob to a path this scan covers, or remove it.",
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return stableConfigDiagnostics(diagnostics);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
interface ScaleBinding {
|
|
673
|
+
label: string;
|
|
674
|
+
mechanism: string;
|
|
675
|
+
scale: string;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function effectiveScaleBindings(policy: GovernanceConfig | undefined): ScaleBinding[] {
|
|
679
|
+
const bindings: ScaleBinding[] = [];
|
|
680
|
+
for (const style of policy?.styles ?? []) {
|
|
681
|
+
if (style.kind === "style.rawSpacing.mustMatchScale") {
|
|
682
|
+
bindings.push({
|
|
683
|
+
label: "Spacing properties",
|
|
684
|
+
mechanism: "style.rawSpacing.mustMatchScale",
|
|
685
|
+
scale: style.scale,
|
|
686
|
+
});
|
|
687
|
+
} else if (style.kind === "style.fontSize.mustMatchScale") {
|
|
688
|
+
bindings.push({
|
|
689
|
+
label: "Font-size properties",
|
|
690
|
+
mechanism: "style.fontSize.mustMatchScale",
|
|
691
|
+
scale: style.scale,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return bindings;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Compare locally declared scales with the effective property-policy graph.
|
|
700
|
+
* Preset-only scales are not diagnosed because the user did not declare them.
|
|
701
|
+
*/
|
|
702
|
+
export function detectOrphanGovernanceScales(
|
|
703
|
+
declaredPolicy: GovernanceConfig | undefined,
|
|
704
|
+
effectivePolicy: GovernanceConfig | undefined
|
|
705
|
+
): InertConfigDiagnostic[] {
|
|
706
|
+
const bindings = effectiveScaleBindings(effectivePolicy);
|
|
707
|
+
const referenced = new Set(bindings.map((binding) => binding.scale));
|
|
708
|
+
const diagnostics: InertConfigDiagnostic[] = [];
|
|
709
|
+
|
|
710
|
+
for (const scale of Object.keys(declaredPolicy?.scales ?? {}).sort()) {
|
|
711
|
+
if (referenced.has(scale)) continue;
|
|
712
|
+
const path = `govern.scales.${scale}`;
|
|
713
|
+
const preferred =
|
|
714
|
+
(scale.toLowerCase().includes("spac")
|
|
715
|
+
? bindings.find((binding) => binding.mechanism === "style.rawSpacing.mustMatchScale")
|
|
716
|
+
: undefined) ??
|
|
717
|
+
(scale.toLowerCase().includes("font")
|
|
718
|
+
? bindings.find((binding) => binding.mechanism === "style.fontSize.mustMatchScale")
|
|
719
|
+
: undefined) ??
|
|
720
|
+
bindings[0];
|
|
721
|
+
const remediation = preferred
|
|
722
|
+
? `${preferred.label} are bound to the scale named "${preferred.scale}" — rename the key to "${preferred.scale}" or bind it via ${preferred.mechanism}.`
|
|
723
|
+
: `Bind it via style.rawSpacing.mustMatchScale or style.fontSize.mustMatchScale, or remove it.`;
|
|
724
|
+
diagnostics.push({
|
|
725
|
+
code: "FUI9005",
|
|
726
|
+
kind: "orphan-scale",
|
|
727
|
+
severity: "warn",
|
|
728
|
+
path,
|
|
729
|
+
message: `${path} is not referenced by any property policy. ${remediation}`,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return stableConfigDiagnostics(diagnostics);
|
|
734
|
+
}
|
|
735
|
+
|
|
134
736
|
function summarize(
|
|
135
737
|
status: GovernanceIntegrityStatus,
|
|
136
738
|
flags: { componentsArmed: boolean; tokensArmed: boolean; blockingArmed: boolean }
|
|
@@ -152,6 +754,7 @@ export function evaluateGovernanceIntegrity(
|
|
|
152
754
|
input: GovernanceIntegrityInput
|
|
153
755
|
): GovernanceIntegrityVerdict {
|
|
154
756
|
const configs = effectiveRuleConfigs(input.policy);
|
|
757
|
+
const configDiagnostics = stableConfigDiagnostics(input.configDiagnostics ?? []);
|
|
155
758
|
|
|
156
759
|
// --- policy family --------------------------------------------------------
|
|
157
760
|
const policyArmed = input.policy !== undefined && input.policySource !== "none";
|
|
@@ -280,6 +883,17 @@ export function evaluateGovernanceIntegrity(
|
|
|
280
883
|
.filter((remediation): remediation is string => remediation !== undefined)
|
|
281
884
|
);
|
|
282
885
|
const summary = summarize(status, { componentsArmed, tokensArmed, blockingArmed });
|
|
886
|
+
const configuredRuleCount = [...configs.keys()].filter((ruleId) =>
|
|
887
|
+
CONSUMED_RULE_IDS.has(ruleId)
|
|
888
|
+
).length;
|
|
889
|
+
const activeRuleCount = [...configs.entries()].filter(
|
|
890
|
+
([ruleId, config]) => CONSUMED_RULE_IDS.has(ruleId) && config.enabled
|
|
891
|
+
).length;
|
|
892
|
+
const roster = {
|
|
893
|
+
configured: configuredRuleCount + configDiagnostics.length,
|
|
894
|
+
active: activeRuleCount,
|
|
895
|
+
inert: configDiagnostics.length,
|
|
896
|
+
};
|
|
283
897
|
|
|
284
898
|
return {
|
|
285
899
|
status,
|
|
@@ -290,5 +904,7 @@ export function evaluateGovernanceIntegrity(
|
|
|
290
904
|
armed,
|
|
291
905
|
summary,
|
|
292
906
|
remediations,
|
|
907
|
+
configDiagnostics,
|
|
908
|
+
roster,
|
|
293
909
|
};
|
|
294
910
|
}
|
package/src/governance.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
compileFragment,
|
|
4
|
+
configDeclarationForDiagnostics,
|
|
5
|
+
defineConfig,
|
|
6
|
+
defineFragment,
|
|
7
|
+
g,
|
|
8
|
+
type FragmentsConfig,
|
|
9
|
+
} from "./index.js";
|
|
3
10
|
|
|
4
11
|
type ButtonProps = {
|
|
5
12
|
variant?: "primary" | "secondary" | "ghost" | "link";
|
|
@@ -12,6 +19,18 @@ function Button(_props: ButtonProps) {
|
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
describe("governance DSL", () => {
|
|
22
|
+
it("preserves the authored declaration for stripped-key diagnostics", () => {
|
|
23
|
+
const authored = {
|
|
24
|
+
include: ["src/**/*.fragment.ts"],
|
|
25
|
+
styles: { spacing: true },
|
|
26
|
+
};
|
|
27
|
+
const config = defineConfig(authored as FragmentsConfig);
|
|
28
|
+
|
|
29
|
+
expect(config).not.toHaveProperty("styles");
|
|
30
|
+
expect(configDeclarationForDiagnostics(config)).toBe(authored);
|
|
31
|
+
expect(Object.keys(config)).toEqual(["include"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
15
34
|
it("defineConfig accepts global governance records", () => {
|
|
16
35
|
const config = defineConfig({
|
|
17
36
|
include: ["src/**/*.fragment.ts"],
|