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.
This commit is contained in:
Garrett Griffin-Morales 2026-09-03 09:38:04 -04:00
parent 932d937085
commit e6d14edab8
4 changed files with 990 additions and 0 deletions

View file

@ -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<string, boolean>;
/**
* 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<string, string>;
/**
* 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 = <expr>` 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<string, boolean>();
const aliases = new Map<string, string>();
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<string, boolean>,
aliases: ReadonlyMap<string, string>,
): 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<string>();
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<string>,
buildZon: ZigBuildZonConfig | null | undefined,
): ZigImportAliasMap {
const out = new Map<string, string>();
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<string, string> {
const out = new Map<string, string>();
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<string>,
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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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);
});
});