mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-04 08:04:37 +08:00
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:
co-authored by
Claude Opus 5.5
parent
56e8417746
commit
5e843317af
@@ -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 <package> <selector> <type></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>^<installed version></code>. Nothing is written when the selector matches nothing. <code>--range <range></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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user