diff --git a/.github/workflows/source-lints.yml b/.github/workflows/source-lints.yml index ffbca8fcfc30..c4d54fc01ad3 100644 --- a/.github/workflows/source-lints.yml +++ b/.github/workflows/source-lints.yml @@ -16,6 +16,7 @@ on: - "src/**/*.classes.ts" - "src/codegen/class-definitions.ts" - "src/jsc/bindings/**" + - "packages/bun-types/bun.d.ts" - "packages/bun-types/redis.d.ts" - "scripts/build/**" - "scripts/glob-sources.ts" @@ -34,6 +35,7 @@ on: - "src/**/*.classes.ts" - "src/codegen/class-definitions.ts" - "src/jsc/bindings/**" + - "packages/bun-types/bun.d.ts" - "packages/bun-types/redis.d.ts" - "scripts/build/**" - "scripts/glob-sources.ts" diff --git a/docs/runtime/child-process.mdx b/docs/runtime/child-process.mdx index 60bf1a2e733b..bf9624d207f5 100644 --- a/docs/runtime/child-process.mdx +++ b/docs/runtime/child-process.mdx @@ -575,7 +575,8 @@ interface Subprocess extends AsyncDisposable { readonly stdin: FileSink | number | undefined | null; readonly stdout: ReadableStream> | number | undefined | null; readonly stderr: ReadableStream> | number | undefined | null; - readonly readable: ReadableStream> | number | undefined | null; + readonly writable: Subprocess["stdin"]; // the same value as stdin + readonly readable: Subprocess["stdout"]; // the same value as stdout readonly terminal: Terminal | undefined; readonly pid: number; readonly exited: Promise; diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d8c9402e2871..0b7d988bf0f9 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7578,6 +7578,14 @@ declare module "bun" { */ readonly stdio: [null, null, null, ...(number | null)[]]; + /** + * The same value as {@link Subprocess.stdin} + * + * The counterpart of {@link Subprocess.readable}. With `stdin: "pipe"` this is + * the {@link FileSink} for the process's stdin, not a `WritableStream`. + */ + readonly writable: SpawnOptions.WritableToIO; + /** * The same value as {@link Subprocess.stdout} * diff --git a/test/integration/bun-types/fixture/spawn.ts b/test/integration/bun-types/fixture/spawn.ts index 2036413158a0..91441364e5eb 100644 --- a/test/integration/bun-types/fixture/spawn.ts +++ b/test/integration/bun-types/fixture/spawn.ts @@ -188,6 +188,32 @@ tsd.expectAssignable(Bun.spawn([], { stdio: [null, null, null] } tsd.expectAssignable>(Bun.spawnSync([], {})); +// `writable` and `readable` return the same values as `stdin` and `stdout`, so they have the +// same types as `stdin` and `stdout` whatever the process was spawned with. +{ + function aliases< + In extends Bun.SpawnOptions.Writable, + Out extends Bun.SpawnOptions.Readable, + Err extends Bun.SpawnOptions.Readable, + >(proc: Bun.Subprocess) { + tsd.expectType(proc.writable).is(); + tsd.expectType(proc.readable).is(); + } + aliases(Bun.spawn(["cat"])); +} +{ + const proc = Bun.spawn(["cat"], { stdin: "pipe" }); + tsd.expectType(proc.writable).is(); + proc.writable.write("hello"); + + // @ts-expect-error writable is a read-only getter, like stdin + proc.writable = proc.stdin; +} +tsd.expectType().is(); +tsd.expectType().is(); +tsd.expectType().is(); +tsd.expectType().is>>(); + // Lazy option types (async only) { // valid: lazy usable with async spawn diff --git a/test/internal/source-lints/subprocess-types.test.ts b/test/internal/source-lints/subprocess-types.test.ts new file mode 100644 index 000000000000..c157d2f13e4c --- /dev/null +++ b/test/internal/source-lints/subprocess-types.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import bunClasses from "../../../src/runtime/api/BunObject.classes.ts"; + +// `interface Subprocess` in packages/bun-types/bun.d.ts is a hand-written mirror +// of the Subprocess `proto` table in src/runtime/api/BunObject.classes.ts, which +// is what the class codegen installs on Subprocess.prototype. Nothing else +// compares the two: `writable` was registered next to `readable` in 2022 and +// stayed undeclared until 2026, so `proc.writable` worked at runtime and failed +// to type-check. Same check as redis-client-types.test.ts, reading an interface +// body instead of a class body. + +// Registered members that are not declared yet, each with the open PR that +// declares it. Delete the entry when that PR lands: the lint fails while a name +// listed here is declared (or is no longer registered). +const pendingDeclarations: Record = { + connected: "#38677", +}; + +// Registered members the interface declares through its `extends` clause rather +// than in its body, keyed to the interface that declares them. +const declaredByExtends: Record = { + "@@asyncDispose": "AsyncDisposable", +}; + +const classesFile = "src/runtime/api/BunObject.classes.ts"; +const dtsFile = "packages/bun-types/bun.d.ts"; +const root = path.resolve(import.meta.dir, "..", "..", ".."); + +const definition = bunClasses.find(c => c.name === "Subprocess"); +if (definition === undefined) throw new Error(`${classesFile} no longer defines Subprocess`); +// An interface body can only mirror prototype members. A constructor or statics +// would need a value declaration this lint does not read. +if (!definition.noConstructor || Object.keys(definition.klass).length !== 0) { + throw new Error( + `Subprocess in ${classesFile} has a constructor or statics, which \`interface Subprocess\` cannot declare`, + ); +} + +// Every member the codegen installs on the prototype. A well-known symbol stays +// in the table's `@@x` spelling; parseInterfaceBody maps `[Symbol.x]` onto it. +const registered = new Set(); +for (const [name, field] of Object.entries(definition.proto)) { + // Installed under a private name or a Symbol.for() symbol, or (internal) not + // installed at all; none of these has a declaration to mirror. + if ("internal" in field || "privateSymbol" in field || "publicSymbol" in field) continue; + registered.add(name); +} + +const { extended, declared, unrecognized } = parseInterfaceBody(readFileSync(path.join(root, dtsFile), "utf8")); +for (const [name, base] of Object.entries(declaredByExtends)) { + if (extended.includes(base)) declared.add(name); +} + +function parseInterfaceBody(dts: string): { extended: string[]; declared: Set; unrecognized: string[] } { + // The header spans several lines because of the type parameter list. + const open = /^ interface Subprocess<\n(?: {4}.*\n)* >(?: extends (.+))? \{$/m.exec(dts); + if (open === null) throw new Error(`${dtsFile} no longer declares \`interface Subprocess<...> {\``); + const extended = open[1] === undefined ? [] : open[1].split(",").map(base => base.trim()); + const bodyStart = open.index + open[0].length; + const bodyEnd = dts.indexOf("\n }\n", bodyStart); + if (bodyEnd === -1) throw new Error(`${dtsFile}: unterminated interface Subprocess body`); + + const body = dts + .slice(bodyStart, bodyEnd) + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^[ \t]*\/\/.*$/gm, ""); + + const declared = new Set(); + const unrecognized: string[] = []; + // Members start at the body's four-space indent. The only other lines at that + // indent are the `): ...;` closers of multi-line signatures, which start with + // punctuation, so everything else at that indent is a member and has to + // parse: a declaration shape this does not know is reported rather than + // skipped. + const member = /^(?:readonly )?(?:\[Symbol\.(\w+)\]|([A-Za-z_$][\w$]*))\s*\??\s*[(:<]/; + for (const [line] of body.matchAll(/^ [^\s)\]}>].*$/gm)) { + const m = member.exec(line.slice(4)); + if (m === null) { + unrecognized.push(line.trim()); + continue; + } + declared.add(m[1] !== undefined ? `@@${m[1]}` : m[2]!); + } + return { extended, declared, unrecognized }; +} + +test(`every member of interface Subprocess in ${dtsFile} has a shape this lint can read`, () => { + expect(unrecognized).toEqual([]); +}); + +test(`${dtsFile} declares every Subprocess member ${classesFile} installs`, () => { + const undeclared = [...registered] + .filter(name => !declared.has(name) && !Object.hasOwn(pendingDeclarations, name)) + .sort(); + expect(undeclared).toEqual([]); +}); + +test(`${dtsFile} declares no Subprocess member ${classesFile} does not install`, () => { + const phantom = [...declared].filter(name => !registered.has(name)).sort(); + expect(phantom).toEqual([]); +}); + +test("pendingDeclarations and declaredByExtends describe the current files", () => { + const stale = [ + ...Object.entries(pendingDeclarations).flatMap(([name, pr]) => { + if (!registered.has(name)) return [`${name} (${pr}) is no longer registered in ${classesFile}`]; + if (declared.has(name)) return [`${name} (${pr}) is declared in ${dtsFile} now; delete its entry`]; + return []; + }), + ...Object.entries(declaredByExtends).flatMap(([name, base]) => + extended.includes(base) ? [] : [`interface Subprocess no longer extends ${base}, which declared ${name}`], + ), + ].sort(); + expect(stale).toEqual([]); +});