diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 2cfedac4f..d842b4617 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -94,5 +94,10 @@ { "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `` reach the native onChange handler on iOS/Android?" }, { "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `` reach the native RNSScreenStackView component?" }, { "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `` JSX usage reach the iOS / Android native renderer?" } + ], + "Salesforce (Apex + LWC + Aura + Visualforce)": [ + { "name": "dreamhouse-lwc", "repo": "https://github.com/trailheadapps/dreamhouse-lwc", "size": "Small", "files": "~176", "question": "How does the propertyTileList component reach the Apex that queries property records?" }, + { "name": "ebikes-lwc", "repo": "https://github.com/trailheadapps/ebikes-lwc", "size": "Small", "files": "~182", "question": "How does the orderBuilder LWC reach the Apex OrderController that updates order items?" }, + { "name": "apex-recipes", "repo": "https://github.com/trailheadapps/apex-recipes", "size": "Medium", "files": "~432", "question": "How does an LWC recipe invoke its Apex controller method, and what does that method call in turn?" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 06de7752f..b24ce660b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class; and the C# inside `@code { }` / `@functions { }` / `@{ }` blocks is analyzed too, so services and types used in component logic are linked. A view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor) - A Razor/Blazor type reference now resolves through the component's `@using` namespaces — including the folder's cascading `_Imports.razor` — so a simple name that exists in several namespaces lands on the right one. A `@model` / `` / `@code` reference to `CatalogBrand` resolves to the `@using`'d DTO (`BlazorShared.Models.CatalogBrand`) rather than a same-named domain entity. (ASP.NET, Blazor) - `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329) +- CodeGraph now indexes **Salesforce Apex** (`.cls`, `.trigger`) — classes, interfaces, enums, methods, constructors, properties, triggers, and inner classes, with call edges, `extends`/`implements`, and `@AuraEnabled`/`@RemoteAction` annotations. A class that another class or a trigger calls now shows its callers, and impact/blast-radius work across Apex. +- CodeGraph now connects the **Salesforce front end to its Apex back end** as one graph. A Lightning Web Component that imports an Apex method (`import getX from '@salesforce/apex/MyController.getX'`) links to that method; a Visualforce page links to its `controller=` and `extensions=` Apex classes; and an Aura component's controller/helper links to the Apex it calls via `cmp.get("c.method")`. So editing an Apex method now surfaces every LWC, Aura, and Visualforce file that depends on it. (Apex, LWC, Aura, Visualforce) +- CodeGraph now indexes **Salesforce UI markup** — Visualforce pages and components (`.page`, `.component`), Aura components (`.cmp`, `.app`, `.evt`, `.intf`), and Lightning Web Component templates (`.html` inside an `lwc` bundle). Custom component usage (`` in Visualforce/Aura, `` in LWC) links each parent to the child component it renders, so the component tree is navigable. A Visualforce `standardController` is left unlinked because it names a Salesforce object, not an Apex class. (LWC, Aura, Visualforce) ### Fixes @@ -27,6 +30,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - React Native native→JS events now connect through the common `sendEvent(context, "X", body)` wrapper. Many libraries (react-native-device-info and others) wrap the event emitter behind a helper whose `.emit(eventName, …)` takes a *variable*, so the matcher — which looked for `.emit("literal", …)` — missed it; the literal event name actually lives in the wrapper call. Now a native method that fires `sendEvent(…, "batteryLevelChanged", …)` links to the JS `addListener('batteryLevelChanged', …)` handler, so editing the native emitter surfaces the JS subscriber. (React Native) - React Native / Expo cross-language bridges are more complete and more precise. An Expo Module method declared with a generic type — Android's `AsyncFunction("getBatteryLevelAsync")` — is now indexed (the `` used to defeat the matcher, so every Android Expo method was dropped and a JS call resolved only to the iOS Swift impl). The iOS and Android implementations of the same JS-visible method — both Expo Modules and classic NativeModules (`@ReactMethod` on Android, the matching method on iOS) — are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either platform's native code surfaces the JS caller. And a `Type.member` static read in native code (e.g. Android's `BatteryManager.EXTRA_LEVEL`) no longer falsely links to a coincidentally same-named class in another language (a web `BatteryManager`) — type references stay within a language family, while genuine cross-language bridges (config→code, JS↔native calls) are unaffected. (React Native, Expo) - A TypeScript/JavaScript reference or import no longer gets mis-linked to a same-named class in a native language. In a React Native / Expo repo that has both a TypeScript `TestRunner` type and a Kotlin `TestRunner` class, a TS reference to `TestRunner` — or an `import React` sitting next to a Swift `React` — used to resolve onto the native symbol (the component resolver matched any same-named class regardless of language, and import statements weren't language-checked at all). References and imports now stay within their language family, so they land on the right symbol while genuine cross-language bridges (JS↔native calls, config→code) are untouched. A C/C++ `#include "Foo.h"` likewise no longer resolves to a same-named header from another platform (an iOS Objective-C `Foo.h`). (React Native, Expo, TypeScript, C/C++) +- A method call in one programming language no longer gets mis-linked to an unrelated same-named method in another. A built-in or local call like `.replace(...)` or `.resolve(...)` — in JavaScript, or a Python `str.replace(...)` — used to bind to an Apex `CurrencyTokenReplacer::replace` or `…::resolve` just because the names matched, so a React or script file could look like it depended on Apex it never calls and an Apex method's dependents were inflated with false callers. Calls from a self-contained programming language (JS/TS, Java/Kotlin, Swift/Objective-C, C/C++, and Python, Go, Rust, PHP, Ruby, Dart, Lua) now stay within their language family, while genuine cross-layer calls still connect — a Salesforce Aura controller's `cmp.get("c.method")` reaches its Apex method, and JS↔native bridges are unaffected. (Apex, LWC, Aura, Visualforce, TypeScript/JavaScript, Python) - Native includes and Kotlin Multiplatform imports now resolve to the correct file in multi-platform projects. A C/C++ `#include "Foo.h"` now resolves to the header in the including file's own directory first (the C quoted-include rule), so when a module ships a same-named header per platform (a Windows, an Apple, and an Android `Foo.h` side by side) the local one correctly shows its dependents instead of an arbitrary other-platform header looking like the dependency. And a Kotlin Multiplatform `expect` declaration is no longer reported as having no dependents: a `commonMain` import now resolves to the `commonMain` `expect` (matched within the importing source set) rather than being absorbed by one platform's `actual`. (C/C++, Kotlin) - `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too. - Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript) diff --git a/README.md b/README.md index 7c7b84a9e..9dc197837 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ CodeGraph cuts **tokens, tool calls, and wall-clock time on every repo** — acr | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi, Apex, Visualforce, Aura, LWC | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -638,6 +638,10 @@ is written): | Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) | | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | +| Apex | `.cls`, `.trigger` | Full support (classes, interfaces, enums, methods, constructors, properties, triggers, inner classes; call/extends/implements edges; `@AuraEnabled`/`@RemoteAction` annotations) | +| Visualforce | `.page`, `.component` | Markup support (page/component → `controller=`/`extensions=` Apex classes and `` components; `standardController` left unlinked) | +| Aura | `.cmp`, `.app`, `.evt`, `.intf` | Markup support (component → `` components and `{!c.handler}` controller actions; controller/helper JS handlers → Apex via `cmp.get("c.x")`) | +| Lightning Web Components | `.js` + `.html` | Full support (the `.js` via JavaScript; the template links `` components and the `@salesforce/apex/...` import links to the Apex method) | ## Measured cross-file coverage @@ -666,9 +670,12 @@ Impact and blast-radius queries are only as good as the dependency graph behind | Luau | dphfox/Fusion | 92.2% | | Liquid | Shopify/dawn | 73.8% | | Pascal / Delphi | PascalCoin | 75.7% | +| Apex (+ LWC / Aura / Visualforce → Apex) | trailheadapps/ebikes-lwc | 55.6% | Framework routing is validated the same way, on a canonical app per framework: Express 100%, FastAPI 98%, Flask 100%, NestJS 96.8%, Gin 96.5%, Axum 100%, Rocket 93.8%, Vapor 100%, Laravel 92%, Rails 89.6%, React Router 100% — and the convention/reflection-heavy ones at their honest static-analysis ceiling: ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Django 74.1%. +The **Salesforce** number reflects a quirk of the platform, not the extractor: every non-test Apex class in ebikes-lwc that something depends on is covered — the residual is the mandatory `*Test` classes (the platform requires a test class per class, and nothing in code depends on a test) plus one class referenced only from metadata XML. The novel coverage here is *cross-layer*: a Lightning Web Component's `@salesforce/apex/...` import resolves to the Apex method, a Visualforce page's `controller=`/`extensions=` resolve to their Apex classes, and an Aura controller's `cmp.get("c.x")` resolves to the Apex method — so editing Apex surfaces the LWC/Aura/Visualforce that depend on it. + ## Troubleshooting **"CodeGraph not initialized"** — Run `codegraph init` in your project directory first. diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index f4a0ace03..9012006d3 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -6376,3 +6376,328 @@ describe('Swift property wrappers / attributes (blast-radius recall)', () => { } finally { cleanupTempDir(dir); } }); }); + +describe('Apex Extraction', () => { + it('extracts class/interface/enum/inner-class/static-method/trigger symbols', () => { + const code = `public class AccountService extends BaseService implements IService { + public static Integer COUNT = 0; + public String label { get; set; } + + public AccountService() { COUNT = 1; } + + @AuraEnabled + public static String greet(String who) { + Helper h = new Helper(); + return h.format(who); + } + + public enum Status { ACTIVE, CLOSED } + + public class Inner { + public Integer bump(Integer n) { return n + 1; } + } +}`; + const result = extractFromSource('AccountService.cls', code); + + const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'AccountService'); + expect(cls).toBeDefined(); + expect(cls?.visibility).toBe('public'); + + const inner = result.nodes.find((n) => n.kind === 'class' && n.name === 'Inner'); + expect(inner).toBeDefined(); + + const greet = result.nodes.find((n) => n.kind === 'method' && n.name === 'greet'); + expect(greet).toBeDefined(); + expect(greet?.isStatic).toBe(true); + + const status = result.nodes.find((n) => n.kind === 'enum' && n.name === 'Status'); + expect(status).toBeDefined(); + + // extends BaseService / implements IService + const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends'); + expect(extendsRefs.map((r) => r.referenceName)).toContain('BaseService'); + const implRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements'); + expect(implRefs.map((r) => r.referenceName)).toContain('IService'); + + // @AuraEnabled annotation captured as a `decorates` reference + const decorRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'decorates'); + expect(decorRefs.map((r) => r.referenceName)).toContain('AuraEnabled'); + + // cross-class call greet -> format (receiver kept until resolution) + const callRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + expect(callRefs.some((r) => r.referenceName.endsWith('format'))).toBe(true); + }); + + it('extracts a trigger and its handler call', () => { + const code = `trigger AccountTrigger on Account (before insert, after update) { + AccountService.greet('world'); +}`; + const result = extractFromSource('AccountTrigger.trigger', code); + + const trigger = result.nodes.find((n) => n.name === 'AccountTrigger'); + expect(trigger).toBeDefined(); + expect(trigger?.signature).toContain('Account'); + + const callRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + expect(callRefs.some((r) => r.referenceName.endsWith('greet'))).toBe(true); + }); + + it('resolves cross-file caller→callee edges across .cls and .trigger', async () => { + const dir = createTempDir(); + try { + const classes = path.join(dir, 'force-app/main/default/classes'); + const triggers = path.join(dir, 'force-app/main/default/triggers'); + fs.mkdirSync(classes, { recursive: true }); + fs.mkdirSync(triggers, { recursive: true }); + + fs.writeFileSync(path.join(classes, 'Helper.cls'), + `public class Helper {\n public String format(String who) { return 'Hi ' + who; }\n}\n`); + fs.writeFileSync(path.join(classes, 'AccountService.cls'), + `public class AccountService {\n public static String greet(String who) {\n Helper h = new Helper();\n return h.format(who);\n }\n}\n`); + fs.writeFileSync(path.join(triggers, 'AccountTrigger.trigger'), + `trigger AccountTrigger on Account (before insert) {\n AccountService.greet('world');\n}\n`); + + const cg = CodeGraph.initSync(dir, { + config: { include: ['**/*.cls', '**/*.trigger'], exclude: [] }, + }); + const indexResult = await cg.indexAll(); + cg.resolveReferences(); + + expect(indexResult.filesIndexed).toBe(3); + // AccountService.greet() calls Helper.format() → Helper.cls has AccountService.cls as a dependent + expect(cg.getFileDependents('force-app/main/default/classes/Helper.cls')) + .toContain('force-app/main/default/classes/AccountService.cls'); + // AccountTrigger calls AccountService.greet() → AccountService.cls has the trigger as a dependent + expect(cg.getFileDependents('force-app/main/default/classes/AccountService.cls')) + .toContain('force-app/main/default/triggers/AccountTrigger.trigger'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); + +describe('Salesforce LWC → Apex resolver', () => { + it('links an LWC @salesforce/apex import to the Apex method node', async () => { + const dir = createTempDir(); + try { + const classes = path.join(dir, 'force-app/main/default/classes'); + const lwc = path.join(dir, 'force-app/main/default/lwc/acctList'); + fs.mkdirSync(classes, { recursive: true }); + fs.mkdirSync(lwc, { recursive: true }); + + fs.writeFileSync(path.join(classes, 'AccountController.cls'), + `public with sharing class AccountController {\n @AuraEnabled(cacheable=true)\n public static List getAccounts() { return [SELECT Id FROM Account]; }\n}\n`); + fs.writeFileSync(path.join(lwc, 'acctList.js'), + `import { LightningElement, wire } from 'lwc';\n` + + `import getAccounts from '@salesforce/apex/AccountController.getAccounts';\n` + + `export default class AcctList extends LightningElement {\n` + + ` @wire(getAccounts) accounts;\n` + + ` refresh() { getAccounts({ limit: 10 }).then(r => { this.data = r; }); }\n` + + `}\n`); + + const cg = CodeGraph.initSync(dir, { + config: { include: ['**/*.cls', '**/*.js'], exclude: [] }, + }); + await cg.indexAll(); + cg.resolveReferences(); + + // The Apex method node exists with qualifiedName Class::method. + const apexMethod = cg.searchNodes('getAccounts', { limit: 20 }) + .map((r) => r.node) + .find((n) => n.kind === 'method' && n.qualifiedName === 'AccountController::getAccounts'); + expect(apexMethod).toBeDefined(); + + // Cross-layer edge is queryable: the LWC .js depends on the Apex .cls. + expect(cg.getFileDependents('force-app/main/default/classes/AccountController.cls')) + .toContain('force-app/main/default/lwc/acctList/acctList.js'); + + // getCallers on the Apex method surfaces the LWC call site. + const callers = cg.getCallers(apexMethod!.id).map((c: any) => (c.node ? c.node : c)); + expect(callers.some((c: any) => (c.filePath || '').includes('/lwc/'))).toBe(true); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); + +describe('Visualforce extraction + resolver', () => { + it('emits a component node and Tier-1 references (single file)', () => { + const code = ` + + +`; + const result = extractFromSource('force-app/main/default/pages/AccountPage.page', code); + + const comp = result.nodes.find((n) => n.kind === 'component' && n.language === 'visualforce'); + expect(comp?.name).toBe('AccountPage'); + + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName); + expect(refs).toContain('AccountController'); // controller + expect(refs).toContain('ExtA'); // extensions, split + expect(refs).toContain('ExtB'); + expect(refs).toContain('AccountCard'); // + expect(refs).not.toContain('pageBlock'); // apex: standard tag skipped + }); + + it('skips standardController (SObject, not an Apex class)', () => { + const code = ``; + const result = extractFromSource('force-app/main/default/pages/StdPage.page', code); + const refs = result.unresolvedReferences.map((r) => r.referenceName); + expect(refs).not.toContain('Account'); + }); + + it('resolves VF page → Apex controller/extension/component across files', async () => { + const dir = createTempDir(); + try { + const classes = path.join(dir, 'force-app/main/default/classes'); + const pages = path.join(dir, 'force-app/main/default/pages'); + const components = path.join(dir, 'force-app/main/default/components'); + fs.mkdirSync(classes, { recursive: true }); + fs.mkdirSync(pages, { recursive: true }); + fs.mkdirSync(components, { recursive: true }); + + fs.writeFileSync(path.join(classes, 'AccountController.cls'), + `public with sharing class AccountController {\n public List getAccounts() { return null; }\n}\n`); + fs.writeFileSync(path.join(classes, 'AccountExt.cls'), + `public with sharing class AccountExt {\n public AccountExt(ApexPages.StandardController c) {}\n}\n`); + fs.writeFileSync(path.join(components, 'AccountCard.component'), + `\n`); + fs.writeFileSync(path.join(pages, 'AccountPage.page'), + `\n \n\n`); + + const cg = CodeGraph.initSync(dir, { + config: { include: ['**/*.cls', '**/*.page', '**/*.component'], exclude: [] }, + }); + await cg.indexAll(); + cg.resolveReferences(); + + const page = 'force-app/main/default/pages/AccountPage.page'; + expect(cg.getFileDependents('force-app/main/default/classes/AccountController.cls')).toContain(page); + expect(cg.getFileDependents('force-app/main/default/classes/AccountExt.cls')).toContain(page); + expect(cg.getFileDependents('force-app/main/default/components/AccountCard.component')).toContain(page); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); + +describe('LWC template extraction + resolver', () => { + it('emits a component node and references, ignoring base components', () => { + const code = ``; + const result = extractFromSource('force-app/main/default/lwc/acctList/acctList.html', code); + + const comp = result.nodes.find((n) => n.kind === 'component' && n.language === 'lwc'); + expect(comp?.name).toBe('acctList'); + + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName); + expect(refs).toContain('AcctTile'); // pascalized + expect(refs).not.toContain('Button'); // lightning-button base component skipped + }); + + it('does not index generic (non-LWC) .html files', () => { + expect(detectLanguage('force-app/main/default/lwc/acctList/acctList.html')).toBe('lwc'); + expect(detectLanguage('public/index.html')).toBe('unknown'); + expect(isSourceFile('public/index.html')).toBe(false); + }); + + it('resolves an LWC template to the child component class', async () => { + const dir = createTempDir(); + try { + const base = path.join(dir, 'force-app/main/default/lwc'); + const parent = path.join(base, 'acctList'); + const child = path.join(base, 'acctTile'); + fs.mkdirSync(parent, { recursive: true }); + fs.mkdirSync(child, { recursive: true }); + + fs.writeFileSync(path.join(parent, 'acctList.js'), + `import { LightningElement } from 'lwc';\nexport default class AcctList extends LightningElement {}\n`); + fs.writeFileSync(path.join(parent, 'acctList.html'), + `\n`); + fs.writeFileSync(path.join(child, 'acctTile.js'), + `import { LightningElement, api } from 'lwc';\nexport default class AcctTile extends LightningElement { @api record; }\n`); + + const cg = CodeGraph.initSync(dir, { + config: { include: ['**/*.js', '**/*.html'], exclude: [] }, + }); + await cg.indexAll(); + cg.resolveReferences(); + + expect(cg.getFileDependents('force-app/main/default/lwc/acctTile/acctTile.js')) + .toContain('force-app/main/default/lwc/acctList/acctList.html'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); + +describe('Aura extraction + resolver', () => { + it('emits a component node, references, and {!c.handler} calls', () => { + const code = ` + + + +`; + const result = extractFromSource('force-app/main/default/aura/AccountList/AccountList.cmp', code); + + const comp = result.nodes.find((n) => n.kind === 'component' && n.language === 'aura'); + expect(comp?.name).toBe('AccountList'); + + const refNames = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName); + expect(refNames).toContain('accountTile'); // + const callNames = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(callNames).toContain('doInit'); // {!c.doInit} + }); + + it('extracts Aura controller handlers and cmp.get("c.x") → Apex calls', () => { + const code = `({ + doInit : function(cmp, event, helper) { + var action = cmp.get("c.getAccounts"); + helper.process(cmp, action); + } +})`; + const result = extractFromSource('force-app/main/default/aura/AccountList/AccountListController.js', code); + + const handler = result.nodes.find((n) => n.name === 'doInit'); + expect(handler).toBeDefined(); // object-literal handler is now a node + + const callNames = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(callNames).toContain('getAccounts'); // cmp.get("c.getAccounts") bare name + }); + + it('does NOT extract object-literal handlers from non-Aura JS', () => { + const code = `({ doInit : function(cmp) { return cmp; } })`; + const result = extractFromSource('src/random.js', code); + expect(result.nodes.find((n) => n.name === 'doInit')).toBeUndefined(); + }); + + it('resolves Aura JS cmp.get and markup across files', async () => { + const dir = createTempDir(); + try { + const classes = path.join(dir, 'force-app/main/default/classes'); + const parent = path.join(dir, 'force-app/main/default/aura/AccountList'); + const child = path.join(dir, 'force-app/main/default/aura/accountTile'); + fs.mkdirSync(classes, { recursive: true }); + fs.mkdirSync(parent, { recursive: true }); + fs.mkdirSync(child, { recursive: true }); + + fs.writeFileSync(path.join(classes, 'AccountController.cls'), + `public with sharing class AccountController {\n @AuraEnabled public static List getAccounts() { return null; }\n}\n`); + fs.writeFileSync(path.join(parent, 'AccountList.cmp'), + `\n \n\n`); + fs.writeFileSync(path.join(parent, 'AccountListController.js'), + `({\n doInit : function(cmp) { var a = cmp.get("c.getAccounts"); }\n})\n`); + fs.writeFileSync(path.join(child, 'accountTile.cmp'), + `\n`); + + const cg = CodeGraph.initSync(dir, { + config: { include: ['**/*.cls', '**/*.cmp', '**/*.js'], exclude: [] }, + }); + await cg.indexAll(); + cg.resolveReferences(); + + // Aura JS → Apex (cmp.get string ref) + expect(cg.getFileDependents('force-app/main/default/classes/AccountController.cls')) + .toContain('force-app/main/default/aura/AccountList/AccountListController.js'); + // Aura markup → child component () + expect(cg.getFileDependents('force-app/main/default/aura/accountTile/accountTile.cmp')) + .toContain('force-app/main/default/aura/AccountList/AccountList.cmp'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 9d0788fc7..fc8283e61 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1115,6 +1115,88 @@ func main() { }); }); + describe('Name Matcher: cross-family `calls` gate', () => { + const baseContext = (candidates: Node[]): ResolutionContext => ({ + getNodesInFile: () => [], + getNodesByName: (name) => candidates.filter((c) => c.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }); + + const apexReplace: Node = { + id: 'apex:CurrencyTokenReplacer.cls:replace:10', kind: 'method', name: 'replace', + qualifiedName: 'CurrencyTokenReplacer::replace', filePath: 'classes/CurrencyTokenReplacer.cls', + language: 'apex', startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + + it('drops a JS bare call that only collides with a foreign-language method name', () => { + // A TS file calling `someString.replace(...)` emits a bare `replace` + // call ref. The graph has no JS `replace` node (it's a String builtin), + // only an Apex `CurrencyTokenReplacer::replace`. A known-family caller + // (web) must NOT bind to a different family by bare coincidental name. + const ref = { + fromNodeId: 'func:DataTransformer.ts:cleanText:1', + referenceName: 'replace', + referenceKind: 'calls' as const, + line: 5, column: 0, filePath: 'src/utils/DataTransformer.ts', language: 'typescript' as const, + }; + const result = matchReference(ref, baseContext([apexReplace])); + expect(result).toBeNull(); + }); + + it('still resolves a same-language call when a same-family candidate exists', () => { + // If a real TS `replace` method exists, the cross-family Apex one is + // dropped but the same-language one is kept. + const tsReplace: Node = { + id: 'ts:tokens.ts:replace:3', kind: 'method', name: 'replace', + qualifiedName: 'tokens.ts::TokenReplacer.replace', filePath: 'src/tokens.ts', + language: 'typescript', startLine: 3, endLine: 6, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const ref = { + fromNodeId: 'func:main.ts:run:1', + referenceName: 'replace', + referenceKind: 'calls' as const, + line: 5, column: 0, filePath: 'src/main.ts', language: 'typescript' as const, + }; + const result = matchReference(ref, baseContext([apexReplace, tsReplace])); + expect(result?.targetNodeId).toBe('ts:tokens.ts:replace:3'); + }); + + it('drops a Python bare call that only collides with a foreign-language method name', () => { + // Python is a singleton family but a self-sufficient programming language: + // a `str.replace(...)` builtin must NOT bind to the Apex `replace`. + const ref = { + fromNodeId: 'func:tokens.py:clean:1', + referenceName: 'replace', + referenceKind: 'calls' as const, + line: 5, column: 0, filePath: 'scripts/tokens.py', language: 'python' as const, + }; + const result = matchReference(ref, baseContext([apexReplace])); + expect(result).toBeNull(); + }); + + it('keeps a cross-layer call from a markup/template caller (Aura → Apex)', () => { + // Aura is not in LANGUAGE_FAMILY, so it stays ungated: a genuine + // cross-layer server call (`cmp.get("c.replace")`) with no same-language + // target survives — the gate only fences off self-sufficient known + // families (js/ts, java/kotlin, …), not config↔code bridges. + const ref = { + fromNodeId: 'aura:Foo.cmp:Foo:1', + referenceName: 'replace', + referenceKind: 'calls' as const, + line: 5, column: 0, filePath: 'aura/Foo/FooController.js', language: 'aura' as const, + }; + const result = matchReference(ref, baseContext([apexReplace])); + expect(result?.targetNodeId).toBe('apex:CurrencyTokenReplacer.cls:replace:10'); + }); + }); + describe('tsconfig path aliases', () => { it('resolves an aliased import to the alias-mapped file (not a same-named file elsewhere)', async () => { // Two same-named exports in different directories. Without alias diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index aa65398e4..86848f990 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -258,6 +258,8 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. | JS × Swift/Kotlin | Expo Modules | JS `requireNativeModule('X').fn(...)` → Swift/Kotlin `Function("fn") { ... }` | R (extract → synthetic method nodes) | ✅ **expo-modules framework extractor** — parses Swift/Kotlin `Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... }; Property("w") { ... } }` literals and synthesizes `method` nodes named after each declaration. JS callsites resolve via existing name-matcher (no separate `resolve()` needed). expo-haptics S **6 method nodes** (`notificationAsync`, `impactAsync`, `selectionAsync` × Swift + Kotlin), expo-camera M **41** (full SDK surface incl. `takePictureAsync`, `record`, `scanFromURLAsync`, view props `width`/`height`), expo SDK sweep L **134** (7 packages, 72 Swift + 62 Kotlin). Same-name JS wrappers in the package itself shadow the native names (`CameraView.tsx`'s `pausePreview` wraps native `pausePreview`); external consumer apps bridge through to native directly. 🔬 closure body extraction (the Function trailing closure isn't a body-range node yet) | | JS × native | React Native Fabric / Codegen + legacy Paper view components | JSX `` → Codegen spec → native class (or Paper `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp`) | R (extract) + S (native-impl) + JSX | ✅ **fabric-view extractor + fabric-native-impl synthesizer** — extractor parses **both** modern Codegen TS specs (`codegenNativeComponent('Name', ...)`) **and** legacy Paper view-manager macros (`RCT_EXPORT_VIEW_PROPERTY` on ObjC, `@ReactProp` on Java/Kotlin). Emits a `component` node per declaration + a `property` node per declared prop. Synthesizer links the component to its native impl class by RN's convention-based name+suffix (`exact`/`View`/`ComponentView`/`Manager`/`ViewManager`). Combined with `reactJsxChildEdges`, full consumer flow: JSX `` → fabric `component` → native class. Validated on RNSegmentedControl S **(legacy Paper) 1 component + 11 props + 4 bridges**, RNScreens M **(pure Codegen) 27 components + 272 props + 68 bridges** (was 0 before Phase 6), RNSkia L **(hybrid + monorepo) 5 + 14 + 15 across Codegen TS + Android Java + iOS ObjC**. **Monorepo detect** added: probes `packages//package.json` etc. via `listDirectories` when the root manifest is a workspace declaration (was the gating bug on RNSkia). 🔬 Fabric event-handler props (`onTap={cb}`) — JSX attribute extraction needed | +| Apex × LWC/Aura/Visualforce | Salesforce DX | UI → Apex `@AuraEnabled` method; page → controller; child component usage | X + R | ✅ **Apex grammar** (vendored `tree-sitter-apex.wasm`, ABI 15 — `check-grammar` PASS) extracts classes/interfaces/enums/methods/triggers/inner classes with call + extends/implements edges + `@AuraEnabled`/`@RemoteAction` annotations. **Markup extractors** (`visualforce-extractor.ts`, `aura-extractor.ts`, `lwc-template-extractor.ts`) emit one `component` node per file + reference/call refs (``/``, `{!c.handler}`). **Cross-layer `salesforce.ts` resolver**: LWC/Aura JS `import x from '@salesforce/apex/Class.method'` → Apex method (`imports`), VF `controller=`/`extensions=`/`` → Apex class/component (`references`), Aura `cmp.get("c.x")` → Apex method (`calls`, cross-language). Apex stays its own language family so `gateFrameworkLanguage` keeps these cross-layer edges (no LANGUAGE_FAMILY change needed). **Deterministic validation** (`verify-extraction` PASS, real repos): ebikes-lwc S (182 files, langs apex+aura+lwc+visualforce detected; cross-layer edges: 16 js→apex imports, 1 vf→apex, 17 lwc→child) · dreamhouse-lwc S (176 files) · apex-recipes M (432 files; 10 js→apex imports + 5 calls). Apex fair coverage 55.6% on ebikes — the residual is Salesforce's mandatory `*Test` classes (nothing in code depends on a test) + one metadata-only class; every non-test business class with a dependent is covered. **Agent A/B (headless `claude -p`, Opus 4.8, `--strict-mcp-config`):** ebikes-lwc (n=2, "how does orderBuilder LWC reach the Apex OrderController"): WITH = **0 Read / 0 Grep**, 1–2 `codegraph_explore` calls, 27–30s; WITHOUT = 5–9 Read + 10–16 Bash, 61–90s. apex-recipes (n=1, "how does an LWC recipe invoke its Apex controller"): WITH = **0 Read**, 1 explore, 33s, $0.31; WITHOUT = 4 Read + 6 Bash, 47s, 11 turns, $0.36. **Both repos: a single `codegraph_explore` on the LWC→Apex flow answers with zero file reads and ~30–50% less wall-clock** — the agent rides the cross-layer edges. Cost is mixed (WITH higher on the lean small-repo native baseline, lower on the medium repo where the without-arm thrashes). 🔬 `@salesforce/schema` → SObject (no SObject nodes yet), Apex `@AuraEnabled` not queryable from the persisted graph (no `extractModifiers`), managed-package namespaces (`ns__Class`), VF ``/composition + `{!ctrl.method}` merge bindings, Aura `` nodes | + (Verify the exact supported set against `src/extraction/languages/` and `src/resolution/frameworks/` before starting — this table is a starting point.) diff --git a/docs/design/salesforce-stack.md b/docs/design/salesforce-stack.md new file mode 100644 index 000000000..bad24cf3d --- /dev/null +++ b/docs/design/salesforce-stack.md @@ -0,0 +1,92 @@ +# Scope: Salesforce stack (Apex + LWC + Aura + Visualforce) + +Index the full Salesforce stack as one connected graph: the Apex back end, and +the three UI layers that reach into it. The novel value is **cross-layer edges** +— editing an Apex method surfaces every LWC, Aura, and Visualforce file that +depends on it. + +## Layers and how each is wired + +| Layer | Files | Mechanism | Why | +|---|---|---|---| +| Apex | `.cls`, `.trigger` | tree-sitter grammar + declarative `LanguageExtractor` (`languages/apex.ts`) | sfapex grammar exists (ABI 15), node types mirror Java | +| Visualforce | `.page`, `.component` | custom `VisualforceExtractor` | no grammar; `{!expr}` / `` are the semantics, not text | +| Aura | `.cmp`, `.app`, `.evt`, `.intf` | custom `AuraExtractor` + Aura-JS handler path | markup + bare `({...})` controller objects | +| LWC | `.js` (already indexed) + `.html` | `LwcTemplateExtractor` (template only) | the `.js` is plain ES modules; only the template + the Apex-import link are missing | + +The markup extractors follow the existing standalone pattern (`svelte-extractor.ts`, +`razor-extractor.ts`): a class taking `(filePath, source)`, dispatched in +`tree-sitter.ts`'s `extractFromSource`. Exactly **one `component` node per file**; +child/handler/controller links are `references`/`calls` EDGES, never per-tag nodes. + +## Cross-layer edges (the point) + +| From | To | EdgeKind | How | +|---|---|---|---| +| LWC `.js` `import x from '@salesforce/apex/C.m'` | Apex method `C::m` | `imports` + `calls` | `salesforce.ts` resolver maps the `@salesforce/apex/...` specifier to the Apex method's qualifiedName | +| Visualforce `controller=`/`extensions=` | Apex class | `references` | resolver, by class name | +| Visualforce / Aura `` | child component | `references` | resolver, by component name | +| LWC `.html` `` | child LWC class | `references` | resolver, kebab→PascalCase | +| Aura `cmp.get("c.x")` | Apex method | `calls` | extractor emits the bare method name; the generic name-matcher resolves it cross-language (`calls` aren't language-gated) | + +**Family-gate note (important):** Apex is deliberately left out of +`LANGUAGE_FAMILY` (`name-matcher.ts`). The framework gate (`gateFrameworkLanguage`) +only drops a `references`/`imports` edge between two *known* families; with Apex +unknown, every cross-layer edge survives the framework-resolver path. This is why +no LANGUAGE_FAMILY change was needed (unlike Razor, which shares `dotnet` with C#). + +## Invariants / risk mitigations + +- **`standardController="Account"` is skipped** — it names an SObject, not an + Apex class; linking it would mis-resolve to a same-named class. +- **`.html` is path-gated** to `lwc//*.html` (`isLwcTemplate`) so generic + HTML elsewhere stays unindexed (no HTML hijack). +- **Aura JS handler extraction is path-gated** to `aura/*Controller|Helper|Renderer.js` + — a new branch for the bare `({...})` object, not a change to the existing + `export const x = {...}` object-method gate (zero effect on other JS). +- Only the custom `c:`/`c-` namespace is captured; standard namespaces (`apex:`, + `lightning:`, `aura:`, `ui:`, `force:`) are framework built-ins → skipped. + +## Validation (deterministic, real repos) + +`scripts/add-lang/check-grammar.mjs apex` → ABI 15, PASS. `verify-extraction.mjs` +PASS on real repos with all four languages detected: + +| Repo | Tier | Files | Cross-layer edges observed | +|---|---|---|---| +| trailheadapps/ebikes-lwc | Small | 182 | 16 LWC.js→Apex `imports`, 1 VF→Apex, 17 LWC.html→child | +| trailheadapps/dreamhouse-lwc | Small | 176 | LWC→Apex + LWC.html→child | +| trailheadapps/apex-recipes | Medium | 432 | 10 LWC.js→Apex `imports` + 5 `calls`, 7 LWC.html→child | + +Apex fair coverage on ebikes-lwc is 55.6%; the residual is Salesforce's mandatory +`*Test` classes (the platform requires a test class per class; nothing in code +depends on a test) plus one metadata-referenced class — every non-test business +class with a dependent is covered. Unit/integration coverage lives in +`__tests__/extraction.test.ts` (Apex / Visualforce / LWC template / Aura blocks). + +**Agent A/B** (headless `claude -p`, Opus 4.8, `--strict-mcp-config`, codegraph +the only variable — `scripts/agent-eval/run-all.sh`): + +| Repo | n | Arm | Read | Grep/Bash | explore | duration | cost | +|---|---|---|---|---|---|---|---| +| ebikes-lwc | 2 | WITH | 0 | 0 | 1–2 | 27–30s | $0.34–0.53 | +| ebikes-lwc | 2 | WITHOUT | 5–9 | 10–16 | — | 61–90s | $0.22–0.28 | +| apex-recipes | 1 | WITH | 0 | 0 | 1 | 33s | $0.31 | +| apex-recipes | 1 | WITHOUT | 4 | 6 | — | 47s | $0.36 | + +Question per repo is the LWC→Apex flow (e.g. "how does orderBuilder reach the +Apex OrderController"). In every run a **single `codegraph_explore` answers with +zero file reads** and ~30–50% less wall-clock — the agent rides the cross-layer +edges this feature adds. Cost is mixed: higher on the small repo (Opus 4.8's +native Read/Grep baseline is lean there), lower on the medium repo (the +without-arm thrashes more). Corpus entries for `/agent-eval` are in +`.claude/skills/agent-eval/corpus.json` under "Salesforce". + +## Out of scope (follow-ups) + +- `@salesforce/schema/Object.Field` → SObject (no SObject nodes exist yet). +- Apex `@AuraEnabled` not queryable from the persisted graph (no `extractModifiers` + on the Apex extractor; annotations exist only as dangling `decorates` refs). +- Managed-package namespaces (`ns__Class`, `ns:comp`) — default `c` namespace only. +- Visualforce ``/`` page-to-page, + `{!ctrl.method}` merge-field bindings; Aura `` nodes. diff --git a/src/extraction/aura-extractor.ts b/src/extraction/aura-extractor.ts new file mode 100644 index 000000000..dd6c25f42 --- /dev/null +++ b/src/extraction/aura-extractor.ts @@ -0,0 +1,129 @@ +import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; + +/** + * AuraExtractor — extracts the component graph from Aura component markup + * (`.cmp`, `.app`, `.evt`, `.intf`). + * + * The bundle's `*Controller.js` / `*Helper.js` are indexed by the JavaScript + * extractor (with the Aura object-literal handler path in tree-sitter.ts). This + * markup extractor links the component to: + * + * - `` (custom `c:` namespace) → the child Aura/LWC component + * - `{!c.handleClick}` action expressions → the controller handler method + * + * Mirrors RazorExtractor/VisualforceExtractor: exactly ONE `component` node per + * file; child/handler links are EDGES, never nodes. Standard namespaces + * (`aura:`, `lightning:`, `ui:`, `force:`, `ltng:`) are framework built-ins and + * skipped. `{!v.attr}` value bindings are intra-component data flow — deferred. + * + * `` refs resolve through the Salesforce framework resolver + * (salesforce.ts); `{!c.handler}` refs are `calls` and resolve through the + * generic name-matcher (cross-language `calls` bridges aren't gated). + */ +export class AuraExtractor { + private filePath: string; + private source: string; + private nodes: Node[] = []; + private edges: Edge[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private errors: ExtractionError[] = []; + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + } + + extract(): ExtractionResult { + const startTime = Date.now(); + try { + const componentId = this.createComponentNode().id; + this.extractChildComponentTags(componentId); + this.extractControllerActions(componentId); + } catch (error) { + this.errors.push({ + message: `Aura extraction error: ${error instanceof Error ? error.message : String(error)}`, + severity: 'error', + code: 'parse_error', + }); + } + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + private createComponentNode(): Node { + const lines = this.source.split('\n'); + const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath; + const componentName = fileName.replace(/\.(cmp|app|evt|intf)$/i, ''); + const node: Node = { + id: generateNodeId(this.filePath, 'component', componentName, 1), + kind: 'component', + name: componentName, + qualifiedName: `${this.filePath}::${componentName}`, + filePath: this.filePath, + language: 'aura', + startLine: 1, + endLine: lines.length, + startColumn: 0, + endColumn: lines[lines.length - 1]?.length || 0, + isExported: true, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + private lineAt(index: number): number { + return (this.source.slice(0, index).match(/\n/g) || []).length + 1; + } + + /** + * Custom child components in the `c:` namespace (``). Closing + * tags (``) don't match (leading `/`); standard namespaces never match. + */ + private extractChildComponentTags(componentId: string): void { + const tagRe = /. +// v5: added LWC HTML template (lwc/*.html) extractor — template → component. +// v6: added Aura (.cmp/.app/.evt/.intf) extractor + Aura JS handlers + cmp.get("c.x") → Apex. +export const EXTRACTION_VERSION = 6; diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index f35e2afea..a10b13606 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; import { Language } from '../types'; -export type GrammarLanguage = Exclude; +export type GrammarLanguage = Exclude; /** * WASM filename map — maps each language to its .wasm grammar file @@ -33,6 +33,9 @@ const WASM_GRAMMAR_FILES: Record = { swift: 'tree-sitter-swift.wasm', kotlin: 'tree-sitter-kotlin.wasm', dart: 'tree-sitter-dart.wasm', + // Apex grammar isn't in tree-sitter-wasms — vendored from web-tree-sitter-sfapex + // (ABI 15, MIT). See the vendored-wasm branch in loadGrammarsForLanguages. + apex: 'tree-sitter-apex.wasm', pascal: 'tree-sitter-pascal.wasm', scala: 'tree-sitter-scala.wasm', lua: 'tree-sitter-lua.wasm', @@ -90,6 +93,20 @@ export const EXTENSION_MAP: Record = { '.kt': 'kotlin', '.kts': 'kotlin', '.dart': 'dart', + // Salesforce Apex: classes (.cls), triggers (.trigger), anonymous Apex (.apex) + '.cls': 'apex', + '.trigger': 'apex', + '.apex': 'apex', + // Visualforce pages (.page) and components (.component) — custom markup + // extractor links controller/extensions/ to Apex/components. + '.page': 'visualforce', + '.component': 'visualforce', + // Aura components (.cmp), apps (.app), events (.evt), interfaces (.intf) — + // custom markup extractor links /{!c.handler} to components/methods. + '.cmp': 'aura', + '.app': 'aura', + '.evt': 'aura', + '.intf': 'aura', '.liquid': 'liquid', '.svelte': 'svelte', '.vue': 'vue', @@ -122,6 +139,7 @@ export const EXTENSION_MAP: Record = { export function isSourceFile(filePath: string): boolean { if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless if (isShopifyLiquidJson(filePath)) return true; // Shopify OS 2.0 JSON templates / section groups + if (isLwcTemplate(filePath)) return true; // LWC bundle .html template (path-gated, not all .html) const dot = filePath.lastIndexOf('.'); if (dot < 0) return false; return filePath.slice(dot).toLowerCase() in EXTENSION_MAP; @@ -138,6 +156,15 @@ export function isShopifyLiquidJson(filePath: string): boolean { return /(^|\/)(templates|sections)\/.+\.json$/i.test(filePath); } +/** + * Lightning Web Component HTML template: `.../lwc//.html`. + * Path-gated so only LWC bundle templates are indexed — generic `.html` files + * elsewhere are not in EXTENSION_MAP and stay unindexed (no HTML hijack). + */ +export function isLwcTemplate(filePath: string): boolean { + return /(?:^|\/)lwc\/[^/]+\/[^/]+\.html$/i.test(filePath); +} + /** * Play Framework routes file: the extensionless `conf/routes` (and included * `conf/*.routes`). No grammar — route extraction is done by the Play framework @@ -201,7 +228,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise.liquid`). if (isShopifyLiquidJson(filePath)) return 'liquid'; + // LWC bundle .html templates → custom LWC template extractor (path-gated so + // generic .html stays unindexed). + if (isLwcTemplate(filePath)) return 'lwc'; const lang = EXTENSION_MAP[ext] || 'unknown'; // .h files could be C, C++, or Objective-C — check source content @@ -298,6 +328,9 @@ export function isLanguageSupported(language: Language): boolean { if (language === 'vue') return true; // custom extractor (script block delegation) if (language === 'liquid') return true; // custom regex extractor if (language === 'razor') return true; // custom RazorExtractor (.cshtml/.razor markup) + if (language === 'visualforce') return true; // custom VisualforceExtractor (.page/.component markup) + if (language === 'lwc') return true; // custom LwcTemplateExtractor (lwc/*.html templates) + if (language === 'aura') return true; // custom AuraExtractor (.cmp/.app/.evt/.intf markup) if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver if (language === 'twig') return true; // file-level tracking only if (language === 'xml') return true; // MyBatis mapper extractor @@ -310,7 +343,7 @@ export function isLanguageSupported(language: Language): boolean { * Check if a grammar has been loaded and is ready for parsing. */ export function isGrammarLoaded(language: Language): boolean { - if (language === 'svelte' || language === 'vue' || language === 'liquid' || language === 'razor') return true; + if (language === 'svelte' || language === 'vue' || language === 'liquid' || language === 'razor' || language === 'visualforce' || language === 'lwc' || language === 'aura') return true; if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed if (language === 'xml' || language === 'properties') return true; // no WASM grammar needed return languageCache.has(language); @@ -333,7 +366,7 @@ export function isFileLevelOnlyLanguage(language: Language): boolean { * Get all supported languages (those with grammar definitions). */ export function getSupportedLanguages(): Language[] { - return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'liquid']; + return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'liquid', 'visualforce', 'lwc', 'aura']; } /** @@ -397,6 +430,10 @@ export function getLanguageDisplayName(language: Language): string { swift: 'Swift', kotlin: 'Kotlin', dart: 'Dart', + apex: 'Apex', + visualforce: 'Visualforce', + lwc: 'LWC', + aura: 'Aura', svelte: 'Svelte', vue: 'Vue', liquid: 'Liquid', diff --git a/src/extraction/languages/apex.ts b/src/extraction/languages/apex.ts new file mode 100644 index 000000000..f45cd2817 --- /dev/null +++ b/src/extraction/languages/apex.ts @@ -0,0 +1,80 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +/** + * Salesforce Apex. + * + * The tree-sitter-sfapex grammar is modeled on tree-sitter-java, so the node + * types are nearly identical (class_declaration, method_declaration, superclass, + * interfaces, enum_constant, ...). This extractor mirrors java.ts; the only + * Apex-specific addition is `trigger_declaration` — a top-level executable that + * we treat as a function so its body's method_invocation calls (trigger → + * handler class) are captured as `calls` edges. + * + * Apex has no import/package statements (all classes are globally visible by + * name), so there's no importTypes/extractPackage. Annotations (@AuraEnabled, + * @RemoteAction, @InvocableMethod, @isTest) live in a `modifiers` node and are + * picked up generically as `decorates` references by the core extractor. + */ +export const apexExtractor: LanguageExtractor = { + // Triggers are top-level executables (not inside a class) → function nodes, + // body visited for handler calls. + functionTypes: ['trigger_declaration'], + classTypes: ['class_declaration'], + methodTypes: ['method_declaration', 'constructor_declaration'], + interfaceTypes: ['interface_declaration'], + structTypes: [], + enumTypes: ['enum_declaration'], + enumMemberTypes: ['enum_constant'], + typeAliasTypes: [], + importTypes: [], + callTypes: ['method_invocation'], + variableTypes: ['local_variable_declaration'], + // Apex properties are `field_declaration`s carrying an `accessor_list`; the + // grammar has no dedicated property node, so both fields and properties are + // extracted as fields. + fieldTypes: ['field_declaration'], + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + returnField: 'type', + getSignature: (node, source) => { + // trigger AccountTrigger on Account (before insert, after update) + if (node.type === 'trigger_declaration') { + const object = getChildByField(node, 'object'); + const events = node.namedChildren + .filter((c: SyntaxNode) => c.type === 'trigger_event') + .map((c: SyntaxNode) => getNodeText(c, source).trim()); + const on = object ? `on ${getNodeText(object, source)}` : ''; + return events.length ? `${on} (${events.join(', ')})`.trim() : on || undefined; + } + const params = getChildByField(node, 'parameters'); + const returnType = getChildByField(node, 'type'); + if (!params) return undefined; + const paramsText = getNodeText(params, source); + return returnType ? getNodeText(returnType, source) + ' ' + paramsText : paramsText; + }, + getVisibility: (node) => { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.type === 'modifiers') { + const text = child.text; + // `global` is broader than `public` (cross-namespace) but maps to it here. + if (text.includes('public') || text.includes('global')) return 'public'; + if (text.includes('private')) return 'private'; + if (text.includes('protected')) return 'protected'; + } + } + return undefined; + }, + isStatic: (node) => { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.type === 'modifiers' && child.text.includes('static')) { + return true; + } + } + return false; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 543598b8e..292f277c6 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -21,6 +21,7 @@ import { rubyExtractor } from './ruby'; import { swiftExtractor } from './swift'; import { kotlinExtractor } from './kotlin'; import { dartExtractor } from './dart'; +import { apexExtractor } from './apex'; import { pascalExtractor } from './pascal'; import { scalaExtractor } from './scala'; import { luaExtractor } from './lua'; @@ -44,6 +45,7 @@ export const EXTRACTORS: Partial> = { swift: swiftExtractor, kotlin: kotlinExtractor, dart: dartExtractor, + apex: apexExtractor, pascal: pascalExtractor, scala: scalaExtractor, lua: luaExtractor, diff --git a/src/extraction/lwc-template-extractor.ts b/src/extraction/lwc-template-extractor.ts new file mode 100644 index 000000000..b21929105 --- /dev/null +++ b/src/extraction/lwc-template-extractor.ts @@ -0,0 +1,113 @@ +import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; + +/** + * LwcTemplateExtractor — extracts the component graph from a Lightning Web + * Component HTML template (`.../lwc//.html`). + * + * The component's `.js` is already indexed by the JavaScript extractor; the + * template adds the child-component usages that the JS never names: + * + * - `` (custom `c-` namespace) → the child LWC component class + * + * The kebab-case tag (`c-acct-tile`) maps to the child's PascalCase class + * (`AcctTile`) by Lightning's naming convention; resolution happens through the + * Salesforce framework resolver (salesforce.ts). + * + * Scope: only the `c-` namespace is captured. Base components (`lightning-*`, + * `lightning/*`) are framework built-ins not defined in-repo. Template member + * bindings (`{getter}`, `onclick={handler}`) are intra-bundle and lower value — + * deferred (the `.js` already carries those members). + */ +export class LwcTemplateExtractor { + private filePath: string; + private source: string; + private nodes: Node[] = []; + private edges: Edge[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private errors: ExtractionError[] = []; + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + } + + extract(): ExtractionResult { + const startTime = Date.now(); + try { + const componentId = this.createComponentNode().id; + this.extractChildComponentTags(componentId); + } catch (error) { + this.errors.push({ + message: `LWC template extraction error: ${error instanceof Error ? error.message : String(error)}`, + severity: 'error', + code: 'parse_error', + }); + } + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + private createComponentNode(): Node { + const lines = this.source.split('\n'); + const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath; + const componentName = fileName.replace(/\.html$/i, ''); + const node: Node = { + id: generateNodeId(this.filePath, 'component', componentName, 1), + kind: 'component', + name: componentName, + qualifiedName: `${this.filePath}::${componentName}`, + filePath: this.filePath, + language: 'lwc', + startLine: 1, + endLine: lines.length, + startColumn: 0, + endColumn: lines[lines.length - 1]?.length || 0, + isExported: true, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + /** `acct-tile` → `AcctTile` (Lightning kebab → PascalCase class convention). */ + private pascalize(kebab: string): string { + return kebab + .split('-') + .filter(Boolean) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); + } + + private lineAt(index: number): number { + return (this.source.slice(0, index).match(/\n/g) || []).length + 1; + } + + /** + * Custom child components in the `c-` namespace (``). Closing tags + * (``) don't match because of the leading `/`. Base components + * (`lightning-…`) start with a different prefix and never match. + */ + private extractChildComponentTags(componentId: string): void { + const tagRe = / | null = null; // lookup key → node ID for Pascal defProc lookup + // Aura controller/helper/renderer JS: a bare `({ handler: function(){} })` + // object literal whose members are handlers. Path-gated so only aura/ bundle + // files get the object-literal handler treatment (no effect on other JS). + private isAuraComponentJs: boolean; constructor(filePath: string, source: string, language?: Language) { this.filePath = filePath; this.source = source; this.language = language || detectLanguage(filePath, source); this.extractor = EXTRACTORS[this.language] || null; + this.isAuraComponentJs = /(^|\/)aura\/[^/]+\/[^/]+(Controller|Helper|Renderer)\.js$/i.test(filePath); } /** @@ -360,6 +368,21 @@ export class TreeSitterExtractor { if (handled) return; } + // Aura controller/helper/renderer: a top-level `({ handler: function(){} })` + // expression statement. The generic object-method gate only covers + // `export const x = {...}`; Aura uses a bare parenthesized object, so handle + // it here and extract each member as a handler node (path-gated to aura/ + // bundle files — no effect on other JS). Returns to skip the default walk so + // handler-body calls attribute to the handler, not the file. + if (this.isAuraComponentJs && nodeType === 'expression_statement') { + const paren = node.namedChild(0); + const obj = paren?.type === 'parenthesized_expression' ? paren.namedChild(0) : null; + if (obj && (obj.type === 'object' || obj.type === 'object_expression')) { + this.extractObjectLiteralFunctions(obj); + return; + } + } + // Pascal-specific AST handling if (this.language === 'pascal') { skipChildren = this.visitPascalNode(node); @@ -2402,6 +2425,40 @@ export class TreeSitterExtractor { column: node.startPosition.column, }); } + + // Aura: `cmp.get("c.apexMethod")` invokes a server-side @AuraEnabled Apex + // method by string name. Emit an extra bare `calls` ref so the cross-language + // name-matcher links it to the Apex method (the `c.` namespace = Apex + // controller; `v.` = view attribute, skipped). + if (this.isAuraComponentJs) { + this.extractAuraApexCall(node, callerId); + } + } + + /** + * Detect `cmp.get("c.method")` / `component.get("c.method")` / + * `cmp.getReference("c.method")` and emit a `calls` reference to the bare Apex + * method name. Only the `c.` (Apex controller) namespace is linked. + */ + private extractAuraApexCall(node: SyntaxNode, callerId: string): void { + const func = getChildByField(node, 'function'); + if (!func || func.type !== 'member_expression') return; + const prop = getChildByField(func, 'property'); + const propName = prop ? getNodeText(prop, this.source) : ''; + if (propName !== 'get' && propName !== 'getReference') return; + const args = getChildByField(node, 'arguments'); + const firstArg = args?.namedChild(0); + if (!firstArg || firstArg.type !== 'string') return; + const literal = getNodeText(firstArg, this.source).replace(/^['"`]|['"`]$/g, ''); + const m = literal.match(/^c\.([A-Za-z_]\w*)$/); + if (!m || !m[1]) return; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: m[1], + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); } /** @@ -4121,6 +4178,18 @@ export function extractFromSource( // Use custom extractor for ASP.NET Razor (.cshtml) / Blazor (.razor) markup const extractor = new RazorExtractor(filePath, source); result = extractor.extract(); + } else if (detectedLanguage === 'visualforce') { + // Custom extractor for Visualforce (.page) / VF component (.component) markup + const extractor = new VisualforceExtractor(filePath, source); + result = extractor.extract(); + } else if (detectedLanguage === 'lwc') { + // Custom extractor for LWC HTML templates (lwc//*.html) + const extractor = new LwcTemplateExtractor(filePath, source); + result = extractor.extract(); + } else if (detectedLanguage === 'aura') { + // Custom extractor for Aura component markup (.cmp/.app/.evt/.intf) + const extractor = new AuraExtractor(filePath, source); + result = extractor.extract(); } else if (detectedLanguage === 'xml') { // Custom extractor for MyBatis mapper XML. Non-mapper XML returns just a // file node so the watcher tracks it without emitting symbols. diff --git a/src/extraction/visualforce-extractor.ts b/src/extraction/visualforce-extractor.ts new file mode 100644 index 000000000..1f3959105 --- /dev/null +++ b/src/extraction/visualforce-extractor.ts @@ -0,0 +1,138 @@ +import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; + +/** + * VisualforceExtractor — extracts code relationships from Salesforce Visualforce + * (`.page`) and Visualforce component (`.component`) markup. + * + * Visualforce markup references Apex backing classes and custom components that + * the engine otherwise never parses, so those Apex classes look like nothing + * depends on them. This extractor links the markup → the symbols it names, + * mirroring RazorExtractor (markup → C# type): + * + * - `` → the Apex controller class + * - `extensions="ExtA,ExtB"` → each Apex extension class + * - `` (custom-namespace component) → the Visualforce/LWC component + * + * Risk mitigations: + * - Exactly ONE `component` node per file; controller/extension/component refs + * become `references` EDGES, never nodes — no per-tag node explosion. + * - `standardController="Account"` is intentionally SKIPPED — it names an + * SObject, not an Apex class, and would mis-link to a same-named class. + * - Only the custom `c:` namespace is captured; standard namespaces (`apex:`, + * `lightning:`, `chatter:`, …) are framework built-ins not defined in-repo, + * so a ref would just dangle. + * - Refs resolve through the Salesforce framework resolver (salesforce.ts), + * which keeps the cross-language edge (Apex is its own language family, so the + * framework gate never drops it). + */ +export class VisualforceExtractor { + private filePath: string; + private source: string; + private nodes: Node[] = []; + private edges: Edge[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private errors: ExtractionError[] = []; + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + } + + extract(): ExtractionResult { + const startTime = Date.now(); + try { + const componentId = this.createComponentNode().id; + this.extractControllerRefs(componentId); + this.extractCustomComponentTags(componentId); + } catch (error) { + this.errors.push({ + message: `Visualforce extraction error: ${error instanceof Error ? error.message : String(error)}`, + severity: 'error', + code: 'parse_error', + }); + } + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + private createComponentNode(): Node { + const lines = this.source.split('\n'); + const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath; + const componentName = fileName.replace(/\.(page|component)$/i, ''); + const node: Node = { + id: generateNodeId(this.filePath, 'component', componentName, 1), + kind: 'component', + name: componentName, + qualifiedName: `${this.filePath}::${componentName}`, + filePath: this.filePath, + language: 'visualforce', + startLine: 1, + endLine: lines.length, + startColumn: 0, + endColumn: lines[lines.length - 1]?.length || 0, + isExported: true, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + /** 0-indexed source offset → 1-indexed line number. */ + private lineAt(index: number): number { + return (this.source.slice(0, index).match(/\n/g) || []).length + 1; + } + + private pushRef(componentId: string, name: string, line: number): void { + this.unresolvedReferences.push({ + fromNodeId: componentId, + referenceName: name, + referenceKind: 'references', + line, + column: 0, + filePath: this.filePath, + language: 'visualforce', + }); + } + + /** + * `controller="X"` (an Apex class) and `extensions="A,B"` (comma-separated Apex + * classes). `standardController` is deliberately not matched — `(?:^|\s)` before + * `controller` ensures the `Controller` inside `standardController` never matches. + */ + private extractControllerRefs(componentId: string): void { + const ctrl = this.source.match(/(?:^|\s)controller\s*=\s*"([^"]+)"/i); + if (ctrl && ctrl[1]) { + const name = ctrl[1].trim(); + if (name) this.pushRef(componentId, name, this.lineAt(ctrl.index ?? 0)); + } + const ext = this.source.match(/(?:^|\s)extensions\s*=\s*"([^"]+)"/i); + if (ext && ext[1]) { + const line = this.lineAt(ext.index ?? 0); + // Comma list; cap at 5 to bound pathological input. + for (const raw of ext[1].split(',').slice(0, 5)) { + const name = raw.trim(); + if (name) this.pushRef(componentId, name, line); + } + } + } + + /** + * Custom component tags in the default `c:` namespace (``). Standard + * namespaces (`apex:`, `lightning:`, etc.) are skipped. Closing tags (``) + * don't match because of the leading `/`. + */ + private extractCustomComponentTags(componentId: string): void { + const tagRe = /` names a Visualforce or LWC component. Class is tried + * first (controller refs), then component (custom-tag refs) — names rarely + * collide across the two. + */ +function resolveVisualforceRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceKind !== 'references') return null; + const named = context.getNodesByName(ref.referenceName); + const apexClass = named.find((n) => n.kind === 'class' && n.language === 'apex'); + const component = named.find((n) => n.kind === 'component'); + const target = apexClass ?? component; + if (!target) return null; + return { + original: ref, + targetNodeId: target.id, + confidence: 0.9, + resolvedBy: 'framework', + }; +} + +/** + * Resolve an LWC template `` reference. The extractor pascalized the + * kebab tag (`c-acct-tile` → `AcctTile`); the child component is its `.js` + * default-export class (Lightning convention names it after the bundle) or a + * component node. Same-bundle proximity isn't needed — child names are unique. + */ +function resolveLwcTemplateRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceKind !== 'references') return null; + const named = context.getNodesByName(ref.referenceName); + const childClass = named.find( + (n) => n.kind === 'class' && (n.language === 'javascript' || n.language === 'typescript') && /(^|\/)lwc\//.test(n.filePath) + ); + const component = named.find((n) => n.kind === 'component'); + const target = childClass ?? component; + if (!target) return null; + return { + original: ref, + targetNodeId: target.id, + confidence: 0.9, + resolvedBy: 'framework', + }; +} + +/** + * Resolve an Aura controller's server call to Apex. An Aura JS controller + * (`aura//Controller|Helper|Renderer.js`) invokes a server + * method as `cmp.get("c.method")`; the extractor emits a bare `method` `calls` + * ref. The file is plain `javascript`, so the generic name-matcher's + * cross-family `calls` gate would (correctly, for coincidental collisions) drop + * it — but this IS a deliberate cross-layer dispatch, so the framework resolver + * binds it to the same-named Apex method here (Strategy 1, ungated). Scoped to + * the `aura/` bundle path so ordinary app JS never reaches it. + */ +function resolveAuraApexCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; + if (ref.language !== 'javascript' && ref.language !== 'typescript') return null; + if (!/(^|\/)aura\/[^/]+\/[^/]+\.js$/i.test(ref.filePath)) return null; + const target = context + .getNodesByName(ref.referenceName) + .find((n) => n.kind === 'method' && n.language === 'apex'); + if (!target) return null; + return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'framework' }; +} + +export const salesforceResolver: FrameworkResolver = { + name: 'salesforce', + languages: ['javascript', 'typescript', 'visualforce', 'lwc', 'aura'], + + detect(context: ResolutionContext): boolean { + // Salesforce DX project: Apex classes present, or an lwc/aura bundle dir. + const files = context.getAllFiles(); + return files.some( + (f) => f.endsWith('.cls') || f.endsWith('.page') || f.endsWith('.component') || /(^|\/)(lwc|aura)\//.test(f) + ); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Visualforce markup → Apex controller/extension class or custom component. + // Resolved through the framework path so the cross-language edge survives the + // gate (Apex is its own language family — `gateFrameworkLanguage` keeps it). + if (ref.language === 'visualforce') { + return resolveVisualforceRef(ref, context); + } + + // LWC HTML template → child LWC component. + if (ref.language === 'lwc') { + return resolveLwcTemplateRef(ref, context); + } + + // Aura markup (references) → child component. The {!c.handler} + // `calls` refs resolve through the generic name-matcher (calls aren't gated). + if (ref.language === 'aura') { + if (ref.referenceKind !== 'references') return null; + const target = context.getNodesByName(ref.referenceName).find((n) => n.kind === 'component'); + if (!target) return null; + return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'framework' }; + } + + // Aura controller JS `cmp.get("c.method")` → same-named Apex method. + const auraCall = resolveAuraApexCall(ref, context); + if (auraCall) return auraCall; + + // LWC/Aura JS: only the import binding and its call sites link to Apex. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'imports') return null; + + // Find the `@salesforce/apex/...` specifier this reference binds to: + // either the import-source ref itself, or a call/binding whose local name + // maps to such an import in this file. + let apexSource: string | null = null; + if (ref.referenceName.startsWith(APEX_PREFIX)) { + apexSource = ref.referenceName; + } else { + const mappings = context.getImportMappings(ref.filePath, ref.language); + const match = mappings.find( + (im) => im.localName === ref.referenceName && im.source.startsWith(APEX_PREFIX) + ); + if (match) apexSource = match.source; + } + if (!apexSource) return null; + + const qualifiedName = parseApexQualifiedName(apexSource); + if (!qualifiedName) return null; + + const target = context + .getNodesByQualifiedName(qualifiedName) + .find((n) => n.kind === 'method'); + if (!target) return null; + + return { + original: ref, + targetNodeId: target.id, + confidence: 0.95, + resolvedBy: 'framework', + }; + }, +}; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d6bce5659..35b1d6436 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -134,6 +134,29 @@ export function sameLanguageFamily(a: string, b: string): boolean { export function isKnownLanguageFamily(lang: string): boolean { return LANGUAGE_FAMILY[lang] !== undefined; } +/** + * Singleton-family languages that are nonetheless self-sufficient, + * general-purpose PROGRAMMING languages (their own runtime + builtins). Like + * the known-family ones, they never invoke another language by a bare + * coincidental name — a real FFI/cross-layer call goes through a framework or + * import resolver. They are NOT in {@link LANGUAGE_FAMILY} (no sibling shares + * their runtime), so this set carries the same "self-sufficient caller" signal + * for the cross-family `calls` gate. Markup/template/config languages + * (visualforce/lwc/aura/svelte/vue/liquid/twig/xml/yaml/…) are deliberately + * excluded — they legitimately dispatch to code in another language by name + * (config↔code bridges), so they stay ungated. + */ +const STANDALONE_CODE_LANGUAGES = new Set([ + 'python', 'go', 'rust', 'php', 'ruby', 'dart', 'lua', 'luau', 'pascal', +]); +/** + * True when a CALLER in `lang` is a self-sufficient programming language — + * either a known multi-language family member or a standalone language above. + * Such a caller's bare cross-family `calls` are name collisions, not calls. + */ +export function isSelfSufficientCaller(lang: string): boolean { + return isKnownLanguageFamily(lang) || STANDALONE_CODE_LANGUAGES.has(lang); +} /** * True when `a` and `b` are two DIFFERENT *known* language families — the * signature of a coincidental cross-language name collision (a TS `import @@ -148,7 +171,7 @@ export function crossesKnownFamily(a: string, b: string): boolean { return isKnownLanguageFamily(a) && isKnownLanguageFamily(b) && !sameLanguageFamily(a, b); } /** - * Drop cross-language candidates from a name lookup. Two regimes: + * Drop cross-language candidates from a name lookup. Three regimes: * - `references` (type-usage): a type named in language X resolves to a * SAME-family type, never a coincidentally same-named symbol in another * language (the Android `BatteryManager` system class vs a JS one). Strict @@ -156,6 +179,17 @@ export function crossesKnownFamily(a: string, b: string): boolean { * - `imports` (import binding): an `import`/`#include` never crosses two * KNOWN families (TS `import React` ↮ Swift `import React`). Weaker * both-known filter so `.vue`/`.svelte` (own tag) importing `.ts` survives. + * - `calls`: left ungated for callers in a MARKUP/TEMPLATE/CONFIG language + * (`aura`/`visualforce`/`liquid`/`yaml` → code, and other config↔code + * bridges) so genuine cross-layer calls with no same-language target survive. + * But a caller in a self-sufficient PROGRAMMING language (js/ts, java/kotlin, + * swift/objc, c/cpp, and standalone python/go/rust/php/ruby/dart/lua/pascal) + * has its own runtime + builtins: a real cross-family call from it goes + * through a framework or import resolver, never a bare coincidental name. So + * for those callers we keep only same-family candidates — otherwise a JS + * `String.replace()` (or a Python `str.replace()`) binds to an Apex + * `CurrencyTokenReplacer::replace`, a JS `.resolve()` to an Apex `…::resolve`, + * etc. (a name collision, not a call). */ function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] { if (ref.referenceKind === 'references') { @@ -164,6 +198,9 @@ function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] { if (ref.referenceKind === 'imports') { return candidates.filter((c) => !crossesKnownFamily(c.language, ref.language)); } + if (ref.referenceKind === 'calls' && isSelfSufficientCaller(ref.language)) { + return candidates.filter((c) => sameLanguageFamily(c.language, ref.language)); + } return candidates; } diff --git a/src/types.ts b/src/types.ts index 01aadae02..b0786238e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,6 +81,10 @@ export const LANGUAGES = [ 'swift', 'kotlin', 'dart', + 'apex', + 'visualforce', + 'lwc', + 'aura', 'svelte', 'vue', 'liquid',