diff --git a/eslint-rules/require-safe-parse.mjs b/eslint-rules/require-safe-parse.mjs index 4ab9dbd8a..4bad4280d 100644 --- a/eslint-rules/require-safe-parse.mjs +++ b/eslint-rules/require-safe-parse.mjs @@ -19,14 +19,14 @@ * * False-positive suppression: * - Skips calls whose receiver is a known non-tree-sitter library (`JSON`, - * `URL`, `marked`, `Number`). + * `URL`, `marked`, `Number`, `path`). * - Skips calls whose first argument is a string-literal (grammar-load smoke * tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`). * - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`). * - Skips the `safe-parse.ts` helper itself. */ -const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']); +const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math', 'path']); export default { meta: { @@ -74,7 +74,7 @@ export default { // Receiver-text-shape skip: anything matching well-known JS APIs that // happen to have a `.parse()` shape but aren't tree-sitter. if ( - /^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) || + /^(JSON|URL|marked|Number|Math|Date|path|globalThis\.JSON)\b/.test(receiverText) || /\bjson\.parse\b/i.test(receiverText) ) { return; diff --git a/gitnexus/src/cli/watch.ts b/gitnexus/src/cli/watch.ts index ea548580e..c995ccf6b 100644 --- a/gitnexus/src/cli/watch.ts +++ b/gitnexus/src/cli/watch.ts @@ -68,9 +68,13 @@ async function initWatchConfig(): Promise { const configPath = getAutoSyncConfigPath(); try { await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, defaultSyncConfig(path.resolve(path.dirname(configPath), 'repo')), { - flag: 'wx', - }); + await fs.writeFile( + configPath, + defaultSyncConfig(path.resolve(path.dirname(configPath), 'repo')), + { + flag: 'wx', + }, + ); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'EEXIST') { process.stderr.write(`[auto-sync] Config already exists: ${configPath}\n`); diff --git a/gitnexus/src/core/auto-sync/config.ts b/gitnexus/src/core/auto-sync/config.ts index febf8490f..95579b91a 100644 --- a/gitnexus/src/core/auto-sync/config.ts +++ b/gitnexus/src/core/auto-sync/config.ts @@ -209,7 +209,9 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy export function validateAutoSyncRemoteUrl(remoteUrl: string): void { const match = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim()); if (!match) { - throw new Error('must use git@github.com:owner/repo.git, git@gitlab.com:group/repo.git, or git@gitee.com:owner/repo.git'); + throw new Error( + 'must use git@github.com:owner/repo.git, git@gitlab.com:group/repo.git, or git@gitee.com:owner/repo.git', + ); } const host = match[1].toLowerCase(); const repoPath = match[2]; @@ -223,7 +225,8 @@ export function validateAutoSyncRemoteUrl(remoteUrl: string): void { export function validateAutoSyncBranchName(branch: string): void { if (!branch.trim()) throw new Error('must not be empty'); - if (/[\s\0-\x1f\x7f]/.test(branch)) throw new Error('must not contain whitespace or control characters'); + if (/[\s\0-\x1f\x7f]/.test(branch)) + throw new Error('must not contain whitespace or control characters'); if (/[~^:?*[\\]/.test(branch)) throw new Error('contains characters not allowed in a git ref'); if (branch.startsWith('-')) throw new Error('must not start with "-"'); if (branch.includes('..')) throw new Error('must not contain ".."'); diff --git a/gitnexus/src/core/auto-sync/path-security.ts b/gitnexus/src/core/auto-sync/path-security.ts index e8510c248..7359c4679 100644 --- a/gitnexus/src/core/auto-sync/path-security.ts +++ b/gitnexus/src/core/auto-sync/path-security.ts @@ -65,7 +65,11 @@ export async function resolveConfiguredCloneRoot(localPath: string): Promise { +export async function quarantineAutoSyncPartial( + targetDir: string, + quarantineRoot: string, +): Promise { await fs.mkdir(quarantineRoot, { recursive: true, mode: 0o700 }); const base = path.basename(targetDir); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); @@ -119,7 +126,8 @@ function assertNotDangerousRoot(root: string): void { throw new Error(`Refusing unsafe auto-sync clone root under ${dangerousRoot}: ${root}`); } } - if (path.parse(root).root === root) throw new Error(`Refusing filesystem root as clone root: ${root}`); + if (path.parse(root).root === root) + throw new Error(`Refusing filesystem root as clone root: ${root}`); } function assertNotGitNexusInternalRoot(root: string): void { @@ -151,7 +159,8 @@ async function assertNoSymlinkPath(root: string): Promise { if ((err as NodeJS.ErrnoException).code === 'ENOENT') break; throw err; } - if (stat.isSymbolicLink()) throw new Error(`Refusing symlink in auto-sync clone root path: ${current}`); + if (stat.isSymbolicLink()) + throw new Error(`Refusing symlink in auto-sync clone root path: ${current}`); } } diff --git a/gitnexus/src/core/auto-sync/runner.ts b/gitnexus/src/core/auto-sync/runner.ts index 55e4e75ab..558896290 100644 --- a/gitnexus/src/core/auto-sync/runner.ts +++ b/gitnexus/src/core/auto-sync/runner.ts @@ -83,7 +83,10 @@ export async function runAutoSyncOnce( const groupsToSync = new Set(); const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }; const commitInfoEntries: ProjectCommitInfoEntry[] = []; - const actualConcurrency = resolveActualConcurrency(config.maxConcurrency, deps.getAvailableMemoryGB()); + const actualConcurrency = resolveActualConcurrency( + config.maxConcurrency, + deps.getAvailableMemoryGB(), + ); logger.info( `[auto-sync] Starting sync loop with max_concurrency=${actualConcurrency} analyze_failure_threshold=${config.analyzeFailureThreshold}.`, ); @@ -182,7 +185,9 @@ export async function runAutoSyncOnce( lastSyncTime, }; } catch (err: unknown) { - logger.error(`[auto-sync] Repository sync failed for ${item.remoteUrl}: ${(err as Error).message}`); + logger.error( + `[auto-sync] Repository sync failed for ${item.remoteUrl}: ${(err as Error).message}`, + ); return { kind: 'failed' as const, project: item.project, @@ -258,7 +263,9 @@ export async function runAutoSyncOnce( groupMembershipOk = true; } catch (err: unknown) { result.failed += 1; - logger.error(`[auto-sync] Group update failed for ${repoResult.project.groupName}: ${(err as Error).message}`); + logger.error( + `[auto-sync] Group update failed for ${repoResult.project.groupName}: ${(err as Error).message}`, + ); } if (groupMembershipOk && repoResult.analyzeStatus === 'success') { groupsToSync.add(repoResult.project.groupName); @@ -321,7 +328,10 @@ export function resolveActualConcurrency(configured: number, availableMemoryGB: return Math.max(1, Math.min(configured, memoryLimit)); } -async function buildWorkItems(config: AutoSyncConfig, deps: AutoSyncRunDeps): Promise { +async function buildWorkItems( + config: AutoSyncConfig, + deps: AutoSyncRunDeps, +): Promise { const items: AutoSyncWorkItem[] = []; const targetOwners = new Map(); for (const project of config.projects) { @@ -332,7 +342,9 @@ async function buildWorkItems(config: AutoSyncConfig, deps: AutoSyncRunDeps): Pr const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName); const previous = targetOwners.get(targetDir); if (previous !== undefined) { - throw new Error(`Duplicate auto-sync targetDir ${targetDir} for ${previous} and ${remoteUrl}`); + throw new Error( + `Duplicate auto-sync targetDir ${targetDir} for ${previous} and ${remoteUrl}`, + ); } targetOwners.set(targetDir, remoteUrl); } catch (err: unknown) { diff --git a/gitnexus/src/core/auto-sync/starter.ts b/gitnexus/src/core/auto-sync/starter.ts index 8d4b00a16..15bb06295 100644 --- a/gitnexus/src/core/auto-sync/starter.ts +++ b/gitnexus/src/core/auto-sync/starter.ts @@ -48,15 +48,17 @@ export function getAutoSyncWatchPaths(gitnexusDir = getGlobalDir()): AutoSyncWat }; } -export async function startAutoSyncWatch(options: { - setIntervalFn?: typeof setInterval; - clearIntervalFn?: typeof clearInterval; - runOnce?: typeof runAutoSyncOnce; - stderr?: Pick; - keepAlive?: boolean; - paths?: AutoSyncWatchPaths; - deps?: Partial; -} = {}): Promise { +export async function startAutoSyncWatch( + options: { + setIntervalFn?: typeof setInterval; + clearIntervalFn?: typeof clearInterval; + runOnce?: typeof runAutoSyncOnce; + stderr?: Pick; + keepAlive?: boolean; + paths?: AutoSyncWatchPaths; + deps?: Partial; + } = {}, +): Promise { const stderr = options.stderr ?? process.stderr; const paths = options.paths ?? getAutoSyncWatchPaths(); const deps = resolveWatchDeps(options.deps); @@ -199,13 +201,15 @@ async function acquireWatchLock( } } -export async function stopAutoSyncWatch(options: { - paths?: AutoSyncWatchPaths; - stderr?: Pick; - deps?: Partial; - timeoutMs?: number; - pollMs?: number; -} = {}): Promise { +export async function stopAutoSyncWatch( + options: { + paths?: AutoSyncWatchPaths; + stderr?: Pick; + deps?: Partial; + timeoutMs?: number; + pollMs?: number; + } = {}, +): Promise { const stderr = options.stderr ?? process.stderr; const paths = options.paths ?? getAutoSyncWatchPaths(); const deps = resolveWatchDeps(options.deps); @@ -428,14 +432,20 @@ async function readStatusFile(statusPath: string): Promise { +async function writeWatchStatus( + paths: AutoSyncWatchPaths, + record: WatchStatusRecord, +): Promise { await fs.mkdir(path.dirname(paths.statusPath), { recursive: true }); const tmpPath = `${paths.statusPath}.tmp.${process.pid}.${Date.now()}`; await fs.writeFile(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf-8'); await fs.rename(tmpPath, paths.statusPath); } -async function cleanupWatchFiles(paths: AutoSyncWatchPaths, lockHandle?: fs.FileHandle): Promise { +async function cleanupWatchFiles( + paths: AutoSyncWatchPaths, + lockHandle?: fs.FileHandle, +): Promise { await lockHandle?.close().catch(() => {}); await removeIfExists(paths.pidPath); await removeIfExists(paths.lockPath); diff --git a/gitnexus/src/core/auto-sync/state.ts b/gitnexus/src/core/auto-sync/state.ts index 0de053ca9..5c4d1e39c 100644 --- a/gitnexus/src/core/auto-sync/state.ts +++ b/gitnexus/src/core/auto-sync/state.ts @@ -106,7 +106,12 @@ export interface ProjectCommitInfoEntry { branch?: string; codeCommitId?: string; analyzedCommitId?: string; - status: AutoSyncAnalyzeStatus | 'sync_failed' | 'branch_skipped' | 'branch_unavailable' | 'sync_timeout'; + status: + | AutoSyncAnalyzeStatus + | 'sync_failed' + | 'branch_skipped' + | 'branch_unavailable' + | 'sync_timeout'; analyzeConsecutiveFailures?: number; analyzeFailureThreshold?: number; lastAnalyzeError?: string; diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 60f0b44d6..5553d115b 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -790,11 +790,7 @@ export function buildGitEnv( // host-scoped Authorization header (GitHub PAT for github.com, else the // server's AZURE_DEVOPS_PAT for Azure hosts) via the GIT_CONFIG_* protocol — // never in argv. See resolveGitCredential / buildExtraHeaderKey. -function runGit( - args: string[], - cwd?: string, - options?: RunGitOptions, -): Promise { +function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise { return new Promise((resolve, reject) => { const spawnGit = options?.spawnForTest ?? spawn; const proc = spawnGit('git', args, { diff --git a/gitnexus/test/unit/auto-sync-runner.test.ts b/gitnexus/test/unit/auto-sync-runner.test.ts index 9e8c5b13e..ada907d7c 100644 --- a/gitnexus/test/unit/auto-sync-runner.test.ts +++ b/gitnexus/test/unit/auto-sync-runner.test.ts @@ -13,7 +13,11 @@ import { startAutoSyncWatch, stopAutoSyncWatch, } from '../../src/core/auto-sync/index.js'; -import type { AutoSyncConfig, AutoSyncRunDeps, AutoSyncWatchPaths } from '../../src/core/auto-sync/index.js'; +import type { + AutoSyncConfig, + AutoSyncRunDeps, + AutoSyncWatchPaths, +} from '../../src/core/auto-sync/index.js'; const config: AutoSyncConfig = { configPath: '/tmp/.gitnexus/watch_config.yml', @@ -44,7 +48,11 @@ function withCloneRoot(deps: Partial): Partial }; } -async function writeWatchOwner(paths: AutoSyncWatchPaths, pid: number, ownerId = `owner-${pid}`): Promise { +async function writeWatchOwner( + paths: AutoSyncWatchPaths, + pid: number, + ownerId = `owner-${pid}`, +): Promise { await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); await fs.writeFile(paths.pidPath, `${pid}\n`); await fs.writeFile( @@ -473,7 +481,9 @@ describe('auto-sync runner', () => { }), ); expect(errorLogger).toHaveBeenCalledWith( - expect.stringContaining('Repository sync failed for git@gitee.com:qts_server/failing_sync.git'), + expect.stringContaining( + 'Repository sync failed for git@gitee.com:qts_server/failing_sync.git', + ), ); expect(errorLogger).toHaveBeenCalledWith( expect.stringContaining('Analysis failed for /tmp/repos/qts_account'), @@ -540,7 +550,9 @@ describe('auto-sync runner', () => { return remoteUrl.includes('/one.git') ? '/tmp/repos/one' : '/tmp/repos/two'; }), getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn((repoPath) => (repoPath.endsWith('/one') ? 'one-commit' : 'two-commit')), + getCurrentCommit: vi.fn((repoPath) => + repoPath.endsWith('/one') ? 'one-commit' : 'two-commit', + ), runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'repo'), loadState: vi.fn(async () => ({})), @@ -792,17 +804,10 @@ describe('auto-sync runner', () => { await fs.mkdir(groupDir, { recursive: true }); await fs.writeFile( path.join(groupDir, 'group.yaml'), - [ - 'version: 1', - 'name: back_end', - 'repos:', - ' hr/hiring/backend: qts_account', - ].join('\n'), + ['version: 1', 'name: back_end', 'repos:', ' hr/hiring/backend: qts_account'].join('\n'), ); - await expect(addRepoToGroup({ groupName: 'back_end' }, 'qts_account')).resolves.toBe( - false, - ); + await expect(addRepoToGroup({ groupName: 'back_end' }, 'qts_account')).resolves.toBe(false); await expect(fs.readFile(path.join(groupDir, 'group.yaml'), 'utf-8')).resolves.toContain( 'hr/hiring/backend: qts_account', @@ -854,7 +859,9 @@ describe('auto-sync starter', () => { expect(setIntervalFn).toHaveBeenCalledWith(expect.any(Function), 300_000); expect(timer.unref).toHaveBeenCalled(); await vi.waitFor(() => { - expect(stderr.write).toHaveBeenCalledWith(expect.stringContaining('[auto-sync] Watch loop started at ')); + expect(stderr.write).toHaveBeenCalledWith( + expect.stringContaining('[auto-sync] Watch loop started at '), + ); expect(stderr.write).toHaveBeenCalledWith( '[auto-sync] Watch loop finished: synced=0 analyzed=0 skipped=0 failed=0.\n', ); @@ -935,7 +942,9 @@ describe('auto-sync starter', () => { }); expect(handle).toBeNull(); - expect(stderr.write).toHaveBeenCalledWith('[auto-sync] Watch is already running with pid 12345.\n'); + expect(stderr.write).toHaveBeenCalledWith( + '[auto-sync] Watch is already running with pid 12345.\n', + ); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } @@ -1014,7 +1023,9 @@ describe('auto-sync starter', () => { }); expect(handle).toBeNull(); - expect(stderr.write).toHaveBeenCalledWith('[auto-sync] Watch is already running with pid 12345.\n'); + expect(stderr.write).toHaveBeenCalledWith( + '[auto-sync] Watch is already running with pid 12345.\n', + ); expect(await fs.readFile(paths.lockPath, 'utf-8')).toContain('starting-owner'); await expect(fs.access(paths.pidPath)).rejects.toThrow(); } finally { @@ -1073,7 +1084,11 @@ describe('auto-sync starter', () => { stopAutoSyncWatch({ paths, stderr: { write: vi.fn() }, - deps: { isProcessAlive: vi.fn(() => false), killProcess: vi.fn(), sleep: vi.fn(async () => {}) }, + deps: { + isProcessAlive: vi.fn(() => false), + killProcess: vi.fn(), + sleep: vi.fn(async () => {}), + }, }), ).resolves.toBe(false); @@ -1146,7 +1161,9 @@ describe('auto-sync starter', () => { }), ).resolves.toBe(false); - await expect(readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true) })).resolves.toMatchObject({ + await expect( + readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true) }), + ).resolves.toMatchObject({ state: 'stopping', pid: 12345, message: expect.stringContaining('did not exit'), @@ -1182,7 +1199,9 @@ describe('auto-sync starter', () => { ).resolves.toBe(false); expect(killProcess).not.toHaveBeenCalled(); - await expect(readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true) })).resolves.toMatchObject({ + await expect( + readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true) }), + ).resolves.toMatchObject({ state: 'error', pid: 12345, message: expect.stringContaining('owner'), diff --git a/gitnexus/test/unit/auto-sync.test.ts b/gitnexus/test/unit/auto-sync.test.ts index cc8be6e17..896613a90 100644 --- a/gitnexus/test/unit/auto-sync.test.ts +++ b/gitnexus/test/unit/auto-sync.test.ts @@ -176,14 +176,14 @@ describe('auto-sync', () => { it('hard-fails unsafe configured clone roots', async () => { await expect(resolveConfiguredCloneRoot('/')).rejects.toThrow('unsafe auto-sync clone root'); - await expect(resolveConfiguredCloneRoot(os.homedir())).rejects.toThrow('unsafe auto-sync clone root'); - await expect(resolveConfiguredCloneRoot(path.join(await fs.realpath(os.tmpdir()), 'repos'))).rejects.toThrow( + await expect(resolveConfiguredCloneRoot(os.homedir())).rejects.toThrow( 'unsafe auto-sync clone root', ); + await expect( + resolveConfiguredCloneRoot(path.join(await fs.realpath(os.tmpdir()), 'repos')), + ).rejects.toThrow('unsafe auto-sync clone root'); const root = path.join(tempDir, 'repos'); - await expect(resolveConfiguredCloneRoot(`${root}/../repos`)).rejects.toThrow( - 'normalized', - ); + await expect(resolveConfiguredCloneRoot(`${root}/../repos`)).rejects.toThrow('normalized'); }); it('rejects GitNexus internal directory descendants as clone roots', async () => { @@ -258,7 +258,9 @@ describe('auto-sync', () => { expect(extractRepoNameFromRemoteUrl('git@gitee.com:qts_server/qts_account.git')).toBe( 'qts_account', ); - expect(extractRepoNameFromRemoteUrl('git@gitlab.com:team/subgroup/repo-name.git')).toBe('repo-name'); + expect(extractRepoNameFromRemoteUrl('git@gitlab.com:team/subgroup/repo-name.git')).toBe( + 'repo-name', + ); }); it('rejects unsafe repository names without sanitizing them', () => { @@ -271,11 +273,19 @@ describe('auto-sync', () => { it('allows only github, gitlab, and gitee SSH SCP remote URLs', () => { expect(() => validateAutoSyncRemoteUrl('git@github.com:im-fan/multica.git')).not.toThrow(); expect(() => validateAutoSyncRemoteUrl('git@gitlab.com:group/subgroup/repo.git')).not.toThrow(); - expect(() => validateAutoSyncRemoteUrl('git@gitee.com:qts-ops/qts-code-engineering.git')).not.toThrow(); - expect(() => validateAutoSyncRemoteUrl('https://github.com/owner/repo.git')).toThrow('must use'); - expect(() => validateAutoSyncRemoteUrl('ssh://git@github.com/owner/repo.git')).toThrow('must use'); + expect(() => + validateAutoSyncRemoteUrl('git@gitee.com:qts-ops/qts-code-engineering.git'), + ).not.toThrow(); + expect(() => validateAutoSyncRemoteUrl('https://github.com/owner/repo.git')).toThrow( + 'must use', + ); + expect(() => validateAutoSyncRemoteUrl('ssh://git@github.com/owner/repo.git')).toThrow( + 'must use', + ); expect(() => validateAutoSyncRemoteUrl('user@github.com:owner/repo.git')).toThrow('must use'); - expect(() => validateAutoSyncRemoteUrl('git@example.com:owner/repo.git')).toThrow('host must be'); + expect(() => validateAutoSyncRemoteUrl('git@example.com:owner/repo.git')).toThrow( + 'host must be', + ); }); it('parses repo git timeout durations', () => { @@ -318,9 +328,7 @@ describe('auto-sync', () => { previousStatus: 'failed', }), ).toBe(true); - expect(shouldAnalyzeCommit({ currentCommit: 'def', previousAnalyzedCommit: 'abc' })).toBe( - true, - ); + expect(shouldAnalyzeCommit({ currentCommit: 'def', previousAnalyzedCommit: 'abc' })).toBe(true); }); it('saves state atomically and reloads it', async () => { @@ -345,13 +353,13 @@ describe('auto-sync', () => { ); await expect(loadAutoSyncState(statePath)).resolves.toEqual({ '/tmp/repos/qts_account|master': { - codeCommitId: 'abc', - analyzedCommitId: 'abc', - lastAnalyzeStatus: 'success', - analyzeConsecutiveFailures: 2, - lastAnalyzeError: 'old error', - lastSyncTime: '2026-06-30T00:00:00.000Z', - }, + codeCommitId: 'abc', + analyzedCommitId: 'abc', + lastAnalyzeStatus: 'success', + analyzeConsecutiveFailures: 2, + lastAnalyzeError: 'old error', + lastSyncTime: '2026-06-30T00:00:00.000Z', + }, }); }); diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index c16c7eb2a..11ec037e6 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -672,7 +672,9 @@ describe('git-clone', () => { try { await new Promise((resolve, reject) => { const proc = spawn('git', ['init'], { cwd: root, stdio: 'ignore' }); - proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`git init ${code}`)))); + proc.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`git init ${code}`)), + ); proc.on('error', reject); }); await fs.rename(path.join(root, '.git'), path.join(target, '.git')).catch(async () => { @@ -719,9 +721,9 @@ describe('git-clone', () => { ).rejects.toThrow('git clone failed'); const entries = await fs.readdir(quarantineRoot); - expect(entries.some((entry) => entry.startsWith('auto-sync-') && entry.endsWith('-repo'))).toBe( - true, - ); + expect( + entries.some((entry) => entry.startsWith('auto-sync-') && entry.endsWith('-repo')), + ).toBe(true); } finally { await fs.rm(root, { recursive: true, force: true }); } @@ -881,9 +883,9 @@ describe('git-clone', () => { describe('extractWebRepoName — API clone compatibility', () => { it('sanitizes repo names with spaces and unsafe directory characters at the web boundary', () => { - expect( - extractWebRepoName('https://dev.azure.com/org/project/_git/My Repo With Spaces'), - ).toBe('My_Repo_With_Spaces'); + expect(extractWebRepoName('https://dev.azure.com/org/project/_git/My Repo With Spaces')).toBe( + 'My_Repo_With_Spaces', + ); expect(extractWebRepoName('https://example.com/team/repo$name.git')).toBe('repo_name'); }); @@ -1102,9 +1104,11 @@ describe('git-clone', () => { await vi.advanceTimersByTimeAsync(25); let settled = false; - promise.catch(() => {}).finally(() => { - settled = true; - }); + promise + .catch(() => {}) + .finally(() => { + settled = true; + }); await vi.runAllTicks(); expect(child.kill).toHaveBeenCalledWith('SIGTERM'); expect(settled).toBe(false); diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index 91f18232c..3c2c9d250 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -411,14 +411,32 @@ describe('windowsHide regression', () => { /** * Count spawn-family invocations. The regex matches ``spawn(``, * ``spawnSync(``, ``execFile(``, ``execFileSync(``, - * ``execFileAsync(``, ``execSync(`` as function calls — not - * destructures (``const { spawn } = ...``), not method calls - * (``.exec(``), not bare ``exec()`` (which collides with regex - * ``.exec()``; we explicitly drop it). + * ``execFileAsync(``, ``execSync(`` and simple local aliases that + * point at one of those functions as function calls — not destructures + * (``const { spawn } = ...``), not method calls (``.exec(``), not bare + * ``exec()`` (which collides with regex ``.exec()``; we explicitly + * drop it). */ function countSpawnCalls(codeSource: string): number { - const re = - /(^|[^a-zA-Z0-9_$.])(spawn|spawnSync|execFile|execFileSync|execFileAsync|execSync)\s*\(/gm; + const spawnFunctions = [ + 'spawn', + 'spawnSync', + 'execFile', + 'execFileSync', + 'execFileAsync', + 'execSync', + ]; + const spawnNames = new Set(spawnFunctions); + const aliasRe = new RegExp( + `\\bconst\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*[^;\\n]*\\b(?:${spawnFunctions.join('|')})\\b`, + 'g', + ); + let aliasMatch: RegExpExecArray | null; + while ((aliasMatch = aliasRe.exec(codeSource)) !== null) { + spawnNames.add(aliasMatch[1]); + } + + const re = new RegExp(`(^|[^a-zA-Z0-9_$.])(${[...spawnNames].join('|')})\\s*\\(`, 'gm'); let count = 0; while (re.exec(codeSource) !== null) { count++;