Skip to content

get_file_skeleton never descends into matched declarations; most C# files collapse to one line #37

Closed

Description

@DarkHelmet433

Summary

get_file_skeleton reuses the chunker's AST walker (walkTree). The walker
stops descending at the first matching node on each branch, so it reports
outermost matching declarations but never the members nested inside them.
Sibling top-level declarations are still returned, so this is not "one element
per file": a file with three enums reports three elements, and a Python module
of top-level functions reports all of them. The loss is specifically nested
members, and C# is the worst case because most .cs files contain exactly one
top-level type, collapsing the whole outline to one line.

Measured on a large Unity C# project with 1427 .cs files: 1327 files (93%)
return exactly one element with the current release.

The patch below also expands the C# recipe to the useful contract surface
(properties, records, delegates, events, indexers, operators, destructors)
while deliberately excluding fields and other data. That part is independent
of the walker fix if you prefer a minimal change.

Environment

  • opencode-rag-plugin 2.10.1 (latest at the time of writing)
  • OpenCode v2.1.x, Linux, Node v26.12.1
  • C# grammar loaded from @vscode/tree-sitter-wasm
  • Upstream main @ 1052c0ceb0f5 (checked 2026-10-27)
  • Test corpus: a large Unity C# project with 1427 .cs files

Reproduction

With a minimal C# file containing one class, one constructor and one method:

public class Example
{
    public Example() {}
    public void Run() {}
}

get_file_skeleton returns:

1 structural elements (1 class)

  □ `public class Example` L1-5

Expected with descent alone: class + method = 2 elements. With the C#
recipe below (which adds constructors): 3 elements. Actual: 1.

The same happens for essentially every C# file in the project. Corpus A/B
(calling extractSkeleton directly over all 1427 .cs files):

