pr-checkmate 2.1.2 → 3.0.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.
- package/README.md +63 -268
- package/cspell.json +470 -447
- package/dist/cli.js +1 -1
- package/dist/config/constants.d.ts +6 -1
- package/dist/config/constants.js +9 -7
- package/dist/config/index.d.ts +23 -11
- package/dist/config/index.js +27 -33
- package/dist/core/checks/dependencies/banned-imports.js +4 -1
- package/dist/core/checks/dependencies/circular-deps.js +14 -5
- package/dist/core/checks/dependencies/dependency-check.js +1 -1
- package/dist/core/checks/dependencies/grype-scan.js +2 -1
- package/dist/core/checks/dependencies/vuln-scan.js +2 -1
- package/dist/core/checks/findings.d.ts +46 -2
- package/dist/core/checks/findings.js +140 -2
- package/dist/core/checks/git/custom-rules.js +2 -0
- package/dist/core/checks/git/diff-smell.js +6 -1
- package/dist/core/checks/git/leftover-debug.js +8 -2
- package/dist/core/checks/git/lockfile-drift.js +6 -1
- package/dist/core/checks/git/merge-conflict.js +1 -1
- package/dist/core/checks/git/missing-tests.js +1 -1
- package/dist/core/checks/git/todo-fixme.js +10 -2
- package/dist/core/checks/languages/cpp-format.js +19 -42
- package/dist/core/checks/languages/external-checks.d.ts +77 -0
- package/dist/core/checks/languages/external-checks.js +200 -0
- package/dist/core/checks/languages/external-runner.d.ts +119 -0
- package/dist/core/checks/languages/external-runner.js +143 -0
- package/dist/core/checks/languages/go-format.d.ts +4 -4
- package/dist/core/checks/languages/go-format.js +151 -30
- package/dist/core/checks/languages/lint.js +19 -19
- package/dist/core/checks/languages/prettier.js +2 -7
- package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
- package/dist/core/checks/languages/python-typecheck.js +161 -37
- package/dist/core/checks/languages/ruff-shared.d.ts +3 -3
- package/dist/core/checks/languages/ruff-shared.js +27 -4
- package/dist/core/checks/languages/typecheck.js +10 -8
- package/dist/core/checks/pr/commitlint.js +7 -1
- package/dist/core/checks/quality/case-collision.js +6 -4
- package/dist/core/checks/quality/config-validation.d.ts +0 -7
- package/dist/core/checks/quality/config-validation.js +30 -0
- package/dist/core/checks/quality/coverage.js +6 -1
- package/dist/core/checks/quality/dead-code.js +12 -3
- package/dist/core/checks/quality/duplicate-check.js +54 -27
- package/dist/core/checks/quality/license-header.js +7 -4
- package/dist/core/checks/quality/markdown-lint.js +1 -6
- package/dist/core/checks/quality/package-size.d.ts +15 -0
- package/dist/core/checks/quality/package-size.js +130 -0
- package/dist/core/checks/quality/spellcheck.js +34 -5
- package/dist/core/checks/quality/symlinks.js +6 -4
- package/dist/core/checks/security/diff-security.js +26 -18
- package/dist/core/checks/security/dockerfile-security.js +7 -1
- package/dist/core/checks/security/migration-safety.js +32 -4
- package/dist/core/checks/security/security.d.ts +56 -0
- package/dist/core/checks/security/security.js +135 -11
- package/dist/core/checks/security/sensitive-file-guard.js +40 -6
- package/dist/core/checks/security/workflow-security.js +7 -1
- package/dist/core/checks/tool-install.d.ts +102 -0
- package/dist/core/checks/tool-install.js +69 -0
- package/dist/core/commands.js +1 -0
- package/dist/core/context.js +9 -1
- package/dist/core/install/cache.d.ts +20 -1
- package/dist/core/install/cache.js +65 -12
- package/dist/core/install/doctor.js +49 -15
- package/dist/core/install/ensure.d.ts +31 -14
- package/dist/core/install/ensure.js +165 -61
- package/dist/core/install/lock.d.ts +2 -1
- package/dist/core/install/merge.d.ts +47 -0
- package/dist/core/install/merge.js +91 -0
- package/dist/core/install/prepare.d.ts +76 -0
- package/dist/core/install/prepare.js +147 -0
- package/dist/core/install/profiles.d.ts +17 -3
- package/dist/core/install/profiles.js +27 -0
- package/dist/core/install/reuse.d.ts +41 -0
- package/dist/core/install/reuse.js +107 -0
- package/dist/core/registry.js +12 -18
- package/dist/core/run-checks.js +74 -3
- package/dist/core/targets.d.ts +116 -5
- package/dist/core/targets.js +101 -16
- package/dist/core/types.d.ts +8 -2
- package/dist/init.js +21 -1
- package/dist/interfaces/index.d.ts +6 -0
- package/dist/utils/resolve-bin.d.ts +56 -6
- package/dist/utils/resolve-bin.js +136 -26
- package/gitleaks.toml +14 -0
- package/install-manifests/commitlint/package-lock.json +128 -250
- package/install-manifests/commitlint/package.json +2 -2
- package/install-manifests/core/package-lock.json +76 -76
- package/install-manifests/core/package.json +1 -1
- package/install-manifests/duplicate/package-lock.json +45 -45
- package/install-manifests/duplicate/package.json +1 -1
- package/install-manifests/go/package-lock.json +24 -0
- package/install-manifests/go/package.json +8 -0
- package/install-manifests/js/package-lock.json +248 -283
- package/install-manifests/js/package.json +9 -9
- package/install-manifests/python/package-lock.json +4 -4
- package/install-manifests/python/package.json +1 -1
- package/install-manifests/pytypes/package-lock.json +45 -0
- package/install-manifests/pytypes/package.json +8 -0
- package/package.json +21 -19
- package/pr-checkmate.schema.json +41 -13
- package/src/config/cspell.json +470 -447
- package/src/config/pr-checkmate.schema.json +41 -13
- package/dist/core/checks/languages/csharp-format.d.ts +0 -13
- package/dist/core/checks/languages/csharp-format.js +0 -52
- package/dist/core/checks/languages/go-lint.d.ts +0 -8
- package/dist/core/checks/languages/go-lint.js +0 -48
- package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
- package/dist/core/checks/languages/kotlin-lint.js +0 -42
- package/dist/core/checks/languages/php-format.d.ts +0 -9
- package/dist/core/checks/languages/php-format.js +0 -49
- package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
- package/dist/core/checks/languages/ruby-lint.js +0 -50
- package/dist/core/checks/languages/rust-format.d.ts +0 -9
- package/dist/core/checks/languages/rust-format.js +0 -45
- package/dist/core/checks/languages/rust-lint.d.ts +0 -8
- package/dist/core/checks/languages/rust-lint.js +0 -50
- package/dist/core/checks/languages/shellcheck.d.ts +0 -8
- package/dist/core/checks/languages/shellcheck.js +0 -55
- package/dist/core/checks/languages/swift-lint.d.ts +0 -2
- package/dist/core/checks/languages/swift-lint.js +0 -46
|
@@ -3,12 +3,36 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.sensitiveFileGuardCheck = 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");
|
|
9
|
+
/**
|
|
10
|
+
* The committed template, in the names every ecosystem uses for it.
|
|
11
|
+
*
|
|
12
|
+
* `.env.example` holds placeholders, is checked in on purpose, and is what the
|
|
13
|
+
* `.env` convention tells people to commit — a repository without one is the
|
|
14
|
+
* unusual case. Flagging it as a secret is not a stylistic quibble now that
|
|
15
|
+
* ticket 055 makes this category block: it turns the recommended practice into a
|
|
16
|
+
* red build, and a gate that fires on correct code is a gate people switch off.
|
|
17
|
+
*
|
|
18
|
+
* Confirmed in the field before it was fixed here: a client shipped
|
|
19
|
+
* `"sensitiveFileGuard": { "ignorePaths": [".env.example"] }` in their config to
|
|
20
|
+
* work around it. Needing a hand-written exclusion for the file the ecosystem
|
|
21
|
+
* mandates is the definition of a false positive.
|
|
22
|
+
*
|
|
23
|
+
* Matched only at the end of the path, so `.env.example.local` — a real file with
|
|
24
|
+
* real values, named to sort next to the template — is still flagged.
|
|
25
|
+
*/
|
|
26
|
+
const TEMPLATE_SUFFIX = /\.(?:example|sample|template|dist|defaults)$/i;
|
|
8
27
|
const SENSITIVE_CATEGORIES = [
|
|
9
28
|
{
|
|
10
29
|
label: 'Environment files (.env)',
|
|
11
|
-
|
|
30
|
+
// The dots are escaped. They were not, and `\/.env$` therefore matched ANY
|
|
31
|
+
// single character before `env`, so a nested file whose name merely ended in
|
|
32
|
+
// those three letters was reported as an environment file. Harmless while
|
|
33
|
+
// this category only warned; a red build over a filename once it blocks.
|
|
34
|
+
patterns: [/^\.env$/, /^\.env\..+/, /\/\.env$/, /\/\.env\..+/],
|
|
35
|
+
except: [TEMPLATE_SUFFIX],
|
|
12
36
|
severity: 'error',
|
|
13
37
|
},
|
|
14
38
|
{
|
|
@@ -61,6 +85,8 @@ function categorizeFiles(changedFiles) {
|
|
|
61
85
|
const warnings = {};
|
|
62
86
|
for (const filePath of changedFiles) {
|
|
63
87
|
for (const category of SENSITIVE_CATEGORIES) {
|
|
88
|
+
if (category.except?.some(p => p.test(filePath)))
|
|
89
|
+
continue;
|
|
64
90
|
if (category.patterns.some(p => p.test(filePath))) {
|
|
65
91
|
const bucket = category.severity === 'error' ? errors : warnings;
|
|
66
92
|
(bucket[_a = category.label] ?? (bucket[_a] = [])).push(filePath);
|
|
@@ -82,7 +108,7 @@ exports.sensitiveFileGuardCheck = {
|
|
|
82
108
|
async run(ctx) {
|
|
83
109
|
try {
|
|
84
110
|
const args = ctx.baseSha
|
|
85
|
-
? ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, 'HEAD']
|
|
111
|
+
? ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, ctx.headSha ?? 'HEAD']
|
|
86
112
|
: ['ls-files'];
|
|
87
113
|
if (!ctx.baseSha && ctx.config.run?.fullScanBehaviour === 'skip')
|
|
88
114
|
return (0, types_1.pass)();
|
|
@@ -100,10 +126,18 @@ exports.sensitiveFileGuardCheck = {
|
|
|
100
126
|
// Without this the only exclusion a client has is `ignore`, which suppresses
|
|
101
127
|
// the whole CATEGORY: muting "Private keys and certificates" to excuse one
|
|
102
128
|
// fixture would also excuse the next real key someone commits.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
129
|
+
// `excludePaths` before the check's own `ignorePaths`, and both before
|
|
130
|
+
// categorising.
|
|
131
|
+
//
|
|
132
|
+
// This check deliberately does NOT go through `targets.ts`: a committed
|
|
133
|
+
// `.env` inside `dist/` is still a committed secret, so `ignoreDirs` must
|
|
134
|
+
// not hide it, and a key outside `sourcePath` is still a key. But
|
|
135
|
+
// `excludePaths` is different in kind — it is the client saying "do not
|
|
136
|
+
// look here" in as many words, and the documentation calls it the one
|
|
137
|
+
// exclusion that reaches every check. It did not reach this one, which
|
|
138
|
+
// meant a `.pem` test fixture failed the build and the key the docs told
|
|
139
|
+
// people to reach for did nothing (ticket 080).
|
|
140
|
+
const changedFiles = (0, targets_1.excludeClientPaths)(result.stdout.split('\n').filter(Boolean), ctx.cwd).filter(file => !(0, utils_1.matchesAnyGlob)(file, cfg.ignorePaths ?? []));
|
|
107
141
|
if (changedFiles.length === 0)
|
|
108
142
|
return (0, types_1.pass)();
|
|
109
143
|
const { errors, warnings } = categorizeFiles(changedFiles);
|
|
@@ -79,7 +79,13 @@ exports.workflowSecurityCheck = {
|
|
|
79
79
|
scanInto(await (0, findings_1.diffAddedLines)(ctx, globs));
|
|
80
80
|
}
|
|
81
81
|
else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
|
|
82
|
-
|
|
82
|
+
// `.github` is globally ignored and is the only place these files live,
|
|
83
|
+
// so this caller has to un-ignore it or scan nothing.
|
|
84
|
+
const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.'], {
|
|
85
|
+
keepDirs: ['.github', '.gitlab', '.circleci'],
|
|
86
|
+
});
|
|
87
|
+
if (files === null)
|
|
88
|
+
return (0, types_1.skip)('git unavailable');
|
|
83
89
|
await (0, findings_1.scanTrackedFiles)(ctx, files, scanInto);
|
|
84
90
|
}
|
|
85
91
|
return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { CheckOutcome } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Where to get the external runners we deliberately do not ship.
|
|
4
|
+
*
|
|
5
|
+
* Eleven checks shell out to a tool that is not ours to bundle — a Kotlin linter,
|
|
6
|
+
* a Swift linter, the Go toolchain. When it is absent the check skips, which is
|
|
7
|
+
* the honest outcome, but `⏭️ ktlint: ktlint not installed` tells the reader
|
|
8
|
+
* something they already knew and nothing they can act on. A skip is only useful
|
|
9
|
+
* if it says what would make the check run.
|
|
10
|
+
*
|
|
11
|
+
* One table rather than a string at each call site: fourteen hand-written
|
|
12
|
+
* messages drift, and half of them end up naming an install route that has since
|
|
13
|
+
* moved.
|
|
14
|
+
*
|
|
15
|
+
* **Repository homepages, not deep documentation links.** A `#installation`
|
|
16
|
+
* anchor is exactly the kind of URL that rots, and a dead link printed into a
|
|
17
|
+
* client's CI log is worse than no link — it reads as carelessness about the
|
|
18
|
+
* thing we are asking them to trust. A project's repository root survives
|
|
19
|
+
* documentation reorganisations and always carries current install instructions.
|
|
20
|
+
*
|
|
21
|
+
* The command is the platform-agnostic one where a project has one, and the
|
|
22
|
+
* common-case one otherwise; the URL covers every other platform.
|
|
23
|
+
*/
|
|
24
|
+
declare const INSTALL_HINTS: {
|
|
25
|
+
readonly ktlint: {
|
|
26
|
+
readonly command: "brew install ktlint";
|
|
27
|
+
readonly url: "https://github.com/pinterest/ktlint";
|
|
28
|
+
};
|
|
29
|
+
readonly swiftlint: {
|
|
30
|
+
readonly command: "brew install swiftlint";
|
|
31
|
+
readonly url: "https://github.com/realm/SwiftLint";
|
|
32
|
+
};
|
|
33
|
+
readonly rubocop: {
|
|
34
|
+
readonly command: "gem install rubocop";
|
|
35
|
+
readonly url: "https://github.com/rubocop/rubocop";
|
|
36
|
+
};
|
|
37
|
+
readonly shellcheck: {
|
|
38
|
+
readonly command: "brew install shellcheck";
|
|
39
|
+
readonly url: "https://github.com/koalaman/shellcheck";
|
|
40
|
+
};
|
|
41
|
+
readonly go: {
|
|
42
|
+
readonly command: null;
|
|
43
|
+
readonly url: "https://go.dev/doc/install";
|
|
44
|
+
};
|
|
45
|
+
readonly gofmt: {
|
|
46
|
+
readonly command: null;
|
|
47
|
+
readonly url: "https://go.dev/doc/install";
|
|
48
|
+
};
|
|
49
|
+
readonly clippy: {
|
|
50
|
+
readonly command: "rustup component add clippy";
|
|
51
|
+
readonly url: "https://github.com/rust-lang/rust-clippy";
|
|
52
|
+
};
|
|
53
|
+
readonly rustfmt: {
|
|
54
|
+
readonly command: "rustup component add rustfmt";
|
|
55
|
+
readonly url: "https://github.com/rust-lang/rustfmt";
|
|
56
|
+
};
|
|
57
|
+
readonly 'php-cs-fixer': {
|
|
58
|
+
readonly command: "composer global require friendsofphp/php-cs-fixer";
|
|
59
|
+
readonly url: "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer";
|
|
60
|
+
};
|
|
61
|
+
readonly dotnet: {
|
|
62
|
+
readonly command: null;
|
|
63
|
+
readonly url: "https://dotnet.microsoft.com/download";
|
|
64
|
+
};
|
|
65
|
+
readonly mypy: {
|
|
66
|
+
readonly command: "pip install mypy";
|
|
67
|
+
readonly url: "https://github.com/python/mypy";
|
|
68
|
+
};
|
|
69
|
+
readonly pyright: {
|
|
70
|
+
readonly command: "npm i -g pyright";
|
|
71
|
+
readonly url: "https://github.com/microsoft/pyright";
|
|
72
|
+
};
|
|
73
|
+
readonly grype: {
|
|
74
|
+
readonly command: "brew install grype";
|
|
75
|
+
readonly url: "https://github.com/anchore/grype";
|
|
76
|
+
};
|
|
77
|
+
readonly 'osv-scanner': {
|
|
78
|
+
readonly command: "brew install osv-scanner";
|
|
79
|
+
readonly url: "https://github.com/google/osv-scanner";
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A tool this package expects the environment to provide.
|
|
84
|
+
*
|
|
85
|
+
* Exported because the runner specs in `languages/external-checks.ts` name one
|
|
86
|
+
* per check, and that is the point of naming them: a spec that cannot compile
|
|
87
|
+
* with a typo in the key cannot ship a skip pointing at an install route that
|
|
88
|
+
* does not exist.
|
|
89
|
+
*/
|
|
90
|
+
export type ExternalTool = keyof typeof INSTALL_HINTS;
|
|
91
|
+
/**
|
|
92
|
+
* Skip because `tool` is not on the path, saying how to install it.
|
|
93
|
+
*
|
|
94
|
+
* @param tool Which runner is missing.
|
|
95
|
+
* @param label How to name it in the message. Omit when the check's own display
|
|
96
|
+
* name already is the tool's name — `⏭️ ktlint: ktlint not installed` repeats
|
|
97
|
+
* itself, which is what made these messages worth revisiting. Pass a label
|
|
98
|
+
* when the two differ (`Go Vet` needs `go`) or when either of two tools would
|
|
99
|
+
* have done (`mypy/pyright`).
|
|
100
|
+
*/
|
|
101
|
+
export declare function notInstalled(tool: ExternalTool, label?: string): CheckOutcome;
|
|
102
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.notInstalled = notInstalled;
|
|
4
|
+
const types_1 = require("../types");
|
|
5
|
+
/**
|
|
6
|
+
* Where to get the external runners we deliberately do not ship.
|
|
7
|
+
*
|
|
8
|
+
* Eleven checks shell out to a tool that is not ours to bundle — a Kotlin linter,
|
|
9
|
+
* a Swift linter, the Go toolchain. When it is absent the check skips, which is
|
|
10
|
+
* the honest outcome, but `⏭️ ktlint: ktlint not installed` tells the reader
|
|
11
|
+
* something they already knew and nothing they can act on. A skip is only useful
|
|
12
|
+
* if it says what would make the check run.
|
|
13
|
+
*
|
|
14
|
+
* One table rather than a string at each call site: fourteen hand-written
|
|
15
|
+
* messages drift, and half of them end up naming an install route that has since
|
|
16
|
+
* moved.
|
|
17
|
+
*
|
|
18
|
+
* **Repository homepages, not deep documentation links.** A `#installation`
|
|
19
|
+
* anchor is exactly the kind of URL that rots, and a dead link printed into a
|
|
20
|
+
* client's CI log is worse than no link — it reads as carelessness about the
|
|
21
|
+
* thing we are asking them to trust. A project's repository root survives
|
|
22
|
+
* documentation reorganisations and always carries current install instructions.
|
|
23
|
+
*
|
|
24
|
+
* The command is the platform-agnostic one where a project has one, and the
|
|
25
|
+
* common-case one otherwise; the URL covers every other platform.
|
|
26
|
+
*/
|
|
27
|
+
const INSTALL_HINTS = {
|
|
28
|
+
ktlint: { command: 'brew install ktlint', url: 'https://github.com/pinterest/ktlint' },
|
|
29
|
+
swiftlint: { command: 'brew install swiftlint', url: 'https://github.com/realm/SwiftLint' },
|
|
30
|
+
rubocop: { command: 'gem install rubocop', url: 'https://github.com/rubocop/rubocop' },
|
|
31
|
+
shellcheck: {
|
|
32
|
+
command: 'brew install shellcheck',
|
|
33
|
+
url: 'https://github.com/koalaman/shellcheck',
|
|
34
|
+
},
|
|
35
|
+
go: { command: null, url: 'https://go.dev/doc/install' },
|
|
36
|
+
gofmt: { command: null, url: 'https://go.dev/doc/install' },
|
|
37
|
+
clippy: {
|
|
38
|
+
command: 'rustup component add clippy',
|
|
39
|
+
url: 'https://github.com/rust-lang/rust-clippy',
|
|
40
|
+
},
|
|
41
|
+
rustfmt: { command: 'rustup component add rustfmt', url: 'https://github.com/rust-lang/rustfmt' },
|
|
42
|
+
'php-cs-fixer': {
|
|
43
|
+
command: 'composer global require friendsofphp/php-cs-fixer',
|
|
44
|
+
url: 'https://github.com/PHP-CS-Fixer/PHP-CS-Fixer',
|
|
45
|
+
},
|
|
46
|
+
dotnet: { command: null, url: 'https://dotnet.microsoft.com/download' },
|
|
47
|
+
mypy: { command: 'pip install mypy', url: 'https://github.com/python/mypy' },
|
|
48
|
+
pyright: { command: 'npm i -g pyright', url: 'https://github.com/microsoft/pyright' },
|
|
49
|
+
grype: { command: 'brew install grype', url: 'https://github.com/anchore/grype' },
|
|
50
|
+
'osv-scanner': {
|
|
51
|
+
command: 'brew install osv-scanner',
|
|
52
|
+
url: 'https://github.com/google/osv-scanner',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Skip because `tool` is not on the path, saying how to install it.
|
|
57
|
+
*
|
|
58
|
+
* @param tool Which runner is missing.
|
|
59
|
+
* @param label How to name it in the message. Omit when the check's own display
|
|
60
|
+
* name already is the tool's name — `⏭️ ktlint: ktlint not installed` repeats
|
|
61
|
+
* itself, which is what made these messages worth revisiting. Pass a label
|
|
62
|
+
* when the two differ (`Go Vet` needs `go`) or when either of two tools would
|
|
63
|
+
* have done (`mypy/pyright`).
|
|
64
|
+
*/
|
|
65
|
+
function notInstalled(tool, label) {
|
|
66
|
+
const { command, url } = INSTALL_HINTS[tool];
|
|
67
|
+
const subject = label ? `${label} not installed` : 'not installed';
|
|
68
|
+
return (0, types_1.skip)(command ? `${subject} — ${command} · ${url}` : `${subject} — ${url}`);
|
|
69
|
+
}
|
package/dist/core/commands.js
CHANGED
package/dist/core/context.js
CHANGED
|
@@ -192,7 +192,14 @@ async function buildContext(opts = {}) {
|
|
|
192
192
|
// rather than calling getIgnoredDirs() — that one resolves against process.cwd(),
|
|
193
193
|
// which is not necessarily the `cwd` under review.
|
|
194
194
|
applyLogConfig(config);
|
|
195
|
-
|
|
195
|
+
// Detected as usage (language + file count), not as a bare list: the same scan
|
|
196
|
+
// answers two different questions, and the install layer needs the counts. A
|
|
197
|
+
// `Cargo.toml` beside zero `.rs` files is enough to run Rust checks and not
|
|
198
|
+
// enough to download a toolchain — see `detectLanguageUsage`.
|
|
199
|
+
const usage = opts.languages
|
|
200
|
+
? opts.languages.map(lang => ({ lang, files: 1, marker: true }))
|
|
201
|
+
: await (0, detect_language_1.detectLanguageUsage)(cwd, config.ignoreDirs, config.detect);
|
|
202
|
+
const languages = usage.map(u => u.lang);
|
|
196
203
|
const { baseSha, headSha, scope } = await resolveDiffRange(cwd, opts);
|
|
197
204
|
// `scope` is logged because it decides what the whole run looks at, and getting
|
|
198
205
|
// it wrong is invisible in the results: a run that reviewed one commit and one
|
|
@@ -201,6 +208,7 @@ async function buildContext(opts = {}) {
|
|
|
201
208
|
return {
|
|
202
209
|
cwd,
|
|
203
210
|
languages,
|
|
211
|
+
usage,
|
|
204
212
|
config,
|
|
205
213
|
write: opts.write ?? false,
|
|
206
214
|
baseSha,
|
|
@@ -27,7 +27,26 @@ export declare function expandHome(dir: string, home?: string): string;
|
|
|
27
27
|
* @param configured `install.cacheDir`, if the client set one.
|
|
28
28
|
*/
|
|
29
29
|
export declare function resolveCacheDir(version: string, configured?: string, env?: NodeJS.ProcessEnv): CacheLocation | null;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Where fetched tooling is installed — one tree for the whole run, not one per profile.
|
|
32
|
+
*
|
|
33
|
+
* A directory per profile left npm nothing to deduplicate, and the profiles
|
|
34
|
+
* overlap heavily: `commitlint` and `js` share 30 MB, so two trees fetched both
|
|
35
|
+
* copies. Measured on a JavaScript repository, per-profile directories came to
|
|
36
|
+
* 267 MB where a single merged tree costs 213 (ticket 087).
|
|
37
|
+
*
|
|
38
|
+
* The name is deliberately not derived from which profiles are inside. What is
|
|
39
|
+
* installed is recorded in the directory's own stamp and checked as a superset,
|
|
40
|
+
* so a cache warmed by `install --all` serves every narrower run — a path keyed
|
|
41
|
+
* by the profile set would miss on the first one.
|
|
42
|
+
*/
|
|
43
|
+
export declare function toolingDir(cacheDir: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Where a single profile's tooling used to be installed.
|
|
46
|
+
*
|
|
47
|
+
* Kept only so `cache prune` can still recognise and remove the directories left
|
|
48
|
+
* by versions that used the per-profile layout.
|
|
49
|
+
*/
|
|
31
50
|
export declare function profileDir(cacheDir: string, profile: string): string;
|
|
32
51
|
/**
|
|
33
52
|
* Profile directories left behind by other versions of pr-checkmate.
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.expandHome = expandHome;
|
|
7
7
|
exports.resolveCacheDir = resolveCacheDir;
|
|
8
|
+
exports.toolingDir = toolingDir;
|
|
8
9
|
exports.profileDir = profileDir;
|
|
9
10
|
exports.stalePaths = stalePaths;
|
|
10
11
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
@@ -24,14 +25,13 @@ function expandHome(dir, home = node_os_1.default.homedir()) {
|
|
|
24
25
|
return home;
|
|
25
26
|
return dir.startsWith('~/') ? node_path_1.default.join(home, dir.slice(2)) : dir;
|
|
26
27
|
}
|
|
27
|
-
/** A directory we can actually write to
|
|
28
|
-
function
|
|
28
|
+
/** A directory we can actually write to. */
|
|
29
|
+
function writable(dir) {
|
|
29
30
|
try {
|
|
30
31
|
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
31
|
-
// Existence is not permission
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// and a stack trace halfway through fetching.
|
|
32
|
+
// Existence is not permission, and finding that out here rather than at write
|
|
33
|
+
// time is the difference between a clear message and a stack trace halfway
|
|
34
|
+
// through fetching.
|
|
35
35
|
node_fs_1.default.accessSync(dir, node_fs_1.default.constants.W_OK);
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
@@ -39,6 +39,30 @@ function usable(dir) {
|
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* A directory that already exists and can at least be read.
|
|
44
|
+
*
|
|
45
|
+
* Only ever asked of a cache path the client named explicitly, and only after
|
|
46
|
+
* {@link writable} has said no. That combination has exactly one meaning: a cache
|
|
47
|
+
* baked into a container image, read-only on purpose, holding tooling somebody
|
|
48
|
+
* warmed at build time. Reading it needs no write permission, and refusing it
|
|
49
|
+
* because a *future* fetch could not write there discards the tooling that is
|
|
50
|
+
* sitting right there — which is how our own image lost `Python Types` under
|
|
51
|
+
* `--user` (ticket 083).
|
|
52
|
+
*
|
|
53
|
+
* A fetch into such a directory still fails, in `ensureProfile`, with the path it
|
|
54
|
+
* could not create. That is the clear message the write check was for; it is just
|
|
55
|
+
* no longer paid for by every read.
|
|
56
|
+
*/
|
|
57
|
+
function readable(dir) {
|
|
58
|
+
try {
|
|
59
|
+
node_fs_1.default.accessSync(dir, node_fs_1.default.constants.R_OK | node_fs_1.default.constants.X_OK);
|
|
60
|
+
return node_fs_1.default.statSync(dir).isDirectory();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
42
66
|
/**
|
|
43
67
|
* Resolve the cache directory, falling back rather than failing.
|
|
44
68
|
*
|
|
@@ -62,16 +86,45 @@ function resolveCacheDir(version, configured, env = process.env) {
|
|
|
62
86
|
candidates.push({ dir: node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-cache'), reason: 'temp directory' });
|
|
63
87
|
for (const [index, candidate] of candidates.entries()) {
|
|
64
88
|
const dir = node_path_1.default.join(candidate.dir, version);
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
if (writable(dir)) {
|
|
90
|
+
if (index > 0) {
|
|
91
|
+
logger_1.logger.warn(`[install]: ⚠️ cache directory "${candidates[0].dir}" is not writable — using ${candidate.reason} (${dir}). Fetched tooling will not survive a container rebuild.`);
|
|
92
|
+
}
|
|
93
|
+
return { dir, preferred: index === 0, reason: candidate.reason };
|
|
94
|
+
}
|
|
95
|
+
// An explicitly named cache that cannot be written but CAN be read is still
|
|
96
|
+
// the answer — see {@link readable} — and it has to win here rather than in a
|
|
97
|
+
// second pass at the end. Otherwise the writable-but-empty temp directory two
|
|
98
|
+
// candidates down takes precedence over a read-only cache holding the very
|
|
99
|
+
// tooling this run needs, which is the shape our own image has.
|
|
100
|
+
if (index === 0 && explicit && readable(dir)) {
|
|
101
|
+
return { dir, preferred: true, reason: 'configured, read-only' };
|
|
69
102
|
}
|
|
70
|
-
return { dir, preferred: index === 0, reason: candidate.reason };
|
|
71
103
|
}
|
|
72
104
|
return null;
|
|
73
105
|
}
|
|
74
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Where fetched tooling is installed — one tree for the whole run, not one per profile.
|
|
108
|
+
*
|
|
109
|
+
* A directory per profile left npm nothing to deduplicate, and the profiles
|
|
110
|
+
* overlap heavily: `commitlint` and `js` share 30 MB, so two trees fetched both
|
|
111
|
+
* copies. Measured on a JavaScript repository, per-profile directories came to
|
|
112
|
+
* 267 MB where a single merged tree costs 213 (ticket 087).
|
|
113
|
+
*
|
|
114
|
+
* The name is deliberately not derived from which profiles are inside. What is
|
|
115
|
+
* installed is recorded in the directory's own stamp and checked as a superset,
|
|
116
|
+
* so a cache warmed by `install --all` serves every narrower run — a path keyed
|
|
117
|
+
* by the profile set would miss on the first one.
|
|
118
|
+
*/
|
|
119
|
+
function toolingDir(cacheDir) {
|
|
120
|
+
return node_path_1.default.join(cacheDir, 'tooling');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Where a single profile's tooling used to be installed.
|
|
124
|
+
*
|
|
125
|
+
* Kept only so `cache prune` can still recognise and remove the directories left
|
|
126
|
+
* by versions that used the per-profile layout.
|
|
127
|
+
*/
|
|
75
128
|
function profileDir(cacheDir, profile) {
|
|
76
129
|
return node_path_1.default.join(cacheDir, profile);
|
|
77
130
|
}
|
|
@@ -9,7 +9,6 @@ exports.installProfiles = installProfiles;
|
|
|
9
9
|
exports.runDoctor = runDoctor;
|
|
10
10
|
exports.pruneCache = pruneCache;
|
|
11
11
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
-
const node_path_1 = __importDefault(require("node:path"));
|
|
13
12
|
const loader_1 = require("../../config/loader");
|
|
14
13
|
const detect_language_1 = require("../../utils/detect-language");
|
|
15
14
|
const logger_1 = require("../../utils/logger");
|
|
@@ -17,6 +16,7 @@ const package_version_1 = require("../../utils/package-version");
|
|
|
17
16
|
Object.defineProperty(exports, "packageVersion", { enumerable: true, get: function () { return package_version_1.packageVersion; } });
|
|
18
17
|
const cache_1 = require("./cache");
|
|
19
18
|
const ensure_1 = require("./ensure");
|
|
19
|
+
const prepare_1 = require("./prepare");
|
|
20
20
|
const profiles_1 = require("./profiles");
|
|
21
21
|
/**
|
|
22
22
|
* Everything the install layer knows about this repository, in one shot.
|
|
@@ -31,11 +31,31 @@ async function planInstall(cwd = process.cwd(), usage) {
|
|
|
31
31
|
const location = (0, cache_1.resolveCacheDir)((0, package_version_1.packageVersion)(), config.install?.cacheDir);
|
|
32
32
|
return { decisions: (0, profiles_1.planProfiles)(detected, config), cacheDir: location?.dir ?? null };
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Which profiles are usable right now, and why — fetched, or shipped with us.
|
|
36
|
+
*
|
|
37
|
+
* The cache is only half the answer, and reporting only that half was a lie in
|
|
38
|
+
* the client's favour's opposite direction: most profiles ship inside the
|
|
39
|
+
* package, are never fetched, and were listed as "not installed yet" on a machine
|
|
40
|
+
* where they worked perfectly. `doctor` exists to answer "why did my typecheck
|
|
41
|
+
* not run", so telling someone seven profiles are missing when six of them are
|
|
42
|
+
* present sends them to debug nothing.
|
|
43
|
+
*
|
|
44
|
+
* The cached half is read from the tooling directory's own stamp rather than
|
|
45
|
+
* inferred from paths: there is one tree for everything fetched, so what went
|
|
46
|
+
* into it is recorded rather than visible in the layout (ticket 087).
|
|
47
|
+
*/
|
|
35
48
|
function installedProfiles(cacheDir) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
const present = new Map();
|
|
50
|
+
for (const profile of profiles_1.PROFILES) {
|
|
51
|
+
if ((0, prepare_1.profileSatisfiedLocally)(profile))
|
|
52
|
+
present.set(profile.name, 'ships with the package');
|
|
53
|
+
}
|
|
54
|
+
if (cacheDir) {
|
|
55
|
+
for (const name of (0, ensure_1.cachedProfiles)(cacheDir))
|
|
56
|
+
present.set(name, 'fetched');
|
|
57
|
+
}
|
|
58
|
+
return present;
|
|
39
59
|
}
|
|
40
60
|
/**
|
|
41
61
|
* Fetch every profile this repository needs.
|
|
@@ -52,20 +72,29 @@ async function installProfiles(cwd = process.cwd(), all = false) {
|
|
|
52
72
|
const denied = new Set(install.deny ?? []);
|
|
53
73
|
const wanted = (all ? profiles_1.PROFILES.map(p => p.name) : decisions.filter(d => d.required).map(d => d.profile)).filter(name => {
|
|
54
74
|
const profile = profiles_1.PROFILES.find(p => p.name === name);
|
|
75
|
+
// Nothing to fetch for a profile whose packages already ship with us, and
|
|
76
|
+
// fetching it anyway is not merely wasted: `--all` downloaded a second copy
|
|
77
|
+
// of every bundled profile — some 800 packages — beside the ones already in
|
|
78
|
+
// `node_modules`. On a container build that lands in the image (ticket 083).
|
|
79
|
+
if (profile && (0, prepare_1.profileSatisfiedLocally)(profile)) {
|
|
80
|
+
logger_1.logger.info(`[install]: profile "${name}" already ships with the package`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
55
83
|
const blocked = profile?.packages.filter(pkg => denied.has(pkg)) ?? [];
|
|
56
84
|
if (blocked.length === 0)
|
|
57
85
|
return true;
|
|
58
86
|
logger_1.logger.info(`[install]: profile "${name}" skipped — install.deny lists ${blocked.join(', ')}`);
|
|
59
87
|
return false;
|
|
60
88
|
});
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
// One install for the whole set. Profiles that share hundreds of packages used
|
|
90
|
+
// to be fetched into a tree each, which fought over npm's cache and downloaded
|
|
91
|
+
// the shared half once per profile (ticket 087).
|
|
92
|
+
const result = await (0, ensure_1.ensureTooling)(wanted, {
|
|
93
|
+
cwd,
|
|
94
|
+
version: (0, package_version_1.packageVersion)(),
|
|
95
|
+
install,
|
|
96
|
+
});
|
|
97
|
+
return [result];
|
|
69
98
|
}
|
|
70
99
|
/**
|
|
71
100
|
* Explain what this repository needs, what it has, and what is missing — and why.
|
|
@@ -99,8 +128,13 @@ async function runDoctor(cwd = process.cwd()) {
|
|
|
99
128
|
lines.push('');
|
|
100
129
|
lines.push(' Profiles');
|
|
101
130
|
for (const decision of decisions) {
|
|
102
|
-
const
|
|
103
|
-
|
|
131
|
+
const source = present.get(decision.profile);
|
|
132
|
+
const state = decision.required ? (source ? '✅' : '⬇️ ') : '—';
|
|
133
|
+
// Where a present profile came from, because "it works" and "it works
|
|
134
|
+
// because it was downloaded onto this runner" are different situations for
|
|
135
|
+
// anyone debugging a cache or an air-gapped build.
|
|
136
|
+
const how = decision.required && source ? ` (${source})` : '';
|
|
137
|
+
lines.push(` ${state} ${decision.profile.padEnd(12)} ${decision.reason}${how}`);
|
|
104
138
|
}
|
|
105
139
|
const missing = decisions.filter(d => d.required && !present.has(d.profile));
|
|
106
140
|
if (missing.length > 0) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { InstallConfig } from '../../interfaces';
|
|
2
2
|
import type { ProfileName } from './profiles';
|
|
3
3
|
/**
|
|
4
|
-
* What happened to
|
|
4
|
+
* What happened to the tooling cache, in the words the report and `doctor` use.
|
|
5
5
|
*
|
|
6
6
|
* `skipped` and `failed` are deliberately distinct. Skipped means we chose not
|
|
7
7
|
* to fetch — offline, `strategy: "manual"`, an explicit exclusion — and the
|
|
@@ -9,12 +9,13 @@ import type { ProfileName } from './profiles';
|
|
|
9
9
|
* case that has to reach the report: ticket 057 is the standing reminder that a
|
|
10
10
|
* check quietly vanishing is worse than a check failing loudly.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
type EnsureStatus = 'cached' | 'installed' | 'skipped' | 'failed';
|
|
13
13
|
export interface EnsureResult {
|
|
14
|
-
profile: ProfileName;
|
|
15
14
|
status: EnsureStatus;
|
|
16
15
|
reason: string;
|
|
17
16
|
dir?: string;
|
|
17
|
+
/** Profiles the directory holds once this call is done. */
|
|
18
|
+
profiles: ProfileName[];
|
|
18
19
|
durationMs?: number;
|
|
19
20
|
}
|
|
20
21
|
/** Whether the fetch succeeded, and what to quote in the report when it did not. */
|
|
@@ -22,7 +23,7 @@ export interface InstallOutcome {
|
|
|
22
23
|
ok: boolean;
|
|
23
24
|
detail?: string;
|
|
24
25
|
}
|
|
25
|
-
/** Options `
|
|
26
|
+
/** Options `ensureTooling` needs that do not come from the config file. */
|
|
26
27
|
export interface EnsureOptions {
|
|
27
28
|
/** Repository under review — the cwd npm runs in, so the client's `.npmrc` applies. */
|
|
28
29
|
cwd: string;
|
|
@@ -42,19 +43,35 @@ export interface EnsureOptions {
|
|
|
42
43
|
/** Where the shipped manifests live inside the installed package. */
|
|
43
44
|
export declare function manifestDir(profile: string): string;
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
+
* Which profiles the tooling cache holds, as recorded by the install that filled it.
|
|
46
47
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
48
|
+
* The one way to ask, so the stamp's format has a single owner. There is one tree
|
|
49
|
+
* for all fetched tooling now, so what is in it is written down rather than
|
|
50
|
+
* readable from the directory layout.
|
|
51
|
+
*/
|
|
52
|
+
export declare function cachedProfiles(cacheDir: string): ProfileName[];
|
|
53
|
+
/**
|
|
54
|
+
* Make a set of profiles' tooling available, fetching only what is not already there.
|
|
55
|
+
*
|
|
56
|
+
* Idempotent by design: this is the single entry point for both the `install`
|
|
57
|
+
* command and the lazy path taken when it was never run (`npm ci
|
|
49
58
|
* --ignore-scripts`, a `node_modules` restored from a CI cache). Two code paths
|
|
50
59
|
* for the same job is how the two drift apart and how one of them silently stops
|
|
51
60
|
* working.
|
|
52
61
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
62
|
+
* Everything needed goes into **one** tree. Profiles overlap heavily — `js` and
|
|
63
|
+
* `commitlint` share 30 MB — and a directory each left npm nothing to
|
|
64
|
+
* deduplicate: 267 MB against the 211 one merged tree costs for the same set
|
|
65
|
+
* (ticket 087). The set only ever grows: a run needing a profile the directory
|
|
66
|
+
* lacks reinstalls the union, so nothing already fetched is lost.
|
|
67
|
+
*
|
|
68
|
+
* The install itself is a plain `npm ci` against a manifest merged from the ones
|
|
69
|
+
* we generated from our own lockfile, run from the client's repository so their
|
|
70
|
+
* `.npmrc`, registry and proxy settings apply unchanged. `--ignore-scripts` is
|
|
71
|
+
* not negotiable: we are a supply-chain tool, and running arbitrary install hooks
|
|
72
|
+
* from a security check would be indefensible. Integrity is npm's own, over the
|
|
73
|
+
* whole transitive tree — a tampered tarball aborts with `EINTEGRITY` rather than
|
|
74
|
+
* being installed.
|
|
59
75
|
*/
|
|
60
|
-
export declare function
|
|
76
|
+
export declare function ensureTooling(wanted: ProfileName[], opts: EnsureOptions): Promise<EnsureResult>;
|
|
77
|
+
export {};
|