@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,131 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import type {
|
|
3
|
+
AdvisoryLockHandle,
|
|
4
|
+
AdvisoryLockService,
|
|
5
|
+
SafeDatabase,
|
|
6
|
+
} from "@checkstack/backend-api";
|
|
7
|
+
import type {
|
|
8
|
+
InstallState,
|
|
9
|
+
ManifestEntry,
|
|
10
|
+
} from "@checkstack/script-packages-common";
|
|
11
|
+
import { scriptPackageInstallState } from "./schema";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Singleton install-state persistence (data) and the installer-election
|
|
15
|
+
* advisory lock (coordination), kept as two separate factories so read-only
|
|
16
|
+
* call sites (per-run resolution-root, router reads) depend only on the data
|
|
17
|
+
* store and never need the pool-backed lock service.
|
|
18
|
+
*
|
|
19
|
+
* Exactly one core instance performs the registry-facing `bun install` at a
|
|
20
|
+
* time, guarded by a Postgres advisory lock. The lock is held across a
|
|
21
|
+
* MINUTES-long `bun install`, so it MUST use a dedicated pooled connection
|
|
22
|
+
* (not a long-open transaction): {@link createInstallerLock} uses the shared
|
|
23
|
+
* {@link AdvisoryLockService}, which checks out one client, acquires the
|
|
24
|
+
* session lock on it, and releases on the SAME client. The lock auto-releases
|
|
25
|
+
* when that connection dies, so a crashed installer never wedges the
|
|
26
|
+
* election.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const INSTALLER_LOCK_KEY = "script-packages.installer";
|
|
30
|
+
const SINGLETON_ID = "singleton";
|
|
31
|
+
|
|
32
|
+
type Schema = { scriptPackageInstallState: typeof scriptPackageInstallState };
|
|
33
|
+
|
|
34
|
+
export interface InstallStateStore {
|
|
35
|
+
load(): Promise<InstallState>;
|
|
36
|
+
setInstalling(): Promise<void>;
|
|
37
|
+
setReady(input: {
|
|
38
|
+
lockfileHash: string;
|
|
39
|
+
manifest: ManifestEntry[];
|
|
40
|
+
totalSizeBytes: number;
|
|
41
|
+
}): Promise<void>;
|
|
42
|
+
setError(message: string): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface InstallerLock {
|
|
46
|
+
/**
|
|
47
|
+
* Acquire the installer-election lock on a dedicated client. Returns a
|
|
48
|
+
* handle (call `release()` in a `finally`) on success, or `null` if
|
|
49
|
+
* another instance already holds it.
|
|
50
|
+
*/
|
|
51
|
+
tryInstallerLock(): Promise<AdvisoryLockHandle | null>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The installer-election lock, backed by the pool-backed advisory-lock
|
|
56
|
+
* service so the session lock keeps connection affinity across the long
|
|
57
|
+
* `bun install`.
|
|
58
|
+
*/
|
|
59
|
+
export function createInstallerLock(
|
|
60
|
+
advisoryLock: AdvisoryLockService,
|
|
61
|
+
): InstallerLock {
|
|
62
|
+
return {
|
|
63
|
+
async tryInstallerLock() {
|
|
64
|
+
return advisoryLock.tryAcquire(INSTALLER_LOCK_KEY);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_STATE: InstallState = {
|
|
70
|
+
status: "idle",
|
|
71
|
+
lockfileHash: null,
|
|
72
|
+
manifest: [],
|
|
73
|
+
totalSizeBytes: 0,
|
|
74
|
+
lastInstalledAt: null,
|
|
75
|
+
errorMessage: null,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function createInstallStateStore(
|
|
79
|
+
db: SafeDatabase<Schema>,
|
|
80
|
+
): InstallStateStore {
|
|
81
|
+
async function upsert(
|
|
82
|
+
set: Partial<typeof scriptPackageInstallState.$inferInsert>,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
await db
|
|
85
|
+
.insert(scriptPackageInstallState)
|
|
86
|
+
.values({ id: SINGLETON_ID, ...set })
|
|
87
|
+
.onConflictDoUpdate({
|
|
88
|
+
target: scriptPackageInstallState.id,
|
|
89
|
+
set,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
async load() {
|
|
95
|
+
const rows = await db
|
|
96
|
+
.select()
|
|
97
|
+
.from(scriptPackageInstallState)
|
|
98
|
+
.where(eq(scriptPackageInstallState.id, SINGLETON_ID))
|
|
99
|
+
.limit(1);
|
|
100
|
+
const row = rows[0];
|
|
101
|
+
if (!row) return DEFAULT_STATE;
|
|
102
|
+
return {
|
|
103
|
+
status: row.status as InstallState["status"],
|
|
104
|
+
lockfileHash: row.lockfileHash,
|
|
105
|
+
manifest: row.manifest,
|
|
106
|
+
totalSizeBytes: row.totalSizeBytes,
|
|
107
|
+
lastInstalledAt: row.lastInstalledAt,
|
|
108
|
+
errorMessage: row.errorMessage,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async setInstalling() {
|
|
113
|
+
await upsert({ status: "installing", errorMessage: null });
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async setReady({ lockfileHash, manifest, totalSizeBytes }) {
|
|
117
|
+
await upsert({
|
|
118
|
+
status: "ready",
|
|
119
|
+
lockfileHash,
|
|
120
|
+
manifest,
|
|
121
|
+
totalSizeBytes,
|
|
122
|
+
lastInstalledAt: new Date(),
|
|
123
|
+
errorMessage: null,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async setError(message) {
|
|
128
|
+
await upsert({ status: "error", errorMessage: message });
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ManifestEntry } from "@checkstack/script-packages-common";
|
|
3
|
+
import {
|
|
4
|
+
buildDependencies,
|
|
5
|
+
buildStorePackageJson,
|
|
6
|
+
computeLockfileHash,
|
|
7
|
+
sortManifest,
|
|
8
|
+
} from "./lockfile";
|
|
9
|
+
|
|
10
|
+
const a: ManifestEntry = { name: "a", version: "1.0.0", integrity: "sha-a" };
|
|
11
|
+
const b: ManifestEntry = { name: "b", version: "2.0.0", integrity: "sha-b" };
|
|
12
|
+
|
|
13
|
+
describe("sortManifest", () => {
|
|
14
|
+
test("orders by name then version", () => {
|
|
15
|
+
expect(sortManifest([b, a]).map((e) => e.name)).toEqual(["a", "b"]);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("computeLockfileHash", () => {
|
|
20
|
+
test("is order-independent", () => {
|
|
21
|
+
expect(computeLockfileHash([a, b])).toBe(computeLockfileHash([b, a]));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("changes when an integrity changes (content-addressed)", () => {
|
|
25
|
+
const a2: ManifestEntry = { ...a, integrity: "sha-a-v2" };
|
|
26
|
+
expect(computeLockfileHash([a, b])).not.toBe(computeLockfileHash([a2, b]));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("changes when a version changes", () => {
|
|
30
|
+
const a2: ManifestEntry = { ...a, version: "1.0.1" };
|
|
31
|
+
expect(computeLockfileHash([a])).not.toBe(computeLockfileHash([a2]));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("empty manifest hashes deterministically", () => {
|
|
35
|
+
expect(computeLockfileHash([])).toBe(computeLockfileHash([]));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("buildDependencies", () => {
|
|
40
|
+
test("excludes disabled packages and sorts keys", () => {
|
|
41
|
+
const deps = buildDependencies([
|
|
42
|
+
{ name: "zeta", version: "1.0.0", enabled: true },
|
|
43
|
+
{ name: "alpha", version: "2.0.0", enabled: true },
|
|
44
|
+
{ name: "off", version: "3.0.0", enabled: false },
|
|
45
|
+
]);
|
|
46
|
+
expect(Object.keys(deps)).toEqual(["alpha", "zeta"]);
|
|
47
|
+
expect(deps.alpha).toBe("2.0.0");
|
|
48
|
+
expect(deps.off).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("buildStorePackageJson", () => {
|
|
53
|
+
test("renders a private package with pinned deps", () => {
|
|
54
|
+
const json = JSON.parse(
|
|
55
|
+
buildStorePackageJson([{ name: "lodash", version: "4.17.21", enabled: true }]),
|
|
56
|
+
);
|
|
57
|
+
expect(json.private).toBe(true);
|
|
58
|
+
expect(json.dependencies.lodash).toBe("4.17.21");
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/lockfile.ts
ADDED
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { renderNpmrc } from "./npmrc";
|
|
3
|
+
|
|
4
|
+
describe("renderNpmrc", () => {
|
|
5
|
+
test("renders the default registry without auth when no token", () => {
|
|
6
|
+
const out = renderNpmrc({
|
|
7
|
+
registryUrl: "https://registry.npmjs.org/",
|
|
8
|
+
scopedRegistries: [],
|
|
9
|
+
});
|
|
10
|
+
expect(out).toContain("registry=https://registry.npmjs.org/");
|
|
11
|
+
expect(out).not.toContain("_authToken");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("renders scoped registries", () => {
|
|
15
|
+
const out = renderNpmrc({
|
|
16
|
+
registryUrl: "https://artifactory.acme.com/npm/",
|
|
17
|
+
scopedRegistries: [
|
|
18
|
+
{ scope: "@acme", registryUrl: "https://artifactory.acme.com/npm-acme/" },
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
expect(out).toContain(
|
|
22
|
+
"@acme:registry=https://artifactory.acme.com/npm-acme/",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("emits protocol-relative _authToken lines for the registry and scoped registries", () => {
|
|
27
|
+
const out = renderNpmrc({
|
|
28
|
+
registryUrl: "https://artifactory.acme.com/npm/",
|
|
29
|
+
scopedRegistries: [
|
|
30
|
+
{ scope: "@acme", registryUrl: "https://artifactory.acme.com/npm-acme/" },
|
|
31
|
+
],
|
|
32
|
+
authToken: "TOKEN123",
|
|
33
|
+
});
|
|
34
|
+
expect(out).toContain("//artifactory.acme.com/npm/:_authToken=TOKEN123");
|
|
35
|
+
expect(out).toContain(
|
|
36
|
+
"//artifactory.acme.com/npm-acme/:_authToken=TOKEN123",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("omits auth lines for an empty token", () => {
|
|
41
|
+
const out = renderNpmrc({
|
|
42
|
+
registryUrl: "https://registry.npmjs.org/",
|
|
43
|
+
scopedRegistries: [],
|
|
44
|
+
authToken: "",
|
|
45
|
+
});
|
|
46
|
+
expect(out).not.toContain("_authToken");
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/npmrc.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an `.npmrc` for the managed store from registry config.
|
|
3
|
+
*
|
|
4
|
+
* The auth token is written to the on-disk `.npmrc` at install time only;
|
|
5
|
+
* it is NEVER logged or returned to the client. Callers pass the resolved
|
|
6
|
+
* plaintext token (fetched from the connection-store secret) directly to
|
|
7
|
+
* this pure function and write the result to disk.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface NpmrcInput {
|
|
11
|
+
registryUrl: string;
|
|
12
|
+
scopedRegistries: { scope: string; registryUrl: string }[];
|
|
13
|
+
/** Resolved plaintext token, or undefined for an anonymous registry. */
|
|
14
|
+
authToken?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Strip protocol from a registry URL for the `//host/:_authToken` key. */
|
|
18
|
+
function registryAuthKey(registryUrl: string): string {
|
|
19
|
+
// `//registry.example.com/:_authToken=...` - npm keys auth by the
|
|
20
|
+
// protocol-relative registry path.
|
|
21
|
+
return registryUrl.replace(/^https?:/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderNpmrc(input: NpmrcInput): string {
|
|
25
|
+
const lines: string[] = [`registry=${input.registryUrl}`];
|
|
26
|
+
|
|
27
|
+
for (const scoped of input.scopedRegistries) {
|
|
28
|
+
lines.push(`${scoped.scope}:registry=${scoped.registryUrl}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (input.authToken && input.authToken.length > 0) {
|
|
32
|
+
const key = registryAuthKey(input.registryUrl);
|
|
33
|
+
lines.push(`${key}:_authToken=${input.authToken}`);
|
|
34
|
+
// Scoped registries on the same host commonly share the token; emit a
|
|
35
|
+
// per-scoped-registry auth line too so private scopes authenticate.
|
|
36
|
+
for (const scoped of input.scopedRegistries) {
|
|
37
|
+
lines.push(`${registryAuthKey(scoped.registryUrl)}:_authToken=${input.authToken}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return lines.join("\n") + "\n";
|
|
42
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
extractReferences,
|
|
7
|
+
resolvePackageTypeClosure,
|
|
8
|
+
typesPackageDirName,
|
|
9
|
+
} from "./package-types";
|
|
10
|
+
|
|
11
|
+
describe("typesPackageDirName", () => {
|
|
12
|
+
test("unscoped name maps to itself", () => {
|
|
13
|
+
expect(typesPackageDirName("lodash")).toBe("lodash");
|
|
14
|
+
});
|
|
15
|
+
test("scoped name mangles @scope/name -> scope__name", () => {
|
|
16
|
+
expect(typesPackageDirName("@babel/core")).toBe("babel__core");
|
|
17
|
+
});
|
|
18
|
+
test("only the first slash is mangled", () => {
|
|
19
|
+
expect(typesPackageDirName("@scope/name")).toBe("scope__name");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("extractReferences", () => {
|
|
24
|
+
test("captures triple-slash path + types references", () => {
|
|
25
|
+
const refs = extractReferences(
|
|
26
|
+
`/// <reference path="./common/array.d.ts" />\n/// <reference types="node" />`,
|
|
27
|
+
);
|
|
28
|
+
expect(refs).toContainEqual({
|
|
29
|
+
kind: "path",
|
|
30
|
+
target: "./common/array.d.ts",
|
|
31
|
+
});
|
|
32
|
+
expect(refs).toContainEqual({ kind: "types", target: "node" });
|
|
33
|
+
});
|
|
34
|
+
test("captures relative import/export/require, ignores bare", () => {
|
|
35
|
+
const refs = extractReferences(
|
|
36
|
+
[
|
|
37
|
+
`import x from "./sibling";`,
|
|
38
|
+
`export { y } from "../up";`,
|
|
39
|
+
`import _ = require("../index");`,
|
|
40
|
+
`import bare from "lodash";`,
|
|
41
|
+
].join("\n"),
|
|
42
|
+
);
|
|
43
|
+
const targets = refs.map((r) => r.target);
|
|
44
|
+
expect(targets).toContain("./sibling");
|
|
45
|
+
expect(targets).toContain("../up");
|
|
46
|
+
expect(targets).toContain("../index");
|
|
47
|
+
expect(targets).not.toContain("lodash");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("resolvePackageTypeClosure", () => {
|
|
52
|
+
let nm: string;
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
nm = path.join(
|
|
55
|
+
await mkdtemp(path.join(tmpdir(), "cs-types-")),
|
|
56
|
+
"node_modules",
|
|
57
|
+
);
|
|
58
|
+
await mkdir(nm, { recursive: true });
|
|
59
|
+
});
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await rm(path.dirname(nm), { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Write a file under node_modules at the given relative path. */
|
|
65
|
+
async function file(rel: string, content: string): Promise<void> {
|
|
66
|
+
const full = path.join(nm, rel);
|
|
67
|
+
await mkdir(path.dirname(full), { recursive: true });
|
|
68
|
+
await writeFile(full, content);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test("lodash-like: own types absent, @types present, multi-file /// <reference> closure, unwrapped", async () => {
|
|
72
|
+
// lodash itself ships NO own types.
|
|
73
|
+
await file(
|
|
74
|
+
"lodash/package.json",
|
|
75
|
+
JSON.stringify({ name: "lodash", main: "index.js" }),
|
|
76
|
+
);
|
|
77
|
+
await file("lodash/index.js", "module.exports = {};");
|
|
78
|
+
// @types/lodash: index fans out via /// <reference path>.
|
|
79
|
+
await file(
|
|
80
|
+
"@types/lodash/package.json",
|
|
81
|
+
JSON.stringify({ name: "@types/lodash", types: "index.d.ts" }),
|
|
82
|
+
);
|
|
83
|
+
await file(
|
|
84
|
+
"@types/lodash/index.d.ts",
|
|
85
|
+
[
|
|
86
|
+
`/// <reference path="./common/common.d.ts" />`,
|
|
87
|
+
`export = _;`,
|
|
88
|
+
`export as namespace _;`,
|
|
89
|
+
`declare const _: _.LoDashStatic;`,
|
|
90
|
+
`declare namespace _ { interface LoDashStatic {} }`,
|
|
91
|
+
].join("\n"),
|
|
92
|
+
);
|
|
93
|
+
await file(
|
|
94
|
+
"@types/lodash/common/common.d.ts",
|
|
95
|
+
`import _ = require("../index");\ndeclare module "../index" { interface LoDashStatic { debounce(): void; } }`,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const closure = await resolvePackageTypeClosure({
|
|
99
|
+
nodeModulesDir: nm,
|
|
100
|
+
specifier: "lodash",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(closure.notFound).toBe(false);
|
|
104
|
+
expect(closure.hasOwnTypes).toBe(false);
|
|
105
|
+
expect(closure.hasAtTypes).toBe(true);
|
|
106
|
+
const paths = closure.files.map((f) => f.path);
|
|
107
|
+
expect(paths).toContain("node_modules/@types/lodash/index.d.ts");
|
|
108
|
+
// The referenced common/common.d.ts must be followed + included.
|
|
109
|
+
expect(paths).toContain("node_modules/@types/lodash/common/common.d.ts");
|
|
110
|
+
// The @types package.json is included so resolution finds the entry.
|
|
111
|
+
expect(paths).toContain("node_modules/@types/lodash/package.json");
|
|
112
|
+
// UNWRAPPED: no `declare module "lodash"` envelope was added.
|
|
113
|
+
const indexFile = closure.files.find(
|
|
114
|
+
(f) => f.path === "node_modules/@types/lodash/index.d.ts",
|
|
115
|
+
);
|
|
116
|
+
expect(indexFile?.content).toContain("export = _;");
|
|
117
|
+
expect(indexFile?.content).not.toContain('declare module "lodash"');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("bundled-types (zod/dayjs-like): own index.d.ts present, no @types, returns bundled files (not a 404)", async () => {
|
|
121
|
+
await file(
|
|
122
|
+
"zod/package.json",
|
|
123
|
+
JSON.stringify({ name: "zod", types: "index.d.ts", main: "index.js" }),
|
|
124
|
+
);
|
|
125
|
+
await file("zod/index.d.ts", `export declare function parse(): unknown;`);
|
|
126
|
+
await file("zod/index.js", "module.exports = {};");
|
|
127
|
+
// No @types/zod on purpose.
|
|
128
|
+
|
|
129
|
+
const closure = await resolvePackageTypeClosure({
|
|
130
|
+
nodeModulesDir: nm,
|
|
131
|
+
specifier: "zod",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(closure.notFound).toBe(false);
|
|
135
|
+
expect(closure.hasOwnTypes).toBe(true);
|
|
136
|
+
expect(closure.hasAtTypes).toBe(false);
|
|
137
|
+
const paths = closure.files.map((f) => f.path);
|
|
138
|
+
expect(paths).toContain("node_modules/zod/index.d.ts");
|
|
139
|
+
expect(paths).toContain("node_modules/zod/package.json");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("bundled-types via exports map `types` condition", async () => {
|
|
143
|
+
await file(
|
|
144
|
+
"dayjs/package.json",
|
|
145
|
+
JSON.stringify({
|
|
146
|
+
name: "dayjs",
|
|
147
|
+
exports: {
|
|
148
|
+
".": { types: "./esm/index.d.ts", import: "./esm/index.js" },
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
await file(
|
|
153
|
+
"dayjs/esm/index.d.ts",
|
|
154
|
+
`export declare function dayjs(): unknown;`,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const closure = await resolvePackageTypeClosure({
|
|
158
|
+
nodeModulesDir: nm,
|
|
159
|
+
specifier: "dayjs",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(closure.hasOwnTypes).toBe(true);
|
|
163
|
+
expect(closure.files.map((f) => f.path)).toContain(
|
|
164
|
+
"node_modules/dayjs/esm/index.d.ts",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("scoped: @scope/name resolves to @types/scope__name", async () => {
|
|
169
|
+
await file(
|
|
170
|
+
"@babel/core/package.json",
|
|
171
|
+
JSON.stringify({ name: "@babel/core", main: "lib/index.js" }),
|
|
172
|
+
);
|
|
173
|
+
await file("@babel/core/lib/index.js", "module.exports = {};");
|
|
174
|
+
await file(
|
|
175
|
+
"@types/babel__core/package.json",
|
|
176
|
+
JSON.stringify({ name: "@types/babel__core", types: "index.d.ts" }),
|
|
177
|
+
);
|
|
178
|
+
await file(
|
|
179
|
+
"@types/babel__core/index.d.ts",
|
|
180
|
+
`export declare function transform(): unknown;`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const closure = await resolvePackageTypeClosure({
|
|
184
|
+
nodeModulesDir: nm,
|
|
185
|
+
specifier: "@babel/core",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(closure.hasAtTypes).toBe(true);
|
|
189
|
+
expect(closure.files.map((f) => f.path)).toContain(
|
|
190
|
+
"node_modules/@types/babel__core/index.d.ts",
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("both own types AND @types present -> both included", async () => {
|
|
195
|
+
await file(
|
|
196
|
+
"thing/package.json",
|
|
197
|
+
JSON.stringify({ name: "thing", types: "index.d.ts" }),
|
|
198
|
+
);
|
|
199
|
+
await file("thing/index.d.ts", `export const own: number;`);
|
|
200
|
+
await file(
|
|
201
|
+
"@types/thing/package.json",
|
|
202
|
+
JSON.stringify({ name: "@types/thing", types: "index.d.ts" }),
|
|
203
|
+
);
|
|
204
|
+
await file("@types/thing/index.d.ts", `export const fromTypes: string;`);
|
|
205
|
+
|
|
206
|
+
const closure = await resolvePackageTypeClosure({
|
|
207
|
+
nodeModulesDir: nm,
|
|
208
|
+
specifier: "thing",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(closure.hasOwnTypes).toBe(true);
|
|
212
|
+
expect(closure.hasAtTypes).toBe(true);
|
|
213
|
+
const paths = closure.files.map((f) => f.path);
|
|
214
|
+
expect(paths).toContain("node_modules/thing/index.d.ts");
|
|
215
|
+
expect(paths).toContain("node_modules/@types/thing/index.d.ts");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("neither own types nor @types -> graceful empty closure, no throw", async () => {
|
|
219
|
+
await file(
|
|
220
|
+
"notyped/package.json",
|
|
221
|
+
JSON.stringify({ name: "notyped", main: "index.js" }),
|
|
222
|
+
);
|
|
223
|
+
await file("notyped/index.js", "module.exports = 1;");
|
|
224
|
+
|
|
225
|
+
const closure = await resolvePackageTypeClosure({
|
|
226
|
+
nodeModulesDir: nm,
|
|
227
|
+
specifier: "notyped",
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(closure.notFound).toBe(true);
|
|
231
|
+
expect(closure.hasOwnTypes).toBe(false);
|
|
232
|
+
expect(closure.hasAtTypes).toBe(false);
|
|
233
|
+
// package.json is still included (harmless), but no .d.ts.
|
|
234
|
+
expect(closure.files.every((f) => !f.path.endsWith(".d.ts"))).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("missing package entirely -> notFound, never throws", async () => {
|
|
238
|
+
const closure = await resolvePackageTypeClosure({
|
|
239
|
+
nodeModulesDir: nm,
|
|
240
|
+
specifier: "ghost",
|
|
241
|
+
});
|
|
242
|
+
expect(closure.notFound).toBe(true);
|
|
243
|
+
expect(closure.files).toHaveLength(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("follows cross-package /// <reference types> into another @types", async () => {
|
|
247
|
+
await file(
|
|
248
|
+
"@types/needsnode/package.json",
|
|
249
|
+
JSON.stringify({ name: "@types/needsnode", types: "index.d.ts" }),
|
|
250
|
+
);
|
|
251
|
+
await file(
|
|
252
|
+
"@types/needsnode/index.d.ts",
|
|
253
|
+
`/// <reference types="node" />\nexport declare const x: number;`,
|
|
254
|
+
);
|
|
255
|
+
await file(
|
|
256
|
+
"@types/node/package.json",
|
|
257
|
+
JSON.stringify({ name: "@types/node", types: "index.d.ts" }),
|
|
258
|
+
);
|
|
259
|
+
await file("@types/node/index.d.ts", `declare const process: unknown;`);
|
|
260
|
+
await file("needsnode/package.json", JSON.stringify({ name: "needsnode" }));
|
|
261
|
+
|
|
262
|
+
const closure = await resolvePackageTypeClosure({
|
|
263
|
+
nodeModulesDir: nm,
|
|
264
|
+
specifier: "needsnode",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(closure.files.map((f) => f.path)).toContain(
|
|
268
|
+
"node_modules/@types/node/index.d.ts",
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("truncated flag set (not silent) when the closure exceeds the ceiling", async () => {
|
|
273
|
+
const big = "x".repeat(2000);
|
|
274
|
+
await file(
|
|
275
|
+
"@types/huge/package.json",
|
|
276
|
+
JSON.stringify({ name: "@types/huge", types: "index.d.ts" }),
|
|
277
|
+
);
|
|
278
|
+
await file(
|
|
279
|
+
"@types/huge/index.d.ts",
|
|
280
|
+
`/// <reference path="./a.d.ts" />\nexport const a: 1;`,
|
|
281
|
+
);
|
|
282
|
+
await file("@types/huge/a.d.ts", `export const big = "${big}";`);
|
|
283
|
+
await file("huge/package.json", JSON.stringify({ name: "huge" }));
|
|
284
|
+
|
|
285
|
+
const closure = await resolvePackageTypeClosure({
|
|
286
|
+
nodeModulesDir: nm,
|
|
287
|
+
specifier: "huge",
|
|
288
|
+
maxTotalBytes: 100, // tiny ceiling forces a drop
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(closure.truncated).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
});
|