Engineering notes / Context for better software

Before the edit: a Neo4j code graph for Hermes

How we give coding agents a queryable map of files, functions, data access, and affected pages—and why we still verify it against the source.

Published

Updated

20 min read Revision 1

  • Neo4j
  • AI agents
  • Code intelligence

An AI coding agent can make a locally sensible change and still break a page it never opened. The hard part is often not writing the replacement function. It is finding the other places that depend on the function's behavior.

At Cauld & Clark, we use a local Neo4j code map alongside Hermes to make that dependency investigation explicit. Before editing shared code, an agent can ask which functions, callers, data tables, and user-facing pages may be affected. It gets a focused reading list and a starting point for regression checks—not a certificate that the change is safe.

This is our custom integration, not a claim that every Hermes installation includes Neo4j. Hermes is the open-source agent framework from Nous Research; its tools and skills let us make a local workflow available to the agent.[1] The parser, graph schema, wrapper command, and verification rules described here are the surrounding system we maintain.

Scope and privacy. All example paths, identifiers, routes, and code snippets in this article are invented illustrations. They do not describe a client or a production schema. We discuss the engineering method without publishing private repository names, infrastructure addresses, data records, or codebase inventories.

The question we want the agent to ask

Suppose we change an OrderService helper. Searching for its name finds immediate references. It does not automatically explain whether a reference is a generated declaration, a server handler, an API wrapper, or a screen that a human can open.

The useful investigation crosses several boundaries:

  1. Which source files depend on the changed file or symbol?
  2. Which backend functions or endpoints are defined in those files?
  3. Which frontend files call those functions?
  4. Which route files import those callers?
  5. Which data tables appear in the reachable code?
  6. What source evidence should we inspect, and which pages should we re-check?

We want a repeatable answer to those questions before an agent edits the shared implementation. We do not want to paste an entire repository into its context, nor ask a language model to invent dependency edges from file names.

Source files pass through deterministic extractors into Neo4j; impact queries return evidence for a Hermes agent and regression checklist.

Architecture: deterministic extraction, graph retrieval, agent judgment

The pipeline has four distinct responsibilities.

The indexer reads source. It enumerates eligible files through Git, parses supported syntax, resolves local imports, identifies framework-specific functions and routes, and writes structured facts. TypeScript's parser handles the JavaScript and TypeScript syntax tree. Some adapters also use intentionally narrow text patterns. No language model generates the graph edges.

Neo4j stores relationships. Files, callable backend functions, and data tables become nodes. Imports, definitions, calls, and observed data access become typed edges. Every node has a project key, so queries can stay inside the intended repository.

The command-line interface answers bounded questions. It resolves the working directory to a registered project, queries the graph, and prints both intermediate evidence and a final list of pages or routes to re-check. This is a text interface; an agent does not need a graph visualization to use it.

A Hermes skill defines the workflow. The skill tells the agent when to ask for impact, how to interpret the result, what information to carry into a review or delegated task, and when to fall back to plain text search. The graph contributes facts. The skill contributes procedure. The agent still has to read code and verify behavior.

That separation is important. A deterministic extraction mistake is something we can reproduce and test. An agent's explanation is not the ground truth for whether a relationship exists.

The graph model we actually use

The core model is deliberately small. A page is a file with route metadata, not a separate universal Page node. A symbol is not automatically a Function node: the indexer creates those nodes for supported backend function patterns and endpoints. Some finer-grained declaration analysis stays inside the indexer's working data instead of becoming a full language-wide call graph.

Scroll horizontally to see all table columns →

Entity or relationship What it represents Important boundary
File Project-relative source path, application grouping, optional route, generated/test flags A recognized route convention is not proof that the page is deployed
Function Supported backend callable, with identifier, module, name, and kind Not every method or callback in the repository is indexed as a callable
Table A statically identified table or collection name No rows or business records are imported
IMPORTS One file imports another; carries imported names and re-export information A static dependency is not proof of runtime execution
DEFINED_IN A function or schema table is declared in a source file Definitions depend on the relevant extractor
CALLS A file refers to a supported backend callable or matches an endpoint URL An inferred possible call, not an observed request trace
READS / WRITES A recognized function body references a literal table access Indirect and identifier-only access can be invisible
TOUCHES File-level read, write, or use of a table or collection Preserves evidence in helpers even when function attribution is unavailable

Composite uniqueness constraints combine the project key with the file path, function identifier, or table name. Identically named files in different projects should not collapse into one node.

For opt-in TypeScript HTTP analysis, endpoint nodes also carry dependency entries such as file:lib/order-rules.ts and table:orders. These entries summarize an index-time traversal through declarations. They are not a claim that every function-to-function call has been stored as an edge in Neo4j.

