Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion __tests__/extraction-vba-event-fanout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import { VbaExtractor, DEFAULT_MAX_RAISE_FANOUT } from '../src/extraction/vba-ex
import { loadVbaConfig, clearProjectConfigCache } from '../src/project-config';

function extract(filePath: string, source: string, maxRaiseFanout?: number) {
return new VbaExtractor(filePath, source, undefined, maxRaiseFanout).extract();
// Issue #243: the options-object form. An absent `maxRaiseFanout` still
// means "use DEFAULT_MAX_RAISE_FANOUT", exactly as the old positional form
// did — the legacy positional form is pinned in
// `extraction-vba-options.test.ts`.
return new VbaExtractor(filePath, source, { maxRaiseFanout }).extract();
}

/** Build a `.cls` with one event raised `n` times across N small Sub procs. */
Expand Down
290 changes: 290 additions & 0 deletions __tests__/extraction-vba-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
/**
* Issue #243 — `VbaExtractionOptions`, the one object that replaces seven
* positional VBA-extraction parameters across five files.
*
* This is a PURE REFACTOR, so every test here is an equivalence test: the new
* object form must produce the SAME `ExtractionResult` as the old positional
* form, and the knobs must keep gating exactly as they did.
*
* Acceptance criteria pinned here (from the issue body):
* - object form and legacy positional form produce deep-equal
* `ExtractionResult` (minus the wall-clock `durationMs`)
* - an option set on the object survives a REAL worker round-trip through
* `parse-pool` — i.e. it crosses `structuredClone` intact
* - `maxRaiseFanout` still gates as before (regression guard for #152)
*
* Real files, real extractor, a real `worker_threads.Worker`. No mocking.
*/
import { describe, it, expect } from 'vitest';
import { Worker } from 'node:worker_threads';
import { VbaExtractor, DEFAULT_MAX_RAISE_FANOUT } from '../src/extraction/vba-extractor';
import { extractFromSource } from '../src/extraction/tree-sitter';
import { ParseWorkerPool, type ParsePoolWorker } from '../src/extraction/parse-pool';
import type { VbaExtractionOptions } from '../src/extraction/vba/options';
import type { ExtractionResult, Language } from '../src/types';

const FILE = 'src/classes/OptionsProbe.cls';

/**
* A module that exercises BOTH knobs at once:
* - `#If DEV` makes the result depend on `targets`
* - `RaiseEvent Changed` makes it depend on `maxRaiseFanout`
*/
const VBA_SRC = [
'Attribute VB_Name = "OptionsProbe"',
'Public Event Changed()',
'',
'#If DEV Then',
'Public Sub DevOnly()',
' Helper',
'End Sub',
'#End If',
'',
'Public Sub DoWork()',
' RaiseEvent Changed',
' Helper',
'End Sub',
'',
'Public Sub Helper()',
'End Sub',
'',
].join('\n');

/** Build a `.cls` with one event raised `n` times, one raise site per Sub. */
function buildClassWithRaisedEvent(eventName: string, n: number): string {
const lines = ['Attribute VB_Name = "FanoutProbe"', `Public Event ${eventName}()`];
for (let i = 0; i < n; i++) {
lines.push(`Public Sub RaiseSite_${i}()`, ` RaiseEvent ${eventName}`, 'End Sub');
}
return lines.join('\n');
}

/**
* `ExtractionResult.durationMs` and `Node.updatedAt` are wall-clock readings,
* so they differ between two runs of identical input. They are the ONLY fields
* an equivalence assertion may ignore. Everything else — every node field,
* every edge, every unresolved reference, every error — must match.
*/
function comparable(r: ExtractionResult): unknown {
const { durationMs: _durationMs, ...rest } = r;
return {
...rest,
nodes: rest.nodes.map(({ updatedAt: _updatedAt, ...node }) => node),
};
}

const names = (r: ExtractionResult): string[] => r.nodes.map((n) => n.name).sort();
const raiseEdges = (r: ExtractionResult) => r.edges.filter((e) => e.kind === 'raises-event');

