mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
feat(files): list a project's files by the package that owns them (#3090)
Nothing could answer "which package owns this file": manifests are not source, so they have no row in the index, and `--format grouped` groups by language. `codegraph files --format package` and `codegraph_files` with `format: "package"` now group files by the nearest package.json or composer.json above them that names a package, so a monorepo splits into its workspace members with no configuration. The answer is read from disk when asked, so it stays current without a re-index. A manifest that names nothing doesn't stop the walk, and files nothing claims get a section of their own, last. Each directory is resolved once and shares its parent's answer. Reads are bounded by the source size limit and stay inside the project. Opt-in only: the default tree output and the `--json` shape are unchanged, and `--json` keeps ignoring `--format` as it always has. Validation: 31 new tests, including the real CLI. On vuejs/core the default listing and `--json` are byte-identical to main. The walk over babel/babel's 28,653 files in 13,484 directories takes a median 460 ms, on the query worker pool for `codegraph_files`. Reimplements PR 2863 by @LuisAlbertoMK. Fixes #1871 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cd04b21a31
commit
17d5426d63
@@ -17,6 +17,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- CodeGraph's refusal to use a sensitive folder such as `~/.ssh`, `~/.config`, `/etc` or `C:\Windows` as a project can no longer be sidestepped: a link or junction that leads to one, a differently capitalized name on Windows and macOS, a `\\?\` spelling on Windows, and a path to a missing folder inside one are now refused like the folder itself. Before, any of these let an AI agent's query read the files inside. Thanks @mixxer for finding it.
|
||||
- The MCP server no longer uses a sensitive folder such as `~/.ssh`, `~/.config` or `/etc` as a session's default project, whether the folder came from your AI agent's workspace, the folder the server was started in, or `--path`. Before, only a folder an agent asked for by path was refused, so a session started in one that held an index answered from the files inside and kept it synced; now that session has no default project, and the agent can still query any other project by path.
|
||||
|
||||
### New Features
|
||||
|
||||
- `codegraph files --format package` and `codegraph_files` with `format: "package"` now list a project's files by the package that owns them, the nearest `package.json` or `composer.json` above each file that names one, so a monorepo splits into its workspace members with no setup and files nothing claims get a section of their own (#1871). Thanks @LuisAlbertoMK.
|
||||
|
||||
### Fixes
|
||||
|
||||
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* `codegraph files --format package` and `codegraph_files` `format: "package"`
|
||||
* (#1871): a file's package is the nearest manifest above it that names one.
|
||||
*
|
||||
* Manifests are not indexed, so the walk reads the disk. These pin what it stops
|
||||
* on, what it refuses to read, and that the listing is opt-in: nothing else
|
||||
* `codegraph files` prints changes.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, 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/index';
|
||||
import { ToolHandler, tools } from '../src/mcp/tools';
|
||||
import { MAX_SOURCE_FILE_SIZE_BYTES } from '../src/file-limits';
|
||||
import {
|
||||
groupFilesByPackage,
|
||||
owningPackageLookup,
|
||||
packageGroupTitle,
|
||||
packageListingTitle,
|
||||
} from '../src/graph/owning-package';
|
||||
|
||||
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
||||
|
||||
// These index a project and spawn the real CLI, up to several times a test.
|
||||
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
|
||||
|
||||
function write(root: string, file: string, content: string): void {
|
||||
const full = path.join(root, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
function manifest(root: string, file: string, value: unknown): void {
|
||||
write(root, file, JSON.stringify(value));
|
||||
}
|
||||
|
||||
describe('owning package of a file', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-owning-package-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ownerName = (file: string): string | null => owningPackageLookup(root)(file)?.name ?? null;
|
||||
|
||||
it('takes the nearest manifest, however deep the file is', () => {
|
||||
manifest(root, 'package.json', { name: 'workspace-root' });
|
||||
manifest(root, 'apps/api/package.json', { name: '@acme/api' });
|
||||
|
||||
expect(ownerName('apps/api/src/deep/er/a.ts')).toBe('@acme/api');
|
||||
expect(ownerName('apps/api/b.ts')).toBe('@acme/api');
|
||||
expect(ownerName('apps/web/c.ts')).toBe('workspace-root');
|
||||
expect(ownerName('d.ts')).toBe('workspace-root');
|
||||
});
|
||||
|
||||
it('walks past a manifest that names nothing', () => {
|
||||
manifest(root, 'apps/api/package.json', { name: '@acme/api' });
|
||||
// The `{"type":"module"}` a build drops into an output folder.
|
||||
manifest(root, 'apps/api/dist/esm/package.json', { type: 'module' });
|
||||
|
||||
expect(ownerName('apps/api/dist/esm/index.js')).toBe('@acme/api');
|
||||
});
|
||||
|
||||
it('says so when nothing above a file names a package', () => {
|
||||
manifest(root, 'package.json', { private: true });
|
||||
|
||||
expect(owningPackageLookup(root)('scripts/a.ts')).toBeNull();
|
||||
});
|
||||
|
||||
it('settles a directory holding both manifests in favour of package.json', () => {
|
||||
manifest(root, 'lib/composer.json', { name: 'acme/lib' });
|
||||
manifest(root, 'lib/package.json', { name: '@acme/lib' });
|
||||
|
||||
expect(owningPackageLookup(root)('lib/a.ts')).toEqual({
|
||||
name: '@acme/lib',
|
||||
dir: 'lib',
|
||||
manifest: 'package.json',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses composer.json when package.json in the same directory names nothing', () => {
|
||||
manifest(root, 'package.json', { name: 'workspace-root' });
|
||||
manifest(root, 'lib/package.json', { private: true });
|
||||
manifest(root, 'lib/composer.json', { name: 'acme/lib' });
|
||||
|
||||
expect(owningPackageLookup(root)('lib/a.php')).toEqual({
|
||||
name: 'acme/lib',
|
||||
dir: 'lib',
|
||||
manifest: 'composer.json',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed JSON', '{ "name": '],
|
||||
['an empty file', ''],
|
||||
['a blank name', '{ "name": " " }'],
|
||||
['a name that is not a string', '{ "name": { "first": "x" } }'],
|
||||
['a top-level array', '["name"]'],
|
||||
['a top-level null', 'null'],
|
||||
])('reads %s as naming nothing, not as a failure', (_label, content) => {
|
||||
manifest(root, 'package.json', { name: 'workspace-root' });
|
||||
write(root, 'lib/package.json', content);
|
||||
|
||||
expect(ownerName('lib/a.ts')).toBe('workspace-root');
|
||||
});
|
||||
|
||||
it('reads a manifest saved with a byte-order mark', () => {
|
||||
write(root, 'lib/composer.json', '{ "name": "acme/lib" }');
|
||||
|
||||
expect(ownerName('lib/a.php')).toBe('acme/lib');
|
||||
});
|
||||
|
||||
it('trims the name it reports', () => {
|
||||
manifest(root, 'lib/package.json', { name: ' @acme/lib ' });
|
||||
|
||||
expect(ownerName('lib/a.ts')).toBe('@acme/lib');
|
||||
});
|
||||
|
||||
it('skips a manifest over the source size limit instead of reading it', () => {
|
||||
manifest(root, 'package.json', { name: 'workspace-root' });
|
||||
write(root, 'lib/package.json', `{"name":"huge","pad":"${'x'.repeat(MAX_SOURCE_FILE_SIZE_BYTES)}"}`);
|
||||
|
||||
expect(ownerName('lib/a.ts')).toBe('workspace-root');
|
||||
});
|
||||
|
||||
it('does not read a manifest reached through a link out of the project', () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-owning-outside-'));
|
||||
try {
|
||||
manifest(outside, 'package.json', { name: 'from-outside' });
|
||||
manifest(root, 'package.json', { name: 'workspace-root' });
|
||||
fs.symlinkSync(outside, path.join(root, 'linked'), 'junction');
|
||||
|
||||
expect(ownerName('linked/a.ts')).toBe('workspace-root');
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('shares one answer across a package, and reads the disk again for the next listing', () => {
|
||||
manifest(root, 'lib/package.json', { name: '@acme/lib' });
|
||||
|
||||
const lookup = owningPackageLookup(root);
|
||||
expect(lookup('lib/a.ts')).toBe(lookup('lib/src/b.ts'));
|
||||
|
||||
manifest(root, 'lib/package.json', { name: '@acme/renamed' });
|
||||
expect(owningPackageLookup(root)('lib/a.ts')?.name).toBe('@acme/renamed');
|
||||
});
|
||||
|
||||
it('orders packages by name, then directory, with the unowned group last', () => {
|
||||
manifest(root, 'z/package.json', { name: 'alpha' });
|
||||
manifest(root, 'a/package.json', { name: 'alpha' });
|
||||
manifest(root, 'm/package.json', { name: 'beta' });
|
||||
|
||||
const groups = groupFilesByPackage(root, [
|
||||
{ path: 'orphan.ts' },
|
||||
{ path: 'm/b.ts' },
|
||||
{ path: 'z/b.ts' },
|
||||
{ path: 'z/a.ts' },
|
||||
{ path: 'a/a.ts' },
|
||||
]);
|
||||
|
||||
expect(groups.map((group) => group.owner && `${group.owner.name}@${group.owner.dir}`)).toEqual([
|
||||
'alpha@a',
|
||||
'alpha@z',
|
||||
'beta@m',
|
||||
null,
|
||||
]);
|
||||
expect(groups[1]!.files.map((file) => file.path)).toEqual(['z/a.ts', 'z/b.ts']);
|
||||
expect(groups[3]!.files.map((file) => file.path)).toEqual(['orphan.ts']);
|
||||
});
|
||||
|
||||
it('keeps the original file records, so callers can render their metadata', () => {
|
||||
manifest(root, 'lib/package.json', { name: '@acme/lib' });
|
||||
const file = { path: 'lib/a.ts', language: 'typescript', nodeCount: 3 };
|
||||
|
||||
expect(groupFilesByPackage(root, [file])[0]!.files[0]).toBe(file);
|
||||
});
|
||||
|
||||
it('words a listing the same way for every surface', () => {
|
||||
manifest(root, 'package.json', { name: 'workspace-root' });
|
||||
manifest(root, 'apps/api/package.json', { name: '@acme/api' });
|
||||
|
||||
const groups = groupFilesByPackage(root, [
|
||||
{ path: 'apps/api/a.ts' },
|
||||
{ path: 'apps/api/b.ts' },
|
||||
{ path: 'c.ts' },
|
||||
]);
|
||||
|
||||
expect(packageListingTitle(groups)).toBe('Files by Package (3 files, 2 packages)');
|
||||
expect(groups.map(packageGroupTitle)).toEqual(['@acme/api in apps/api (2 files)', 'workspace-root (1 file)']);
|
||||
expect(packageGroupTitle({ owner: null, files: [{}] })).toBe('no owning package (1 file)');
|
||||
expect(packageListingTitle([{ owner: null, files: [{}] }])).toBe('Files by Package (1 file, 0 packages)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listing an indexed project by package', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-files-package-'));
|
||||
manifest(dir, 'package.json', { private: true });
|
||||
manifest(dir, 'apps/api/package.json', { name: '@acme/api' });
|
||||
manifest(dir, 'apps/web/package.json', { name: '@acme/web' });
|
||||
write(dir, 'apps/api/src/handler.ts', 'export function handler() { return 1; }\n');
|
||||
write(dir, 'apps/web/src/view.ts', 'export function view() { return 2; }\n');
|
||||
write(dir, 'scripts/tool.ts', 'export function tool() { return 3; }\n');
|
||||
const cg = await CodeGraph.init(dir, { config: { include: ['**/*.ts'], exclude: [] } });
|
||||
await cg.indexAll();
|
||||
cg.destroy();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function codegraph(...args: string[]): string {
|
||||
return execFileSync(process.execPath, [BIN, ...args, '--path', dir], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
describe('codegraph_files', () => {
|
||||
async function listed(args: Record<string, unknown>): Promise<string> {
|
||||
const cg = await CodeGraph.open(dir);
|
||||
try {
|
||||
const result = await new ToolHandler(cg).execute('codegraph_files', { format: 'package', ...args });
|
||||
expect(result.isError).toBeFalsy();
|
||||
return result.content[0]!.text as string;
|
||||
} finally {
|
||||
cg.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
it('offers the format in its schema', () => {
|
||||
const files = tools.find((tool) => tool.name === 'codegraph_files')!;
|
||||
expect(files.inputSchema.properties.format.enum).toContain('package');
|
||||
});
|
||||
|
||||
it('sections the files by package, the unowned ones last', async () => {
|
||||
expect(await listed({ includeMetadata: false })).toBe(
|
||||
[
|
||||
'**Files by Package (3 files, 2 packages)**',
|
||||
'',
|
||||
'**@acme/api in apps/api (1 file)**',
|
||||
'- apps/api/src/handler.ts',
|
||||
'',
|
||||
'**@acme/web in apps/web (1 file)**',
|
||||
'- apps/web/src/view.ts',
|
||||
'',
|
||||
'**no owning package (1 file)**',
|
||||
'- scripts/tool.ts',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows metadata unless told not to', async () => {
|
||||
expect(await listed({})).toMatch(/- apps\/api\/src\/handler\.ts \(typescript, \d+ symbols\)/);
|
||||
});
|
||||
|
||||
it('finds the manifest above a path filter', async () => {
|
||||
const text = await listed({ path: 'apps/api/src', includeMetadata: false });
|
||||
|
||||
expect(text).toContain('**@acme/api in apps/api (1 file)**');
|
||||
expect(text).not.toContain('@acme/web');
|
||||
expect(text).not.toContain('no owning package');
|
||||
});
|
||||
|
||||
it('follows a renamed manifest without a re-index', async () => {
|
||||
manifest(dir, 'apps/api/package.json', { name: '@acme/renamed' });
|
||||
|
||||
expect(await listed({ includeMetadata: false })).toContain('**@acme/renamed in apps/api (1 file)**');
|
||||
});
|
||||
|
||||
it('leaves the other formats alone', async () => {
|
||||
const flat = await listed({ format: 'flat', includeMetadata: false });
|
||||
|
||||
expect(flat).toContain('- apps/api/src/handler.ts');
|
||||
expect(flat).not.toContain('Files by Package');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codegraph files', () => {
|
||||
it('prints sections when asked for --format package', () => {
|
||||
const out = codegraph('files', '--format', 'package', '--no-metadata');
|
||||
|
||||
expect(out).toContain('Files by Package (3 files, 2 packages):');
|
||||
expect(out).toContain('@acme/api in apps/api (1 file):\n apps/api/src/handler.ts\n');
|
||||
expect(out).toContain('no owning package (1 file):\n scripts/tool.ts\n');
|
||||
expect(out.indexOf('@acme/web')).toBeLessThan(out.indexOf('no owning package'));
|
||||
});
|
||||
|
||||
it('shows metadata unless told not to', () => {
|
||||
expect(codegraph('files', '--format', 'package')).toMatch(
|
||||
/apps\/api\/src\/handler\.ts \(typescript, \d+ symbols\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies --filter before grouping', () => {
|
||||
const out = codegraph('files', '--format', 'package', '--filter', 'apps/web');
|
||||
|
||||
expect(out).toContain('Files by Package (1 file, 1 package):');
|
||||
expect(out).not.toContain('@acme/api');
|
||||
});
|
||||
|
||||
it('lists the help text for the new format', () => {
|
||||
expect(execFileSync(process.execPath, [BIN, 'files', '--help'], { encoding: 'utf-8' })).toContain(
|
||||
'tree, flat, grouped, package',
|
||||
);
|
||||
});
|
||||
|
||||
it('still prints a tree by default', () => {
|
||||
const out = codegraph('files');
|
||||
|
||||
expect(out).toContain('Project Structure (3 files):');
|
||||
expect(out).not.toContain('Files by Package');
|
||||
});
|
||||
|
||||
it('keeps the --json shape, with or without the format', () => {
|
||||
const plain = JSON.parse(codegraph('files', '--json')) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(plain.map((file) => Object.keys(file).sort())).toEqual(
|
||||
plain.map(() => ['errors', 'language', 'nodeCount', 'path', 'size']),
|
||||
);
|
||||
expect(JSON.parse(codegraph('files', '--json', '--format', 'package'))).toEqual(plain);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ codegraph impact AuthMiddleware --depth 3
|
||||
|
||||
`explore` and `node` are the CLI faces of the `codegraph_explore` and `codegraph_node` MCP tools — same output — so subagents and non-MCP harnesses can reach the graph from a shell.
|
||||
|
||||
`codegraph files --format package` lists files by the package that owns them: the nearest `package.json` or `composer.json` above each file that names one. A monorepo splits into its workspace members with no setup, and files nothing claims get a section of their own. The other formats are `tree` (the default), `flat` and `grouped` (by language).
|
||||
|
||||
## affected
|
||||
|
||||
Traces import dependencies transitively to find which test files are affected by changed source files. See [Affected Tests in CI](/codegraph/guides/affected-tests/) for options and a CI example.
|
||||
|
||||
+19
-1
@@ -74,6 +74,7 @@ import type { UiServerHandle } from '../ui-server';
|
||||
import { lookupSymbolNodes, describeSymbolNode, groupDefinitions } from '../graph/symbol-lookup';
|
||||
import { callSiteLines, formatCallSiteLines } from '../graph/call-sites';
|
||||
import { goPackageReaderFor } from '../graph/go-package';
|
||||
import { groupFilesByPackage, packageGroupTitle, packageListingTitle } from '../graph/owning-package';
|
||||
import type { Node, Edge } from '../types';
|
||||
import { isTestPath } from '../search/query-utils';
|
||||
|
||||
@@ -1816,7 +1817,7 @@ program
|
||||
.option('-p, --path <path>', 'Project path')
|
||||
.option('--filter <dir>', 'Filter to files under this directory')
|
||||
.option('--pattern <glob>', 'Filter files matching this glob pattern')
|
||||
.option('--format <format>', 'Output format (tree, flat, grouped)', 'tree')
|
||||
.option('--format <format>', 'Output format (tree, flat, grouped, package)', 'tree')
|
||||
.option('--max-depth <number>', 'Maximum directory depth for tree format')
|
||||
.option('--no-metadata', 'Hide file metadata (language, symbol count)')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
@@ -1924,6 +1925,23 @@ program
|
||||
}
|
||||
break;
|
||||
|
||||
case 'package': {
|
||||
const groups = groupFilesByPackage(projectPath, files);
|
||||
console.log(chalk.bold(`\n${packageListingTitle(groups)}:\n`));
|
||||
for (const group of groups) {
|
||||
console.log(chalk.cyan(`${packageGroupTitle(group)}:`));
|
||||
for (const file of group.files) {
|
||||
if (includeMetadata) {
|
||||
console.log(` ${file.path} ${chalk.dim(`(${file.language}, ${file.nodeCount} symbols)`)}`);
|
||||
} else {
|
||||
console.log(` ${file.path}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tree':
|
||||
default:
|
||||
console.log(chalk.bold(`\nProject Structure (${files.length} files):\n`));
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The package that owns a file: the nearest manifest above it that names one.
|
||||
*
|
||||
* `package.json` and `composer.json` are not source, so nothing about them is
|
||||
* in the index and "which package owns this file" (#1871) is answered from disk
|
||||
* when it is asked. That also keeps the answer current without a re-index. The
|
||||
* nearest manifest is the whole rule: a workspace member's own manifest sits
|
||||
* closer to its files than the root's, so a monorepo needs no configuration, and
|
||||
* a `package.json` that names nothing (`{ "type": "module" }` in a build folder,
|
||||
* a private root) simply doesn't stop the walk.
|
||||
*
|
||||
* `Cargo.toml` and `pyproject.toml` are left out on purpose. Reading a name from
|
||||
* them needs a TOML parser, and a `name =` search would group files under a
|
||||
* dependency's name; a listing that says "no owning package" is the honest
|
||||
* answer until then.
|
||||
*
|
||||
* Two surfaces render this (`codegraph files` and `codegraph_files`), so the
|
||||
* grouping and its wording both live here.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { readBoundedSourceSync } from '../file-limits';
|
||||
import { validatePathWithinRoot } from '../utils';
|
||||
|
||||
/** The manifests read, in the order that settles a directory holding both. */
|
||||
const PACKAGE_MANIFESTS = ['package.json', 'composer.json'] as const;
|
||||
|
||||
export interface OwningPackage {
|
||||
/** The manifest's `name`, trimmed. */
|
||||
name: string;
|
||||
/** Project-relative POSIX directory of the manifest; `.` is the project root. */
|
||||
dir: string;
|
||||
/** Which manifest named it. */
|
||||
manifest: (typeof PACKAGE_MANIFESTS)[number];
|
||||
}
|
||||
|
||||
export interface PackageGroup<F> {
|
||||
/** Null for the files no manifest above them names a package for. */
|
||||
owner: OwningPackage | null;
|
||||
files: F[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `name` a manifest declares. Null when the file is absent, outside the
|
||||
* project, over the source size limit, not valid JSON, or names nothing: a bad
|
||||
* manifest must cost a listing one package name, never the listing.
|
||||
*/
|
||||
function manifestName(projectRoot: string, manifestPath: string): string | null {
|
||||
// Most directories hold no manifest, so this is the common exit, and it comes
|
||||
// before the containment check, which resolves symlinks on every path.
|
||||
if (!fs.existsSync(path.join(projectRoot, manifestPath))) return null;
|
||||
const file = validatePathWithinRoot(projectRoot, manifestPath);
|
||||
if (!file) return null;
|
||||
try {
|
||||
const { bytes } = readBoundedSourceSync(file);
|
||||
if (!bytes) return null;
|
||||
// JSON.parse rejects the byte-order mark some Windows editors write.
|
||||
const manifest: unknown = JSON.parse(bytes.toString('utf8').replace(/^/, ''));
|
||||
const name = (manifest as { name?: unknown } | null)?.name;
|
||||
return typeof name === 'string' ? name.trim() || null : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `(projectRelativePath) => owner` lookup for one listing. Each directory is
|
||||
* resolved once and shares its parent's answer, so a listing costs one probe per
|
||||
* manifest type per directory however many files it holds, and every file of a
|
||||
* package gets the same `OwningPackage` object.
|
||||
*/
|
||||
export function owningPackageLookup(projectRoot: string): (filePath: string) => OwningPackage | null {
|
||||
const ownerByDir = new Map<string, OwningPackage | null>();
|
||||
|
||||
const ownerOf = (dir: string): OwningPackage | null => {
|
||||
const known = ownerByDir.get(dir);
|
||||
if (known !== undefined) return known;
|
||||
|
||||
let owner: OwningPackage | null = null;
|
||||
for (const manifest of PACKAGE_MANIFESTS) {
|
||||
const name = manifestName(projectRoot, path.posix.join(dir, manifest));
|
||||
if (name) {
|
||||
owner = { name, dir, manifest };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!owner) {
|
||||
const parent = path.posix.dirname(dir);
|
||||
if (parent !== dir) owner = ownerOf(parent);
|
||||
}
|
||||
ownerByDir.set(dir, owner);
|
||||
return owner;
|
||||
};
|
||||
|
||||
return (filePath) => ownerOf(path.posix.dirname(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* `files` grouped by owning package: packages by name, then directory when two
|
||||
* share a name, files by path. The unowned group comes last and is a group of
|
||||
* its own, never folded into a package, because a file nothing claims is what
|
||||
* someone splitting work by package needs to see.
|
||||
*
|
||||
* `projectRoot` is always the project's root, not a filtered subdirectory: a
|
||||
* file's manifest can sit above the filter.
|
||||
*/
|
||||
export function groupFilesByPackage<F extends { path: string }>(
|
||||
projectRoot: string,
|
||||
files: readonly F[],
|
||||
): PackageGroup<F>[] {
|
||||
const ownerOf = owningPackageLookup(projectRoot);
|
||||
const filesByOwner = new Map<OwningPackage | null, F[]>();
|
||||
for (const file of files) {
|
||||
const owner = ownerOf(file.path);
|
||||
const owned = filesByOwner.get(owner);
|
||||
if (owned) owned.push(file);
|
||||
else filesByOwner.set(owner, [file]);
|
||||
}
|
||||
return [...filesByOwner]
|
||||
.map(([owner, owned]) => ({ owner, files: owned.sort((a, b) => a.path.localeCompare(b.path)) }))
|
||||
.sort((a, b) => {
|
||||
if (!a.owner || !b.owner) return Number(!a.owner) - Number(!b.owner);
|
||||
return a.owner.name.localeCompare(b.owner.name) || a.owner.dir.localeCompare(b.owner.dir);
|
||||
});
|
||||
}
|
||||
|
||||
function countOf(count: number, noun: string): string {
|
||||
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/** The heading of a by-package listing. */
|
||||
export function packageListingTitle(groups: readonly PackageGroup<unknown>[]): string {
|
||||
const files = groups.reduce((total, group) => total + group.files.length, 0);
|
||||
const packages = groups.filter((group) => group.owner).length;
|
||||
return `Files by Package (${countOf(files, 'file')}, ${countOf(packages, 'package')})`;
|
||||
}
|
||||
|
||||
/** The heading of one package's section. */
|
||||
export function packageGroupTitle({ owner, files }: PackageGroup<unknown>): string {
|
||||
const size = countOf(files.length, 'file');
|
||||
if (!owner) return `no owning package (${size})`;
|
||||
return owner.dir === '.' ? `${owner.name} (${size})` : `${owner.name} in ${owner.dir} (${size})`;
|
||||
}
|
||||
+33
-3
@@ -60,6 +60,7 @@ import { describeSynthesizedHop } from '../graph/synthesized-hop';
|
||||
import { callSiteLines, formatCallSiteLines } from '../graph/call-sites';
|
||||
import type { CallSiteRow } from '../graph/traversal';
|
||||
import { isCopybookInclude, MAX_COPYBOOK_INCLUDES, type NamedCopybook } from '../graph/cobol-copybooks';
|
||||
import { groupFilesByPackage, packageGroupTitle, packageListingTitle } from '../graph/owning-package';
|
||||
import {
|
||||
findAllSymbols,
|
||||
resolveNamedSymbolFlow,
|
||||
@@ -1879,8 +1880,8 @@ export const tools: ToolDefinition[] = [
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
|
||||
enum: ['tree', 'flat', 'grouped'],
|
||||
description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language), "package" (by the nearest package.json or composer.json that names a package)',
|
||||
enum: ['tree', 'flat', 'grouped', 'package'],
|
||||
default: 'tree',
|
||||
},
|
||||
includeMetadata: {
|
||||
@@ -8596,7 +8597,7 @@ export class ToolHandler {
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const pathFilter = args.path as string | undefined;
|
||||
const pattern = args.pattern as string | undefined;
|
||||
const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
|
||||
const format = (args.format as 'tree' | 'flat' | 'grouped' | 'package') || 'tree';
|
||||
const includeMetadata = args.includeMetadata !== false;
|
||||
const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
|
||||
|
||||
@@ -8642,6 +8643,9 @@ export class ToolHandler {
|
||||
case 'grouped':
|
||||
output = this.formatFilesGrouped(files, includeMetadata);
|
||||
break;
|
||||
case 'package':
|
||||
output = this.formatFilesByPackage(cg.getProjectRoot(), files, includeMetadata);
|
||||
break;
|
||||
case 'tree':
|
||||
default:
|
||||
output = this.formatFilesTree(files, includeMetadata, maxDepth);
|
||||
@@ -8713,6 +8717,32 @@ export class ToolHandler {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format files grouped by the package that owns them
|
||||
*/
|
||||
private formatFilesByPackage(
|
||||
projectRoot: string,
|
||||
files: { path: string; language: string; nodeCount: number }[],
|
||||
includeMetadata: boolean
|
||||
): string {
|
||||
const groups = groupFilesByPackage(projectRoot, files);
|
||||
const lines: string[] = [`**${packageListingTitle(groups)}**`, ''];
|
||||
|
||||
for (const group of groups) {
|
||||
lines.push(`**${packageGroupTitle(group)}**`);
|
||||
for (const file of group.files) {
|
||||
if (includeMetadata) {
|
||||
lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
|
||||
} else {
|
||||
lines.push(`- ${file.path}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format files as a tree structure
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user