Summary
StripLeadingCSharpAttributeLists at SymbolExtractor.cs:623-661 has an off-by-one fallback: when the attribute list consumes the entire line (ending with ] at the last column, followed by newline), cursor == line.Length is reached, and the function returns the original line instead of an empty string. This allows regex patterns to match attribute content as if it were code.
The bug is masked for single-attribute lines because the stripped-style attribute content doesn't match any method regex anchor. But it manifests for comma-separated multi-attribute lines ([Attr1, Namespace.Attr2("arg")]) where the comma + following space create an alignment that satisfies the explicit interface regex at :116, producing a phantom function symbol.
Repro
CDIDX=/root/.local/bin/cdidx
mkdir -p /tmp/dogfood/cs-attr-strip-bug
cat > /tmp/dogfood/cs-attr-strip-bug/A.cs <<'EOF'
namespace AttrStripBug;
public class Svc
{
// Single attribute — works correctly (no phantom)
[System.ComponentModel.DisplayName("Hidden")]
public void Single() { }
// Comma-separated attributes — PHANTOM `function DisplayName` emitted
[System.Obsolete, System.ComponentModel.DisplayName("Hidden")]
public void Combo() { }
}
EOF
"$CDIDX" index /tmp/dogfood/cs-attr-strip-bug --rebuild
"$CDIDX" symbols --db /tmp/dogfood/cs-attr-strip-bug/.cdidx/codeindex.db
Expected: Single, Combo, Svc, namespace = 4 symbols.
Actual:
function Combo A.cs:11
function DisplayName A.cs:10-11 ← PHANTOM (body includes next line)
function Single A.cs:7
class Svc A.cs:3-12
namespace AttrStripBug A.cs:1
DisplayName at A.cs:10-11 is a phantom created from the content of the attribute list on line 10.
Suspected root cause
src/CodeIndex/Indexer/SymbolExtractor.cs:623-661 — StripLeadingCSharpAttributeLists:
private static string StripLeadingCSharpAttributeLists(string line)
{
var index = 0;
while (index < line.Length && char.IsWhiteSpace(line[index]))
index++;
if (index >= line.Length || line[index] != '[')
return line;
var cursor = index;
while (cursor < line.Length && line[cursor] == '[')
{
var depth = 0;
var sawBracket = false;
while (cursor < line.Length)
{
var ch = line[cursor++];
if (ch == '[')
{
depth++;
sawBracket = true;
}
else if (ch == ']')
{
depth--;
if (depth == 0 && sawBracket)
break;
}
}
if (depth != 0)
return line;
while (cursor < line.Length && char.IsWhiteSpace(line[cursor]))
cursor++;
}
return cursor < line.Length ? line[cursor..] : line; // ← BUG
}
Trace for line 10 [System.Obsolete, System.ComponentModel.DisplayName("Hidden")] (length 66):
index = 4 (after skipping 4 spaces).
line[4] == '[', enter outer loop.
- Inner loop consumes through
] at position 65. cursor becomes 66 (past the ]).
depth == 0, OK.
- Skip whitespace:
cursor == 66 == line.Length, no more chars.
- Outer loop check:
cursor < line.Length → 66 < 66 is false. Exit.
- Final return:
cursor < line.Length ? line[cursor..] : line → 66 < 66 is false → returns line (the ORIGINAL).
Now the explicit interface regex at :116 processes the original line:
- returnType
[\w?.<>\[\],:]+ greedy matches [System.Obsolete, (positions 4-20, 17 chars — the char class includes [, ,).
\s+ matches the space at position 21.
- Qualifier
[\w.<>:]+ backtracks to System.ComponentModel (positions 22-42).
\. matches . at position 43.
(?<name>\w+) captures DisplayName (positions 44-54).
[\(\[] matches ( at position 55.
Phantom emitted: function DisplayName with body range starting at line 10 (via BodyStyle.Brace which finds { on the next line).
The single-attribute case [System.ComponentModel.DisplayName("Hidden")] ALSO hits the same stripping bug (returns the original line), but the explicit interface regex doesn't match because there's no , to create the intermediate whitespace alignment.
Suggested fix
The final return statement should use line[cursor..] unconditionally:
return line[cursor..]; // When cursor == line.Length, this returns ""
C#'s range operator handles cursor == line.Length correctly — it returns an empty string. The existing ternary is defensive programming gone wrong: it assumes line[cursor..] might throw, but it doesn't.
Alternative: keep the ternary but flip the fallback:
return cursor < line.Length ? line[cursor..] : string.Empty;
Both fixes produce the same behavior: when the attribute consumes the entire line, the stripped result is empty.
Defense in depth: the explicit interface regex at :116 could also add [ to its negative returnType lookahead. [ is not a legal starting character for a C# return type, so excluding it would harden against any future similar stripping failure. But fixing the stripper is sufficient.
Why it matters
- Attribute-list phantom symbols pollute
symbols, definition, outline, inspect, map. Any codebase using comma-separated attributes ([Obsolete, DisplayName("x")] is idiomatic short-form) gets phantom rows.
- Real-world frequency:
[Flags, Serializable], [JsonIgnore, XmlIgnore], [Obsolete, EditorBrowsable(...)] — comma-separated attribute lists are extremely common in .NET DTOs, EF entities, and serialization-heavy code.
- Silent. The phantom has a realistic-looking name (the attribute type name), a body range spanning the attribute line + the following method line, so it can be mistaken for a legitimate method until you notice the namespace prefix issue.
- The bug is width-independent. Any line where an attribute consumes the entire line triggers the stripping fault; the explicit interface regex just happens to be the one that reliably capitalizes on it for comma-separated forms.
Cross-language note
C#-specific. StripLeadingCSharpAttributeLists is only called for lang == "csharp" at SymbolExtractor.cs:447. Other languages have their own attribute/annotation stripping (or don't need it).
Scope
src/CodeIndex/Indexer/SymbolExtractor.cs:660 — fix the final return to use line[cursor..] unconditionally (or return string.Empty when cursor >= line.Length).
src/CodeIndex/Indexer/SymbolExtractor.cs:116 — (optional defense) add [ to the returnType negative lookahead.
tests/CodeIndex.Tests/SymbolExtractorTests.cs — fixtures: comma-separated attribute list ending exactly at end-of-line must NOT produce phantom symbols.
Related
Environment
- cdidx: v1.10.0 (
/root/.local/bin/cdidx)
- Platform: Linux 4.4.0
- Fixture inline in Repro section.
Summary
StripLeadingCSharpAttributeListsatSymbolExtractor.cs:623-661has an off-by-one fallback: when the attribute list consumes the entire line (ending with]at the last column, followed by newline),cursor == line.Lengthis reached, and the function returns the original line instead of an empty string. This allows regex patterns to match attribute content as if it were code.The bug is masked for single-attribute lines because the stripped-style attribute content doesn't match any method regex anchor. But it manifests for comma-separated multi-attribute lines (
[Attr1, Namespace.Attr2("arg")]) where the comma + following space create an alignment that satisfies the explicit interface regex at:116, producing a phantomfunctionsymbol.Repro
Expected:
Single,Combo,Svc, namespace = 4 symbols.Actual:
DisplayNameat A.cs:10-11 is a phantom created from the content of the attribute list on line 10.Suspected root cause
src/CodeIndex/Indexer/SymbolExtractor.cs:623-661—StripLeadingCSharpAttributeLists:Trace for line 10
[System.Obsolete, System.ComponentModel.DisplayName("Hidden")](length 66):index = 4(after skipping 4 spaces).line[4] == '[', enter outer loop.]at position 65.cursorbecomes 66 (past the]).depth == 0, OK.cursor == 66 == line.Length, no more chars.cursor < line.Length→66 < 66is false. Exit.cursor < line.Length ? line[cursor..] : line→66 < 66is false → returnsline(the ORIGINAL).Now the explicit interface regex at
:116processes the original line:[\w?.<>\[\],:]+greedy matches[System.Obsolete,(positions 4-20, 17 chars — the char class includes[,,).\s+matches the space at position 21.[\w.<>:]+backtracks toSystem.ComponentModel(positions 22-42).\.matches.at position 43.(?<name>\w+)capturesDisplayName(positions 44-54).[\(\[]matches(at position 55.Phantom emitted:
function DisplayNamewith body range starting at line 10 (viaBodyStyle.Bracewhich finds{on the next line).The single-attribute case
[System.ComponentModel.DisplayName("Hidden")]ALSO hits the same stripping bug (returns the original line), but the explicit interface regex doesn't match because there's no,to create the intermediate whitespace alignment.Suggested fix
The final return statement should use
line[cursor..]unconditionally:C#'s range operator handles
cursor == line.Lengthcorrectly — it returns an empty string. The existing ternary is defensive programming gone wrong: it assumesline[cursor..]might throw, but it doesn't.Alternative: keep the ternary but flip the fallback:
Both fixes produce the same behavior: when the attribute consumes the entire line, the stripped result is empty.
Defense in depth: the explicit interface regex at
:116could also add[to its negative returnType lookahead.[is not a legal starting character for a C# return type, so excluding it would harden against any future similar stripping failure. But fixing the stripper is sufficient.Why it matters
symbols,definition,outline,inspect,map. Any codebase using comma-separated attributes ([Obsolete, DisplayName("x")]is idiomatic short-form) gets phantom rows.[Flags, Serializable],[JsonIgnore, XmlIgnore],[Obsolete, EditorBrowsable(...)]— comma-separated attribute lists are extremely common in .NET DTOs, EF entities, and serialization-heavy code.Cross-language note
C#-specific.
StripLeadingCSharpAttributeListsis only called forlang == "csharp"atSymbolExtractor.cs:447. Other languages have their own attribute/annotation stripping (or don't need it).Scope
src/CodeIndex/Indexer/SymbolExtractor.cs:660— fix the final return to useline[cursor..]unconditionally (or returnstring.Emptywhencursor >= line.Length).src/CodeIndex/Indexer/SymbolExtractor.cs:116— (optional defense) add[to the returnType negative lookahead.tests/CodeIndex.Tests/SymbolExtractorTests.cs— fixtures: comma-separated attribute list ending exactly at end-of-line must NOT produce phantom symbols.Related
"""..."""raw strings (and other multi-line strings) becomes phantom symbols — 52 fakeclass Approws on cdidx itself, topmapentrypoint is a Lua fixture string #177 (closed) — raw string phantoms. Same per-line regex architecture. Different root cause.event T IFoo.Evt { add; remove; }) captured with phantom name (interface nameIFoo) instead of event nameEvt— event regex has no dot-qualified name branch #351 — explicit interface event phantom naming. Same:116family regex.new Qualified.Type()statement creates phantomfunctionrows via the explicit interface regex at:116—newmistaken for return type #362 —new Qualified.Type()phantom. Same:116regex, different trigger (newkeyword).public readonly (int, int) X;) emit phantomfunction readonlyrows — method regex backtrackspublicinto returnType #336 — readonly field tuple phantom. Same regex family (returnType backtracking produces phantoms).Environment
/root/.local/bin/cdidx)