diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 2918394b4..001bf798d 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -34,8 +34,9 @@ const NO_IMPORT_ALIASES: ZigImportAliasMap = new Map(); * cross-file alias walk in `zig-static-gating.ts` needs the repo file list, * which the capture layer does not see. */ function stampZigStaticGating(out: readonly CaptureMatch[], root: SyntaxNode): CaptureMatch[] { + // No early return on an empty constant table: `collectZigStaticGatedRanges` + // also folds bare literals (`if (false) { ... }`), which need no constants. const bools = buildZigBoolConstMap(root); - if (bools.size === 0) return [...out]; const ranges = collectZigStaticGatedRanges(root, bools, NO_IMPORT_ALIASES, () => undefined); if (ranges.length === 0) return [...out]; return out.map((m) => { diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 8cf73cbb0..0457767b2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -183,6 +183,8 @@ export function emitFreeCallFallback( }; for (const parsed of parsedFiles) { + type PendingRel = { rel: Parameters[0]; gatedAll: boolean }; + const pending = new Map(); const bindingCandidatesByScope = options.freeCallsRequireInstanceOwnership === true ? new Map>() @@ -616,26 +618,40 @@ export function emitFreeCallFallback( tgtGraphId, ); const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`; + // One edge per (caller, callee): `staticGated` is the AND over every site + // that collapses into it, so a callee reached from one live site and one + // dead site stays live whichever site the walk meets first. Emission is + // deferred to the end of this file's sites for that reason. + const pendingRel = pending.get(relId); + if (pendingRel !== undefined) { + if (site.staticGated !== true) pendingRel.gatedAll = false; + continue; + } if (seen.has(relId)) continue; seen.add(relId); - graph.addRelationship({ - id: relId, - sourceId: callerGraphId, - targetId: tgtGraphId, - type: 'CALLS', - confidence: 0.85, - // Match legacy DAG's reason convention so consumers that - // assert `reason === 'import-resolved'` keep working. The - // construction-site marker is opt-in for the same reason. - reason: constructionSiteReason( - fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', - site, - options.markConstructionSites, - ), - ...(site.staticGated === true ? { staticGated: true } : {}), + pending.set(relId, { + gatedAll: site.staticGated === true, + rel: { + id: relId, + sourceId: callerGraphId, + targetId: tgtGraphId, + type: 'CALLS', + confidence: 0.85, + // Match legacy DAG's reason convention so consumers that + // assert `reason === 'import-resolved'` keep working. The + // construction-site marker is opt-in for the same reason. + reason: constructionSiteReason( + fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', + site, + options.markConstructionSites, + ), + }, }); emitted++; } + for (const { rel, gatedAll } of pending.values()) { + graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel); + } } return emitted; } 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 58d1c99cf..5cb821b63 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 @@ -162,6 +162,22 @@ pub fn run() void { if (cfg.NOT_A_BOOL != 0) { live_cross_file_not_bool(); } + + // Bare literal gate: no constant table involved, must still be gated + // (PR #3161 review, finding 1). + if (false) { + gated_bare_literal(); + } + + // Same callee reached from a LIVE site and a GATED site in one caller: the + // free-call edge is deduplicated per (caller, callee), so the flag must be + // the AND over all sites, never whichever site was visited first + // (PR #3161 review, finding 2). Live first here, gated first in + // `run_gated_first` below. + live_and_gated_same_callee(); + if (UPGRADERS_ENABLED) { + live_and_gated_same_callee(); + } } fn live_unconditional() void { @@ -295,3 +311,22 @@ fn live_cross_file_not_bool() void { fn some_runtime_flag() bool { return false; } + +pub fn run_gated_first() void { + if (UPGRADERS_ENABLED) { + gated_then_live_same_callee(); + } + gated_then_live_same_callee(); +} + +fn gated_bare_literal() void { + _ = 1; +} + +fn live_and_gated_same_callee() void { + _ = 1; +} + +fn gated_then_live_same_callee() void { + _ = 1; +} diff --git a/gitnexus/test/integration/resolvers/zig-static-gating.test.ts b/gitnexus/test/integration/resolvers/zig-static-gating.test.ts index 258d92d7f..cc2e96461 100644 --- a/gitnexus/test/integration/resolvers/zig-static-gating.test.ts +++ b/gitnexus/test/integration/resolvers/zig-static-gating.test.ts @@ -40,6 +40,18 @@ describe('Zig static-gated edges', () => { expect(isGated('gated_or_both_false')).toBe(true); }); + it('tags a bare `if (false)` gate even when the file declares no bool constants', () => { + expect(isGated('gated_bare_literal')).toBe(true); + }); + + it('keeps a deduplicated edge LIVE when a live site precedes a gated site', () => { + expect(isGated('live_and_gated_same_callee')).toBe(false); + }); + + it('keeps a deduplicated edge LIVE when a gated site precedes a live site', () => { + expect(isGated('gated_then_live_same_callee')).toBe(false); + }); + it('does NOT tag unconditional calls', () => { expect(isGated('live_unconditional')).toBe(false); });