@rightkit/release 0.6.3 → 0.6.5

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 (48) hide show
  1. package/README.md +67 -5
  2. package/admission-files.mjs +279 -0
  3. package/admission-host.mjs +403 -0
  4. package/admission.mjs +146 -0
  5. package/app-check.mjs +869 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +68 -29
  8. package/build-release.mjs +81 -21
  9. package/cli/right-release.mjs +39 -5
  10. package/deterministic-scans.mjs +42 -15
  11. package/development.mjs +3 -2
  12. package/hosted-workflow-policy.mjs +111 -0
  13. package/inventory-check.mjs +12 -1
  14. package/ios-dev.mjs +2 -2
  15. package/ios-host.mjs +7 -7
  16. package/ios-package-recipe.mjs +2 -2
  17. package/local-publish.mjs +1 -1
  18. package/local-qualify.mjs +3 -3
  19. package/local-runtime.mjs +1 -1
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/package.json +5 -3
  22. package/pipeline-admission.mjs +5 -4
  23. package/pipeline-receipts.mjs +302 -302
  24. package/pipeline-scan.mjs +3 -3
  25. package/pipeline-tools.mjs +175 -171
  26. package/prebuild-gates.mjs +191 -0
  27. package/progress-control.mjs +1 -1
  28. package/publish-update.mjs +4 -3
  29. package/qa-admission-policy.json +5 -0
  30. package/release-config.mjs +353 -330
  31. package/release-qa-audit.mjs +37 -11
  32. package/release-qa-command.mjs +46 -5
  33. package/release-qa-fixtures.mjs +127 -0
  34. package/release-qa-floor.json +6 -1
  35. package/release-qa-hardening.mjs +173 -0
  36. package/release-qa-policy.mjs +168 -0
  37. package/release-qa-receipt.mjs +21 -1
  38. package/release-switch-scan.mjs +137 -6
  39. package/release.mjs +333 -231
  40. package/rightapps-register.mjs +3 -1
  41. package/rightkit-drift.mjs +1 -1
  42. package/rightkit-ownership.json +15 -5
  43. package/rightkit-versions.json +50 -37
  44. package/stage-sidecar.mjs +17 -15
  45. package/suite-api-literal.mjs +109 -0
  46. package/suite-api.generated.mjs +78 -0
  47. package/swap-helper-bundle.mjs +2 -2
  48. package/win-package-recipe.mjs +7 -4
Binary file
@@ -24,10 +24,16 @@
24
24
  // rightkit-gate [--base <ref>] [--plan] [--no-cache]
25
25
  // run the gate (default base origin/main); --plan prints the scans and runs none;
26
26
  // --no-cache (or RK_GATE_CACHE=0) reruns every scan
27
+ // rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
28
+ // the offline app check (../app-check.mjs): committed files only, no network, compile or
29
+ // broker, every finding in one run. Exit 0 pass, 1 findings, 2 could not run
27
30
  // rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
28
- // pre-push hook runs the repository's own .git/hooks/pre-push first and every other hook
29
- // present in .git/hooks gets a .githooks/<name> trampoline that runs it; the hook finds
30
- // the gate binary in the root node_modules/.bin, then any workspace package's, then pnpm exec
31
+ // pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
32
+ // check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
33
+ // .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
34
+ // node_modules/.bin, then any workspace package's, then pnpm exec. Where @rightkit/release is
35
+ // installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
36
+ // is an alias of this command)
31
37
  //
32
38
  // From the pre-push hook, git's ref lines arrive on stdin: the commit being pushed must be the checked-out HEAD (broker scans
33
39
  // read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
@@ -58,7 +64,7 @@ const text = (r) => String(r?.stdout ?? "");
58
64
  const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
59
65
  const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
60
66
 
61
- const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | --version`;
67
+ const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
62
68
 
63
69
  // ---- pure helpers (exported for tests) ----------------------------------------------------------------------
64
70
 
@@ -340,13 +346,15 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
340
346
  /**
341
347
  * The pre-push hook. git's ref lines arrive on stdin and the gate consumes them, so stdin is buffered once and fed to both the
342
348
  * repository's own .git/hooks/pre-push (which core.hooksPath=.githooks would otherwise hide; it runs first and a failure stops the
343
- * push) and the gate. The gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three
344
- * levels down: tauri-app-next, apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when
345
- * pnpm is absent; the line it chose is printed. No absolute path is written: everything is resolved from git at run time.
349
+ * push) and the gate. Between them runs the app check, `rightkit-gate check --commit HEAD`: seconds, committed files only, so a
350
+ * red tree stops the push before the gate's scans start; a push that only deletes refs has no content to check and skips it. The
351
+ * gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
352
+ * apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
353
+ * chose is printed once. No absolute path is written: everything is resolved from git at run time.
346
354
  */
347
355
  export function hookText(nodePath) {
348
356
  return `#!/bin/sh
349
- # Mandatory local gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
357
+ # Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
350
358
  cd "$(git rev-parse --show-toplevel)" || exit 1
351
359
  stdin=$(mktemp "\${TMPDIR:-/tmp}/rightkit-gate.XXXXXX") || exit 1
352
360
  trap 'rm -f "$stdin"' EXIT
@@ -358,18 +366,27 @@ if [ -x "$own" ]; then
358
366
  "$own" "$@" < "$stdin" || exit $?
359
367
  fi
360
368
  # The gate binary: the root install, any workspace package's install, then pnpm exec.
369
+ found=""
361
370
  for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
