@rightkit/release 0.6.8 → 0.6.11

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 (96) hide show
  1. package/README.md +30 -12
  2. package/admission-files.mjs +4 -1
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +105 -27
  7. package/assemble-app-command.mjs +151 -0
  8. package/assembled-app.mjs +445 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/darwin/rightkit-swap.sign.json +6 -0
  11. package/bin/rightkit-gate.mjs +1608 -345
  12. package/broker-pins.mjs +152 -0
  13. package/build-launch.mjs +88 -0
  14. package/build-ownership.mjs +1231 -0
  15. package/build-release.mjs +82 -28
  16. package/cargo-contract.mjs +3 -1
  17. package/cargo-ledger.mjs +62 -0
  18. package/cargo-target-shim.mjs +238 -0
  19. package/class-a-launch-guard.mjs +126 -0
  20. package/cli/right-release.mjs +31 -5
  21. package/clippy-equivalence.mjs +202 -0
  22. package/clippy-route.mjs +543 -0
  23. package/codesign-shim.mjs +11 -1
  24. package/committed-source.mjs +39 -5
  25. package/crate-workspaces.mjs +87 -0
  26. package/create-mac-updater.mjs +14 -2
  27. package/deterministic-scans.mjs +997 -71
  28. package/development-signing.mjs +4 -1
  29. package/development.mjs +120 -21
  30. package/direct-receipt.mjs +135 -0
  31. package/door-policy.mjs +71 -6
  32. package/door.mjs +6 -6
  33. package/freshness-census.mjs +257 -0
  34. package/gate-executor.mjs +14 -2
  35. package/gate-stages.mjs +91 -15
  36. package/heavy-command.mjs +3 -2
  37. package/hook-retirement.mjs +65 -0
  38. package/init-command.mjs +1 -1
  39. package/install-app.mjs +11 -2
  40. package/install-assembled-app.mjs +423 -0
  41. package/inventory-check.mjs +11 -8
  42. package/ios-dev.mjs +70 -3
  43. package/ios-upload.mjs +20 -15
  44. package/linux-artifacts.mjs +93 -0
  45. package/local-publish.mjs +224 -53
  46. package/local-qualify.mjs +5 -3
  47. package/local-runtime.mjs +5 -4
  48. package/mac-package-recipe.mjs +4 -1
  49. package/managed-cargo.mjs +47 -5
  50. package/managed-root-guard.mjs +34 -0
  51. package/mirror-root-artifact.mjs +18 -5
  52. package/native-helpers.mjs +355 -0
  53. package/ownership-class-rules.mjs +1 -1
  54. package/ownership-evidence.mjs +37 -13
  55. package/ownership-hooks.mjs +20 -59
  56. package/ownership-lockfiles.mjs +14 -1
  57. package/ownership-policy-bundle.mjs +3 -2
  58. package/ownership-staged.mjs +2 -1
  59. package/ownership.mjs +24 -0
  60. package/package-recipe-targets.mjs +84 -10
  61. package/package.json +4 -3
  62. package/pipeline-admission.mjs +4 -4
  63. package/pipeline-receipts.mjs +302 -302
  64. package/preflight.mjs +27 -4
  65. package/project-type.mjs +2 -2
  66. package/provenance-verify.mjs +115 -0
  67. package/publish-cargo.mjs +12 -9
  68. package/qa-admission-policy.json +10 -1
  69. package/recipe-build.mjs +106 -14
  70. package/release-artifact-path.mjs +341 -0
  71. package/release-config.mjs +1 -1
  72. package/release-invocation.mjs +8 -1
  73. package/release-qa-command.mjs +25 -9
  74. package/release-source-brand.mjs +17 -0
  75. package/release-source-identity.mjs +171 -0
  76. package/release.mjs +59 -22
  77. package/rightkit-versions.json +73 -47
  78. package/runtime-budgets.mjs +6 -2
  79. package/scan-digest.mjs +71 -5
  80. package/sign-updater.mjs +6 -3
  81. package/snapshot-broker.mjs +172 -0
  82. package/snapshot-check.mjs +312 -0
  83. package/snapshot-git.mjs +166 -0
  84. package/snapshot-request.mjs +363 -0
  85. package/snapshot-root-config.mjs +76 -0
  86. package/snapshot.mjs +969 -0
  87. package/source-split.mjs +73 -0
  88. package/stage-commands.mjs +60 -0
  89. package/stage-ledger.mjs +204 -0
  90. package/stage-sidecar-command.mjs +5 -1
  91. package/stage-sidecar.mjs +157 -27
  92. package/swap-helper-bundle.mjs +5 -2
  93. package/test-command.mjs +17 -4
  94. package/unit-gate.mjs +301 -0
  95. package/web-build.mjs +40 -6
  96. package/win-package-recipe.mjs +20 -7
@@ -1,4 +1,5 @@
1
1
  import { createHash, X509Certificate } from "node:crypto";
2
+ import { nativeHelperEntitlementMap, nativeHelpersFromConfig } from "./native-helpers.mjs";
2
3
  import { closeSync, copyFileSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -192,7 +193,9 @@ export function readDevSigningSettings(descriptor = {}, config = {}, { root, exi
192
193
  };
193
194
  const signMap = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
194
195
  const entitlements = [...compileBundlePathMap(descriptor.entitlements, "install.entitlements", { reject: invalidConfig, value: plistPath }),
195
- ...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath })];
196
+ ...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath }),
197
+ // Native helpers embedded in the dev .app carry their own entitlements, exactly as the release lane signs them.
198
+ ...compileBundlePathMap((() => { const h = nativeHelpersFromConfig(config); return h ? nativeHelperEntitlementMap(h) : undefined; })(), "macPackage.nativeHelpers", { reject: invalidConfig, value: plistPath })];
196
199
  if (descriptor.timestamp !== undefined && typeof descriptor.timestamp !== "boolean") invalidConfig("install.timestamp must be true or false");
197
200
  if (descriptor.assertTeamSignedNested !== undefined && typeof descriptor.assertTeamSignedNested !== "boolean") invalidConfig("install.assertTeamSignedNested must be true or false");
198
201
  const rawAssertions = descriptor.assert ?? [];
package/development.mjs CHANGED
@@ -2,12 +2,14 @@ import { discoverBrokerSocket, resolveManagedRoots } from './managed-roots.mjs';
2
2
  import { spawnSync } from "./background-process.mjs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, lstatSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
5
+ import { buildLaunch } from "./build-launch.mjs";
5
6
  import path from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
8
9
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
10
+ import { readSourceSplit } from "./source-split.mjs";
9
11
  import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
