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:
Colby Mchenry
2026-10-30 17:59:30 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cd04b21a31
commit 17d5426d63
6 changed files with 539 additions and 4 deletions
+4
View File
@@ -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)
+337
View File
@@ -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);
});
});
});
+2
View File
@@ -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
View File
@@ -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`));
+144
View File
@@ -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
View File
@@ -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
*/