362
- if [ -x "$bin" ]; then
363
- echo "rightkit-gate: using $bin" >&2
364
- ("$bin" --base origin/main) < "$stdin"; exit $?
365
- fi
371
+ if [ -x "$bin" ]; then found="$bin"; break; fi
366
372
  done
367
- if command -v pnpm >/dev/null 2>&1; then
373
+ if [ -n "$found" ]; then
374
+ echo "rightkit-gate: using $found" >&2
375
+ rightkit_gate() { "$found" "$@"; }
376
+ elif command -v pnpm >/dev/null 2>&1; then
368
377
  echo "rightkit-gate: using pnpm exec rightkit-gate" >&2
369
- (exec pnpm exec rightkit-gate --base origin/main) < "$stdin"; exit $?
378
+ rightkit_gate() { pnpm exec rightkit-gate "$@"; }
379
+ else
380
+ echo "rightkit-gate: using node ${nodePath}" >&2
381
+ rightkit_gate() { node "${nodePath}" "$@"; }
370
382
  fi
371
- echo "rightkit-gate: using node ${nodePath}" >&2
372
- (exec node "${nodePath}" --base origin/main) < "$stdin"; exit $?
383
+ # The check first, over the committed files of HEAD; a push that only deletes refs has nothing to check.
384
+ if [ -s "$stdin" ] && ! awk '$2 !~ /^0+$/ { found = 1 } END { exit !found }' "$stdin"; then
385
+ echo "rightkit-gate: only deletions are pushed; nothing to check" >&2
386
+ else
387
+ rightkit_gate check --commit HEAD < /dev/null >&2 || exit $?
388
+ fi
389
+ rightkit_gate --base origin/main < "$stdin"; exit $?
373
390
  `;
374
391
  }
375
392
 
@@ -750,13 +767,13 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
750
767
 
751
768
  // ---- command resolution -------------------------------------------------------------------------------------
752
769
 
753
- // Cargo only through the broker: `rightkit cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
770
+ // Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
754
771
  function resolveCargoCommand(o) {
755
772
  const which = (cmd) => {
756
773
  const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
757
774
  return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
758
775
  };
759
- if (which("rightkit").length) return ["rightkit", "cargo"];
776
+ if (which("rustright").length) return ["rustright", "cargo"];
760
777
  const first = which("cargo")[0];
761
778
  if (first) { try { if (o.readFile(first).includes("cargo-guard.mjs")) return ["cargo"]; } catch { /* unreadable: not the shim */ } }
762
779
  return null;
@@ -780,7 +797,12 @@ function releaseProvider(o, root, git) {
780
797
  return null;
781
798
  }
782
799
 
783
- function installHook(o, root, force) {
800
+ // The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
801
+ // it is written only where that is installed. Its own hook and the repository's .git/hooks/pre-commit both keep running.
802
+ const PINNED_CLI = "node_modules/@rightkit/release/cli/right-release.mjs";
803
+ const FOREIGN_PRE_COMMIT = "pre-commit.rightkit-foreign";
804
+
805
+ async function installHook(o, root, force) {
784
806
  const git = (...a) => o.spawn("git", a, { cwd: root });
785
807
  const hooksDir = path.join(root, ".githooks");
786
808
  const hookPath = path.join(hooksDir, "pre-push");
@@ -797,15 +819,29 @@ function installHook(o, root, force) {
797
819
  const gitDirResult = git("rev-parse", "--git-dir");
798
820
  let present = [];
799
821
  if (!failed(gitDirResult) && text(gitDirResult).trim()) { try { present = o.readdir(path.join(path.resolve(root, text(gitDirResult).trim()), "hooks")); } catch { present = []; } }
822
+ const gitHooksDir = !failed(gitDirResult) && text(gitDirResult).trim() ? path.join(path.resolve(root, text(gitDirResult).trim()), "hooks") : null;
800
823
  const own = present.filter(chainableHook).sort();
801
- const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name)).sort();
824
+ // The ownership step takes .githooks/pre-commit. The repository's own pre-commit hook keeps running first, through a trampoline
825
+ // beside it, unless it is the ownership hook a plain `right-release hooks install` wrote (this one replaces that), whose own
826
+ // chained hook, pre-commit.rightkit-foreign, then runs instead. A name that is no plain hook name is reported, never chained.
827
+ const ownership = o.exists(path.join(root, PINNED_CLI)) ? await import("../ownership-hooks.mjs") : null;
828
+ const readHook = (name) => { try { return o.readFile(path.join(gitHooksDir, name)); } catch { return ""; } };
829
+ const chainName = !ownership ? null : own.includes("pre-commit") && !readHook("pre-commit").includes(ownership.HOOK_MARKER) ? "pre-commit" : present.includes(FOREIGN_PRE_COMMIT) ? FOREIGN_PRE_COMMIT : null;
830
+ const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name) && !(ownership && name === FOREIGN_PRE_COMMIT)).sort();
802
831
  if (skipped.length) o.err(`rightkit-gate: WARNING: not chaining .git/hooks/${skipped.join(", .git/hooks/")}: not a plain hook file name (letters, digits, - and _ only); run it from a hook of its own.`);
