pr-checkmate 3.1.0 → 3.2.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.
@@ -1,3 +1,4 @@
1
+ import type { GrypeScanEnvOverrides, PRCheckMateConfig } from '../../../interfaces/index.js';
1
2
  import { Check } from '../../types.js';
2
3
  /**
3
4
  * Dependency vulnerability scan via Anchore's `grype`. Non-blocking and ON by
@@ -19,4 +20,11 @@ import { Check } from '../../types.js';
19
20
  * installed `node_modules` is minutes of work whose every finding this check then
20
21
  * throws away as third-party.
21
22
  */
23
+ /**
24
+ * The effective grypeScan settings for this run: the top-level values, with the
25
+ * `ci` or `local` block layered on top depending on where we are running. Only
26
+ * keys actually set in the block win, so `local: { enabled: false }` leaves the
27
+ * severity and ignore list alone.
28
+ */
29
+ export declare function resolveGrypeScan(config: PRCheckMateConfig['grypeScan'], env?: NodeJS.ProcessEnv): GrypeScanEnvOverrides;
22
30
  export declare const grypeScanCheck: Check;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.grypeScanCheck = void 0;
4
+ exports.resolveGrypeScan = resolveGrypeScan;
4
5
  const execa_js_1 = require("../../../utils/execa.js");
5
6
  const index_js_1 = require("../../../utils/index.js");
6
7
  const types_js_1 = require("../../types.js");
