@rightkit/release 0.6.15 → 0.6.20

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 (65) hide show
  1. package/README.md +7 -3
  2. package/admission-host.mjs +9 -5
  3. package/app-closure.mjs +9 -3
  4. package/bin/darwin/rightkit-swap +0 -0
  5. package/bin/rightkit-gate.mjs +469 -102
  6. package/bin/rightkit-linux-setup.mjs +15 -0
  7. package/broker-admission.mjs +184 -0
  8. package/broker-wire.mjs +15 -3
  9. package/build-launch.mjs +4 -1
  10. package/build-ownership.mjs +125 -36
  11. package/build-release.mjs +39 -7
  12. package/cargo-contract.mjs +4 -1
  13. package/cargo-guard.mjs +25 -5
  14. package/cargo-target-shim.mjs +56 -7
  15. package/clippy-route.mjs +8 -7
  16. package/crate-graph.mjs +5 -1
  17. package/credential-provider.mjs +174 -0
  18. package/deterministic-scans.mjs +256 -41
  19. package/development.mjs +8 -0
  20. package/disk-policy.mjs +2 -2
  21. package/gate-executor.mjs +5 -3
  22. package/gate-stages.mjs +34 -3
  23. package/gradle-lock.mjs +206 -0
  24. package/heavy-command.mjs +33 -2
  25. package/linux-artifacts.mjs +2 -1
  26. package/linux-setup.mjs +106 -0
  27. package/local-publish.mjs +21 -10
  28. package/local-qualify.mjs +9 -3
  29. package/managed-cargo.mjs +54 -18
  30. package/npm-license-notices.mjs +49 -1
  31. package/ownership-lockfiles.mjs +27 -4
  32. package/ownership.mjs +5 -1
  33. package/package.json +17 -11
  34. package/protected-credentials.mjs +13 -5
  35. package/publication-gate.mjs +9 -4
  36. package/python-lock.mjs +209 -0
  37. package/qa-admission-policy.json +32 -0
  38. package/qa-layout.mjs +229 -0
  39. package/qa-runner.mjs +156 -0
  40. package/release-artifact-path.mjs +29 -3
  41. package/release-config.mjs +1 -1
  42. package/release-qa-command.mjs +58 -8
  43. package/release-qa-policy.mjs +5 -3
  44. package/release-source-identity.mjs +1 -0
  45. package/release.mjs +10 -2
  46. package/rightkit-ownership.json +1465 -79
  47. package/rightkit-registry-resolver.mjs +7 -3
  48. package/rightkit-versions.json +173 -36
  49. package/runtime-budget-classes.json +4 -0
  50. package/runtime-budgets.mjs +5 -1
  51. package/shell-contract.mjs +1 -1
  52. package/snapshot-broker.mjs +107 -9
  53. package/snapshot-check.mjs +2 -0
  54. package/snapshot-request.mjs +20 -3
  55. package/snapshot.mjs +11 -4
  56. package/stage-commands.mjs +2 -1
  57. package/stage-ledger.mjs +15 -0
  58. package/stage-sidecar.mjs +17 -3
  59. package/suite-signing.mjs +40 -4
  60. package/swift-lock.mjs +334 -0
  61. package/test-command.mjs +13 -3
  62. package/tool-build.mjs +156 -0
  63. package/unit-gate.mjs +9 -2
  64. package/windows-identity-plan.mjs +119 -0
  65. package/windows-identity.mjs +136 -0
package/managed-cargo.mjs CHANGED
@@ -125,12 +125,38 @@ function fileHead(file) {
125
125
  catch { return ""; } finally { if (fd !== null) try { closeSync(fd); } catch { /* closed */ } }
126
126
  }
127
127
 
128
+ /** True when `file` is a gate's own runtime cargo wrapper (cargo-target-shim.mjs installCargoShim writes it), not a toolchain cargo. */
129
+ export function isGateCargoWrapper(file, readHead = fileHead) {
130
+ const head = readHead(file);
131
+ return head.startsWith("#!/bin/sh\n") && head.includes("cargo-target-shim.mjs") && head.includes(" --map ");
132
+ }
133
+
134
+ /**
135
+ * The absolute cargo a gate wraps: the first of `candidates` (a PATH lookup, in order) that is not another gate's runtime wrapper, or the
136
+ * toolchain cargo such a wrapper's map records (a lookup that returns only the first match finds only the wrapper).
137
+ * A gate started inside a scan of an outer gate finds that gate's wrapper first on PATH; wrapping it would send every call through
138
+ * the outer map, which binds none of this gate's workspaces (0.6.18 Linux qualify: RK_CARGO_UNBOUND_WORKSPACE "resolved (none)").
139
+ */
140
+ export function realCargoOf(candidates, readHead = fileHead, readMap = (file) => JSON.parse(readFileSync(file, "utf8"))) {
141
+ for (const c of candidates) {
142
+ if (!path.isAbsolute(c)) continue;
143
+ if (!isGateCargoWrapper(c, readHead)) return c;
144
+ // A PATH lookup (`which`) names only the first match: the wrapper's own map records the toolchain cargo it wraps.
145
+ try {
146
+ const at = /--map '([^']+)'/.exec(readHead(c));
147
+ const wrapped = at ? readMap(at[1]).realCargo : null;
148
+ if (typeof wrapped === "string" && path.isAbsolute(wrapped) && !isGateCargoWrapper(wrapped, readHead)) return wrapped;
149
+ } catch { /* an unreadable map names nothing: keep looking */ }
150
+ }
151
+ return undefined;
152
+ }
153
+
128
154
  /**
129
155
  * On a direct (Linux) host: PATH without the directories whose `cargo` is a RustRight shim (the managed agent-bin, a configured
130
156
  * RIGHTKIT_MANAGED_AGENT_BIN, or a script that routes through the broker, read 4 KiB at most), and the first real cargo left on it.
131
157
  * Plain `cargo` then can only be a real toolchain; with none, a cargo launch fails as missing, never through a shim.
132
158
  */
