feat(resolution): CFML receiver-type inference for locals, typed args, and component properties (#1155)
CFML joins the #1108 receiver-inference family: new/createObject/typed-arg/property(inject) declarations type the receiver, variables./this. fields scan whole-file, method QNs re-scoped to Class::member in all three extraction paths. 1,649 typed edges on fw1/ColdBox/CFWheels, 1,649/1,649 audit-consistent, inherited methods resolve via #1152 extends edges. Co-authored-by: ghedwards <125586+ghedwards@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
ghedwards
Claude Fable 5
parent
5f22da35f3
commit
7d624ecfac
@@ -73,6 +73,12 @@ export class CfmlExtractor {
|
||||
if (node.name === '<anonymous>' && (node.kind === 'class' || node.kind === 'interface')) {
|
||||
node.name = componentName;
|
||||
node.qualifiedName = `${this.filePath}::${componentName}`;
|
||||
} else if (node.qualifiedName === '<anonymous>' || node.qualifiedName.startsWith('<anonymous>::')) {
|
||||
// Members were scoped under the anonymous component (`<anonymous>::save`)
|
||||
// — carry the rename into their scope chains so type-validated method
|
||||
// resolution (which wants `UserService::save`, see resolveMethodOnType)
|
||||
// can match them. Inner genuinely-anonymous segments are untouched.
|
||||
node.qualifiedName = componentName + node.qualifiedName.slice('<anonymous>'.length);
|
||||
}
|
||||
this.nodes.push(node);
|
||||
}
|
||||
@@ -225,13 +231,13 @@ export class CfmlExtractor {
|
||||
break;
|
||||
}
|
||||
if (sibling.type === 'cf_function_tag') {
|
||||
this.extractFunctionTag(sibling, classNode.id, classNode.id);
|
||||
this.extractFunctionTag(sibling, classNode.id, classNode.id, classNode.name);
|
||||
} else if (sibling.type === 'cf_script_tag') {
|
||||
this.delegateScriptTag(sibling, classNode.id, true);
|
||||
this.delegateScriptTag(sibling, classNode.id, classNode.name);
|
||||
} else if (sibling.type === 'cf_query_tag') {
|
||||
this.delegateQueryTag(sibling, classNode.id);
|
||||
} else {
|
||||
this.delegateNestedTags(sibling, classNode.id, true);
|
||||
this.delegateNestedTags(sibling, classNode.id, classNode.name);
|
||||
}
|
||||
lastNode = sibling;
|
||||
sibling = sibling.nextSibling;
|
||||
@@ -246,9 +252,11 @@ export class CfmlExtractor {
|
||||
* the `contains`-edge target (the class when inside one, otherwise the file
|
||||
* node for a bare top-level cffunction) — kept separate so a top-level
|
||||
* function still gets a containment edge without being misclassified as a
|
||||
* method of the file.
|
||||
* method of the file. A method's qualifiedName is scoped under
|
||||
* `parentClassName` (`TagService::save`, the same `Class::member` shape the
|
||||
* generic extractor produces) so type-validated method resolution can match.
|
||||
*/
|
||||
private extractFunctionTag(tag: SyntaxNode, parentClassId: string | undefined, containerId: string | undefined): void {
|
||||
private extractFunctionTag(tag: SyntaxNode, parentClassId: string | undefined, containerId: string | undefined, parentClassName?: string): void {
|
||||
const name = this.tagAttr(tag, 'name');
|
||||
if (!name) return;
|
||||
|
||||
@@ -264,7 +272,7 @@ export class CfmlExtractor {
|
||||
id,
|
||||
kind,
|
||||
name,
|
||||
qualifiedName: `${this.filePath}::${name}`,
|
||||
qualifiedName: parentClassName ? `${parentClassName}::${name}` : `${this.filePath}::${name}`,
|
||||
filePath: this.filePath,
|
||||
language: this.language,
|
||||
startLine: tag.startPosition.row + 1,
|
||||
@@ -293,34 +301,38 @@ export class CfmlExtractor {
|
||||
* `<cfcomponent>`'s body — see the implicit-end-tag note on `extractComponent`)
|
||||
* ARE normal children, just possibly several levels deep, so a direct-children
|
||||
* check misses them. Does not descend into a nested `cf_function_tag` — that
|
||||
* has its own scope and is walked separately. `parentIsClass` rides along so
|
||||
* a `<cfscript>` at component scope classifies its functions as methods.
|
||||
* has its own scope and is walked separately. `parentClassName` rides along
|
||||
* so a `<cfscript>` at component scope classifies its functions as methods
|
||||
* scoped under the component.
|
||||
*/
|
||||
private delegateNestedTags(node: SyntaxNode, containerId: string | undefined, parentIsClass = false): void {
|
||||
private delegateNestedTags(node: SyntaxNode, containerId: string | undefined, parentClassName?: string): void {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'cf_script_tag') {
|
||||
this.delegateScriptTag(child, containerId, parentIsClass);
|
||||
this.delegateScriptTag(child, containerId, parentClassName);
|
||||
} else if (child.type === 'cf_query_tag') {
|
||||
this.delegateQueryTag(child, containerId);
|
||||
} else if (child.type === 'cf_function_tag') {
|
||||
continue;
|
||||
} else {
|
||||
this.delegateNestedTags(child, containerId, parentIsClass);
|
||||
this.delegateNestedTags(child, containerId, parentClassName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate a `<cfscript>...</cfscript>` tag body to the cfscript grammar.
|
||||
* With `parentIsClass`, functions declared at the script's top level are the
|
||||
* component's methods (`<cfcomponent><cfscript>function configure(){}` — the
|
||||
* standard ColdBox ModuleConfig shape), so they're re-kinded `function` →
|
||||
* `method` to match how the same function classifies in a script-style CFC.
|
||||
* Functions nested inside another function (closures) keep kind `function`.
|
||||
* With `parentClassName` set (the block sits at component scope), functions
|
||||
* declared at the script's top level are the component's methods
|
||||
* (`<cfcomponent><cfscript>function configure(){}` — the standard ColdBox
|
||||
* ModuleConfig shape): they're re-kinded `function` → `method`, and every
|
||||
* merged symbol's qualifiedName is prefixed with the component scope
|
||||
* (`configure` → `ModuleConfig::configure`) so type-validated method
|
||||
* resolution can match them. Functions nested inside another function
|
||||
* (closures) keep kind `function`.
|
||||
*/
|
||||
private delegateScriptTag(scriptTag: SyntaxNode, parentId: string | undefined, parentIsClass = false): void {
|
||||
private delegateScriptTag(scriptTag: SyntaxNode, parentId: string | undefined, parentClassName?: string): void {
|
||||
const content = scriptTag.namedChildren.find((c: SyntaxNode) => c.type === 'cf_script_content');
|
||||
if (!content) return;
|
||||
|
||||
@@ -349,8 +361,11 @@ export class CfmlExtractor {
|
||||
node.startLine += startLine;
|
||||
node.endLine += startLine;
|
||||
node.language = this.language;
|
||||
if (parentIsClass && node.kind === 'function' && topLevelIds.has(node.id)) {
|
||||
node.kind = 'method';
|
||||
if (parentClassName) {
|
||||
if (node.kind === 'function' && topLevelIds.has(node.id)) {
|
||||
node.kind = 'method';
|
||||
}
|
||||
node.qualifiedName = `${parentClassName}::${node.qualifiedName}`;
|
||||
}
|
||||
this.nodes.push(node);
|
||||
if (parentId) {
|
||||
|
||||
@@ -3830,6 +3830,21 @@ export class TreeSitterExtractor {
|
||||
else reencode = !!innerCallee;
|
||||
}
|
||||
calleeName = reencode ? `${innerCallee}().${methodName}` : methodName;
|
||||
} else if (
|
||||
this.language === 'cfscript' &&
|
||||
receiver &&
|
||||
receiver.type === 'member_expression' &&
|
||||
/^(variables|this|local|arguments)\.[A-Za-z_][\w]*$/i.test(getNodeText(receiver, this.source))
|
||||
) {
|
||||
// CFML scope-prefixed member call — `variables.svc.save()` /
|
||||
// `arguments.svc.save()`: the receiver is a component field,
|
||||
// injected property, or typed argument reached through one of
|
||||
// CFML's file-local scopes. Keep the full receiver chain so
|
||||
// resolution can strip the scope prefix and infer the field's
|
||||
// component type from its declaration (#1108). Gated to these
|
||||
// scope keywords: such calls previously emitted a bare method
|
||||
// name, which either failed to resolve or resolved ambiguously.
|
||||
calleeName = `${getNodeText(receiver, this.source)}.${methodName}`;
|
||||
} else {
|
||||
calleeName = methodName;
|
||||
}
|
||||
|
||||
@@ -1185,6 +1185,36 @@ function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
|
||||
new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w]*)`), // var lg: TLogger / param lg: TLogger
|
||||
new RegExp(`\\b${r}\\b\\s*:=\\s*([A-Z][\\w.]*)\\.Create\\b`), // lg := TLogger.Create
|
||||
];
|
||||
case 'cfml':
|
||||
case 'cfscript':
|
||||
return [
|
||||
// svc = new UserService() / new path.to.UserService() — dotted component
|
||||
// paths reduce to their final segment via normalizeInferredTypeName.
|
||||
// Also matches inside tag markup (`<cfset svc = new UserService()>`)
|
||||
// since the scan reads raw source lines.
|
||||
new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`),
|
||||
// The classic form: svc = createObject("component", "path.to.UserService")
|
||||
// (casing of createObject varies in the wild), plus the modern
|
||||
// single-argument form createObject("path.to.UserService").
|
||||
new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']component["']\\s*,\\s*["']([\\w.]+)["']`),
|
||||
new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']([\\w.]+)["']\\s*\\)`),
|
||||
// Typed cfscript parameter: `function save(UserService svc)` /
|
||||
// `required UserService svc` — CFML's built-in types (string, numeric,
|
||||
// any, struct…) are lowercase by convention, so the PascalCase guard
|
||||
// excludes them.
|
||||
new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`),
|
||||
// Tag-form typed argument, either attribute order:
|
||||
// <cfargument name="svc" type="path.to.UserService">
|
||||
new RegExp(`\\bcfargument[^>\\n]*\\bname\\s*=\\s*["']${r}["'][^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["']`, 'i'),
|
||||
new RegExp(`\\bcfargument[^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["'][^>\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'),
|
||||
// Component property (incl. WireBox DI): `property name="svc"
|
||||
// inject="UserService";` / `<cfproperty name="svc" type="UserService">`,
|
||||
// either attribute order. An inject DSL value with a namespace
|
||||
// (`inject="svc@core"`) captures only the leading name and simply
|
||||
// fails type-validation — no edge, never a wrong one.
|
||||
new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\bname\\s*=\\s*["']${r}["'][^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["']`, 'i'),
|
||||
new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["'][^;\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'),
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -1215,9 +1245,28 @@ function inferLocalReceiverType(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): string | null {
|
||||
// CFML scope prefixes: `variables.svc` / `this.svc` name a COMPONENT-scoped
|
||||
// field whose assignment or `property` declaration usually lives outside the
|
||||
// calling function (the init-pseudoconstructor / WireBox-injection pattern),
|
||||
// and `local.svc` is an explicit function-local. Strip the prefix so the
|
||||
// declaration patterns match (`variables.svc = new X()`, `property
|
||||
// name="svc" …`, `var svc = …` all bind the bare name), and widen the scan
|
||||
// to the whole file for the component-scoped forms — nearest-declaration-
|
||||
// backward still wins, so a function-local shadowing the field is preferred.
|
||||
let scanReceiver = receiverName;
|
||||
let componentScoped = false;
|
||||
if (ref.language === 'cfml' || ref.language === 'cfscript') {
|
||||
const scoped = receiverName.match(/^(variables|this|local|arguments)\.(.+)$/i);
|
||||
if (scoped) {
|
||||
scanReceiver = scoped[2]!;
|
||||
const scope = scoped[1]!.toLowerCase();
|
||||
componentScoped = scope === 'variables' || scope === 'this';
|
||||
}
|
||||
}
|
||||
|
||||
const patterns = localReceiverTypePatterns(
|
||||
ref.language,
|
||||
receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
||||
scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
||||
);
|
||||
if (patterns.length === 0) return null;
|
||||
|
||||
@@ -1230,16 +1279,17 @@ function inferLocalReceiverType(
|
||||
if (!lines || lines.length === 0) return null;
|
||||
|
||||
const callIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
|
||||
const startIdx = Math.max(0, enclosingScopeStartLine(ref, context) - 1);
|
||||
const startIdx = componentScoped
|
||||
? 0
|
||||
: Math.max(0, enclosingScopeStartLine(ref, context) - 1);
|
||||
|
||||
// Nearest declaration wins: scan backward from the call to the scope start.
|
||||
for (let i = callIdx; i >= startIdx; i--) {
|
||||
const matchLine = (i: number): string | null => {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
if (!line) return null;
|
||||
// A generated/minified line (one multi-KB statement) is not something a
|
||||
// human-written local declaration lives on, and regexing it per ref is
|
||||
// pure waste — skip it rather than scan it.
|
||||
if (line.length > 10_000) continue;
|
||||
if (line.length > 10_000) return null;
|
||||
for (const re of patterns) {
|
||||
const m = line.match(re);
|
||||
if (m && m[1]) {
|
||||
@@ -1247,6 +1297,23 @@ function inferLocalReceiverType(
|
||||
if (type) return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Nearest declaration wins: scan backward from the call to the scope start.
|
||||
for (let i = callIdx; i >= startIdx; i--) {
|
||||
const type = matchLine(i);
|
||||
if (type) return type;
|
||||
}
|
||||
// A component-scoped field's declaration is position-independent — the
|
||||
// `variables.svc = new X()` pseudoconstructor assignment or `property`
|
||||
// declaration may sit BELOW the calling function in the file — so when the
|
||||
// backward pass finds nothing, sweep the remainder of the file too.
|
||||
if (componentScoped) {
|
||||
for (let i = callIdx + 1; i < lines.length; i++) {
|
||||
const type = matchLine(i);
|
||||
if (type) return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user