GitNexus/gitnexus/test/helpers/grammar-introspection.ts
mengkaka 4154b63131
feat(indexing): add Objective-C semantic indexing support (#3179)
* docs: add Objective-C fork provider notes

* feat(objective-c): add deterministic provider and grammar

* feat(objective-c): finalize provider MVP

* fix(objective-c): harden provider integration

* fix(objective-c): normalize bare macro markers

* docs(objective-c): integrate provider documentation

* fix(objective-c): harden resolution and header classification

* fix(objective-c): complete provider follow-ups

* fix: address Objective-C review follow-ups

* chore: format Objective-C grammar sources

* fix(objective-c): harden review follow-ups

* Address PR review feedback (#3179)

Keep Objective-C chunking and macro recovery aligned with the grammar, and stop Community MEMBER_OF edges from leaking into symbol context.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address follow-up review on ObjC chunking and language fallback.

Keep preprocessor directive text from changing file-scope brace depth, group real ivar nodes, skip header modifiers, and restore Rakefile/Gemfile detection through getLanguageFromFilename.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Parse Objective-C headers with the objc grammar in embeddings.

ensureAndParse and structural extraction now use the same content classifier as ingest, including method snippets from .h files, so Protocol/Category/Class chunks are not re-parsed as C++.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Keep file-scope macro elision off C line splices and @interface/@protocol/@implementation bodies, and attach ivar attributes to the following instance variable when chunking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(bench): rebaseline Objective-C CSV emit

* feat(objective-c): add workspace resolution and linear emit benches

Plain .h files are classified as C++, so the ObjC pass could not
resolve #import of those headers. Load a C/C#-style workspace once
per pass, and keep protocol-candidate USES linear.

Refs #3179

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

- Compare LadybugDB labels() as a scalar when excluding Community MEMBER_OF edges.
- Walk superclass members, skip file-static C sibling defs, and ignore comments in ObjC header/macro scans.

Note: pre-existing failure in objective-c-provider integration (worker-pool ready timeout) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Emit Objective-C declaration captures so compilation-unit siblings can share
header/implementation bindings, and keep class vs protocol visibility groups
distinct.

Note: pre-existing failure in worker-pool startup (GITNEXUS_WORKER_READY_TIMEOUT_MS) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Emit every comma-separated property/ivar declarator, and count @interface
after a multiline block comment closes so in-declaration macros stay intact.

Note: pre-existing failure in worker-pool startup (GITNEXUS_WORKER_READY_TIMEOUT_MS) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ximengkai <ximengkai@soyoung.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:40:21 +00:00

335 lines
13 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.

/**
* Grammar introspection helper for the tree-sitter node-type / field-name
* validation gate (issue #1920).
*
* Two oracles, layered (see the plan's KTD1):
* 1. A fast **membership set** built from each grammar's static
* `node-types.json` — the union of every top-level `type`, every
* `subtypes[].type`, and every children/per-field `types[].type`,
* retaining anonymous (`named:false`) tokens and supertype names.
* 2. A `probeNodeType` **authoritative fallback** that compiles a probe
* query against the *live* grammar — used for any literal the static
* JSON under-reports (regex / `token(...)` tokens, aliased nodes).
*
* This file lives under `test/` and is therefore allowed to name languages
* (the AGENTS.md "shared pipeline code must not name languages" rule applies
* to `src/core/ingestion/`, not to test helpers). The live-grammar access and
* the tsx/php_only variant handling are delegated to the production
* `parser-loader.ts` so the gate validates against exactly the grammar the
* runtime uses.
*/
import Parser from 'tree-sitter';
import { createRequire } from 'node:module';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import {
getLanguageGrammar,
isLanguageAvailable,
resolveLanguageKey,
} from '../../src/core/tree-sitter/parser-loader.js';
import {
VENDORED_GRAMMAR_PACKAGES,
vendoredGrammarDir,
} from '../../src/core/tree-sitter/vendored-grammars.js';
const _require = createRequire(import.meta.url);
/**
* Per-language grammar package + the `node-types.json` subpath(s) to union.
* COBOL is intentionally absent (regex preprocessor, no grammar). Vue has no
* grammar of its own and reuses tree-sitter-typescript, so its literals are
* validated against the typescript ∪ tsx node set (JSX/TSX-only nodes
* included). The package names mirror `parser-loader.ts` `SOURCES`.
*/
const GRAMMAR_PACKAGES: Partial<Record<SupportedLanguages, { pkg: string; subpaths: string[] }>> = {
[SupportedLanguages.JavaScript]: {
pkg: 'tree-sitter-javascript',
subpaths: ['src/node-types.json'],
},
[SupportedLanguages.TypeScript]: {
pkg: 'tree-sitter-typescript',
subpaths: ['typescript/src/node-types.json', 'tsx/src/node-types.json'],
},
[SupportedLanguages.Python]: { pkg: 'tree-sitter-python', subpaths: ['src/node-types.json'] },
[SupportedLanguages.Java]: { pkg: 'tree-sitter-java', subpaths: ['src/node-types.json'] },
[SupportedLanguages.C]: { pkg: 'tree-sitter-c', subpaths: ['src/node-types.json'] },
[SupportedLanguages.CPlusPlus]: { pkg: 'tree-sitter-cpp', subpaths: ['src/node-types.json'] },
[SupportedLanguages.CSharp]: { pkg: 'tree-sitter-c-sharp', subpaths: ['src/node-types.json'] },
[SupportedLanguages.Go]: { pkg: 'tree-sitter-go', subpaths: ['src/node-types.json'] },
[SupportedLanguages.Ruby]: { pkg: 'tree-sitter-ruby', subpaths: ['src/node-types.json'] },
[SupportedLanguages.Rust]: { pkg: 'tree-sitter-rust', subpaths: ['src/node-types.json'] },
// tree-sitter-php's runtime export is `php_only` (see parser-loader), so the
// gate must validate against that variant's node set, not the embedded-HTML
// `php` grammar.
[SupportedLanguages.PHP]: { pkg: 'tree-sitter-php', subpaths: ['php_only/src/node-types.json'] },
[SupportedLanguages.Kotlin]: { pkg: 'tree-sitter-kotlin', subpaths: ['src/node-types.json'] },
[SupportedLanguages.Swift]: { pkg: 'tree-sitter-swift', subpaths: ['src/node-types.json'] },
[SupportedLanguages.Dart]: { pkg: 'tree-sitter-dart', subpaths: ['src/node-types.json'] },
[SupportedLanguages.Vue]: {
pkg: 'tree-sitter-typescript',
subpaths: ['typescript/src/node-types.json', 'tsx/src/node-types.json'],
},
[SupportedLanguages.Zig]: {
pkg: 'tree-sitter-zig',
subpaths: ['src/node-types.json'],
},
};
/** Languages the gate validates (everything with a grammar package). */
export const GATED_LANGUAGES: readonly SupportedLanguages[] = Object.keys(
GRAMMAR_PACKAGES,
) as SupportedLanguages[];
export interface GrammarModel {
language: SupportedLanguages;
/** Every node-type string the grammar can surface (named + anonymous + supertypes). */
nodeTypes: ReadonlySet<string>;
/** Valid field names per node type. */
fieldsByNode: ReadonlyMap<string, ReadonlySet<string>>;
/** Union of every field name across all node types (sound global existence check). */
allFields: ReadonlySet<string>;
}
// ---- node-types.json shape (only the parts we read) ----
interface ChildType {
type: string;
named: boolean;
}
interface FieldInfo {
types?: ChildType[];
}
interface NodeTypeEntry {
type: string;
named?: boolean;
fields?: Record<string, FieldInfo>;
children?: { types?: ChildType[] };
subtypes?: ChildType[];
}
/** Resolve the on-disk directory of an installed package, or null if absent. */
function resolvePackageDir(pkg: string): string | null {
// Vendored grammars (c/dart/proto/swift/kotlin/objc/zig) are NOT in node_modules — they
// load from vendor/ by absolute path (vendored-grammars.ts / #2111), so resolve
// their node-types.json from there rather than via _require.resolve.
if (VENDORED_GRAMMAR_PACKAGES.has(pkg)) {
const dir = vendoredGrammarDir(pkg);
return existsSync(dir) ? dir : null;
}
try {
return dirname(_require.resolve(`${pkg}/package.json`));
} catch {
/* package.json may be blocked by an `exports` map — fall back to main */
}
try {
let dir = dirname(_require.resolve(pkg));
for (let i = 0; i < 10; i++) {
if (existsSync(join(dir, 'package.json'))) return dir;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
} catch {
/* not installed (optional grammar) */
}
return null;
}
function addChildTypes(into: Set<string>, types: ChildType[] | undefined): void {
if (!types) return;
for (const t of types) into.add(t.type);
}
/**
* Build the membership model for one language by unioning its node-types.json
* file(s). Returns null when no node-types.json can be resolved (e.g. an
* optional grammar is not installed) so callers can skip rather than fail.
*/
export function loadGrammarModel(language: SupportedLanguages): GrammarModel | null {
const entry = GRAMMAR_PACKAGES[language];
if (!entry) return null;
const dir = resolvePackageDir(entry.pkg);
if (!dir) return null;
const nodeTypes = new Set<string>();
const fieldsByNode = new Map<string, Set<string>>();
const allFields = new Set<string>();
let read = 0;
for (const subpath of entry.subpaths) {
const file = join(dir, subpath);
if (!existsSync(file)) continue;
let parsed: NodeTypeEntry[];
try {
parsed = JSON.parse(readFileSync(file, 'utf8')) as NodeTypeEntry[];
} catch {
continue;
}
read += 1;
for (const node of parsed) {
if (typeof node.type === 'string') nodeTypes.add(node.type);
addChildTypes(nodeTypes, node.subtypes);
addChildTypes(nodeTypes, node.children?.types);
if (node.fields) {
const fieldSet = fieldsByNode.get(node.type) ?? new Set<string>();
for (const [fieldName, info] of Object.entries(node.fields)) {
fieldSet.add(fieldName);
allFields.add(fieldName);
addChildTypes(nodeTypes, info.types);
}
fieldsByNode.set(node.type, fieldSet);
}
}
}
if (read === 0) return null;
return { language, nodeTypes, fieldsByNode, allFields };
}
/** True when the thrown object is tree-sitter's "invalid node type" query error. */
export function isNodeTypeError(err: unknown): boolean {
return err instanceof Error && /TSQueryErrorNodeType/.test(err.message);
}
/**
* True when the thrown object is tree-sitter's "field invalid for this node"
* query error. `TSQueryErrorStructure` is thrown when a field exists on other
* nodes but not on the queried one (the common dead-field case, e.g.
* `(parameter pattern: (_))`); `TSQueryErrorField` is thrown for a field name
* unknown to the grammar entirely. Both mean the field is dead on that node.
* `TSQueryErrorNodeType` is deliberately NOT a field error — it means the node
* type is absent in this grammar, which `probeField` reports as `unavailable`
* (abstain), never `dead`.
*/
export function isFieldError(err: unknown): boolean {
return err instanceof Error && /TSQueryError(Structure|Field)/.test(err.message);
}
/** Escape a string so it is safe inside a `"..."` anonymous-node query literal. */
function escapeAnonymous(literal: string): string {
return literal.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/** The live grammar object(s) a language's literals should be probed against. */
function grammarsFor(language: SupportedLanguages): unknown[] {
if (!isLanguageAvailable(language)) return [];
const grammars: unknown[] = [getLanguageGrammar(language)];
// TypeScript and Vue (which reuses the TS grammar) also have a tsx grammar
// with JSX-only node types; probe both.
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.Vue) {
try {
// resolveLanguageKey only switches TypeScript -> tsx on a .tsx path.
const tsx = getLanguageGrammar(SupportedLanguages.TypeScript, 'x.tsx');
if (resolveLanguageKey(SupportedLanguages.TypeScript, 'x.tsx').endsWith(':tsx')) {
grammars.push(tsx);
}
} catch {
/* tsx unavailable — base grammar still probed */
}
}
return grammars;
}
/**
* Authoritative fallback: ask the live grammar whether `literal` can be a node
* type. A literal is `valid` if it compiles in ANY of the named `(x)`,
* anonymous `"x"`, or supertype `(_x)` forms against ANY of the language's
* grammars; `dead` only if every form is rejected; `unavailable` if no grammar
* loads (so the caller skips rather than fails). See KTD1.
*/
export function probeNodeType(
language: SupportedLanguages,
literal: string,
): 'valid' | 'dead' | 'unavailable' {
const grammars = grammarsFor(language);
if (grammars.length === 0) return 'unavailable';
const forms = [`(${literal}) @_`, `"${escapeAnonymous(literal)}" @_`, `(_${literal}) @_`];
for (const grammar of grammars) {
for (const form of forms) {
try {
// Constructing the Query is the validation: it throws
// TSQueryErrorNodeType iff the node type cannot exist.
new Parser.Query(grammar as ConstructorParameters<typeof Parser.Query>[0], form);
return 'valid';
} catch {
/* this (form, grammar) rejected — try the next */
}
}
}
return 'dead';
}
/**
* Field-existence oracle — the node-scoped analogue of `probeNodeType`. Compiles
* a field-bearing probe query `(<nodeType> <field>: (_)) @_` against the live
* grammar(s) for `language`:
* - compiles on ANY grammar → `valid`
* - rejected as a field/structure error on a grammar that HAS the node, and
* never accepted → `dead`
* - the node type is absent in every probed grammar (only `TSQueryErrorNodeType`),
* or no grammar loads → `unavailable` (abstain — never `dead`, so multi-language
* valid-if-any can defer to the grammar that actually emits the node)
*
* Conservative-toward-valid: supertype-typed fields make some structurally-wrong
* field queries compile, so the probe can return `valid` for a semantically wrong
* field. That is the sound direction — false negatives only, never a false
* positive that would block CI on correct code.
*/
export function probeField(
language: SupportedLanguages,
nodeType: string,
field: string,
): 'valid' | 'dead' | 'unavailable' {
const grammars = grammarsFor(language);
if (grammars.length === 0) return 'unavailable';
const form = `(${nodeType} ${field}: (_)) @_`;
let sawFieldDead = false;
for (const grammar of grammars) {
try {
new Parser.Query(grammar as ConstructorParameters<typeof Parser.Query>[0], form);
return 'valid';
} catch (err) {
if (isFieldError(err)) sawFieldDead = true;
// TSQueryErrorNodeType (node absent here) or any other error → abstain
}
}
return sawFieldDead ? 'dead' : 'unavailable';
}
/**
* Combined check used by the gate: fast membership first, authoritative live
* probe only for literals the static JSON does not list. Returns `valid`,
* `dead`, or `unavailable`.
*/
export function validateNodeType(
language: SupportedLanguages,
model: GrammarModel | null,
literal: string,
): 'valid' | 'dead' | 'unavailable' {
if (model && model.nodeTypes.has(literal)) return 'valid';
return probeNodeType(language, literal);
}
/**
* Field-name validation. Node-scoped when `receiverNodeType` is given:
* membership hit is authoritative, and a miss falls through to the live
* `probeField` rather than declaring `dead` — node-types.json is not a sound
* negative oracle for fields (it can under-report). Without a receiver it is a
* sound global existence check. Returns `unavailable` when the model could not
* be loaded. See KTD1.
*/
export function validateField(
model: GrammarModel | null,
field: string,
receiverNodeType?: string,
): 'valid' | 'dead' | 'unavailable' {
if (!model) return 'unavailable';
if (receiverNodeType) {
const scoped = model.fieldsByNode.get(receiverNodeType);
if (scoped && scoped.has(field)) return 'valid';
return probeField(model.language, receiverNodeType, field);
}
return model.allFields.has(field) ? 'valid' : 'dead';
}