Skip to content

feat: add codegraph brief command for hook context injection#480

Merged
carlos-alm merged 7 commits intomainfrom
feat/brief-command
Mar 17, 2026
Merged

feat: add codegraph brief command for hook context injection#480
carlos-alm merged 7 commits intomainfrom
feat/brief-command

Conversation

@carlos-alm
Copy link
Contributor

Summary

  • New codegraph brief <file> command: token-efficient file summary with symbol roles, transitive caller counts, importer counts, and file risk tier (high/medium/low)
  • Added --brief flag to deps command as alias
  • New MCP tool brief for AI agent access
  • Updated enrich-context.sh hook to use brief instead of deps — passively-injected context now includes roles, blast radius, and risk tiers

Before (deps output in hook)

[codegraph] src/domain/graph/builder.js
  Defines: function buildGraph
  Imported by: src/cli/commands/build.js

After (brief output in hook)

[codegraph] src/domain/graph/builder.js [HIGH RISK]
  Symbols: buildGraph [core, 12 callers], collectFiles [leaf, 2 callers]
  Imports: src/db/index.js, src/domain/parser.js
  Imported by: src/cli/commands/build.js (+8 transitive)

Test plan

  • 3 new integration tests for briefData (roles, caller counts, noTests filter, empty result)
  • Updated MCP tool name list in unit tests
  • Full test suite passes (1883 tests, 0 failures)
  • Lint clean

Hook-optimized command that returns compact per-file context: each symbol
with its role and transitive caller count, direct + transitive importer
counts, and a file risk tier (high/medium/low). Designed for the
enrich-context.sh hook to inject richer passive context — roles, blast
radius, and risk — without separate fn-impact calls.

- New CLI command: `codegraph brief <file>`
- New `--brief` flag on `deps` as alias
- New MCP tool: `brief`
- Updated enrich-context.sh hook to use `brief` instead of `deps`

Impact: 8 functions changed, 11 affected
@claude
Copy link

claude bot commented Mar 17, 2026

Claude finished @carlos-alm's task —— View job


PR Review Complete ⭐⭐⭐⭐☆

Reviewing PR #480: feat: add codegraph brief command for hook context injection

Review Tasks

  • Phase 1: Root Cause Analysis - Understand the problem being solved
  • Phase 2: Configuration Change Detection - Check for high-risk changes
  • Phase 3: Test Integrity - Verify test quality and coverage
  • Phase 4: Code Quality - Review implementation details
  • Phase 5: Backlog Compliance - Grade against BACKLOG.md criteria
  • Final Assessment: Rating and recommendation

Root Cause Analysis

Problem Being Solved: This PR implements Backlog ID #83 - the highest priority "Tier 0" item designed to enrich the passively-injected context that agents see via hooks. Currently, enrich-context.sh uses deps command which only shows shallow file-level imports/exports and symbol names. Agents get no role information (is this a core vs leaf symbol?) or blast radius data (how many callers will break if I change this?).

Why This Approach: The brief command addresses this by providing token-efficient output with:

  • Symbol roles and transitive caller counts (buildGraph [core, 12 callers])
  • File risk tier assessment (high/medium/low)
  • Transitive importer counts for blast radius visibility

Risk Assessment: ✅ LOW RISK - This is purely additive functionality. No existing APIs changed, no breaking changes, solid test coverage.

Backlog Compliance

  • Zero-dep: ✅ - Builds entirely on existing tree-sitter/SQLite infrastructure, no new runtime dependencies
  • Foundation-aligned: ✅ - Fully aligned with P1 (always-current), P4 (zero-cost core), P5 (embeddable-first), P8 (agent-focused)
  • Problem-fit: 4/5 - Directly reduces token waste in agent orientation by enriching passively-injected context with roles and blast radius
  • Breaking: No - Purely additive, preserves all existing behavior
  • Tier: Tier 1 (Zero-dep ✓ + Foundation-aligned ✓)

Technical Review

