@rightkit/release 0.6.5 → 0.6.7
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 +196 -11
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +702 -26
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +671 -133
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +20 -12
- package/cache-command.mjs +4 -3
- package/cargo-license-notices.mjs +2 -1
- package/cargo-manifest-text.mjs +25 -0
- package/cargo-profile-contract.mjs +26 -13
- package/cli/right-release.mjs +37 -7
- package/command-spec.mjs +4 -5
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +513 -54
- package/development.mjs +5 -5
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-release.mjs +5 -3
- package/github-updater-release.mjs +48 -8
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +175 -8
- package/ios-upload.mjs +4 -4
- package/legion-semantic-contracts.mjs +428 -0
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +46 -3
- package/pipeline-admission.mjs +12 -0
- package/publish-cargo.mjs +219 -4
- package/publish-update.mjs +19 -14
- package/release-config.mjs +87 -11
- package/release-dependencies-command.mjs +4 -3
- package/release-qa-audit.mjs +12 -10
- package/release-qa-command.mjs +42 -7
- package/release-qa-hardening.mjs +10 -3
- package/release-qa-policy.mjs +214 -47
- package/release.mjs +4 -4
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +178 -170
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +7 -6
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
package/deterministic-scans.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// every scan that applies to a change runs here, before any artifact build, local or CI.
|
|
3
3
|
//
|
|
4
4
|
// Class A: compile-free, executes no tests and no repository-supplied plugin or generator.
|
|
5
|
-
// Class B: needs the compiler or runs tests or repository code (clippy, check, doc, tsc, eslint,
|
|
5
|
+
// Class B: needs the compiler or runs tests or repository code (clippy, check, doc, tsc, eslint, biome,
|
|
6
6
|
// package tests, the repository's own docs generator).
|
|
7
7
|
//
|
|
8
8
|
// A required tool that is missing FAILS (status "fail", code TOOL_MISSING); a scan is never skipped.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// pass (result.ok and result.complete are false while any scan is deferred) and cannot become a SCAN_B receipt.
|
|
12
12
|
// Content scans run on the exported HEAD (contentRoot). Only a scan that inherently needs the live
|
|
13
13
|
// checkout uses liveRoot: `rustright cargo` scans (the broker binds to the registered checkout), the
|
|
14
|
-
// tsc, eslint and package tests (need installed node_modules).
|
|
14
|
+
// tsc, eslint, biome and package tests (need installed node_modules).
|
|
15
15
|
// Class B runs only when every selected class A scan passed (SCAN_B is distinct from SCAN_A and
|
|
16
16
|
// follows it). Rust commands go through `rustright cargo`, JavaScript tools through `pnpm exec`.
|
|
17
17
|
// The runner takes injectable spawn/exists/readFile/readdir so tests execute nothing real. The
|
|
@@ -26,18 +26,34 @@ import { spawn as spawnChild, spawnSync } from "./background-process.mjs";
|
|
|
26
26
|
import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
|
|
27
27
|
import { canonicalJson, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
28
28
|
import { reach } from "./lock-graph.mjs";
|
|
29
|
+
import { cargoManifestState } from "./cargo-manifest-text.mjs";
|
|
30
|
+
import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
|
|
29
31
|
|
|
30
32
|
export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
|
|
33
|
+
/**
|
|
34
|
+
* The checker that produced a scan result: `<name>@<version>#<digest>` where the digest covers this module and scan-digest.mjs,
|
|
35
|
+
* so a changed checker at the same version is a different identity. null when it cannot be computed; prior class A evidence
|
|
36
|
+
* then never unlocks class B (an unknown checker is not trusted, it is refused).
|
|
37
|
+
*/
|
|
38
|
+
export const CHECKER_IDENTITY = (() => {
|
|
39
|
+
try {
|
|
40
|
+
const p = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"));
|
|
41
|
+
const h = crypto.createHash("sha256");
|
|
42
|
+
for (const f of ["./deterministic-scans.mjs", "./scan-digest.mjs"]) { h.update(f); h.update("\0"); h.update(fs.readFileSync(new URL(f, import.meta.url))); h.update("\0"); }
|
|
43
|
+
return `${p.name}@${p.version}#${h.digest("hex").slice(0, 16)}`;
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
})();
|
|
46
|
+
export const CHECKER_VERSION = CHECKER_IDENTITY;
|
|
31
47
|
export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
|
|
32
48
|
|
|
33
49
|
/** Scan id prefixes (the text before the first ":") that belong to each class. */
|
|
34
50
|
export const CLASS_A_SCAN_PREFIXES = Object.freeze([
|
|
35
51
|
"rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
|
|
36
|
-
"actionlint", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
|
|
52
|
+
"actionlint", "shellcheck", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
|
|
37
53
|
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
|
|
38
54
|
]);
|
|
39
55
|
export const CLASS_B_SCAN_PREFIXES = Object.freeze([
|
|
40
|
-
"clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "tsc", "eslint", "swift-build", "test", "generated-docs",
|
|
56
|
+
"clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "tsc", "eslint", "biome", "swift-build", "test", "generated-docs",
|
|
41
57
|
]);
|
|
42
58
|
|
|
43
59
|
/**
|
|
@@ -54,6 +70,27 @@ export function deterministicScansEnabled(config) {
|
|
|
54
70
|
return config?.deterministicScans?.enabled === true;
|
|
55
71
|
}
|
|
56
72
|
|
|
73
|
+
let neutralDir = null;
|
|
74
|
+
/** An empty directory outside every repository, for tool version probes. */
|
|
75
|
+
function probeDir() {
|
|
76
|
+
if (!neutralDir || !fs.existsSync(neutralDir)) neutralDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-probe-"));
|
|
77
|
+
return neutralDir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** JSONC to JSON: drops line and block comments outside strings, and trailing commas before } or ]. */
|
|
81
|
+
export function stripJsonComments(text) {
|
|
82
|
+
let out = ""; let i = 0; let inString = false;
|
|
83
|
+
while (i < text.length) {
|
|
84
|
+
const c = text[i];
|
|
85
|
+
if (inString) { out += c; if (c === "\\") { out += text[i + 1] ?? ""; i += 2; continue; } if (c === "\"") inString = false; i++; continue; }
|
|
86
|
+
if (c === "\"") { inString = true; out += c; i++; continue; }
|
|
87
|
+
if (c === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; continue; }
|
|
88
|
+
if (c === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i += 2; continue; }
|
|
89
|
+
out += c; i++;
|
|
90
|
+
}
|
|
91
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
92
|
+
}
|
|
93
|
+
|
|
57
94
|
/** "A", "B" or null for an id such as "clippy:rightkit-fs". */
|
|
58
95
|
export function scanClassOf(id) {
|
|
59
96
|
const prefix = String(id).split(":")[0];
|
|
@@ -177,6 +214,16 @@ function containedPath(raw, contentRoot) {
|
|
|
177
214
|
return { path: norm };
|
|
178
215
|
}
|
|
179
216
|
const PNPM_NO_HOOKS_ENV = Object.freeze({ npm_config_ignore_pnpmfile: "true", npm_config_ignore_scripts: "true", pnpm_config_ignore_pnpmfile: "true", pnpm_config_ignore_scripts: "true" });
|
|
217
|
+
// pnpm 11 checks, before every `pnpm run` and `pnpm exec`, that node_modules matches pnpm-lock.yaml (`verify-deps-before-run`, default
|
|
218
|
+
// `install`) and answers a stale tree by running `pnpm install` in it. That install wants to purge the modules directory and, with no TTY (a
|
|
219
|
+
// gate child has none), aborts: `[ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY] Aborted removal of modules directory due to no TTY` and a
|
|
220
|
+
// stack through runDepsStatusCheck, before the tool or the test runs at all (CodeRight, 2026-10-25: tsc, biome and test:npm failed in 2 s on a
|
|
221
|
+
// tree installed before the last lockfile change). A gate scan must not install into, or purge, the checkout it reads, and the lockfile is
|
|
222
|
+
// verified where it belongs, by `pnpm-lockfile` (`install --frozen-lockfile --lockfile-only`) in the clean export, so the class B pnpm scans
|
|
223
|
+
// turn the check off and run against the tree as installed, as pnpm 10 (default `false`) always did; a tree that is really missing something
|
|
224
|
+
// fails in the tool or the test with its own error. The `pnpm_config_` spelling is the one pnpm 11.25 honours for this setting
|
|
225
|
+
// (`npm_config_verify_deps_before_run=false` still aborted, checked on a stale tree), the lowercase spelling PNPM_NO_HOOKS_ENV uses for its settings.
|
|
226
|
+
const PNPM_RUN_ENV = Object.freeze({ pnpm_config_verify_deps_before_run: "false" });
|
|
180
227
|
const CONFIG_CRATE_FILE = /(^|\/)Cargo\.(toml|lock)$/;
|
|
181
228
|
const JS_TOOLING_FILE = /(^|\/)(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$/;
|
|
182
229
|
// Root files that change how EVERY crate (or package) builds: a change to one puts every unit in scope,
|
|
@@ -202,18 +249,45 @@ export const ancestorConfigChanged = (ws, changed, pattern = ANCESTOR_RUST_CONFI
|
|
|
202
249
|
return rel !== null && pattern.test(rel);
|
|
203
250
|
}));
|
|
204
251
|
const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)$/;
|
|
205
|
-
|
|
206
|
-
|
|
252
|
+
// The linter a package has is the one it configures. ESLint reads a flat `eslint.config.*` or a legacy `.eslintrc*` from the package directory or
|
|
253
|
+
// any ancestor of it; Biome reads `biome.json` / `biome.jsonc` the same way. A Biome-only app (ViewRight, ScrapeRight: `"lint": "biome lint ."`)
|
|
254
|
+
// has no ESLint config and no ESLint dependency, so `eslint .` there fails with "couldn't find an eslint.config" (a gate false positive):
|
|
255
|
+
// planScans plans eslint only where an ESLint config exists and biome only where a Biome config exists, both where both do.
|
|
256
|
+
export const ESLINT_CONFIG_FILES = Object.freeze([
|
|
257
|
+
"eslint.config.js", "eslint.config.mjs", ".eslintrc.json",
|
|
258
|
+
"eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts",
|
|
259
|
+
".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml",
|
|
260
|
+
]);
|
|
261
|
+
export const BIOME_CONFIG_FILES = Object.freeze(["biome.json", "biome.jsonc"]);
|
|
207
262
|
// Everything scripts/generate-index.mjs reads or writes (its header and loadInventory): crate and package manifests and
|
|
208
263
|
// READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself,
|
|
209
264
|
// the port matrix generator it imports, and its outputs (the port matrix's MD and HTML are checked for consistency with its JSON).
|
|
210
265
|
// The Right Suite API contract and every projection scripts/gen-suite-api.mjs writes from it (the test-vector copy included).
|
|
211
|
-
const SUITE_API_INPUT = /^(contracts\/right-suite-api\.v1\.json|scripts\/gen-suite-api\.mjs|crates\/rightkit-license\/src\/suite_api_generated\.rs|packages\/app-update\/src\/suite-api\.generated\.ts|packages\/release\/suite-api\.generated\.mjs|crates\/rightkit-license\/test-vectors\/right-suite-api\.v1\.json)$/;
|
|
266
|
+
const SUITE_API_INPUT = /^(contracts\/right-suite-api\.v1\.json|scripts\/gen-suite-api\.mjs|crates\/rightkit-license\/src\/suite_api_generated\.rs|packages\/app-update\/src\/suite-api\.generated\.ts|packages\/release\/suite-api\.generated\.mjs|swift\/RightKitSwift\/Sources\/RightKitLicense\/RightSuiteAPI\.generated\.swift|crates\/rightkit-license\/test-vectors\/right-suite-api\.v1\.json)$/;
|
|
212
267
|
const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-(index|port-matrix)\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP|PORT-MATRIX)\.md|docs\/port-matrix\.(html|json))$/;
|
|
213
268
|
const PROBES = Object.freeze({
|
|
214
269
|
rustfmt: ["--version"], node: ["--version"], rustright: ["cargo", "--version"], pnpm: ["--version"],
|
|
215
|
-
actionlint: ["-version"], "right-git": ["--version"], swift: ["--version"],
|
|
270
|
+
actionlint: ["-version"], shellcheck: ["--version"], "right-git": ["--version"], swift: ["--version"],
|
|
216
271
|
});
|
|
272
|
+
/**
|
|
273
|
+
* The one version of each analyzer whose verdict class A accepts. A class A finding must not depend on which build of the
|
|
274
|
+
* analyzer a host happens to have: ShellCheck 0.10.0 fails SellRight's repaired deploy scripts on SC2015 and a documented
|
|
275
|
+
* `# shellcheck disable=SC2329` on SC2317 (0.11.0 split that code), so the same commit was red on one host and green on another.
|
|
276
|
+
* A scan (or a scan's `requires`) whose tool answers another version fails with TOOL_VERSION; it is never passed or skipped.
|
|
277
|
+
* The installers (.github/workflows/publish.yml, publish-app.yml, ci.yml) and the managed host path in ci/rk-pipeline.json
|
|
278
|
+
* provision exactly this version; scripts/ci/scan-receipt.test.mjs holds them to it.
|
|
279
|
+
*/
|
|
280
|
+
export const PINNED_SCAN_TOOLS = Object.freeze({ shellcheck: "0.11.0" });
|
|
281
|
+
// The version a probe's output names, for a tool whose first line is not its version (`shellcheck --version` opens with a banner).
|
|
282
|
+
const PROBE_VERSION = Object.freeze({
|
|
283
|
+
shellcheck: (text) => { const m = /^version:\s*(\S+)\s*$/m.exec(text); return m ? `shellcheck ${m[1]}` : null; },
|
|
284
|
+
});
|
|
285
|
+
/** null when `version` (a probe's answer) is the pinned one or the tool has no pin, else the problem. */
|
|
286
|
+
export function pinnedVersionProblem(tool, version) {
|
|
287
|
+
const required = PINNED_SCAN_TOOLS[tool];
|
|
288
|
+
if (!required || version === `${tool} ${required}`) return null;
|
|
289
|
+
return `${tool} found ${version ? `"${version}"` : "with no readable version"}, class A requires ${tool} ${required} (PINNED_SCAN_TOOLS in @rightkit/release deterministic-scans.mjs); another version judges scripts differently, so it is refused, never passed or skipped`;
|
|
290
|
+
}
|
|
217
291
|
|
|
218
292
|
function commandText(d) {
|
|
219
293
|
if (d.internal) return `internal:${d.internal}`;
|
|
@@ -221,6 +295,139 @@ function commandText(d) {
|
|
|
221
295
|
return `${env}${d.tool} ${d.args.join(" ")}`.trim();
|
|
222
296
|
}
|
|
223
297
|
|
|
298
|
+
// ---- package scripts that call the broker -----------------------------------------------------------------------------------
|
|
299
|
+
// A package's `test` script may run Rust through the broker (`rustright storage run --app x -- right-release test`, `rustright cargo test`) or
|
|
300
|
+
// through plain cargo, which on a host with no broker means the unmanaged toolchain. On such a host the `test:npm:<unit>` scan cannot pass, and
|
|
301
|
+
// its failure says nothing about the code: rightkit-gate defers it with the Rust gate. scriptBrokerCall only reads the scripts; it runs nothing.
|
|
302
|
+
const SCRIPT_TEXT_LIMIT = 16 * 1024;
|
|
303
|
+
const BROKER_TOOLS = new Set(["rustright", "cargo"]);
|
|
304
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
305
|
+
// Words that run the command after them.
|
|
306
|
+
const COMMAND_WRAPPERS = new Set(["cross-env", "env", "time", "exec", "command", "sudo", "nice", "nohup"]);
|
|
307
|
+
const PACKAGE_MANAGERS = new Set(["pnpm", "npm", "yarn"]);
|
|
308
|
+
const PACKAGE_RUNNERS = new Set(["npx", "bunx"]);
|
|
309
|
+
// Package manager options that point the run at another package or at every package: the script that runs is not in this package.json.
|
|
310
|
+
const RETARGET_FLAGS = new Set(["-r", "--recursive", "-w", "--workspace-root", "--workspaces", "-ws", "--include-workspace-root"]);
|
|
311
|
+
const RETARGET_VALUE_OPTIONS = new Set(["-F", "--filter", "--filter-prod", "-C", "--dir", "--prefix", "--workspace"]);
|
|
312
|
+
const SAME_DIR_VALUES = new Set([".", "./"]);
|
|
313
|
+
// Options that take the next word as their value (the rest are flags).
|
|
314
|
+
const VALUE_OPTIONS = new Set(["--reporter", "--loglevel", "--color", "--workspace-concurrency", "--stream", "--shell-emulator", "--test-pattern"]);
|
|
315
|
+
|
|
316
|
+
/** The simple commands of a shell line, each as its words: split on `&&`, `||`, `;`, `|`, `&`, `(`, `)` and newlines outside quotes. A backslash outside quotes is a path separator (Windows), not an escape. */
|
|
317
|
+
function shellCommands(text) {
|
|
318
|
+
const commands = []; let words = []; let word = ""; let inWord = false; let quote = null;
|
|
319
|
+
const endWord = () => { if (inWord) words.push(word); word = ""; inWord = false; };
|
|
320
|
+
const endCommand = () => { endWord(); if (words.length) commands.push(words); words = []; };
|
|
321
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
322
|
+
const c = text[i];
|
|
323
|
+
if (quote) {
|
|
324
|
+
if (c === quote) quote = null;
|
|
325
|
+
else if (c === "\\" && quote === '"' && i + 1 < text.length) word += text[++i];
|
|
326
|
+
else word += c;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (c === '"' || c === "'") { quote = c; inWord = true; continue; }
|
|
330
|
+
if (c === "&" || c === "|" || c === ";" || c === "\n" || c === "(" || c === ")") { endCommand(); continue; }
|
|
331
|
+
if (/\s/.test(c)) { endWord(); continue; }
|
|
332
|
+
word += c; inWord = true;
|
|
333
|
+
}
|
|
334
|
+
endCommand();
|
|
335
|
+
return commands;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** What `pnpm|npm|yarn <args>` does in this package: { run: <script name> } or {} (another package, a built-in, an unknown word). */
|
|
339
|
+
function inspectPackageManager(args, scripts) {
|
|
340
|
+
let i = 0;
|
|
341
|
+
while (i < args.length && args[i].startsWith("-") && args[i] !== "--") {
|
|
342
|
+
const [name, ...inline] = args[i].split("=");
|
|
343
|
+
const value = inline.length ? inline.join("=") : undefined;
|
|
344
|
+
if (RETARGET_FLAGS.has(name)) return {};
|
|
345
|
+
if (RETARGET_VALUE_OPTIONS.has(name)) {
|
|
346
|
+
if (!SAME_DIR_VALUES.has(value ?? args[i + 1])) return {};
|
|
347
|
+
i += value === undefined ? 2 : 1;
|
|
348
|
+
} else i += VALUE_OPTIONS.has(name) && value === undefined ? 2 : 1;
|
|
349
|
+
}
|
|
350
|
+
const sub = args[i];
|
|
351
|
+
if (sub === undefined || sub === "--") return {};
|
|
352
|
+
const rest = args.slice(i + 1);
|
|
353
|
+
if (sub === "exec" || sub === "dlx") return inspectCommand(rest, scripts);
|
|
354
|
+
if (sub === "run" || sub === "run-script" || sub === "rum" || sub === "urun") {
|
|
355
|
+
let j = 0;
|
|
356
|
+
while (j < rest.length && rest[j].startsWith("-") && rest[j] !== "--") j += VALUE_OPTIONS.has(rest[j]) ? 2 : 1;
|
|
357
|
+
return rest[j] !== undefined && rest[j] !== "--" ? { run: rest[j] } : {};
|
|
358
|
+
}
|
|
359
|
+
if (sub === "test" || sub === "t" || sub === "tst") return { run: "test" };
|
|
360
|
+
return Object.hasOwn(scripts, sub) ? { run: sub } : {};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** What one simple command does: { call: "rustright" | "cargo" } when it runs the tool (through env words and wrappers), { run: <script> } for a package script, else {}. */
|
|
364
|
+
function inspectCommand(words, scripts) {
|
|
365
|
+
let i = 0;
|
|
366
|
+
for (let guard = 0; guard < 16; guard += 1) {
|
|
367
|
+
while (i < words.length && ENV_ASSIGNMENT.test(words[i])) i += 1;
|
|
368
|
+
if (i >= words.length) return {};
|
|
369
|
+
const word = path.posix.basename(words[i].replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "");
|
|
370
|
+
if (BROKER_TOOLS.has(word)) return { call: word };
|
|
371
|
+
if (PACKAGE_MANAGERS.has(word)) return inspectPackageManager(words.slice(i + 1), scripts);
|
|
372
|
+
if (COMMAND_WRAPPERS.has(word) || PACKAGE_RUNNERS.has(word)) {
|
|
373
|
+
i += 1;
|
|
374
|
+
while (i < words.length && words[i].startsWith("-")) i += 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
return {};
|
|
378
|
+
}
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Whether the package script `entry` runs `rustright` or `cargo`, directly or through the scripts it starts in the same package.json
|
|
384
|
+
* (`pnpm <script>`, `pnpm run <script>`, `npm run <script>`, `yarn [run] <script>`, `npm test`), their `pre<script>` and `post<script>` hooks, and
|
|
385
|
+
* `exec`/`dlx`/`npx` wrappers. A run pointed at another package (`--filter`, `-r`, `--dir` elsewhere) is not followed. Bounded to `maxDepth` nested
|
|
386
|
+
* scripts and cycle-safe. Returns `{ tool, via, command }` for the first call in execution order (`via` is the scripts walked, `command` the command
|
|
387
|
+
* line that calls the tool), or null. Reads package.json `scripts` data only.
|
|
388
|
+
*/
|
|
389
|
+
export function scriptBrokerCall(scripts, entry = "test", { maxDepth = 8 } = {}) {
|
|
390
|
+
if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) return null;
|
|
391
|
+
const textOf = (name) => (Object.hasOwn(scripts, name) && typeof scripts[name] === "string" ? scripts[name].slice(0, SCRIPT_TEXT_LIMIT) : null);
|
|
392
|
+
const seen = new Set();
|
|
393
|
+
const visit = (name, via) => {
|
|
394
|
+
if (via.length >= maxDepth || seen.has(name)) return null;
|
|
395
|
+
seen.add(name);
|
|
396
|
+
for (const part of [`pre${name}`, name, `post${name}`]) {
|
|
397
|
+
const text = textOf(part);
|
|
398
|
+
if (text === null) continue;
|
|
399
|
+
for (const words of shellCommands(text)) {
|
|
400
|
+
const found = inspectCommand(words, scripts);
|
|
401
|
+
if (found.call) return { tool: found.call, via: [...via, part], command: words.join(" ").slice(0, 200) };
|
|
402
|
+
if (found.run !== undefined) {
|
|
403
|
+
const deeper = visit(found.run, [...via, part]);
|
|
404
|
+
if (deeper) return deeper;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
};
|
|
410
|
+
return visit(String(entry), []);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A test script can reach the broker in ways scriptBrokerCall cannot read from package.json alone: `node -e "...spawnSync('pnpm', ['run', n])..."`,
|
|
414
|
+
// `node ./scripts/verify-local.mjs` (a script that spawns `rustright cargo test`), `sh -c`, `pnpm --dir <elsewhere> run verify`, `pnpm --filter`. HeardRight's
|
|
415
|
+
// root `test` is a `node -e` over `verify`, which is `pnpm --dir tauri-app-next run verify`, which is `node ./scripts/verify-local.mjs`, which runs
|
|
416
|
+
// `rustright cargo test`; guessing from the text of such a script would defer tests that merely mention the tool, so the app says it instead.
|
|
417
|
+
// `ci/gate.config.json` `npm.<unit>.requiresBroker` is `{ "reason": "<why this unit's test needs the broker>" }`, committed with the app and reviewed in the
|
|
418
|
+
// diff that adds it. The reason is required: a declaration without one, or of any other shape, is ignored (the test is planned and runs where this host can
|
|
419
|
+
// run it, the way the fail-closed default goes) and planScans says so in a note. `false` and `null` are plain "no declaration". It changes nothing on a host
|
|
420
|
+
// with the broker, in CI, on a protected ref or with the opt-out: the scan runs there exactly as before. Only where rightkit-gate defers the Rust gate does it
|
|
421
|
+
// take the unit's `test:npm:<unit>` scan out, with the Rust scans, named with its reason and never passed.
|
|
422
|
+
const REQUIRES_BROKER_REASON_LIMIT = 400;
|
|
423
|
+
export function declaredBrokerCall(unit, value) {
|
|
424
|
+
if (value === undefined || value === null || value === false) return {};
|
|
425
|
+
const reason = typeof value === "object" && !Array.isArray(value) && typeof value.reason === "string" ? value.reason.replace(/\s+/g, " ").trim() : "";
|
|
426
|
+
if (!reason) return { problem: `ci/gate.config.json npm.${unit}.requiresBroker is ignored: it must be { "reason": "<why this unit's test needs the broker>" } with a non-empty reason; test:npm:${unit} is planned as if it were not there` };
|
|
427
|
+
const text = reason.length > REQUIRES_BROKER_REASON_LIMIT ? `${reason.slice(0, REQUIRES_BROKER_REASON_LIMIT)}...` : reason;
|
|
428
|
+
return { call: { tool: "rustright", via: ["test"], command: `ci/gate.config.json npm.${unit}.requiresBroker: ${text}`, declared: true } };
|
|
429
|
+
}
|
|
430
|
+
|
|
224
431
|
/**
|
|
225
432
|
* The scan plan for a change: derived from the changed files plus the manifests they belong to.
|
|
226
433
|
* `contentRoot` is a clean export of the commit (pure content checks); `liveRoot` is the checkout the
|
|
@@ -255,12 +462,20 @@ export function planScans({
|
|
|
255
462
|
// `crateFeatures` is read by `name` first, then by `crate`. Units of different workspaces never share a lock, a deny/audit run or a
|
|
256
463
|
// `cargo metadata`; a unit that sets no cargoDir belongs to the global `cargoDir` above.
|
|
257
464
|
rustUnits = null, npmUnits = null, cargoDir = ".",
|
|
465
|
+
// pnpm roots: repository-relative directories ("." for the repository root) that hold their own lock or workspace file. null keeps the single
|
|
466
|
+
// root "." (every npm unit belongs to it; one pnpm-lockfile and one pnpm-audit scan run there). With a list, each root gets its own
|
|
467
|
+
// `pnpm-lockfile:<root>` and `pnpm-audit:<root>` scans, run in that directory ("." keeps the unsuffixed ids), planned when one of its units is in
|
|
468
|
+
// scope or one of its own package.json / pnpm-lock.yaml / pnpm-workspace.yaml changed. An npm unit names its root as `root` (default "."); a
|
|
469
|
+
// repository with no pnpm root at all (npmRoots: []) plans no pnpm scan (CodeRight: two independent roots and nothing at the repository root).
|
|
470
|
+
npmRoots = null,
|
|
258
471
|
// Parsed Cargo.lock graph (lock-graph.mjs parseLock). With it, every workspace crate that depends on a crate in scope and is
|
|
259
472
|
// not itself in scope gets a `check-dependent` scan, as CI's rust-check rows do. null plans none.
|
|
260
473
|
rustLock = null,
|
|
261
474
|
// The same per Cargo workspace: { [cargoDir]: parsedLock }. A workspace without an entry here (and not the global `cargoDir`) plans
|
|
262
475
|
// no reverse dependents.
|
|
263
476
|
rustLocks = null,
|
|
477
|
+
// The pinned RustSec advisory database ({ url, commit }, advisory-db.json): cargo-deny and cargo-audit read exactly that commit and never fetch.
|
|
478
|
+
advisoryPin = null,
|
|
264
479
|
} = {}) {
|
|
265
480
|
if (!contentRoot) throw new TypeError("planScans: contentRoot is required");
|
|
266
481
|
if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rustright", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
|
|
@@ -320,6 +535,15 @@ export function planScans({
|
|
|
320
535
|
const dir = cleanDir(u.dir);
|
|
321
536
|
if (!plainDir(dir)) { refuse(`${kind} unit ${JSON.stringify(u.name)} directory ${JSON.stringify(u.dir)}`, "not a plain repository-relative path"); continue; }
|
|
322
537
|
const entry = { dir, publish: u.publish !== false };
|
|
538
|
+
if (kind === "npm") {
|
|
539
|
+
entry.root = ".";
|
|
540
|
+
if (u.root !== undefined) {
|
|
541
|
+
if (typeof u.root !== "string") throw new TypeError(`planScans: npm unit ${u.name} needs a string root`);
|
|
542
|
+
const r = cleanDir(u.root);
|
|
543
|
+
if (!plainDir(r) || !(r === "." || dir === r || dir.startsWith(`${r}/`))) { refuse(`npm unit ${JSON.stringify(u.name)} root ${JSON.stringify(u.root)}`, "not a plain repository-relative directory that holds the unit"); continue; }
|
|
544
|
+
entry.root = r;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
323
547
|
if (kind === "rust") {
|
|
324
548
|
if (u.crate !== undefined && typeof u.crate !== "string") throw new TypeError(`planScans: rust unit ${u.name} needs a string crate`);
|
|
325
549
|
entry.crate = u.crate ?? u.name;
|
|
@@ -335,6 +559,24 @@ export function planScans({
|
|
|
335
559
|
return map;
|
|
336
560
|
};
|
|
337
561
|
const rustMap = unitMap(rustUnits, "rust"); const npmMap = unitMap(npmUnits, "npm");
|
|
562
|
+
// The pnpm roots (see npmRoots): the listed ones and the roots the units name.
|
|
563
|
+
let pnpmRoots = ["."];
|
|
564
|
+
if (npmRoots !== null) {
|
|
565
|
+
if (!Array.isArray(npmRoots)) throw new TypeError("planScans: npmRoots must be null or an array of repository-relative directories");
|
|
566
|
+
const set = new Set(npmMap ? [...npmMap.values()].map((u) => u.root) : []);
|
|
567
|
+
for (const raw of npmRoots) {
|
|
568
|
+
const r = cleanDir(String(raw));
|
|
569
|
+
if (plainDir(r)) set.add(r); else refuse(`pnpm root ${JSON.stringify(raw)}`, "not a plain repository-relative path");
|
|
570
|
+
}
|
|
571
|
+
pnpmRoots = [...set].sort();
|
|
572
|
+
}
|
|
573
|
+
const rootOfUnit = (n) => (npmRoots !== null && npmMap ? npmMap.get(n).root : ".");
|
|
574
|
+
// The deepest pnpm root that holds a repository-relative file (every file is under "." when "." is a root).
|
|
575
|
+
const rootOfFile = (f) => {
|
|
576
|
+
let best = null;
|
|
577
|
+
for (const r of pnpmRoots) if ((r === "." || f.startsWith(`${r}/`)) && (best === null || r.length > best.length)) best = r;
|
|
578
|
+
return best;
|
|
579
|
+
};
|
|
338
580
|
// Units of an explicit-workspace layout (some unit names its cargoDir) get workspace-suffixed ids for the per-workspace scans.
|
|
339
581
|
const perWorkspaceIds = Array.isArray(rustUnits) && rustUnits.some((u) => u?.cargoDir !== undefined);
|
|
340
582
|
const wsIdSuffix = (ws) => (perWorkspaceIds && ws !== "." ? `:${ws}` : "");
|
|
@@ -379,21 +621,16 @@ export function planScans({
|
|
|
379
621
|
};
|
|
380
622
|
const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f));
|
|
381
623
|
const byEdition = new Map();
|
|
624
|
+
const editionOf = rustEditionResolver({ contentRoot, exists, readFile });
|
|
625
|
+
const refusedEditions = new Set();
|
|
382
626
|
for (const f of rustFiles) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const m = readFile(manifest).match(/^edition\s*=\s*"(\d{4})"/m);
|
|
389
|
-
if (m) edition = m[1];
|
|
390
|
-
break;
|
|
391
|
-
}
|
|
392
|
-
const parent = path.dirname(dir);
|
|
393
|
-
if (parent === dir) break;
|
|
394
|
-
dir = parent;
|
|
627
|
+
const found = editionOf(f);
|
|
628
|
+
if (found.problem) {
|
|
629
|
+
// Never guess an edition: a file whose edition cannot be resolved is not rustfmt-checked under a default, the gate fails on it.
|
|
630
|
+
if (!refusedEditions.has(found.manifest)) { refusedEditions.add(found.manifest); refuse(`rustfmt edition of ${found.manifest}`, found.problem); }
|
|
631
|
+
continue;
|
|
395
632
|
}
|
|
396
|
-
byEdition.set(edition, [...(byEdition.get(edition) ?? []), f]);
|
|
633
|
+
byEdition.set(found.edition, [...(byEdition.get(found.edition) ?? []), f]);
|
|
397
634
|
}
|
|
398
635
|
for (const [edition, group] of [...byEdition].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
399
636
|
add({ id: `rustfmt:${edition}`, class: A, tool: "rustfmt", args: ["--check", "--edition", edition, ...group.sort()], root: "content" });
|
|
@@ -417,7 +654,8 @@ export function planScans({
|
|
|
417
654
|
// A workspace is also wide when Rust configuration changed in ANY ancestor directory of it (a repository-level .cargo/config.toml,
|
|
418
655
|
// an intermediate apps/.cargo, a root rust-toolchain.toml, clippy.toml, rustfmt.toml or deny.toml reaches every nested workspace).
|
|
419
656
|
const wideWs = new Set(workspaces.filter((ws) => ancestorConfigChanged(ws, files) || files.some((f) => { const r = wsRel(ws, f); return r !== null && WORKSPACE_RUST_FILE.test(r); })));
|
|
420
|
-
|
|
657
|
+
// A pnpm root's own install graph (package.json, lock, workspace file, .npmrc) changed: every unit of that root is in scope.
|
|
658
|
+
const wideJsRoots = new Set(pnpmRoots.filter((r) => files.some((f) => { const rel = wsRel(r, f); return rel !== null && WORKSPACE_JS_FILE.test(rel); })));
|
|
421
659
|
const wideCrates = wideWs.size ? (rustMap ? [...rustMap.keys()].filter((n) => wideWs.has(wsOf(n))) : listDir(path.join(contentRoot, "crates"))).filter((n) => !rustScope || rustScope.has(n)) : [];
|
|
422
660
|
if (wideWs.size && rustScope) notes.push(`workspace Rust change reaches ${wideCrates.length} crate(s) through the lock graph`);
|
|
423
661
|
const crateNames = safeUnits([...new Set([...(rustMap ? files.map((f) => unitOf(rustMap, f)).filter(Boolean) : under("crates/").map((f) => f.split("/")[1])), ...wideCrates])].filter((n) => exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort(), "crate");
|
|
@@ -440,18 +678,37 @@ export function planScans({
|
|
|
440
678
|
if (n !== null && npmMap.get(n).dir === "." && rustMap) { const r = unitOf(rustMap, f); if (r !== null && rustMap.get(r).dir !== ".") return null; }
|
|
441
679
|
return n;
|
|
442
680
|
};
|
|
443
|
-
const pkgNames = safeUnits([...new Set([...(npmMap ? files.map(npmOwner).filter(Boolean) : under("packages/").map((f) => f.split("/")[1])), ...(
|
|
444
|
-
|
|
681
|
+
const pkgNames = safeUnits([...new Set([...(npmMap ? files.map(npmOwner).filter(Boolean) : under("packages/").map((f) => f.split("/")[1])), ...(wideJsRoots.size ? (npmMap ? [...npmMap.keys()].filter((n) => wideJsRoots.has(rootOfUnit(n))) : wideJsRoots.has(".") ? listDir(path.join(contentRoot, "packages")) : []) : [])])].filter((n) => exists(path.join(contentRoot, npmDir(n), "package.json"))).sort(), "package");
|
|
682
|
+
// The pnpm roots with something to check: a unit in scope, or a package.json / lock / workspace file that belongs to the root (the deepest one).
|
|
683
|
+
const jsRoots = [...new Set([...pkgNames.map(rootOfUnit), ...files.filter((f) => JS_TOOLING_FILE.test(f)).map(rootOfFile).filter((r) => r !== null)])].sort();
|
|
445
684
|
|
|
446
685
|
// Explicit files: the exported commit has no .git for actionlint's project discovery.
|
|
447
686
|
const workflowFiles = files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f)).sort();
|
|
448
687
|
const actionlintConfig = exists(path.join(contentRoot, ".github/actionlint.yaml")) ? ["-config-file", ".github/actionlint.yaml"] : [];
|
|
449
|
-
|
|
688
|
+
// actionlint lints every run: script with shellcheck only when shellcheck is found, and otherwise passes silently; class A
|
|
689
|
+
// requires it (`requires`), so a host without it fails the scan instead of skipping the shell checks.
|
|
690
|
+
if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", requires: ["shellcheck"], args: [...actionlintConfig, ...workflowFiles], root: "content" });
|
|
691
|
+
// Standalone shell scripts (SellRight's deploy scripts failed SC2154, SC2030/2031 and SC2016 in CI): every tracked .sh/.bash file,
|
|
692
|
+
// with ShellCheck's own defaults and the repository's .shellcheckrc (data, not code). actionlint covers only workflow run: blocks.
|
|
693
|
+
// A script whose shebang names another shell (zsh, fish) is not ShellCheck's to judge, so it is left out, never failed.
|
|
694
|
+
const otherShell = (f) => { try { return /^#!.*\b(zsh|fish|pwsh|powershell)\b/.test(String(readFile(path.join(contentRoot, f))).split("\n", 1)[0]); } catch { return false; } };
|
|
695
|
+
const shellFiles = files.filter((f) => /\.(sh|bash)$/.test(f) && !/(^|\/)node_modules\//.test(f) && !otherShell(f)).sort();
|
|
696
|
+
if (shellFiles.length) add({ id: "shellcheck", class: A, tool: "shellcheck", args: ["--format=gcc", "--external-sources", ...shellFiles], root: "content" });
|
|
450
697
|
// No right-git drift scan: the one workflow right-git renders here, ci.yml, is hand-owned (its header;
|
|
451
698
|
// docs/CI.md) and held by scripts/ci/ci-scans-contract.mjs instead, so drift is always reported by design.
|
|
452
699
|
|
|
453
700
|
// Exceptions expire by date, not by change: checked on every run, whatever changed.
|
|
454
701
|
if (exists(path.join(contentRoot, "ci/advisory-exceptions.json"))) add({ id: "advisory-exceptions", class: A, internal: "advisory-exceptions" });
|
|
702
|
+
// An app's advisory exceptions follow the same policy as RightKit's own: a Cargo workspace whose deny.toml or .cargo/audit.toml
|
|
703
|
+
// ignores an advisory needs its ledger, <workspace>/.rightkit/advisory-exceptions.json, with owner, evidence, an unexpired
|
|
704
|
+
// reviewBy, the exact affected versions and dependents in that workspace's Cargo.lock, and a person's approval; deny.toml and
|
|
705
|
+
// audit.toml must match it exactly.
|
|
706
|
+
for (const ws of workspaces) {
|
|
707
|
+
if (typeof ws !== "string") continue;
|
|
708
|
+
const dir = ws === "." ? "" : ws;
|
|
709
|
+
const ignores = ["deny.toml", ".cargo/audit.toml"].some((f) => { try { return /\bignore\s*=\s*\[[^\]]*\S[^\]]*\]/s.test(readFile(path.join(contentRoot, dir, f))); } catch { return false; } });
|
|
710
|
+
if (ignores && !(dir === "" && exists(path.join(contentRoot, "ci/advisory-exceptions.json")))) add({ id: `advisory-exceptions${dir ? `:${dir}` : ""}`, class: A, internal: "advisory-exceptions", dir, exceptionsFile: ".rightkit/advisory-exceptions.json" });
|
|
711
|
+
}
|
|
455
712
|
// Like generated-docs, planned only when a changed file is one of the contract's inputs or projections. The generator runs from
|
|
456
713
|
// the trusted scanner checkout (the import below); the exported contract and projections are data it checks.
|
|
457
714
|
if (files.some((f) => SUITE_API_INPUT.test(f)) && exists(path.join(contentRoot, "contracts/right-suite-api.v1.json"))) add({ id: "suite-api", class: A, internal: "suite-api" });
|
|
@@ -460,14 +717,43 @@ export function planScans({
|
|
|
460
717
|
for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
|
|
461
718
|
}
|
|
462
719
|
for (const ws of [...configWs].sort()) {
|
|
463
|
-
|
|
464
|
-
|
|
720
|
+
// Offline against the pinned database. The pin commit is part of the command (env), so a pin change is a different scan, and the
|
|
721
|
+
// placeholders are filled at run time from the materialised checkout (runScans).
|
|
722
|
+
const pin = advisoryPin ?? (advisoryPin = readAdvisoryPin());
|
|
723
|
+
const pinEnv = { RK_ADVISORY_DB_COMMIT: pin.commit };
|
|
724
|
+
add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "--offline", "--metadata-path", "{advisoryMetadata}", "--config", "{advisoryDenyConfig}", "check"), env: pinEnv, advisoryDb: "deny", advisoryPin: pin });
|
|
725
|
+
add({ id: `cargo-audit${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "audit", "--db", "{advisoryDb}", "--no-fetch"), env: pinEnv, advisoryDb: "audit", advisoryPin: pin });
|
|
465
726
|
}
|
|
466
|
-
|
|
727
|
+
// The audit policy is the consumer's own: every package.json script that is exactly `pnpm audit <flags>` (only --prod, --dev,
|
|
728
|
+
// --audit-level <low|moderate|high|critical> are accepted) declares one, and the scan runs the strictest of them all: a dependency
|
|
729
|
+
// scope only when every declaration names that same scope, and the lowest audit level any declares (one without a level means low).
|
|
730
|
+
// With none declared, every dependency at every severity. The gate never runs a weaker audit than the repository declares
|
|
731
|
+
// (RightSites declares `pnpm audit --prod`: production dependencies, all severities).
|
|
732
|
+
const LEVELS = ["low", "moderate", "high", "critical"];
|
|
733
|
+
const consumerAuditFlags = (r) => {
|
|
734
|
+
let scripts = {};
|
|
735
|
+
try { scripts = JSON.parse(readFile(path.join(contentRoot, r, "package.json"))).scripts ?? {}; } catch { /* none: the strict default */ }
|
|
736
|
+
const declared = [];
|
|
737
|
+
for (const cmd of Object.values(scripts)) {
|
|
738
|
+
const m = /^\s*pnpm\s+audit((?:\s+(?:--prod|--dev|--audit-level(?:=|\s+)(?:low|moderate|high|critical)))*)\s*$/.exec(String(cmd));
|
|
739
|
+
if (!m) continue;
|
|
740
|
+
const words = m[1].trim().split(/\s+|=/).filter(Boolean);
|
|
741
|
+
const at = words.indexOf("--audit-level");
|
|
742
|
+
const scope = words.includes("--prod") && !words.includes("--dev") ? "--prod" : words.includes("--dev") && !words.includes("--prod") ? "--dev" : null;
|
|
743
|
+
declared.push({ scope, level: at < 0 ? 0 : LEVELS.indexOf(words[at + 1]) });
|
|
744
|
+
}
|
|
745
|
+
if (!declared.length) return [];
|
|
746
|
+
const scope = declared.every((d) => d.scope === declared[0].scope) ? declared[0].scope : null;
|
|
747
|
+
const level = Math.min(...declared.map((d) => d.level));
|
|
748
|
+
return [...(scope ? [scope] : []), ...(level > 0 ? ["--audit-level", LEVELS[level]] : [])];
|
|
749
|
+
};
|
|
750
|
+
for (const r of jsRoots) {
|
|
467
751
|
// Class A runs no repository hooks: `install` accepts --ignore-pnpmfile and --ignore-scripts; `audit` accepts
|
|
468
752
|
// neither flag, so every class A pnpm scan also sets the same two settings through the environment.
|
|
469
|
-
|
|
470
|
-
|
|
753
|
+
// One pair per pnpm root, run in the root's directory; the repository root keeps the unsuffixed ids.
|
|
754
|
+
const where = r === "." ? {} : { cwd: r }; const suffix = r === "." ? "" : `:${r}`;
|
|
755
|
+
add({ id: `pnpm-lockfile${suffix}`, class: A, tool: "pnpm", args: ["install", "--frozen-lockfile", "--lockfile-only", "--ignore-pnpmfile", "--ignore-scripts"], env: PNPM_NO_HOOKS_ENV, root: "content", ...where });
|
|
756
|
+
add({ id: `pnpm-audit${suffix}`, class: A, tool: "pnpm", args: ["audit", ...consumerAuditFlags(r)], env: PNPM_NO_HOOKS_ENV, root: "content", ...where });
|
|
471
757
|
}
|
|
472
758
|
const crateDirs = crateNames.map(rustDir); const pkgDirs = pkgNames.map(npmDir);
|
|
473
759
|
const withChangelog = crateNames.filter((n) => exists(path.join(contentRoot, rustDir(n), "CHANGELOG.md")) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")));
|
|
@@ -521,11 +807,13 @@ export function planScans({
|
|
|
521
807
|
const dependents = safeUnits(workspace.filter((n) => !inScope.has(n) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")) && scoped.some((t) => reach(lock, crateOf(n), memo).has(t))).sort(), "crate");
|
|
522
808
|
for (const name of dependents) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) });
|
|
523
809
|
}
|
|
524
|
-
|
|
810
|
+
// A linter config in the unit's directory or any ancestor of it (a repository-level config covers every unit, as the tools look it up).
|
|
811
|
+
const hasConfig = (dir, names) => ancestorDirsOf(dir).some((d) => names.some((f) => exists(path.join(liveRoot, inDir(d, f)))));
|
|
525
812
|
for (const name of pkgNames) {
|
|
526
813
|
const dir = npmDir(name);
|
|
527
|
-
if (exists(path.join(liveRoot, dir, "tsconfig.json"))) add({ id: `tsc:${name}`, class: B, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], cwd: dir });
|
|
528
|
-
if (
|
|
814
|
+
if (exists(path.join(liveRoot, dir, "tsconfig.json"))) add({ id: `tsc:${name}`, class: B, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], env: PNPM_RUN_ENV, cwd: dir });
|
|
815
|
+
if (hasConfig(dir, ESLINT_CONFIG_FILES)) add({ id: `eslint:${name}`, class: B, tool: "pnpm", args: ["exec", "eslint", "."], env: PNPM_RUN_ENV, cwd: dir });
|
|
816
|
+
if (hasConfig(dir, BIOME_CONFIG_FILES)) add({ id: `biome:${name}`, class: B, tool: "pnpm", args: ["exec", "biome", "lint", "."], env: PNPM_RUN_ENV, cwd: dir });
|
|
529
817
|
}
|
|
530
818
|
if (files.some((f) => f.startsWith("swift/") || f === "Package.swift")) {
|
|
531
819
|
if (platform === "darwin") add({ id: "swift-build", class: B, tool: "swift", args: ["build"] });
|
|
@@ -558,7 +846,13 @@ export function planScans({
|
|
|
558
846
|
if (own.quietTests === true && quietTests === "ci") { deferredToCi.push(`test:npm:${name}`); continue; }
|
|
559
847
|
// A gate proves the repository's own content: sibling checkouts beside it (RightKit's suite-contract tests read the app
|
|
560
848
|
// repositories when present) are not inputs, so the package tests run as an isolated checkout, as they do in CI.
|
|
561
|
-
|
|
849
|
+
// `brokerCall` marks a test script that runs the broker (or cargo) through its script chain (scriptBrokerCall), or a unit whose app declares that
|
|
850
|
+
// its test needs the broker (declaredBrokerCall): rightkit-gate defers such a scan with the Rust gate where this host has no broker.
|
|
851
|
+
// A declaration (`npm.<unit>.requiresBroker`, declaredBrokerCall) covers what the script text does not show; a call the scripts do show wins.
|
|
852
|
+
const declared = declaredBrokerCall(name, own.requiresBroker);
|
|
853
|
+
if (declared.problem) notes.push(declared.problem);
|
|
854
|
+
const brokerCall = scriptBrokerCall(manifest.scripts, "test") ?? declared.call ?? null;
|
|
855
|
+
add({ id: `test:npm:${name}`, class: B, ...(own.quietTests === true ? { quiet: true } : {}), ...(brokerCall ? { brokerCall } : {}), tool: "pnpm", args: ["--dir", npmDir(name), "run", "test"], env: { ...PNPM_RUN_ENV, RIGHTKIT_ISOLATED_CHECKOUT: "1" } });
|
|
562
856
|
}
|
|
563
857
|
if (deferredToCi.length) notes.push(`${deferredToCi.length} quiet test scan(s) left to the CI shards (one crate per runner), not run here: ${deferredToCi.join(", ")}`);
|
|
564
858
|
// scripts/ci tests run when scripts/, ci/ or .github/ changed, or a file the scripts/ci sources reference (imports and
|
|
@@ -670,9 +964,10 @@ export function killProcessTree(child, { platform = process.platform, run = spaw
|
|
|
670
964
|
/**
|
|
671
965
|
* The same invocation as defaultSpawn without blocking, so scans can run side by side. Keeps the last 4 MiB of each stream.
|
|
672
966
|
* With `options.signal` (an AbortSignal), an abort kills the whole process tree and the result carries `cancelled: true`.
|
|
967
|
+
* With `options.stdoutFile`, stdout is written to that file whole instead of kept in memory.
|
|
673
968
|
*/
|
|
674
969
|
export function defaultSpawnAsync(command, args, options = {}) {
|
|
675
|
-
const { signal, ...rest } = options;
|
|
970
|
+
const { signal, stdoutFile, ...rest } = options;
|
|
676
971
|
const inv = resolveInvocation(command, args, { env: rest.env ?? process.env });
|
|
677
972
|
if (inv.error) return Promise.resolve({ status: null, stdout: "", stderr: "", error: inv.error });
|
|
678
973
|
if (signal?.aborted) return Promise.resolve({ status: null, stdout: "", stderr: "", error: new Error("cancelled"), cancelled: true });
|
|
@@ -686,10 +981,12 @@ export function defaultSpawnAsync(command, args, options = {}) {
|
|
|
686
981
|
const onAbort = () => killProcessTree(child);
|
|
687
982
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
688
983
|
child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
|
|
689
|
-
|
|
984
|
+
const sink = stdoutFile ? fs.createWriteStream(stdoutFile) : null;
|
|
985
|
+
if (sink) child.stdout.pipe(sink); else child.stdout.on("data", (c) => { stdout = keep(stdout, c); });
|
|
690
986
|
child.stderr.on("data", (c) => { stderr = keep(stderr, c); });
|
|
691
987
|
child.on("error", (e) => { error = e; });
|
|
692
|
-
child.on("close", (status) => {
|
|
988
|
+
child.on("close", async (status) => {
|
|
989
|
+
if (sink) await new Promise((done) => (sink.writableFinished ? done() : sink.on("finish", done).on("error", done)));
|
|
693
990
|
signal?.removeEventListener("abort", onAbort);
|
|
694
991
|
const cancelled = signal?.aborted === true;
|
|
695
992
|
resolve({ status, stdout, stderr, ...(error ? { error } : cancelled ? { error: new Error("cancelled") } : {}), ...(cancelled ? { cancelled: true } : {}) });
|
|
@@ -712,7 +1009,7 @@ export function laneOf(scan) {
|
|
|
712
1009
|
// The lines a reader needs from a failed scan: every line that names a failure (a failed test, a panic, a compiler or tool error,
|
|
713
1010
|
// cargo test's `failures:` block header, pnpm's ERR_ codes), at most twelve of them, then the last four lines of stdout and of
|
|
714
1011
|
// stderr. cargo test writes results to stdout and "Running ..." to stderr, so a plain tail showed only harness chatter.
|
|
715
|
-
const FAILURE_LINE = /(
|
|
1012
|
+
const FAILURE_LINE = /(^|[\s\[])(FAILED|panicked at|error(\[E\d+\])?:|error\[|^failures:|ERR_PNPM_\w+|Error:|assertion `?left|^thread '.*' panicked)/;
|
|
716
1013
|
export function failureDetail(r) {
|
|
717
1014
|
const lines = (text) => String(text ?? "").split(/\r?\n/).map((l) => l.trimEnd()).filter((l) => l.trim());
|
|
718
1015
|
const out = lines(r?.stdout); const err = lines(r?.stderr);
|
|
@@ -735,6 +1032,8 @@ export function findRustfmt({ env = process.env, exists = fs.existsSync, readdir
|
|
|
735
1032
|
if (found.length) return path.join(toolchains, found[0].d, "bin", exe);
|
|
736
1033
|
}
|
|
737
1034
|
const which = spawn(platform === "win32" ? "where" : "which", [exe]);
|
|
1035
|
+
// A Promise has no status: an async spawn here would report a present rustfmt as missing.
|
|
1036
|
+
if (typeof which?.then === "function") throw new TypeError("findRustfmt needs a synchronous spawn");
|
|
738
1037
|
return which && which.status === 0 ? String(which.stdout).split(/\r?\n/)[0].trim() : null;
|
|
739
1038
|
}
|
|
740
1039
|
|
|
@@ -753,11 +1052,97 @@ async function defaultScanSecrets() {
|
|
|
753
1052
|
return (await import("./publish-cargo.mjs")).scanCrateForSecrets;
|
|
754
1053
|
}
|
|
755
1054
|
|
|
1055
|
+
// What rustfmt needs from a Cargo.toml, read by table so an `edition` under [package.metadata.x] or [dependencies.x] is not the
|
|
1056
|
+
// package's: `pkg` is the [package] edition (a literal year, or "workspace" for `edition.workspace = true` / `edition = { workspace = true }`),
|
|
1057
|
+
// `ws` the [workspace.package] edition literal, `isWorkspace` whether the manifest declares a [workspace]. An `edition` entry in one of
|
|
1058
|
+
// those two tables that is none of the recognised spellings (`edition = 2021`, `edition.workspace = false`) is kept as `pkgBad` / `wsBad`
|
|
1059
|
+
// (the line), never skipped, so it cannot fall back to a default. Keys before any table header count as [package] keys (the fixtures of
|
|
1060
|
+
// this package's tests write manifests that way; cargo itself rejects them).
|
|
1061
|
+
export function cargoEditions(text) {
|
|
1062
|
+
const out = { pkg: undefined, ws: undefined, pkgBad: undefined, wsBad: undefined, isWorkspace: false };
|
|
1063
|
+
let table = "";
|
|
1064
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
1065
|
+
const header = /^\s*\[\[?([^\]]+)\]\]?\s*(?:#.*)?$/.exec(raw);
|
|
1066
|
+
if (header) {
|
|
1067
|
+
table = header[1].trim();
|
|
1068
|
+
if (table === "workspace" || table.startsWith("workspace.")) out.isWorkspace = true;
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
if (table !== "" && table !== "package" && table !== "workspace.package") continue;
|
|
1072
|
+
const line = raw.replace(/^(\s*)(["'])edition\2/, "$1edition");
|
|
1073
|
+
if (!/^\s*edition\s*(?:[.=]|$)/.test(line)) continue;
|
|
1074
|
+
let value;
|
|
1075
|
+
if (/^\s*edition\.workspace\s*=\s*true\s*(?:#.*)?$/.test(line) || /^\s*edition\s*=\s*\{\s*workspace\s*=\s*true\s*\}\s*(?:#.*)?$/.test(line)) value = "workspace";
|
|
1076
|
+
else value = (/^\s*edition\s*=\s*(?:"(\d{4})"|'(\d{4})')\s*(?:#.*)?$/.exec(line) || []).slice(1).find(Boolean);
|
|
1077
|
+
if (table === "workspace.package") {
|
|
1078
|
+
if (value !== undefined && value !== "workspace") out.ws = value; else out.wsBad = raw.trim();
|
|
1079
|
+
} else if (value !== undefined) out.pkg = value;
|
|
1080
|
+
else out.pkgBad = raw.trim();
|
|
1081
|
+
}
|
|
1082
|
+
return out;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* The edition rustfmt must run a Rust file under: `{ edition }`, or `{ manifest, problem }` when it cannot be resolved.
|
|
1087
|
+
* The nearest Cargo.toml at or above the file decides. A literal `edition = "NNNN"` is that edition; `edition.workspace = true`
|
|
1088
|
+
* (or `edition = { workspace = true }`) is the `[workspace.package] edition` of the owning workspace root, the nearest manifest at or
|
|
1089
|
+
* above it that declares `[workspace]`, which is the manifest itself for a root package. An inheritance that cannot be followed
|
|
1090
|
+
* (no workspace root, an unreadable manifest, or a root with no literal `[workspace.package] edition`) is a `problem`, never a default.
|
|
1091
|
+
* A manifest that names no edition at all, and a file below no manifest, keep rustfmt's "2021" as before.
|
|
1092
|
+
*/
|
|
1093
|
+
function rustEditionResolver({ contentRoot, exists, readFile }) {
|
|
1094
|
+
const root = path.normalize(contentRoot);
|
|
1095
|
+
const inside = (dir) => { const rel = path.relative(root, dir); return rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)); };
|
|
1096
|
+
const rel = (dir) => posix(path.relative(root, path.join(dir, "Cargo.toml")));
|
|
1097
|
+
const parsed = new Map();
|
|
1098
|
+
const manifestAt = (dir) => {
|
|
1099
|
+
if (!parsed.has(dir)) {
|
|
1100
|
+
let value;
|
|
1101
|
+
try { value = cargoEditions(readFile(path.join(dir, "Cargo.toml"))); } catch (e) { value = { unreadable: String(e?.message ?? e).split("\n")[0] }; }
|
|
1102
|
+
parsed.set(dir, value);
|
|
1103
|
+
}
|
|
1104
|
+
return parsed.get(dir);
|
|
1105
|
+
};
|
|
1106
|
+
const ancestors = function* (start) {
|
|
1107
|
+
for (let dir = start; inside(dir); dir = path.dirname(dir)) {
|
|
1108
|
+
yield dir;
|
|
1109
|
+
if (path.dirname(dir) === dir) return;
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
return (file) => {
|
|
1113
|
+
const dir = [...ancestors(path.dirname(path.join(root, file)))].find((d) => exists(path.join(d, "Cargo.toml")));
|
|
1114
|
+
if (dir === undefined) return { edition: "2021" };
|
|
1115
|
+
const manifest = rel(dir);
|
|
1116
|
+
const info = manifestAt(dir);
|
|
1117
|
+
if (info.unreadable) return { manifest, problem: `${manifest} cannot be read (${info.unreadable}), so its edition is unknown` };
|
|
1118
|
+
if (info.pkgBad !== undefined) return { manifest, problem: `${manifest} has an edition entry the scan cannot read (${info.pkgBad}); write edition = "NNNN" or edition.workspace = true` };
|
|
1119
|
+
if (info.pkg === undefined) return { edition: info.ws ?? "2021" };
|
|
1120
|
+
if (info.pkg !== "workspace") return { edition: info.pkg };
|
|
1121
|
+
const inherits = `${manifest} sets edition.workspace = true`;
|
|
1122
|
+
for (const up of ancestors(dir)) {
|
|
1123
|
+
if (!exists(path.join(up, "Cargo.toml"))) continue;
|
|
1124
|
+
const w = manifestAt(up);
|
|
1125
|
+
if (w.unreadable) return { manifest, problem: `${inherits}, but ${rel(up)} cannot be read (${w.unreadable}), so its edition is unknown` };
|
|
1126
|
+
if (!w.isWorkspace) continue;
|
|
1127
|
+
if (w.wsBad !== undefined) return { manifest, problem: `${inherits}, but its workspace root ${rel(up)} has a [workspace.package] edition entry the scan cannot read (${w.wsBad})` };
|
|
1128
|
+
if (w.ws === undefined) return { manifest, problem: `${inherits}, but its workspace root ${rel(up)} declares no literal [workspace.package] edition = "NNNN" to inherit` };
|
|
1129
|
+
return { edition: w.ws };
|
|
1130
|
+
}
|
|
1131
|
+
return { manifest, problem: `${inherits}, but no Cargo.toml at or above it declares a [workspace], so there is no [workspace.package] edition to inherit` };
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// A crate that can never be published (`publish = false` or `publish = []` in its [package] table) needs no license key, like a
|
|
1136
|
+
// package.json with `"private": true`; cargo-manifest-text.mjs holds the one reading of that rule, which the secret scan shares.
|
|
1137
|
+
function cargoUnpublished(text) { return cargoManifestState(text).unpublished; }
|
|
1138
|
+
|
|
756
1139
|
const INTERNAL = {
|
|
757
1140
|
"json-parse": (s, ctx) => {
|
|
758
1141
|
const bad = [];
|
|
759
1142
|
for (const f of s.files) {
|
|
760
|
-
|
|
1143
|
+
// tsconfig/jsconfig and editor settings are JSON with comments by their own spec (TypeScript reads them that way).
|
|
1144
|
+
const jsonc = /(^|\/)(tsconfig|jsconfig)(\.[^/]*)?\.json$|(^|\/)\.vscode\/[^/]+\.json$/.test(f);
|
|
1145
|
+
try { const text = ctx.read(f).replace(/^\uFEFF/, ""); JSON.parse(jsonc ? stripJsonComments(text) : text); } catch (e) { bad.push(`${f}: ${e.message}`); }
|
|
761
1146
|
}
|
|
762
1147
|
return bad;
|
|
763
1148
|
},
|
|
@@ -778,7 +1163,9 @@ const INTERNAL = {
|
|
|
778
1163
|
refuse: (s) => [s.detail],
|
|
779
1164
|
// Every RustSec exception is dated, scoped to the locked versions and identical in deny.toml and
|
|
780
1165
|
// .cargo/audit.toml (ci/advisory-exceptions.json; Astra 2026-10-22). Expiry fails every run.
|
|
781
|
-
"advisory-exceptions": (s, ctx) =>
|
|
1166
|
+
"advisory-exceptions": (s, ctx) => (s.dir === undefined
|
|
1167
|
+
? checkAdvisoryExceptions({ root: ctx.contentRoot, readFile: ctx.read })
|
|
1168
|
+
: checkAdvisoryExceptions({ root: path.join(ctx.contentRoot, s.dir), readFile: (p) => ctx.read(path.join(s.dir, p)), exceptionsFile: s.exceptionsFile }).map((problem) => `${s.dir || "."}: ${problem}`)),
|
|
782
1169
|
// Every Right Suite API projection must equal what the generator writes from the contract. The pure check is imported from the
|
|
783
1170
|
// scanner's own checkout (scripts/ sits beside packages/release), never from the content root.
|
|
784
1171
|
"suite-api": async (s, ctx) => {
|
|
@@ -797,22 +1184,25 @@ const INTERNAL = {
|
|
|
797
1184
|
for (const f of s.files) {
|
|
798
1185
|
const text = ctx.read(f);
|
|
799
1186
|
if (f.endsWith("Cargo.toml")) {
|
|
800
|
-
if (!/^\s*(license|license-file)\s*(=|\.workspace)/m.test(text)) bad.push(`${f}: no license or license-file`);
|
|
1187
|
+
if (!/^\s*(license|license-file)\s*(=|\.workspace)/m.test(text) && !cargoUnpublished(text)) bad.push(`${f}: no license or license-file`);
|
|
801
1188
|
} else {
|
|
802
1189
|
try { const m = JSON.parse(text); if (m.private !== true && !(typeof m.license === "string" && m.license.trim()) && !m.licenses) bad.push(`${f}: no license field`); } catch (e) { bad.push(`${f}: ${e.message}`); }
|
|
803
1190
|
}
|
|
804
1191
|
}
|
|
805
1192
|
return bad;
|
|
806
1193
|
},
|
|
1194
|
+
// The repository gate reads env templates and test vectors against the reviewed records of the gated commit
|
|
1195
|
+
// (.rightkit/secret-scan-records.json, read from the content root by the scanner); crate packaging stays strict.
|
|
1196
|
+
// Each violation names its repository-relative path, so a unit that is both a crate and a package reports a file once.
|
|
807
1197
|
"secret-scan": async (s, ctx) => {
|
|
808
1198
|
const scan = await ctx.deps.scanSecrets();
|
|
809
|
-
const bad =
|
|
1199
|
+
const bad = new Set();
|
|
810
1200
|
for (const dir of s.dirs) {
|
|
811
1201
|
const abs = path.join(ctx.contentRoot, dir);
|
|
812
|
-
if (!ctx.deps.exists(abs)) { bad.
|
|
813
|
-
for (const v of scan(abs)) bad.
|
|
1202
|
+
if (!ctx.deps.exists(abs)) { bad.add(`${dir}: directory not found`); continue; }
|
|
1203
|
+
for (const v of scan(abs, { envTemplates: true, repoRoot: ctx.contentRoot })) bad.add(v);
|
|
814
1204
|
}
|
|
815
|
-
return bad;
|
|
1205
|
+
return [...bad];
|
|
816
1206
|
},
|
|
817
1207
|
};
|
|
818
1208
|
|
|
@@ -824,7 +1214,9 @@ const INTERNAL = {
|
|
|
824
1214
|
export async function runScans(plan, options = {}) {
|
|
825
1215
|
const deps = {
|
|
826
1216
|
// Concurrent runs need a spawn that does not block the event loop.
|
|
827
|
-
spawn: Number(options.jobs ?? 1) > 1 || options.signal ? defaultSpawnAsync : defaultSpawn,
|
|
1217
|
+
spawn: Number(options.jobs ?? 1) > 1 || options.signal ? defaultSpawnAsync : defaultSpawn,
|
|
1218
|
+
// Locating a tool (rustfmt's `which`/`where` fallback) is synchronous whatever `spawn` is.
|
|
1219
|
+
lookupSpawn: defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), readdir: fs.readdirSync,
|
|
828
1220
|
env: process.env, platform: process.platform, execPath: process.execPath,
|
|
829
1221
|
loadToml: defaultLoadToml, loadYaml: defaultLoadYaml, scanSecrets: defaultScanSecrets, findRustfmt,
|
|
830
1222
|
...options,
|
|
@@ -838,7 +1230,7 @@ export async function runScans(plan, options = {}) {
|
|
|
838
1230
|
const selected = plan.scans.filter((s) => classes.includes(s.class) && !exclude(s));
|
|
839
1231
|
const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
|
|
840
1232
|
const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
|
|
841
|
-
const toolVersions = {}; const probed = new Map();
|
|
1233
|
+
const toolVersions = {}; const probed = new Map(); const advisoryDbs = new Map();
|
|
842
1234
|
const results = [];
|
|
843
1235
|
// options.cache = { read(key), write(key, row), digest(scan) => string | null } (null: not cacheable, which is how the live class A checks opt out).
|
|
844
1236
|
const cache = options.cache && typeof options.cache.read === "function" && typeof options.cache.write === "function" && typeof options.cache.digest === "function" ? options.cache : null;
|
|
@@ -850,25 +1242,35 @@ export async function runScans(plan, options = {}) {
|
|
|
850
1242
|
const priorRows = Array.isArray(priorA?.scans) ? priorA.scans.filter((r) => r.class === SCAN_CLASS.A) : [];
|
|
851
1243
|
// Prior evidence binds to the input content (inputDigest of the changed files), the class A scan set, and a
|
|
852
1244
|
// class A run that actually included class A.
|
|
1245
|
+
// ... and to the checker that produced it: evidence from another checker (other version, or same version with changed bytes)
|
|
1246
|
+
// does not unlock class B, and an unknown identity on either side refuses.
|
|
1247
|
+
const checker = CHECKER_IDENTITY;
|
|
853
1248
|
const priorClassAValid = Boolean(priorA) && priorA.schema === SCAN_RESULT_SCHEMA && Boolean(plan.classDigests?.A) && Boolean(plan.inputDigest)
|
|
854
1249
|
&& priorA.inputDigest === plan.inputDigest && Array.isArray(priorA.classes) && priorA.classes.includes(SCAN_CLASS.A)
|
|
855
|
-
&& priorA.classDigests?.A === plan.classDigests.A && digestScanSet(priorRows) === plan.classDigests.A && priorRows.every((r) => r.status === "pass")
|
|
1250
|
+
&& priorA.classDigests?.A === plan.classDigests.A && digestScanSet(priorRows) === plan.classDigests.A && priorRows.every((r) => r.status === "pass")
|
|
1251
|
+
&& typeof checker === "string" && priorA.checker === checker;
|
|
856
1252
|
// Class A ran "in this call" only when it was selected, the plan has class A scans, and none was excluded:
|
|
857
1253
|
// excluding every class A scan (or a plan with none) never unlocks class B; explicit prior evidence is needed.
|
|
858
1254
|
const planA = plan.scans.filter((x) => x.class === SCAN_CLASS.A);
|
|
859
1255
|
const inCallA = classes.includes(SCAN_CLASS.A) && planA.length > 0 && selected.filter((x) => x.class === SCAN_CLASS.A).length === planA.length;
|
|
860
1256
|
const classBBlocked = !inCallA && !priorClassAValid;
|
|
861
1257
|
|
|
862
|
-
const toolCommand = (tool) => (tool === "node" ? deps.execPath : tool === "rustfmt" ? deps.findRustfmt({ env: deps.env, exists: deps.exists, readdir: deps.readdir, platform: deps.platform, spawn: deps.
|
|
1258
|
+
const toolCommand = (tool) => (tool === "node" ? deps.execPath : tool === "rustfmt" ? deps.findRustfmt({ env: deps.env, exists: deps.exists, readdir: deps.readdir, platform: deps.platform, spawn: deps.lookupSpawn }) : tool);
|
|
863
1259
|
async function probe(tool) {
|
|
864
1260
|
if (probed.has(tool)) return probed.get(tool);
|
|
865
1261
|
let outcome;
|
|
866
1262
|
const command = toolCommand(tool);
|
|
867
1263
|
if (!command) outcome = { ok: false, why: "not found" };
|
|
868
1264
|
else {
|
|
869
|
-
|
|
1265
|
+
// A version probe never runs inside the repository: a tool started there reads the repository's own configuration first
|
|
1266
|
+
// (pnpm's .npmrc and pnpmfile, corepack's packageManager pin, cargo's .cargo/config), before any scan's protections apply.
|
|
1267
|
+
const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd: deps.probeDir ?? probeDir(), env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0" }, ...sig });
|
|
870
1268
|
if (!r || r.error || r.status !== 0) outcome = { ok: false, why: r?.error?.message ?? `exit ${r?.status}` };
|
|
871
|
-
else
|
|
1269
|
+
else {
|
|
1270
|
+
const text = String(r.stdout || r.stderr || "");
|
|
1271
|
+
const version = PROBE_VERSION[tool] ? PROBE_VERSION[tool](text) : text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "unknown";
|
|
1272
|
+
outcome = { ok: true, command, version: version ?? "unknown", pinProblem: pinnedVersionProblem(tool, version) };
|
|
1273
|
+
}
|
|
872
1274
|
}
|
|
873
1275
|
toolVersions[tool] = outcome.ok ? outcome.version : null;
|
|
874
1276
|
probed.set(tool, outcome);
|
|
@@ -922,6 +1324,29 @@ export async function runScans(plan, options = {}) {
|
|
|
922
1324
|
results.push(...slots);
|
|
923
1325
|
}
|
|
924
1326
|
|
|
1327
|
+
// The pinned advisory database, materialised once per run on first use (a failure is remembered and refuses every advisory scan).
|
|
1328
|
+
function prepareAdvisory(s) {
|
|
1329
|
+
const key = s.advisoryPin.commit;
|
|
1330
|
+
if (!advisoryDbs.has(key)) {
|
|
1331
|
+
try {
|
|
1332
|
+
const ensure = deps.ensureAdvisoryDb ?? ensureAdvisoryDb;
|
|
1333
|
+
advisoryDbs.set(key, { dir: ensure({ cacheRoot: deps.advisoryCacheRoot ?? advisoryCacheRoot({ env: deps.env, platform: deps.platform }), pin: s.advisoryPin }) });
|
|
1334
|
+
} catch (e) { advisoryDbs.set(key, { error: e }); }
|
|
1335
|
+
}
|
|
1336
|
+
const db = advisoryDbs.get(key);
|
|
1337
|
+
if (db.error) throw db.error;
|
|
1338
|
+
if (s.advisoryDb !== "deny") return { dbDir: db.dir };
|
|
1339
|
+
const prepare = deps.prepareDenyOverlay ?? prepareDenyOverlay;
|
|
1340
|
+
let denyToml = "";
|
|
1341
|
+
try { denyToml = deps.readFile(path.join(rootDir(s.root), s.cwd ?? "", "deny.toml")); } catch {
|
|
1342
|
+
// No deny.toml in the workspace: RightKit's own policy (deny-default.toml) applies, never cargo-deny's empty default,
|
|
1343
|
+
// which allows no licence at all.
|
|
1344
|
+
denyToml = deps.defaultDenyToml ?? fs.readFileSync(new URL("./deny-default.toml", import.meta.url), "utf8");
|
|
1345
|
+
}
|
|
1346
|
+
const overlay = prepare({ denyToml, dbDir: db.dir, pin: s.advisoryPin });
|
|
1347
|
+
return { dbDir: db.dir, configPath: overlay.configPath, cleanup: overlay.cleanup };
|
|
1348
|
+
}
|
|
1349
|
+
|
|
925
1350
|
// `extraEnv` (the quiet lease id) reaches the child but never the cache key: a lease changes nothing a scan proves.
|
|
926
1351
|
async function scanRow(s, extraEnv = null) {
|
|
927
1352
|
const row = { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: null, status: "fail" };
|
|
@@ -945,13 +1370,25 @@ export async function runScans(plan, options = {}) {
|
|
|
945
1370
|
} else {
|
|
946
1371
|
const p = await probe(s.tool);
|
|
947
1372
|
row.toolVersion = p.ok ? p.version : null;
|
|
1373
|
+
let missingDep = null; let depVersionProblem = null; const depVersions = [];
|
|
1374
|
+
for (const dep of s.requires ?? []) {
|
|
1375
|
+
const d = await probe(dep);
|
|
1376
|
+
if (!d.ok) { missingDep = `${dep} (${d.why})`; break; }
|
|
1377
|
+
if (d.pinProblem) { depVersionProblem = d.pinProblem; break; }
|
|
1378
|
+
depVersions.push(d.version);
|
|
1379
|
+
}
|
|
948
1380
|
if (!p.ok) { status = "fail"; code = "TOOL_MISSING"; detail = `required tool ${s.tool} is missing or not runnable (${p.why}); a required scan is never skipped`; }
|
|
1381
|
+
else if (p.pinProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${p.pinProblem} (at ${p.command})`; }
|
|
1382
|
+
else if (missingDep) { status = "fail"; code = "TOOL_MISSING"; detail = `${s.tool} needs ${missingDep}, which is missing or not runnable; without it the scan would pass unchecked, so it fails`; }
|
|
1383
|
+
else if (depVersionProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${s.tool} runs ${depVersionProblem}`; }
|
|
949
1384
|
else {
|
|
950
1385
|
let cacheKey = null;
|
|
951
1386
|
if (cache) {
|
|
952
1387
|
try {
|
|
953
1388
|
const digest = cache.digest(s);
|
|
954
|
-
|
|
1389
|
+
// The analyzers a scan runs through (actionlint's shellcheck) judge it too, so their versions key it as well.
|
|
1390
|
+
const toolVersion = [p.version, ...depVersions].join(" + ");
|
|
1391
|
+
if (digest) cacheKey = scanCacheKey({ id: s.id, command: s.command, toolVersion, env: s.env ?? {}, inheritedEnv: inheritedEnvDigest({ ...deps.env, ...s.env }), digest });
|
|
955
1392
|
} catch { cacheKey = null; }
|
|
956
1393
|
const hit = cacheKey ? cache.read(cacheKey) : null;
|
|
957
1394
|
if (hit && hit.key === cacheKey && hit.row?.status === "pass" && hit.row.id === s.id && hit.row.command === s.command) {
|
|
@@ -959,7 +1396,29 @@ export async function runScans(plan, options = {}) {
|
|
|
959
1396
|
return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}` };
|
|
960
1397
|
}
|
|
961
1398
|
}
|
|
962
|
-
|
|
1399
|
+
let advisory = null; let args = s.args;
|
|
1400
|
+
if (s.advisoryDb) {
|
|
1401
|
+
try { advisory = prepareAdvisory(s); } catch (e) { return { ...row, status: "fail", code: "ADVISORY_DB", detail: e.message }; }
|
|
1402
|
+
// cargo-deny --offline must not fetch the advisory database, but its own `cargo metadata` would then be offline too and
|
|
1403
|
+
// fail on a runner whose cargo cache is cold. The workspace's metadata is resolved first, online and --locked (the
|
|
1404
|
+
// lockfile decides every version), and handed to cargo-deny as a file.
|
|
1405
|
+
if (s.advisoryDb === "deny") {
|
|
1406
|
+
const at = args.indexOf("deny");
|
|
1407
|
+
// The whole document is needed (the concurrent spawn keeps only the tail of stdout), so it streams to the file; a
|
|
1408
|
+
// spawn that does not know `stdoutFile` returns stdout, which is written below.
|
|
1409
|
+
const metadataDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-deny-metadata-"));
|
|
1410
|
+
const metadataFile = path.join(metadataDir, "metadata.json");
|
|
1411
|
+
const metadata = await deps.spawn(p.command, [...args.slice(0, at), ...metadataArgs({ noDeps: false })], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env }, stdoutFile: metadataFile, ...sig });
|
|
1412
|
+
if (!metadata || metadata.error || metadata.status !== 0) { fs.rmSync(metadataDir, { recursive: true, force: true }); advisory.cleanup?.(); return { ...row, status: "fail", code: "EXIT", detail: `cargo metadata for cargo-deny failed: ${metadata?.error?.message ?? (tail(metadata) || `exit ${metadata?.status}`)}` }; }
|
|
1413
|
+
if (!fs.existsSync(metadataFile)) fs.writeFileSync(metadataFile, String(metadata.stdout ?? ""));
|
|
1414
|
+
advisory.metadataPath = metadataFile;
|
|
1415
|
+
const overlayCleanup = advisory.cleanup;
|
|
1416
|
+
advisory.cleanup = () => { fs.rmSync(metadataDir, { recursive: true, force: true }); overlayCleanup?.(); };
|
|
1417
|
+
}
|
|
1418
|
+
args = args.map((a) => (a === "{advisoryDb}" ? advisory.dbDir : a === "{advisoryDenyConfig}" ? advisory.configPath : a === "{advisoryMetadata}" ? advisory.metadataPath : a));
|
|
1419
|
+
}
|
|
1420
|
+
let r;
|
|
1421
|
+
try { r = await deps.spawn(p.command, args.map((a) => (a === "{contentRoot}" ? contentRoot : a)), { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env, ...(extraEnv ?? {}) }, ...sig }); } finally { advisory?.cleanup?.(); }
|
|
963
1422
|
if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
|
|
964
1423
|
if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
|
|
965
1424
|
else if (r.status !== 0) { status = "fail"; code = "EXIT"; detail = tail(r) || `exit ${r.status}`; }
|
|
@@ -973,7 +1432,7 @@ export async function runScans(plan, options = {}) {
|
|
|
973
1432
|
|
|
974
1433
|
const cancelled = signal?.aborted === true;
|
|
975
1434
|
return {
|
|
976
|
-
schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, ...(cancelled ? { cancelled: true } : {}),
|
|
1435
|
+
schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, checker, ...(cancelled ? { cancelled: true } : {}),
|
|
977
1436
|
inputDigest: plan.inputDigest ?? null,
|
|
978
1437
|
deferred: results.filter((r) => r.status === "deferred").map((r) => r.id),
|
|
979
1438
|
scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: plan.notes ?? [],
|