@rightkit/release 0.6.6 → 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 (45) hide show
  1. package/README.md +122 -9
  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/app-check.mjs +257 -38
  7. package/bin/darwin/rightkit-swap +0 -0
  8. package/bin/rightkit-agent-hook.mjs +9 -0
  9. package/bin/rightkit-gate.mjs +495 -344
  10. package/build-release.mjs +13 -6
  11. package/cli/right-release.mjs +29 -6
  12. package/compile-inputs.mjs +66 -0
  13. package/consumer-contracts.json +1044 -0
  14. package/consumer-contracts.mjs +627 -0
  15. package/consumer-input-contracts.mjs +422 -0
  16. package/consumer-policy.mjs +588 -0
  17. package/consumer-source-contracts.mjs +469 -0
  18. package/deny-default.toml +42 -0
  19. package/deterministic-scans.mjs +240 -29
  20. package/disk-command.mjs +270 -30
  21. package/disk-policy.json +50 -10
  22. package/disk-policy.mjs +482 -35
  23. package/disk-report.mjs +129 -0
  24. package/door-policy.mjs +228 -0
  25. package/gate-executor.mjs +30 -0
  26. package/gate-stages.mjs +110 -0
  27. package/github-updater-release.mjs +48 -8
  28. package/init-command.mjs +41 -0
  29. package/legion-semantic-contracts.mjs +428 -0
  30. package/ownership-class-rules.mjs +277 -0
  31. package/ownership-key-authority.mjs +3 -1
  32. package/ownership-mandatory-set.mjs +3 -2
  33. package/package.json +10 -3
  34. package/publish-cargo.mjs +5 -2
  35. package/publish-update.mjs +14 -11
  36. package/release-config.mjs +51 -6
  37. package/rightkit-versions.json +172 -167
  38. package/shell-contract-bridge.json +251 -0
  39. package/shell-contract.mjs +1069 -0
  40. package/sign-updater.mjs +24 -4
  41. package/source-closure-rules.mjs +204 -0
  42. package/stage-sidecar-command.mjs +2 -1
  43. package/stage-sidecar.mjs +437 -114
  44. package/suite-api.generated.mjs +96 -6
  45. package/suite-trust-roots.mjs +198 -0
package/app-check.mjs CHANGED
@@ -20,13 +20,62 @@
20
20
  // undefined Cargo profile, JSON right-release cannot load
21
21
  // skip-checks G17 a package.json script that runs right-release with --skip-checks
22
22
  // qa-dev-server G18 a package.json qa* script that runs `tauri dev` (QA would test the dev server, not the app)
23
- // ffmpeg-release-escape G19 `allow_in_release_builds` in non-test Rust outside crates/rightkit-ffmpeg
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)
24
25
  // switches-inventory G20 a right-release.config.json (or .mjs) directory without a switches.json (or its releaseSwitches.inventory)
25
26
  // speech-link G21 `import Speech` anywhere under swift/RightKitSwift/Sources or Tests (Apple Speech is removed, rightkit#978;
26
27
  // the LinkProbes control fixture is the one sanctioned import)
27
28
  // e2e-under-cargo-test G22 `cargo test|run|nextest` (also as `rustright cargo`, or the retired `rightkit`) that runs an end-to-end
28
29
  // crate (rightkit-qa driver modules) in the broker without --no-run: it holds the compile slot for the whole run
29
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
78
+ //
30
79
  // Ratchet: findings that exist today may be listed so they print as KNOWN and do not fail; an unlisted finding fails; a listed
31
80
  // identity (`${rule}|${file}|${subject}`, no line numbers) that is gone fails as STALE, so the list only shrinks. The baseline is
32
81
  // 1. --baseline <file>, which must exist; else
@@ -46,6 +95,15 @@ import os from "node:os";
46
95
  import path from "node:path";
47
96
  import { fileURLToPath } from "node:url";
48
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";
49
107
 
50
108
  const HERE = path.dirname(fileURLToPath(import.meta.url));
