From ef391cc672bd33e5c906985d8f81e24f09f282cd Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Thu, 20 Aug 2026 15:17:29 +0200 Subject: [PATCH] fix(zig): type the optional-grammar gate by grammar key, not language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>` 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. --- gitnexus/test/helpers/optional-grammar.ts | 19 +++++++++++++----- .../structural-pair-coverage.test.ts | 5 ++++- .../test/unit/optional-grammar-gate.test.ts | 20 +++++++++++++++++++ gitnexus/test/unit/zig-extractors.test.ts | 9 ++++++--- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/gitnexus/test/helpers/optional-grammar.ts b/gitnexus/test/helpers/optional-grammar.ts index 1e573e947..d3901e1a2 100644 --- a/gitnexus/test/helpers/optional-grammar.ts +++ b/gitnexus/test/helpers/optional-grammar.ts @@ -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> = { +export const OPTIONAL_GRAMMAR_ENV: Readonly>> = { // @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>; -/** 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'; }; diff --git a/gitnexus/test/integration/structural-pair-coverage.test.ts b/gitnexus/test/integration/structural-pair-coverage.test.ts index cfa1171b9..56af148c1 100644 --- a/gitnexus/test/integration/structural-pair-coverage.test.ts +++ b/gitnexus/test/integration/structural-pair-coverage.test.ts @@ -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(); diff --git a/gitnexus/test/unit/optional-grammar-gate.test.ts b/gitnexus/test/unit/optional-grammar-gate.test.ts index ddfbeb3c3..a118b8354 100644 --- a/gitnexus/test/unit/optional-grammar-gate.test.ts +++ b/gitnexus/test/unit/optional-grammar-gate.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index 621712ca3..40bbd7474 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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[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, { 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],