Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
3892e7d
chore: gitignore napi-generated artifacts in crates/codegraph-core
carlos-alm Jun 13, 2026
ef8ea4f
chore(tests): remove unused biome suppression in visitor.test.ts
carlos-alm Jun 13, 2026
a372b82
fix(titan-run): sync --start-from enum and phase-timestamp list with …
carlos-alm Jun 13, 2026
9a52c7c
fix(hooks): track Bash file modifications via before/after git status…
carlos-alm Jun 13, 2026
85a26df
chore(native): remove dead code (unused var, method, variant, fields)
carlos-alm Jun 13, 2026
184d221
refactor(native): extract emit_pts_alias_edges params into PtsAliasCt…
carlos-alm Jun 13, 2026
909e1df
fix(wasm): sort call targets by confidence before emit to match nativ…
carlos-alm Jun 13, 2026
66fc899
fix(bench): add 2 warmup runs and raise INCREMENTAL_RUNS to 5 for inc…
carlos-alm Jun 13, 2026
84e1a5f
ci(bench): add per-PR perf canary for extractor/graph/native changes
carlos-alm Jun 13, 2026
d07b358
fix(perf): plumb symbolsOnly through parseFilesWasmInline to skip ana…
carlos-alm Jun 13, 2026
3db5d8c
fix(perf): scope runPostNativeCha to changed files on incremental builds
carlos-alm Jun 13, 2026
d70a9ab
Merge remote-tracking branch 'origin/main' into fix/cha-incremental-s…
carlos-alm Jun 13, 2026
a79855e
fix(perf): broaden Gate B to cover constructor/function-kind RTA fall…
carlos-alm Jun 13, 2026
76e5910
docs(native): document Gate A deletion-safety invariant in runPostNat…
carlos-alm Jun 13, 2026
ea219c9
chore: merge origin/main into fix/cha-incremental-scope-1441
carlos-alm Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions .github/workflows/perf-canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: Perf Canary

# Lightweight per-PR build-time regression gate for PRs that touch the
# extractor, graph-builder, or native Rust layers — the parts of the codebase
# that caused the v3.12.0 regressions (+1827% 1-file rebuild, +98% full build).
#
# Only the incremental-benchmark suite is run (full build + no-op + 1-file
# rebuild for both engines). The regression guard uses BENCH_CANARY=1 mode,
# which applies a 50% threshold instead of the full suite's 25% — enough
# to catch catastrophic regressions while tolerating CI runner variance.
#
# This is intentionally separate from the full pre-publish-benchmark job in
# ci.yml, which runs unconditionally on every PR and measures the complete
# suite. The canary completes in roughly 5–10 minutes; the full suite takes
# 20–60 minutes.

on:
pull_request:
paths:
- "src/extractors/**"
- "src/domain/graph/**"
- "crates/**"
- "scripts/benchmark.ts"
- "scripts/incremental-benchmark.ts"
- "scripts/lib/bench-config.ts"
- "scripts/lib/fork-engine.ts"

concurrency:
group: perf-canary-${{ github.ref }}
cancel-in-progress: true

jobs:
perf-canary:
name: Perf canary (incremental tiers)
runs-on: ubuntu-latest
env:
CODEGRAPH_FAST_SKIP_DIAG: "1"

steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "npm"

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: crates/codegraph-core

- name: Install napi-rs CLI
timeout-minutes: 5
run: npm install -g @napi-rs/cli@3

- name: Build native addon
working-directory: crates/codegraph-core
run: napi build --release

- name: Install dependencies
timeout-minutes: 20
shell: bash
run: |
for attempt in 1 2 3; do
npm install && break
if [ "$attempt" -lt 3 ]; then
echo "::warning::npm install attempt $attempt failed, retrying in 15s..."
sleep 15
else
echo "::error::npm install failed after 3 attempts"
exit 1
fi
done

- name: Install native addon over published binary
run: node scripts/ci-install-native.mjs

