Files
codegraph/__tests__/cfml-inheritance-resolution.test.ts
T
Colby MchenryandClaude Opus 5.5 f406fe0064 fix(cfml): createObject("component", path) links its component, and .init() on it (#2841)
`new a.b.Batch()` made an instantiates edge, but
`createObject("component", "a.b.Batch")`, the form older CFML uses most,
was extracted as a call to the engine function createObject, which never
resolves. A call chained onto either construction, `createObject(...).init()`
or `new a.b.Batch().init()`, was extracted as a bare `init`, which the
name matcher bound to whichever component's init came first (0.4), or to
none when there are many.

Fix:
- cfscript extraction (tag expressions included): a component
  createObject, with two arguments, one, or named ones, makes an
  instantiates reference under the dotted path as written, not a call.
  Java and other object types, and names built at runtime, are unchanged.
- A call on a construction is extracted as `<component>().<method>`.
- The resolver reads a dotted instantiates path with
  resolveCfmlComponentPath, the rules extends uses, and resolves a chained
  call to the method of that component or one it inherits (matched
  ignoring case; inheritance is wired before calls). A bare component name
  is the one in the calling file's folder, else the only one so named.
  Without both, the call gets no edge rather than a guess by name.
- resolveCfmlComponentPath also accepts a component at the project root
  for a path with one folder segment, its file named like it:
  `wheels.JobWorker` in Wheels' own source, where `wheels` maps onto the
  root. Without it Wheels' core lost calls like
  `new wheels.JobWorker().processNext()` that name matching had found. It
  ranks below any folder match; extends gains the same.

Validation:
- New cfml-create-object.test.ts (5 cases) and a root-component case in
  cfml-inheritance-resolution.test.ts: 5 of the 6 fail on main (the sixth
  pins the variable typing that already worked), all pass.
- A/B vs main d90f6abe on FW/1, BlogCFC, ColdBox, ContentBox, Preside,
  CFWheels and its vendor/wheels core indexed as its own project:
  1,022 edges removed: 563 resolved again to the same target by type,
  444 moved from a method guessed by name to the constructed component's
  own or inherited method, and 15 dropped, all name guesses for
  components outside the index, without the method, or tied. 1,342 added:
  641 instantiates, 38 calls, and in Wheels' core 653 extends and 10
  implements to root components. Oracles over the fix graphs: of the 983
  chained-construction call edges, 965 land in the file named like the
  component and 18 on a method its supertypes declare (each checked); all
  608 dotted instantiates and, in Wheels' core, all 786 dotted
  extends/implements edges target a file named like the path's last
  segment. One site is doubtful: Wheels' core public/docs/core.cfm names
  the application's app.models.Model, which that subtree lacks; one
  `models` folder corroborates a test asset's Model.cfc, as it would for
  extends. express and flask controls byte-identical.
- Index time, median of 4 interleaved runs, CPU with workers, main vs
  fix: Preside 58.7 / 54.5 s, Wheels core 32.2 / 33.0 s. Within noise.

Fixes #2652

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:49:44 +00:00

183 lines
10 KiB
TypeScript

/**
* 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('passes over a test suite that mirrors the component path (#2650)', async () => {
// `contenscms` is a server mapping onto `contens/`, so only `core`
// corroborates either file: the component and the TestBox spec named
// after it tied, and a tie yields no edge.
write('contens/core/basecore.cfc', `component {\n function load() { return 1; }\n}\n`);
write('tests/testbox/tests/specs/unit/core/basecore.cfc', `component extends="testbox.system.BaseSpec" {}\n`);
write('contens/model/page.cfc', `component extends="contenscms.core.basecore" {}\n`);
write('contens/model/tagpage.cfc', `<cfcomponent extends="contenscms.core.basecore">\n<cffunction name="run"><cfreturn 1></cffunction>\n</cfcomponent>\n`);
// A test that names the component means the component too.
write('tests/testbox/tests/specs/unit/model/fakecore.cfc', `component extends="contenscms.core.basecore" {}\n`);
const edges = await load();
expect(has(edges, 'page', 'basecore', 'contens/core/basecore.cfc')).toBe(true);
expect(has(edges, 'tagpage', 'basecore', 'contens/core/basecore.cfc')).toBe(true);
expect(has(edges, 'fakecore', 'basecore', 'contens/core/basecore.cfc')).toBe(true);
expect(edges.filter((e) => e.tgtFile.startsWith('tests/'))).toHaveLength(0);
});
it('never resolves production code into a test suite, even on a closer path (#2650)', async () => {
write('contens/core/basecore.cfc', `component {}\n`);
// The mirror repeats `contenscms/core`, so it corroborates more segments.
write('tests/unit/contenscms/core/basecore.cfc', `component {}\n`);
write('contens/model/page.cfc', `component extends="contenscms.core.basecore" {}\n`);
// Only a test suite holds `contenscms.core.queue`: no edge from production code.
write('tests/specs/core/queue/worker.cfc', `component {}\n`);
write('contens/model/job.cfc', `component extends="contenscms.core.queue.worker" {}\n`);
// A test naming the suite's own path still reaches it.
write('tests/specs/core/queue/workerTest.cfc', `component extends="tests.specs.core.queue.worker" {}\n`);
const edges = await load();
expect(has(edges, 'page', 'basecore', 'contens/core/basecore.cfc')).toBe(true);
expect(edges.filter((e) => e.src === 'page')).toHaveLength(1);
expect(edges.filter((e) => e.src === 'job')).toHaveLength(0);
expect(has(edges, 'workerTest', 'worker', 'tests/specs/core/queue/worker.cfc')).toBe(true);
});
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);
});
it('resolves a one-folder path to a component at the project root (a framework\'s own repo, #2652)', async () => {
// Wheels' own source: `wheels` is the mapping onto the root, so
// `wheels.Model` is Model.cfc there, with no folder to corroborate.
write('Model.cfc', `component {}\n`);
write('models/User.cfc', `component extends="wheels.Model" {}\n`);
// A longer path names folders the root component isn't in.
write('Util.cfc', `component {}\n`);
write('models/Helper.cfc', `component extends="coldbox.system.Util" {}\n`);
// An app's own Controller sits in folders the path doesn't name.
write('app/controllers/Controller.cfc', `component {}\n`);
write('app/controllers/Main.cfc', `component extends="wheels.Controller" {}\n`);
const edges = await load();
expect(has(edges, 'User', 'Model', 'Model.cfc')).toBe(true);
expect(edges.filter((e) => e.src === 'Helper' || e.src === 'Main')).toHaveLength(0);
});
});