← Blog

Graft v3.6: Find All References and the First Bug in 24 Rounds

3 min read
graftcompilerlspadversarial-debateai-agentstypescript

Graft is a graph-native language for AI agent harness engineering. It compiles .gft source files into execution harnesses — declaring how agents communicate, what context they share, and how token budgets flow through a pipeline. The compiler is built with a multi-agent adversarial debate process where 2-4 AI agents independently analyze and converge on implementations.

v3.6 completes the LSP feature set with find-all-references, and breaks a 24-round streak of zero bugs caught by review.

What's New

Find All References

The last major LSP feature. Right-click any context, node, memory, graph, or produces name and see every reference across your workspace:

context TaskSpec(max_tokens: 1k) {    // declaration
  description: String
}

node Analyzer(model: sonnet, budget: 5k/2k) {
  reads: [TaskSpec]                     // reference
  produces Analysis { issues: List<String> }
}

graph Pipeline(input: TaskSpec, ...) {  // reference
  Analyzer -> done
}

The implementation reuses collectRenameLocations from the rename feature — same word-boundary regex, same comment/string/import-path filtering. A new isReferable() function gates which symbols get references, and it's broader than isRenameable: it also checks producesNodeMap, so produces names like Analysis above are findable too.

Cross-file references scan all workspace .gft files (not just importers like rename does) with a fast string.includes() pre-filter before running the full regex pipeline.

The Produces Name Gap

This was the most interesting finding from A3-Skeptic's analysis. The rename feature uses isRenameable() to gate which symbols can be renamed — but isRenameable only checks 4 ProgramIndex maps (contextMap, nodeMap, memoryMap, graphMap). It doesn't check producesNodeMap.

If find-all-references had reused isRenameable as the gate (the obvious minimal approach), produces names would silently return zero results. A3 caught this before implementation began, thanks to R-PROC-14 (enforce analysis completion before convergence) — the process improvement we proposed in v3.4's retro and first tested here.

Keyword Unification

A tech debt item from v3.5: GRAFT_KEYWORDS was a hand-maintained Set of 26 strings, separate from the lexer's KEYWORDS Record of 38+ entries. Now derived automatically:

export const GRAFT_KEYWORDS = new Set(
  Object.keys(KEYWORDS).filter(k => !TYPE_KEYWORDS.has(k))
);

Type keywords (String, Int, Float, etc.) and the contextual keyword output are excluded — they're valid as identifiers in some positions.

Parse-Based Conflict Detection

Cross-file rename conflict checking previously used a regex (\b(context|node|memory|graph)\s+NewName\b) which could false-positive on declarations inside comments. Now it parses each workspace file and checks ProgramIndex maps directly.

The 24-Round Streak Ends

v3.6-R3's reviewer caught a bug: selectionRange in document symbols started at the keyword position instead of the name position. For context TaskSpec, the selectionRange covered "context " instead of "TaskSpec". The tests passed because they only verified the width, not the absolute position.

This is the first NEEDS_CHANGES verdict since v3.1 — breaking a streak of 24 consecutive rounds across 5 versions with zero bugs caught by review. The fix was one line.

Try It

npm install -g @graft-lang/graft@3.6.0

graft compile pipeline.gft
graft check pipeline.gft
graft run pipeline.gft --input '{"query": "test"}' --dry-run

Stats

| Metric | v3.5 | v3.6 | Delta | |--------|------|------|-------| | Tests | 739 | 790 | +51 | | Ratchet decisions | ~210 | ~220 | +8 | | Agent calls | ~8 | ~11 | +3 | | Rounds | 4 | 4 | 0 | | NEEDS_CHANGES | 0 | 1 | +1 | | Debated rounds | 0 | 1 | +1 | | LSP features | 7 | 8 (+ references) | +1 |


Built with Claude Opus 4.6 via Claude Code. April 2026.