803
- const trampolines = own.filter((name) => name !== "pre-push").map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name) }));
832
+ const trampolines = own.filter((name) => name !== "pre-push" && !(ownership && name === "pre-commit")).map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name), note: `runs .git/hooks/${name}` }));
833
+ if (ownership) {
834
+ trampolines.push({ name: "pre-commit", file: path.join(hooksDir, "pre-commit"), wanted: ownership.renderPreCommitHook(), ownership: true, replaces: [trampolineText("pre-commit")], marker: ownership.HOOK_MARKER, note: "the advisory ownership gate: right-release ownership --staged" });
835
+ if (chainName) trampolines.push({ name: FOREIGN_PRE_COMMIT, file: path.join(hooksDir, FOREIGN_PRE_COMMIT), wanted: trampolineText(chainName), note: `runs .git/hooks/${chainName}, before the ownership gate` });
836
+ }
804
837
  for (const t of trampolines) {
805
838
  t.exists = o.exists(t.file);
806
- t.current = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") === t.wanted : false;
807
- if (t.exists && !t.current && !force) {
808
- o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with a trampoline to .git/hooks/${t.name}.`);
839
+ const found = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") : null;
840
+ t.current = found === t.wanted;
841
+ // Our own earlier artifacts are updated in place: a plain trampoline where the ownership hook now goes, and an older ownership hook.
842
+ const ours = found !== null && (t.current || t.replaces?.includes(found) || (t.marker && found.includes(t.marker)));
843
+ if (t.exists && !ours && !force) {
844
+ o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with ${t.ownership ? "the ownership pre-commit hook" : `a trampoline to .git/hooks/${chainName ?? t.name}`}.`);
809
845
  return 1;
810
846
  }
811
847
  }
@@ -825,7 +861,7 @@ function installHook(o, root, force) {
825
861
  else put(hookPath, wanted, `${hasHook ? "replaced" : "wrote"} .githooks/pre-push`, ` (${nodePath} without pnpm)`);
826
862
  for (const t of trampolines) {
827
863
  if (t.current) done.push(`.githooks/${t.name} already current`);
828
- else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (runs .git/hooks/${t.name})`);
864
+ else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (${t.note})`);
829
865
  }
830
866
  if (existingPath === ".githooks") done.push("core.hooksPath already .githooks");
