pr-checkmate 1.23.24 → 2.1.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.
Files changed (105) hide show
  1. package/README.md +89 -13
  2. package/cspell.json +11 -9
  3. package/dist/cli.js +37 -64
  4. package/dist/config/constants.d.ts +51 -3
  5. package/dist/config/constants.js +73 -11
  6. package/dist/config/pr-checkmate-workflow-docker.yml +79 -0
  7. package/dist/config/pr-checkmate-workflow.yml +65 -326
  8. package/dist/core/checks/dependencies/banned-imports.js +23 -24
  9. package/dist/core/checks/dependencies/circular-deps.js +1 -0
  10. package/dist/core/checks/dependencies/dependency-check.js +1 -0
  11. package/dist/core/checks/dependencies/grype-scan.js +1 -0
  12. package/dist/core/checks/dependencies/npm-audit.js +6 -1
  13. package/dist/core/checks/dependencies/vuln-scan.js +1 -0
  14. package/dist/core/checks/findings.d.ts +198 -19
  15. package/dist/core/checks/findings.js +474 -22
  16. package/dist/core/checks/git/custom-rules.js +21 -16
  17. package/dist/core/checks/git/diff-smell.d.ts +17 -0
  18. package/dist/core/checks/git/diff-smell.js +43 -24
  19. package/dist/core/checks/git/large-files.js +14 -2
  20. package/dist/core/checks/git/leftover-debug.js +23 -14
  21. package/dist/core/checks/git/lockfile-drift.js +2 -1
  22. package/dist/core/checks/git/merge-conflict.js +19 -8
  23. package/dist/core/checks/git/missing-tests.js +22 -6
  24. package/dist/core/checks/git/todo-fixme.js +10 -9
  25. package/dist/core/checks/languages/shellcheck.d.ts +1 -1
  26. package/dist/core/checks/languages/shellcheck.js +1 -1
  27. package/dist/core/checks/languages/typecheck.js +1 -0
  28. package/dist/core/checks/pr/pr-size-check.js +2 -1
  29. package/dist/core/checks/quality/config-validation.d.ts +14 -4
  30. package/dist/core/checks/quality/config-validation.js +119 -17
  31. package/dist/core/checks/quality/dead-code.js +1 -0
  32. package/dist/core/checks/quality/duplicate-check.js +1 -0
  33. package/dist/core/checks/quality/license-header.js +12 -2
  34. package/dist/core/checks/quality/markdown-lint.js +7 -2
  35. package/dist/core/checks/quality/spellcheck.d.ts +50 -0
  36. package/dist/core/checks/quality/spellcheck.js +141 -13
  37. package/dist/core/checks/quality/yaml-lint.js +22 -9
  38. package/dist/core/checks/security/diff-security.js +115 -28
  39. package/dist/core/checks/security/dockerfile-security.js +23 -20
  40. package/dist/core/checks/security/migration-safety.js +19 -17
  41. package/dist/core/checks/security/sensitive-file-guard.js +37 -5
  42. package/dist/core/checks/security/workflow-security.d.ts +10 -0
  43. package/dist/core/checks/security/workflow-security.js +48 -26
  44. package/dist/core/commands.d.ts +27 -0
  45. package/dist/core/commands.js +91 -0
  46. package/dist/core/context.d.ts +12 -3
  47. package/dist/core/context.js +118 -16
  48. package/dist/core/install/cache.d.ts +39 -0
  49. package/dist/core/install/cache.js +99 -0
  50. package/dist/core/install/doctor.d.ts +35 -0
  51. package/dist/core/install/doctor.js +145 -0
  52. package/dist/core/install/ensure.d.ts +60 -0
  53. package/dist/core/install/ensure.js +169 -0
  54. package/dist/core/install/lock.d.ts +33 -0
  55. package/dist/core/install/lock.js +99 -0
  56. package/dist/core/install/profiles.d.ts +64 -0
  57. package/dist/core/install/profiles.js +170 -0
  58. package/dist/core/reporters.d.ts +13 -2
  59. package/dist/core/reporters.js +145 -104
  60. package/dist/core/run-checks.d.ts +25 -1
  61. package/dist/core/run-checks.js +124 -14
  62. package/dist/core/targets.js +26 -4
  63. package/dist/core/types.d.ts +49 -1
  64. package/dist/init.d.ts +29 -14
  65. package/dist/init.js +284 -263
  66. package/dist/interfaces/index.d.ts +120 -0
  67. package/dist/scripts/copy-configs.js +4 -0
  68. package/dist/scripts/generate-install-manifests.d.ts +39 -0
  69. package/dist/scripts/generate-install-manifests.js +121 -0
  70. package/dist/scripts/publish-github.js +18 -4
  71. package/dist/utils/detect-language.d.ts +37 -4
  72. package/dist/utils/detect-language.js +113 -19
  73. package/dist/utils/http.d.ts +59 -0
  74. package/dist/utils/http.js +119 -0
  75. package/dist/utils/logger.d.ts +52 -6
  76. package/dist/utils/logger.js +125 -33
  77. package/dist/utils/pool.d.ts +49 -0
  78. package/dist/utils/pool.js +124 -0
  79. package/dist/utils/update-notifier.d.ts +9 -0
  80. package/dist/utils/update-notifier.js +57 -3
  81. package/eslint-default-rules.json +1 -1
  82. package/eslint.config.mjs +14 -2
  83. package/install-manifests/commitlint/package-lock.json +1028 -0
  84. package/install-manifests/commitlint/package.json +9 -0
  85. package/install-manifests/core/package-lock.json +1335 -0
  86. package/install-manifests/core/package.json +11 -0
  87. package/install-manifests/cpp/package-lock.json +29 -0
  88. package/install-manifests/cpp/package.json +8 -0
  89. package/install-manifests/duplicate/package-lock.json +113 -0
  90. package/install-manifests/duplicate/package.json +8 -0
  91. package/install-manifests/js/package-lock.json +5099 -0
  92. package/install-manifests/js/package.json +26 -0
  93. package/install-manifests/markdown/package-lock.json +1281 -0
  94. package/install-manifests/markdown/package.json +8 -0
  95. package/install-manifests/python/package-lock.json +21 -0
  96. package/install-manifests/python/package.json +8 -0
  97. package/install-manifests/spellcheck/package-lock.json +1047 -0
  98. package/install-manifests/spellcheck/package.json +8 -0
  99. package/package.json +14 -28
  100. package/pr-checkmate.schema.json +1062 -104
  101. package/src/config/cspell.json +11 -9
  102. package/src/config/eslint-default-rules.json +1 -1
  103. package/src/config/pr-checkmate-workflow-docker.yml +79 -0
  104. package/src/config/pr-checkmate-workflow.yml +65 -326
  105. package/src/config/pr-checkmate.schema.json +1062 -104
