Files
codegraph/__tests__/cfml-create-object.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

132 lines
5.8 KiB
TypeScript

/**
* CFML `createObject("component", "a.b.Batch")` (#2652). `new a.b.Batch()`
* made an `instantiates` edge; createObject, the form older code uses most,
* made none — it was a call to an engine function that never resolves. And a
* call chained onto either construction, `createObject(…).init()` or `new
* a.b.Batch().init()`, was emitted as a bare `init`, which matched whichever
* component's `init` came first. A component path resolves as `extends`
* resolves one: by its folders, from the right.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CodeGraph } from '../src';
let dir = '';
let edges: string[] = [];
let unresolved: string[] = [];
const files: Record<string, string> = {
// Other components with an `init`, which a guess by name would reach first.
'contens/aaa/alpha.cfc': `component {\n\tfunction init() { return this; }\n\tfunction $init() { return this; }\n}\n`,
'contens/core/mixin.cfc': `component {\n\tfunction $init() { return this; }\n}\n`,
// A framework's own source, where its mapping (`wheels`) is the project root.
'JobWorker.cfc': `component {\n\tfunction processNext() {}\n}\n`,
'tests/specs/JobSpec.cfc': `component {
\tfunction run() {
\t\tnew wheels.JobWorker().processNext();
\t\tvar w = createObject("component", "wheels.JobWorker");
\t}
}
`,
// An app's own Controller is not the framework's `wheels.Controller`.
'app/controllers/Controller.cfc': `component {\n\tfunction init() { return this; }\n}\n`,
'app/handlers/Main.cfc': `component {\n\tfunction index() {\n\t\tcreateObject("component", "wheels.Controller").init();\n\t}\n}\n`,
'contens/aaa/beta.cfc': `component {\n\tfunction init() { return this; }\n}\n`,
'contens/core/base.cfc': `component {\n\tfunction init() { return this; }\n}\n`,
'contens/core/queue/batchcontext.cfc': `<cfcomponent>
\t<cffunction name="init"><cfreturn this></cffunction>
\t<cffunction name="run"></cffunction>
</cfcomponent>
`,
// Inherits its init.
'contens/core/queue/jobrunner.cfc': `component extends="contenscms.core.base" {\n\tfunction start() {}\n}\n`,
'contens/api/batch/collection.cfc': `<cfcomponent>
\t<cffunction name="process">
\t\t<cfset var oContext = createObject("component", "contenscms.core.queue.batchcontext").init()>
\t\t<cfset oContext.run()>
\t\t<cfset var oRunner = CreateObject('Component', 'contenscms.core.queue.jobrunner')>
\t</cffunction>
</cfcomponent>
`,
'contens/api/batch/scriptcollection.cfc': `component {
\tfunction process() {
\t\tvar oRunner = createObject("contenscms.core.queue.jobrunner").init();
\t\tvar oNew = new contenscms.core.queue.batchcontext().init();
\t\tvar oMap = createObject("java", "java.util.HashMap");
\t\tvar oBox = createObject("component", "testbox.system.TestBox").init();
\t\tvar oDyn = createObject("component", "#variables.root#.batchcontext");
\t\tvar oMix = new contenscms.core.mixin().$init();
\t}
}
`,
};
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-create-object-'));
for (const [rel, body] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
}
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
edges = db
.prepare(
`SELECT e.kind, e.line, s.qualified_name src, t.qualified_name tgt FROM edges e
JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind IN ('instantiates', 'calls')`
)
.all()
.map((r: any) => `${r.src} -${r.kind}-> ${r.tgt} @${r.line}`)
.sort();
unresolved = db
.prepare(`SELECT reference_kind k, reference_name n, line FROM unresolved_refs`)
.all()
.map((r: any) => `${r.k} ${r.n} @${r.line}`);
cg.close?.();
});
afterAll(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
describe('CFML createObject (#2652)', () => {
it('links createObject("component", "a.b.c") to the component it makes, in tags and in script', () => {
expect(edges).toEqual(expect.arrayContaining([
'collection::process -instantiates-> contens/core/queue/batchcontext.cfc::batchcontext @3',
'collection::process -instantiates-> contens/core/queue/jobrunner.cfc::jobrunner @5',
'scriptcollection::process -instantiates-> contens/core/queue/jobrunner.cfc::jobrunner @3',
]));
// Only the Java object and the component named at runtime are left as calls.
expect(unresolved.filter((r) => r.startsWith('calls createObject')).sort()).toEqual(['calls createObject @5', 'calls createObject @7']);
});
it('links a call chained onto the construction to that component\'s method, or one it inherits', () => {
expect(edges).toEqual(expect.arrayContaining([
'collection::process -calls-> batchcontext::init @3',
'scriptcollection::process -calls-> base::init @3',
'scriptcollection::process -calls-> batchcontext::init @4',
'scriptcollection::process -calls-> mixin::$init @8',
]));
expect(edges.filter((e) => /-> (alpha|beta)::\$?init/.test(e))).toEqual([]);
});
it('makes nothing of a component outside the project, another type of object or a name built at runtime', () => {
expect(edges.filter((e) => / @[567]$/.test(e) && e.startsWith('scriptcollection::'))).toEqual([]);
});
it('still types a variable assigned from createObject', () => {
expect(edges).toContain('collection::process -calls-> batchcontext::run @4');
});
it('finds a component at the project root by a one-folder path, and only there', () => {
expect(edges).toEqual(expect.arrayContaining([
'JobSpec::run -calls-> JobWorker::processNext @3',
'JobSpec::run -instantiates-> JobWorker.cfc::JobWorker @4',
]));
expect(edges.filter((e) => e.startsWith('Main::index'))).toEqual([]);
});
});