@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.
Files changed (73) hide show
  1. package/README.md +196 -11
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/android-play-upload.mjs +4 -4
  7. package/app-check.mjs +702 -26
  8. package/apple-profile-create.mjs +4 -3
  9. package/apple-profile-renew.mjs +5 -4
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/rightkit-agent-hook.mjs +9 -0
  12. package/bin/rightkit-gate.mjs +671 -133
  13. package/binary-license-notices.mjs +2 -1
  14. package/build-release.mjs +20 -12
  15. package/cache-command.mjs +4 -3
  16. package/cargo-license-notices.mjs +2 -1
  17. package/cargo-manifest-text.mjs +25 -0
  18. package/cargo-profile-contract.mjs +26 -13
  19. package/cli/right-release.mjs +37 -7
  20. package/command-spec.mjs +4 -5
  21. package/compile-inputs.mjs +66 -0
  22. package/consumer-contracts.json +1044 -0
  23. package/consumer-contracts.mjs +627 -0
  24. package/consumer-input-contracts.mjs +422 -0
  25. package/consumer-policy.mjs +588 -0
  26. package/consumer-source-contracts.mjs +469 -0
  27. package/deny-default.toml +42 -0
  28. package/deps.mjs +5 -4
  29. package/deterministic-scans.mjs +513 -54
  30. package/development.mjs +5 -5
  31. package/disk-command.mjs +270 -30
  32. package/disk-policy.json +50 -10
  33. package/disk-policy.mjs +482 -35
  34. package/disk-report.mjs +129 -0
  35. package/door-policy.mjs +228 -0
  36. package/gate-executor.mjs +30 -0
  37. package/gate-stages.mjs +110 -0
  38. package/github-release.mjs +5 -3
  39. package/github-updater-release.mjs +48 -8
  40. package/github-upload-gate.mjs +6 -6
  41. package/init-command.mjs +175 -8
  42. package/ios-upload.mjs +4 -4
  43. package/legion-semantic-contracts.mjs +428 -0
  44. package/mirror-root-artifact.mjs +3 -4
  45. package/npm-license-notices.mjs +2 -1
  46. package/outdated-command.mjs +91 -0
  47. package/ownership-class-rules.mjs +277 -0
  48. package/ownership-key-authority.mjs +3 -1
  49. package/ownership-mandatory-set.mjs +3 -2
  50. package/package.json +46 -3
  51. package/pipeline-admission.mjs +12 -0
  52. package/publish-cargo.mjs +219 -4
  53. package/publish-update.mjs +19 -14
  54. package/release-config.mjs +87 -11
  55. package/release-dependencies-command.mjs +4 -3
  56. package/release-qa-audit.mjs +12 -10
  57. package/release-qa-command.mjs +42 -7
  58. package/release-qa-hardening.mjs +10 -3
  59. package/release-qa-policy.mjs +214 -47
  60. package/release.mjs +4 -4
  61. package/rightkit-registry-resolver.mjs +5 -2
  62. package/rightkit-versions.json +178 -170
  63. package/shell-contract-bridge.json +251 -0
  64. package/shell-contract.mjs +1069 -0
  65. package/sign-updater.mjs +24 -4
  66. package/source-closure-rules.mjs +204 -0
  67. package/stage-sidecar-command.mjs +7 -6
  68. package/stage-sidecar.mjs +437 -114
  69. package/suite-api.generated.mjs +96 -6
  70. package/suite-trust-roots.mjs +198 -0
  71. package/test-command.mjs +3 -2
  72. package/upload-release.mjs +3 -2
  73. 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 undefined Cargo profile
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 outside crates/rightkit-ffmpeg
23
- // switches-inventory G20 a right-release.config.mjs directory without a switches.json (or its releaseSwitches.inventory)
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-G20: app defects found from files alone ----------------------------------------------------------------------------
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
- const configFiles = (files) => files.filter((f) => /(^|\/)right-release\.config\.mjs$/.test(f) && scanned(f));
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
- for (const m of text.matchAll(/\bprovider\s*:\s*(["'`])([^"'`]+)\1/g)) {
309
- if (!RELEASE_PROVIDERS.has(m[2])) add("provider", m[2], m.index, `distribution provider "${m[2]}" is refused by packages/release/publish-update.mjs (it takes r2-rightapps or github-releases)`, 'set provider: "r2-rightapps" (private) or "github-releases" (public)');
310
- }
311
- for (const m of text.matchAll(/\bupdater\s*:\s*\{/g)) {
312
- const block = braceBlock(text, m.index + m[0].length - 1);
313
- const key = /\bkeyFile\s*:\s*([^,\n}]+)/.exec(block);
314
- if (key) add("keyFile", key[1].trim().slice(0, 60), m.index + key.index, "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");
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
- const profiles = [...text.matchAll(/(["'])--profile\1\s*,\s*(["'])([^"']+)\2/g)].map((m) => [m[3], m.index])
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 outside it and its tests opens a
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 || !text.includes("allow_in_release_builds") || /^\s*#!\[cfg\(test\)\]/m.test(text)) continue;
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 hit = [...blankComments(text).matchAll(/allow_in_release_builds/g)].find((m) => m.index < testModule);
365
- if (hit) out.push({ file, subject: file, problem: `${file}:${lineOf(text, hit.index)} uses allow_in_release_builds outside test code: a release build could ship what the ffmpeg gate refuses`, fix: "remove it from non-test code; only tests and crates/rightkit-ffmpeg itself may name it" });
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
- const block = /\breleaseSwitches\s*:\s*\{/.exec(text);
380
- const named = block ? /\binventory\s*:\s*(["'`])([^"'`]+)\1/.exec(braceBlock(text, block.index + block[0].length - 1))?.[2] : undefined;
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"],