Replace a package's declaration files or supply its declarations with type patches

A scriptc.typePatches entry can now replace a declaration file of the
package (or of its @types twin, written @types/<name>/<path>) with a
local .d.ts ("files"), or give the package a declaration entry ("types").
A supplied entry answers the package's types file and the declarations
TypeScript probes beside its JavaScript entries, and exists for the
program host, the resolver and npm-static's untyped-package test, so a
package without declarations becomes a typed package. Declaration patches
still apply on top. A files target the installed copy does not ship is
SC8031.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 12:05:52 -07:00
co-authored by Claude Opus 5.5
parent c46f70e451
commit f4825e05a7
25 changed files with 513 additions and 33 deletions
+19
View File
@@ -172,6 +172,25 @@ Each key names a package and a semver range of the installed versions the patch
A declaration is named by its path: `Options.name` for a member of a type, `parse()` for a function's return type, `parse(text)` for a parameter, `list()[]` for an array's elements, and `Env[string]` for an index signature. `export=()` names the return type of the function the module exports, `default` its default export, and a file prefix such as `index.js:parse()` names a declaration inside one of the package's files. One patch applies to the package's own declarations, its JSDoc, and its `@types/` package. The patched type then holds everywhere, so your code has to handle the `undefined` that a widened declaration allows. An optional `"note"` string can link the upstream issue.
For larger fixes, replace a whole declaration file, or give a package without declarations its own:
```json:package.json
{
"scriptc": {
"typePatches": {
"some-client@^3.2.0": {
"files": { "dist/types/options.d.ts": "./type-patches/options.d.ts" }
},
"untyped-helper@1.x": {
"types": "./type-patches/untyped-helper.d.ts"
}
}
}
}
```
`files` maps a declaration file inside the package, or inside its `@types/` package written as `@types/<name>/<path>`, to a local `.d.ts` file. `types` supplies the package's declaration entry. Paths of local files are relative to the `package.json`, and a replaced file can import the package's other declaration files with the same relative paths as the file it replaces.
Patches change only declarations, never the package's code, and values are still checked against the patched types. A build warns when a patch doesn't apply as written: when no range covers the installed version, when a declaration path matches nothing, when the declaration already has the type, or when a path is ambiguous. Report wrong declarations to the package too, so the patch can be removed later.
## Provenance sources (experimental)
+2 -1
View File
@@ -23,7 +23,7 @@ import {
trackedRealpath,
} from "./input-tracker.js";
import { npmBuiltinAliasOf } from "./builtin-modules.js";
import { packageSourceText } from "./type-patches/layer.js";
import { packageSourceText, typePatchSuppliesTypes } from "./type-patches/layer.js";
let activePackages: ReadonlySet<string> = new Set();
let declarationOverloads: ReadonlyMap<string, NpmStaticDeclarationOverloads> = new Map();
@@ -374,6 +374,7 @@ function packageIsUntyped(path: string): boolean {
/* no package.json — keep probing */
}
if (untyped && trackedExists(`${pkgDir}/index.d.ts`)) untyped = false;
if (untyped && typePatchSuppliesTypes(pkgDir)) untyped = false;
if (untyped) {
// the @types twin, hoisted anywhere up the realm chain
const mangled = mangledTypesName(dirName);
+3 -1
View File
@@ -37,9 +37,11 @@ import {
trackedRealpath,
} from "./input-tracker.js";
import { isRelativeSpecifier, packageNameOfSpecifier } from "./workspace-registry.js";
import { typePatchFileExists } from "./type-patches/layer.js";
function isFile(path: string): boolean {
return trackedFileExists(path);
// A declaration entry a project type patch supplies (type-patches/layer.ts).
return trackedFileExists(path) || typePatchFileExists(path);
}
function isDirectory(path: string): boolean {
@@ -46,7 +46,7 @@ import {
trackedRealpath,
} from "../input-tracker.js";
import { packageShimDeclarationText } from "../package-shims.js";
import { typePatchedText } from "../type-patches/layer.js";
import { typePatchedText, typePatchFileExists } from "../type-patches/layer.js";
import { ambientDtsPath, fallbackDtsPath, overridesDtsPath } from "../dts-paths.js";
import { reusedLibDiagnostics } from "./lib-diagnostics.js";
import { ts7Diagnostic } from "./diagnostic.js";
@@ -190,7 +190,7 @@ export class Ts7Host {
fileExists: (fileName) => {
if (virtualFiles.has(tsgoPath(fileName))) return true;
if (shadow !== null && shadow.hideFile(fileName)) return false;
return trackedFileExists(fileName);
return trackedFileExists(fileName) || typePatchFileExists(fileName);
},
directoryExists: (path) => trackedDirectoryExists(path),
realpath: (path) =>
@@ -27,7 +27,7 @@ import { dirname, relative } from "node:path";
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
import { typePatchDiag } from "../../diagnostics/diagnostic.js";
import { readProjectConfig, type TypePatchEntry } from "../../project-config.js";
import { trackedReadFile } from "../input-tracker.js";
import { trackedFileExists, trackedReadFile } from "../input-tracker.js";
import type { SourceFile } from "../ts7/syntax.js";
import { applyPatchEdits, patchFile, type PatchMatch, type PatchRequest } from "./locate.js";
import { parseSelector, parseTypeText, type Selector, type TypePatchOp } from "./selector.js";
@@ -49,6 +49,11 @@ interface CopyInfo {
root: string;
/** Requests whose entry's range the version satisfies. */
requests: Request[];
/** Replaced declaration files: package-relative path → local file. */
files: Map<string, string>;
/** Declaration entries a `types` patch supplies: package-relative path
* → local file (existing entries are replaced, missing ones appear). */
entries: Map<string, string>;
}
interface MatchRecord extends PatchMatch {
@@ -61,9 +66,11 @@ interface State {
manifest: string;
manifestText: string;
parse: TypePatchParse;
byPackage: Map<string, Request[]>;
byPackage: Map<string, { entries: TypePatchEntry[]; requests: Request[] }>;
copies: Map<string, CopyInfo | null>;
files: Map<string, string | null>;
/** `files` targets that do not exist in a copy. */
missingFiles: { entry: TypePatchEntry; root: string; rel: string }[];
/** Package copies whose version no patch range covers. */
unmatchedCopies: Map<string, { pkg: string; version: string | null; entries: TypePatchEntry[] }>;
/** Requests applicable to a copy this load read files of. */
@@ -84,22 +91,23 @@ export function configureTypePatches(entryPath: string, parse: TypePatchParse):
state = null;
return;
}
const byPackage = new Map<string, Request[]>();
const byPackage = new Map<string, { entries: TypePatchEntry[]; requests: Request[] }>();
let id = 0;
for (const entry of config.typePatches) {
const pkg = byPackage.get(entry.pkg) ?? { entries: [], requests: [] };
byPackage.set(entry.pkg, pkg);
pkg.entries.push(entry);
for (const decl of entry.declarations) {
const selector = parseSelector(decl.selector);
const op = parseTypeText(decl.type);
if ("error" in selector || "error" in op) continue; // reported as SC8004
const list = byPackage.get(entry.pkg) ?? [];
list.push({
pkg.requests.push({
id: id++,
selector: selector as Selector,
op: op as TypePatchOp,
entry,
offset: decl.offset,
});
byPackage.set(entry.pkg, list);
}
}
state = {
@@ -109,6 +117,7 @@ export function configureTypePatches(entryPath: string, parse: TypePatchParse):
byPackage,
copies: new Map(),
files: new Map(),
missingFiles: [],
unmatchedCopies: new Map(),
touched: new Set(),
matches: [],
@@ -145,7 +154,7 @@ function versionAt(dir: string): string | null {
/** The installed package copy a file belongs to, with the requests that
* apply to it; null for files outside node_modules or of unpatched
* packages. */
function copyOf(s: State, path: string): { copy: CopyInfo; rel: string } | null {
function copyOf(s: State, path: string, touch = true): { copy: CopyInfo; rel: string } | null {
const marker = "/node_modules/";
const at = path.lastIndexOf(marker);
if (at < 0) return null;
@@ -159,33 +168,120 @@ function copyOf(s: State, path: string): { copy: CopyInfo; rel: string } | null
let copy = s.copies.get(root);
if (copy === undefined) {
copy = null;
const pkg = name.startsWith("@types/") ? unmangleTypesName(name) : name;
const requests = s.byPackage.get(pkg);
if (requests !== undefined) {
const twin = name.startsWith("@types/");
const pkg = twin ? unmangleTypesName(name) : name;
const patches = s.byPackage.get(pkg);
if (patches !== undefined) {
// A twin describes the runtime package installed beside it.
const version = name.startsWith("@types/")
const version = twin
? (versionAt(path.slice(0, at + marker.length) + pkg) ?? versionAt(root))
: versionAt(root);
const applicable = requests.filter(
(r) => version !== null && satisfies(version, r.entry.range),
const applicable = patches.entries.filter(
(e) => version !== null && satisfies(version, e.range),
);
// Later entries win on the same selector.
const bySelector = new Map<string, Request>();
for (const r of applicable) bySelector.set(r.selector.text, r);
const chosen = [...bySelector.values()];
if (chosen.length === 0) {
const entries = [...new Set(requests.map((r) => r.entry))];
s.unmatchedCopies.set(root, { pkg, version, entries });
if (applicable.length === 0) {
s.unmatchedCopies.set(root, { pkg, version, entries: patches.entries });
} else {
// Later entries win on the same selector, file or entry.
const bySelector = new Map<string, Request>();
for (const r of patches.requests)
if (applicable.includes(r.entry)) bySelector.set(r.selector.text, r);
const files = new Map<string, string>();
let types: string | null = null;
const prefix = `${name}/`;
for (const e of applicable) {
for (const [target, local] of e.files) {
// `@types/<name>/<path>` names the twin's file; plain paths the
// package's own.
const forTwin = target.startsWith("@types/");
if (forTwin !== twin) continue;
const rel = forTwin
? target.startsWith(prefix)
? target.slice(prefix.length)
: null
: target;
if (rel === null) continue;
if (!trackedFileExists(`${root}/${rel}`)) {
s.missingFiles.push({ entry: e, root, rel });
continue;
}
files.set(rel, local);
}
if (e.types !== null && !twin) types = e.types;
}
copy = {
pkg,
version,
root,
requests: [...bySelector.values()],
files,
entries: types === null ? new Map() : declarationEntries(root, types),
};
}
copy = chosen.length === 0 ? null : { pkg, version, root, requests: chosen };
}
s.copies.set(root, copy);
}
if (copy === null) return null;
for (const r of copy.requests) s.touched.add(r);
if (touch) for (const r of copy.requests) s.touched.add(r);
return { copy, rel };
}
/** The declaration entry paths of a package a `types` patch stands for:
* its `types`/`typings` file and every declaration TypeScript probes
* beside a JavaScript entry (`main`, the `exports` targets of "."). */
function declarationEntries(root: string, local: string): Map<string, string> {
const out = new Map<string, string>();
let pkg: Record<string, unknown> = {};
try {
pkg = JSON.parse(trackedReadFile(`${root}/package.json`) ?? "{}") as Record<string, unknown>;
} catch {
pkg = {};
}
const add = (target: unknown): void => {
if (typeof target !== "string") return;
const rel = target.replace(/^\.\//, "");
if (/\.d\.[cm]?ts$/.test(rel)) out.set(rel, local);
else if (/\.[cm]?js$/.test(rel)) out.set(rel.replace(/\.([cm]?)js$/, ".d.$1ts"), local);
else if (!/\.\w+$/.test(rel)) out.set(`${rel}.d.ts`, local);
};
add(pkg["types"]);
add(pkg["typings"]);
add(typeof pkg["main"] === "string" ? pkg["main"] : "index.js");
const exportsField = pkg["exports"];
const dot =
exportsField !== null && typeof exportsField === "object" && "." in exportsField
? (exportsField as Record<string, unknown>)["."]
: exportsField;
const visit = (v: unknown): void => {
if (typeof v === "string") add(v);
else if (v !== null && typeof v === "object") for (const x of Object.values(v)) visit(x);
};
// Subpath exports ("./x") are not the entry.
if (dot !== null && typeof dot === "object" && Object.keys(dot).some((k) => k.startsWith(".")))
return out;
visit(dot);
return out;
}
/** True for a declaration entry a `types` patch supplies that does not
* exist on disk (a package without declarations gains one). */
export function typePatchFileExists(fileName: string): boolean {
const s = state;
if (s === null) return false;
const path = fileName.split("\\").join("/");
if (!/\.d\.[cm]?ts$/.test(path)) return false;
const located = copyOf(s, path, false);
return located !== null && located.copy.entries.has(located.rel);
}
/** True when a `types` patch gives the package at `pkgDir` declarations. */
export function typePatchSuppliesTypes(pkgDir: string): boolean {
const s = state;
if (s === null) return false;
const located = copyOf(s, `${pkgDir.split("\\").join("/")}/package.json`, false);
return located !== null && located.copy.entries.size > 0;
}
/** Names a request's root can appear under in a file's text. */
function rootWords(sel: Selector): string[] {
if (sel.root === "export=") return ["exports", "export"];
@@ -205,7 +301,12 @@ export function typePatchedText(fileName: string): string | undefined {
let result: string | null = null;
const located = copyOf(s, path);
if (located !== null) {
const text = trackedReadFile(path);
// A replaced file or a supplied entry reads as the local file; the
// declaration patches then apply to it like to the package's own.
const local = located.copy.files.get(located.rel) ?? located.copy.entries.get(located.rel);
const replaced = local === undefined ? null : trackedReadFile(local);
const text = replaced ?? trackedReadFile(path);
if (replaced !== null) result = replaced;
const requests = located.copy.requests.filter(
(r) =>
(r.selector.file === null || r.selector.file === located.rel) &&
@@ -314,6 +415,16 @@ export function typePatchDiagnostics(): {
);
}
}
for (const missing of s.missingFiles) {
out.push(
typePatchDiag(
"SC8031",
`type patch '${missing.entry.key}': ${missing.rel} is not a file of ${missing.entry.pkg} at ${relativeToManifest(s, missing.root)}`,
"name a declaration file the installed package ships; the package may have moved it",
loc(s, missing.entry.offset),
),
);
}
for (const r of s.touched) {
const mine = s.matches.filter((m) => m.id === r.id);
// A selector that matched somewhere (the .d.ts) is not a problem where
+42 -2
View File
@@ -214,7 +214,7 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () =
"A.c": "string\nnumber",
"A.d": "| undefined",
},
files: {},
patches: {},
},
"other@^1.0.0": [],
},
@@ -225,7 +225,7 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () =
`package.json 'scriptc.typePatches.ms@^banana' has the version range "^banana", which is not a semver range`,
"package.json 'scriptc.typePatches.@types/node@*' patches @types/node, whose declarations ship with scriptc or TypeScript",
"package.json 'scriptc.typePatches.typescript@5' patches typescript, whose declarations ship with scriptc or TypeScript",
"package.json 'scriptc.typePatches.lib@^1.0.0.files' is not a known type patch setting",
"package.json 'scriptc.typePatches.lib@^1.0.0.patches' is not a known type patch setting",
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.1bad' is not a selector: it must start with a declared name, 'export=' or 'default'",
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.A.b' must map to a type string, not 3",
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.A.c' has a type that must be one line",
@@ -241,3 +241,43 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () =
});
expect(partial.typePatches[0]!.declarations.map((d) => d.selector)).toEqual(["A.d"]);
});
test("typePatches: files and types name local declaration files", () => {
const root = mkdtempSync(join(tmpdir(), "scriptc-project-config-"));
try {
mkdirSync(join(root, "patches"));
writeFileSync(join(root, "patches", "entry.d.ts"), "export {};\n");
const text = JSON.stringify({
scriptc: {
typePatches: {
"lib@^1.0.0": {
files: {
"dist/entry.d.ts": "./patches/entry.d.ts",
"@types/lib/index.d.ts": "patches/entry.d.ts",
"dist/index.js": "./patches/entry.d.ts",
"../outside.d.ts": "./patches/entry.d.ts",
"dist/other.d.ts": "./patches/missing.d.ts",
},
types: "./patches/entry.d.ts",
},
"other@^1.0.0": { types: "./patches/entry.ts" },
},
},
});
const c = parseProjectConfig(join(root, "package.json"), text);
expect(c.diagnostics.map((d) => d.message)).toEqual([
"package.json 'scriptc.typePatches.lib@^1.0.0.files.dist/index.js' must name a declaration file inside the package",
"package.json 'scriptc.typePatches.lib@^1.0.0.files.../outside.d.ts' must name a declaration file inside the package",
`package.json 'scriptc.typePatches.lib@^1.0.0.files.dist/other.d.ts' names "./patches/missing.d.ts", which does not exist`,
`package.json 'scriptc.typePatches.other@^1.0.0.types' names "./patches/entry.ts", which is not a declaration file`,
]);
const entry = c.typePatches.find((e) => e.pkg === "lib")!;
expect([...entry.files]).toEqual([
["dist/entry.d.ts", join(root, "patches/entry.d.ts")],
["@types/lib/index.d.ts", join(root, "patches/entry.d.ts")],
]);
expect(entry.types).toBe(join(root, "patches/entry.d.ts"));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+66 -2
View File
@@ -27,7 +27,7 @@
* to their own readers. */
import { dirname, join, resolve } from "node:path";
import { projectConfigDiag, type ScrDiagnostic } from "./diagnostics/diagnostic.js";
import { trackedReadFile } from "./frontend/input-tracker.js";
import { trackedFileExists, trackedReadFile } from "./frontend/input-tracker.js";
import { parseSelector, parseTypeText } from "./frontend/type-patches/selector.js";
import { isValidRange } from "./frontend/type-patches/semver.js";
@@ -79,6 +79,13 @@ export interface TypePatchEntry {
pkg: string;
range: string;
declarations: TypePatchDeclaration[];
/** `files`: declaration files of the package (or of its `@types/` twin,
* written `@types/<name>/<path>`) replaced by local files, by
* package-relative path. The local paths are absolute. */
files: Map<string, string>;
/** `types`: a local declaration file that becomes the package's
* declaration entry (absolute), or null. */
types: string | null;
/** Offset of the key in the manifest. */
offset: number;
}
@@ -341,7 +348,8 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi
return config;
}
const TYPE_PATCH_KEYS = ["declarations", "note"];
const TYPE_PATCH_KEYS = ["declarations", "files", "types", "note"];
const DECLARATION_FILE = /\.d\.[cm]?ts$/;
/** Packages whose declarations ship with scriptc or TypeScript: a wrong
* one is a bug to report, not a project-local patch. */
const UNPATCHABLE = /^(?:@types\/node|typescript|@typescript\/.*)$/;
@@ -404,8 +412,64 @@ function readTypePatches(
pkg,
range,
declarations: [],
files: new Map(),
types: null,
offset: locate.offsetOf(["scriptc", "typePatches", key]),
};
const base = dirname(config.manifest!);
const localFile = (path: string[], v: unknown): string | null => {
if (typeof v !== "string" || v === "") {
problem(
path,
`must be the path of a local declaration file, not ${shown(v)}`,
`write "./type-patches/${pkg}.d.ts"`,
);
return null;
}
const abs = resolve(base, v);
if (!DECLARATION_FILE.test(abs)) {
problem(
path,
`names "${v}", which is not a declaration file`,
"use a .d.ts, .d.mts or .d.cts file",
);
return null;
}
if (!trackedFileExists(abs)) {
problem(
path,
`names "${v}", which does not exist`,
"give the path relative to this package.json",
);
return null;
}
return abs;
};
const files = value["files"];
if (files !== undefined && !isRecord(files)) {
problem(
["typePatches", key, "files"],
`must be an object mapping declaration files of the package to local files, not ${shown(files)}`,
`write "files": { "dist/index.d.ts": "./type-patches/${pkg}.d.ts" }`,
);
} else if (files !== undefined) {
for (const target of Object.keys(files)) {
const path = ["typePatches", key, "files", target];
const rel = target.replace(/^\.\//, "");
if (!DECLARATION_FILE.test(rel) || rel.startsWith("/") || rel.split("/").includes("..")) {
problem(
path,
"must name a declaration file inside the package",
`write a package-relative .d.ts path such as "dist/index.d.ts", or "@types/<name>/index.d.ts" for its @types package`,
);
continue;
}
const abs = localFile(path, files[target]);
if (abs !== null) entry.files.set(rel, abs);
}
}
if (value["types"] !== undefined)
entry.types = localFile(["typePatches", key, "types"], value["types"]);
let valid = true;
for (const k of Object.keys(value)) {
if (!TYPE_PATCH_KEYS.includes(k)) {
@@ -0,0 +1,25 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/type-patch-files/main.ts"
],
"diags": [
{
"code": "SC0001",
"message": "Property 'toUpperCase' does not exist on type 'number'.",
"loc": {
"file": "<repo>/tests/fixtures/npm/cases/type-patch-files/main.ts",
"start": 346,
"end": 357
}
},
{
"code": "SC0001",
"message": "This comparison appears to be unintentional because the types 'number' and 'string' have no overlap.",
"loc": {
"file": "<repo>/tests/fixtures/npm/cases/type-patch-files/main.ts",
"start": 429,
"end": 447
}
}
]
}
@@ -0,0 +1,16 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/type-patch-types-entry/main.ts"
],
"diags": [
{
"code": "SC0001",
"message": "Could not find a declaration file for module 'pointjs'. '<repo>/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/lib/point.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/pointjs` if it exists or add a new declaration (.d.ts) file containing `declare module 'pointjs';`",
"loc": {
"file": "<repo>/tests/fixtures/npm/cases/type-patch-types-entry/main.ts",
"start": 246,
"end": 255
}
}
]
}
@@ -12,7 +12,9 @@ import {
typePatchDiagnostics,
typePatchedSpans,
typePatchedText,
typePatchFileExists,
typePatchMatches,
typePatchSuppliesTypes,
} from "../src/frontend/type-patches/layer.js";
const fixtures = join(import.meta.dirname, "../../../tests/fixtures");
@@ -122,3 +124,56 @@ test("an @types twin is matched by the runtime package's installed version", ()
rmSync(root, { recursive: true, force: true });
}
});
test("files replaces a declaration file and declarations still apply on top", () => {
const root = join(fixtures, "npm/cases/type-patch-files");
configureTypePatches(join(root, "main.ts"), parse);
const entry = typePatchedText(join(root, "node_modules/eventlog/dist/entry.d.ts"));
expect(entry).toBe(readFileSync(join(root, "type-patches/entry.d.ts"), "utf8"));
expect(typePatchedText(join(root, "node_modules/eventlog/dist/index.d.ts"))).toContain(
"{ count: number; last: string | undefined }",
);
expect(typePatchDiagnostics()!.diagnostics).toEqual([]);
});
test("types gives a package without declarations an entry beside its main", () => {
const root = join(fixtures, "npm/cases/type-patch-types-entry");
configureTypePatches(join(root, "main.ts"), parse);
const pkg = join(root, "node_modules/pointjs");
expect(typePatchFileExists(join(pkg, "lib/point.d.ts"))).toBe(true);
expect(typePatchFileExists(join(pkg, "index.d.ts"))).toBe(false);
expect(typePatchFileExists(join(pkg, "lib/other.d.ts"))).toBe(false);
expect(typePatchSuppliesTypes(pkg)).toBe(true);
expect(typePatchedText(join(pkg, "lib/point.d.ts"))).toBe(
readFileSync(join(root, "type-patches/pointjs.d.ts"), "utf8"),
);
// Without patches nothing exists that is not on disk.
configureTypePatches(join(tmpdir(), "none", "main.ts"), parse);
expect(typePatchFileExists(join(pkg, "lib/point.d.ts"))).toBe(false);
expect(typePatchSuppliesTypes(pkg)).toBe(false);
});
test("a files target the installed package does not ship warns", () => {
const root = mkdtempSync(join(tmpdir(), "scriptc-type-patches-"));
try {
const twin = join(fixtures, "npm/cases/type-patch-files");
writeFileSync(join(root, "local.d.ts"), "export type Entry = {};\n");
writeFileSync(
join(root, "package.json"),
JSON.stringify({
scriptc: {
typePatches: { "eventlog@^2.0.0": { files: { "dist/gone.d.ts": "./local.d.ts" } } },
},
}),
);
configureTypePatches(join(root, "main.ts"), parse);
expect(typePatchedText(join(twin, "node_modules/eventlog/dist/index.d.ts"))).toBeUndefined();
const diags = typePatchDiagnostics()!.diagnostics;
expect(diags.map((d) => d.code)).toEqual(["SC8031"]);
expect(diags[0]!.message).toMatch(
/^type patch 'eventlog@\^2\.0\.0': dist\/gone\.d\.ts is not a file of eventlog at .*type-patch-files\/node_modules\/eventlog$/,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+1
View File
@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@effect+platform-node@4.0.0_effect@4.0.0_redis@6.3.0/node_modules/@effect/platform-node
+1
View File
@@ -0,0 +1 @@
../../../../node_modules/.pnpm/effect@4.0.0/node_modules/effect
+10
View File
@@ -0,0 +1,10 @@
// A package declaration file replaced by a local one (scriptc.typePatches
// files), plus a declaration patch on another file of the same package.
import { parseLog, summarize } from "eventlog";
const entries = parseLog("10 info ada started\n12 warn - disk low\n15 error lin crashed hard\n");
for (const e of entries) console.log(e.at, e.level.toUpperCase(), e.user ?? "(system)", e.message);
console.log(entries.filter((e) => e.level !== "info").length, JSON.stringify(entries[1]));
const all = summarize(entries);
const none = summarize([]);
console.log(all.count, all.last ?? "-", none.count, none.last ?? "-");
@@ -0,0 +1,8 @@
// Out of date: the JavaScript reports levels as strings and has no
// `user` for system entries.
export type Entry = {
at: number;
level: number;
user: string;
message: string;
};
@@ -0,0 +1,4 @@
export type { Entry } from "./entry";
import type { Entry } from "./entry";
export declare function parseLog(text: string): Entry[];
export declare function summarize(entries: Entry[]): { count: number; last: string };
@@ -0,0 +1,17 @@
"use strict";
function parseLog(text) {
const rows = String(text)
.split("\n")
.filter(Boolean)
.map((line) => {
const [at, level, user, ...rest] = line.split(" ");
return { at: Number(at), level: level, user: user === "-" ? undefined : user, message: rest.join(" ") };
});
// Entries travel as JSON (the log format's own wire form).
return JSON.parse(JSON.stringify(rows));
}
function summarize(entries) {
return { count: entries.length, last: entries.length === 0 ? undefined : entries[entries.length - 1].message };
}
exports.parseLog = parseLog;
exports.summarize = summarize;
@@ -0,0 +1 @@
{ "name": "eventlog", "version": "2.3.1", "main": "dist/index.js", "types": "dist/index.d.ts" }
+13
View File
@@ -0,0 +1,13 @@
{
"name": "type-patch-files-case",
"private": true,
"type": "module",
"scriptc": {
"typePatches": {
"eventlog@^2.0.0": {
"files": { "dist/entry.d.ts": "./type-patches/entry.d.ts" },
"declarations": { "summarize().last": "| undefined" }
}
}
}
}
@@ -0,0 +1,7 @@
// The project's replacement for eventlog's dist/entry.d.ts.
export type Entry = {
at: number;
level: "info" | "warn" | "error";
user?: string;
message: string;
};
@@ -0,0 +1,9 @@
// A package without declarations, typed by a local declaration file
// (scriptc.typePatches types): user code is checked against it, and the
// values cross the declaration boundary as native records.
import { centroid, point, type Point } from "pointjs";
const points: Point[] = [point(0, 0), point(4, 0), point(4, 3)];
const c = centroid(points);
console.log(c === null ? "none" : `${c.x.toFixed(2)},${c.y.toFixed(2)}`);
console.log(centroid([]) === null, JSON.stringify(points.map((p) => p.x + p.y)));
@@ -0,0 +1,17 @@
"use strict";
// Plain JavaScript without declarations.
function point(x, y) {
return { x: x, y: y };
}
function centroid(points) {
if (points.length === 0) return null;
var sx = 0;
var sy = 0;
for (var i = 0; i < points.length; i++) {
sx += points[i].x;
sy += points[i].y;
}
return point(sx / points.length, sy / points.length);
}
exports.point = point;
exports.centroid = centroid;
@@ -0,0 +1 @@
{ "name": "pointjs", "version": "1.4.0", "main": "lib/point.js" }
@@ -0,0 +1,10 @@
{
"name": "type-patch-types-entry-case",
"private": true,
"type": "module",
"scriptc": {
"typePatches": {
"pointjs@1.x": { "types": "./type-patches/pointjs.d.ts" }
}
}
}
@@ -0,0 +1,4 @@
// The project's declarations for pointjs, which ships none.
export type Point = { x: number; y: number };
export declare function point(x: number, y: number): Point;
export declare function centroid(points: Point[]): Point | null;
+46 -2
View File
@@ -4,8 +4,10 @@
* value (the shape of commander's HelpContext.helpWidth), and ms's @return
* tags, compile natively and byte-match Node once patched; without the
* patches the same declarations throw where Node runs. Patches that do not
* apply as written warn (SC8030-SC8033). The `.d.ts` and `@types` cases
* run in npm.test.ts (type-patch-dts, type-patch-types-twin), and
* apply as written warn (SC8030-SC8033). The `.d.ts`, `@types`, replaced
* file and supplied entry cases run in npm.test.ts (type-patch-dts,
* type-patch-types-twin, type-patch-files, type-patch-types-entry); here
* their unpatched copies show the patches are what makes them compile. And
* commander-calc carries the HelpContext.helpWidth patch. */
import { execFile } from "node:child_process";
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -121,6 +123,48 @@ describe("type patches", () => {
},
);
test(
"without its patch, code using the out-of-date declaration file does not type-check",
{ timeout: 600_000 },
async () => {
const copy = unpatched(join(repoRoot, "tests/fixtures/npm/cases/type-patch-files"));
try {
mkdirSync(outDir, { recursive: true });
const result = await compile(join(copy, "main.ts"), {
outPath: join(outDir, "files-unpatched"),
outDir,
sanitize,
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.diagnostics.map((d) => d.message)).toContain(
"Property 'toUpperCase' does not exist on type 'number'.",
);
} finally {
rmSync(copy, { recursive: true, force: true });
}
},
);
test(
"without its patch, a package without declarations is not compiled for a typed import",
{ timeout: 600_000 },
async () => {
const copy = unpatched(join(repoRoot, "tests/fixtures/npm/cases/type-patch-types-entry"));
try {
mkdirSync(outDir, { recursive: true });
const result = await compile(join(copy, "main.ts"), {
outPath: join(outDir, "types-entry-unpatched"),
outDir,
sanitize,
});
expect(result.ok).toBe(false);
} finally {
rmSync(copy, { recursive: true, force: true });
}
},
);
test(
"patches that do not apply as written warn and the build goes on",
{ timeout: 600_000 },