Illustrative model: a page imports an API wrapper, the wrapper calls a function, and the function reads a table. The function is defined in a source file.

Building the map from source

Start with Git's view of the working tree

The indexer uses Git's file listing with tracked and non-ignored untracked files. It then restricts the scan to supported source extensions and excludes build output, dependency folders, and declaration-only TypeScript files.

This is not the same as indexing a commit tree. On a normal checkout, an eligible uncommitted file can be visible. On a dedicated mirror, the source follows the selected remote branch. The command's project selection also resolves Git worktrees to their shared repository identity: running an impact query inside a feature worktree does not mean the graph contains that worktree's unmerged changes.

We keep that distinction visible in the output. A useful dependency map can become dangerous if the agent mistakes its snapshot for the exact code it is about to edit.

Resolve imports before counting dependencies

Relative imports are only the beginning in a monorepo. The resolver reads nearby TypeScript configuration, applies recognized path aliases, inspects workspace package names and export mappings, and tests source-file and directory-index candidates.

For named imports, it records the names brought in from the target file. For re-exports, it marks the relationship as forwarding names. Namespace imports, dynamic imports with literal targets, and side-effect imports are treated more broadly.

Why keep that detail? Consider an illustrative shared client module exporting loadOrders and loadPreferences. A screen that imports only loadPreferences should not automatically be flagged for a change that reaches the module only through loadOrders. File-level reach alone can turn one narrow change into a list of almost every screen. Name-aware traversal reduces that noise, although aliases and unusual barrel patterns still deserve tests.

Recognize backend functions and data access

The existing adapters recognize particular exported query, mutation, and action definitions. A stable identifier is formed from the module and exported name. The body is inspected for supported literal table reads and writes.

We also extract table access at file level. A non-exported helper may contain the database call even when the exported entry point does not. A TOUCHES edge gives the agent a reason to open the file without pretending we have proved which exported function reaches that access.

The distinction between recognition and semantic understanding matters here. A call like db.query("orders") exposes a literal name. A lookup through an identifier, a dynamically selected collection, or a helper that resolves the table at runtime may not. The resulting graph must not translate “no recognized table edge” into “this code does not access data.”

Match HTTP endpoints conservatively

The toolkit contains adapters for several route styles: Go registrations and route annotations, Python router decorators, and opt-in hand-dispatched TypeScript servers. Their coverage is not identical. Python route recognition, for example, is not a general Python import or call graph.

Backend route parameters are normalized for comparison with frontend URL literals. A whole interpolated URL segment can match a route parameter; it must not stand in for an unrelated literal segment. An unresolved base URL is not silently converted into certainty. If the backend prefix is unknown, a partial route must still match the literal tail conservatively.

For an invented example, /api/orders/${orderId}/status is evidence for a route shaped like /api/orders/:id/status. It is not evidence for every route with four segments. The current matching is also not a complete HTTP client analysis: a URL can be ambiguous across methods, and strings assembled through variables may not be placed at all.

For supported TypeScript handlers, the indexer follows referenced declarations, local and imported helpers, and re-exporting modules to build endpoint dependencies. This is more precise than recursively treating every export in every imported file as used. It still relies on recognized source patterns and bounded handler spans, not a whole-program proof.

Translate source locations into pages

Route metadata comes from recognizable application folder conventions. The extractor removes pathless layout segments, normalizes parameter notation, and attributes colocated components to a route where the convention permits it. Explicit screen mappings are an option for applications without a route-folder structure.

When a caller lives in a component or client module rather than a page, the CLI walks incoming import relationships to find route files. Named imports constrain the first relevant hops; forwarding barrels preserve the names that matter. Once a real consuming module is affected, its importers can be relevant more broadly.

The traversal is bounded. A deeply nested dependency chain can fall outside the default depth. The output is a useful candidate regression list, not an exhaustive statement about every possible route in a running application.

GraphQL: distinguish the design from the active extractor

A large TypeScript monorepo with a GraphQL API and several web and mobile front ends makes the API boundary especially interesting. status is not a globally unique field: Order.status and Invoice.status are different dependencies. A fragment can select a field even when the consuming operation never spells that field's name locally. Generated document imports add another indirection.

We inspected both the active indexer and a separate GraphQL verification utility while preparing this article. The active indexer we reviewed does not implement GraphQL resolver-to-operation linking. The presence of a schema configuration key or a verification script is not evidence that the scheduled indexer supports that path. We therefore do not describe GraphQL field coverage as a working end-to-end capability of this snapshot.

