fix(steps): three things the real repos caught

Validating the reading against four real servers turned up three defects, all
in the walk and all visible in both readings:

- **A hop's span is only a hop's span when the call is the one we asked for.**
  An inline Express handler's edges carry the ROUTE's line — where the only
  call is `router.post('/users/login', async (req, res) => {` — so the read
  span covered the whole registration and every call in the handler counted as
  written inside it, and so as running first. express-realworld's login drew
  its 200 before the `login()` that produces it. A read that does not find the
  call it was asked for is now a bare position: no span, no `inside`.

- **A name-match the call as written disproves.** `crypto.createHash('sha256')
  .update(…)` in a Nest service kept only `update` in the index and matched it
  to the caller's own `AuthService.update` — and the login endpoint then read
  as though it updated the user, four extra replies and a session delete
  included. In this family a method of your own class is written `this.x(…)`,
  so a receiver that is not `this` proves the guess wrong; the call leaves the
  index instead. The endpoint goes from 15 steps to 6, all of them real.

- **A value with no calls of its own is lent the file's.** The gate counted any
  edge, and `const signIn = validatedAction(schema, async (data) => { … })`
  holds one plain `references` edge to its schema — so the whole server action
  went unlent and its picture had one call out of nine. Only edges the walk
  follows as behaviour count now, and next-saas-starter's `signIn` reads whole:
  the lookup, two early returns, `Promise.all` of session and activity log, and
  the redirect to /dashboard or the checkout session.

Also: an `elif` whose body raises does not mean the arm it is written in always
raises — FastAPI's `if not user: raise … elif not user.is_active: raise …` was
ending its own arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
Colby McHenry
2026-08-29 13:46:01 -05:00
co-authored by Claude Opus 5
parent 7b6704a70d
commit 676030314a
3 changed files with 58 additions and 7 deletions
+2 -2
View File
@@ -1212,7 +1212,8 @@ function branchKey(node: SyntaxNode): string {
* Containers whose LAST statement decides how the whole thing leaves: a block,
* and the clause wrappers a grammar puts an arm's block inside (`else_clause`,
* `except_clause`) — the walk climbs through those, so the node it hands back
* is the wrapper, not the block.
* is the wrapper, not the block. A CONDITIONAL wrapper is not one of them: an
* `elif` whose body raises does not mean the arm it is in always raises.
*/
const BLOCKISH: ReadonlySet<string> = new Set([
'statement_block',
@@ -1222,7 +1223,6 @@ const BLOCKISH: ReadonlySet<string> = new Set([
'compound_statement',
'control_structure_body',
'else_clause',
'elif_clause',
'else_statement',
'catch_clause',
'catch_block',
+42 -5
View File
@@ -397,12 +397,20 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
const line = at.line ?? caller.startLine;
const column = at.column ?? 0;
const read = at.line ? await callAt(caller, { ...at, ...(callee ? { callee } : {}) }) : null;
// The read must be THIS call. An inline handler's edges carry the ROUTE's
// line (`router.post('/users/login', async (req, res) => {`), where the
// only call is the registration itself — and taking its span would make
// every call in the handler read as written inside it, and so as running
// first. A miss is a bare position: no span to nest by, no `within`.
const last = (n: string) => n.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? n;
const usable = read !== null && (!callee || last(read.callee) === last(callee));
if (!usable) return { file: caller.filePath, line, column, end: { line, column }, within: null };
return {
file: caller.filePath,
line: read?.span?.start.line ?? line,
column: read?.span?.start.column ?? column,
end: read?.span?.end ?? { line, column },
within: read?.within ?? null,
line: read.span?.start.line ?? line,
column: read.span?.start.column ?? column,
end: read.span?.end ?? { line, column },
within: read.within ?? null,
};
};
const pointHop = (caller: Node, at: { line?: number; column?: number }): HopSite => {
@@ -478,6 +486,19 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
return members[0] ?? null;
};
/**
* A name-match that the call as written disproves: the target is a method of
* the CALLER's own class, but the call names a receiver that is not `this` —
* and in the JS family a method of your own class cannot be called any other
* way. Only there: `self.` is a convention elsewhere, not a rule.
*/
const ownMethodWithoutReceiver = (caller: Node, target: Node, written: string): boolean => {
if (!JS_FAMILY.has(caller.language) || target.kind !== 'method') return false;
if (/^(?:this|self)[.?]/.test(written)) return false;
const container = (q: string) => q.replace(/[.:]+[^.:]*$/, '');
return caller.filePath === target.filePath && container(caller.qualifiedName) === container(target.qualifiedName);
};
/** A method the walk cannot enter (an interface's, an ORM's) on a repository-shaped container. */
const repositoryMethod = (target: Node): boolean => {
if (target.kind !== 'method' && target.kind !== 'function') return false;
@@ -892,7 +913,15 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
// lands on the constant, and the walk goes on into what the handler does.
for (const fold of frontier) {
const value = fold.node.kind === 'constant' || fold.node.kind === 'variable';
if ((fold.node.kind !== 'component' && !value) || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
// Edges the walk would follow as BEHAVIOUR. `const signIn =
// validatedAction(schema, async (data) => { … })` holds a plain
// `references` edge to its schema and nothing else: counting that as
// "it has edges of its own" left the whole handler body unlent, and the
// picture showed one call out of nine.
const behaviour = (bySource.get(fold.node.id) ?? []).filter(
(e) => e.kind !== 'references' || (e.metadata as Record<string, unknown> | undefined)?.fnRef === true
).length;
if ((fold.node.kind !== 'component' && !value) || behaviour > 0) continue;
for (const e of fileScopeEdgesWithin(cg, fold.node, fileScopeRefs, value)) {
const list = bySource.get(fold.node.id) ?? [];
list.push({ ...e, source: fold.node.id });
@@ -1025,6 +1054,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
if (real && real.id !== target.id) {
target = real;
retargeted = true;
} else if (real === null && ownMethodWithoutReceiver(fold.node, target, written.callee)) {
// `crypto.createHash('sha256').update(…)` in a method of a
// class that happens to have an `update`: the index kept
// only `update` and matched it by name. In this family a
// method of your own class is written `this.update(…)`, so
// a receiver that is not `this` proves the guess wrong. The
// call leaves the index — say nothing rather than the wrong thing.
continue;
}
}
}