Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a call to a method whose name contains a `$` now links to that method through its receiver's type, like any other method call: RxJS-style `this.organizationService.organizations$(userId)`, or vscode's `this._proxy.$acceptModelChanged(…)` on the interface its RPC proxy is declared with. Before, such a call linked nothing, however well its receiver was typed. Re-index JavaScript and TypeScript projects after upgrading.
- In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading.
- `codegraph sync` and the file watcher now relink an import that reaches a module through a barrel file passing it on under another name, like `import { MatxLoading } from './components'` when `components/index.js` says `export { default as MatxLoading } from './MatxLoading'`, once an edit gives that module what is imported. Before, the import and every call through it stayed unlinked, or stayed on something else in the project with the same name, until the importing file itself changed or the project was indexed again.
- In JavaScript and TypeScript projects, a folder named `build` inside `src/` is now indexed for its TypeScript, JSX, Vue, Svelte and Astro files, so a feature kept there, like the `src/build/` pages of luci-go's milo, shows up in `codegraph_explore` and the routes that lazily load its pages link to them. Before, every folder named `build` was skipped as build output. The JavaScript, declaration and other files such a folder holds are still left out, since a build may have written them, and so is every `build` folder outside `src/`.

## [1.6.2] - 2026-10-03

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,10 @@ What it skips out of the box:
- **Dependency, build, and cache directories** — `node_modules`, `vendor`,
`dist`, `build`, `target`, `.venv`, `Pods`, `.next`, and the like across every
[supported stack](#supported-languages) — so the graph is your code, not
third-party noise. This holds even with no `.gitignore`.
third-party noise. This holds even with no `.gitignore`. A `build` folder
inside `src/` is the exception: no build step writes TypeScript, JSX, Vue,
Svelte or Astro files, so those are indexed from it, and a Java, Kotlin or
Scala package named `build` is indexed whole.
- **Anything in your `.gitignore`** — honored in git repos via git, and in
non-git projects by reading `.gitignore` directly (root and nested).
- **Files larger than 1 MB** — generated bundles, minified JS, vendored blobs.
Expand Down
4 changes: 2 additions & 2 deletions __tests__/react-router-lazy-barrel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* `invocation_page/index.ts`, whose `export * from './invocation_page'`
* forwards the `Component` that `invocation_page.tsx` declares. The route
* renders that component; before, the barrel was read for an export of its
* own, found none, and the route linked nothing. (The fixture's folder is
* `builds/`: a `build/` folder is skipped as build output.)
* own, found none, and the route linked nothing. (milo's own folder is
* `src/build/`; src-build-folders.test.ts covers routes into it.)
*/
import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest';
import * as fs from 'fs';
Expand Down
271 changes: 271 additions & 0 deletions __tests__/src-build-folders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/**
* A folder named `build` below `src/` is a source folder in a JS/TS app:
* luci-go's milo/ui keeps its whole LUCI "build" feature in `src/build/`, and
* its router lazily loads pages from `@/build/...`. The default ignore list
* skipped every folder named `build` as build output, so none of that feature
* was indexed and those routes linked nothing.
*
* Such a folder is now walked and its hand-written source (TypeScript, JSX,
* Vue, Svelte, Astro) indexed, while what a build step writes there —
* JavaScript, declarations, CSS, HTML, maps — stays out, as does every `build/`
* folder outside `src/`. Both enumeration paths, the git-status sync and the
* watcher share the rule (#1728).
*/
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 { buildDefaultIgnore, buildScopeIgnore, getGitChangedFiles, scanDirectory, scanDirectoryAsync } from '../src/extraction';
import { FileWatcher, __emitWatchEventForTests } from '../src/sync/watcher';

const git = (cwd: string, ...args: string[]): void => {
execFileSync('git', args, { cwd, stdio: 'pipe' });
};

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 = [];
});

const tempDir = (): string => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-src-build-'));
dirs.push(dir);
return dir;
};

const write = (dir: string, files: Record<string, string>): void => {
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);
}
};

const gitRepo = (dir: string): void => {
git(dir, 'init', '-q');
git(dir, 'config', 'user.email', 'test@test.com');
git(dir, 'config', 'user.name', 'Test');
};

