fix(zig): resolve cross-file static gates (#3185)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled

* fix(zig): resolve cross-file static gates

* Fix Zig workspace import alias enrichment

* Handle extensionless Zig workspace imports

* Reuse Zig import resolution for static gates

* Document Zig workspace static gating

* Harden Zig workspace reference enrichment

* Benchmark Zig cross-file static gating

* Enforce linear Zig benchmark scaling

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
Parafee41 2026-09-07 14:19:30 +08:00 • committed by GitHub
parent 8f006bd759
commit 1c1cbf111e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 400 additions and 15 deletions

View file

@ -745,6 +745,13 @@ jobs:
run: node --import tsx bench/scope-emission/measure.mjs --check
working-directory: gitnexus
- name: Zig cross-file static-gating guards (#3162)
if: ${{ !cancelled() }}
# Build-free: fingerprints cross-file dead-call classification and
# guards the workspace enrichment pass across file-count scaling.
run: node --import tsx bench/zig-cross-file-resolution/measure.mjs --check
working-directory: gitnexus
- name: CFG construction time / disk / memory guards (#2081 M1)
if: ${{ !cancelled() }}
# Build-free: asserts collectFunctionCfgs output is unchanged

View file

@ -0,0 +1,16 @@
{
"_comment": "Correctness counts are exact. Timing budgets are deliberately loose and only guard large regressions in the post-extraction Zig workspace pass.",
"small": {
"modules": 40,
"calls_per_module": 12,
"gated_calls": 480,
"ms_budget": 1000
},
"large": {
"modules": 160,
"calls_per_module": 12,
"gated_calls": 1920,
"ms_budget": 4000
},
"linear_scaling_slack": 1.375
}

View file

@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* Build-free scaling and correctness guard for Zig cross-file static gates.
*
* The workspace pass parses every indexed Zig file, resolves direct @import
* aliases, then applies sibling boolean constants to call sites. This bench
* makes both relevant axes explicit: file count and calls per importer. It
* also fingerprints the number of calls classified dead, so a fast no-op
* implementation cannot pass the timing gate.
*
* Usage:
* node --import tsx bench/zig-cross-file-resolution/measure.mjs
* node --import tsx bench/zig-cross-file-resolution/measure.mjs --check
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { performance } from 'node:perf_hooks';
import { populateZigWorkspaceStaticGating } from '../../src/core/ingestion/languages/zig/workspace-static-gating.ts';
const HERE = dirname(fileURLToPath(import.meta.url));
const SMALL_MODULES = 40;
const LARGE_MODULES = 160;
const CALLS_PER_MODULE = 12;
const REPS = 7;
const range = (line, col) => ({ startLine: line, startCol: col, endLine: line, endCol: col + 4 });
function corpus(modules) {
const parsedFiles = [];
const fileContents = new Map();
for (let i = 0; i < modules; i++) {
const cfgPath = `bench/cfg${i}.zig`;
const appPath = `bench/app${i}.zig`;
fileContents.set(cfgPath, 'pub const ENABLED = false;\n');
fileContents.set(
appPath,
`const cfg = @import(\"./cfg${i}.zig\");\n` +
Array.from(
{ length: CALLS_PER_MODULE },
(_, j) => `pub fn run${j}() void { if (cfg.ENABLED) dead${j}(); }`,
).join('\n'),
);
parsedFiles.push(Object.freeze({ filePath: cfgPath, referenceSites: Object.freeze([]) }));
parsedFiles.push(
Object.freeze({
filePath: appPath,
referenceSites: Object.freeze(
Array.from({ length: CALLS_PER_MODULE }, (_, j) => ({
kind: 'call',
name: `dead${j}`,
atRange: range(j + 2, 44),
})),
),
}),
);
}
return { parsedFiles, fileContents };
}
function run(modules) {
const { parsedFiles, fileContents } = corpus(modules);
populateZigWorkspaceStaticGating(parsedFiles, { fileContents });
let gatedCalls = 0;
for (const file of parsedFiles) {
for (const site of file.referenceSites) if (site.staticGated === true) gatedCalls++;
}
return gatedCalls;
}
function measure(modules) {
run(modules);
let bestMs = Infinity;
let gatedCalls = 0;
for (let i = 0; i < REPS; i++) {
const start = performance.now();
gatedCalls = run(modules);
bestMs = Math.min(bestMs, performance.now() - start);
}
return {
modules,
calls_per_module: CALLS_PER_MODULE,
gated_calls: gatedCalls,
min_ms: Number(bestMs.toFixed(2)),
};
}
const report = { small: measure(SMALL_MODULES), large: measure(LARGE_MODULES) };
report.workload_ratio = LARGE_MODULES / SMALL_MODULES;
report.scaling_ratio = Number(
(report.large.min_ms / Math.max(report.small.min_ms, 0.01)).toFixed(3),
);
report.linear_factor = Number((report.scaling_ratio / report.workload_ratio).toFixed(3));
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
const baseline = JSON.parse(readFileSync(join(HERE, 'baseline.json'), 'utf8'));
const failures = [];
const requirePositiveNumber = (path, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
failures.push(`${path}: expected a finite positive number, got ${JSON.stringify(value)}`);
return false;
}
return true;
};
for (const arm of ['small', 'large']) {
for (const key of ['modules', 'calls_per_module', 'gated_calls']) {
if (report[arm][key] !== baseline[arm][key]) {
failures.push(`${arm}.${key}: expected ${baseline[arm][key]}, got ${report[arm][key]}`);
}
}
if (
requirePositiveNumber(`${arm}.ms_budget`, baseline[arm].ms_budget) &&
report[arm].min_ms > baseline[arm].ms_budget
) {
failures.push(`${arm}.min_ms ${report[arm].min_ms} exceeds budget ${baseline[arm].ms_budget}`);
}
}
if (
requirePositiveNumber('linear_scaling_slack', baseline.linear_scaling_slack) &&
report.linear_factor > baseline.linear_scaling_slack
) {
failures.push(
`linear_factor ${report.linear_factor} exceeds slack ${baseline.linear_scaling_slack} ` +
`(runtime ${report.scaling_ratio}x for ${report.workload_ratio}x work)`,
);
}
console.log(JSON.stringify(report, null, 2));
if (failures.length > 0) {
console.error('[zig-cross-file-resolution --check] FAIL');
for (const failure of failures) console.error(` - ${failure}`);
process.exit(1);
}
console.log('[zig-cross-file-resolution --check] PASS');

View file

@ -13,17 +13,14 @@
* Conservative by design: we only tag an edge when we can prove the
* gating expression evaluates to `false`. Anything ambiguous → live.
*
* Scope of v1:
* Supported scope:
*
* (a) **File-local** consts (`pub const FOO = false;`, plus const-to-const
* aliases up to 5 hops), built once per file by `buildZigBoolConstMap`.
* (b) **Cross-file** (`const cfg = @import("./cfg.zig"); if (cfg.FOO)`) is
* NOT resolved yet. The evaluator keeps the seam for it (`importAliases`
* + `lookupBoolsForPath`, consumed by the `field_expression` case), but
* the only caller passes an empty alias map and a lookup that always
* returns `undefined`, because the capture emitter runs in the parse
* worker and sees only the current file. Tracked in #3162. Until then
* every `cfg.FOO` condition folds to unknown, i.e. live.
* (b) **Cross-file** direct imports (`const cfg = @import("./cfg.zig");
* if (cfg.FOO)`) are enriched after per-file extraction. The workspace
* caller supplies `importAliases` and `lookupBoolsForPath`; the parse
* worker still uses empty/undefined inputs and remains file-local.
*
* Also out of scope: multi-hop member access (`cfg.sub.FOO`), re-exported
* consts, runtime-evaluated bools (`const FOO = computeIt();`), and

View file

@ -19,6 +19,7 @@ import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
import { zigProvider } from '../zig.js';
import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js';
import { populateZigRangeBindings } from './range-binding.js';
import { populateZigWorkspaceStaticGating } from './workspace-static-gating.js';
export const zigScopeResolver: ScopeResolver = {
language: SupportedLanguages.Zig,
@ -67,6 +68,8 @@ export const zigScopeResolver: ScopeResolver = {
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
populateWorkspaceReferences: populateZigWorkspaceStaticGating,
// Payload captures — `for (items) |it|`, `if (opt) |v|`, `while (it.next())
// |x|` — typed from the subject's binding after finalize (F6).
populateRangeBindings: populateZigRangeBindings,

View file

@ -0,0 +1,106 @@
import type { ParsedFile, ReferenceSite } from 'gitnexus-shared';
import { getTreeSitterBufferSize } from '../../constants.js';
import type { ZigBuildZonConfig } from '../../language-config.js';
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
import {
buildZigBoolConstMap,
collectZigStaticGatedRanges,
isPositionStaticGated,
type ZigImportAliasMap,
} from '../../call-extractors/zig-static-gating.js';
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
import { getZigParser } from './query.js';
type ZigTree = ReturnType<ReturnType<typeof getZigParser>['parse']>;
export function populateZigWorkspaceStaticGating(
parsedFiles: ParsedFile[],
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
readonly resolutionConfig?: unknown;
},
): void {
const parser = getZigParser();
const trees = new Map<string, ZigTree>();
const bools = new Map<string, ReturnType<typeof buildZigBoolConstMap>>();
for (const parsed of parsedFiles) {
const source = ctx.fileContents.get(parsed.filePath);
if (source === undefined) continue;
let tree = ctx.treeCache?.get(parsed.filePath) as ZigTree | undefined;
if (tree === undefined) {
try {
tree = parseSourceSafe(parser, source, undefined, {
bufferSize: getTreeSitterBufferSize(source),
});
} catch (err) {
if (err instanceof ParseTimeoutError) continue;
throw err;
}
}
trees.set(parsed.filePath, tree);
bools.set(parsed.filePath, buildZigBoolConstMap(tree.rootNode));
}
const knownPaths = new Set(trees.keys());
for (const [index, parsed] of parsedFiles.entries()) {
const tree = trees.get(parsed.filePath);
if (tree === undefined) continue;
const aliases = collectImportAliases(
tree,
parsed.filePath,
knownPaths,
ctx.resolutionConfig as ZigBuildZonConfig | null | undefined,
);
if (aliases.size === 0) continue;
const ranges = collectZigStaticGatedRanges(
tree.rootNode,
bools.get(parsed.filePath) ?? new Map(),
aliases,
(filePath) => bools.get(filePath),
);
if (ranges.length === 0) continue;
const next = parsed.referenceSites.map((site) =>
site.kind === 'call' &&
site.staticGated !== true &&
isPositionStaticGated(site.atRange.startLine, site.atRange.startCol, ranges)
? ({ ...site, staticGated: true } satisfies ReferenceSite)
: site,
);
parsedFiles[index] = Object.freeze({ ...parsed, referenceSites: Object.freeze(next) });
}
}
function collectImportAliases(
tree: ZigTree,
fromFile: string,
knownPaths: ReadonlySet<string>,
resolutionConfig?: ZigBuildZonConfig | null,
): ZigImportAliasMap {
const candidates = new Map<string, string>();
const declarationCounts = new Map<string, number>();
for (const decl of tree.rootNode.descendantsOfType('variable_declaration')) {
const names = decl.namedChildren.filter((node) => node.type === 'identifier');
const binding = names[0]?.text;
if (binding === undefined) continue;
declarationCounts.set(binding, (declarationCounts.get(binding) ?? 0) + 1);
const builtin = decl.namedChildren.find(
(node) => node.type === 'builtin_function' && node.text.startsWith('@import('),
);
const raw = builtin?.descendantsOfType('string').at(0)?.text;
if (raw === undefined) continue;
const specifier = raw.replace(/^['"]|['"]$/g, '');
const target = resolveZigImportInternal(fromFile, specifier, knownPaths, resolutionConfig);
if (target !== null) candidates.set(binding, target);
}
const aliases = new Map<string, string>();
for (const [binding, target] of candidates) {
// Alias lookup below is name-based rather than position-aware. If a name
// is redeclared in another lexical scope, fail open instead of applying
// either module's constants to every use of that spelling.
if (declarationCounts.get(binding) === 1) aliases.set(binding, target);
}
return aliases;
}

View file

@ -697,6 +697,21 @@ export interface ScopeResolver {
ctx: { readonly fileContents: ReadonlyMap<string, string> },
) => void;
/**
* Optional workspace-wide enrichment of extracted reference sites. Runs
* after all files have been extracted and before reference finalization.
* Use this when a per-file capture needs conservative facts from an
* imported sibling (for example a compile-time branch constant).
*/
readonly populateWorkspaceReferences?: (
parsedFiles: ParsedFile[],
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
readonly resolutionConfig?: unknown;
},
) => void;
/**
* Recognize a `super(...)`-style receiver text. Python returns
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may

View file

@ -145,6 +145,7 @@ export function selectScopeSourcePathsToRead(
): string[] {
const hasPostExtractHooks =
provider.populateWorkspaceOwners !== undefined ||
provider.populateWorkspaceReferences !== undefined ||
provider.populateNamespaceSiblings !== undefined ||
provider.populateRangeBindings !== undefined ||
provider.emitPostResolutionEdges !== undefined;

View file

@ -639,6 +639,11 @@ export function runScopeResolution(
`lang=${provider.language} parsedFiles=${parsedFiles.length} preExtractedHits=${preExtractedHits} skipped=${filesSkipped}`,
);
provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() });
provider.populateWorkspaceReferences?.(parsedFiles, {
fileContents: getFileContents(),
treeCache,
resolutionConfig: input.resolutionConfig,
});
// A callable-flow-only provider has no reason to build the whole-graph
// lookup or finalize ordinary references when none of its files emitted a

View file

@ -9,6 +9,9 @@
// Cross-file alias — should resolve `cfg.FOO`, `cfg.BAR` against cfg.zig.
const cfg = @import("./cfg.zig");
const cfg_no_ext = @import("./cfg");
const wrapped_cfg = wrap(@import("./cfg.zig"));
const shadowed_cfg = @import("./cfg.zig");
pub const UPGRADERS_ENABLED: bool = false;
pub const DEBUG: bool = true;
@ -30,6 +33,7 @@ pub const CYCLE_B = CYCLE_A;
pub const ALIAS_TO_VAR = IS_RUNTIME_FLAG_FALSE;
pub fn run() void {
const local_cfg = @import("./cfg.zig");
// Live: not under any if-gate.
live_unconditional();
@ -162,6 +166,18 @@ pub fn run() void {
if (cfg.NOT_A_BOOL != 0) {
live_cross_file_not_bool();
}
if (cfg_no_ext.FOO) {
gated_extensionless_cross_file_foo();
}
// Function-local imports use the same workspace constants.
if (local_cfg.FOO) {
gated_local_cross_file_foo();
}
// An import nested inside another initializer does not bind the variable
// directly to that module, so its members must remain unknown/fail-open.
if (wrapped_cfg.FOO) {
live_wrapped_cross_file_foo();
}
// Bare literal gate: no constant table involved, but it must still be gated.
if (false) {
@ -205,10 +221,37 @@ pub fn run() void {
_ = e3;
}
pub fn run_shadowed_alias() void {
const shadowed_cfg = @import("./other.zig");
if (shadowed_cfg.FOO) {
live_shadowed_cross_file_foo();
}
}
fn live_unconditional() void {
_ = 1;
}
fn wrap(value: anytype) @TypeOf(value) {
return value;
}
fn gated_local_cross_file_foo() void {
_ = 1;
}
fn gated_extensionless_cross_file_foo() void {
_ = 1;
}
fn live_wrapped_cross_file_foo() void {
_ = 1;
}
fn live_shadowed_cross_file_foo() void {
_ = 1;
}
fn gated_simple() void {
_ = 1;
}

View file

@ -0,0 +1 @@
pub const FOO: bool = true;

View file

@ -7,8 +7,11 @@
* such branches keep `staticGated` falsy.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'node:fs';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
import { populateZigWorkspaceStaticGating } from '../../../src/core/ingestion/languages/zig/workspace-static-gating.js';
import type { ParsedFile } from 'gitnexus-shared';
describe('Zig static-gated edges', () => {
let result: PipelineResult;
@ -174,12 +177,9 @@ describe('Zig static-gated edges', () => {
expect(isGated('gated_chain_tail')).toBe(true);
});
// Cross-file positive cases: the gating module resolves `alias.NAME` through
// `lookupBoolsForPath`, but the scope-capture emitter runs per file in the
// 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 (tracked: #3162)', () => {
// Cross-file cases are enriched after per-file extraction, once sibling
// source facts are available but before reference finalization.
it('tags `if (cfg.FOO)` cross-file when FOO is false in cfg.zig', () => {
expect(isGated('gated_cross_file_foo')).toBe(true);
});
@ -187,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 (tracked: #3162)', () => {
it('tags the ELSE branch of `if (cfg.BAR)` when BAR is true', () => {
expect(isGated('gated_cross_file_else')).toBe(true);
});
@ -198,4 +198,57 @@ describe('Zig static-gated edges', () => {
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);
});
it('resolves a relative cross-file import with an omitted .zig extension', () => {
expect(isGated('gated_extensionless_cross_file_foo')).toBe(true);
});
it('tags a cross-file bool accessed through a function-local import alias', () => {
expect(isGated('gated_local_cross_file_foo')).toBe(true);
});
it('does NOT treat a nested @import as the declaration direct module alias', () => {
expect(isGated('live_wrapped_cross_file_foo')).toBe(false);
});
it('fails open when an import alias is shadowed in another lexical scope', () => {
expect(isGated('live_shadowed_cross_file_foo')).toBe(false);
});
it('replaces a frozen parsed file instead of mutating it', () => {
const site = Object.freeze({
kind: 'call',
atRange: { startLine: 153, startCol: 8, endLine: 153, endCol: 30 },
staticGated: false,
});
const original = Object.freeze({
filePath: 'src/main.zig',
referenceSites: Object.freeze([site]),
}) as unknown as ParsedFile;
const parsedFiles = [
original,
Object.freeze({
filePath: 'src/cfg.zig',
referenceSites: Object.freeze([]),
}) as unknown as ParsedFile,
];
expect(() =>
populateZigWorkspaceStaticGating(parsedFiles, {
fileContents: new Map([
[
'src/main.zig',
fs.readFileSync(path.join(FIXTURES, 'zig-static-gating', 'src', 'main.zig'), 'utf8'),
],
[
'src/cfg.zig',
fs.readFileSync(path.join(FIXTURES, 'zig-static-gating', 'src', 'cfg.zig'), 'utf8'),
],
]),
}),
).not.toThrow();
expect(parsedFiles[0]).not.toBe(original);
expect(Object.isFrozen(parsedFiles[0])).toBe(true);
expect(parsedFiles[0]?.referenceSites[0]?.staticGated).toBe(true);
});
});