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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions