Skip to content

refactor: index-hook framework — eliminate per-pass CodeGraph mutations#119

Closed
andreinknv wants to merge 2 commits into
colbymchenry:mainfrom
andreinknv:refactor/index-hooks
Closed

refactor: index-hook framework — eliminate per-pass CodeGraph mutations#119
andreinknv wants to merge 2 commits into
colbymchenry:mainfrom
andreinknv:refactor/index-hooks

Conversation

@andreinknv
Copy link
Copy Markdown
Contributor

@andreinknv andreinknv commented Apr 27, 2026

Reviewers: for a 5-minute review path that surfaces only what to verify, see the review checklist comment below. The original description is preserved here for full technical detail.


refactor: index-hook framework — eliminate per-pass CodeGraph mutations

Today every PR adding a derived-signal pass (centrality, churn,
issue-history, config-refs, sql-refs, cochange) edits the same
3 spots in src/index.ts:

  1. New imports at the top
  2. New private method on CodeGraph (e.g. runDerivedSignals,
    runIssueHistoryPass, runConfigRefsPass, runSqlRefsPass)
  3. New call site in indexAll AFTER resolution
  4. New call site in sync AFTER resolution

5 PRs collide on every one of those.

After this refactor:

Adding a new derived-signal pass:

  1. Create src/index-hooks/<name>.ts exporting a
    HOOK: IndexHook constant with afterIndexAll and/or
    afterSync methods.
  2. Add one import + one entry to
    src/index-hooks/registry.ts.

CodeGraph.indexAll and sync invoke the hook runner once;
adding a new pass touches only the hook file + the registry.
Zero changes to CodeGraph itself.

What's new

  • src/index-hooks/types.tsIndexHook interface
    (afterIndexAll, afterSync, both optional), IndexHookContext
    (projectRoot + config + queries + db), and
    IndexHookOutcome for diagnostic reporting.
  • src/index-hooks/registry.ts — static-import list of every
    registered hook (empty on main today; PRs adding hooks fill it
    in), plus the runAfterIndexAll / runAfterSync runners that
    iterate hooks and catch errors so one broken hook never fails
    indexing.
  • src/index.tsindexAll calls runAfterIndexAll(ctx)
    after resolution. sync calls runAfterSync(ctx, result)
    after resolution. New private buildHookContext() helper
    exposes a stable read-only context.
  • tests/index-hooks.test.ts — 6 tests covering empty
    registry, runner shape, and the afterIndexAll / afterSync
    contracts.

Why ship the framework on main with zero registered hooks?