describe('the scope of a build folder below src', () => {
const SOURCES = [
'src/build/constants.ts',
'src/build/legacy/build_page/build_page.tsx',
'src/build/nested/build/deep.ts',
// Any case, like the other defaults (its own parent: Windows and macOS
// file systems would merge it into `src/build/`).
'web/src/Build/upper_case.ts',
'packages/app/src/core/build/runner.mts',
'packages/app/src/core/build/loader.cts',
'src/views/tool/build/index.vue',
'src/routes/build/+page.svelte',
'src/pages/build/BuildPage.jsx',
'src/content/build/Card.astro',
// A JVM package named `build` keeps every file, as before (#1642).
'src/main/java/com/acme/build/Script.js',
];
const IGNORED = [
// What a build step writes, below src/ too.
'src/build/bundle.js',
'src/build/chunk.mjs',
'src/build/legacy.cjs',
'src/build/types/index.d.ts',
'src/build/types/module.d.mts',
'src/build/routes.yml',
'src/client/build/static/js/main.4f3a2b1c.js',
'src/main/resources/static/build/bundle.js',
'src/main/resources/build/Generated.java',
// The other defaults still apply inside such a folder.
'src/build/node_modules/pkg/index.ts',
'src/build/dist/index.ts',
'src/build/vendor/lib.ts',
// A build folder outside src/ stays excluded, whatever it holds.
'build/index.ts',
'build/static/js/main.4f3a2b1c.js',
'packages/app/build/index.ts',
'tools/build/release.ts',
];

it.each(['filesystem', 'untracked', 'tracked'])(
'indexes its hand-written source and nothing a build writes (%s)',
async (mode) => {
const root = tempDir();
write(root, Object.fromEntries([...SOURCES, ...IGNORED].map((rel) => [rel, 'export const x = 1;\n'])));
write(root, { 'src/client/package.json': '{"name":"client"}' });
if (mode !== 'filesystem') {
git(root, 'init', '-q');
if (mode === 'tracked') git(root, 'add', '-f', '.');
}

const defaults = buildDefaultIgnore(root);
const scope = buildScopeIgnore(root);
const files = scanDirectory(root);
expect(await scanDirectoryAsync(root)).toEqual(files);
for (const rel of SOURCES) {
expect(defaults.ignores(rel), rel).toBe(false);
expect(scope.ignores(rel), rel).toBe(false);
// The watcher and the filesystem walk must reach every folder on the way.
const parts = rel.split('/');
for (let i = 1; i < parts.length; i++) {
expect(scope.ignores(parts.slice(0, i).join('/') + '/'), rel).toBe(false);
}
expect(files).toContain(rel);
}
for (const rel of IGNORED) {
expect(defaults.ignores(rel), rel).toBe(true);
expect(scope.ignores(rel), rel).toBe(true);
expect(files).not.toContain(rel);
}
},
);

it("follows the project's own rules first", () => {
const files = {
'src/build/page.tsx': 'export const page = 1;\n',
'src/build/bundle.js': 'export const bundle = 1;\n',
'src/build/legacy/helper.js': 'export const helper = 1;\n',
'src/build/node_modules/pkg/index.js': 'export const dep = 1;\n',
};
const scope = (ignoreFile: string, rules: string): ReturnType<typeof buildScopeIgnore> => {
const root = tempDir();
write(root, files);
gitRepo(root);
write(root, { [ignoreFile]: rules });
return buildScopeIgnore(root);
};

// An explicit exclusion wins over the folder's source.
expect(scope('.gitignore', 'build/\n').ignores('src/build/page.tsx')).toBe(true);
expect(scope('codegraph.json', JSON.stringify({ exclude: ['src/build/'] })).ignores('src/build/page.tsx')).toBe(true);

// Re-including the folder — the documented opt-in — keeps every type in it, as before.
const optedIn = scope('.gitignore', '!src/build/\n');
expect(optedIn.ignores('src/build/bundle.js')).toBe(false);
expect(optedIn.ignores('src/build/legacy/helper.js')).toBe(false);
expect(optedIn.ignores('src/build/node_modules/pkg/index.js')).toBe(true);

// So does re-including a folder inside it, or the file itself.
const legacy = scope('.gitignore', '!src/build/legacy/\n');
expect(legacy.ignores('src/build/legacy/helper.js')).toBe(false);
expect(legacy.ignores('src/build/bundle.js')).toBe(true);
expect(scope('.gitignore', '!src/build/bundle.js\n').ignores('src/build/bundle.js')).toBe(false);

// Without project rules, the folder's JavaScript stays out.
const root = tempDir();
write(root, files);
expect(scanDirectory(root).sort()).toEqual(['src/build/page.tsx']);
});
});

describe('a JS/TS feature kept in src/build', () => {
const FILES: Record<string, string> = {
'package.json': JSON.stringify({ name: 'ui', private: true, dependencies: { react: '^18', 'react-router': '^7' } }),
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }),
'src/core/routes/routes.ts': `import { createBrowserRouter } from 'react-router';

export const router = createBrowserRouter([
{ path: '/p/:project/builders/:bucket/:builder', lazy: () => import('@/build/pages/builder_page') },
{ path: '/b/:buildId', lazy: () => import('@/build/legacy/build_page') },
]);
`,
'src/build/pages/builder_page/index.ts': `export * from './builder_page';
`,
'src/build/pages/builder_page/builder_page.tsx': `export function BuilderPage() {
return null;
}

export function Component() {
return <BuilderPage />;
}
`,
'src/build/legacy/build_page/index.ts': `export * from './build_page';
`,
'src/build/legacy/build_page/build_page.tsx': `export const BuildPage = () => null;

export function Component() {
return <BuildPage />;
}
`,
};

