@rightkit/release 0.6.5 → 0.6.6

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 (42) hide show
  1. package/README.md +79 -7
  2. package/android-play-upload.mjs +4 -4
  3. package/app-check.mjs +474 -17
  4. package/apple-profile-create.mjs +4 -3
  5. package/apple-profile-renew.mjs +5 -4
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +431 -44
  8. package/binary-license-notices.mjs +2 -1
  9. package/build-release.mjs +7 -6
  10. package/cache-command.mjs +4 -3
  11. package/cargo-license-notices.mjs +2 -1
  12. package/cargo-manifest-text.mjs +25 -0
  13. package/cargo-profile-contract.mjs +26 -13
  14. package/cli/right-release.mjs +8 -1
  15. package/command-spec.mjs +4 -5
  16. package/deps.mjs +5 -4
  17. package/deterministic-scans.mjs +280 -32
  18. package/development.mjs +5 -5
  19. package/github-release.mjs +5 -3
  20. package/github-upload-gate.mjs +6 -6
  21. package/init-command.mjs +134 -8
  22. package/ios-upload.mjs +4 -4
  23. package/mirror-root-artifact.mjs +3 -4
  24. package/npm-license-notices.mjs +2 -1
  25. package/outdated-command.mjs +91 -0
  26. package/package.json +37 -1
  27. package/pipeline-admission.mjs +12 -0
  28. package/publish-cargo.mjs +215 -3
  29. package/publish-update.mjs +5 -3
  30. package/release-config.mjs +36 -5
  31. package/release-dependencies-command.mjs +4 -3
  32. package/release-qa-audit.mjs +12 -10
  33. package/release-qa-command.mjs +42 -7
  34. package/release-qa-hardening.mjs +10 -3
  35. package/release-qa-policy.mjs +214 -47
  36. package/release.mjs +4 -4
  37. package/rightkit-registry-resolver.mjs +5 -2
  38. package/rightkit-versions.json +11 -8
  39. package/stage-sidecar-command.mjs +5 -5
  40. package/test-command.mjs +3 -2
  41. package/upload-release.mjs +3 -2
  42. package/web-build.mjs +2 -1
package/app-check.mjs CHANGED
@@ -16,13 +16,16 @@
16
16
  // qa-platform G13 a Rust `"platform:<x>"` requirement that is not darwin or windows, the words rightkit-qa emits (it skips forever)
17
17
  // test-harness G14 a Cargo.toml [[test]] whose file defines `fn main(` but lacks `harness = false` (libtest ignores it: 0 tests)
18
18
  // scenario-skip G15 a `.scenario(` test that prints SKIP and returns: a skip that reports green
19
- // release-config G16 right-release.config.mjs: a provider publish-update.mjs refuses, an updater keyFile, an undefined Cargo profile
19
+ // release-config G16 right-release.config.json (or a legacy .mjs): a provider publish-update.mjs refuses, an updater keyFile, an
20
+ // undefined Cargo profile, JSON right-release cannot load
20
21
  // skip-checks G17 a package.json script that runs right-release with --skip-checks
21
22
  // qa-dev-server G18 a package.json qa* script that runs `tauri dev` (QA would test the dev server, not the app)
22
23
  // ffmpeg-release-escape G19 `allow_in_release_builds` in non-test Rust outside crates/rightkit-ffmpeg
23
- // switches-inventory G20 a right-release.config.mjs directory without a switches.json (or its releaseSwitches.inventory)
24
+ // switches-inventory G20 a right-release.config.json (or .mjs) directory without a switches.json (or its releaseSwitches.inventory)
24
25
  // speech-link G21 `import Speech` anywhere under swift/RightKitSwift/Sources or Tests (Apple Speech is removed, rightkit#978;
25
26
  // the LinkProbes control fixture is the one sanctioned import)
27
+ // e2e-under-cargo-test G22 `cargo test|run|nextest` (also as `rustright cargo`, or the retired `rightkit`) that runs an end-to-end
28
+ // crate (rightkit-qa driver modules) in the broker without --no-run: it holds the compile slot for the whole run
26
29
  //
27
30
  // Ratchet: findings that exist today may be listed so they print as KNOWN and do not fail; an unlisted finding fails; a listed
28
31
  // identity (`${rule}|${file}|${subject}`, no line numbers) that is gone fails as STALE, so the list only shrinks. The baseline is
@@ -178,7 +181,7 @@ function fileUrls({ root, files }) {
178
181
  return out;
179
182
  }
180
183
 
181
- // ---- G13-G20: app defects found from files alone ----------------------------------------------------------------------------
184
+ // ---- G13-G22: app defects found from files alone ----------------------------------------------------------------------------
182
185
  // Each finding is { problem, fix, file, subject }; `${rule}|${file}|${subject}` (no line numbers, so an edit above it does not
183
186
  // change it) is its identity in ci/app-check-baseline.json.
184
187
  const SKIPPED_TREE = /(^|\/)(node_modules|vendor|target)\//;
@@ -187,7 +190,17 @@ const lineOf = (text, offset) => text.slice(0, offset).split("\n").length;
187
190
  /** `text` with every whole-line `//` comment blanked, offsets and line numbers kept (a URL inside a string is untouched). */
188
191
  const blankComments = (text) => text.replace(/^[ \t]*\/\/.*$/gm, (m) => " ".repeat(m.length));
189
192
  const rustFiles = (files) => files.filter((f) => f.endsWith(".rs") && scanned(f));