51
109
  export const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), "utf8"); } catch { return null; } };
@@ -379,17 +437,82 @@ function qaDevServer({ root, files }) {
379
437
  return out;
380
438
  }
381
439
 
382
- // G19: ffmpeg's release gate is the point of crates/rightkit-ffmpeg; allow_in_release_builds outside it and its tests opens a
383
- // 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
+
384
496
  function ffmpegReleaseEscape({ root, files }) {
385
497
  const out = [];
386
498
  for (const file of rustFiles(files)) {
387
499
  if (/(^|\/)tests\//.test(file) || /(^|\/)(?:tests|[^/]*_tests?)\.rs$/.test(file) || /(^|\/)crates\/rightkit-ffmpeg\//.test(file)) continue;
388
500
  const text = readText(root, file);
389
- 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;
390
502
  const testModule = /^#\[cfg\(test\)\]/m.exec(text)?.index ?? Infinity;
391
- const hit = [...blankComments(text).matchAll(/allow_in_release_builds/g)].find((m) => m.index < testModule);
392
- 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("; ") });
393
516
  }
394
517
  return out;
395
518
  }
@@ -444,16 +567,22 @@ function speechLink({ root, files }) {
444
567
  // RustRight client as `rustright cargo`, or as `rightkit` while the retired alias lasts) run the test binary or the program inside the
445
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
446
569
  // session or unscrubbed environment a real-app test needs. The fix is `--no-run` to compile and `rightkit-qa run-tests` to run
447
- // outside the broker (crates/rightkit-qa/docs/run-tests.md). Found from files alone, in three steps:
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:
448
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
449
574
  // server behind a qa-native / dev-control feature) and has a .rs file, outside examples/, that names a driver module of it;
450
575
  // 2. the commands: `cargo test|run|nextest` in a package.json script, a shell, PowerShell, batch, Node or Python script, a Makefile
451
576
  // or justfile recipe, a GitHub workflow `run:` step, a git hook, or a code span of a runbook document;
452
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
453
- // -p / --package, --manifest-path, --workspace / --all (a member of the workspace in the working directory), --test or --bin.
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).
454
580
  // Known limits, deliberate for an offline check: no cargo binary (a path dependency that cargo adds to a workspace implicitly is not
455
581
  // a member here), a command assembled from variables is matched on its literal text only, a package.json script that calls another
456
- // script is judged where the cargo text is, a cargo alias (`cargo t`) is not matched, and a bare `cargo test` selects nothing.
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.
457
586
  const E2E_DRIVER_MODULES = new Set(["harness", "control", "suite", "scenario", "engine", "in_app", "interactive", "page", "browser", "android"]);
458
587
  const E2E_GUARD_TARGET = "qa_surface_guard"; // the one test target of an end-to-end crate that only runs the static guard
459
588
  const E2E_NAME_ONLY = /-e2e$|^qa-/; // a name is not evidence: crates that match only this are listed for a reviewer by e2eCrates()