The separate verifier shows the validation approach we want for that extension. It loads a schema, parses operation documents, uses type information to identify fields as ParentType.field, follows named fragments within an application, and recognizes supported generated document or hook imports. It then compares those independently derived callers with graph edges of the GraphQL kind. It is a verification utility, not the missing ingestion pipeline, and we are not reporting a fresh accuracy result from it here.

An illustrative extension would use an identifier such as Query.orders, link its resolver definition to a source file, then link operation-bearing files and supported generated-document consumers to that field. Nested output fields need their own type-qualified identities. Aliases must resolve to the underlying schema field; fragments need cycle protection; similarly named operations in separate applications must not be merged indiscriminately.

Until ingestion, refresh, and verification all agree, our practical fallback is to search the resolver name, the schema field, operation definitions, fragment spreads, and generated-document imports directly. The architecture can accommodate an edge; that does not mean the edge has been extracted.

What an impact query does

An impact request can start from a function identifier, a table name, or a repository-relative file. The entry point changes the traversal.

From a function: find files with CALLS edges to it, show recognized table reads and writes, then trace callers back to pages.

From a table: find function-level reads and writes, include file-level TOUCHES evidence, identify supported endpoints associated with those files, then find callers and pages. The file-level evidence is printed separately so the agent does not overlook helper code.

From a file: find importers within the configured depth, identify relevant backend functions, follow their callers, and collect page candidates. For Go and Python, endpoint attribution is intentionally limited to the affected package directory; expanding package-wide imports into every downstream endpoint would overstate certainty. The other importers are still evidence to inspect when changing shared types or helpers.

Here are three illustrative, read-only Cypher queries using the core model. Bind $project and the target parameters to synthetic or authorized local values. These examples explain the relationships; they are not a verbatim replacement for the CLI's name-aware traversal.

cypher · Scroll horizontally for long lines →

MATCH (fn:Function {project: $project, id: $functionId})
      <-[:CALLS]-(caller:File {project: $project})
RETURN DISTINCT caller.path AS file,
                caller.app AS app,
                caller.route AS route
ORDER BY app, file

cypher · Scroll horizontally for long lines →

MATCH (t:Table {project: $project, name: $tableName})
      <-[access:READS|WRITES]-(fn:Function {project: $project})
MATCH (fn)-[:DEFINED_IN]->(source:File {project: $project})
RETURN fn.id AS function,
       collect(DISTINCT type(access)) AS access,
       collect(DISTINCT source.path) AS definitions

cypher · Scroll horizontally for long lines →

MATCH (changed:File {project: $project, path: $path})
      <-[:IMPORTS*1..3]-(candidate:File {project: $project})
WHERE candidate.route IS NOT NULL
  AND coalesce(candidate.test, false) = false
RETURN DISTINCT candidate.app AS app, candidate.route AS route
ORDER BY app, route

The last query is intentionally a coarse file-import check. It does not apply the CLI's imported-name filtering, and it excludes the seed file itself. A route file can itself be the changed file, so a real impact workflow must include that case separately. Cypher makes the traversal readable, but the correctness of its interpretation still depends on the schema and extraction rules.[2]

How Hermes calls it

Our wrapper delegates to a local Node-based CLI. The skill gives the agent a compact vocabulary. With that wrapper available on the command path, an illustrative session looks like this:

bash · Scroll horizontally for long lines →

# Invented targets, not commands against a published demo dataset.
code-graph impact orders.queries.list
code-graph callers orders.queries.list
code-graph table orders
code-graph impact lib/order-rules.ts

A typical decision sequence is:

  1. Resolve the project from the current repository and inspect the freshness notice.
  2. Ask for impact before editing a shared function, route, table, or component.
  3. Read the listed implementation files and callers, not just the final page names.
  4. Compare important or surprising results with direct searches.
  5. Carry the pages to re-check into the implementation brief and regression checklist.
  6. Make the change, run focused tests, and exercise the affected user-visible flows.

The CLI also exposes a raw query command with $project supplied automatically. We use it for read-only investigation. Its read-access session mode expresses intent; it should not be confused with a database authorization boundary. A reader building this system should use database permissions to restrict a query principal and keep ingestion privileges separate.

There is a practical output constraint too: the CLI caps displayed sections. A printed total can exceed the number of visible rows. The agent must retrieve omitted results before claiming that it has a complete regression list. Summarizing a truncated answer as exhaustive would lose the benefit of the graph at the last step.

Refreshing the graph without disturbing development

