@checkstack/script-packages-backend 0.2.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 (66) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/drizzle/0000_flashy_squadron_supreme.sql +63 -0
  3. package/drizzle/0001_flawless_drax.sql +15 -0
  4. package/drizzle/meta/0000_snapshot.json +395 -0
  5. package/drizzle/meta/0001_snapshot.json +491 -0
  6. package/drizzle/meta/_journal.json +20 -0
  7. package/drizzle.config.ts +7 -0
  8. package/package.json +32 -0
  9. package/src/atomic-symlink.test.ts +47 -0
  10. package/src/atomic-symlink.ts +66 -0
  11. package/src/blob-gc-runner.test.ts +120 -0
  12. package/src/blob-gc-runner.ts +139 -0
  13. package/src/blob-gc.test.ts +182 -0
  14. package/src/blob-gc.ts +161 -0
  15. package/src/blob-hash.test.ts +70 -0
  16. package/src/blob-hash.ts +56 -0
  17. package/src/blob-store-registry.test.ts +78 -0
  18. package/src/blob-store-registry.ts +75 -0
  19. package/src/blob-store.ts +51 -0
  20. package/src/cache-archive.test.ts +164 -0
  21. package/src/cache-archive.ts +192 -0
  22. package/src/cache-layout.ts +64 -0
  23. package/src/data-dir.test.ts +41 -0
  24. package/src/data-dir.ts +42 -0
  25. package/src/e2e-install-reconcile.test.ts +121 -0
  26. package/src/hooks.ts +20 -0
  27. package/src/index.ts +594 -0
  28. package/src/install-controller.test.ts +257 -0
  29. package/src/install-controller.ts +144 -0
  30. package/src/install-service.test.ts +104 -0
  31. package/src/install-service.ts +116 -0
  32. package/src/install-state-store.ts +131 -0
  33. package/src/lockfile.test.ts +60 -0
  34. package/src/lockfile.ts +0 -0
  35. package/src/npmrc.test.ts +48 -0
  36. package/src/npmrc.ts +42 -0
  37. package/src/package-types.test.ts +293 -0
  38. package/src/package-types.ts +408 -0
  39. package/src/parse-bun-lock.test.ts +62 -0
  40. package/src/parse-bun-lock.ts +59 -0
  41. package/src/reconcile-diff.test.ts +41 -0
  42. package/src/reconcile-diff.ts +26 -0
  43. package/src/reconcile-fs.ts +199 -0
  44. package/src/reconciler.test.ts +289 -0
  45. package/src/reconciler.ts +81 -0
  46. package/src/registry-client.test.ts +314 -0
  47. package/src/registry-client.ts +0 -0
  48. package/src/registry-request-config.ts +63 -0
  49. package/src/registry-token.test.ts +124 -0
  50. package/src/registry-token.ts +104 -0
  51. package/src/resolution-root.test.ts +82 -0
  52. package/src/resolution-root.ts +127 -0
  53. package/src/resolver.test.ts +133 -0
  54. package/src/resolver.ts +132 -0
  55. package/src/router.ts +273 -0
  56. package/src/schema.ts +166 -0
  57. package/src/size-cap.test.ts +32 -0
  58. package/src/size-cap.ts +40 -0
  59. package/src/storage-migration.test.ts +318 -0
  60. package/src/storage-migration.ts +213 -0
  61. package/src/stores.ts +533 -0
  62. package/src/tree-gc.test.ts +184 -0
  63. package/src/tree-gc.ts +160 -0
  64. package/src/tree-retirement.ts +81 -0
  65. package/src/type-acquisition-route.ts +178 -0
  66. package/tsconfig.json +23 -0