@@ -1,13 +1,27 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.severityEmitter = severityEmitter;
7
+ exports.mergeSeverityBuckets = mergeSeverityBuckets;
8
+ exports.emptyBuckets = emptyBuckets;
4
9
  exports.diffAddedLines = diffAddedLines;
10
+ exports.scanTrackedFiles = scanTrackedFiles;
11
+ exports.reviewLines = reviewLines;
12
+ exports.commentSyntaxFor = commentSyntaxFor;
5
13
  exports.stripInlineComment = stripInlineComment;
14
+ exports.stripStringLiterals = stripStringLiterals;
15
+ exports.scanDiffPatterns = scanDiffPatterns;
6
16
  exports.bucketTotal = bucketTotal;
7
- exports.logFindings = logFindings;
17
+ exports.reportCountedFindings = reportCountedFindings;
18
+ exports.clientFindingOptions = clientFindingOptions;
8
19
  exports.reportSeverityBuckets = reportSeverityBuckets;
20
+ const node_fs_1 = __importDefault(require("node:fs"));
21
+ const node_path_1 = __importDefault(require("node:path"));
9
22
  const utils_1 = require("../../utils");
10
23
  const execa_1 = require("../../utils/execa");
24
+ const targets_1 = require("../targets");
11
25
  const types_1 = require("../types");
