refactor(ingestion): merge python/ast-utils into utils/ast-helpers; iterative findNodeAtRange

python/ast-utils.ts held three language-agnostic helpers
(nodeToCapture, syntheticCapture, findNodeAtRange) plus two
duplicates of the shared utils version (findChildOfType ==
findChild; findIdentifierChild was unused). Consolidating into
utils/ast-helpers.ts so the next language migrating to the
scope-resolution pipeline imports from one place.

findNodeAtRange rewritten iteratively using an explicit stack.
Previous implementation was recursive — fine for shallow Python
trees today, but a landmine for languages with deeper nesting
(Kotlin sealed-hierarchy decomposition, Rust macro expansion,
etc.) and the task hooks explicitly call out "no recursion".
Children are pushed reverse-index so LIFO pop visits them
left-to-right; row-bound pruning preserves the prior early-skip
optimization (the `break` shortcut is replaced with `continue`
since a stack can't leverage ordered sibling termination).

findChildOfType consumers migrated to the existing findChild
helper. findIdentifierChild deleted — no callers remained.

Coverage: 204/204 test/integration/resolvers/python.test.ts both
REGISTRY_PRIMARY_PYTHON=0 and =1. 339/339 scope-resolution +
graph unit tests. tsc clean.
This commit is contained in:
Gergo Magyar 2026-04-21 13:34:41 +01:00
parent 4ce1c419e3
commit c3adf4ac7c
5 changed files with 92 additions and 107 deletions

View file

@ -1,96 +0,0 @@
/**
* Tree-sitter `SyntaxNode` helpers used by the Python scope-resolution
* hooks. Pure utilities no Python-specific knowledge but kept local
* to the `python/` package because they're only consumed here today.
*/
import type { Capture } from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
/** Convert a tree-sitter node to a `Capture` with 1-based line numbers
* (matching RFC §2.1). The tag includes the leading `@`. */
export function nodeToCapture(name: string, node: SyntaxNode): Capture {
return {
name,
range: {
startLine: node.startPosition.row + 1,
startCol: node.startPosition.column,
endLine: node.endPosition.row + 1,
endCol: node.endPosition.column,
},
text: node.text,
};
}
/** Build a `Capture` whose range mirrors `atNode` but whose `text` is
* caller-supplied. Used to synthesize markers like `@import.kind` that
* don't have a corresponding source token. */
export function syntheticCapture(name: string, atNode: SyntaxNode, text: string): Capture {
return {
name,
range: {
startLine: atNode.startPosition.row + 1,
startCol: atNode.startPosition.column,
endLine: atNode.endPosition.row + 1,
endCol: atNode.endPosition.column,
},
text,
};
}
function rangeMatches(
node: SyntaxNode,
range: { startLine: number; startCol: number; endLine: number; endCol: number },
): boolean {
return (
node.startPosition.row + 1 === range.startLine &&
node.startPosition.column === range.startCol &&
node.endPosition.row + 1 === range.endLine &&
node.endPosition.column === range.endCol
);
}
/** Walk subtree to find a node whose range exactly matches AND whose
* type matches `expectedType` (when given). When multiple nodes share
* the range e.g., `function_definition` and its inner `block` body
* for a one-liner the type filter disambiguates. O(n) over the
* candidate subtree; only descends into spans that cover the target,
* so in practice it's near-O(depth). */
export function findNodeAtRange(
root: SyntaxNode,
range: { startLine: number; startCol: number; endLine: number; endCol: number },
expectedType?: string,
): SyntaxNode | null {
if (rangeMatches(root, range) && (expectedType === undefined || root.type === expectedType)) {
return root;
}
const startRow = range.startLine - 1;
const endRow = range.endLine - 1;
for (let i = 0; i < root.namedChildCount; i++) {
const child = root.namedChild(i);
if (child === null) continue;
if (child.endPosition.row < startRow) continue;
if (child.startPosition.row > endRow) break;
const hit = findNodeAtRange(child, range, expectedType);
if (hit !== null) return hit;
}
return null;
}
/** Find the first named child of `node` whose `type` matches `type`. */
export function findChildOfType(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === type) return child;
}
return null;
}
/** First named `identifier` child of `node`, or `null`. */
export function findIdentifierChild(node: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === 'identifier') return child;
}
return null;
}

View file

@ -17,7 +17,7 @@
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from './ast-utils.js';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitImportStatement } from './import-decomposer.js';
import { getPythonParser, getPythonScopeQuery } from './query.js';
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';

View file