190
- const configFiles = (files) => files.filter((f) => /(^|\/)right-release\.config\.mjs$/.test(f) && scanned(f));
193
+ // The release config: the data config (right-release.config.json, M24) or a legacy module (right-release.config.mjs) not yet moved.
194
+ const configFiles = (files) => files.filter((f) => /(^|\/)right-release\.config\.(?:mjs|json)$/.test(f) && scanned(f));
195
+ const isJsonConfig = (file) => file.endsWith(".json");
196
+ /** Every `[key, value, parentKey]` of a parsed JSON value, depth first (the walk the JSON release config rules read). */
197
+ function* jsonEntries(node, parentKey = null) {
198
+ if (Array.isArray(node)) { for (const item of node) yield* jsonEntries(item, parentKey); return; }
199
+ if (!node || typeof node !== "object") return;
200
+ for (const [key, value] of Object.entries(node)) { yield [key, value, parentKey]; yield* jsonEntries(value, key); }
201
+ }
202
+ /** The offset of the first `"key"` in JSON text that is followed by `:` (a key, not a string value), else 0. */
203
+ const jsonKeyOffset = (text, key) => Math.max(0, text.search(new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`)));
191
204
  const dirOf = (file) => path.posix.dirname(file);
192
205
 
193
206
  // G13: rightkit-qa's util::platform() emits "darwin" or "windows"; a requirement spelled any other way never matches, so the
@@ -303,19 +316,33 @@ function releaseConfig({ root, files }) {
303
316
  for (const file of configFiles(files)) {
304
317
  const raw = readText(root, file);
305
318
  if (raw === null) continue;
306
- const text = blankComments(raw);
319
+ const text = isJsonConfig(file) ? raw : blankComments(raw);
307
320
  const add = (kind, value, offset, why, fix) => out.push({ file, subject: `${kind}:${value}`, problem: `${file}:${lineOf(text, offset)} ${why}`, fix });
308
- for (const m of text.matchAll(/\bprovider\s*:\s*(["'`])([^"'`]+)\1/g)) {
309
- if (!RELEASE_PROVIDERS.has(m[2])) add("provider", m[2], m.index, `distribution provider "${m[2]}" is refused by packages/release/publish-update.mjs (it takes r2-rightapps or github-releases)`, 'set provider: "r2-rightapps" (private) or "github-releases" (public)');
310
- }
311
- for (const m of text.matchAll(/\bupdater\s*:\s*\{/g)) {
312
- const block = braceBlock(text, m.index + m[0].length - 1);
313
- const key = /\bkeyFile\s*:\s*([^,\n}]+)/.exec(block);
314
- if (key) add("keyFile", key[1].trim().slice(0, 60), m.index + key.index, "updater keyFile: sign-updater.mjs refuses --key-file; updater keys come from the OS credential provider", "remove keyFile; the updater key is read from the credential store");
321
+ const badProvider = (provider, offset) => add("provider", provider, offset, `distribution provider "${provider}" is refused by packages/release/publish-update.mjs (it takes r2-rightapps or github-releases)`, 'set provider: "r2-rightapps" (private) or "github-releases" (public)');
322
+ const keyFile = (value, offset) => add("keyFile", value.slice(0, 60), offset, "updater keyFile: sign-updater.mjs refuses --key-file; updater keys come from the OS credential provider", "remove keyFile; the updater key is read from the credential store");
323
+ const profiles = [];
324
+ if (isJsonConfig(file)) {
325
+ let config;
326
+ try { config = JSON.parse(raw.replace(/^\uFEFF/, "")); } catch (error) {
327
+ add("json", "invalid", 0, `is not valid JSON (${error.message}): right-release refuses to load it`, "fix the JSON; the release config is strict JSON (no comments or trailing commas)");
328
+ continue;
329
+ }
330
+ for (const [key, value, parent] of jsonEntries(config)) {
331
+ if (key === "provider" && parent === "distribution" && typeof value === "string" && !RELEASE_PROVIDERS.has(value)) badProvider(value, text.indexOf(JSON.stringify(value)));
332
+ if (key === "keyFile" && parent === "updater") keyFile(JSON.stringify(value), jsonKeyOffset(text, "keyFile"));
333
+ if (key === "profile" && (parent === "winPackage" || parent === "macPackage") && typeof value === "string") profiles.push([value, text.indexOf(JSON.stringify(value))]);
334
+ }
335
+ } else {
336
+ for (const m of text.matchAll(/\bprovider\s*:\s*(["'`])([^"'`]+)\1/g)) if (!RELEASE_PROVIDERS.has(m[2])) badProvider(m[2], m.index);
337
+ for (const m of text.matchAll(/\bupdater\s*:\s*\{/g)) {
338
+ const block = braceBlock(text, m.index + m[0].length - 1);
339
+ const key = /\bkeyFile\s*:\s*([^,\n}]+)/.exec(block);
340
+ if (key) keyFile(key[1].trim(), m.index + key.index);
341
+ }
315
342
  }
316
- const profiles = [...text.matchAll(/(["'])--profile\1\s*,\s*(["'])([^"']+)\2/g)].map((m) => [m[3], m.index])
343
+ profiles.push(...[...text.matchAll(/(["'])--profile\1\s*,\s*(["'])([^"']+)\2/g)].map((m) => [m[3], m.index])
317
344
  .concat([...text.matchAll(/(["'])--profile=([^"']+)\1/g)].map((m) => [m[2], m.index]))
318
- .concat([...text.matchAll(/\bPROFILE\s*(?:\?\?|\|\|)\s*(["'])([^"']+)\1/g)].map((m) => [m[2], m.index]));
345
+ .concat([...text.matchAll(/\bPROFILE\s*(?:\?\?|\|\|)\s*(["'])([^"']+)\1/g)].map((m) => [m[2], m.index])));
319
346
  if (profiles.length) {
320
347
  const defined = profilesFor(root, files, dirOf(file));
321
348
  for (const [name, offset] of profiles) {
@@ -374,10 +401,20 @@ function switchesInventory({ root, files }) {
374
401
  for (const file of configFiles(files)) {
375
402
  const raw = readText(root, file);
376
403
  if (raw === null) continue;
377
- const text = blankComments(raw);
404
+ const text = isJsonConfig(file) ? raw : blankComments(raw);
378
405
  const dir = dirOf(file);
379
- const block = /\breleaseSwitches\s*:\s*\{/.exec(text);
380
- const named = block ? /\binventory\s*:\s*(["'`])([^"'`]+)\1/.exec(braceBlock(text, block.index + block[0].length - 1))?.[2] : undefined;
406
+ let block;
407
+ let named;
408
+ if (isJsonConfig(file)) {
409
+ let config;
410
+ try { config = JSON.parse(raw.replace(/^\uFEFF/, "")); } catch { config = null; }
411
+ const switches = config?.releaseSwitches;
412
+ if (switches && typeof switches === "object") block = { index: jsonKeyOffset(text, "releaseSwitches") };
413
+ if (typeof switches?.inventory === "string") named = switches.inventory;
414
+ } else {
415
+ block = /\breleaseSwitches\s*:\s*\{/.exec(text);
416
+ named = block ? /\binventory\s*:\s*(["'`])([^"'`]+)\1/.exec(braceBlock(text, block.index + block[0].length - 1))?.[2] : undefined;
417
+ }
381
418
  const inventory = path.posix.normalize(path.posix.join(dir, named ?? "switches.json"));
382
419
  if (fs.existsSync(path.join(root, inventory))) continue;
383
420
  out.push({ file, subject: dir, problem: `${file}:${block ? lineOf(text, block.index) : 1} has no switches inventory (${inventory} does not exist)`, fix: named ? `commit ${inventory} or point releaseSwitches.inventory at the app's inventory` : `add ${path.posix.join(dir, "switches.json")} listing the app's QA and dev switches (see packages/release/fixtures/release-switches/app/switches.json)` });
@@ -403,6 +440,424 @@ function speechLink({ root, files }) {
403
440
  return out;
404
441
  }
405
442
 
443
+ // G22: the broker compiles; it does not run end-to-end tests. `cargo test`, `cargo run` and `cargo nextest` (plain, through the
444
+ // RustRight client as `rustright cargo`, or as `rightkit` while the retired alias lasts) run the test binary or the program inside the
445
+ // broker job, which holds the compile slot for the whole run (Pulse, 2026-10-23: 55 minutes with 15 builds queued) and has no GUI
446
+ // session or unscrubbed environment a real-app test needs. The fix is `--no-run` to compile and `rightkit-qa run-tests` to run
447
+ // outside the broker (crates/rightkit-qa/docs/run-tests.md). Found from files alone, in three steps:
448
+ // 1. the end-to-end crates: a Cargo package that depends on rightkit-qa not optionally (the optional dependency is the in-app control
449
+ // server behind a qa-native / dev-control feature) and has a .rs file, outside examples/, that names a driver module of it;
450
+ // 2. the commands: `cargo test|run|nextest` in a package.json script, a shell, PowerShell, batch, Node or Python script, a Makefile
451
+ // or justfile recipe, a GitHub workflow `run:` step, a git hook, or a code span of a runbook document;
452
+ // 3. the selection: a command that is not `--no-run` or a rightkit-qa run-tests / journeys run, and names an end-to-end crate by
453
+ // -p / --package, --manifest-path, --workspace / --all (a member of the workspace in the working directory), --test or --bin.
454
+ // Known limits, deliberate for an offline check: no cargo binary (a path dependency that cargo adds to a workspace implicitly is not
455
+ // a member here), a command assembled from variables is matched on its literal text only, a package.json script that calls another
456
+ // script is judged where the cargo text is, a cargo alias (`cargo t`) is not matched, and a bare `cargo test` selects nothing.
457
+ const E2E_DRIVER_MODULES = new Set(["harness", "control", "suite", "scenario", "engine", "in_app", "interactive", "page", "browser", "android"]);
458
+ const E2E_GUARD_TARGET = "qa_surface_guard"; // the one test target of an end-to-end crate that only runs the static guard
459
+ const E2E_NAME_ONLY = /-e2e$|^qa-/; // a name is not evidence: crates that match only this are listed for a reviewer by e2eCrates()
460
+
461
+ const unquote = (text) => String(text).replace(/^(["'])(.*)\1$/, "$2");
462
+ const escapeRegExp = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
463
+ /** A dotted TOML key split at the dots outside quotes: `target.'cfg(unix)'.dependencies` is three parts. */
464
+ const tomlPath = (text) => [...String(text).matchAll(/"([^"]*)"|'([^']*)'|([^\s.'"]+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
465
+ const tomlStrings = (text) => [...String(text).matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
466
+ const uncomment = (line) => line.replace(/^\s*#.*$/, "").replace(/\s+#.*$/, "");
467
+
468
+ /**
469
+ * What the end-to-end check reads from a Cargo manifest, literal values only: the package name, whether it holds a [workspace] and its
470
+ * members and exclude, the dependencies of [dependencies], [dev-dependencies] and their [target.*] tables (inline, dotted-key and
471
+ * `[dependencies.name]` forms, each with optional, package and workspace), [workspace.dependencies] as key -> package, and the
472
+ * [[test]] and [[bin]] tables. Build dependencies are not dependencies of the tests.
473
+ */
474
+ function parseCargoManifest(text) {
475
+ const manifest = { name: null, workspace: false, members: [], exclude: [], dependencies: [], workspaceDependencies: new Map(), tests: [], bins: [] };
476
+ const entries = new Map();
477
+ const dependency = (table, key) => {
478
+ const id = `${table}\0${key}`;
479
+ if (!entries.has(id)) { const entry = { key, optional: false, package: undefined, workspace: false }; entries.set(id, entry); manifest.dependencies.push(entry); }
480
+ return entries.get(id);
481
+ };
482
+ const setAttribute = (entry, attribute, value) => {
483
+ if (attribute === "optional") entry.optional = entry.optional || value === "true";
484
+ else if (attribute === "package") entry.package = tomlStrings(value)[0] ?? entry.package;
485
+ else if (attribute === "workspace") entry.workspace = entry.workspace || value === "true";
486
+ };
487
+ const setInline = (entry, value) => {
488
+ if (/\boptional\s*=\s*true\b/.test(value)) entry.optional = true;
489
+ const renamed = /\bpackage\s*=\s*(?:"([^"]+)"|'([^']+)')/.exec(value);
490
+ if (renamed) entry.package = renamed[1] ?? renamed[2];
491
+ if (/\bworkspace\s*=\s*true\b/.test(value)) entry.workspace = true;
492
+ };
493
+ const lines = String(text ?? "").split("\n");
494
+ let section = { kind: "other" };
495
+ for (let i = 0; i < lines.length; i++) {
496
+ const line = uncomment(lines[i]);
497
+ const header = /^\s*(\[\[?)\s*(.+?)\s*\]\]?\s*$/.exec(line);
498
+ if (header) {
499
+ const parts = tomlPath(header[2]);
500
+ if (header[1] === "[[") {
501
+ const item = parts[0] === "test" || parts[0] === "bin" ? { name: undefined, path: undefined } : null;
502
+ if (item) manifest[parts[0] === "test" ? "tests" : "bins"].push(item);
503
+ section = item ? { kind: "target", item } : { kind: "other" };
504
+ continue;
505
+ }
506
+ const at = parts[0] === "dependencies" || parts[0] === "dev-dependencies" ? 0 : parts[0] === "target" && (parts[2] === "dependencies" || parts[2] === "dev-dependencies") ? 2 : -1;
507
+ if (parts[0] === "package" && parts.length === 1) section = { kind: "package" };
508
+ else if (parts[0] === "workspace") {
509
+ manifest.workspace = true;
510
+ section = parts.length === 1 ? { kind: "workspace" } : parts[1] === "dependencies" ? (parts.length === 2 ? { kind: "workspace-dependencies" } : { kind: "workspace-dependency", key: parts[2] }) : { kind: "other" };
511
+ } else if (at >= 0) section = parts.length === at + 1 ? { kind: "dependencies", table: parts.join(".") } : { kind: "dependency", table: parts.slice(0, at + 1).join("."), key: parts[at + 1] };
512
+ else section = { kind: "other" };
513
+ continue;
514
+ }
515
+ const kv = /^\s*("[^"]+"|'[^']+'|[A-Za-z0-9_-]+)(?:\s*\.\s*("[^"]+"|'[^']+'|[A-Za-z0-9_-]+))?\s*=\s*(.*?)\s*$/.exec(line);
516
+ if (!kv) continue;
517
+ const key = unquote(kv[1]);
518
+ const attribute = kv[2] === undefined ? undefined : unquote(kv[2]);
519
+ const value = kv[3];
520
+ switch (section.kind) {
521
+ case "package":
522
+ if (key === "name" && attribute === undefined) manifest.name = tomlStrings(value)[0] ?? null;
523
+ break;
524
+ case "workspace":
525
+ if ((key === "members" || key === "exclude") && attribute === undefined) {
526
+ let list = value;
527
+ if (list.startsWith("[")) while (!list.includes("]") && i + 1 < lines.length) list += ` ${uncomment(lines[++i])}`;
528
+ manifest[key].push(...tomlStrings(list));
529
+ }
530
+ break;
531
+ case "target":
532
+ if ((key === "name" || key === "path") && attribute === undefined) section.item[key] = tomlStrings(value)[0];
533
+ break;
534
+ case "dependencies":
535
+ if (attribute === undefined) setInline(dependency(section.table, key), value); else setAttribute(dependency(section.table, key), attribute, value);
536
+ break;
537
+ case "dependency":
538
+ if (attribute === undefined) setAttribute(dependency(section.table, section.key), key, value);
539
+ break;
540
+ case "workspace-dependencies":
541
+ if (attribute === undefined) manifest.workspaceDependencies.set(key, /\bpackage\s*=\s*(?:"([^"]+)"|'([^']+)')/.exec(value)?.slice(1).find(Boolean) ?? key);
542
+ else if (attribute === "package") manifest.workspaceDependencies.set(key, tomlStrings(value)[0] ?? key);
543
+ break;
544
+ case "workspace-dependency":
545
+ if (key === "package" && attribute === undefined) manifest.workspaceDependencies.set(section.key, tomlStrings(value)[0] ?? section.key);
546
+ else if (!manifest.workspaceDependencies.has(section.key)) manifest.workspaceDependencies.set(section.key, section.key);
547
+ break;
548
+ default:
549
+ }
550
+ }
551
+ return manifest;
552
+ }
553
+
554
+ /** A `[workspace] members` pattern (`crates/*`, `apps/**`, `./x`) as a regular expression over a posix path; a pattern that is no
555
+ * valid glob matches nothing (the rule must not throw on a manifest cargo would refuse). */
556
+ function memberPattern(glob) {
557
+ const source = String(glob).replace(/^\.\//, "").replace(/\/+$/, "").split("**").map((part) => part.replace(/[.+^$(){}|\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
558
+ try { return new RegExp(`^${source}$`); } catch { return /$^/; }
559
+ }
560
+
561
+ /** True when the Rust source names a driver module of rightkit-qa through any of `names` (its crate identifier(s) in this package):
562
+ * `rightkit_qa::harness`, or a `use rightkit_qa::{...}` list with a driver module among its top-level items. Comments do not count. */
563
+ function namesDriverModule(source, names) {
564
+ const text = source.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/\/\/[^\n]*/g, "");
565
+ for (const name of names) {
566
+ for (const m of text.matchAll(new RegExp(`(?<!\\w)${escapeRegExp(name)}\\s*::\\s*(\\{|[A-Za-z_]\\w*)`, "g"))) {
567
+ if (m[1] !== "{") { if (E2E_DRIVER_MODULES.has(m[1])) return true; continue; }
568
+ const list = braceBlock(text, m.index + m[0].length - 1).slice(1, -1);
569
+ let depth = 0, item = "";
570
+ const items = [];
571
+ for (const ch of list) {
572
+ if (ch === "{") depth += 1; else if (ch === "}") depth -= 1;
573
+ if (ch === "," && depth === 0) { items.push(item); item = ""; } else item += ch;
574
+ }
575
+ items.push(item);
576
+ if (items.some((it) => E2E_DRIVER_MODULES.has(/^\s*([A-Za-z_]\w*)/.exec(it)?.[1]))) return true;
577
+ }
578
+ }
579
+ return false;
580
+ }
581
+
582
+ /**
583
+ * The Cargo layout of a tree and the end-to-end crates in it. `crates` are the end-to-end ones as { name, dir, tests, bins } (a
584
+ * package that depends on rightkit-qa not optionally and has a non-example .rs file naming one of its driver modules); `nameOnly`
585
+ * lists packages named like one (`*-e2e`, `qa-*`) that are not, for a reviewer: a name is not evidence.
586
+ */
587
+ export function e2eCrates(root, files) {
588
+ const manifests = new Map();
589
+ for (const file of files.filter((f) => /(^|\/)Cargo\.toml$/.test(f) && scanned(f))) {
590
+ const text = readText(root, file);
591
+ if (text !== null) manifests.set(dirOf(file), parseCargoManifest(text));
592
+ }
593
+ /** The package directory a file belongs to: the nearest directory above it with a Cargo.toml, when that holds a [package]. */
594
+ const ownerOf = (file) => {
595
+ for (let dir = dirOf(file); ; dir = dirOf(dir)) {
596
+ const manifest = manifests.get(dir);
597
+ if (manifest) return manifest.name === null ? null : dir;
598
+ if (dir === ".") return null;
599
+ }
600
+ };
601
+ const packageOf = (dep, dir) => {
602
+ if (dep.package) return dep.package;
603
+ if (dep.workspace) {
604
+ for (let d = dir; ; d = dirOf(d)) {
605
+ const inherited = manifests.get(d)?.workspaceDependencies.get(dep.key);
606
+ if (inherited) return inherited;
607
+ if (d === ".") break;
608
+ }
609
+ }
610
+ return dep.key;
611
+ };
612
+ const identifiers = new Map(); // package dir -> how Rust code names rightkit-qa there (the key, with - as _)
613
+ for (const [dir, manifest] of manifests) {
614
+ if (manifest.name === null) continue;
615
+ const names = manifest.dependencies.filter((dep) => !dep.optional && packageOf(dep, dir) === "rightkit-qa").map((dep) => dep.key.replaceAll("-", "_"));
616
+ if (names.length) identifiers.set(dir, [...new Set(names)]);
617
+ }
618
+ const driving = new Set();
619
+ if (identifiers.size) {
620
+ for (const file of rustFiles(files)) {
621
+ const dir = ownerOf(file);
622
+ if (dir === null || !identifiers.has(dir) || driving.has(dir) || (dir === "." ? file : file.slice(dir.length + 1)).startsWith("examples/")) continue;
623
+ const text = readText(root, file);
624
+ if (text !== null && identifiers.get(dir).some((name) => text.includes(name)) && namesDriverModule(text, identifiers.get(dir))) driving.add(dir);
625
+ }
626
+ }
627
+ const crates = [...driving].sort().map((dir) => {
628
+ const manifest = manifests.get(dir);
629
+ const within = (file) => (dir === "." ? file : file.startsWith(`${dir}/`) ? file.slice(dir.length + 1) : null);
630
+ const tests = new Set(manifest.tests.map((t) => t.name).filter(Boolean));
631
+ const bins = new Set(manifest.bins.map((b) => b.name).filter(Boolean));
632
+ for (const file of files) {
633
+ const rel = within(file);
634
+ if (rel === null || !scanned(file) || ownerOf(file) !== dir) continue;
635
+ const test = /^tests\/([^/]+)(?:\.rs|\/main\.rs)$/.exec(rel);
636
+ if (test) tests.add(test[1]);
637
+ const bin = /^src\/bin\/([^/]+)(?:\.rs|\/main\.rs)$/.exec(rel);
638
+ if (bin) bins.add(bin[1]);
639
+ if (rel === "src/main.rs") bins.add(manifest.name);
640
+ }
641
+ return { name: manifest.name, dir, tests, bins };
642
+ });
643
+ const nameOnly = [...manifests].filter(([dir, m]) => m.name !== null && E2E_NAME_ONLY.test(m.name) && !driving.has(dir)).map(([, m]) => m.name).sort();
644
+ /** The end-to-end crates that are members of the workspace rooted at `wsDir`: its root package, and every crate that matches a
645
+ * `members` glob, is not under `exclude` and is not a workspace root itself (a nested [workspace] makes a crate its own root). */
646
+ const membersOf = (wsDir) => {
647
+ const ws = manifests.get(wsDir);
648
+ if (!ws?.workspace) return [];
649
+ const included = ws.members.map(memberPattern);
650
+ const excluded = ws.exclude.map((x) => String(x).replace(/^\.\//, "").replace(/\/+$/, ""));
651
+ return crates.filter((c) => {
652
+ if (c.dir === wsDir) return true;
653
+ const rel = wsDir === "." ? c.dir : c.dir.startsWith(`${wsDir}/`) ? c.dir.slice(wsDir.length + 1) : null;
654
+ return rel !== null && !manifests.get(c.dir).workspace && included.some((re) => re.test(rel)) && !excluded.some((x) => rel === x || rel.startsWith(`${x}/`));
655
+ });
656
+ };
657
+ return { crates, nameOnly, manifests, membersOf };
658
+ }
659
+
660
+ const CARGO_COMMAND = /(?<![\w-])((?:(?:rustright|rightkit)\s+)?cargo)\s+(?:\+\S+\s+)?(test|run|nextest)\b([^&;|`\n]*)/g;
661
+ const CD_COMMAND = /(?<![\w./-])(?:cd|Set-Location)\s+(?:-(?:Path|LiteralPath)\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/gi;
662
+ const COMMENT_LINE = /^\s*(?:#|\/\/|\/?\*|;|::|REM\b)/i;
663
+ const QA_RUNNER = /\brightkit-qa(?:\.exe)?\s+(?:run-tests|journeys\s+run)\b/;
664
+ const NEXTEST_NO_TESTS = new Set("list archive show-config self help".split(" ")); // nextest subcommands that run no test
665
+ const HISTORY_DOC = /^docs\/(?:archive|plans|provenance|handoff[^/]*)\/|(?:^|\/)(?:tasks|evidence)\/|(?:^|\/)(?:changelog|pending|status|state)\.md$/i;
666
+ const HOOK_FILE = /(?:^|\/)(?:\.githooks|\.husky|githooks)\/(?!_\/)[^/]+/;
667
+ const WORKFLOW_FILE = /^\.github\/(?:workflows\/[^/]+|actions\/.+\/action)\.ya?ml$/;
668
+ const BUILD_FILE = /(?:^|\/)(?:Makefile|makefile|GNUmakefile|[Jj]ustfile|\.justfile)$|\.mk$/;
669
+ const SCRIPT_FILE = /\.(?:sh|bash|zsh|ps1|psm1|bat|cmd|mjs|cjs|js|py)$/i;
670
+
671
+ /** A script's `cd` / `Set-Location` target applied to `cwd` (a posix path from the repository root). A target that is not a plain
672
+ * relative path (a variable, a substitution, an absolute path, one that leaves the repository) leaves the directory as it was: the
673
+ * `cd "$(dirname "$0")/.."` of a script under scripts/ is the repository root, where a script starts. */
674
+ function changeDirectory(cwd, target) {
675
+ const t = unquote(target);
676
+ if (!t || /[$`(%~]/.test(t) || /^([\\/]|[A-Za-z]:)/.test(t) || t === "-") return cwd;
677
+ const next = path.posix.normalize(path.posix.join(cwd, t.replaceAll("\\", "/")));
678
+ return next === ".." || next.startsWith("../") ? cwd : next;
679
+ }
680
+
681
+ /** The logical lines of physical `lines` ({ no, text }): comment lines dropped, and a line that ends with `continuation` joined to the
682
+ * next, numbered by the line the command starts on. */
683
+ function logicalLines(lines, continuation) {
684
+ const out = [];
685
+ let pending = null;
686
+ for (const line of lines) {
687
+ if (!pending && COMMENT_LINE.test(line.text)) continue;
688
+ const text = line.text.replace(/\s+$/, "");
689
+ const joined = pending ? `${pending.text} ${text.trim()}` : text;
690
+ const no = pending?.no ?? line.no;
691
+ if (continuation && text.endsWith(continuation)) { pending = { no, text: joined.slice(0, -continuation.length) }; continue; }
692
+ pending = null;
693
+ out.push({ no, text: joined });
694
+ }
695
+ if (pending) out.push(pending);
696
+ return out;
697
+ }
698
+
699
+ /** The `run:` steps of a GitHub workflow or composite action: { lines, cwd } where cwd is the step's working-directory (the
700
+ * repository root when it has none, or one that is an expression). */
701
+ function workflowSteps(text) {
702
+ const lines = text.split("\n");
703
+ const indent = (l) => l.length - l.trimStart().length;
704
+ const steps = [];
705
+ lines.forEach((raw, i) => {
706
+ const m = /^(\s*)(-\s+)?run:\s*(.*?)\s*$/.exec(raw);
707
+ if (!m) return;
708
+ const keyColumn = m[1].length + (m[2]?.length ?? 0);
709
+ const value = m[3];
710
+ let block;
711
+ if (/^[|>][+-]?\d*(?:\s+#.*)?$/.test(value)) {
712
+ block = [];
713
+ for (let j = i + 1; j < lines.length; j++) {
714
+ if (lines[j].trim() === "") continue;
715
+ if (indent(lines[j]) <= keyColumn) break;
716
+ block.push({ no: j + 1, text: lines[j].trim() });
717
+ }
718
+ // A folded scalar (`>`) is one line to the shell.
719
+ if (value.startsWith(">") && block.length) block = [{ no: block[0].no, text: block.map((l) => l.text).join(" ") }];
720
+ } else {
721
+ const plain = /^(["'])(.*)\1$/.test(value) ? unquote(value) : value.replace(/\s+#.*$/, "");
722
+ block = plain ? [{ no: i + 1, text: plain }] : [];
723
+ }
724
+ // The step's own working-directory sits beside `run:` (same column), on either side of it.
725
+ const sibling = (j) => { const k = /^(\s*)(-\s+)?working-directory:\s*(.*?)\s*$/.exec(lines[j]); return k && k[1].length + (k[2]?.length ?? 0) === keyColumn ? unquote(k[3].replace(/\s+#.*$/, "")) : null; };
726
+ let directory = null;
727
+ for (let j = i - 1; j >= 0 && directory === null; j--) {
728
+ if (lines[j].trim() === "") continue;
729
+ directory = sibling(j);
730
+ if (indent(lines[j]) < keyColumn) break;
731
+ }
732
+ for (let j = i + 1; j < lines.length && directory === null; j++) {
733
+ if (lines[j].trim() === "") continue;
734
+ if (indent(lines[j]) < keyColumn) break;
735
+ directory = sibling(j);
736
+ }
737
+ steps.push({ lines: block, cwd: directory === null ? "." : changeDirectory(".", directory) });
738
+ });
739
+ return steps;
740
+ }
741
+
742
+ /** The command-bearing units of a runbook document: each fenced code block (its lines) and each inline code span (one line). Prose
743
+ * outside code is never a command. */
744
+ function markdownUnits(text) {
745
+ const units = [];
746
+ let fence = null;
747
+ text.split("\n").forEach((raw, i) => {
748
+ if (fence) {
749
+ const close = /^\s*(`{3,}|~{3,})\s*$/.exec(raw);
750
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) { units.push(fence.lines); fence = null; } else fence.lines.push({ no: i + 1, text: raw });
751
+ return;
752
+ }
753
+ const open = /^\s*(`{3,}|~{3,})/.exec(raw);
754
+ if (open) { fence = { char: open[1][0], length: open[1].length, lines: [] }; return; }
755
+ for (const span of raw.matchAll(/(`+)(.+?)\1(?!`)/g)) units.push([{ no: i + 1, text: span[2].trim() }]);
756
+ });
757
+ if (fence) units.push(fence.lines);
758
+ return units;
759
+ }
760
+
761
+ // G22: see the block comment above.
762
+ function e2eUnderCargoTest({ root, files }) {
763
+ const layout = e2eCrates(root, files);
764
+ if (!layout.crates.length) return [];
765
+ const out = [];
766
+ const seen = new Set();
767
+ const flagValues = (tokens, ...names) => tokens.flatMap((t, i) => names.flatMap((name) => (t === name ? (i + 1 < tokens.length ? [tokens[i + 1]] : []) : name.startsWith("--") && t.startsWith(`${name}=`) ? [t.slice(name.length + 1)] : [])));
768
+ /** The directory of the manifest a --manifest-path names (relative to the cwd, then to the repository root), when it holds one. */
769
+ const manifestDirectory = (value, cwd) => {
770
+ const base = value.replaceAll("\\", "/").replace(/(^|\/)Cargo\.toml$/, "") || ".";
771
+ if (path.posix.isAbsolute(base) || /^[A-Za-z]:/.test(base)) return null;
772
+ for (const from of [cwd, "."]) {
773
+ const dir = path.posix.normalize(path.posix.join(from, base));
774
+ if (dir !== ".." && !dir.startsWith("../") && layout.manifests.has(dir)) return dir;
775
+ }
776
+ return null;
777
+ };
778
+ /** The end-to-end crates a cargo command selects, each with the flag that did it: [{ crate, selector }], first rule first. */
779
+ const selected = (tokens, isRun, cwd) => {
780
+ const picks = new Map();
781
+ const pick = (crate, selector) => { if (!picks.has(crate)) picks.set(crate, selector); };
782
+ const packages = flagValues(tokens, "--package", "-p");
783
+ const manifestPaths = flagValues(tokens, "--manifest-path");
784
+ const tests = flagValues(tokens, "--test");
785
+ const wholeWorkspace = tokens.includes("--workspace") || tokens.includes("--all");
786
+ const excluded = new Set(flagValues(tokens, "--exclude"));
787
+ const guardOnly = tests.length > 0 && tests.every((t) => t === E2E_GUARD_TARGET); // `--test qa_surface_guard` runs the static guard only
788
+ const members = (dir) => layout.membersOf(dir).filter((c) => !excluded.has(c.name));
789
+ if (!guardOnly) {
790
+ for (const p of packages) for (const c of layout.crates.filter((crate) => crate.name === p)) pick(c, `-p ${p}`);
791
+ for (const mp of manifestPaths) {
792
+ const dir = manifestDirectory(mp, cwd);
793
+ if (dir === null) continue;
794
+ for (const c of layout.crates.filter((crate) => crate.dir === dir)) pick(c, `--manifest-path ${mp}`);
795
+ if (wholeWorkspace) for (const c of members(dir)) pick(c, `--manifest-path ${mp} ${tokens.includes("--workspace") ? "--workspace" : "--all"}`);
796
+ }
797
+ if (wholeWorkspace && !manifestPaths.length) for (const c of members(cwd)) pick(c, tokens.includes("--workspace") ? "--workspace" : "--all");
798
+ }
799
+ if (!packages.length && !manifestPaths.length) {
800
+ for (const t of tests) {
801
+ const owners = t === E2E_GUARD_TARGET ? [] : layout.crates.filter((c) => c.tests.has(t));
802
+ if (owners.length === 1) pick(owners[0], `--test ${t}`);
803
+ }
804
+ }
805
+ if (isRun) for (const b of flagValues(tokens, "--bin")) for (const c of layout.crates.filter((crate) => crate.bins.has(b))) pick(c, `--bin ${b}`);
806
+ return [...picks].map(([crate, selector]) => ({ crate, selector }));
807
+ };
808
+ /** Scan logical lines ({ no, text }) that run in order from `cwd`; `what` names the place and `subject` overrides the crate name in the identity. */
809
+ const scan = (file, lines, cwd, what, subject) => {
810
+ for (const { no, text } of lines) {
811
+ if (!/cargo|\bcd\b|Set-Location/i.test(text)) continue; // a line with neither a command nor a directory change cannot matter
812
+ const events = [...text.matchAll(CD_COMMAND)].map((m) => ({ at: m.index, cd: m[1] })).concat([...text.matchAll(CARGO_COMMAND)].map((m) => ({ at: m.index, m })));
813
+ for (const event of events.sort((a, b) => a.at - b.at)) {
814
+ if (event.cd !== undefined) { cwd = changeDirectory(cwd, event.cd); continue; }
815
+ const [, client, verb] = event.m;
816
+ // A command inside a string literal ends at the quote that closes it: `run("rustright cargo test -p x")`.
817
+ let rest = event.m[3];
818
+ for (const quote of ['"', "'"]) {
819
+ if ((rest.split(quote).length - 1) % 2 === 1 && (text.slice(0, event.at).split(quote).length - 1) % 2 === 1) rest = rest.slice(0, rest.indexOf(quote));
820
+ }
821
+ const tokens = (rest.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((t) => t.replace(/["']/g, "").replace(/\)+$/, ""));
822
+ const ownArguments = tokens.indexOf("--") < 0 ? tokens : tokens.slice(0, tokens.indexOf("--"));
823
+ const segment = text.slice(Math.max(text.lastIndexOf("&", event.at), text.lastIndexOf(";", event.at), text.lastIndexOf("|", event.at), text.lastIndexOf("`", event.at)) + 1, event.at + event.m[0].length);
824
+ if (ownArguments.includes("--no-run") || QA_RUNNER.test(segment)) continue;
825
+ if (ownArguments.some((t) => t === "--help" || t === "-h" || t === "--version" || t === "-V")) continue;
826
+ if (verb === "nextest" && NEXTEST_NO_TESTS.has(tokens[0])) continue;
827
+ for (const { crate, selector } of selected(ownArguments, verb === "run", cwd)) {
828
+ const key = `${file}\0${no}\0${crate.name}`;
829
+ if (seen.has(key)) continue;
830
+ seen.add(key);
831
+ const via = `${client.replace(/\s+/g, " ")} ${verb} ${selector}`;
832
+ out.push({ file, subject: subject ?? crate.name, problem: `${file}:${no} ${what} runs the end-to-end crate ${crate.name} under cargo test (${via}): the test binary runs in the broker's context and holds its compile slot for the whole run`, fix: `compile it with --no-run and run it outside the broker: rightkit-qa run-tests -p ${crate.name} [--test <target>] -- <libtest args>` });
833
+ }
834
+ }
835
+ }
836
+ };
837
+ for (const { file, text, manifest } of packageManifests(root, files)) {
838
+ for (const [name, command] of Object.entries(manifest.scripts)) {
839
+ if (typeof command === "string") scan(file, [{ no: scriptLine(text, name), text: command }], dirOf(file), `script "${name}"`, name);
840
+ }
841
+ }
842
+ for (const file of files.filter(scanned)) {
843
+ const base = path.posix.basename(file);
844
+ const runbook = (file === "README.md" || file === "AGENTS.md" || file === "CLAUDE.md" || /^docs\/.+\.md$/.test(file)) && !HISTORY_DOC.test(file) && !/\d{4}-\d{2}-\d{2}/.test(base);
845
+ const workflow = WORKFLOW_FILE.test(file);
846
+ const hook = !workflow && HOOK_FILE.test(file);
847
+ const recipe = !workflow && !hook && BUILD_FILE.test(file);
848
+ const script = !workflow && !hook && !recipe && SCRIPT_FILE.test(file);
849
+ if (!(runbook || workflow || hook || recipe || script) || base === "package.json") continue;
850
+ const content = readText(root, file);
851
+ if (content === null || !/cargo/.test(content)) continue;
852
+ const continuation = /\.(?:ps1|psm1)$/i.test(file) ? "`" : /\.(?:bat|cmd)$/i.test(file) ? "^" : "\\";
853
+ const physical = content.split("\n").map((line, i) => ({ no: i + 1, text: line }));
854
+ if (workflow) for (const step of workflowSteps(content)) scan(file, logicalLines(step.lines, continuation), step.cwd, "workflow step");
855
+ else if (runbook) for (const unit of markdownUnits(content)) scan(file, logicalLines(unit, continuation), ".", "runbook command");
856
+ else scan(file, logicalLines(physical, continuation), ".", hook ? "git hook" : recipe ? "recipe" : "command");
857
+ }
858
+ return out;
859
+ }
860
+
406
861
  export const RULES = Object.freeze([
407
862
  { id: "eol-policy", gap: "G04", run: eolPolicy },
408
863
  { id: "file-url", gap: "G12", run: fileUrls },
@@ -416,6 +871,7 @@ export const RULES = Object.freeze([
416
871
  { id: "ffmpeg-release-escape", gap: "G19", run: ffmpegReleaseEscape },
417
872
  { id: "switches-inventory", gap: "G20", run: switchesInventory },
418
873
  { id: "speech-link", gap: "G21", run: speechLink },
874
+ { id: "e2e-under-cargo-test", gap: "G22", run: e2eUnderCargoTest },
419
875
  ]);
420
876
 
421
877
  // ---- export: the committed files, by git alone ----------------------------------------------------------------------------------
@@ -740,6 +1196,7 @@ export const BUILD_REFUSES = Object.freeze(["switches-inventory"]);
740
1196
  * classification test in app-check.test.mjs, so a new command cannot appear unclassified. */
741
1197
  export const UNCHECKED_COMMANDS = new Map([
742
1198
  ["check", "is the check"],
1199
+ ["update", "runs @rightkit/outdated over the app's RightKit pins (right-update); reads no release inputs and builds nothing"],
743
1200
  ["deps", "the shared dependency lane: reads and updates dependency pins, not the app's release inputs"],
744
1201
  ["hardening", "scans artifacts named on the command line; reads no source tree"],
745
1202
  ["harden", "alias of hardening"],
@@ -2,8 +2,9 @@ import { createHash, generateKeyPairSync, randomUUID, sign, X509Certificate } fr
2
2
  import { chmodSync, copyFileSync, constants, existsSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { fileURLToPath } from "node:url";
6
6
  import { spawnSync } from "./background-process.mjs";
7
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
7
8
  import { appleProfileAuth, decodeAppleProfile, findAppleSigningCertificate, signAppleApiJwt } from "./apple-profile-renew.mjs";
8
9
 
9
10
  // right-release apple-profile-create: issue a Mac Development provisioning profile
@@ -452,10 +453,10 @@ export async function main(args = process.argv.slice(2), options = {}) {
452
453
  return;
453
454
  }
454
455
  const parsed = parseCreateArgs(args);
455
- const configPath = parsed.configPath ?? "right-release.config.mjs";
456
+ const configPath = parsed.configPath ?? defaultReleaseConfigFile();
456
457
  const absolute = path.resolve(configPath);
457
458
  let config = {};
458
- if (existsSync(absolute)) { const loaded = await import(pathToFileURL(absolute)); config = loaded.default ?? loaded.config ?? loaded; }
459
+ if (existsSync(absolute)) config = await importReleaseConfig(absolute, { lenient: true });
459
460
  else if (parsed.configPath) fail("APPLE_PROFILE_USAGE", `Config file not found: ${parsed.configPath}`);
460
461
  return createAppleProfile({ ...options, root: path.dirname(absolute), config, ...parsed });
461
462
  }
@@ -1,8 +1,9 @@
1
1
  import { createHash, createPrivateKey, randomUUID, sign, X509Certificate } from "node:crypto";
2
2
  import { constants, copyFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { fileURLToPath } from "node:url";
5
5
  import { spawnSync } from "./background-process.mjs";
6
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
6
7
  import { isManagedHost, refuseManagedEnvSecret } from "./managed-host-secrets.mjs";
7
8
  import os from "node:os";
8
9
 
@@ -226,7 +227,7 @@ export async function main(args = process.argv.slice(2), options = {}) {
226
227
  return;
227
228
  }
228
229
  if (args[0] !== "profile" || args[1] !== "renew") fail("APPLE_PROFILE_USAGE", "Expected: right-release apple profile renew");
229
- let configPath = "right-release.config.mjs", profilePath, dryRun = false;
230
+ let configPath = defaultReleaseConfigFile(), profilePath, dryRun = false;
230
231
  for (let i = 2; i < args.length; i++) {
231
232
  if (args[i] === "--dry-run") dryRun = true;
232
233
  else if (["--config", "--profile"].includes(args[i])) {
@@ -236,8 +237,8 @@ export async function main(args = process.argv.slice(2), options = {}) {
236
237
  } else fail("APPLE_PROFILE_USAGE", `Unknown option: ${args[i]}`);
237
238
  }
238
239
  const absolute = path.resolve(configPath);
239
- const loaded = await import(pathToFileURL(absolute));
240
- return renewAppleProfile({ ...options, root: path.dirname(absolute), config: loaded.default ?? loaded.config ?? loaded, profilePath, dryRun });
240
+ const config = await importReleaseConfig(absolute, { lenient: true });
241
+ return renewAppleProfile({ ...options, root: path.dirname(absolute), config, profilePath, dryRun });
241
242
  }
242
243
 
243
244
  const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
Binary file