@@ -467,12 +596,12 @@ const uncomment = (line) => line.replace(/^\s*#.*$/, "").replace(/\s+#.*$/, "");
467
596
 
468
597
  /**
469
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
470
- * members and exclude, the dependencies of [dependencies], [dev-dependencies] and their [target.*] tables (inline, dotted-key and
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
471
600
  * `[dependencies.name]` forms, each with optional, package and workspace), [workspace.dependencies] as key -> package, and the
472
601
  * [[test]] and [[bin]] tables. Build dependencies are not dependencies of the tests.
473
602
  */
474
603
  function parseCargoManifest(text) {
475
- const manifest = { name: null, workspace: false, members: [], exclude: [], dependencies: [], workspaceDependencies: new Map(), tests: [], bins: [] };
604
+ const manifest = { name: null, workspace: false, members: [], exclude: [], defaultMembers: null, dependencies: [], workspaceDependencies: new Map(), tests: [], bins: [] };
476
605
  const entries = new Map();
477
606
  const dependency = (table, key) => {
478
607
  const id = `${table}\0${key}`;
@@ -522,10 +651,11 @@ function parseCargoManifest(text) {
522
651
  if (key === "name" && attribute === undefined) manifest.name = tomlStrings(value)[0] ?? null;
523
652
  break;
524
653
  case "workspace":
525
- if ((key === "members" || key === "exclude") && attribute === undefined) {
654
+ if ((key === "members" || key === "exclude" || key === "default-members") && attribute === undefined) {
526
655
  let list = value;
527
656
  if (list.startsWith("[")) while (!list.includes("]") && i + 1 < lines.length) list += ` ${uncomment(lines[++i])}`;
528
- manifest[key].push(...tomlStrings(list));
657
+ if (key === "default-members") manifest.defaultMembers = [...(manifest.defaultMembers ?? []), ...tomlStrings(list)];
658
+ else manifest[key].push(...tomlStrings(list));
529
659
  }
530
660
  break;
531
661
  case "target":
@@ -642,11 +772,13 @@ export function e2eCrates(root, files) {
642
772
  });
643
773
  const nameOnly = [...manifests].filter(([dir, m]) => m.name !== null && E2E_NAME_ONLY.test(m.name) && !driving.has(dir)).map(([, m]) => m.name).sort();
644
774
  /** The end-to-end crates that are members of the workspace rooted at `wsDir`: its root package, and every crate that matches a
645
- * `members` glob, is not under `exclude` and is not a workspace root itself (a nested [workspace] makes a crate its own root). */
646
- const membersOf = (wsDir) => {
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 } = {}) => {
647
778
  const ws = manifests.get(wsDir);
648
779
  if (!ws?.workspace) return [];
649
- const included = ws.members.map(memberPattern);
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);
650
782
  const excluded = ws.exclude.map((x) => String(x).replace(/^\.\//, "").replace(/\/+$/, ""));
651
783
  return crates.filter((c) => {
652
784
  if (c.dir === wsDir) return true;
@@ -667,6 +799,40 @@ const HOOK_FILE = /(?:^|\/)(?:\.githooks|\.husky|githooks)\/(?!_\/)[^/]+/;
667
799
  const WORKFLOW_FILE = /^\.github\/(?:workflows\/[^/]+|actions\/.+\/action)\.ya?ml$/;
668
800
  const BUILD_FILE = /(?:^|\/)(?:Makefile|makefile|GNUmakefile|[Jj]ustfile|\.justfile)$|\.mk$/;
669
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
+ }
670
836
 
671
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
672
838
  * relative path (a variable, a substitution, an absolute path, one that leaves the repository) leaves the directory as it was: the
@@ -739,22 +905,22 @@ function workflowSteps(text) {
739
905
  return steps;
740
906
  }
741
907
 
742
- /** The command-bearing units of a runbook document: each fenced code block (its lines) and each inline code span (one line). Prose
743
- * outside code is never a command. */
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. */
744
910
  function markdownUnits(text) {
745
911
  const units = [];
746
912
  let fence = null;
747
913
  text.split("\n").forEach((raw, i) => {
748
914
  if (fence) {
749
915
  const close = /^\s*(`{3,}|~{3,})\s*$/.exec(raw);
750
- if (close && close[1][0] === fence.char && close[1].length >= fence.length) { units.push(fence.lines); fence = null; } else fence.lines.push({ no: i + 1, text: 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 });
751
917
  return;
752
918
  }
753
919
  const open = /^\s*(`{3,}|~{3,})/.exec(raw);
754
920
  if (open) { fence = { char: open[1][0], length: open[1].length, lines: [] }; return; }
755
- for (const span of raw.matchAll(/(`+)(.+?)\1(?!`)/g)) units.push([{ no: i + 1, text: span[2].trim() }]);
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) });
756
922
  });
757
- if (fence) units.push(fence.lines);
923
+ if (fence) units.push({ lines: fence.lines });
758
924
  return units;
759
925
  }
760
926
 
@@ -775,26 +941,50 @@ function e2eUnderCargoTest({ root, files }) {
775
941
  }
776
942
  return null;
777
943
  };
778
- /** The end-to-end crates a cargo command selects, each with the flag that did it: [{ crate, selector }], first rule first. */
779
- const selected = (tokens, isRun, cwd) => {
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";
780
958
  const picks = new Map();
781
- const pick = (crate, selector) => { if (!picks.has(crate)) picks.set(crate, selector); };
959
+ const pick = (crate, selector, how = {}) => { if (!picks.has(crate)) picks.set(crate, { selector, ...how }); };
782
960
  const packages = flagValues(tokens, "--package", "-p");
783
961
  const manifestPaths = flagValues(tokens, "--manifest-path");
784
962
  const tests = flagValues(tokens, "--test");
785
963
  const wholeWorkspace = tokens.includes("--workspace") || tokens.includes("--all");
786
964
  const excluded = new Set(flagValues(tokens, "--exclude"));
787
965
  const guardOnly = tests.length > 0 && tests.every((t) => t === E2E_GUARD_TARGET); // `--test qa_surface_guard` runs the static guard only
788
- const members = (dir) => layout.membersOf(dir).filter((c) => !excluded.has(c.name));
966
+ const members = (dir, defaultOnly = false) => layout.membersOf(dir, { defaultOnly }).filter((c) => !excluded.has(c.name));
789
967
  if (!guardOnly) {
790
968
  for (const p of packages) for (const c of layout.crates.filter((crate) => crate.name === p)) pick(c, `-p ${p}`);
791
969
  for (const mp of manifestPaths) {
792
970
  const dir = manifestDirectory(mp, cwd);
793
971
  if (dir === null) continue;
794
- for (const c of layout.crates.filter((crate) => crate.dir === dir)) pick(c, `--manifest-path ${mp}`);
795
- if (wholeWorkspace) for (const c of members(dir)) pick(c, `--manifest-path ${mp} ${tokens.includes("--workspace") ? "--workspace" : "--all"}`);
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
+ }
796
987
  }
797
- if (wholeWorkspace && !manifestPaths.length) for (const c of members(cwd)) pick(c, tokens.includes("--workspace") ? "--workspace" : "--all");
798
988
  }
799
989
  if (!packages.length && !manifestPaths.length) {
800
990
  for (const t of tests) {
@@ -803,10 +993,26 @@ function e2eUnderCargoTest({ root, files }) {
803
993
  }
804
994
  }
805
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}`);
806
- return [...picks].map(([crate, selector]) => ({ crate, selector }));
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}`;
807
1013
  };
808
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. */
809
- const scan = (file, lines, cwd, what, subject) => {
1015
+ const scan = (file, lines, cwd, what, subject, span = null) => {
810
1016
  for (const { no, text } of lines) {
811
1017
  if (!/cargo|\bcd\b|Set-Location/i.test(text)) continue; // a line with neither a command nor a directory change cannot matter
812
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 })));
@@ -821,15 +1027,17 @@ function e2eUnderCargoTest({ root, files }) {
821
1027
  const tokens = (rest.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((t) => t.replace(/["']/g, "").replace(/\)+$/, ""));
822
1028
  const ownArguments = tokens.indexOf("--") < 0 ? tokens : tokens.slice(0, tokens.indexOf("--"));
823
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;
824
1032
  if (ownArguments.includes("--no-run") || QA_RUNNER.test(segment)) continue;
825
1033
  if (ownArguments.some((t) => t === "--help" || t === "-h" || t === "--version" || t === "-V")) continue;
826
1034
  if (verb === "nextest" && NEXTEST_NO_TESTS.has(tokens[0])) continue;
827
- for (const { crate, selector } of selected(ownArguments, verb === "run", cwd)) {
1035
+ for (const { crate, selector, wide, manifestPath } of selected(ownArguments, verb, cwd)) {
828
1036
  const key = `${file}\0${no}\0${crate.name}`;
829
1037
  if (seen.has(key)) continue;
830
1038
  seen.add(key);
831
- const via = `${client.replace(/\s+/g, " ")} ${verb} ${selector}`;
832
- 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: `compile it with --no-run and run it outside the broker: rightkit-qa run-tests -p ${crate.name} [--test <target>] -- <libtest args>` });
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 }) });
833
1041
  }
834
1042
  }
