diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a0ada..075274e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features - 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) - 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-inheritance-resolution.test.ts b/__tests__/cfml-inheritance-resolution.test.ts new file mode 100644 index 0000000..e1a815e --- /dev/null +++ b/__tests__/cfml-inheritance-resolution.test.ts @@ -0,0 +1,132 @@ +/** + * CFML dotted / relative component-path inheritance resolution (#1152). + * + * CFML names a supertype by its component path, not a bare class name: + * `extends="coldbox.system.web.Controller"` (dots = directories from the + * webroot or a CFML mapping) or `extends="../base"` (FW/1's relative style). + * The graph indexes the class under its final segment only, so before #1152 + * these references never resolved — measured on ColdBox core, 49 of 52 + * extends declarations were dotted and only 3 inheritance edges existed. + * + * These tests pin the matcher's precision rules: the mapping-root prefix may + * be absent from the repo (`coldbox.` IS the repo root in the coldbox repo), + * directory comparison is case-insensitive, a candidate needs at least one + * corroborating parent directory (an uncorroborated same-named class is + * almost always an out-of-repo library supertype — mxunit/testbox), a + * corroboration tie yields no edge, and dotted `calls` refs (member-access + * chains) are never treated as component paths. + */ +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 component-path inheritance resolution (#1152)', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-inh-')); }); + 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 edges: { src: string; srcFile: string; tgt: string; tgtFile: string; kind: string }[] = db + .prepare( + `SELECT s.name src, s.file_path srcFile, t.name tgt, t.file_path tgtFile, e.kind kind + FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind IN ('extends', 'implements')` + ) + .all(); + cg.close?.(); + return edges; + }; + const has = (edges: any[], src: string, tgt: string, tgtFile: string, kind = 'extends') => + edges.some((e) => e.src === src && e.tgt === tgt && e.tgtFile === tgtFile && e.kind === kind); + + it('resolves a dotted path whose mapping root is absent from the repo (the ColdBox shape)', async () => { + write('system/web/Controller.cfc', `component {\n function handle() { return 1; }\n}\n`); + write('handlers/Main.cfc', `component extends="coldbox.system.web.Controller" {\n function index() { return 1; }\n}\n`); + const edges = await load(); + expect(has(edges, 'Main', 'Controller', 'system/web/Controller.cfc')).toBe(true); + }); + + it('disambiguates same-named classes by directory corroboration', async () => { + write('system/web/Controller.cfc', `component {}\n`); + write('other/Controller.cfc', `component {}\n`); + write('handlers/Main.cfc', `component extends="coldbox.system.web.Controller" {}\n`); + const edges = await load(); + expect(has(edges, 'Main', 'Controller', 'system/web/Controller.cfc')).toBe(true); + expect(has(edges, 'Main', 'Controller', 'other/Controller.cfc')).toBe(false); + }); + + it('compares directories case-insensitively (CFML path resolution is)', async () => { + write('system/web/Controller.cfc', `component {}\n`); + write('handlers/Main.cfc', `component extends="COLDBOX.System.Web.Controller" {}\n`); + const edges = await load(); + expect(has(edges, 'Main', 'Controller', 'system/web/Controller.cfc')).toBe(true); + }); + + it('creates no edge when the only same-named class has no corroborating directory (out-of-repo supertype)', async () => { + // `mxunit.framework.TestCase` is an external library; the repo's own + // unrelated TestCase must NOT be claimed as the supertype. + write('lib/TestCase.cfc', `component {}\n`); + write('tests/MyTest.cfc', `component extends="mxunit.framework.TestCase" {}\n`); + const edges = await load(); + expect(edges.filter((e) => e.src === 'MyTest')).toHaveLength(0); + }); + + it('creates no edge on a corroboration tie', async () => { + write('a/models/User.cfc', `component {}\n`); + write('b/models/User.cfc', `component {}\n`); + write('handlers/Main.cfc', `component extends="models.User" {}\n`); + const edges = await load(); + expect(edges.filter((e) => e.src === 'Main')).toHaveLength(0); + }); + + it('resolves a relative path against the referencing file (the FW/1 shape)', async () => { + write('examples/base.cfc', `component {\n function shared() { return 1; }\n}\n`); + write('examples/sub/app.cfc', `component extends="../base" {}\n`); + write('examples/sub/sibling.cfc', `component extends="./app" {}\n`); + const edges = await load(); + expect(has(edges, 'app', 'base', 'examples/base.cfc')).toBe(true); + expect(has(edges, 'sibling', 'app', 'examples/sub/app.cfc')).toBe(true); + }); + + it('resolves dotted implements to an interface as an implements edge', async () => { + write('app/interfaces/IService.cfc', `interface {\n public string function getName();\n}\n`); + write('app/services/Greeter.cfc', `component implements="app.interfaces.IService" {\n public string function getName() { return "hi"; }\n}\n`); + const edges = await load(); + expect(has(edges, 'Greeter', 'IService', 'app/interfaces/IService.cfc', 'implements')).toBe(true); + }); + + it('resolves the tag-based extends attribute the same way', async () => { + write('system/Base.cfc', `component {}\n`); + write('legacy/Old.cfc', `\n\n\n`); + const edges = await load(); + expect(has(edges, 'Old', 'Base', 'system/Base.cfc')).toBe(true); + }); + + it('lowercase dotted paths still resolve when the file name case matches (framework.one)', async () => { + write('framework/one.cfc', `component {\n function onRequest() { return 1; }\n}\n`); + write('Application.cfc', `component extends="framework.one" {}\n`); + const edges = await load(); + expect(has(edges, 'Application', 'one', 'framework/one.cfc')).toBe(true); + }); + + it('never treats a dotted calls reference as a component path', async () => { + // `variables.dsn.getName()` is a member-access chain; the matcher is + // gated to extends/implements so this must not mint a bogus edge to + // a class that happens to share a trailing name. + write('util/getName.cfc', `component {}\n`); + write('svc/Caller.cfc', `component {\n function go() { return variables.dsn.getName(); }\n}\n`); + const edges = await load(); + expect(edges.filter((e) => e.src === 'Caller' || e.src === 'go')).toHaveLength(0); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 43d1992..6ed2394 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -721,6 +721,23 @@ export class ReferenceResolver { return null; } + // CFML component paths in inheritance (#1152): `extends="coldbox.system.web. + // Controller"` names the supertype by its dot-separated path (or `extends= + // "../base"` by relative file path) — the graph indexes the class under its + // final segment only, so these die at the fast pre-filter below and never + // resolved. Handled by a dedicated path-corroborated matcher, gated to + // inheritance refs only (a dotted `calls` ref is a member-access chain, not + // a component path). No fallthrough on miss: the full path string can only + // ever mis-match downstream, and an unresolvable supertype usually lives in + // an out-of-repo library (mxunit, testbox) — silent beats wrong. + if ( + (ref.language === 'cfml' || ref.language === 'cfscript') && + (ref.referenceKind === 'extends' || ref.referenceKind === 'implements') && + (ref.referenceName.includes('.') || ref.referenceName.includes('/')) + ) { + return this.resolveCfmlComponentPath(ref); + } + // Fast pre-filter: skip if no symbol with this name exists anywhere // AND the name doesn't match a local import. The import escape is // necessary because re-export rename chains (`import { login } @@ -1323,6 +1340,99 @@ export class ReferenceResolver { return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' }; } + /** + * Resolve a CFML inheritance reference written as a component path (#1152). + * Two forms exist in real code: + * + * - Dotted: `extends="coldbox.system.web.Controller"` — dots are directory + * separators from the webroot or a CFML mapping. Mappings live in server + * config / Application.cfc, so the leading segments may not exist in the + * repo at all (in the coldbox repo itself the path is `system/web/ + * Controller.cfc` — the `coldbox.` root IS the repo). Matched by final + * segment (the class), corroborated right-to-left against the candidate's + * parent directories. + * - Relative: `extends="../base"` / `extends="./base"` (the FW/1 style) — + * resolved against the referencing file's own directory. + * + * Conservative by design: a candidate needs at least one corroborating + * directory segment (a dotted path whose only same-named class sits in an + * unrelated directory is almost always an out-of-repo library supertype — + * mxunit/testbox/coldbox-as-dependency), and a corroboration tie yields no + * edge. Directory comparison is case-insensitive (CFML path resolution is); + * the class segment itself is matched exactly, which real code satisfies — + * dotted paths are written to match the on-disk file name. + */ + private resolveCfmlComponentPath(ref: UnresolvedRef): ResolvedRef | null { + const cfmlCandidates = (name: string): Node[] => + this.context + .getNodesByName(name) + .filter( + (n) => + (n.kind === 'class' || n.kind === 'interface') && + (n.language === 'cfml' || n.language === 'cfscript') + ); + const norm = (p: string): string => p.replace(/\\/g, '/').toLowerCase(); + + // Relative-path form: `../base`, `./base`, `sub/thing` — resolve against + // the referencing file's directory and require an exact (case-insensitive) + // file match. + if (ref.referenceName.includes('/')) { + const rel = ref.referenceName.replace(/\.cfc$/i, ''); + const fromDir = ref.filePath.replace(/\\/g, '/').split('/').slice(0, -1); + const parts = [...fromDir]; + for (const seg of rel.split('/')) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + if (parts.length === 0) return null; // escapes the project root + parts.pop(); + } else { + parts.push(seg); + } + } + const wantPath = norm(parts.join('/') + '.cfc'); + const className = parts[parts.length - 1]; + if (!className) return null; + const target = cfmlCandidates(className).find((c) => norm(c.filePath) === wantPath); + return target + ? { original: ref, targetNodeId: target.id, confidence: 0.95, resolvedBy: 'file-path' } + : null; + } + + // Dotted form. + const segments = ref.referenceName.split('.').map((s) => s.trim()).filter(Boolean); + if (segments.length < 2) return null; + const className = segments[segments.length - 1]!; + const dirSegments = segments.slice(0, -1); + + let best: Node | null = null; + let bestScore = 0; + let tie = false; + for (const cand of cfmlCandidates(className)) { + const dirs = cand.filePath.replace(/\\/g, '/').split('/').slice(0, -1); + // Count matching directory segments right-to-left: for + // `coldbox.system.web.Controller` vs `system/web/Controller.cfc`, + // `web` and `system` match, then the repo root ends the run → score 2. + let score = 0; + while ( + score < dirSegments.length && + score < dirs.length && + dirSegments[dirSegments.length - 1 - score]!.toLowerCase() === + dirs[dirs.length - 1 - score]!.toLowerCase() + ) { + score++; + } + if (score > bestScore) { + best = cand; + bestScore = score; + tie = false; + } else if (score === bestScore && score > 0) { + tie = true; + } + } + if (!best || bestScore === 0 || tie) return null; + return { original: ref, targetNodeId: best.id, confidence: 0.9, resolvedBy: 'qualified-name' }; + } + /** * Resolve a `this.` function-as-value reference (#756/#808) to the * ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The