feat(pascal): extract paren-less method calls (Obj.Free; / TFoo.GetInstance.DoIt;) (#793)

Pascal/Delphi lets a no-arg method or procedure drop its parens, so the call
parses as a bare `exprDot` (not an `exprCall`) and was never recorded as a call —
callers/impact/trace missed all of them (e.g. `Obj.Free`, `List.Clear`, the
paren-less factory chain `TFoo.GetInstance.DoIt`).

extractPascalParenlessCall handles these, wired into visitPascalBlock scoped to
STATEMENT position only: a bare `Obj.Field;` statement is a no-op, so a
statement-level dot expression is a call — but a dot in assignment LHS/RHS or a
condition is left alone, since there it's genuinely ambiguous with a
field/property access. The chained paren-less form reuses the #750 chain encoding
(gated on the Delphi `TFoo`/`IFoo` type convention) and resolves the same way.

PascalCoin A/B: +1131 / -1 — purely additive, and all 1131 new edges resolve to
METHOD nodes (zero field/property false positives, confirming the statement-level
gate). 3 new synthetic tests (paren-less call, paren-less chained factory, and the
property-write/read non-extraction guard). EXTRACTION_VERSION 16->17. Full suite green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-11 08:54:17 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4c35b72136
commit 35dce04e1f
4 changed files with 164 additions and 6 deletions
+1 -1
View File
@@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 16;
export const EXTRACTION_VERSION = 17;
+75 -5
View File
@@ -4371,6 +4371,69 @@ export class TreeSitterExtractor {
}
}
/**
* Extract a PAREN-LESS Pascal method/procedure call (`Obj.Method;`,
* `TFoo.GetInstance.DoIt;`). Pascal lets a no-arg method drop its parens, so it
* parses as a bare `exprDot` (not an `exprCall`). A bare `exprDot` is
* syntactically identical to a field/property access, so this is only ever
* called for a STATEMENT-level exprDot (caller-gated): a bare `Obj.Field;`
* statement is a no-op, so a statement-level dot expression is a call. (An
* exprDot in assignment LHS/RHS or a condition is left alone — there it really
* can be a field/property read.)
*/
private extractPascalParenlessCall(node: SyntaxNode): void {
if (this.nodeStack.length === 0) return;
const callerId = this.nodeStack[this.nodeStack.length - 1];
if (!callerId) return;
const receiver = node.namedChild(0);
const outerId = node.namedChildren.filter((c: SyntaxNode) => c.type === 'identifier').pop();
const method = outerId ? getNodeText(outerId, this.source) : '';
if (!method) return;
let calleeName = '';
// Chained: the receiver is itself a call — a paren-less `TFoo.GetInstance` (an
// inner exprDot) or a paren'd `TFoo.GetInstance()` (an exprCall). Encode the
// chain `TFoo.GetInstance().DoIt` so resolution infers DoIt's class from what
// the factory RETURNS (#645/#608), gated on the Delphi `TFoo`/`IFoo` type
// convention; a capitalized VARIABLE chain stays a bare method name.
if ((receiver?.type === 'exprDot' || receiver?.type === 'exprCall') && /^\w+$/.test(method)) {
const innerCalleeNode = receiver.type === 'exprCall' ? receiver.namedChild(0) : receiver;
const innerCallee = !innerCalleeNode
? ''
: innerCalleeNode.type === 'identifier'
? getNodeText(innerCalleeNode, this.source)
: innerCalleeNode.namedChildren
.filter((c: SyntaxNode) => c.type === 'identifier')
.map((id: SyntaxNode) => getNodeText(id, this.source))
.join('.');
if (innerCallee && /^[TI][A-Z]/.test(innerCallee)) {
calleeName = `${innerCallee}().${method}`;
// The T/I-prefixed inner is itself a real call — record it too.
if (receiver.type === 'exprCall') this.extractPascalCall(receiver);
else this.extractPascalParenlessCall(receiver);
} else {
calleeName = method; // non-class receiver: a bare method ref (no field-access ref)
}
} else {
// Simple: `Obj.Method` → the dotted name (resolves via the receiver / bare name).
calleeName = node.namedChildren
.filter((c: SyntaxNode) => c.type === 'identifier')
.map((id: SyntaxNode) => getNodeText(id, this.source))
.join('.');
}
if (calleeName) {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: calleeName,
referenceKind: 'calls',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
}
/**
* Recursively visit a Pascal block/statement tree for call expressions
*/
@@ -4381,11 +4444,18 @@ export class TreeSitterExtractor {
if (child.type === 'exprCall') {
this.extractPascalCall(child);
} else if (child.type === 'exprDot') {
// Check if exprDot contains an exprCall
for (let j = 0; j < child.namedChildCount; j++) {
const grandchild = child.namedChild(j);
if (grandchild?.type === 'exprCall') {
this.extractPascalCall(grandchild);
// A STATEMENT-level bare exprDot is a paren-less call (`Obj.Free;`,
// `TFoo.GetInstance.DoIt;`). Anywhere else (assignment side, condition,
// expression) a bare exprDot is ambiguous with a field/property access,
// so there we only descend for paren'd inner calls.
if (node.type === 'statement') {
this.extractPascalParenlessCall(child);
} else {
for (let j = 0; j < child.namedChildCount; j++) {
const grandchild = child.namedChild(j);
if (grandchild?.type === 'exprCall') {
this.extractPascalCall(grandchild);
}
}
}
} else {