835
1043
  }
@@ -852,8 +1060,8 @@ function e2eUnderCargoTest({ root, files }) {
852
1060
  const continuation = /\.(?:ps1|psm1)$/i.test(file) ? "`" : /\.(?:bat|cmd)$/i.test(file) ? "^" : "\\";
853
1061
  const physical = content.split("\n").map((line, i) => ({ no: i + 1, text: line }));
854
1062
  if (workflow) for (const step of workflowSteps(content)) scan(file, logicalLines(step.lines, continuation), step.cwd, "workflow step");
855
- else if (runbook) for (const unit of markdownUnits(content)) scan(file, logicalLines(unit, continuation), ".", "runbook command");
856
- else scan(file, logicalLines(physical, continuation), ".", hook ? "git hook" : recipe ? "recipe" : "command");
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");
857
1065
  }
858
1066
  return out;
859
1067
  }
@@ -872,6 +1080,17 @@ export const RULES = Object.freeze([
872
1080
  { id: "switches-inventory", gap: "G20", run: switchesInventory },
873
1081
  { id: "speech-link", gap: "G21", run: speechLink },
874
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,
875
1094
  ]);
876
1095
 
877
1096
  // ---- export: the committed files, by git alone ----------------------------------------------------------------------------------
@@ -966,14 +1185,14 @@ export function resolveCommit(repo, rev) {
966
1185
  }
