Преглед изворни кода

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>
Colby Mchenry пре 23 часа
родитељ
комит
7d624ecfac

+ 1 - 0
CHANGELOG.md

@@ -13,6 +13,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - 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)
+- CFML method calls made through a local variable, typed argument, or component property now resolve to the right method — the same receiver-type inference the other object-oriented languages already had. `var svc = new UserService(); svc.save()`, `createObject("component", "path.UserService")`, a typed `<cfargument>` or cfscript parameter, and `variables.`/`this.`-scoped fields — including the pseudoconstructor pattern (`variables.svc = new UserService()` in `init()`) and WireBox-injected properties (`property name="svc" inject="UserService"`) — all now link the call to the declared component's method, with methods inherited from a supertype resolved through the inheritance links above. This makes callers, impact/blast-radius, and `codegraph_explore` flow traces follow CFML service calls instead of dropping them or guessing among same-named methods.
 - 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)

+ 185 - 0
__tests__/cfml-receiver-inference.test.ts

@@ -0,0 +1,185 @@
+/**
+ * CFML local-variable / component-field receiver-type inference (#1108 family).
+ *
+ * `var svc = new UserService(); svc.save()` — the call's receiver type is
+ * recoverable from its declaration, and resolveMethodOnType validates the
+ * inferred type actually declares the method, so a mis-inference produces no
+ * edge. CFML brings four declaration idioms the shared inferrer must know:
+ * `new` (dotted component paths included), `createObject("component", "...")`,
+ * typed arguments (cfscript params and `<cfargument>` tags), and component
+ * properties — including WireBox DI (`property name="svc" inject="..."`),
+ * whose receivers are `variables.`-scoped fields declared OUTSIDE the calling
+ * function (so the scan must widen to the whole file, in both directions).
+ *
+ * These tests also pin the extraction prerequisite: CFML method
+ * qualifiedNames carry the component scope (`UserService::save`) in all three
+ * extraction paths (bare-script, `<cffunction>`, component-level `<cfscript>`
+ * blocks) — without that, type-validated resolution can never match.
+ */
+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 receiver-type inference', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-recv-')); });
+  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 calls: { src: string; tgt: string; tgtQn: string }[] = db
+      .prepare(
+        `SELECT s.name src, t.name tgt, t.qualified_name tgtQn
+         FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+         WHERE e.kind = 'calls' AND t.kind = 'method'`
+      )
+      .all();
+    const methods: { name: string; qn: string }[] = db
+      .prepare(`SELECT name, qualified_name qn FROM nodes WHERE kind = 'method'`)
+      .all();
+    cg.close?.();
+    return { calls, methods };
+  };
+  const hasCall = (calls: any[], src: string, tgtQn: string) =>
+    calls.some((e) => e.src === src && e.tgtQn === tgtQn);
+
+  // Two same-named methods so resolution MUST disambiguate by receiver type —
+  // plain name-matching alone can't pick one.
+  const userService = `component {\n  function save(any u) { return u; }\n}\n`;
+  const orderService = `component {\n  function save(any o) { return o; }\n}\n`;
+
+  it('scopes method qualifiedNames under the component in all three extraction paths', async () => {
+    write('svc/UserService.cfc', userService);
+    write('tag/TagService.cfc', `<cfcomponent>\n<cffunction name="save"><cfreturn 1></cffunction>\n</cfcomponent>\n`);
+    write('mod/ModuleConfig.cfc', `<cfcomponent>\n<cfscript>\nfunction configure() { return 1; }\n</cfscript>\n</cfcomponent>\n`);
+    const { methods } = await load();
+    expect(methods.find((m) => m.name === 'save' && m.qn === 'UserService::save')).toBeDefined();
+    expect(methods.find((m) => m.name === 'save' && m.qn === 'TagService::save')).toBeDefined();
+    expect(methods.find((m) => m.name === 'configure' && m.qn === 'ModuleConfig::configure')).toBeDefined();
+  });
+
+  it('infers a local declared with new, including a dotted component path', async () => {
+    write('svc/UserService.cfc', userService);
+    write('svc/OrderService.cfc', orderService);
+    write('handlers/Main.cfc', `component {
+  function bare() {
+    var svc = new UserService();
+    return svc.save(1);
+  }
+  function dotted() {
+    var svc2 = new svc.UserService();
+    return svc2.save(2);
+  }
+}
+`);
+    const { calls } = await load();
+    expect(hasCall(calls, 'bare', 'UserService::save')).toBe(true);
+    expect(hasCall(calls, 'dotted', 'UserService::save')).toBe(true);
+    expect(hasCall(calls, 'bare', 'OrderService::save')).toBe(false);
+  });
+
+  it('infers a local declared with createObject (two-arg and single-arg forms)', async () => {
+    write('svc/UserService.cfc', userService);
+    write('svc/OrderService.cfc', orderService);
+    write('handlers/Legacy.cfc', `component {
+  function classic() {
+    var svc = createObject("component", "svc.UserService");
+    return svc.save(1);
+  }
+  function modern() {
+    var svc2 = CreateObject("svc.OrderService");
+    return svc2.save(2);
+  }
+}
+`);
+    const { calls } = await load();
+    expect(hasCall(calls, 'classic', 'UserService::save')).toBe(true);
+    expect(hasCall(calls, 'modern', 'OrderService::save')).toBe(true);
+  });
+
+  it('infers a typed cfscript parameter', async () => {
+    write('svc/UserService.cfc', userService);
+    write('svc/OrderService.cfc', orderService);
+    write('handlers/Typed.cfc', `component {
+  function process(required UserService svc) {
+    return svc.save(1);
+  }
+}
+`);
+    const { calls } = await load();
+    expect(hasCall(calls, 'process', 'UserService::save')).toBe(true);
+    expect(hasCall(calls, 'process', 'OrderService::save')).toBe(false);
+  });
+
+  it('infers a <cfargument> typed argument used inside a <cfscript> body', async () => {
+    write('svc/UserService.cfc', userService);
+    write('svc/OrderService.cfc', orderService);
+    write('handlers/TagTyped.cfc', `<cfcomponent>
+<cffunction name="process">
+  <cfargument name="svc" type="svc.UserService">
+  <cfscript>
+    return svc.save(1);
+  </cfscript>
+</cffunction>
+</cfcomponent>
+`);
+    const { calls } = await load();
+    expect(hasCall(calls, 'process', 'UserService::save')).toBe(true);
+  });
+
+  it('infers a variables-scoped field from its pseudoconstructor assignment, even when init sits below the call', async () => {
+    write('svc/UserService.cfc', userService);
+    write('svc/OrderService.cfc', orderService);
+    write('handlers/Fielded.cfc', `component {
+  function handle() {
+    return variables.svc.save(1);
+  }
+  function init() {
+    variables.svc = new UserService();
+    return this;
+  }
+}
+`);
+    const { calls } = await load();
+    expect(hasCall(calls, 'handle', 'UserService::save')).toBe(true);
+    expect(hasCall(calls, 'handle', 'OrderService::save')).toBe(false);
+  });
+
+  it('infers a WireBox-injected property (the ColdBox DI shape)', async () => {
+    write('svc/UserService.cfc', userService);
+    write('svc/OrderService.cfc', orderService);
+    write('handlers/Injected.cfc', `component {
+  property name="svc" inject="UserService";
+
+  function handle() {
+    return variables.svc.save(1);
+  }
+}
+`);
+    const { calls } = await load();
+    expect(hasCall(calls, 'handle', 'UserService::save')).toBe(true);
+  });
+
+  it('creates no method edge when the inferred type does not declare the method', async () => {
+    write('svc/UserService.cfc', userService);
+    write('handlers/Wrong.cfc', `component {
+  function go() {
+    var svc = new UserService();
+    return svc.destroyEverything();
+  }
+}
+`);
+    const { calls } = await load();
+    expect(calls.filter((e) => e.src === 'go')).toHaveLength(0);
+  });
+});

+ 34 - 19
src/extraction/cfml-extractor.ts

@@ -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) {

+ 15 - 0
src/extraction/tree-sitter.ts

@@ -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;
             }

+ 73 - 6
src/resolution/name-matcher.ts

@@ -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;
 }