feat(analysis): inter-procedural return-type propagation (Phase 8.2)#1279
Conversation
Extends type tracking beyond single-function scope. The JS/TS extractor now records each function's declared or inferred return type in a new `returnTypeMap`, then propagates that information to variables assigned from call expressions before call-edge resolution runs. Intra-file: `const x = createUser()` resolves x's type when `createUser` has a TS return annotation or a `return new Constructor()` body. Method chains (`getService().getRepo()`) are resolved recursively up to three hops with confidence decaying 0.1 per hop (1.0 → 0.9 → 0.8 → 0.7). Cross-file: `build-edges.ts` collects unresolved call assignments from each extractor output and propagates return types from imported files before both the native and JS call-edge paths run, so both engines benefit automatically. Expected impact: +10–15 pp on caller coverage for factory patterns, builder patterns, and method chains.
|
Claude finished @carlos-alm's task —— View job PR Review: feat(analysis): inter-procedural return-type propagation (Phase 8.2)Tasks:
Root Cause AnalysisProblem Being Solved: When AI agents query codegraph for Why This Approach: This is the right architectural fit — building on the existing Risk Assessment: Medium. The new walk ( Phase 2: Configuration ChangesNo lint/biome rules relaxed. Config changes are:
Both follow the documented pattern for adding behavioral constants. The value Phase 3: Test IntegrityTests in
Phase 4: Code QualityBug:
|
Greptile SummaryPhase 8.2 adds inter-procedural return-type propagation for JS/TS: the extractor now records each function's return type in a
Confidence Score: 5/5Safe to merge; the propagation is purely additive in-memory work that feeds existing call-edge logic without touching the DB schema or edge insertion paths. All previously identified issues (partial mutation inside a transaction, currentClass leaking into function bodies, the annotation guard being always-true, and the dead config knob) have been addressed. The only remaining finding is that 3-hop method chains yield confidence 0.8 instead of the documented 0.7 — a minor discrepancy in a probabilistic value that does not affect correctness of edge construction. resolveCallExprReturnType in src/extractors/javascript.ts — the effectiveDepth accounting for chains longer than 2 hops. Important Files Changed
Sequence DiagramsequenceDiagram
participant Extractor as JS/TS Extractor
participant RTM as returnTypeMap
participant TM as typeMap
participant BE as build-edges.ts
participant DB as SQLite
Extractor->>RTM: "extractReturnTypeMapWalk()<br/>(fn → {type, confidence})"
Extractor->>TM: "extractTypeMapWalk(returnTypeMap)<br/>resolveCallExprReturnType (intra-file, ≤3 hops)"
Extractor->>Extractor: "recordCallAssignment()<br/>(unresolved calls → callAssignments[])"
BE->>BE: "propagateReturnTypesAcrossFiles()<br/>build globalReturnTypeMap + importedNamesMap"
BE->>TM: "setTypeMapEntry(varName, type,<br/>confidence − 0.1) [cross-file]"
BE->>DB: "computeEdgesTx()<br/>buildCallEdgesNative / buildCallEdgesJS"
Reviews (2): Last reviewed commit: "fix(analysis): move cross-file propagati..." | Re-trigger Greptile |
| briefImporterDepth: 5, | ||
| briefHighRiskCallers: 10, | ||
| briefMediumRiskCallers: 3, | ||
| typePropagationDepth: 3, |
There was a problem hiding this comment.
typePropagationDepth config value is never read
The config field is added to DEFAULTS and CodegraphConfig but the actual depth limit used throughout is MAX_PROPAGATION_DEPTH = 3, a hardcoded module-level constant in javascript.ts. Changing analysis.typePropagationDepth in a project config will have zero effect on propagation behaviour. If this is intentionally deferred, the field should at minimum carry a // TODO in the source so it isn't mistaken for a live control knob.
There was a problem hiding this comment.
Fixed — the config field now carries a TODO comment pointing to MAX_PROPAGATION_DEPTH and explaining this will be wired through when config is threaded into extractSymbols (Phase 8.3). The comment explicitly notes the current code uses the hardcoded constant, so it won't be mistaken for a live control knob.
| function walk(node: TreeSitterNode, depth: number, currentClass: string | null): void { | ||
| if (depth >= MAX_WALK_DEPTH) return; | ||
| const t = node.type; | ||
|
|
||
| if (t === 'class_declaration' || t === 'class') { | ||
| const nameNode = node.childForFieldName('name'); | ||
| const className = nameNode?.text ?? null; | ||
| for (let i = 0; i < node.childCount; i++) { | ||
| walk(node.child(i)!, depth + 1, className); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| if (t === 'function_declaration') { | ||
| const nameNode = node.childForFieldName('name'); | ||
| if (nameNode?.type === 'identifier' && nameNode.text !== 'constructor') { | ||
| const fnName = currentClass ? `${currentClass}.${nameNode.text}` : nameNode.text; | ||
| storeReturnType(node, fnName, returnTypeMap); | ||
| } | ||
| } else if (t === 'method_definition') { | ||
| const nameNode = node.childForFieldName('name'); | ||
| if (nameNode && currentClass && nameNode.text !== 'constructor') { | ||
| storeReturnType(node, `${currentClass}.${nameNode.text}`, returnTypeMap); | ||
| } | ||
| } else if (t === 'variable_declarator') { | ||
| // const foo = (): ReturnType => … or const foo = function(): ReturnType { … } | ||
| const nameN = node.childForFieldName('name'); | ||
| const valueN = node.childForFieldName('value'); | ||
| if (nameN?.type === 'identifier' && valueN) { | ||
| const vt = valueN.type; | ||
| if (vt === 'arrow_function' || vt === 'function_expression') { | ||
| const fnName = currentClass ? `${currentClass}.${nameN.text}` : nameN.text; | ||
| storeReturnType(valueN, fnName, returnTypeMap); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| for (let i = 0; i < node.childCount; i++) { | ||
| walk(node.child(i)!, depth + 1, currentClass); | ||
| } | ||
| } |
There was a problem hiding this comment.
currentClass leaks into function bodies, producing incorrect qualified keys
After entering a class_declaration, currentClass is set and propagated to all children. When the walk later visits a function_declaration or method_definition node, it stores the entry correctly — but because neither handler contains an early return before the fall-through child loop, the walk continues into the function/method body still carrying currentClass. Any function_declaration or arrow-function variable_declarator nested inside a method body is then stored in returnTypeMap under ClassName.innerFnName rather than just innerFnName. This creates a false returnTypeMap entry that can trigger incorrect type propagation for calls like svc.formatUser(...) on any UserService-typed receiver. The fix is to pass null as currentClass when recursing into a function body, so nested function declarations are treated as module-scoped.
There was a problem hiding this comment.
Fixed — when recursing into the children of function_declaration and method_definition nodes, the walk now passes null as currentClass. This prevents any function_declaration or arrow-function variable_declarator nested inside a method body from inheriting the enclosing class name. Added early return statements after the null-class recursive calls to avoid double-visiting.
| if (returnTypeNode) { | ||
| const typeName = extractSimpleTypeName(returnTypeNode); | ||
| if (typeName) { | ||
| const existing = returnTypeMap.get(fnName); | ||
| if (!existing || 1.0 > existing.confidence) | ||
| returnTypeMap.set(fnName, { type: typeName, confidence: 1.0 }); | ||
| return; |
There was a problem hiding this comment.
Annotation guard condition is always true — misleading intent
The condition if (!existing || 1.0 > existing.confidence) is designed to mean "don't overwrite a higher-confidence entry", but since 1.0 is the maximum possible confidence, 1.0 > existing.confidence is always true for any real entry (you'd need existing.confidence === 1.0 to avoid overwriting, in which case 1.0 > 1.0 is false). In practice this means the last annotation for a duplicated function name wins rather than the first. Consider existing.confidence < 1.0 or simply removing the redundant guard.
There was a problem hiding this comment.
Fixed — changed 1.0 > existing.confidence to existing.confidence < 1.0. This is semantically identical in effect but clearly expresses the intent: "overwrite unless already at maximum confidence", making it obvious the first annotation at 1.0 wins for duplicate function names.
| buildImportEdges(ctx, getNodeIdStmt, allEdgeRows); | ||
| } | ||
|
|
||
| // Phase 8.2: Augment typeMaps with cross-file return-type propagation before | ||
| // call-edge building so both native and JS paths see the enriched typeMaps. | ||
| propagateReturnTypesAcrossFiles(ctx.fileSymbols, ctx, ctx.rootDir); |
There was a problem hiding this comment.
In-memory mutation runs inside a SQLite transaction — partial-write risk on throw
propagateReturnTypesAcrossFiles mutates symbols.typeMap on the live ctx.fileSymbols entries. If it throws unexpectedly, the SQLite transaction aborts (rolling back the already-computed import edges) while the in-memory typeMap mutations are left in a partial state. Since this function performs no DB I/O it would be safer to call it immediately before computeEdgesTx is opened.
There was a problem hiding this comment.
Fixed — moved propagateReturnTypesAcrossFiles to just before computeEdgesTx() is called (outside the transaction). Since it performs no DB I/O, it needs no transactional protection; running it inside was a risk for leaving ctx.fileSymbols in a partial mutated state if the transaction rolled back.
Codegraph Impact Analysis19 functions changed → 13 callers affected across 5 files
|
…eturnTypeMapWalk - Pass null as currentClass when recursing into function_declaration and method_definition bodies, preventing nested function declarations from being stored under the enclosing class name (e.g. ClassName.innerFn) - Fix annotation guard condition: `1.0 > existing.confidence` was always true (since 1.0 is maximum confidence); replace with `existing.confidence < 1.0` so the first annotation wins for duplicate function names
…t penalty constant - Move propagateReturnTypesAcrossFiles call to before computeEdgesTx opens, avoiding partial in-memory typeMap mutations if the transaction rolls back - Export PROPAGATION_HOP_PENALTY from javascript.ts and use it in build-edges.ts instead of a hardcoded 0.1 literal - Add TODO comment to typePropagationDepth config entry clarifying it is not yet wired to MAX_PROPAGATION_DEPTH (planned for Phase 8.3)
|
Addressed the review feedback: Fixed:
Tracked as issue (out of scope for this PR): |
|
Claude finished @carlos-alm's task —— View job Re-review: feat(analysis): inter-procedural return-type propagation (Phase 8.2)Tasks:
All five code fixes from the original review have been correctly implemented. Here is a verification of each: 1.
|
Summary
extractReturnTypeMapWalkto the JS/TS extractor: scans every function/method definition and records its return type (confidence 1.0 from explicit TS annotations, 0.85 fromreturn new Constructor())const x = createUser()→xgets typeUserin the typeMap; method chains likegetService().getRepo()are resolved recursively up to 3 hops with confidence decaying 0.1 per hop (0.9 → 0.8 → 0.7)build-edges.ts: unresolved call assignments are resolved against imported files'returnTypeMaps before both native and JS call-edge paths runanalysis.typePropagationDepth: 3toDEFAULTS/CodegraphConfigfor future tunabilityTest plan
npm test— all 2790 tests pass, 12 new tests covering:returnTypeMapextraction (annotations, inferred, method qualification, arrow fns), single-hop propagation, method-call propagation, chain resolution, confidence values, and edge cases (no-override, missing return info)npx tsc --noEmit— cleanCloses #[roadmap Phase 8.2]