967
1186
 
968
1187
  /** Run the selected rules over an exported tree. Returns { failures: [{ rule, gap, problem, fix, file?, subject?, identity? }], timings }. */
969
- 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() }) {
970
1189
  const failures = [];
971
1190
  const timings = [];
972
1191
  for (const rule of rules) {
973
1192
  if (only && !only.includes(rule.id)) continue;
974
1193
  const started = Date.now();
975
1194
  let found;
976
- try { found = rule.run({ root, commit, files, repo }); }
1195
+ try { found = rule.run({ root, commit, files, repo, now }); }
977
1196
  catch (error) { found = [{ problem: `the rule itself failed: ${error.message}` }]; }
978
1197
  timings.push({ rule: rule.id, ms: Date.now() - started, failures: found.length });
979
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}` } : {}) });
@@ -1115,7 +1334,7 @@ export function runAppCheck({ repo, mode = "commit", commit, only = null, baseli
1115
1334
  return finish(2);
1116
1335
  }
1117
1336
  try {
1118
- const { failures: found } = runRules({ root, commit: checked, files, repo, only, rules });
1337
+ const { failures: found } = runRules({ root, commit: checked, files, repo, only, rules, now });
1119
1338
  if (printBaseline) {
1120
1339
  lines.push(JSON.stringify([...new Set(found.map((f) => f.identity).filter(Boolean))].sort(), null, 2));
1121
1340
  const unkeyed = found.filter((f) => !f.identity).length;
Binary file
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code PreToolUse hook for the one door (door-policy.mjs): reads the hook input on stdin and prints the decision. Registered in
3
+ // the repository's .claude/settings.json by `rightkit-gate install-hook`. Any input it cannot read allows (a hook never breaks the tool).
4
+ import fs from "node:fs";
5
+ import { claudeDoorDecision } from "../door-policy.mjs";
6
+
7
+ let input = {};
8
+ try { input = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); } catch { input = {}; }
9
+ process.stdout.write(`${JSON.stringify(claudeDoorDecision(input))}\n`);