The only consumers of this framework today are 5 unmerged PRs
(#105 cochange + my #112-#115). Landing the framework now lets
each of those PRs rebase to a 2-line change instead of 8-10
lines mutating CodeGraph adjacent-line. Without this, all 5 PRs
collide on the same indexAll/sync call sites.

The framework adds zero behavior on main (no registered hooks =
no-op runner). 380→386 tests confirm no regression.

Affected open PRs

PR Today After this lands
#105 cochange runDerivedSignals helper + 2 call sites 1 hook file in src/index-hooks/ + 2 lines in registry.ts
#112 centrality+churn same shape same shape
#113 issue-history same shape same shape
#114 config-refs same shape same shape
#115 sql-refs same shape same shape

Each goes from "edit CodeGraph in 4 spots" to "drop a hook file."

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

Today every PR adding a derived-signal pass (centrality, churn,
issue-history, config-refs, sql-refs, cochange) edits the same
3 spots in src/index.ts:

  1. New imports at the top
  2. New private method on `CodeGraph` (e.g. runDerivedSignals,
     runIssueHistoryPass, runConfigRefsPass, runSqlRefsPass)
  3. New call site in `indexAll` AFTER resolution
  4. New call site in `sync` AFTER resolution

5 PRs collide on every one of those.

After this refactor:

  Adding a new derived-signal pass:
  1. Create `src/index-hooks/<name>.ts` exporting a
     `HOOK: IndexHook` constant with `afterIndexAll` and/or
     `afterSync` methods.
  2. Add one import + one entry to
     `src/index-hooks/registry.ts`.

`CodeGraph.indexAll` and `sync` invoke the hook runner once;
adding a new pass touches only the hook file + the registry.
Zero changes to CodeGraph itself.

## What's new

- **src/index-hooks/types.ts** — `IndexHook` interface
  (`afterIndexAll`, `afterSync`, both optional), `IndexHookContext`
  (projectRoot + config + queries + db), and
  `IndexHookOutcome` for diagnostic reporting.
- **src/index-hooks/registry.ts** — static-import list of every
  registered hook (empty on main today; PRs adding hooks fill it
  in), plus the `runAfterIndexAll` / `runAfterSync` runners that
  iterate hooks and catch errors so one broken hook never fails
  indexing.
- **src/index.ts** — `indexAll` calls `runAfterIndexAll(ctx)`
  after resolution. `sync` calls `runAfterSync(ctx, result)`
  after resolution. New private `buildHookContext()` helper
  exposes a stable read-only context.
- **__tests__/index-hooks.test.ts** — 6 tests covering empty
  registry, runner shape, and the `afterIndexAll` / `afterSync`
  contracts.

## Why ship the framework on main with zero registered hooks?

The only consumers of this framework today are 5 unmerged PRs
(colbymchenry#105 cochange + my colbymchenry#112-colbymchenry#115). Landing the framework now lets
each of those PRs rebase to a 2-line change instead of 8-10
lines mutating CodeGraph adjacent-line. Without this, all 5 PRs
collide on the same indexAll/sync call sites.

The framework adds zero behavior on main (no registered hooks =
no-op runner). 380→386 tests confirm no regression.

## Affected open PRs

| PR | Today | After this lands |
|---|---|---|
| colbymchenry#105 cochange | runDerivedSignals helper + 2 call sites | 1 hook file in src/index-hooks/ + 2 lines in registry.ts |
| colbymchenry#112 centrality+churn | same shape | same shape |
| colbymchenry#113 issue-history | same shape | same shape |
| colbymchenry#114 config-refs | same shape | same shape |
| colbymchenry#115 sql-refs | same shape | same shape |

Each goes from "edit CodeGraph in 4 spots" to "drop a hook file."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@andreinknv
Copy link
Copy Markdown
Contributor Author

Part of a coordinated 4-PR refactor that unblocks the open PR backlog. See #120 for the full merge-order guide explaining how this PR fits into the picture and how each open language/feature PR rebases onto the new pattern.

@andreinknv
Copy link
Copy Markdown
Contributor Author

Maintainer review checklist (5-min path)

This refactor is purely additive on main (zero registered hooks → no-op runner). Below is the shortest path to verify both the safety and the leverage.

1. The conflict surface this eliminates

Today, every PR adding a derived-signal pass (centrality, churn, issue-history, config-refs, sql-refs, cochange) edits the same 3 spots in src/index.ts:

  1. New imports at the top
  2. New private method on CodeGraph (runDerivedSignals, runIssueHistoryPass, etc.)
  3. New call site in indexAll after resolution
  4. New call site in sync after resolution

Five PRs (#105, #112, #113, #114, #115) collide on every one of those adjacent-line edits. After this lands, each PR drops to one new hook file + 2 lines in registry.ts. Zero edits to CodeGraph.

2. What changed structurally

  • New src/index-hooks/types.ts (65 lines) — IndexHook interface (afterIndexAll, afterSync — both optional), IndexHookContext (projectRoot, config, queries, db), IndexHookOutcome for diagnostic reporting
  • New src/index-hooks/registry.ts (89 lines) — static-import list (empty on main) + runAfterIndexAll(ctx) / runAfterSync(ctx, result) runners that iterate hooks and catch errors so one broken hook never fails indexing
  • Modified src/index.ts (+29 lines) — indexAll calls runAfterIndexAll(ctx) after resolution; sync calls runAfterSync(ctx, result) after resolution; new private buildHookContext() helper exposes a stable read-only context

3. What stays exactly the same — verify in seconds

  • No public API changes. CodeGraph.indexAll and CodeGraph.sync keep their existing signatures and return types. The hook calls are inserted at the same spots where derived-signal passes are added in feature PRs today.
  • Empty hook registry on main. The runner iterates an empty array — total runtime overhead is one Map iteration over zero entries.
  • No private method rename or removal — this is purely additive plumbing.

4. Why ship the framework with zero registered hooks?

The only consumers today are 5 unmerged PRs. Landing the framework first lets each one rebase to a 2-line registry edit instead of an 8-10-line CodeGraph mutation. Without this, all 5 PRs collide pairwise on indexAll/sync call sites. The framework adds zero behavior on main.

5. Invariants now enforced by tests (__tests__/index-hooks.test.ts, 6 tests)

  • main ships with no registered hooks — regression guard against accidentally registering a hook in main
  • runAfterIndexAll / runAfterSync on an empty registry return an empty outcome list (no crashes from missing iteration handling)
  • ✅ A hook with afterIndexAll receives ctx and is awaited — async hooks can't accidentally be fire-and-forget
  • ✅ A hook with afterSync receives both ctx and result (the sync result object) — needed for hooks that act on changed-files lists
  • ✅ A hook missing afterIndexAll is silently skipped (the contract is "either or both methods")

If a future hook breaks contract (sync hook that doesn't await, hook that mutates context, etc.), these tests are the regression net.

6. Mergeability

380/380 existing tests pass; 6 new tests pass (386 total). If §1-§5 look right, this is safe to land — there is no behavior change on main until a PR registers a hook.

🤖 Comment template generated with Claude Code

…done heartbeat

A hook awaiting a promise that never resolves (e.g. a fetch with no
abort, mis-handled worker IPC) used to hang the whole indexAll/sync
forever — the runner had no per-hook budget and one stuck await
would freeze every later hook plus the post-hook maintenance + caller.

Wraps each hook in Promise.race against a 5-minute budget, surfaces
the timeout in the IndexHookOutcome.error field, and adds start /
done log lines so a stuck hook is identifiable without sampling the
process. Hook failures are now logWarn rather than logDebug so they
aren't silent without CODEGRAPH_DEBUG=1.

Promise.race only unwinds *async* hangs — fully synchronous code
still blocks the event loop. That's acceptable: hooks doing heavy
work shell out via execFileSync with their own per-call timeout.

Adds a regression test covering the racing primitive on a never-
resolving promise.
@colbymchenry
Copy link
Copy Markdown
Owner

Closing — same reasoning as #117. Framework refactor whose only consumers are stacked PRs from the same flood (#105/#112/#113/#114/#115), most of which I've been declining on independent grounds. Codegraph has zero derived-signal passes today, so this is solving a problem that doesn't exist on main. If I decide to add such passes later, I'd rather design the dispatch myself.

Thanks for the work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants