diff --git a/CHANGELOG.md b/CHANGELOG.md index 075274efa..90dcbfca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (``/``) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `` blocks (at any nesting depth, including inside ``/``/``), call edges, and calls embedded in `#hash#` expressions inside `` 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 `` 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) diff --git a/__tests__/cfml-receiver-inference.test.ts b/__tests__/cfml-receiver-inference.test.ts new file mode 100644 index 000000000..740527208 --- /dev/null +++ b/__tests__/cfml-receiver-inference.test.ts @@ -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 `` 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, ``, component-level `` + * 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', `\n\n\n`); + write('mod/ModuleConfig.cfc', `\n\nfunction configure() { return 1; }\n\n\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 typed argument used inside a body', async () => { + write('svc/UserService.cfc', userService); + write('svc/OrderService.cfc', orderService); + write('handlers/TagTyped.cfc', ` + + + + return svc.save(1); + + + +`); + 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); + }); +}); diff --git a/src/extraction/cfml-extractor.ts b/src/extraction/cfml-extractor.ts index 2cb834d1f..2f4bc4779 100644 --- a/src/extraction/cfml-extractor.ts +++ b/src/extraction/cfml-extractor.ts @@ -73,6 +73,12 @@ export class CfmlExtractor { if (node.name === '' && (node.kind === 'class' || node.kind === 'interface')) { node.name = componentName; node.qualifiedName = `${this.filePath}::${componentName}`; + } else if (node.qualifiedName === '' || node.qualifiedName.startsWith('::')) { + // Members were scoped under the anonymous component (`::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(''.length); } this.nodes.push(node); } @@ -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; @@ -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; @@ -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, @@ -293,34 +301,38 @@ export class CfmlExtractor { * ``'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 `` at component scope classifies its functions as methods. + * has its own scope and is walked separately. `parentClassName` rides along + * so a `` 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 `...` tag body to the cfscript grammar. - * With `parentIsClass`, functions declared at the script's top level are the - * component's methods (`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 + * (`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; @@ -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) { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 1d1ffb3fd..83b63adce 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index dd609243b..feb3e8ec1 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1185,6 +1185,36 @@ function localReceiverTypePatterns(language: Language, r: string): RegExp[] { new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w]*)`), // var lg: TLogger / param lg: TLogger new RegExp(`\\b${r}\\b\\s*:=\\s*([A-Z][\\w.]*)\\.Create\\b`), // lg := TLogger.Create ]; + case 'cfml': + case 'cfscript': + return [ + // svc = new UserService() / new path.to.UserService() — dotted component + // paths reduce to their final segment via normalizeInferredTypeName. + // Also matches inside tag markup (``) + // since the scan reads raw source lines. + new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), + // The classic form: svc = createObject("component", "path.to.UserService") + // (casing of createObject varies in the wild), plus the modern + // single-argument form createObject("path.to.UserService"). + new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']component["']\\s*,\\s*["']([\\w.]+)["']`), + new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']([\\w.]+)["']\\s*\\)`), + // Typed cfscript parameter: `function save(UserService svc)` / + // `required UserService svc` — CFML's built-in types (string, numeric, + // any, struct…) are lowercase by convention, so the PascalCase guard + // excludes them. + new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), + // Tag-form typed argument, either attribute order: + // + new RegExp(`\\bcfargument[^>\\n]*\\bname\\s*=\\s*["']${r}["'][^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["']`, 'i'), + new RegExp(`\\bcfargument[^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["'][^>\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'), + // Component property (incl. WireBox DI): `property name="svc" + // inject="UserService";` / ``, + // either attribute order. An inject DSL value with a namespace + // (`inject="svc@core"`) captures only the leading name and simply + // fails type-validation — no edge, never a wrong one. + new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\bname\\s*=\\s*["']${r}["'][^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["']`, 'i'), + new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["'][^;\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'), + ]; default: return []; } @@ -1215,9 +1245,28 @@ function inferLocalReceiverType( ref: UnresolvedRef, context: ResolutionContext, ): string | null { + // CFML scope prefixes: `variables.svc` / `this.svc` name a COMPONENT-scoped + // field whose assignment or `property` declaration usually lives outside the + // calling function (the init-pseudoconstructor / WireBox-injection pattern), + // and `local.svc` is an explicit function-local. Strip the prefix so the + // declaration patterns match (`variables.svc = new X()`, `property + // name="svc" …`, `var svc = …` all bind the bare name), and widen the scan + // to the whole file for the component-scoped forms — nearest-declaration- + // backward still wins, so a function-local shadowing the field is preferred. + let scanReceiver = receiverName; + let componentScoped = false; + if (ref.language === 'cfml' || ref.language === 'cfscript') { + const scoped = receiverName.match(/^(variables|this|local|arguments)\.(.+)$/i); + if (scoped) { + scanReceiver = scoped[2]!; + const scope = scoped[1]!.toLowerCase(); + componentScoped = scope === 'variables' || scope === 'this'; + } + } + const patterns = localReceiverTypePatterns( ref.language, - receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ); if (patterns.length === 0) return null; @@ -1230,16 +1279,17 @@ function inferLocalReceiverType( if (!lines || lines.length === 0) return null; const callIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1)); - const startIdx = Math.max(0, enclosingScopeStartLine(ref, context) - 1); + const startIdx = componentScoped + ? 0 + : Math.max(0, enclosingScopeStartLine(ref, context) - 1); - // Nearest declaration wins: scan backward from the call to the scope start. - for (let i = callIdx; i >= startIdx; i--) { + const matchLine = (i: number): string | null => { const line = lines[i]; - if (!line) continue; + if (!line) return null; // A generated/minified line (one multi-KB statement) is not something a // human-written local declaration lives on, and regexing it per ref is // pure waste — skip it rather than scan it. - if (line.length > 10_000) continue; + if (line.length > 10_000) return null; for (const re of patterns) { const m = line.match(re); if (m && m[1]) { @@ -1247,6 +1297,23 @@ function inferLocalReceiverType( if (type) return type; } } + return null; + }; + + // Nearest declaration wins: scan backward from the call to the scope start. + for (let i = callIdx; i >= startIdx; i--) { + const type = matchLine(i); + if (type) return type; + } + // A component-scoped field's declaration is position-independent — the + // `variables.svc = new X()` pseudoconstructor assignment or `property` + // declaration may sit BELOW the calling function in the file — so when the + // backward pass finds nothing, sweep the remainder of the file too. + if (componentScoped) { + for (let i = callIdx + 1; i < lines.length; i++) { + const type = matchLine(i); + if (type) return type; + } } return null; }