From 3803ce37f6588df375b5ded1996b9870f636749e Mon Sep 17 00:00:00 2001 From: johnhldiver-beep <336945940+johnhldiver-beep@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:55:51 +0000 Subject: [PATCH] fix(cfml): tag cfinclude creates imports edge and mixin membership (#2651) A component-level include makes its template's functions members of the component (and of every subclass), so this./bare calls reach them. Shared include-path matching, an include-aware scope chain, and a synthesized cfml-mixin contains pass; function-local includes stay function-scoped. --- CHANGELOG.md | 1 + README.md | 2 +- __tests__/cfml-include-mixin.test.ts | 138 +++++++++++++++++++++++++ src/resolution/callback-synthesizer.ts | 48 +++++++++ src/resolution/cfml-includes.ts | 131 +++++++++++++++++++++++ src/resolution/index.ts | 35 ++----- src/resolution/name-matcher.ts | 95 ++++++++++++++--- 7 files changed, 409 insertions(+), 41 deletions(-) create mode 100644 __tests__/cfml-include-mixin.test.ts create mode 100644 src/resolution/cfml-includes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17022e634..3581a6e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now also update the files that import through an `index.ts` re-exporting another barrel, like `export * from './dropdown-menu'`, when that inner barrel is deleted or added. Before, their imports stayed linked through the deleted barrel, or stayed linked the way they were while it was missing, until each importing file changed or the project was re-indexed. - An import in a Vue, Svelte or Astro component now links the component to the file it imports, as an import in a script does, and so does an import of a scoped package that a `tsconfig` path or a workspace package maps to a project file, like `import { debounce } from '@excalidraw/common'`. Before, only the names such an import binds were linked, so a barrel or a module imported for its side effects showed no dependency from those files in `codegraph affected`, and `codegraph sync` could not update them when a barrel they import through was deleted or added. `codegraph sync` and the file watcher also link such an import again, and one written as `.` or `..`, when the file it names comes back. Re-index JavaScript, TypeScript, Vue, Svelte and Astro projects after upgrading. - In CFML, `` now links the component, function or template it is written in to the file it includes, as `include "formlistshared.cfm";` in a script already did, and so do `cfinclude(template="…")`, `include template="…";` and an `include` inside a function. A relative path like `../shared/helpers.cfm` now finds that file even when another file has the same name. Before, these includes left no trace in the graph, and `cfinclude(…)` showed up as a call to a function that doesn't exist. Thanks @HarryMuc for the report. (#2651) +- In CFML, a template a component pulls in at component scope — `` or `include "formlistshared.cfm";` — now contributes its functions to the component, and to every component that extends it. Calls like `this.checkRightsPartPrepare()` and an unqualified call in a subclass now link to that function, so callers, impact and `codegraph_explore` follow them. Re-index CFML projects after upgrading. Thanks @HarryMuc for the report. (#2651) - In a pnpm, npm, Yarn or Bun workspace whose globs reach packages more than one folder down, like pnpm's `packages/**/*` or npm's `libs/**/*`, those packages now count as part of the project, so an import of one is no longer taken for an outside package's, and `codegraph sync` and the file watcher relink the calls through it when its files change. A workspace glob can now also name packages by part of a folder name (`packages/plugin-*`) or by a folder below the wildcard (`apps/*/web`), and a negated glob like `!**/example` leaves packages out, as the package managers do. Before, only the folders directly under a glob's first wildcard were read, and a negated glob was ignored. Re-index after upgrading. - In a project that uses Vue, an import written through an `@/` or `~/` alias, like `import { Button } from '@/components'`, now links the file it imports, as it does in any other project, so `codegraph sync` and the file watcher update the files that import a module when that module, or a barrel they import through, is added or deleted. Before, Vue support linked such an import to the first declaration or the component in that file, which sync does not follow, and an import through a `tsconfig` alias that starts like one of Nuxt's built-in modules, like `#app/where`, linked the importing file to itself. Re-index Vue projects after upgrading. - `codegraph sync` and the file watcher now move an import written without an extension, like `import { sizeOf } from './button'`, to a file added beside the one it found before when the new file comes first, such as `button.ts` next to `button.vue`. Before, the import and the names it binds stayed linked to `button.vue` until the importing file changed or the project was re-indexed. JavaScript, TypeScript, Vue, Svelte and Astro. diff --git a/README.md b/README.md index baac93178..d758240cc 100644 --- a/README.md +++ b/README.md @@ -838,7 +838,7 @@ is written): | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | | R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) | | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | -| CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based ``/`` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `` delegation, call edges) | +| CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based ``/`` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `` delegation, call edges, includes and component-level mixin membership) | | COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) | | Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) | | Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) | diff --git a/__tests__/cfml-include-mixin.test.ts b/__tests__/cfml-include-mixin.test.ts new file mode 100644 index 000000000..16c2c9297 --- /dev/null +++ b/__tests__/cfml-include-mixin.test.ts @@ -0,0 +1,138 @@ +/** + * CFML mixin membership (#2651). A component that includes a template at + * component scope — `` or + * `include "formlistshared.cfm";` — gains that template's functions as its + * own members, and its subclasses gain them too, so `this.checkRightsPartPrepare()` + * and an unqualified `checkRightsPartPrepare()` in a subclass are member + * calls. Extraction is per file and cannot see the included template's + * declarations, so membership is a synthesized `contains` edge; resolution + * reads the same include chain, which is what keeps a same-named function in + * another directory from winning the call. An include written inside a + * function mixes nothing into the component — it is in scope for that + * function's calls only. + */ +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 calls: string[] = []; +let members: string[] = []; + +const files: Record = { + // Tag form at component scope: this.-scoped and unqualified calls, an + // include of a second template that itself includes a third. + 'app/core/baseform.cfc': ` +\t +\t\t +\t\t +\t\t +\t +\t +\t + +`, + // A subclass reaches the parent's mixin with a bare call. + 'app/core/subform.cfc': ` +\t +\t\t +\t + +`, + // Script form at component scope, a path from the web root. + 'app/core/scriptcomp.cfc': `component { +\tinclude "/app/shared/helpers.cfm"; +\tfunction handle() { +\t\treturn this.helperA(); +\t} +} +`, + // A function-scoped include is in scope for that function's calls only. + 'app/core/localonly.cfc': `component { +\tfunction run() { +\t\tinclude "../shared/localshared.cfm"; +\t\treturn localFn(); +\t} +} +`, + 'app/core/formlistshared.cfm': `\n\t\n\n`, + 'app/shared/helpers.cfm': `\n\n`, + 'app/shared/nested.cfm': `\n`, + 'app/shared/localshared.cfm': `\n`, + // Same-named functions in the callers' own directory: nearest-name matching + // prefers these, so only the include chain can tell them apart. + 'app/core/adecoy.cfm': `\n\n\n\n`, +}; + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-mixin-')); + 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; + calls = db + .prepare( + `SELECT s.qualified_name src, t.file_path tgt FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind = 'calls'` + ) + .all() + .map((r: any) => `${r.src} -> ${r.tgt}`) + .sort(); + members = db + .prepare( + `SELECT s.qualified_name src, t.file_path tgt FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind = 'contains' + AND json_extract(e.metadata, '$.synthesizedBy') = 'cfml-mixin'` + ) + .all() + .map((r: any) => `${r.src} -> ${r.tgt}`) + .sort(); + cg.close?.(); +}); + +afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('CFML mixin membership (#2651)', () => { + it('makes a component-level include’s functions members of the component, nested includes too', () => { + expect(members).toEqual(expect.arrayContaining([ + 'app/core/baseform.cfc::baseform -> app/core/formlistshared.cfm', + 'app/core/baseform.cfc::baseform -> app/shared/helpers.cfm', + 'app/core/baseform.cfc::baseform -> app/shared/nested.cfm', + 'app/core/scriptcomp.cfc::scriptcomp -> app/shared/helpers.cfm', + 'app/core/scriptcomp.cfc::scriptcomp -> app/shared/nested.cfm', + ])); + }); + + it('does not make a function-scoped include a member of the component', () => { + expect(members.filter((m) => m.startsWith('app/core/localonly.cfc::localonly'))).toEqual([]); + }); + + it('resolves this.-scoped and unqualified calls to the mixin, not to a same-named function elsewhere', () => { + expect(calls).toEqual(expect.arrayContaining([ + 'baseform::run -> app/core/formlistshared.cfm', + 'baseform::run -> app/shared/helpers.cfm', + 'baseform::run -> app/shared/nested.cfm', + 'scriptcomp::handle -> app/shared/helpers.cfm', + ])); + expect(calls.filter((c) => c.endsWith('app/core/adecoy.cfm'))).toEqual([]); + }); + + it('resolves an unqualified call in a subclass through the parent’s mixin', () => { + expect(calls).toEqual(expect.arrayContaining([ + 'subform::go -> app/core/formlistshared.cfm', + ])); + }); + + it('still resolves a call to a function included in the calling function', () => { + expect(calls).toContain('localonly::run -> app/shared/localshared.cfm'); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index d747898ab..56144498f 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -47,6 +47,7 @@ import { overrideFilter } from './override-parameters'; import { declaredComponent, exportedComponent, lazyValueModules, wrappedComponentName } from './frameworks/react'; import { jsxCode } from './jsx-code'; import { isTestPath } from '../search/query-utils'; +import { cfmlComponentIncludes } from './cfml-includes'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i; @@ -1392,6 +1393,47 @@ async function goCrossFileMethodContainsEdges(queries: QueryBuilder, onYield: Ma return edges; } +/** + * CFML mixin `contains` edges (#2651). A component may pull a shared template + * into its body — `include "formlistshared.cfm";` or `` at + * component scope — and CFML makes that template's functions members of the + * component (and of every subclass), which is how `this.checkRightsPartPrepare()` + * and a subclass's unqualified call reach them. Extraction is per file and + * cannot see the included template's declarations, so the membership is + * synthesized here: each component → the functions declared at the top level + * of every template its body includes, following a template's own file-scope + * includes too. An include inside a function mixes nothing in and is skipped + * by cfmlComponentIncludes. + */ +async function cfmlMixinContainsEdges(queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield): Promise { + const edges: Edge[] = []; + const seen = new Set(); + let scanned = 0; + for (const cls of queries.iterateNodesByKindIn('class', ['cfml', 'cfscript'])) { + if ((++scanned & 63) === 0) await onYield(); + for (const file of cfmlComponentIncludes(cls.filePath, ctx)) { + for (const fn of ctx.getNodesInFile(file)) { + if (fn.kind !== 'function') continue; + // A closure is contained by its enclosing function, not the template. + const topLevel = queries.getIncomingEdges(fn.id, ['contains']) + .some((e) => queries.getNodeById(e.source)?.kind === 'file'); + if (!topLevel) continue; + const key = `${cls.id}>${fn.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: cls.id, + target: fn.id, + kind: 'contains', + line: fn.startLine, + metadata: { synthesizedBy: 'cfml-mixin' }, + }); + } + } + } + return edges; +} + /** * Kotlin Multiplatform `expect`/`actual` linking. A `common` source set declares * `expect fun foo()` / `expect class Bar`; each platform source set (jvm, native, @@ -5077,6 +5119,9 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean if (/\.(?:c|h|cc|cpp|cxx|hpp|hh|hxx|cppm|ipp|inl|tcc|def|inc|tbl)$/i.test(filePath) && /\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[/.test(content)) return true; if (/\b(?:class|interface|protocol|trait|impl|extends|implements|expect|actual)\b/.test(content)) return true; + // A CFML component that includes a template gains its functions as members. + if (/\.(?:cfc|cfm|cfs)$/i.test(filePath) && + / has('typescript'), run: (_q, c, y) => angularTemplateEdges(c, y) }, { name: 'svelteKitPageEdges', gate: (has) => has('svelte'), run: (_q, c, y) => svelteKitPageComponentEdges(c, y) }, { name: 'svelteKitLinkEdges', gate: (has) => has('svelte'), run: (_q, c, y) => svelteKitLinkEdges(c, y) }, + // A CFML component-level include makes the included template's functions + // the component's members (the mixin pattern, #2651). + { name: 'cfmlMixinEdges', gate: (has) => has('cfml', 'cfscript'), run: (q, c, y) => cfmlMixinContainsEdges(q, c, y) }, { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) }, ]; diff --git a/src/resolution/cfml-includes.ts b/src/resolution/cfml-includes.ts new file mode 100644 index 000000000..5bc3f3630 --- /dev/null +++ b/src/resolution/cfml-includes.ts @@ -0,0 +1,131 @@ +/** + * CFML include resolution — the file a template path names, and the templates + * a component's body pulls in. + * + * A CFML include (`include "x.cfm";`, ``, + * `cfinclude("x.cfm")`) names a template relative to the including file, or + * from the web root when it starts with `/`, never by symbol name (#2651). + * Both the `imports` edge the resolver builds and the scope a component's + * calls are judged in read the same path, so both go through + * {@link cfmlIncludeFile}. + */ +import type { Node } from '../types'; +import type { ResolutionContext } from './types'; + +/** Minimal context the path lookup needs — the name → node index. */ +type FileLookup = Pick; + +/** + * Whether a CFML `imports` reference names a template path (`x.cfm`, + * `../shared/helpers.cfm`, `/views/page.cfm`) rather than a component or + * module import (`com.foo.Bar`, `java:…`), which no file path can name. + */ +export function isCfmlTemplatePath(name: string): boolean { + return !name.includes(':') && (name.includes('/') || name.includes('\\') || /\.(?:cfml?|cfc|cfs)$/i.test(name.trim())); +} + +export interface CfmlIncludeFile { + /** The indexed `file` node the template path names. */ + file: Node; + /** Written from the web root (`/x.cfm`), so the project root is assumed to be it. */ + fromRoot: boolean; + /** Matched only when ignoring case, as a case-blind CFML file system would. */ + caseFolded: boolean; +} + +/** + * The indexed file a CFML include path names, or null when no file is there. + * + * `written` is the path as written (`formlistshared.cfm`, + * `../shared/helpers.cfm`, `/views/page.cfm`); `fromFile` is the including + * file. A path that starts with `/` is resolved from the web root — the + * project root, when the project is the web root (an assumption, hence the + * returned flag). CFML code written on a case-blind file system often spells + * a path in another case, so a single file matching ignoring case counts too. + */ +export function cfmlIncludeFile(written: string, fromFile: string, context: FileLookup): CfmlIncludeFile | null { + const path = written.trim().replace(/\\/g, '/'); + const fromRoot = path.startsWith('/'); + const parts = fromRoot ? [] : fromFile.replace(/\\/g, '/').split('/').slice(0, -1); + for (const seg of path.split('/')) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + if (parts.length === 0) return null; // above the project root + parts.pop(); + } else { + parts.push(seg); + } + } + const fileName = parts[parts.length - 1]; + if (!fileName) return null; + const wanted = parts.join('/'); + const files = context.getNodesByLowerName(fileName.toLowerCase()).filter((n) => n.kind === 'file'); + const exact = files.find((n) => n.filePath === wanted); + if (exact) return { file: exact, fromRoot, caseFolded: false }; + const folded = files.filter((n) => n.filePath.toLowerCase() === wanted.toLowerCase()); + return folded.length === 1 ? { file: folded[0]!, fromRoot, caseFolded: true } : null; +} + +/** + * Every template a component's body includes, directly or through another + * included template — the mixin files whose functions become the component's + * members (#2651). + * + * An include written at component scope is qualified under the component's + * own name (`baseform::formlistshared.cfm`) by both extraction paths, while + * one written inside a function carries that function's scope + * (`baseform::handle::formlistshared.cfm`) — a function-local include mixes + * nothing into the component, so only the former counts. A `.cfm` template + * has no component name, so a file-scope include there is qualified by its + * path alone (`formlistshared.cfm`); those are followed too, since an + * included template may itself include the file whose functions it wants. + */ +export function cfmlComponentIncludes(componentFile: string, context: ResolutionContext): string[] { + const nodes = context.getNodesInFile(componentFile); + const component = nodes.find((n) => (n.kind === 'class' || n.kind === 'interface') && /\.cfc$/i.test(n.filePath)); + return component ? cfmlScopeIncludes(componentFile, component.name, context) : []; +} + +/** + * The templates a declaration scope includes — a component's body when + * `scopeQualifiedName` is its name, a function's body when it is the + * function's qualified name. Includes nested inside another scope + * (`baseform::handle::x.cfm` under the component scope) belong to that + * scope, not this one, and are skipped. A template's own file-scope includes + * are followed, since an included template may pull in the file it needs. + */ +export function cfmlScopeIncludes(file: string, scopeQualifiedName: string, context: ResolutionContext): string[] { + const prefix = `${scopeQualifiedName}::`.toLowerCase(); + const written = context.getNodesInFile(file) + .filter((n) => n.kind === 'import' && isCfmlTemplatePath(n.name) && + n.qualifiedName.toLowerCase().startsWith(prefix) && + !n.qualifiedName.slice(prefix.length).includes('::')) + .map((n) => n.name); + return cfmlIncludeClosure(written, file, context); +} + +/** The templates a `.cfm`/`.cfc` includes at file scope, which it then mixes in itself. */ +function cfmlTemplateIncludes(file: string, context: ResolutionContext): string[] { + return context.getNodesInFile(file) + .filter((n) => n.kind === 'import' && isCfmlTemplatePath(n.name) && !n.qualifiedName.includes('::')) + .map((n) => n.name); +} + +/** Resolve `written` include paths (relative to `fromFile`) and everything they include, cycle-guarded. */ +function cfmlIncludeClosure(written: string[], fromFile: string, context: ResolutionContext): string[] { + const out: string[] = []; + const seen = new Set([fromFile]); + let queue = written.map((path) => ({ path, from: fromFile })); + while (queue.length > 0 && out.length < 30) { + const next: { path: string; from: string }[] = []; + for (const { path, from } of queue) { + const target = cfmlIncludeFile(path, from, context); + if (!target || seen.has(target.file.filePath)) continue; + seen.add(target.file.filePath); + out.push(target.file.filePath); + next.push(...cfmlTemplateIncludes(target.file.filePath, context).map((p) => ({ path: p, from: target.file.filePath }))); + } + queue = next; + } + return out; +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 45538ccc9..5ec223624 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -43,6 +43,7 @@ import { isJsSuperCallLanguage, isJsSuperCallSite, matchJsSuperCall } from './js import { CLASS_EXPRESSION_REF_LANGUAGES, isCommonJsExportedClass, isJsClassExpression, writtenIn, type ClassExpressionIndex } from './js-class-expressions'; import { matchRubyConstantPathRef } from './ruby-constant-paths'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver'; +import { cfmlIncludeFile, isCfmlTemplatePath } from './cfml-includes'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; import { detectFrameworks, getResolvingFrameworks } from './frameworks'; @@ -3722,28 +3723,14 @@ export class ReferenceResolver { * in another case, so a single file that matches ignoring case counts too. */ private resolveCfmlIncludePath(ref: UnresolvedRef): ResolvedRef | null { - const written = ref.referenceName.trim().replace(/\\/g, '/'); - const fromRoot = written.startsWith('/'); - const parts = fromRoot ? [] : ref.filePath.replace(/\\/g, '/').split('/').slice(0, -1); - for (const seg of written.split('/')) { - if (seg === '' || seg === '.') continue; - if (seg === '..') { - if (parts.length === 0) return null; // above the project root - parts.pop(); - } else { - parts.push(seg); - } - } - const fileName = parts[parts.length - 1]; - if (!fileName) return null; - const wanted = parts.join('/'); - const files = this.context.getNodesByLowerName(fileName.toLowerCase()).filter((n) => n.kind === 'file'); - const exact = files.find((n) => n.filePath === wanted); - if (exact) return { original: ref, targetNodeId: exact.id, confidence: fromRoot ? 0.85 : 0.95, resolvedBy: 'file-path' }; - const folded = files.filter((n) => n.filePath.toLowerCase() === wanted.toLowerCase()); - return folded.length === 1 - ? { original: ref, targetNodeId: folded[0]!.id, confidence: fromRoot ? 0.8 : 0.9, resolvedBy: 'file-path' } - : null; + const target = cfmlIncludeFile(ref.referenceName, ref.filePath, this.context); + if (!target) return null; + return { + original: ref, + targetNodeId: target.file.id, + confidence: target.fromRoot ? (target.caseFolded ? 0.8 : 0.85) : (target.caseFolded ? 0.9 : 0.95), + resolvedBy: 'file-path', + }; } /** @@ -4437,10 +4424,6 @@ export class ReferenceResolver { * (`formlistshared.cfm`, `../shared/helpers.cfm`, `/app/x.cfm`), not a * cfscript `import com.foo.Bar;` or `import "java:…";` (#2651). */ -function isCfmlTemplatePath(name: string): boolean { - return !name.includes(':') && (name.includes('/') || name.includes('\\') || /\.(?:cfml?|cfc|cfs)$/i.test(name.trim())); -} - /** The project-relative directory holding `p` ('' for the project root). */ function goDirOf(p: string): string { const dir = path.posix.dirname(p.replace(/\\/g, '/')); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6b582f6d9..a66559bab 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -26,6 +26,7 @@ import { inBlock, isDeclarationFile, isMergedAmbientDeclaration, scanGlobalScope import { isMinifiedContent } from '../extraction/generated-detection'; import { getCargoWorkspaceCrateMap } from './frameworks/cargo-workspace'; import { readRustScopes, rustBindingAt, rustInnerBlockAt, rustBoundsOf, rustFnAt, rustImplAt, splitRustTopLevel, type RustBinding, type RustPatternSite, type RustScopes } from './rust-scopes'; +import { cfmlComponentIncludes, cfmlScopeIncludes } from './cfml-includes'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will * score. A name defined more times than this is "ubiquitous" — a method/symbol @@ -7733,20 +7734,51 @@ function isVbUnqualifiedName(ref: UnresolvedRef, receiver: string | null, contex const CFML_CHAINS = new WeakMap>>(); /** - * Whether a bare CFML call written in a component can mean `method`: its own - * component's, or one of the components it `extends` (read from source — a - * dotted path matched against the indexed files by its longest suffix). A - * call in a `.cfm` template is not judged: a ColdBox view runs inside the - * renderer's scope. coldbox's `now()` — the built-in — went to a date - * helper's `now` 228 times; chained `.then()` calls arrive bare too, and keep - * their method. - */ -function isCfmlMethodInScope(method: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { - if (!/\.cfc$/i.test(ref.filePath) || !hasNoReceiverOnLine(ref, context)) return true; - return cfmlChain(ref.filePath, context).has(method.filePath); + * Whether a CFML call written in a component can mean `candidate` — a method + * of the component, or a `function` a component-level include mixed in + * (#2651). A receiver-less call is a call on `this`, as is an explicit + * `this.` one, so it reaches the component's own members and those of the + * components it `extends` and of every template its body `include`s; a + * `super.` call reaches the same chain except its own file. A call on any + * other receiver is judged elsewhere. A call in a `.cfm` template is not + * judged: a ColdBox view runs inside the renderer's scope. coldbox's + * `now()` — the built-in — went to a date helper's `now` 228 times; chained + * `.then()` calls arrive bare too, and keep their method. + */ +function isCfmlCallInScope(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (!/\.cfc$/i.test(ref.filePath)) return true; + const receiver = cfmlCallReceiver(ref, context); + if (receiver === 'other') return true; + const chain = cfmlRefChain(ref, context); + return receiver === 'super' + ? candidate.filePath !== ref.filePath && chain.has(candidate.filePath) + : chain.has(candidate.filePath); +} + +/** + * The files whose functions a call at `ref` can name: the component's chain + * (see cfmlChain), plus the templates included directly in the calling + * function — a function-local include puts its functions in that function's + * scope only, so it widens this call and not the component's members. + */ +function cfmlRefChain(ref: UnresolvedRef, context: ResolutionContext): Set { + let memo = CFML_REF_CHAINS.get(context); + if (!memo) CFML_REF_CHAINS.set(context, (memo = new WeakMap())); + const hit = memo.get(ref); + if (hit) return hit; + let chain = cfmlChain(ref.filePath, context); + const from = ref.fromNodeId ? context.getNodeById?.(ref.fromNodeId) : null; + if (from && from.filePath === ref.filePath && (from.kind === 'method' || from.kind === 'function')) { + const included = cfmlScopeIncludes(ref.filePath, from.qualifiedName, context); + if (included.length > 0) chain = new Set([...chain, ...included]); + } + memo.set(ref, chain); + return chain; } -/** A component file and every component file it extends. */ +const CFML_REF_CHAINS = new WeakMap>>(); + +/** A component file, every component file it extends, and the templates any of them includes. */ function cfmlChain(file: string, context: ResolutionContext): Set { let memo = CFML_CHAINS.get(context); if (!memo) CFML_CHAINS.set(context, (memo = new Map())); @@ -7762,11 +7794,46 @@ function cfmlChain(file: string, context: ResolutionContext): Set { const head = text.slice(0, text.search(/\bfunction\b|>> 0 || text.length); const ext = /\bextends\s*=\s*["']?([\w.\/:-]+)["']?/i.exec(head)?.[1]; if (ext) queue.push(...cfmlComponentFiles(ext, f, context)); + // A template a component's body includes contributes its functions as + // the component's own members (the CFML mixin pattern, #2651). + if (/\.cfc$/i.test(f)) queue.push(...cfmlComponentIncludes(f, context)); } memo.set(file, chain); return chain; } +type CfmlCallReceiver = 'this' | 'super' | 'none' | 'other'; + +const CFML_CALL_RECEIVERS = new WeakMap>(); + +/** The receiver a bare CFML call names at its call site (#2651). */ +function cfmlCallReceiver(ref: UnresolvedRef, context: ResolutionContext): CfmlCallReceiver { + let memo = CFML_CALL_RECEIVERS.get(context); + if (!memo) CFML_CALL_RECEIVERS.set(context, (memo = new WeakMap())); + const hit = memo.get(ref); + if (hit) return hit; + const answer = readCfmlCallReceiver(ref, context); + memo.set(ref, answer); + return answer; +} + +function readCfmlCallReceiver(ref: UnresolvedRef, context: ResolutionContext): CfmlCallReceiver { + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; + if (line === undefined) return 'none'; + const lower = line.toLowerCase(); + const name = ref.referenceName.toLowerCase(); + let start = -1; + if (lower.startsWith(name, ref.column)) start = ref.column; + else if (ref.column >= name.length && lower.startsWith(name, ref.column - name.length)) start = ref.column - name.length; + else start = lower.search(new RegExp(`(?