pr-checkmate 1.23.23 → 2.0.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.
- package/README.md +68 -15
- package/cspell.json +3 -8
- package/dist/cli.js +37 -62
- package/dist/config/constants.d.ts +21 -3
- package/dist/config/constants.js +33 -6
- package/dist/config/pr-checkmate-workflow.yml +33 -327
- package/dist/core/checks/dependencies/banned-imports.js +23 -24
- package/dist/core/checks/dependencies/circular-deps.js +1 -0
- package/dist/core/checks/dependencies/dependency-check.js +1 -0
- package/dist/core/checks/dependencies/grype-scan.js +1 -0
- package/dist/core/checks/dependencies/npm-audit.js +6 -1
- package/dist/core/checks/dependencies/vuln-scan.js +1 -0
- package/dist/core/checks/findings.d.ts +198 -19
- package/dist/core/checks/findings.js +474 -22
- package/dist/core/checks/git/custom-rules.js +21 -16
- package/dist/core/checks/git/diff-smell.d.ts +17 -0
- package/dist/core/checks/git/diff-smell.js +43 -24
- package/dist/core/checks/git/large-files.js +14 -2
- package/dist/core/checks/git/leftover-debug.js +23 -14
- package/dist/core/checks/git/lockfile-drift.js +2 -1
- package/dist/core/checks/git/merge-conflict.js +19 -8
- package/dist/core/checks/git/missing-tests.js +22 -6
- package/dist/core/checks/git/todo-fixme.js +10 -9
- package/dist/core/checks/languages/shellcheck.d.ts +8 -0
- package/dist/core/checks/languages/shellcheck.js +55 -0
- package/dist/core/checks/languages/typecheck.js +1 -0
- package/dist/core/checks/pr/pr-size-check.js +2 -1
- package/dist/core/checks/quality/config-validation.d.ts +14 -4
- package/dist/core/checks/quality/config-validation.js +119 -17
- package/dist/core/checks/quality/dead-code.js +1 -0
- package/dist/core/checks/quality/duplicate-check.js +1 -0
- package/dist/core/checks/quality/license-header.js +12 -2
- package/dist/core/checks/quality/spellcheck.d.ts +50 -0
- package/dist/core/checks/quality/spellcheck.js +141 -13
- package/dist/core/checks/quality/symlinks.d.ts +16 -0
- package/dist/core/checks/quality/symlinks.js +62 -0
- package/dist/core/checks/quality/yaml-lint.js +22 -9
- package/dist/core/checks/security/diff-security.js +111 -28
- package/dist/core/checks/security/dockerfile-security.js +23 -20
- package/dist/core/checks/security/migration-safety.js +19 -17
- package/dist/core/checks/security/sensitive-file-guard.js +37 -5
- package/dist/core/checks/security/workflow-security.d.ts +10 -0
- package/dist/core/checks/security/workflow-security.js +48 -26
- package/dist/core/commands.d.ts +27 -0
- package/dist/core/commands.js +91 -0
- package/dist/core/context.d.ts +12 -3
- package/dist/core/context.js +118 -16
- package/dist/core/install/cache.d.ts +39 -0
- package/dist/core/install/cache.js +99 -0
- package/dist/core/install/doctor.d.ts +35 -0
- package/dist/core/install/doctor.js +145 -0
- package/dist/core/install/ensure.d.ts +60 -0
- package/dist/core/install/ensure.js +169 -0
- package/dist/core/install/lock.d.ts +33 -0
- package/dist/core/install/lock.js +99 -0
- package/dist/core/install/profiles.d.ts +64 -0
- package/dist/core/install/profiles.js +170 -0
- package/dist/core/registry.js +4 -0
- package/dist/core/reporters.d.ts +13 -2
- package/dist/core/reporters.js +145 -104
- package/dist/core/run-checks.js +91 -14
- package/dist/core/targets.js +26 -4
- package/dist/core/types.d.ts +49 -1
- package/dist/init.d.ts +29 -14
- package/dist/init.js +204 -262
- package/dist/interfaces/index.d.ts +130 -0
- package/dist/scripts/generate-install-manifests.d.ts +39 -0
- package/dist/scripts/generate-install-manifests.js +121 -0
- package/dist/scripts/publish-github.js +18 -4
- package/dist/utils/detect-language.d.ts +37 -4
- package/dist/utils/detect-language.js +113 -19
- package/dist/utils/http.d.ts +59 -0
- package/dist/utils/http.js +119 -0
- package/dist/utils/logger.d.ts +52 -6
- package/dist/utils/logger.js +125 -33
- package/dist/utils/pool.d.ts +49 -0
- package/dist/utils/pool.js +124 -0
- package/dist/utils/update-notifier.d.ts +9 -0
- package/dist/utils/update-notifier.js +57 -3
- package/eslint.config.mjs +14 -2
- package/install-manifests/commitlint/package-lock.json +1028 -0
- package/install-manifests/commitlint/package.json +9 -0
- package/install-manifests/core/package-lock.json +1335 -0
- package/install-manifests/core/package.json +11 -0
- package/install-manifests/cpp/package-lock.json +29 -0
- package/install-manifests/cpp/package.json +8 -0
- package/install-manifests/duplicate/package-lock.json +113 -0
- package/install-manifests/duplicate/package.json +8 -0
- package/install-manifests/js/package-lock.json +5099 -0
- package/install-manifests/js/package.json +26 -0
- package/install-manifests/markdown/package-lock.json +1281 -0
- package/install-manifests/markdown/package.json +8 -0
- package/install-manifests/python/package-lock.json +21 -0
- package/install-manifests/python/package.json +8 -0
- package/install-manifests/spellcheck/package-lock.json +1047 -0
- package/install-manifests/spellcheck/package.json +8 -0
- package/package.json +13 -28
- package/pr-checkmate.schema.json +1070 -96
- package/src/config/cspell.json +3 -8
- package/src/config/pr-checkmate-workflow.yml +33 -327
- package/src/config/pr-checkmate.schema.json +1070 -96
package/dist/core/reporters.js
CHANGED
|
@@ -3,9 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildMarkdownReport = buildMarkdownReport;
|
|
6
7
|
exports.report = report;
|
|
7
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
9
|
const utils_1 = require("../utils");
|
|
10
|
+
const http_1 = require("../utils/http");
|
|
9
11
|
const STATUS_ICON = {
|
|
10
12
|
pass: '✅',
|
|
11
13
|
warn: '⚠️',
|
|
@@ -16,6 +18,9 @@ const STATUS_ICON = {
|
|
|
16
18
|
const COMMENT_MARKER = '<!-- pr-checkmate-summary -->';
|
|
17
19
|
const SUMMARY_BORDER = '═══════════════════════════════════════════════════════════════';
|
|
18
20
|
const SUMMARY_RULE = '───────────────────────────────────────────────────────────────';
|
|
21
|
+
/** Page size asked for everywhere; both GitHub and GitLab cap at 100. */
|
|
22
|
+
const PER_PAGE = 100;
|
|
23
|
+
const SKIPPED = { attempted: false, ok: true };
|
|
19
24
|
function getPrContext() {
|
|
20
25
|
const token = process.env.GITHUB_TOKEN;
|
|
21
26
|
const repository = process.env.GITHUB_REPOSITORY;
|
|
@@ -35,14 +40,28 @@ function getPrContext() {
|
|
|
35
40
|
return null;
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
|
-
|
|
43
|
+
/** Rows the client wants in the PR comment. */
|
|
44
|
+
function visibleResults(report, cfg) {
|
|
45
|
+
const include = cfg.include ?? {};
|
|
46
|
+
return report.results.filter(r => {
|
|
47
|
+
if (r.status === 'pass' && include.passed === false)
|
|
48
|
+
return false;
|
|
49
|
+
if (r.status === 'skip' && include.skipped === false)
|
|
50
|
+
return false;
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function buildMarkdownReport(report, cfg = {}) {
|
|
39
55
|
const { passed, warned, failed, skipped } = report.summary;
|
|
40
|
-
const
|
|
56
|
+
const include = cfg.include ?? {};
|
|
57
|
+
const rows = visibleResults(report, cfg);
|
|
58
|
+
const table = rows
|
|
41
59
|
.map(r => {
|
|
42
|
-
const details = r.details ? ` — ${r.details}` : '';
|
|
60
|
+
const details = r.details && include.details !== false ? ` — ${r.details}` : '';
|
|
43
61
|
return `| ${r.name} | ${STATUS_ICON[r.status]} ${r.status}${details} |`;
|
|
44
62
|
})
|
|
45
63
|
.join('\n');
|
|
64
|
+
const hidden = report.results.length - rows.length;
|
|
46
65
|
return [
|
|
47
66
|
COMMENT_MARKER,
|
|
48
67
|
'## PR CheckMate Summary',
|
|
@@ -51,144 +70,156 @@ function buildMarkdownReport(report) {
|
|
|
51
70
|
'',
|
|
52
71
|
'| Check | Result |',
|
|
53
72
|
'|---|---|',
|
|
54
|
-
|
|
73
|
+
table,
|
|
74
|
+
// Say so rather than letting the table quietly disagree with the counts above.
|
|
75
|
+
...(hidden > 0 ? ['', `_${hidden} row(s) hidden by \`reporter.include\`._`] : []),
|
|
55
76
|
'',
|
|
56
77
|
'_Generated by [PR CheckMate](https://www.npmjs.com/package/pr-checkmate)_',
|
|
57
78
|
].join('\n');
|
|
58
79
|
}
|
|
59
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The run summary, printed verbatim.
|
|
82
|
+
*
|
|
83
|
+
* `logger.print` rather than `logger.info`: this is the product's output, not a
|
|
84
|
+
* note about producing it. Sent through the log formatter, every line picked up
|
|
85
|
+
* an ISO timestamp and a level tag, which pushed the box borders out of
|
|
86
|
+
* alignment — the headline artefact of the whole run looked broken.
|
|
87
|
+
*/
|
|
88
|
+
function printStdout(report, cfg) {
|
|
60
89
|
const { passed, warned, failed, skipped } = report.summary;
|
|
61
|
-
utils_1.logger.
|
|
62
|
-
utils_1.logger.
|
|
63
|
-
utils_1.logger.
|
|
64
|
-
utils_1.logger.
|
|
90
|
+
utils_1.logger.print('');
|
|
91
|
+
utils_1.logger.print(SUMMARY_BORDER);
|
|
92
|
+
utils_1.logger.print(' PR CheckMate Summary');
|
|
93
|
+
utils_1.logger.print(SUMMARY_BORDER);
|
|
65
94
|
for (const r of report.results) {
|
|
66
95
|
const details = r.details ? `: ${r.details}` : '';
|
|
67
|
-
utils_1.logger.
|
|
96
|
+
utils_1.logger.print(` ${STATUS_ICON[r.status]} ${r.name}${details}`);
|
|
97
|
+
}
|
|
98
|
+
utils_1.logger.print(SUMMARY_RULE);
|
|
99
|
+
utils_1.logger.print(` ✅ ${passed} passed ⚠️ ${warned} warnings ❌ ${failed} failed ⏭️ ${skipped} skipped`);
|
|
100
|
+
utils_1.logger.print(SUMMARY_BORDER);
|
|
101
|
+
if (cfg.include?.docsLink !== false) {
|
|
102
|
+
// The docs cover every check and config key.
|
|
103
|
+
utils_1.logger.print(' DOCUMENTATION: https://pr-checkmate-docs.pages.dev/');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Network settings shared by every platform call. */
|
|
107
|
+
function httpOptions(cfg, label) {
|
|
108
|
+
return {
|
|
109
|
+
timeoutMs: cfg.timeoutMs,
|
|
110
|
+
retries: cfg.retries,
|
|
111
|
+
maxPages: cfg.maxPages,
|
|
112
|
+
label,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Log and shape the result of a write. */
|
|
116
|
+
function finish(platform, res, verb) {
|
|
117
|
+
if (res.ok) {
|
|
118
|
+
utils_1.logger.info(`[reporter]: ✅ ${verb} PR CheckMate summary on ${platform}`);
|
|
119
|
+
return { attempted: true, ok: true };
|
|
68
120
|
}
|
|
69
|
-
utils_1.logger.
|
|
70
|
-
|
|
71
|
-
utils_1.logger.info(SUMMARY_BORDER);
|
|
72
|
-
// Always shown under the report — the docs cover every check and config key.
|
|
73
|
-
utils_1.logger.info(' DOCUMENTATION: https://pr-checkmate-docs.pages.dev/');
|
|
121
|
+
utils_1.logger.warn(`[reporter]: ⚠️ Could not ${verb.toLowerCase()} ${platform} comment: ${res.error}`);
|
|
122
|
+
return { attempted: true, ok: false, error: res.error };
|
|
74
123
|
}
|
|
75
|
-
|
|
124
|
+
/** Page-number pagination: stop as soon as a page comes back short. */
|
|
125
|
+
function pageAfter(base, count, page) {
|
|
126
|
+
return count < PER_PAGE ? null : `${base}${base.includes('?') ? '&' : '?'}page=${page + 1}`;
|
|
127
|
+
}
|
|
128
|
+
async function upsertGithubComment(body, cfg) {
|
|
76
129
|
const ctx = getPrContext();
|
|
77
130
|
if (!ctx) {
|
|
78
131
|
utils_1.logger.info('[reporter]: Skipping GitHub comment (not in PR context or GITHUB_TOKEN missing)');
|
|
79
|
-
return;
|
|
132
|
+
return SKIPPED;
|
|
80
133
|
}
|
|
81
134
|
const { token, owner, repo, prNumber } = ctx;
|
|
82
135
|
const apiBase = `https://api.github.com/repos/${owner}/${repo}`;
|
|
136
|
+
const listBase = `${apiBase}/issues/${prNumber}/comments?per_page=${PER_PAGE}`;
|
|
83
137
|
const headers = {
|
|
84
138
|
Authorization: `Bearer ${token}`,
|
|
85
139
|
'User-Agent': 'pr-checkmate',
|
|
86
140
|
Accept: 'application/vnd.github.v3+json',
|
|
87
141
|
'Content-Type': 'application/json',
|
|
88
142
|
};
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (existing) {
|
|
95
|
-
await fetch(`${apiBase}/issues/comments/${existing.id}`, {
|
|
96
|
-
method: 'PATCH',
|
|
97
|
-
headers,
|
|
98
|
-
body: JSON.stringify({ body }),
|
|
99
|
-
});
|
|
100
|
-
utils_1.logger.info('[reporter]: ✅ Updated PR CheckMate comment on PR');
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
await fetch(`${apiBase}/issues/${prNumber}/comments`, {
|
|
105
|
-
method: 'POST',
|
|
106
|
-
headers,
|
|
107
|
-
body: JSON.stringify({ body }),
|
|
108
|
-
});
|
|
109
|
-
utils_1.logger.info('[reporter]: ✅ Posted PR CheckMate summary comment to PR');
|
|
110
|
-
}
|
|
111
|
-
catch (err) {
|
|
112
|
-
utils_1.logger.warn(`[reporter]: ⚠️ Could not post GitHub comment: ${err}`);
|
|
143
|
+
const opts = httpOptions(cfg, 'GitHub');
|
|
144
|
+
const existing = await findExisting(listBase, { headers }, b => (Array.isArray(b) ? b : []), c => typeof c.body === 'string' && c.body.includes(COMMENT_MARKER), opts);
|
|
145
|
+
if (existing.reuse && cfg.mode !== 'append') {
|
|
146
|
+
const res = await (0, http_1.request)(`${apiBase}/issues/comments/${existing.reuse.id}`, { method: 'PATCH', headers, body: JSON.stringify({ body }) }, opts);
|
|
147
|
+
return finish('GitHub', res, 'Updated');
|
|
113
148
|
}
|
|
149
|
+
const res = await (0, http_1.request)(`${apiBase}/issues/${prNumber}/comments`, { method: 'POST', headers, body: JSON.stringify({ body }) }, opts);
|
|
150
|
+
return finish('GitHub', res, 'Posted');
|
|
114
151
|
}
|
|
115
152
|
/**
|
|
116
|
-
* Upsert a GitLab MR note
|
|
117
|
-
*
|
|
118
|
-
*
|
|
153
|
+
* Upsert a GitLab MR note. Uses the standard CI variables plus a `GITLAB_TOKEN`
|
|
154
|
+
* with `api` scope. Skips gracefully when not in an MR pipeline or the token is
|
|
155
|
+
* missing.
|
|
119
156
|
*/
|
|
120
|
-
async function upsertGitlabNote(body) {
|
|
157
|
+
async function upsertGitlabNote(body, cfg) {
|
|
121
158
|
const base = process.env.CI_API_V4_URL;
|
|
122
159
|
const project = process.env.CI_PROJECT_ID;
|
|
123
160
|
const iid = process.env.CI_MERGE_REQUEST_IID;
|
|
124
161
|
const token = process.env.GITLAB_TOKEN;
|
|
125
162
|
if (!base || !project || !iid || !token) {
|
|
126
163
|
utils_1.logger.info('[reporter]: Skipping GitLab note (not an MR pipeline or GITLAB_TOKEN missing)');
|
|
127
|
-
return;
|
|
164
|
+
return SKIPPED;
|
|
128
165
|
}
|
|
129
166
|
const notes = `${base}/projects/${project}/merge_requests/${iid}/notes`;
|
|
130
167
|
const headers = { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' };
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await fetch(`${notes}/${existing.id}`, {
|
|
137
|
-
method: 'PUT',
|
|
138
|
-
headers,
|
|
139
|
-
body: JSON.stringify({ body }),
|
|
140
|
-
});
|
|
141
|
-
utils_1.logger.info('[reporter]: ✅ Updated PR CheckMate note on GitLab MR');
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
await fetch(notes, { method: 'POST', headers, body: JSON.stringify({ body }) });
|
|
146
|
-
utils_1.logger.info('[reporter]: ✅ Posted PR CheckMate summary to GitLab MR');
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
utils_1.logger.warn(`[reporter]: ⚠️ Could not post GitLab note: ${err}`);
|
|
168
|
+
const opts = httpOptions(cfg, 'GitLab');
|
|
169
|
+
const existing = await findExisting(`${notes}?per_page=${PER_PAGE}`, { headers }, b => (Array.isArray(b) ? b : []), n => typeof n.body === 'string' && n.body.includes(COMMENT_MARKER), opts);
|
|
170
|
+
if (existing.reuse && cfg.mode !== 'append') {
|
|
171
|
+
const res = await (0, http_1.request)(`${notes}/${existing.reuse.id}`, { method: 'PUT', headers, body: JSON.stringify({ body }) }, opts);
|
|
172
|
+
return finish('GitLab', res, 'Updated');
|
|
150
173
|
}
|
|
174
|
+
const res = await (0, http_1.request)(notes, { method: 'POST', headers, body: JSON.stringify({ body }) }, opts);
|
|
175
|
+
return finish('GitLab', res, 'Posted');
|
|
151
176
|
}
|
|
152
177
|
/**
|
|
153
|
-
* Upsert a Bitbucket PR comment
|
|
154
|
-
*
|
|
155
|
-
*
|
|
178
|
+
* Upsert a Bitbucket PR comment. Uses Bitbucket Pipelines variables plus a
|
|
179
|
+
* `BITBUCKET_TOKEN` (repo/PR write). Skips gracefully when not in a PR pipeline
|
|
180
|
+
* or the token is missing.
|
|
156
181
|
*/
|
|
157
|
-
async function upsertBitbucketComment(body) {
|
|
182
|
+
async function upsertBitbucketComment(body, cfg) {
|
|
158
183
|
const fullName = process.env.BITBUCKET_REPO_FULL_NAME;
|
|
159
184
|
const prId = process.env.BITBUCKET_PR_ID;
|
|
160
185
|
const token = process.env.BITBUCKET_TOKEN;
|
|
161
186
|
if (!fullName || !prId || !token) {
|
|
162
187
|
utils_1.logger.info('[reporter]: Skipping Bitbucket comment (not a PR pipeline or BITBUCKET_TOKEN missing)');
|
|
163
|
-
return;
|
|
188
|
+
return SKIPPED;
|
|
164
189
|
}
|
|
165
190
|
const commentsUrl = `https://api.bitbucket.org/2.0/repositories/${fullName}/pullrequests/${prId}/comments`;
|
|
166
191
|
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
method: 'PUT',
|
|
175
|
-
headers,
|
|
176
|
-
body: JSON.stringify({ content: { raw: body } }),
|
|
177
|
-
});
|
|
178
|
-
utils_1.logger.info('[reporter]: ✅ Updated PR CheckMate comment on Bitbucket PR');
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
await fetch(commentsUrl, {
|
|
183
|
-
method: 'POST',
|
|
184
|
-
headers,
|
|
185
|
-
body: JSON.stringify({ content: { raw: body } }),
|
|
186
|
-
});
|
|
187
|
-
utils_1.logger.info('[reporter]: ✅ Posted PR CheckMate summary to Bitbucket PR');
|
|
192
|
+
const opts = httpOptions(cfg, 'Bitbucket');
|
|
193
|
+
const existing = await findExisting(`${commentsUrl}?pagelen=${PER_PAGE}`, { headers }, b => b?.values ?? [], c => Boolean(c.content?.raw?.includes(COMMENT_MARKER)), opts,
|
|
194
|
+
// Bitbucket is cursor-paginated: the next page is a URL in the body.
|
|
195
|
+
b => b?.next ?? null);
|
|
196
|
+
if (existing.reuse && cfg.mode !== 'append') {
|
|
197
|
+
const res = await (0, http_1.request)(`${commentsUrl}/${existing.reuse.id}`, { method: 'PUT', headers, body: JSON.stringify({ content: { raw: body } }) }, opts);
|
|
198
|
+
return finish('Bitbucket', res, 'Updated');
|
|
188
199
|
}
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
const res = await (0, http_1.request)(commentsUrl, { method: 'POST', headers, body: JSON.stringify({ content: { raw: body } }) }, opts);
|
|
201
|
+
return finish('Bitbucket', res, 'Posted');
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Search every page of a comment thread for our own marker.
|
|
205
|
+
*
|
|
206
|
+
* Reading only the first page is what turned the upsert into an insert: with 30
|
|
207
|
+
* comments per page on GitHub and 20 on GitLab, an actively reviewed PR pushed
|
|
208
|
+
* the marker past the end and each new push added another summary.
|
|
209
|
+
*/
|
|
210
|
+
async function findExisting(listUrl, init, extract, matches, opts, cursorNext) {
|
|
211
|
+
const base = listUrl;
|
|
212
|
+
const result = await (0, http_1.paginate)(listUrl, init, extract, (body, page, count) => (cursorNext ? cursorNext(body) : pageAfter(base, count, page)), opts);
|
|
213
|
+
if (!result.ok) {
|
|
214
|
+
// A failed search is not a failed post: fall through and create a comment.
|
|
215
|
+
// A duplicate is a far smaller problem than no report at all.
|
|
216
|
+
utils_1.logger.warn(`[reporter]: ⚠️ Could not read existing comments (${result.error}) — will create`);
|
|
191
217
|
}
|
|
218
|
+
if (result.truncated) {
|
|
219
|
+
utils_1.logger.warn('[reporter]: ⚠️ Stopped searching comments at the page limit — a duplicate summary is ' +
|
|
220
|
+
'possible (raise reporter.maxPages)');
|
|
221
|
+
}
|
|
222
|
+
return { reuse: result.items.find(matches) ?? null };
|
|
192
223
|
}
|
|
193
224
|
/** Detect the CI platform from the environment, or null when unknown/local. */
|
|
194
225
|
function detectPlatform() {
|
|
@@ -201,26 +232,36 @@ function detectPlatform() {
|
|
|
201
232
|
return null;
|
|
202
233
|
}
|
|
203
234
|
/** Post the markdown report to a specific platform's PR/MR. */
|
|
204
|
-
async function postToPlatform(platform, result) {
|
|
205
|
-
const body = buildMarkdownReport(result);
|
|
235
|
+
async function postToPlatform(platform, result, cfg) {
|
|
236
|
+
const body = buildMarkdownReport(result, cfg);
|
|
206
237
|
if (platform === 'github')
|
|
207
|
-
return upsertGithubComment(body);
|
|
238
|
+
return upsertGithubComment(body, cfg);
|
|
208
239
|
if (platform === 'gitlab')
|
|
209
|
-
return upsertGitlabNote(body);
|
|
210
|
-
return upsertBitbucketComment(body);
|
|
240
|
+
return upsertGitlabNote(body, cfg);
|
|
241
|
+
return upsertBitbucketComment(body, cfg);
|
|
211
242
|
}
|
|
212
243
|
/**
|
|
213
244
|
* Emit the report through the selected channel.
|
|
214
245
|
* none: nothing · stdout: console table · github/gitlab/bitbucket: table +
|
|
215
246
|
* upsert the PR/MR comment · auto: detect the platform from the environment.
|
|
247
|
+
*
|
|
248
|
+
* Returns whether the comment reached the PR, so `runChecks` can honour
|
|
249
|
+
* `reporter.onPostFailure` — for teams whose review process depends on the
|
|
250
|
+
* comment, a silently lost report is worse than a red build.
|
|
216
251
|
*/
|
|
217
|
-
async function report(kind, result) {
|
|
252
|
+
async function report(kind, result, cfg = {}) {
|
|
218
253
|
if (kind === 'none')
|
|
219
|
-
return;
|
|
220
|
-
printStdout(result);
|
|
254
|
+
return SKIPPED;
|
|
255
|
+
printStdout(result, cfg);
|
|
221
256
|
if (result.results.length === 0)
|
|
222
|
-
return;
|
|
257
|
+
return SKIPPED;
|
|
258
|
+
// Nothing to say and nothing wrong: don't notify every PR subscriber.
|
|
259
|
+
if (cfg.skipWhenClean && result.summary.failed === 0 && result.summary.warned === 0) {
|
|
260
|
+
utils_1.logger.info('[reporter]: Nothing to report (skipWhenClean)');
|
|
261
|
+
return SKIPPED;
|
|
262
|
+
}
|
|
223
263
|
const platform = kind === 'auto' ? detectPlatform() : kind === 'stdout' ? null : kind;
|
|
224
|
-
if (platform)
|
|
225
|
-
|
|
264
|
+
if (!platform)
|
|
265
|
+
return SKIPPED;
|
|
266
|
+
return postToPlatform(platform, result, cfg);
|
|
226
267
|
}
|
package/dist/core/run-checks.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runChecks = runChecks;
|
|
4
4
|
const utils_1 = require("../utils");
|
|
5
|
+
const pool_1 = require("../utils/pool");
|
|
5
6
|
const result_collector_1 = require("../utils/result-collector");
|
|
6
7
|
const context_1 = require("./context");
|
|
7
8
|
const registry_1 = require("./registry");
|
|
@@ -27,13 +28,19 @@ function resolveChecks(opts) {
|
|
|
27
28
|
byName.set(c.name, c); // custom wins on name clash
|
|
28
29
|
return [...byName.values()];
|
|
29
30
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Emit the report via a preset channel or a custom reporter function.
|
|
33
|
+
*
|
|
34
|
+
* Returns whether delivery succeeded so the caller can honour
|
|
35
|
+
* `reporter.onPostFailure`. A custom function owns its own error handling, so it
|
|
36
|
+
* is always treated as delivered.
|
|
37
|
+
*/
|
|
38
|
+
async function emitReport(reporter, result, cfg) {
|
|
32
39
|
if (typeof reporter === 'function') {
|
|
33
40
|
await reporter(result);
|
|
34
|
-
return;
|
|
41
|
+
return { attempted: false, ok: true };
|
|
35
42
|
}
|
|
36
|
-
|
|
43
|
+
return (0, reporters_1.report)(reporter ?? 'stdout', result, cfg);
|
|
37
44
|
}
|
|
38
45
|
/** Apply only/skip filters to the resolved check set. */
|
|
39
46
|
function selectChecks(checks, opts) {
|
|
@@ -64,9 +71,34 @@ function applySeverity(name, status, ctx) {
|
|
|
64
71
|
return 'warn';
|
|
65
72
|
return status;
|
|
66
73
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Why a `requiresDiff` check could not run, phrased for the person reading the
|
|
76
|
+
* report. A full scan the client asked for is expected; one we fell back to is
|
|
77
|
+
* a configuration problem, and the message says which.
|
|
78
|
+
*/
|
|
79
|
+
function noDiffReason(ctx) {
|
|
80
|
+
const scope = ctx.scope;
|
|
81
|
+
if (scope?.degraded)
|
|
82
|
+
return `no diff range (${scope.reason}) — set fetch-depth: 0`;
|
|
83
|
+
return 'full scan — this check reviews a diff';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Run one check: gate on `applies`, execute, translate throws into `fail`.
|
|
87
|
+
*
|
|
88
|
+
* `heavy` admits only a couple of expensive checks at a time, and the per-check
|
|
89
|
+
* timeout means one that never returns costs a warning rather than the run.
|
|
90
|
+
*/
|
|
91
|
+
async function runOne(check, ctx, collector, heavy) {
|
|
69
92
|
try {
|
|
93
|
+
// A diff-only check without a range is a hole in coverage, not an
|
|
94
|
+
// irrelevance: report it as a skip so the gap is visible in the summary.
|
|
95
|
+
if (check.requiresDiff && !ctx.baseSha) {
|
|
96
|
+
const show = ctx.config.run?.reportSkipped ?? 'auto';
|
|
97
|
+
if (show !== 'never' && (await check.applies(ctx))) {
|
|
98
|
+
collector.register(check.name, 'skip', noDiffReason(ctx));
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
70
102
|
if (!(await check.applies(ctx))) {
|
|
71
103
|
// A check that doesn't apply is irrelevant to this project/run — e.g. a
|
|
72
104
|
// language-specific check (SwiftLint, ktlint, Ruff, C++) in a repo of a
|
|
@@ -76,7 +108,16 @@ async function runOne(check, ctx, collector) {
|
|
|
76
108
|
// from run() (e.g. "no relevant files changed") are still reported.
|
|
77
109
|
return;
|
|
78
110
|
}
|
|
79
|
-
const
|
|
111
|
+
const started = Date.now();
|
|
112
|
+
const timeoutMs = ctx.config.run?.checkTimeoutMs ?? 300000;
|
|
113
|
+
const execute = () => (0, pool_1.withTimeout)(check.run(ctx), timeoutMs, () => {
|
|
114
|
+
utils_1.logger.warn(`[runChecks]: ⏱️ ${check.name} exceeded ${timeoutMs}ms`);
|
|
115
|
+
return { status: 'warn', details: `timed out after ${timeoutMs}ms` };
|
|
116
|
+
});
|
|
117
|
+
const outcome = check.weight === 'heavy' ? await heavy.run(execute) : await execute();
|
|
118
|
+
if (ctx.config.run?.timings) {
|
|
119
|
+
utils_1.logger.info(`[runChecks]: ${check.name} took ${Date.now() - started}ms`);
|
|
120
|
+
}
|
|
80
121
|
collector.register(check.name, applySeverity(check.name, outcome.status, ctx), outcome.details);
|
|
81
122
|
}
|
|
82
123
|
catch (err) {
|
|
@@ -102,16 +143,52 @@ async function runChecks(opts = {}) {
|
|
|
102
143
|
const off = ctx.config.severity ?? {};
|
|
103
144
|
const selected = selectChecks(resolveChecks(opts), opts).filter(c => off[c.name] !== 'off');
|
|
104
145
|
const collector = new result_collector_1.ResultCollector();
|
|
146
|
+
// Config Validation verifies that `severity` keys name real checks. It reads
|
|
147
|
+
// the list from here rather than importing the registry, which would make
|
|
148
|
+
// registry → check → registry a cycle.
|
|
149
|
+
ctx.checkNames = resolveChecks(opts).map(c => c.name);
|
|
150
|
+
const limit = (0, pool_1.resolveConcurrency)(ctx.config.run?.concurrency);
|
|
151
|
+
const heavyLimit = Math.max(1, ctx.config.run?.heavyConcurrency ?? 1);
|
|
152
|
+
const heavy = new pool_1.Semaphore(heavyLimit);
|
|
105
153
|
for (const phase of types_1.PHASES) {
|
|
106
154
|
const inPhase = selected.filter(c => c.phase === phase);
|
|
107
|
-
await
|
|
155
|
+
await (0, pool_1.runPool)(inPhase.map(c => () => runOne(c, ctx, collector, heavy)), limit);
|
|
108
156
|
}
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
summary,
|
|
113
|
-
ok: summary.failed === 0,
|
|
157
|
+
reportDegradedScope(ctx, collector);
|
|
158
|
+
const build = () => {
|
|
159
|
+
const summary = collector.getSummary();
|
|
160
|
+
return { results: collector.getResults(), summary, ok: summary.failed === 0 };
|
|
114
161
|
};
|
|
115
|
-
|
|
162
|
+
const result = build();
|
|
163
|
+
const posted = await emitReport(opts.reporter, result, ctx.config.reporter ?? {});
|
|
164
|
+
// A summary that never reached the PR can matter as much as a finding. The row
|
|
165
|
+
// is added after the report was rendered — the printed table is what the run
|
|
166
|
+
// actually found — and the returned report carries it so the exit code can.
|
|
167
|
+
const onFailure = ctx.config.reporter?.onPostFailure ?? 'warn';
|
|
168
|
+
if (posted.attempted && !posted.ok && onFailure !== 'off') {
|
|
169
|
+
collector.register('Reporting', onFailure === 'error' ? 'fail' : 'warn', posted.error);
|
|
170
|
+
return build();
|
|
171
|
+
}
|
|
116
172
|
return result;
|
|
117
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Surface a scope that silently narrowed the run.
|
|
176
|
+
*
|
|
177
|
+
* Falling back to a full scan keeps the file-based checks working, but it drops
|
|
178
|
+
* every diff-only check — most of the security surface — and the report gives no
|
|
179
|
+
* sign of it. Outside CI a full scan is simply the default, so this stays quiet
|
|
180
|
+
* there; inside CI it means the range could not be built, which is a
|
|
181
|
+
* configuration problem the client should hear about.
|
|
182
|
+
*
|
|
183
|
+
* `run.onDegradedScope: "error"` turns it into a gate, for repos where losing
|
|
184
|
+
* the security checks silently is worse than a red build.
|
|
185
|
+
*/
|
|
186
|
+
function reportDegradedScope(ctx, collector) {
|
|
187
|
+
const mode = ctx.config.run?.onDegradedScope ?? 'warn';
|
|
188
|
+
if (mode === 'off' || !ctx.scope?.degraded || !(0, utils_1.isCiEnvironment)())
|
|
189
|
+
return;
|
|
190
|
+
const dropped = collector.getResults().filter(r => r.status === 'skip').length;
|
|
191
|
+
const detail = `${ctx.scope.reason}; ${dropped} diff-only check(s) did not run — set fetch-depth: 0`;
|
|
192
|
+
utils_1.logger.error(`[runChecks]: ${mode === 'error' ? '❌' : '⚠️'} Degraded scope: ${detail}`);
|
|
193
|
+
collector.register('Scan Scope', mode === 'error' ? 'fail' : 'warn', detail);
|
|
194
|
+
}
|
package/dist/core/targets.js
CHANGED
|
@@ -9,6 +9,7 @@ exports.listTrackedFiles = listTrackedFiles;
|
|
|
9
9
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
11
|
const config_1 = require("../config");
|
|
12
|
+
const loader_1 = require("../config/loader");
|
|
12
13
|
const utils_1 = require("../utils");
|
|
13
14
|
const execa_1 = require("../utils/execa");
|
|
14
15
|
/**
|
|
@@ -21,6 +22,27 @@ const execa_1 = require("../utils/execa");
|
|
|
21
22
|
* keeps the CI dirs (`.github`, `.gitlab`, `.circleci`), which are ignored
|
|
22
23
|
* globally but hold the workflow files a YAML linter most needs to see.
|
|
23
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* Drop files the client excluded by path.
|
|
27
|
+
*
|
|
28
|
+
* Separate from `ignoreDirs`, which matches a directory *name* on any segment:
|
|
29
|
+
* that cannot express "this one file" and cannot express "this one path" —
|
|
30
|
+
* `ignoreDirs: ["generated"]` silences every `generated/` in the repository, not
|
|
31
|
+
* the one that is actually generated. Globs answer both, and because this sits
|
|
32
|
+
* in the one function every file-scanning check calls, a client writes the rule
|
|
33
|
+
* once instead of hunting for the right key on each check.
|
|
34
|
+
*/
|
|
35
|
+
function excludeConfiguredPaths(files) {
|
|
36
|
+
let patterns = [];
|
|
37
|
+
try {
|
|
38
|
+
patterns = (0, loader_1.loadProjectConfig)().excludePaths ?? [];
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// A config that cannot be read is the loader's problem to report, not a
|
|
42
|
+
// reason to scan nothing.
|
|
43
|
+
}
|
|
44
|
+
return patterns.length > 0 ? files.filter(f => !(0, utils_1.matchesAnyGlob)(f, patterns)) : files;
|
|
45
|
+
}
|
|
24
46
|
function excludeIgnoredDirs(files, keepDirs = []) {
|
|
25
47
|
const ignored = new Set((0, config_1.getIgnoredDirs)());
|
|
26
48
|
for (const dir of keepDirs)
|
|
@@ -60,10 +82,10 @@ async function resolveTargetFiles(ctx, globs, opts = {}) {
|
|
|
60
82
|
stdout = (await (0, execa_1.execa)('git', args, { cwd: ctx.cwd, reject: false })).stdout;
|
|
61
83
|
ctx.cache?.set(cacheKey, stdout);
|
|
62
84
|
}
|
|
63
|
-
const files = excludeIgnoredDirs(stdout
|
|
85
|
+
const files = excludeConfiguredPaths(excludeIgnoredDirs(stdout
|
|
64
86
|
.split('\n')
|
|
65
87
|
.map(f => f.trim())
|
|
66
|
-
.filter(Boolean), opts.keepDirs).filter(f => node_fs_1.default.existsSync(node_path_1.default.join(ctx.cwd, f)));
|
|
88
|
+
.filter(Boolean), opts.keepDirs)).filter(f => node_fs_1.default.existsSync(node_path_1.default.join(ctx.cwd, f)));
|
|
67
89
|
utils_1.logger.info(`[targets]: ${ctx.baseSha ? '📂 Delta' : 'Full scan'}: ${files.length} file(s) for ${globs.join(', ')}`);
|
|
68
90
|
return files;
|
|
69
91
|
}
|
|
@@ -103,10 +125,10 @@ async function resolveScopedTargetFiles(ctx, globs, sourcePaths) {
|
|
|
103
125
|
async function listTrackedFiles(ctx, globs, sourcePaths) {
|
|
104
126
|
try {
|
|
105
127
|
const { stdout } = await (0, execa_1.execa)('git', ['ls-files', ...globs], { cwd: ctx.cwd, reject: false });
|
|
106
|
-
const files = excludeIgnoredDirs(stdout
|
|
128
|
+
const files = excludeConfiguredPaths(excludeIgnoredDirs(stdout
|
|
107
129
|
.split('\n')
|
|
108
130
|
.map(f => f.trim())
|
|
109
|
-
.filter(Boolean));
|
|
131
|
+
.filter(Boolean)));
|
|
110
132
|
return scopeToSourcePaths(files, sourcePaths);
|
|
111
133
|
}
|
|
112
134
|
catch (err) {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PRCheckMateConfig } from '../interfaces';
|
|
1
|
+
import type { PRCheckMateConfig, ReporterConfig } from '../interfaces';
|
|
2
2
|
import type { Language } from '../utils/detect-language';
|
|
3
3
|
import type { CheckResult, CheckStatus } from '../utils/result-collector';
|
|
4
4
|
export type { CheckResult, CheckStatus, Language };
|
|
@@ -8,6 +8,21 @@ export type { CheckResult, CheckStatus, Language };
|
|
|
8
8
|
*/
|
|
9
9
|
export type Phase = 'blocking' | 'informational' | 'format';
|
|
10
10
|
export declare const PHASES: readonly Phase[];
|
|
11
|
+
/**
|
|
12
|
+
* What this run is looking at, and whether that is what was asked for.
|
|
13
|
+
*
|
|
14
|
+
* `degraded` is the field that earns this its own type: a full scan requested by
|
|
15
|
+
* a human and a full scan we fell back to because the PR base was missing are
|
|
16
|
+
* indistinguishable in the results — both just omit every delta-only check — but
|
|
17
|
+
* only one of them is a problem worth telling anyone about.
|
|
18
|
+
*/
|
|
19
|
+
export interface RunScope {
|
|
20
|
+
kind: 'delta' | 'full' | 'staged';
|
|
21
|
+
/** Short explanation for the log line, e.g. `PR base unavailable`. */
|
|
22
|
+
reason?: string;
|
|
23
|
+
/** True when a diff range was wanted but could not be built. */
|
|
24
|
+
degraded: boolean;
|
|
25
|
+
}
|
|
11
26
|
/**
|
|
12
27
|
* Everything a check needs to run. Assembled once by `buildContext` and shared
|
|
13
28
|
* across all checks — no check reads `process.cwd()` or `process.env` directly.
|
|
@@ -24,6 +39,17 @@ export interface CheckContext {
|
|
|
24
39
|
/** Diff range for delta-aware checks; undefined → full-repo scan. */
|
|
25
40
|
baseSha?: string;
|
|
26
41
|
headSha?: string;
|
|
42
|
+
/** What this run is looking at, and why. Optional so hand-built contexts stay terse. */
|
|
43
|
+
scope?: RunScope;
|
|
44
|
+
/**
|
|
45
|
+
* Display names of the checks in this run, for the one check that reasons about
|
|
46
|
+
* the others (Config Validation, verifying `severity` keys).
|
|
47
|
+
*
|
|
48
|
+
* Passed down rather than imported: a check importing the registry would close
|
|
49
|
+
* the loop registry → check → registry, and it also lets custom checks supplied
|
|
50
|
+
* through `RunOptions.checks` be validated like built-in ones.
|
|
51
|
+
*/
|
|
52
|
+
checkNames?: string[];
|
|
27
53
|
/**
|
|
28
54
|
* Per-run memoization cache for identical git invocations (many checks diff
|
|
29
55
|
* the same range). Created once per `runChecks`; safe because git state is
|
|
@@ -45,12 +71,34 @@ export interface CheckOutcome {
|
|
|
45
71
|
export interface Check {
|
|
46
72
|
name: string;
|
|
47
73
|
phase: Phase;
|
|
74
|
+
/**
|
|
75
|
+
* The check reviews a diff and has nothing to say without one. Declared here
|
|
76
|
+
* rather than folded into `applies` so the orchestrator can tell the two kinds
|
|
77
|
+
* of "not applicable" apart:
|
|
78
|
+
*
|
|
79
|
+
* - a check that does not apply (SwiftLint in a Python repo) is noise, and is
|
|
80
|
+
* left out of the report entirely;
|
|
81
|
+
* - a check that *would* have applied but had no diff range is a gap in
|
|
82
|
+
* coverage, and is reported as a skip with the reason.
|
|
83
|
+
*
|
|
84
|
+
* Before this existed, both simply vanished — so a shallow clone removed most
|
|
85
|
+
* of the security surface and still printed a clean, complete-looking report.
|
|
86
|
+
*/
|
|
87
|
+
requiresDiff?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* The check spawns something expensive — a whole-project analyser like knip,
|
|
90
|
+
* jscpd, dpdm, tsc or grype. The orchestrator admits only a couple of these at
|
|
91
|
+
* once, independently of the overall concurrency limit: four regex scans in
|
|
92
|
+
* parallel are free, four project-wide analysers are not.
|
|
93
|
+
*/
|
|
94
|
+
weight?: 'heavy';
|
|
48
95
|
applies(ctx: CheckContext): boolean | Promise<boolean>;
|
|
49
96
|
run(ctx: CheckContext): Promise<CheckOutcome>;
|
|
50
97
|
}
|
|
51
98
|
export type ReporterKind = 'none' | 'stdout' | 'github' | 'gitlab' | 'bitbucket' | 'auto';
|
|
52
99
|
/** Custom reporter: receives the final report to emit however the consumer wants. */
|
|
53
100
|
export type ReporterFn = (report: CheckReport) => void | Promise<void>;
|
|
101
|
+
export type { ReporterConfig };
|
|
54
102
|
export interface RunOptions {
|
|
55
103
|
cwd?: string;
|
|
56
104
|
/** Override language detection (e.g. PRValidator passes its already-detected type). */
|