133
- export function directCargoPath({ env = process.env, exists = existsSync, readHead = fileHead, platform = process.platform } = {}) {
159
+ export function directCargoPath({ env = process.env, exists = existsSync, readHead = fileHead, platform = process.platform, gateWrappers = false } = {}) {
134
160
  // `platform` only matters for a forced direct run on Windows (a recorded broker exception): ";" separators, backslash paths, cargo.exe.
135
161
  const win = platform === "win32";
136
162
  const norm = (d) => (win ? String(d).replaceAll("\\", "/") : d);
@@ -139,7 +165,10 @@ export function directCargoPath({ env = process.env, exists = existsSync, readHe
139
165
  for (const dir of String(env.PATH ?? "").split(win ? ";" : ":").filter(Boolean)) {
140
166
  const candidates = win ? ["cargo.exe", "cargo.cmd", "cargo.bat", "cargo"].map((n) => path.win32.join(dir, n)) : [path.posix.join(dir, "cargo")];
141
167
  const found = candidates.find((c) => exists(c));
142
- const isShim = Boolean(found) && (MANAGED_AGENT_BIN.test(norm(dir)) || (configured && path.posix.resolve(norm(dir)) === configured) || /cargo-guard\.mjs|rustright/i.test(readHead(found)));
168
+ // `gateWrappers`: a gate resolving its OWN executor also skips another gate's runtime cargo wrapper (a gate started inside an outer gate's scan,
169
+ // 0.6.18/0.6.19 Linux qualify). A gate's children never pass it: inside a stage the wrapper first on PATH is the binding they must go through. The
170
+ // one other caller is test-support/toolchain-cargo.mjs, for a test's throwaway fixtures under the temp directory, never anything in the snapshot.
171
+ const isShim = Boolean(found) && (MANAGED_AGENT_BIN.test(norm(dir)) || (configured && path.posix.resolve(norm(dir)) === configured) || /cargo-guard\.mjs|rustright/i.test(readHead(found)) || (gateWrappers && isGateCargoWrapper(found, readHead)));
143
172
  if (isShim) { dropped.push(dir); continue; }
144
173
  kept.push(dir);
145
174
  if (!cargo && found) cargo = found;
@@ -201,28 +230,35 @@ function carriedSeam(args, options, { platform, spawn }) {
201
230
  return runCarriedCargo(args, options, { map: carried.map, spawn });
202
231
  }
203
232
 
233
+ /**
234
+ * The ONE route a call takes to the broker's client on a broker host (runCargoSync and cargo-guard both use it): the declared Linux route when the host declared one (only when it
235
+ * validates; an invalid one is `{ refusal }`, never a fallback to another client), else the installed runtime client with its discovered socket (on the Mac a missing socket is
236
+ * `{ refusal: "RightKit broker socket is unavailable" }`), else null. `{ client, env, mapUnavailable }` runs as `<client> cargo <args>`; `mapUnavailable` says a RK_BROKER_UNAVAILABLE
237
+ * answer of the client is to be reported as an unavailable broker (the declared route, and the Mac).
238
+ */
239
+ export function brokerClientRoute({ env = process.env, platform = process.platform, exists = existsSync, fsOps, linuxRoute = resolveLinuxBrokerRoute, installedClient = managedRuntimeClient, socketOf = discoverBrokerSocket } = {}) {
240
+ const linux = linuxRoute({ env, platform, fsOps });
241
+ if (linux.declared) return linux.ok ? { client: linux.client, env: { ...env, RUSTRIGHT_BUILD_BROKER_SOCKET: linux.socket }, mapUnavailable: true } : { refusal: linux.problem };
242
+ const client = installedClient(env, platform, exists);
243
+ if (!client) return null;
244
+ const socket = socketOf(env, { platform, exists });
245
+ if (platform === 'darwin' && (!socket || !exists(socket))) return { refusal: 'RightKit broker socket is unavailable' };
246
+ return { client, env: socket ? { ...env, RUSTRIGHT_BUILD_BROKER_SOCKET: socket } : env, mapUnavailable: platform === 'darwin' };
247
+ }
248
+
204
249
  export function runCargoSync(args, options = {}, { platform = process.platform, exists = existsSync, spawn = spawnSync, fsOps } = {}) {
205
250
  const env = options.env ?? process.env;
206
251
  const seam = carriedSeam(args, options, { platform, spawn });
207
252
  if (seam) return seam;
208
253
  // A gate run outside the broker by Adrian's recorded exception: the real toolchain, shims off PATH, on every platform (NO_BROKER_ENV).
209
254
  if (gateNoBroker(env)) return spawn("cargo", args, { ...options, env: directCargoEnv(env, platform, exists) });
210
- // A Linux host that declared a broker route: every call goes to the validated client at the declared endpoint, or is refused. Never direct.
211
- const linux = resolveLinuxBrokerRoute({ env, platform, fsOps });
212
- if (linux.declared) {
213
- if (!linux.ok) return unavailable(linux.problem);
214
- const result = spawn(linux.client, ["cargo", ...args], { ...options, env: { ...env, RUSTRIGHT_BUILD_BROKER_SOCKET: linux.socket } });
215
- if (result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) return { ...result, error: unavailable(String(result.stderr).trim()).error };
216
- return result;
217
- }
218
- const client = managedRuntimeClient(env, platform, exists);
219
- if (client) {
220
- const socket = discoverBrokerSocket(env, { platform, exists });
221
- if (platform === 'darwin' && (!socket || !exists(socket))) return unavailable('RightKit broker socket is unavailable');
222
- const childEnv = socket ? { ...env, RUSTRIGHT_BUILD_BROKER_SOCKET: socket } : env;
223
- const invocation = managedRuntimeCommand(client, ["cargo", ...args]);
224
- const result = spawn(invocation.command, invocation.args, { ...options, env: childEnv });
225
- if (platform === 'darwin' && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
255
+ // A Linux host that declared a broker route: every call goes to the validated client at the declared endpoint, or is refused. Never direct. Otherwise the managed client (brokerClientRoute).
256
+ const route = brokerClientRoute({ env, platform, exists, fsOps });
257
+ if (route?.refusal) return unavailable(route.refusal);
258
+ if (route) {
259
+ const invocation = managedRuntimeCommand(route.client, ["cargo", ...args]);
260
+ const result = spawn(invocation.command, invocation.args, { ...options, env: route.env });
261
+ if (route.mapUnavailable && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
226
262
  return { ...result, error: unavailable(String(result.stderr).trim()).error };
227
263
  }
228
264
  return result;
@@ -46,6 +46,46 @@ export function collectNpmPackages(groups, exclude = ["@rightkit/"]) {
46
46
  .sort((a, b) => `${a.name}@${a.versions.join(",")}`.localeCompare(`${b.name}@${b.versions.join(",")}`));
47
47
  }
48
48
 
49
+ /**
50
+ * The production closure of `pnpm list --json --depth Infinity` output: every package reachable from a project's `dependencies` or
51
+ * `optionalDependencies` through the dependencies, optional dependencies and resolved peer dependencies pnpm lists, as
52
+ * `name@version` -> { name, version, path }. pnpm 12's `licenses list --prod` no longer follows a peer edge (an optional peer of a
53
+ * production package that the app also installs as a dev dependency, so `@rightkit/legal` -> `@rightkit/release` -> smol-toml went
54
+ * missing), but the package is still installed beside the production package and loadable by it; the notices must list it.
55
+ */
56
+ export function productionClosure(tree) {
57
+ if (!Array.isArray(tree)) fail("pnpm list --json did not return a project array");
58
+ const closure = new Map(), seen = new Set();
59
+ const visit = (name, node) => {
60
+ if (!node || typeof node !== "object" || typeof node.version !== "string" || node.version.startsWith("link:")) return;
61
+ const id = `${name}@${node.version}`, at = `${id}|${node.path ?? ""}`;
62
+ if (seen.has(at)) return;
63
+ seen.add(at);
64
+ if (!closure.has(id)) closure.set(id, { name, version: node.version, path: node.path });
65
+ for (const section of ["dependencies", "optionalDependencies"]) for (const [child, value] of Object.entries(node[section] ?? {})) visit(child, value);
66
+ };
67
+ for (const project of tree) for (const section of ["dependencies", "optionalDependencies"]) for (const [name, node] of Object.entries(project?.[section] ?? {})) visit(name, node);
68
+ return closure;
69
+ }
70
+
71
+ /**
72
+ * `groups` (the `--prod` listing) plus every closure package it lacks, with its license read from `allGroups` (the full listing,
73
+ * fetched lazily by `loadAll`) and its installed path from the tree. A missing package that the full listing does not know is an
74
+ * error: a notice is never silently shorter than the production closure. Excluded prefixes are skipped before the lookup.
75
+ */
76
+ export function completeProductionGroups(groups, closure, loadAll, exclude = ["@rightkit/"]) {
77
+ const listed = new Set(Object.values(groups).flatMap((entries) => entries.flatMap((entry) => entry.versions.map((v) => `${entry.name}@${v}`))));
78
+ const missing = [...closure].filter(([id, item]) => !listed.has(id) && !exclude.some((prefix) => item.name.startsWith(prefix)));
79
+ if (!missing.length) return groups;
80
+ const all = loadAll(), completed = Object.fromEntries(Object.entries(groups).map(([license, entries]) => [license, [...entries]]));
81
+ for (const [id, item] of missing) {
82
+ const hit = Object.entries(all).find(([, entries]) => entries.some((entry) => entry.name === item.name && entry.versions.includes(item.version)));
83
+ if (!hit || typeof item.path !== "string") fail(`${id} is in the production closure (pnpm list) but not in pnpm licenses list: its notice cannot be produced`);
84
+ (completed[hit[0]] ??= []).push({ name: item.name, versions: [item.version], paths: [item.path] });
85
+ }
86
+ return completed;
87
+ }
88
+
49
89
  /**
50
90
  * Render the appendix. `readNames(dir)` lists a package directory and `readText(file)` reads one file (both injectable).
51
91
  * Returns `{ html, packageCount, fallbackCount }`.
@@ -106,7 +146,15 @@ export function generateNpmNotices({ appRoot, config, flags = {}, check = false,
106
146
  });
107
147
  if (result.error) throw result.error;
108
148
  if (result.status !== 0) fail(result.stderr || "pnpm licenses list failed");
109
- const packages = collectNpmPackages(JSON.parse(result.stdout), exclude);
149
+ const run = (args) => {
150
+ const out = spawnSyncFn("pnpm", args, { cwd: appRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, shell: process.platform === "win32" });
151
+ if (out.error) throw out.error;
152
+ if (out.status !== 0) fail(out.stderr || `pnpm ${args.join(" ")} failed`);
153
+ return JSON.parse(out.stdout);
154
+ };
155
+ const closure = productionClosure(run(["list", "--json", "--depth", "Infinity"]));
156
+ const groups = completeProductionGroups(JSON.parse(result.stdout), closure, () => run(["licenses", "list", "--json"]), exclude);
157
+ const packages = collectNpmPackages(groups, exclude);
110
158
  const lockHash = createHash("sha256").update(readFileSync(path.join(appRoot, "pnpm-lock.yaml"))).digest("hex");
111
159
  const { html, packageCount, fallbackCount } = renderNpmNotices({ productName, version, lockHash, packages });
112
160
  const target = path.join(appRoot, output);
@@ -1,5 +1,9 @@
1
1
  // Stage 1: resolved lock graphs, without feature or production-only pruning.
2
- import { parse as parseToml } from "smol-toml";
2
+ import { createRequire } from "node:module";
3
+ // smol-toml loads on first parse, not at import: CI jobs whose closure reaches this module without installing
4
+ // @rightkit/release dependencies (publish.yml crates pack, bind) never parse TOML here (rightkit#1341).
5
+ let smolTomlParse;
6
+ const parseToml = (text) => (smolTomlParse ??= createRequire(import.meta.url)("smol-toml").parse)(text);
3
7
  import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
4
8
  import path from "node:path";
5
9
  import { sha256 } from "./ownership-approval.mjs";
@@ -244,8 +248,20 @@ function packageLockGraph(text, manifests, lockDir) {
244
248
  }
245
249
  return g;
246
250
  }
251
+ /**
252
+ * pnpm 12 writes the lockfile as several YAML documents: leading ones carry the tooling pnpm installs for itself
253
+ * (configDependencies, packageManagerDependencies), and the last one is the project lock. Only the project's document is the
254
+ * dependency graph; pnpm 6/9/11 write a single document, which is returned unchanged.
255
+ */
256
+ export function pnpmProjectDocument(text) {
257
+ const docs = text.split(/^---[ \t]*\r?$/m).filter((doc) => doc.trim());
258
+ if (docs.length <= 1) return text;
259
+ const last = docs.at(-1);
260
+ if (!/^lockfileVersion:/m.test(last)) fail("pnpm multi-document lockfile does not end with the project document");
261
+ return last;
262
+ }
247
263
  function pnpmGraph(text, manifests, lockDir, workspaceBytes) {
248
- const lock = parsePnpmYaml(text), version = String(lock.lockfileVersion), g = graph();
264
+ const lock = parsePnpmYaml(pnpmProjectDocument(text)), version = String(lock.lockfileVersion), g = graph();
249
265
  if (!/^(?:9|6)(?:\.\d+)?$/.test(version) || !object(lock.importers) || !object(lock.packages ?? {})) throw new LockError("lockfile-unsupported-format", "pnpm requires v6/v9 importers");
250
266
  const v9 = version.startsWith("9"), packages = lock.packages ?? {}, snapshots = v9 ? lock.snapshots ?? {} : packages;
251
267
  if (!object(snapshots)) fail("invalid pnpm snapshots");
@@ -269,8 +285,14 @@ function pnpmGraph(text, manifests, lockDir, workspaceBytes) {
269
285
  if (typeof ref !== "string") fail("invalid pnpm dependency version");
270
286
  if (/^(?:link:|workspace:)/.test(ref)) {
271
287
  if (!ref.startsWith("link:")) throw new LockError("lockfile-unresolved-dependency", ref);
272
- const importer = node.id.replace(/^importer:/, "");
273
- return g.nodes.get(`importer:${path.posix.normalize(path.posix.join(importer, ref.slice(5)))}`);
288
+ const importer = node.id.replace(/^importer:/, ""), target = path.posix.normalize(path.posix.join(importer, ref.slice(5)));
289
+ const found = g.nodes.get(`importer:${target}`);
290
+ if (found) return found;
291
+ // A link to a directory of the scanned repository outside this lock's importers (RightKit's own sample apps link
292
+ // packages/*) is that repository's own source: a local node, never a gap. A link leaving the repository stays unresolved.
293
+ const repoRel = path.posix.normalize(path.posix.join(lockDir, target));
294
+ if (repoRel === ".." || repoRel.startsWith("../") || path.posix.isAbsolute(repoRel)) return undefined;
295
+ return g.nodes.get(`linked:${repoRel}`) ?? g.add(`linked:${repoRel}`, name, "0.0.0", `path:${repoRel}`, true);
274
296
  }
275
297
  const candidates = [ref, `${name}@${ref}`, `/${name}@${ref}`];
276
298
  const exact = candidates.map((id) => g.nodes.get(id)).find(Boolean);
@@ -280,6 +302,7 @@ function pnpmGraph(text, manifests, lockDir, workspaceBytes) {
280
302
  return g.nodes.get(normalized) ?? g.nodes.get(`/${normalized}`);
281
303
  };
282
304
  for (const node of g.nodes.values()) {
305
+ if (node.id.startsWith("linked:")) continue; // its own dependencies belong to its own manifest and lock
283
306
  const isImporter = node.id.startsWith("importer:"), pkg = isImporter ? lock.importers[node.id.slice(9)] : snapshots[node.id];
284
307
  for (const section of isImporter ? sections : ["dependencies", "optionalDependencies"]) {
285
308
  if (pkg[section] !== undefined && !object(pkg[section])) fail("invalid pnpm dependency map");
package/ownership.mjs CHANGED
@@ -2,7 +2,11 @@
2
2
  import { isBlockingFinding } from "./ownership-verdict.mjs";
3
3
  import { spawnSync } from "./background-process.mjs";
4
4
  import { canonicalRepositoryIdentity, loadTrustedExternal, occurrenceHash, resolveKeyLogTrust, sha256, verifyProtectedProvenance } from "./ownership-approval.mjs";
5
- import { parse as parseToml } from "smol-toml";
5
+ import { createRequire } from "node:module";
6
+ // smol-toml loads on first parse, not at import: CI jobs whose closure reaches this module without installing
7
+ // @rightkit/release dependencies (publish.yml crates pack, bind) never parse TOML here (rightkit#1341).
8
+ let smolTomlParse;
9
+ const parseToml = (text) => (smolTomlParse ??= createRequire(import.meta.url)("smol-toml").parse)(text);
6
10
  import { sourceNonCode } from "./ownership-source.mjs";
7
11
  import { OWNERSHIP_LOCKFILES, transitiveDependencies } from "./ownership-lockfiles.mjs";
8
12
  import { ALLOWLIST_FILE, ALLOWLIST_FINDING_ID, evaluateDependency, sourceKind, validateAllowlist } from "./ownership-dependency-allowlist.mjs";
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.15",
3
+ "version": "0.6.20",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "right-release": "cli/right-release.mjs",
9
9
  "rightkit-gate": "bin/rightkit-gate.mjs",
10
+ "rightkit-linux-setup": "bin/rightkit-linux-setup.mjs",
10
11
  "rightkit-agent-hook": "bin/rightkit-agent-hook.mjs"
11
12
  },
12
13
  "exports": {
13
14
  "./bin/rightkit-agent-hook.mjs": "./bin/rightkit-agent-hook.mjs",
14
15
  "./bin/rightkit-gate.mjs": "./bin/rightkit-gate.mjs",
16
+ "./bin/rightkit-linux-setup.mjs": "./bin/rightkit-linux-setup.mjs",
15
17
  "./cargo-contract.mjs": "./cargo-contract.mjs",
16
18
  "./cargo-target.mjs": "./cargo-target.mjs",
17
19
  "./cli/right-release.mjs": "./cli/right-release.mjs",
@@ -23,6 +25,7 @@
23
25
  "./engine-locator.mjs": "./engine-locator.mjs",
24
26
  "./github-release.mjs": "./github-release.mjs",
25
27
  "./legal-contract.mjs": "./legal-contract.mjs",
28
+ "./linux-setup.mjs": "./linux-setup.mjs",
26
29
  "./macos-file-evidence.mjs": "./macos-file-evidence.mjs",
27
30
  "./macos-notarization.mjs": "./macos-notarization.mjs",
28
31
  "./macos-signing-identity.mjs": "./macos-signing-identity.mjs",
@@ -53,6 +56,9 @@
53
56
  "rightkit-gate": [
54
57
  "--version"
55
58
  ],
59
+ "rightkit-linux-setup": [
60
+ "--version"
61
+ ],
56
62
  "rightkit-agent-hook": {
57
63
  "args": [],
58
64
  "output": "{}"
@@ -80,14 +86,6 @@
80
86
  "!provenance-real-receipt.fixture.json"
81
87
  ],
82
88
  "sideEffects": false,
83
- "scripts": {
84
- "test": "node test-support/run-test-shards.mjs",
85
- "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
86
- "test:signer:windows": "node sign-release-manifest.integration.mjs",
87
- "prepublishOnly": "pnpm test && pnpm test:signer:windows && pnpm test:registry-parity",
88
- "doctor:all": "node --test right-suite-contract.test.mjs",
89
- "verify:standalone": "node standalone-clone-verify.mjs"
90
- },
91
89
  "publishConfig": {
92
90
  "registry": "https://registry.npmjs.org/",
93
91
  "access": "public"
@@ -97,9 +95,17 @@
97
95
  "url": "git+https://github.com/bogusyogi/rightkit.git",
98
96
  "directory": "packages/release"
99
97
  },
100
- "packageManager": "pnpm@12.16.1",
101
98
  "dependencies": {
102
99
  "@rightkit/outdated": "0.3.0",
103
100
  "smol-toml": "1.9.1"
101
+ },
102
+ "scripts": {
103
+ "test": "node test-support/run-test-shards.mjs --class=unit",
104
+ "test:broker": "node test-support/run-test-shards.mjs --class=broker",
105
+ "test:all": "node test-support/run-test-shards.mjs --class=all",
106
+ "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
107
+ "test:signer:windows": "node sign-release-manifest.integration.mjs",
108
+ "doctor:all": "node --test right-suite-contract.test.mjs",
109
+ "verify:standalone": "node standalone-clone-verify.mjs"
104
110
  }
105
- }
111
+ }
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { isManagedHost, refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
6
+ import { CREDENTIAL_PROVIDER_INSTALL, updaterCredentialProvider } from "./credential-provider.mjs";
6
7
 
7
8
  // Protected credential loaders for release sites that previously read only
8
9
  // ambient environment variables. The storage pattern matches release-token.mjs:
@@ -35,13 +36,17 @@ export class ProtectedSecretMissingError extends Error {
35
36
 
36
37
  // One-line command the operator runs once per host. macOS prompts for the value
37
38
  // (-w with no argument); Windows prompts with Read-Host -AsSecureString.
38
- export function protectedSecretStoreCommand(name, platform = process.platform) {
39
+ // `provider` is the enrolled rightkit-credentials a Windows store command runs (execWithUpdaterCredential's file); unset, the bare executable name.
40
+ export function protectedSecretStoreCommand(name, platform = process.platform, { provider, env = process.env } = {}) {
39
41
  const spec = PROTECTED_SECRETS[name];
40
42
  if (!spec) throw new Error(`unknown protected secret: ${name}`);
41
43
  if (platform === "darwin" && spec.darwinFile) return null;
42
44
  if (platform === "darwin") return `security add-generic-password -a "$USER" -s ${spec.keychain} -U -w`;
43
45
  if (platform === "win32") {
44
- if (spec.providerExec) return `$rightkitSecure = Read-Host -AsSecureString '${spec.label}'; $rightkitPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($rightkitSecure); try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rightkitPointer) | & rightkit-credentials.exe set --namespace '${spec.keychain}' --account ([Environment]::UserName) --replace } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rightkitPointer) }`;
46
+ if (spec.providerExec && !provider && platform === process.platform) {
47
+ try { provider = updaterCredentialProvider({ env, hostPlatform: platform }).file; } catch { /* not enrolled: the hint names the bare executable; signing itself refuses with the install command */ }
48
+ }
49
+ if (spec.providerExec) return `$rightkitSecure = Read-Host -AsSecureString '${spec.label}'; $rightkitPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($rightkitSecure); try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rightkitPointer) | & ${provider ? `'${String(provider).replaceAll("'", "''")}'` : "rightkit-credentials.exe"} set --namespace '${spec.keychain}' --account ([Environment]::UserName) --replace } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rightkitPointer) }`;
45
50
  return `powershell -NoProfile -Command "$s = Read-Host -AsSecureString '${spec.label}'; $d = Join-Path $env:APPDATA 'RightKit'; New-Item -ItemType Directory -Force -Path $d | Out-Null; [IO.File]::WriteAllText((Join-Path $d '${spec.file}'), [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)))"`;
46
51
  }
47
52
  return null;
@@ -120,9 +125,11 @@ export function resolveEnvBackedSecret({ name, envName, env = process.env, crede
120
125
 
121
126
  // M11 owns OS acquisition & secret lifetime. The release adapter supplies only
122
127
  // non-secret identity plus an absolute native child executable and literal argv.
128
+ // The provider is the ENROLLED rightkit-credentials (credential-provider.mjs; installed by `rightkit-gate --to tool --unit cargo:rightkit-credentials`) on a Mac or
129
+ // Windows host, resolved and re-hashed here; the bare PATH name is used only where no managed data root exists (Linux, CI). `provider` names a file explicitly.
123
130
  export function execWithUpdaterCredential(command, args, {
124
- env = process.env, cwd = process.cwd(), run = spawnSync,
125
- provider = process.platform === "win32" ? "rightkit-credentials.exe" : "rightkit-credentials",
131
+ env = process.env, cwd = process.cwd(), run = spawnSync, hostPlatform = process.platform, dataRoot,
132
+ provider,
126
133
  account = os.userInfo().username,
127
134
  } = {}) {
128
135
  for (const key of Object.keys(env)) {
@@ -131,10 +138,11 @@ export function execWithUpdaterCredential(command, args, {
131
138
  }
132
139
  }
133
140
  if (!path.isAbsolute(command)) throw new Error("credential provider child must be an absolute native executable");
141
+ provider ??= updaterCredentialProvider({ env, hostPlatform, dataRoot }).file;
134
142
  const result = run(provider, ["exec", "--namespace", PROTECTED_SECRETS["updater-signing-key"].keychain,
135
143
  "--account", account, "--env", "TAURI_SIGNING_PRIVATE_KEY", "--", command, ...args],
136
144
  { cwd, env: { ...env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" }, stdio: "inherit", shell: false, windowsHide: true });
137
- if (result.error?.code === "ENOENT") throw new Error("OS credential provider unavailable: install rightkit-credentials >=0.2.2 on PATH before updater signing");
145
+ if (result.error?.code === "ENOENT") throw new Error(`OS credential provider unavailable: install rightkit-credentials >=0.2.2 on PATH before updater signing (a Mac or Windows host installs it with \`${CREDENTIAL_PROVIDER_INSTALL}\`)`);
138
146
  return result;
139
147
  }
140
148
 
@@ -177,7 +177,12 @@ export function validatePublicationEvidence({ expected = {}, evidence, artifactB
177
177
  if (!SHA.test(String(commit ?? ""))) missing.push("expected commit is not a full 40-character SHA");
178
178
  const defaults = requiredHosts(kind);
179
179
  let hosts = defaults;
180
- if (expected.requiredHosts !== undefined) {
180
+ // GB-29 (rightkit#1341): a direct Linux qualification bundle proves its own Linux run; Mac and Windows are proved by the publish run's
181
+ // native verify jobs before upload. Only an explicit caller request with linux-direct evidence selects it; it never satisfies an upload.
182
+ if (expected.directLinux === true) {
183
+ if (evidence?.hostPolicy !== "linux-direct" || kind === "swift") missing.push("direct Linux qualification needs linux-direct cargo or npm evidence");
184
+ hosts = ["linux"];
185
+ } else if (expected.requiredHosts !== undefined) {
181
186
  const requested = expected.requiredHosts;
182
187
  if (!(Array.isArray(requested) && requested.length > 0 && new Set(requested).size === requested.length
183
188
  && Array.from(requested).every((host) => defaults.includes(host)))) {
@@ -197,7 +202,7 @@ export function validatePublicationEvidence({ expected = {}, evidence, artifactB
197
202
  const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
198
203
  if (runs.some((run) => run?.batch !== undefined) && !SHA.test(String(tree ?? ""))) missing.push("expected tree is not a full 40-character SHA for batch evidence");
199
204
  for (const host of hosts) {
200
- const label = host === "darwin" ? "macOS" : "Windows";
205
+ const label = host === "darwin" ? "macOS" : host === "linux" ? "Linux" : "Windows";
201
206
  const matching = runs.filter((run) => run?.host?.os === host);
202
207
  if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
203
208
  // Every run on the host must qualify: a failing run is not masked by a passing run beside it.
@@ -213,7 +218,7 @@ export function validatePublicationEvidence({ expected = {}, evidence, artifactB
213
218
  }
214
219
 
215
220
  // Acquire local source identity and cleanliness, then use the same content validator as CI.
216
- export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
221
+ export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, directLinux = false, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
217
222
  const missing = [];
218
223
  const head = git(["rev-parse", "HEAD"], packagePath);
219
224
  if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
@@ -230,7 +235,7 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
230
235
  // Local publication retains its recorded single-host grant. CI callers own their policy separately.
231
236
  const singleHost = (kind === "npm" || kind === "cargo") ? singleHostOfPolicy(evidence) : null;
232
237
  const result = validatePublicationEvidence({ expected: { kind, name, version, commit: head, tree,
233
- ...(singleHost ? { requiredHosts: [singleHost] } : {}) }, evidence, artifactBytes });
238
+ ...(directLinux ? { directLinux: true } : singleHost ? { requiredHosts: [singleHost] } : {}) }, evidence, artifactBytes });
234
239
  for (const problem of result.missing) {
235
240
  if (problem === `evidence commit ${evidence?.commit} != expected commit ${head}`) {
236
241
  missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
@@ -0,0 +1,209 @@
1
+ // File-only Python lock check (class A scan `python-lock`). Reads committed files in one directory and executes nothing (no pip, uv or poetry).
2
+ // * `*pins.txt` (and `requirements*.txt` compiled from a sibling `<stem>.in`): every entry must be an exact `name==version` pin; when the
3
+ // `<stem>.in` (or `<stem>.txt`) it was pinned from exists, each of its requirements must be satisfied by the pin of that package.
4
+ // * `requirements*.txt` or `pyproject.toml` beside a `uv.lock` / `poetry.lock`: each direct requirement must be satisfied by the locked
5
+ // version of that package, and a requirements file's unpinned entries are fine only where no lock exists (they are then the declaration).
6
+ // A requirement line the checker cannot read literally (URL, VCS, editable, unknown specifier) is a finding, never a silent pass. An entry with
7
+ // an environment marker is checked when its package is pinned and not required to be pinned (a lock may be for another platform).
8
+
9
+ export const PYTHON_LOCK_FILE = /(^|\/)(requirements[^/]*\.(txt|in)|[^/]*pins?\.txt|pyproject\.toml|uv\.lock|poetry\.lock)$/;
10
+ const join = (dir, f) => (dir === "" || dir === "." ? f : `${dir}/${f}`);
11
+ export const normalizeName = (n) => String(n).toLowerCase().replace(/[-_.]+/g, "-");
12
+
13
+ // ---- versions (PEP 440 subset) --------------------------------------------------------------------------------------------
14
+ const PEP440 = /^v?(\d+(?:\.\d+)*)(?:[-_.]?(a|b|rc|alpha|beta|c|pre|preview)[-_.]?(\d*))?(?:[-_.]?(?:post|rev|r)[-_.]?(\d*))?(?:[-_.]?dev[-_.]?(\d*))?(?:\+[A-Za-z0-9.]+)?$/i;
15
+ export function parsePyVersion(text) {
16
+ const m = PEP440.exec(String(text).trim());
17
+ if (!m) return null;
18
+ const rank = { a: 0, alpha: 0, b: 1, beta: 1, c: 2, rc: 2, pre: 2, preview: 2 };
19
+ return { rel: m[1].split(".").map(Number), pre: m[2] ? [rank[m[2].toLowerCase()], Number(m[3] || 0)] : null, post: m[4] !== undefined ? Number(m[4] || 0) : null, dev: m[5] !== undefined ? Number(m[5] || 0) : null, text: String(text).trim() };
20
+ }
21
+ export function comparePyVersions(a, b) {
22
+ for (let i = 0; i < Math.max(a.rel.length, b.rel.length); i++) { const x = a.rel[i] ?? 0; const y = b.rel[i] ?? 0; if (x !== y) return x < y ? -1 : 1; }
23
+ const key = (v) => [v.dev !== null && v.pre === null && v.post === null ? -1 : 0, v.pre ? 0 : 1, ...(v.pre ?? [0, 0]), v.post ?? -1, v.dev === null ? 1 : 0, v.dev ?? 0];
24
+ const ka = key(a); const kb = key(b);
25
+ for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] < kb[i] ? -1 : 1;
26
+ return 0;
27
+ }
28
+
29
+ /** Clauses of a specifier string ("" for any): [{ op, version, wildcard }] or null when not readable. */
30
+ export function parseSpecifier(spec) {
31
+ const text = String(spec).trim().replace(/^\(|\)$/g, "");
32
+ if (text === "") return [];
33
+ const out = [];
34
+ for (const part of text.split(",")) {
35
+ const m = /^\s*(===|==|!=|~=|>=|<=|>|<)\s*([^\s,]+?)(\.\*)?\s*$/.exec(part);
36
+ if (!m) return null;
37
+ if (m[3] && !["==", "!="].includes(m[1])) return null;
38
+ const version = parsePyVersion(m[2]);
39
+ if (!version) return null;
40
+ out.push({ op: m[1], version, wildcard: Boolean(m[3]), raw: part.trim() });
41
+ }
42
+ return out;
43
+ }
44
+ /** true when `version` (parsed) satisfies every clause. */
45
+ export function satisfies(version, clauses) {
46
+ return clauses.every(({ op, version: v, wildcard }) => {
47
+ const c = comparePyVersions(version, v);
48
+ switch (op) {
49
+ case "===": case "==": return wildcard ? v.rel.every((n, i) => (version.rel[i] ?? 0) === n) : c === 0;
50
+ case "!=": return wildcard ? !v.rel.every((n, i) => (version.rel[i] ?? 0) === n) : c !== 0;
51
+ case ">=": return c >= 0;
52
+ case "<=": return c <= 0;
53
+ case ">": return c > 0;
54
+ case "<": return c < 0;
55
+ case "~=": { if (c < 0 || v.rel.length < 2) return false; return v.rel.slice(0, -1).every((n, i) => (version.rel[i] ?? 0) === n); }
56
+ default: return false;
57
+ }
58
+ });
59
+ }
60
+
61
+ // ---- requirement lines ----------------------------------------------------------------------------------------------------
62
+ /** { reqs: [{ line, name, spec, clauses, marker, pinned }], problems: [{ line, text }] } from requirements-file text. */
63
+ export function parseRequirements(text) {
64
+ const reqs = []; const problems = [];
65
+ const lines = String(text).split(/\r?\n/);
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const start = i + 1; let line = lines[i];
68
+ while (/\\\s*$/.test(line) && i + 1 < lines.length) { line = line.replace(/\\\s*$/, " ") + lines[++i]; }
69
+ line = line.replace(/(^|\s)#.*$/, "").trim();
70
+ if (!line) continue;
71
+ if (/^-(r|c|-requirement|-constraint)\b/.test(line) || /^--(index-url|extra-index-url|find-links|trusted-host|no-binary|only-binary|prefer-binary|pre|require-hashes|no-index|use-feature|prefer-binary)\b/.test(line) || /^-[ifz]\s/.test(line)) continue;
72
+ if (/^-e\b|^--editable\b|^-/.test(line)) { problems.push({ line: start, text: `option ${line.slice(0, 50)} cannot be read literally` }); continue; }
73
+ line = line.replace(/\s--hash[=\s]\S+/g, "").trim();
74
+ const [body, ...markerParts] = line.split(";");
75
+ const marker = markerParts.join(";").trim() || null;
76
+ if (/@|:\/\/|^\.{0,2}\//.test(body)) { problems.push({ line: start, text: `requirement ${body.trim().slice(0, 60)} is a URL, VCS or path reference, which cannot be read literally` }); continue; }
77
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(.*)$/.exec(body.trim());
78
+ if (!m) { problems.push({ line: start, text: `requirement ${body.trim().slice(0, 60)} cannot be read literally` }); continue; }
79
+ const clauses = parseSpecifier(m[2]);
80
+ if (clauses === null) { problems.push({ line: start, text: `specifier ${m[2].slice(0, 60)} of ${m[1]} cannot be read literally` }); continue; }
81
+ const pinned = clauses.length === 1 && ["==", "==="].includes(clauses[0].op) && !clauses[0].wildcard;
82
+ reqs.push({ line: start, name: normalizeName(m[1]), display: m[1], spec: m[2].trim(), clauses, marker, pinned });
83
+ }
84
+ return { reqs, problems };
85
+ }
86
+
87
+ // ---- locks ----------------------------------------------------------------------------------------------------------------
88
+ /** Map name -> [versions] from a uv.lock / poetry.lock parsed as TOML (`[[package]]` name + version). */
89
+ export function lockVersions(doc) {
90
+ const map = new Map();
91
+ for (const p of Array.isArray(doc?.package) ? doc.package : []) {
92
+ if (typeof p?.name !== "string" || typeof p?.version !== "string") continue;
93
+ const k = normalizeName(p.name);
94
+ map.set(k, [...(map.get(k) ?? []), p.version]);
95
+ }
96
+ return map;
97
+ }
98
+
99
+ /** PEP 508 strings or poetry specs from a parsed pyproject.toml -> [{ name, spec, marker, where }], plus problems. */
100
+ export function pyprojectRequirements(doc) {
101
+ const out = []; const problems = [];
102
+ const pep = (list, where) => { for (const s of Array.isArray(list) ? list : []) { if (typeof s !== "string") continue; const r = parseRequirements(s); r.reqs.forEach((q) => out.push({ name: q.name, display: q.display, clauses: q.clauses, spec: q.spec, marker: q.marker, where })); r.problems.forEach((p) => problems.push(`${where}: ${p.text}`)); } };
103
+ pep(doc?.project?.dependencies, "[project].dependencies");
104
+ for (const [k, v] of Object.entries(doc?.project?.["optional-dependencies"] ?? {})) pep(v, `[project.optional-dependencies].${k}`);
105
+ for (const [k, v] of Object.entries(doc?.["dependency-groups"] ?? {})) pep(Array.isArray(v) ? v.filter((x) => typeof x === "string") : [], `[dependency-groups].${k}`);
106
+ pep(doc?.tool?.uv?.["dev-dependencies"], "[tool.uv].dev-dependencies");
107
+ const poetry = (table, where) => {
108
+ for (const [name, raw] of Object.entries(table ?? {})) {
109
+ if (name.toLowerCase() === "python") continue;
110
+ const spec = typeof raw === "string" ? raw : typeof raw?.version === "string" ? raw.version : null;
111
+ if (spec === null) { if (!(raw && typeof raw === "object" && (raw.git || raw.path || raw.url))) problems.push(`${where}: ${name} has no literal version`); continue; }
112
+ const clauses = poetrySpec(spec);
113
+ if (clauses === null) problems.push(`${where}: ${name} specifier ${spec} cannot be read literally`);
114
+ else out.push({ name: normalizeName(name), display: name, clauses, spec, marker: raw?.markers ?? (raw?.python ? "python" : null), where });
115
+ }
116
+ };
117
+ poetry(doc?.tool?.poetry?.dependencies, "[tool.poetry.dependencies]");
118
+ poetry(doc?.tool?.poetry?.["dev-dependencies"], "[tool.poetry.dev-dependencies]");
119
+ for (const [g, v] of Object.entries(doc?.tool?.poetry?.group ?? {})) poetry(v?.dependencies, `[tool.poetry.group.${g}.dependencies]`);
120
+ return { reqs: out, problems };
121
+ }
122
+ /** Poetry version constraint -> clauses (caret and tilde expanded, bare version exact, `*` any); null when not readable. */
123
+ export function poetrySpec(spec) {
124
+ const t = String(spec).trim();
125
+ if (t === "*" || t === "") return [];
126
+ if (t.includes("||")) return null;
127
+ const clauses = [];
128
+ for (const part of t.split(",").map((x) => x.trim())) {
129
+ let m;
130
+ if ((m = /^\^\s*(\d+(?:\.\d+)*)$/.exec(part))) {
131
+ const r = m[1].split(".").map(Number); const at = r.findIndex((n) => n !== 0); const i = at < 0 ? r.length - 1 : at;
132
+ const upper = r.slice(0, i); upper.push(r[i] + 1);
133
+ clauses.push({ op: ">=", version: parsePyVersion(m[1]) }, { op: "<", version: parsePyVersion(upper.join(".")) });
134
+ } else if ((m = /^~=?\s*(\d+(?:\.\d+)*)$/.exec(part))) {
135
+ const r = m[1].split(".").map(Number); const upper = r.length === 1 ? [r[0] + 1] : [r[0], r[1] + 1];
136
+ clauses.push({ op: ">=", version: parsePyVersion(m[1]) }, { op: "<", version: parsePyVersion(upper.join(".")) });
137
+ } else if ((m = /^(\d+(?:\.\d+)*)$/.exec(part))) clauses.push({ op: "==", version: parsePyVersion(m[1]) });
138
+ else { const c = parseSpecifier(part); if (c === null || c.length !== 1) return null; clauses.push(c[0]); }
139
+ }
140
+ return clauses.every((c) => c.version) ? clauses : null;
141
+ }
142
+
143
+ // ---- the check ------------------------------------------------------------------------------------------------------------
144
+ const describe = (r) => `${r.display ?? r.name}${r.spec ? ` ${r.spec}` : ""}`;
145
+ function against(reqs, versionsOf, { file, source, kind }) {
146
+ const out = [];
147
+ for (const r of reqs) {
148
+ const have = versionsOf.get(r.name);
149
+ const at = `${file}${r.line ? `:${r.line}` : ""}${r.where ? ` ${r.where}` : ""}`;
150
+ if (!have || !have.length) { if (!r.marker) out.push(`${at}: ${describe(r)} has no ${kind} in ${source}`); continue; }
151
+ const parsed = have.map(parsePyVersion);
152
+ if (parsed.some((p) => p === null)) { out.push(`${source}: ${r.display ?? r.name} version ${have.join(", ")} is not a readable version`); continue; }
153
+ if (!parsed.some((p) => satisfies(p, r.clauses))) out.push(`${at}: ${describe(r)} is not satisfied by ${source}, which has ${have.join(", ")}`);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Findings for one directory. `names` = the directory's file names; `read(file)` takes repo-relative paths; `parseToml(text)` parses TOML.
160
+ */
161
+ export function pythonLockProblems({ dir, names, read, parseToml }) {
162
+ const out = []; const has = (n) => names.includes(n);
163
+ const f = (n) => join(dir, n);
164
+ const isPins = (n) => /pins?\.txt$/.test(n);
165
+ const reqFiles = names.filter((n) => /^requirements[^/]*\.txt$/.test(n) && !isPins(n));
166
+ const pinsFiles = names.filter(isPins);
167
+ const toml = (file) => { try { return parseToml(read(file)); } catch (e) { out.push(`${file}: cannot be parsed as TOML (${String(e.message).split("\n")[0]})`); return null; } };
168
+ const parsedTxt = new Map();
169
+ const reqsOf = (n) => { if (!parsedTxt.has(n)) { const r = parseRequirements(read(f(n))); parsedTxt.set(n, r); } return parsedTxt.get(n); };
170
+ const needPins = (n, r) => { for (const q of r.reqs) if (!q.pinned) out.push(`${f(n)}:${q.line}: ${describe(q)} is not an exact == pin in a lock file`); };
171
+ const pinMap = (r) => { const m = new Map(); for (const q of r.reqs) if (q.pinned) m.set(q.name, [...(m.get(q.name) ?? []), q.clauses[0].version.text]); return m; };
172
+ const problems = (n, r) => r.problems.forEach((p) => out.push(`${f(n)}:${p.line}: ${p.text}`));
173
+
174
+ // pins files
175
+ for (const n of pinsFiles) {
176
+ const pins = reqsOf(n); problems(n, pins); needPins(n, pins);
177
+ const stem = n.replace(/[._-]?pins?\.txt$/, "");
178
+ for (const src of [`${stem}.in`, `${stem}.txt`]) {
179
+ if (src === n || !has(src) || isPins(src)) continue;
180
+ const direct = reqsOf(src); problems(src, direct);
181
+ out.push(...against(direct.reqs, pinMap(pins), { file: f(src), source: f(n), kind: "pin" }));
182
+ break;
183
+ }
184
+ }
185
+ // requirements*.txt
186
+ const locks = [["uv.lock"], ["poetry.lock"]].map(([n]) => n).filter(has);
187
+ const lockMaps = locks.map((n) => { const doc = toml(f(n)); return doc ? { n, map: lockVersions(doc) } : null; }).filter(Boolean);
188
+ for (const n of reqFiles) {
189
+ const stem = n.replace(/\.txt$/, ""); const r = reqsOf(n);
190
+ if (has(`${stem}.in`)) {
191
+ problems(n, r); needPins(n, r);
192
+ const direct = reqsOf(`${stem}.in`); problems(`${stem}.in`, direct);
193
+ out.push(...against(direct.reqs, pinMap(r), { file: f(`${stem}.in`), source: f(n), kind: "pin" }));
194
+ } else if (lockMaps.length) {
195
+ problems(n, r);
196
+ for (const l of lockMaps) out.push(...against(r.reqs, l.map, { file: f(n), source: f(l.n), kind: "locked version" }));
197
+ } else if (pinsFiles.some((p) => p.replace(/[._-]?pins?\.txt$/, "") === stem)) { /* its pins file is the lock (checked above) */ }
198
+ }
199
+ // pyproject.toml
200
+ if (has("pyproject.toml") && lockMaps.length) {
201
+ const doc = toml(f("pyproject.toml"));
202
+ if (doc) {
203
+ const p = pyprojectRequirements(doc);
204
+ out.push(...p.problems.map((t) => `${f("pyproject.toml")} ${t}`));
205
+ for (const l of lockMaps) out.push(...against(p.reqs, l.map, { file: f("pyproject.toml"), source: f(l.n), kind: "locked version" }));
206
+ }
207
+ }
208
+ return out;
209
+ }