Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ leaves behind, and asserts the seam: nothing outside `lib/adapter.js` may reach
package is **prepared, not published** — `ui/package.json` carries `"private": true` deliberately,
and `scripts/pack-npm.sh` only packs a tarball when `CODEGRAPH_PACK_UI=1`.

Tests run as **two vitest projects** (`vitest.workspace.mts`): `engine` (node) and `ui` (jsdom, the
Tests run as **two Vitest projects** (`test.projects` in `vitest.config.mts`): `engine` (node) and `ui` (jsdom, the
Svelte plugin, `resolve.conditions: ['browser']`) for the single `__tests__/ui-package.test.ts`.
`npm test` still runs both. The split is not cosmetic — `browser` is a package-resolution
condition, and applied globally it hands the engine's suites the browser builds of
Expand Down
39 changes: 28 additions & 11 deletions __tests__/git-index-currency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,28 @@ vi.mock('child_process', async importOriginal => {
});
vi.mock('fs', async importOriginal => {
const actual = await importOriginal<typeof import('fs')>();
return { ...actual, readFileSync: vi.fn(actual.readFileSync) };
// openSync too: a bounded source reader opens a descriptor instead of
// calling readFileSync, and the injected read failure must reach either.
return { ...actual, readFileSync: vi.fn(actual.readFileSync), openSync: vi.fn(actual.openSync) };
});
// The real entry points, for the injecting implementations to fall through to
// and for afterEach to reinstate. Under Vitest 4 `vi.spyOn` on a mocked
// module's export returns the same `vi.fn`, so reading `cp.execFileSync` inside
// a test and calling it from the injected implementation recurses into itself,
// and `vi.restoreAllMocks()` no longer resets a `vi.fn`'s implementation.
const actualCp = await vi.importActual<typeof import('child_process')>('child_process');
const actualFs = await vi.importActual<typeof import('fs')>('fs');
const injectExecFileSync = (impl: (real: typeof cp.execFileSync, ...args: any[]) => unknown) =>
vi.mocked(cp.execFileSync).mockImplementation(((...args: any[]) => impl(actualCp.execFileSync, ...args)) as typeof cp.execFileSync);
const injectReadFileSync = (impl: (real: typeof fs.readFileSync, ...args: any[]) => unknown) =>
vi.mocked(fs.readFileSync).mockImplementation(((...args: any[]) => impl(actualFs.readFileSync, ...args)) as typeof fs.readFileSync);
const injectOpenSync = (impl: (real: typeof fs.openSync, ...args: any[]) => unknown) =>
vi.mocked(fs.openSync).mockImplementation(((...args: any[]) => impl(actualFs.openSync, ...args)) as typeof fs.openSync);
const reinstateIo = () => {
vi.mocked(cp.execFileSync).mockImplementation(actualCp.execFileSync as typeof cp.execFileSync);
vi.mocked(fs.readFileSync).mockImplementation(actualFs.readFileSync as typeof fs.readFileSync);
vi.mocked(fs.openSync).mockImplementation(actualFs.openSync as typeof fs.openSync);
};

describe('git index currency across commits and restores (#1829)', () => {
let root: string;
Expand All @@ -34,7 +54,7 @@ describe('git index currency across commits and restores (#1829)', () => {
cg = CodeGraph.initSync(root);
expect((await cg.indexAll()).success).toBe(true);
});
afterEach(() => { vi.restoreAllMocks(); cg?.close(); fs.rmSync(root, { recursive: true, force: true }); });
afterEach(() => { reinstateIo(); vi.restoreAllMocks(); cg?.close(); fs.rmSync(root, { recursive: true, force: true }); });

it.each(['index', 'sync', 'scoped'] as const)('sees a restored indexed dirty edit after %s', async mode => {
write('source.ts', 'dirtyVersion');
Expand Down Expand Up @@ -80,12 +100,11 @@ describe('git index currency across commits and restores (#1829)', () => {

it('falls back when git diff fails instead of claiming a clean index', () => {
write('new.ts', 'newSymbol'); commit();
const real = cp.execFileSync;
let injected = 0;
vi.spyOn(cp, 'execFileSync').mockImplementation(((file: string, args: string[], options: any) => {
injectExecFileSync((real, file: string, args: string[], options: any) => {
if (file === 'git' && args[0] === 'diff') { injected++; throw new Error('Injected git diff timeout'); }
return real(file, args, options);
}) as typeof cp.execFileSync);
});
expect(cg.getChangedFiles().added).toEqual(['new.ts']);
expect(injected).toBeGreaterThan(0);
});
Expand Down Expand Up @@ -135,19 +154,17 @@ describe('git index currency across commits and restores (#1829)', () => {
// Sync reads a source file through a bounded reader that opens a
// descriptor (#1910), so the failure is injected at openSync as well as
// readFileSync: it must reach whichever one the read goes through.
const realRead = fs.readFileSync;
const realOpen = fs.openSync;
let injected = 0;
const failNewTs = (real: (...args: any[]) => unknown) => (file: any, ...args: any[]) => {
const failNewTs = (real: (...args: any[]) => unknown, file: any, ...args: any[]) => {
if (String(file) === path.join(root, 'new.ts')) { injected++; throw new Error('Injected transient read error'); }
return real(file, ...args);
};
vi.spyOn(fs, 'readFileSync').mockImplementation(failNewTs(realRead as any) as typeof fs.readFileSync);
vi.spyOn(fs, 'openSync').mockImplementation(failNewTs(realOpen as any) as typeof fs.openSync);
injectReadFileSync(failNewTs);
injectOpenSync(failNewTs);
await cg.sync();
expect(injected).toBeGreaterThan(0);
expect(symbols('newSymbol')).not.toContain('newSymbol');
vi.restoreAllMocks();
reinstateIo();
expect(cg.getChangedFiles().added).toEqual(['new.ts']);
await cg.sync(); clean();
});
Expand Down
5 changes: 5 additions & 0 deletions __tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ describe('Symlink escape prevention (#527)', () => {
expect(validatePathWithinRoot(root, 'escapedir/secret.txt')).toBeNull();
});

it('rejects a missing leaf below an out-of-root directory symlink', () => {
if (!link(path.join(root, 'escapedir'), path.join(outside, 'pkg'))) return;
expect(validatePathWithinRoot(root, 'escapedir/not-created-yet/file.txt')).toBeNull();
});

it('still allows an in-repo symlink that stays WITHIN the root (no over-blocking)', () => {
if (!link(path.join(root, 'src', 'inlink.ts'), path.join(root, 'src', 'in.ts'))) return;
expect(validatePathWithinRoot(root, 'src/inlink.ts')).not.toBeNull();
Expand Down
169 changes: 169 additions & 0 deletions __tests__/ui-trails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as http from 'http';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
Expand All @@ -26,11 +27,15 @@ import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } fro
import {
encodeResolvedRun,
isTrailId,
listStoredTrails,
parseTrail,
slugify,
TRAILS_RELATIVE_DIR,
type StoredTrail,
type WireTrailHop,
} from '../src/ui-server/api';
import { writeStoredTrail } from '../src/ui-server/api/trail-store';
import { resetTrailAuthor, trailAuthor } from '../src/ui-server/api/trails';

interface Res {
status: number;
Expand Down Expand Up @@ -115,6 +120,29 @@ function trailsDir(): string {
return path.join(projectRoot, TRAILS_RELATIVE_DIR);
}

function storedTrail(id: string): StoredTrail {
return {
version: 1,
id,
name: 'Must stay inside',
note: '',
author: 'test',
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
hops: [
{
dir: 'start',
name: 'start',
qualifiedName: 'start',
kind: 'function',
file: 'src/start.ts',
line: 1,
id: 'function:start',
},
],
};
}

/** Re-index in place, the way a `codegraph sync` would after an edit. */
async function reindex(): Promise<void> {
const cg = CodeGraph.openSync(projectRoot);
Expand Down Expand Up @@ -216,6 +244,141 @@ describe('trail ids', () => {
});
});

describe('trail write containment', () => {
it.runIf(process.platform !== 'win32')(
'refuses a missing trail directory below a symlink that leaves the project',
() => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trail-symlink-'));
const project = path.join(base, 'project');
const outside = path.join(base, 'outside');
fs.mkdirSync(path.join(project, '.codegraph'), { recursive: true });
fs.mkdirSync(outside, { recursive: true });
fs.symlinkSync(outside, path.join(project, '.codegraph', 'ui'), 'dir');

try {
expect(() => writeStoredTrail(project, storedTrail('must-stay-inside'))).toThrow(
/outside the project/
);
expect(fs.existsSync(path.join(outside, 'trails'))).toBe(false);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
}
);

it.runIf(process.platform !== 'win32')(
'allows a project root reached through a symlink when the real path stays inside it',
() => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trail-root-link-'));
const project = path.join(base, 'project');
const alias = path.join(base, 'project-link');
fs.mkdirSync(path.join(project, '.codegraph'), { recursive: true });
fs.symlinkSync(project, alias, 'dir');

try {
expect(() => writeStoredTrail(alias, storedTrail('safe-root-link'))).not.toThrow();
expect(
fs.existsSync(path.join(project, TRAILS_RELATIVE_DIR, 'safe-root-link.json'))
).toBe(true);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
}
);
});

describe('trail read containment', () => {
it.runIf(process.platform !== 'win32')(
'skips a trail file symlinked outside the project instead of reading it',
() => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trail-read-link-'));
const project = path.join(base, 'project');
const outside = path.join(base, 'outside');
const dir = path.join(project, TRAILS_RELATIVE_DIR);
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(
path.join(outside, 'secret.json'),
JSON.stringify({ ...storedTrail('outside'), note: 'must not be read' })
);
fs.symlinkSync(path.join(outside, 'secret.json'), path.join(dir, 'linked.json'));

try {
expect(listStoredTrails(project)).toEqual({ trails: [], skipped: 1 });
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
}
);

it.runIf(process.platform !== 'win32')(
'skips a FIFO in the trails directory without blocking on it',
() => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trail-read-fifo-'));
const project = path.join(base, 'project');
const dir = path.join(project, TRAILS_RELATIVE_DIR);
fs.mkdirSync(dir, { recursive: true });
// A FIFO with no writer: a blocking open would hang here until one showed up.
execFileSync('mkfifo', [path.join(dir, 'blocked.json')]);

try {
// Run current TS source in a child: a synchronous open blocks Vitest's
// own timeout, but the parent's execFileSync timeout can kill the child.
const script = `
const fs = require('fs');
const ts = require('typescript');
require.extensions['.ts'] = (module, filename) => {
const source = fs.readFileSync(filename, 'utf8');
const { outputText } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
module._compile(outputText, filename);
};
const { listStoredTrails } = require(process.argv[1]);
process.stdout.write(JSON.stringify(listStoredTrails(process.argv[2])));
`;
const result = execFileSync(process.execPath, [
'-e', script, path.resolve(__dirname, '../src/ui-server/api/trail-store.ts'), project,
], {
cwd: path.resolve(__dirname, '..'),
encoding: 'utf8',
timeout: 5_000,
killSignal: 'SIGKILL',
});
expect(JSON.parse(result)).toEqual({ trails: [], skipped: 1 });
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
},
10_000
);
});

describe('trail author cache', () => {
it('keeps git user names separate for projects served by one process', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trail-authors-'));
const first = path.join(base, 'first');
const second = path.join(base, 'second');

try {
for (const [dir, author] of [
[first, 'Alice Project'],
[second, 'Bob Project'],
] as const) {
fs.mkdirSync(dir, { recursive: true });
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['config', 'user.name', author], { cwd: dir });
}
resetTrailAuthor();
expect(trailAuthor(first)).toBe('Alice Project');
expect(trailAuthor(second)).toBe('Bob Project');
} finally {
resetTrailAuthor();
fs.rmSync(base, { recursive: true, force: true });
}
});
});

describe('parseTrail', () => {
it('rejects a file that is not a trail rather than half-reading it', () => {
expect(parseTrail('x', 'not json')).toBeNull();
Expand All @@ -225,6 +388,12 @@ describe('parseTrail', () => {
expect(parseTrail('x', '{"name":"","hops":[{"qualifiedName":"a"}]}')).toBeNull();
});

it('rejects a declared format version this build does not understand', () => {
expect(
parseTrail('future', JSON.stringify({ ...storedTrail('future'), version: 2 }))
).toBeNull();
});

it('takes its id from the FILE, not from the field inside it', () => {
const trail = parseTrail('on-disk', '{"name":"a","id":"remembered","hops":[{"name":"f"}]}');
expect(trail?.id).toBe('on-disk');
Expand Down
4 changes: 4 additions & 0 deletions docs/viewer-launch-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel

- **Entry points and the Symbol view no longer go blank.** The Symbol view of every Vue, Svelte and Astro component stayed on "Loading…", because its member list held the component's script symbols twice, along with the component and its file. Entry points went blank on apps with inline route handlers like `app.get('/x', async (c) => { … })`, which came back once per call in the handler, each call listed as if it were the handler. Each member is now listed once, and each route is one row: the handler it is bound to, or "inline handler" when the handler is written at the registration.

- **Saved trails stay inside the indexed project even when a directory or trail file is a symlink.** The viewer refuses paths whose nearest existing directory resolves outside the project, opens trail files without following links and without blocking on a named pipe left in the trails directory, and creates its atomic temporary file exclusively so a pre-planted link cannot capture a read or write.

- **Saved-trail authors are now resolved per project.** An embedded host serving several projects in one process no longer reuses the first repository's Git user name for every later trail.

- **Entry points' most-depended-on list shows your code, not a vendored bundle's.** A minified library or bundled docs script (`n`, `t`, `Buffer` with thousands of callers, all from inside the bundle) could top the list. Symbols in generated files are now left out of it, like test files.

- **Stopping `codegraph ui` stops the server.** Killing the command by its process id, as a process manager, an editor task or `kill` does, left the server running on its port with no way to reach it, until the machine restarted. The server now notices it has been left behind and shuts down, closing the index first. Ctrl+C was never affected.
Expand Down
Loading