Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- CodeGraph now indexes **CFML** (`.cfc`, `.cfm`, `.cfs`) — both the classic tag-based style (`<cfcomponent>`/`<cffunction>`) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `<cfscript>` blocks (at any nesting depth, including inside `<cfif>`/`<cfloop>`/`<cftry>`), call edges, and calls embedded in `#hash#` expressions inside `<cfquery>` SQL bodies. Files saved with a UTF-8 byte-order mark and tags with unquoted attribute values — both common in long-lived CFML codebases — are handled too. Thanks @ghedwards. (#1118)
- CFML inheritance written as a component path now links to the right component. `extends="coldbox.system.web.Controller"` names its supertype by dotted path and `extends="../base"` by relative path (the FW/1 style) — both previously produced no inheritance edge at all, which on framework-style CFML apps hid most of the type hierarchy from impact and blast-radius analysis (on ColdBox's own core, over 90% of inheritance was invisible). Resolution is deliberately conservative: the target's directory layout must corroborate the declared path — so a supertype that lives in an out-of-repo library (testbox, mxunit, an installed framework) correctly stays unlinked rather than being guessed at, and an ambiguous path produces no edge rather than a wrong one. (#1152)
- CFML method calls made through a local variable, typed argument, or component property now resolve to the right method — the same receiver-type inference the other object-oriented languages already had. `var svc = new UserService(); svc.save()`, `createObject("component", "path.UserService")`, a typed `<cfargument>` or cfscript parameter, and `variables.`/`this.`-scoped fields — including the pseudoconstructor pattern (`variables.svc = new UserService()` in `init()`) and WireBox-injected properties (`property name="svc" inject="UserService"`) — all now link the call to the declared component's method, with methods inherited from a supertype resolved through the inheritance links above. This makes callers, impact/blast-radius, and `codegraph_explore` flow traces follow CFML service calls instead of dropping them or guessing among same-named methods.
- The Claude Code context hook now recognizes prompts that describe code in plain words — in any language — by checking the prompt's words against the symbol names actually in your project's index. Asking about "the state machine des commandes" finds `OrderStateMachine` with no keyword involved. Confidence decides how much gets injected: structural questions and prompts naming a real symbol still get full context up front; a plain-words match gets a short pointer to the matching symbols so the agent queries them itself; everything else stays silent, exactly as before.
- Anonymous usage telemetry now counts how often the context hook injected context, offered a hint, or stayed silent — fixed counter names only; the prompt's content is never stored or sent. This makes the hook's accuracy measurable instead of guessed. The counters record what actually happened, not what was attempted: a lookup that errors or comes back empty counts as a distinct silent outcome, never as delivered context (#1143, thanks @inth3shadows).
- Metal shader files (`.metal`) are now indexed. Metal Shading Language is close enough to C++ that vertex/fragment/kernel functions, structs, type aliases, and the calls between them all land in the graph — so shader pipelines in Apple-platform projects show up in impact analysis and flow traces instead of being silently skipped. Metal's `[[buffer(0)]]`-style attribute annotations are handled so they can't corrupt what gets extracted. Thanks @FluxKo for the report. (#1121)
Expand Down
185 changes: 185 additions & 0 deletions __tests__/cfml-receiver-inference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* CFML local-variable / component-field receiver-type inference (#1108 family).
*
* `var svc = new UserService(); svc.save()` — the call's receiver type is
* recoverable from its declaration, and resolveMethodOnType validates the
* inferred type actually declares the method, so a mis-inference produces no
* edge. CFML brings four declaration idioms the shared inferrer must know:
* `new` (dotted component paths included), `createObject("component", "...")`,
* typed arguments (cfscript params and `<cfargument>` tags), and component
* properties — including WireBox DI (`property name="svc" inject="..."`),
* whose receivers are `variables.`-scoped fields declared OUTSIDE the calling
* function (so the scan must widen to the whole file, in both directions).
*
* These tests also pin the extraction prerequisite: CFML method
* qualifiedNames carry the component scope (`UserService::save`) in all three
* extraction paths (bare-script, `<cffunction>`, component-level `<cfscript>`
* blocks) — without that, type-validated resolution can never match.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';

describe('CFML receiver-type inference', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-recv-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });

const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};

const load = async () => {
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const calls: { src: string; tgt: string; tgtQn: string }[] = db
.prepare(
`SELECT s.name src, t.name tgt, t.qualified_name tgtQn
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind = 'calls' AND t.kind = 'method'`
)
.all();
const methods: { name: string; qn: string }[] = db
.prepare(`SELECT name, qualified_name qn FROM nodes WHERE kind = 'method'`)
.all();
cg.close?.();
return { calls, methods };
};
const hasCall = (calls: any[], src: string, tgtQn: string) =>
calls.some((e) => e.src === src && e.tgtQn === tgtQn);

// Two same-named methods so resolution MUST disambiguate by receiver type —
// plain name-matching alone can't pick one.
const userService = `component {\n function save(any u) { return u; }\n}\n`;
const orderService = `component {\n function save(any o) { return o; }\n}\n`;

it('scopes method qualifiedNames under the component in all three extraction paths', async () => {
write('svc/UserService.cfc', userService);
write('tag/TagService.cfc', `<cfcomponent>\n<cffunction name="save"><cfreturn 1></cffunction>\n</cfcomponent>\n`);
write('mod/ModuleConfig.cfc', `<cfcomponent>\n<cfscript>\nfunction configure() { return 1; }\n</cfscript>\n</cfcomponent>\n`);
const { methods } = await load();
expect(methods.find((m) => m.name === 'save' && m.qn === 'UserService::save')).toBeDefined();
expect(methods.find((m) => m.name === 'save' && m.qn === 'TagService::save')).toBeDefined();
expect(methods.find((m) => m.name === 'configure' && m.qn === 'ModuleConfig::configure')).toBeDefined();
});

it('infers a local declared with new, including a dotted component path', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Main.cfc', `component {
function bare() {
var svc = new UserService();
return svc.save(1);
}
function dotted() {
var svc2 = new svc.UserService();
return svc2.save(2);
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'bare', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'dotted', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'bare', 'OrderService::save')).toBe(false);
});

it('infers a local declared with createObject (two-arg and single-arg forms)', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Legacy.cfc', `component {
function classic() {
var svc = createObject("component", "svc.UserService");
return svc.save(1);
}
function modern() {
var svc2 = CreateObject("svc.OrderService");
return svc2.save(2);
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'classic', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'modern', 'OrderService::save')).toBe(true);
});

