@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.
- package/README.md +74 -10
- package/addon-command.mjs +3 -4
- package/adoption-evidence.mjs +650 -0
- package/android-play-upload.mjs +2 -1
- package/apple-profile-create.mjs +113 -10
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/binary-license-notices.mjs +161 -0
- package/build-release.mjs +138 -47
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +21 -2
- package/codesign-environment.mjs +2 -2
- package/content-digest.mjs +209 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/github-updater-release.mjs +70 -7
- package/in-place-install.mjs +17 -14
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +21 -40
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +222 -53
- package/managed-cargo.mjs +8 -0
- package/mirror-root-artifact.mjs +5 -1
- package/native-cargo-layout.mjs +47 -4
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +3 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.json +117 -67
- package/ownership-mandatory-set.mjs +141 -21
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +49 -7
- package/package-recipe-targets.mjs +41 -1
- package/package.json +1 -1
- package/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/preflight.mjs +14 -7
- package/production-mode.mjs +42 -0
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +117 -25
- package/publish-swift.mjs +12 -3
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +8 -0
- package/release-config.mjs +330 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +88 -21
- package/release.mjs +82 -20
- package/rightkit-ownership.json +1 -1
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +428 -194
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +248 -47
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +207 -40
- package/swap-helper-bundle.mjs +7 -3
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
- package/bin/darwin/rightkit-swap.sign.json +0 -6
package/release-invocation.mjs
CHANGED
|
@@ -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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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"]
|
|
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": [
|
|
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
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
{
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{
|
|
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
|
}
|
package/release-switch-scan.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
+
}
|