Просмотр исходного кода

feat(resolution): resolve CFML dotted and relative component-path inheritance (#1152) (#1154)

extends="coldbox.system.web.Controller" (dotted) and extends="../base" (relative) now resolve to the right component via directory-corroborated matching; >=1 corroborating segment required, ties yield no edge. fw1 14->47, ColdBox 21->242, CFWheels 60->201 inheritance edges; 394/394 audited path-consistent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 1 день назад
Родитель
Сommit
5f22da35f3
3 измененных файлов с 243 добавлено и 0 удалено
  1. 1 0
      CHANGELOG.md
  2. 132 0
      __tests__/cfml-inheritance-resolution.test.ts
  3. 110 0
      src/resolution/index.ts

+ 1 - 0
CHANGELOG.md

@@ -12,6 +12,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 ### New Features
 ### New Features
 
 
 - 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)
 - 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)
 - 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.
 - 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).
 - 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)
 - 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)

+ 132 - 0
__tests__/cfml-inheritance-resolution.test.ts

@@ -0,0 +1,132 @@
+/**
+ * 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('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);
+  });
+});

+ 110 - 0
src/resolution/index.ts

@@ -721,6 +721,23 @@ export class ReferenceResolver {
       return null;
       return null;
     }
     }
 
 
+    // CFML component paths in inheritance (#1152): `extends="coldbox.system.web.
+    // Controller"` names the supertype by its dot-separated path (or `extends=
+    // "../base"` by relative file path) — the graph indexes the class under its
+    // final segment only, so these die at the fast pre-filter below and never
+    // resolved. Handled by a dedicated path-corroborated matcher, gated to
+    // inheritance refs only (a dotted `calls` ref is a member-access chain, not
+    // a component path). No fallthrough on miss: the full path string can only
+    // ever mis-match downstream, and an unresolvable supertype usually lives in
+    // an out-of-repo library (mxunit, testbox) — silent beats wrong.
+    if (
+      (ref.language === 'cfml' || ref.language === 'cfscript') &&
+      (ref.referenceKind === 'extends' || ref.referenceKind === 'implements') &&
+      (ref.referenceName.includes('.') || ref.referenceName.includes('/'))
+    ) {
+      return this.resolveCfmlComponentPath(ref);
+    }
+
     // Fast pre-filter: skip if no symbol with this name exists anywhere
     // Fast pre-filter: skip if no symbol with this name exists anywhere
     // AND the name doesn't match a local import. The import escape is
     // AND the name doesn't match a local import. The import escape is
     // necessary because re-export rename chains (`import { login }
     // necessary because re-export rename chains (`import { login }
@@ -1323,6 +1340,99 @@ export class ReferenceResolver {
     return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
     return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
   }
   }
 
 
+  /**
+   * Resolve a CFML inheritance reference written as a component path (#1152).
+   * Two forms exist in real code:
+   *
+   * - Dotted: `extends="coldbox.system.web.Controller"` — dots are directory
+   *   separators from the webroot or a CFML mapping. Mappings live in server
+   *   config / Application.cfc, so the leading segments may not exist in the
+   *   repo at all (in the coldbox repo itself the path is `system/web/
+   *   Controller.cfc` — the `coldbox.` root IS the repo). Matched by final
+   *   segment (the class), corroborated right-to-left against the candidate's
+   *   parent directories.
+   * - Relative: `extends="../base"` / `extends="./base"` (the FW/1 style) —
+   *   resolved against the referencing file's own directory.
+   *
+   * Conservative by design: a candidate needs at least one corroborating
+   * directory segment (a dotted path whose only same-named class sits in an
+   * unrelated directory is almost always an out-of-repo library supertype —
+   * mxunit/testbox/coldbox-as-dependency), and a corroboration tie yields no
+   * edge. Directory comparison is case-insensitive (CFML path resolution is);
+   * the class segment itself is matched exactly, which real code satisfies —
+   * dotted paths are written to match the on-disk file name.
+   */
+  private resolveCfmlComponentPath(ref: UnresolvedRef): ResolvedRef | null {
+    const cfmlCandidates = (name: string): Node[] =>
+      this.context
+        .getNodesByName(name)
+        .filter(
+          (n) =>
+            (n.kind === 'class' || n.kind === 'interface') &&
+            (n.language === 'cfml' || n.language === 'cfscript')
+        );
+    const norm = (p: string): string => p.replace(/\\/g, '/').toLowerCase();
+
+    // Relative-path form: `../base`, `./base`, `sub/thing` — resolve against
+    // the referencing file's directory and require an exact (case-insensitive)
+    // file match.
+    if (ref.referenceName.includes('/')) {
+      const rel = ref.referenceName.replace(/\.cfc$/i, '');
+      const fromDir = ref.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
+      const parts = [...fromDir];
+      for (const seg of rel.split('/')) {
+        if (seg === '' || seg === '.') continue;
+        if (seg === '..') {
+          if (parts.length === 0) return null; // escapes the project root
+          parts.pop();
+        } else {
+          parts.push(seg);
+        }
+      }
+      const wantPath = norm(parts.join('/') + '.cfc');
+      const className = parts[parts.length - 1];
+      if (!className) return null;
+      const target = cfmlCandidates(className).find((c) => norm(c.filePath) === wantPath);
+      return target
+        ? { original: ref, targetNodeId: target.id, confidence: 0.95, resolvedBy: 'file-path' }
+        : null;
+    }
+
+    // Dotted form.
+    const segments = ref.referenceName.split('.').map((s) => s.trim()).filter(Boolean);
+    if (segments.length < 2) return null;
+    const className = segments[segments.length - 1]!;
+    const dirSegments = segments.slice(0, -1);
+
+    let best: Node | null = null;
+    let bestScore = 0;
+    let tie = false;
+    for (const cand of cfmlCandidates(className)) {
+      const dirs = cand.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
+      // Count matching directory segments right-to-left: for
+      // `coldbox.system.web.Controller` vs `system/web/Controller.cfc`,
+      // `web` and `system` match, then the repo root ends the run → score 2.
+      let score = 0;
+      while (
+        score < dirSegments.length &&
+        score < dirs.length &&
+        dirSegments[dirSegments.length - 1 - score]!.toLowerCase() ===
+          dirs[dirs.length - 1 - score]!.toLowerCase()
+      ) {
+        score++;
+      }
+      if (score > bestScore) {
+        best = cand;
+        bestScore = score;
+        tie = false;
+      } else if (score === bestScore && score > 0) {
+        tie = true;
+      }
+    }
+    if (!best || bestScore === 0 || tie) return null;
+    return { original: ref, targetNodeId: best.id, confidence: 0.9, resolvedBy: 'qualified-name' };
+  }
+
   /**
   /**
    * Resolve a `this.<member>` function-as-value reference (#756/#808) to the
    * Resolve a `this.<member>` function-as-value reference (#756/#808) to the
    * ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The
    * ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The