feat(kernel): R2 — full TypeScript/JavaScript extraction port, byte-parity with the wasm path
Replaces the R1 seed .scm emitter with a bespoke Rust walker (codegraph-kernel/src/tsjs/) that mirrors TreeSitterExtractor's TS/JS paths function-for-function: declarations (incl. #808 field/property classification), qualified names, docstrings (#780 wrapper climbs), signatures, imports/re-exports + per-binding refs, calls with receiver-qualified callees (#1230 literal-receiver skip), instantiations, decorators, inheritance, type annotations (#381), type-alias members + tuple contracts (#359/#634), React component recognition (#841 forwardRef/memo/styled), object-of-functions / zustand-through-middleware / RTK Query endpoints + generated hooks / vuex + pinia store shapes, function-as-value capture with the flush gate (#756), and value-reference edges with the shadow prune (#895/#897). The generic query emitter is deleted — extraction parity needs logic .scm can't express; future languages get walkers too (migration plan §4a). Positions and JS string-slice semantics are emitted in UTF-16 code units natively, so kernel output is byte-identical to web-tree-sitter's — no column diff class exists. Parity evidence (macOS): scripts/kernel-parity.mjs (full-object multiset diff per file) — this repo 353/353 files, excalidraw 643/643 (10,650 nodes / 10,726 edges / 68,307 refs), plus torture fixtures checked into __tests__/fixtures/kernel-parity/ and enforced in npm test by kernel-tsjs-parity.test.ts. The strict compare caught one real decoder bug the loose harness missed: refs must NOT carry denormalized filePath/language at the extractFromSource seam (the store fills them). Perf: extraction 2.6× single-thread on excalidraw (487ms vs 1,255ms, identical outputs). Routing stays opt-in (CODEGRAPH_KERNEL_LANGS) until the R3 equivalence gate (large repo, DB dump-diff, retrieval invariants, agent A/B, Linux/Windows) passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c5eebe6beb
commit
9ad5cd7ba2
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* JS-grammar torture fixture (javascript variant: no type machinery, JS class
|
||||
* fields use `field_definition` with a `property` field).
|
||||
*/
|
||||
import { EventEmitter } from 'node:events';
|
||||
const { promisify } = require('node:util');
|
||||
|
||||
/** Legacy prototype-style helper. */
|
||||
function legacyHelper(a, b) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
const arrow = (x) => legacyHelper(x, 1);
|
||||
|
||||
class Widget extends EventEmitter {
|
||||
static registry = new Map();
|
||||
#privateField = 1;
|
||||
label = 'w';
|
||||
onTick = () => {
|
||||
this.render();
|
||||
};
|
||||
wrapped = debounce(function () {
|
||||
expensive();
|
||||
}, 50);
|
||||
|
||||
constructor(opts) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
register(this.onTick);
|
||||
}
|
||||
|
||||
render() {
|
||||
paint(this.label);
|
||||
}
|
||||
|
||||
static create(opts) {
|
||||
return new Widget(opts);
|
||||
}
|
||||
}
|
||||
|
||||
// AMD-style wrapper — anonymous, but inner functions must still surface.
|
||||
(function () {
|
||||
function hiddenInner() {
|
||||
return 7;
|
||||
}
|
||||
hiddenInner();
|
||||
})();
|
||||
|
||||
module.exports.makeWidget = function makeWidget(opts) {
|
||||
return Widget.create(opts);
|
||||
};
|
||||
|
||||
// Vuex module shape (store-file signals: mutations + actions + getters).
|
||||
const mutations = {
|
||||
SET_USER(state, user) {
|
||||
state.user = user;
|
||||
},
|
||||
};
|
||||
const actions = {
|
||||
async loadUser({ commit }, id) {
|
||||
const user = await fetchUser(id);
|
||||
commit('SET_USER', user);
|
||||
},
|
||||
};
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: () => ({ user: null }),
|
||||
mutations,
|
||||
actions,
|
||||
getters: {
|
||||
userName(state) {
|
||||
return state.user?.name;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Torture fixture — exercises every TS/TSX extraction path the kernel ports.
|
||||
*/
|
||||
import React, { forwardRef, memo, useState as useStateAlias } from 'react';
|
||||
import * as NS from './namespace-module';
|
||||
import DefaultThing from './default-module';
|
||||
import './side-effect';
|
||||
import { helperFn, CONFIG_TABLE } from './helpers';
|
||||
|
||||
export { reExported, orig as aliased } from './barrel-source';
|
||||
export * from './star-source';
|
||||
|
||||
// A documented constant table (value-ref target).
|
||||
const RETRY_LIMITS = { a: 1, b: 2 };
|
||||
export const API_BASE = 'https://example.test';
|
||||
let plainLet = 42;
|
||||
var oldVar = 'x';
|
||||
|
||||
/** Class docs.
|
||||
* Multi-line.
|
||||
*/
|
||||
@Injectable()
|
||||
@scoped.Registry<Config>('name')
|
||||
export abstract class BaseService extends EventTarget implements Disposable, Serializable {
|
||||
static instances = 0;
|
||||
private readonly cache: Map<string, Config> = new Map();
|
||||
public fonts: FontConfig;
|
||||
count = 0;
|
||||
onScroll = throttle((e: Event) => {
|
||||
this.handleScroll(e);
|
||||
}, 100);
|
||||
handleClick = (ev: MouseEvent): void => {
|
||||
emitTelemetry(ev);
|
||||
new AbortController();
|
||||
};
|
||||
|
||||
@Get('/list')
|
||||
async list(query: QueryOpts): Promise<Result<Item>> {
|
||||
const local: ItemMapper = makeMapper();
|
||||
const limit = RETRY_LIMITS;
|
||||
return this.cache.get(query.key) ?? helperFn(query);
|
||||
}
|
||||
|
||||
private static compute(n: number): number {
|
||||
return n * RETRY_LIMITS.a;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.count;
|
||||
}
|
||||
|
||||
protected dispose(): void {
|
||||
listeners.forEach((l) => unregister(l));
|
||||
}
|
||||
}
|
||||
|
||||
class Plain {
|
||||
constructor(private svc: BaseService) {
|
||||
register(this.onEvent);
|
||||
btn.on('click', this.handleClick);
|
||||
}
|
||||
onEvent() {}
|
||||
handleClick() {}
|
||||
}
|
||||
|
||||
export interface Shape extends Named, Sized {
|
||||
area: number;
|
||||
resize(f: number): Shape;
|
||||
onchange: (s: Shape) => void;
|
||||
}
|
||||
|
||||
export enum Direction {
|
||||
Up,
|
||||
Down = 2,
|
||||
Left,
|
||||
}
|
||||
|
||||
export type Handle = {
|
||||
stop: () => void;
|
||||
id: string;
|
||||
refresh(force: boolean): Promise<void>;
|
||||
};
|
||||
|
||||
export type ServiceList = [
|
||||
Service<'query_apply_record', Req, Resp>,
|
||||
Service<'apply_confirm', Req, Resp>,
|
||||
];
|
||||
|
||||
export type MaybeShape = Shape | null;
|
||||
|
||||
export function topLevel(a: ShapeConfig): Shape {
|
||||
const inner = () => reallyDeep();
|
||||
function namedInner(): void {
|
||||
chained(a).value;
|
||||
}
|
||||
namedInner();
|
||||
return makeShape(a);
|
||||
}
|
||||
|
||||
async function* generatorFn(items: Item[]) {
|
||||
yield* items.map((i) => transform(i));
|
||||
}
|
||||
|
||||
export const arrowConst = async (x: number) => {
|
||||
return x + plainLet;
|
||||
};
|
||||
|
||||
const AnonClassHolder = class {
|
||||
method() {}
|
||||
};
|
||||
|
||||
// React components (#841).
|
||||
export const Button = forwardRef((props: ButtonProps, ref) => {
|
||||
const [state, setState] = useStateAlias(0);
|
||||
useEffect(() => {
|
||||
trackRender();
|
||||
});
|
||||
return <BaseButton ref={ref} onClick={() => setState(state + 1)} {...props} />;
|
||||
});
|
||||
|
||||
export const MemoRow = memo(function Row(props: RowProps) {
|
||||
return <tr className={props.cls}>{props.children}</tr>;
|
||||
});
|
||||
|
||||
export const Wrapped = React.memo(ImportedComponent);
|
||||
export const Styled = styled.button`
|
||||
color: red;
|
||||
`;
|
||||
const memoCache = memo(computeThing); // lowercase — stays a constant
|
||||
|
||||
export function App() {
|
||||
const nodes: GraphNode[] = [];
|
||||
return (
|
||||
<div>
|
||||
<Button label={CONFIG_TABLE.label} />
|
||||
<NS.Panel />
|
||||
{nodes.map((n) => (
|
||||
<MemoRow key={n.id} cls={n.cls} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Object-of-functions (SvelteKit-style actions map).
|
||||
export const actionsMap = {
|
||||
create: async (input: CreateInput) => {
|
||||
return persistNew(input);
|
||||
},
|
||||
update(input: UpdateInput) {
|
||||
return persistExisting(input);
|
||||
},
|
||||
};
|
||||
|
||||
// Zustand-style store through middleware wrappers.
|
||||
export const useStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
fetchUser: async (id: string) => {
|
||||
const user = await api.load(id);
|
||||
set({ user });
|
||||
},
|
||||
reset: () => set({ user: null }),
|
||||
}),
|
||||
{ name: 'store' }
|
||||
)
|
||||
);
|
||||
|
||||
// RTK Query.
|
||||
export const widgetApi = createApi({
|
||||
reducerPath: 'widgets',
|
||||
endpoints: (build) => ({
|
||||
getWidget: build.query({
|
||||
query: (id: string) => fetchWidget(id),
|
||||
}),
|
||||
updateWidget: build.mutation({
|
||||
queryFn: async (patch) => {
|
||||
return applyPatch(patch);
|
||||
},
|
||||
}),
|
||||
prebuilt: build.query(makeEndpointConfig()),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetWidgetQuery, useUpdateWidgetMutation } = widgetApi;
|
||||
const { notAHook } = widgetApi;
|
||||
|
||||
// Value-ref shadowing: SHADOWED_LIMIT re-bound locally must be pruned.
|
||||
const SHADOWED_LIMIT = 10;
|
||||
export function readsShadowed() {
|
||||
const SHADOWED_LIMIT = 20;
|
||||
return SHADOWED_LIMIT;
|
||||
}
|
||||
export function readsTable() {
|
||||
return RETRY_LIMITS.b + API_BASE.length;
|
||||
}
|
||||
|
||||
// Fn-ref registrations.
|
||||
registerHandler(helperFn);
|
||||
queueMicrotask(topLevel);
|
||||
const routeTable = { home: topLevel, missing: notDefinedAnywhere };
|
||||
const handlerList = [helperFn, DefaultThing];
|
||||
target.cb = topLevel;
|
||||
const aliasFn = topLevel;
|
||||
|
||||
// Calls with interesting callees.
|
||||
;(topLevel)(cfg);
|
||||
NS.helper.deep(1);
|
||||
"literal".includes('x');
|
||||
[1, 2].map(String);
|
||||
obj?.optMethod?.(3);
|
||||
import('./dynamic-module');
|
||||
new NS.Widget(makeArg());
|
||||
new Map<string, number>();
|
||||
super_weird?.();
|
||||
@@ -124,8 +124,11 @@ describe.skipIf(!kernelBuilt)('kernel scaffold', () => {
|
||||
[file.id, 'helper'],
|
||||
]);
|
||||
for (const r of calls) {
|
||||
expect(r.filePath).toBe('src/utils.ts');
|
||||
expect(r.language).toBe('typescript');
|
||||
// No denormalized filePath/language at the extraction seam — the wasm
|
||||
// extractors leave them unset (the store fills them, `?? filePath`),
|
||||
// and the kernel matches that exactly (see decode.ts).
|
||||
expect(r.filePath).toBeUndefined();
|
||||
expect(r.language).toBeUndefined();
|
||||
expect(r.line).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Kernel↔wasm TS/JS extraction parity (R2 of the kernel migration).
|
||||
*
|
||||
* Asserts the native walker (codegraph-kernel/src/tsjs/) produces the SAME
|
||||
* ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
|
||||
* unresolved refs compared as canonicalized multisets — over:
|
||||
* - the checked-in torture fixtures (every ported feature: components/HOCs,
|
||||
* stores, RTK, vuex, fn-refs, value-ref shadowing, decorators, enums,
|
||||
* type-alias members/tuple contracts, re-exports, JSX, field methods), and
|
||||
* - this repo's own extraction sources (real-world TS).
|
||||
*
|
||||
* The full-repo sweep lives in scripts/kernel-parity.mjs (excalidraw et al.,
|
||||
* run for the §5 gate); this suite keeps the invariant alive in `npm test`.
|
||||
* Skips when no kernel binary is staged; CODEGRAPH_KERNEL_EXPECT=1 turns that
|
||||
* into a failure (wired in kernel-scaffold.test.ts).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { extractFromSource } from '../src/extraction';
|
||||
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
|
||||
import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
|
||||
import type { ExtractionResult, Language } from '../src/types';
|
||||
|
||||
const KERNEL_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'codegraph-kernel',
|
||||
'prebuilds',
|
||||
`${process.platform}-${process.arch}`,
|
||||
'codegraph-kernel.node'
|
||||
);
|
||||
const kernelBuilt = fs.existsSync(KERNEL_PATH);
|
||||
|
||||
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'kernel-parity');
|
||||
const REAL_SOURCES = [
|
||||
'src/extraction/kernel/loader.ts',
|
||||
'src/extraction/kernel/decode.ts',
|
||||
'src/extraction/parse-pool.ts',
|
||||
'src/extraction/function-ref.ts',
|
||||
'src/mcp/tools.ts',
|
||||
];
|
||||
|
||||
function canon(result: ExtractionResult): { nodes: string[]; edges: string[]; refs: string[] } {
|
||||
return {
|
||||
nodes: result.nodes
|
||||
.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort()))
|
||||
.sort(),
|
||||
edges: result.edges.map((e) => JSON.stringify(e, Object.keys(e).sort())).sort(),
|
||||
refs: result.unresolvedReferences
|
||||
.map((r) => JSON.stringify(r, Object.keys(r).sort()))
|
||||
.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {
|
||||
beforeAll(async () => {
|
||||
await initGrammars();
|
||||
await loadGrammarsForLanguages(['typescript', 'tsx', 'javascript', 'jsx']);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
resetKernelForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = savedEnv[k];
|
||||
}
|
||||
resetKernelForTests();
|
||||
});
|
||||
|
||||
function assertParity(filePath: string, source: string, language: Language): void {
|
||||
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
||||
delete process.env.CODEGRAPH_KERNEL;
|
||||
const viaKernel = tryKernelExtract(filePath, source, language);
|
||||
expect(viaKernel, `kernel extraction failed for ${filePath}`).not.toBeNull();
|
||||
|
||||
process.env.CODEGRAPH_KERNEL = '0';
|
||||
const viaWasm = extractFromSource(filePath, source, language);
|
||||
delete process.env.CODEGRAPH_KERNEL;
|
||||
|
||||
const k = canon(viaKernel!);
|
||||
const w = canon(viaWasm);
|
||||
expect(k.nodes, `${filePath}: nodes`).toEqual(w.nodes);
|
||||
expect(k.edges, `${filePath}: edges`).toEqual(w.edges);
|
||||
expect(k.refs, `${filePath}: refs`).toEqual(w.refs);
|
||||
// Meaningful comparison, not empty-vs-empty.
|
||||
expect(viaWasm.nodes.length).toBeGreaterThan(3);
|
||||
}
|
||||
|
||||
it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => {
|
||||
const file = path.join(FIXTURE_DIR, 'torture.tsx');
|
||||
assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx');
|
||||
});
|
||||
|
||||
it('torture fixture (js): field methods, wrappers, vuex module shape', () => {
|
||||
const file = path.join(FIXTURE_DIR, 'torture.js');
|
||||
assertParity('fixtures/torture.js', fs.readFileSync(file, 'utf8'), 'javascript');
|
||||
});
|
||||
|
||||
it.each(REAL_SOURCES)('real source parity: %s', (rel) => {
|
||||
const file = path.join(__dirname, '..', rel);
|
||||
assertParity(rel, fs.readFileSync(file, 'utf8'), 'typescript');
|
||||
});
|
||||
|
||||
it('typescript fixture parsed as plain typescript variant', () => {
|
||||
// Same content through the non-tsx grammar exercises the typescript
|
||||
// (vs tsx) LangSpec pairing.
|
||||
const file = path.join(__dirname, '..', 'src/extraction/kernel/index.ts');
|
||||
assertParity('src/extraction/kernel/index.ts', fs.readFileSync(file, 'utf8'), 'typescript');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user