Code Quality: Strong ⭐⭐⭐⭐☆

  • Architecture: Clean separation of concerns - domain/analysis/brief.js for core logic, presentation/brief.js for formatting, proper CLI/MCP integration
  • Implementation: Well-structured BFS algorithms for transitive counting with proper depth limits and visited tracking
  • Risk Assessment Logic: Sound risk tier computation based on caller count thresholds and role classification (computeRiskTier in brief.js:31-41)
  • Error Handling: Proper DB connection management with try/finally blocks
  • Performance: Efficient - uses lightweight counting instead of full traversal, bounded depth searches

Test Coverage: Comprehensive ⭐⭐⭐⭐⭐

  • New Tests: 3 focused integration tests covering core functionality:
    • Symbol roles and caller count validation (tests/integration/queries.test.js:265-284)
    • Empty result handling for unknown files (lines 286-289)
    • Test file filtering with noTests option (lines 291-297)
  • Updated Tests: MCP tool registry properly updated with brief in expected tools list

Hook Integration: Well-Executed ⭐⭐⭐⭐☆

  • Context Enhancement: The updated enrich-context.sh now injects rich context like:
    [codegraph] src/domain/graph/builder.js [HIGH RISK]
      Symbols: buildGraph [core, 12 callers], collectFiles [leaf, 2 callers]
      Imports: src/db/index.js, src/domain/parser.js  
      Imported by: src/cli/commands/build.js (+8 transitive)
    
  • JSON Processing: Robust Node.js JSON parsing in the hook with proper error handling

Minor Areas for Improvement:

  1. Magic Numbers: Risk tier thresholds are hardcoded (maxCallers >= 10 = high risk). Consider making configurable.
  2. BFS Depth: Fixed maxDepth = 5 in countTransitiveCallers - could benefit from configuration.

Critical Concerns

None identified. This is a well-implemented, thoroughly tested feature that directly addresses the stated problem.

Final Recommendation

  • Rating: ⭐⭐⭐⭐☆ (4/5)
  • Action: APPROVE
  • Reasoning: Excellent execution of the highest-priority backlog item. Provides substantial value by enriching agent context with role and blast radius information while maintaining code quality standards. Zero breaking changes, comprehensive tests, clean architecture. The magic number thresholds are minor compared to the significant improvement in agent experience.

This directly addresses the core problem of "AI coding assistants waste tokens re-orienting in large codebases" by making the passively-injected hook context much richer and more actionable.


@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 17, 2026

Greptile Summary

This PR adds a codegraph brief <file> command that produces a token-efficient file summary (symbol roles, transitive caller counts, importer counts, and risk tier), and updates the enrich-context.sh hook to use it instead of deps, enriching passively-injected AI context with blast-radius signals. The implementation is well-structured across domain, presentation, CLI, and MCP layers, and addresses several issues identified in prior review rounds (depth-bounded BFS, core-only role escalation, totalImporterCount naming, directImporters deduplication, getQueries() convention).

One concrete issue remains:

  • imports array not deduplicated (src/domain/analysis/brief.js:123-144): findImportTargets returns one row per edge kind (imports + imports-type), so a TypeScript file with both a value import and a type import from the same target will produce duplicate entries in r.imports. The directImporters path was already fixed with [...new Set(...)], but the symmetric fix was not applied to importsTo. This causes the Imports: line in CLI output and hook context to list the same file twice.

Confidence Score: 3/5

  • Safe to merge with one small fix — the imports deduplication gap is a one-liner and mirrors the already-applied fix for directImporters.
  • The feature is well-designed and most of the previously-flagged issues have been correctly resolved. The single remaining bug (duplicate imports entries from multi-edge TypeScript imports) will produce noisy CLI/hook output but is not a data-loss or security issue. The fix is trivially a [...new Set(...)] wrap, identical to the existing fix on directImporters.
  • src/domain/analysis/brief.js — the importsTo.map((i) => i.file) on line 144 needs the same [...new Set(...)] deduplication that was already applied to directImporters.

Important Files Changed

