fix(extraction): preserve complete DFM component ranges (#1350) (#2010)

DFM/FMX component ranges stayed pinned to their declaration lines because closing blocks only popped IDs.
Store nodes in the component stack and update their ending positions at the matching end.
Cover persisted ranges and MCP source retrieval while preserving containment and event references.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 13:05:57 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c505d0de08
commit f6e557c3ec
2 changed files with 97 additions and 9 deletions
+83
View File
@@ -5822,6 +5822,89 @@ end`;
expect(components.length).toBe(2);
});
describe('component source ranges (#1350)', () => {
let tempDir: string;
let cg: CodeGraph | undefined;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cg?.close();
cg = undefined;
cleanupTempDir(tempDir);
});
it.each(['dfm', 'fmx'])('persists complete nested %s bodies and retrieves event bindings', async (extension) => {
const source = `inherited Form1: TForm1
inline Frame1: TFrame
object Button1: TButton
Caption = 'Click'
Items.Strings = (
'First'
'Second')
Panels = <
item
Width = 100
end
item
Width = 200
end>
OnClick = Button1Click
end
OnEnter = FrameEnter
end
object Label1: TLabel
Caption = 'Sibling'
end
end`;
const fileName = `Form1.${extension}`;
fs.writeFileSync(path.join(tempDir, fileName), source);
cg = CodeGraph.initSync(tempDir);
expect((await cg.indexAll()).filesIndexed).toBe(1);
const nodes = cg.getNodesInFile(fileName);
const components = nodes.filter((node) => node.kind === 'component');
expect(components.map(({ name, startLine, endLine, endColumn }) => ({
name, startLine, endLine, endColumn,
}))).toEqual(expect.arrayContaining([
{ name: 'Form1', startLine: 1, endLine: 22, endColumn: 3 },
{ name: 'Frame1', startLine: 2, endLine: 18, endColumn: 5 },
{ name: 'Button1', startLine: 3, endLine: 16, endColumn: 7 },
{ name: 'Label1', startLine: 19, endLine: 21, endColumn: 5 },
]));
expect(components).toHaveLength(4);
const extracted = extractFromSource(fileName, source);
const file = extracted.nodes.find((node) => node.kind === 'file')!;
const form = components.find((node) => node.name === 'Form1')!;
const frame = components.find((node) => node.name === 'Frame1')!;
const button = components.find((node) => node.name === 'Button1')!;
const label = components.find((node) => node.name === 'Label1')!;
for (const [parent, child] of [[file, form], [form, frame], [frame, button], [form, label]]) {
expect(extracted.edges).toContainEqual({ source: parent!.id, target: child!.id, kind: 'contains' });
}
expect(extracted.unresolvedReferences).toEqual([
expect.objectContaining({ fromNodeId: button.id, referenceName: 'Button1Click' }),
expect.objectContaining({ fromNodeId: frame.id, referenceName: 'FrameEnter' }),
]);
expect(file.endLine).toBe(22);
const { ToolHandler } = await import('../src/mcp/tools');
const handler = new ToolHandler(cg);
for (const [tool, args] of [
['codegraph_node', { symbol: 'Button1', includeCode: true }],
['codegraph_explore', { query: 'Button1' }],
] as const) {
const result = await handler.execute(tool, args);
expect(result.isError).toBeUndefined();
expect(result.content[0]!.text).toContain("Caption = 'Click'");
expect(result.content[0]!.text).toContain('OnClick = Button1Click');
}
});
});
describe('Full fixture: MainForm.dfm', () => {
const code = `object frmMain: TfrmMain
Left = 0
+14 -9
View File
@@ -34,7 +34,7 @@ export class DfmExtractor {
try {
const fileNode = this.createFileNode();
this.parseComponents(fileNode.id);
this.parseComponents(fileNode);
} catch (error) {
this.errors.push({
message: `DFM extraction error: ${error instanceof Error ? error.message : String(error)}`,
@@ -76,9 +76,9 @@ export class DfmExtractor {
}
/** Parse object/end blocks and extract components + event handlers */
private parseComponents(fileNodeId: string): void {
private parseComponents(fileNode: Node): void {
const lines = this.source.split('\n');
const stack: string[] = [fileNodeId];
const stack: Node[] = [fileNode];
const objectPattern = /^\s*(object|inherited|inline)\s+(\w+)\s*:\s*(\w+)/;
const eventPattern = /^\s*(On\w+)\s*=\s*(\w+)\s*$/;
@@ -113,7 +113,7 @@ export class DfmExtractor {
if (objMatch) {
const [, , name, typeName] = objMatch;
const nodeId = generateNodeId(this.filePath, 'component', name!, lineNum);
this.nodes.push({
const node: Node = {
id: nodeId,
kind: 'component',
name: name!,
@@ -126,13 +126,14 @@ export class DfmExtractor {
endColumn: line.length,
signature: typeName,
updatedAt: Date.now(),
});
};
this.nodes.push(node);
this.edges.push({
source: stack[stack.length - 1]!,
source: stack[stack.length - 1]!.id,
target: nodeId,
kind: 'contains',
});
stack.push(nodeId);
stack.push(node);
continue;
}
@@ -141,7 +142,7 @@ export class DfmExtractor {
if (eventMatch) {
const [, , methodName] = eventMatch;
this.unresolvedReferences.push({
fromNodeId: stack[stack.length - 1]!,
fromNodeId: stack[stack.length - 1]!.id,
referenceName: methodName!,
referenceKind: 'references',
line: lineNum,
@@ -152,7 +153,11 @@ export class DfmExtractor {
// Block end
if (endPattern.test(line)) {
if (stack.length > 1) stack.pop();
if (stack.length > 1) {
const node = stack.pop()!;
node.endLine = lineNum;
node.endColumn = line.length;
}
}
}
}