# Build dist/ so benchmarks load the same compiled JS that ships to npm,
# matching the methodology used by the full pre-publish-benchmark gate.
- name: Build TypeScript
run: npm run build

- name: Run incremental benchmark
timeout-minutes: 15
run: |
STRIP_FLAG=$(node -e "const [M]=process.versions.node.split('.').map(Number); console.log(M>=23?'--strip-types':'--experimental-strip-types')")
node $STRIP_FLAG --import ./scripts/ts-resolve-loader.js scripts/incremental-benchmark.ts --version dev --dist > incremental-canary-result.json

- name: Update incremental report
run: |
STRIP_FLAG=$(node -e "const [M]=process.versions.node.split('.').map(Number); console.log(M>=23?'--strip-types':'--experimental-strip-types')")
node $STRIP_FLAG scripts/update-incremental-report.ts incremental-canary-result.json

- name: Regression guard (50% threshold)
env:
RUN_REGRESSION_GUARD: "1"
BENCH_CANARY: "1"
run: npm run test:regression-guard

- name: Upload canary result
if: always()
uses: actions/upload-artifact@v7
with:
name: incremental-canary-result
path: incremental-canary-result.json
if-no-files-found: warn
4 changes: 0 additions & 4 deletions crates/codegraph-core/src/ast_analysis/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,10 +659,6 @@ impl<'a> CfgBuilder<'a> {
}
}

fn start_line_of(&self, block_idx: u32) -> Option<u32> {
self.blocks.iter().find(|b| b.index == block_idx).and_then(|b| b.start_line)
}

/// Get statement children from a block or statement list.
fn get_statements<'b>(&self, node: &Node<'b>) -> Vec<Node<'b>> {
let kind = node.kind();
Expand Down
36 changes: 15 additions & 21 deletions crates/codegraph-core/src/ast_analysis/complexity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -516,8 +516,6 @@ fn walk_children(
enum BranchAction {
/// Node handled — walk children at the given nesting delta, then return.
Handled { cognitive_delta: u32, cyclomatic_delta: u32, nesting_delta: u32 },
/// Not a special branch pattern — fall through to normal processing.
NotHandled,
}

/// Classify a branch node (one where `rules.is_branch(kind)` is true).
Expand Down Expand Up @@ -675,14 +673,12 @@ fn walk(

// Branch/control flow nodes (skip keyword leaf tokens)
if rules.is_branch(kind) && node.child_count() > 0 {
if let BranchAction::Handled { cognitive_delta, cyclomatic_delta, nesting_delta } =
classify_branch(node, kind, rules, nesting_level)
{
*cognitive += cognitive_delta;
*cyclomatic += cyclomatic_delta;
walk_children(node, nesting_level + nesting_delta, false, rules, cognitive, cyclomatic, max_nesting, depth);
return;
}
let BranchAction::Handled { cognitive_delta, cyclomatic_delta, nesting_delta } =
classify_branch(node, kind, rules, nesting_level);
*cognitive += cognitive_delta;
*cyclomatic += cyclomatic_delta;
walk_children(node, nesting_level + nesting_delta, false, rules, cognitive, cyclomatic, max_nesting, depth);
return;
}

// Pattern C plain else (Go/Java)
Expand Down Expand Up @@ -1323,17 +1319,15 @@ fn walk_all(

// Branch/control flow nodes (skip keyword leaf tokens)
if c_rules.is_branch(kind) && node.child_count() > 0 {
if let BranchAction::Handled { cognitive_delta, cyclomatic_delta, nesting_delta } =
classify_branch(node, kind, c_rules, nesting_level)
{
*cognitive += cognitive_delta;
*cyclomatic += cyclomatic_delta;
walk_all_children(
node, source, nesting_level + nesting_delta, false, skip_h,
c_rules, h_rules, cognitive, cyclomatic, max_nesting, operators, operands,
);
return;
}
let BranchAction::Handled { cognitive_delta, cyclomatic_delta, nesting_delta } =
classify_branch(node, kind, c_rules, nesting_level);
*cognitive += cognitive_delta;
*cyclomatic += cyclomatic_delta;
walk_all_children(
node, source, nesting_level + nesting_delta, false, skip_h,
c_rules, h_rules, cognitive, cyclomatic, max_nesting, operators, operands,
);
return;
}

// Pattern C plain else (Go/Java)
Expand Down
10 changes: 5 additions & 5 deletions crates/codegraph-core/src/ast_analysis/dataflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -882,8 +882,8 @@ fn collect_identifiers(node: &Node, out: &mut Vec<String>, rules: &DataflowRules

#[derive(Debug, Clone)]
enum LocalSource {
CallReturn { callee: String },
Destructured { callee: String },
CallReturn,
Destructured,
}

struct ScopeFrame {
Expand Down Expand Up @@ -1200,7 +1200,7 @@ fn handle_var_declarator(
});
scope
.locals
.insert(n.clone(), LocalSource::Destructured { callee: callee.clone() });
.insert(n.clone(), LocalSource::Destructured);
}
} else {
let var_name = node_text(&name_n, source).to_string();
Expand All @@ -1211,7 +1211,7 @@ fn handle_var_declarator(
expression: truncate(node_text(node, source), DATAFLOW_TRUNCATION_LIMIT),
line: node_line(node),
});
scope.locals.insert(var_name, LocalSource::CallReturn { callee });
scope.locals.insert(var_name, LocalSource::CallReturn);
}
}