Filename Overview
src/domain/analysis/brief.js Core domain logic for the new briefData function. Contains a deduplication gap on imports (line 123-124) — findImportTargets returns one row per edge kind, so TypeScript files with both value and type imports to the same target produce duplicate paths, unlike directImporters which was already fixed with [...new Set(...)].
src/presentation/brief.js Presentation layer for the brief command. Correctly computes the transitive suffix as totalImporterCount - importedBy.length and handles the edge case where importedBy is empty but transitive importers exist. Clean, readable formatting logic.
.claude/hooks/enrich-context.sh Updated to use codegraph brief instead of deps. Comment accurately reflects the silent no-op behavior on older installs. Hook logic mirrors the presentation layer and handles the extra transitive suffix consistently.
src/mcp/tools/brief.js Thin MCP tool handler for brief. Correctly uses ctx.getQueries() to match the shared module-cache pattern of all other tools in this directory.
src/cli/commands/brief.js New CLI command that delegates directly to the presentation layer. Auto-discovered by the CLI framework via filesystem scan; no manual registration needed.
src/cli/commands/deps.js Added --brief flag as an alias that routes to the brief presentation function. Clean conditional dispatch.
tests/integration/queries.test.js Three new integration tests covering symbol structure, empty result, and noTests filtering. Tests verify totalImporterCount >= importedBy.length invariant. Missing a test that verifies the imports array itself contains no duplicates.
src/mcp/tool-registry.js New brief tool entry added with correct schema. no_tests boolean with a default: false follows the pattern of other tools.

Sequence Diagram

sequenceDiagram
    participant Hook as enrich-context.sh
    participant CLI as codegraph brief
    participant Pres as presentation/brief.js
    participant Domain as domain/analysis/brief.js
    participant DB as graph.db (SQLite)

    Hook->>CLI: codegraph brief <file> --json -d <db>
    CLI->>Pres: brief(file, dbPath, opts)
    Pres->>Domain: briefData(file, dbPath, opts)
    Domain->>DB: findFileNodes(db, '%file%')
    DB-->>Domain: fileNodes[]
    loop per fileNode
        Domain->>DB: findImportSources (direct importers)
        Domain->>DB: findImportTargets (direct imports)
        Domain->>DB: findNodesByFile (symbol defs)
        loop per symbol
            Domain->>DB: countTransitiveCallers BFS (maxDepth=5)
        end
        Domain->>DB: countTransitiveImporters BFS (maxDepth=5)
    end
    Domain-->>Pres: { file, results[] }
    Pres-->>CLI: JSON / formatted text
    CLI-->>Hook: JSON stdout
    Hook->>Hook: node inline: format additionalContext
    Hook-->>Claude: hookSpecificOutput.additionalContext
Loading

Last reviewed commit: f6373ab

export const name = 'brief';