it('infers a typed cfscript parameter', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Typed.cfc', `component {
function process(required UserService svc) {
return svc.save(1);
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'process', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'process', 'OrderService::save')).toBe(false);
});

it('infers a <cfargument> typed argument used inside a <cfscript> body', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/TagTyped.cfc', `<cfcomponent>
<cffunction name="process">
<cfargument name="svc" type="svc.UserService">
<cfscript>
return svc.save(1);
</cfscript>
</cffunction>
</cfcomponent>
`);
const { calls } = await load();
expect(hasCall(calls, 'process', 'UserService::save')).toBe(true);
});

it('infers a variables-scoped field from its pseudoconstructor assignment, even when init sits below the call', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Fielded.cfc', `component {
function handle() {
return variables.svc.save(1);
}
function init() {
variables.svc = new UserService();
return this;
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'handle', 'UserService::save')).toBe(true);
expect(hasCall(calls, 'handle', 'OrderService::save')).toBe(false);
});

it('infers a WireBox-injected property (the ColdBox DI shape)', async () => {
write('svc/UserService.cfc', userService);
write('svc/OrderService.cfc', orderService);
write('handlers/Injected.cfc', `component {
property name="svc" inject="UserService";

function handle() {
return variables.svc.save(1);
}
}
`);
const { calls } = await load();
expect(hasCall(calls, 'handle', 'UserService::save')).toBe(true);
});

