Skip to content

ts.transform output may have inconsistent node.parent pointers, observed around usingTransformer #1685

@ChouUn

Description

@ChouUn

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:

  1. Creates a virtual program.
  2. Runs the usingTransformer pre-transformer.
  3. Assigns numeric IDs to nodes using ts.forEachChild (structural edges).
  4. Records each node’s .parent:
    • outside:<Kind> means node.parent is set but is not present in the visited node set (likely a different object instance than the structural parent).
    • - means node.parent is missing.
  5. 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.ts

Observed 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

  1. Is it expected (given TypeScript transformer behavior) that ts.transform(...) output may have .parent pointers that:

    • are missing (-), or
    • point to nodes not reachable from the returned root via ts.forEachChild (our outside:*)?
  2. 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?
  3. If .parent is 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 .parent consistency)?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions