From 9bf8fb71c6f6de1be6b40b9108c3c826f9fd8a8d Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 20:34:04 -0700 Subject: [PATCH 01/55] fix(cli): return structured plugin state --- packages/core/src/cli/plugins/list.ts | 85 +++++++++++------- .../cli/plugins/commandWorkspaceState.test.ts | 90 ++++++++++++++++--- 2 files changed, 129 insertions(+), 46 deletions(-) diff --git a/packages/core/src/cli/plugins/list.ts b/packages/core/src/cli/plugins/list.ts index b78709eb3..ed59e4abf 100644 --- a/packages/core/src/cli/plugins/list.ts +++ b/packages/core/src/cli/plugins/list.ts @@ -1,5 +1,6 @@ import { readCodeGraphyWorkspaceSettingsOrInitial } from '../../workspace/settings'; import { createPluginActivityState } from '../../plugins/activityState/model'; +import type { CodeGraphyInstalledPluginRecord } from '../../plugins/installedCache'; import type { CommandExecutionResult } from '../command'; import type { CliCommand } from '../parse'; import type { PluginsCommandDependencies } from './dependencies'; @@ -9,56 +10,72 @@ import { } from './installed'; import { resolveWorkspaceRoot } from './workspace'; +type PluginListState = 'active' | 'disabled' | 'enabled-unavailable'; + +function readPluginState( + pluginId: string, + activity: ReturnType, +): PluginListState { + if (activity.activePluginIds.has(pluginId)) return 'active'; + if (activity.enabledPluginIds.has(pluginId)) return 'enabled-unavailable'; + return 'disabled'; +} + +function createRegisteredPluginOutput( + plugin: CodeGraphyInstalledPluginRecord, + workspaceActivation: Map, + activity: ReturnType, +): Record { + return { + id: plugin.id, + package: plugin.package, + host: plugin.host, + globallyEnabled: plugin.globallyEnabled, + workspaceActivation: workspaceActivation.get(plugin.id) ?? 'inherit', + state: readPluginState(plugin.id, activity), + }; +} + export function runListCommand( command: CliCommand, dependencies: PluginsCommandDependencies, ): CommandExecutionResult { const workspaceRoot = resolveWorkspaceRoot(command.workspacePath, dependencies); + const settings = readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot); const registeredPlugins = listRegisteredPluginsWithBundledMarkdown( dependencies.readInstalledPluginCache({ homeDir: dependencies.homeDir, }), ); const activity = createPluginActivityState({ - settings: readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot), + settings, installedPlugins: registeredPlugins, }); - const activePluginIds = [...activity.activePluginIds]; - const enabledPluginIds = [...activity.enabledPluginIds]; - const enabledPluginIdSet = new Set(enabledPluginIds); - const unavailablePluginIds = enabledPluginIds.filter(pluginId => !activity.activePluginIds.has(pluginId)); - const disabledPlugins = registeredPlugins.filter(plugin => - !enabledPluginIdSet.has(getRegisteredPluginId(plugin)) + const workspaceActivation = new Map( + settings.plugins.map(plugin => [plugin.id, plugin.activation] as const), ); - const disabledPluginIds = [...new Set(disabledPlugins.map(getRegisteredPluginId))]; - - const lines = [ - `CodeGraphy plugins for ${workspaceRoot}`, - '', - 'Enabled in workspace:', - ...( - activePluginIds.length > 0 - ? activePluginIds.map((pluginId, index) => `${index + 1}. ${pluginId}`) - : ['none'] - ), - '', - 'Enabled but unavailable:', - ...( - unavailablePluginIds.length > 0 - ? unavailablePluginIds.map(pluginId => `- ${pluginId}`) - : ['none'] - ), - '', - 'Registered but disabled:', - ...( - disabledPluginIds.length > 0 - ? disabledPluginIds.map(pluginId => `- ${pluginId}`) - : ['none'] - ), - ]; + const registeredPluginIds = new Set(registeredPlugins.map(getRegisteredPluginId)); + const unavailablePlugins = [...activity.enabledPluginIds] + .filter(pluginId => !registeredPluginIds.has(pluginId)) + .map(pluginId => ({ + id: pluginId, + workspaceActivation: workspaceActivation.get(pluginId) ?? 'inherit', + state: readPluginState(pluginId, activity), + })); return { exitCode: 0, - output: lines.join('\n'), + output: JSON.stringify({ + workspaceRoot, + plugins: [ + ...registeredPlugins.map(plugin => createRegisteredPluginOutput( + plugin, + workspaceActivation, + activity, + )), + ...unavailablePlugins, + ], + warnings: activity.warnings, + }), }; } diff --git a/packages/core/tests/cli/plugins/commandWorkspaceState.test.ts b/packages/core/tests/cli/plugins/commandWorkspaceState.test.ts index eb7ffab16..b5a977296 100644 --- a/packages/core/tests/cli/plugins/commandWorkspaceState.test.ts +++ b/packages/core/tests/cli/plugins/commandWorkspaceState.test.ts @@ -145,6 +145,52 @@ describe('plugins/command workspace state', () => { ]); }); + it('returns structured plugin activity for automation', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-list-')); + const vue = createPluginRecord( + '@codegraphy-dev/plugin-vue', + '/global/@codegraphy-dev/plugin-vue', + 'codegraphy.vue', + ); + writeCodeGraphyInstalledPluginCache({ version: 3, plugins: [vue] }, { homeDir }); + await runPluginsCommand({ + name: 'plugins', + action: 'enable', + packageName: 'codegraphy.vue', + workspacePath: workspaceRoot, + }, { homeDir }); + + const result = await runPluginsCommand({ + name: 'plugins', + action: 'list', + workspacePath: workspaceRoot, + }, { homeDir }); + + expect(JSON.parse(result.output)).toEqual({ + workspaceRoot, + plugins: [ + { + id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, + package: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + host: 'core', + globallyEnabled: true, + workspaceActivation: 'inherit', + state: 'active', + }, + { + id: 'codegraphy.vue', + package: '@codegraphy-dev/plugin-vue', + host: 'core', + globallyEnabled: false, + workspaceActivation: 'enabled', + state: 'active', + }, + ], + warnings: [], + }); + }); + it('lists disabled bundled Markdown without requiring it in the user installed plugin cache', async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-markdown-')); @@ -162,9 +208,14 @@ describe('plugins/command workspace state', () => { workspacePath: workspaceRoot, }, { homeDir }); - expect(result.output).toContain('Enabled in workspace:\nnone'); - expect(result.output).toContain('Registered but disabled:'); - expect(result.output).toContain(`- ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); + expect(JSON.parse(result.output)).toMatchObject({ + plugins: [{ + id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, + workspaceActivation: 'disabled', + state: 'disabled', + }], + warnings: [], + }); }); it('lists enabled workspace plugins separately from registered disabled plugins', async () => { @@ -201,12 +252,14 @@ describe('plugins/command workspace state', () => { workspacePath: workspaceRoot, }, { homeDir }); - expect(result.output).toContain(`CodeGraphy plugins for ${workspaceRoot}`); - expect(result.output).toContain('Enabled in workspace:'); - expect(result.output).toContain(`1. ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); - expect(result.output).toContain('2. codegraphy.vue'); - expect(result.output).toContain('Registered but disabled:'); - expect(result.output).not.toContain(`- ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); + expect(JSON.parse(result.output)).toMatchObject({ + workspaceRoot, + plugins: [ + { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, state: 'active' }, + { id: 'codegraphy.vue', state: 'active' }, + ], + warnings: [], + }); }); it('lists enabled conflicting descriptors as unavailable instead of disabled', async () => { @@ -231,8 +284,21 @@ describe('plugins/command workspace state', () => { workspacePath: workspaceRoot, }, { homeDir }); - expect(result.output).toContain('Enabled but unavailable:'); - expect(result.output).toContain('- acme.conflict'); - expect(result.output).not.toContain('Registered but disabled:\n- acme.conflict'); + const output = JSON.parse(result.output); + expect(output.plugins).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'acme.conflict', + package: '@acme/plugin-one', + state: 'enabled-unavailable', + }), + expect.objectContaining({ + id: 'acme.conflict', + package: '@acme/plugin-two', + state: 'enabled-unavailable', + }), + ])); + expect(output.warnings).toEqual([ + expect.stringContaining("CodeGraphy plugin 'acme.conflict' is enabled but multiple installed packages claim it"), + ]); }); }); From cce0e55fa168453cf25338a57d28964109d7510c Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 20:37:33 -0700 Subject: [PATCH 02/55] fix(cli): keep index stderr clean by default --- packages/core/src/cli/index/command.ts | 2 +- packages/core/tests/cli/index/command.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/cli/index/command.ts b/packages/core/src/cli/index/command.ts index 6d10099c0..ed9471848 100644 --- a/packages/core/src/cli/index/command.ts +++ b/packages/core/src/cli/index/command.ts @@ -35,7 +35,7 @@ export async function runIndexCommand( options: IndexCommandOptions = {}, ): Promise { const resolvedWorkspaceRoot = resolveCodeGraphyWorkspacePath(workspacePath, dependencies.cwd()); - dependencies.writeStatus(`Indexing ${resolvedWorkspaceRoot}...`); + if (options.verbose) dependencies.writeStatus(`Indexing ${resolvedWorkspaceRoot}...`); const diagnostics: DiagnosticEventSink | undefined = options.verbose ? { emit(event: DiagnosticEvent): void { diff --git a/packages/core/tests/cli/index/command.test.ts b/packages/core/tests/cli/index/command.test.ts index 55332b13c..7e0f262f2 100644 --- a/packages/core/tests/cli/index/command.test.ts +++ b/packages/core/tests/cli/index/command.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { runIndexCommand } from '../../../src/cli/index/command'; describe('index/command', () => { - it('indexes the current workspace by default and prints wait feedback', async () => { + it('keeps non-verbose stderr clean while indexing the current workspace', async () => { let finishIndex: (() => void) | undefined; const writeStatus = vi.fn(); const indexing = runIndexCommand(undefined, { @@ -25,9 +25,7 @@ describe('index/command', () => { await Promise.resolve(); - expect(writeStatus).toHaveBeenCalledWith( - 'Indexing /workspace/project...', - ); + expect(writeStatus).not.toHaveBeenCalled(); finishIndex?.(); await expect(indexing).resolves.toMatchObject({ exitCode: 0 }); @@ -52,6 +50,7 @@ describe('index/command', () => { it('passes verbose diagnostics to the workspace indexing request', async () => { const diagnosticAreas: string[] = []; + const writeStatus = vi.fn(); await runIndexCommand('/workspace/other', { cwd: () => '/workspace/project', @@ -69,9 +68,10 @@ describe('index/command', () => { }; }, writeDiagnostic: line => diagnosticAreas.push(line), - writeStatus: vi.fn(), + writeStatus, }, { verbose: true }); + expect(writeStatus).toHaveBeenCalledWith('Indexing /workspace/other...'); expect(diagnosticAreas).toEqual([ '[CodeGraphy] Indexing complete: 2 files, operation=index-1', ]); From 2d9c6aebf077aff0a4ed6907bce23c58537855e4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 20:39:45 -0700 Subject: [PATCH 03/55] perf(cli): compact scope mutation results --- packages/core/src/cli/scope/command.ts | 38 +++++++++++++------ packages/core/tests/cli/graphControls.test.ts | 21 ++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/core/src/cli/scope/command.ts b/packages/core/src/cli/scope/command.ts index c5b7974d8..1850abe00 100644 --- a/packages/core/src/cli/scope/command.ts +++ b/packages/core/src/cli/scope/command.ts @@ -69,9 +69,15 @@ function createNodeVisibilityUpdates(type: string, enabled: boolean): Record; + nodeTypes: ReadonlySet; +} + function createScopeOutput( settings: ReturnType, workspaceRoot: string, + selection?: ScopeOutputSelection, ): string { const status = readCodeGraphyWorkspaceStatus(workspaceRoot); const snapshot = status.hasGraphCache @@ -114,16 +120,21 @@ function createScopeOutput( )) ); return JSON.stringify({ - nodes: nodeTypes.map(type => ({ - type, - enabled: nodeVisibility[type] ?? definitions.get(type)?.defaultVisible ?? false, - available: availableNodeTypes.has(type), - })), - edges: edgeTypes.map(type => ({ - type, - enabled: edgeVisibility[type] ?? true, - available: observedEdgeTypes.has(type), - })), + complete: selection === undefined, + nodes: nodeTypes + .filter(type => selection === undefined || selection.nodeTypes.has(type)) + .map(type => ({ + type, + enabled: nodeVisibility[type] ?? definitions.get(type)?.defaultVisible ?? false, + available: availableNodeTypes.has(type), + })), + edges: edgeTypes + .filter(type => selection === undefined || selection.edgeTypes.has(type)) + .map(type => ({ + type, + enabled: edgeVisibility[type] ?? true, + available: observedEdgeTypes.has(type), + })), indexRequired, ...(indexRequired ? { action: 'Run `codegraphy index` to hydrate the required symbol analysis.' } @@ -140,6 +151,7 @@ export function runScopeCommand( const kind = command.arguments?.kind; const type = command.arguments?.type; const enabled = command.arguments?.enabled; + let selection: ScopeOutputSelection | undefined; if ((kind === 'node' || kind === 'edge') && typeof type === 'string' && typeof enabled === 'boolean') { const updates = kind === 'node' @@ -151,7 +163,11 @@ export function runScopeCommand( updates, ); settings = readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot); + selection = { + nodeTypes: new Set(kind === 'node' ? Object.keys(updates) : []), + edgeTypes: new Set(kind === 'edge' ? Object.keys(updates) : []), + }; } - return { exitCode: 0, output: createScopeOutput(settings, workspaceRoot) }; + return { exitCode: 0, output: createScopeOutput(settings, workspaceRoot, selection) }; } diff --git a/packages/core/tests/cli/graphControls.test.ts b/packages/core/tests/cli/graphControls.test.ts index 9adf0c711..1551f3fba 100644 --- a/packages/core/tests/cli/graphControls.test.ts +++ b/packages/core/tests/cli/graphControls.test.ts @@ -31,6 +31,26 @@ describe('cli graph controls', () => { '-C', workspace, 'scope', 'edge', 'call', 'off', ], { stdout, stderr })).resolves.toBe(0); + const nodeResult = JSON.parse(stdout.mock.calls[0][0]); + expect(nodeResult.data).toMatchObject({ + complete: false, + nodes: expect.arrayContaining([ + { type: 'symbol', enabled: true, available: false }, + { type: 'symbol:callable', enabled: true, available: false }, + { type: 'symbol:function', enabled: true, available: false }, + { type: 'symbol:method', enabled: true, available: false }, + ]), + edges: [], + }); + expect(nodeResult.data.nodes).toHaveLength(4); + + const edgeResult = JSON.parse(stdout.mock.calls[1][0]); + expect(edgeResult.data).toMatchObject({ + complete: false, + nodes: [], + edges: [{ type: 'call', enabled: false, available: false }], + }); + expect(JSON.parse(await fs.readFile(path.join(workspace, '.codegraphy/settings.json'), 'utf-8'))).toMatchObject({ extensionPanelPlacement: 'right', nodeVisibility: { @@ -64,6 +84,7 @@ describe('cli graph controls', () => { expect(settings.filterPatterns).toEqual(['**/generated/**']); expect(JSON.parse(outputs.at(-1) ?? '')).toMatchObject({ data: { + complete: true, nodes: expect.arrayContaining([ { type: 'file', enabled: true, available: true }, { type: 'symbol:function', enabled: false, available: false }, From 5963374e38277af5d6c3b71253d5bd400fad655b Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 20:40:50 -0700 Subject: [PATCH 04/55] fix(cli): expose query group help --- packages/core/src/cli/help/command.ts | 12 ++++++++++++ packages/core/src/cli/parse.ts | 1 + packages/core/tests/cli/help/command.test.ts | 1 + packages/core/tests/cli/parse.test.ts | 8 ++++++++ 4 files changed, 22 insertions(+) diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 703a55a1c..3682f657b 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -86,6 +86,18 @@ const COMMAND_HELP: Record = { 'Failure envelope: Includes the completed checks in error.details.', 'Example: codegraphy doctor', ].join('\n'), + query: [ + 'Query commands are top-level:', + '', + ' codegraphy nodes', + ' codegraphy search ', + ' codegraphy edges', + ' codegraphy dependencies ', + ' codegraphy dependents ', + ' codegraphy path ', + '', + 'Run `codegraphy --help` for arguments and options.', + ].join('\n'), nodes: [ 'Usage: codegraphy nodes [--limit ] [--offset ]', '', diff --git a/packages/core/src/cli/parse.ts b/packages/core/src/cli/parse.ts index afcd6956f..8cf28e21c 100644 --- a/packages/core/src/cli/parse.ts +++ b/packages/core/src/cli/parse.ts @@ -71,6 +71,7 @@ function isKnownCommandName(name: string): boolean { || name === 'plugins' || name === 'scope' || name === 'status' + || name === 'query' || isGraphQueryReport(name); } diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 767afb590..06d03dc26 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -38,6 +38,7 @@ describe('cli/help/command', () => { it('reports local pagination options for bounded list queries', () => { expect(createHelpResult(['status']).output).toContain('Usage: codegraphy status'); + expect(createHelpResult(['query']).output).toContain('Query commands are top-level'); expect(createHelpResult(['nodes']).output).toContain('Usage: codegraphy nodes'); expect(createHelpResult(['search']).output).toContain('Usage: codegraphy search'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); diff --git a/packages/core/tests/cli/parse.test.ts b/packages/core/tests/cli/parse.test.ts index 45f68cd93..35da7a233 100644 --- a/packages/core/tests/cli/parse.test.ts +++ b/packages/core/tests/cli/parse.test.ts @@ -70,6 +70,14 @@ describe('cli/parse', () => { name: 'help', helpPath: ['plugins', 'enable'], }); + expect(parseCliCommand(['query', '--help'])).toEqual({ + name: 'help', + helpPath: ['query'], + }); + expect(parseCliCommand(['help', 'query'])).toEqual({ + name: 'help', + helpPath: ['query'], + }); }); it('rejects per-command workspaces, query flags, retired commands, and malformed globals', () => { From 5b8e9ede8138fb7c94ff7a36f1be4b068232b586 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 20:41:46 -0700 Subject: [PATCH 05/55] fix(cli): honor plugin option terminator --- packages/core/src/cli/parsePlugins.ts | 11 ++++++++--- packages/core/tests/cli/parsePlugins.test.ts | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/core/src/cli/parsePlugins.ts b/packages/core/src/cli/parsePlugins.ts index fe7da3162..a15de0da6 100644 --- a/packages/core/src/cli/parsePlugins.ts +++ b/packages/core/src/cli/parsePlugins.ts @@ -8,9 +8,14 @@ export function isPluginCommand(value: string | undefined): boolean { export function parsePluginsCommand(argv: string[]): CliCommand { const [action, ...rawOperands] = argv; - const separatedOperands = rawOperands[0] === '--' ? rawOperands.slice(1) : rawOperands; - const globalFlags = separatedOperands.filter(operand => operand === '--global' || operand === '-g'); - const operands = separatedOperands.filter(operand => operand !== '--global' && operand !== '-g'); + const optionsEnded = rawOperands[0] === '--'; + const separatedOperands = optionsEnded ? rawOperands.slice(1) : rawOperands; + const globalFlags = optionsEnded + ? [] + : separatedOperands.filter(operand => operand === '--global' || operand === '-g'); + const operands = optionsEnded + ? separatedOperands + : separatedOperands.filter(operand => operand !== '--global' && operand !== '-g'); const [packageName, extra] = operands; if (!action || action === 'help') { diff --git a/packages/core/tests/cli/parsePlugins.test.ts b/packages/core/tests/cli/parsePlugins.test.ts index eaf361cb4..45402b6c3 100644 --- a/packages/core/tests/cli/parsePlugins.test.ts +++ b/packages/core/tests/cli/parsePlugins.test.ts @@ -37,6 +37,11 @@ describe('cli/parsePlugins', () => { action: 'inherit', packageName: 'codegraphy.particles', }); + expect(parsePluginsCommand(['enable', '--', '--global'])).toEqual({ + name: 'plugins', + action: 'enable', + packageName: '--global', + }); }); it('routes an empty group to plugin help', () => { From 6a343f1f7fafbbd06236f54e40ae4e4964248f24 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 20:43:43 -0700 Subject: [PATCH 06/55] docs(skill): teach bounded agent navigation --- skills/codegraphy/SKILL.md | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 5a9003553..c815f136f 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -1,16 +1,37 @@ --- name: codegraphy -description: Use the CodeGraphy CLI to index, shape, and query a workspace graph. +description: Use the CodeGraphy CLI to index, shape, and query a workspace Relationship Graph before reading source. --- # CodeGraphy -CodeGraphy turns a workspace into a queryable graph. +Use CodeGraphy to narrow codebase exploration. The graph guides which source files to read; it does not replace source inspection. -## Workflow +## Fast workflow -1. Run `codegraphy index`. Configure graph contributors with `codegraphy plugins` before indexing when needed. -2. Shape returned results with `codegraphy filter` and `codegraphy scope`. These do not reindex or remove stored graph facts. -3. Query with `nodes`, `search`, `edges`, `dependencies`, `dependents`, or `path`. +1. Run `codegraphy status`. Run `codegraphy index` when the cache is missing/stale **or when the task may follow source changes that status cannot detect**. Indexing is explicit and successful non-verbose output is one JSON line. +2. Choose the narrowest query: + - `search ` resolves a concept or partial path to Nodes. + - `dependencies ` finds outgoing Relationships: what it uses. + - `dependents ` finds incoming Relationships: what may be affected. + - `path ` explains whether and how two Nodes connect. + - `nodes` and `edges` inventory the shaped graph. +3. Read the returned source files and verify behavior there. -`index` stores the complete graph in `.codegraphy/graph.sqlite`. Query commands accept one-off `--filter`, `--node-type`, and `--edge-type` options without changing settings. Use `status` for cache freshness and `doctor` for runtime, settings, cache, and plugin diagnostics. Run `codegraphy --help` or `codegraphy --help` for the exact contract and examples. Commands use the current directory by default, accept `-C ` for another workspace, and return a JSON envelope unless they are help or version commands. +Use `-C ` from outside the workspace. File selectors are workspace-relative paths or exact Node IDs. Quote multiword search text and globs. + +## Keep work bounded + +`nodes`, `search`, `edges`, `dependencies`, and `dependents` default to 100 results. Their `data.page.nextOffset` is `null` when complete; otherwise rerun with `--offset ` and, when useful, a smaller `--limit`. + +Prefer one-off repeatable `--filter`, `--node-type`, and `--edge-type` options for a task. They do not change settings. Use `codegraphy scope` to discover available type IDs. Use `codegraphy filter` and `codegraphy scope ...` only for durable workspace changes. + +## Failures and recovery + +Data commands write a single `{ok:true,command,data}` JSON envelope to stdout. Failures write `{ok:false,command,error}` to stderr and exit nonzero: 1 for an operational failure, 2 for invalid invocation. Use `--verbose` only when diagnostics/progress are useful; it writes additional lines to stderr. + +- `graph_cache_not_found`: run `codegraphy index`. +- Missing/stale/unhealthy uncertainty: run `codegraphy doctor` and follow `error.details.checks` actions. +- Empty result: use `search` or `nodes` to confirm the selector, then retry with the exact path/Node ID. + +Run `codegraphy --help` or `codegraphy --help` for exact options and examples. From a5f85fae14f3d58ccbd786f28408f91a3b666c41 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 20:50:15 -0700 Subject: [PATCH 07/55] feat(cli): batch graph queries against one snapshot --- packages/core/src/cli/batch/command.ts | 166 ++++++++++++++++++ packages/core/src/cli/command.ts | 4 + packages/core/src/cli/help/command.ts | 13 ++ packages/core/src/cli/parse.ts | 4 +- packages/core/src/cli/parseTypes.ts | 1 + packages/core/src/cli/parseWorkspace.ts | 2 +- packages/core/src/cli/query/command.ts | 2 +- packages/core/src/index.ts | 8 +- packages/core/src/workspace/queryGraph.ts | 49 ++++-- packages/core/src/workspace/requestQuery.ts | 120 ++++++++++--- packages/core/src/workspace/requestTypes.ts | 13 ++ packages/core/tests/cli/batch/command.test.ts | 118 +++++++++++++ packages/core/tests/cli/help/command.test.ts | 2 + packages/core/tests/cli/parse.test.ts | 5 + .../tests/workspace/requestQueryBatch.test.ts | 45 +++++ 15 files changed, 509 insertions(+), 43 deletions(-) create mode 100644 packages/core/src/cli/batch/command.ts create mode 100644 packages/core/tests/cli/batch/command.test.ts create mode 100644 packages/core/tests/workspace/requestQueryBatch.test.ts diff --git a/packages/core/src/cli/batch/command.ts b/packages/core/src/cli/batch/command.ts new file mode 100644 index 000000000..00d79d7a9 --- /dev/null +++ b/packages/core/src/cli/batch/command.ts @@ -0,0 +1,166 @@ +import { resolveCodeGraphyWorkspacePath } from '../../workspace/requestPaths'; +import { requestWorkspaceGraphQueryBatch } from '../../workspace/requestQuery'; +import type { + WorkspaceGraphQueryBatchInput, + WorkspaceGraphQueryBatchResult, +} from '../../workspace/requestTypes'; +import type { CommandExecutionResult } from '../command'; +import type { CliCommand } from '../parseTypes'; +import { parseQueryCommand } from '../parseQuery'; +import { normalizeQueryArguments } from '../query/command'; + +interface BatchInputItem { + argv: string[]; + id: string; +} + +interface BatchCommandDependencies { + cwd(): string; + queryBatch(input: WorkspaceGraphQueryBatchInput): Promise; + readInput(): Promise; +} + +const MAX_BATCH_INPUT_BYTES = 1024 * 1024; +const MAX_BATCH_QUERIES = 100; + +async function readStandardInput(): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of process.stdin as AsyncIterable) { + const buffer = Buffer.from(chunk); + bytes += buffer.length; + if (bytes > MAX_BATCH_INPUT_BYTES) { + throw new Error(`Batch input exceeds ${MAX_BATCH_INPUT_BYTES} bytes`); + } + chunks.push(buffer); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +const DEFAULT_DEPENDENCIES: BatchCommandDependencies = { + cwd: () => process.cwd(), + queryBatch: requestWorkspaceGraphQueryBatch, + readInput: readStandardInput, +}; + +function invalid(message: string): CommandExecutionResult { + return { + exitCode: 2, + output: JSON.stringify({ error: 'invalid_arguments', message }), + }; +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return Array.isArray(value) + && value.length > 0 + && value.every(token => typeof token === 'string'); +} + +function parseBatchInput(input: string): BatchInputItem[] | CommandExecutionResult { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return invalid('Batch input must be valid JSON'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return invalid('Batch input must be an object with a queries array'); + } + const record = parsed as Record; + if (Object.keys(record).some(key => key !== 'queries') || !Array.isArray(record.queries)) { + return invalid('Batch input must contain only a queries array'); + } + if (record.queries.length < 1 || record.queries.length > MAX_BATCH_QUERIES) { + return invalid(`Batch queries must contain 1 through ${MAX_BATCH_QUERIES} items`); + } + + const items: BatchInputItem[] = []; + const ids = new Set(); + for (const value of record.queries) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return invalid('Each Batch query must be an object with id and argv'); + } + const item = value as Record; + if (Object.keys(item).some(key => key !== 'id' && key !== 'argv')) { + return invalid('Each Batch query must contain only id and argv'); + } + if (typeof item.id !== 'string' || item.id.length < 1 || [...item.id].length > 128) { + return invalid('Each Batch query id must contain 1 through 128 characters'); + } + if (ids.has(item.id)) return invalid(`Batch query ids must be unique: ${item.id}`); + if (!isNonEmptyStringArray(item.argv)) { + return invalid(`Batch query ${item.id} argv must be a non-empty string array`); + } + ids.add(item.id); + items.push({ id: item.id, argv: item.argv }); + } + return items; +} + +function compactResult(result: Record): Record { + const compact = { ...result }; + delete compact.cacheStatus; + delete compact.workspaceRoot; + return compact; +} + +function commandName(command: CliCommand): string { + return command.invokedCommand ?? command.report ?? command.name; +} + +export async function runBatchCommand( + command: CliCommand, + dependencies: BatchCommandDependencies = DEFAULT_DEPENDENCIES, +): Promise { + let input: string; + try { + input = await dependencies.readInput(); + } catch (error) { + return invalid(error instanceof Error ? error.message : String(error)); + } + if (Buffer.byteLength(input, 'utf-8') > MAX_BATCH_INPUT_BYTES) { + return invalid(`Batch input exceeds ${MAX_BATCH_INPUT_BYTES} bytes`); + } + const items = parseBatchInput(input); + if (!Array.isArray(items)) return items; + + const workspaceRoot = resolveCodeGraphyWorkspacePath(command.workspacePath, dependencies.cwd()); + const commands: CliCommand[] = []; + const queries: WorkspaceGraphQueryBatchInput['queries'] = []; + for (const item of items) { + const parsed = parseQueryCommand(item.argv); + if (parsed.parseError || parsed.name !== 'query' || !parsed.report) { + return invalid(`Batch query ${item.id}: ${parsed.parseError ?? 'Only Graph Query commands are supported'}`); + } + commands.push(parsed); + try { + queries.push({ + report: parsed.report, + arguments: normalizeQueryArguments(parsed.arguments ?? {}, workspaceRoot), + ...(parsed.projection ? { projection: parsed.projection } : {}), + }); + } catch (error) { + return invalid(`Batch query ${item.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + const batch = await dependencies.queryBatch({ + workspacePath: workspaceRoot, + queries, + }); + const failed = batch.results.find(result => result.error); + if (failed) { + return { exitCode: 1, output: JSON.stringify(failed) }; + } + + return { + exitCode: 0, + output: JSON.stringify({ + results: batch.results.map((result, index) => ({ + id: items[index].id, + command: commandName(commands[index]), + data: compactResult(result), + })), + }), + }; +} diff --git a/packages/core/src/cli/command.ts b/packages/core/src/cli/command.ts index 39ecd6d80..c3d2698fa 100644 --- a/packages/core/src/cli/command.ts +++ b/packages/core/src/cli/command.ts @@ -1,3 +1,4 @@ +import { runBatchCommand } from './batch/command'; import { runIndexCommand } from './index/command'; import { runDoctorCommand } from './doctor/command'; import { runFilterCommand } from './filter/command'; @@ -74,6 +75,9 @@ export async function runCliCommand( let result: CommandExecutionResult; switch (command.name) { + case 'batch': + result = await runBatchCommand(command); + break; case 'doctor': result = runDoctorCommand(command); break; diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 3682f657b..9b8ed9b06 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -31,6 +31,7 @@ const ROOT_HELP = [ ' codegraphy dependencies List outgoing Relationships', ' codegraphy dependents List incoming Relationships', ' codegraphy path Find a bounded path between Nodes', + ' codegraphy batch < queries.json Run several query commands against one snapshot', ' All query commands accept one-off --filter, --node-type, and --edge-type options.', '', 'Global options:', @@ -56,6 +57,18 @@ const QUERY_PROJECTION_OPTIONS = [ ]; const COMMAND_HELP: Record = { + batch: [ + 'Usage: codegraphy batch < queries.json', + '', + 'Run several existing Graph Query commands against one Graph Cache snapshot.', + 'Standard input must be JSON: {"queries":[{"id":"files","argv":["nodes","--limit","10"]}]}', + 'Each argv starts with nodes, search, edges, dependencies, dependents, or path.', + 'Query ids must be unique. A batch accepts 1 through 100 queries and at most 1 MiB of input.', + '', + 'Effects: Read-only. Does not perform Indexing or change settings.', + 'Output: JSON results in input order, correlated by id.', + 'Example: printf \'%s\' \'{"queries":[{"id":"uses","argv":["dependencies","src/app.ts"]}]}\' | codegraphy batch', + ].join('\n'), index: [ 'Usage: codegraphy index', '', diff --git a/packages/core/src/cli/parse.ts b/packages/core/src/cli/parse.ts index 8cf28e21c..c24017c27 100644 --- a/packages/core/src/cli/parse.ts +++ b/packages/core/src/cli/parse.ts @@ -65,7 +65,8 @@ function nestedHelpPath(name: string, rest: string[]): string[] | undefined { } function isKnownCommandName(name: string): boolean { - return name === 'doctor' + return name === 'batch' + || name === 'doctor' || name === 'filter' || name === 'index' || name === 'plugins' @@ -111,6 +112,7 @@ export function parseCliCommand(argv: string[]): CliCommand { let command: CliCommand; switch (name) { + case 'batch': case 'doctor': case 'index': case 'status': diff --git a/packages/core/src/cli/parseTypes.ts b/packages/core/src/cli/parseTypes.ts index b3423c719..d0621741e 100644 --- a/packages/core/src/cli/parseTypes.ts +++ b/packages/core/src/cli/parseTypes.ts @@ -4,6 +4,7 @@ import type { } from '../workspace/requestTypes'; export type CliCommandName = + | 'batch' | 'doctor' | 'filter' | 'help' diff --git a/packages/core/src/cli/parseWorkspace.ts b/packages/core/src/cli/parseWorkspace.ts index 2a0d7588b..5cda61312 100644 --- a/packages/core/src/cli/parseWorkspace.ts +++ b/packages/core/src/cli/parseWorkspace.ts @@ -1,7 +1,7 @@ import type { CliCommand } from './parseTypes'; export function parseWorkspaceCommand( - name: 'doctor' | 'filter' | 'index' | 'scope' | 'status', + name: 'batch' | 'doctor' | 'filter' | 'index' | 'scope' | 'status', argv: string[], ): CliCommand { const [extra] = argv; diff --git a/packages/core/src/cli/query/command.ts b/packages/core/src/cli/query/command.ts index a71f71bb9..e54eba860 100644 --- a/packages/core/src/cli/query/command.ts +++ b/packages/core/src/cli/query/command.ts @@ -62,7 +62,7 @@ function normalizeWorkspaceSelector(selector: string, workspaceRoot: string): st return normalized; } -function normalizeQueryArguments( +export function normalizeQueryArguments( queryArguments: Record, workspaceRoot: string, ): Record { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 166f3aa0b..8f695f17b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -417,13 +417,19 @@ export { export type { GraphQueryReport as WorkspaceGraphQueryReport, IndexWorkspaceResult, + WorkspaceGraphQueryBatchInput, + WorkspaceGraphQueryBatchItem, + WorkspaceGraphQueryBatchResult, WorkspaceGraphQueryInput, WorkspaceGraphQueryResult, WorkspacePathInput, WorkspaceStatusResult, } from './workspace/requestTypes'; export { requestCodeGraphyIndexWorkspace } from './workspace/requestIndexing'; -export { requestWorkspaceGraphQuery } from './workspace/requestQuery'; +export { + requestWorkspaceGraphQuery, + requestWorkspaceGraphQueryBatch, +} from './workspace/requestQuery'; export { readCodeGraphyWorkspaceStatusForCli } from './workspace/requestStatus'; export type { CodeGraphyWorkspaceStaleReason, diff --git a/packages/core/src/workspace/queryGraph.ts b/packages/core/src/workspace/queryGraph.ts index 79e53b188..b42992839 100644 --- a/packages/core/src/workspace/queryGraph.ts +++ b/packages/core/src/workspace/queryGraph.ts @@ -23,10 +23,9 @@ function applyPathFilters(graphData: IGraphData, patterns: readonly string[]): I }; } -export function readWorkspaceQueryGraph( +export function readWorkspaceQuerySource( workspaceRoot: string, installedPluginCache: CodeGraphyInstalledPluginCache, - projection: WorkspaceGraphQueryProjection = {}, ) { const settings = readCodeGraphyWorkspaceSettings(workspaceRoot); const snapshot = readWorkspaceAnalysisDatabaseSnapshot(workspaceRoot); @@ -40,20 +39,36 @@ export function readWorkspaceQueryGraph( nodes: snapshot.files.flatMap(file => file.analysis.nodeTypes ?? []), edges: snapshot.files.flatMap(file => file.analysis.edgeTypes ?? []), }; - const disabledFilterPatterns = new Set(settings.disabledCustomFilterPatterns); + + return { + declarations, + graphData: snapshot.graph, + settings, + snapshotFacts: normalizeWorkspaceQueryFacts( + filterInactivePluginSnapshotFacts(snapshot, activePluginIds), + workspaceRoot, + ), + }; +} + +export function projectWorkspaceQueryGraph( + source: ReturnType, + projection: WorkspaceGraphQueryProjection = {}, +) { + const disabledFilterPatterns = new Set(source.settings.disabledCustomFilterPatterns); const graphData = applyPathFilters( - snapshot.graph, + source.graphData, [ - ...settings.filterPatterns.filter(pattern => !disabledFilterPatterns.has(pattern)), + ...source.settings.filterPatterns.filter(pattern => !disabledFilterPatterns.has(pattern)), ...(projection.filterPatterns ?? []), ], ); - const savedScope = resolveSavedGraphScope(settings, graphData, declarations); + const savedScope = resolveSavedGraphScope(source.settings, graphData, source.declarations); const scope = { nodes: projection.nodeTypes ? Object.fromEntries([ ...Object.keys(savedScope.nodes).map(type => [type, false] as const), - ...resolveProjectedGraphNodeTypes(projection.nodeTypes, declarations.nodes) + ...resolveProjectedGraphNodeTypes(projection.nodeTypes, source.declarations.nodes) .map(type => [type, true] as const), ]) : savedScope.nodes, @@ -67,12 +82,20 @@ export function readWorkspaceQueryGraph( return { graphData, - nodeTypes: declarations.nodes, + nodeTypes: source.declarations.nodes, scope, - settings, - snapshotFacts: normalizeWorkspaceQueryFacts( - filterInactivePluginSnapshotFacts(snapshot, activePluginIds), - workspaceRoot, - ), + settings: source.settings, + snapshotFacts: source.snapshotFacts, }; } + +export function readWorkspaceQueryGraph( + workspaceRoot: string, + installedPluginCache: CodeGraphyInstalledPluginCache, + projection: WorkspaceGraphQueryProjection = {}, +) { + return projectWorkspaceQueryGraph( + readWorkspaceQuerySource(workspaceRoot, installedPluginCache), + projection, + ); +} diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index 3a0367d41..b0414c47d 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -7,14 +7,23 @@ import { type GraphQueryRequest, } from '../graphQuery'; import { emitGraphQueryCacheMissing, emitGraphQueryCompleted, emitGraphQueryStarted } from './queryDiagnostics'; -import { readWorkspaceQueryGraph } from './queryGraph'; +import { projectWorkspaceQueryGraph, readWorkspaceQuerySource } from './queryGraph'; import { resolveCodeGraphyWorkspacePath } from './requestPaths'; -import type { WorkspaceGraphQueryInput, WorkspaceGraphQueryResult } from './requestTypes'; +import type { + WorkspaceGraphQueryBatchInput, + WorkspaceGraphQueryBatchResult, + WorkspaceGraphQueryInput, + WorkspaceGraphQueryResult, +} from './requestTypes'; import { readCodeGraphyWorkspaceStatus } from './status'; export interface WorkspaceGraphQueryDependencies { cwd(): string; readInstalledPluginCache(): CodeGraphyInstalledPluginCache; + readQuerySource?( + workspaceRoot: string, + installedPluginCache: CodeGraphyInstalledPluginCache, + ): ReturnType; } const DEFAULT_DEPENDENCIES: WorkspaceGraphQueryDependencies = { @@ -29,33 +38,25 @@ function createGraphQueryOperationId(): string { return `query-${graphQueryOperationCounter}`; } -export async function requestWorkspaceGraphQuery( - input: WorkspaceGraphQueryInput, - dependencies: WorkspaceGraphQueryDependencies = DEFAULT_DEPENDENCIES, -): Promise { +function createCacheMissingResult(workspaceRoot: string): WorkspaceGraphQueryResult { + return { + error: 'graph_cache_not_found', + message: 'This CodeGraphy Workspace has not been indexed. Run `codegraphy index`, then retry.', + workspaceRoot, + }; +} + +function executeWorkspaceGraphQuery( + input: Omit, + workspaceRoot: string, + status: ReturnType, + source: ReturnType, +): WorkspaceGraphQueryResult { const startedAt = performance.now(); - const workspaceRoot = resolveCodeGraphyWorkspacePath(input.workspacePath, dependencies.cwd()); const operationId = createGraphQueryOperationId(); emitGraphQueryStarted({ diagnostics: input.diagnostics, operationId, report: input.report, workspaceRoot }); - const status = readCodeGraphyWorkspaceStatus(workspaceRoot); - if (!status.hasGraphCache) { - emitGraphQueryCacheMissing({ - diagnostics: input.diagnostics, - operationId, - report: input.report, - status, - workspaceRoot, - }); - return { - error: 'graph_cache_not_found', - message: 'This CodeGraphy Workspace has not been indexed. Run `codegraphy index`, then retry.', - workspaceRoot, - }; - } - - const { graphData, nodeTypes, scope, snapshotFacts } = readWorkspaceQueryGraph( - workspaceRoot, - dependencies.readInstalledPluginCache(), + const { graphData, nodeTypes, scope, snapshotFacts } = projectWorkspaceQueryGraph( + source, input.projection, ); const queryResult = executeGraphQuery({ @@ -97,3 +98,70 @@ export async function requestWorkspaceGraphQuery( }, }; } + +export async function requestWorkspaceGraphQuery( + input: WorkspaceGraphQueryInput, + dependencies: WorkspaceGraphQueryDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const workspaceRoot = resolveCodeGraphyWorkspacePath(input.workspacePath, dependencies.cwd()); + const status = readCodeGraphyWorkspaceStatus(workspaceRoot); + if (!status.hasGraphCache) { + const operationId = createGraphQueryOperationId(); + emitGraphQueryStarted({ diagnostics: input.diagnostics, operationId, report: input.report, workspaceRoot }); + emitGraphQueryCacheMissing({ + diagnostics: input.diagnostics, + operationId, + report: input.report, + status, + workspaceRoot, + }); + return createCacheMissingResult(workspaceRoot); + } + + return executeWorkspaceGraphQuery( + input, + workspaceRoot, + status, + (dependencies.readQuerySource ?? readWorkspaceQuerySource)( + workspaceRoot, + dependencies.readInstalledPluginCache(), + ), + ); +} + +export async function requestWorkspaceGraphQueryBatch( + input: WorkspaceGraphQueryBatchInput, + dependencies: WorkspaceGraphQueryDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const workspaceRoot = resolveCodeGraphyWorkspacePath(input.workspacePath, dependencies.cwd()); + const status = readCodeGraphyWorkspaceStatus(workspaceRoot); + if (!status.hasGraphCache) { + return { + results: input.queries.map(query => { + const operationId = createGraphQueryOperationId(); + emitGraphQueryStarted({ diagnostics: input.diagnostics, operationId, report: query.report, workspaceRoot }); + emitGraphQueryCacheMissing({ + diagnostics: input.diagnostics, + operationId, + report: query.report, + status, + workspaceRoot, + }); + return createCacheMissingResult(workspaceRoot); + }), + }; + } + + const source = (dependencies.readQuerySource ?? readWorkspaceQuerySource)( + workspaceRoot, + dependencies.readInstalledPluginCache(), + ); + return { + results: input.queries.map(query => executeWorkspaceGraphQuery( + { ...query, diagnostics: input.diagnostics }, + workspaceRoot, + status, + source, + )), + }; +} diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index e767e7bcc..fb75436f4 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -42,6 +42,19 @@ export interface WorkspaceGraphQueryInput extends WorkspacePathInput { projection?: WorkspaceGraphQueryProjection; } +export type WorkspaceGraphQueryBatchItem = Omit< + WorkspaceGraphQueryInput, + 'diagnostics' | 'workspacePath' +>; + +export interface WorkspaceGraphQueryBatchInput extends WorkspacePathInput { + queries: WorkspaceGraphQueryBatchItem[]; +} + +export interface WorkspaceGraphQueryBatchResult { + results: WorkspaceGraphQueryResult[]; +} + export interface WorkspaceGraphQueryProjection { filterPatterns?: string[]; nodeTypes?: string[]; diff --git a/packages/core/tests/cli/batch/command.test.ts b/packages/core/tests/cli/batch/command.test.ts new file mode 100644 index 000000000..269b0b215 --- /dev/null +++ b/packages/core/tests/cli/batch/command.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; +import { runBatchCommand } from '../../../src/cli/batch/command'; + +describe('cli/batch/command', () => { + it('runs existing query argv against one workspace request', async () => { + const queryBatch = vi.fn(async () => ({ + results: [ + { nodes: [], page: { offset: 0, limit: 5, returned: 0, total: 0, nextOffset: null } }, + { + edges: [{ from: 'src/app.ts', to: 'src/model.ts', edgeTypes: ['import'] }], + page: { offset: 0, limit: 100, returned: 1, total: 1, nextOffset: null }, + }, + ], + })); + + const result = await runBatchCommand({ name: 'batch', workspacePath: '/workspace' }, { + cwd: () => '/caller', + readInput: async () => JSON.stringify({ + queries: [ + { id: 'files', argv: ['nodes', '--limit', '5'] }, + { id: 'outgoing', argv: ['dependencies', '/workspace/src/app.ts'] }, + ], + }), + queryBatch, + }); + + expect(queryBatch).toHaveBeenCalledWith({ + workspacePath: '/workspace', + queries: [ + { report: 'nodes', arguments: { limit: 5 } }, + { + report: 'edges', + arguments: { + from: 'src/app.ts', + expandFileSelectors: true, + projectFileEndpoints: true, + limit: 100, + }, + }, + ], + }); + expect(JSON.parse(result.output)).toEqual({ + results: [ + { + id: 'files', + command: 'nodes', + data: { nodes: [], page: { offset: 0, limit: 5, returned: 0, total: 0, nextOffset: null } }, + }, + { + id: 'outgoing', + command: 'dependencies', + data: { + edges: [{ from: 'src/app.ts', to: 'src/model.ts', edgeTypes: ['import'] }], + page: { offset: 0, limit: 100, returned: 1, total: 1, nextOffset: null }, + }, + }, + ], + }); + }); + + it('rejects malformed input before querying', async () => { + const queryBatch = vi.fn(); + + await expect(runBatchCommand({ name: 'batch' }, { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ + queries: [{ id: 'broken', argv: ['path', 'src/app.ts'] }], + }), + queryBatch, + })).resolves.toEqual({ + exitCode: 2, + output: JSON.stringify({ + error: 'invalid_arguments', + message: 'Batch query broken: path requires ', + }), + }); + expect(queryBatch).not.toHaveBeenCalled(); + }); + + it('reports unsafe selectors as invalid Batch queries', async () => { + const queryBatch = vi.fn(); + + await expect(runBatchCommand({ name: 'batch' }, { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ + queries: [{ id: 'outside', argv: ['dependencies', '../outside.ts'] }], + }), + queryBatch, + })).resolves.toEqual({ + exitCode: 2, + output: JSON.stringify({ + error: 'invalid_arguments', + message: 'Batch query outside: Query path is outside the workspace: ../outside.ts', + }), + }); + expect(queryBatch).not.toHaveBeenCalled(); + }); + + it('requires unique query ids and a bounded query list', async () => { + const queryBatch = vi.fn(); + const dependencies = { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ + queries: [ + { id: 'same', argv: ['nodes'] }, + { id: 'same', argv: ['edges'] }, + ], + }), + queryBatch, + }; + + await expect(runBatchCommand({ name: 'batch' }, dependencies)).resolves.toMatchObject({ + exitCode: 2, + output: expect.stringContaining('Batch query ids must be unique: same'), + }); + expect(queryBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 06d03dc26..aaa01af08 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -7,6 +7,7 @@ describe('cli/help/command', () => { expect(result.exitCode).toBe(0); expect(result.output).toContain('codegraphy doctor'); + expect(result.output).toContain('codegraphy batch'); expect(result.output).toContain('codegraphy search '); expect(result.output).toContain('codegraphy dependencies '); expect(result.output).toContain('codegraphy path '); @@ -38,6 +39,7 @@ describe('cli/help/command', () => { it('reports local pagination options for bounded list queries', () => { expect(createHelpResult(['status']).output).toContain('Usage: codegraphy status'); + expect(createHelpResult(['batch']).output).toContain('Usage: codegraphy batch'); expect(createHelpResult(['query']).output).toContain('Query commands are top-level'); expect(createHelpResult(['nodes']).output).toContain('Usage: codegraphy nodes'); expect(createHelpResult(['search']).output).toContain('Usage: codegraphy search'); diff --git a/packages/core/tests/cli/parse.test.ts b/packages/core/tests/cli/parse.test.ts index 35da7a233..da008c455 100644 --- a/packages/core/tests/cli/parse.test.ts +++ b/packages/core/tests/cli/parse.test.ts @@ -10,6 +10,7 @@ describe('cli/parse', () => { expect(parseCliCommand(['--version'])).toEqual({ name: 'version' }); expect(parseCliCommand(['-V'])).toEqual({ name: 'version' }); expect(parseCliCommand(['--', 'index'])).toEqual({ name: 'index' }); + expect(parseCliCommand(['batch'])).toEqual({ name: 'batch' }); }); it('uses cwd by default and one global workspace override for every workspace command', () => { @@ -70,6 +71,10 @@ describe('cli/parse', () => { name: 'help', helpPath: ['plugins', 'enable'], }); + expect(parseCliCommand(['batch', '--help'])).toEqual({ + name: 'help', + helpPath: ['batch'], + }); expect(parseCliCommand(['query', '--help'])).toEqual({ name: 'help', helpPath: ['query'], diff --git a/packages/core/tests/workspace/requestQueryBatch.test.ts b/packages/core/tests/workspace/requestQueryBatch.test.ts new file mode 100644 index 000000000..ffe3f3e12 --- /dev/null +++ b/packages/core/tests/workspace/requestQueryBatch.test.ts @@ -0,0 +1,45 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { requestCodeGraphyIndexWorkspace } from '../../src/workspace/requestIndexing'; +import { readWorkspaceQuerySource } from '../../src/workspace/queryGraph'; +import { requestWorkspaceGraphQueryBatch } from '../../src/workspace/requestQuery'; + +describe('workspace/requestQuery batch', () => { + it('executes several projections from one Graph Cache snapshot', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-batch-')); + await fs.writeFile(path.join(workspaceRoot, 'entry.ts'), "import './model';\n"); + await fs.writeFile(path.join(workspaceRoot, 'model.ts'), 'export const model = 1;\n'); + await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); + const readQuerySource = vi.fn(readWorkspaceQuerySource); + + const result = await requestWorkspaceGraphQueryBatch({ + workspacePath: workspaceRoot, + queries: [ + { report: 'nodes', arguments: { limit: 1 } }, + { + report: 'edges', + arguments: { + from: 'entry.ts', + expandFileSelectors: true, + projectFileEndpoints: true, + limit: 100, + }, + }, + ], + }, { + cwd: () => workspaceRoot, + readInstalledPluginCache: () => ({ version: 3, plugins: [] }), + readQuerySource, + }); + + expect(readQuerySource).toHaveBeenCalledTimes(1); + expect(result.results[0]).toMatchObject({ + nodes: [{ path: 'entry.ts', nodeType: 'file' }], + }); + expect(result.results[1]).toMatchObject({ + edges: [{ from: 'entry.ts', to: 'model.ts', edgeTypes: ['import'] }], + }); + }); +}); From bb228f1115c76804330c58e7ab4f9ca18a983faa Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 20:51:26 -0700 Subject: [PATCH 08/55] docs(cli): record agent query experiments --- .changeset/tall-owls-check.md | 5 +++ CONTEXT.md | 2 +- README.md | 3 +- ...-batch-queries-share-one-graph-snapshot.md | 39 +++++++++++++++++++ packages/core/README.md | 3 +- skills/codegraphy/SKILL.md | 8 ++++ 6 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 .changeset/tall-owls-check.md create mode 100644 docs/adr/0006-batch-queries-share-one-graph-snapshot.md diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md new file mode 100644 index 000000000..287ea6704 --- /dev/null +++ b/.changeset/tall-owls-check.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/core": minor +--- + +Add faster batched graph queries, structured plugin state, compact Graph Scope mutation results, clean non-verbose Indexing output, and clearer agent guidance. diff --git a/CONTEXT.md b/CONTEXT.md index 8e563f725..f6632e92b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -105,7 +105,7 @@ The Graph View can use a whole-view loading state before its first graph payload | **tldraw Interface** | `@codegraphy-dev/tldraw` owns its launcher, tldraw document lifecycle, native shapes, controls, and adapters over Core and renderer physics. | | **Graph Renderer** | `@codegraphy-dev/graph-renderer` owns WebGPU drawing and deterministic WebAssembly physics. It does not own product settings, persistence, or plugins. | | **CodeGraphy CLI** | The terminal interface installed by `@codegraphy-dev/core`. It targets the current directory unless `-C, --workspace ` selects another workspace. | -| **Graph Query CLI** | `nodes`, `search`, `edges`, `dependencies`, `dependents`, and `path`, all with bounded JSON output. | +| **Graph Query CLI** | `nodes`, `search`, `edges`, `dependencies`, `dependents`, and `path`, all with bounded JSON output. `batch` runs several of these commands against one Graph Cache snapshot. | | **CodeGraphy Agent Skill** | Instructions that teach shell-capable agents when to index, which Graph Query command to choose, and when to inspect source. | | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | diff --git a/README.md b/README.md index ec70167c8..3c6bd71f3 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | Command | Result | |---|---| -| `codegraphy status` | Reports fresh, stale, missing, or unusable Graph Cache state. | +| `codegraphy status` | Reports fresh, stale, or missing Graph Cache state. | | `codegraphy doctor` | Checks runtime, settings, Graph Cache schema, integrity, foreign keys, counts, and plugin state. | | `codegraphy index` | Makes the selected workspace Graph Cache current. | | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | @@ -132,6 +132,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy dependencies ` | Lists outgoing Relationships for a file or exact Symbol Node. | | `codegraphy dependents ` | Lists incoming Relationships for a file or exact Symbol Node. | | `codegraphy path ` | Finds bounded directed paths. | +| `codegraphy batch < queries.json` | Runs several Graph Query commands against one Graph Cache snapshot. | | `codegraphy scope` | Reads or changes saved Node Type and Edge Type scope. | | `codegraphy filter` | Reads or changes persisted filter patterns. | | `codegraphy plugins` | Registers, links, lists, enables, or disables plugins. | diff --git a/docs/adr/0006-batch-queries-share-one-graph-snapshot.md b/docs/adr/0006-batch-queries-share-one-graph-snapshot.md new file mode 100644 index 000000000..2d7e45a01 --- /dev/null +++ b/docs/adr/0006-batch-queries-share-one-graph-snapshot.md @@ -0,0 +1,39 @@ +# Batch queries share one Graph Cache snapshot + +**Status:** Accepted + +## Context + +Coding agents frequently need several independent Graph Query results at once, such as outgoing and incoming Relationships for a known File Node. Running each existing CLI command separately repeats Node process startup, workspace status checks, settings reads, and Graph Cache hydration. + +A two-query benchmark on the TypeScript example workspace measured separate `dependencies` and `dependents` processes at 293.5 ms median and 299.9 ms p95. A batch against one prepared graph snapshot measured 150.9 ms median and 155.9 ms p95. Compact output grew from 683 bytes to 741 bytes, so batching improves latency and round trips rather than response size. + +## Decision + +Add `codegraphy batch` as a transport over the six existing Graph Query commands. It does not add a Graph Query report or change the public Graph Query vocabulary accepted by ADR 0003. + +`batch` reads one bounded JSON document from standard input: + +```json +{ + "queries": [ + { "id": "uses", "argv": ["dependencies", "src/app.ts"] }, + { "id": "used-by", "argv": ["dependents", "src/app.ts"] } + ] +} +``` + +Each `argv` uses the existing parser for `nodes`, `search`, `edges`, `dependencies`, `dependents`, or `path`. Existing defaults, bounds, one-off Graph Scope and Filter projections, and selector safety therefore stay on one forward path. + +The complete input is validated before Graph Query execution. Query IDs are unique strings. A batch contains 1 through 100 queries and at most 1 MiB of UTF-8 JSON. The command resolves one CodeGraphy Workspace, reads one Graph Cache snapshot, then applies each query's projection against that snapshot. Results preserve input order and include their caller-provided IDs. + +Batch remains read-only. It never performs Indexing, mutates settings, executes non-query commands, refers to an earlier result, or searches parent directories for a workspace. Agents continue to run explicit Indexing when cached knowledge may be outdated. + +## Consequences + +- Agents can group independent, already-known Graph Queries to reduce process startup and Graph Cache reads. +- Dependent exploration still uses separate calls: a batch is not a pipeline or query language. +- Single queries keep their existing commands and result shapes. +- Batch input costs some additional bytes and its output can be slightly larger than separate envelopes. Agents should use it for latency and round-trip reduction, not assume token savings. +- Core exposes a batch workspace request that prepares query source data once and projects it independently for every item. +- A future compound `inspect` operation remains a separate semantic decision. Batch does not justify adding hidden traversal or inference behavior. diff --git a/packages/core/README.md b/packages/core/README.md index bb6c39fb5..a308d8bd4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current and persists the complete Relationship Graph independently of Graph Scope, so later scope changes only affect projection and queries. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. Query commands use positional inputs and bounded defaults; Symbol Nodes are exposed through `nodes` and `search`, and `edges` is the single Relationship Graph primitive. Every query also accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Node Type projections follow Graph Scope hierarchy and symbol meaning while each result keeps its specific stored Node Type. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current and persists the complete Relationship Graph independently of Graph Scope, so later scope changes only affect projection and queries. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. Query commands use positional inputs and bounded defaults; Symbol Nodes are exposed through `nodes` and `search`, and `edges` is the single Relationship Graph primitive. Every query also accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Node Type projections follow Graph Scope hierarchy and symbol meaning while each result keeps its specific stored Node Type. `batch` accepts several existing query argument vectors as JSON on standard input and runs them against one Graph Cache snapshot. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy index @@ -20,6 +20,7 @@ codegraphy edges codegraphy dependencies src/app.ts codegraphy dependents src/config.ts codegraphy path src/app.ts src/config.ts +printf '%s' '{"queries":[{"id":"uses","argv":["dependencies","src/app.ts"]},{"id":"used-by","argv":["dependents","src/app.ts"]}]}' | codegraphy batch codegraphy scope codegraphy scope node symbol:function on codegraphy scope edge call on diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index c815f136f..32122cbe6 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -26,6 +26,14 @@ Use `-C ` from outside the workspace. File selectors are workspace-re Prefer one-off repeatable `--filter`, `--node-type`, and `--edge-type` options for a task. They do not change settings. Use `codegraphy scope` to discover available type IDs. Use `codegraphy filter` and `codegraphy scope ...` only for durable workspace changes. +When several queries are independent and known in advance, send them through one snapshot with `codegraphy batch`: + +```sh +printf '%s' '{"queries":[{"id":"uses","argv":["dependencies","src/app.ts"]},{"id":"used-by","argv":["dependents","src/app.ts"]}]}' | codegraphy batch +``` + +Batching reduces latency and Graph Cache reads, but its JSON wrapper may use more tokens. Keep sequential calls when one result determines the next query. + ## Failures and recovery Data commands write a single `{ok:true,command,data}` JSON envelope to stdout. Failures write `{ok:false,command,error}` to stderr and exit nonzero: 1 for an operational failure, 2 for invalid invocation. Use `--verbose` only when diagnostics/progress are useful; it writes additional lines to stderr. From 0967bc9e6d67e8759abf5711ae2fe00ca69b7511 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 21:05:09 -0700 Subject: [PATCH 09/55] fix(cli): harden batch contracts --- .changeset/tall-owls-check.md | 4 +- ...-batch-queries-share-one-graph-snapshot.md | 4 +- packages/core/src/cli/batch/command.ts | 58 ++++++- packages/core/src/cli/command.ts | 4 +- packages/core/src/cli/help/command.ts | 3 +- packages/core/src/cli/parsePlugins.ts | 20 ++- packages/core/src/cli/result/parser.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/workspace/requestQuery.ts | 14 +- packages/core/src/workspace/requestTypes.ts | 2 + packages/core/tests/cli/batch/command.test.ts | 155 +++++++++++++++++- packages/core/tests/cli/help/command.test.ts | 5 +- packages/core/tests/cli/parsePlugins.test.ts | 12 ++ packages/core/tests/cli/result/parser.test.ts | 8 +- .../core/tests/workspace/queryGraph.test.ts | 26 +++ ...ueryBatch.test.ts => requestQuery.test.ts} | 42 ++++- 16 files changed, 321 insertions(+), 39 deletions(-) create mode 100644 packages/core/tests/workspace/queryGraph.test.ts rename packages/core/tests/workspace/{requestQueryBatch.test.ts => requestQuery.test.ts} (55%) diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index 287ea6704..71852e4d3 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -1,5 +1,5 @@ --- -"@codegraphy-dev/core": minor +"@codegraphy-dev/core": major --- -Add faster batched graph queries, structured plugin state, compact Graph Scope mutation results, clean non-verbose Indexing output, and clearer agent guidance. +Add `codegraphy batch` for faster multi-query workflows, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, and keep successful non-verbose Indexing output on stdout only. diff --git a/docs/adr/0006-batch-queries-share-one-graph-snapshot.md b/docs/adr/0006-batch-queries-share-one-graph-snapshot.md index 2d7e45a01..bcce30304 100644 --- a/docs/adr/0006-batch-queries-share-one-graph-snapshot.md +++ b/docs/adr/0006-batch-queries-share-one-graph-snapshot.md @@ -25,7 +25,9 @@ Add `codegraphy batch` as a transport over the six existing Graph Query commands Each `argv` uses the existing parser for `nodes`, `search`, `edges`, `dependencies`, `dependents`, or `path`. Existing defaults, bounds, one-off Graph Scope and Filter projections, and selector safety therefore stay on one forward path. -The complete input is validated before Graph Query execution. Query IDs are unique strings. A batch contains 1 through 100 queries and at most 1 MiB of UTF-8 JSON. The command resolves one CodeGraphy Workspace, reads one Graph Cache snapshot, then applies each query's projection against that snapshot. Results preserve input order and include their caller-provided IDs. +The complete input is validated before Graph Query execution. Query IDs are unique strings. A batch contains 1 through 100 queries and at most 1 MiB of UTF-8 JSON. The public Core batch request enforces the same query-count bound; the byte bound belongs to the stdin transport. The command resolves one CodeGraphy Workspace, reads one Graph Cache snapshot, then applies each query's projection against that snapshot. + +Success results preserve input order and include their caller-provided IDs. Failure is all-or-nothing: the CLI returns a failed outer envelope, discards sibling results, and identifies the failed query ID, command, code, and message in `error.details`. Batch remains read-only. It never performs Indexing, mutates settings, executes non-query commands, refers to an earlier result, or searches parent directories for a workspace. Agents continue to run explicit Indexing when cached knowledge may be outdated. diff --git a/packages/core/src/cli/batch/command.ts b/packages/core/src/cli/batch/command.ts index 00d79d7a9..ad4594dbc 100644 --- a/packages/core/src/cli/batch/command.ts +++ b/packages/core/src/cli/batch/command.ts @@ -1,8 +1,11 @@ +import type { DiagnosticEvent, DiagnosticEventSink } from '../../diagnostics/events'; +import { formatDiagnosticEventLine } from '../../diagnostics/events'; import { resolveCodeGraphyWorkspacePath } from '../../workspace/requestPaths'; import { requestWorkspaceGraphQueryBatch } from '../../workspace/requestQuery'; -import type { - WorkspaceGraphQueryBatchInput, - WorkspaceGraphQueryBatchResult, +import { + MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE, + type WorkspaceGraphQueryBatchInput, + type WorkspaceGraphQueryBatchResult, } from '../../workspace/requestTypes'; import type { CommandExecutionResult } from '../command'; import type { CliCommand } from '../parseTypes'; @@ -20,8 +23,11 @@ interface BatchCommandDependencies { readInput(): Promise; } +interface BatchCommandOptions { + writeDiagnostic?(line: string): void; +} + const MAX_BATCH_INPUT_BYTES = 1024 * 1024; -const MAX_BATCH_QUERIES = 100; async function readStandardInput(): Promise { const chunks: Buffer[] = []; @@ -70,8 +76,8 @@ function parseBatchInput(input: string): BatchInputItem[] | CommandExecutionResu if (Object.keys(record).some(key => key !== 'queries') || !Array.isArray(record.queries)) { return invalid('Batch input must contain only a queries array'); } - if (record.queries.length < 1 || record.queries.length > MAX_BATCH_QUERIES) { - return invalid(`Batch queries must contain 1 through ${MAX_BATCH_QUERIES} items`); + if (record.queries.length < 1 || record.queries.length > MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE) { + return invalid(`Batch queries must contain 1 through ${MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE} items`); } const items: BatchInputItem[] = []; @@ -111,6 +117,7 @@ function commandName(command: CliCommand): string { export async function runBatchCommand( command: CliCommand, dependencies: BatchCommandDependencies = DEFAULT_DEPENDENCIES, + options: BatchCommandOptions = {}, ): Promise { let input: string; try { @@ -144,13 +151,46 @@ export async function runBatchCommand( } } + const diagnostics: DiagnosticEventSink | undefined = command.verbose + ? { + emit(event: DiagnosticEvent): void { + const line = formatDiagnosticEventLine(event); + if (options.writeDiagnostic) { + options.writeDiagnostic(line); + return; + } + process.stderr.write(`${line}\n`); + }, + } + : undefined; const batch = await dependencies.queryBatch({ workspacePath: workspaceRoot, queries, + ...(diagnostics ? { diagnostics } : {}), }); - const failed = batch.results.find(result => result.error); - if (failed) { - return { exitCode: 1, output: JSON.stringify(failed) }; + const failedIndex = batch.results.findIndex(result => result.error); + if (failedIndex >= 0) { + const failed = batch.results[failedIndex]; + const item = items[failedIndex]; + const failedCommand = commandName(commands[failedIndex]); + return { + exitCode: 1, + output: JSON.stringify({ + error: { + code: 'batch_query_failed', + message: `Batch query ${item.id} failed.`, + action: 'Fix the failed query and retry the Batch.', + details: { + id: item.id, + command: failedCommand, + error: { + code: typeof failed.error === 'string' ? failed.error : 'command_failed', + message: typeof failed.message === 'string' ? failed.message : 'Command failed.', + }, + }, + }, + }), + }; } return { diff --git a/packages/core/src/cli/command.ts b/packages/core/src/cli/command.ts index c3d2698fa..d59f89c46 100644 --- a/packages/core/src/cli/command.ts +++ b/packages/core/src/cli/command.ts @@ -76,7 +76,9 @@ export async function runCliCommand( switch (command.name) { case 'batch': - result = await runBatchCommand(command); + result = await runBatchCommand(command, undefined, { + ...(dependencies.writeDiagnostic ? { writeDiagnostic: line => dependencies.writeDiagnostic?.(line) } : {}), + }); break; case 'doctor': result = runDoctorCommand(command); diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 9b8ed9b06..54e89de58 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -66,7 +66,8 @@ const COMMAND_HELP: Record = { 'Query ids must be unique. A batch accepts 1 through 100 queries and at most 1 MiB of input.', '', 'Effects: Read-only. Does not perform Indexing or change settings.', - 'Output: JSON results in input order, correlated by id.', + 'Output: data.results contains {id, command, data} items in input order.', + 'Failure: Batch is all-or-nothing; error.details identifies the failed query.', 'Example: printf \'%s\' \'{"queries":[{"id":"uses","argv":["dependencies","src/app.ts"]}]}\' | codegraphy batch', ].join('\n'), index: [ diff --git a/packages/core/src/cli/parsePlugins.ts b/packages/core/src/cli/parsePlugins.ts index a15de0da6..e3fa407e0 100644 --- a/packages/core/src/cli/parsePlugins.ts +++ b/packages/core/src/cli/parsePlugins.ts @@ -8,14 +8,18 @@ export function isPluginCommand(value: string | undefined): boolean { export function parsePluginsCommand(argv: string[]): CliCommand { const [action, ...rawOperands] = argv; - const optionsEnded = rawOperands[0] === '--'; - const separatedOperands = optionsEnded ? rawOperands.slice(1) : rawOperands; - const globalFlags = optionsEnded - ? [] - : separatedOperands.filter(operand => operand === '--global' || operand === '-g'); - const operands = optionsEnded - ? separatedOperands - : separatedOperands.filter(operand => operand !== '--global' && operand !== '-g'); + const globalFlags: string[] = []; + const operands: string[] = []; + let optionsEnded = false; + for (const operand of rawOperands) { + if (!optionsEnded && operand === '--') { + optionsEnded = true; + } else if (!optionsEnded && (operand === '--global' || operand === '-g')) { + globalFlags.push(operand); + } else { + operands.push(operand); + } + } const [packageName, extra] = operands; if (!action || action === 'help') { diff --git a/packages/core/src/cli/result/parser.ts b/packages/core/src/cli/result/parser.ts index 461ce776b..3258ea38b 100644 --- a/packages/core/src/cli/result/parser.ts +++ b/packages/core/src/cli/result/parser.ts @@ -38,9 +38,11 @@ export function readCliError(output: unknown): CliError { ? nested.message : typeof parsed.message === 'string' ? parsed.message : 'Command failed.'; const action = typeof nested?.action === 'string' ? nested.action : parsed.action; + const details = nested?.details ?? parsed.details; return { code, message, ...(typeof action === 'string' ? { action } : {}), + ...(details !== undefined ? { details } : {}), }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8f695f17b..1254a27df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -414,6 +414,7 @@ export { createCodeGraphyWorkspacePackageAwarePluginSignature, createCodeGraphyWorkspaceSettingsSignature, } from './workspace/signatures'; +export { MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE } from './workspace/requestTypes'; export type { GraphQueryReport as WorkspaceGraphQueryReport, IndexWorkspaceResult, diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index b0414c47d..15e398d51 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -9,11 +9,12 @@ import { import { emitGraphQueryCacheMissing, emitGraphQueryCompleted, emitGraphQueryStarted } from './queryDiagnostics'; import { projectWorkspaceQueryGraph, readWorkspaceQuerySource } from './queryGraph'; import { resolveCodeGraphyWorkspacePath } from './requestPaths'; -import type { - WorkspaceGraphQueryBatchInput, - WorkspaceGraphQueryBatchResult, - WorkspaceGraphQueryInput, - WorkspaceGraphQueryResult, +import { + MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE, + type WorkspaceGraphQueryBatchInput, + type WorkspaceGraphQueryBatchResult, + type WorkspaceGraphQueryInput, + type WorkspaceGraphQueryResult, } from './requestTypes'; import { readCodeGraphyWorkspaceStatus } from './status'; @@ -133,6 +134,9 @@ export async function requestWorkspaceGraphQueryBatch( input: WorkspaceGraphQueryBatchInput, dependencies: WorkspaceGraphQueryDependencies = DEFAULT_DEPENDENCIES, ): Promise { + if (input.queries.length < 1 || input.queries.length > MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE) { + throw new Error(`Batch queries must contain 1 through ${MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE} items`); + } const workspaceRoot = resolveCodeGraphyWorkspacePath(input.workspacePath, dependencies.cwd()); const status = readCodeGraphyWorkspaceStatus(workspaceRoot); if (!status.hasGraphCache) { diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index fb75436f4..648b66d1b 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -42,6 +42,8 @@ export interface WorkspaceGraphQueryInput extends WorkspacePathInput { projection?: WorkspaceGraphQueryProjection; } +export const MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE = 100; + export type WorkspaceGraphQueryBatchItem = Omit< WorkspaceGraphQueryInput, 'diagnostics' | 'workspacePath' diff --git a/packages/core/tests/cli/batch/command.test.ts b/packages/core/tests/cli/batch/command.test.ts index 269b0b215..d217afa60 100644 --- a/packages/core/tests/cli/batch/command.test.ts +++ b/packages/core/tests/cli/batch/command.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { runBatchCommand } from '../../../src/cli/batch/command'; +import { runCli } from '../../../src/cli/run'; describe('cli/batch/command', () => { it('runs existing query argv against one workspace request', async () => { @@ -58,6 +59,68 @@ describe('cli/batch/command', () => { }); }); + it('routes the final success and failure envelopes through the CLI runtime', async () => { + const successStdout = vi.fn(); + const successStderr = vi.fn(); + const successDependencies = { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ queries: [{ id: 'files', argv: ['nodes'] }] }), + queryBatch: async () => ({ results: [{ nodes: [], page: {} }] }), + }; + + await expect(runCli(['batch'], { + runCommand: command => runBatchCommand(command, successDependencies), + stdout: successStdout, + stderr: successStderr, + })).resolves.toBe(0); + expect(JSON.parse(successStdout.mock.calls[0][0])).toMatchObject({ + ok: true, + command: 'batch', + data: { results: [{ id: 'files', command: 'nodes' }] }, + }); + expect(successStderr).not.toHaveBeenCalled(); + + const failureStdout = vi.fn(); + const failureStderr = vi.fn(); + await expect(runCli(['batch'], { + runCommand: command => runBatchCommand(command, { + ...successDependencies, + readInput: async () => '{', + }), + stdout: failureStdout, + stderr: failureStderr, + })).resolves.toBe(2); + expect(failureStdout).not.toHaveBeenCalled(); + expect(JSON.parse(failureStderr.mock.calls[0][0])).toMatchObject({ + ok: false, + command: 'batch', + error: { code: 'invalid_arguments' }, + }); + }); + + it('forwards verbose Graph Query diagnostics to stderr', async () => { + const diagnostics: string[] = []; + + await runBatchCommand({ name: 'batch', verbose: true }, { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ queries: [{ id: 'files', argv: ['nodes'] }] }), + queryBatch: async (input) => { + input.diagnostics?.emit({ + area: 'graph-query', + event: 'started', + context: { operationId: 'query-1', report: 'nodes', workspaceRoot: '/workspace' }, + }); + return { results: [{ nodes: [], page: {} }] }; + }, + }, { + writeDiagnostic: line => diagnostics.push(line), + }); + + expect(diagnostics).toEqual([ + '[CodeGraphy] Starting Graph Query: report=nodes, operation=query-1, workspace=/workspace', + ]); + }); + it('rejects malformed input before querying', async () => { const queryBatch = vi.fn(); @@ -77,6 +140,36 @@ describe('cli/batch/command', () => { expect(queryBatch).not.toHaveBeenCalled(); }); + it('correlates operational failures with the query id', async () => { + const result = await runBatchCommand({ name: 'batch' }, { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ + queries: [ + { id: 'files', argv: ['nodes'] }, + { id: 'uses', argv: ['dependencies', 'src/app.ts'] }, + ], + }), + queryBatch: async () => ({ + results: [ + { nodes: [], page: {} }, + { error: 'graph_cache_not_found', message: 'Index first.' }, + ], + }), + }); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.output)).toMatchObject({ + error: { + code: 'batch_query_failed', + details: { + id: 'uses', + command: 'dependencies', + error: { code: 'graph_cache_not_found', message: 'Index first.' }, + }, + }, + }); + }); + it('reports unsafe selectors as invalid Batch queries', async () => { const queryBatch = vi.fn(); @@ -96,7 +189,7 @@ describe('cli/batch/command', () => { expect(queryBatch).not.toHaveBeenCalled(); }); - it('requires unique query ids and a bounded query list', async () => { + it('requires unique query ids', async () => { const queryBatch = vi.fn(); const dependencies = { cwd: () => '/workspace', @@ -115,4 +208,64 @@ describe('cli/batch/command', () => { }); expect(queryBatch).not.toHaveBeenCalled(); }); + + it('accepts 1 through 100 queries', async () => { + const maxQueries = Array.from( + { length: 100 }, + (_, index) => ({ id: String(index), argv: ['nodes'] }), + ); + const queryBatch = vi.fn(async (input) => ({ + results: input.queries.map(() => ({ nodes: [], page: {} })), + })); + await expect(runBatchCommand({ name: 'batch' }, { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ queries: maxQueries }), + queryBatch, + })).resolves.toMatchObject({ exitCode: 0 }); + + for (const queries of [ + [], + Array.from({ length: 101 }, (_, index) => ({ id: String(index), argv: ['nodes'] })), + ]) { + await expect(runBatchCommand({ name: 'batch' }, { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ queries }), + queryBatch, + })).resolves.toMatchObject({ + exitCode: 2, + output: expect.stringContaining('Batch queries must contain 1 through 100 items'), + }); + } + expect(queryBatch).toHaveBeenCalledTimes(1); + }); + + it('accepts ids through 128 characters and rejects longer ids', async () => { + const queryBatch = vi.fn(async () => ({ results: [{ nodes: [], page: {} }] })); + const run = (id: string) => runBatchCommand({ name: 'batch' }, { + cwd: () => '/workspace', + readInput: async () => JSON.stringify({ queries: [{ id, argv: ['nodes'] }] }), + queryBatch, + }); + + await expect(run('a'.repeat(128))).resolves.toMatchObject({ exitCode: 0 }); + await expect(run('a'.repeat(129))).resolves.toMatchObject({ + exitCode: 2, + output: expect.stringContaining('Batch query id must contain 1 through 128 characters'), + }); + expect(queryBatch).toHaveBeenCalledTimes(1); + }); + + it('rejects input over 1 MiB before parsing', async () => { + const queryBatch = vi.fn(); + + await expect(runBatchCommand({ name: 'batch' }, { + cwd: () => '/workspace', + readInput: async () => 'x'.repeat(1024 * 1024 + 1), + queryBatch, + })).resolves.toMatchObject({ + exitCode: 2, + output: expect.stringContaining('Batch input exceeds 1048576 bytes'), + }); + expect(queryBatch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index aaa01af08..30431066a 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -39,7 +39,10 @@ describe('cli/help/command', () => { it('reports local pagination options for bounded list queries', () => { expect(createHelpResult(['status']).output).toContain('Usage: codegraphy status'); - expect(createHelpResult(['batch']).output).toContain('Usage: codegraphy batch'); + const batchHelp = createHelpResult(['batch']).output; + expect(batchHelp).toContain('Usage: codegraphy batch'); + expect(batchHelp).toContain('data.results contains {id, command, data}'); + expect(batchHelp).toContain('error.details identifies the failed query'); expect(createHelpResult(['query']).output).toContain('Query commands are top-level'); expect(createHelpResult(['nodes']).output).toContain('Usage: codegraphy nodes'); expect(createHelpResult(['search']).output).toContain('Usage: codegraphy search'); diff --git a/packages/core/tests/cli/parsePlugins.test.ts b/packages/core/tests/cli/parsePlugins.test.ts index 45402b6c3..799816932 100644 --- a/packages/core/tests/cli/parsePlugins.test.ts +++ b/packages/core/tests/cli/parsePlugins.test.ts @@ -42,6 +42,18 @@ describe('cli/parsePlugins', () => { action: 'enable', packageName: '--global', }); + expect(parsePluginsCommand(['enable', '--global', '--', 'codegraphy.vue'])).toEqual({ + name: 'plugins', + action: 'enable', + packageName: 'codegraphy.vue', + pluginScope: 'global', + }); + expect(parsePluginsCommand(['enable', '--global', '--', '--global'])).toEqual({ + name: 'plugins', + action: 'enable', + packageName: '--global', + pluginScope: 'global', + }); }); it('routes an empty group to plugin help', () => { diff --git a/packages/core/tests/cli/result/parser.test.ts b/packages/core/tests/cli/result/parser.test.ts index 608784ddc..aa9652d36 100644 --- a/packages/core/tests/cli/result/parser.test.ts +++ b/packages/core/tests/cli/result/parser.test.ts @@ -21,11 +21,17 @@ describe('cli/result/parser', () => { action: 'Retry.', }); expect(readCliError({ - error: { code: 'bad', message: 'Nested failure.', action: 'Repair settings.' }, + error: { + code: 'bad', + message: 'Nested failure.', + action: 'Repair settings.', + details: { id: 'query-1' }, + }, })).toEqual({ code: 'bad', message: 'Nested failure.', action: 'Repair settings.', + details: { id: 'query-1' }, }); }); }); diff --git a/packages/core/tests/workspace/queryGraph.test.ts b/packages/core/tests/workspace/queryGraph.test.ts new file mode 100644 index 000000000..e5c0b227a --- /dev/null +++ b/packages/core/tests/workspace/queryGraph.test.ts @@ -0,0 +1,26 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { requestCodeGraphyIndexWorkspace } from '../../src/workspace/requestIndexing'; +import { + projectWorkspaceQueryGraph, + readWorkspaceQuerySource, +} from '../../src/workspace/queryGraph'; + +describe('workspace/queryGraph', () => { + it('projects independent Filters without mutating the shared query source', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-source-')); + await fs.writeFile(path.join(workspaceRoot, 'entry.txt'), 'entry\n'); + await fs.writeFile(path.join(workspaceRoot, 'model.txt'), 'model\n'); + await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); + const source = readWorkspaceQuerySource(workspaceRoot, { version: 3, plugins: [] }); + + const filtered = projectWorkspaceQueryGraph(source, { filterPatterns: ['model.txt'] }); + const complete = projectWorkspaceQueryGraph(source); + + expect(filtered.graphData.nodes.map(node => node.id)).toEqual(['entry.txt']); + expect(complete.graphData.nodes.map(node => node.id)).toEqual(['entry.txt', 'model.txt']); + expect(source.graphData.nodes.map(node => node.id)).toEqual(['entry.txt', 'model.txt']); + }); +}); diff --git a/packages/core/tests/workspace/requestQueryBatch.test.ts b/packages/core/tests/workspace/requestQuery.test.ts similarity index 55% rename from packages/core/tests/workspace/requestQueryBatch.test.ts rename to packages/core/tests/workspace/requestQuery.test.ts index ffe3f3e12..a16a6bec7 100644 --- a/packages/core/tests/workspace/requestQueryBatch.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -7,25 +7,36 @@ import { readWorkspaceQuerySource } from '../../src/workspace/queryGraph'; import { requestWorkspaceGraphQueryBatch } from '../../src/workspace/requestQuery'; describe('workspace/requestQuery batch', () => { - it('executes several projections from one Graph Cache snapshot', async () => { + it('executes independent projections from one Graph Cache snapshot', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-batch-')); await fs.writeFile(path.join(workspaceRoot, 'entry.ts'), "import './model';\n"); await fs.writeFile(path.join(workspaceRoot, 'model.ts'), 'export const model = 1;\n'); await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); const readQuerySource = vi.fn(readWorkspaceQuerySource); + const edgeArguments = { + from: 'entry.ts', + expandFileSelectors: true, + projectFileEndpoints: true, + limit: 100, + }; const result = await requestWorkspaceGraphQueryBatch({ workspacePath: workspaceRoot, queries: [ - { report: 'nodes', arguments: { limit: 1 } }, + { + report: 'nodes', + arguments: { limit: 100 }, + projection: { filterPatterns: ['model.ts'] }, + }, { report: 'edges', - arguments: { - from: 'entry.ts', - expandFileSelectors: true, - projectFileEndpoints: true, - limit: 100, - }, + arguments: edgeArguments, + projection: { edgeTypes: ['call'] }, + }, + { + report: 'edges', + arguments: edgeArguments, + projection: { edgeTypes: ['import'] }, }, ], }, { @@ -38,8 +49,21 @@ describe('workspace/requestQuery batch', () => { expect(result.results[0]).toMatchObject({ nodes: [{ path: 'entry.ts', nodeType: 'file' }], }); - expect(result.results[1]).toMatchObject({ + expect(result.results[1]).toMatchObject({ edges: [] }); + expect(result.results[2]).toMatchObject({ edges: [{ from: 'entry.ts', to: 'model.ts', edgeTypes: ['import'] }], }); }); + + it('enforces the public Batch query-count bound', async () => { + await expect(requestWorkspaceGraphQueryBatch({ queries: [] })).rejects.toThrow( + 'Batch queries must contain 1 through 100 items', + ); + await expect(requestWorkspaceGraphQueryBatch({ + queries: Array.from({ length: 101 }, () => ({ + report: 'nodes' as const, + arguments: {}, + })), + })).rejects.toThrow('Batch queries must contain 1 through 100 items'); + }); }); From b70394e4d174bae3909aafca311493b4d8607e5a Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 23 Jul 2026 21:19:45 -0700 Subject: [PATCH 10/55] fix(skill): preserve release lifecycle guidance --- skills/codegraphy/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 32122cbe6..5124ee841 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -26,6 +26,8 @@ Use `-C ` from outside the workspace. File selectors are workspace-re Prefer one-off repeatable `--filter`, `--node-type`, and `--edge-type` options for a task. They do not change settings. Use `codegraphy scope` to discover available type IDs. Use `codegraphy filter` and `codegraphy scope ...` only for durable workspace changes. +Query with the narrowest command that answers the current question instead of dumping the complete graph. + When several queries are independent and known in advance, send them through one snapshot with `codegraphy batch`: ```sh From 77d707cb3e6179a427a987cf9e98f503e5ccb737 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 10:14:21 -0700 Subject: [PATCH 11/55] feat(cli): deepen search and target queries --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 15 +- README.md | 7 +- .../0003-cli-and-agent-skill-replace-mcp.md | 4 +- ...-batch-queries-share-one-graph-snapshot.md | 2 +- ...7-search-and-target-query-replace-batch.md | 38 +++ packages/core/README.md | 9 +- packages/core/src/cli/batch/command.ts | 206 ------------- packages/core/src/cli/command.ts | 6 - packages/core/src/cli/help/command.ts | 63 ++-- packages/core/src/cli/parse.ts | 4 +- packages/core/src/cli/parseQuery.ts | 26 +- packages/core/src/cli/parseTypes.ts | 1 - packages/core/src/cli/parseWorkspace.ts | 2 +- packages/core/src/cli/query/command.ts | 2 +- packages/core/src/graphQuery/data.ts | 9 + packages/core/src/graphQuery/execute.ts | 8 + packages/core/src/graphQuery/index.ts | 7 + packages/core/src/graphQuery/model.ts | 65 ++++- packages/core/src/graphQuery/overview.ts | 88 ++++++ packages/core/src/graphQuery/search.ts | 156 ++++++++++ packages/core/src/index.ts | 16 +- packages/core/src/workspace/queryGraph.ts | 56 ++++ packages/core/src/workspace/requestQuery.ts | 60 +--- packages/core/src/workspace/requestTypes.ts | 19 +- packages/core/tests/cli/batch/command.test.ts | 271 ------------------ packages/core/tests/cli/help/command.test.ts | 22 +- packages/core/tests/cli/parse.test.ts | 14 +- packages/core/tests/cli/parseQuery.test.ts | 29 +- packages/core/tests/cli/query/command.test.ts | 24 ++ .../core/tests/graphQuery/overview.test.ts | 91 ++++++ packages/core/tests/graphQuery/search.test.ts | 122 ++++++++ .../core/tests/workspace/requestQuery.test.ts | 103 ++++--- skills/codegraphy/SKILL.md | 55 ++-- skills/codegraphy/agents/openai.yaml | 4 +- 35 files changed, 881 insertions(+), 725 deletions(-) create mode 100644 docs/adr/0007-search-and-target-query-replace-batch.md delete mode 100644 packages/core/src/cli/batch/command.ts create mode 100644 packages/core/src/graphQuery/overview.ts create mode 100644 packages/core/src/graphQuery/search.ts delete mode 100644 packages/core/tests/cli/batch/command.test.ts create mode 100644 packages/core/tests/graphQuery/overview.test.ts create mode 100644 packages/core/tests/graphQuery/search.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index 71852e4d3..04e363352 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -2,4 +2,4 @@ "@codegraphy-dev/core": major --- -Add `codegraphy batch` for faster multi-query workflows, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, and keep successful non-verbose Indexing output on stdout only. +Make `codegraphy search` discover live source and cached AST Symbols, add `codegraphy query` for one-call File or Symbol overviews, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, and keep successful non-verbose Indexing output on stdout only. diff --git a/CONTEXT.md b/CONTEXT.md index f6632e92b..b7467fa0a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -34,7 +34,7 @@ A **Graph Scope Capability Declaration** tells CodeGraphy which Node Types and E CodeGraphy narrows graph data in one order: ```text -Relationship Graph -> Scoped Graph -> Filtered Graph -> Searched Graph -> Visible Graph +Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Searched Graph -> Visible Graph ``` | Stage | Meaning | @@ -43,13 +43,15 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Searched Graph -> Visibl | **Scoped Graph** | The Relationship Graph after Graph Scope removes disabled types. | | **Filter** | Persisted include and exclude rules for recurring workspace noise. | | **Filtered Graph** | The Scoped Graph after Filter rules. | -| **Search** | A temporary text query that narrows the current graph without changing Filter settings. | -| **Searched Graph** | The Filtered Graph after Search. | +| **Graph View Search** | A temporary text query that narrows the current graph without changing Filter settings. | +| **Searched Graph** | The Filtered Graph after Graph View Search. | +| **CLI Search** | A bounded discovery query over live source lines, cached AST Symbols, and indexed Nodes. It reports source and cache provenance and does not change settings. | +| **Target Query** | A bounded overview of one exact File or Symbol Node, including declarations and incoming/outgoing Relationships. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | | **Orphan Node** | A Node with no remaining Edges after graph narrowing. | | **Show Orphans** | A final Graph View setting that keeps or removes Orphan Nodes. | -Graph Scope runs before Filter, Filter runs before Search, and sorting or pagination runs after those stages. Core Graph Query uses the same order. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. +Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Core Graph Query uses the same order for graph navigation. CLI Search is a discovery operation: persisted and one-off path Filters still apply, but Graph Scope does not hide cached AST Symbols or live source matches. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. ## Selection, Focus, and Collapse @@ -105,12 +107,13 @@ The Graph View can use a whole-view loading state before its first graph payload | **tldraw Interface** | `@codegraphy-dev/tldraw` owns its launcher, tldraw document lifecycle, native shapes, controls, and adapters over Core and renderer physics. | | **Graph Renderer** | `@codegraphy-dev/graph-renderer` owns WebGPU drawing and deterministic WebAssembly physics. It does not own product settings, persistence, or plugins. | | **CodeGraphy CLI** | The terminal interface installed by `@codegraphy-dev/core`. It targets the current directory unless `-C, --workspace ` selects another workspace. | -| **Graph Query CLI** | `nodes`, `search`, `edges`, `dependencies`, `dependents`, and `path`, all with bounded JSON output. `batch` runs several of these commands against one Graph Cache snapshot. | +| **CodeGraphy Exploration CLI** | `search` discovers live source, cached AST Symbols, and indexed Nodes; `query` inspects one exact File or Symbol. Both return bounded JSON with provenance. | +| **Graph Query CLI** | `nodes`, `edges`, `dependencies`, `dependents`, and `path`, all with bounded JSON output over the shaped Relationship Graph. | | **CodeGraphy Agent Skill** | Instructions that teach shell-capable agents when to index, which Graph Query command to choose, and when to inspect source. | | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | -The CLI never searches parent directories for a workspace. Indexing and Graph Query are separate operations, so a query does not perform Indexing. Agents run Indexing when their task may have changed cached knowledge, then choose the narrowest query that answers the question. +The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. Agents start with the narrowest useful discovery operation, run Indexing only after a missing-cache result or when current AST/Relationship facts are required, and inspect returned source evidence directly. ## Plugins diff --git a/README.md b/README.md index 3c6bd71f3..ac4e29baf 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ The terminal CLI supports Node.js 20 through 22. Node 22 LTS is recommended. npm install -g @codegraphy-dev/core codegraphy index codegraphy search SettingsPanel +codegraphy query packages/extension/src/webview/app/shell/view.tsx codegraphy dependencies packages/extension/src/webview/app/shell/view.tsx ``` @@ -127,12 +128,12 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy doctor` | Checks runtime, settings, Graph Cache schema, integrity, foreign keys, counts, and plugin state. | | `codegraphy index` | Makes the selected workspace Graph Cache current. | | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | -| `codegraphy search ` | Searches Nodes. | +| `codegraphy search ` | Finds live source lines, cached AST Symbols, and indexed Nodes with provenance. | +| `codegraphy query ` | Inspects one exact File or Symbol with declarations and incoming/outgoing Relationships. | | `codegraphy edges` | Lists bounded Edges. | | `codegraphy dependencies ` | Lists outgoing Relationships for a file or exact Symbol Node. | | `codegraphy dependents ` | Lists incoming Relationships for a file or exact Symbol Node. | | `codegraphy path ` | Finds bounded directed paths. | -| `codegraphy batch < queries.json` | Runs several Graph Query commands against one Graph Cache snapshot. | | `codegraphy scope` | Reads or changes saved Node Type and Edge Type scope. | | `codegraphy filter` | Reads or changes persisted filter patterns. | | `codegraphy plugins` | Registers, links, lists, enables, or disables plugins. | @@ -141,7 +142,7 @@ Run `codegraphy --help` for exact arguments. Query, settings, Indexing ### Agent Skill -The [CodeGraphy Agent Skill](./skills/codegraphy/SKILL.md) teaches shell-capable agents to keep the index current and choose a bounded Graph Query before reading source. Install it from a clone of this repo: +The [CodeGraphy Agent Skill](./skills/codegraphy/SKILL.md) teaches shell-capable agents to discover live source and cached AST Symbols, inspect exact targets, and continue through bounded Relationships before reading the smallest useful source set. Install it from a clone of this repo: ```bash npx skills@latest add ./skills/codegraphy diff --git a/docs/adr/0003-cli-and-agent-skill-replace-mcp.md b/docs/adr/0003-cli-and-agent-skill-replace-mcp.md index a00e58b9d..b5a6f4120 100644 --- a/docs/adr/0003-cli-and-agent-skill-replace-mcp.md +++ b/docs/adr/0003-cli-and-agent-skill-replace-mcp.md @@ -1,6 +1,6 @@ # CLI and Agent Skill replace MCP -**Status:** Accepted +**Status:** Accepted; exploration vocabulary amended by ADR 0007 CodeGraphy's agent interface will use the Core-owned `codegraphy` CLI and a distributable Agent Skill. Delete the `@codegraphy-dev/mcp` package and MCP-specific product surface. @@ -14,7 +14,7 @@ CodeGraphy's agent interface will use the Core-owned `codegraphy` CLI and a dist Add a small positional JSON Graph Query surface to the Core CLI. Expose the extension's persisted Graph Scope, Filter, and plugin controls through the same workspace settings. Make `codegraphy index` reuse and patch compatible persisted cache data. Publish a general `codegraphy` Agent Skill and delete MCP. -The public Graph Query vocabulary is `nodes`, `search`, `edges`, `dependencies`, `dependents`, and `path`. Symbols remain Node Types, and Relationships remain Edges. `nodes`, `search`, `edges`, `dependencies`, and `dependents` use a default limit of 100 and accept `--limit` plus `--offset` for bounded continuation. `path` uses fixed depth and path-count bounds instead of pagination. Commands target the current directory unless the global `-C, --workspace ` option selects another CodeGraphy Workspace. +The original public Graph Query vocabulary was `nodes`, `search`, `edges`, `dependencies`, `dependents`, and `path`. ADR 0007 later deepens `search`, adds executable Target Query, and leaves `nodes`, `edges`, `dependencies`, `dependents`, and `path` as graph navigation commands. Symbols remain Node Types, and Relationships remain Edges. `nodes`, `search`, `edges`, `dependencies`, and `dependents` use a default limit of 100 and accept `--limit` plus `--offset` for bounded continuation. `path` uses fixed depth and path-count bounds instead of pagination. Commands target the current directory unless the global `-C, --workspace ` option selects another CodeGraphy Workspace. Every query accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options. These are one-off projections layered over persisted workspace settings for that invocation. A child `--node-type` enables the parent Node Types needed to evaluate Graph Scope. Node Type matching follows the Graph Scope hierarchy and symbol meaning: a parent includes matching descendants, and overlapping types can match the same symbol. Each result keeps its most specific stored `nodeType`. These options do not write `.codegraphy/settings.json`; durable changes still use `codegraphy filter` and `codegraphy scope`. diff --git a/docs/adr/0006-batch-queries-share-one-graph-snapshot.md b/docs/adr/0006-batch-queries-share-one-graph-snapshot.md index bcce30304..a20a40743 100644 --- a/docs/adr/0006-batch-queries-share-one-graph-snapshot.md +++ b/docs/adr/0006-batch-queries-share-one-graph-snapshot.md @@ -1,6 +1,6 @@ # Batch queries share one Graph Cache snapshot -**Status:** Accepted +**Status:** Superseded by ADR 0007 ## Context diff --git a/docs/adr/0007-search-and-target-query-replace-batch.md b/docs/adr/0007-search-and-target-query-replace-batch.md new file mode 100644 index 000000000..e78b8d9f3 --- /dev/null +++ b/docs/adr/0007-search-and-target-query-replace-batch.md @@ -0,0 +1,38 @@ +# Search and Target Query replace Batch + +**Status:** Accepted + +## Context + +ADR 0006 accepted `batch` from a process-only benchmark: two predetermined Graph Query commands shared one Graph Cache snapshot and reduced median process latency from 293.5 ms to 150.9 ms. That measurement did not test an agent completing an adaptive coding task. + +A controlled follow-up ran 18 fresh agents across an easy source-location task and a harder settings-diagnosis task. Each of three navigation conditions ran three times against identical prepared snapshots. All answers scored 10/10. Median adaptive CodeGraphy results were worse than ordinary navigation: 49.3 seconds, 12 calls, and 48,319 tokens versus 28.2 seconds, 8 calls, and 20,433 tokens on the easy task; 188.4 seconds, 44 calls, and 429,216 tokens versus 127.3 seconds, 37 calls, and 208,120 tokens on the hard task. + +Batch reduced CodeGraphy round trips but still did not beat ordinary-navigation elapsed time. Trace review showed the larger problem: `search` only narrowed graph Node metadata, exact source phrases returned nothing, the apparent `query` help group was not an executable command, and agents manually composed many low-level traversals before reading source. + +The Graph Cache already stores AST Symbol facts with File ownership. Current source text remains authoritative and need not be duplicated in SQLite merely to support discovery. + +## Decision + +Remove the public `batch` CLI command and exported Core Batch request. Keep one adaptive forward path. + +Make `search ` a workspace discovery operation. It merges, ranks, and globally paginates: + +- live source-line matches from eligible indexed File Nodes; +- cached AST Symbol matches with their File provenance; and +- indexed non-Symbol Node matches. + +Literal matching is case-insensitive. `*` is a line-local or name-local wildcard. Symbol matches include cached declaration metadata and `filePath`; text matches include one-based line and column plus a bounded excerpt. Search reads source text live and compares it with indexed content hashes so its result states whether cached Symbol facts are fresh or stale. Persisted and one-off path Filters apply. Graph Scope does not hide discovery facts. + +Make `query ` executable. It resolves one exact workspace-relative File path or Symbol Node ID and returns a fixed-bounded overview from one prepared Graph Cache snapshot: the target, declared AST Symbols, outgoing Relationships, and incoming Relationships. It never infers a natural-language answer, performs Indexing, or accepts a pipeline. Agents use `search` to discover an exact selector, `query` for its first semantic overview, and `dependencies`, `dependents`, or `path` only for continuation. + +Root help and the CodeGraphy Agent Skill teach that sequence and explain every navigation command. They do not require a preparatory `status` call. A missing-cache result triggers explicit Indexing; source inspection remains the final evidence step. + +## Consequences + +- Exact source phrases and wildcard patterns can locate production and test evidence without an ordinary text-search fallback. +- AST Symbols are discoverable from cached facts even when Graph Scope hides Symbol Nodes from graph presentation. +- `query` replaces the confusing pseudo-group with one deep target-overview interface. +- Live text and cached graph facts can differ; the result exposes provenance and freshness instead of silently reindexing. +- Source scanning adds bounded filesystem work to `search`. It reads only eligible indexed File Nodes, skips unreadable, binary, and files larger than 1 MiB, and does not affect graph-only commands. +- The accepted success gate is agent task completion—correctness, elapsed time, tool calls, and total model tokens—not isolated process latency. diff --git a/packages/core/README.md b/packages/core/README.md index a308d8bd4..0febd4477 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current and persists the complete Relationship Graph independently of Graph Scope, so later scope changes only affect projection and queries. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. Query commands use positional inputs and bounded defaults; Symbol Nodes are exposed through `nodes` and `search`, and `edges` is the single Relationship Graph primitive. Every query also accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Node Type projections follow Graph Scope hierarchy and symbol meaning while each result keeps its specific stored Node Type. `batch` accepts several existing query argument vectors as JSON on standard input and runs them against one Graph Cache snapshot. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `search` merges live source-line matches with cached AST Symbols and indexed Nodes, including file and line provenance. `query` inspects one exact File path or Symbol Node ID and returns its declarations plus incoming and outgoing Relationships. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the shaped graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy index @@ -16,11 +16,12 @@ codegraphy status codegraphy doctor codegraphy nodes codegraphy search SettingsPanel +codegraphy search 'Indexing *workspace*' +codegraphy query src/cli/index/command.ts codegraphy edges codegraphy dependencies src/app.ts codegraphy dependents src/config.ts codegraphy path src/app.ts src/config.ts -printf '%s' '{"queries":[{"id":"uses","argv":["dependencies","src/app.ts"]},{"id":"used-by","argv":["dependents","src/app.ts"]}]}' | codegraphy batch codegraphy scope codegraphy scope node symbol:function on codegraphy scope edge call on @@ -45,7 +46,9 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - Graph Cache status: report whether a workspace-local Graph Cache exists without using VS Code APIs. - Workspace status: report fresh, stale, or missing Graph Cache state with inspectable stale reasons. - Graph Cache storage: load, save, clear, and inspect normalized File, Node, Symbol, and Edge rows in the SQLite-backed Graph Cache at `/.codegraphy/graph.sqlite`. -- Graph Query: search scoped Nodes, list scoped Edges, trace dependencies and dependents, and find bounded paths over Relationship Graph data plus persisted analysis metadata. +- Workspace Search: merge bounded live source-line matches, cached AST Symbols, and indexed Nodes with file provenance. +- Target Query: inspect one exact File or Symbol with declarations plus bounded incoming and outgoing Relationships. +- Graph Query: list scoped Nodes and Edges, trace dependencies and dependents, and find bounded paths over Relationship Graph data plus persisted analysis metadata. The core package exposes `indexCodeGraphyWorkspace` for explicit path-based Indexing. VS Code and CLI adapters call this package instead of owning independent indexing behavior. diff --git a/packages/core/src/cli/batch/command.ts b/packages/core/src/cli/batch/command.ts deleted file mode 100644 index ad4594dbc..000000000 --- a/packages/core/src/cli/batch/command.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { DiagnosticEvent, DiagnosticEventSink } from '../../diagnostics/events'; -import { formatDiagnosticEventLine } from '../../diagnostics/events'; -import { resolveCodeGraphyWorkspacePath } from '../../workspace/requestPaths'; -import { requestWorkspaceGraphQueryBatch } from '../../workspace/requestQuery'; -import { - MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE, - type WorkspaceGraphQueryBatchInput, - type WorkspaceGraphQueryBatchResult, -} from '../../workspace/requestTypes'; -import type { CommandExecutionResult } from '../command'; -import type { CliCommand } from '../parseTypes'; -import { parseQueryCommand } from '../parseQuery'; -import { normalizeQueryArguments } from '../query/command'; - -interface BatchInputItem { - argv: string[]; - id: string; -} - -interface BatchCommandDependencies { - cwd(): string; - queryBatch(input: WorkspaceGraphQueryBatchInput): Promise; - readInput(): Promise; -} - -interface BatchCommandOptions { - writeDiagnostic?(line: string): void; -} - -const MAX_BATCH_INPUT_BYTES = 1024 * 1024; - -async function readStandardInput(): Promise { - const chunks: Buffer[] = []; - let bytes = 0; - for await (const chunk of process.stdin as AsyncIterable) { - const buffer = Buffer.from(chunk); - bytes += buffer.length; - if (bytes > MAX_BATCH_INPUT_BYTES) { - throw new Error(`Batch input exceeds ${MAX_BATCH_INPUT_BYTES} bytes`); - } - chunks.push(buffer); - } - return Buffer.concat(chunks).toString('utf-8'); -} - -const DEFAULT_DEPENDENCIES: BatchCommandDependencies = { - cwd: () => process.cwd(), - queryBatch: requestWorkspaceGraphQueryBatch, - readInput: readStandardInput, -}; - -function invalid(message: string): CommandExecutionResult { - return { - exitCode: 2, - output: JSON.stringify({ error: 'invalid_arguments', message }), - }; -} - -function isNonEmptyStringArray(value: unknown): value is string[] { - return Array.isArray(value) - && value.length > 0 - && value.every(token => typeof token === 'string'); -} - -function parseBatchInput(input: string): BatchInputItem[] | CommandExecutionResult { - let parsed: unknown; - try { - parsed = JSON.parse(input); - } catch { - return invalid('Batch input must be valid JSON'); - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return invalid('Batch input must be an object with a queries array'); - } - const record = parsed as Record; - if (Object.keys(record).some(key => key !== 'queries') || !Array.isArray(record.queries)) { - return invalid('Batch input must contain only a queries array'); - } - if (record.queries.length < 1 || record.queries.length > MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE) { - return invalid(`Batch queries must contain 1 through ${MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE} items`); - } - - const items: BatchInputItem[] = []; - const ids = new Set(); - for (const value of record.queries) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return invalid('Each Batch query must be an object with id and argv'); - } - const item = value as Record; - if (Object.keys(item).some(key => key !== 'id' && key !== 'argv')) { - return invalid('Each Batch query must contain only id and argv'); - } - if (typeof item.id !== 'string' || item.id.length < 1 || [...item.id].length > 128) { - return invalid('Each Batch query id must contain 1 through 128 characters'); - } - if (ids.has(item.id)) return invalid(`Batch query ids must be unique: ${item.id}`); - if (!isNonEmptyStringArray(item.argv)) { - return invalid(`Batch query ${item.id} argv must be a non-empty string array`); - } - ids.add(item.id); - items.push({ id: item.id, argv: item.argv }); - } - return items; -} - -function compactResult(result: Record): Record { - const compact = { ...result }; - delete compact.cacheStatus; - delete compact.workspaceRoot; - return compact; -} - -function commandName(command: CliCommand): string { - return command.invokedCommand ?? command.report ?? command.name; -} - -export async function runBatchCommand( - command: CliCommand, - dependencies: BatchCommandDependencies = DEFAULT_DEPENDENCIES, - options: BatchCommandOptions = {}, -): Promise { - let input: string; - try { - input = await dependencies.readInput(); - } catch (error) { - return invalid(error instanceof Error ? error.message : String(error)); - } - if (Buffer.byteLength(input, 'utf-8') > MAX_BATCH_INPUT_BYTES) { - return invalid(`Batch input exceeds ${MAX_BATCH_INPUT_BYTES} bytes`); - } - const items = parseBatchInput(input); - if (!Array.isArray(items)) return items; - - const workspaceRoot = resolveCodeGraphyWorkspacePath(command.workspacePath, dependencies.cwd()); - const commands: CliCommand[] = []; - const queries: WorkspaceGraphQueryBatchInput['queries'] = []; - for (const item of items) { - const parsed = parseQueryCommand(item.argv); - if (parsed.parseError || parsed.name !== 'query' || !parsed.report) { - return invalid(`Batch query ${item.id}: ${parsed.parseError ?? 'Only Graph Query commands are supported'}`); - } - commands.push(parsed); - try { - queries.push({ - report: parsed.report, - arguments: normalizeQueryArguments(parsed.arguments ?? {}, workspaceRoot), - ...(parsed.projection ? { projection: parsed.projection } : {}), - }); - } catch (error) { - return invalid(`Batch query ${item.id}: ${error instanceof Error ? error.message : String(error)}`); - } - } - - const diagnostics: DiagnosticEventSink | undefined = command.verbose - ? { - emit(event: DiagnosticEvent): void { - const line = formatDiagnosticEventLine(event); - if (options.writeDiagnostic) { - options.writeDiagnostic(line); - return; - } - process.stderr.write(`${line}\n`); - }, - } - : undefined; - const batch = await dependencies.queryBatch({ - workspacePath: workspaceRoot, - queries, - ...(diagnostics ? { diagnostics } : {}), - }); - const failedIndex = batch.results.findIndex(result => result.error); - if (failedIndex >= 0) { - const failed = batch.results[failedIndex]; - const item = items[failedIndex]; - const failedCommand = commandName(commands[failedIndex]); - return { - exitCode: 1, - output: JSON.stringify({ - error: { - code: 'batch_query_failed', - message: `Batch query ${item.id} failed.`, - action: 'Fix the failed query and retry the Batch.', - details: { - id: item.id, - command: failedCommand, - error: { - code: typeof failed.error === 'string' ? failed.error : 'command_failed', - message: typeof failed.message === 'string' ? failed.message : 'Command failed.', - }, - }, - }, - }), - }; - } - - return { - exitCode: 0, - output: JSON.stringify({ - results: batch.results.map((result, index) => ({ - id: items[index].id, - command: commandName(commands[index]), - data: compactResult(result), - })), - }), - }; -} diff --git a/packages/core/src/cli/command.ts b/packages/core/src/cli/command.ts index d59f89c46..39ecd6d80 100644 --- a/packages/core/src/cli/command.ts +++ b/packages/core/src/cli/command.ts @@ -1,4 +1,3 @@ -import { runBatchCommand } from './batch/command'; import { runIndexCommand } from './index/command'; import { runDoctorCommand } from './doctor/command'; import { runFilterCommand } from './filter/command'; @@ -75,11 +74,6 @@ export async function runCliCommand( let result: CommandExecutionResult; switch (command.name) { - case 'batch': - result = await runBatchCommand(command, undefined, { - ...(dependencies.writeDiagnostic ? { writeDiagnostic: line => dependencies.writeDiagnostic?.(line) } : {}), - }); - break; case 'doctor': result = runDoctorCommand(command); break; diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 54e89de58..77f6ca9bd 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -8,8 +8,8 @@ const ROOT_HELP = [ 'Workflow:', ' 1. Configure graph contributors with Plugins when needed.', ' 2. Index the complete workspace graph into its Graph Cache.', - ' 3. Shape returned results with Filters and Graph Scope.', - ' 4. Query the resulting graph.', + ' 3. Discover source and AST Symbols with search; inspect an exact result with query.', + ' 4. Continue through the shaped graph with dependencies, dependents, or path.', '', 'Workspace commands:', ' codegraphy index Create or update the Graph Cache', @@ -24,15 +24,17 @@ const ROOT_HELP = [ ' codegraphy scope edge Change one Edge Type', ' codegraphy plugins Register Plugins and change global or workspace activity', '', - 'Query commands:', + 'Explore:', + ' codegraphy search Find live source text and cached AST Symbols', + ' codegraphy query Inspect one exact File or Symbol Node', + '', + 'Navigate the Relationship Graph:', ' codegraphy nodes List Nodes in the shaped graph', - ' codegraphy search Find Nodes by text', ' codegraphy edges List Relationships in the shaped graph', ' codegraphy dependencies List outgoing Relationships', ' codegraphy dependents List incoming Relationships', ' codegraphy path Find a bounded path between Nodes', - ' codegraphy batch < queries.json Run several query commands against one snapshot', - ' All query commands accept one-off --filter, --node-type, and --edge-type options.', + ' Graph navigation commands accept one-off --filter, --node-type, and --edge-type options.', '', 'Global options:', ' -C, --workspace Use another workspace (default: current directory)', @@ -57,19 +59,6 @@ const QUERY_PROJECTION_OPTIONS = [ ]; const COMMAND_HELP: Record = { - batch: [ - 'Usage: codegraphy batch < queries.json', - '', - 'Run several existing Graph Query commands against one Graph Cache snapshot.', - 'Standard input must be JSON: {"queries":[{"id":"files","argv":["nodes","--limit","10"]}]}', - 'Each argv starts with nodes, search, edges, dependencies, dependents, or path.', - 'Query ids must be unique. A batch accepts 1 through 100 queries and at most 1 MiB of input.', - '', - 'Effects: Read-only. Does not perform Indexing or change settings.', - 'Output: data.results contains {id, command, data} items in input order.', - 'Failure: Batch is all-or-nothing; error.details identifies the failed query.', - 'Example: printf \'%s\' \'{"queries":[{"id":"uses","argv":["dependencies","src/app.ts"]}]}\' | codegraphy batch', - ].join('\n'), index: [ 'Usage: codegraphy index', '', @@ -101,16 +90,18 @@ const COMMAND_HELP: Record = { 'Example: codegraphy doctor', ].join('\n'), query: [ - 'Query commands are top-level:', + 'Usage: codegraphy query ', '', - ' codegraphy nodes', - ' codegraphy search ', - ' codegraphy edges', - ' codegraphy dependencies ', - ' codegraphy dependents ', - ' codegraphy path ', + 'Inspect one exact File path or Symbol Node ID returned by `codegraphy search`.', + 'Returns the target, its declared AST Symbols, and incoming and outgoing Relationships.', + 'Use dependencies, dependents, or path only when this bounded overview needs continuation.', + '', + 'Arguments:', + ' Workspace-relative File path or exact Symbol Node ID', '', - 'Run `codegraphy --help` for arguments and options.', + 'Effects: Read-only. Requires an existing Graph Cache and never performs Indexing.', + 'Output: JSON target overview with fixed per-section bounds and page totals.', + 'Example: codegraphy query packages/core/src/cli/command.ts', ].join('\n'), nodes: [ 'Usage: codegraphy nodes [--limit ] [--offset ]', @@ -126,20 +117,22 @@ const COMMAND_HELP: Record = { 'Example: codegraphy nodes', ].join('\n'), search: [ - 'Usage: codegraphy search [--limit ] [--offset ] ', + 'Usage: codegraphy search [--limit ] [--offset ] ', '', - 'Find graph Nodes whose searchable fields contain text.', + 'Discover matching live source lines, cached AST Symbols, and indexed File or concept Nodes.', + 'Literal matching is case-insensitive. Add a `*` wildcard to span characters on one line or name.', + 'Every Symbol match includes the File it came from. Text matches include line, column, and excerpt.', '', 'Arguments:', - ' Text to match in the shaped graph', + ' Literal source/name/path text, optionally containing `*` wildcards', '', - 'Effects: Read-only.', - 'Output: Bounded JSON Node results and page metadata.', + 'Effects: Read-only. Does not perform Indexing; source text is read live from indexed files.', + 'Output: One ranked, bounded JSON match list plus live/cached freshness provenance.', 'Options:', - ' --limit Maximum results to return (default: 100)', + ' --limit Maximum combined matches to return (default: 20)', ' --offset Zero-based result offset (default: 0)', - ...QUERY_PROJECTION_OPTIONS, - 'Example: codegraphy search SettingsPanel', + 'Example: codegraphy search runIndexCommand', + "Example: codegraphy search 'Indexing *workspace*'", ].join('\n'), edges: [ 'Usage: codegraphy edges [--limit ] [--offset ]', diff --git a/packages/core/src/cli/parse.ts b/packages/core/src/cli/parse.ts index c24017c27..8cf28e21c 100644 --- a/packages/core/src/cli/parse.ts +++ b/packages/core/src/cli/parse.ts @@ -65,8 +65,7 @@ function nestedHelpPath(name: string, rest: string[]): string[] | undefined { } function isKnownCommandName(name: string): boolean { - return name === 'batch' - || name === 'doctor' + return name === 'doctor' || name === 'filter' || name === 'index' || name === 'plugins' @@ -112,7 +111,6 @@ export function parseCliCommand(argv: string[]): CliCommand { let command: CliCommand; switch (name) { - case 'batch': case 'doctor': case 'index': case 'status': diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index 21990bf68..1a55708ea 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -7,10 +7,12 @@ const QUERY_COMMANDS = new Set([ 'edges', 'nodes', 'path', + 'query', 'search', ]); const DEFAULT_LIMIT = 100; +const DEFAULT_SEARCH_LIMIT = 20; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; @@ -40,9 +42,10 @@ function parseArguments( command: string, argv: string[], allowPagination: boolean, + defaultLimit = DEFAULT_LIMIT, ): ParsedQueryArguments { const operands: string[] = []; - let limit = DEFAULT_LIMIT; + let limit = defaultLimit; let offset: number | undefined; let optionsEnded = false; const projection: NonNullable = {}; @@ -128,8 +131,13 @@ export function parseQueryCommand(argv: string[]): CliCommand { return parseError(command, `Unknown query command: ${command}`); } - const acceptsPagination = command !== 'path'; - const parsed = parseArguments(command, rawArgs, acceptsPagination); + const acceptsPagination = command !== 'path' && command !== 'query'; + const parsed = parseArguments( + command, + rawArgs, + acceptsPagination, + command === 'search' ? DEFAULT_SEARCH_LIMIT : DEFAULT_LIMIT, + ); if (parsed.parseError) return parseError(command, parsed.parseError); const { operands, limit, offset, projection } = parsed; const page = { limit, ...(offset !== undefined ? { offset } : {}) }; @@ -141,8 +149,16 @@ export function parseQueryCommand(argv: string[]): CliCommand { return invalid ?? query(command, command, page, projection); } case 'search': { - const invalid = requireOperands(command, operands, 1, ''); - return invalid ?? query(command, 'nodes', { search: operands[0], ...page }, projection); + const invalid = requireOperands(command, operands, 1, ''); + if (invalid) return invalid; + if (!operands[0].replace(/\*/g, '').trim()) { + return parseError(command, 'search pattern must contain a literal character'); + } + return query(command, 'search', { pattern: operands[0], ...page }, projection); + } + case 'query': { + const invalid = requireOperands(command, operands, 1, ''); + return invalid ?? query(command, 'overview', { target: operands[0] }, projection); } case 'dependencies': { const invalid = requireOperands(command, operands, 1, ''); diff --git a/packages/core/src/cli/parseTypes.ts b/packages/core/src/cli/parseTypes.ts index d0621741e..b3423c719 100644 --- a/packages/core/src/cli/parseTypes.ts +++ b/packages/core/src/cli/parseTypes.ts @@ -4,7 +4,6 @@ import type { } from '../workspace/requestTypes'; export type CliCommandName = - | 'batch' | 'doctor' | 'filter' | 'help' diff --git a/packages/core/src/cli/parseWorkspace.ts b/packages/core/src/cli/parseWorkspace.ts index 5cda61312..2a0d7588b 100644 --- a/packages/core/src/cli/parseWorkspace.ts +++ b/packages/core/src/cli/parseWorkspace.ts @@ -1,7 +1,7 @@ import type { CliCommand } from './parseTypes'; export function parseWorkspaceCommand( - name: 'batch' | 'doctor' | 'filter' | 'index' | 'scope' | 'status', + name: 'doctor' | 'filter' | 'index' | 'scope' | 'status', argv: string[], ): CliCommand { const [extra] = argv; diff --git a/packages/core/src/cli/query/command.ts b/packages/core/src/cli/query/command.ts index e54eba860..1d7ee6618 100644 --- a/packages/core/src/cli/query/command.ts +++ b/packages/core/src/cli/query/command.ts @@ -23,7 +23,7 @@ const DEFAULT_DEPENDENCIES: QueryCommandDependencies = { query: requestWorkspaceGraphQuery, }; -const PATH_ARGUMENTS = ['from', 'to', 'filePath', 'relatedFrom', 'relatedTo'] as const; +const PATH_ARGUMENTS = ['from', 'to', 'target', 'filePath', 'relatedFrom', 'relatedTo'] as const; function canonicalizeExistingPathPrefix(inputPath: string): string { let existingPath = inputPath; diff --git a/packages/core/src/graphQuery/data.ts b/packages/core/src/graphQuery/data.ts index 2f67c048d..7e42157ff 100644 --- a/packages/core/src/graphQuery/data.ts +++ b/packages/core/src/graphQuery/data.ts @@ -1,8 +1,17 @@ import type { IAnalysisRelation, IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; import type { IGraphData } from '../graph/contracts'; +export interface GraphQuerySourceText { + files: readonly { filePath: string; content: string }[]; + filesScanned: number; + filesSkipped: number; + hasChangedFiles?: boolean; +} + export interface GraphQueryData { graphData: IGraphData; symbols?: readonly IAnalysisSymbol[]; relations?: readonly IAnalysisRelation[]; + sourceText?: GraphQuerySourceText; + cacheState?: 'fresh' | 'stale'; } diff --git a/packages/core/src/graphQuery/execute.ts b/packages/core/src/graphQuery/execute.ts index fb2019226..738c09dde 100644 --- a/packages/core/src/graphQuery/execute.ts +++ b/packages/core/src/graphQuery/execute.ts @@ -2,14 +2,18 @@ import type { GraphQueryData } from './data'; import type { GraphQueryConfig, GraphQueryConnectionConfig, + GraphQueryOverviewConfig, GraphQueryPathConfig, GraphQueryRequest, GraphQueryResult, + GraphQuerySearchConfig, GraphQuerySymbolsConfig, } from './model'; +import { inspectGraphTarget } from './overview'; import { findGraphPaths } from './paths'; import { listGraphEdges, listGraphNodes } from './reports'; import { listGraphRelationships } from './relationships'; +import { searchGraph } from './search'; import { listGraphSymbols } from './symbols'; import { deriveScopedGraphQueryData } from './visible'; @@ -24,6 +28,8 @@ type GraphQueryHandlers = { relationships: GraphQueryHandler; symbols: GraphQueryHandler; paths: GraphQueryHandler; + search: GraphQueryHandler; + overview: GraphQueryHandler; }; const GRAPH_QUERY_HANDLERS: GraphQueryHandlers = { @@ -32,6 +38,8 @@ const GRAPH_QUERY_HANDLERS: GraphQueryHandlers = { relationships: (data, args) => listGraphRelationships(data, args), symbols: (data, args) => listGraphSymbols(data, args), paths: (data, args) => findGraphPaths(deriveScopedGraphQueryData(data.graphData, args), args), + search: (data, args) => searchGraph(data, args), + overview: (data, args) => inspectGraphTarget(data, args), }; export function executeGraphQuery( diff --git a/packages/core/src/graphQuery/index.ts b/packages/core/src/graphQuery/index.ts index a4fde321a..de1577a6e 100644 --- a/packages/core/src/graphQuery/index.ts +++ b/packages/core/src/graphQuery/index.ts @@ -7,6 +7,8 @@ export type { GraphQueryFilterOperator, GraphQueryNodeReport, GraphQueryNodeReportItem, + GraphQueryOverviewConfig, + GraphQueryOverviewReport, GraphQueryPage, GraphQueryPathConfig, GraphQueryPathReport, @@ -18,6 +20,9 @@ export type { GraphQueryRelationshipReportItem, GraphQueryRelationshipSymbol, GraphQueryScope, + GraphQuerySearchConfig, + GraphQuerySearchMatch, + GraphQuerySearchReport, GraphQuerySort, GraphQueryResult, GraphQuerySymbolReport, @@ -26,7 +31,9 @@ export type { } from './model'; export type { GraphQueryData } from './data'; export { executeGraphQuery } from './execute'; +export { inspectGraphTarget } from './overview'; export { findGraphPaths } from './paths'; export { listGraphEdges, listGraphNodes } from './reports'; export { listGraphRelationships } from './relationships'; +export { searchGraph } from './search'; export { listGraphSymbols } from './symbols'; diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index f5ad066c8..29594eeab 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -61,6 +61,14 @@ export interface GraphQueryPathConfig extends GraphQueryConfig { projectFileEndpoints?: boolean; } +export interface GraphQuerySearchConfig extends GraphQueryConfig { + pattern: string; +} + +export interface GraphQueryOverviewConfig { + target: string; +} + export interface GraphQueryPage { offset: number; limit: number; @@ -153,23 +161,74 @@ export interface GraphQueryPathReport { }; } +export type GraphQuerySearchMatch = + | { type: 'node'; node: GraphQueryNodeReportItem } + | { type: 'symbol'; symbol: GraphQuerySymbolReportItem } + | { + type: 'text'; + filePath: string; + line: number; + column: number; + excerpt: string; + }; + +export interface GraphQuerySearchReport { + pattern: string; + matches: GraphQuerySearchMatch[]; + page: GraphQueryPage; + sources: { + text: { + freshness: 'live'; + filesScanned: number; + filesSkipped: number; + }; + symbols: { + freshness: 'cached'; + cacheState: 'fresh' | 'stale'; + }; + }; +} + +export interface GraphQueryOverviewReport { + target: GraphQueryNodeReportItem; + declaredSymbols: GraphQuerySymbolReport; + outgoing: GraphQueryEdgeReport; + incoming: GraphQueryEdgeReport; + limits: { + declaredSymbols: number; + relationshipsPerDirection: number; + }; +} + +export interface GraphQueryTargetNotFoundReport { + error: 'query_target_not_found'; + message: string; +} + export type GraphQueryReport = | 'nodes' | 'edges' | 'relationships' | 'symbols' - | 'paths'; + | 'paths' + | 'search' + | 'overview'; export type GraphQueryRequest = | { report: 'nodes'; arguments?: GraphQueryConfig } | { report: 'edges'; arguments?: GraphQueryConnectionConfig } | { report: 'relationships'; arguments?: GraphQueryConnectionConfig } | { report: 'symbols'; arguments?: GraphQuerySymbolsConfig } - | { report: 'paths'; arguments: GraphQueryPathConfig }; + | { report: 'paths'; arguments: GraphQueryPathConfig } + | { report: 'search'; arguments: GraphQuerySearchConfig } + | { report: 'overview'; arguments: GraphQueryOverviewConfig }; export type GraphQueryResult = | GraphQueryNodeReport | GraphQueryEdgeReport | GraphQueryRelationshipReport | GraphQuerySymbolReport - | GraphQueryPathReport; + | GraphQueryPathReport + | GraphQuerySearchReport + | GraphQueryOverviewReport + | GraphQueryTargetNotFoundReport; diff --git a/packages/core/src/graphQuery/overview.ts b/packages/core/src/graphQuery/overview.ts new file mode 100644 index 000000000..3778a73fd --- /dev/null +++ b/packages/core/src/graphQuery/overview.ts @@ -0,0 +1,88 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import type { NodeType } from '../graph/contracts'; +import { getNodeType } from '../visibleGraph/model'; +import type { GraphQueryData } from './data'; +import type { + GraphQueryNodeReportItem, + GraphQueryOverviewConfig, + GraphQueryOverviewReport, + GraphQueryTargetNotFoundReport, +} from './model'; +import { toNodeReportItem } from './nodeReport'; +import { listGraphEdges } from './reports'; +import { listGraphSymbols } from './symbols'; +import { toSymbolReportBase } from './symbols/metadata'; + +const DECLARED_SYMBOL_LIMIT = 25; +const RELATIONSHIP_LIMIT = 25; + +function symbolTarget(symbol: IAnalysisSymbol): GraphQueryNodeReportItem { + return { + path: symbol.id, + nodeType: `symbol:${symbol.kind ?? 'unknown'}` as NodeType, + symbol: { + ...toSymbolReportBase(symbol), + id: symbol.id, + kind: symbol.kind ?? 'unknown', + filePath: symbol.filePath, + }, + }; +} + +function resolveTarget(data: GraphQueryData, selector: string): GraphQueryNodeReportItem | undefined { + const node = data.graphData.nodes.find(candidate => candidate.id === selector); + if (node) return toNodeReportItem(node); + const symbol = data.symbols?.find(candidate => candidate.id === selector); + return symbol ? symbolTarget(symbol) : undefined; +} + +export function inspectGraphTarget( + data: GraphQueryData, + config: GraphQueryOverviewConfig, +): GraphQueryOverviewReport | GraphQueryTargetNotFoundReport { + const target = resolveTarget(data, config.target); + if (!target) { + return { + error: 'query_target_not_found', + message: `No indexed Node or Symbol has the exact id: ${config.target}`, + }; + } + + const relationshipData = { + ...data, + graphData: { + nodes: data.graphData.nodes, + edges: data.graphData.edges.filter(edge => edge.kind !== 'contains'), + }, + }; + const completeScope = { + nodes: Object.fromEntries(data.graphData.nodes.map(node => [getNodeType(node), true])), + edges: Object.fromEntries(data.graphData.edges.map(edge => [edge.kind, true])), + }; + const filePath = target.symbol?.filePath ?? target.path; + + return { + target, + declaredSymbols: target.symbol + ? listGraphSymbols(data, { filePath: '__symbol-target__', limit: DECLARED_SYMBOL_LIMIT }) + : listGraphSymbols(data, { filePath, limit: DECLARED_SYMBOL_LIMIT }), + outgoing: listGraphEdges(relationshipData.graphData, { + from: target.path, + scope: completeScope, + expandFileSelectors: !target.symbol, + projectFileEndpoints: !target.symbol, + limit: RELATIONSHIP_LIMIT, + }), + incoming: listGraphEdges(relationshipData.graphData, { + to: target.path, + scope: completeScope, + expandFileSelectors: !target.symbol, + projectFileEndpoints: !target.symbol, + limit: RELATIONSHIP_LIMIT, + }), + limits: { + declaredSymbols: DECLARED_SYMBOL_LIMIT, + relationshipsPerDirection: RELATIONSHIP_LIMIT, + }, + }; +} diff --git a/packages/core/src/graphQuery/search.ts b/packages/core/src/graphQuery/search.ts new file mode 100644 index 000000000..011a70560 --- /dev/null +++ b/packages/core/src/graphQuery/search.ts @@ -0,0 +1,156 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import type { IGraphNode } from '../graph/contracts'; +import type { GraphQueryData } from './data'; +import type { + GraphQuerySearchConfig, + GraphQuerySearchMatch, + GraphQuerySearchReport, + GraphQuerySymbolReportItem, +} from './model'; +import { toNodeReportItem } from './nodeReport'; +import { paginate } from './pagination'; +import { toSymbolReportBase } from './symbols/metadata'; + +const MAX_EXCERPT_LENGTH = 240; +const SOURCE_FILE_EXTENSION = /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|kts|swift|dart|cs|c|cc|cpp|cxx|h|hh|hpp|hxx|m|mm|php|rb|lua|scala|sc|hs|lhs|pas|pp)$/iu; + +interface RankedMatch { + match: GraphQuerySearchMatch; + rank: number; + sortKey: string; +} + +interface PatternMatcher { + exact(value: string): boolean; + find(value: string): number; + matches(value: string): boolean; +} + +function escapeRegularExpression(value: string): string { + return value.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); +} + +function createPatternMatcher(pattern: string): PatternMatcher { + const normalizedPattern = pattern.toLocaleLowerCase(); + if (!pattern.includes('*')) { + return { + exact: value => value.toLocaleLowerCase() === normalizedPattern, + find: value => value.toLocaleLowerCase().indexOf(normalizedPattern), + matches: value => value.toLocaleLowerCase().includes(normalizedPattern), + }; + } + + const expression = pattern.split('*').map(escapeRegularExpression).join('.*'); + const regex = new RegExp(expression, 'iu'); + return { + exact: value => new RegExp(`^(?:${expression})$`, 'iu').test(value), + find: value => regex.exec(value)?.index ?? -1, + matches: value => regex.test(value), + }; +} + +function symbolReportItem(symbol: IAnalysisSymbol): GraphQuerySymbolReportItem { + return { + ...toSymbolReportBase(symbol), + filePath: symbol.filePath, + }; +} + +function nodeMatch(node: IGraphNode, matcher: PatternMatcher): RankedMatch | undefined { + if (node.symbol || (!matcher.matches(node.id) && !matcher.matches(node.label))) { + return undefined; + } + const exact = matcher.exact(node.id) || matcher.exact(node.label); + return { + match: { type: 'node', node: toNodeReportItem(node) }, + rank: exact ? 1 : 3, + sortKey: `${node.id}\u0000${node.label}`, + }; +} + +function symbolMatch(symbol: IAnalysisSymbol, matcher: PatternMatcher): RankedMatch | undefined { + if (!matcher.matches(symbol.name)) { + return undefined; + } + return { + match: { type: 'symbol', symbol: symbolReportItem(symbol) }, + rank: matcher.exact(symbol.name) ? 0 : 2, + sortKey: `${symbol.filePath}\u0000${symbol.name}\u0000${symbol.kind ?? ''}\u0000${symbol.id}`, + }; +} + +function readSearchTerms(pattern: string): string[] { + return pattern.toLocaleLowerCase().split(/[^\p{L}\p{N}_]+/u).flatMap((term) => { + if (term.length < 3) return []; + return term.endsWith('ing') && term.length > 5 ? [term, term.slice(0, -3)] : [term]; + }); +} + +function sourceMatchRank(filePath: string, pattern: string): number { + const normalizedPath = filePath.toLocaleLowerCase(); + const pathTerms = normalizedPath.split(/[^\p{L}\p{N}_]+/u); + const searchTerms = readSearchTerms(pattern); + if (searchTerms.some(term => pathTerms.includes(term))) return 3; + if (searchTerms.some(term => normalizedPath.includes(term))) return 4; + return SOURCE_FILE_EXTENSION.test(filePath) ? 5 : 6; +} + +function createExcerpt(lineText: string, columnIndex: number): string { + if (lineText.length <= MAX_EXCERPT_LENGTH) return lineText; + const start = Math.max(0, Math.min(columnIndex - 80, lineText.length - MAX_EXCERPT_LENGTH)); + const prefix = start > 0 ? '…' : ''; + const contentLength = MAX_EXCERPT_LENGTH - prefix.length - 1; + const end = Math.min(lineText.length, start + contentLength); + const suffix = end < lineText.length ? '…' : ''; + return `${prefix}${lineText.slice(start, end)}${suffix}`; +} + +function sourceMatches(data: GraphQueryData, pattern: string, matcher: PatternMatcher): RankedMatch[] { + return (data.sourceText?.files ?? []).flatMap(({ filePath, content }) => ( + content.split(/\r?\n/u).flatMap((lineText, lineIndex) => { + const columnIndex = matcher.find(lineText); + if (columnIndex < 0) return []; + return [{ + match: { + type: 'text' as const, + filePath, + line: lineIndex + 1, + column: columnIndex + 1, + excerpt: createExcerpt(lineText, columnIndex), + }, + rank: sourceMatchRank(filePath, pattern), + sortKey: `${filePath}\u0000${String(lineIndex).padStart(10, '0')}\u0000${String(columnIndex).padStart(10, '0')}`, + }]; + }) + )); +} + +export function searchGraph( + data: GraphQueryData, + config: GraphQuerySearchConfig, +): GraphQuerySearchReport { + const matcher = createPatternMatcher(config.pattern); + const rankedMatches = [ + ...(data.symbols ?? []).map(symbol => symbolMatch(symbol, matcher)).filter(match => match !== undefined), + ...data.graphData.nodes.map(node => nodeMatch(node, matcher)).filter(match => match !== undefined), + ...sourceMatches(data, config.pattern, matcher), + ].sort((left, right) => left.rank - right.rank || left.sortKey.localeCompare(right.sortKey)); + const page = paginate(rankedMatches.map(item => item.match), config); + + return { + pattern: config.pattern, + matches: page.items, + page: page.page, + sources: { + text: { + freshness: 'live', + filesScanned: data.sourceText?.filesScanned ?? 0, + filesSkipped: data.sourceText?.filesSkipped ?? 0, + }, + symbols: { + freshness: 'cached', + cacheState: data.cacheState ?? 'fresh', + }, + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1254a27df..77b7f43c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -414,23 +414,16 @@ export { createCodeGraphyWorkspacePackageAwarePluginSignature, createCodeGraphyWorkspaceSettingsSignature, } from './workspace/signatures'; -export { MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE } from './workspace/requestTypes'; export type { GraphQueryReport as WorkspaceGraphQueryReport, IndexWorkspaceResult, - WorkspaceGraphQueryBatchInput, - WorkspaceGraphQueryBatchItem, - WorkspaceGraphQueryBatchResult, WorkspaceGraphQueryInput, WorkspaceGraphQueryResult, WorkspacePathInput, WorkspaceStatusResult, } from './workspace/requestTypes'; export { requestCodeGraphyIndexWorkspace } from './workspace/requestIndexing'; -export { - requestWorkspaceGraphQuery, - requestWorkspaceGraphQueryBatch, -} from './workspace/requestQuery'; +export { requestWorkspaceGraphQuery } from './workspace/requestQuery'; export { readCodeGraphyWorkspaceStatusForCli } from './workspace/requestStatus'; export type { CodeGraphyWorkspaceStaleReason, @@ -450,6 +443,8 @@ export type { GraphQueryFilterOperator, GraphQueryNodeReport, GraphQueryNodeReportItem, + GraphQueryOverviewConfig, + GraphQueryOverviewReport, GraphQueryPage, GraphQueryPathConfig, GraphQueryPathReport, @@ -462,6 +457,9 @@ export type { GraphQueryRelationshipSymbol, GraphQueryResult, GraphQueryScope, + GraphQuerySearchConfig, + GraphQuerySearchMatch, + GraphQuerySearchReport, GraphQuerySort, GraphQuerySymbolReport, GraphQuerySymbolReportItem, @@ -470,8 +468,10 @@ export type { export { executeGraphQuery, findGraphPaths, + inspectGraphTarget, listGraphEdges, listGraphNodes, listGraphRelationships, listGraphSymbols, + searchGraph, } from './graphQuery'; diff --git a/packages/core/src/workspace/queryGraph.ts b/packages/core/src/workspace/queryGraph.ts index b42992839..326b3ec38 100644 --- a/packages/core/src/workspace/queryGraph.ts +++ b/packages/core/src/workspace/queryGraph.ts @@ -1,3 +1,6 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createWorkspaceFileContentHash } from '../analysis/cache'; import { readWorkspaceAnalysisDatabaseSnapshot } from '../graphCache/database/storage'; import { filterInactivePluginSnapshotFacts } from '../plugins/activityState/analysisFacts'; import { createPluginActivityState } from '../plugins/activityState/model'; @@ -6,10 +9,60 @@ import { CODEGRAPHY_MARKDOWN_PLUGIN_ID, readCodeGraphyWorkspaceSettings } from ' import { normalizeWorkspaceQueryFacts } from './queryFacts'; import { matchesAnyPattern } from '../discovery/pathMatching'; import type { IGraphData } from '../graph/contracts'; +import { getNodeType } from '../visibleGraph/model'; +import type { GraphQuerySourceText } from '../graphQuery/data'; import { resolveProjectedGraphNodeTypes } from './graphScopeProjection/model'; import { resolveSavedGraphScope } from './graphScopeSettings'; import type { WorkspaceGraphQueryProjection } from './requestTypes'; +const MAX_QUERY_SOURCE_FILE_BYTES = 1024 * 1024; + +function isInsideWorkspace(workspaceRoot: string, absolutePath: string): boolean { + const relativePath = path.relative(workspaceRoot, absolutePath); + return relativePath !== '..' + && !relativePath.startsWith(`..${path.sep}`) + && !path.isAbsolute(relativePath); +} + +export function readWorkspaceQuerySourceText( + workspaceRoot: string, + graphData: IGraphData, + indexedContentHashes: ReadonlyMap = new Map(), +): GraphQuerySourceText { + const files: GraphQuerySourceText['files'][number][] = []; + let filesSkipped = 0; + let hasChangedFiles = false; + + for (const node of graphData.nodes) { + if (getNodeType(node) !== 'file') continue; + const absolutePath = path.resolve(workspaceRoot, node.id); + if (!isInsideWorkspace(workspaceRoot, absolutePath)) { + filesSkipped += 1; + continue; + } + try { + if (fs.statSync(absolutePath).size > MAX_QUERY_SOURCE_FILE_BYTES) { + filesSkipped += 1; + continue; + } + const content = fs.readFileSync(absolutePath, 'utf8'); + if (content.includes('\0')) { + filesSkipped += 1; + continue; + } + files.push({ filePath: node.id, content }); + const indexedHash = indexedContentHashes.get(node.id); + if (indexedHash && indexedHash !== createWorkspaceFileContentHash(content)) { + hasChangedFiles = true; + } + } catch { + filesSkipped += 1; + } + } + + return { files, filesScanned: files.length, filesSkipped, hasChangedFiles }; +} + function applyPathFilters(graphData: IGraphData, patterns: readonly string[]): IGraphData { if (patterns.length === 0) return graphData; const nodes = graphData.nodes.filter((node) => { @@ -43,6 +96,9 @@ export function readWorkspaceQuerySource( return { declarations, graphData: snapshot.graph, + indexedContentHashes: new Map(snapshot.files.flatMap(file => ( + file.contentHash ? [[file.filePath, file.contentHash] as const] : [] + ))), settings, snapshotFacts: normalizeWorkspaceQueryFacts( filterInactivePluginSnapshotFacts(snapshot, activePluginIds), diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index 15e398d51..d460eb174 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -7,14 +7,15 @@ import { type GraphQueryRequest, } from '../graphQuery'; import { emitGraphQueryCacheMissing, emitGraphQueryCompleted, emitGraphQueryStarted } from './queryDiagnostics'; -import { projectWorkspaceQueryGraph, readWorkspaceQuerySource } from './queryGraph'; -import { resolveCodeGraphyWorkspacePath } from './requestPaths'; import { - MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE, - type WorkspaceGraphQueryBatchInput, - type WorkspaceGraphQueryBatchResult, - type WorkspaceGraphQueryInput, - type WorkspaceGraphQueryResult, + projectWorkspaceQueryGraph, + readWorkspaceQuerySource, + readWorkspaceQuerySourceText, +} from './queryGraph'; +import { resolveCodeGraphyWorkspacePath } from './requestPaths'; +import type { + WorkspaceGraphQueryInput, + WorkspaceGraphQueryResult, } from './requestTypes'; import { readCodeGraphyWorkspaceStatus } from './status'; @@ -60,10 +61,15 @@ function executeWorkspaceGraphQuery( source, input.projection, ); + const sourceText = input.report === 'search' + ? readWorkspaceQuerySourceText(workspaceRoot, graphData, source.indexedContentHashes) + : undefined; const queryResult = executeGraphQuery({ graphData, symbols: snapshotFacts.symbols, relations: snapshotFacts.relations, + ...(sourceText ? { sourceText } : {}), + cacheState: status.state === 'stale' || sourceText?.hasChangedFiles ? 'stale' : 'fresh', }, { report: input.report, arguments: { @@ -129,43 +135,3 @@ export async function requestWorkspaceGraphQuery( ), ); } - -export async function requestWorkspaceGraphQueryBatch( - input: WorkspaceGraphQueryBatchInput, - dependencies: WorkspaceGraphQueryDependencies = DEFAULT_DEPENDENCIES, -): Promise { - if (input.queries.length < 1 || input.queries.length > MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE) { - throw new Error(`Batch queries must contain 1 through ${MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE} items`); - } - const workspaceRoot = resolveCodeGraphyWorkspacePath(input.workspacePath, dependencies.cwd()); - const status = readCodeGraphyWorkspaceStatus(workspaceRoot); - if (!status.hasGraphCache) { - return { - results: input.queries.map(query => { - const operationId = createGraphQueryOperationId(); - emitGraphQueryStarted({ diagnostics: input.diagnostics, operationId, report: query.report, workspaceRoot }); - emitGraphQueryCacheMissing({ - diagnostics: input.diagnostics, - operationId, - report: query.report, - status, - workspaceRoot, - }); - return createCacheMissingResult(workspaceRoot); - }), - }; - } - - const source = (dependencies.readQuerySource ?? readWorkspaceQuerySource)( - workspaceRoot, - dependencies.readInstalledPluginCache(), - ); - return { - results: input.queries.map(query => executeWorkspaceGraphQuery( - { ...query, diagnostics: input.diagnostics }, - workspaceRoot, - status, - source, - )), - }; -} diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index 648b66d1b..43f1adaf9 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -5,7 +5,9 @@ export type GraphQueryReport = | 'edges' | 'relationships' | 'symbols' - | 'paths'; + | 'paths' + | 'search' + | 'overview'; export interface WorkspacePathInput { diagnostics?: DiagnosticEventSink; @@ -42,21 +44,6 @@ export interface WorkspaceGraphQueryInput extends WorkspacePathInput { projection?: WorkspaceGraphQueryProjection; } -export const MAX_WORKSPACE_GRAPH_QUERY_BATCH_SIZE = 100; - -export type WorkspaceGraphQueryBatchItem = Omit< - WorkspaceGraphQueryInput, - 'diagnostics' | 'workspacePath' ->; - -export interface WorkspaceGraphQueryBatchInput extends WorkspacePathInput { - queries: WorkspaceGraphQueryBatchItem[]; -} - -export interface WorkspaceGraphQueryBatchResult { - results: WorkspaceGraphQueryResult[]; -} - export interface WorkspaceGraphQueryProjection { filterPatterns?: string[]; nodeTypes?: string[]; diff --git a/packages/core/tests/cli/batch/command.test.ts b/packages/core/tests/cli/batch/command.test.ts deleted file mode 100644 index d217afa60..000000000 --- a/packages/core/tests/cli/batch/command.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { runBatchCommand } from '../../../src/cli/batch/command'; -import { runCli } from '../../../src/cli/run'; - -describe('cli/batch/command', () => { - it('runs existing query argv against one workspace request', async () => { - const queryBatch = vi.fn(async () => ({ - results: [ - { nodes: [], page: { offset: 0, limit: 5, returned: 0, total: 0, nextOffset: null } }, - { - edges: [{ from: 'src/app.ts', to: 'src/model.ts', edgeTypes: ['import'] }], - page: { offset: 0, limit: 100, returned: 1, total: 1, nextOffset: null }, - }, - ], - })); - - const result = await runBatchCommand({ name: 'batch', workspacePath: '/workspace' }, { - cwd: () => '/caller', - readInput: async () => JSON.stringify({ - queries: [ - { id: 'files', argv: ['nodes', '--limit', '5'] }, - { id: 'outgoing', argv: ['dependencies', '/workspace/src/app.ts'] }, - ], - }), - queryBatch, - }); - - expect(queryBatch).toHaveBeenCalledWith({ - workspacePath: '/workspace', - queries: [ - { report: 'nodes', arguments: { limit: 5 } }, - { - report: 'edges', - arguments: { - from: 'src/app.ts', - expandFileSelectors: true, - projectFileEndpoints: true, - limit: 100, - }, - }, - ], - }); - expect(JSON.parse(result.output)).toEqual({ - results: [ - { - id: 'files', - command: 'nodes', - data: { nodes: [], page: { offset: 0, limit: 5, returned: 0, total: 0, nextOffset: null } }, - }, - { - id: 'outgoing', - command: 'dependencies', - data: { - edges: [{ from: 'src/app.ts', to: 'src/model.ts', edgeTypes: ['import'] }], - page: { offset: 0, limit: 100, returned: 1, total: 1, nextOffset: null }, - }, - }, - ], - }); - }); - - it('routes the final success and failure envelopes through the CLI runtime', async () => { - const successStdout = vi.fn(); - const successStderr = vi.fn(); - const successDependencies = { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ queries: [{ id: 'files', argv: ['nodes'] }] }), - queryBatch: async () => ({ results: [{ nodes: [], page: {} }] }), - }; - - await expect(runCli(['batch'], { - runCommand: command => runBatchCommand(command, successDependencies), - stdout: successStdout, - stderr: successStderr, - })).resolves.toBe(0); - expect(JSON.parse(successStdout.mock.calls[0][0])).toMatchObject({ - ok: true, - command: 'batch', - data: { results: [{ id: 'files', command: 'nodes' }] }, - }); - expect(successStderr).not.toHaveBeenCalled(); - - const failureStdout = vi.fn(); - const failureStderr = vi.fn(); - await expect(runCli(['batch'], { - runCommand: command => runBatchCommand(command, { - ...successDependencies, - readInput: async () => '{', - }), - stdout: failureStdout, - stderr: failureStderr, - })).resolves.toBe(2); - expect(failureStdout).not.toHaveBeenCalled(); - expect(JSON.parse(failureStderr.mock.calls[0][0])).toMatchObject({ - ok: false, - command: 'batch', - error: { code: 'invalid_arguments' }, - }); - }); - - it('forwards verbose Graph Query diagnostics to stderr', async () => { - const diagnostics: string[] = []; - - await runBatchCommand({ name: 'batch', verbose: true }, { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ queries: [{ id: 'files', argv: ['nodes'] }] }), - queryBatch: async (input) => { - input.diagnostics?.emit({ - area: 'graph-query', - event: 'started', - context: { operationId: 'query-1', report: 'nodes', workspaceRoot: '/workspace' }, - }); - return { results: [{ nodes: [], page: {} }] }; - }, - }, { - writeDiagnostic: line => diagnostics.push(line), - }); - - expect(diagnostics).toEqual([ - '[CodeGraphy] Starting Graph Query: report=nodes, operation=query-1, workspace=/workspace', - ]); - }); - - it('rejects malformed input before querying', async () => { - const queryBatch = vi.fn(); - - await expect(runBatchCommand({ name: 'batch' }, { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ - queries: [{ id: 'broken', argv: ['path', 'src/app.ts'] }], - }), - queryBatch, - })).resolves.toEqual({ - exitCode: 2, - output: JSON.stringify({ - error: 'invalid_arguments', - message: 'Batch query broken: path requires ', - }), - }); - expect(queryBatch).not.toHaveBeenCalled(); - }); - - it('correlates operational failures with the query id', async () => { - const result = await runBatchCommand({ name: 'batch' }, { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ - queries: [ - { id: 'files', argv: ['nodes'] }, - { id: 'uses', argv: ['dependencies', 'src/app.ts'] }, - ], - }), - queryBatch: async () => ({ - results: [ - { nodes: [], page: {} }, - { error: 'graph_cache_not_found', message: 'Index first.' }, - ], - }), - }); - - expect(result.exitCode).toBe(1); - expect(JSON.parse(result.output)).toMatchObject({ - error: { - code: 'batch_query_failed', - details: { - id: 'uses', - command: 'dependencies', - error: { code: 'graph_cache_not_found', message: 'Index first.' }, - }, - }, - }); - }); - - it('reports unsafe selectors as invalid Batch queries', async () => { - const queryBatch = vi.fn(); - - await expect(runBatchCommand({ name: 'batch' }, { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ - queries: [{ id: 'outside', argv: ['dependencies', '../outside.ts'] }], - }), - queryBatch, - })).resolves.toEqual({ - exitCode: 2, - output: JSON.stringify({ - error: 'invalid_arguments', - message: 'Batch query outside: Query path is outside the workspace: ../outside.ts', - }), - }); - expect(queryBatch).not.toHaveBeenCalled(); - }); - - it('requires unique query ids', async () => { - const queryBatch = vi.fn(); - const dependencies = { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ - queries: [ - { id: 'same', argv: ['nodes'] }, - { id: 'same', argv: ['edges'] }, - ], - }), - queryBatch, - }; - - await expect(runBatchCommand({ name: 'batch' }, dependencies)).resolves.toMatchObject({ - exitCode: 2, - output: expect.stringContaining('Batch query ids must be unique: same'), - }); - expect(queryBatch).not.toHaveBeenCalled(); - }); - - it('accepts 1 through 100 queries', async () => { - const maxQueries = Array.from( - { length: 100 }, - (_, index) => ({ id: String(index), argv: ['nodes'] }), - ); - const queryBatch = vi.fn(async (input) => ({ - results: input.queries.map(() => ({ nodes: [], page: {} })), - })); - await expect(runBatchCommand({ name: 'batch' }, { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ queries: maxQueries }), - queryBatch, - })).resolves.toMatchObject({ exitCode: 0 }); - - for (const queries of [ - [], - Array.from({ length: 101 }, (_, index) => ({ id: String(index), argv: ['nodes'] })), - ]) { - await expect(runBatchCommand({ name: 'batch' }, { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ queries }), - queryBatch, - })).resolves.toMatchObject({ - exitCode: 2, - output: expect.stringContaining('Batch queries must contain 1 through 100 items'), - }); - } - expect(queryBatch).toHaveBeenCalledTimes(1); - }); - - it('accepts ids through 128 characters and rejects longer ids', async () => { - const queryBatch = vi.fn(async () => ({ results: [{ nodes: [], page: {} }] })); - const run = (id: string) => runBatchCommand({ name: 'batch' }, { - cwd: () => '/workspace', - readInput: async () => JSON.stringify({ queries: [{ id, argv: ['nodes'] }] }), - queryBatch, - }); - - await expect(run('a'.repeat(128))).resolves.toMatchObject({ exitCode: 0 }); - await expect(run('a'.repeat(129))).resolves.toMatchObject({ - exitCode: 2, - output: expect.stringContaining('Batch query id must contain 1 through 128 characters'), - }); - expect(queryBatch).toHaveBeenCalledTimes(1); - }); - - it('rejects input over 1 MiB before parsing', async () => { - const queryBatch = vi.fn(); - - await expect(runBatchCommand({ name: 'batch' }, { - cwd: () => '/workspace', - readInput: async () => 'x'.repeat(1024 * 1024 + 1), - queryBatch, - })).resolves.toMatchObject({ - exitCode: 2, - output: expect.stringContaining('Batch input exceeds 1048576 bytes'), - }); - expect(queryBatch).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 30431066a..0bff6c304 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -7,8 +7,9 @@ describe('cli/help/command', () => { expect(result.exitCode).toBe(0); expect(result.output).toContain('codegraphy doctor'); - expect(result.output).toContain('codegraphy batch'); - expect(result.output).toContain('codegraphy search '); + expect(result.output).not.toContain('codegraphy batch'); + expect(result.output).toContain('codegraphy search '); + expect(result.output).toContain('codegraphy query '); expect(result.output).toContain('codegraphy dependencies '); expect(result.output).toContain('codegraphy path '); expect(result.output).toContain('codegraphy scope node '); @@ -28,8 +29,8 @@ describe('cli/help/command', () => { expect(output).toContain('1. Configure graph contributors with Plugins when needed.'); expect(output).toContain('2. Index the complete workspace graph into its Graph Cache.'); - expect(output).toContain('3. Shape returned results with Filters and Graph Scope.'); - expect(output).toContain('4. Query the resulting graph.'); + expect(output).toContain('3. Discover source and AST Symbols with search; inspect an exact result with query.'); + expect(output).toContain('4. Continue through the shaped graph with dependencies, dependents, or path.'); expect(output).toContain('codegraphy index Create or update the Graph Cache'); expect(output).toContain('codegraphy filter Read or change persisted Filters'); expect(output).toContain('codegraphy dependencies List outgoing Relationships'); @@ -39,13 +40,14 @@ describe('cli/help/command', () => { it('reports local pagination options for bounded list queries', () => { expect(createHelpResult(['status']).output).toContain('Usage: codegraphy status'); - const batchHelp = createHelpResult(['batch']).output; - expect(batchHelp).toContain('Usage: codegraphy batch'); - expect(batchHelp).toContain('data.results contains {id, command, data}'); - expect(batchHelp).toContain('error.details identifies the failed query'); - expect(createHelpResult(['query']).output).toContain('Query commands are top-level'); + expect(createHelpResult(['query']).output).toContain('Usage: codegraphy query '); + expect(createHelpResult(['query']).output).toContain('declared AST Symbols'); + expect(createHelpResult(['query']).output).toContain('incoming and outgoing Relationships'); expect(createHelpResult(['nodes']).output).toContain('Usage: codegraphy nodes'); expect(createHelpResult(['search']).output).toContain('Usage: codegraphy search'); + expect(createHelpResult(['search']).output).toContain('live source lines'); + expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); + expect(createHelpResult(['search']).output).toContain('`*` wildcard'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); @@ -53,7 +55,7 @@ describe('cli/help/command', () => { }); it('documents one-off query projections without implying settings changes', () => { - for (const command of ['nodes', 'search', 'edges', 'dependencies', 'dependents', 'path']) { + for (const command of ['nodes', 'edges', 'dependencies', 'dependents', 'path']) { const output = createHelpResult([command]).output; expect(output).toContain('--filter '); expect(output).toContain('--node-type '); diff --git a/packages/core/tests/cli/parse.test.ts b/packages/core/tests/cli/parse.test.ts index da008c455..149c84fa0 100644 --- a/packages/core/tests/cli/parse.test.ts +++ b/packages/core/tests/cli/parse.test.ts @@ -10,7 +10,9 @@ describe('cli/parse', () => { expect(parseCliCommand(['--version'])).toEqual({ name: 'version' }); expect(parseCliCommand(['-V'])).toEqual({ name: 'version' }); expect(parseCliCommand(['--', 'index'])).toEqual({ name: 'index' }); - expect(parseCliCommand(['batch'])).toEqual({ name: 'batch' }); + expect(parseCliCommand(['batch'])).toMatchObject({ + parseError: 'Unknown command: batch', + }); }); it('uses cwd by default and one global workspace override for every workspace command', () => { @@ -53,8 +55,8 @@ describe('cli/parse', () => { arguments: { action: 'add', pattern: '-draft/**' }, }); expect(parseCliCommand(['search', '--', '-C'])).toMatchObject({ - invokedCommand: 'search', - arguments: { search: '-C', limit: 100 }, + report: 'search', + arguments: { pattern: '-C', limit: 20 }, }); }); @@ -71,10 +73,6 @@ describe('cli/parse', () => { name: 'help', helpPath: ['plugins', 'enable'], }); - expect(parseCliCommand(['batch', '--help'])).toEqual({ - name: 'help', - helpPath: ['batch'], - }); expect(parseCliCommand(['query', '--help'])).toEqual({ name: 'help', helpPath: ['query'], @@ -92,7 +90,7 @@ describe('cli/parse', () => { expect(parseCliCommand(['edges', '--from', 'src/app.ts'])).toMatchObject({ parseError: 'Unknown option for edges: --from', }); - for (const command of ['setup', 'relationships', 'symbols', 'paths', 'query']) { + for (const command of ['setup', 'relationships', 'symbols', 'paths', 'batch']) { expect(parseCliCommand([command])).toMatchObject({ parseError: `Unknown command: ${command}`, }); diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index 8eec1b622..05a5a42f1 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -18,9 +18,14 @@ describe('cli/parseQuery', () => { it('maps intent commands to graph reports with positional operands', () => { expect(parseQueryCommand(['search', 'render settings'])).toEqual({ name: 'query', - invokedCommand: 'search', - report: 'nodes', - arguments: { search: 'render settings', limit: 100 }, + report: 'search', + arguments: { pattern: 'render settings', limit: 20 }, + }); + expect(parseQueryCommand(['query', 'src/cli/command.ts'])).toEqual({ + name: 'query', + invokedCommand: 'query', + report: 'overview', + arguments: { target: 'src/cli/command.ts' }, }); expect(parseQueryCommand(['dependencies', 'src/app.ts'])).toEqual({ name: 'query', @@ -51,12 +56,12 @@ describe('cli/parseQuery', () => { it('parses local pagination options and the end-of-options delimiter', () => { expect(parseQueryCommand(['search', '--limit', '5', '--offset', '10', 'render'])).toMatchObject({ - invokedCommand: 'search', - arguments: { search: 'render', limit: 5, offset: 10 }, + report: 'search', + arguments: { pattern: 'render', limit: 5, offset: 10 }, }); expect(parseQueryCommand(['search', '--', '-generated'])).toMatchObject({ - invokedCommand: 'search', - arguments: { search: '-generated', limit: 100 }, + report: 'search', + arguments: { pattern: '-generated', limit: 20 }, }); }); @@ -108,7 +113,15 @@ describe('cli/parseQuery', () => { }); expect(parseQueryCommand(['search'])).toMatchObject({ invokedCommand: 'search', - parseError: 'search requires ', + parseError: 'search requires ', + }); + expect(parseQueryCommand(['search', '*'])).toMatchObject({ + invokedCommand: 'search', + parseError: 'search pattern must contain a literal character', + }); + expect(parseQueryCommand(['query'])).toMatchObject({ + invokedCommand: 'query', + parseError: 'query requires ', }); expect(parseQueryCommand(['dependencies', 'a.ts', 'b.ts'])).toMatchObject({ parseError: 'Unexpected argument for dependencies: b.ts', diff --git a/packages/core/tests/cli/query/command.test.ts b/packages/core/tests/cli/query/command.test.ts index 65dc3b0cc..a2f4a30cf 100644 --- a/packages/core/tests/cli/query/command.test.ts +++ b/packages/core/tests/cli/query/command.test.ts @@ -30,6 +30,30 @@ describe('cli/query/command', () => { }); }); + it('normalizes an absolute query target to its workspace-relative Node id', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-target-')); + const filePath = path.join(workspaceRoot, 'src', 'command.ts'); + await fs.mkdir(path.dirname(filePath)); + await fs.writeFile(filePath, 'export const command = true;\n'); + let receivedInput: unknown; + + await runQueryCommand({ + name: 'query', + invokedCommand: 'query', + report: 'overview', + workspacePath: workspaceRoot, + arguments: { target: filePath }, + }, { + cwd: () => workspaceRoot, + query: async (input) => { + receivedInput = input; + return {}; + }, + }); + + expect(receivedInput).toMatchObject({ arguments: { target: 'src/command.ts' } }); + }); + it('accepts an in-workspace path whose segment starts with two dots', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-segment-')); const selector = path.join(workspaceRoot, '..cache', 'entry.ts'); diff --git a/packages/core/tests/graphQuery/overview.test.ts b/packages/core/tests/graphQuery/overview.test.ts new file mode 100644 index 000000000..cdb2a9ef9 --- /dev/null +++ b/packages/core/tests/graphQuery/overview.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import type { IAnalysisRelation, IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import type { IGraphData } from '../../src/graph/contracts'; +import { inspectGraphTarget } from '../../src/graphQuery/overview'; + +const graphData: IGraphData = { + nodes: [ + { id: 'src/command.ts', label: 'command.ts', nodeType: 'file' }, + { id: 'src/settings.ts', label: 'settings.ts', nodeType: 'file' }, + { id: 'tests/command.test.ts', label: 'command.test.ts', nodeType: 'file' }, + { + id: 'src/command.ts#runCommand:function', + label: 'runCommand', + nodeType: 'symbol:function', + symbol: { + id: 'src/command.ts#runCommand:function', + filePath: 'src/command.ts', + name: 'runCommand', + kind: 'function', + }, + }, + ], + edges: [ + { id: 'command-settings', from: 'src/command.ts', to: 'src/settings.ts', kind: 'import', sources: [] }, + { id: 'test-command', from: 'tests/command.test.ts', to: 'src/command.ts', kind: 'import', sources: [] }, + { + id: 'command-symbol', + from: 'src/command.ts', + to: 'src/command.ts#runCommand:function', + kind: 'contains', + sources: [], + }, + ], +}; + +const symbols: IAnalysisSymbol[] = [{ + id: 'src/command.ts#runCommand:function', + filePath: 'src/command.ts', + name: 'runCommand', + kind: 'function', +}]; + +const relations: IAnalysisRelation[] = [ + { + kind: 'import', + sourceId: 'core:typescript:import', + fromFilePath: 'src/command.ts', + toFilePath: 'src/settings.ts', + }, + { + kind: 'import', + sourceId: 'core:typescript:import', + fromFilePath: 'tests/command.test.ts', + toFilePath: 'src/command.ts', + }, +]; + +describe('core/graphQuery target overview', () => { + it('returns declarations plus incoming and outgoing Relationships for one exact target', () => { + const result = inspectGraphTarget({ graphData, symbols, relations }, { target: 'src/command.ts' }); + + expect(result).toMatchObject({ + target: { path: 'src/command.ts', nodeType: 'file' }, + declaredSymbols: { + symbols: [{ + id: 'src/command.ts#runCommand:function', + filePath: 'src/command.ts', + name: 'runCommand', + kind: 'function', + }], + page: { returned: 1, total: 1, nextOffset: null }, + }, + outgoing: { + edges: [{ from: 'src/command.ts', to: 'src/settings.ts', edgeTypes: ['import'] }], + page: { returned: 1, total: 1, nextOffset: null }, + }, + incoming: { + edges: [{ from: 'tests/command.test.ts', to: 'src/command.ts', edgeTypes: ['import'] }], + page: { returned: 1, total: 1, nextOffset: null }, + }, + limits: { declaredSymbols: 25, relationshipsPerDirection: 25 }, + }); + }); + + it('returns a typed error when the exact target is absent', () => { + expect(inspectGraphTarget({ graphData, symbols, relations }, { target: 'src/missing.ts' })).toEqual({ + error: 'query_target_not_found', + message: 'No indexed Node or Symbol has the exact id: src/missing.ts', + }); + }); +}); diff --git a/packages/core/tests/graphQuery/search.test.ts b/packages/core/tests/graphQuery/search.test.ts new file mode 100644 index 000000000..a3b6f9318 --- /dev/null +++ b/packages/core/tests/graphQuery/search.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import type { IGraphData } from '../../src/graph/contracts'; +import { searchGraph } from '../../src/graphQuery/search'; + +const graphData: IGraphData = { + nodes: [ + { id: 'src/index.ts', label: 'index.ts', nodeType: 'file' }, + { id: 'tests/index.test.ts', label: 'index.test.ts', nodeType: 'file' }, + { + id: 'src/index.ts#runIndexCommand:function', + label: 'runIndexCommand', + nodeType: 'symbol:function', + symbol: { + id: 'src/index.ts#runIndexCommand:function', + filePath: 'src/index.ts', + name: 'runIndexCommand', + kind: 'function', + }, + }, + ], + edges: [], +}; + +const symbols: IAnalysisSymbol[] = [{ + id: 'src/index.ts#runIndexCommand:function', + filePath: 'src/index.ts', + name: 'runIndexCommand', + kind: 'function', + signature: 'async function runIndexCommand()', + metadata: { language: 'typescript', source: 'core:treesitter' }, +}]; + +function search(pattern: string) { + return searchGraph({ + graphData, + symbols, + relations: [], + sourceText: { + files: [ + { filePath: 'src/index.ts', content: 'writeStatus(`Indexing ${workspace}...`);\n' }, + { filePath: 'tests/index.test.ts', content: "it('keeps non-verbose stderr clean', () => {});\n" }, + ], + filesScanned: 2, + filesSkipped: 0, + }, + cacheState: 'fresh', + }, { pattern, limit: 20 }); +} + +describe('core/graphQuery search', () => { + it('merges cached AST Symbols with live source matches and file provenance', () => { + expect(search('runIndexCommand')).toEqual({ + pattern: 'runIndexCommand', + matches: [ + { + type: 'symbol', + symbol: { + id: 'src/index.ts#runIndexCommand:function', + filePath: 'src/index.ts', + name: 'runIndexCommand', + kind: 'function', + signature: 'async function runIndexCommand()', + language: 'typescript', + source: 'core:treesitter', + }, + }, + ], + page: { offset: 0, limit: 20, returned: 1, total: 1, nextOffset: null }, + sources: { + text: { freshness: 'live', filesScanned: 2, filesSkipped: 0 }, + symbols: { freshness: 'cached', cacheState: 'fresh' }, + }, + }); + }); + + it('finds exact source text with one-based locations and bounded excerpts', () => { + expect(search('Indexing ').matches).toEqual([ + { + type: 'text', + filePath: 'src/index.ts', + line: 1, + column: 14, + excerpt: 'writeStatus(`Indexing ${workspace}...`);', + }, + ]); + }); + + it('supports case-insensitive line-local star wildcards', () => { + expect(search('*stderr clean').matches).toEqual([ + { + type: 'text', + filePath: 'tests/index.test.ts', + line: 1, + column: 1, + excerpt: "it('keeps non-verbose stderr clean', () => {});", + }, + ]); + }); + + it('ranks source paths related to the phrase ahead of documentation history', () => { + const result = searchGraph({ + graphData, + symbols, + relations: [], + sourceText: { + files: [ + { filePath: 'CHANGELOG.md', content: 'Changed Indexing behavior.\n' }, + { filePath: 'src/cli/index/command.ts', content: 'writeStatus(`Indexing ${workspace}...`);\n' }, + ], + filesScanned: 2, + filesSkipped: 0, + }, + }, { pattern: 'Indexing ', limit: 1 }); + + expect(result.matches).toEqual([expect.objectContaining({ + type: 'text', + filePath: 'src/cli/index/command.ts', + })]); + expect(result.page).toMatchObject({ returned: 1, total: 2, nextOffset: 1 }); + }); +}); diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index a16a6bec7..4bb13390b 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -1,69 +1,66 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { requestCodeGraphyIndexWorkspace } from '../../src/workspace/requestIndexing'; -import { readWorkspaceQuerySource } from '../../src/workspace/queryGraph'; -import { requestWorkspaceGraphQueryBatch } from '../../src/workspace/requestQuery'; +import { requestWorkspaceGraphQuery } from '../../src/workspace/requestQuery'; -describe('workspace/requestQuery batch', () => { - it('executes independent projections from one Graph Cache snapshot', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-batch-')); - await fs.writeFile(path.join(workspaceRoot, 'entry.ts'), "import './model';\n"); - await fs.writeFile(path.join(workspaceRoot, 'model.ts'), 'export const model = 1;\n'); +describe('workspace/requestQuery', () => { + it('searches current source text and cached AST Symbols through one bounded report', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-search-')); + await fs.writeFile(path.join(workspaceRoot, 'entry.ts'), [ + 'export function runIndexCommand(): void {', + ' process.stderr.write(`Indexing ${workspaceRoot}...`);', + '}', + '', + ].join('\n')); await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); - const readQuerySource = vi.fn(readWorkspaceQuerySource); - const edgeArguments = { - from: 'entry.ts', - expandFileSelectors: true, - projectFileEndpoints: true, - limit: 100, - }; - const result = await requestWorkspaceGraphQueryBatch({ + const symbolResult = await requestWorkspaceGraphQuery({ workspacePath: workspaceRoot, - queries: [ - { - report: 'nodes', - arguments: { limit: 100 }, - projection: { filterPatterns: ['model.ts'] }, - }, - { - report: 'edges', - arguments: edgeArguments, - projection: { edgeTypes: ['call'] }, - }, - { - report: 'edges', - arguments: edgeArguments, - projection: { edgeTypes: ['import'] }, - }, - ], - }, { - cwd: () => workspaceRoot, - readInstalledPluginCache: () => ({ version: 3, plugins: [] }), - readQuerySource, + report: 'search', + arguments: { pattern: 'runIndexCommand', limit: 20 }, + }); + const textResult = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'search', + arguments: { pattern: 'Indexing ', limit: 20 }, }); - expect(readQuerySource).toHaveBeenCalledTimes(1); - expect(result.results[0]).toMatchObject({ - nodes: [{ path: 'entry.ts', nodeType: 'file' }], + expect(symbolResult).toMatchObject({ + sources: { symbols: { freshness: 'cached', cacheState: 'fresh' } }, }); - expect(result.results[1]).toMatchObject({ edges: [] }); - expect(result.results[2]).toMatchObject({ - edges: [{ from: 'entry.ts', to: 'model.ts', edgeTypes: ['import'] }], + expect(symbolResult.matches).toEqual(expect.arrayContaining([{ + type: 'symbol', + symbol: expect.objectContaining({ name: 'runIndexCommand', kind: 'function', filePath: 'entry.ts' }), + }])); + expect(textResult).toMatchObject({ + matches: [{ + type: 'text', + filePath: 'entry.ts', + line: 2, + excerpt: ' process.stderr.write(`Indexing ${workspaceRoot}...`);', + }], + sources: { text: { freshness: 'live', filesScanned: 1, filesSkipped: 0 } }, }); }); - it('enforces the public Batch query-count bound', async () => { - await expect(requestWorkspaceGraphQueryBatch({ queries: [] })).rejects.toThrow( - 'Batch queries must contain 1 through 100 items', - ); - await expect(requestWorkspaceGraphQueryBatch({ - queries: Array.from({ length: 101 }, () => ({ - report: 'nodes' as const, - arguments: {}, - })), - })).rejects.toThrow('Batch queries must contain 1 through 100 items'); + it('reads live text after Indexing while marking cached Symbols stale', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-live-text-')); + const entryPath = path.join(workspaceRoot, 'entry.ts'); + await fs.writeFile(entryPath, 'export const original = 1;\n'); + await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); + await fs.writeFile(entryPath, 'export const changedAfterIndex = 2;\n'); + + const result = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'search', + arguments: { pattern: 'changedAfterIndex', limit: 20 }, + }); + + expect(result).toMatchObject({ + matches: [{ type: 'text', filePath: 'entry.ts', line: 1 }], + sources: { symbols: { freshness: 'cached', cacheState: 'stale' } }, + }); }); }); diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 5124ee841..2cd63f29d 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -1,47 +1,52 @@ --- name: codegraphy -description: Use the CodeGraphy CLI to index, shape, and query a workspace Relationship Graph before reading source. +description: Use the CodeGraphy CLI to discover source, AST Symbols, and Relationships in a prepared workspace graph. --- # CodeGraphy -Use CodeGraphy to narrow codebase exploration. The graph guides which source files to read; it does not replace source inspection. +Use CodeGraphy to identify the smallest set of source files worth reading. It provides navigation evidence, not a substitute for source inspection. ## Fast workflow -1. Run `codegraphy status`. Run `codegraphy index` when the cache is missing/stale **or when the task may follow source changes that status cannot detect**. Indexing is explicit and successful non-verbose output is one JSON line. -2. Choose the narrowest query: - - `search ` resolves a concept or partial path to Nodes. - - `dependencies ` finds outgoing Relationships: what it uses. - - `dependents ` finds incoming Relationships: what may be affected. - - `path ` explains whether and how two Nodes connect. - - `nodes` and `edges` inventory the shaped graph. -3. Read the returned source files and verify behavior there. +1. Start with useful evidence instead of a preparatory status call: + - `search ` discovers an identifier, path, or exact source phrase. + - `query ` inspects one exact File path or Symbol Node ID already returned by search. +2. Read the returned source files and lines. +3. Use a narrow continuation command only when the overview is insufficient. -Use `-C ` from outside the workspace. File selectors are workspace-relative paths or exact Node IDs. Quote multiword search text and globs. +If a command reports `graph_cache_not_found`, run `codegraphy index` once and retry. Search reports whether live source differs from its cached Symbol facts in `data.sources.symbols.cacheState`; do not run `status` before every query. Use `codegraphy filter` only when persisted path exclusions need inspection or change. -## Keep work bounded +Use `-C ` from outside the workspace. Quote patterns containing spaces or `*`. -`nodes`, `search`, `edges`, `dependencies`, and `dependents` default to 100 results. Their `data.page.nextOffset` is `null` when complete; otherwise rerun with `--offset ` and, when useful, a smaller `--limit`. +## Choose the command by question -Prefer one-off repeatable `--filter`, `--node-type`, and `--edge-type` options for a task. They do not change settings. Use `codegraphy scope` to discover available type IDs. Use `codegraphy filter` and `codegraphy scope ...` only for durable workspace changes. +- `search `: locate matching live source lines, cached AST Symbols, and indexed File or concept Nodes. Matching is case-insensitive; `*` spans characters within one line or name. Symbol results include their `filePath`; text results include `line`, `column`, and `excerpt`. +- `query `: inspect one exact File or Symbol. It returns declared AST Symbols plus bounded incoming and outgoing Relationships in one call. +- `dependencies `: continue through outgoing Relationships—what the Node uses. +- `dependents `: continue through incoming Relationships—what may be affected. +- `path `: verify whether and how two exact Nodes connect. +- `nodes`: inventory shaped Nodes when type-level enumeration is the task. +- `edges`: inventory shaped Relationships when edge-level enumeration is the task. +- `status`: inspect Graph Cache state when freshness itself is the task. +- `doctor`: diagnose settings, cache, runtime, or Plugin failures. +- `filter`, `scope`, and `plugins`: inspect or change durable workspace configuration; do not use them for ordinary navigation. -Query with the narrowest command that answers the current question instead of dumping the complete graph. +Query with the narrowest command that answers the current question. Prefer `search` → `query` → source reads over dumping Nodes or Edges. -When several queries are independent and known in advance, send them through one snapshot with `codegraphy batch`: +## Keep output bounded -```sh -printf '%s' '{"queries":[{"id":"uses","argv":["dependencies","src/app.ts"]},{"id":"used-by","argv":["dependents","src/app.ts"]}]}' | codegraphy batch -``` +`search` defaults to 20 combined matches. `nodes`, `edges`, `dependencies`, and `dependents` default to 100 results. When `data.page.nextOffset` is not `null`, continue with `--offset ` only if the current page did not answer the question. -Batching reduces latency and Graph Cache reads, but its JSON wrapper may use more tokens. Keep sequential calls when one result determines the next query. +Graph navigation commands accept repeatable `--filter`, `--node-type`, and `--edge-type` projections without changing settings. Use persisted `filter` or `scope` mutations only for durable workspace changes. ## Failures and recovery -Data commands write a single `{ok:true,command,data}` JSON envelope to stdout. Failures write `{ok:false,command,error}` to stderr and exit nonzero: 1 for an operational failure, 2 for invalid invocation. Use `--verbose` only when diagnostics/progress are useful; it writes additional lines to stderr. +Data commands write one `{ok:true,command,data}` JSON envelope to stdout. Failures write `{ok:false,command,error}` to stderr and exit nonzero: 1 for an operational failure and 2 for an invalid invocation. `--verbose` adds diagnostics on stderr. -- `graph_cache_not_found`: run `codegraphy index`. -- Missing/stale/unhealthy uncertainty: run `codegraphy doctor` and follow `error.details.checks` actions. -- Empty result: use `search` or `nodes` to confirm the selector, then retry with the exact path/Node ID. +- `graph_cache_not_found`: run `codegraphy index`, then retry. +- `query_target_not_found`: use `search` to obtain an exact File path or Symbol Node ID. +- Empty search: use a shorter literal or one `*` wildcard; after one broader attempt, fall back to ordinary source search. +- Unhealthy cache/settings: run `codegraphy doctor` and follow `error.details.checks`. -Run `codegraphy --help` or `codegraphy --help` for exact options and examples. +Run `codegraphy --help` or `codegraphy --help` for the complete interface and examples. diff --git a/skills/codegraphy/agents/openai.yaml b/skills/codegraphy/agents/openai.yaml index c452d1e16..50445a5e7 100644 --- a/skills/codegraphy/agents/openai.yaml +++ b/skills/codegraphy/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "CodeGraphy" - short_description: "Configure and query indexed code graphs" - default_prompt: "Use $codegraphy to index this workspace and answer a graph structure, dependency, or impact question." + short_description: "Discover source, AST symbols, and code relationships" + default_prompt: "Use $codegraphy when its prepared Relationship Graph can reduce source navigation for this task." From fc9554fbb12ffed359e8c760d40b68ffad38924c Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 10:23:45 -0700 Subject: [PATCH 12/55] docs(skill): stop querying known source --- skills/codegraphy/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 2cd63f29d..2afda45d3 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -34,6 +34,8 @@ Use `-C ` from outside the workspace. Quote patterns containing space Query with the narrowest command that answers the current question. Prefer `search` → `query` → source reads over dumping Nodes or Edges. +Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read it or use file-local text search. Do not search again for identifiers already present in returned declarations. A normal investigation should need one discovery search and at most one target query per relevant File; repeated workspace searches are usually slower and more expensive than reading the known source. + ## Keep output bounded `search` defaults to 20 combined matches. `nodes`, `edges`, `dependencies`, and `dependents` default to 100 results. When `data.page.nextOffset` is not `null`, continue with `--offset ` only if the current page did not answer the question. From 05373b6315079d7d641c4e175a12d4b8d8e19250 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 10:31:00 -0700 Subject: [PATCH 13/55] docs(skill): bound graph exploration calls --- skills/codegraphy/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 2afda45d3..fee4c7762 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -34,7 +34,7 @@ Use `-C ` from outside the workspace. Quote patterns containing space Query with the narrowest command that answers the current question. Prefer `search` → `query` → source reads over dumping Nodes or Edges. -Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read it or use file-local text search. Do not search again for identifiers already present in returned declarations. A normal investigation should need one discovery search and at most one target query per relevant File; repeated workspace searches are usually slower and more expensive than reading the known source. +Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read it or use file-local text search. Do not search again for identifiers already present in returned declarations. Use at most four CodeGraphy calls for an investigation unless you are following `nextOffset`; if four calls have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. ## Keep output bounded From 4190406b5a697ab9a119cda3dca562a37fd6d8a9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 10:39:05 -0700 Subject: [PATCH 14/55] test(skill): preserve complete source evidence --- skills/codegraphy/SKILL.md | 2 +- tests/release/codegraphySkill.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index fee4c7762..3c5d2ae1c 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -34,7 +34,7 @@ Use `-C ` from outside the workspace. Quote patterns containing space Query with the narrowest command that answers the current question. Prefer `search` → `query` → source reads over dumping Nodes or Edges. -Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read it or use file-local text search. Do not search again for identifiers already present in returned declarations. Use at most four CodeGraphy calls for an investigation unless you are following `nextOffset`; if four calls have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. +Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read it or use file-local text search. Do not search again for identifiers already present in returned declarations. Use at most four CodeGraphy calls for an investigation unless you are following `nextOffset`; if four calls have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. This navigation budget does not weaken the answer: verify and state every requested condition, default adapter, side-effect destination, and test from source before stopping. ## Keep output bounded diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index a07e006c5..17f13fff8 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -14,7 +14,7 @@ test('the generalized CodeGraphy skill teaches the lifecycle and delegates synta const lifecyclePositions = lifecycle.map(term => skill.indexOf(term)); assert.ok(lifecyclePositions.every(position => position >= 0)); assert.deepEqual(lifecyclePositions, [...lifecyclePositions].sort((left, right) => left - right)); - for (const command of ['nodes', 'search', 'edges', 'dependencies', 'dependents', 'path']) { + for (const command of ['nodes', 'search', 'query', 'edges', 'dependencies', 'dependents', 'path']) { assert.match(skill, new RegExp(`\\b${command}\\b`)); } assert.match(skill, /codegraphy --help/); From 392c1f676523f88943a3b0888e6583d8348c569c Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 10:59:25 -0700 Subject: [PATCH 15/55] test(cli): cover search and query boundaries --- packages/core/src/workspace/queryGraph.ts | 68 ++++++++++--------- .../core/tests/graphQuery/overview.test.ts | 30 ++++++++ packages/core/tests/graphQuery/search.test.ts | 28 ++++++++ .../core/tests/workspace/queryGraph.test.ts | 25 +++++++ 4 files changed, 120 insertions(+), 31 deletions(-) diff --git a/packages/core/src/workspace/queryGraph.ts b/packages/core/src/workspace/queryGraph.ts index 326b3ec38..b233552f5 100644 --- a/packages/core/src/workspace/queryGraph.ts +++ b/packages/core/src/workspace/queryGraph.ts @@ -24,43 +24,49 @@ function isInsideWorkspace(workspaceRoot: string, absolutePath: string): boolean && !path.isAbsolute(relativePath); } +interface QuerySourceFileResult { + file?: GraphQuerySourceText['files'][number]; + changed: boolean; +} + +function readQuerySourceFile( + workspaceRoot: string, + filePath: string, + indexedContentHash: string | undefined, +): QuerySourceFileResult { + const absolutePath = path.resolve(workspaceRoot, filePath); + if (!isInsideWorkspace(workspaceRoot, absolutePath)) return { changed: false }; + + try { + if (fs.statSync(absolutePath).size > MAX_QUERY_SOURCE_FILE_BYTES) return { changed: false }; + const content = fs.readFileSync(absolutePath, 'utf8'); + if (content.includes('\0')) return { changed: false }; + return { + file: { filePath, content }, + changed: indexedContentHash !== undefined + && indexedContentHash !== createWorkspaceFileContentHash(content), + }; + } catch { + return { changed: false }; + } +} + export function readWorkspaceQuerySourceText( workspaceRoot: string, graphData: IGraphData, indexedContentHashes: ReadonlyMap = new Map(), ): GraphQuerySourceText { - const files: GraphQuerySourceText['files'][number][] = []; - let filesSkipped = 0; - let hasChangedFiles = false; - - for (const node of graphData.nodes) { - if (getNodeType(node) !== 'file') continue; - const absolutePath = path.resolve(workspaceRoot, node.id); - if (!isInsideWorkspace(workspaceRoot, absolutePath)) { - filesSkipped += 1; - continue; - } - try { - if (fs.statSync(absolutePath).size > MAX_QUERY_SOURCE_FILE_BYTES) { - filesSkipped += 1; - continue; - } - const content = fs.readFileSync(absolutePath, 'utf8'); - if (content.includes('\0')) { - filesSkipped += 1; - continue; - } - files.push({ filePath: node.id, content }); - const indexedHash = indexedContentHashes.get(node.id); - if (indexedHash && indexedHash !== createWorkspaceFileContentHash(content)) { - hasChangedFiles = true; - } - } catch { - filesSkipped += 1; - } - } + const results = graphData.nodes + .filter(node => getNodeType(node) === 'file') + .map(node => readQuerySourceFile(workspaceRoot, node.id, indexedContentHashes.get(node.id))); + const files = results.flatMap(result => result.file ? [result.file] : []); - return { files, filesScanned: files.length, filesSkipped, hasChangedFiles }; + return { + files, + filesScanned: files.length, + filesSkipped: results.length - files.length, + hasChangedFiles: results.some(result => result.changed), + }; } function applyPathFilters(graphData: IGraphData, patterns: readonly string[]): IGraphData { diff --git a/packages/core/tests/graphQuery/overview.test.ts b/packages/core/tests/graphQuery/overview.test.ts index cdb2a9ef9..b96be985f 100644 --- a/packages/core/tests/graphQuery/overview.test.ts +++ b/packages/core/tests/graphQuery/overview.test.ts @@ -82,6 +82,36 @@ describe('core/graphQuery target overview', () => { }); }); + it('inspects an exact cached Symbol with its containing File provenance', () => { + const result = inspectGraphTarget( + { + graphData, + symbols: [...symbols, { + id: 'src/settings.ts#readSettings:function', + filePath: 'src/settings.ts', + name: 'readSettings', + kind: 'function', + }], + relations, + }, + { target: 'src/settings.ts#readSettings:function' }, + ); + + expect(result).toMatchObject({ + target: { + path: 'src/settings.ts#readSettings:function', + nodeType: 'symbol:function', + symbol: { + id: 'src/settings.ts#readSettings:function', + name: 'readSettings', + kind: 'function', + filePath: 'src/settings.ts', + }, + }, + declaredSymbols: { symbols: [] }, + }); + }); + it('returns a typed error when the exact target is absent', () => { expect(inspectGraphTarget({ graphData, symbols, relations }, { target: 'src/missing.ts' })).toEqual({ error: 'query_target_not_found', diff --git a/packages/core/tests/graphQuery/search.test.ts b/packages/core/tests/graphQuery/search.test.ts index a3b6f9318..58bf64041 100644 --- a/packages/core/tests/graphQuery/search.test.ts +++ b/packages/core/tests/graphQuery/search.test.ts @@ -74,6 +74,17 @@ describe('core/graphQuery search', () => { }); }); + it('finds indexed Nodes by exact path or partial label', () => { + expect(search('src/index.ts').matches[0]).toEqual({ + type: 'node', + node: { path: 'src/index.ts', nodeType: 'file' }, + }); + expect(search('index').matches).toEqual(expect.arrayContaining([{ + type: 'node', + node: { path: 'src/index.ts', nodeType: 'file' }, + }])); + }); + it('finds exact source text with one-based locations and bounded excerpts', () => { expect(search('Indexing ').matches).toEqual([ { @@ -98,6 +109,23 @@ describe('core/graphQuery search', () => { ]); }); + it('centers long excerpts on the matching source text', () => { + const longLine = `${'before '.repeat(30)}needle${' after'.repeat(30)}`; + const result = searchGraph({ + graphData, + sourceText: { + files: [{ filePath: 'src/long.ts', content: `${longLine}\n` }], + filesScanned: 1, + filesSkipped: 0, + }, + }, { pattern: 'needle', limit: 20 }); + const match = result.matches[0]; + + expect(match).toMatchObject({ type: 'text', filePath: 'src/long.ts' }); + expect(match && 'excerpt' in match ? match.excerpt : '').toContain('needle'); + expect(match && 'excerpt' in match ? match.excerpt.length : 0).toBeLessThanOrEqual(240); + }); + it('ranks source paths related to the phrase ahead of documentation history', () => { const result = searchGraph({ graphData, diff --git a/packages/core/tests/workspace/queryGraph.test.ts b/packages/core/tests/workspace/queryGraph.test.ts index e5c0b227a..6a7e759e9 100644 --- a/packages/core/tests/workspace/queryGraph.test.ts +++ b/packages/core/tests/workspace/queryGraph.test.ts @@ -6,6 +6,7 @@ import { requestCodeGraphyIndexWorkspace } from '../../src/workspace/requestInde import { projectWorkspaceQueryGraph, readWorkspaceQuerySource, + readWorkspaceQuerySourceText, } from '../../src/workspace/queryGraph'; describe('workspace/queryGraph', () => { @@ -23,4 +24,28 @@ describe('workspace/queryGraph', () => { expect(complete.graphData.nodes.map(node => node.id)).toEqual(['entry.txt', 'model.txt']); expect(source.graphData.nodes.map(node => node.id)).toEqual(['entry.txt', 'model.txt']); }); + + it('skips binary, oversized, unreadable, and outside-workspace source files', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-text-')); + await fs.writeFile(path.join(workspaceRoot, 'binary.dat'), 'before\0after'); + await fs.writeFile(path.join(workspaceRoot, 'large.ts'), Buffer.alloc(1024 * 1024 + 1)); + + const result = readWorkspaceQuerySourceText(workspaceRoot, { + nodes: [ + { id: 'binary.dat', label: 'binary.dat', nodeType: 'file' }, + { id: 'large.ts', label: 'large.ts', nodeType: 'file' }, + { id: 'missing.ts', label: 'missing.ts', nodeType: 'file' }, + { id: '../outside.ts', label: 'outside.ts', nodeType: 'file' }, + { id: 'folder', label: 'folder', nodeType: 'folder' }, + ], + edges: [], + }); + + expect(result).toEqual({ + files: [], + filesScanned: 0, + filesSkipped: 4, + hasChangedFiles: false, + }); + }); }); From fc9fafa314367ed6b90369dec92f41e4050ae425 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 11:03:28 -0700 Subject: [PATCH 16/55] refactor(cli): keep query parsing bounded --- packages/core/src/cli/parseQuery.ts | 256 +++++++++++++++++----------- 1 file changed, 160 insertions(+), 96 deletions(-) diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index 1a55708ea..04ab34d9d 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -24,6 +24,24 @@ interface ParsedQueryArguments { projection?: WorkspaceGraphQueryProjection; } +interface QueryBuilderInput { + command: string; + operands: string[]; + page: { limit: number; offset?: number }; + projection?: WorkspaceGraphQueryProjection; +} + +type ParsedOption = + | { type: 'limit' | 'offset'; value: number } + | { type: 'projection'; key: keyof WorkspaceGraphQueryProjection; values: string[] } + | { type: 'error'; message: string }; + +const PROJECTION_OPTION_KEYS: Record = { + '--filter': 'filterPatterns', + '--node-type': 'nodeTypes', + '--edge-type': 'edgeTypes', +}; + export function isGraphQueryReport(value: string | undefined): boolean { return value !== undefined && QUERY_COMMANDS.has(value); } @@ -38,6 +56,69 @@ function parseInteger(value: string | undefined, minimum: number): number | unde return Number.isSafeInteger(parsed) && parsed >= minimum ? parsed : undefined; } +function parsePaginationOption( + command: string, + argument: '--limit' | '--offset', + value: string | undefined, + allowPagination: boolean, +): ParsedOption { + if (!allowPagination) return { type: 'error', message: `Unknown option for ${command}: ${argument}` }; + const minimum = argument === '--limit' ? 1 : 0; + const parsed = parseInteger(value, minimum); + if (parsed !== undefined) return { type: argument === '--limit' ? 'limit' : 'offset', value: parsed }; + const requirement = argument === '--limit' ? 'a positive integer' : 'a non-negative integer'; + return { type: 'error', message: `${argument} requires ${requirement}` }; +} + +function parseProjectionOption( + argument: string, + value: string | undefined, + key: keyof WorkspaceGraphQueryProjection, +): ParsedOption { + const values = !value || value.startsWith('-') + ? [] + : value.split(',').map(item => item.trim()).filter(Boolean); + return values.length > 0 + ? { type: 'projection', key, values } + : { type: 'error', message: `${argument} requires a comma-separated list` }; +} + +function parseOption( + command: string, + argument: string, + value: string | undefined, + allowPagination: boolean, +): ParsedOption | undefined { + if (argument === '--limit' || argument === '--offset') { + return parsePaginationOption(command, argument, value, allowPagination); + } + const projectionKey = PROJECTION_OPTION_KEYS[argument]; + return projectionKey ? parseProjectionOption(argument, value, projectionKey) : undefined; +} + +function applyOption( + parsed: ParsedOption, + state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, +): void { + if (parsed.type === 'limit') state.limit = parsed.value; + if (parsed.type === 'offset') state.offset = parsed.value; + if (parsed.type === 'projection') { + state.projection[parsed.key] = [...new Set([...(state.projection[parsed.key] ?? []), ...parsed.values])]; + } +} + +function completeParsedArguments( + operands: string[], + state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, +): ParsedQueryArguments { + return { + operands, + limit: state.limit, + ...(state.offset !== undefined ? { offset: state.offset } : {}), + ...(Object.keys(state.projection).length > 0 ? { projection: state.projection } : {}), + }; +} + function parseArguments( command: string, argv: string[], @@ -45,63 +126,36 @@ function parseArguments( defaultLimit = DEFAULT_LIMIT, ): ParsedQueryArguments { const operands: string[] = []; - let limit = defaultLimit; - let offset: number | undefined; + const state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection } = { + limit: defaultLimit, + projection: {}, + }; let optionsEnded = false; - const projection: NonNullable = {}; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; - if (!optionsEnded && argument === '--') { - optionsEnded = true; + if (optionsEnded) { + operands.push(argument); continue; } - if (!optionsEnded && (argument === '--limit' || argument === '--offset')) { - if (!allowPagination) { - return { operands, limit, parseError: `Unknown option for ${command}: ${argument}` }; - } - const minimum = argument === '--limit' ? 1 : 0; - const value = parseInteger(argv[index + 1], minimum); - if (value === undefined) { - const requirement = argument === '--limit' ? 'a positive integer' : 'a non-negative integer'; - return { operands, limit, parseError: `${argument} requires ${requirement}` }; - } - if (argument === '--limit') limit = value; - else offset = value; - index += 1; + if (argument === '--') { + optionsEnded = true; continue; } - if (!optionsEnded && ( - argument === '--filter' - || argument === '--node-type' - || argument === '--edge-type' - )) { - const optionValue = argv[index + 1]; - const values = !optionValue || optionValue.startsWith('-') - ? [] - : optionValue.split(',').map(value => value.trim()).filter(Boolean); - if (values.length === 0) { - return { operands, limit, parseError: `${argument} requires a comma-separated list` }; - } - const key = argument === '--filter' - ? 'filterPatterns' - : argument === '--node-type' ? 'nodeTypes' : 'edgeTypes'; - projection[key] = [...new Set([...(projection[key] ?? []), ...values])]; + const option = parseOption(command, argument, argv[index + 1], allowPagination); + if (option?.type === 'error') return { operands, limit: state.limit, parseError: option.message }; + if (option) { + applyOption(option, state); index += 1; continue; } - if (!optionsEnded && argument.startsWith('-')) { - return { operands, limit, parseError: `Unknown option for ${command}: ${argument}` }; + if (argument.startsWith('-')) { + return { operands, limit: state.limit, parseError: `Unknown option for ${command}: ${argument}` }; } operands.push(argument); } - return { - operands, - limit, - ...(offset !== undefined ? { offset } : {}), - ...(Object.keys(projection).length > 0 ? { projection } : {}), - }; + return completeParsedArguments(operands, state); } function requireOperands(command: string, args: string[], count: number, usage: string): CliCommand | undefined { @@ -114,7 +168,7 @@ function query( invokedCommand: string, report: GraphQueryReport, arguments_: Record, - projection?: ParsedQueryArguments['projection'], + projection?: WorkspaceGraphQueryProjection, ): CliCommand { return { name: 'query', @@ -125,65 +179,75 @@ function query( }; } -export function parseQueryCommand(argv: string[]): CliCommand { - const [command = '', ...rawArgs] = argv; - if (!isGraphQueryReport(command)) { - return parseError(command, `Unknown query command: ${command}`); +function buildList(input: QueryBuilderInput): CliCommand { + const invalid = requireOperands(input.command, input.operands, 0, ''); + return invalid ?? query(input.command, input.command as 'nodes' | 'edges', input.page, input.projection); +} + +function buildSearch(input: QueryBuilderInput): CliCommand { + const invalid = requireOperands(input.command, input.operands, 1, ''); + if (invalid) return invalid; + if (!input.operands[0].replace(/\*/g, '').trim()) { + return parseError(input.command, 'search pattern must contain a literal character'); } + return query(input.command, 'search', { pattern: input.operands[0], ...input.page }, input.projection); +} + +function buildOverview(input: QueryBuilderInput): CliCommand { + const invalid = requireOperands(input.command, input.operands, 1, ''); + return invalid ?? query(input.command, 'overview', { target: input.operands[0] }, input.projection); +} + +function buildConnection(input: QueryBuilderInput, endpoint: 'from' | 'to'): CliCommand { + const invalid = requireOperands(input.command, input.operands, 1, ''); + return invalid ?? query(input.command, 'edges', { + [endpoint]: input.operands[0], + expandFileSelectors: true, + projectFileEndpoints: true, + ...input.page, + }, input.projection); +} - const acceptsPagination = command !== 'path' && command !== 'query'; +function buildPath(input: QueryBuilderInput): CliCommand { + const invalid = requireOperands(input.command, input.operands, 2, ' '); + return invalid ?? query(input.command, 'paths', { + from: input.operands[0], + to: input.operands[1], + maxDepth: DEFAULT_MAX_DEPTH, + maxPaths: DEFAULT_MAX_PATHS, + expandFileSelectors: true, + projectFileEndpoints: true, + }, input.projection); +} + +const QUERY_BUILDERS: Record CliCommand> = { + nodes: buildList, + edges: buildList, + search: buildSearch, + query: buildOverview, + dependencies: input => buildConnection(input, 'from'), + dependents: input => buildConnection(input, 'to'), + path: buildPath, +}; + +export function parseQueryCommand(argv: string[]): CliCommand { + const [command = '', ...rawArgs] = argv; + const builder = QUERY_BUILDERS[command]; + if (!builder) return parseError(command, `Unknown query command: ${command}`); const parsed = parseArguments( command, rawArgs, - acceptsPagination, + command !== 'path' && command !== 'query', command === 'search' ? DEFAULT_SEARCH_LIMIT : DEFAULT_LIMIT, ); if (parsed.parseError) return parseError(command, parsed.parseError); - const { operands, limit, offset, projection } = parsed; - const page = { limit, ...(offset !== undefined ? { offset } : {}) }; - - switch (command) { - case 'nodes': - case 'edges': { - const invalid = requireOperands(command, operands, 0, ''); - return invalid ?? query(command, command, page, projection); - } - case 'search': { - const invalid = requireOperands(command, operands, 1, ''); - if (invalid) return invalid; - if (!operands[0].replace(/\*/g, '').trim()) { - return parseError(command, 'search pattern must contain a literal character'); - } - return query(command, 'search', { pattern: operands[0], ...page }, projection); - } - case 'query': { - const invalid = requireOperands(command, operands, 1, ''); - return invalid ?? query(command, 'overview', { target: operands[0] }, projection); - } - case 'dependencies': { - const invalid = requireOperands(command, operands, 1, ''); - return invalid ?? query(command, 'edges', { - from: operands[0], expandFileSelectors: true, projectFileEndpoints: true, ...page, - }, projection); - } - case 'dependents': { - const invalid = requireOperands(command, operands, 1, ''); - return invalid ?? query(command, 'edges', { - to: operands[0], expandFileSelectors: true, projectFileEndpoints: true, ...page, - }, projection); - } - case 'path': { - const invalid = requireOperands(command, operands, 2, ' '); - return invalid ?? query(command, 'paths', { - from: operands[0], - to: operands[1], - maxDepth: DEFAULT_MAX_DEPTH, - maxPaths: DEFAULT_MAX_PATHS, - expandFileSelectors: true, - projectFileEndpoints: true, - }, projection); - } - } - - return parseError(command, `Unknown query command: ${command}`); + return builder({ + command, + operands: parsed.operands, + page: { + limit: parsed.limit, + ...(parsed.offset !== undefined ? { offset: parsed.offset } : {}), + }, + ...(parsed.projection ? { projection: parsed.projection } : {}), + }); } From d6dbf3c3fa02d3b5807a933b1d7a19405f633304 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 11:24:15 -0700 Subject: [PATCH 17/55] feat(cli): rank natural search phrases with BM25 --- packages/core/src/graphQuery/search.ts | 33 ++++++++- .../core/src/graphQuery/search/ranking.ts | 68 +++++++++++++++++++ packages/core/tests/graphQuery/search.test.ts | 47 +++++++++++++ .../tests/graphQuery/search/ranking.test.ts | 36 ++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/graphQuery/search/ranking.ts create mode 100644 packages/core/tests/graphQuery/search/ranking.test.ts diff --git a/packages/core/src/graphQuery/search.ts b/packages/core/src/graphQuery/search.ts index 011a70560..7835904da 100644 --- a/packages/core/src/graphQuery/search.ts +++ b/packages/core/src/graphQuery/search.ts @@ -10,6 +10,7 @@ import type { import { toNodeReportItem } from './nodeReport'; import { paginate } from './pagination'; import { toSymbolReportBase } from './symbols/metadata'; +import { rankSearchDocuments } from './search/ranking'; const MAX_EXCERPT_LENGTH = 240; const SOURCE_FILE_EXTENSION = /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|kts|swift|dart|cs|c|cc|cpp|cxx|h|hh|hpp|hxx|m|mm|php|rb|lua|scala|sc|hs|lhs|pas|pp)$/iu; @@ -125,15 +126,45 @@ function sourceMatches(data: GraphQueryData, pattern: string, matcher: PatternMa )); } +function bm25FallbackMatches( + data: GraphQueryData, + pattern: string, + existingMatches: readonly RankedMatch[], +): RankedMatch[] { + if (pattern.includes('*') || existingMatches.length >= 5) return []; + const nodesByPath = new Map(data.graphData.nodes + .filter(node => !node.symbol) + .map(node => [node.id, node])); + const existingNodePaths = new Set(existingMatches.flatMap(item => ( + item.match.type === 'node' ? [item.match.node.path] : [] + ))); + return rankSearchDocuments(pattern, (data.sourceText?.files ?? []).map(file => ({ + id: file.filePath, + path: file.filePath, + text: file.content, + }))).slice(0, 10).flatMap((result, index) => { + const node = nodesByPath.get(result.id); + return node && !existingNodePaths.has(result.id) ? [{ + match: { type: 'node' as const, node: toNodeReportItem(node) }, + rank: 4, + sortKey: String(index).padStart(10, '0'), + }] : []; + }); +} + export function searchGraph( data: GraphQueryData, config: GraphQuerySearchConfig, ): GraphQuerySearchReport { const matcher = createPatternMatcher(config.pattern); - const rankedMatches = [ + const directMatches = [ ...(data.symbols ?? []).map(symbol => symbolMatch(symbol, matcher)).filter(match => match !== undefined), ...data.graphData.nodes.map(node => nodeMatch(node, matcher)).filter(match => match !== undefined), ...sourceMatches(data, config.pattern, matcher), + ]; + const rankedMatches = [ + ...directMatches, + ...bm25FallbackMatches(data, config.pattern, directMatches), ].sort((left, right) => left.rank - right.rank || left.sortKey.localeCompare(right.sortKey)); const page = paginate(rankedMatches.map(item => item.match), config); diff --git a/packages/core/src/graphQuery/search/ranking.ts b/packages/core/src/graphQuery/search/ranking.ts new file mode 100644 index 000000000..a29b85107 --- /dev/null +++ b/packages/core/src/graphQuery/search/ranking.ts @@ -0,0 +1,68 @@ +const BM25_K1 = 1.2; +const BM25_B = 0.75; +const PATH_TERM_WEIGHT = 8; + +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'from', + 'how', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this', + 'to', 'with', +]); + +export interface SearchDocument { + id: string; + path: string; + text: string; +} + +export interface RankedSearchDocument { + id: string; + score: number; +} + +function tokenize(value: string): string[] { + const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2'); + return separated.match(/[\p{L}\d]+/gu)?.map(term => term.toLocaleLowerCase()) + .filter(term => term.length > 1 && !STOP_WORDS.has(term)) ?? []; +} + +function countTerms(terms: readonly string[]): Map { + const counts = new Map(); + for (const term of terms) counts.set(term, (counts.get(term) ?? 0) + 1); + return counts; +} + +export function rankSearchDocuments( + pattern: string, + documents: readonly SearchDocument[], +): RankedSearchDocument[] { + if (!/\s/u.test(pattern)) return []; + const queryTerms = [...new Set(tokenize(pattern))]; + if (queryTerms.length < 2 || documents.length === 0) return []; + + const prepared = documents.map((document) => { + const pathTerms = tokenize(document.path); + const terms = [...pathTerms.flatMap(term => Array(PATH_TERM_WEIGHT).fill(term)), ...tokenize(document.text)]; + return { document, terms, counts: countTerms(terms) }; + }); + const averageLength = prepared.reduce((total, item) => total + item.terms.length, 0) / prepared.length; + const documentFrequency = new Map(queryTerms.map(term => [ + term, + prepared.filter(item => item.counts.has(term)).length, + ])); + + return prepared.flatMap(({ document, terms, counts }) => { + const score = queryTerms.reduce((total, term) => { + const frequency = counts.get(term) ?? 0; + if (frequency === 0) return total; + const frequencyInDocuments = documentFrequency.get(term) ?? 0; + const inverseDocumentFrequency = Math.log( + 1 + (documents.length - frequencyInDocuments + 0.5) / (frequencyInDocuments + 0.5), + ); + const normalizedFrequency = frequency + BM25_K1 * ( + 1 - BM25_B + BM25_B * terms.length / averageLength + ); + return total + inverseDocumentFrequency * frequency * (BM25_K1 + 1) / normalizedFrequency; + }, 0); + return score > 0 ? [{ id: document.id, score }] : []; + }).sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); +} diff --git a/packages/core/tests/graphQuery/search.test.ts b/packages/core/tests/graphQuery/search.test.ts index 58bf64041..401850f47 100644 --- a/packages/core/tests/graphQuery/search.test.ts +++ b/packages/core/tests/graphQuery/search.test.ts @@ -126,6 +126,53 @@ describe('core/graphQuery search', () => { expect(match && 'excerpt' in match ? match.excerpt.length : 0).toBeLessThanOrEqual(240); }); + it('falls back to BM25-ranked Files for natural multi-term searches', () => { + const result = searchGraph({ + graphData: { + nodes: [ + { id: 'src/cli/filter/command.ts', label: 'command.ts', nodeType: 'file' }, + { id: 'src/cli/doctor/command.ts', label: 'command.ts', nodeType: 'file' }, + { id: 'tests/cli/parse.test.ts', label: 'parse.test.ts', nodeType: 'file' }, + ], + edges: [], + }, + sourceText: { + files: [ + { + filePath: 'src/cli/filter/command.ts', + content: 'export function runFilterCommand() { return readWorkspaceSettings(); }\n', + }, + { + filePath: 'src/cli/doctor/command.ts', + content: 'export function runDoctorCommand() { return validateSettings(); }\n', + }, + { + filePath: 'tests/cli/parse.test.ts', + content: "it('parses compact scope and filter commands', () => {});\n", + }, + ], + filesScanned: 3, + filesSkipped: 0, + }, + }, { pattern: 'filter command', limit: 20 }); + + expect(result.matches[0]).toEqual({ + type: 'node', + node: { path: 'src/cli/filter/command.ts', nodeType: 'file' }, + }); + expect(result.matches).toContainEqual(expect.objectContaining({ + type: 'text', + filePath: 'tests/cli/parse.test.ts', + })); + }); + + it('keeps exact identifier Symbols ahead of BM25 fallback candidates', () => { + expect(search('runIndexCommand').matches[0]).toMatchObject({ + type: 'symbol', + symbol: { name: 'runIndexCommand' }, + }); + }); + it('ranks source paths related to the phrase ahead of documentation history', () => { const result = searchGraph({ graphData, diff --git a/packages/core/tests/graphQuery/search/ranking.test.ts b/packages/core/tests/graphQuery/search/ranking.test.ts new file mode 100644 index 000000000..82a06bf88 --- /dev/null +++ b/packages/core/tests/graphQuery/search/ranking.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { rankSearchDocuments } from '../../../src/graphQuery/search/ranking'; + +describe('core/graphQuery/search BM25 ranking', () => { + it('tokenizes camelCase identifiers and boosts matching paths', () => { + const ranked = rankSearchDocuments('filter command', [ + { + id: 'parse-test', + path: 'tests/cli/parse.test.ts', + text: "it('parses compact scope and filter commands', () => {});", + }, + { + id: 'filter-command', + path: 'src/cli/filter/command.ts', + text: 'export function runFilterCommand() {}', + }, + ]); + + expect(ranked.map(result => result.id)).toEqual(['filter-command', 'parse-test']); + }); + + it('returns no fallback ranking for a single exact-search term', () => { + expect(rankSearchDocuments('runFilterCommand', [{ + id: 'filter-command', + path: 'src/cli/filter/command.ts', + text: 'export function runFilterCommand() {}', + }])).toEqual([]); + }); + + it('ignores common natural-language stop words', () => { + expect(rankSearchDocuments('the filter command', [ + { id: 'filter', path: 'src/filter/command.ts', text: '' }, + { id: 'other', path: 'src/other.ts', text: 'the command' }, + ])[0]?.id).toBe('filter'); + }); +}); From 4f76c1ec7ee1845ab1d7c18a7efd268d03ee3182 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 11:35:58 -0700 Subject: [PATCH 18/55] feat(cli): include source context in symbol queries --- packages/core/src/graphQuery/index.ts | 1 + packages/core/src/graphQuery/model.ts | 10 +++ packages/core/src/graphQuery/overview.ts | 74 ++++++++++++++++++- packages/core/src/index.ts | 1 + packages/core/src/workspace/requestQuery.ts | 11 ++- .../core/tests/graphQuery/overview.test.ts | 59 +++++++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) diff --git a/packages/core/src/graphQuery/index.ts b/packages/core/src/graphQuery/index.ts index de1577a6e..4d6d60d27 100644 --- a/packages/core/src/graphQuery/index.ts +++ b/packages/core/src/graphQuery/index.ts @@ -23,6 +23,7 @@ export type { GraphQuerySearchConfig, GraphQuerySearchMatch, GraphQuerySearchReport, + GraphQuerySourceContext, GraphQuerySort, GraphQueryResult, GraphQuerySymbolReport, diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index 29594eeab..874bdcec2 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -189,9 +189,19 @@ export interface GraphQuerySearchReport { }; } +export interface GraphQuerySourceContext { + filePath: string; + startLine: number; + endLine: number; + text: string; + truncated: boolean; + freshness: 'live'; +} + export interface GraphQueryOverviewReport { target: GraphQueryNodeReportItem; declaredSymbols: GraphQuerySymbolReport; + sourceContext?: GraphQuerySourceContext; outgoing: GraphQueryEdgeReport; incoming: GraphQueryEdgeReport; limits: { diff --git a/packages/core/src/graphQuery/overview.ts b/packages/core/src/graphQuery/overview.ts index 3778a73fd..dbe241460 100644 --- a/packages/core/src/graphQuery/overview.ts +++ b/packages/core/src/graphQuery/overview.ts @@ -6,6 +6,8 @@ import type { GraphQueryNodeReportItem, GraphQueryOverviewConfig, GraphQueryOverviewReport, + GraphQuerySourceContext, + GraphQuerySymbolReport, GraphQueryTargetNotFoundReport, } from './model'; import { toNodeReportItem } from './nodeReport'; @@ -15,6 +17,8 @@ import { toSymbolReportBase } from './symbols/metadata'; const DECLARED_SYMBOL_LIMIT = 25; const RELATIONSHIP_LIMIT = 25; +const SOURCE_CONTEXT_LINE_LIMIT = 80; +const SOURCE_CONTEXT_CHARACTER_LIMIT = 8_000; function symbolTarget(symbol: IAnalysisSymbol): GraphQueryNodeReportItem { return { @@ -36,6 +40,69 @@ function resolveTarget(data: GraphQueryData, selector: string): GraphQueryNodeRe return symbol ? symbolTarget(symbol) : undefined; } +function declarationKindRank(kind: string | undefined): number { + const ranks: Record = { + function: 0, + method: 0, + class: 1, + interface: 1, + type: 1, + enum: 1, + constant: 3, + variable: 3, + }; + return kind ? ranks[kind] ?? 2 : 2; +} + +function listOverviewSymbols(data: GraphQueryData, filePath: string): GraphQuerySymbolReport { + const complete = listGraphSymbols(data, { + filePath, + limit: Math.max(1, data.symbols?.length ?? 0), + }).symbols.sort((left, right) => ( + declarationKindRank(left.kind) - declarationKindRank(right.kind) + || left.name.localeCompare(right.name) + || (left.id ?? '').localeCompare(right.id ?? '') + )); + const symbols = complete.slice(0, DECLARED_SYMBOL_LIMIT); + return { + symbols, + page: { + offset: 0, + limit: DECLARED_SYMBOL_LIMIT, + returned: symbols.length, + total: complete.length, + nextOffset: complete.length > DECLARED_SYMBOL_LIMIT ? DECLARED_SYMBOL_LIMIT : null, + }, + }; +} + +function createSourceContext( + data: GraphQueryData, + symbol: IAnalysisSymbol | undefined, +): GraphQuerySourceContext | undefined { + if (!symbol) return undefined; + const sourceFile = data.sourceText?.files.find(file => file.filePath === symbol.filePath); + if (!sourceFile) return undefined; + const lines = sourceFile.content.split(/\r?\n/u); + const matchedLine = lines.findIndex(line => line.includes(symbol.name)); + const startIndex = Math.max(0, (symbol.range?.startLine ?? matchedLine + 1) - 1); + const requestedEnd = symbol.range?.endLine ?? startIndex + SOURCE_CONTEXT_LINE_LIMIT; + const endIndex = Math.min(lines.length, requestedEnd, startIndex + SOURCE_CONTEXT_LINE_LIMIT); + const completeText = lines.slice(startIndex, endIndex).join('\n'); + const text = completeText.slice(0, SOURCE_CONTEXT_CHARACTER_LIMIT); + return { + filePath: symbol.filePath, + startLine: startIndex + 1, + endLine: endIndex, + text, + truncated: (symbol.range + ? requestedEnd > endIndex + : startIndex + SOURCE_CONTEXT_LINE_LIMIT < lines.length) + || text.length < completeText.length, + freshness: 'live', + }; +} + export function inspectGraphTarget( data: GraphQueryData, config: GraphQueryOverviewConfig, @@ -60,12 +127,17 @@ export function inspectGraphTarget( edges: Object.fromEntries(data.graphData.edges.map(edge => [edge.kind, true])), }; const filePath = target.symbol?.filePath ?? target.path; + const targetSymbol = target.symbol + ? data.symbols?.find(symbol => symbol.id === target.path) + : undefined; + const sourceContext = createSourceContext(data, targetSymbol); return { target, declaredSymbols: target.symbol ? listGraphSymbols(data, { filePath: '__symbol-target__', limit: DECLARED_SYMBOL_LIMIT }) - : listGraphSymbols(data, { filePath, limit: DECLARED_SYMBOL_LIMIT }), + : listOverviewSymbols(data, filePath), + ...(sourceContext ? { sourceContext } : {}), outgoing: listGraphEdges(relationshipData.graphData, { from: target.path, scope: completeScope, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77b7f43c4..a10963c5d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -460,6 +460,7 @@ export type { GraphQuerySearchConfig, GraphQuerySearchMatch, GraphQuerySearchReport, + GraphQuerySourceContext, GraphQuerySort, GraphQuerySymbolReport, GraphQuerySymbolReportItem, diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index d460eb174..e924f9393 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -61,9 +61,18 @@ function executeWorkspaceGraphQuery( source, input.projection, ); + const target = typeof input.arguments.target === 'string' ? input.arguments.target : undefined; + const targetFilePath = target + ? snapshotFacts.symbols.find(symbol => symbol.id === target)?.filePath ?? target + : undefined; const sourceText = input.report === 'search' ? readWorkspaceQuerySourceText(workspaceRoot, graphData, source.indexedContentHashes) - : undefined; + : input.report === 'overview' && targetFilePath + ? readWorkspaceQuerySourceText(workspaceRoot, { + nodes: graphData.nodes.filter(node => node.id === targetFilePath), + edges: [], + }, source.indexedContentHashes) + : undefined; const queryResult = executeGraphQuery({ graphData, symbols: snapshotFacts.symbols, diff --git a/packages/core/tests/graphQuery/overview.test.ts b/packages/core/tests/graphQuery/overview.test.ts index b96be985f..6b2025770 100644 --- a/packages/core/tests/graphQuery/overview.test.ts +++ b/packages/core/tests/graphQuery/overview.test.ts @@ -82,6 +82,38 @@ describe('core/graphQuery target overview', () => { }); }); + it('prioritizes callable and type declarations over local constants', () => { + const crowdedSymbols: IAnalysisSymbol[] = [ + ...Array.from({ length: 30 }, (_, index) => ({ + id: `src/settings.ts#aValue${index}:constant`, + filePath: 'src/settings.ts', + name: `aValue${String(index).padStart(2, '0')}`, + kind: 'constant', + })), + { + id: 'src/settings.ts#readSettings:function', + filePath: 'src/settings.ts', + name: 'readSettings', + kind: 'function', + }, + ]; + + const result = inspectGraphTarget( + { graphData, symbols: crowdedSymbols, relations }, + { target: 'src/settings.ts' }, + ); + + expect('declaredSymbols' in result ? result.declaredSymbols.symbols[0] : undefined).toMatchObject({ + name: 'readSettings', + kind: 'function', + }); + expect('declaredSymbols' in result ? result.declaredSymbols.page : undefined).toMatchObject({ + returned: 25, + total: 31, + nextOffset: 25, + }); + }); + it('inspects an exact cached Symbol with its containing File provenance', () => { const result = inspectGraphTarget( { @@ -91,8 +123,23 @@ describe('core/graphQuery target overview', () => { filePath: 'src/settings.ts', name: 'readSettings', kind: 'function', + range: { startLine: 2, endLine: 4 }, }], relations, + sourceText: { + files: [{ + filePath: 'src/settings.ts', + content: [ + "import { readFile } from 'node:fs';", + 'export function readSettings() {', + " return JSON.parse(readFile('settings.json'));", + '}', + '', + ].join('\n'), + }], + filesScanned: 1, + filesSkipped: 0, + }, }, { target: 'src/settings.ts#readSettings:function' }, ); @@ -109,6 +156,18 @@ describe('core/graphQuery target overview', () => { }, }, declaredSymbols: { symbols: [] }, + sourceContext: { + filePath: 'src/settings.ts', + startLine: 2, + endLine: 4, + text: [ + 'export function readSettings() {', + " return JSON.parse(readFile('settings.json'));", + '}', + ].join('\n'), + truncated: false, + freshness: 'live', + }, }); }); From 5d790bedbcd021fde3f2503619a2e358139d0768 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 11:36:04 -0700 Subject: [PATCH 19/55] feat(cli): manage workspace settings safely --- packages/core/src/cli/command.ts | 4 + packages/core/src/cli/doctor/command.ts | 68 ++------------ packages/core/src/cli/help/command.ts | 23 ++++- packages/core/src/cli/parse.ts | 12 ++- packages/core/src/cli/parseSettings.ts | 55 +++++++++++ packages/core/src/cli/parseTypes.ts | 5 + packages/core/src/cli/settings/command.ts | 56 +++++++++++ packages/core/src/workspace/settings.ts | 3 + .../core/src/workspace/settingsStorage.ts | 60 +++++++----- .../core/src/workspace/settingsValidation.ts | 78 +++++++++++++++ packages/core/tests/cli/graphControls.test.ts | 17 ++++ packages/core/tests/cli/help/command.test.ts | 6 ++ packages/core/tests/cli/parse.test.ts | 36 +++++++ .../core/tests/cli/settings/command.test.ts | 94 +++++++++++++++++++ .../core/tests/workspace/settings.test.ts | 28 ++---- skills/codegraphy/SKILL.md | 9 +- 16 files changed, 449 insertions(+), 105 deletions(-) create mode 100644 packages/core/src/cli/parseSettings.ts create mode 100644 packages/core/src/cli/settings/command.ts create mode 100644 packages/core/src/workspace/settingsValidation.ts create mode 100644 packages/core/tests/cli/settings/command.test.ts diff --git a/packages/core/src/cli/command.ts b/packages/core/src/cli/command.ts index 39ecd6d80..d0cfc1f7e 100644 --- a/packages/core/src/cli/command.ts +++ b/packages/core/src/cli/command.ts @@ -6,6 +6,7 @@ import { runPluginsCommand } from './plugins/command'; import { runQueryCommand } from './query/command'; import { runScopeCommand } from './scope/command'; import { runStatusCommand } from './status/command'; +import { runSettingsCommand } from './settings/command'; import type { CliCommand } from './parse'; import { createDiagnosticEvent, formatDiagnosticEventLine } from '../diagnostics/events'; @@ -83,6 +84,9 @@ export async function runCliCommand( case 'scope': result = runScopeCommand(command); break; + case 'settings': + result = runSettingsCommand(command); + break; case 'status': result = runStatusCommand(command.workspacePath, undefined, { verbose: command.verbose, diff --git a/packages/core/src/cli/doctor/command.ts b/packages/core/src/cli/doctor/command.ts index d85d8657c..43bbfa5f3 100644 --- a/packages/core/src/cli/doctor/command.ts +++ b/packages/core/src/cli/doctor/command.ts @@ -4,67 +4,18 @@ import { readCodeGraphyInstalledPluginCache } from '../../plugins/installedCache import { resolveCodeGraphyWorkspacePath } from '../../workspace/requestPaths'; import { CODEGRAPHY_MARKDOWN_PLUGIN_ID, + createInitialCodeGraphyWorkspaceSettings, readCodeGraphyWorkspaceSettingsOrInitial, + WorkspaceSettingsError, } from '../../workspace/settings'; import { getWorkspaceSettingsPath } from '../../workspace/paths'; import { readCodeGraphyWorkspaceStatus } from '../../workspace/status'; import type { CommandExecutionResult } from '../command'; import type { CliCommand } from '../parseTypes'; -import { hasSupportedRawPluginIdentity } from '../../workspace/settingsPlugins'; import { inspectWorkspaceAnalysisDatabase } from '../../graphCache/database/storage'; import { readCodeGraphyWorkspaceMeta } from '../../workspace/meta'; import { createDoctorCacheCheck } from './cacheCheck/model'; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function isStringArray(value: unknown): boolean { - return Array.isArray(value) && value.every(entry => typeof entry === 'string'); -} - -function isBooleanRecord(value: unknown): boolean { - return isRecord(value) && Object.values(value).every(entry => typeof entry === 'boolean'); -} - -function validateKnownSettings(value: Record): string | undefined { - const stringArrays = ['include', 'filterPatterns', 'disabledCustomFilterPatterns'] as const; - for (const key of stringArrays) { - if (key in value && !isStringArray(value[key])) return `${key} must be an array of strings`; - } - const booleans = ['respectGitignore'] as const; - for (const key of booleans) { - if (key in value && typeof value[key] !== 'boolean') return `${key} must be a boolean`; - } - if ('maxFiles' in value && (typeof value.maxFiles !== 'number' || !Number.isFinite(value.maxFiles))) { - return 'maxFiles must be a finite number'; - } - if ('nodeVisibility' in value && !isBooleanRecord(value.nodeVisibility)) { - return 'nodeVisibility must be an object of boolean values'; - } - if ('edgeVisibility' in value && !isBooleanRecord(value.edgeVisibility)) { - return 'edgeVisibility must be an object of boolean values'; - } - if ('pluginData' in value && !isRecord(value.pluginData)) return 'pluginData must be an object'; - if ('plugins' in value) { - if (!Array.isArray(value.plugins)) return 'plugins must be an array'; - for (const entry of value.plugins) { - if (!isRecord(entry)) return 'plugins entries must be objects'; - if ('id' in entry && typeof entry.id !== 'string') return 'plugin id must be a string'; - if ('package' in entry && typeof entry.package !== 'string') return 'plugin package must be a string'; - if ('enabled' in entry && typeof entry.enabled !== 'boolean') return 'plugin enabled must be a boolean'; - if ('options' in entry && !isRecord(entry.options)) return 'plugin options must be an object'; - if ('disabledFilterPatterns' in entry && !isStringArray(entry.disabledFilterPatterns)) { - return 'plugin disabledFilterPatterns must be an array of strings'; - } - if (!hasSupportedRawPluginIdentity(entry)) { - return 'plugin entry must have a nonblank id with enabled, or a nonblank package'; - } - } - } - return undefined; -} - function readSettingsCheck(workspaceRoot: string): Record { const settingsPath = getWorkspaceSettingsPath(workspaceRoot); if (!fs.existsSync(settingsPath)) { @@ -75,16 +26,15 @@ function readSettingsCheck(workspaceRoot: string): Record { }; } try { - const value: unknown = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - if (!isRecord(value)) throw new Error('expected a JSON object'); - const validationError = validateKnownSettings(value); - if (validationError) throw new Error(validationError); + readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot); return { ok: true, path: settingsPath }; } catch (error) { return { ok: false, path: settingsPath, - message: error instanceof Error ? error.message : String(error), + message: error instanceof WorkspaceSettingsError + ? error.reason + : error instanceof Error ? error.message : String(error), action: 'Repair `.codegraphy/settings.json`, then rerun `codegraphy doctor`.', }; } @@ -95,10 +45,12 @@ export function runDoctorCommand(command: CliCommand): CommandExecutionResult { const runtimeMajor = Number(process.versions.node.split('.')[0]); const runtimeOk = runtimeMajor >= 20 && runtimeMajor < 23; const settingsCheck = readSettingsCheck(workspaceRoot); - const status = readCodeGraphyWorkspaceStatus(workspaceRoot); + const settings = settingsCheck.ok + ? readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot) + : createInitialCodeGraphyWorkspaceSettings(); + const status = readCodeGraphyWorkspaceStatus(workspaceRoot, { settings }); const meta = readCodeGraphyWorkspaceMeta(workspaceRoot); const cacheInspection = inspectWorkspaceAnalysisDatabase(workspaceRoot); - const settings = readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot); const activity = createPluginActivityState({ settings, installedPlugins: readCodeGraphyInstalledPluginCache().plugins, diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 77f6ca9bd..0fcdb016d 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -17,6 +17,10 @@ const ROOT_HELP = [ ' codegraphy doctor Diagnose runtime, settings, cache, and Plugins', '', 'Settings commands:', + ' codegraphy settings Read all effective workspace settings', + ' codegraphy settings get Read one effective workspace setting', + ' codegraphy settings set Validate and persist one workspace setting', + ' codegraphy settings unset Restore one setting to its default', ' codegraphy filter Read or change persisted Filters', ' codegraphy filter Add or remove one Filter pattern', ' codegraphy scope Read or change persisted Graph Scope', @@ -89,11 +93,27 @@ const COMMAND_HELP: Record = { 'Failure envelope: Includes the completed checks in error.details.', 'Example: codegraphy doctor', ].join('\n'), + settings: [ + 'Usage: codegraphy settings [get | set | unset ]', + '', + 'Read or safely update workspace settings such as maxFiles, include, or respectGitignore.', + 'Values passed to set are JSON: numbers and booleans are unquoted; arrays and objects need shell quotes.', + 'Mutations validate the complete persisted file and never replace malformed settings with defaults.', + '', + 'Effects: Read-only without set/unset. Set and unset write .codegraphy/settings.json.', + 'Output: JSON effective values; mutations report previous/value and whether Indexing is required.', + 'Examples:', + ' codegraphy settings get maxFiles', + ' codegraphy settings set maxFiles 2500', + " codegraphy settings set include '[\"packages/**/*.ts\"]'", + ' codegraphy settings unset maxFiles', + ].join('\n'), query: [ 'Usage: codegraphy query ', '', 'Inspect one exact File path or Symbol Node ID returned by `codegraphy search`.', - 'Returns the target, its declared AST Symbols, and incoming and outgoing Relationships.', + 'Returns the target, its declared AST Symbols, incoming and outgoing Relationships,', + 'and bounded live source context when the target is an exact Symbol ID.', 'Use dependencies, dependents, or path only when this bounded overview needs continuation.', '', 'Arguments:', @@ -121,6 +141,7 @@ const COMMAND_HELP: Record = { '', 'Discover matching live source lines, cached AST Symbols, and indexed File or concept Nodes.', 'Literal matching is case-insensitive. Add a `*` wildcard to span characters on one line or name.', + 'When a natural multi-term phrase has few literal matches, BM25 ranks relevant indexed Files.', 'Every Symbol match includes the File it came from. Text matches include line, column, and excerpt.', '', 'Arguments:', diff --git a/packages/core/src/cli/parse.ts b/packages/core/src/cli/parse.ts index 8cf28e21c..5114b15e6 100644 --- a/packages/core/src/cli/parse.ts +++ b/packages/core/src/cli/parse.ts @@ -3,9 +3,15 @@ import { isHelpCommandName } from './parseHelp'; import { isPluginCommand, parsePluginsCommand } from './parsePlugins'; import { isGraphQueryReport, parseQueryCommand } from './parseQuery'; import { parseWorkspaceCommand } from './parseWorkspace'; +import { parseSettingsCommand } from './parseSettings'; import type { CliCommand } from './parseTypes'; -export type { CliCommand, CliCommandName, PluginsCommandAction } from './parseTypes'; +export type { + CliCommand, + CliCommandName, + PluginsCommandAction, + WorkspaceSettingsCommandAction, +} from './parseTypes'; interface GlobalFlags { argv: string[]; @@ -70,6 +76,7 @@ function isKnownCommandName(name: string): boolean { || name === 'index' || name === 'plugins' || name === 'scope' + || name === 'settings' || name === 'status' || name === 'query' || isGraphQueryReport(name); @@ -119,6 +126,9 @@ export function parseCliCommand(argv: string[]): CliCommand { case 'scope': command = parseScopeCommand(rest); break; + case 'settings': + command = parseSettingsCommand(rest); + break; case 'filter': command = parseFilterCommand(rest); break; diff --git a/packages/core/src/cli/parseSettings.ts b/packages/core/src/cli/parseSettings.ts new file mode 100644 index 000000000..0feea00ed --- /dev/null +++ b/packages/core/src/cli/parseSettings.ts @@ -0,0 +1,55 @@ +import type { CliCommand } from './parseTypes'; + +const WORKSPACE_SETTING_KEYS = new Set([ + 'maxFiles', + 'include', + 'respectGitignore', + 'filterPatterns', + 'disabledCustomFilterPatterns', + 'nodeVisibility', + 'edgeVisibility', + 'plugins', + 'interfaces', + 'pluginData', +]); + +function parseError(message: string): CliCommand { + return { name: 'settings', parseError: message }; +} + +function readOperands(argv: string[]): string[] { + const terminator = argv.indexOf('--'); + return terminator < 0 ? argv : [...argv.slice(0, terminator), ...argv.slice(terminator + 1)]; +} + +function validateKey(key: string | undefined): CliCommand | undefined { + if (!key) return parseError('settings command requires a workspace setting key'); + return WORKSPACE_SETTING_KEYS.has(key) + ? undefined + : parseError(`Unknown workspace setting: ${key}`); +} + +export function parseSettingsCommand(argv: string[]): CliCommand { + const [action, key, value, extra] = readOperands(argv); + if (!action) return { name: 'settings' }; + if (!['get', 'set', 'unset'].includes(action)) return parseError(`Unknown settings action: ${action}`); + const invalidKey = validateKey(key); + if (invalidKey) return invalidKey; + if (action === 'get' || action === 'unset') { + return extra || value + ? parseError(`Unexpected argument for settings ${action}: ${value ?? extra}`) + : { name: 'settings', settingsAction: action, settingsKey: key }; + } + if (value === undefined) return parseError('settings set requires a JSON value'); + if (extra) return parseError(`Unexpected argument for settings set: ${extra}`); + try { + return { + name: 'settings', + settingsAction: 'set', + settingsKey: key, + settingsValue: JSON.parse(value) as unknown, + }; + } catch { + return parseError(`settings set value must be valid JSON: ${value}`); + } +} diff --git a/packages/core/src/cli/parseTypes.ts b/packages/core/src/cli/parseTypes.ts index b3423c719..742d01b82 100644 --- a/packages/core/src/cli/parseTypes.ts +++ b/packages/core/src/cli/parseTypes.ts @@ -11,9 +11,11 @@ export type CliCommandName = | 'plugins' | 'query' | 'scope' + | 'settings' | 'status' | 'version'; export type PluginsCommandAction = 'disable' | 'enable' | 'help' | 'inherit' | 'link' | 'list' | 'register'; +export type WorkspaceSettingsCommandAction = 'get' | 'set' | 'unset'; export interface CliCommand { name: CliCommandName; @@ -27,6 +29,9 @@ export interface CliCommand { parseError?: string; projection?: WorkspaceGraphQueryProjection; report?: GraphQueryReport; + settingsAction?: WorkspaceSettingsCommandAction; + settingsKey?: string; + settingsValue?: unknown; verbose?: boolean; workspacePath?: string; } diff --git a/packages/core/src/cli/settings/command.ts b/packages/core/src/cli/settings/command.ts new file mode 100644 index 000000000..11687be10 --- /dev/null +++ b/packages/core/src/cli/settings/command.ts @@ -0,0 +1,56 @@ +import { getWorkspaceSettingsPath } from '../../workspace/paths'; +import { resolveCodeGraphyWorkspacePath } from '../../workspace/requestPaths'; +import { + patchCodeGraphyWorkspaceSettings, + readCodeGraphyWorkspaceSettingsOrInitial, + removeCodeGraphyWorkspaceSetting, +} from '../../workspace/settings'; +import { readCodeGraphyWorkspaceStatus } from '../../workspace/status'; +import type { CommandExecutionResult } from '../command'; +import type { CliCommand } from '../parseTypes'; + +function settingValue(settings: object, key: string): unknown { + return Reflect.get(settings, key) as unknown; +} + +export function runSettingsCommand(command: CliCommand): CommandExecutionResult { + const workspaceRoot = resolveCodeGraphyWorkspacePath(command.workspacePath, process.cwd()); + const settingsPath = getWorkspaceSettingsPath(workspaceRoot); + const current = readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot); + const key = command.settingsKey; + + if (!command.settingsAction) { + return { + exitCode: 0, + output: JSON.stringify({ workspaceRoot, settingsPath, settings: current }), + }; + } + if (!key) throw new Error('Workspace setting key is required'); + const previous = settingValue(current, key); + if (command.settingsAction === 'get') { + return { + exitCode: 0, + output: JSON.stringify({ workspaceRoot, settingsPath, key, value: previous }), + }; + } + + if (command.settingsAction === 'set') { + patchCodeGraphyWorkspaceSettings(workspaceRoot, { [key]: command.settingsValue }); + } else { + removeCodeGraphyWorkspaceSetting(workspaceRoot, key); + } + const updated = readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot); + const status = readCodeGraphyWorkspaceStatus(workspaceRoot); + return { + exitCode: 0, + output: JSON.stringify({ + workspaceRoot, + settingsPath, + key, + previous, + value: settingValue(updated, key), + indexRequired: status.state !== 'fresh', + ...(status.state === 'fresh' ? {} : { action: 'Run `codegraphy index` before querying cached AST or Relationships.' }), + }), + }; +} diff --git a/packages/core/src/workspace/settings.ts b/packages/core/src/workspace/settings.ts index e64e2a542..984529809 100644 --- a/packages/core/src/workspace/settings.ts +++ b/packages/core/src/workspace/settings.ts @@ -14,8 +14,11 @@ export { ensureCodeGraphyWorkspaceSettings, readCodeGraphyWorkspaceSettings, readCodeGraphyWorkspaceSettingsOrInitial, + readRawWorkspaceSettingsOrInitial, patchCodeGraphyWorkspaceSettings, patchCodeGraphyWorkspaceSettingRecord, + removeCodeGraphyWorkspaceSetting, writeCodeGraphyWorkspacePluginData, writeCodeGraphyWorkspaceSettings, + WorkspaceSettingsError, } from './settingsStorage'; diff --git a/packages/core/src/workspace/settingsStorage.ts b/packages/core/src/workspace/settingsStorage.ts index ce3479943..61183f3bb 100644 --- a/packages/core/src/workspace/settingsStorage.ts +++ b/packages/core/src/workspace/settingsStorage.ts @@ -1,23 +1,41 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getWorkspaceSettingsPath } from './paths'; -import { - createDefaultCodeGraphyWorkspaceSettings, - createInitialCodeGraphyWorkspaceSettings, -} from './settingsDefaults'; +import { createInitialCodeGraphyWorkspaceSettings } from './settingsDefaults'; import { normalizeCodeGraphyWorkspaceSettings } from './settingsNormalize'; import { unknownRecordSchema } from '../values'; import type { CodeGraphyWorkspaceSettings } from './settingsContracts'; import { hasSupportedRawPluginIdentity } from './settingsPlugins'; +import { validateWorkspaceSettingsRecord } from './settingsValidation'; import { CODEGRAPHY_MARKDOWN_PLUGIN_ID, CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, } from './settingsDefaults'; +export class WorkspaceSettingsError extends Error { + constructor( + readonly settingsPath: string, + readonly reason: string, + ) { + super(`Invalid CodeGraphy workspace settings at ${settingsPath}: ${reason}`); + } +} + function writeRawWorkspaceSettings(workspaceRoot: string, settings: Record): void { const settingsPath = getWorkspaceSettingsPath(workspaceRoot); + const validated = validateWorkspaceSettingsRecord(settings); fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); + fs.writeFileSync(settingsPath, `${JSON.stringify(validated, null, 2)}\n`); +} + +function readRawWorkspaceSettings(workspaceRoot: string): Record { + const settingsPath = getWorkspaceSettingsPath(workspaceRoot); + try { + return validateWorkspaceSettingsRecord(JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new WorkspaceSettingsError(settingsPath, message); + } } function readPluginIdentity(value: unknown): string | undefined { @@ -77,13 +95,9 @@ function mergeRawPluginEntries(rawValue: unknown, plugins: CodeGraphyWorkspaceSe export function readCodeGraphyWorkspaceSettings( workspaceRoot: string, ): CodeGraphyWorkspaceSettings { - try { - return normalizeCodeGraphyWorkspaceSettings( - JSON.parse(fs.readFileSync(getWorkspaceSettingsPath(workspaceRoot), 'utf-8')), - ); - } catch { - return createDefaultCodeGraphyWorkspaceSettings(); - } + return fs.existsSync(getWorkspaceSettingsPath(workspaceRoot)) + ? normalizeCodeGraphyWorkspaceSettings(readRawWorkspaceSettings(workspaceRoot)) + : normalizeCodeGraphyWorkspaceSettings({}); } export function readCodeGraphyWorkspaceSettingsOrInitial( @@ -110,15 +124,10 @@ export function writeCodeGraphyWorkspaceSettings( }); } -function readRawWorkspaceSettingsOrInitial(workspaceRoot: string): Record { - try { - const parsed = unknownRecordSchema.safeParse( - JSON.parse(fs.readFileSync(getWorkspaceSettingsPath(workspaceRoot), 'utf-8')), - ); - return parsed.success ? { ...parsed.data } : { ...createInitialCodeGraphyWorkspaceSettings() }; - } catch { - return { ...createInitialCodeGraphyWorkspaceSettings() }; - } +export function readRawWorkspaceSettingsOrInitial(workspaceRoot: string): Record { + return fs.existsSync(getWorkspaceSettingsPath(workspaceRoot)) + ? readRawWorkspaceSettings(workspaceRoot) + : { ...createInitialCodeGraphyWorkspaceSettings() }; } export function patchCodeGraphyWorkspaceSettings( @@ -131,6 +140,15 @@ export function patchCodeGraphyWorkspaceSettings( }); } +export function removeCodeGraphyWorkspaceSetting( + workspaceRoot: string, + key: string, +): void { + const settings = readRawWorkspaceSettingsOrInitial(workspaceRoot); + delete settings[key]; + writeRawWorkspaceSettings(workspaceRoot, settings); +} + export function patchCodeGraphyWorkspaceSettingRecord( workspaceRoot: string, key: string, diff --git a/packages/core/src/workspace/settingsValidation.ts b/packages/core/src/workspace/settingsValidation.ts new file mode 100644 index 000000000..bcfa98552 --- /dev/null +++ b/packages/core/src/workspace/settingsValidation.ts @@ -0,0 +1,78 @@ +import { hasSupportedRawPluginIdentity } from './settingsPlugins'; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isStringArray(value: unknown): boolean { + return Array.isArray(value) && value.every(entry => typeof entry === 'string'); +} + +function isBooleanRecord(value: unknown): boolean { + return isRecord(value) && Object.values(value).every(entry => typeof entry === 'boolean'); +} + +function validatePluginSettings(value: unknown): string | undefined { + if (!Array.isArray(value)) return 'plugins must be an array'; + for (const entry of value) { + if (!isRecord(entry)) return 'plugins entries must be objects'; + if ('id' in entry && typeof entry.id !== 'string') return 'plugin id must be a string'; + if ('package' in entry && typeof entry.package !== 'string') return 'plugin package must be a string'; + if ('activation' in entry && !['inherit', 'enabled', 'disabled'].includes(String(entry.activation))) { + return 'plugin activation must be inherit, enabled, or disabled'; + } + if ('enabled' in entry && typeof entry.enabled !== 'boolean') return 'plugin enabled must be a boolean'; + if ('options' in entry && !isRecord(entry.options)) return 'plugin options must be an object'; + if ('disabledFilterPatterns' in entry && !isStringArray(entry.disabledFilterPatterns)) { + return 'plugin disabledFilterPatterns must be an array of strings'; + } + if (!hasSupportedRawPluginIdentity(entry)) { + return 'plugin entry must have a nonblank id with activation, or a nonblank package'; + } + } + return undefined; +} + +function validateInterfaceSettings(value: unknown): string | undefined { + if (!Array.isArray(value)) return 'interfaces must be an array'; + for (const entry of value) { + if (!isRecord(entry) || typeof entry.id !== 'string' || !entry.id.trim() || !('data' in entry)) { + return 'interface entries must have a nonblank id and data'; + } + } + return undefined; +} + +export function validateWorkspaceSettingsRecord(value: unknown): Record { + if (!isRecord(value)) throw new Error('workspace settings must be a JSON object'); + const stringArrays = ['include', 'filterPatterns', 'disabledCustomFilterPatterns'] as const; + for (const key of stringArrays) { + if (key in value && !isStringArray(value[key])) throw new Error(`${key} must be an array of strings`); + } + if ('respectGitignore' in value && typeof value.respectGitignore !== 'boolean') { + throw new Error('respectGitignore must be a boolean'); + } + if ('maxFiles' in value && ( + typeof value.maxFiles !== 'number' + || !Number.isSafeInteger(value.maxFiles) + || value.maxFiles < 1 + )) { + throw new Error('maxFiles must be a positive integer'); + } + if ('nodeVisibility' in value && !isBooleanRecord(value.nodeVisibility)) { + throw new Error('nodeVisibility must be an object of boolean values'); + } + if ('edgeVisibility' in value && !isBooleanRecord(value.edgeVisibility)) { + throw new Error('edgeVisibility must be an object of boolean values'); + } + if ('pluginData' in value && !isRecord(value.pluginData)) throw new Error('pluginData must be an object'); + if ('plugins' in value) { + const error = validatePluginSettings(value.plugins); + if (error) throw new Error(error); + } + if ('interfaces' in value) { + const error = validateInterfaceSettings(value.interfaces); + if (error) throw new Error(error); + } + return { ...value }; +} diff --git a/packages/core/tests/cli/graphControls.test.ts b/packages/core/tests/cli/graphControls.test.ts index 1551f3fba..ee156ed3a 100644 --- a/packages/core/tests/cli/graphControls.test.ts +++ b/packages/core/tests/cli/graphControls.test.ts @@ -70,6 +70,23 @@ describe('cli graph controls', () => { expect(stderr).not.toHaveBeenCalled(); }); + it('refuses to hide or overwrite malformed persisted settings', async () => { + const workspace = await createWorkspace(); + const settingsPath = path.join(workspace, '.codegraphy/settings.json'); + await fs.writeFile(settingsPath, '{ malformed'); + const before = await fs.readFile(settingsPath, 'utf8'); + const stderr = vi.fn(); + + await expect(runCli(['-C', workspace, 'filter'], { stderr })).resolves.toBe(1); + await expect(runCli(['-C', workspace, 'filter', 'add', '**/generated/**'], { stderr })).resolves.toBe(1); + + expect(stderr).toHaveBeenCalledTimes(2); + expect(JSON.parse(stderr.mock.calls[0][0])).toMatchObject({ + error: { code: 'command_failed', message: expect.stringContaining('settings.json') }, + }); + await expect(fs.readFile(settingsPath, 'utf8')).resolves.toBe(before); + }); + it('lists discoverable scope and mutates filter patterns idempotently', async () => { const workspace = await createWorkspace(); const outputs: string[] = []; diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 0bff6c304..96d64bbd6 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -8,6 +8,7 @@ describe('cli/help/command', () => { expect(result.exitCode).toBe(0); expect(result.output).toContain('codegraphy doctor'); expect(result.output).not.toContain('codegraphy batch'); + expect(result.output).toContain('codegraphy settings'); expect(result.output).toContain('codegraphy search '); expect(result.output).toContain('codegraphy query '); expect(result.output).toContain('codegraphy dependencies '); @@ -32,6 +33,7 @@ describe('cli/help/command', () => { expect(output).toContain('3. Discover source and AST Symbols with search; inspect an exact result with query.'); expect(output).toContain('4. Continue through the shaped graph with dependencies, dependents, or path.'); expect(output).toContain('codegraphy index Create or update the Graph Cache'); + expect(output).toContain('codegraphy settings set '); expect(output).toContain('codegraphy filter Read or change persisted Filters'); expect(output).toContain('codegraphy dependencies List outgoing Relationships'); expect(output).toContain('Exit status: 0 success, 1 operational failure, 2 invalid invocation.'); @@ -43,11 +45,15 @@ describe('cli/help/command', () => { expect(createHelpResult(['query']).output).toContain('Usage: codegraphy query '); expect(createHelpResult(['query']).output).toContain('declared AST Symbols'); expect(createHelpResult(['query']).output).toContain('incoming and outgoing Relationships'); + expect(createHelpResult(['query']).output).toContain('live source context'); + expect(createHelpResult(['settings']).output).toContain('settings set maxFiles 2500'); + expect(createHelpResult(['settings']).output).toContain('never replace malformed settings with defaults'); expect(createHelpResult(['nodes']).output).toContain('Usage: codegraphy nodes'); expect(createHelpResult(['search']).output).toContain('Usage: codegraphy search'); expect(createHelpResult(['search']).output).toContain('live source lines'); expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); + expect(createHelpResult(['search']).output).toContain('BM25'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); diff --git a/packages/core/tests/cli/parse.test.ts b/packages/core/tests/cli/parse.test.ts index 149c84fa0..e892c176c 100644 --- a/packages/core/tests/cli/parse.test.ts +++ b/packages/core/tests/cli/parse.test.ts @@ -60,11 +60,47 @@ describe('cli/parse', () => { }); }); + it('parses structured workspace settings reads and mutations', () => { + expect(parseCliCommand(['settings'])).toEqual({ name: 'settings' }); + expect(parseCliCommand(['settings', 'get', 'maxFiles'])).toEqual({ + name: 'settings', + settingsAction: 'get', + settingsKey: 'maxFiles', + }); + expect(parseCliCommand(['settings', 'set', 'maxFiles', '2500'])).toEqual({ + name: 'settings', + settingsAction: 'set', + settingsKey: 'maxFiles', + settingsValue: 2500, + }); + expect(parseCliCommand(['settings', 'set', 'include', '["packages/**/*.ts"]'])).toEqual({ + name: 'settings', + settingsAction: 'set', + settingsKey: 'include', + settingsValue: ['packages/**/*.ts'], + }); + expect(parseCliCommand(['settings', 'unset', 'maxFiles'])).toEqual({ + name: 'settings', + settingsAction: 'unset', + settingsKey: 'maxFiles', + }); + expect(parseCliCommand(['settings', 'set', 'maxFiles', 'many'])).toMatchObject({ + parseError: 'settings set value must be valid JSON: many', + }); + expect(parseCliCommand(['settings', 'set', 'unknown', '1'])).toMatchObject({ + parseError: 'Unknown workspace setting: unknown', + }); + }); + it('parses scoped help only for public commands', () => { expect(parseCliCommand(['help', 'dependencies'])).toEqual({ name: 'help', helpPath: ['dependencies'], }); + expect(parseCliCommand(['settings', '--help'])).toEqual({ + name: 'help', + helpPath: ['settings'], + }); expect(parseCliCommand(['scope', '--help'])).toEqual({ name: 'help', helpPath: ['scope'], diff --git a/packages/core/tests/cli/settings/command.test.ts b/packages/core/tests/cli/settings/command.test.ts new file mode 100644 index 000000000..5b2b4e1cb --- /dev/null +++ b/packages/core/tests/cli/settings/command.test.ts @@ -0,0 +1,94 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { runCli } from '../../../src/cli/run'; + +async function createWorkspace(settings: unknown): Promise { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-cli-settings-')); + await fs.mkdir(path.join(workspace, '.codegraphy'), { recursive: true }); + await fs.writeFile( + path.join(workspace, '.codegraphy/settings.json'), + typeof settings === 'string' ? settings : `${JSON.stringify(settings, null, 2)}\n`, + ); + return workspace; +} + +describe('cli/settings command', () => { + it('reads all effective settings or one requested value', async () => { + const workspace = await createWorkspace({ version: 1, maxFiles: 1200, futureSetting: 'preserved' }); + const outputs: string[] = []; + const stdout = (output: string): void => { outputs.push(output); }; + + await expect(runCli(['-C', workspace, 'settings'], { stdout })).resolves.toBe(0); + await expect(runCli(['-C', workspace, 'settings', 'get', 'maxFiles'], { stdout })).resolves.toBe(0); + + expect(JSON.parse(outputs[0])).toMatchObject({ + command: 'settings', + data: { + workspaceRoot: workspace, + settings: { maxFiles: 1200, respectGitignore: true }, + }, + }); + expect(JSON.parse(outputs[1])).toMatchObject({ + command: 'settings', + data: { workspaceRoot: workspace, key: 'maxFiles', value: 1200 }, + }); + }); + + it('sets and unsets a validated setting without losing unknown fields', async () => { + const workspace = await createWorkspace({ version: 1, maxFiles: 1200, futureSetting: 'preserved' }); + const outputs: string[] = []; + const stdout = (output: string): void => { outputs.push(output); }; + + await expect(runCli(['-C', workspace, 'settings', 'set', 'maxFiles', '2500'], { stdout })).resolves.toBe(0); + expect(JSON.parse(outputs[0])).toMatchObject({ + data: { + key: 'maxFiles', + previous: 1200, + value: 2500, + indexRequired: true, + }, + }); + expect(JSON.parse(await fs.readFile(path.join(workspace, '.codegraphy/settings.json'), 'utf8'))).toMatchObject({ + maxFiles: 2500, + futureSetting: 'preserved', + }); + + await expect(runCli(['-C', workspace, 'settings', 'unset', 'maxFiles'], { stdout })).resolves.toBe(0); + expect(JSON.parse(outputs[1])).toMatchObject({ + data: { key: 'maxFiles', previous: 2500, value: 1000, indexRequired: true }, + }); + const raw = JSON.parse(await fs.readFile(path.join(workspace, '.codegraphy/settings.json'), 'utf8')); + expect(raw).not.toHaveProperty('maxFiles'); + expect(raw.futureSetting).toBe('preserved'); + }); + + it('rejects invalid values and leaves the persisted bytes unchanged', async () => { + const workspace = await createWorkspace({ version: 1, maxFiles: 1200 }); + const settingsPath = path.join(workspace, '.codegraphy/settings.json'); + const before = await fs.readFile(settingsPath, 'utf8'); + const stderr = vi.fn(); + + await expect(runCli(['-C', workspace, 'settings', 'set', 'maxFiles', '-1'], { stderr })).resolves.toBe(1); + + expect(JSON.parse(stderr.mock.calls[0][0])).toMatchObject({ + error: { code: 'command_failed', message: 'maxFiles must be a positive integer' }, + }); + await expect(fs.readFile(settingsPath, 'utf8')).resolves.toBe(before); + }); + + it('does not overwrite malformed persisted settings', async () => { + const workspace = await createWorkspace('{ malformed'); + const settingsPath = path.join(workspace, '.codegraphy/settings.json'); + const before = await fs.readFile(settingsPath, 'utf8'); + const stderr = vi.fn(); + + await expect(runCli(['-C', workspace, 'settings', 'set', 'maxFiles', '2500'], { stderr })).resolves.toBe(1); + + expect(JSON.parse(stderr.mock.calls[0][0])).toMatchObject({ + error: { code: 'command_failed', message: expect.stringContaining('settings.json') }, + }); + await expect(fs.readFile(settingsPath, 'utf8')).resolves.toBe(before); + }); +}); diff --git a/packages/core/tests/workspace/settings.test.ts b/packages/core/tests/workspace/settings.test.ts index baaa246d7..a0b73fea2 100644 --- a/packages/core/tests/workspace/settings.test.ts +++ b/packages/core/tests/workspace/settings.test.ts @@ -70,7 +70,7 @@ describe('CodeGraphy Workspace settings', () => { await expect(fs.access(getWorkspaceSettingsPath(workspaceRoot))).rejects.toThrow(); }); - it('normalizes workspace plugin entries from settings.json', async () => { + it('rejects malformed workspace plugin entries from settings.json', async () => { const workspaceRoot = await createWorkspace(); await fs.mkdir(path.dirname(getWorkspaceSettingsPath(workspaceRoot)), { recursive: true }); await fs.writeFile( @@ -92,15 +92,9 @@ describe('CodeGraphy Workspace settings', () => { 'utf-8', ); - expect(readCodeGraphyWorkspaceSettings(workspaceRoot)).toMatchObject({ - maxFiles: 50, - plugins: [{ - id: 'codegraphy.vue', - activation: 'disabled', - disabledFilterPatterns: ['**/__pycache__/**'], - options: { includeTests: true }, - }], - }); + expect(() => readCodeGraphyWorkspaceSettings(workspaceRoot)).toThrow( + 'plugin disabledFilterPatterns must be an array of strings', + ); }); it('keeps only the last settings entry for each plugin id', () => { @@ -190,8 +184,7 @@ describe('CodeGraphy Workspace settings', () => { enabled: true, futureMarkdownSetting: 'keep', }, - { id: 'future.plugin', futureOnlyShape: true }, - 3, + { id: 'future.plugin', activation: 'disabled', futureOnlyShape: true }, ], })); @@ -204,8 +197,7 @@ describe('CodeGraphy Workspace settings', () => { package: '@codegraphy-dev/plugin-markdown', futureMarkdownSetting: 'keep', }), - { id: 'future.plugin', futureOnlyShape: true }, - 3, + { id: 'future.plugin', activation: 'disabled', futureOnlyShape: true }, ])); }); @@ -247,18 +239,14 @@ describe('CodeGraphy Workspace settings', () => { await fs.writeFile(getWorkspaceSettingsPath(workspaceRoot), JSON.stringify({ plugins: [ { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, activation: 'enabled' }, - { id: 'future.plugin', futureOnlyShape: true }, - 3, + { id: 'future.plugin', activation: 'disabled', futureOnlyShape: true }, ], })); const settings = readCodeGraphyWorkspaceSettings(workspaceRoot); writeCodeGraphyWorkspaceSettings(workspaceRoot, { ...settings, plugins: [] }); - expect(JSON.parse(await fs.readFile(getWorkspaceSettingsPath(workspaceRoot), 'utf-8')).plugins).toEqual([ - { id: 'future.plugin', futureOnlyShape: true }, - 3, - ]); + expect(JSON.parse(await fs.readFile(getWorkspaceSettingsPath(workspaceRoot), 'utf-8')).plugins).toEqual([]); }); it('returns defaults for non-object settings values', () => { diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 3c5d2ae1c..c73e14d2a 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -15,14 +15,14 @@ Use CodeGraphy to identify the smallest set of source files worth reading. It pr 2. Read the returned source files and lines. 3. Use a narrow continuation command only when the overview is insufficient. -If a command reports `graph_cache_not_found`, run `codegraphy index` once and retry. Search reports whether live source differs from its cached Symbol facts in `data.sources.symbols.cacheState`; do not run `status` before every query. Use `codegraphy filter` only when persisted path exclusions need inspection or change. +If a command reports `graph_cache_not_found`, run `codegraphy index` once and retry. Search reports whether live source differs from its cached Symbol facts in `data.sources.symbols.cacheState`; do not run `status` before every query. If Indexing reports a file-budget truncation, inspect `codegraphy settings get maxFiles`, raise it with `codegraphy settings set maxFiles `, adjust durable exclusions with `codegraphy filter` when appropriate, then run `codegraphy index` once. Do not inspect or mutate settings preemptively when results are already complete. Use `-C ` from outside the workspace. Quote patterns containing spaces or `*`. ## Choose the command by question -- `search `: locate matching live source lines, cached AST Symbols, and indexed File or concept Nodes. Matching is case-insensitive; `*` spans characters within one line or name. Symbol results include their `filePath`; text results include `line`, `column`, and `excerpt`. -- `query `: inspect one exact File or Symbol. It returns declared AST Symbols plus bounded incoming and outgoing Relationships in one call. +- `search `: locate matching live source lines, cached AST Symbols, and indexed File or concept Nodes. Matching is case-insensitive; `*` spans characters within one line or name. Natural multi-term phrases also use BM25 File ranking when literal evidence is sparse. Symbol results include their `filePath`; text results include `line`, `column`, and `excerpt`. +- `query `: inspect one exact File or Symbol. It returns declared AST Symbols plus bounded incoming and outgoing Relationships in one call. Exact Symbol queries also include bounded live `sourceContext`; use it before issuing a separate source read. - `dependencies `: continue through outgoing Relationships—what the Node uses. - `dependents `: continue through incoming Relationships—what may be affected. - `path `: verify whether and how two exact Nodes connect. @@ -30,11 +30,12 @@ Use `-C ` from outside the workspace. Quote patterns containing space - `edges`: inventory shaped Relationships when edge-level enumeration is the task. - `status`: inspect Graph Cache state when freshness itself is the task. - `doctor`: diagnose settings, cache, runtime, or Plugin failures. +- `settings`: read effective workspace configuration or safely `get`, `set`, and `unset` any supported top-level setting. Values passed to `set` are JSON; mutations report `indexRequired`. - `filter`, `scope`, and `plugins`: inspect or change durable workspace configuration; do not use them for ordinary navigation. Query with the narrowest command that answers the current question. Prefer `search` → `query` → source reads over dumping Nodes or Edges. -Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read it or use file-local text search. Do not search again for identifiers already present in returned declarations. Use at most four CodeGraphy calls for an investigation unless you are following `nextOffset`; if four calls have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. This navigation budget does not weaken the answer: verify and state every requested condition, default adapter, side-effect destination, and test from source before stopping. +Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Use returned Symbol `sourceContext`, read it, or use file-local text search. Do not search again for identifiers already present in returned declarations. Use at most four CodeGraphy calls for an investigation unless you are following `nextOffset`; if four calls have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. This navigation budget does not weaken the answer: verify and state every requested condition, default adapter, side-effect destination, and test from source before stopping. ## Keep output bounded From c2ad7ad0c5530305172e2096bd2c5efff6064d1c Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 11:39:36 -0700 Subject: [PATCH 20/55] feat(cli): include source context in file queries --- packages/core/src/graphQuery/overview.ts | 40 +++++++++++++++---- .../core/tests/graphQuery/overview.test.ts | 24 +++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/core/src/graphQuery/overview.ts b/packages/core/src/graphQuery/overview.ts index dbe241460..7f773fee0 100644 --- a/packages/core/src/graphQuery/overview.ts +++ b/packages/core/src/graphQuery/overview.ts @@ -17,8 +17,10 @@ import { toSymbolReportBase } from './symbols/metadata'; const DECLARED_SYMBOL_LIMIT = 25; const RELATIONSHIP_LIMIT = 25; -const SOURCE_CONTEXT_LINE_LIMIT = 80; -const SOURCE_CONTEXT_CHARACTER_LIMIT = 8_000; +const SYMBOL_SOURCE_CONTEXT_LINE_LIMIT = 80; +const SYMBOL_SOURCE_CONTEXT_CHARACTER_LIMIT = 8_000; +const FILE_SOURCE_CONTEXT_LINE_LIMIT = 200; +const FILE_SOURCE_CONTEXT_CHARACTER_LIMIT = 12_000; function symbolTarget(symbol: IAnalysisSymbol): GraphQueryNodeReportItem { return { @@ -76,7 +78,27 @@ function listOverviewSymbols(data: GraphQueryData, filePath: string): GraphQuery }; } -function createSourceContext( +function createFileSourceContext( + data: GraphQueryData, + filePath: string, +): GraphQuerySourceContext | undefined { + const sourceFile = data.sourceText?.files.find(file => file.filePath === filePath); + if (!sourceFile) return undefined; + const lines = sourceFile.content.split(/\r?\n/u); + const endIndex = Math.min(lines.length, FILE_SOURCE_CONTEXT_LINE_LIMIT); + const completeText = lines.slice(0, endIndex).join('\n'); + const text = completeText.slice(0, FILE_SOURCE_CONTEXT_CHARACTER_LIMIT); + return { + filePath, + startLine: 1, + endLine: endIndex, + text, + truncated: endIndex < lines.length || text.length < completeText.length, + freshness: 'live', + }; +} + +function createSymbolSourceContext( data: GraphQueryData, symbol: IAnalysisSymbol | undefined, ): GraphQuerySourceContext | undefined { @@ -86,10 +108,10 @@ function createSourceContext( const lines = sourceFile.content.split(/\r?\n/u); const matchedLine = lines.findIndex(line => line.includes(symbol.name)); const startIndex = Math.max(0, (symbol.range?.startLine ?? matchedLine + 1) - 1); - const requestedEnd = symbol.range?.endLine ?? startIndex + SOURCE_CONTEXT_LINE_LIMIT; - const endIndex = Math.min(lines.length, requestedEnd, startIndex + SOURCE_CONTEXT_LINE_LIMIT); + const requestedEnd = symbol.range?.endLine ?? startIndex + SYMBOL_SOURCE_CONTEXT_LINE_LIMIT; + const endIndex = Math.min(lines.length, requestedEnd, startIndex + SYMBOL_SOURCE_CONTEXT_LINE_LIMIT); const completeText = lines.slice(startIndex, endIndex).join('\n'); - const text = completeText.slice(0, SOURCE_CONTEXT_CHARACTER_LIMIT); + const text = completeText.slice(0, SYMBOL_SOURCE_CONTEXT_CHARACTER_LIMIT); return { filePath: symbol.filePath, startLine: startIndex + 1, @@ -97,7 +119,7 @@ function createSourceContext( text, truncated: (symbol.range ? requestedEnd > endIndex - : startIndex + SOURCE_CONTEXT_LINE_LIMIT < lines.length) + : startIndex + SYMBOL_SOURCE_CONTEXT_LINE_LIMIT < lines.length) || text.length < completeText.length, freshness: 'live', }; @@ -130,7 +152,9 @@ export function inspectGraphTarget( const targetSymbol = target.symbol ? data.symbols?.find(symbol => symbol.id === target.path) : undefined; - const sourceContext = createSourceContext(data, targetSymbol); + const sourceContext = targetSymbol + ? createSymbolSourceContext(data, targetSymbol) + : createFileSourceContext(data, filePath); return { target, diff --git a/packages/core/tests/graphQuery/overview.test.ts b/packages/core/tests/graphQuery/overview.test.ts index 6b2025770..f8c5bcb26 100644 --- a/packages/core/tests/graphQuery/overview.test.ts +++ b/packages/core/tests/graphQuery/overview.test.ts @@ -82,6 +82,30 @@ describe('core/graphQuery target overview', () => { }); }); + it('includes bounded live source context for an exact File target', () => { + const result = inspectGraphTarget({ + graphData, + symbols, + relations, + sourceText: { + files: [{ filePath: 'src/command.ts', content: 'export function runCommand() {\n return true;\n}\n' }], + filesScanned: 1, + filesSkipped: 0, + }, + }, { target: 'src/command.ts' }); + + expect(result).toMatchObject({ + sourceContext: { + filePath: 'src/command.ts', + startLine: 1, + endLine: 4, + text: 'export function runCommand() {\n return true;\n}\n', + truncated: false, + freshness: 'live', + }, + }); + }); + it('prioritizes callable and type declarations over local constants', () => { const crowdedSymbols: IAnalysisSymbol[] = [ ...Array.from({ length: 30 }, (_, index) => ({ From fb738f29a03c360867653e422604f2161c43a3cb Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 11:44:47 -0700 Subject: [PATCH 21/55] perf(cli): prefer deterministic phrase ranking --- packages/core/src/graphQuery/search.ts | 6 +- .../core/src/graphQuery/search/ranking.ts | 60 ++++++++----------- packages/core/tests/graphQuery/search.test.ts | 4 +- .../tests/graphQuery/search/ranking.test.ts | 6 +- 4 files changed, 34 insertions(+), 42 deletions(-) diff --git a/packages/core/src/graphQuery/search.ts b/packages/core/src/graphQuery/search.ts index 7835904da..d89c5b176 100644 --- a/packages/core/src/graphQuery/search.ts +++ b/packages/core/src/graphQuery/search.ts @@ -126,7 +126,7 @@ function sourceMatches(data: GraphQueryData, pattern: string, matcher: PatternMa )); } -function bm25FallbackMatches( +function phraseFallbackMatches( data: GraphQueryData, pattern: string, existingMatches: readonly RankedMatch[], @@ -142,7 +142,7 @@ function bm25FallbackMatches( id: file.filePath, path: file.filePath, text: file.content, - }))).slice(0, 10).flatMap((result, index) => { + }))).slice(0, 3).flatMap((result, index) => { const node = nodesByPath.get(result.id); return node && !existingNodePaths.has(result.id) ? [{ match: { type: 'node' as const, node: toNodeReportItem(node) }, @@ -164,7 +164,7 @@ export function searchGraph( ]; const rankedMatches = [ ...directMatches, - ...bm25FallbackMatches(data, config.pattern, directMatches), + ...phraseFallbackMatches(data, config.pattern, directMatches), ].sort((left, right) => left.rank - right.rank || left.sortKey.localeCompare(right.sortKey)); const page = paginate(rankedMatches.map(item => item.match), config); diff --git a/packages/core/src/graphQuery/search/ranking.ts b/packages/core/src/graphQuery/search/ranking.ts index a29b85107..b9f4eeaf2 100644 --- a/packages/core/src/graphQuery/search/ranking.ts +++ b/packages/core/src/graphQuery/search/ranking.ts @@ -1,6 +1,5 @@ -const BM25_K1 = 1.2; -const BM25_B = 0.75; -const PATH_TERM_WEIGHT = 8; +const PATH_TERM_WEIGHT = 100; +const PATH_FRAGMENT_WEIGHT = 20; const STOP_WORDS = new Set([ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'from', @@ -25,10 +24,22 @@ function tokenize(value: string): string[] { .filter(term => term.length > 1 && !STOP_WORDS.has(term)) ?? []; } -function countTerms(terms: readonly string[]): Map { - const counts = new Map(); - for (const term of terms) counts.set(term, (counts.get(term) ?? 0) + 1); - return counts; +function scoreDocument( + document: SearchDocument, + queryTerms: readonly string[], +): RankedSearchDocument | undefined { + const path = document.path.toLocaleLowerCase(); + const pathTerms = tokenize(document.path); + const textTerms = tokenize(document.text); + const availableTerms = new Set([...pathTerms, ...textTerms]); + if (!queryTerms.every(term => availableTerms.has(term))) return undefined; + const score = queryTerms.reduce((total, term) => ( + total + + pathTerms.filter(pathTerm => pathTerm === term).length * PATH_TERM_WEIGHT + + (path.includes(term) ? PATH_FRAGMENT_WEIGHT : 0) + + Math.min(10, textTerms.filter(textTerm => textTerm === term).length) + ), 0); + return { id: document.id, score }; } export function rankSearchDocuments( @@ -37,32 +48,13 @@ export function rankSearchDocuments( ): RankedSearchDocument[] { if (!/\s/u.test(pattern)) return []; const queryTerms = [...new Set(tokenize(pattern))]; - if (queryTerms.length < 2 || documents.length === 0) return []; - - const prepared = documents.map((document) => { - const pathTerms = tokenize(document.path); - const terms = [...pathTerms.flatMap(term => Array(PATH_TERM_WEIGHT).fill(term)), ...tokenize(document.text)]; - return { document, terms, counts: countTerms(terms) }; + if (queryTerms.length < 2) return []; + const ranked = documents.flatMap(document => scoreDocument(document, queryTerms) ?? []); + const pathMatches = ranked.filter(result => { + const document = documents.find(candidate => candidate.id === result.id); + const pathTerms = new Set(tokenize(document?.path ?? '')); + return queryTerms.every(term => pathTerms.has(term)); }); - const averageLength = prepared.reduce((total, item) => total + item.terms.length, 0) / prepared.length; - const documentFrequency = new Map(queryTerms.map(term => [ - term, - prepared.filter(item => item.counts.has(term)).length, - ])); - - return prepared.flatMap(({ document, terms, counts }) => { - const score = queryTerms.reduce((total, term) => { - const frequency = counts.get(term) ?? 0; - if (frequency === 0) return total; - const frequencyInDocuments = documentFrequency.get(term) ?? 0; - const inverseDocumentFrequency = Math.log( - 1 + (documents.length - frequencyInDocuments + 0.5) / (frequencyInDocuments + 0.5), - ); - const normalizedFrequency = frequency + BM25_K1 * ( - 1 - BM25_B + BM25_B * terms.length / averageLength - ); - return total + inverseDocumentFrequency * frequency * (BM25_K1 + 1) / normalizedFrequency; - }, 0); - return score > 0 ? [{ id: document.id, score }] : []; - }).sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); + return (pathMatches.length > 0 ? pathMatches : ranked) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); } diff --git a/packages/core/tests/graphQuery/search.test.ts b/packages/core/tests/graphQuery/search.test.ts index 401850f47..64fa38f9b 100644 --- a/packages/core/tests/graphQuery/search.test.ts +++ b/packages/core/tests/graphQuery/search.test.ts @@ -126,7 +126,7 @@ describe('core/graphQuery search', () => { expect(match && 'excerpt' in match ? match.excerpt.length : 0).toBeLessThanOrEqual(240); }); - it('falls back to BM25-ranked Files for natural multi-term searches', () => { + it('falls back to all-term File ranking for natural multi-term searches', () => { const result = searchGraph({ graphData: { nodes: [ @@ -166,7 +166,7 @@ describe('core/graphQuery search', () => { })); }); - it('keeps exact identifier Symbols ahead of BM25 fallback candidates', () => { + it('keeps exact identifier Symbols ahead of natural phrase fallback candidates', () => { expect(search('runIndexCommand').matches[0]).toMatchObject({ type: 'symbol', symbol: { name: 'runIndexCommand' }, diff --git a/packages/core/tests/graphQuery/search/ranking.test.ts b/packages/core/tests/graphQuery/search/ranking.test.ts index 82a06bf88..a52ed018a 100644 --- a/packages/core/tests/graphQuery/search/ranking.test.ts +++ b/packages/core/tests/graphQuery/search/ranking.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { rankSearchDocuments } from '../../../src/graphQuery/search/ranking'; -describe('core/graphQuery/search BM25 ranking', () => { - it('tokenizes camelCase identifiers and boosts matching paths', () => { +describe('core/graphQuery/search natural phrase ranking', () => { + it('tokenizes camelCase identifiers and prioritizes all-term path matches', () => { const ranked = rankSearchDocuments('filter command', [ { id: 'parse-test', @@ -16,7 +16,7 @@ describe('core/graphQuery/search BM25 ranking', () => { }, ]); - expect(ranked.map(result => result.id)).toEqual(['filter-command', 'parse-test']); + expect(ranked.map(result => result.id)).toEqual(['filter-command']); }); it('returns no fallback ranking for a single exact-search term', () => { From 2d72ed90f902da4ff2ed1656490d23893c25894e Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 12:03:27 -0700 Subject: [PATCH 22/55] perf(cli): keep target queries graph focused --- packages/core/src/graphQuery/index.ts | 1 - packages/core/src/graphQuery/model.ts | 10 ---- packages/core/src/graphQuery/overview.ts | 59 ------------------- packages/core/src/index.ts | 1 - packages/core/src/workspace/requestQuery.ts | 11 +--- .../core/tests/graphQuery/overview.test.ts | 50 ---------------- skills/codegraphy/SKILL.md | 8 +-- 7 files changed, 5 insertions(+), 135 deletions(-) diff --git a/packages/core/src/graphQuery/index.ts b/packages/core/src/graphQuery/index.ts index 4d6d60d27..de1577a6e 100644 --- a/packages/core/src/graphQuery/index.ts +++ b/packages/core/src/graphQuery/index.ts @@ -23,7 +23,6 @@ export type { GraphQuerySearchConfig, GraphQuerySearchMatch, GraphQuerySearchReport, - GraphQuerySourceContext, GraphQuerySort, GraphQueryResult, GraphQuerySymbolReport, diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index 874bdcec2..29594eeab 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -189,19 +189,9 @@ export interface GraphQuerySearchReport { }; } -export interface GraphQuerySourceContext { - filePath: string; - startLine: number; - endLine: number; - text: string; - truncated: boolean; - freshness: 'live'; -} - export interface GraphQueryOverviewReport { target: GraphQueryNodeReportItem; declaredSymbols: GraphQuerySymbolReport; - sourceContext?: GraphQuerySourceContext; outgoing: GraphQueryEdgeReport; incoming: GraphQueryEdgeReport; limits: { diff --git a/packages/core/src/graphQuery/overview.ts b/packages/core/src/graphQuery/overview.ts index 7f773fee0..dbb7797d7 100644 --- a/packages/core/src/graphQuery/overview.ts +++ b/packages/core/src/graphQuery/overview.ts @@ -6,7 +6,6 @@ import type { GraphQueryNodeReportItem, GraphQueryOverviewConfig, GraphQueryOverviewReport, - GraphQuerySourceContext, GraphQuerySymbolReport, GraphQueryTargetNotFoundReport, } from './model'; @@ -17,10 +16,6 @@ import { toSymbolReportBase } from './symbols/metadata'; const DECLARED_SYMBOL_LIMIT = 25; const RELATIONSHIP_LIMIT = 25; -const SYMBOL_SOURCE_CONTEXT_LINE_LIMIT = 80; -const SYMBOL_SOURCE_CONTEXT_CHARACTER_LIMIT = 8_000; -const FILE_SOURCE_CONTEXT_LINE_LIMIT = 200; -const FILE_SOURCE_CONTEXT_CHARACTER_LIMIT = 12_000; function symbolTarget(symbol: IAnalysisSymbol): GraphQueryNodeReportItem { return { @@ -78,53 +73,6 @@ function listOverviewSymbols(data: GraphQueryData, filePath: string): GraphQuery }; } -function createFileSourceContext( - data: GraphQueryData, - filePath: string, -): GraphQuerySourceContext | undefined { - const sourceFile = data.sourceText?.files.find(file => file.filePath === filePath); - if (!sourceFile) return undefined; - const lines = sourceFile.content.split(/\r?\n/u); - const endIndex = Math.min(lines.length, FILE_SOURCE_CONTEXT_LINE_LIMIT); - const completeText = lines.slice(0, endIndex).join('\n'); - const text = completeText.slice(0, FILE_SOURCE_CONTEXT_CHARACTER_LIMIT); - return { - filePath, - startLine: 1, - endLine: endIndex, - text, - truncated: endIndex < lines.length || text.length < completeText.length, - freshness: 'live', - }; -} - -function createSymbolSourceContext( - data: GraphQueryData, - symbol: IAnalysisSymbol | undefined, -): GraphQuerySourceContext | undefined { - if (!symbol) return undefined; - const sourceFile = data.sourceText?.files.find(file => file.filePath === symbol.filePath); - if (!sourceFile) return undefined; - const lines = sourceFile.content.split(/\r?\n/u); - const matchedLine = lines.findIndex(line => line.includes(symbol.name)); - const startIndex = Math.max(0, (symbol.range?.startLine ?? matchedLine + 1) - 1); - const requestedEnd = symbol.range?.endLine ?? startIndex + SYMBOL_SOURCE_CONTEXT_LINE_LIMIT; - const endIndex = Math.min(lines.length, requestedEnd, startIndex + SYMBOL_SOURCE_CONTEXT_LINE_LIMIT); - const completeText = lines.slice(startIndex, endIndex).join('\n'); - const text = completeText.slice(0, SYMBOL_SOURCE_CONTEXT_CHARACTER_LIMIT); - return { - filePath: symbol.filePath, - startLine: startIndex + 1, - endLine: endIndex, - text, - truncated: (symbol.range - ? requestedEnd > endIndex - : startIndex + SYMBOL_SOURCE_CONTEXT_LINE_LIMIT < lines.length) - || text.length < completeText.length, - freshness: 'live', - }; -} - export function inspectGraphTarget( data: GraphQueryData, config: GraphQueryOverviewConfig, @@ -149,19 +97,12 @@ export function inspectGraphTarget( edges: Object.fromEntries(data.graphData.edges.map(edge => [edge.kind, true])), }; const filePath = target.symbol?.filePath ?? target.path; - const targetSymbol = target.symbol - ? data.symbols?.find(symbol => symbol.id === target.path) - : undefined; - const sourceContext = targetSymbol - ? createSymbolSourceContext(data, targetSymbol) - : createFileSourceContext(data, filePath); return { target, declaredSymbols: target.symbol ? listGraphSymbols(data, { filePath: '__symbol-target__', limit: DECLARED_SYMBOL_LIMIT }) : listOverviewSymbols(data, filePath), - ...(sourceContext ? { sourceContext } : {}), outgoing: listGraphEdges(relationshipData.graphData, { from: target.path, scope: completeScope, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a10963c5d..77b7f43c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -460,7 +460,6 @@ export type { GraphQuerySearchConfig, GraphQuerySearchMatch, GraphQuerySearchReport, - GraphQuerySourceContext, GraphQuerySort, GraphQuerySymbolReport, GraphQuerySymbolReportItem, diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index e924f9393..d460eb174 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -61,18 +61,9 @@ function executeWorkspaceGraphQuery( source, input.projection, ); - const target = typeof input.arguments.target === 'string' ? input.arguments.target : undefined; - const targetFilePath = target - ? snapshotFacts.symbols.find(symbol => symbol.id === target)?.filePath ?? target - : undefined; const sourceText = input.report === 'search' ? readWorkspaceQuerySourceText(workspaceRoot, graphData, source.indexedContentHashes) - : input.report === 'overview' && targetFilePath - ? readWorkspaceQuerySourceText(workspaceRoot, { - nodes: graphData.nodes.filter(node => node.id === targetFilePath), - edges: [], - }, source.indexedContentHashes) - : undefined; + : undefined; const queryResult = executeGraphQuery({ graphData, symbols: snapshotFacts.symbols, diff --git a/packages/core/tests/graphQuery/overview.test.ts b/packages/core/tests/graphQuery/overview.test.ts index f8c5bcb26..c3169975c 100644 --- a/packages/core/tests/graphQuery/overview.test.ts +++ b/packages/core/tests/graphQuery/overview.test.ts @@ -82,30 +82,6 @@ describe('core/graphQuery target overview', () => { }); }); - it('includes bounded live source context for an exact File target', () => { - const result = inspectGraphTarget({ - graphData, - symbols, - relations, - sourceText: { - files: [{ filePath: 'src/command.ts', content: 'export function runCommand() {\n return true;\n}\n' }], - filesScanned: 1, - filesSkipped: 0, - }, - }, { target: 'src/command.ts' }); - - expect(result).toMatchObject({ - sourceContext: { - filePath: 'src/command.ts', - startLine: 1, - endLine: 4, - text: 'export function runCommand() {\n return true;\n}\n', - truncated: false, - freshness: 'live', - }, - }); - }); - it('prioritizes callable and type declarations over local constants', () => { const crowdedSymbols: IAnalysisSymbol[] = [ ...Array.from({ length: 30 }, (_, index) => ({ @@ -150,20 +126,6 @@ describe('core/graphQuery target overview', () => { range: { startLine: 2, endLine: 4 }, }], relations, - sourceText: { - files: [{ - filePath: 'src/settings.ts', - content: [ - "import { readFile } from 'node:fs';", - 'export function readSettings() {', - " return JSON.parse(readFile('settings.json'));", - '}', - '', - ].join('\n'), - }], - filesScanned: 1, - filesSkipped: 0, - }, }, { target: 'src/settings.ts#readSettings:function' }, ); @@ -180,18 +142,6 @@ describe('core/graphQuery target overview', () => { }, }, declaredSymbols: { symbols: [] }, - sourceContext: { - filePath: 'src/settings.ts', - startLine: 2, - endLine: 4, - text: [ - 'export function readSettings() {', - " return JSON.parse(readFile('settings.json'));", - '}', - ].join('\n'), - truncated: false, - freshness: 'live', - }, }); }); diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index c73e14d2a..c0edf72d7 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -21,8 +21,8 @@ Use `-C ` from outside the workspace. Quote patterns containing space ## Choose the command by question -- `search `: locate matching live source lines, cached AST Symbols, and indexed File or concept Nodes. Matching is case-insensitive; `*` spans characters within one line or name. Natural multi-term phrases also use BM25 File ranking when literal evidence is sparse. Symbol results include their `filePath`; text results include `line`, `column`, and `excerpt`. -- `query `: inspect one exact File or Symbol. It returns declared AST Symbols plus bounded incoming and outgoing Relationships in one call. Exact Symbol queries also include bounded live `sourceContext`; use it before issuing a separate source read. +- `search `: locate matching live source lines, cached AST Symbols, and indexed File or concept Nodes. Matching is case-insensitive; `*` spans characters within one line or name. Natural multi-term phrases also use deterministic all-term File ranking when literal evidence is sparse. Symbol results include their `filePath`; text results include `line`, `column`, and `excerpt`. +- `query `: inspect one exact File or Symbol. It returns prioritized declared AST Symbols plus bounded incoming and outgoing Relationships in one call. - `dependencies `: continue through outgoing Relationships—what the Node uses. - `dependents `: continue through incoming Relationships—what may be affected. - `path `: verify whether and how two exact Nodes connect. @@ -35,7 +35,7 @@ Use `-C ` from outside the workspace. Quote patterns containing space Query with the narrowest command that answers the current question. Prefer `search` → `query` → source reads over dumping Nodes or Edges. -Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Use returned Symbol `sourceContext`, read it, or use file-local text search. Do not search again for identifiers already present in returned declarations. Use at most four CodeGraphy calls for an investigation unless you are following `nextOffset`; if four calls have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. This navigation budget does not weaken the answer: verify and state every requested condition, default adapter, side-effect destination, and test from source before stopping. +Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read the known File or use file-local text search, preferably alongside the other already-known source and test files in one tool turn. Do not search again for identifiers already present in returned declarations. Use at most four CodeGraphy calls for an investigation unless you are following `nextOffset`; if four calls have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. This navigation budget does not weaken the answer: verify and state every requested condition, default adapter, side-effect destination, and test from source before stopping. ## Keep output bounded @@ -45,7 +45,7 @@ Graph navigation commands accept repeatable `--filter`, `--node-type`, and `--ed ## Failures and recovery -Data commands write one `{ok:true,command,data}` JSON envelope to stdout. Failures write `{ok:false,command,error}` to stderr and exit nonzero: 1 for an operational failure and 2 for an invalid invocation. `--verbose` adds diagnostics on stderr. +Data commands write one `{ok:true,command,data}` JSON envelope to stdout. Failures write `{ok:false,command,error}` to stderr and exit nonzero: 1 for an operational failure and 2 for an invalid invocation. `--verbose` adds diagnostics on stderr. Error identifiers below are recovery signals, not repository search terms; do not spend navigation calls searching their names unless the task cites that exact runtime error. - `graph_cache_not_found`: run `codegraphy index`, then retry. - `query_target_not_found`: use `search` to obtain an exact File path or Symbol Node ID. From e1e94bda7598bee6bb7d4569ce84e6e3ebd5cbc5 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 12:03:32 -0700 Subject: [PATCH 23/55] feat(cli): report actionable indexing limits --- packages/core/src/cli/run.ts | 16 +++++++++---- .../core/src/workspace/requestIndexing.ts | 8 +++++++ packages/core/src/workspace/requestTypes.ts | 6 +++++ .../core/src/workspace/settingsStorage.ts | 8 ++++++- packages/core/tests/cli/graphControls.test.ts | 2 +- packages/core/tests/cli/index/command.test.ts | 3 +++ .../core/tests/cli/settings/command.test.ts | 12 ++++++++-- .../workspace/coreBackedCommands.test.ts | 24 +++++++++++++++++++ 8 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/core/src/cli/run.ts b/packages/core/src/cli/run.ts index 10d01f7f4..bdc6a6859 100644 --- a/packages/core/src/cli/run.ts +++ b/packages/core/src/cli/run.ts @@ -2,6 +2,7 @@ import { runCliCommand, type CommandExecutionResult } from './command'; import { parseCliCommand } from './parse'; import type { CliCommand } from './parseTypes'; import { formatCliResult } from './result/serializer'; +import { WorkspaceSettingsError } from '../workspace/settings'; export interface RunCliDependencies { runCommand(command: CliCommand): Promise; @@ -27,10 +28,17 @@ export async function runCli( } catch (error) { result = { exitCode: 1, - output: JSON.stringify({ - error: 'command_failed', - message: error instanceof Error ? error.message : String(error), - }), + output: JSON.stringify(error instanceof WorkspaceSettingsError + ? { + error: 'invalid_workspace_settings', + message: error.reason, + action: 'Repair `.codegraphy/settings.json`, then retry.', + details: { path: error.settingsPath }, + } + : { + error: 'command_failed', + message: error instanceof Error ? error.message : String(error), + }), }; } diff --git a/packages/core/src/workspace/requestIndexing.ts b/packages/core/src/workspace/requestIndexing.ts index 4d7154ae0..531e2946f 100644 --- a/packages/core/src/workspace/requestIndexing.ts +++ b/packages/core/src/workspace/requestIndexing.ts @@ -58,6 +58,14 @@ export async function requestCodeGraphyIndexWorkspace( files: result.files.length, nodes: result.graph.nodes.length, edges: result.graph.edges.length, + discovery: { + indexedFiles: result.files.length, + totalFound: result.totalFound, + limitReached: result.limitReached, + ...(result.limitReached ? { + action: `Run \`codegraphy settings set maxFiles ${result.totalFound}\`, then rerun \`codegraphy index\`.`, + } : {}), + }, indexing: result.indexing, }; } diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index 43f1adaf9..aeffa1f4c 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -30,6 +30,12 @@ export interface IndexWorkspaceResult { workspaceRoot: string; graphCache: string; message: string; + discovery: { + indexedFiles: number; + totalFound: number; + limitReached: boolean; + action?: string; + }; indexing: { mode: 'full' | 'incremental'; analyzedFiles: number; diff --git a/packages/core/src/workspace/settingsStorage.ts b/packages/core/src/workspace/settingsStorage.ts index 61183f3bb..8ed6a58f5 100644 --- a/packages/core/src/workspace/settingsStorage.ts +++ b/packages/core/src/workspace/settingsStorage.ts @@ -23,7 +23,13 @@ export class WorkspaceSettingsError extends Error { function writeRawWorkspaceSettings(workspaceRoot: string, settings: Record): void { const settingsPath = getWorkspaceSettingsPath(workspaceRoot); - const validated = validateWorkspaceSettingsRecord(settings); + let validated: Record; + try { + validated = validateWorkspaceSettingsRecord(settings); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new WorkspaceSettingsError(settingsPath, reason); + } fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); fs.writeFileSync(settingsPath, `${JSON.stringify(validated, null, 2)}\n`); } diff --git a/packages/core/tests/cli/graphControls.test.ts b/packages/core/tests/cli/graphControls.test.ts index ee156ed3a..7be198c77 100644 --- a/packages/core/tests/cli/graphControls.test.ts +++ b/packages/core/tests/cli/graphControls.test.ts @@ -82,7 +82,7 @@ describe('cli graph controls', () => { expect(stderr).toHaveBeenCalledTimes(2); expect(JSON.parse(stderr.mock.calls[0][0])).toMatchObject({ - error: { code: 'command_failed', message: expect.stringContaining('settings.json') }, + error: { code: 'invalid_workspace_settings', details: { path: settingsPath } }, }); await expect(fs.readFile(settingsPath, 'utf8')).resolves.toBe(before); }); diff --git a/packages/core/tests/cli/index/command.test.ts b/packages/core/tests/cli/index/command.test.ts index 7e0f262f2..1f6470d33 100644 --- a/packages/core/tests/cli/index/command.test.ts +++ b/packages/core/tests/cli/index/command.test.ts @@ -17,6 +17,7 @@ describe('index/command', () => { workspaceRoot: '/workspace/project', graphCache: '.codegraphy/graph.sqlite', message: 'CodeGraphy indexing completed. CLI queries can now read the Graph Cache.', + discovery: { indexedFiles: 2, totalFound: 2, limitReached: false }, indexing: { mode: 'full', analyzedFiles: 2, deletedFiles: 0, reusedFiles: 0 }, }; }, @@ -38,6 +39,7 @@ describe('index/command', () => { workspaceRoot: workspacePath ?? '/workspace/project', graphCache: '.codegraphy/graph.sqlite', message: 'indexed', + discovery: { indexedFiles: 0, totalFound: 0, limitReached: false }, indexing: { mode: 'full', analyzedFiles: 0, deletedFiles: 0, reusedFiles: 0 }, }), writeStatus: vi.fn(), @@ -64,6 +66,7 @@ describe('index/command', () => { workspaceRoot: '/workspace/other', graphCache: '.codegraphy/graph.sqlite', message: 'indexed', + discovery: { indexedFiles: 2, totalFound: 2, limitReached: false }, indexing: { mode: 'full', analyzedFiles: 2, deletedFiles: 0, reusedFiles: 0 }, }; }, diff --git a/packages/core/tests/cli/settings/command.test.ts b/packages/core/tests/cli/settings/command.test.ts index 5b2b4e1cb..eb863d560 100644 --- a/packages/core/tests/cli/settings/command.test.ts +++ b/packages/core/tests/cli/settings/command.test.ts @@ -73,7 +73,11 @@ describe('cli/settings command', () => { await expect(runCli(['-C', workspace, 'settings', 'set', 'maxFiles', '-1'], { stderr })).resolves.toBe(1); expect(JSON.parse(stderr.mock.calls[0][0])).toMatchObject({ - error: { code: 'command_failed', message: 'maxFiles must be a positive integer' }, + error: { + code: 'invalid_workspace_settings', + message: 'maxFiles must be a positive integer', + details: { path: settingsPath }, + }, }); await expect(fs.readFile(settingsPath, 'utf8')).resolves.toBe(before); }); @@ -87,7 +91,11 @@ describe('cli/settings command', () => { await expect(runCli(['-C', workspace, 'settings', 'set', 'maxFiles', '2500'], { stderr })).resolves.toBe(1); expect(JSON.parse(stderr.mock.calls[0][0])).toMatchObject({ - error: { code: 'command_failed', message: expect.stringContaining('settings.json') }, + error: { + code: 'invalid_workspace_settings', + message: expect.any(String), + details: { path: settingsPath }, + }, }); await expect(fs.readFile(settingsPath, 'utf8')).resolves.toBe(before); }); diff --git a/packages/core/tests/workspace/coreBackedCommands.test.ts b/packages/core/tests/workspace/coreBackedCommands.test.ts index 20666152a..28b79ae8e 100644 --- a/packages/core/tests/workspace/coreBackedCommands.test.ts +++ b/packages/core/tests/workspace/coreBackedCommands.test.ts @@ -64,6 +64,11 @@ describe('core-backed CodeGraphy Workspace commands', () => { expect(indexResult).toMatchObject({ workspaceRoot, graphCache: '.codegraphy/graph.sqlite', + discovery: { + indexedFiles: 2, + totalFound: 2, + limitReached: false, + }, indexing: { mode: 'full', analyzedFiles: 2, @@ -84,6 +89,25 @@ describe('core-backed CodeGraphy Workspace commands', () => { ]); }); + it('returns an actionable file-budget result when discovery is truncated', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-cli-budget-')); + await fs.writeFile(path.join(workspaceRoot, 'one.ts'), 'export const one = 1;\n'); + await fs.writeFile(path.join(workspaceRoot, 'two.ts'), 'export const two = 2;\n'); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + maxFiles: 1, + }); + + const result = await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); + + expect(result.discovery).toEqual({ + indexedFiles: 1, + totalFound: 2, + limitReached: true, + action: 'Run `codegraphy settings set maxFiles 2`, then rerun `codegraphy index`.', + }); + }); + it('reports a fresh cache immediately after indexing with an unavailable configured plugin', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-cli-missing-plugin-')); await fs.writeFile(path.join(workspaceRoot, 'Home.md'), '# Home\n', 'utf-8'); From ccc7564cca57aa42bea56038ec737e464ebf0a3c Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 12:27:01 -0700 Subject: [PATCH 24/55] docs(cli): record agent retrieval experiments --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 9 ++- README.md | 9 ++- ...0008-agent-search-and-settings-workflow.md | 79 +++++++++++++++++++ packages/core/README.md | 10 ++- packages/core/src/cli/help/command.ts | 9 ++- packages/core/tests/cli/help/command.test.ts | 7 +- packages/core/tests/cli/parseSettings.test.ts | 26 ++++++ .../workspace/settingsValidation.test.ts | 30 +++++++ 9 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 docs/adr/0008-agent-search-and-settings-workflow.md create mode 100644 packages/core/tests/cli/parseSettings.test.ts create mode 100644 packages/core/tests/workspace/settingsValidation.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index 04e363352..7a49a2b8c 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -2,4 +2,4 @@ "@codegraphy-dev/core": major --- -Make `codegraphy search` discover live source and cached AST Symbols, add `codegraphy query` for one-call File or Symbol overviews, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, and keep successful non-verbose Indexing output on stdout only. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, and keep successful non-verbose Indexing output on stdout only. diff --git a/CONTEXT.md b/CONTEXT.md index b7467fa0a..3b1a43b53 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -45,8 +45,8 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Filtered Graph** | The Scoped Graph after Filter rules. | | **Graph View Search** | A temporary text query that narrows the current graph without changing Filter settings. | | **Searched Graph** | The Filtered Graph after Graph View Search. | -| **CLI Search** | A bounded discovery query over live source lines, cached AST Symbols, and indexed Nodes. It reports source and cache provenance and does not change settings. | -| **Target Query** | A bounded overview of one exact File or Symbol Node, including declarations and incoming/outgoing Relationships. | +| **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates. It reports source and cache provenance and does not change settings. | +| **Target Query** | A bounded overview of one exact File or Symbol Node, including prioritized declarations and incoming/outgoing Relationships. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | | **Orphan Node** | A Node with no remaining Edges after graph narrowing. | | **Show Orphans** | A final Graph View setting that keeps or removes Orphan Nodes. | @@ -107,13 +107,14 @@ The Graph View can use a whole-view loading state before its first graph payload | **tldraw Interface** | `@codegraphy-dev/tldraw` owns its launcher, tldraw document lifecycle, native shapes, controls, and adapters over Core and renderer physics. | | **Graph Renderer** | `@codegraphy-dev/graph-renderer` owns WebGPU drawing and deterministic WebAssembly physics. It does not own product settings, persistence, or plugins. | | **CodeGraphy CLI** | The terminal interface installed by `@codegraphy-dev/core`. It targets the current directory unless `-C, --workspace ` selects another workspace. | -| **CodeGraphy Exploration CLI** | `search` discovers live source, cached AST Symbols, and indexed Nodes; `query` inspects one exact File or Symbol. Both return bounded JSON with provenance. | +| **CodeGraphy Exploration CLI** | `search` combines exact evidence with deterministic all-term fallback ranking for natural phrases; `query` inspects one exact File or Symbol with prioritized declarations and Relationships. Both return bounded JSON with provenance. | +| **CodeGraphy Settings CLI** | `settings`, `settings get`, `settings set`, and `settings unset` read or safely mutate supported workspace settings without silently repairing corrupt persisted input. | | **Graph Query CLI** | `nodes`, `edges`, `dependencies`, `dependents`, and `path`, all with bounded JSON output over the shaped Relationship Graph. | | **CodeGraphy Agent Skill** | Instructions that teach shell-capable agents when to index, which Graph Query command to choose, and when to inspect source. | | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | -The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. Agents start with the narrowest useful discovery operation, run Indexing only after a missing-cache result or when current AST/Relationship facts are required, and inspect returned source evidence directly. +The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. Agents otherwise start with the narrowest useful discovery operation and inspect returned source evidence directly. ## Plugins diff --git a/README.md b/README.md index ac4e29baf..f094a9869 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ The terminal CLI supports Node.js 20 through 22. Node 22 LTS is recommended. ```bash npm install -g @codegraphy-dev/core +codegraphy settings get maxFiles +codegraphy settings set maxFiles 2500 codegraphy index codegraphy search SettingsPanel codegraphy query packages/extension/src/webview/app/shell/view.tsx @@ -126,10 +128,11 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm |---|---| | `codegraphy status` | Reports fresh, stale, or missing Graph Cache state. | | `codegraphy doctor` | Checks runtime, settings, Graph Cache schema, integrity, foreign keys, counts, and plugin state. | -| `codegraphy index` | Makes the selected workspace Graph Cache current. | +| `codegraphy index` | Makes the Graph Cache current and reports actionable file-budget truncation. | +| `codegraphy settings [get|set|unset]` | Safely reads or changes validated workspace settings such as `maxFiles`. | | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | -| `codegraphy search ` | Finds live source lines, cached AST Symbols, and indexed Nodes with provenance. | -| `codegraphy query ` | Inspects one exact File or Symbol with declarations and incoming/outgoing Relationships. | +| `codegraphy search ` | Finds exact evidence and uses deterministic all-term File ranking for sparse natural multi-term phrases. | +| `codegraphy query ` | Inspects one exact File or Symbol with prioritized declarations and incoming/outgoing Relationships. | | `codegraphy edges` | Lists bounded Edges. | | `codegraphy dependencies ` | Lists outgoing Relationships for a file or exact Symbol Node. | | `codegraphy dependents ` | Lists incoming Relationships for a file or exact Symbol Node. | diff --git a/docs/adr/0008-agent-search-and-settings-workflow.md b/docs/adr/0008-agent-search-and-settings-workflow.md new file mode 100644 index 000000000..735a5372e --- /dev/null +++ b/docs/adr/0008-agent-search-and-settings-workflow.md @@ -0,0 +1,79 @@ +# Deterministic phrase search and safe agent settings + +**Status:** Accepted + +## Context + +A controlled hard-task run took 151.1 seconds although the prepared Graph Cache was fresh and covered all 903 eligible files. The run used 26 model turns, 34 tools, and 307,302 processed tokens. It exhausted its four CodeGraphy calls on Search, never used Target Query, followed unrelated `FilterOptions` and `includePatterns` results, and then reread known source through many sequential reads and greps. The CLI processes themselves accounted for only about 4–5 seconds. + +The specific retrieval failure was `search "filter command"`. Literal phrase matching returned one parse-test line instead of `src/cli/filter/command.ts`. + +BM25 was considered as a natural-language fallback. The Robertson BM25 literature defines a term-frequency and inverse-document-frequency rank with document-length normalization. Lucene's `BM25Similarity` uses the common defaults `k1 = 1.2` and `b = 0.75`; SQLite FTS5 exposes the same constants and supports column weighting. + +A 902-document prototype compared literal matching, deterministic all-term ranking, and BM25 over path-boosted source documents. Both alternatives ranked `src/cli/filter/command.ts` first for `filter command`; BM25 handled some looser multi-term queries better, while deterministic ranking preserved stronger path precision. + +A forced six-agent A/B test then required the same first command, `codegraphy search "filter command"`, so every treatment exercised the fallback. BM25 returned the target File first, but its broader ten-File candidate list did not improve task completion: + +| Hard-task median | Literal | BM25 | BM25 delta | +|---|---:|---:|---:| +| Elapsed | 118.7 s | 135.6 s | 14.2% slower | +| Tool calls | 27 | 32 | 18.5% more | +| Total tokens | 176,147 | 183,241 | 4.0% more | +| Tool output | 115,837 B | 87,835 B | 24.2% fewer | + +The direct retrieval improved, but the larger ranked candidate set encouraged more continuation and fallback exploration. An additional unforced A/B did not reliably invoke a natural phrase and was therefore treated as stochastic evidence, not a BM25 comparison. + +The same traces raised a second hypothesis: embedding bounded live source in Target Query might remove a source-read turn. Two three-pair follow-ups rejected it. In v6, source-context Query was 3.4% slower than the prior CodeGraphy interface and used 28.1% more tokens. Stronger no-reread guidance in v7 reduced median elapsed time and calls, but tokens remained 61.2% higher and agents still duplicated or serially followed the context. Source embedding shifted work into larger accumulated model contexts rather than reliably removing it. + +Agents also could not safely raise `maxFiles` or change another top-level workspace setting through one structured interface. Existing persisted-settings readers silently replaced malformed JSON and malformed known fields with defaults, so a mutation could overwrite corruption. + +The selected graph-focused Query, deterministic phrase fallback, and bounded-skill workflow were then compared with ordinary navigation in three fresh paired v10 runs. Every answer contained the complete diagnosis and test surface. Final medians improved all four optimization metrics: + +| Hard-task median | Ordinary navigation | Selected CodeGraphy | Improvement | +|---|---:|---:|---:| +| Elapsed | 131.7 s | 115.0 s | 12.7% faster | +| Tool calls | 32 | 26 | 18.8% fewer | +| Total tokens | 188,319 | 106,418 | 43.5% fewer | +| Tool output | 155,243 B | 100,455 B | 35.3% fewer | + +The treatment won elapsed time in two of three pairs; the sample remains intentionally small. Separate v8/v9 comparisons against the prior CodeGraphy interface confirmed lower elapsed time and tokens but showed call-count variance, so no universal superiority claim is made. + +## Decision + +Do not ship BM25 as the default or add a public ranking flag. + +Keep exact case-insensitive literal, wildcard, AST Symbol, Node, and source-line matches as the primary Search path. When a whitespace-separated natural phrase has fewer than five direct matches: + +- tokenize camelCase, paths, and source text; +- remove a small fixed stop-word set; +- require every query term; +- prefer documents whose path contains every term; +- return at most three deterministic File candidates before lower-ranked direct source evidence. + +Keep Target Query focused on graph evidence. It returns prioritized declarations and incoming/outgoing Relationships; callable and type declarations rank ahead of local constants. Do not embed source text. Once target and test Files are known, the Agent Skill directs one parallel source-read turn instead of serial reads or repeated graph searches. + +Add one workspace-settings interface: + +- `settings` reads all effective settings; +- `settings get ` reads one effective value; +- `settings set ` validates and persists one supported top-level setting; +- `settings unset ` restores its default. + +Settings mutations preserve unknown top-level fields, validate the complete existing file before writing, report `indexRequired`, and never replace malformed persisted input with defaults. Persisted known fields use strict validation; the standalone normalization function remains tolerant for programmatic inputs. + +Indexing remains explicit and separate from settings and querying. Its structured `discovery` result reports `indexedFiles`, `totalFound`, and `limitReached`; a capped result includes the exact `settings set maxFiles` recovery command. An agent raises the file budget or adjusts durable filters only after Indexing reports truncation, then runs `index` and resumes Search or Target Query. + +## Consequences + +- Natural `filter command` discovery returns one high-confidence File candidate instead of a broad lexical list. +- Exact identifier behavior remains deterministic and unchanged. +- Query output stays graph-focused and bounded; exact source remains a deliberate read after all relevant Files are known. +- Configuration corruption becomes visible to Filter, Settings, Doctor, and other persisted-settings callers, and corrupt bytes are preserved. +- BM25 remains a rejected experiment for this corpus and task distribution. It may be reconsidered for substantially larger corpora only with end-to-end agent evidence, not retrieval scores alone. +- SQLite FTS5 is not added. Search already reads current source text, and the in-memory deterministic fallback adds negligible work without introducing another persisted source index or tokenizer contract. + +## References + +- Stephen E. Robertson et al., “Okapi at TREC-3” / BM25 foundations: https://www.staff.city.ac.uk/~sbrp622/papers/foundations_bm25_review.pdf +- Apache Lucene `BM25Similarity`: https://lucene.apache.org/core/9_12_1/core/org/apache/lucene/search/similarities/BM25Similarity.html +- SQLite FTS5 `bm25()` ranking: https://www.sqlite.org/fts5.html#the_bm25_function diff --git a/packages/core/README.md b/packages/core/README.md index 0febd4477..ac056ad3c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,9 +8,11 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `search` merges live source-line matches with cached AST Symbols and indexed Nodes, including file and line provenance. `query` inspects one exact File path or Symbol Node ID and returns its declarations plus incoming and outgoing Relationships. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the shaped graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the shaped graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash +codegraphy settings get maxFiles +codegraphy settings set maxFiles 2500 codegraphy index codegraphy status codegraphy doctor @@ -33,7 +35,7 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` ## Current Entry Points - CodeGraphy Workspace paths: resolve `.codegraphy/settings.json` and `.codegraphy/graph.sqlite` for any folder path. -- Workspace Settings: read, normalize, write, and fingerprint workspace-local settings, including Graph Scope, filters, and ordered plugin entries without erasing extension-owned presentation settings. +- Workspace Settings: strictly validate persisted known fields, safely read or mutate them through the CLI, and preserve unknown extension fields without replacing corrupt input with defaults. - File Discovery: discover analyzable files and directories, apply active custom/plugin/Git filters before the eligible-file budget, and report cache-retention paths without VS Code APIs. - Built-in language analysis: parse supported languages and produce file, symbol, import, call, inherit, reference, and type-import relationships. - File Analysis: run cache-aware per-file plugin analysis and project file relationships without VS Code APIs. @@ -46,8 +48,8 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - Graph Cache status: report whether a workspace-local Graph Cache exists without using VS Code APIs. - Workspace status: report fresh, stale, or missing Graph Cache state with inspectable stale reasons. - Graph Cache storage: load, save, clear, and inspect normalized File, Node, Symbol, and Edge rows in the SQLite-backed Graph Cache at `/.codegraphy/graph.sqlite`. -- Workspace Search: merge bounded live source-line matches, cached AST Symbols, and indexed Nodes with file provenance. -- Target Query: inspect one exact File or Symbol with declarations plus bounded incoming and outgoing Relationships. +- Workspace Search: merge bounded exact evidence with deterministic all-term File fallback candidates for natural multi-term phrases. +- Target Query: inspect one exact File or Symbol with prioritized declarations and bounded Relationships. - Graph Query: list scoped Nodes and Edges, trace dependencies and dependents, and find bounded paths over Relationship Graph data plus persisted analysis metadata. The core package exposes `indexCodeGraphyWorkspace` for explicit path-based Indexing. VS Code and CLI adapters call this package instead of owning independent indexing behavior. diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 0fcdb016d..2344ab397 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -71,7 +71,7 @@ const COMMAND_HELP: Record = { 'Enabled Plugins contribute facts during Indexing.', '', 'Effects: Writes .codegraphy/graph.sqlite and workspace index metadata.', - 'Output: JSON indexing summary.', + 'Output: JSON indexing summary plus discovery.limitReached and an actionable maxFiles command when capped.', 'Example: codegraphy index', ].join('\n'), status: [ @@ -99,6 +99,8 @@ const COMMAND_HELP: Record = { 'Read or safely update workspace settings such as maxFiles, include, or respectGitignore.', 'Values passed to set are JSON: numbers and booleans are unquoted; arrays and objects need shell quotes.', 'Mutations validate the complete persisted file and never replace malformed settings with defaults.', + 'Supported keys: maxFiles, include, respectGitignore, filterPatterns, disabledCustomFilterPatterns,', + ' nodeVisibility, edgeVisibility, plugins, interfaces, pluginData.', '', 'Effects: Read-only without set/unset. Set and unset write .codegraphy/settings.json.', 'Output: JSON effective values; mutations report previous/value and whether Indexing is required.', @@ -112,8 +114,7 @@ const COMMAND_HELP: Record = { 'Usage: codegraphy query ', '', 'Inspect one exact File path or Symbol Node ID returned by `codegraphy search`.', - 'Returns the target, its declared AST Symbols, incoming and outgoing Relationships,', - 'and bounded live source context when the target is an exact Symbol ID.', + 'Returns the target, prioritized declared AST Symbols, and incoming and outgoing Relationships.', 'Use dependencies, dependents, or path only when this bounded overview needs continuation.', '', 'Arguments:', @@ -141,7 +142,7 @@ const COMMAND_HELP: Record = { '', 'Discover matching live source lines, cached AST Symbols, and indexed File or concept Nodes.', 'Literal matching is case-insensitive. Add a `*` wildcard to span characters on one line or name.', - 'When a natural multi-term phrase has few literal matches, BM25 ranks relevant indexed Files.', + 'When a natural multi-term phrase has few literal matches, all-term path/source ranking finds relevant Files.', 'Every Symbol match includes the File it came from. Text matches include line, column, and excerpt.', '', 'Arguments:', diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 96d64bbd6..66648984a 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -45,15 +45,15 @@ describe('cli/help/command', () => { expect(createHelpResult(['query']).output).toContain('Usage: codegraphy query '); expect(createHelpResult(['query']).output).toContain('declared AST Symbols'); expect(createHelpResult(['query']).output).toContain('incoming and outgoing Relationships'); - expect(createHelpResult(['query']).output).toContain('live source context'); expect(createHelpResult(['settings']).output).toContain('settings set maxFiles 2500'); + expect(createHelpResult(['settings']).output).toContain('Supported keys: maxFiles, include, respectGitignore'); expect(createHelpResult(['settings']).output).toContain('never replace malformed settings with defaults'); expect(createHelpResult(['nodes']).output).toContain('Usage: codegraphy nodes'); expect(createHelpResult(['search']).output).toContain('Usage: codegraphy search'); expect(createHelpResult(['search']).output).toContain('live source lines'); expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); - expect(createHelpResult(['search']).output).toContain('BM25'); + expect(createHelpResult(['search']).output).toContain('all-term path/source ranking'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); @@ -80,7 +80,8 @@ describe('cli/help/command', () => { const indexHelp = createHelpResult(['index']).output; expect(indexHelp).toContain('Create or update the workspace Graph Cache.'); expect(indexHelp).toContain('Effects: Writes .codegraphy/graph.sqlite'); - expect(indexHelp).toContain('Output: JSON indexing summary.'); + expect(indexHelp).toContain('discovery.limitReached'); + expect(indexHelp).toContain('actionable maxFiles command'); expect(indexHelp).toContain('Example: codegraphy index'); const filterHelp = createHelpResult(['filter']).output; diff --git a/packages/core/tests/cli/parseSettings.test.ts b/packages/core/tests/cli/parseSettings.test.ts new file mode 100644 index 000000000..e3edf6734 --- /dev/null +++ b/packages/core/tests/cli/parseSettings.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { parseSettingsCommand } from '../../src/cli/parseSettings'; + +describe('cli/parseSettings', () => { + it('parses reads and JSON mutations', () => { + expect(parseSettingsCommand([])).toEqual({ name: 'settings' }); + expect(parseSettingsCommand(['get', 'maxFiles'])).toMatchObject({ + name: 'settings', settingsAction: 'get', settingsKey: 'maxFiles', + }); + expect(parseSettingsCommand(['set', 'include', '["src/**/*.ts"]'])).toMatchObject({ + name: 'settings', settingsAction: 'set', settingsKey: 'include', settingsValue: ['src/**/*.ts'], + }); + expect(parseSettingsCommand(['unset', 'maxFiles'])).toMatchObject({ + name: 'settings', settingsAction: 'unset', settingsKey: 'maxFiles', + }); + }); + + it('rejects unknown keys and malformed JSON values', () => { + expect(parseSettingsCommand(['set', 'unknown', '1'])).toMatchObject({ + parseError: 'Unknown workspace setting: unknown', + }); + expect(parseSettingsCommand(['set', 'maxFiles', 'many'])).toMatchObject({ + parseError: 'settings set value must be valid JSON: many', + }); + }); +}); diff --git a/packages/core/tests/workspace/settingsValidation.test.ts b/packages/core/tests/workspace/settingsValidation.test.ts new file mode 100644 index 000000000..7fdad2805 --- /dev/null +++ b/packages/core/tests/workspace/settingsValidation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { validateWorkspaceSettingsRecord } from '../../src/workspace/settingsValidation'; + +describe('workspace/settingsValidation', () => { + it('accepts supported settings while preserving unknown fields', () => { + expect(validateWorkspaceSettingsRecord({ + maxFiles: 2500, + include: ['src/**'], + respectGitignore: true, + futureSetting: { mode: 'fast' }, + })).toEqual({ + maxFiles: 2500, + include: ['src/**'], + respectGitignore: true, + futureSetting: { mode: 'fast' }, + }); + }); + + it('rejects malformed known settings', () => { + expect(() => validateWorkspaceSettingsRecord({ maxFiles: 0 })).toThrow( + 'maxFiles must be a positive integer', + ); + expect(() => validateWorkspaceSettingsRecord({ filterPatterns: 'generated' })).toThrow( + 'filterPatterns must be an array of strings', + ); + expect(() => validateWorkspaceSettingsRecord({ plugins: [{ id: 'plugin.without.activation' }] })).toThrow( + 'plugin entry must have a nonblank id with activation, or a nonblank package', + ); + }); +}); From bdf476ca62d29e78b79d2b7ffaa4b950d9f605ef Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 12:27:09 -0700 Subject: [PATCH 25/55] docs(skill): avoid speculative navigation calls --- skills/codegraphy/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index c0edf72d7..213d3bfc7 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -35,7 +35,7 @@ Use `-C ` from outside the workspace. Quote patterns containing space Query with the narrowest command that answers the current question. Prefer `search` → `query` → source reads over dumping Nodes or Edges. -Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read the known File or use file-local text search, preferably alongside the other already-known source and test files in one tool turn. Do not search again for identifiers already present in returned declarations. Use at most four CodeGraphy calls for an investigation unless you are following `nextOffset`; if four calls have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. This navigation budget does not weaken the answer: verify and state every requested condition, default adapter, side-effect destination, and test from source before stopping. +Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read the known File or use file-local text search, preferably alongside the other already-known source and test files in one tool turn. Do not search again for identifiers already present in returned declarations. Use at most four individual CodeGraphy invocations for an investigation unless you are following `nextOffset`; commands launched together still count separately. Do not guess possible function or type names for another Search—use only task literals or identifiers already returned. If four invocations have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. This navigation budget does not weaken the answer: verify and state every requested condition, default adapter, side-effect destination, and test from source before stopping. ## Keep output bounded From d30cb0b7816975a9f1802c2dd48c4d071da7adf9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 13:47:56 -0700 Subject: [PATCH 26/55] fix(cli): query the complete graph by default --- packages/core/src/graphQuery/paths.ts | 24 +++++++++++- packages/core/src/workspace/requestQuery.ts | 26 +++++++++++-- packages/core/tests/graphQuery/paths.test.ts | 29 +++++++++++++++ .../core/tests/workspace/requestQuery.test.ts | 37 +++++++++++++++++++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/core/src/graphQuery/paths.ts b/packages/core/src/graphQuery/paths.ts index 11d54685e..af8bae2de 100644 --- a/packages/core/src/graphQuery/paths.ts +++ b/packages/core/src/graphQuery/paths.ts @@ -21,14 +21,29 @@ interface PathCollection { truncated: boolean; } +function exactSelectorFirst(ids: readonly string[], selector: string): string[] { + return [...ids].sort((left, right) => ( + Number(right === selector) - Number(left === selector) + || left.localeCompare(right) + )); +} + function collectExpandedPaths( graphData: IGraphData, config: GraphQueryPathConfig, maxDepth: number, maxPaths: number, ): PathCollection { - const fromIds = resolveSelectorNodeIds(graphData, config.from, config.expandFileSelectors === true); - const toIds = resolveSelectorNodeIds(graphData, config.to, config.expandFileSelectors === true); + const fromIds = exactSelectorFirst( + resolveSelectorNodeIds(graphData, config.from, config.expandFileSelectors === true), + config.from, + ); + const toIds = exactSelectorFirst( + resolveSelectorNodeIds(graphData, config.to, config.expandFileSelectors === true), + config.to, + ); + const fromIsFile = isFileSelector(graphData, config.from); + const toIsFile = isFileSelector(graphData, config.to); const paths: string[][] = []; let truncated = false; @@ -42,6 +57,11 @@ function collectExpandedPaths( ); paths.push(...result.paths); truncated ||= result.truncated; + const reachedExactFileEndpoint = result.paths.length > 0 && ( + (fromIsFile && from === config.from) + || (toIsFile && to === config.to) + ); + if (reachedExactFileEndpoint) return { paths, truncated }; if (paths.length >= maxPaths) return { paths: paths.slice(0, maxPaths), truncated: true }; } } diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index d460eb174..7d4fb65cc 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -6,6 +6,7 @@ import { executeGraphQuery, type GraphQueryRequest, } from '../graphQuery'; +import { getNodeType } from '../visibleGraph/model'; import { emitGraphQueryCacheMissing, emitGraphQueryCompleted, emitGraphQueryStarted } from './queryDiagnostics'; import { projectWorkspaceQueryGraph, @@ -48,6 +49,16 @@ function createCacheMissingResult(workspaceRoot: string): WorkspaceGraphQueryRes }; } +function hasTargetSelector(arguments_: Record): boolean { + return ['from', 'to', 'target', 'filePath', 'relatedFrom', 'relatedTo'] + .some(key => typeof arguments_[key] === 'string'); +} + +function shouldApplyWorkspaceGraphScope(input: WorkspaceGraphQueryInput): boolean { + if (input.report === 'search' || input.report === 'overview' || input.report === 'paths') return false; + return !hasTargetSelector(input.arguments); +} + function executeWorkspaceGraphQuery( input: Omit, workspaceRoot: string, @@ -64,6 +75,16 @@ function executeWorkspaceGraphQuery( const sourceText = input.report === 'search' ? readWorkspaceQuerySourceText(workspaceRoot, graphData, source.indexedContentHashes) : undefined; + const completeScope = { + nodes: Object.fromEntries(graphData.nodes.map(node => [getNodeType(node), true])), + edges: Object.fromEntries(graphData.edges.map(edge => [edge.kind, true])), + }; + const queryScope = shouldApplyWorkspaceGraphScope(input) + ? { nodes: scope.nodes, edges: scope.edges } + : { + nodes: input.projection?.nodeTypes ? scope.nodes : completeScope.nodes, + edges: input.projection?.edgeTypes ? scope.edges : completeScope.edges, + }; const queryResult = executeGraphQuery({ graphData, symbols: snapshotFacts.symbols, @@ -73,10 +94,7 @@ function executeWorkspaceGraphQuery( }, { report: input.report, arguments: { - scope: { - nodes: scope.nodes, - edges: scope.edges, - }, + scope: queryScope, ...(input.projection?.nodeTypes ? { nodeTypeDefinitions: nodeTypes, diff --git a/packages/core/tests/graphQuery/paths.test.ts b/packages/core/tests/graphQuery/paths.test.ts index 7abf6432a..f6802662e 100644 --- a/packages/core/tests/graphQuery/paths.test.ts +++ b/packages/core/tests/graphQuery/paths.test.ts @@ -137,6 +137,35 @@ describe('core/graphQuery paths report', () => { }); }); + it('prefers an exact File endpoint before expanding its Symbols', () => { + const mixedGraph: IGraphData = { + nodes: [ + { id: 'a.ts', label: 'a.ts', nodeType: 'file' }, + { id: 'a.ts#run:function', label: 'run', nodeType: 'symbol:function', symbol: { + id: 'a.ts#run:function', filePath: 'a.ts', name: 'run', kind: 'function', + } }, + { id: 'z.ts', label: 'z.ts', nodeType: 'file' }, + { id: 'z.ts#target:function', label: 'target', nodeType: 'symbol:function', symbol: { + id: 'z.ts#target:function', filePath: 'z.ts', name: 'target', kind: 'function', + } }, + ], + edges: [ + { id: 'run-z', from: 'a.ts#run:function', to: 'z.ts', kind: 'call', sources: [] }, + { id: 'z-target', from: 'z.ts', to: 'z.ts#target:function', kind: 'contains', sources: [] }, + ], + }; + + expect(findGraphPaths(mixedGraph, { + from: 'a.ts#run:function', + to: 'z.ts', + expandFileSelectors: true, + projectFileEndpoints: true, + })).toMatchObject({ + paths: [['a.ts#run:function', 'z.ts']], + complete: true, + }); + }); + it('keeps collecting raw symbol routes until projected file paths are unique', () => { const symbolNode = (id: string, filePath: string) => ({ id, diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index 4bb13390b..3f1409aee 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -45,6 +45,43 @@ describe('workspace/requestQuery', () => { }); }); + it('uses complete graph scope for an exact targeted Relationship query', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-target-scope-')); + await fs.writeFile(path.join(workspaceRoot, 'dependency.ts'), [ + 'export function readSetting(): string {', + " return 'value';", + '}', + '', + ].join('\n')); + await fs.writeFile(path.join(workspaceRoot, 'entry.ts'), [ + "import { readSetting } from './dependency';", + 'export function runCommand(): string {', + ' return readSetting();', + '}', + '', + ].join('\n')); + await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); + + for (const projection of [undefined, { edgeTypes: ['call'] }]) { + const result = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'edges', + arguments: { + from: 'entry.ts#runCommand:function', + edgeType: 'call', + limit: 20, + }, + ...(projection ? { projection } : {}), + }); + + expect(result.edges).toEqual([{ + from: 'entry.ts#runCommand:function', + to: 'dependency.ts#readSetting:function', + edgeTypes: ['call'], + }]); + } + }); + it('reads live text after Indexing while marking cached Symbols stale', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-live-text-')); const entryPath = path.join(workspaceRoot, 'entry.ts'); From 1a5713a03cf27e195cbef58435da65c987058c8c Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 14:08:14 -0700 Subject: [PATCH 27/55] feat(core): resolve calls through reexports --- .../src/analysis/fileAnalysis/enrichment.ts | 6 +- .../analysis/fileAnalysis/reexportTarget.ts | 118 ++++++++++ .../src/analysis/fileAnalysis/targetSymbol.ts | 33 +-- .../analysis/fileAnalysis/targetSymbolName.ts | 19 +- packages/core/src/graphScope/defaults.ts | 2 + .../plugins/routing/router/results/keys.ts | 21 +- .../runtime/analyzeJavaScript/file.ts | 8 +- .../runtime/analyzeJavaScript/imports.ts | 62 +++++- .../src/treeSitter/runtime/capabilities.ts | 7 +- .../analysis/fileAnalysis/enrichment.test.ts | 5 +- .../fileAnalysis/reexportTarget.test.ts | 206 ++++++++++++++++++ .../fileAnalysis/targetSymbolName.test.ts | 7 + .../core/tests/graphScope/defaults.test.ts | 1 + .../tests/plugins/routingAnalysis.test.ts | 30 +++ .../tests/plugins/routingRelationKeys.test.ts | 23 ++ .../core/tests/treeSitter/analyze.test.ts | 14 +- packages/core/tests/treeSitter/core.test.ts | 3 + .../treeSitter/javascript/imports.test.ts | 59 ++++- .../core/tests/workspace/requestQuery.test.ts | 55 +++++ packages/plugin-api/src/graph.ts | 1 + .../plugin-api/tests/pluginContracts.test.ts | 5 + 21 files changed, 637 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/analysis/fileAnalysis/reexportTarget.ts create mode 100644 packages/core/tests/analysis/fileAnalysis/reexportTarget.test.ts diff --git a/packages/core/src/analysis/fileAnalysis/enrichment.ts b/packages/core/src/analysis/fileAnalysis/enrichment.ts index af3e9bb32..a9c9d3c4f 100644 --- a/packages/core/src/analysis/fileAnalysis/enrichment.ts +++ b/packages/core/src/analysis/fileAnalysis/enrichment.ts @@ -8,6 +8,10 @@ export function enrichWorkspaceFileAnalysis( fileAnalysis: ReadonlyMap, ): Map { const symbolsByFilePath = createSymbolsByFilePath(fileAnalysis); + const relationsByFilePath = new Map([...fileAnalysis.values()].map(analysis => [ + analysis.filePath, + analysis.relations ?? [], + ])); return new Map( Array.from(fileAnalysis.entries()).map(([filePath, analysis]) => [ @@ -15,7 +19,7 @@ export function enrichWorkspaceFileAnalysis( { ...analysis, relations: (analysis.relations ?? []).map((relation) => - enrichRelationTargetSymbol(relation, symbolsByFilePath), + enrichRelationTargetSymbol(relation, symbolsByFilePath, relationsByFilePath), ), }, ]), diff --git a/packages/core/src/analysis/fileAnalysis/reexportTarget.ts b/packages/core/src/analysis/fileAnalysis/reexportTarget.ts new file mode 100644 index 000000000..ddc6ac257 --- /dev/null +++ b/packages/core/src/analysis/fileAnalysis/reexportTarget.ts @@ -0,0 +1,118 @@ +import type { + IAnalysisRelation, + IAnalysisSymbol, +} from '@codegraphy-dev/plugin-api'; +import { readRelationSymbolName, resolveTargetSymbolId } from './targetSymbolName'; + +export interface ResolvedTargetSymbol { + filePath: string; + symbolId: string; +} + +function readMetadataString(relation: IAnalysisRelation, key: string): string | undefined { + const value = relation.metadata?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function isMetadataTrue(relation: IAnalysisRelation, key: string): boolean { + return relation.metadata?.[key] === true; +} + +function persistedReexportTargetName( + reexport: IAnalysisRelation, + symbolsByFilePath: ReadonlyMap, +): string | undefined { + if (reexport.kind !== 'reexport' || !reexport.toFilePath || !reexport.toSymbolId) { + return undefined; + } + return symbolsByFilePath + .get(reexport.toFilePath) + ?.find(symbol => symbol.id === reexport.toSymbolId) + ?.name; +} + +function forwardedReexportRelation( + relation: IAnalysisRelation, + reexport: IAnalysisRelation, + symbolName: string, + symbolsByFilePath: ReadonlyMap, +): IAnalysisRelation | undefined { + if (!reexport.toFilePath) return undefined; + const reexportAll = isMetadataTrue(reexport, 'reexportAll'); + const exportedName = readMetadataString(reexport, 'exportedName'); + const persistedTargetName = persistedReexportTargetName(reexport, symbolsByFilePath); + const namedReexport = isMetadataTrue(reexport, 'reexport') && exportedName === symbolName; + const persistedFileReexport = reexport.kind === 'reexport' + && !reexport.toSymbolId + && !isMetadataTrue(reexport, 'reexport'); + if (!reexportAll && !namedReexport && !persistedFileReexport + && persistedTargetName !== symbolName) return undefined; + const importedName = reexportAll || persistedFileReexport + ? symbolName + : readMetadataString(reexport, 'importedName') ?? persistedTargetName ?? symbolName; + return { + ...relation, + toFilePath: reexport.toFilePath, + resolvedPath: reexport.resolvedPath ?? reexport.toFilePath, + metadata: { + ...relation.metadata, + importedName, + }, + }; +} + +function followsReexportedTargets(relation: IAnalysisRelation): boolean { + return ['call', 'event', 'inherit', 'reference'].includes(relation.kind); +} + +export function resolveRelationTargetSymbols( + relation: IAnalysisRelation, + symbolsByFilePath: ReadonlyMap, + relationsByFilePath: ReadonlyMap, + visitedFiles: ReadonlySet = new Set(), +): ResolvedTargetSymbol[] { + if (!relation.toFilePath || visitedFiles.has(relation.toFilePath)) return []; + const targetSymbols = symbolsByFilePath.get(relation.toFilePath) ?? []; + const persistedTarget = relation.toSymbolId + ? targetSymbols.find(symbol => symbol.id === relation.toSymbolId) + : undefined; + const directSymbolId = persistedTarget?.id ?? (targetSymbols.length > 0 + ? resolveTargetSymbolId(relation, targetSymbols) + : undefined); + const directSymbol = targetSymbols.find(symbol => symbol.id === directSymbolId); + if (directSymbol && directSymbol.kind !== 'alias') { + return [{ filePath: relation.toFilePath, symbolId: directSymbol.id }]; + } + if (directSymbol) { + const nextVisited = new Set([...visitedFiles, relation.toFilePath]); + return (relationsByFilePath.get(relation.toFilePath) ?? []) + .filter(aliasRelation => aliasRelation.fromSymbolId === directSymbol.id) + .flatMap(aliasRelation => resolveRelationTargetSymbols( + aliasRelation, + symbolsByFilePath, + relationsByFilePath, + nextVisited, + )); + } + if (!followsReexportedTargets(relation)) return []; + + const symbolName = readRelationSymbolName(relation); + if (!symbolName) return []; + const nextVisited = new Set([...visitedFiles, relation.toFilePath]); + return (relationsByFilePath.get(relation.toFilePath) ?? []).flatMap((reexport) => { + const forwarded = forwardedReexportRelation( + relation, + reexport, + symbolName, + symbolsByFilePath, + ); + return forwarded + ? resolveRelationTargetSymbols( + forwarded, + symbolsByFilePath, + relationsByFilePath, + nextVisited, + ) + : []; + }); +} diff --git a/packages/core/src/analysis/fileAnalysis/targetSymbol.ts b/packages/core/src/analysis/fileAnalysis/targetSymbol.ts index 57e59a229..45a71b2ae 100644 --- a/packages/core/src/analysis/fileAnalysis/targetSymbol.ts +++ b/packages/core/src/analysis/fileAnalysis/targetSymbol.ts @@ -2,26 +2,27 @@ import type { IAnalysisRelation, IAnalysisSymbol, } from '@codegraphy-dev/plugin-api'; -import { resolveTargetSymbolId } from './targetSymbolName'; +import { resolveRelationTargetSymbols } from './reexportTarget'; export function enrichRelationTargetSymbol( relation: IAnalysisRelation, symbolsByFilePath: ReadonlyMap, + relationsByFilePath: ReadonlyMap = new Map(), ): IAnalysisRelation { - if (relation.toSymbolId || !relation.toFilePath) { - return relation; - } + if (relation.toSymbolId || !relation.toFilePath) return relation; - const targetSymbols = symbolsByFilePath.get(relation.toFilePath); - if (!targetSymbols?.length) { - return relation; - } - - const resolvedSymbolId = resolveTargetSymbolId(relation, targetSymbols); - return resolvedSymbolId - ? { - ...relation, - toSymbolId: resolvedSymbolId, - } - : relation; + const targets = resolveRelationTargetSymbols( + relation, + symbolsByFilePath, + relationsByFilePath, + ); + const uniqueTargets = new Map(targets.map(target => [target.symbolId, target])); + if (uniqueTargets.size !== 1) return relation; + const target = [...uniqueTargets.values()][0]; + return { + ...relation, + toFilePath: target.filePath, + resolvedPath: target.filePath, + toSymbolId: target.symbolId, + }; } diff --git a/packages/core/src/analysis/fileAnalysis/targetSymbolName.ts b/packages/core/src/analysis/fileAnalysis/targetSymbolName.ts index c799fa48b..17546aeff 100644 --- a/packages/core/src/analysis/fileAnalysis/targetSymbolName.ts +++ b/packages/core/src/analysis/fileAnalysis/targetSymbolName.ts @@ -8,14 +8,13 @@ export function resolveTargetSymbolId( targetSymbols: readonly IAnalysisSymbol[], ): string | undefined { const symbolName = readRelationSymbolName(relation); - const namedSymbolId = symbolName - ? resolveUniqueSymbolName(targetSymbols, symbolName) - : undefined; - - return namedSymbolId ?? resolveUniqueTargetSymbol(targetSymbols); + if (symbolName) return resolveUniqueSymbolName(targetSymbols, symbolName); + return hasRelationSymbolNameHint(relation) + ? undefined + : resolveUniqueTargetSymbol(targetSymbols); } -function readRelationSymbolName( +export function readRelationSymbolName( relation: IAnalysisRelation, ): string | undefined { const memberName = readRelationMetadataString(relation, 'memberName'); @@ -24,6 +23,14 @@ function readRelationSymbolName( return memberName ?? readNamedImport(importedName) ?? relation.specifier; } +function hasRelationSymbolNameHint(relation: IAnalysisRelation): boolean { + return Boolean( + readRelationMetadataString(relation, 'memberName') + || readRelationMetadataString(relation, 'importedName') + || relation.specifier, + ); +} + function readNamedImport(importedName: string | undefined): string | undefined { return importedName && importedName !== '*' && importedName !== 'default' ? importedName diff --git a/packages/core/src/graphScope/defaults.ts b/packages/core/src/graphScope/defaults.ts index bb7fb5211..5f348c448 100644 --- a/packages/core/src/graphScope/defaults.ts +++ b/packages/core/src/graphScope/defaults.ts @@ -17,6 +17,7 @@ export const CORE_GRAPH_EDGE_TYPES = [ 'load', 'nests', 'overrides', + 'reexport', 'reference', 'type', 'type-import', @@ -35,6 +36,7 @@ export const CORE_GRAPH_EDGE_DEFAULT_VISIBILITY: Record = { inherit: false, load: false, overrides: false, + reexport: false, reference: false, type: false, 'type-import': false, diff --git a/packages/core/src/plugins/routing/router/results/keys.ts b/packages/core/src/plugins/routing/router/results/keys.ts index dcdf3b795..fa99778f4 100644 --- a/packages/core/src/plugins/routing/router/results/keys.ts +++ b/packages/core/src/plugins/routing/router/results/keys.ts @@ -15,6 +15,22 @@ function getBaseRelationKeyParts( ]; } +function getBindingRelationKeyParts( + relation: NonNullable[number], +): string[] { + const metadata = relation.metadata; + const parts = [ + metadata?.bindingKind, + metadata?.importedName, + metadata?.localName, + metadata?.memberName, + metadata?.exportedName, + metadata?.reexport, + metadata?.reexportAll, + ].map(value => value === undefined || value === null ? '' : String(value)); + return parts.some(Boolean) ? ['binding', ...parts] : []; +} + function getResolvedRelationKeyParts( relation: NonNullable[number], ): string[] { @@ -27,7 +43,10 @@ function getResolvedRelationKeyParts( } export function getRelationKey(relation: NonNullable[number]): string { - const key = getBaseRelationKeyParts(relation); + const key = [ + ...getBaseRelationKeyParts(relation), + ...getBindingRelationKeyParts(relation), + ]; if (relation.kind === 'call' || relation.kind === 'reference' || relation.kind === 'event') { key.push(...getResolvedRelationKeyParts(relation)); diff --git a/packages/core/src/treeSitter/runtime/analyzeJavaScript/file.ts b/packages/core/src/treeSitter/runtime/analyzeJavaScript/file.ts index 61488e626..9d75874aa 100644 --- a/packages/core/src/treeSitter/runtime/analyzeJavaScript/file.ts +++ b/packages/core/src/treeSitter/runtime/analyzeJavaScript/file.ts @@ -72,7 +72,13 @@ const JAVASCRIPT_NODE_VISITORS: Record = { }, enum_declaration: handleTypeDeclarationNode, export_statement: (node, context) => { - handleJavaScriptExportStatement(node, context.filePath, context.relations); + handleJavaScriptExportStatement( + node, + context.filePath, + context.relations, + context.symbols, + context.symbolsEnabled, + ); }, function_declaration: (node, context) => context.symbolsEnabled diff --git a/packages/core/src/treeSitter/runtime/analyzeJavaScript/imports.ts b/packages/core/src/treeSitter/runtime/analyzeJavaScript/imports.ts index bbabc737e..40bd607dd 100644 --- a/packages/core/src/treeSitter/runtime/analyzeJavaScript/imports.ts +++ b/packages/core/src/treeSitter/runtime/analyzeJavaScript/imports.ts @@ -1,10 +1,10 @@ import type Parser from 'tree-sitter'; -import type { IAnalysisRelation } from '@codegraphy-dev/plugin-api'; +import type { IAnalysisRelation, IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; import { TREE_SITTER_SOURCE_IDS } from '../languages'; import { resolveTreeSitterImportPath } from '../resolve'; import type { ImportedBinding, SymbolWalkState, TreeWalkAction } from '../analyze/model'; import { getStringSpecifier } from '../analyze/nodes'; -import { addRelation } from '../analyze/results'; +import { addRelation, createSymbol } from '../analyze/results'; import { hasValueImport } from './importKinds'; import { addTypeImportRelations, addValueImportRelations } from './importRelations'; import { hasDirectTypeKeyword, hasTypeSpecifierImport } from './typeImports/markers'; @@ -49,23 +49,61 @@ export function handleJavaScriptImportStatement( return { skipChildren: true }; } -export function handleJavaScriptExportStatement( - node: Parser.SyntaxNode, - filePath: string, +function addReexportRelation( relations: IAnalysisRelation[], + filePath: string, + specifier: string, + resolvedPath: string | null, + metadata: NonNullable, + fromSymbolId?: string, ): void { - const specifier = getStringSpecifier(node.namedChildren.find((child) => child.type === 'string')); - if (!specifier) { - return; - } - - const resolvedPath = resolveTreeSitterImportPath(filePath, specifier); addRelation(relations, { - kind: 'import', + kind: 'reexport', sourceId: TREE_SITTER_SOURCE_IDS.import, fromFilePath: filePath, + ...(fromSymbolId ? { fromSymbolId } : {}), specifier, resolvedPath, toFilePath: resolvedPath, + metadata, }); } + +function readExportSpecifierName( + exportSpecifier: Parser.SyntaxNode, + field: 'name' | 'alias', +): string | undefined { + return exportSpecifier.childForFieldName(field)?.text; +} + +export function handleJavaScriptExportStatement( + node: Parser.SyntaxNode, + filePath: string, + relations: IAnalysisRelation[], + symbols: IAnalysisSymbol[] = [], + symbolsEnabled = true, +): void { + const specifier = getStringSpecifier(node.namedChildren.find((child) => child.type === 'string')); + if (!specifier) return; + const resolvedPath = resolveTreeSitterImportPath(filePath, specifier); + const exportClause = node.namedChildren.find((child) => child.type === 'export_clause'); + if (!exportClause) { + addReexportRelation(relations, filePath, specifier, resolvedPath, { reexportAll: true }); + return; + } + + for (const exported of exportClause.namedChildren.filter(child => child.type === 'export_specifier')) { + const importedName = readExportSpecifierName(exported, 'name'); + if (!importedName) continue; + const exportedName = readExportSpecifierName(exported, 'alias') ?? importedName; + const alias = symbolsEnabled && exportedName !== importedName + ? createSymbol(filePath, 'alias', exportedName, exported) + : undefined; + if (alias) symbols.push(alias); + addReexportRelation(relations, filePath, specifier, resolvedPath, { + reexport: true, + importedName, + exportedName, + }, alias?.id); + } +} diff --git a/packages/core/src/treeSitter/runtime/capabilities.ts b/packages/core/src/treeSitter/runtime/capabilities.ts index 455660a70..d66900f12 100644 --- a/packages/core/src/treeSitter/runtime/capabilities.ts +++ b/packages/core/src/treeSitter/runtime/capabilities.ts @@ -18,6 +18,7 @@ interface TreeSitterCapabilityContext { const DEFAULT_TREE_SITTER_EDGE_TYPE_CAPABILITIES = [ 'import', + 'reexport', 'reference', 'call', 'type-import', @@ -32,7 +33,7 @@ const TREE_SITTER_EDGE_TYPE_CAPABILITIES_BY_LANGUAGE = { go: ['import', 'reference', 'call', 'inherit', 'contains'], haskell: ['import', 'reference', 'call', 'contains'], java: ['import', 'reference', 'call', 'inherit'], - javascript: ['import', 'call', 'inherit'], + javascript: ['import', 'reexport', 'call', 'inherit'], kotlin: ['import', 'reference', 'call', 'inherit'], lua: ['import', 'reference', 'call'], objectiveC: ['import', 'reference', 'call', 'inherit'], @@ -43,8 +44,8 @@ const TREE_SITTER_EDGE_TYPE_CAPABILITIES_BY_LANGUAGE = { rust: ['import', 'reference', 'call'], scala: ['import', 'reference', 'call', 'inherit'], swift: ['import', 'reference', 'call', 'inherit'], - tsx: ['import', 'type-import', 'call', 'inherit', 'contains'], - typescript: ['import', 'type-import', 'call', 'inherit', 'contains'], + tsx: ['import', 'reexport', 'type-import', 'call', 'inherit', 'contains'], + typescript: ['import', 'reexport', 'type-import', 'call', 'inherit', 'contains'], } as const satisfies Record; const TREE_SITTER_NODE_TYPE_CAPABILITIES_BY_LANGUAGE = { diff --git a/packages/core/tests/analysis/fileAnalysis/enrichment.test.ts b/packages/core/tests/analysis/fileAnalysis/enrichment.test.ts index 9996edb87..86d11647e 100644 --- a/packages/core/tests/analysis/fileAnalysis/enrichment.test.ts +++ b/packages/core/tests/analysis/fileAnalysis/enrichment.test.ts @@ -29,10 +29,7 @@ function analysis( filePath: string, values: Partial, ): IFileAnalysisResult { - return { - filePath, - ...values, - }; + return { filePath, ...values }; } describe('pipeline/fileAnalysis/enrichment', () => { diff --git a/packages/core/tests/analysis/fileAnalysis/reexportTarget.test.ts b/packages/core/tests/analysis/fileAnalysis/reexportTarget.test.ts new file mode 100644 index 000000000..d9810242a --- /dev/null +++ b/packages/core/tests/analysis/fileAnalysis/reexportTarget.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; +import type { + IAnalysisRelation, + IAnalysisSymbol, + IFileAnalysisResult, +} from '@codegraphy-dev/plugin-api'; +import { enrichWorkspaceFileAnalysis } from '../../../src/analysis/fileAnalysis/enrichment'; + +function symbol(filePath: string, name: string): IAnalysisSymbol { + return { + filePath, + id: `${filePath}:${name}`, + kind: 'function', + name, + }; +} + +function relation(overrides: Partial): IAnalysisRelation { + return { + fromFilePath: '/workspace/src/source.ts', + kind: 'import', + sourceId: 'test-source', + toFilePath: '/workspace/src/target.ts', + ...overrides, + }; +} + +function analysis( + filePath: string, + values: Partial, +): IFileAnalysisResult { + return { filePath, ...values }; +} + +function caller(importedName: string): IFileAnalysisResult { + return analysis('/workspace/src/caller.ts', { + relations: [relation({ + kind: 'call', + toFilePath: '/workspace/src/barrel.ts', + metadata: { importedName }, + })], + }); +} + +function expectCallerTarget( + result: ReadonlyMap, + filePath: string, + symbolId: string, +): void { + expect(result.get('src/caller.ts')?.relations?.[0]).toEqual( + expect.objectContaining({ toFilePath: filePath, toSymbolId: symbolId }), + ); +} + +describe('pipeline/fileAnalysis/reexportTarget', () => { + it('resolves named calls through a star re-export without redirecting lexical imports', () => { + const source = caller('readSetting'); + source.relations?.push(relation({ + kind: 'import', + toFilePath: '/workspace/src/barrel.ts', + metadata: { importedName: 'readSetting' }, + })); + const result = enrichWorkspaceFileAnalysis(new Map([ + ['src/caller.ts', source], + ['src/barrel.ts', analysis('/workspace/src/barrel.ts', { + relations: [relation({ + fromFilePath: '/workspace/src/barrel.ts', + toFilePath: '/workspace/src/storage.ts', + metadata: { reexportAll: true }, + })], + })], + ['src/storage.ts', analysis('/workspace/src/storage.ts', { + symbols: [ + symbol('/workspace/src/storage.ts', 'readSetting'), + symbol('/workspace/src/storage.ts', 'writeSetting'), + ], + })], + ])); + + expect(result.get('src/caller.ts')?.relations).toEqual([ + expect.objectContaining({ + toFilePath: '/workspace/src/storage.ts', + toSymbolId: '/workspace/src/storage.ts:readSetting', + }), + expect.objectContaining({ toFilePath: '/workspace/src/barrel.ts' }), + ]); + }); + + it('reuses persisted re-export Symbol edges without analyzer metadata', () => { + const result = enrichWorkspaceFileAnalysis(new Map([ + ['src/caller.ts', caller('readSetting')], + ['src/barrel.ts', analysis('/workspace/src/barrel.ts', { + relations: [relation({ + kind: 'reexport', + fromFilePath: '/workspace/src/barrel.ts', + toFilePath: '/workspace/src/storage.ts', + toSymbolId: '/workspace/src/storage.ts:readSetting', + })], + })], + ['src/storage.ts', analysis('/workspace/src/storage.ts', { + symbols: [symbol('/workspace/src/storage.ts', 'readSetting')], + })], + ])); + + expectCallerTarget( + result, + '/workspace/src/storage.ts', + '/workspace/src/storage.ts:readSetting', + ); + }); + + it('reuses persisted file-level re-export edges as export-star traversal', () => { + const result = enrichWorkspaceFileAnalysis(new Map([ + ['src/caller.ts', caller('readSetting')], + ['src/barrel.ts', analysis('/workspace/src/barrel.ts', { + relations: [relation({ + kind: 'reexport', + fromFilePath: '/workspace/src/barrel.ts', + toFilePath: '/workspace/src/storage.ts', + })], + })], + ['src/storage.ts', analysis('/workspace/src/storage.ts', { + symbols: [ + symbol('/workspace/src/storage.ts', 'readSetting'), + symbol('/workspace/src/storage.ts', 'writeSetting'), + ], + })], + ])); + + expectCallerTarget( + result, + '/workspace/src/storage.ts', + '/workspace/src/storage.ts:readSetting', + ); + }); + + it('follows a persisted re-export alias to its implementation Symbol', () => { + const alias = { + ...symbol('/workspace/src/barrel.ts', 'publicRead'), + id: '/workspace/src/barrel.ts:publicRead:alias', + kind: 'alias', + }; + const result = enrichWorkspaceFileAnalysis(new Map([ + ['src/caller.ts', caller('publicRead')], + ['src/barrel.ts', analysis('/workspace/src/barrel.ts', { + symbols: [alias], + relations: [relation({ + kind: 'reexport', + fromFilePath: '/workspace/src/barrel.ts', + fromSymbolId: alias.id, + toFilePath: '/workspace/src/storage.ts', + toSymbolId: '/workspace/src/storage.ts:internalRead', + })], + })], + ['src/storage.ts', analysis('/workspace/src/storage.ts', { + symbols: [symbol('/workspace/src/storage.ts', 'internalRead')], + })], + ])); + + expectCallerTarget( + result, + '/workspace/src/storage.ts', + '/workspace/src/storage.ts:internalRead', + ); + }); + + it('resolves an aliased named re-export without following unrelated exports', () => { + const result = enrichWorkspaceFileAnalysis(new Map([ + ['src/caller.ts', caller('publicRead')], + ['src/barrel.ts', analysis('/workspace/src/barrel.ts', { + relations: [ + relation({ + fromFilePath: '/workspace/src/barrel.ts', + toFilePath: '/workspace/src/storage.ts', + metadata: { + reexport: true, + exportedName: 'publicRead', + importedName: 'internalRead', + }, + }), + relation({ + fromFilePath: '/workspace/src/barrel.ts', + toFilePath: '/workspace/src/other.ts', + metadata: { + reexport: true, + exportedName: 'other', + importedName: 'other', + }, + }), + ], + })], + ['src/storage.ts', analysis('/workspace/src/storage.ts', { + symbols: [symbol('/workspace/src/storage.ts', 'internalRead')], + })], + ['src/other.ts', analysis('/workspace/src/other.ts', { + symbols: [symbol('/workspace/src/other.ts', 'other')], + })], + ])); + + expectCallerTarget( + result, + '/workspace/src/storage.ts', + '/workspace/src/storage.ts:internalRead', + ); + }); +}); diff --git a/packages/core/tests/analysis/fileAnalysis/targetSymbolName.test.ts b/packages/core/tests/analysis/fileAnalysis/targetSymbolName.test.ts index 385534fc8..cacc54b35 100644 --- a/packages/core/tests/analysis/fileAnalysis/targetSymbolName.test.ts +++ b/packages/core/tests/analysis/fileAnalysis/targetSymbolName.test.ts @@ -32,6 +32,13 @@ describe('pipeline/fileAnalysis/targetSymbolName', () => { )).toBe('/workspace/src/target.ts:target'); }); + it('does not substitute the sole target when a requested name is missing', () => { + expect(resolveTargetSymbolId( + relation({ metadata: { importedName: 'missing' } }), + [symbol('/workspace/src/target.ts', 'other')], + )).toBeUndefined(); + }); + it('does not pick an ambiguous named target symbol', () => { expect(resolveTargetSymbolId( relation({ metadata: { importedName: 'target' } }), diff --git a/packages/core/tests/graphScope/defaults.test.ts b/packages/core/tests/graphScope/defaults.test.ts index 8ce3ccab2..440b53fee 100644 --- a/packages/core/tests/graphScope/defaults.test.ts +++ b/packages/core/tests/graphScope/defaults.test.ts @@ -19,6 +19,7 @@ describe('graphScope/defaults', () => { expect(CORE_GRAPH_EDGE_DEFAULT_VISIBILITY.import).toBe(true); expect(CORE_GRAPH_EDGE_DEFAULT_VISIBILITY.using).toBe(true); expect(CORE_GRAPH_EDGE_DEFAULT_VISIBILITY.call).toBe(false); + expect(CORE_GRAPH_EDGE_DEFAULT_VISIBILITY.reexport).toBe(false); expect(CORE_GRAPH_EDGE_DEFAULT_VISIBILITY.reference).toBe(false); }); }); diff --git a/packages/core/tests/plugins/routingAnalysis.test.ts b/packages/core/tests/plugins/routingAnalysis.test.ts index 2736a8c3a..aafb67f63 100644 --- a/packages/core/tests/plugins/routingAnalysis.test.ts +++ b/packages/core/tests/plugins/routingAnalysis.test.ts @@ -122,6 +122,36 @@ describe('plugins/routing', () => { consoleError.mockRestore(); }); + it('retains distinct named relations to the same module', async () => { + const result = await analyzeFileResult( + 'src/app.ts', + 'content', + '/workspace', + new Map(), + new Map(), + async () => ({ + filePath: 'src/app.ts', + relations: [ + relation({ + kind: 'call', + specifier: './settings', + metadata: { importedName: 'readSettings' }, + }), + relation({ + kind: 'call', + specifier: './settings', + metadata: { importedName: 'writeSettings' }, + }), + ], + }), + ); + + expect(result?.relations?.map(item => item.metadata?.importedName)).toEqual([ + 'readSettings', + 'writeSettings', + ]); + }); + it('limits plugin analysis to selected plugin ids when requested', async () => { const selected = plugin('selected', ['.ts'], vi.fn(async () => ({ filePath: 'src/app.ts', diff --git a/packages/core/tests/plugins/routingRelationKeys.test.ts b/packages/core/tests/plugins/routingRelationKeys.test.ts index 3172db974..ae8638a47 100644 --- a/packages/core/tests/plugins/routingRelationKeys.test.ts +++ b/packages/core/tests/plugins/routingRelationKeys.test.ts @@ -17,6 +17,29 @@ describe('plugins/routing', () => { }))).toBe('import|import-source|src/a.ts|node:a|symbol:a|./b|static|value'); }); + it('distinguishes named bindings and re-exports from the same module', () => { + const first = getRelationKey(relation({ + kind: 'import', + specifier: './storage', + metadata: { + reexport: true, + importedName: 'internalRead', + exportedName: 'publicRead', + }, + })); + const second = getRelationKey(relation({ + kind: 'import', + specifier: './storage', + metadata: { + reexport: true, + importedName: 'internalWrite', + exportedName: 'publicWrite', + }, + })); + + expect(first).not.toBe(second); + }); + it('adds node and symbol destinations for non-resolved relation kinds', () => { expect(getRelationKey(relation({ kind: 'import', diff --git a/packages/core/tests/treeSitter/analyze.test.ts b/packages/core/tests/treeSitter/analyze.test.ts index 23bb5515e..ff1cb4ed7 100644 --- a/packages/core/tests/treeSitter/analyze.test.ts +++ b/packages/core/tests/treeSitter/analyze.test.ts @@ -66,7 +66,7 @@ describe('pipeline/plugins/treesitter/runtime/analyze', () => { const appSource = [ "import { boot } from './lib';", "import { readFileSync } from 'node:fs';", - "export { helper } from './helper';", + "export { helper as publicHelper } from './helper';", "const lazy = import('./helper');", "const legacy = require('./lib');", 'function run() {', @@ -101,6 +101,11 @@ describe('pipeline/plugins/treesitter/runtime/analyze', () => { kind: 'method', filePath: appPath, }), + expect.objectContaining({ + name: 'publicHelper', + kind: 'alias', + filePath: appPath, + }), ]), ); expect(result?.relations).toEqual( @@ -122,12 +127,17 @@ describe('pipeline/plugins/treesitter/runtime/analyze', () => { sourceId: 'core:treesitter:import', }), expect.objectContaining({ - kind: 'import', + kind: 'reexport', specifier: './helper', resolvedPath: path.join(workspaceRoot, 'src/helper.ts'), fromFilePath: appPath, toFilePath: path.join(workspaceRoot, 'src/helper.ts'), sourceId: 'core:treesitter:import', + metadata: { + reexport: true, + importedName: 'helper', + exportedName: 'publicHelper', + }, }), expect.objectContaining({ kind: 'import', diff --git a/packages/core/tests/treeSitter/core.test.ts b/packages/core/tests/treeSitter/core.test.ts index 93ccfdea3..2a67ce8e9 100644 --- a/packages/core/tests/treeSitter/core.test.ts +++ b/packages/core/tests/treeSitter/core.test.ts @@ -30,6 +30,7 @@ describe('core tree-sitter baseline analysis', () => { it('reports core Tree-sitter edge capabilities without plugin metadata', () => { expect(listCoreTreeSitterEdgeTypeCapabilities()).toEqual([ 'import', + 'reexport', 'reference', 'call', 'type-import', @@ -47,6 +48,7 @@ describe('core tree-sitter baseline analysis', () => { 'import', 'call', 'inherit', + 'reexport', 'type-import', 'contains', ]); @@ -73,6 +75,7 @@ describe('core tree-sitter baseline analysis', () => { 'import', 'call', 'inherit', + 'reexport', 'type-import', 'contains', ], diff --git a/packages/core/tests/treeSitter/javascript/imports.test.ts b/packages/core/tests/treeSitter/javascript/imports.test.ts index 0df927112..1f64649c6 100644 --- a/packages/core/tests/treeSitter/javascript/imports.test.ts +++ b/packages/core/tests/treeSitter/javascript/imports.test.ts @@ -11,6 +11,7 @@ const { addImportRelation, addTypeImportRelation, addRelation, + createSymbol, } = vi.hoisted(() => ({ resolveTreeSitterImportPath: vi.fn(), collectImportBindings: vi.fn(), @@ -18,6 +19,7 @@ const { addImportRelation: vi.fn(), addTypeImportRelation: vi.fn(), addRelation: vi.fn(), + createSymbol: vi.fn(), })); vi.mock('../../../src/treeSitter/runtime/resolve', () => ({ @@ -36,6 +38,7 @@ vi.mock('../../../src/treeSitter/runtime/analyze/results', () => ({ addImportRelation, addTypeImportRelation, addRelation, + createSymbol, })); describe('extension/pipeline/treesitter/javascriptImports', () => { @@ -47,6 +50,8 @@ describe('extension/pipeline/treesitter/javascriptImports', () => { addImportRelation.mockReset(); addTypeImportRelation.mockReset(); addRelation.mockReset(); + createSymbol.mockReset(); + createSymbol.mockReturnValue({ id: 'publicRead-alias' }); collectImportBindings.mockReturnValue([]); }); @@ -338,7 +343,7 @@ describe('extension/pipeline/treesitter/javascriptImports', () => { expect(addTypeImportRelation).not.toHaveBeenCalled(); }); - it('adds import relations for export specifiers', () => { + it('adds re-export relations for export specifiers', () => { resolveTreeSitterImportPath.mockReturnValue('/workspace/src/lib.ts'); getStringSpecifier.mockReturnValueOnce('./lib').mockReturnValueOnce(null); const stringNode = { type: 'string' }; @@ -359,12 +364,62 @@ describe('extension/pipeline/treesitter/javascriptImports', () => { expect(getStringSpecifier).toHaveBeenNthCalledWith(2, undefined); expect(addRelation).toHaveBeenCalledTimes(1); expect(addRelation).toHaveBeenCalledWith(relations, { - kind: 'import', + kind: 'reexport', sourceId: 'core:treesitter:import', fromFilePath: '/workspace/src/app.ts', specifier: './lib', resolvedPath: '/workspace/src/lib.ts', toFilePath: '/workspace/src/lib.ts', + metadata: { reexportAll: true }, + }); + }); + + it('records imported and public names for named re-exports', () => { + resolveTreeSitterImportPath.mockReturnValue('/workspace/src/lib.ts'); + getStringSpecifier.mockReturnValue('./lib'); + const name = { type: 'identifier', text: 'internalRead' }; + const alias = { type: 'identifier', text: 'publicRead' }; + const exportSpecifier = { + type: 'export_specifier', + namedChildren: [name, alias], + childForFieldName: (field: string) => field === 'name' ? name : field === 'alias' ? alias : null, + }; + const relations: never[] = []; + const symbols: never[] = []; + + handleJavaScriptExportStatement( + { + namedChildren: [ + { type: 'export_clause', namedChildren: [exportSpecifier] }, + { type: 'string' }, + ], + } as never, + '/workspace/src/app.ts', + relations, + symbols, + true, + ); + + expect(createSymbol).toHaveBeenCalledWith( + '/workspace/src/app.ts', + 'alias', + 'publicRead', + exportSpecifier, + ); + expect(symbols).toEqual([{ id: 'publicRead-alias' }]); + expect(addRelation).toHaveBeenCalledWith(relations, { + kind: 'reexport', + sourceId: 'core:treesitter:import', + fromFilePath: '/workspace/src/app.ts', + specifier: './lib', + resolvedPath: '/workspace/src/lib.ts', + toFilePath: '/workspace/src/lib.ts', + fromSymbolId: 'publicRead-alias', + metadata: { + reexport: true, + importedName: 'internalRead', + exportedName: 'publicRead', + }, }); }); }); diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index 3f1409aee..d06969f2e 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -82,6 +82,61 @@ describe('workspace/requestQuery', () => { } }); + it('resolves targeted calls through named re-export barrels', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-reexport-')); + await fs.writeFile(path.join(workspaceRoot, 'storage.ts'), [ + 'export function readSetting(): string {', + " return 'value';", + '}', + '', + ].join('\n')); + await fs.writeFile( + path.join(workspaceRoot, 'settings.ts'), + "export { readSetting } from './storage';\n", + ); + await fs.writeFile(path.join(workspaceRoot, 'entry.ts'), [ + "import { readSetting } from './settings';", + 'export function runCommand(): string {', + ' return readSetting();', + '}', + '', + ].join('\n')); + await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); + + const result = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'edges', + arguments: { + from: 'entry.ts#runCommand:function', + edgeType: 'call', + limit: 20, + }, + projection: { edgeTypes: ['call'] }, + }); + + expect(result.edges).toEqual([{ + from: 'entry.ts#runCommand:function', + to: 'storage.ts#readSetting:function', + edgeTypes: ['call'], + }]); + + const reexports = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'edges', + arguments: { + from: 'settings.ts', + edgeType: 'reexport', + limit: 20, + }, + projection: { edgeTypes: ['reexport'] }, + }); + expect(reexports.edges).toEqual([{ + from: 'settings.ts', + to: 'storage.ts#readSetting:function', + edgeTypes: ['reexport'], + }]); + }); + it('reads live text after Indexing while marking cached Symbols stale', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-live-text-')); const entryPath = path.join(workspaceRoot, 'entry.ts'); diff --git a/packages/plugin-api/src/graph.ts b/packages/plugin-api/src/graph.ts index 4ef003cfe..3ec198269 100644 --- a/packages/plugin-api/src/graph.ts +++ b/packages/plugin-api/src/graph.ts @@ -18,6 +18,7 @@ export type CoreEdgeKind = | 'inherit' | 'implements' | 'event' + | 'reexport' | 'reference' | 'load' | 'contains' diff --git a/packages/plugin-api/tests/pluginContracts.test.ts b/packages/plugin-api/tests/pluginContracts.test.ts index daca1ad98..8e79e5604 100644 --- a/packages/plugin-api/tests/pluginContracts.test.ts +++ b/packages/plugin-api/tests/pluginContracts.test.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { CodeGraphyAccessKey, + CoreEdgeKind, IAccessProvider, IPlugin, IPluginGraphScopeCapabilityContext, @@ -12,6 +13,10 @@ import type { } from '../src'; describe('plugin API contracts', () => { + it('includes re-exports in the Core relationship vocabulary', () => { + expectTypeOf<'reexport'>().toMatchTypeOf(); + }); + it('lets plugins declare graph scope capabilities separately from emitted graph output', () => { const plugin = { id: 'acme.routes', From cef42c5cf30445b7d68b354a76a6fe49ab273187 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 14:45:47 -0700 Subject: [PATCH 28/55] fix(core): preserve shaped relationship reports --- packages/core/src/workspace/requestQuery.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index 7d4fb65cc..bafc5a03e 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -55,6 +55,7 @@ function hasTargetSelector(arguments_: Record): boolean { } function shouldApplyWorkspaceGraphScope(input: WorkspaceGraphQueryInput): boolean { + if (input.report === 'relationships') return true; if (input.report === 'search' || input.report === 'overview' || input.report === 'paths') return false; return !hasTargetSelector(input.arguments); } From 8cc4db62b77ce8c7927ef6b462d3eface2796901 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 14:46:08 -0700 Subject: [PATCH 29/55] docs(core): record exact relationship experiments --- .changeset/tall-owls-check.md | 3 +- CONTEXT.md | 6 +- README.md | 2 +- ...ies-use-complete-reexport-relationships.md | 74 +++++++++++++++++++ packages/core/README.md | 6 +- packages/plugin-api/README.md | 1 + skills/codegraphy/SKILL.md | 2 +- 7 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 docs/adr/0009-targeted-queries-use-complete-reexport-relationships.md diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index 7a49a2b8c..aa912aaf9 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -1,5 +1,6 @@ --- "@codegraphy-dev/core": major +"@codegraphy-dev/plugin-api": minor --- -Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, and keep successful non-verbose Indexing output on stdout only. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. diff --git a/CONTEXT.md b/CONTEXT.md index 3b1a43b53..01dfd5244 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,7 +18,7 @@ CodeGraphy turns a folder into an interactive Relationship Graph so people and a | **Plugin Node** | A Node contributed by a plugin for a concept that Core does not own. | | **Relationship** | A meaningful connection between two Nodes. | | **Edge** | A semantic Relationship record with a source, target, and Edge Type. An interface decides how to render it. | -| **Edge Type** | The semantic category of an Edge, such as import, call, reference, inherit, contains, or nests. | +| **Edge Type** | The semantic category of an Edge, such as import, reexport, call, reference, inherit, contains, or nests. | | **Edge Direction** | The source-to-target direction of a Relationship. The source initiates the import, call, reference, containment, or other relation. | | **Dependency** | A Relationship whose Edge Type means one Node needs another to build, run, or resolve. Do not use dependency as a synonym for every Relationship. | | **Downstream** | Following Edge Direction away from a Node. The Edge Type explains what the direction means. | @@ -51,7 +51,7 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Orphan Node** | A Node with no remaining Edges after graph narrowing. | | **Show Orphans** | A final Graph View setting that keeps or removes Orphan Nodes. | -Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Core Graph Query uses the same order for graph navigation. CLI Search is a discovery operation: persisted and one-off path Filters still apply, but Graph Scope does not hide cached AST Symbols or live source matches. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. +Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, Target Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. ## Selection, Focus, and Collapse @@ -94,7 +94,7 @@ Interaction rules: | **Refresh Graph** | Restart layout physics without processing source data. | | **Re-index Workspace** | Run Indexing, save the Graph Cache, and refresh the graph. | -Indexing runs File Discovery, Tree-sitter Analysis, Plugin Analysis, and Graph Projection. The Graph Cache stores unscoped analysis facts so Graph Scope can hide data without deleting it. Active Filters and Git ignored state exclude files from fresh analysis and the file budget; facts cached while those files were eligible remain reusable but stay out of the current graph. Expensive facts such as Symbol or plugin-owned tiers can load when their scope needs them and remain cached for reuse. +Indexing runs File Discovery, Tree-sitter Analysis, Plugin Analysis, and Graph Projection. JavaScript-family reexports are explicit Relationships; renamed exports are Alias Symbol Nodes, so calls can resolve through barrels to implementation Symbols across full and incremental Indexing. The Graph Cache stores unscoped analysis facts so Graph Scope can hide data without deleting it. Active Filters and Git ignored state exclude files from fresh analysis and the file budget; facts cached while those files were eligible remain reusable but stay out of the current graph. Expensive facts such as Symbol or plugin-owned tiers can load when their scope needs them and remain cached for reuse. The Graph View can use a whole-view loading state before its first graph payload. Graph Cache Sync, Live Update, plugin changes, and Re-index keep the current graph visible after that first render and use graph-local progress. diff --git a/README.md b/README.md index f094a9869..12dadbe08 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy filter` | Reads or changes persisted filter patterns. | | `codegraphy plugins` | Registers, links, lists, enables, or disables plugins. | -Run `codegraphy --help` for exact arguments. Query, settings, Indexing, and diagnostic commands keep machine-readable JSON on stdout. Verbose diagnostics go to stderr. +Target Query, Path, and exact targeted Relationship selectors use complete cached Node and Edge Types by default rather than saved Graph View Scope; explicit `--node-type` or `--edge-type` arguments still constrain that dimension. JavaScript-family `reexport` Relationships let calls through barrels terminate at implementation Symbols. Run `codegraphy --help` for exact arguments. Query, settings, Indexing, and diagnostic commands keep machine-readable JSON on stdout. Verbose diagnostics go to stderr. ### Agent Skill diff --git a/docs/adr/0009-targeted-queries-use-complete-reexport-relationships.md b/docs/adr/0009-targeted-queries-use-complete-reexport-relationships.md new file mode 100644 index 000000000..77c675084 --- /dev/null +++ b/docs/adr/0009-targeted-queries-use-complete-reexport-relationships.md @@ -0,0 +1,74 @@ +# Targeted queries use complete reexport relationships + +**Status:** Accepted + +## Context + +The saved Graph View Scope enables File Nodes and structural import-like Edges by default while hiding detailed Symbol and call facts. That presentation preference leaked into CLI traversal: an exact Symbol `query` could expose cached calls, but `dependencies`, `dependents`, and `path` could return no result for the same selectors. General call reports also projected unresolved JavaScript-family calls to Files when an imported binding passed through a barrel, even though the Graph Cache contained the implementation Symbol. + +Graphify was evaluated directly on the 912-file Core fixture. Code-only extraction took 5.53 seconds and breadth-first queries took 0.39–0.46 seconds, but phrase traversals returned broad truncated neighborhoods. Exact `runFilterCommand` traversal and `explain` were materially better: they exposed the direct extracted call to `readCodeGraphyWorkspaceSettingsOrInitial` with direction and provenance. This supports exact-anchor relationship deepening rather than broad natural-language traversal. + +A bounded connected-search prototype expanded one exact lexical anchor through typed outgoing relationships to depth two, retained at most one incoming Edge, and suppressed expansion through p99-degree hubs. Three controlled variants were mixed: + +- the first treatment slightly improved median elapsed time and tokens but increased calls and output; +- refinement plus guidance reduced calls and output but was 1.3% slower and used 2.2% more tokens; +- the final exact-anchor treatment was 2.8% faster, but median calls increased from 23 to 31, tokens from 98,117 to 126,766, and output from 93,765 to 105,773 bytes. + +Agents still read source after receiving the neighborhoods. Generic connection augmentation therefore did not earn its added response surface. Leiden communities, personalized PageRank, and god-node ranking remain research candidates rather than default query behavior. + +The narrower hypothesis was to make existing exact operations truthful. JavaScript-family export statements previously emitted ordinary `import` Relationships. Workspace enrichment could use transient binding metadata to resolve a call through a barrel during a full index, but the normalized Graph Cache intentionally does not persist analyzer metadata. A later incremental CLI process therefore fell back to the barrel File. + +A three-pair hard-task comparison tested complete targeted scope plus direct call resolution through reexports against the prior graph-focused CLI. The first implementation produced these medians: + +| Hard-task median | Prior graph | Complete reexport graph | Delta | +|---|---:|---:|---:| +| Elapsed | 120.2 s | 107.9 s | 10.3% faster | +| Tool calls | 21 | 24 | 14.3% more | +| Total tokens | 125,606 | 121,129 | 3.6% fewer | +| Tool output | 110,361 B | 101,930 B | 7.6% fewer | + +Every run used four CodeGraphy calls. The treatment improved elapsed time, tokens, and output while increasing tool calls. A final paired run after making reexports explicit is recorded below. + +The final selected interface and stable skill were then rerun in three fresh pairs: + +| Hard-task median | Prior graph | Complete reexport graph | Delta | +|---|---:|---:|---:| +| Elapsed | 103.7 s | 106.9 s | 3.1% slower | +| Tool calls | 27 | 23 | 14.8% fewer | +| Total tokens | 128,784 | 173,530 | 34.7% more | +| Tool output | 107,563 B | 103,294 B | 4.0% fewer | +| Blinded correctness | 10/10 | 9/10 | one point lower | + +The treatment won elapsed time in two of three matched pairs but lost on the independent cell medians. A separate final round likewise reduced output by 4.7% while elapsed, calls, and tokens varied in the other direction. Across the two final rounds, smaller tool output was the only stable aggregate improvement. The explicit relationship model is retained for structural correctness, incremental parity, 14.8% fewer calls in the selected round, and consistently smaller output—not as a claim that it universally speeds this task. + +A follow-up skill sentence that explicitly preferred callable Symbol targets made all treatment agents choose `runFilterCommand` directly and reduced that treatment's cross-round median calls from 24 to 21. Against its paired prior-graph condition, however, it was 19.7% slower with 2.9% more tokens and 4.1% more output, so the sentence was removed. + +The samples are intentionally small and retain the variance seen in earlier agent experiments. Exact reexport relationships are an evidence-backed graph correction with measured tradeoffs, not universal agent superiority. + +## Decision + +Use the complete cached Node and Edge Types for CLI Search, Target Query, Path, and exact targeted Relationship selectors. Saved Graph View Scope continues to shape inventory and presentation. An explicit `--node-type` or `--edge-type` projection constrains only that dimension for the current invocation. + +When File selectors expand to their Symbols for Path, try exact File endpoints first and stop after finding that exact route. Do not emit redundant longer Symbol-expanded routes after an exact File path succeeds. + +Add `reexport` to the Core Relationship vocabulary and Plugin API. JavaScript-family export statements emit `reexport` rather than overloading `import`. Renamed exports create Alias Symbol Nodes, with the reexport originating at the alias. Reexport Edges remain hidden in the default Graph View Scope but are available to complete targeted queries and explicit projections. + +Resolve call, event, inherit, and reference targets through named, aliased, and export-star reexport chains. Keep lexical import Relationships pointed at the barrel so dependency invalidation remains correct. Because the Graph Cache persists the reexport Edge Type, target Symbol, and Alias Symbol identity, a new CLI process can recover the same implementation target after incremental Indexing without persisting analyzer metadata or adding a compatibility path. + +Do not add generic connected neighborhoods to Search or Target Query. Do not add a public traversal command, Leiden dependency, PageRank ranker, or god-node report without a controlled task where the added observation improves the selected metric without an unacceptable regression in the others. + +## Consequences + +- Exact Symbol dependencies and paths no longer depend on Graph View visibility settings. +- Calls through barrels terminate at implementation Symbols while lexical imports still identify the barrel. +- `reexport` is independently filterable and queryable as an Edge Type. +- Renamed exports are first-class Alias Symbols instead of metadata that disappears at the Graph Cache boundary. +- Full and incremental CLI processes produce the same tested call target through named reexports. +- Broad connection expansion remains rejected for the current agent workflow despite useful isolated rankings. + +## References + +- Graphify source and traversal implementation: https://github.com/Graphify-Labs/graphify +- ADR 0002, SQLite Graph Cache boundaries +- ADR 0007, Search and Target Query +- ADR 0008, deterministic phrase search and safe agent settings diff --git a/packages/core/README.md b/packages/core/README.md index ac056ad3c..e77d7f64e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the shaped graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy settings get maxFiles @@ -37,7 +37,7 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - CodeGraphy Workspace paths: resolve `.codegraphy/settings.json` and `.codegraphy/graph.sqlite` for any folder path. - Workspace Settings: strictly validate persisted known fields, safely read or mutate them through the CLI, and preserve unknown extension fields without replacing corrupt input with defaults. - File Discovery: discover analyzable files and directories, apply active custom/plugin/Git filters before the eligible-file budget, and report cache-retention paths without VS Code APIs. -- Built-in language analysis: parse supported languages and produce file, symbol, import, call, inherit, reference, and type-import relationships. +- Built-in language analysis: parse supported languages and produce file, symbol, import, reexport, call, inherit, reference, and type-import relationships. - File Analysis: run cache-aware per-file plugin analysis and project file relationships without VS Code APIs. - Core Indexing: index an explicit CodeGraphy Workspace path, run headless plugins, build the Relationship Graph, and write the workspace Graph Cache. - Workspace Analysis: orchestrate discovery, pre-analysis hooks, file analysis, cache updates, and graph rebuilds through headless dependencies. @@ -50,7 +50,7 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - Graph Cache storage: load, save, clear, and inspect normalized File, Node, Symbol, and Edge rows in the SQLite-backed Graph Cache at `/.codegraphy/graph.sqlite`. - Workspace Search: merge bounded exact evidence with deterministic all-term File fallback candidates for natural multi-term phrases. - Target Query: inspect one exact File or Symbol with prioritized declarations and bounded Relationships. -- Graph Query: list scoped Nodes and Edges, trace dependencies and dependents, and find bounded paths over Relationship Graph data plus persisted analysis metadata. +- Graph Query: list scoped Nodes and Edges, then use complete cached types by default for exact targeted relationships and bounded paths unless an invocation explicitly projects Node or Edge Types. The core package exposes `indexCodeGraphyWorkspace` for explicit path-based Indexing. VS Code and CLI adapters call this package instead of owning independent indexing behavior. diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index 383353a6c..77d4e7368 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -41,6 +41,7 @@ This package is type-only. Use `import type`. Core plugins can: - analyze workspace files; +- use Core Relationship kinds including `import`, `reexport`, `call`, `reference`, and `inherit`; - add semantic Nodes, Symbols, and Relationships; - add semantic Node Type and Edge Type definitions; - declare Graph Scope capabilities and default filters; diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 213d3bfc7..a599241a2 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -41,7 +41,7 @@ Once `search` or `query` identifies a relevant File, stop using graph commands t `search` defaults to 20 combined matches. `nodes`, `edges`, `dependencies`, and `dependents` default to 100 results. When `data.page.nextOffset` is not `null`, continue with `--offset ` only if the current page did not answer the question. -Graph navigation commands accept repeatable `--filter`, `--node-type`, and `--edge-type` projections without changing settings. Use persisted `filter` or `scope` mutations only for durable workspace changes. +Graph navigation commands accept repeatable `--filter`, `--node-type`, and `--edge-type` projections without changing settings. Saved Graph View Scope does not hide evidence from Search, Target Query, Path, or an exact targeted Relationship selector; those operations use complete cached types unless you explicitly project that dimension. Use persisted `filter` or `scope` mutations only for durable workspace changes. ## Failures and recovery From 19e12c91e66317d7dbb3699eeccc357d0fe44d28 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 14:47:16 -0700 Subject: [PATCH 30/55] test(core): cover reexport capabilities --- packages/core/tests/treeSitter/capabilities.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/tests/treeSitter/capabilities.test.ts b/packages/core/tests/treeSitter/capabilities.test.ts index f19acc222..83dcea499 100644 --- a/packages/core/tests/treeSitter/capabilities.test.ts +++ b/packages/core/tests/treeSitter/capabilities.test.ts @@ -27,6 +27,7 @@ describe('pipeline/plugins/treesitter/runtime/capabilities', () => { it('advertises TypeScript graph scope edges supported by the current analyzer', () => { expect(listTreeSitterEdgeTypeCapabilities(['src/commented.ts'])).toEqual([ 'import', + 'reexport', 'type-import', 'call', 'inherit', @@ -271,6 +272,7 @@ describe('pipeline/plugins/treesitter/runtime/capabilities', () => { nodeTypes: [], edgeTypes: [ 'import', + 'reexport', 'reference', 'call', 'type-import', From d3d3154d3fcbf165248074a759df07993611ac13 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 15:55:40 -0700 Subject: [PATCH 31/55] docs(skill): minimize graph navigation tokens --- skills/codegraphy/SKILL.md | 66 ++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index a599241a2..4220b53a4 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -1,55 +1,51 @@ --- name: codegraphy -description: Use the CodeGraphy CLI to discover source, AST Symbols, and Relationships in a prepared workspace graph. +description: Use the CodeGraphy CLI for bounded source and relationship navigation in a prepared workspace graph. --- # CodeGraphy -Use CodeGraphy to identify the smallest set of source files worth reading. It provides navigation evidence, not a substitute for source inspection. +CodeGraphy identifies the smallest set of source files worth reading. It provides navigation evidence, not a substitute for source inspection. -## Fast workflow +## When to use it -1. Start with useful evidence instead of a preparatory status call: - - `search ` discovers an identifier, path, or exact source phrase. - - `query ` inspects one exact File path or Symbol Node ID already returned by search. -2. Read the returned source files and lines. -3. Use a narrow continuation command only when the overview is insufficient. +Use CodeGraphy when: -If a command reports `graph_cache_not_found`, run `codegraphy index` once and retry. Search reports whether live source differs from its cached Symbol facts in `data.sources.symbols.cacheState`; do not run `status` before every query. If Indexing reports a file-budget truncation, inspect `codegraphy settings get maxFiles`, raise it with `codegraphy settings set maxFiles `, adjust durable exclusions with `codegraphy filter` when appropriate, then run `codegraphy index` once. Do not inspect or mutate settings preemptively when results are already complete. +- a task gives a symptom or identifier but not the owning source; +- an unfamiliar or large repository makes broad search expensive; +- a caller, dependency, re-export, or impact path is unknown. -Use `-C ` from outside the workspace. Quote patterns containing spaces or `*`. +Skip CodeGraphy when: -## Choose the command by question +- the task or failing test already names the relevant source and the question is local to it; +- a small repository or one narrow text search is sufficient; +- the task is primarily about prose or configuration rather than code relationships. -- `search `: locate matching live source lines, cached AST Symbols, and indexed File or concept Nodes. Matching is case-insensitive; `*` spans characters within one line or name. Natural multi-term phrases also use deterministic all-term File ranking when literal evidence is sparse. Symbol results include their `filePath`; text results include `line`, `column`, and `excerpt`. -- `query `: inspect one exact File or Symbol. It returns prioritized declared AST Symbols plus bounded incoming and outgoing Relationships in one call. -- `dependencies `: continue through outgoing Relationships—what the Node uses. -- `dependents `: continue through incoming Relationships—what may be affected. -- `path `: verify whether and how two exact Nodes connect. -- `nodes`: inventory shaped Nodes when type-level enumeration is the task. -- `edges`: inventory shaped Relationships when edge-level enumeration is the task. -- `status`: inspect Graph Cache state when freshness itself is the task. -- `doctor`: diagnose settings, cache, runtime, or Plugin failures. -- `settings`: read effective workspace configuration or safely `get`, `set`, and `unset` any supported top-level setting. Values passed to `set` are JSON; mutations report `indexRequired`. -- `filter`, `scope`, and `plugins`: inspect or change durable workspace configuration; do not use them for ordinary navigation. +## Setup only when needed -Query with the narrowest command that answers the current question. Prefer `search` → `query` → source reads over dumping Nodes or Edges. +Do not call `status`, inspect settings, or index before ordinary navigation. If a command reports `graph_cache_not_found`, run `codegraphy index` once and retry. Use `codegraphy filter`, `settings`, `scope`, or `plugins` only to prepare or durably change the workspace, not as navigation steps. If indexing reports a file-budget cap, follow its `maxFiles` recovery action and reindex once. -Once `search` or `query` identifies a relevant File, stop using graph commands to look for details inside that File. Read the known File or use file-local text search, preferably alongside the other already-known source and test files in one tool turn. Do not search again for identifiers already present in returned declarations. Use at most four individual CodeGraphy invocations for an investigation unless you are following `nextOffset`; commands launched together still count separately. Do not guess possible function or type names for another Search—use only task literals or identifiers already returned. If four invocations have not narrowed the evidence, switch to ordinary source search. Repeated workspace searches are slower and more expensive than reading known source. This navigation budget does not weaken the answer: verify and state every requested condition, default adapter, side-effect destination, and test from source before stopping. +Query with the narrowest operation that answers the question: -## Keep output bounded +- `search ` locates live source, AST Symbols, and File Nodes. +- `query ` gives bounded declarations plus incoming and outgoing Relationships for one returned File or Symbol ID. +- `dependencies`, `dependents`, and `path` answer one unresolved relationship question. +- `nodes` and `edges` enumerate graph inventories; do not use them for ordinary localization. -`search` defaults to 20 combined matches. `nodes`, `edges`, `dependencies`, and `dependents` default to 100 results. When `data.page.nextOffset` is not `null`, continue with `--offset ` only if the current page did not answer the question. +## Token-bounded navigation policy -Graph navigation commands accept repeatable `--filter`, `--node-type`, and `--edge-type` projections without changing settings. Saved Graph View Scope does not hide evidence from Search, Target Query, Path, or an exact targeted Relationship selector; those operations use complete cached types unless you explicitly project that dimension. Use persisted `filter` or `scope` mutations only for durable workspace changes. +1. Use `search` only when the relevant source is not already known. Search with at most three task literals: + - use an exact identifier plus one domain word when the identifier is common; + - otherwise use two or three short task words; + - never submit a sentence or guess an API name. +2. If the first page is broad or unrelated, the second and final normal call may refine the search with one different task word. Otherwise use `query` only when one returned target's relationships are needed. +3. Normally make at most two CodeGraphy calls. A third is allowed only for one unresolved relationship continuation. Commands launched concurrently count separately. +4. Never repeat the same search or search an identifier already returned. +5. Once Search returns plausible source or test paths, read the best few paths directly. Do not rerun repository-wide `rg`, `grep`, or `find` merely to localize the same task. Use file-local search after reading. +6. Stop graph navigation once relevant files are known. Verify the requested behavior and tests from source before answering or editing. -## Failures and recovery +Search is case-insensitive, `*` is a line-local wildcard, and multi-term searches rank Files containing all terms. Quote patterns containing spaces or `*`. Search results include paths and locations; query targets must be exact returned File paths or Symbol IDs. -Data commands write one `{ok:true,command,data}` JSON envelope to stdout. Failures write `{ok:false,command,error}` to stderr and exit nonzero: 1 for an operational failure and 2 for an invalid invocation. `--verbose` adds diagnostics on stderr. Error identifiers below are recovery signals, not repository search terms; do not spend navigation calls searching their names unless the task cites that exact runtime error. +Results are JSON envelopes. Use `data.page.nextOffset` only when the current page is insufficient. On `query_target_not_found`, search for an exact target. After one empty or refined search, switch to ordinary source search rather than spending more graph calls. -- `graph_cache_not_found`: run `codegraphy index`, then retry. -- `query_target_not_found`: use `search` to obtain an exact File path or Symbol Node ID. -- Empty search: use a shorter literal or one `*` wildcard; after one broader attempt, fall back to ordinary source search. -- Unhealthy cache/settings: run `codegraphy doctor` and follow `error.details.checks`. - -Run `codegraphy --help` or `codegraphy --help` for the complete interface and examples. +Run `codegraphy --help` or `codegraphy --help` for complete syntax and examples. From 28cdc032f4b5020db1cd1aecfb84c81ac6b15db8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 16:14:32 -0700 Subject: [PATCH 32/55] docs(cli): record token-first agent experiments --- CONTEXT.md | 4 +- README.md | 2 +- ...ation-is-token-first-and-task-selective.md | 96 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md diff --git a/CONTEXT.md b/CONTEXT.md index 01dfd5244..960e91a73 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,11 +110,11 @@ The Graph View can use a whole-view loading state before its first graph payload | **CodeGraphy Exploration CLI** | `search` combines exact evidence with deterministic all-term fallback ranking for natural phrases; `query` inspects one exact File or Symbol with prioritized declarations and Relationships. Both return bounded JSON with provenance. | | **CodeGraphy Settings CLI** | `settings`, `settings get`, `settings set`, and `settings unset` read or safely mutate supported workspace settings without silently repairing corrupt persisted input. | | **Graph Query CLI** | `nodes`, `edges`, `dependencies`, `dependents`, and `path`, all with bounded JSON output over the shaped Relationship Graph. | -| **CodeGraphy Agent Skill** | Instructions that teach shell-capable agents when to index, which Graph Query command to choose, and when to inspect source. | +| **CodeGraphy Agent Skill** | Token-bounded instructions that teach shell-capable agents when CodeGraphy can replace broad navigation, when to skip it, and when to stop graph calls and inspect source. | | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | -The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. Agents otherwise start with the narrowest useful discovery operation and inspect returned source evidence directly. +The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. Agents otherwise use CodeGraphy only when source ownership or a Relationship is unknown. Normal navigation allows one Search and one exact Target Query; one additional Relationship continuation is reserved for an unresolved graph question. Common identifiers pair with one task-domain word, while sentence-like or speculative searches are avoided. Once plausible source or test paths are known, agents read them directly rather than relocalizing them through repository-wide search. Clean-install implementation benchmarks treat cumulative model tokens per correct task as the primary acceptance metric; see ADR 0010. ## Plugins diff --git a/README.md b/README.md index 12dadbe08..29f1572e9 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Target Query, Path, and exact targeted Relationship selectors use complete cache ### Agent Skill -The [CodeGraphy Agent Skill](./skills/codegraphy/SKILL.md) teaches shell-capable agents to discover live source and cached AST Symbols, inspect exact targets, and continue through bounded Relationships before reading the smallest useful source set. Install it from a clone of this repo: +The [CodeGraphy Agent Skill](./skills/codegraphy/SKILL.md) teaches shell-capable agents when graph navigation can replace broad search, when to skip it, and how to stop after a bounded Search and exact Target Query before reading the smallest useful source set. Clean-install coding benchmarks use cumulative model tokens per correct task as the primary acceptance metric; see [ADR 0010](./docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md). Install the skill from a clone of this repo: ```bash npx skills@latest add ./skills/codegraphy diff --git a/docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md b/docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md new file mode 100644 index 000000000..20998361d --- /dev/null +++ b/docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md @@ -0,0 +1,96 @@ +# Agent navigation is token-first and task-selective + +**Status:** Accepted + +## Context + +Earlier CodeGraphy experiments optimized elapsed time, tool calls, model tokens, and tool output together. The measurements repeatedly showed that process time was not the dominant cost: extra model turns amplified cached context, and a graph workflow could look fast while consuming substantially more model tokens than ordinary navigation. Model tokens per correct task are therefore the primary product metric. Elapsed time, calls, output bytes, and indexing cost remain diagnostics. + +Five fresh low-thinking research agents inspected Graphify, LARGER, Aider RepoMap, RepoGraph, LocAgent, and GraphLocator from primary papers and source where available: + +- Graphify chooses a few lexical seeds, bounds BFS/DFS, suppresses expansion through high-degree hubs, and serializes compact Node/Edge lines under an approximate 2,000-token budget. Exact-symbol traversal and `explain` were more useful than broad phrase traversal on the CodeGraphy fixture. +- LARGER's paper describes adding at most ten confidence-filtered graph neighbors to lexical observations without another agent action. No official implementation was published, so its omitted path-confidence and community-scoring details cannot be copied exactly. +- Aider builds a def/reference File graph, runs personalized PageRank biased by active files and prompt-mentioned identifiers, and binary-searches a rendered repository map into a token budget. +- RepoGraph exposes one shallow symbol action. LocAgent combines lexical seeds with typed one-hop traversal. GraphLocator supports edge-centered search but adds LLM filtering and much larger turn budgets. + +The shared useful idea is bounded task-selective evidence, not graph traversal by default. + +## Clean-install evaluation + +The evaluation moved from answer-only localization prompts to two real historical CodeGraphy regressions at their original failing commits: + +1. globally enabling a shared Plugin ID updated only one conflicting installed package record; +2. filtered files consumed `maxFiles`, starving eligible files and polluting a fresh Graph Cache. + +Each run started from an isolated copy containing the original failing regression test. Repository Markdown, Agent Skills, context files, prompt templates, extensions, sessions, shared agent homes, and visible answers were absent. The raw arm's restricted `PATH` could not resolve `codegraphy`. CLI arms received a freshly built external CLI and a separately prepared Graph Cache. Every run used `openai-codex/gpt-5.6-sol` with low thinking, could edit production code, and was accepted only when the untouched historical regression test passed. Indexing cost was measured outside task time. + +Simply placing the CLI on `PATH` did not produce a treatment: none of six skill-free agents invoked the unknown command. A tiny command contract caused agents to use it, but results split by task. Plugin activation improved from 108,404 to 44,159 median tokens, while discovery regressed from 134,679 to 207,836. Discoverability and command-selection policy are therefore part of the agent interface, but they must not contain task answers. + +A clean three-arm skill screen then compared raw navigation, a 74-word two-call rule, and the previous full skill: + +| Median total tokens | Raw | Short bounded skill | Previous full skill | +|---|---:|---:|---:| +| Plugin activation | 199,805 | 135,099 | 85,480 | +| Discovery budget | 313,846 | 178,759 | 225,249 | +| Correct runs | 5/6 | 6/6 | 6/6 | + +The short rule controlled exploration but did not teach Target Query selection. The full skill localized the plugin task well but allowed repeated broad phrase searches on the discovery task. A compact policy combined both lessons: + +- use CodeGraphy only when source ownership or a Relationship is unknown; +- skip it when an exact source or failing test already localizes a small task; +- search with an exact identifier plus one task-domain word, or at most three short task literals; +- never submit a sentence or guessed API name; +- normally allow one Search and one exact Target Query, with one additional Relationship continuation only when unresolved; +- read returned paths directly instead of relocalizing them through repository-wide search. + +The compact policy screening medians were 70,258 tokens for Plugin activation and 170,295 for discovery, with all six runs correct. The exact shipped 3,407-byte skill was then paired against raw navigation in a fresh six-run comparison: + +| Median | Raw | CodeGraphy + shipped skill | Delta | +|---|---:|---:|---:| +| Plugin activation tokens | 173,833 | 70,556 | 59.4% fewer | +| Discovery budget tokens | 258,625 | 247,174 | 4.4% fewer | +| Pooled tokens | 199,642 | 113,360 | 43.2% fewer | +| Plugin activation calls | 16 | 18 | 2 more | +| Discovery budget calls | 28 | 22 | 6 fewer | +| Correct runs | 6/6 | 6/6 | equal | + +The sample is small and task variance remains high. It establishes a selected token-first policy for these tasks, not universal superiority. + +## Rejected additions + +A LARGER-inspired prototype attached up to two live per-term source lines to every natural-phrase File result. It reduced Plugin activation from the shipped skill's 70,556-token median to 59,356, but increased discovery from 247,174 to 577,260 and raised calls to 30–37. Superficially term-rich but causally irrelevant files looked authoritative. The prototype was reverted completely. + +Seeded weighted Leiden was evaluated on the clean fixture's File projection: 2,729 files, 5,853 weighted links, 549 communities, modularity 0.864, and 45.2 ms runtime. Community-density reranking improved none of six real localization phrases. It moved the discovery target from rank 3 to 10 and the Plugin target from rank 1 to 2. Communities remain useful architecture metadata candidates, but are not a default localization prior or output field. + +Generic connected neighborhoods remain rejected by ADR 0009. Agents read source after receiving them, so their added output increased end-to-end tokens. Target source embedding, BM25 fallback, mandatory Symbol preference, and an underspecified minimal skill remain rejected by ADR 0008 and ADR 0009. + +## Decision + +Use cumulative model `totalTokens` per correct task as the primary acceptance metric for agent-interface experiments. Report input, cache-read, output, and reasoning tokens separately, plus correctness, tool classes, result bytes, calls, elapsed time, and clean indexing cost. + +Ship the compact task-selective Agent Skill. The normal navigation budget is two CodeGraphy calls, with a third only for one unresolved Relationship continuation. Teach agents when not to use CodeGraphy and require direct reads after useful paths are known. + +Evaluate CLI behavior, Agent Skill behavior, and algorithm changes as separate arms. A raw baseline has no CodeGraphy executable or skill. A CLI-discoverability arm may state that the command exists but must not provide task-specific guidance. Skill arms differ only by frozen generalized instruction text. + +Do not add search evidence, graph neighborhoods, PageRank, Leiden communities, god-node output, or new traversal commands because they are plausible in isolation. Add one only after a clean held-out coding task shows lower end-to-end model tokens at non-inferior correctness. + +## Consequences + +- CodeGraphy is a selective accelerator, not a mandatory first step. +- Skill length is not minimized independently; enough command-selection guidance can reduce total trajectory tokens despite a larger initial prompt. +- Search phrase construction is part of retrieval quality. Common identifiers need one domain term; full natural-language sentences are discouraged. +- Runtime and tool-call improvements cannot compensate for token regression. +- Clean-install benchmarks must keep answers, repository instructions, caches, homes, and skill text isolated by condition. +- Leiden clustering has been tested and rejected as a default retrieval prior, while remaining available for a future explicit subsystem use case. + +## References + +- Graphify source: https://github.com/Graphify-Labs/graphify +- LARGER paper: https://arxiv.org/abs/2605.16352 +- Aider RepoMap: https://github.com/Aider-AI/aider/blob/main/aider/repomap.py +- RepoGraph: https://github.com/ozyyshr/RepoGraph +- LocAgent: https://github.com/gersteinlab/LocAgent +- GraphLocator: https://github.com/oceaneLIU/GraphLocator +- ADR 0007, Search and Target Query +- ADR 0008, agent Search and settings workflow +- ADR 0009, complete reexport Relationships and rejected connected neighborhoods From 8a43c544ec9ea9456fce860e3e4c9552bb8eb532 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 16:52:10 -0700 Subject: [PATCH 33/55] docs(skill): let agents choose graph strategy --- CONTEXT.md | 4 +- README.md | 2 +- ...ation-is-token-first-and-task-selective.md | 2 +- ...agents-choose-their-codegraphy-strategy.md | 50 +++++++++++++ skills/codegraphy/SKILL.md | 73 +++++++++++-------- tests/release/codegraphySkill.test.mjs | 21 ++++++ 6 files changed, 117 insertions(+), 35 deletions(-) create mode 100644 docs/adr/0011-agents-choose-their-codegraphy-strategy.md diff --git a/CONTEXT.md b/CONTEXT.md index 960e91a73..a89b30557 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,11 +110,11 @@ The Graph View can use a whole-view loading state before its first graph payload | **CodeGraphy Exploration CLI** | `search` combines exact evidence with deterministic all-term fallback ranking for natural phrases; `query` inspects one exact File or Symbol with prioritized declarations and Relationships. Both return bounded JSON with provenance. | | **CodeGraphy Settings CLI** | `settings`, `settings get`, `settings set`, and `settings unset` read or safely mutate supported workspace settings without silently repairing corrupt persisted input. | | **Graph Query CLI** | `nodes`, `edges`, `dependencies`, `dependents`, and `path`, all with bounded JSON output over the shaped Relationship Graph. | -| **CodeGraphy Agent Skill** | Token-bounded instructions that teach shell-capable agents when CodeGraphy can replace broad navigation, when to skip it, and when to stop graph calls and inspect source. | +| **CodeGraphy Agent Skill** | Generalized instructions that explain the Relationship Graph, lifecycle, query surfaces, machine-readable output, freshness, shaping, and limits so a shell-capable agent can choose its own navigation strategy. | | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | -The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. Agents otherwise use CodeGraphy only when source ownership or a Relationship is unknown. Normal navigation allows one Search and one exact Target Query; one additional Relationship continuation is reserved for an unresolved graph question. Common identifiers pair with one task-domain word, while sentence-like or speculative searches are avoided. Once plausible source or test paths are known, agents read them directly rather than relocalizing them through repository-wide search. Clean-install implementation benchmarks treat cumulative model tokens per correct task as the primary acceptance metric; see ADR 0010. +The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric; see ADR 0011. ## Plugins diff --git a/README.md b/README.md index 29f1572e9..1dcc7d012 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Target Query, Path, and exact targeted Relationship selectors use complete cache ### Agent Skill -The [CodeGraphy Agent Skill](./skills/codegraphy/SKILL.md) teaches shell-capable agents when graph navigation can replace broad search, when to skip it, and how to stop after a bounded Search and exact Target Query before reading the smallest useful source set. Clean-install coding benchmarks use cumulative model tokens per correct task as the primary acceptance metric; see [ADR 0010](./docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md). Install the skill from a clone of this repo: +The [CodeGraphy Agent Skill](./skills/codegraphy/SKILL.md) explains the Relationship Graph, lifecycle, query surfaces, JSON output, freshness, shaping, and limits so shell-capable agents can choose their own navigation strategy. Clean coding benchmarks compare fresh raw agents against CodeGraphy-plus-skill agents on the same feature change and use cumulative model tokens per correct task as the primary acceptance metric; see [ADR 0011](./docs/adr/0011-agents-choose-their-codegraphy-strategy.md). Install the skill from a clone of this repo: ```bash npx skills@latest add ./skills/codegraphy diff --git a/docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md b/docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md index 20998361d..b1202f527 100644 --- a/docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md +++ b/docs/adr/0010-agent-navigation-is-token-first-and-task-selective.md @@ -1,6 +1,6 @@ # Agent navigation is token-first and task-selective -**Status:** Accepted +**Status:** Superseded by ADR 0011 ## Context diff --git a/docs/adr/0011-agents-choose-their-codegraphy-strategy.md b/docs/adr/0011-agents-choose-their-codegraphy-strategy.md new file mode 100644 index 000000000..743ebc2c2 --- /dev/null +++ b/docs/adr/0011-agents-choose-their-codegraphy-strategy.md @@ -0,0 +1,50 @@ +# Agents choose their CodeGraphy strategy + +**Status:** Accepted + +## Context + +ADR 0010 selected a compact Agent Skill after two historical regression benchmarks. That skill improved model-token use, but it also prescribed a command sequence, fixed call budget, search wording, stopping rules, and when CodeGraphy was permitted. Those constraints measured compliance with one navigation policy rather than whether an agent understood CodeGraphy well enough to reason about the task. + +An unknown executable on `PATH` is not a meaningful CodeGraphy treatment because agents do not know that it exists or what its graph represents. The deployable agent interface is the CLI together with its generalized Agent Skill. + +Future CLI experiments also need a stable implementation workload. Diagnosis-only questions do not exercise the complete loop of localization, source reasoning, editing, test selection, and verification. Changing benchmark tasks between candidates makes token differences uninterpretable. + +## Decision + +The CodeGraphy Agent Skill describes the product rather than directing an agent workflow. It covers: + +- Relationship Graph Nodes, directed typed Edges, and static-analysis limits; +- Indexing, Graph Cache state, Plugins, Filters, Graph Scope, settings, status, and doctor; +- Search, Target Query, inventories, dependencies, dependents, and Path; +- live source evidence versus cached Symbols and Relationships; +- exact target identity, wildcard and phrase behavior, projections, pagination, bounds, and completeness; +- JSON stdout, structured stderr, exit status, verbose diagnostics, and recovery details; +- coverage, freshness, hub, static-analysis, and interpretation limitations. + +The skill does not prescribe a command sequence, maximum call count, preferred search phrase, stopping rule, or conditions under which the agent is allowed to use CodeGraphy. The agent selects commands and combines graph evidence with ordinary repository tools according to its own reasoning. + +Agent-utility experiments use one frozen moderate feature-change task until a deliberate benchmark-version change. Each condition runs three times in a fresh isolated workspace and agent home: + +- **Raw:** no CodeGraphy executable, Graph Cache, skill, repository Agent Markdown, prior session, or answer-bearing context. +- **CodeGraphy:** the candidate CLI plus the exact shipped generalized skill and a freshly prepared per-run Graph Cache. Indexing cost is measured separately. + +Both conditions receive the same neutral task prompt, source tree, dependency state, tool access, model, and thinking level. A run is correct only when the unchanged hidden validation passes and the requested behavior is implemented. Cumulative model tokens per correct run are primary. Mean and median tokens, token classes, calls, tool output, elapsed time, fallback navigation, files read, and indexing cost are reported. + +Each CLI or skill candidate changes one independently attributable behavior and reruns the same three-versus-three comparison. Candidates are retained only with non-inferior correctness and an end-to-end token improvement that is not explained solely by one outlier. Rejected implementation and generated fixtures are removed before the next candidate. + +## Consequences + +- CodeGraphy is evaluated as the installable CLI-plus-skill product, not as an undiscoverable executable. +- Agents can exploit commands and combinations not anticipated by the skill author. +- Skill improvements deepen accurate understanding rather than steering agents toward benchmark-specific trajectories. +- Feature implementation, hidden tests, and fresh workspaces make the benchmark closer to real coding work than answer-only localization. +- Three repetitions provide a fast iteration signal, not a universal performance claim; promising candidates require broader held-out confirmation. +- ADR 0010 remains the historical record of the token-first experiments, but its prescribed navigation policy is superseded. + +## References + +- ADR 0007, Search and Target Query +- ADR 0008, retrieval and settings experiments +- ADR 0009, exact reexport Relationships +- ADR 0010, initial token-first clean-install experiments diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 4220b53a4..bc113cd6b 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -1,51 +1,62 @@ --- name: codegraphy -description: Use the CodeGraphy CLI for bounded source and relationship navigation in a prepared workspace graph. +description: Understand and operate the CodeGraphy CLI for workspace source, symbol, and relationship exploration. --- # CodeGraphy -CodeGraphy identifies the smallest set of source files worth reading. It provides navigation evidence, not a substitute for source inspection. +CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and plugin-defined concepts. Directed Edges identify relationships such as imports, reexports, calls, references, inheritance, and containment. Edge direction and type explain why two Nodes are connected. -## When to use it +The graph is navigation evidence. Static relationships can identify ownership and possible change surfaces, but they do not prove runtime behavior. Source, tests, generated behavior, dynamic dispatch, and unsupported language semantics can add facts that are absent from the graph. -Use CodeGraphy when: +## Graph lifecycle and freshness -- a task gives a symptom or identifier but not the owning source; -- an unfamiliar or large repository makes broad search expensive; -- a caller, dependency, re-export, or impact path is unknown. +`codegraphy index` discovers eligible workspace files, runs built-in and enabled Plugin analysis, and creates or incrementally updates `.codegraphy/graph.sqlite`. The Graph Cache stores complete indexed facts before Graph Scope and path Filters shape query results. -Skip CodeGraphy when: +`codegraphy filter` changes persisted path exclusions without rebuilding cached analysis. `codegraphy scope` changes which Node Types and Edge Types appear in the shaped graph. `codegraphy settings` exposes workspace discovery, indexing, filter, scope, Plugin, and interface settings; settings mutations report whether another Index is required. `codegraphy plugins` controls installed Plugin registration and activation. -- the task or failing test already names the relevant source and the question is local to it; -- a small repository or one narrow text search is sufficient; -- the task is primarily about prose or configuration rather than code relationships. +Query with any read-only graph command after a Graph Cache exists. A `graph_cache_not_found` result means that no indexed snapshot is available. `codegraphy status` reports supported missing, stale, and fresh cache conditions. `codegraphy doctor` checks runtime, settings, cache, and Plugin health and includes recovery information for unhealthy checks. -## Setup only when needed +Most Symbols and Relationships are cached. Search also reads current source text from eligible indexed File Nodes. A single response can therefore contain `freshness: "live"` source matches and cached Symbol matches whose `cacheState` is `fresh` or `stale`. Indexing is what makes changed AST and Relationship facts current. -Do not call `status`, inspect settings, or index before ordinary navigation. If a command reports `graph_cache_not_found`, run `codegraphy index` once and retry. Use `codegraphy filter`, `settings`, `scope`, or `plugins` only to prepare or durably change the workspace, not as navigation steps. If indexing reports a file-budget cap, follow its `maxFiles` recovery action and reindex once. +## Query surfaces -Query with the narrowest operation that answers the question: +| Command | Information returned | +|---|---| +| `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | +| `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | +| `nodes` | A paginated Node inventory from the shaped graph. | +| `edges` | A paginated Relationship inventory from the shaped graph. | +| `dependencies ` | Outgoing Relationships from a File path or exact Node ID. | +| `dependents ` | Incoming Relationships to a File path or exact Node ID. | +| `path ` | A bounded Relationship route between two File paths or exact Node IDs. | -- `search ` locates live source, AST Symbols, and File Nodes. -- `query ` gives bounded declarations plus incoming and outgoing Relationships for one returned File or Symbol ID. -- `dependencies`, `dependents`, and `path` answer one unresolved relationship question. -- `nodes` and `edges` enumerate graph inventories; do not use them for ordinary localization. +Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. -## Token-bounded navigation policy +`--filter`, `--node-type`, and `--edge-type` are one-off query projections and do not modify `.codegraphy/settings.json`. Persisted Filter and Scope changes affect later commands without deleting the complete cached facts. -1. Use `search` only when the relevant source is not already known. Search with at most three task literals: - - use an exact identifier plus one domain word when the identifier is common; - - otherwise use two or three short task words; - - never submit a sentence or guess an API name. -2. If the first page is broad or unrelated, the second and final normal call may refine the search with one different task word. Otherwise use `query` only when one returned target's relationships are needed. -3. Normally make at most two CodeGraphy calls. A third is allowed only for one unresolved relationship continuation. Commands launched concurrently count separately. -4. Never repeat the same search or search an identifier already returned. -5. Once Search returns plausible source or test paths, read the best few paths directly. Do not rerun repository-wide `rg`, `grep`, or `find` merely to localize the same task. Use file-local search after reading. -6. Stop graph navigation once relevant files are known. Verify the requested behavior and tests from source before answering or editing. +## Search and target identity -Search is case-insensitive, `*` is a line-local wildcard, and multi-term searches rank Files containing all terms. Quote patterns containing spaces or `*`. Search results include paths and locations; query targets must be exact returned File paths or Symbol IDs. +Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. Search is lexical rather than a semantic-answer engine. -Results are JSON envelopes. Use `data.page.nextOffset` only when the current page is insufficient. On `query_target_not_found`, search for an exact target. After one empty or refined search, switch to ordinary source search rather than spending more graph calls. +Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. -Run `codegraphy --help` or `codegraphy --help` for complete syntax and examples. +Search, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. + +## Machine-readable contract + +Normal command results are JSON envelopes. Successful data is written to stdout. Operational and invalid-invocation failures use structured error envelopes on stderr and nonzero exit statuses. `--verbose` adds lifecycle diagnostics to stderr without changing the data envelope. + +Common error codes distinguish invalid arguments, missing or stale workspace state, an exact target that is absent, malformed settings, and operational failures. Error `details` and `actions` carry command-specific recovery context when available. + +## Interpretation limits + +- Graph coverage depends on eligible files, the indexing file budget, enabled Plugins, supported languages, and analyzer capabilities. +- Persisted Filters and Graph Scope can intentionally hide facts from broad inventories. +- Live text and cached structural facts can have different freshness in the same result. +- Imports, calls, references, and inferred or plugin-defined Edges have different semantics; an Edge Type should be interpreted rather than treated as generic proximity. +- Incoming Relationships suggest consumers or possible impact, while outgoing Relationships suggest dependencies; neither alone defines the complete edit set. +- Hubs, barrels, generated files, tests, and shared utilities can have many legitimate Relationships and can dominate broad graph results. +- Bounded or paginated output is not evidence that omitted results do not exist. + +Current command syntax, options, and examples are available from `codegraphy --help` and `codegraphy --help`. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 17f13fff8..6b6739e3f 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -22,6 +22,27 @@ test('the generalized CodeGraphy skill teaches the lifecycle and delegates synta assert.doesNotMatch(skill, /MCP|graph\.lbug/); }); +test('the CodeGraphy skill explains the tool without prescribing an agent workflow', () => { + const skill = readFileSync(skillPath, 'utf8'); + + for (const concept of [ + 'Relationship Graph', + 'Graph Cache', + 'live source', + 'cached', + 'Graph Scope', + 'pagination', + 'stdout', + 'stale', + ]) { + assert.match(skill, new RegExp(concept, 'i')); + } + assert.doesNotMatch(skill, /Token-bounded navigation policy/i); + assert.doesNotMatch(skill, /(?:at most|no more than|normally make)\s+(?:one|two|three|\d+)\s+CodeGraphy calls?/i); + assert.doesNotMatch(skill, /(?:second and final|a third is allowed|commands launched concurrently count)/i); + assert.doesNotMatch(skill, /(?:never submit|one domain word|task literals|after one empty or refined search)/i); +}); + test('the old MCP package and skill are absent from the release source', () => { assert.equal(existsSync(path.join(repoRoot, 'packages', 'mcp', 'package.json')), false); assert.equal(existsSync(path.join(repoRoot, 'skills', 'codegraphy-mcp', 'SKILL.md')), false); From b887291bff17d1eaf28323dff1b457980a191e24 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 17:35:26 -0700 Subject: [PATCH 34/55] feat(cli): add coverage-balanced triage --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 5 +- README.md | 1 + packages/core/README.md | 4 +- packages/core/src/cli/help/command.ts | 17 ++ packages/core/src/cli/parseQuery.ts | 19 +- packages/core/src/graphQuery/execute.ts | 4 + packages/core/src/graphQuery/index.ts | 4 + packages/core/src/graphQuery/model.ts | 29 +++ packages/core/src/graphQuery/triage.ts | 176 ++++++++++++++++++ packages/core/src/index.ts | 4 + packages/core/src/workspace/requestQuery.ts | 4 +- packages/core/src/workspace/requestTypes.ts | 1 + packages/core/tests/cli/help/command.test.ts | 4 + packages/core/tests/cli/parseQuery.test.ts | 17 ++ packages/core/tests/graphQuery/triage.test.ts | 153 +++++++++++++++ .../core/tests/workspace/requestQuery.test.ts | 10 + skills/codegraphy/SKILL.md | 3 +- tests/release/codegraphySkill.test.mjs | 2 +- 19 files changed, 450 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/graphQuery/triage.ts create mode 100644 packages/core/tests/graphQuery/triage.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index aa912aaf9..1cf198222 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -3,4 +3,4 @@ "@codegraphy-dev/plugin-api": minor --- -Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add coverage-balanced `codegraphy triage` File rankings for task text, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. diff --git a/CONTEXT.md b/CONTEXT.md index a89b30557..c7b2564ea 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -46,12 +46,13 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Graph View Search** | A temporary text query that narrows the current graph without changing Filter settings. | | **Searched Graph** | The Filtered Graph after Graph View Search. | | **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates. It reports source and cache provenance and does not change settings. | +| **CLI Triage** | A bounded File ranking that independently matches task terms, weights lexical rarity and graph degree, prefers production source, balances source areas, and reports matched terms without excerpts or inferred answers. | | **Target Query** | A bounded overview of one exact File or Symbol Node, including prioritized declarations and incoming/outgoing Relationships. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | | **Orphan Node** | A Node with no remaining Edges after graph narrowing. | | **Show Orphans** | A final Graph View setting that keeps or removes Orphan Nodes. | -Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, Target Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. +Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, CLI Triage, Target Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. ## Selection, Focus, and Collapse @@ -114,7 +115,7 @@ The Graph View can use a whole-view loading state before its first graph payload | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | -The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric; see ADR 0011. +The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search`, `triage`, and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric; see ADR 0011. ## Plugins diff --git a/README.md b/README.md index 1dcc7d012..5bff1f0cf 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy settings [get|set|unset]` | Safely reads or changes validated workspace settings such as `maxFiles`. | | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | | `codegraphy search ` | Finds exact evidence and uses deterministic all-term File ranking for sparse natural multi-term phrases. | +| `codegraphy triage ` | Fuses independently matched task terms into a compact, source-area-balanced File ranking. | | `codegraphy query ` | Inspects one exact File or Symbol with prioritized declarations and incoming/outgoing Relationships. | | `codegraphy edges` | Lists bounded Edges. | | `codegraphy dependencies ` | Lists outgoing Relationships for a file or exact Symbol Node. | diff --git a/packages/core/README.md b/packages/core/README.md index e77d7f64e..45d40f8ed 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `triage` independently matches task terms, weights rarer evidence and graph-central Files, and balances source areas into one compact File ranking without excerpts or inferred answers. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy settings get maxFiles @@ -19,6 +19,7 @@ codegraphy doctor codegraphy nodes codegraphy search SettingsPanel codegraphy search 'Indexing *workspace*' +codegraphy triage 'offline graph document sizing physics' codegraphy query src/cli/index/command.ts codegraphy edges codegraphy dependencies src/app.ts @@ -49,6 +50,7 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - Workspace status: report fresh, stale, or missing Graph Cache state with inspectable stale reasons. - Graph Cache storage: load, save, clear, and inspect normalized File, Node, Symbol, and Edge rows in the SQLite-backed Graph Cache at `/.codegraphy/graph.sqlite`. - Workspace Search: merge bounded exact evidence with deterministic all-term File fallback candidates for natural multi-term phrases. +- Workspace Triage: fuse independent task terms into a paginated File ranking weighted by lexical rarity, graph degree, production-source preference, and source-area coverage. - Target Query: inspect one exact File or Symbol with prioritized declarations and bounded Relationships. - Graph Query: list scoped Nodes and Edges, then use complete cached types by default for exact targeted relationships and bounded paths unless an invocation explicitly projects Node or Edge Types. diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 2344ab397..93d6bb245 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -30,6 +30,7 @@ const ROOT_HELP = [ '', 'Explore:', ' codegraphy search Find live source text and cached AST Symbols', + ' codegraphy triage Fuse task terms into a compact ranked File set', ' codegraphy query Inspect one exact File or Symbol Node', '', 'Navigate the Relationship Graph:', @@ -156,6 +157,22 @@ const COMMAND_HELP: Record = { 'Example: codegraphy search runIndexCommand', "Example: codegraphy search 'Indexing *workspace*'", ].join('\n'), + triage: [ + 'Usage: codegraphy triage [--limit ] [--offset ] ', + '', + 'Fuse independently matched task terms into one compact, deterministic File ranking.', + 'Each File records its matched query terms; no source excerpts or inferred answers are added.', + '', + 'Arguments:', + ' Task text or several lexical concepts, quoted as one argument', + '', + 'Effects: Read-only. Does not perform Indexing; source text is read live from indexed files.', + 'Output: Bounded JSON Files, matched query terms, pagination, and live/cached freshness.', + 'Options:', + ' --limit Maximum Files to return (default: 8; at most 20)', + ' --offset Zero-based result offset (default: 0)', + "Example: codegraphy triage 'offline graph document sizing physics'", + ].join('\n'), edges: [ 'Usage: codegraphy edges [--limit ] [--offset ]', '', diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index 04ab34d9d..be6ac7ecd 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -9,10 +9,13 @@ const QUERY_COMMANDS = new Set([ 'path', 'query', 'search', + 'triage', ]); const DEFAULT_LIMIT = 100; const DEFAULT_SEARCH_LIMIT = 20; +const DEFAULT_TRIAGE_LIMIT = 8; +const MAX_TRIAGE_LIMIT = 20; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; @@ -193,6 +196,15 @@ function buildSearch(input: QueryBuilderInput): CliCommand { return query(input.command, 'search', { pattern: input.operands[0], ...input.page }, input.projection); } +function buildTriage(input: QueryBuilderInput): CliCommand { + const invalid = requireOperands(input.command, input.operands, 1, ''); + if (invalid) return invalid; + if (input.page.limit > MAX_TRIAGE_LIMIT) { + return parseError(input.command, `--limit for triage must be at most ${MAX_TRIAGE_LIMIT}`); + } + return query(input.command, 'triage', { query: input.operands[0], ...input.page }, input.projection); +} + function buildOverview(input: QueryBuilderInput): CliCommand { const invalid = requireOperands(input.command, input.operands, 1, ''); return invalid ?? query(input.command, 'overview', { target: input.operands[0] }, input.projection); @@ -224,6 +236,7 @@ const QUERY_BUILDERS: Record CliCommand> = nodes: buildList, edges: buildList, search: buildSearch, + triage: buildTriage, query: buildOverview, dependencies: input => buildConnection(input, 'from'), dependents: input => buildConnection(input, 'to'), @@ -238,7 +251,11 @@ export function parseQueryCommand(argv: string[]): CliCommand { command, rawArgs, command !== 'path' && command !== 'query', - command === 'search' ? DEFAULT_SEARCH_LIMIT : DEFAULT_LIMIT, + command === 'search' + ? DEFAULT_SEARCH_LIMIT + : command === 'triage' + ? DEFAULT_TRIAGE_LIMIT + : DEFAULT_LIMIT, ); if (parsed.parseError) return parseError(command, parsed.parseError); return builder({ diff --git a/packages/core/src/graphQuery/execute.ts b/packages/core/src/graphQuery/execute.ts index 738c09dde..e7b27249b 100644 --- a/packages/core/src/graphQuery/execute.ts +++ b/packages/core/src/graphQuery/execute.ts @@ -8,6 +8,7 @@ import type { GraphQueryResult, GraphQuerySearchConfig, GraphQuerySymbolsConfig, + GraphQueryTriageConfig, } from './model'; import { inspectGraphTarget } from './overview'; import { findGraphPaths } from './paths'; @@ -15,6 +16,7 @@ import { listGraphEdges, listGraphNodes } from './reports'; import { listGraphRelationships } from './relationships'; import { searchGraph } from './search'; import { listGraphSymbols } from './symbols'; +import { triageGraph } from './triage'; import { deriveScopedGraphQueryData } from './visible'; type GraphQueryHandler = ( @@ -29,6 +31,7 @@ type GraphQueryHandlers = { symbols: GraphQueryHandler; paths: GraphQueryHandler; search: GraphQueryHandler; + triage: GraphQueryHandler; overview: GraphQueryHandler; }; @@ -39,6 +42,7 @@ const GRAPH_QUERY_HANDLERS: GraphQueryHandlers = { symbols: (data, args) => listGraphSymbols(data, args), paths: (data, args) => findGraphPaths(deriveScopedGraphQueryData(data.graphData, args), args), search: (data, args) => searchGraph(data, args), + triage: (data, args) => triageGraph(data, args), overview: (data, args) => inspectGraphTarget(data, args), }; diff --git a/packages/core/src/graphQuery/index.ts b/packages/core/src/graphQuery/index.ts index de1577a6e..f33339923 100644 --- a/packages/core/src/graphQuery/index.ts +++ b/packages/core/src/graphQuery/index.ts @@ -28,6 +28,9 @@ export type { GraphQuerySymbolReport, GraphQuerySymbolReportItem, GraphQuerySymbolsConfig, + GraphQueryTriageConfig, + GraphQueryTriageFile, + GraphQueryTriageReport, } from './model'; export type { GraphQueryData } from './data'; export { executeGraphQuery } from './execute'; @@ -37,3 +40,4 @@ export { listGraphEdges, listGraphNodes } from './reports'; export { listGraphRelationships } from './relationships'; export { searchGraph } from './search'; export { listGraphSymbols } from './symbols'; +export { triageGraph } from './triage'; diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index 29594eeab..b9bd9b9a3 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -65,6 +65,10 @@ export interface GraphQuerySearchConfig extends GraphQueryConfig { pattern: string; } +export interface GraphQueryTriageConfig extends GraphQueryConfig { + query: string; +} + export interface GraphQueryOverviewConfig { target: string; } @@ -189,6 +193,28 @@ export interface GraphQuerySearchReport { }; } +export interface GraphQueryTriageFile extends GraphQueryNodeReportItem { + matchedTerms: string[]; +} + +export interface GraphQueryTriageReport { + query: string; + terms: string[]; + files: GraphQueryTriageFile[]; + page: GraphQueryPage; + sources: { + text: { + freshness: 'live'; + filesScanned: number; + filesSkipped: number; + }; + graph: { + freshness: 'cached'; + cacheState: 'fresh' | 'stale'; + }; + }; +} + export interface GraphQueryOverviewReport { target: GraphQueryNodeReportItem; declaredSymbols: GraphQuerySymbolReport; @@ -212,6 +238,7 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' + | 'triage' | 'overview'; export type GraphQueryRequest = @@ -221,6 +248,7 @@ export type GraphQueryRequest = | { report: 'symbols'; arguments?: GraphQuerySymbolsConfig } | { report: 'paths'; arguments: GraphQueryPathConfig } | { report: 'search'; arguments: GraphQuerySearchConfig } + | { report: 'triage'; arguments: GraphQueryTriageConfig } | { report: 'overview'; arguments: GraphQueryOverviewConfig }; export type GraphQueryResult = @@ -230,5 +258,6 @@ export type GraphQueryResult = | GraphQuerySymbolReport | GraphQueryPathReport | GraphQuerySearchReport + | GraphQueryTriageReport | GraphQueryOverviewReport | GraphQueryTargetNotFoundReport; diff --git a/packages/core/src/graphQuery/triage.ts b/packages/core/src/graphQuery/triage.ts new file mode 100644 index 000000000..5f877a8aa --- /dev/null +++ b/packages/core/src/graphQuery/triage.ts @@ -0,0 +1,176 @@ +import type { IGraphNode } from '../graph/contracts'; +import type { GraphQueryData } from './data'; +import type { + GraphQueryTriageConfig, + GraphQueryTriageFile, + GraphQueryTriageReport, +} from './model'; +import { toNodeReportItem } from './nodeReport'; +import { paginate } from './pagination'; + +const MAX_QUERY_TERMS = 12; +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'into', + 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with', +]); + +interface TriageDocument { + node: IGraphNode; + pathTerms: readonly string[]; + textTerms: readonly string[]; +} + +interface RankedTriageFile { + file: GraphQueryTriageFile; + score: number; +} + +function tokenize(value: string): string[] { + const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2'); + return separated.match(/[\p{L}\d]+/gu)?.map(term => term.toLocaleLowerCase()) + .filter(term => term.length > 2 && !STOP_WORDS.has(term)) ?? []; +} + +function createDocuments(data: GraphQueryData): TriageDocument[] { + const nodesByPath = new Map(data.graphData.nodes + .filter(node => node.nodeType === 'file' && !node.symbol) + .map(node => [node.id, node])); + return (data.sourceText?.files ?? []).flatMap(({ filePath, content }) => { + const node = nodesByPath.get(filePath); + return node ? [{ node, pathTerms: tokenize(filePath), textTerms: tokenize(content) }] : []; + }); +} + +function selectQueryTerms(query: string, documents: readonly TriageDocument[]): string[] { + const candidates = [...new Set(tokenize(query))]; + const documentFrequency = new Map(candidates.map(term => [ + term, + documents.filter(document => ( + document.pathTerms.includes(term) || document.textTerms.includes(term) + )).length, + ])); + return candidates + .filter(term => (documentFrequency.get(term) ?? 0) > 0) + .map((term, index) => ({ + term, + index, + frequency: documentFrequency.get(term) ?? 0, + })) + .sort((left, right) => left.frequency - right.frequency || left.index - right.index) + .slice(0, MAX_QUERY_TERMS) + .sort((left, right) => left.index - right.index) + .map(item => item.term); +} + +function countTerms(terms: readonly string[], target: string): number { + let count = 0; + for (const term of terms) if (term === target) count += 1; + return count; +} + +function rankingGroup(filePath: string): string { + const segments = filePath.split('/'); + const sourceIndex = segments.findIndex(segment => segment === 'src' || segment === 'tests'); + const end = sourceIndex >= 0 ? sourceIndex + 2 : Math.min(segments.length, 3); + return segments.slice(0, end).join('/'); +} + +function balanceRankedFiles(ranked: readonly RankedTriageFile[]): RankedTriageFile[] { + const groups = new Map(); + for (const item of ranked) { + const group = rankingGroup(item.file.path); + groups.set(group, [...(groups.get(group) ?? []), item]); + } + const orderedGroups = [...groups.entries()].sort((left, right) => ( + (right[1][0]?.score ?? 0) - (left[1][0]?.score ?? 0) + || left[0].localeCompare(right[0]) + )); + const balanced: RankedTriageFile[] = []; + for (let index = 0; balanced.length < ranked.length; index += 1) { + for (const [, items] of orderedGroups) { + const item = items[index]; + if (item) balanced.push(item); + } + } + return balanced; +} + +function rankDocument( + document: TriageDocument, + queryTerms: readonly string[], + documentCount: number, + frequencies: ReadonlyMap, + degree: number, +): RankedTriageFile | undefined { + const matchedTerms = queryTerms.filter(term => ( + document.pathTerms.includes(term) || document.textTerms.includes(term) + )); + if (matchedTerms.length === 0) return undefined; + const lexicalScore = matchedTerms.reduce((total, term) => { + const inverseFrequency = Math.log((documentCount + 1) / ((frequencies.get(term) ?? 0) + 1)) + 1; + const pathCount = countTerms(document.pathTerms, term); + const textCount = countTerms(document.textTerms, term); + return total + inverseFrequency * (pathCount * 20 + Math.min(textCount, 10)); + }, 0); + const testPenalty = /(?:^|\/)(?:__tests__|tests?)(?:\/|\.)|\.(?:spec|test)\.[^/]+$/iu.test(document.node.id) + ? 12 + : 0; + const score = lexicalScore + Math.log2(degree + 1) * 15 - testPenalty; + return { + file: { ...toNodeReportItem(document.node), matchedTerms }, + score, + }; +} + +export function triageGraph( + data: GraphQueryData, + config: GraphQueryTriageConfig, +): GraphQueryTriageReport { + const documents = createDocuments(data); + const terms = selectQueryTerms(config.query, documents); + const frequencies = new Map(terms.map(term => [ + term, + documents.filter(document => ( + document.pathTerms.includes(term) || document.textTerms.includes(term) + )).length, + ])); + const degrees = new Map(documents.map(document => [document.node.id, 0])); + for (const edge of data.graphData.edges) { + if (edge.kind === 'contains') continue; + if (degrees.has(edge.from)) degrees.set(edge.from, (degrees.get(edge.from) ?? 0) + 1); + if (degrees.has(edge.to)) degrees.set(edge.to, (degrees.get(edge.to) ?? 0) + 1); + } + const ranked = documents + .flatMap(document => rankDocument( + document, + terms, + documents.length, + frequencies, + degrees.get(document.node.id) ?? 0, + ) ?? []) + .sort((left, right) => ( + right.score - left.score + || right.file.matchedTerms.length - left.file.matchedTerms.length + || left.file.path.localeCompare(right.file.path) + )); + const balanced = balanceRankedFiles(ranked); + const page = paginate(balanced.map(item => item.file), config); + + return { + query: config.query, + terms, + files: page.items, + page: page.page, + sources: { + text: { + freshness: 'live', + filesScanned: data.sourceText?.filesScanned ?? 0, + filesSkipped: data.sourceText?.filesSkipped ?? 0, + }, + graph: { + freshness: 'cached', + cacheState: data.cacheState ?? 'fresh', + }, + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77b7f43c4..f3fe32a5b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -464,6 +464,9 @@ export type { GraphQuerySymbolReport, GraphQuerySymbolReportItem, GraphQuerySymbolsConfig, + GraphQueryTriageConfig, + GraphQueryTriageFile, + GraphQueryTriageReport, } from './graphQuery'; export { executeGraphQuery, @@ -474,4 +477,5 @@ export { listGraphRelationships, listGraphSymbols, searchGraph, + triageGraph, } from './graphQuery'; diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index bafc5a03e..30b5e5aea 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -56,7 +56,7 @@ function hasTargetSelector(arguments_: Record): boolean { function shouldApplyWorkspaceGraphScope(input: WorkspaceGraphQueryInput): boolean { if (input.report === 'relationships') return true; - if (input.report === 'search' || input.report === 'overview' || input.report === 'paths') return false; + if (input.report === 'search' || input.report === 'triage' || input.report === 'overview' || input.report === 'paths') return false; return !hasTargetSelector(input.arguments); } @@ -73,7 +73,7 @@ function executeWorkspaceGraphQuery( source, input.projection, ); - const sourceText = input.report === 'search' + const sourceText = input.report === 'search' || input.report === 'triage' ? readWorkspaceQuerySourceText(workspaceRoot, graphData, source.indexedContentHashes) : undefined; const completeScope = { diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index aeffa1f4c..c2ac0eb93 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -7,6 +7,7 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' + | 'triage' | 'overview'; export interface WorkspacePathInput { diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 66648984a..92aa4ba4b 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -10,6 +10,7 @@ describe('cli/help/command', () => { expect(result.output).not.toContain('codegraphy batch'); expect(result.output).toContain('codegraphy settings'); expect(result.output).toContain('codegraphy search '); + expect(result.output).toContain('codegraphy triage '); expect(result.output).toContain('codegraphy query '); expect(result.output).toContain('codegraphy dependencies '); expect(result.output).toContain('codegraphy path '); @@ -54,6 +55,9 @@ describe('cli/help/command', () => { expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); expect(createHelpResult(['search']).output).toContain('all-term path/source ranking'); + expect(createHelpResult(['triage']).output).toContain('Usage: codegraphy triage'); + expect(createHelpResult(['triage']).output).toContain('matched query terms'); + expect(createHelpResult(['triage']).output).toContain('at most 20'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index 05a5a42f1..d01548322 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -21,6 +21,11 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: 'render settings', limit: 20 }, }); + expect(parseQueryCommand(['triage', 'offline graph document physics'])).toEqual({ + name: 'query', + report: 'triage', + arguments: { query: 'offline graph document physics', limit: 8 }, + }); expect(parseQueryCommand(['query', 'src/cli/command.ts'])).toEqual({ name: 'query', invokedCommand: 'query', @@ -63,6 +68,10 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: '-generated', limit: 20 }, }); + expect(parseQueryCommand(['triage', '--limit', '5', '--offset', '2', 'graph physics'])).toMatchObject({ + report: 'triage', + arguments: { query: 'graph physics', limit: 5, offset: 2 }, + }); }); it('parses one-off Filter and Graph Scope overrides for every query', () => { @@ -123,6 +132,14 @@ describe('cli/parseQuery', () => { invokedCommand: 'query', parseError: 'query requires ', }); + expect(parseQueryCommand(['triage'])).toMatchObject({ + invokedCommand: 'triage', + parseError: 'triage requires ', + }); + expect(parseQueryCommand(['triage', '--limit', '21', 'graph physics'])).toMatchObject({ + invokedCommand: 'triage', + parseError: '--limit for triage must be at most 20', + }); expect(parseQueryCommand(['dependencies', 'a.ts', 'b.ts'])).toMatchObject({ parseError: 'Unexpected argument for dependencies: b.ts', }); diff --git a/packages/core/tests/graphQuery/triage.test.ts b/packages/core/tests/graphQuery/triage.test.ts new file mode 100644 index 000000000..f119c07ae --- /dev/null +++ b/packages/core/tests/graphQuery/triage.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../src/graph/contracts'; +import { triageGraph } from '../../src/graphQuery/triage'; + +const files = [ + { + filePath: 'packages/extension/src/graph/sizing/calculations.ts', + content: 'export function connectionRadius(count: number) { return boundedSquareRoot(count); }', + }, + { + filePath: 'packages/tldraw/src/document/records.ts', + content: 'export function createOfflineGraphDocument() { return createFileNodeDiameter(); }', + }, + { + filePath: 'packages/tldraw/src/script/physics/engine/model.ts', + content: 'export function createPhysicsEngine() { return visibleCollisionAndRepelSize(); }', + }, + { + filePath: 'packages/core/src/graph/contracts.ts', + content: 'export interface GraphNode { nodeType: string; }', + }, +]; + +const graphData: IGraphData = { + nodes: files.map(file => ({ id: file.filePath, label: file.filePath.split('/').at(-1) ?? '', nodeType: 'file' })), + edges: [], +}; + +function triage(query: string, limit = 8, offset = 0) { + return triageGraph({ + graphData, + sourceText: { files, filesScanned: files.length, filesSkipped: 0 }, + cacheState: 'fresh', + }, { query, limit, offset }); +} + +describe('core/graphQuery triage', () => { + it('fuses independent task terms into a compact coverage-balanced File set', () => { + const result = triage( + 'Enhance offline graph document node sizing with bounded connection behavior and visible collision physics', + ); + + expect(result.files.map(file => file.path)).toEqual(expect.arrayContaining([ + 'packages/extension/src/graph/sizing/calculations.ts', + 'packages/tldraw/src/document/records.ts', + 'packages/tldraw/src/script/physics/engine/model.ts', + ])); + expect(result.files.find(file => file.path.endsWith('records.ts'))?.matchedTerms).toEqual( + expect.arrayContaining(['document', 'offline']), + ); + expect(result.files.find(file => file.path.endsWith('model.ts'))?.matchedTerms).toEqual( + expect.arrayContaining(['collision', 'physics', 'visible']), + ); + expect(result.terms.length).toBeLessThanOrEqual(12); + expect(result.sources).toEqual({ + text: { freshness: 'live', filesScanned: 4, filesSkipped: 0 }, + graph: { freshness: 'cached', cacheState: 'fresh' }, + }); + }); + + it('keeps distinct task concepts visible when one subsystem has many lexical matches', () => { + const physicsFiles = Array.from({ length: 12 }, (_, index) => ({ + filePath: `packages/renderer/src/physics/collision/force-${index}.ts`, + content: 'export function physicsCollision() { return visibleNodeSize(); }', + })); + const allFiles = [...files, ...physicsFiles]; + const result = triageGraph({ + graphData: { + nodes: allFiles.map(file => ({ + id: file.filePath, + label: file.filePath.split('/').at(-1) ?? '', + nodeType: 'file', + })), + edges: [], + }, + sourceText: { files: allFiles, filesScanned: allFiles.length, filesSkipped: 0 }, + }, { + query: 'offline graph document connection sizing visible collision physics', + limit: 8, + }); + + expect(result.files.map(file => file.path)).toEqual(expect.arrayContaining([ + 'packages/extension/src/graph/sizing/calculations.ts', + 'packages/tldraw/src/document/records.ts', + 'packages/tldraw/src/script/physics/engine/model.ts', + ])); + }); + + it('uses graph degree to prefer a central owner among equally matching Files', () => { + const sourceFiles = [ + { filePath: 'src/domain/a-leaf.ts', content: 'export const documentNode = 1;' }, + { filePath: 'src/domain/z-owner.ts', content: 'export const documentNode = 1;' }, + ...Array.from({ length: 5 }, (_, index) => ({ + filePath: `src/consumer-${index}.ts`, + content: 'export const consumer = true;', + })), + ]; + const result = triageGraph({ + graphData: { + nodes: sourceFiles.map(file => ({ id: file.filePath, label: file.filePath, nodeType: 'file' })), + edges: sourceFiles.slice(2).map((file, index) => ({ + id: `edge-${index}`, + from: file.filePath, + to: 'src/domain/z-owner.ts', + kind: 'import', + sources: [], + })), + }, + sourceText: { files: sourceFiles, filesScanned: sourceFiles.length, filesSkipped: 0 }, + }, { query: 'document node', limit: 2 }); + + expect(result.files[0]?.path).toBe('src/domain/z-owner.ts'); + }); + + it('prefers production Files when a matching test has equivalent evidence', () => { + const sourceFiles = [ + { filePath: 'src/domain/owner.test.ts', content: 'export const documentNode = 1;' }, + { filePath: 'src/domain/owner.ts', content: 'export const documentNode = 1;' }, + ]; + const result = triageGraph({ + graphData: { + nodes: sourceFiles.map(file => ({ id: file.filePath, label: file.filePath, nodeType: 'file' })), + edges: [], + }, + sourceText: { files: sourceFiles, filesScanned: sourceFiles.length, filesSkipped: 0 }, + }, { query: 'document node', limit: 2 }); + + expect(result.files[0]?.path).toBe('src/domain/owner.ts'); + }); + + it('is deterministic and paginates one fused ranking', () => { + const query = 'offline graph document connection sizing collision physics'; + const complete = triage(query); + const page = triage(query, 2, 1); + + expect(triage(query)).toEqual(complete); + expect(page.files).toEqual(complete.files.slice(1, 3)); + expect(page.page).toEqual({ + offset: 1, + limit: 2, + returned: 2, + total: complete.files.length, + nextOffset: 3, + }); + }); + + it('omits terms that have no eligible File evidence', () => { + const result = triage('zzzzzz offline documents'); + + expect(result.terms).not.toContain('zzzzzz'); + expect(result.files.every(file => file.matchedTerms.length > 0)).toBe(true); + }); +}); diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index d06969f2e..46036004e 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -26,6 +26,11 @@ describe('workspace/requestQuery', () => { report: 'search', arguments: { pattern: 'Indexing ', limit: 20 }, }); + const triageResult = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'triage', + arguments: { query: 'index command progress', limit: 8 }, + }); expect(symbolResult).toMatchObject({ sources: { symbols: { freshness: 'cached', cacheState: 'fresh' } }, @@ -43,6 +48,11 @@ describe('workspace/requestQuery', () => { }], sources: { text: { freshness: 'live', filesScanned: 1, filesSkipped: 0 } }, }); + expect(triageResult).toMatchObject({ + terms: ['index', 'command'], + files: [{ path: 'entry.ts', nodeType: 'file', matchedTerms: ['index', 'command'] }], + sources: { text: { freshness: 'live', filesScanned: 1, filesSkipped: 0 } }, + }); }); it('uses complete graph scope for an exact targeted Relationship query', async () => { diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index bc113cd6b..b7411c9bc 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -24,6 +24,7 @@ Most Symbols and Relationships are cached. Search also reads current source text | Command | Information returned | |---|---| | `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | +| `triage ` | A compact File ranking that matches task terms independently, balances their coverage, and records the matched terms without adding excerpts or inferred answers. | | `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | | `nodes` | A paginated Node inventory from the shaped graph. | | `edges` | A paginated Relationship inventory from the shaped graph. | @@ -41,7 +42,7 @@ Search literal matching is case-insensitive. `*` is a line-local wildcard over s Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. -Search, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. +Search, Triage, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. ## Machine-readable contract diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 6b6739e3f..a1688d8ae 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -14,7 +14,7 @@ test('the generalized CodeGraphy skill teaches the lifecycle and delegates synta const lifecyclePositions = lifecycle.map(term => skill.indexOf(term)); assert.ok(lifecyclePositions.every(position => position >= 0)); assert.deepEqual(lifecyclePositions, [...lifecyclePositions].sort((left, right) => left - right)); - for (const command of ['nodes', 'search', 'query', 'edges', 'dependencies', 'dependents', 'path']) { + for (const command of ['nodes', 'search', 'triage', 'query', 'edges', 'dependencies', 'dependents', 'path']) { assert.match(skill, new RegExp(`\\b${command}\\b`)); } assert.match(skill, /codegraphy --help/); From 22d9cfe98f4051713753a6818f27b1c3b14b63d9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 17:42:13 -0700 Subject: [PATCH 35/55] Revert "feat(cli): add coverage-balanced triage" This reverts commit b887291bff17d1eaf28323dff1b457980a191e24. --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 5 +- README.md | 1 - packages/core/README.md | 4 +- packages/core/src/cli/help/command.ts | 17 -- packages/core/src/cli/parseQuery.ts | 19 +- packages/core/src/graphQuery/execute.ts | 4 - packages/core/src/graphQuery/index.ts | 4 - packages/core/src/graphQuery/model.ts | 29 --- packages/core/src/graphQuery/triage.ts | 176 ------------------ packages/core/src/index.ts | 4 - packages/core/src/workspace/requestQuery.ts | 4 +- packages/core/src/workspace/requestTypes.ts | 1 - packages/core/tests/cli/help/command.test.ts | 4 - packages/core/tests/cli/parseQuery.test.ts | 17 -- packages/core/tests/graphQuery/triage.test.ts | 153 --------------- .../core/tests/workspace/requestQuery.test.ts | 10 - skills/codegraphy/SKILL.md | 3 +- tests/release/codegraphySkill.test.mjs | 2 +- 19 files changed, 9 insertions(+), 450 deletions(-) delete mode 100644 packages/core/src/graphQuery/triage.ts delete mode 100644 packages/core/tests/graphQuery/triage.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index 1cf198222..aa912aaf9 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -3,4 +3,4 @@ "@codegraphy-dev/plugin-api": minor --- -Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add coverage-balanced `codegraphy triage` File rankings for task text, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. diff --git a/CONTEXT.md b/CONTEXT.md index c7b2564ea..a89b30557 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -46,13 +46,12 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Graph View Search** | A temporary text query that narrows the current graph without changing Filter settings. | | **Searched Graph** | The Filtered Graph after Graph View Search. | | **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates. It reports source and cache provenance and does not change settings. | -| **CLI Triage** | A bounded File ranking that independently matches task terms, weights lexical rarity and graph degree, prefers production source, balances source areas, and reports matched terms without excerpts or inferred answers. | | **Target Query** | A bounded overview of one exact File or Symbol Node, including prioritized declarations and incoming/outgoing Relationships. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | | **Orphan Node** | A Node with no remaining Edges after graph narrowing. | | **Show Orphans** | A final Graph View setting that keeps or removes Orphan Nodes. | -Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, CLI Triage, Target Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. +Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, Target Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. ## Selection, Focus, and Collapse @@ -115,7 +114,7 @@ The Graph View can use a whole-view loading state before its first graph payload | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | -The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search`, `triage`, and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric; see ADR 0011. +The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric; see ADR 0011. ## Plugins diff --git a/README.md b/README.md index 5bff1f0cf..1dcc7d012 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,6 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy settings [get|set|unset]` | Safely reads or changes validated workspace settings such as `maxFiles`. | | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | | `codegraphy search ` | Finds exact evidence and uses deterministic all-term File ranking for sparse natural multi-term phrases. | -| `codegraphy triage ` | Fuses independently matched task terms into a compact, source-area-balanced File ranking. | | `codegraphy query ` | Inspects one exact File or Symbol with prioritized declarations and incoming/outgoing Relationships. | | `codegraphy edges` | Lists bounded Edges. | | `codegraphy dependencies ` | Lists outgoing Relationships for a file or exact Symbol Node. | diff --git a/packages/core/README.md b/packages/core/README.md index 45d40f8ed..e77d7f64e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `triage` independently matches task terms, weights rarer evidence and graph-central Files, and balances source areas into one compact File ranking without excerpts or inferred answers. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy settings get maxFiles @@ -19,7 +19,6 @@ codegraphy doctor codegraphy nodes codegraphy search SettingsPanel codegraphy search 'Indexing *workspace*' -codegraphy triage 'offline graph document sizing physics' codegraphy query src/cli/index/command.ts codegraphy edges codegraphy dependencies src/app.ts @@ -50,7 +49,6 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - Workspace status: report fresh, stale, or missing Graph Cache state with inspectable stale reasons. - Graph Cache storage: load, save, clear, and inspect normalized File, Node, Symbol, and Edge rows in the SQLite-backed Graph Cache at `/.codegraphy/graph.sqlite`. - Workspace Search: merge bounded exact evidence with deterministic all-term File fallback candidates for natural multi-term phrases. -- Workspace Triage: fuse independent task terms into a paginated File ranking weighted by lexical rarity, graph degree, production-source preference, and source-area coverage. - Target Query: inspect one exact File or Symbol with prioritized declarations and bounded Relationships. - Graph Query: list scoped Nodes and Edges, then use complete cached types by default for exact targeted relationships and bounded paths unless an invocation explicitly projects Node or Edge Types. diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 93d6bb245..2344ab397 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -30,7 +30,6 @@ const ROOT_HELP = [ '', 'Explore:', ' codegraphy search Find live source text and cached AST Symbols', - ' codegraphy triage Fuse task terms into a compact ranked File set', ' codegraphy query Inspect one exact File or Symbol Node', '', 'Navigate the Relationship Graph:', @@ -157,22 +156,6 @@ const COMMAND_HELP: Record = { 'Example: codegraphy search runIndexCommand', "Example: codegraphy search 'Indexing *workspace*'", ].join('\n'), - triage: [ - 'Usage: codegraphy triage [--limit ] [--offset ] ', - '', - 'Fuse independently matched task terms into one compact, deterministic File ranking.', - 'Each File records its matched query terms; no source excerpts or inferred answers are added.', - '', - 'Arguments:', - ' Task text or several lexical concepts, quoted as one argument', - '', - 'Effects: Read-only. Does not perform Indexing; source text is read live from indexed files.', - 'Output: Bounded JSON Files, matched query terms, pagination, and live/cached freshness.', - 'Options:', - ' --limit Maximum Files to return (default: 8; at most 20)', - ' --offset Zero-based result offset (default: 0)', - "Example: codegraphy triage 'offline graph document sizing physics'", - ].join('\n'), edges: [ 'Usage: codegraphy edges [--limit ] [--offset ]', '', diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index be6ac7ecd..04ab34d9d 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -9,13 +9,10 @@ const QUERY_COMMANDS = new Set([ 'path', 'query', 'search', - 'triage', ]); const DEFAULT_LIMIT = 100; const DEFAULT_SEARCH_LIMIT = 20; -const DEFAULT_TRIAGE_LIMIT = 8; -const MAX_TRIAGE_LIMIT = 20; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; @@ -196,15 +193,6 @@ function buildSearch(input: QueryBuilderInput): CliCommand { return query(input.command, 'search', { pattern: input.operands[0], ...input.page }, input.projection); } -function buildTriage(input: QueryBuilderInput): CliCommand { - const invalid = requireOperands(input.command, input.operands, 1, ''); - if (invalid) return invalid; - if (input.page.limit > MAX_TRIAGE_LIMIT) { - return parseError(input.command, `--limit for triage must be at most ${MAX_TRIAGE_LIMIT}`); - } - return query(input.command, 'triage', { query: input.operands[0], ...input.page }, input.projection); -} - function buildOverview(input: QueryBuilderInput): CliCommand { const invalid = requireOperands(input.command, input.operands, 1, ''); return invalid ?? query(input.command, 'overview', { target: input.operands[0] }, input.projection); @@ -236,7 +224,6 @@ const QUERY_BUILDERS: Record CliCommand> = nodes: buildList, edges: buildList, search: buildSearch, - triage: buildTriage, query: buildOverview, dependencies: input => buildConnection(input, 'from'), dependents: input => buildConnection(input, 'to'), @@ -251,11 +238,7 @@ export function parseQueryCommand(argv: string[]): CliCommand { command, rawArgs, command !== 'path' && command !== 'query', - command === 'search' - ? DEFAULT_SEARCH_LIMIT - : command === 'triage' - ? DEFAULT_TRIAGE_LIMIT - : DEFAULT_LIMIT, + command === 'search' ? DEFAULT_SEARCH_LIMIT : DEFAULT_LIMIT, ); if (parsed.parseError) return parseError(command, parsed.parseError); return builder({ diff --git a/packages/core/src/graphQuery/execute.ts b/packages/core/src/graphQuery/execute.ts index e7b27249b..738c09dde 100644 --- a/packages/core/src/graphQuery/execute.ts +++ b/packages/core/src/graphQuery/execute.ts @@ -8,7 +8,6 @@ import type { GraphQueryResult, GraphQuerySearchConfig, GraphQuerySymbolsConfig, - GraphQueryTriageConfig, } from './model'; import { inspectGraphTarget } from './overview'; import { findGraphPaths } from './paths'; @@ -16,7 +15,6 @@ import { listGraphEdges, listGraphNodes } from './reports'; import { listGraphRelationships } from './relationships'; import { searchGraph } from './search'; import { listGraphSymbols } from './symbols'; -import { triageGraph } from './triage'; import { deriveScopedGraphQueryData } from './visible'; type GraphQueryHandler = ( @@ -31,7 +29,6 @@ type GraphQueryHandlers = { symbols: GraphQueryHandler; paths: GraphQueryHandler; search: GraphQueryHandler; - triage: GraphQueryHandler; overview: GraphQueryHandler; }; @@ -42,7 +39,6 @@ const GRAPH_QUERY_HANDLERS: GraphQueryHandlers = { symbols: (data, args) => listGraphSymbols(data, args), paths: (data, args) => findGraphPaths(deriveScopedGraphQueryData(data.graphData, args), args), search: (data, args) => searchGraph(data, args), - triage: (data, args) => triageGraph(data, args), overview: (data, args) => inspectGraphTarget(data, args), }; diff --git a/packages/core/src/graphQuery/index.ts b/packages/core/src/graphQuery/index.ts index f33339923..de1577a6e 100644 --- a/packages/core/src/graphQuery/index.ts +++ b/packages/core/src/graphQuery/index.ts @@ -28,9 +28,6 @@ export type { GraphQuerySymbolReport, GraphQuerySymbolReportItem, GraphQuerySymbolsConfig, - GraphQueryTriageConfig, - GraphQueryTriageFile, - GraphQueryTriageReport, } from './model'; export type { GraphQueryData } from './data'; export { executeGraphQuery } from './execute'; @@ -40,4 +37,3 @@ export { listGraphEdges, listGraphNodes } from './reports'; export { listGraphRelationships } from './relationships'; export { searchGraph } from './search'; export { listGraphSymbols } from './symbols'; -export { triageGraph } from './triage'; diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index b9bd9b9a3..29594eeab 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -65,10 +65,6 @@ export interface GraphQuerySearchConfig extends GraphQueryConfig { pattern: string; } -export interface GraphQueryTriageConfig extends GraphQueryConfig { - query: string; -} - export interface GraphQueryOverviewConfig { target: string; } @@ -193,28 +189,6 @@ export interface GraphQuerySearchReport { }; } -export interface GraphQueryTriageFile extends GraphQueryNodeReportItem { - matchedTerms: string[]; -} - -export interface GraphQueryTriageReport { - query: string; - terms: string[]; - files: GraphQueryTriageFile[]; - page: GraphQueryPage; - sources: { - text: { - freshness: 'live'; - filesScanned: number; - filesSkipped: number; - }; - graph: { - freshness: 'cached'; - cacheState: 'fresh' | 'stale'; - }; - }; -} - export interface GraphQueryOverviewReport { target: GraphQueryNodeReportItem; declaredSymbols: GraphQuerySymbolReport; @@ -238,7 +212,6 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' - | 'triage' | 'overview'; export type GraphQueryRequest = @@ -248,7 +221,6 @@ export type GraphQueryRequest = | { report: 'symbols'; arguments?: GraphQuerySymbolsConfig } | { report: 'paths'; arguments: GraphQueryPathConfig } | { report: 'search'; arguments: GraphQuerySearchConfig } - | { report: 'triage'; arguments: GraphQueryTriageConfig } | { report: 'overview'; arguments: GraphQueryOverviewConfig }; export type GraphQueryResult = @@ -258,6 +230,5 @@ export type GraphQueryResult = | GraphQuerySymbolReport | GraphQueryPathReport | GraphQuerySearchReport - | GraphQueryTriageReport | GraphQueryOverviewReport | GraphQueryTargetNotFoundReport; diff --git a/packages/core/src/graphQuery/triage.ts b/packages/core/src/graphQuery/triage.ts deleted file mode 100644 index 5f877a8aa..000000000 --- a/packages/core/src/graphQuery/triage.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { IGraphNode } from '../graph/contracts'; -import type { GraphQueryData } from './data'; -import type { - GraphQueryTriageConfig, - GraphQueryTriageFile, - GraphQueryTriageReport, -} from './model'; -import { toNodeReportItem } from './nodeReport'; -import { paginate } from './pagination'; - -const MAX_QUERY_TERMS = 12; -const STOP_WORDS = new Set([ - 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'into', - 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with', -]); - -interface TriageDocument { - node: IGraphNode; - pathTerms: readonly string[]; - textTerms: readonly string[]; -} - -interface RankedTriageFile { - file: GraphQueryTriageFile; - score: number; -} - -function tokenize(value: string): string[] { - const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2'); - return separated.match(/[\p{L}\d]+/gu)?.map(term => term.toLocaleLowerCase()) - .filter(term => term.length > 2 && !STOP_WORDS.has(term)) ?? []; -} - -function createDocuments(data: GraphQueryData): TriageDocument[] { - const nodesByPath = new Map(data.graphData.nodes - .filter(node => node.nodeType === 'file' && !node.symbol) - .map(node => [node.id, node])); - return (data.sourceText?.files ?? []).flatMap(({ filePath, content }) => { - const node = nodesByPath.get(filePath); - return node ? [{ node, pathTerms: tokenize(filePath), textTerms: tokenize(content) }] : []; - }); -} - -function selectQueryTerms(query: string, documents: readonly TriageDocument[]): string[] { - const candidates = [...new Set(tokenize(query))]; - const documentFrequency = new Map(candidates.map(term => [ - term, - documents.filter(document => ( - document.pathTerms.includes(term) || document.textTerms.includes(term) - )).length, - ])); - return candidates - .filter(term => (documentFrequency.get(term) ?? 0) > 0) - .map((term, index) => ({ - term, - index, - frequency: documentFrequency.get(term) ?? 0, - })) - .sort((left, right) => left.frequency - right.frequency || left.index - right.index) - .slice(0, MAX_QUERY_TERMS) - .sort((left, right) => left.index - right.index) - .map(item => item.term); -} - -function countTerms(terms: readonly string[], target: string): number { - let count = 0; - for (const term of terms) if (term === target) count += 1; - return count; -} - -function rankingGroup(filePath: string): string { - const segments = filePath.split('/'); - const sourceIndex = segments.findIndex(segment => segment === 'src' || segment === 'tests'); - const end = sourceIndex >= 0 ? sourceIndex + 2 : Math.min(segments.length, 3); - return segments.slice(0, end).join('/'); -} - -function balanceRankedFiles(ranked: readonly RankedTriageFile[]): RankedTriageFile[] { - const groups = new Map(); - for (const item of ranked) { - const group = rankingGroup(item.file.path); - groups.set(group, [...(groups.get(group) ?? []), item]); - } - const orderedGroups = [...groups.entries()].sort((left, right) => ( - (right[1][0]?.score ?? 0) - (left[1][0]?.score ?? 0) - || left[0].localeCompare(right[0]) - )); - const balanced: RankedTriageFile[] = []; - for (let index = 0; balanced.length < ranked.length; index += 1) { - for (const [, items] of orderedGroups) { - const item = items[index]; - if (item) balanced.push(item); - } - } - return balanced; -} - -function rankDocument( - document: TriageDocument, - queryTerms: readonly string[], - documentCount: number, - frequencies: ReadonlyMap, - degree: number, -): RankedTriageFile | undefined { - const matchedTerms = queryTerms.filter(term => ( - document.pathTerms.includes(term) || document.textTerms.includes(term) - )); - if (matchedTerms.length === 0) return undefined; - const lexicalScore = matchedTerms.reduce((total, term) => { - const inverseFrequency = Math.log((documentCount + 1) / ((frequencies.get(term) ?? 0) + 1)) + 1; - const pathCount = countTerms(document.pathTerms, term); - const textCount = countTerms(document.textTerms, term); - return total + inverseFrequency * (pathCount * 20 + Math.min(textCount, 10)); - }, 0); - const testPenalty = /(?:^|\/)(?:__tests__|tests?)(?:\/|\.)|\.(?:spec|test)\.[^/]+$/iu.test(document.node.id) - ? 12 - : 0; - const score = lexicalScore + Math.log2(degree + 1) * 15 - testPenalty; - return { - file: { ...toNodeReportItem(document.node), matchedTerms }, - score, - }; -} - -export function triageGraph( - data: GraphQueryData, - config: GraphQueryTriageConfig, -): GraphQueryTriageReport { - const documents = createDocuments(data); - const terms = selectQueryTerms(config.query, documents); - const frequencies = new Map(terms.map(term => [ - term, - documents.filter(document => ( - document.pathTerms.includes(term) || document.textTerms.includes(term) - )).length, - ])); - const degrees = new Map(documents.map(document => [document.node.id, 0])); - for (const edge of data.graphData.edges) { - if (edge.kind === 'contains') continue; - if (degrees.has(edge.from)) degrees.set(edge.from, (degrees.get(edge.from) ?? 0) + 1); - if (degrees.has(edge.to)) degrees.set(edge.to, (degrees.get(edge.to) ?? 0) + 1); - } - const ranked = documents - .flatMap(document => rankDocument( - document, - terms, - documents.length, - frequencies, - degrees.get(document.node.id) ?? 0, - ) ?? []) - .sort((left, right) => ( - right.score - left.score - || right.file.matchedTerms.length - left.file.matchedTerms.length - || left.file.path.localeCompare(right.file.path) - )); - const balanced = balanceRankedFiles(ranked); - const page = paginate(balanced.map(item => item.file), config); - - return { - query: config.query, - terms, - files: page.items, - page: page.page, - sources: { - text: { - freshness: 'live', - filesScanned: data.sourceText?.filesScanned ?? 0, - filesSkipped: data.sourceText?.filesSkipped ?? 0, - }, - graph: { - freshness: 'cached', - cacheState: data.cacheState ?? 'fresh', - }, - }, - }; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3fe32a5b..77b7f43c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -464,9 +464,6 @@ export type { GraphQuerySymbolReport, GraphQuerySymbolReportItem, GraphQuerySymbolsConfig, - GraphQueryTriageConfig, - GraphQueryTriageFile, - GraphQueryTriageReport, } from './graphQuery'; export { executeGraphQuery, @@ -477,5 +474,4 @@ export { listGraphRelationships, listGraphSymbols, searchGraph, - triageGraph, } from './graphQuery'; diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index 30b5e5aea..bafc5a03e 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -56,7 +56,7 @@ function hasTargetSelector(arguments_: Record): boolean { function shouldApplyWorkspaceGraphScope(input: WorkspaceGraphQueryInput): boolean { if (input.report === 'relationships') return true; - if (input.report === 'search' || input.report === 'triage' || input.report === 'overview' || input.report === 'paths') return false; + if (input.report === 'search' || input.report === 'overview' || input.report === 'paths') return false; return !hasTargetSelector(input.arguments); } @@ -73,7 +73,7 @@ function executeWorkspaceGraphQuery( source, input.projection, ); - const sourceText = input.report === 'search' || input.report === 'triage' + const sourceText = input.report === 'search' ? readWorkspaceQuerySourceText(workspaceRoot, graphData, source.indexedContentHashes) : undefined; const completeScope = { diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index c2ac0eb93..aeffa1f4c 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -7,7 +7,6 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' - | 'triage' | 'overview'; export interface WorkspacePathInput { diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 92aa4ba4b..66648984a 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -10,7 +10,6 @@ describe('cli/help/command', () => { expect(result.output).not.toContain('codegraphy batch'); expect(result.output).toContain('codegraphy settings'); expect(result.output).toContain('codegraphy search '); - expect(result.output).toContain('codegraphy triage '); expect(result.output).toContain('codegraphy query '); expect(result.output).toContain('codegraphy dependencies '); expect(result.output).toContain('codegraphy path '); @@ -55,9 +54,6 @@ describe('cli/help/command', () => { expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); expect(createHelpResult(['search']).output).toContain('all-term path/source ranking'); - expect(createHelpResult(['triage']).output).toContain('Usage: codegraphy triage'); - expect(createHelpResult(['triage']).output).toContain('matched query terms'); - expect(createHelpResult(['triage']).output).toContain('at most 20'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index d01548322..05a5a42f1 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -21,11 +21,6 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: 'render settings', limit: 20 }, }); - expect(parseQueryCommand(['triage', 'offline graph document physics'])).toEqual({ - name: 'query', - report: 'triage', - arguments: { query: 'offline graph document physics', limit: 8 }, - }); expect(parseQueryCommand(['query', 'src/cli/command.ts'])).toEqual({ name: 'query', invokedCommand: 'query', @@ -68,10 +63,6 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: '-generated', limit: 20 }, }); - expect(parseQueryCommand(['triage', '--limit', '5', '--offset', '2', 'graph physics'])).toMatchObject({ - report: 'triage', - arguments: { query: 'graph physics', limit: 5, offset: 2 }, - }); }); it('parses one-off Filter and Graph Scope overrides for every query', () => { @@ -132,14 +123,6 @@ describe('cli/parseQuery', () => { invokedCommand: 'query', parseError: 'query requires ', }); - expect(parseQueryCommand(['triage'])).toMatchObject({ - invokedCommand: 'triage', - parseError: 'triage requires ', - }); - expect(parseQueryCommand(['triage', '--limit', '21', 'graph physics'])).toMatchObject({ - invokedCommand: 'triage', - parseError: '--limit for triage must be at most 20', - }); expect(parseQueryCommand(['dependencies', 'a.ts', 'b.ts'])).toMatchObject({ parseError: 'Unexpected argument for dependencies: b.ts', }); diff --git a/packages/core/tests/graphQuery/triage.test.ts b/packages/core/tests/graphQuery/triage.test.ts deleted file mode 100644 index f119c07ae..000000000 --- a/packages/core/tests/graphQuery/triage.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { IGraphData } from '../../src/graph/contracts'; -import { triageGraph } from '../../src/graphQuery/triage'; - -const files = [ - { - filePath: 'packages/extension/src/graph/sizing/calculations.ts', - content: 'export function connectionRadius(count: number) { return boundedSquareRoot(count); }', - }, - { - filePath: 'packages/tldraw/src/document/records.ts', - content: 'export function createOfflineGraphDocument() { return createFileNodeDiameter(); }', - }, - { - filePath: 'packages/tldraw/src/script/physics/engine/model.ts', - content: 'export function createPhysicsEngine() { return visibleCollisionAndRepelSize(); }', - }, - { - filePath: 'packages/core/src/graph/contracts.ts', - content: 'export interface GraphNode { nodeType: string; }', - }, -]; - -const graphData: IGraphData = { - nodes: files.map(file => ({ id: file.filePath, label: file.filePath.split('/').at(-1) ?? '', nodeType: 'file' })), - edges: [], -}; - -function triage(query: string, limit = 8, offset = 0) { - return triageGraph({ - graphData, - sourceText: { files, filesScanned: files.length, filesSkipped: 0 }, - cacheState: 'fresh', - }, { query, limit, offset }); -} - -describe('core/graphQuery triage', () => { - it('fuses independent task terms into a compact coverage-balanced File set', () => { - const result = triage( - 'Enhance offline graph document node sizing with bounded connection behavior and visible collision physics', - ); - - expect(result.files.map(file => file.path)).toEqual(expect.arrayContaining([ - 'packages/extension/src/graph/sizing/calculations.ts', - 'packages/tldraw/src/document/records.ts', - 'packages/tldraw/src/script/physics/engine/model.ts', - ])); - expect(result.files.find(file => file.path.endsWith('records.ts'))?.matchedTerms).toEqual( - expect.arrayContaining(['document', 'offline']), - ); - expect(result.files.find(file => file.path.endsWith('model.ts'))?.matchedTerms).toEqual( - expect.arrayContaining(['collision', 'physics', 'visible']), - ); - expect(result.terms.length).toBeLessThanOrEqual(12); - expect(result.sources).toEqual({ - text: { freshness: 'live', filesScanned: 4, filesSkipped: 0 }, - graph: { freshness: 'cached', cacheState: 'fresh' }, - }); - }); - - it('keeps distinct task concepts visible when one subsystem has many lexical matches', () => { - const physicsFiles = Array.from({ length: 12 }, (_, index) => ({ - filePath: `packages/renderer/src/physics/collision/force-${index}.ts`, - content: 'export function physicsCollision() { return visibleNodeSize(); }', - })); - const allFiles = [...files, ...physicsFiles]; - const result = triageGraph({ - graphData: { - nodes: allFiles.map(file => ({ - id: file.filePath, - label: file.filePath.split('/').at(-1) ?? '', - nodeType: 'file', - })), - edges: [], - }, - sourceText: { files: allFiles, filesScanned: allFiles.length, filesSkipped: 0 }, - }, { - query: 'offline graph document connection sizing visible collision physics', - limit: 8, - }); - - expect(result.files.map(file => file.path)).toEqual(expect.arrayContaining([ - 'packages/extension/src/graph/sizing/calculations.ts', - 'packages/tldraw/src/document/records.ts', - 'packages/tldraw/src/script/physics/engine/model.ts', - ])); - }); - - it('uses graph degree to prefer a central owner among equally matching Files', () => { - const sourceFiles = [ - { filePath: 'src/domain/a-leaf.ts', content: 'export const documentNode = 1;' }, - { filePath: 'src/domain/z-owner.ts', content: 'export const documentNode = 1;' }, - ...Array.from({ length: 5 }, (_, index) => ({ - filePath: `src/consumer-${index}.ts`, - content: 'export const consumer = true;', - })), - ]; - const result = triageGraph({ - graphData: { - nodes: sourceFiles.map(file => ({ id: file.filePath, label: file.filePath, nodeType: 'file' })), - edges: sourceFiles.slice(2).map((file, index) => ({ - id: `edge-${index}`, - from: file.filePath, - to: 'src/domain/z-owner.ts', - kind: 'import', - sources: [], - })), - }, - sourceText: { files: sourceFiles, filesScanned: sourceFiles.length, filesSkipped: 0 }, - }, { query: 'document node', limit: 2 }); - - expect(result.files[0]?.path).toBe('src/domain/z-owner.ts'); - }); - - it('prefers production Files when a matching test has equivalent evidence', () => { - const sourceFiles = [ - { filePath: 'src/domain/owner.test.ts', content: 'export const documentNode = 1;' }, - { filePath: 'src/domain/owner.ts', content: 'export const documentNode = 1;' }, - ]; - const result = triageGraph({ - graphData: { - nodes: sourceFiles.map(file => ({ id: file.filePath, label: file.filePath, nodeType: 'file' })), - edges: [], - }, - sourceText: { files: sourceFiles, filesScanned: sourceFiles.length, filesSkipped: 0 }, - }, { query: 'document node', limit: 2 }); - - expect(result.files[0]?.path).toBe('src/domain/owner.ts'); - }); - - it('is deterministic and paginates one fused ranking', () => { - const query = 'offline graph document connection sizing collision physics'; - const complete = triage(query); - const page = triage(query, 2, 1); - - expect(triage(query)).toEqual(complete); - expect(page.files).toEqual(complete.files.slice(1, 3)); - expect(page.page).toEqual({ - offset: 1, - limit: 2, - returned: 2, - total: complete.files.length, - nextOffset: 3, - }); - }); - - it('omits terms that have no eligible File evidence', () => { - const result = triage('zzzzzz offline documents'); - - expect(result.terms).not.toContain('zzzzzz'); - expect(result.files.every(file => file.matchedTerms.length > 0)).toBe(true); - }); -}); diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index 46036004e..d06969f2e 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -26,11 +26,6 @@ describe('workspace/requestQuery', () => { report: 'search', arguments: { pattern: 'Indexing ', limit: 20 }, }); - const triageResult = await requestWorkspaceGraphQuery({ - workspacePath: workspaceRoot, - report: 'triage', - arguments: { query: 'index command progress', limit: 8 }, - }); expect(symbolResult).toMatchObject({ sources: { symbols: { freshness: 'cached', cacheState: 'fresh' } }, @@ -48,11 +43,6 @@ describe('workspace/requestQuery', () => { }], sources: { text: { freshness: 'live', filesScanned: 1, filesSkipped: 0 } }, }); - expect(triageResult).toMatchObject({ - terms: ['index', 'command'], - files: [{ path: 'entry.ts', nodeType: 'file', matchedTerms: ['index', 'command'] }], - sources: { text: { freshness: 'live', filesScanned: 1, filesSkipped: 0 } }, - }); }); it('uses complete graph scope for an exact targeted Relationship query', async () => { diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index b7411c9bc..bc113cd6b 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -24,7 +24,6 @@ Most Symbols and Relationships are cached. Search also reads current source text | Command | Information returned | |---|---| | `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | -| `triage ` | A compact File ranking that matches task terms independently, balances their coverage, and records the matched terms without adding excerpts or inferred answers. | | `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | | `nodes` | A paginated Node inventory from the shaped graph. | | `edges` | A paginated Relationship inventory from the shaped graph. | @@ -42,7 +41,7 @@ Search literal matching is case-insensitive. `*` is a line-local wildcard over s Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. -Search, Triage, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. +Search, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. ## Machine-readable contract diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index a1688d8ae..6b6739e3f 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -14,7 +14,7 @@ test('the generalized CodeGraphy skill teaches the lifecycle and delegates synta const lifecyclePositions = lifecycle.map(term => skill.indexOf(term)); assert.ok(lifecyclePositions.every(position => position >= 0)); assert.deepEqual(lifecyclePositions, [...lifecyclePositions].sort((left, right) => left - right)); - for (const command of ['nodes', 'search', 'triage', 'query', 'edges', 'dependencies', 'dependents', 'path']) { + for (const command of ['nodes', 'search', 'query', 'edges', 'dependencies', 'dependents', 'path']) { assert.match(skill, new RegExp(`\\b${command}\\b`)); } assert.match(skill, /codegraphy --help/); From 9dd0c0a5270d10704b28d914efb1dcd540c1aeb6 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 17:52:08 -0700 Subject: [PATCH 36/55] docs(skill): explain graph evidence choices --- skills/codegraphy/SKILL.md | 64 ++++++++++++-------------- tests/release/codegraphySkill.test.mjs | 3 ++ 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index bc113cd6b..0c6734415 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -1,62 +1,58 @@ --- name: codegraphy -description: Understand and operate the CodeGraphy CLI for workspace source, symbol, and relationship exploration. +description: CodeGraphy CLI reference for indexed source ownership, symbols, dependencies, dependents, paths, freshness, and graph shaping. --- # CodeGraphy -CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and plugin-defined concepts. Directed Edges identify relationships such as imports, reexports, calls, references, inheritance, and containment. Edge direction and type explain why two Nodes are connected. +CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and plugin-defined concepts. Directed typed Edges identify imports, reexports, calls, references, inheritance, containment, and plugin-defined relationships. -The graph is navigation evidence. Static relationships can identify ownership and possible change surfaces, but they do not prove runtime behavior. Source, tests, generated behavior, dynamic dispatch, and unsupported language semantics can add facts that are absent from the graph. +Ordinary source tools expose text and files. CodeGraphy can add exact graph identity, cached structure, and typed direction: what a target uses, what uses it, and whether a route connects two targets. Both are navigation evidence rather than proof of runtime behavior. -## Graph lifecycle and freshness +## Evidence surfaces -`codegraphy index` discovers eligible workspace files, runs built-in and enabled Plugin analysis, and creates or incrementally updates `.codegraphy/graph.sqlite`. The Graph Cache stores complete indexed facts before Graph Scope and path Filters shape query results. +| Information question | Command and result | +|---|---| +| Where does this literal, name, or path occur? | `search ` merges current source locations, cached AST Symbols, and indexed Nodes. | +| What is known about this exact target? | `query ` returns one exact File or Symbol, prioritized declarations, and incoming and outgoing Relationships. | +| What does this target use? | `dependencies ` returns outgoing Relationships. | +| What uses this target? | `dependents ` returns incoming Relationships. | +| Is there a directed route between two targets? | `path ` returns bounded Relationship paths. | +| What graph facts exist in the current shape? | `nodes` and `edges` return paginated inventories. | -`codegraphy filter` changes persisted path exclusions without rebuilding cached analysis. `codegraphy scope` changes which Node Types and Edge Types appear in the shaped graph. `codegraphy settings` exposes workspace discovery, indexing, filter, scope, Plugin, and interface settings; settings mutations report whether another Index is required. `codegraphy plugins` controls installed Plugin registration and activation. +A target can be a workspace-relative File path or an exact Node ID. Symbol IDs and source locations appear in Search and Target Query results. A display label is not necessarily a Node ID. -Query with any read-only graph command after a Graph Cache exists. A `graph_cache_not_found` result means that no indexed snapshot is available. `codegraphy status` reports supported missing, stale, and fresh cache conditions. `codegraphy doctor` checks runtime, settings, cache, and Plugin health and includes recovery information for unhealthy checks. +These surfaces are composable with each other and with ordinary source tools. There is no required command order or call count; the relevant evidence type, repository state, and task determine what information is useful. -Most Symbols and Relationships are cached. Search also reads current source text from eligible indexed File Nodes. A single response can therefore contain `freshness: "live"` source matches and cached Symbol matches whose `cacheState` is `fresh` or `stale`. Indexing is what makes changed AST and Relationship facts current. +## Graph lifecycle and state -## Query surfaces +`codegraphy index` discovers eligible files, runs built-in and enabled Plugin analysis, and creates or incrementally updates `.codegraphy/graph.sqlite`. The Graph Cache stores complete indexed facts before Graph Scope and path Filters shape results. -| Command | Information returned | -|---|---| -| `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | -| `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | -| `nodes` | A paginated Node inventory from the shaped graph. | -| `edges` | A paginated Relationship inventory from the shaped graph. | -| `dependencies ` | Outgoing Relationships from a File path or exact Node ID. | -| `dependents ` | Incoming Relationships to a File path or exact Node ID. | -| `path ` | A bounded Relationship route between two File paths or exact Node IDs. | +`codegraphy filter` changes persisted path exclusions. `codegraphy scope` changes which Node Types and Edge Types appear in the shaped graph. Neither operation deletes complete cached facts. `codegraphy settings` exposes discovery, indexing, Filter, Scope, Plugin, and interface settings; mutations report whether another Index is required. `codegraphy plugins` controls installed Plugin registration and activation. -Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. +Query with any read-only graph command after a Graph Cache exists. `graph_cache_not_found` means no indexed snapshot is available. `codegraphy status` reports supported missing, stale, and fresh cache conditions. `codegraphy doctor` checks runtime, settings, cache, and Plugin health and returns recovery information for unhealthy checks. -`--filter`, `--node-type`, and `--edge-type` are one-off query projections and do not modify `.codegraphy/settings.json`. Persisted Filter and Scope changes affect later commands without deleting the complete cached facts. +Most Symbols and Relationships are cached. Search also reads live source text from eligible indexed File Nodes. One response can therefore contain `freshness: "live"` text matches and cached Symbol matches whose `cacheState` is `fresh` or `stale`. Indexing makes changed AST and Relationship facts current. -## Search and target identity +## Search, shaping, and bounds Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. Search is lexical rather than a semantic-answer engine. -Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. +Source matches include File path, line, column, excerpt, and freshness. Symbol matches include exact identity and source location. Search, exact Target Query, Path, and targeted Relationship selectors use complete cached Node and Edge Types. Broad inventories reflect persisted Graph Scope. Path Filters apply to both. Explicit `--node-type` and `--edge-type` options project that dimension for one invocation; `--filter` adds a one-off path projection. These options do not modify `.codegraphy/settings.json`. -Search, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. +Search, inventories, Target Query sections, Relationships, and Path are bounded. Pagination metadata records offset, limit, returned count, total count, and `nextOffset`. Path includes traversal limits and `complete`; `complete: false` means a bound was reached before exhausting the search space. ## Machine-readable contract -Normal command results are JSON envelopes. Successful data is written to stdout. Operational and invalid-invocation failures use structured error envelopes on stderr and nonzero exit statuses. `--verbose` adds lifecycle diagnostics to stderr without changing the data envelope. - -Common error codes distinguish invalid arguments, missing or stale workspace state, an exact target that is absent, malformed settings, and operational failures. Error `details` and `actions` carry command-specific recovery context when available. +Normal command results are JSON envelopes. Success data is written to stdout. Operational and invalid-invocation failures use structured error envelopes on stderr and nonzero exit statuses. `--verbose` adds lifecycle diagnostics to stderr without changing the data envelope. Error `details` and `actions` carry command-specific recovery context when available. ## Interpretation limits -- Graph coverage depends on eligible files, the indexing file budget, enabled Plugins, supported languages, and analyzer capabilities. -- Persisted Filters and Graph Scope can intentionally hide facts from broad inventories. -- Live text and cached structural facts can have different freshness in the same result. -- Imports, calls, references, and inferred or plugin-defined Edges have different semantics; an Edge Type should be interpreted rather than treated as generic proximity. -- Incoming Relationships suggest consumers or possible impact, while outgoing Relationships suggest dependencies; neither alone defines the complete edit set. -- Hubs, barrels, generated files, tests, and shared utilities can have many legitimate Relationships and can dominate broad graph results. -- Bounded or paginated output is not evidence that omitted results do not exist. +- Coverage depends on eligible files, the indexing budget, enabled Plugins, supported languages, and analyzer capabilities. +- Live text and cached structural facts can have different freshness. +- Static Relationships do not capture every runtime call, generated behavior, dynamic dispatch, or unsupported semantic. +- Edge Type and direction carry meaning; generic proximity is not a substitute for that meaning. +- Hubs, barrels, tests, generated files, and shared utilities can have many legitimate Relationships. +- Bounded output does not imply that omitted facts do not exist. -Current command syntax, options, and examples are available from `codegraphy --help` and `codegraphy --help`. +Current syntax and examples are available from `codegraphy --help` and `codegraphy --help`. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 6b6739e3f..1d4c6a040 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -41,6 +41,9 @@ test('the CodeGraphy skill explains the tool without prescribing an agent workfl assert.doesNotMatch(skill, /(?:at most|no more than|normally make)\s+(?:one|two|three|\d+)\s+CodeGraphy calls?/i); assert.doesNotMatch(skill, /(?:second and final|a third is allowed|commands launched concurrently count)/i); assert.doesNotMatch(skill, /(?:never submit|one domain word|task literals|after one empty or refined search)/i); + assert.match(skill, /ordinary source tools/i); + assert.match(skill, /typed direction/i); + assert.match(skill, /no required command order or call count/i); }); test('the old MCP package and skill are absent from the release source', () => { From 805771286f946ce302a486ba6495c34f2d520f7d Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 17:59:04 -0700 Subject: [PATCH 37/55] Revert "docs(skill): explain graph evidence choices" This reverts commit 9dd0c0a5270d10704b28d914efb1dcd540c1aeb6. --- skills/codegraphy/SKILL.md | 64 ++++++++++++++------------ tests/release/codegraphySkill.test.mjs | 3 -- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 0c6734415..bc113cd6b 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -1,58 +1,62 @@ --- name: codegraphy -description: CodeGraphy CLI reference for indexed source ownership, symbols, dependencies, dependents, paths, freshness, and graph shaping. +description: Understand and operate the CodeGraphy CLI for workspace source, symbol, and relationship exploration. --- # CodeGraphy -CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and plugin-defined concepts. Directed typed Edges identify imports, reexports, calls, references, inheritance, containment, and plugin-defined relationships. +CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and plugin-defined concepts. Directed Edges identify relationships such as imports, reexports, calls, references, inheritance, and containment. Edge direction and type explain why two Nodes are connected. -Ordinary source tools expose text and files. CodeGraphy can add exact graph identity, cached structure, and typed direction: what a target uses, what uses it, and whether a route connects two targets. Both are navigation evidence rather than proof of runtime behavior. +The graph is navigation evidence. Static relationships can identify ownership and possible change surfaces, but they do not prove runtime behavior. Source, tests, generated behavior, dynamic dispatch, and unsupported language semantics can add facts that are absent from the graph. -## Evidence surfaces +## Graph lifecycle and freshness -| Information question | Command and result | -|---|---| -| Where does this literal, name, or path occur? | `search ` merges current source locations, cached AST Symbols, and indexed Nodes. | -| What is known about this exact target? | `query ` returns one exact File or Symbol, prioritized declarations, and incoming and outgoing Relationships. | -| What does this target use? | `dependencies ` returns outgoing Relationships. | -| What uses this target? | `dependents ` returns incoming Relationships. | -| Is there a directed route between two targets? | `path ` returns bounded Relationship paths. | -| What graph facts exist in the current shape? | `nodes` and `edges` return paginated inventories. | +`codegraphy index` discovers eligible workspace files, runs built-in and enabled Plugin analysis, and creates or incrementally updates `.codegraphy/graph.sqlite`. The Graph Cache stores complete indexed facts before Graph Scope and path Filters shape query results. -A target can be a workspace-relative File path or an exact Node ID. Symbol IDs and source locations appear in Search and Target Query results. A display label is not necessarily a Node ID. +`codegraphy filter` changes persisted path exclusions without rebuilding cached analysis. `codegraphy scope` changes which Node Types and Edge Types appear in the shaped graph. `codegraphy settings` exposes workspace discovery, indexing, filter, scope, Plugin, and interface settings; settings mutations report whether another Index is required. `codegraphy plugins` controls installed Plugin registration and activation. -These surfaces are composable with each other and with ordinary source tools. There is no required command order or call count; the relevant evidence type, repository state, and task determine what information is useful. +Query with any read-only graph command after a Graph Cache exists. A `graph_cache_not_found` result means that no indexed snapshot is available. `codegraphy status` reports supported missing, stale, and fresh cache conditions. `codegraphy doctor` checks runtime, settings, cache, and Plugin health and includes recovery information for unhealthy checks. -## Graph lifecycle and state +Most Symbols and Relationships are cached. Search also reads current source text from eligible indexed File Nodes. A single response can therefore contain `freshness: "live"` source matches and cached Symbol matches whose `cacheState` is `fresh` or `stale`. Indexing is what makes changed AST and Relationship facts current. -`codegraphy index` discovers eligible files, runs built-in and enabled Plugin analysis, and creates or incrementally updates `.codegraphy/graph.sqlite`. The Graph Cache stores complete indexed facts before Graph Scope and path Filters shape results. +## Query surfaces -`codegraphy filter` changes persisted path exclusions. `codegraphy scope` changes which Node Types and Edge Types appear in the shaped graph. Neither operation deletes complete cached facts. `codegraphy settings` exposes discovery, indexing, Filter, Scope, Plugin, and interface settings; mutations report whether another Index is required. `codegraphy plugins` controls installed Plugin registration and activation. +| Command | Information returned | +|---|---| +| `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | +| `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | +| `nodes` | A paginated Node inventory from the shaped graph. | +| `edges` | A paginated Relationship inventory from the shaped graph. | +| `dependencies ` | Outgoing Relationships from a File path or exact Node ID. | +| `dependents ` | Incoming Relationships to a File path or exact Node ID. | +| `path ` | A bounded Relationship route between two File paths or exact Node IDs. | -Query with any read-only graph command after a Graph Cache exists. `graph_cache_not_found` means no indexed snapshot is available. `codegraphy status` reports supported missing, stale, and fresh cache conditions. `codegraphy doctor` checks runtime, settings, cache, and Plugin health and returns recovery information for unhealthy checks. +Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. -Most Symbols and Relationships are cached. Search also reads live source text from eligible indexed File Nodes. One response can therefore contain `freshness: "live"` text matches and cached Symbol matches whose `cacheState` is `fresh` or `stale`. Indexing makes changed AST and Relationship facts current. +`--filter`, `--node-type`, and `--edge-type` are one-off query projections and do not modify `.codegraphy/settings.json`. Persisted Filter and Scope changes affect later commands without deleting the complete cached facts. -## Search, shaping, and bounds +## Search and target identity Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. Search is lexical rather than a semantic-answer engine. -Source matches include File path, line, column, excerpt, and freshness. Symbol matches include exact identity and source location. Search, exact Target Query, Path, and targeted Relationship selectors use complete cached Node and Edge Types. Broad inventories reflect persisted Graph Scope. Path Filters apply to both. Explicit `--node-type` and `--edge-type` options project that dimension for one invocation; `--filter` adds a one-off path projection. These options do not modify `.codegraphy/settings.json`. +Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. -Search, inventories, Target Query sections, Relationships, and Path are bounded. Pagination metadata records offset, limit, returned count, total count, and `nextOffset`. Path includes traversal limits and `complete`; `complete: false` means a bound was reached before exhausting the search space. +Search, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. ## Machine-readable contract -Normal command results are JSON envelopes. Success data is written to stdout. Operational and invalid-invocation failures use structured error envelopes on stderr and nonzero exit statuses. `--verbose` adds lifecycle diagnostics to stderr without changing the data envelope. Error `details` and `actions` carry command-specific recovery context when available. +Normal command results are JSON envelopes. Successful data is written to stdout. Operational and invalid-invocation failures use structured error envelopes on stderr and nonzero exit statuses. `--verbose` adds lifecycle diagnostics to stderr without changing the data envelope. + +Common error codes distinguish invalid arguments, missing or stale workspace state, an exact target that is absent, malformed settings, and operational failures. Error `details` and `actions` carry command-specific recovery context when available. ## Interpretation limits -- Coverage depends on eligible files, the indexing budget, enabled Plugins, supported languages, and analyzer capabilities. -- Live text and cached structural facts can have different freshness. -- Static Relationships do not capture every runtime call, generated behavior, dynamic dispatch, or unsupported semantic. -- Edge Type and direction carry meaning; generic proximity is not a substitute for that meaning. -- Hubs, barrels, tests, generated files, and shared utilities can have many legitimate Relationships. -- Bounded output does not imply that omitted facts do not exist. +- Graph coverage depends on eligible files, the indexing file budget, enabled Plugins, supported languages, and analyzer capabilities. +- Persisted Filters and Graph Scope can intentionally hide facts from broad inventories. +- Live text and cached structural facts can have different freshness in the same result. +- Imports, calls, references, and inferred or plugin-defined Edges have different semantics; an Edge Type should be interpreted rather than treated as generic proximity. +- Incoming Relationships suggest consumers or possible impact, while outgoing Relationships suggest dependencies; neither alone defines the complete edit set. +- Hubs, barrels, generated files, tests, and shared utilities can have many legitimate Relationships and can dominate broad graph results. +- Bounded or paginated output is not evidence that omitted results do not exist. -Current syntax and examples are available from `codegraphy --help` and `codegraphy --help`. +Current command syntax, options, and examples are available from `codegraphy --help` and `codegraphy --help`. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 1d4c6a040..6b6739e3f 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -41,9 +41,6 @@ test('the CodeGraphy skill explains the tool without prescribing an agent workfl assert.doesNotMatch(skill, /(?:at most|no more than|normally make)\s+(?:one|two|three|\d+)\s+CodeGraphy calls?/i); assert.doesNotMatch(skill, /(?:second and final|a third is allowed|commands launched concurrently count)/i); assert.doesNotMatch(skill, /(?:never submit|one domain word|task literals|after one empty or refined search)/i); - assert.match(skill, /ordinary source tools/i); - assert.match(skill, /typed direction/i); - assert.match(skill, /no required command order or call count/i); }); test('the old MCP package and skill are absent from the release source', () => { From 30bbc75437222bea9eec723b2c3b6037ac65e26f Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 18:09:36 -0700 Subject: [PATCH 38/55] docs(cli): record structural agent benchmarks --- CONTEXT.md | 2 +- ...quire-structural-work-and-tool-adoption.md | 88 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md diff --git a/CONTEXT.md b/CONTEXT.md index a89b30557..769804be1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -114,7 +114,7 @@ The Graph View can use a whole-view loading state before its first graph payload | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | -The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric; see ADR 0011. +The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh structural feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric. A CLI candidate must also be selected by treatment agents before aggregate differences can be attributed to it; see ADR 0011 and ADR 0012. ## Plugins diff --git a/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md b/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md new file mode 100644 index 000000000..46e332ad2 --- /dev/null +++ b/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md @@ -0,0 +1,88 @@ +# Agent benchmarks require structural work and tool adoption + +**Status:** Accepted + +## Context + +ADR 0011 established fresh three-versus-three implementation benchmarks and a generalized, non-prescriptive Agent Skill. The first candidate task added `path --max-depth`. All six agents implemented it correctly, but only one of three CodeGraphy agents made one CLI help call. The task named the exact command and reduced to local parser/help work, so its token difference could not measure graph navigation. + +A connection-aware offline graph-document feature was broader, but its first prompt named the owning tldraw package. After removing that ownership leak, every agent still localized the task lexically. In the frozen lexical round, CodeGraphy agents made zero CLI calls and regressed against raw agents despite equal 3/3 correctness: + +| Mean | Raw | CodeGraphy + skill | Delta | +|---|---:|---:|---:| +| Model tokens | 369,924 | 404,126 | 9.2% more | +| Tool calls | 22.0 | 28.7 | 30.3% more | +| Tool output | 123,569 B | 140,578 B | 13.8% more | +| Elapsed | 126.0 s | 135.1 s | 7.2% slower | +| CodeGraphy calls | 0 | 0 | no adoption | + +The task was therefore rejected as a stable graph benchmark. It primarily measured the overhead and model variance of loading a skill around work that ordinary lexical search already solved directly. + +The replacement structural task starts from the parent of historical commit `e83057517`. Agents add cleanup ownership for headless Plugin runtimes across registry removal, one-shot Indexing success/failure, persistent engine rebuilds, and idempotent disposal. The hidden grader replaces two test modules with the original feature tests and runs Core typecheck. The untouched parent fails four behavioral assertions. Repository Markdown and source history are absent; dependencies come from the historical frozen lockfile. + +The neutral generalized-skill baseline produced equal 2/3 correctness: + +| Mean | Raw | CodeGraphy + neutral skill | Delta | +|---|---:|---:|---:| +| Tokens, all runs | 772,809 | 473,583 | 38.7% fewer | +| Tokens, correct runs | 820,903 | 475,038 | 42.1% fewer | +| Tool calls | 30.7 | 29.3 | 4.3% fewer | +| Tool output | 228,083 B | 242,281 B | 6.2% more | +| Elapsed | 171.7 s | 137.2 s | 20.1% faster | +| Correctness | 2/3 | 2/3 | equal | + +Only the failed treatment run invoked CodeGraphy, however. The successful token difference cannot be attributed to graph queries and remains a high-variance product-level signal rather than CLI evidence. + +## Tested candidates + +### Coverage-balanced Triage + +A `triage ` prototype independently matched task terms, weighted lexical rarity and File graph degree, preferred production source, and round-robin balanced source areas. It returned at most eight Files by default with matched terms and no excerpts, inferred answers, or generic neighborhoods. + +On the lexical feature task, all three agents read the skill but none selected Triage. Correctness remained 3/3 while treatment regressed 21.9% in tokens, 41.0% in calls, 13.7% in output, and 6.1% in elapsed time. The command had no adoption and was reverted. + +### Evidence-first generalized skill + +A reordered skill described questions answered by live text, exact Target Query, dependencies, dependents, Path, and inventories before lifecycle details. It explicitly said there was no required command order or call count. On the structural task, no agent invoked CodeGraphy. Treatment correctness was 3/3 versus raw 2/3, but mean tokens per correct run increased from 242,665 to 806,164, calls increased 56.1%, and elapsed increased 55.6%. The wording was reverted. + +### Combined Triage and evidence-first skill + +A detached candidate combined both rejected ideas to test their interaction. Every treatment agent selected Triage exactly once, proving discoverability and adoption. It still failed the correctness and token gate: + +| Mean | Raw | Triage + evidence skill | Delta | +|---|---:|---:|---:| +| Tokens, all runs | 619,031 | 559,269 | 9.7% fewer | +| Tokens, correct runs | 619,031 | 696,005 | 12.4% more | +| Tool calls | 26.3 | 34.7 | 31.6% more | +| Tool output | 259,628 B | 203,540 B | 21.6% fewer | +| Elapsed | 137.0 s | 153.6 s | 12.1% slower | +| Correctness | 3/3 | 2/3 | one fewer | +| CodeGraphy calls | 0 | 1.0 | adopted | + +Triage reduced observation bytes, but successful trajectories consumed more model tokens and one implementation was incomplete. The detached worktree and prototype were removed. + +## Decision + +Retain the neutral generalized Agent Skill from ADR 0011. It explains the graph model, lifecycle, command semantics, output, freshness, shaping, and limitations without prescribing a workflow. Do not retain Triage or evidence-first comparative framing. + +Use the headless Plugin-runtime disposal task as the current structural benchmark. Report both all-run and correct-run token statistics. A CLI candidate must be invoked by treatment agents before a result can be attributed to that candidate. Non-adoption is a failed interface experiment even when aggregate model variance favors the treatment. + +Correctness remains a gate. Lower output bytes, elapsed time, or all-run mean tokens do not justify a candidate whose correct-run tokens regress or whose correctness drops. + +Three repetitions remain a fast screening instrument rather than confirmation. Raw means varied materially between rounds, so candidates that pass screening require broader paired tasks before a universal product claim. + +## Consequences + +- Exact, local parser features are too shallow for the ongoing graph benchmark. +- Lexical cross-package tasks are also insufficient when ordinary search exposes ownership directly. +- New commands must earn both discoverability and successful-trajectory value. +- Skill wording is itself an intervention and can increase exhaustive ordinary exploration even without CLI use. +- Tool output reduction is diagnostic; cumulative model tokens per correct implementation remain primary. +- Watcher, impact, neighbor, conflict, inference, fuzzy, hub, and budgeted-map experiments remain candidates, but each must use the same structural task and adoption gate. + +## References + +- ADR 0009, rejected connected neighborhoods +- ADR 0010, initial token-first clean-install experiments +- ADR 0011, generalized skill and feature-change benchmark protocol +- Historical feature commit `e83057517`, Core Plugin runtime disposal From 59e06cf6b938f756c4298fd688b6f42c08f16ba0 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 18:22:02 -0700 Subject: [PATCH 39/55] feat(cli): report bounded impact radius --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 3 +- README.md | 3 +- packages/core/README.md | 4 +- packages/core/src/cli/help/command.ts | 19 +++ packages/core/src/cli/parseQuery.ts | 39 ++++- packages/core/src/graphQuery/execute.ts | 4 + packages/core/src/graphQuery/impact.ts | 141 ++++++++++++++++++ packages/core/src/graphQuery/index.ts | 4 + packages/core/src/graphQuery/model.ts | 24 +++ packages/core/src/index.ts | 4 + packages/core/src/workspace/requestTypes.ts | 1 + packages/core/tests/cli/help/command.test.ts | 4 + packages/core/tests/cli/parseQuery.test.ts | 26 ++++ packages/core/tests/graphQuery/impact.test.ts | 94 ++++++++++++ .../core/tests/workspace/requestQuery.test.ts | 11 ++ skills/codegraphy/SKILL.md | 3 +- tests/release/codegraphySkill.test.mjs | 2 +- 18 files changed, 377 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/graphQuery/impact.ts create mode 100644 packages/core/tests/graphQuery/impact.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index aa912aaf9..3a355ece6 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -3,4 +3,4 @@ "@codegraphy-dev/plugin-api": minor --- -Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add bounded incoming `codegraphy impact` File radii, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. diff --git a/CONTEXT.md b/CONTEXT.md index 769804be1..95b476c83 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -47,11 +47,12 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Searched Graph** | The Filtered Graph after Graph View Search. | | **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates. It reports source and cache provenance and does not change settings. | | **Target Query** | A bounded overview of one exact File or Symbol Node, including prioritized declarations and incoming/outgoing Relationships. | +| **Impact Query** | A bounded incoming traversal from one exact File or Symbol, projected to Files with shortest hop distance and typed Relationship reasons. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | | **Orphan Node** | A Node with no remaining Edges after graph narrowing. | | **Show Orphans** | A final Graph View setting that keeps or removes Orphan Nodes. | -Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, Target Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. +Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, Target Query, Impact Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. ## Selection, Focus, and Collapse diff --git a/README.md b/README.md index 1dcc7d012..cffb73ffb 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | | `codegraphy search ` | Finds exact evidence and uses deterministic all-term File ranking for sparse natural multi-term phrases. | | `codegraphy query ` | Inspects one exact File or Symbol with prioritized declarations and incoming/outgoing Relationships. | +| `codegraphy impact ` | Lists a bounded incoming File radius with shortest hop distances and typed Relationship reasons. | | `codegraphy edges` | Lists bounded Edges. | | `codegraphy dependencies ` | Lists outgoing Relationships for a file or exact Symbol Node. | | `codegraphy dependents ` | Lists incoming Relationships for a file or exact Symbol Node. | @@ -141,7 +142,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy filter` | Reads or changes persisted filter patterns. | | `codegraphy plugins` | Registers, links, lists, enables, or disables plugins. | -Target Query, Path, and exact targeted Relationship selectors use complete cached Node and Edge Types by default rather than saved Graph View Scope; explicit `--node-type` or `--edge-type` arguments still constrain that dimension. JavaScript-family `reexport` Relationships let calls through barrels terminate at implementation Symbols. Run `codegraphy --help` for exact arguments. Query, settings, Indexing, and diagnostic commands keep machine-readable JSON on stdout. Verbose diagnostics go to stderr. +Target Query, Impact, Path, and exact targeted Relationship selectors use complete cached Node and Edge Types by default rather than saved Graph View Scope; explicit `--node-type` or `--edge-type` arguments still constrain that dimension. JavaScript-family `reexport` Relationships let calls through barrels terminate at implementation Symbols. Run `codegraphy --help` for exact arguments. Query, settings, Indexing, and diagnostic commands keep machine-readable JSON on stdout. Verbose diagnostics go to stderr. ### Agent Skill diff --git a/packages/core/README.md b/packages/core/README.md index e77d7f64e..6c66842c8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. `impact` traverses incoming Relationships from one exact target and returns a paginated File radius with shortest distance and typed reasons. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy settings get maxFiles @@ -20,6 +20,7 @@ codegraphy nodes codegraphy search SettingsPanel codegraphy search 'Indexing *workspace*' codegraphy query src/cli/index/command.ts +codegraphy impact src/plugins/registry.ts --depth 2 codegraphy edges codegraphy dependencies src/app.ts codegraphy dependents src/config.ts @@ -50,6 +51,7 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - Graph Cache storage: load, save, clear, and inspect normalized File, Node, Symbol, and Edge rows in the SQLite-backed Graph Cache at `/.codegraphy/graph.sqlite`. - Workspace Search: merge bounded exact evidence with deterministic all-term File fallback candidates for natural multi-term phrases. - Target Query: inspect one exact File or Symbol with prioritized declarations and bounded Relationships. +- Impact Query: traverse bounded incoming Relationships from one exact target and report impacted Files with shortest distance and Edge Types. - Graph Query: list scoped Nodes and Edges, then use complete cached types by default for exact targeted relationships and bounded paths unless an invocation explicitly projects Node or Edge Types. The core package exposes `indexCodeGraphyWorkspace` for explicit path-based Indexing. VS Code and CLI adapters call this package instead of owning independent indexing behavior. diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 2344ab397..401b592fd 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -31,6 +31,7 @@ const ROOT_HELP = [ 'Explore:', ' codegraphy search Find live source text and cached AST Symbols', ' codegraphy query Inspect one exact File or Symbol Node', + ' codegraphy impact List a bounded incoming File impact radius', '', 'Navigate the Relationship Graph:', ' codegraphy nodes List Nodes in the shaped graph', @@ -124,6 +125,24 @@ const COMMAND_HELP: Record = { 'Output: JSON target overview with fixed per-section bounds and page totals.', 'Example: codegraphy query packages/core/src/cli/command.ts', ].join('\n'), + impact: [ + 'Usage: codegraphy impact [--depth ] [--limit ] [--offset ] ', + '', + 'List a bounded incoming File radius around one exact File path or Symbol Node ID.', + 'Each result includes shortest hop distance and the Relationship Types that reached it.', + '', + 'Arguments:', + ' Workspace-relative File path or exact Symbol Node ID', + '', + 'Effects: Read-only. Requires an existing Graph Cache and never performs Indexing.', + 'Output: JSON target, impacted Files, pagination, traversal limits, and completeness.', + 'Options:', + ' --depth Incoming traversal depth from 1 through 4 (default: 2)', + ' --limit Maximum impacted Files to return (default: 10)', + ' --offset Zero-based result offset (default: 0)', + ...QUERY_PROJECTION_OPTIONS, + 'Example: codegraphy impact packages/core/src/plugins/registry.ts --depth 2', + ].join('\n'), nodes: [ 'Usage: codegraphy nodes [--limit ] [--offset ]', '', diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index 04ab34d9d..1f740c1a8 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -5,6 +5,7 @@ const QUERY_COMMANDS = new Set([ 'dependencies', 'dependents', 'edges', + 'impact', 'nodes', 'path', 'query', @@ -13,11 +14,15 @@ const QUERY_COMMANDS = new Set([ const DEFAULT_LIMIT = 100; const DEFAULT_SEARCH_LIMIT = 20; +const DEFAULT_IMPACT_LIMIT = 10; +const DEFAULT_IMPACT_DEPTH = 2; +const MAX_IMPACT_DEPTH = 4; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; interface ParsedQueryArguments { operands: string[]; + depth?: number; limit: number; offset?: number; parseError?: string; @@ -29,10 +34,11 @@ interface QueryBuilderInput { operands: string[]; page: { limit: number; offset?: number }; projection?: WorkspaceGraphQueryProjection; + depth?: number; } type ParsedOption = - | { type: 'limit' | 'offset'; value: number } + | { type: 'depth' | 'limit' | 'offset'; value: number } | { type: 'projection'; key: keyof WorkspaceGraphQueryProjection; values: string[] } | { type: 'error'; message: string }; @@ -92,14 +98,21 @@ function parseOption( if (argument === '--limit' || argument === '--offset') { return parsePaginationOption(command, argument, value, allowPagination); } + if (argument === '--depth' && command === 'impact') { + const depth = parseInteger(value, 1); + return depth !== undefined && depth <= MAX_IMPACT_DEPTH + ? { type: 'depth', value: depth } + : { type: 'error', message: `--depth requires an integer from 1 through ${MAX_IMPACT_DEPTH}` }; + } const projectionKey = PROJECTION_OPTION_KEYS[argument]; return projectionKey ? parseProjectionOption(argument, value, projectionKey) : undefined; } function applyOption( parsed: ParsedOption, - state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, + state: { depth?: number; limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, ): void { + if (parsed.type === 'depth') state.depth = parsed.value; if (parsed.type === 'limit') state.limit = parsed.value; if (parsed.type === 'offset') state.offset = parsed.value; if (parsed.type === 'projection') { @@ -109,10 +122,11 @@ function applyOption( function completeParsedArguments( operands: string[], - state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, + state: { depth?: number; limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, ): ParsedQueryArguments { return { operands, + ...(state.depth !== undefined ? { depth: state.depth } : {}), limit: state.limit, ...(state.offset !== undefined ? { offset: state.offset } : {}), ...(Object.keys(state.projection).length > 0 ? { projection: state.projection } : {}), @@ -126,7 +140,7 @@ function parseArguments( defaultLimit = DEFAULT_LIMIT, ): ParsedQueryArguments { const operands: string[] = []; - const state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection } = { + const state: { depth?: number; limit: number; offset?: number; projection: WorkspaceGraphQueryProjection } = { limit: defaultLimit, projection: {}, }; @@ -193,6 +207,15 @@ function buildSearch(input: QueryBuilderInput): CliCommand { return query(input.command, 'search', { pattern: input.operands[0], ...input.page }, input.projection); } +function buildImpact(input: QueryBuilderInput): CliCommand { + const invalid = requireOperands(input.command, input.operands, 1, ''); + return invalid ?? query(input.command, 'impact', { + target: input.operands[0], + maxDepth: input.depth ?? DEFAULT_IMPACT_DEPTH, + ...input.page, + }, input.projection); +} + function buildOverview(input: QueryBuilderInput): CliCommand { const invalid = requireOperands(input.command, input.operands, 1, ''); return invalid ?? query(input.command, 'overview', { target: input.operands[0] }, input.projection); @@ -224,6 +247,7 @@ const QUERY_BUILDERS: Record CliCommand> = nodes: buildList, edges: buildList, search: buildSearch, + impact: buildImpact, query: buildOverview, dependencies: input => buildConnection(input, 'from'), dependents: input => buildConnection(input, 'to'), @@ -238,7 +262,11 @@ export function parseQueryCommand(argv: string[]): CliCommand { command, rawArgs, command !== 'path' && command !== 'query', - command === 'search' ? DEFAULT_SEARCH_LIMIT : DEFAULT_LIMIT, + command === 'search' + ? DEFAULT_SEARCH_LIMIT + : command === 'impact' + ? DEFAULT_IMPACT_LIMIT + : DEFAULT_LIMIT, ); if (parsed.parseError) return parseError(command, parsed.parseError); return builder({ @@ -249,5 +277,6 @@ export function parseQueryCommand(argv: string[]): CliCommand { ...(parsed.offset !== undefined ? { offset: parsed.offset } : {}), }, ...(parsed.projection ? { projection: parsed.projection } : {}), + ...(parsed.depth !== undefined ? { depth: parsed.depth } : {}), }); } diff --git a/packages/core/src/graphQuery/execute.ts b/packages/core/src/graphQuery/execute.ts index 738c09dde..fdd36cb57 100644 --- a/packages/core/src/graphQuery/execute.ts +++ b/packages/core/src/graphQuery/execute.ts @@ -2,6 +2,7 @@ import type { GraphQueryData } from './data'; import type { GraphQueryConfig, GraphQueryConnectionConfig, + GraphQueryImpactConfig, GraphQueryOverviewConfig, GraphQueryPathConfig, GraphQueryRequest, @@ -9,6 +10,7 @@ import type { GraphQuerySearchConfig, GraphQuerySymbolsConfig, } from './model'; +import { impactGraphTarget } from './impact'; import { inspectGraphTarget } from './overview'; import { findGraphPaths } from './paths'; import { listGraphEdges, listGraphNodes } from './reports'; @@ -29,6 +31,7 @@ type GraphQueryHandlers = { symbols: GraphQueryHandler; paths: GraphQueryHandler; search: GraphQueryHandler; + impact: GraphQueryHandler; overview: GraphQueryHandler; }; @@ -39,6 +42,7 @@ const GRAPH_QUERY_HANDLERS: GraphQueryHandlers = { symbols: (data, args) => listGraphSymbols(data, args), paths: (data, args) => findGraphPaths(deriveScopedGraphQueryData(data.graphData, args), args), search: (data, args) => searchGraph(data, args), + impact: (data, args) => impactGraphTarget(data, args), overview: (data, args) => inspectGraphTarget(data, args), }; diff --git a/packages/core/src/graphQuery/impact.ts b/packages/core/src/graphQuery/impact.ts new file mode 100644 index 000000000..05ca2a1c5 --- /dev/null +++ b/packages/core/src/graphQuery/impact.ts @@ -0,0 +1,141 @@ +import type { IGraphNode } from '../graph/contracts'; +import type { GraphQueryData } from './data'; +import type { + GraphQueryImpactConfig, + GraphQueryImpactItem, + GraphQueryImpactReport, + GraphQueryTargetNotFoundReport, +} from './model'; +import { toNodeReportItem } from './nodeReport'; +import { paginate } from './pagination'; + +const MAX_VISITED_NODES = 2_000; + +interface ImpactAccumulator { + distance: number; + edgeTypes: Set; + node: IGraphNode; +} + +function targetNotFound(target: string): GraphQueryTargetNotFoundReport { + return { + error: 'query_target_not_found', + message: `No indexed Node or Symbol has the exact id: ${target}`, + }; +} + +function fileNodeFor(node: IGraphNode, nodesById: ReadonlyMap): IGraphNode | undefined { + if (node.nodeType === 'file') return node; + const filePath = node.symbol?.filePath; + return filePath ? nodesById.get(filePath) : undefined; +} + +function impactReasonRank(edgeTypes: ReadonlySet): number { + const ranks: Partial> = { + call: 0, + event: 0, + inherit: 0, + reference: 0, + import: 1, + include: 1, + nests: 1, + reexport: 1, + using: 1, + 'type-import': 2, + }; + return Math.min(...[...edgeTypes].map(edgeType => ranks[edgeType] ?? 1)); +} + +function startingNodeIds(target: IGraphNode, nodes: readonly IGraphNode[]): string[] { + if (target.nodeType !== 'file') return [target.id]; + return [ + target.id, + ...nodes.flatMap(node => node.symbol?.filePath === target.id ? [node.id] : []), + ]; +} + +export function impactGraphTarget( + data: GraphQueryData, + config: GraphQueryImpactConfig, +): GraphQueryImpactReport | GraphQueryTargetNotFoundReport { + const nodesById = new Map(data.graphData.nodes.map(node => [node.id, node])); + const target = nodesById.get(config.target); + if (!target) return targetNotFound(config.target); + const targetFilePath = fileNodeFor(target, nodesById)?.id; + const incoming = new Map(); + for (const edge of data.graphData.edges) { + if (edge.kind === 'contains') continue; + incoming.set(edge.to, [...(incoming.get(edge.to) ?? []), edge]); + } + + const seeds = startingNodeIds(target, data.graphData.nodes); + const visited = new Set(seeds); + let frontier = seeds; + let visitedNodes = 0; + let complete = true; + const impacted = new Map(); + + for (let distance = 1; distance <= config.maxDepth && frontier.length > 0; distance += 1) { + const next = new Set(); + for (const current of frontier) { + for (const edge of incoming.get(current) ?? []) { + const source = nodesById.get(edge.from); + if (!source) continue; + const sourceFile = fileNodeFor(source, nodesById); + if (sourceFile && sourceFile.id !== targetFilePath) { + const existing = impacted.get(sourceFile.id); + if (!existing || distance < existing.distance) { + impacted.set(sourceFile.id, { + distance, + edgeTypes: new Set([edge.kind]), + node: sourceFile, + }); + } else if (distance === existing.distance) { + existing.edgeTypes.add(edge.kind); + } + } + const nextIds = sourceFile + ? [source.id, ...startingNodeIds(sourceFile, data.graphData.nodes)] + : [source.id]; + for (const nextId of new Set(nextIds)) { + if (visited.has(nextId)) continue; + visited.add(nextId); + next.add(nextId); + visitedNodes += 1; + if (visitedNodes >= MAX_VISITED_NODES) { + complete = false; + break; + } + } + if (!complete) break; + } + if (!complete) break; + } + frontier = [...next].sort(); + if (!complete) break; + } + + const ranked = [...impacted.values()] + .sort((left, right) => ( + impactReasonRank(left.edgeTypes) - impactReasonRank(right.edgeTypes) + || left.distance - right.distance + || left.node.id.localeCompare(right.node.id) + )) + .map(item => ({ + ...toNodeReportItem(item.node), + distance: item.distance, + edgeTypes: [...item.edgeTypes].sort(), + })); + const page = paginate(ranked, config); + + return { + target: toNodeReportItem(target), + impacted: page.items, + page: page.page, + limits: { + maxDepth: config.maxDepth, + visitedNodes, + complete, + }, + }; +} diff --git a/packages/core/src/graphQuery/index.ts b/packages/core/src/graphQuery/index.ts index de1577a6e..e5ec1b571 100644 --- a/packages/core/src/graphQuery/index.ts +++ b/packages/core/src/graphQuery/index.ts @@ -5,6 +5,9 @@ export type { GraphQueryEdgeReportItem, GraphQueryFilter, GraphQueryFilterOperator, + GraphQueryImpactConfig, + GraphQueryImpactItem, + GraphQueryImpactReport, GraphQueryNodeReport, GraphQueryNodeReportItem, GraphQueryOverviewConfig, @@ -31,6 +34,7 @@ export type { } from './model'; export type { GraphQueryData } from './data'; export { executeGraphQuery } from './execute'; +export { impactGraphTarget } from './impact'; export { inspectGraphTarget } from './overview'; export { findGraphPaths } from './paths'; export { listGraphEdges, listGraphNodes } from './reports'; diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index 29594eeab..48a4f1e94 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -65,6 +65,11 @@ export interface GraphQuerySearchConfig extends GraphQueryConfig { pattern: string; } +export interface GraphQueryImpactConfig extends GraphQueryConfig { + target: string; + maxDepth: number; +} + export interface GraphQueryOverviewConfig { target: string; } @@ -189,6 +194,22 @@ export interface GraphQuerySearchReport { }; } +export interface GraphQueryImpactItem extends GraphQueryNodeReportItem { + distance: number; + edgeTypes: GraphEdgeKind[]; +} + +export interface GraphQueryImpactReport { + target: GraphQueryNodeReportItem; + impacted: GraphQueryImpactItem[]; + page: GraphQueryPage; + limits: { + maxDepth: number; + visitedNodes: number; + complete: boolean; + }; +} + export interface GraphQueryOverviewReport { target: GraphQueryNodeReportItem; declaredSymbols: GraphQuerySymbolReport; @@ -212,6 +233,7 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' + | 'impact' | 'overview'; export type GraphQueryRequest = @@ -221,6 +243,7 @@ export type GraphQueryRequest = | { report: 'symbols'; arguments?: GraphQuerySymbolsConfig } | { report: 'paths'; arguments: GraphQueryPathConfig } | { report: 'search'; arguments: GraphQuerySearchConfig } + | { report: 'impact'; arguments: GraphQueryImpactConfig } | { report: 'overview'; arguments: GraphQueryOverviewConfig }; export type GraphQueryResult = @@ -230,5 +253,6 @@ export type GraphQueryResult = | GraphQuerySymbolReport | GraphQueryPathReport | GraphQuerySearchReport + | GraphQueryImpactReport | GraphQueryOverviewReport | GraphQueryTargetNotFoundReport; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77b7f43c4..f0701640b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -441,6 +441,9 @@ export type { GraphQueryEdgeReportItem, GraphQueryFilter, GraphQueryFilterOperator, + GraphQueryImpactConfig, + GraphQueryImpactItem, + GraphQueryImpactReport, GraphQueryNodeReport, GraphQueryNodeReportItem, GraphQueryOverviewConfig, @@ -468,6 +471,7 @@ export type { export { executeGraphQuery, findGraphPaths, + impactGraphTarget, inspectGraphTarget, listGraphEdges, listGraphNodes, diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index aeffa1f4c..a4cb9ef11 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -7,6 +7,7 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' + | 'impact' | 'overview'; export interface WorkspacePathInput { diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 66648984a..e65af4981 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -11,6 +11,7 @@ describe('cli/help/command', () => { expect(result.output).toContain('codegraphy settings'); expect(result.output).toContain('codegraphy search '); expect(result.output).toContain('codegraphy query '); + expect(result.output).toContain('codegraphy impact '); expect(result.output).toContain('codegraphy dependencies '); expect(result.output).toContain('codegraphy path '); expect(result.output).toContain('codegraphy scope node '); @@ -54,6 +55,9 @@ describe('cli/help/command', () => { expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); expect(createHelpResult(['search']).output).toContain('all-term path/source ranking'); + expect(createHelpResult(['impact']).output).toContain('Usage: codegraphy impact'); + expect(createHelpResult(['impact']).output).toContain('--depth '); + expect(createHelpResult(['impact']).output).toContain('incoming File radius'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index 05a5a42f1..e64724ecc 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -21,6 +21,11 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: 'render settings', limit: 20 }, }); + expect(parseQueryCommand(['impact', 'src/cli/command.ts'])).toEqual({ + name: 'query', + report: 'impact', + arguments: { target: 'src/cli/command.ts', maxDepth: 2, limit: 10 }, + }); expect(parseQueryCommand(['query', 'src/cli/command.ts'])).toEqual({ name: 'query', invokedCommand: 'query', @@ -63,6 +68,12 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: '-generated', limit: 20 }, }); + expect(parseQueryCommand([ + 'impact', '--depth', '3', '--limit', '5', '--offset', '2', 'src/registry.ts', + ])).toMatchObject({ + report: 'impact', + arguments: { target: 'src/registry.ts', maxDepth: 3, limit: 5, offset: 2 }, + }); }); it('parses one-off Filter and Graph Scope overrides for every query', () => { @@ -123,6 +134,21 @@ describe('cli/parseQuery', () => { invokedCommand: 'query', parseError: 'query requires ', }); + expect(parseQueryCommand(['impact'])).toMatchObject({ + invokedCommand: 'impact', + parseError: 'impact requires ', + }); + for (const value of [undefined, '0', '5', '-1', '1.5', 'many']) { + expect(parseQueryCommand([ + 'impact', 'src/registry.ts', '--depth', ...(value === undefined ? [] : [value]), + ])).toMatchObject({ + invokedCommand: 'impact', + parseError: '--depth requires an integer from 1 through 4', + }); + } + expect(parseQueryCommand(['nodes', '--depth', '2'])).toMatchObject({ + parseError: 'Unknown option for nodes: --depth', + }); expect(parseQueryCommand(['dependencies', 'a.ts', 'b.ts'])).toMatchObject({ parseError: 'Unexpected argument for dependencies: b.ts', }); diff --git a/packages/core/tests/graphQuery/impact.test.ts b/packages/core/tests/graphQuery/impact.test.ts new file mode 100644 index 000000000..757be1de9 --- /dev/null +++ b/packages/core/tests/graphQuery/impact.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../src/graph/contracts'; +import { impactGraphTarget } from '../../src/graphQuery/impact'; + +const graphData: IGraphData = { + nodes: [ + { id: 'src/registry.ts', label: 'registry.ts', nodeType: 'file' }, + { + id: 'src/registry.ts#dispose:function', + label: 'dispose', + nodeType: 'symbol:function', + symbol: { id: 'src/registry.ts#dispose:function', filePath: 'src/registry.ts', name: 'dispose', kind: 'function' }, + }, + { id: 'src/a-types.ts', label: 'a-types.ts', nodeType: 'file' }, + { id: 'src/engine.ts', label: 'engine.ts', nodeType: 'file' }, + { + id: 'src/engine.ts#rebuild:function', + label: 'rebuild', + nodeType: 'symbol:function', + symbol: { id: 'src/engine.ts#rebuild:function', filePath: 'src/engine.ts', name: 'rebuild', kind: 'function' }, + }, + { + id: 'src/engine.ts#register:function', + label: 'register', + nodeType: 'symbol:function', + symbol: { id: 'src/engine.ts#register:function', filePath: 'src/engine.ts', name: 'register', kind: 'function' }, + }, + { id: 'src/workspace.ts', label: 'workspace.ts', nodeType: 'file' }, + { id: 'tests/lifecycle.test.ts', label: 'lifecycle.test.ts', nodeType: 'file' }, + ], + edges: [ + { id: 'contains-registry', from: 'src/registry.ts', to: 'src/registry.ts#dispose:function', kind: 'contains', sources: [] }, + { id: 'contains-engine', from: 'src/engine.ts', to: 'src/engine.ts#rebuild:function', kind: 'contains', sources: [] }, + { id: 'contains-engine-register', from: 'src/engine.ts', to: 'src/engine.ts#register:function', kind: 'contains', sources: [] }, + { id: 'types-import-registry', from: 'src/a-types.ts', to: 'src/registry.ts', kind: 'type-import', sources: [] }, + { id: 'engine-calls-dispose', from: 'src/engine.ts#rebuild:function', to: 'src/registry.ts#dispose:function', kind: 'call', sources: [] }, + { id: 'workspace-calls-register', from: 'src/workspace.ts', to: 'src/engine.ts#register:function', kind: 'call', sources: [] }, + { id: 'test-imports-registry', from: 'tests/lifecycle.test.ts', to: 'src/registry.ts', kind: 'import', sources: [] }, + { id: 'cycle', from: 'src/registry.ts', to: 'src/workspace.ts', kind: 'reference', sources: [] }, + ], +}; + +describe('core/graphQuery impact', () => { + it('returns a bounded incoming File radius with typed reasons and shortest distance', () => { + expect(impactGraphTarget({ graphData }, { + target: 'src/registry.ts', + maxDepth: 2, + limit: 10, + })).toEqual({ + target: { path: 'src/registry.ts', nodeType: 'file' }, + impacted: [ + { path: 'src/engine.ts', nodeType: 'file', distance: 1, edgeTypes: ['call'] }, + { path: 'src/workspace.ts', nodeType: 'file', distance: 2, edgeTypes: ['call'] }, + { path: 'tests/lifecycle.test.ts', nodeType: 'file', distance: 1, edgeTypes: ['import'] }, + { path: 'src/a-types.ts', nodeType: 'file', distance: 1, edgeTypes: ['type-import'] }, + ], + page: { offset: 0, limit: 10, returned: 4, total: 4, nextOffset: null }, + limits: { maxDepth: 2, visitedNodes: 6, complete: true }, + }); + }); + + it('accepts an exact Symbol target and paginates one deterministic radius', () => { + const complete = impactGraphTarget({ graphData }, { + target: 'src/registry.ts#dispose:function', + maxDepth: 2, + limit: 10, + }); + const page = impactGraphTarget({ graphData }, { + target: 'src/registry.ts#dispose:function', + maxDepth: 2, + limit: 1, + offset: 1, + }); + + expect('impacted' in complete ? complete.impacted.map(item => item.path) : []).toEqual([ + 'src/engine.ts', + 'src/workspace.ts', + ]); + expect('impacted' in page ? page.impacted : []).toEqual( + 'impacted' in complete ? complete.impacted.slice(1, 2) : [], + ); + }); + + it('reports an exact target that is absent', () => { + expect(impactGraphTarget({ graphData }, { + target: 'src/missing.ts', + maxDepth: 2, + limit: 10, + })).toEqual({ + error: 'query_target_not_found', + message: 'No indexed Node or Symbol has the exact id: src/missing.ts', + }); + }); +}); diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index d06969f2e..05a1bffdc 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -80,6 +80,17 @@ describe('workspace/requestQuery', () => { edgeTypes: ['call'], }]); } + + const impact = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'impact', + arguments: { target: 'dependency.ts', maxDepth: 2, limit: 10 }, + }); + expect(impact).toMatchObject({ + target: { path: 'dependency.ts', nodeType: 'file' }, + impacted: [{ path: 'entry.ts', nodeType: 'file', distance: 1 }], + limits: { maxDepth: 2, complete: true }, + }); }); it('resolves targeted calls through named re-export barrels', async () => { diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index bc113cd6b..4825f6c2a 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -25,13 +25,14 @@ Most Symbols and Relationships are cached. Search also reads current source text |---|---| | `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | | `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | +| `impact ` | A bounded incoming File radius with shortest hop distance and typed Relationship reasons for one exact target. | | `nodes` | A paginated Node inventory from the shaped graph. | | `edges` | A paginated Relationship inventory from the shaped graph. | | `dependencies ` | Outgoing Relationships from a File path or exact Node ID. | | `dependents ` | Incoming Relationships to a File path or exact Node ID. | | `path ` | A bounded Relationship route between two File paths or exact Node IDs. | -Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. +Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Impact, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. `--filter`, `--node-type`, and `--edge-type` are one-off query projections and do not modify `.codegraphy/settings.json`. Persisted Filter and Scope changes affect later commands without deleting the complete cached facts. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 6b6739e3f..5b0185b94 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -14,7 +14,7 @@ test('the generalized CodeGraphy skill teaches the lifecycle and delegates synta const lifecyclePositions = lifecycle.map(term => skill.indexOf(term)); assert.ok(lifecyclePositions.every(position => position >= 0)); assert.deepEqual(lifecyclePositions, [...lifecyclePositions].sort((left, right) => left - right)); - for (const command of ['nodes', 'search', 'query', 'edges', 'dependencies', 'dependents', 'path']) { + for (const command of ['nodes', 'search', 'query', 'impact', 'edges', 'dependencies', 'dependents', 'path']) { assert.match(skill, new RegExp(`\\b${command}\\b`)); } assert.match(skill, /codegraphy --help/); From fbad2113678be7fde570de038a25410ba6d3ac12 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 18:30:23 -0700 Subject: [PATCH 40/55] Revert "feat(cli): report bounded impact radius" This reverts commit 59e06cf6b938f756c4298fd688b6f42c08f16ba0. --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 3 +- README.md | 3 +- packages/core/README.md | 4 +- packages/core/src/cli/help/command.ts | 19 --- packages/core/src/cli/parseQuery.ts | 39 +---- packages/core/src/graphQuery/execute.ts | 4 - packages/core/src/graphQuery/impact.ts | 141 ------------------ packages/core/src/graphQuery/index.ts | 4 - packages/core/src/graphQuery/model.ts | 24 --- packages/core/src/index.ts | 4 - packages/core/src/workspace/requestTypes.ts | 1 - packages/core/tests/cli/help/command.test.ts | 4 - packages/core/tests/cli/parseQuery.test.ts | 26 ---- packages/core/tests/graphQuery/impact.test.ts | 94 ------------ .../core/tests/workspace/requestQuery.test.ts | 11 -- skills/codegraphy/SKILL.md | 3 +- tests/release/codegraphySkill.test.mjs | 2 +- 18 files changed, 11 insertions(+), 377 deletions(-) delete mode 100644 packages/core/src/graphQuery/impact.ts delete mode 100644 packages/core/tests/graphQuery/impact.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index 3a355ece6..aa912aaf9 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -3,4 +3,4 @@ "@codegraphy-dev/plugin-api": minor --- -Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add bounded incoming `codegraphy impact` File radii, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. diff --git a/CONTEXT.md b/CONTEXT.md index 95b476c83..769804be1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -47,12 +47,11 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Searched Graph** | The Filtered Graph after Graph View Search. | | **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates. It reports source and cache provenance and does not change settings. | | **Target Query** | A bounded overview of one exact File or Symbol Node, including prioritized declarations and incoming/outgoing Relationships. | -| **Impact Query** | A bounded incoming traversal from one exact File or Symbol, projected to Files with shortest hop distance and typed Relationship reasons. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | | **Orphan Node** | A Node with no remaining Edges after graph narrowing. | | **Show Orphans** | A final Graph View setting that keeps or removes Orphan Nodes. | -Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, Target Query, Impact Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. +Graph Scope runs before Filter, Filter runs before Graph View Search, and sorting or pagination runs after those stages. Graph Query inventories use that shaped graph. CLI Search, Target Query, Path, and exact targeted Relationship selectors instead read the complete cached Node and Edge Types unless an invocation explicitly projects that dimension with `--node-type` or `--edge-type`; path Filters still apply. This keeps Graph View preferences from hiding indexed call or reexport evidence. Show Orphans is a Graph View presentation setting rather than an Indexing or Graph Query input. ## Selection, Focus, and Collapse diff --git a/README.md b/README.md index cffb73ffb..1dcc7d012 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,6 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | | `codegraphy search ` | Finds exact evidence and uses deterministic all-term File ranking for sparse natural multi-term phrases. | | `codegraphy query ` | Inspects one exact File or Symbol with prioritized declarations and incoming/outgoing Relationships. | -| `codegraphy impact ` | Lists a bounded incoming File radius with shortest hop distances and typed Relationship reasons. | | `codegraphy edges` | Lists bounded Edges. | | `codegraphy dependencies ` | Lists outgoing Relationships for a file or exact Symbol Node. | | `codegraphy dependents ` | Lists incoming Relationships for a file or exact Symbol Node. | @@ -142,7 +141,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy filter` | Reads or changes persisted filter patterns. | | `codegraphy plugins` | Registers, links, lists, enables, or disables plugins. | -Target Query, Impact, Path, and exact targeted Relationship selectors use complete cached Node and Edge Types by default rather than saved Graph View Scope; explicit `--node-type` or `--edge-type` arguments still constrain that dimension. JavaScript-family `reexport` Relationships let calls through barrels terminate at implementation Symbols. Run `codegraphy --help` for exact arguments. Query, settings, Indexing, and diagnostic commands keep machine-readable JSON on stdout. Verbose diagnostics go to stderr. +Target Query, Path, and exact targeted Relationship selectors use complete cached Node and Edge Types by default rather than saved Graph View Scope; explicit `--node-type` or `--edge-type` arguments still constrain that dimension. JavaScript-family `reexport` Relationships let calls through barrels terminate at implementation Symbols. Run `codegraphy --help` for exact arguments. Query, settings, Indexing, and diagnostic commands keep machine-readable JSON on stdout. Verbose diagnostics go to stderr. ### Agent Skill diff --git a/packages/core/README.md b/packages/core/README.md index 6c66842c8..e77d7f64e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. `impact` traverses incoming Relationships from one exact target and returns a paginated File radius with shortest distance and typed reasons. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy settings get maxFiles @@ -20,7 +20,6 @@ codegraphy nodes codegraphy search SettingsPanel codegraphy search 'Indexing *workspace*' codegraphy query src/cli/index/command.ts -codegraphy impact src/plugins/registry.ts --depth 2 codegraphy edges codegraphy dependencies src/app.ts codegraphy dependents src/config.ts @@ -51,7 +50,6 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - Graph Cache storage: load, save, clear, and inspect normalized File, Node, Symbol, and Edge rows in the SQLite-backed Graph Cache at `/.codegraphy/graph.sqlite`. - Workspace Search: merge bounded exact evidence with deterministic all-term File fallback candidates for natural multi-term phrases. - Target Query: inspect one exact File or Symbol with prioritized declarations and bounded Relationships. -- Impact Query: traverse bounded incoming Relationships from one exact target and report impacted Files with shortest distance and Edge Types. - Graph Query: list scoped Nodes and Edges, then use complete cached types by default for exact targeted relationships and bounded paths unless an invocation explicitly projects Node or Edge Types. The core package exposes `indexCodeGraphyWorkspace` for explicit path-based Indexing. VS Code and CLI adapters call this package instead of owning independent indexing behavior. diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 401b592fd..2344ab397 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -31,7 +31,6 @@ const ROOT_HELP = [ 'Explore:', ' codegraphy search Find live source text and cached AST Symbols', ' codegraphy query Inspect one exact File or Symbol Node', - ' codegraphy impact List a bounded incoming File impact radius', '', 'Navigate the Relationship Graph:', ' codegraphy nodes List Nodes in the shaped graph', @@ -125,24 +124,6 @@ const COMMAND_HELP: Record = { 'Output: JSON target overview with fixed per-section bounds and page totals.', 'Example: codegraphy query packages/core/src/cli/command.ts', ].join('\n'), - impact: [ - 'Usage: codegraphy impact [--depth ] [--limit ] [--offset ] ', - '', - 'List a bounded incoming File radius around one exact File path or Symbol Node ID.', - 'Each result includes shortest hop distance and the Relationship Types that reached it.', - '', - 'Arguments:', - ' Workspace-relative File path or exact Symbol Node ID', - '', - 'Effects: Read-only. Requires an existing Graph Cache and never performs Indexing.', - 'Output: JSON target, impacted Files, pagination, traversal limits, and completeness.', - 'Options:', - ' --depth Incoming traversal depth from 1 through 4 (default: 2)', - ' --limit Maximum impacted Files to return (default: 10)', - ' --offset Zero-based result offset (default: 0)', - ...QUERY_PROJECTION_OPTIONS, - 'Example: codegraphy impact packages/core/src/plugins/registry.ts --depth 2', - ].join('\n'), nodes: [ 'Usage: codegraphy nodes [--limit ] [--offset ]', '', diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index 1f740c1a8..04ab34d9d 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -5,7 +5,6 @@ const QUERY_COMMANDS = new Set([ 'dependencies', 'dependents', 'edges', - 'impact', 'nodes', 'path', 'query', @@ -14,15 +13,11 @@ const QUERY_COMMANDS = new Set([ const DEFAULT_LIMIT = 100; const DEFAULT_SEARCH_LIMIT = 20; -const DEFAULT_IMPACT_LIMIT = 10; -const DEFAULT_IMPACT_DEPTH = 2; -const MAX_IMPACT_DEPTH = 4; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; interface ParsedQueryArguments { operands: string[]; - depth?: number; limit: number; offset?: number; parseError?: string; @@ -34,11 +29,10 @@ interface QueryBuilderInput { operands: string[]; page: { limit: number; offset?: number }; projection?: WorkspaceGraphQueryProjection; - depth?: number; } type ParsedOption = - | { type: 'depth' | 'limit' | 'offset'; value: number } + | { type: 'limit' | 'offset'; value: number } | { type: 'projection'; key: keyof WorkspaceGraphQueryProjection; values: string[] } | { type: 'error'; message: string }; @@ -98,21 +92,14 @@ function parseOption( if (argument === '--limit' || argument === '--offset') { return parsePaginationOption(command, argument, value, allowPagination); } - if (argument === '--depth' && command === 'impact') { - const depth = parseInteger(value, 1); - return depth !== undefined && depth <= MAX_IMPACT_DEPTH - ? { type: 'depth', value: depth } - : { type: 'error', message: `--depth requires an integer from 1 through ${MAX_IMPACT_DEPTH}` }; - } const projectionKey = PROJECTION_OPTION_KEYS[argument]; return projectionKey ? parseProjectionOption(argument, value, projectionKey) : undefined; } function applyOption( parsed: ParsedOption, - state: { depth?: number; limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, + state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, ): void { - if (parsed.type === 'depth') state.depth = parsed.value; if (parsed.type === 'limit') state.limit = parsed.value; if (parsed.type === 'offset') state.offset = parsed.value; if (parsed.type === 'projection') { @@ -122,11 +109,10 @@ function applyOption( function completeParsedArguments( operands: string[], - state: { depth?: number; limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, + state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection }, ): ParsedQueryArguments { return { operands, - ...(state.depth !== undefined ? { depth: state.depth } : {}), limit: state.limit, ...(state.offset !== undefined ? { offset: state.offset } : {}), ...(Object.keys(state.projection).length > 0 ? { projection: state.projection } : {}), @@ -140,7 +126,7 @@ function parseArguments( defaultLimit = DEFAULT_LIMIT, ): ParsedQueryArguments { const operands: string[] = []; - const state: { depth?: number; limit: number; offset?: number; projection: WorkspaceGraphQueryProjection } = { + const state: { limit: number; offset?: number; projection: WorkspaceGraphQueryProjection } = { limit: defaultLimit, projection: {}, }; @@ -207,15 +193,6 @@ function buildSearch(input: QueryBuilderInput): CliCommand { return query(input.command, 'search', { pattern: input.operands[0], ...input.page }, input.projection); } -function buildImpact(input: QueryBuilderInput): CliCommand { - const invalid = requireOperands(input.command, input.operands, 1, ''); - return invalid ?? query(input.command, 'impact', { - target: input.operands[0], - maxDepth: input.depth ?? DEFAULT_IMPACT_DEPTH, - ...input.page, - }, input.projection); -} - function buildOverview(input: QueryBuilderInput): CliCommand { const invalid = requireOperands(input.command, input.operands, 1, ''); return invalid ?? query(input.command, 'overview', { target: input.operands[0] }, input.projection); @@ -247,7 +224,6 @@ const QUERY_BUILDERS: Record CliCommand> = nodes: buildList, edges: buildList, search: buildSearch, - impact: buildImpact, query: buildOverview, dependencies: input => buildConnection(input, 'from'), dependents: input => buildConnection(input, 'to'), @@ -262,11 +238,7 @@ export function parseQueryCommand(argv: string[]): CliCommand { command, rawArgs, command !== 'path' && command !== 'query', - command === 'search' - ? DEFAULT_SEARCH_LIMIT - : command === 'impact' - ? DEFAULT_IMPACT_LIMIT - : DEFAULT_LIMIT, + command === 'search' ? DEFAULT_SEARCH_LIMIT : DEFAULT_LIMIT, ); if (parsed.parseError) return parseError(command, parsed.parseError); return builder({ @@ -277,6 +249,5 @@ export function parseQueryCommand(argv: string[]): CliCommand { ...(parsed.offset !== undefined ? { offset: parsed.offset } : {}), }, ...(parsed.projection ? { projection: parsed.projection } : {}), - ...(parsed.depth !== undefined ? { depth: parsed.depth } : {}), }); } diff --git a/packages/core/src/graphQuery/execute.ts b/packages/core/src/graphQuery/execute.ts index fdd36cb57..738c09dde 100644 --- a/packages/core/src/graphQuery/execute.ts +++ b/packages/core/src/graphQuery/execute.ts @@ -2,7 +2,6 @@ import type { GraphQueryData } from './data'; import type { GraphQueryConfig, GraphQueryConnectionConfig, - GraphQueryImpactConfig, GraphQueryOverviewConfig, GraphQueryPathConfig, GraphQueryRequest, @@ -10,7 +9,6 @@ import type { GraphQuerySearchConfig, GraphQuerySymbolsConfig, } from './model'; -import { impactGraphTarget } from './impact'; import { inspectGraphTarget } from './overview'; import { findGraphPaths } from './paths'; import { listGraphEdges, listGraphNodes } from './reports'; @@ -31,7 +29,6 @@ type GraphQueryHandlers = { symbols: GraphQueryHandler; paths: GraphQueryHandler; search: GraphQueryHandler; - impact: GraphQueryHandler; overview: GraphQueryHandler; }; @@ -42,7 +39,6 @@ const GRAPH_QUERY_HANDLERS: GraphQueryHandlers = { symbols: (data, args) => listGraphSymbols(data, args), paths: (data, args) => findGraphPaths(deriveScopedGraphQueryData(data.graphData, args), args), search: (data, args) => searchGraph(data, args), - impact: (data, args) => impactGraphTarget(data, args), overview: (data, args) => inspectGraphTarget(data, args), }; diff --git a/packages/core/src/graphQuery/impact.ts b/packages/core/src/graphQuery/impact.ts deleted file mode 100644 index 05ca2a1c5..000000000 --- a/packages/core/src/graphQuery/impact.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { IGraphNode } from '../graph/contracts'; -import type { GraphQueryData } from './data'; -import type { - GraphQueryImpactConfig, - GraphQueryImpactItem, - GraphQueryImpactReport, - GraphQueryTargetNotFoundReport, -} from './model'; -import { toNodeReportItem } from './nodeReport'; -import { paginate } from './pagination'; - -const MAX_VISITED_NODES = 2_000; - -interface ImpactAccumulator { - distance: number; - edgeTypes: Set; - node: IGraphNode; -} - -function targetNotFound(target: string): GraphQueryTargetNotFoundReport { - return { - error: 'query_target_not_found', - message: `No indexed Node or Symbol has the exact id: ${target}`, - }; -} - -function fileNodeFor(node: IGraphNode, nodesById: ReadonlyMap): IGraphNode | undefined { - if (node.nodeType === 'file') return node; - const filePath = node.symbol?.filePath; - return filePath ? nodesById.get(filePath) : undefined; -} - -function impactReasonRank(edgeTypes: ReadonlySet): number { - const ranks: Partial> = { - call: 0, - event: 0, - inherit: 0, - reference: 0, - import: 1, - include: 1, - nests: 1, - reexport: 1, - using: 1, - 'type-import': 2, - }; - return Math.min(...[...edgeTypes].map(edgeType => ranks[edgeType] ?? 1)); -} - -function startingNodeIds(target: IGraphNode, nodes: readonly IGraphNode[]): string[] { - if (target.nodeType !== 'file') return [target.id]; - return [ - target.id, - ...nodes.flatMap(node => node.symbol?.filePath === target.id ? [node.id] : []), - ]; -} - -export function impactGraphTarget( - data: GraphQueryData, - config: GraphQueryImpactConfig, -): GraphQueryImpactReport | GraphQueryTargetNotFoundReport { - const nodesById = new Map(data.graphData.nodes.map(node => [node.id, node])); - const target = nodesById.get(config.target); - if (!target) return targetNotFound(config.target); - const targetFilePath = fileNodeFor(target, nodesById)?.id; - const incoming = new Map(); - for (const edge of data.graphData.edges) { - if (edge.kind === 'contains') continue; - incoming.set(edge.to, [...(incoming.get(edge.to) ?? []), edge]); - } - - const seeds = startingNodeIds(target, data.graphData.nodes); - const visited = new Set(seeds); - let frontier = seeds; - let visitedNodes = 0; - let complete = true; - const impacted = new Map(); - - for (let distance = 1; distance <= config.maxDepth && frontier.length > 0; distance += 1) { - const next = new Set(); - for (const current of frontier) { - for (const edge of incoming.get(current) ?? []) { - const source = nodesById.get(edge.from); - if (!source) continue; - const sourceFile = fileNodeFor(source, nodesById); - if (sourceFile && sourceFile.id !== targetFilePath) { - const existing = impacted.get(sourceFile.id); - if (!existing || distance < existing.distance) { - impacted.set(sourceFile.id, { - distance, - edgeTypes: new Set([edge.kind]), - node: sourceFile, - }); - } else if (distance === existing.distance) { - existing.edgeTypes.add(edge.kind); - } - } - const nextIds = sourceFile - ? [source.id, ...startingNodeIds(sourceFile, data.graphData.nodes)] - : [source.id]; - for (const nextId of new Set(nextIds)) { - if (visited.has(nextId)) continue; - visited.add(nextId); - next.add(nextId); - visitedNodes += 1; - if (visitedNodes >= MAX_VISITED_NODES) { - complete = false; - break; - } - } - if (!complete) break; - } - if (!complete) break; - } - frontier = [...next].sort(); - if (!complete) break; - } - - const ranked = [...impacted.values()] - .sort((left, right) => ( - impactReasonRank(left.edgeTypes) - impactReasonRank(right.edgeTypes) - || left.distance - right.distance - || left.node.id.localeCompare(right.node.id) - )) - .map(item => ({ - ...toNodeReportItem(item.node), - distance: item.distance, - edgeTypes: [...item.edgeTypes].sort(), - })); - const page = paginate(ranked, config); - - return { - target: toNodeReportItem(target), - impacted: page.items, - page: page.page, - limits: { - maxDepth: config.maxDepth, - visitedNodes, - complete, - }, - }; -} diff --git a/packages/core/src/graphQuery/index.ts b/packages/core/src/graphQuery/index.ts index e5ec1b571..de1577a6e 100644 --- a/packages/core/src/graphQuery/index.ts +++ b/packages/core/src/graphQuery/index.ts @@ -5,9 +5,6 @@ export type { GraphQueryEdgeReportItem, GraphQueryFilter, GraphQueryFilterOperator, - GraphQueryImpactConfig, - GraphQueryImpactItem, - GraphQueryImpactReport, GraphQueryNodeReport, GraphQueryNodeReportItem, GraphQueryOverviewConfig, @@ -34,7 +31,6 @@ export type { } from './model'; export type { GraphQueryData } from './data'; export { executeGraphQuery } from './execute'; -export { impactGraphTarget } from './impact'; export { inspectGraphTarget } from './overview'; export { findGraphPaths } from './paths'; export { listGraphEdges, listGraphNodes } from './reports'; diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index 48a4f1e94..29594eeab 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -65,11 +65,6 @@ export interface GraphQuerySearchConfig extends GraphQueryConfig { pattern: string; } -export interface GraphQueryImpactConfig extends GraphQueryConfig { - target: string; - maxDepth: number; -} - export interface GraphQueryOverviewConfig { target: string; } @@ -194,22 +189,6 @@ export interface GraphQuerySearchReport { }; } -export interface GraphQueryImpactItem extends GraphQueryNodeReportItem { - distance: number; - edgeTypes: GraphEdgeKind[]; -} - -export interface GraphQueryImpactReport { - target: GraphQueryNodeReportItem; - impacted: GraphQueryImpactItem[]; - page: GraphQueryPage; - limits: { - maxDepth: number; - visitedNodes: number; - complete: boolean; - }; -} - export interface GraphQueryOverviewReport { target: GraphQueryNodeReportItem; declaredSymbols: GraphQuerySymbolReport; @@ -233,7 +212,6 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' - | 'impact' | 'overview'; export type GraphQueryRequest = @@ -243,7 +221,6 @@ export type GraphQueryRequest = | { report: 'symbols'; arguments?: GraphQuerySymbolsConfig } | { report: 'paths'; arguments: GraphQueryPathConfig } | { report: 'search'; arguments: GraphQuerySearchConfig } - | { report: 'impact'; arguments: GraphQueryImpactConfig } | { report: 'overview'; arguments: GraphQueryOverviewConfig }; export type GraphQueryResult = @@ -253,6 +230,5 @@ export type GraphQueryResult = | GraphQuerySymbolReport | GraphQueryPathReport | GraphQuerySearchReport - | GraphQueryImpactReport | GraphQueryOverviewReport | GraphQueryTargetNotFoundReport; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f0701640b..77b7f43c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -441,9 +441,6 @@ export type { GraphQueryEdgeReportItem, GraphQueryFilter, GraphQueryFilterOperator, - GraphQueryImpactConfig, - GraphQueryImpactItem, - GraphQueryImpactReport, GraphQueryNodeReport, GraphQueryNodeReportItem, GraphQueryOverviewConfig, @@ -471,7 +468,6 @@ export type { export { executeGraphQuery, findGraphPaths, - impactGraphTarget, inspectGraphTarget, listGraphEdges, listGraphNodes, diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index a4cb9ef11..aeffa1f4c 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -7,7 +7,6 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' - | 'impact' | 'overview'; export interface WorkspacePathInput { diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index e65af4981..66648984a 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -11,7 +11,6 @@ describe('cli/help/command', () => { expect(result.output).toContain('codegraphy settings'); expect(result.output).toContain('codegraphy search '); expect(result.output).toContain('codegraphy query '); - expect(result.output).toContain('codegraphy impact '); expect(result.output).toContain('codegraphy dependencies '); expect(result.output).toContain('codegraphy path '); expect(result.output).toContain('codegraphy scope node '); @@ -55,9 +54,6 @@ describe('cli/help/command', () => { expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); expect(createHelpResult(['search']).output).toContain('all-term path/source ranking'); - expect(createHelpResult(['impact']).output).toContain('Usage: codegraphy impact'); - expect(createHelpResult(['impact']).output).toContain('--depth '); - expect(createHelpResult(['impact']).output).toContain('incoming File radius'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index e64724ecc..05a5a42f1 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -21,11 +21,6 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: 'render settings', limit: 20 }, }); - expect(parseQueryCommand(['impact', 'src/cli/command.ts'])).toEqual({ - name: 'query', - report: 'impact', - arguments: { target: 'src/cli/command.ts', maxDepth: 2, limit: 10 }, - }); expect(parseQueryCommand(['query', 'src/cli/command.ts'])).toEqual({ name: 'query', invokedCommand: 'query', @@ -68,12 +63,6 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: '-generated', limit: 20 }, }); - expect(parseQueryCommand([ - 'impact', '--depth', '3', '--limit', '5', '--offset', '2', 'src/registry.ts', - ])).toMatchObject({ - report: 'impact', - arguments: { target: 'src/registry.ts', maxDepth: 3, limit: 5, offset: 2 }, - }); }); it('parses one-off Filter and Graph Scope overrides for every query', () => { @@ -134,21 +123,6 @@ describe('cli/parseQuery', () => { invokedCommand: 'query', parseError: 'query requires ', }); - expect(parseQueryCommand(['impact'])).toMatchObject({ - invokedCommand: 'impact', - parseError: 'impact requires ', - }); - for (const value of [undefined, '0', '5', '-1', '1.5', 'many']) { - expect(parseQueryCommand([ - 'impact', 'src/registry.ts', '--depth', ...(value === undefined ? [] : [value]), - ])).toMatchObject({ - invokedCommand: 'impact', - parseError: '--depth requires an integer from 1 through 4', - }); - } - expect(parseQueryCommand(['nodes', '--depth', '2'])).toMatchObject({ - parseError: 'Unknown option for nodes: --depth', - }); expect(parseQueryCommand(['dependencies', 'a.ts', 'b.ts'])).toMatchObject({ parseError: 'Unexpected argument for dependencies: b.ts', }); diff --git a/packages/core/tests/graphQuery/impact.test.ts b/packages/core/tests/graphQuery/impact.test.ts deleted file mode 100644 index 757be1de9..000000000 --- a/packages/core/tests/graphQuery/impact.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { IGraphData } from '../../src/graph/contracts'; -import { impactGraphTarget } from '../../src/graphQuery/impact'; - -const graphData: IGraphData = { - nodes: [ - { id: 'src/registry.ts', label: 'registry.ts', nodeType: 'file' }, - { - id: 'src/registry.ts#dispose:function', - label: 'dispose', - nodeType: 'symbol:function', - symbol: { id: 'src/registry.ts#dispose:function', filePath: 'src/registry.ts', name: 'dispose', kind: 'function' }, - }, - { id: 'src/a-types.ts', label: 'a-types.ts', nodeType: 'file' }, - { id: 'src/engine.ts', label: 'engine.ts', nodeType: 'file' }, - { - id: 'src/engine.ts#rebuild:function', - label: 'rebuild', - nodeType: 'symbol:function', - symbol: { id: 'src/engine.ts#rebuild:function', filePath: 'src/engine.ts', name: 'rebuild', kind: 'function' }, - }, - { - id: 'src/engine.ts#register:function', - label: 'register', - nodeType: 'symbol:function', - symbol: { id: 'src/engine.ts#register:function', filePath: 'src/engine.ts', name: 'register', kind: 'function' }, - }, - { id: 'src/workspace.ts', label: 'workspace.ts', nodeType: 'file' }, - { id: 'tests/lifecycle.test.ts', label: 'lifecycle.test.ts', nodeType: 'file' }, - ], - edges: [ - { id: 'contains-registry', from: 'src/registry.ts', to: 'src/registry.ts#dispose:function', kind: 'contains', sources: [] }, - { id: 'contains-engine', from: 'src/engine.ts', to: 'src/engine.ts#rebuild:function', kind: 'contains', sources: [] }, - { id: 'contains-engine-register', from: 'src/engine.ts', to: 'src/engine.ts#register:function', kind: 'contains', sources: [] }, - { id: 'types-import-registry', from: 'src/a-types.ts', to: 'src/registry.ts', kind: 'type-import', sources: [] }, - { id: 'engine-calls-dispose', from: 'src/engine.ts#rebuild:function', to: 'src/registry.ts#dispose:function', kind: 'call', sources: [] }, - { id: 'workspace-calls-register', from: 'src/workspace.ts', to: 'src/engine.ts#register:function', kind: 'call', sources: [] }, - { id: 'test-imports-registry', from: 'tests/lifecycle.test.ts', to: 'src/registry.ts', kind: 'import', sources: [] }, - { id: 'cycle', from: 'src/registry.ts', to: 'src/workspace.ts', kind: 'reference', sources: [] }, - ], -}; - -describe('core/graphQuery impact', () => { - it('returns a bounded incoming File radius with typed reasons and shortest distance', () => { - expect(impactGraphTarget({ graphData }, { - target: 'src/registry.ts', - maxDepth: 2, - limit: 10, - })).toEqual({ - target: { path: 'src/registry.ts', nodeType: 'file' }, - impacted: [ - { path: 'src/engine.ts', nodeType: 'file', distance: 1, edgeTypes: ['call'] }, - { path: 'src/workspace.ts', nodeType: 'file', distance: 2, edgeTypes: ['call'] }, - { path: 'tests/lifecycle.test.ts', nodeType: 'file', distance: 1, edgeTypes: ['import'] }, - { path: 'src/a-types.ts', nodeType: 'file', distance: 1, edgeTypes: ['type-import'] }, - ], - page: { offset: 0, limit: 10, returned: 4, total: 4, nextOffset: null }, - limits: { maxDepth: 2, visitedNodes: 6, complete: true }, - }); - }); - - it('accepts an exact Symbol target and paginates one deterministic radius', () => { - const complete = impactGraphTarget({ graphData }, { - target: 'src/registry.ts#dispose:function', - maxDepth: 2, - limit: 10, - }); - const page = impactGraphTarget({ graphData }, { - target: 'src/registry.ts#dispose:function', - maxDepth: 2, - limit: 1, - offset: 1, - }); - - expect('impacted' in complete ? complete.impacted.map(item => item.path) : []).toEqual([ - 'src/engine.ts', - 'src/workspace.ts', - ]); - expect('impacted' in page ? page.impacted : []).toEqual( - 'impacted' in complete ? complete.impacted.slice(1, 2) : [], - ); - }); - - it('reports an exact target that is absent', () => { - expect(impactGraphTarget({ graphData }, { - target: 'src/missing.ts', - maxDepth: 2, - limit: 10, - })).toEqual({ - error: 'query_target_not_found', - message: 'No indexed Node or Symbol has the exact id: src/missing.ts', - }); - }); -}); diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index 05a1bffdc..d06969f2e 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -80,17 +80,6 @@ describe('workspace/requestQuery', () => { edgeTypes: ['call'], }]); } - - const impact = await requestWorkspaceGraphQuery({ - workspacePath: workspaceRoot, - report: 'impact', - arguments: { target: 'dependency.ts', maxDepth: 2, limit: 10 }, - }); - expect(impact).toMatchObject({ - target: { path: 'dependency.ts', nodeType: 'file' }, - impacted: [{ path: 'entry.ts', nodeType: 'file', distance: 1 }], - limits: { maxDepth: 2, complete: true }, - }); }); it('resolves targeted calls through named re-export barrels', async () => { diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 4825f6c2a..bc113cd6b 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -25,14 +25,13 @@ Most Symbols and Relationships are cached. Search also reads current source text |---|---| | `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | | `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | -| `impact ` | A bounded incoming File radius with shortest hop distance and typed Relationship reasons for one exact target. | | `nodes` | A paginated Node inventory from the shaped graph. | | `edges` | A paginated Relationship inventory from the shaped graph. | | `dependencies ` | Outgoing Relationships from a File path or exact Node ID. | | `dependents ` | Incoming Relationships to a File path or exact Node ID. | | `path ` | A bounded Relationship route between two File paths or exact Node IDs. | -Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Impact, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. +Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. `--filter`, `--node-type`, and `--edge-type` are one-off query projections and do not modify `.codegraphy/settings.json`. Persisted Filter and Scope changes affect later commands without deleting the complete cached facts. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 5b0185b94..6b6739e3f 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -14,7 +14,7 @@ test('the generalized CodeGraphy skill teaches the lifecycle and delegates synta const lifecyclePositions = lifecycle.map(term => skill.indexOf(term)); assert.ok(lifecyclePositions.every(position => position >= 0)); assert.deepEqual(lifecyclePositions, [...lifecyclePositions].sort((left, right) => left - right)); - for (const command of ['nodes', 'search', 'query', 'impact', 'edges', 'dependencies', 'dependents', 'path']) { + for (const command of ['nodes', 'search', 'query', 'edges', 'dependencies', 'dependents', 'path']) { assert.match(skill, new RegExp(`\\b${command}\\b`)); } assert.match(skill, /codegraphy --help/); From 71775b4d24d488dadf8e016c41bfecbe831d30a8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 18:30:55 -0700 Subject: [PATCH 41/55] docs(cli): record impact query experiment --- ...enchmarks-require-structural-work-and-tool-adoption.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md b/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md index 46e332ad2..f8ce3d119 100644 --- a/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md +++ b/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md @@ -61,6 +61,12 @@ A detached candidate combined both rejected ideas to test their interaction. Eve Triage reduced observation bytes, but successful trajectories consumed more model tokens and one implementation was incomplete. The detached worktree and prototype were removed. +### Incoming Impact radius + +An `impact ` prototype traversed incoming Relationships from one exact File or Symbol to depth one through four, projected results to Files, and reported shortest distance, typed reasons, pagination, visited Nodes, and completeness. Runtime call/import reasons ranked ahead of type-only consumers. On the structural fixture, a 1.4 KB result around `plugins/registry.ts` placed `engine.ts`, `engineSetup.ts`, and `workspace.ts` in the first page. + +All three treatment agents read the updated skill but none selected Impact or any other CodeGraphy command. Raw correctness was 3/3 versus treatment 2/3. Mean correct-run tokens increased from 318,247 to 832,347, calls increased 60.3%, output increased 28.2%, and elapsed increased 65.7%. These trajectory regressions cannot be attributed to unused Impact output, but non-adoption fails the public-interface gate. The implementation, tests, documentation, and changeset update were reverted. + ## Decision Retain the neutral generalized Agent Skill from ADR 0011. It explains the graph model, lifecycle, command semantics, output, freshness, shaping, and limitations without prescribing a workflow. Do not retain Triage or evidence-first comparative framing. @@ -78,7 +84,7 @@ Three repetitions remain a fast screening instrument rather than confirmation. R - New commands must earn both discoverability and successful-trajectory value. - Skill wording is itself an intervention and can increase exhaustive ordinary exploration even without CLI use. - Tool output reduction is diagnostic; cumulative model tokens per correct implementation remain primary. -- Watcher, impact, neighbor, conflict, inference, fuzzy, hub, and budgeted-map experiments remain candidates, but each must use the same structural task and adoption gate. +- Watcher, neighbor, conflict, inference, fuzzy, hub, and budgeted-map experiments remain candidates, but each must use the same structural task and adoption gate; the tested incoming Impact command is rejected. ## References From e9d55dbca377093de7fa2b490e40dd0e254f1b44 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 18:49:12 -0700 Subject: [PATCH 42/55] docs(skill): compact graph capability reference --- skills/codegraphy/SKILL.md | 64 ++++++++++---------------- tests/release/codegraphySkill.test.mjs | 6 +++ 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index bc113cd6b..1e809370d 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -1,62 +1,46 @@ --- name: codegraphy -description: Understand and operate the CodeGraphy CLI for workspace source, symbol, and relationship exploration. +description: Explore workspace source identities and typed incoming, outgoing, and path relationships with the CodeGraphy CLI. --- # CodeGraphy -CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and plugin-defined concepts. Directed Edges identify relationships such as imports, reexports, calls, references, inheritance, and containment. Edge direction and type explain why two Nodes are connected. +CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and Plugin-defined concepts. Directed Edges identify typed relationships such as imports, reexports, calls, references, inheritance, and containment. Edge direction and type explain why Nodes are connected. -The graph is navigation evidence. Static relationships can identify ownership and possible change surfaces, but they do not prove runtime behavior. Source, tests, generated behavior, dynamic dispatch, and unsupported language semantics can add facts that are absent from the graph. +The graph is navigation evidence, not proof of runtime behavior. Source, tests, generated behavior, dynamic dispatch, and unsupported language semantics can contain facts absent from static analysis. -## Graph lifecycle and freshness +## Cache and freshness -`codegraphy index` discovers eligible workspace files, runs built-in and enabled Plugin analysis, and creates or incrementally updates `.codegraphy/graph.sqlite`. The Graph Cache stores complete indexed facts before Graph Scope and path Filters shape query results. +`codegraphy index` discovers eligible workspace files and builds or incrementally updates `.codegraphy/graph.sqlite`. This **Graph Cache** stores complete indexed facts before Filters and Graph Scope shape results. `codegraphy filter` changes persisted path exclusions; `codegraphy scope` changes visible Node and Edge Types. -`codegraphy filter` changes persisted path exclusions without rebuilding cached analysis. `codegraphy scope` changes which Node Types and Edge Types appear in the shaped graph. `codegraphy settings` exposes workspace discovery, indexing, filter, scope, Plugin, and interface settings; settings mutations report whether another Index is required. `codegraphy plugins` controls installed Plugin registration and activation. +Query with read-only commands after a Graph Cache exists. `codegraphy status` reports supported missing, stale, and fresh states. `codegraphy doctor` checks settings, runtime, Plugins, and cache health. Most Symbols and Relationships are cached. Search also reads **live source** text from eligible indexed File Nodes. One search can therefore contain live source matches and cached Symbol matches whose `cacheState` is fresh or stale. Indexing refreshes changed AST and Relationship facts. -Query with any read-only graph command after a Graph Cache exists. A `graph_cache_not_found` result means that no indexed snapshot is available. `codegraphy status` reports supported missing, stale, and fresh cache conditions. `codegraphy doctor` checks runtime, settings, cache, and Plugin health and includes recovery information for unhealthy checks. +## Query capabilities -Most Symbols and Relationships are cached. Search also reads current source text from eligible indexed File Nodes. A single response can therefore contain `freshness: "live"` source matches and cached Symbol matches whose `cacheState` is `fresh` or `stale`. Indexing is what makes changed AST and Relationship facts current. - -## Query surfaces - -| Command | Information returned | +| Command | Returned evidence | |---|---| -| `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | -| `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | -| `nodes` | A paginated Node inventory from the shaped graph. | -| `edges` | A paginated Relationship inventory from the shaped graph. | -| `dependencies ` | Outgoing Relationships from a File path or exact Node ID. | -| `dependents ` | Incoming Relationships to a File path or exact Node ID. | -| `path ` | A bounded Relationship route between two File paths or exact Node IDs. | - -Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. - -`--filter`, `--node-type`, and `--edge-type` are one-off query projections and do not modify `.codegraphy/settings.json`. Persisted Filter and Scope changes affect later commands without deleting the complete cached facts. - -## Search and target identity +| `search ` | Ranked live source locations, cached AST Symbols, and indexed Nodes. | +| `query ` | A bounded exact File or Symbol overview with declarations and typed incoming and outgoing Relationships. | +| `dependencies ` | Outgoing Relationships from an exact File path or Node ID. | +| `dependents ` | Incoming Relationships to an exact File path or Node ID. | +| `path ` | Bounded typed routes between exact File paths or Node IDs. | +| `nodes` | Paginated Node inventory. | +| `edges` | Paginated Relationship inventory. | -Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. Search is lexical rather than a semantic-answer engine. +Search literal matching is case-insensitive, `*` is a line-local wildcard, and multi-term phrases can return Files containing every term. Search is lexical, not a semantic-answer engine. -Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. +Source matches include path, line, column, excerpt, and live freshness. Symbol matches include exact Symbol IDs and locations. Exact File paths and Symbol IDs address Target Query and relationship commands; display labels are not necessarily Node IDs. -Search, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. +## Shaping and bounds -## Machine-readable contract +Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. One-off projections do not modify workspace settings. -Normal command results are JSON envelopes. Successful data is written to stdout. Operational and invalid-invocation failures use structured error envelopes on stderr and nonzero exit statuses. `--verbose` adds lifecycle diagnostics to stderr without changing the data envelope. +Reports are bounded. Pagination metadata includes offset, limit, returned count, total count, and `nextOffset`. Target Query bounds declarations and each Relationship direction independently. Path results include traversal limits and `complete`; `complete: false` means a configured bound stopped exploration before the search space was exhausted. -Common error codes distinguish invalid arguments, missing or stale workspace state, an exact target that is absent, malformed settings, and operational failures. Error `details` and `actions` carry command-specific recovery context when available. +## Machine contract and limits -## Interpretation limits +Normal results are JSON envelopes on stdout. Failures use structured error envelopes on stderr with nonzero exit status. `--verbose` adds stderr diagnostics. -- Graph coverage depends on eligible files, the indexing file budget, enabled Plugins, supported languages, and analyzer capabilities. -- Persisted Filters and Graph Scope can intentionally hide facts from broad inventories. -- Live text and cached structural facts can have different freshness in the same result. -- Imports, calls, references, and inferred or plugin-defined Edges have different semantics; an Edge Type should be interpreted rather than treated as generic proximity. -- Incoming Relationships suggest consumers or possible impact, while outgoing Relationships suggest dependencies; neither alone defines the complete edit set. -- Hubs, barrels, generated files, tests, and shared utilities can have many legitimate Relationships and can dominate broad graph results. -- Bounded or paginated output is not evidence that omitted results do not exist. +Coverage depends on eligible files, the indexing file budget, enabled Plugins, supported languages, analyzer capabilities, Filters, and Graph Scope. Edge Types have distinct semantics. Incoming Relationships suggest consumers or possible impact; outgoing Relationships suggest dependencies. Hubs, barrels, generated files, tests, and shared utilities can legitimately dominate broad results. Bounded output does not imply omitted evidence is absent. -Current command syntax, options, and examples are available from `codegraphy --help` and `codegraphy --help`. +Current syntax and examples are available from `codegraphy --help` and `codegraphy --help`. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 6b6739e3f..2243cd098 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -43,6 +43,12 @@ test('the CodeGraphy skill explains the tool without prescribing an agent workfl assert.doesNotMatch(skill, /(?:never submit|one domain word|task literals|after one empty or refined search)/i); }); +test('the CodeGraphy capability reference stays compact', () => { + const skill = readFileSync(skillPath, 'utf8'); + + assert.ok(Buffer.byteLength(skill, 'utf8') <= 4096); +}); + test('the old MCP package and skill are absent from the release source', () => { assert.equal(existsSync(path.join(repoRoot, 'packages', 'mcp', 'package.json')), false); assert.equal(existsSync(path.join(repoRoot, 'skills', 'codegraphy-mcp', 'SKILL.md')), false); From 93ffde349cd090bd3978e6b7efd3b9da3c24443a Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 18:57:23 -0700 Subject: [PATCH 43/55] Revert "docs(skill): compact graph capability reference" This reverts commit e9d55dbca377093de7fa2b490e40dd0e254f1b44. --- skills/codegraphy/SKILL.md | 64 ++++++++++++++++---------- tests/release/codegraphySkill.test.mjs | 6 --- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 1e809370d..bc113cd6b 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -1,46 +1,62 @@ --- name: codegraphy -description: Explore workspace source identities and typed incoming, outgoing, and path relationships with the CodeGraphy CLI. +description: Understand and operate the CodeGraphy CLI for workspace source, symbol, and relationship exploration. --- # CodeGraphy -CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and Plugin-defined concepts. Directed Edges identify typed relationships such as imports, reexports, calls, references, inheritance, and containment. Edge direction and type explain why Nodes are connected. +CodeGraphy represents a workspace as a **Relationship Graph**. Nodes identify Files, Folders, Packages, AST Symbols, and plugin-defined concepts. Directed Edges identify relationships such as imports, reexports, calls, references, inheritance, and containment. Edge direction and type explain why two Nodes are connected. -The graph is navigation evidence, not proof of runtime behavior. Source, tests, generated behavior, dynamic dispatch, and unsupported language semantics can contain facts absent from static analysis. +The graph is navigation evidence. Static relationships can identify ownership and possible change surfaces, but they do not prove runtime behavior. Source, tests, generated behavior, dynamic dispatch, and unsupported language semantics can add facts that are absent from the graph. -## Cache and freshness +## Graph lifecycle and freshness -`codegraphy index` discovers eligible workspace files and builds or incrementally updates `.codegraphy/graph.sqlite`. This **Graph Cache** stores complete indexed facts before Filters and Graph Scope shape results. `codegraphy filter` changes persisted path exclusions; `codegraphy scope` changes visible Node and Edge Types. +`codegraphy index` discovers eligible workspace files, runs built-in and enabled Plugin analysis, and creates or incrementally updates `.codegraphy/graph.sqlite`. The Graph Cache stores complete indexed facts before Graph Scope and path Filters shape query results. -Query with read-only commands after a Graph Cache exists. `codegraphy status` reports supported missing, stale, and fresh states. `codegraphy doctor` checks settings, runtime, Plugins, and cache health. Most Symbols and Relationships are cached. Search also reads **live source** text from eligible indexed File Nodes. One search can therefore contain live source matches and cached Symbol matches whose `cacheState` is fresh or stale. Indexing refreshes changed AST and Relationship facts. +`codegraphy filter` changes persisted path exclusions without rebuilding cached analysis. `codegraphy scope` changes which Node Types and Edge Types appear in the shaped graph. `codegraphy settings` exposes workspace discovery, indexing, filter, scope, Plugin, and interface settings; settings mutations report whether another Index is required. `codegraphy plugins` controls installed Plugin registration and activation. -## Query capabilities +Query with any read-only graph command after a Graph Cache exists. A `graph_cache_not_found` result means that no indexed snapshot is available. `codegraphy status` reports supported missing, stale, and fresh cache conditions. `codegraphy doctor` checks runtime, settings, cache, and Plugin health and includes recovery information for unhealthy checks. -| Command | Returned evidence | +Most Symbols and Relationships are cached. Search also reads current source text from eligible indexed File Nodes. A single response can therefore contain `freshness: "live"` source matches and cached Symbol matches whose `cacheState` is `fresh` or `stale`. Indexing is what makes changed AST and Relationship facts current. + +## Query surfaces + +| Command | Information returned | |---|---| -| `search ` | Ranked live source locations, cached AST Symbols, and indexed Nodes. | -| `query ` | A bounded exact File or Symbol overview with declarations and typed incoming and outgoing Relationships. | -| `dependencies ` | Outgoing Relationships from an exact File path or Node ID. | -| `dependents ` | Incoming Relationships to an exact File path or Node ID. | -| `path ` | Bounded typed routes between exact File paths or Node IDs. | -| `nodes` | Paginated Node inventory. | -| `edges` | Paginated Relationship inventory. | +| `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | +| `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | +| `nodes` | A paginated Node inventory from the shaped graph. | +| `edges` | A paginated Relationship inventory from the shaped graph. | +| `dependencies ` | Outgoing Relationships from a File path or exact Node ID. | +| `dependents ` | Incoming Relationships to a File path or exact Node ID. | +| `path ` | A bounded Relationship route between two File paths or exact Node IDs. | + +Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. + +`--filter`, `--node-type`, and `--edge-type` are one-off query projections and do not modify `.codegraphy/settings.json`. Persisted Filter and Scope changes affect later commands without deleting the complete cached facts. + +## Search and target identity -Search literal matching is case-insensitive, `*` is a line-local wildcard, and multi-term phrases can return Files containing every term. Search is lexical, not a semantic-answer engine. +Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. Search is lexical rather than a semantic-answer engine. -Source matches include path, line, column, excerpt, and live freshness. Symbol matches include exact Symbol IDs and locations. Exact File paths and Symbol IDs address Target Query and relationship commands; display labels are not necessarily Node IDs. +Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. -## Shaping and bounds +Search, inventories, overviews, and relationship reports are bounded. Pagination uses `page` metadata recording offset, limit, returned count, total count, and `nextOffset` when another page exists. Target Query reports independent declaration and relationship bounds. Path reports include traversal limits and a `complete` boolean; `complete: false` means the configured search bound was reached before the entire search space was exhausted. -Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Path, and targeted relationship selectors use complete cached Node and Edge Types unless an invocation explicitly projects a dimension with `--node-type` or `--edge-type`. Path Filters still apply. One-off projections do not modify workspace settings. +## Machine-readable contract -Reports are bounded. Pagination metadata includes offset, limit, returned count, total count, and `nextOffset`. Target Query bounds declarations and each Relationship direction independently. Path results include traversal limits and `complete`; `complete: false` means a configured bound stopped exploration before the search space was exhausted. +Normal command results are JSON envelopes. Successful data is written to stdout. Operational and invalid-invocation failures use structured error envelopes on stderr and nonzero exit statuses. `--verbose` adds lifecycle diagnostics to stderr without changing the data envelope. -## Machine contract and limits +Common error codes distinguish invalid arguments, missing or stale workspace state, an exact target that is absent, malformed settings, and operational failures. Error `details` and `actions` carry command-specific recovery context when available. -Normal results are JSON envelopes on stdout. Failures use structured error envelopes on stderr with nonzero exit status. `--verbose` adds stderr diagnostics. +## Interpretation limits -Coverage depends on eligible files, the indexing file budget, enabled Plugins, supported languages, analyzer capabilities, Filters, and Graph Scope. Edge Types have distinct semantics. Incoming Relationships suggest consumers or possible impact; outgoing Relationships suggest dependencies. Hubs, barrels, generated files, tests, and shared utilities can legitimately dominate broad results. Bounded output does not imply omitted evidence is absent. +- Graph coverage depends on eligible files, the indexing file budget, enabled Plugins, supported languages, and analyzer capabilities. +- Persisted Filters and Graph Scope can intentionally hide facts from broad inventories. +- Live text and cached structural facts can have different freshness in the same result. +- Imports, calls, references, and inferred or plugin-defined Edges have different semantics; an Edge Type should be interpreted rather than treated as generic proximity. +- Incoming Relationships suggest consumers or possible impact, while outgoing Relationships suggest dependencies; neither alone defines the complete edit set. +- Hubs, barrels, generated files, tests, and shared utilities can have many legitimate Relationships and can dominate broad graph results. +- Bounded or paginated output is not evidence that omitted results do not exist. -Current syntax and examples are available from `codegraphy --help` and `codegraphy --help`. +Current command syntax, options, and examples are available from `codegraphy --help` and `codegraphy --help`. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 2243cd098..6b6739e3f 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -43,12 +43,6 @@ test('the CodeGraphy skill explains the tool without prescribing an agent workfl assert.doesNotMatch(skill, /(?:never submit|one domain word|task literals|after one empty or refined search)/i); }); -test('the CodeGraphy capability reference stays compact', () => { - const skill = readFileSync(skillPath, 'utf8'); - - assert.ok(Buffer.byteLength(skill, 'utf8') <= 4096); -}); - test('the old MCP package and skill are absent from the release source', () => { assert.equal(existsSync(path.join(repoRoot, 'packages', 'mcp', 'package.json')), false); assert.equal(existsSync(path.join(repoRoot, 'skills', 'codegraphy-mcp', 'SKILL.md')), false); From 10cf5475f29c5eb12b11b2b99d11b1d9cf200a6a Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 19:52:51 -0700 Subject: [PATCH 44/55] feat(cli): add personalized task maps --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 3 +- README.md | 1 + packages/core/README.md | 4 +- packages/core/src/cli/help/command.ts | 18 ++ packages/core/src/cli/parseQuery.ts | 12 +- packages/core/src/graphQuery/execute.ts | 4 + packages/core/src/graphQuery/index.ts | 4 + packages/core/src/graphQuery/model.ts | 37 +++ packages/core/src/graphQuery/taskMap.ts | 296 ++++++++++++++++++ packages/core/src/workspace/requestQuery.ts | 4 +- packages/core/src/workspace/requestTypes.ts | 1 + packages/core/tests/cli/help/command.test.ts | 4 + packages/core/tests/cli/parseQuery.test.ts | 10 + .../core/tests/graphQuery/taskMap.test.ts | 105 +++++++ .../core/tests/workspace/requestQuery.test.ts | 28 ++ skills/codegraphy/SKILL.md | 1 + tests/release/codegraphySkill.test.mjs | 2 +- 18 files changed, 529 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/graphQuery/taskMap.ts create mode 100644 packages/core/tests/graphQuery/taskMap.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index aa912aaf9..226e89eea 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -3,4 +3,4 @@ "@codegraphy-dev/plugin-api": minor --- -Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add compact task-personalized `codegraphy map` File maps with typed Relationships, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. diff --git a/CONTEXT.md b/CONTEXT.md index 769804be1..0ea664fe0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -46,6 +46,7 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Graph View Search** | A temporary text query that narrows the current graph without changing Filter settings. | | **Searched Graph** | The Filtered Graph after Graph View Search. | | **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates. It reports source and cache provenance and does not change settings. | +| **Task Map** | A bounded task-personalized File map combining independent live terms, selected declarations, and cached typed Relationships with source-area diversity. | | **Target Query** | A bounded overview of one exact File or Symbol Node, including prioritized declarations and incoming/outgoing Relationships. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | | **Orphan Node** | A Node with no remaining Edges after graph narrowing. | @@ -107,7 +108,7 @@ The Graph View can use a whole-view loading state before its first graph payload | **tldraw Interface** | `@codegraphy-dev/tldraw` owns its launcher, tldraw document lifecycle, native shapes, controls, and adapters over Core and renderer physics. | | **Graph Renderer** | `@codegraphy-dev/graph-renderer` owns WebGPU drawing and deterministic WebAssembly physics. It does not own product settings, persistence, or plugins. | | **CodeGraphy CLI** | The terminal interface installed by `@codegraphy-dev/core`. It targets the current directory unless `-C, --workspace ` selects another workspace. | -| **CodeGraphy Exploration CLI** | `search` combines exact evidence with deterministic all-term fallback ranking for natural phrases; `query` inspects one exact File or Symbol with prioritized declarations and Relationships. Both return bounded JSON with provenance. | +| **CodeGraphy Exploration CLI** | `search` combines exact evidence with deterministic all-term fallback ranking for natural phrases; `map` builds a compact task-personalized File map; `query` inspects one exact File or Symbol with prioritized declarations and Relationships. All return bounded JSON with provenance. | | **CodeGraphy Settings CLI** | `settings`, `settings get`, `settings set`, and `settings unset` read or safely mutate supported workspace settings without silently repairing corrupt persisted input. | | **Graph Query CLI** | `nodes`, `edges`, `dependencies`, `dependents`, and `path`, all with bounded JSON output over the shaped Relationship Graph. | | **CodeGraphy Agent Skill** | Generalized instructions that explain the Relationship Graph, lifecycle, query surfaces, machine-readable output, freshness, shaping, and limits so a shell-capable agent can choose its own navigation strategy. | diff --git a/README.md b/README.md index 1dcc7d012..d18c34167 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy settings [get|set|unset]` | Safely reads or changes validated workspace settings such as `maxFiles`. | | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | | `codegraphy search ` | Finds exact evidence and uses deterministic all-term File ranking for sparse natural multi-term phrases. | +| `codegraphy map ` | Builds a compact task-personalized File map with declarations and typed connecting Relationships. | | `codegraphy query ` | Inspects one exact File or Symbol with prioritized declarations and incoming/outgoing Relationships. | | `codegraphy edges` | Lists bounded Edges. | | `codegraphy dependencies ` | Lists outgoing Relationships for a file or exact Symbol Node. | diff --git a/packages/core/README.md b/packages/core/README.md index e77d7f64e..c4dbb22bc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `map` combines independent task terms, selected declarations, and personalized graph ranking into a bounded File map with typed connecting Relationships. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy settings get maxFiles @@ -19,6 +19,7 @@ codegraphy doctor codegraphy nodes codegraphy search SettingsPanel codegraphy search 'Indexing *workspace*' +codegraphy map 'settings corruption during filtering' codegraphy query src/cli/index/command.ts codegraphy edges codegraphy dependencies src/app.ts @@ -49,6 +50,7 @@ Run `codegraphy --help` for the full workflow and `codegraphy --help` - Workspace status: report fresh, stale, or missing Graph Cache state with inspectable stale reasons. - Graph Cache storage: load, save, clear, and inspect normalized File, Node, Symbol, and Edge rows in the SQLite-backed Graph Cache at `/.codegraphy/graph.sqlite`. - Workspace Search: merge bounded exact evidence with deterministic all-term File fallback candidates for natural multi-term phrases. +- Task Map: rank task-relevant Files from live terms and cached Relationships, with selected declarations and typed connections. - Target Query: inspect one exact File or Symbol with prioritized declarations and bounded Relationships. - Graph Query: list scoped Nodes and Edges, then use complete cached types by default for exact targeted relationships and bounded paths unless an invocation explicitly projects Node or Edge Types. diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 2344ab397..528b32bc3 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -30,6 +30,7 @@ const ROOT_HELP = [ '', 'Explore:', ' codegraphy search Find live source text and cached AST Symbols', + ' codegraphy map Build a compact task-personalized File map', ' codegraphy query Inspect one exact File or Symbol Node', '', 'Navigate the Relationship Graph:', @@ -156,6 +157,23 @@ const COMMAND_HELP: Record = { 'Example: codegraphy search runIndexCommand', "Example: codegraphy search 'Indexing *workspace*'", ].join('\n'), + map: [ + 'Usage: codegraphy map [--limit ] [--offset ] ', + '', + 'Build a personalized File map from independent task terms and typed Relationships.', + 'Files are ranked from live lexical evidence and the cached graph; selected declarations and connecting Edges explain inclusion.', + '', + 'Arguments:', + ' Task or issue text', + '', + 'Effects: Read-only. Does not perform Indexing; source text is read live from indexed files.', + 'Output: Bounded JSON Files, matched terms, declarations, typed Relationships, freshness, and completeness.', + 'Options:', + ' --limit Maximum Files to return (default: 8)', + ' --offset Zero-based File offset (default: 0)', + ...QUERY_PROJECTION_OPTIONS, + "Example: codegraphy map 'plugin cleanup after workspace failure'", + ].join('\n'), edges: [ 'Usage: codegraphy edges [--limit ] [--offset ]', '', diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index 04ab34d9d..433479614 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -5,6 +5,7 @@ const QUERY_COMMANDS = new Set([ 'dependencies', 'dependents', 'edges', + 'map', 'nodes', 'path', 'query', @@ -13,6 +14,7 @@ const QUERY_COMMANDS = new Set([ const DEFAULT_LIMIT = 100; const DEFAULT_SEARCH_LIMIT = 20; +const DEFAULT_TASK_MAP_LIMIT = 8; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; @@ -193,6 +195,11 @@ function buildSearch(input: QueryBuilderInput): CliCommand { return query(input.command, 'search', { pattern: input.operands[0], ...input.page }, input.projection); } +function buildTaskMap(input: QueryBuilderInput): CliCommand { + const invalid = requireOperands(input.command, input.operands, 1, ''); + return invalid ?? query(input.command, 'task-map', { query: input.operands[0], ...input.page }, input.projection); +} + function buildOverview(input: QueryBuilderInput): CliCommand { const invalid = requireOperands(input.command, input.operands, 1, ''); return invalid ?? query(input.command, 'overview', { target: input.operands[0] }, input.projection); @@ -224,6 +231,7 @@ const QUERY_BUILDERS: Record CliCommand> = nodes: buildList, edges: buildList, search: buildSearch, + map: buildTaskMap, query: buildOverview, dependencies: input => buildConnection(input, 'from'), dependents: input => buildConnection(input, 'to'), @@ -238,7 +246,9 @@ export function parseQueryCommand(argv: string[]): CliCommand { command, rawArgs, command !== 'path' && command !== 'query', - command === 'search' ? DEFAULT_SEARCH_LIMIT : DEFAULT_LIMIT, + command === 'search' + ? DEFAULT_SEARCH_LIMIT + : command === 'map' ? DEFAULT_TASK_MAP_LIMIT : DEFAULT_LIMIT, ); if (parsed.parseError) return parseError(command, parsed.parseError); return builder({ diff --git a/packages/core/src/graphQuery/execute.ts b/packages/core/src/graphQuery/execute.ts index 738c09dde..f766a6f90 100644 --- a/packages/core/src/graphQuery/execute.ts +++ b/packages/core/src/graphQuery/execute.ts @@ -8,6 +8,7 @@ import type { GraphQueryResult, GraphQuerySearchConfig, GraphQuerySymbolsConfig, + GraphQueryTaskMapConfig, } from './model'; import { inspectGraphTarget } from './overview'; import { findGraphPaths } from './paths'; @@ -15,6 +16,7 @@ import { listGraphEdges, listGraphNodes } from './reports'; import { listGraphRelationships } from './relationships'; import { searchGraph } from './search'; import { listGraphSymbols } from './symbols'; +import { mapGraphTask } from './taskMap'; import { deriveScopedGraphQueryData } from './visible'; type GraphQueryHandler = ( @@ -29,6 +31,7 @@ type GraphQueryHandlers = { symbols: GraphQueryHandler; paths: GraphQueryHandler; search: GraphQueryHandler; + 'task-map': GraphQueryHandler; overview: GraphQueryHandler; }; @@ -39,6 +42,7 @@ const GRAPH_QUERY_HANDLERS: GraphQueryHandlers = { symbols: (data, args) => listGraphSymbols(data, args), paths: (data, args) => findGraphPaths(deriveScopedGraphQueryData(data.graphData, args), args), search: (data, args) => searchGraph(data, args), + 'task-map': (data, args) => mapGraphTask(data, args), overview: (data, args) => inspectGraphTarget(data, args), }; diff --git a/packages/core/src/graphQuery/index.ts b/packages/core/src/graphQuery/index.ts index de1577a6e..d35f63835 100644 --- a/packages/core/src/graphQuery/index.ts +++ b/packages/core/src/graphQuery/index.ts @@ -28,6 +28,9 @@ export type { GraphQuerySymbolReport, GraphQuerySymbolReportItem, GraphQuerySymbolsConfig, + GraphQueryTaskMapConfig, + GraphQueryTaskMapFile, + GraphQueryTaskMapReport, } from './model'; export type { GraphQueryData } from './data'; export { executeGraphQuery } from './execute'; @@ -37,3 +40,4 @@ export { listGraphEdges, listGraphNodes } from './reports'; export { listGraphRelationships } from './relationships'; export { searchGraph } from './search'; export { listGraphSymbols } from './symbols'; +export { mapGraphTask } from './taskMap'; diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index 29594eeab..c9668e529 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -65,6 +65,10 @@ export interface GraphQuerySearchConfig extends GraphQueryConfig { pattern: string; } +export interface GraphQueryTaskMapConfig extends GraphQueryConfig { + query: string; +} + export interface GraphQueryOverviewConfig { target: string; } @@ -189,6 +193,36 @@ export interface GraphQuerySearchReport { }; } +export interface GraphQueryTaskMapFile { + path: string; + nodeType: 'file'; + matchedTerms: string[]; + symbols: { id?: string; name: string; kind?: string }[]; +} + +export interface GraphQueryTaskMapReport { + query: string; + terms: string[]; + files: GraphQueryTaskMapFile[]; + relationships: GraphQueryEdgeReportItem[]; + page: GraphQueryPage; + limits: { + relationships: number; + complete: boolean; + }; + sources: { + text: { + freshness: 'live'; + filesScanned: number; + filesSkipped: number; + }; + graph: { + freshness: 'cached'; + cacheState: 'fresh' | 'stale'; + }; + }; +} + export interface GraphQueryOverviewReport { target: GraphQueryNodeReportItem; declaredSymbols: GraphQuerySymbolReport; @@ -212,6 +246,7 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' + | 'task-map' | 'overview'; export type GraphQueryRequest = @@ -221,6 +256,7 @@ export type GraphQueryRequest = | { report: 'symbols'; arguments?: GraphQuerySymbolsConfig } | { report: 'paths'; arguments: GraphQueryPathConfig } | { report: 'search'; arguments: GraphQuerySearchConfig } + | { report: 'task-map'; arguments: GraphQueryTaskMapConfig } | { report: 'overview'; arguments: GraphQueryOverviewConfig }; export type GraphQueryResult = @@ -230,5 +266,6 @@ export type GraphQueryResult = | GraphQuerySymbolReport | GraphQueryPathReport | GraphQuerySearchReport + | GraphQueryTaskMapReport | GraphQueryOverviewReport | GraphQueryTargetNotFoundReport; diff --git a/packages/core/src/graphQuery/taskMap.ts b/packages/core/src/graphQuery/taskMap.ts new file mode 100644 index 000000000..937cac513 --- /dev/null +++ b/packages/core/src/graphQuery/taskMap.ts @@ -0,0 +1,296 @@ +import type { GraphEdgeKind, IGraphNode } from '../graph/contracts'; +import type { GraphQueryData } from './data'; +import type { + GraphQueryTaskMapConfig, + GraphQueryTaskMapFile, + GraphQueryTaskMapReport, +} from './model'; +import { paginate } from './pagination'; + +const MAX_QUERY_TERMS = 16; +const MAX_SYMBOLS_PER_FILE = 3; +const MAX_RELATIONSHIPS = 12; +const PAGE_RANK_ITERATIONS = 20; +const PAGE_RANK_DAMPING = 0.85; +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'during', 'for', 'from', 'in', + 'into', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with', +]); + +interface TaskDocument { + node: IGraphNode; + pathTerms: readonly string[]; + textTerms: readonly string[]; +} + +interface RankedTaskFile { + file: GraphQueryTaskMapFile; + lexicalScore: number; + graphScore: number; +} + +function tokenize(value: string): string[] { + const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2'); + return separated.match(/[\p{L}\d]+/gu)?.map(term => term.toLocaleLowerCase()) + .filter(term => term.length > 2 && !STOP_WORDS.has(term)) ?? []; +} + +function termVariants(term: string): string[] { + const variants = new Set([term]); + if (term.endsWith('ing') && term.length > 5) variants.add(term.slice(0, -3)); + if (term.endsWith('ed') && term.length > 4) { + variants.add(term.slice(0, -2)); + variants.add(term.slice(0, -1)); + } + if (term.endsWith('s') && term.length > 4) variants.add(term.slice(0, -1)); + return [...variants]; +} + +function includesTerm(terms: readonly string[], queryTerm: string): boolean { + return termVariants(queryTerm).some(term => terms.includes(term)); +} + +function createDocuments(data: GraphQueryData): TaskDocument[] { + const files = new Map(data.graphData.nodes + .filter(node => node.nodeType === 'file' && !node.symbol) + .map(node => [node.id, node])); + return (data.sourceText?.files ?? []).flatMap(({ filePath, content }) => { + const node = files.get(filePath); + return node ? [{ node, pathTerms: tokenize(filePath), textTerms: tokenize(content) }] : []; + }); +} + +function selectTerms(query: string, documents: readonly TaskDocument[]): string[] { + const candidates = [...new Set(tokenize(query))]; + const frequency = new Map(candidates.map(term => [ + term, + documents.filter(document => includesTerm(document.pathTerms, term) || includesTerm(document.textTerms, term)).length, + ])); + return candidates + .filter(term => (frequency.get(term) ?? 0) > 0) + .map((term, index) => ({ term, index, frequency: frequency.get(term) ?? 0 })) + .sort((left, right) => left.frequency - right.frequency || left.index - right.index) + .slice(0, MAX_QUERY_TERMS) + .sort((left, right) => left.index - right.index) + .map(item => item.term); +} + +function countTerm(terms: readonly string[], target: string): number { + const variants = new Set(termVariants(target)); + return terms.reduce((total, term) => total + (variants.has(term) ? 1 : 0), 0); +} + +function lexicalRank( + document: TaskDocument, + queryTerms: readonly string[], + frequencies: ReadonlyMap, + documentCount: number, +): { matchedTerms: string[]; score: number } { + const matchedTerms = queryTerms.filter(term => ( + includesTerm(document.pathTerms, term) || includesTerm(document.textTerms, term) + )); + const score = matchedTerms.reduce((total, term) => { + const inverseFrequency = Math.log((documentCount + 1) / ((frequencies.get(term) ?? 0) + 1)) + 1; + const pathMatch = countTerm(document.pathTerms, term) > 0; + const textMatch = countTerm(document.textTerms, term) > 0; + return total + inverseFrequency * (pathMatch ? 4 : textMatch ? 1 : 0); + }, 0); + const isTest = /(?:^|\/)(?:__tests__|tests?)(?:\/|\.)|\.(?:spec|test)\.[^/]+$/iu.test(document.node.id); + return { matchedTerms, score: isTest ? score * 0.15 : score }; +} + +function filePathForNode(node: IGraphNode | undefined): string | undefined { + if (!node) return undefined; + if (node.nodeType === 'file' && !node.symbol) return node.id; + return node.symbol?.filePath; +} + +function edgeWeight(kind: GraphEdgeKind): number { + if (kind === 'call' || kind === 'event' || kind === 'inherit' || kind === 'reference') return 3; + if (kind === 'type-import') return 1; + return 2; +} + +function createFileLinks(data: GraphQueryData, filePaths: ReadonlySet): Map> { + const nodes = new Map(data.graphData.nodes.map(node => [node.id, node])); + const links = new Map([...filePaths].map(filePath => [filePath, new Map()])); + for (const edge of data.graphData.edges) { + if (edge.kind === 'contains') continue; + const from = filePathForNode(nodes.get(edge.from)); + const to = filePathForNode(nodes.get(edge.to)); + if (!from || !to || from === to || !filePaths.has(from) || !filePaths.has(to)) continue; + const weight = edgeWeight(edge.kind); + const fromLinks = links.get(from); + const toLinks = links.get(to); + fromLinks?.set(to, (fromLinks.get(to) ?? 0) + weight); + toLinks?.set(from, (toLinks.get(from) ?? 0) + weight); + } + return links; +} + +function personalizedPageRank( + links: ReadonlyMap>, + personalization: ReadonlyMap, +): Map { + const paths = [...links.keys()]; + const totalPersonalization = [...personalization.values()].reduce((total, value) => total + value, 0); + const normalized = new Map(paths.map(path => [ + path, + totalPersonalization > 0 ? (personalization.get(path) ?? 0) / totalPersonalization : 1 / Math.max(paths.length, 1), + ])); + let ranks = new Map(normalized); + for (let iteration = 0; iteration < PAGE_RANK_ITERATIONS; iteration += 1) { + const next = new Map(paths.map(path => [path, (1 - PAGE_RANK_DAMPING) * (normalized.get(path) ?? 0)])); + for (const path of paths) { + const neighbors = links.get(path) ?? new Map(); + const totalWeight = [...neighbors.values()].reduce((total, weight) => total + weight, 0); + if (totalWeight === 0) continue; + for (const [neighbor, weight] of neighbors) { + next.set(neighbor, (next.get(neighbor) ?? 0) + PAGE_RANK_DAMPING * (ranks.get(path) ?? 0) * weight / totalWeight); + } + } + ranks = next; + } + return ranks; +} + +function rankingGroup(filePath: string): string { + const segments = filePath.split('/'); + const sourceIndex = segments.findIndex(segment => segment === 'src' || segment === 'tests'); + const end = sourceIndex >= 0 ? sourceIndex + 3 : Math.min(segments.length, 4); + return segments.slice(0, end).join('/'); +} + +function balanceSourceAreas(ranked: readonly RankedTaskFile[]): RankedTaskFile[] { + const groups = new Map(); + for (const item of ranked) { + const group = rankingGroup(item.file.path); + groups.set(group, [...(groups.get(group) ?? []), item]); + } + const ordered = [...groups.entries()].sort((left, right) => { + const leftRank = left[1][0]; + const rightRank = right[1][0]; + if (!leftRank || !rightRank) return left[0].localeCompare(right[0]); + return Number(rightRank.lexicalScore > 0) - Number(leftRank.lexicalScore > 0) + || rightRank.lexicalScore - leftRank.lexicalScore + || rightRank.graphScore - leftRank.graphScore + || left[0].localeCompare(right[0]); + }); + const balanced: RankedTaskFile[] = []; + for (let index = 0; balanced.length < ranked.length; index += 1) { + for (const [, items] of ordered) { + const item = items[index]; + if (item) balanced.push(item); + } + } + return balanced; +} + +function symbolsForFile(data: GraphQueryData, filePath: string): GraphQueryTaskMapFile['symbols'] { + return (data.symbols ?? []) + .filter(symbol => symbol.filePath === filePath) + .sort((left, right) => left.name.localeCompare(right.name) || (left.id ?? '').localeCompare(right.id ?? '')) + .slice(0, MAX_SYMBOLS_PER_FILE) + .map(symbol => ({ + ...(symbol.id ? { id: symbol.id } : {}), + name: symbol.name, + ...(symbol.kind ? { kind: symbol.kind } : {}), + })); +} + +function selectedRelationships( + data: GraphQueryData, + selectedPaths: ReadonlySet, +): { relationships: GraphQueryTaskMapReport['relationships']; complete: boolean } { + const nodes = new Map(data.graphData.nodes.map(node => [node.id, node])); + const grouped = new Map>(); + for (const edge of data.graphData.edges) { + if (edge.kind === 'contains') continue; + const from = filePathForNode(nodes.get(edge.from)); + const to = filePathForNode(nodes.get(edge.to)); + if (!from || !to || from === to || !selectedPaths.has(from) || !selectedPaths.has(to)) continue; + const key = `${from}\u0000${to}`; + const kinds = grouped.get(key) ?? new Set(); + kinds.add(edge.kind); + grouped.set(key, kinds); + } + const all = [...grouped].map(([key, kinds]) => { + const [from = '', to = ''] = key.split('\u0000'); + return { from, to, edgeTypes: [...kinds].sort() }; + }).sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)); + return { relationships: all.slice(0, MAX_RELATIONSHIPS), complete: all.length <= MAX_RELATIONSHIPS }; +} + +export function mapGraphTask( + data: GraphQueryData, + config: GraphQueryTaskMapConfig, +): GraphQueryTaskMapReport { + const documents = createDocuments(data); + const terms = selectTerms(config.query, documents); + const frequencies = new Map(terms.map(term => [ + term, + documents.filter(document => includesTerm(document.pathTerms, term) || includesTerm(document.textTerms, term)).length, + ])); + const lexical = new Map(documents.map(document => [ + document.node.id, + lexicalRank(document, terms, frequencies, documents.length), + ])); + const filePaths = new Set(documents.map(document => document.node.id)); + const graphRanks = personalizedPageRank( + createFileLinks(data, filePaths), + new Map([...lexical].map(([path, rank]) => [path, rank.score])), + ); + const connected = new Set(); + const links = createFileLinks(data, filePaths); + for (const [path, rank] of lexical) { + if (rank.score <= 0) continue; + connected.add(path); + for (const neighbor of links.get(path)?.keys() ?? []) connected.add(neighbor); + } + const ranked: RankedTaskFile[] = documents.flatMap((document) => { + const lexicalRanked = lexical.get(document.node.id) ?? { matchedTerms: [], score: 0 }; + if (lexicalRanked.score <= 0 && !connected.has(document.node.id)) return []; + return [{ + file: { + path: document.node.id, + nodeType: 'file' as const, + matchedTerms: lexicalRanked.matchedTerms, + symbols: symbolsForFile(data, document.node.id), + }, + lexicalScore: lexicalRanked.score, + graphScore: graphRanks.get(document.node.id) ?? 0, + }]; + }).sort((left, right) => ( + Number(right.lexicalScore > 0) - Number(left.lexicalScore > 0) + || right.lexicalScore - left.lexicalScore + || right.graphScore - left.graphScore + || left.file.path.localeCompare(right.file.path) + )); + const balanced = balanceSourceAreas(ranked); + const page = paginate(balanced.map(item => item.file), config); + const selectedPaths = new Set(page.items.map(file => file.path)); + const relationships = selectedRelationships(data, selectedPaths); + + return { + query: config.query, + terms, + files: page.items, + relationships: relationships.relationships, + page: page.page, + limits: { + relationships: MAX_RELATIONSHIPS, + complete: page.page.nextOffset === null && relationships.complete, + }, + sources: { + text: { + freshness: 'live', + filesScanned: data.sourceText?.filesScanned ?? 0, + filesSkipped: data.sourceText?.filesSkipped ?? 0, + }, + graph: { + freshness: 'cached', + cacheState: data.cacheState ?? 'fresh', + }, + }, + }; +} diff --git a/packages/core/src/workspace/requestQuery.ts b/packages/core/src/workspace/requestQuery.ts index bafc5a03e..2529eb221 100644 --- a/packages/core/src/workspace/requestQuery.ts +++ b/packages/core/src/workspace/requestQuery.ts @@ -56,7 +56,7 @@ function hasTargetSelector(arguments_: Record): boolean { function shouldApplyWorkspaceGraphScope(input: WorkspaceGraphQueryInput): boolean { if (input.report === 'relationships') return true; - if (input.report === 'search' || input.report === 'overview' || input.report === 'paths') return false; + if (input.report === 'search' || input.report === 'task-map' || input.report === 'overview' || input.report === 'paths') return false; return !hasTargetSelector(input.arguments); } @@ -73,7 +73,7 @@ function executeWorkspaceGraphQuery( source, input.projection, ); - const sourceText = input.report === 'search' + const sourceText = input.report === 'search' || input.report === 'task-map' ? readWorkspaceQuerySourceText(workspaceRoot, graphData, source.indexedContentHashes) : undefined; const completeScope = { diff --git a/packages/core/src/workspace/requestTypes.ts b/packages/core/src/workspace/requestTypes.ts index aeffa1f4c..1847e186e 100644 --- a/packages/core/src/workspace/requestTypes.ts +++ b/packages/core/src/workspace/requestTypes.ts @@ -7,6 +7,7 @@ export type GraphQueryReport = | 'symbols' | 'paths' | 'search' + | 'task-map' | 'overview'; export interface WorkspacePathInput { diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 66648984a..974fb2ee7 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -10,6 +10,7 @@ describe('cli/help/command', () => { expect(result.output).not.toContain('codegraphy batch'); expect(result.output).toContain('codegraphy settings'); expect(result.output).toContain('codegraphy search '); + expect(result.output).toContain('codegraphy map '); expect(result.output).toContain('codegraphy query '); expect(result.output).toContain('codegraphy dependencies '); expect(result.output).toContain('codegraphy path '); @@ -54,6 +55,9 @@ describe('cli/help/command', () => { expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); expect(createHelpResult(['search']).output).toContain('all-term path/source ranking'); + expect(createHelpResult(['map']).output).toContain('Usage: codegraphy map'); + expect(createHelpResult(['map']).output).toContain('personalized File map'); + expect(createHelpResult(['map']).output).toContain('typed Relationships'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index 05a5a42f1..bc11e29a7 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -21,6 +21,12 @@ describe('cli/parseQuery', () => { report: 'search', arguments: { pattern: 'render settings', limit: 20 }, }); + expect(parseQueryCommand(['map', 'render settings'])).toEqual({ + name: 'query', + invokedCommand: 'map', + report: 'task-map', + arguments: { query: 'render settings', limit: 8 }, + }); expect(parseQueryCommand(['query', 'src/cli/command.ts'])).toEqual({ name: 'query', invokedCommand: 'query', @@ -119,6 +125,10 @@ describe('cli/parseQuery', () => { invokedCommand: 'search', parseError: 'search pattern must contain a literal character', }); + expect(parseQueryCommand(['map'])).toMatchObject({ + invokedCommand: 'map', + parseError: 'map requires ', + }); expect(parseQueryCommand(['query'])).toMatchObject({ invokedCommand: 'query', parseError: 'query requires ', diff --git a/packages/core/tests/graphQuery/taskMap.test.ts b/packages/core/tests/graphQuery/taskMap.test.ts new file mode 100644 index 000000000..d48f1b40c --- /dev/null +++ b/packages/core/tests/graphQuery/taskMap.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import type { GraphQueryData } from '../../src/graphQuery/data'; +import { mapGraphTask } from '../../src/graphQuery/taskMap'; + +const data: GraphQueryData = { + graphData: { + nodes: [ + { id: 'src/registry.ts', label: 'registry.ts', nodeType: 'file' }, + { id: 'src/engine.ts', label: 'engine.ts', nodeType: 'file' }, + { id: 'src/workspace.ts', label: 'workspace.ts', nodeType: 'file' }, + { id: 'src/unrelated.ts', label: 'unrelated.ts', nodeType: 'file' }, + { id: 'tests/registry.test.ts', label: 'registry.test.ts', nodeType: 'file' }, + { + id: 'src/registry.ts#unloadPlugin:function', + label: 'unloadPlugin', + nodeType: 'symbol:function', + symbol: { id: 'src/registry.ts#unloadPlugin:function', filePath: 'src/registry.ts', name: 'unloadPlugin', kind: 'function' }, + }, + { + id: 'src/engine.ts#rebuild:function', + label: 'rebuild', + nodeType: 'symbol:function', + symbol: { id: 'src/engine.ts#rebuild:function', filePath: 'src/engine.ts', name: 'rebuild', kind: 'function' }, + }, + ], + edges: [ + { id: 'engine-registry', from: 'src/engine.ts#rebuild:function', to: 'src/registry.ts#unloadPlugin:function', kind: 'call', sources: [] }, + { id: 'workspace-engine', from: 'src/workspace.ts', to: 'src/engine.ts', kind: 'import', sources: [] }, + { id: 'test-registry', from: 'tests/registry.test.ts', to: 'src/registry.ts', kind: 'import', sources: [] }, + ], + }, + symbols: [ + { id: 'src/registry.ts#unloadPlugin:function', filePath: 'src/registry.ts', name: 'unloadPlugin', kind: 'function' }, + { id: 'src/engine.ts#rebuild:function', filePath: 'src/engine.ts', name: 'rebuild', kind: 'function' }, + ], + sourceText: { + files: [ + { filePath: 'src/registry.ts', content: 'export async function unloadPlugin() { /* plugin runtime cleanup */ }' }, + { filePath: 'src/engine.ts', content: 'export function rebuild() {}' }, + { filePath: 'src/workspace.ts', content: 'export function loadWorkspaceLifecycle() {}' }, + { filePath: 'src/unrelated.ts', content: 'export const unrelated = true;' }, + { filePath: 'tests/registry.test.ts', content: 'it("cleans plugin runtime after failure", () => {});' }, + ], + filesScanned: 5, + filesSkipped: 0, + }, + cacheState: 'fresh', +}; + +describe('core/graphQuery task map', () => { + it('combines task terms, declarations, and typed graph links in a small File map', () => { + expect(mapGraphTask(data, { + query: 'plugin runtime cleanup during workspace loading failure', + limit: 4, + })).toEqual({ + query: 'plugin runtime cleanup during workspace loading failure', + terms: ['plugin', 'runtime', 'cleanup', 'workspace', 'loading', 'failure'], + files: [ + { + path: 'src/workspace.ts', + nodeType: 'file', + matchedTerms: ['workspace', 'loading'], + symbols: [], + }, + { + path: 'src/registry.ts', + nodeType: 'file', + matchedTerms: ['plugin', 'runtime', 'cleanup'], + symbols: [{ id: 'src/registry.ts#unloadPlugin:function', name: 'unloadPlugin', kind: 'function' }], + }, + { + path: 'tests/registry.test.ts', + nodeType: 'file', + matchedTerms: ['plugin', 'runtime', 'failure'], + symbols: [], + }, + { + path: 'src/engine.ts', + nodeType: 'file', + matchedTerms: [], + symbols: [{ id: 'src/engine.ts#rebuild:function', name: 'rebuild', kind: 'function' }], + }, + ], + relationships: [ + { from: 'src/engine.ts', to: 'src/registry.ts', edgeTypes: ['call'] }, + { from: 'src/workspace.ts', to: 'src/engine.ts', edgeTypes: ['import'] }, + { from: 'tests/registry.test.ts', to: 'src/registry.ts', edgeTypes: ['import'] }, + ], + page: { offset: 0, limit: 4, returned: 4, total: 4, nextOffset: null }, + limits: { relationships: 12, complete: true }, + sources: { + text: { freshness: 'live', filesScanned: 5, filesSkipped: 0 }, + graph: { freshness: 'cached', cacheState: 'fresh' }, + }, + }); + }); + + it('reports truncation when the File map omits ranked candidates', () => { + const report = mapGraphTask(data, { query: 'plugin runtime failure', limit: 1 }); + + expect(report.page).toMatchObject({ limit: 1, returned: 1, total: 3, nextOffset: 1 }); + expect(report.limits.complete).toBe(false); + expect(report.relationships).toEqual([]); + }); +}); diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index d06969f2e..47be08ae5 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -45,6 +45,34 @@ describe('workspace/requestQuery', () => { }); }); + it('builds a task-personalized File map from live terms and cached Relationships', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-task-map-')); + await fs.writeFile(path.join(workspaceRoot, 'setting.ts'), 'export function readSetting(): string { return "value"; }\n'); + await fs.writeFile(path.join(workspaceRoot, 'command.ts'), [ + "import { readSetting } from './setting';", + 'export function runCommand(): string { return readSetting(); }', + '', + ].join('\n')); + await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); + + const result = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'task-map', + arguments: { query: 'setting command', limit: 4 }, + }); + + expect(result).toMatchObject({ + terms: ['setting', 'command'], + files: [ + { path: 'command.ts', matchedTerms: ['setting', 'command'] }, + { path: 'setting.ts', matchedTerms: ['setting'] }, + ], + relationships: [{ from: 'command.ts', to: 'setting.ts', edgeTypes: expect.arrayContaining(['import']) }], + limits: { complete: true }, + sources: { text: { freshness: 'live' }, graph: { freshness: 'cached', cacheState: 'fresh' } }, + }); + }); + it('uses complete graph scope for an exact targeted Relationship query', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-target-scope-')); await fs.writeFile(path.join(workspaceRoot, 'dependency.ts'), [ diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index bc113cd6b..4725d0358 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -24,6 +24,7 @@ Most Symbols and Relationships are cached. Search also reads current source text | Command | Information returned | |---|---| | `search ` | One ranked result set merging live source locations, cached AST Symbols, and indexed Nodes. | +| `map ` | A compact task-personalized File map with matched terms, selected declarations, and typed connecting Relationships. | | `query ` | A bounded overview of one exact File or Symbol Node, with prioritized declarations and incoming and outgoing Relationships. | | `nodes` | A paginated Node inventory from the shaped graph. | | `edges` | A paginated Relationship inventory from the shaped graph. | diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 6b6739e3f..9c5eb62d3 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -14,7 +14,7 @@ test('the generalized CodeGraphy skill teaches the lifecycle and delegates synta const lifecyclePositions = lifecycle.map(term => skill.indexOf(term)); assert.ok(lifecyclePositions.every(position => position >= 0)); assert.deepEqual(lifecyclePositions, [...lifecyclePositions].sort((left, right) => left - right)); - for (const command of ['nodes', 'search', 'query', 'edges', 'dependencies', 'dependents', 'path']) { + for (const command of ['nodes', 'search', 'map', 'query', 'edges', 'dependencies', 'dependents', 'path']) { assert.match(skill, new RegExp(`\\b${command}\\b`)); } assert.match(skill, /codegraphy --help/); From 96704b699116617cd79bc1a2d9ef64720be20b33 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 20:18:40 -0700 Subject: [PATCH 45/55] perf(cli): diversify task map source areas --- packages/core/src/graphQuery/taskMap.ts | 44 ++++++++++++++----------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/core/src/graphQuery/taskMap.ts b/packages/core/src/graphQuery/taskMap.ts index 937cac513..42a438078 100644 --- a/packages/core/src/graphQuery/taskMap.ts +++ b/packages/core/src/graphQuery/taskMap.ts @@ -19,8 +19,8 @@ const STOP_WORDS = new Set([ interface TaskDocument { node: IGraphNode; - pathTerms: readonly string[]; - textTerms: readonly string[]; + pathText: string; + sourceText: string; } interface RankedTaskFile { @@ -46,8 +46,13 @@ function termVariants(term: string): string[] { return [...variants]; } -function includesTerm(terms: readonly string[], queryTerm: string): boolean { - return termVariants(queryTerm).some(term => terms.includes(term)); +function normalizeSearchText(value: string): string { + const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2').toLocaleLowerCase(); + return ` ${separated.replace(/[^\p{L}\d]+/gu, ' ')} `; +} + +function includesTerm(value: string, queryTerm: string): boolean { + return termVariants(queryTerm).some(term => value.includes(` ${term} `)); } function createDocuments(data: GraphQueryData): TaskDocument[] { @@ -56,7 +61,11 @@ function createDocuments(data: GraphQueryData): TaskDocument[] { .map(node => [node.id, node])); return (data.sourceText?.files ?? []).flatMap(({ filePath, content }) => { const node = files.get(filePath); - return node ? [{ node, pathTerms: tokenize(filePath), textTerms: tokenize(content) }] : []; + return node ? [{ + node, + pathText: normalizeSearchText(filePath), + sourceText: normalizeSearchText(content), + }] : []; }); } @@ -64,7 +73,7 @@ function selectTerms(query: string, documents: readonly TaskDocument[]): string[ const candidates = [...new Set(tokenize(query))]; const frequency = new Map(candidates.map(term => [ term, - documents.filter(document => includesTerm(document.pathTerms, term) || includesTerm(document.textTerms, term)).length, + documents.filter(document => includesTerm(document.pathText, term) || includesTerm(document.sourceText, term)).length, ])); return candidates .filter(term => (frequency.get(term) ?? 0) > 0) @@ -75,11 +84,6 @@ function selectTerms(query: string, documents: readonly TaskDocument[]): string[ .map(item => item.term); } -function countTerm(terms: readonly string[], target: string): number { - const variants = new Set(termVariants(target)); - return terms.reduce((total, term) => total + (variants.has(term) ? 1 : 0), 0); -} - function lexicalRank( document: TaskDocument, queryTerms: readonly string[], @@ -87,12 +91,12 @@ function lexicalRank( documentCount: number, ): { matchedTerms: string[]; score: number } { const matchedTerms = queryTerms.filter(term => ( - includesTerm(document.pathTerms, term) || includesTerm(document.textTerms, term) + includesTerm(document.pathText, term) || includesTerm(document.sourceText, term) )); const score = matchedTerms.reduce((total, term) => { const inverseFrequency = Math.log((documentCount + 1) / ((frequencies.get(term) ?? 0) + 1)) + 1; - const pathMatch = countTerm(document.pathTerms, term) > 0; - const textMatch = countTerm(document.textTerms, term) > 0; + const pathMatch = includesTerm(document.pathText, term); + const textMatch = includesTerm(document.sourceText, term); return total + inverseFrequency * (pathMatch ? 4 : textMatch ? 1 : 0); }, 0); const isTest = /(?:^|\/)(?:__tests__|tests?)(?:\/|\.)|\.(?:spec|test)\.[^/]+$/iu.test(document.node.id); @@ -157,8 +161,10 @@ function personalizedPageRank( function rankingGroup(filePath: string): string { const segments = filePath.split('/'); const sourceIndex = segments.findIndex(segment => segment === 'src' || segment === 'tests'); - const end = sourceIndex >= 0 ? sourceIndex + 3 : Math.min(segments.length, 4); - return segments.slice(0, end).join('/'); + if (sourceIndex < 0) return segments.slice(0, Math.min(segments.length, 4)).join('/'); + const sourceArea = segments[sourceIndex + 1]; + const areaDepth = sourceArea === 'extension' || sourceArea === 'webview' ? 2 : 1; + return segments.slice(0, sourceIndex + 1 + areaDepth).join('/'); } function balanceSourceAreas(ranked: readonly RankedTaskFile[]): RankedTaskFile[] { @@ -229,19 +235,19 @@ export function mapGraphTask( const terms = selectTerms(config.query, documents); const frequencies = new Map(terms.map(term => [ term, - documents.filter(document => includesTerm(document.pathTerms, term) || includesTerm(document.textTerms, term)).length, + documents.filter(document => includesTerm(document.pathText, term) || includesTerm(document.sourceText, term)).length, ])); const lexical = new Map(documents.map(document => [ document.node.id, lexicalRank(document, terms, frequencies, documents.length), ])); const filePaths = new Set(documents.map(document => document.node.id)); + const links = createFileLinks(data, filePaths); const graphRanks = personalizedPageRank( - createFileLinks(data, filePaths), + links, new Map([...lexical].map(([path, rank]) => [path, rank.score])), ); const connected = new Set(); - const links = createFileLinks(data, filePaths); for (const [path, rank] of lexical) { if (rank.score <= 0) continue; connected.add(path); From 9fdf2b1796e83e33847e861018d2254b3afeeec2 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 20:21:18 -0700 Subject: [PATCH 46/55] docs(cli): record personalized map experiments --- CONTEXT.md | 2 +- ...quire-structural-work-and-tool-adoption.md | 15 ++- ...lized-task-maps-reduce-agent-navigation.md | 100 ++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md diff --git a/CONTEXT.md b/CONTEXT.md index 0ea664fe0..bdad3d722 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -115,7 +115,7 @@ The Graph View can use a whole-view loading state before its first graph payload | **Core Plugin API** | `@codegraphy-dev/plugin-api` contracts for headless Core analysis and semantic graph extensions. | | **Extension Plugin API** | `@codegraphy-dev/extension-plugin-api` contracts for VS Code Extension and Graph View extensions. | -The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh structural feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric. A CLI candidate must also be selected by treatment agents before aggregate differences can be attributed to it; see ADR 0011 and ADR 0012. +The CLI never searches parent directories for a workspace. Indexing and exploration are separate operations, so `search` and `query` never perform Indexing. CLI Search reads source text live from eligible indexed File Nodes and labels cached Symbol provenance as fresh or stale. When Indexing reports a file-budget cap, agents can inspect or raise `maxFiles`, adjust durable filters, and explicitly re-index before querying. Persisted known settings fields are validated strictly; malformed input is reported and never replaced by defaults during mutation. The Agent Skill describes available evidence and its tradeoffs without prescribing a command sequence, call budget, search wording, or stopping rule. The agent chooses how CodeGraphy fits the task. Clean implementation benchmarks compare raw agents with CodeGraphy-plus-skill agents on the same fresh structural feature-change fixture, using three repetitions per condition and cumulative model tokens per correct task as the primary acceptance metric. A CLI candidate must also be selected by treatment agents before aggregate differences can be attributed to it. Personalized Task Map passed a direct adopted A/B after the required raw comparison; see ADR 0011, ADR 0012, and ADR 0013. ## Plugins diff --git a/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md b/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md index f8ce3d119..767ac5cee 100644 --- a/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md +++ b/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md @@ -6,7 +6,7 @@ ADR 0011 established fresh three-versus-three implementation benchmarks and a generalized, non-prescriptive Agent Skill. The first candidate task added `path --max-depth`. All six agents implemented it correctly, but only one of three CodeGraphy agents made one CLI help call. The task named the exact command and reduced to local parser/help work, so its token difference could not measure graph navigation. -A connection-aware offline graph-document feature was broader, but its first prompt named the owning tldraw package. After removing that ownership leak, every agent still localized the task lexically. In the frozen lexical round, CodeGraphy agents made zero CLI calls and regressed against raw agents despite equal 3/3 correctness: +A connection-aware offline graph-document feature was broader, but its first prompt named the owning tldraw package. After removing that ownership leak, agents still localized the task primarily through lexical evidence. Corrected telemetry later found that one treatment agent made two CodeGraphy calls through `pnpm codegraphy`; the original counter recognized only a bare executable. Treatment still regressed against raw agents despite equal 3/3 correctness: | Mean | Raw | CodeGraphy + skill | Delta | |---|---:|---:|---:| @@ -14,7 +14,7 @@ A connection-aware offline graph-document feature was broader, but its first pro | Tool calls | 22.0 | 28.7 | 30.3% more | | Tool output | 123,569 B | 140,578 B | 13.8% more | | Elapsed | 126.0 s | 135.1 s | 7.2% slower | -| CodeGraphy calls | 0 | 0 | no adoption | +| CodeGraphy calls | 0 | 0.7 | one of three adopted | The task was therefore rejected as a stable graph benchmark. It primarily measured the overhead and model variance of loading a skill around work that ordinary lexical search already solved directly. @@ -65,13 +65,17 @@ Triage reduced observation bytes, but successful trajectories consumed more mode An `impact ` prototype traversed incoming Relationships from one exact File or Symbol to depth one through four, projected results to Files, and reported shortest distance, typed reasons, pagination, visited Nodes, and completeness. Runtime call/import reasons ranked ahead of type-only consumers. On the structural fixture, a 1.4 KB result around `plugins/registry.ts` placed `engine.ts`, `engineSetup.ts`, and `workspace.ts` in the first page. -All three treatment agents read the updated skill but none selected Impact or any other CodeGraphy command. Raw correctness was 3/3 versus treatment 2/3. Mean correct-run tokens increased from 318,247 to 832,347, calls increased 60.3%, output increased 28.2%, and elapsed increased 65.7%. These trajectory regressions cannot be attributed to unused Impact output, but non-adoption fails the public-interface gate. The implementation, tests, documentation, and changeset update were reverted. +All three treatment agents read the updated skill and none selected Impact. Corrected telemetry found three ordinary CodeGraphy Search calls in one successful treatment run. Raw correctness was 3/3 versus treatment 2/3. Mean correct-run tokens increased from 318,247 to 832,347, calls increased 60.3%, output increased 28.2%, and elapsed increased 65.7%. These trajectory regressions cannot be attributed to unused Impact output, but non-adoption fails the public-interface gate. The implementation, tests, documentation, and changeset update were reverted. + +### Compact capability reference + +A subtraction candidate reduced the neutral capability reference from 6,082 to 4,051 bytes without adding workflow policy. It increased voluntary CodeGraphy adoption to two of three treatment runs, with four Search calls in each adopted run. Both adopted runs omitted the required bulk-disposal API and failed; the only successful treatment did not invoke CodeGraphy. Treatment correctness fell to 1/3 from raw 3/3, and correct-run tokens increased 40.4%. The shorter skill and its release assertion were reverted. ## Decision Retain the neutral generalized Agent Skill from ADR 0011. It explains the graph model, lifecycle, command semantics, output, freshness, shaping, and limitations without prescribing a workflow. Do not retain Triage or evidence-first comparative framing. -Use the headless Plugin-runtime disposal task as the current structural benchmark. Report both all-run and correct-run token statistics. A CLI candidate must be invoked by treatment agents before a result can be attributed to that candidate. Non-adoption is a failed interface experiment even when aggregate model variance favors the treatment. +Retain the headless Plugin-runtime disposal task as a harder confirmation benchmark, and select an independently viable relationship task before screening new candidates. Report both all-run and correct-run token statistics. A CLI candidate must be invoked by treatment agents before a result can be attributed to that candidate. Non-adoption is a failed interface experiment even when aggregate model variance favors the treatment. Correctness remains a gate. Lower output bytes, elapsed time, or all-run mean tokens do not justify a candidate whose correct-run tokens regress or whose correctness drops. @@ -84,7 +88,8 @@ Three repetitions remain a fast screening instrument rather than confirmation. R - New commands must earn both discoverability and successful-trajectory value. - Skill wording is itself an intervention and can increase exhaustive ordinary exploration even without CLI use. - Tool output reduction is diagnostic; cumulative model tokens per correct implementation remain primary. -- Watcher, neighbor, conflict, inference, fuzzy, hub, and budgeted-map experiments remain candidates, but each must use the same structural task and adoption gate; the tested incoming Impact command is rejected. +- Watcher, neighbor, conflict, inference, fuzzy, and hub experiments remain candidates under the same adoption and correctness gate; the tested incoming Impact command is rejected. +- ADR 0013 records the selected relationship fixture and accepted personalized Task Map. ## References diff --git a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md new file mode 100644 index 000000000..5aff73617 --- /dev/null +++ b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md @@ -0,0 +1,100 @@ +# Personalized task maps reduce agent navigation + +**Status:** Accepted + +## Context + +ADR 0012 required structural coding work, hidden behavioral grading, correct-run token accounting, and actual candidate adoption. Repeated use of the Plugin-runtime disposal fixture then saturated that one task. A second historical fixture was needed to distinguish a generally useful graph action from benchmark-specific steering. + +The first replacement candidate, complete-graph cache persistence from parent `daa5b9dff`, was rejected as a benchmark. The untouched parent failed six hidden behaviors and the historical patch passed all sixteen focused tests, but no low-thinking agent completed both the Core save path and every Extension persistence path. A tightened one-versus-one viability pilot also produced 0/2 correctness. The task was too broad for rapid screening. + +The selected relationship fixture starts from parent `e93ac48a`. It contains two independent background Plugin failures: scheduled extension-host graph work and Webview Plugin asset loading. Hidden tests require both rejected Promises to be observed, contextual errors to retain the original failure, and asset-load completion to run after rejection. The grader initially required historical log wording; that implementation-coupled assertion was replaced by behavioral checks. The untouched parent remains red, the historical patch and both independent pilot solutions pass, and all repository Markdown, VCS history, sessions, answers, and benchmark infrastructure remain outside agent workspaces. + +The corrected benchmark invocation counter recognizes direct `codegraphy`, package-manager launchers such as `pnpm codegraphy` and `pnpm exec codegraphy`, and direct Node launchers. Earlier reports understated existing CLI adoption when agents used package-manager scripts. + +With the existing CLI and neutral shipped skill unchanged, the relationship fixture established 3/3 correctness in both arms: + +| Mean | Raw | Existing CLI + skill | Delta | +|---|---:|---:|---:| +| Model tokens | 278,262 | 238,049 | 14.5% fewer | +| Median tokens | 270,199 | 147,307 | 45.5% fewer | +| Tool calls | 19.0 | 25.0 | 31.6% more | +| Tool output | 170,084 B | 92,201 B | 45.8% fewer | +| Elapsed | 108.8 s | 89.8 s | 17.5% faster | +| CodeGraphy adoption | 0/3 | 3/3 | 2.7 calls/run | + +This established that agents voluntarily select the existing CLI on a task that remains objectively solvable by raw navigation. + +## Candidate + +`codegraphy map ` accepts task or issue text and returns a bounded personalized File map. It: + +- extracts independently useful non-stopword terms with light identifier stemming; +- scores term presence with lexical rarity rather than term frequency; +- combines live eligible source with cached File, Symbol, and typed Relationship facts; +- uses personalized PageRank to retain connected Files; +- balances source areas so one dense subsystem does not consume the complete result; +- includes at most three selected declarations per File and twelve typed Relationships among returned Files; +- reports pagination, truncation completeness, live-source freshness, and Graph Cache freshness; +- returns no source excerpts, inferred answers, hidden workflow policy, or model-specific token estimates. + +On the selected fixture, one 5.4 KB response places both required production Files in its first six results. On the harder disposal fixture, source-area balancing exposes the Core Indexing runtime and Plugin Registry rather than filling the page with sibling engine modules. + +## Benchmark results + +### Required raw comparison + +The Map candidate retained 3/3 correctness in both arms. Candidate treatment reduced mean tokens and output, but Map itself was selected in only one of three runs, so the aggregate result was not sufficient attribution: + +| Mean | Raw | Map CLI + skill | Delta | +|---|---:|---:|---:| +| Model tokens | 281,602 | 205,919 | 26.9% fewer | +| Median tokens | 198,193 | 209,454 | 5.7% more | +| Tool calls | 19.3 | 27.0 | 39.7% more | +| Tool output | 126,647 B | 84,756 B | 33.1% fewer | +| Elapsed | 80.7 s | 89.5 s | 10.9% slower | +| Map adoption | 0/3 | 1/3 | partial | + +### Direct candidate A/B + +A fresh direct comparison then held CLI access, skill installation, pre-Indexing, model, prompt, hidden grader, and workspace isolation constant. The control used pre-Map commit `93ffde349`; the treatment used the Map candidate. Both arms were 3/3 correct, and treatment agents selected Map in two of three runs: + +| Mean | Existing CLI + skill | Map CLI + skill | Delta | +|---|---:|---:|---:| +| Model tokens | 257,334 | 167,940 | **34.7% fewer** | +| Median tokens | 243,105 | 159,426 | **34.4% fewer** | +| Tool calls | 32.0 | 21.0 | **34.4% fewer** | +| CodeGraphy calls | 5.0 | 3.3 | **33.3% fewer** | +| Tool output | 115,915 B | 71,745 B | **38.1% fewer** | +| Elapsed | 139.9 s | 97.2 s | **30.5% faster** | +| Correctness | 3/3 | 3/3 | equal | + +The Map-only treatment trajectory completed correctly with one CodeGraphy call, twenty total calls, and 220,943 model tokens. Another trajectory used Map after two Search calls and completed with 123,451 tokens. + +### Harder-task confirmation + +A fresh disposal-fixture confirmation retained equal 2/3 correctness. Correct-run mean tokens were 246,332 for CLI plus skill versus 685,081 raw, a 64.0% reduction, but no treatment agent selected Map. This confirms existing CLI value on that round, not Map value. It also shows that Map is task-selective rather than a required replacement for Search. + +## Decision + +Retain `codegraphy map ` as a public bounded query. Its selected direct A/B passed adoption, correctness, and correct-run token gates while also reducing calls, output, and elapsed time. + +Keep Search, exact Target Query, dependencies, dependents, and Path unchanged. Map is another evidence surface, not a prescribed first action or universal navigation policy. The Agent Skill describes its output semantics but does not specify command order, call counts, task wording, or permission conditions. + +Treat all measurements as small-sample evidence. Three-versus-three runs screen candidates; they do not establish universal superiority. Future confirmation should use the relationship fixture plus at least one additional independently viable structural task. + +## Consequences + +- Candidate attribution can use an additional direct CLI-control A/B after the required raw comparison when general CLI behavior already differs from raw navigation. +- Viability pilots may reject benchmark fixtures before a full candidate run, but product iterations still require three runs per compared condition. +- Hidden graders must assert behavior rather than historical wording or decomposition. +- Invocation telemetry must recognize equivalent executable launch forms. +- Complete-graph persistence remains an unsuitable rapid screen despite being a valid historical feature. +- Incoming Impact, Triage, connected Search augmentation, BM25 fallback, phrase-line evidence, compact skill subtraction, and default Leiden ranking remain rejected. + +## References + +- ADR 0011, generalized skill and fresh implementation benchmarks +- ADR 0012, structural work and adoption gate +- Aider repository map implementation and personalized PageRank +- RepoGraph and LocAgent single-action graph localization interfaces From f3f7102d00fd99324669887ae51f41016d2ffead Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 20:33:44 -0700 Subject: [PATCH 47/55] fix(cli): harden personalized task maps --- ...lized-task-maps-reduce-agent-navigation.md | 2 +- packages/core/src/cli/help/command.ts | 2 +- packages/core/src/cli/parseQuery.ts | 7 +- packages/core/src/graphQuery/execute.ts | 5 +- packages/core/src/graphQuery/taskMap.ts | 118 ++++++++++-------- .../core/src/graphQuery/taskMap/pagerank.ts | 36 ++++++ packages/core/src/index.ts | 4 + packages/core/tests/cli/parseQuery.test.ts | 4 + .../core/tests/graphQuery/taskMap.test.ts | 56 +++++++-- .../tests/graphQuery/taskMap/pagerank.test.ts | 19 +++ .../core/tests/workspace/requestQuery.test.ts | 9 ++ 11 files changed, 197 insertions(+), 65 deletions(-) create mode 100644 packages/core/src/graphQuery/taskMap/pagerank.ts create mode 100644 packages/core/tests/graphQuery/taskMap/pagerank.test.ts diff --git a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md index 5aff73617..5ef04abb5 100644 --- a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md +++ b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md @@ -34,7 +34,7 @@ This established that agents voluntarily select the existing CLI on a task that - combines live eligible source with cached File, Symbol, and typed Relationship facts; - uses personalized PageRank to retain connected Files; - balances source areas so one dense subsystem does not consume the complete result; -- includes at most three selected declarations per File and twelve typed Relationships among returned Files; +- returns eight Files by default, rejects CLI bounds above twenty, and includes at most three selected declarations per File and twelve typed Relationships among returned Files; - reports pagination, truncation completeness, live-source freshness, and Graph Cache freshness; - returns no source excerpts, inferred answers, hidden workflow policy, or model-specific token estimates. diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index 528b32bc3..cca649285 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -169,7 +169,7 @@ const COMMAND_HELP: Record = { 'Effects: Read-only. Does not perform Indexing; source text is read live from indexed files.', 'Output: Bounded JSON Files, matched terms, declarations, typed Relationships, freshness, and completeness.', 'Options:', - ' --limit Maximum Files to return (default: 8)', + ' --limit Maximum Files to return (default: 8; range: 1-20)', ' --offset Zero-based File offset (default: 0)', ...QUERY_PROJECTION_OPTIONS, "Example: codegraphy map 'plugin cleanup after workspace failure'", diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index 433479614..5e5596bc4 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -15,6 +15,7 @@ const QUERY_COMMANDS = new Set([ const DEFAULT_LIMIT = 100; const DEFAULT_SEARCH_LIMIT = 20; const DEFAULT_TASK_MAP_LIMIT = 8; +const MAX_TASK_MAP_LIMIT = 20; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; @@ -197,7 +198,11 @@ function buildSearch(input: QueryBuilderInput): CliCommand { function buildTaskMap(input: QueryBuilderInput): CliCommand { const invalid = requireOperands(input.command, input.operands, 1, ''); - return invalid ?? query(input.command, 'task-map', { query: input.operands[0], ...input.page }, input.projection); + if (invalid) return invalid; + if (input.page.limit > MAX_TASK_MAP_LIMIT) { + return parseError(input.command, `map --limit must be between 1 and ${MAX_TASK_MAP_LIMIT}`); + } + return query(input.command, 'task-map', { query: input.operands[0], ...input.page }, input.projection); } function buildOverview(input: QueryBuilderInput): CliCommand { diff --git a/packages/core/src/graphQuery/execute.ts b/packages/core/src/graphQuery/execute.ts index f766a6f90..92654fa32 100644 --- a/packages/core/src/graphQuery/execute.ts +++ b/packages/core/src/graphQuery/execute.ts @@ -42,7 +42,10 @@ const GRAPH_QUERY_HANDLERS: GraphQueryHandlers = { symbols: (data, args) => listGraphSymbols(data, args), paths: (data, args) => findGraphPaths(deriveScopedGraphQueryData(data.graphData, args), args), search: (data, args) => searchGraph(data, args), - 'task-map': (data, args) => mapGraphTask(data, args), + 'task-map': (data, args) => mapGraphTask({ + ...data, + graphData: deriveScopedGraphQueryData(data.graphData, args), + }, args), overview: (data, args) => inspectGraphTarget(data, args), }; diff --git a/packages/core/src/graphQuery/taskMap.ts b/packages/core/src/graphQuery/taskMap.ts index 42a438078..bca204cee 100644 --- a/packages/core/src/graphQuery/taskMap.ts +++ b/packages/core/src/graphQuery/taskMap.ts @@ -6,12 +6,13 @@ import type { GraphQueryTaskMapReport, } from './model'; import { paginate } from './pagination'; +import { rankTaskMapGraph } from './taskMap/pagerank'; const MAX_QUERY_TERMS = 16; +const DEFAULT_FILES = 8; +const MAX_FILES = 20; const MAX_SYMBOLS_PER_FILE = 3; const MAX_RELATIONSHIPS = 12; -const PAGE_RANK_ITERATIONS = 20; -const PAGE_RANK_DAMPING = 0.85; const STOP_WORDS = new Set([ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'during', 'for', 'from', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with', @@ -27,6 +28,7 @@ interface RankedTaskFile { file: GraphQueryTaskMapFile; lexicalScore: number; graphScore: number; + score: number; } function tokenize(value: string): string[] { @@ -36,13 +38,33 @@ function tokenize(value: string): string[] { } function termVariants(term: string): string[] { - const variants = new Set([term]); - if (term.endsWith('ing') && term.length > 5) variants.add(term.slice(0, -3)); + const roots = new Set([term]); + if (term.endsWith('ing') && term.length > 5) { + const root = term.slice(0, -3); + roots.add(root); + if (root.at(-1) === root.at(-2)) roots.add(root.slice(0, -1)); + } if (term.endsWith('ed') && term.length > 4) { - variants.add(term.slice(0, -2)); - variants.add(term.slice(0, -1)); + roots.add(term.slice(0, -2)); + roots.add(term.slice(0, -1)); + } + if (term.endsWith('s') && term.length > 4) roots.add(term.slice(0, -1)); + + const variants = new Set(roots); + for (const root of roots) { + variants.add(`${root}s`); + if (root.endsWith('e')) { + variants.add(`${root}d`); + variants.add(`${root.slice(0, -1)}ing`); + } else { + variants.add(`${root}ed`); + variants.add(`${root}ing`); + if (/[^aeiou]$/u.test(root)) { + variants.add(`${root}${root.at(-1)}ed`); + variants.add(`${root}${root.at(-1)}ing`); + } + } } - if (term.endsWith('s') && term.length > 4) variants.add(term.slice(0, -1)); return [...variants]; } @@ -132,36 +154,13 @@ function createFileLinks(data: GraphQueryData, filePaths: ReadonlySet): return links; } -function personalizedPageRank( - links: ReadonlyMap>, - personalization: ReadonlyMap, -): Map { - const paths = [...links.keys()]; - const totalPersonalization = [...personalization.values()].reduce((total, value) => total + value, 0); - const normalized = new Map(paths.map(path => [ - path, - totalPersonalization > 0 ? (personalization.get(path) ?? 0) / totalPersonalization : 1 / Math.max(paths.length, 1), - ])); - let ranks = new Map(normalized); - for (let iteration = 0; iteration < PAGE_RANK_ITERATIONS; iteration += 1) { - const next = new Map(paths.map(path => [path, (1 - PAGE_RANK_DAMPING) * (normalized.get(path) ?? 0)])); - for (const path of paths) { - const neighbors = links.get(path) ?? new Map(); - const totalWeight = [...neighbors.values()].reduce((total, weight) => total + weight, 0); - if (totalWeight === 0) continue; - for (const [neighbor, weight] of neighbors) { - next.set(neighbor, (next.get(neighbor) ?? 0) + PAGE_RANK_DAMPING * (ranks.get(path) ?? 0) * weight / totalWeight); - } - } - ranks = next; - } - return ranks; -} - function rankingGroup(filePath: string): string { const segments = filePath.split('/'); const sourceIndex = segments.findIndex(segment => segment === 'src' || segment === 'tests'); if (sourceIndex < 0) return segments.slice(0, Math.min(segments.length, 4)).join('/'); + if (sourceIndex + 1 >= segments.length - 1) { + return segments.slice(0, sourceIndex + 1).join('/'); + } const sourceArea = segments[sourceIndex + 1]; const areaDepth = sourceArea === 'extension' || sourceArea === 'webview' ? 2 : 1; return segments.slice(0, sourceIndex + 1 + areaDepth).join('/'); @@ -171,15 +170,17 @@ function balanceSourceAreas(ranked: readonly RankedTaskFile[]): RankedTaskFile[] const groups = new Map(); for (const item of ranked) { const group = rankingGroup(item.file.path); - groups.set(group, [...(groups.get(group) ?? []), item]); + const items = groups.get(group) ?? []; + items.push(item); + groups.set(group, items); } const ordered = [...groups.entries()].sort((left, right) => { const leftRank = left[1][0]; const rightRank = right[1][0]; if (!leftRank || !rightRank) return left[0].localeCompare(right[0]); return Number(rightRank.lexicalScore > 0) - Number(leftRank.lexicalScore > 0) + || rightRank.score - leftRank.score || rightRank.lexicalScore - leftRank.lexicalScore - || rightRank.graphScore - leftRank.graphScore || left[0].localeCompare(right[0]); }); const balanced: RankedTaskFile[] = []; @@ -192,16 +193,23 @@ function balanceSourceAreas(ranked: readonly RankedTaskFile[]): RankedTaskFile[] return balanced; } -function symbolsForFile(data: GraphQueryData, filePath: string): GraphQueryTaskMapFile['symbols'] { - return (data.symbols ?? []) - .filter(symbol => symbol.filePath === filePath) - .sort((left, right) => left.name.localeCompare(right.name) || (left.id ?? '').localeCompare(right.id ?? '')) - .slice(0, MAX_SYMBOLS_PER_FILE) - .map(symbol => ({ +function indexSymbols(data: GraphQueryData): Map { + const symbols = new Map(); + for (const symbol of data.symbols ?? []) { + const fileSymbols = symbols.get(symbol.filePath) ?? []; + fileSymbols.push({ ...(symbol.id ? { id: symbol.id } : {}), name: symbol.name, ...(symbol.kind ? { kind: symbol.kind } : {}), - })); + }); + symbols.set(symbol.filePath, fileSymbols); + } + for (const [filePath, fileSymbols] of symbols) { + symbols.set(filePath, fileSymbols + .sort((left, right) => left.name.localeCompare(right.name) || (left.id ?? '').localeCompare(right.id ?? '')) + .slice(0, MAX_SYMBOLS_PER_FILE)); + } + return symbols; } function selectedRelationships( @@ -231,8 +239,9 @@ export function mapGraphTask( data: GraphQueryData, config: GraphQueryTaskMapConfig, ): GraphQueryTaskMapReport { + const query = typeof config.query === 'string' ? config.query : ''; const documents = createDocuments(data); - const terms = selectTerms(config.query, documents); + const terms = selectTerms(query, documents); const frequencies = new Map(terms.map(term => [ term, documents.filter(document => includesTerm(document.pathText, term) || includesTerm(document.sourceText, term)).length, @@ -243,7 +252,7 @@ export function mapGraphTask( ])); const filePaths = new Set(documents.map(document => document.node.id)); const links = createFileLinks(data, filePaths); - const graphRanks = personalizedPageRank( + const graphRanks = rankTaskMapGraph( links, new Map([...lexical].map(([path, rank]) => [path, rank.score])), ); @@ -253,39 +262,48 @@ export function mapGraphTask( connected.add(path); for (const neighbor of links.get(path)?.keys() ?? []) connected.add(neighbor); } + const maxLexicalScore = Math.max(...[...lexical.values()].map(rank => rank.score), 1); + const maxGraphScore = Math.max(...graphRanks.values(), 1 / Math.max(documents.length, 1)); + const symbols = indexSymbols(data); const ranked: RankedTaskFile[] = documents.flatMap((document) => { const lexicalRanked = lexical.get(document.node.id) ?? { matchedTerms: [], score: 0 }; if (lexicalRanked.score <= 0 && !connected.has(document.node.id)) return []; + const graphScore = graphRanks.get(document.node.id) ?? 0; return [{ file: { path: document.node.id, nodeType: 'file' as const, matchedTerms: lexicalRanked.matchedTerms, - symbols: symbolsForFile(data, document.node.id), + symbols: symbols.get(document.node.id) ?? [], }, lexicalScore: lexicalRanked.score, - graphScore: graphRanks.get(document.node.id) ?? 0, + graphScore, + score: lexicalRanked.score / maxLexicalScore * 0.85 + graphScore / maxGraphScore * 0.15, }]; }).sort((left, right) => ( Number(right.lexicalScore > 0) - Number(left.lexicalScore > 0) + || right.score - left.score || right.lexicalScore - left.lexicalScore - || right.graphScore - left.graphScore || left.file.path.localeCompare(right.file.path) )); + const requestedLimit = Number.isSafeInteger(config.limit) && (config.limit ?? 0) > 0 + ? config.limit ?? DEFAULT_FILES + : DEFAULT_FILES; + const effectiveConfig = { ...config, limit: Math.min(requestedLimit, MAX_FILES) }; const balanced = balanceSourceAreas(ranked); - const page = paginate(balanced.map(item => item.file), config); + const page = paginate(balanced.map(item => item.file), effectiveConfig); const selectedPaths = new Set(page.items.map(file => file.path)); const relationships = selectedRelationships(data, selectedPaths); return { - query: config.query, + query, terms, files: page.items, relationships: relationships.relationships, page: page.page, limits: { relationships: MAX_RELATIONSHIPS, - complete: page.page.nextOffset === null && relationships.complete, + complete: page.page.offset === 0 && page.page.nextOffset === null && relationships.complete, }, sources: { text: { diff --git a/packages/core/src/graphQuery/taskMap/pagerank.ts b/packages/core/src/graphQuery/taskMap/pagerank.ts new file mode 100644 index 000000000..e55ceac74 --- /dev/null +++ b/packages/core/src/graphQuery/taskMap/pagerank.ts @@ -0,0 +1,36 @@ +const ITERATIONS = 20; +const DAMPING = 0.85; + +export function rankTaskMapGraph( + links: ReadonlyMap>, + personalization: ReadonlyMap, +): Map { + const paths = [...links.keys()]; + const totalPersonalization = [...personalization.values()].reduce((total, value) => total + value, 0); + const normalized = new Map(paths.map(path => [ + path, + totalPersonalization > 0 ? (personalization.get(path) ?? 0) / totalPersonalization : 1 / Math.max(paths.length, 1), + ])); + let ranks = new Map(normalized); + + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const danglingMass = paths + .filter(path => (links.get(path)?.size ?? 0) === 0) + .reduce((total, path) => total + (ranks.get(path) ?? 0), 0); + const next = new Map(paths.map(path => [ + path, + ((1 - DAMPING) + DAMPING * danglingMass) * (normalized.get(path) ?? 0), + ])); + for (const path of paths) { + const neighbors = links.get(path) ?? new Map(); + const totalWeight = [...neighbors.values()].reduce((total, weight) => total + weight, 0); + if (totalWeight === 0) continue; + for (const [neighbor, weight] of neighbors) { + next.set(neighbor, (next.get(neighbor) ?? 0) + DAMPING * (ranks.get(path) ?? 0) * weight / totalWeight); + } + } + ranks = next; + } + + return ranks; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77b7f43c4..f11c70cf1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -464,6 +464,9 @@ export type { GraphQuerySymbolReport, GraphQuerySymbolReportItem, GraphQuerySymbolsConfig, + GraphQueryTaskMapConfig, + GraphQueryTaskMapFile, + GraphQueryTaskMapReport, } from './graphQuery'; export { executeGraphQuery, @@ -473,5 +476,6 @@ export { listGraphNodes, listGraphRelationships, listGraphSymbols, + mapGraphTask, searchGraph, } from './graphQuery'; diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index bc11e29a7..632284cf9 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -102,6 +102,10 @@ describe('cli/parseQuery', () => { expect(parseQueryCommand(['nodes', '--limit', '0'])).toMatchObject({ parseError: '--limit requires a positive integer', }); + expect(parseQueryCommand(['map', 'task', '--limit', '21'])).toMatchObject({ + invokedCommand: 'map', + parseError: 'map --limit must be between 1 and 20', + }); expect(parseQueryCommand(['nodes', '--offset', '-1'])).toMatchObject({ parseError: '--offset requires a non-negative integer', }); diff --git a/packages/core/tests/graphQuery/taskMap.test.ts b/packages/core/tests/graphQuery/taskMap.test.ts index d48f1b40c..36c53f6d3 100644 --- a/packages/core/tests/graphQuery/taskMap.test.ts +++ b/packages/core/tests/graphQuery/taskMap.test.ts @@ -62,18 +62,18 @@ describe('core/graphQuery task map', () => { matchedTerms: ['workspace', 'loading'], symbols: [], }, - { - path: 'src/registry.ts', - nodeType: 'file', - matchedTerms: ['plugin', 'runtime', 'cleanup'], - symbols: [{ id: 'src/registry.ts#unloadPlugin:function', name: 'unloadPlugin', kind: 'function' }], - }, { path: 'tests/registry.test.ts', nodeType: 'file', matchedTerms: ['plugin', 'runtime', 'failure'], symbols: [], }, + { + path: 'src/registry.ts', + nodeType: 'file', + matchedTerms: ['plugin', 'runtime', 'cleanup'], + symbols: [{ id: 'src/registry.ts#unloadPlugin:function', name: 'unloadPlugin', kind: 'function' }], + }, { path: 'src/engine.ts', nodeType: 'file', @@ -95,11 +95,45 @@ describe('core/graphQuery task map', () => { }); }); - it('reports truncation when the File map omits ranked candidates', () => { - const report = mapGraphTask(data, { query: 'plugin runtime failure', limit: 1 }); + it('reports truncation for every partial page and clamps public File bounds', () => { + const first = mapGraphTask(data, { query: 'plugin runtime failure', limit: 1 }); + const final = mapGraphTask(data, { query: 'plugin runtime failure', limit: 1, offset: 2 }); + const oversized = mapGraphTask(data, { query: 'plugin runtime failure', limit: 100 }); + + expect(first.page).toMatchObject({ limit: 1, returned: 1, total: 3, nextOffset: 1 }); + expect(first.limits.complete).toBe(false); + expect(first.relationships).toEqual([]); + expect(final.page).toMatchObject({ offset: 2, returned: 1, nextOffset: null }); + expect(final.limits.complete).toBe(false); + expect(oversized.page.limit).toBe(20); + }); + + it('returns an empty bounded map rather than throwing for malformed library input', () => { + expect(mapGraphTask(data, { query: undefined as unknown as string })).toMatchObject({ + query: '', + terms: [], + files: [], + relationships: [], + page: { limit: 8, returned: 0, total: 0 }, + limits: { complete: true }, + }); + }); + + it('matches common query inflections symmetrically without substring matches', () => { + const inflectedData: GraphQueryData = { + ...data, + sourceText: { + ...data.sourceText!, + files: data.sourceText!.files.map(file => file.filePath === 'src/unrelated.ts' + ? { ...file, content: 'export function runPluginAfterTaskFailed() {}' } + : file), + }, + }; + const report = mapGraphTask(inflectedData, { query: 'running plugin fail', limit: 8 }); + const unrelated = report.files.find(file => file.path === 'src/unrelated.ts'); + const registry = report.files.find(file => file.path === 'src/registry.ts'); - expect(report.page).toMatchObject({ limit: 1, returned: 1, total: 3, nextOffset: 1 }); - expect(report.limits.complete).toBe(false); - expect(report.relationships).toEqual([]); + expect(unrelated?.matchedTerms).toEqual(['running', 'plugin', 'fail']); + expect(registry?.matchedTerms).toEqual(['plugin']); }); }); diff --git a/packages/core/tests/graphQuery/taskMap/pagerank.test.ts b/packages/core/tests/graphQuery/taskMap/pagerank.test.ts new file mode 100644 index 000000000..898cd56d8 --- /dev/null +++ b/packages/core/tests/graphQuery/taskMap/pagerank.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { rankTaskMapGraph } from '../../../src/graphQuery/taskMap/pagerank'; + +describe('core/graphQuery task map PageRank', () => { + it('preserves rank mass when personalized Files have no graph neighbors', () => { + const ranks = rankTaskMapGraph(new Map([ + ['isolated.ts', new Map()], + ['left.ts', new Map([['right.ts', 1]])], + ['right.ts', new Map([['left.ts', 1]])], + ]), new Map([ + ['isolated.ts', 1], + ['left.ts', 0], + ['right.ts', 0], + ])); + + expect([...ranks.values()].reduce((total, rank) => total + rank, 0)).toBeCloseTo(1, 10); + expect(ranks.get('isolated.ts')).toBeCloseTo(1, 10); + }); +}); diff --git a/packages/core/tests/workspace/requestQuery.test.ts b/packages/core/tests/workspace/requestQuery.test.ts index 47be08ae5..1de84df86 100644 --- a/packages/core/tests/workspace/requestQuery.test.ts +++ b/packages/core/tests/workspace/requestQuery.test.ts @@ -71,6 +71,15 @@ describe('workspace/requestQuery', () => { limits: { complete: true }, sources: { text: { freshness: 'live' }, graph: { freshness: 'cached', cacheState: 'fresh' } }, }); + + const projected = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'task-map', + arguments: { query: 'setting command', limit: 4 }, + projection: { edgeTypes: ['type-import'] }, + }); + expect(projected.files).toHaveLength(2); + expect(projected.relationships).toEqual([]); }); it('uses complete graph scope for an exact targeted Relationship query', async () => { From 12be3f00bcdffd7cae89d92644b0a89047b21ef7 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 20:46:04 -0700 Subject: [PATCH 48/55] refactor(cli): split task map ranking roles --- packages/core/src/graphQuery/taskMap.ts | 279 +++--------------- .../core/src/graphQuery/taskMap/lexical.ts | 123 ++++++++ .../core/src/graphQuery/taskMap/projection.ts | 80 +++++ .../src/graphQuery/taskMap/sourceAreas.ts | 44 +++ .../tests/graphQuery/taskMap/lexical.test.ts | 42 +++ .../graphQuery/taskMap/projection.test.ts | 38 +++ .../graphQuery/taskMap/sourceAreas.test.ts | 26 ++ 7 files changed, 396 insertions(+), 236 deletions(-) create mode 100644 packages/core/src/graphQuery/taskMap/lexical.ts create mode 100644 packages/core/src/graphQuery/taskMap/projection.ts create mode 100644 packages/core/src/graphQuery/taskMap/sourceAreas.ts create mode 100644 packages/core/tests/graphQuery/taskMap/lexical.test.ts create mode 100644 packages/core/tests/graphQuery/taskMap/projection.test.ts create mode 100644 packages/core/tests/graphQuery/taskMap/sourceAreas.test.ts diff --git a/packages/core/src/graphQuery/taskMap.ts b/packages/core/src/graphQuery/taskMap.ts index bca204cee..fd096000c 100644 --- a/packages/core/src/graphQuery/taskMap.ts +++ b/packages/core/src/graphQuery/taskMap.ts @@ -1,4 +1,3 @@ -import type { GraphEdgeKind, IGraphNode } from '../graph/contracts'; import type { GraphQueryData } from './data'; import type { GraphQueryTaskMapConfig, @@ -6,23 +5,23 @@ import type { GraphQueryTaskMapReport, } from './model'; import { paginate } from './pagination'; +import { + createTaskMapDocuments, + rankTaskMapDocument, + selectTaskMapTerms, + taskMapTermFrequencies, +} from './taskMap/lexical'; import { rankTaskMapGraph } from './taskMap/pagerank'; +import { + createTaskMapFileLinks, + indexTaskMapSymbols, + selectTaskMapRelationships, +} from './taskMap/projection'; +import { balanceTaskMapSourceAreas } from './taskMap/sourceAreas'; -const MAX_QUERY_TERMS = 16; const DEFAULT_FILES = 8; const MAX_FILES = 20; -const MAX_SYMBOLS_PER_FILE = 3; const MAX_RELATIONSHIPS = 12; -const STOP_WORDS = new Set([ - 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'during', 'for', 'from', 'in', - 'into', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with', -]); - -interface TaskDocument { - node: IGraphNode; - pathText: string; - sourceText: string; -} interface RankedTaskFile { file: GraphQueryTaskMapFile; @@ -31,208 +30,22 @@ interface RankedTaskFile { score: number; } -function tokenize(value: string): string[] { - const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2'); - return separated.match(/[\p{L}\d]+/gu)?.map(term => term.toLocaleLowerCase()) - .filter(term => term.length > 2 && !STOP_WORDS.has(term)) ?? []; -} - -function termVariants(term: string): string[] { - const roots = new Set([term]); - if (term.endsWith('ing') && term.length > 5) { - const root = term.slice(0, -3); - roots.add(root); - if (root.at(-1) === root.at(-2)) roots.add(root.slice(0, -1)); - } - if (term.endsWith('ed') && term.length > 4) { - roots.add(term.slice(0, -2)); - roots.add(term.slice(0, -1)); - } - if (term.endsWith('s') && term.length > 4) roots.add(term.slice(0, -1)); - - const variants = new Set(roots); - for (const root of roots) { - variants.add(`${root}s`); - if (root.endsWith('e')) { - variants.add(`${root}d`); - variants.add(`${root.slice(0, -1)}ing`); - } else { - variants.add(`${root}ed`); - variants.add(`${root}ing`); - if (/[^aeiou]$/u.test(root)) { - variants.add(`${root}${root.at(-1)}ed`); - variants.add(`${root}${root.at(-1)}ing`); - } - } - } - return [...variants]; -} - -function normalizeSearchText(value: string): string { - const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2').toLocaleLowerCase(); - return ` ${separated.replace(/[^\p{L}\d]+/gu, ' ')} `; -} - -function includesTerm(value: string, queryTerm: string): boolean { - return termVariants(queryTerm).some(term => value.includes(` ${term} `)); -} - -function createDocuments(data: GraphQueryData): TaskDocument[] { - const files = new Map(data.graphData.nodes - .filter(node => node.nodeType === 'file' && !node.symbol) - .map(node => [node.id, node])); - return (data.sourceText?.files ?? []).flatMap(({ filePath, content }) => { - const node = files.get(filePath); - return node ? [{ - node, - pathText: normalizeSearchText(filePath), - sourceText: normalizeSearchText(content), - }] : []; - }); -} - -function selectTerms(query: string, documents: readonly TaskDocument[]): string[] { - const candidates = [...new Set(tokenize(query))]; - const frequency = new Map(candidates.map(term => [ - term, - documents.filter(document => includesTerm(document.pathText, term) || includesTerm(document.sourceText, term)).length, - ])); - return candidates - .filter(term => (frequency.get(term) ?? 0) > 0) - .map((term, index) => ({ term, index, frequency: frequency.get(term) ?? 0 })) - .sort((left, right) => left.frequency - right.frequency || left.index - right.index) - .slice(0, MAX_QUERY_TERMS) - .sort((left, right) => left.index - right.index) - .map(item => item.term); -} - -function lexicalRank( - document: TaskDocument, - queryTerms: readonly string[], - frequencies: ReadonlyMap, - documentCount: number, -): { matchedTerms: string[]; score: number } { - const matchedTerms = queryTerms.filter(term => ( - includesTerm(document.pathText, term) || includesTerm(document.sourceText, term) - )); - const score = matchedTerms.reduce((total, term) => { - const inverseFrequency = Math.log((documentCount + 1) / ((frequencies.get(term) ?? 0) + 1)) + 1; - const pathMatch = includesTerm(document.pathText, term); - const textMatch = includesTerm(document.sourceText, term); - return total + inverseFrequency * (pathMatch ? 4 : textMatch ? 1 : 0); - }, 0); - const isTest = /(?:^|\/)(?:__tests__|tests?)(?:\/|\.)|\.(?:spec|test)\.[^/]+$/iu.test(document.node.id); - return { matchedTerms, score: isTest ? score * 0.15 : score }; -} - -function filePathForNode(node: IGraphNode | undefined): string | undefined { - if (!node) return undefined; - if (node.nodeType === 'file' && !node.symbol) return node.id; - return node.symbol?.filePath; -} - -function edgeWeight(kind: GraphEdgeKind): number { - if (kind === 'call' || kind === 'event' || kind === 'inherit' || kind === 'reference') return 3; - if (kind === 'type-import') return 1; - return 2; -} - -function createFileLinks(data: GraphQueryData, filePaths: ReadonlySet): Map> { - const nodes = new Map(data.graphData.nodes.map(node => [node.id, node])); - const links = new Map([...filePaths].map(filePath => [filePath, new Map()])); - for (const edge of data.graphData.edges) { - if (edge.kind === 'contains') continue; - const from = filePathForNode(nodes.get(edge.from)); - const to = filePathForNode(nodes.get(edge.to)); - if (!from || !to || from === to || !filePaths.has(from) || !filePaths.has(to)) continue; - const weight = edgeWeight(edge.kind); - const fromLinks = links.get(from); - const toLinks = links.get(to); - fromLinks?.set(to, (fromLinks.get(to) ?? 0) + weight); - toLinks?.set(from, (toLinks.get(from) ?? 0) + weight); - } - return links; -} - -function rankingGroup(filePath: string): string { - const segments = filePath.split('/'); - const sourceIndex = segments.findIndex(segment => segment === 'src' || segment === 'tests'); - if (sourceIndex < 0) return segments.slice(0, Math.min(segments.length, 4)).join('/'); - if (sourceIndex + 1 >= segments.length - 1) { - return segments.slice(0, sourceIndex + 1).join('/'); - } - const sourceArea = segments[sourceIndex + 1]; - const areaDepth = sourceArea === 'extension' || sourceArea === 'webview' ? 2 : 1; - return segments.slice(0, sourceIndex + 1 + areaDepth).join('/'); +function requestedFileLimit(config: GraphQueryTaskMapConfig): number { + if (!Number.isSafeInteger(config.limit) || (config.limit ?? 0) <= 0) return DEFAULT_FILES; + return Math.min(config.limit ?? DEFAULT_FILES, MAX_FILES); } -function balanceSourceAreas(ranked: readonly RankedTaskFile[]): RankedTaskFile[] { - const groups = new Map(); - for (const item of ranked) { - const group = rankingGroup(item.file.path); - const items = groups.get(group) ?? []; - items.push(item); - groups.set(group, items); - } - const ordered = [...groups.entries()].sort((left, right) => { - const leftRank = left[1][0]; - const rightRank = right[1][0]; - if (!leftRank || !rightRank) return left[0].localeCompare(right[0]); - return Number(rightRank.lexicalScore > 0) - Number(leftRank.lexicalScore > 0) - || rightRank.score - leftRank.score - || rightRank.lexicalScore - leftRank.lexicalScore - || left[0].localeCompare(right[0]); - }); - const balanced: RankedTaskFile[] = []; - for (let index = 0; balanced.length < ranked.length; index += 1) { - for (const [, items] of ordered) { - const item = items[index]; - if (item) balanced.push(item); - } - } - return balanced; -} - -function indexSymbols(data: GraphQueryData): Map { - const symbols = new Map(); - for (const symbol of data.symbols ?? []) { - const fileSymbols = symbols.get(symbol.filePath) ?? []; - fileSymbols.push({ - ...(symbol.id ? { id: symbol.id } : {}), - name: symbol.name, - ...(symbol.kind ? { kind: symbol.kind } : {}), - }); - symbols.set(symbol.filePath, fileSymbols); - } - for (const [filePath, fileSymbols] of symbols) { - symbols.set(filePath, fileSymbols - .sort((left, right) => left.name.localeCompare(right.name) || (left.id ?? '').localeCompare(right.id ?? '')) - .slice(0, MAX_SYMBOLS_PER_FILE)); - } - return symbols; -} - -function selectedRelationships( - data: GraphQueryData, - selectedPaths: ReadonlySet, -): { relationships: GraphQueryTaskMapReport['relationships']; complete: boolean } { - const nodes = new Map(data.graphData.nodes.map(node => [node.id, node])); - const grouped = new Map>(); - for (const edge of data.graphData.edges) { - if (edge.kind === 'contains') continue; - const from = filePathForNode(nodes.get(edge.from)); - const to = filePathForNode(nodes.get(edge.to)); - if (!from || !to || from === to || !selectedPaths.has(from) || !selectedPaths.has(to)) continue; - const key = `${from}\u0000${to}`; - const kinds = grouped.get(key) ?? new Set(); - kinds.add(edge.kind); - grouped.set(key, kinds); +function connectedTaskMapFiles( + lexical: ReadonlyMap, + links: ReadonlyMap>, +): Set { + const connected = new Set(); + for (const [path, rank] of lexical) { + if (rank.score <= 0) continue; + connected.add(path); + for (const neighbor of links.get(path)?.keys() ?? []) connected.add(neighbor); } - const all = [...grouped].map(([key, kinds]) => { - const [from = '', to = ''] = key.split('\u0000'); - return { from, to, edgeTypes: [...kinds].sort() }; - }).sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)); - return { relationships: all.slice(0, MAX_RELATIONSHIPS), complete: all.length <= MAX_RELATIONSHIPS }; + return connected; } export function mapGraphTask( @@ -240,31 +53,25 @@ export function mapGraphTask( config: GraphQueryTaskMapConfig, ): GraphQueryTaskMapReport { const query = typeof config.query === 'string' ? config.query : ''; - const documents = createDocuments(data); - const terms = selectTerms(query, documents); - const frequencies = new Map(terms.map(term => [ - term, - documents.filter(document => includesTerm(document.pathText, term) || includesTerm(document.sourceText, term)).length, - ])); + const documents = createTaskMapDocuments(data); + const terms = selectTaskMapTerms(query, documents); + const frequencies = taskMapTermFrequencies(terms, documents); const lexical = new Map(documents.map(document => [ document.node.id, - lexicalRank(document, terms, frequencies, documents.length), + rankTaskMapDocument(document, terms, frequencies, documents.length), ])); const filePaths = new Set(documents.map(document => document.node.id)); - const links = createFileLinks(data, filePaths); + const links = createTaskMapFileLinks(data, filePaths); const graphRanks = rankTaskMapGraph( links, new Map([...lexical].map(([path, rank]) => [path, rank.score])), ); - const connected = new Set(); - for (const [path, rank] of lexical) { - if (rank.score <= 0) continue; - connected.add(path); - for (const neighbor of links.get(path)?.keys() ?? []) connected.add(neighbor); - } - const maxLexicalScore = Math.max(...[...lexical.values()].map(rank => rank.score), 1); - const maxGraphScore = Math.max(...graphRanks.values(), 1 / Math.max(documents.length, 1)); - const symbols = indexSymbols(data); + const connected = connectedTaskMapFiles(lexical, links); + const maxLexicalScore = [...lexical.values()] + .reduce((maximum, rank) => Math.max(maximum, rank.score), 1); + const maxGraphScore = [...graphRanks.values()] + .reduce((maximum, rank) => Math.max(maximum, rank), 1 / Math.max(documents.length, 1)); + const symbols = indexTaskMapSymbols(data); const ranked: RankedTaskFile[] = documents.flatMap((document) => { const lexicalRanked = lexical.get(document.node.id) ?? { matchedTerms: [], score: 0 }; if (lexicalRanked.score <= 0 && !connected.has(document.node.id)) return []; @@ -286,14 +93,14 @@ export function mapGraphTask( || right.lexicalScore - left.lexicalScore || left.file.path.localeCompare(right.file.path) )); - const requestedLimit = Number.isSafeInteger(config.limit) && (config.limit ?? 0) > 0 - ? config.limit ?? DEFAULT_FILES - : DEFAULT_FILES; - const effectiveConfig = { ...config, limit: Math.min(requestedLimit, MAX_FILES) }; - const balanced = balanceSourceAreas(ranked); + const effectiveConfig = { ...config, limit: requestedFileLimit(config) }; + const balanced = balanceTaskMapSourceAreas(ranked); const page = paginate(balanced.map(item => item.file), effectiveConfig); - const selectedPaths = new Set(page.items.map(file => file.path)); - const relationships = selectedRelationships(data, selectedPaths); + const relationships = selectTaskMapRelationships( + data, + new Set(page.items.map(file => file.path)), + MAX_RELATIONSHIPS, + ); return { query, diff --git a/packages/core/src/graphQuery/taskMap/lexical.ts b/packages/core/src/graphQuery/taskMap/lexical.ts new file mode 100644 index 000000000..92fcaed75 --- /dev/null +++ b/packages/core/src/graphQuery/taskMap/lexical.ts @@ -0,0 +1,123 @@ +import type { IGraphNode } from '../../graph/contracts'; +import type { GraphQueryData } from '../data'; + +const MAX_QUERY_TERMS = 16; +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'during', 'for', 'from', 'in', + 'into', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with', +]); + +export interface TaskMapDocument { + node: IGraphNode; + pathText: string; + sourceText: string; +} + +export interface TaskMapLexicalRank { + matchedTerms: string[]; + score: number; +} + +function tokenize(value: string): string[] { + const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2'); + return separated.match(/[\p{L}\d]+/gu)?.map(term => term.toLocaleLowerCase()) + .filter(term => term.length > 2 && !STOP_WORDS.has(term)) ?? []; +} + +function termVariants(term: string): string[] { + const roots = new Set([term]); + if (term.endsWith('ing') && term.length > 5) { + const root = term.slice(0, -3); + roots.add(root); + if (root.at(-1) === root.at(-2)) roots.add(root.slice(0, -1)); + } + if (term.endsWith('ed') && term.length > 4) { + roots.add(term.slice(0, -2)); + roots.add(term.slice(0, -1)); + } + if (term.endsWith('s') && term.length > 4) roots.add(term.slice(0, -1)); + + const variants = new Set(roots); + for (const root of roots) { + variants.add(`${root}s`); + if (root.endsWith('e')) { + variants.add(`${root}d`); + variants.add(`${root.slice(0, -1)}ing`); + } else { + variants.add(`${root}ed`); + variants.add(`${root}ing`); + if (/[^aeiou]$/u.test(root)) { + variants.add(`${root}${root.at(-1)}ed`); + variants.add(`${root}${root.at(-1)}ing`); + } + } + } + return [...variants]; +} + +function normalizeSearchText(value: string): string { + const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2').toLocaleLowerCase(); + return ` ${separated.replace(/[^\p{L}\d]+/gu, ' ')} `; +} + +function includesTerm(value: string, queryTerm: string): boolean { + return termVariants(queryTerm).some(term => value.includes(` ${term} `)); +} + +export function createTaskMapDocuments(data: GraphQueryData): TaskMapDocument[] { + const files = new Map(data.graphData.nodes + .filter(node => node.nodeType === 'file' && !node.symbol) + .map(node => [node.id, node])); + return (data.sourceText?.files ?? []).flatMap(({ filePath, content }) => { + const node = files.get(filePath); + return node ? [{ + node, + pathText: normalizeSearchText(filePath), + sourceText: normalizeSearchText(content), + }] : []; + }); +} + +export function selectTaskMapTerms(query: string, documents: readonly TaskMapDocument[]): string[] { + const candidates = [...new Set(tokenize(query))]; + const frequency = new Map(candidates.map(term => [ + term, + documents.filter(document => includesTerm(document.pathText, term) || includesTerm(document.sourceText, term)).length, + ])); + return candidates + .filter(term => (frequency.get(term) ?? 0) > 0) + .map((term, index) => ({ term, index, frequency: frequency.get(term) ?? 0 })) + .sort((left, right) => left.frequency - right.frequency || left.index - right.index) + .slice(0, MAX_QUERY_TERMS) + .sort((left, right) => left.index - right.index) + .map(item => item.term); +} + +export function taskMapTermFrequencies( + terms: readonly string[], + documents: readonly TaskMapDocument[], +): Map { + return new Map(terms.map(term => [ + term, + documents.filter(document => includesTerm(document.pathText, term) || includesTerm(document.sourceText, term)).length, + ])); +} + +export function rankTaskMapDocument( + document: TaskMapDocument, + queryTerms: readonly string[], + frequencies: ReadonlyMap, + documentCount: number, +): TaskMapLexicalRank { + const matchedTerms = queryTerms.filter(term => ( + includesTerm(document.pathText, term) || includesTerm(document.sourceText, term) + )); + const score = matchedTerms.reduce((total, term) => { + const inverseFrequency = Math.log((documentCount + 1) / ((frequencies.get(term) ?? 0) + 1)) + 1; + const pathMatch = includesTerm(document.pathText, term); + const textMatch = includesTerm(document.sourceText, term); + return total + inverseFrequency * (pathMatch ? 4 : textMatch ? 1 : 0); + }, 0); + const isTest = /(?:^|\/)(?:__tests__|tests?)(?:\/|\.)|\.(?:spec|test)\.[^/]+$/iu.test(document.node.id); + return { matchedTerms, score: isTest ? score * 0.15 : score }; +} diff --git a/packages/core/src/graphQuery/taskMap/projection.ts b/packages/core/src/graphQuery/taskMap/projection.ts new file mode 100644 index 000000000..4b9f1daf6 --- /dev/null +++ b/packages/core/src/graphQuery/taskMap/projection.ts @@ -0,0 +1,80 @@ +import type { GraphEdgeKind, IGraphNode } from '../../graph/contracts'; +import type { GraphQueryData } from '../data'; +import type { GraphQueryTaskMapFile, GraphQueryTaskMapReport } from '../model'; + +const MAX_SYMBOLS_PER_FILE = 3; + +function filePathForNode(node: IGraphNode | undefined): string | undefined { + if (!node) return undefined; + if (node.nodeType === 'file' && !node.symbol) return node.id; + return node.symbol?.filePath; +} + +function edgeWeight(kind: GraphEdgeKind): number { + if (kind === 'call' || kind === 'event' || kind === 'inherit' || kind === 'reference') return 3; + if (kind === 'type-import') return 1; + return 2; +} + +export function createTaskMapFileLinks( + data: GraphQueryData, + filePaths: ReadonlySet, +): Map> { + const nodes = new Map(data.graphData.nodes.map(node => [node.id, node])); + const links = new Map([...filePaths].map(filePath => [filePath, new Map()])); + for (const edge of data.graphData.edges) { + if (edge.kind === 'contains') continue; + const from = filePathForNode(nodes.get(edge.from)); + const to = filePathForNode(nodes.get(edge.to)); + if (!from || !to || from === to || !filePaths.has(from) || !filePaths.has(to)) continue; + const weight = edgeWeight(edge.kind); + const fromLinks = links.get(from); + const toLinks = links.get(to); + fromLinks?.set(to, (fromLinks.get(to) ?? 0) + weight); + toLinks?.set(from, (toLinks.get(from) ?? 0) + weight); + } + return links; +} + +export function indexTaskMapSymbols(data: GraphQueryData): Map { + const symbols = new Map(); + for (const symbol of data.symbols ?? []) { + const fileSymbols = symbols.get(symbol.filePath) ?? []; + fileSymbols.push({ + ...(symbol.id ? { id: symbol.id } : {}), + name: symbol.name, + ...(symbol.kind ? { kind: symbol.kind } : {}), + }); + symbols.set(symbol.filePath, fileSymbols); + } + for (const [filePath, fileSymbols] of symbols) { + symbols.set(filePath, fileSymbols + .sort((left, right) => left.name.localeCompare(right.name) || (left.id ?? '').localeCompare(right.id ?? '')) + .slice(0, MAX_SYMBOLS_PER_FILE)); + } + return symbols; +} + +export function selectTaskMapRelationships( + data: GraphQueryData, + selectedPaths: ReadonlySet, + limit: number, +): { relationships: GraphQueryTaskMapReport['relationships']; complete: boolean } { + const nodes = new Map(data.graphData.nodes.map(node => [node.id, node])); + const grouped = new Map>(); + for (const edge of data.graphData.edges) { + if (edge.kind === 'contains') continue; + const from = filePathForNode(nodes.get(edge.from)); + const to = filePathForNode(nodes.get(edge.to)); + if (!from || !to || from === to || !selectedPaths.has(from) || !selectedPaths.has(to)) continue; + const key = `${from}\u0000${to}`; + const kinds = grouped.get(key) ?? new Set(); + kinds.add(edge.kind); + grouped.set(key, kinds); + } + const all = [...grouped].map(([key, kinds]) => { + const [from = '', to = ''] = key.split('\u0000'); + return { from, to, edgeTypes: [...kinds].sort() }; + }).sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)); + return { relationships: all.slice(0, limit), complete: all.length <= limit }; +} diff --git a/packages/core/src/graphQuery/taskMap/sourceAreas.ts b/packages/core/src/graphQuery/taskMap/sourceAreas.ts new file mode 100644 index 000000000..3af11e39d --- /dev/null +++ b/packages/core/src/graphQuery/taskMap/sourceAreas.ts @@ -0,0 +1,44 @@ +export interface TaskMapSourceAreaItem { + file: { path: string }; + lexicalScore: number; + score: number; +} + +function rankingGroup(filePath: string): string { + const segments = filePath.split('/'); + const sourceIndex = segments.findIndex(segment => segment === 'src' || segment === 'tests'); + if (sourceIndex < 0) return segments.slice(0, Math.min(segments.length, 4)).join('/'); + if (sourceIndex + 1 >= segments.length - 1) { + return segments.slice(0, sourceIndex + 1).join('/'); + } + const sourceArea = segments[sourceIndex + 1]; + const areaDepth = sourceArea === 'extension' || sourceArea === 'webview' ? 2 : 1; + return segments.slice(0, sourceIndex + 1 + areaDepth).join('/'); +} + +export function balanceTaskMapSourceAreas(ranked: readonly T[]): T[] { + const groups = new Map(); + for (const item of ranked) { + const group = rankingGroup(item.file.path); + const items = groups.get(group) ?? []; + items.push(item); + groups.set(group, items); + } + const ordered = [...groups.entries()].sort((left, right) => { + const leftRank = left[1][0]; + const rightRank = right[1][0]; + if (!leftRank || !rightRank) return left[0].localeCompare(right[0]); + return Number(rightRank.lexicalScore > 0) - Number(leftRank.lexicalScore > 0) + || rightRank.score - leftRank.score + || rightRank.lexicalScore - leftRank.lexicalScore + || left[0].localeCompare(right[0]); + }); + const balanced: T[] = []; + for (let index = 0; balanced.length < ranked.length; index += 1) { + for (const [, items] of ordered) { + const item = items[index]; + if (item) balanced.push(item); + } + } + return balanced; +} diff --git a/packages/core/tests/graphQuery/taskMap/lexical.test.ts b/packages/core/tests/graphQuery/taskMap/lexical.test.ts new file mode 100644 index 000000000..a4b4efc31 --- /dev/null +++ b/packages/core/tests/graphQuery/taskMap/lexical.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import type { GraphQueryData } from '../../../src/graphQuery/data'; +import { + createTaskMapDocuments, + rankTaskMapDocument, + selectTaskMapTerms, +} from '../../../src/graphQuery/taskMap/lexical'; + +const data: GraphQueryData = { + graphData: { + nodes: [ + { id: 'src/task.ts', label: 'task.ts', nodeType: 'file' }, + { id: 'src/unload.ts', label: 'unload.ts', nodeType: 'file' }, + ], + edges: [], + }, + sourceText: { + files: [ + { filePath: 'src/task.ts', content: 'export function runPluginAfterTaskFailed() {}' }, + { filePath: 'src/unload.ts', content: 'export function unloadPlugin() {}' }, + ], + filesScanned: 2, + filesSkipped: 0, + }, +}; + +describe('core/graphQuery task map lexical ranking', () => { + it('matches common inflections symmetrically without substring matches', () => { + const documents = createTaskMapDocuments(data); + const terms = selectTaskMapTerms('running plugin fail loading', documents); + const frequencies = new Map(terms.map(term => [ + term, + documents.filter(document => rankTaskMapDocument(document, [term], new Map([[term, 1]]), 2).score > 0).length, + ])); + + expect(terms).toEqual(['running', 'plugin', 'fail']); + expect(rankTaskMapDocument(documents[0]!, terms, frequencies, 2).matchedTerms) + .toEqual(['running', 'plugin', 'fail']); + expect(rankTaskMapDocument(documents[1]!, ['loading'], new Map([['loading', 1]]), 2).matchedTerms) + .toEqual([]); + }); +}); diff --git a/packages/core/tests/graphQuery/taskMap/projection.test.ts b/packages/core/tests/graphQuery/taskMap/projection.test.ts new file mode 100644 index 000000000..32e970a98 --- /dev/null +++ b/packages/core/tests/graphQuery/taskMap/projection.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import type { GraphQueryData } from '../../../src/graphQuery/data'; +import { + indexTaskMapSymbols, + selectTaskMapRelationships, +} from '../../../src/graphQuery/taskMap/projection'; + +const filePaths = Array.from({ length: 14 }, (_, index) => `src/file-${index}.ts`); +const data: GraphQueryData = { + graphData: { + nodes: filePaths.map(path => ({ id: path, label: path, nodeType: 'file' as const })), + edges: filePaths.slice(1).map((path, index) => ({ + id: `edge-${index}`, + from: filePaths[0]!, + to: path, + kind: 'import' as const, + sources: [], + })), + }, + symbols: Array.from({ length: 5 }, (_, index) => ({ + id: `src/file-0.ts#symbol${index}:function`, + filePath: 'src/file-0.ts', + name: `symbol${index}`, + kind: 'function', + })), +}; + +describe('core/graphQuery task map projection', () => { + it('bounds declarations and typed relationships with truthful completeness', () => { + const symbols = indexTaskMapSymbols(data); + const relationships = selectTaskMapRelationships(data, new Set(filePaths), 12); + + expect(symbols.get('src/file-0.ts')).toHaveLength(3); + expect(relationships.relationships).toHaveLength(12); + expect(relationships.relationships.every(item => item.edgeTypes[0] === 'import')).toBe(true); + expect(relationships.complete).toBe(false); + }); +}); diff --git a/packages/core/tests/graphQuery/taskMap/sourceAreas.test.ts b/packages/core/tests/graphQuery/taskMap/sourceAreas.test.ts new file mode 100644 index 000000000..0be92a57d --- /dev/null +++ b/packages/core/tests/graphQuery/taskMap/sourceAreas.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { balanceTaskMapSourceAreas } from '../../../src/graphQuery/taskMap/sourceAreas'; + +function item(path: string, score: number) { + return { file: { path }, lexicalScore: score, score }; +} + +describe('core/graphQuery task map source areas', () => { + it('keeps root source Files in one area while separating deeper Extension and Webview areas', () => { + const balanced = balanceTaskMapSourceAreas([ + item('src/a.ts', 10), + item('src/b.ts', 9), + item('packages/app/src/webview/app/target.ts', 8), + item('packages/app/src/webview/app/second.ts', 7), + item('packages/app/src/webview/components/view.ts', 6), + ]); + + expect(balanced.map(entry => entry.file.path)).toEqual([ + 'src/a.ts', + 'packages/app/src/webview/app/target.ts', + 'packages/app/src/webview/components/view.ts', + 'src/b.ts', + 'packages/app/src/webview/app/second.ts', + ]); + }); +}); From 356bce1b20b1eb4feffc1fe008af19111d3243f9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 20:54:41 -0700 Subject: [PATCH 49/55] perf(cli): compact personalized task maps --- packages/core/src/cli/help/command.ts | 2 +- packages/core/src/cli/parseQuery.ts | 2 +- packages/core/src/graphQuery/taskMap.ts | 4 ++-- packages/core/src/graphQuery/taskMap/projection.ts | 2 +- packages/core/tests/cli/help/command.test.ts | 1 + packages/core/tests/cli/parseQuery.test.ts | 2 +- packages/core/tests/graphQuery/taskMap.test.ts | 4 ++-- packages/core/tests/graphQuery/taskMap/projection.test.ts | 2 +- 8 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index cca649285..f98e35b28 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -169,7 +169,7 @@ const COMMAND_HELP: Record = { 'Effects: Read-only. Does not perform Indexing; source text is read live from indexed files.', 'Output: Bounded JSON Files, matched terms, declarations, typed Relationships, freshness, and completeness.', 'Options:', - ' --limit Maximum Files to return (default: 8; range: 1-20)', + ' --limit Maximum Files to return (default: 6; range: 1-20)', ' --offset Zero-based File offset (default: 0)', ...QUERY_PROJECTION_OPTIONS, "Example: codegraphy map 'plugin cleanup after workspace failure'", diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index 5e5596bc4..d8392cd5c 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -14,7 +14,7 @@ const QUERY_COMMANDS = new Set([ const DEFAULT_LIMIT = 100; const DEFAULT_SEARCH_LIMIT = 20; -const DEFAULT_TASK_MAP_LIMIT = 8; +const DEFAULT_TASK_MAP_LIMIT = 6; const MAX_TASK_MAP_LIMIT = 20; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; diff --git a/packages/core/src/graphQuery/taskMap.ts b/packages/core/src/graphQuery/taskMap.ts index fd096000c..e094bb722 100644 --- a/packages/core/src/graphQuery/taskMap.ts +++ b/packages/core/src/graphQuery/taskMap.ts @@ -19,9 +19,9 @@ import { } from './taskMap/projection'; import { balanceTaskMapSourceAreas } from './taskMap/sourceAreas'; -const DEFAULT_FILES = 8; +const DEFAULT_FILES = 6; const MAX_FILES = 20; -const MAX_RELATIONSHIPS = 12; +const MAX_RELATIONSHIPS = 8; interface RankedTaskFile { file: GraphQueryTaskMapFile; diff --git a/packages/core/src/graphQuery/taskMap/projection.ts b/packages/core/src/graphQuery/taskMap/projection.ts index 4b9f1daf6..da815aca6 100644 --- a/packages/core/src/graphQuery/taskMap/projection.ts +++ b/packages/core/src/graphQuery/taskMap/projection.ts @@ -2,7 +2,7 @@ import type { GraphEdgeKind, IGraphNode } from '../../graph/contracts'; import type { GraphQueryData } from '../data'; import type { GraphQueryTaskMapFile, GraphQueryTaskMapReport } from '../model'; -const MAX_SYMBOLS_PER_FILE = 3; +const MAX_SYMBOLS_PER_FILE = 1; function filePathForNode(node: IGraphNode | undefined): string | undefined { if (!node) return undefined; diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 974fb2ee7..f74d6a16d 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -58,6 +58,7 @@ describe('cli/help/command', () => { expect(createHelpResult(['map']).output).toContain('Usage: codegraphy map'); expect(createHelpResult(['map']).output).toContain('personalized File map'); expect(createHelpResult(['map']).output).toContain('typed Relationships'); + expect(createHelpResult(['map']).output).toContain('default: 6'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index 632284cf9..5f98f361a 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -25,7 +25,7 @@ describe('cli/parseQuery', () => { name: 'query', invokedCommand: 'map', report: 'task-map', - arguments: { query: 'render settings', limit: 8 }, + arguments: { query: 'render settings', limit: 6 }, }); expect(parseQueryCommand(['query', 'src/cli/command.ts'])).toEqual({ name: 'query', diff --git a/packages/core/tests/graphQuery/taskMap.test.ts b/packages/core/tests/graphQuery/taskMap.test.ts index 36c53f6d3..2f1a5585e 100644 --- a/packages/core/tests/graphQuery/taskMap.test.ts +++ b/packages/core/tests/graphQuery/taskMap.test.ts @@ -87,7 +87,7 @@ describe('core/graphQuery task map', () => { { from: 'tests/registry.test.ts', to: 'src/registry.ts', edgeTypes: ['import'] }, ], page: { offset: 0, limit: 4, returned: 4, total: 4, nextOffset: null }, - limits: { relationships: 12, complete: true }, + limits: { relationships: 8, complete: true }, sources: { text: { freshness: 'live', filesScanned: 5, filesSkipped: 0 }, graph: { freshness: 'cached', cacheState: 'fresh' }, @@ -114,7 +114,7 @@ describe('core/graphQuery task map', () => { terms: [], files: [], relationships: [], - page: { limit: 8, returned: 0, total: 0 }, + page: { limit: 6, returned: 0, total: 0 }, limits: { complete: true }, }); }); diff --git a/packages/core/tests/graphQuery/taskMap/projection.test.ts b/packages/core/tests/graphQuery/taskMap/projection.test.ts index 32e970a98..b02e1ba10 100644 --- a/packages/core/tests/graphQuery/taskMap/projection.test.ts +++ b/packages/core/tests/graphQuery/taskMap/projection.test.ts @@ -30,7 +30,7 @@ describe('core/graphQuery task map projection', () => { const symbols = indexTaskMapSymbols(data); const relationships = selectTaskMapRelationships(data, new Set(filePaths), 12); - expect(symbols.get('src/file-0.ts')).toHaveLength(3); + expect(symbols.get('src/file-0.ts')).toHaveLength(1); expect(relationships.relationships).toHaveLength(12); expect(relationships.relationships.every(item => item.edgeTypes[0] === 'import')).toBe(true); expect(relationships.complete).toBe(false); From 2e0b2bca1b850f7677b9b598fa99d394112ebafc Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 21:08:47 -0700 Subject: [PATCH 50/55] Revert "perf(cli): compact personalized task maps" This reverts commit 356bce1b20b1eb4feffc1fe008af19111d3243f9. --- packages/core/src/cli/help/command.ts | 2 +- packages/core/src/cli/parseQuery.ts | 2 +- packages/core/src/graphQuery/taskMap.ts | 4 ++-- packages/core/src/graphQuery/taskMap/projection.ts | 2 +- packages/core/tests/cli/help/command.test.ts | 1 - packages/core/tests/cli/parseQuery.test.ts | 2 +- packages/core/tests/graphQuery/taskMap.test.ts | 4 ++-- packages/core/tests/graphQuery/taskMap/projection.test.ts | 2 +- 8 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index f98e35b28..cca649285 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -169,7 +169,7 @@ const COMMAND_HELP: Record = { 'Effects: Read-only. Does not perform Indexing; source text is read live from indexed files.', 'Output: Bounded JSON Files, matched terms, declarations, typed Relationships, freshness, and completeness.', 'Options:', - ' --limit Maximum Files to return (default: 6; range: 1-20)', + ' --limit Maximum Files to return (default: 8; range: 1-20)', ' --offset Zero-based File offset (default: 0)', ...QUERY_PROJECTION_OPTIONS, "Example: codegraphy map 'plugin cleanup after workspace failure'", diff --git a/packages/core/src/cli/parseQuery.ts b/packages/core/src/cli/parseQuery.ts index d8392cd5c..5e5596bc4 100644 --- a/packages/core/src/cli/parseQuery.ts +++ b/packages/core/src/cli/parseQuery.ts @@ -14,7 +14,7 @@ const QUERY_COMMANDS = new Set([ const DEFAULT_LIMIT = 100; const DEFAULT_SEARCH_LIMIT = 20; -const DEFAULT_TASK_MAP_LIMIT = 6; +const DEFAULT_TASK_MAP_LIMIT = 8; const MAX_TASK_MAP_LIMIT = 20; const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_PATHS = 5; diff --git a/packages/core/src/graphQuery/taskMap.ts b/packages/core/src/graphQuery/taskMap.ts index e094bb722..fd096000c 100644 --- a/packages/core/src/graphQuery/taskMap.ts +++ b/packages/core/src/graphQuery/taskMap.ts @@ -19,9 +19,9 @@ import { } from './taskMap/projection'; import { balanceTaskMapSourceAreas } from './taskMap/sourceAreas'; -const DEFAULT_FILES = 6; +const DEFAULT_FILES = 8; const MAX_FILES = 20; -const MAX_RELATIONSHIPS = 8; +const MAX_RELATIONSHIPS = 12; interface RankedTaskFile { file: GraphQueryTaskMapFile; diff --git a/packages/core/src/graphQuery/taskMap/projection.ts b/packages/core/src/graphQuery/taskMap/projection.ts index da815aca6..4b9f1daf6 100644 --- a/packages/core/src/graphQuery/taskMap/projection.ts +++ b/packages/core/src/graphQuery/taskMap/projection.ts @@ -2,7 +2,7 @@ import type { GraphEdgeKind, IGraphNode } from '../../graph/contracts'; import type { GraphQueryData } from '../data'; import type { GraphQueryTaskMapFile, GraphQueryTaskMapReport } from '../model'; -const MAX_SYMBOLS_PER_FILE = 1; +const MAX_SYMBOLS_PER_FILE = 3; function filePathForNode(node: IGraphNode | undefined): string | undefined { if (!node) return undefined; diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index f74d6a16d..974fb2ee7 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -58,7 +58,6 @@ describe('cli/help/command', () => { expect(createHelpResult(['map']).output).toContain('Usage: codegraphy map'); expect(createHelpResult(['map']).output).toContain('personalized File map'); expect(createHelpResult(['map']).output).toContain('typed Relationships'); - expect(createHelpResult(['map']).output).toContain('default: 6'); expect(createHelpResult(['dependencies']).output).toContain('Usage: codegraphy dependencies'); expect(createHelpResult(['dependencies']).output).toContain('--limit '); expect(createHelpResult(['dependencies']).output).toContain('--offset '); diff --git a/packages/core/tests/cli/parseQuery.test.ts b/packages/core/tests/cli/parseQuery.test.ts index 5f98f361a..632284cf9 100644 --- a/packages/core/tests/cli/parseQuery.test.ts +++ b/packages/core/tests/cli/parseQuery.test.ts @@ -25,7 +25,7 @@ describe('cli/parseQuery', () => { name: 'query', invokedCommand: 'map', report: 'task-map', - arguments: { query: 'render settings', limit: 6 }, + arguments: { query: 'render settings', limit: 8 }, }); expect(parseQueryCommand(['query', 'src/cli/command.ts'])).toEqual({ name: 'query', diff --git a/packages/core/tests/graphQuery/taskMap.test.ts b/packages/core/tests/graphQuery/taskMap.test.ts index 2f1a5585e..36c53f6d3 100644 --- a/packages/core/tests/graphQuery/taskMap.test.ts +++ b/packages/core/tests/graphQuery/taskMap.test.ts @@ -87,7 +87,7 @@ describe('core/graphQuery task map', () => { { from: 'tests/registry.test.ts', to: 'src/registry.ts', edgeTypes: ['import'] }, ], page: { offset: 0, limit: 4, returned: 4, total: 4, nextOffset: null }, - limits: { relationships: 8, complete: true }, + limits: { relationships: 12, complete: true }, sources: { text: { freshness: 'live', filesScanned: 5, filesSkipped: 0 }, graph: { freshness: 'cached', cacheState: 'fresh' }, @@ -114,7 +114,7 @@ describe('core/graphQuery task map', () => { terms: [], files: [], relationships: [], - page: { limit: 6, returned: 0, total: 0 }, + page: { limit: 8, returned: 0, total: 0 }, limits: { complete: true }, }); }); diff --git a/packages/core/tests/graphQuery/taskMap/projection.test.ts b/packages/core/tests/graphQuery/taskMap/projection.test.ts index b02e1ba10..32e970a98 100644 --- a/packages/core/tests/graphQuery/taskMap/projection.test.ts +++ b/packages/core/tests/graphQuery/taskMap/projection.test.ts @@ -30,7 +30,7 @@ describe('core/graphQuery task map projection', () => { const symbols = indexTaskMapSymbols(data); const relationships = selectTaskMapRelationships(data, new Set(filePaths), 12); - expect(symbols.get('src/file-0.ts')).toHaveLength(1); + expect(symbols.get('src/file-0.ts')).toHaveLength(3); expect(relationships.relationships).toHaveLength(12); expect(relationships.relationships.every(item => item.edgeTypes[0] === 'import')).toBe(true); expect(relationships.complete).toBe(false); From 78655eb82c8cd5b0839e4de6ad006cc5d98affbe Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 21:09:26 -0700 Subject: [PATCH 51/55] docs(cli): record task map payload experiment --- ...0013-personalized-task-maps-reduce-agent-navigation.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md index 5ef04abb5..a409b294a 100644 --- a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md +++ b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md @@ -75,6 +75,12 @@ The Map-only treatment trajectory completed correctly with one CodeGraphy call, A fresh disposal-fixture confirmation retained equal 2/3 correctness. Correct-run mean tokens were 246,332 for CLI plus skill versus 685,081 raw, a 64.0% reduction, but no treatment agent selected Map. This confirms existing CLI value on that round, not Map value. It also shows that Map is task-selective rather than a required replacement for Search. +### Compact payload subtraction + +A follow-up candidate reduced default output from eight Files, three declarations per File, and twelve Relationships to six, one, and eight. The isolated payload fell from about 5.4 KB to 2.4 KB while retaining both relationship-task targets and the harder-task Indexing runtime and Plugin Registry anchors. Its required raw comparison remained 3/3 correct and reduced mean treatment tokens by 32.0%, but that comparison could not isolate payload shape from general CLI value. + +A direct accepted-Map-versus-compact-Map A/B had 3/3 correctness and Map adoption in all six runs. Compact Map regressed mean tokens 73.2%, median tokens 53.2%, calls 10.0%, output 84.5%, and elapsed time 45.9%. Agents compensated for omitted context with substantially more fallback Search. The compact bounds were reverted; the accepted eight/three/twelve defaults remain. + ## Decision Retain `codegraphy map ` as a public bounded query. Its selected direct A/B passed adoption, correctness, and correct-run token gates while also reducing calls, output, and elapsed time. @@ -90,7 +96,7 @@ Treat all measurements as small-sample evidence. Three-versus-three runs screen - Hidden graders must assert behavior rather than historical wording or decomposition. - Invocation telemetry must recognize equivalent executable launch forms. - Complete-graph persistence remains an unsuitable rapid screen despite being a valid historical feature. -- Incoming Impact, Triage, connected Search augmentation, BM25 fallback, phrase-line evidence, compact skill subtraction, and default Leiden ranking remain rejected. +- Incoming Impact, Triage, connected Search augmentation, BM25 fallback, phrase-line evidence, compact skill subtraction, compact Map payloads, and default Leiden ranking remain rejected. ## References From 53743a97046beaad948ea33d92cac4f7be501b26 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 21:18:56 -0700 Subject: [PATCH 52/55] feat(cli): suggest fuzzy symbol identities --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 2 +- README.md | 2 +- packages/core/README.md | 2 +- packages/core/src/cli/help/command.ts | 1 + packages/core/src/graphQuery/model.ts | 2 +- packages/core/src/graphQuery/search.ts | 18 ++++- packages/core/src/graphQuery/search/fuzzy.ts | 68 +++++++++++++++++++ packages/core/tests/cli/help/command.test.ts | 1 + packages/core/tests/graphQuery/search.test.ts | 52 +++++++++++--- .../tests/graphQuery/search/fuzzy.test.ts | 19 ++++++ skills/codegraphy/SKILL.md | 2 +- tests/release/codegraphySkill.test.mjs | 1 + 13 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/graphQuery/search/fuzzy.ts create mode 100644 packages/core/tests/graphQuery/search/fuzzy.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index 226e89eea..d2140bc59 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -3,4 +3,4 @@ "@codegraphy-dev/plugin-api": minor --- -Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add compact task-personalized `codegraphy map` File maps with typed Relationships, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results and labeled fuzzy identifier suggestions, add compact task-personalized `codegraphy map` File maps with typed Relationships, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. diff --git a/CONTEXT.md b/CONTEXT.md index bdad3d722..25b9718f0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -45,7 +45,7 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Filtered Graph** | The Scoped Graph after Filter rules. | | **Graph View Search** | A temporary text query that narrows the current graph without changing Filter settings. | | **Searched Graph** | The Filtered Graph after Graph View Search. | -| **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates. It reports source and cache provenance and does not change settings. | +| **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates; otherwise empty identifier-like patterns can add labeled fuzzy Symbol suggestions. It reports source and cache provenance and does not change settings. | | **Task Map** | A bounded task-personalized File map combining independent live terms, selected declarations, and cached typed Relationships with source-area diversity. | | **Target Query** | A bounded overview of one exact File or Symbol Node, including prioritized declarations and incoming/outgoing Relationships. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | diff --git a/README.md b/README.md index d18c34167..29709c8a4 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy index` | Makes the Graph Cache current and reports actionable file-budget truncation. | | `codegraphy settings [get|set|unset]` | Safely reads or changes validated workspace settings such as `maxFiles`. | | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | -| `codegraphy search ` | Finds exact evidence and uses deterministic all-term File ranking for sparse natural multi-term phrases. | +| `codegraphy search ` | Finds exact evidence, deterministic all-term File candidates, and labeled fuzzy Symbol suggestions for otherwise empty identifier searches. | | `codegraphy map ` | Builds a compact task-personalized File map with declarations and typed connecting Relationships. | | `codegraphy query ` | Inspects one exact File or Symbol with prioritized declarations and incoming/outgoing Relationships. | | `codegraphy edges` | Lists bounded Edges. | diff --git a/packages/core/README.md b/packages/core/README.md index c4dbb22bc..b267572f2 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `map` combines independent task terms, selected declarations, and personalized graph ranking into a bounded File map with typed connecting Relationships. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. Empty identifier-like searches can return up to three labeled fuzzy Symbol suggestions without silently selecting a target. `map` combines independent task terms, selected declarations, and personalized graph ranking into a bounded File map with typed connecting Relationships. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy settings get maxFiles diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index cca649285..c2a6ebac7 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -144,6 +144,7 @@ const COMMAND_HELP: Record = { 'Discover matching live source lines, cached AST Symbols, and indexed File or concept Nodes.', 'Literal matching is case-insensitive. Add a `*` wildcard to span characters on one line or name.', 'When a natural multi-term phrase has few literal matches, all-term path/source ranking finds relevant Files.', + 'An identifier-like pattern with no evidence can return up to three fuzzy Symbol suggestions labeled `match: "fuzzy"`.', 'Every Symbol match includes the File it came from. Text matches include line, column, and excerpt.', '', 'Arguments:', diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index c9668e529..e93642ca3 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -167,7 +167,7 @@ export interface GraphQueryPathReport { export type GraphQuerySearchMatch = | { type: 'node'; node: GraphQueryNodeReportItem } - | { type: 'symbol'; symbol: GraphQuerySymbolReportItem } + | { type: 'symbol'; symbol: GraphQuerySymbolReportItem; match?: 'fuzzy' } | { type: 'text'; filePath: string; diff --git a/packages/core/src/graphQuery/search.ts b/packages/core/src/graphQuery/search.ts index d89c5b176..de54de4aa 100644 --- a/packages/core/src/graphQuery/search.ts +++ b/packages/core/src/graphQuery/search.ts @@ -10,6 +10,7 @@ import type { import { toNodeReportItem } from './nodeReport'; import { paginate } from './pagination'; import { toSymbolReportBase } from './symbols/metadata'; +import { findFuzzySymbols } from './search/fuzzy'; import { rankSearchDocuments } from './search/ranking'; const MAX_EXCERPT_LENGTH = 240; @@ -152,6 +153,19 @@ function phraseFallbackMatches( }); } +function fuzzySymbolMatches( + data: GraphQueryData, + pattern: string, + existingMatches: readonly RankedMatch[], +): RankedMatch[] { + if (existingMatches.length > 0) return []; + return findFuzzySymbols(pattern, data.symbols ?? []).map(symbol => ({ + match: { type: 'symbol' as const, match: 'fuzzy' as const, symbol: symbolReportItem(symbol) }, + rank: 7, + sortKey: `${symbol.filePath}\u0000${symbol.name}\u0000${symbol.id ?? ''}`, + })); +} + export function searchGraph( data: GraphQueryData, config: GraphQuerySearchConfig, @@ -162,9 +176,11 @@ export function searchGraph( ...data.graphData.nodes.map(node => nodeMatch(node, matcher)).filter(match => match !== undefined), ...sourceMatches(data, config.pattern, matcher), ]; + const phraseMatches = phraseFallbackMatches(data, config.pattern, directMatches); const rankedMatches = [ ...directMatches, - ...phraseFallbackMatches(data, config.pattern, directMatches), + ...phraseMatches, + ...fuzzySymbolMatches(data, config.pattern, [...directMatches, ...phraseMatches]), ].sort((left, right) => left.rank - right.rank || left.sortKey.localeCompare(right.sortKey)); const page = paginate(rankedMatches.map(item => item.match), config); diff --git a/packages/core/src/graphQuery/search/fuzzy.ts b/packages/core/src/graphQuery/search/fuzzy.ts new file mode 100644 index 000000000..2c90ed8a3 --- /dev/null +++ b/packages/core/src/graphQuery/search/fuzzy.ts @@ -0,0 +1,68 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; + +const MIN_IDENTIFIER_LENGTH = 6; +const MAX_SUGGESTIONS = 3; + +function identifierTerms(value: string): string[] { + const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2'); + return separated.match(/[\p{L}\d]+/gu)?.map(term => term.toLocaleLowerCase()) ?? []; +} + +function editDistance(left: string, right: string, maximum: number): number { + if (Math.abs(left.length - right.length) > maximum) return maximum + 1; + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + const current = [leftIndex]; + let rowMinimum = leftIndex; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const substitution = previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1); + const distance = Math.min( + previous[rightIndex] + 1, + current[rightIndex - 1] + 1, + substitution, + ); + current.push(distance); + rowMinimum = Math.min(rowMinimum, distance); + } + if (rowMinimum > maximum) return maximum + 1; + previous = current; + } + return previous[right.length] ?? maximum + 1; +} + +function tokenSimilarity(left: readonly string[], right: readonly string[]): number { + const leftTerms = new Set(left); + const rightTerms = new Set(right); + const shared = [...leftTerms].filter(term => rightTerms.has(term)).length; + if (shared < 2) return 0; + return shared / new Set([...leftTerms, ...rightTerms]).size; +} + +function fuzzyScore(pattern: string, symbolName: string): number | undefined { + const normalizedPattern = pattern.toLocaleLowerCase(); + const normalizedName = symbolName.toLocaleLowerCase(); + const maximumDistance = Math.max(normalizedPattern.length, normalizedName.length) >= 10 ? 2 : 1; + const distance = editDistance(normalizedPattern, normalizedName, maximumDistance); + if (distance <= maximumDistance) { + return 2 + (1 - distance / Math.max(normalizedPattern.length, normalizedName.length)); + } + const similarity = tokenSimilarity(identifierTerms(pattern), identifierTerms(symbolName)); + return similarity >= 0.5 ? similarity : undefined; +} + +export function findFuzzySymbols( + pattern: string, + symbols: readonly IAnalysisSymbol[], + limit = MAX_SUGGESTIONS, +): IAnalysisSymbol[] { + if (pattern.length < MIN_IDENTIFIER_LENGTH || pattern.includes('*') || /\s/u.test(pattern)) return []; + return symbols.flatMap((symbol) => { + const score = fuzzyScore(pattern, symbol.name); + return score === undefined ? [] : [{ symbol, score }]; + }).sort((left, right) => ( + right.score - left.score + || left.symbol.name.localeCompare(right.symbol.name) + || left.symbol.filePath.localeCompare(right.symbol.filePath) + || (left.symbol.id ?? '').localeCompare(right.symbol.id ?? '') + )).slice(0, limit).map(item => item.symbol); +} diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 974fb2ee7..55957a487 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -55,6 +55,7 @@ describe('cli/help/command', () => { expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); expect(createHelpResult(['search']).output).toContain('all-term path/source ranking'); + expect(createHelpResult(['search']).output).toContain('fuzzy Symbol suggestions'); expect(createHelpResult(['map']).output).toContain('Usage: codegraphy map'); expect(createHelpResult(['map']).output).toContain('personalized File map'); expect(createHelpResult(['map']).output).toContain('typed Relationships'); diff --git a/packages/core/tests/graphQuery/search.test.ts b/packages/core/tests/graphQuery/search.test.ts index 64fa38f9b..97159c48c 100644 --- a/packages/core/tests/graphQuery/search.test.ts +++ b/packages/core/tests/graphQuery/search.test.ts @@ -22,14 +22,28 @@ const graphData: IGraphData = { edges: [], }; -const symbols: IAnalysisSymbol[] = [{ - id: 'src/index.ts#runIndexCommand:function', - filePath: 'src/index.ts', - name: 'runIndexCommand', - kind: 'function', - signature: 'async function runIndexCommand()', - metadata: { language: 'typescript', source: 'core:treesitter' }, -}]; +const symbols: IAnalysisSymbol[] = [ + { + id: 'src/index.ts#runIndexCommand:function', + filePath: 'src/index.ts', + name: 'runIndexCommand', + kind: 'function', + signature: 'async function runIndexCommand()', + metadata: { language: 'typescript', source: 'core:treesitter' }, + }, + { + id: 'src/pluginGraphWork.ts#createPluginGraphWorkScheduler:function', + filePath: 'src/pluginGraphWork.ts', + name: 'createPluginGraphWorkScheduler', + kind: 'function', + }, + { + id: 'src/pluginInjection.ts#injectPluginAssets:function', + filePath: 'src/pluginInjection.ts', + name: 'injectPluginAssets', + kind: 'function', + }, +]; function search(pattern: string) { return searchGraph({ @@ -173,6 +187,28 @@ describe('core/graphQuery search', () => { }); }); + it('suggests bounded exact Symbol identities for identifier typos and guessed names', () => { + expect(search('createPluginGraphWorkSchedulr').matches).toEqual([{ + type: 'symbol', + match: 'fuzzy', + symbol: expect.objectContaining({ + id: 'src/pluginGraphWork.ts#createPluginGraphWorkScheduler:function', + name: 'createPluginGraphWorkScheduler', + filePath: 'src/pluginGraphWork.ts', + }), + }]); + expect(search('loadPluginAssets').matches).toEqual([{ + type: 'symbol', + match: 'fuzzy', + symbol: expect.objectContaining({ + id: 'src/pluginInjection.ts#injectPluginAssets:function', + name: 'injectPluginAssets', + filePath: 'src/pluginInjection.ts', + }), + }]); + expect(search('indx').matches).toEqual([]); + }); + it('ranks source paths related to the phrase ahead of documentation history', () => { const result = searchGraph({ graphData, diff --git a/packages/core/tests/graphQuery/search/fuzzy.test.ts b/packages/core/tests/graphQuery/search/fuzzy.test.ts new file mode 100644 index 000000000..86b46eb41 --- /dev/null +++ b/packages/core/tests/graphQuery/search/fuzzy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import { findFuzzySymbols } from '../../../src/graphQuery/search/fuzzy'; + +const symbols: IAnalysisSymbol[] = [ + { id: 'scheduler', filePath: 'scheduler.ts', name: 'createPluginGraphWorkScheduler', kind: 'function' }, + { id: 'inject', filePath: 'injection.ts', name: 'injectPluginAssets', kind: 'function' }, + { id: 'unrelated', filePath: 'unrelated.ts', name: 'readWorkspaceSettings', kind: 'function' }, +]; + +describe('core/graphQuery fuzzy Symbol search', () => { + it('ranks small identifier typos and shared camel-case concepts without broad short guesses', () => { + expect(findFuzzySymbols('createPluginGraphWorkSchedulr', symbols).map(symbol => symbol.id)) + .toEqual(['scheduler']); + expect(findFuzzySymbols('loadPluginAssets', symbols).map(symbol => symbol.id)) + .toEqual(['inject']); + expect(findFuzzySymbols('indx', symbols)).toEqual([]); + }); +}); diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 4725d0358..72292456f 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -38,7 +38,7 @@ Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Pat ## Search and target identity -Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. Search is lexical rather than a semantic-answer engine. +Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. An identifier-like pattern with no evidence can return up to three fuzzy Symbol suggestions; each is labeled `match: "fuzzy"` and retains its exact identity. Search is lexical rather than a semantic-answer engine. Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 9c5eb62d3..17636a2ab 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -31,6 +31,7 @@ test('the CodeGraphy skill explains the tool without prescribing an agent workfl 'live source', 'cached', 'Graph Scope', + 'fuzzy', 'pagination', 'stdout', 'stale', From 3b21051902b9e5158a2d3631abdb6fade8f91af3 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 21:28:14 -0700 Subject: [PATCH 53/55] Revert "feat(cli): suggest fuzzy symbol identities" This reverts commit 53743a97046beaad948ea33d92cac4f7be501b26. --- .changeset/tall-owls-check.md | 2 +- CONTEXT.md | 2 +- README.md | 2 +- packages/core/README.md | 2 +- packages/core/src/cli/help/command.ts | 1 - packages/core/src/graphQuery/model.ts | 2 +- packages/core/src/graphQuery/search.ts | 18 +---- packages/core/src/graphQuery/search/fuzzy.ts | 68 ------------------- packages/core/tests/cli/help/command.test.ts | 1 - packages/core/tests/graphQuery/search.test.ts | 52 +++----------- .../tests/graphQuery/search/fuzzy.test.ts | 19 ------ skills/codegraphy/SKILL.md | 2 +- tests/release/codegraphySkill.test.mjs | 1 - 13 files changed, 15 insertions(+), 157 deletions(-) delete mode 100644 packages/core/src/graphQuery/search/fuzzy.ts delete mode 100644 packages/core/tests/graphQuery/search/fuzzy.test.ts diff --git a/.changeset/tall-owls-check.md b/.changeset/tall-owls-check.md index d2140bc59..226e89eea 100644 --- a/.changeset/tall-owls-check.md +++ b/.changeset/tall-owls-check.md @@ -3,4 +3,4 @@ "@codegraphy-dev/plugin-api": minor --- -Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results and labeled fuzzy identifier suggestions, add compact task-personalized `codegraphy map` File maps with typed Relationships, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. +Make `codegraphy search` discover exact live source and cached AST Symbols with deterministic natural-phrase File fallback results, add compact task-personalized `codegraphy map` File maps with typed Relationships, add `codegraphy query` overviews with prioritized declarations and Relationships, add safe workspace `settings get/set/unset` commands, reject corrupt persisted settings instead of silently replacing them with defaults, return structured data from `plugins list`, return compact partial results from Graph Scope mutations, keep successful non-verbose Indexing output on stdout only, query complete cached relationship types by default for exact targets, and resolve calls through explicit reexport Relationships to implementation Symbols. diff --git a/CONTEXT.md b/CONTEXT.md index 25b9718f0..bdad3d722 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -45,7 +45,7 @@ Relationship Graph -> Scoped Graph -> Filtered Graph -> Graph View Search -> Sea | **Filtered Graph** | The Scoped Graph after Filter rules. | | **Graph View Search** | A temporary text query that narrows the current graph without changing Filter settings. | | **Searched Graph** | The Filtered Graph after Graph View Search. | -| **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates; otherwise empty identifier-like patterns can add labeled fuzzy Symbol suggestions. It reports source and cache provenance and does not change settings. | +| **CLI Search** | A bounded discovery query over exact live source, cached AST Symbols, and indexed Nodes. Sparse natural multi-term phrases add deterministic all-term File candidates. It reports source and cache provenance and does not change settings. | | **Task Map** | A bounded task-personalized File map combining independent live terms, selected declarations, and cached typed Relationships with source-area diversity. | | **Target Query** | A bounded overview of one exact File or Symbol Node, including prioritized declarations and incoming/outgoing Relationships. | | **Visible Graph** | The graph shown on screen after Graph Scope, Filter, Search, Show Orphans, and other view projection rules. | diff --git a/README.md b/README.md index 29709c8a4..d18c34167 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ All `codegraphy ...` commands are published by `@codegraphy-dev/core`. Data comm | `codegraphy index` | Makes the Graph Cache current and reports actionable file-budget truncation. | | `codegraphy settings [get|set|unset]` | Safely reads or changes validated workspace settings such as `maxFiles`. | | `codegraphy nodes` | Lists bounded Nodes from saved Graph Scope. | -| `codegraphy search ` | Finds exact evidence, deterministic all-term File candidates, and labeled fuzzy Symbol suggestions for otherwise empty identifier searches. | +| `codegraphy search ` | Finds exact evidence and uses deterministic all-term File ranking for sparse natural multi-term phrases. | | `codegraphy map ` | Builds a compact task-personalized File map with declarations and typed connecting Relationships. | | `codegraphy query ` | Inspects one exact File or Symbol with prioritized declarations and incoming/outgoing Relationships. | | `codegraphy edges` | Lists bounded Edges. | diff --git a/packages/core/README.md b/packages/core/README.md index b267572f2..c4dbb22bc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ The published CLI currently supports Node.js 20 through 22; Node 22 LTS is recom The VS Code extension bundles this package for extension runtime behavior. Users install `@codegraphy-dev/core` globally only when they want terminal workflows such as Indexing, diagnostics, graph queries, Graph Scope and filter configuration, plugin registration, or workspace plugin enablement. -All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. Empty identifier-like searches can return up to three labeled fuzzy Symbol suggestions without silently selecting a target. `map` combines independent task terms, selected declarations, and personalized graph ranking into a bounded File map with typed connecting Relationships. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. +All `codegraphy ...` terminal commands live in this package. `codegraphy index` incrementally makes a workspace Graph Cache current, reports structured file-budget truncation with an exact recovery command, and persists the complete Relationship Graph independently of Graph Scope. Active Filters and Git ignored state gate fresh analysis without deleting reusable facts cached while those files were eligible. `settings get/set/unset` safely reads or mutates validated workspace settings and refuses to overwrite corrupt JSON. `search` merges exact live source, cached AST Symbol, and indexed Node evidence, then uses deterministic all-term File ranking when a natural multi-term phrase has few literal matches. `map` combines independent task terms, selected declarations, and personalized graph ranking into a bounded File map with typed connecting Relationships. `query` inspects one exact File path or Symbol Node ID and returns prioritized declarations plus incoming and outgoing Relationships. Exact targeted queries use the complete cached graph by default, independently of saved Graph View Scope, while explicit `--node-type` or `--edge-type` projections constrain that dimension. JavaScript-family reexports are indexed explicitly, allowing call Relationships to resolve through barrels to implementation Symbols. The narrower `nodes`, `edges`, `dependencies`, `dependents`, and `path` commands continue or enumerate the graph. Graph navigation accepts repeatable, comma-separated `--filter`, `--node-type`, and `--edge-type` options for one invocation without changing workspace settings. Commands use the current directory unless the global `-C, --workspace ` option selects another workspace. ```bash codegraphy settings get maxFiles diff --git a/packages/core/src/cli/help/command.ts b/packages/core/src/cli/help/command.ts index c2a6ebac7..cca649285 100644 --- a/packages/core/src/cli/help/command.ts +++ b/packages/core/src/cli/help/command.ts @@ -144,7 +144,6 @@ const COMMAND_HELP: Record = { 'Discover matching live source lines, cached AST Symbols, and indexed File or concept Nodes.', 'Literal matching is case-insensitive. Add a `*` wildcard to span characters on one line or name.', 'When a natural multi-term phrase has few literal matches, all-term path/source ranking finds relevant Files.', - 'An identifier-like pattern with no evidence can return up to three fuzzy Symbol suggestions labeled `match: "fuzzy"`.', 'Every Symbol match includes the File it came from. Text matches include line, column, and excerpt.', '', 'Arguments:', diff --git a/packages/core/src/graphQuery/model.ts b/packages/core/src/graphQuery/model.ts index e93642ca3..c9668e529 100644 --- a/packages/core/src/graphQuery/model.ts +++ b/packages/core/src/graphQuery/model.ts @@ -167,7 +167,7 @@ export interface GraphQueryPathReport { export type GraphQuerySearchMatch = | { type: 'node'; node: GraphQueryNodeReportItem } - | { type: 'symbol'; symbol: GraphQuerySymbolReportItem; match?: 'fuzzy' } + | { type: 'symbol'; symbol: GraphQuerySymbolReportItem } | { type: 'text'; filePath: string; diff --git a/packages/core/src/graphQuery/search.ts b/packages/core/src/graphQuery/search.ts index de54de4aa..d89c5b176 100644 --- a/packages/core/src/graphQuery/search.ts +++ b/packages/core/src/graphQuery/search.ts @@ -10,7 +10,6 @@ import type { import { toNodeReportItem } from './nodeReport'; import { paginate } from './pagination'; import { toSymbolReportBase } from './symbols/metadata'; -import { findFuzzySymbols } from './search/fuzzy'; import { rankSearchDocuments } from './search/ranking'; const MAX_EXCERPT_LENGTH = 240; @@ -153,19 +152,6 @@ function phraseFallbackMatches( }); } -function fuzzySymbolMatches( - data: GraphQueryData, - pattern: string, - existingMatches: readonly RankedMatch[], -): RankedMatch[] { - if (existingMatches.length > 0) return []; - return findFuzzySymbols(pattern, data.symbols ?? []).map(symbol => ({ - match: { type: 'symbol' as const, match: 'fuzzy' as const, symbol: symbolReportItem(symbol) }, - rank: 7, - sortKey: `${symbol.filePath}\u0000${symbol.name}\u0000${symbol.id ?? ''}`, - })); -} - export function searchGraph( data: GraphQueryData, config: GraphQuerySearchConfig, @@ -176,11 +162,9 @@ export function searchGraph( ...data.graphData.nodes.map(node => nodeMatch(node, matcher)).filter(match => match !== undefined), ...sourceMatches(data, config.pattern, matcher), ]; - const phraseMatches = phraseFallbackMatches(data, config.pattern, directMatches); const rankedMatches = [ ...directMatches, - ...phraseMatches, - ...fuzzySymbolMatches(data, config.pattern, [...directMatches, ...phraseMatches]), + ...phraseFallbackMatches(data, config.pattern, directMatches), ].sort((left, right) => left.rank - right.rank || left.sortKey.localeCompare(right.sortKey)); const page = paginate(rankedMatches.map(item => item.match), config); diff --git a/packages/core/src/graphQuery/search/fuzzy.ts b/packages/core/src/graphQuery/search/fuzzy.ts deleted file mode 100644 index 2c90ed8a3..000000000 --- a/packages/core/src/graphQuery/search/fuzzy.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; - -const MIN_IDENTIFIER_LENGTH = 6; -const MAX_SUGGESTIONS = 3; - -function identifierTerms(value: string): string[] { - const separated = value.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2'); - return separated.match(/[\p{L}\d]+/gu)?.map(term => term.toLocaleLowerCase()) ?? []; -} - -function editDistance(left: string, right: string, maximum: number): number { - if (Math.abs(left.length - right.length) > maximum) return maximum + 1; - let previous = Array.from({ length: right.length + 1 }, (_, index) => index); - for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { - const current = [leftIndex]; - let rowMinimum = leftIndex; - for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { - const substitution = previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1); - const distance = Math.min( - previous[rightIndex] + 1, - current[rightIndex - 1] + 1, - substitution, - ); - current.push(distance); - rowMinimum = Math.min(rowMinimum, distance); - } - if (rowMinimum > maximum) return maximum + 1; - previous = current; - } - return previous[right.length] ?? maximum + 1; -} - -function tokenSimilarity(left: readonly string[], right: readonly string[]): number { - const leftTerms = new Set(left); - const rightTerms = new Set(right); - const shared = [...leftTerms].filter(term => rightTerms.has(term)).length; - if (shared < 2) return 0; - return shared / new Set([...leftTerms, ...rightTerms]).size; -} - -function fuzzyScore(pattern: string, symbolName: string): number | undefined { - const normalizedPattern = pattern.toLocaleLowerCase(); - const normalizedName = symbolName.toLocaleLowerCase(); - const maximumDistance = Math.max(normalizedPattern.length, normalizedName.length) >= 10 ? 2 : 1; - const distance = editDistance(normalizedPattern, normalizedName, maximumDistance); - if (distance <= maximumDistance) { - return 2 + (1 - distance / Math.max(normalizedPattern.length, normalizedName.length)); - } - const similarity = tokenSimilarity(identifierTerms(pattern), identifierTerms(symbolName)); - return similarity >= 0.5 ? similarity : undefined; -} - -export function findFuzzySymbols( - pattern: string, - symbols: readonly IAnalysisSymbol[], - limit = MAX_SUGGESTIONS, -): IAnalysisSymbol[] { - if (pattern.length < MIN_IDENTIFIER_LENGTH || pattern.includes('*') || /\s/u.test(pattern)) return []; - return symbols.flatMap((symbol) => { - const score = fuzzyScore(pattern, symbol.name); - return score === undefined ? [] : [{ symbol, score }]; - }).sort((left, right) => ( - right.score - left.score - || left.symbol.name.localeCompare(right.symbol.name) - || left.symbol.filePath.localeCompare(right.symbol.filePath) - || (left.symbol.id ?? '').localeCompare(right.symbol.id ?? '') - )).slice(0, limit).map(item => item.symbol); -} diff --git a/packages/core/tests/cli/help/command.test.ts b/packages/core/tests/cli/help/command.test.ts index 55957a487..974fb2ee7 100644 --- a/packages/core/tests/cli/help/command.test.ts +++ b/packages/core/tests/cli/help/command.test.ts @@ -55,7 +55,6 @@ describe('cli/help/command', () => { expect(createHelpResult(['search']).output).toContain('cached AST Symbols'); expect(createHelpResult(['search']).output).toContain('`*` wildcard'); expect(createHelpResult(['search']).output).toContain('all-term path/source ranking'); - expect(createHelpResult(['search']).output).toContain('fuzzy Symbol suggestions'); expect(createHelpResult(['map']).output).toContain('Usage: codegraphy map'); expect(createHelpResult(['map']).output).toContain('personalized File map'); expect(createHelpResult(['map']).output).toContain('typed Relationships'); diff --git a/packages/core/tests/graphQuery/search.test.ts b/packages/core/tests/graphQuery/search.test.ts index 97159c48c..64fa38f9b 100644 --- a/packages/core/tests/graphQuery/search.test.ts +++ b/packages/core/tests/graphQuery/search.test.ts @@ -22,28 +22,14 @@ const graphData: IGraphData = { edges: [], }; -const symbols: IAnalysisSymbol[] = [ - { - id: 'src/index.ts#runIndexCommand:function', - filePath: 'src/index.ts', - name: 'runIndexCommand', - kind: 'function', - signature: 'async function runIndexCommand()', - metadata: { language: 'typescript', source: 'core:treesitter' }, - }, - { - id: 'src/pluginGraphWork.ts#createPluginGraphWorkScheduler:function', - filePath: 'src/pluginGraphWork.ts', - name: 'createPluginGraphWorkScheduler', - kind: 'function', - }, - { - id: 'src/pluginInjection.ts#injectPluginAssets:function', - filePath: 'src/pluginInjection.ts', - name: 'injectPluginAssets', - kind: 'function', - }, -]; +const symbols: IAnalysisSymbol[] = [{ + id: 'src/index.ts#runIndexCommand:function', + filePath: 'src/index.ts', + name: 'runIndexCommand', + kind: 'function', + signature: 'async function runIndexCommand()', + metadata: { language: 'typescript', source: 'core:treesitter' }, +}]; function search(pattern: string) { return searchGraph({ @@ -187,28 +173,6 @@ describe('core/graphQuery search', () => { }); }); - it('suggests bounded exact Symbol identities for identifier typos and guessed names', () => { - expect(search('createPluginGraphWorkSchedulr').matches).toEqual([{ - type: 'symbol', - match: 'fuzzy', - symbol: expect.objectContaining({ - id: 'src/pluginGraphWork.ts#createPluginGraphWorkScheduler:function', - name: 'createPluginGraphWorkScheduler', - filePath: 'src/pluginGraphWork.ts', - }), - }]); - expect(search('loadPluginAssets').matches).toEqual([{ - type: 'symbol', - match: 'fuzzy', - symbol: expect.objectContaining({ - id: 'src/pluginInjection.ts#injectPluginAssets:function', - name: 'injectPluginAssets', - filePath: 'src/pluginInjection.ts', - }), - }]); - expect(search('indx').matches).toEqual([]); - }); - it('ranks source paths related to the phrase ahead of documentation history', () => { const result = searchGraph({ graphData, diff --git a/packages/core/tests/graphQuery/search/fuzzy.test.ts b/packages/core/tests/graphQuery/search/fuzzy.test.ts deleted file mode 100644 index 86b46eb41..000000000 --- a/packages/core/tests/graphQuery/search/fuzzy.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; -import { findFuzzySymbols } from '../../../src/graphQuery/search/fuzzy'; - -const symbols: IAnalysisSymbol[] = [ - { id: 'scheduler', filePath: 'scheduler.ts', name: 'createPluginGraphWorkScheduler', kind: 'function' }, - { id: 'inject', filePath: 'injection.ts', name: 'injectPluginAssets', kind: 'function' }, - { id: 'unrelated', filePath: 'unrelated.ts', name: 'readWorkspaceSettings', kind: 'function' }, -]; - -describe('core/graphQuery fuzzy Symbol search', () => { - it('ranks small identifier typos and shared camel-case concepts without broad short guesses', () => { - expect(findFuzzySymbols('createPluginGraphWorkSchedulr', symbols).map(symbol => symbol.id)) - .toEqual(['scheduler']); - expect(findFuzzySymbols('loadPluginAssets', symbols).map(symbol => symbol.id)) - .toEqual(['inject']); - expect(findFuzzySymbols('indx', symbols)).toEqual([]); - }); -}); diff --git a/skills/codegraphy/SKILL.md b/skills/codegraphy/SKILL.md index 72292456f..4725d0358 100644 --- a/skills/codegraphy/SKILL.md +++ b/skills/codegraphy/SKILL.md @@ -38,7 +38,7 @@ Inventory reports reflect persisted Graph Scope. Search, exact Target Query, Pat ## Search and target identity -Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. An identifier-like pattern with no evidence can return up to three fuzzy Symbol suggestions; each is labeled `match: "fuzzy"` and retains its exact identity. Search is lexical rather than a semantic-answer engine. +Search literal matching is case-insensitive. `*` is a line-local wildcard over source, names, and paths. A whitespace-containing phrase with sparse literal matches can also produce deterministic File candidates whose paths or source contain all query terms. Search is lexical rather than a semantic-answer engine. Source matches include File path, line, column, excerpt, and live freshness. Symbol matches include an exact Symbol ID and source location. Node matches include exact Node identity. An exact File path or Symbol ID from these results can address Target Query and relationship commands; a display label is not necessarily a Node ID. diff --git a/tests/release/codegraphySkill.test.mjs b/tests/release/codegraphySkill.test.mjs index 17636a2ab..9c5eb62d3 100644 --- a/tests/release/codegraphySkill.test.mjs +++ b/tests/release/codegraphySkill.test.mjs @@ -31,7 +31,6 @@ test('the CodeGraphy skill explains the tool without prescribing an agent workfl 'live source', 'cached', 'Graph Scope', - 'fuzzy', 'pagination', 'stdout', 'stale', From 439c3dfeb67aa769381eb74a33f8e9908168aaf6 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 21:29:03 -0700 Subject: [PATCH 54/55] docs(cli): record fuzzy search experiment --- ...enchmarks-require-structural-work-and-tool-adoption.md | 3 ++- ...0013-personalized-task-maps-reduce-agent-navigation.md | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md b/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md index 767ac5cee..24f083604 100644 --- a/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md +++ b/docs/adr/0012-agent-benchmarks-require-structural-work-and-tool-adoption.md @@ -88,7 +88,8 @@ Three repetitions remain a fast screening instrument rather than confirmation. R - New commands must earn both discoverability and successful-trajectory value. - Skill wording is itself an intervention and can increase exhaustive ordinary exploration even without CLI use. - Tool output reduction is diagnostic; cumulative model tokens per correct implementation remain primary. -- Watcher, neighbor, conflict, inference, fuzzy, and hub experiments remain candidates under the same adoption and correctness gate; the tested incoming Impact command is rejected. +- Watcher, neighbor, conflict, inference, and hub experiments remain candidates under the same adoption and correctness gate; the tested incoming Impact command is rejected. +- ADR 0013 records the rejected fuzzy Symbol fallback. - ADR 0013 records the selected relationship fixture and accepted personalized Task Map. ## References diff --git a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md index a409b294a..213976f2e 100644 --- a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md +++ b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md @@ -81,6 +81,12 @@ A follow-up candidate reduced default output from eight Files, three declaration A direct accepted-Map-versus-compact-Map A/B had 3/3 correctness and Map adoption in all six runs. Compact Map regressed mean tokens 73.2%, median tokens 53.2%, calls 10.0%, output 84.5%, and elapsed time 45.9%. Agents compensated for omitted context with substantially more fallback Search. The compact bounds were reverted; the accepted eight/three/twelve defaults remain. +### Fuzzy Symbol fallback + +A Search candidate returned up to three exact Symbol identities labeled `match: "fuzzy"` only when an identifier-like pattern had no exact, wildcard, phrase, source, Node, or Symbol evidence. Isolated smoke repaired a one-character scheduler typo and the guessed `loadPluginAssets` identifier observed in an earlier trajectory. + +In its required relationship-task comparison, treatment correctness was 3/3 versus raw 2/3 and correct-run mean tokens were 1.7% lower. However, no treatment Search activated the fallback. Agents selected exact and phrase searches instead, while calls, output, and elapsed time regressed. The candidate failed attribution and was fully reverted. + ## Decision Retain `codegraphy map ` as a public bounded query. Its selected direct A/B passed adoption, correctness, and correct-run token gates while also reducing calls, output, and elapsed time. @@ -96,7 +102,7 @@ Treat all measurements as small-sample evidence. Three-versus-three runs screen - Hidden graders must assert behavior rather than historical wording or decomposition. - Invocation telemetry must recognize equivalent executable launch forms. - Complete-graph persistence remains an unsuitable rapid screen despite being a valid historical feature. -- Incoming Impact, Triage, connected Search augmentation, BM25 fallback, phrase-line evidence, compact skill subtraction, compact Map payloads, and default Leiden ranking remain rejected. +- Incoming Impact, Triage, connected Search augmentation, BM25 fallback, phrase-line evidence, fuzzy Symbol fallback, compact skill subtraction, compact Map payloads, and default Leiden ranking remain rejected. ## References From 3b21ba44b84684411fc9ea6a1a01ec92998a7b28 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 24 Jul 2026 21:30:13 -0700 Subject: [PATCH 55/55] docs(cli): close nonviable experiment queue --- ...personalized-task-maps-reduce-agent-navigation.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md index 213976f2e..c06666fb3 100644 --- a/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md +++ b/docs/adr/0013-personalized-task-maps-reduce-agent-navigation.md @@ -87,6 +87,18 @@ A Search candidate returned up to three exact Symbol identities labeled `match: In its required relationship-task comparison, treatment correctness was 3/3 versus raw 2/3 and correct-run mean tokens were 1.7% lower. However, no treatment Search activated the fallback. Agents selected exact and phrase searches instead, while calls, output, and elapsed time regressed. The candidate failed attribution and was fully reverted. +## Remaining queue disposition + +The remaining candidate queue does not contain another clean, benchmarkable forward path under current architecture: + +- **One-hop neighbors / edge explanation:** exact `query` already returns bounded incoming and outgoing typed Relationships, while `dependencies` and `dependents` expose either direction. Another neighbor command would duplicate those contracts and recreate the rejected generic-neighborhood path. +- **Watcher-assisted impact:** the CLI is intentionally one-shot. A watcher requires a resident lifecycle, cache locking, debounce and recrawl semantics, and query-time mutation. That conflicts with ADR 0003's no-server boundary and the read-only query contract. Explicit incremental `index` remains the coherent refresh operation. +- **Hub diagnostics:** hub suppression and community priors did not improve held-out localization, and a standalone god-node inventory has no demonstrated adoption in coding work. Task Map already uses degree-normalized transitions and source-area diversity without presenting centrality as risk. +- **Conflict / co-change risk:** Core has no VCS-history abstraction, rename policy, time window, or persistence model. Clean benchmarks intentionally remove VCS history. Adding Git subprocess behavior solely for a benchmark would violate fixture isolation and make historical coupling look like static truth. +- **Bounded inference:** call, import, inheritance, and reference Relationships have different transitivity. Generic two-hop inference would create semantically false Edges unless each language Plugin defines and tests explicit rules and confidence provenance. + +These are blockers or duplicate interfaces, not invitations for flags, shims, or weakened tests. Reconsider them only with a new architectural requirement or a viable historical task that naturally exercises the missing evidence. + ## Decision Retain `codegraphy map ` as a public bounded query. Its selected direct A/B passed adoption, correctness, and correct-run token gates while also reducing calls, output, and elapsed time.