it('creates no method edge when the inferred type does not declare the method', async () => {
write('svc/UserService.cfc', userService);
write('handlers/Wrong.cfc', `component {
function go() {
var svc = new UserService();
return svc.destroyEverything();
}
}
`);
const { calls } = await load();
expect(calls.filter((e) => e.src === 'go')).toHaveLength(0);
});
});
53 changes: 34 additions & 19 deletions src/extraction/cfml-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ export class CfmlExtractor {
if (node.name === '<anonymous>' && (node.kind === 'class' || node.kind === 'interface')) {
node.name = componentName;
node.qualifiedName = `${this.filePath}::${componentName}`;
} else if (node.qualifiedName === '<anonymous>' || node.qualifiedName.startsWith('<anonymous>::')) {
// Members were scoped under the anonymous component (`<anonymous>::save`)
// — carry the rename into their scope chains so type-validated method
// resolution (which wants `UserService::save`, see resolveMethodOnType)
// can match them. Inner genuinely-anonymous segments are untouched.
node.qualifiedName = componentName + node.qualifiedName.slice('<anonymous>'.length);
}
this.nodes.push(node);
}
Expand Down Expand Up @@ -225,13 +231,13 @@ export class CfmlExtractor {
break;
}
if (sibling.type === 'cf_function_tag') {
this.extractFunctionTag(sibling, classNode.id, classNode.id);
this.extractFunctionTag(sibling, classNode.id, classNode.id, classNode.name);
} else if (sibling.type === 'cf_script_tag') {
this.delegateScriptTag(sibling, classNode.id, true);
this.delegateScriptTag(sibling, classNode.id, classNode.name);
} else if (sibling.type === 'cf_query_tag') {
this.delegateQueryTag(sibling, classNode.id);
} else {
this.delegateNestedTags(sibling, classNode.id, true);
this.delegateNestedTags(sibling, classNode.id, classNode.name);
}
lastNode = sibling;
sibling = sibling.nextSibling;
Expand All @@ -246,9 +252,11 @@ export class CfmlExtractor {
* the `contains`-edge target (the class when inside one, otherwise the file
* node for a bare top-level cffunction) — kept separate so a top-level
* function still gets a containment edge without being misclassified as a
* method of the file.
* method of the file. A method's qualifiedName is scoped under
* `parentClassName` (`TagService::save`, the same `Class::member` shape the
* generic extractor produces) so type-validated method resolution can match.
*/
private extractFunctionTag(tag: SyntaxNode, parentClassId: string | undefined, containerId: string | undefined): void {
private extractFunctionTag(tag: SyntaxNode, parentClassId: string | undefined, containerId: string | undefined, parentClassName?: string): void {
const name = this.tagAttr(tag, 'name');
if (!name) return;

Expand All @@ -264,7 +272,7 @@ export class CfmlExtractor {
id,
kind,
name,
qualifiedName: `${this.filePath}::${name}`,
qualifiedName: parentClassName ? `${parentClassName}::${name}` : `${this.filePath}::${name}`,
filePath: this.filePath,
language: this.language,
startLine: tag.startPosition.row + 1,
Expand Down Expand Up @@ -293,34 +301,38 @@ export class CfmlExtractor {
* `<cfcomponent>`'s body — see the implicit-end-tag note on `extractComponent`)
* ARE normal children, just possibly several levels deep, so a direct-children
* check misses them. Does not descend into a nested `cf_function_tag` — that
* has its own scope and is walked separately. `parentIsClass` rides along so
* a `<cfscript>` at component scope classifies its functions as methods.
* has its own scope and is walked separately. `parentClassName` rides along
* so a `<cfscript>` at component scope classifies its functions as methods
* scoped under the component.
*/
private delegateNestedTags(node: SyntaxNode, containerId: string | undefined, parentIsClass = false): void {
private delegateNestedTags(node: SyntaxNode, containerId: string | undefined, parentClassName?: string): void {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'cf_script_tag') {
this.delegateScriptTag(child, containerId, parentIsClass);
this.delegateScriptTag(child, containerId, parentClassName);
} else if (child.type === 'cf_query_tag') {
this.delegateQueryTag(child, containerId);
} else if (child.type === 'cf_function_tag') {
continue;
} else {
this.delegateNestedTags(child, containerId, parentIsClass);
this.delegateNestedTags(child, containerId, parentClassName);
}
}
}

/**
* Delegate a `<cfscript>...</cfscript>` tag body to the cfscript grammar.
* With `parentIsClass`, functions declared at the script's top level are the
* component's methods (`<cfcomponent><cfscript>function configure(){}` — the
* standard ColdBox ModuleConfig shape), so they're re-kinded `function` →
* `method` to match how the same function classifies in a script-style CFC.
* Functions nested inside another function (closures) keep kind `function`.
* With `parentClassName` set (the block sits at component scope), functions
* declared at the script's top level are the component's methods
* (`<cfcomponent><cfscript>function configure(){}` — the standard ColdBox
* ModuleConfig shape): they're re-kinded `function` → `method`, and every
* merged symbol's qualifiedName is prefixed with the component scope
* (`configure` → `ModuleConfig::configure`) so type-validated method
* resolution can match them. Functions nested inside another function
* (closures) keep kind `function`.
*/
private delegateScriptTag(scriptTag: SyntaxNode, parentId: string | undefined, parentIsClass = false): void {
private delegateScriptTag(scriptTag: SyntaxNode, parentId: string | undefined, parentClassName?: string): void {
const content = scriptTag.namedChildren.find((c: SyntaxNode) => c.type === 'cf_script_content');
if (!content) return;

Expand Down Expand Up @@ -349,8 +361,11 @@ export class CfmlExtractor {
node.startLine += startLine;
node.endLine += startLine;
node.language = this.language;
if (parentIsClass && node.kind === 'function' && topLevelIds.has(node.id)) {
node.kind = 'method';
if (parentClassName) {
if (node.kind === 'function' && topLevelIds.has(node.id)) {
node.kind = 'method';
}
node.qualifiedName = `${parentClassName}::${node.qualifiedName}`;
}
this.nodes.push(node);
if (parentId) {
Expand Down
15 changes: 15 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3830,6 +3830,21 @@ export class TreeSitterExtractor {
else reencode = !!innerCallee;
}
calleeName = reencode ? `${innerCallee}().${methodName}` : methodName;
} else if (
this.language === 'cfscript' &&
receiver &&
receiver.type === 'member_expression' &&
/^(variables|this|local|arguments)\.[A-Za-z_][\w]*$/i.test(getNodeText(receiver, this.source))
) {
// CFML scope-prefixed member call — `variables.svc.save()` /
// `arguments.svc.save()`: the receiver is a component field,
// injected property, or typed argument reached through one of
// CFML's file-local scopes. Keep the full receiver chain so
// resolution can strip the scope prefix and infer the field's
// component type from its declaration (#1108). Gated to these
// scope keywords: such calls previously emitted a bare method
// name, which either failed to resolve or resolved ambiguously.
calleeName = `${getNodeText(receiver, this.source)}.${methodName}`;
} else {
calleeName = methodName;
}
Expand Down
Loading