@brighterly/lib-fe-codestyle 0.4.1 → 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.
@@ -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;
@@ -238,6 +385,17 @@ export const findDanglingRuleRefs = (input) => {
238
385
  }
239
386
  return out;
240
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
+ })));
241
399
  const LEVEL_RANK = { off: 0, warn: 1, error: 2 };
242
400
  /**
243
401
  * Severity of one finding, from three inputs that used to be one.
@@ -284,3 +442,9 @@ export const knownRuleIds = (parts) => new Set([...parts.fileScan, ...parts.spec
284
442
  * accessor keeps every caller from having to care.
285
443
  */
286
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.4.1",
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",