@ -12,8 +12,12 @@
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { findChildOfType, nodeToCapture, syntheticCapture } from './ast-utils.js';
import {
findChild,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
/** Tag a single decomposed import. Mirrors the `case` arms of
* `interpretPythonImport`. */
@ -49,8 +53,8 @@ function splitImportStmt(stmtNode: SyntaxNode): CaptureMatch[] {
}),
);
} else if (child.type === 'aliased_import') {
const dotted = findChildOfType(child, 'dotted_name');
const alias = findChildOfType(child, 'identifier');
const dotted = findChild(child, 'dotted_name');
const alias = findChild(child, 'identifier');
if (dotted !== null && alias !== null) {
out.push(
buildImportMatch(stmtNode, {
@ -75,7 +79,7 @@ function splitImportFromStmt(stmtNode: SyntaxNode): CaptureMatch[] {
// Wildcard? tree-sitter-python represents `*` as a `wildcard_import`
// child and emits no name children.
const wildcardChild = findChildOfType(stmtNode, 'wildcard_import');
const wildcardChild = findChild(stmtNode, 'wildcard_import');
if (wildcardChild !== null) {
out.push(
buildImportMatch(stmtNode, {
@ -104,8 +108,8 @@ function splitImportFromStmt(stmtNode: SyntaxNode): CaptureMatch[] {
}),
);
} else if (child.type === 'aliased_import') {
const dotted = findChildOfType(child, 'dotted_name');
const alias = findChildOfType(child, 'identifier');
const dotted = findChild(child, 'dotted_name');
const alias = findChild(child, 'identifier');
if (dotted !== null && alias !== null) {
out.push(
buildImportMatch(stmtNode, {

View file

@ -10,8 +10,7 @@
*/
import type { CaptureMatch } from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { nodeToCapture, syntheticCapture } from './ast-utils.js';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
/** Walk up to the enclosing `class_definition`, ignoring the immediate
* `decorated_definition` wrapper. Returns `null` when the function is

View file

@ -1,5 +1,5 @@
import type Parser from 'tree-sitter';
import type { NodeLabel } from 'gitnexus-shared';
import type { Capture, NodeLabel, Range } from 'gitnexus-shared';
import type { LanguageProvider } from '../language-provider.js';
import { generateId } from '../../../lib/utils.js';
@ -491,3 +491,81 @@ export function findChild(node: SyntaxNode, type: string): SyntaxNode | null {
}
return null;
}
// ============================================================================
// Capture + range helpers (formerly python/ast-utils.ts — language-agnostic)
// ============================================================================
/** Convert a tree-sitter node to a `Capture` with 1-based line numbers
* (matching RFC §2.1). The tag includes the leading `@`. */
export function nodeToCapture(name: string, node: SyntaxNode): Capture {
return {
name,
range: {
startLine: node.startPosition.row + 1,
startCol: node.startPosition.column,
endLine: node.endPosition.row + 1,
endCol: node.endPosition.column,
},
text: node.text,
};
}
/** Build a `Capture` whose range mirrors `atNode` but whose `text` is
* caller-supplied. Used to synthesize markers that don't have a
* corresponding source token. */
export function syntheticCapture(name: string, atNode: SyntaxNode, text: string): Capture {
return {
name,
range: {
startLine: atNode.startPosition.row + 1,
startCol: atNode.startPosition.column,
endLine: atNode.endPosition.row + 1,
endCol: atNode.endPosition.column,
},
text,
};
}
function rangeMatches(node: SyntaxNode, range: Range): boolean {
return (
node.startPosition.row + 1 === range.startLine &&
node.startPosition.column === range.startCol &&
node.endPosition.row + 1 === range.endLine &&
node.endPosition.column === range.endCol
);
}
/** Walk a subtree to find a node whose range exactly matches AND whose
* type matches `expectedType` (when given). When multiple nodes share
* the range e.g., `function_definition` and its inner `block` body
* for a one-liner the type filter disambiguates.
*
* Iterative depth-first-left-to-right via an explicit stack. Children
* are pushed in reverse index order so LIFO pop visits them in source
* order. Prunes branches that can't contain the target range by
* row bounds same optimization the prior recursive form used, minus
* the early-break since stack-push is cheap. */
export function findNodeAtRange(
root: SyntaxNode,
range: Range,
expectedType?: string,
): SyntaxNode | null {
const startRow = range.startLine - 1;
const endRow = range.endLine - 1;
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (rangeMatches(node, range) && (expectedType === undefined || node.type === expectedType)) {
return node;
}
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child === null) continue;
if (child.endPosition.row < startRow) continue;
if (child.startPosition.row > endRow) continue;
stack.push(child);
}
}
return null;
}