Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 89 additions & 34 deletions internal/renderers/llmcontext/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"sort"
"strings"
"unicode/utf8"

"github.com/enola-labs/enola/internal/facts"
)
Expand All @@ -30,6 +31,26 @@ func (r *LLMContextRenderer) Name() string {
type section struct {
name string
content string
// reserve sets this section's budget aside before layout, so an oversized
// earlier section cannot starve it.
reserve bool
}

// cutAt returns the first n bytes of s, backed off to the nearest rune boundary.
// The token budget is counted in bytes but the content is UTF-8, so a naive slice
// can split a multi-byte character (a warning glyph, a non-ASCII identifier) and
// emit invalid UTF-8.
func cutAt(s string, n int) string {
if n >= len(s) {
return s
}
if n < 0 {
n = 0
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}

// Render produces the llm_context.md artifact using progressive summarization.
Expand All @@ -38,59 +59,77 @@ type section struct {
func (r *LLMContextRenderer) Render(ctx context.Context, snapshot *facts.Snapshot) ([]facts.Artifact, error) {
// Sections ordered by priority (most important first)
sections := []section{
{"Repository Map", r.renderRepoMap(snapshot)},
{name: "Repository Map", content: r.renderRepoMap(snapshot)},
// Extraction Quality is a compact trust-preface: it states how complete this
// extraction was (thin extraction, parse errors, coverage gaps) BEFORE the
// reader relies on the map below. It sits high on purpose — it is small, and
// it is the in-loop signal an agent uses to spot when enola's own coverage is
// the problem, so it must survive the token budget rather than being the first
// thing truncated on a large repo.
{"Extraction Quality", r.renderExtractionQuality(snapshot)},
{"Architecture Pattern", r.renderArchPattern(snapshot)},
{"Cross-Repo Dependencies", r.renderCrossRepo(snapshot)},
{"Entry Points", r.renderEntryPoints(snapshot)},
{"Routes", r.renderRoutes(snapshot)},
{"Storage", r.renderStorage(snapshot)},
{"Dependency Rules", r.renderDependencyRules(snapshot)},
{"Critical Modules", r.renderCriticalModules(snapshot)},
{"Risk Zones", r.renderRiskZones(snapshot)},
{"How to Add a Feature", r.renderFeatureGuide(snapshot)},
{"Meta", r.renderMeta(snapshot)},
// extraction was (thin extraction, parse errors, unresolved cross-repo edges)
// before the reader relies on the graph. It is reserved rather than merely
// placed high: Repository Map grows with the repo and, on a large multi-repo
// snapshot, overran the whole budget — which truncated this section away on
// exactly the clusters whose extraction is least complete.
{name: "Extraction Quality", content: r.renderExtractionQuality(snapshot), reserve: true},
{name: "Architecture Pattern", content: r.renderArchPattern(snapshot)},
{name: "Cross-Repo Dependencies", content: r.renderCrossRepo(snapshot)},
{name: "Entry Points", content: r.renderEntryPoints(snapshot)},
{name: "Routes", content: r.renderRoutes(snapshot)},
{name: "Storage", content: r.renderStorage(snapshot)},
{name: "Dependency Rules", content: r.renderDependencyRules(snapshot)},
{name: "Critical Modules", content: r.renderCriticalModules(snapshot)},
{name: "Risk Zones", content: r.renderRiskZones(snapshot)},
{name: "How to Add a Feature", content: r.renderFeatureGuide(snapshot)},
{name: "Meta", content: r.renderMeta(snapshot)},
}

header := "# Architecture Snapshot\n\n"
maxChars := r.maxTokens * 4 // rough estimate: 1 token ~= 4 chars
remaining := maxChars - len(header)

// Set the reserved sections' budget aside before laying anything out, so the
// running total the loop spends can never encroach on them. Sections are still
// emitted in priority order; reserving buys survival, not precedence.
for _, sec := range sections {
if sec.reserve {
remaining -= len(sec.content)
}
}

var sb strings.Builder
sb.WriteString(header)

// Once the budget is spent, keep scanning: a later reserved section is still owed
// the bytes held back for it.
spent := false
for i, sec := range sections {
if sec.content == "" {
continue
}
if len(sec.content) <= remaining {
if sec.reserve {
sb.WriteString(sec.content)
continue
}
if spent {
continue
}

switch {
case len(sec.content) <= remaining:
sb.WriteString(sec.content)
remaining -= len(sec.content)
} else if remaining > 200 {
case remaining > 200:
// Partially include this section
cutpoint := remaining - 100
if cutpoint < 0 {
cutpoint = 0
}
sb.WriteString(sec.content[:cutpoint])
sb.WriteString(cutAt(sec.content, remaining-100))
fmt.Fprintf(&sb, "\n\n---\n*[Truncated in: %s]*\n", sec.name)
break
} else {
// List omitted sections
spent = true
default:
// List omitted sections. Reserved ones are not omitted, so naming them
// here would be a lie.
var omitted []string
for _, s := range sections[i:] {
if s.content != "" {
if s.content != "" && !s.reserve {
omitted = append(omitted, s.name)
}
}
fmt.Fprintf(&sb, "\n\n---\n*[Omitted: %s]*\n", strings.Join(omitted, ", "))
break
spent = true
}
}

Expand Down Expand Up @@ -570,7 +609,8 @@ func (r *LLMContextRenderer) renderFeatureGuide(snapshot *facts.Snapshot) string
// reading the snapshot can SEE thin extraction (a bad ignore glob, a failing
// extractor, unresolved cross-repo edges) without calling snapshot_receipt — the
// in-loop signal for improving enola's own coverage. It emphasizes genuine
// signals (parse errors, coverage gaps) and stays quiet when extraction is clean.
// signals (parse errors, coverage gaps, unresolved edges) and stays quiet when
// extraction is clean.
func (r *LLMContextRenderer) renderExtractionQuality(snapshot *facts.Snapshot) string {
m := snapshot.Meta
// Nothing meaningful to report on an old/auto-loaded snapshot with no receipt.
Expand All @@ -589,12 +629,27 @@ func (r *LLMContextRenderer) renderExtractionQuality(snapshot *facts.Snapshot) s
sb.WriteString("- Parse errors: 0\n")
}

if m.Coverage != nil && m.Coverage.CoverageGaps > 0 {
fmt.Fprintf(&sb, "- ⚠️ Cross-repo coverage gaps: **%d** service(s), %d unresolved outbound edge(s) — some links could not be resolved to a loaded repo\n",
m.Coverage.CoverageGaps, m.Coverage.UnresolvedEdges)
// Gaps and unresolved edges are independent: a connected service has resolved an
// outbound edge by definition, yet may still have call sites that resolved to no
// loaded repo. Guarding the count on the gap total hid it on exactly the healthy
// multi-repo snapshot an agent is most likely to trust. External edges are a third
// signal — expected, not a blind spot — so they are reported without a warning.
if m.Coverage != nil {
if m.Coverage.CoverageGaps > 0 {
fmt.Fprintf(&sb, "- ⚠️ Cross-repo coverage gaps: **%d** service(s) with no resolved outbound edges — their outbound links could not be resolved to a loaded repo\n",
m.Coverage.CoverageGaps)
}
if m.Coverage.UnresolvedEdges > 0 {
fmt.Fprintf(&sb, "- ⚠️ Unresolved outbound edges: **%d** — call site(s) that did not resolve to a loaded repo (an unloaded repo, or an extractor blind spot)\n",
m.Coverage.UnresolvedEdges)
}
if m.Coverage.ExternalEdges > 0 {
fmt.Fprintf(&sb, "- Outbound edges to external hosts: **%d** — third-party APIs, expected (not a coverage blind spot)\n",
m.Coverage.ExternalEdges)
}
}

if m.ParseErrors > 0 || (m.Coverage != nil && m.Coverage.CoverageGaps > 0) {
if m.ParseErrors > 0 || (m.Coverage != nil && (m.Coverage.CoverageGaps > 0 || m.Coverage.UnresolvedEdges > 0)) {
sb.WriteString("\n_These are extraction limits, not code defects — verify against source, and consider whether an extractor, detection, or ignore glob needs improving._\n")
}

Expand Down
184 changes: 184 additions & 0 deletions internal/renderers/llmcontext/llm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package llmcontext

import (
"context"
"fmt"
"strings"
"testing"
"unicode/utf8"

"github.com/enola-labs/enola/internal/facts"
)
Expand Down Expand Up @@ -249,6 +251,188 @@ func TestCriticalModules_FanInFanOut(t *testing.T) {
}
}

// renderWithCoverage renders a minimal snapshot whose only extraction-quality
// signal is the given cross-repo coverage summary. A nil summary is the
// single-repo shape: coverageSummary returns nil when there are no service nodes.
func renderWithCoverage(t *testing.T, cov *facts.CoverageSummary) string {
t.Helper()
snapshot := makeSnapshot(nil, nil)
snapshot.Meta.Coverage = cov
return string(mustRender(t, snapshot))
}

// lineWith returns the first rendered line containing substr, or "" if none does.
func lineWith(content, substr string) string {
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, substr) {
return line
}
}
return ""
}

// A connected service resolves at least one outbound edge, so it is not a coverage
// gap — yet any call site it could not resolve still lands in UnresolvedEdges. That
// is the normal healthy multi-repo shape, and it must not hide the count.
func TestExtractionQuality_UnresolvedEdgesWithoutGaps(t *testing.T) {
content := renderWithCoverage(t, &facts.CoverageSummary{
ServicesTotal: 4, CoverageGaps: 0, UnresolvedEdges: 23, ExternalEdges: 2,
})

unresolved := lineWith(content, "Unresolved outbound edges")
if !strings.Contains(unresolved, "**23**") {
t.Errorf("unresolved-edge count not rendered; got line %q in:\n%s", unresolved, content)
}
if !strings.Contains(unresolved, "⚠️") {
t.Errorf("unresolved edges are an internal blind spot and must be flagged; got %q", unresolved)
}

external := lineWith(content, "external hosts")
if !strings.Contains(external, "**2**") {
t.Errorf("external-edge count not rendered; got line %q in:\n%s", external, content)
}
// External call sites are expected, not a blind spot: flagging them would invite
// exactly the misreading this section exists to prevent.
if strings.Contains(external, "⚠️") {
t.Errorf("external edges must not be flagged as a warning; got %q", external)
}

if strings.Contains(content, "coverage gaps") {
t.Errorf("no gaps in this snapshot, yet a gaps line rendered:\n%s", content)
}
if !strings.Contains(content, "These are extraction limits") {
t.Errorf("unresolved edges are an extraction limit; footer missing in:\n%s", content)
}
}

func TestExtractionQuality_GapsStillRender(t *testing.T) {
content := renderWithCoverage(t, &facts.CoverageSummary{
ServicesTotal: 2, CoverageGaps: 1, UnresolvedEdges: 348,
})

if gaps := lineWith(content, "Cross-repo coverage gaps"); !strings.Contains(gaps, "**1**") {
t.Errorf("gap count not rendered; got line %q in:\n%s", gaps, content)
}
if unresolved := lineWith(content, "Unresolved outbound edges"); !strings.Contains(unresolved, "**348**") {
t.Errorf("unresolved count not rendered alongside gaps; got line %q in:\n%s", unresolved, content)
}
if strings.Contains(content, "external hosts") {
t.Errorf("no external edges in this snapshot, yet a line rendered:\n%s", content)
}
if !strings.Contains(content, "These are extraction limits") {
t.Errorf("footer missing in:\n%s", content)
}
}

// Clean cross-repo coverage stays quiet: the section header still renders (Coverage
// is non-nil), but nothing warns.
func TestExtractionQuality_CleanCoverageQuiet(t *testing.T) {
content := renderWithCoverage(t, &facts.CoverageSummary{ServicesTotal: 4})

if !strings.Contains(content, "## Extraction Quality") {
t.Fatalf("section should render whenever Coverage is non-nil:\n%s", content)
}
for _, unwanted := range []string{
"coverage gaps", "Unresolved outbound edges", "external hosts", "These are extraction limits",
} {
if strings.Contains(content, unwanted) {
t.Errorf("clean coverage must not render %q in:\n%s", unwanted, content)
}
}
}

// A single-repo snapshot has no service nodes, so Coverage is nil and there is no
// receipt to report on at all.
func TestExtractionQuality_SingleRepoNoCoverage(t *testing.T) {
content := renderWithCoverage(t, nil)
if strings.Contains(content, "## Extraction Quality") {
t.Errorf("section should be absent with no receipt and no coverage:\n%s", content)
}
}

// bigRepoMapSnapshot returns a snapshot whose Repository Map alone overruns any
// small token budget — the shape of a real multi-repo cluster.
func bigRepoMapSnapshot(modules int, cov *facts.CoverageSummary) *facts.Snapshot {
var ff []facts.Fact
for i := 0; i < modules; i++ {
ff = append(ff, facts.Fact{
Kind: facts.KindModule,
Name: fmt.Sprintf("service/%03d/%s", i, strings.Repeat("segment_", 4)),
Props: map[string]any{"language": "go"},
})
}
snapshot := makeSnapshot(ff, nil)
snapshot.Meta.FilesSeen = 5552
snapshot.Meta.FilesParsed = 2870
snapshot.Meta.Coverage = cov
return snapshot
}

// The Extraction Quality preface is the signal an agent uses to calibrate how far to
// trust the graph, and it is worth more than the tail of a module table. An earlier
// oversized section must not starve it — which is exactly what happened on a 4-repo
// snapshot, where the whole section vanished behind "[Truncated in: Repository Map]".
func TestRender_ExtractionQualitySurvivesTruncatedRepoMap(t *testing.T) {
const maxTokens = 1000
snapshot := bigRepoMapSnapshot(400, &facts.CoverageSummary{
ServicesTotal: 4, CoverageGaps: 0, UnresolvedEdges: 23, ExternalEdges: 2,
})

artifacts, err := New(maxTokens).Render(context.Background(), snapshot)
if err != nil {
t.Fatalf("Render: %v", err)
}
content := string(artifacts[0].Content)

if !strings.Contains(content, "[Truncated in: Repository Map]") {
t.Fatalf("test is not exercising truncation; Repository Map fit the budget:\n%s", content)
}
if !strings.Contains(content, "## Extraction Quality") {
t.Errorf("Extraction Quality starved by an earlier oversized section:\n%s", content)
}
if !strings.Contains(content, "**23**") {
t.Errorf("unresolved-edge count lost to truncation:\n%s", content)
}
// The reservation must come out of the budget, not be added on top of it.
if limit := maxTokens*4 + 100; len(content) > limit {
t.Errorf("content length %d exceeds budget %d", len(content), limit)
}
}

// The budget is a byte count, but the content is UTF-8: a warning glyph or a
// non-ASCII identifier must never be cut in half into invalid UTF-8.
func TestCutAt_NeverSplitsRune(t *testing.T) {
s := "map: `ünïcode/módule` ⚠️ tail"
for n := 0; n <= len(s); n++ {
got := cutAt(s, n)
if !utf8.ValidString(got) {
t.Errorf("cutAt(%q, %d) = %q, which is not valid UTF-8", s, n, got)
}
if len(got) > n {
t.Errorf("cutAt(%q, %d) returned %d bytes, over the limit", s, n, len(got))
}
}
}

// The cut point moves with the budget, so sweep it: some budget lands mid-rune in a
// non-ASCII module name, and the artifact must stay valid UTF-8 at every one.
func TestRender_TruncatedOutputIsValidUTF8(t *testing.T) {
snapshot := bigRepoMapSnapshot(400, nil)
for i := range snapshot.Facts {
snapshot.Facts[i].Name = fmt.Sprintf("sérvice/%03d/%s", i, strings.Repeat("ø", 20))
}

for tokens := 200; tokens <= 400; tokens++ {
artifacts, err := New(tokens).Render(context.Background(), snapshot)
if err != nil {
t.Fatalf("Render(%d): %v", tokens, err)
}
if !utf8.Valid(artifacts[0].Content) {
t.Fatalf("truncation at maxTokens=%d produced invalid UTF-8", tokens)
}
}
}

func TestFileDir(t *testing.T) {
tests := []struct {
input string
Expand Down
Loading