From d90f6abec79b4e2a90691c94b4cb8f27a187e555 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 23 Oct 2026 09:59:26 +0000 Subject: [PATCH] fix(cfml): and cfinclude() link the code to the file they include (#2838) Only cfscript's `include "x.cfm";`, written outside any function, linked the including code to the template. CfmlExtractor never read ``, which the grammar parses as a cf_selfclose_tag whose name is only in the text after `` is extracted like the script form: an import node in the scope it sits in (function, component, or the template's file) and an imports reference from that scope. `cfinclude(...)` and `include template="...";` make the same import and no call, and an `include` in a function body counts, from the function. - A path built at runtime (`#dir#/x.cfm`) is no import, in every form. - A CFML include of a template path resolves as CFML does: relative to the including file's directory, or from the project root for a leading `/` (less sure, 0.85), to the file at that path, ignoring case when one file matches. A path that names no file there goes to the file-name matcher, as before, but only ever to a file, and never to the including file. Validation: - New cfml-include.test.ts: the tag form at component level (same folder, ../, upper case, unquoted), in a function body inside , in a template and from the web root; the script forms; runtime-built paths, cfinclude(template=variables.x) and (no import); a relative path with a namesake elsewhere; an unmatched path that another template also includes, and a path whose only namesake is the includer. 5 of 7 fail on main, all 7 pass. - A/B vs main 51c4124d (CFML has no kernel path) on FW/1, BlogCFC, ColdBox, ContentBox, Preside, CFWheels and its vendor/wheels core as a project of its own: 127 new imports edges and 152 import nodes (36 more resolved again to the same file, more surely); 9 import nodes of runtime-built paths gone; 1 edge gone, CFWheels' starter-app views/wheels/wheels.cfm including itself. No edge moved to another target. Every CFML imports edge in the fix graphs (225) targets a file named like the include path, and every relative path that stays inside the project reaches exactly that file but one: Wheels' Public.cfc including views/routetesterprocess.cfm, no file beside it, matched by name to public/views/routetesterprocess.cfm as before. express and flask controls byte-identical. - Index time, median of 4 interleaved runs, CPU with workers, main vs fix: Preside 52.6 / 50.8 s, Wheels core 28.2 / 29.5 s. Within noise. Refs #2651 (part 1; part 2 is a feature request and stays open) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cfml-include.test.ts | 162 +++++++++++++++++++++++++++ src/extraction/cfml-extractor.ts | 65 ++++++++++- src/extraction/languages/cfscript.ts | 52 ++++++++- src/extraction/tree-sitter.ts | 12 ++ src/resolution/index.ts | 59 ++++++++++ 6 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 __tests__/cfml-include.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 298b336b..0a3a6253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a file that re-exports a module, like an `index.ts` with `export * from './button'` or `export { Button } from './button'`, now counts as importing that module, the way an `import` statement does. `codegraph affected` and a module's dependents now include the barrels that re-export it; before, a barrel made only of `export *` lines showed no dependency at all. Re-index JavaScript and TypeScript projects after upgrading. - `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 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/__tests__/cfml-include.test.ts b/__tests__/cfml-include.test.ts new file mode 100644 index 00000000..33924221 --- /dev/null +++ b/__tests__/cfml-include.test.ts @@ -0,0 +1,162 @@ +/** + * CFML includes (#2651). Only cfscript's `include "x.cfm";`, written outside + * any function, linked the including code to the template: the tag form + * `` — the common one in tag-based code — made + * nothing anywhere, the function form `cfinclude(template="x.cfm")` became a + * call to a function named `cfinclude`, and an `include` inside a function + * was dropped. A template path is relative to the including file's directory + * (or to the web root with a leading `/`), so `../shared/helpers.cfm` is that + * file even when another `helpers.cfm` exists. + */ +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 imports: string[] = []; +let unresolved: string[] = []; +let importTargetKinds: string[] = []; + +const files: Record = { + // Tag form at component scope: same directory, relative, upper case, unquoted. + 'app/core/baseform.cfc': ` +\t +\t +\t +\t +\t + +`, + // Tag form in a function body, nested in control flow; runtime-built paths and are no includes. + 'app/core/report.cfc': ` +\t +\t\t +\t\t\t +\t\t +\t\t +\t\t +\t\t +\t + +`, + // Tag form in a template, and a path from the web root. + 'app/page.cfm': ` + +`, + // A template and a function including a path no file has (a server + // mapping's): the function may not link to the template's import of it. + 'app/one.cfm': `\n`, + 'app/core/runtests.cfc': `component {\n\tfunction run() {\n\t\tinclude "/mapped/runner.cfm";\n\t}\n}\n`, + // The only file of the name an include names is the includer itself. + 'app/views/wheels/wheels.cfm': `\n`, + // Script forms: in a function body, the attribute form, the function form. + 'app/core/scriptform.cfc': `component { +\tinclude "formlistshared.cfm"; +\tfunction render() { +\t\tinclude "../shared/helpers.cfm"; +\t\tcfinclude(template="upper.cfm"); +\t\tcfinclude("bare.cfm"); +\t\tCFINCLUDE( TEMPLATE = 'formlistshared.cfm' ); +\t} +\tfunction other() { +\t\tinclude template="formlistshared.cfm"; +\t\tcfinclude(template=variables.view); +\t\tinclude "#variables.view#"; +\t\tinclude "#variables.dir#/helpers.cfm"; +\t} +} +`, + 'app/core/formlistshared.cfm': `\n`, + 'app/core/Upper.cfm': `\n`, + 'app/core/bare.cfm': `\n`, + 'app/shared/helpers.cfm': `\n`, + // A namesake the relative path does not name. + 'other/shared/helpers.cfm': `\n`, +}; + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-include-')); + 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 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 = 'imports'` + ) + .all() + .map((r: any) => `${r.src} -> ${r.tgt}`) + .sort(); + imports = db.prepare(`SELECT qualified_name q FROM nodes WHERE kind = 'import'`).all().map((r: any) => r.q).sort(); + importTargetKinds = db + .prepare(`SELECT DISTINCT t.kind k FROM edges e JOIN nodes t ON t.id = e.target WHERE e.kind = 'imports'`) + .all() + .map((r: any) => r.k); + unresolved = db + .prepare(`SELECT reference_kind k, reference_name n FROM unresolved_refs`) + .all() + .map((r: any) => `${r.k} ${r.n}`); + cg.close?.(); +}); + +afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('CFML includes (#2651)', () => { + it('links at component scope to the template, relative to the including file', () => { + expect(edges).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/core/Upper.cfm', + 'app/core/baseform.cfc::baseform -> app/core/bare.cfm', + ])); + expect(imports).toContain('baseform::formlistshared.cfm'); + }); + + it('links a in a function body from that function', () => { + expect(edges).toContain('report::render -> app/core/formlistshared.cfm'); + expect(imports).toContain('report::render::formlistshared.cfm'); + }); + + it('links a in a template, including one from the web root', () => { + expect(edges).toContain('app/page.cfm -> app/core/formlistshared.cfm'); + expect(edges).toContain('app/page.cfm -> app/shared/helpers.cfm'); + }); + + it('makes nothing of a template built at runtime or of ', () => { + expect(edges.filter((e) => e.startsWith('report::render ->'))).toEqual(['report::render -> app/core/formlistshared.cfm']); + expect(imports.filter((q) => q.startsWith('report::'))).toEqual(['report::render::formlistshared.cfm']); + expect(edges.filter((e) => e.startsWith('scriptform::other ->'))).toEqual(['scriptform::other -> app/core/formlistshared.cfm']); + expect(imports.filter((q) => q.includes('#'))).toEqual([]); + }); + + it('links the script forms: in a function body, `include template=…` and cfinclude(…)', () => { + expect(edges).toEqual(expect.arrayContaining([ + 'scriptform::render -> app/shared/helpers.cfm', + 'scriptform::render -> app/core/Upper.cfm', + 'scriptform::render -> app/core/bare.cfm', + 'scriptform::render -> app/core/formlistshared.cfm', + 'scriptform::other -> app/core/formlistshared.cfm', + ])); + expect(unresolved.filter((r) => /cfinclude/i.test(r))).toEqual([]); + }); + + it('never links a relative include to a namesake the path does not name', () => { + expect(edges.filter((e) => e.endsWith('other/shared/helpers.cfm'))).toEqual([]); + }); + + it('links an include to a file or to nothing: never to an import statement, nor to the including file', () => { + expect(importTargetKinds).toEqual(['file']); + expect(edges.filter((e) => e.startsWith('app/one.cfm') || e.startsWith('runtests::'))).toEqual([]); + expect(edges.filter((e) => e.startsWith('app/views/wheels/wheels.cfm'))).toEqual([]); + }); +}); diff --git a/src/extraction/cfml-extractor.ts b/src/extraction/cfml-extractor.ts index a1a66870..fb6ef865 100644 --- a/src/extraction/cfml-extractor.ts +++ b/src/extraction/cfml-extractor.ts @@ -417,6 +417,11 @@ export class CfmlExtractor { case 'cf_function_tag': if (declScope) this.extractFunctionTag(node, parentClassName ? containerId : undefined, containerId, parentClassName); return; + case 'cf_selfclose_tag': + // ``, wherever it is written (#2651). Its + // attributes are still walked below for `#hash#` expressions. + if (this.selfCloseTagName(node) === 'include') this.extractIncludeTag(node, containerId); + break; case 'cf_tag': { const name = this.cfTagName(node); if (name === 'interface' && declScope && !parentClassName) { @@ -559,6 +564,60 @@ export class CfmlExtractor { return nameNode ? this.source.substring(nameNode.startIndex, nameNode.endIndex).toLowerCase() : undefined; } + /** + * A void tag's name, lowercased, without the `cf` prefix (`` → + * `include`). The grammar keeps no node for it: it is the text after `` (#2651) includes the template + * as cfscript's `include "formlistshared.cfm";` does, so it is extracted the + * same way: an import node in the scope it sits in (the function, the + * component, or the file of a template) and an `imports` reference from + * that scope, which the resolver links to the file. A template built at + * runtime (`template="#dir#/x.cfm"`) names no file the index can link. + */ + private extractIncludeTag(tag: SyntaxNode, scopeId: string): void { + const valueNode = this.tagAttrValueNode(tag, 'template', true); + const template = valueNode ? this.source.substring(valueNode.startIndex, valueNode.endIndex).trim() : ''; + if (!template) return; + // Qualified like the generic extractor's import nodes: under the scope's + // own name, without the file path (`baseform::formlistshared.cfm`). + const scope = this.nodes.find((n) => n.id === scopeId); + const prefix = !scope || scope.kind === 'file' ? '' : scope.kind === 'method' ? scope.qualifiedName : scope.name; + const line = tag.startPosition.row + 1; + const column = tag.startPosition.column; + const id = this.nodeIds.generate(this.filePath, 'import', template, line, column); + this.nodes.push({ + id, + kind: 'import', + name: template, + qualifiedName: prefix ? `${prefix}::${template}` : template, + filePath: this.filePath, + language: this.language, + startLine: line, + endLine: tag.endPosition.row + 1, + startColumn: column, + endColumn: tag.endPosition.column, + signature: this.source.substring(tag.startIndex, tag.endIndex).trim(), + updatedAt: Date.now(), + }); + this.edges.push({ source: scopeId, target: id, kind: 'contains' }); + this.unresolvedReferences.push({ + fromNodeId: scopeId, + referenceName: template, + referenceKind: 'imports', + filePath: this.filePath, + line, + column, + language: this.language, + }); + } + /** * Delegate a `...` tag body to the cfscript grammar. * With `parentClassName` set (the block sits at component scope), functions @@ -691,9 +750,10 @@ export class CfmlExtractor { /** * The `attribute_value` node of a tag attribute — `undefined` when the tag * has no such attribute, `null` when its value isn't plain text (empty, or - * a `#hash#` expression). + * a `#hash#` expression). With `wholeValue`, also `null` when only part of + * the value is plain text (`template="#dir#/x.cfm"`). */ - private tagAttrValueNode(tag: SyntaxNode, attrName: string): SyntaxNode | null | undefined { + private tagAttrValueNode(tag: SyntaxNode, attrName: string, wholeValue = false): SyntaxNode | null | undefined { const attrs: SyntaxNode[] = []; for (let i = 0; i < tag.namedChildCount; i++) { const child = tag.namedChild(i); @@ -716,6 +776,7 @@ export class CfmlExtractor { const valueWrapper = attr.namedChildren.find( (c: SyntaxNode) => c.type === 'quoted_cf_attribute_value' || c.type === 'cf_attribute_value' ); + if (wholeValue && valueWrapper?.namedChildCount !== 1) return null; return valueWrapper?.namedChildren.find((c: SyntaxNode) => c.type === 'attribute_value') ?? null; } return undefined; diff --git a/src/extraction/languages/cfscript.ts b/src/extraction/languages/cfscript.ts index 361806f4..68cd21ae 100644 --- a/src/extraction/languages/cfscript.ts +++ b/src/extraction/languages/cfscript.ts @@ -17,6 +17,44 @@ function cfmlVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' | return undefined; } +/** + * 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. + */ +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; + return getNodeText(node, source).replace(/^["']|["']$/g, '').trim() || null; +} + +/** The value of a `template=…` argument or attribute, when it is a literal string. */ +function templateArgument(node: SyntaxNode, source: string): string | null { + if (node.type !== 'assignment_expression') return null; + const left = getChildByField(node, 'left'); + if (left?.type !== 'identifier' || getNodeText(left, source).toLowerCase() !== 'template') return null; + return literalString(getChildByField(node, 'right'), source); +} + +/** + * Whether a call is `cfinclude(…)` — the function form of `include` and of + * `` (ACF 11+, Lucee): an include of a template, not a call (#2651). + */ +export function isCfIncludeCall(node: SyntaxNode, source: string): boolean { + const fn = getChildByField(node, 'function'); + return fn?.type === 'identifier' && getNodeText(fn, source).toLowerCase() === 'cfinclude'; +} + +/** The literal template `cfinclude(template="x.cfm")` or `cfinclude("x.cfm")` names. */ +function cfIncludeCallTemplate(node: SyntaxNode, source: string): string | null { + const args = getChildByField(node, 'arguments'); + if (!args) return null; + for (const arg of args.namedChildren) { + const named = templateArgument(arg, source); + if (named) return named; + } + return literalString(args.namedChildren[0], source); +} + export const cfscriptExtractor: LanguageExtractor = { functionTypes: ['function_declaration', 'function_expression', 'arrow_function'], classTypes: ['component'], @@ -45,10 +83,18 @@ export const cfscriptExtractor: LanguageExtractor = { const importText = source.substring(node.startIndex, node.endIndex).trim(); if (node.type === 'include_statement') { - // `include "path/to/file.cfm";` — the included template path. + // `include "path/to/file.cfm";` — the included template path — or the + // attribute form, `include template="path/to/file.cfm";` (#2651). A + // path built at runtime (`include "#view#";`) is no import. const expr = node.namedChildren.find((c: SyntaxNode) => c.type === 'string'); - if (!expr) return null; - const moduleName = getNodeText(expr, source).replace(/^["']|["']$/g, ''); + const attr = expr ? undefined : node.namedChildren.find((c: SyntaxNode) => c.type === 'assignment_expression'); + const moduleName = expr ? literalString(expr, source) : attr ? templateArgument(attr, source) : null; + return moduleName ? { moduleName, signature: importText } : null; + } + + if (node.type === 'call_expression') { + // `cfinclude(template="path/to/file.cfm");` (#2651). + const moduleName = isCfIncludeCall(node, source) ? cfIncludeCallTemplate(node, source) : null; return moduleName ? { moduleName, signature: importText } : null; } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index caf11234..c0449a40 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -27,6 +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 { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; import { SvelteExtractor } from './svelte-extractor'; @@ -5166,6 +5167,13 @@ export class TreeSitterExtractor { const callerId = this.nodeStack[this.nodeStack.length - 1]; if (!callerId) return; + // CFML `cfinclude(template="x.cfm")` includes a template, as `include + // "x.cfm";` does (#2651): an import of the file, never a call. + if (this.language === 'cfscript' && isCfIncludeCall(node, this.source)) { + this.extractImport(node); + 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 @@ -7249,6 +7257,10 @@ export class TreeSitterExtractor { // handler paths live in a raw token tree the call walker can't see. if (nodeType === 'macro_invocation') this.extractRustRouteMacro(node); + // A CFML `include "x.cfm";` in a function body includes the template + // there, as it does at component scope (#2651). + if (nodeType === 'include_statement' && this.language === 'cfscript') this.extractImport(node); + if (this.extractor!.callTypes.includes(nodeType)) { this.extractCall(node); } else if (INSTANTIATION_KINDS.has(nodeType) || this.isVbnetConstructorShapedArrayCreation(node)) { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 01e363ca..536f8689 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1568,6 +1568,21 @@ export class ReferenceResolver { return this.resolveCfmlComponentPath(ref); } + // A CFML include names a template by its path from the including file's + // directory, or from the web root (#2651): `` is that file, not whichever `helpers.cfm` file-name + // matching would settle on. A path that names no file here (one through a + // server mapping, `/contenscms/…`) is matched by its file name, as before, + // but only to a file: the symbol strategies below took a path no file + // matched for another template's import statement of the same path. Nor + // is it the including file, which a file-name match can land on (Wheels' + // `views/wheels/wheels.cfm` including `../../wheels/public/wheels.cfm`). + if ((ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'imports' && isCfmlTemplatePath(ref.referenceName)) { + const included = this.resolveCfmlIncludePath(ref) ?? matchByFilePath(ref, this.context); + if (included && this.nodeById(included.targetNodeId)?.filePath === ref.filePath) return null; + return this.gateLanguage(included, ref); + } + // A PHP class written with a namespace in it — `new Alias\X()` through a // `use Ns as Alias;` namespace alias, `extends Sub\Base`, `\Ns\X::make()` // (#2256). The pre-filter below would drop most of these before any import @@ -3581,6 +3596,41 @@ export class ReferenceResolver { return { original: ref, targetNodeId: tied[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' }; } + /** + * The file a CFML include names (#2651) — `include "x.cfm";`, + * ``, `cfinclude(template="x.cfm")`. A template + * path is relative to the directory of the file that includes it + * (`formlistshared.cfm`, `../shared/helpers.cfm`), or to the web root when + * it starts with `/`: the project root, when the project is the web root — + * an assumption, so such a match is less sure. Null when no indexed file is + * there. CFML code written on a case-blind file system often spells a path + * 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; + } + /** * Resolve a `this.` function-as-value reference (#756/#808) to the * ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The @@ -4267,6 +4317,15 @@ export class ReferenceResolver { } } +/** + * Whether a CFML `imports` name is the path of a template an include names + * (`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, '/'));