831
867
  else {
@@ -836,6 +872,7 @@ function installHook(o, root, force) {
836
872
  for (const line of done) o.out(`rightkit-gate: ${line}`);
837
873
  const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
838
874
  o.out(`rightkit-gate: chained hooks from .git/hooks: ${chained.length ? chained.join(", ") : "none"}${own.includes("pre-push") ? "" : "; the pre-push hook still runs .git/hooks/pre-push first if one appears later"}`);
875
+ if (!ownership) o.out(`rightkit-gate: no ownership pre-commit step: ${PINNED_CLI} is not installed here; install @rightkit/release, then run install-hook again`);
839
876
  const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
840
877
  o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, else of any workspace package, else through \`pnpm exec\`.`);
841
878
  const provider = releaseProvider(o, root, git);
@@ -865,14 +902,16 @@ export async function main(argv, io = {}) {
865
902
  }
866
903
  if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
867
904
  const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
868
- if (sub && sub !== "install-hook") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
905
+ if (sub && sub !== "install-hook" && sub !== "check") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
906
+ // The app check has its own export and reads no gate state: it needs neither the base ref nor the pushed refs on stdin.
907
+ if (sub === "check") return (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
869
908
  if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
870
909
 
871
910
  const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
872
911
  if (failed(top) || !text(top).trim()) { o.err("rightkit-gate: not inside a git repository"); return 2; }
873
912
  const root = path.resolve(text(top).trim());
874
913
  const git = (...a) => o.spawn("git", a, { cwd: root });
875
- if (sub === "install-hook") return installHook(o, root, args.includes("--force"));
914
+ if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
876
915
 
877
916
  const planOnly = args.includes("--plan");
878
917
  let base = option("--base") ?? "origin/main";
@@ -900,12 +939,12 @@ export async function main(argv, io = {}) {
900
939
  const readLive = (f) => o.readFile(f);
901
940
 
902
941
  // Units come from the live checkout (cargo reads it); a dirty checkout then blocks below.
903
- let rust; let npm; let cargoCommand = ["rightkit", "cargo"];
942
+ let rust; let npm; let cargoCommand = ["rustright", "cargo"];
904
943
  try {
905
944
  const manifests = tracked("Cargo.toml");
906
945
  if (manifests.length) {
907
946
  cargoCommand = resolveCargoCommand(o);
908
- if (!cargoCommand) { o.err(`${tag} BLOCKED: neither \`rightkit\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`); return 1; }
947
+ if (!cargoCommand) { o.err(`${tag} BLOCKED: neither \`rustright\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`); return 1; }
909
948
  if (!o.exists(path.join(root, GRANT_FILE))) { o.err(`${tag} BLOCKED: no local compile grant (${GRANT_FILE}); the broker refuses cargo, so the Rust units cannot be discovered or scanned`); return 1; }
910
949
  }
911
950
  // Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
package/build-release.mjs CHANGED
@@ -20,7 +20,9 @@ import {
20
20
  import path from "node:path";
21
21
  import { preflightOwnershipScope } from "./ownership-scope.mjs";
22
22
  import { spawn, spawnSync } from "./background-process.mjs";
23
- import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord } from "./rightkit-registry-resolver.mjs";
23
+ import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord, writeSnapshot } from "./rightkit-registry-resolver.mjs";
24
+ import { formatAdmission, formatNotRun, runAdmission } from "./admission.mjs";
25
+ import { sccacheRefusal } from "./admission-host.mjs";
24
26
  import { fileURLToPath, pathToFileURL } from "node:url";
25
27
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
26
28
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
@@ -129,12 +131,15 @@ for (let i = 0; i < args.length; i += 1) {
129
131
  if (platform !== "win" && platform !== "mac" && platform !== "android" && platform !== "ios") fail("--platform must be win, mac, android or ios");
130
132
  if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].includes(resumeFrom)) fail("--resume-from must be prepackage, sign, package, post-sign, or harden");
131
133
 
132
- const configPath = path.resolve(configName);
134
+ // Native real paths: Git reports the long form of a Windows short name (RUNNER~1) and the target of a symlinked temp root
135
+ // (macOS /var -> /private/var). The config path is resolved the same way the doctor resolves it (release.mjs), so every path
136
+ // derived from it (app root, build inputs, scan roots) compares equal to what Git reports; a non-canonical app root made the
137
+ // build-inputs qualification drop every input and the clean-source gate speak before the dirty-inputs row.
138
+ const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
139
+ const configPath = nativeReal(path.resolve(configName));
133
140
  const invocationRoot = path.dirname(configPath);
134
141
  const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
135
142
  assertPrimaryReleaseCheckout(repoRoot);
136
- // Native real paths: Git reports the long form of a Windows short name (RUNNER~1).
137
- const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
138
143
  const relativeConfig = path.relative(nativeReal(repoRoot), nativeReal(configPath)).replaceAll("\\", "/");
139
144
  if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
140
145
  const appRoot = path.dirname(configPath);
@@ -148,21 +153,19 @@ let heavySlot;
148
153
  let interrupted;
149
154
  let requestActiveRunStop = null;
150
155
 
151
- // A dry-run is an inspection contract, not a partial build. It must finish
152
- // before locks, cache directories, receipts, dependency installation, or any
156
+ // Admission (admission.mjs): every refusal the committed files, this host or the registries can decide is raised here, before the
157
+ // dry-run exit, the clean-source gate, the release lock, any state file, the install, a slot or the worker. `right-release doctor`
158
+ // runs the same rows with the same text, so the two cannot disagree (rightkit#946); a refused build leaves nothing behind.
159
+ const admitted = await admit();
160
+
161
+ // A dry-run is an inspection contract, not a partial build. It prints the admission rows and exits with their verdict, so it can
162
+ // never pass what the build refuses. It must finish before locks, cache directories, receipts, dependency installation, or any
153
163
  // child build/sign process can exist.
154
164
  if (dryRun) {
155
- const commit = git(invocationRoot, ["rev-parse", "HEAD"]);
156
- const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
157
- if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
158
- const config = (await import(`${pathToFileURL(configPath).href}?dry-run=${commit}`)).default;
159
- if (!config?.app || !config?.version) fail("release config must expose app and version");
160
- const target = config.targets?.[platform];
161
- // Android builds with target.gradle (validated by release.mjs), not a package command; iOS with targets.ios.iosPackage.
162
- if (platform !== "android" && platform !== "ios" && !target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
163
- console.log(`right-release build dry-run: ${config.app} ${config.version} ${platform}`);
165
+ console.log(`right-release build dry-run: ${admitted.config.app} ${admitted.config.version} ${platform}`);
164
166
  console.log(`config: ${configPath}`);
165
- console.log(`commit: ${commit}`);
167
+ console.log(`commit: ${admitted.commit}`);
168
+ for (const line of formatAdmission(admitted.result)) console.log(line);
166
169
  process.exit(0);
167
170
  }
168
171
 
@@ -396,7 +399,8 @@ try {
396
399
  let rightKitResolution = null;
397
400
  if (!legacySealed) {
398
401
  try {
399
- rightKitResolution = await prepareRightKitResolution({
402
+ // A fresh build already asked the registries in admission(); the snapshot is bound to this release's state root below.
403
+ rightKitResolution = (!rightKitReuse && admitted.registry) || await prepareRightKitResolution({
400
404
  root: appRoot,
401
405
  extraLocks: rightKitLockInventory({ requiredInputs, cargoLockPaths: pinnedCargoLocks, nativeLayout, config, root: appRoot }),
402
406
  versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
@@ -414,6 +418,7 @@ try {
414
418
  if (!(error instanceof RightKitRegistryError)) throw error;
415
419
  fail(`${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
416
420
  }
421
+ if (rightKitResolution === admitted.registry) writeSnapshot(rightKitSnapshotFile, rightKitResolution.snapshot);
417
422
  for (const line of rightKitResolution.freshness.tasks) console.error(`[right-release] LANE TASK ${line}`);
418
423
  for (const line of rightKitResolution.freshness.warnings) console.error(`[right-release] WARNING ${line}`);
419
424
  console.error(`[right-release] rightkit-registry: ${rightKitResolution.summary}`);
@@ -552,6 +557,52 @@ try {
552
557
  lock?.release();
553
558
  }
554
559
 
560
+ // Read the config and run the admission rows (files, this host and, for a fresh build, the registries) with no side effect: no lock,
561
+ // state directory, cache directory, install or snapshot file. A finding is printed with its row and the process exits 1, no stack.
562
+ async function admit() {
563
+ let commit;
564
+ let config;
565
+ try {
566
+ commit = git(invocationRoot, ["rev-parse", "HEAD"]);
567
+ const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
568
+ if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
569
+ try { config = (await import(`${pathToFileURL(configPath).href}?commit=${commit}`)).default; } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
570
+ } catch (error) {
571
+ if (error?.reported) process.exit(1);
572
+ throw error;
573
+ }
574
+ // A fresh build asks the registries once, here; a resume or a re-run over a sealed release reuses its recorded snapshot instead.
575
+ const platformName = platform === "win" ? "windows" : platform;
576
+ const sealed = config?.app && config?.version
577
+ && existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
578
+ const reuse = Boolean(resumeFrom) || Boolean(sealed);
579
+ const result = await runAdmission({
580
+ root: appRoot, repoRoot, configPath, config: config ?? {}, platform, commit, skipChecks,
581
+ tiers: reuse ? ["files", "host"] : ["files", "host", "network"],
582
+ registry: async (ctx) => {
583
+ const inputs = ctx.buildInputs();
584
+ if (!inputs.ok) return undefined; // the build-inputs row reports it
585
+ return prepareRightKitResolution({
586
+ root: appRoot,
587
+ extraLocks: rightKitLockInventory({ requiredInputs: inputs.value.requiredInputs, cargoLockPaths: inputs.value.cargoLockPaths, nativeLayout: inputs.value.nativeLayout, config, root: appRoot }),
588
+ versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
589
+ ownership: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-ownership.json"), "utf8")),
590
+ releaseVersion: RELEASE_TOOL_VERSION,
591
+ // Build admission: refuse every pin behind the registry's latest. A dry run asks, but keeps no cache on disk.
592
+ admission: true,
593
+ ...(dryRun ? { cacheDir: "" } : {}),
594
+ });
595
+ },
596
+ });
597
+ for (const finding of result.findings) console.error(`right-release build: ${finding.row}: ${finding.detail}`);
598
+ for (const line of formatNotRun(result)) console.error(`right-release build: ${line}`);
599
+ if (result.findings.length > 0) {
600
+ console.error(`right-release build: admission found ${result.findings.length} blocking problem(s); nothing was started`);
601
+ process.exit(1);
602
+ }
603
+ return { config, commit, result, registry: result.out.registry ?? null };
604
+ }
605
+
555
606
  function hashInputs(files) {
556
607
  const hashes = {};
557
608
  for (const file of files) {
@@ -610,6 +661,14 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
610
661
  current.add(role);
611
662
  sources.set(source, current);
612
663
  };
664
+ // Named refusals for a config the admission's seal-routes row already refused, so nothing here crashes on an undefined field.
665
+ for (const artifact of [...(target.installer?.artifacts ?? []), ...(target.updater?.artifacts ?? [])]) {
666
+ if (typeof artifact?.file !== "string" || !artifact.file) fail("installer and updater artifacts must each declare a file");
667
+ }
668
+ for (const artifact of target.updater?.artifacts ?? []) {
669
+ if (target.updater?.kind !== "appinstaller" && (typeof artifact.signature !== "string" || !artifact.signature)) fail(`updater artifact ${artifact.file} must declare the signature file sign-updater writes next to it`);
670
+ if (artifact.key === undefined && config.distribution?.provider !== "github-releases") fail(`updater artifact ${artifact.file} must declare the R2 route key (key); only github-releases names the route from the file`);
671
+ }
613
672
  for (const artifact of target.installer?.artifacts ?? []) addSource(resolveArtifactPath(artifact.file), "installer");
614
673
  for (const artifact of target.updater?.artifacts ?? []) {
615
674
  addSource(resolveArtifactPath(artifact.file), "updater");
@@ -741,9 +800,9 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
741
800
  // an unsound seal, not a portable release.
742
801
  signing: signingIdentity ? { ...signingIdentity, receipts: (() => {
743
802
  const present = signingIdentity.receiptInputs.filter((file) => existsSync(file));
744
- if (present.length === 0) throw new Error(`no signing receipts found to seal; expected at least ${signingIdentity.receiptInputs[0]}`);
803
+ if (present.length === 0) fail(`no signing receipts found to seal; expected at least ${signingIdentity.receiptInputs[0]}`);
745
804
  if (platform === "mac") {
746
- if (present.length !== signingIdentity.receiptInputs.length) throw new Error("missing current-build macOS signing or notarization receipt");
805
+ if (present.length !== signingIdentity.receiptInputs.length) fail("missing current-build macOS signing or notarization receipt");
747
806
  validateNotarizationReceipt({ receiptPath: signingIdentity.receiptInputs[1], archive: resolveArtifactPath(target.notarize.file) });
748
807
  }
749
808
  return present.map((file) => ({ file, sha256: hashFile(file) }));
@@ -941,7 +1000,7 @@ function commandExists(name) {
941
1000
 
942
1001
  function assertSccacheVersion(version) {
943
1002
  const match = String(version ?? "").match(/sccache\s+(\d+)\.(\d+)\.(\d+)/i);
944
- if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail("shared cache requires sccache >= 0.15.0; install with: cargo install sccache --locked; then verify: sccache --version");
1003
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail(sccacheRefusal(match ? match.slice(1).join(".") : null));
945
1004
  }
946
1005
 
947
1006
  function commandOutput(cmd, runArgs) {
@@ -1000,7 +1059,8 @@ function usage(code) {
1000
1059
 
1001
1060
  function fail(message) {
1002
1061
  console.error(`right-release build: ${message}`);
1003
- throw new Error(message);
1062
+ // `reported` marks an error whose text is already printed, so a caller that exits on it prints nothing twice.
1063
+ throw Object.assign(new Error(message), { reported: true });
1004
1064
  }
1005
1065
 
1006
1066
  function throwIfInterrupted() { if (interrupted) throw interrupted; }
@@ -54,7 +54,17 @@ const commands = new Map([
54
54
 
55
55
  const args = process.argv.slice(2);
56
56
  const first = args[0];
57
+ // The commands that compile, package, sign, ship or inspect a release run the app check (../app-check.mjs, CHECKED_COMMANDS) in this
58
+ // process before they start a child. A red check prints its findings and exits 1; there is no flag that skips it.
59
+ async function requireGreenCheck() {
60
+ const { guardCommand } = await import("../app-check.mjs");
61
+ const refused = guardCommand({ args });
62
+ if (!refused) return;
63
+ for (const line of refused.lines) console.error(line);
64
+ process.exit(refused.exitCode);
65
+ }
57
66
  if (first === "dev") {
67
+ await requireGreenCheck();
58
68
  import("../development.mjs").then(({ main }) => main(args.slice(1))).catch((error) => {
59
69
  console.error(`right-release dev: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
60
70
  process.exitCode = 1;
@@ -81,6 +91,8 @@ if (!first || first === "-h" || first === "--help") {
81
91
  process.exit(0);
82
92
  }
83
93
 
94
+ await requireGreenCheck();
95
+
84
96
  if (first === "--version" || first === "-v") {
85
97
  run("release.mjs", ["--version"]);
86
98
  } else if (first === "doctor") {
@@ -89,6 +101,18 @@ if (first === "--version" || first === "-v") {
89
101
  } else {
90
102
  run("release.mjs", ["--doctor", ...args.slice(1)]);
91
103
  }
104
+ } else if (first === "check") {
105
+ // The same offline check as `rightkit-gate check`; the repository is the git top-level of --config's folder, else the cwd.
106
+ const { appDirectory, main } = await import("../app-check.mjs");
107
+ process.exit(await main(args.slice(1), { label: "check", cwd: appDirectory(args, process.cwd()) }));
108
+ } else if (first === "hooks" && args[1] === "install") {
109
+ // One installer: `hooks install` is `rightkit-gate install-hook` (pre-push check and gate, ownership pre-commit step) run in the app.
110
+ const app = args.indexOf("--app");
111
+ if (app >= 0 && (!args[app + 1] || args[app + 1].startsWith("-"))) {
112
+ console.error("right-release hooks install: --app needs a directory");
113
+ process.exit(2);
114
+ }
115
+ run("bin/rightkit-gate.mjs", ["install-hook", ...(args.includes("--force") ? ["--force"] : [])], { cwd: app >= 0 ? path.resolve(args[app + 1]) : process.cwd() });
92
116
  } else if (first === "hooks") {
93
117
  run("ownership-hooks.mjs", args.slice(1));
94
118
  } else if (first === "suite-doctor") {
@@ -170,9 +194,9 @@ if (first === "--version" || first === "-v") {
170
194
  process.exit(2);
171
195
  }
172
196
 
173
- function run(script, scriptArgs) {
197
+ function run(script, scriptArgs, { cwd = process.cwd() } = {}) {
174
198
  const child = spawn(process.execPath, [path.join(packageRoot, script), ...scriptArgs], {
175
- cwd: process.cwd(),
199
+ cwd,
176
200
  env: process.env,
177
201
  stdio: "inherit",
178
202
  windowsHide: true,
@@ -270,7 +294,8 @@ Commands:
270
294
  upload --platform ios --release <id> [--apply] Sealed IPA to TestFlight, then the exact build into the beta groups; dry run unless --apply (macOS)
271
295
  ios-credentials store --key-id <KEYID> --issuer <uuid> | check
272
296
  Put the App Store Connect API key (stdin), key id and issuer into the login Keychain once, or check them; never a file (macOS)
273
- qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa and write the sealed QA receipt (a missing receipt warns and is recorded; it never blocks)
297
+ qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa (and the journeys lane when the config sets qa.journeys) and write the sealed QA receipt. Refused inside a QA run. The receipt must match the platform and release id, come from an approved runner digest, and show every release-critical journey passed
298
+ Admission is per app in qa-admission-policy.json: warn (default) records a refusal and continues; refuse blocks build, upload and publish. Only adrian moves an app from refuse back to warn
274
299
  qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] List source/installed QA status per release; exit 1 if an uploaded build lacks passing QA
275
300
  publish-update --release <id> --platform mac|win --tier patch|update
276
301
  Upload sealed bytes to R2 & register RightApps
@@ -307,12 +332,21 @@ Commands:
307
332
  apple profile renew [--config <file>] [--profile <file>] [--dry-run]
308
333
  Renew macOS profile for current Keychain signer
309
334
  signing-agent install|uninstall|status Manage invisible Aqua-session signing service
310
- doctor [--platform mac|win] Inspect one app's release config
335
+ check [--repo <dir>] [--config <file>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
336
+ The offline app check, same as rightkit-gate check: committed files only, every finding in one run (exit 0 pass, 1 findings, 2 could not run).
337
+ Build, package, dev, test, web-build, swift-package, recipe-build, stage-sidecar, qa, upload, publish, publish-update, github, doctor,
338
+ sign-windows, sign-updater, sign-candidate, sign-dev-artifact, addon build and create-mac-updater run it first and start nothing when it is red; there is no flag that skips it.
339
+ Baseline: .rightkit/app-check-baseline.json (entries carry issue, added and sunset; the sunset is at most 90 days after added)
340
+ doctor [--platform mac|win] [--gates] [--inspect] [--strict]
341
+ Inspect one app's release config the way the build would refuse it; --gates also runs the build's pre-build source gates
342
+ --inspect lists admission findings (newer RightKit versions) as information instead of refusing them
343
+ --strict makes a row that could not run on this host (NOT RUN) a blocking problem; without it NOT RUN is printed and does not fail
344
+ --admission is still accepted and does nothing (admission is the default); it cannot be combined with --inspect
311
345
  doctor --all Verify all Right Suite app release contracts
312
346
  suite-doctor Verify all local Right Suite repositories
313
347
  ownership [--app <dir>] [--json] Fail on app copies of RightKit-owned capabilities
314
348
  ownership --staged [--app <dir>] [--json] Pre-commit gate: scan the staged index for block-tier findings
315
- hooks install [--app <dir>] Install the advisory pre-commit ownership gate (pinned @rightkit/release)
349
+ hooks install [--app <dir>] [--force] Alias of rightkit-gate install-hook: the pre-push check and gate, and the advisory pre-commit ownership gate (pinned @rightkit/release)
316
350
  ownership enroll [--root] [--name <device>] [--root-fingerprint <sha256>] [--app <dir>]
317
351
  Enroll one native key or create a signed-device request
318
352
  ownership approve-key <fingerprint|request.json> Approve one pending device key from an active host
@@ -10,10 +10,10 @@
10
10
  // repository has none. A deferred scan is listed by name (result.deferred, scanDeferrals), is never a
11
11
  // pass (result.ok and result.complete are false while any scan is deferred) and cannot become a SCAN_B receipt.
12
12
  // Content scans run on the exported HEAD (contentRoot). Only a scan that inherently needs the live
13
- // checkout uses liveRoot: `rightkit cargo` scans (the broker binds to the registered checkout), the
13
+ // checkout uses liveRoot: `rustright cargo` scans (the broker binds to the registered checkout), the
14
14
  // tsc, eslint and package tests (need installed node_modules).
15
15
  // Class B runs only when every selected class A scan passed (SCAN_B is distinct from SCAN_A and
16
- // follows it). Rust commands go through `rightkit cargo`, JavaScript tools through `pnpm exec`.
16
+ // follows it). Rust commands go through `rustright cargo`, JavaScript tools through `pnpm exec`.
17
17
  // The runner takes injectable spawn/exists/readFile/readdir so tests execute nothing real. The
18
18
  // result is a plain object (per-scan status, tool versions, scanSetDigest) usable as receipt content
19
19
  // (packages/release/pipeline-receipts.mjs); it carries no authority by itself.
@@ -34,7 +34,7 @@ export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
34
34
  export const CLASS_A_SCAN_PREFIXES = Object.freeze([
35
35
  "rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
36
36
  "actionlint", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
37
- "changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions",
37
+ "changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
38
38
  ]);
39
39
  export const CLASS_B_SCAN_PREFIXES = Object.freeze([
40
40
  "clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "tsc", "eslint", "swift-build", "test", "generated-docs",
@@ -205,10 +205,13 @@ const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|
205
205
  const ESLINT_FILES = ["eslint.config.js", "eslint.config.mjs", ".eslintrc.json"];
206
206
  const ROOT_LINT_FILES = [...ESLINT_FILES, "biome.json"];
207
207
  // Everything scripts/generate-index.mjs reads or writes (its header and loadInventory): crate and package manifests and
208
- // READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself, and its outputs.
209
- const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-index\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP)\.md)$/;
208
+ // READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself,
209
+ // the port matrix generator it imports, and its outputs (the port matrix's MD and HTML are checked for consistency with its JSON).
210
+ // The Right Suite API contract and every projection scripts/gen-suite-api.mjs writes from it (the test-vector copy included).
211
+ const SUITE_API_INPUT = /^(contracts\/right-suite-api\.v1\.json|scripts\/gen-suite-api\.mjs|crates\/rightkit-license\/src\/suite_api_generated\.rs|packages\/app-update\/src\/suite-api\.generated\.ts|packages\/release\/suite-api\.generated\.mjs|crates\/rightkit-license\/test-vectors\/right-suite-api\.v1\.json)$/;
212
+ const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-(index|port-matrix)\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP|PORT-MATRIX)\.md|docs\/port-matrix\.(html|json))$/;
210
213
  const PROBES = Object.freeze({
211
- rustfmt: ["--version"], node: ["--version"], rightkit: ["cargo", "--version"], pnpm: ["--version"],
214
+ rustfmt: ["--version"], node: ["--version"], rustright: ["cargo", "--version"], pnpm: ["--version"],
212
215
  actionlint: ["-version"], "right-git": ["--version"], swift: ["--version"],
213
216
  });
214
217
 
@@ -221,7 +224,7 @@ function commandText(d) {
221
224
  /**
222
225
  * The scan plan for a change: derived from the changed files plus the manifests they belong to.
223
226
  * `contentRoot` is a clean export of the commit (pure content checks); `liveRoot` is the checkout the
224
- * managed `rightkit cargo` and `pnpm` run in. A scan whose files did not change is not planned.
227
+ * managed `rustright cargo` and `pnpm` run in. A scan whose files did not change is not planned.
225
228
  */
226
229
  export function planScans({
227
230
  changed = [], contentRoot, liveRoot = contentRoot, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8"),
@@ -230,8 +233,8 @@ export function planScans({
230
233
  // CI shards (one crate per runner, where the port, pipe, desktop and wall-clock conflicts that force the serial lane do not exist)
231
234
  // and records a note naming them (Adrian 2026-10-22: compile locally on the warm cache, qualify the conflicting tests on VMs).
232
235
  quietTests = "local",
233
- // The command prefix every cargo scan runs through: `rightkit cargo` on a managed host, plain `cargo` in CI.
234
- cargoCommand = ["rightkit", "cargo"],
236
+ // The command prefix every cargo scan runs through: `rustright cargo` on a managed host, plain `cargo` in CI.
237
+ cargoCommand = ["rustright", "cargo"],
235
238
  // Physical containment: a changed path whose real path leaves the real content root (a symlink) is refused.
236
239
  realpath = (p) => fs.realpathSync(p),
237
240
  // Bytes of a changed file for the input digest (defaults to readFile; the gates pass the raw reader).
@@ -260,8 +263,8 @@ export function planScans({
260
263
  rustLocks = null,
261
264
  } = {}) {
262
265
  if (!contentRoot) throw new TypeError("planScans: contentRoot is required");
263
- if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rightkit", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
264
- throw new TypeError('planScans: cargoCommand must be ["rightkit", "cargo"] or ["cargo"]');
266
+ if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rustright", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
267
+ throw new TypeError('planScans: cargoCommand must be ["rustright", "cargo"] or ["cargo"]');
265
268
  }
266
269
  const refusals = []; const accepted = [];
267
270
  for (const raw of new Set(changed)) {
@@ -356,10 +359,25 @@ export function planScans({
356
359
  // ---- class A: content checks on the exported commit
357
360
  // A vendor/ file keeps upstream formatting only when vendor/upstream-files.json records it and its
358
361
  // bytes still match that upstream digest; any other vendor/ .rs (new, patched) is rustfmt-checked.
359
- let upstream = {};
360
- try { upstream = JSON.parse(readFile(path.join(contentRoot, "vendor/upstream-files.json"))).files ?? {}; } catch { /* no manifest: nothing exempt */ }
362
+ // A patched vendor/ file (rightkit#915) keeps upstream formatting only while its bytes match the
363
+ // reviewed `patched` record, which names its upstream base digest and the note describing the patch;
364
+ // reformatting it would bury the patch. Any edit changes the digest, so the file is checked until re-recorded.
365
+ let upstream = {}; let patched = {};
366
+ try {
367
+ const manifest = JSON.parse(readFile(path.join(contentRoot, "vendor/upstream-files.json")));
368
+ upstream = manifest.files ?? {}; patched = manifest.patched ?? {};
369
+ } catch { /* no manifest: nothing exempt */ }
370
+ const DIGEST = /^[0-9a-f]{64}$/;
361
371
  const verbatimUpstream = (f) => f.startsWith("vendor/") && typeof upstream[f] === "string" && bytesDigest(f) === upstream[f];
362
- const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f));
372
+ const recordedPatch = (f) => f.startsWith("vendor/") && DIGEST.test(patched[f]?.base ?? "") && typeof patched[f]?.note === "string"
373
+ && patched[f].note.startsWith("vendor/") && DIGEST.test(patched[f]?.sha256 ?? "") && bytesDigest(f) === patched[f].sha256;
374
+ // Generated Rust is not hand-formatted: bindgen output, or rustfmt's own `@generated` marker in the first five lines.
375
+ const generatedRust = (f) => {
376
+ let head = "";
377
+ try { head = readFile(path.join(contentRoot, f)).split("\n", 5).join("\n"); } catch { return false; }
378
+ return /@generated\b|automatically generated by rust-bindgen\b/.test(head);
379
+ };
380
+ const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f));
363
381
  const byEdition = new Map();
364
382
  for (const f of rustFiles) {
365
383
  let dir = path.dirname(path.join(contentRoot, f));
@@ -434,6 +452,9 @@ export function planScans({
434
452
 
435
453
  // Exceptions expire by date, not by change: checked on every run, whatever changed.
436
454
  if (exists(path.join(contentRoot, "ci/advisory-exceptions.json"))) add({ id: "advisory-exceptions", class: A, internal: "advisory-exceptions" });
455
+ // Like generated-docs, planned only when a changed file is one of the contract's inputs or projections. The generator runs from
456
+ // the trusted scanner checkout (the import below); the exported contract and projections are data it checks.
457
+ if (files.some((f) => SUITE_API_INPUT.test(f)) && exists(path.join(contentRoot, "contracts/right-suite-api.v1.json"))) add({ id: "suite-api", class: A, internal: "suite-api" });
437
458
  if (rustTouched) {
438
459
  for (const ws of touchedWs) add({ id: `cargo-metadata${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, ...metadataArgs()) });
439
460
  for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
@@ -578,7 +599,7 @@ export function planScans({
578
599
  const deferred = [];
579
600
  if (!localCompile) {
580
601
  scans = scans.map((x) => {
581
- if (x.tool !== "rightkit" && !(x.tool === "cargo" && cargoCommand.length === 1)) return x;
602
+ if (x.tool !== "rustright" && !(x.tool === "cargo" && cargoCommand.length === 1)) return x;
582
603
  deferred.push(x.id);
583
604
  return { ...x, deferred: "no local compile grant; the broker refuses this scan" };
584
605
  });
@@ -758,6 +779,12 @@ const INTERNAL = {
758
779
  // Every RustSec exception is dated, scoped to the locked versions and identical in deny.toml and
759
780
  // .cargo/audit.toml (ci/advisory-exceptions.json; Astra 2026-10-22). Expiry fails every run.
760
781
  "advisory-exceptions": (s, ctx) => checkAdvisoryExceptions({ root: ctx.contentRoot, readFile: ctx.read }),
782
+ // Every Right Suite API projection must equal what the generator writes from the contract. The pure check is imported from the
783
+ // scanner's own checkout (scripts/ sits beside packages/release), never from the content root.
784
+ "suite-api": async (s, ctx) => {
785
+ const { suiteApiProblems } = await import("../../scripts/gen-suite-api.mjs");
786
+ return suiteApiProblems((f) => ctx.read(f));
787
+ },
761
788
  "ownership-catalog": (s, ctx) => (ctx.read(s.files[0]) === ctx.read(s.files[1]) ? [] : ["copy packages/release/rightkit-ownership.json to crates/rightkit-ownership/catalog/ownership-catalog.json"]),
762
789
  "changelog-version": (s, ctx) => {
763
790
  const version = (ctx.read(s.files[0]).match(/^version\s*=\s*"([^"]+)"/m) || [])[1];