mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-06 08:16:02 +00:00
feat(zig): surface staticGated on impact byDepth items; gate if-expressions and negated/parenthesized conditions
Addresses the tri-review on #3161. - impact: the depth traversal already selected r.staticGated but dropped it when building the byDepth item. Forward it (present only when true) and document the field on the impact tool's byDepth contract. Traversal and ranking still do not act on it; that stays opt-in for consumers. - zig-static-gating: walk `if_expression` (`const x = if (c) a() else b();`) in addition to `if_statement`. The expression form has no field names and no else_clause wrapper, so the arms are located positionally (`ifExpressionArms`). Labeled-block arms are covered. - evalCond: `parenthesized_expression` is transparent, so `!(A and B)` and `((FLAG))` fold. Prefix `!` has no unary node in tree-sitter-zig; the header now says exactly which shapes fold instead of "simple negation". - fixture + tests: nine new cases (negation x2, parentheses x2, if-expression then/else/labeled-block x5). Cross-file `@import` constants remain skipped and now cite the tracking issue #3162. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ciQ3MTjpQXkCoNntZR9zG
This commit is contained in:
parent
dda4c65749
commit
b77cb4ed33
5 changed files with 155 additions and 14 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue