From 8e87f6f9ef5a5b90444f7cd624902bf87f163238 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 08:49:48 -0500 Subject: [PATCH] Resolve const aliases when deciding a child stream's setEncoding state An untyped child-stream 'data' listener takes strings when a setEncoding call on the same stream precedes it. A const alias of an access path (`const out = child.stdout`) now resolves to that path, so a setEncoding through one alias and a listener through another, or through the stream itself, agree. A setEncoding on another receiver that can hold the same stream (a let copy, a parameter, a call result) leaves the state unclear and reports the existing SC1090. Receivers that name different members of one root, or two const roots each initialized by a call or new, are treated as different streams. --- .../builtins/child-process-methods.ts | 70 +++++++++++++++---- tests/corpus/6472-child-data-unknown-chunk.ts | 22 ++++++ tests/diagnostics/child-process.ts | 5 ++ .../__snapshots__/child-process.ts.txt | 9 ++- 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts index a2b0e1f57..aae51a8be 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -541,25 +541,64 @@ export function lowerChildWriterMethodCall( } /** A receiver spelled as a plain access path (`child.stdout`, `out`): its - * root symbol and member names, or null for other forms. */ + * root symbol and member names, or null for other forms. A `const` alias + * of an access path (`const out = child.stdout`) resolves to that path, + * so every spelling of one stream gets the same key. */ function accessPathOf( lowerer: Lowerer, expr: ts.Expression, + depth = 0, ): { root: ts.Symbol; path: string } | null { + if (depth > 16) return null; if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) { - return accessPathOf(lowerer, expr.expression); + return accessPathOf(lowerer, expr.expression, depth + 1); } if (ts.isIdentifier(expr)) { const root = lowerer.checker.getSymbolAtLocation(expr); - return root ? { root, path: "" } : null; + if (!root) return null; + const init = constInitializerOf(root); + const aliased = init === null ? null : accessPathOf(lowerer, init, depth + 1); + return aliased ?? { root, path: "" }; } if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { - const base = accessPathOf(lowerer, expr.expression); + const base = accessPathOf(lowerer, expr.expression, depth + 1); return base === null ? null : { root: base.root, path: `${base.path}.${expr.name.text}` }; } return null; } +/** The initializer of a symbol declared once as a `const` variable, else null. */ +function constInitializerOf(symbol: ts.Symbol): ts.Expression | null { + const decls = symbol.declarations; + if (decls.length !== 1) return null; + const decl = decls[0]!; + if ( + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + decl.initializer === undefined || + (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) === 0 + ) + return null; + return decl.initializer; +} + +/** Whether two resolved access paths name different objects: the same root + * with different members, or two `const` roots each initialized by a call + * or `new` (such as two `spawn()` results). */ +function distinctPaths( + a: { root: ts.Symbol; path: string }, + b: { root: ts.Symbol; path: string }, +): boolean { + if (a.root === b.root) return a.path !== b.path; + const fresh = (root: ts.Symbol): boolean => { + let init = constInitializerOf(root); + while (init !== null && (ts.isParenthesizedExpression(init) || ts.isAwaitExpression(init))) + init = init.expression; + return init !== null && (ts.isCallExpression(init) || ts.isNewExpression(init)); + }; + return fresh(a.root) && fresh(b.root); +} + /** Whether an unknown-typed 'data' listener registered by `call` receives * strings: a `setEncoding` statement on the same receiver path precedes * the registration in its own block or an enclosing block of the same @@ -594,10 +633,7 @@ function childStreamEncodedAt( } node = parent; } - const samePath = ( - a: { root: ts.Symbol; path: string } | null, - b: { root: ts.Symbol; path: string }, - ): boolean => a !== null && a.root === b.root && a.path === b.path; + const receiverType = lowerer.checker.getNonNullableType(lowerer.typeOf(receiver)); let before = false; let elsewhere = false; const visit = (node: ts.Node): void => { @@ -605,11 +641,21 @@ function childStreamEncodedAt( if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === "setEncoding" && - samePath(accessPathOf(lowerer, node.expression.expression), key) + node.expression.name.text === "setEncoding" ) { - if (ts.isExpressionStatement(node.parent) && preceding.has(node.parent)) before = true; - else elsewhere = true; + const target = node.expression.expression; + const path = accessPathOf(lowerer, target); + if (path !== null && path.root === key.root && path.path === key.path) { + if (ts.isExpressionStatement(node.parent) && preceding.has(node.parent)) before = true; + else elsewhere = true; + } else if ( + (path === null || !distinctPaths(path, key)) && + lowerer.checker.isTypeAssignableTo(receiverType, lowerer.typeOf(target)) + ) { + // Another spelling that can hold this stream (a parameter, a `let` + // copy, a call result): its setEncoding may be this stream's. + elsewhere = true; + } } ts.forEachChild(node, visit); }; diff --git a/tests/corpus/6472-child-data-unknown-chunk.ts b/tests/corpus/6472-child-data-unknown-chunk.ts index 4a0e97d34..8308aa8cf 100644 --- a/tests/corpus/6472-child-data-unknown-chunk.ts +++ b/tests/corpus/6472-child-data-unknown-chunk.ts @@ -60,6 +60,28 @@ function runRaw(script: string): Promise { }); } +// setEncoding through one const alias of the stream and the listener +// through another: both name child.stdout, so chunks are strings. +function runAliased(script: string): Promise { + return new Promise((resolve) => { + const child = spawn("/bin/sh", ["-c", script], { stdio: "pipe" }); + const encoded = child.stdout!; + const listened = child.stdout!; + let stdout = ""; + const kinds: string[] = []; + encoded.setEncoding("utf8"); + listened.on("data", (c: unknown) => { + kinds.push(typeof c); + stdout += String(c); + }); + child.on("close", (code) => { + report(code, kinds, stdout, ""); + resolve(); + }); + }); +} + runEncoded("printf 'h\\303\\251llo'; echo warn >&2") .then(() => runRaw("printf raw; echo oops >&2; exit 2")) + .then(() => runAliased("printf alias")) .then(() => console.log("done")); diff --git a/tests/diagnostics/child-process.ts b/tests/diagnostics/child-process.ts index 6c5683efc..02b4349e8 100644 --- a/tests/diagnostics/child-process.ts +++ b/tests/diagnostics/child-process.ts @@ -47,3 +47,8 @@ fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); const pipedOut = piped.stdout!; pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); pipedOut.setEncoding("utf8"); +// A setEncoding through a copy that can hold the same stream (a `let` +// binding here) leaves the state unclear as well. +let pipedErr = piped.stderr!; +pipedErr.setEncoding("utf8"); +piped.stderr!.on("data", (chunk: unknown) => console.log(String(chunk))); diff --git a/tests/harness/__snapshots__/child-process.ts.txt b/tests/harness/__snapshots__/child-process.ts.txt index 6fee8a2c2..f5f9f8f86 100644 --- a/tests/harness/__snapshots__/child-process.ts.txt +++ b/tests/harness/__snapshots__/child-process.ts.txt @@ -98,4 +98,11 @@ child-process.ts:48:21 - error SC1090: untyped child-stream 'data' listeners who 47 | const pipedOut = piped.stdout!; 48 | pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 49 | pipedOut.setEncoding("utf8"); \ No newline at end of file + 49 | pipedOut.setEncoding("utf8"); + +child-process.ts:54:26 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet + + 53 | pipedErr.setEncoding("utf8"); + 54 | piped.stderr!.on("data", (chunk: unknown) => console.log(String(chunk))); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 55 | \ No newline at end of file