Skip to content
Open
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
13 changes: 13 additions & 0 deletions __tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,19 @@ export function validateEmail(email: string): boolean {

expect(result.nodes.size).toBeLessThanOrEqual(5);
});

it('should clamp absurd searchLimit/maxNodes values to safe upper bounds', async () => {
// Without clamping, the internal `findNodesByExactName` query would
// request `searchLimit * 5` rows — passing 1e9 here would blow out
// memory. The call should complete in normal time and not return more
// than the hard cap on maxNodes (1000).
const result = await cg.findRelevantContext('function', {
searchLimit: 1_000_000_000,
maxNodes: 1_000_000_000,
traversalDepth: 1_000,
});
expect(result.nodes.size).toBeLessThanOrEqual(1000);
});
});

describe('buildContext()', () => {
Expand Down
69 changes: 69 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3079,3 +3079,72 @@ describe('Directory Exclusion', () => {
expect(files.every((f) => !f.includes('vendor'))).toBe(true);
});
});

// =============================================================================
// Svelte line-number regressions (audit fix)
// =============================================================================

describe('Svelte line numbering', () => {
it('reports symbol line numbers relative to the .svelte file, not the script content', () => {
// Line 1: <script>
// Line 2: function add(a, b) { return a + b; }
// Line 3: </script>
const code = `<script>\nfunction add(a, b) { return a + b; }\n</script>\n`;
const result = extractFromSource('Comp.svelte', code);
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'add');
expect(fn).toBeDefined();
expect(fn?.startLine).toBe(2);
});

it('handles multi-line opening tags (script with attributes wrapped)', () => {
// Line 1: <script
// Line 2: lang="ts">
// Line 3: function greet() { return "hi"; }
// Line 4: </script>
const code = `<script\n lang="ts">\nfunction greet() { return "hi"; }\n</script>\n`;
const result = extractFromSource('Comp.svelte', code);
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet');
expect(fn).toBeDefined();
expect(fn?.startLine).toBe(3);
});

it('preserves correct line numbers when the script block is offset by template lines', () => {
// Line 1: <h1>Hello</h1>
// Line 2:
// Line 3: <script>
// Line 4: function bottom() {}
// Line 5: </script>
const code = `<h1>Hello</h1>\n\n<script>\nfunction bottom() {}\n</script>\n`;
const result = extractFromSource('Comp.svelte', code);
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'bottom');
expect(fn).toBeDefined();
expect(fn?.startLine).toBe(4);
});

it('handles a single-line script block with no internal newline', () => {
// Line 1: <script>function inline() { return 1; }</script>
const code = `<script>function inline() { return 1; }</script>\n`;
const result = extractFromSource('Comp.svelte', code);
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'inline');
expect(fn).toBeDefined();
expect(fn?.startLine).toBe(1);
});

it('attributes each block correctly when a file has both module and instance scripts', () => {
// Line 1: <script context="module">
// Line 2: function moduleHelper() {}
// Line 3: </script>
// Line 4:
// Line 5: <script>
// Line 6: function instanceHelper() {}
// Line 7: </script>
const code =
`<script context="module">\nfunction moduleHelper() {}\n</script>\n` +
`\n<script>\nfunction instanceHelper() {}\n</script>\n`;
const result = extractFromSource('Comp.svelte', code);
const moduleFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'moduleHelper');
const instanceFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'instanceHelper');
expect(moduleFn?.startLine).toBe(2);
expect(instanceFn?.startLine).toBe(6);
});
});
30 changes: 30 additions & 0 deletions __tests__/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,36 @@ describe('FileWatcher', () => {

watcher.stop();
});

it('should retry pending changes after a sync failure (no events lost)', async () => {
// First call rejects, subsequent calls resolve. After the initial
// failure, the watcher should retry the same batch on its own — without
// this, transient sync failures (DB locked etc.) would silently drop the
// changes until a new file event happened.
let calls = 0;
const syncFn = vi.fn().mockImplementation(() => {
calls++;
if (calls === 1) return Promise.reject(new Error('transient'));
return Promise.resolve({ filesChanged: 1, durationMs: 5 });
});
const onSyncError = vi.fn();
const onSyncComplete = vi.fn();
const watcher = new FileWatcher(testDir, baseConfig, syncFn, {
debounceMs: 100,
onSyncError,
onSyncComplete,
});

watcher.start();
fs.writeFileSync(path.join(testDir, 'src', 'test.ts'), 'export const z = 3;');

await waitFor(() => onSyncComplete.mock.calls.length > 0, 5000);
expect(onSyncError).toHaveBeenCalledTimes(1);
expect(syncFn).toHaveBeenCalledTimes(2);
expect(onSyncComplete).toHaveBeenCalledWith({ filesChanged: 1, durationMs: 5 });

watcher.stop();
});
});

