From e6d14edab85f08592622ccfe7191fa34c9138ff8 Mon Sep 17 00:00:00 2001 From: Garrett Griffin-Morales Date: Thu, 3 Sep 2026 09:38:04 -0400 Subject: [PATCH] feat(zig): static-gating analysis module + fixture (ported from feat/zig-static-gated-edges-v2) Squashes c6fe922c, 2f3c8e9e, fab088f4, 9b58af74, aef2ae83, 86b892ef, d5657861, f3780b3a: file-local comptime bool constants, cross-file flag resolution via the @import alias map, re-aliased const chains, == / != against known bools, else / else-if branch awareness. The module is self-contained; the hooks that call it land in the next commit. --- .../call-extractors/zig-static-gating.ts | 532 ++++++++++++++++++ .../zig-static-gating/src/cfg.zig | 4 + .../zig-static-gating/src/main.zig | 297 ++++++++++ .../resolvers/zig-static-gating.test.ts | 157 ++++++ 4 files changed, 990 insertions(+) create mode 100644 gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/cfg.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig create mode 100644 gitnexus/test/integration/resolvers/zig-static-gating.test.ts diff --git a/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts b/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts new file mode 100644 index 000000000..649bba703 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts @@ -0,0 +1,532 @@ +// gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts + +/** + * 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 + * 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. + * + * Conservative by design: we only tag an edge when we can prove the + * gating expression evaluates to `false`. Anything ambiguous → live. + * + * v1 supports two scopes of resolution: + * + * (a) **File-local** consts (`pub const FOO = false;`) — built per + * Zig file once and cached. + * (b) **One-hop @import alias** (`const cfg = @import("./cfg.zig"); + * if (cfg.FOO) { ... }`) — resolved via the existing Zig import + * resolver, with a separate per-file alias map. + * + * Out of scope for v1: + * - Multi-hop: `cfg.sub.FOO` (we only handle one identifier dot one). + * - Re-exported consts across multiple files. + * - Runtime-evaluated bools (`const FOO = computeIt();`). + * - Field re-aliasing (`const FOO = OTHER;`). + * + * Wire-up: see ./configs/zig.ts — the call extractor's + * `extractLanguageCallSite` hook walks ancestors and consults the + * resolver to decide whether to emit `staticGated: true`. + */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { resolveZigImportInternal } from '../import-resolvers/zig.js'; +import type { ZigBuildZonConfig } from '../language-config.js'; + +/** Maximum number of `if_statement` ancestors we walk above a call. */ +const MAX_IF_ANCESTORS = 5; + +/** Maximum recursion depth when evaluating a boolean condition expression. */ +const MAX_COND_DEPTH = 4; + +/** A definite truth value, or `undefined` for "unknown / cannot prove". */ +type TriBool = boolean | undefined; + +/** + * Per-file table of comptime-known booleans: + * `pub const FOO: bool = false;` → Map { 'FOO' → false } + * `pub const BAR = true;` → Map { 'BAR' → true } + * + * Constants whose RHS is anything other than a bare `boolean` literal + * (e.g. function calls, struct accesses) are intentionally absent — + * they resolve to `undefined`. + */ +export type ZigBoolConstMap = ReadonlyMap; + +/** + * Per-file `@import` alias map, mapping a local identifier to the + * resolved absolute file path of the imported module. Used for the + * `cfg.FOO` cross-file lookup pattern. + * + * `const cfg = @import("./cfg.zig");` → Map { 'cfg' → 'src/cfg.zig' } + */ +export type ZigImportAliasMap = ReadonlyMap; + +/** + * Cross-file lookup: given an alias-resolved file path, return that + * file's known-bool map. Implemented by the caller — the resolver + * itself stays stateless. + */ +export type ZigBoolConstLookup = (filePath: string) => ZigBoolConstMap | undefined; + +// --------------------------------------------------------------------------- +// Phase 2: per-file extraction +// --------------------------------------------------------------------------- + +/** Cap on alias-chain hops walked when resolving `const A = B; const B = C; ...`. */ +const MAX_ALIAS_HOPS = 5; + +/** + * Walk the top-level of a Zig source file and collect known-bool + * constants. Two passes: + * + * Pass 1: collect every top-level `const X = ` where the RHS + * is either a `boolean` literal (recorded in `literals`) or + * a single `identifier` (recorded in `aliases`). + * Pass 2: walk each alias entry up to `MAX_ALIAS_HOPS` hops; if the + * chain terminates at a known-bool literal, record `X` with + * the literal's value. Cycles, chains exceeding the cap, and + * chains exiting file scope (unknown identifier at the root) + * bail to "unknown". + * + * Only `const` decls qualify (not `var`). The function is permissive + * about modifier tokens (`pub`, type annotation) — it reads only the + * identifier name and the RHS expression shape. + */ +export function buildZigBoolConstMap(rootNode: SyntaxNode): ZigBoolConstMap { + const literals = new Map(); + const aliases = new Map(); + for (const child of rootNode.namedChildren) { + if (child.type !== 'variable_declaration') continue; + const entry = extractBoolConstOrAlias(child); + if (!entry) continue; + if (entry.kind === 'literal') { + literals.set(entry.name, entry.value); + } else { + aliases.set(entry.name, entry.aliasOf); + } + } + + // Pass 2: resolve alias chains. + for (const [name, target] of aliases) { + const resolved = resolveAliasChain(name, target, literals, aliases); + if (resolved !== undefined) { + literals.set(name, resolved); + } + } + + return literals; +} + +function resolveAliasChain( + start: string, + firstTarget: string, + literals: ReadonlyMap, + aliases: ReadonlyMap, +): boolean | undefined { + // Walk: start → firstTarget → aliases.get(firstTarget) → ... up to MAX_ALIAS_HOPS. + // Cycle protection via a visited set seeded with `start` itself. + const visited = new Set(); + visited.add(start); + let current: string = firstTarget; + for (let hop = 0; hop < MAX_ALIAS_HOPS; hop++) { + if (visited.has(current)) return undefined; // cycle + visited.add(current); + const lit = literals.get(current); + if (lit !== undefined) return lit; + const next = aliases.get(current); + if (next === undefined) return undefined; // chain exits file scope or hits unknown + current = next; + } + return undefined; // hop cap exceeded +} + +type RawDecl = + | { kind: 'literal'; name: string; value: boolean } + | { kind: 'alias'; name: string; aliasOf: string }; + +function extractBoolConstOrAlias(decl: SyntaxNode): RawDecl | null { + // Require `const` and exclude `var`. tree-sitter-zig parses both with + // the same `variable_declaration` shape; the qualifier is an anonymous + // child token. A `pub var FOO = false;` is mutable global state — its + // initial value is NOT a comptime constant and must not feed gating. + let isConst = false; + let isVar = false; + for (let i = 0; i < decl.childCount; i++) { + const c = decl.child(i); + if (!c || c.isNamed) continue; + if (c.type === 'const') isConst = true; + else if (c.type === 'var') isVar = true; + } + if (!isConst || isVar) return null; + + let name: string | undefined; + let value: boolean | undefined; + let aliasOf: string | undefined; + + for (const c of decl.namedChildren) { + if (c.type === 'identifier' && name === undefined) { + name = c.text; + continue; + } + if (c.type === 'boolean') { + const t = c.text; + if (t === 'true') value = true; + else if (t === 'false') value = false; + } else if (c.type === 'identifier' && name !== undefined && aliasOf === undefined) { + // Second `identifier` child is the RHS alias target: + // `const B = A;` → name='B', aliasOf='A'. + aliasOf = c.text; + } + } + + if (name === undefined) return null; + if (value !== undefined) return { kind: 'literal', name, value }; + if (aliasOf !== undefined) return { kind: 'alias', name, aliasOf }; + return null; +} + +/** + * Walk the top-level of a Zig source file and collect `@import` alias + * declarations: + * + * `const cfg = @import("./cfg.zig");` → Map { 'cfg' → 'src/cfg.zig' } + * + * Imports that don't resolve to a file in the repo (`@import("std")`, + * package deps without a build.zig.zon path, etc.) are skipped. + */ +export function buildZigImportAliasMap( + rootNode: SyntaxNode, + currentFilePath: string, + allFilePaths: Set, + buildZon: ZigBuildZonConfig | null | undefined, +): ZigImportAliasMap { + const out = new Map(); + for (const child of rootNode.namedChildren) { + if (child.type !== 'variable_declaration') continue; + const entry = extractImportAlias(child, currentFilePath, allFilePaths, buildZon); + if (entry) out.set(entry.name, entry.target); + } + return out; +} + +/** + * Worker-side variant of `buildZigImportAliasMap` that emits the raw + * `@import("...")` path strings without resolving them — workers don't + * have access to the global file list / build.zig.zon needed for path + * resolution. The main thread later resolves each entry via + * `resolveZigImportInternal` when aggregating cross-file gating context. + * + * `const cfg = @import("./cfg.zig");` → Map { 'cfg' → './cfg.zig' } + */ +export function buildZigRawImportAliasMap(rootNode: SyntaxNode): ReadonlyMap { + const out = new Map(); + for (const child of rootNode.namedChildren) { + if (child.type !== 'variable_declaration') continue; + const entry = extractRawImportAlias(child); + if (entry) out.set(entry.name, entry.importPath); + } + return out; +} + +function extractRawImportAlias(decl: SyntaxNode): { name: string; importPath: string } | null { + let isConst = false; + let isVar = false; + for (let i = 0; i < decl.childCount; i++) { + const c = decl.child(i); + if (!c || c.isNamed) continue; + if (c.type === 'const') isConst = true; + else if (c.type === 'var') isVar = true; + } + if (!isConst || isVar) return null; + + let name: string | undefined; + let importPath: string | undefined; + + for (const c of decl.namedChildren) { + if (c.type === 'identifier' && name === undefined) { + name = c.text; + continue; + } + if (c.type === 'builtin_function') { + const ident = c.namedChildren.find((cc) => cc.type === 'builtin_identifier'); + if (!ident || ident.text !== '@import') continue; + const args = c.namedChildren.find((cc) => cc.type === 'arguments'); + if (!args) continue; + const str = args.namedChildren.find((cc) => cc.type === 'string'); + if (!str) continue; + const content = str.namedChildren.find((cc) => cc.type === 'string_content'); + if (content) importPath = content.text; + } + } + + if (name === undefined || importPath === undefined) return null; + return { name, importPath }; +} + +function extractImportAlias( + decl: SyntaxNode, + currentFilePath: string, + allFilePaths: Set, + buildZon: ZigBuildZonConfig | null | undefined, +): { name: string; target: string } | null { + // Require `const` (the idiomatic spelling for `@import` aliases). + // `var cfg = @import(...)` is technically legal but would let a + // later assignment shadow the import; we don't follow assignments. + let isConst = false; + let isVar = false; + for (let i = 0; i < decl.childCount; i++) { + const c = decl.child(i); + if (!c || c.isNamed) continue; + if (c.type === 'const') isConst = true; + else if (c.type === 'var') isVar = true; + } + if (!isConst || isVar) return null; + + let name: string | undefined; + let importPath: string | undefined; + + for (const c of decl.namedChildren) { + if (c.type === 'identifier' && name === undefined) { + name = c.text; + continue; + } + if (c.type === 'builtin_function') { + // Look for @import("...") shape. + const ident = c.namedChildren.find((cc) => cc.type === 'builtin_identifier'); + if (!ident || ident.text !== '@import') continue; + const args = c.namedChildren.find((cc) => cc.type === 'arguments'); + if (!args) continue; + const str = args.namedChildren.find((cc) => cc.type === 'string'); + if (!str) continue; + const content = str.namedChildren.find((cc) => cc.type === 'string_content'); + if (content) importPath = content.text; + } + } + + if (name === undefined || importPath === undefined) return null; + const resolved = resolveZigImportInternal(currentFilePath, importPath, allFilePaths, buildZon); + if (!resolved) return null; + return { name, target: resolved }; +} + +// --------------------------------------------------------------------------- +// Phase 3: ancestor walk + condition evaluation +// --------------------------------------------------------------------------- + +/** + * Decide whether a call expression sits inside a statically-dead + * branch of any enclosing `if` statement. + * + * Walks up to {@link MAX_IF_ANCESTORS} `if_statement` ancestors, + * tracking the descent direction (consequence vs alternative) so we + * can flag the dead branch correctly: + * + * cond=false, via body → DEAD (gated) + * cond=true, via else_clause → DEAD (gated) + * cond=true, via body → live (no signal from this ancestor) + * cond=false, via else_clause → live (no signal) + * cond=unknown → no signal + * + * Returns `true` if ANY ancestor proves the call is dead. `else if` + * chains nest naturally as `else_clause → if_statement → ...`, so + * each level applies the rule independently and the walker visits + * them all on the way up. + */ +export function isCallStaticGated( + callNode: SyntaxNode, + localBools: ZigBoolConstMap, + importAliases: ZigImportAliasMap, + lookupBoolsForPath: ZigBoolConstLookup, +): boolean { + let child: SyntaxNode = callNode; + let parent: SyntaxNode | null = callNode.parent; + let ifCount = 0; + while (parent && ifCount < MAX_IF_ANCESTORS) { + if (parent.type === 'if_statement') { + ifCount++; + const direction = ifBranchDirection(parent, child); + if (direction !== 'condition') { + const cond = findIfCondition(parent); + if (cond) { + const result = evalCond(cond, localBools, importAliases, lookupBoolsForPath, 0); + if (result === false && direction === 'consequence') return true; + if (result === true && direction === 'alternative') return true; + } + } + } + child = parent; + parent = parent.parent; + } + return false; +} + +/** + * Given an `if_statement` and a direct child node we just ascended + * from, classify which slot of the if the child sits in. + * + * Returns `'consequence'` when the child is the body (the THEN + * branch), `'alternative'` when the child is the `else_clause` (or + * the call lives inside its subtree, which it does because we walked + * up through it), and `'condition'` when — pathologically — the call + * lives inside the condition expression itself. Conditions don't + * gate themselves, so the caller treats `'condition'` as no-op. + */ +function ifBranchDirection( + ifNode: SyntaxNode, + ascendedFrom: SyntaxNode, +): 'consequence' | 'alternative' | 'condition' { + if (ascendedFrom.type === 'else_clause') return 'alternative'; + const body = ifNode.childForFieldName('body'); + // tree-sitter wraps each accessor call in a fresh JS object, so + // reference equality (`a === b`) is unreliable even when both + // wrappers point at the same underlying syntax node. Compare by + // the stable numeric `id` instead — every wrapper for one node + // exposes the same `id`. + if (body && nodesEqual(body, ascendedFrom)) return 'consequence'; + // Fall through: must be the condition expression. + return 'condition'; +} + +/** Reference-equal-by-stable-id check for tree-sitter syntax nodes. */ +function nodesEqual(a: SyntaxNode, b: SyntaxNode): boolean { + // The native binding exposes a numeric `id` per node. TypeScript's + // `SyntaxNode` typing doesn't surface it, so we widen to `unknown` + // and read defensively — if the runtime lacks `id`, fall back to + // structural equality on byte range + type. + const aId = (a as unknown as { id?: number }).id; + const bId = (b as unknown as { id?: number }).id; + if (typeof aId === 'number' && typeof bId === 'number') return aId === bId; + return ( + a.type === b.type && + a.startIndex === b.startIndex && + a.endIndex === b.endIndex + ); +} + +/** Pick the condition node out of an `if_statement`. The condition is + * the first named child (the body / else branches come after). */ +function findIfCondition(ifNode: SyntaxNode): SyntaxNode | null { + return ifNode.namedChildren[0] ?? null; +} + +/** + * Evaluate a boolean condition expression to a tribool. + * + * Handles only the shapes we can resolve symbolically; everything else + * returns `undefined` ("unknown — treat as live"). + */ +function evalCond( + node: SyntaxNode, + localBools: ZigBoolConstMap, + importAliases: ZigImportAliasMap, + lookupBoolsForPath: ZigBoolConstLookup, + depth: number, +): TriBool { + if (depth > MAX_COND_DEPTH) return undefined; + + switch (node.type) { + case 'boolean': { + // Bare literal: `if (false)`. + if (node.text === 'true') return true; + if (node.text === 'false') return false; + return undefined; + } + + case 'identifier': { + // Bare flag check: `if (FOO)`. + const v = localBools.get(node.text); + return v === undefined ? undefined : v; + } + + case 'field_expression': { + // `cfg.FOO` — alias hop. + const obj = node.namedChildren[0]; + const member = node.namedChildren[1]; + if ( + obj?.type !== 'identifier' || + member?.type !== 'identifier' + ) { + return undefined; + } + const targetFile = importAliases.get(obj.text); + if (!targetFile) return undefined; + const targetBools = lookupBoolsForPath(targetFile); + if (!targetBools) return undefined; + const v = targetBools.get(member.text); + return v === undefined ? undefined : v; + } + + case 'binary_expression': { + // `lhs and rhs` / `lhs or rhs` / `lhs == rhs` / `lhs != rhs`. + const op = findOperatorToken(node); + const lhs = node.namedChildren[0]; + const rhs = node.namedChildren[1]; + if (!lhs || !rhs) return undefined; + const l = evalCond(lhs, localBools, importAliases, lookupBoolsForPath, depth + 1); + const r = evalCond(rhs, localBools, importAliases, lookupBoolsForPath, depth + 1); + if (op === 'and') { + // `false and *` = false; `* and false` = false. + if (l === false || r === false) return false; + if (l === true && r === true) return true; + return undefined; + } + if (op === 'or') { + // Only `false or false` is provably false. + if (l === false && r === false) return false; + if (l === true || r === true) return true; + return undefined; + } + if (op === '==') { + // Equality folds when both sides are known booleans. + // `FOO == false` ↔ `!FOO`; `FOO == true` ↔ `FOO`. + if (l === undefined || r === undefined) return undefined; + return l === r; + } + if (op === '!=') { + if (l === undefined || r === undefined) return undefined; + return l !== r; + } + // Other comparison ops (<, >, …) — not booleans we can prove. + return undefined; + } + + 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. + const inner = node.namedChildren[0]; + if (!inner) return undefined; + const v = evalCond(inner, localBools, importAliases, lookupBoolsForPath, depth + 1); + if (v === undefined) return undefined; + return !v; + } + + default: + return undefined; + } +} + +/** + * Pull the textual operator (e.g. "and", "or") out of a + * `binary_expression`. In tree-sitter-zig, the operator is an + * anonymous child token whose `type` equals the operator string. + */ +function findOperatorToken(binExpr: SyntaxNode): string | undefined { + for (let i = 0; i < binExpr.childCount; i++) { + const c = binExpr.child(i); + if (!c) continue; + if (!c.isNamed) { + // Anonymous tokens for these ops carry their text as the type. + if (c.type === 'and' || c.type === 'or' || c.type === '==' || c.type === '!=') { + return c.type; + } + } + } + return undefined; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/cfg.zig b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/cfg.zig new file mode 100644 index 000000000..9325ab46b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/cfg.zig @@ -0,0 +1,4 @@ +// Cross-file config module. Imported by main.zig as `const cfg = @import("./cfg.zig")`. +pub const FOO: bool = false; +pub const BAR: bool = true; +pub const NOT_A_BOOL: i32 = 42; 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 new file mode 100644 index 000000000..58d1c99cf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig @@ -0,0 +1,297 @@ +// Fixture for static-gated edge detection. +// +// Layout: +// - UPGRADERS_ENABLED (false) gates dead branches that should be tagged. +// - DEBUG (true) gates live branches that should NOT be tagged. +// - LIVE_FLAG (no value resolution) is treated as live. +// - All callees (`gated_*` and `live_*`) live in the same file so the +// CALLS edges resolve cleanly. + +// Cross-file alias — should resolve `cfg.FOO`, `cfg.BAR` against cfg.zig. +const cfg = @import("./cfg.zig"); + +pub const UPGRADERS_ENABLED: bool = false; +pub const DEBUG: bool = true; +pub const FEATURE_X: bool = false; +// `var` (mutable global) — must NOT feed static gating, even though +// the initial value is `false`. `live_under_var` below must stay live. +pub var IS_RUNTIME_FLAG_FALSE: bool = false; + +// Re-aliased const chains. v2: walk up to 5 hops, fold to the literal +// at the chain root. +pub const ALIAS_ONE = UPGRADERS_ENABLED; // → false (1 hop) +pub const ALIAS_TWO = ALIAS_ONE; // → false (2 hops) +pub const ALIAS_THREE = ALIAS_TWO; // → false (3 hops) +pub const ALIAS_TO_UNKNOWN = NOT_DEFINED_ANYWHERE; // → unknown +// Cycle: A→B→A. Both bail to unknown. +pub const CYCLE_A = CYCLE_B; +pub const CYCLE_B = CYCLE_A; +// Alias to a `var` — must NOT resolve (var is not a comptime constant). +pub const ALIAS_TO_VAR = IS_RUNTIME_FLAG_FALSE; + +pub fn run() void { + // Live: not under any if-gate. + live_unconditional(); + + // Gated: simple `if (FALSE)`. + if (UPGRADERS_ENABLED) { + gated_simple(); + } + + // Gated: `if (FALSE and other)` — `false and *` is false. + if (UPGRADERS_ENABLED and DEBUG) { + gated_and_left(); + } + + // Gated: `if (other and FALSE)` — `* and false` is false. + if (DEBUG and UPGRADERS_ENABLED) { + gated_and_right(); + } + + // Gated: `if (FALSE or FALSE)`. + if (UPGRADERS_ENABLED or FEATURE_X) { + gated_or_both_false(); + } + + // Live: `if (FALSE or DEBUG)` — DEBUG is true, so the disjunction is true. + if (UPGRADERS_ENABLED or DEBUG) { + live_or_one_true(); + } + + // Live: `if (DEBUG)` — the constant is true. + if (DEBUG) { + live_under_true_const(); + } + + // Live: condition references an unknown identifier. + if (some_runtime_flag()) { + live_under_unknown(); + } + + // Live: `var` initialized to `false` is mutable global state, not + // a comptime constant — gating must NOT trigger. + if (IS_RUNTIME_FLAG_FALSE) { + live_under_var(); + } + + // Gated: comparison operators against known-bool constants. + // `UPGRADERS_ENABLED == true` ≡ `false`. + if (UPGRADERS_ENABLED == true) { + gated_eq_true(); + } + // `DEBUG == false` ≡ `false` (DEBUG is true, so equality with false is false). + if (DEBUG == false) { + gated_eq_false(); + } + // `UPGRADERS_ENABLED != false` ≡ `false`. + if (UPGRADERS_ENABLED != false) { + gated_neq_false(); + } + // `DEBUG != true` ≡ `false` (DEBUG is true). + if (DEBUG != true) { + gated_neq_true(); + } + // Symmetric: `false == UPGRADERS_ENABLED` ≡ `true` (both false). + if (false == UPGRADERS_ENABLED) { + live_sym_eq(); + } + + // Re-aliased const chains. ALIAS_ONE=ALIAS_TWO=ALIAS_THREE all fold to false. + if (ALIAS_ONE) { + gated_alias_one(); + } + if (ALIAS_TWO) { + gated_alias_two(); + } + if (ALIAS_THREE) { + gated_alias_three(); + } + // Alias to unknown: must NOT resolve. + if (ALIAS_TO_UNKNOWN) { + live_alias_to_unknown(); + } + // Cycle: must NOT resolve, do not infinite-loop. + if (CYCLE_A) { + live_alias_cycle(); + } + // Alias to `var`: must NOT resolve (var is mutable global). + if (ALIAS_TO_VAR) { + live_alias_to_var(); + } + + // Branch awareness: `if (FALSE) { dead } else { live }`. + if (UPGRADERS_ENABLED) { + gated_then_branch(); + } else { + live_else_branch(); + } + + // Inverse: `if (TRUE) { live } else { dead }`. + if (DEBUG) { + live_then_branch(); + } else { + gated_else_branch(); + } + + // else-if chain: A=false → enter else; B=true → take that branch. + // gated_outer_then is dead (A=false). live_chain_mid is live (B=true). + // dead_chain_tail (in inner else) is also dead (B=true makes its branch unreachable). + if (UPGRADERS_ENABLED) { + gated_outer_then(); + } else if (DEBUG) { + live_chain_mid(); + } else { + gated_chain_tail(); + } + + // Cross-file flag resolution: `cfg.FOO == false` (defined in cfg.zig). + if (cfg.FOO) { + gated_cross_file_foo(); + } + // Cross-file: `cfg.BAR == true` — THEN branch is live, ELSE branch is gated. + if (cfg.BAR) { + live_cross_file_bar(); + } else { + gated_cross_file_else(); + } + // Cross-file unknown member: `cfg.UNDEFINED_NAME` resolves to unknown. + if (cfg.UNDEFINED_NAME) { + live_cross_file_undefined(); + } + // Cross-file non-bool: `cfg.NOT_A_BOOL` is an i32 — must NOT resolve. + if (cfg.NOT_A_BOOL != 0) { + live_cross_file_not_bool(); + } +} + +fn live_unconditional() void { + _ = 1; +} + +fn gated_simple() void { + _ = 1; +} + +fn gated_and_left() void { + _ = 1; +} + +fn gated_and_right() void { + _ = 1; +} + +fn gated_or_both_false() void { + _ = 1; +} + +fn live_or_one_true() void { + _ = 1; +} + +fn live_under_true_const() void { + _ = 1; +} + +fn live_under_unknown() void { + _ = 1; +} + +fn live_under_var() void { + _ = 1; +} + +fn gated_eq_true() void { + _ = 1; +} + +fn gated_eq_false() void { + _ = 1; +} + +fn gated_neq_false() void { + _ = 1; +} + +fn gated_neq_true() void { + _ = 1; +} + +fn live_sym_eq() void { + _ = 1; +} + +fn gated_alias_one() void { + _ = 1; +} + +fn gated_alias_two() void { + _ = 1; +} + +fn gated_alias_three() void { + _ = 1; +} + +fn live_alias_to_unknown() void { + _ = 1; +} + +fn live_alias_cycle() void { + _ = 1; +} + +fn live_alias_to_var() void { + _ = 1; +} + +fn gated_then_branch() void { + _ = 1; +} + +fn live_else_branch() void { + _ = 1; +} + +fn live_then_branch() void { + _ = 1; +} + +fn gated_else_branch() void { + _ = 1; +} + +fn gated_outer_then() void { + _ = 1; +} + +fn live_chain_mid() void { + _ = 1; +} + +fn gated_chain_tail() void { + _ = 1; +} + +fn gated_cross_file_foo() void { + _ = 1; +} + +fn live_cross_file_bar() void { + _ = 1; +} + +fn gated_cross_file_else() void { + _ = 1; +} + +fn live_cross_file_undefined() void { + _ = 1; +} + +fn live_cross_file_not_bool() void { + _ = 1; +} + +fn some_runtime_flag() bool { + return false; +} diff --git a/gitnexus/test/integration/resolvers/zig-static-gating.test.ts b/gitnexus/test/integration/resolvers/zig-static-gating.test.ts new file mode 100644 index 000000000..10fa502b6 --- /dev/null +++ b/gitnexus/test/integration/resolvers/zig-static-gating.test.ts @@ -0,0 +1,157 @@ +/** + * Zig: static-gated CALLS edges. + * + * Verifies that calls inside `if (CONST_FALSE)` branches (and trivial + * boolean-and / boolean-or extensions) get tagged with + * `staticGated: true` on the emitted CALLS edge, while calls outside + * such branches keep `staticGated` falsy. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('Zig static-gated edges', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'zig-static-gating'), + () => {}, + ); + }, 60000); + + function isGated(callee: string): boolean | undefined { + const calls = getRelationships(result, 'CALLS').filter((e) => e.target === callee); + if (calls.length === 0) return undefined; + // If any caller-edge to `callee` carries staticGated, treat as gated. + return calls.some((c) => c.rel.staticGated === true); + } + + it('tags calls inside `if (UPGRADERS_ENABLED)` as staticGated', () => { + expect(isGated('gated_simple')).toBe(true); + }); + + it('tags `if (FALSE and other)` as staticGated (and-left)', () => { + expect(isGated('gated_and_left')).toBe(true); + }); + + it('tags `if (other and FALSE)` as staticGated (and-right)', () => { + expect(isGated('gated_and_right')).toBe(true); + }); + + it('tags `if (FALSE or FALSE)` as staticGated', () => { + expect(isGated('gated_or_both_false')).toBe(true); + }); + + it('does NOT tag unconditional calls', () => { + expect(isGated('live_unconditional')).toBe(false); + }); + + it('does NOT tag calls under `if (TRUE_CONST)`', () => { + expect(isGated('live_under_true_const')).toBe(false); + }); + + it('does NOT tag `if (FALSE or TRUE)` (disjunction is true)', () => { + expect(isGated('live_or_one_true')).toBe(false); + }); + + it('does NOT tag calls under unknown / runtime conditions', () => { + expect(isGated('live_under_unknown')).toBe(false); + }); + + it('does NOT tag calls under `if (var FOO = false)` (var is mutable global, not const)', () => { + expect(isGated('live_under_var')).toBe(false); + }); + + it('tags `if (FOO == true)` when FOO is false', () => { + expect(isGated('gated_eq_true')).toBe(true); + }); + + it('tags `if (FOO == false)` when FOO is true', () => { + expect(isGated('gated_eq_false')).toBe(true); + }); + + it('tags `if (FOO != false)` when FOO is false', () => { + expect(isGated('gated_neq_false')).toBe(true); + }); + + it('tags `if (FOO != true)` when FOO is true', () => { + expect(isGated('gated_neq_true')).toBe(true); + }); + + it('does NOT tag `if (false == FOO)` when FOO is false (provably TRUE)', () => { + expect(isGated('live_sym_eq')).toBe(false); + }); + + it('tags re-aliased const chain (1 hop)', () => { + expect(isGated('gated_alias_one')).toBe(true); + }); + + it('tags re-aliased const chain (2 hops)', () => { + expect(isGated('gated_alias_two')).toBe(true); + }); + + it('tags re-aliased const chain (3 hops)', () => { + expect(isGated('gated_alias_three')).toBe(true); + }); + + it('does NOT tag alias chain that exits to unknown identifier', () => { + expect(isGated('live_alias_to_unknown')).toBe(false); + }); + + it('does NOT tag (and does not infinite-loop on) alias cycles', () => { + expect(isGated('live_alias_cycle')).toBe(false); + }); + + it('does NOT tag alias whose chain root is a `var` (not a const)', () => { + expect(isGated('live_alias_to_var')).toBe(false); + }); + + it('tags `if (FALSE) { dead }` THEN branch as gated', () => { + expect(isGated('gated_then_branch')).toBe(true); + }); + + it('does NOT tag the ELSE branch of `if (FALSE)` (it is live)', () => { + expect(isGated('live_else_branch')).toBe(false); + }); + + it('does NOT tag the THEN branch of `if (TRUE)` (it is live)', () => { + expect(isGated('live_then_branch')).toBe(false); + }); + + it('tags the ELSE branch of `if (TRUE)` as gated', () => { + expect(isGated('gated_else_branch')).toBe(true); + }); + + it('tags `if (FALSE) { dead }` in else-if chain as gated', () => { + expect(isGated('gated_outer_then')).toBe(true); + }); + + it('does NOT tag the live arm of an else-if chain', () => { + expect(isGated('live_chain_mid')).toBe(false); + }); + + it('tags the trailing else of `else if (TRUE)` as gated', () => { + expect(isGated('gated_chain_tail')).toBe(true); + }); + + it('tags `if (cfg.FOO)` cross-file when FOO is false in cfg.zig', () => { + expect(isGated('gated_cross_file_foo')).toBe(true); + }); + + it('does NOT tag the THEN branch of `if (cfg.BAR)` when BAR is true', () => { + expect(isGated('live_cross_file_bar')).toBe(false); + }); + + it('tags the ELSE branch of `if (cfg.BAR)` when BAR is true', () => { + expect(isGated('gated_cross_file_else')).toBe(true); + }); + + it('does NOT tag `cfg.UNDEFINED_NAME` (member not found in imported file)', () => { + expect(isGated('live_cross_file_undefined')).toBe(false); + }); + + it('does NOT tag `cfg.NOT_A_BOOL != 0` (imported decl is not a bool literal)', () => { + expect(isGated('live_cross_file_not_bool')).toBe(false); + }); +});