GitNexus/gitnexus/test/integration/filesystem-walker.test.ts

784 lines
31 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import {
walkRepositoryPaths,
readFileContents,
} from '../../src/core/ingestion/filesystem-walker.js';
import { _resetMaxFileSizeWarnings } from '../../src/core/ingestion/utils/max-file-size.js';
import { _captureLogger } from '../../src/core/logger.js';
describe('filesystem-walker', () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-test-'));
// Create test directory structure
await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fs.mkdir(path.join(tmpDir, 'src', 'components'), { recursive: true });
await fs.mkdir(path.join(tmpDir, 'node_modules', 'lodash'), { recursive: true });
await fs.mkdir(path.join(tmpDir, '.git'), { recursive: true });
await fs.writeFile(path.join(tmpDir, 'src', 'index.ts'), 'export const main = () => {}');
await fs.writeFile(path.join(tmpDir, 'src', 'utils.ts'), 'export const helper = () => {}');
await fs.writeFile(
path.join(tmpDir, 'src', 'components', 'Button.tsx'),
'export const Button = () => <div/>',
);
await fs.writeFile(
path.join(tmpDir, 'node_modules', 'lodash', 'index.js'),
'module.exports = {}',
);
await fs.writeFile(path.join(tmpDir, '.git', 'HEAD'), 'ref: refs/heads/main');
await fs.writeFile(path.join(tmpDir, 'package.json'), '{}');
await fs.writeFile(
path.join(tmpDir, 'src', 'image.png'),
Buffer.from([0x89, 0x50, 0x4e, 0x47]),
);
});
afterAll(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
/* best-effort */
}
});
describe('walkRepositoryPaths', () => {
it('discovers source files', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('src/index.ts'))).toBe(true);
expect(paths.some((p) => p.includes('src/utils.ts'))).toBe(true);
});
it('discovers nested files', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('components/Button.tsx'))).toBe(true);
});
it('skips node_modules', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.every((p) => !p.includes('node_modules'))).toBe(true);
});
it('skips .git directory', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.every((p) => !p.includes('.git/'))).toBe(true);
});
it('returns file sizes', async () => {
const files = await walkRepositoryPaths(tmpDir);
for (const file of files) {
expect(typeof file.size).toBe('number');
expect(file.size).toBeGreaterThan(0);
}
});
it('calls progress callback', async () => {
const onProgress = vi.fn();
await walkRepositoryPaths(tmpDir, onProgress);
expect(onProgress).toHaveBeenCalled();
});
// ─── Unhappy paths ────────────────────────────────────────────────
it('throws or returns empty for non-existent directory', async () => {
try {
const files = await walkRepositoryPaths('/nonexistent/path/xyz123');
// If it doesn't throw, it should return empty
expect(files).toEqual([]);
} catch (err: any) {
expect(err).toBeDefined();
}
});
it('returns empty for directory with only ignored files', async () => {
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-empty-'));
await fs.mkdir(path.join(emptyDir, '.git'), { recursive: true });
await fs.writeFile(path.join(emptyDir, '.git', 'HEAD'), 'ref: refs/heads/main');
try {
const files = await walkRepositoryPaths(emptyDir);
expect(files).toEqual([]);
} finally {
await fs.rm(emptyDir, { recursive: true, force: true });
}
});
it('returns empty for truly empty directory', async () => {
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-truly-empty-'));
try {
const files = await walkRepositoryPaths(emptyDir);
expect(files).toEqual([]);
} finally {
await fs.rm(emptyDir, { recursive: true, force: true });
}
});
});
describe('.gitignore support', () => {
let gitignoreDir: string;
beforeAll(async () => {
gitignoreDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-gitignore-'));
// Create directory structure
await fs.mkdir(path.join(gitignoreDir, 'src'), { recursive: true });
await fs.mkdir(path.join(gitignoreDir, 'data', 'cache'), { recursive: true });
await fs.mkdir(path.join(gitignoreDir, 'logs'), { recursive: true });
// Source files (should be indexed)
await fs.writeFile(
path.join(gitignoreDir, 'src', 'index.ts'),
'export const main = () => {}',
);
await fs.writeFile(
path.join(gitignoreDir, 'src', 'utils.ts'),
'export const helper = () => {}',
);
// Data files (should be ignored via .gitignore)
await fs.writeFile(path.join(gitignoreDir, 'data', 'cache', 'file.json'), '{}');
await fs.writeFile(path.join(gitignoreDir, 'logs', 'app.log'), 'log entry');
// .gitignore
await fs.writeFile(path.join(gitignoreDir, '.gitignore'), 'data/\nlogs/\n');
});
afterAll(async () => {
await fs.rm(gitignoreDir, { recursive: true, force: true });
});
it('excludes directories listed in .gitignore', async () => {
const files = await walkRepositoryPaths(gitignoreDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
// Source files should be present
expect(paths.some((p) => p.includes('src/index.ts'))).toBe(true);
expect(paths.some((p) => p.includes('src/utils.ts'))).toBe(true);
// Ignored directories should not be present
expect(paths.every((p) => !p.includes('data/'))).toBe(true);
expect(paths.every((p) => !p.includes('logs/'))).toBe(true);
});
it('still applies hardcoded ignore list alongside .gitignore', async () => {
// Add node_modules (hardcoded ignore) to verify both work
await fs.mkdir(path.join(gitignoreDir, 'node_modules', 'pkg'), { recursive: true });
await fs.writeFile(
path.join(gitignoreDir, 'node_modules', 'pkg', 'index.js'),
'module.exports = {}',
);
const files = await walkRepositoryPaths(gitignoreDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.every((p) => !p.includes('node_modules'))).toBe(true);
expect(paths.every((p) => !p.includes('data/'))).toBe(true);
await fs.rm(path.join(gitignoreDir, 'node_modules'), { recursive: true, force: true });
});
});
describe('ambiguous source-directory names (#3039)', () => {
let sourceDir: string;
beforeAll(async () => {
sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-source-names-'));
await fs.mkdir(path.join(sourceDir, 'apps', 'client', 'src', 'shared', 'env'), {
recursive: true,
});
await fs.mkdir(path.join(sourceDir, 'packages', 'ai', 'src', 'generated'), {
recursive: true,
});
await fs.mkdir(path.join(sourceDir, 'build-cache', 'generated'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'env'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'generated'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'Scripts'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'include'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'share'), { recursive: true });
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'shared', 'env', 'getAppEnv.ts'),
'export const getAppEnv = () => "test";\n',
);
await fs.writeFile(
path.join(sourceDir, 'packages', 'ai', 'src', 'generated', 'bundle.ts'),
'export const bundled = true;\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'vite-env.d.ts'),
'declare const APP_ENV: string;\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'service.ts'),
'export class UserService {}\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'service.d.ts'),
'export declare class UserService {}\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'legacy.js'),
'export class LegacyService {}\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'legacy.d.ts'),
'export declare class LegacyService {}\n',
);
await fs.writeFile(
path.join(sourceDir, 'build-cache', 'generated', 'ignored.ts'),
'export const ignored = true;\n',
);
await fs.writeFile(path.join(sourceDir, '.gitignore'), 'build-cache/generated/\n');
await fs.writeFile(path.join(sourceDir, 'env', 'pyvenv.cfg'), 'home = python\n');
await fs.writeFile(path.join(sourceDir, 'env', 'settings.py'), 'VALUE = 1\n');
await fs.writeFile(path.join(sourceDir, 'backend', 'env', 'pyvenv.cfg'), 'home = python\n');
await fs.writeFile(
path.join(sourceDir, 'backend', 'env', 'Scripts', 'activate_this.py'),
'VALUE = 1\n',
);
await fs.writeFile(
path.join(sourceDir, 'backend', 'env', 'include', 'header.py'),
'VALUE = 1\n',
);
await fs.writeFile(
path.join(sourceDir, 'backend', 'env', 'share', 'manual.py'),
'VALUE = 1\n',
);
await fs.writeFile(
path.join(sourceDir, 'generated', 'client.ts'),
'export const generatedClient = true;\n',
);
});
afterAll(async () => {
await fs.rm(sourceDir, { recursive: true, force: true });
});
it('discovers nested env/generated and .d.ts source while pruning root artifacts', async () => {
const files = await walkRepositoryPaths(sourceDir);
const paths = files.map((file) => file.path);
expect(paths).toContain('apps/client/src/shared/env/getAppEnv.ts');
expect(paths).toContain('packages/ai/src/generated/bundle.ts');
expect(paths).toContain('apps/client/src/vite-env.d.ts');
expect(paths).toContain('apps/client/src/service.ts');
expect(paths).not.toContain('apps/client/src/service.d.ts');
expect(paths).toContain('apps/client/src/legacy.js');
expect(paths).toContain('apps/client/src/legacy.d.ts');
expect(paths).not.toContain('build-cache/generated/ignored.ts');
expect(paths).not.toContain('env/settings.py');
expect(paths).not.toContain('backend/env/Scripts/activate_this.py');
expect(paths).not.toContain('backend/env/include/header.py');
expect(paths).not.toContain('backend/env/share/manual.py');
expect(paths).not.toContain('generated/client.ts');
});
it('preserves case variants that were not hardcoded ignore names', async () => {
const caseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-source-case-'));
try {
await fs.mkdir(path.join(caseDir, 'Generated'), { recursive: true });
await fs.mkdir(path.join(caseDir, 'Env'), { recursive: true });
await fs.writeFile(
path.join(caseDir, 'Generated', 'client.cs'),
'public class GeneratedClient {}\n',
);
await fs.writeFile(
path.join(caseDir, 'Env', 'settings.ts'),
'export const environment = "test";\n',
);
const paths = (await walkRepositoryPaths(caseDir)).map((file) => file.path);
expect(paths).toContain('Generated/client.cs');
expect(paths).toContain('Env/settings.ts');
} finally {
await fs.rm(caseDir, { recursive: true, force: true });
}
});
});
describe('.gitnexusignore support', () => {
let nexusignoreDir: string;
beforeAll(async () => {
nexusignoreDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-nexusignore-'));
await fs.mkdir(path.join(nexusignoreDir, 'src'), { recursive: true });
await fs.mkdir(path.join(nexusignoreDir, 'local', 'grafana'), { recursive: true });
await fs.writeFile(
path.join(nexusignoreDir, 'src', 'index.ts'),
'export const main = () => {}',
);
await fs.writeFile(path.join(nexusignoreDir, 'local', 'grafana', 'module.js'), 'var x = 1;');
// Only .gitnexusignore, no .gitignore
await fs.writeFile(path.join(nexusignoreDir, '.gitnexusignore'), 'local/\n');
});
afterAll(async () => {
await fs.rm(nexusignoreDir, { recursive: true, force: true });
});
it('excludes directories listed in .gitnexusignore', async () => {
const files = await walkRepositoryPaths(nexusignoreDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('src/index.ts'))).toBe(true);
expect(paths.every((p) => !p.includes('local/'))).toBe(true);
});
});
describe('.gitnexusignore negation of hardcoded directories', () => {
let partsDir: string;
beforeAll(async () => {
partsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-parts-'));
await fs.mkdir(path.join(partsDir, 'parts', 'src', 'main', 'java', 'com', 'example'), {
recursive: true,
});
await fs.mkdir(
path.join(
partsDir,
'admin',
'src',
'main',
'java',
'com',
'example',
'controller',
'parts',
),
{ recursive: true },
);
await fs.mkdir(path.join(partsDir, 'node_modules', 'pkg'), { recursive: true });
await fs.writeFile(path.join(partsDir, '.gitnexusignore'), '!parts/\n');
await fs.writeFile(
path.join(partsDir, 'parts', 'src', 'main', 'java', 'com', 'example', 'Part.java'),
'package com.example; class Part {}',
);
await fs.writeFile(
path.join(
partsDir,
'admin',
'src',
'main',
'java',
'com',
'example',
'controller',
'parts',
'PartsController.java',
),
'package com.example.controller.parts; class PartsController {}',
);
await fs.writeFile(
path.join(partsDir, 'node_modules', 'pkg', 'index.js'),
'module.exports = {}',
);
});
afterAll(async () => {
await fs.rm(partsDir, { recursive: true, force: true });
});
it('traverses top-level and nested parts directories when explicitly unignored (#2673)', async () => {
const files = await walkRepositoryPaths(partsDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).toContain('parts/src/main/java/com/example/Part.java');
expect(paths).toContain(
'admin/src/main/java/com/example/controller/parts/PartsController.java',
);
expect(paths.every((p) => !p.includes('node_modules/'))).toBe(true);
});
});
describe('combined .gitignore + .gitnexusignore', () => {
let combinedDir: string;
beforeAll(async () => {
combinedDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-combined-'));
await fs.mkdir(path.join(combinedDir, 'src'), { recursive: true });
await fs.mkdir(path.join(combinedDir, 'data'), { recursive: true });
await fs.mkdir(path.join(combinedDir, 'local', 'plugins'), { recursive: true });
await fs.writeFile(path.join(combinedDir, 'src', 'index.ts'), 'export const main = () => {}');
await fs.writeFile(path.join(combinedDir, 'data', 'dump.json'), '{}');
await fs.writeFile(path.join(combinedDir, 'local', 'plugins', 'module.js'), 'var x = 1;');
await fs.writeFile(path.join(combinedDir, '.gitignore'), 'data/\n');
await fs.writeFile(path.join(combinedDir, '.gitnexusignore'), 'local/\n');
});
afterAll(async () => {
await fs.rm(combinedDir, { recursive: true, force: true });
});
it('excludes directories from both files', async () => {
const files = await walkRepositoryPaths(combinedDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('src/index.ts'))).toBe(true);
expect(paths.every((p) => !p.includes('data/'))).toBe(true);
expect(paths.every((p) => !p.includes('local/'))).toBe(true);
});
});
describe('GITNEXUS_NO_GITIGNORE env var', () => {
let envDir: string;
beforeAll(async () => {
envDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-noignore-'));
await fs.mkdir(path.join(envDir, 'src'), { recursive: true });
await fs.mkdir(path.join(envDir, 'data'), { recursive: true });
await fs.writeFile(path.join(envDir, 'src', 'index.ts'), 'export const main = () => {}');
await fs.writeFile(path.join(envDir, 'data', 'dump.json'), '{}');
await fs.writeFile(path.join(envDir, '.gitignore'), 'data/\n');
});
afterAll(async () => {
await fs.rm(envDir, { recursive: true, force: true });
});
it('excludes gitignored directory by default', async () => {
const files = await walkRepositoryPaths(envDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.every((p) => !p.includes('data/'))).toBe(true);
});
it('includes gitignored directory when GITNEXUS_NO_GITIGNORE is set', async () => {
const original = process.env.GITNEXUS_NO_GITIGNORE;
process.env.GITNEXUS_NO_GITIGNORE = '1';
try {
const files = await walkRepositoryPaths(envDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('data/dump.json'))).toBe(true);
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_NO_GITIGNORE;
} else {
process.env.GITNEXUS_NO_GITIGNORE = original;
}
}
});
});
describe('readFileContents', () => {
it('reads file contents by relative paths', async () => {
const contents = await readFileContents(tmpDir, ['src/index.ts', 'src/utils.ts']);
expect(contents.get('src/index.ts')).toContain('main');
expect(contents.get('src/utils.ts')).toContain('helper');
});
it('handles empty path list', async () => {
const contents = await readFileContents(tmpDir, []);
expect(contents.size).toBe(0);
});
it('skips non-existent files gracefully', async () => {
const contents = await readFileContents(tmpDir, ['nonexistent.ts']);
expect(contents.size).toBe(0);
});
// ─── Unhappy paths ────────────────────────────────────────────────
it('skips multiple non-existent files gracefully', async () => {
const contents = await readFileContents(tmpDir, ['a.ts', 'b.ts', 'c.ts']);
expect(contents.size).toBe(0);
});
it('handles binary file content without crashing', async () => {
const contents = await readFileContents(tmpDir, ['src/image.png']);
// May return content or skip — should not throw
expect(contents.size).toBeLessThanOrEqual(1);
});
});
describe('large file skip threshold (#991)', () => {
let sizeDir: string;
const BIG_FILE = 'src/big.ts';
const BIG_DECLARATION = 'src/big.d.ts';
const BIG_FILE_BYTES = 600 * 1024;
const ORIGINAL_ENV = process.env.GITNEXUS_MAX_FILE_SIZE;
let cap: ReturnType<typeof _captureLogger>;
beforeAll(async () => {
sizeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-test-'));
await fs.mkdir(path.join(sizeDir, 'src'), { recursive: true });
await fs.writeFile(path.join(sizeDir, 'src', 'small.ts'), 'export const x = 1;');
await fs.writeFile(path.join(sizeDir, BIG_FILE), 'x'.repeat(BIG_FILE_BYTES));
await fs.writeFile(
path.join(sizeDir, BIG_DECLARATION),
'export declare const generatedTypes: string;\n',
);
});
afterAll(async () => {
await fs.rm(sizeDir, { recursive: true, force: true });
});
beforeEach(() => {
delete process.env.GITNEXUS_MAX_FILE_SIZE;
_resetMaxFileSizeWarnings();
cap = _captureLogger();
});
afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.GITNEXUS_MAX_FILE_SIZE;
} else {
process.env.GITNEXUS_MAX_FILE_SIZE = ORIGINAL_ENV;
}
cap.restore();
});
it('skips a 600KB file by default', async () => {
const files = await walkRepositoryPaths(sizeDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).toContain('src/small.ts');
expect(paths).not.toContain(BIG_FILE);
expect(paths).toContain(BIG_DECLARATION);
});
it('includes the 600KB file when GITNEXUS_MAX_FILE_SIZE=1024', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = '1024';
const files = await walkRepositoryPaths(sizeDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).toContain(BIG_FILE);
expect(paths).not.toContain(BIG_DECLARATION);
});
it('falls back to default and warns once on invalid GITNEXUS_MAX_FILE_SIZE', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = 'abc';
const files = await walkRepositoryPaths(sizeDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).not.toContain(BIG_FILE);
const invalidWarnings = cap
.records()
.filter((r) => String(r.msg ?? '').includes('must be a positive integer'));
expect(invalidWarnings).toHaveLength(1);
});
it('omits the "generated/vendored" suffix when threshold is overridden', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = '1';
await walkRepositoryPaths(sizeDir);
const skipWarnings = cap.records().filter((r) => String(r.msg ?? '').includes('Skipped '));
expect(skipWarnings.length).toBeGreaterThan(0);
for (const r of skipWarnings) {
expect(String(r.msg ?? '')).not.toContain('generated/vendored');
}
});
it('keeps the "generated/vendored" suffix under the default threshold', async () => {
await walkRepositoryPaths(sizeDir);
const skipWarnings = cap.records().filter((r) => String(r.msg ?? '').includes('Skipped '));
expect(skipWarnings.length).toBeGreaterThan(0);
expect(String(skipWarnings[0].msg ?? '')).toContain('generated/vendored');
});
// Regression: issue #1659. The skipped-paths list and the
// GITNEXUS_MAX_FILE_SIZE hint must appear by default, otherwise users
// see "Skipped N large files" with no actionable detail and misdiagnose
// missing IMPORTS/CALLS edges as a resolver bug.
it('lists the skipped path by default (not gated behind GITNEXUS_VERBOSE)', async () => {
await walkRepositoryPaths(sizeDir);
const pathWarnings = cap.records().filter((r) => String(r.msg ?? '').includes(BIG_FILE));
expect(pathWarnings.length).toBeGreaterThan(0);
});
it('emits a GITNEXUS_MAX_FILE_SIZE hint when running with the default cap', async () => {
await walkRepositoryPaths(sizeDir);
const hint = cap
.records()
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
expect(hint.length).toBe(1);
});
it('omits the GITNEXUS_MAX_FILE_SIZE hint when an override is active', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = '1';
await walkRepositoryPaths(sizeDir);
const hint = cap
.records()
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
expect(hint.length).toBe(0);
});
// Edge case from the #1661 adversarial review: setting GITNEXUS_MAX_FILE_SIZE
// to the same value as the default (512KB) used to still print the hint
// because the byte comparison resolved to equal. The hint should care
// about whether the operator set the env var, not what value they chose.
it('omits the GITNEXUS_MAX_FILE_SIZE hint when the override equals the default value', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = '512';
await walkRepositoryPaths(sizeDir);
const hint = cap
.records()
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
expect(hint.length).toBe(0);
});
it('routes large-file notices through console.warn while analyze progress is active', async () => {
const originalProgressActive = process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1';
await walkRepositoryPaths(sizeDir);
const messages = warnSpy.mock.calls.map(([msg]) => String(msg));
expect(messages.some((m) => m.includes('Skipped 1 large files'))).toBe(true);
expect(messages.some((m) => m.includes(BIG_FILE))).toBe(true);
expect(cap.records().filter((r) => String(r.msg ?? '').includes('Skipped '))).toHaveLength(
0,
);
} finally {
warnSpy.mockRestore();
if (originalProgressActive === undefined) {
delete process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
} else {
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = originalProgressActive;
}
}
});
});
describe('large file skip preview cap (#1659)', () => {
let manyDir: string;
const ORIGINAL_ENV = process.env.GITNEXUS_MAX_FILE_SIZE;
const ORIGINAL_VERBOSE = process.env.GITNEXUS_VERBOSE;
let cap: ReturnType<typeof _captureLogger>;
beforeAll(async () => {
manyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-many-'));
await fs.mkdir(path.join(manyDir, 'src'), { recursive: true });
// 8 files >512KB so the preview-cap path (5) is exercised.
for (let i = 0; i < 8; i++) {
await fs.writeFile(path.join(manyDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
}
});
afterAll(async () => {
await fs.rm(manyDir, { recursive: true, force: true });
});
beforeEach(() => {
delete process.env.GITNEXUS_MAX_FILE_SIZE;
delete process.env.GITNEXUS_VERBOSE;
_resetMaxFileSizeWarnings();
cap = _captureLogger();
});
afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.GITNEXUS_MAX_FILE_SIZE;
} else {
process.env.GITNEXUS_MAX_FILE_SIZE = ORIGINAL_ENV;
}
if (ORIGINAL_VERBOSE === undefined) {
delete process.env.GITNEXUS_VERBOSE;
} else {
process.env.GITNEXUS_VERBOSE = ORIGINAL_VERBOSE;
}
cap.restore();
});
it('truncates the path list to 5 and mentions GITNEXUS_VERBOSE when over the cap', async () => {
await walkRepositoryPaths(manyDir);
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
expect(pathLines.length).toBe(5);
const more = cap
.records()
.filter((r) => String(r.msg ?? '').includes('and 3 more (set GITNEXUS_VERBOSE=1'));
expect(more.length).toBe(1);
});
// Boundary check from the #1661 adversarial review: the SKIPPED_PREVIEW_CAP
// comparison is `<=`, so 5 paths should list all five without a truncation
// line and 6 paths should list exactly five plus "...and 1 more". Tested
// explicitly so a future off-by-one refactor (`<=` → `<`) fails fast.
it('lists all paths and omits the truncation line at exactly 5 skipped files', async () => {
const fiveDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-five-'));
try {
await fs.mkdir(path.join(fiveDir, 'src'), { recursive: true });
for (let i = 0; i < 5; i++) {
await fs.writeFile(path.join(fiveDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
}
await walkRepositoryPaths(fiveDir);
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
expect(pathLines.length).toBe(5);
const more = cap.records().filter((r) => String(r.msg ?? '').includes('...and '));
expect(more.length).toBe(0);
} finally {
await fs.rm(fiveDir, { recursive: true, force: true });
}
});
it('lists exactly 5 paths plus "...and 1 more" at exactly 6 skipped files', async () => {
const sixDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-six-'));
try {
await fs.mkdir(path.join(sixDir, 'src'), { recursive: true });
for (let i = 0; i < 6; i++) {
await fs.writeFile(path.join(sixDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
}
await walkRepositoryPaths(sixDir);
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
expect(pathLines.length).toBe(5);
const more = cap
.records()
.filter((r) => String(r.msg ?? '').includes('and 1 more (set GITNEXUS_VERBOSE=1'));
expect(more.length).toBe(1);
} finally {
await fs.rm(sixDir, { recursive: true, force: true });
}
});
it('lists every skipped path when GITNEXUS_VERBOSE=1', async () => {
process.env.GITNEXUS_VERBOSE = '1';
await walkRepositoryPaths(manyDir);
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
expect(pathLines.length).toBe(8);
const more = cap.records().filter((r) => String(r.msg ?? '').includes('and '));
expect(more.length).toBe(0);
});
// Issue #1659 follow-up (PR #1661 review): paths were pushed in fs.stat
// completion order, so the default preview could vary between runs on
// the same repo. The implementation sorts skippedLargePaths before
// slicing, so the listed paths come out in sorted order, which is the
// stable contract operators can rely on.
it('lists skipped paths in sorted order (deterministic preview)', async () => {
process.env.GITNEXUS_VERBOSE = '1';
await walkRepositoryPaths(manyDir);
const pathLines = cap
.records()
.map((r) => String(r.msg ?? ''))
.filter((m) => /^\s*-\s/.test(m))
.map((m) => m.replace(/^\s*-\s*/, ''));
expect(pathLines).toEqual([...pathLines].sort());
// sanity-check we actually saw all 8 of the manyDir fixture
expect(pathLines).toEqual([
'src/big0.ts',
'src/big1.ts',
'src/big2.ts',
'src/big3.ts',
'src/big4.ts',
'src/big5.ts',
'src/big6.ts',
'src/big7.ts',
]);
});
});
});