describe('Issue #243 — VbaExtractor options object vs the legacy positionals', () => {
it('object form and legacy positional form produce deep-equal results', () => {
const objectForm = new VbaExtractor(FILE, VBA_SRC, {
targets: { DEV: true },
maxRaiseFanout: 7,
}).extract();
// The `@deprecated` overload: targets 3rd, maxRaiseFanout 4th.
const legacyForm = new VbaExtractor(FILE, VBA_SRC, { DEV: true }, 7).extract();

expect(comparable(objectForm)).toEqual(comparable(legacyForm));
// Guard the guard: if `targets` were silently dropped, both runs would be
// equal AND wrong. `DevOnly` only survives preprocessing when DEV is true.
expect(names(objectForm)).toContain('DevOnly');
});

it('targets threads through the object exactly as the positional did (DEV off)', () => {
const objectForm = new VbaExtractor(FILE, VBA_SRC, { targets: { DEV: false } }).extract();
const legacyForm = new VbaExtractor(FILE, VBA_SRC, { DEV: false }).extract();

expect(comparable(objectForm)).toEqual(comparable(legacyForm));
expect(names(objectForm)).not.toContain('DevOnly');
});

it('no options, empty options, and explicit-undefined positionals are all the same run', () => {
const bare = new VbaExtractor(FILE, VBA_SRC).extract();
const emptyObject = new VbaExtractor(FILE, VBA_SRC, {}).extract();
const explicitUndefined = new VbaExtractor(FILE, VBA_SRC, undefined, undefined).extract();

expect(comparable(emptyObject)).toEqual(comparable(bare));
expect(comparable(explicitUndefined)).toEqual(comparable(bare));
// The zero-config default leaves the DEV branch out.
expect(names(bare)).not.toContain('DevOnly');
});

it('an absent maxRaiseFanout still falls back to DEFAULT_MAX_RAISE_FANOUT', () => {
const src = buildClassWithRaisedEvent('Changed', DEFAULT_MAX_RAISE_FANOUT + 10);
const viaObject = new VbaExtractor(FILE, src, { targets: undefined }).extract();
const viaLegacy = new VbaExtractor(FILE, src, undefined, undefined).extract();

expect(comparable(viaObject)).toEqual(comparable(viaLegacy));
// Default 50 < 60 raise sites → the gate fires on both.
expect(raiseEdges(viaObject)).toHaveLength(0);
});
});

describe('Issue #243 — maxRaiseFanout still gates as before (regression guard for #152)', () => {
it('over the threshold drops every raises-event edge and flags the node', () => {
const src = buildClassWithRaisedEvent('Changed', 12);
const r = new VbaExtractor(FILE, src, { maxRaiseFanout: 10 }).extract();

const event = r.nodes.find((n) => n.kind === 'event' && n.name === 'Changed');
expect(event?.metadata?.highFanout).toBe(true);
expect(event?.metadata?.raiseCount).toBe(12);
expect(raiseEdges(r)).toHaveLength(0);
});

it('at the threshold every edge survives (the gate is a strict `>`)', () => {
const src = buildClassWithRaisedEvent('Changed', 10);
const r = new VbaExtractor(FILE, src, { maxRaiseFanout: 10 }).extract();

const event = r.nodes.find((n) => n.kind === 'event' && n.name === 'Changed');
expect(event?.metadata?.highFanout).toBeUndefined();
expect(raiseEdges(r)).toHaveLength(10);
});

it('the object form and the positional form gate identically', () => {
const src = buildClassWithRaisedEvent('Changed', 12);
const viaObject = new VbaExtractor(FILE, src, { maxRaiseFanout: 10 }).extract();
const viaLegacy = new VbaExtractor(FILE, src, undefined, 10).extract();

expect(comparable(viaObject)).toEqual(comparable(viaLegacy));
});
});

describe('Issue #243 — extractFromSource merges the legacy positionals into vbaOptions', () => {
it('trailing vbaOptions matches the legacy 5th/6th positionals', () => {
const viaObject = extractFromSource(FILE, VBA_SRC, 'vba', undefined, undefined, undefined, true, {
targets: { DEV: true },
maxRaiseFanout: 7,
});
const viaLegacy = extractFromSource(FILE, VBA_SRC, 'vba', undefined, { DEV: true }, 7, true);

expect(comparable(viaObject)).toEqual(comparable(viaLegacy));
expect(names(viaObject)).toContain('DevOnly');
});

it('the object wins per field when both forms are supplied', () => {
const src = buildClassWithRaisedEvent('Changed', 12);
// Positional says "cap at 1000" (no gate); the object says "cap at 10".
const merged = extractFromSource(FILE, src, 'vba', undefined, undefined, 1000, true, {
maxRaiseFanout: 10,
});
const objectOnly = extractFromSource(FILE, src, 'vba', undefined, undefined, undefined, true, {
maxRaiseFanout: 10,
});
const positionalOnly = extractFromSource(FILE, src, 'vba', undefined, undefined, 1000, true);

expect(comparable(merged)).toEqual(comparable(objectOnly));
expect(raiseEdges(merged)).toHaveLength(0);
// Sanity: the positional value alone really would NOT have gated.
expect(raiseEdges(positionalOnly)).toHaveLength(12);
});

it('a field absent from both forms falls through to the extractor default', () => {
const viaNothing = extractFromSource(FILE, VBA_SRC, 'vba');
const viaEmptyObject = extractFromSource(FILE, VBA_SRC, 'vba', undefined, undefined, undefined, true, {});

expect(comparable(viaEmptyObject)).toEqual(comparable(viaNothing));
});
});

