@usefragments/core 1.7.1 → 1.9.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-WNMWKUYG.js → chunk-DGHZQTLH.js} +324 -6
- package/dist/chunk-DGHZQTLH.js.map +1 -0
- package/dist/chunk-MZ4SW3TP.js +375 -0
- package/dist/chunk-MZ4SW3TP.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- package/dist/codes/index.js +1 -2
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/governance-telemetry.d.ts +316 -0
- package/dist/governance-telemetry.js +11 -0
- package/dist/governance-telemetry.js.map +1 -0
- package/dist/{index-hZAlYCli.d.ts → index-DtHxs0Pf.d.ts} +1099 -1009
- package/dist/index.d.ts +503 -477
- package/dist/index.js +94 -37
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +146 -146
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +3 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +5 -1
- package/src/governance-telemetry.test.ts +18 -0
- package/src/governance-telemetry.ts +398 -0
- package/src/index.ts +17 -0
- package/src/rules/finding.ts +22 -0
- package/src/rules/index.ts +5 -1
- package/src/rules/jsx-preferred-import-path.ts +18 -0
- package/src/rules/rules.test.ts +114 -0
- package/src/rules/tokens-css-vars-must-be-defined.test.ts +66 -1
- package/src/rules/tokens-css-vars-must-be-defined.ts +88 -16
- package/src/schemas/index.ts +10 -0
- package/src/schemas/normalize-finding.test.ts +50 -0
- package/dist/chunk-WNMWKUYG.js.map +0 -1
- package/dist/chunk-ZHS52OT4.js +0 -317
- package/dist/chunk-ZHS52OT4.js.map +0 -1
- package/dist/{governance-D9KtH-vg.d.ts → governance-eEzCyfes.d.ts} +154 -154
package/src/rules/rules.test.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from "../index.js";
|
|
38
38
|
import type { CanonicalBridgeV1, Fact, FactId } from "../index.js";
|
|
39
39
|
import { RULE_VERSION as A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION } from "./a11y-required-accessible-name.js";
|
|
40
|
+
import { makeFinding } from "./finding.js";
|
|
40
41
|
|
|
41
42
|
type ButtonProps = {
|
|
42
43
|
variant?: "primary" | "secondary" | "ghost" | "link";
|
|
@@ -438,6 +439,78 @@ describe("preferred JSX imports and components", () => {
|
|
|
438
439
|
});
|
|
439
440
|
});
|
|
440
441
|
|
|
442
|
+
it("keeps preferred-path fingerprints stable across line drift", () => {
|
|
443
|
+
const findingsAtLine = (line: number) => {
|
|
444
|
+
const ix = new FactIndex();
|
|
445
|
+
ix.addMany(
|
|
446
|
+
compileGlobalGovernanceFacts({
|
|
447
|
+
jsx: [g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui", { severity: "error" })],
|
|
448
|
+
})
|
|
449
|
+
);
|
|
450
|
+
ix.add(
|
|
451
|
+
makeUsageImportFact({
|
|
452
|
+
file: "apps/checkout/page.tsx",
|
|
453
|
+
local: "Button",
|
|
454
|
+
imported: "Button",
|
|
455
|
+
source: "@legacy/ui",
|
|
456
|
+
location: { file: "apps/checkout/page.tsx", line, column: 0 },
|
|
457
|
+
})
|
|
458
|
+
);
|
|
459
|
+
return ruleJsxPreferredImportPath(ix);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const before = findingsAtLine(1);
|
|
463
|
+
const after = findingsAtLine(9);
|
|
464
|
+
|
|
465
|
+
expect(after[0].fingerprint).toBe(before[0].fingerprint);
|
|
466
|
+
// Byte-pinned hashes: the legacy value is what a released v1 CLI recorded
|
|
467
|
+
// for this exact tuple (hash64Hex(canonicalJson({ruleId, file, from, to,
|
|
468
|
+
// line, column}))). If previousFingerprintIdentity ever drifts from the
|
|
469
|
+
// true v1 formula, real baselines mass-invalidate — this literal is the
|
|
470
|
+
// tripwire.
|
|
471
|
+
expect(before[0].fingerprint).toBe("4deee0ff7bb1810b");
|
|
472
|
+
expect(before[0].previousFingerprint).toBe("c04344ad7a96b651");
|
|
473
|
+
// The grace-window legacy hash still tracks position, so a v1 baseline
|
|
474
|
+
// recorded at either line matches exactly one of the two runs.
|
|
475
|
+
expect(after[0].previousFingerprint).not.toBe(before[0].previousFingerprint);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("distinguishes repeated same-identity imports by source-order occurrence", () => {
|
|
479
|
+
// Two imports of the same specifier under different local aliases share the
|
|
480
|
+
// fingerprint identity tuple (local never enters the hash) — the ordinal
|
|
481
|
+
// tells them apart. Byte-identical duplicates collapse at the fact layer.
|
|
482
|
+
const findingsFor = (locals: readonly string[]) => {
|
|
483
|
+
const ix = new FactIndex();
|
|
484
|
+
ix.addMany(
|
|
485
|
+
compileGlobalGovernanceFacts({
|
|
486
|
+
jsx: [g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui", { severity: "error" })],
|
|
487
|
+
})
|
|
488
|
+
);
|
|
489
|
+
locals.forEach((local, index) => {
|
|
490
|
+
ix.add(
|
|
491
|
+
makeUsageImportFact({
|
|
492
|
+
file: "apps/checkout/page.tsx",
|
|
493
|
+
local,
|
|
494
|
+
imported: "Button",
|
|
495
|
+
source: "@legacy/ui",
|
|
496
|
+
location: { file: "apps/checkout/page.tsx", line: index * 10 + 1, column: 0 },
|
|
497
|
+
})
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
return ruleJsxPreferredImportPath(ix);
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const both = findingsFor(["Button", "AliasedButton"]);
|
|
504
|
+
expect(both).toHaveLength(2);
|
|
505
|
+
expect(both[0].fingerprint).not.toBe(both[1].fingerprint);
|
|
506
|
+
|
|
507
|
+
// Deleting the first occurrence promotes the survivor to its ordinal, so
|
|
508
|
+
// the remaining debt matches a fingerprint the baseline already accepts.
|
|
509
|
+
const survivor = findingsFor(["AliasedButton"]);
|
|
510
|
+
expect(survivor).toHaveLength(1);
|
|
511
|
+
expect(survivor[0].fingerprint).toBe(both[0].fingerprint);
|
|
512
|
+
});
|
|
513
|
+
|
|
441
514
|
it("emits a deterministic component replacement for canonical component mappings", () => {
|
|
442
515
|
const legacyButtonId = makeComponentId("@legacy/ui", "LegacyButton");
|
|
443
516
|
const ix = new FactIndex();
|
|
@@ -4163,6 +4236,47 @@ describe("styles/no-raw-dimensions", () => {
|
|
|
4163
4236
|
});
|
|
4164
4237
|
});
|
|
4165
4238
|
|
|
4239
|
+
// ---------------------------------------------------------------------------
|
|
4240
|
+
// makeFinding fingerprint contract
|
|
4241
|
+
// ---------------------------------------------------------------------------
|
|
4242
|
+
|
|
4243
|
+
describe("makeFinding fingerprint contract", () => {
|
|
4244
|
+
const base = {
|
|
4245
|
+
ruleId: "styles/no-raw-color",
|
|
4246
|
+
ruleVersion: "1",
|
|
4247
|
+
severity: "serious" as const,
|
|
4248
|
+
message: "Raw color",
|
|
4249
|
+
location: { file: "src/App.tsx", line: 3, column: 1 },
|
|
4250
|
+
evidence: [{ factId: "fact-1" as FactId, fact: { kind: "style_declaration" } }],
|
|
4251
|
+
};
|
|
4252
|
+
|
|
4253
|
+
it("rejects position keys in fingerprintIdentity", () => {
|
|
4254
|
+
expect(() =>
|
|
4255
|
+
makeFinding({
|
|
4256
|
+
...base,
|
|
4257
|
+
fingerprintIdentity: { file: "src/App.tsx", value: "#fff", line: 3, column: 1 },
|
|
4258
|
+
})
|
|
4259
|
+
).toThrow(/position keys \(line, column\)/);
|
|
4260
|
+
});
|
|
4261
|
+
|
|
4262
|
+
it("hashes a legacy identity into previousFingerprint when provided", () => {
|
|
4263
|
+
const migrated = makeFinding({
|
|
4264
|
+
...base,
|
|
4265
|
+
fingerprintIdentity: { file: "src/App.tsx", value: "#fff", occurrence: 0 },
|
|
4266
|
+
previousFingerprintIdentity: { file: "src/App.tsx", value: "#fff", line: 3, column: 1 },
|
|
4267
|
+
});
|
|
4268
|
+
const plain = makeFinding({
|
|
4269
|
+
...base,
|
|
4270
|
+
fingerprintIdentity: { file: "src/App.tsx", value: "#fff", occurrence: 0 },
|
|
4271
|
+
});
|
|
4272
|
+
|
|
4273
|
+
expect(migrated.fingerprint).toBe(plain.fingerprint);
|
|
4274
|
+
expect(migrated.previousFingerprint).toBeDefined();
|
|
4275
|
+
expect(migrated.previousFingerprint).not.toBe(migrated.fingerprint);
|
|
4276
|
+
expect(plain.previousFingerprint).toBeUndefined();
|
|
4277
|
+
});
|
|
4278
|
+
});
|
|
4279
|
+
|
|
4166
4280
|
// Type witness so eslint/tsc don't trip on unused imports above.
|
|
4167
4281
|
const _typeWitness: Fact[] = [];
|
|
4168
4282
|
void _typeWitness;
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
makeStyleDeclarationFact,
|
|
8
8
|
} from "../index.js";
|
|
9
9
|
import type { Fact } from "../index.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
contractTokenReferenceCensus,
|
|
12
|
+
ruleTokensCssVarsMustBeDefined,
|
|
13
|
+
} from "./tokens-css-vars-must-be-defined.js";
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* ACCEPTANCE §4 (rule level) + §11 (regression lock).
|
|
@@ -242,3 +245,65 @@ describe("ruleTokensCssVarsMustBeDefined", () => {
|
|
|
242
245
|
expect(reversed).toHaveLength(2);
|
|
243
246
|
});
|
|
244
247
|
});
|
|
248
|
+
|
|
249
|
+
describe("contractTokenReferenceCensus", () => {
|
|
250
|
+
function declAt(line: number, value: string): Fact {
|
|
251
|
+
const file = "src/Card.module.scss";
|
|
252
|
+
return makeStyleDeclarationFact({
|
|
253
|
+
file,
|
|
254
|
+
selector: ".card",
|
|
255
|
+
declarationPath: `.card:${line}`,
|
|
256
|
+
property: "color",
|
|
257
|
+
value,
|
|
258
|
+
location: { file, line, column: 2 },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
it("is unmeasured (undefined) while the rule is inert — no policy fact or no vocabulary", () => {
|
|
263
|
+
expect(
|
|
264
|
+
contractTokenReferenceCensus(index([...vocabulary(), declAt(1, "var(--fui-color-accent)")]))
|
|
265
|
+
).toBeUndefined();
|
|
266
|
+
expect(
|
|
267
|
+
contractTokenReferenceCensus(index([POLICY, declAt(1, "var(--fui-color-accent)")]))
|
|
268
|
+
).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("counts distinct judged declaration sites — clean and drifting alike, foreign excluded", () => {
|
|
272
|
+
const census = contractTokenReferenceCensus(
|
|
273
|
+
index([
|
|
274
|
+
POLICY,
|
|
275
|
+
...vocabulary(),
|
|
276
|
+
declAt(1, "var(--fui-color-accent)"), // clean, judged
|
|
277
|
+
declAt(2, "var(--fui-color-brand)"), // drift, judged
|
|
278
|
+
declAt(3, "var(--swiper-theme-color)"), // foreign, never judged
|
|
279
|
+
declAt(4, "#39594d"), // no reference at all
|
|
280
|
+
])
|
|
281
|
+
);
|
|
282
|
+
expect(census).toEqual({ checkedSites: 2 });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("counts a site once regardless of how many judged references it carries", () => {
|
|
286
|
+
const census = contractTokenReferenceCensus(
|
|
287
|
+
index([
|
|
288
|
+
POLICY,
|
|
289
|
+
...vocabulary(),
|
|
290
|
+
declAt(1, "var(--fui-color-accent) var(--fui-space-2) var(--fui-color-brand)"),
|
|
291
|
+
])
|
|
292
|
+
);
|
|
293
|
+
expect(census).toEqual({ checkedSites: 1 });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("judges only the PRIMARY var() position, exactly like the rule (#17)", () => {
|
|
297
|
+
// The only primary reference is foreign; the in-contract token sits in
|
|
298
|
+
// fallback position and must not create a checked site the rule would
|
|
299
|
+
// never have judged.
|
|
300
|
+
const census = contractTokenReferenceCensus(
|
|
301
|
+
index([
|
|
302
|
+
POLICY,
|
|
303
|
+
...vocabulary(),
|
|
304
|
+
declAt(1, "var(--swiper-theme-color, var(--fui-color-accent))"),
|
|
305
|
+
])
|
|
306
|
+
);
|
|
307
|
+
expect(census).toEqual({ checkedSites: 0 });
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -39,9 +39,22 @@ const CSS_VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/gi;
|
|
|
39
39
|
* for projects without a contract. Hardcoded values are handled by the unchanged
|
|
40
40
|
* `styles/no-raw-color` rule; this rule only judges custom-property references.
|
|
41
41
|
*/
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
interface ContractVocabularyContext {
|
|
43
|
+
vocabulary: ReadonlySet<string>;
|
|
44
|
+
prefixes: ReadonlySet<string>;
|
|
45
|
+
hasFlatTokens: boolean;
|
|
46
|
+
locallyDefinedCustomProperties: ReadonlySet<string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The judgment context the rule scans with, or `undefined` while the rule is
|
|
51
|
+
* inert (no `style.cssVars.mustBeDefined` policy fact, or no authored contract
|
|
52
|
+
* vocabulary). Shared with `contractTokenReferenceCensus` so the health
|
|
53
|
+
* metric's denominator and this rule's findings are computed over the same
|
|
54
|
+
* population, by the same shape gate.
|
|
55
|
+
*/
|
|
56
|
+
function contractVocabularyContext(ix: FactIndex): ContractVocabularyContext | undefined {
|
|
57
|
+
if (!ix.policy.cssVarsMustBeDefined()) return undefined;
|
|
45
58
|
|
|
46
59
|
// The contract vocabulary travels on its OWN fact channel (contract_token),
|
|
47
60
|
// injected by the CLI from the user's token source files — kept separate from
|
|
@@ -49,21 +62,47 @@ export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] {
|
|
|
49
62
|
const vocabulary = new Set(ix.byKind("contract_token").map((token) => token.name));
|
|
50
63
|
// The truly-unauthored case (no contract token facts) is the only hard return —
|
|
51
64
|
// it preserves the §11 inert-without-contract regression lock.
|
|
52
|
-
if (vocabulary.size === 0) return
|
|
65
|
+
if (vocabulary.size === 0) return undefined;
|
|
53
66
|
|
|
54
67
|
// A flat/single-segment vocabulary (`--accent`, `--bg`) yields no prefix family,
|
|
55
68
|
// but the rule must STILL enforce it (#22/#25/#31/#36) — we no longer hard-return
|
|
56
69
|
// on an empty family set. `hasFlatTokens` lets single-segment refs through the
|
|
57
70
|
// shaped gate whenever the vocabulary itself declares single-segment tokens,
|
|
58
71
|
// which also enforces the flat half of a mixed vocabulary symmetrically (#18).
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
return {
|
|
73
|
+
vocabulary,
|
|
74
|
+
prefixes: contractPrefixFamilies(vocabulary),
|
|
75
|
+
hasFlatTokens: [...vocabulary].some(isSingleSegmentVarName),
|
|
76
|
+
locallyDefinedCustomProperties: new Set(
|
|
77
|
+
ix
|
|
78
|
+
.byKind("style_declaration")
|
|
79
|
+
.filter((decl) => decl.property.startsWith("--"))
|
|
80
|
+
.map((decl) => decl.property)
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a primary-position `var()` reference is judged against the contract:
|
|
87
|
+
* either it IS a vocabulary token (clean) or it is shaped like one (drift).
|
|
88
|
+
* Foreign vendor custom properties (`--radix-*`, `--swiper-*`) are inspected
|
|
89
|
+
* but never judged, so they stay outside both counts.
|
|
90
|
+
*/
|
|
91
|
+
function isJudgedReference(tokenName: string, context: ContractVocabularyContext): boolean {
|
|
92
|
+
return (
|
|
93
|
+
context.vocabulary.has(tokenName) ||
|
|
94
|
+
sharesContractPrefix(tokenName, context.prefixes) ||
|
|
95
|
+
(context.hasFlatTokens && isSingleSegmentVarName(tokenName)) ||
|
|
96
|
+
context.locallyDefinedCustomProperties.has(tokenName)
|
|
66
97
|
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] {
|
|
101
|
+
const policy = ix.policy.cssVarsMustBeDefined();
|
|
102
|
+
if (!policy) return [];
|
|
103
|
+
const context = contractVocabularyContext(ix);
|
|
104
|
+
if (!context) return [];
|
|
105
|
+
const { vocabulary } = context;
|
|
67
106
|
|
|
68
107
|
const findings: Finding[] = [];
|
|
69
108
|
|
|
@@ -83,11 +122,7 @@ export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] {
|
|
|
83
122
|
CSS_VAR_REFERENCE.lastIndex = skipToCloseParen(decl.value, match.index + 3);
|
|
84
123
|
|
|
85
124
|
if (vocabulary.has(tokenName)) continue;
|
|
86
|
-
|
|
87
|
-
sharesContractPrefix(tokenName, prefixes) ||
|
|
88
|
-
(hasFlatTokens && isSingleSegmentVarName(tokenName)) ||
|
|
89
|
-
locallyDefinedCustomProperties.has(tokenName);
|
|
90
|
-
if (!shaped) continue;
|
|
125
|
+
if (!isJudgedReference(tokenName, context)) continue;
|
|
91
126
|
if (seen.has(tokenName)) continue;
|
|
92
127
|
seen.add(tokenName);
|
|
93
128
|
|
|
@@ -136,6 +171,43 @@ function isSingleSegmentVarName(name: string): boolean {
|
|
|
136
171
|
return name.indexOf("-", 2) === -1;
|
|
137
172
|
}
|
|
138
173
|
|
|
174
|
+
export interface ContractTokenReferenceCensus {
|
|
175
|
+
/**
|
|
176
|
+
* Distinct style-declaration sites (by file:line:column) carrying at least
|
|
177
|
+
* one contract-judged primary `var()` reference.
|
|
178
|
+
*/
|
|
179
|
+
checkedSites: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The checked-site denominator for the `contract-site-health` metric's
|
|
184
|
+
* token-reference population: how many style-declaration sites this rule
|
|
185
|
+
* actually judged against the contract vocabulary. `undefined` while the rule
|
|
186
|
+
* is inert — an unmeasured population, never a measured zero. Walks the same
|
|
187
|
+
* `style_declaration` facts with the same primary-position `var()` scan and
|
|
188
|
+
* shape gate as the rule itself, so a site can violate iff it was counted.
|
|
189
|
+
*/
|
|
190
|
+
export function contractTokenReferenceCensus(
|
|
191
|
+
ix: FactIndex
|
|
192
|
+
): ContractTokenReferenceCensus | undefined {
|
|
193
|
+
const context = contractVocabularyContext(ix);
|
|
194
|
+
if (!context) return undefined;
|
|
195
|
+
|
|
196
|
+
const checked = new Set<string>();
|
|
197
|
+
for (const decl of ix.byKind("style_declaration")) {
|
|
198
|
+
CSS_VAR_REFERENCE.lastIndex = 0;
|
|
199
|
+
let match: RegExpExecArray | null;
|
|
200
|
+
while ((match = CSS_VAR_REFERENCE.exec(decl.value)) !== null) {
|
|
201
|
+
const tokenName = match[1];
|
|
202
|
+
CSS_VAR_REFERENCE.lastIndex = skipToCloseParen(decl.value, match.index + 3);
|
|
203
|
+
if (!isJudgedReference(tokenName, context)) continue;
|
|
204
|
+
checked.add(`${decl.location.file}:${decl.location.line}:${decl.location.column}`);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { checkedSites: checked.size };
|
|
209
|
+
}
|
|
210
|
+
|
|
139
211
|
/**
|
|
140
212
|
* Index just past the `)` that closes the open paren AT `openParenIndex`
|
|
141
213
|
* (a balanced-paren scan, so a nested fallback `var(--a, var(--b))` is skipped as
|
package/src/schemas/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../severity.js";
|
|
10
10
|
import type { LegacySeverityLevel, Severity } from "../severity.js";
|
|
11
11
|
import type { EvidenceGrade } from "../evidence.js";
|
|
12
|
+
import { byRuleId } from "../codes/index.js";
|
|
12
13
|
|
|
13
14
|
const evidenceGradeSchema: z.ZodType<EvidenceGrade> = z.enum([
|
|
14
15
|
"none",
|
|
@@ -115,6 +116,12 @@ export const findingSchema = z.object({
|
|
|
115
116
|
helpUrl: z.string().url().optional(),
|
|
116
117
|
message: z.string(),
|
|
117
118
|
fingerprint: z.string(),
|
|
119
|
+
/**
|
|
120
|
+
* Fingerprint this finding carried before a fingerprint-identity migration.
|
|
121
|
+
* Emitted only by migrated rules during the grace window so baseline
|
|
122
|
+
* records keyed on the legacy hash keep matching instead of churning.
|
|
123
|
+
*/
|
|
124
|
+
previousFingerprint: z.string().optional(),
|
|
118
125
|
location: factLocationSchema,
|
|
119
126
|
evidence: z.array(factEvidenceSchema).min(1),
|
|
120
127
|
evidenceGrade: evidenceGradeSchema.optional(),
|
|
@@ -357,8 +364,11 @@ export function normalizeSeverity(severity: Severity | LegacySeverityLevel): Sev
|
|
|
357
364
|
export function normalizeFinding(
|
|
358
365
|
input: Omit<Finding, "level"> & { level?: Finding["level"] }
|
|
359
366
|
): Finding {
|
|
367
|
+
const code = input.code === undefined ? byRuleId.get(input.ruleId) : undefined;
|
|
360
368
|
return {
|
|
361
369
|
...input,
|
|
370
|
+
code: input.code ?? code?.code,
|
|
371
|
+
helpUrl: input.helpUrl ?? code?.explainUrl,
|
|
362
372
|
evidenceGrade: input.evidenceGrade ?? "source_backed",
|
|
363
373
|
level: input.level ?? severityLevel(input.severity),
|
|
364
374
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `normalizeFinding` — FUI code attachment for findings that skip the rules
|
|
3
|
+
* engine's builder.
|
|
4
|
+
*
|
|
5
|
+
* The engine's suppression system findings (unused/expired/malformed
|
|
6
|
+
* suppression, missing expiry) are normalized here rather than through
|
|
7
|
+
* `rules/finding.ts`, and field report #3 (C16) caught the consequence: the
|
|
8
|
+
* human summary printed FUI9002 while `--format json` emitted
|
|
9
|
+
* `code: undefined`, so no JSON consumer could map the finding to rule
|
|
10
|
+
* metadata. The normalizer now resolves the registered code itself; an
|
|
11
|
+
* explicitly provided code always wins.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
import { normalizeFinding } from "./index.js";
|
|
17
|
+
|
|
18
|
+
const base = {
|
|
19
|
+
ruleVersion: "typestyle-suppressions:v1",
|
|
20
|
+
severity: "serious",
|
|
21
|
+
message: "Suppression has expired for FUI2005.",
|
|
22
|
+
fingerprint: "0000000000000000",
|
|
23
|
+
location: { file: "src/App.tsx", line: 3, column: 1 },
|
|
24
|
+
evidence: [],
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
describe("normalizeFinding", () => {
|
|
28
|
+
it("attaches the registered FUI code and explain URL for a registered ruleId", () => {
|
|
29
|
+
const finding = normalizeFinding({ ...base, ruleId: "expired-suppression" });
|
|
30
|
+
expect(finding.code).toBe("FUI9002");
|
|
31
|
+
expect(finding.helpUrl).toBe("https://usefragments.com/errors/FUI9002");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps an explicitly provided code and helpUrl", () => {
|
|
35
|
+
const finding = normalizeFinding({
|
|
36
|
+
...base,
|
|
37
|
+
ruleId: "expired-suppression",
|
|
38
|
+
code: "FUI0000",
|
|
39
|
+
helpUrl: "https://example.com/FUI0000",
|
|
40
|
+
});
|
|
41
|
+
expect(finding.code).toBe("FUI0000");
|
|
42
|
+
expect(finding.helpUrl).toBe("https://example.com/FUI0000");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("leaves code undefined for an unregistered ruleId", () => {
|
|
46
|
+
const finding = normalizeFinding({ ...base, ruleId: "not-a-registered-rule" });
|
|
47
|
+
expect(finding.code).toBeUndefined();
|
|
48
|
+
expect(finding.helpUrl).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|