The refresh wrapper is designed for a nominal five-minute schedule. It enumerates registered projects, checks database availability, updates dedicated mirrors when configured, and invokes the incremental indexer. New registry entries are picked up by that same loop; they do not require one schedule per repository.

Only a disposable mirror may be reset to a remote branch. A developer's working checkout is not a mirror. Resetting it to keep a code map current would be an unacceptable way to destroy uncommitted work. The distinction belongs in configuration and in the operating procedure, not in an agent's guess about which folder is safe.

The indexer hashes eligible file contents and compares them with per-project state. Changed files are parsed again; removed files are deleted from the graph. Stale outgoing relationships for changed files are cleared before replacement. When backend definitions change, callers can need relinking even if their source files did not change: an unchanged caller may now resolve to a newly added target.

Some updates deliberately do more than patch one file. Backend endpoint changes rebuild the endpoint set. Opt-in TypeScript endpoint dependency analysis can require a wider rebuild when another TypeScript file changes. “Incremental” means avoiding unnecessary work where the model permits it, not promising that every edit updates exactly one node.

The state file is written after the successful indexing sequence. Unchanged runs exit early, so its updatedAt is the last successful graph-changing update, not necessarily the last health check. We inspect refresh logs as well as the graph timestamp before declaring a snapshot stale.

There are further freshness limits worth stating. The writes run in batches, not one atomic publication of an entire repository snapshot; a concurrent query can observe an intermediate rebuild. Configuration changes and extractor changes are not themselves source-file hash changes, so they require an intentional full rebuild rather than blind trust in the incremental fast path.

The logs we reviewed show unchanged checks and incremental updates with timestamps and summary counts. They do not contain per-run elapsed durations. We therefore make no claim here about indexing speed, token savings, or an observed latency improvement. A schedule interval is not a benchmark.

Failure reporting is transition-based: report when a failure first appears and when recovery occurs, rather than sending the same message on every tick. When the map is unavailable, agents continue with direct source searches. The graph is an aid to engineering, not a single point of permission to do engineering.

Checking accuracy against plain text search

A graph can be consistently wrong. We validate extraction rules against another way of obtaining evidence, not against how convincing the graph visualization looks.

For a sampled callable, we independently search source for supported reference patterns, exclude known generated noise, inspect ambiguous matches, normalize file paths, and compare sets:

text · Scroll horizontally for long lines →

expected = independently confirmed callers
actual   = callers returned by the graph
missed   = expected - actual
extra    = actual - expected

Both differences matter. Missed callers create false reassurance. Extra callers turn a focused review into noise, and noise teaches agents to disregard the tool.

For HTTP links, we check in both directions: does each reported frontend-to-endpoint edge have evidence, and what proportion of candidate frontend URLs could be placed at all? Perfect precision on recognized links would not mean complete route coverage. Unresolved base variables, library-owned endpoints, and dynamically assembled paths must remain visible as gaps.

For GraphQL, literal field-name search alone is insufficient. The independent verifier's type-aware and fragment-aware approach is necessary to distinguish fields and include indirect consumers. A search result is a candidate to validate, not a substitute for schema context.

We also use before-and-after regression comparisons when changing an extractor. Representative function, table, and file targets should keep their established relationships unless the new rule intentionally changes them. An unexplained disappearance is not accepted as “cleaner output.”

This article does not publish private codebase totals or a new accuracy percentage. The actionable lesson is the validation method: compare exact sets, inspect disagreements, test both precision and coverage, and retain fixtures for the syntax that caused the mistake.

What the graph does not know

The limitations are part of the interface, not a footnote to hide after the success story.

  • Runtime dispatch. Reflection, dependency injection, plugin registration, computed imports, and dynamically composed URLs may not resolve statically.
  • All language semantics. Syntax-tree parsing is not the same as type checking, control-flow analysis, or a full interprocedural call graph. Some adapters still use text heuristics.
  • Every data dependency. Identifier-based lookups, opaque helpers, and dynamic table selection can leave no recognized read or write edge.
  • Request behavior. A matching URL does not prove the request executes, uses the expected method, passes authorization, or returns a compatible shape.
  • Unmerged changes. A worktree-local edit is not in the registered checkout merely because the CLI is invoked from that worktree.
  • Unlimited reach. Traversal bounds, generated-file exclusions, route recognition, and output caps all influence the answer.
  • A coherent global instant. Batched refreshes are not an atomic snapshot swap. A query during rebuilding may need to be repeated after completion.
  • GraphQL completeness. A verifier and a configuration field do not complete the missing active ingestion path described above.
  • Business correctness. The graph cannot tell us whether a discount rule, permission policy, or lifecycle transition is appropriate. Those require specifications, tests, and people.