@@ -0,0 +1,408 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { PackageTypeClosure } from "@checkstack/script-packages-common";
4
+
5
+ /**
6
+ * Tree-driven `.d.ts` closure extractor for editor IntelliSense (Monaco
7
+ * lazy Automatic Type Acquisition).
8
+ *
9
+ * Given a requested bare specifier (e.g. `lodash`) and the materialized
10
+ * `node_modules` at `<storeRoot>/trees/<lockfileHash>/node_modules`, this
11
+ * returns a FILE-MAP (`{ path, content }[]`) of the declaration closure,
12
+ * preserving the real `node_modules/...` layout and leaving every file
13
+ * UNWRAPPED (no `declare module` envelope). The frontend registers each
14
+ * file at `file:///<path>` and lets TypeScript's own NodeJs + `@types`
15
+ * resolution pick own-types vs. `@types` fallback.
16
+ *
17
+ * Resolution mirrors TypeScript's own, against the actual tree:
18
+ *
19
+ * 1. OWN types: `node_modules/<pkg>/package.json` `types`/`typings`/
20
+ * `exports[...]types`, falling back to `index.d.ts`. Included when
21
+ * the package ships its own declarations (e.g. `zod`, `dayjs`).
22
+ * 2. `@types` COMPANION: the DefinitelyTyped dir name is computed by the
23
+ * mangling rule (`@scope/name` -> `@types/scope__name`, unscoped
24
+ * `lodash` -> `@types/lodash`) and included ONLY when it actually
25
+ * exists in the tree (e.g. `lodash` -> `@types/lodash`).
26
+ * 3. Either, both, or neither may be present. Neither -> a graceful
27
+ * empty closure (`notFound`), never a throw: the editor simply gets
28
+ * no completions for that import.
29
+ *
30
+ * Triple-slash `/// <reference path|types=...>` directives and cross-file
31
+ * relative `import`/`require` references are followed so multi-file packages
32
+ * (lodash's ~700-file `/// <reference path>` web) resolve fully. Cross-
33
+ * package `@types` references (a `@types` pkg referencing another) are
34
+ * followed too. The whole walk is BOUNDED by `maxTotalBytes`; if anything
35
+ * is dropped, `truncated` is set (NO silent caps).
36
+ */
37
+
38
+ /** Default closure ceiling. Well above lodash's ~866 KB type set. */
39
+ const DEFAULT_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
40
+
41
+ interface CollectedFile {
42
+ /** `node_modules/...`-relative path (the virtual editor path). */
43
+ path: string;
44
+ content: string;
45
+ }
46
+
47
+ /**
48
+ * Map a bare package specifier to its DefinitelyTyped (`@types`) directory
49
+ * name segment. Unscoped: `lodash` -> `lodash`. Scoped: `@scope/name` ->
50
+ * `scope__name` (drop the leading `@`, replace `/` with `__`).
51
+ *
52
+ * Pure + exported for unit testing the mangling rule directly.
53
+ */
54
+ export function typesPackageDirName(specifier: string): string {
55
+ if (specifier.startsWith("@")) {
56
+ const withoutAt = specifier.slice(1);
57
+ return withoutAt.replace("/", "__");
58
+ }
59
+ return specifier;
60
+ }
61
+
62
+ interface ResolveClosureArgs {
63
+ /** Absolute path to the materialized tree's `node_modules`. */
64
+ nodeModulesDir: string;
65
+ /** Requested bare specifier, e.g. `lodash` or `@babel/core`. */
66
+ specifier: string;
67
+ /** Total-byte ceiling for the whole closure. */
68
+ maxTotalBytes?: number;
69
+ }
70
+
71
+ /**
72
+ * Resolve the declaration-file closure for one requested specifier against
73
+ * the materialized tree. Never throws on a missing package / file / dir;
74
+ * returns a graceful empty (`notFound: true`) closure instead.
75
+ */
76
+ export async function resolvePackageTypeClosure({
77
+ nodeModulesDir,
78
+ specifier,
79
+ maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES,
80
+ }: ResolveClosureArgs): Promise<PackageTypeClosure> {
81
+ const collected = new Map<string, CollectedFile>();
82
+ let totalBytes = 0;
83
+ let truncated = false;
84
+
85
+ // BFS over reference roots so the closure follows triple-slash + import
86
+ // references. Each queue item is an ABSOLUTE file path to read; the queue
87
+ // is seeded with the resolved entry points (own types + @types).
88
+ const queue: string[] = [];
89
+ const queuedOrDone = new Set<string>();
90
+
91
+ const enqueue = (absFile: string): void => {
92
+ if (queuedOrDone.has(absFile)) return;
93
+ queuedOrDone.add(absFile);
94
+ queue.push(absFile);
95
+ };
96
+
97
+ // Convert an absolute path inside the tree to its `node_modules/...`
98
+ // virtual path. Returns undefined for paths outside the tree (defensive;
99
+ // we never follow `..` past node_modules).
100
+ const toVirtualPath = (absFile: string): string | undefined => {
101
+ const rel = path.relative(nodeModulesDir, absFile);
102
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return undefined;
103
+ // Always POSIX-style for the virtual editor URI.
104
+ return `node_modules/${rel.split(path.sep).join("/")}`;
105
+ };
106
+
107
+ // ─── Seed: own types ──────────────────────────────────────────────────
108
+ const pkgDir = path.join(nodeModulesDir, ...specifier.split("/"));
109
+ const ownEntries = await resolveOwnTypeEntries(pkgDir);
110
+ let hasOwnTypes = false;
111
+ for (const entry of ownEntries) {
112
+ hasOwnTypes = true;
113
+ enqueue(entry);
114
+ }
115
+ // The package.json itself is part of the closure so NodeJs/@types
116
+ // resolution can read `types`/`typings`/`exports`/`main`.
117
+ const pkgJsonPath = path.join(pkgDir, "package.json");
118
+ if (await fileExists(pkgJsonPath)) {
119
+ enqueue(pkgJsonPath);
120
+ }
121
+
122
+ // ─── Seed: @types companion (existence-checked, never assumed) ────────
123
+ const typesDir = path.join(
124
+ nodeModulesDir,
125
+ "@types",
126
+ typesPackageDirName(specifier),
127
+ );
128
+ let hasAtTypes = false;
129
+ if (await dirExists(typesDir)) {
130
+ hasAtTypes = true;
131
+ const typesPkgJson = path.join(typesDir, "package.json");
132
+ if (await fileExists(typesPkgJson)) enqueue(typesPkgJson);
133
+ // Walk ALL .d.ts under the @types dir (not just index): DefinitelyTyped
134
+ // packages like lodash fan out across many files via /// <reference>.
135
+ for (const dtsFile of await walkDtsFiles(typesDir)) {
136
+ enqueue(dtsFile);
137
+ }
138
+ }
139
+
140
+ // ─── BFS drain ────────────────────────────────────────────────────────
141
+ while (queue.length > 0) {
142
+ const absFile = queue.shift();
143
+ if (absFile === undefined) break;
144
+ const virtualPath = toVirtualPath(absFile);
145
+ if (virtualPath === undefined) continue;
146
+ if (collected.has(virtualPath)) continue;
147
+
148
+ let content: string;
149
+ try {
150
+ content = await readFile(absFile, "utf8");
151
+ } catch {
152
+ continue; // Skip unreadable file; never fatal.
153
+ }
154
+
155
+ if (totalBytes + content.length > maxTotalBytes) {
156
+ truncated = true;
157
+ continue; // Drop this file (and stop following its refs) but keep going.
158
+ }
159
+ collected.set(virtualPath, { path: virtualPath, content });
160
+ totalBytes += content.length;
161
+
162
+ // Only declaration files carry references worth following.
163
+ if (
164
+ absFile.endsWith(".d.ts") ||
165
+ absFile.endsWith(".d.mts") ||
166
+ absFile.endsWith(".d.cts")
167
+ ) {
168
+ for (const ref of extractReferences(content)) {
169
+ const resolved = await resolveReference({
170
+ ref,
171
+ fromFile: absFile,
172
+ nodeModulesDir,
173
+ });
174
+ if (resolved) enqueue(resolved);
175
+ }
176
+ }
177
+ }
178
+
179
+ const files = [...collected.values()].toSorted((a, b) =>
180
+ a.path.localeCompare(b.path),
181
+ );
182
+
183
+ return {
184
+ specifier,
185
+ files,
186
+ hasOwnTypes,
187
+ hasAtTypes,
188
+ notFound: !hasOwnTypes && !hasAtTypes,
189
+ truncated,
190
+ };
191
+ }
192
+
193
+ // ─── Own-type entry resolution ────────────────────────────────────────────
194
+
195
+ /**
196
+ * Resolve a package's own declaration entry point(s) from its
197
+ * `package.json` (`types`/`typings`, `exports[...]types`), falling back to
198
+ * `index.d.ts`. Returns absolute paths to existing files (possibly several
199
+ * from an `exports` map). Empty when the package ships no own types.
200
+ */
201
+ async function resolveOwnTypeEntries(pkgDir: string): Promise<string[]> {
202
+ const out = new Set<string>();
203
+ const pkgJsonPath = path.join(pkgDir, "package.json");
204
+
205
+ let pkg: Record<string, unknown> | undefined;
206
+ try {
207
+ const raw = await readFile(pkgJsonPath, "utf8");
208
+ const parsed: unknown = JSON.parse(raw);
209
+ if (parsed !== null && typeof parsed === "object") {
210
+ pkg = parsed as Record<string, unknown>;
211
+ }
212
+ } catch {
213
+ pkg = undefined;
214
+ }
215
+
216
+ const addIfExists = async (rel: string): Promise<void> => {
217
+ if (
218
+ !rel.endsWith(".d.ts") &&
219
+ !rel.endsWith(".d.mts") &&
220
+ !rel.endsWith(".d.cts")
221
+ ) {
222
+ return;
223
+ }
224
+ const abs = path.join(pkgDir, rel);
225
+ if (await fileExists(abs)) out.add(abs);
226
+ };
227
+
228
+ if (pkg) {
229
+ const typesField = pkg.types ?? pkg.typings;
230
+ if (typeof typesField === "string") {
231
+ await addIfExists(typesField);
232
+ }
233
+ // `exports` map: collect every `types` condition (subpaths included).
234
+ for (const rel of collectExportsTypes(pkg.exports)) {
235
+ await addIfExists(rel);
236
+ }
237
+ }
238
+
239
+ // Conventional fallback.
240
+ if (out.size === 0) {
241
+ const indexDts = path.join(pkgDir, "index.d.ts");
242
+ if (await fileExists(indexDts)) out.add(indexDts);
243
+ }
244
+
245
+ return [...out];
246
+ }
247
+
248
+ /**
249
+ * Recursively collect every `types`-condition target from an `exports`
250
+ * map. Handles strings, nested condition objects, and subpath maps.
251
+ */
252
+ function collectExportsTypes(exportsField: unknown): string[] {
253
+ const out: string[] = [];
254
+ const visit = (node: unknown): void => {
255
+ if (node === null || typeof node !== "object") return;
256
+ const obj = node as Record<string, unknown>;
257
+ for (const [key, value] of Object.entries(obj)) {
258
+ if (key === "types" && typeof value === "string") {
259
+ out.push(value.replace(/^\.\//, ""));
260
+ } else {
261
+ visit(value);
262
+ }
263
+ }
264
+ };
265
+ visit(exportsField);
266
+ return out;
267
+ }
268
+
269
+ // ─── Reference following ───────────────────────────────────────────────────
270
+
271
+ interface FileReference {
272
+ kind: "path" | "types";
273
+ target: string;
274
+ }
275
+
276
+ /**
277
+ * Extract triple-slash references and relative module imports/requires from
278
+ * a `.d.ts` body. `/// <reference path=...>` -> relative file; `/// <reference
279
+ * types=...>` -> another package's `@types`; relative `import`/`require` /
280
+ * `export ... from` -> a sibling declaration file.
281
+ *
282
+ * Pure + exported for unit testing.
283
+ */
284
+ export function extractReferences(content: string): FileReference[] {
285
+ const refs: FileReference[] = [];
286
+
287
+ // Triple-slash references.
288
+ const tripleSlash =
289
+ /\/\/\/\s*<reference\s+(path|types)\s*=\s*["']([^"']+)["']\s*\/>/g;
290
+ let m: RegExpExecArray | null = tripleSlash.exec(content);
291
+ while (m !== null) {
292
+ const kind = m[1] === "types" ? "types" : "path";
293
+ refs.push({ kind, target: m[2] });
294
+ m = tripleSlash.exec(content);
295
+ }
296
+
297
+ // Relative module specifiers in import/export/require/dynamic-import. The
298
+ // `from` form is constrained to NOT cross a `;` or newline so an
299
+ // `import x = require(...)` (no `from`) on one line isn't swallowed by a
300
+ // later `... from "..."` on another line.
301
+ const moduleSpecifier =
302
+ /(?:import|export)[^;\n]*?from\s*["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)|import\(\s*["']([^"']+)["']\s*\)|import\s*["']([^"']+)["']/g;
303
+ let mm: RegExpExecArray | null = moduleSpecifier.exec(content);
304
+ while (mm !== null) {
305
+ const spec = mm[1] ?? mm[2] ?? mm[3] ?? mm[4];
306
+ if (spec && (spec.startsWith("./") || spec.startsWith("../"))) {
307
+ refs.push({ kind: "path", target: spec });
308
+ }
309
+ mm = moduleSpecifier.exec(content);
310
+ }
311
+
312
+ return refs;
313
+ }
314
+
315
+ /**
316
+ * Resolve one extracted reference to an absolute declaration file inside the
317
+ * tree, or undefined when it can't be resolved (skipped gracefully).
318
+ */
319
+ async function resolveReference({
320
+ ref,
321
+ fromFile,
322
+ nodeModulesDir,
323
+ }: {
324
+ ref: FileReference;
325
+ fromFile: string;
326
+ nodeModulesDir: string;
327
+ }): Promise<string | undefined> {
328
+ if (ref.kind === "types") {
329
+ // `/// <reference types="node" />` -> @types/node (or own-typed pkg).
330
+ const dir = path.join(
331
+ nodeModulesDir,
332
+ "@types",
333
+ typesPackageDirName(ref.target),
334
+ );
335
+ const index = path.join(dir, "index.d.ts");
336
+ if (await fileExists(index)) return index;
337
+ return undefined;
338
+ }
339
+
340
+ // Path/relative reference, resolved against the referencing file's dir.
341
+ const baseDir = path.dirname(fromFile);
342
+ const raw = path.join(baseDir, ref.target);
343
+
344
+ // Try as-is, then with .d.ts, then as a dir/index.d.ts.
345
+ const candidates = [
346
+ raw,
347
+ `${raw}.d.ts`,
348
+ `${raw}.d.mts`,
349
+ `${raw}.d.cts`,
350
+ path.join(raw, "index.d.ts"),
351
+ ];
352
+ for (const candidate of candidates) {
353
+ if (await fileExists(candidate)) return candidate;
354
+ }
355
+ return undefined;
356
+ }
357
+
358
+ // ─── Filesystem helpers ────────────────────────────────────────────────────
359
+
360
+ async function fileExists(p: string): Promise<boolean> {
361
+ try {
362
+ const info = await stat(p);
363
+ return info.isFile();
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
368
+
369
+ async function dirExists(p: string): Promise<boolean> {
370
+ try {
371
+ const info = await stat(p);
372
+ return info.isDirectory();
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Recursively collect every `.d.ts` (and `.d.mts`/`.d.cts`) under `dir`,
380
+ * skipping nested `node_modules`. Best-effort; unreadable dirs are skipped.
381
+ */
382
+ async function walkDtsFiles(dir: string): Promise<string[]> {
383
+ const result: string[] = [];
384
+ const walk = async (current: string, depth: number): Promise<void> => {
385
+ if (depth > 12) return;
386
+ let entries: import("node:fs").Dirent[];
387
+ try {
388
+ entries = await readdir(current, { withFileTypes: true });
389
+ } catch {
390
+ return;
391
+ }
392
+ for (const dirent of entries) {
393
+ const full = path.join(current, dirent.name);
394
+ if (dirent.isDirectory()) {
395
+ if (dirent.name === "node_modules") continue;
396
+ await walk(full, depth + 1);
397
+ } else if (
398
+ dirent.name.endsWith(".d.ts") ||
399
+ dirent.name.endsWith(".d.mts") ||
400
+ dirent.name.endsWith(".d.cts")
401
+ ) {
402
+ result.push(full);
403
+ }
404
+ }
405
+ };
406
+ await walk(dir, 0);
407
+ return result;
408
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseBunLock, splitSpec } from "./parse-bun-lock";
3
+
4
+ describe("splitSpec", () => {
5
+ test("splits a plain spec", () => {
6
+ expect(splitSpec("leftpad@0.0.1")).toEqual({
7
+ name: "leftpad",
8
+ version: "0.0.1",
9
+ });
10
+ });
11
+ test("splits a scoped spec", () => {
12
+ expect(splitSpec("@acme/utils@1.2.3")).toEqual({
13
+ name: "@acme/utils",
14
+ version: "1.2.3",
15
+ });
16
+ });
17
+ test("rejects a bare name", () => {
18
+ expect(splitSpec("leftpad")).toBeUndefined();
19
+ });
20
+ });
21
+
22
+ const LOCK = `{
23
+ "lockfileVersion": 1,
24
+ "packages": {
25
+ "leftpad": ["leftpad@0.0.1", "", {}, "sha512-AAA=="],
26
+ "@acme/utils": ["@acme/utils@1.2.3", "", { "dependencies": {} }, "sha512-BBB=="],
27
+ }
28
+ }`;
29
+
30
+ describe("parseBunLock", () => {
31
+ test("extracts name/version/integrity for each package", () => {
32
+ const entries = parseBunLock(LOCK);
33
+ expect(entries).toHaveLength(2);
34
+ const left = entries.find((e) => e.name === "leftpad");
35
+ expect(left).toEqual({
36
+ name: "leftpad",
37
+ version: "0.0.1",
38
+ integrity: "sha512-AAA==",
39
+ });
40
+ const acme = entries.find((e) => e.name === "@acme/utils");
41
+ expect(acme?.integrity).toBe("sha512-BBB==");
42
+ });
43
+
44
+ test("dedupes by integrity", () => {
45
+ const dup = `{ "packages": {
46
+ "a": ["a@1.0.0", "", {}, "sha512-SAME=="],
47
+ "b": ["b@1.0.0", "", {}, "sha512-SAME=="]
48
+ } }`;
49
+ expect(parseBunLock(dup)).toHaveLength(1);
50
+ });
51
+
52
+ test("skips entries without an sri integrity", () => {
53
+ const lock = `{ "packages": {
54
+ "ws": ["ws@1.0.0", "", {}, "workspace"]
55
+ } }`;
56
+ expect(parseBunLock(lock)).toHaveLength(0);
57
+ });
58
+
59
+ test("throws on invalid lockfile text", () => {
60
+ expect(() => parseBunLock("not json {{{")).toThrow(/invalid lockfile/i);
61
+ });
62
+ });
@@ -0,0 +1,59 @@
1
+ import type { ManifestEntry } from "@checkstack/script-packages-common";
2
+
3
+ /**
4
+ * Parse a Bun text lockfile (`bun.lock`) into manifest entries.
5
+ *
6
+ * The lockfile is JSONC-ish (trailing commas) with a `packages` map whose
7
+ * values are tuples `[ "<name>@<version>", <registry>, <meta>, <integrity> ]`.
8
+ * We extract `name`, `version`, and `integrity` (the content-addressed
9
+ * key). Entries without an integrity (e.g. workspace/link packages) are
10
+ * skipped - they aren't distributable blobs.
11
+ */
12
+
13
+ interface BunLock {
14
+ packages?: Record<string, unknown>;
15
+ }
16
+
17
+ /** Strip trailing commas so JSON.parse accepts Bun's lockfile. */
18
+ function stripTrailingCommas(text: string): string {
19
+ return text.replaceAll(/,(\s*[}\]])/g, "$1");
20
+ }
21
+
22
+ /** Split a `name@version` spec, honoring scoped names (`@scope/name@v`). */
23
+ export function splitSpec(
24
+ spec: string,
25
+ ): { name: string; version: string } | undefined {
26
+ const at = spec.lastIndexOf("@");
27
+ if (at <= 0) return undefined;
28
+ const name = spec.slice(0, at);
29
+ const version = spec.slice(at + 1);
30
+ if (name.length === 0 || version.length === 0) return undefined;
31
+ return { name, version };
32
+ }
33
+
34
+ export function parseBunLock(text: string): ManifestEntry[] {
35
+ let parsed: BunLock;
36
+ try {
37
+ parsed = JSON.parse(stripTrailingCommas(text)) as BunLock;
38
+ } catch {
39
+ throw new Error("Failed to parse bun.lock: invalid lockfile format");
40
+ }
41
+
42
+ const entries: ManifestEntry[] = [];
43
+ const seen = new Set<string>();
44
+
45
+ for (const value of Object.values(parsed.packages ?? {})) {
46
+ if (!Array.isArray(value)) continue;
47
+ const spec = value[0];
48
+ const integrity = value.at(-1);
49
+ if (typeof spec !== "string" || typeof integrity !== "string") continue;
50
+ if (!integrity.includes("-")) continue; // not an sri hash
51
+ const split = splitSpec(spec);
52
+ if (!split) continue;
53
+ if (seen.has(integrity)) continue;
54
+ seen.add(integrity);
55
+ entries.push({ ...split, integrity });
56
+ }
57
+
58
+ return entries;
59
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ManifestEntry } from "@checkstack/script-packages-common";
3
+ import { computeMissingBlobs } from "./reconcile-diff";
4
+
5
+ const a: ManifestEntry = { name: "a", version: "1.0.0", integrity: "sha-a" };
6
+ const b: ManifestEntry = { name: "b", version: "2.0.0", integrity: "sha-b" };
7
+ const c: ManifestEntry = { name: "c", version: "3.0.0", integrity: "sha-c" };
8
+
9
+ describe("computeMissingBlobs", () => {
10
+ test("returns only integrities not present locally (delta)", () => {
11
+ const missing = computeMissingBlobs({
12
+ manifest: [a, b, c],
13
+ localIntegrities: ["sha-a", "sha-c"],
14
+ });
15
+ expect(missing.map((e) => e.integrity)).toEqual(["sha-b"]);
16
+ });
17
+
18
+ test("returns the full manifest for an empty (cold) cache", () => {
19
+ const missing = computeMissingBlobs({
20
+ manifest: [a, b],
21
+ localIntegrities: [],
22
+ });
23
+ expect(missing).toHaveLength(2);
24
+ });
25
+
26
+ test("returns nothing when already at the desired set (idempotent)", () => {
27
+ const missing = computeMissingBlobs({
28
+ manifest: [a, b],
29
+ localIntegrities: ["sha-a", "sha-b"],
30
+ });
31
+ expect(missing).toEqual([]);
32
+ });
33
+
34
+ test("dedupes repeated integrities", () => {
35
+ const missing = computeMissingBlobs({
36
+ manifest: [a, { ...b, integrity: "sha-a" }],
37
+ localIntegrities: [],
38
+ });
39
+ expect(missing).toHaveLength(1);
40
+ });
41
+ });
@@ -0,0 +1,26 @@
1
+ import type { ManifestEntry } from "@checkstack/script-packages-common";
2
+
3
+ /**
4
+ * Pure delta computation for reconciliation: given the desired manifest and
5
+ * the set of integrities already present in the local content-addressed
6
+ * cache, return the integrities that must be pulled. Changing one package
7
+ * in the allowlist ships exactly one blob, not a new full tree.
8
+ */
9
+ export function computeMissingBlobs({
10
+ manifest,
11
+ localIntegrities,
12
+ }: {
13
+ manifest: ManifestEntry[];
14
+ localIntegrities: Iterable<string>;
15
+ }): ManifestEntry[] {
16
+ const local = new Set(localIntegrities);
17
+ const missing: ManifestEntry[] = [];
18
+ const seen = new Set<string>();
19
+ for (const entry of manifest) {
20
+ if (local.has(entry.integrity)) continue;
21
+ if (seen.has(entry.integrity)) continue;
22
+ seen.add(entry.integrity);
23
+ missing.push(entry);
24
+ }
25
+ return missing;
26
+ }