Expand Down Expand Up @@ -1267,7 +1267,7 @@ fn handle_assignment(
line: node_line(node),
});
if let Some(scope) = scope_stack.last_mut() {
scope.locals.insert(var_name, LocalSource::CallReturn { callee });
scope.locals.insert(var_name, LocalSource::CallReturn);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use crate::types::FileSymbols;

struct CacheEntry {
tree: Tree,
lang: LanguageKind,
}

/// Cache of parse trees for incremental parsing.
Expand Down Expand Up @@ -51,7 +50,7 @@ impl ParseTreeCache {

let symbols = extract_symbols(lang, &tree, source_bytes, &file_path);

self.entries.insert(file_path, CacheEntry { tree, lang });
self.entries.insert(file_path, CacheEntry { tree });

Some(symbols)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,50 +360,55 @@ fn resolve_via_points_to<'a>(
}
}

/// Per-call-site inputs for `emit_pts_alias_edges`.
/// Groups the lookup parameters so the function stays within the argument-count limit.
struct PtsAliasCtx<'a> {
pts: &'a HashMap<String, HashSet<String>>,
lookup_name: &'a str,
call_line: u32,
caller_id: u32,
caller_name: &'a str,
is_dynamic: u32,
rel_path: &'a str,
imported_names: &'a HashMap<&'a str, &'a str>,
type_map: &'a HashMap<&'a str, (&'a str, f64)>,
}

