tim witter
All articles

Blog

Structural navigation with honest unknowns

I use structural navigation to narrow my reading, not to replace it.

Start with a map

When a codebase is unfamiliar, an index can point me toward definitions and plausible callers faster than reading files at random. That is a useful saving of attention, especially when names are common. I treat the result as a route to inspect, not a verdict about behavior. Before changing anything, I read the relevant source and check that the symbol, signature, and surrounding conditions match the question I am actually asking.

My usual first query is a symbol name. If the index finds a definition, I open that file and read the surrounding function; if it finds several, I compare the module paths before choosing. For callers I do not trust the list blindly either: I look for string references and configuration keys that might reach the same place by another route.

Common names are where this discipline earns its keep. A query for a generic handler can return a dozen candidates in unrelated modules, and the shortest path is to take the first one and stop. I spend a moment on the module boundaries instead, because the wrong definition read carefully is still the wrong definition.

Leave uncertain edges uncertain

Names can be ambiguous, calls can be indirect, and some connections only appear at runtime. A missing edge in a structural view therefore does not prove that no relationship exists. I would rather see an unresolved edge than a confident but guessed connection. When the map stops, I follow the local evidence: inspect how a value enters, where it is dispatched, and which parts cannot be decided from static structure alone.

Two kinds of error are possible: a missing edge when dispatch is dynamic, and a wrong edge when two symbols share a name. Both are reasons to treat the map as a hypothesis. I check the import at the top of a file and the actual call expression before I believe a connection, and I write down the ones I could not decide.

An honest note is more useful than a complete-looking diagram. In a review I would rather read that one path could not be confirmed statically, so that someone can observe it, than see a confident arrow that nobody verified. The note also ages better, because the next person knows exactly where the map stopped.

query-result.jsonJSON
{
  "query": "handleRequest",
  "resolved": false,
  "reason": "three same-named symbols in different modules",
  "candidates": [
    { "file": "src/api/handler.ts", "line": 42 },
    { "file": "src/jobs/handler.ts", "line": 17 },
    { "file": "src/tools/handler.ts", "line": 8 }
  ],
  "callers": []
}

Verify the path that matters

For a proposed change, I identify a specific behavior at risk and trace enough source to explain that path. Then I choose a focused test or a direct observation that could contradict my reading. If dynamic behavior still leaves a gap, I record it rather than calling the map complete. Structural tools are strongest when they make investigation cheaper while leaving room for honest uncertainty.

For a change, I pick one behavior that could break and trace it from input to effect, reading each step in the source. Then I choose the smallest check that could contradict my reading, and if the check cannot run in isolation, I say why and fall back to a recorded observation. What matters is that the check targets the path I actually traced.

follow-up.shShell
#!/usr/bin/env bash
set -euo pipefail

result=$(code-index query --symbol handleRequest)

if [ "$(echo "$result" | jq -r .resolved)" != "true" ]; then
  # The map stopped here: read every candidate instead of guessing.
  echo "$result" | jq -r '.candidates[].file' | sort -u | while read -r file; do
    echo "read: $file"
  done
  exit 0
fi

echo "$result" | jq -r '.callers[].file' | sort -u