@brighterly/lib-fe-codestyle 0.3.0 → 0.5.1
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/README.md +13 -6
- package/dist/invariants/ast.d.ts +82 -0
- package/dist/invariants/ast.js +270 -0
- package/dist/invariants/rules/index.d.ts +2 -2
- package/dist/invariants/rules/index.js +2 -2
- package/dist/invariants/rules/regression.js +10 -2
- package/dist/invariants/run.d.ts +2 -5
- package/dist/invariants/run.js +241 -52
- package/dist/invariants/self-test.d.ts +11 -3
- package/dist/invariants/self-test.js +41 -14
- package/dist/invariants/types.d.ts +32 -4
- package/dist/invariants/util.d.ts +33 -8
- package/dist/invariants/util.js +190 -11
- package/package.json +1 -1
|
@@ -14,19 +14,21 @@ export type Level = 'error' | 'warn' | 'off';
|
|
|
14
14
|
* B — business: the funnel-data contract, confirmed by PMM
|
|
15
15
|
* C — convention: a preference we chose, advisory by default
|
|
16
16
|
* L — linter: covered by a built-in ESLint rule, configured elsewhere
|
|
17
|
+
* E — e2e: end-to-end test authoring standards
|
|
17
18
|
* IDs are load-bearing (`--rule=`, `invariant-ignore:`, allowlists, levels) —
|
|
18
19
|
* retire one, never recycle it.
|
|
19
20
|
*/
|
|
20
21
|
/** App-local ids are bare; shared-pack ids carry the `core/` prefix (`core/R1`). */
|
|
21
|
-
export type RuleId = `${'' | 'core/'}${'R' | 'B' | 'C' | 'L'}${number}`;
|
|
22
|
+
export type RuleId = `${'' | 'core/'}${'R' | 'B' | 'C' | 'L' | 'E'}${number}`;
|
|
22
23
|
/**
|
|
23
24
|
* Meta rules. They police the escape hatch rather than the app, so they have no
|
|
24
25
|
* family letter — but they are addressable exactly like any other rule
|
|
25
26
|
* (`allowlists`, `levels`, `invariant-ignore`), so they belong in the ID space.
|
|
26
27
|
* IG0 — an `invariant-ignore` with no reason
|
|
27
28
|
* IG1 — a reference to a rule ID that no longer exists
|
|
29
|
+
* IG2 — an `invariant-ignore` naming a rule declared `noIgnore`
|
|
28
30
|
*/
|
|
29
|
-
export type MetaRuleId = 'IG0' | 'IG1';
|
|
31
|
+
export type MetaRuleId = 'IG0' | 'IG1' | 'IG2';
|
|
30
32
|
/** Anything addressable in config or a suppression comment. */
|
|
31
33
|
export type AnyRuleId = RuleId | MetaRuleId;
|
|
32
34
|
/** One violation. `line` is 1-based. */
|
|
@@ -74,6 +76,8 @@ export interface SharedRuleSettings {
|
|
|
74
76
|
typographySource?: string;
|
|
75
77
|
/** `core/C7`: which `<script>` attribute order is canonical in this app. Default 'setup-first' (`<script setup lang="ts">`). */
|
|
76
78
|
scriptAttrOrder?: 'setup-first' | 'lang-first';
|
|
79
|
+
/** The consumer file whose `invariant-ignore:` strings are self-test FIXTURE DATA, not real suppressions — the engine skips ignore-parsing it entirely, replacing per-file IG0/IG1 allowlist boilerplate. E.g. 'invariants/self-test.mts'. */
|
|
80
|
+
selfTestFile?: string;
|
|
77
81
|
}
|
|
78
82
|
/** What a rule's `check` receives. */
|
|
79
83
|
export interface RuleContext {
|
|
@@ -85,8 +89,14 @@ export interface RuleContext {
|
|
|
85
89
|
code: string;
|
|
86
90
|
/** Read any other repo-relative file (cached). Returns null if unreadable. */
|
|
87
91
|
read: (rel: string) => string | null;
|
|
88
|
-
/** Every scanned file in the repo — for rules that resolve references. */
|
|
92
|
+
/** Every scanned file in the repo minus the `unmaintained` paths — for rules that resolve references. */
|
|
89
93
|
allFiles: string[];
|
|
94
|
+
/**
|
|
95
|
+
* Every scanned file INCLUDING the `unmaintained` paths. Undefined unless the rule declares
|
|
96
|
+
* `seesUnmaintained` — the exclusion is meant to be invisible to a rule, and this is the one
|
|
97
|
+
* declared way through it.
|
|
98
|
+
*/
|
|
99
|
+
scanned?: string[];
|
|
90
100
|
/**
|
|
91
101
|
* App-specific paths a SHARED rule needs, supplied by `config.settings`. This is how a
|
|
92
102
|
* portable rule learns where this app keeps its SCSS tokens or its UI-kit root, without
|
|
@@ -95,7 +105,7 @@ export interface RuleContext {
|
|
|
95
105
|
settings: Readonly<SharedRuleSettings & Record<string, string>>;
|
|
96
106
|
}
|
|
97
107
|
/** A `once: true` rule reads its own inputs, so it gets no per-file fields. */
|
|
98
|
-
export type OnceRuleContext = Pick<RuleContext, 'read' | 'allFiles'> & Partial<RuleContext>;
|
|
108
|
+
export type OnceRuleContext = Pick<RuleContext, 'read' | 'allFiles' | 'scanned'> & Partial<RuleContext>;
|
|
99
109
|
interface BaseRule {
|
|
100
110
|
id: RuleId;
|
|
101
111
|
/** Short description — appears in the report and the README inventory. */
|
|
@@ -109,8 +119,24 @@ interface BaseRule {
|
|
|
109
119
|
scope: string[];
|
|
110
120
|
/** Glob patterns to skip within scope. */
|
|
111
121
|
exclude?: string[];
|
|
122
|
+
/**
|
|
123
|
+
* The rule cannot be suppressed with `invariant-ignore`; a comment naming it is
|
|
124
|
+
* itself a finding (IG2). For rules whose author is an AI: exceptions are made by
|
|
125
|
+
* a human in the rulebook (rule + fixture), never by the agent in the file under
|
|
126
|
+
* check — an AI writes a plausible reason for free.
|
|
127
|
+
* Honoured by the runner: the suppression lookup skips such rules, and the IG2
|
|
128
|
+
* block reports the attempt.
|
|
129
|
+
*/
|
|
130
|
+
noIgnore?: boolean;
|
|
112
131
|
/** Set when `check` returns a promise, so the runner awaits it. */
|
|
113
132
|
async?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Hand this rule `scanned` — every file, the `unmaintained` paths included — where every other
|
|
135
|
+
* rule gets only `allFiles`. The exclusion exists so rules that model the component contract
|
|
136
|
+
* never meet a tree that does not follow it; a rule asking a different question may opt out,
|
|
137
|
+
* and says so here.
|
|
138
|
+
*/
|
|
139
|
+
seesUnmaintained?: boolean;
|
|
114
140
|
}
|
|
115
141
|
/** Runs once per matching file. */
|
|
116
142
|
export interface FileRule extends BaseRule {
|
|
@@ -164,6 +190,8 @@ export interface DanglingRefInput {
|
|
|
164
190
|
configFile?: string;
|
|
165
191
|
/** Contents of `configFile`, so a stale key can be reported on its own line. */
|
|
166
192
|
configSource?: string;
|
|
193
|
+
/** Answers "does this repo-relative file exist?" — enables the allowlist-path staleness check. */
|
|
194
|
+
pathExists?: (path: string) => boolean;
|
|
167
195
|
}
|
|
168
196
|
/** An invariant a built-in ESLint rule already covers. */
|
|
169
197
|
export interface LinterOwnedRule {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* No dependencies — this runs in pre-commit, so it must not need an install.
|
|
6
6
|
*/
|
|
7
|
-
import type { DanglingRefInput, Finding, Level, Levels } from './types.js';
|
|
7
|
+
import type { DanglingRefInput, Finding, Level, Levels, MetaRuleId } from './types.js';
|
|
8
8
|
/**
|
|
9
9
|
* Minimal glob → RegExp. Supports `**`, `*`, `?` and `{a,b}`.
|
|
10
10
|
* Deliberately not a full glob implementation: rule scopes are simple by
|
|
@@ -37,14 +37,22 @@ export declare const parseIgnores: (source: string) => {
|
|
|
37
37
|
}[];
|
|
38
38
|
};
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* block comments and HTML comments.
|
|
40
|
+
* Blanks comment bodies in place: every line and every non-comment character stays where it was,
|
|
41
|
+
* so line numbers and offsets survive. Two grammars:
|
|
43
42
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* - script (TS/JS/JSON): a tokenizer that knows string literals, template literals with `${}`
|
|
44
|
+
* nesting and regex literals, so `'…/**'` in a glob or `'http://'` in a URL opens nothing. The
|
|
45
|
+
* regex version this replaces turned the first such glob in a rule file into an open block
|
|
46
|
+
* comment and hid the rest of the file from every rule that reads `code` (land-app, 2026-09-23);
|
|
47
|
+
* - the template part of a .vue file: HTML comments only — a script tokenizer over prose would take
|
|
48
|
+
* "child's" as an open string. Script blocks inside the SFC get the script grammar.
|
|
49
|
+
*
|
|
50
|
+
* Markdown and HTML get HTML comments only; no rule reads code out of them. The regex-vs-division
|
|
51
|
+
* call for `/` is the usual heuristic (what precedes it); a regex right after `)` reads as division,
|
|
52
|
+
* which leaves its body unparsed — never blanked, so the failure mode is a missed comment, not
|
|
53
|
+
* hidden code.
|
|
46
54
|
*/
|
|
47
|
-
export declare const stripCodeComments: (source: string) => string;
|
|
55
|
+
export declare const stripCodeComments: (source: string, file?: string) => string;
|
|
48
56
|
/** Line number (1-based) of a character offset. */
|
|
49
57
|
export declare const lineOf: (source: string, index: number) => number;
|
|
50
58
|
/**
|
|
@@ -67,6 +75,17 @@ export declare const stringKeysOf: (literal: string) => Set<string>;
|
|
|
67
75
|
* self-test can exercise it without touching the repo.
|
|
68
76
|
*/
|
|
69
77
|
export declare const findDanglingRuleRefs: (input: DanglingRefInput) => Finding[];
|
|
78
|
+
/**
|
|
79
|
+
* `invariant-ignore` comments that name a rule declared `noIgnore` (IG2). The
|
|
80
|
+
* runner never honours such a comment; reporting it makes the attempt visible so a
|
|
81
|
+
* reviewer sees the intent and an AI author learns the rule cannot be argued with
|
|
82
|
+
* in the file under check.
|
|
83
|
+
*/
|
|
84
|
+
export declare const findForbiddenSuppressions: (ignores: {
|
|
85
|
+
file: string;
|
|
86
|
+
line: number;
|
|
87
|
+
ids: string[];
|
|
88
|
+
}[], noIgnoreIds: Set<string>) => Finding[];
|
|
70
89
|
/**
|
|
71
90
|
* Severity of one finding, from three inputs that used to be one.
|
|
72
91
|
*
|
|
@@ -101,7 +120,7 @@ export declare const knownRuleIds: (parts: {
|
|
|
101
120
|
fileScan: string[];
|
|
102
121
|
specs: string[];
|
|
103
122
|
linter: string[];
|
|
104
|
-
meta?:
|
|
123
|
+
meta?: MetaRuleId[];
|
|
105
124
|
}) => Set<string>;
|
|
106
125
|
/**
|
|
107
126
|
* The severity override for a rule, or undefined.
|
|
@@ -111,3 +130,9 @@ export declare const knownRuleIds: (parts: {
|
|
|
111
130
|
* accessor keeps every caller from having to care.
|
|
112
131
|
*/
|
|
113
132
|
export declare const levelOverrideOf: (levels: Levels, id: string) => Level | undefined;
|
|
133
|
+
/**
|
|
134
|
+
* The `until YYYY-MM-DD` an allowlist reason may carry. A rule may require one — a coverage gap is
|
|
135
|
+
* a debt with a date, not a permanent exemption — and report the entry once the date has passed.
|
|
136
|
+
* ISO dates compare as strings, so the caller needs no Date arithmetic.
|
|
137
|
+
*/
|
|
138
|
+
export declare const exemptionExpiry: (reason: string) => string | null;
|
package/dist/invariants/util.js
CHANGED
|
@@ -135,19 +135,166 @@ export const parseIgnores = (source) => {
|
|
|
135
135
|
return { suppressions, unreasoned, comments };
|
|
136
136
|
};
|
|
137
137
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* block comments and HTML comments.
|
|
138
|
+
* Blanks comment bodies in place: every line and every non-comment character stays where it was,
|
|
139
|
+
* so line numbers and offsets survive. Two grammars:
|
|
141
140
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
141
|
+
* - script (TS/JS/JSON): a tokenizer that knows string literals, template literals with `${}`
|
|
142
|
+
* nesting and regex literals, so `'…/**'` in a glob or `'http://'` in a URL opens nothing. The
|
|
143
|
+
* regex version this replaces turned the first such glob in a rule file into an open block
|
|
144
|
+
* comment and hid the rest of the file from every rule that reads `code` (land-app, 2026-09-23);
|
|
145
|
+
* - the template part of a .vue file: HTML comments only — a script tokenizer over prose would take
|
|
146
|
+
* "child's" as an open string. Script blocks inside the SFC get the script grammar.
|
|
147
|
+
*
|
|
148
|
+
* Markdown and HTML get HTML comments only; no rule reads code out of them. The regex-vs-division
|
|
149
|
+
* call for `/` is the usual heuristic (what precedes it); a regex right after `)` reads as division,
|
|
150
|
+
* which leaves its body unparsed — never blanked, so the failure mode is a missed comment, not
|
|
151
|
+
* hidden code.
|
|
144
152
|
*/
|
|
145
|
-
export const stripCodeComments = (source) => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
export const stripCodeComments = (source, file = '') => {
|
|
154
|
+
if (/\.vue$/.test(file) || (file === '' && /<template[\s>]/.test(source) && /<script[\s>]/.test(source)))
|
|
155
|
+
return stripSfc(source);
|
|
156
|
+
if (/\.(?:md|html)$/.test(file))
|
|
157
|
+
return blankHtmlComments(source);
|
|
158
|
+
return stripScript(source);
|
|
159
|
+
};
|
|
160
|
+
const blankRun = (text) => text.replace(/[^\n]/g, ' ');
|
|
161
|
+
const blankHtmlComments = (source) => source.replace(/<!--[\s\S]*?-->/g, blankRun);
|
|
162
|
+
const stripSfc = (source) => {
|
|
163
|
+
let out = '';
|
|
164
|
+
let last = 0;
|
|
165
|
+
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g;
|
|
166
|
+
let m;
|
|
167
|
+
while ((m = re.exec(source)) !== null) {
|
|
168
|
+
const body = m[1] ?? '';
|
|
169
|
+
const bodyStart = m.index + m[0].length - body.length - '</script>'.length;
|
|
170
|
+
out += blankHtmlComments(source.slice(last, bodyStart));
|
|
171
|
+
out += stripScript(body);
|
|
172
|
+
last = bodyStart + body.length;
|
|
173
|
+
}
|
|
174
|
+
return out + blankHtmlComments(source.slice(last));
|
|
175
|
+
};
|
|
176
|
+
/** After one of these a `/` starts a regex literal, not a division. */
|
|
177
|
+
const REGEX_PRECEDER = /[(,=:[!&|?{};+\-*%<>~^]$/;
|
|
178
|
+
const REGEX_KEYWORD = /(?:^|[^\w$])(?:return|typeof|case|do|else|in|instanceof|new|throw|void|yield|await|delete)$/;
|
|
179
|
+
const stripScript = (code) => {
|
|
180
|
+
const out = code.split('');
|
|
181
|
+
const n = code.length;
|
|
182
|
+
const blankAt = (i) => {
|
|
183
|
+
if (out[i] !== '\n')
|
|
184
|
+
out[i] = ' ';
|
|
185
|
+
};
|
|
186
|
+
const regexAllowed = (i) => {
|
|
187
|
+
let j = i - 1;
|
|
188
|
+
while (j >= 0 && /\s/.test(out[j] ?? ''))
|
|
189
|
+
j--;
|
|
190
|
+
if (j < 0)
|
|
191
|
+
return true;
|
|
192
|
+
const before = out.slice(Math.max(0, j - 12), j + 1).join('');
|
|
193
|
+
return REGEX_PRECEDER.test(before) || REGEX_KEYWORD.test(before);
|
|
194
|
+
};
|
|
195
|
+
const skipString = (start, quote) => {
|
|
196
|
+
let i = start + 1;
|
|
197
|
+
while (i < n && code[i] !== quote && code[i] !== '\n') {
|
|
198
|
+
if (code[i] === '\\')
|
|
199
|
+
i++;
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
return i + 1;
|
|
203
|
+
};
|
|
204
|
+
const skipRegex = (start) => {
|
|
205
|
+
let i = start + 1;
|
|
206
|
+
let inClass = false;
|
|
207
|
+
while (i < n && code[i] !== '\n') {
|
|
208
|
+
const ch = code[i];
|
|
209
|
+
if (ch === '\\') {
|
|
210
|
+
i += 2;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (ch === '[')
|
|
214
|
+
inClass = true;
|
|
215
|
+
else if (ch === ']')
|
|
216
|
+
inClass = false;
|
|
217
|
+
else if (ch === '/' && !inClass) {
|
|
218
|
+
i++;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
while (i < n && /[a-z]/i.test(code[i] ?? ''))
|
|
224
|
+
i++;
|
|
225
|
+
return i;
|
|
226
|
+
};
|
|
227
|
+
// Returns the index it stopped at: `n`, or the `}` that closes a `${` when `untilBrace` is set.
|
|
228
|
+
const scan = (start, untilBrace) => {
|
|
229
|
+
let i = start;
|
|
230
|
+
let depth = 0;
|
|
231
|
+
while (i < n) {
|
|
232
|
+
const c = code[i];
|
|
233
|
+
const next = code[i + 1];
|
|
234
|
+
if (c === '"' || c === '\'') {
|
|
235
|
+
i = skipString(i, c);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (c === '`') {
|
|
239
|
+
i = skipTemplate(i);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (c === '/' && next === '/') {
|
|
243
|
+
while (i < n && code[i] !== '\n') {
|
|
244
|
+
blankAt(i);
|
|
245
|
+
i++;
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (c === '/' && next === '*') {
|
|
250
|
+
blankAt(i);
|
|
251
|
+
blankAt(i + 1);
|
|
252
|
+
i += 2;
|
|
253
|
+
while (i < n && !(code[i] === '*' && code[i + 1] === '/')) {
|
|
254
|
+
blankAt(i);
|
|
255
|
+
i++;
|
|
256
|
+
}
|
|
257
|
+
if (i < n) {
|
|
258
|
+
blankAt(i);
|
|
259
|
+
blankAt(i + 1);
|
|
260
|
+
i += 2;
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (c === '/' && regexAllowed(i)) {
|
|
265
|
+
i = skipRegex(i);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (untilBrace) {
|
|
269
|
+
if (c === '{')
|
|
270
|
+
depth++;
|
|
271
|
+
else if (c === '}') {
|
|
272
|
+
if (depth === 0)
|
|
273
|
+
return i;
|
|
274
|
+
depth--;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
i++;
|
|
278
|
+
}
|
|
279
|
+
return i;
|
|
280
|
+
};
|
|
281
|
+
const skipTemplate = (start) => {
|
|
282
|
+
let i = start + 1;
|
|
283
|
+
while (i < n && code[i] !== '`') {
|
|
284
|
+
if (code[i] === '\\') {
|
|
285
|
+
i += 2;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (code[i] === '$' && code[i + 1] === '{') {
|
|
289
|
+
i = scan(i + 2, true) + 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
i++;
|
|
293
|
+
}
|
|
294
|
+
return i + 1;
|
|
295
|
+
};
|
|
296
|
+
scan(0, false);
|
|
297
|
+
return out.join('');
|
|
151
298
|
};
|
|
152
299
|
/** Line number (1-based) of a character offset. */
|
|
153
300
|
export const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
|
@@ -214,6 +361,21 @@ export const findDanglingRuleRefs = (input) => {
|
|
|
214
361
|
continue;
|
|
215
362
|
out.push({ file: configFile, line: lineOfKey('levels', id), message: `levels names "${id}", but ${explain(id)} — the severity override does nothing; drop it` });
|
|
216
363
|
}
|
|
364
|
+
// Allowlist PATHS had no staleness guard: an entry whose file is gone (or renamed)
|
|
365
|
+
// is inert and silent. Found live in app-funnels — a dead BtnAuthGoogle.vue entry
|
|
366
|
+
// survived a fork divergence (2026-09-25). Checked only when the caller can answer
|
|
367
|
+
// existence, so the self-test can still exercise this function without a filesystem.
|
|
368
|
+
if (input.pathExists) {
|
|
369
|
+
for (const [id, entries] of Object.entries(allowlists)) {
|
|
370
|
+
if (!live.has(id))
|
|
371
|
+
continue; // the dead-id finding above already covers the whole entry
|
|
372
|
+
for (const path of Object.keys(entries ?? {})) {
|
|
373
|
+
if (input.pathExists(path))
|
|
374
|
+
continue;
|
|
375
|
+
out.push({ file: configFile, line: lineOfKey('allowlists', path), message: `allowlists["${id}"] names "${path}", but no such file exists — the exemption is inert; drop it or fix the path` });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
217
379
|
for (const { file, line, ids } of ignores) {
|
|
218
380
|
for (const id of ids) {
|
|
219
381
|
if (live.has(id))
|
|
@@ -223,6 +385,17 @@ export const findDanglingRuleRefs = (input) => {
|
|
|
223
385
|
}
|
|
224
386
|
return out;
|
|
225
387
|
};
|
|
388
|
+
/**
|
|
389
|
+
* `invariant-ignore` comments that name a rule declared `noIgnore` (IG2). The
|
|
390
|
+
* runner never honours such a comment; reporting it makes the attempt visible so a
|
|
391
|
+
* reviewer sees the intent and an AI author learns the rule cannot be argued with
|
|
392
|
+
* in the file under check.
|
|
393
|
+
*/
|
|
394
|
+
export const findForbiddenSuppressions = (ignores, noIgnoreIds) => ignores.flatMap(({ file, line, ids }) => ids.filter(id => noIgnoreIds.has(id)).map(id => ({
|
|
395
|
+
file,
|
|
396
|
+
line,
|
|
397
|
+
message: `invariant-ignore names "${id}", a noIgnore rule — this suppression is inert and the attempt is reported`,
|
|
398
|
+
})));
|
|
226
399
|
const LEVEL_RANK = { off: 0, warn: 1, error: 2 };
|
|
227
400
|
/**
|
|
228
401
|
* Severity of one finding, from three inputs that used to be one.
|
|
@@ -269,3 +442,9 @@ export const knownRuleIds = (parts) => new Set([...parts.fileScan, ...parts.spec
|
|
|
269
442
|
* accessor keeps every caller from having to care.
|
|
270
443
|
*/
|
|
271
444
|
export const levelOverrideOf = (levels, id) => levels[id]?.level;
|
|
445
|
+
/**
|
|
446
|
+
* The `until YYYY-MM-DD` an allowlist reason may carry. A rule may require one — a coverage gap is
|
|
447
|
+
* a debt with a date, not a permanent exemption — and report the entry once the date has passed.
|
|
448
|
+
* ISO dates compare as strings, so the caller needs no Date arithmetic.
|
|
449
|
+
*/
|
|
450
|
+
export const exemptionExpiry = (reason) => reason.match(/\buntil\s+(\d{4}-\d{2}-\d{2})\b/)?.[1] ?? null;
|
package/package.json
CHANGED