diff --git a/__tests__/language-extractor-hooks.test.ts b/__tests__/language-extractor-hooks.test.ts new file mode 100644 index 000000000..d7509ce57 --- /dev/null +++ b/__tests__/language-extractor-hooks.test.ts @@ -0,0 +1,95 @@ +/** + * Static source guard: every member of LanguageExtractor + * (src/extraction/tree-sitter-types.ts) is read by the extraction core. + * + * A hook the core never calls is a trap. A language author finds it, writes + * it, and gets nothing back: no error, no warning, no node. That happened with + * `extractVariables`, declared and documented but never called (#2465). + * + * "Read" means a property access through an `extractor` receiver + * (`this.extractor.x`, `this.extractor?.x`, `this.extractor!.x`, + * `extractor.x`, `extractor?.x`) or a destructuring of one, anywhere under + * src/extraction except the type file itself and the per-language extractors + * in languages/, which define these members rather than read them. The guard + * fails closed: a member read some other way shows up as unread. + */ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import ts from 'typescript'; + +const EXTRACTION_DIR = path.join(__dirname, '..', 'src', 'extraction'); +const TYPES_FILE = path.join(EXTRACTION_DIR, 'tree-sitter-types.ts'); + +function parse(file: string): ts.SourceFile { + return ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); +} + +function declaredMembers(): string[] { + const source = parse(TYPES_FILE); + const iface = source.statements.find( + (s): s is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(s) && s.name.text === 'LanguageExtractor' + ); + if (!iface) throw new Error('LanguageExtractor not found in tree-sitter-types.ts'); + return iface.members.flatMap((m) => (m.name && ts.isIdentifier(m.name) ? [m.name.text] : [])); +} + +function coreFiles(dir = EXTRACTION_DIR): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'languages') out.push(...coreFiles(p)); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts') && p !== TYPES_FILE) { + out.push(p); + } + } + return out; +} + +/** `extractor`, `this.extractor`, either one behind `!` or parentheses. */ +function isExtractorReceiver(node: ts.Expression): boolean { + while (ts.isNonNullExpression(node) || ts.isParenthesizedExpression(node)) node = node.expression; + if (ts.isIdentifier(node)) return node.text === 'extractor'; + return ts.isPropertyAccessExpression(node) && + node.expression.kind === ts.SyntaxKind.ThisKeyword && + node.name.text === 'extractor'; +} + +function membersRead(): Set { + const read = new Set(); + for (const file of coreFiles()) { + const visit = (node: ts.Node): void => { + if (ts.isPropertyAccessExpression(node) && isExtractorReceiver(node.expression)) { + read.add(node.name.text); + } else if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && + node.initializer && isExtractorReceiver(node.initializer)) { + for (const element of node.name.elements) { + const key = element.propertyName ?? element.name; + if (ts.isIdentifier(key)) read.add(key.text); + } + } + ts.forEachChild(node, visit); + }; + visit(parse(file)); + } + return read; +} + +describe('LanguageExtractor hooks (#2465)', () => { + it('declares only members the extraction core reads', () => { + const read = membersRead(); + const unread = declaredMembers().filter((name) => !read.has(name)); + expect(unread, 'LanguageExtractor members the extraction core never reads: call each one, or remove it').toEqual([]); + }); + + it('sees the members the core does read', () => { + // Guards the guard: if the receiver matching broke, every member would + // look unread, or none would be checked at all. + const read = membersRead(); + for (const name of ['functionTypes', 'variableTypes', 'extractImport', 'preParse', 'extractBareCall']) { + expect(read.has(name), name).toBe(true); + } + expect(declaredMembers().length).toBeGreaterThan(40); + }); +}); diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index ae629e553..ade9fff8b 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -25,23 +25,6 @@ export interface ImportInfo { handledRefs?: boolean; } -/** - * Information about a single variable within a declaration. - * Returned by a language's extractVariables hook. - */ -export interface VariableInfo { - /** Variable name */ - name: string; - /** Node kind: 'variable' or 'constant' */ - kind: NodeKind; - /** Optional signature string */ - signature?: string; - /** If set, this declarator is actually a function and should be extracted as such */ - delegateToFunction?: SyntaxNode; - /** The AST node to use for positioning (may differ from the declaration node) */ - positionNode?: SyntaxNode; -} - /** * Context object passed to language hooks that need to call back into the core extractor. * Provides a controlled API surface — hooks can create nodes, visit children, and add @@ -137,7 +120,14 @@ export interface LanguageExtractor { importTypes: string[]; /** Node types that represent function calls */ callTypes: string[]; - /** Node types that represent variable declarations (const, let, var, etc.) */ + /** + * Node types that represent variable declarations (const, let, var, etc.). + * By default the core names each declared variable from the node's direct + * `identifier` or `variable_declarator` children (`extractVariable` in + * tree-sitter.ts). A grammar that nests the names deeper, like Swift's + * `pattern` or Lua's `variable_list`, gets its own branch there; there is no + * per-language hook for it. + */ variableTypes: string[]; /** Node types that represent class fields (extracted as 'field' kind inside class bodies) */ fieldTypes?: string[]; @@ -331,12 +321,6 @@ export interface LanguageExtractor { */ extractImport?: (node: SyntaxNode, source: string) => ImportInfo | null; - /** - * Extract variable declarations from a variable declaration node. - * Returns info about each declared variable, allowing the core to create nodes. - */ - extractVariables?: (node: SyntaxNode, source: string) => VariableInfo[]; - /** * Extract receiver/owner type name from a method declaration. * Used by Go to get the struct receiver (e.g., "scrapeLoop" from "func (sl *scrapeLoop) run()").