Clicking a file path now opens the file itself: what reaches into it, its symbols in source order, and what it reaches. The two rails count DEPENDENCIES, not import statements. The prototype drew `imports` edges; on this repo `src/graph/traversal.ts` imports two files and depends on four, because it reaches the LRU cache through a call no import names. A rail headed "Imports 2" would be quietly wrong about what changing the file would touch, which is the only question the screen answers — so the rails read `getFileDependencies` / `getFileDependents` and merge the import rows in for the symbol names. Imports that resolved to nothing indexed keep their own section rather than vanishing. The outline is windowed above 250 rows against a fixed 28px row: this repo's own fixtures hold a 1,681-symbol `.d.ts`, and paging it would hide the one thing an outline is for. `src/mcp/tools.ts` draws its 135 rows whole. `/api/file` gains `topLevel.calls` — module-level calls out of the file node — so a file that RUNS something offers the badge that opens it as a symbol, the only place code belonging to no symbol can be read. File results in the search palette and the entry-point list now land here rather than on the file node's Symbol view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305 lines
9.7 KiB
TypeScript
305 lines
9.7 KiB
TypeScript
/**
|
|
* The File view's models, without a browser (CG-46).
|
|
*
|
|
* The decision under test throughout is the rails' source of truth: they are
|
|
* built from `dependencies` / `dependents` — the engine's own
|
|
* `getFileDependencies` / `getFileDependents` — and merely *decorated* with
|
|
* the `imports` rows. Getting that backwards is not a cosmetic bug: it silently
|
|
* understates what a change to the file would reach, which is the only reason
|
|
* the screen exists.
|
|
*
|
|
* The geometry-free sibling of `ui-symbol-model.test.ts` and
|
|
* `ui-search-model.test.ts`.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
buildFileOutline,
|
|
buildFileRail,
|
|
fileMetaLine,
|
|
formatBytes,
|
|
looksLikeTest,
|
|
OUTLINE_ROW_HEIGHT,
|
|
OUTLINE_VIRTUAL_THRESHOLD,
|
|
} from '../ui/src/lib/file-model';
|
|
import type { WireFilePayload, WireImportRow, WireOutlineEntry } from '../ui/src/lib/api';
|
|
|
|
/* ------------------------------------------------------------- fixtures -- */
|
|
|
|
function importRow(over: Partial<WireImportRow> = {}): WireImportRow {
|
|
const symbols = over.symbols ?? [
|
|
{ id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 },
|
|
];
|
|
return {
|
|
file: over.file ?? 'src/db/queries.ts',
|
|
test: over.test ?? false,
|
|
symbols,
|
|
symbolCount: over.symbolCount ?? symbols.length,
|
|
};
|
|
}
|
|
|
|
function entry(over: Partial<WireOutlineEntry> = {}): WireOutlineEntry {
|
|
return {
|
|
id: over.id ?? 'method:x',
|
|
kind: 'method',
|
|
name: 'traverseBFS',
|
|
qualifiedName: 'GraphTraverser.traverseBFS',
|
|
file: 'src/graph/traversal.ts',
|
|
line: 48,
|
|
endLine: 150,
|
|
language: 'typescript',
|
|
test: false,
|
|
parentId: 'class:GraphTraverser',
|
|
depth: 1,
|
|
fanIn: 3,
|
|
fanOut: 7,
|
|
...over,
|
|
} as WireOutlineEntry;
|
|
}
|
|
|
|
function payload(over: Partial<WireFilePayload> = {}): WireFilePayload {
|
|
return {
|
|
file: {
|
|
path: 'src/graph/traversal.ts',
|
|
language: 'typescript',
|
|
size: 24216,
|
|
modifiedAt: 1,
|
|
indexedAt: 2,
|
|
contentHash: 'abc',
|
|
nodeCount: 26,
|
|
generated: false,
|
|
test: false,
|
|
errors: [],
|
|
id: 'file:src/graph/traversal.ts',
|
|
},
|
|
topLevel: { calls: 0 },
|
|
drift: false,
|
|
outline: { total: 0, shown: 0, truncated: false, items: [] },
|
|
imports: { total: 0, shown: 0, truncated: false, items: [] },
|
|
importedBy: { total: 0, shown: 0, truncated: false, items: [] },
|
|
unresolvedImports: [],
|
|
dependencies: [],
|
|
dependents: [],
|
|
...over,
|
|
} as WireFilePayload;
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- rail -- */
|
|
|
|
describe('the import rails', () => {
|
|
it('counts every dependency, not just the ones an import statement named', () => {
|
|
// The real shape on this repo: traversal.ts imports two files and depends
|
|
// on four — it reaches the LRU cache through a call with no import.
|
|
const rail = buildFileRail(
|
|
[
|
|
'src/db/queries.ts',
|
|
'src/resolution/lru-cache.ts',
|
|
'src/types.ts',
|
|
'scripts/agent-eval/probe.mjs',
|
|
],
|
|
[importRow({ file: 'src/db/queries.ts' }), importRow({ file: 'src/types.ts' })]
|
|
);
|
|
|
|
expect(rail.total).toBe(4);
|
|
expect(rail.rows).toHaveLength(4);
|
|
expect(rail.rows.filter((r) => r.imported).map((r) => r.path)).toEqual([
|
|
'src/db/queries.ts',
|
|
'src/types.ts',
|
|
]);
|
|
expect(rail.rows.find((r) => r.path === 'src/resolution/lru-cache.ts')?.imported).toBe(false);
|
|
});
|
|
|
|
it('names the symbols an import row carries, on the row for that file', () => {
|
|
const rail = buildFileRail(
|
|
['src/db/queries.ts'],
|
|
[
|
|
importRow({
|
|
symbols: [
|
|
{ id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 },
|
|
{ id: 'iface:R', name: 'Row', kind: 'interface', line: 12 },
|
|
],
|
|
}),
|
|
]
|
|
);
|
|
expect(rail.rows[0]?.symbols.map((s) => s.name)).toEqual(['QueryBuilder', 'Row']);
|
|
expect(rail.rows[0]?.symbolCount).toBe(2);
|
|
});
|
|
|
|
it('does not count a file node as a named symbol', () => {
|
|
// An `importedBy` edge's far end is the importing file's own file node, so
|
|
// its "symbols" repeat the path already in the row. A `1` there would be a
|
|
// count of nothing.
|
|
const rail = buildFileRail(
|
|
['src/index.ts'],
|
|
[
|
|
importRow({
|
|
file: 'src/index.ts',
|
|
symbols: [{ id: 'file:src/index.ts', name: 'index.ts', kind: 'file', line: 1 }],
|
|
}),
|
|
]
|
|
);
|
|
expect(rail.rows[0]?.symbolCount).toBe(0);
|
|
expect(rail.rows[0]?.imported).toBe(true);
|
|
});
|
|
|
|
it('sorts production files before tests, each alphabetically', () => {
|
|
const rail = buildFileRail(
|
|
['src/z.ts', '__tests__/graph.test.ts', 'src/a.ts', '__tests__/a.test.ts'],
|
|
[]
|
|
);
|
|
expect(rail.rows.map((r) => r.path)).toEqual([
|
|
'src/a.ts',
|
|
'src/z.ts',
|
|
'__tests__/a.test.ts',
|
|
'__tests__/graph.test.ts',
|
|
]);
|
|
expect(rail.testCount).toBe(2);
|
|
});
|
|
|
|
it('trusts the server about what is a test, and falls back to the path', () => {
|
|
const rail = buildFileRail(
|
|
['src/looks-normal.ts', 'src/other.ts'],
|
|
// The server can see more than a path; a row it marks wins.
|
|
[importRow({ file: 'src/looks-normal.ts', test: true })]
|
|
);
|
|
expect(rail.rows[0]?.path).toBe('src/other.ts');
|
|
expect(rail.rows[1]?.test).toBe(true);
|
|
});
|
|
|
|
it('de-duplicates a file the engine listed twice', () => {
|
|
const rail = buildFileRail(['src/a.ts', 'src/a.ts'], []);
|
|
expect(rail.rows).toHaveLength(1);
|
|
expect(rail.total).toBe(1);
|
|
});
|
|
|
|
it('folds unresolved imports by name, keeping every line', () => {
|
|
const rail = buildFileRail(
|
|
[],
|
|
[],
|
|
[
|
|
{ name: 'node:fs', line: 12 },
|
|
{ name: 'react', line: 3 },
|
|
{ name: 'node:fs', line: 4 },
|
|
]
|
|
);
|
|
expect(rail.outside).toEqual([
|
|
{ name: 'node:fs', lines: [4, 12] },
|
|
{ name: 'react', lines: [3] },
|
|
]);
|
|
// Outside-index rows never inflate the dependency count.
|
|
expect(rail.total).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('looksLikeTest', () => {
|
|
it('recognises the shapes an unnamed dependency can arrive in', () => {
|
|
expect(looksLikeTest('__tests__/graph.test.ts')).toBe(true);
|
|
expect(looksLikeTest('src/service.spec.ts')).toBe(true);
|
|
expect(looksLikeTest('test/helper.go')).toBe(true);
|
|
expect(looksLikeTest('__tests__/fixtures/app/main.ts')).toBe(true);
|
|
});
|
|
|
|
it('errs towards production — misfiling a real file is the worse mistake', () => {
|
|
expect(looksLikeTest('src/latest.ts')).toBe(false);
|
|
expect(looksLikeTest('src/protest/index.ts')).toBe(false);
|
|
expect(looksLikeTest('src/testing-library.ts')).toBe(false);
|
|
});
|
|
});
|
|
|
|
/* -------------------------------------------------------------- outline -- */
|
|
|
|
describe('the file outline', () => {
|
|
it('keeps the server order and indents by depth', () => {
|
|
const rows = buildFileOutline(
|
|
payload({
|
|
outline: {
|
|
total: 3,
|
|
shown: 3,
|
|
truncated: false,
|
|
items: [
|
|
entry({ id: 'class:C', kind: 'class', name: 'GraphTraverser', depth: 0, line: 34 }),
|
|
entry({ id: 'method:m', depth: 1, line: 48 }),
|
|
entry({ id: 'prop:p', kind: 'property', name: 'queries', depth: 1, line: 35 }),
|
|
],
|
|
},
|
|
})
|
|
);
|
|
expect(rows.map((r) => r.entry.id)).toEqual(['class:C', 'method:m', 'prop:p']);
|
|
expect(rows.map((r) => r.indent)).toEqual([0, 1, 1]);
|
|
});
|
|
|
|
it('dims data rather than behaviour', () => {
|
|
const rows = buildFileOutline(
|
|
payload({
|
|
outline: {
|
|
total: 4,
|
|
shown: 4,
|
|
truncated: false,
|
|
items: [
|
|
entry({ id: 'a', kind: 'property' }),
|
|
entry({ id: 'b', kind: 'enum_member' }),
|
|
entry({ id: 'c', kind: 'method' }),
|
|
entry({ id: 'd', kind: 'class' }),
|
|
],
|
|
},
|
|
})
|
|
);
|
|
expect(rows.map((r) => r.dimmed)).toEqual([true, true, false, false]);
|
|
});
|
|
|
|
it('clamps the indent so a deeply nested closure stays in its column', () => {
|
|
const rows = buildFileOutline(
|
|
payload({
|
|
outline: {
|
|
total: 1,
|
|
shown: 1,
|
|
truncated: false,
|
|
items: [entry({ depth: 9 })],
|
|
},
|
|
})
|
|
);
|
|
expect(rows[0]?.indent).toBe(3);
|
|
});
|
|
|
|
it('windows past a threshold that leaves ordinary files alone', () => {
|
|
// 135 symbols in this repo's biggest hand-written file (src/mcp/tools.ts);
|
|
// 1,681 in the generated fixture that motivated the window.
|
|
expect(OUTLINE_VIRTUAL_THRESHOLD).toBeGreaterThan(135);
|
|
expect(OUTLINE_ROW_HEIGHT).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
/* --------------------------------------------------------------- header -- */
|
|
|
|
describe('the header line', () => {
|
|
it('counts the outline, not the file record', () => {
|
|
// nodeCount includes the file node and its import declarations; neither is
|
|
// a row, and a header disagreeing with the list under it is unresolvable.
|
|
const line = fileMetaLine(
|
|
payload({
|
|
file: { ...payload().file, nodeCount: 26 },
|
|
outline: { total: 23, shown: 23, truncated: false, items: [] },
|
|
})
|
|
);
|
|
expect(line).toBe('typescript · 23.6 KB · 23 symbols');
|
|
});
|
|
|
|
it('tags a generated file and a test file', () => {
|
|
const line = fileMetaLine(
|
|
payload({
|
|
file: { ...payload().file, generated: true, test: true, size: 1024 },
|
|
outline: { total: 1, shown: 1, truncated: false, items: [] },
|
|
})
|
|
);
|
|
expect(line).toBe('typescript · 1.0 KB · 1 symbol · generated · test');
|
|
});
|
|
|
|
it('formats sizes for scale, never for accounting', () => {
|
|
expect(formatBytes(0)).toBe('0 B');
|
|
expect(formatBytes(999)).toBe('999 B');
|
|
expect(formatBytes(24216)).toBe('23.6 KB');
|
|
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
|
|
expect(formatBytes(Number.NaN)).toBe('—');
|
|
});
|
|
});
|