GitNexus/gitnexus/test/integration/grammar-introspection.test.ts
Gergő Magyar b43aa104d3
feat(ingestion): tree-sitter node-type/field validation gate + remove dead literals (#1937)
* feat(ingestion): tree-sitter node-type/field validation gate + remove dead literals

Add a CI gate (test/integration/grammar-literal-validation.test.ts) validating
every node-type and field-name literal in the ingestion code layer against each
grammar's node-types.json, with a live `new Parser.Query` probe fallback for
literals the static JSON under-reports. Covers all three surfaces:
 - legacy Call-Resolution DAG (type-extractors, *-extractors/configs) + the
   ungated structure phase (field/method extractors, export-detection) — AST scan;
 - registry scope-resolution captures + scope queries (Mode 3 compile);
 - the registry RESOLUTION layer (scope-resolver/type-binding/receiver-binding/
   interpret/arity/import-decomposer …) via a TS-TypeChecker discriminator that
   collects a literal ONLY when its `.type` receiver is a tree-sitter SyntaxNode
   (so resolved-symbol `.type` kinds like 'Class' are never mistaken for nodes).
Helpers: test/helpers/{grammar-introspection,literal-collectors}.ts.

Remove every existence-dead literal the gate surfaces (behavior-neutral
dead-branch/fallback deletions verified absent from the installed grammar),
spanning the legacy, structure-phase, and registry production paths:
reference_type/pointer_type/scoped_identifier/scoped_type_identifier/
rvalue_reference_declarator/variadic_parameter (C/C++), equals_value_clause/
identifier_name/simple_identifier/record_struct_declaration/record_class_declaration
(C#), generic_type/`type` field (Dart), nullable_type (PHP), method_call/symbol
(Ruby), method_call_expression/slice_type/shorthand_field_pattern (Rust),
struct_declaration/internal_name (Swift), comment (Java), parameter/
parameterized_type and dead childForFieldName('pattern'|'modifiers'|
'formal_parameters'|'declaration'|'default'|'return_value'|'alias_clause') /
class_expression fallbacks. Gate ships with an empty allowlist.

One behavior FIX (scope-resolution): PHP `findEnclosingTypeDeclaration` omitted
`anonymous_class`, so a method inside an anonymous class mis-bound `$this` to the
enclosing named class; add `anonymous_class` so it is correctly skipped.

Verified: tsc clean; gate green (empty allowlist); scope-resolution parity 26/26
on both REGISTRY_PRIMARY_*=0 and =1; resolver suite no new failures.

Issue #1920 (epic #1919).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ingestion): assert real grammar node types in #1920 dead-literal tests

Three tests asserted defensive handling of node types the installed
grammars never emit (verified via real tree-sitter parse), so they broke
once the dead literals were removed in af9d709f:

 - parsing.test.ts isNodeExported / csharp: `record struct` and `record
   class` both parse to `record_declaration` (kept in CSHARP_DECL_TYPES) —
   tree-sitter-c-sharp emits no `record_struct_declaration` /
   `record_class_declaration` node. Switch the two mock nodes to
   `record_declaration`.
 - extract-generic-type-args.test.ts: Java emits `generic_type` and Kotlin
   `user_type`+`type_projection`; `parameterized_type` is produced by no
   installed grammar, so the shared extractor returns [] for it. Convert the
   case to a documented negative assertion (real paths already covered by the
   generic_type cases).

No source behavior change: production export detection (record_declaration)
and generic type-arg extraction (generic_type / type_projection) were
already correct. Fixes the 3 CI failures on PR #1937.

Issue #1920 (epic #1919).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): keep parameterized_type generic-arg extraction (allowlisted)

Restore the `parameterized_type` branch in extractSimpleTypeName /
extractGenericTypeArgs (type-extractors/shared.ts) so a parameterized_type
node still yields its type arguments (List<User> -> [User]). Current
tree-sitter-java emits `generic_type` and tree-sitter-kotlin
`user_type`+`type_projection`, so this is a defensive alternate node kept
for grammar-version resilience; it is allowlisted in the node-type
validation gate with a documented justification rather than removed.

extract-generic-type-args.test.ts now asserts the User type argument is
captured from a parameterized_type node.

Issue #1920 (epic #1919).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): extract generic args from real grammar nodes, drop parameterized_type guess

extractGenericTypeArgs / extractSimpleTypeName special-cased `parameterized_type`,
a node type NO installed grammar emits (real parse: Java/TypeScript/Rust ->
generic_type, C# -> generic_name, Kotlin -> user_type). It was a guess masking a
real gap; remove it.

The genuine 'Kotlin alternate node type' is `user_type` (`List<User>` parses to
user_type > [type_identifier, type_arguments]), which the extractor returned []
for. Handle it: read a user_type's own type_arguments, else recurse into its
wrapped child (preserving the existing user_type > generic_type unwrap). No
production caller passes user_type today (Kotlin generics resolve via jvm.ts), so
this only makes the function's documented Kotlin contract correct — zero
behaviour change for current callers (Java/TS/C#/Rust pass generic_type/name).

Replace the mock parameterized_type test with REAL-PARSE coverage across
Java/TypeScript/C#/Rust/Kotlin (+ Java Map<String,User>) so a wrong node-type
guess can't silently pass again. Gate allowlist returns to empty.

Issue #1920 (epic #1919).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(test): wrap real-parse cases to prettier printWidth (CI format gate)

CI runs `prettier --check .` from the repo root (printWidth 100) and flagged the
new real-parse cases array's long single-line object literals. Wrap them.
Format-only; no behaviour change.

Issue #1920 (epic #1919).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ingestion): node-scoped field probe oracle for the literal gate (U1)

Add probeField(language, nodeType, field) — the node-scoped analogue of
probeNodeType: compiles `(<nodeType> <field>: (_)) @_` against the live
grammar and classifies TSQueryErrorStructure/Field -> dead, TSQueryErrorNodeType
(node absent here) -> unavailable, compile -> valid. Conservative-toward-valid
(supertype-typed fields make some wrong fields compile), so it never produces a
false positive. Add isFieldError classifier; make validateField's node-scoped
path membership-then-probe so node-types.json field under-reporting can't yield
a false `dead`.

Foundation for the node-scoped field validation gate (no gate behavior change
yet). Issue #1920 (epic #1919).

* test(ingestion): capture receiver node type + extend Mode-4 to type-env.ts (U2)

CollectedField gains receiverNodeType, captured conservatively by
receiverNodeTypeOf: only when a childForFieldName receiver is unambiguously
narrowed by a single enclosing positive guard (if (recv.type==='X') then-branch,
or switch case 'X') with no reassignment/shadowing of the receiver in the
enclosing function. Any uncertainty -> undefined (sound global fallback);
fail-safe (benign false negative, never a false positive).

Extend Mode-4's resolutionLayerFiles to include shared resolution files directly
under ingestion/ (type-env.ts), tagged with the full gated language set via
fileLanguages (valid-if-any). Entries now carry a language SET. Rename
Mode2Result -> ScanResult; fix the header doc (THREE -> FOUR modes).

Gate behavior unchanged until U3 consumes receiverNodeType. Issue #1920.

* feat(ingestion): node-scoped field gate + remove gate-flagged dead literals (U3, U4)

U3: the gate validates childForFieldName lookups node-scoped (validateField with
the captured receiverNodeType) and fails loudly on a degraded/vacuous run
(asserts resolutionLayerProgramOk, floors collected counts, requires
knownFailures empty).

U4: remove every dead field/literal the hardened gate flags — all behavior-neutral
(the dead disjunct never fired on reachable nodes; verified by real parse + the
type-extractor/resolution unit suites, 484 passing):
 - type-env.ts: parameterized_type (emitted by no grammar) and switch_block_label
   (real Java enhanced switch is switch_label/switch_rule) from the SyntaxNode .type sets
 - languages/csharp/captures.ts: generic_name has no `name` field -> firstNamedChild
 - type-extractors/jvm.ts: Kotlin property_declaration has no name/type fields
   (positional children) -> findChild; drop the else-branch `pattern` fallbacks x2
 - type-extractors/csharp.ts: drop the else-branch `pattern` fallback (parity with go/php/python/swift)

Gate green with node-scoped validation on; tsc clean. Closes the Mode-4
type-env coverage opened in U2. Latent follow-up: Java enhanced-switch arms
(switch_rule) are absent from NARROWING_BRANCH_TYPES — a separate behavior fix.
Issue #1920 (epic #1919).

* fix(java): exclude interleaved comments from call arity (U5)

tree-sitter-java emits block_comment/line_comment as named children of
argument_list; counting them inflated @reference.arity / @reference.parameter-
types / @reference.arg-names for any Java call with an inline comment, which
skews arity-based overload resolution (arity feeds call-processor symbol-ID
generation). Filter them at the single arg-list site (also corrects the
downstream args.map). The previously-removed `comment` literal never matched —
the real nodes are block_comment/line_comment (the #1920 gate lesson).

Isolated from the behavior-neutral gate units (U1-U4) since this changes
production graph output. Java resolver suite 178/178; new java-call-arity test
covers block/line comments, leading comment, constructor calls, and the
no-comment regression. Issue #1920 (epic #1919).

* test(ingestion): cover Kotlin/C# multi-arg generics + tighten probe assertions (U6)

- extract-generic-type-args: add real-parse Kotlin Map<String,User>
  (user_type > type_arguments > type_projection) and C# Dictionary<string,User>
  (generic_name > type_argument_list) multi-arg cases.
- grammar-introspection: the probeNodeType test now asserts 'dead' for a bogus
  node on installed grammars (not merely not-throw), and documents the null-model
  split (validateField -> unavailable; validateNodeType -> still probes the live
  grammar). Issue #1920 (epic #1919).

* style(test): apply root prettier formatting (CI format gate)

CI runs `prettier --check .` from the repo root (printWidth 100); the gitnexus/
pre-commit hook formatted these two files differently. Format-only, no behavior
change. Issue #1920.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:29:41 +01:00

206 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import {
getLanguageGrammar,
isLanguageAvailable,
} from '../../src/core/tree-sitter/parser-loader.js';
import {
GATED_LANGUAGES,
loadGrammarModel,
probeNodeType,
probeField,
validateNodeType,
validateField,
isNodeTypeError,
isFieldError,
} from '../helpers/grammar-introspection.js';
describe('grammar-introspection helper', () => {
describe('loadGrammarModel — membership set', () => {
it('builds named, anonymous, supertype node types and per-node fields for Python', () => {
const model = loadGrammarModel(SupportedLanguages.Python);
expect(model).not.toBeNull();
// named node, anonymous token, and a supertype name are all members
expect(model!.nodeTypes.has('function_definition')).toBe(true);
expect(model!.nodeTypes.has('{')).toBe(true);
expect(model!.nodeTypes.has('expression')).toBe(true);
// per-node fields
const fields = model!.fieldsByNode.get('function_definition');
expect(fields).toBeDefined();
expect(fields!.has('name')).toBe(true);
expect(fields!.has('body')).toBe(true);
expect(fields!.has('parameters')).toBe(true);
expect(model!.allFields.has('name')).toBe(true);
});
it('unions typescript ∪ tsx so JSX-only nodes are members', () => {
const model = loadGrammarModel(SupportedLanguages.TypeScript);
expect(model).not.toBeNull();
expect(model!.nodeTypes.has('jsx_element')).toBe(true); // tsx-only
expect(model!.nodeTypes.has('type_annotation')).toBe(true); // typescript
});
it('resolves PHP to the php_only variant (excludes embedded-HTML nodes)', () => {
const model = loadGrammarModel(SupportedLanguages.PHP);
expect(model).not.toBeNull();
expect(model!.nodeTypes.has('function_definition')).toBe(true);
// text_interpolation exists only in the full `php` (embedded-HTML) grammar
expect(model!.nodeTypes.has('text_interpolation')).toBe(false);
});
it('excludes COBOL and never throws for any gated language', () => {
expect(GATED_LANGUAGES).not.toContain(SupportedLanguages.Cobol);
for (const lang of GATED_LANGUAGES) {
// returns a model (installed) or null (optional grammar absent) — never throws
expect(() => loadGrammarModel(lang)).not.toThrow();
}
});
});
describe('probeNodeType — live-grammar fallback', () => {
it('classifies an absent node type as dead and a real one as valid (Rust)', () => {
if (!isLanguageAvailable(SupportedLanguages.Rust)) return;
expect(probeNodeType(SupportedLanguages.Rust, 'method_call_expression')).toBe('dead');
expect(probeNodeType(SupportedLanguages.Rust, 'call_expression')).toBe('valid');
});
it('accepts an anonymous token via the "x" form (Python)', () => {
if (!isLanguageAvailable(SupportedLanguages.Python)) return;
expect(probeNodeType(SupportedLanguages.Python, '{')).toBe('valid');
});
it('accepts a supertype via membership without needing a probe (Python)', () => {
const model = loadGrammarModel(SupportedLanguages.Python);
expect(validateNodeType(SupportedLanguages.Python, model, 'expression')).toBe('valid');
});
it('classifies a bogus node type as dead for installed grammars (never just not-throw)', () => {
for (const lang of GATED_LANGUAGES) {
const verdict = probeNodeType(lang, 'definitely_not_a_node_type_xyz');
// installed → an absent node type is 'dead'; uninstalled optional grammar → 'unavailable'.
if (isLanguageAvailable(lang)) {
expect(verdict, `${lang} should classify a bogus node type as dead`).toBe('dead');
} else {
expect(verdict).toBe('unavailable');
}
}
});
it('distinguishes the null-model paths: validateField unavailable vs validateNodeType still probes', () => {
// validateField short-circuits to unavailable with no model (no grammar set).
expect(validateField(null, 'anything', 'some_node')).toBe('unavailable');
// validateNodeType, by contrast, still probes the LIVE grammar when the model
// is null, so for an installed language a bogus node type is 'dead'.
if (isLanguageAvailable(SupportedLanguages.Python)) {
expect(validateNodeType(SupportedLanguages.Python, null, 'definitely_not_xyz')).toBe(
'dead',
);
}
});
});
describe('isNodeTypeError — classifier self-test', () => {
it('matches the TSQueryErrorNodeType message and rejects valid queries', () => {
if (!isLanguageAvailable(SupportedLanguages.Rust)) return;
const grammar = getLanguageGrammar(SupportedLanguages.Rust) as ConstructorParameters<
typeof Parser.Query
>[0];
let caught: unknown;
try {
// method_call_expression does not exist in tree-sitter-rust
new Parser.Query(grammar, '(method_call_expression) @_');
} catch (e) {
caught = e;
}
expect(caught).toBeDefined();
// If a future tree-sitter bump changes the wording, this fails loudly
// instead of silently passing every literal.
expect(isNodeTypeError(caught)).toBe(true);
// a valid node type compiles without throwing
expect(() => new Parser.Query(grammar, '(call_expression) @_')).not.toThrow();
});
});
describe('validateField', () => {
it('passes a real node-scoped field and fails a non-existent one', () => {
const model = loadGrammarModel(SupportedLanguages.Python);
expect(validateField(model, 'name', 'function_definition')).toBe('valid');
expect(validateField(model, 'nonexistent_field_xyz', 'function_definition')).toBe('dead');
});
it('rescues a JSON-under-reported / supertype-permissive field via the probe (not a false positive)', () => {
// C# `parameter` has no `pattern` field (TSQueryErrorStructure), but
// `binary_expression` accepts `pattern` through its supertype-typed slots,
// so the probe compiles and validateField must NOT flag it dead. This pins
// the conservative-toward-valid direction: a membership miss falls through
// to the probe, never straight to dead.
if (!isLanguageAvailable(SupportedLanguages.CSharp)) return;
const model = loadGrammarModel(SupportedLanguages.CSharp);
expect(validateField(model, 'pattern', 'parameter')).toBe('dead'); // structurally impossible
expect(validateField(model, 'pattern', 'binary_expression')).toBe('valid'); // probe-rescued
});
});
describe('probeField — conservative node-scoped field oracle', () => {
it('classifies a structurally-impossible field as dead (C# parameter/pattern)', () => {
if (!isLanguageAvailable(SupportedLanguages.CSharp)) return;
// (parameter pattern: (_)) throws TSQueryErrorStructure
expect(probeField(SupportedLanguages.CSharp, 'parameter', 'pattern')).toBe('dead');
// an unknown field name throws TSQueryErrorField
expect(probeField(SupportedLanguages.CSharp, 'parameter', 'total_garbage_field')).toBe(
'dead',
);
// a real field compiles
expect(probeField(SupportedLanguages.CSharp, 'parameter', 'type')).toBe('valid');
});
it('returns unavailable (not dead) when the node type is absent in the grammar', () => {
if (!isLanguageAvailable(SupportedLanguages.Java)) return;
// `parameter` is not a Java node (Java uses `formal_parameter`) → NodeType error
// → unavailable, so multi-language ANY-semantics can defer to the right grammar.
expect(probeField(SupportedLanguages.Java, 'parameter', 'name')).toBe('unavailable');
});
it('is conservative-toward-valid for supertype-typed fields (never false-positive)', () => {
if (!isLanguageAvailable(SupportedLanguages.CSharp)) return;
// `binary_expression` has no `pattern` field, but its supertype-typed slots
// make the query compile → valid. The probe errs toward valid by design.
expect(probeField(SupportedLanguages.CSharp, 'binary_expression', 'pattern')).toBe('valid');
});
it('never throws for any gated language', () => {
for (const lang of GATED_LANGUAGES) {
expect(() => probeField(lang, 'some_node', 'some_field')).not.toThrow();
}
});
});
describe('isFieldError — classifier self-test', () => {
it('matches TSQueryErrorStructure and TSQueryErrorField but not NodeType', () => {
if (!isLanguageAvailable(SupportedLanguages.CSharp)) return;
const grammar = getLanguageGrammar(SupportedLanguages.CSharp) as ConstructorParameters<
typeof Parser.Query
>[0];
const grab = (q: string): unknown => {
try {
new Parser.Query(grammar, q);
return undefined;
} catch (e) {
return e;
}
};
const structureErr = grab('(parameter pattern: (_)) @_'); // TSQueryErrorStructure
const fieldErr = grab('(parameter total_garbage_field: (_)) @_'); // TSQueryErrorField
const nodeTypeErr = grab('(nonexistent_node_xyz) @_'); // TSQueryErrorNodeType
expect(structureErr).toBeDefined();
expect(fieldErr).toBeDefined();
expect(nodeTypeErr).toBeDefined();
expect(isFieldError(structureErr)).toBe(true);
expect(isFieldError(fieldErr)).toBe(true);
// a node-type error is NOT a field error (it routes to `unavailable`, not `dead`)
expect(isFieldError(nodeTypeErr)).toBe(false);
expect(isNodeTypeError(nodeTypeErr)).toBe(true);
});
});
});