Skip to content

search --snippet-lines N has no per-line character cap — a single hit in a minified/transpiled file (one 120 KB line) returns 120 KB of output even with --snippet-lines 1 #196

Description

@Widthdom

Summary

SearchSnippetFormatter.ClampSnippetLines (src/CodeIndex/Cli/SearchSnippetFormatter.cs:143) caps the number of lines in a search snippet between 1 and 20, but there is no per-line character cap. When the matching file contains a very long single line — extremely common for *.min.js, *.bundle.js, generated JSON, transpiled output, embedded base64 blobs, vendored CSS, etc. — search returns that entire line verbatim. --snippet-lines 1 is supposed to be the smallest possible payload, but on a 120 KB minified line it still returns 120 KB.

This is the highest-leverage payload-size footgun I've found. A single-shot AI consumer asking search getElementById --snippet-lines 1 against a typical webapp index instantly blows the context budget if any hit lands in a minified bundle.

Repro

mkdir /tmp/long && cd /tmp/long
python3 -c "
with open('huge_line.py', 'w') as f:
    f.write('x = ' + '\"a\" + ' * 20000 + '\"end\"\n')
    f.write('def hello(): pass\n')
"
wc -L huge_line.py
# 120009                            ← longest line is 120,009 chars

/root/.local/bin/cdidx index .
/root/.local/bin/cdidx search end --snippet-lines 1 | wc -c
# 120059                            ← 1-line snippet still returns 120 KB

--snippet-lines 1 only changes how many line records are returned, not how many bytes are in each. The CLI / MCP search snippet contract is effectively "lines, not bytes" — but most real-world long-line files are hostile.

For comparison, outline huge_line.py correctly stays small (it only references line numbers, not contents), so the reading layer can already produce small output. Search is the outlier.

Suspected root cause (from reading the source)

src/CodeIndex/Cli/SearchSnippetFormatter.cs:11-14, 143-144:

public const int DefaultSnippetLines = 8;
public const int MaxSnippetLines = 20;

public static IReadOnlyList<string> Format(string content, string query, int maxLines = DefaultSnippetLines, bool caseSensitive = false)

public static int ClampSnippetLines(int maxLines) =>
    Math.Clamp(maxLines, 1, MaxSnippetLines);

maxLines is a line cap. There's no MaxLineChars analogue, no truncation, no ellipsis. Each line of the snippet is included verbatim.

Suggested direction

Three options, increasingly comprehensive. Option A is enough on its own.

Option A — per-line character cap with ellipsis

Add a constant (suggest MaxLineChars = 500 or 1000) and truncate any snippet line longer than that, leaving a marker so AI consumers can ask for the full content via excerpt if needed:

public const int MaxLineChars = 500;

private static string CapLine(string line, int matchColumn = -1)
{
    if (line.Length <= MaxLineChars) return line;

    if (matchColumn < 0)
        return line[..MaxLineChars] + $"… [truncated, full length {line.Length}]";

    // Center the snippet around the match column.
    var halfWindow = MaxLineChars / 2;
    var start = Math.Max(0, matchColumn - halfWindow);
    var end = Math.Min(line.Length, start + MaxLineChars);
    var prefix = start > 0 ? "…" : "";
    var suffix = end < line.Length ? "…" : "";
    return $"{prefix}{line[start..end]}{suffix}";
}

Apply in BuildExcerpt / Format / ToCompactResult before emitting each line. Also expose the original line length on the JSON match_lines records so AI consumers know "this line was 120000 chars, I'm seeing 500".

Option B — also add a per-result byte cap

Useful as a hard ceiling regardless of line count. E.g. MaxResultBytes = 8192 per snippet; if exceeded after Option A's per-line truncation, drop trailing context lines and add a marker.

Option C — --max-line-chars N flag

Lets clients tune the cap explicitly. Default to MaxLineChars; allow --max-line-chars 0 to mean "no cap" for users who really want raw output.

Real-world impact

Tested specifically with a synthetic long-line file, but the failure mode applies to every minified asset under any web project. A first-time search hit on a minified file in a node_modules-adjacent project (or a vendored CSS file in a Rails app) could easily pour 1 MB+ into a single response. AI consumers can't recover gracefully — by the time the response is parsed, the budget is gone.

The MCP search tool has the same vulnerability (it shares the snippet formatter), so the same payload bomb hits Claude / Cursor / Windsurf clients.

Scope

  • src/CodeIndex/Cli/SearchSnippetFormatter.cs — add MaxLineChars const, CapLine helper, integrate into BuildExcerpt / Format / ToCompactResult.
  • src/CodeIndex/Cli/QueryCommandRunner.cs (search command) — wire --max-line-chars option if Option C is chosen.
  • src/CodeIndex/Mcp/McpToolDefinitions.cs + McpToolHandlers.cs — expose the same option to MCP clients.
  • tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs — add tests for:
    • line longer than MaxLineChars is truncated with ellipsis,
    • match-centered windowing keeps the actual match visible (no ellipsis-cut-the-match bug),
    • the truncation marker carries the original length.
  • README + CLAUDE.md design-decisions section — document the cap and how to opt out.

Notes for reviewer

I lack a local .NET SDK in this cloud session, so I can't compile/test the fix. The change is well-isolated to SearchSnippetFormatter.cs; CI / a reviewer with an SDK can validate the truncation math (especially the match-centered window).

Related

  • The general "AI-friendly default payload size" concern is the underlying design value being violated here. The CLAUDE.md design decisions list "Compact search snippets for AI" as a key constraint; per-line cap is the missing half of that contract.

Environment

  • cdidx: v1.10.0 (install.sh).
  • Repro: 120 KB single-line synthetic Python file.
  • Platform: linux-x64 container.
  • Filed from a cloud Claude Code session per CLOUD_BOOTSTRAP_PROMPT.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions