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>
This commit is contained in:
Colby Mchenry
2026-10-23 11:49:44 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d90f6abec7
commit f406fe0064
6 changed files with 304 additions and 4 deletions
+1
View File
@@ -43,6 +43,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading.
- In C#, a field or property initializer like `private readonly ILogger _log = LogManager.GetLogger(typeof(X));` or `public List<Foo> Items { get; } = new();` now links what it calls, creates and reads, including inside a lambda, and a target-typed `new()` there counts as creating the declared type. Before, initializers were skipped, so a method or class used only from one — like a converter created in a static list — looked unused, and a method passed as a value there was credited to the whole class instead of the field or property.
- In C# and VB.NET, a constant no longer stands in for a type with the same name: `new Station { … }` links to class `Station` even when another class declares `private const string Station`, `new Version(…)` no longer links to a `const string Version`, and a field like `static readonly Meter Meter` no longer points at itself. Thanks @EvanYu1980 for the report and @drakeo338. (#2337)
- In CFML, `createObject("component", "contenscms.core.queue.batchcontext")`, and the one-argument `createObject("contenscms.core.queue.batchcontext")`, now link to the component they create, found by its path the way `extends` finds one, as `new` already did. A call chained onto the new object, like `createObject(…).init()` or `new contenscms.core.queue.batchcontext().init()`, now links to that component's `init`, or to the one it inherits. In a framework's own source, a path like `wheels.JobWorker`, whose first part is the framework itself, now also finds the component at the project root, for `extends` as well. Before, `createObject` showed up as a call to a function that doesn't exist, and the chained `init` was linked to whichever component's `init` came first, or to none. Thanks @HarryMuc for the report. (#2652)
- In VB.NET, a call on a variable, parameter, field or property now reaches a method of the type it is declared with — one that type inherits, or an extension method written for it — and nothing when that type comes from outside your project, instead of any project method that merely shares the name.
- In VB.NET, a call to one of .NET's own methods such as `Add`, `Contains`, `Clear` or `Dispose` on a value whose type isn't known is no longer linked to a project method of that name, unless the value is named after that method's class.
- In VB.NET, a type name is looked up the way VB.NET does it — through the namespaces around it, the file's and project's `Imports` (aliases included), and the caller's own project — so a class declared in several namespaces or projects no longer draws every call to whichever copy was indexed first, and two candidates nothing tells apart get no link at all.
+131
View File
@@ -0,0 +1,131 @@
/**
* 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([]);
});
});
@@ -163,4 +163,20 @@ describe('CFML component-path inheritance resolution (#1152)', () => {
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);
});
});
+53 -2
View File
@@ -19,8 +19,9 @@ function cfmlVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' |
/**
* A string literal's text when it is written out in full, or null when a
* `#…#` expression builds it at runtime (`"#dir#/x.cfm"`): such a path names
* no file the index can link.
* `#…#` expression builds it at runtime (`"#dir#/x.cfm"`, `"#dir#.Batch"`):
* such a path names no file, and such a name no component, the index can
* link.
*/
function literalString(node: SyntaxNode | null | undefined, source: string): string | null {
if (node?.type !== 'string' || node.namedChildren.some((c: SyntaxNode) => c.type !== 'string_fragment')) return null;
@@ -55,6 +56,56 @@ function cfIncludeCallTemplate(node: SyntaxNode, source: string): string | null
return literalString(args.namedChildren[0], source);
}
/** A component name or dotted component path (`Batch`, `contenscms.core.queue.Batch`). */
const COMPONENT_PATH = /^[A-Za-z_][\w$-]*(?:\.[A-Za-z_][\w$-]*)*$/;
/**
* The component `createObject("component", "a.b.Batch")` makes — or the
* one-argument `createObject("a.b.Batch")` (Lucee, ACF), or with named
* arguments, `createObject(type="component", component="a.b.Batch")` — as
* written (#2652). Null for any other call, for another type of object
* (`createObject("java", …)`), and for a name built at runtime.
*/
export function cfmlCreatedComponent(call: SyntaxNode, source: string): string | null {
if (call.type !== 'call_expression') return null;
const fn = getChildByField(call, 'function');
if (fn?.type !== 'identifier' || getNodeText(fn, source).toLowerCase() !== 'createobject') return null;
const args = getChildByField(call, 'arguments')?.namedChildren ?? [];
let type: string | null | undefined;
let name: string | null | undefined;
if (args.length > 0 && args.every((a: SyntaxNode) => a.type === 'assignment_expression')) {
for (const arg of args) {
const key = getNodeText(getChildByField(arg, 'left') ?? arg, source).toLowerCase();
if (key === 'type') type = literalString(getChildByField(arg, 'right'), source);
else if (key === 'component') name = literalString(getChildByField(arg, 'right'), source);
}
} else if (args.length === 1) {
name = literalString(args[0], source);
} else if (args.length >= 2) {
type = literalString(args[0], source);
name = literalString(args[1], source);
}
if (type !== undefined && type?.toLowerCase() !== 'component') return null;
return name && COMPONENT_PATH.test(name) ? name : null;
}
/**
* The component an expression constructs, when the expression is a
* construction a call is chained onto: `new a.b.Batch()` or a component
* `createObject(…)`, parenthesized or not (#2652).
*/
export function cfmlConstructedComponent(node: SyntaxNode, source: string): string | null {
let expr: SyntaxNode | null = node;
while (expr?.type === 'parenthesized_expression') expr = expr.namedChild(0);
if (!expr) return null;
if (expr.type === 'new_expression') {
const ctor = getChildByField(expr, 'constructor');
const name = ctor ? getNodeText(ctor, source).replace(/\s+/g, '') : '';
return COMPONENT_PATH.test(name) ? name : null;
}
return cfmlCreatedComponent(expr, source);
}
export const cfscriptExtractor: LanguageExtractor = {
functionTypes: ['function_declaration', 'function_expression', 'arrow_function'],
classTypes: ['component'],
+28 -1
View File
@@ -27,7 +27,7 @@ import { appendCppMacroMethods } from './languages/cpp-macro-methods';
import { rustImplItemOwner, rustImplTypeName } from './languages/rust';
import { goAliasTypeNames, goEmbeddedTypeName, goInterfaceMethodTypeNames, goStructFieldTypeNames } from './languages/go';
import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeAliasRefs, pushDartTypeRefs } from './languages/dart';
import { isCfIncludeCall } from './languages/cfscript';
import { cfmlConstructedComponent, cfmlCreatedComponent, isCfIncludeCall } from './languages/cfscript';
import { LiquidExtractor } from './liquid-extractor';
import { RazorExtractor } from './razor-extractor';
import { SvelteExtractor } from './svelte-extractor';
@@ -5174,6 +5174,24 @@ export class TreeSitterExtractor {
return;
}
// CFML `createObject("component", "a.b.Batch")` makes an instance of the
// component, as `new a.b.Batch()` does (#2652): an instantiation of it,
// under the path as written, which the resolver reads as `extends`'
// component paths. Not a call: createObject is the engine's own function.
if (this.language === 'cfscript') {
const created = cfmlCreatedComponent(node, this.source);
if (created) {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: created,
referenceKind: 'instantiates',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
return;
}
}
// VB.NET: `foo(args)` is syntactically ambiguous between a call and an
// index read, so the grammar parses non-empty parens as
// array_access_expression (field `array`, not `function`) — even Roslyn
@@ -5956,6 +5974,7 @@ export class TreeSitterExtractor {
return;
}
const SKIP_RECEIVERS = new Set(['self', 'this', 'cls', 'super']);
let constructed: string | null = null;
if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) {
const receiverName = getNodeText(receiver, this.source);
if (!SKIP_RECEIVERS.has(receiverName)) {
@@ -6079,6 +6098,14 @@ export class TreeSitterExtractor {
// (`!Settings.Defaults.isOn()`). Mirrored in the kernel's
// extract_call (swift.rs).
calleeName = `${swiftUnwrappedText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`;
} else if (this.language === 'cfscript' && receiver && (constructed = cfmlConstructedComponent(receiver, this.source))) {
// CFML call on the component the receiver constructs —
// `createObject("component", "a.b.Batch").init()`, `new
// a.b.Batch().init()` (#2652). Encode `<component>().<method>`:
// the resolver finds the component as `new`/`extends` do and the
// method on it, or nothing. The bare `init` this used to emit
// matched whichever component's `init` came first.
calleeName = `${constructed}().${methodName}`;
} else if (
this.language === 'cfscript' &&
receiver &&
+75 -1
View File
@@ -97,6 +97,9 @@ const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;
/** PHP `$this->prop->method()` encoded as `this->prop.method` — no `()`, so CHAIN_SHAPE misses it. */
const PHP_PROP_SHAPE = /^this->\w+\.\w+$/;
/** A CFML call on a construction, `a.b.Batch().init` (#2652); CFML names may hold `$` (`$init`). */
const CFML_CONSTRUCTED_CALL = /^([A-Za-z_$][\w$.-]*)\(\)\.([A-Za-z_$][\w$]*)$/;
/**
* How many files that import a changed module by default a sync reads the
* edges of, when the module forwards its default from another file, to see
@@ -1567,6 +1570,15 @@ export class ReferenceResolver {
) {
return this.resolveCfmlComponentPath(ref);
}
// The component a CFML `createObject("component", "a.b.Batch")` makes,
// named by its component path as `extends` names one (#2652), and a call
// chained onto a construction — `createObject(…).init()`, `new
// a.b.Batch().init()`, encoded `a.b.Batch().init` — which is a method of
// that component or of nothing: no name match may guess past it.
if (ref.language === 'cfml' || ref.language === 'cfscript') {
if (ref.referenceKind === 'instantiates' && ref.referenceName.includes('.')) return this.resolveCfmlComponentPath(ref);
if (ref.referenceKind === 'calls' && CFML_CONSTRUCTED_CALL.test(ref.referenceName)) return this.resolveCfmlConstructedCall(ref);
}
// A CFML include names a template by its path from the including file's
// directory, or from the web root (#2651): `<cfinclude template="../shared/
@@ -3519,6 +3531,12 @@ export class ReferenceResolver {
* TestBox spec mirroring the component's path (`tests/specs/unit/core/
* basecore.cfc` beside `core/basecore.cfc`) corroborates the same segments,
* and a mapping root like `contenscms` (onto `contens/`) matches neither.
*
* The one exception to corroboration is a path with a single folder segment
* and a component at the project root (#2652): `wheels.JobWorker` in Wheels'
* own source, where `wheels` is the mapping onto the root, has no folder of
* the file's to corroborate. It ranks below any candidate a folder
* corroborates, and the file must be named like the component.
*/
private resolveCfmlComponentPath(ref: UnresolvedRef): ResolvedRef | null {
const cfmlCandidates = (name: string): Node[] =>
@@ -3582,6 +3600,12 @@ export class ReferenceResolver {
) {
score++;
}
if (
score === 0 && dirs.length === 0 && dirSegments.length === 1 &&
cand.filePath.replace(/\.cfc$/i, '').toLowerCase() === className.toLowerCase()
) {
score = 0.5; // a root component, named by the mapping onto the root
}
if (score > bestScore) {
bestScore = score;
tied = [cand];
@@ -3593,7 +3617,57 @@ export class ReferenceResolver {
// path names the component, as it does from production code.
if (tied.length > 1) tied = tied.filter((c) => !isTestSuitePath(c.filePath));
if (bestScore === 0 || tied.length !== 1) return null;
return { original: ref, targetNodeId: tied[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' };
return { original: ref, targetNodeId: tied[0]!.id, confidence: bestScore >= 1 ? 0.9 : 0.8, resolvedBy: 'qualified-name' };
}
/**
* A CFML call chained onto a construction (#2652): `createObject("component",
* "a.b.Batch").init()` or `new a.b.Batch().init()`, encoded
* `a.b.Batch().init`. The component is the one its path names, by
* resolveCfmlComponentPath's rules, or for a bare name the one beside the
* calling file (where CFML looks first), else the only one of that name. The
* method is the component's own or one it inherits — inheritance is wired
* before calls are resolved. Null when either is missing: the bare `init`
* such a call used to emit matched whichever component's `init` came first.
*/
private resolveCfmlConstructedCall(ref: UnresolvedRef): ResolvedRef | null {
const match = CFML_CONSTRUCTED_CALL.exec(ref.referenceName);
if (!match) return null;
const component = match[1]!;
const cls = component.includes('.')
? this.nodeById(this.resolveCfmlComponentPath({ ...ref, referenceName: component, referenceKind: 'instantiates' })?.targetNodeId ?? '')
: this.cfmlComponentByName(component, ref.filePath);
const method = cls ? this.cfmlMethodOf(cls, match[2]!.toLowerCase(), new Set()) : undefined;
return method ? { original: ref, targetNodeId: method.id, confidence: 0.9, resolvedBy: 'instance-method' } : null;
}
/** The CFML component a bare name reaches from `fromFile`: the one in its directory, else the only one so named. */
private cfmlComponentByName(name: string, fromFile: string): Node | undefined {
const components = this.context
.getNodesByLowerName(name.toLowerCase())
.filter((n) => (n.kind === 'class' || n.kind === 'interface') && (n.language === 'cfml' || n.language === 'cfscript'));
const dirOf = (p: string): string => p.slice(0, p.lastIndexOf('/') + 1);
const beside = components.filter((n) => dirOf(n.filePath) === dirOf(fromFile));
if (beside.length === 1) return beside[0];
return components.length === 1 ? components[0] : undefined;
}
/** A CFML component's method by its lowercased name (CFML names ignore case): its own, else one a supertype has. */
private cfmlMethodOf(cls: Node, lowerName: string, seen: Set<string>): Node | undefined {
if (seen.has(cls.id) || seen.size >= 8) return undefined;
seen.add(cls.id);
const own = this.context.getNodesInFile(cls.filePath).find((n) =>
n.kind === 'method' &&
n.name.toLowerCase() === lowerName &&
n.qualifiedName.toLowerCase() === `${cls.name}::${n.name}`.toLowerCase() &&
n.startLine >= cls.startLine && n.endLine <= cls.endLine
);
if (own) return own;
for (const supertype of this.context.getSupertypeNodes?.(cls.id) ?? []) {
const inherited = this.cfmlMethodOf(supertype, lowerName, seen);
if (inherited) return inherited;
}
return undefined;
}
/**