10
- import { acquireManagedRunLease, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
12
+ import { acquireManagedRunLease, directCargoEnv, directPlatform, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
11
13
  import { BUNDLE_MARKERS, bundleMarkerBuffer, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
12
14
  import { installPortableDevelopment } from "./portable-development.mjs";
13
15
  import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
@@ -15,7 +17,10 @@ import { stageDevelopmentBinaries, validateStageEntries } from "./stage-binary.m
15
17
  import { assertStableDesignatedRequirement, readBundleExecutable, requestedCiAdhocSigning, signMacosDevelopmentBundle } from "./development-signing.mjs";
16
18
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
17
19
  import { installTauriDevId, resolveTauriDevIdDescriptor, TAURI_DEVID_KIND } from "./install-tauri-devid.mjs";
18
- import { assertIosDevelopmentSession, installIosDevelopment, prepareIosDevelopment, validateIosDevelopment } from "./ios-dev.mjs";
20
+ import { ASSEMBLED_APP_KIND, installAssembledMac, installAssembledWindows, resolveAssembledInstall } from "./install-assembled-app.mjs";
21
+ import { assertIosDevelopmentSession, installIosDevelopment, iosUiTestCommands, prepareIosDevelopment, prepareIosUiTests, validateIosDevelopment } from "./ios-dev.mjs";
22
+ import { packLinuxArtifacts, validateLinuxArchive } from "./linux-artifacts.mjs";
23
+ import { buildAndEmbedNativeHelpers, nativeHelpersFromConfig } from "./native-helpers.mjs";
19
24
  import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
20
25
 
21
26
  // One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
@@ -30,9 +35,9 @@ export function cargoProfileDirectory(profile) {
30
35
  }
31
36
  const RELEASE_ONLY_APPLE_ENV = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_API_KEY", "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD"];
32
37
 
33
- export function developmentEnvironment(env = process.env) {
38
+ export function developmentEnvironment(env = process.env, platform = process.platform) {
34
39
  const result = {
35
- ...env,
40
+ ...(directPlatform(platform) ? directCargoEnv(env, platform) : env),
36
41
  RIGHTKIT_BUILD_MODE: "dev",
37
42
  RIGHTKIT_CARGO_PROFILE: DEVELOPMENT_PROFILE,
38
43
  TAURI_SIGNING_PRIVATE_KEY: "",
@@ -79,22 +84,39 @@ export function validateDevelopmentConfig(config, platform, { root = process.cwd
79
84
  if (platform !== "mac") throw new Error(`the ${TAURI_DEVID_KIND} development installer is for --platform mac`);
80
85
  resolveTauriDevIdDescriptor(target.install, { appRoot: root });
81
86
  if (!target.install.targetTriple && !target.install.bundleDirectory) throw new Error(`development.targets.${platform}.install requires targetTriple (or bundleDirectory) to find the built .app`);
87
+ } else if (target.install?.kind === ASSEMBLED_APP_KIND) {
88
+ if (platform !== "mac" && platform !== "win") throw new Error(`the ${ASSEMBLED_APP_KIND} development installer is for --platform mac or win`);
89
+ // The assembly (assembly.<platform>) and the install keys are validated together, before any build.
90
+ resolveAssembledInstall({ config, platform });
82
91
  } else if (target.install?.kind === "adb") {
83
92
  if (platform !== "android") throw new Error("the adb development installer is for --platform android");
84
93
  } else if (target.install) throw new Error(`unsupported development installer kind: ${target.install.kind}`);
94
+ // Linux is build-only: its artifacts are the staged sidecars bound in the stage receipt; nothing is installed on a Linux host.
95
+ if (platform === "linux" && target.install) throw new Error("development.targets.linux is build-only: it takes no install (the staged artifacts are the result)");
96
+ if (target.archive !== undefined && platform !== "linux") throw new Error(`development.targets.${platform}.archive: only the linux target packs an archive`);
97
+ if (platform === "linux") validateLinuxArchive(target.archive);
85
98
  validateStageEntries(target.stage, `development.targets.${platform}.stage`);
86
99
  if (platform === "android") return { ...target, ...validateAndroidDevelopment(target, { root }) };
87
100
  return target;
88
101
  }
89
102
 
90
- export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync,
91
- codesignEnvironment = macosCodesignEnvironment, serial }) {
92
- if (!new Set(["win", "mac", "android", "ios"]).has(platform)) throw new Error("--platform must be win, mac, android or ios");
103
+ export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync, door = buildLaunch,
104
+ codesignEnvironment = macosCodesignEnvironment, serial, uiTests = false }) {
105
+ // UI test journeys (`right-release test --platform ios`): the declared XCUITest journeys on the dev device, under the same guards.
106
+ if (uiTests && platform !== "ios") throw new Error("UI test journeys run for --platform ios only");
107
+ if (!new Set(["win", "mac", "linux", "android", "ios"]).has(platform)) throw new Error("--platform must be win, mac, linux, android or ios");
93
108
  // iOS: --dry-run validates the config and prints the plan on any host; everything else needs macOS with Xcode.
94
109
  if (!(platform === "ios" && dryRun)) assertNativeDevelopmentHost(platform);
95
110
  const target = validateDevelopmentConfig(config, platform, { root });
96
- const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
111
+ // Inside the door's source split `root` is the snapshot of a commit (no git directory): the repository is the checkout the door
112
+ // ran in, whose primary-checkout rule and execution policy still apply, and the commit is the split's.
113
+ const split = readSourceSplit({ env, cwd: root });
114
+ const repoRoot = split ? split.checkout : git(root, ["rev-parse", "--show-toplevel"]);
97
115
  assertPrimaryReleaseCheckout(repoRoot, env);
116
+ if (dryRun && uiTests) {
117
+ if (!target.uiTests) throw new Error("development.targets.ios.uiTests is not declared: list the journeys in uiTests.onlyTesting");
118
+ return { built: false, installed: false, plan: { tests: iosUiTestCommands(target, { root, storage: { derivedData: "<rustright storage xcode-args -derivedDataPath>" }, destinationId: "<device or simulator id>", resultBundle: "<fresh .xcresult per run>" }) } };
119
+ }
98
120
  if (dryRun) return { built: false, installed: false, plan: { ...(target.ndk ? { ndk: androidDevelopmentNdkCommand(target.ndk) } : {}), build: target.build, install: buildOnly ? null : target.install ?? null, profile: DEVELOPMENT_PROFILE } };
99
121
  assertReleaseExecutionAllowed({ cwd: repoRoot, environment: env, command: `right-release dev --platform ${platform}` });
100
122
  // Refuse an invalid or off-CI ad hoc opt-in before the (long) build, not after it.
@@ -103,7 +125,8 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
103
125
  let iosPrepared = null;
104
126
  if (platform === "ios") {
105
127
  assertIosDevelopmentSession(target, { env, run });
106
- iosPrepared = prepareIosDevelopment(target, { root, env, run, serial });
128
+ if (uiTests && !target.uiTests) throw new Error("development.targets.ios.uiTests is not declared: list the journeys in uiTests.onlyTesting");
129
+ iosPrepared = uiTests ? prepareIosUiTests(target, { root, env, run, serial }) : prepareIosDevelopment(target, { root, env, run, serial });
107
130
  }
108
131
  // Android: the device serial is required, so a missing one is refused before the (long) build too.
109
132
  if (!buildOnly && platform === "android" && target.install) androidDevelopmentSerial({ descriptor: target.install, serial, env });
@@ -114,34 +137,85 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
114
137
  try {
115
138
  const startedMs = Date.now();
116
139
  // Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
117
- if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
118
- for (const command of iosPrepared ? iosPrepared.build : target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
140
+ if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, door, label: "native build" }); }
141
+ if (uiTests) {
142
+ for (const command of iosPrepared.tests) execute(command, { root, env: childEnv, dryRun, run, door, label: "ui tests" });
143
+ console.log(`right-release test: ios journeys passed (${target.uiTests.onlyTesting.join(", ")}); result bundle ${iosPrepared.resultBundle}`);
144
+ return { built: true, installed: false, tested: true, resultBundle: iosPrepared.resultBundle };
145
+ }
146
+ for (const command of iosPrepared ? iosPrepared.build : target.build) execute(command, { root, env: childEnv, dryRun, run, door, label: "build" });
119
147
  // Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
120
148
  // broker's per-generation target folder or a hashed dev-targets folder.
121
149
  const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
122
150
  for (const file of staged) console.log(`right-release dev: staged ${file}`);
151
+ // Linux: the declared files become one tarball per architecture plus the digest manifest a guest link pins (rightkit#1230).
152
+ // Only from a clean checkout: an artifact others install must name the exact commit it was built from.
153
+ if (platform === "linux" && target.archive && !dryRun) {
154
+ // A split's snapshot is exactly its commit (the door verifies it at the start and end of the stage); the checkout may hold anything.
155
+ if (!split && git(repoRoot, ["status", "--porcelain", "--untracked-files=normal"])) throw new Error("linux artifacts are packed only from a clean checkout: commit first, so the manifest names the exact source");
156
+ let version;
157
+ try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
158
+ packLinuxArtifacts({ root, archive: validateLinuxArchive(target.archive), app: target.archive.name, version, commit: split ? split.commit : git(repoRoot, ["rev-parse", "HEAD"]), run });
159
+ }
123
160
  if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
124
161
  if (iosPrepared) return { built: true, installed: true, ios: installIosDevelopment(iosPrepared.install, { root, env: childEnv, run }) };
125
162
  if (target.install.kind === TAURI_DEVID_KIND) {
126
163
  // installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
127
164
  const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
165
+ // Dev = production + QA: the same native helpers the release embeds are built, embedded and signed into the dev .app too.
166
+ const helpers = platform === "mac" ? nativeHelpersFromConfig(config) : undefined;
167
+ if (helpers) {
168
+ let version;
169
+ try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
170
+ buildAndEmbedNativeHelpers({ appRoot: root, app: built, helpers, version, forceArch: String(target.install.targetTriple).startsWith("x86_64") ? "x86_64" : "arm64", configuration: "debug", replaceExisting: true, env: childEnv });
171
+ }
128
172
  releaseLease = false;
129
173
  return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
130
174
  .then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
131
175
  .finally(() => lease.release());
132
176
  }
133
- const signing = install(target.install, { root, platform, config, env: childEnv, run, sinceMs: startedMs, serial });
177
+ if (target.install.kind === ASSEMBLED_APP_KIND) {
178
+ const { spec, descriptor } = resolveAssembledInstall({ config, platform });
179
+ if (platform === "win") {
180
+ const assembled = installAssembledWindows({ root, config, descriptor, spec, env: childEnv });
181
+ return { built: true, installed: true, ...(staged.length ? { staged } : {}), assembled };
182
+ }
183
+ // installAssembledMac is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
184
+ releaseLease = false;
185
+ return installAssembledMac({ root, config, descriptor, spec, env: childEnv })
186
+ .then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), assembled: outcome }))
187
+ .finally(() => lease.release());
188
+ }
189
+ const signing = install(target.install, { root, platform, config, env: childEnv, run, door, sinceMs: startedMs, serial });
134
190
  return { built: true, installed: true, ...(staged.length ? { staged } : {}), ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
135
191
  } finally { if (releaseLease) lease.release(); }
