feat(zig): callable-value-flow captures + main's per-language conformance gates

Post-rebase catch-up: since this branch forked, main added three "every
registered language must appear here" tests. Each needs a Zig entry:

- callable-value-flow (#2522): Zig now emits `@callable-flow.*` facts via
  `synthesizeCallableFlowCaptures` (ZIG_CALLABLE_CAPTURE_OPTIONS in
  zig/captures.ts). tree-sitter-zig's `call_expression` carries arguments
  as direct children with no wrapper node, which the shared helper could
  not decompose, so this adds a language-neutral `extractCallArguments`
  hook (mirror of `extractFunctionParameters`; `undefined` = shared path).
  Zig joins the provider matrix as 'matrix' with a real assign→copy→
  argument→invoke case.
- external-import-conformance (#2953): `@import("std")` beside a decoy
  `src/std.zig` resolves to nothing; the decoy stays reachable via the
  relative spelling. Zig holds the property (no suffix fallback), so it is
  a case, not a KNOWN_GAPS entry.
- import-target-index-reuse contract (#2909): membership-probe-only
  fixture (minimumScans: 0, same shape as Rust).
This commit is contained in:
Navid EMAD 2026-08-18 03:01:53 +02:00
parent 618133db98
commit a69cb3a48a
No known key found for this signature in database
5 changed files with 110 additions and 0 deletions

View file

@ -3,6 +3,7 @@ import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { getZigParser, getZigScopeQuery } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
export const ZIG_CONTAINER_TYPES: ReadonlySet<string> = new Set([
'struct_declaration',
@ -43,6 +44,51 @@ export function isZigContainerMethod(
return false;
}
/**
* Callable-value-flow vocabulary for tree-sitter-zig 1.1.2 (verified by AST
* dump). Two grammar quirks drive the callbacks:
*
* - `variable_declaration` is FIELDLESS apart from `type:` — `const f = target;`
* is `(variable_declaration (identifier) (identifier))`, and a bare
* re-assignment statement `f = target;` parses to the SAME node shape. The
* shared `left`/`name`/`value` fallback decomposes nothing, so
* `extractAssignment` pairs first-identifier → last-child positionally.
* `assignment_expression` (`self.f = target`) carries real `left`/`right`
* fields and is left to the shared path.
* - `call_expression` has NO argument-list wrapper: `invoke(second)` is
* `(call_expression function: (identifier) (identifier))`. Arguments are
* every named child other than the `function` field, hence
* `extractCallArguments`.
*
* `builtin_function` (`@import`, `@sizeOf`, …) is deliberately not a call node:
* builtins never take user callables as flow arguments.
*/
const ZIG_CALLABLE_CAPTURE_OPTIONS = {
functionNodeTypes: new Set(['function_declaration']),
callNodeTypes: new Set(['call_expression']),
parameterListNodeTypes: new Set(['parameters']),
parameterNodeTypes: new Set(['parameter']),
bindingNodeTypes: new Set(['variable_declaration']),
assignmentNodeTypes: new Set(['assignment_expression']),
identifierNodeTypes: new Set(['identifier']),
extractAssignment: (node: SyntaxNode) => {
if (node.type !== 'variable_declaration') return undefined;
const named = node.namedChildren.filter((child): child is SyntaxNode => child !== null);
if (named.length < 2 || named[0]!.type !== 'identifier') return undefined;
const source = named[named.length - 1]!;
// `const x: T;` (extern) — the trailing child is the type, not a value.
if (source.id === node.childForFieldName('type')?.id) return undefined;
return { destination: named[0]!, source };
},
extractCallArguments: (call: SyntaxNode) => {
const callee = call.childForFieldName('function');
return call.namedChildren.filter(
(child): child is SyntaxNode =>
child !== null && child.id !== callee?.id && child.type !== 'comment',
);
},
} as const;
export function emitZigScopeCaptures(
sourceText: string,
_filePath: string,
@ -88,5 +134,7 @@ export function emitZigScopeCaptures(
out.push(grouped);
}
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, ZIG_CALLABLE_CAPTURE_OPTIONS));
return out;
}

View file

@ -120,6 +120,15 @@ export interface CallableFlowCaptureOptions {
| readonly { readonly destination: SyntaxNode; readonly source: SyntaxNode }[]
| undefined;
readonly extractFunctionParameters?: (node: SyntaxNode) => readonly SyntaxNode[] | undefined;
/**
* Provider-owned call-argument extraction, for grammars whose call node
* carries its arguments as DIRECT children with no argument-list wrapper
* (tree-sitter-zig's `call_expression`). Without a wrapper node the shared
* `arguments`/`parameterListNodeTypes` lookup finds nothing, so every
* `argument` fact is lost. Returning `undefined` falls back to the shared
* path (mirrors `extractFunctionParameters`).
*/
readonly extractCallArguments?: (call: SyntaxNode) => readonly SyntaxNode[] | undefined;
readonly extractCallCallee?: (node: SyntaxNode) => SyntaxNode | undefined;
readonly isCallNode?: (node: SyntaxNode) => boolean;
/**
@ -891,6 +900,8 @@ function callArguments(
call: SyntaxNode,
options: CallableFlowCaptureOptions,
): readonly SyntaxNode[] {
const providerArguments = options.extractCallArguments?.(call);
if (providerArguments !== undefined) return providerArguments;
const list =
call.childForFieldName('arguments') ??
call.childForFieldName('argument') ??

View file

@ -33,6 +33,7 @@ const CALLABLE_FLOW_PROVIDER_COVERAGE = {
[SupportedLanguages.Dart]: 'matrix',
[SupportedLanguages.Vue]: 'matrix',
[SupportedLanguages.Cobol]: 'matrix',
[SupportedLanguages.Zig]: 'matrix',
} as const satisfies Record<SupportedLanguages, 'matrix' | 'dedicated'>;
const PROVIDER_FLOW_CASES = [
@ -249,6 +250,21 @@ PROGRAM-ID. TARGET.
PROCEDURE DIVISION.
GOBACK.
END PROGRAM TARGET.
`,
},
{
language: SupportedLanguages.Zig,
extension: 'zig',
caller: 'invoke',
target: 'target',
source: `
fn target() void {}
fn invoke(callback: *const fn () void) void { callback(); }
pub fn main() void {
const first = target;
const second = first;
invoke(second);
}
`,
},
] as const;

View file

@ -362,6 +362,22 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
reachesDecoy: 'CUSTREC',
},
],
[
SupportedLanguages.Zig,
{
// `@import("std")` is the standard library, and Zig's resolver answers
// null for the stdlib names outright — it never suffix-matches a bare
// name against the file set, so a repo file that happens to be called
// `std.zig` is not a candidate. The same file IS reachable through the
// filesystem-relative spelling, which is what the decoy arm proves.
files: ['src/std.zig', 'src/util.zig', 'src/main.zig'],
fromFile: 'src/main.zig',
resolutionConfig: undefined,
external: 'std',
decoy: 'src/std.zig',
reachesDecoy: 'std.zig',
},
],
]);
/**

View file

@ -528,6 +528,25 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.Zig,
{
files: ['src/util.zig', 'src/main.zig'],
fromFile: 'src/main.zig',
resolutionConfig: undefined,
// A relative `@import` that names no file: the resolver walks the path
// arithmetically from `fromFile` and probes the Set with two `has` calls
// (`<candidate>` and `<candidate>.zig`) — membership probes only, no
// traversal, the same shape as Rust. Bare names go through the
// build.zig.zon `.path` map (a Map lookup) and never touch the Set.
missTarget: (i) => `ghost${i}.zig`,
hitTarget: 'util.zig',
parsedImport: IGNORES_CONTEXT,
// See `minimumScans` on the interface: membership probes only.
minimumScans: 0,
minimumParsedFileReads: 0,
},
],
]);
/**