From ceaf7361f06ebff8d3746413ba74dd01fd47e9e6 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:05:51 -0600 Subject: [PATCH 1/4] refactor: adopt dead helpers across codebase Wire up extracted helpers from Titan runs that existed but were never consumed, reducing boilerplate and improving error specificity. - Adopt named_child_text across 27 sites in 11 Rust extractors - Migrate cpp.rs from hand-rolled find_cpp_parent_class to find_enclosing_type_name - Add toSymbolRef helper in shared/normalize.ts, adopt at 15 mapping sites - Wire ParseError in parser.ts for structured PARSE_FAILED error codes - Wire BoundaryError in boundaries.ts to distinguish config/DB failures from clean results - Add --modules/--threshold flags to codegraph structure command - Wire batchQuery in CLI batch command, removing duplicated routing logic - Route detect-changes pending analysis through unified runAnalyses engine --- crates/codegraph-core/src/extractors/c.rs | 8 ++--- crates/codegraph-core/src/extractors/cpp.rs | 25 +++------------ .../codegraph-core/src/extractors/csharp.rs | 4 +-- crates/codegraph-core/src/extractors/go.rs | 4 +-- .../codegraph-core/src/extractors/helpers.rs | 13 ++++---- crates/codegraph-core/src/extractors/java.rs | 4 +-- .../src/extractors/javascript.rs | 14 ++++----- crates/codegraph-core/src/extractors/php.rs | 8 ++--- .../codegraph-core/src/extractors/python.rs | 17 +++++----- crates/codegraph-core/src/extractors/ruby.rs | 4 +-- .../src/extractors/rust_lang.rs | 21 ++++++------- src/cli/commands/batch.ts | 31 +++---------------- src/cli/commands/structure.ts | 16 +++++++++- src/domain/analysis/context.ts | 20 +++--------- src/domain/analysis/dependencies.ts | 21 +++---------- src/domain/analysis/fn-impact.ts | 4 +-- src/domain/analysis/implementations.ts | 16 ++-------- .../graph/builder/stages/detect-changes.ts | 16 +++++----- src/domain/parser.ts | 8 +++-- src/domain/queries.ts | 2 +- src/features/audit.ts | 5 +-- src/features/boundaries.ts | 8 ++--- src/features/branch-compare.ts | 5 ++- src/features/flow.ts | 3 +- src/features/manifesto.ts | 16 +++++++++- src/presentation/structure.ts | 4 +-- src/shared/normalize.ts | 10 ++++++ tests/unit/boundaries.test.ts | 7 +++-- 28 files changed, 142 insertions(+), 172 deletions(-) diff --git a/crates/codegraph-core/src/extractors/c.rs b/crates/codegraph-core/src/extractors/c.rs index 1dde22be..cd864a05 100644 --- a/crates/codegraph-core/src/extractors/c.rs +++ b/crates/codegraph-core/src/extractors/c.rs @@ -297,11 +297,11 @@ fn match_c_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: u }); } "field_expression" => { - let name = fn_node.child_by_field_name("field") - .map(|n| node_text(&n, source).to_string()) + let name = named_child_text(&fn_node, "field", source) + .map(|s| s.to_string()) .unwrap_or_else(|| node_text(&fn_node, source).to_string()); - let receiver = fn_node.child_by_field_name("argument") - .map(|n| node_text(&n, source).to_string()); + let receiver = named_child_text(&fn_node, "argument", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name, line: start_line(node), diff --git a/crates/codegraph-core/src/extractors/cpp.rs b/crates/codegraph-core/src/extractors/cpp.rs index d9e97266..ea069146 100644 --- a/crates/codegraph-core/src/extractors/cpp.rs +++ b/crates/codegraph-core/src/extractors/cpp.rs @@ -171,21 +171,6 @@ fn extract_cpp_enum_constants(node: &Node, source: &[u8]) -> Vec { constants } -fn find_cpp_parent_class<'a>(node: &Node<'a>, source: &[u8]) -> Option { - let mut current = node.parent(); - while let Some(parent) = current { - match parent.kind() { - "class_specifier" | "struct_specifier" => { - return parent.child_by_field_name("name") - .map(|n| node_text(&n, source).to_string()); - } - _ => {} - } - current = parent.parent(); - } - None -} - fn extract_cpp_base_classes(node: &Node, source: &[u8], class_name: &str, symbols: &mut FileSymbols) { for i in 0..node.child_count() { if let Some(child) = node.child(i) { @@ -214,7 +199,7 @@ fn extract_cpp_base_classes(node: &Node, source: &[u8], class_name: &str, symbol fn handle_cpp_function_definition(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(name) = extract_cpp_function_name(node, source) { - let parent_class = find_cpp_parent_class(node, source); + let parent_class = find_enclosing_type_name(node, &["class_specifier", "struct_specifier"], source); let full_name = match &parent_class { Some(cls) => format!("{}.{}", cls, name), None => name, @@ -360,11 +345,11 @@ fn handle_cpp_call_expression(node: &Node, source: &[u8], symbols: &mut FileSymb }); } "field_expression" => { - let name = fn_node.child_by_field_name("field") - .map(|n| node_text(&n, source).to_string()) + let name = named_child_text(&fn_node, "field", source) + .map(|s| s.to_string()) .unwrap_or_else(|| node_text(&fn_node, source).to_string()); - let receiver = fn_node.child_by_field_name("argument") - .map(|n| node_text(&n, source).to_string()); + let receiver = named_child_text(&fn_node, "argument", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name, line: start_line(node), diff --git a/crates/codegraph-core/src/extractors/csharp.rs b/crates/codegraph-core/src/extractors/csharp.rs index 6118dfc0..53245371 100644 --- a/crates/codegraph-core/src/extractors/csharp.rs +++ b/crates/codegraph-core/src/extractors/csharp.rs @@ -221,8 +221,8 @@ fn handle_invocation_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) } "member_access_expression" => { if let Some(name) = fn_node.child_by_field_name("name") { - let receiver = fn_node.child_by_field_name("expression") - .map(|expr| node_text(&expr, source).to_string()); + let receiver = named_child_text(&fn_node, "expression", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name: node_text(&name, source).to_string(), line: start_line(node), diff --git a/crates/codegraph-core/src/extractors/go.rs b/crates/codegraph-core/src/extractors/go.rs index efd5d1bf..0d9d30b0 100644 --- a/crates/codegraph-core/src/extractors/go.rs +++ b/crates/codegraph-core/src/extractors/go.rs @@ -207,8 +207,8 @@ fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } "selector_expression" => { if let Some(field) = fn_node.child_by_field_name("field") { - let receiver = fn_node.child_by_field_name("operand") - .map(|op| node_text(&op, source).to_string()); + let receiver = named_child_text(&fn_node, "operand", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name: node_text(&field, source).to_string(), line: start_line(node), diff --git a/crates/codegraph-core/src/extractors/helpers.rs b/crates/codegraph-core/src/extractors/helpers.rs index 77ac8220..b0253189 100644 --- a/crates/codegraph-core/src/extractors/helpers.rs +++ b/crates/codegraph-core/src/extractors/helpers.rs @@ -75,9 +75,8 @@ pub fn find_enclosing_type_name(node: &Node, kinds: &[&str], source: &[u8]) -> O let mut current = node.parent(); while let Some(parent) = current { if kinds.contains(&parent.kind()) { - return parent - .child_by_field_name("name") - .map(|n| node_text(&n, source).to_string()); + return named_child_text(&parent, "name", source) + .map(|s| s.to_string()); } current = parent.parent(); } @@ -541,8 +540,8 @@ fn walk_ast_nodes_with_config_depth( fn extract_constructor_name(node: &Node, source: &[u8]) -> String { // Try common field names for the constructed type for field in &["type", "class", "constructor"] { - if let Some(child) = node.child_by_field_name(field) { - return node_text(&child, source).to_string(); + if let Some(text) = named_child_text(node, field, source) { + return text.to_string(); } } for i in 0..node.child_count() { @@ -599,8 +598,8 @@ fn extract_awaited_name(node: &Node, source: &[u8]) -> String { /// Extract function name from a call node. fn extract_call_name(node: &Node, source: &[u8]) -> String { for field in &["function", "method", "name"] { - if let Some(fn_node) = node.child_by_field_name(field) { - return node_text(&fn_node, source).to_string(); + if let Some(text) = named_child_text(node, field, source) { + return text.to_string(); } } let text = node_text(node, source); diff --git a/crates/codegraph-core/src/extractors/java.rs b/crates/codegraph-core/src/extractors/java.rs index 8739539a..0f7af903 100644 --- a/crates/codegraph-core/src/extractors/java.rs +++ b/crates/codegraph-core/src/extractors/java.rs @@ -264,8 +264,8 @@ fn handle_import_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { fn handle_method_invocation(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(name_node) = node.child_by_field_name("name") { - let receiver = node.child_by_field_name("object") - .map(|obj| node_text(&obj, source).to_string()); + let receiver = named_child_text(node, "object", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name: node_text(&name_node, source).to_string(), line: start_line(node), diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 724b52d7..45ae52df 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -46,7 +46,7 @@ fn extract_new_expr_type_name<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<& match ctor.kind() { "identifier" => Some(node_text(&ctor, source)), "member_expression" => { - ctor.child_by_field_name("property").map(|p| node_text(&p, source)) + named_child_text(&ctor, "property", source) } _ => None, } @@ -905,8 +905,8 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option< if prop.kind() == "string" || prop.kind() == "string_fragment" { let method_name = node_text(&prop, source).replace(&['\'', '"'][..], ""); if !method_name.is_empty() { - let receiver = fn_node.child_by_field_name("object") - .map(|obj| node_text(&obj, source).to_string()); + let receiver = named_child_text(&fn_node, "object", source) + .map(|s| s.to_string()); return Some(Call { name: method_name, line: start_line(call_node), @@ -916,8 +916,8 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option< } } - let receiver = fn_node.child_by_field_name("object") - .map(|obj| node_text(&obj, source).to_string()); + let receiver = named_child_text(&fn_node, "object", source) + .map(|s| s.to_string()); Some(Call { name: prop_text.to_string(), line: start_line(call_node), @@ -932,8 +932,8 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option< let method_name = node_text(&index, source) .replace(&['\'', '"', '`'][..], ""); if !method_name.is_empty() && !method_name.contains('$') { - let receiver = fn_node.child_by_field_name("object") - .map(|obj| node_text(&obj, source).to_string()); + let receiver = named_child_text(&fn_node, "object", source) + .map(|s| s.to_string()); return Some(Call { name: method_name, line: start_line(call_node), diff --git a/crates/codegraph-core/src/extractors/php.rs b/crates/codegraph-core/src/extractors/php.rs index 6e111107..00806987 100644 --- a/crates/codegraph-core/src/extractors/php.rs +++ b/crates/codegraph-core/src/extractors/php.rs @@ -255,8 +255,8 @@ fn handle_function_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { fn handle_member_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(name) = node.child_by_field_name("name") { - let receiver = node.child_by_field_name("object") - .map(|obj| node_text(&obj, source).to_string()); + let receiver = named_child_text(node, "object", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name: node_text(&name, source).to_string(), line: start_line(node), @@ -268,8 +268,8 @@ fn handle_member_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { fn handle_scoped_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(name) = node.child_by_field_name("name") { - let receiver = node.child_by_field_name("scope") - .map(|s| node_text(&s, source).to_string()); + let receiver = named_child_text(node, "scope", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name: node_text(&name, source).to_string(), line: start_line(node), diff --git a/crates/codegraph-core/src/extractors/python.rs b/crates/codegraph-core/src/extractors/python.rs index b59a4a89..108fb099 100644 --- a/crates/codegraph-core/src/extractors/python.rs +++ b/crates/codegraph-core/src/extractors/python.rs @@ -116,11 +116,10 @@ fn handle_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let (call_name, receiver) = match fn_node.kind() { "identifier" => (Some(node_text(&fn_node, source).to_string()), None), "attribute" => { - let name = fn_node - .child_by_field_name("attribute") - .map(|a| node_text(&a, source).to_string()); - let recv = fn_node.child_by_field_name("object") - .map(|obj| node_text(&obj, source).to_string()); + let name = named_child_text(&fn_node, "attribute", source) + .map(|s| s.to_string()); + let recv = named_child_text(&fn_node, "object", source) + .map(|s| s.to_string()); (name, recv) } _ => (None, None), @@ -207,8 +206,8 @@ fn extract_python_parameters(node: &Node, source: &[u8], is_method: bool) -> Vec Some(text.to_string()) } "default_parameter" | "typed_default_parameter" => { - child.child_by_field_name("name") - .map(|n| node_text(&n, source).to_string()) + named_child_text(&child, "name", source) + .map(|s| s.to_string()) } "typed_parameter" => { // typed_parameter: first child is the identifier @@ -312,9 +311,7 @@ fn extract_python_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Optio "identifier" | "attribute" => Some(node_text(type_node, source)), "subscript" => { // List[int] → List - type_node - .child_by_field_name("value") - .map(|n| node_text(&n, source)) + named_child_text(type_node, "value", source) } _ => None, } diff --git a/crates/codegraph-core/src/extractors/ruby.rs b/crates/codegraph-core/src/extractors/ruby.rs index 6402a381..8f7506f7 100644 --- a/crates/codegraph-core/src/extractors/ruby.rs +++ b/crates/codegraph-core/src/extractors/ruby.rs @@ -119,8 +119,8 @@ fn handle_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } else if method_text == "include" || method_text == "extend" || method_text == "prepend" { handle_mixin_call(node, source, symbols); } else { - let receiver = node.child_by_field_name("receiver") - .map(|r| node_text(&r, source).to_string()); + let receiver = named_child_text(node, "receiver", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name: method_text.to_string(), line: start_line(node), diff --git a/crates/codegraph-core/src/extractors/rust_lang.rs b/crates/codegraph-core/src/extractors/rust_lang.rs index ff4d7324..a8397a4f 100644 --- a/crates/codegraph-core/src/extractors/rust_lang.rs +++ b/crates/codegraph-core/src/extractors/rust_lang.rs @@ -21,9 +21,8 @@ fn find_current_impl<'a>(node: &Node<'a>, source: &[u8]) -> Option { let mut current = node.parent(); while let Some(parent) = current { if parent.kind() == "impl_item" { - return parent - .child_by_field_name("type") - .map(|n| node_text(&n, source).to_string()); + return named_child_text(&parent, "type", source) + .map(|s| s.to_string()); } current = parent.parent(); } @@ -194,8 +193,8 @@ fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } "field_expression" => { if let Some(field) = fn_node.child_by_field_name("field") { - let receiver = fn_node.child_by_field_name("value") - .map(|v| node_text(&v, source).to_string()); + let receiver = named_child_text(&fn_node, "value", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name: node_text(&field, source).to_string(), line: start_line(node), @@ -206,8 +205,8 @@ fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } "scoped_identifier" => { if let Some(name) = fn_node.child_by_field_name("name") { - let receiver = fn_node.child_by_field_name("path") - .map(|p| node_text(&p, source).to_string()); + let receiver = named_child_text(&fn_node, "path", source) + .map(|s| s.to_string()); symbols.calls.push(Call { name: node_text(&name, source).to_string(), line: start_line(node), @@ -323,8 +322,8 @@ fn extract_rust_use_path(node: &Node, source: &[u8]) -> Vec<(String, Vec vec![(node_text(node, source).to_string(), name.into_iter().collect())] } "use_wildcard" => { - let src = node.child_by_field_name("path") - .map(|p| node_text(&p, source).to_string()) + let src = named_child_text(&node, "path", source) + .map(|s| s.to_string()) .unwrap_or_else(|| "*".to_string()); vec![(src, vec!["*".to_string()])] } @@ -338,8 +337,8 @@ fn extract_rust_use_path(node: &Node, source: &[u8]) -> Vec<(String, Vec } fn extract_scoped_use_list(node: &Node, source: &[u8]) -> Vec<(String, Vec)> { - let prefix = node.child_by_field_name("path") - .map(|p| node_text(&p, source).to_string()) + let prefix = named_child_text(&node, "path", source) + .map(|s| s.to_string()) .unwrap_or_default(); let Some(list_node) = node.child_by_field_name("list") else { return vec![(prefix, vec![])]; diff --git a/src/cli/commands/batch.ts b/src/cli/commands/batch.ts index 3ba6e1ef..c77324a3 100644 --- a/src/cli/commands/batch.ts +++ b/src/cli/commands/batch.ts @@ -1,26 +1,11 @@ import fs from 'node:fs'; import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; -import { BATCH_COMMANDS, multiBatchData, splitTargets } from '../../features/batch.js'; -import { batch } from '../../presentation/batch.js'; +import { BATCH_COMMANDS, splitTargets } from '../../features/batch.js'; +import { batchQuery } from '../../presentation/batch.js'; import { ConfigError, toErrorMessage } from '../../shared/errors.js'; import type { CommandDefinition } from '../types.js'; -interface MultiBatchItem { - command: string; - target: string; - opts?: Record; -} - -function isMultiBatch(targets: unknown[]): targets is MultiBatchItem[] { - return ( - targets.length > 0 && - typeof targets[0] === 'object' && - targets[0] !== null && - 'command' in targets[0] - ); -} - export const command: CommandDefinition = { name: 'batch [targets...]', description: `Run a query against multiple targets in one call. Output is always JSON.\nValid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`, @@ -69,18 +54,12 @@ export const command: CommandDefinition = { ); } - const batchOpts = { + batchQuery(targets as Array, opts.db, { + command, depth: opts.depth ? parseInt(opts.depth as string, 10) : undefined, file: opts.file, kind: opts.kind, noTests: ctx.resolveNoTests(opts), - }; - - if (isMultiBatch(targets)) { - const data = multiBatchData(targets as MultiBatchItem[], opts.db, batchOpts); - console.log(JSON.stringify(data, null, 2)); - } else { - batch(command!, targets as string[], opts.db, batchOpts); - } + }); }, }; diff --git a/src/cli/commands/structure.ts b/src/cli/commands/structure.ts index 795ab70f..c17ff99d 100644 --- a/src/cli/commands/structure.ts +++ b/src/cli/commands/structure.ts @@ -15,9 +15,23 @@ export const command: CommandDefinition = { ['--limit ', 'Max results to return'], ['--offset ', 'Skip N results (default: 0)'], ['--ndjson', 'Newline-delimited JSON output'], + ['--modules', 'Show module boundaries (directories with high cohesion)'], + ['--threshold ', 'Cohesion threshold for --modules (default: 0.3)'], ], async execute([dir], opts, ctx) { - const { structureData, formatStructure } = await import('../../presentation/structure.js'); + const { structureData, formatStructure, moduleBoundariesData, formatModuleBoundaries } = + await import('../../presentation/structure.js'); + + if (opts.modules) { + const data = moduleBoundariesData(opts.db, { + threshold: opts.threshold ? parseFloat(opts.threshold as string) : undefined, + }); + if (!ctx.outputResult(data, 'modules', opts)) { + console.log(formatModuleBoundaries(data)); + } + return; + } + const qOpts = ctx.resolveQueryOpts(opts); const data = structureData(opts.db, { directory: dir, diff --git a/src/domain/analysis/context.ts b/src/domain/analysis/context.ts index 32969d3e..d5ef6b0c 100644 --- a/src/domain/analysis/context.ts +++ b/src/domain/analysis/context.ts @@ -27,7 +27,7 @@ import { readSourceRange, } from '../../shared/file-utils.js'; import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; +import { normalizeSymbol, toSymbolRef } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; import type { BetterSqlite3Database, @@ -177,7 +177,7 @@ function buildImplementationInfo(db: BetterSqlite3Database, node: NodeRow, noTes let impls = findImplementors(db, node.id) as RelatedNodeRow[]; if (noTests) impls = impls.filter((n) => !isTestFile(n.file)); return { - implementors: impls.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + implementors: impls.map(toSymbolRef), }; } // For classes/structs: show what they implement @@ -186,7 +186,7 @@ function buildImplementationInfo(db: BetterSqlite3Database, node: NodeRow, noTes if (noTests) ifaces = ifaces.filter((n) => !isTestFile(n.file)); if (ifaces.length > 0) { return { - implements: ifaces.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + implements: ifaces.map(toSymbolRef), }; } } @@ -359,21 +359,11 @@ function explainFunctionImpl( const summary = fileLines ? extractSummary(fileLines, node.line, displayOpts) : null; const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; - const callees = (findCallees(db, node.id) as RelatedNodeRow[]).map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - })); + const callees = (findCallees(db, node.id) as RelatedNodeRow[]).map(toSymbolRef); const allCallerRows = findCallers(db, node.id) as RelatedNodeRow[]; - let callers = allCallerRows.map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - })); + let callers = allCallerRows.map(toSymbolRef); if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); const seenFiles = new Set(); diff --git a/src/domain/analysis/dependencies.ts b/src/domain/analysis/dependencies.ts index 730f5309..8df56786 100644 --- a/src/domain/analysis/dependencies.ts +++ b/src/domain/analysis/dependencies.ts @@ -2,7 +2,7 @@ import { findFileNodes, type Repository } from '../../db/index.js'; import { cachedStmt } from '../../db/repository/cached-stmt.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; +import { normalizeSymbol, toSymbolRef } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; import type { BetterSqlite3Database, @@ -143,12 +143,7 @@ function buildNodeDepsResult( return { ...normalizeSymbol(node, repo, hc), - callees: filteredCallees.map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - })), + callees: filteredCallees.map(toSymbolRef), callers: callers.map((c) => ({ name: c.name, kind: c.kind, @@ -245,20 +240,14 @@ function resolveEndpoints( to, found: false, error: `No symbol matching "${to}"`, - fromCandidates: fromNodes - .slice(0, 5) - .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + fromCandidates: fromNodes.slice(0, 5).map(toSymbolRef), toCandidates: [], }, }; } - const fromCandidates = fromNodes - .slice(0, 5) - .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })); - const toCandidates = toNodes - .slice(0, 5) - .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })); + const fromCandidates = fromNodes.slice(0, 5).map(toSymbolRef); + const toCandidates = toNodes.slice(0, 5).map(toSymbolRef); return { sourceNode: fromNodes[0], diff --git a/src/domain/analysis/fn-impact.ts b/src/domain/analysis/fn-impact.ts index 687be1ea..f33ab26f 100644 --- a/src/domain/analysis/fn-impact.ts +++ b/src/domain/analysis/fn-impact.ts @@ -1,6 +1,6 @@ import { Repository, SqliteRepository } from '../../db/index.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; +import { normalizeSymbol, toSymbolRef } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; import type { BetterSqlite3Database, NodeRow, RelatedNodeRow } from '../../types.js'; import { resolveAnalysisOpts, withRepo } from './query-helpers.js'; @@ -125,7 +125,7 @@ export function bfsTransitiveCallers( visited.add(c.id); nextFrontier.push(c.id); if (!levels[d]) levels[d] = []; - levels[d]!.push({ name: c.name, kind: c.kind, file: c.file, line: c.line }); + levels[d]!.push(toSymbolRef(c)); if (onVisit) onVisit(c, fid, d); } if (resolveImplementors && INTERFACE_LIKE_KINDS.has(c.kind)) { diff --git a/src/domain/analysis/implementations.ts b/src/domain/analysis/implementations.ts index 2c5dc9e4..ea670383 100644 --- a/src/domain/analysis/implementations.ts +++ b/src/domain/analysis/implementations.ts @@ -1,6 +1,6 @@ import { isTestFile } from '../../infrastructure/test-filter.js'; import { CORE_SYMBOL_KINDS } from '../../shared/kinds.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; +import { normalizeSymbol, toSymbolRef } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; import type { RelatedNodeRow } from '../../types.js'; import { withRepo } from './query-helpers.js'; @@ -34,12 +34,7 @@ export function implementationsData( return { ...normalizeSymbol(node, repo, hc), - implementors: implementors.map((impl) => ({ - name: impl.name, - kind: impl.kind, - file: impl.file, - line: impl.line, - })), + implementors: implementors.map(toSymbolRef), }; }); @@ -76,12 +71,7 @@ export function interfacesData( return { ...normalizeSymbol(node, repo, hc), - interfaces: interfaces.map((iface) => ({ - name: iface.name, - kind: iface.kind, - file: iface.file, - line: iface.line, - })), + interfaces: interfaces.map(toSymbolRef), }; }); diff --git a/src/domain/graph/builder/stages/detect-changes.ts b/src/domain/graph/builder/stages/detect-changes.ts index bcd40510..ff5c6d8a 100644 --- a/src/domain/graph/builder/stages/detect-changes.ts +++ b/src/domain/graph/builder/stages/detect-changes.ts @@ -277,14 +277,14 @@ async function runPendingAnalysis(ctx: PipelineContext): Promise { rootDir, analysisOpts, ); - if (needsCfg) { - const { buildCFGData } = await import('../../../../features/cfg.js'); - await buildCFGData(db, analysisSymbols, rootDir, engineOpts); - } - if (needsDataflow) { - const { buildDataflowEdges } = await import('../../../../features/dataflow.js'); - await buildDataflowEdges(db, analysisSymbols, rootDir, engineOpts); - } + const { runAnalyses } = await import('../../../../ast-analysis/engine.js'); + await runAnalyses( + db, + analysisSymbols, + rootDir, + { ast: false, complexity: false, cfg: needsCfg, dataflow: needsDataflow }, + engineOpts, + ); return true; } diff --git a/src/domain/parser.ts b/src/domain/parser.ts index ea424ab3..97272262 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -5,7 +5,7 @@ import type { Tree } from 'web-tree-sitter'; import { Language, Parser, Query } from 'web-tree-sitter'; import { debug, warn } from '../infrastructure/logger.js'; import { getNative, getNativePackageVersion, loadNative } from '../infrastructure/native.js'; -import { toErrorMessage } from '../shared/errors.js'; +import { ParseError, toErrorMessage } from '../shared/errors.js'; import type { EngineMode, ExtractorOutput, @@ -188,7 +188,11 @@ async function doLoadLanguage(entry: LanguageRegistryEntry): Promise { _queryCache.set(entry.id, new Query(lang, patterns.join('\n'))); } } catch (e: unknown) { - if (entry.required) throw e; + if (entry.required) + throw new ParseError(`Required parser ${entry.id} failed to initialize`, { + file: entry.grammarFile, + cause: e as Error, + }); warn( `${entry.id} parser failed to initialize: ${(e as Error).message}. ${entry.id} files will be skipped.`, ); diff --git a/src/domain/queries.ts b/src/domain/queries.ts index 7a4413e9..70c465b3 100644 --- a/src/domain/queries.ts +++ b/src/domain/queries.ts @@ -22,7 +22,7 @@ export { VALID_ROLES, } from '../shared/kinds.js'; // ── Shared utilities ───────────────────────────────────────────────────── -export { kindIcon, normalizeSymbol } from '../shared/normalize.js'; +export { kindIcon, normalizeSymbol, toSymbolRef } from '../shared/normalize.js'; export { briefData } from './analysis/brief.js'; export { contextData, explainData } from './analysis/context.js'; export { fileDepsData, filePathData, fnDepsData, pathData } from './analysis/dependencies.js'; diff --git a/src/features/audit.ts b/src/features/audit.ts index d0ccb78d..18ee8174 100644 --- a/src/features/audit.ts +++ b/src/features/audit.ts @@ -7,6 +7,7 @@ import { loadConfig } from '../infrastructure/config.js'; import { debug } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { toErrorMessage } from '../shared/errors.js'; +import { toSymbolRef } from '../shared/normalize.js'; import type { BetterSqlite3Database, CodegraphConfig } from '../types.js'; import { RULE_DEFS } from './manifesto.js'; @@ -317,7 +318,7 @@ function enrichSymbol( WHERE e.source_id = ? AND e.kind = 'calls'`, ) .all(nodeId) as SymbolRef[] - ).map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line })); + ).map(toSymbolRef); callers = ( db @@ -327,7 +328,7 @@ function enrichSymbol( WHERE e.target_id = ? AND e.kind = 'calls'`, ) .all(nodeId) as SymbolRef[] - ).map((c) => ({ name: c.name, kind: c.kind, file: c.file, line: c.line })); + ).map(toSymbolRef); if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); const testCallerRows = db diff --git a/src/features/boundaries.ts b/src/features/boundaries.ts index 2580b90d..c792a284 100644 --- a/src/features/boundaries.ts +++ b/src/features/boundaries.ts @@ -1,5 +1,5 @@ -import { debug } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; +import { BoundaryError } from '../shared/errors.js'; import type { BetterSqlite3Database } from '../types.js'; // ─── Glob-to-Regex ─────────────────────────────────────────────────── @@ -269,8 +269,7 @@ export function evaluateBoundaries( const { valid, errors } = validateBoundaryConfig(boundaryConfig); if (!valid) { - debug(`boundary config validation failed: ${errors.join('; ')}`); - return { violations: [], violationCount: 0 }; + throw new BoundaryError(`Invalid boundary configuration: ${errors.join('; ')}`); } const modules = resolveModules(boundaryConfig); @@ -297,8 +296,7 @@ export function evaluateBoundaries( ) .all() as Array<{ source: string; target: string }>; } catch (err) { - debug(`boundary edge query failed: ${(err as Error).message}`); - return { violations: [], violationCount: 0 }; + throw new BoundaryError('Boundary evaluation failed', { cause: err as Error }); } if (opts.noTests) { diff --git a/src/features/branch-compare.ts b/src/features/branch-compare.ts index d0ec80e9..086ed1f1 100644 --- a/src/features/branch-compare.ts +++ b/src/features/branch-compare.ts @@ -9,6 +9,7 @@ import { debug } from '../infrastructure/logger.js'; import { getNative, isNativeAvailable } from '../infrastructure/native.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { toErrorMessage } from '../shared/errors.js'; +import { toSymbolRef } from '../shared/normalize.js'; import type { EngineMode, NativeDatabase } from '../types.js'; // ─── Git Helpers ──────────────────────────────────────────────────────── @@ -255,9 +256,7 @@ function loadCallersFromDb( if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { visited.add(c.id); nextFrontier.push(c.id); - allCallers.add( - JSON.stringify({ name: c.name, kind: c.kind, file: c.file, line: c.line }), - ); + allCallers.add(JSON.stringify(toSymbolRef(c))); } } } diff --git a/src/features/flow.ts b/src/features/flow.ts index 0d2d6c26..18c52215 100644 --- a/src/features/flow.ts +++ b/src/features/flow.ts @@ -8,6 +8,7 @@ import { openReadonlyOrFail } from '../db/index.js'; import { CORE_SYMBOL_KINDS, findMatchingNodes } from '../domain/queries.js'; import { isTestFile } from '../infrastructure/test-filter.js'; +import { toSymbolRef } from '../shared/normalize.js'; import { paginateResult } from '../shared/paginate.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; @@ -175,7 +176,7 @@ function bfsCallees( visited.add(c.id); nextFrontier.push(c.id); - const nodeInfo: NodeInfo = { name: c.name, kind: c.kind, file: c.file, line: c.line }; + const nodeInfo: NodeInfo = toSymbolRef(c); levelNodes.push(nodeInfo); nodeDepths.set(c.id, d); idToNode.set(c.id, nodeInfo); diff --git a/src/features/manifesto.ts b/src/features/manifesto.ts index edf22bc0..cbecf1ed 100644 --- a/src/features/manifesto.ts +++ b/src/features/manifesto.ts @@ -5,6 +5,7 @@ import { loadConfig } from '../infrastructure/config.js'; import { debug } from '../infrastructure/logger.js'; import { paginateResult } from '../shared/paginate.js'; import type { BetterSqlite3Database, CodegraphConfig, ThresholdRule } from '../types.js'; +import type { BoundaryViolation } from './boundaries.js'; import { evaluateBoundaries } from './boundaries.js'; // ─── Rule Definitions ───────────────────────────────────────────────── @@ -416,7 +417,20 @@ function evaluateBoundaryRules( return; } - const result = evaluateBoundaries(db, boundaryConfig, { noTests: opts.noTests || false }); + let result: { violations: BoundaryViolation[]; violationCount: number }; + try { + result = evaluateBoundaries(db, boundaryConfig, { noTests: opts.noTests || false }); + } catch (e: unknown) { + debug(`boundary check failed: ${(e as Error).message}`); + ruleResults.push({ + name: 'boundaries', + level: 'graph', + status: 'pass', + thresholds: effectiveThresholds, + violationCount: 0, + }); + return; + } const hasBoundaryViolations = result.violationCount > 0; if (!hasBoundaryViolations) { diff --git a/src/presentation/structure.ts b/src/presentation/structure.ts index b5208d48..202f57dd 100644 --- a/src/presentation/structure.ts +++ b/src/presentation/structure.ts @@ -75,7 +75,7 @@ export function formatHotspots(data: HotspotsResult): string { interface ModuleBoundaryEntry { directory: string; - cohesion: number; + cohesion: number | null; fileCount: number; symbolCount: number; fanIn: number; @@ -95,7 +95,7 @@ export function formatModuleBoundaries(data: ModuleBoundariesResult): string { const lines = [`\nModule boundaries (cohesion >= ${data.threshold}, ${data.count} modules):\n`]; for (const m of data.modules) { lines.push( - ` ${m.directory}/ cohesion=${m.cohesion.toFixed(2)} (${m.fileCount} files, ${m.symbolCount} symbols)`, + ` ${m.directory}/ cohesion=${m.cohesion !== null ? m.cohesion.toFixed(2) : 'n/a'} (${m.fileCount} files, ${m.symbolCount} symbols)`, ); lines.push(` Incoming: ${m.fanIn} edges Outgoing: ${m.fanOut} edges`); if (m.files.length > 0) { diff --git a/src/shared/normalize.ts b/src/shared/normalize.ts index 6650d8dc..ce69d10c 100644 --- a/src/shared/normalize.ts +++ b/src/shared/normalize.ts @@ -19,6 +19,16 @@ export function getFileHash(db: DbHandle, file: string): string | null { return row ? row.hash : null; } +/** Pick the 4-field symbol reference from any row that carries at least {name, kind, file, line}. */ +export function toSymbolRef(row: { name: string; kind: string; file: string; line: number }): { + name: string; + kind: string; + file: string; + line: number; +} { + return { name: row.name, kind: row.kind, file: row.file, line: row.line }; +} + export function kindIcon(kind: string): string { switch (kind) { case 'function': diff --git a/tests/unit/boundaries.test.ts b/tests/unit/boundaries.test.ts index d3f9b440..8684d8bc 100644 --- a/tests/unit/boundaries.test.ts +++ b/tests/unit/boundaries.test.ts @@ -383,11 +383,12 @@ describe('evaluateBoundaries', () => { } }); - test('returns empty on invalid config', () => { + test('throws BoundaryError on invalid config', () => { const database = openDb(); try { - const result = evaluateBoundaries(database, { modules: {} }); - expect(result.violations).toHaveLength(0); + expect(() => evaluateBoundaries(database, { modules: {} })).toThrow( + /Invalid boundary configuration/, + ); } finally { database.close(); } From d6e25a6275dec5ff7be2672e5c652a95cd1817f7 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:40:56 -0600 Subject: [PATCH 2/4] fix: address review feedback on dead helper adoption (#895) - manifesto.ts: report 'warn' instead of 'pass' when boundary check throws - structure.ts: validate --threshold flag rejects non-numeric input - dependencies.ts: clarify intentional skip of toSymbolRef for callers --- src/cli/commands/structure.ts | 8 +++++++- src/domain/analysis/dependencies.ts | 1 + src/features/manifesto.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/structure.ts b/src/cli/commands/structure.ts index c17ff99d..20676305 100644 --- a/src/cli/commands/structure.ts +++ b/src/cli/commands/structure.ts @@ -23,8 +23,14 @@ export const command: CommandDefinition = { await import('../../presentation/structure.js'); if (opts.modules) { + const parsed = opts.threshold ? parseFloat(opts.threshold as string) : undefined; + if (parsed !== undefined && Number.isNaN(parsed)) { + console.error('Error: --threshold must be a number'); + process.exitCode = 1; + return; + } const data = moduleBoundariesData(opts.db, { - threshold: opts.threshold ? parseFloat(opts.threshold as string) : undefined, + threshold: parsed, }); if (!ctx.outputResult(data, 'modules', opts)) { console.log(formatModuleBoundaries(data)); diff --git a/src/domain/analysis/dependencies.ts b/src/domain/analysis/dependencies.ts index 8df56786..1a619c86 100644 --- a/src/domain/analysis/dependencies.ts +++ b/src/domain/analysis/dependencies.ts @@ -144,6 +144,7 @@ function buildNodeDepsResult( return { ...normalizeSymbol(node, repo, hc), callees: filteredCallees.map(toSymbolRef), + // Not using toSymbolRef — callers include the extra viaHierarchy field callers: callers.map((c) => ({ name: c.name, kind: c.kind, diff --git a/src/features/manifesto.ts b/src/features/manifesto.ts index cbecf1ed..3a94bd2d 100644 --- a/src/features/manifesto.ts +++ b/src/features/manifesto.ts @@ -425,7 +425,7 @@ function evaluateBoundaryRules( ruleResults.push({ name: 'boundaries', level: 'graph', - status: 'pass', + status: 'warn', thresholds: effectiveThresholds, violationCount: 0, }); From 90c185d7c92997b53e80f285c3d9bb84a068f411 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:57:11 -0600 Subject: [PATCH 3/4] feat(skill): add /titan-grind phase and wire into /titan-run pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forge extracts helpers but never completes the adoption loop — dead symbol count inflates with every run. Grind closes the gap by finding dead helpers from forge, classifying them (adopt/re-export/promote/ false-positive/remove), wiring them into consumers, and gating on a non-positive dead-symbol delta. Pipeline is now: recon → gauntlet → sync → forge → grind → close --- .claude/skills/titan-grind/SKILL.md | 328 ++++++++++++++++++++++++++++ .claude/skills/titan-run/SKILL.md | 125 ++++++++++- 2 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/titan-grind/SKILL.md diff --git a/.claude/skills/titan-grind/SKILL.md b/.claude/skills/titan-grind/SKILL.md new file mode 100644 index 00000000..8c43b3c3 --- /dev/null +++ b/.claude/skills/titan-grind/SKILL.md @@ -0,0 +1,328 @@ +--- +name: titan-grind +description: Adopt extracted helpers — find dead symbols from forge, wire them into consumers, replace duplicated inline patterns, and gate on dead-symbol delta (Titan Paradigm Phase 4.5) +argument-hint: <--dry-run> <--phase N> <--yes> +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Skill, Agent +--- + +# Titan GRIND — Adopt Extracted Helpers + +You are running the **GRIND** phase of the Titan Paradigm. + +Forge shapes the metal. Grind smooths the rough edges. Your goal: find helpers that forge extracted but never wired into consumers, adopt them across the codebase, and gate on a non-positive dead-symbol delta. + +> **Why this phase exists:** Forge decomposes god-functions into smaller helpers, but those helpers are only called within their own file. The dead symbol count inflates with every forge phase because the adoption loop is never closed. Grind closes it. + +> **Context budget:** One forge phase per invocation. Process all targets from one forge phase's commits, then stop. User re-runs for the next phase. + +**Arguments** (from `$ARGUMENTS`): +- No args → process the next unground forge phase +- `--phase N` → process a specific forge phase +- `--dry-run` → analyze and report without making changes +- `--yes` → skip confirmation prompt (typically passed by `/titan-run` orchestrator) + +--- + +## Step 0 — Pre-flight + +1. **Worktree check:** + ```bash + git rev-parse --show-toplevel && git worktree list + ``` + If not in a worktree, stop: "Run `/worktree` first." + +2. **Sync with main:** + ```bash + git fetch origin main && git merge origin/main --no-edit + ``` + If merge conflicts → stop: "Merge conflict detected. Resolve and re-run `/titan-grind`." + +3. **Load artifacts.** Read: + - `.codegraph/titan/titan-state.json` — current state (required) + - `.codegraph/titan/sync.json` — execution plan (required) + - `.codegraph/titan/gate-log.ndjson` — gate verdicts (optional) + +4. **Validate state.** Grind runs after forge. Check: + - `titan-state.json → execution` block exists + - `execution.completedPhases` has at least one entry + - If no `execution` block → stop: "No forge execution found. Run `/titan-forge` first." + +5. **Initialize grind state** (if first run). Add to `titan-state.json`: + ```json + { + "grind": { + "completedPhases": [], + "currentPhase": null, + "adoptions": [], + "deadSymbolBaseline": null, + "deadSymbolCurrent": null + } + } + ``` + +6. **Capture dead-symbol baseline** (if first run): + ```bash + codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const items=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:items.length,byRole:items.reduce((a,i)=>{a[i.role]=(a[i.role]||0)+1;return a},{})}));})" + ``` + Store the total in `grind.deadSymbolBaseline`. + +7. **Determine next phase.** Use `--phase N` if provided, otherwise find the lowest forge phase number not in `grind.completedPhases`. + +8. **Print plan and ask for confirmation** (unless `--yes`): + ``` + GRIND — Phase N: