diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index b1caad07f..a0d4abcad 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/gitnexus/bench/zig-cross-file-resolution/baseline.json b/gitnexus/bench/zig-cross-file-resolution/baseline.json new file mode 100644 index 000000000..0626ec2e1 --- /dev/null +++ b/gitnexus/bench/zig-cross-file-resolution/baseline.json @@ -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 +} diff --git a/gitnexus/bench/zig-cross-file-resolution/measure.mjs b/gitnexus/bench/zig-cross-file-resolution/measure.mjs new file mode 100644 index 000000000..9f0c4e884 --- /dev/null +++ b/gitnexus/bench/zig-cross-file-resolution/measure.mjs @@ -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'); diff --git a/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts b/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts index a41f04ae2..7a589e5cb 100644 --- a/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts +++ b/gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts index 229d91cf3..86afcfbf2 100644 --- a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/zig/workspace-static-gating.ts b/gitnexus/src/core/ingestion/languages/zig/workspace-static-gating.ts new file mode 100644 index 000000000..040c058be --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/workspace-static-gating.ts @@ -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['parse']>; + +export function populateZigWorkspaceStaticGating( + parsedFiles: ParsedFile[], + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + readonly resolutionConfig?: unknown; + }, +): void { + const parser = getZigParser(); + const trees = new Map(); + const bools = new Map>(); + + 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, + resolutionConfig?: ZigBuildZonConfig | null, +): ZigImportAliasMap { + const candidates = new Map(); + const declarationCounts = new Map(); + 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(); + 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; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 9ac2423f5..e364b0227 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -697,6 +697,21 @@ export interface ScopeResolver { ctx: { readonly fileContents: ReadonlyMap }, ) => 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; + 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index 8218a9656..bb9854b3e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index df823fecb..aa30acf91 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig index cb9686e96..6e397a013 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/main.zig @@ -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; } diff --git a/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/other.zig b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/other.zig new file mode 100644 index 000000000..4d7502d54 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-static-gating/src/other.zig @@ -0,0 +1 @@ +pub const FOO: bool = true; diff --git a/gitnexus/test/integration/resolvers/zig-static-gating.test.ts b/gitnexus/test/integration/resolvers/zig-static-gating.test.ts index 92526c157..33ee827fb 100644 --- a/gitnexus/test/integration/resolvers/zig-static-gating.test.ts +++ b/gitnexus/test/integration/resolvers/zig-static-gating.test.ts @@ -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); + }); });