Build elements files returning exactly 1
upstream 2.10.1 1583 1327 / 1427 (93%)
local patch (descend + C# contract recipe) 14724 72 / 1427

0 extraction errors in both runs. Multiple sibling top-level types still appear
in the unpatched build: a C# file containing three sibling enums reports all
three, and a Python module of top-level functions/classes reports all ten. The
loss is specifically the members nested inside a matched declaration, not a
per-file limit.

Root cause

src/chunker/grammar.ts:312 defines walkTree; in the match branch it pushes
the node and returns immediately (:338):

if (nodeTypes.has(node.type) && depth > 0) {
  results.push({ ... });
  return results;   // <-- never descends into the matched node
}

src/chunker/skeleton.ts:186 calls the same walker for the outline:

const astNodes = walkTree(tree.rootNode, nodeTypes, content, 15);

For the chunker (src/chunker/base.ts:60,132, maxDepth 10) the early return is
intentional: one matched declaration = one chunk, and descending would create
duplicate chunks. The skeleton has the opposite requirement.

Proposed fix

Add an optional descend flag to walkTree, thread it through the recursive
call, and have only extractSkeleton set it. The chunker keeps its current
behaviour, so chunk boundaries and the index are unchanged.

export function walkTree(..., offsetMap?: ..., descend = false): AstNode[] {
  const results: AstNode[] = [];
  if (nodeTypes.has(node.type) && depth > 0) {
    results.push({ ... });
    if (!descend) return results;
  }
  if (depth < maxDepth) {
    for (const child of node.children)
      results.push(...walkTree(child, nodeTypes, source, maxDepth, depth + 1, offsetMap, descend));
  }
  return results;
}
// skeleton.ts
const astNodes = walkTree(tree.rootNode, nodeTypes, content, 15, 0, undefined, true);

The easy trap is forgetting to pass descend in the recursive call: only the
root invocation then descends and the output does not change.

C# recipe (included in the patch)

The generic descend fix is not enough for C#: the .cs recipe at
skeleton.ts:45 only collects classes, methods, interfaces, enums and structs.
The patch replaces it with:

".cs": { grammarName: "c_sharp", nodeTypes: [
  "class_declaration", "struct_declaration", "interface_declaration",
  "enum_declaration", "record_declaration", "delegate_declaration",
  "constructor_declaration", "destructor_declaration", "method_declaration",
  "property_declaration", "indexer_declaration",
  "event_field_declaration", "event_declaration",
  "operator_declaration", "conversion_operator_declaration",
] },

record_declaration covers both record and record struct.

Deliberately excluded: field_declaration, enum_member_declaration,
using_directive, namespace_declaration/file_scoped_namespace_declaration,
accessor_declaration and local_function_statement. These are data,
containers or implementation details, and the measured cost is high:

Node type total files max/file Decision
property_declaration 487 141 22 include
delegate_declaration 22 13 4 include
event_field_declaration 16 5 5 include
indexer_declaration 13 6 8 include
operator_declaration 12 5 4 include
conversion_operator_declaration 3 2 2 include
field_declaration 17575 1073 366 exclude
enum_member_declaration 3042 205 868 exclude
using_directive 4085 1342 10 exclude
accessor_declaration 680 121 49 exclude

The resulting outline is ~10.3 elements/file (14724 total) versus ~9.9/file for
the current patched set (14171); adding fields would take it to ~22.6/file
(32299). A 515-line constants class is 11 elements with the recommendation
versus 377 with fields. Const tables and enum members are data, not
structure; the type line and line range already tell a reader where to look.

Name extraction remains a follow-up. extractNodeName is TS/Python-oriented,
so properties currently render as string Name { get; }, and an attributed
method can render the attribute list as its name. Tree-sitter exposes
childForFieldName("name") for most declarations; fields need
variable_declaration → variable_declarator, indexers this[...], and
operators the operator token. The patch does not change name extraction.

Impact

get_file_skeleton is the documented "orient before reading" step in agent
workflows. For C#, it returns nothing useful, so agents fall back to
grep/read and spend tokens on files the outline should have summarised. The
tool is on-demand, so descent costs ~3.8 ms/file for a 1427-file corpus —
irrelevant.

Proposed patch

The patch below was applied to the source tree against main @
1052c0ceb0f5 and has not been committed. It applies with git apply.
It contains the generic descend fix, the C# contract recipe, and regression
tests; the recipe and the walker fix are independent if you prefer to split
them.

Verified with:

  • npm run typecheck — passes
  • 52 focused tests — pass (skeleton/C#, grammar and chunker), including the
    existing "stops recursing once a matching node is found (no grandchildren)"
    test that confirms the chunker default is unchanged, plus a new test that
    asserts properties are included and fields are not
  • Live on plugin 2.10.1 after reinstall + reapply + restart: a 16-property
    interface fixture → 18 elements; a 515-line constants class → 11 elements
    (366 fields excluded)
  • Through the source build, an all-constructs fixture (record, delegate, event,
    indexer, operator, conversion operator, destructor) → 22 elements with 0
    fields
Full diff (3 files, 76 insertions, 4 deletions)
diff --git a/src/__tests__/chunker/skeleton.test.ts b/src/__tests__/chunker/skeleton.test.ts
index d234109..20fa540 100644
--- a/src/__tests__/chunker/skeleton.test.ts
+++ b/src/__tests__/chunker/skeleton.test.ts
@@ -18,6 +18,71 @@ describe("extractSkeleton", () => {
     );
   });
 
+  it("descends into matched declarations for a member-level outline", async () => {
+    // The chunker intentionally stops at the first matched declaration so one
+    // declaration stays one chunk; the skeleton must descend so members are
+    // reported. Regression: C# files used to collapse to a single class line.
+    const source = [
+      "public class Example",
+      "{",
+      "  public Example() {}",
+      "  public void Run() {}",
+      "  public string Name { get; set; }",
+      "  private int _count;",
+      "}",
+    ].join("\n");
+    const items = await extractSkeleton(source, ".cs");
+    assert.ok(
+      items.some((i) => i.type === "class_declaration"),
+      `expected the class, got: ${JSON.stringify(items)}`,
+    );
+    assert.ok(
+      items.some((i) => i.type === "constructor_declaration"),
+      `expected the constructor, got: ${JSON.stringify(items)}`,
+    );
+    assert.ok(
+      items.some((i) => i.type === "method_declaration"),
+      `expected the method, got: ${JSON.stringify(items)}`,
+    );
+    assert.ok(
+      items.some((i) => i.type === "property_declaration"),
+      `expected the property, got: ${JSON.stringify(items)}`,
+    );
+    assert.equal(
+      items.some((i) => i.type === "field_declaration"),
+      false,
+      "fields are intentionally excluded from the outline",
+    );
+  });
+
+  it("includes C# contract members but excludes data members", () => {
+    const types = SKELETON_CONFIGS[".cs"]!.nodeTypes;
+    for (const type of [
+      "property_declaration",
+      "record_declaration",
+      "delegate_declaration",
+      "event_field_declaration",
+      "event_declaration",
+      "indexer_declaration",
+      "operator_declaration",
+      "conversion_operator_declaration",
+      "destructor_declaration",
+      "constructor_declaration",
+    ]) {
+      assert.ok(types.includes(type), `expected ${type} in the .cs recipe`);
+    }
+    for (const type of [
+      "field_declaration",
+      "enum_member_declaration",
+      "using_directive",
+      "namespace_declaration",
+      "accessor_declaration",
+      "local_function_statement",
+    ]) {
+      assert.equal(types.includes(type), false, `did not expect ${type} in the .cs recipe`);
+    }
+  });
+
   it("keeps per-extension recipes that differ (.tsx includes arrow functions)", async () => {
     const source = "const App = () => 1;\n";
     const tsxItems = await extractSkeleton(source, ".tsx");
diff --git a/src/chunker/grammar.ts b/src/chunker/grammar.ts
index 039d02e..16dc2d1 100644
--- a/src/chunker/grammar.ts
+++ b/src/chunker/grammar.ts
@@ -307,6 +307,9 @@ function extractDocstringFromBody(
  * @param source - Full source text (used to slice node text and comments).
  * @param maxDepth - Maximum recursion depth (default: 10).
  * @param depth - Current recursion depth (internal, start at 0).
+ * @param descend - When true, continue into a matched node's children. The
+ *   chunker keeps the default so one matched declaration stays one chunk; the
+ *   skeleton outline sets it so members inside a declaration are reported.
  * @returns An array of {@link AstNode} objects for matching declarations.
  */
 export function walkTree(
@@ -316,6 +319,7 @@ export function walkTree(
   maxDepth: number = 10,
   depth: number = 0,
   offsetMap?: Map<number, number> | null,
+  descend: boolean = false,
 ): AstNode[] {
   const results: AstNode[] = [];
 
@@ -335,13 +339,15 @@ export function walkTree(
       type: node.type,
       leadingDoc: leadingDoc.length > 0 ? leadingDoc : undefined,
     });
-    return results;
+    // The chunker stops at the first matched declaration so that one
+    // declaration produces one chunk. The skeleton opts into descending.
+    if (!descend) return results;
   }
 
   if (depth < maxDepth) {
     for (const child of node.children) {
       results.push(
-        ...walkTree(child, nodeTypes, source, maxDepth, depth + 1, offsetMap)
+        ...walkTree(child, nodeTypes, source, maxDepth, depth + 1, offsetMap, descend)
       );
     }
   }
diff --git a/src/chunker/skeleton.ts b/src/chunker/skeleton.ts
index 6503a46..192c801 100644
--- a/src/chunker/skeleton.ts
+++ b/src/chunker/skeleton.ts
@@ -42,7 +42,7 @@ export const SKELETON_CONFIGS: Record<string, SkeletonConfig> = {
   ".cxx":   { grammarName: "cpp", nodeTypes: ["function_definition", "class_specifier", "struct_specifier", "enum_specifier"] },
   ".h":     { grammarName: "cpp", nodeTypes: ["function_definition", "class_specifier", "struct_specifier", "enum_specifier"] },
   ".hpp":   { grammarName: "cpp", nodeTypes: ["function_definition", "class_specifier", "struct_specifier", "enum_specifier"] },
-  ".cs":    { grammarName: "c_sharp", nodeTypes: ["class_declaration", "method_declaration", "interface_declaration", "enum_declaration", "struct_declaration"] },
+  ".cs":    { grammarName: "c_sharp", nodeTypes: ["class_declaration", "struct_declaration", "interface_declaration", "enum_declaration", "record_declaration", "delegate_declaration", "constructor_declaration", "destructor_declaration", "method_declaration", "property_declaration", "indexer_declaration", "event_field_declaration", "event_declaration", "operator_declaration", "conversion_operator_declaration"] },
   ".rb":    { grammarName: "ruby", nodeTypes: ["method", "class", "module"] },
   ".swift": { grammarName: "swift", nodeTypes: ["function_declaration", "class_declaration", "struct_declaration", "enum_declaration", "protocol_declaration"] },
   ".kt":    { grammarName: "kotlin", nodeTypes: ["function_declaration", "class_declaration", "interface_declaration", "object_declaration"] },
@@ -183,7 +183,8 @@ export async function extractSkeleton(
 
   try {
     const nodeTypes = new Set(config.nodeTypes);
-    const astNodes: AstNode[] = walkTree(tree.rootNode, nodeTypes, content, 15);
+    // Descend into matched declarations to produce a member-level outline.
+    const astNodes: AstNode[] = walkTree(tree.rootNode, nodeTypes, content, 15, 0, undefined, true);
     return astNodes.map((node) => ({
       type: node.type,
       name: extractNodeName(node.text, node.type),

I'm happy to open a PR instead if that is easier to review — just say which form
you prefer.

Activity

  1. MrDoe commented on Oct 27, 2026

    @MrDoe
    Owner

    Thanks for the detailed report and the patch — this is fixed on main in a5007b3.

    walkTree now takes an opt-in descend flag (the chunker keeps its default, so chunk boundaries are unchanged). extractSkeleton enables it, and the .cs recipe now covers properties, records, delegates, events, indexers, operators, constructors and destructors, while still excluding fields and enum members.

    I folded in two refinements on top of your patch:

    • callables nested inside another callable are filtered out, so inline JSX callbacks and local helpers don't flood the outline (class members still appear, even for a class nested in a function);
    • Python/Go wrapper node types were removed from the skeleton recipes so descent doesn't produce duplicate rows (decorated_definition, type_declaration).

    Verified with 47 focused tests, the full unit suite (1539 tests), npm run typecheck and npm run build. It will go out in the next npm release. The name-extraction follow-up you mentioned is still open — a PR for that would be welcome. Thanks again!

  2. self-assigned this
    on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions