Add scriptc types patch and scriptc types list

`scriptc types patch <package> <selector> <type> [--range] [--dry-run]`
finds the installed package from the working directory, shows every
declaration the selector matches (declaration files, JSDoc, @types
package) with its type before and after, and adds the patch to the
nearest package.json: into an entry whose range covers the installed
version, or under ^<version>. Nothing is written when the selector
matches nothing. `scriptc types list` shows each patch and what it
matches. Both CLI hosts run the same implementation with their own
parser. The TypeError note for a wrong declaration now names the command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 15:07:09 -07:00
co-authored by Claude Opus 5.5
parent 56e8417746
commit 5e843317af
13 changed files with 632 additions and 18 deletions
+26 -1
View File
@@ -2,7 +2,7 @@
title: "CLI Reference"
---
The `scriptc` command has five subcommands: `build`, `run`, `coverage`, `cache warm`, and `merge-profiles`. `scriptc --help` prints this summary and the options below, and `scriptc --version` prints the installed version.
The `scriptc` command has six subcommands: `build`, `run`, `coverage`, `types`, `cache warm`, and `merge-profiles`. `scriptc --help` prints this summary and the options below, and `scriptc --version` prints the installed version.
```console
$ scriptc --help
@@ -21,6 +21,13 @@ Usage:
exporting the profile symbols; a profile
with a sidecar section also gets the
contract sidecar JSON beside the archive
scriptc types patch <package> <selector> <type> [--range <r>] [--dry-run]
fix a wrong declaration of an installed
package: shows what the selector
matches and adds the type patch to
package.json ("| undefined" widens)
scriptc types list the project's type patches and what
they match
scriptc cache warm [runtime|tls…] verify and load precompiled runtime families
for the current compiler/SDK/target
scriptc merge-profiles -o <out.profdata> [--sparse] <profile…>
@@ -88,6 +95,24 @@ $ scriptc run fib.ts
Analyzes the program without building it and reports what compiles and what blocks the rest. See [Coverage Reports](/docs/coverage).
## scriptc types
Writes and lists [type patches](/docs/dependencies#fix-a-packages-types), the project's fixes for wrong declarations of npm packages.
<dl>
<dt><code>scriptc types patch &lt;package&gt; &lt;selector&gt; &lt;type&gt;</code></dt>
<dd>Finds the package installed for the working directory, prints every declaration the selector matches in its declaration files, its JSDoc and its <code>@types/</code> package with the type before and after, and adds the patch to the nearest <code>package.json</code>. The patch joins an entry whose range covers the installed version, or gets the range <code>^&lt;installed version&gt;</code>. Nothing is written when the selector matches nothing. <code>--range &lt;range&gt;</code> chooses the range, and <code>--dry-run</code> only prints.</dd>
<dt><code>scriptc types list</code></dt>
<dd>Prints each type patch in the nearest <code>package.json</code>, whether it applies to the installed version, and what it matches.</dd>
</dl>
```console
$ scriptc types list
commander@^15.0.0
HelpContext.helpWidth: | undefined
node_modules/commander/lib/command.js:2481 number -> number | undefined
```
## scriptc cache warm
Verifies and loads the installed precompiled runtime objects and vendored libraries, and prints the cache directory. Pass `runtime`, `tls`, or both to select which to warm. Ordinary builds don't need this step. With `--sanitize`, it builds the instrumented runtime objects using a C toolchain.
+11 -2
View File
@@ -150,10 +150,19 @@ When the type comes from a package's declarations, the error names the declarati
```text
Uncaught TypeError: expected string at $, got undefined
profilekit@3.1.0 declares Profile.email as string (index.d.ts:7)
if that declaration is wrong, patch it in package.json: "scriptc": { "typePatches": { "profilekit@^3.1.0": { "declarations": { "Profile.email": "| undefined" } } } }
if that declaration is wrong, patch it: scriptc types patch profilekit "Profile.email" "| undefined"
```
Fix such a declaration in your project with a type patch in `package.json`:
Fix such a declaration in your project with a type patch. `scriptc types patch` shows what a declaration path matches in the installed package and adds the patch to your `package.json`:
```console
$ scriptc types patch commander "HelpContext.helpWidth" "| undefined"
commander 15.0.0 (node_modules/commander)
lib/command.js:2481 number -> number | undefined
wrote package.json scriptc.typePatches["commander@^15.0.0"].declarations["HelpContext.helpWidth"]
```
The patch it writes looks like this:
```json:package.json
{
+2
View File
@@ -10,6 +10,7 @@ import {
mergeProfiles,
resolveProvenanceSources,
sourceTargetPlatform,
typePatches,
warmNativeCaches,
} from "@scriptc/compiler";
import { runCli } from "./command.js";
@@ -39,6 +40,7 @@ process.exitCode = await runCli(process.argv.slice(2), {
sourceTargetPlatform,
warmNativeCaches,
mergeProfiles,
typePatches,
run: (binary) =>
new Promise<number>((resolveExit) => {
let child;
+34
View File
@@ -1,3 +1,4 @@
import type { TypePatchCommandRequest } from "../frontend/type-patches/command.js";
import { rmSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { parseArgs } from "node:util";
@@ -205,6 +206,39 @@ async function main(args: string[], host: CliHost): Promise<number> {
process.stdout.write(`${outputPath}\n`);
return 0;
}
if (command === "types") {
const sub = positionals[1];
const rest = positionals.slice(2);
let request: TypePatchCommandRequest;
if (sub === "patch" && rest.length === 3) {
request = {
action: "patch",
cwd: process.cwd(),
pkg: rest[0]!,
selector: rest[1]!,
type: rest[2]!,
range: values.range ?? null,
dryRun: values["dry-run"],
};
} else if (
sub === "list" &&
rest.length === 0 &&
values.range === undefined &&
!values["dry-run"]
) {
request = { action: "list", cwd: process.cwd() };
} else {
fail(
`scriptc types takes "patch <package> <selector> <type>" (with --range or --dry-run) or "list"\n\n${USAGE}`,
);
}
const result = await host.typePatches(request);
process.stdout.write(result.stdout);
process.stderr.write(result.stderr);
return result.exitCode;
}
if (values.range !== undefined || values["dry-run"])
fail(`--range and --dry-run are scriptc types patch options\n\n${USAGE}`);
if (values.sparse) fail(`--sparse is a merge-profiles option\n\n${USAGE}`);
if (command !== "build" && command !== "run" && command !== "coverage") {
fail(`unknown command "${command}"\n\n${USAGE}`);
+6
View File
@@ -1,3 +1,7 @@
import type {
TypePatchCommandRequest,
TypePatchCommandResult,
} from "../frontend/type-patches/command.js";
import type {
AnalyzeOptions,
AnalyzeResult,
@@ -30,5 +34,7 @@ export interface CliHost {
/** Merges raw or indexed profiles into one indexed profile; a sparse one
* leaves out functions that never ran. */
mergeProfiles: (outputPath: string, inputPaths: string[], sparse: boolean) => Promise<void>;
/** `scriptc types patch|list` (frontend/type-patches/command.ts). */
typePatches: (request: TypePatchCommandRequest) => Promise<TypePatchCommandResult>;
run: (binary: string) => Promise<number>;
}
+9
View File
@@ -13,6 +13,13 @@ Usage:
exporting the profile symbols; a profile
with a sidecar section also gets the
contract sidecar JSON beside the archive
scriptc types patch <package> <selector> <type> [--range <r>] [--dry-run]
fix a wrong declaration of an installed
package: shows what the selector
matches and adds the type patch to
package.json ("| undefined" widens)
scriptc types list the project's type patches and what
they match
scriptc cache warm [runtime|tls…] verify and load precompiled runtime families
for the current compiler/SDK/target
scriptc merge-profiles -o <out.profdata> [--sparse] <profile…>
@@ -157,6 +164,8 @@ export const CLI_OPTIONS = {
"profile-generate": { type: "string" },
"profile-use": { type: "string" },
sparse: { type: "boolean", default: false },
range: { type: "string" },
"dry-run": { type: "boolean", default: false },
"keep-llvm": { type: "boolean", default: true },
"emit-ir": { type: "boolean", default: false },
sanitize: { type: "boolean", default: false },
@@ -348,12 +348,12 @@ export function declarationSelector(decl: ts.Node): string | null {
/** The note a union extraction's TypeError carries for a failing `arm`
* (the same text the runtime renders for checked conversions). */
export function renderOriginNote(origin: string, arm: string): string {
const [pkg = "", version = "", range = "", selector = "", where = "", declared = ""] =
const [pkg = "", version = "", , selector = "", where = "", declared = ""] =
origin.split(ORIGIN_SEPARATOR);
return (
`\n ${pkg}${version === "" ? "" : `@${version}`} declares ${selector}` +
`${declared === "" ? "" : ` as ${declared}`} (${where})` +
`\n if that declaration is wrong, patch it in package.json: "scriptc": { "typePatches": { "${pkg}@${range}": { "declarations": { "${selector}": "| ${arm}" } } } }`
`\n if that declaration is wrong, patch it: scriptc types patch ${pkg} "${selector}" "| ${arm}"`
);
}
@@ -0,0 +1,382 @@
/* `scriptc types patch` and `scriptc types list`: write and inspect the
* project's type patches (package.json scriptc.typePatches).
*
* scriptc types patch <package> <selector> <type> [--range <r>] [--dry-run]
* scriptc types list
*
* `patch` finds the installed package from the working directory, shows
* every declaration the selector matches (in the package's declaration
* files, its JSDoc and its @types package) with its type before and after,
* and adds the patch to the nearest package.json under a range covering
* the installed version (`^<version>` unless --range says otherwise, or an
* existing entry's range when one covers it). Nothing is written when the
* selector matches nothing. `list` shows each configured patch, the
* installed copies it applies to and what it matches. */
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { parseProjectConfig, type TypePatchEntry } from "../../project-config.js";
import type { TypePatchParse } from "./layer.js";
import { patchFile, type PatchMatch } from "./locate.js";
import { parseSelector, parseTypeText, type Selector, type TypePatchOp } from "./selector.js";
import { isValidRange, satisfies } from "./semver.js";
export type TypePatchCommandRequest =
| {
action: "patch";
cwd: string;
pkg: string;
selector: string;
type: string;
range: string | null;
dryRun: boolean;
}
| { action: "list"; cwd: string };
export interface TypePatchCommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface PackageCopy {
root: string;
version: string | null;
/** True for an `@types/<name>` twin. */
twin: boolean;
}
const PATCHABLE = /\.(?:[cm]?js|d\.[cm]?ts|[cm]?ts)$/;
function nearestManifest(cwd: string): string | null {
for (let dir = cwd; ;) {
const manifest = join(dir, "package.json");
if (existsSync(manifest)) return manifest;
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function versionOf(root: string): string | null {
try {
const parsed = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
version?: unknown;
};
return typeof parsed.version === "string" ? parsed.version : null;
} catch {
return null;
}
}
function typesName(pkg: string): string {
return pkg.startsWith("@") ? `@types/${pkg.slice(1).replace("/", "__")}` : `@types/${pkg}`;
}
/** The copy of `pkg` (and of its @types twin) Node resolution finds from
* `cwd`. A twin is versioned by the runtime package beside it. */
function installedCopies(cwd: string, pkg: string): PackageCopy[] {
const out: PackageCopy[] = [];
let runtimeVersion: string | null = null;
let twinFound = false;
for (let dir = cwd; ;) {
const modules = join(dir, "node_modules");
if (runtimeVersion === null && existsSync(join(modules, pkg, "package.json"))) {
const root = join(modules, pkg);
runtimeVersion = versionOf(root);
out.push({ root, version: runtimeVersion, twin: false });
}
if (!twinFound && existsSync(join(modules, typesName(pkg), "package.json"))) {
twinFound = true;
out.push({ root: join(modules, typesName(pkg)), version: null, twin: true });
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
for (const copy of out) if (copy.twin) copy.version = runtimeVersion ?? versionOf(copy.root);
return out;
}
/** The files of a package copy a patch can apply to (nested packages
* excluded), package-relative. */
function packageFiles(root: string): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
let names: string[];
try {
names = readdirSync(dir).sort();
} catch {
return;
}
for (const name of names) {
if (name === "node_modules" || name.startsWith(".")) continue;
const path = join(dir, name);
let isDir = false;
try {
isDir = statSync(path).isDirectory();
} catch {
continue;
}
if (isDir) walk(path);
else if (PATCHABLE.test(name)) out.push(relative(root, path).split("\\").join("/"));
}
};
walk(root);
return out;
}
interface FileMatch extends PatchMatch {
file: string;
}
/** Every declaration a selector matches in a copy, and the problems where
* it cannot apply. */
function matchCopy(
copy: PackageCopy,
selector: Selector,
op: TypePatchOp,
parse: TypePatchParse,
): { matches: FileMatch[]; problems: string[] } {
const words =
selector.root === "export="
? ["exports", "export"]
: selector.root === "default"
? ["default"]
: [selector.root];
const matches: FileMatch[] = [];
const problems: string[] = [];
for (const rel of packageFiles(copy.root)) {
if (selector.file !== null && selector.file !== rel) continue;
const path = join(copy.root, rel);
let text: string;
try {
text = readFileSync(path, "utf8");
} catch {
continue;
}
if (!words.some((w) => text.includes(w))) continue;
let result;
try {
result = patchFile(rel, text, parse(path, text, /\.[cm]?ts$/.test(rel) ? "ts" : "js"), [
{ id: 0, selector, op },
]);
} catch {
continue;
}
for (const m of result.matches) matches.push({ ...m, file: rel });
for (const p of result.problems) problems.push(`${p.message} (${rel})`);
}
return { matches, problems };
}
function shownCopy(copy: PackageCopy, base: string, pkg: string): string {
const name = copy.twin ? typesName(pkg) : pkg;
return `${name} ${copy.version ?? "(no version)"} (${relative(base, copy.root).split("\\").join("/")})`;
}
function describe(m: FileMatch): string {
if (m.redundant) return ` ${m.file}:${m.line} ${m.before} (already declared so)`;
return ` ${m.file}:${m.line} ${m.before} -> ${m.after}`;
}
/** The indentation a JSON text uses (two spaces when it cannot tell). */
function indentOf(text: string): string {
const m = /^[{[]\s*\n([ \t]+)\S/.exec(text);
return m === null ? " " : m[1]!;
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** JSON text of `value` with `path` set to the string `leaf` (objects
* created along the way, the leaf replaced if present), keeping key order
* and indenting like JSON.stringify(value, null, indent). The parsed value
* itself is only read. */
function renderJson(
value: unknown,
indent: string,
at: string,
path: readonly string[],
leaf: string,
): string {
const inner = at + indent;
if (path.length > 0 && !isObject(value)) return renderJson({}, indent, at, path, leaf);
if (isObject(value)) {
const keys = Object.keys(value);
const parts: string[] = [];
const head = path[0];
for (const k of keys) {
const child =
head === k
? path.length === 1
? JSON.stringify(leaf)
: renderJson(value[k], indent, inner, path.slice(1), leaf)
: renderJson(value[k], indent, inner, [], leaf);
parts.push(`${inner}${JSON.stringify(k)}: ${child}`);
}
if (head !== undefined && !keys.includes(head)) {
const child =
path.length === 1
? JSON.stringify(leaf)
: renderJson({}, indent, inner, path.slice(1), leaf);
parts.push(`${inner}${JSON.stringify(head)}: ${child}`);
}
return parts.length === 0 ? "{}" : `{\n${parts.join(",\n")}\n${at}}`;
}
if (Array.isArray(value)) {
const items: unknown[] = value;
if (items.length === 0) return "[]";
return `[\n${items.map((item) => `${inner}${renderJson(item, indent, inner, [], leaf)}`).join(",\n")}\n${at}]`;
}
return JSON.stringify(value) ?? "null";
}
function patchCommand(
req: Extract<TypePatchCommandRequest, { action: "patch" }>,
parse: TypePatchParse,
): TypePatchCommandResult {
const fail = (message: string): TypePatchCommandResult => ({
stdout: "",
stderr: `scriptc: ${message}\n`,
exitCode: 1,
});
const selector = parseSelector(req.selector);
if ("error" in selector) return fail(`'${req.selector}' is not a selector: it ${selector.error}`);
const op = parseTypeText(req.type);
if ("error" in op) return fail(`the type '${req.type}' ${op.error}`);
if (req.range !== null && !isValidRange(req.range))
return fail(`'${req.range}' is not a semver range`);
const manifest = nearestManifest(req.cwd);
if (manifest === null) return fail("no package.json found from the working directory");
const base = dirname(manifest);
const copies = installedCopies(req.cwd, req.pkg);
const runtime = copies.find((c) => !c.twin) ?? copies[0];
if (runtime === undefined) return fail(`${req.pkg} is not installed here`);
const version = runtime.version;
const lines: string[] = [];
let matched = 0;
let changed = 0;
const problems: string[] = [];
for (const copy of copies) {
const found = matchCopy(copy, selector, op, parse);
problems.push(...found.problems);
if (found.matches.length === 0) continue;
lines.push(shownCopy(copy, base, req.pkg));
for (const m of found.matches) {
lines.push(describe(m));
matched++;
if (!m.redundant) changed++;
}
}
if (matched === 0) {
const why =
problems.length > 0 ? problems.join("\n") : `'${req.selector}' matches no declaration`;
return fail(`${why}\nnothing was written`);
}
if (changed === 0) {
lines.push("the declarations already say this; nothing was written");
return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 };
}
const text = readFileSync(manifest, "utf8");
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return fail(`${relative(req.cwd, manifest) || "package.json"} is not valid JSON`);
}
// An existing entry whose range covers the installed version takes the
// patch; otherwise a new one under the range asked for.
const existing = parseProjectConfig(manifest, text).typePatches.find(
(e: TypePatchEntry) =>
e.pkg === req.pkg &&
(req.range === null
? version !== null && satisfies(version, e.range)
: e.range === req.range),
);
const key =
existing?.key ?? `${req.pkg}@${req.range ?? (version === null ? "*" : `^${version}`)}`;
const where = `scriptc.typePatches["${key}"].declarations["${req.selector}"]`;
if (req.dryRun) {
lines.push(`would write ${relative(req.cwd, manifest) || "package.json"} ${where}`);
return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 };
}
const trailing = text.endsWith("\n") ? "\n" : "";
writeFileSync(
manifest,
renderJson(
parsed,
indentOf(text),
"",
["scriptc", "typePatches", key, "declarations", req.selector],
req.type,
) + trailing,
);
lines.push(`wrote ${relative(req.cwd, manifest) || "package.json"} ${where}`);
return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 };
}
function listCommand(cwd: string, parse: TypePatchParse): TypePatchCommandResult {
const manifest = nearestManifest(cwd);
if (manifest === null)
return {
stdout: "",
stderr: "scriptc: no package.json found from the working directory\n",
exitCode: 1,
};
const base = dirname(manifest);
const config = parseProjectConfig(manifest, readFileSync(manifest, "utf8"));
const lines: string[] = [];
for (const d of config.diagnostics)
if (d.message.includes("typePatches")) lines.push(`warning ${d.code}: ${d.message}`);
if (config.typePatches.length === 0) {
lines.push("no type patches");
return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 };
}
for (const entry of config.typePatches) {
lines.push(entry.key);
const copies = installedCopies(cwd, entry.pkg);
const covered = copies.filter((c) => c.version !== null && satisfies(c.version, entry.range));
if (copies.length === 0) lines.push(` not installed`);
else if (covered.length === 0)
lines.push(
` does not apply to the installed ${entry.pkg} ${copies[0]!.version ?? "(no version)"}`,
);
for (const decl of entry.declarations) {
const selector = parseSelector(decl.selector) as Selector;
const op = parseTypeText(decl.type) as TypePatchOp;
const found: FileMatch[] = [];
for (const copy of covered) {
for (const m of matchCopy(copy, selector, op, parse).matches)
found.push({
...m,
file: `${relative(base, copy.root).split("\\").join("/")}/${m.file}`,
});
}
const status =
covered.length === 0
? ""
: found.length === 0
? " (matches nothing)"
: found.every((m) => m.redundant)
? " (already declared so)"
: "";
lines.push(` ${decl.selector}: ${decl.type}${status}`);
for (const m of found)
if (!m.redundant) lines.push(` ${m.file}:${m.line} ${m.before} -> ${m.after}`);
}
for (const [target, local] of entry.files)
lines.push(` file ${target}: ${relative(base, local).split("\\").join("/")}`);
if (entry.types !== null)
lines.push(` types: ${relative(base, entry.types).split("\\").join("/")}`);
}
return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 };
}
export function runTypePatchCommand(
req: TypePatchCommandRequest,
parse: TypePatchParse,
): TypePatchCommandResult {
return req.action === "patch" ? patchCommand(req, parse) : listCommand(req.cwd, parse);
}
+15
View File
@@ -1,3 +1,9 @@
import { nodeFrontendServices } from "./frontend/services-node.js";
import {
runTypePatchCommand,
type TypePatchCommandRequest,
type TypePatchCommandResult,
} from "./frontend/type-patches/command.js";
import { optimizationField, type NativeOptimization } from "./backend/optimization.js";
import { targetCpuField, targetCpuTargetRefusal } from "./backend/target-cpu.js";
import { compilationTiming, type CompilationTiming, type TimingClock } from "./timing.js";
@@ -197,6 +203,15 @@ export {
} from "./backend/external-c.js";
export { warmNativeCaches } from "./backend/warm-cache.js";
export { mergeNativeProfiles as mergeProfiles } from "./backend/native-codegen.js";
/** `scriptc types patch|list` with the Node frontend's parser. */
export async function typePatches(
request: TypePatchCommandRequest,
): Promise<TypePatchCommandResult> {
return runTypePatchCommand(request, (path, text, kind) =>
nodeFrontendServices().parse(path, text, kind),
);
}
export { collectGarbageUnderHeapPressure } from "./heap-collection.js";
export {
ANDROID_MIN_API,
+14
View File
@@ -12,6 +12,7 @@ import { mergeNativeProfiles } from "../backend/native-tools.js";
import { setDeclarationRoot } from "../frontend/dts-paths.js";
import { setFrontendDisposedHook } from "../frontend/dispose-hook.js";
import { FrontendServices } from "../frontend/services.js";
import { runTypePatchCommand } from "../frontend/type-patches/command.js";
import { createNativeTs7Api } from "../frontend/ts7/native-api.js";
import { resolveProvenanceSourcesWithParser } from "../frontend/provenance-core.js";
import { setCompilerReleaseVersion } from "../library/sidecar.js";
@@ -169,6 +170,19 @@ async function main(): Promise<number> {
}
return { cacheRoot: cache.root, profiles };
},
typePatches: async (request) => {
const toolchain = getCompiler().toolchain;
const services = new FrontendServices((options) =>
createNativeTs7Api({ ...options, executable: toolchain.ts7Executable }),
);
try {
return runTypePatchCommand(request, (path, source, kind) =>
services.parse(path, source, kind),
);
} finally {
services.close();
}
},
mergeProfiles: async (outputPath, inputPaths, sparse) => {
const toolchain = getCompiler().toolchain;
mergeNativeProfiles({
@@ -0,0 +1,120 @@
/* `scriptc types patch` and `scriptc types list`
* (frontend/type-patches/command.ts) on a copy of the type patch fixture:
* matches are shown before anything is written, the patch lands in the
* nearest package.json under a range covering the installed version (or
* an existing entry that covers it), and nothing is written when the
* selector matches nothing. */
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { nodeFrontendServices } from "../src/frontend/services-node.js";
import { runTypePatchCommand } from "../src/frontend/type-patches/command.js";
const fixture = join(import.meta.dirname, "../../../tests/fixtures/type-patches");
const parse = (path: string, text: string, kind: "ts" | "js") =>
nodeFrontendServices().parse(path, text, kind);
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "scriptc-types-command-"));
cpSync(fixture, root, { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({ name: "app", private: true, type: "module" }, null, 2) + "\n",
);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
const patch = (pkg: string, selector: string, type: string, extra: object = {}) =>
runTypePatchCommand(
{ action: "patch", cwd: root, pkg, selector, type, range: null, dryRun: false, ...extra },
parse,
);
test("patch shows the matches and writes the entry", () => {
const result = patch("termctx", "OutputContext.width", "| undefined");
expect(result).toEqual({
stdout:
"termctx 1.2.0 (node_modules/termctx)\n" +
" index.js:24 number -> number | undefined\n" +
`wrote package.json scriptc.typePatches["termctx@^1.2.0"].declarations["OutputContext.width"]\n`,
stderr: "",
exitCode: 0,
});
expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8"))).toEqual({
name: "app",
private: true,
type: "module",
scriptc: {
typePatches: {
"termctx@^1.2.0": { declarations: { "OutputContext.width": "| undefined" } },
},
},
});
// A second patch joins the entry whose range covers the installed copy.
expect(patch("termctx", "Terminal.columns()", "| undefined").exitCode).toBe(0);
const text = readFileSync(join(root, "package.json"), "utf8");
expect(text.endsWith("}\n")).toBe(true);
expect(JSON.parse(text).scriptc.typePatches).toEqual({
"termctx@^1.2.0": {
declarations: { "OutputContext.width": "| undefined", "Terminal.columns()": "| undefined" },
},
});
});
test("--range and --dry-run", () => {
const dry = patch("ms", "index.js:parse()", "| undefined", { dryRun: true, range: "2.1.x" });
expect(dry.stdout).toBe(
"ms 2.1.3 (node_modules/ms)\n" +
" index.js:44 Number -> Number | undefined\n" +
`would write package.json scriptc.typePatches["ms@2.1.x"].declarations["index.js:parse()"]\n`,
);
expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).scriptc).toBeUndefined();
});
test("nothing is written for a selector that matches nothing or is already so", () => {
const missing = patch("termctx", "OutputContext.height", "| undefined");
expect(missing.exitCode).toBe(1);
expect(missing.stderr).toBe(
"scriptc: 'OutputContext.height' matches no declaration\nnothing was written\n",
);
const redundant = patch("ms", "index.js:fmtShort(ms)", "| Number");
expect(redundant).toMatchObject({ exitCode: 0 });
expect(redundant.stdout).toContain("the declarations already say this; nothing was written\n");
expect(patch("absent-package", "A.b", "| undefined").stderr).toBe(
"scriptc: absent-package is not installed here\n",
);
expect(patch("termctx", "1bad", "| undefined").stderr).toContain("is not a selector");
expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).scriptc).toBeUndefined();
});
test("list shows each patch and what it matches", () => {
writeFileSync(join(root, "package.json"), readFileSync(join(fixture, "package.json")));
const listed = runTypePatchCommand({ action: "list", cwd: root }, parse);
expect(listed.stdout).toBe(
[
"termctx@^1.0.0",
" OutputContext.width: | undefined",
" node_modules/termctx/index.js:24 number -> number | undefined",
" Terminal.columns(): | undefined",
" node_modules/termctx/index.js:14 number -> number | undefined",
"ms@^2.1.0",
" index.js:parse(): | undefined",
" node_modules/ms/index.js:44 Number -> Number | undefined",
" export=(): | undefined",
" node_modules/ms/index.js:22 String|Number -> String|Number | undefined",
"",
].join("\n"),
);
writeFileSync(join(root, "stale.json"), "");
writeFileSync(
join(root, "package.json"),
JSON.stringify({
scriptc: { typePatches: { "termctx@^2.0.0": { declarations: { "A.b": "| undefined" } } } },
}),
);
expect(runTypePatchCommand({ action: "list", cwd: root }, parse).stdout).toBe(
"termctx@^2.0.0\n does not apply to the installed termctx 1.2.0\n A.b: | undefined\n",
);
});
+9 -11
View File
@@ -9,12 +9,13 @@
* site's cold exception path, which calls scr_type_origin_note: the
* pending TypeError's message gains two lines naming the declaration and
* the type patch (package.json scriptc.typePatches) that would make the
* declaration admit the value. The hot path is unchanged, and so is every
* declaration admit the value, as the `scriptc types patch` command that
* writes it. The hot path is unchanged, and so is every
* other error.
*
* The origin is one string of fields separated by \x1f: package, version,
* suggested range, the selector of the declared slot, where it is
* declared, and the declared type. */
* suggested range (which the command chooses itself), the selector of the
* declared slot, where it is declared, and the declared type. */
#include "scr_runtime.h"
#include <stdlib.h>
@@ -92,10 +93,9 @@ void scr_type_origin_note(const char *origin) {
}
}
}
const char *pkg, *version, *range, *selector, *where, *declared;
const char *pkg, *version, *selector, *where, *declared;
size_t pkg_len = scr_origin_field(origin, 0, &pkg);
size_t version_len = scr_origin_field(origin, 1, &version);
size_t range_len = scr_origin_field(origin, 2, &range);
size_t selector_len = scr_origin_field(origin, 3, &selector);
size_t where_len = scr_origin_field(origin, 4, &where);
size_t declared_len = scr_origin_field(origin, 5, &declared);
@@ -116,11 +116,9 @@ void scr_type_origin_note(const char *origin) {
}
scr_origin_puts(&b, " (");
scr_origin_put(&b, where, where_len);
scr_origin_puts(&b, ")\n if that declaration is wrong, patch it in package.json: \"scriptc\": { \"typePatches\": { \"");
scr_origin_puts(&b, ")\n if that declaration is wrong, patch it: scriptc types patch ");
scr_origin_put(&b, pkg, pkg_len);
scr_origin_puts(&b, "@");
scr_origin_put(&b, range, range_len);
scr_origin_puts(&b, "\": { \"declarations\": { \"");
scr_origin_puts(&b, " \"");
scr_origin_put(&b, selector, selector_len);
/* $[3].email extends the selector as [].email: elements of an array
* share one declared type. */
@@ -132,7 +130,7 @@ void scr_type_origin_note(const char *origin) {
scr_origin_put(&b, path + i, 1);
}
}
scr_origin_puts(&b, "\": \"| ");
scr_origin_puts(&b, "\" \"| ");
if (got != NULL) {
const char *arm = scr_origin_arm(got, got_len);
if (arm != NULL) scr_origin_puts(&b, arm);
@@ -140,7 +138,7 @@ void scr_type_origin_note(const char *origin) {
} else {
scr_origin_puts(&b, "undefined");
}
scr_origin_puts(&b, "\" } } } }");
scr_origin_puts(&b, "\"");
ScrStr *next = scr_str_new(b.data, b.len);
free(b.data);
+2 -2
View File
@@ -120,7 +120,7 @@ describe("type patches", () => {
expect(native.stderr).toBe(
"Uncaught TypeError: expected string at $, got undefined\n" +
" profilekit@3.1.0 declares Profile.email as string (index.d.ts:7)\n" +
` if that declaration is wrong, patch it in package.json: "scriptc": { "typePatches": { "profilekit@^3.1.0": { "declarations": { "Profile.email": "| undefined" } } } }\n`,
` if that declaration is wrong, patch it: scriptc types patch profilekit "Profile.email" "| undefined"\n`,
);
}
} finally {
@@ -178,7 +178,7 @@ describe("type patches", () => {
const result = await build(join(fixture, "unpatched/main.ts"), "unpatched", ["termctx"]);
const native = await run(result.binaryPath, []);
const patch = (selector: string) =>
` if that declaration is wrong, patch it in package.json: "scriptc": { "typePatches": { "termctx@^1.2.0": { "declarations": { "${selector}": "| undefined" } } } }`;
` if that declaration is wrong, patch it: scriptc types patch termctx "${selector}" "| undefined"`;
expect(native.stdout).toBe(
[
"expected number at $, got undefined",