From d33c516b4095ef55abe8e2bdb8973fdee0ce5e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20W=C3=B3jt?= Date: Fri, 10 Jul 2026 10:03:15 +0200 Subject: [PATCH 1/2] Expand Salesforce metadata and frontend indexing --- CHANGELOG.md | 4 + src/hooks_bash.ts | 2 +- src/hooks_read.ts | 5 +- src/languages/index.ts | 1 + src/languages/salesforce_frontend.ts | 212 +++++++++++++++++ src/languages/salesforce_metadata.ts | 223 ++++++++++++++++-- src/parser.ts | 57 ++++- src/parser_types.ts | 36 +-- .../default/classes/AccountController.cls | 6 + .../classes/AccountController.cls-meta.xml | 5 + .../flows/Account_Orchestrator.flow-meta.xml | 22 ++ .../default/lwc/accountCard/accountCard.html | 4 + .../default/lwc/accountCard/accountCard.js | 12 + .../lwc/accountCard/accountCard.js-meta.xml | 13 + .../OddThing.mysteryType-meta.xml | 5 + .../recordTypes/Business.recordType-meta.xml | 6 + tests/hooks_bash.test.ts | 14 ++ tests/hooks_read.test.ts | 19 ++ tests/parser_types.test.ts | 29 ++- tests/salesforce_frontend.test.ts | 159 +++++++++++++ tests/salesforce_metadata_adapter.test.ts | 151 ++++++++++++ tests/salesforce_worker_e2e.test.ts | 166 +++++++++++++ tests/worker_index_e2e.test.ts | 24 +- 23 files changed, 1126 insertions(+), 49 deletions(-) create mode 100644 src/languages/salesforce_frontend.ts create mode 100644 tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls create mode 100644 tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls-meta.xml create mode 100644 tests/fixtures/salesforce-dx/force-app/main/default/flows/Account_Orchestrator.flow-meta.xml create mode 100644 tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.html create mode 100644 tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js create mode 100644 tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js-meta.xml create mode 100644 tests/fixtures/salesforce-dx/force-app/main/default/mysteryTypes/OddThing.mysteryType-meta.xml create mode 100644 tests/fixtures/salesforce-dx/force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml create mode 100644 tests/salesforce_frontend.test.ts create mode 100644 tests/salesforce_metadata_adapter.test.ts create mode 100644 tests/salesforce_worker_e2e.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ea83af..33689f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Token-Goat are documented in this file. Format follows Ke ## [Unreleased] +### Added + +- **Layered Salesforce DX indexing for metadata and frontend bundles.** Every `*-meta.xml` file now receives a stable top-level symbol, while Apex, Flow, object-member metadata, Custom Labels, LWC, Aura, and Visualforce receive detailed symbols and selected cross-file references. LWC indexing understands public `@api` members, Salesforce imports, template handlers/child components, bundle targets, and target properties; Aura/Visualforce markup now has a dedicated `salesforce_markup` adapter. The real default worker path and built bundle are covered by a Salesforce DX fixture so these adapters cannot silently disappear from the shipped artifact. + ## [2.6.12] - 2026-07-10 ### Fixed diff --git a/src/hooks_bash.ts b/src/hooks_bash.ts index c5359769..e1b8afcf 100644 --- a/src/hooks_bash.ts +++ b/src/hooks_bash.ts @@ -439,7 +439,7 @@ function extractSedRange(cmd: string): { filePath: string; ranges: Array = new Set([ - 'python', 'typescript', 'javascript', 'rust', 'go', 'c', 'cpp', 'ruby', 'java', 'csharp', 'php', 'kotlin', 'sql', 'graphql', 'proto', 'bash', 'powershell', + 'python', 'typescript', 'javascript', 'rust', 'go', 'c', 'cpp', 'ruby', 'java', 'csharp', 'php', 'kotlin', 'sql', 'graphql', 'proto', 'bash', 'powershell', 'apex', 'salesforce_metadata', 'salesforce_markup', ]) // Builds the recall hint for a `sed -n 'N,Mp' file` read, tailored to the file's language: Markdown -> section by heading; structured config -> config-get/section; source code -> symbol read (robust to line shifts); everything else -> the exact line range. diff --git a/src/hooks_read.ts b/src/hooks_read.ts index 67f04a3e..7778bef8 100644 --- a/src/hooks_read.ts +++ b/src/hooks_read.ts @@ -43,6 +43,7 @@ import { isImagePath } from './image_shrink.js' import { compactPathFor, isCompactFresh, readCompactBody } from './doc_compact.js' import { getOrCreateSidecar, NB_STRIP_MIN_SAVINGS } from './notebook_compact.js' import { dataDir } from './constants.js' +import { detectLanguage } from './parser_types.js' /** True when `basename` is a tsconfig or jsconfig file. */ function isTsConfigFile(basename: string): boolean { @@ -286,7 +287,9 @@ const SOURCE_EXT_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|pyi|go|rs|java|rb|php|kt|kts|cpp|cc|cxx|hpp|hxx|c|h|cs)$/i function isSourceExtension(basename: string): boolean { - return SOURCE_EXT_RE.test(basename) + if (SOURCE_EXT_RE.test(basename)) return true + const language = detectLanguage(basename) + return language === 'apex' || language === 'salesforce_metadata' || language === 'salesforce_markup' } // Extensions dispatchFileTypeHandler() (hints/file_type_handler.ts) recognizes and gives diff --git a/src/languages/index.ts b/src/languages/index.ts index 16591742..c1de3696 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -22,3 +22,4 @@ export * from './powershell_idx.js' export * from './env_idx.js' export * from './apex.js' export * from './salesforce_metadata.js' +export * from './salesforce_frontend.js' diff --git a/src/languages/salesforce_frontend.ts b/src/languages/salesforce_frontend.ts new file mode 100644 index 00000000..9dff962f --- /dev/null +++ b/src/languages/salesforce_frontend.ts @@ -0,0 +1,212 @@ +import * as path from 'node:path' + +import type { RefEntry, SymbolEntry } from '../parser_types.js' + +export interface SalesforceFrontendResult { + readonly symbols: SymbolEntry[] + readonly refs: RefEntry[] +} + +function lines(content: string): string[] { + return content.split('\n') +} + +function bundleName(filePath: string): string { + const normalized = filePath.replaceAll('\\', '/') + const parent = path.posix.basename(path.posix.dirname(normalized)) + const base = path.posix.basename(normalized).replace(/\.[^.]+$/, '') + return parent === 'lwc' || parent === 'aura' ? base : parent +} + +function lwcTagAlias(name: string): string { + const kebab = name + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .replace(/([a-z\d])([A-Z])/g, '$1-$2') + .toLowerCase() + return `c-${kebab}` +} + +function symbol(filePath: string, name: string, kind: string, lineStart: number, lineEnd = lineStart): SymbolEntry { + return { filePath, name, kind, lineStart, lineEnd, body: '', docstring: '' } +} + +function ref(filePath: string, name: string, line: number, col: number, context: string): RefEntry { + return { filePath, name, line, col, context } +} + +function dedupe(values: T[], key: (value: T) => string): T[] { + const seen = new Set() + return values.filter((value) => { + const id = key(value) + if (seen.has(id)) return false + seen.add(id) + return true + }) +} + +export function extractLwcJavaScript(content: string, filePath: string): SalesforceFrontendResult { + const sourceLines = lines(content) + const bundle = bundleName(filePath) + const symbols: SymbolEntry[] = [ + symbol(filePath, bundle, 'lwc_bundle', 1, sourceLines.length), + symbol(filePath, lwcTagAlias(bundle), 'lwc_component_alias', 1, sourceLines.length), + ] + const refs: RefEntry[] = [] + + const apiRe = /@api\s*(?:\r?\n\s*)?(?:(get|set)\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*(\()?/g + for (const match of content.matchAll(apiRe)) { + const before = content.slice(0, match.index ?? 0) + const line = before.split('\n').length + const kind = match[3] && !match[1] ? 'lwc_api_method' : 'lwc_api_property' + symbols.push(symbol(filePath, match[2] ?? '', kind, line)) + } + + const importRe = /from\s+['"]@salesforce\/(apex|schema|label|resourceUrl|messageChannel|customPermission|userPermission)\/([^'"]+)['"]/g + for (const match of content.matchAll(importRe)) { + const offset = match.index ?? 0 + const line = content.slice(0, offset).split('\n').length + const context = sourceLines[line - 1]?.trim() ?? '' + const target = match[2] ?? '' + const targetOffset = offset + (match[0]?.indexOf(target) ?? 0) + const col = targetOffset - (content.lastIndexOf('\n', targetOffset) + 1) + if (match[1] === 'apex') { + const className = target.split('.')[0] ?? target + refs.push(ref(filePath, className, line, col, context)) + } + refs.push(ref(filePath, target, line, col, context)) + } + + return { + symbols: dedupe(symbols, (entry) => `${entry.name}\0${entry.kind}\0${entry.lineStart}`), + refs: dedupe(refs, (entry) => `${entry.filePath}\0${entry.name}\0${entry.line}\0${entry.col}`), + } +} + +function matchLine(content: string, offset: number): number { + return content.slice(0, offset).split('\n').length +} + +function lineContext(content: string, line: number): string { + return lines(content)[line - 1]?.trim() ?? '' +} + +export function extractLwcTemplate(content: string, filePath: string): SalesforceFrontendResult { + const symbols: SymbolEntry[] = [] + const refs: RefEntry[] = [] + + for (const match of content.matchAll(/\blwc:ref\s*=\s*["']([^"']+)["']/gi)) { + const line = matchLine(content, match.index ?? 0) + symbols.push(symbol(filePath, match[1] ?? '', 'lwc_ref', line)) + } + for (const match of content.matchAll(/\bid\s*=\s*["']([^"'{}:]+)["']/gi)) { + const line = matchLine(content, match.index ?? 0) + symbols.push(symbol(filePath, match[1] ?? '', 'lwc_id', line)) + } + for (const match of content.matchAll(/\bon[a-z][\w-]*\s*=\s*\{\s*([A-Za-z_$][\w$]*)\s*\}/gi)) { + const offset = match.index ?? 0 + const line = matchLine(content, offset) + refs.push(ref(filePath, match[1] ?? '', line, 0, lineContext(content, line))) + } + for (const match of content.matchAll(/<\s*(c-[a-z][\w-]*)\b/gi)) { + const offset = match.index ?? 0 + const line = matchLine(content, offset) + refs.push(ref(filePath, (match[1] ?? '').toLowerCase(), line, 0, lineContext(content, line))) + } + + return { + symbols: dedupe(symbols, (entry) => `${entry.name}\0${entry.kind}\0${entry.lineStart}`), + refs: dedupe(refs, (entry) => `${entry.filePath}\0${entry.name}\0${entry.line}\0${entry.col}`), + } +} + +export const SALESFORCE_MARKUP_EXTENSIONS = new Set([ + '.cmp', '.app', '.evt', '.intf', '.design', '.auradoc', '.tokens', '.page', '.component', '.email', +]) + +const MARKUP_KIND: Readonly> = { + '.cmp': 'aura_bundle', + '.app': 'aura_application', + '.evt': 'aura_event_bundle', + '.intf': 'aura_interface', + '.design': 'aura_design', + '.auradoc': 'aura_documentation', + '.tokens': 'aura_tokens', + '.page': 'visualforce_page', + '.component': 'visualforce_component', + '.email': 'visualforce_email_template', +} + +function markupArtifactName(filePath: string, extension: string): string { + if (extension === '.page' || extension === '.component' || extension === '.email') { + return path.posix.basename(filePath.replaceAll('\\', '/')).replace(new RegExp(`${extension.replace('.', '\\.')}$`, 'i'), '') + } + return bundleName(filePath) +} + +function addAttributeSymbols( + symbols: SymbolEntry[], + content: string, + filePath: string, + tag: string, + kind: string, +): void { + const tagRe = new RegExp(`<\\s*${tag}\\b[^>]*\\bname\\s*=\\s*["']([^"']+)["'][^>]*>`, 'gi') + for (const match of content.matchAll(tagRe)) { + symbols.push(symbol(filePath, match[1] ?? '', kind, matchLine(content, match.index ?? 0))) + } +} + +function attributeRefs( + refs: RefEntry[], + content: string, + filePath: string, + attribute: string, + split = false, +): void { + const attributeRe = new RegExp(`\\b${attribute}\\s*=\\s*["']([^"']+)["']`, 'gi') + for (const match of content.matchAll(attributeRe)) { + const line = matchLine(content, match.index ?? 0) + const values = split ? (match[1] ?? '').split(',').map((value) => value.trim()).filter(Boolean) : [match[1] ?? ''] + for (const value of values) refs.push(ref(filePath, value, line, 0, lineContext(content, line))) + } +} + +export function extractSalesforceMarkup(content: string, filePath: string): SalesforceFrontendResult { + const normalized = filePath.replaceAll('\\', '/') + const extension = path.posix.extname(normalized).toLowerCase() + const sourceLines = lines(content) + const kind = MARKUP_KIND[extension] ?? 'salesforce_markup' + const symbols: SymbolEntry[] = [ + symbol(filePath, markupArtifactName(normalized, extension), kind, 1, sourceLines.length), + ] + const refs: RefEntry[] = [] + const isAura = ['.cmp', '.app', '.evt', '.intf', '.design', '.auradoc', '.tokens'].includes(extension) + + if (isAura) { + addAttributeSymbols(symbols, content, filePath, 'aura:attribute', 'aura_attribute') + addAttributeSymbols(symbols, content, filePath, 'aura:handler', 'aura_handler') + addAttributeSymbols(symbols, content, filePath, 'aura:registerEvent', 'aura_event') + addAttributeSymbols(symbols, content, filePath, 'design:attribute', 'aura_design_attribute') + } + + attributeRefs(refs, content, filePath, 'controller') + attributeRefs(refs, content, filePath, 'extensions', true) + + const actionRe = isAura + ? /\{!\s*c\.([A-Za-z_$][\w$]*)[^}]*\}/gi + : /\baction\s*=\s*["']\{!\s*(?:c\.)?([A-Za-z_$][\w$]*)[^}]*\}["']/gi + for (const match of content.matchAll(actionRe)) { + const line = matchLine(content, match.index ?? 0) + refs.push(ref(filePath, match[1] ?? '', line, 0, lineContext(content, line))) + } + + for (const match of content.matchAll(/\bc:[A-Za-z_$][\w$]*/g)) { + const line = matchLine(content, match.index ?? 0) + refs.push(ref(filePath, match[0], line, 0, lineContext(content, line))) + } + + return { + symbols: dedupe(symbols, (entry) => `${entry.name}\0${entry.kind}\0${entry.lineStart}`), + refs: dedupe(refs, (entry) => `${entry.filePath}\0${entry.name}\0${entry.line}\0${entry.col}`), + } +} diff --git a/src/languages/salesforce_metadata.ts b/src/languages/salesforce_metadata.ts index 7d97ac4f..5ca45105 100644 --- a/src/languages/salesforce_metadata.ts +++ b/src/languages/salesforce_metadata.ts @@ -1,6 +1,6 @@ import * as path from 'node:path' -import type { SymbolEntry } from '../parser_types.js' +import type { RefEntry, SymbolEntry } from '../parser_types.js' import { buildLineIndex, offsetToLine } from './common.js' const MAX_SYMBOLS = 1000 @@ -33,7 +33,10 @@ interface Span { } function xmlText(content: string, tag: string): string | null { - const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)`, 'i') + const re = new RegExp( + `<(?:[A-Za-z_][\\w.-]*:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)`, + 'i', + ) const match = re.exec(content) if (match?.[1] === undefined) return null return decodeXml(match[1].trim()) @@ -109,8 +112,90 @@ function makeSymbol( } function rootElement(content: string): string | null { - const match = /<([A-Za-z][A-Za-z0-9_]*)\b/.exec(content) - return match?.[1] ?? null + const match = /<(?!\?|!)(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)\b[^>]*>/.exec(content) + const root = match?.[1] + if (root === undefined) return null + const close = new RegExp(``, 'i') + return close.test(content) ? root : null +} + +function snakeCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase() +} + +function companionName(filePath: string): string | null { + const base = path.basename(filePath) + const match = /^(.+)\.(?:cls|trigger|page|component|cmp|app|evt|intf|design|auradoc|tokens|js)-meta\.xml$/i.exec(base) + return match?.[1] === undefined ? null : `${match[1]}.metadata` +} + +function metadataArtifactName(filePath: string): string { + const base = path.basename(filePath) + const match = /^(.+)\.[^.]+-meta\.xml$/i.exec(base) + return match?.[1] ?? basenameWithout(filePath, '-meta.xml') +} + +function elementBlocks(content: string, tag: string): Array<{ inner: string; offset: number; text: string }> { + const re = new RegExp( + `<(?:[A-Za-z_][\\w.-]*:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)`, + 'gi', + ) + return [...content.matchAll(re)].map((match) => ({ + inner: match[1] ?? '', + offset: match.index ?? 0, + text: match[0], + })) +} + +function attributeValue(attributes: string, name: string): string | null { + const match = new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i').exec(attributes) + return match?.[2] === undefined ? null : decodeXml(match[2]) +} + +function propertyElements(content: string): Array<{ name: string; offset: number; text: string }> { + const re = + /<(?:[A-Za-z_][\w.-]*:)?property\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?property\s*>)/gi + const out: Array<{ name: string; offset: number; text: string }> = [] + for (const match of content.matchAll(re)) { + const name = attributeValue(match[1] ?? '', 'name') + if (name !== null && name !== '') out.push({ name, offset: match.index ?? 0, text: match[0] }) + } + return out +} + +function makeRef(content: string, filePath: string, name: string, offset: number): RefEntry { + const before = content.slice(0, offset) + const line = before.split(/\r?\n/).length + const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1 + const sourceLine = content.slice(lineStart).split(/\r?\n/, 1)[0] ?? '' + return { filePath, name, line, col: offset - lineStart, context: sourceLine.trim() } +} + +function emitRef(refs: RefEntry[], seen: Set, ref: RefEntry): void { + const key = `${ref.filePath}\0${ref.name}\0${ref.line}\0${ref.col}` + if (!ref.name || seen.has(key)) return + seen.add(key) + refs.push(ref) +} + +function addTagRefs( + refs: RefEntry[], + seen: Set, + content: string, + filePath: string, + tags: readonly string[], +): void { + for (const tag of tags) { + for (const block of elementBlocks(content, tag)) { + const name = decodeXml(block.inner.trim()) + if (name !== '') emitRef(refs, seen, makeRef(content, filePath, name, block.offset)) + } + } } function metadataName(filePath: string, content: string, suffix: string): string { @@ -153,17 +238,22 @@ function emit(symbols: SymbolEntry[], seen: Set, symbol: SymbolEntry): v export function extractSalesforceMetadata( content: string, filePath: string, -): { symbols: SymbolEntry[] } { +): { symbols: SymbolEntry[]; refs: RefEntry[] } { const symbols: SymbolEntry[] = [] const seen = new Set() + const refs: RefEntry[] = [] + const seenRefs = new Set() const base = path.basename(filePath).toLowerCase() const whole = wholeFileSpan(content) + const root = rootElement(content) + + if (root === null) return { symbols, refs } if (base.endsWith('.object-meta.xml')) { const name = metadataName(filePath, content, '.object-meta.xml') const isPlatformEvent = name.endsWith('__e') || xmlText(content, 'eventType') !== null emit(symbols, seen, makeSymbol(filePath, name, isPlatformEvent ? 'sf_platform_event' : 'sf_object', whole)) - return { symbols } + return { symbols, refs } } if (base.endsWith('.field-meta.xml')) { @@ -173,7 +263,7 @@ export function extractSalesforceMetadata( if (objectName !== '') { emit(symbols, seen, makeSymbol(filePath, `${objectName}.${name}`, 'sf_custom_field', whole, objectName)) } - return { symbols } + return { symbols, refs } } if (base.endsWith('.validationrule-meta.xml')) { @@ -183,38 +273,137 @@ export function extractSalesforceMetadata( if (objectName !== '') { emit(symbols, seen, makeSymbol(filePath, `${objectName}.${name}`, 'sf_validation_rule', whole, objectName)) } - return { symbols } + return { symbols, refs } } if (base.endsWith('.flow-meta.xml')) { const name = basenameWithout(filePath, '.flow-meta.xml') emit(symbols, seen, makeSymbol(filePath, name, 'sf_flow', whole)) addFlowElements(symbols, seen, content, filePath, name) - return { symbols } + addTagRefs(refs, seenRefs, content, filePath, ['actionName', 'flowName']) + for (const tag of ['recordLookups', 'recordCreates', 'recordUpdates', 'recordDeletes']) { + for (const block of elementBlocks(content, tag)) { + const objectName = xmlText(block.inner, 'object') + if (objectName === null) continue + const objectOffset = block.offset + block.text.indexOf(objectName) + emitRef(refs, seenRefs, makeRef(content, filePath, objectName, objectOffset)) + for (const field of elementBlocks(block.inner, 'field')) { + const fieldName = decodeXml(field.inner.trim()) + if (fieldName === '') continue + const offset = block.offset + block.text.indexOf(field.text) + emitRef(refs, seenRefs, makeRef(content, filePath, `${objectName}.${fieldName}`, offset)) + } + } + } + return { symbols, refs } } if (base.endsWith('.permissionset-meta.xml')) { const name = basenameWithout(filePath, '.permissionset-meta.xml') emit(symbols, seen, makeSymbol(filePath, name, 'sf_permission_set', whole)) - return { symbols } + return { symbols, refs } } if (base.endsWith('.profile-meta.xml')) { const name = basenameWithout(filePath, '.profile-meta.xml') emit(symbols, seen, makeSymbol(filePath, name, 'sf_profile', whole)) - return { symbols } + return { symbols, refs } } if (base.endsWith('.md-meta.xml')) { const name = basenameWithout(filePath, '.md-meta.xml') emit(symbols, seen, makeSymbol(filePath, name, 'sf_custom_metadata_record', whole)) - return { symbols } + return { symbols, refs } } - const root = rootElement(content) - if (root !== null) { - const name = xmlText(content, 'fullName') ?? basenameWithout(filePath, '-meta.xml') - emit(symbols, seen, makeSymbol(filePath, name, `sf_${root}`, whole)) + const objectMemberKinds: Readonly> = { + 'recordtype-meta.xml': 'sf_record_type', + 'fieldset-meta.xml': 'sf_field_set', + 'compactlayout-meta.xml': 'sf_compact_layout', + 'businessprocess-meta.xml': 'sf_business_process', + 'weblink-meta.xml': 'sf_web_link', + 'sharingreason-meta.xml': 'sf_sharing_reason', + } + const memberEntry = Object.entries(objectMemberKinds).find(([suffix]) => base.endsWith(suffix)) + if (memberEntry !== undefined) { + const member = xmlText(content, 'fullName') ?? basenameWithout(filePath, `.${memberEntry[0]}`) + const objectName = objectNameFromPath(filePath) + const name = objectName === null ? member : `${objectName}.${member}` + emit(symbols, seen, makeSymbol(filePath, name, memberEntry[1], whole, objectName ?? '')) + return { symbols, refs } } - return { symbols } + + const companion = companionName(filePath) + const name = + companion ?? + (base.endsWith('.labels-meta.xml') ? null : xmlText(content, 'fullName')) ?? + metadataArtifactName(filePath) + emit(symbols, seen, makeSymbol(filePath, name, `sf_${snakeCase(root)}`, whole)) + + if (base.endsWith('.labels-meta.xml')) { + const lineIndex = buildLineIndex(content) + for (const block of elementBlocks(content, 'labels')) { + const labelName = xmlText(block.inner, 'fullName') + if (labelName === null) continue + emit( + symbols, + seen, + makeSymbol( + filePath, + labelName, + 'sf_custom_label', + spanFromOffsets(content, lineIndex, block.offset, block.offset + block.text.length), + ), + ) + } + } + + if (base.endsWith('.js-meta.xml')) { + const lineIndex = buildLineIndex(content) + const lwcSeen = new Set() + for (const target of elementBlocks(content, 'target')) { + const targetName = decodeXml(target.inner.trim()) + const key = `target\0${targetName}` + if (targetName === '' || lwcSeen.has(key)) continue + lwcSeen.add(key) + emit( + symbols, + seen, + makeSymbol( + filePath, + targetName, + 'sf_lwc_target', + spanFromOffsets(content, lineIndex, target.offset, target.offset + target.text.length), + ), + ) + } + for (const configs of elementBlocks(content, 'targetConfigs')) { + for (const property of propertyElements(configs.inner)) { + const key = `property\0${property.name}` + if (lwcSeen.has(key)) continue + lwcSeen.add(key) + const offset = configs.offset + configs.text.indexOf(property.text) + emit( + symbols, + seen, + makeSymbol( + filePath, + property.name, + 'sf_lwc_property', + spanFromOffsets(content, lineIndex, offset, offset + property.text.length), + ), + ) + } + } + } + + if (base.endsWith('.flexipage-meta.xml')) { + addTagRefs(refs, seenRefs, content, filePath, ['sobjectType', 'componentName']) + } else if (base.endsWith('.quickaction-meta.xml')) { + addTagRefs(refs, seenRefs, content, filePath, ['targetObject', 'lightningComponent']) + } else if (base.endsWith('.messagechannel-meta.xml')) { + addTagRefs(refs, seenRefs, content, filePath, ['fieldName']) + } + + return { symbols, refs } } diff --git a/src/parser.ts b/src/parser.ts index 6bcb8648..867027b0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -45,6 +45,11 @@ import { extractProto } from './languages/proto_idx.js' import { extractPowershell } from './languages/powershell_idx.js' import { extractApex } from './languages/apex.js' import { extractSalesforceMetadata } from './languages/salesforce_metadata.js' +import { + extractLwcJavaScript, + extractLwcTemplate, + extractSalesforceMarkup, +} from './languages/salesforce_frontend.js' import { foldPath } from './util.js' const _require = createRequire(import.meta.url) @@ -1211,6 +1216,33 @@ interface ParseContentResult { readonly refs: RefEntry[] } +function isLwcFile(filePath: string, extension: '.js' | '.html'): boolean { + const normalized = filePath.replace(/\\/g, '/') + return /\/lwc\/[^/]+\/[^/]+$/i.test(normalized) && normalized.toLowerCase().endsWith(extension) +} + +function mergeParseResults(...results: readonly ParseContentResult[]): ParseContentResult { + const symbols: SymbolEntry[] = [] + const refs: RefEntry[] = [] + const seenSymbols = new Set() + const seenRefs = new Set() + for (const result of results) { + for (const entry of result.symbols) { + const key = `${entry.filePath}\0${entry.name}\0${entry.kind}\0${entry.lineStart}` + if (seenSymbols.has(key)) continue + seenSymbols.add(key) + symbols.push(entry) + } + for (const entry of result.refs) { + const key = `${entry.filePath}\0${entry.name}\0${entry.line}\0${entry.col}` + if (seenRefs.has(key)) continue + seenRefs.add(key) + refs.push(entry) + } + } + return { symbols, refs } +} + /** Shared sync core: pick an extractor for `language` and run it on `content`. */ function parseContent(content: string, filePath: string, language: Language): ParseContentResult { // Strip UTF-8 BOM if present (U+FEFF); some editors save files with this prefix. @@ -1247,7 +1279,10 @@ function parseContent(content: string, filePath: string, language: Language): Pa symbols = extractTsJsSymbols(root, filePath) } const refs = REF_LANGUAGES.has(language) ? extractRefs(root, filePath, language) : [] - return { symbols, refs } + const parsed = { symbols, refs } + return language === 'javascript' && isLwcFile(filePath, '.js') + ? mergeParseResults(parsed, extractLwcJavaScript(content, filePath)) + : parsed } } catch { // Parser threw on this input — fall through to the regex pass below. @@ -1255,7 +1290,7 @@ function parseContent(content: string, filePath: string, language: Language): Pa } // Regex-based extractors for languages without tree-sitter - return { symbols: extractSymbolsNoTreeSitter(content, filePath, language), refs: [] } + return extractNoTreeSitter(content, filePath, language) } /** @@ -1317,6 +1352,24 @@ const NO_TREE_SITTER_EXTRACTORS: Partial> = { env_file: extractEnv, } +function extractNoTreeSitter( + content: string, + filePath: string, + language: Language, +): ParseContentResult { + if (language === 'salesforce_metadata') return extractSalesforceMetadata(content, filePath) + if (language === 'salesforce_markup') return extractSalesforceMarkup(content, filePath) + if (language === 'html' && isLwcFile(filePath, '.html')) return extractLwcTemplate(content, filePath) + + const parsed: ParseContentResult = { + symbols: extractSymbolsNoTreeSitter(content, filePath, language), + refs: [], + } + return language === 'javascript' && isLwcFile(filePath, '.js') + ? mergeParseResults(parsed, extractLwcJavaScript(content, filePath)) + : parsed +} + function extractSymbolsNoTreeSitter( content: string, filePath: string, diff --git a/src/parser_types.ts b/src/parser_types.ts index 8fdc52e1..2e9c936b 100644 --- a/src/parser_types.ts +++ b/src/parser_types.ts @@ -78,6 +78,7 @@ export type Language = | 'powershell' | 'apex' | 'salesforce_metadata' + | 'salesforce_markup' | 'unknown' /** @@ -140,31 +141,18 @@ const EXTENSION_LANGUAGE: ReadonlyMap = new Map([ ['.env', 'env_file'], ['.cls', 'apex'], ['.trigger', 'apex'], + ['.cmp', 'salesforce_markup'], + ['.app', 'salesforce_markup'], + ['.evt', 'salesforce_markup'], + ['.intf', 'salesforce_markup'], + ['.design', 'salesforce_markup'], + ['.auradoc', 'salesforce_markup'], + ['.tokens', 'salesforce_markup'], + ['.page', 'salesforce_markup'], + ['.component', 'salesforce_markup'], + ['.email', 'salesforce_markup'], ]) -const SALESFORCE_METADATA_SUFFIXES = [ - '.object-meta.xml', - '.field-meta.xml', - '.validationrule-meta.xml', - '.flow-meta.xml', - '.permissionset-meta.xml', - '.permissionsetgroup-meta.xml', - '.profile-meta.xml', - '.md-meta.xml', - '.layout-meta.xml', - '.flexipage-meta.xml', - '.app-meta.xml', - '.tab-meta.xml', - '.labels-meta.xml', - '.globalvalueset-meta.xml', - '.standardvalueset-meta.xml', - '.custompermission-meta.xml', - '.recordtype-meta.xml', - '.sharingrules-meta.xml', - '.workflow-meta.xml', - '.duplicaterule-meta.xml', -] - /** * Filenames (no extension or special name) that map directly to a language. * @@ -201,7 +189,7 @@ export function detectLanguage(filePath: string): Language { const byName = FILENAME_LANGUAGE.get(base) if (byName !== undefined) return byName - if (SALESFORCE_METADATA_SUFFIXES.some((suffix) => base.endsWith(suffix))) { + if (base.endsWith('-meta.xml')) { return 'salesforce_metadata' } diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls b/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls new file mode 100644 index 00000000..c425a0c2 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls @@ -0,0 +1,6 @@ +public with sharing class AccountController { + @AuraEnabled(cacheable=true) + public static Account loadAccount(Id recordId) { + return [SELECT Id, Name FROM Account WHERE Id = :recordId]; + } +} diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls-meta.xml new file mode 100644 index 00000000..7e18afc4 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/flows/Account_Orchestrator.flow-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/flows/Account_Orchestrator.flow-meta.xml new file mode 100644 index 00000000..ae4dbccf --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/flows/Account_Orchestrator.flow-meta.xml @@ -0,0 +1,22 @@ + + + + Load_Account + AccountController.loadAccount + apex + + + Run_Child + Child_Account_Flow + + + Find_Account + Account + + Name + EqualTo + Acme + + + Active + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.html b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.html new file mode 100644 index 00000000..6095d3a2 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.html @@ -0,0 +1,4 @@ + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js new file mode 100644 index 00000000..84659bd5 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js @@ -0,0 +1,12 @@ +import { LightningElement, api } from 'lwc'; +import loadAccount from '@salesforce/apex/AccountController.loadAccount'; +import NAME_FIELD from '@salesforce/schema/Account.Name'; + +export default class AccountCard extends LightningElement { + @api recordId; + + @api + refresh() { + return loadAccount({ recordId: this.recordId, fields: [NAME_FIELD] }); + } +} diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js-meta.xml new file mode 100644 index 00000000..591fae46 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js-meta.xml @@ -0,0 +1,13 @@ + + + 65.0 + true + + lightning__RecordPage + + + + + + + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/mysteryTypes/OddThing.mysteryType-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/mysteryTypes/OddThing.mysteryType-meta.xml new file mode 100644 index 00000000..9a9a020e --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/mysteryTypes/OddThing.mysteryType-meta.xml @@ -0,0 +1,5 @@ + + + OddThing + + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml new file mode 100644 index 00000000..c9caa148 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml @@ -0,0 +1,6 @@ + + + Business + + true + diff --git a/tests/hooks_bash.test.ts b/tests/hooks_bash.test.ts index 78b0c39c..731167e4 100644 --- a/tests/hooks_bash.test.ts +++ b/tests/hooks_bash.test.ts @@ -1266,6 +1266,20 @@ describe('preBashHandler — sed line-range interception', () => { expect(result.context).toContain('docs/report.md@13-31') } }) + + it('treats Salesforce Apex, metadata, and markup as symbol-bearing surgical reads', () => { + for (const file of [ + 'force-app/main/default/classes/ExampleController.cls', + 'force-app/main/default/flows/Example.flow-meta.xml', + 'force-app/main/default/aura/example/example.cmp', + ]) { + const result = preBashHandler(makeBashEvent(`sed -n '10,40p' ${file}`)) + expect(result.hookType).toBe('context') + if (result.hookType === 'context') { + expect(result.context).toContain('token-goat symbol') + } + } + }) }) describe('preBashHandler — rg indented def patterns', () => { diff --git a/tests/hooks_read.test.ts b/tests/hooks_read.test.ts index ac5ecbb8..82514532 100644 --- a/tests/hooks_read.test.ts +++ b/tests/hooks_read.test.ts @@ -921,6 +921,25 @@ Some content that makes the file large enough` } }) + it('recognizes Salesforce source and metadata files for symbol-aware re-read guidance', () => { + for (const suffix of ['.cls', '.cmp', '.flow-meta.xml']) { + const p = path.join( + os.tmpdir(), + `tg-read-${process.pid}-${Math.random().toString(36).slice(2)}${suffix}`, + ) + fs.writeFileSync(p, suffix === '.cls' ? 'public class Example {}' : '') + tmpFiles.push(p) + + expect(preReadHandler(readEvent(p)).hookType).toBe('pass') + expect(preReadHandler(readEvent(p)).hookType).toBe('context') + const third = preReadHandler(readEvent(p)) + expect(third.hookType).toBe('deny') + if (third.hookType === 'deny') { + expect(third.message).toContain('token-goat skeleton') + } + } + }) + it('does not recognize .swift as a source extension for the count-based re-read deny (regression: .swift was hardcoded into SOURCE_EXT_RE despite parser_types.ts having no adapter for it, so a 3rd read produced the skeleton/outline-pointing deny message even though those commands would return nothing for a .swift file)', () => { const p = path.join( os.tmpdir(), diff --git a/tests/parser_types.test.ts b/tests/parser_types.test.ts index 171a5b79..543f6193 100644 --- a/tests/parser_types.test.ts +++ b/tests/parser_types.test.ts @@ -38,12 +38,39 @@ describe('detectLanguage', () => { it('classifies Salesforce Apex and source-format metadata', () => { expect(detectLanguage('force-app/main/default/classes/ExampleController.cls')).toBe('apex') expect(detectLanguage('force-app/main/default/triggers/ExampleTrigger.trigger')).toBe('apex') - expect(detectLanguage('force-app/main/default/classes/ExampleController.cls-meta.xml')).toBe('unknown') + expect(detectLanguage('force-app/main/default/classes/ExampleController.cls-meta.xml')).toBe( + 'salesforce_metadata', + ) expect(detectLanguage('force-app/main/default/objects/Example__c/Example__c.object-meta.xml')).toBe( 'salesforce_metadata', ) expect(detectLanguage('force-app/main/default/flows/Example_Flow.flow-meta.xml')).toBe( 'salesforce_metadata', ) + expect(detectLanguage('force-app\\main\\default\\permissionsetgroups\\Sales.PERMISSIONS ETGROUP-META.XML')).toBe( + 'salesforce_metadata', + ) + expect(detectLanguage('force-app/main/default/unknown/Future.futureType-meta.xml')).toBe( + 'salesforce_metadata', + ) + }) + + it('classifies Aura and Visualforce markup', () => { + for (const extension of [ + 'cmp', + 'app', + 'evt', + 'intf', + 'design', + 'auradoc', + 'tokens', + 'page', + 'component', + 'email', + ]) { + expect(detectLanguage(`force-app\\main\\default\\ui\\Example.${extension.toUpperCase()}`)).toBe( + 'salesforce_markup', + ) + } }) }) diff --git a/tests/salesforce_frontend.test.ts b/tests/salesforce_frontend.test.ts new file mode 100644 index 00000000..28351bf9 --- /dev/null +++ b/tests/salesforce_frontend.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' + +import { + extractLwcJavaScript, + extractLwcTemplate, + extractSalesforceMarkup, +} from '../src/languages/salesforce_frontend.js' + +describe('Salesforce frontend adapters', () => { + it('indexes an LWC bundle, public API, and Salesforce imports', () => { + const source = `import { LightningElement, api } from 'lwc'; +import getRows from '@salesforce/apex/OrderController.getRows'; +import ACCOUNT_NAME from '@salesforce/schema/Account.Name'; +import TITLE from '@salesforce/label/c.Order_Title'; +import LOGO from '@salesforce/resourceUrl/companyLogo'; +import EVENTS from '@salesforce/messageChannel/OrderEvents__c'; +import CAN_EDIT from '@salesforce/customPermission/Can_Edit_Order'; +import VIEW_SETUP from '@salesforce/userPermission/ViewSetup'; + +export default class OrderList extends LightningElement { + @api recordId; + + @api + refresh() {} +} +` + + const result = extractLwcJavaScript(source, 'force-app/main/default/lwc/orderList/orderList.js') + + expect(result.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'orderList', kind: 'lwc_bundle' }, + { name: 'c-order-list', kind: 'lwc_component_alias' }, + { name: 'recordId', kind: 'lwc_api_property' }, + { name: 'refresh', kind: 'lwc_api_method' }, + ]) + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'OrderController', line: 2 }, + { name: 'OrderController.getRows', line: 2 }, + { name: 'Account.Name', line: 3 }, + { name: 'c.Order_Title', line: 4 }, + { name: 'companyLogo', line: 5 }, + { name: 'OrderEvents__c', line: 6 }, + { name: 'Can_Edit_Order', line: 7 }, + { name: 'ViewSetup', line: 8 }, + ]) + }) + + it('classifies public LWC accessors as properties and async functions as methods', () => { + const source = `export default class StatusPanel { + @api get status() { return 'ready'; } + @api async reload() {} +}` + const result = extractLwcJavaScript(source, 'force-app/main/default/lwc/statusPanel/statusPanel.js') + expect(result.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'statusPanel', kind: 'lwc_bundle' }, + { name: 'c-status-panel', kind: 'lwc_component_alias' }, + { name: 'status', kind: 'lwc_api_property' }, + { name: 'reload', kind: 'lwc_api_method' }, + ]) + }) + + it('indexes LWC template refs, event handlers, child components, and useful ids without SLDS classes', () => { + const source = `` + + const result = extractLwcTemplate(source, 'force-app\\main\\default\\lwc\\checkoutPanel\\checkoutPanel.html') + + expect(result.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'panel', kind: 'lwc_ref' }, + { name: 'checkout-panel', kind: 'lwc_id' }, + ]) + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'handleCheckout', line: 3 }, + { name: 'handleRemove', line: 4 }, + { name: 'c-order-row', line: 4 }, + { name: 'c-order-row', line: 5 }, + ]) + }) + + it('indexes Aura bundles, members, controller actions, and component references', () => { + const source = ` + + + + +` + + const result = extractSalesforceMarkup(source, 'force-app/main/default/aura/orderPanel/orderPanel.cmp') + + expect(result.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'orderPanel', kind: 'aura_bundle' }, + { name: 'recordId', kind: 'aura_attribute' }, + { name: 'init', kind: 'aura_handler' }, + { name: 'changed', kind: 'aura_event' }, + ]) + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'OrderController', line: 1 }, + { name: 'load', line: 3 }, + { name: 'select', line: 5 }, + { name: 'c:OrderChanged', line: 4 }, + { name: 'c:OrderRow', line: 5 }, + ]) + }) + + it('indexes Aura design attributes and Visualforce controller, extension, action, and component refs', () => { + const design = extractSalesforceMarkup( + '', + 'force-app/main/default/aura/orderPanel/orderPanel.design', + ) + expect(design.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'orderPanel', kind: 'aura_design' }, + { name: 'title', kind: 'aura_design_attribute' }, + ]) + + const source = ` + +` + const page = extractSalesforceMarkup(source, 'force-app/main/default/pages/Order.page') + expect(page.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'Order', kind: 'visualforce_page' }, + ]) + expect(page.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'OrderPageController', line: 1 }, + { name: 'AuditExtension', line: 1 }, + { name: 'SharingExtension', line: 1 }, + { name: 'prepare', line: 1 }, + { name: 'c:OrderSummary', line: 2 }, + ]) + }) + + it.each([ + ['cmp', 'aura_bundle'], + ['app', 'aura_application'], + ['evt', 'aura_event_bundle'], + ['intf', 'aura_interface'], + ['design', 'aura_design'], + ['auradoc', 'aura_documentation'], + ['tokens', 'aura_tokens'], + ['page', 'visualforce_page'], + ['component', 'visualforce_component'], + ['email', 'visualforce_email_template'], + ])('recognizes Salesforce markup extension .%s', (extension, kind) => { + const isAura = ['cmp', 'app', 'evt', 'intf', 'design', 'auradoc', 'tokens'].includes(extension) + const fileName = extension === 'page' ? 'Invoice.PAGE' : `sample.${extension}` + const filePath = isAura + ? `force-app/main/default/aura/sample/${fileName}` + : `force-app/main/default/pages/${fileName}` + const result = extractSalesforceMarkup('', filePath) + expect(result.symbols[0]).toMatchObject({ + name: extension === 'page' ? 'Invoice' : 'sample', + kind, + }) + }) +}) diff --git a/tests/salesforce_metadata_adapter.test.ts b/tests/salesforce_metadata_adapter.test.ts new file mode 100644 index 00000000..a28e39f3 --- /dev/null +++ b/tests/salesforce_metadata_adapter.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' + +import { extractSalesforceMetadata } from '../src/languages/salesforce_metadata.js' + +describe('Salesforce metadata XML adapter', () => { + it('indexes companion descriptors and unknown metadata with stable snake-case kinds', () => { + const companion = extractSalesforceMetadata( + '64.0', + 'force-app/main/default/classes/Checkout.cls-meta.xml', + ) + const future = extractSalesforceMetadata( + 'Sales & Service', + 'force-app/main/default/future/Fallback.future-meta.xml', + ) + + expect(companion.symbols).toEqual([ + expect.objectContaining({ name: 'Checkout.metadata', kind: 'sf_apex_class', body: '' }), + ]) + expect(future.symbols).toEqual([ + expect.objectContaining({ name: 'Sales & Service', kind: 'sf_permission_set_group' }), + ]) + expect(companion.refs).toEqual([]) + }) + + it.each([ + ['recordTypes/Partner.recordType-meta.xml', 'RecordType', 'Partner', 'sf_record_type'], + ['fieldSets/Summary.fieldSet-meta.xml', 'FieldSet', 'Summary', 'sf_field_set'], + ['compactLayouts/Phone.compactLayout-meta.xml', 'CompactLayout', 'Phone', 'sf_compact_layout'], + ['businessProcesses/Sales.businessProcess-meta.xml', 'BusinessProcess', 'Sales', 'sf_business_process'], + ['webLinks/Portal.webLink-meta.xml', 'WebLink', 'Portal', 'sf_web_link'], + ['sharingReasons/Support.sharingReason-meta.xml', 'SharingReason', 'Support', 'sf_sharing_reason'], + ])('qualifies object member %s', (tail, root, member, kind) => { + const result = extractSalesforceMetadata( + `<${root} xmlns="urn:test">${member}`, + `force-app/main/default/objects/Account/${tail}`, + ) + + expect(result.symbols.map(({ name, kind: actualKind }) => [name, actualKind])).toContainEqual([ + `Account.${member}`, + kind, + ]) + }) + + it('indexes individual custom labels and decodes XML entities', () => { + const result = extractSalesforceMetadata( + ` + First_LabelOne + Second_LabelFish & Chips +`, + 'force-app/main/default/labels/CustomLabels.labels-meta.xml', + ) + + expect(result.symbols.map((symbol) => [symbol.name, symbol.kind])).toEqual([ + ['CustomLabels', 'sf_custom_labels'], + ['First_Label', 'sf_custom_label'], + ['Second_Label', 'sf_custom_label'], + ]) + }) + + it('extracts and deduplicates Flow references', () => { + const result = extractSalesforceMetadata( + ` + InvokeInvoiceService.create + ChildShared_Subflow + FindAccountExternal_Id__c + UpdateAccountName +`, + 'force-app/main/default/flows/Checkout.flow-meta.xml', + ) + + expect(result.refs.map((ref) => ref.name)).toEqual([ + 'InvoiceService.create', + 'Shared_Subflow', + 'Account', + 'Account.External_Id__c', + 'Account', + 'Account.Name', + ]) + expect(result.refs.every((ref) => ref.line > 0 && ref.col >= 0 && ref.context !== '')).toBe(true) + }) + + it('indexes deduplicated LWC targets and target-config properties with surgical spans', () => { + const result = extractSalesforceMetadata( + ` + + lightning__RecordPage + lightning__AppPage + lightning__RecordPage + + + + + + + + +`, + 'force-app/main/default/lwc/orderCard/orderCard.js-meta.xml', + ) + + expect(result.symbols.map(({ name, kind }) => [name, kind])).toEqual([ + ['orderCard.metadata', 'sf_lightning_component_bundle'], + ['lightning__RecordPage', 'sf_lwc_target'], + ['lightning__AppPage', 'sf_lwc_target'], + ['recordId', 'sf_lwc_property'], + ['mode', 'sf_lwc_property'], + ]) + for (const symbol of result.symbols.slice(1)) { + expect(symbol.body).not.toBe('') + expect(symbol.lineEnd).toBeGreaterThanOrEqual(symbol.lineStart) + } + }) + + it.each([ + [ + 'pages/Home.flexiPage-meta.xml', + 'Accountc:AccountHero', + ['Account', 'c:AccountHero'], + ], + [ + 'quickActions/Account.Start.quickAction-meta.xml', + 'Accountc:StartWizard', + ['Account', 'c:StartWizard'], + ], + [ + 'messageChannels/Order.messageChannel-meta.xml', + 'OrderrecordId', + ['recordId'], + ], + ])('extracts UI metadata references from %s', (tail, content, expected) => { + const result = extractSalesforceMetadata(content, `force-app/main/default/${tail}`) + expect(result.refs.map((ref) => ref.name)).toEqual(expected) + }) + + it('returns no entries for malformed metadata and caps duplicate child symbols', () => { + expect( + extractSalesforceMetadata('Broken', 'Broken.labels-meta.xml'), + ).toEqual({ symbols: [], refs: [] }) + + const repeated = Array.from( + { length: 1100 }, + (_, index) => `Label_${index}`, + ).join('') + const result = extractSalesforceMetadata( + `${repeated}Label_0`, + 'CustomLabels.labels-meta.xml', + ) + expect(result.symbols).toHaveLength(1000) + expect(new Set(result.symbols.map((symbol) => symbol.name)).size).toBe(1000) + }) +}) diff --git a/tests/salesforce_worker_e2e.test.ts b/tests/salesforce_worker_e2e.test.ts new file mode 100644 index 00000000..0c7a59cc --- /dev/null +++ b/tests/salesforce_worker_e2e.test.ts @@ -0,0 +1,166 @@ +/** + * Salesforce DX regression coverage for the two shipping seams: + * + * 1. the worker drains dirty.txt with its real default indexer (no injected + * callback), and + * 2. the built CLI indexes and surgically reads the same fixture. + * + * Keep this fixture deliberately small but cross-layer: Apex, an LWC bundle, + * detailed metadata, Flow references, and an otherwise unknown *-meta.xml. + */ + +import { execFileSync, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, describe, expect, it } from 'vitest' + +import { closeDb } from '../src/db.js' +import { queryRefs, querySymbols } from '../src/index_reader.js' +import { normalizePath } from '../src/paths.js' +import { drainOnce } from '../src/worker.js' + +import { BUNDLE } from './helpers/bundle.js' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE = path.join(HERE, 'fixtures', 'salesforce-dx') + +const tempDirs = new Set() + +function tempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.add(dir) + return dir +} + +function copyFixture(): string { + const repo = tempDir('tg-salesforce-repo-') + fs.cpSync(FIXTURE, repo, { recursive: true }) + return repo +} + +function fixtureFiles(repo: string): string[] { + const files: string[] = [] + const visit = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const absolute = path.join(dir, entry.name) + if (entry.isDirectory()) visit(absolute) + else if (entry.name !== 'sfdx-project.json') files.push(normalizePath(absolute)) + } + } + visit(repo) + return files.sort() +} + +function writeDirtyQueue(dataDir: string, files: readonly string[]): void { + const queue = path.join(dataDir, 'queue', 'dirty.txt') + fs.mkdirSync(path.dirname(queue), { recursive: true }) + fs.writeFileSync(queue, `${files.join('\n')}\n`) +} + +function runBundle( + repo: string, + dataBase: string, + args: readonly string[], +): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync(process.execPath, [BUNDLE, ...args], { + cwd: repo, + // token-goat follows platformdirs semantics: macOS derives its data dir from HOME, + // Linux from XDG_DATA_HOME, and Windows from LOCALAPPDATA/USERPROFILE. + env: { + ...process.env, + HOME: dataBase, + USERPROFILE: dataBase, + LOCALAPPDATA: dataBase, + XDG_DATA_HOME: dataBase, + }, + encoding: 'utf8', + }) + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + } +} + +afterEach(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }) + tempDirs.clear() +}) + +describe('Salesforce DX worker default path', () => { + it('drains Apex, LWC, Flow, and metadata into searchable symbols and refs', () => { + const repo = copyFixture() + const dataDir = tempDir('tg-salesforce-worker-data-') + const files = fixtureFiles(repo) + writeDirtyQueue(dataDir, files) + + expect(drainOnce(dataDir)).toBe(files.length) + + const dbPath = path.join(dataDir, 'global.db') + try { + expect(querySymbols({ name: 'AccountController' }, dbPath)[0]).toMatchObject({ + kind: 'apex_class', + }) + expect(querySymbols({ name: 'loadAccount' }, dbPath)[0]).toMatchObject({ + kind: 'apex_method', + }) + + // The LWC bundle and its public API are addressable across JS/template/metadata files. + expect(querySymbols({ name: 'accountCard' }, dbPath).length).toBeGreaterThan(0) + expect(querySymbols({ name: 'recordId' }, dbPath).length).toBeGreaterThan(0) + expect(querySymbols({ name: 'refresh' }, dbPath).length).toBeGreaterThan(0) + expect(querySymbols({ name: 'refreshButton' }, dbPath).length).toBeGreaterThan(0) + expect(querySymbols({ name: 'variant' }, dbPath).length).toBeGreaterThan(0) + + // Selected metadata receives qualified symbols, while an unknown metadata type + // still gets a stable top-level symbol and never stores the whole XML body. + expect(querySymbols({ name: 'Account.Business' }, dbPath)[0]).toMatchObject({ + kind: 'sf_record_type', + }) + expect(querySymbols({ name: 'OddThing' }, dbPath)[0]).toMatchObject({ + kind: 'sf_mystery_type', + body: '', + }) + + // Canonical cross-file names make navigation independent of import aliases. + expect(queryRefs({ name: 'AccountController.loadAccount' }, dbPath).length).toBeGreaterThan(0) + expect(queryRefs({ name: 'Account.Name' }, dbPath).length).toBeGreaterThan(0) + expect(queryRefs({ name: 'refresh' }, dbPath).length).toBeGreaterThan(0) + expect(queryRefs({ name: 'c-child-card' }, dbPath).length).toBeGreaterThan(0) + expect(queryRefs({ name: 'Child_Account_Flow' }, dbPath).length).toBeGreaterThan(0) + } finally { + closeDb(dbPath) + } + }) +}) + +describe('Salesforce DX built bundle smoke', () => { + it('indexes the fixture and serves symbol, read, and refs from dist', () => { + const repo = copyFixture() + const dataBase = tempDir('tg-salesforce-bundle-data-') + execFileSync('git', ['init'], { cwd: repo, stdio: 'ignore' }) + execFileSync('git', ['add', '.'], { cwd: repo, stdio: 'ignore' }) + + const indexed = runBundle(repo, dataBase, ['index', repo]) + expect(indexed.status, indexed.stderr).toBe(0) + expect(indexed.stdout).toMatch(/Indexed \d+ files/) + + const symbol = runBundle(repo, dataBase, ['symbol', 'accountCard']) + expect(symbol.status, symbol.stderr).toBe(0) + expect(symbol.stdout).toContain('accountCard') + + const read = runBundle(repo, dataBase, [ + 'read', + 'force-app/main/default/classes/AccountController.cls::loadAccount', + ]) + expect(read.status, read.stderr).toBe(0) + expect(read.stdout).toContain('public static Account loadAccount') + + const refs = runBundle(repo, dataBase, ['refs', 'AccountController.loadAccount']) + expect(refs.status, refs.stderr).toBe(0) + expect(refs.stdout).toContain('accountCard.js') + }, 60_000) +}) diff --git a/tests/worker_index_e2e.test.ts b/tests/worker_index_e2e.test.ts index 63b3d170..29d6c504 100644 --- a/tests/worker_index_e2e.test.ts +++ b/tests/worker_index_e2e.test.ts @@ -43,7 +43,13 @@ let dataBase: string /** Redirect the data dir into a temp base so the e2e never touches the real index. */ function tgEnv(): NodeJS.ProcessEnv { - return { ...process.env, LOCALAPPDATA: dataBase, XDG_DATA_HOME: dataBase } + return { + ...process.env, + HOME: dataBase, + USERPROFILE: dataBase, + LOCALAPPDATA: dataBase, + XDG_DATA_HOME: dataBase, + } } function runBundle(args: string[]): { status: number | null; stdout: string; stderr: string } { @@ -270,7 +276,13 @@ describe('built bundle keys a relative-root index on the absolute path', () => { let relData: string function relEnv(): NodeJS.ProcessEnv { - return { ...process.env, LOCALAPPDATA: relData, XDG_DATA_HOME: relData } + return { + ...process.env, + HOME: relData, + USERPROFILE: relData, + LOCALAPPDATA: relData, + XDG_DATA_HOME: relData, + } } function runRel(args: string[]): { status: number | null; stdout: string; stderr: string } { @@ -355,7 +367,13 @@ describe('built bundle exposes exports / imports / find / web-output', () => { function run(args: string[]): { status: number | null; stdout: string; stderr: string } { const res = spawnSync(process.execPath, [BUNDLE, ...args], { cwd: cmdRepo, - env: { ...process.env, LOCALAPPDATA: cmdData, XDG_DATA_HOME: cmdData }, + env: { + ...process.env, + HOME: cmdData, + USERPROFILE: cmdData, + LOCALAPPDATA: cmdData, + XDG_DATA_HOME: cmdData, + }, encoding: 'utf8', }) return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' } From 6569ad120fc8ab520de15ee8616a6b687230f924 Mon Sep 17 00:00:00 2001 From: Zelys Date: Sat, 11 Jul 2026 15:14:30 -0500 Subject: [PATCH 2/2] fix: self-closing metadata roots, unbounded ref growth, comment-blind extraction extractSalesforceMetadata's rootElement() required a matching close tag, so a self-closing root () indexed to zero symbols. emitRef() had no growth cap, unlike emit()'s existing MAX_SYMBOLS, so a large metadata file could push refs unbounded. And every regex-based extractor in both salesforce_metadata.ts and salesforce_frontend.ts scanned raw content with no comment stripping, so commented-out XML/JS/HTML markup indexed as if it were live. Adds stripXmlComments and stripSlashLineComments to common.ts, applies them (plus the existing stripCstyleComments) ahead of every regex pass in both files, caps emitRef at MAX_REFS, and lets rootElement recognize a self-closed root. Regression tests cover all four cases and confirm they fail pre-fix. --- src/languages/common.ts | 68 +++++++++++++++++++++++ src/languages/salesforce_frontend.ts | 26 ++++++--- src/languages/salesforce_metadata.ts | 15 +++-- tests/salesforce_frontend.test.ts | 47 ++++++++++++++++ tests/salesforce_metadata_adapter.test.ts | 60 ++++++++++++++++++++ 5 files changed, 203 insertions(+), 13 deletions(-) diff --git a/src/languages/common.ts b/src/languages/common.ts index f0760896..9296ef3c 100644 --- a/src/languages/common.ts +++ b/src/languages/common.ts @@ -101,6 +101,74 @@ export function stripCstyleComments( return out } +/** + * Strip XML/HTML ```` block comments. Comment content is blanked with spaces (not + * removed), and newlines inside a multi-line comment are preserved as-is, so line/column offsets + * are unaffected downstream — mirrors `stripCstyleComments`'s span-blanking approach for `/* ... *\/` + * comments, just with the `` delimiters instead of `/*`/`*\/`. + */ +export function stripXmlComments(text: string): string { + const lines = text.split('\n') + let inComment = false + const outLines: string[] = [] + for (const line of lines) { + let result = '' + let j = 0 + while (j < line.length) { + if (!inComment) { + const open = line.indexOf('', open + 4) + if (close === -1) { + result += ' '.repeat(line.length - open) + inComment = true + break + } + result += ' '.repeat(close + 3 - open) + j = close + 3 + inComment = false + } else { + const close = line.indexOf('-->', j) + if (close === -1) { + result += ' '.repeat(line.length - j) + break + } + result += ' '.repeat(close + 3 - j) + j = close + 3 + inComment = false + } + } + outLines.push(result) + } + return outLines.join('\n') +} + +/** + * Strip `//` line comments, quote-aware (like `stripSqlLineComments`'s `--` handling below) so a + * `//` inside an open string literal (e.g. a URL like `'https://example.com'`) is not mistaken for + * a real comment starter. Blank-fills (rather than deletes) the comment span so line/column offsets + * are preserved for downstream line-based symbol extraction. Unlike `stripCstyleComments`'s + * `lineCommentRe` parameter, this is quote-aware on its own and does not require callers to blank + * string literals first — needed by callers (like the Salesforce LWC JS adapter) that still need + * string-literal content intact after stripping comments, e.g. to read an import path. + */ +export function stripSlashLineComments(text: string): string { + return text + .split('\n') + .map((line) => { + let idx = line.indexOf('//') + while (idx !== -1 && isInsideStringLiteral(line, idx)) { + idx = line.indexOf('//', idx + 1) + } + return idx === -1 ? line : line.slice(0, idx) + ' '.repeat(line.length - idx) + }) + .join('\n') +} + /** * Strip GraphQL / shell / Python style ``# …`` line comments. Quote-aware: a `#` inside an * open single- or double-quoted string literal on the same line is not treated as a comment diff --git a/src/languages/salesforce_frontend.ts b/src/languages/salesforce_frontend.ts index 9dff962f..483d78ea 100644 --- a/src/languages/salesforce_frontend.ts +++ b/src/languages/salesforce_frontend.ts @@ -1,6 +1,7 @@ import * as path from 'node:path' import type { RefEntry, SymbolEntry } from '../parser_types.js' +import { stripCstyleComments, stripSlashLineComments, stripXmlComments } from './common.js' export interface SalesforceFrontendResult { readonly symbols: SymbolEntry[] @@ -53,22 +54,25 @@ export function extractLwcJavaScript(content: string, filePath: string): Salesfo ] const refs: RefEntry[] = [] + // Blank comments (block first, then quote-aware `//`) so commented-out `@api` declarations and Salesforce imports aren't indexed as live code; string-literal content is untouched. + const commentFree = stripSlashLineComments(stripCstyleComments(content)) + const apiRe = /@api\s*(?:\r?\n\s*)?(?:(get|set)\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*(\()?/g - for (const match of content.matchAll(apiRe)) { - const before = content.slice(0, match.index ?? 0) + for (const match of commentFree.matchAll(apiRe)) { + const before = commentFree.slice(0, match.index ?? 0) const line = before.split('\n').length const kind = match[3] && !match[1] ? 'lwc_api_method' : 'lwc_api_property' symbols.push(symbol(filePath, match[2] ?? '', kind, line)) } const importRe = /from\s+['"]@salesforce\/(apex|schema|label|resourceUrl|messageChannel|customPermission|userPermission)\/([^'"]+)['"]/g - for (const match of content.matchAll(importRe)) { + for (const match of commentFree.matchAll(importRe)) { const offset = match.index ?? 0 - const line = content.slice(0, offset).split('\n').length + const line = commentFree.slice(0, offset).split('\n').length const context = sourceLines[line - 1]?.trim() ?? '' const target = match[2] ?? '' const targetOffset = offset + (match[0]?.indexOf(target) ?? 0) - const col = targetOffset - (content.lastIndexOf('\n', targetOffset) + 1) + const col = targetOffset - (commentFree.lastIndexOf('\n', targetOffset) + 1) if (match[1] === 'apex') { const className = target.split('.')[0] ?? target refs.push(ref(filePath, className, line, col, context)) @@ -102,9 +106,11 @@ export function extractLwcTemplate(content: string, filePath: string): Salesforc const line = matchLine(content, match.index ?? 0) symbols.push(symbol(filePath, match[1] ?? '', 'lwc_id', line)) } - for (const match of content.matchAll(/\bon[a-z][\w-]*\s*=\s*\{\s*([A-Za-z_$][\w$]*)\s*\}/gi)) { + // Blank `` spans before matching event-handler bindings so a commented-out element (e.g. ``) isn't indexed as a live ref. + const markupNoComments = stripXmlComments(content) + for (const match of markupNoComments.matchAll(/\bon[a-z][\w-]*\s*=\s*\{\s*([A-Za-z_$][\w$]*)\s*\}/gi)) { const offset = match.index ?? 0 - const line = matchLine(content, offset) + const line = matchLine(markupNoComments, offset) refs.push(ref(filePath, match[1] ?? '', line, 0, lineContext(content, line))) } for (const match of content.matchAll(/<\s*(c-[a-z][\w-]*)\b/gi)) { @@ -192,11 +198,13 @@ export function extractSalesforceMarkup(content: string, filePath: string): Sale attributeRefs(refs, content, filePath, 'controller') attributeRefs(refs, content, filePath, 'extensions', true) + // Blank `` spans before matching `{!c.action}` / `action="{!...}"` bindings so a commented-out element isn't indexed as a live controller-action ref. + const markupNoComments = stripXmlComments(content) const actionRe = isAura ? /\{!\s*c\.([A-Za-z_$][\w$]*)[^}]*\}/gi : /\baction\s*=\s*["']\{!\s*(?:c\.)?([A-Za-z_$][\w$]*)[^}]*\}["']/gi - for (const match of content.matchAll(actionRe)) { - const line = matchLine(content, match.index ?? 0) + for (const match of markupNoComments.matchAll(actionRe)) { + const line = matchLine(markupNoComments, match.index ?? 0) refs.push(ref(filePath, match[1] ?? '', line, 0, lineContext(content, line))) } diff --git a/src/languages/salesforce_metadata.ts b/src/languages/salesforce_metadata.ts index 5ca45105..fe073060 100644 --- a/src/languages/salesforce_metadata.ts +++ b/src/languages/salesforce_metadata.ts @@ -1,9 +1,10 @@ import * as path from 'node:path' import type { RefEntry, SymbolEntry } from '../parser_types.js' -import { buildLineIndex, offsetToLine } from './common.js' +import { buildLineIndex, offsetToLine, stripXmlComments } from './common.js' const MAX_SYMBOLS = 1000 +const MAX_REFS = 1000 const FLOW_TAG_KIND: Readonly> = { actionCalls: 'sf_flow_action', @@ -113,8 +114,11 @@ function makeSymbol( function rootElement(content: string): string | null { const match = /<(?!\?|!)(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)\b[^>]*>/.exec(content) - const root = match?.[1] + if (match === null) return null + const root = match[1] if (root === undefined) return null + // A self-closing root (e.g. ``) has no separate close tag to find. + if (match[0].endsWith('/>')) return root const close = new RegExp(``, 'i') return close.test(content) ? root : null } @@ -177,8 +181,9 @@ function makeRef(content: string, filePath: string, name: string, offset: number } function emitRef(refs: RefEntry[], seen: Set, ref: RefEntry): void { + if (!ref.name || refs.length >= MAX_REFS) return const key = `${ref.filePath}\0${ref.name}\0${ref.line}\0${ref.col}` - if (!ref.name || seen.has(key)) return + if (seen.has(key)) return seen.add(key) refs.push(ref) } @@ -236,9 +241,11 @@ function emit(symbols: SymbolEntry[], seen: Set, symbol: SymbolEntry): v } export function extractSalesforceMetadata( - content: string, + rawContent: string, filePath: string, ): { symbols: SymbolEntry[]; refs: RefEntry[] } { + // Blank `` spans up front so every regex-based extractor below scans only live XML and never mistakes commented-out metadata for the real thing; blanking (not deleting) preserves line/column offsets. + const content = stripXmlComments(rawContent) const symbols: SymbolEntry[] = [] const seen = new Set() const refs: RefEntry[] = [] diff --git a/tests/salesforce_frontend.test.ts b/tests/salesforce_frontend.test.ts index 28351bf9..330e5232 100644 --- a/tests/salesforce_frontend.test.ts +++ b/tests/salesforce_frontend.test.ts @@ -45,6 +45,28 @@ export default class OrderList extends LightningElement { ]) }) + it('ignores @api declarations and Salesforce imports inside JS comments', () => { + const source = `import { LightningElement, api } from 'lwc'; +// import disabledImport from '@salesforce/apex/OldController.getRows'; +/* + @api disabledProp; +*/ +import getRows from '@salesforce/apex/OrderController.getRows'; + +export default class OrderList extends LightningElement { + @api recordId; +} +` + + const result = extractLwcJavaScript(source, 'force-app/main/default/lwc/orderList/orderList.js') + + expect(result.symbols.map(({ name }) => name)).toEqual(['orderList', 'c-order-list', 'recordId']) + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'OrderController', line: 6 }, + { name: 'OrderController.getRows', line: 6 }, + ]) + }) + it('classifies public LWC accessors as properties and async functions as methods', () => { const source = `export default class StatusPanel { @api get status() { return 'ready'; } @@ -82,6 +104,31 @@ export default class OrderList extends LightningElement { ]) }) + it('ignores event-handler bindings inside HTML comments', () => { + const source = `` + + const result = extractLwcTemplate(source, 'force-app/main/default/lwc/checkoutPanel/checkoutPanel.html') + + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'handleCheckout', line: 3 }, + ]) + }) + + it('ignores {!c.action} bindings inside HTML comments', () => { + const source = ` + + +` + + const result = extractSalesforceMarkup(source, 'force-app/main/default/aura/orderPanel/orderPanel.cmp') + + expect(result.refs.map(({ name }) => name)).not.toContain('disabledAction') + expect(result.refs.map(({ name }) => name)).toContain('select') + }) + it('indexes Aura bundles, members, controller actions, and component references', () => { const source = ` diff --git a/tests/salesforce_metadata_adapter.test.ts b/tests/salesforce_metadata_adapter.test.ts index a28e39f3..53cda700 100644 --- a/tests/salesforce_metadata_adapter.test.ts +++ b/tests/salesforce_metadata_adapter.test.ts @@ -132,6 +132,44 @@ describe('Salesforce metadata XML adapter', () => { expect(result.refs.map((ref) => ref.name)).toEqual(expected) }) + it('ignores metadata names, flow refs, and target-config properties inside XML comments', () => { + const commentedName = extractSalesforceMetadata( + ` + + Real_Object__c +`, + 'force-app/main/default/objects/Real_Object__c/Real_Object__c.object-meta.xml', + ) + expect(commentedName.symbols).toEqual([ + expect.objectContaining({ name: 'Real_Object__c', kind: 'sf_object' }), + ]) + + const commentedFlow = extractSalesforceMetadata( + ` + + InvokeInvoiceService.create +`, + 'force-app/main/default/flows/Checkout.flow-meta.xml', + ) + expect(commentedFlow.symbols.map((symbol) => symbol.name)).not.toContain('Disabled') + expect(commentedFlow.symbols.map((symbol) => symbol.name)).toContain('Invoke') + expect(commentedFlow.refs.map((ref) => ref.name)).toEqual(['InvoiceService.create']) + + const commentedProperty = extractSalesforceMetadata( + ` + + + + + + +`, + 'force-app/main/default/lwc/orderCard/orderCard.js-meta.xml', + ) + expect(commentedProperty.symbols.map((symbol) => symbol.name)).not.toContain('disabledProp') + expect(commentedProperty.symbols.map((symbol) => symbol.name)).toContain('recordId') + }) + it('returns no entries for malformed metadata and caps duplicate child symbols', () => { expect( extractSalesforceMetadata('Broken', 'Broken.labels-meta.xml'), @@ -148,4 +186,26 @@ describe('Salesforce metadata XML adapter', () => { expect(result.symbols).toHaveLength(1000) expect(new Set(result.symbols.map((symbol) => symbol.name)).size).toBe(1000) }) + + it('indexes a metadata file whose root element is self-closing (regression: rootElement required a separate close tag, so a self-closing root indexed as zero symbols)', () => { + const result = extractSalesforceMetadata( + '', + 'Account.objectTranslation-meta.xml', + ) + expect(result.symbols).toEqual([ + expect.objectContaining({ name: 'Account', kind: 'sf_custom_object_translation' }), + ]) + }) + + it('caps ref emission at MAX_REFS instead of growing unbounded (regression: emitRef had no cap, unlike emit’s MAX_SYMBOLS)', () => { + const repeated = Array.from( + { length: 1100 }, + (_, index) => `cmp_${index}`, + ).join('') + const result = extractSalesforceMetadata( + `${repeated}`, + 'Test.flexipage-meta.xml', + ) + expect(result.refs.length).toBeLessThanOrEqual(1000) + }) })