/** Each route's `references` edges, as `name@file`. */
const routeLinks = (graph: CodeGraph): Record<string, string[]> => Object.fromEntries(
graph.getNodesByKind('route').map((route) => [route.name, graph.getOutgoingEdges(route.id)
.filter((e) => e.kind === 'references')
.map((e) => graph.getNode(e.target)!)
.map((n) => `${n.name}@${n.filePath}`)
.sort()])
);

it('is indexed, so its lazy routes link, and sync keeps it', async () => {
const root = tempDir();
write(root, FILES);
gitRepo(root);
git(root, 'add', '.');
git(root, 'commit', '-q', '-m', 'init');
const cg = await CodeGraph.init(root, { index: true });
graphs.push(cg);

expect(routeLinks(cg)).toEqual({
'/p/:project/builders/:bucket/:builder': ['Component@src/build/pages/builder_page/builder_page.tsx'],
'/b/:buildId': ['Component@src/build/legacy/build_page/build_page.tsx'],
});
expect(cg.getNodesInFile('src/build/pages/builder_page/builder_page.tsx').map((n) => n.name)).toContain('BuilderPage');

// An edit, a new page and new build output below src/build: the git-status
// sync reads them by the same rule as the full index.
write(root, {
'src/build/pages/builder_page/builder_page.tsx': FILES['src/build/pages/builder_page/builder_page.tsx']!.replace(/BuilderPage/g, 'BuilderView'),
'src/build/pages/search_page/search_page.tsx': 'export function Component() {\n return null;\n}\n',
'src/build/pages/bundle.js': 'export function bundled() {}\n',
'src/build/pages/types.d.ts': 'export declare function bundled(): void;\n',
});
const changes = getGitChangedFiles(root)!;
expect([...changes.added, ...changes.modified].sort()).toEqual([
'src/build/pages/builder_page/builder_page.tsx',
'src/build/pages/search_page/search_page.tsx',
]);
const synced = await cg.sync();
expect(synced.filesAdded).toBe(1);
expect(synced.filesModified).toBe(1);
expect(cg.getNodesInFile('src/build/pages/builder_page/builder_page.tsx').map((n) => n.name)).toContain('BuilderView');
expect(cg.getNodesInFile('src/build/pages/bundle.js')).toEqual([]);
}, 60_000);

it('is watched: its source edits sync, build output there does not', async () => {
const root = tempDir();
write(root, FILES);
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();

write(root, {
'src/build/pages/bundle.js': 'export function bundled() {}\n',
'build/main.ts': 'export const out = 1;\n',
'src/build/pages/search_page/search_page.tsx': 'export function Component() {\n return null;\n}\n',
});
__emitWatchEventForTests(root, 'src/build/pages/bundle.js');
__emitWatchEventForTests(root, 'build/main.ts');
__emitWatchEventForTests(root, 'src/build/pages/search_page/search_page.tsx');
expect(watcher.getPendingFiles().map((p) => p.path)).toEqual(['src/build/pages/search_page/search_page.tsx']);
await vi.waitFor(() => expect(syncFn).toHaveBeenCalledTimes(1));
expect(syncFn.mock.calls[0]![0]).toEqual(['src/build/pages/search_page/search_page.tsx']);

// A folder removed inside it arrives as one event on the folder's path:
// that schedules the full reconcile which drops its files (#1285).
fs.rmSync(path.join(root, 'src/build/legacy'), { recursive: true, force: true });
__emitWatchEventForTests(root, 'src/build/legacy');
await vi.waitFor(() => expect(syncFn).toHaveBeenCalledTimes(2));
expect(syncFn.mock.calls[1]![0]).toBeUndefined();
});
});
2 changes: 1 addition & 1 deletion site/src/content/docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Next to none — CodeGraph is **zero-config by default**, with nothing to write

## What it skips out of the box

- **Dependency, build, and cache directories** — `node_modules`, `vendor`, `dist`, `build`, `target`, `.venv`, `Pods`, `.next`, and the like across every [supported stack](/codegraph/reference/languages/) — so the graph is your code, not third-party noise. This holds even with no `.gitignore`.
- **Dependency, build, and cache directories** — `node_modules`, `vendor`, `dist`, `build`, `target`, `.venv`, `Pods`, `.next`, and the like across every [supported stack](/codegraph/reference/languages/) — so the graph is your code, not third-party noise. This holds even with no `.gitignore`. A `build` folder inside `src/` is the exception: no build step writes TypeScript, JSX, Vue, Svelte or Astro files, so those are indexed from it, and a Java, Kotlin or Scala package named `build` is indexed whole.
- **Anything in your `.gitignore`** — honored in git repos via git, and in non-git projects by reading `.gitignore` directly (root and nested).
- **Files larger than 1 MB** — generated bundles, minified JS, vendored blobs.

Expand Down
Loading