@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/app-check.mjs
CHANGED
|
@@ -16,13 +16,65 @@
|
|
|
16
16
|
// qa-platform G13 a Rust `"platform:<x>"` requirement that is not darwin or windows, the words rightkit-qa emits (it skips forever)
|
|
17
17
|
// test-harness G14 a Cargo.toml [[test]] whose file defines `fn main(` but lacks `harness = false` (libtest ignores it: 0 tests)
|
|
18
18
|
// scenario-skip G15 a `.scenario(` test that prints SKIP and returns: a skip that reports green
|
|
19
|
-
// release-config G16 right-release.config.mjs: a provider publish-update.mjs refuses, an updater keyFile, an
|
|
19
|
+
// release-config G16 right-release.config.json (or a legacy .mjs): a provider publish-update.mjs refuses, an updater keyFile, an
|
|
20
|
+
// undefined Cargo profile, JSON right-release cannot load
|
|
20
21
|
// skip-checks G17 a package.json script that runs right-release with --skip-checks
|
|
21
22
|
// qa-dev-server G18 a package.json qa* script that runs `tauri dev` (QA would test the dev server, not the app)
|
|
22
|
-
// ffmpeg-release-escape G19 `allow_in_release_builds` in non-test Rust
|
|
23
|
-
//
|
|
23
|
+
// ffmpeg-release-escape G19 `allow_in_release_builds` or a use of `DevFfmpeg::unverified` (call or path) in non-test Rust
|
|
24
|
+
// outside crates/rightkit-ffmpeg (an item or file gated on feature "dev-overrides" is excused)
|
|
25
|
+
// switches-inventory G20 a right-release.config.json (or .mjs) directory without a switches.json (or its releaseSwitches.inventory)
|
|
24
26
|
// speech-link G21 `import Speech` anywhere under swift/RightKitSwift/Sources or Tests (Apple Speech is removed, rightkit#978;
|
|
25
27
|
// the LinkProbes control fixture is the one sanctioned import)
|
|
28
|
+
// e2e-under-cargo-test G22 `cargo test|run|nextest` (also as `rustright cargo`, or the retired `rightkit`) that runs an end-to-end
|
|
29
|
+
// crate (rightkit-qa driver modules) in the broker without --no-run: it holds the compile slot for the whole run
|
|
30
|
+
//
|
|
31
|
+
// Suite trust roots (lane C3; logic in suite-trust-roots.mjs). The licence and updater public keys have one owner,
|
|
32
|
+
// contracts/right-suite-api.v1.json (generated into suite-api.generated.mjs):
|
|
33
|
+
// suite-key-literal C3-04 the licence public key pasted as a byte array, 64 hex digits or base64, or the updater key text outside
|
|
34
|
+
// a tauri.conf.json, in a non-test file
|
|
35
|
+
// suite-updater-key C3-14 a tauri*.conf.json plugins.updater.pubkey that is not a copy of a contract key (missing, a placeholder,
|
|
36
|
+
// another key, or an unparseable file)
|
|
37
|
+
// engine-record-store C3-05 a call to from_record_store outside crates/rightkit-license: the owner binary hands the engine a
|
|
38
|
+
// snapshot through rightkit_credentials::handoff instead
|
|
39
|
+
// The shell contract (shell-contract.mjs, SHELL_CONTRACT_RULES, appended after e2e-under-cargo-test) reads an app's Tauri config, capabilities,
|
|
40
|
+
// Rust and renderer sources:
|
|
41
|
+
// shell-window-hidden SC-R1 a startup window of any platform config that is not `visible: false`
|
|
42
|
+
// shell-plugin-order SC-R2 the shell plugin installed from setup, or after a non-QA plugin
|
|
43
|
+
// shell-handler SC-R3 an invoked rk_shell_* command that no handler registers
|
|
44
|
+
// shell-csp SC-R4 a CSP without default-src 'self', frame-ancestors 'none', connect-src, or with script unsafe-inline/eval
|
|
45
|
+
// shell-grants SC-R5 a core:window:allow-* grant that the shell's own window command mirrors
|
|
46
|
+
// qa-window-label SC-R6 the shell's main_label, the QA Control's window and the created windows disagree
|
|
47
|
+
// devtools-declaration SC-R7 the five DevTools declaration sites (config, allow-list, Cargo feature, ACL manifest, runtime policy) disagree
|
|
48
|
+
// shell-lifecycle-external SC-R8 lifecycle_only() in an app that also opens an external webview (hardening and navigation policy are off)
|
|
49
|
+
// legal-receipt-native SC-R9 an Onboarding legal gate whose receipt is not the native service
|
|
50
|
+
// The consumer input contracts (consumer-input-contracts.mjs, CONSUMER_INPUT_RULES, appended after the shell contract) read workflow YAML,
|
|
51
|
+
// JSON and Markdown as data for inputs a committed tree declares but a later step cannot reach:
|
|
52
|
+
// checkout-step-input CA-07 a workflow step reads a tracked path its job's cone-mode sparse checkout does not materialize
|
|
53
|
+
// source-relation CA-09 a value in one committed file names a row another committed file does not have (SOURCE_RELATIONS
|
|
54
|
+
// plus an app's .rightkit/source-relations.json)
|
|
55
|
+
// checkout-longpaths CA-11 a GitHub-hosted Windows job checks out tracked paths over the Win32 limit without Git long paths
|
|
56
|
+
// Legion's semantic catalog and provenance contracts (legion-semantic-contracts.mjs, LEGION_SEMANTIC_RULES, after the consumer source contracts; each
|
|
57
|
+
// applies only to a tree holding its Legion producer or data file):
|
|
58
|
+
// legion-schema-projection LG-02 a schema Legion's generator byte-compares that .gitattributes leaves to eol conversion, or whose
|
|
59
|
+
// enum is not the code-owned Rust list it projects
|
|
60
|
+
// legion-legacy-tokens LG-03 a legacy token in a path or file that src/config/naming-legacy-allowlist.json does not classify
|
|
61
|
+
// with its exact occurrence count
|
|
62
|
+
// legion-retirement-ledger LG-04 a ledger-surface file deleted after c1d80c9d with no docs/provenance/retirements.md row
|
|
63
|
+
// legion-authority-body LG-05 an agent card without its doctrine (and, for a variant, roster) body verbatim
|
|
64
|
+
// Consumer semantic policy (consumer-policy.mjs, CONSUMER_POLICY_RULES, after Legion's semantic contracts) reads an app's own declared contracts from
|
|
65
|
+
// .rightkit/consumer-policy.json, as data: retired-token, route-deny-policy, layer-reach and spec-route-parity (CA-13).
|
|
66
|
+
// Sealed source closures (source-closure-rules.mjs, SOURCE_CLOSURE_RULES, after the shell contract):
|
|
67
|
+
// source-closure SF-01 a committed JSON record { sourceClosure: { sources: { path: sha256 }, digest } } whose sources are
|
|
68
|
+
// missing or changed since sealing, or whose digest does not cover them (normalised digests are
|
|
69
|
+
// declared in .rightkit/source-closures.json)
|
|
70
|
+
// The ownership class rules (ownership-class-rules.mjs, OWNERSHIP_CLASS_RULES, appended last; code is placed by class, never by
|
|
71
|
+
// consumer count):
|
|
72
|
+
// mandatory-foundation OW-05 an app (root rightkit-mandatory.json, or a release config that ships a desktop app) whose profile
|
|
73
|
+
// requires a foundation owner (ownership-mandatory-set.json) it does not use, that selects an unknown
|
|
74
|
+
// or unratified profile, that has no declaration, or that excuses a row without a ratified ruling
|
|
75
|
+
// ownership-exceptions OW-06 a .rightkit/ownership-exceptions.json entry without capability, package, reason, owner, approvedBy,
|
|
76
|
+
// date, expires and a RightKit issue, past its expires date (never baselineable), or that no
|
|
77
|
+
// ratified ruling (appAmendments.<app>.exceptions) covers
|
|
26
78
|
//
|
|
27
79
|
// Ratchet: findings that exist today may be listed so they print as KNOWN and do not fail; an unlisted finding fails; a listed
|
|
28
80
|
// identity (`${rule}|${file}|${subject}`, no line numbers) that is gone fails as STALE, so the list only shrinks. The baseline is
|
|
@@ -43,6 +95,15 @@ import os from "node:os";
|
|
|
43
95
|
import path from "node:path";
|
|
44
96
|
import { fileURLToPath } from "node:url";
|
|
45
97
|
import { spawnSync } from "./background-process.mjs";
|
|
98
|
+
import { LEGION_SEMANTIC_RULES } from "./legion-semantic-contracts.mjs";
|
|
99
|
+
import { CONSUMER_INPUT_RULES } from "./consumer-input-contracts.mjs";
|
|
100
|
+
import { CONSUMER_CONTRACT_RULES } from "./consumer-contracts.mjs";
|
|
101
|
+
import { OWNERSHIP_CLASS_RULES } from "./ownership-class-rules.mjs";
|
|
102
|
+
import { SOURCE_CLOSURE_RULES } from "./source-closure-rules.mjs";
|
|
103
|
+
import { SHELL_CONTRACT_RULES } from "./shell-contract.mjs";
|
|
104
|
+
import { CONSUMER_SOURCE_RULES } from "./consumer-source-contracts.mjs";
|
|
105
|
+
import { CONSUMER_POLICY_RULES } from "./consumer-policy.mjs";
|
|
106
|
+
import { engineRecordStore, suiteKeyLiteral, suiteUpdaterKey } from "./suite-trust-roots.mjs";
|
|
46
107
|
|
|
47
108
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
48
109
|
export const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), "utf8"); } catch { return null; } };
|
|
@@ -178,7 +239,7 @@ function fileUrls({ root, files }) {
|
|
|
178
239
|
return out;
|
|
179
240
|
}
|
|
180
241
|
|
|
181
|
-
// ---- G13-
|
|
242
|
+
// ---- G13-G22: app defects found from files alone ----------------------------------------------------------------------------
|
|
182
243
|
// Each finding is { problem, fix, file, subject }; `${rule}|${file}|${subject}` (no line numbers, so an edit above it does not
|
|
183
244
|
// change it) is its identity in ci/app-check-baseline.json.
|
|
184
245
|
const SKIPPED_TREE = /(^|\/)(node_modules|vendor|target)\//;
|
|
@@ -187,7 +248,17 @@ const lineOf = (text, offset) => text.slice(0, offset).split("\n").length;
|
|
|
187
248
|
/** `text` with every whole-line `//` comment blanked, offsets and line numbers kept (a URL inside a string is untouched). */
|
|
188
249
|
const blankComments = (text) => text.replace(/^[ \t]*\/\/.*$/gm, (m) => " ".repeat(m.length));
|
|
189
250
|
const rustFiles = (files) => files.filter((f) => f.endsWith(".rs") && scanned(f));
|
|
190
|
-
|
|
251
|
+
// The release config: the data config (right-release.config.json, M24) or a legacy module (right-release.config.mjs) not yet moved.
|
|
252
|
+
const configFiles = (files) => files.filter((f) => /(^|\/)right-release\.config\.(?:mjs|json)$/.test(f) && scanned(f));
|
|
253
|
+
const isJsonConfig = (file) => file.endsWith(".json");
|
|
254
|
+
/** Every `[key, value, parentKey]` of a parsed JSON value, depth first (the walk the JSON release config rules read). */
|
|
255
|
+
function* jsonEntries(node, parentKey = null) {
|
|
256
|
+
if (Array.isArray(node)) { for (const item of node) yield* jsonEntries(item, parentKey); return; }
|
|
257
|
+
if (!node || typeof node !== "object") return;
|
|
258
|
+
for (const [key, value] of Object.entries(node)) { yield [key, value, parentKey]; yield* jsonEntries(value, key); }
|
|
259
|
+
}
|
|
260
|
+
/** The offset of the first `"key"` in JSON text that is followed by `:` (a key, not a string value), else 0. */
|
|
261
|
+
const jsonKeyOffset = (text, key) => Math.max(0, text.search(new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`)));
|
|
191
262
|
const dirOf = (file) => path.posix.dirname(file);
|
|
192
263
|
|
|
193
264
|
// G13: rightkit-qa's util::platform() emits "darwin" or "windows"; a requirement spelled any other way never matches, so the
|
|
@@ -303,19 +374,33 @@ function releaseConfig({ root, files }) {
|
|
|
303
374
|
for (const file of configFiles(files)) {
|
|
304
375
|
const raw = readText(root, file);
|
|
305
376
|
if (raw === null) continue;
|
|
306
|
-
const text = blankComments(raw);
|
|
377
|
+
const text = isJsonConfig(file) ? raw : blankComments(raw);
|
|
307
378
|
const add = (kind, value, offset, why, fix) => out.push({ file, subject: `${kind}:${value}`, problem: `${file}:${lineOf(text, offset)} ${why}`, fix });
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
379
|
+
const badProvider = (provider, offset) => add("provider", provider, offset, `distribution provider "${provider}" is refused by packages/release/publish-update.mjs (it takes r2-rightapps or github-releases)`, 'set provider: "r2-rightapps" (private) or "github-releases" (public)');
|
|
380
|
+
const keyFile = (value, offset) => add("keyFile", value.slice(0, 60), offset, "updater keyFile: sign-updater.mjs refuses --key-file; updater keys come from the OS credential provider", "remove keyFile; the updater key is read from the credential store");
|
|
381
|
+
const profiles = [];
|
|
382
|
+
if (isJsonConfig(file)) {
|
|
383
|
+
let config;
|
|
384
|
+
try { config = JSON.parse(raw.replace(/^\uFEFF/, "")); } catch (error) {
|
|
385
|
+
add("json", "invalid", 0, `is not valid JSON (${error.message}): right-release refuses to load it`, "fix the JSON; the release config is strict JSON (no comments or trailing commas)");
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
for (const [key, value, parent] of jsonEntries(config)) {
|
|
389
|
+
if (key === "provider" && parent === "distribution" && typeof value === "string" && !RELEASE_PROVIDERS.has(value)) badProvider(value, text.indexOf(JSON.stringify(value)));
|
|
390
|
+
if (key === "keyFile" && parent === "updater") keyFile(JSON.stringify(value), jsonKeyOffset(text, "keyFile"));
|
|
391
|
+
if (key === "profile" && (parent === "winPackage" || parent === "macPackage") && typeof value === "string") profiles.push([value, text.indexOf(JSON.stringify(value))]);
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
for (const m of text.matchAll(/\bprovider\s*:\s*(["'`])([^"'`]+)\1/g)) if (!RELEASE_PROVIDERS.has(m[2])) badProvider(m[2], m.index);
|
|
395
|
+
for (const m of text.matchAll(/\bupdater\s*:\s*\{/g)) {
|
|
396
|
+
const block = braceBlock(text, m.index + m[0].length - 1);
|
|
397
|
+
const key = /\bkeyFile\s*:\s*([^,\n}]+)/.exec(block);
|
|
398
|
+
if (key) keyFile(key[1].trim(), m.index + key.index);
|
|
399
|
+
}
|
|
315
400
|
}
|
|
316
|
-
|
|
401
|
+
profiles.push(...[...text.matchAll(/(["'])--profile\1\s*,\s*(["'])([^"']+)\2/g)].map((m) => [m[3], m.index])
|
|
317
402
|
.concat([...text.matchAll(/(["'])--profile=([^"']+)\1/g)].map((m) => [m[2], m.index]))
|
|
318
|
-
.concat([...text.matchAll(/\bPROFILE\s*(?:\?\?|\|\|)\s*(["'])([^"']+)\1/g)].map((m) => [m[2], m.index]));
|
|
403
|
+
.concat([...text.matchAll(/\bPROFILE\s*(?:\?\?|\|\|)\s*(["'])([^"']+)\1/g)].map((m) => [m[2], m.index])));
|
|
319
404
|
if (profiles.length) {
|
|
320
405
|
const defined = profilesFor(root, files, dirOf(file));
|
|
321
406
|
for (const [name, offset] of profiles) {
|
|
@@ -352,17 +437,82 @@ function qaDevServer({ root, files }) {
|
|
|
352
437
|
return out;
|
|
353
438
|
}
|
|
354
439
|
|
|
355
|
-
// G19: ffmpeg's release gate is the point of crates/rightkit-ffmpeg; allow_in_release_builds
|
|
356
|
-
// path that ships a build the gate would have refused.
|
|
440
|
+
// G19: ffmpeg's release gate is the point of crates/rightkit-ffmpeg; allow_in_release_builds and a use of DevFfmpeg::unverified (a call,
|
|
441
|
+
// or the path itself, as in .map(DevFfmpeg::unverified)) outside it and its tests open a path that ships a build the gate would have refused. An app pinned to an older rightkit-ffmpeg still
|
|
442
|
+
// compiles both, so the names stay refused after the crate stops exporting them. UnverifiedRequest is not named here: it is the
|
|
443
|
+
// argument of resolve_ffmpeg_handle(_with), which refuses in release builds, so refusing it would refuse the sanctioned fix.
|
|
444
|
+
// DevFfmpeg::try_unverified is the mode-checked constructor and never matches (nor does any other name that merely starts with "unverified").
|
|
445
|
+
const DEV_UNVERIFIED = /\bDevFfmpeg\s*::\s*unverified\b/g;
|
|
446
|
+
// cfg(feature = "dev-overrides") and cfg(any(test, feature = "dev-overrides")): what they gate is not compiled without the feature.
|
|
447
|
+
const DEV_OVERRIDES_PREDICATE = String.raw`(?:feature\s*=\s*"dev-overrides"|any\s*\(\s*(?:test\s*,\s*feature\s*=\s*"dev-overrides"|feature\s*=\s*"dev-overrides"\s*,\s*test)\s*\))`;
|
|
448
|
+
const DEV_OVERRIDES_GATE = new RegExp(String.raw`#\s*\[\s*cfg\s*\(\s*${DEV_OVERRIDES_PREDICATE}\s*\)\s*\]`, "g");
|
|
449
|
+
// The same gate as an inner attribute that opens the file (only other inner attributes may come before it): the whole file is gated.
|
|
450
|
+
const DEV_OVERRIDES_FILE_GATE = new RegExp(String.raw`^(?:\s*#\s*!\s*\[[^\]]*\])*\s*#\s*!\s*\[\s*cfg\s*\(\s*${DEV_OVERRIDES_PREDICATE}\s*\)\s*\]`);
|
|
451
|
+
// What starts a gated item or statement (the item rule: ends at its ';' or its closing '}'); anything else is a match arm, a struct field or an expression, which a depth-0 ',' ends too.
|
|
452
|
+
const ITEM_START = /^(?:(?:pub|fn|const|static|mod|impl|struct|enum|union|trait|type|use|unsafe|async|extern|let|macro_rules)\b|\{)/;
|
|
453
|
+
// A comment or a string or char literal: its text is prose or a message, and its braces are not code.
|
|
454
|
+
const NON_CODE = /\/\/[^\n]*|\/\*[\s\S]*?\*\/|b?r(#*)"[\s\S]*?"\1|"(?:\\[\s\S]|[^"\\])*"|'(?:\\(?:u\{[0-9a-fA-F_]+\}|x[0-9a-fA-F]{2}|[^\n])|[^\\'\n])'/g;
|
|
455
|
+
/** Rust `text` with every comment and string or char literal blanked, offsets and line numbers kept. */
|
|
456
|
+
const blankNonCode = (text) => text.replace(NON_CODE, (m) => m.replace(/[^\n]/g, " "));
|
|
457
|
+
|
|
458
|
+
/** [from, to) spans of `text` that a dev-overrides gate covers (`prose` is its blanked copy, same offsets). The gated item is what
|
|
459
|
+
* follows the attribute, stacked attributes included, up to the first `;` outside brackets or the `}` that closes the first `{`:
|
|
460
|
+
* an fn, mod, impl or block, or a `let` or const that ends at its `;`. A gated match arm or struct field (it starts with no item
|
|
461
|
+
* keyword and no `{`) ends at its `,` outside brackets as well, so an arm with no block never excuses the arms after it.
|
|
462
|
+
* Limits: a gate on `mod x;` covers that line only (x.rs is checked on its own, so a call there is still refused); any other
|
|
463
|
+
* predicate (`all(...)`, `not(...)`, another feature) is no gate; an inner `#![cfg(...)]` is not a span (ffmpegReleaseEscape excuses the whole file for it);
|
|
464
|
+
* a `let` whose value goes on after a block (`if a { b } else { c }`) is covered to that block's end; a gated arm or field
|
|
465
|
+
* whose value has a `,` outside brackets (generics such as `f::<A, B>(...)`) is covered to that comma; a tail whose braces
|
|
466
|
+
* never balance is covered to the end of the file. A hit it misses is refused: gate the call itself. */
|
|
467
|
+
function devOverrideItems(text, prose) {
|
|
468
|
+
const spans = [];
|
|
469
|
+
for (const gate of text.matchAll(DEV_OVERRIDES_GATE)) {
|
|
470
|
+
if (prose[gate.index] !== "#") continue; // the attribute is itself inside a comment or a string
|
|
471
|
+
let brackets = 0;
|
|
472
|
+
let braces = 0;
|
|
473
|
+
let end = prose.length;
|
|
474
|
+
let first = gate.index + gate[0].length;
|
|
475
|
+
for (let attribute = /^\s*#\s*\[/.exec(prose.slice(first)); attribute; attribute = /^\s*#\s*\[/.exec(prose.slice(first))) {
|
|
476
|
+
let depth = 1;
|
|
477
|
+
first += attribute[0].length;
|
|
478
|
+
for (; first < prose.length && depth > 0; first++) depth += prose[first] === "[" ? 1 : prose[first] === "]" ? -1 : 0;
|
|
479
|
+
}
|
|
480
|
+
const item = ITEM_START.test(prose.slice(first).trimStart());
|
|
481
|
+
for (let i = gate.index + gate[0].length; i < prose.length; i++) {
|
|
482
|
+
const c = prose[i];
|
|
483
|
+
if (c === "(" || c === "[") brackets += 1;
|
|
484
|
+
else if (c === ")" || c === "]") brackets -= 1;
|
|
485
|
+
else if (c === "{") braces += 1;
|
|
486
|
+
else if (c === "}") braces -= 1;
|
|
487
|
+
if (braces < 0 || brackets < 0) { end = i; break; } // the block or call the attribute sits in closes first
|
|
488
|
+
if (!item && c === "," && brackets === 0 && braces === 0) { end = i + 1; break; } // a match arm or field ends at its comma
|
|
489
|
+
if (brackets === 0 && braces === 0 && (c === ";" || c === "}")) { end = i + 1; break; }
|
|
490
|
+
}
|
|
491
|
+
spans.push([gate.index, end]);
|
|
492
|
+
}
|
|
493
|
+
return spans;
|
|
494
|
+
}
|
|
495
|
+
|
|
357
496
|
function ffmpegReleaseEscape({ root, files }) {
|
|
358
497
|
const out = [];
|
|
359
498
|
for (const file of rustFiles(files)) {
|
|
360
499
|
if (/(^|\/)tests\//.test(file) || /(^|\/)(?:tests|[^/]*_tests?)\.rs$/.test(file) || /(^|\/)crates\/rightkit-ffmpeg\//.test(file)) continue;
|
|
361
500
|
const text = readText(root, file);
|
|
362
|
-
if (!text ||
|
|
501
|
+
if (!text || !/allow_in_release_builds|DevFfmpeg\s*::\s*unverified/.test(text) || /^\s*#!\[cfg\(test\)\]/m.test(text)) continue;
|
|
363
502
|
const testModule = /^#\[cfg\(test\)\]/m.exec(text)?.index ?? Infinity;
|
|
364
|
-
const
|
|
365
|
-
|
|
503
|
+
const prose = blankNonCode(text);
|
|
504
|
+
const gated = devOverrideItems(text, prose);
|
|
505
|
+
const allow = [...blankComments(text).matchAll(/allow_in_release_builds/g)].find((m) => m.index < testModule);
|
|
506
|
+
// Only DevFfmpeg::unverified is excused under a dev-overrides gate: allow_in_release_builds is being removed from the crate
|
|
507
|
+
// and from every app, gated or not.
|
|
508
|
+
// A file that opens with the inner attribute #![cfg(feature = "dev-overrides")] is not compiled without the feature at all.
|
|
509
|
+
const fileGated = DEV_OVERRIDES_FILE_GATE.test(text.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")));
|
|
510
|
+
const unverified = fileGated ? undefined : [...prose.matchAll(DEV_UNVERIFIED)].find((m) => m.index < testModule && !gated.some(([from, to]) => m.index >= from && m.index < to));
|
|
511
|
+
const uses = [];
|
|
512
|
+
const fixes = [];
|
|
513
|
+
if (allow) { uses.push(`${file}:${lineOf(text, allow.index)} uses allow_in_release_builds`); fixes.push("remove it from non-test code; only tests and crates/rightkit-ffmpeg itself may name it"); }
|
|
514
|
+
if (unverified) { uses.push(`${file}:${lineOf(text, unverified.index)} uses DevFfmpeg::unverified`); fixes.push('resolve the binary with rightkit_ffmpeg::resolve_ffmpeg_handle, which refuses in release builds; keep DevFfmpeg::unverified in tests or under #[cfg(any(test, feature = "dev-overrides"))] (or #![cfg(...)] at the top of the file)'); }
|
|
515
|
+
if (uses.length) out.push({ file, subject: file, problem: `${uses.join(" and ")} outside test code: a release build could ship what the ffmpeg gate refuses`, fix: fixes.join("; ") });
|
|
366
516
|
}
|
|
367
517
|
return out;
|
|
368
518
|
}
|
|
@@ -374,10 +524,20 @@ function switchesInventory({ root, files }) {
|
|
|
374
524
|
for (const file of configFiles(files)) {
|
|
375
525
|
const raw = readText(root, file);
|
|
376
526
|
if (raw === null) continue;
|
|
377
|
-
const text = blankComments(raw);
|
|
527
|
+
const text = isJsonConfig(file) ? raw : blankComments(raw);
|
|
378
528
|
const dir = dirOf(file);
|
|
379
|
-
|
|
380
|
-
|
|
529
|
+
let block;
|
|
530
|
+
let named;
|
|
531
|
+
if (isJsonConfig(file)) {
|
|
532
|
+
let config;
|
|
533
|
+
try { config = JSON.parse(raw.replace(/^\uFEFF/, "")); } catch { config = null; }
|
|
534
|
+
const switches = config?.releaseSwitches;
|
|
535
|
+
if (switches && typeof switches === "object") block = { index: jsonKeyOffset(text, "releaseSwitches") };
|
|
536
|
+
if (typeof switches?.inventory === "string") named = switches.inventory;
|
|
537
|
+
} else {
|
|
538
|
+
block = /\breleaseSwitches\s*:\s*\{/.exec(text);
|
|
539
|
+
named = block ? /\binventory\s*:\s*(["'`])([^"'`]+)\1/.exec(braceBlock(text, block.index + block[0].length - 1))?.[2] : undefined;
|
|
540
|
+
}
|
|
381
541
|
const inventory = path.posix.normalize(path.posix.join(dir, named ?? "switches.json"));
|
|
382
542
|
if (fs.existsSync(path.join(root, inventory))) continue;
|
|
383
543
|
out.push({ file, subject: dir, problem: `${file}:${block ? lineOf(text, block.index) : 1} has no switches inventory (${inventory} does not exist)`, fix: named ? `commit ${inventory} or point releaseSwitches.inventory at the app's inventory` : `add ${path.posix.join(dir, "switches.json")} listing the app's QA and dev switches (see packages/release/fixtures/release-switches/app/switches.json)` });
|
|
@@ -403,6 +563,509 @@ function speechLink({ root, files }) {
|
|
|
403
563
|
return out;
|
|
404
564
|
}
|
|
405
565
|
|
|
566
|
+
// G22: the broker compiles; it does not run end-to-end tests. `cargo test`, `cargo run` and `cargo nextest` (plain, through the
|
|
567
|
+
// RustRight client as `rustright cargo`, or as `rightkit` while the retired alias lasts) run the test binary or the program inside the
|
|
568
|
+
// broker job, which holds the compile slot for the whole run (Pulse, 2026-10-23: 55 minutes with 15 builds queued) and has no GUI
|
|
569
|
+
// session or unscrubbed environment a real-app test needs. The fix is `--no-run` to compile and `rightkit-qa run-tests` to run
|
|
570
|
+
// outside the broker (crates/rightkit-qa/docs/run-tests.md); for `cargo run`, which has no --no-run, `cargo build --bin` in the broker and the
|
|
571
|
+
// built binary outside it (or `rightkit-qa journeys run`); for a whole-workspace run, also leaving the crate out (--exclude).
|
|
572
|
+
// Found from files alone, in three steps:
|
|
573
|
+
// 1. the end-to-end crates: a Cargo package that depends on rightkit-qa not optionally (the optional dependency is the in-app control
|
|
574
|
+
// server behind a qa-native / dev-control feature) and has a .rs file, outside examples/, that names a driver module of it;
|
|
575
|
+
// 2. the commands: `cargo test|run|nextest` in a package.json script, a shell, PowerShell, batch, Node or Python script, a Makefile
|
|
576
|
+
// or justfile recipe, a GitHub workflow `run:` step, a git hook, or a code span of a runbook document;
|
|
577
|
+
// 3. the selection: a command that is not `--no-run` or a rightkit-qa run-tests / journeys run, and names an end-to-end crate by
|
|
578
|
+
// -p / --package, --manifest-path, --workspace / --all (a member of the workspace in the working directory), --test or --bin, or by
|
|
579
|
+
// naming nothing in a virtual workspace (`cargo test`, `cargo test --manifest-path Cargo.toml`: every member, or the default-members).
|
|
580
|
+
// Known limits, deliberate for an offline check: no cargo binary (a path dependency that cargo adds to a workspace implicitly is not
|
|
581
|
+
// a member here), a command assembled from variables is matched on its literal text only, a package.json script that calls another
|
|
582
|
+
// script is judged where the cargo text is, a cargo alias (`cargo t`) is not matched, and a bare `cargo test` selects nothing unless the
|
|
583
|
+
// directory's manifest is a virtual workspace (a [workspace] with no [package]): there it tests every member, or the default-members.
|
|
584
|
+
// A code span of a runbook that is only the command (no argument) is a mention unless it is told to be run (INSTRUCTION_VERB), and a PowerShell
|
|
585
|
+
// `<# #>` block is a comment: the same command in a fenced block, a script, a package.json script, a recipe, a workflow or a hook is a command line.
|
|
586
|
+
const E2E_DRIVER_MODULES = new Set(["harness", "control", "suite", "scenario", "engine", "in_app", "interactive", "page", "browser", "android"]);
|
|
587
|
+
const E2E_GUARD_TARGET = "qa_surface_guard"; // the one test target of an end-to-end crate that only runs the static guard
|
|
588
|
+
const E2E_NAME_ONLY = /-e2e$|^qa-/; // a name is not evidence: crates that match only this are listed for a reviewer by e2eCrates()
|
|
589
|
+
|
|
590
|
+
const unquote = (text) => String(text).replace(/^(["'])(.*)\1$/, "$2");
|
|
591
|
+
const escapeRegExp = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
592
|
+
/** A dotted TOML key split at the dots outside quotes: `target.'cfg(unix)'.dependencies` is three parts. */
|
|
593
|
+
const tomlPath = (text) => [...String(text).matchAll(/"([^"]*)"|'([^']*)'|([^\s.'"]+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
594
|
+
const tomlStrings = (text) => [...String(text).matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
|
|
595
|
+
const uncomment = (line) => line.replace(/^\s*#.*$/, "").replace(/\s+#.*$/, "");
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* What the end-to-end check reads from a Cargo manifest, literal values only: the package name, whether it holds a [workspace] and its
|
|
599
|
+
* members, exclude and default-members (null when it declares none), the dependencies of [dependencies], [dev-dependencies] and their [target.*] tables (inline, dotted-key and
|
|
600
|
+
* `[dependencies.name]` forms, each with optional, package and workspace), [workspace.dependencies] as key -> package, and the
|
|
601
|
+
* [[test]] and [[bin]] tables. Build dependencies are not dependencies of the tests.
|
|
602
|
+
*/
|
|
603
|
+
function parseCargoManifest(text) {
|
|
604
|
+
const manifest = { name: null, workspace: false, members: [], exclude: [], defaultMembers: null, dependencies: [], workspaceDependencies: new Map(), tests: [], bins: [] };
|
|
605
|
+
const entries = new Map();
|
|
606
|
+
const dependency = (table, key) => {
|
|
607
|
+
const id = `${table}\0${key}`;
|
|
608
|
+
if (!entries.has(id)) { const entry = { key, optional: false, package: undefined, workspace: false }; entries.set(id, entry); manifest.dependencies.push(entry); }
|
|
609
|
+
return entries.get(id);
|
|
610
|
+
};
|
|
611
|
+
const setAttribute = (entry, attribute, value) => {
|
|
612
|
+
if (attribute === "optional") entry.optional = entry.optional || value === "true";
|
|
613
|
+
else if (attribute === "package") entry.package = tomlStrings(value)[0] ?? entry.package;
|
|
614
|
+
else if (attribute === "workspace") entry.workspace = entry.workspace || value === "true";
|
|
615
|
+
};
|
|
616
|
+
const setInline = (entry, value) => {
|
|
617
|
+
if (/\boptional\s*=\s*true\b/.test(value)) entry.optional = true;
|
|
618
|
+
const renamed = /\bpackage\s*=\s*(?:"([^"]+)"|'([^']+)')/.exec(value);
|
|
619
|
+
if (renamed) entry.package = renamed[1] ?? renamed[2];
|
|
620
|
+
if (/\bworkspace\s*=\s*true\b/.test(value)) entry.workspace = true;
|
|
621
|
+
};
|
|
622
|
+
const lines = String(text ?? "").split("\n");
|
|
623
|
+
let section = { kind: "other" };
|
|
624
|
+
for (let i = 0; i < lines.length; i++) {
|
|
625
|
+
const line = uncomment(lines[i]);
|
|
626
|
+
const header = /^\s*(\[\[?)\s*(.+?)\s*\]\]?\s*$/.exec(line);
|
|
627
|
+
if (header) {
|
|
628
|
+
const parts = tomlPath(header[2]);
|
|
629
|
+
if (header[1] === "[[") {
|
|
630
|
+
const item = parts[0] === "test" || parts[0] === "bin" ? { name: undefined, path: undefined } : null;
|
|
631
|
+
if (item) manifest[parts[0] === "test" ? "tests" : "bins"].push(item);
|
|
632
|
+
section = item ? { kind: "target", item } : { kind: "other" };
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const at = parts[0] === "dependencies" || parts[0] === "dev-dependencies" ? 0 : parts[0] === "target" && (parts[2] === "dependencies" || parts[2] === "dev-dependencies") ? 2 : -1;
|
|
636
|
+
if (parts[0] === "package" && parts.length === 1) section = { kind: "package" };
|
|
637
|
+
else if (parts[0] === "workspace") {
|
|
638
|
+
manifest.workspace = true;
|
|
639
|
+
section = parts.length === 1 ? { kind: "workspace" } : parts[1] === "dependencies" ? (parts.length === 2 ? { kind: "workspace-dependencies" } : { kind: "workspace-dependency", key: parts[2] }) : { kind: "other" };
|
|
640
|
+
} else if (at >= 0) section = parts.length === at + 1 ? { kind: "dependencies", table: parts.join(".") } : { kind: "dependency", table: parts.slice(0, at + 1).join("."), key: parts[at + 1] };
|
|
641
|
+
else section = { kind: "other" };
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const kv = /^\s*("[^"]+"|'[^']+'|[A-Za-z0-9_-]+)(?:\s*\.\s*("[^"]+"|'[^']+'|[A-Za-z0-9_-]+))?\s*=\s*(.*?)\s*$/.exec(line);
|
|
645
|
+
if (!kv) continue;
|
|
646
|
+
const key = unquote(kv[1]);
|
|
647
|
+
const attribute = kv[2] === undefined ? undefined : unquote(kv[2]);
|
|
648
|
+
const value = kv[3];
|
|
649
|
+
switch (section.kind) {
|
|
650
|
+
case "package":
|
|
651
|
+
if (key === "name" && attribute === undefined) manifest.name = tomlStrings(value)[0] ?? null;
|
|
652
|
+
break;
|
|
653
|
+
case "workspace":
|
|
654
|
+
if ((key === "members" || key === "exclude" || key === "default-members") && attribute === undefined) {
|
|
655
|
+
let list = value;
|
|
656
|
+
if (list.startsWith("[")) while (!list.includes("]") && i + 1 < lines.length) list += ` ${uncomment(lines[++i])}`;
|
|
657
|
+
if (key === "default-members") manifest.defaultMembers = [...(manifest.defaultMembers ?? []), ...tomlStrings(list)];
|
|
658
|
+
else manifest[key].push(...tomlStrings(list));
|
|
659
|
+
}
|
|
660
|
+
break;
|
|
661
|
+
case "target":
|
|
662
|
+
if ((key === "name" || key === "path") && attribute === undefined) section.item[key] = tomlStrings(value)[0];
|
|
663
|
+
break;
|
|
664
|
+
case "dependencies":
|
|
665
|
+
if (attribute === undefined) setInline(dependency(section.table, key), value); else setAttribute(dependency(section.table, key), attribute, value);
|
|
666
|
+
break;
|
|
667
|
+
case "dependency":
|
|
668
|
+
if (attribute === undefined) setAttribute(dependency(section.table, section.key), key, value);
|
|
669
|
+
break;
|
|
670
|
+
case "workspace-dependencies":
|
|
671
|
+
if (attribute === undefined) manifest.workspaceDependencies.set(key, /\bpackage\s*=\s*(?:"([^"]+)"|'([^']+)')/.exec(value)?.slice(1).find(Boolean) ?? key);
|
|
672
|
+
else if (attribute === "package") manifest.workspaceDependencies.set(key, tomlStrings(value)[0] ?? key);
|
|
673
|
+
break;
|
|
674
|
+
case "workspace-dependency":
|
|
675
|
+
if (key === "package" && attribute === undefined) manifest.workspaceDependencies.set(section.key, tomlStrings(value)[0] ?? section.key);
|
|
676
|
+
else if (!manifest.workspaceDependencies.has(section.key)) manifest.workspaceDependencies.set(section.key, section.key);
|
|
677
|
+
break;
|
|
678
|
+
default:
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return manifest;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** A `[workspace] members` pattern (`crates/*`, `apps/**`, `./x`) as a regular expression over a posix path; a pattern that is no
|
|
685
|
+
* valid glob matches nothing (the rule must not throw on a manifest cargo would refuse). */
|
|
686
|
+
function memberPattern(glob) {
|
|
687
|
+
const source = String(glob).replace(/^\.\//, "").replace(/\/+$/, "").split("**").map((part) => part.replace(/[.+^$(){}|\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
|
|
688
|
+
try { return new RegExp(`^${source}$`); } catch { return /$^/; }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** True when the Rust source names a driver module of rightkit-qa through any of `names` (its crate identifier(s) in this package):
|
|
692
|
+
* `rightkit_qa::harness`, or a `use rightkit_qa::{...}` list with a driver module among its top-level items. Comments do not count. */
|
|
693
|
+
function namesDriverModule(source, names) {
|
|
694
|
+
const text = source.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/\/\/[^\n]*/g, "");
|
|
695
|
+
for (const name of names) {
|
|
696
|
+
for (const m of text.matchAll(new RegExp(`(?<!\\w)${escapeRegExp(name)}\\s*::\\s*(\\{|[A-Za-z_]\\w*)`, "g"))) {
|
|
697
|
+
if (m[1] !== "{") { if (E2E_DRIVER_MODULES.has(m[1])) return true; continue; }
|
|
698
|
+
const list = braceBlock(text, m.index + m[0].length - 1).slice(1, -1);
|
|
699
|
+
let depth = 0, item = "";
|
|
700
|
+
const items = [];
|
|
701
|
+
for (const ch of list) {
|
|
702
|
+
if (ch === "{") depth += 1; else if (ch === "}") depth -= 1;
|
|
703
|
+
if (ch === "," && depth === 0) { items.push(item); item = ""; } else item += ch;
|
|
704
|
+
}
|
|
705
|
+
items.push(item);
|
|
706
|
+
if (items.some((it) => E2E_DRIVER_MODULES.has(/^\s*([A-Za-z_]\w*)/.exec(it)?.[1]))) return true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The Cargo layout of a tree and the end-to-end crates in it. `crates` are the end-to-end ones as { name, dir, tests, bins } (a
|
|
714
|
+
* package that depends on rightkit-qa not optionally and has a non-example .rs file naming one of its driver modules); `nameOnly`
|
|
715
|
+
* lists packages named like one (`*-e2e`, `qa-*`) that are not, for a reviewer: a name is not evidence.
|
|
716
|
+
*/
|
|
717
|
+
export function e2eCrates(root, files) {
|
|
718
|
+
const manifests = new Map();
|
|
719
|
+
for (const file of files.filter((f) => /(^|\/)Cargo\.toml$/.test(f) && scanned(f))) {
|
|
720
|
+
const text = readText(root, file);
|
|
721
|
+
if (text !== null) manifests.set(dirOf(file), parseCargoManifest(text));
|
|
722
|
+
}
|
|
723
|
+
/** The package directory a file belongs to: the nearest directory above it with a Cargo.toml, when that holds a [package]. */
|
|
724
|
+
const ownerOf = (file) => {
|
|
725
|
+
for (let dir = dirOf(file); ; dir = dirOf(dir)) {
|
|
726
|
+
const manifest = manifests.get(dir);
|
|
727
|
+
if (manifest) return manifest.name === null ? null : dir;
|
|
728
|
+
if (dir === ".") return null;
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
const packageOf = (dep, dir) => {
|
|
732
|
+
if (dep.package) return dep.package;
|
|
733
|
+
if (dep.workspace) {
|
|
734
|
+
for (let d = dir; ; d = dirOf(d)) {
|
|
735
|
+
const inherited = manifests.get(d)?.workspaceDependencies.get(dep.key);
|
|
736
|
+
if (inherited) return inherited;
|
|
737
|
+
if (d === ".") break;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return dep.key;
|
|
741
|
+
};
|
|
742
|
+
const identifiers = new Map(); // package dir -> how Rust code names rightkit-qa there (the key, with - as _)
|
|
743
|
+
for (const [dir, manifest] of manifests) {
|
|
744
|
+
if (manifest.name === null) continue;
|
|
745
|
+
const names = manifest.dependencies.filter((dep) => !dep.optional && packageOf(dep, dir) === "rightkit-qa").map((dep) => dep.key.replaceAll("-", "_"));
|
|
746
|
+
if (names.length) identifiers.set(dir, [...new Set(names)]);
|
|
747
|
+
}
|
|
748
|
+
const driving = new Set();
|
|
749
|
+
if (identifiers.size) {
|
|
750
|
+
for (const file of rustFiles(files)) {
|
|
751
|
+
const dir = ownerOf(file);
|
|
752
|
+
if (dir === null || !identifiers.has(dir) || driving.has(dir) || (dir === "." ? file : file.slice(dir.length + 1)).startsWith("examples/")) continue;
|
|
753
|
+
const text = readText(root, file);
|
|
754
|
+
if (text !== null && identifiers.get(dir).some((name) => text.includes(name)) && namesDriverModule(text, identifiers.get(dir))) driving.add(dir);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
const crates = [...driving].sort().map((dir) => {
|
|
758
|
+
const manifest = manifests.get(dir);
|
|
759
|
+
const within = (file) => (dir === "." ? file : file.startsWith(`${dir}/`) ? file.slice(dir.length + 1) : null);
|
|
760
|
+
const tests = new Set(manifest.tests.map((t) => t.name).filter(Boolean));
|
|
761
|
+
const bins = new Set(manifest.bins.map((b) => b.name).filter(Boolean));
|
|
762
|
+
for (const file of files) {
|
|
763
|
+
const rel = within(file);
|
|
764
|
+
if (rel === null || !scanned(file) || ownerOf(file) !== dir) continue;
|
|
765
|
+
const test = /^tests\/([^/]+)(?:\.rs|\/main\.rs)$/.exec(rel);
|
|
766
|
+
if (test) tests.add(test[1]);
|
|
767
|
+
const bin = /^src\/bin\/([^/]+)(?:\.rs|\/main\.rs)$/.exec(rel);
|
|
768
|
+
if (bin) bins.add(bin[1]);
|
|
769
|
+
if (rel === "src/main.rs") bins.add(manifest.name);
|
|
770
|
+
}
|
|
771
|
+
return { name: manifest.name, dir, tests, bins };
|
|
772
|
+
});
|
|
773
|
+
const nameOnly = [...manifests].filter(([dir, m]) => m.name !== null && E2E_NAME_ONLY.test(m.name) && !driving.has(dir)).map(([, m]) => m.name).sort();
|
|
774
|
+
/** The end-to-end crates that are members of the workspace rooted at `wsDir`: its root package, and every crate that matches a
|
|
775
|
+
* `members` glob (or, with `defaultOnly`, the `default-members` list when there is one), is not under `exclude` and is not a workspace root
|
|
776
|
+
* itself (a nested [workspace] makes a crate its own root). */
|
|
777
|
+
const membersOf = (wsDir, { defaultOnly = false } = {}) => {
|
|
778
|
+
const ws = manifests.get(wsDir);
|
|
779
|
+
if (!ws?.workspace) return [];
|
|
780
|
+
// `defaultOnly`: what a bare `cargo test` in a virtual workspace selects, its default-members when it declares them, else every member.
|
|
781
|
+
const included = (defaultOnly && ws.defaultMembers !== null ? ws.defaultMembers : ws.members).map(memberPattern);
|
|
782
|
+
const excluded = ws.exclude.map((x) => String(x).replace(/^\.\//, "").replace(/\/+$/, ""));
|
|
783
|
+
return crates.filter((c) => {
|
|
784
|
+
if (c.dir === wsDir) return true;
|
|
785
|
+
const rel = wsDir === "." ? c.dir : c.dir.startsWith(`${wsDir}/`) ? c.dir.slice(wsDir.length + 1) : null;
|
|
786
|
+
return rel !== null && !manifests.get(c.dir).workspace && included.some((re) => re.test(rel)) && !excluded.some((x) => rel === x || rel.startsWith(`${x}/`));
|
|
787
|
+
});
|
|
788
|
+
};
|
|
789
|
+
return { crates, nameOnly, manifests, membersOf };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const CARGO_COMMAND = /(?<![\w-])((?:(?:rustright|rightkit)\s+)?cargo)\s+(?:\+\S+\s+)?(test|run|nextest)\b([^&;|`\n]*)/g;
|
|
793
|
+
const CD_COMMAND = /(?<![\w./-])(?:cd|Set-Location)\s+(?:-(?:Path|LiteralPath)\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/gi;
|
|
794
|
+
const COMMENT_LINE = /^\s*(?:#|\/\/|\/?\*|;|::|REM\b)/i;
|
|
795
|
+
const QA_RUNNER = /\brightkit-qa(?:\.exe)?\s+(?:run-tests|journeys\s+run)\b/;
|
|
796
|
+
const NEXTEST_NO_TESTS = new Set("list archive show-config self help".split(" ")); // nextest subcommands that run no test
|
|
797
|
+
const HISTORY_DOC = /^docs\/(?:archive|plans|provenance|handoff[^/]*)\/|(?:^|\/)(?:tasks|evidence)\/|(?:^|\/)(?:changelog|pending|status|state)\.md$/i;
|
|
798
|
+
const HOOK_FILE = /(?:^|\/)(?:\.githooks|\.husky|githooks)\/(?!_\/)[^/]+/;
|
|
799
|
+
const WORKFLOW_FILE = /^\.github\/(?:workflows\/[^/]+|actions\/.+\/action)\.ya?ml$/;
|
|
800
|
+
const BUILD_FILE = /(?:^|\/)(?:Makefile|makefile|GNUmakefile|[Jj]ustfile|\.justfile)$|\.mk$/;
|
|
801
|
+
const SCRIPT_FILE = /\.(?:sh|bash|zsh|ps1|psm1|bat|cmd|mjs|cjs|js|py)$/i;
|
|
802
|
+
// A code span in running text that is only the cargo command (`cargo test`, `rustright cargo test`: no argument) names the command as often as it
|
|
803
|
+
// tells a reader to run it ("`cargo test` unit tests: ...", "`cargo test` would hold the compile slot", "only CI runs `cargo test`", a list item
|
|
804
|
+
// "`cargo test`: Rust unit tests"). It is a command line only where an imperative verb stands right before it in its clause and nothing in that
|
|
805
|
+
// clause negates it: "Run `cargo test` before you push", not "Do not run `cargo test`". With an argument, in a fenced block, a script, a package.json
|
|
806
|
+
// script, a recipe, a workflow step or a hook, the same words are a command line whatever stands around them.
|
|
807
|
+
const INSTRUCTION_VERB = /\b(?:run|use|execute|invoke|call|type)[*_]*\s*$/i;
|
|
808
|
+
const NEGATION = /\b(?:not|never|no longer|avoid|without|instead of|rather than)\b|n't\b/i;
|
|
809
|
+
const instructsToRun = (before) => {
|
|
810
|
+
const clause = String(before ?? "").split(/[.;!?]\s+|:\s+/).pop();
|
|
811
|
+
return INSTRUCTION_VERB.test(clause) && !NEGATION.test(clause);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
/** The physical lines ({ no, text }) of a PowerShell script with its `<# ... #>` block comments blanked, line numbers kept. A block opens only
|
|
815
|
+
* where `<#` is the first thing on a line, so a `<#` met after code (in a string, say) never hides what follows it; code after the `#>` that
|
|
816
|
+
* closes a block on the same line is kept. */
|
|
817
|
+
function withoutPowerShellBlockComments(lines) {
|
|
818
|
+
let inBlock = false;
|
|
819
|
+
return lines.map(({ no, text }) => {
|
|
820
|
+
let rest = text;
|
|
821
|
+
for (;;) {
|
|
822
|
+
if (!inBlock) {
|
|
823
|
+
const open = /^\s*<#/.exec(rest);
|
|
824
|
+
if (!open) break;
|
|
825
|
+
inBlock = true;
|
|
826
|
+
rest = rest.slice(open[0].length);
|
|
827
|
+
}
|
|
828
|
+
const end = rest.indexOf("#>");
|
|
829
|
+
if (end < 0) { rest = ""; break; }
|
|
830
|
+
inBlock = false;
|
|
831
|
+
rest = rest.slice(end + 2);
|
|
832
|
+
}
|
|
833
|
+
return { no, text: rest };
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** A script's `cd` / `Set-Location` target applied to `cwd` (a posix path from the repository root). A target that is not a plain
|
|
838
|
+
* relative path (a variable, a substitution, an absolute path, one that leaves the repository) leaves the directory as it was: the
|
|
839
|
+
* `cd "$(dirname "$0")/.."` of a script under scripts/ is the repository root, where a script starts. */
|
|
840
|
+
function changeDirectory(cwd, target) {
|
|
841
|
+
const t = unquote(target);
|
|
842
|
+
if (!t || /[$`(%~]/.test(t) || /^([\\/]|[A-Za-z]:)/.test(t) || t === "-") return cwd;
|
|
843
|
+
const next = path.posix.normalize(path.posix.join(cwd, t.replaceAll("\\", "/")));
|
|
844
|
+
return next === ".." || next.startsWith("../") ? cwd : next;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** The logical lines of physical `lines` ({ no, text }): comment lines dropped, and a line that ends with `continuation` joined to the
|
|
848
|
+
* next, numbered by the line the command starts on. */
|
|
849
|
+
function logicalLines(lines, continuation) {
|
|
850
|
+
const out = [];
|
|
851
|
+
let pending = null;
|
|
852
|
+
for (const line of lines) {
|
|
853
|
+
if (!pending && COMMENT_LINE.test(line.text)) continue;
|
|
854
|
+
const text = line.text.replace(/\s+$/, "");
|
|
855
|
+
const joined = pending ? `${pending.text} ${text.trim()}` : text;
|
|
856
|
+
const no = pending?.no ?? line.no;
|
|
857
|
+
if (continuation && text.endsWith(continuation)) { pending = { no, text: joined.slice(0, -continuation.length) }; continue; }
|
|
858
|
+
pending = null;
|
|
859
|
+
out.push({ no, text: joined });
|
|
860
|
+
}
|
|
861
|
+
if (pending) out.push(pending);
|
|
862
|
+
return out;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** The `run:` steps of a GitHub workflow or composite action: { lines, cwd } where cwd is the step's working-directory (the
|
|
866
|
+
* repository root when it has none, or one that is an expression). */
|
|
867
|
+
function workflowSteps(text) {
|
|
868
|
+
const lines = text.split("\n");
|
|
869
|
+
const indent = (l) => l.length - l.trimStart().length;
|
|
870
|
+
const steps = [];
|
|
871
|
+
lines.forEach((raw, i) => {
|
|
872
|
+
const m = /^(\s*)(-\s+)?run:\s*(.*?)\s*$/.exec(raw);
|
|
873
|
+
if (!m) return;
|
|
874
|
+
const keyColumn = m[1].length + (m[2]?.length ?? 0);
|
|
875
|
+
const value = m[3];
|
|
876
|
+
let block;
|
|
877
|
+
if (/^[|>][+-]?\d*(?:\s+#.*)?$/.test(value)) {
|
|
878
|
+
block = [];
|
|
879
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
880
|
+
if (lines[j].trim() === "") continue;
|
|
881
|
+
if (indent(lines[j]) <= keyColumn) break;
|
|
882
|
+
block.push({ no: j + 1, text: lines[j].trim() });
|
|
883
|
+
}
|
|
884
|
+
// A folded scalar (`>`) is one line to the shell.
|
|
885
|
+
if (value.startsWith(">") && block.length) block = [{ no: block[0].no, text: block.map((l) => l.text).join(" ") }];
|
|
886
|
+
} else {
|
|
887
|
+
const plain = /^(["'])(.*)\1$/.test(value) ? unquote(value) : value.replace(/\s+#.*$/, "");
|
|
888
|
+
block = plain ? [{ no: i + 1, text: plain }] : [];
|
|
889
|
+
}
|
|
890
|
+
// The step's own working-directory sits beside `run:` (same column), on either side of it.
|
|
891
|
+
const sibling = (j) => { const k = /^(\s*)(-\s+)?working-directory:\s*(.*?)\s*$/.exec(lines[j]); return k && k[1].length + (k[2]?.length ?? 0) === keyColumn ? unquote(k[3].replace(/\s+#.*$/, "")) : null; };
|
|
892
|
+
let directory = null;
|
|
893
|
+
for (let j = i - 1; j >= 0 && directory === null; j--) {
|
|
894
|
+
if (lines[j].trim() === "") continue;
|
|
895
|
+
directory = sibling(j);
|
|
896
|
+
if (indent(lines[j]) < keyColumn) break;
|
|
897
|
+
}
|
|
898
|
+
for (let j = i + 1; j < lines.length && directory === null; j++) {
|
|
899
|
+
if (lines[j].trim() === "") continue;
|
|
900
|
+
if (indent(lines[j]) < keyColumn) break;
|
|
901
|
+
directory = sibling(j);
|
|
902
|
+
}
|
|
903
|
+
steps.push({ lines: block, cwd: directory === null ? "." : changeDirectory(".", directory) });
|
|
904
|
+
});
|
|
905
|
+
return steps;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** The command-bearing units of a runbook document: each fenced code block ({ lines }) and each inline code span ({ lines: one line, inline, before: the
|
|
909
|
+
* text before it on its line }). Prose outside code is never a command. */
|
|
910
|
+
function markdownUnits(text) {
|
|
911
|
+
const units = [];
|
|
912
|
+
let fence = null;
|
|
913
|
+
text.split("\n").forEach((raw, i) => {
|
|
914
|
+
if (fence) {
|
|
915
|
+
const close = /^\s*(`{3,}|~{3,})\s*$/.exec(raw);
|
|
916
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length) { units.push({ lines: fence.lines }); fence = null; } else fence.lines.push({ no: i + 1, text: raw });
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const open = /^\s*(`{3,}|~{3,})/.exec(raw);
|
|
920
|
+
if (open) { fence = { char: open[1][0], length: open[1].length, lines: [] }; return; }
|
|
921
|
+
for (const span of raw.matchAll(/(`+)(.+?)\1(?!`)/g)) units.push({ lines: [{ no: i + 1, text: span[2].trim() }], inline: true, before: raw.slice(0, span.index) });
|
|
922
|
+
});
|
|
923
|
+
if (fence) units.push({ lines: fence.lines });
|
|
924
|
+
return units;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// G22: see the block comment above.
|
|
928
|
+
function e2eUnderCargoTest({ root, files }) {
|
|
929
|
+
const layout = e2eCrates(root, files);
|
|
930
|
+
if (!layout.crates.length) return [];
|
|
931
|
+
const out = [];
|
|
932
|
+
const seen = new Set();
|
|
933
|
+
const flagValues = (tokens, ...names) => tokens.flatMap((t, i) => names.flatMap((name) => (t === name ? (i + 1 < tokens.length ? [tokens[i + 1]] : []) : name.startsWith("--") && t.startsWith(`${name}=`) ? [t.slice(name.length + 1)] : [])));
|
|
934
|
+
/** The directory of the manifest a --manifest-path names (relative to the cwd, then to the repository root), when it holds one. */
|
|
935
|
+
const manifestDirectory = (value, cwd) => {
|
|
936
|
+
const base = value.replaceAll("\\", "/").replace(/(^|\/)Cargo\.toml$/, "") || ".";
|
|
937
|
+
if (path.posix.isAbsolute(base) || /^[A-Za-z]:/.test(base)) return null;
|
|
938
|
+
for (const from of [cwd, "."]) {
|
|
939
|
+
const dir = path.posix.normalize(path.posix.join(from, base));
|
|
940
|
+
if (dir !== ".." && !dir.startsWith("../") && layout.manifests.has(dir)) return dir;
|
|
941
|
+
}
|
|
942
|
+
return null;
|
|
943
|
+
};
|
|
944
|
+
/** The nearest directory at or above `cwd` that holds a Cargo.toml, as cargo finds the manifest with no --manifest-path; null when none. */
|
|
945
|
+
const nearestManifestDirectory = (cwd) => {
|
|
946
|
+
for (let dir = cwd; ; dir = dirOf(dir)) {
|
|
947
|
+
if (layout.manifests.has(dir)) return dir;
|
|
948
|
+
if (dir === "." || dir === "") return null;
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
/**
|
|
952
|
+
* The end-to-end crates a cargo command selects, each with the flag that did it: [{ crate, selector, wide, manifestPath }], first rule first.
|
|
953
|
+
* `wide` marks a selection of a whole workspace (--workspace / --all, or a bare test run in a virtual workspace), whose fix is to leave the crate
|
|
954
|
+
* out of the run; `manifestPath` is the --manifest-path the selection went through, if any.
|
|
955
|
+
*/
|
|
956
|
+
const selected = (tokens, verb, cwd) => {
|
|
957
|
+
const isRun = verb === "run";
|
|
958
|
+
const picks = new Map();
|
|
959
|
+
const pick = (crate, selector, how = {}) => { if (!picks.has(crate)) picks.set(crate, { selector, ...how }); };
|
|
960
|
+
const packages = flagValues(tokens, "--package", "-p");
|
|
961
|
+
const manifestPaths = flagValues(tokens, "--manifest-path");
|
|
962
|
+
const tests = flagValues(tokens, "--test");
|
|
963
|
+
const wholeWorkspace = tokens.includes("--workspace") || tokens.includes("--all");
|
|
964
|
+
const excluded = new Set(flagValues(tokens, "--exclude"));
|
|
965
|
+
const guardOnly = tests.length > 0 && tests.every((t) => t === E2E_GUARD_TARGET); // `--test qa_surface_guard` runs the static guard only
|
|
966
|
+
const members = (dir, defaultOnly = false) => layout.membersOf(dir, { defaultOnly }).filter((c) => !excluded.has(c.name));
|
|
967
|
+
if (!guardOnly) {
|
|
968
|
+
for (const p of packages) for (const c of layout.crates.filter((crate) => crate.name === p)) pick(c, `-p ${p}`);
|
|
969
|
+
for (const mp of manifestPaths) {
|
|
970
|
+
const dir = manifestDirectory(mp, cwd);
|
|
971
|
+
if (dir === null) continue;
|
|
972
|
+
for (const c of layout.crates.filter((crate) => crate.dir === dir)) pick(c, `--manifest-path ${mp}`, { manifestPath: mp });
|
|
973
|
+
if (wholeWorkspace) for (const c of members(dir)) pick(c, `--manifest-path ${mp} ${tokens.includes("--workspace") ? "--workspace" : "--all"}`, { wide: true, manifestPath: mp });
|
|
974
|
+
}
|
|
975
|
+
if (wholeWorkspace && !manifestPaths.length) for (const c of members(cwd)) pick(c, tokens.includes("--workspace") ? "--workspace" : "--all", { wide: true });
|
|
976
|
+
// No -p, no --workspace, no named target: in a virtual workspace (a [workspace] with no [package]) cargo tests every member, or the
|
|
977
|
+
// default-members when it declares them. GenRight's `rustright cargo test --manifest-path Cargo.toml`, its root a virtual workspace that holds
|
|
978
|
+
// genright-e2e, names no crate and no flag, and runs the end-to-end crate in the broker all the same. A root with a package of its own tests
|
|
979
|
+
// that package only, so it selects nothing here (the limit below). `cargo run` needs a target, which --bin/-p already select.
|
|
980
|
+
const targeted = tests.length > 0 || flagValues(tokens, "--bin", "--example", "--bench").length > 0;
|
|
981
|
+
if (!isRun && !packages.length && !wholeWorkspace && !targeted) {
|
|
982
|
+
for (const [dir, mp] of manifestPaths.length ? manifestPaths.map((m) => [manifestDirectory(m, cwd), m]) : [[nearestManifestDirectory(cwd), null]]) {
|
|
983
|
+
const manifest = dir === null ? undefined : layout.manifests.get(dir);
|
|
984
|
+
if (!manifest?.workspace || manifest.name !== null) continue;
|
|
985
|
+
for (const c of members(dir, true)) pick(c, mp === null ? "" : `--manifest-path ${mp}`, { wide: true, ...(mp === null ? {} : { manifestPath: mp }) });
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if (!packages.length && !manifestPaths.length) {
|
|
990
|
+
for (const t of tests) {
|
|
991
|
+
const owners = t === E2E_GUARD_TARGET ? [] : layout.crates.filter((c) => c.tests.has(t));
|
|
992
|
+
if (owners.length === 1) pick(owners[0], `--test ${t}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (isRun) for (const b of flagValues(tokens, "--bin")) for (const c of layout.crates.filter((crate) => crate.bins.has(b))) pick(c, `--bin ${b}`);
|
|
996
|
+
return [...picks].map(([crate, how]) => ({ crate, ...how }));
|
|
997
|
+
};
|
|
998
|
+
/**
|
|
999
|
+
* What to do about a finding. The test binary of `cargo test` / `nextest` is compiled with --no-run and run by rightkit-qa outside the broker; a
|
|
1000
|
+
* workspace-wide run must also leave the crate out (a --no-run on the whole workspace compiles it all and tests none). `cargo run` has no --no-run:
|
|
1001
|
+
* the program is built in the broker and the built binary runs outside it.
|
|
1002
|
+
*/
|
|
1003
|
+
const fixFor = (crate, verb, tokens, { wide, manifestPath }) => {
|
|
1004
|
+
if (verb === "run") {
|
|
1005
|
+
const named = flagValues(tokens, "--bin").find((b) => crate.bins.has(b));
|
|
1006
|
+
const bin = named ?? (crate.bins.size === 1 ? [...crate.bins][0] : "<bin>");
|
|
1007
|
+
return `build it in the broker (rustright cargo build${manifestPath === undefined ? "" : ` --manifest-path ${manifestPath}`} -p ${crate.name} --bin ${bin}) and run the built binary outside the broker (or rightkit-qa journeys run)`;
|
|
1008
|
+
}
|
|
1009
|
+
const runner = `rightkit-qa run-tests -p ${crate.name} [--test <target>] -- <libtest args>`;
|
|
1010
|
+
return wide
|
|
1011
|
+
? `leave it out of the workspace run (--workspace --exclude ${crate.name}), compile it with --no-run and run it outside the broker: ${runner}`
|
|
1012
|
+
: `compile it with --no-run and run it outside the broker: ${runner}`;
|
|
1013
|
+
};
|
|
1014
|
+
/** Scan logical lines ({ no, text }) that run in order from `cwd`; `what` names the place and `subject` overrides the crate name in the identity. */
|
|
1015
|
+
const scan = (file, lines, cwd, what, subject, span = null) => {
|
|
1016
|
+
for (const { no, text } of lines) {
|
|
1017
|
+
if (!/cargo|\bcd\b|Set-Location/i.test(text)) continue; // a line with neither a command nor a directory change cannot matter
|
|
1018
|
+
const events = [...text.matchAll(CD_COMMAND)].map((m) => ({ at: m.index, cd: m[1] })).concat([...text.matchAll(CARGO_COMMAND)].map((m) => ({ at: m.index, m })));
|
|
1019
|
+
for (const event of events.sort((a, b) => a.at - b.at)) {
|
|
1020
|
+
if (event.cd !== undefined) { cwd = changeDirectory(cwd, event.cd); continue; }
|
|
1021
|
+
const [, client, verb] = event.m;
|
|
1022
|
+
// A command inside a string literal ends at the quote that closes it: `run("rustright cargo test -p x")`.
|
|
1023
|
+
let rest = event.m[3];
|
|
1024
|
+
for (const quote of ['"', "'"]) {
|
|
1025
|
+
if ((rest.split(quote).length - 1) % 2 === 1 && (text.slice(0, event.at).split(quote).length - 1) % 2 === 1) rest = rest.slice(0, rest.indexOf(quote));
|
|
1026
|
+
}
|
|
1027
|
+
const tokens = (rest.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((t) => t.replace(/["']/g, "").replace(/\)+$/, ""));
|
|
1028
|
+
const ownArguments = tokens.indexOf("--") < 0 ? tokens : tokens.slice(0, tokens.indexOf("--"));
|
|
1029
|
+
const segment = text.slice(Math.max(text.lastIndexOf("&", event.at), text.lastIndexOf(";", event.at), text.lastIndexOf("|", event.at), text.lastIndexOf("`", event.at)) + 1, event.at + event.m[0].length);
|
|
1030
|
+
// A code span in running text that is only the command is a mention unless it is told to be run (INSTRUCTION_VERB).
|
|
1031
|
+
if (span && tokens.length === 0 && text.trim() === event.m[0].trim() && !instructsToRun(span.before)) continue;
|
|
1032
|
+
if (ownArguments.includes("--no-run") || QA_RUNNER.test(segment)) continue;
|
|
1033
|
+
if (ownArguments.some((t) => t === "--help" || t === "-h" || t === "--version" || t === "-V")) continue;
|
|
1034
|
+
if (verb === "nextest" && NEXTEST_NO_TESTS.has(tokens[0])) continue;
|
|
1035
|
+
for (const { crate, selector, wide, manifestPath } of selected(ownArguments, verb, cwd)) {
|
|
1036
|
+
const key = `${file}\0${no}\0${crate.name}`;
|
|
1037
|
+
if (seen.has(key)) continue;
|
|
1038
|
+
seen.add(key);
|
|
1039
|
+
const via = [client.replace(/\s+/g, " "), verb, selector].filter(Boolean).join(" ");
|
|
1040
|
+
out.push({ file, subject: subject ?? crate.name, problem: `${file}:${no} ${what} runs the end-to-end crate ${crate.name} under cargo test (${via}): the test binary runs in the broker's context and holds its compile slot for the whole run`, fix: fixFor(crate, verb, ownArguments, { wide, manifestPath }) });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
for (const { file, text, manifest } of packageManifests(root, files)) {
|
|
1046
|
+
for (const [name, command] of Object.entries(manifest.scripts)) {
|
|
1047
|
+
if (typeof command === "string") scan(file, [{ no: scriptLine(text, name), text: command }], dirOf(file), `script "${name}"`, name);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
for (const file of files.filter(scanned)) {
|
|
1051
|
+
const base = path.posix.basename(file);
|
|
1052
|
+
const runbook = (file === "README.md" || file === "AGENTS.md" || file === "CLAUDE.md" || /^docs\/.+\.md$/.test(file)) && !HISTORY_DOC.test(file) && !/\d{4}-\d{2}-\d{2}/.test(base);
|
|
1053
|
+
const workflow = WORKFLOW_FILE.test(file);
|
|
1054
|
+
const hook = !workflow && HOOK_FILE.test(file);
|
|
1055
|
+
const recipe = !workflow && !hook && BUILD_FILE.test(file);
|
|
1056
|
+
const script = !workflow && !hook && !recipe && SCRIPT_FILE.test(file);
|
|
1057
|
+
if (!(runbook || workflow || hook || recipe || script) || base === "package.json") continue;
|
|
1058
|
+
const content = readText(root, file);
|
|
1059
|
+
if (content === null || !/cargo/.test(content)) continue;
|
|
1060
|
+
const continuation = /\.(?:ps1|psm1)$/i.test(file) ? "`" : /\.(?:bat|cmd)$/i.test(file) ? "^" : "\\";
|
|
1061
|
+
const physical = content.split("\n").map((line, i) => ({ no: i + 1, text: line }));
|
|
1062
|
+
if (workflow) for (const step of workflowSteps(content)) scan(file, logicalLines(step.lines, continuation), step.cwd, "workflow step");
|
|
1063
|
+
else if (runbook) for (const unit of markdownUnits(content)) scan(file, logicalLines(unit.lines, continuation), ".", "runbook command", undefined, unit.inline ? unit : null);
|
|
1064
|
+
else scan(file, logicalLines(/\.(?:ps1|psm1)$/i.test(file) ? withoutPowerShellBlockComments(physical) : physical, continuation), ".", hook ? "git hook" : recipe ? "recipe" : "command");
|
|
1065
|
+
}
|
|
1066
|
+
return out;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
406
1069
|
export const RULES = Object.freeze([
|
|
407
1070
|
{ id: "eol-policy", gap: "G04", run: eolPolicy },
|
|
408
1071
|
{ id: "file-url", gap: "G12", run: fileUrls },
|
|
@@ -416,6 +1079,18 @@ export const RULES = Object.freeze([
|
|
|
416
1079
|
{ id: "ffmpeg-release-escape", gap: "G19", run: ffmpegReleaseEscape },
|
|
417
1080
|
{ id: "switches-inventory", gap: "G20", run: switchesInventory },
|
|
418
1081
|
{ id: "speech-link", gap: "G21", run: speechLink },
|
|
1082
|
+
{ id: "e2e-under-cargo-test", gap: "G22", run: e2eUnderCargoTest },
|
|
1083
|
+
{ id: "suite-key-literal", gap: "C3-04", run: ({ root, files }) => suiteKeyLiteral(root, files) },
|
|
1084
|
+
{ id: "suite-updater-key", gap: "C3-14", run: ({ root, files }) => suiteUpdaterKey(root, files) },
|
|
1085
|
+
{ id: "engine-record-store", gap: "C3-05", run: ({ root, files }) => engineRecordStore(root, files) },
|
|
1086
|
+
...SHELL_CONTRACT_RULES,
|
|
1087
|
+
...CONSUMER_CONTRACT_RULES,
|
|
1088
|
+
...CONSUMER_INPUT_RULES,
|
|
1089
|
+
...CONSUMER_SOURCE_RULES,
|
|
1090
|
+
...LEGION_SEMANTIC_RULES,
|
|
1091
|
+
...CONSUMER_POLICY_RULES,
|
|
1092
|
+
...SOURCE_CLOSURE_RULES,
|
|
1093
|
+
...OWNERSHIP_CLASS_RULES,
|
|
419
1094
|
]);
|
|
420
1095
|
|
|
421
1096
|
// ---- export: the committed files, by git alone ----------------------------------------------------------------------------------
|
|
@@ -510,14 +1185,14 @@ export function resolveCommit(repo, rev) {
|
|
|
510
1185
|
}
|
|
511
1186
|
|
|
512
1187
|
/** Run the selected rules over an exported tree. Returns { failures: [{ rule, gap, problem, fix, file?, subject?, identity? }], timings }. */
|
|
513
|
-
export function runRules({ root, commit, files, repo = process.cwd(), only = null, rules = RULES }) {
|
|
1188
|
+
export function runRules({ root, commit, files, repo = process.cwd(), only = null, rules = RULES, now = new Date() }) {
|
|
514
1189
|
const failures = [];
|
|
515
1190
|
const timings = [];
|
|
516
1191
|
for (const rule of rules) {
|
|
517
1192
|
if (only && !only.includes(rule.id)) continue;
|
|
518
1193
|
const started = Date.now();
|
|
519
1194
|
let found;
|
|
520
|
-
try { found = rule.run({ root, commit, files, repo }); }
|
|
1195
|
+
try { found = rule.run({ root, commit, files, repo, now }); }
|
|
521
1196
|
catch (error) { found = [{ problem: `the rule itself failed: ${error.message}` }]; }
|
|
522
1197
|
timings.push({ rule: rule.id, ms: Date.now() - started, failures: found.length });
|
|
523
1198
|
for (const f of found) failures.push({ rule: rule.id, gap: rule.gap, ...f, ...(f.file && f.subject !== undefined ? { identity: `${rule.id}|${f.file}|${f.subject}` } : {}) });
|
|
@@ -659,7 +1334,7 @@ export function runAppCheck({ repo, mode = "commit", commit, only = null, baseli
|
|
|
659
1334
|
return finish(2);
|
|
660
1335
|
}
|
|
661
1336
|
try {
|
|
662
|
-
const { failures: found } = runRules({ root, commit: checked, files, repo, only, rules });
|
|
1337
|
+
const { failures: found } = runRules({ root, commit: checked, files, repo, only, rules, now });
|
|
663
1338
|
if (printBaseline) {
|
|
664
1339
|
lines.push(JSON.stringify([...new Set(found.map((f) => f.identity).filter(Boolean))].sort(), null, 2));
|
|
665
1340
|
const unkeyed = found.filter((f) => !f.identity).length;
|
|
@@ -740,6 +1415,7 @@ export const BUILD_REFUSES = Object.freeze(["switches-inventory"]);
|
|
|
740
1415
|
* classification test in app-check.test.mjs, so a new command cannot appear unclassified. */
|
|
741
1416
|
export const UNCHECKED_COMMANDS = new Map([
|
|
742
1417
|
["check", "is the check"],
|
|
1418
|
+
["update", "runs @rightkit/outdated over the app's RightKit pins (right-update); reads no release inputs and builds nothing"],
|
|
743
1419
|
["deps", "the shared dependency lane: reads and updates dependency pins, not the app's release inputs"],
|
|
744
1420
|
["hardening", "scans artifacts named on the command line; reads no source tree"],
|
|
745
1421
|
["harden", "alias of hardening"],
|