From 4674308b9f14ac367e5e8db0c47d79f144b7a528 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 18 Aug 2026 04:53:53 +0200 Subject: [PATCH] fix(zig): address fourth gitnexus-check review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - language-config: `parseZigBuildZon` only accepts a `.path` that is a DIRECT field of a dependency entry. Nested blocks inside the entry body are blanked (string-aware, offsets preserved) before the `.path` regex runs, and a match starting inside a string literal is rejected, so `.dep = .{ .url = "…", .meta = .{ .path = "x" } }` no longer becomes a path dep. Regression test in zig-import-resolver.test.ts (fails on the previous code). - tree-sitter-languages test: the "grammar is absent" case now drives the loader's real `source.load()` catch branch — `node:module` is stood in with a `createRequire` whose require throws MODULE_NOT_FOUND for `@tree-sitter-grammars/tree-sitter-zig` and delegates everything else — instead of the `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` opt-out, which has its own test. It also asserts the opt-out flag is NOT set and that other grammars still load (non-fatal optional failure). Not re-fixed: - "Return type is read from the wrong tree-sitter field": tree-sitter-zig 1.1.2 has NO `return_type` field on function_declaration — the type after `)` is the `type` field (AST dump: `builtin_type "i32" field=type`; the proposed `childForFieldName('return_type')` is null for every function). A pin test in zig-extractors.test.ts asserts both the grammar fact and that `returnType` is extracted (`void`, `!*Counter`). - "`DIR_LANG` has no zig entry": it does (added in the first pass and answered again in the third); the finding is carried over unchanged. --- .../src/core/ingestion/language-config.ts | 45 ++++++++++++++++++- .../integration/tree-sitter-languages.test.ts | 43 +++++++++++++----- gitnexus/test/unit/zig-extractors.test.ts | 22 +++++++++ .../test/unit/zig-import-resolver.test.ts | 21 +++++++++ 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 9836dc0af..1a5257ec7 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -574,6 +574,37 @@ function findZonBlockEnd(text: string, start: number): number { return -1; } +/** + * `body` with every nested `{ … }` block (string-aware) replaced by spaces of + * equal length, so a regex over the result only sees the block's DIRECT + * fields and offsets still line up with the original text. + */ +function zonBlankNestedBlocks(body: string): string { + const out = body.split(''); + let depth = 0; + let inString = false; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (inString) { + if (ch === '\\') { + if (depth > 0 && i + 1 < body.length) out[i + 1] = ' '; + i++; + } else if (ch === '"') inString = false; + if (depth > 0) out[i] = ' '; + continue; + } + if (ch === '"') inString = true; + else if (ch === '{') depth++; + else if (ch === '}' && depth > 0) { + depth--; + out[i] = ' '; + continue; + } + if (depth > 0) out[i] = ' '; + } + return out.join(''); +} + /** * Per-offset "is inside a `"…"` literal" mask for comment-stripped ZON text, * so header regexes can reject a match that merely LOOKS like a field @@ -644,9 +675,19 @@ export function parseZigBuildZon(raw: string): ZigBuildZonConfig | null { const bodyStart = m.index + m[0].length; const bodyEnd = findZonBlockEnd(text, bodyStart); if (bodyEnd < 0 || bodyEnd > end) break; - const body = text.slice(bodyStart, bodyEnd); cursor = bodyEnd + 1; - const pathMatch = body.match(/\.path\s*=\s*"([^"\n]+)"/); + // Only a `.path` that is a DIRECT field of the entry counts: a nested + // object inside the entry (`.foo = .{ .url = "…", .x = .{ .path = "…" } }`) + // must not turn a URL dep into a path dep. Blank nested blocks first and + // reject a match that starts inside a string literal. + const body = zonBlankNestedBlocks(text.slice(bodyStart, bodyEnd)); + const pathMatch = matchZonHeader( + body, + /\.path\s*=\s*"([^"\n]+)"/g, + mask.subarray(bodyStart, bodyEnd), + 0, + body.length, + ); if (pathMatch) { pathDeps.set(depName, pathMatch[1]); } diff --git a/gitnexus/test/integration/tree-sitter-languages.test.ts b/gitnexus/test/integration/tree-sitter-languages.test.ts index 3fcf9045c..cdd3c155f 100644 --- a/gitnexus/test/integration/tree-sitter-languages.test.ts +++ b/gitnexus/test/integration/tree-sitter-languages.test.ts @@ -715,23 +715,46 @@ describe('Tree-sitter multi-language parsing', () => { }); it('reports Zig unavailable and throws "Unsupported language" when the grammar is absent', async () => { - // Force the absent-binding path instead of hoping the package is missing: - // the loader's runtime opt-out (`GITNEXUS_SKIP_OPTIONAL_GRAMMARS`) makes a - // userSkippable grammar report exactly as an absent binding does. A fresh - // module copy is needed because the loader memoizes load results. - const ENV = 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS'; - const previous = process.env[ENV]; - process.env[ENV] = 'zig'; + // Exercise the loader's real absent-binding branch (the `source.load()` + // catch in `loadGrammar`), not the `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` + // opt-out, which is a separate code path with its own test + // (parser-loader-skip-optional.test.ts). The Zig row loads through the + // module-scoped `createRequire(import.meta.url)`, so stand in for + // `node:module` with a require that reports the package missing and + // delegates everything else. A fresh module copy is needed because the + // loader memoizes load results. + const ZIG_PKG = '@tree-sitter-grammars/tree-sitter-zig'; + vi.doMock('node:module', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRequire: (url: string | URL) => { + const real = actual.createRequire(url); + const stub = ((id: string) => { + if (id === ZIG_PKG) { + const err = new Error(`Cannot find module '${id}'`) as NodeJS.ErrnoException; + err.code = 'MODULE_NOT_FOUND'; + throw err; + } + return real(id); + }) as unknown as NodeJS.Require; + return Object.assign(stub, real); + }, + }; + }); + vi.resetModules(); try { - vi.resetModules(); const fresh = await import('../../src/core/tree-sitter/parser-loader.js'); + // Not the opt-out path: the env is untouched. + expect(fresh.isGrammarRuntimeSkipped(SupportedLanguages.Zig)).toBe(false); expect(fresh.isLanguageAvailable(SupportedLanguages.Zig)).toBe(false); await expect(fresh.loadLanguage(SupportedLanguages.Zig)).rejects.toThrow( /Unsupported language/, ); + // Optional-grammar failure is non-fatal: the other grammars still load. + expect(fresh.isLanguageAvailable(SupportedLanguages.TypeScript)).toBe(true); } finally { - if (previous === undefined) delete process.env[ENV]; - else process.env[ENV] = previous; + vi.doUnmock('node:module'); vi.resetModules(); } }); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index 0598b8fac..afa2a0272 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -116,6 +116,28 @@ const Counter = struct { expect(make.isStatic).toBe(true); }); + it('reads the return type from function_declaration’s `type:` field (there is no `return_type`)', () => { + // tree-sitter-zig 1.1.2 labels the type after `)` as the `type` field on + // function_declaration — the same field NAME parameter nodes use, but on + // a different node. There is no `return_type` field: reading that would + // drop every Zig return type. Pin both the grammar fact and the extractor. + const root = parse(` +const Counter = struct { + n: u32, + pub fn add(self: *Counter, by: u32) void { self.n += by; } + pub fn make(n: u32) !*Counter { return error.Nope; } +}; +`).rootNode; + const addDecl = find(root, 'function_declaration', 'pub fn add'); + expect(addDecl.childForFieldName('return_type')).toBeNull(); + expect(addDecl.childForFieldName('type')?.text).toBe('void'); + + const result = extractor.extract(find(root, 'struct_declaration'), ctx); + const byName = new Map(result!.methods.map((m) => [m.name, m])); + expect(byName.get('add')!.returnType).toBe('void'); + expect(byName.get('make')!.returnType).toBe('!*Counter'); + }); + it('only a FIRST parameter named self is the receiver', () => { const root = parse(` const S = struct { diff --git a/gitnexus/test/unit/zig-import-resolver.test.ts b/gitnexus/test/unit/zig-import-resolver.test.ts index 4b90a2552..db732d432 100644 --- a/gitnexus/test/unit/zig-import-resolver.test.ts +++ b/gitnexus/test/unit/zig-import-resolver.test.ts @@ -214,6 +214,27 @@ describe('parseZigBuildZon', () => { expect(parseZigBuildZon(raw)).toBeNull(); }); + it('ignores a `.path` nested inside an entry — only a direct field makes a path dep', () => { + // A URL dep whose body carries a nested object with its own `.path` must + // not be reported as a path dep: the resolver would otherwise add an + // import edge to an unrelated `/` for `@import("only_url")`. + const raw = ` +.{ + .dependencies = .{ + .only_url = .{ + .url = "https://x", + .hash = "1220y", + .meta = .{ .path = "vendor/unrelated" }, + }, + .real = .{ .path = "vendor/real", .extra = .{ .path = "vendor/nested" } }, + }, +} +`; + const cfg = parseZigBuildZon(raw); + expect(cfg).not.toBeNull(); + expect([...cfg!.pathDeps.entries()]).toEqual([['real', 'vendor/real']]); + }); + it('returns null when the deps block has no `.path` entries', () => { const raw = ` .{