You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
C# modifier order matters: readonly static field (:71 requires static\s+readonly) and static public method (:94 requires visibility before modifiers) are silently dropped while canonical orderings are captured #355
The C# spec allows type-member modifiers to appear in any order — public static readonly int X; and public readonly static int X; are both syntactically valid. SymbolExtractor's static-readonly-field regex at :71 hard-codes the sequence [(visibility) (new)? static readonly], so the moment the user writes readonly before static, the regex fails and the field is silently dropped from the symbol index. The same rigidity affects more exotic but legal orderings like readonly new static and new readonly static.
A second, related, modifier-order failure on the method regex at :94 drops methods that use the legacy static public convention (visibility after modifier) — common in older codebases, in Main declarations, and in some style guides where static is seen as the more important annotation.
publicclassSvc{// Captured — canonical orderpublicstaticreadonlyintA=1;// Dropped — :71 requires static then readonly in that orderpublicreadonlystaticintB=2;// Dropped — modifier order [readonly new static] is legal but :71 requires [(new)? static readonly]publicreadonlynewstaticintD=4;// Dropped — :94 requires visibility before modifiers, but `static public` puts them in the other orderstaticpublicintF()=>0;}
definition B, definition D, and definition F all return zero hits, even though all three declarations are perfectly legal C#. inspect Svc under-reports the static surface, and unused / hotspots --kind function falsely conclude the dropped members are absent.
CDIDX=/root/.local/bin/cdidx
mkdir -p /tmp/dogfood/cs-modifier-order
cat > /tmp/dogfood/cs-modifier-order/M.cs <<'EOF'namespace CsModifierOrder;public class Svc{ // Canonical order — captured public static readonly int A = 1; // Reversed order — DROPPED (:71 requires static then readonly) public readonly static int B = 2; // Canonical order with `new` — captured public new static readonly int C = 3; // Reversed order with `new` — DROPPED public readonly new static int D = 4; // const — captured (canonical) public const int E = 5; // Method with visibility-after-modifier — DROPPED (:94 requires visibility before modifier) static public int F() => 0; // Property with required+override — both orders captured (:100 modifier slot uses `*`) public required override int P { get; set; } public override required int Q { get; set; }}public abstract class Base{ public virtual int P { get; set; } public virtual int Q { get; set; }}EOF"$CDIDX" index /tmp/dogfood/cs-modifier-order --rebuild
"$CDIDX" symbols --db /tmp/dogfood/cs-modifier-order/.cdidx/codeindex.db
Observed:
function A M.cs:6 ← captured
function C M.cs:12 ← captured
function E M.cs:18 ← captured (const)
property P M.cs:23 ← captured (required override)
property Q M.cs:24 ← captured (override required)
class Svc M.cs:3-25
class Base M.cs:27-31
property P M.cs:29
property Q M.cs:30
namespace CsModifierOrder M.cs:1
Missing:
B at M.cs:9 (public readonly static int B = 2;) — :71's rigid static\s+readonly order rejects.
D at M.cs:15 (public readonly new static int D = 4;) — :71's (?:(?:new)\s+)?static\s+readonly rejects — new is required to be between (visibility) and static, not after readonly.
F at M.cs:21 (static public int F() => 0;) — :94's (?:visibility\s+)?(?:modifier\s+)* requires visibility before modifier; static matched first, then public is consumed as returnType (greedy) and int becomes name, then \s*\( expects ( but sees F( after int's capture — actually let me re-trace: visibility group matches nothing, modifier loop eats static, returnType matches public, name matches int, \s*(?:<[^>]+>\s*)?\( requires ( but sees F( after int — \s* eats space, then expects ( but sees F → fails. No match.
Note the property regex :100 correctly handles both required override and override required because its modifier list (?:(?:static|virtual|override|abstract|sealed|new|required)\s+)* uses * and includes both keywords; the regex is order-free for the modifier slot. The static-readonly-field regex :71 is the lone holdout that hard-codes a specific sequence.
Suspected root cause (from reading the source)
Row A — static readonly field at src/CodeIndex/Indexer/SymbolExtractor.cs:71:
The structure (?:(?:new)\s+)?static\s+readonly\s+ enforces:
new must come immediately before static (or be absent).
static must come immediately before readonly (no other words between).
readonly cannot precede static.
C# allows any order: static readonly, readonly static, new static readonly, new readonly static, readonly new static, static new readonly. Today only the canonical [(visibility)? (new)? static readonly] is matched.
Trace for public readonly static int B = 2;:
^\s* eats whitespace.
visibility matches public + space.
(?:(?:new)\s+)? — new not present, zero iterations.
static\s+readonly\s+ — cursor at readonly static; literal static expected, but readonly is next → fails.
Backtrack: visibility could be absent. Then (?:(?:new)\s+)? — try new: not present. Then static: expected, but public is next → fails.
No other row rescues:
:69 (const) requires const keyword → fails.
:94 (method) requires \s*\( after the name → no ( on the line → fails.
The structure forces visibility to come before the modifier loop. C# allows the visibility token to appear anywhere among the modifier sequence. Trace for static public int F() => 0;:
Negative lookahead passes (next token is static, not a statement keyword).
Visibility group is optional and tries to match — at the cursor static, static is not in the visibility alternation → group matches zero (skipped).
Modifier loop: static matches, eaten.
Cursor at public int F() => 0;. Modifier loop tries public — not in the modifier alternation (it's a visibility keyword, not a modifier) → loop terminates.
returnType greedy matches public (just an identifier per the char class).
\s+ eats space.
name matches int.
\s*(?:<[^>]+>\s*)?\( — at F() => 0;. \s* eats space, then \( expects ( but sees F → fails.
Backtracking the returnType-vs-name split doesn't help: any split that puts int after the space requires ( next, which never appears.
Same issue would affect static internal, async public, override public, virtual public, etc. — any line where a modifier token appears before the visibility token.
Suggested direction
(A) Replace :71's hard-coded sequence with a flexible modifier slot that requires both static and readonly to appear (in any order, possibly with new interleaved):
// :71 — change(?:(?:new)\s+)?static\s+readonly\s+// to a slot that requires `static` and `readonly` and allows `new` in any position(?=(?:(?:static|readonly|new)\s+)*static\s+)(?=(?:(?:static|readonly|new)\s+)*readonly\s+)(?:(?:static|readonly|new|volatile)\s+)+
The two leading lookaheads enforce both keywords; the trailing modifier loop consumes the run in any order. volatile is added defensively (legal on fields, mutually exclusive with readonly per spec but harmless here since the lookaheads still require readonly).
A simpler alternative is to enumerate the small set of legal pairings as alternatives:
(?:(?:new\s+)?static\s+readonly// canonical|(?:new\s+)?readonly\s+static// reversed|static\s+(?:new\s+)?readonly// new sandwiched|readonly\s+(?:new\s+)?static// ditto reversed|static\s+readonly\s+new// new at end| readonly\s+static\s+new)\s+
The lookahead form is more compact; the alternation form is easier to maintain. Either is fine.
(B) Allow modifier-then-visibility on :94 by promoting visibility into the modifier loop with a captured group:
Today :94 separates visibility from modifiers; the order is fixed (visibility)?(modifier)*. Change to a single loop that captures visibility wherever it appears:
This change applies the same fix to all peer regexes that need it: :97 (ctor), :100 / :103 (property), :105 (delegate), :107 (event), :118 (indexer). Each row's modifier-loop becomes a single alternation that includes visibility as one option.
For backward compatibility (preserving existing capture semantics), the (?<visibility>…) named group inside the alternation captures only when the visibility branch fires; otherwise the named group is empty, matching today's behavior on lines that omit visibility.
(C) Tests. Add fixtures to SymbolExtractorTests.cs covering:
public static readonly int A = 1; → captured (regression).
public readonly static int B = 2; → captured (this issue).
public new static readonly int C = 3; → captured (regression).
public readonly new static int D = 4; → captured (this issue).
public new readonly static int D2 = 4; → captured (defensive).
static public int F() => 0; → captured (this issue, method row).
async public System.Threading.Tasks.Task H() { } → captured (defensive).
Regression: public static int I() => 0; still captured.
Why it matters
Modifier order is a style preference, not a correctness gate. Real codebases freely mix public static, static public, public readonly static, etc. — particularly older codebases, codebases following STL/native-style conventions, generated code, and Main-method declarations in tutorials. Silent drops in those codebases skew unused / hotspots and break definition lookups.
Tutorial / educational corpus is heavy on static public void Main (the first-method-students-see convention). An AI agent reviewing a learning project sees an empty symbol table for the entry point and concludes the project doesn't compile.
Main matters for map's entrypoint inference. The map command scores files by entrypoint heuristics (SymbolExtractor → RepoMapBuilder). A static public void Main is invisible to the symbol table, so map falls back to the file-name heuristic — which works for Program.cs but not for Main.cs / custom-named files.
Silent. No warning, no phantom — uniform with the rest of the modifier-list-omission family.
Fix is bounded. (A) is a localized rewrite of one regex; (B) is a one-shot edit applied uniformly across the C# row set.
Cross-language note
C# only. The free modifier order for static, readonly, etc. is C#-specific grammar.
Java uses a similar free-order grammar (public static final ↔ static public final are both legal). The Java row set in SymbolExtractor.cs should be spot-checked for the same family of bugs — out of scope for this filing.
Kotlin / Swift / Rust all enforce specific modifier orders syntactically; their regexes don't have this problem.
Summary
The C# spec allows type-member modifiers to appear in any order —
public static readonly int X;andpublic readonly static int X;are both syntactically valid.SymbolExtractor's static-readonly-field regex at:71hard-codes the sequence[(visibility) (new)? static readonly], so the moment the user writesreadonlybeforestatic, the regex fails and the field is silently dropped from the symbol index. The same rigidity affects more exotic but legal orderings likereadonly new staticandnew readonly static.A second, related, modifier-order failure on the method regex at
:94drops methods that use the legacystatic publicconvention (visibility after modifier) — common in older codebases, inMaindeclarations, and in some style guides wherestaticis seen as the more important annotation.definition B,definition D, anddefinition Fall return zero hits, even though all three declarations are perfectly legal C#.inspect Svcunder-reports the static surface, andunused/hotspots --kind functionfalsely conclude the dropped members are absent.This is distinct from:
public int Count;,private readonly List<int> _items;,public static int GlobalCount;) are not captured as symbols — onlyconstandstatic readonlyare indexed #298 — plain fields silently dropped (onlyconstandstatic readonlyare captured at all). This issue is about the static-readonly-captured-only-in-canonical-order sub-case of the broader field-coverage problem in C#: plain fields (public int Count;,private readonly List<int> _items;,public static int GlobalCount;) are not captured as symbols — onlyconstandstatic readonlyare indexed #298.partial,required,readonly) + tuple-suffix return type emit phantomfunction partial/function required/function readonlyrows via ctor-regex fallback #349 — phantomfunction partial/function required/function readonlyrows from contextual-keyword + tuple-suffix lines via ctor-regex fallback. Different mechanism (visibility-backtrack into returnType producing a phantom rather than a silent drop).readonlyproperties silently dropped ANDreadonly get =>accessor bodies misclassified as phantomproperty getrows —readonlymissing from property regex modifier slot #327 / C# 13 partial indexers (public partial int this[int i] { get; set; }) are silently dropped —partialnot in indexer-regex modifier list #350 / C# 8readonlyinstance indexers on structs (public readonly int this[int i] => ...) are silently dropped —readonlynot in indexer-regex modifier list #352 — missing single modifier (readonly/partial) on property / indexer rows. Single-keyword scope. This issue is specifically about order independence of two co-occurring modifiers.Repro
Observed:
Missing:
Bat M.cs:9 (public readonly static int B = 2;) — :71's rigidstatic\s+readonlyorder rejects.Dat M.cs:15 (public readonly new static int D = 4;) — :71's(?:(?:new)\s+)?static\s+readonlyrejects —newis required to be between(visibility)andstatic, not afterreadonly.Fat M.cs:21 (static public int F() => 0;) — :94's(?:visibility\s+)?(?:modifier\s+)*requires visibility before modifier;staticmatched first, thenpublicis consumed as returnType (greedy) andintbecomes name, then\s*\(expects(but seesF(afterint's capture — actually let me re-trace: visibility group matches nothing, modifier loop eatsstatic, returnType matchespublic, name matchesint,\s*(?:<[^>]+>\s*)?\(requires(but seesF(afterint—\s*eats space, then expects(but seesF→ fails. No match.Note the property regex
:100correctly handles bothrequired overrideandoverride requiredbecause its modifier list(?:(?:static|virtual|override|abstract|sealed|new|required)\s+)*uses*and includes both keywords; the regex is order-free for the modifier slot. The static-readonly-field regex:71is the lone holdout that hard-codes a specific sequence.Suspected root cause (from reading the source)
Row A — static readonly field at
src/CodeIndex/Indexer/SymbolExtractor.cs:71:The structure
(?:(?:new)\s+)?static\s+readonly\s+enforces:newmust come immediately beforestatic(or be absent).staticmust come immediately beforereadonly(no other words between).readonlycannot precedestatic.C# allows any order:
static readonly,readonly static,new static readonly,new readonly static,readonly new static,static new readonly. Today only the canonical[(visibility)? (new)? static readonly]is matched.Trace for
public readonly static int B = 2;:^\s*eats whitespace.visibilitymatchespublic+ space.(?:(?:new)\s+)?—newnot present, zero iterations.static\s+readonly\s+— cursor atreadonly static; literalstaticexpected, butreadonlyis next → fails.(?:(?:new)\s+)?— trynew: not present. Thenstatic: expected, butpublicis next → fails.No other row rescues:
constkeyword → fails.\s*\(after the name → no(on the line → fails.{or=>→ neither present → fails.Row B — method at
src/CodeIndex/Indexer/SymbolExtractor.cs:94:The structure forces visibility to come before the modifier loop. C# allows the visibility token to appear anywhere among the modifier sequence. Trace for
static public int F() => 0;:static, not a statement keyword).static,staticis not in the visibility alternation → group matches zero (skipped).staticmatches, eaten.public int F() => 0;. Modifier loop triespublic— not in the modifier alternation (it's a visibility keyword, not a modifier) → loop terminates.public(just an identifier per the char class).\s+eats space.int.\s*(?:<[^>]+>\s*)?\(— atF() => 0;.\s*eats space, then\(expects(but seesF→ fails.Backtracking the returnType-vs-name split doesn't help: any split that puts
intafter the space requires(next, which never appears.Same issue would affect
static internal,async public,override public,virtual public, etc. — any line where a modifier token appears before the visibility token.Suggested direction
(A) Replace
:71's hard-coded sequence with a flexible modifier slot that requires bothstaticandreadonlyto appear (in any order, possibly withnewinterleaved):The two leading lookaheads enforce both keywords; the trailing modifier loop consumes the run in any order.
volatileis added defensively (legal on fields, mutually exclusive withreadonlyper spec but harmless here since the lookaheads still requirereadonly).A simpler alternative is to enumerate the small set of legal pairings as alternatives:
The lookahead form is more compact; the alternation form is easier to maintain. Either is fine.
(B) Allow modifier-then-visibility on
:94by promoting visibility into the modifier loop with a captured group:Today
:94separates visibility from modifiers; the order is fixed(visibility)?(modifier)*. Change to a single loop that captures visibility wherever it appears:This change applies the same fix to all peer regexes that need it:
:97(ctor),:100/:103(property),:105(delegate),:107(event),:118(indexer). Each row's modifier-loop becomes a single alternation that includes visibility as one option.For backward compatibility (preserving existing capture semantics), the
(?<visibility>…)named group inside the alternation captures only when the visibility branch fires; otherwise the named group is empty, matching today's behavior on lines that omit visibility.(C) Tests. Add fixtures to
SymbolExtractorTests.cscovering:public static readonly int A = 1;→ captured (regression).public readonly static int B = 2;→ captured (this issue).public new static readonly int C = 3;→ captured (regression).public readonly new static int D = 4;→ captured (this issue).public new readonly static int D2 = 4;→ captured (defensive).static public int F() => 0;→ captured (this issue, method row).static internal void G() { }→ captured (defensive).async public System.Threading.Tasks.Task H() { }→ captured (defensive).public static int I() => 0;still captured.Why it matters
public static,static public,public readonly static, etc. — particularly older codebases, codebases following STL/native-style conventions, generated code, andMain-method declarations in tutorials. Silent drops in those codebases skewunused/hotspotsand breakdefinitionlookups.static public void Main(the first-method-students-see convention). An AI agent reviewing a learning project sees an empty symbol table for the entry point and concludes the project doesn't compile.Mainmatters formap's entrypoint inference. Themapcommand scores files by entrypoint heuristics (SymbolExtractor→RepoMapBuilder). Astatic public void Mainis invisible to the symbol table, somapfalls back to the file-name heuristic — which works forProgram.csbut not forMain.cs/ custom-named files.Cross-language note
static,readonly, etc. is C#-specific grammar.public static final↔static public finalare both legal). The Java row set inSymbolExtractor.csshould be spot-checked for the same family of bugs — out of scope for this filing.Scope
src/CodeIndex/Indexer/SymbolExtractor.cs:71— static-readonly-field regex — replace hard-codedstatic\s+readonlywith order-free modifier slot.src/CodeIndex/Indexer/SymbolExtractor.cs:94— method regex — allow visibility token to appear anywhere in the modifier sequence.:97(ctor),:100/:103(property),:105(delegate),:107(event),:118(indexer) for uniform behavior.tests/CodeIndex.Tests/SymbolExtractorTests.cs— fixtures listed in (C).DEVELOPER_GUIDE.mdlanguage-pattern reference table — note that modifier order is free for C# rows.Related
public int Count;,private readonly List<int> _items;,public static int GlobalCount;) are not captured as symbols — onlyconstandstatic readonlyare indexed #298 — plain fields silently dropped (onlyconstandstatic readonlyindexed at all). Same regex:71; the order-rigidity issue here only matters because:71is the only path for capturing static readonly fields, so its narrowness compounds with C#: plain fields (public int Count;,private readonly List<int> _items;,public static int GlobalCount;) are not captured as symbols — onlyconstandstatic readonlyare indexed #298's narrowness.readonlyproperties silently dropped ANDreadonly get =>accessor bodies misclassified as phantomproperty getrows —readonlymissing from property regex modifier slot #327 —readonlyproperties silently dropped. Same family (modifier-list omission), but:71isn't a candidate row for properties. Independent.{ internal get; set; }) is silently dropped #332 — accessor-level visibility modifier. Different row, different mechanism.abstract/virtual/override/sealed/newmodifiers are silently dropped from the symbol index #334 — events withabstract/virtual/override/sealed/newmodifiers. Same family of "modifier-list narrower than grammar" but on a different row, and the suggested fix uses*for order-free behavior — same approach this issue suggests.partial,required,readonly) + tuple-suffix return type emit phantomfunction partial/function required/function readonlyrows via ctor-regex fallback #349 — contextual-keyword phantomfunction partial/function required/function readonlyfrom ctor-regex fallback. Adjacent: these phantoms occur because the visibility-backtracks-into-returnType behavior is exposed; the fix in (B) above (single-loop visibility-in-modifier-set) eliminates the backtrack path entirely.public partial int this[int i] { get; set; }) are silently dropped —partialnot in indexer-regex modifier list #350 / C# 8readonlyinstance indexers on structs (public readonly int this[int i] => ...) are silently dropped —readonlynot in indexer-regex modifier list #352 — missing single modifier (partial/readonly) on indexer regex:118. Same family, single-keyword scope.new delegateandnew enum(hiding base-class nested delegate / enum) are silently dropped —newmissing from delegate-regex (:105) and enum-regex (:75) modifier lists #353 —new delegate/new enumdrops. Same family, single-keyword scope.Environment
/root/.local/bin/cdidx)./tmp/dogfood/cs-modifier-order/M.cs(inline in Repro).CLOUD_BOOTSTRAP_PROMPT.md.