mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 05:59:11 +08:00
Several checks asked whether a path relative to the project root leaves
it with rel.startsWith('..'), which is also true of a folder whose name
starts with two dots, like `..generated/client.ts`. So a tsconfig paths
alias or baseUrl into such a folder linked nothing, the file watcher
dropped every edit made there (and the Linux per-folder watch tree never
skipped a gitignored one), a gitignored folder of that name could not be
brought back with codegraph.json include, a .gitignore rule for one was
never applied on the filesystem walk of a project that isn't a git
repository, and deprioritize could not name one.
One escapesRoot(rel) helper in utils.ts now answers it: `..` itself, a
first segment of `..` (`../`, or `..\` from path.relative on Windows),
or an absolute path, which path.relative gives for another Windows drive
(applyAliases did not reject that case before). It replaces the bare
prefix test at both path-aliases.ts sites, the watcher's handleChange
and shouldIgnoreDir, the extraction include walk and nested-gitignore
check, and the deprioritize matcher.
Left as they were, since a folder named that way is not a real input
there: Python source roots from config values and C/C++ include
directories in import-resolver.ts, Laravel require targets, and the
workspace-package targets, entries and path dependencies in
workspace-packages.ts.
Validation: new dotdot-folder-names tests (9) fail 8 of 9 on the base
and pass with the fix; the helper's own unit test runs against the new
helper in both. A/B against main on bulletproof-react, excalidraw,
outline, vben, gin and flask: byte-identical (none has a folder named
that way).
Reimplements PR #1406 by @amplifierplus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
7.2 KiB
TypeScript
154 lines
7.2 KiB
TypeScript
/**
|
|
* A folder whose name starts with two dots, like `..generated/`, is inside the
|
|
* project.
|
|
*
|
|
* Several places asked whether a relative path leaves the root with
|
|
* `rel.startsWith('..')`, which is also true of `..generated/client.ts`. So a
|
|
* `paths` alias into such a folder linked nothing (`"@generated/*":
|
|
* ["..generated/*"]`), the file watcher dropped every edit there, a gitignored
|
|
* folder of that name could not be brought back with `include`, a `.gitignore`
|
|
* rule for one was never applied when walking a folder that isn't a git
|
|
* repository, and `deprioritize` never matched one. A path leaves the root
|
|
* only through a `..` segment, or as an absolute path (another drive on
|
|
* Windows).
|
|
*/
|
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
import { execFileSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { escapesRoot } from '../src/utils';
|
|
import { applyAliases, aliasAndBaseUrlCandidates, loadProjectAliases } from '../src/resolution/path-aliases';
|
|
import { scanDirectory } from '../src/extraction';
|
|
import { clearProjectConfigCache } from '../src/project-config';
|
|
import { FileWatcher, __emitWatchEventForTests } from '../src/sync/watcher';
|
|
|
|
let dirs: string[] = [];
|
|
let graphs: CodeGraph[] = [];
|
|
let watchers: FileWatcher[] = [];
|
|
afterEach(() => {
|
|
for (const watcher of watchers) watcher.stop();
|
|
for (const graph of graphs) graph.close();
|
|
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
watchers = [];
|
|
graphs = [];
|
|
dirs = [];
|
|
clearProjectConfigCache();
|
|
});
|
|
|
|
const project = (files: Record<string, string>): string => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dotdot-'));
|
|
dirs.push(dir);
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, rel), content);
|
|
}
|
|
return dir;
|
|
};
|
|
|
|
const gitCommitAll = (dir: string): void => {
|
|
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
execFileSync('git', ['add', '-A'], { cwd: dir });
|
|
execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'x'], { cwd: dir });
|
|
};
|
|
|
|
const CLIENT = 'export function makeClient(): number { return 1; }\n';
|
|
|
|
describe('escapesRoot', () => {
|
|
it('leaves the root only through a `..` segment or an absolute path', () => {
|
|
for (const rel of ['..', '../x', '..\\x', '../..']) expect(escapesRoot(rel), rel).toBe(true);
|
|
expect(escapesRoot(path.resolve(os.tmpdir(), 'elsewhere'))).toBe(true);
|
|
for (const rel of ['..generated', '..generated/client.ts', '..generated\\client.ts', 'src/..x/y.ts', '.hidden/z.ts', '']) {
|
|
expect(escapesRoot(rel), rel).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('a path alias into a folder named like `..generated`', () => {
|
|
it('maps into the folder, and still drops a target outside the project', () => {
|
|
const root = project({
|
|
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@generated/*': ['..generated/*', '../outside/*'] } } }),
|
|
});
|
|
expect(applyAliases('@generated/client', loadProjectAliases(root)!, root)).toEqual(['..generated/client']);
|
|
});
|
|
|
|
it('reads a specifier from a baseUrl that is such a folder', () => {
|
|
const root = project({ 'jsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '..generated' } }) });
|
|
expect(aliasAndBaseUrlCandidates('client', loadProjectAliases(root)!, root)).toEqual(['..generated/client']);
|
|
});
|
|
|
|
it('links the import and the calls through it', async () => {
|
|
const root = project({
|
|
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@generated/*': ['..generated/*'] } } }),
|
|
'..generated/client.ts': CLIENT,
|
|
'src/app.ts': "import { makeClient as mk } from '@generated/client';\nexport function run(): number { return mk(); }\n",
|
|
});
|
|
const cg = await CodeGraph.init(root, { index: true });
|
|
graphs.push(cg);
|
|
const run = cg.getNodesByName('run')[0]!;
|
|
expect(cg.getCallees(run.id).map((c) => `${c.node.name}@${c.node.filePath}`)).toEqual(['makeClient@..generated/client.ts']);
|
|
});
|
|
});
|
|
|
|
describe('the file watcher and a folder named like `..generated`', () => {
|
|
it('records an edit there, and still drops a path outside the project', async () => {
|
|
const root = project({ '..generated/client.ts': CLIENT, 'src/app.ts': 'export const app = 1;\n' });
|
|
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
|
|
const watcher = new FileWatcher(root, syncFn, { inertForTests: true, debounceMs: 100 });
|
|
watchers.push(watcher);
|
|
watcher.start();
|
|
await watcher.waitUntilReady();
|
|
|
|
fs.writeFileSync(path.join(root, '..generated/client.ts'), 'export function makeClient(): number { return 2; }\n');
|
|
__emitWatchEventForTests(root, '..generated/client.ts');
|
|
__emitWatchEventForTests(root, '../outside.ts');
|
|
expect(watcher.getPendingFiles().map((p) => p.path)).toEqual(['..generated/client.ts']);
|
|
});
|
|
|
|
it('skips such a folder in the per-folder watch tree when .gitignore lists it', async () => {
|
|
const root = project({ '..cache/x.ts': 'export const x = 1;\n', '.gitignore': '..cache/\n' });
|
|
const watcher = new FileWatcher(root, vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }), { inertForTests: true });
|
|
watchers.push(watcher);
|
|
watcher.start();
|
|
await watcher.waitUntilReady();
|
|
expect((watcher as unknown as { shouldIgnoreDir(p: string): boolean }).shouldIgnoreDir(path.join(root, '..cache'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('scanning a folder named like `..generated`', () => {
|
|
const scan = (dir: string) => scanDirectory(dir).map((f) => f.replace(/\\/g, '/')).sort();
|
|
|
|
it('brings a gitignored one back when `include` names it', () => {
|
|
const root = project({
|
|
'app/main.ts': 'export const main = 1;\n',
|
|
'..generated/client.ts': CLIENT,
|
|
'.gitignore': '..generated/\n',
|
|
'codegraph.json': JSON.stringify({ include: ['..generated/'] }),
|
|
});
|
|
gitCommitAll(root);
|
|
expect(scan(root)).toEqual(['..generated/client.ts', 'app/main.ts']);
|
|
});
|
|
|
|
it('applies a .gitignore rule for one when the folder is no git repository', () => {
|
|
const root = project({ 'app/main.ts': 'export const main = 1;\n', '..cache/x.ts': 'export const x = 1;\n', '.gitignore': '..cache/\n' });
|
|
expect(scan(root)).toEqual(['app/main.ts']);
|
|
});
|
|
});
|
|
|
|
describe('deprioritize and a folder named like `..generated`', () => {
|
|
it('ranks what such a folder holds lower when codegraph.json names it', async () => {
|
|
const files = {
|
|
'..generated/client.ts': 'export function makeClient(): number { return 1; }\n',
|
|
'src/client.ts': 'export function makeClient(): number { return 2; }\n',
|
|
};
|
|
const score = async (config: object | null): Promise<number> => {
|
|
const root = project(config ? { ...files, 'codegraph.json': JSON.stringify(config) } : files);
|
|
const cg = await CodeGraph.init(root, { index: true });
|
|
graphs.push(cg);
|
|
return cg.searchNodes('makeClient', { limit: 10 }).find((r) => r.node.filePath === '..generated/client.ts')!.score;
|
|
};
|
|
expect(await score({ deprioritize: ['..generated/'] })).toBeLessThan(await score(null));
|
|
});
|
|
});
|