mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-06 08:23:19 +08:00
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:
co-authored by
Claude Opus 5.5
parent
c46f70e451
commit
f4825e05a7
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+16
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/effect@4.0.0/node_modules/effect
|
||||
@@ -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 ?? "-");
|
||||
+8
@@ -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;
|
||||
};
|
||||
+4
@@ -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 };
|
||||
+17
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "eventlog", "version": "2.3.1", "main": "dist/index.js", "types": "dist/index.d.ts" }
|
||||
@@ -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)));
|
||||
Generated
Vendored
+17
@@ -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;
|
||||
Generated
Vendored
+1
@@ -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;
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user