mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
784 lines
31 KiB
TypeScript
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',
|
|
]);
|
|
});
|
|
});
|
|
});
|