diff --git a/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts b/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts index 83783e441..4d5995e52 100644 --- a/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts +++ b/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts @@ -4,7 +4,8 @@ * Zig static-gating resolver. * * Detects calls inside `if (CONST_FALSE)` blocks (and trivial boolean - * extensions: `and`, `or`, simple negation) so the call edge can be + * extensions: `and`, `or`, `==`/`!=`, parentheses, and prefix `!` negation, + * which tree-sitter-zig parses as `error_union_type`) so the call edge can be * tagged with `staticGated: true`. The flag lets impact-analysis * consumers filter out paper-tiger callers that live in dead branches * gated behind a comptime-known `false` constant. @@ -377,7 +378,10 @@ export function isCallStaticGated( /** * Every source range that is statically dead in this file: the body of an * `if` whose condition folds to `false`, and the `else` clause of an `if` - * whose condition folds to `true`. Line/col ranges, so a capture layer that + * whose condition folds to `true`. Both the statement form (`if (c) { .. }`) + * and the expression form (`const x = if (c) a else b;`) are walked; the + * arms differ only in how the grammar exposes them, see `ifExpressionArms`. + * Line/col ranges, so a capture layer that * only keeps `Capture.range` (no node) can still stamp its call sites — * that is how the scope-resolution provider consumes this module. * @@ -402,14 +406,21 @@ export function collectZigStaticGatedRanges( const stack: SyntaxNode[] = [rootNode]; while (stack.length > 0) { const node = stack.pop()!; - if (node.type === 'if_statement') { + if (node.type === 'if_statement' || node.type === 'if_expression') { const cond = findIfCondition(node); const result = cond ? evalCond(cond, localBools, importAliases, lookupBoolsForPath, 0) : undefined; let dead: SyntaxNode | null = null; - if (result === false) dead = node.childForFieldName('body'); - if (result === true) dead = node.namedChildren.find((c) => c.type === 'else_clause') ?? null; + if (node.type === 'if_statement') { + if (result === false) dead = node.childForFieldName('body'); + if (result === true) + dead = node.namedChildren.find((c) => c.type === 'else_clause') ?? null; + } else { + const arms = ifExpressionArms(node); + if (result === false) dead = arms.consequence; + if (result === true) dead = arms.alternative; + } if (dead) { out.push({ startLine: dead.startPosition.row + 1, @@ -427,6 +438,34 @@ export function collectZigStaticGatedRanges( return out; } +/** + * The two arms of an `if_expression` (`const x = if (c) a else b;`). Unlike + * `if_statement` the grammar gives them no field names and no `else_clause` + * wrapper: the consequence is the first named child after the closing `)` + * of the condition, the alternative is the first named child after the + * anonymous `else` token. Either may be absent. + */ +function ifExpressionArms(node: SyntaxNode): { + consequence: SyntaxNode | null; + alternative: SyntaxNode | null; +} { + let consequence: SyntaxNode | null = null; + let alternative: SyntaxNode | null = null; + let slot: 'none' | 'consequence' | 'alternative' = 'none'; + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (!c) continue; + if (!c.isNamed) { + if (c.type === ')' && slot === 'none') slot = 'consequence'; + else if (c.type === 'else') slot = 'alternative'; + continue; + } + if (slot === 'consequence' && !consequence) consequence = c; + else if (slot === 'alternative' && !alternative) alternative = c; + } + return { consequence, alternative }; +} + /** Is a (1-based line, 0-based col) position inside one of `ranges`? */ export function isPositionStaticGated( line: number, @@ -554,13 +593,19 @@ function evalCond( return undefined; } + case 'parenthesized_expression': { + // `(cond)`: transparent. + const inner = node.namedChildren[0]; + if (!inner) return undefined; + return evalCond(inner, localBools, importAliases, lookupBoolsForPath, depth + 1); + } + case 'error_union_type': { - // tree-sitter-zig misparses prefix `!FOO` (boolean negation) as - // `error_union_type` because the same `!` token is used for - // error-union types. We handle the pragmatic case: a single - // resolvable identifier inside an `error_union_type` whose - // immediate parent is an `if_statement` condition position. - // Negate the inner value. + // tree-sitter-zig has no unary `!` node: prefix `!cond` (boolean + // negation) parses as `error_union_type` because the same `!` token + // introduces error-union types. In condition position that reading is + // never a type, so negate whatever the operand folds to: an + // identifier, a literal, or a parenthesized compound like `!(A and B)`. const inner = node.namedChildren[0]; if (!inner) return undefined; const v = evalCond(inner, localBools, importAliases, lookupBoolsForPath, depth + 1); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 425c2d141..16180b82a 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -7522,6 +7522,9 @@ export class LocalBackend { filePath: edge.filePath, relationType, confidence: effectiveConfidence, + // Surfaced, never acted on: traversal and ranking ignore the flag + // (see GraphRelationship.staticGated). Absent = live or unmodelled. + ...(edge.staticGated === true ? { staticGated: true } : {}), }); } } catch (e) { diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 8e5c25682..d68b5b70b 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -487,7 +487,7 @@ Output includes: - summary: direct callers, processes affected, modules affected - affected_processes: which execution flows break and at which step - affected_modules: which functional areas are hit (direct vs indirect; classification-unavailable when that secondary query fails) -- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list. +- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list. An item carries staticGated:true only when the edge that reached it is provably unreachable at compile time from the indexed source (today: Zig calls inside an 'if (CONST_FALSE)' body or the else of 'if (CONST_TRUE)'); the field is absent when the edge is live or the language does not model it. Traversal and risk do NOT filter or rank on it: it is metadata for the caller to weigh. - epistemic: 'exact' | 'lower-bound' — whether impactedCount is the whole story. 'lower-bound' means the walk provably missed callers, so the count is a floor. Absent only on skipped probes (ambiguous-candidate lists, group fan-out). - boundaries: string[] — one plain-language sentence per reason the count is short. Prose for humans; branch on causes instead. - causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences: diff --git a/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig index 5cb821b63..bcc700a04 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig @@ -178,6 +178,33 @@ pub fn run() void { if (UPGRADERS_ENABLED) { live_and_gated_same_callee(); } + + // Negation and parentheses (PR #3161 tri-review, finding 4). tree-sitter-zig + // parses prefix `!` as `error_union_type`; the evaluator negates the operand. + if (!DEBUG) { + gated_not_true(); + } + if (!UPGRADERS_ENABLED) { + live_not_false(); + } + if (!(DEBUG and DEBUG)) { + gated_not_paren_and(); + } + if ((UPGRADERS_ENABLED)) { + gated_paren_ident(); + } + + // `if` as an EXPRESSION is a different grammar node (`if_expression`) + // with no `else_clause` wrapper (PR #3161 tri-review, finding 5). + const e1 = if (UPGRADERS_ENABLED) gated_expr_then() else live_expr_else(); + const e2 = if (DEBUG) live_expr_then() else gated_expr_else(); + const e3 = if (UPGRADERS_ENABLED) blk: { + gated_expr_block(); + break :blk 1; + } else 2; + _ = e1; + _ = e2; + _ = e3; } fn live_unconditional() void { @@ -330,3 +357,39 @@ fn live_and_gated_same_callee() void { fn gated_then_live_same_callee() void { _ = 1; } + +fn gated_not_true() void { + _ = 1; +} + +fn live_not_false() void { + _ = 1; +} + +fn gated_not_paren_and() void { + _ = 1; +} + +fn gated_paren_ident() void { + _ = 1; +} + +fn gated_expr_then() i32 { + return 1; +} + +fn live_expr_else() i32 { + return 2; +} + +fn live_expr_then() i32 { + return 3; +} + +fn gated_expr_else() i32 { + return 4; +} + +fn gated_expr_block() void { + _ = 1; +} diff --git a/gitnexus/test/integration/resolvers/zig-static-gating.test.ts b/gitnexus/test/integration/resolvers/zig-static-gating.test.ts index cc2e96461..92526c157 100644 --- a/gitnexus/test/integration/resolvers/zig-static-gating.test.ts +++ b/gitnexus/test/integration/resolvers/zig-static-gating.test.ts @@ -52,6 +52,36 @@ describe('Zig static-gated edges', () => { expect(isGated('gated_then_live_same_callee')).toBe(false); }); + it('tags `if (!TRUE_CONST)` (negation of a true constant is dead)', () => { + expect(isGated('gated_not_true')).toBe(true); + }); + + it('does NOT tag `if (!FALSE_CONST)` (negation of a false constant is live)', () => { + expect(isGated('live_not_false')).toBe(false); + }); + + it('tags `if (!(TRUE and TRUE))` (negated parenthesized compound)', () => { + expect(isGated('gated_not_paren_and')).toBe(true); + }); + + it('tags `if ((FALSE_CONST))` (parentheses are transparent)', () => { + expect(isGated('gated_paren_ident')).toBe(true); + }); + + it('tags the THEN arm of an if-EXPRESSION `x = if (FALSE) a() else b()`', () => { + expect(isGated('gated_expr_then')).toBe(true); + expect(isGated('live_expr_else')).toBe(false); + }); + + it('tags the ELSE arm of an if-EXPRESSION `x = if (TRUE) a() else b()`', () => { + expect(isGated('live_expr_then')).toBe(false); + expect(isGated('gated_expr_else')).toBe(true); + }); + + it('tags calls inside a labeled-block THEN arm of an if-expression', () => { + expect(isGated('gated_expr_block')).toBe(true); + }); + it('does NOT tag unconditional calls', () => { expect(isGated('live_unconditional')).toBe(false); }); @@ -149,7 +179,7 @@ describe('Zig static-gated edges', () => { // parse worker with only `{ path, content }` in hand — no sibling sources — // so v1 stamps file-local constants only. Re-enable once the emitter can // see imported files (see PR description, "Cross-file constants"). - it.skip('tags `if (cfg.FOO)` cross-file when FOO is false in cfg.zig', () => { + it.skip('tags `if (cfg.FOO)` cross-file when FOO is false in cfg.zig (tracked: #3162)', () => { expect(isGated('gated_cross_file_foo')).toBe(true); }); @@ -157,7 +187,7 @@ describe('Zig static-gated edges', () => { expect(isGated('live_cross_file_bar')).toBe(false); }); - it.skip('tags the ELSE branch of `if (cfg.BAR)` when BAR is true', () => { + it.skip('tags the ELSE branch of `if (cfg.BAR)` when BAR is true (tracked: #3162)', () => { expect(isGated('gated_cross_file_else')).toBe(true); });