From 17d5426d631cf19702e7113aab347d452c7c1580 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 30 Oct 2026 17:59:30 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + __tests__/owning-package.test.ts | 337 +++++++++++++++++++++++++ site/src/content/docs/reference/cli.md | 2 + src/bin/codegraph.ts | 20 +- src/graph/owning-package.ts | 144 +++++++++++ src/mcp/tools.ts | 36 ++- 6 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 __tests__/owning-package.test.ts create mode 100644 src/graph/owning-package.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32df517fb..4efb079ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) diff --git a/__tests__/owning-package.test.ts b/__tests__/owning-package.test.ts new file mode 100644 index 000000000..5a606a46b --- /dev/null +++ b/__tests__/owning-package.test.ts @@ -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): Promise { + 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>; + + 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); + }); + }); +}); diff --git a/site/src/content/docs/reference/cli.md b/site/src/content/docs/reference/cli.md index be9ecf95b..26f4d52aa 100644 --- a/site/src/content/docs/reference/cli.md +++ b/site/src/content/docs/reference/cli.md @@ -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. diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 71c45142b..d9a91aa8a 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -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 ', 'Project path') .option('--filter ', 'Filter to files under this directory') .option('--pattern ', 'Filter files matching this glob pattern') - .option('--format ', 'Output format (tree, flat, grouped)', 'tree') + .option('--format ', 'Output format (tree, flat, grouped, package)', 'tree') .option('--max-depth ', '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`)); diff --git a/src/graph/owning-package.ts b/src/graph/owning-package.ts new file mode 100644 index 000000000..7344937e2 --- /dev/null +++ b/src/graph/owning-package.ts @@ -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 { + /** 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(); + + 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( + projectRoot: string, + files: readonly F[], +): PackageGroup[] { + const ownerOf = owningPackageLookup(projectRoot); + const filesByOwner = new Map(); + 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[]): 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): 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})`; +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 3aa6258d7..3be1f39b8 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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 */