export async function handler(args, ctx) {
const { briefData } = await import('../../domain/analysis/brief.js');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inconsistent import pattern vs. all other MCP tools

Every other tool in src/mcp/tools/ retrieves domain functions via await ctx.getQueries(), which lazily imports domain/queries.js and caches the module for the lifetime of the server. This new tool bypasses that mechanism and uses a direct dynamic import instead.

briefData is already re-exported from domain/queries.js in this same PR, so there's no reason to go around ctx.getQueries():

Suggested change
const { briefData } = await import('../../domain/analysis/brief.js');
const { briefData } = await ctx.getQueries();

Using the shared getQueries() path ensures the module cache is shared with every other tool call, matches the established convention, and makes the tool immediately discoverable from domain/queries.js.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — switched to ctx.getQueries() to match the established MCP tool pattern and share the module cache.

Comment on lines 42 to 48
# Run codegraph brief and capture output (falls back to deps for older installs)
BRIEF=""
if command -v codegraph &>/dev/null; then
DEPS=$(codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
BRIEF=$(codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
else
DEPS=$(npx --yes @optave/codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
BRIEF=$(npx --yes @optave/codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
fi
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Misleading "falls back to deps" comment — no actual fallback

The comment says "falls back to deps for older installs", but the else branch still runs brief (via npx), not deps. On an older install that doesn't have the brief command, the npx invocation will fail silently (due to || true), leaving BRIEF empty, and the hook exits at line 51 with no context injected at all. There is no deps fallback.

Either update the comment to accurately describe what happens (silent no-op on old installs), or implement the actual fallback by trying deps when brief produces no output:

Suggested change
# Run codegraph brief and capture output (falls back to deps for older installs)
BRIEF=""
if command -v codegraph &>/dev/null; then
DEPS=$(codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
BRIEF=$(codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
else
DEPS=$(npx --yes @optave/codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
BRIEF=$(npx --yes @optave/codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
fi
# Run codegraph brief and capture output (silent no-op on older installs without the brief command)
BRIEF=""
if command -v codegraph &>/dev/null; then
BRIEF=$(codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
else
BRIEF=$(npx --yes @optave/codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
fi

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — updated the comment to accurately describe the behavior: silent no-op on older installs without the brief command. No actual deps fallback exists.

Comment on lines +43 to +46
if (r.importedBy.length > 0) {
const transitive = r.transitiveImporterCount - r.importedBy.length;
const suffix = transitive > 0 ? ` (+${transitive} transitive)` : '';
console.log(` Imported by: ${r.importedBy.join(', ')}${suffix}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Implicit assumption: transitiveImporterCount includes direct importers

transitiveImporterCount is produced by countTransitiveImporters (a BFS seeded from the file node itself). The BFS visits direct importers in its first step, then their importers, and so on — so it counts all importers at every depth, including the direct ones. The subtraction here relies on that invariant to compute "extra beyond direct".

The name transitiveImporterCount does not signal this: readers naturally interpret it as only the non-direct (transitive) importers, not total importers. This creates two risks:

  1. A future refactor that makes countTransitiveImporters return only non-direct importers (to match the name) would silently break this computation.
  2. The same field is consumed in enrich-context.sh with identical arithmetic — two places to keep in sync.

Consider renaming transitiveImporterCounttotalImporterCount (or allImporterCount) in briefData's return shape to make the semantics self-documenting, and update both consumers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — renamed transitiveImporterCount to totalImporterCount across all consumers (domain, presentation, hook, and tests). The new name makes it clear the field includes both direct and transitive importers.

Comment on lines +31 to +41
function computeRiskTier(symbols) {
let maxCallers = 0;
let hasCoreOrUtility = false;
for (const s of symbols) {
if (s.callerCount > maxCallers) maxCallers = s.callerCount;
if (s.role === 'core' || s.role === 'utility') hasCoreOrUtility = true;
}
if (maxCallers >= 10 || hasCoreOrUtility) return 'high';
if (maxCallers >= 3) return 'medium';
return 'low';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 utility role unconditionally forces HIGH risk regardless of caller count

The current logic promotes any file containing a utility-role symbol straight to high, even if that symbol has 0 or 1 transitive callers. In practice, many small helper modules are tagged utility but are barely used — flagging them all as HIGH RISK will saturate hook output with spurious high-risk labels and reduce the signal value of the tier.

Consider requiring both a role signal and a meaningful caller threshold, or at least reserving the unconditional HIGH escalation for core (which typically implies high fan-in by definition):

function computeRiskTier(symbols) {
  let maxCallers = 0;
  let hasCoreRole = false;
  for (const s of symbols) {
    if (s.callerCount > maxCallers) maxCallers = s.callerCount;
    if (s.role === 'core') hasCoreRole = true;
  }
  if (maxCallers >= 10 || hasCoreRole) return 'high';
  if (maxCallers >= 3) return 'medium';
  return 'low';
}

This is a design decision, but the current behaviour is worth an explicit comment if intentional.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — computeRiskTier now only unconditionally escalates to HIGH for core role symbols (which inherently have high fan-in). utility role symbols are now assessed purely on caller count thresholds like any other role, preventing spurious HIGH labels on lightly-used helpers.

Aligns with the established pattern used by all other MCP tools,
sharing the module cache from domain/queries.js.
The hook runs `brief` in both branches — there is no `deps` fallback.
Updated the comment to accurately describe the silent no-op behavior
on older installs.
The field counts all importers (direct + transitive), not just
transitive ones. The new name makes the semantics self-documenting
and prevents future refactoring mistakes.
@carlos-alm
Copy link
Contributor Author

@greptileai

Comment on lines +72 to +88
function countTransitiveImporters(db, fileNodeIds, noTests) {
const visited = new Set(fileNodeIds);
const queue = [...fileNodeIds];

while (queue.length > 0) {
const current = queue.shift();
const dependents = findImportDependents(db, current);
for (const dep of dependents) {
if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
visited.add(dep.id);
queue.push(dep.id);
}
}
}

return visited.size - fileNodeIds.length;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 countTransitiveImporters has no depth bound

countTransitiveCallers (line 47) accepts a maxDepth = 5 guard specifically to prevent expensive full-graph traversals. countTransitiveImporters has no equivalent limit and will traverse every reachable node in the entire import graph.

For a widely-imported utility file (e.g. a logging or config module imported by dozens of files, each imported by dozens more), the BFS could touch thousands of nodes. Since brief is designed for passive hook injection on every file read/grep, this can make the hook noticeably slow on large repos.

Consider adding a matching depth cap:

function countTransitiveImporters(db, fileNodeIds, noTests, maxDepth = 5) {
  const visited = new Set(fileNodeIds);
  let frontier = [...fileNodeIds];

  for (let d = 1; d <= maxDepth; d++) {
    const nextFrontier = [];
    for (const current of frontier) {
      const dependents = findImportDependents(db, current);
      for (const dep of dependents) {
        if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
          visited.add(dep.id);
          nextFrontier.push(dep.id);
        }
      }
    }
    frontier = nextFrontier;
    if (frontier.length === 0) break;
  }

  return visited.size - fileNodeIds.length;
}

This makes the performance profile consistent with countTransitiveCallers and keeps hook latency predictable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added maxDepth = 5 bound to countTransitiveImporters, matching the same guard in countTransitiveCallers. Uses frontier-based BFS instead of unbounded queue to enforce the depth limit.

Matches the maxDepth=5 guard in countTransitiveCallers to prevent
expensive full-graph traversals on widely-imported files. Keeps
hook latency predictable for passive context injection.
@carlos-alm
Copy link
Contributor Author

@greptileai

Comment on lines +115 to +117
let importedBy = findImportSources(db, fn.id);
if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
const directImporters = importedBy.map((i) => i.file);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 directImporters not deduplicated — negative transitive count possible

findImportSources selects one row per edge kind (no DISTINCT on file), so a TypeScript file that has both an imports and an imports-type edge to the same target produces two rows with the same n.file. After the map, directImporters ends up with duplicate paths.

countTransitiveImporters avoids this because its BFS deduplicates by node ID via the visited set. The mismatch means r.importedBy.length can exceed r.totalImporterCount (e.g. 2 > 1), driving totalImporterCount - r.importedBy.length to a negative value in both presentation/brief.js:44 and the hook's extra computation, silently dropping the transitive suffix even when real transitive importers exist. The "Imported by" line also prints the same file twice.

Suggested change
let importedBy = findImportSources(db, fn.id);
if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
const directImporters = importedBy.map((i) => i.file);
let importedBy = findImportSources(db, fn.id);
if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
const directImporters = [...new Set(importedBy.map((i) => i.file))];

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — deduplicated directImporters with [...new Set(...)] to prevent duplicate file paths from multi-edge imports (e.g. imports + imports-type). This ensures importedBy.length never exceeds totalImporterCount.

…#480)

findImportSources can return multiple edges to the same file (e.g.
imports + imports-type), causing importedBy.length to exceed
totalImporterCount and produce a negative transitive suffix.
@carlos-alm
Copy link
Contributor Author

@greptileai

carlos-alm added a commit that referenced this pull request Mar 17, 2026
Inventory 78+ hardcoded magic numbers across the codebase and design
a 6-phase plan to route them through the existing .codegraphrc.json
config system. Includes PR #480 brief command thresholds.
@carlos-alm carlos-alm merged commit fdc61a8 into main Mar 17, 2026
18 checks passed
@carlos-alm carlos-alm deleted the feat/brief-command branch March 17, 2026 06:48
@github-actions github-actions bot locked and limited conversation to collaborators Mar 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant