Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<cfinclude template="formlistshared.cfm">` 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 — `<cfinclude template="formlistshared.cfm">` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cfcomponent>`/`<cffunction>` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `<cfscript>` delegation, call edges) |
| CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based `<cfcomponent>`/`<cffunction>` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `<cfscript>` 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) |
Expand Down
138 changes: 138 additions & 0 deletions __tests__/cfml-include-mixin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* CFML mixin membership (#2651). A component that includes a template at
* component scope — `<cfinclude template="formlistshared.cfm">` 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<string, string> = {
// 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': `<cfcomponent output="false">
\t<cffunction name="run" access="public">
\t\t<cfset this.checkRightsPartPrepare()>
\t\t<cfset helperA()>
\t\t<cfset nestedFn()>
\t</cffunction>
\t<cfinclude template="formlistshared.cfm">
\t<cfinclude template="../shared/helpers.cfm">
</cfcomponent>
`,
// A subclass reaches the parent's mixin with a bare call.
'app/core/subform.cfc': `<cfcomponent extends="baseform">
\t<cffunction name="go" access="public">
\t\t<cfset checkRightsPartPrepare()>
\t</cffunction>
</cfcomponent>
`,
// 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': `<cffunction name="checkRightsPartPrepare">\n\t<cfreturn true>\n</cffunction>\n`,
'app/shared/helpers.cfm': `<cfinclude template="nested.cfm">\n<cffunction name="helperA"><cfreturn 1></cffunction>\n`,
'app/shared/nested.cfm': `<cffunction name="nestedFn"><cfreturn 2></cffunction>\n`,
'app/shared/localshared.cfm': `<cffunction name="localFn"><cfreturn 3></cffunction>\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': `<cffunction name="checkRightsPartPrepare"><cfreturn false></cffunction>\n<cffunction name="helperA"><cfreturn 98></cffunction>\n<cffunction name="nestedFn"><cfreturn 99></cffunction>\n<cffunction name="localFn"><cfreturn 97></cffunction>\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');
});
});
48 changes: 48 additions & 0 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 `<cfinclude …>` 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<Edge[]> {
const edges: Edge[] = [];
const seen = new Set<string>();
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,
Expand Down Expand Up @@ -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) &&
/<cfinclude\b|\b(?:cfinclude|include)\s*(?:\(|template\s*=|["'])/i.test(content)) return true;
// Every Go file: besides the declarations the interface passes read, a call
// through a func field, or a function put in one, can sit in any of them.
if (/\.go$/.test(filePath)) return true;
Expand Down Expand Up @@ -5192,6 +5237,9 @@ export const SYNTH_PASSES: SynthPassDef[] = [
{ name: 'angularTemplateEdges', gate: (has) => 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) },
];

Expand Down
Loading