@rightkit/release 0.3.0 → 0.4.1

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 (83) hide show
  1. package/README.md +74 -10
  2. package/addon-command.mjs +3 -4
  3. package/adoption-evidence.mjs +650 -0
  4. package/android-play-upload.mjs +2 -1
  5. package/apple-profile-create.mjs +113 -10
  6. package/apple-profile-renew.mjs +11 -3
  7. package/artifact-storage.mjs +35 -2
  8. package/bin/darwin/rightkit-swap +0 -0
  9. package/binary-license-notices.mjs +161 -0
  10. package/build-release.mjs +138 -47
  11. package/cargo-license-notices.mjs +111 -0
  12. package/cli/right-release.mjs +21 -2
  13. package/codesign-environment.mjs +2 -2
  14. package/content-digest.mjs +209 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/github-updater-release.mjs +70 -7
  21. package/in-place-install.mjs +17 -14
  22. package/install-app-windows.mjs +24 -7
  23. package/install-app.mjs +21 -40
  24. package/ios-asc-credentials.mjs +163 -0
  25. package/ios-build-context.mjs +129 -0
  26. package/ios-build-upload.mjs +282 -0
  27. package/ios-credentials-command.mjs +70 -0
  28. package/ios-dev.mjs +248 -0
  29. package/ios-host.mjs +76 -0
  30. package/ios-lock.mjs +75 -0
  31. package/ios-package-recipe.mjs +707 -0
  32. package/ios-testflight.mjs +266 -0
  33. package/ios-upload.mjs +313 -0
  34. package/local-publish.mjs +222 -53
  35. package/managed-cargo.mjs +8 -0
  36. package/mirror-root-artifact.mjs +5 -1
  37. package/native-cargo-layout.mjs +47 -4
  38. package/ownership-command.mjs +2 -1
  39. package/ownership-dependency-allowlist.json +3 -1
  40. package/ownership-evidence.mjs +30 -18
  41. package/ownership-key-command.mjs +69 -2
  42. package/ownership-mandatory-set.json +117 -67
  43. package/ownership-mandatory-set.mjs +141 -21
  44. package/ownership-repositories.json +1 -1
  45. package/ownership-scope.mjs +94 -0
  46. package/ownership.mjs +49 -7
  47. package/package-recipe-targets.mjs +41 -1
  48. package/package.json +1 -1
  49. package/pipeline-admission.mjs +375 -0
  50. package/pipeline-config.mjs +100 -0
  51. package/pipeline-generators.json +4 -0
  52. package/pipeline-generators.mjs +137 -0
  53. package/pipeline-scan.mjs +285 -0
  54. package/pipeline-tools.mjs +171 -0
  55. package/preflight.mjs +14 -7
  56. package/production-mode.mjs +42 -0
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +94 -32
  61. package/publish-cargo.mjs +117 -25
  62. package/publish-swift.mjs +12 -3
  63. package/publish-update.mjs +15 -2
  64. package/qa-build-features.mjs +16 -13
  65. package/qa-contract.mjs +30 -2
  66. package/qa-control-scan.mjs +8 -0
  67. package/release-config.mjs +330 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +88 -21
  71. package/release.mjs +82 -20
  72. package/rightkit-ownership.json +1 -1
  73. package/rightkit-sync.mjs +8 -0
  74. package/rightkit-versions.json +428 -194
  75. package/runtime-budget-crates.mjs +14 -0
  76. package/runtime-budgets.mjs +248 -47
  77. package/sign-updater.mjs +56 -59
  78. package/source-gate.mjs +8 -1
  79. package/stage-sidecar.mjs +207 -40
  80. package/swap-helper-bundle.mjs +7 -3
  81. package/target-distribution.mjs +5 -0
  82. package/upload-release.mjs +7 -2
  83. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -2,7 +2,7 @@ import { spawnSync } from "./background-process.mjs";
2
2
  import { resolveProjectType } from "./project-type.mjs";
3
3
  import { existsSync, readFileSync, realpathSync } from "node:fs";
4
4
  import path from "node:path";
