@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.
- package/CHANGELOG.md +273 -0
- package/drizzle/0000_flashy_squadron_supreme.sql +63 -0
- package/drizzle/0001_flawless_drax.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +395 -0
- package/drizzle/meta/0001_snapshot.json +491 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +7 -0
- package/package.json +32 -0
- package/src/atomic-symlink.test.ts +47 -0
- package/src/atomic-symlink.ts +66 -0
- package/src/blob-gc-runner.test.ts +120 -0
- package/src/blob-gc-runner.ts +139 -0
- package/src/blob-gc.test.ts +182 -0
- package/src/blob-gc.ts +161 -0
- package/src/blob-hash.test.ts +70 -0
- package/src/blob-hash.ts +56 -0
- package/src/blob-store-registry.test.ts +78 -0
- package/src/blob-store-registry.ts +75 -0
- package/src/blob-store.ts +51 -0
- package/src/cache-archive.test.ts +164 -0
- package/src/cache-archive.ts +192 -0
- package/src/cache-layout.ts +64 -0
- package/src/data-dir.test.ts +41 -0
- package/src/data-dir.ts +42 -0
- package/src/e2e-install-reconcile.test.ts +121 -0
- package/src/hooks.ts +20 -0
- package/src/index.ts +594 -0
- package/src/install-controller.test.ts +257 -0
- package/src/install-controller.ts +144 -0
- package/src/install-service.test.ts +104 -0
- package/src/install-service.ts +116 -0
- package/src/install-state-store.ts +131 -0
- package/src/lockfile.test.ts +60 -0
- package/src/lockfile.ts +0 -0
- package/src/npmrc.test.ts +48 -0
- package/src/npmrc.ts +42 -0
- package/src/package-types.test.ts +293 -0
- package/src/package-types.ts +408 -0
- package/src/parse-bun-lock.test.ts +62 -0
- package/src/parse-bun-lock.ts +59 -0
- package/src/reconcile-diff.test.ts +41 -0
- package/src/reconcile-diff.ts +26 -0
- package/src/reconcile-fs.ts +199 -0
- package/src/reconciler.test.ts +289 -0
- package/src/reconciler.ts +81 -0
- package/src/registry-client.test.ts +314 -0
- package/src/registry-client.ts +0 -0
- package/src/registry-request-config.ts +63 -0
- package/src/registry-token.test.ts +124 -0
- package/src/registry-token.ts +104 -0
- package/src/resolution-root.test.ts +82 -0
- package/src/resolution-root.ts +127 -0
- package/src/resolver.test.ts +133 -0
- package/src/resolver.ts +132 -0
- package/src/router.ts +273 -0
- package/src/schema.ts +166 -0
- package/src/size-cap.test.ts +32 -0
- package/src/size-cap.ts +40 -0
- package/src/storage-migration.test.ts +318 -0
- package/src/storage-migration.ts +213 -0
- package/src/stores.ts +533 -0
- package/src/tree-gc.test.ts +184 -0
- package/src/tree-gc.ts +160 -0
- package/src/tree-retirement.ts +81 -0
- package/src/type-acquisition-route.ts +178 -0
- 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
|
+
}
|