fix(zig): type the optional-grammar gate by grammar key, not language

The gitnexus-check finding on parser-loader-abi.test.ts:155 is right, and
none of the gates caught it: `tsconfig.json` includes only `src/**/*`, so
neither `tsc --noEmit` nor CI typechecks the test tree, and vitest strips
types without checking them. Confirmed with a scoped tsc run over the file:
`error TS2345: Argument of type 'string' is not assignable to parameter of
type 'SupportedLanguages'`.

Not fixed with the proposed `key as SupportedLanguages` cast, which would
assert something false: `listGrammarSources()` yields one row per SOURCES
entry, including variants like `typescript:tsx` that are not enum members.
`isOptionalGrammarRequired` now takes the grammar KEY it is really given,
and the registry keeps a `satisfies Partial<Record<SupportedLanguages,
string>>` so every key we write is still pinned to a real language.

Two new cases cover the failure mode the type error was pointing at — a
registry key that can never match what the ABI smoke passes, leaving the
gate configured-looking and permanently inert: every OPTIONAL_GRAMMAR_ENV
key must be a key `listGrammarSources()` yields and must be marked optional
there, and an unregistered variant (`typescript:tsx`) must not be required
even with the variable set.

Same blind spot, two more latent errors in files this PR adds, both fixed:
`Parser.Language` is not an exported member (use the `setLanguage` parameter
type, as parser-loader-abi.test.ts already does), and the `ParsedImport`
filter did not narrow the union, so `localName` was read through a `!` on an
arm that has no such property — now a type predicate. The one remaining
error under the same probe, in `resolvers/callable-value-flow.test.ts:319`,
predates this branch (authored 2026-07-17, on main) and is left alone.

structural-pair-coverage's optional-grammar case switches from
`it.concurrent.each` to `it.concurrent.for`: only `for` passes the test
context as a second argument (`each`'s callback is `(...args: T[])`), and
that context carries the dynamic `skip()` the per-language gate calls.
Behaviour is unchanged — grammar present: 10 passed; grammar forced away:
9 passed, 1 skipped.

Whole test tree typechecking is a separate, much larger job: the same probe
over `test/**` minus fixtures reports 734 pre-existing errors across the
repo. Out of scope here.
This commit is contained in:
Navid EMAD 2026-08-20 15:17:29 +02:00
parent d51fbdbf9a
commit ef391cc672
No known key found for this signature in database
4 changed files with 44 additions and 9 deletions

View file

@ -19,15 +19,24 @@ import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js
* platform its grammar publishes a prebuild for; otherwise the gate would fail
* a job it cannot satisfy.
*/
export const OPTIONAL_GRAMMAR_ENV: Partial<Record<SupportedLanguages, string>> = {
export const OPTIONAL_GRAMMAR_ENV: Readonly<Partial<Record<string, string>>> = {
// @tree-sitter-grammars/tree-sitter-zig@1.1.2 publishes prebuilds for
// {darwin,linux,win32}-{x64,arm64} — every OS in the CI matrix.
[SupportedLanguages.Zig]: 'GITNEXUS_REQUIRE_ZIG',
};
} satisfies Partial<Record<SupportedLanguages, string>>;
/** True when CI declared `language`'s optional grammar mandatory on this runner. */
export const isOptionalGrammarRequired = (language: SupportedLanguages): boolean => {
const envVar = OPTIONAL_GRAMMAR_ENV[language];
/**
* True when CI declared this grammar mandatory on the current runner.
*
* Keyed by GRAMMAR key, not by `SupportedLanguages`: the registry the ABI
* load-smoke walks (`listGrammarSources()`) yields one row per `SOURCES` entry,
* which includes variants such as `typescript:tsx` that are not enum members.
* Widening the parameter is what keeps that call honest narrowing the key
* with a cast would claim every grammar row is a language, which is false.
* `satisfies` above still pins every key WE write to a real language.
*/
export const isOptionalGrammarRequired = (grammarKey: string): boolean => {
const envVar = OPTIONAL_GRAMMAR_ENV[grammarKey];
return envVar !== undefined && process.env[envVar] === '1';
};

View file

@ -248,7 +248,10 @@ describeIfWorkerBuilt('RELATION_SCHEMA covers the non-bridge emitters', () => {
},
);
it.concurrent.each(OPTIONAL_GRAMMAR_CORPUS)(
// `.for`, not `.each`: only `for` passes the test context as a second
// argument (`each`'s callback is `(...args: T[])`), and the context is what
// carries the dynamic `skip()` this per-language gate needs.
it.concurrent.for(OPTIONAL_GRAMMAR_CORPUS)(
'$fixture emits only declared FROM/TO pairs, and still reaches $emitter',
async ({ fixture, language, sentinels }, ctx) => {
if (!isLanguageAvailable(language)) ctx.skip();

View file

@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { SupportedLanguages } from 'gitnexus-shared';
import { listGrammarSources } from '../../src/core/tree-sitter/parser-loader.js';
import { OPTIONAL_GRAMMAR_ENV, isOptionalGrammarRequired } from '../helpers/optional-grammar.js';
/**
@ -45,6 +46,25 @@ describe('optional grammar required-gate', () => {
expect(isOptionalGrammarRequired(SupportedLanguages.Zig)).toBe(false);
});
// The ABI load-smoke passes a GRAMMAR key, not a SupportedLanguages value:
// listGrammarSources() has one row per SOURCES entry, including variants like
// `typescript:tsx`. A registry key no row ever yields would leave the gate
// permanently inert — it would look configured and require nothing.
it('registers only keys the grammar registry actually yields', () => {
const sources = listGrammarSources();
for (const key of Object.keys(OPTIONAL_GRAMMAR_ENV)) {
const source = sources.find((s) => s.key === key);
expect(source, `${key} is not a grammar key listGrammarSources() yields`).toBeDefined();
// Requiring a grammar that is already mandatory would be a no-op gate.
expect(source?.optional, `${key} is not an optional grammar`).toBe(true);
}
});
it('is inert for a grammar-key variant nobody registered', () => {
process.env[envVar] = '1';
expect(isOptionalGrammarRequired('typescript:tsx')).toBe(false);
});
// Languages with no entry must never be gated — an unregistered language
// reading a stray env var would fail jobs on platforms with no prebuild.
it('never requires a language that is not registered', () => {

View file

@ -78,7 +78,7 @@ describe.skipIf(!isOptionalGrammarRequired(SupportedLanguages.Zig))(
const parser = new Parser();
const parse = (code: string) => {
parser.setLanguage(Zig as Parser.Language);
parser.setLanguage(Zig as Parameters<Parser['setLanguage']>[0]);
return parser.parse(code);
};
@ -1178,8 +1178,11 @@ fn f() void {
const imports = emitZigScopeCaptures(src, 'lp.zig')
.filter((m) => m['@import.source'] !== undefined)
.map((m) => interpretZigImport(m))
.filter((i) => i !== null && (i.kind === 'named' || i.kind === 'alias'))
.map((i) => [i!.localName, (i as { reexportsName?: boolean }).reexportsName === true]);
.filter(
(i): i is Extract<NonNullable<typeof i>, { kind: 'named' | 'alias' }> =>
i !== null && (i.kind === 'named' || i.kind === 'alias'),
)
.map((i) => [i.localName, (i as { reexportsName?: boolean }).reexportsName === true]);
expect(imports).toEqual([
['Arena', true], // the file-struct TYPE twin of a pub namespace import
['Foo', true],