describe('Issue #243 — VbaExtractionOptions is plain, structured-cloneable data', () => {
it('survives structuredClone unchanged (no functions, RegExp, Map or class instances)', () => {
const options: VbaExtractionOptions = {
targets: { DEV: true, WIN64: false },
maxRaiseFanout: 17,
sqlWrappers: ['EjecutarSQL', 'AbrirRecordset'],
};

// This is the exact serialization `worker.postMessage` performs. A
// function or a RegExp in the object would throw DataCloneError or lose
// its identity here — which is why the compiled matcher lives on the
// extractor context, not in this object.
expect(structuredClone(options)).toEqual(options);
});
});

/**
* The REAL worker round-trip. A `worker_threads.Worker` speaks the same
* {load-grammars → grammars-loaded} / {parse → parse-result} protocol the
* production `parse-worker` does, and echoes the `vbaOptions` it received back
* across the boundary. Both hops are genuine `structuredClone`s, so this fails
* the moment a non-cloneable value is added to `VbaExtractionOptions`.
*
* It deliberately does NOT load tree-sitter grammars or a built `dist/`: the
* claim under test is that the OPTIONS survive the boundary, not that the
* extractor runs inside a worker (the extraction suite covers that).
*/
const ECHO_WORKER = `
const { parentPort } = require('node:worker_threads');
parentPort.on('message', (msg) => {
if (msg.type === 'load-grammars') {
parentPort.postMessage({ type: 'grammars-loaded' });
return;
}
if (msg.type !== 'parse') return;
parentPort.postMessage({
type: 'parse-result',
id: msg.id,
result: {
nodes: [],
edges: [],
unresolvedReferences: [],
// The echo channel: the options exactly as they arrived in the worker.
errors: [{ message: JSON.stringify(msg.vbaOptions ?? null) }],
durationMs: 0,
},
});
});
`;

