From b368d911737abce0fed29ca238b4fc857859e172 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:22:32 -0700 Subject: [PATCH 1/4] feat: expand node types with parameter, property, constant kinds (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sub-declaration node extraction to all 9 WASM language extractors, enabling structural queries like "which functions take a Request param?" or "which classes have a userId field?" without reading source code. Schema: migration v11 adds nullable parent_id column with indexes. Builder: insertNode links children to parent via parent_id FK. Extractors: JS/TS, Python, Go, Rust, Java, C#, Ruby, PHP, HCL now emit children arrays for parameters, properties, and constants. Queries: new childrenData() function, children in contextData output. CLI: new `children` command, EVERY_SYMBOL_KIND validation on --kind. MCP: new `symbol_children` tool, extended kind enum on all kind fields. Constants: CORE_SYMBOL_KINDS (10), EXTENDED_SYMBOL_KINDS (3), EVERY_SYMBOL_KIND (13). ALL_SYMBOL_KINDS preserved for backward compat. Native Rust engine: Definition struct gains children field but actual extraction is deferred to Phase 2 — WASM fallback handles new kinds. Impact: 63 functions changed, 62 affected --- crates/codegraph-core/src/types.rs | 2 + src/builder.js | 23 +- src/cli.js | 72 ++-- src/db.js | 23 ++ src/extractors/csharp.js | 65 +++- src/extractors/go.js | 67 +++- src/extractors/hcl.js | 22 ++ src/extractors/java.js | 62 ++- src/extractors/javascript.js | 142 +++++++ src/extractors/php.js | 79 ++++ src/extractors/python.js | 134 +++++++ src/extractors/ruby.js | 89 +++++ src/extractors/rust.js | 72 +++- src/index.js | 4 + src/mcp.js | 40 +- src/parser.js | 8 + src/queries.js | 109 +++++- tests/integration/build-parity.test.js | 7 +- tests/parsers/csharp.test.js | 2 +- tests/parsers/extended-kinds.test.js | 504 +++++++++++++++++++++++++ tests/unit/mcp.test.js | 16 + 21 files changed, 1501 insertions(+), 41 deletions(-) create mode 100644 tests/parsers/extended-kinds.test.js diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index f6593ebc..ed299f0c 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -65,6 +65,8 @@ pub struct Definition { #[napi(ts_type = "string[] | undefined")] pub decorators: Option>, pub complexity: Option, + #[napi(ts_type = "Definition[] | undefined")] + pub children: Option>, } #[napi(object)] diff --git a/src/builder.js b/src/builder.js index a9ae11d4..7a916647 100644 --- a/src/builder.js +++ b/src/builder.js @@ -543,7 +543,7 @@ export async function buildGraph(rootDir, opts = {}) { } const insertNode = db.prepare( - 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line, parent_id) VALUES (?, ?, ?, ?, ?, ?)', ); const getNodeId = db.prepare( 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?', @@ -597,12 +597,27 @@ export async function buildGraph(rootDir, opts = {}) { for (const [relPath, symbols] of allSymbols) { fileSymbols.set(relPath, symbols); - insertNode.run(relPath, 'file', relPath, 0, null); + insertNode.run(relPath, 'file', relPath, 0, null, null); for (const def of symbols.definitions) { - insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null); + insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null, null); + if (def.children?.length) { + const parentRow = getNodeId.get(def.name, def.kind, relPath, def.line); + if (parentRow) { + for (const child of def.children) { + insertNode.run( + child.name, + child.kind, + relPath, + child.line, + child.endLine || null, + parentRow.id, + ); + } + } + } } for (const exp of symbols.exports) { - insertNode.run(exp.name, exp.kind, relPath, exp.line, null); + insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null); } // Update file hash with real mtime+size for incremental builds diff --git a/src/cli.js b/src/cli.js index ddd853aa..391d2274 100644 --- a/src/cli.js +++ b/src/cli.js @@ -20,9 +20,10 @@ import { exportDOT, exportJSON, exportMermaid } from './export.js'; import { setVerbose } from './logger.js'; import { printNdjson } from './paginate.js'; import { - ALL_SYMBOL_KINDS, + children, context, diffImpact, + EVERY_SYMBOL_KIND, explain, fileDeps, fnDeps, @@ -122,8 +123,8 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--ndjson', 'Newline-delimited JSON output') .action((name, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } if (opts.path) { @@ -231,8 +232,8 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--ndjson', 'Newline-delimited JSON output') .action((name, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } fnImpact(name, opts.db, { @@ -263,8 +264,8 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--ndjson', 'Newline-delimited JSON output') .action((name, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } context(name, opts.db, { @@ -281,6 +282,31 @@ program }); }); +program + .command('children ') + .description('List parameters, properties, and constants of a symbol') + .option('-d, --db ', 'Path to graph.db') + .option('-f, --file ', 'Scope search to symbols in this file (partial match)') + .option('-k, --kind ', 'Filter to a specific symbol kind') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .action((name, opts) => { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); + process.exit(1); + } + children(name, opts.db, { + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + }); + }); + program .command('explain ') .description('Structural summary of a file or function (no LLM needed)') @@ -314,8 +340,8 @@ program .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') .action((target, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } audit(target, opts.db, { @@ -917,8 +943,8 @@ program console.error('Provide a function/entry point name or use --list to see all entry points.'); process.exit(1); } - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } const { flow } = await import('./flow.js'); @@ -950,8 +976,8 @@ program .option('--impact', 'Show data-dependent blast radius') .option('--depth ', 'Max traversal depth', '5') .action(async (name, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } const { dataflow } = await import('./dataflow.js'); @@ -988,8 +1014,8 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--ndjson', 'Newline-delimited JSON output') .action(async (target, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } const { complexity } = await import('./complexity.js'); @@ -1021,8 +1047,8 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--ndjson', 'Newline-delimited JSON output') .action(async (opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } const { manifesto } = await import('./manifesto.js'); @@ -1083,8 +1109,8 @@ program .option('--ndjson', 'Newline-delimited JSON output') .option('--weights ', 'Custom weights JSON (e.g. \'{"fanIn":1,"complexity":0}\')') .action(async (opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } if (opts.role && !VALID_ROLES.includes(opts.role)) { @@ -1246,8 +1272,8 @@ program .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .action(async (command, positionalTargets, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } @@ -1310,8 +1336,8 @@ program .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .action(async (positionalTargets, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } diff --git a/src/db.js b/src/db.js index f3f55fa4..9f40d7cc 100644 --- a/src/db.js +++ b/src/db.js @@ -165,6 +165,14 @@ export const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_dataflow_source_kind ON dataflow(source_id, kind); `, }, + { + version: 11, + up: ` + ALTER TABLE nodes ADD COLUMN parent_id INTEGER REFERENCES nodes(id); + CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id); + CREATE INDEX IF NOT EXISTS idx_nodes_kind_parent ON nodes(kind, parent_id); + `, + }, ]; export function getBuildMeta(db, key) { @@ -286,6 +294,21 @@ export function initSchema(db) { } catch { /* already exists */ } + try { + db.exec('ALTER TABLE nodes ADD COLUMN parent_id INTEGER REFERENCES nodes(id)'); + } catch { + /* already exists */ + } + try { + db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id)'); + } catch { + /* already exists */ + } + try { + db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_kind_parent ON nodes(kind, parent_id)'); + } catch { + /* already exists */ + } } export function findDbPath(customPath) { diff --git a/src/extractors/csharp.js b/src/extractors/csharp.js index 5af523f3..43231d1e 100644 --- a/src/extractors/csharp.js +++ b/src/extractors/csharp.js @@ -33,11 +33,13 @@ export function extractCSharpSymbols(tree, _filePath) { case 'class_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const classChildren = extractCSharpClassFields(node); definitions.push({ name: nameNode.text, kind: 'class', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: classChildren.length > 0 ? classChildren : undefined, }); extractCSharpBaseTypes(node, nameNode.text, classes); } @@ -47,11 +49,13 @@ export function extractCSharpSymbols(tree, _filePath) { case 'struct_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const structChildren = extractCSharpClassFields(node); definitions.push({ name: nameNode.text, kind: 'struct', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: structChildren.length > 0 ? structChildren : undefined, }); extractCSharpBaseTypes(node, nameNode.text, classes); } @@ -105,11 +109,13 @@ export function extractCSharpSymbols(tree, _filePath) { case 'enum_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const enumChildren = extractCSharpEnumMembers(node); definitions.push({ name: nameNode.text, kind: 'enum', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: enumChildren.length > 0 ? enumChildren : undefined, }); } break; @@ -120,11 +126,13 @@ export function extractCSharpSymbols(tree, _filePath) { if (nameNode) { const parentType = findCSharpParentType(node); const fullName = parentType ? `${parentType}.${nameNode.text}` : nameNode.text; + const params = extractCSharpParameters(node.childForFieldName('parameters')); definitions.push({ name: fullName, kind: 'method', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -135,11 +143,13 @@ export function extractCSharpSymbols(tree, _filePath) { if (nameNode) { const parentType = findCSharpParentType(node); const fullName = parentType ? `${parentType}.${nameNode.text}` : nameNode.text; + const params = extractCSharpParameters(node.childForFieldName('parameters')); definitions.push({ name: fullName, kind: 'method', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -152,7 +162,7 @@ export function extractCSharpSymbols(tree, _filePath) { const fullName = parentType ? `${parentType}.${nameNode.text}` : nameNode.text; definitions.push({ name: fullName, - kind: 'method', + kind: 'property', line: node.startPosition.row + 1, endLine: nodeEndLine(node), }); @@ -220,6 +230,59 @@ export function extractCSharpSymbols(tree, _filePath) { return { definitions, calls, imports, classes, exports }; } +// ── Child extraction helpers ──────────────────────────────────────────────── + +function extractCSharpParameters(paramListNode) { + const params = []; + if (!paramListNode) return params; + for (let i = 0; i < paramListNode.childCount; i++) { + const param = paramListNode.child(i); + if (!param || param.type !== 'parameter') continue; + const nameNode = param.childForFieldName('name'); + if (nameNode) { + params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 }); + } + } + return params; +} + +function extractCSharpClassFields(classNode) { + const fields = []; + const body = classNode.childForFieldName('body') || findChild(classNode, 'declaration_list'); + if (!body) return fields; + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member || member.type !== 'field_declaration') continue; + const varDecl = findChild(member, 'variable_declaration'); + if (!varDecl) continue; + for (let j = 0; j < varDecl.childCount; j++) { + const child = varDecl.child(j); + if (!child || child.type !== 'variable_declarator') continue; + const nameNode = child.childForFieldName('name'); + if (nameNode) { + fields.push({ name: nameNode.text, kind: 'property', line: member.startPosition.row + 1 }); + } + } + } + return fields; +} + +function extractCSharpEnumMembers(enumNode) { + const constants = []; + const body = + enumNode.childForFieldName('body') || findChild(enumNode, 'enum_member_declaration_list'); + if (!body) return constants; + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member || member.type !== 'enum_member_declaration') continue; + const nameNode = member.childForFieldName('name'); + if (nameNode) { + constants.push({ name: nameNode.text, kind: 'constant', line: member.startPosition.row + 1 }); + } + } + return constants; +} + function extractCSharpBaseTypes(node, className, classes) { const baseList = node.childForFieldName('bases'); if (!baseList) return; diff --git a/src/extractors/go.js b/src/extractors/go.js index 8b943012..a3a50158 100644 --- a/src/extractors/go.js +++ b/src/extractors/go.js @@ -1,4 +1,4 @@ -import { nodeEndLine } from './helpers.js'; +import { findChild, nodeEndLine } from './helpers.js'; /** * Extract symbols from Go files. @@ -15,11 +15,13 @@ export function extractGoSymbols(tree, _filePath) { case 'function_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const params = extractGoParameters(node.childForFieldName('parameters')); definitions.push({ name: nameNode.text, kind: 'function', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -46,11 +48,13 @@ export function extractGoSymbols(tree, _filePath) { } } const fullName = receiverType ? `${receiverType}.${nameNode.text}` : nameNode.text; + const params = extractGoParameters(node.childForFieldName('parameters')); definitions.push({ name: fullName, kind: 'method', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -64,11 +68,13 @@ export function extractGoSymbols(tree, _filePath) { const typeNode = spec.childForFieldName('type'); if (nameNode && typeNode) { if (typeNode.type === 'struct_type') { + const fields = extractStructFields(typeNode); definitions.push({ name: nameNode.text, kind: 'struct', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: fields.length > 0 ? fields : undefined, }); } else if (typeNode.type === 'interface_type') { definitions.push({ @@ -145,6 +151,23 @@ export function extractGoSymbols(tree, _filePath) { break; } + case 'const_declaration': { + for (let i = 0; i < node.childCount; i++) { + const spec = node.child(i); + if (!spec || spec.type !== 'const_spec') continue; + const constName = spec.childForFieldName('name'); + if (constName) { + definitions.push({ + name: constName.text, + kind: 'constant', + line: spec.startPosition.row + 1, + endLine: spec.endPosition.row + 1, + }); + } + } + break; + } + case 'call_expression': { const fn = node.childForFieldName('function'); if (fn) { @@ -170,3 +193,45 @@ export function extractGoSymbols(tree, _filePath) { walkGoNode(tree.rootNode); return { definitions, calls, imports, classes, exports }; } + +// ── Child extraction helpers ──────────────────────────────────────────────── + +function extractGoParameters(paramListNode) { + const params = []; + if (!paramListNode) return params; + for (let i = 0; i < paramListNode.childCount; i++) { + const param = paramListNode.child(i); + if (!param || param.type !== 'parameter_declaration') continue; + // A parameter_declaration may have multiple identifiers (e.g., `a, b int`) + for (let j = 0; j < param.childCount; j++) { + const child = param.child(j); + if (child && child.type === 'identifier') { + params.push({ name: child.text, kind: 'parameter', line: child.startPosition.row + 1 }); + } + } + } + return params; +} + +function extractStructFields(structTypeNode) { + const fields = []; + const fieldList = findChild(structTypeNode, 'field_declaration_list'); + if (!fieldList) return fields; + for (let i = 0; i < fieldList.childCount; i++) { + const field = fieldList.child(i); + if (!field || field.type !== 'field_declaration') continue; + const nameNode = field.childForFieldName('name'); + if (nameNode) { + fields.push({ name: nameNode.text, kind: 'property', line: field.startPosition.row + 1 }); + } else { + // Struct fields may have multiple names or use first identifier child + for (let j = 0; j < field.childCount; j++) { + const child = field.child(j); + if (child && child.type === 'field_identifier') { + fields.push({ name: child.text, kind: 'property', line: field.startPosition.row + 1 }); + } + } + } + } + return fields; +} diff --git a/src/extractors/hcl.js b/src/extractors/hcl.js index 4df5af4d..aba022a5 100644 --- a/src/extractors/hcl.js +++ b/src/extractors/hcl.js @@ -36,11 +36,33 @@ export function extractHCLSymbols(tree, _filePath) { } if (name) { + // Extract attributes as property children for variable/output blocks + let blockChildren; + if (blockType === 'variable' || blockType === 'output') { + blockChildren = []; + const body = children.find((c) => c.type === 'body'); + if (body) { + for (let j = 0; j < body.childCount; j++) { + const attr = body.child(j); + if (attr && attr.type === 'attribute') { + const key = attr.childForFieldName('key') || attr.child(0); + if (key) { + blockChildren.push({ + name: key.text, + kind: 'property', + line: attr.startPosition.row + 1, + }); + } + } + } + } + } definitions.push({ name, kind: blockType, line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: blockChildren?.length > 0 ? blockChildren : undefined, }); } diff --git a/src/extractors/java.js b/src/extractors/java.js index 87f10d39..bfa24571 100644 --- a/src/extractors/java.js +++ b/src/extractors/java.js @@ -1,4 +1,4 @@ -import { nodeEndLine } from './helpers.js'; +import { findChild, nodeEndLine } from './helpers.js'; /** * Extract symbols from Java files. @@ -31,11 +31,13 @@ export function extractJavaSymbols(tree, _filePath) { case 'class_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const classChildren = extractClassFields(node); definitions.push({ name: nameNode.text, kind: 'class', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: classChildren.length > 0 ? classChildren : undefined, }); const superclass = node.childForFieldName('superclass'); @@ -139,11 +141,13 @@ export function extractJavaSymbols(tree, _filePath) { case 'enum_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const enumChildren = extractEnumConstants(node); definitions.push({ name: nameNode.text, kind: 'enum', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: enumChildren.length > 0 ? enumChildren : undefined, }); } break; @@ -154,11 +158,13 @@ export function extractJavaSymbols(tree, _filePath) { if (nameNode) { const parentClass = findJavaParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; + const params = extractJavaParameters(node.childForFieldName('parameters')); definitions.push({ name: fullName, kind: 'method', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -169,11 +175,13 @@ export function extractJavaSymbols(tree, _filePath) { if (nameNode) { const parentClass = findJavaParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; + const params = extractJavaParameters(node.childForFieldName('parameters')); definitions.push({ name: fullName, kind: 'method', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -228,3 +236,55 @@ export function extractJavaSymbols(tree, _filePath) { walkJavaNode(tree.rootNode); return { definitions, calls, imports, classes, exports }; } + +// ── Child extraction helpers ──────────────────────────────────────────────── + +function extractJavaParameters(paramListNode) { + const params = []; + if (!paramListNode) return params; + for (let i = 0; i < paramListNode.childCount; i++) { + const param = paramListNode.child(i); + if (!param) continue; + if (param.type === 'formal_parameter' || param.type === 'spread_parameter') { + const nameNode = param.childForFieldName('name'); + if (nameNode) { + params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 }); + } + } + } + return params; +} + +function extractClassFields(classNode) { + const fields = []; + const body = classNode.childForFieldName('body') || findChild(classNode, 'class_body'); + if (!body) return fields; + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member || member.type !== 'field_declaration') continue; + for (let j = 0; j < member.childCount; j++) { + const child = member.child(j); + if (!child || child.type !== 'variable_declarator') continue; + const nameNode = child.childForFieldName('name'); + if (nameNode) { + fields.push({ name: nameNode.text, kind: 'property', line: member.startPosition.row + 1 }); + } + } + } + return fields; +} + +function extractEnumConstants(enumNode) { + const constants = []; + const body = enumNode.childForFieldName('body') || findChild(enumNode, 'enum_body'); + if (!body) return constants; + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member || member.type !== 'enum_constant') continue; + const nameNode = member.childForFieldName('name'); + if (nameNode) { + constants.push({ name: nameNode.text, kind: 'constant', line: member.startPosition.row + 1 }); + } + } + return constants; +} diff --git a/src/extractors/javascript.js b/src/extractors/javascript.js index 57ba0392..c4a0d3bf 100644 --- a/src/extractors/javascript.js +++ b/src/extractors/javascript.js @@ -28,31 +28,37 @@ function extractSymbolsQuery(tree, query) { if (c.fn_node) { // function_declaration + const fnChildren = extractParameters(c.fn_node); definitions.push({ name: c.fn_name.text, kind: 'function', line: c.fn_node.startPosition.row + 1, endLine: nodeEndLine(c.fn_node), + children: fnChildren.length > 0 ? fnChildren : undefined, }); } else if (c.varfn_name) { // variable_declarator with arrow_function / function_expression const declNode = c.varfn_name.parent?.parent; const line = declNode ? declNode.startPosition.row + 1 : c.varfn_name.startPosition.row + 1; + const varFnChildren = extractParameters(c.varfn_value); definitions.push({ name: c.varfn_name.text, kind: 'function', line, endLine: nodeEndLine(c.varfn_value), + children: varFnChildren.length > 0 ? varFnChildren : undefined, }); } else if (c.cls_node) { // class_declaration const className = c.cls_name.text; const startLine = c.cls_node.startPosition.row + 1; + const clsChildren = extractClassProperties(c.cls_node); definitions.push({ name: className, kind: 'class', line: startLine, endLine: nodeEndLine(c.cls_node), + children: clsChildren.length > 0 ? clsChildren : undefined, }); const heritage = c.cls_node.childForFieldName('heritage') || findChild(c.cls_node, 'class_heritage'); @@ -69,11 +75,13 @@ function extractSymbolsQuery(tree, query) { const methName = c.meth_name.text; const parentClass = findParentClass(c.meth_node); const fullName = parentClass ? `${parentClass}.${methName}` : methName; + const methChildren = extractParameters(c.meth_node); definitions.push({ name: fullName, kind: 'method', line: c.meth_node.startPosition.row + 1, endLine: nodeEndLine(c.meth_node), + children: methChildren.length > 0 ? methChildren : undefined, }); } else if (c.iface_node) { // interface_declaration (TS/TSX only) @@ -231,11 +239,13 @@ function extractSymbolsWalk(tree) { case 'function_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const fnChildren = extractParameters(node); definitions.push({ name: nameNode.text, kind: 'function', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: fnChildren.length > 0 ? fnChildren : undefined, }); } break; @@ -246,11 +256,13 @@ function extractSymbolsWalk(tree) { if (nameNode) { const className = nameNode.text; const startLine = node.startPosition.row + 1; + const clsChildren = extractClassProperties(node); definitions.push({ name: className, kind: 'class', line: startLine, endLine: nodeEndLine(node), + children: clsChildren.length > 0 ? clsChildren : undefined, }); const heritage = node.childForFieldName('heritage') || findChild(node, 'class_heritage'); if (heritage) { @@ -272,11 +284,13 @@ function extractSymbolsWalk(tree) { if (nameNode) { const parentClass = findParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; + const methChildren = extractParameters(node); definitions.push({ name: fullName, kind: 'method', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: methChildren.length > 0 ? methChildren : undefined, }); } break; @@ -317,6 +331,7 @@ function extractSymbolsWalk(tree) { case 'lexical_declaration': case 'variable_declaration': { + const isConst = node.text.startsWith('const '); for (let i = 0; i < node.childCount; i++) { const declarator = node.child(i); if (declarator && declarator.type === 'variable_declarator') { @@ -329,15 +344,59 @@ function extractSymbolsWalk(tree) { valType === 'function_expression' || valType === 'function' ) { + const varFnChildren = extractParameters(valueN); definitions.push({ name: nameN.text, kind: 'function', line: node.startPosition.row + 1, endLine: nodeEndLine(valueN), + children: varFnChildren.length > 0 ? varFnChildren : undefined, }); + } else if (isConst && nameN.type === 'identifier' && isConstantValue(valueN)) { + definitions.push({ + name: nameN.text, + kind: 'constant', + line: node.startPosition.row + 1, + endLine: nodeEndLine(node), + }); + } + } else if (isConst && nameN && nameN.type === 'identifier' && !valueN) { + // const with no value (shouldn't happen but be safe) + } + } + } + break; + } + + case 'enum_declaration': { + // TypeScript enum + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const enumChildren = []; + const body = node.childForFieldName('body') || findChild(node, 'enum_body'); + if (body) { + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member) continue; + if (member.type === 'enum_assignment' || member.type === 'property_identifier') { + const mName = member.childForFieldName('name') || member.child(0); + if (mName) { + enumChildren.push({ + name: mName.text, + kind: 'constant', + line: member.startPosition.row + 1, + }); + } } } } + definitions.push({ + name: nameNode.text, + kind: 'enum', + line: node.startPosition.row + 1, + endLine: nodeEndLine(node), + children: enumChildren.length > 0 ? enumChildren : undefined, + }); } break; } @@ -471,6 +530,89 @@ function extractSymbolsWalk(tree) { return { definitions, calls, imports, classes, exports }; } +// ── Child extraction helpers ──────────────────────────────────────────────── + +function extractParameters(node) { + const params = []; + const paramsNode = node.childForFieldName('parameters') || findChild(node, 'formal_parameters'); + if (!paramsNode) return params; + for (let i = 0; i < paramsNode.childCount; i++) { + const child = paramsNode.child(i); + if (!child) continue; + const t = child.type; + if (t === 'identifier') { + params.push({ name: child.text, kind: 'parameter', line: child.startPosition.row + 1 }); + } else if ( + t === 'required_parameter' || + t === 'optional_parameter' || + t === 'assignment_pattern' + ) { + const nameNode = + child.childForFieldName('pattern') || child.childForFieldName('left') || child.child(0); + if ( + nameNode && + (nameNode.type === 'identifier' || + nameNode.type === 'shorthand_property_identifier_pattern') + ) { + params.push({ name: nameNode.text, kind: 'parameter', line: child.startPosition.row + 1 }); + } + } else if (t === 'rest_pattern' || t === 'rest_element') { + const nameNode = child.child(1) || child.childForFieldName('name'); + if (nameNode && nameNode.type === 'identifier') { + params.push({ name: nameNode.text, kind: 'parameter', line: child.startPosition.row + 1 }); + } + } + } + return params; +} + +function extractClassProperties(classNode) { + const props = []; + const body = classNode.childForFieldName('body') || findChild(classNode, 'class_body'); + if (!body) return props; + for (let i = 0; i < body.childCount; i++) { + const child = body.child(i); + if (!child) continue; + if ( + child.type === 'field_definition' || + child.type === 'public_field_definition' || + child.type === 'property_definition' + ) { + const nameNode = + child.childForFieldName('name') || child.childForFieldName('property') || child.child(0); + if ( + nameNode && + (nameNode.type === 'property_identifier' || + nameNode.type === 'identifier' || + nameNode.type === 'private_property_identifier') + ) { + props.push({ name: nameNode.text, kind: 'property', line: child.startPosition.row + 1 }); + } + } + } + return props; +} + +function isConstantValue(valueNode) { + if (!valueNode) return false; + const t = valueNode.type; + return ( + t === 'number' || + t === 'string' || + t === 'template_string' || + t === 'true' || + t === 'false' || + t === 'null' || + t === 'undefined' || + t === 'array' || + t === 'object' || + t === 'regex' || + t === 'unary_expression' || + t === 'binary_expression' || + t === 'new_expression' + ); +} + // ── Shared helpers ────────────────────────────────────────────────────────── function extractInterfaceMethods(bodyNode, interfaceName, definitions) { diff --git a/src/extractors/php.js b/src/extractors/php.js index 95b44570..d2b4f09d 100644 --- a/src/extractors/php.js +++ b/src/extractors/php.js @@ -1,5 +1,76 @@ import { findChild, nodeEndLine } from './helpers.js'; +function extractPhpParameters(fnNode) { + const params = []; + const paramsNode = + fnNode.childForFieldName('parameters') || findChild(fnNode, 'formal_parameters'); + if (!paramsNode) return params; + for (let i = 0; i < paramsNode.childCount; i++) { + const param = paramsNode.child(i); + if (!param) continue; + if (param.type === 'simple_parameter' || param.type === 'variadic_parameter') { + const nameNode = param.childForFieldName('name') || findChild(param, 'variable_name'); + if (nameNode) { + params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 }); + } + } + } + return params; +} + +function extractPhpClassChildren(classNode) { + const children = []; + const body = classNode.childForFieldName('body') || findChild(classNode, 'declaration_list'); + if (!body) return children; + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member) continue; + if (member.type === 'property_declaration') { + for (let j = 0; j < member.childCount; j++) { + const el = member.child(j); + if (!el || el.type !== 'property_element') continue; + const varNode = findChild(el, 'variable_name'); + if (varNode) { + children.push({ + name: varNode.text, + kind: 'property', + line: member.startPosition.row + 1, + }); + } + } + } else if (member.type === 'const_declaration') { + for (let j = 0; j < member.childCount; j++) { + const el = member.child(j); + if (!el || el.type !== 'const_element') continue; + const nameNode = el.childForFieldName('name') || findChild(el, 'name'); + if (nameNode) { + children.push({ + name: nameNode.text, + kind: 'constant', + line: member.startPosition.row + 1, + }); + } + } + } + } + return children; +} + +function extractPhpEnumCases(enumNode) { + const children = []; + const body = enumNode.childForFieldName('body') || findChild(enumNode, 'enum_declaration_list'); + if (!body) return children; + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member || member.type !== 'enum_case') continue; + const nameNode = member.childForFieldName('name'); + if (nameNode) { + children.push({ name: nameNode.text, kind: 'constant', line: member.startPosition.row + 1 }); + } + } + return children; +} + /** * Extract symbols from PHP files. */ @@ -31,11 +102,13 @@ export function extractPHPSymbols(tree, _filePath) { case 'function_definition': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const params = extractPhpParameters(node); definitions.push({ name: nameNode.text, kind: 'function', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -44,11 +117,13 @@ export function extractPHPSymbols(tree, _filePath) { case 'class_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const classChildren = extractPhpClassChildren(node); definitions.push({ name: nameNode.text, kind: 'class', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: classChildren.length > 0 ? classChildren : undefined, }); // Check base clause (extends) @@ -132,11 +207,13 @@ export function extractPHPSymbols(tree, _filePath) { case 'enum_declaration': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const enumChildren = extractPhpEnumCases(node); definitions.push({ name: nameNode.text, kind: 'enum', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: enumChildren.length > 0 ? enumChildren : undefined, }); } break; @@ -147,11 +224,13 @@ export function extractPHPSymbols(tree, _filePath) { if (nameNode) { const parentClass = findPHPParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; + const params = extractPhpParameters(node); definitions.push({ name: fullName, kind: 'method', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; diff --git a/src/extractors/python.js b/src/extractors/python.js index 832232f0..6542aab7 100644 --- a/src/extractors/python.js +++ b/src/extractors/python.js @@ -22,12 +22,14 @@ export function extractPythonSymbols(tree, _filePath) { const parentClass = findPythonParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; const kind = parentClass ? 'method' : 'function'; + const fnChildren = extractPythonParameters(node); definitions.push({ name: fullName, kind, line: node.startPosition.row + 1, endLine: nodeEndLine(node), decorators, + children: fnChildren.length > 0 ? fnChildren : undefined, }); } break; @@ -36,11 +38,13 @@ export function extractPythonSymbols(tree, _filePath) { case 'class_definition': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const clsChildren = extractPythonClassProperties(node); definitions.push({ name: nameNode.text, kind: 'class', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: clsChildren.length > 0 ? clsChildren : undefined, }); const superclasses = node.childForFieldName('superclasses') || findChild(node, 'argument_list'); @@ -108,6 +112,24 @@ export function extractPythonSymbols(tree, _filePath) { break; } + case 'expression_statement': { + // Module-level UPPER_CASE assignments → constants + if (node.parent && node.parent.type === 'module') { + const assignment = findChild(node, 'assignment'); + if (assignment) { + const left = assignment.childForFieldName('left'); + if (left && left.type === 'identifier' && /^[A-Z_][A-Z0-9_]*$/.test(left.text)) { + definitions.push({ + name: left.text, + kind: 'constant', + line: node.startPosition.row + 1, + }); + } + } + } + break; + } + case 'import_from_statement': { let source = ''; const names = []; @@ -133,6 +155,118 @@ export function extractPythonSymbols(tree, _filePath) { for (let i = 0; i < node.childCount; i++) walkPythonNode(node.child(i)); } + function extractPythonParameters(fnNode) { + const params = []; + const paramsNode = fnNode.childForFieldName('parameters') || findChild(fnNode, 'parameters'); + if (!paramsNode) return params; + for (let i = 0; i < paramsNode.childCount; i++) { + const child = paramsNode.child(i); + if (!child) continue; + const t = child.type; + if (t === 'identifier') { + params.push({ name: child.text, kind: 'parameter', line: child.startPosition.row + 1 }); + } else if ( + t === 'typed_parameter' || + t === 'default_parameter' || + t === 'typed_default_parameter' + ) { + const nameNode = child.childForFieldName('name') || child.child(0); + if (nameNode && nameNode.type === 'identifier') { + params.push({ + name: nameNode.text, + kind: 'parameter', + line: child.startPosition.row + 1, + }); + } + } else if (t === 'list_splat_pattern' || t === 'dictionary_splat_pattern') { + // *args, **kwargs + for (let j = 0; j < child.childCount; j++) { + const inner = child.child(j); + if (inner && inner.type === 'identifier') { + params.push({ name: inner.text, kind: 'parameter', line: child.startPosition.row + 1 }); + break; + } + } + } + } + return params; + } + + function extractPythonClassProperties(classNode) { + const props = []; + const seen = new Set(); + const body = classNode.childForFieldName('body') || findChild(classNode, 'block'); + if (!body) return props; + + for (let i = 0; i < body.childCount; i++) { + const child = body.child(i); + if (!child) continue; + + // Direct class attribute assignments: x = 5 + if (child.type === 'expression_statement') { + const assignment = findChild(child, 'assignment'); + if (assignment) { + const left = assignment.childForFieldName('left'); + if (left && left.type === 'identifier' && !seen.has(left.text)) { + seen.add(left.text); + props.push({ name: left.text, kind: 'property', line: child.startPosition.row + 1 }); + } + } + } + + // __init__ method: self.x = ... assignments + if (child.type === 'function_definition') { + const fnName = child.childForFieldName('name'); + if (fnName && fnName.text === '__init__') { + const initBody = child.childForFieldName('body') || findChild(child, 'block'); + if (initBody) { + walkInitBody(initBody, seen, props); + } + } + } + + // decorated __init__ + if (child.type === 'decorated_definition') { + for (let j = 0; j < child.childCount; j++) { + const inner = child.child(j); + if (inner && inner.type === 'function_definition') { + const fnName = inner.childForFieldName('name'); + if (fnName && fnName.text === '__init__') { + const initBody = inner.childForFieldName('body') || findChild(inner, 'block'); + if (initBody) { + walkInitBody(initBody, seen, props); + } + } + } + } + } + } + return props; + } + + function walkInitBody(bodyNode, seen, props) { + for (let i = 0; i < bodyNode.childCount; i++) { + const stmt = bodyNode.child(i); + if (!stmt || stmt.type !== 'expression_statement') continue; + const assignment = findChild(stmt, 'assignment'); + if (!assignment) continue; + const left = assignment.childForFieldName('left'); + if (!left || left.type !== 'attribute') continue; + const obj = left.childForFieldName('object'); + const attr = left.childForFieldName('attribute'); + if ( + obj && + obj.text === 'self' && + attr && + attr.type === 'identifier' && + !seen.has(attr.text) + ) { + seen.add(attr.text); + props.push({ name: attr.text, kind: 'property', line: stmt.startPosition.row + 1 }); + } + } + } + function findPythonParentClass(node) { let current = node.parent; while (current) { diff --git a/src/extractors/ruby.js b/src/extractors/ruby.js index 73b3f0d4..400d410d 100644 --- a/src/extractors/ruby.js +++ b/src/extractors/ruby.js @@ -31,11 +31,13 @@ export function extractRubySymbols(tree, _filePath) { case 'class': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const classChildren = extractRubyClassChildren(node); definitions.push({ name: nameNode.text, kind: 'class', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: classChildren.length > 0 ? classChildren : undefined, }); const superclass = node.childForFieldName('superclass'); if (superclass) { @@ -73,11 +75,13 @@ export function extractRubySymbols(tree, _filePath) { case 'module': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const moduleChildren = extractRubyBodyConstants(node); definitions.push({ name: nameNode.text, kind: 'module', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: moduleChildren.length > 0 ? moduleChildren : undefined, }); } break; @@ -88,11 +92,13 @@ export function extractRubySymbols(tree, _filePath) { if (nameNode) { const parentClass = findRubyParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; + const params = extractRubyParameters(node); definitions.push({ name: fullName, kind: 'method', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -103,16 +109,34 @@ export function extractRubySymbols(tree, _filePath) { if (nameNode) { const parentClass = findRubyParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; + const params = extractRubyParameters(node); definitions.push({ name: fullName, kind: 'function', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; } + case 'assignment': { + // Top-level constant assignments (parent is program) + if (node.parent && node.parent.type === 'program') { + const left = node.childForFieldName('left'); + if (left && left.type === 'constant') { + definitions.push({ + name: left.text, + kind: 'constant', + line: node.startPosition.row + 1, + endLine: nodeEndLine(node), + }); + } + } + break; + } + case 'call': { const methodNode = node.childForFieldName('method'); if (methodNode) { @@ -186,3 +210,68 @@ export function extractRubySymbols(tree, _filePath) { walkRubyNode(tree.rootNode); return { definitions, calls, imports, classes, exports }; } + +// ── Child extraction helpers ──────────────────────────────────────────────── + +const RUBY_PARAM_TYPES = new Set([ + 'identifier', + 'optional_parameter', + 'splat_parameter', + 'hash_splat_parameter', + 'block_parameter', + 'keyword_parameter', +]); + +function extractRubyParameters(methodNode) { + const params = []; + const paramList = + methodNode.childForFieldName('parameters') || findChild(methodNode, 'method_parameters'); + if (!paramList) return params; + for (let i = 0; i < paramList.childCount; i++) { + const param = paramList.child(i); + if (!param || !RUBY_PARAM_TYPES.has(param.type)) continue; + let name; + if (param.type === 'identifier') { + name = param.text; + } else { + // Compound parameter types have an identifier child for the name + const id = findChild(param, 'identifier'); + name = id ? id.text : param.text; + } + params.push({ name, kind: 'parameter', line: param.startPosition.row + 1 }); + } + return params; +} + +function extractRubyBodyConstants(containerNode) { + const children = []; + const body = containerNode.childForFieldName('body') || findChild(containerNode, 'body'); + if (!body) return children; + for (let i = 0; i < body.childCount; i++) { + const child = body.child(i); + if (!child || child.type !== 'assignment') continue; + const left = child.childForFieldName('left'); + if (left && left.type === 'constant') { + children.push({ name: left.text, kind: 'constant', line: child.startPosition.row + 1 }); + } + } + return children; +} + +function extractRubyClassChildren(classNode) { + const children = []; + const body = classNode.childForFieldName('body') || findChild(classNode, 'body'); + if (!body) return children; + for (let i = 0; i < body.childCount; i++) { + const child = body.child(i); + if (!child || child.type !== 'assignment') continue; + const left = child.childForFieldName('left'); + if (!left) continue; + if (left.type === 'instance_variable') { + children.push({ name: left.text, kind: 'property', line: child.startPosition.row + 1 }); + } else if (left.type === 'constant') { + children.push({ name: left.text, kind: 'constant', line: child.startPosition.row + 1 }); + } + } + return children; +} diff --git a/src/extractors/rust.js b/src/extractors/rust.js index 5a8d6225..2a013481 100644 --- a/src/extractors/rust.js +++ b/src/extractors/rust.js @@ -1,4 +1,4 @@ -import { nodeEndLine } from './helpers.js'; +import { findChild, nodeEndLine } from './helpers.js'; /** * Extract symbols from Rust files. @@ -30,11 +30,13 @@ export function extractRustSymbols(tree, _filePath) { const implType = findCurrentImpl(node); const fullName = implType ? `${implType}.${nameNode.text}` : nameNode.text; const kind = implType ? 'method' : 'function'; + const params = extractRustParameters(node.childForFieldName('parameters')); definitions.push({ name: fullName, kind, line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: params.length > 0 ? params : undefined, }); } break; @@ -43,11 +45,13 @@ export function extractRustSymbols(tree, _filePath) { case 'struct_item': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const fields = extractStructFields(node); definitions.push({ name: nameNode.text, kind: 'struct', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: fields.length > 0 ? fields : undefined, }); } break; @@ -56,11 +60,26 @@ export function extractRustSymbols(tree, _filePath) { case 'enum_item': { const nameNode = node.childForFieldName('name'); if (nameNode) { + const variants = extractEnumVariants(node); definitions.push({ name: nameNode.text, kind: 'enum', line: node.startPosition.row + 1, endLine: nodeEndLine(node), + children: variants.length > 0 ? variants : undefined, + }); + } + break; + } + + case 'const_item': { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + definitions.push({ + name: nameNode.text, + kind: 'constant', + line: node.startPosition.row + 1, + endLine: nodeEndLine(node), }); } break; @@ -170,6 +189,57 @@ export function extractRustSymbols(tree, _filePath) { return { definitions, calls, imports, classes, exports }; } +// ── Child extraction helpers ──────────────────────────────────────────────── + +function extractRustParameters(paramListNode) { + const params = []; + if (!paramListNode) return params; + for (let i = 0; i < paramListNode.childCount; i++) { + const param = paramListNode.child(i); + if (!param) continue; + if (param.type === 'self_parameter') { + params.push({ name: 'self', kind: 'parameter', line: param.startPosition.row + 1 }); + } else if (param.type === 'parameter') { + const pattern = param.childForFieldName('pattern'); + if (pattern) { + params.push({ name: pattern.text, kind: 'parameter', line: param.startPosition.row + 1 }); + } + } + } + return params; +} + +function extractStructFields(structNode) { + const fields = []; + const fieldList = + structNode.childForFieldName('body') || findChild(structNode, 'field_declaration_list'); + if (!fieldList) return fields; + for (let i = 0; i < fieldList.childCount; i++) { + const field = fieldList.child(i); + if (!field || field.type !== 'field_declaration') continue; + const nameNode = field.childForFieldName('name'); + if (nameNode) { + fields.push({ name: nameNode.text, kind: 'property', line: field.startPosition.row + 1 }); + } + } + return fields; +} + +function extractEnumVariants(enumNode) { + const variants = []; + const body = enumNode.childForFieldName('body') || findChild(enumNode, 'enum_variant_list'); + if (!body) return variants; + for (let i = 0; i < body.childCount; i++) { + const variant = body.child(i); + if (!variant || variant.type !== 'enum_variant') continue; + const nameNode = variant.childForFieldName('name'); + if (nameNode) { + variants.push({ name: nameNode.text, kind: 'constant', line: variant.startPosition.row + 1 }); + } + } + return variants; +} + function extractRustUsePath(node) { if (!node) return []; diff --git a/src/index.js b/src/index.js index 03be6853..973d2475 100644 --- a/src/index.js +++ b/src/index.js @@ -107,9 +107,13 @@ export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js'; // Query functions (data-returning) export { ALL_SYMBOL_KINDS, + CORE_SYMBOL_KINDS, + childrenData, contextData, diffImpactData, diffImpactMermaid, + EVERY_SYMBOL_KIND, + EXTENDED_SYMBOL_KINDS, explainData, FALSE_POSITIVE_CALLER_THRESHOLD, FALSE_POSITIVE_NAMES, diff --git a/src/mcp.js b/src/mcp.js index 405b09c2..d02cdf29 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -9,7 +9,7 @@ import { createRequire } from 'node:module'; import { findCycles } from './cycles.js'; import { findDbPath } from './db.js'; import { MCP_DEFAULTS, MCP_MAX_LIMIT } from './paginate.js'; -import { ALL_SYMBOL_KINDS, diffImpactMermaid, VALID_ROLES } from './queries.js'; +import { diffImpactMermaid, EVERY_SYMBOL_KIND, VALID_ROLES } from './queries.js'; const REPO_PROP = { repo: { @@ -47,7 +47,7 @@ const BASE_TOOLS = [ }, kind: { type: 'string', - enum: ALL_SYMBOL_KINDS, + enum: EVERY_SYMBOL_KIND, description: 'Filter by symbol kind', }, to: { type: 'string', description: 'Target symbol for path mode (required in path mode)' }, @@ -129,7 +129,7 @@ const BASE_TOOLS = [ }, kind: { type: 'string', - enum: ALL_SYMBOL_KINDS, + enum: EVERY_SYMBOL_KIND, description: 'Filter to a specific symbol kind', }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, @@ -157,7 +157,7 @@ const BASE_TOOLS = [ }, kind: { type: 'string', - enum: ALL_SYMBOL_KINDS, + enum: EVERY_SYMBOL_KIND, description: 'Filter to a specific symbol kind', }, no_source: { @@ -176,6 +176,22 @@ const BASE_TOOLS = [ required: ['name'], }, }, + { + name: 'symbol_children', + description: + 'List sub-declaration children of a symbol: parameters, properties, constants. Answers "what fields does this class have?" without reading source.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Function/method/class name (partial match)' }, + file: { type: 'string', description: 'Scope to file (partial match)' }, + kind: { type: 'string', enum: EVERY_SYMBOL_KIND, description: 'Filter by symbol kind' }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, + }, + required: ['name'], + }, + }, { name: 'explain', description: @@ -394,7 +410,7 @@ const BASE_TOOLS = [ }, kind: { type: 'string', - enum: ALL_SYMBOL_KINDS, + enum: EVERY_SYMBOL_KIND, description: 'Filter to a specific symbol kind', }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, @@ -560,7 +576,7 @@ const BASE_TOOLS = [ }, kind: { type: 'string', - enum: ALL_SYMBOL_KINDS, + enum: EVERY_SYMBOL_KIND, description: 'Filter symbol kind', }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, @@ -639,7 +655,7 @@ const BASE_TOOLS = [ }, depth: { type: 'number', description: 'Max depth for impact mode', default: 5 }, file: { type: 'string', description: 'Scope to file (partial match)' }, - kind: { type: 'string', enum: ALL_SYMBOL_KINDS, description: 'Filter by symbol kind' }, + kind: { type: 'string', enum: EVERY_SYMBOL_KIND, description: 'Filter by symbol kind' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, ...PAGINATION_PROPS, }, @@ -740,6 +756,7 @@ export async function startMCPServer(customDbPath, options = {}) { fnImpactData, pathData, contextData, + childrenData, explainData, whereData, diffImpactData, @@ -864,6 +881,15 @@ export async function startMCPServer(customDbPath, options = {}) { offset: args.offset ?? 0, }); break; + case 'symbol_children': + result = childrenData(args.name, dbPath, { + file: args.file, + kind: args.kind, + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.context, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); + break; case 'explain': result = explainData(args.target, dbPath, { noTests: args.no_tests, diff --git a/src/parser.js b/src/parser.js index f70e67c2..54eb0820 100644 --- a/src/parser.js +++ b/src/parser.js @@ -142,6 +142,14 @@ function normalizeNativeSymbols(result) { maintainabilityIndex: d.complexity.maintainabilityIndex ?? null, } : null, + children: d.children?.length + ? d.children.map((c) => ({ + name: c.name, + kind: c.kind, + line: c.line, + endLine: c.endLine ?? c.end_line ?? null, + })) + : undefined, })), calls: (result.calls || []).map((c) => ({ name: c.name, diff --git a/src/queries.js b/src/queries.js index 5ee87b0c..636cfae9 100644 --- a/src/queries.js +++ b/src/queries.js @@ -59,7 +59,9 @@ export const FALSE_POSITIVE_NAMES = new Set([ export const FALSE_POSITIVE_CALLER_THRESHOLD = 20; const FUNCTION_KINDS = ['function', 'method', 'class']; -export const ALL_SYMBOL_KINDS = [ + +// Original 10 kinds — used as default query scope +export const CORE_SYMBOL_KINDS = [ 'function', 'method', 'class', @@ -72,6 +74,21 @@ export const ALL_SYMBOL_KINDS = [ 'module', ]; +// Sub-declaration kinds (Phase 1) +export const EXTENDED_SYMBOL_KINDS = [ + 'parameter', + 'property', + 'constant', + // Phase 2 (reserved, not yet extracted): + // 'constructor', 'namespace', 'decorator', 'getter', 'setter', +]; + +// Full set for --kind validation and MCP enum +export const EVERY_SYMBOL_KIND = [...CORE_SYMBOL_KINDS, ...EXTENDED_SYMBOL_KINDS]; + +// Backward compat: ALL_SYMBOL_KINDS stays as the core 10 +export const ALL_SYMBOL_KINDS = CORE_SYMBOL_KINDS; + export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'leaf']; /** @@ -190,6 +207,12 @@ export function kindIcon(kind) { return 'I'; case 'type': return 'T'; + case 'parameter': + return 'p'; + case 'property': + return '.'; + case 'constant': + return 'C'; default: return '-'; } @@ -2224,6 +2247,17 @@ export function contextData(name, customDbPath, opts = {}) { /* table may not exist */ } + // Children (parameters, properties, constants) + let nodeChildren = []; + try { + nodeChildren = db + .prepare('SELECT name, kind, line, end_line FROM nodes WHERE parent_id = ? ORDER BY line') + .all(node.id) + .map((c) => ({ name: c.name, kind: c.kind, line: c.line, endLine: c.end_line || null })); + } catch { + /* parent_id column may not exist */ + } + return { name: node.name, kind: node.kind, @@ -2234,6 +2268,7 @@ export function contextData(name, customDbPath, opts = {}) { source, signature, complexity: complexityMetrics, + children: nodeChildren.length > 0 ? nodeChildren : undefined, callees, callers, relatedTests, @@ -2273,6 +2308,15 @@ export function context(name, customDbPath, opts = {}) { console.log(); } + // Children + if (r.children && r.children.length > 0) { + console.log(`## Children (${r.children.length})`); + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + console.log(); + } + // Complexity if (r.complexity) { const cx = r.complexity; @@ -2345,6 +2389,69 @@ export function context(name, customDbPath, opts = {}) { } } +// ─── childrenData ─────────────────────────────────────────────────────── + +export function childrenData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + const noTests = opts.noTests || false; + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + db.close(); + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let children; + try { + children = db + .prepare('SELECT name, kind, line, end_line FROM nodes WHERE parent_id = ? ORDER BY line') + .all(node.id); + } catch { + children = []; + } + if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file)); + return { + name: node.name, + kind: node.kind, + file: node.file, + line: node.line, + children: children.map((c) => ({ + name: c.name, + kind: c.kind, + line: c.line, + endLine: c.end_line || null, + })), + }; + }); + + db.close(); + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); +} + +export function children(name, customDbPath, opts = {}) { + const data = childrenData(name, customDbPath, opts); + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + if (data.results.length === 0) { + console.log(`No symbol matching "${name}"`); + return; + } + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); + if (r.children.length === 0) { + console.log(' (no children)'); + } else { + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + } + } +} + // ─── explainData ──────────────────────────────────────────────────────── function isFileLikeTarget(target) { diff --git a/tests/integration/build-parity.test.js b/tests/integration/build-parity.test.js index 94097e7f..5651a61b 100644 --- a/tests/integration/build-parity.test.js +++ b/tests/integration/build-parity.test.js @@ -76,9 +76,14 @@ describeOrSkip('Build parity: native vs WASM', () => { }); it('produces identical nodes', () => { + // Filter out extended kinds (parameter, property, constant) — WASM extracts + // these as children but native engine defers child extraction for now. + const EXTENDED = new Set(['parameter', 'property', 'constant']); + const filterCore = (nodes) => nodes.filter((n) => !EXTENDED.has(n.kind)); + const wasmGraph = readGraph(path.join(wasmDir, '.codegraph', 'graph.db')); const nativeGraph = readGraph(path.join(nativeDir, '.codegraph', 'graph.db')); - expect(nativeGraph.nodes).toEqual(wasmGraph.nodes); + expect(filterCore(nativeGraph.nodes)).toEqual(filterCore(wasmGraph.nodes)); }); it('produces identical edges', () => { diff --git a/tests/parsers/csharp.test.js b/tests/parsers/csharp.test.js index f49913d2..e8031262 100644 --- a/tests/parsers/csharp.test.js +++ b/tests/parsers/csharp.test.js @@ -108,7 +108,7 @@ public class Foo {}`); public string Name { get; set; } }`); expect(symbols.definitions).toContainEqual( - expect.objectContaining({ name: 'User.Name', kind: 'method' }), + expect.objectContaining({ name: 'User.Name', kind: 'property' }), ); }); }); diff --git a/tests/parsers/extended-kinds.test.js b/tests/parsers/extended-kinds.test.js new file mode 100644 index 00000000..266ac44a --- /dev/null +++ b/tests/parsers/extended-kinds.test.js @@ -0,0 +1,504 @@ +/** + * Extended kind extraction tests (parameters, properties, constants). + * + * Validates that each language extractor populates the `children` array + * on definitions with parameter, property, and constant entries. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { + createParsers, + extractCSharpSymbols, + extractGoSymbols, + extractJavaSymbols, + extractPHPSymbols, + extractPythonSymbols, + extractRubySymbols, + extractRustSymbols, + extractSymbols, +} from '../../src/parser.js'; + +// ── JavaScript ────────────────────────────────────────────────────────────── + +describe('JavaScript extended kinds', () => { + let parsers; + + beforeAll(async () => { + parsers = await createParsers(); + }); + + function parseJS(code) { + const parser = parsers.get('javascript'); + const tree = parser.parse(code); + return extractSymbols(tree, 'test.js'); + } + + describe('parameter extraction', () => { + it('extracts parameters from function declarations', () => { + const symbols = parseJS('function greet(name, age) { }'); + const greet = symbols.definitions.find((d) => d.name === 'greet'); + expect(greet).toBeDefined(); + expect(greet.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'name', kind: 'parameter' }), + expect.objectContaining({ name: 'age', kind: 'parameter' }), + ]), + ); + }); + + it('extracts parameters from arrow functions', () => { + const symbols = parseJS('const add = (a, b) => a + b;'); + const add = symbols.definitions.find((d) => d.name === 'add'); + expect(add).toBeDefined(); + expect(add.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'a', kind: 'parameter' }), + expect.objectContaining({ name: 'b', kind: 'parameter' }), + ]), + ); + }); + + it('extracts parameters from class methods', () => { + const symbols = parseJS('class Foo { bar(x, y) {} }'); + const bar = symbols.definitions.find((d) => d.name === 'Foo.bar'); + expect(bar).toBeDefined(); + expect(bar.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'x', kind: 'parameter' }), + expect.objectContaining({ name: 'y', kind: 'parameter' }), + ]), + ); + }); + }); + + describe('property extraction', () => { + it('extracts class field properties', () => { + const symbols = parseJS('class User { name; age; greet() {} }'); + const user = symbols.definitions.find((d) => d.name === 'User'); + expect(user).toBeDefined(); + expect(user.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'name', kind: 'property' }), + expect.objectContaining({ name: 'age', kind: 'property' }), + ]), + ); + }); + }); + + describe('constant extraction', () => { + it('extracts constant definitions from const declarations', () => { + const symbols = parseJS('const MAX = 100;'); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'MAX', kind: 'constant' }), + ); + }); + }); +}); + +// ── Python ────────────────────────────────────────────────────────────────── + +describe('Python extended kinds', () => { + let parsers; + + beforeAll(async () => { + parsers = await createParsers(); + }); + + function parsePython(code) { + const parser = parsers.get('python'); + if (!parser) throw new Error('Python parser not available'); + const tree = parser.parse(code); + return extractPythonSymbols(tree, 'test.py'); + } + + describe('parameter extraction', () => { + it('extracts parameters from function definitions', () => { + const symbols = parsePython('def greet(name, age=30):\n pass'); + const greet = symbols.definitions.find((d) => d.name === 'greet'); + expect(greet).toBeDefined(); + expect(greet.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'name', kind: 'parameter' }), + expect.objectContaining({ name: 'age', kind: 'parameter' }), + ]), + ); + }); + }); + + describe('property extraction', () => { + it('extracts properties from __init__ self assignments', () => { + const symbols = parsePython( + ['class User:', ' def __init__(self, x, y):', ' self.x = x', ' self.y = y'].join( + '\n', + ), + ); + const user = symbols.definitions.find((d) => d.name === 'User'); + expect(user).toBeDefined(); + expect(user.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'x', kind: 'property' }), + expect.objectContaining({ name: 'y', kind: 'property' }), + ]), + ); + }); + }); + + describe('constant extraction', () => { + it('extracts module-level UPPER_CASE constants', () => { + const symbols = parsePython('MAX_RETRIES = 3'); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'MAX_RETRIES', kind: 'constant' }), + ); + }); + }); +}); + +// ── Go ────────────────────────────────────────────────────────────────────── + +describe('Go extended kinds', () => { + let parsers; + + beforeAll(async () => { + parsers = await createParsers(); + }); + + function parseGo(code) { + const parser = parsers.get('go'); + if (!parser) throw new Error('Go parser not available'); + const tree = parser.parse(code); + return extractGoSymbols(tree, 'test.go'); + } + + describe('parameter extraction', () => { + it('extracts parameters from function declarations', () => { + const symbols = parseGo('package main\nfunc add(a int, b int) int { return a + b }'); + const add = symbols.definitions.find((d) => d.name === 'add'); + expect(add).toBeDefined(); + expect(add.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'a', kind: 'parameter' }), + expect.objectContaining({ name: 'b', kind: 'parameter' }), + ]), + ); + }); + }); + + describe('property extraction', () => { + it('extracts struct fields as properties', () => { + const symbols = parseGo('package main\ntype User struct {\n Name string\n Age int\n}'); + const user = symbols.definitions.find((d) => d.name === 'User'); + expect(user).toBeDefined(); + expect(user.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Name', kind: 'property' }), + expect.objectContaining({ name: 'Age', kind: 'property' }), + ]), + ); + }); + }); + + describe('constant extraction', () => { + it('extracts const declarations', () => { + const symbols = parseGo('package main\nconst MaxRetries = 3'); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'MaxRetries', kind: 'constant' }), + ); + }); + }); +}); + +// ── Rust ───────────────────────────────────────────────────────────────────── + +describe('Rust extended kinds', () => { + let parsers; + + beforeAll(async () => { + parsers = await createParsers(); + }); + + function parseRust(code) { + const parser = parsers.get('rust'); + if (!parser) throw new Error('Rust parser not available'); + const tree = parser.parse(code); + return extractRustSymbols(tree, 'test.rs'); + } + + describe('parameter extraction', () => { + it('extracts parameters from function declarations', () => { + const symbols = parseRust('fn add(a: i32, b: i32) -> i32 { a + b }'); + const add = symbols.definitions.find((d) => d.name === 'add'); + expect(add).toBeDefined(); + expect(add.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'a', kind: 'parameter' }), + expect.objectContaining({ name: 'b', kind: 'parameter' }), + ]), + ); + }); + }); + + describe('property extraction', () => { + it('extracts struct fields as properties', () => { + const symbols = parseRust('struct User { name: String, age: u32 }'); + const user = symbols.definitions.find((d) => d.name === 'User'); + expect(user).toBeDefined(); + expect(user.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'name', kind: 'property' }), + expect.objectContaining({ name: 'age', kind: 'property' }), + ]), + ); + }); + }); + + describe('constant extraction', () => { + it('extracts const item declarations', () => { + const symbols = parseRust('const MAX: i32 = 100;'); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'MAX', kind: 'constant' }), + ); + }); + + it('extracts enum variants as constant children', () => { + const symbols = parseRust('enum Color { Red, Green, Blue }'); + const color = symbols.definitions.find((d) => d.name === 'Color'); + expect(color).toBeDefined(); + expect(color.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Red', kind: 'constant' }), + expect.objectContaining({ name: 'Green', kind: 'constant' }), + expect.objectContaining({ name: 'Blue', kind: 'constant' }), + ]), + ); + }); + }); +}); + +// ── Java ───────────────────────────────────────────────────────────────────── + +describe('Java extended kinds', () => { + let parsers; + + beforeAll(async () => { + parsers = await createParsers(); + }); + + function parseJava(code) { + const parser = parsers.get('java'); + if (!parser) throw new Error('Java parser not available'); + const tree = parser.parse(code); + return extractJavaSymbols(tree, 'Test.java'); + } + + describe('parameter extraction', () => { + it('extracts method parameters', () => { + const symbols = parseJava('class Foo { void bar(int x, String y) {} }'); + const bar = symbols.definitions.find((d) => d.name === 'Foo.bar'); + expect(bar).toBeDefined(); + expect(bar.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'x', kind: 'parameter' }), + expect.objectContaining({ name: 'y', kind: 'parameter' }), + ]), + ); + }); + }); + + describe('property extraction', () => { + it('extracts class field declarations as properties', () => { + const symbols = parseJava('class User { String name; int age; }'); + const user = symbols.definitions.find((d) => d.name === 'User'); + expect(user).toBeDefined(); + expect(user.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'name', kind: 'property' }), + expect.objectContaining({ name: 'age', kind: 'property' }), + ]), + ); + }); + }); + + describe('constant extraction', () => { + it('extracts enum constants as children', () => { + const symbols = parseJava('enum Status { ACTIVE, INACTIVE }'); + const status = symbols.definitions.find((d) => d.name === 'Status'); + expect(status).toBeDefined(); + expect(status.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'ACTIVE', kind: 'constant' }), + expect.objectContaining({ name: 'INACTIVE', kind: 'constant' }), + ]), + ); + }); + }); +}); + +// ── C# ────────────────────────────────────────────────────────────────────── + +describe('C# extended kinds', () => { + let parsers; + + beforeAll(async () => { + parsers = await createParsers(); + }); + + function parseCSharp(code) { + const parser = parsers.get('csharp'); + if (!parser) throw new Error('C# parser not available'); + const tree = parser.parse(code); + return extractCSharpSymbols(tree, 'Test.cs'); + } + + describe('parameter extraction', () => { + it('extracts method parameters', () => { + const symbols = parseCSharp('class Foo { void Bar(int x, string y) {} }'); + const bar = symbols.definitions.find((d) => d.name === 'Foo.Bar'); + expect(bar).toBeDefined(); + expect(bar.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'x', kind: 'parameter' }), + expect.objectContaining({ name: 'y', kind: 'parameter' }), + ]), + ); + }); + }); + + describe('property extraction', () => { + it('extracts class field declarations as properties', () => { + const symbols = parseCSharp('class User { string Name; int Age; }'); + const user = symbols.definitions.find((d) => d.name === 'User'); + expect(user).toBeDefined(); + expect(user.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Name', kind: 'property' }), + expect.objectContaining({ name: 'Age', kind: 'property' }), + ]), + ); + }); + }); + + describe('constant extraction', () => { + it('extracts enum member declarations as constants', () => { + const symbols = parseCSharp('enum Status { Active, Inactive }'); + const status = symbols.definitions.find((d) => d.name === 'Status'); + expect(status).toBeDefined(); + expect(status.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Active', kind: 'constant' }), + expect.objectContaining({ name: 'Inactive', kind: 'constant' }), + ]), + ); + }); + }); +}); + +// ── Ruby ───────────────────────────────────────────────────────────────────── + +describe('Ruby extended kinds', () => { + let parsers; + + beforeAll(async () => { + parsers = await createParsers(); + }); + + function parseRuby(code) { + const parser = parsers.get('ruby'); + if (!parser) throw new Error('Ruby parser not available'); + const tree = parser.parse(code); + return extractRubySymbols(tree, 'test.rb'); + } + + describe('parameter extraction', () => { + it('extracts method parameters', () => { + const symbols = parseRuby('def greet(name, age)\nend'); + const greet = symbols.definitions.find((d) => d.name === 'greet'); + expect(greet).toBeDefined(); + expect(greet.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'name', kind: 'parameter' }), + expect.objectContaining({ name: 'age', kind: 'parameter' }), + ]), + ); + }); + }); + + describe('property extraction', () => { + it('extracts instance variable assignments as properties', () => { + const symbols = parseRuby('class User\n @name = nil\nend'); + const user = symbols.definitions.find((d) => d.name === 'User'); + expect(user).toBeDefined(); + expect(user.children).toEqual( + expect.arrayContaining([expect.objectContaining({ name: '@name', kind: 'property' })]), + ); + }); + }); + + describe('constant extraction', () => { + it('extracts class-level constant assignments', () => { + const symbols = parseRuby('class Foo\n MAX = 100\nend'); + const foo = symbols.definitions.find((d) => d.name === 'Foo'); + expect(foo).toBeDefined(); + expect(foo.children).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'MAX', kind: 'constant' })]), + ); + }); + }); +}); + +// ── PHP ────────────────────────────────────────────────────────────────────── + +describe('PHP extended kinds', () => { + let parsers; + + beforeAll(async () => { + parsers = await createParsers(); + }); + + function parsePHP(code) { + const parser = parsers.get('php'); + if (!parser) throw new Error('PHP parser not available'); + const tree = parser.parse(code); + return extractPHPSymbols(tree, 'test.php'); + } + + describe('parameter extraction', () => { + it('extracts function parameters', () => { + const symbols = parsePHP(' d.name === 'greet'); + expect(greet).toBeDefined(); + expect(greet.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: '$name', kind: 'parameter' }), + expect.objectContaining({ name: '$age', kind: 'parameter' }), + ]), + ); + }); + }); + + describe('property extraction', () => { + it('extracts class property declarations', () => { + const symbols = parsePHP(' d.name === 'User'); + expect(user).toBeDefined(); + expect(user.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: '$name', kind: 'property' }), + expect.objectContaining({ name: '$age', kind: 'property' }), + ]), + ); + }); + }); + + describe('constant extraction', () => { + it('extracts enum case declarations as constants', () => { + const symbols = parsePHP(' d.name === 'Status'); + expect(status).toBeDefined(); + expect(status.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Active', kind: 'constant' }), + expect.objectContaining({ name: 'Inactive', kind: 'constant' }), + ]), + ); + }); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index fc610c4b..3b38f590 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -16,6 +16,7 @@ const ALL_TOOL_NAMES = [ 'module_map', 'fn_impact', 'context', + 'symbol_children', 'explain', 'where', 'diff_impact', @@ -249,6 +250,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(() => ({ name: 'test', results: [] })), fnImpactData: vi.fn(() => ({ name: 'test', results: [] })), contextData: vi.fn(() => ({ name: 'test', results: [] })), + childrenData: vi.fn(() => ({ name: 'test', results: [] })), explainData: vi.fn(() => ({ target: 'test', kind: 'function', results: [] })), whereData: vi.fn(() => ({ target: 'test', mode: 'symbol', results: [] })), diffImpactData: vi.fn(() => ({ changedFiles: 0, affectedFunctions: [] })), @@ -312,6 +314,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: fnDepsMock, fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -371,6 +374,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: fnImpactMock, contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -427,6 +431,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: diffImpactMock, @@ -486,6 +491,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -546,6 +552,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: fnDepsMock, fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -604,6 +611,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -656,6 +664,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -710,6 +719,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: fnDepsMock, fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -774,6 +784,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -831,6 +842,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -879,6 +891,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -927,6 +940,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -975,6 +989,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), @@ -1024,6 +1039,7 @@ describe('startMCPServer handler dispatch', () => { fnDepsData: vi.fn(), fnImpactData: vi.fn(), contextData: vi.fn(), + childrenData: vi.fn(), explainData: vi.fn(), whereData: vi.fn(), diffImpactData: vi.fn(), From 5cd7de38898b034376e26dba0a0289583a4e6fd9 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:03:10 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20add=20expanded=20edge=20types=20?= =?UTF-8?q?=E2=80=94=20contains,=20parameter=5Fof,=20receiver=20(Phase=202?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build file→definition and parent→child contains edges, parameter_of inverse edges, and receiver edges for method-call dispatch. Add CORE_EDGE_KINDS, STRUCTURAL_EDGE_KINDS, EVERY_EDGE_KIND constants. Exclude structural edges from moduleMapData coupling counts. Scope directory contains-edge cleanup to preserve symbol-level edges. Impact: 3 functions changed, 22 affected --- src/builder.js | 62 ++++++++++++++---- src/index.js | 3 + src/mcp.js | 4 +- src/queries.js | 24 ++++++- src/structure.js | 5 +- tests/integration/build-parity.test.js | 25 +++++++- tests/integration/queries.test.js | 87 +++++++++++++++++++++++++- 7 files changed, 187 insertions(+), 23 deletions(-) diff --git a/src/builder.js b/src/builder.js index 7a916647..79fd9d47 100644 --- a/src/builder.js +++ b/src/builder.js @@ -598,20 +598,32 @@ export async function buildGraph(rootDir, opts = {}) { fileSymbols.set(relPath, symbols); insertNode.run(relPath, 'file', relPath, 0, null, null); + const fileRow = getNodeId.get(relPath, 'file', relPath, 0); for (const def of symbols.definitions) { insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null, null); - if (def.children?.length) { - const parentRow = getNodeId.get(def.name, def.kind, relPath, def.line); - if (parentRow) { - for (const child of def.children) { - insertNode.run( - child.name, - child.kind, - relPath, - child.line, - child.endLine || null, - parentRow.id, - ); + const defRow = getNodeId.get(def.name, def.kind, relPath, def.line); + // File → top-level definition contains edge + if (fileRow && defRow) { + insertEdge.run(fileRow.id, defRow.id, 'contains', 1.0, 0); + } + if (def.children?.length && defRow) { + for (const child of def.children) { + insertNode.run( + child.name, + child.kind, + relPath, + child.line, + child.endLine || null, + defRow.id, + ); + // Parent → child contains edge + const childRow = getNodeId.get(child.name, child.kind, relPath, child.line); + if (childRow) { + insertEdge.run(defRow.id, childRow.id, 'contains', 1.0, 0); + // Parameter → parent parameter_of edge (inverse direction) + if (child.kind === 'parameter') { + insertEdge.run(childRow.id, defRow.id, 'parameter_of', 1.0, 0); + } } } } @@ -797,7 +809,7 @@ export async function buildGraph(rootDir, opts = {}) { // N+1 optimization: pre-load all nodes into a lookup map for edge building const allNodes = db .prepare( - `SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface')`, + `SELECT id, name, kind, file FROM nodes WHERE kind IN ('function','method','class','interface','struct','type','module','enum','trait')`, ) .all(); const nodesByName = new Map(); @@ -956,6 +968,30 @@ export async function buildGraph(rootDir, opts = {}) { edgeCount++; } } + + // Receiver edge: caller → receiver type node + if ( + call.receiver && + !BUILTIN_RECEIVERS.has(call.receiver) && + call.receiver !== 'this' && + call.receiver !== 'self' && + call.receiver !== 'super' + ) { + const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']); + // Same-file first, then global + const samefile = nodesByNameAndFile.get(`${call.receiver}|${relPath}`) || []; + const candidates = samefile.length > 0 ? samefile : nodesByName.get(call.receiver) || []; + const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind)); + if (receiverNodes.length > 0 && caller) { + const recvTarget = receiverNodes[0]; + const recvKey = `recv|${caller.id}|${recvTarget.id}`; + if (!seenCallEdges.has(recvKey)) { + seenCallEdges.add(recvKey); + insertEdge.run(caller.id, recvTarget.id, 'receiver', 0.7, 0); + edgeCount++; + } + } + } } // Class extends edges (use pre-loaded maps instead of inline DB queries) diff --git a/src/index.js b/src/index.js index 973d2475..6774d54b 100644 --- a/src/index.js +++ b/src/index.js @@ -107,11 +107,13 @@ export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js'; // Query functions (data-returning) export { ALL_SYMBOL_KINDS, + CORE_EDGE_KINDS, CORE_SYMBOL_KINDS, childrenData, contextData, diffImpactData, diffImpactMermaid, + EVERY_EDGE_KIND, EVERY_SYMBOL_KIND, EXTENDED_SYMBOL_KINDS, explainData, @@ -130,6 +132,7 @@ export { pathData, queryNameData, rolesData, + STRUCTURAL_EDGE_KINDS, statsData, VALID_ROLES, whereData, diff --git a/src/mcp.js b/src/mcp.js index d02cdf29..cd0b8808 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -9,7 +9,7 @@ import { createRequire } from 'node:module'; import { findCycles } from './cycles.js'; import { findDbPath } from './db.js'; import { MCP_DEFAULTS, MCP_MAX_LIMIT } from './paginate.js'; -import { diffImpactMermaid, EVERY_SYMBOL_KIND, VALID_ROLES } from './queries.js'; +import { diffImpactMermaid, EVERY_EDGE_KIND, EVERY_SYMBOL_KIND, VALID_ROLES } from './queries.js'; const REPO_PROP = { repo: { @@ -53,7 +53,7 @@ const BASE_TOOLS = [ to: { type: 'string', description: 'Target symbol for path mode (required in path mode)' }, edge_kinds: { type: 'array', - items: { type: 'string' }, + items: { type: 'string', enum: EVERY_EDGE_KIND }, description: 'Edge kinds to follow in path mode (default: ["calls"])', }, reverse: { diff --git a/src/queries.js b/src/queries.js index 636cfae9..3637629a 100644 --- a/src/queries.js +++ b/src/queries.js @@ -89,6 +89,24 @@ export const EVERY_SYMBOL_KIND = [...CORE_SYMBOL_KINDS, ...EXTENDED_SYMBOL_KINDS // Backward compat: ALL_SYMBOL_KINDS stays as the core 10 export const ALL_SYMBOL_KINDS = CORE_SYMBOL_KINDS; +// ── Edge kind constants ───────────────────────────────────────────── +// Core edge kinds — coupling and dependency relationships +export const CORE_EDGE_KINDS = [ + 'imports', + 'imports-type', + 'reexports', + 'calls', + 'extends', + 'implements', + 'contains', +]; + +// Structural edge kinds — parent/child and type relationships +export const STRUCTURAL_EDGE_KINDS = ['parameter_of', 'receiver']; + +// Full set for MCP enum and validation +export const EVERY_EDGE_KIND = [...CORE_EDGE_KINDS, ...STRUCTURAL_EDGE_KINDS]; + export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'leaf']; /** @@ -348,12 +366,12 @@ export function moduleMapData(customDbPath, limit = 20, opts = {}) { const nodes = db .prepare(` SELECT n.*, - (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind != 'contains') as out_edges, - (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') as in_edges + (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges, + (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges FROM nodes n WHERE n.kind = 'file' ${testFilter} - ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') DESC + ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC LIMIT ? `) .all(limit); diff --git a/src/structure.js b/src/structure.js index a4c28f41..6169795d 100644 --- a/src/structure.js +++ b/src/structure.js @@ -34,8 +34,11 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director `); // Clean previous directory nodes/edges (idempotent rebuild) + // Scope contains-edge delete to directory-sourced edges only, + // preserving symbol-level contains edges (file→def, class→method, etc.) db.exec(` - DELETE FROM edges WHERE kind = 'contains'; + DELETE FROM edges WHERE kind = 'contains' + AND source_id IN (SELECT id FROM nodes WHERE kind = 'directory'); DELETE FROM node_metrics; DELETE FROM nodes WHERE kind = 'directory'; `); diff --git a/tests/integration/build-parity.test.js b/tests/integration/build-parity.test.js index 5651a61b..7811f6df 100644 --- a/tests/integration/build-parity.test.js +++ b/tests/integration/build-parity.test.js @@ -87,8 +87,27 @@ describeOrSkip('Build parity: native vs WASM', () => { }); it('produces identical edges', () => { - const wasmGraph = readGraph(path.join(wasmDir, '.codegraph', 'graph.db')); - const nativeGraph = readGraph(path.join(nativeDir, '.codegraph', 'graph.db')); - expect(nativeGraph.edges).toEqual(wasmGraph.edges); + // Filter out edges involving extended-kind nodes (parameter, property, constant) + // — WASM extracts children but native engine defers child extraction for now. + function readCoreEdges(dbPath) { + const db = new Database(dbPath, { readonly: true }); + const edges = db + .prepare(` + SELECT n1.name AS source_name, n2.name AS target_name, e.kind + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.kind NOT IN ('parameter', 'property', 'constant') + AND n2.kind NOT IN ('parameter', 'property', 'constant') + ORDER BY n1.name, n2.name, e.kind + `) + .all(); + db.close(); + return edges; + } + + const wasmEdges = readCoreEdges(path.join(wasmDir, '.codegraph', 'graph.db')); + const nativeEdges = readCoreEdges(path.join(nativeDir, '.codegraph', 'graph.db')); + expect(nativeEdges).toEqual(wasmEdges); }); }); diff --git a/tests/integration/queries.test.js b/tests/integration/queries.test.js index 0bb3b7dc..af288060 100644 --- a/tests/integration/queries.test.js +++ b/tests/integration/queries.test.js @@ -103,6 +103,24 @@ beforeAll(() => { // Low-confidence call edge for quality tests insertEdge(db, formatResponse, validateToken, 'calls', 0.3); + // ── Phase 2: expanded node/edge types ────────────────────────────── + // Class with method and property children + const userService = insertNode(db, 'UserService', 'class', 'auth.js', 40); + const getUser = insertNode(db, 'UserService.getUser', 'method', 'auth.js', 42); + const dbConn = insertNode(db, 'dbConn', 'property', 'auth.js', 41); + const userId = insertNode(db, 'userId', 'parameter', 'auth.js', 10); + + // Symbol-level contains edges (file → class, class → method/property) + insertEdge(db, fAuth, userService, 'contains'); + insertEdge(db, userService, getUser, 'contains'); + insertEdge(db, userService, dbConn, 'contains'); + + // parameter_of edge (parameter → owning function) + insertEdge(db, userId, authenticate, 'parameter_of'); + + // receiver edge (caller → receiver type) + insertEdge(db, handleRoute, userService, 'receiver', 0.7); + // File hashes (for fileHash exposure) for (const f of ['auth.js', 'middleware.js', 'routes.js', 'utils.js', 'auth.test.js']) { db.prepare('INSERT INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)').run( @@ -448,7 +466,7 @@ describe('explainData', () => { const r = data.results[0]; expect(r.file).toBe('auth.js'); - expect(r.symbolCount).toBe(2); + expect(r.symbolCount).toBe(6); // Both authenticate and validateToken are called from middleware.js expect(r.publicApi.map((s) => s.name)).toContain('authenticate'); expect(r.publicApi.map((s) => s.name)).toContain('validateToken'); @@ -661,6 +679,73 @@ describe('noTests filtering', () => { }); }); +// ─── Expanded edge types (Phase 2) ───────────────────────────────────── + +describe('expanded edge types', () => { + test('statsData counts new edge kinds', () => { + const data = statsData(dbPath); + expect(data.edges.byKind.contains).toBeGreaterThanOrEqual(3); + expect(data.edges.byKind.parameter_of).toBeGreaterThanOrEqual(1); + expect(data.edges.byKind.receiver).toBeGreaterThanOrEqual(1); + }); + + test('moduleMapData excludes structural edges from coupling', () => { + const data = moduleMapData(dbPath); + // auth.js has contains, parameter_of, receiver edges but they should + // not inflate coupling counts — only imports/calls/etc. count + const authNode = data.topNodes.find((n) => n.file === 'auth.js'); + expect(authNode).toBeDefined(); + // in_edges should not include contains/parameter_of/receiver + // auth.js is imported by middleware.js and auth.test.js → in_edges = 2 + expect(authNode.inEdges).toBe(2); + }); + + test('queryNameData returns new edge kinds in callers/callees', () => { + // authenticate has a parameter_of edge from userId + const authData = queryNameData('authenticate', dbPath); + const fn = authData.results.find((r) => r.kind === 'function' && r.name === 'authenticate'); + expect(fn).toBeDefined(); + const paramCaller = fn.callers.find((c) => c.edgeKind === 'parameter_of'); + expect(paramCaller).toBeDefined(); + expect(paramCaller.name).toBe('userId'); + + // UserService has contains callees (method and property) + const usData = queryNameData('UserService', dbPath); + const cls = usData.results.find((r) => r.kind === 'class' && r.name === 'UserService'); + expect(cls).toBeDefined(); + const containsCallees = cls.callees.filter((c) => c.edgeKind === 'contains'); + expect(containsCallees.length).toBeGreaterThanOrEqual(2); + const names = containsCallees.map((c) => c.name); + expect(names).toContain('UserService.getUser'); + expect(names).toContain('dbConn'); + + // UserService has a receiver caller (handleRoute) + const receiverCaller = cls.callers.find((c) => c.edgeKind === 'receiver'); + expect(receiverCaller).toBeDefined(); + expect(receiverCaller.name).toBe('handleRoute'); + }); + + test('pathData traverses contains edges', () => { + const data = pathData('UserService', 'UserService.getUser', dbPath, { + edgeKinds: ['contains'], + }); + expect(data.found).toBe(true); + expect(data.hops).toBe(1); + expect(data.path[0].name).toBe('UserService'); + expect(data.path[1].name).toBe('UserService.getUser'); + expect(data.path[1].edgeKind).toBe('contains'); + }); + + test('pathData traverses receiver edges', () => { + const data = pathData('handleRoute', 'UserService', dbPath, { + edgeKinds: ['receiver'], + }); + expect(data.found).toBe(true); + expect(data.hops).toBe(1); + expect(data.path[1].edgeKind).toBe('receiver'); + }); +}); + // ─── Stable symbol schema conformance ────────────────────────────────── const STABLE_FIELDS = ['name', 'kind', 'file', 'line', 'endLine', 'role', 'fileHash']; From 4b0fd8ac9ee0d131776ffeb9399401eaa1c8953d Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:23:08 -0700 Subject: [PATCH 3/4] fix(native): add missing children field to all Rust extractors The Definition struct gained a children field but no extractor was updated to include it, causing 50 compilation errors. Add children: None to every Definition initializer across all 9 language extractors. Also fix unused variable warnings in parser_registry.rs and parallel.rs. Impact: 13 functions changed, 10 affected --- crates/codegraph-core/src/extractors/csharp.rs | 9 +++++++++ crates/codegraph-core/src/extractors/go.rs | 6 ++++++ crates/codegraph-core/src/extractors/hcl.rs | 1 + crates/codegraph-core/src/extractors/java.rs | 6 ++++++ crates/codegraph-core/src/extractors/javascript.rs | 10 ++++++++++ crates/codegraph-core/src/extractors/php.rs | 7 +++++++ crates/codegraph-core/src/extractors/python.rs | 2 ++ crates/codegraph-core/src/extractors/ruby.rs | 4 ++++ crates/codegraph-core/src/extractors/rust_lang.rs | 5 +++++ crates/codegraph-core/src/parallel.rs | 2 +- crates/codegraph-core/src/parser_registry.rs | 2 +- 11 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-core/src/extractors/csharp.rs b/crates/codegraph-core/src/extractors/csharp.rs index c92b6b6f..9b8ac071 100644 --- a/crates/codegraph-core/src/extractors/csharp.rs +++ b/crates/codegraph-core/src/extractors/csharp.rs @@ -43,6 +43,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); extract_csharp_base_types(node, &class_name, source, symbols); } @@ -58,6 +59,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); extract_csharp_base_types(node, &name, source, symbols); } @@ -73,6 +75,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); extract_csharp_base_types(node, &name, source, symbols); } @@ -88,6 +91,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); if let Some(body) = node.child_by_field_name("body") { for i in 0..body.child_count() { @@ -105,6 +109,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(&child)), decorators: None, complexity: compute_all_metrics(&child, source, "c_sharp"), + children: None, }); } } @@ -123,6 +128,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); } } @@ -142,6 +148,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "c_sharp"), + children: None, }); } } @@ -161,6 +168,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "c_sharp"), + children: None, }); } } @@ -180,6 +188,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "c_sharp"), + children: None, }); } } diff --git a/crates/codegraph-core/src/extractors/go.rs b/crates/codegraph-core/src/extractors/go.rs index 8d429e87..fee7abc8 100644 --- a/crates/codegraph-core/src/extractors/go.rs +++ b/crates/codegraph-core/src/extractors/go.rs @@ -25,6 +25,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "go"), + children: None, }); } } @@ -61,6 +62,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "go"), + children: None, }); } } @@ -84,6 +86,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); } "interface_type" => { @@ -94,6 +97,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); // Extract interface methods for j in 0..type_node.child_count() { @@ -113,6 +117,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(&member)), decorators: None, complexity: None, + children: None, }); } } @@ -127,6 +132,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); } } diff --git a/crates/codegraph-core/src/extractors/hcl.rs b/crates/codegraph-core/src/extractors/hcl.rs index 1cbb539d..ab516418 100644 --- a/crates/codegraph-core/src/extractors/hcl.rs +++ b/crates/codegraph-core/src/extractors/hcl.rs @@ -67,6 +67,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); // Module source imports diff --git a/crates/codegraph-core/src/extractors/java.rs b/crates/codegraph-core/src/extractors/java.rs index 829eb6f6..b6161da0 100644 --- a/crates/codegraph-core/src/extractors/java.rs +++ b/crates/codegraph-core/src/extractors/java.rs @@ -42,6 +42,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); // Superclass @@ -94,6 +95,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); if let Some(body) = node.child_by_field_name("body") { for i in 0..body.child_count() { @@ -111,6 +113,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(&child)), decorators: None, complexity: compute_all_metrics(&child, source, "java"), + children: None, }); } } @@ -129,6 +132,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); } } @@ -148,6 +152,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "java"), + children: None, }); } } @@ -167,6 +172,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "java"), + children: None, }); } } diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index f6451fe2..30cf6bc6 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -25,6 +25,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "javascript"), + children: None, }); } } @@ -39,6 +40,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); // Heritage: extends + implements @@ -81,6 +83,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: compute_all_metrics(node, source, "javascript"), + children: None, }); } } @@ -95,6 +98,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); // Extract interface methods let body = node @@ -116,6 +120,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(node)), decorators: None, complexity: None, + children: None, }); } } @@ -139,6 +144,7 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { end_line: Some(end_line(&value_n)), decorators: None, complexity: compute_all_metrics(&value_n, source, "javascript"), + children: None, }); } } @@ -348,6 +354,7 @@ fn extract_interface_methods( end_line: Some(end_line(&child)), decorators: None, complexity: None, + children: None, }); } } @@ -563,6 +570,7 @@ fn extract_callback_definition(call_node: &Node, source: &[u8]) -> Option Option Option Vec { +pub fn parse_files_parallel(file_paths: &[String], _root_dir: &str) -> Vec { file_paths .par_iter() .filter_map(|file_path| { diff --git a/crates/codegraph-core/src/parser_registry.rs b/crates/codegraph-core/src/parser_registry.rs index 0fdc766f..2c2c7e9e 100644 --- a/crates/codegraph-core/src/parser_registry.rs +++ b/crates/codegraph-core/src/parser_registry.rs @@ -21,7 +21,7 @@ impl LanguageKind { pub fn from_extension(file_path: &str) -> Option { let path = Path::new(file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let _name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); // .tsx must come before .ts check if file_path.ends_with(".tsx") { From b5ff3685ae6ec7b6464d34353152066885aae751 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:27:09 -0700 Subject: [PATCH 4/4] ci: trigger workflow re-run