From f6e557c3ec15d7ee21974cef8b8b0e5d4fe4c623 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 27 Sep 2026 13:05:57 +0000 Subject: [PATCH] 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 --- __tests__/extraction.test.ts | 83 +++++++++++++++++++++++++++++++++ src/extraction/dfm-extractor.ts | 23 +++++---- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d095e895b..1a971e1c5 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -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 diff --git a/src/extraction/dfm-extractor.ts b/src/extraction/dfm-extractor.ts index 678451300..3b2bfb1c7 100644 --- a/src/extraction/dfm-extractor.ts +++ b/src/extraction/dfm-extractor.ts @@ -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; + } } } }