12
26
  /**
13
27
  * Resolve a check's effective severity (`configured` from config, else its
@@ -22,6 +36,31 @@ function severityEmitter(configured, fallback) {
22
36
  emit: (details) => (isError ? (0, types_1.fail)(details) : (0, types_1.warn)(details)),
23
37
  };
24
38
  }
39
+ /** Append `from`'s samples to `into`, merging labels both sides produced. */
40
+ function mergeBucket(into, from) {
41
+ for (const [label, items] of Object.entries(from))
42
+ (into[label] ?? (into[label] = [])).push(...items);
43
+ }
44
+ /**
45
+ * Fold one scan's findings into a check's running totals.
46
+ *
47
+ * Checks accumulate across several scans — one per language, or one per file on
48
+ * the full-scan path — so every accumulating check needs this, and each had the
49
+ * same pair of nested loops written out inline.
50
+ */
51
+ function mergeSeverityBuckets(into, from) {
52
+ mergeBucket(into.errors, from.errors);
53
+ mergeBucket(into.warnings, from.warnings);
54
+ }
55
+ /** A fresh pair of empty buckets for a check to accumulate into. */
56
+ function emptyBuckets() {
57
+ return { errors: {}, warnings: {} };
58
+ }
59
+ /**
60
+ * Inline opt-out honoured by every content check, on both the diff path and the
61
+ * whole-file path — a line muted in a PR must stay muted in a full scan.
62
+ */
63
+ const IGNORE_DIRECTIVE = 'pr-checkmate-ignore';
25
64
  /**
26
65
  * Run `git diff` over the given file globs, returning the raw unified diff
27
66
  * (zero context, added/copied/modified/renamed files) for added-line scanning.
@@ -47,41 +86,398 @@ async function diffAddedLines(ctx, globs) {
47
86
  // diff honours it without repeating the check itself.
48
87
  return stdout
49
88
  .split('\n')
50
- .filter(line => !(line.startsWith('+') && line.includes('pr-checkmate-ignore')))
89
+ .filter(line => !(line.startsWith('+') && line.includes(IGNORE_DIRECTIVE)))
51
90
  .join('\n');
52
91
  }
53
92
  /**
54
- * Strip a trailing line comment (`//` or `#`) from a line, tracking quote state
55
- * so a marker inside a string literal (or a `#` in `"#fff"`, a `//` in a quoted
56
- * URL) is preserved. Heuristic, not a tokenizer: block comments and regex
57
- * literals aren't handled. Used by code-targeting checks so `# print(x)` or
58
- * `// eval(x)` in a comment isn't flagged as real code.
93
+ * Present tracked files as if every line had just been added, so a check written
94
+ * for a diff can review the whole repository unchanged.
95
+ *
96
+ * The patterns are anchored on the diff's `+` marker (`/^\+(?!\+)…/`), which is
97
+ * why `--full` used to skip most of the security surface: no diff, no lines
98
+ * starting with `+`, no findings. Rather than rewrite forty patterns — and lose
99
+ * the guarantee that both modes agree — the file contents are wrapped in the
100
+ * shape the pipeline already understands. Comment stripping, string blanking,
101
+ * `view: 'code'`, grouping and every client knob then work identically.
102
+ *
103
+ * Files are read one at a time and handed to `onFile` rather than concatenated:
104
+ * a monorepo would otherwise be assembled into a single multi-megabyte string
105
+ * before the first pattern ran.
106
+ */
107
+ async function scanTrackedFiles(ctx, files, onFile) {
108
+ for (const file of files) {
109
+ let content;
110
+ try {
111
+ content = node_fs_1.default.readFileSync(node_path_1.default.join(ctx.cwd, file), 'utf-8');
112
+ }
113
+ catch {
114
+ // Unreadable or vanished between listing and reading — nothing to review.
115
+ continue;
116
+ }
117
+ // Binary files have no lines worth matching and would produce nonsense
118
+ // samples in the report.
119
+ if (content.includes('\0'))
120
+ continue;
121
+ // The `pr-checkmate-ignore` opt-out has to be honoured here too. It is applied
122
+ // in `diffAddedLines` for the delta path, and a line muted in a PR that then
123
+ // reappears in the full scan would make the directive look unreliable.
124
+ const lines = content
125
+ .split('\n')
126
+ .filter(line => !line.includes(IGNORE_DIRECTIVE))
127
+ .map(line => `+${line}`);
128
+ // The `+++` header tells `scanDiffPatterns` which comment syntax applies;
129
+ // the `@@` hunk header is what makes line numbers come out 1-based for the
130
+ // checks that report them (Merge Conflict). Without it they start at zero.
131
+ onFile([`+++ b/${file}`, `@@ -0,0 +1,${lines.length} @@`, ...lines].join('\n'));
132
+ }
133
+ }
134
+ /**
135
+ * Hand a check the lines it should review, from the diff when there is one and
136
+ * from the tracked files otherwise.
137
+ *
138
+ * The single entry point every content check uses, so the choice between the two
139
+ * modes is made once rather than re-implemented per check — and so a check cannot
140
+ * accidentally support only one of them, which is how `--full` ended up with no
141
+ * security coverage at all (ticket 067).
142
+ *
143
+ * `onChunk` is called once per diff in delta mode and once per file in full mode,
144
+ * which keeps a whole monorepo from being concatenated before matching starts.
145
+ */
146
+ async function reviewLines(ctx, globs, onChunk) {
147
+ if (ctx.baseSha) {
148
+ onChunk(await diffAddedLines(ctx, globs));
149
+ return;
150
+ }
151
+ if (ctx.config.run?.fullScanBehaviour === 'skip')
152
+ return;
153
+ const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.']);
154
+ await scanTrackedFiles(ctx, files, onChunk);
155
+ }
156
+ const C_LIKE_EXTENSIONS = new Set([
157
+ 'ts',
158
+ 'tsx',
159
+ 'js',
160
+ 'jsx',
161
+ 'mjs',
162
+ 'cjs',
163
+ 'go',
164
+ 'java',
165
+ 'kt',
166
+ 'kts',
167
+ 'cs',
168
+ 'c',
169
+ 'cc',
170
+ 'cpp',
171
+ 'cxx',
172
+ 'h',
173
+ 'hpp',
174
+ 'php',
175
+ 'rs',
176
+ 'swift',
177
+ 'scala',
178
+ 'm',
179
+ 'mm',
180
+ ]);
181
+ const HASH_EXTENSIONS = new Set([
182
+ 'py',
183
+ 'pyi',
184
+ 'sh',
185
+ 'bash',
186
+ 'zsh',
187
+ 'ksh',
188
+ 'yml',
189
+ 'yaml',
190
+ 'rb',
191
+ 'toml',
192
+ 'tf',
193
+ 'pl',
194
+ 'r',
195
+ ]);
196
+ /** Comment syntax for a repo-relative path; `both` when the extension is unknown. */
197
+ function commentSyntaxFor(file) {
198
+ const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
199
+ if (C_LIKE_EXTENSIONS.has(ext))
200
+ return 'c-like';
201
+ if (HASH_EXTENSIONS.has(ext))
202
+ return 'hash';
203
+ return 'both';
204
+ }
205
+ /**
206
+ * True when the `//` at `i` opens a comment rather than a regex literal.
207
+ *
208
+ * A regex literal can contain `//` (`line.replace(/\/\//g, '')`), and treating
209
+ * it as a comment truncates the line — dropping whatever a security pattern
210
+ * would have matched further along. Regex literals only appear where a value is
211
+ * expected, so the preceding non-space character is enough to tell them apart
212
+ * without tokenizing.
213
+ */
214
+ function isCommentSlash(line, i) {
215
+ const before = line.slice(0, i).trimEnd();
216
+ if (before.length === 0)
217
+ return true;
218
+ // These all precede a value, so a `/` after one opens a regex literal.
219
+ // `;` and `}` are deliberately absent: they close a statement, where a
220
+ // trailing `// comment` is overwhelmingly the more likely reading.
221
+ if (/[(,=:[!&|?]$/.test(before))
222
+ return false;
223
+ return !/\breturn$/.test(before);
224
+ }
225
+ /** Returned by a {@link walkStringState} visitor to end the walk early. */
226
+ const STOP = Symbol('stop');
227
+ /**
228
+ * Walk `line` once, reporting for each character whether it sits inside a string
229
+ * literal.
230
+ *
231
+ * The bookkeeping — which quote opened the literal, and that a backslash escapes
232
+ * the closing one — is the part {@link stripInlineComment} and
233
+ * {@link stripStringLiterals} genuinely have in common; only what each does with
234
+ * the answer differs. Kept in one place so a correction to the escape handling
235
+ * cannot land in one of them and be forgotten in the other.
236
+ *
237
+ * A visitor may return an index to resume the walk from, which is how a caller
238
+ * skips wholesale past a regex literal, or {@link STOP} to end the walk.
59
239
  */
60
- function stripInlineComment(line) {
240
+ function walkStringState(line, visit) {
61
241
  let quote = '';
62
242
  for (let i = 0; i < line.length; i++) {
63
243
  const c = line[i];
64
- if (quote) {
65
- if (c === quote && line[i - 1] !== '\\')
66
- quote = '';
244
+ const closes = quote !== '' && c === quote && line[i - 1] !== '\\';
245
+ const opens = quote === '' && (c === '"' || c === "'" || c === '`');
246
+ const signal = visit({ c, i, inString: quote !== '', isDelimiter: closes || opens });
247
+ if (signal === STOP)
248
+ return;
249
+ if (typeof signal === 'number') {
250
+ i = signal;
67
251
  continue;
68
252
  }
69
- if (c === '"' || c === "'" || c === '`') {
253
+ if (closes)
254
+ quote = '';
255
+ else if (opens)
70
256
  quote = c;
257
+ }
258
+ }
259
+ /**
260
+ * Strip a trailing line comment from a line, tracking quote state so a marker
261
+ * inside a string literal (a `#` in `"#fff"`, a `//` in a quoted URL) survives.
262
+ *
263
+ * Heuristic, not a tokenizer. `syntax` selects the applicable markers — passing
264
+ * the wrong one is how a valid `this.#handler = eval(payload)` used to be read
265
+ * as a comment and dropped entirely (ticket 059).
266
+ *
267
+ * Block comments are handled by the caller, which alone can track `/* *\/` state
268
+ * across lines; see {@link scanDiffPatterns}.
269
+ */
270
+ function stripInlineComment(line, syntax = 'both') {
271
+ const hash = syntax !== 'c-like';
272
+ const slash = syntax !== 'hash';
273
+ let cut = -1;
274
+ walkStringState(line, ({ c, i, inString, isDelimiter }) => {
275
+ if (inString || isDelimiter)
276
+ return;
277
+ if (hash && c === '#') {
278
+ cut = i;
279
+ return STOP;
280
+ }
281
+ if (slash && c === '/') {
282
+ // A `/` where a value is expected opens a regex literal. Skipping past it
283
+ // wholesale is what handles the escaped slashes inside one — `/\/\//` ends
284
+ // in two literal `/` characters that would otherwise read as a comment.
285
+ if (!isCommentSlash(line, i))
286
+ return endOfRegexLiteral(line, i);
287
+ if (line[i + 1] === '/') {
288
+ cut = i;
289
+ return STOP;
290
+ }
291
+ }
292
+ });
293
+ return cut === -1 ? line : line.slice(0, cut);
294
+ }
295
+ /**
296
+ * Index of the closing `/` of the regex literal opening at `start`, or the end
297
+ * of the line when it never closes. Escapes and character classes both hide a
298
+ * `/` that does not terminate the literal.
299
+ */
300
+ function endOfRegexLiteral(line, start) {
301
+ let inClass = false;
302
+ for (let i = start + 1; i < line.length; i++) {
303
+ const c = line[i];
304
+ if (c === '\\') {
305
+ i++;
306
+ continue;
307
+ }
308
+ if (c === '[')
309
+ inClass = true;
310
+ else if (c === ']')
311
+ inClass = false;
312
+ else if (c === '/' && !inClass)
313
+ return i;
314
+ }
315
+ return line.length;
316
+ }
317
+ /**
318
+ * Blank out the contents of string literals, keeping the quotes and the original
319
+ * length so the rest of the line still reads as code.
320
+ *
321
+ * This is what lets a pattern ask "is a real call being made here" without a
322
+ * lookahead that guesses at quoting. The `eval` rule used to carry
323
+ * `(?![^'"`]*['"`])` — intended to skip `eval` written inside a string, but in
324
+ * practice it disabled the rule on any line containing a quote anywhere, so
325
+ * `eval(userInput + "x")` and ``eval(`${payload}`)`` both went unreported
326
+ * (ticket 058).
327
+ *
328
+ * Only for patterns that match *code*. Rules that look for string *contents* —
329
+ * an SQL keyword inside quotes, a hardcoded URL — must keep reading the raw
330
+ * line, which is what {@link DiffPattern.view} selects.
331
+ */
332
+ function stripStringLiterals(line) {
333
+ let out = '';
334
+ // Both quotes are kept and only what they enclose is blanked, so the line
335
+ // still reads as code — `f("x")` stays a call with an argument.
336
+ walkStringState(line, ({ c, inString, isDelimiter }) => {
337
+ out += inString && !isDelimiter ? ' ' : c;
338
+ });
339
+ return out;
340
+ }
341
+ /** Characters kept per logged sample, so one minified added line can't flood the log. */
342
+ const SAMPLE_MAX_LENGTH = 120;
343
+ /** `+++ b/path/to/file.ts` — the header that says which file follows. */
344
+ const DIFF_FILE_HEADER = /^\+\+\+ b\/(.+)$/;
345
+ /**
346
+ * Bucket added diff lines by the {@link DiffPattern}s they match, split by
347
+ * severity. Patterns are expected to anchor on the leading `+` themselves
348
+ * (`/^\+(?!\+)…/`), which is what keeps `+++` file headers and context lines out.
349
+ *
350
+ * Comment handling follows the file each hunk belongs to, read from the `+++`
351
+ * headers as the diff is walked — a single run reviews files of several
352
+ * languages, so a repo-wide setting would be wrong for most of them.
353
+ *
354
+ * `skipIgnored` re-applies the `pr-checkmate-ignore` opt-out for callers that run
355
+ * `git diff` themselves; a diff obtained from {@link diffAddedLines} is already
356
+ * filtered and should leave it off.
357
+ *
358
+ * `comments: 'off'` scans lines verbatim, which is how a client audits code that
359
+ * was commented out rather than deleted.
360
+ */
361
+ function scanDiffPatterns(stdout, patterns, opts = {}) {
362
+ const errors = {};
363
+ const warnings = {};
364
+ const mode = opts.comments ?? 'language';
365
+ const ignorePaths = opts.ignorePaths ?? [];
366
+ let syntax = 'both';
367
+ // Block comments span lines, so the state belongs to the walk, not to one line.
368
+ let inBlockComment = false;
369
+ // Whole files can be excluded by glob; the flag persists until the next header.
370
+ let skippingFile = false;
371
+ for (const line of stdout.split('\n')) {
372
+ const header = DIFF_FILE_HEADER.exec(line);
373
+ if (header) {
374
+ syntax = mode === 'language' ? commentSyntaxFor(header[1]) : 'both';
375
+ inBlockComment = false;
376
+ skippingFile = ignorePaths.length > 0 && (0, utils_1.matchesAnyGlob)(header[1], ignorePaths);
377
+ continue;
378
+ }
379
+ if (skippingFile)
380
+ continue;
381
+ // Only added lines carry block-comment state: a `-` line is code being
382
+ // removed, and letting it open a `/*` would blank the additions after it.
383
+ if (!line.startsWith('+'))
384
+ continue;
385
+ if (opts.skipIgnored && line.includes(IGNORE_DIRECTIVE))
386
+ continue;
387
+ const prepared = uncomment(line, { mode, syntax, inBlockComment });
388
+ inBlockComment = prepared.inBlockComment;
389
+ const asCode = stripStringLiterals(prepared.text);
390
+ for (const { pattern, label, severity, view } of patterns) {
391
+ if (pattern.test(view === 'code' ? asCode : prepared.text)) {
392
+ const bucket = severity === 'error' ? errors : warnings;
393
+ (bucket[label] ?? (bucket[label] = [])).push(line.slice(1).trim().substring(0, SAMPLE_MAX_LENGTH));
394
+ }
395
+ }
396
+ }
397
+ return { errors, warnings };
398
+ }
399
+ /**
400
+ * Reduce one added line to what should be matched against: block comments
401
+ * blanked (carrying the open state on), then the trailing line comment removed.
402
+ * `mode: 'off'` returns the line untouched.
403
+ */
404
+ function uncomment(line, state) {
405
+ const { mode, syntax } = state;
406
+ if (mode === 'off')
407
+ return { text: line, inBlockComment: false };
408
+ // `hash` languages have no `/* */`, and running the stripper over them would
409
+ // eat a legitimate `/*` appearing in, say, a shell glob.
410
+ if (syntax === 'hash') {
411
+ return { text: stripInlineComment(line, syntax), inBlockComment: false };
412
+ }
413
+ const { text, open } = stripBlockComments(line, state.inBlockComment);
414
+ return { text: stripInlineComment(text, syntax), inBlockComment: open };
415
+ }
416
+ /**
417
+ * Blank out `/* … *\/` regions, carrying the open state across lines. Without
418
+ * this a call commented out with a block comment reads as live code, and — worse
419
+ * — a line ending mid-comment leaves the rest of the hunk being scanned as if it
420
+ * were code.
421
+ */
422
+ function stripBlockComments(line, open) {
423
+ let out = '';
424
+ let i = 0;
425
+ let inside = open;
426
+ while (i < line.length) {
427
+ if (inside) {
428
+ const end = line.indexOf('*/', i);
429
+ if (end === -1)
430
+ return { text: out, open: true };
431
+ out += ' '.repeat(end + 2 - i);
432
+ i = end + 2;
433
+ inside = false;
71
434
  continue;
72
435
  }
73
- if (c === '#')
74
- return line.slice(0, i);
75
- if (c === '/' && line[i + 1] === '/')
76
- return line.slice(0, i);
436
+ const start = line.indexOf('/*', i);
437
+ if (start === -1) {
438
+ out += line.slice(i);
439
+ break;
440
+ }
441
+ out += line.slice(i, start);
442
+ i = start;
443
+ inside = true;
77
444
  }
78
- return line;
445
+ return { text: out, open: inside };
79
446
  }
80
447
  /** Total number of findings across all labels in a bucket. */
81
448
  function bucketTotal(bucket) {
82
449
  return Object.values(bucket).reduce((sum, arr) => sum + arr.length, 0);
83
450
  }
84
- /** Log one bucket of findings using the shared check format. */
451
+ /**
452
+ * Report a bucket at the check's configured level, summarized as
453
+ * `9× console.*(), 1× debugger statement`.
454
+ *
455
+ * The phrasing every keyword-counting check uses: the label already says what
456
+ * was found, so only the count varies. Written out per check, the four-step tail
457
+ * — resolve the level, log at it, join the counts, emit at the same level — was
458
+ * copied verbatim, and `isError: sev.icon === '❌'` is exactly the kind of link
459
+ * between two of those steps that silently rots when only one side is edited.
460
+ */
461
+ function reportCountedFindings(findings, ctx, opts) {
462
+ const sev = severityEmitter(opts.severity, opts.fallback ?? 'warn');
463
+ logFindings(findings, {
464
+ prefix: opts.prefix,
465
+ sampleLimit: opts.sampleLimit ?? ctx.config.log?.sampleLimit ?? 3,
466
+ isError: sev.icon === '❌',
467
+ });
468
+ const label = opts.summaryLabel ?? ((l) => l);
469
+ const summary = Object.entries(findings)
470
+ .map(([name, arr]) => `${arr.length}× ${label(name)}`)
471
+ .join(', ');
472
+ return sev.emit(summary + (opts.suffix ?? ''));
473
+ }
474
+ /**
475
+ * Log one bucket of findings using the shared check format.
476
+ *
477
+ * Internal: checks reach it through {@link reportCountedFindings} or
478
+ * {@link reportSeverityBuckets}, which pair the logging with the outcome so the
479
+ * two cannot report different levels.
480
+ */
85
481
  function logFindings(bucket, opts) {
86
482
  const emit = opts.isError ? utils_1.logger.error.bind(utils_1.logger) : utils_1.logger.warn.bind(utils_1.logger);
87
483
  const icon = opts.isError ? '❌' : '⚠️';
@@ -96,6 +492,25 @@ function logFindings(bucket, opts) {
96
492
  }
97
493
  }
98
494
  }
495
+ /**
496
+ * The half of {@link SeverityReportOptions} every check fills in identically:
497
+ * the client's own `<check>.ignore` / `failOn` / `severityOverrides`, the shared
498
+ * sample limit, and the repo-wide `severityDefaults` fallback.
499
+ *
500
+ * Only the phrasing (`prefix` and the two summaries) is ever check-specific, so
501
+ * spelling the rest out per check meant five lines of copy-paste that had to be
502
+ * kept in step by hand — and a check that forgot one line silently ignored a
503
+ * knob the client had set.
504
+ */
505
+ function clientFindingOptions(ctx, cfg = {}) {
506
+ return {
507
+ sampleLimit: ctx.config.log?.sampleLimit ?? 3,
508
+ ignore: cfg.ignore,
509
+ failOn: cfg.failOn,
510
+ severityOverrides: cfg.severityOverrides,
511
+ defaults: ctx.config.severityDefaults,
512
+ };
513
+ }
99
514
  /** Remove bucket entries whose label matches any ignore substring. */
100
515
  function dropIgnored(bucket, ignore) {
101
516
  if (ignore.length === 0)
@@ -108,14 +523,47 @@ function dropIgnored(bucket, ignore) {
108
523
  }
109
524
  return kept;
110
525
  }
526
+ /**
527
+ * Re-file findings according to the client's per-label overrides, returning
528
+ * fresh buckets. A label matching several overrides takes the longest (most
529
+ * specific) match, so a broad rule and a narrow exception can coexist.
530
+ */
531
+ function applyOverrides(errors, warnings, overrides) {
532
+ const entries = Object.entries(overrides).map(([k, v]) => [k.toLowerCase(), v]);
533
+ if (entries.length === 0)
534
+ return { errors, warnings };
535
+ const out = { errors: {}, warnings: {} };
536
+ const place = (label, items, fallback) => {
537
+ const lowered = label.toLowerCase();
538
+ const matches = entries.filter(([key]) => lowered.includes(key));
539
+ const best = matches.sort((a, b) => b[0].length - a[0].length)[0];
540
+ const level = best ? best[1] : fallback;
541
+ if (level === 'off')
542
+ return;
543
+ const bucket = level === 'error' ? out.errors : out.warnings;
544
+ (bucket[label] ?? (bucket[label] = [])).push(...items);
545
+ };
546
+ for (const [label, items] of Object.entries(errors))
547
+ place(label, items, 'error');
548
+ for (const [label, items] of Object.entries(warnings))
549
+ place(label, items, 'warn');
550
+ return out;
551
+ }
111
552
  /**
112
553
  * Report error/warning finding buckets: log both, then produce the outcome.
113
- * Returns pass() when both buckets are empty, otherwise warn() with a summary.
554
+ *
555
+ * Order is the contract, and each step exists because the one before it is too
556
+ * blunt on its own:
557
+ * 1. `ignore` — drop the finding outright.
558
+ * 2. `severityOverrides` — re-file what survived.
559
+ * 3. `failOn` — decide what a remaining error means for the run.
560
+ * The universal `severity[<check name>]` map is applied later still, by the
561
+ * orchestrator, so a client can always override a check wholesale by name.
114
562
  */
115
563
  function reportSeverityBuckets(errorsIn, warningsIn, opts) {
116
564
  const ignore = opts.ignore ?? [];
117
- const errors = dropIgnored(errorsIn, ignore);
118
- const warnings = dropIgnored(warningsIn, ignore);
565
+ const filed = applyOverrides(dropIgnored(errorsIn, ignore), dropIgnored(warningsIn, ignore), opts.severityOverrides ?? {});
566
+ const { errors, warnings } = filed;
119
567
  const totalErrors = bucketTotal(errors);
120
568
  const totalWarnings = bucketTotal(warnings);
121
569
  if (totalErrors === 0 && totalWarnings === 0)
@@ -123,10 +571,14 @@ function reportSeverityBuckets(errorsIn, warningsIn, opts) {
123
571
  const logOpts = { prefix: opts.prefix, showCount: opts.showCount, sampleLimit: opts.sampleLimit };
124
572
  logFindings(errors, { ...logOpts, isError: true });
125
573
  logFindings(warnings, { ...logOpts, isError: false });
574
+ const failOn = opts.failOn ?? opts.defaults?.failOn ?? 'error';
575
+ if (failOn === 'never')
576
+ return (0, types_1.pass)();
126
577
  const parts = [];
127
578
  if (totalErrors > 0)
128
579
  parts.push(opts.errorSummary(totalErrors));
129
580
  if (totalWarnings > 0)
130
581
  parts.push(opts.warningSummary(totalWarnings));
131
- return (0, types_1.warn)(parts.join(', '));
582
+ const summary = parts.join(', ');
583
+ return failOn === 'error' && totalErrors > 0 ? (0, types_1.fail)(summary) : (0, types_1.warn)(summary);
132
584
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.customRulesCheck = void 0;
4
4
  const utils_1 = require("../../../utils");
5
5
  const execa_1 = require("../../../utils/execa");
6
+ const targets_1 = require("../../targets");
6
7
  const types_1 = require("../../types");
7
8
  const findings_1 = require("../findings");
8
9
  /**
@@ -69,30 +70,34 @@ function scanDiff(stdout, rules) {
69
70
  exports.customRulesCheck = {
70
71
  name: 'Custom Rules',
71
72
  phase: 'informational',
73
+ // No `requiresDiff`: a project rule describes what the code should look like,
74
+ // not what this PR changed (ticket 067). A repo adopting a rule wants to know
75
+ // where it is already violated.
72
76
  applies(ctx) {
73
- return (ctx.config.customRules?.length ?? 0) > 0 && Boolean(ctx.baseSha);
77
+ return (ctx.config.customRules?.length ?? 0) > 0;
74
78
  },
75
79
  async run(ctx) {
76
80
  const rules = compileRules(ctx);
77
81
  if (rules.length === 0)
78
82
  return (0, types_1.pass)('no valid custom rules');
79
83
  try {
80
- const { stdout } = await (0, execa_1.execa)('git', ['diff', '--unified=0', '--diff-filter=ACMR', ctx.baseSha, ctx.headSha ?? 'HEAD'], { cwd: ctx.cwd });
81
- const { errors, warnings } = scanDiff(stdout, rules);
82
- const totalErrors = (0, findings_1.bucketTotal)(errors);
83
- const totalWarnings = (0, findings_1.bucketTotal)(warnings);
84
- if (totalErrors === 0 && totalWarnings === 0)
85
- return (0, types_1.pass)();
86
- (0, findings_1.logFindings)(errors, { prefix: PREFIX, isError: true, sampleLimit: 3 });
87
- (0, findings_1.logFindings)(warnings, { prefix: PREFIX, sampleLimit: 3 });
88
- const parts = [];
89
- if (totalErrors > 0)
90
- parts.push(`${totalErrors} error(s)`);
91
- if (totalWarnings > 0)
92
- parts.push(`${totalWarnings} warning(s)`);
93
- const summary = parts.join(', ');
84
+ const found = (0, findings_1.emptyBuckets)();
85
+ const collect = (stdout) => (0, findings_1.mergeSeverityBuckets)(found, scanDiff(stdout, rules));
86
+ if (ctx.baseSha) {
87
+ const { stdout } = await (0, execa_1.execa)('git', ['diff', '--unified=0', '--diff-filter=ACMR', ctx.baseSha, ctx.headSha ?? 'HEAD'], { cwd: ctx.cwd });
88
+ collect(stdout);
89
+ }
90
+ else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
91
+ const files = await (0, targets_1.listTrackedFiles)(ctx, ['*'], ['.']);
92
+ await (0, findings_1.scanTrackedFiles)(ctx, files, collect);
93
+ }
94
94
  // A rule with severity "error" blocks the run (fail), regardless of phase.
95
- return totalErrors > 0 ? (0, types_1.fail)(summary) : (0, types_1.warn)(summary);
95
+ return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
96
+ prefix: PREFIX,
97
+ errorSummary: n => `${n} error(s)`,
98
+ warningSummary: n => `${n} warning(s)`,
99
+ ...(0, findings_1.clientFindingOptions)(ctx),
100
+ });
96
101
  }
97
102
  catch (err) {
98
103
  utils_1.logger.warn(`[Custom Rules]: ⚠️ Could not analyze diff\n${err}`);
@@ -1,2 +1,19 @@
1
1
  import { Check } from '../../types';
2
+ import { DiffPattern } from '../findings';
3
+ /**
4
+ * Smells that are only smells in live code.
5
+ *
6
+ * `view: 'code'` blanks string literals before matching, which is what tells a
7
+ * `debugger` statement apart from the seven places an ESLint config names the
8
+ * rule that bans it — `'no-debugger': 'error'` was reported as a leftover
9
+ * `debugger` in every repo that configures the rule, this one included.
10
+ * Comment stripping (the default) covers the other half: prose *about* a
11
+ * `debugger` is not one.
12
+ */
13
+ export declare const CODE_SMELLS: DiffPattern[];
14
+ /**
15
+ * Markers that live *in* comments, so this set is scanned with comment handling
16
+ * off — stripping comments first would leave these patterns nothing to match.
17
+ */
18
+ export declare const COMMENT_SMELLS: DiffPattern[];
2
19
  export declare const diffSmellCheck: Check;