The most important sentence we teach the agent is simple: an empty impact result means “no supported relationship was found,” not “nothing can break.”

Why Neo4j, and when we would choose something simpler

Our question is naturally about relationships across several hops. Neo4j lets us store those relationships directly and express the traversal in Cypher rather than bury it in a one-off script.[2] We can inspect intermediate nodes, separate relationship types, and evolve extraction without changing the agent-facing command vocabulary.

That is an architectural preference, not a published performance comparison. A relational database with edge tables and recursive queries could serve a similar purpose. A small repository could use an in-memory adjacency map or a JSON artifact. Starting there can be the right choice if refresh, deployment, and access control should remain minimal.

Plain text search remains the simplest way to discover exact names and patterns. A language server can offer richer symbol semantics for supported languages. Semantic retrieval can help find conceptually related code when we do not know a name. None of these must be replaced by a graph. We use the graph for a specific job: making recognized structural dependencies easy to traverse and carry into an agent's workflow.

The cost of the choice is real: an extra database, a schema to maintain, adapters that drift with source conventions, freshness management, and an independent verification suite. If the traversal does not improve the actual review checklist, a simpler representation wins.

Building a similar system

We would start with one narrow, testable slice rather than attempt a universal code intelligence platform.

  1. Choose the engineering question. “Which pages use this backend callable?” is a better initial target than “understand the codebase.” Define the output an agent will act on.
  2. Choose a safe source snapshot. Use an authorized local checkout or a dedicated mirror. Record its branch and revision. Keep read-only indexing separate from developer work.
  3. Define a small schema. Start with project-scoped files, supported callables, imports, definitions, and calls. Add tables only when you can extract them with evidence.
  4. Parse rather than guess. Use the language parser for syntax and explicit adapters for framework conventions. Preserve unresolved cases instead of inventing edges.
  5. Make identity stable. Combine project identity with paths and qualified callable identifiers. Decide how renames and deletions remove stale nodes and edges.
  6. Validate a tiny fixture end to end. Give a synthetic page an API wrapper and backend callable. Add a negative case that imports an unrelated export. Prove both the included and excluded pages.
  7. Add incremental state and recovery. Detect changed and removed files, invalidate dependent links, and write state only after successful updates. Plan for a full rebuild when configuration or parser behavior changes.
  8. Expose a narrow CLI. Prefer predictable commands and structured evidence over giving the agent unrestricted graph administration. Use scoped credentials and query limits appropriate to the environment.
  9. Write the skill as a procedure. Specify when to call the tool, what caveats to check, how to handle truncation, and which source searches to run when the answer is incomplete.
  10. Measure after it works. Record elapsed durations if speed matters. Track independently verified missed and extra relationships. Do not infer productivity gains from the size of the graph.

For a production-strength next iteration, we would also want explicit extractor-version fingerprints, a last-checked timestamp distinct from last-changed, source revision metadata, and an atomic generation switch for readers. Those are recommendations, not claims about the current implementation.

The lesson: better context needs a checking loop

The value of this system is not that Neo4j makes an agent omniscient. It is that we can turn “I think this change is local” into a concrete investigation: these definitions, these possible callers, these data accesses, these pages, and these known gaps.

We keep the graph small enough to understand, the extraction deterministic enough to debug, and the agent instructions explicit enough to use the evidence properly. Then we check the map against the source and the change against running software.

Before the edit, find the consequences. After the edit, verify them.

Implementation and source notes

The implementation-specific descriptions above come from a read-only review of our local indexer, CLI, refresh wrapper, project registry structure, skill instructions, verification utilities, and refresh log. No private source files, credentials, repository inventories, or data records are reproduced. The GraphQL caveat reflects the inspected active indexer, not an assumption that every toolkit copy has identical capabilities. The diagrams and code examples are original, illustrative teaching material.

The public references below explain the underlying tools, not our private deployment or an independently audited performance result.

Sources

[1] https://hermes-agent.nousresearch.com/docs — Hermes Agent documentation [2] https://neo4j.com/docs/cypher-manual/current/introduction/ — Cypher Manual introduction (Neo4j)

Share the thinking

A living document

Revision history

Snapshots preserve what was published. Corrections and additions are recorded here.

  1. Initial publication: architecture, extraction rules, impact queries, freshness, and verification boundaries.

More from the engineering notebook.

Explore all articles Subscribe via RSS

Cauld & Clark

Our services, explained by AI.

Hi! I can help with AI Agents, Context Infra, Serverless Infra, and SaaS Software. What are you working on?

Complete the security check to send.