mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
* fix(cfml): tag-based calls, wrapped functions and <cfinterface> are indexed (#2091) Tag-based CFML lost almost all of its call edges. The tag walk sent only <cfscript> and <cfquery> bodies through call extraction, although the cfml grammar parses the expressions in <cfset>, <cfif>/<cfelseif>, <cfreturn> and #hash# (output, strings, attributes) with the same rules as cfscript. The walk now records each such expression with the scope that owns it (the enclosing <cffunction>, else the component, else the file) and reads them all through the existing cfscript extraction in one parse per file: the expressions are copied out on their original lines, each ended with a `;`, and every reference is handed back to its expression's scope with its column restored. <cfloop condition="..."> is read the same way. A <cffunction> nested in a generic container tag at component or template scope (<cfprocessingdirective> in Application.cfc, <cfsilent>) was skipped because only direct children were examined; it is now extracted (never through an ERROR node, where a lost <cfcomponent> would misfile methods as top-level functions). <cfinterface>, a generic tag to the grammar, now yields an interface node with its functions as methods and an `extends` ref per comma-separated parent, so `implements` resolves to it. The tag-based parse tree is now freed after the walk. Resolution: the CFML/Ruby/C# bare-call scope filters evaluated the receiver-on-line check once per same-named candidate though only the ref decides it. With tag calls extracted, that made Mach-II's resolution ~8x slower; the check is now memoized per ref (edges byte-identical). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): tag-based CFML calls (#2091) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tag-based CFML end to end (#2091). Most calls in a tag-based component sit
|
||||
* in `<cfset>`/`<cfif>`/`<cfreturn>` and `#hash#` expressions, not in
|
||||
* `<cfscript>`; on a 6,000-file tag-based codebase only 6.4% of methods had a
|
||||
* caller in the graph. Functions wrapped in `<cfprocessingdirective>` (the
|
||||
* Application.cfc shape) and `<cfinterface>` components were missing as well,
|
||||
* so their callers and implementers had nothing to land on.
|
||||
*/
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
let root = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cfml-tag-calls-'));
|
||||
const files: Record<string, string> = {
|
||||
'model/Svc.cfc': `<cfcomponent>
|
||||
\t<cffunction name="a">
|
||||
\t\t<cfset x = b(1)>
|
||||
\t\t<cfif c(2)></cfif>
|
||||
\t\t<cfreturn d()>
|
||||
\t</cffunction>
|
||||
\t<cffunction name="b"></cffunction>
|
||||
\t<cffunction name="c"></cffunction>
|
||||
\t<cffunction name="d"></cffunction>
|
||||
</cfcomponent>
|
||||
`,
|
||||
'Application.cfc': `<cfcomponent>
|
||||
\t<cfprocessingdirective suppresswhitespace="true">
|
||||
\t\t<cffunction name="onRequestStart">
|
||||
\t\t\t<cfset loadConfig()>
|
||||
\t\t</cffunction>
|
||||
\t\t<cffunction name="loadConfig"></cffunction>
|
||||
\t</cfprocessingdirective>
|
||||
</cfcomponent>
|
||||
`,
|
||||
'model/ISearchable.cfc': `<cfinterface>
|
||||
\t<cffunction name="search" access="public"></cffunction>
|
||||
</cfinterface>
|
||||
`,
|
||||
'model/Catalog.cfc': `<cfcomponent implements="ISearchable">
|
||||
\t<cffunction name="search" access="public"></cffunction>
|
||||
</cfcomponent>
|
||||
`,
|
||||
};
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const edgesFrom = (file: string, kind: string): string[] => {
|
||||
const ids = cg.getNodesInFile(file).map((n) => n.id);
|
||||
return cg
|
||||
.getOutgoingEdgesFrom(ids)
|
||||
.filter((e) => e.kind === kind)
|
||||
.map((e) => `${cg.getNode(e.source)!.qualifiedName} -> ${cg.getNode(e.target)!.qualifiedName}`)
|
||||
.sort();
|
||||
};
|
||||
|
||||
describe('tag-based CFML (#2091)', () => {
|
||||
it('links calls written in <cfset>, <cfif> and <cfreturn> to the methods they call', () => {
|
||||
expect(edgesFrom('model/Svc.cfc', 'calls')).toEqual(['Svc::a -> Svc::b', 'Svc::a -> Svc::c', 'Svc::a -> Svc::d']);
|
||||
});
|
||||
|
||||
it('indexes a method wrapped in <cfprocessingdirective> and the calls it makes', () => {
|
||||
expect(edgesFrom('Application.cfc', 'calls')).toEqual(['Application::onRequestStart -> Application::loadConfig']);
|
||||
});
|
||||
|
||||
it('resolves <cfcomponent implements="…"> to a <cfinterface> component', () => {
|
||||
const iface = cg.getNodesInFile('model/ISearchable.cfc').find((n) => n.kind === 'interface');
|
||||
expect(iface?.name).toBe('ISearchable');
|
||||
expect(edgesFrom('model/Catalog.cfc', 'implements')).toEqual(['model/Catalog.cfc::Catalog -> model/ISearchable.cfc::ISearchable']);
|
||||
});
|
||||
});
|
||||
@@ -11099,6 +11099,162 @@ import foo.cfm;
|
||||
expect(result.nodes.find((n) => n.name === 'innerHelper')?.kind).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calls in tag expressions outside <cfscript>/<cfquery> (#2091)', () => {
|
||||
const callsFrom = (result: ReturnType<typeof extractFromSource>, fromId: string | undefined) =>
|
||||
result.unresolvedReferences
|
||||
.filter((r) => r.fromNodeId === fromId && (r.referenceKind === 'calls' || r.referenceKind === 'instantiates'))
|
||||
.map((r) => `${r.referenceKind === 'instantiates' ? 'new ' : ''}${r.referenceName}@${r.line}`)
|
||||
.sort();
|
||||
|
||||
it('should extract calls in <cfset>, <cfif>, <cfelseif> and <cfreturn>, attributed to the enclosing method', () => {
|
||||
const code = `<cfcomponent>
|
||||
\t<cffunction name="a">
|
||||
\t\t<cfset x = b(1)>
|
||||
\t\t<cfif c(2)>
|
||||
\t\t<cfelseif e(3)>
|
||||
\t\t</cfif>
|
||||
\t\t<cfreturn d()>
|
||||
\t</cffunction>
|
||||
\t<cffunction name="b"></cffunction>
|
||||
</cfcomponent>
|
||||
`;
|
||||
const result = extractFromSource('Svc.cfc', code);
|
||||
const a = result.nodes.find((n) => n.kind === 'method' && n.name === 'a');
|
||||
expect(a).toBeDefined();
|
||||
expect(callsFrom(result, a?.id)).toEqual(['b@3', 'c@4', 'd@7', 'e@5']);
|
||||
});
|
||||
|
||||
it('should extract #hash# expressions in output, strings and tag attributes — once each, alongside <cfquery> bodies', () => {
|
||||
const code = `<cfcomponent>
|
||||
<cffunction name="render">
|
||||
<cfset var y = svc.load(1)>
|
||||
<cfset local.msg = "Hi #userName()#">
|
||||
<cfoutput>#fmt(y)# and #variables.mailer.send()#</cfoutput>
|
||||
<cfloop array="#getItems()#" index="i"></cfloop>
|
||||
<cfquery name="q" datasource="#dsn()#">SELECT #col()# FROM t</cfquery>
|
||||
<cfset obj = new Widget()>
|
||||
</cffunction>
|
||||
</cfcomponent>
|
||||
`;
|
||||
const result = extractFromSource('View.cfc', code);
|
||||
const render = result.nodes.find((n) => n.kind === 'method' && n.name === 'render');
|
||||
expect(callsFrom(result, render?.id)).toEqual([
|
||||
'col@7',
|
||||
'dsn@7',
|
||||
'fmt@5',
|
||||
'getItems@6',
|
||||
'new Widget@8',
|
||||
'svc.load@3',
|
||||
'userName@4',
|
||||
'variables.mailer.send@5',
|
||||
]);
|
||||
// `<cfset var y = …>` is a function local — no variable node for it.
|
||||
expect(result.nodes.find((n) => n.name === 'y')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should attribute component-scope and template-scope calls to the component and the file', () => {
|
||||
const component = `<cfcomponent>\n<cfset setup()>\n<cffunction name="m"></cffunction>\n</cfcomponent>\n`;
|
||||
const cfc = extractFromSource('Pseudo.cfc', component);
|
||||
const cls = cfc.nodes.find((n) => n.kind === 'class');
|
||||
expect(callsFrom(cfc, cls?.id)).toEqual(['setup@2']);
|
||||
|
||||
const template = `<cfset items = loadItems()>\n<cfoutput>#renderList(items)#</cfoutput>\n`;
|
||||
const cfm = extractFromSource('index.cfm', template);
|
||||
const file = cfm.nodes.find((n) => n.kind === 'file');
|
||||
expect(callsFrom(cfm, file?.id)).toEqual(['loadItems@1', 'renderList@2']);
|
||||
});
|
||||
|
||||
it("should keep each call's own line and column (several per line, and across lines)", () => {
|
||||
const code = `<cfcomponent>
|
||||
<cffunction name="m"><cfset a()><cfif b.c()><cfset d(e())></cfif>
|
||||
<cfset total = sum(
|
||||
count(), other.fetch())><cfoutput>#last()#</cfoutput>
|
||||
</cffunction>
|
||||
</cfcomponent>
|
||||
`;
|
||||
const lines = code.split('\n');
|
||||
const result = extractFromSource('Pos.cfc', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
|
||||
expect(calls.map((r) => r.referenceName).sort()).toEqual(['a', 'b.c', 'count', 'd', 'e', 'last', 'other.fetch', 'sum']);
|
||||
for (const r of calls) {
|
||||
expect(lines[r.line - 1]!.slice(r.column).startsWith(r.referenceName)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should read a <cfloop condition="…"> attribute as an expression', () => {
|
||||
const code = `<cfcomponent>\n<cffunction name="drain">\n<cfloop condition="hasNext()">\n</cfloop>\n</cffunction>\n</cfcomponent>\n`;
|
||||
const result = extractFromSource('Queue.cfc', code);
|
||||
const drain = result.nodes.find((n) => n.kind === 'method' && n.name === 'drain');
|
||||
expect(callsFrom(result, drain?.id)).toEqual(['hasNext@3']);
|
||||
});
|
||||
|
||||
it('should not read HTML <script> bodies or plain text as CFML calls', () => {
|
||||
const code = `<cfcomponent>\n<cffunction name="page">\n<script>notACall();</script>\n<p>also(not)</p>\n</cffunction>\n</cfcomponent>\n`;
|
||||
const result = extractFromSource('Page.cfc', code);
|
||||
expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<cffunction> inside a generic container tag (#2091)', () => {
|
||||
it('should extract a method nested in <cfprocessingdirective> (the Application.cfc shape)', () => {
|
||||
const code = `<cfcomponent>
|
||||
\t<cfprocessingdirective suppresswhitespace="true">
|
||||
\t\t<cffunction name="onRequestStart">
|
||||
\t\t\t<cfset loadConfig()>
|
||||
\t\t</cffunction>
|
||||
\t</cfprocessingdirective>
|
||||
</cfcomponent>
|
||||
`;
|
||||
const result = extractFromSource('Application.cfc', code);
|
||||
const cls = result.nodes.find((n) => n.kind === 'class');
|
||||
const method = result.nodes.find((n) => n.kind === 'method' && n.name === 'onRequestStart');
|
||||
expect(method).toBeDefined();
|
||||
expect(method?.qualifiedName).toBe('Application::onRequestStart');
|
||||
expect(result.edges.some((e) => e.kind === 'contains' && e.source === cls?.id && e.target === method?.id)).toBe(true);
|
||||
const call = result.unresolvedReferences.find((r) => r.referenceName === 'loadConfig');
|
||||
expect(call?.fromNodeId).toBe(method?.id);
|
||||
});
|
||||
|
||||
it('should extract a top-level function nested in <cfsilent> in a template', () => {
|
||||
const code = `<cfsilent>\n<cffunction name="helper"><cfreturn 1></cffunction>\n</cfsilent>\n`;
|
||||
const result = extractFromSource('lib.cfm', code);
|
||||
expect(result.nodes.find((n) => n.name === 'helper')?.kind).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<cfinterface> (#2091)', () => {
|
||||
const code = `<cfinterface extends="IBase, IOther">
|
||||
\t<cffunction name="search" access="public" returntype="any">
|
||||
\t\t<cfargument name="q" type="string">
|
||||
\t</cffunction>
|
||||
\t<cffunction name="count"></cffunction>
|
||||
</cfinterface>
|
||||
`;
|
||||
|
||||
it('should extract an interface node named from the file, with its functions as methods', () => {
|
||||
const result = extractFromSource('ISearchable.cfc', code);
|
||||
const iface = result.nodes.find((n) => n.kind === 'interface');
|
||||
expect(iface?.name).toBe('ISearchable');
|
||||
expect(iface?.startLine).toBe(1);
|
||||
expect(iface?.endLine).toBe(6);
|
||||
expect(result.nodes.filter((n) => n.kind === 'class')).toHaveLength(0);
|
||||
const methods = result.nodes.filter((n) => n.kind === 'method');
|
||||
expect(methods.map((m) => m.qualifiedName).sort()).toEqual(['ISearchable::count', 'ISearchable::search']);
|
||||
for (const m of methods) {
|
||||
expect(result.edges.some((e) => e.kind === 'contains' && e.source === iface?.id && e.target === m.id)).toBe(true);
|
||||
}
|
||||
expect(methods.find((m) => m.name === 'search')?.returnType).toBe('any');
|
||||
});
|
||||
|
||||
it('should extract each interface it extends', () => {
|
||||
const result = extractFromSource('ISearchable.cfc', code);
|
||||
const iface = result.nodes.find((n) => n.kind === 'interface');
|
||||
const ext = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
|
||||
expect(ext.map((r) => r.referenceName)).toEqual(['IBase', 'IOther']);
|
||||
expect(ext.every((r) => r.fromNodeId === iface?.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('COBOL Extraction', () => {
|
||||
|
||||
Reference in New Issue
Block a user