@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.
@@ -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
- * Blank out comment bodies while preserving every line number and offset, so a
41
- * rule that greps for an identifier does not fire on prose. Handles `//`,
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
- * Known limitation: a `//` inside a string literal (a URL) blanks the rest of
45
- * that line. Harmless for identifier scans; do not use this for parsing.
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?: string[];
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;
@@ -135,19 +135,166 @@ export const parseIgnores = (source) => {
135
135
  return { suppressions, unreasoned, comments };
136
136
  };
137
137
  /**
138
- * Blank out comment bodies while preserving every line number and offset, so a
139
- * rule that greps for an identifier does not fire on prose. Handles `//`,
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
- * Known limitation: a `//` inside a string literal (a URL) blanks the rest of
143
- * that line. Harmless for identifier scans; do not use this for parsing.
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
- const blank = (m) => m.replace(/[^\n]/g, ' ');
147
- return source
148
- .replace(/<!--[\s\S]*?-->/g, blank)
149
- .replace(/\/\*[\s\S]*?\*\//g, blank)
150
- .replace(/(^|[^:])\/\/[^\n]*/g, (m, pre) => pre + blank(m.slice(pre.length)));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brighterly/lib-fe-codestyle",
3
- "version": "0.3.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "description": "Shared invariant-validator engine, rule pack and ESLint config for Brighterly Nuxt frontend apps",