-
-
Notifications
You must be signed in to change notification settings - Fork 184
Description
Context
While working on a regression test for usingTransformer (PR #1613), I noticed a broader/related situation around TypeScript AST node.parent pointer consistency after ts.transform.
In short: when building a tree via ts.forEachChild(...), some nodes appear as children of a parent in the transformed tree, but their .parent pointers can still reference a different node instance (or be missing), making .parent chains unreliable for whole-tree invariants.
This issue is not about runtime Lua output; it’s about the shape/invariants of the transformed TS AST and what invariants TSTL expects/relies on.
Repro (copy/paste test)
This is an exact test which can place into test/unit/using.spec.ts. It:
- Creates a virtual program.
- Runs the
usingTransformerpre-transformer. - Assigns numeric IDs to nodes using
ts.forEachChild(structural edges). - Records each node’s
.parent:outside:<Kind>meansnode.parentis set but is not present in the visited node set (likely a different object instance than the structural parent).-meansnode.parentis missing.
- Prints the full subtree starting at the function body block, plus a “parent mismatch” section asserting structural edge consistency (
child.parent === parent), but only within the function-body block subtree.
// https://github.com/TypeScriptToLua/TypeScriptToLua/pull/1613
test("using transformer keeps parent chain for recursively transformed nested usings", () => {
const code = `
declare function disposable(): Disposable;
function f() {
using a = disposable();
{
using b = disposable();
}
}
`;
const program = createVirtualProgram({ "main.ts": code }, { target: ts.ScriptTarget.ESNext, lib: ["lib.esnext.d.ts"] });
const sourceFile = program.getSourceFile("main.ts");
util.assert(sourceFile);
const context = new TransformationContext(program, sourceFile, createVisitorMap([]));
const transformed = ts.transform(sourceFile, [usingTransformer(context)]).transformed[0];
type NodeRelation = {
id: number;
kind: ts.SyntaxKind;
text?: string;
parent: string;
children: number[];
};
const nodeToId = new Map<ts.Node, number>();
const relations: NodeRelation[] = [];
const visit = (node: ts.Node) => {
const id = relations.length;
nodeToId.set(node, id);
relations.push({ id, kind: node.kind, text: ts.isIdentifier(node) ? node.text : undefined, parent: "-", children: [] });
ts.forEachChild(node, child => {
const childId = visit(child);
relations[id].children.push(childId);
});
return id;
};
visit(transformed);
for (const [node, id] of nodeToId.entries()) {
if (!node.parent) {
relations[id].parent = "-";
continue;
}
const parentId = nodeToId.get(node.parent);
relations[id].parent = parentId !== undefined ? String(parentId) : `outside:${ts.SyntaxKind[node.parent.kind]}`;
}
const usingIds = relations.filter(r => r.kind === ts.SyntaxKind.Identifier && r.text === "__TS__Using").map(r => r.id);
expect(usingIds).toHaveLength(2);
const functionId = relations.find(r => r.kind === ts.SyntaxKind.FunctionDeclaration && r.children.some(childId => relations[childId].kind === ts.SyntaxKind.Identifier && relations[childId].text === "f"))?.id;
util.assert(functionId !== undefined);
const functionBlockId = relations[functionId].children.find(childId => relations[childId].kind === ts.SyntaxKind.Block);
util.assert(functionBlockId !== undefined);
const formatNodeLine = (id: number, depth: number) => {
const r = relations[id];
const text = r.text ?? "-";
return `${" ".repeat(depth)}- ${r.id} ${ts.SyntaxKind[r.kind]} ${text} parent=${r.parent}`;
};
const renderTree = (id: number, depth: number): string[] => {
const lines = [formatNodeLine(id, depth)];
for (const childId of relations[id].children) {
lines.push(...renderTree(childId, depth + 1));
}
return lines;
};
const subtreeIds = new Set<number>();
const collectSubtree = (id: number) => {
if (subtreeIds.has(id)) return;
subtreeIds.add(id);
for (const childId of relations[id].children) collectSubtree(childId);
};
collectSubtree(functionBlockId);
const mismatchLines: string[] = [];
for (const relation of relations) {
if (!subtreeIds.has(relation.id)) continue;
for (const childId of relation.children) {
if (!subtreeIds.has(childId)) continue;
const child = relations[childId];
const expectedParent = String(relation.id);
if (child.parent !== expectedParent) {
mismatchLines.push(`- child ${child.id} ${ts.SyntaxKind[child.kind]} expectedParent=${expectedParent} actualParent=${child.parent}`);
}
}
}
const treeDump = renderTree(functionBlockId, 0).join("\n");
const mismatchesDump = mismatchLines.length > 0 ? mismatchLines.join("\n") : "none";
const dump = `tree:\n${treeDump}\nparentMismatches:\n${mismatchesDump}`;
expect(dump).toBe(`...`)
});Commands
Run the single test:
npx jest --runInBand --runTestsByPath test/unit/using.spec.ts \
--testNamePattern "using transformer keeps parent chain for recursively transformed nested usings"To compare transformer behavior, I checked out two revisions of:
src/transformation/pre-transformers/using-transformer.ts
# fixed
git checkout 316f968 -- src/transformation/pre-transformers/using-transformer.ts
# pre-fix
git checkout 7ebbff5 -- src/transformation/pre-transformers/using-transformer.tsObserved behavior
Fixed transformer (316f968)
Within the function-body block subtree, parentMismatches: none, but the block root itself still reports parent=outside:FunctionDeclaration.
Pre-fix transformer (7ebbff5)
Many nodes inside the function-body block subtree have missing or outside parents (- / outside:*), and the mismatch report shows structural edges not matching .parent pointers.
Representative diff (fixed vs pre-fix):
tree:
- - 8 Block - parent=outside:FunctionDeclaration
+ - 8 Block - parent=-
- - 9 ReturnStatement - parent=8
+ - 9 ReturnStatement - parent=-
- - 10 CallExpression - parent=9
+ - 10 CallExpression - parent=-
- - 11 Identifier __TS__Using parent=10
+ - 11 Identifier __TS__Using parent=outside:CallExpression
- - 12 FunctionExpression - parent=10
+ - 12 FunctionExpression - parent=-
- - 13 Parameter - parent=12
+ - 13 Parameter - parent=outside:FunctionExpression
...
- - 34 CallExpression - parent=10
+ - 34 CallExpression - parent=outside:CallExpression
...
parentMismatches:
- - none
+ - child 11 Identifier expectedParent=10 actualParent=outside:CallExpression
+ - child 12 FunctionExpression expectedParent=10 actualParent=-
+ - child 34 CallExpression expectedParent=10 actualParent=outside:CallExpression
+ ...Expected / Questions
-
Is it expected (given TypeScript transformer behavior) that
ts.transform(...)output may have.parentpointers that:- are missing (
-), or - point to nodes not reachable from the returned root via
ts.forEachChild(ouroutside:*)?
- are missing (
-
What invariants does TSTL want to maintain?
- Only for synthetic nodes introduced by TSTL transformers (e.g.
usingTransformer), or - For larger subtrees / whole-file AST?
- Only for synthetic nodes introduced by TSTL transformers (e.g.
-
If
.parentis not intended to be reliable post-transform, would it be helpful to document that in contribution/testing guidance (to avoid writing brittle tests that assume whole-tree.parentconsistency)?