Repository navigation
get_file_skeleton never descends into matched declarations; most C# files collapse to one line #37
Description
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
.csfiles
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
fix(skeleton): descend into matched declarations for member outlines
MrDoe commentedon Oct 27, 2026
Thanks for the detailed report and the patch — this is fixed on
mainin a5007b3.walkTreenow takes an opt-indescendflag (the chunker keeps its default, so chunk boundaries are unchanged).extractSkeletonenables it, and the.csrecipe 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:
decorated_definition,type_declaration).Verified with 47 focused tests, the full unit suite (1539 tests),
npm run typecheckandnpm 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!