5
- import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
5
+ import { declaredCargoManifest, hasDefaultTauriManifest, resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
6
6
 
7
7
  function git(cwd, args) {
8
8
  const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
@@ -129,20 +129,128 @@ export function resolveConfiguredBuildInputs(config, target, label = "release co
129
129
  return configured;
130
130
  }
131
131
 
132
+ /** The validated or raw iOS package spec of a target (`targets.ios.iosPackage`, or the recipe the build expands it to). */
133
+ export function iosPackageSpecOf(target) {
134
+ if (target?.recipe?.kind === "iosPackage") return target.recipe.spec ?? {};
135
+ if (target?.iosPackage && typeof target.iosPackage === "object") return target.iosPackage;
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Native iOS mode: an iOS target with iosPackage and no src-tauri/Cargo.toml (a declared nativeAssembly.cargoManifest does not
141
+ * leave it). The app is an xcodegen project built by xcodebuild, not a Tauri host: no broker target, no target bridge, no
142
+ * Tauri layout. A declared manifest only pins its Cargo.toml and Cargo.lock as inputs; iosPackage.prepare runs the cargo build.
143
+ * An iOS app that has src-tauri/Cargo.toml (Pulse) keeps the Cargo-backed behaviour exactly.
144
+ */
145
+ export function isNativeIosTarget({ appRoot, target, exists } = {}) {
146
+ return iosPackageSpecOf(target) !== null && !hasDefaultTauriManifest({ appRoot, exists });
147
+ }
148
+
149
+ /**
150
+ * prepare commands exist to build the app's Rust static library. Without a declared nativeAssembly.cargoManifest the release
151
+ * would hash and seal none of that Rust source graph, so a native iOS target may not carry them.
152
+ */
153
+ export function assertNativeIosPrepareHasManifest(iosSpec, config, appRoot) {
154
+ if (declaredCargoManifest(config)) {
155
+ const problems = nativeIosPrepareProblems({ appRoot, config, prepare: iosSpec?.prepare ?? [] });
156
+ if (problems.length) throw new Error(`iosPackage.prepare must be only bare cargo commands when a Rust manifest is declared; express the Rust build as cargo commands in prepare (no node, scripts, pnpm, python, rustup run, +toolchain or other manifest-path), so the features it enables can be checked and the release pins exactly the manifest it builds:\n- ${problems.join("\n- ")}`);
157
+ return;
158
+ }
159
+ if (Array.isArray(iosSpec?.prepare) && iosSpec.prepare.length > 0) {
160
+ throw new Error("targets.ios.iosPackage.prepare builds a Rust library but no Cargo manifest is declared; declare nativeAssembly.cargoManifest (and keep its Cargo.lock beside it, or name it with nativeAssembly.cargoLockPath) so the Rust manifest and lock are pinned as required release inputs");
161
+ }
162
+ }
163
+
164
+ const FEATURE_FLAGS = new Set(["--features", "-F", "--feature"]);
165
+ // The only cargo subcommands a native iOS prepare may run: each takes --features and builds exactly one manifest. xtask, aliases
166
+ // (which .cargo/config.toml can point anywhere) and cargo-<custom> subcommands are opaque to the feature scan.
167
+ const PREPARE_SUBCOMMANDS = new Set(["build", "rustc", "check"]);
168
+
169
+ /**
170
+ * With a declared Rust manifest, every prepare command is a bare `cargo`: nothing else can be scanned for the features it enables
171
+ * or be trusted to build only the manifest the release pins. Returns the reasons a command is refused (empty when all are fine).
172
+ */
173
+ export function nativeIosPrepareProblems({ appRoot, config, prepare = [] }) {
174
+ const declared = declaredCargoManifest(config);
175
+ if (!declared) return [];
176
+ const manifest = path.resolve(appRoot, declared);
177
+ const problems = [];
178
+ prepare.forEach((command, index) => {
179
+ const where = `targets.ios.iosPackage.prepare[${index}] (${command?.cmd})`;
180
+ if (command?.cmd !== "cargo") { problems.push(`${where} is not a bare cargo command`); return; }
181
+ const args = (command.args ?? []).map(String);
182
+ if (args[0]?.startsWith("+")) problems.push(`${where} selects a toolchain with ${args[0]}`);
183
+ else if (!PREPARE_SUBCOMMANDS.has(args[0])) problems.push(`${where} runs cargo ${args[0] ?? "(no subcommand)"}; only cargo build, rustc and check are allowed (no xtask, alias or cargo-<custom> subcommand)`);
184
+ const cwd = path.resolve(appRoot, command.cwd ?? ".");
185
+ let manifestPathSeen = false;
186
+ args.forEach((arg, position) => {
187
+ if (arg === "--config" || arg.startsWith("--config=")) problems.push(`${where} passes ${arg.split("=")[0]}, which can change what cargo builds`);
188
+ if (arg === "-Z" || /^-Z.+/.test(arg)) problems.push(`${where} passes the unstable flag ${arg.split("=")[0]}`);
189
+ if (arg === "--target-dir" || arg.startsWith("--target-dir=")) problems.push(`${where} passes --target-dir; build output goes where the release expects it`);
190
+ const pathValue = arg === "--manifest-path" ? args[position + 1] : arg.startsWith("--manifest-path=") ? arg.slice("--manifest-path=".length) : undefined;
191
+ if (pathValue !== undefined) manifestPathSeen = true;
192
+ if (pathValue !== undefined && path.relative(path.resolve(cwd, pathValue), manifest) !== "") problems.push(`${where} builds ${pathValue}, not the declared manifest ${declared}`);
193
+ const featureValue = FEATURE_FLAGS.has(arg) ? args[position + 1] : arg.startsWith("--features=") ? arg.slice("--features=".length) : /^-F.+/.test(arg) ? arg.slice(2) : undefined;
194
+ if (featureValue !== undefined && /[$%`]/.test(featureValue)) problems.push(`${where} selects features from a variable (${arg}); the Cargo features it enables cannot be determined`);
195
+ });
196
+ // No cwd fallback: cargo would otherwise build whatever Cargo.toml the working directory holds.
197
+ if (!manifestPathSeen) problems.push(`${where} has no --manifest-path; pass --manifest-path ${declared} so it builds exactly the declared manifest`);
198
+ // The lock is hashed before prepare runs; without --locked cargo may rewrite it and build different dependencies.
199
+ if (!args.some((arg) => arg === "--locked" || arg === "--frozen")) problems.push(`${where} has no --locked; pass --locked so cargo builds exactly the pinned Cargo.lock`);
200
+ });
201
+ return problems;
202
+ }
203
+
204
+ /** The repo-relative-to-app Package.resolved an iosPackage with trusted Swift macros/plugins pins, or null when it trusts none. */
205
+ export function iosTrustPackageResolved(iosSpec) {
206
+ const listed = (value) => Array.isArray(value) && value.length > 0;
207
+ if (!listed(iosSpec?.trustedSwiftMacros) && !listed(iosSpec?.trustedSwiftPlugins)) return null;
208
+ if (typeof iosSpec.packageResolved === "string" && iosSpec.packageResolved) return iosSpec.packageResolved;
209
+ if (typeof iosSpec.project !== "string" || !iosSpec.project) return null;
210
+ return path.posix.join(iosSpec.project.replaceAll("\\", "/"), "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved");
211
+ }
212
+
213
+ const outside = (root, file) => { const relative = path.relative(root, file); return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); };
214
+
132
215
  export function resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target, env = process.env }) {
133
216
  const swift = resolveProjectType({ appRoot, config, target }) === "swiftpm";
134
- const nativeLayout = resolveNativeCargoLayout({
135
- appRoot,
136
- config,
137
- requireManifest: !swift,
138
- requireLock: !swift,
139
- });
217
+ const iosSpec = iosPackageSpecOf(target);
218
+ const nativeIos = !swift && isNativeIosTarget({ appRoot, target });
219
+ if (nativeIos) assertNativeIosPrepareHasManifest(iosSpec, config, appRoot);
220
+ // Native iOS: a declared manifest is resolved only to pin its Cargo.toml and lock (refused when there is no lock, or when
221
+ // either lies outside the repository). The returned layout is null, so no consumer links a broker target or bridges a cargo target.
222
+ const declaredManifest = nativeIos ? declaredCargoManifest(config) : undefined;
223
+ if (declaredManifest && outside(repoRoot, path.resolve(appRoot, declaredManifest))) throw new Error(`declared Cargo manifest ${declaredManifest} is outside the repository ${repoRoot}; a release pins only sources it can seal`);
224
+ const pinnedLayout = nativeIos
225
+ ? (declaredManifest ? resolveNativeCargoLayout({ appRoot, config, requireManifest: true, requireLock: true, repoRoot }) : null)
226
+ : resolveNativeCargoLayout({
227
+ appRoot,
228
+ config,
229
+ requireManifest: !swift,
230
+ requireLock: !swift,
231
+ });
232
+ if (nativeIos && pinnedLayout && outside(repoRoot, pinnedLayout.lockPath)) throw new Error(`Cargo.lock ${pinnedLayout.lockPath} is outside the repository ${repoRoot}; a release pins only sources it can seal`);
233
+ const nativeLayout = nativeIos ? null : pinnedLayout;
234
+ const optionalIfPresent = (names) => names.map((f) => path.join(appRoot, f)).filter((f) => existsSync(f));
235
+ // The xcodegen spec is an input whenever iosPackage names one (a declared-but-missing spec is refused below).
236
+ // Only native iOS mode: an iOS app with src-tauri keeps exactly its pre-existing input set.
237
+ const iosProjectSpec = nativeIos && typeof iosSpec?.projectSpec === "string" && iosSpec.projectSpec ? [path.resolve(appRoot, iosSpec.projectSpec)] : [];
238
+ // Reviewed Swift macro/plugin trust (iosPackage.trustedSwiftMacros / trustedSwiftPlugins): the Package.resolved holding the pins is a
239
+ // required, hashed, git-tracked release input on both iOS paths (native and src-tauri). Without a list nothing is added.
240
+ const packageResolvedInput = iosTrustPackageResolved(iosSpec);
241
+ if (packageResolvedInput && outside(repoRoot, path.resolve(appRoot, packageResolvedInput))) throw new Error(`Package.resolved ${packageResolvedInput} is outside the repository ${repoRoot}; a release pins only sources it can seal`);
242
+ const iosPackageResolved = packageResolvedInput ? [path.resolve(appRoot, packageResolvedInput)] : [];
140
243
  const requiredInputs = [
141
244
  configPath,
142
245
  ...(swift
143
246
  // SwiftPM: Package.swift is required; Package.resolved and the Node manifests only when they exist.
144
- ? [path.join(appRoot, "Package.swift"), ...["Package.resolved", "package.json", "pnpm-lock.yaml"].map((f) => path.join(appRoot, f)).filter((f) => existsSync(f))]
247
+ ? [path.join(appRoot, "Package.swift"), ...optionalIfPresent(["Package.resolved", "package.json", "pnpm-lock.yaml"])]
248
+ : nativeIos
249
+ // Native iOS: package.json and pnpm-lock.yaml only when the app has them, plus the declared Cargo manifest and its lock.
250
+ ? [...optionalIfPresent(["package.json", "pnpm-lock.yaml"]), ...(pinnedLayout ? [pinnedLayout.manifestPath, pinnedLayout.lockPath] : [])]
145
251
  : [path.join(appRoot, "package.json"), path.join(appRoot, "pnpm-lock.yaml"), nativeLayout.manifestPath, nativeLayout.lockPath]),
252
+ ...iosProjectSpec,
253
+ ...iosPackageResolved,
146
254
  ...(target?.preflight?.files ?? []).map((file) => path.resolve(appRoot, expandEnv(file, env))),
147
255
  ].filter((file, index, files) => files.indexOf(file) === index);
148
256
  for (const file of requiredInputs) {
@@ -162,6 +270,8 @@ export function resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, confi
162
270
  return {
163
271
  requiredInputs,
164
272
  nativeLayout,
273
+ // The Cargo.lock a native iOS app pins (its layout is null by design); preflight checks it like any other lock.
274
+ cargoLockPaths: nativeIos && pinnedLayout ? [pinnedLayout.lockPath] : [],
165
275
  buildInputs: {
166
276
  include: configured.include.map(qualify),
167
277
  exclude: (configured.exclude ?? []).map(qualify),
@@ -1,33 +1,132 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "owner": "rightkit-daemon::mode (D4, ratified)",
4
- "forbiddenFeatures": ["test-mode", "qa-native", "dev-native", "qa-control", "dev-inspect"],
4
+ "forbiddenFeatures": [
5
+ "test-mode",
6
+ "qa-native",
7
+ "dev-native",
8
+ "qa-control",
9
+ "dev-inspect",
10
+ "testing",
11
+ "test-seams",
12
+ "isolated-test-keychain",
13
+ "demo",
14
+ "dev-overrides",
15
+ "qa-probes",
16
+ "dev-worker-override",
17
+ "qa-shot",
18
+ "qa-tools",
19
+ "dev-control",
20
+ "bench-native",
21
+ "rightkit-qa/control"
22
+ ],
5
23
  "testModeMarker": "RIGHTKIT-TEST-MODE-COMPILED",
6
24
  "denyNames": [
7
- { "name": "RIGHTKIT_QA_NATIVE", "class": "S1" },
8
- { "name": "RIGHTKIT_QA_HIDDEN", "class": "S1" },
9
- { "name": "RIGHTKIT_QA_DATA_DIR", "class": "S4" },
10
- { "name": "RIGHTKIT_QA_BACKGROUND", "class": "S1" },
11
- { "name": "RIGHTKIT_UNDER_TEST", "class": "S1" },
12
- { "name": "RIGHTKIT_SUITE_ROOT", "class": "S4" },
13
- { "name": "RIGHT_SUITE_DIR", "class": "S4" },
14
- { "name": "ORTHIC_SHARED_DIR", "class": "S4" },
15
- { "name": "RIGHTKIT_CONTROL_SERVICE", "class": "S3" },
16
- { "name": "HEARDRIGHT_ENGINE_TEST_MODE", "class": "S1" },
17
- { "name": "HR_APP_DATA_DIR", "class": "S4" },
18
- { "name": "MAILRIGHT_DATA_DIR", "class": "S4" },
19
- { "name": "MAILRIGHT_EMBED_MODEL_DIR", "class": "S3" },
20
- { "name": "MAILRIGHT_ONNXRUNTIME_DYLIB", "class": "S3" },
21
- { "name": "CUTRIGHT_DATA_DIR", "class": "S4" },
22
- { "name": "GENRIGHT_QA", "class": "S1" },
23
- { "name": "GENRIGHT_APP_DATA", "class": "S4" },
24
- { "name": "GENRIGHT_SD_SERVER", "class": "S3" },
25
- { "name": "CODERIGHT_HOME", "class": "S4" },
26
- { "name": "POSTRIGHT_ROLE=daemon-dev", "class": "S1" },
27
- { "name": "RIGHTAPP_ENGINE_BIN", "class": "S3" },
28
- { "name": "LIGHTCRAFT_SAM3_DIR", "class": "S3" },
29
- { "name": "LIGHTCRAFT_DEVICE_ROOTS", "class": "S3" },
30
- { "name": "--qa", "class": "S1" },
31
- { "name": "--demo", "class": "S1" }
32
- ]
25
+ {
26
+ "name": "RIGHTKIT_QA_NATIVE",
27
+ "class": "S1"
28
+ },
29
+ {
30
+ "name": "RIGHTKIT_QA_HIDDEN",
31
+ "class": "S1"
32
+ },
33
+ {
34
+ "name": "RIGHTKIT_QA_DATA_DIR",
35
+ "class": "S4"
36
+ },
37
+ {
38
+ "name": "RIGHTKIT_QA_BACKGROUND",
39
+ "class": "S1"
40
+ },
41
+ {
42
+ "name": "RIGHTKIT_UNDER_TEST",
43
+ "class": "S1"
44
+ },
45
+ {
46
+ "name": "RIGHTKIT_SUITE_ROOT",
47
+ "class": "S4"
48
+ },
49
+ {
50
+ "name": "RIGHT_SUITE_DIR",
51
+ "class": "S4"
52
+ },
53
+ {
54
+ "name": "ORTHIC_SHARED_DIR",
55
+ "class": "S4"
56
+ },
57
+ {
58
+ "name": "RIGHTKIT_CONTROL_SERVICE",
59
+ "class": "S3"
60
+ },
61
+ {
62
+ "name": "HEARDRIGHT_ENGINE_TEST_MODE",
63
+ "class": "S1"
64
+ },
65
+ {
66
+ "name": "HR_APP_DATA_DIR",
67
+ "class": "S4"
68
+ },
69
+ {
70
+ "name": "MAILRIGHT_DATA_DIR",
71
+ "class": "S4"
72
+ },
73
+ {
74
+ "name": "MAILRIGHT_EMBED_MODEL_DIR",
75
+ "class": "S3"
76
+ },
77
+ {
78
+ "name": "MAILRIGHT_ONNXRUNTIME_DYLIB",
79
+ "class": "S3"
80
+ },
81
+ {
82
+ "name": "CUTRIGHT_DATA_DIR",
83
+ "class": "S4"
84
+ },
85
+ {
86
+ "name": "GENRIGHT_QA",
87
+ "class": "S1"
88
+ },
89
+ {
90
+ "name": "GENRIGHT_APP_DATA",
91
+ "class": "S4"
92
+ },
93
+ {
94
+ "name": "GENRIGHT_SD_SERVER",
95
+ "class": "S3"
96
+ },
97
+ {
98
+ "name": "CODERIGHT_HOME",
99
+ "class": "S4"
100
+ },
101
+ {
102
+ "name": "POSTRIGHT_ROLE=daemon-dev",
103
+ "class": "S1"
104
+ },
105
+ {
106
+ "name": "RIGHTAPP_ENGINE_BIN",
107
+ "class": "S3"
108
+ },
109
+ {
110
+ "name": "LIGHTCRAFT_SAM3_DIR",
111
+ "class": "S3"
112
+ },
113
+ {
114
+ "name": "LIGHTCRAFT_DEVICE_ROOTS",
115
+ "class": "S3"
116
+ },
117
+ {
118
+ "name": "--qa",
119
+ "class": "S1"
120
+ },
121
+ {
122
+ "name": "--demo",
123
+ "class": "S1"
124
+ }
125
+ ],
126
+ "conditionalForbiddenFeatures": {
127
+ "content-only": [
128
+ "devtools",
129
+ "content-devtools"
130
+ ]
131
+ }
33
132
  }
@@ -36,7 +36,7 @@
36
36
  // "channel": "env", // env | argv | settings | preference | frontend
37
37
  // "anchor": "src/test_mode.rs:97", // where it is read (informational)
38
38
  // "compiledOut": "feature:test-mode", // required for S1-S4: feature:<name> | cfg:test |
39
- // // cfg:debug_assertions (read exists only there)
39
+ // // cfg:debug_assertions | swift:DEBUG (read exists only there)
40
40
  // "valueType": "bool", // required for S6: bool | int | enum
41
41
  // "userGesture": true, // required for S5: chosen by a user gesture
42
42
  // "note": "free text" } ],
@@ -54,7 +54,8 @@ const CHUNK = 4 * 1024 * 1024;
54
54
  const FORBIDDEN_CLASSES = new Set(["S1", "S2", "S3", "S4"]);
55
55
  const CLASSES = new Set(["S1", "S2", "S3", "S4", "S5", "S6"]);
56
56
  const CHANNELS = new Set(["env", "argv", "settings", "preference", "frontend"]);
57
- const COMPILED_OUT = /^(feature:[A-Za-z0-9_-]+|cfg:test|cfg:debug_assertions)$/;
57
+ // `swift:DEBUG`: the read sits inside `#if DEBUG`, which an Xcode Release configuration compiles out.
58
+ const COMPILED_OUT = /^(feature:[A-Za-z0-9_-]+|cfg:test|cfg:debug_assertions|swift:DEBUG)$/;
58
59
 
59
60
  export const REGISTRY = JSON.parse(readFileSync(new URL("./release-switch-registry.json", import.meta.url), "utf8"));
60
61
 
@@ -64,12 +65,13 @@ export const REGISTRY = JSON.parse(readFileSync(new URL("./release-switch-regist
64
65
  // `cargo tree -e features` lines look like: `├── rightkit-daemon feature "test-mode"` (possibly `(*)` suffixed).
65
66
  const FEATURE_LINE = /^[\s│├└─|`+\\-]*([A-Za-z0-9_][A-Za-z0-9_-]*)(?: v[^\s]+)? feature "([^"]+)"/;
66
67
 
67
- export function featureClosureFindings(treeText, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
68
+ export function featureClosureFindings(treeText, { forbidden = [...REGISTRY.forbiddenFeatures, ...REGISTRY.conditionalForbiddenFeatures["content-only"]], allowDevtools = false } = {}) {
68
69
  const deny = new Set(forbidden);
69
70
  const findings = []; const seen = new Set();
70
71
  for (const line of String(treeText).split(/\r?\n/)) {
71
72
  const m = FEATURE_LINE.exec(line);
72
- if (!m || !deny.has(m[2])) continue;
73
+ if (!m || (!deny.has(m[2]) && !deny.has(`${m[1]}/${m[2]}`))) continue;
74
+ if (allowDevtools && REGISTRY.conditionalForbiddenFeatures["content-only"].includes(m[2])) continue;
73
75
  const id = `forbidden-feature ${m[2]}`;
74
76
  const key = `${m[1]}/${m[2]}`;
75
77
  if (seen.has(key)) continue;
@@ -82,22 +84,60 @@ export function featureClosureFindings(treeText, { forbidden = REGISTRY.forbidde
82
84
  // M7 lane E: rightkit-daemon `remote` (the HTTPS host with tokio, axum and hyper) may enter a release graph only for an app
83
85
  // that declares `releaseSwitches.remoteHost: true` (the Hetzner Pulse and PostRight server builds). `testing` implies `remote`,
84
86
  // so it is caught by the same line. Fail-closed: a value other than a boolean throws, and `false` or an absent key refuses.
87
+ // PK17-2: a `remote` that appears ONLY beneath the daemon `remote-loopback` feature (the loopback feature enables the shared
88
+ // router and auth internals) is not a host declaration; that case is judged by `remoteLoopbackFindings`, not here. Any other
89
+ // path to `remote` (a direct request, `testing`, another crate's feature) is still refused exactly as before.
85
90
  export const REMOTE_SERVICE_CRATE = "rightkit-daemon";
86
91
  export const REMOTE_SERVICE_FEATURE = "remote";
92
+ export const REMOTE_LOOPBACK_FEATURE = "remote-loopback";
93
+
94
+ // Feature lines of a `cargo tree -e features` text with their parent. Default `--prefix indent` output indents each level by
95
+ // four columns (`├── `, `│ `, ` `; `--charset ascii` keeps the width), so depth is the prefix width / 4 and the parent is
96
+ // the nearest earlier line one level up. A flat tree (`--prefix none`) has depth 0 everywhere, so no feature has a parent and
97
+ // every parent-dependent rule treats it as the direct (stricter) case: this fails closed.
98
+ const TREE_PREFIX = /^[\s│├└─|`+\\-]*/;
99
+ function featureTreeEntries(treeText) {
100
+ const entries = []; const stack = [];
101
+ for (const line of String(treeText).split(/\r?\n/)) {
102
+ if (!line.trim()) continue;
103
+ const depth = Math.floor(TREE_PREFIX.exec(line)[0].length / 4);
104
+ const m = FEATURE_LINE.exec(line);
105
+ const entry = { crate: m ? m[1] : null, feature: m ? m[2] : null, parent: depth > 0 ? (stack[depth - 1] ?? null) : null };
106
+ stack[depth] = entry; stack.length = depth + 1;
107
+ if (m) entries.push(entry);
108
+ }
109
+ return entries;
110
+ }
111
+ const impliedByLoopback = (entry) => entry.parent?.crate === REMOTE_SERVICE_CRATE && entry.parent?.feature === REMOTE_LOOPBACK_FEATURE;
112
+
87
113
  export function remoteServiceFindings(treeText, { remoteHost = false } = {}) {
88
114
  if (typeof remoteHost !== "boolean") throw new Error(`releaseSwitches.remoteHost must be true or false, got ${JSON.stringify(remoteHost)}`);
89
115
  if (remoteHost) return [];
90
116
  const findings = []; const seen = new Set();
91
- for (const line of String(treeText).split(/\r?\n/)) {
92
- const m = FEATURE_LINE.exec(line);
93
- if (!m || m[1] !== REMOTE_SERVICE_CRATE || m[2] !== REMOTE_SERVICE_FEATURE || seen.has(m[1])) continue;
94
- seen.add(m[1]);
117
+ for (const entry of featureTreeEntries(treeText)) {
118
+ if (entry.crate !== REMOTE_SERVICE_CRATE || entry.feature !== REMOTE_SERVICE_FEATURE || seen.has(entry.crate) || impliedByLoopback(entry)) continue;
119
+ seen.add(entry.crate);
95
120
  const id = "remote-feature-undeclared";
96
121
  findings.push({ layer: "feature-closure", rule: id, file: "<cargo tree>", reason: `${id}: crate \`${REMOTE_SERVICE_CRATE}\` has feature \`${REMOTE_SERVICE_FEATURE}\` enabled in the resolved graph, but the app does not declare releaseSwitches.remoteHost: true (only Pulse and PostRight server builds may enable it)` });
97
122
  }
98
123
  return findings;
99
124
  }
100
125
 
126
+ // PK17-2 / PK06-5: the daemon loopback feature (CodeRight's loopback HTTP + bearer host, loopback-only bind) is its own release
127
+ // declaration, `releaseSwitches.remoteLoopback: true`, distinct from `remoteHost`. Neither declaration implies the other: a server
128
+ // build with `remoteHost: true` still needs `remoteLoopback: true` to ship the loopback feature, and a loopback app that also
129
+ // enables `remote` on its own still needs `remoteHost: true`. Same fail-closed boolean rule as `remoteHost`.
130
+ export function remoteLoopbackFindings(treeText, { remoteLoopback = false } = {}) {
131
+ if (typeof remoteLoopback !== "boolean") throw new Error(`releaseSwitches.remoteLoopback must be true or false, got ${JSON.stringify(remoteLoopback)}`);
132
+ if (remoteLoopback) return [];
133
+ for (const entry of featureTreeEntries(treeText)) {
134
+ if (entry.crate !== REMOTE_SERVICE_CRATE || entry.feature !== REMOTE_LOOPBACK_FEATURE) continue;
135
+ const id = "remote-loopback-undeclared";
136
+ return [{ layer: "feature-closure", rule: id, file: "<cargo tree>", reason: `${id}: crate \`${REMOTE_SERVICE_CRATE}\` has feature \`${REMOTE_LOOPBACK_FEATURE}\` enabled in the resolved graph, but the app does not declare releaseSwitches.remoteLoopback: true (only a headless engine build that serves its own loopback API may enable it; releaseSwitches.remoteHost does not cover it)` }];
137
+ }
138
+ return [];
139
+ }
140
+
101
141
  // A build command (shell string or { cmd, args }) as one flat token list, whole arguments plus their shell re-tokenisation.
102
142
  export function commandTokens(command) {
103
143
  const parts = typeof command === "string" ? [command] : [command?.cmd, ...(command?.args ?? [])].filter((v) => v !== undefined).map(String);
@@ -191,7 +231,7 @@ export function inventoryDenyNames(inventoryText) {
191
231
  }
192
232
 
193
233
  // Findings for a build command (`--features test-mode`, `-F`, `--all-features`, ...). Uses the existing tokenizer.
194
- export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
234
+ export function commandSwitchFindings(argv, { forbidden = [...REGISTRY.forbiddenFeatures, ...REGISTRY.conditionalForbiddenFeatures["content-only"]], allowDevtools = false } = {}) {
195
235
  const deny = new Set(forbidden);
196
236
  const tokens = argv;
197
237
  const enabled = []; let all = false;
@@ -206,7 +246,8 @@ export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeat
206
246
  const findings = [];
207
247
  for (const item of enabled) {
208
248
  const name = item.includes("/") ? item.slice(item.lastIndexOf("/") + 1) : item;
209
- if (deny.has(name)) findings.push({ layer: "feature-closure", rule: `forbidden-feature ${name}`, file: "<command>", reason: `forbidden-feature ${name}: build command enables \`${item}\`` });
249
+ if (allowDevtools && REGISTRY.conditionalForbiddenFeatures["content-only"].includes(name)) continue;
250
+ if (deny.has(name) || deny.has(item.replace("?/", "/"))) findings.push({ layer: "feature-closure", rule: `forbidden-feature ${name}`, file: "<command>", reason: `forbidden-feature ${name}: build command enables \`${item}\`` });
210
251
  }
211
252
  if (all) findings.push({ layer: "feature-closure", rule: "forbidden-feature all-features", file: "<command>", reason: "forbidden-feature all-features: build command enables every feature, including test-mode" });
212
253
  return findings;
@@ -218,7 +259,7 @@ export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeat
218
259
  export function parseInventory(text, label = "switches.json") {
219
260
  const issues = [];
220
261
  let doc;
221
- try { doc = JSON.parse(text); } catch (error) { return { doc: null, issues: [{ rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${label} is not valid JSON (${error.message})` }] }; }
262
+ try { doc = JSON.parse(text); } catch (error) { return { doc: null, issues: [{ layer: "inventory", rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${label} is not valid JSON (${error.message})` }] }; }
222
263
  const bad = (reason) => issues.push({ layer: "inventory", rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${reason}` });
223
264
  if (doc?.schema !== 1) bad("`schema` must be 1");
224
265
  if (typeof doc?.app !== "string" || !doc.app) bad("`app` must be a non-empty string");
@@ -233,7 +274,7 @@ export function parseInventory(text, label = "switches.json") {
233
274
  if (!CHANNELS.has(entry.channel)) bad(`${where}: \`channel\` must be one of ${[...CHANNELS].join(", ")}`);
234
275
  if (FORBIDDEN_CLASSES.has(entry.class)) {
235
276
  if (typeof entry.compiledOut !== "string" || !COMPILED_OUT.test(entry.compiledOut)) {
236
- issues.push({ layer: "inventory", rule: "not-compiled-out", file: label, reason: `not-compiled-out: ${where} is class ${entry.class} and must declare \`compiledOut\` as feature:<name>, cfg:test or cfg:debug_assertions (release builds may not contain it)` });
277
+ issues.push({ layer: "inventory", rule: "not-compiled-out", file: label, reason: `not-compiled-out: ${where} is class ${entry.class} and must declare \`compiledOut\` as feature:<name>, cfg:test, cfg:debug_assertions or swift:DEBUG (release builds may not contain it)` });
237
278
  }
238
279
  } else if (entry.class === "S6") {
239
280
  if (!["bool", "int", "enum"].includes(entry.valueType)) bad(`${where}: class S6 needs a bounded \`valueType\` (bool, int or enum)`);
@@ -245,13 +286,20 @@ export function parseInventory(text, label = "switches.json") {
245
286
  }
246
287
 
247
288
  const SOURCE_EXT = new Set([".rs", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx", ".vue", ".svelte", ".swift", ".c", ".cc", ".cpp", ".h", ".m", ".mm"]);
248
- const SKIP_DIRS = new Set(["node_modules", "target", "dist", "build", ".git", "tests", "__tests__", "benches", "examples", "fixtures", "test", "coverage", ".vite"]);
289
+ // Build output and fetched dependencies, never app source: the release recipe's own .right-release/ (Xcode DerivedData, whose
290
+ // SourcePackages/checkouts holds whole dependency repositories), SwiftPM's .build and .swiftpm, and Xcode DerivedData anywhere.
291
+ const SKIP_DIRS = new Set(["node_modules", "target", "dist", "build", ".git", "tests", "__tests__", "benches", "examples", "fixtures", "test", "coverage", ".vite", ".right-release", "deriveddata", "sourcepackages", ".build", ".swiftpm"]);
292
+ // Swift/Xcode test bundles are capitalised (`Tests`, `UITests`, `<App>Tests`, `<App>UITests`): they are test code too.
293
+ const skipDir = (name) => SKIP_DIRS.has(name.toLowerCase()) || /(?:^|[a-z0-9])(?:UI)?Tests$/.test(name);
249
294
  const TEST_FILE = /(\.test\.|\.spec\.|_tests?\.rs$|^tests?\.rs$)/;
250
295
 
296
+ const CARGO_COMPILE_TIME = /\b(?:option_)?env!\(\s*"([^"]+)"/g;
297
+ // Variables Cargo sets for every build script and crate compilation (the Cargo book, "Environment variables Cargo sets for crates").
298
+ const CARGO_SET = /^(CARGO|CARGO_(PKG_[A-Z_]+|MANIFEST_DIR|MANIFEST_PATH|CRATE_NAME|BIN_NAME|BIN_EXE_[A-Za-z0-9_-]+|PRIMARY_PACKAGE|TARGET_TMPDIR|RUSTC_CURRENT_DIR)|OUT_DIR)$/;
251
299
  // Reads of a literal name; group 1 is the name.
252
300
  const LITERAL_READS = [
253
301
  /\b(?:std::)?env::var(?:_os)?\(\s*"([^"]+)"\s*\)/g,
254
- /\b(?:option_)?env!\(\s*"([^"]+)"/g,
302
+ CARGO_COMPILE_TIME,
255
303
  /\bgetenv\(\s*"([^"]+)"\s*\)/g,
256
304
  /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
257
305
  /process\.env\[\s*["']([^"']+)["']\s*\]/g,
@@ -287,7 +335,7 @@ function* walkSource(root) {
287
335
  for (const entry of entries) {
288
336
  const full = path.join(dir, entry.name);
289
337
  if (entry.isSymbolicLink()) continue;
290
- if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) stack.push(full); continue; }
338
+ if (entry.isDirectory()) { if (!skipDir(entry.name)) stack.push(full); continue; }
291
339
  if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name)) && !TEST_FILE.test(entry.name)) yield full;
292
340
  }
293
341
  }
@@ -296,7 +344,7 @@ function* walkSource(root) {
296
344
  function lineOf(text, index) { let n = 1; for (let i = 0; i < index; i += 1) if (text.charCodeAt(i) === 10) n += 1; return n; }
297
345
 
298
346
  // inventoryText: contents of switches.json. Returns findings only.
299
- export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
347
+ export function sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
300
348
  const { doc, issues } = parseInventory(inventoryText, inventoryLabel);
301
349
  const findings = [...issues];
302
350
  if (!doc || !Array.isArray(doc.switches)) return findings;
@@ -304,7 +352,9 @@ export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel
304
352
  const dynamicAllowed = new Set((doc.dynamicReads ?? []).map((d) => path.normalize(d.file)));
305
353
  const reads = new Set();
306
354
  const denyFlags = [...REGISTRY.denyNames, ...extraDeny.map((n) => ({ name: typeof n === "string" ? n : n.name }))].map((d) => d.name).filter((n) => n.startsWith("-"));
307
- for (const file of walkSource(sourceRoot)) {
355
+ // `sourceRoots` (absolute directories) narrows the walk to what a target compiles; paths stay relative to `sourceRoot`.
356
+ const walkRoots = Array.isArray(sourceRoots) && sourceRoots.length ? sourceRoots : [sourceRoot];
357
+ for (const file of walkRoots.flatMap((walkRoot) => [...walkSource(walkRoot)])) {
308
358
  let text;
309
359
  try { text = readFileSync(file, "utf8"); } catch { continue; }
310
360
  const rel = path.relative(sourceRoot, file);
@@ -314,6 +364,9 @@ export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel
314
364
  for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
315
365
  const name = m[1];
316
366
  if (JS_BUILTINS.has(name) && /\.(ts|tsx|js|mjs|cjs|jsx|vue|svelte)$/.test(file)) continue;
367
+ // `env!`/`option_env!` of a name Cargo itself sets at compile time (CARGO_PKG_VERSION, CARGO_MANIFEST_DIR, ...) is
368
+ // not a switch: no caller can choose it. Any other compile-time name is still a read the builder could set.
369
+ if (pattern === CARGO_COMPILE_TIME && CARGO_SET.test(name)) continue;
317
370
  reads.add(name);
318
371
  if (!registered.has(name)) findings.push({ layer: "inventory", rule: "unregistered-read", file: `${rel}:${lineOf(text, m.index)}`, reason: `unregistered-read: \`${name}\` is read here but is not listed in ${inventoryLabel}` });
319
372
  }
@@ -430,18 +483,19 @@ export function scanSwitchPaths(paths, { denyMap = denyNamesFor() } = {}) {
430
483
  // Every layer is optional input; a layer given no input is skipped, except that `requireBinary` fails a scan that
431
484
  // found no native binary. `bypass: ["feature-closure"]` exists only for the negative fixtures that prove the binary
432
485
  // layer independently; release.mjs never passes it.
433
- export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost } = {}) {
486
+ export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, remoteLoopback, devtools } = {}) {
434
487
  const findings = [];
435
488
  const bypassed = new Set(bypass);
436
489
  const denyMap = denyNamesFor({ appForbid: [...appForbid, ...(inventoryText != null ? inventoryDenyNames(inventoryText) : [])] });
437
490
  if (!bypassed.has("feature-closure")) {
438
491
  if (treeText != null) {
439
- findings.push(...featureClosureFindings(treeText));
492
+ findings.push(...featureClosureFindings(treeText, { allowDevtools: devtools?.mode === "content-only" }));
440
493
  findings.push(...remoteServiceFindings(treeText, { remoteHost }));
494
+ findings.push(...remoteLoopbackFindings(treeText, { remoteLoopback }));
441
495
  }
442
- for (const argv of commands) findings.push(...commandSwitchFindings(argv));
496
+ for (const argv of commands) findings.push(...commandSwitchFindings(argv, { allowDevtools: devtools?.mode === "content-only" }));
443
497
  }
444
- if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, inventoryText, extraDeny: appForbid }));
498
+ if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, extraDeny: appForbid }));
445
499
  let receipt = { files: [], binaries: 0 };
446
500
  if (binaryPaths.length) {
447
501
  const scanned = scanSwitchPaths(binaryPaths, { denyMap });
@@ -460,3 +514,16 @@ export function assertNoReleaseSwitches(input, label = "release") {
460
514
  + "Build release and update tiers without the `test-mode` feature, and register every environment read in the app's switches.json.",
461
515
  );
462
516
  }
517
+
518
+ /** Local (path) packages in a `cargo tree` text under `root`: the directories of crates a target actually compiles. */
519
+ export function localCrateDirsFromTree(treeText, root) {
520
+ const dirs = new Set();
521
+ const base = path.resolve(root);
522
+ // `name v1 (/abs/path)` and `name v1 (proc-macro) (/abs/path)`; a bare `(*)` or `(proc-macro)` is a marker, not a path.
523
+ for (const m of String(treeText ?? "").matchAll(/ v\S+(?: \(proc-macro\))? \(([^)]+)\)/g)) {
524
+ if (!path.isAbsolute(m[1])) continue;
525
+ const dir = path.resolve(m[1]);
526
+ if (dir === base || dir.startsWith(base + path.sep)) dirs.add(dir);
527
+ }
528
+ return [...dirs].sort();
529
+ }