136
192
  }
137
193
 
138
- export function assertNativeDevelopmentHost(platform, host = process.platform) {
139
- if ((platform === "win" && host !== "win32") || ((platform === "mac" || platform === "ios") && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
194
+ /** WSL reports `linux` as its platform under both generations; which one it is decides whether a Linux kernel runs at all. */
195
+ export function isWsl({ env = process.env, readFile = (f) => readFileSync(f, "utf8") } = {}) {
196
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
197
+ try { return /microsoft|wsl/i.test(readFile("/proc/sys/kernel/osrelease")) || /microsoft|wsl/i.test(readFile("/proc/version")); } catch { return false; }
140
198
  }
141
199
 
142
- function install(descriptor, { root, platform, config, env, run, sinceMs = null, serial }) {
200
+ /**
201
+ * WSL1 translates Linux system calls onto the Windows kernel (osrelease like `4.4.0-19041-Microsoft`), so it is not a Linux host.
202
+ * WSL2 runs a genuine Linux kernel in a Hyper-V utility VM (osrelease `…-microsoft-standard-WSL2`): a Linux VM, which is what
203
+ * native Linux builds need (rightkit#1230: "x86_64 on a Linux VM on the Dell"; Astra 2026-10-28). Unknown WSL counts as WSL1.
204
+ */
205
+ export function isWsl1({ env = process.env, readFile = (f) => readFileSync(f, "utf8") } = {}) {
206
+ if (!isWsl({ env, readFile })) return false;
207
+ try { return !/microsoft-standard-WSL2|WSL2/i.test(readFile("/proc/sys/kernel/osrelease")); } catch { return true; }
208
+ }
209
+
210
+ export function assertNativeDevelopmentHost(platform, host = process.platform, { wsl1 = () => isWsl1() } = {}) {
211
+ // Linux builds run on a Linux kernel of the target's architecture (no cross-compiling); WSL1 has no Linux kernel.
212
+ if ((platform === "win" && host !== "win32") || ((platform === "mac" || platform === "ios") && host !== "darwin") || (platform === "linux" && host !== "linux")) throw new Error(`development target ${platform} requires its native host`);
213
+ if (platform === "linux" && wsl1()) throw new Error("development target linux requires a Linux kernel; this is WSL1 (Linux system calls translated onto the Windows kernel). Use WSL2 or a Linux VM");
214
+ }
215
+
216
+ function install(descriptor, { root, platform, config, env, run, door, sinceMs = null, serial }) {
143
217
  if (descriptor.kind === "adb") return installAndroidDevelopment(descriptor, { root, env, run, serial });
144
- if (descriptor.kind === "command") return execute(descriptor, { root, env, run, label: "install" });
218
+ if (descriptor.kind === "command") return execute(descriptor, { root, env, run, door, label: "install" });
145
219
  if (descriptor.kind === "portable") return installPortableDevelopment(descriptor, { root, platform: platform === "win" ? "win32" : "darwin", env });
146
220
  if (platform === "win") {
147
221
  if (!descriptor.expectedInstalledPath) throw new Error("Windows Tauri installer requires expectedInstalledPath for installed verification");
@@ -286,20 +360,45 @@ function resolveBundleRoot(descriptor, { root, env, runCargo }) {
286
360
 
287
361
  function platformBundleKind(descriptor) { return descriptor.targetTriple.includes("apple") ? "macos" : "nsis"; }
288
362
  function validateCommand(command, label) { if (!command || typeof command.cmd !== "string" || !command.cmd.trim() || (command.args !== undefined && !Array.isArray(command.args))) throw new Error(`${label} must declare cmd and args`); }
289
- function execute(command, { root, env, dryRun, run, label }) {
363
+ function execute(command, { root, env, dryRun, run, label, door = null }) {
290
364
  validateCommand(command, `development ${label}`);
291
365
  const cwd = path.resolve(root, command.cwd ?? ".");
292
366
  if (!inside(root, cwd)) throw new Error(`development ${label} cwd must stay inside app root`);
293
367
  const args = (command.args ?? []).map(String);
294
368
  if (dryRun) return { cmd: command.cmd, args, cwd };
295
- const launch = resolveCommand(command.cmd, args, { root, env });
369
+ // A pnpm step through the door is the broker's rightkit-pnpm job (or plain pnpm on a direct host), launched by name: the door never uses
370
+ // the node entry resolveCommand would find, so resolving pnpm's JavaScript CLI here would only fail a host that does not need it.
371
+ // The door's own `pnpm exec right-release` forms still resolve to this package's right-release.
372
+ const ownReleaseStep = (args[0] === "exec" && args[1] === "right-release") || args[0] === "right-release";
373
+ const resolved = door && command.cmd === "pnpm" && !ownReleaseStep ? { command: "pnpm", args } : resolveCommand(command.cmd, args, { root, env });
374
+ // Every dev build step goes through the one door (build-launch.mjs): the door's own right-release stays in process order, a pnpm step
375
+ // is the rightkit-pnpm broker job (by its own name, not the node entry resolveCommand finds), anything else as resolved.
376
+ // A configured command can run code regardless of its label, so installation commands use the door too.
377
+ const own = resolved.command === process.execPath && resolved.args[0] === OWN_RIGHT_RELEASE;
378
+ const launch = own || !door ? resolved : { ...resolved, ...door(command.cmd === "pnpm" ? "pnpm" : resolved.command, command.cmd === "pnpm" ? args : resolved.args, { env: resolved.env ?? env, cwd, exists: existsSync }) };
296
379
  const result = run(launch.command, launch.args, { cwd, env: launch.env ?? env, stdio: "inherit", windowsHide: true, shell: false, timeout: command.timeoutMs });
297
380
  if (result.status !== 0) throw new Error(`development ${label} command failed (${result.status}): ${command.name ?? command.cmd}`);
298
381
  }
299
- export function resolveCommand(command, args, { root, env = process.env } = {}) {
382
+ /** This package's own right-release entry: the door's RightKit, whatever the app's node_modules holds. */
383
+ const OWN_RIGHT_RELEASE = fileURLToPath(new URL("./cli/right-release.mjs", import.meta.url));
384
+ /** `rustright <tool>` forms that map to the real toolchain on a direct (Linux) host. */
385
+ const DIRECT_TOOLS = new Set(["cargo", "rustc", "rustdoc"]);
386
+ export function resolveCommand(command, args, { root, env = process.env, platform = process.platform } = {}) {
300
387
  if (command === "node") return { command: process.execPath, args };
388
+ // A dev target's `right-release …` step runs the door's own right-release (this package), never the app's node_modules copy:
389
+ // `pnpm exec right-release` puts the app's bin first on PATH, so PATH order cannot decide it (HeardRight on the Dell, 2026-10-27:
390
+ // the app's older guard refused what the door had admitted). `pnpm exec right-release …` and `pnpm right-release …` are the same step.
391
+ if (command === "right-release") return { command: process.execPath, args: [OWN_RIGHT_RELEASE, ...args] };
392
+ if (command === "pnpm" && ((args[0] === "exec" && args[1] === "right-release") || args[0] === "right-release")) {
393
+ return { command: process.execPath, args: [OWN_RIGHT_RELEASE, ...args.slice(args[0] === "exec" ? 2 : 1)] };
394
+ }
301
395
  if (command === "pnpm") return { command: process.execPath, args: [resolvePnpmCli({ root, env }), ...args] };
302
396
  if (command === "rightkit") throw new Error("`rightkit` is the retired name of the RustRight client (rustright#91); use rustright (cmd: \"rustright\")");
397
+ // A direct (Linux) host has no broker: `rustright cargo <args>` runs the real cargo, RustRight shims off PATH (managed-cargo directPlatform).
398
+ if (command === "rustright" && directPlatform(platform)) {
399
+ if (!DIRECT_TOOLS.has(args[0])) throw new Error(`\`rustright ${args[0] ?? ""}\` has no direct form on this ${platform} host (no broker on Linux); only \`rustright cargo|rustc|rustdoc\` map to the toolchain`);
400
+ return { command: args[0], args: args.slice(1), env: directCargoEnv(env, platform) };
401
+ }
303
402
  if (command === "rustright") {
304
403
  const client = managedRuntimeClient(env);
305
404
  if (!client) throw new Error("installed RightKit runtime client is unavailable for development command");
@@ -351,7 +450,7 @@ function inside(root, child) { const rel = path.relative(path.resolve(root), pat
351
450
  export async function main(args = process.argv.slice(2)) {
352
451
  let platform, configPath = defaultReleaseConfigFile(), dryRun = false, buildOnly = false, serial;
353
452
  for (let i = 0; i < args.length; i++) {
354
- if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android|ios [--config right-release.config.json] [--dry-run] [--build-only] [--serial <adb serial | iOS device id>]"); return; }
453
+ if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|linux|android|ios [--config right-release.config.json] [--dry-run] [--build-only] [--serial <adb serial | iOS device id>]"); return; }
355
454
  if (args[i] === "--platform") platform = args[++i];
356
455
  else if (args[i] === "--config") configPath = args[++i];
357
456
  else if (args[i] === "--dry-run") dryRun = true;
@@ -359,7 +458,7 @@ export async function main(args = process.argv.slice(2)) {
359
458
  else if (args[i] === "--serial") serial = args[++i];
360
459
  else throw new Error(`unknown dev option: ${args[i]}`);
361
460
  }
362
- if (!platform) throw new Error("right-release dev requires --platform win|mac|android|ios");
461
+ if (!platform) throw new Error("right-release dev requires --platform win|mac|linux|android|ios");
363
462
  const absoluteConfig = path.resolve(configPath);
364
463
  const safeEnv = developmentEnvironment(process.env);
365
464
  for (const [key, value] of Object.entries(safeEnv)) process.env[key] = value;
@@ -0,0 +1,135 @@
1
+ // The direct (Linux) host's evidence for a snapshot compile (item 7 rows 7.09, 7.12, 7.18; GATE-CONTRACT "Broker interface"):
2
+ // - the output directory, resolved in the PRIMARY CHECKOUT with the stage's environment before the source location changes, with
3
+ // its inputs recorded (7.09);
4
+ // - the receipt the gate writes itself, "the same field names minus the broker-only ones (admission, queue, reusedFrom)" (7.18),
5
+ // and the consumer-side matrix that refuses a receipt that lacks one, carries a broker-only one, or holds no census (7.22 Linux half).
6
+ // Pure functions with injected spawn and file system: the gate (bin/rightkit-gate.mjs) wires them to the host.
7
+ import { createHash } from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { validateFreshness } from "./freshness-census.mjs";
11
+
12
+ export const DIRECT_RECEIPT_SCHEMA = "rightkit.gate-direct-receipt/1";
13
+ export const DIAGNOSTICS_SCHEMA = "rightkit.gate-direct-diagnostics/1";
14
+ /** The names GATE-CONTRACT lists as broker-only: a direct receipt never carries them. */
15
+ export const BROKER_ONLY_FIELDS = Object.freeze(["admission", "queue", "reusedFrom"]);
16
+ /** The contract's returned-evidence names a direct receipt carries (7.18). `snapshot.path|treeAtStart|treeAtExit` are checked inside `snapshot`. */
17
+ export const DIRECT_RECEIPT_REQUIRED = Object.freeze(["requestId", "brokerVersion", "executor", "commit", "snapshot", "declaredInputDigests", "verdict", "freshness", "timingsMs", "exit", "diagnosticsPath", "outputDir", "refreshed"]);
18
+ export const TIMING_FIELDS = Object.freeze(["bind", "cargo", "afterExitBind", "diagnostics"]);
19
+ /** The environment a `cargo metadata` target resolution reads: recorded by value (the rest of the environment by digest only). */
20
+ export const TARGET_ENV_NAMES = Object.freeze(["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR", "CARGO_HOME", "RUSTUP_TOOLCHAIN"]);
21
+
22
+ const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
23
+ const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
24
+
25
+ /** True when `child` is `parent` or below it (lexically, after resolving both). */
26
+ export function isWithin(parent, child) {
27
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
28
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
29
+ }
30
+
31
+ /** The stage environment a resolution ran with: the target-relevant names by value, the whole environment by digest. */
32
+ export function envInputs(env) {
33
+ const record = {};
34
+ for (const name of TARGET_ENV_NAMES) record[name] = typeof env?.[name] === "string" ? env[name] : null;
35
+ const all = Object.keys(env ?? {}).sort().map((k) => `${k}=${env[k]}`).join("\n");
36
+ return { ...record, digest: createHash("sha256").update(all).digest("hex") };
37
+ }
38
+
39
+ /**
40
+ * The absolute output directory Cargo would use for one workspace, resolved where the contract says: `cargo metadata --format-version 1
41
+ * --no-deps` run with its working directory in the PRIMARY CHECKOUT (never the snapshot) and with the stage's environment, so
42
+ * `CARGO_TARGET_DIR`, `.cargo/config.toml` `build.target-dir` (relative to the checkout's config, or absolute) and the default `target/`
43
+ * all resolve as they do for a developer's own build. Returns `{ dir, inputs }` (the inputs are recorded in the receipt) or throws an
44
+ * `RK_TARGET_RESOLUTION` error whose message names why: a resolution made in the snapshot, no absolute directory, or a directory that
45
+ * lies inside the snapshot (a second output directory).
46
+ */
47
+ export function resolveTargetDirectory({ spawn, cargoCommand, manifest, env, checkoutRoot, snapshotRoot = null, workspace = "." }) {
48
+ const fail = (message) => Object.assign(new Error(message), { code: "RK_TARGET_RESOLUTION" });
49
+ const cwd = path.dirname(manifest);
50
+ if (!isWithin(checkoutRoot, cwd)) throw fail(`the output directory of workspace ${workspace} must be resolved in the primary checkout ${checkoutRoot}, not in ${cwd}`);
51
+ if (snapshotRoot && isWithin(snapshotRoot, cwd)) throw fail(`the output directory of workspace ${workspace} must be resolved before the source location changes, not in the snapshot ${snapshotRoot}`);
52
+ const args = [...cargoCommand.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", manifest];
53
+ const r = spawn(cargoCommand[0], args, { cwd, env });
54
+ let dir = null;
55
+ try { dir = failed(r) ? null : JSON.parse(String(r.stdout ?? "")).target_directory; } catch { dir = null; }
56
+ if (typeof dir !== "string" || !path.isAbsolute(dir)) throw fail(`cargo metadata in the checkout reported no absolute target_directory for ${workspace}${failed(r) ? ` (${why(r)})` : ""}`);
57
+ const resolved = path.resolve(dir);
58
+ if (snapshotRoot && isWithin(snapshotRoot, resolved)) throw fail(`the output directory of workspace ${workspace} resolves to ${resolved}, inside the snapshot ${snapshotRoot}; a snapshot compile writes only to the checkout's directory, never a second one under the commit`);
59
+ return { dir: resolved, inputs: { cwd, manifest, command: [cargoCommand[0], ...args], env: envInputs(env), targetDirectory: resolved } };
60
+ }
61
+
62
+ /** The Class A and B result the receipt's `verdict` carries (a direct compile reuses nothing, so no `reusedFrom`). */
63
+ export function directVerdict({ scans = [], failures = [], deferrals = [] } = {}) {
64
+ const byClass = (c) => { const rows = scans.filter((s) => s.class === c); return { scans: rows.length, failed: rows.filter((s) => s.status === "fail").length }; };
65
+ return { result: failures.length ? "FAILED" : "PASSED", classA: byClass("A"), classB: byClass("B"), deferred: deferrals.length };
66
+ }
67
+
68
+ /** Writes the structured diagnostics of one direct compile and returns the file's absolute path (the receipt's `diagnosticsPath`). */
69
+ export function writeDirectDiagnostics({ dir, requestId, commit, exit, scans = [], failures = [], deferrals = [], notes = [], freshness = null, writeFile = fs.writeFileSync, mkdir = fs.mkdirSync }) {
70
+ mkdir(dir, { recursive: true });
71
+ const file = path.resolve(dir, `direct-diagnostics-${requestId}.json`);
72
+ const rows = scans.map((s) => ({ id: s.id, class: s.class ?? null, status: s.status, ...(s.code ? { code: s.code } : {}), ...(s.detail ? { detail: String(s.detail).slice(0, 1000) } : {}) }));
73
+ writeFile(file, `${JSON.stringify({ schema: DIAGNOSTICS_SCHEMA, requestId, commit, exit, scans: rows, failures, deferrals, notes, freshness }, null, 2)}\n`);
74
+ return file;
75
+ }
76
+
77
+ /** The receipt of one direct compile: the contract's returned-evidence names, no broker-only one. Throws nothing; `verifyDirectReceipt` judges it. */
78
+ export function buildDirectReceipt({ requestId, issuedAt, commit, snapshotPath, treeAtStart, treeAtExit, ownershipHandle = null, declaredInputDigests = [], verdict, freshness, timingsMs, exit, diagnosticsPath, outputDirs = {}, outputResolution = {}, cargoWrapper = null }) {
79
+ const dirs = [...new Set(Object.values(outputDirs))];
80
+ return {
81
+ schema: DIRECT_RECEIPT_SCHEMA, requestId, brokerVersion: null, executor: "direct", issuedAt,
82
+ commit: { sha: commit.sha, tree: commit.tree },
83
+ snapshot: { path: snapshotPath, treeAtStart, treeAtExit },
84
+ declaredInputDigests, ownership: { handle: ownershipHandle },
85
+ verdict, freshness, timingsMs, exit, exitStatus: exit, diagnosticsPath,
86
+ outputDir: dirs.length === 1 ? dirs[0] : null, outputDirs: { ...outputDirs }, outputResolution,
87
+ refreshed: [], cargoWrapper,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * The consumer's matrix over a direct receipt (the Linux half of row 7.22): every contract name present with the right type, the
93
+ * snapshot bound to the commit's tree at start and exit, a well formed freshness census, and none of the broker-only names.
94
+ * Returns the list of problems; an empty list means the receipt verifies.
95
+ */
96
+ export function verifyDirectReceipt(receipt) {
97
+ const problems = [];
98
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return ["the receipt is not an object"];
99
+ if (receipt.schema !== DIRECT_RECEIPT_SCHEMA) problems.push(`schema is ${JSON.stringify(receipt.schema)}, not ${DIRECT_RECEIPT_SCHEMA}`);
100
+ for (const name of DIRECT_RECEIPT_REQUIRED) if (!Object.hasOwn(receipt, name)) problems.push(`${name} is absent`);
101
+ for (const name of BROKER_ONLY_FIELDS) if (Object.hasOwn(receipt, name)) problems.push(`${name} is a broker-only field; a direct receipt never carries it`);
102
+ const text = (v) => typeof v === "string" && v !== "";
103
+ if (Object.hasOwn(receipt, "requestId") && !text(receipt.requestId)) problems.push("requestId is not a string");
104
+ if (Object.hasOwn(receipt, "brokerVersion") && receipt.brokerVersion !== null) problems.push("brokerVersion is not null on a direct host");
105
+ if (Object.hasOwn(receipt, "executor") && receipt.executor !== "direct") problems.push(`executor is ${JSON.stringify(receipt.executor)}, not "direct"`);
106
+ const commit = receipt.commit;
107
+ if (Object.hasOwn(receipt, "commit") && !(commit && text(commit.sha) && text(commit.tree))) problems.push("commit has no sha and tree");
108
+ const snap = receipt.snapshot;
109
+ if (Object.hasOwn(receipt, "snapshot")) {
110
+ if (!(snap && text(snap.path) && text(snap.treeAtStart) && text(snap.treeAtExit))) problems.push("snapshot has no path, treeAtStart and treeAtExit");
111
+ else if (commit && text(commit.tree) && (snap.treeAtStart !== commit.tree || snap.treeAtExit !== commit.tree)) problems.push("snapshot.treeAtStart and treeAtExit are not the commit's tree");
112
+ }
113
+ if (Object.hasOwn(receipt, "declaredInputDigests") && !Array.isArray(receipt.declaredInputDigests)) problems.push("declaredInputDigests is not a list");
114
+ const verdict = receipt.verdict;
115
+ if (Object.hasOwn(receipt, "verdict") && !(verdict && (verdict.result === "PASSED" || verdict.result === "FAILED"))) problems.push("verdict has no PASSED or FAILED result");
116
+ if (Object.hasOwn(receipt, "freshness")) for (const p of validateFreshness(receipt.freshness)) problems.push(p);
117
+ const timings = receipt.timingsMs;
118
+ if (Object.hasOwn(receipt, "timingsMs")) {
119
+ if (!timings || typeof timings !== "object") problems.push("timingsMs is not an object");
120
+ else {
121
+ for (const name of TIMING_FIELDS) if (!(Number.isFinite(timings[name]) && timings[name] >= 0)) problems.push(`timingsMs.${name} is not a duration`);
122
+ for (const name of BROKER_ONLY_FIELDS) if (Object.hasOwn(timings, name)) problems.push(`timingsMs.${name} is a broker-only field`);
123
+ }
124
+ }
125
+ if (Object.hasOwn(receipt, "exit") && !Number.isInteger(receipt.exit)) problems.push("exit is not an exit status");
126
+ if (Object.hasOwn(receipt, "diagnosticsPath") && !(text(receipt.diagnosticsPath) && path.isAbsolute(receipt.diagnosticsPath))) problems.push("diagnosticsPath is not an absolute path");
127
+ if (Object.hasOwn(receipt, "outputDir")) {
128
+ const dirs = receipt.outputDirs && typeof receipt.outputDirs === "object" ? Object.values(receipt.outputDirs) : [];
129
+ if (text(receipt.outputDir)) { if (!path.isAbsolute(receipt.outputDir)) problems.push("outputDir is not absolute"); }
130
+ else if (!(receipt.outputDir === null && new Set(dirs).size !== 1)) problems.push("outputDir is not the resolved absolute path (null only when the commit has no Cargo workspace, or several workspaces name several directories)");
131
+ for (const d of dirs) if (!(text(d) && path.isAbsolute(d))) problems.push(`outputDirs holds ${JSON.stringify(d)}, not an absolute path`);
132
+ }
133
+ if (Object.hasOwn(receipt, "refreshed") && !Array.isArray(receipt.refreshed)) problems.push("refreshed is not a list");
134
+ return problems;
135
+ }
package/door-policy.mjs CHANGED
@@ -17,7 +17,25 @@ const WRAPPERS = new Set(["env", "command", "exec", "time", "nice", "nohup", "su
17
17
  // Shell reserved words and grouping that can precede a command in the same simple command (`if true; then cargo build; fi`,
18
18
  // `{ cargo build; }`, `f() { cargo build; }`, `! cargo test`): they are skipped, never taken for the command.
19
19
  const KEYWORDS = new Set(["if", "then", "elif", "else", "fi", "do", "done", "while", "until", "{", "}", "!", "function"]);
20
- const DOOR_TOOLS = new Set(["cargo", "tauri", "right-release"]);
20
+ // Adrian, 2026-10-26: every native and frontend build tool an app can be built with, not only cargo. xcodebuild's actions, swift's
21
+ // compiling subcommands, Gradle tasks, the frontend bundlers, and a package script whose name says it builds.
22
+ const XCODE_ACTIONS = new Set(["build", "archive", "test", "build-for-testing", "test-without-building", "analyze", "install", "installsrc", "clean"]);
23
+ const XCODE_READONLY = new Set(["-version", "-list", "-showsdks", "-showBuildSettings", "-showdestinations", "-showTestPlans", "-help", "-usage", "-checkFirstLaunchStatus", "-exportLocalizations"]);
24
+ const SWIFT_COMPILES = new Set(["build", "test", "run"]);
25
+ const GRADLE_READONLY = new Set(["--version", "-v", "-version", "--help", "-h", "-?", "help", "tasks", "dependencies", "projects", "properties", "--stop", "--status"]);
26
+ const FRONTEND_BUNDLERS = new Set(["webpack", "rollup", "esbuild", "tsup", "parcel"]);
27
+ // A package script whose name says it builds (`pnpm build`, `pnpm run build:mac`, `npm run tauri`): the apps have no scripts, and a
28
+ // script that builds would be a second door even if they did.
29
+ const SCRIPT_BUILDS = /^(build|bundle|package|dist|compile|release|tauri|android|ios|xcode|gradle|test|tests|e2e|qa)(:|$)/;
30
+ // Tools whose own options take a value, so the word after them is not the subcommand.
31
+ const XCRUN_VALUE_OPTIONS = new Set(["--sdk", "-sdk", "--toolchain", "-toolchain", "--log", "-f", "--find", "--run"]);
32
+ const SWIFT_VALUE_OPTIONS = new Set(["--package-path", "-c", "--configuration", "--build-path", "--scratch-path", "--cache-path", "--sdk", "--toolchain", "-Xswiftc", "-Xcc", "-Xlinker", "--triple", "--arch", "-j", "--jobs"]);
33
+ const GRADLE_VALUE_OPTIONS = new Set(["-p", "--project-dir", "-b", "--build-file", "-c", "--settings-file", "-g", "--gradle-user-home", "-I", "--init-script", "--max-workers", "--console", "-x", "--exclude-task", "--project-cache-dir"]);
34
+ const VITE_VALUE_OPTIONS = new Set(["--config", "-c", "--mode", "-m", "--base", "--logLevel", "-l", "--outDir", "--target", "--assetsDir", "--filter"]);
35
+ /** The non-option words of argv, skipping the value of each option named in `valued` (`--config x`, and `--config=x` as one word). */
36
+ const wordsOf = (argv, valued) => { const out = []; for (let i = 0; i < argv.length; i++) { const w = argv[i]; if (w.startsWith("-")) { if (valued.has(w)) i++; continue; } out.push(w); } return out; };
37
+ const firstWord = (argv, valued) => wordsOf(argv, valued)[0] ?? null;
38
+ const DOOR_TOOLS = new Set(["cargo", "tauri", "right-release", "xcodebuild", "swift", "swiftc", "gradle", "gradlew", "vite", ...FRONTEND_BUNDLERS]);
21
39
 
22
40
  /** Splits a shell command into simple commands (on ; && || | and newlines), each an argv of words. Quotes are respected. */
23
41
  export function simpleCommands(command) {
@@ -98,7 +116,8 @@ function argvViolation(argv, depth = 0, stdin = null) {
98
116
  let tool = base(a[0]);
99
117
  // A command string handed to another shell (`bash -c "cargo build"`, `eval "..."`) is judged as the command it is.
100
118
  if (depth < 4 && (SHELLS.has(tool.toLowerCase()) || tool === "eval")) {
101
- const flag = a.findIndex((w, i) => i > 0 && /^(-c|\/c|-Command|-command)$/.test(w));
119
+ // `-c`, and combined short flags that carry it (`-lc`, `-xec`); pwsh `-Command`; cmd `/c`.
120
+ const flag = a.findIndex((w, i) => i > 0 && /^(-[a-zA-Z]*c[a-zA-Z]*|\/c|-Command|-command)$/.test(w));
102
121
  const script = tool === "eval" ? a.slice(1).join(" ") : flag > 0 ? a.slice(flag + 1).join(" ") : null;
103
122
  return script ? doorViolation(script, depth + 1) : null;
104
123
  }
@@ -115,12 +134,47 @@ function argvViolation(argv, depth = 0, stdin = null) {
115
134
  if (RUNNERS.has(tool)) {
116
135
  let rest = a.slice(1).filter((w, i, all) => !(w.startsWith("-") || (i > 0 && ["--filter", "-F", "--dir", "-C", "--prefix"].includes(all[i - 1]))));
117
136
  if (["exec", "dlx", "x"].includes(rest[0])) rest = rest.slice(1);
118
- if (rest[0] === "run" || rest[0] === "run-script") return null; // the apps have no scripts; a script name is not a tool
137
+ const script = rest[0] === "run" || rest[0] === "run-script";
138
+ if (script) rest = rest.slice(1);
119
139
  if (!rest.length) return null;
120
- a = rest; tool = base(a[0]);
140
+ // A script whose name says it builds is denied as the runner's script (`pnpm build`, `npm run tauri`); any other script name is
141
+ // not a tool (the apps have none). Without `run`, the word is judged as the tool it names (`pnpm tauri build`, `pnpm exec vite build`).
142
+ if (!script && DOOR_TOOLS.has(base(rest[0]))) { a = rest; tool = base(a[0]); }
143
+ else if (SCRIPT_BUILDS.test(rest[0])) return `${tool} ${rest[0]}`;
144
+ else if (script) return null;
145
+ else { a = rest; tool = base(a[0]); }
121
146
  }
147
+ // `rightkit-gate clippy [<cargo clippy args>]` is the agent entry for Clippy (GATE-CONTRACT §1 "Clippy route & batching", item 8 row
148
+ // 8.14): like every `rightkit-gate` subcommand it is the door, taken through the pinned gate (build-control-policy agentDoorViolation
149
+ // denies an unpinned copy as untrusted-gate) and run in the same order for a human and an agent. Direct `cargo clippy` and
150
+ // `cargo +<toolchain> clippy` stay denied by CARGO_COMPILES above, with the route named in the denial.
122
151
  if (tool === "rightkit-gate" || tool === "rustright") return null;
123
152
  if (tool === "tauri" && TAURI_BUILDS.has(a[1])) return `tauri ${a[1]}`;
153
+ // Apple: xcrun wraps the tool it names; xcodebuild builds by default (no action word is `build`); swift build|test|run and swiftc compile.
154
+ if (tool === "xcrun" && a.length > 1) {
155
+ // xcrun's own options come first (`--sdk iphoneos`, `--toolchain x`, `-f`); the tool it runs is the first word after them.
156
+ let rest = a.slice(1);
157
+ while (rest.length && rest[0].startsWith("-")) rest = XCRUN_VALUE_OPTIONS.has(rest[0]) ? rest.slice(2) : rest.slice(1);
158
+ return rest.length ? argvViolation(rest, depth + 1, stdin) : null;
159
+ }
160
+ if (tool === "xcodebuild") {
161
+ const words = a.slice(1);
162
+ const action = words.find((w) => XCODE_ACTIONS.has(w));
163
+ if (action) return `xcodebuild ${action}`;
164
+ return words.some((w) => XCODE_READONLY.has(w)) ? null : "xcodebuild build";
165
+ }
166
+ if (tool === "swift") { const sub = firstWord(a.slice(1), SWIFT_VALUE_OPTIONS); if (sub && SWIFT_COMPILES.has(sub)) return `swift ${sub}`; }
167
+ if (tool === "swiftc") return "swiftc";
168
+ // Android: a Gradle task that is not one of the read-only reports.
169
+ if (tool === "gradle" || tool === "gradlew") {
170
+ // Every task named counts (`gradle tasks assembleRelease` builds); a bare call prints help.
171
+ const tasks = wordsOf(a.slice(1), GRADLE_VALUE_OPTIONS);
172
+ const building = tasks.find((t) => !GRADLE_READONLY.has(t));
173
+ return building ? `gradle ${building}` : null;
174
+ }
175
+ // Frontend: a production bundle is the compile stage's frontend build.
176
+ if (tool === "vite" && wordsOf(a.slice(1), VITE_VALUE_OPTIONS).includes("build")) return "vite build";
177
+ if (FRONTEND_BUNDLERS.has(tool)) return tool;
124
178
  if (tool === "cargo") {
125
179
  let sub = null;
126
180
  for (let i = 1; i < a.length; i++) {
@@ -212,10 +266,21 @@ export function doorViolation(command, depth = 0) {
212
266
  return null;
213
267
  }
214
268
 
269
+ /** The agent entry for Clippy; a direct `cargo clippy` is denied in its favour. */
270
+ export const CLIPPY_ROUTE = "rightkit-gate clippy [<cargo clippy args>]";
271
+
215
272
  export function doorDenial(violation) {
273
+ if (violation === "cargo clippy") {
274
+ return `[HOOK:one-door:block]\nWhy: \`${violation}\` builds outside the one door. Every check, compile, test, package and release goes through rightkit-gate, class A first.\nRequired: ${CLIPPY_ROUTE} (Clippy on HEAD's commit, class A first).`;
275
+ }
276
+ const mobile = /^(cargo )?tauri (android|ios)$|^xcodebuild (archive|install)|^gradle (assemble|bundle|build|publish|install)/.test(violation) ? (/ios|xcodebuild/.test(violation) ? "ios" : "android") : null;
216
277
  const stage = /^right-release (dev|test)$/.test(violation) ? violation.slice("right-release ".length) : /^(cargo )?tauri dev$/.test(violation) ? "dev"
217
- : /^right-release (upload|publish|publish-update|github)$/.test(violation) ? "release" : /^right-release qa$/.test(violation) ? "qualify" : /^right-release /.test(violation) || /^(cargo )?tauri (build|bundle|android|ios)$/.test(violation) ? "package" : "compile";
218
- return `[HOOK:one-door:block]\nWhy: \`${violation}\` builds outside the one door. Every check, compile, test, package and release goes through rightkit-gate, class A first.\nRequired: rightkit-gate --to ${stage}${stage === "package" || stage === "qualify" || stage === "release" ? " --platform mac|win" : ""}${stage === "dev" || stage === "test" ? " -- <arguments>" : ""}${stage === "release" ? " --tier patch|update" : ""} (on a machine with the RustRight broker, \`rustright cargo ...\` is also admitted: the broker runs class A first).`;
278
+ : /^right-release (upload|publish|publish-update|github)$/.test(violation) ? "release" : /^right-release qa$/.test(violation) ? "qualify"
279
+ : /^right-release /.test(violation) || /^(cargo )?tauri (build|bundle|android|ios)$/.test(violation) || /^xcodebuild (archive|install)/.test(violation) || /^gradle /.test(violation) ? "package"
280
+ : "compile";
281
+ const platform = mobile ?? "mac|win";
282
+ const tail = stage === "package" || stage === "qualify" ? ` --platform ${platform}` : stage === "release" ? ` --platform ${platform}${mobile ? "" : " --tier patch|update"}` : stage === "dev" || stage === "test" ? " -- <arguments>" : "";
283
+ return `[HOOK:one-door:block]\nWhy: \`${violation}\` builds outside the one door. Every check, compile, test, package and release goes through rightkit-gate, class A first.\nRequired: rightkit-gate --to ${stage}${tail} (or rustright cargo ... on a broker host, which runs class A first).`;
219
284
  }
220
285
 
221
286
  /** Claude Code PreToolUse decision for one hook input. */
package/door.mjs CHANGED
@@ -2,16 +2,16 @@
2
2
  // packager) that right-release runs inside a stage. The step proceeds only when a live `rightkit-gate --to` run for HEAD launched it,
3
3
  // the same check right-release makes for itself (gate-stages.mjs requireDoorRun), so `node scripts/release/<step>.mjs` run by hand is
4
4
  // refused instead of becoming a side door around class A.
5
- import path from "node:path";
6
5
  import { spawnSync } from "./background-process.mjs";
7
6
  import { requireDoorRun } from "./gate-stages.mjs";
7
+ import { doorContextFor } from "./source-split.mjs";
8
8
 
9
- /** null when a live door run for HEAD launched this process, else the refusal message. */
9
+ /** null when a live door run for HEAD (or, inside a source split, for the split's commit) launched this process, else the refusal message. */
10
10
  export function doorRefusal({ cwd = process.cwd(), env = process.env, command = "this build step", run = spawnSync, alive } = {}) {
11
- const git = (args) => { const r = run("git", args, { cwd, encoding: "utf8" }); return r.status === 0 ? String(r.stdout).trim() : ""; };
12
- const common = git(["rev-parse", "--git-common-dir"]);
13
- if (!common) return `${command} runs only in a git checkout, through the one door: rightkit-gate --to <stage>`;
14
- return requireDoorRun({ env, commonDir: path.resolve(cwd, common), commit: git(["rev-parse", "HEAD"]), command, ...(alive ? { alive } : {}) });
11
+ let context;
12
+ try { context = doorContextFor({ cwd, env, run }); } catch (error) { return `${command}: ${error.code ?? "RK_SOURCE_SPLIT"}: ${error.message}`; }
13
+ if (!context) return `${command} runs only in a git checkout, through the one door: rightkit-gate --to <stage>`;
14
+ return requireDoorRun({ env, commonDir: context.commonDir, commit: context.commit, command, ...(alive ? { alive } : {}) });
15
15
  }
16
16
 
17
17
  /** Exits 2 with the refusal unless a live door run launched this process. */