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
9 changes: 9 additions & 0 deletions __tests__/default-export-declaration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@ export default Main
export default (
value
) => value;
`,
'src/gen/commented.ts': `export function helper() { return 1; }
export default /* comment */ () => 2;
`,
'src/gen/use.ts': `import Issue from "./issue"
import Render from "./render"
Expand All @@ -286,6 +289,7 @@ import Persisted from "./model"
import Plain from "./plain"
import Unbalanced from "./unbalanced"
import Multi from "./multi"
import Commented from "./commented"

export function generate() {
Issue()
Expand All @@ -295,6 +299,7 @@ export function generate() {
Plain()
Unbalanced()
Multi()
Commented()
}
`,
'src/use.tsx': `import handler from "./api/vans"
Expand Down Expand Up @@ -453,6 +458,10 @@ describe('an `export default` line inside a template literal', () => {
expect(generated()).not.toContain('src/gen/multi.ts:helper');
});

it('binds a comment before an anonymous default to nothing, not to a function beside it', () => {
expect(generated()).not.toContain('src/gen/commented.ts:helper');
});

it('leaves regexes, strings and division alone, and a scan it cannot finish', () => {
const targets = generated();
expect(targets).toContain('src/gen/plain.ts:Main');
Expand Down
13 changes: 9 additions & 4 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import {
} from '../src/installer/instructions-template';

function mkTmpDir(label: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
// Realpath so the target's real paths and the test's expected paths agree
// through a symlinked temp root (macOS /var, or a TMPDIR symlink).
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`)));
}

describe('standalone installer Windows shell guidance (#1294)', () => {
Expand Down Expand Up @@ -4061,7 +4063,9 @@ function useTmpDirs(label: string): { home: () => string; cwd: () => string } {
tmpCwd = mkTmpDir(`${label}-cwd`);
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
// Global targets build paths from HOME as spelled; use the real path so a
// symlinked temp root or macOS /var matches the expected real paths.
homeRestore = setHome(fs.realpathSync(tmpHome));
});

afterEach(() => {
Expand All @@ -4081,9 +4085,10 @@ describe('Installer targets — agents with a JSON mcpServers file', () => {
const locations = (['global', 'local'] as const).filter((l) => c[l] !== null);

for (const location of locations) {
const file = () => path.join(location === 'global' ? dirs.home() : dirs.cwd(), ...c[location]!);
const baseDir = () => fs.realpathSync(location === 'global' ? dirs.home() : dirs.cwd());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Keep global expected paths consistent with the test HOME · ref 465003

baseDir() realpaths dirs.home(), but useTmpDirs passes the original tmpHome to setHome. Global targets build and return paths from os.homedir(), which retains that original spelling.

Failure: When CODEGRAPH_TEST_TMPDIR is long enough for the test setup to create its /tmp/cgt-* symlink, the global targets return paths through the symlink while file() expects paths through its real destination. The JSON target path assertions fail even though the files were written successfully. The same mismatch occurs when macOS temp paths use /var rather than /private/var.

Suggested fix: Realpath tmpHome before passing it to setHome, or preserve the original home spelling when constructing global expected paths.

const file = () => path.join(baseDir(), ...c[location]!);
const instructions = location === 'global' ? c.globalInstructions : c.localInstructions;
const instructionsFile = () => path.join(location === 'global' ? dirs.home() : dirs.cwd(), ...instructions!);
const instructionsFile = () => path.join(baseDir(), ...instructions!);

describe(`${c.id} (${location})`, () => {
it('writes the documented entry to the documented file, and nothing else for the MCP config', () => {
Expand Down
71 changes: 67 additions & 4 deletions codegraph-kernel/src/resolve/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -937,15 +937,48 @@ fn is_js_member_node(n: &KNode) -> bool {
/// function, class or arrow with no name. `export default memo(Card)` or
/// `new Service()` is an expression and answers `false`; a head the line
/// doesn't begin conservatively answers `true`.
/// The byte index of the first JavaScript line terminator at or after `from`.
fn line_comment_end(s: &str, from: usize) -> usize {
s[from..]
.char_indices()
.find(|(_, c)| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}'))
.map(|(i, _)| from + i)
.unwrap_or(s.len())
}

/// Skip whitespace, block comments and line comments between `export default`
/// and the expression that follows it.
fn skip_js_trivia(mut s: &str) -> &str {
loop {
let t = s.trim_start();
if let Some(rest) = t.strip_prefix("/*") {
if let Some(end) = rest.find("*/") {
s = &rest[end + 2..];
continue;
}
return t;
}
if let Some(rest) = t.strip_prefix("//") {
let end = line_comment_end(rest, 0);
if end == rest.len() {
return "";
}
s = &rest[end..];
continue;
}
return t;
}
}

fn export_default_head_is_anonymous(line: &str) -> bool {
let Some(m) = re!(r"^[ \t]*export[ \t]+default[ \t]+").find(line) else {
return true;
};
let mut rest = &line[m.end()..];
let mut rest = skip_js_trivia(&line[m.end()..]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Require keyword boundaries after skipping comments · ref db0043

skip_js_trivia(...) exposes the expression head to re!(r"^(?:function|class)"), which matches identifier prefixes as well as keywords.

Failure: With an exported Model and a local classify(x) { return x; }, export default /* c */ classify(Model) is classified as anonymous. A call through a default import consequently loses its edge to Model, which the previous expression fallback resolved.

Suggested fix: Recognize complete JavaScript keyword tokens for function, class, and async, and add a regression test for a commented expression whose callee starts with one of those words.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Ignore comments when balancing a parenthesized expression · ref a06475

After skip_js_trivia(...) exposes a (, the classifier scans every character and treats any ) followed by => or : as the end of an arrow parameter list.

Failure: For export default /* c */ (/* ) => */ memo(Model)), the ) inside the second comment closes the scan early, so the expression is classified as anonymous. With an exported Model and a local identity memo, calls through the default import lose their previously resolved edge to Model.

Suggested fix: Skip comments and other JavaScript literals while balancing parentheses, or classify the head from the syntax tree; cover this parenthesized expression in a regression test.

if let Some(after) = rest.strip_prefix("async") {
rest = after.trim_start();
rest = skip_js_trivia(after);
}
if re!(r"^(?:function|class)").is_match(rest) {
if re!(r"^(?:function|class)(?-u:\b)").is_match(rest) {
return true;
}
// `x =>` is anonymous; `(…)` is an arrow's parameters when `=>` or a
Expand All @@ -955,7 +988,23 @@ fn export_default_head_is_anonymous(line: &str) -> bool {
}
if rest.starts_with('(') {
let mut depth = 0i32;
for (i, c) in rest.char_indices() {
let mut i = 0;
while i < rest.len() {
let c = rest[i..].chars().next().unwrap();
// A comment inside the parameters is not a closer.
if c == '/' {
if let Some(next) = rest[i + 1..].chars().next() {
if next == '/' {
i = line_comment_end(rest, i + 2);
continue;
}
if next == '*' {
let Some(end) = rest[i + 2..].find("*/") else { return false };
i = i + 2 + end + 2;
continue;
}
}
}
if c == '(' {
depth += 1;
} else if c == ')' {
Expand All @@ -964,6 +1013,7 @@ fn export_default_head_is_anonymous(line: &str) -> bool {
return re!(r"^[ \t]*(?:=>|:)").is_match(&rest[i + 1..]);
}
}
i += c.len_utf8();
}
}
false
Expand All @@ -973,6 +1023,19 @@ fn export_default_head_is_anonymous(line: &str) -> bool {
mod tests {
use super::*;

#[test]
fn export_default_head_skips_comments() {
assert!(export_default_head_is_anonymous("export default /* c */ () => 2"));
assert!(export_default_head_is_anonymous("export default // c\n() => 2"));
assert!(export_default_head_is_anonymous("export default // c\r() => 2"));
assert!(export_default_head_is_anonymous("export default // c\u{2028}() => 2"));
assert!(export_default_head_is_anonymous("export default /* c */ function () {}"));
assert!(export_default_head_is_anonymous("export default /* c */ (/* ) */ x) => x"));
assert!(!export_default_head_is_anonymous("export default /* c */ memo(Card)"));
assert!(!export_default_head_is_anonymous("export default /* c */ functionality"));
assert!(!export_default_head_is_anonymous("export default /* c */ classy"));
}

#[test]
fn read_source_refuses_directories_and_oversized_files() {
let dir = std::env::temp_dir().join(format!("cg-read-source-{}", std::process::id()));
Expand Down
Loading