@usefragments/core 1.5.2 → 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-BAHCOAVG.js → chunk-WVFNDPM4.js} +423 -189
- 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-pKrfh517.d.ts → governance-DxFipN5V.d.ts} +609 -20
- package/dist/index.d.ts +638 -43
- package/dist/index.js +304 -37
- 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 +40 -0
- package/src/facts/builders.ts +35 -0
- package/src/facts/compile.ts +135 -21
- package/src/facts/fact-index.ts +15 -2
- package/src/facts/facts.test.ts +6 -2
- package/src/facts/index.ts +9 -6
- package/src/facts/types.ts +45 -9
- package/src/governance-integrity.test.ts +174 -3
- package/src/governance-integrity.ts +305 -5
- package/src/governance.ts +87 -1
- package/src/index.ts +38 -1
- 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.ts +12 -4
- package/src/rules/styles-no-raw-typography.ts +13 -4
- package/src/rules/utils.ts +39 -0
- package/dist/chunk-BAHCOAVG.js.map +0 -1
|
@@ -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.
|
|
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", () => {
|
|
@@ -18,14 +18,26 @@
|
|
|
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
|
|
|
27
|
-
export type InertConfigDiagnosticKind =
|
|
28
|
-
|
|
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";
|
|
29
41
|
|
|
30
42
|
export interface InertConfigDiagnostic {
|
|
31
43
|
code: InertConfigDiagnosticCode;
|
|
@@ -152,10 +164,13 @@ function dedupe(values: string[]): string[] {
|
|
|
152
164
|
return [...new Set(values)];
|
|
153
165
|
}
|
|
154
166
|
|
|
155
|
-
const RULE_FAMILY_IDS = new Set(["tokens/hardcoded-values", "components/usage", "a11y/wcag"]);
|
|
156
167
|
const CONSUMED_RULE_IDS = new Set(Object.keys(RULE_TIER));
|
|
157
168
|
const RECOGNIZED_RULE_IDS = new Set([...CONSUMED_RULE_IDS, ...RULE_FAMILY_IDS]);
|
|
158
|
-
|
|
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"]);
|
|
159
174
|
|
|
160
175
|
const PASSTHROUGH_KEYS = [
|
|
161
176
|
{
|
|
@@ -195,7 +210,7 @@ const PASSTHROUGH_KEYS = [
|
|
|
195
210
|
},
|
|
196
211
|
{
|
|
197
212
|
path: ["govern", "ci"],
|
|
198
|
-
keys: ["failOnWarnings"],
|
|
213
|
+
keys: ["failOnWarnings", "failOnInert"],
|
|
199
214
|
},
|
|
200
215
|
] as const;
|
|
201
216
|
|
|
@@ -237,6 +252,232 @@ function unconsumedKeyDiagnostic(path: readonly (string | number)[]): InertConfi
|
|
|
237
252
|
};
|
|
238
253
|
}
|
|
239
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
|
+
|
|
240
481
|
function collectStrippedKeys(
|
|
241
482
|
authored: unknown,
|
|
242
483
|
parsed: unknown,
|
|
@@ -369,6 +610,65 @@ export function detectUnconsumedConfigKeys(
|
|
|
369
610
|
return stableConfigDiagnostics(diagnostics);
|
|
370
611
|
}
|
|
371
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
|
+
|
|
372
672
|
interface ScaleBinding {
|
|
373
673
|
label: string;
|
|
374
674
|
mechanism: string;
|