fix(ingestion): discover nested source directories (#3043)

This commit is contained in:
azizur100389 2026-08-26 13:24:39 +01:00 committed by GitHub
parent 9d4f029001
commit 88df18b829
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 374 additions and 20 deletions

View file

@ -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.

View file

@ -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<string[]> {
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;

View file

@ -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<string>,
): 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;
};
/**

View file

@ -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;
}

View file

@ -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<string[]> {
}
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;

View file

@ -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<typeof _captureLogger>;
@ -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 () => {

View file

@ -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, '/'),

View file

@ -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',

View file

@ -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);

View file

@ -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',

View file

@ -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