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: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Java calls inside an anonymous class's constructor arguments are recorded. In `new Base(build()) { … }`, the `build()` call and any `new Helper()` in the arguments belonged to neither the enclosing method nor the anonymous class. They now belong to the enclosing method, as they do without a class body.
- A `.mdx` path in a code string links to the `.mdx` file. `'docs/intro.mdx'` was read as `docs/intro.md`, so the reference pointed at a file that usually does not exist. Two resolver details are also tightened: the supertype order used to resolve `this.member` no longer depends on SQLite's query plan, and a file that shrank between indexing and resolution can no longer make a field lookup read past its end.
- TypeScript and JavaScript receivers are typed from the code that declares them. A parameter named like a module-level factory (`useIt(make: () => Other)` beside `function make(): Engine`) hides it, so neither `make()` nor `make().run()` links to the factory or `Engine`. An awaited result (`await load()`) and an imported instance (`store.notify()`) are typed in the file that declares them, not by a same-named class in the caller's file. A `/\}/` regex in an object literal no longer ends the literal early, and a `return` inside a callback no longer types the function around it. In a React or other framework project, `api.getState()` on `{ getState: wrong, ...override }` no longer picks `wrong`. Same-line overloads (`function y() {}; function y(n) {}`) each keep their own binding, a guard lookup finds `target` in `if(ok)target()`, and a `window.app.stop()` call split across lines is recorded as `window.app.stop`.
- Claude Code connects to CodeGraph without waiting on the shared daemon. It sends a `server/discover` probe before `initialize` and holds the handshake until it gets an answer. The probe went to the daemon, so every session start waited on the daemon connection, and when the daemon was unavailable the probe waited about 6 s and failed with an internal error. It is now answered at once with method-not-found, the reply that tells a client to use `initialize`. Measured: 6.2 s to 0.1 s with the daemon unavailable, 207 ms to 84 ms when a new daemon starts.
- Claude Code connects to CodeGraph without waiting on the shared daemon. It sends a `server/discover` probe before `initialize` and holds the handshake until it gets an answer. The probe went to the daemon, so every session start waited on the daemon connection, and when the daemon was unavailable the probe waited about 6 s and failed with an internal error. It is now answered at once with method-not-found, the reply that tells a client to use `initialize`. Measured: 6.2 s to 0.1 s with the daemon unavailable, 207 ms to 84 ms when a new daemon starts. Antigravity 2.5 and other newer clients send the same probe and get the same answer. (#2084)
- An explore answer given while the index lags behind edited files reaches Claude Code in full. The answer also carried the stale-file list as `structuredContent`, and Claude Code shows the model a result's `structuredContent` in place of its text, so the agent saw only that list. The file list now rides only in the text, which names every file it could not check where it used to stop at 20; `codegraph_status` drops the same field for the same reason.
- A project queried by `projectPath` is now released after 10 minutes without a query. The MCP server that opened it holds that project's writer lock while it stays open, so a long-running daemon for one checkout could keep another checkout's own daemon from starting until the daemon exited. The next query reopens the project and catches up. `CODEGRAPH_PROJECT_IDLE_RELEASE_MS` changes the delay; `0` keeps projects open as before.
- A search written as separate words now finds the identifier they spell. `add url rule` ranks `add_url_rule` first instead of sixth behind the one-word `add` functions, and `svelte map` finds the `SvelteMap` class, which it missed entirely. A one-word query also finds its base form: `mounting` finds `mount`.

- A search for a name the whole project imports now finds the definition. On Flask, `codegraph query Flask` listed `flask` imports and missed `class Flask` below `--limit 60`: the exact-name lookup kept the first 20 case-insensitive matches, and every one was an import. It now takes definitions first, then exact case, and the class ranks first at the default limit.
Expand Down Expand Up @@ -401,6 +400,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A Go call on the result of an imported package's function, such as `bbb.New().Bar()`, links to `Bar` on the type that `bbb.New` returns. It used to link by the bare name `Bar`, often to a same-named method in another package. A package from outside the module, like `xml.NewEncoder(w).Encode(v)` or `http.FileServer(fs).ServeHTTP(w, r)`, now gets no link instead of a project method that shares the name. Re-index to pick this up.
- Three JavaScript and TypeScript links now reach the code that runs. `setTimeout(this.create)` in a class that implements an interface declaring `create` and inherits a `create` body links to the inherited body, not the interface's declaration; the declaration is still linked when no class declares the member. A default export links to the exported declaration rather than an earlier function of the same name nested inside another function. A re-export chain that comes back into a file for a different name (`export { default as Card } from './card.js'` where `card.js` re-exports that file's `real` as its default) now links the call to `real` instead of dropping it. Re-index JavaScript and TypeScript projects after upgrading.
- Four names are now read where they are used. A PHP or TypeScript receiver typed `Sub` no longer inherits methods from the parent of a different `Sub` in another namespace or module; the walk follows the `Sub` the file names. In Kotlin, `w.let { it.render() }` no longer types `it` as `w` when `w`'s class declares its own `let`, since the member, not the standard-library function, decides what the lambda receives. A C `#define ENABLE_TRACE BACKING` now reads `BACKING` where `#if ENABLE_TRACE` is evaluated, not where the alias was defined, so redefining `BACKING` before a second `#include` changes which macros the header defines. In Python, an assignment inside a nested `def` no longer changes the type of the enclosing function's variable of the same name. Re-index to pick this up.
- On Windows, terminal windows no longer flash open and closed while CodeGraph runs in the background. Since 1.6.1, a background MCP server popped up a console window (a full Windows Terminal window when that is the default terminal) several times when it started and again every time it re-synced a changed file. All of CodeGraph's git calls now run hidden. Thanks @Suharaz, @23q3, @A-Van-Gestel and @HarryMuc. (#2094, #2096)
- The Claude Code prompt hook no longer runs on the messages Claude Code uses to hand a subagent's report back to the main session. Before, such a long report could keep the hook busy past Claude Code's 30-second hook timeout and inject context unrelated to what you asked. Thanks @danusha2345, and @tippmar-nr for the report. (#2184)
- A CodeGraph session that queried another project through `projectPath` no longer keeps that project locked for as long as it runs: after 10 minutes without a query it lets the project go, so the project's own session and `codegraph index` can take over again (tune with `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS`, `0` keeps it open). Thanks @danusha2345, and @bompus for the report. (#2087)

## [1.6.1] - 2026-09-29

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.

## About this fork

This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains all of upstream `main` (last merged: [`ec738ec7`](https://github.com/colbymchenry/codegraph/commit/ec738ec7a322a680dae39a904194ce9271e6873f), after v1.6.1, 2026-10-01) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests.
This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`fdc7754a`](https://github.com/colbymchenry/codegraph/commit/fdc7754a72065ac9a06702aa32ee3bf116a0658f) (after v1.6.1, merged 2026-10-02) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests.

The fork publishes no releases. The install scripts, npm package, badges and `codegraph upgrade` further down this page install **upstream's** releases. To run the fork, build it from source (below).

Expand Down Expand Up @@ -786,7 +786,7 @@ When running as an MCP server, CodeGraph exposes **one tool for code** — `code

The other tools (`codegraph_node`, `codegraph_search`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) stay fully functional but **unlisted by default** — everything they return already arrives inline on `codegraph_explore` (its blast-radius section, the relationship map, a symbol's body as its callee list). Re-enable any of them for the MCP surface with the `CODEGRAPH_MCP_TOOLS` environment variable (e.g. `CODEGRAPH_MCP_TOOLS=explore,node,search,callers`), or use their CLI equivalents (`codegraph node` / `query` / `callers` / `callees` / `impact` / `files` / `status`).

Even when the server's own root has no `.codegraph/` index, the tools stay available: pass `projectPath` to query any indexed project — a sub-service in a monorepo, or a second repo — in the same session. A path that has no index returns clean guidance to use built-in tools instead, so nothing fails loudly, and indexing stays your decision. A project opened this way is watched and kept in sync while the session uses it, and released after 10 minutes without a query (`CODEGRAPH_PROJECT_IDLE_RELEASE_MS`; `0` keeps it open).
Even when the server's own root has no `.codegraph/` index, the tools stay available: pass `projectPath` to query any indexed project — a sub-service in a monorepo, or a second repo — in the same session. A path that has no index returns clean guidance to use built-in tools instead, so nothing fails loudly, and indexing stays your decision. A project opened this way is watched and kept in sync while the session uses it, and released after 10 minutes without a query (`CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS`; `0` keeps it open).

---

Expand Down
208 changes: 208 additions & 0 deletions __tests__/child-process-windows-hide.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Static source guard (all platforms): every child process spawned from `src/`
* sets `windowsHide: true`.
*
* The MCP daemon is spawned detached and has no console of its own. On
* Windows, a console program (git, …) it starts without windowsHide gets a
* brand-new visible console, so a terminal window flashes on screen and closes
* once per call: on daemon start and again on every auto-sync (#485, #928,
* #1092, #2094, #2096). With Windows Terminal set as the default terminal, each
* flash is a full Windows Terminal window.
*
* windowsHide is Windows-only behavior the POSIX test runs can't observe, so it
* is asserted on the TypeScript AST. Options passed through a variable or a
* function parameter are traced back to the object literal that defines them
* (for a parameter: through every call site of that function in the file).
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import ts from 'typescript';

const SRC_DIR = path.join(__dirname, '..', 'src');

/**
* child_process functions that start a process and accept `windowsHide`, by
* argument shape: `file` is `(file, args?, options?)`, `command` is
* `(command, options?)`.
*/
const SPAWNERS: Record<string, 'file' | 'command'> = {
exec: 'command',
execSync: 'command',
execFile: 'file',
execFileSync: 'file',
spawn: 'file',
spawnSync: 'file',
fork: 'file',
};

/**
* Spawns that intentionally run without windowsHide, keyed by
* `<path relative to src>:<spawner>(<command>)`, valued by the reason.
*/
const ALLOWED: Record<string, string> = {
"resolution/memory-budget.ts:execFileSync('/usr/bin/vm_stat')": 'macOS only: guarded by process.platform === darwin',
'ui-server/open-browser.ts:spawn(open.command)':
'detached: true maps to DETACHED_PROCESS on Windows, so `cmd /c start` gets no console to show',
};

function listSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...listSourceFiles(p));
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(p);
}
return out;
}

/** Local names bound to child_process exports in one file. */
interface ChildProcessBindings {
/** Local name → imported child_process export (`import { spawn as s }`). */
named: Map<string, string>;
/** Namespace aliases (`import * as cp from 'child_process'`). */
namespaces: Set<string>;
}

function childProcessBindings(sf: ts.SourceFile): ChildProcessBindings {
const named = new Map<string, string>();
const namespaces = new Set<string>();
for (const stmt of sf.statements) {
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
if (!['child_process', 'node:child_process'].includes(stmt.moduleSpecifier.text)) continue;
const bindings = stmt.importClause?.namedBindings;
if (!bindings) continue;
if (ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
else for (const el of bindings.elements) named.set(el.name.text, (el.propertyName ?? el.name).text);
}
return { named, namespaces };
}

/** The child_process function a call invokes, or null when it isn't one. */
function spawnerOf(call: ts.CallExpression, b: ChildProcessBindings): string | null {
const callee = call.expression;
const name = ts.isIdentifier(callee)
? b.named.get(callee.text)
: ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && b.namespaces.has(callee.expression.text)
? callee.name.text
: undefined;
return name !== undefined && Object.hasOwn(SPAWNERS, name) ? name : null;
}

/** The options argument of a spawner call, or undefined when none is passed. */
function optionsArg(call: ts.CallExpression, spawner: string): ts.Expression | undefined {
const [, second, third] = call.arguments;
if (SPAWNERS[spawner] === 'command') return second;
// (file, options) is allowed when the args array is omitted.
if (second && ts.isObjectLiteralExpression(second)) return second;
return third;
}

function unwrap(expr: ts.Expression): ts.Expression {
let e = expr;
while (ts.isAsExpression(e) || ts.isParenthesizedExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression;
return e;
}

/** Variable initializer or function parameter that `name` refers to at `from`. */
function resolveBinding(name: string, from: ts.Node): ts.VariableDeclaration | ts.ParameterDeclaration | null {
for (let scope: ts.Node | undefined = from.parent; scope; scope = scope.parent) {
if (ts.isFunctionLike(scope)) {
const param = scope.parameters.find((p) => ts.isIdentifier(p.name) && p.name.text === name);
if (param) return param;
}
if (ts.isBlock(scope) || ts.isSourceFile(scope) || ts.isModuleBlock(scope)) {
for (const stmt of scope.statements) {
if (!ts.isVariableStatement(stmt)) continue;
const decl = stmt.declarationList.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === name);
if (decl) return decl;
}
}
}
return null;
}

/** Calls to the function that declares `param`, found by name in the same file. */
function callSitesOf(param: ts.ParameterDeclaration, sf: ts.SourceFile): ts.Expression[] | null {
const fn = param.parent;
const fnName = ts.isFunctionDeclaration(fn) && fn.name ? fn.name.text : null;
if (!fnName) return null;
const index = fn.parameters.indexOf(param);
const args: ts.Expression[] = [];
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === fnName) {
const arg = node.arguments[index];
if (arg) args.push(arg);
}
ts.forEachChild(node, visit);
};
visit(sf);
return args.length > 0 ? args : null;
}

/** True when `expr` provably evaluates to options with `windowsHide: true`. */
function hidesWindow(expr: ts.Expression | undefined, sf: ts.SourceFile, seen = new Set<ts.Node>()): boolean {
if (!expr) return false;
const e = unwrap(expr);
if (seen.has(e)) return false;
seen.add(e);
if (ts.isObjectLiteralExpression(e)) {
let hidden = false;
for (const prop of e.properties) {
if (ts.isPropertyAssignment(prop) && prop.name.getText(sf) === 'windowsHide') {
hidden = unwrap(prop.initializer).kind === ts.SyntaxKind.TrueKeyword;
} else if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === 'windowsHide') {
hidden = false; // not provable statically
} else if (ts.isSpreadAssignment(prop) && hidesWindow(prop.expression, sf, seen)) {
hidden = true;
}
}
return hidden; // last write wins, matching object-literal semantics
}
if (ts.isIdentifier(e)) {
const binding = resolveBinding(e.text, e);
if (!binding) return false;
if (ts.isVariableDeclaration(binding)) return hidesWindow(binding.initializer, sf, seen);
const sites = callSitesOf(binding, sf);
return sites !== null && sites.every((arg) => hidesWindow(arg, sf, seen));
}
return false;
}

function commandLabel(call: ts.CallExpression, sf: ts.SourceFile): string {
const first = call.arguments[0];
if (!first) return '';
if (ts.isStringLiteralLike(first)) return `'${first.text}'`;
return first.getText(sf);
}

describe('child processes set windowsHide (#1092, #2094)', () => {
it('every child_process spawn under src/ sets windowsHide: true', () => {
const offenders: string[] = [];
let seen = 0;
for (const file of listSourceFiles(SRC_DIR)) {
const text = fs.readFileSync(file, 'utf8');
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const bindings = childProcessBindings(sf);
if (bindings.named.size === 0 && bindings.namespaces.size === 0) continue;
const rel = path.relative(SRC_DIR, file).split(path.sep).join('/');
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const spawner = spawnerOf(node, bindings);
if (spawner) {
seen++;
const key = `${rel}:${spawner}(${commandLabel(node, sf)})`;
if (!Object.hasOwn(ALLOWED, key) && !hidesWindow(optionsArg(node, spawner), sf)) {
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
offenders.push(`${rel}:${line + 1} ${key}`);
}
}
}
ts.forEachChild(node, visit);
};
visit(sf);
}
expect(seen).toBeGreaterThan(0); // guard against a false pass if the imports move
expect(offenders, `spawned without windowsHide:\n${offenders.join('\n')}`).toEqual([]);
});
});
Loading
Loading