fix(windows): hide the git calls that flash console windows (#2094) (#2280)

* fix(windows): hide the git calls that flash console windows (#2094)

The two git calls added for exclude-standard scope parity (#1728),
`git config --get core.excludesFile` and
`git ls-files -o -i --exclude-standard --directory`, ran without
windowsHide. Inside the detached, console-less MCP daemon each one got a
fresh visible console, so a window flashed several times on daemon start
and again on every auto-sync. With Windows Terminal as the default
terminal each flash is a full Windows Terminal window. `git config
user.name` in the viewer's trails API had the same gap.

Add windowsHide: true to all three, and replace the per-file regex guards
with an AST guard over every child_process spawn in src/: options passed
through a variable or a function parameter are traced back to the object
literal that defines them, through every call site.

Supersedes #2095 (same fix, rebased on current main). Closes #2094,
closes #2096.

(cherry picked from commit dd2db41f1b)

* docs(changelog): credit the Windows console-window fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit cf93d497fd1b38fd9214945eeb55b6b0be9e2007)

---------

Co-authored-by: Suharaz <minesuhara@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-02 04:09:56 +00:00
committed by GitHub
parent ec738ec7a3
commit 404e7827bc
4 changed files with 213 additions and 1 deletions
+2
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- 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)
- In Vapor apps, a route whose handler is a trailing closure, like `app.get("hello") { req in … }` or `app.webSocket("chat") { req, ws in … }`, now links to what the closure calls, the way an Express inline handler does. Before, the route linked to nothing, so callers, impact and flows stopped at it.
- A COBOL file whose last line ends inside the sequence area (columns 1-6), such as a closing period indented only four spaces, no longer stalls indexing for over a minute and then ends up in the index with no symbols.
- In Laravel apps, a route whose controller is named by string now links to its handler, without needing a `Controller` suffix and keeping the namespace it's written with: `'Common\Uploads@inline'`, and `Route::resource('companies', 'Common\Companies', [...])` with an options array. Two same-named controllers in different namespaces are told apart by that namespace. Before, akaunting linked 304 of its 313 routes to nothing.
@@ -22,6 +23,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Vue, Svelte and Astro files, a component now owns its script, and what a `<script setup>`, a Svelte instance script or Astro frontmatter does at its top level is the component's doing. That covers a Nuxt page's `useAsyncData(…)`, a Svelte page's `onMount(…)` and Astro's `await getCollection(…)`. Imports and a module-level script stay with the file. Before, those calls belonged to the file, so callers and flows from a component found nothing. The component was also recorded as containing its own file, and a file with no script had no file node at all.
- In JavaScript and TypeScript, a CommonJS `require('./application')` now counts as an import of that file, the way an ESM `import` does. A module path also resolves the way Node and bundlers find it: extensions, `index` files and path aliases. Before, a CommonJS file depended on nothing at file level, so Express's own `lib/express.js` showed no imports and impact analysis missed the files it loads. An extensionless side-effect import like `import './polyfills'` also stopped at its own import statement instead of reaching the file.
- An Express route whose handler is written inline as `function (req, res) {…}`, the form Express's own examples use, now links to what that handler calls, just like an arrow handler does. The handler is the route's last argument, so an inline middleware before a named handler no longer stands in for it, and a wrapper like `asyncHandler(async function (req, res) {…})` and a trailing comma both work. Before, a route such as `POST /login` linked to nothing.
- 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. (#2094, #2096) Thanks @A-Van-Gestel, @HarryMuc and @23q3.
- A Python module member used through the imported module, like `generic.ObjectEditView` after `from netbox.views import generic`, `serializers.ValidationError` or `utils.assert_deserialize(…)`, now also follows the package's re-exports to its definition. Before, netbox's view classes lost their `generic.*` base classes, and mealie's test helpers called through `utils.` went unresolved.
- A Python name imported from a package now follows the package's re-exports to where it's defined. That covers both `from .users import *` and `from .tokens import Token` in its `__init__.py`. Before, httpx's own tests' `httpx.Client(…)` and `httpx.URL(…)`, pytest's `pytest.raises(…)` and `pytest.param(…)`, and netbox's `from users.models import User` went unresolved or to a same-named class elsewhere, such as a test's `User` or an Alembic migration's copy of a model.
- In NestJS projects, the provider naming convention (`…Service`, `…Controller`, …) now picks a class only from the reference's own file. Another file's provider is reached through its import, or by ordinary name matching that prefers the nearest declaration. Before, the convention's preference for `*.service.ts` files sent Nest's own specs' `TransientService` and `TestService` to same-named classes in unrelated sample apps.
@@ -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([]);
});
});
+2 -1
View File
@@ -395,7 +395,7 @@ function readGitExcludeExtraPatterns(rootDir: string): string {
const configured = execFileSync(
'git',
['-C', rootDir, 'config', '--get', 'core.excludesFile'],
{ encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] },
{ encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true },
).trim();
if (configured) {
const abs = expandUserPath(configured);
@@ -426,6 +426,7 @@ function listGitIgnoredDirectories(rootDir: string): string[] {
timeout: 60_000,
maxBuffer: 50 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
},
);
const dirs: string[] = [];
+1
View File
@@ -458,6 +458,7 @@ function gitUserName(projectRoot: string): string | null {
encoding: 'utf-8',
timeout: 2_000,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
const name = out.trim();
return name === '' ? null : name.slice(0, 120);