describe('Issue #243 — vbaOptions survives a real parse-pool worker round-trip', () => {
it('arrives in the worker with every field intact', async () => {
const pool = new ParseWorkerPool({
languages: ['vba' as Language],
size: 1,
createWorker: () =>
new Worker(ECHO_WORKER, { eval: true }) as unknown as ParsePoolWorker,
});

const vbaOptions: VbaExtractionOptions = {
targets: { DEV: true, WIN64: false },
maxRaiseFanout: 17,
sqlWrappers: ['EjecutarSQL', 'AbrirRecordset'],
};

try {
const result = await pool.requestParse({
filePath: FILE,
content: VBA_SRC,
language: 'vba' as Language,
vbaOptions,
});

const echoed = JSON.parse(result.errors[0]!.message) as VbaExtractionOptions;
expect(echoed).toEqual(vbaOptions);
} finally {
await pool.destroy();
}
});

it('an omitted vbaOptions arrives as undefined, not as a mangled object', async () => {
const pool = new ParseWorkerPool({
languages: ['vba' as Language],
size: 1,
createWorker: () =>
new Worker(ECHO_WORKER, { eval: true }) as unknown as ParsePoolWorker,
});

try {
const result = await pool.requestParse({
filePath: FILE,
content: VBA_SRC,
language: 'vba' as Language,
});

expect(JSON.parse(result.errors[0]!.message)).toBeNull();
} finally {
await pool.destroy();
}
});
});
25 changes: 19 additions & 6 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../types';
import { QueryBuilder } from '../db/queries';
import { extractFromSource } from './tree-sitter';
import type { VbaExtractionOptions } from './vba/options';
import { readVbaSource, isVbaFamilyFile } from './vba-source';
import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './parse-pool';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes } from './grammars';
Expand Down Expand Up @@ -1603,9 +1604,15 @@ export class ExtractionOrchestrator {
// Issue #152: pull the optional `vba.maxRaiseFanout` knob from the
// same `codegraph.json` → `vba` block the targets live in. The VBA
// extractor applies the per-file fanout gate at this threshold.
// Issue #243: the knobs travel as ONE structured-cloneable options object
// from here to the extractor, on the pool path and the in-process path
// alike, so a new knob is a field here instead of a new positional in
// five signatures.
const vbaConfig = loadVbaConfig(this.rootDir);
const vbaTargets = vbaConfig.targets;
const maxRaiseFanout = vbaConfig.maxRaiseFanout;
const vbaOptions: VbaExtractionOptions = {
targets: vbaConfig.targets,
maxRaiseFanout: vbaConfig.maxRaiseFanout,
};
// Issue #154 — gate the 3 Dysflow-specific VBA sub-extractors. `true`
// (the default) keeps the pre-refactor behavior; `false` opts out so
// form/report/manifest/sequence files are tracked as just a `file`
Expand Down Expand Up @@ -1724,8 +1731,9 @@ export class ExtractionOrchestrator {
// the worker boundary (or the in-process fallback) so every VBA
// file sees the same gate.
// Issue #154: also thread the optional `vba.dysflowExport` flag.
if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, frameworkNames, vbaTargets, maxRaiseFanout, dysflowExport));
return pool.requestParse({ filePath, content, language, frameworkNames, vbaTargets, maxRaiseFanout, dysflowExport });
// Issue #243: both knobs now ride in the `vbaOptions` object.
if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, frameworkNames, undefined, undefined, dysflowExport, vbaOptions));
return pool.requestParse({ filePath, content, language, frameworkNames, vbaOptions, dysflowExport });
};

// --- Bounded rolling-window dispatch, ordered commit ---
Expand Down Expand Up @@ -2256,9 +2264,13 @@ export class ExtractionOrchestrator {
content,
language,
frameworkNames,
vbaReindexConfig.targets,
vbaReindexConfig.maxRaiseFanout,
undefined,
undefined,
dysflowExport,
{
targets: vbaReindexConfig.targets,
maxRaiseFanout: vbaReindexConfig.maxRaiseFanout,
},
);

// Store in database
Expand Down Expand Up @@ -2675,4 +2687,5 @@ export class ExtractionOrchestrator {

// Re-export useful types and functions
export { extractFromSource } from './tree-sitter';
export type { VbaExtractionOptions } from './vba/options';
export { detectLanguage, isSourceFile, isLanguageSupported, isGrammarLoaded, getSupportedLanguages, initGrammars, loadGrammarsForLanguages, loadAllGrammars } from './grammars';
14 changes: 9 additions & 5 deletions src/extraction/parse-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

import { Worker } from 'worker_threads';
import type { Language, ExtractionResult } from '../types';
import type { VbaExtractionOptions } from './vba/options';

/**
* Minimal worker surface the pool drives — satisfied by a real `worker_threads`
Expand All @@ -51,9 +52,13 @@ export interface ParseTask {
content: string;
language: Language;
frameworkNames?: string[];
vbaTargets?: Record<string, boolean>;
/** Issue #152: per-file fanout cap for `RaiseEvent` edges (VBA only). */
maxRaiseFanout?: number;
/**
* Issue #243: every VBA-specific extraction knob in ONE structured-cloneable
* object (previously the separate `vbaTargets` / `maxRaiseFanout` fields).
* It crosses the worker boundary via `postMessage`, so it must stay plain
* data — see the contract in `./vba/options`.
*/
vbaOptions?: VbaExtractionOptions;
/**
* Issue #154 — gate the 3 Dysflow-specific VBA sub-extractors. `true`
* (the default) keeps the pre-refactor behavior; `false` opts out so
Expand Down Expand Up @@ -351,8 +356,7 @@ export class ParseWorkerPool {
content: job.task.content,
frameworkNames: job.task.frameworkNames,
language: job.task.language,
vbaTargets: job.task.vbaTargets,
maxRaiseFanout: job.task.maxRaiseFanout,
vbaOptions: job.task.vbaOptions,
dysflowExport: job.task.dysflowExport,
});
}
Expand Down
Loading