describe('CodeGraph integration', () => {
Expand Down
8 changes: 8 additions & 0 deletions src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,14 @@ export class ContextBuilder {
options: FindRelevantContextOptions = {}
): Promise<Subgraph> {
const opts = { ...DEFAULT_FIND_OPTIONS, ...options };
// Bound user-supplied limits — `searchLimit` is multiplied by 5 in
// findNodesByExactName (line 312) and feeds several other unbounded
// operations below, so a request with `searchLimit: 1_000_000` would
// pull millions of rows before any filtering. 100 is well above the
// largest legitimate use we've seen.
opts.searchLimit = Math.min(Math.max(1, opts.searchLimit), 100);
opts.maxNodes = Math.min(Math.max(1, opts.maxNodes), 1000);
opts.traversalDepth = Math.min(Math.max(0, opts.traversalDepth), 10);

// Start with empty subgraph
const nodes = new Map<string, Node>();
Expand Down
10 changes: 7 additions & 3 deletions src/extraction/svelte-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,17 @@ export class SvelteExtractor {
// Detect module script
const isModule = /context\s*=\s*["']module["']/.test(attrs);

// Calculate start line of the script content (line after <script>)
// The content captured by the regex includes the leading newline that
// follows `>`, so the inner extractor sees that newline as line 1 of
// its (1-indexed) input and the first real code on line 2. Offset is
// therefore the line number where the opening `<script ...>` tag ends
// (0-indexed) — adding it to the inner extractor's 1-indexed lines
// yields correct 1-indexed positions in the .svelte file.
const beforeScript = this.source.substring(0, match.index);
const scriptTagLine = (beforeScript.match(/\n/g) || []).length;
// The content starts on the line after the opening <script> tag
const openingTag = match[0].substring(0, match[0].indexOf('>') + 1);
const openingTagLines = (openingTag.match(/\n/g) || []).length;
const contentStartLine = scriptTagLine + openingTagLines + 1; // 0-indexed line
const contentStartLine = scriptTagLine + openingTagLines;

blocks.push({
content,
Expand Down
23 changes: 21 additions & 2 deletions src/graph/traversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,30 @@ import { Node, Edge, Subgraph, TraversalOptions, EdgeKind } from '../types';
import { QueryBuilder } from '../db/queries';

/**
* Default traversal options
* Default traversal options.
*
* `maxDepth` is bounded by default — an unbounded depth on a highly connected
* graph can grow `visited` and the BFS/DFS frontier well beyond `limit` before
* the limit cuts in. Callers who really want unlimited depth can pass
* `maxDepth: Infinity` explicitly.
*/
const DEFAULT_OPTIONS: Required<TraversalOptions> = {
maxDepth: Infinity,
maxDepth: 10,
edgeKinds: [],
nodeKinds: [],
direction: 'outgoing',
limit: 1000,
includeStart: true,
};

/**
* Hard cap on `findPath`'s BFS queue — each queue entry clones the full path
* array, so on a dense graph the queue can balloon into millions of entries
* before either finding a path or exhausting the search. This bounds the
* worst-case memory footprint of a single findPath call.
*/
const FIND_PATH_MAX_QUEUE = 100_000;

/**
* Result of a single traversal step
*/
Expand Down Expand Up @@ -548,6 +561,12 @@ export class GraphTraverser {
];

while (queue.length > 0) {
// Hard ceiling on memory: each queue entry holds a cloned path array,
// so a single dense node could push the queue well past nominal otherwise.
if (queue.length > FIND_PATH_MAX_QUEUE) {
return null;
}

const { nodeId, path } = queue.shift()!;

if (nodeId === toId) {
Expand Down
12 changes: 11 additions & 1 deletion src/sync/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,17 +177,27 @@ export class FileWatcher {
this.hasChanges = false;
this.syncing = true;

let syncFailed = false;
try {
const result = await this.syncFn();
this.onSyncComplete?.(result);
} catch (err) {
syncFailed = true;
const error = err instanceof Error ? err : new Error(String(err));
logWarn('Watch sync failed', { error: error.message });
this.onSyncError?.(error);
} finally {
this.syncing = false;

// If new changes arrived during sync, schedule another
// Re-set hasChanges if the sync failed so the dropped batch isn't
// forgotten — without this, a transient sync failure leaves the index
// stale until a *new* file event happens to retrigger.
if (syncFailed) {
this.hasChanges = true;
}

// If we have pending changes (either from the failed sync or new
// events that arrived during it), schedule another flush.
if (this.hasChanges && !this.stopped) {
this.scheduleSync();
}
Expand Down
7 changes: 6 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,12 @@ export interface Subgraph {
* Options for graph traversal
*/
export interface TraversalOptions {
/** Maximum depth to traverse (default: Infinity) */
/**
* Maximum depth to traverse (default: 10).
* Pass `Infinity` to traverse the full reachable subgraph; callers should
* combine that with a sensible `limit` since highly connected graphs can
* produce a frontier far larger than `limit` allows during traversal.
*/
maxDepth?: number;

/** Edge types to follow (default: all) */
Expand Down