/// Resolve each pts alias of `lookup_name` and emit hop-penalised call edges.
/// Shared by the no-receiver gate and the receiver-key (`rest.prop()`) fallback;
/// mirrors the alias-emission loops in buildFileCallEdges (build-edges.ts).
#[allow(clippy::too_many_arguments)]
fn emit_pts_alias_edges<'a>(
ctx: &EdgeContext<'a>,
pts: &HashMap<String, HashSet<String>>,
lookup_name: &str,
call_line: u32,
caller_id: u32,
caller_name: &str,
is_dynamic: u32,
rel_path: &str,
imported_names: &HashMap<&str, &str>,
type_map: &HashMap<&str, (&str, f64)>,
alias_ctx: &PtsAliasCtx<'_>,
seen_edges: &HashSet<u64>,
pts_edge_map: &mut HashMap<u64, usize>,
edges: &mut Vec<ComputedEdge>,
) {
for alias in resolve_via_points_to(lookup_name, pts) {
let alias_imported_from = imported_names.get(alias).copied();
for alias in resolve_via_points_to(alias_ctx.lookup_name, alias_ctx.pts) {
let alias_imported_from = alias_ctx.imported_names.get(alias).copied();
let alias_call = CallInfo {
name: alias.to_string(),
line: call_line,
line: alias_ctx.call_line,
dynamic: Some(true),
receiver: None,
};
let mut alias_targets = resolve_call_targets(
ctx, &alias_call, rel_path, alias_imported_from, type_map, caller_name,
ctx, &alias_call, alias_ctx.rel_path, alias_imported_from, alias_ctx.type_map, alias_ctx.caller_name,
);
sort_targets_by_confidence(&mut alias_targets, rel_path, alias_imported_from);
sort_targets_by_confidence(&mut alias_targets, alias_ctx.rel_path, alias_imported_from);
for t in &alias_targets {
let edge_key = ((caller_id as u64) << 32) | (t.id as u64);
if t.id != caller_id && !seen_edges.contains(&edge_key) && !pts_edge_map.contains_key(&edge_key) {
let conf = resolve::compute_confidence(rel_path, &t.file, alias_imported_from)
let edge_key = ((alias_ctx.caller_id as u64) << 32) | (t.id as u64);
if t.id != alias_ctx.caller_id && !seen_edges.contains(&edge_key) && !pts_edge_map.contains_key(&edge_key) {
let conf = resolve::compute_confidence(alias_ctx.rel_path, &t.file, alias_imported_from)
- PROPAGATION_HOP_PENALTY;
if conf > 0.0 {
pts_edge_map.insert(edge_key, edges.len());
edges.push(ComputedEdge {
source_id: caller_id,
source_id: alias_ctx.caller_id,
target_id: t.id,
kind: "calls".to_string(),
confidence: conf,
dynamic: is_dynamic,
dynamic: alias_ctx.is_dynamic,
});
}
}
Expand Down Expand Up @@ -593,8 +598,21 @@ fn process_file<'a>(
};
if let Some(lookup_name) = lookup_name {
emit_pts_alias_edges(
ctx, pts, &lookup_name, call.line, caller_id, caller_name, is_dynamic,
rel_path, &imported_names, &type_map, &seen_edges, &mut pts_edge_map, edges,
ctx,
&PtsAliasCtx {
pts,
lookup_name: &lookup_name,
call_line: call.line,
caller_id,
caller_name,
is_dynamic,
rel_path,
imported_names: &imported_names,
type_map: &type_map,
},
&seen_edges,
&mut pts_edge_map,
edges,
);
}
}
Expand All @@ -609,8 +627,21 @@ fn process_file<'a>(
let receiver_key = format!("{}.{}", receiver, call.name);
if pts.contains_key(receiver_key.as_str()) {
emit_pts_alias_edges(
ctx, pts, &receiver_key, call.line, caller_id, caller_name, is_dynamic,
rel_path, &imported_names, &type_map, &seen_edges, &mut pts_edge_map, edges,
ctx,
&PtsAliasCtx {
pts,
lookup_name: &receiver_key,
call_line: call.line,
caller_id,
caller_name,
is_dynamic,
rel_path,
imported_names: &imported_names,
type_map: &type_map,
},
&seen_edges,
&mut pts_edge_map,
edges,
);
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/codegraph-core/src/extractors/clojure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ fn walk_clojure(
return;
}

// `next_ns_owned` holds the String so that `next_ns` can borrow it as
// `&str` for the duration of this stack frame. The assignment looks
// "never read" to the compiler but the borrow on the next line reads it.
#[allow(unused_assignments)]
let mut next_ns_owned: Option<String> = None;
let next_ns: Option<&str> = if node.kind() == "list_lit" {
match handle_list_form(node, source, symbols, current_ns) {
Expand Down
Loading
Loading