Skip to content

Commit e68f6a7

Browse files
test: add query vs walk parity tests for JS/TS/TSX extractors
Verifies that the query-based fast path (tree-sitter Query API) and the manual tree walk fallback produce identical symbols for 13 test cases covering functions, classes, imports, callbacks, TS types, and TSX components.
1 parent fb6a139 commit e68f6a7

1 file changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/**
2+
* Query-vs-Walk parity tests for JS/TS/TSX extractors.
3+
*
4+
* Parses each snippet twice:
5+
* 1. Query path — via parseFileAuto (uses compiled tree-sitter Query)
6+
* 2. Walk path — direct extractSymbols(tree, filePath) with no query (manual tree walk)
7+
*
8+
* Both paths must produce identical symbols.
9+
*/
10+
11+
import { beforeAll, describe, expect, it } from 'vitest';
12+
import { extractSymbols } from '../../src/extractors/javascript.js';
13+
import { createParsers, getParser, parseFileAuto } from '../../src/parser.js';
14+
15+
let parsers;
16+
17+
beforeAll(async () => {
18+
parsers = await createParsers();
19+
});
20+
21+
/** Strip undefined optional fields for stable comparison. */
22+
function normalize(symbols) {
23+
if (!symbols) return symbols;
24+
return {
25+
definitions: (symbols.definitions || [])
26+
.map((d) => ({
27+
name: d.name,
28+
kind: d.kind,
29+
line: d.line,
30+
endLine: d.endLine ?? null,
31+
}))
32+
.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)),
33+
calls: (symbols.calls || [])
34+
.map((c) => ({
35+
name: c.name,
36+
line: c.line,
37+
...(c.receiver ? { receiver: c.receiver } : {}),
38+
...(c.dynamic ? { dynamic: true } : {}),
39+
}))
40+
.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)),
41+
imports: (symbols.imports || [])
42+
.map((i) => ({
43+
source: i.source,
44+
names: [...(i.names || [])].sort(),
45+
line: i.line,
46+
...(i.reexport ? { reexport: true } : {}),
47+
...(i.wildcardReexport ? { wildcardReexport: true } : {}),
48+
...(i.typeOnly ? { typeOnly: true } : {}),
49+
}))
50+
.sort((a, b) => a.line - b.line),
51+
classes: (symbols.classes || [])
52+
.map((c) => ({
53+
name: c.name,
54+
...(c.extends ? { extends: c.extends } : {}),
55+
...(c.implements ? { implements: c.implements } : {}),
56+
line: c.line,
57+
}))
58+
.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)),
59+
exports: (symbols.exports || [])
60+
.map((e) => ({
61+
name: e.name,
62+
kind: e.kind,
63+
line: e.line,
64+
}))
65+
.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)),
66+
};
67+
}
68+
69+
function walkExtract(code, filePath) {
70+
const parser = getParser(parsers, filePath);
71+
if (!parser) throw new Error(`No parser for ${filePath}`);
72+
const tree = parser.parse(code);
73+
// Call without query → triggers extractSymbolsWalk
74+
return extractSymbols(tree, filePath);
75+
}
76+
77+
async function queryExtract(code, filePath) {
78+
// parseFileAuto with engine:'wasm' passes the compiled query from _queryCache
79+
return parseFileAuto(filePath, code, { engine: 'wasm' });
80+
}
81+
82+
const cases = [
83+
{
84+
name: 'functions and arrow functions',
85+
file: 'test.js',
86+
code: `
87+
function greet(name) { return 'Hello ' + name; }
88+
const add = (a, b) => a + b;
89+
greet('world');
90+
add(1, 2);
91+
`,
92+
},
93+
{
94+
name: 'class with methods and inheritance',
95+
file: 'test.js',
96+
code: `
97+
class Animal {
98+
speak() { return 'generic'; }
99+
}
100+
class Dog extends Animal {
101+
speak() { return 'woof'; }
102+
fetch(item) { return item; }
103+
}
104+
new Dog().speak();
105+
`,
106+
},
107+
{
108+
name: 'imports and re-exports',
109+
file: 'test.js',
110+
code: `
111+
import { readFile, writeFile } from 'fs/promises';
112+
import path from 'path';
113+
export { default as Widget } from './Widget';
114+
export * from './utils';
115+
readFile('file.txt');
116+
`,
117+
},
118+
{
119+
name: 'method calls with receivers',
120+
file: 'test.js',
121+
code: `
122+
obj.method();
123+
standalone();
124+
this.foo();
125+
arr[0].bar();
126+
a.b.c();
127+
`,
128+
},
129+
{
130+
name: 'CommonJS require patterns',
131+
file: 'test.js',
132+
code: `
133+
const fs = require('fs');
134+
const { join } = require('path');
135+
module.exports = { fs };
136+
`,
137+
},
138+
{
139+
name: 'Commander callback patterns',
140+
file: 'test.js',
141+
code: `
142+
program.command('build [dir]').action(async (dir, opts) => { run(); });
143+
program.command('query <name>').action(() => { search(); });
144+
program.command('test').action(handleTest);
145+
`,
146+
},
147+
{
148+
name: 'Express route patterns',
149+
file: 'test.js',
150+
code: `
151+
app.get('/api/users', (req, res) => { res.json([]); });
152+
router.post('/api/items', async (req, res) => { save(); });
153+
`,
154+
},
155+
{
156+
name: 'event emitter patterns',
157+
file: 'test.js',
158+
code: `
159+
emitter.on('data', (chunk) => { process(chunk); });
160+
server.once('listening', () => { log(); });
161+
emitter.on('error', handleError);
162+
`,
163+
},
164+
{
165+
name: 'exported function and class declarations',
166+
file: 'test.js',
167+
code: `
168+
export function serve(port) { listen(port); }
169+
export class Server {
170+
start() { this.init(); }
171+
}
172+
`,
173+
},
174+
{
175+
name: 'dynamic call patterns (.call/.apply)',
176+
file: 'test.js',
177+
code: `
178+
fn.call(null, arg);
179+
obj.apply(undefined, args);
180+
method.bind(ctx);
181+
`,
182+
},
183+
// TypeScript-specific
184+
{
185+
name: 'TS interfaces and type aliases',
186+
file: 'test.ts',
187+
code: `
188+
interface Greeter { greet(name: string): string; }
189+
type ID = string | number;
190+
class MyGreeter implements Greeter {
191+
greet(name: string) { return name; }
192+
}
193+
`,
194+
},
195+
{
196+
name: 'TS import type',
197+
file: 'test.ts',
198+
code: `
199+
import type { Config } from './config';
200+
import { readFile } from 'fs/promises';
201+
function load(): Config { return readFile('cfg.json'); }
202+
`,
203+
},
204+
// TSX
205+
{
206+
name: 'TSX component with extends',
207+
file: 'test.tsx',
208+
code: `
209+
import React from 'react';
210+
class Button extends React.Component {
211+
render() { return <button />; }
212+
}
213+
export default Button;
214+
`,
215+
},
216+
];
217+
218+
describe('Query vs Walk parity', () => {
219+
for (const { name, file, code } of cases) {
220+
it(`${file.split('.').pop().toUpperCase()}${name}`, async () => {
221+
const walkResult = normalize(walkExtract(code, file));
222+
const queryResult = normalize(await queryExtract(code, file));
223+
expect(queryResult).toEqual(walkResult);
224+
});
225+
}
226+
});

0 commit comments

Comments
 (0)