@clossys/launcher 0.3.1 → 0.5.0

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 (290) hide show
  1. package/README.md +1373 -60
  2. package/contracts/conversation-contract.md +2 -2
  3. package/contracts/product-ci-workflow.yml +74 -0
  4. package/contracts/repository-inventory.json +53 -0
  5. package/dist/admission-fixture.d.ts +168 -0
  6. package/dist/admission-fixture.d.ts.map +1 -0
  7. package/dist/admission-fixture.js +467 -0
  8. package/dist/admission-fixture.js.map +1 -0
  9. package/dist/admission.d.ts +124 -0
  10. package/dist/admission.d.ts.map +1 -0
  11. package/dist/admission.js +804 -0
  12. package/dist/admission.js.map +1 -0
  13. package/dist/agents-guide.d.ts +9 -0
  14. package/dist/agents-guide.d.ts.map +1 -0
  15. package/dist/agents-guide.js +26 -0
  16. package/dist/agents-guide.js.map +1 -0
  17. package/dist/apply-command-options.check.d.ts +12 -0
  18. package/dist/apply-command-options.check.d.ts.map +1 -0
  19. package/dist/apply-command-options.check.js +20 -0
  20. package/dist/apply-command-options.check.js.map +1 -0
  21. package/dist/apply-plan-cli.d.ts +39 -1
  22. package/dist/apply-plan-cli.d.ts.map +1 -1
  23. package/dist/apply-plan-cli.js +432 -15
  24. package/dist/apply-plan-cli.js.map +1 -1
  25. package/dist/apply-plan.d.ts +46 -59
  26. package/dist/apply-plan.d.ts.map +1 -1
  27. package/dist/apply-plan.js +112 -97
  28. package/dist/apply-plan.js.map +1 -1
  29. package/dist/apply-step-fixture.d.ts +87 -0
  30. package/dist/apply-step-fixture.d.ts.map +1 -0
  31. package/dist/apply-step-fixture.js +199 -0
  32. package/dist/apply-step-fixture.js.map +1 -0
  33. package/dist/apply-store.d.ts +93 -0
  34. package/dist/apply-store.d.ts.map +1 -0
  35. package/dist/apply-store.js +625 -0
  36. package/dist/apply-store.js.map +1 -0
  37. package/dist/approval-sheet.d.ts +21 -0
  38. package/dist/approval-sheet.d.ts.map +1 -0
  39. package/dist/approval-sheet.js +163 -0
  40. package/dist/approval-sheet.js.map +1 -0
  41. package/dist/body-command.d.ts +42 -0
  42. package/dist/body-command.d.ts.map +1 -0
  43. package/dist/body-command.js +143 -0
  44. package/dist/body-command.js.map +1 -0
  45. package/dist/change-set-contract.d.ts +403 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +781 -0
  48. package/dist/change-set-contract.js.map +1 -0
  49. package/dist/change-set-digest.d.ts +28 -0
  50. package/dist/change-set-digest.d.ts.map +1 -0
  51. package/dist/change-set-digest.js +65 -0
  52. package/dist/change-set-digest.js.map +1 -0
  53. package/dist/check-cli.d.ts.map +1 -1
  54. package/dist/check-cli.js +14 -3
  55. package/dist/check-cli.js.map +1 -1
  56. package/dist/cli.d.ts +17 -6
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +84 -23
  59. package/dist/cli.js.map +1 -1
  60. package/dist/core.d.ts +79 -22
  61. package/dist/core.d.ts.map +1 -1
  62. package/dist/core.js +843 -268
  63. package/dist/core.js.map +1 -1
  64. package/dist/dry-materialize.d.ts +63 -0
  65. package/dist/dry-materialize.d.ts.map +1 -0
  66. package/dist/dry-materialize.js +330 -0
  67. package/dist/dry-materialize.js.map +1 -0
  68. package/dist/existing-declaration-adoption.check.d.ts +2 -0
  69. package/dist/existing-declaration-adoption.check.d.ts.map +1 -0
  70. package/dist/existing-declaration-adoption.check.js +10 -0
  71. package/dist/existing-declaration-adoption.check.js.map +1 -0
  72. package/dist/generated/contract-schema.generated.d.ts +97 -0
  73. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  74. package/dist/generated/contract-schema.generated.js +496 -0
  75. package/dist/generated/contract-schema.generated.js.map +1 -0
  76. package/dist/generated/package-scope.generated.d.ts +6 -0
  77. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  78. package/dist/generated/package-scope.generated.js +10 -0
  79. package/dist/generated/package-scope.generated.js.map +1 -0
  80. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  81. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  82. package/dist/generated/plan-contracts.generated.js +3101 -0
  83. package/dist/generated/plan-contracts.generated.js.map +1 -0
  84. package/dist/host.d.ts.map +1 -1
  85. package/dist/host.js +11 -0
  86. package/dist/host.js.map +1 -1
  87. package/dist/identity.d.ts +15 -0
  88. package/dist/identity.d.ts.map +1 -0
  89. package/dist/identity.js +48 -0
  90. package/dist/identity.js.map +1 -0
  91. package/dist/index.d.ts +35 -5
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +19 -2
  94. package/dist/index.js.map +1 -1
  95. package/dist/inventory-adoption.d.ts +24 -5
  96. package/dist/inventory-adoption.d.ts.map +1 -1
  97. package/dist/inventory-adoption.js +70 -25
  98. package/dist/inventory-adoption.js.map +1 -1
  99. package/dist/inventory-choice.d.ts +40 -0
  100. package/dist/inventory-choice.d.ts.map +1 -0
  101. package/dist/inventory-choice.js +156 -0
  102. package/dist/inventory-choice.js.map +1 -0
  103. package/dist/inventory-contract.d.ts +89 -0
  104. package/dist/inventory-contract.d.ts.map +1 -0
  105. package/dist/inventory-contract.js +121 -0
  106. package/dist/inventory-contract.js.map +1 -0
  107. package/dist/key-editor.d.ts +30 -0
  108. package/dist/key-editor.d.ts.map +1 -0
  109. package/dist/key-editor.js +445 -0
  110. package/dist/key-editor.js.map +1 -0
  111. package/dist/ledger-contract.d.ts +190 -0
  112. package/dist/ledger-contract.d.ts.map +1 -0
  113. package/dist/ledger-contract.js +555 -0
  114. package/dist/ledger-contract.js.map +1 -0
  115. package/dist/ledger-trust.d.ts +90 -0
  116. package/dist/ledger-trust.d.ts.map +1 -0
  117. package/dist/ledger-trust.js +203 -0
  118. package/dist/ledger-trust.js.map +1 -0
  119. package/dist/lockfile-invariants.d.ts +48 -0
  120. package/dist/lockfile-invariants.d.ts.map +1 -0
  121. package/dist/lockfile-invariants.js +375 -0
  122. package/dist/lockfile-invariants.js.map +1 -0
  123. package/dist/lockfile-readers.d.ts +72 -0
  124. package/dist/lockfile-readers.d.ts.map +1 -0
  125. package/dist/lockfile-readers.js +713 -0
  126. package/dist/lockfile-readers.js.map +1 -0
  127. package/dist/lockfile-regen.d.ts +106 -0
  128. package/dist/lockfile-regen.d.ts.map +1 -0
  129. package/dist/lockfile-regen.js +760 -0
  130. package/dist/lockfile-regen.js.map +1 -0
  131. package/dist/lockfile-tool-env.d.ts +29 -0
  132. package/dist/lockfile-tool-env.d.ts.map +1 -0
  133. package/dist/lockfile-tool-env.js +111 -0
  134. package/dist/lockfile-tool-env.js.map +1 -0
  135. package/dist/materialize.d.ts +113 -0
  136. package/dist/materialize.d.ts.map +1 -0
  137. package/dist/materialize.js +881 -0
  138. package/dist/materialize.js.map +1 -0
  139. package/dist/observe-repository.d.ts +90 -0
  140. package/dist/observe-repository.d.ts.map +1 -0
  141. package/dist/observe-repository.js +1367 -0
  142. package/dist/observe-repository.js.map +1 -0
  143. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  144. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  145. package/dist/plan-bundle-setup-fixture.js +167 -0
  146. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  147. package/dist/plan-bundle.d.ts +256 -0
  148. package/dist/plan-bundle.d.ts.map +1 -0
  149. package/dist/plan-bundle.js +882 -0
  150. package/dist/plan-bundle.js.map +1 -0
  151. package/dist/plan-command.d.ts +29 -0
  152. package/dist/plan-command.d.ts.map +1 -0
  153. package/dist/plan-command.js +523 -0
  154. package/dist/plan-command.js.map +1 -0
  155. package/dist/plan-contract.d.ts +153 -0
  156. package/dist/plan-contract.d.ts.map +1 -0
  157. package/dist/plan-contract.js +61 -0
  158. package/dist/plan-contract.js.map +1 -0
  159. package/dist/plan-digest.d.ts +25 -0
  160. package/dist/plan-digest.d.ts.map +1 -0
  161. package/dist/plan-digest.js +106 -0
  162. package/dist/plan-digest.js.map +1 -0
  163. package/dist/plan-rules.d.ts +23 -0
  164. package/dist/plan-rules.d.ts.map +1 -0
  165. package/dist/plan-rules.js +177 -0
  166. package/dist/plan-rules.js.map +1 -0
  167. package/dist/planned-bundle.d.ts +20 -0
  168. package/dist/planned-bundle.d.ts.map +1 -0
  169. package/dist/planned-bundle.js +191 -0
  170. package/dist/planned-bundle.js.map +1 -0
  171. package/dist/product-repository.d.ts +4 -0
  172. package/dist/product-repository.d.ts.map +1 -1
  173. package/dist/product-repository.js +9 -1
  174. package/dist/product-repository.js.map +1 -1
  175. package/dist/provenance-gate.d.ts +48 -0
  176. package/dist/provenance-gate.d.ts.map +1 -0
  177. package/dist/provenance-gate.js +324 -0
  178. package/dist/provenance-gate.js.map +1 -0
  179. package/dist/pull-request-body.d.ts +45 -0
  180. package/dist/pull-request-body.d.ts.map +1 -0
  181. package/dist/pull-request-body.js +232 -0
  182. package/dist/pull-request-body.js.map +1 -0
  183. package/dist/registry-snapshot.d.ts +141 -0
  184. package/dist/registry-snapshot.d.ts.map +1 -0
  185. package/dist/registry-snapshot.js +483 -0
  186. package/dist/registry-snapshot.js.map +1 -0
  187. package/dist/release-age-edit.d.ts +52 -0
  188. package/dist/release-age-edit.d.ts.map +1 -0
  189. package/dist/release-age-edit.js +413 -0
  190. package/dist/release-age-edit.js.map +1 -0
  191. package/dist/root-entries.d.ts +36 -0
  192. package/dist/root-entries.d.ts.map +1 -0
  193. package/dist/root-entries.js +80 -0
  194. package/dist/root-entries.js.map +1 -0
  195. package/dist/setup-template-scripts.d.ts +36 -0
  196. package/dist/setup-template-scripts.d.ts.map +1 -0
  197. package/dist/setup-template-scripts.js +568 -0
  198. package/dist/setup-template-scripts.js.map +1 -0
  199. package/dist/setup-templates.d.ts +55 -0
  200. package/dist/setup-templates.d.ts.map +1 -0
  201. package/dist/setup-templates.js +438 -0
  202. package/dist/setup-templates.js.map +1 -0
  203. package/dist/skills.d.ts +34 -1
  204. package/dist/skills.d.ts.map +1 -1
  205. package/dist/skills.js +129 -17
  206. package/dist/skills.js.map +1 -1
  207. package/dist/status.d.ts +63 -0
  208. package/dist/status.d.ts.map +1 -0
  209. package/dist/status.js +539 -0
  210. package/dist/status.js.map +1 -0
  211. package/dist/types.d.ts +151 -13
  212. package/dist/types.d.ts.map +1 -1
  213. package/package.json +4 -4
  214. package/skeleton/README.md +14 -9
  215. package/skeleton/package.json +2 -1
  216. package/skill/SKILL.md +23 -7
  217. package/skill-catalogue/advisor/SKILL.md +59 -6
  218. package/skill-catalogue/architect/SKILL.md +2 -2
  219. package/skill-catalogue/bouncer/SKILL.md +2 -2
  220. package/skill-catalogue/builder/SKILL.md +2 -2
  221. package/skill-catalogue/butler/SKILL.md +2 -2
  222. package/skill-catalogue/controller/SKILL.md +2 -2
  223. package/skill-catalogue/customer/SKILL.md +2 -2
  224. package/skill-catalogue/designer/SKILL.md +4 -2
  225. package/skill-catalogue/giver/SKILL.md +2 -2
  226. package/skill-catalogue/influencer/SKILL.md +2 -2
  227. package/skill-catalogue/inspector/SKILL.md +2 -2
  228. package/skill-catalogue/integrator/SKILL.md +2 -2
  229. package/skill-catalogue/keeper/SKILL.md +2 -2
  230. package/skill-catalogue/launcher/SKILL.md +23 -7
  231. package/skill-catalogue/locksmith/SKILL.md +2 -2
  232. package/skill-catalogue/messenger/SKILL.md +2 -2
  233. package/skill-catalogue/observer/SKILL.md +2 -2
  234. package/skill-catalogue/publisher/SKILL.md +2 -2
  235. package/skill-catalogue/starter/SKILL.md +3 -2
  236. package/skill-catalogue/strategist/SKILL.md +12 -4
  237. package/skill-catalogue/writer/SKILL.md +2 -2
  238. package/src/admission-fixture.ts +585 -0
  239. package/src/admission.ts +819 -0
  240. package/src/agents-guide.ts +29 -0
  241. package/src/apply-command-options.check.ts +27 -0
  242. package/src/apply-plan-cli.ts +454 -14
  243. package/src/apply-plan.ts +112 -124
  244. package/src/apply-step-fixture.ts +236 -0
  245. package/src/apply-store.ts +584 -0
  246. package/src/approval-sheet.ts +170 -0
  247. package/src/body-command.ts +162 -0
  248. package/src/change-set-contract.ts +987 -0
  249. package/src/change-set-digest.ts +70 -0
  250. package/src/check-cli.ts +14 -3
  251. package/src/cli.ts +90 -22
  252. package/src/core.ts +973 -275
  253. package/src/dry-materialize.ts +353 -0
  254. package/src/existing-declaration-adoption.check.ts +12 -0
  255. package/src/generated/contract-schema.generated.ts +520 -0
  256. package/src/generated/package-scope.generated.ts +10 -0
  257. package/src/generated/plan-contracts.generated.ts +3101 -0
  258. package/src/host.ts +10 -0
  259. package/src/identity.ts +51 -0
  260. package/src/index.ts +74 -3
  261. package/src/inventory-adoption.ts +107 -29
  262. package/src/inventory-choice.ts +172 -0
  263. package/src/inventory-contract.ts +166 -0
  264. package/src/key-editor.ts +446 -0
  265. package/src/ledger-contract.ts +660 -0
  266. package/src/ledger-trust.ts +272 -0
  267. package/src/lockfile-invariants.ts +421 -0
  268. package/src/lockfile-readers.ts +749 -0
  269. package/src/lockfile-regen.ts +851 -0
  270. package/src/lockfile-tool-env.ts +131 -0
  271. package/src/materialize.ts +915 -0
  272. package/src/observe-repository.ts +1365 -0
  273. package/src/plan-bundle-setup-fixture.ts +200 -0
  274. package/src/plan-bundle.ts +1014 -0
  275. package/src/plan-command.ts +532 -0
  276. package/src/plan-contract.ts +179 -0
  277. package/src/plan-digest.ts +102 -0
  278. package/src/plan-rules.ts +188 -0
  279. package/src/planned-bundle.ts +211 -0
  280. package/src/product-repository.ts +10 -1
  281. package/src/provenance-gate.ts +352 -0
  282. package/src/pull-request-body.ts +261 -0
  283. package/src/registry-snapshot.ts +534 -0
  284. package/src/release-age-edit.ts +430 -0
  285. package/src/root-entries.ts +81 -0
  286. package/src/setup-template-scripts.ts +580 -0
  287. package/src/setup-templates.ts +479 -0
  288. package/src/skills.ts +161 -18
  289. package/src/status.ts +557 -0
  290. package/src/types.ts +148 -13
