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
65 changes: 65 additions & 0 deletions .github/workflows/windows-ast-read-memory.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: 'Windows AST Read Memory (issue #3232)'

on:
pull_request:
paths:
- 'packages/tools/src/tools/ast-edit/**'
- 'packages/tools/src/tools/ast-edit.ts'
- 'packages/tools/src/acquisition/**'
- 'packages/tools/src/utils/ast-grep-utils.ts'
- '.bun-version'
- '.github/workflows/windows-ast-read-memory.yml'
push:
branches:
- 'main'
paths:
- 'packages/tools/src/tools/ast-edit/**'
- 'packages/tools/src/tools/ast-edit.ts'
- 'packages/tools/src/acquisition/**'
- 'packages/tools/src/utils/ast-grep-utils.ts'
- '.bun-version'
- '.github/workflows/windows-ast-read-memory.yml'
workflow_dispatch:

# Cancel superseded runs of this workflow for the same ref so repeated pushes
# to a PR do not queue redundant Windows jobs.
concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: true

jobs:
windows-ast-read-memory:
name: 'Bun ast_read_file memory regression on Windows'
runs-on: 'windows-latest'
timeout-minutes: 20
permissions:
contents: 'read'
steps:
- name: 'Checkout'
uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' # ratchet:actions/checkout@v7
with:
fetch-depth: 1
# The job only reads repository content; retained credentials would
# be an unnecessary secret surface for the spawned test children.
persist-credentials: false

- name: 'Setup Bun'
uses: 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' # ratchet:oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'

# Plain `bun install` (NOT --frozen-lockfile): the monorepo lockfile is
# structurally unusable with --frozen-lockfile under Bun re-normalization
# (see AGENTS notes); plain install against the committed lockfile is
# deterministic and sufficient for running the test suite.
- name: 'Install dependencies'
run: 'bun install'

# Cross-platform memory regression for ast_read_file: a child Bun
# process executes the real tool against a generated Git workspace that
# previously triggered the multi-symbol native findInFiles fan-out,
# samples peak RSS conservatively, and proves the child drains and
# exits. The non-Windows path is covered by the ordinary packages/tools
# Bun test suite in CI, which includes the same test file.
- name: 'Run ast_read_file memory regression (Bun)'
run: 'bun test packages/tools/src/tools/ast-edit/__tests__/ast-read-memory.bun.test.ts'
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* @license
* Copyright 2026 Vybestack LLC
* SPDX-License-Identifier: Apache-2.0
*
* Behavioral tests for the ASTQueryExtractor line-scan fallback and its
* bounded variant (issue #3232 remediation). The fallback is reached for
* languages with an ast-grep mapping but no declaration family (ruby, go,
* java, cpp, html, css, json) and whenever a native parse throws.
*/

import { describe, it, expect } from 'bun:test';
import { ASTQueryExtractor } from '../ast-query-extractor.js';

const extractor = new ASTQueryExtractor();

/** Indented fallback-declaration source with known name columns. */
const INDENTED_SOURCE = [
'module Wrapper',
' class Service',
' def run',
' end',
' end',
'end',
].join('\n');

/** C-shaped source whose declarations only exist in the C family mapping. */
const C_SHAPED_SOURCE = 'struct Point {\n int x;\n};\n';

describe('fallback declaration extraction columns', () => {
it('computes declaration columns against the raw line so indentation is kept', async () => {
const declarations = await extractor.extractDeclarations(
'/repo/wrapper.rb',
INDENTED_SOURCE,
);
const service = declarations.find((decl) => decl.name === 'Service');
const run = declarations.find((decl) => decl.name === 'run');
expect(service?.line).toBe(2);
// ' class Service' → 'Service' starts at raw column 8, not 6.
expect(service?.column).toBe(8);
expect(run?.line).toBe(3);
// ' def run' → 'run' starts at raw column 8, not 4.
expect(run?.column).toBe(8);
expect(service?.range.start.column).toBe(8);
expect(service?.range.end.column).toBe(8 + 'Service'.length);
});

it('keeps raw-line columns in the bounded fallback scan', async () => {
const declarations = await extractor.extractDeclarationsBounded(
'/repo/wrapper.rb',
INDENTED_SOURCE,
10,
);
const service = declarations.find((decl) => decl.name === 'Service');
expect(service?.column).toBe(8);
});
});

describe('bounded fallback limit validation', () => {
it('rejects a NaN limit instead of scanning unboundedly', async () => {
await expect(
extractor.extractDeclarationsBounded(
'/repo/wrapper.rb',
INDENTED_SOURCE,
Number.NaN,
),
).rejects.toThrow(/limit/);
});

it('still permits a positive-infinity limit for the unbounded legacy path', async () => {
const declarations = await extractor.extractDeclarationsBounded(
'/repo/wrapper.rb',
INDENTED_SOURCE,
Number.POSITIVE_INFINITY,
);
expect(declarations.map((decl) => decl.name)).toEqual(['Service', 'run']);
});

it('returns no declarations for a zero limit', async () => {
const declarations = await extractor.extractDeclarationsBounded(
'/repo/wrapper.rb',
INDENTED_SOURCE,
0,
);
expect(declarations).toEqual([]);
});

it('stops at the limit in document order with output identical to the unbounded scan', async () => {
// A declaration-dense fallback file: the bounded scan must return
// exactly the first declarations of the unbounded scan (names, lines,
// columns, ranges) and never materialize past the limit.
const lines = Array.from({ length: 2000 }, (_, i) => ` def method${i}`);
const source = lines.join('\n');
const bounded = await extractor.extractDeclarationsBounded(
'/repo/dense.rb',
source,
3,
);
const unbounded = await extractor.extractDeclarations(
'/repo/dense.rb',
source,
);
expect(bounded).toEqual(unbounded.slice(0, 3));
expect(bounded.map((decl) => decl.name)).toEqual([
'method0',
'method1',
'method2',
]);
// Raw-line column is preserved: ' def method0' puts the name at column 6.
expect(bounded.every((decl) => decl.column === 6)).toBe(true);
});
});

describe('declaration family resolution', () => {
it('extracts C declarations for .c but does not default unmapped .cpp to the C family', async () => {
const cDeclarations = await extractor.extractDeclarations(
'/repo/point.c',
C_SHAPED_SOURCE,
);
expect(
cDeclarations.some(
(decl) => decl.name === 'Point' && decl.type === 'struct',
),
).toBe(true);

// '.cpp' has an ast-grep mapping but no declaration family: the same
// source must go through the keyword line scan (no 'struct' keyword),
// never be silently interpreted with C declaration kinds.
const cppDeclarations = await extractor.extractDeclarations(
'/repo/point.cpp',
C_SHAPED_SOURCE,
);
expect(cppDeclarations).toEqual([]);
});

it('uses the same family resolution in the bounded walk', async () => {
const cDeclarations = await extractor.extractDeclarationsBounded(
'/repo/point.c',
C_SHAPED_SOURCE,
5,
);
expect(cDeclarations.map((decl) => decl.name)).toEqual(['Point']);
const cppDeclarations = await extractor.extractDeclarationsBounded(
'/repo/point.cpp',
C_SHAPED_SOURCE,
5,
);
expect(cppDeclarations).toEqual([]);
});
});
Loading
Loading