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.
This commit is contained in:
Chris Tate
2026-10-20 08:49:48 -05:00
parent 447e9bf000
commit 8e87f6f9ef
4 changed files with 93 additions and 13 deletions
@@ -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);
};
@@ -60,6 +60,28 @@ function runRaw(script: string): Promise<void> {
});
}
// 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<void> {
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"));
+5
View File
@@ -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)));
@@ -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");
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 |