@@ -0,0 +1,851 @@
1
+ // Lockfile regeneration (RFC apply-approved-plan: T1, T7, V6, D7, D25).
2
+ //
3
+ // Regenerates one repository's lockfile with install scripts off, under an
4
+ // environment built from a literal allow-list (lockfile-tool-env.ts), with the
5
+ // publishing scope forced onto the publishing registry on the command line so
6
+ // no repository or parent configuration can redirect it. The repository tree
7
+ // is snapshotted before and after: anything but the lockfile changing is a
8
+ // violation, never an exemption. The result is then checked against I1 to I5.
9
+ // Nothing here reverts, repairs or retries.
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import type { Stats } from "node:fs";
14
+ import { lstat, readFile, readdir, readlink, realpath } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { isAbsolute, join } from "node:path";
17
+ import { compareCodeUnits, lockfilePath } from "./change-set-contract.js";
18
+ import type { LockfileName, PackageManagerKind } from "./change-set-contract.js";
19
+ import { PACKAGE_SCOPE } from "./generated/package-scope.generated.js";
20
+ import { checkLockfileInvariants } from "./lockfile-invariants.js";
21
+ import type { LockfileInvariantPackage, LockfileInvariantViolation, TransitiveCounts } from "./lockfile-invariants.js";
22
+ import type { LockfileFormat } from "./lockfile-readers.js";
23
+ import { lockfileToolEnv, prepareLockfileScratch } from "./lockfile-tool-env.js";
24
+
25
+ /** One process launch. `shell` is always false. */
26
+ export interface LockfileSpawnRequest {
27
+ readonly command: string;
28
+ readonly args: readonly string[];
29
+ readonly cwd: string;
30
+ readonly env: Readonly<Record<string, string>>;
31
+ readonly timeoutMs: number;
32
+ /** Bytes kept per stream; output past it is dropped. */
33
+ readonly maxBuffer: number;
34
+ }
35
+
36
+ export interface LockfileSpawnResult {
37
+ readonly status: number | null;
38
+ readonly stdout: string;
39
+ readonly stderr: string;
40
+ /** "not-found": the command could not be launched (ENOENT); "timeout": killed with SIGKILL at timeoutMs. */
41
+ readonly failure?: "not-found" | "timeout";
42
+ }
43
+
44
+ export type LockfileSpawn = (request: LockfileSpawnRequest) => Promise<LockfileSpawnResult>;
45
+
46
+ /** Collects at most `limit` bytes of one stream. */
47
+ function cappedSink(limit: number): { push(chunk: Buffer): void; text(): string } {
48
+ const chunks: Buffer[] = [];
49
+ let kept = 0;
50
+ return {
51
+ push(chunk) {
52
+ if (kept >= limit) return;
53
+ const slice = chunk.length > limit - kept ? chunk.subarray(0, limit - kept) : chunk;
54
+ chunks.push(slice);
55
+ kept += slice.length;
56
+ },
57
+ text: () => Buffer.concat(chunks).toString("utf8"),
58
+ };
59
+ }
60
+
61
+ /** Grace period after SIGKILL before giving up on `close` and settling anyway (fix 5). */
62
+ const LOCKFILE_TOOL_KILL_GRACE_MS = 2_000;
63
+
64
+ /**
65
+ * The default spawn port: node:child_process spawn with shell false, SIGKILL on timeout, capped capture.
66
+ * Off Windows the child leads its own process group, so the kill reaches any grandchild holding the pipes open.
67
+ * A descendant that leaves the process group (for example with `setsid`) and keeps stdout or stderr open can
68
+ * still keep `close` from ever firing; a short grace timer after the SIGKILL settles the promise anyway.
69
+ */
70
+ export function spawnLockfileTool(request: LockfileSpawnRequest): Promise<LockfileSpawnResult> {
71
+ return new Promise((resolvePromise, rejectPromise) => {
72
+ const stdout = cappedSink(request.maxBuffer);
73
+ const stderr = cappedSink(request.maxBuffer);
74
+ const group = process.platform !== "win32";
75
+ let settled = false;
76
+ let timedOut = false;
77
+ let timer: NodeJS.Timeout | undefined;
78
+ let graceTimer: NodeJS.Timeout | undefined;
79
+ const clearTimers = (): void => {
80
+ if (timer !== undefined) clearTimeout(timer);
81
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
82
+ };
83
+ const settle = (result: LockfileSpawnResult): void => {
84
+ if (settled) return;
85
+ settled = true;
86
+ clearTimers();
87
+ resolvePromise(result);
88
+ };
89
+ let child: ReturnType<typeof spawn>;
90
+ try {
91
+ child = spawn(request.command, [...request.args], { cwd: request.cwd, env: { ...request.env }, shell: false, detached: group, stdio: ["ignore", "pipe", "pipe"] });
92
+ } catch (error) {
93
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") settle({ status: null, stdout: "", stderr: "", failure: "not-found" });
94
+ else rejectPromise(error);
95
+ return;
96
+ }
97
+ child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
98
+ child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
99
+ child.on("error", (error: NodeJS.ErrnoException) => {
100
+ if (error.code === "ENOENT") settle({ status: null, stdout: stdout.text(), stderr: stderr.text(), failure: "not-found" });
101
+ else if (child.pid === undefined) settle({ status: null, stdout: stdout.text(), stderr: `${stderr.text()}${error.message}` });
102
+ });
103
+ child.on("close", (status: number | null) => {
104
+ settle({ status, stdout: stdout.text(), stderr: stderr.text(), ...(timedOut ? { failure: "timeout" as const } : {}) });
105
+ });
106
+ timer = setTimeout(() => {
107
+ timedOut = true;
108
+ try {
109
+ if (group && child.pid !== undefined) process.kill(-child.pid, "SIGKILL");
110
+ else child.kill("SIGKILL");
111
+ } catch {
112
+ child.kill("SIGKILL");
113
+ }
114
+ graceTimer = setTimeout(() => {
115
+ child.stdout?.destroy();
116
+ child.stderr?.destroy();
117
+ settle({ status: null, stdout: stdout.text(), stderr: stderr.text(), failure: "timeout" });
118
+ }, LOCKFILE_TOOL_KILL_GRACE_MS);
119
+ }, request.timeoutMs);
120
+ });
121
+ }
122
+
123
+ export interface LockfileRegenInput {
124
+ /** Absolute, real (realpath-equal) directory to regenerate in. */
125
+ readonly root: string;
126
+ readonly packageManager: PackageManagerKind;
127
+ /** The observed lockfile name; lockfilePath() resolves the path. */
128
+ readonly lockfile: LockfileName;
129
+ /** Exact tool version; required for pnpm and Yarn, optional for npm. */
130
+ readonly toolVersion: string | null;
131
+ /** The lockfile text at the base commit, read by the caller. */
132
+ readonly baseLockfile: string;
133
+ readonly packages: readonly LockfileInvariantPackage[];
134
+ readonly now: () => Date;
135
+ }
136
+
137
+ /** Ports. Neither is reachable from a CLI. `registry` is a test-only override of PACKAGE_SCOPE.registry. */
138
+ export interface LockfileRegenPorts {
139
+ readonly spawn?: LockfileSpawn;
140
+ readonly registry?: string;
141
+ }
142
+
143
+ export type LockfileRegenIndeterminateReason =
144
+ | "root-not-canonical"
145
+ | "manifest-absent"
146
+ | "lockfile-absent"
147
+ | "lockfile-is-link"
148
+ | "lockfile-unreadable"
149
+ | "lockfile-format-unsupported"
150
+ | "package-manager-unsupported"
151
+ | "package-manager-version-unknown"
152
+ | "registry-config-needs-credential"
153
+ | "package-manager-config-unsafe"
154
+ | "package-manager-config-unrecognized"
155
+ | "tool-unavailable"
156
+ | "tool-failed"
157
+ | "tool-timeout";
158
+
159
+ export type LockfileRegenViolatedRule = "lockfile-not-at-base" | "tree-changed-outside-lockfile" | "lockfile-invariants";
160
+
161
+ export interface LockfileTooling {
162
+ readonly tool: "npm" | "pnpm" | "yarn";
163
+ /** `<tool> --version` output, trimmed; null when it could not be read. */
164
+ readonly version: string | null;
165
+ }
166
+
167
+ export type LockfileRegenResult =
168
+ | {
169
+ readonly verdict: "satisfied";
170
+ readonly path: string;
171
+ readonly before: string;
172
+ readonly after: string;
173
+ readonly tooling: LockfileTooling;
174
+ readonly transitive: TransitiveCounts;
175
+ }
176
+ | {
177
+ readonly verdict: "violated";
178
+ readonly rule: LockfileRegenViolatedRule;
179
+ readonly violations: readonly LockfileInvariantViolation[];
180
+ /** Changed paths (relative, sorted) for tree-changed-outside-lockfile; empty otherwise. */
181
+ readonly paths: readonly string[];
182
+ readonly tooling: LockfileTooling | null;
183
+ }
184
+ | {
185
+ readonly verdict: "indeterminate";
186
+ readonly reason: LockfileRegenIndeterminateReason;
187
+ /** At most 4 KiB of tool output with scratch and root replaced by $SCRATCH / $ROOT; tool-failed and tool-timeout only. */
188
+ readonly output?: string;
189
+ readonly tooling: LockfileTooling | null;
190
+ };
191
+
192
+ /** Per-launch limits (both the version probe and the regeneration). */
193
+ export const LOCKFILE_TOOL_TIMEOUT_MS = 180_000;
194
+ export const LOCKFILE_TOOL_MAX_BUFFER = 2_000_000;
195
+ /** Bytes of tool output kept in an indeterminate result. */
196
+ export const LOCKFILE_OUTPUT_TAIL_BYTES = 4096;
197
+
198
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Repository configuration scan
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /** Repository configuration files a tool reads; absent files are omitted. The caller passes only the files the tool reads. */
205
+ export interface RepositoryConfigFiles {
206
+ readonly npmrc?: string;
207
+ readonly pnpmWorkspace?: string;
208
+ readonly yarnrc?: string;
209
+ }
210
+
211
+ export type RepositoryConfigRefusal = "registry-config-needs-credential" | "package-manager-config-unsafe" | "package-manager-config-unrecognized";
212
+
213
+ export type RepositoryConfigScan =
214
+ | {
215
+ readonly refused: false;
216
+ /** `.npmrc` sets `min-release-age`: npm then runs with `--min-release-age=0` (D25). */
217
+ readonly minReleaseAge: boolean;
218
+ /** `.npmrc` sets `before`: npm then runs with `--before=<now>` (D25). */
219
+ readonly before: boolean;
220
+ }
221
+ | {
222
+ readonly refused: true;
223
+ readonly reason: RepositoryConfigRefusal;
224
+ /** The file and key that refused, for example `.npmrc:_authToken`. */
225
+ readonly finding: string;
226
+ };
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // .npmrc: a strict allow-list grammar that fails closed (fix 1). The scan
230
+ // tokenises exactly the subset of npm's ini decoding this file is willing to
231
+ // accept -- not npm's own splitting -- so nothing outside the grammar below
232
+ // can reach npm at all, whatever npm's ini decoder would have made of it.
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /** One line matching the allow-list grammar exactly: a bare, lowercase, hyphenated key and an unquoted value. */
236
+ const NPMRC_LINE = /^([a-z][a-z0-9-]*)[ \t]*=[ \t]*([A-Za-z0-9._:^~+-]+)$/;
237
+
238
+ /** `.npmrc` keys this runner treats as inert, each mapped to the value form it must match exactly (fix 1). */
239
+ const NPMRC_ALLOWED_KEYS: ReadonlyMap<string, RegExp> = new Map([
240
+ ["engine-strict", /^(?:true|false)$/],
241
+ ["save-exact", /^(?:true|false)$/],
242
+ ["save-prefix", /^[\^~]$/],
243
+ ["legacy-peer-deps", /^(?:true|false)$/],
244
+ ["strict-peer-deps", /^(?:true|false)$/],
245
+ ["auto-install-peers", /^(?:true|false)$/],
246
+ ["strict-peer-dependencies", /^(?:true|false)$/],
247
+ ["fund", /^(?:true|false)$/],
248
+ ["audit", /^(?:true|false)$/],
249
+ ["update-notifier", /^(?:true|false)$/],
250
+ ["progress", /^(?:true|false)$/],
251
+ ["loglevel", /^(?:silent|error|warn|notice|http|info|verbose|silly)$/],
252
+ ["color", /^(?:true|false|always)$/],
253
+ ["min-release-age", /^[0-9]+$/],
254
+ ["before", /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)?$/],
255
+ ]);
256
+
257
+ /**
258
+ * `.npmrc` keys that, when the line is otherwise inside the grammar, still refuse as a credential rather
259
+ * than as unrecognized (today's precedence, kept). Every other credential spelling npm honours -- `_auth`,
260
+ * `_authToken`, `_password`, and any `//host/:...` key -- starts with a character the grammar's key
261
+ * pattern never allows, so a line naming one of those never matches NPMRC_LINE at all: the whole file is
262
+ * simply package-manager-config-unrecognized.
263
+ */
264
+ const NPMRC_CREDENTIAL_KEYS: ReadonlySet<string> = new Set(["username", "certfile", "keyfile"]);
265
+
266
+ /** Every byte the allow-list grammar accepts: tab, LF, and printable ASCII. No `\r`, ever. */
267
+ function isNpmrcByteSafe(text: string): boolean {
268
+ for (let i = 0; i < text.length; i += 1) {
269
+ const code = text.charCodeAt(i);
270
+ if (code === 0x09 || code === 0x0a) continue;
271
+ if (code >= 0x20 && code <= 0x7e) continue;
272
+ return false;
273
+ }
274
+ return true;
275
+ }
276
+
277
+ interface NpmrcScan {
278
+ readonly credential: string[];
279
+ readonly unrecognized: string[];
280
+ readonly minReleaseAge: boolean;
281
+ readonly before: boolean;
282
+ }
283
+
284
+ /**
285
+ * Scans `.npmrc` against the strict allow-list grammar (fix 1). The file must be pure ASCII with no `\r`
286
+ * anywhere and no `${`, split on `\n` only; every non-blank, non-comment line must match NPMRC_LINE
287
+ * exactly, name an allow-listed key at most once, and hold that key's required value form. Anything else
288
+ * is package-manager-config-unrecognized, except the three credential-bearing keys the grammar can still
289
+ * spell (username, certfile, keyfile), which keep registry-config-needs-credential.
290
+ */
291
+ function scanNpmrc(text: string): NpmrcScan {
292
+ const credential: string[] = [];
293
+ const unrecognized: string[] = [];
294
+ let minReleaseAge = false;
295
+ let before = false;
296
+ if (!isNpmrcByteSafe(text) || text.includes("${")) return { credential, unrecognized: [".npmrc"], minReleaseAge, before };
297
+ const seen = new Set<string>();
298
+ for (const rawLine of text.split("\n")) {
299
+ const trimmed = rawLine.replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
300
+ if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
301
+ const match = NPMRC_LINE.exec(trimmed);
302
+ if (match === null) {
303
+ unrecognized.push(".npmrc");
304
+ continue;
305
+ }
306
+ const key = match[1] as string;
307
+ const value = match[2] as string;
308
+ if (key === "min-release-age") minReleaseAge = true;
309
+ if (key === "before") before = true;
310
+ if (seen.has(key)) {
311
+ unrecognized.push(`.npmrc:${key}`);
312
+ continue;
313
+ }
314
+ seen.add(key);
315
+ const pattern = NPMRC_ALLOWED_KEYS.get(key);
316
+ if (pattern === undefined) {
317
+ if (NPMRC_CREDENTIAL_KEYS.has(key)) credential.push(`.npmrc:${key}`);
318
+ else unrecognized.push(`.npmrc:${key}`);
319
+ continue;
320
+ }
321
+ if (!pattern.test(value)) unrecognized.push(`.npmrc:${key}`);
322
+ }
323
+ return { credential, unrecognized, minReleaseAge, before };
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // pnpm-workspace.yaml and .yarnrc.yml: a small, restricted line grammar (fix
328
+ // 2, reused by fix 3), never a general YAML parser. Every anchor, alias,
329
+ // tag, document marker, directive, merge key, explicit key, block scalar
330
+ // and flow collection is refused wherever it appears; a top-level key must
331
+ // be plain (never quoted) and on the caller's allow-list; nested keys and
332
+ // scalars are plain or single-quoted only. Anything outside this is
333
+ // refused. Double quotes are refused everywhere: this reader never
334
+ // interprets an escape, so it cannot tell a plain "D" from a \x44 hiding one.
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /** Every byte the restricted YAML grammar accepts: LF and printable ASCII. No `\r`, no tab, ever. */
338
+ function isRestrictedYamlByteSafe(text: string): boolean {
339
+ for (let i = 0; i < text.length; i += 1) {
340
+ const code = text.charCodeAt(i);
341
+ if (code === 0x0a) continue;
342
+ if (code >= 0x20 && code <= 0x7e) continue;
343
+ return false;
344
+ }
345
+ return true;
346
+ }
347
+
348
+ /** The end index of a single-quoted scalar starting at `text[0]` (`''` decodes to one `'`), or undefined when unterminated. */
349
+ function findSingleQuoteEnd(text: string): number | undefined {
350
+ let i = 1;
351
+ while (i < text.length) {
352
+ if (text[i] === "'") {
353
+ if (text[i + 1] === "'") {
354
+ i += 2;
355
+ continue;
356
+ }
357
+ return i;
358
+ }
359
+ i += 1;
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ interface RestrictedScalar {
365
+ readonly text: string;
366
+ readonly quoted: boolean;
367
+ }
368
+
369
+ /** Reads a plain or single-quoted scalar filling the rest of a line (after trailing spaces are dropped). Refuses a double quote, a bare YAML-syntax bypass, or any leftover text after a quoted scalar closes. */
370
+ function readRestrictedScalar(text: string): RestrictedScalar | undefined {
371
+ const value = text.replace(/[ \t]+$/, "");
372
+ if (value === "") return undefined;
373
+ if (value.startsWith("'")) {
374
+ const end = findSingleQuoteEnd(value);
375
+ if (end === undefined || end !== value.length - 1) return undefined;
376
+ return { text: value.slice(1, end).replace(/''/g, "'"), quoted: true };
377
+ }
378
+ if (value.startsWith('"')) return undefined;
379
+ if (/^[&*!|>]/.test(value)) return undefined;
380
+ if (/[{}[\]]/.test(value) || value.includes("#") || value.includes("<<")) return undefined;
381
+ return { text: value, quoted: false };
382
+ }
383
+
384
+ /** Reads a plain (`[A-Za-z0-9._-]+`) or single-quoted key at the start of `text`. Refuses a double-quoted key. */
385
+ function readRestrictedKey(text: string): (RestrictedScalar & { readonly consumed: number }) | undefined {
386
+ if (text.startsWith("'")) {
387
+ const end = findSingleQuoteEnd(text);
388
+ if (end === undefined) return undefined;
389
+ return { text: text.slice(1, end).replace(/''/g, "'"), quoted: true, consumed: end + 1 };
390
+ }
391
+ if (text.startsWith('"')) return undefined;
392
+ const match = /^[A-Za-z0-9._-]+/.exec(text);
393
+ if (match === null) return undefined;
394
+ return { text: match[0], quoted: false, consumed: match[0].length };
395
+ }
396
+
397
+ interface RestrictedYamlLine {
398
+ readonly indent: number;
399
+ readonly sequence: boolean;
400
+ readonly key?: string;
401
+ readonly keyQuoted: boolean;
402
+ readonly value?: RestrictedScalar;
403
+ }
404
+
405
+ /**
406
+ * Parses one non-blank, non-full-comment line under the restricted grammar: a sequence item (`- <scalar>`),
407
+ * a mapping entry with an inline scalar (`<key>: <scalar>`), or a mapping entry with no inline value (a
408
+ * nested body follows). Undefined when the line holds a document marker, a directive, an explicit key, a
409
+ * merge key, a tag, an anchor, an alias, a block scalar, a flow collection, a trailing comment, or anything
410
+ * else outside this shape.
411
+ */
412
+ function parseRestrictedYamlLine(line: string): RestrictedYamlLine | undefined {
413
+ const indent = line.length - line.trimStart().length;
414
+ let rest = line.slice(indent);
415
+ let sequence = false;
416
+ if (rest.startsWith("- ")) {
417
+ sequence = true;
418
+ rest = rest.slice(2);
419
+ } else if (rest === "-") {
420
+ sequence = true;
421
+ rest = "";
422
+ }
423
+ if (/^(?:---|\.\.\.)(?:\s|$)/.test(rest) || rest.startsWith("%") || rest.startsWith("? ") || rest === "?" || rest.startsWith("<<")) return undefined;
424
+
425
+ if (sequence) {
426
+ const value = readRestrictedScalar(rest);
427
+ if (value === undefined) return undefined;
428
+ return { indent, sequence: true, keyQuoted: false, value };
429
+ }
430
+
431
+ const keyRead = readRestrictedKey(rest);
432
+ if (keyRead === undefined) return undefined;
433
+ const afterKey = rest.slice(keyRead.consumed);
434
+ if (afterKey !== ":" && !afterKey.startsWith(": ")) return undefined;
435
+ const valuePart = afterKey === ":" ? "" : afterKey.slice(2).replace(/^ +/, "");
436
+ if (valuePart === "") return { indent, sequence: false, key: keyRead.text, keyQuoted: keyRead.quoted };
437
+ const value = readRestrictedScalar(valuePart);
438
+ if (value === undefined) return undefined;
439
+ return { indent, sequence: false, key: keyRead.text, keyQuoted: keyRead.quoted, value };
440
+ }
441
+
442
+ interface RestrictedYamlEntry {
443
+ readonly keyQuoted: boolean;
444
+ readonly value?: RestrictedScalar;
445
+ readonly bodyStart: number;
446
+ readonly bodyEnd: number;
447
+ }
448
+
449
+ /** The indentation of the first non-blank, non-comment line in `lines[start, end)`, or undefined when the range holds none. */
450
+ function firstIndent(lines: readonly string[], start: number, end: number): number | undefined {
451
+ for (let i = start; i < end; i += 1) {
452
+ const trimmedLeft = (lines[i] as string).replace(/^ */, "");
453
+ if (trimmedLeft !== "" && !trimmedLeft.startsWith("#")) return (lines[i] as string).length - trimmedLeft.length;
454
+ }
455
+ return undefined;
456
+ }
457
+
458
+ /** Parses one level of restricted-grammar mapping entries over `lines[start, end)`, all at `indent`. Undefined on a malformed line, a sequence item, a duplicate key, or an entry at the wrong indent. */
459
+ function parseRestrictedYamlLevel(lines: readonly string[], start: number, end: number, indent: number): Map<string, RestrictedYamlEntry> | undefined {
460
+ const result = new Map<string, RestrictedYamlEntry>();
461
+ let i = start;
462
+ while (i < end) {
463
+ const raw = lines[i] as string;
464
+ const trimmedLeft = raw.replace(/^ */, "");
465
+ if (trimmedLeft === "" || trimmedLeft.startsWith("#")) {
466
+ i += 1;
467
+ continue;
468
+ }
469
+ if (raw.length - trimmedLeft.length !== indent) return undefined;
470
+ const parsed = parseRestrictedYamlLine(raw);
471
+ if (parsed === undefined || parsed.sequence || parsed.key === undefined || result.has(parsed.key)) return undefined;
472
+ let j = i + 1;
473
+ while (j < end) {
474
+ const nextTrimmedLeft = (lines[j] as string).replace(/^ */, "");
475
+ if (nextTrimmedLeft !== "" && !nextTrimmedLeft.startsWith("#") && (lines[j] as string).length - nextTrimmedLeft.length <= indent) break;
476
+ j += 1;
477
+ }
478
+ if (parsed.value !== undefined && firstIndent(lines, i + 1, j) !== undefined) return undefined; // both an inline value and a nested body
479
+ result.set(parsed.key, { keyQuoted: parsed.keyQuoted, value: parsed.value, bodyStart: i + 1, bodyEnd: j });
480
+ i = j;
481
+ }
482
+ return result;
483
+ }
484
+
485
+ /** Whether every item of a restricted-grammar block sequence over `lines[start, end)` is a scalar matching `pattern` (unquoted) or single-quoted (any content). An empty sequence is accepted. */
486
+ function validRestrictedSequence(lines: readonly string[], start: number, end: number, pattern: RegExp): boolean {
487
+ const indent = firstIndent(lines, start, end);
488
+ if (indent === undefined) return true;
489
+ for (let i = start; i < end; i += 1) {
490
+ const raw = lines[i] as string;
491
+ const trimmedLeft = raw.replace(/^ */, "");
492
+ if (trimmedLeft === "" || trimmedLeft.startsWith("#")) continue;
493
+ if (raw.length - trimmedLeft.length !== indent) return false;
494
+ const parsed = parseRestrictedYamlLine(raw);
495
+ if (parsed === undefined || !parsed.sequence || parsed.value === undefined) return false;
496
+ if (!parsed.value.quoted && !pattern.test(parsed.value.text)) return false;
497
+ }
498
+ return true;
499
+ }
500
+
501
+ /** Whether every entry of a restricted-grammar one-level block mapping over `lines[start, end)` has a scalar value matching `pattern` (unquoted) or single-quoted (any content). An empty mapping is accepted. */
502
+ function validRestrictedMapping(lines: readonly string[], start: number, end: number, pattern: RegExp): boolean {
503
+ const indent = firstIndent(lines, start, end);
504
+ if (indent === undefined) return true;
505
+ const level = parseRestrictedYamlLevel(lines, start, end, indent);
506
+ if (level === undefined) return false;
507
+ for (const entry of level.values()) {
508
+ if (entry.value === undefined || (!entry.value.quoted && !pattern.test(entry.value.text))) return false;
509
+ }
510
+ return true;
511
+ }
512
+
513
+ /** Whether every entry of a restricted-grammar two-level block mapping (name -> mapping of scalar values matching `pattern`) over `lines[start, end)` is well-formed. An empty mapping is accepted. */
514
+ function validRestrictedNestedMapping(lines: readonly string[], start: number, end: number, pattern: RegExp): boolean {
515
+ const indent = firstIndent(lines, start, end);
516
+ if (indent === undefined) return true;
517
+ const level = parseRestrictedYamlLevel(lines, start, end, indent);
518
+ if (level === undefined) return false;
519
+ for (const entry of level.values()) {
520
+ if (entry.value !== undefined) return false;
521
+ if (!validRestrictedMapping(lines, entry.bodyStart, entry.bodyEnd, pattern)) return false;
522
+ }
523
+ return true;
524
+ }
525
+
526
+ type RestrictedYamlKeyShape =
527
+ | { readonly kind: "inline"; readonly pattern: RegExp }
528
+ | { readonly kind: "sequence"; readonly pattern: RegExp }
529
+ | { readonly kind: "mapping"; readonly pattern: RegExp }
530
+ | { readonly kind: "nestedMapping"; readonly pattern: RegExp };
531
+
532
+ const PLAIN_SCALAR = /^[A-Za-z0-9._/^~<>=+][A-Za-z0-9._/^~<>=+*@-]*$/;
533
+ const BOOL_SCALAR = /^(?:true|false)$/;
534
+ const DIGIT_SCALAR = /^[0-9]+$/;
535
+
536
+ /** `pnpm-workspace.yaml` top-level keys this runner treats as inert, with the shape each must hold (fix 2). None selects a source, registry, transport, hook or executable; the allow-list replaces PNPM_WORKSPACE_UNSAFE_KEYS entirely, so `configDependencies`, every pnpm setting key and `<<` are refused by omission. */
537
+ const PNPM_WORKSPACE_KEYS: Readonly<Record<string, RestrictedYamlKeyShape>> = {
538
+ packages: { kind: "sequence", pattern: PLAIN_SCALAR },
539
+ catalog: { kind: "mapping", pattern: PLAIN_SCALAR },
540
+ catalogs: { kind: "nestedMapping", pattern: PLAIN_SCALAR },
541
+ minimumReleaseAge: { kind: "inline", pattern: DIGIT_SCALAR },
542
+ minimumReleaseAgeExclude: { kind: "sequence", pattern: PLAIN_SCALAR },
543
+ onlyBuiltDependencies: { kind: "sequence", pattern: PLAIN_SCALAR },
544
+ ignoredBuiltDependencies: { kind: "sequence", pattern: PLAIN_SCALAR },
545
+ };
546
+
547
+ /** `.yarnrc.yml` top-level keys this runner treats as inert (fix 3). Everything else, including `yarnPath`, `plugins` and every `npm*Registry*`/`npmAuth*`/`httpProxy`/`httpsProxy` key, is unrecognized by omission. */
548
+ const YARNRC_KEYS: Readonly<Record<string, RestrictedYamlKeyShape>> = {
549
+ nodeLinker: { kind: "inline", pattern: PLAIN_SCALAR },
550
+ enableTelemetry: { kind: "inline", pattern: /^false$/ },
551
+ enableGlobalCache: { kind: "inline", pattern: BOOL_SCALAR },
552
+ npmMinimalAgeGate: { kind: "inline", pattern: PLAIN_SCALAR },
553
+ npmPreapprovedPackages: { kind: "sequence", pattern: PLAIN_SCALAR },
554
+ };
555
+
556
+ /** `.yarnrc.yml` keys that keep today's registry-config-needs-credential wherever in the document they appear, even nested. */
557
+ const YARNRC_CREDENTIAL_KEYS: ReadonlySet<string> = new Set(["npmAuthToken", "npmAuthIdent"]);
558
+
559
+ /** Every mapping key the restricted-grammar parser can identify anywhere in `text`, ignoring lines it cannot parse. Used only to keep the credential precedence for a key named deep inside an otherwise-refused document. */
560
+ function restrictedYamlAllKeys(text: string): string[] {
561
+ const keys: string[] = [];
562
+ for (const raw of text.split("\n")) {
563
+ const trimmedLeft = raw.replace(/^ */, "");
564
+ if (trimmedLeft === "" || trimmedLeft.startsWith("#")) continue;
565
+ const parsed = parseRestrictedYamlLine(raw);
566
+ if (parsed !== undefined && !parsed.sequence && parsed.key !== undefined) keys.push(parsed.key);
567
+ }
568
+ return keys;
569
+ }
570
+
571
+ /** Scans a restricted-grammar YAML document (`pnpm-workspace.yaml` or `.yarnrc.yml`) against `keys`: every top-level key must be plain (never quoted), on the allow-list, and hold its required shape. Returns the first offending key, or undefined when the whole document is accepted. */
572
+ function scanRestrictedYamlTopLevel(text: string, keys: Readonly<Record<string, RestrictedYamlKeyShape>>): string | undefined {
573
+ if (!isRestrictedYamlByteSafe(text) || text.includes("${")) return "";
574
+ const lines = text.split("\n");
575
+ const top = parseRestrictedYamlLevel(lines, 0, lines.length, 0);
576
+ if (top === undefined) return "";
577
+ for (const [key, entry] of top) {
578
+ if (key.length === 0 || entry.keyQuoted) return key.length === 0 ? "" : key;
579
+ const shape = keys[key];
580
+ if (shape === undefined) return key;
581
+ if (shape.kind === "inline") {
582
+ if (entry.value === undefined || entry.value.quoted || !shape.pattern.test(entry.value.text)) return key;
583
+ continue;
584
+ }
585
+ if (entry.value !== undefined) return key;
586
+ if (shape.kind === "sequence" && !validRestrictedSequence(lines, entry.bodyStart, entry.bodyEnd, shape.pattern)) return key;
587
+ if (shape.kind === "mapping" && !validRestrictedMapping(lines, entry.bodyStart, entry.bodyEnd, shape.pattern)) return key;
588
+ if (shape.kind === "nestedMapping" && !validRestrictedNestedMapping(lines, entry.bodyStart, entry.bodyEnd, shape.pattern)) return key;
589
+ }
590
+ return undefined;
591
+ }
592
+
593
+ /**
594
+ * Scans repository package-manager configuration against the allow-list grammars above (fixes 1 to 3). A
595
+ * credential finding wins over an unsafe or unrecognized one; a finding from `.npmrc` wins over one from
596
+ * `pnpm-workspace.yaml` (the only two files ever scanned together). Pure; never throws.
597
+ */
598
+ export function scanRepositoryConfig(files: RepositoryConfigFiles): RepositoryConfigScan {
599
+ const credential: string[] = [];
600
+ const other: { readonly reason: RepositoryConfigRefusal; readonly finding: string }[] = [];
601
+ let minReleaseAge = false;
602
+ let before = false;
603
+ if (files.npmrc !== undefined) {
604
+ const scan = scanNpmrc(files.npmrc);
605
+ credential.push(...scan.credential);
606
+ for (const finding of scan.unrecognized) other.push({ reason: "package-manager-config-unrecognized", finding });
607
+ minReleaseAge = scan.minReleaseAge;
608
+ before = scan.before;
609
+ }
610
+ if (files.pnpmWorkspace !== undefined) {
611
+ const offending = scanRestrictedYamlTopLevel(files.pnpmWorkspace, PNPM_WORKSPACE_KEYS);
612
+ if (offending !== undefined) other.push({ reason: "package-manager-config-unsafe", finding: offending.length > 0 ? `pnpm-workspace.yaml:${offending}` : "pnpm-workspace.yaml" });
613
+ }
614
+ if (files.yarnrc !== undefined) {
615
+ for (const key of restrictedYamlAllKeys(files.yarnrc)) if (YARNRC_CREDENTIAL_KEYS.has(key)) credential.push(`.yarnrc.yml:${key}`);
616
+ const offending = scanRestrictedYamlTopLevel(files.yarnrc, YARNRC_KEYS);
617
+ if (offending !== undefined && credential.length === 0) other.push({ reason: "package-manager-config-unrecognized", finding: offending.length > 0 ? `.yarnrc.yml:${offending}` : ".yarnrc.yml" });
618
+ }
619
+ if (credential.length > 0) return { refused: true, reason: "registry-config-needs-credential", finding: credential[0] ?? "" };
620
+ if (other.length > 0) return { refused: true, reason: other[0]!.reason, finding: other[0]!.finding };
621
+ return { refused: false, minReleaseAge, before };
622
+ }
623
+
624
+ /** The configuration files each tool reads from the repository root. */
625
+ const CONFIG_FILES: Readonly<Record<"npm" | "pnpm" | "yarn", readonly (readonly [keyof RepositoryConfigFiles, string])[]>> = {
626
+ npm: [["npmrc", ".npmrc"]],
627
+ pnpm: [["npmrc", ".npmrc"], ["pnpmWorkspace", "pnpm-workspace.yaml"]],
628
+ yarn: [["yarnrc", ".yarnrc.yml"]],
629
+ };
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // Tree footprint
633
+ // ---------------------------------------------------------------------------
634
+
635
+ const sha256hex = (bytes: Buffer | string): string => createHash("sha256").update(bytes).digest("hex");
636
+
637
+ /**
638
+ * Every entry under root by relative `/` path, without following links: type, mode, and the sha256
639
+ * of a file or the target of a link. Top-level `.git` is left out; top-level `node_modules` is its
640
+ * sorted entry names only.
641
+ */
642
+ async function snapshotTree(root: string): Promise<Map<string, string>> {
643
+ const entries = new Map<string, string>();
644
+ const walk = async (directory: string, relative: string): Promise<void> => {
645
+ const names = (await readdir(directory)).sort(compareCodeUnits);
646
+ for (const name of names) {
647
+ if (relative === "" && name === ".git") continue;
648
+ const path = relative === "" ? name : `${relative}/${name}`;
649
+ const absolute = join(directory, name);
650
+ const stats = await lstat(absolute);
651
+ const mode = (stats.mode & 0o7777).toString(8);
652
+ if (stats.isSymbolicLink()) entries.set(path, `symlink ${mode} ${await readlink(absolute)}`);
653
+ else if (stats.isFile()) entries.set(path, `file ${mode} ${sha256hex(await readFile(absolute))}`);
654
+ else if (stats.isDirectory() && relative === "" && name === "node_modules") entries.set(path, `names ${mode} ${JSON.stringify((await readdir(absolute)).sort(compareCodeUnits))}`);
655
+ else if (stats.isDirectory()) {
656
+ entries.set(path, `dir ${mode}`);
657
+ await walk(absolute, path);
658
+ } else entries.set(path, `other ${mode}`);
659
+ }
660
+ };
661
+ await walk(root, "");
662
+ return entries;
663
+ }
664
+
665
+ function changedPaths(before: ReadonlyMap<string, string>, after: ReadonlyMap<string, string>, lockfile: string): string[] {
666
+ const paths = new Set<string>();
667
+ for (const [path, entry] of before) if (after.get(path) !== entry) paths.add(path);
668
+ for (const path of after.keys()) if (!before.has(path)) paths.add(path);
669
+ paths.delete(lockfile);
670
+ return [...paths].sort(compareCodeUnits);
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Regeneration
675
+ // ---------------------------------------------------------------------------
676
+
677
+ const LOCKFILE_FORMAT: Readonly<Record<Exclude<LockfileName, "none">, { readonly tool: "npm" | "pnpm" | "yarn"; readonly format: LockfileFormat | null }>> = {
678
+ "package-lock.json": { tool: "npm", format: "npm" },
679
+ "pnpm-lock.yaml": { tool: "pnpm", format: "pnpm" },
680
+ "yarn.lock": { tool: "yarn", format: null },
681
+ };
682
+
683
+ type Tool = "npm" | "pnpm" | "yarn";
684
+
685
+ const indeterminate = (reason: LockfileRegenIndeterminateReason, tooling: LockfileTooling | null = null, output?: string): LockfileRegenResult =>
686
+ output === undefined ? { verdict: "indeterminate", reason, tooling } : { verdict: "indeterminate", reason, output, tooling };
687
+
688
+ async function lstatOrNull(path: string): Promise<Stats | null> {
689
+ try {
690
+ return await lstat(path);
691
+ } catch {
692
+ return null;
693
+ }
694
+ }
695
+
696
+ const nonEmptyEnv = (value: string | undefined): value is string => typeof value === "string" && value.length > 0;
697
+
698
+ /** Replaces each named path with its token (longest first), then keeps the last LOCKFILE_OUTPUT_TAIL_BYTES bytes on a UTF-8 boundary. */
699
+ function outputTail(text: string, replacements: readonly (readonly [string, string])[]): string {
700
+ let redacted = text;
701
+ for (const [path, token] of [...replacements].filter(([path]) => path.length > 0).sort((left, right) => right[0].length - left[0].length)) redacted = redacted.split(path).join(token);
702
+ const bytes = Buffer.from(redacted, "utf8");
703
+ if (bytes.length <= LOCKFILE_OUTPUT_TAIL_BYTES) return redacted;
704
+ let start = bytes.length - LOCKFILE_OUTPUT_TAIL_BYTES;
705
+ while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1;
706
+ return bytes.subarray(start).toString("utf8");
707
+ }
708
+
709
+ /** The launcher prefix: npm from PATH, pnpm and Yarn through corepack at the exact version. */
710
+ function launcher(tool: Tool, toolVersion: string | null): { readonly command: string; readonly prefix: readonly string[] } {
711
+ return tool === "npm" ? { command: "npm", prefix: [] } : { command: "corepack", prefix: [`${tool}@${toolVersion ?? ""}`] };
712
+ }
713
+
714
+ /**
715
+ * The regeneration arguments. The scope registry is a command-line setting, which outranks every
716
+ * `.npmrc`. pnpm takes it as `--config.<scope>:registry=`: pnpm 10 also accepts `--<scope>:registry=`,
717
+ * but pnpm 12 rejects that spelling as an unknown argument. pnpm always runs with the publishing
718
+ * scope excluded from its release-age window (D25) for this one invocation: pnpm 12's built-in
719
+ * window is non-strict and otherwise records every scoped version it finds too young in
720
+ * `pnpm-workspace.yaml`, a write outside the lockfile. Other packages keep the window.
721
+ */
722
+ function regenerateArgs(tool: Tool, scope: string, registry: string, config: { readonly minReleaseAge: boolean; readonly before: boolean }, now: () => Date): string[] {
723
+ if (tool === "npm") {
724
+ return [
725
+ "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", `--${scope}:registry=${registry}/`,
726
+ ...(config.minReleaseAge ? ["--min-release-age=0"] : []),
727
+ ...(config.before ? [`--before=${now().toISOString()}`] : []),
728
+ ];
729
+ }
730
+ if (tool === "pnpm") return ["install", "--lockfile-only", "--ignore-scripts", "--ignore-pnpmfile", `--config.${scope}:registry=${registry}/`, `--config.minimum-release-age-exclude=${scope}/*`];
731
+ return ["install", "--mode=update-lockfile"];
732
+ }
733
+
734
+ /** Regenerates the lockfile under a sanitized environment and checks I1 to I5 against baseLockfile. Never reverts, repairs or retries. */
735
+ export async function regenerateLockfile(input: LockfileRegenInput, ports: LockfileRegenPorts = {}): Promise<LockfileRegenResult> {
736
+ // 1. Preconditions: nothing runs until these hold.
737
+ if (input.packageManager === "none") return indeterminate("manifest-absent");
738
+ const { root } = input;
739
+ if (!isAbsolute(root)) return indeterminate("root-not-canonical");
740
+ const rootStats = await lstatOrNull(root);
741
+ if (rootStats === null || rootStats.isSymbolicLink() || !rootStats.isDirectory()) return indeterminate("root-not-canonical");
742
+ let realRoot: string;
743
+ try {
744
+ realRoot = await realpath(root);
745
+ } catch {
746
+ return indeterminate("root-not-canonical");
747
+ }
748
+ if (realRoot !== root) return indeterminate("root-not-canonical");
749
+ const manifest = await lstatOrNull(join(root, "package.json"));
750
+ if (manifest === null || !manifest.isFile()) return indeterminate("manifest-absent");
751
+ const lockfile = lockfilePath({ packageManager: input.packageManager, lockfile: input.lockfile });
752
+ if (input.lockfile === "none" || lockfile === null || input.baseLockfile === "") return indeterminate("lockfile-absent");
753
+ const { tool, format } = LOCKFILE_FORMAT[input.lockfile];
754
+ if (tool !== input.packageManager) return indeterminate("lockfile-format-unsupported");
755
+ const lockfileAbsolute = join(root, lockfile);
756
+ const lockfileStats = await lstatOrNull(lockfileAbsolute);
757
+ if (lockfileStats === null) return indeterminate("lockfile-absent");
758
+ if (!lockfileStats.isFile()) return indeterminate("lockfile-is-link");
759
+ let current: Buffer;
760
+ try {
761
+ current = await readFile(lockfileAbsolute);
762
+ } catch {
763
+ return indeterminate("lockfile-unreadable");
764
+ }
765
+ if (!current.equals(Buffer.from(input.baseLockfile, "utf8"))) return { verdict: "violated", rule: "lockfile-not-at-base", violations: [], paths: [], tooling: null };
766
+
767
+ // 2. Tool version: exact, and never Yarn classic.
768
+ if (input.toolVersion !== null && !EXACT_VERSION.test(input.toolVersion)) return indeterminate("package-manager-version-unknown");
769
+ if (tool !== "npm" && input.toolVersion === null) return indeterminate("package-manager-version-unknown");
770
+ if (tool === "yarn" && input.toolVersion?.split(".")[0] === "1") return indeterminate("package-manager-unsupported");
771
+
772
+ // 3. Repository configuration.
773
+ if ((await lstatOrNull(join(root, ".corepack.env"))) !== null) return indeterminate("package-manager-config-unsafe");
774
+ const files: { -readonly [key in keyof RepositoryConfigFiles]: string } = {};
775
+ for (const [key, name] of CONFIG_FILES[tool]) {
776
+ const stats = await lstatOrNull(join(root, name));
777
+ if (stats === null) continue;
778
+ if (!stats.isFile()) return indeterminate("package-manager-config-unsafe");
779
+ files[key] = await readFile(join(root, name), "utf8");
780
+ }
781
+ const config = scanRepositoryConfig(files);
782
+ if (config.refused) return indeterminate(config.reason);
783
+
784
+ // 4 to 6. Scratch, environment, registry, launch.
785
+ const registry = (ports.registry ?? PACKAGE_SCOPE.registry).replace(/\/+$/, "");
786
+ const scope = PACKAGE_SCOPE.scope;
787
+ const run = ports.spawn ?? spawnLockfileTool;
788
+ const scratch = await prepareLockfileScratch();
789
+ try {
790
+ const env = lockfileToolEnv({ tool, scratch: scratch.path, registry, corepack: tool !== "npm", parent: process.env });
791
+ let realScratch = scratch.path;
792
+ try {
793
+ realScratch = await realpath(scratch.path);
794
+ } catch {
795
+ // The literal path is still redacted.
796
+ }
797
+ const redactions: (readonly [string, string])[] = [
798
+ [scratch.path, "$SCRATCH"],
799
+ [realScratch, "$SCRATCH"],
800
+ [root, "$ROOT"],
801
+ [homedir(), "$HOME"],
802
+ ...(nonEmptyEnv(process.env.COREPACK_HOME) ? ([[process.env.COREPACK_HOME as string, "$COREPACK_HOME"]] as const) : []),
803
+ ];
804
+ const { command, prefix } = launcher(tool, input.toolVersion);
805
+ const launch = (args: readonly string[], cwd: string): Promise<LockfileSpawnResult> =>
806
+ run({ command, args: [...prefix, ...args], cwd, env, timeoutMs: LOCKFILE_TOOL_TIMEOUT_MS, maxBuffer: LOCKFILE_TOOL_MAX_BUFFER });
807
+
808
+ const before = await snapshotTree(root);
809
+ // The probe runs in scratch: pnpm 10 loads the working directory's .pnpmfile.cjs even for --version.
810
+ const probe = await launch(["--version"], scratch.path);
811
+ if (probe.failure === "not-found") return indeterminate("tool-unavailable", { tool, version: null });
812
+ // The first line only: a tool may append a notice after the version.
813
+ const firstLine = probe.stdout.trim().split(/\r?\n/)[0]?.trim() ?? "";
814
+ const version = probe.failure === undefined && probe.status === 0 && firstLine !== "" ? firstLine : null;
815
+ const tooling: LockfileTooling = { tool, version };
816
+
817
+ const result = await launch(regenerateArgs(tool, scope, registry, config, input.now), root);
818
+ if (result.failure === "not-found") return indeterminate("tool-unavailable", tooling);
819
+ if (result.failure === "timeout") return indeterminate("tool-timeout", tooling, outputTail(result.stdout + result.stderr, redactions));
820
+ if (result.status !== 0) return indeterminate("tool-failed", tooling, outputTail(result.stdout + result.stderr, redactions));
821
+
822
+ // 7. Bounded footprint.
823
+ const paths = changedPaths(before, await snapshotTree(root), lockfile);
824
+ if (paths.length > 0) return { verdict: "violated", rule: "tree-changed-outside-lockfile", violations: [], paths, tooling };
825
+ if (format === null) return indeterminate("package-manager-unsupported", tooling);
826
+
827
+ // 8. Invariants.
828
+ const afterStats = await lstatOrNull(lockfileAbsolute);
829
+ if (afterStats === null) return indeterminate("lockfile-unreadable", tooling);
830
+ if (!afterStats.isFile()) return indeterminate("lockfile-is-link", tooling);
831
+ let regeneratedBytes: Buffer;
832
+ try {
833
+ regeneratedBytes = await readFile(lockfileAbsolute);
834
+ } catch {
835
+ return indeterminate("lockfile-unreadable", tooling);
836
+ }
837
+ const regenerated = regeneratedBytes.toString("utf8");
838
+ const checked = checkLockfileInvariants({
839
+ format,
840
+ base: input.baseLockfile,
841
+ regenerated,
842
+ packages: input.packages,
843
+ ...(ports.registry === undefined ? {} : { publishing: { scope, registry } }),
844
+ });
845
+ if (checked.verdict === "indeterminate") return indeterminate(checked.reason, tooling);
846
+ if (checked.verdict === "violated") return { verdict: "violated", rule: "lockfile-invariants", violations: checked.violations, paths: [], tooling };
847
+ return { verdict: "satisfied", path: lockfile, before: sha256hex(input.baseLockfile), after: sha256hex(regeneratedBytes), tooling, transitive: checked.transitive };
848
+ } finally {
849
+ await scratch.remove();
850
+ }
851
+ }