@@ -43,17 +44,30 @@ function severityRank(severity) {
43
44
  * installed `node_modules` is minutes of work whose every finding this check then
44
45
  * throws away as third-party.
45
46
  */
47
+ /**
48
+ * The effective grypeScan settings for this run: the top-level values, with the
49
+ * `ci` or `local` block layered on top depending on where we are running. Only
50
+ * keys actually set in the block win, so `local: { enabled: false }` leaves the
51
+ * severity and ignore list alone.
52
+ */
53
+ function resolveGrypeScan(config, env = process.env) {
54
+ const { ci, local, ...base } = config ?? {};
55
+ const overrides = ((0, index_js_1.isCiEnvironment)(env) ? ci : local) ?? {};
56
+ const defined = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
57
+ return { ...base, ...defined };
58
+ }
46
59
  exports.grypeScanCheck = {
47
60
  name: 'Grype Scan',
48
61
  phase: 'informational',
49
62
  weight: 'heavy',
50
63
  applies(ctx) {
51
- return ctx.config.grypeScan?.enabled !== false;
64
+ return resolveGrypeScan(ctx.config.grypeScan).enabled !== false;
52
65
  },
53
66
  async run(ctx) {
54
- const minSeverity = (ctx.config.grypeScan?.minSeverity ?? 'medium').toLowerCase();
67
+ const settings = resolveGrypeScan(ctx.config.grypeScan);
68
+ const minSeverity = (settings.minSeverity ?? 'medium').toLowerCase();
55
69
  const minRank = severityRank(minSeverity);
56
- const ignore = (ctx.config.grypeScan?.ignore ?? []).map(s => s.toLowerCase());
70
+ const ignore = (settings.ignore ?? []).map(s => s.toLowerCase());
57
71
  // Never walk `ignoreDirs` — the same contract every other check honours, and
58
72
  // here it is the difference between a usable check and an unusable one. A bare
59
73
  // `grype dir:.` catalogs every file under `node_modules`, which on this repo took
@@ -61,7 +75,7 @@ exports.grypeScanCheck = {
61
75
  // findings: grype resolves package versions from the lockfile, and installed
62
76
  // third-party code is not something the client could fix anyway (see below).
63
77
  const excludes = (ctx.config.ignoreDirs ?? []).flatMap(dir => ['--exclude', `**/${dir}/**`]);
64
- const timeout = ctx.config.grypeScan?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
78
+ const timeout = settings.timeoutMs ?? DEFAULT_TIMEOUT_MS;
65
79
  const result = await (0, execa_js_1.execa)('grype', ['dir:.', '-o', 'json', '-q', ...excludes], {
66
80
  cwd: ctx.cwd,
67
81
  reject: false,
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
36
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
4
  };
@@ -69,7 +36,7 @@ const GO_GLOBS = ['*.go'];
69
36
  async function loadBundledGofmt() {
70
37
  try {
71
38
  const entry = (0, index_js_1.resolvePackageBin)('@wasm-fmt/gofmt', 'gofmt_node.js');
72
- const mod = (await Promise.resolve(`${(0, node_url_1.pathToFileURL)(entry).href}`).then(s => __importStar(require(s))));
39
+ const mod = (await import((0, node_url_1.pathToFileURL)(entry).href));
73
40
  return mod.format;
74
41
  }
75
42
  catch (err) {
@@ -5,10 +5,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.markdownLintCheck = void 0;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
- const node_os_1 = __importDefault(require("node:os"));
9
8
  const node_path_1 = __importDefault(require("node:path"));
9
+ const node_url_1 = require("node:url");
10
10
  const index_js_1 = require("../../../config/index.js");
11
- const execa_js_1 = require("../../../utils/execa.js");
12
11
  const index_js_2 = require("../../../utils/index.js");
13
12
  const targets_js_1 = require("../../targets.js");
14
13
  const types_js_1 = require("../../types.js");
@@ -24,12 +23,66 @@ function loadUserMarkdownConfig(cwd) {
24
23
  return {};
25
24
  }
26
25
  }
27
- function buildMergedConfig(bundledConfigPath, userRules) {
28
- const bundled = JSON.parse(node_fs_1.default.readFileSync(bundledConfigPath, 'utf-8'));
29
- const merged = { ...bundled, ...userRules };
30
- const tmpFile = node_path_1.default.join(node_os_1.default.tmpdir(), `.markdownlint-merged-${process.pid}.json`);
31
- node_fs_1.default.writeFileSync(tmpFile, JSON.stringify(merged, null, 2));
32
- return tmpFile;
26
+ /**
27
+ * Load the markdownlint library, or `null` when it cannot be found.
28
+ *
29
+ * The library rather than `markdownlint-cli2`: the CLI drags in globby →
30
+ * micromatch → braces, which carries an unpatched advisory every client's
31
+ * `npm audit` then reports against pr-checkmate. We already have the file list
32
+ * from git, so the CLI's globbing bought nothing.
33
+ *
34
+ * Resolved through `resolvePackageBin` and loaded by a dynamic `import()` for the
35
+ * same two reasons as the bundled gofmt: the package may live in a fetched
36
+ * profile directory, and it is ESM while we emit CommonJS.
37
+ */
38
+ async function loadMarkdownlint() {
39
+ try {
40
+ const promiseEntry = (0, index_js_2.resolvePackageBin)('markdownlint', 'lib/exports-promise.mjs');
41
+ const mainEntry = node_path_1.default.join(node_path_1.default.dirname(promiseEntry), 'exports.mjs');
42
+ const promise = (await import((0, node_url_1.pathToFileURL)(promiseEntry).href));
43
+ const main = (await import((0, node_url_1.pathToFileURL)(mainEntry).href));
44
+ return { lint: promise.lint, readConfig: promise.readConfig, applyFixes: main.applyFixes };
45
+ }
46
+ catch (err) {
47
+ index_js_2.logger.warn(`[Markdown]: ⚠️ markdownlint could not be loaded\n${err}`);
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * The rules to lint with: the repository's own config when it has one, ours
53
+ * (plus the client's `markdownlint.rules`) when it does not. Someone with a
54
+ * `.markdownlint.json` has an opinion; merging ours into it would silently
55
+ * override the rules they wrote.
56
+ */
57
+ async function readLintConfig(lib, cwd, userRules) {
58
+ const local = ['.markdownlint.json', '.markdownlint.jsonc'].find(name => (0, index_js_1.hasLocalConfig)([name], cwd));
59
+ if (local)
60
+ return lib.readConfig(node_path_1.default.join(cwd, local));
61
+ if ((0, index_js_1.hasLocalConfig)(['.markdownlint.yaml', '.markdownlint.yml'], cwd)) {
62
+ index_js_2.logger.warn('[Markdown]: ⚠️ YAML markdownlint configs are not supported, using bundled rules');
63
+ }
64
+ const bundled = await lib.readConfig((0, index_js_1.getPackageConfigPath)('.markdownlint.json'));
65
+ return { ...bundled, ...userRules };
66
+ }
67
+ /**
68
+ * An ignore pattern as a regular expression over a repo-relative path.
69
+ *
70
+ * A bare directory name excludes its contents; a file name matches exactly; `*`
71
+ * stays within a segment and `**` crosses them. `path.extname` is what tells a
72
+ * directory from a file, and it reads a leading dot as part of the name, so
73
+ * `.claude` is still the directory it is.
74
+ */
75
+ function toIgnoreRegExp(pattern) {
76
+ const clean = pattern.replace(/\/+$/, '');
77
+ const escaped = clean
78
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
79
+ .replace(/\*\*\//g, '\u0001')
80
+ .replace(/\*\*/g, '\u0000')
81
+ .replace(/\*/g, '[^/]*')
82
+ .replace(/\u0001/g, '(?:.*/)?')
83
+ .replace(/\u0000/g, '.*');
84
+ const isDir = !clean.includes('*') && !node_path_1.default.extname(clean);
85
+ return new RegExp(`^${escaped}${isDir ? '(/.*)?' : ''}$`);
33
86
  }
34
87
  exports.markdownLintCheck = {
35
88
  name: 'Markdown',
@@ -45,23 +98,10 @@ exports.markdownLintCheck = {
45
98
  return (0, types_js_1.skip)('no markdown files');
46
99
  const userConfig = loadUserMarkdownConfig(ctx.cwd);
47
100
  const userRules = userConfig.rules ?? {};
48
- let tempConfigFile = null;
49
- let configArg;
50
- if ((0, index_js_1.hasLocalConfig)(['.markdownlint.json', '.markdownlint.jsonc', '.markdownlint.yaml', '.markdownlint.yml'], ctx.cwd)) {
51
- configArg = [];
52
- }
53
- else {
54
- const bundledConfig = (0, index_js_1.getPackageConfigPath)('.markdownlint.json');
55
- if (Object.keys(userRules).length > 0) {
56
- tempConfigFile = buildMergedConfig(bundledConfig, userRules);
57
- configArg = ['--config', tempConfigFile];
58
- }
59
- else {
60
- configArg = ['--config', bundledConfig];
61
- }
62
- }
63
- const markdownlintBin = (0, index_js_2.resolvePackageBin)('markdownlint-cli2', 'markdownlint-cli2-bin.mjs');
64
- const defaultIgnorePatterns = [
101
+ const lib = await loadMarkdownlint();
102
+ if (!lib)
103
+ return (0, types_js_1.warn)('markdownlint failed to run (exit 2) — check the markdownlint config');
104
+ const ignores = [
65
105
  'node_modules/**',
66
106
  'dist/**',
67
107
  'coverage/**',
@@ -70,41 +110,38 @@ exports.markdownLintCheck = {
70
110
  // Release-tool output, not prose anyone edits — see GENERATED_DOC_FILES.
71
111
  ...index_js_1.GENERATED_DOC_FILES,
72
112
  ...(userConfig.ignores ?? []),
73
- ];
74
- // markdownlint-cli2 has no --ignore-path flag; exclusions are expressed as
75
- // negated globs (`#pattern`). A bare directory name also needs `#name/**`
76
- // to exclude its contents — but a filename does not, and `#CHANGELOG.md/**`
77
- // is a glob that can never match. `path.extname` is what tells them apart,
78
- // and it reads a leading dot as part of the name, so `.claude` is still
79
- // treated as the directory it is.
80
- const ignoreGlobs = defaultIgnorePatterns.flatMap(pattern => {
81
- const clean = pattern.replace(/\/+$/, '');
82
- const globs = [`#${clean}`];
83
- if (!clean.includes('*') && !node_path_1.default.extname(clean))
84
- globs.push(`#${clean}/**`);
85
- return globs;
86
- });
87
- const args = [...(ctx.write ? ['--fix'] : []), ...targets, ...ignoreGlobs, ...configArg];
113
+ ].map(toIgnoreRegExp);
114
+ const files = targets.filter(file => !ignores.some(re => re.test(file)));
115
+ if (files.length === 0)
116
+ return (0, types_js_1.skip)('no markdown files');
88
117
  try {
89
- const result = await (0, execa_js_1.execa)(process.execPath, [markdownlintBin, ...args], {
90
- cwd: ctx.cwd,
91
- reject: false,
92
- stdio: 'inherit',
93
- });
94
- if (result.exitCode === 0)
95
- return (0, types_js_1.pass)();
96
- // markdownlint-cli2 exits 1 for lint findings and 2 when it could not run
97
- // (an unreadable config, a bad glob). Reporting the latter as "markdown lint
98
- // issues found" names the wrong cause — and under `severity: error` it
99
- // becomes a gate on a tooling problem.
100
- if (result.exitCode === 2) {
101
- return (0, types_js_1.warn)('markdownlint failed to run (exit 2) — check the markdownlint config');
118
+ const config = await readLintConfig(lib, ctx.cwd, userRules);
119
+ const results = await lib.lint({ files: files.map(f => node_path_1.default.resolve(ctx.cwd, f)), config });
120
+ let findings = 0;
121
+ for (const [file, errors] of Object.entries(results)) {
122
+ if (errors.length === 0)
123
+ continue;
124
+ const rel = node_path_1.default.relative(ctx.cwd, file);
125
+ let remaining = errors;
126
+ if (ctx.write && errors.some(e => e.fixInfo)) {
127
+ const fixed = lib.applyFixes(node_fs_1.default.readFileSync(file, 'utf-8'), errors);
128
+ node_fs_1.default.writeFileSync(file, fixed);
129
+ remaining = errors.filter(e => !e.fixInfo);
130
+ }
131
+ for (const e of remaining) {
132
+ findings++;
133
+ const col = e.errorRange?.[0] ? `:${e.errorRange[0]}` : '';
134
+ index_js_2.logger.warn(`${rel}:${e.lineNumber}${col} ${e.ruleNames.join('/')} ${e.ruleDescription}` +
135
+ (e.errorDetail ? ` [${e.errorDetail}]` : ''));
136
+ }
102
137
  }
103
- return (0, types_js_1.warn)('markdown lint issues found');
138
+ return findings === 0 ? (0, types_js_1.pass)() : (0, types_js_1.warn)('markdown lint issues found');
104
139
  }
105
- finally {
106
- if (tempConfigFile && node_fs_1.default.existsSync(tempConfigFile))
107
- node_fs_1.default.unlinkSync(tempConfigFile);
140
+ catch (err) {
141
+ // An unreadable config or a file that vanished: the tool could not run,
142
+ // which is not the same claim as "the prose has problems".
143
+ index_js_2.logger.warn(`[Markdown]: ⚠️ ${err}`);
144
+ return (0, types_js_1.warn)('markdownlint failed to run (exit 2) — check the markdownlint config');
108
145
  }
109
146
  },
110
147
  };
@@ -91,7 +91,7 @@ exports.PROFILES = [
91
91
  },
92
92
  {
93
93
  name: 'markdown',
94
- packages: ['markdownlint-cli2'],
94
+ packages: ['markdownlint'],
95
95
  checks: ['Markdown'],
96
96
  },
97
97
  {
@@ -369,6 +369,13 @@ export interface PRCheckMateConfig {
369
369
  * can never stall a commit.
370
370
  */
371
371
  timeoutMs?: number;
372
+ /**
373
+ * Overrides applied only on a CI build (see `isCiEnvironment`). Any key set here wins
374
+ * over the top-level value, so CI can keep the scan while local runs turn it off.
375
+ */
376
+ ci?: GrypeScanEnvOverrides;
377
+ /** Same as `ci`, applied only outside CI (a developer's machine, `be-commit`, hooks). */
378
+ local?: GrypeScanEnvOverrides;
372
379
  };
373
380
  bannedImports?: Array<{
374
381
  name: string;
@@ -430,3 +437,10 @@ export interface PRCheckMateConfig {
430
437
  }
431
438
  export type { CheckStatus, CheckResult } from '../utils/result-collector.js';
432
439
  export type { Language } from '../utils/detect-language.js';
440
+ /** Per-environment overrides for `grypeScan`: the subset of its settings that may differ by environment. */
441
+ export interface GrypeScanEnvOverrides {
442
+ enabled?: boolean;
443
+ minSeverity?: 'negligible' | 'low' | 'medium' | 'high' | 'critical';
444
+ ignore?: string[];
445
+ timeoutMs?: number;
446
+ }
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.execa = execa;
37
4
  /**
@@ -41,6 +8,6 @@ exports.execa = execa;
41
8
  * this package — all of which await the result and read it synchronously.
42
9
  */
43
10
  async function execa(file, args, options) {
44
- const { execa: run } = await Promise.resolve().then(() => __importStar(require('execa')));
11
+ const { execa: run } = await import('execa');
45
12
  return (await run(file, args, options));
46
13
  }
@@ -1,3 +1,4 @@
1
+ import type { GrypeScanEnvOverrides, PRCheckMateConfig } from '../../../interfaces/index.js';
1
2
  import { Check } from '../../types.js';
2
3
  /**
3
4
  * Dependency vulnerability scan via Anchore's `grype`. Non-blocking and ON by
@@ -19,4 +20,11 @@ import { Check } from '../../types.js';
19
20
  * installed `node_modules` is minutes of work whose every finding this check then
20
21
  * throws away as third-party.
21
22
  */
23
+ /**
24
+ * The effective grypeScan settings for this run: the top-level values, with the
25
+ * `ci` or `local` block layered on top depending on where we are running. Only
26
+ * keys actually set in the block win, so `local: { enabled: false }` leaves the
27
+ * severity and ignore list alone.
28
+ */
29
+ export declare function resolveGrypeScan(config: PRCheckMateConfig['grypeScan'], env?: NodeJS.ProcessEnv): GrypeScanEnvOverrides;
22
30
  export declare const grypeScanCheck: Check;
@@ -1,5 +1,5 @@
1
1
  import { execa } from '../../../utils/execa.js';
2
- import { collectDirectDeps, logger, normalizeName } from '../../../utils/index.js';
2
+ import { collectDirectDeps, isCiEnvironment, logger, normalizeName, } from '../../../utils/index.js';
3
3
  import { pass, skip, warn } from '../../types.js';
4
4
  import { notInstalled } from '../tool-install.js';
5
5
  const SEVERITY_ORDER = ['negligible', 'low', 'medium', 'high', 'critical'];
@@ -40,17 +40,30 @@ function severityRank(severity) {
40
40
  * installed `node_modules` is minutes of work whose every finding this check then
41
41
  * throws away as third-party.
42
42
  */
43
+ /**
44
+ * The effective grypeScan settings for this run: the top-level values, with the
45
+ * `ci` or `local` block layered on top depending on where we are running. Only
46
+ * keys actually set in the block win, so `local: { enabled: false }` leaves the
47
+ * severity and ignore list alone.
48
+ */
49
+ export function resolveGrypeScan(config, env = process.env) {
50
+ const { ci, local, ...base } = config ?? {};
51
+ const overrides = (isCiEnvironment(env) ? ci : local) ?? {};
52
+ const defined = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
53
+ return { ...base, ...defined };
54
+ }
43
55
  export const grypeScanCheck = {
44
56
  name: 'Grype Scan',
45
57
  phase: 'informational',
46
58
  weight: 'heavy',
47
59
  applies(ctx) {
48
- return ctx.config.grypeScan?.enabled !== false;
60
+ return resolveGrypeScan(ctx.config.grypeScan).enabled !== false;
49
61
  },
50
62
  async run(ctx) {
51
- const minSeverity = (ctx.config.grypeScan?.minSeverity ?? 'medium').toLowerCase();
63
+ const settings = resolveGrypeScan(ctx.config.grypeScan);
64
+ const minSeverity = (settings.minSeverity ?? 'medium').toLowerCase();
52
65
  const minRank = severityRank(minSeverity);
53
- const ignore = (ctx.config.grypeScan?.ignore ?? []).map(s => s.toLowerCase());
66
+ const ignore = (settings.ignore ?? []).map(s => s.toLowerCase());
54
67
  // Never walk `ignoreDirs` — the same contract every other check honours, and
55
68
  // here it is the difference between a usable check and an unusable one. A bare
56
69
  // `grype dir:.` catalogs every file under `node_modules`, which on this repo took
@@ -58,7 +71,7 @@ export const grypeScanCheck = {
58
71
  // findings: grype resolves package versions from the lockfile, and installed
59
72
  // third-party code is not something the client could fix anyway (see below).
60
73
  const excludes = (ctx.config.ignoreDirs ?? []).flatMap(dir => ['--exclude', `**/${dir}/**`]);
61
- const timeout = ctx.config.grypeScan?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
74
+ const timeout = settings.timeoutMs ?? DEFAULT_TIMEOUT_MS;
62
75
  const result = await execa('grype', ['dir:.', '-o', 'json', '-q', ...excludes], {
63
76
  cwd: ctx.cwd,
64
77
  reject: false,
@@ -1,8 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
4
  import { GENERATED_DOC_FILES, getPackageConfigPath, hasLocalConfig, } from '../../../config/index.js';
5
- import { execa } from '../../../utils/execa.js';
6
5
  import { logger, resolvePackageBin } from '../../../utils/index.js';
7
6
  import { resolveTargetFiles } from '../../targets.js';
8
7
  import { pass, warn, skip } from '../../types.js';
@@ -18,12 +17,66 @@ function loadUserMarkdownConfig(cwd) {
18
17
  return {};
19
18
  }
20
19
  }
21
- function buildMergedConfig(bundledConfigPath, userRules) {
22
- const bundled = JSON.parse(fs.readFileSync(bundledConfigPath, 'utf-8'));
23
- const merged = { ...bundled, ...userRules };
24
- const tmpFile = path.join(os.tmpdir(), `.markdownlint-merged-${process.pid}.json`);
25
- fs.writeFileSync(tmpFile, JSON.stringify(merged, null, 2));
26
- return tmpFile;
20
+ /**
21
+ * Load the markdownlint library, or `null` when it cannot be found.
22
+ *
23
+ * The library rather than `markdownlint-cli2`: the CLI drags in globby →
24
+ * micromatch → braces, which carries an unpatched advisory every client's
25
+ * `npm audit` then reports against pr-checkmate. We already have the file list
26
+ * from git, so the CLI's globbing bought nothing.
27
+ *
28
+ * Resolved through `resolvePackageBin` and loaded by a dynamic `import()` for the
29
+ * same two reasons as the bundled gofmt: the package may live in a fetched
30
+ * profile directory, and it is ESM while we emit CommonJS.
31
+ */
32
+ async function loadMarkdownlint() {
33
+ try {
34
+ const promiseEntry = resolvePackageBin('markdownlint', 'lib/exports-promise.mjs');
35
+ const mainEntry = path.join(path.dirname(promiseEntry), 'exports.mjs');
36
+ const promise = (await import(pathToFileURL(promiseEntry).href));
37
+ const main = (await import(pathToFileURL(mainEntry).href));
38
+ return { lint: promise.lint, readConfig: promise.readConfig, applyFixes: main.applyFixes };
39
+ }
40
+ catch (err) {
41
+ logger.warn(`[Markdown]: ⚠️ markdownlint could not be loaded\n${err}`);
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * The rules to lint with: the repository's own config when it has one, ours
47
+ * (plus the client's `markdownlint.rules`) when it does not. Someone with a
48
+ * `.markdownlint.json` has an opinion; merging ours into it would silently
49
+ * override the rules they wrote.
50
+ */
51
+ async function readLintConfig(lib, cwd, userRules) {
52
+ const local = ['.markdownlint.json', '.markdownlint.jsonc'].find(name => hasLocalConfig([name], cwd));
53
+ if (local)
54
+ return lib.readConfig(path.join(cwd, local));
55
+ if (hasLocalConfig(['.markdownlint.yaml', '.markdownlint.yml'], cwd)) {
56
+ logger.warn('[Markdown]: ⚠️ YAML markdownlint configs are not supported, using bundled rules');
57
+ }
58
+ const bundled = await lib.readConfig(getPackageConfigPath('.markdownlint.json'));
59
+ return { ...bundled, ...userRules };
60
+ }
61
+ /**
62
+ * An ignore pattern as a regular expression over a repo-relative path.
63
+ *
64
+ * A bare directory name excludes its contents; a file name matches exactly; `*`
65
+ * stays within a segment and `**` crosses them. `path.extname` is what tells a
66
+ * directory from a file, and it reads a leading dot as part of the name, so
67
+ * `.claude` is still the directory it is.
68
+ */
69
+ function toIgnoreRegExp(pattern) {
70
+ const clean = pattern.replace(/\/+$/, '');
71
+ const escaped = clean
72
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
73
+ .replace(/\*\*\//g, '\u0001')
74
+ .replace(/\*\*/g, '\u0000')
75
+ .replace(/\*/g, '[^/]*')
76
+ .replace(/\u0001/g, '(?:.*/)?')
77
+ .replace(/\u0000/g, '.*');
78
+ const isDir = !clean.includes('*') && !path.extname(clean);
79
+ return new RegExp(`^${escaped}${isDir ? '(/.*)?' : ''}$`);
27
80
  }
28
81
  export const markdownLintCheck = {
29
82
  name: 'Markdown',
@@ -39,23 +92,10 @@ export const markdownLintCheck = {
39
92
  return skip('no markdown files');
40
93
  const userConfig = loadUserMarkdownConfig(ctx.cwd);
41
94
  const userRules = userConfig.rules ?? {};
42
- let tempConfigFile = null;
43
- let configArg;
44
- if (hasLocalConfig(['.markdownlint.json', '.markdownlint.jsonc', '.markdownlint.yaml', '.markdownlint.yml'], ctx.cwd)) {
45
- configArg = [];
46
- }
47
- else {
48
- const bundledConfig = getPackageConfigPath('.markdownlint.json');
49
- if (Object.keys(userRules).length > 0) {
50
- tempConfigFile = buildMergedConfig(bundledConfig, userRules);
51
- configArg = ['--config', tempConfigFile];
52
- }
53
- else {
54
- configArg = ['--config', bundledConfig];
55
- }
56
- }
57
- const markdownlintBin = resolvePackageBin('markdownlint-cli2', 'markdownlint-cli2-bin.mjs');
58
- const defaultIgnorePatterns = [
95
+ const lib = await loadMarkdownlint();
96
+ if (!lib)
97
+ return warn('markdownlint failed to run (exit 2) — check the markdownlint config');
98
+ const ignores = [
59
99
  'node_modules/**',
60
100
  'dist/**',
61
101
  'coverage/**',
@@ -64,41 +104,38 @@ export const markdownLintCheck = {
64
104
  // Release-tool output, not prose anyone edits — see GENERATED_DOC_FILES.
65
105
  ...GENERATED_DOC_FILES,
66
106
  ...(userConfig.ignores ?? []),
67
- ];
68
- // markdownlint-cli2 has no --ignore-path flag; exclusions are expressed as
69
- // negated globs (`#pattern`). A bare directory name also needs `#name/**`
70
- // to exclude its contents — but a filename does not, and `#CHANGELOG.md/**`
71
- // is a glob that can never match. `path.extname` is what tells them apart,
72
- // and it reads a leading dot as part of the name, so `.claude` is still
73
- // treated as the directory it is.
74
- const ignoreGlobs = defaultIgnorePatterns.flatMap(pattern => {
75
- const clean = pattern.replace(/\/+$/, '');
76
- const globs = [`#${clean}`];
77
- if (!clean.includes('*') && !path.extname(clean))
78
- globs.push(`#${clean}/**`);
79
- return globs;
80
- });
81
- const args = [...(ctx.write ? ['--fix'] : []), ...targets, ...ignoreGlobs, ...configArg];
107
+ ].map(toIgnoreRegExp);
108
+ const files = targets.filter(file => !ignores.some(re => re.test(file)));
109
+ if (files.length === 0)
110
+ return skip('no markdown files');
82
111
  try {
83
- const result = await execa(process.execPath, [markdownlintBin, ...args], {
84
- cwd: ctx.cwd,
85
- reject: false,
86
- stdio: 'inherit',
87
- });
88
- if (result.exitCode === 0)
89
- return pass();
90
- // markdownlint-cli2 exits 1 for lint findings and 2 when it could not run
91
- // (an unreadable config, a bad glob). Reporting the latter as "markdown lint
92
- // issues found" names the wrong cause — and under `severity: error` it
93
- // becomes a gate on a tooling problem.
94
- if (result.exitCode === 2) {
95
- return warn('markdownlint failed to run (exit 2) — check the markdownlint config');
112
+ const config = await readLintConfig(lib, ctx.cwd, userRules);
113
+ const results = await lib.lint({ files: files.map(f => path.resolve(ctx.cwd, f)), config });
114
+ let findings = 0;
115
+ for (const [file, errors] of Object.entries(results)) {
116
+ if (errors.length === 0)
117
+ continue;
118
+ const rel = path.relative(ctx.cwd, file);
119
+ let remaining = errors;
120
+ if (ctx.write && errors.some(e => e.fixInfo)) {
121
+ const fixed = lib.applyFixes(fs.readFileSync(file, 'utf-8'), errors);
122
+ fs.writeFileSync(file, fixed);
123
+ remaining = errors.filter(e => !e.fixInfo);
124
+ }
125
+ for (const e of remaining) {
126
+ findings++;
127
+ const col = e.errorRange?.[0] ? `:${e.errorRange[0]}` : '';
128
+ logger.warn(`${rel}:${e.lineNumber}${col} ${e.ruleNames.join('/')} ${e.ruleDescription}` +
129
+ (e.errorDetail ? ` [${e.errorDetail}]` : ''));
130
+ }
96
131
  }
97
- return warn('markdown lint issues found');
132
+ return findings === 0 ? pass() : warn('markdown lint issues found');
98
133
  }
99
- finally {
100
- if (tempConfigFile && fs.existsSync(tempConfigFile))
101
- fs.unlinkSync(tempConfigFile);
134
+ catch (err) {
135
+ // An unreadable config or a file that vanished: the tool could not run,
136
+ // which is not the same claim as "the prose has problems".
137
+ logger.warn(`[Markdown]: ⚠️ ${err}`);
138
+ return warn('markdownlint failed to run (exit 2) — check the markdownlint config');
102
139
  }
103
140
  },
104
141
  };
@@ -86,7 +86,7 @@ export const PROFILES = [
86
86
  },
87
87
  {
88
88
  name: 'markdown',
89
- packages: ['markdownlint-cli2'],
89
+ packages: ['markdownlint'],
90
90
  checks: ['Markdown'],
91
91
  },
92
92
  {