fix(zig): a build-module alias bound to an unindexed root fails closed

resolveThroughBuildModules returned undefined when the containing module
bound the alias to a file that is not indexed, which let the repo-wide
addModule map answer under the same name (gitnexus-check on 5299c552).
The module's table is the authority for its aliases: bound-but-unindexed
is null, only an unbound name falls through. Unit case with a same-named
repo-wide decoy, plus the outside-module file that still reaches it.
This commit is contained in:
Navid EMAD 2026-09-03 00:47:12 +02:00
parent 5299c5521f
commit fe24b37f5e
No known key found for this signature in database
2 changed files with 23 additions and 6 deletions

View file

@ -69,9 +69,11 @@ function zigModulesContaining(
* tables. `undefined` when no containing module binds the name (the caller * tables. `undefined` when no containing module binds the name (the caller
* falls back to the repo-wide tables); `null` when the containing modules * falls back to the repo-wide tables); `null` when the containing modules
* DISAGREE two same-directory modules that bind one alias to different * DISAGREE two same-directory modules that bind one alias to different
* roots. That is fail-closed on purpose: picking either would mint a * roots or when the alias is bound to a root that is not indexed (a
* confident wrong `IMPORTS` edge for half the files, which is the * generated or skipped file). Both are fail-closed on purpose: the module's
* first-wins defect this table exists to remove. * own table is the authority for the alias, and falling through to the
* repo-wide map would reintroduce the first-wins answer this table exists
* to remove, under a name the module never meant.
*/ */
function resolveThroughBuildModules( function resolveThroughBuildModules(
currentFile: string, currentFile: string,
@ -80,11 +82,14 @@ function resolveThroughBuildModules(
modules: readonly ZigBuildModule[], modules: readonly ZigBuildModule[],
): string | null | undefined { ): string | null | undefined {
const targets = new Set<string>(); const targets = new Set<string>();
let bound = false;
for (const mod of zigModulesContaining(currentFile, modules)) { for (const mod of zigModulesContaining(currentFile, modules)) {
const target = mod.imports.get(importPath); const target = mod.imports.get(importPath);
if (target !== undefined && allFiles.has(target)) targets.add(target); if (target === undefined) continue;
bound = true;
if (allFiles.has(target)) targets.add(target);
} }
if (targets.size === 0) return undefined; if (targets.size === 0) return bound ? null : undefined;
if (targets.size > 1) return null; if (targets.size > 1) return null;
return targets.values().next().value ?? null; return targets.values().next().value ?? null;
} }

View file

@ -317,12 +317,24 @@ describe('resolveZigImportInternal', () => {
buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg.zig']]) }], buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg.zig']]) }],
}; };
expect(resolveZigImportInternal('src/main.zig', 'mylib', files, config)).toBe('src/lib.zig'); expect(resolveZigImportInternal('src/main.zig', 'mylib', files, config)).toBe('src/lib.zig');
// A table entry whose root is not indexed is not an answer either. // A table entry whose root is not indexed is not an answer either — and
// it does not fall through: the module's table is the authority for the
// alias, so a same-named repo-wide `addModule("cfg")` pointing elsewhere
// must not answer in its place (gitnexus-check on 5299c552).
const stale = { const stale = {
...config, ...config,
rootModules: new Map([
['mylib', 'src/lib.zig'],
['cfg', 'src/cfg.zig'],
]),
buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/gone.zig']]) }], buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/gone.zig']]) }],
}; };
expect(resolveZigImportInternal('src/main.zig', 'cfg', files, stale)).toBeNull(); expect(resolveZigImportInternal('src/main.zig', 'cfg', files, stale)).toBeNull();
// …while a file OUTSIDE that module (`src/lib.zig` shares the root's
// directory and so belongs to it; `other/x.zig` does not) still reaches
// the repo-wide name.
const outside = new Set<string>([...files, 'other/x.zig']);
expect(resolveZigImportInternal('other/x.zig', 'cfg', outside, stale)).toBe('src/cfg.zig');
}); });
it('returns null for an unknown bare name not in build.zig.zon', () => { it('returns null for an unknown bare name not in build.zig.zon', () => {