diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 163998a8e..645754427 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -1,4 +1,5 @@ import ignore, { type Ignore } from 'ignore'; +import { existsSync } from 'fs'; import fs from 'fs/promises'; import nodePath from 'path'; import type { Path } from 'path-scurry'; @@ -31,12 +32,15 @@ const DEFAULT_IGNORE_LIST = new Set([ // 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces) 'venv', '.venv', - 'env', '.env', + // Bare `env/` can be application source or a Python virtual environment. + // Path-aware rules below prune it at the root and wherever pyvenv.cfg marks + // a virtual environment, while preserving ordinary nested source folders. '__pycache__', '.pytest_cache', '.mypy_cache', 'site-packages', + 'dist-packages', '.tox', 'eggs', '.eggs', @@ -86,8 +90,9 @@ const DEFAULT_IGNORE_LIST = new Set([ // Generated/Compiled '.generated', - 'generated', 'auto-generated', + // Bare `generated/` can contain tracked source-of-truth code. Build output + // remains covered by .gitignore/.gitnexusignore and the unambiguous names. 'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime '.terraform', '.serverless', @@ -106,6 +111,14 @@ const DEFAULT_IGNORE_LIST = new Set([ '__snapshots__', ]); +// Ambiguous names that conventionally denote generated artifacts only at the +// repository root. Nested directories with these names are frequently source +// modules (for example apps/web/src/env or packages/api/generated). +const ROOT_ARTIFACT_DIRECTORIES = new Set(['env', 'generated']); + +const isRootArtifactDirectory = (relativePath: string, name: string): boolean => + !relativePath.includes('/') && ROOT_ARTIFACT_DIRECTORIES.has(name); + const IGNORED_EXTENSIONS = new Set([ // Images '.png', @@ -290,6 +303,10 @@ export const shouldIgnorePath = (filePath: string): boolean => { const fileName = parts[parts.length - 1]; const fileNameLower = fileName.toLowerCase(); + if (parts.length > 0 && isRootArtifactDirectory(parts[0], parts[0])) { + return true; + } + // Laravel compiles Blade templates into generated PHP cache files under // storage/framework/views. Source templates live in resources/views and are // handled separately; compiled cache should not become source-of-truth. Keep @@ -329,10 +346,8 @@ export const shouldIgnorePath = (filePath: string): boolean => { if ( fileNameLower.includes('.bundle.') || fileNameLower.includes('.chunk.') || - fileNameLower.includes('.generated.') || - fileNameLower.endsWith('.d.ts') + fileNameLower.includes('.generated.') ) { - // TypeScript declaration files return true; } @@ -344,6 +359,20 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => { return DEFAULT_IGNORE_LIST.has(name); }; +/** Apply directory ignore rules that depend on repository-relative depth. */ +export const isHardcodedIgnoredDirectoryAtPath = ( + repoRoot: string, + directoryPath: string, +): boolean => { + const name = nodePath.basename(directoryPath); + if (isHardcodedIgnoredDirectory(name)) return true; + + const relative = nodePath.relative(repoRoot, directoryPath).replace(/\\/g, '/'); + if (isRootArtifactDirectory(relative, name)) return true; + + return name === 'env' && existsSync(nodePath.join(directoryPath, 'pyvenv.cfg')); +}; + /** * Load .gitignore and .gitnexusignore rules from the repo root. * Returns an `ignore` instance with all patterns, or null if no files found. @@ -496,8 +525,10 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // last-match-wins: `!__tests__/` + `__tests__/generated/` still // blocks descent into `__tests__/generated/`. if (ig && rel && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false; - // Hardcoded list: block descent into well-known noise directories. - if (DEFAULT_IGNORE_LIST.has(p.name)) return true; + // Hardcoded and path-aware rules prune whole trees before glob walks them. + if (rel && isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel))) { + return true; + } // Check against .gitignore / .gitnexusignore patterns. // Since childrenIgnored is only called for directories, always test with // a trailing slash. This ensures directory-only negation patterns (e.g. diff --git a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts index 4453852a6..c07e5d8cc 100644 --- a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts @@ -2,7 +2,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { CypherExecutor } from '../contract-extractor.js'; import type { GroupManifestLink, ContractRole } from '../types.js'; -import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; +import { + shouldIgnorePath, + loadIgnoreRules, + isHardcodedIgnoredDirectoryAtPath, +} from '../../../config/ignore-service.js'; import { logger } from '../../logger.js'; interface PythonPackageMeta { @@ -161,9 +165,11 @@ async function findPythonFiles(repoPath: string): Promise { for (const entry of entries) { const childRel = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) { + const childPath = path.join(dir, entry.name); if (shouldIgnorePath(childRel)) continue; + if (isHardcodedIgnoredDirectoryAtPath(repoPath, childPath)) continue; if (ig && ig.ignores(childRel + '/')) continue; - await walk(path.join(dir, entry.name), childRel); + await walk(childPath, childRel); } else if (entry.name.endsWith('.py')) { if (shouldIgnorePath(childRel)) continue; if (ig && ig.ignores(childRel)) continue; diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index fc65cda09..804921a62 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -22,6 +22,27 @@ export interface FilePath { const READ_CONCURRENCY = 32; const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE'; +const DECLARATION_COMPANION_SUFFIXES = [ + { declaration: '.d.ts', implementations: ['.ts', '.tsx'] }, + { declaration: '.d.mts', implementations: ['.mts'] }, + { declaration: '.d.cts', implementations: ['.cts'] }, +] as const; + +const hasImplementationSibling = ( + declarationPath: string, + scannedPaths: ReadonlySet, +): boolean => { + const companion = DECLARATION_COMPANION_SUFFIXES.find(({ declaration }) => + declarationPath.endsWith(declaration), + ); + if (!companion) return false; + + // Keep standalone declarations. Only suppress declaration output that sits + // beside an implementation with the corresponding module suffix. + const stem = declarationPath.slice(0, -companion.declaration.length); + return companion.implementations.some((suffix) => scannedPaths.has(`${stem}${suffix}`)); +}; + const warnLargeFileSkip = (message: string): void => { if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') { // analyze.ts routes console.warn through the progress bar logger while @@ -84,10 +105,17 @@ export const walkRepositoryPaths = async ( } } + const scannedPaths = new Set(entries.map((entry) => entry.path)); + const deduplicatedEntries = entries.filter( + (entry) => !hasImplementationSibling(entry.path, scannedPaths), + ); + // Filesystem/glob traversal order is not stable across filesystems or repeated // scans. Canonicalize once at the scan boundary so every downstream phase sees // the same repository order. - entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + deduplicatedEntries.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ); if (skippedLarge > 0) { const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES; @@ -123,7 +151,7 @@ export const walkRepositoryPaths = async ( } } - return entries; + return deduplicatedEntries; }; /** diff --git a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts index 712359a41..5750da917 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts @@ -19,7 +19,7 @@ import fs from 'fs/promises'; import path from 'path'; import { createRequire } from 'node:module'; -import { isHardcodedIgnoredDirectory } from '../../../config/ignore-service.js'; +import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js'; import { logger } from '../../logger.js'; import { resolveFile } from '../languages/typescript/file-candidates.js'; @@ -361,9 +361,10 @@ export async function loadNodeWorkspacePackages( for (const entry of entries) { if (entry.isDirectory()) { - if (isHardcodedIgnoredDirectory(entry.name)) continue; + const childDir = path.join(dir, entry.name); + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue; if (depth < SCAN_MAX_DEPTH) { - queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); + queue.push({ dir: childDir, depth: depth + 1 }); } continue; } diff --git a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts index a111f5752..0e9871c64 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts @@ -23,7 +23,7 @@ import fs from 'fs/promises'; import path from 'path'; -import { isHardcodedIgnoredDirectory } from '../../../../config/ignore-service.js'; +import { isHardcodedIgnoredDirectoryAtPath } from '../../../../config/ignore-service.js'; import { logger } from '../../../logger.js'; /** One `paths` entry, pattern and targets kept in declaration order. */ @@ -291,9 +291,9 @@ async function findTsconfigFiles(repoRoot: string): Promise { } for (const entry of entries) { if (entry.isDirectory()) { - if (isHardcodedIgnoredDirectory(entry.name)) continue; - if (depth < SCAN_MAX_DEPTH) - queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); + const childDir = path.join(dir, entry.name); + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue; + if (depth < SCAN_MAX_DEPTH) queue.push({ dir: childDir, depth: depth + 1 }); continue; } if (!entry.isFile()) continue; diff --git a/gitnexus/test/integration/filesystem-walker.test.ts b/gitnexus/test/integration/filesystem-walker.test.ts index 031f446dd..4cf6ca5e4 100644 --- a/gitnexus/test/integration/filesystem-walker.test.ts +++ b/gitnexus/test/integration/filesystem-walker.test.ts @@ -187,6 +187,125 @@ describe('filesystem-walker', () => { }); }); + 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; @@ -394,6 +513,7 @@ describe('filesystem-walker', () => { 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; @@ -403,6 +523,10 @@ describe('filesystem-walker', () => { 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 () => { @@ -429,6 +553,7 @@ describe('filesystem-walker', () => { 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 () => { @@ -436,6 +561,7 @@ describe('filesystem-walker', () => { 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 () => { diff --git a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts index ea65ec88c..465f90a9b 100644 --- a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts +++ b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts @@ -40,6 +40,36 @@ describe('ignore + language-skip E2E', () => { path.join(tmpDir, 'src', 'greet.ts'), "export function greet(): string {\n return 'hello';\n}\n", ); + await fs.writeFile( + path.join(tmpDir, 'src', 'service.ts'), + 'export class UserService { load(): string { return "loaded"; } }\n', + ); + await fs.writeFile( + path.join(tmpDir, 'src', 'service.d.ts'), + 'export declare class UserService { load(): string; }\n', + ); + await fs.writeFile( + path.join(tmpDir, 'src', 'vite-env.d.ts'), + 'declare const APP_ENV: string;\n', + ); + await fs.writeFile(path.join(tmpDir, 'src', 'esm-service.mts'), 'export class EsmService {}\n'); + await fs.writeFile( + path.join(tmpDir, 'src', 'esm-service.d.mts'), + 'export declare class EsmService {}\n', + ); + await fs.writeFile(path.join(tmpDir, 'src', 'cjs-service.cts'), 'export class CjsService {}\n'); + await fs.writeFile( + path.join(tmpDir, 'src', 'cjs-service.d.cts'), + 'export declare class CjsService {}\n', + ); + await fs.writeFile( + path.join(tmpDir, 'src', 'ambient.d.mts'), + 'export declare class AmbientEsmService {}\n', + ); + await fs.writeFile( + path.join(tmpDir, 'src', 'ambient.d.cts'), + 'export declare class AmbientCjsService {}\n', + ); // Swift file — triggers language skip when grammar unavailable await fs.writeFile( @@ -70,6 +100,15 @@ describe('ignore + language-skip E2E', () => { expect(paths).toContain('src/index.ts'); expect(paths).toContain('src/greet.ts'); + expect(paths).toContain('src/service.ts'); + expect(paths).not.toContain('src/service.d.ts'); + expect(paths).toContain('src/vite-env.d.ts'); + expect(paths).toContain('src/esm-service.mts'); + expect(paths).not.toContain('src/esm-service.d.mts'); + expect(paths).toContain('src/cjs-service.cts'); + expect(paths).not.toContain('src/cjs-service.d.cts'); + expect(paths).toContain('src/ambient.d.mts'); + expect(paths).toContain('src/ambient.d.cts'); }); it('includes .swift files (discovery does not filter by language)', async () => { @@ -130,6 +169,30 @@ describe('ignore + language-skip E2E', () => { expect(functionNames).toContain('main'); expect(functionNames).toContain('greet'); + const userServiceNodes = nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'UserService', + ); + expect(userServiceNodes).toHaveLength(1); + expect(userServiceNodes[0].properties.filePath).toBe('src/service.ts'); + expect(nodes.some((node) => node.properties.filePath === 'src/service.d.ts')).toBe(false); + + expect( + nodes.filter((node) => node.label === 'Class' && node.properties.name === 'EsmService'), + ).toHaveLength(1); + expect( + nodes.filter((node) => node.label === 'Class' && node.properties.name === 'CjsService'), + ).toHaveLength(1); + expect( + nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'AmbientEsmService', + ), + ).toHaveLength(1); + expect( + nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'AmbientCjsService', + ), + ).toHaveLength(1); + // Function nodes should reference the correct source files const fnFilePaths = functionNodes.map((n) => (n.properties.filePath as string).replace(/\\/g, '/'), diff --git a/gitnexus/test/unit/group/python-workspace-extractor.test.ts b/gitnexus/test/unit/group/python-workspace-extractor.test.ts index 96a8b5e3f..87b9ffcb3 100644 --- a/gitnexus/test/unit/group/python-workspace-extractor.test.ts +++ b/gitnexus/test/unit/group/python-workspace-extractor.test.ts @@ -52,6 +52,31 @@ describe('PythonWorkspaceExtractor', () => { }); }); + it('does not emit contracts from a nested Python virtual environment', async () => { + await writeFile( + 'provider/pyproject.toml', + '[project]\nname = "provider"\nversion = "0.1.0"\ndependencies = []\n', + ); + await writeFile('provider/provider/__init__.py', 'class SecretClient: pass\n'); + + await writeFile( + 'consumer/pyproject.toml', + '[project]\nname = "consumer"\nversion = "0.1.0"\ndependencies = ["provider"]\n', + ); + await writeFile('consumer/backend/env/pyvenv.cfg', 'home = python\n'); + await writeFile('consumer/backend/env/leaked.py', 'from provider import SecretClient\n'); + + const repos = { provider: 'provider', consumer: 'consumer' }; + const repoPaths = new Map([ + ['provider', path.join(tmpDir, 'provider')], + ['consumer', path.join(tmpDir, 'consumer')], + ]); + + const result = await extractPythonWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(0); + }); + it('discovers imports via setup.py', async () => { await writeFile( 'core/setup.py', diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 42e1986df..4fb5536ef 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -204,8 +204,8 @@ describe('shouldIgnorePath', () => { expect(shouldIgnorePath('keep-ui/public/monaco-workers/125.js')).toBe(true); }); - it('ignores TypeScript declaration files', () => { - expect(shouldIgnorePath('types/index.d.ts')).toBe(true); + it('keeps tracked TypeScript declaration files discoverable', () => { + expect(shouldIgnorePath('types/index.d.ts')).toBe(false); }); it('ignores Laravel compiled Blade view cache files', () => { @@ -226,6 +226,12 @@ describe('shouldIgnorePath', () => { it.each([ 'src/index.ts', 'src/components/Button.tsx', + 'apps/client/src/shared/env/getAppEnv.ts', + 'packages/ai/src/generated/bundle.ts', + 'apps/client/src/vite-env.d.ts', + 'Generated/client.cs', + 'Env/settings.ts', + 'ENV/config.ts', 'lib/utils.py', 'cmd/server/main.go', 'src/main.rs', @@ -238,6 +244,13 @@ describe('shouldIgnorePath', () => { ])('does not ignore source file %s', (filePath) => { expect(shouldIgnorePath(filePath)).toBe(false); }); + + it.each(['env/pyvenv.cfg', 'env/settings.py', 'generated/client.ts'])( + 'prunes ambiguous artifact directories only at the repository root: %s', + (filePath) => { + expect(shouldIgnorePath(filePath)).toBe(true); + }, + ); }); }); @@ -248,6 +261,7 @@ describe('isHardcodedIgnoredDirectory', () => { expect(isHardcodedIgnoredDirectory('dist')).toBe(true); expect(isHardcodedIgnoredDirectory('monaco-workers')).toBe(true); expect(isHardcodedIgnoredDirectory('__pycache__')).toBe(true); + expect(isHardcodedIgnoredDirectory('dist-packages')).toBe(true); }); it('returns false for source directories', () => { @@ -255,6 +269,8 @@ describe('isHardcodedIgnoredDirectory', () => { expect(isHardcodedIgnoredDirectory('lib')).toBe(false); expect(isHardcodedIgnoredDirectory('app')).toBe(false); expect(isHardcodedIgnoredDirectory('local')).toBe(false); + expect(isHardcodedIgnoredDirectory('env')).toBe(false); + expect(isHardcodedIgnoredDirectory('generated')).toBe(false); }); }); @@ -308,6 +324,33 @@ describe('.gitnexusignore negation overrides hardcoded DEFAULT_IGNORE_LIST (#771 expect(filter.childrenIgnored(mkPath('__tests__'))).toBe(true); }); + it('prunes exact-case root artifacts while allowing nested source directories', async () => { + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('generated'))).toBe(true); + expect(filter.childrenIgnored(mkPath('env'))).toBe(true); + expect(filter.childrenIgnored(mkPath('packages/api/generated'))).toBe(false); + expect(filter.childrenIgnored(mkPath('Generated'))).toBe(false); + expect(filter.childrenIgnored(mkPath('Env'))).toBe(false); + }); + + it('prunes a nested env directory only when pyvenv.cfg identifies a virtual environment', async () => { + await fs.mkdir(path.join(tmpDir, 'backend', 'env'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'backend', 'env', 'pyvenv.cfg'), 'home = python\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('backend/env'))).toBe(true); + expect(filter.childrenIgnored(mkPath('services/api/env'))).toBe(false); + }); + + it('`!env/` negation unlocks the root artifact directory', async () => { + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!env/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('env'))).toBe(false); + expect(filter.ignored(mkPath('env/settings.py'))).toBe(false); + }); + it('`!__tests__/` negation unlocks the directory and its descendants', async () => { await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!__tests__/\n'); const filter = await createIgnoreFilter(tmpDir); diff --git a/gitnexus/test/unit/node-workspace-scope.test.ts b/gitnexus/test/unit/node-workspace-scope.test.ts index b59243b18..a37515c51 100644 --- a/gitnexus/test/unit/node-workspace-scope.test.ts +++ b/gitnexus/test/unit/node-workspace-scope.test.ts @@ -90,6 +90,22 @@ describe('workspace boundary', () => { expect(packages?.byName.has('@repo/web')).toBe(true); }); + it('prunes root artifact workspaces while keeping nested source directories', async () => { + const root = repo({ + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['generated/*', 'packages/*/generated'], + }), + 'generated/apiclient/package.json': pkg('@repo/root-artifact'), + 'packages/api/generated/package.json': pkg('@repo/generated-source'), + }); + + const packages = await loadNodeWorkspacePackages(root); + + expect(packages?.byName.has('@repo/root-artifact')).toBe(false); + expect(packages?.byName.has('@repo/generated-source')).toBe(true); + }); + it('honours a `!` exclusion', async () => { const root = repo({ 'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n - "!packages/internal"\n', diff --git a/gitnexus/test/unit/tsconfig-index.test.ts b/gitnexus/test/unit/tsconfig-index.test.ts index 55362c3e8..f711ae9cc 100644 --- a/gitnexus/test/unit/tsconfig-index.test.ts +++ b/gitnexus/test/unit/tsconfig-index.test.ts @@ -153,6 +153,21 @@ describe('extends chains', () => { }); describe('which config governs a file', () => { + it('prunes root artifact configs while keeping nested source directories', async () => { + const root = repo({ + 'generated/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'root-artifact' } }), + 'packages/api/generated/tsconfig.json': JSON.stringify({ + compilerOptions: { baseUrl: 'src' }, + }), + }); + const index = await loadTsconfigIndex(root); + + expect(tsconfigFor(index, 'generated/main.ts')).toBeNull(); + expect(tsconfigFor(index, 'packages/api/generated/main.ts')?.baseUrl).toBe( + 'packages/api/generated/src', + ); + }); + it('lets a child config with no baseUrl shadow the root, rather than inheriting it', async () => { // The child project declares no `baseUrl`, which in TypeScript means its // non-relative specifiers are PACKAGE lookups. Dropping the empty child let