extends="coldbox.system.web.Controller" (dotted) and extends="../base" (relative) now resolve to the right component via directory-corroborated matching; >=1 corroborating segment required, ties yield no edge. fw1 14->47, ColdBox 21->242, CFWheels 60->201 inheritance edges; 394/394 audited path-consistent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
816bacb7f2
commit
5f22da35f3
@@ -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 (`<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)
|
||||
- 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)
|
||||
|
||||
@@ -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', `<cfcomponent extends="app.system.Base">\n<cffunction name="run"><cfreturn 1></cffunction>\n</cfcomponent>\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);
|
||||
});
|
||||
});
|
||||
@@ -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.<member>` function-as-value reference (#756/#808) to the
|
||||
* ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The
|
||||
|
||||
Reference in New Issue
Block a user