This commit is contained in:
mengkaka 2026-09-05 15:35:00 +00:00 committed by GitHub
commit ab40f5b1de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 715527 additions and 73 deletions

View file

@ -21,7 +21,7 @@
* node update-vendored-grammars.mjs # detect only JSON report on stdout
* node update-vendored-grammars.mjs --apply X # re-vendor grammar X in place
*
* tree-sitter-c is MONITORED but report-only (`hold`): it is ABI-pinned at 0.21.4
* tree-sitter-c and tree-sitter-objc are MONITORED but report-only (`hold`): c is ABI-pinned at 0.21.4
* (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
* available c update is detected + reported but never auto-applied even if it is
* ABI-13/14. A maintainer re-vendors it deliberately.

View file

@ -6,6 +6,11 @@
"upstream": { "npm": "tree-sitter-c" },
"hold": "ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping"
},
"objc": {
"name": "tree-sitter-objc",
"upstream": { "npm": "tree-sitter-objc" },
"hold": "Pinned at 3.0.2 for the Objective-C provider MVP; carries darwin/linux arm64+x64 prebuilds compatible with the current tree-sitter runtime (linux-arm64 built from vendored source because the upstream npm artifact is mislabeled)"
},
"swift": {
"name": "tree-sitter-swift",
"upstream": { "npm": "tree-sitter-swift" }

View file

@ -7,7 +7,7 @@ name: Build tree-sitter prebuilds
#
# Grammars covered here (the at-risk set — everything else already ships 6
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
# All five are vendored under gitnexus/vendor/; `kind` (below) only picks where
# All seven are vendored under gitnexus/vendor/; `kind` (below) only picks where
# the build job fetches the C source to compile:
# - tree-sitter-c (vendored prebuild-only; built from the published npm
# package — closes upstream's 4/6 ARM gap #2116 for a
@ -17,6 +17,8 @@ name: Build tree-sitter prebuilds
# - tree-sitter-kotlin (vendored source; built from gitnexus/vendor/ — pinned to
# an unreleased main commit for `fun interface` support
# (#169) that no npm release carries yet)
# - tree-sitter-objc (vendored source; built from gitnexus/vendor/ — pinned
# for the Objective-C provider MVP)
# - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its
# prebuilds were originally upstream-shipped, now
# GitNexus-cross-built like the rest for uniformity)
@ -24,13 +26,13 @@ name: Build tree-sitter prebuilds
# off npm optionalDependency so `npm i -g gitnexus`
# no longer warns on peerOptional tree-sitter@^0.22.1.
# Upstream linux-arm64 prebuild is a mispackaged
# x86-64 binary; this workflow rebuilds all six.)
# x86-64 binary; this workflow rebuilds all seven.)
#
# Output: gitnexus/vendor/<grammar>/prebuilds/<platform-arch>/<grammar>.node for
# all 6 targets ({linux,darwin,win32}-{x64,arm64}). tree-sitter grammars are
# N-API, so one ABI-stable .node per platform-arch works across all Node majors.
#
# COST DISCIPLINE — this is a HEAVY native matrix (up to 3 grammars x 6 runners,
# COST DISCIPLINE — this is a HEAVY native matrix (up to 7 grammars x 6 runners,
# incl. macOS + arm64). It is DELIBERATELY NOT wired into normal PR/push CI. It
# runs only:
# 1. on manual dispatch (workflow_dispatch); or
@ -61,7 +63,7 @@ on:
workflow_dispatch:
inputs:
grammars:
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift,zig), or "all".'
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,objc,swift,zig), or "all".'
required: false
type: string
default: 'all'
@ -93,7 +95,7 @@ on:
- '!gitnexus/vendor/tree-sitter-*/prebuilds/**'
# Self-test: re-run the guard if a future grammar pin is reintroduced in
# the main package.json (optionalDependencies fallback). No-op otherwise —
# all six grammars are now fully vendored (kotlin and zig included).
# all seven grammars are now fully vendored (including kotlin, objc, and zig).
- 'gitnexus/package.json'
# Self-test: re-run the guard (normally a no-op) when the recipe changes.
- '.github/workflows/build-tree-sitter-prebuilds.yml'
@ -163,6 +165,9 @@ jobs:
// unreleased main commit for `fun interface` support (#169) that no
// npm release carries yet — so it must build from the vendored source.
kotlin: { name: 'tree-sitter-kotlin', kind: 'vendored' },
// Objective-C is vendored WITH its source and its native bindings
// must be recut together with the pinned grammar snapshot.
objc: { name: 'tree-sitter-objc', kind: 'vendored' },
// swift is vendored WITH its source (parser.c/scanner.c/binding.gyp),
// so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds
// were originally upstream-shipped; rebuilding them here unifies it.
@ -505,6 +510,7 @@ jobs:
dart: "void main() { print(\"hi\"); }",
proto: "syntax = \"proto3\";\nmessage M { int32 id = 1; }",
kotlin: "fun main() { println(\"hi\") }",
objc: "@interface GNValidationProbe : NSObject\n@end",
swift: "func greet() { print(\"hi\") }",
zig: "pub fn main() void {}",
};

View file

@ -4,7 +4,7 @@ name: Tree-sitter Upgrade Readiness
# 1. Peer-dep compatibility — can each NPM-installed grammar install cleanly
# with tree-sitter@0.25.0 without --legacy-peer-deps?
# 2. Vendored grammars — each grammar in .github/vendored-grammars.json
# (c/swift/kotlin/dart/proto) is classified by its vendored ABI, read
# (c/swift/kotlin/dart/proto/objc) is classified by its vendored ABI, read
# straight from gitnexus/vendor/<name>/src/parser.c (NOT node_modules,
# which is never populated for vendored grammars — that mismatch is why
# the report used to render bare "?" placeholders, #858).

2
.gitignore vendored
View file

@ -72,6 +72,8 @@ eval/.hypothesis/
# Local docs — planning output (gitnexus-plan / gitnexus-work) stays local, not tracked
docs/*
!docs/fork/
!docs/fork/**
gitnexus/test/fixtures/mini-repo/*.md
gitnexus/test/fixtures/mini-repo/.claude

View file

@ -39,6 +39,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
## Reference docs
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
- **Objective-C provider work:** read **[docs/languages/objective-c-provider.md](docs/languages/objective-c-provider.md)** before changing Objective-C parsing or resolution.
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro``MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP rules in `gitnexus:start` block below.

View file

@ -377,7 +377,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
## Language-agnostic graph feeding
16 languages → single unified graph. Four abstraction layers:
18 languages → single unified graph. Four abstraction layers:
```
Unified Graph Schema (44 node types, 21 relationship types)
@ -405,7 +405,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
18 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
### Unified capture tags
@ -542,4 +542,5 @@ Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents
- [TESTING.md](TESTING.md) — how to run tests
- [docs/languages/objective-c-provider.md](docs/languages/objective-c-provider.md) — Objective-C provider behavior and limits
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage

View file

@ -657,6 +657,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Objective-C | ✓ | — | ✓ | ✓ | ✓ | — | — | — | — |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |

View file

@ -0,0 +1,107 @@
# Objective-C Language Provider
Status: implemented
The deterministic provider is covered by focused unit and integration tests. The parser-loader ABI
smoke runs in the published multi-OS test matrix, and the native prebuild workflow owns
Objective-C together with all six vendored grammar targets. This status describes the implemented
MVP; it does not promise full Objective-C runtime dispatch.
## Goal
Add deterministic, symbol-level Objective-C analysis to GitNexus. The first release must support high-confidence code navigation and direct static dependency analysis for `.m`, `.mm`, and Objective-C `.h` files. It must not imply that Objective-C runtime dispatch is fully resolved.
The provider belongs in the existing language-provider and scope-resolution extension points. Shared ingestion code must remain language-agnostic.
## Compatibility contract
- Existing language detection and parsing must remain unchanged.
- A `.h` file must be classified from its content or surrounding context; it cannot be unconditionally claimed by Objective-C because C and C++ also use that extension.
- If Objective-C grammar loading fails, the error must clearly name the missing provider/grammar and cannot corrupt a previously valid index.
- Provider and grammar versions must be stored in index metadata. A version change that can alter node identity or edges requires a full rebuild.
- No LLM participates in parsing, name resolution, or edge creation. Analysis is Tree-sitter plus deterministic static resolution.
## MVP model
The provider must extract and connect:
- Classes, superclasses, protocols, categories, class extensions, properties, ivars, C functions, imports, declarations, and implementations.
- Instance and class methods, preserving their complete multi-part selector.
- Inheritance, protocol conformance, import, declaration/implementation, host-class/category, and statically resolved call relationships.
Stable identity must include enough ownership to distinguish same-named methods. Recommended forms are:
```text
objc:class:<ClassName>
objc:protocol:<ProtocolName>
objc:category:<HostClass>:<CategoryName>
objc:method:<Owner>:-:<selector>
objc:method:<Owner>:+:<selector>
objc:function:<qualified-or-file-scoped-name>
```
For example, `-loadData:completion:` and `+loadData:completion:` are different symbols. A category method remains linked to both its category and host class; querying the host class must expose distributed implementations.
## Resolution policy
Resolution must be conservative. A missing or dynamic target is evidence of uncertainty, not proof that no target exists.
| Receiver case | Required result |
| --- | --- |
| Explicit class name, `self`, or `super` | Resolve when the owner is statically known. |
| Local, parameter, property, or ivar with known static type | Resolve to matching owner and selector. |
| Protocol-typed receiver | Link the protocol method and identify possible implementations as candidates. |
| `id`, `Class`, macros, reflection, `performSelector:`, `NSInvocation`, runtime injection, or unknown type | Store selector/location with `resolution=unresolved`; do not emit a certain call edge. |
The provider should first collect file-local declarations, imports, and types, then resolve across the repository. It must use structured Tree-sitter captures or AST traversal, not regular expressions over source text. Multi-part selectors, block arguments, nullability annotations, generics, macros, and multiline declarations make a regex-only extractor unsafe.
## Imports and incremental correctness
- Resolve quoted project imports against the current directory, configured include roots, and indexed headers. Model framework imports as external-module evidence without downloading SDK source.
- Merge `@interface`, `@implementation`, categories, and extensions across files.
- A changed header, protocol, class declaration, or category invalidates importing and affected implementation/call-resolution state. Incremental output after such a change must match a full rebuild.
- Index metadata must record provider version, grammar version, include/exclude configuration, and parsing options used for resolution.
## Implementation sequence
1. Add and package a pinned Objective-C Tree-sitter grammar; verify macOS arm64 and the production Linux runner can load it.
2. Add language detection for `.m`, `.mm`, and content-classified `.h` files.
3. Implement AST extraction and stable IDs for declarations and definitions.
4. Implement repository-level merge, imports, inheritance, protocol, and category relationships.
5. Add conservative message-send resolution and explicit unresolved evidence.
6. Integrate invalidation, metadata comparison, MCP/CLI output, and fixtures.
## Fixtures and acceptance
Create a minimal Objective-C fixture containing a class, protocol, category, extension, superclass, properties, ivars, C function, imports, multi-part selector, block parameter, `self`, `super`, protocol receiver, and `id` receiver. Use `symodulebridge` as a real integration fixture after the minimal suite is stable.
The acceptance bar is:
- `query "SYModuleCaller"` yields class/method semantic nodes, not only file nodes.
- `context "SYModuleCaller" --file <path>` yields declaration, implementation, imports, and known references.
- Known statically typed message sends create call edges; dynamic sends are marked unresolved.
- Same selector on multiple classes, a category override, and `+` versus `-` methods remain distinct.
- A `.m`, `.h`, protocol, or category edit produces results equivalent to a clean rebuild.
- Generated documentation, dependency directories, and build output are excluded through explicit indexing configuration.
## Non-goals
The MVP does not promise exact runtime type inference for `id` or `instancetype`, reflection, swizzling, arbitrary category replacement, dynamic selector construction, or complete impact analysis across every runtime dispatch path. Tool results must surface confidence and unresolved evidence rather than presenting guesses as certain graph facts.
## Current implementation coverage
Implemented capabilities:
- Vendored `tree-sitter-objc` grammar, registered through the existing Tree-sitter loader.
- `.m` and `.mm` language mapping plus content-based `.h` classification so plain C/C++ headers are not unconditionally claimed.
- LanguageProvider extraction for classes, protocols, categories, extensions, methods, properties, ivars, C functions, imports, unresolved message evidence, stable Objective-C qualified names, and provider/grammar metadata.
- Length-preserving preprocessing of bare, file-scope all-caps macro markers before Tree-sitter parsing. This recovers declarations after wrappers such as `RCT_EXTERN_C_BEGIN` / `RCT_EXTERN_C_END` without expanding macros or adding framework-specific rules.
- ScopeResolver edges for imports, inheritance, protocol conformance, category host membership, implementation evidence, and conservative static message sends.
- Persisted query/context support for Objective-C class and method nodes, including implementation evidence via `DECLARES`.
- Regression tests for grammar loading, `.h` classification, stable identities, conservative calls, metadata feature mismatch, persisted query/context behavior, and incremental-vs-force parity for Objective-C fixture edits.
Known limits of this MVP:
- The first version does not perform full Objective-C runtime dispatch, swizzling, dynamic selector construction, macro expansion, or `id` flow inference. Bare file-scope marker macros are elided only to preserve parser recovery; their expansion semantics are not interpreted.
- Protocol receiver handling records the protocol method and candidate implementation evidence, but candidate implementations are not emitted as certain call edges.
- Objective-C++ `.mm` files are parsed with the Objective-C grammar path for this MVP; deep C++ semantic extraction inside Objective-C++ bodies remains outside this provider.

View file

@ -14,6 +14,8 @@ export type NodeLabel =
| 'Folder'
| 'File'
| 'Class'
| 'Protocol'
| 'Category'
| 'Function'
| 'Method'
| 'Variable'

View file

@ -32,6 +32,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Python]: ['.py'],
[SupportedLanguages.Java]: ['.java'],
[SupportedLanguages.C]: ['.c'],
[SupportedLanguages.ObjectiveC]: ['.m', '.mm'],
[SupportedLanguages.CPlusPlus]: [
'.cpp',
'.cc',
@ -111,6 +112,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Python]: 'python',
[SupportedLanguages.Java]: 'java',
[SupportedLanguages.C]: 'c',
[SupportedLanguages.ObjectiveC]: 'objectivec',
[SupportedLanguages.CPlusPlus]: 'cpp',
[SupportedLanguages.CSharp]: 'csharp',
[SupportedLanguages.Go]: 'go',

View file

@ -11,6 +11,7 @@ export enum SupportedLanguages {
Java = 'java',
C = 'c',
CPlusPlus = 'cpp',
ObjectiveC = 'objective-c',
CSharp = 'csharp',
Go = 'go',
Ruby = 'ruby',

View file

@ -13,6 +13,8 @@ export const NODE_TABLES = [
'Folder',
'Function',
'Class',
'Protocol',
'Category',
'Interface',
'Method',
'CodeElement',

View file

@ -9,7 +9,8 @@
* Initial classification (locked in Ring 1 #910):
* - production: javascript, typescript, python, java, c, cpp, csharp, go,
* ruby, rust, php, kotlin, swift, dart
* - experimental: vue (embedded-language / SFC complexity),
* - experimental: objective-c (fork provider MVP),
* vue (embedded-language / SFC complexity),
* cobol (regex-provider path)
* - quarantined: (none)
*
@ -34,6 +35,7 @@ export const LanguageClassifications: Readonly<Record<SupportedLanguages, Langua
[SupportedLanguages.Java]: 'production',
[SupportedLanguages.C]: 'production',
[SupportedLanguages.CPlusPlus]: 'production',
[SupportedLanguages.ObjectiveC]: 'experimental',
[SupportedLanguages.CSharp]: 'production',
[SupportedLanguages.Go]: 'production',
[SupportedLanguages.Ruby]: 'production',

View file

@ -4,9 +4,9 @@
*
* Thin wrapper over `lookupCore`, specialized for class kinds:
*
* - `acceptedKinds` = Class / Interface / Enum / Struct / Union /
* Trait / TypeAlias / Typedef / Record / Delegate / Annotation /
* Template / Namespace.
* - `acceptedKinds` = Class / Protocol / Category / Interface / Enum /
* Struct / Union / Trait / TypeAlias / Typedef / Record / Delegate /
* Annotation / Template / Namespace.
* - `useReceiverTypeBinding` is **false** classes are resolved by
* name through the lexical chain + global qualified fallback, not
* via a receiver type.

View file

@ -136,6 +136,8 @@ export interface RegistryContext {
export const CLASS_KINDS: readonly NodeLabel[] = Object.freeze([
'Class',
'Protocol',
'Category',
'Interface',
'Enum',
'Struct',

View file

@ -82,6 +82,8 @@ const STRICT_ORIGINS: ReadonlySet<BindingRef['origin']> = new Set<BindingRef['or
*/
const TYPE_KINDS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
'Class',
'Protocol',
'Category',
'Interface',
'Enum',
'Struct',

View file

@ -186,6 +186,10 @@ const getNodeTypeIcon = (label: NodeLabel) => {
return FileCode;
case 'Class':
return Box;
case 'Protocol':
return Hash;
case 'Category':
return Box;
case 'Function':
return Braces;
case 'Method':

View file

@ -8,6 +8,8 @@ export const NODE_COLORS: Record<NodeLabel, string> = {
Folder: '#6366f1', // Indigo
File: '#3b82f6', // Blue
Class: '#f59e0b', // Amber - stands out
Protocol: '#ec4899', // Pink - like Interface
Category: '#14b8a6', // Teal - like Method
Function: '#10b981', // Emerald
Method: '#14b8a6', // Teal
Variable: '#64748b', // Slate - muted (less important)
@ -51,6 +53,8 @@ export const NODE_SIZES: Record<NodeLabel, number> = {
Folder: 10, // Structural - clearly bigger than files
File: 6, // Common element - smaller than folders
Class: 8, // Important code structure
Protocol: 7, // Like Interface
Category: 3, // Like Method
Function: 4, // Common code element - small
Method: 3, // Smaller than function
Variable: 2, // Tiny - leaf node
@ -115,6 +119,8 @@ export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [
'Folder',
'File',
'Class',
'Protocol',
'Category',
'Function',
'Method',
'Property', // Kotlin/Java fields (HAS_PROPERTY + DEFINES File→Property)
@ -129,6 +135,8 @@ export const FILTERABLE_LABELS: NodeLabel[] = [
'Folder',
'File',
'Class',
'Protocol',
'Category',
'Interface',
'Enum',
'Type',

View file

@ -65,6 +65,8 @@ describe('FILTERABLE_LABELS', () => {
expect(FILTERABLE_LABELS).toContain('Type');
expect(FILTERABLE_LABELS).toContain('Decorator');
expect(FILTERABLE_LABELS).toContain('Variable');
expect(FILTERABLE_LABELS).toContain('Protocol');
expect(FILTERABLE_LABELS).toContain('Category');
});
it('every filterable label has a defined color in NODE_COLORS', () => {

View file

@ -7,6 +7,8 @@ const LEGEND_LABELS: NodeLabel[] = [
'Folder',
'File',
'Class',
'Protocol',
'Category',
'Interface',
'Enum',
'Type',
@ -20,6 +22,8 @@ const ICON_MAP: Record<string, string> = {
Folder: 'Folder',
File: 'FileCode',
Class: 'Box',
Protocol: 'Hash',
Category: 'Box',
Function: 'Braces',
Method: 'Braces',
Interface: 'Hash',
@ -62,6 +66,8 @@ describe('color legend', () => {
expect(LEGEND_LABELS).toContain('Type');
expect(LEGEND_LABELS).toContain('Decorator');
expect(LEGEND_LABELS).toContain('Variable');
expect(LEGEND_LABELS).toContain('Protocol');
expect(LEGEND_LABELS).toContain('Category');
});
it('every legend label has a color defined', () => {
@ -76,6 +82,8 @@ describe('color legend', () => {
'Folder',
'File',
'Class',
'Protocol',
'Category',
'Interface',
'Enum',
'Type',

View file

@ -25,7 +25,7 @@
* or exit non-zero a failure for any single grammar must not break the install.
*
* Opt-out: GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (strict '1') skips the OPTIONAL
* grammars only. tree-sitter-c is REQUIRED (it backstops upstream's 4/6 ARM
* grammars only. tree-sitter-c and tree-sitter-objc are REQUIRED (C backstops upstream's 4/6 ARM
* prebuild gap, #2116) and is always built.
*
* Usage:
@ -40,6 +40,7 @@ const { execSync } = require('child_process');
// grammars ignore the opt-out gate. Insertion order == build order (c first).
const GRAMMARS = {
c: { required: true, display: 'C', ext: '.c' },
objc: { required: true, display: 'Objective-C', ext: '.m/.mm/.h' },
dart: { required: false, display: 'Dart', ext: '.dart' },
proto: { required: false, display: 'Proto', ext: '.proto' },
swift: { required: false, display: 'Swift', ext: '.swift' },

View file

@ -11,6 +11,7 @@ import {
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
SPRING_CONFIG_BINDINGS_FEATURE,
} from './ingestion/languages/java/analysis-features.js';
import { OBJECTIVE_C_PROVIDER_FEATURE } from './ingestion/languages/objective-c/analysis-features.js';
/** Production registry of independently versioned analysis capabilities. */
export const ANALYSIS_FEATURES = [
@ -23,4 +24,5 @@ export const ANALYSIS_FEATURES = [
SPRING_CONFIG_BINDINGS_FEATURE,
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
OBJECTIVE_C_PROVIDER_FEATURE,
] as const;

View file

@ -100,6 +100,8 @@ export const findDeclarationNode = (root: any): any | null => {
'struct_item',
'interface_declaration',
'interface_definition',
'protocol_declaration', // Objective-C protocol
'class_interface', // Objective-C class, category, or extension
'enum_declaration',
'enum_item',
'type_declaration', // Go: type X struct

View file

@ -148,6 +148,10 @@ const DECLARATION_BODY_NODE_TYPES = new Set([
'interface_body',
]);
const DIRECT_MEMBER_DECLARATION_TYPES = new Set(['protocol_declaration', 'class_interface']);
const DIRECT_MEMBER_HEADER_NODE_TYPES = new Set(['identifier', 'protocol_reference_list']);
const FIELD_LIKE_MEMBER_TYPES = new Set([
'field_definition',
'public_field_definition',
@ -159,6 +163,11 @@ const FIELD_LIKE_MEMBER_TYPES = new Set([
'enum_assignment',
]);
const DECLARATION_MEMBER_WRAPPER_TYPES = new Set([
'qualified_protocol_interface_declaration',
'instance_variables',
]);
const declarationChunk = async (
content: string,
filePath: string,
@ -337,6 +346,8 @@ const getDeclarationBodyNode = (node: any): any | null => {
const bodyNode = node.childForFieldName?.('body');
if (bodyNode) return bodyNode;
if (DIRECT_MEMBER_DECLARATION_TYPES.has(node.type)) return node;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
@ -352,15 +363,34 @@ const collectDeclarationUnits = (
): Array<{ startIndex: number; endIndex: number }> => {
const members: Array<{ startIndex: number; endIndex: number; groupable: boolean }> = [];
for (let i = 0; i < bodyNode.namedChildCount; i++) {
const child = bodyNode.namedChild(i);
if (!child) continue;
members.push({
startIndex: child.startIndex,
endIndex: child.endIndex,
groupable: groupFields && FIELD_LIKE_MEMBER_TYPES.has(child.type),
});
}
const collectMembers = (
node: any,
skipHeaderChildren: boolean,
includeNodePrefixOnFirstMember = false,
): void => {
const firstMemberIndex = members.length;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (DECLARATION_MEMBER_WRAPPER_TYPES.has(child.type)) {
collectMembers(child, false, true);
continue;
}
if (skipHeaderChildren && DIRECT_MEMBER_HEADER_NODE_TYPES.has(child.type)) continue;
members.push({
startIndex: child.startIndex,
endIndex: child.endIndex,
groupable: groupFields && FIELD_LIKE_MEMBER_TYPES.has(child.type),
});
}
const firstMember = members[firstMemberIndex];
if (includeNodePrefixOnFirstMember && firstMember) {
firstMember.startIndex = node.startIndex;
}
};
collectMembers(bodyNode, DIRECT_MEMBER_DECLARATION_TYPES.has(bodyNode.type));
if (members.length === 0) return [];

View file

@ -8,6 +8,8 @@ export const LABEL_FUNCTION = 'Function' as const;
export const LABEL_METHOD = 'Method' as const;
export const LABEL_CONSTRUCTOR = 'Constructor' as const;
export const LABEL_CLASS = 'Class' as const;
export const LABEL_PROTOCOL = 'Protocol' as const;
export const LABEL_CATEGORY = 'Category' as const;
export const LABEL_INTERFACE = 'Interface' as const;
export const LABEL_STRUCT = 'Struct' as const;
export const LABEL_ENUM = 'Enum' as const;
@ -53,6 +55,8 @@ export const CHUNKABLE_LABELS = [
LABEL_METHOD,
LABEL_CONSTRUCTOR,
LABEL_CLASS,
LABEL_PROTOCOL,
LABEL_CATEGORY,
LABEL_INTERFACE,
LABEL_STRUCT,
LABEL_ENUM,
@ -165,6 +169,20 @@ export const CHUNKING_RULES: Readonly<Partial<Record<ChunkableLabel, ChunkingRul
groupFields: false,
structuralTextMode: STRUCTURAL_TEXT_MODE_DECLARATION,
},
[LABEL_PROTOCOL]: {
mode: CHUNK_MODE_AST_DECLARATION,
includePrefix: true,
includeSuffix: false,
groupFields: false,
structuralTextMode: STRUCTURAL_TEXT_MODE_DECLARATION,
},
[LABEL_CATEGORY]: {
mode: CHUNK_MODE_AST_DECLARATION,
includePrefix: true,
includeSuffix: false,
groupFields: true,
structuralTextMode: STRUCTURAL_TEXT_MODE_DECLARATION,
},
[LABEL_STRUCT]: {
mode: CHUNK_MODE_AST_DECLARATION,
includePrefix: true,

View file

@ -16,7 +16,7 @@ export interface ManifestExtractResult {
// #2325 integration test can run the EXACT production query against a real
// LadybugDB — a hand-copied query string in the test would silently drift
// from this allowlist. Uses the `labels(n) IN [...]` allowlist form rather
// than a `MATCH (n:A|B)` disjunction: this 21-label list contains the
// than a `MATCH (n:A|B)` disjunction: this 23-label list contains the
// reserved-keyword labels `Macro` and `Union`, and LadybugDB's parser rejects
// a disjunction that names a reserved keyword (#2325) — which the resolver's
// try/catch then swallowed. `labels(n) IN` has no such collision.
@ -25,7 +25,7 @@ export interface ManifestExtractResult {
// two would widen which nodes resolve as contract symbols and must update the
// #2325 test, so they are intentionally kept separate for now.
export const CUSTOM_CONTRACT_RESOLVE_QUERY = `MATCH (n)
WHERE labels(n) IN ['Function','Method','Class','Interface','Struct','Enum','Trait','Constructor','TypeAlias','Impl','Macro','Union','Typedef','Property','Record','Delegate','Annotation','Template','Const','Static','CodeElement']
WHERE labels(n) IN ['Function','Method','Class','Protocol','Category','Interface','Struct','Enum','Trait','Constructor','TypeAlias','Impl','Macro','Union','Typedef','Property','Record','Delegate','Annotation','Template','Const','Static','CodeElement']
AND n.name = $symbolName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC, n.id ASC

View file

@ -0,0 +1,40 @@
import fs from 'fs/promises';
import path from 'path';
import type { SupportedLanguages } from 'gitnexus-shared';
import { mapConcurrent } from '../../lib/utils.js';
import { READ_CONCURRENCY } from './filesystem-walker.js';
import { getLanguageForFileContent } from './languages/index.js';
/**
* Classify files whose language cannot be determined from their extension.
*
* Source text is retained only for the duration of each individual read and
* classification. The returned map deliberately contains language results,
* not source text, so downstream phases can reuse the decision without
* holding every candidate header in memory or rereading it to classify again.
*/
export async function classifyContentLanguages(
repoPath: string,
relativePaths: readonly string[],
): Promise<ReadonlyMap<string, SupportedLanguages | null>> {
const classifications = new Map<string, SupportedLanguages | null>();
const results = await mapConcurrent(
relativePaths,
async (relativePath) => {
const sourceText = await fs.readFile(path.join(repoPath, relativePath), 'utf-8');
return {
path: relativePath,
language: getLanguageForFileContent(relativePath, sourceText),
};
},
{ concurrency: READ_CONCURRENCY },
);
// An unreadable file yields `undefined` from mapConcurrent. Leave it absent
// so the caller retains the existing filename-based fallback behavior.
for (const result of results) {
if (result !== undefined) classifications.set(result.path, result.language);
}
return classifications;
}

View file

@ -19,7 +19,7 @@ export interface FilePath {
path: string;
}
const READ_CONCURRENCY = 32;
export const READ_CONCURRENCY = 32;
const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
const DECLARATION_COMPANION_SUFFIXES = [

View file

@ -36,7 +36,12 @@ import type { VariableExtractor } from './variable-types.js';
import type { ImportResolverFn } from './import-resolvers/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import type { CfgVisitor } from './cfg/types.js';
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
import type {
GraphNode,
NodeLabel,
ParameterTypeClass,
RelationshipType,
} from 'gitnexus-shared';
import type { ExtractedRoute } from './route-extractors/laravel.js';
import type { SharedSpringType } from './route-extractors/spring-shared.js';
import type {
@ -120,6 +125,68 @@ export function mergeCanonicalDefinitionProperties<
return { ...providerProperties, ...canonicalProperties } as Record<string, unknown> & TCanonical;
}
export interface ProviderSemanticNode {
readonly id: string;
readonly label: NodeLabel;
readonly properties: {
readonly name: string;
readonly filePath: string;
readonly startLine: number;
readonly endLine: number;
readonly language: SupportedLanguages;
readonly isExported: boolean;
readonly qualifiedName?: string;
readonly parameterCount?: number;
readonly requiredParameterCount?: number;
readonly parameterTypes?: readonly string[];
readonly parameterTypeClasses?: readonly ParameterTypeClass[];
readonly returnType?: string;
readonly declaredType?: string;
readonly visibility?: string;
readonly isStatic?: boolean;
readonly isReadonly?: boolean;
readonly [key: string]: unknown;
};
}
export type ProviderSemanticRelationshipType = Extract<
RelationshipType,
'DECLARES' | 'DEFINES' | 'HAS_METHOD' | 'HAS_PROPERTY'
>;
export interface ProviderSemanticRelationship {
readonly id: string;
readonly sourceId: string;
readonly targetId: string;
readonly type: ProviderSemanticRelationshipType;
readonly confidence: number;
readonly reason: string;
}
export interface ProviderSemanticSymbol {
readonly filePath: string;
readonly name: string;
readonly nodeId: string;
readonly type: NodeLabel;
readonly qualifiedName?: string;
readonly parameterCount?: number;
readonly requiredParameterCount?: number;
readonly parameterTypes?: readonly string[];
readonly parameterTypeClasses?: readonly ParameterTypeClass[];
readonly returnType?: string;
readonly declaredType?: string;
readonly ownerId?: string;
readonly visibility?: string;
readonly isStatic?: boolean;
readonly isReadonly?: boolean;
}
export interface ProviderSemanticGraph {
readonly nodes: readonly ProviderSemanticNode[];
readonly relationships: readonly ProviderSemanticRelationship[];
readonly symbols: readonly ProviderSemanticSymbol[];
}
// ── Strategy tag types ─────────────────────────────────────────────────────
// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above
// so `core/ingestion/model/resolve.ts` can consume it without importing from
@ -230,6 +297,29 @@ interface LanguageProviderConfig {
*/
readonly runtimeSymbolStrategy?: RuntimeSymbolStrategy;
/**
* Optional content-based language classifier. The filename detector remains
* the default source of truth; this hook lets a provider claim ambiguous
* files only when the source text carries language-specific evidence.
*
* Used for extensions shared by several languages, where mapping the suffix
* globally would steal files from an existing provider. Implementations must
* be deterministic and conservative: false negatives are acceptable, false
* positives change which parser and resolver consumes the file.
*
* Default: undefined (provider never overrides filename detection).
*/
readonly classifyFileContent?: (filePath: string, sourceText: string) => boolean;
/**
* Cheap path-only prefilter for `classifyFileContent`. When supplied, callers
* can avoid loading source text for files this provider would never claim.
*
* Default: undefined (only callers that already have content invoke
* `classifyFileContent`).
*/
readonly shouldClassifyFileContent?: (filePath: string) => boolean;
// ── Core (required) ───────────────────────────────────────────────
/** Type extraction: declarations, initializers, for-loop bindings */
readonly typeConfig: LanguageTypeConfig;
@ -643,6 +733,23 @@ interface LanguageProviderConfig {
repo: RepoConstants,
) => string | null;
/**
* Optional provider-owned semantic graph extraction for languages whose
* stable symbol identities cannot be represented by the generic query
* pipeline's `(label, filePath, qualifiedName)` rule.
*
* Runs in the parse worker after tree-sitter has parsed the file and after
* `extractParsedFile` has produced the scope-resolution artifact. The hook is
* deterministic and AST-based: it receives the already-parsed tree and must
* return plain graph nodes/relationships/symbol-table rows. Existing
* providers leave it undefined, preserving the generic capture path exactly.
*/
readonly extractSemanticGraph?: (
tree: Parser.Tree,
filePath: string,
sourceText: string,
) => ProviderSemanticGraph;
// ── Noise filtering ────────────────────────────────────────────────
/** Built-in/stdlib names that should be filtered from the call graph for this language.
* Default: undefined (no language-specific filtering). */

View file

@ -19,6 +19,7 @@ import { goProvider } from './go.js';
import { rustProvider } from './rust.js';
import { csharpProvider } from './csharp.js';
import { cProvider, cppProvider } from './c-cpp.js';
import { objectiveCProvider } from './objective-c.js';
import { phpProvider } from './php.js';
import { rubyProvider } from './ruby.js';
import { swiftProvider } from './swift.js';
@ -38,6 +39,7 @@ export const providers = {
[SupportedLanguages.CSharp]: csharpProvider,
[SupportedLanguages.C]: cProvider,
[SupportedLanguages.CPlusPlus]: cppProvider,
[SupportedLanguages.ObjectiveC]: objectiveCProvider,
[SupportedLanguages.PHP]: phpProvider,
[SupportedLanguages.Ruby]: rubyProvider,
[SupportedLanguages.Swift]: swiftProvider,
@ -70,3 +72,34 @@ export function getProviderForFile(filePath: string): LanguageProvider | null {
const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
return extensionMap.get(ext) ?? extensionMap.get(basename) ?? null;
}
/** Return the provider whose content classifier confidently claims this file. */
export function getProviderForFileContent(
filePath: string,
content: string,
): LanguageProvider | null {
if (isBladeTemplateFilename(filePath)) return null;
for (const provider of Object.values(providers)) {
if (provider.classifyFileContent?.(filePath, content) === true) return provider;
}
return getProviderForFile(filePath);
}
/** True when at least one provider wants source text before language bucketing. */
export function needsContentLanguageClassification(filePath: string): boolean {
if (isBladeTemplateFilename(filePath)) return false;
return Object.values(providers).some(
(provider) =>
provider.classifyFileContent !== undefined &&
provider.shouldClassifyFileContent?.(filePath) === true,
);
}
/** Return the effective language for a file, optionally using source content. */
export function getLanguageForFileContent(
filePath: string,
content: string,
): SupportedLanguages | null {
return getProviderForFileContent(filePath, content)?.id ?? null;
}

View file

@ -0,0 +1,169 @@
import path from 'path';
import {
SupportedLanguages,
type CaptureMatch,
type ParsedImport,
type ParsedTypeBinding,
} from 'gitnexus-shared';
import Parser from 'tree-sitter';
import { defineLanguage } from '../language-provider.js';
import type { ImportResolverFn } from '../import-resolvers/types.js';
import { getLanguageGrammar } from '../../tree-sitter/parser-loader.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { assertCloneable } from '../workers/clone-safety.js';
import { preprocessObjectiveCMacroMarkers } from './objective-c/macro-marker-preprocess.js';
import {
buildObjectiveCSemanticGraph,
buildObjectiveCScopeCaptures,
collectObjectiveCCaptureSideChannel,
collectObjectiveCFacts,
parseObjCType,
setObjectiveCFileFacts,
} from './objective-c/facts.js';
const OBJECTIVE_C_SCOPE_QUERY = `((translation_unit) @objc.root)`;
const EMPTY_TYPE_CONFIG = {
declarationNodeTypes: new Set<string>(),
extractDeclaration: () => null,
extractParameter: () => null,
};
const noImportResolution: ImportResolverFn = () => null;
function normalizedExt(filePath: string): string {
return path.extname(filePath).toLowerCase();
}
function isObjectiveCSourcePath(filePath: string): boolean {
const ext = normalizedExt(filePath);
return ext === '.m' || ext === '.mm';
}
function isHeaderPath(filePath: string): boolean {
return normalizedExt(filePath) === '.h';
}
const OBJECTIVE_C_HEADER_NODE_TYPES = new Set([
'class_declaration',
'class_interface',
'class_implementation',
'compatibility_alias_declaration',
'module_import',
'protocol_declaration',
]);
function hasObjectiveCHeaderSyntax(sourceText: string): boolean {
try {
const tree = parseObjectiveCSource(sourceText);
const stack: Parser.SyntaxNode[] = [tree.rootNode];
while (stack.length > 0) {
const node = stack.pop();
if (node === undefined) continue;
if (OBJECTIVE_C_HEADER_NODE_TYPES.has(node.type)) return true;
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child !== null) stack.push(child);
}
}
} catch {
// The regular parser availability path reports the actionable grammar error.
}
return false;
}
export function classifyObjectiveCFileContent(filePath: string, sourceText: string): boolean {
if (isObjectiveCSourcePath(filePath)) return true;
if (!isHeaderPath(filePath)) return false;
return hasObjectiveCHeaderSyntax(sourceText);
}
function parseObjectiveCSource(sourceText: string): Parser.Tree {
const parser = new Parser();
parser.setLanguage(getLanguageGrammar(SupportedLanguages.ObjectiveC));
return parseSourceSafe(
parser,
preprocessObjectiveCMacroMarkers(sourceText),
undefined,
undefined,
'Objective-C source',
);
}
function treeFromCachedOrSource(cachedTree: unknown, sourceText: string): Parser.Tree {
if (cachedTree !== undefined && looksLikeTree(cachedTree)) return cachedTree;
return parseObjectiveCSource(sourceText);
}
function looksLikeTree(value: unknown): value is Parser.Tree {
return (
value !== null &&
typeof value === 'object' &&
'rootNode' in value &&
(value as { rootNode?: unknown }).rootNode !== undefined
);
}
function interpretObjectiveCImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source'];
if (source === undefined || source.text.trim().length === 0) return null;
const targetRaw = source.text.trim();
const kind = captures['@import.kind']?.text.trim();
const isSystemHeader = targetRaw.startsWith('<') && targetRaw.endsWith('>');
return {
kind: 'side-effect',
// Scope resolution needs to distinguish a quoted header path from a bare
// @import module name and an angle-bracket system header. The latter stays
// wrapped so the Objective-C resolver can fail closed instead of resolving
// a framework header to a same-named local file.
targetRaw:
isSystemHeader || kind === 'module' || targetRaw.startsWith('./')
? targetRaw
: `./${targetRaw}`,
};
}
function interpretObjectiveCTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name'];
const type = captures['@type-binding.type'];
if (name === undefined || type === undefined) return null;
const parsed = parseObjCType(type.text);
return {
boundName: name.text,
rawTypeName: parsed?.name ?? parsed?.raw ?? type.text,
declaredSpelling: type.text,
source: 'annotation',
};
}
export const objectiveCProvider = defineLanguage({
id: SupportedLanguages.ObjectiveC,
extensions: ['.m', '.mm'],
treeSitterQueries: OBJECTIVE_C_SCOPE_QUERY,
typeConfig: EMPTY_TYPE_CONFIG,
exportChecker: () => true,
importResolver: noImportResolution,
classifyFileContent: classifyObjectiveCFileContent,
shouldClassifyFileContent: isHeaderPath,
preprocessSource: preprocessObjectiveCMacroMarkers,
importsExecuteWhereWritten: false,
emitScopeCaptures: (sourceText, filePath, cachedTree): readonly CaptureMatch[] => {
const tree = treeFromCachedOrSource(cachedTree, sourceText);
const facts = collectObjectiveCFacts(tree, filePath);
setObjectiveCFileFacts(facts);
return buildObjectiveCScopeCaptures(facts, tree.rootNode);
},
collectCaptureSideChannel: (filePath) =>
assertCloneable(collectObjectiveCCaptureSideChannel(filePath)),
interpretImport: interpretObjectiveCImport,
interpretTypeBinding: interpretObjectiveCTypeBinding,
extractSemanticGraph: (tree, filePath) => {
const facts = collectObjectiveCFacts(tree, filePath);
setObjectiveCFileFacts(facts);
return buildObjectiveCSemanticGraph(facts);
},
});

View file

@ -0,0 +1,27 @@
import type { AnalysisFeatureDescriptor } from '../../../analysis-features.js';
import {
OBJECTIVE_C_GRAMMAR_PACKAGE,
OBJECTIVE_C_GRAMMAR_VERSION,
OBJECTIVE_C_PROVIDER_VERSION,
} from './facts.js';
function isObjectiveCProviderCandidatePath(filePath: string): boolean {
const normalized = filePath.replaceAll('\\', '/').toLowerCase();
return normalized.endsWith('.m') || normalized.endsWith('.mm') || normalized.endsWith('.h');
}
/**
* Durable metadata stamp for Objective-C semantic indexing. The feature id
* carries provider and grammar versions verbatim so a semantic identity/edge
* change records the exact producer in index metadata and forces a full rebuild.
*
* `.h` is included only as a path-level rebuild predicate; content classification
* still decides whether a header is actually parsed as Objective-C.
*/
export const OBJECTIVE_C_PROVIDER_FEATURE: AnalysisFeatureDescriptor = {
id:
`objective-c.provider-${OBJECTIVE_C_PROVIDER_VERSION}.` +
`${OBJECTIVE_C_GRAMMAR_PACKAGE}-${OBJECTIVE_C_GRAMMAR_VERSION}`,
version: 1,
appliesTo: (filePaths) => filePaths.some(isObjectiveCProviderCandidatePath),
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,160 @@
/**
* Normalize bare file-scope Objective-C macro markers before parsing.
*
* Headers commonly use macro pairs such as `RCT_EXTERN_C_BEGIN` and
* `RCT_EXTERN_C_END` around C declarations. tree-sitter-objc does not expand
* those macros; a bare invocation can put the parser into error recovery and
* hide every Objective-C declaration that follows it. These markers do not
* contribute syntax on their own, so we replace only the narrow, generic form
* with spaces before parsing.
*
* This is deliberately not macro expansion or a framework-specific allowlist:
* a candidate must be a whole, all-caps identifier at file scope. Function-like
* macros, directives, statements, strings, and comments remain untouched.
* Replacement preserves UTF-16 length and line endings exactly. Candidates are
* ASCII-only, so their byte offsets are preserved as well.
*/
interface ScanState {
inBlockComment: boolean;
inLineCommentContinuation: boolean;
inPreprocessorDirective: boolean;
quote: '"' | "'" | undefined;
braceDepth: number;
}
function isAsciiHorizontalWhitespace(code: number): boolean {
return code === 0x20 || code === 0x09;
}
function isBareMarkerIdentifier(line: string): boolean {
let index = 0;
while (index < line.length && isAsciiHorizontalWhitespace(line.charCodeAt(index))) index++;
const identifierStart = index;
let hasUppercaseLetter = false;
while (index < line.length) {
const code = line.charCodeAt(index);
if (code >= 0x41 && code <= 0x5a) {
hasUppercaseLetter = true;
index++;
continue;
}
if ((code >= 0x30 && code <= 0x39) || code === 0x5f) {
index++;
continue;
}
break;
}
if (index === identifierStart || !hasUppercaseLetter) return false;
while (index < line.length && isAsciiHorizontalWhitespace(line.charCodeAt(index))) index++;
return index === line.length;
}
function hasEscapedLineEnding(line: string): boolean {
let trailingBackslashes = 0;
for (let index = line.length - 1; index >= 0 && line.charCodeAt(index) === 0x5c; index--) {
trailingBackslashes++;
}
return trailingBackslashes % 2 === 1;
}
function startsPreprocessorDirective(line: string): boolean {
let index = 0;
while (index < line.length && isAsciiHorizontalWhitespace(line.charCodeAt(index))) index++;
return line.charCodeAt(index) === 0x23;
}
function scanLine(line: string, state: ScanState): void {
if (state.inLineCommentContinuation) {
state.inLineCommentContinuation = hasEscapedLineEnding(line);
return;
}
if (state.inPreprocessorDirective) {
state.inPreprocessorDirective = hasEscapedLineEnding(line);
return;
}
if (startsPreprocessorDirective(line)) {
state.inPreprocessorDirective = hasEscapedLineEnding(line);
return;
}
for (let index = 0; index < line.length; index++) {
const code = line.charCodeAt(index);
const next = line.charCodeAt(index + 1);
if (state.inBlockComment) {
if (code === 0x2a && next === 0x2f) {
state.inBlockComment = false;
index++;
}
continue;
}
if (state.quote !== undefined) {
if (code === 0x5c) {
index++;
} else if (line[index] === state.quote) {
state.quote = undefined;
}
continue;
}
if (code === 0x2f && next === 0x2f) {
state.inLineCommentContinuation = hasEscapedLineEnding(line);
return;
}
if (code === 0x2f && next === 0x2a) {
state.inBlockComment = true;
index++;
continue;
}
if (code === 0x22 || code === 0x27) {
state.quote = line[index] as '"' | "'";
continue;
}
if (code === 0x7b) state.braceDepth++;
else if (code === 0x7d) state.braceDepth = Math.max(0, state.braceDepth - 1);
}
if (state.quote !== undefined && !hasEscapedLineEnding(line)) state.quote = undefined;
}
/**
* Elide bare, file-scope macro markers while preserving source positions.
*
* `_filePath` is accepted for the LanguageProvider hook signature. The
* transform is based only on source syntax and deliberately has no framework
* or repository-specific configuration.
*/
export function preprocessObjectiveCMacroMarkers(source: string, _filePath?: string): string {
const state: ScanState = {
inBlockComment: false,
inLineCommentContinuation: false,
inPreprocessorDirective: false,
quote: undefined,
braceDepth: 0,
};
const segments = source.split(/(\r\n|\n|\r)/);
let changed = false;
for (let index = 0; index < segments.length; index += 2) {
const line = segments[index];
if (
!state.inBlockComment &&
!state.inLineCommentContinuation &&
!state.inPreprocessorDirective &&
state.quote === undefined &&
state.braceDepth === 0 &&
isBareMarkerIdentifier(line)
) {
segments[index] = ' '.repeat(line.length);
changed = true;
continue;
}
scanLine(line, state);
}
return changed ? segments.join('') : source;
}

View file

@ -0,0 +1,733 @@
import path from 'path';
import { SupportedLanguages, type SymbolDefinition, type Callsite } from 'gitnexus-shared';
import type { GraphNode, RelationshipType } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { generateId } from '../../../../lib/utils.js';
import { perFileSet } from '../../import-resolvers/per-file-set.js';
import { objectiveCProvider } from '../objective-c.js';
import {
applyObjectiveCCaptureSideChannel,
objcClassQualifiedName,
objcProtocolQualifiedName,
objcUnresolvedMessageQualifiedName,
objectiveCFactsFromParsedFiles,
type ObjCContainerFact,
type ObjCFileFacts,
type ObjCMemberFact,
type ObjCMessageFact,
type ObjCMethodFact,
type ObjCTypeInfo,
parseObjCType,
} from './facts.js';
interface ObjCWorkspaceFacts {
readonly containersByQualifiedName: ReadonlyMap<string, ObjCContainerFact>;
readonly classByName: ReadonlyMap<string, ObjCContainerFact>;
readonly protocolsByName: ReadonlyMap<string, ObjCContainerFact>;
readonly categoriesByHost: ReadonlyMap<string, readonly ObjCContainerFact[]>;
readonly methodsByDispatchOwner: ReadonlyMap<string, readonly ObjCMethodFact[]>;
readonly methodsByExactOwner: ReadonlyMap<string, readonly ObjCMethodFact[]>;
readonly memberTypesByOwner: ReadonlyMap<string, ReadonlyMap<string, ObjCTypeInfo>>;
readonly classProtocols: ReadonlyMap<string, ReadonlySet<string>>;
readonly protocolParents: ReadonlyMap<string, ReadonlySet<string>>;
readonly superclassByClass: ReadonlyMap<string, string>;
}
export const objectiveCScopeResolver: ScopeResolver = {
language: SupportedLanguages.ObjectiveC,
languageProvider: objectiveCProvider,
importEdgeReason: 'objective-c-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) =>
resolveObjectiveCImportTarget(targetRaw, fromFile, allFilePaths),
mergeBindings: (existing, incoming) => [...existing, ...incoming],
arityCompatibility: (callsite: Callsite, def: SymbolDefinition) => {
if (callsite.arity === undefined || def.parameterCount === undefined) return 'unknown';
return callsite.arity === def.parameterCount ? 'compatible' : 'incompatible';
},
buildMro: () => new Map(),
applyCaptureSideChannel: applyObjectiveCCaptureSideChannel,
populateOwners: () => {},
isSuperReceiver: (receiverText) => receiverText.trim() === 'super',
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: false,
collapseMemberCallsByCallerTarget: true,
emitPostResolutionEdges(graph, parsedFiles) {
const facts = objectiveCFactsFromParsedFiles(parsedFiles);
if (facts.length === 0) return;
const workspace = buildObjectiveCWorkspaceFacts(facts);
for (const fact of facts) {
emitObjectiveCHeritageEdges(graph, fact, workspace);
emitObjectiveCCategoryEdges(graph, fact, workspace);
emitObjectiveCImplementationEvidence(graph, fact);
emitObjectiveCUnresolvedMessageEvidence(graph, fact);
emitObjectiveCMessageEdges(graph, fact, workspace);
}
},
};
function graphNodeId(label: string, qualifiedName: string): string {
return generateId(label, qualifiedName);
}
function relationshipId(
type: RelationshipType,
sourceId: string,
targetId: string,
reason: string,
): string {
return generateId(type, `${sourceId}->${targetId}:${reason}`);
}
function addRelationship(
graph: KnowledgeGraph,
type: RelationshipType,
sourceId: string,
targetId: string,
reason: string,
confidence = 0.9,
): void {
graph.addRelationship({
id: relationshipId(type, sourceId, targetId, reason),
sourceId,
targetId,
type,
confidence,
reason,
});
}
function labelForContainer(container: ObjCContainerFact): 'Class' | 'Protocol' | 'Category' {
return container.label;
}
function buildObjectiveCWorkspaceFacts(facts: readonly ObjCFileFacts[]): ObjCWorkspaceFacts {
const containersByQualifiedName = new Map<string, ObjCContainerFact>();
const classByName = new Map<string, ObjCContainerFact>();
const protocolsByName = new Map<string, ObjCContainerFact>();
const categoriesByHost = new Map<string, ObjCContainerFact[]>();
const methodsByExactOwner = new Map<string, ObjCMethodFact[]>();
const methodsByDispatchOwner = new Map<string, ObjCMethodFact[]>();
const memberTypesByOwner = new Map<string, Map<string, ObjCTypeInfo>>();
const classProtocols = new Map<string, Set<string>>();
const protocolParents = new Map<string, Set<string>>();
const superclassByClass = new Map<string, string>();
for (const fileFact of facts) {
for (const container of fileFact.containers) {
const existing = containersByQualifiedName.get(container.qualifiedName);
containersByQualifiedName.set(
container.qualifiedName,
mergeContainerFacts(existing, container),
);
if (container.kind === 'class') {
classByName.set(container.name, container);
if (container.superclass !== undefined)
superclassByClass.set(container.name, container.superclass);
if (container.protocols.length > 0) {
let protocols = classProtocols.get(container.name);
if (protocols === undefined) {
protocols = new Set();
classProtocols.set(container.name, protocols);
}
for (const protocol of container.protocols) protocols.add(protocol);
}
} else if (container.kind === 'protocol') {
protocolsByName.set(container.name, container);
if (container.protocols.length > 0) {
let parents = protocolParents.get(container.name);
if (parents === undefined) {
parents = new Set();
protocolParents.set(container.name, parents);
}
for (const protocol of container.protocols) parents.add(protocol);
}
} else if (container.hostClass !== undefined) {
let categories = categoriesByHost.get(container.hostClass);
if (categories === undefined) {
categories = [];
categoriesByHost.set(container.hostClass, categories);
}
categories.push(container);
if (container.protocols.length > 0) {
let protocols = classProtocols.get(container.hostClass);
if (protocols === undefined) {
protocols = new Set();
classProtocols.set(container.hostClass, protocols);
}
for (const protocol of container.protocols) protocols.add(protocol);
}
}
}
for (const method of fileFact.methods) {
appendMap(methodsByExactOwner, method.ownerQualifiedName, method);
appendMap(methodsByDispatchOwner, method.ownerQualifiedName, method);
if (method.hostClass !== undefined) {
appendMap(methodsByDispatchOwner, objcClassQualifiedName(method.hostClass), method);
}
}
for (const member of fileFact.members) {
addMemberType(memberTypesByOwner, member.ownerQualifiedName, member);
if (member.hostClass !== undefined) {
addMemberType(memberTypesByOwner, objcClassQualifiedName(member.hostClass), member);
}
}
}
return {
containersByQualifiedName,
classByName,
protocolsByName,
categoriesByHost,
methodsByDispatchOwner,
methodsByExactOwner,
memberTypesByOwner,
classProtocols,
protocolParents,
superclassByClass,
};
}
function addMemberType(
memberTypesByOwner: Map<string, Map<string, ObjCTypeInfo>>,
ownerQualifiedName: string,
member: ObjCMemberFact,
): void {
const type = parseObjCType(member.declaredType);
if (type === undefined) return;
let types = memberTypesByOwner.get(ownerQualifiedName);
if (types === undefined) {
types = new Map();
memberTypesByOwner.set(ownerQualifiedName, types);
}
types.set(member.name, type);
}
function mergeContainerFacts(
existing: ObjCContainerFact | undefined,
incoming: ObjCContainerFact,
): ObjCContainerFact {
if (existing === undefined) return incoming;
const protocols = Array.from(new Set([...existing.protocols, ...incoming.protocols])).sort();
return {
...existing,
declarationRole:
existing.declarationRole === 'implementation' || incoming.declarationRole === 'implementation'
? 'implementation'
: 'interface',
startLine: Math.min(existing.startLine, incoming.startLine),
endLine: Math.max(existing.endLine, incoming.endLine),
...(existing.superclass !== undefined || incoming.superclass !== undefined
? { superclass: existing.superclass ?? incoming.superclass }
: {}),
protocols,
};
}
function appendMap<K, V>(map: Map<K, V[]>, key: K, value: V): void {
const existing = map.get(key);
if (existing === undefined) map.set(key, [value]);
else existing.push(value);
}
function emitObjectiveCHeritageEdges(
graph: KnowledgeGraph,
facts: ObjCFileFacts,
workspace: ObjCWorkspaceFacts,
): void {
for (const container of facts.containers) {
const sourceId = graphNodeId(labelForContainer(container), container.qualifiedName);
if (container.kind === 'class' && container.superclass !== undefined) {
const superclass = workspace.classByName.get(container.superclass);
if (superclass !== undefined) {
addRelationship(
graph,
'EXTENDS',
sourceId,
graphNodeId('Class', superclass.qualifiedName),
'objc: superclass',
);
}
}
const protocolSourceId =
container.hostClass !== undefined
? graphNodeId('Class', objcClassQualifiedName(container.hostClass))
: sourceId;
for (const protocolName of container.protocols) {
const protocol = workspace.protocolsByName.get(protocolName);
if (protocol === undefined) continue;
addRelationship(
graph,
'IMPLEMENTS',
protocolSourceId,
graphNodeId('Protocol', protocol.qualifiedName),
'objc: protocol conformance',
);
}
}
}
function emitObjectiveCCategoryEdges(
graph: KnowledgeGraph,
facts: ObjCFileFacts,
workspace: ObjCWorkspaceFacts,
): void {
for (const container of facts.containers) {
if (container.hostClass === undefined) continue;
if (!workspace.classByName.has(container.hostClass)) continue;
addRelationship(
graph,
'MEMBER_OF',
graphNodeId('Category', container.qualifiedName),
graphNodeId('Class', objcClassQualifiedName(container.hostClass)),
'objc: category host class',
);
}
}
function emitObjectiveCUnresolvedMessageEvidence(
graph: KnowledgeGraph,
facts: ObjCFileFacts,
): void {
for (const unresolved of facts.unresolvedMessages) {
const evidenceId = graphNodeId(
'CodeElement',
objcUnresolvedMessageQualifiedName(
facts.filePath,
unresolved.startLine,
unresolved.startCol,
unresolved.selector,
),
);
addRelationship(
graph,
'USES',
unresolved.sourceMethodId,
evidenceId,
`objc-message: unresolved: ${unresolved.reason}`,
0.5,
);
}
}
function emitObjectiveCImplementationEvidence(graph: KnowledgeGraph, facts: ObjCFileFacts): void {
for (const container of facts.containers) {
if (container.declarationRole !== 'implementation') continue;
const targetId = graphNodeId(labelForContainer(container), container.qualifiedName);
emitImplementationEvidence(
graph,
facts.filePath,
targetId,
`@implementation ${container.name}`,
`objc:implementation:${container.qualifiedName}:${facts.filePath}:${container.startLine}`,
container.startLine,
container.endLine,
{
objectiveCKind: 'implementation-evidence',
implementationKind: container.kind,
targetQualifiedName: container.qualifiedName,
},
);
}
for (const method of facts.methods) {
if (method.declarationRole !== 'implementation') continue;
emitImplementationEvidence(
graph,
facts.filePath,
method.nodeId,
`${method.methodKind}[${method.ownerName} ${method.selector}] implementation`,
`objc:method-implementation:${method.qualifiedName}:${facts.filePath}:${method.startLine}`,
method.startLine,
method.endLine,
{
objectiveCKind: 'implementation-evidence',
implementationKind: 'method',
targetQualifiedName: method.qualifiedName,
selector: method.selector,
methodKind: method.methodKind,
objectiveCOwner: method.ownerQualifiedName,
},
);
}
}
function emitImplementationEvidence(
graph: KnowledgeGraph,
filePath: string,
targetId: string,
name: string,
qualifiedName: string,
startLine: number,
endLine: number,
extras: Record<string, unknown>,
): void {
const nodeId = graphNodeId('CodeElement', qualifiedName);
graph.addNode({
id: nodeId,
label: 'CodeElement',
properties: {
name,
qualifiedName,
filePath,
startLine,
endLine,
language: SupportedLanguages.ObjectiveC,
isExported: false,
...extras,
},
});
addRelationship(
graph,
'DEFINES',
graphNodeId('File', filePath),
nodeId,
'objc: implementation evidence',
1,
);
addRelationship(graph, 'DECLARES', nodeId, targetId, 'objc: implementation of merged symbol', 1);
}
function emitObjectiveCMessageEdges(
graph: KnowledgeGraph,
facts: ObjCFileFacts,
workspace: ObjCWorkspaceFacts,
): void {
for (const message of facts.messages) {
const targets = resolveMessageTargets(message, workspace);
if (targets.kind === 'none') continue;
if (targets.kind === 'protocol') {
emitProtocolMessageEvidence(graph, facts, message, targets.protocolName, targets.candidates);
}
for (const target of targets.methods) {
if (graph.getNode(target.nodeId) === undefined) continue;
addRelationship(
graph,
'CALLS',
message.sourceMethodId,
target.nodeId,
targets.kind === 'protocol'
? 'objc-message: protocol receiver'
: `objc-message: ${message.receiverKind} receiver`,
targets.kind === 'protocol' ? 0.8 : 0.9,
);
}
}
}
type MessageTargets =
| { readonly kind: 'none'; readonly methods: readonly ObjCMethodFact[] }
| { readonly kind: 'direct'; readonly methods: readonly ObjCMethodFact[] }
| {
readonly kind: 'protocol';
readonly protocolName: string;
readonly methods: readonly ObjCMethodFact[];
readonly candidates: readonly ObjCMethodFact[];
};
function resolveMessageTargets(
message: ObjCMessageFact,
workspace: ObjCWorkspaceFacts,
): MessageTargets {
if (message.receiverKind === 'dynamic') {
return { kind: 'none', methods: [] };
}
if (message.receiverKind === 'class') {
const className = message.receiverType?.name ?? message.receiverText;
return {
kind: 'direct',
methods: findDispatchMethods(workspace, className, '+', message.selector),
};
}
if (message.receiverKind === 'self') {
const owner = workspace.containersByQualifiedName.get(message.sourceOwnerQualifiedName);
const className = owner?.hostClass ?? owner?.name ?? message.sourceOwnerName;
const methods =
owner?.kind === 'protocol'
? findProtocolMethods(workspace, owner.name, message.sourceMethodKind, message.selector)
: findDispatchMethods(workspace, className, message.sourceMethodKind, message.selector);
return { kind: 'direct', methods };
}
if (message.receiverKind === 'super') {
const owner = workspace.containersByQualifiedName.get(message.sourceOwnerQualifiedName);
const className = owner?.hostClass ?? owner?.name ?? message.sourceOwnerName;
const superclass = workspace.superclassByClass.get(className);
return superclass === undefined
? { kind: 'none', methods: [] }
: {
kind: 'direct',
methods: findDispatchMethods(
workspace,
superclass,
message.sourceMethodKind,
message.selector,
),
};
}
const receiverType = message.receiverType ?? resolveMemberReceiverType(message, workspace);
if (receiverType?.kind === 'dynamic' || receiverType?.kind === 'class-object') {
return { kind: 'none', methods: [] };
}
if (message.receiverKind === 'unknown' && receiverType === undefined) {
return { kind: 'none', methods: [] };
}
if (receiverType?.kind === 'class' && receiverType.name !== undefined) {
return {
kind: 'direct',
methods: findDispatchMethods(workspace, receiverType.name, '-', message.selector),
};
}
if (receiverType?.kind === 'protocol' && receiverType.name !== undefined) {
const methods = findProtocolMethods(workspace, receiverType.name, '-', message.selector);
const candidates = findProtocolImplementationCandidates(
workspace,
receiverType.name,
message.selector,
);
return {
kind: 'protocol',
protocolName: receiverType.name,
methods,
candidates,
};
}
return { kind: 'none', methods: [] };
}
function resolveMemberReceiverType(
message: ObjCMessageFact,
workspace: ObjCWorkspaceFacts,
): ObjCTypeInfo | undefined {
if (message.receiverMemberName === undefined) return undefined;
const owner = workspace.containersByQualifiedName.get(message.sourceOwnerQualifiedName);
const ownerQualifiedName =
owner?.hostClass !== undefined
? objcClassQualifiedName(owner.hostClass)
: message.sourceOwnerQualifiedName;
return workspace.memberTypesByOwner.get(ownerQualifiedName)?.get(message.receiverMemberName);
}
function findDispatchMethods(
workspace: ObjCWorkspaceFacts,
className: string,
methodKind: '-' | '+',
selector: string,
): readonly ObjCMethodFact[] {
const seen = new Set<string>();
let currentClass: string | undefined = className;
while (currentClass !== undefined && !seen.has(currentClass)) {
seen.add(currentClass);
const ownerQn = objcClassQualifiedName(currentClass);
const methods = (workspace.methodsByDispatchOwner.get(ownerQn) ?? []).filter(
(method) => method.methodKind === methodKind && method.selector === selector,
);
if (methods.length > 0) return methods;
currentClass = workspace.superclassByClass.get(currentClass);
}
return [];
}
function findExactOwnerMethods(
workspace: ObjCWorkspaceFacts,
ownerQualifiedName: string,
methodKind: '-' | '+',
selector: string,
): readonly ObjCMethodFact[] {
return (workspace.methodsByExactOwner.get(ownerQualifiedName) ?? []).filter(
(method) => method.methodKind === methodKind && method.selector === selector,
);
}
function findProtocolMethods(
workspace: ObjCWorkspaceFacts,
protocolName: string,
methodKind: '-' | '+',
selector: string,
): readonly ObjCMethodFact[] {
for (const name of protocolHierarchy(workspace, protocolName)) {
const methods = findExactOwnerMethods(
workspace,
objcProtocolQualifiedName(name),
methodKind,
selector,
);
if (methods.length > 0) return uniqueMethods(methods);
}
return [];
}
function protocolHierarchy(workspace: ObjCWorkspaceFacts, protocolName: string): readonly string[] {
const seen = new Set<string>();
const pending = [protocolName];
const hierarchy: string[] = [];
while (pending.length > 0) {
const current = pending.shift();
if (current === undefined || seen.has(current)) continue;
seen.add(current);
hierarchy.push(current);
const parents = workspace.protocolParents.get(current);
if (parents === undefined) continue;
pending.push(...[...parents].sort());
}
return hierarchy;
}
function uniqueMethods(methods: readonly ObjCMethodFact[]): readonly ObjCMethodFact[] {
return [...new Map(methods.map((method) => [method.nodeId, method])).values()].sort(
(left, right) => left.qualifiedName.localeCompare(right.qualifiedName),
);
}
function classConformsToProtocol(
workspace: ObjCWorkspaceFacts,
directProtocols: ReadonlySet<string>,
protocolName: string,
): boolean {
return [...directProtocols].some((directProtocol) =>
protocolHierarchy(workspace, directProtocol).includes(protocolName),
);
}
function findProtocolImplementationCandidates(
workspace: ObjCWorkspaceFacts,
protocolName: string,
selector: string,
): readonly ObjCMethodFact[] {
const out: ObjCMethodFact[] = [];
for (const [className, protocols] of workspace.classProtocols) {
if (!classConformsToProtocol(workspace, protocols, protocolName)) continue;
out.push(...findDispatchMethods(workspace, className, '-', selector));
}
return uniqueMethods(out);
}
function emitProtocolMessageEvidence(
graph: KnowledgeGraph,
facts: ObjCFileFacts,
message: ObjCMessageFact,
protocolName: string,
candidates: readonly ObjCMethodFact[],
): void {
if (candidates.length === 0) return;
const qualifiedName = `objc:protocol-candidates:${facts.filePath}:${message.startLine}:${message.startCol}:${message.selector}`;
const nodeId = graphNodeId('CodeElement', qualifiedName);
const node: GraphNode = {
id: nodeId,
label: 'CodeElement',
properties: {
name: `[${message.receiverText} ${message.selector}] protocol ${protocolName} candidates`,
qualifiedName,
filePath: facts.filePath,
startLine: message.startLine,
endLine: message.startLine,
language: SupportedLanguages.ObjectiveC,
isExported: false,
},
};
graph.addNode(node);
addRelationship(
graph,
'DEFINES',
graphNodeId('File', facts.filePath),
nodeId,
`objc: protocol receiver candidate evidence: ${protocolName} ${message.selector}`,
1,
);
addRelationship(
graph,
'USES',
message.sourceMethodId,
nodeId,
`objc-message: protocol receiver candidates: ${protocolName} ${message.selector}`,
0.7,
);
for (const candidate of candidates) {
if (graph.getNode(candidate.nodeId) === undefined) continue;
addRelationship(
graph,
'USES',
nodeId,
candidate.nodeId,
`objc-protocol-candidate: ${protocolName} ${message.selector}`,
0.5,
);
}
}
function resolveObjectiveCImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | null {
const importIndex = getObjectiveCImportIndex(allFilePaths);
const target = targetRaw.trim();
if (target.length === 0) return null;
if (target.startsWith('<') && target.endsWith('>')) return null;
const looksLikeFileImport =
target.startsWith('.') || target.includes('/') || path.posix.extname(target).length > 0;
if (!looksLikeFileImport) return null;
return findImportCandidate(target, fromFile, importIndex);
}
interface ObjectiveCImportIndex {
readonly filePaths: readonly string[];
readonly filePathSet: ReadonlySet<string>;
}
const getObjectiveCImportIndex = perFileSet(
(allFilePaths: ReadonlySet<string>): ObjectiveCImportIndex => {
const filePaths = [...allFilePaths];
return { filePaths, filePathSet: new Set(filePaths) };
},
);
function findImportCandidate(
targetRaw: string,
fromFile: string,
importIndex: ObjectiveCImportIndex,
): string | null {
const normalizedTarget = normalizeRepoPath(targetRaw);
const fromDir = normalizeRepoPath(path.posix.dirname(normalizeRepoPath(fromFile)));
const spelledCandidates = new Set<string>([
normalizeRepoPath(path.posix.join(fromDir, normalizedTarget)),
normalizedTarget,
]);
const ext = path.posix.extname(normalizedTarget);
if (ext.length === 0) {
for (const base of [...spelledCandidates]) {
spelledCandidates.add(`${base}.h`);
spelledCandidates.add(`${base}.m`);
spelledCandidates.add(`${base}.mm`);
}
}
for (const candidate of spelledCandidates) {
if (importIndex.filePathSet.has(candidate)) return candidate;
}
const suffixes = [...spelledCandidates].map((candidate) => `/${candidate}`);
for (const filePath of importIndex.filePaths) {
const normalizedFilePath = normalizeRepoPath(filePath);
if (suffixes.some((suffix) => normalizedFilePath.endsWith(suffix))) return filePath;
}
return null;
}
function normalizeRepoPath(value: string): string {
return value.replaceAll('\\', '/').replace(/^\.\//, '');
}

View file

@ -15,11 +15,11 @@
* symbols (SymbolTable) owns fileIndex + callableByName,
* calls dispatch() in add()
*
* ## Behavior groups (5 hooks, 13 table entries)
* ## Behavior groups (4 hooks, 12 table entries)
*
* | Group | NodeLabel values | Hook | Skip callable? |
* |---------------|---------------------------------------------------|--------------|----------------|
* | class-like | Class, Struct, Interface, Enum, Record, Trait | classLikeHook | no |
* | class-like | Class, Protocol, Category, Struct, Interface, Enum, Record, Trait | classLikeHook | no |
* | method-like | Method, Constructor | methodHook | no |
* | property | Property | propertyHook | YES |
* | impl-block | Impl | implHook | no |
@ -95,7 +95,7 @@ export interface RegistrationTableDeps {
* registry (if any) receives the symbol write during `SymbolTable.add()`:
*
* - `dispatch` owner-scoped registry write via the dispatch table
* (Class/Struct/Interface/Enum/Record/Trait types.registerClass,
* (Class/Protocol/Category/Struct/Interface/Enum/Record/Trait types.registerClass,
* Method/Constructor methods.register,
* Property fields.register,
* Impl types.registerImpl)
@ -143,6 +143,8 @@ export type LabelBehavior = 'dispatch' | 'callable-only' | 'inert';
const LABEL_BEHAVIOR = {
// dispatch — owner-scoped registry writes
Class: 'dispatch',
Protocol: 'dispatch',
Category: 'dispatch',
Struct: 'dispatch',
Interface: 'dispatch',
Enum: 'dispatch',
@ -271,8 +273,8 @@ export const createRegistrationTable = (
): Map<NodeLabel, RegistrationHook> => {
const { types, methods, fields } = deps;
// Hook 1: class-like — Class, Struct, Interface, Enum, Record, Trait.
// Shared reference — six table entries point at this one closure.
// Hook 1: class-like — Class, Protocol, Category, Struct, Interface, Enum, Record, Trait.
// Shared reference — eight table entries point at this one closure.
const classLikeHook: RegistrationHook = (name, def) => {
const qualifiedKey = def.qualifiedName ?? name;
types.registerClass(name, qualifiedKey, def);
@ -311,11 +313,13 @@ export const createRegistrationTable = (
// classified as 'dispatch'. This is the compile-time twin of the
// runtime taxonomy — no drift possible.
const dispatchByLabel = {
// class-like — six labels share the single `classLikeHook` closure,
// class-like — eight labels share the single `classLikeHook` closure,
// kept in lockstep with `CLASS_TYPES_TUPLE` via the
// `Record<ClassLikeLabel, 'dispatch'>` cross-invariant on
// `LABEL_BEHAVIOR`.
Class: classLikeHook,
Protocol: classLikeHook,
Category: classLikeHook,
Struct: classLikeHook,
Interface: classLikeHook,
Enum: classLikeHook,

View file

@ -56,6 +56,8 @@ import type { NodeLabel, ParameterTypeClass, SymbolDefinition } from 'gitnexus-s
*/
export const CLASS_TYPES_TUPLE = [
'Class',
'Protocol',
'Category',
'Struct',
'Interface',
'Enum',

View file

@ -1,7 +1,7 @@
import type { NodeLabel } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import type { SymbolTableWriter } from './model/index.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { getLanguageForFileContent } from './languages/index.js';
import { accumulateExportedTypesFromParsedNode, type ExportedTypeMap } from './call-processor.js';
@ -229,7 +229,7 @@ export const dispatchChunkParse = async (
): Promise<ParseWorkerResult[]> => {
const parseableFiles: ParseWorkerInput[] = [];
for (const file of files) {
const lang = getLanguageFromFilename(file.path);
const lang = getLanguageForFileContent(file.path, file.content);
if (lang) parseableFiles.push({ path: file.path, content: file.content });
}
if (parseableFiles.length === 0) return [];

View file

@ -61,7 +61,13 @@ import {
createParserForLanguage,
} from '../../tree-sitter/parser-loader.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { getProvider, getProviderForFile, providers } from '../languages/index.js';
import {
getProvider,
getProviderForFile,
needsContentLanguageClassification,
providers,
} from '../languages/index.js';
import { classifyContentLanguages } from '../content-language-classification.js';
import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
import type Parser from 'tree-sitter';
@ -464,6 +470,14 @@ export async function runChunkedParseAndResolve(
* cache analyze run can skip the dominant `extractParsedFile` cost
* (otherwise ~58s on a 1000-file repo). */
parsedFiles: import('gitnexus-shared').ParsedFile[];
/**
* Content-derived language decisions for extension-ambiguous files.
*
* The source text used to classify these paths is released before parsing
* begins. Scope resolution consumes this snapshot instead of reading the
* same files again just to repeat classification.
*/
contentLanguageByPath: ReadonlyMap<string, SupportedLanguages | null>;
/** Repo-wide harvested constants, already prepared per provider. See
* `ParseOutput.moduleConstants` for why this leaves the parse phase. */
moduleConstants: ReadonlyMap<string, ModuleConstants>;
@ -474,15 +488,27 @@ export async function runChunkedParseAndResolve(
const model = createSemanticModel();
const symbolTable = model.symbols;
const contentClassifiedPaths = scannedFiles
.map((file) => file.path)
.filter(needsContentLanguageClassification);
const contentLanguageByPath =
contentClassifiedPaths.length > 0
? await classifyContentLanguages(repoPath, contentClassifiedPaths)
: new Map<string, SupportedLanguages | null>();
const languageForScannedFile = (file: (typeof scannedFiles)[number]) => {
return contentLanguageByPath.has(file.path)
? (contentLanguageByPath.get(file.path) ?? null)
: getLanguageFromFilename(file.path);
};
const parseableScanned = scannedFiles.filter((f) => {
const lang = getLanguageFromFilename(f.path);
const lang = languageForScannedFile(f);
return lang && isLanguageAvailable(lang);
});
// Warn about files skipped due to unavailable parsers
const skippedByLang = new Map<string, number>();
for (const f of scannedFiles) {
const lang = getLanguageFromFilename(f.path);
const lang = languageForScannedFile(f);
const provider = lang === null ? undefined : getProvider(lang);
if (lang && provider?.parseStrategy !== 'standalone' && !isLanguageAvailable(lang)) {
skippedByLang.set(lang, (skippedByLang.get(lang) || 0) + 1);
@ -1621,6 +1647,7 @@ export async function runChunkedParseAndResolve(
// cache: when the file's ParsedFile is here, scope-resolution skips its own
// `extractParsedFile` call.
parsedFiles: allParsedFiles,
contentLanguageByPath,
// Repo-wide, file-path-keyed constants, already through each provider's
// `prepareRouteConstants` hook. Empty when no provider harvests constants
// for the languages in this repo.

View file

@ -20,7 +20,7 @@ import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import type { StructureOutput } from './structure.js';
import type { BindingAccumulator } from '../binding-accumulator.js';
import type { ParsedFile } from 'gitnexus-shared';
import type { ParsedFile, SupportedLanguages } from 'gitnexus-shared';
import type {
ExtractedFetchCall,
ExtractedRoute,
@ -83,6 +83,12 @@ export interface ParseOutput {
* costing ~58s on a 1000-file repo).
*/
readonly parsedFiles: readonly ParsedFile[];
/**
* Content-derived language decisions for files such as `.h` whose extension
* is ambiguous. Scope resolution reuses this result rather than rereading
* source solely to classify it.
*/
readonly contentLanguageByPath: ReadonlyMap<string, SupportedLanguages | null>;
/**
* Repo-wide string constants harvested by the providers that declare
* `extractModuleConstants`, keyed by file path and already through each

View file

@ -847,6 +847,10 @@ function normalizeNodeLabel(kindStr: string): SymbolDefinition['type'] | undefin
switch (kindStr.toLowerCase()) {
case 'class':
return 'Class';
case 'protocol':
return 'Protocol';
case 'category':
return 'Category';
case 'interface':
return 'Interface';
case 'enum':

View file

@ -87,6 +87,8 @@ export const CALLER_ANCHOR_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
'Constructor',
'Module',
'Class',
'Protocol',
'Category',
'Interface',
'Struct',
'Enum',

View file

@ -280,6 +280,8 @@ export const LINKABLE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
// targets and need the same def→graph bridge.
'Module',
'Class',
'Protocol',
'Category',
'Interface',
'Struct',
'Enum',

View file

@ -177,7 +177,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
logHeapProbe('scopeResolution-enter');
const { scannedFiles } = getPhaseOutput<StructureOutput>(deps, 'structure');
const parseOutput = getPhaseOutput<ParseOutput>(deps, 'parse');
const { model, parsedFiles: workerParsedFiles } = parseOutput;
const { model, parsedFiles: workerParsedFiles, contentLanguageByPath } = parseOutput;
const scopeExtractionFailures = new Set(parseOutput.scopeExtractionFailures);
// SemanticModel populated during `parse`: scope-resolution consumes
// TypeRegistry / MethodRegistry / SymbolTable lookups instead of
@ -257,7 +257,9 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
(typeof scannedFiles)[number][]
>();
for (const f of scannedFiles) {
const fileLang = getLanguageFromFilename(f.path);
const fileLang = contentLanguageByPath.has(f.path)
? (contentLanguageByPath.get(f.path) ?? null)
: getLanguageFromFilename(f.path);
if (fileLang === null) continue;
// Tree-sitter providers require an available grammar. Standalone regex
// providers deliberately have none and re-extract on the main thread.

View file

@ -28,6 +28,7 @@ import { swiftScopeResolver } from '../../languages/swift/scope-resolver.js';
import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
import { zigScopeResolver } from '../../languages/zig/scope-resolver.js';
import { objectiveCScopeResolver } from '../../languages/objective-c/scope-resolver.js';
/** Map of `SupportedLanguages` `ScopeResolver`. The scope-resolution phase
* iterates this map directly every registered resolver runs. This is the
@ -53,4 +54,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Dart, dartScopeResolver],
[SupportedLanguages.Vue, vueScopeResolver],
[SupportedLanguages.Zig, zigScopeResolver],
[SupportedLanguages.ObjectiveC, objectiveCScopeResolver],
]);

View file

@ -181,6 +181,8 @@ export function namesAtScope(scopeId: ScopeId, scopes: ScopeResolutionIndexes):
export function isClassLike(t: string): boolean {
return (
t === 'Class' ||
t === 'Protocol' ||
t === 'Category' ||
t === 'Interface' ||
t === 'Struct' ||
t === 'Record' ||

View file

@ -2610,12 +2610,15 @@ export const ZIG_QUERIES = `
import { SupportedLanguages } from 'gitnexus-shared';
const OBJECTIVE_C_QUERIES = `((translation_unit) @objc.root)`;
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES,
[SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES,
[SupportedLanguages.Python]: PYTHON_QUERIES,
[SupportedLanguages.Java]: JAVA_QUERIES,
[SupportedLanguages.C]: C_QUERIES,
[SupportedLanguages.ObjectiveC]: OBJECTIVE_C_QUERIES,
[SupportedLanguages.Go]: GO_QUERIES,
[SupportedLanguages.CPlusPlus]: CPP_QUERIES,
[SupportedLanguages.CSharp]: CSHARP_QUERIES,

View file

@ -23,6 +23,8 @@ export const SYMBOL_NODE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
'Function',
'Method',
'Class',
'Protocol',
'Category',
'Interface',
'CodeElement',
'Struct',

View file

@ -19,7 +19,7 @@ import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import { requireVendoredGrammar } from '../../tree-sitter/vendored-grammars.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from '../languages/index.js';
import { getLanguageForFileContent, getProvider } from '../languages/index.js';
import {
getTreeSitterBufferSize,
getTreeSitterContentByteLength,
@ -87,6 +87,11 @@ let Zig: TreeSitterLanguage | null = null;
try {
Zig = requireVendoredGrammar('tree-sitter-zig') as TreeSitterLanguage;
} catch {}
let ObjectiveC: TreeSitterLanguage | null = null;
try {
ObjectiveC = requireVendoredGrammar('tree-sitter-objc') as TreeSitterLanguage;
} catch {}
import { getLanguageFromFilename } from 'gitnexus-shared';
import {
buildDefinitionPreScan,
@ -251,7 +256,7 @@ interface ParsedRelationship {
id: string;
sourceId: string;
targetId: string;
type: 'DEFINES' | 'HAS_METHOD' | 'HAS_PROPERTY';
type: 'DEFINES' | 'DECLARES' | 'HAS_METHOD' | 'HAS_PROPERTY';
confidence: number;
reason: string;
}
@ -557,6 +562,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
[SupportedLanguages.Java]: Java,
...(C ? { [SupportedLanguages.C]: C } : {}),
[SupportedLanguages.CPlusPlus]: CPP,
...(ObjectiveC ? { [SupportedLanguages.ObjectiveC]: ObjectiveC } : {}),
[SupportedLanguages.CSharp]: CSharp,
[SupportedLanguages.Go]: Go,
[SupportedLanguages.Rust]: Rust,
@ -1269,7 +1275,7 @@ const processBatch = (
// Group by language to minimize setLanguage calls
const byLanguage = new Map<SupportedLanguages, ParseWorkerInput[]>();
for (const file of files) {
const lang = getLanguageFromFilename(file.path);
const lang = getLanguageForFileContent(file.path, file.content);
if (!lang) continue;
let list = byLanguage.get(lang);
if (!list) {
@ -1728,6 +1734,52 @@ const processFileGroup = (
result.parsedFiles.push(withChannels);
}
const semanticGraph = provider.extractSemanticGraph?.(tree, file.path, parseContent);
if (semanticGraph !== undefined) {
for (const node of semanticGraph.nodes) {
result.nodes.push({
id: node.id,
label: node.label,
properties: { ...node.properties },
});
}
for (const relationship of semanticGraph.relationships) {
if (
relationship.type === 'DECLARES' ||
relationship.type === 'DEFINES' ||
relationship.type === 'HAS_METHOD' ||
relationship.type === 'HAS_PROPERTY'
) {
result.relationships.push({ ...relationship, type: relationship.type });
}
}
for (const symbol of semanticGraph.symbols) {
result.symbols.push({
filePath: symbol.filePath,
name: symbol.name,
nodeId: symbol.nodeId,
type: symbol.type,
...(symbol.qualifiedName !== undefined ? { qualifiedName: symbol.qualifiedName } : {}),
...(symbol.parameterCount !== undefined ? { parameterCount: symbol.parameterCount } : {}),
...(symbol.requiredParameterCount !== undefined
? { requiredParameterCount: symbol.requiredParameterCount }
: {}),
...(symbol.parameterTypes !== undefined
? { parameterTypes: [...symbol.parameterTypes] }
: {}),
...(symbol.parameterTypeClasses !== undefined
? { parameterTypeClasses: [...symbol.parameterTypeClasses] }
: {}),
...(symbol.returnType !== undefined ? { returnType: symbol.returnType } : {}),
...(symbol.declaredType !== undefined ? { declaredType: symbol.declaredType } : {}),
...(symbol.ownerId !== undefined ? { ownerId: symbol.ownerId } : {}),
...(symbol.visibility !== undefined ? { visibility: symbol.visibility } : {}),
...(symbol.isStatic !== undefined ? { isStatic: symbol.isStatic } : {}),
...(symbol.isReadonly !== undefined ? { isReadonly: symbol.isReadonly } : {}),
});
}
}
// Build per-file type environment + constructor bindings in a single AST walk.
// The legacy heritage pre-pass that seeded a file-local parentMap for
// buildTypeEnv was removed in RING4-1 (#942) along with the rest of the

View file

@ -568,6 +568,8 @@ export const streamAllCSVsToDisk = async (
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
const constHeader = `${multiLangHeader},convexEndpointFactory`;
const MULTI_LANG_TYPES = [
'Protocol',
'Category',
'Struct',
'Enum',
'Macro',

View file

@ -69,6 +69,30 @@ CREATE NODE TABLE Class (
PRIMARY KEY (id)
)`;
export const PROTOCOL_SCHEMA = `
CREATE NODE TABLE Protocol (
id STRING,
name STRING,
filePath STRING,
startLine INT64,
endLine INT64,
content STRING,
description STRING,
PRIMARY KEY (id)
)`;
export const CATEGORY_SCHEMA = `
CREATE NODE TABLE Category (
id STRING,
name STRING,
filePath STRING,
startLine INT64,
endLine INT64,
content STRING,
description STRING,
PRIMARY KEY (id)
)`;
export const INTERFACE_SCHEMA = `
CREATE NODE TABLE Interface (
id STRING,
@ -581,6 +605,9 @@ export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder,
FROM \`Module\` TO \`Namespace\`,
FROM \`Namespace\` TO Function,
FROM CodeElement TO CodeElement,
FROM CodeElement TO Class,
FROM CodeElement TO Category,
FROM CodeElement TO Method,
FROM CodeElement TO \`Module\`,
FROM CodeElement TO \`Property\`,
FROM Section TO Section,
@ -714,6 +741,8 @@ export const NODE_SCHEMA_QUERIES = [
FOLDER_SCHEMA,
FUNCTION_SCHEMA,
CLASS_SCHEMA,
PROTOCOL_SCHEMA,
CATEGORY_SCHEMA,
INTERFACE_SCHEMA,
METHOD_SCHEMA,
CODE_ELEMENT_SCHEMA,

View file

@ -25,6 +25,8 @@ export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
// Original 5 (minus File) gain `description`.
{ table: 'Function', indexName: 'function_fts', properties: FTS_PROPERTIES },
{ table: 'Class', indexName: 'class_fts', properties: FTS_PROPERTIES },
{ table: 'Protocol', indexName: 'protocol_fts', properties: FTS_PROPERTIES },
{ table: 'Category', indexName: 'category_fts', properties: FTS_PROPERTIES },
{ table: 'Method', indexName: 'method_fts', properties: FTS_PROPERTIES },
{ table: 'Interface', indexName: 'interface_fts', properties: FTS_PROPERTIES },
// Remaining EMBEDDABLE_LABELS symbol tables — all CODE_ELEMENT_BASE-shaped

View file

@ -96,6 +96,17 @@ const SOURCES: Record<string, GrammarSource> = {
unavailableNote:
'C++ parsing requires `tree-sitter-cpp`. Check the install and native binding.',
},
[SupportedLanguages.ObjectiveC]: {
load: () => requireVendoredGrammar('tree-sitter-objc'),
optional: true,
severity: 'error',
unavailableNote:
'Objective-C parsing disabled: vendored `tree-sitter-objc` (under ' +
'`gitnexus/vendor/tree-sitter-objc`) could not be loaded. GitNexus ships ' +
'prebuilt binaries for supported macOS/Linux runner architectures; this usually ' +
'indicates a corrupted install or native ABI mismatch with the bundled ' +
'tree-sitter@0.21.1 runtime.',
},
[SupportedLanguages.Go]: {
load: () => _require('tree-sitter-go'),
unavailableNote: 'Go parsing requires `tree-sitter-go`. Check the install and native binding.',

View file

@ -33,6 +33,7 @@ export const VENDORED_GRAMMAR_PACKAGES: ReadonlySet<string> = new Set([
'tree-sitter-proto',
'tree-sitter-swift',
'tree-sitter-kotlin',
'tree-sitter-objc',
'tree-sitter-zig',
]);
@ -43,7 +44,7 @@ export const vendoredGrammarDir = (packageName: string): string =>
/**
* Load a vendored tree-sitter grammar by its absolute path under `vendor/`.
*
* GitNexus vendors six grammars (c/dart/proto/swift/kotlin/zig) inside its own
* GitNexus vendors seven grammars (c/dart/proto/swift/kotlin/objc/zig) inside its own
* package under `vendor/`, each shipping committed per-platform prebuilds. They
* are deliberately NOT npm dependencies and must NEVER be copied into
* `node_modules`: an undeclared package under `node_modules` is "extraneous" to

View file

@ -328,6 +328,8 @@ export const VALID_NODE_LABELS = new Set([
'Folder',
'Function',
'Class',
'Protocol',
'Category',
'Interface',
'Method',
'CodeElement',
@ -4249,7 +4251,7 @@ export class LocalBackend {
repo.lbugPath,
`
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'MEMBER_OF', 'USES', 'DECLARES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
ORDER BY uid, relType
LIMIT 30
@ -4404,7 +4406,7 @@ export class LocalBackend {
repo.lbugPath,
`
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'MEMBER_OF', 'USES', 'DECLARES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
ORDER BY uid, relType
LIMIT 30

View file

@ -735,7 +735,14 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v93: Zig call captures inside a comptime-false branch carry
// `@reference.static-gated` (feat/zig-static-gated-edges); the site gains
// `staticGated` and the CALLS edge a BOOLEAN column.
const SCHEMA_BUMP = 93;
// v94: Objective-C now elides bare, file-scope macro markers before parsing.
// A warm v93 cache can retain error-recovered trees and provider facts that
// omit Objective-C declarations following markers such as RCT_EXTERN_C_END.
// v95: Objective-C header classification no longer treats framework `#import`
// alone as Objective-C syntax. A warm v94 cache can replay Objective-C worker
// output for a C++ header during `--force`, even though the current classifier
// routes that same header through the C++ provider.
const SCHEMA_BUMP = 95;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,9 @@
#import "SYModuleCaller.h"
@interface SYModuleBridge : NSObject
- (void)bridgeValue:(NSInteger)value;
@end
@implementation SYModuleBridge
- (void)bridgeValue:(NSInteger)value {}
@end

View file

@ -0,0 +1,32 @@
#import <Foundation/Foundation.h>
#define RCT_EXTERN_C_BEGIN
#define RCT_EXTERN_C_END
RCT_EXTERN_C_BEGIN
typedef struct SYModuleMethodInfo {
const char *const name;
} SYModuleMethodInfo;
RCT_EXTERN_C_END
int SYModuleSupportAdd(int a, int b);
@protocol SYModuleRunnable <NSObject>
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYBaseCaller : NSObject
- (void)loadData:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYModuleCaller : SYBaseCaller <SYModuleRunnable> {
SYBaseCaller *_base;
}
@property (nonatomic, strong) SYBaseCaller *helper;
+ (instancetype)sharedCaller;
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYModuleCaller (Tracing)
- (void)traceEvent:(NSString *)name;
@end

View file

@ -0,0 +1,32 @@
#import "SYModuleCaller.h"
#include "SYModuleSupport.h"
@import Foundation;
#define SY_OBJC_RECEIVER(x) x
@interface SYModuleCaller ()
@property (nonatomic, strong) SYBaseCaller *privateHelper;
@end
@implementation SYModuleCaller
+ (instancetype)sharedCaller { return [SYModuleCaller new]; }
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion {
SYBaseCaller *typed = self.helper;
id dynamic = typed;
[self traceEvent:name];
[SY_OBJC_RECEIVER(self) traceEvent:name];
[super loadData:name completion:completion];
[self loadData:name completion:completion];
[typed loadData:name completion:completion];
[dynamic loadData:name completion:completion];
}
- (void)runProtocol:(id<SYModuleRunnable>)runner {
[runner runTask:@"x" completion:^(BOOL ok) {}];
}
@end
@implementation SYModuleCaller (Tracing)
- (void)traceEvent:(NSString *)name {}
@end
static int SYModuleCompute(int value) { return value + 1; }

View file

@ -0,0 +1,4 @@
#ifndef SY_MODULE_SUPPORT_H
#define SY_MODULE_SUPPORT_H
int SYModuleSupportAdd(int a, int b);
#endif

View file

@ -109,7 +109,7 @@ interface NodeTypeEntry {
/** 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/zig) are NOT in node_modules — they
// 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)) {

View file

@ -151,6 +151,50 @@ describe('streamAllCSVsToDisk', () => {
expect(await readAllRelRows(result.relsByPair)).toHaveLength(3);
});
it('persists Protocol and Category nodes and their structural relationships', async () => {
const graph = buildTestGraph(
[
{
id: 'Protocol:objc:protocol:Runnable',
label: 'Protocol',
name: 'Runnable',
filePath: 'src/Runnable.h',
},
{
id: 'Class:objc:class:Worker',
label: 'Class',
name: 'Worker',
filePath: 'src/Worker.h',
},
{
id: 'Category:objc:category:Worker:Tracing',
label: 'Category',
name: 'Tracing',
filePath: 'src/Worker+Tracing.m',
},
],
[
{
sourceId: 'Class:objc:class:Worker',
targetId: 'Protocol:objc:protocol:Runnable',
type: 'IMPLEMENTS',
},
{
sourceId: 'Category:objc:category:Worker:Tracing',
targetId: 'Class:objc:class:Worker',
type: 'MEMBER_OF',
},
],
);
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
expect(result.nodeFiles.get('Protocol')?.rows).toBe(1);
expect(result.nodeFiles.get('Category')?.rows).toBe(1);
expect(result.relsByPair.get('Class|Protocol')?.rows).toBe(1);
expect(result.relsByPair.get('Category|Class')?.rows).toBe(1);
});
it('CSV content is properly escaped', async () => {
const graph = buildTestGraph([
{

View file

@ -0,0 +1,767 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import type { GraphNode, RelationshipType } from 'gitnexus-shared';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { classifyObjectiveCFileContent } from '../../src/core/ingestion/languages/objective-c.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../fixtures/objective-c',
);
function readFixture(name: string): string {
return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8');
}
const HEADER = readFixture('SYModuleCaller.h');
const IMPL = readFixture('SYModuleCaller.m');
const MM_IMPL = readFixture('SYModuleBridge.mm');
const PLAIN_C_HEADER = readFixture('SYModuleSupport.h');
const HEADER_V2 = HEADER.replace(
'- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;\n@end',
'- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;\n- (void)cancelTask;\n@end',
).replace(
'- (void)traceEvent:(NSString *)name;\n@end',
'- (void)traceEvent:(NSString *)name;\n- (void)traceDetail:(NSString *)name level:(NSInteger)level;\n@end',
);
const IMPL_V2 = IMPL.replace(
'[self traceEvent:name];',
'[self traceEvent:name];\n [self traceDetail:name level:1];',
).replace(
'@implementation SYModuleCaller (Tracing)\n- (void)traceEvent:(NSString *)name {}\n@end',
'@implementation SYModuleCaller (Tracing)\n- (void)traceEvent:(NSString *)name {}\n- (void)traceDetail:(NSString *)name level:(NSInteger)level {}\n@end',
);
function git(repoRoot: string, command: string): void {
execSync(command, { cwd: repoRoot, stdio: 'pipe' });
}
function gitCommitAll(repoRoot: string, message: string): void {
git(repoRoot, 'git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A');
git(
repoRoot,
`git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m "${message}"`,
);
}
function writeObjectiveCRepo(repoRoot: string, header = HEADER, impl = IMPL): void {
fs.writeFileSync(path.join(repoRoot, 'SYModuleCaller.h'), header);
fs.writeFileSync(path.join(repoRoot, 'SYModuleCaller.m'), impl);
fs.writeFileSync(path.join(repoRoot, 'SYModuleBridge.mm'), MM_IMPL);
fs.writeFileSync(path.join(repoRoot, 'SYModuleSupport.h'), PLAIN_C_HEADER);
}
function normalizeRows(rows: unknown): unknown[] {
if (!Array.isArray(rows)) return [];
return rows
.map((row) => {
const record = row as Record<string, unknown>;
return {
uid: record.uid ?? record.id,
name: record.name,
filePath: record.filePath,
kind: record.kind,
};
})
.sort((left, right) =>
`${left.uid ?? ''}:${left.name ?? ''}:${left.filePath ?? ''}`.localeCompare(
`${right.uid ?? ''}:${right.name ?? ''}:${right.filePath ?? ''}`,
),
);
}
function normalizeBuckets(value: unknown): Record<string, unknown[]> {
const record = (value ?? {}) as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.sort()
.map((key) => [key, normalizeRows(record[key])]),
);
}
function normalizeContext(value: unknown): Record<string, unknown> {
const record = value as Record<string, unknown>;
const symbol = (record.symbol ?? {}) as Record<string, unknown>;
return {
status: record.status,
symbol: {
uid: symbol.uid,
name: symbol.name,
kind: symbol.kind,
filePath: symbol.filePath,
},
incoming: normalizeBuckets(record.incoming),
outgoing: normalizeBuckets(record.outgoing),
};
}
async function readPersistedObjectiveCSurface(repoRoot: string): Promise<Record<string, unknown>> {
const backend = new LocalBackend();
try {
const classContext = await backend.callTool('context', {
name: 'SYModuleCaller',
file_path: 'SYModuleCaller.h',
repo: repoRoot,
});
const runTaskContext = await backend.callTool('context', {
uid: 'Method:objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
repo: repoRoot,
});
const runProtocolContext = await backend.callTool('context', {
uid: 'Method:objc:method:objc:class:SYModuleCaller:-:runProtocol:',
repo: repoRoot,
});
const candidateEvidenceId = (
(
(runProtocolContext as Record<string, unknown>).outgoing as
| Record<string, unknown[]>
| undefined
)?.uses ?? []
)
.map((entry) => (entry as Record<string, unknown>).uid)
.find(
(uid): uid is string =>
typeof uid === 'string' && uid.startsWith('CodeElement:objc:protocol-candidates:'),
);
if (candidateEvidenceId === undefined) {
throw new Error('Persisted protocol candidate evidence was not reachable from context');
}
const candidateEvidenceContext = await backend.callTool('context', {
uid: candidateEvidenceId,
repo: repoRoot,
});
const categoryContext = await backend.callTool('context', {
uid: 'Category:objc:category:SYModuleCaller:Tracing',
repo: repoRoot,
});
const queryResult = (await backend.callTool('query', {
search_query: 'SYModuleCaller',
repo: repoRoot,
limit: 5,
include_content: false,
})) as Record<string, unknown>;
const protocolAndCategoryResult = await backend.callTool('cypher', {
query:
"MATCH (n) WHERE labels(n) IN ['Protocol', 'Category'] " +
'RETURN n.id AS id, labels(n)[0] AS kind ORDER BY kind, id',
repo: repoRoot,
});
const categoryHostResult = await backend.callTool('cypher', {
query:
'MATCH (category:Category)-[r:CodeRelation]->(host:Class) ' +
"WHERE r.type = 'MEMBER_OF' " +
'RETURN category.id AS category, host.id AS host',
repo: repoRoot,
});
const protocolCandidateResult = await backend.callTool('cypher', {
query:
'MATCH (source:Method)-[sourceRel:CodeRelation]->(e:CodeElement)-[candidateRel:CodeRelation]->(candidate:Method) ' +
"WHERE sourceRel.type = 'USES' AND candidateRel.type = 'USES' " +
"AND e.id STARTS WITH 'CodeElement:objc:protocol-candidates:' " +
'RETURN source.id AS sourceId, candidate.id AS candidateId, candidateRel.reason AS reason ' +
'ORDER BY sourceId, candidateId',
repo: repoRoot,
});
const unresolvedReasonResult = await backend.callTool('cypher', {
query:
'MATCH (source:Method)-[r:CodeRelation]->(e:CodeElement) ' +
"WHERE r.type = 'USES' AND e.id STARTS WITH 'CodeElement:objc:unresolved:' " +
'RETURN source.id AS sourceId, e.name AS evidence, r.reason AS reason ' +
'ORDER BY sourceId, evidence',
repo: repoRoot,
});
return {
classContext: normalizeContext(classContext),
runTaskContext: normalizeContext(runTaskContext),
runProtocolContext: normalizeContext(runProtocolContext),
candidateEvidenceContext: normalizeContext(candidateEvidenceContext),
categoryContext: normalizeContext(categoryContext),
queryDefinitions: normalizeRows(queryResult.definitions),
protocolAndCategoryResult,
categoryHostResult,
protocolCandidateResult,
unresolvedReasonResult,
};
} finally {
await backend.disconnect();
}
}
async function analyzeObjectiveCRepo(
repoRoot: string,
options: { force?: boolean } = {},
): Promise<string[]> {
const logs: string[] = [];
await runFullAnalysis(
repoRoot,
{
force: options.force,
skipAgentsMd: true,
skipSkills: true,
workerPoolSize: 1,
},
{
onProgress: () => undefined,
onLog: (message) => logs.push(message),
},
);
return logs;
}
describe('Objective-C provider integration', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-provider-'));
fs.writeFileSync(path.join(repoRoot, 'SYModuleCaller.h'), HEADER);
fs.writeFileSync(path.join(repoRoot, 'SYModuleCaller.m'), IMPL);
fs.writeFileSync(path.join(repoRoot, 'SYModuleBridge.mm'), MM_IMPL);
fs.writeFileSync(path.join(repoRoot, 'SYModuleSupport.h'), PLAIN_C_HEADER);
result = await runPipelineFromRepo(repoRoot, () => undefined, {
workerPoolSize: 1,
});
}, 60000);
afterAll(() => {
fs.rmSync(repoRoot, { recursive: true, force: true });
});
function nodeByQualifiedName(qualifiedName: string): GraphNode | undefined {
return result.graph.nodes.find((node) => node.properties.qualifiedName === qualifiedName);
}
function expectNode(qualifiedName: string, label: GraphNode['label']): GraphNode {
const node = nodeByQualifiedName(qualifiedName);
expect(node, qualifiedName).toBeDefined();
expect(node?.label).toBe(label);
if (node === undefined) throw new Error(`Missing expected node ${qualifiedName}`);
return node;
}
function hasRelationship(
type: RelationshipType,
sourceId: string,
targetId: string,
reason?: string | RegExp,
): boolean {
return result.graph.relationships.some((rel) => {
if (rel.type !== type || rel.sourceId !== sourceId || rel.targetId !== targetId) return false;
if (reason === undefined) return true;
return typeof reason === 'string' ? rel.reason === reason : reason.test(rel.reason);
});
}
it('classifies a macro-wrapped Objective-C header by declarations after the marker', () => {
expect(
classifyObjectiveCFileContent(
'RCTBridgeModule.h',
[
'RCT_EXTERN_C_BEGIN',
'typedef struct RCTMethodInfo {',
' const char *const jsName;',
'} RCTMethodInfo;',
'RCT_EXTERN_C_END',
'@protocol RCTBridgeModule <NSObject>',
'- (void)run;',
'@end',
'',
].join('\n'),
),
).toBe(true);
});
it('indexes Objective-C semantic nodes beyond File nodes', () => {
expectNode('objc:protocol:SYModuleRunnable', 'Protocol');
expectNode('objc:class:SYBaseCaller', 'Class');
expectNode('objc:class:SYModuleCaller', 'Class');
expectNode('objc:class:SYModuleBridge', 'Class');
expectNode('objc:category:SYModuleCaller:Tracing', 'Category');
expectNode('objc:method:objc:class:SYModuleCaller:-:runTask:completion:', 'Method');
expectNode('objc:method:objc:class:SYModuleBridge:-:bridgeValue:', 'Method');
expectNode('objc:method:objc:class:SYModuleCaller:+:sharedCaller', 'Method');
expectNode('objc:method:objc:category:SYModuleCaller:Tracing:-:traceEvent:', 'Method');
expectNode('objc:property:objc:class:SYModuleCaller:helper', 'Property');
expectNode('objc:ivar:objc:class:SYModuleCaller:_base', 'Variable');
expectNode('objc:function:SYModuleSupportAdd', 'Function');
expectNode('objc:function:static:SYModuleCaller.m:SYModuleCompute', 'Function');
});
it('emits imports, inheritance, protocol, and category host relationships', () => {
const caller = expectNode('objc:class:SYModuleCaller', 'Class');
const base = expectNode('objc:class:SYBaseCaller', 'Class');
const protocol = expectNode('objc:protocol:SYModuleRunnable', 'Protocol');
const category = expectNode('objc:category:SYModuleCaller:Tracing', 'Category');
expect(hasRelationship('EXTENDS', caller.id, base.id)).toBe(true);
expect(hasRelationship('IMPLEMENTS', caller.id, protocol.id)).toBe(true);
expect(hasRelationship('MEMBER_OF', category.id, caller.id)).toBe(true);
const importNodes = result.graph.nodes.filter((node) => node.label === 'Import');
expect(importNodes.map((node) => node.properties.targetRaw)).toEqual(
expect.arrayContaining(['SYModuleCaller.h', 'SYModuleSupport.h', 'Foundation']),
);
const mFile = result.graph.nodes.find(
(node) => node.label === 'File' && node.properties.filePath === 'SYModuleCaller.m',
);
const hFile = result.graph.nodes.find(
(node) => node.label === 'File' && node.properties.filePath === 'SYModuleCaller.h',
);
expect(mFile).toBeDefined();
expect(hFile).toBeDefined();
if (mFile === undefined || hFile === undefined) {
throw new Error('Missing Objective-C fixture file nodes');
}
expect(hasRelationship('IMPORTS', mFile.id, hFile.id)).toBe(true);
});
it('records implementation evidence for merged declarations', () => {
const caller = expectNode('objc:class:SYModuleCaller', 'Class');
const runTask = expectNode(
'objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
'Method',
);
const implementationEvidence = result.graph.nodes.filter(
(node) =>
node.label === 'CodeElement' &&
node.properties.objectiveCKind === 'implementation-evidence' &&
node.properties.filePath === 'SYModuleCaller.m',
);
expect(implementationEvidence.map((node) => node.properties.targetQualifiedName)).toEqual(
expect.arrayContaining([
'objc:class:SYModuleCaller',
'objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
]),
);
expect(
implementationEvidence.some((node) =>
hasRelationship('DECLARES', node.id, caller.id, 'objc: implementation of merged symbol'),
),
).toBe(true);
expect(
implementationEvidence.some((node) =>
hasRelationship('DECLARES', node.id, runTask.id, 'objc: implementation of merged symbol'),
),
).toBe(true);
});
it('emits conservative Objective-C message-send call edges and unresolved evidence', () => {
const runTask = expectNode(
'objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
'Method',
);
const loadData = expectNode(
'objc:method:objc:class:SYBaseCaller:-:loadData:completion:',
'Method',
);
const traceEvent = expectNode(
'objc:method:objc:category:SYModuleCaller:Tracing:-:traceEvent:',
'Method',
);
const runProtocol = expectNode(
'objc:method:objc:class:SYModuleCaller:-:runProtocol:',
'Method',
);
const protocolRun = expectNode(
'objc:method:objc:protocol:SYModuleRunnable:-:runTask:completion:',
'Method',
);
expect(
hasRelationship('CALLS', runTask.id, loadData.id, /objc-message: (super|local) receiver/),
).toBe(true);
expect(hasRelationship('CALLS', runTask.id, loadData.id, 'objc-message: self receiver')).toBe(
true,
);
expect(hasRelationship('CALLS', runTask.id, traceEvent.id, 'objc-message: self receiver')).toBe(
true,
);
expect(
hasRelationship('CALLS', runProtocol.id, protocolRun.id, 'objc-message: protocol receiver'),
).toBe(true);
const unresolved = result.graph.nodes.find(
(node) =>
node.label === 'CodeElement' &&
node.properties.objectiveCKind === 'unresolved-message' &&
node.properties.receiver === 'dynamic',
);
expect(unresolved).toBeDefined();
if (unresolved === undefined) throw new Error('Missing unresolved dynamic message evidence');
expect(
hasRelationship('CALLS', runTask.id, unresolved.id),
'dynamic id receiver must not become a certain CALLS edge',
).toBe(false);
expect(
hasRelationship(
'USES',
runTask.id,
unresolved.id,
'objc-message: unresolved: id receiver is dynamic',
),
).toBe(true);
expect(unresolved.properties.name).toContain('unresolved: id receiver is dynamic');
const macroUnresolved = result.graph.nodes.find(
(node) =>
node.label === 'CodeElement' &&
node.properties.objectiveCKind === 'unresolved-message' &&
node.properties.receiver === 'SY_OBJC_RECEIVER(self)',
);
expect(macroUnresolved?.properties.name).toContain(
'unresolved: macro receiver SY_OBJC_RECEIVER is dynamic',
);
const candidates = result.graph.nodes.find(
(node) =>
node.label === 'CodeElement' &&
String(node.properties.qualifiedName).startsWith('objc:protocol-candidates:'),
);
expect(candidates).toBeDefined();
if (candidates === undefined) throw new Error('Missing protocol candidate evidence');
expect(
hasRelationship(
'USES',
runProtocol.id,
candidates.id,
'objc-message: protocol receiver candidates: SYModuleRunnable runTask:completion:',
),
).toBe(true);
expect(
hasRelationship(
'USES',
candidates.id,
runTask.id,
'objc-protocol-candidate: SYModuleRunnable runTask:completion:',
),
).toBe(true);
expect(
hasRelationship('CALLS', runProtocol.id, runTask.id),
'candidate implementations must not become certain CALLS edges',
).toBe(false);
});
it('resolves header member types across files and keeps static C helpers distinct', async () => {
const crossFileRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-cross-file-'));
try {
fs.writeFileSync(
path.join(crossFileRepo, 'Helper.h'),
'@interface Helper\n- (void)ping;\n@end\n',
);
fs.writeFileSync(
path.join(crossFileRepo, 'Worker.h'),
'#import "Helper.h"\n@interface Worker { Helper *_helper; }\n@property Helper *helper;\n- (void)run;\n@end\n',
);
fs.writeFileSync(
path.join(crossFileRepo, 'Worker.m'),
'#import "Worker.h"\n@implementation Worker\n- (void)run { [self.helper ping]; [_helper ping]; }\n@end\n',
);
fs.writeFileSync(
path.join(crossFileRepo, 'First.m'),
'static int helper(void) { return 1; }\n',
);
fs.writeFileSync(
path.join(crossFileRepo, 'Second.m'),
'static int helper(void) { return 2; }\n',
);
const crossFileResult = await runPipelineFromRepo(crossFileRepo, () => undefined, {
workerPoolSize: 1,
});
const run = crossFileResult.graph.nodes.find(
(node) =>
node.label === 'Method' &&
node.properties.qualifiedName === 'objc:method:objc:class:Worker:-:run',
);
const ping = crossFileResult.graph.nodes.find(
(node) =>
node.label === 'Method' &&
node.properties.qualifiedName === 'objc:method:objc:class:Helper:-:ping',
);
expect(run).toBeDefined();
expect(ping).toBeDefined();
if (run === undefined || ping === undefined) {
throw new Error('Missing cross-file Objective-C method nodes');
}
const memberCalls = crossFileResult.graph.relationships.filter(
(relationship) =>
relationship.type === 'CALLS' &&
relationship.sourceId === run.id &&
relationship.targetId === ping.id,
);
expect(memberCalls).toHaveLength(2);
const staticHelpers = crossFileResult.graph.nodes.filter(
(node) => node.label === 'Function' && node.properties.name === 'helper',
);
expect(staticHelpers.map((node) => node.properties.filePath).sort()).toEqual([
'First.m',
'Second.m',
]);
expect(new Set(staticHelpers.map((node) => node.properties.qualifiedName)).size).toBe(2);
} finally {
fs.rmSync(crossFileRepo, { recursive: true, force: true });
}
});
it('resolves inherited protocol members and candidates without looping on protocol cycles', async () => {
const protocolRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-protocols-'));
try {
fs.writeFileSync(
path.join(protocolRepo, 'Protocols.h'),
'@protocol Parent\n' +
'- (void)ping;\n' +
'@end\n' +
'@protocol Child <Parent>\n' +
'@end\n' +
'@protocol CycleA <CycleB>\n' +
'- (void)cycleA;\n' +
'@end\n' +
'@protocol CycleB <CycleA>\n' +
'- (void)cycleB;\n' +
'@end\n' +
'@interface ConcreteWorker <Child>\n' +
'- (void)ping;\n' +
'@end\n' +
'@interface Caller\n' +
'- (void)run:(id<Child>)worker;\n' +
'- (void)runCycle:(id<CycleA>)worker;\n' +
'@end\n',
);
fs.writeFileSync(
path.join(protocolRepo, 'Protocols.m'),
'#import "Protocols.h"\n' +
'@implementation ConcreteWorker\n' +
'- (void)ping {}\n' +
'@end\n' +
'@implementation Caller\n' +
'- (void)run:(id<Child>)worker { [worker ping]; }\n' +
'- (void)runCycle:(id<CycleA>)worker { [worker cycleB]; }\n' +
'@end\n',
);
const protocolResult = await runPipelineFromRepo(protocolRepo, () => undefined, {
workerPoolSize: 1,
});
const node = (qualifiedName: string): GraphNode => {
const found = protocolResult.graph.nodes.find(
(item) => item.properties.qualifiedName === qualifiedName,
);
if (found === undefined) throw new Error(`Missing protocol fixture node ${qualifiedName}`);
return found;
};
const has = (type: RelationshipType, sourceId: string, targetId: string): boolean =>
protocolResult.graph.relationships.some(
(relationship) =>
relationship.type === type &&
relationship.sourceId === sourceId &&
relationship.targetId === targetId,
);
const caller = node('objc:method:objc:class:Caller:-:run:');
const parentMethod = node('objc:method:objc:protocol:Parent:-:ping');
const workerMethod = node('objc:method:objc:class:ConcreteWorker:-:ping');
const cycleCaller = node('objc:method:objc:class:Caller:-:runCycle:');
const cycleMethod = node('objc:method:objc:protocol:CycleB:-:cycleB');
const candidateEvidence = protocolResult.graph.nodes.find(
(item) =>
item.label === 'CodeElement' &&
String(item.properties.qualifiedName).startsWith('objc:protocol-candidates:'),
);
expect(has('CALLS', caller.id, parentMethod.id)).toBe(true);
expect(has('CALLS', caller.id, workerMethod.id)).toBe(false);
expect(candidateEvidence).toBeDefined();
if (candidateEvidence === undefined)
throw new Error('Missing inherited protocol candidate evidence');
expect(has('USES', candidateEvidence.id, workerMethod.id)).toBe(true);
expect(has('CALLS', cycleCaller.id, cycleMethod.id)).toBe(true);
} finally {
fs.rmSync(protocolRepo, { recursive: true, force: true });
}
});
it('emits category membership only when the host class exists locally', async () => {
const categoryRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-category-host-'));
try {
fs.writeFileSync(
path.join(categoryRepo, 'Categories.m'),
'@interface LocalHost\n@end\n' +
'@interface LocalHost (Tracing)\n@end\n' +
'@interface UIView (Tracing)\n@end\n',
);
const categoryResult = await runPipelineFromRepo(categoryRepo, () => undefined, {
workerPoolSize: 1,
});
const localHost = categoryResult.graph.nodes.find(
(node) => node.properties.qualifiedName === 'objc:class:LocalHost',
);
const localCategory = categoryResult.graph.nodes.find(
(node) => node.properties.qualifiedName === 'objc:category:LocalHost:Tracing',
);
const sdkCategory = categoryResult.graph.nodes.find(
(node) => node.properties.qualifiedName === 'objc:category:UIView:Tracing',
);
expect(localHost).toBeDefined();
expect(localCategory).toBeDefined();
expect(sdkCategory).toBeDefined();
if (localHost === undefined || localCategory === undefined || sdkCategory === undefined) {
throw new Error('Missing category host fixture nodes');
}
expect(
categoryResult.graph.relationships.some(
(relationship) =>
relationship.type === 'MEMBER_OF' &&
relationship.sourceId === localCategory.id &&
relationship.targetId === localHost.id,
),
).toBe(true);
expect(
categoryResult.graph.relationships.some(
(relationship) =>
relationship.type === 'MEMBER_OF' && relationship.sourceId === sdkCategory.id,
),
).toBe(false);
} finally {
fs.rmSync(categoryRepo, { recursive: true, force: true });
}
});
});
describe('Objective-C provider persisted index behavior', () => {
it('surfaces query/context semantics and keeps incremental results aligned with force rebuild', async () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-provider-index-'));
try {
writeObjectiveCRepo(repoRoot);
git(repoRoot, 'git init');
gitCommitAll(repoRoot, 'initial Objective-C fixture');
await analyzeObjectiveCRepo(repoRoot);
const initialSurface = await readPersistedObjectiveCSurface(repoRoot);
const reopenedInitialSurface = await readPersistedObjectiveCSurface(repoRoot);
expect(reopenedInitialSurface).toEqual(initialSurface);
expect(initialSurface).toMatchObject({
classContext: {
status: 'found',
symbol: {
uid: 'Class:objc:class:SYModuleCaller',
kind: 'Class',
filePath: 'SYModuleCaller.h',
},
incoming: {
declares: expect.arrayContaining([
expect.objectContaining({
uid: expect.stringContaining(
'CodeElement:objc:implementation:objc:class:SYModuleCaller:SYModuleCaller.m:',
),
filePath: 'SYModuleCaller.m',
}),
]),
imports: expect.arrayContaining([
expect.objectContaining({
uid: 'File:SYModuleCaller.m',
filePath: 'SYModuleCaller.m',
}),
]),
member_of: expect.arrayContaining([
expect.objectContaining({
uid: 'Category:objc:category:SYModuleCaller:Tracing',
}),
]),
},
},
categoryContext: {
outgoing: {
member_of: expect.arrayContaining([
expect.objectContaining({ uid: 'Class:objc:class:SYModuleCaller' }),
]),
},
},
runProtocolContext: {
outgoing: {
uses: expect.arrayContaining([
expect.objectContaining({
uid: expect.stringContaining('CodeElement:objc:protocol-candidates:'),
}),
]),
},
},
candidateEvidenceContext: {
outgoing: {
uses: expect.arrayContaining([
expect.objectContaining({
uid: 'Method:objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
}),
]),
},
},
queryDefinitions: expect.arrayContaining([
expect.objectContaining({ uid: 'Class:objc:class:SYModuleCaller' }),
expect.objectContaining({
uid: expect.stringMatching(/^Method:objc:method:objc:class:SYModuleCaller:/),
}),
]),
protocolAndCategoryResult: expect.objectContaining({
markdown: expect.stringContaining('Protocol:objc:protocol:SYModuleRunnable'),
}),
categoryHostResult: expect.objectContaining({
markdown: expect.stringContaining('Category:objc:category:SYModuleCaller:Tracing'),
}),
protocolCandidateResult: expect.objectContaining({
markdown: expect.stringContaining(
'objc-protocol-candidate: SYModuleRunnable runTask:completion:',
),
}),
unresolvedReasonResult: expect.objectContaining({
markdown: expect.stringContaining('objc-message: unresolved: id receiver is dynamic'),
}),
});
writeObjectiveCRepo(repoRoot, HEADER_V2, IMPL_V2);
gitCommitAll(repoRoot, 'change Objective-C declarations and implementations');
const incrementalLogs = await analyzeObjectiveCRepo(repoRoot);
expect(incrementalLogs).toContainEqual(expect.stringContaining('Incremental: changed='));
const incrementalSurface = await readPersistedObjectiveCSurface(repoRoot);
await analyzeObjectiveCRepo(repoRoot, { force: true });
const forceSurface = await readPersistedObjectiveCSurface(repoRoot);
expect(incrementalSurface).toEqual(forceSurface);
expect(forceSurface).toMatchObject({
classContext: {
outgoing: {
has_method: expect.arrayContaining([
expect.objectContaining({
uid: 'Method:objc:method:objc:category:SYModuleCaller:Tracing:-:traceDetail:level:',
}),
]),
},
},
runTaskContext: {
outgoing: {
calls: expect.arrayContaining([
expect.objectContaining({
uid: 'Method:objc:method:objc:category:SYModuleCaller:Tracing:-:traceDetail:level:',
}),
]),
},
},
});
} finally {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
}, 180000);
});

View file

@ -34,6 +34,7 @@ const CALLABLE_FLOW_PROVIDER_COVERAGE = {
[SupportedLanguages.Vue]: 'matrix',
[SupportedLanguages.Cobol]: 'matrix',
[SupportedLanguages.Zig]: 'matrix',
[SupportedLanguages.ObjectiveC]: 'dedicated',
} as const satisfies Record<SupportedLanguages, 'matrix' | 'dedicated'>;
const PROVIDER_FLOW_CASES = [

View file

@ -6,6 +6,13 @@ import {
type AnalysisFeatureDescriptor,
} from '../../src/core/analysis-features.js';
import { ANALYSIS_FEATURES } from '../../src/core/analysis-feature-registry.js';
import { OBJECTIVE_C_PROVIDER_FEATURE } from '../../src/core/ingestion/languages/objective-c/analysis-features.js';
import {
OBJECTIVE_C_GRAMMAR_PACKAGE,
OBJECTIVE_C_GRAMMAR_VERSION,
OBJECTIVE_C_PROVIDER_VERSION,
} from '../../src/core/ingestion/languages/objective-c/facts.js';
describe('analysis feature versions', () => {
it('separates the global Class schema capability from JVM-only Bean evidence', () => {
@ -88,6 +95,32 @@ describe('analysis feature versions', () => {
]);
});
it('stamps Objective-C provider and grammar versions for semantic rebuilds', () => {
const expectedId =
`objective-c.provider-${OBJECTIVE_C_PROVIDER_VERSION}.` +
`${OBJECTIVE_C_GRAMMAR_PACKAGE}-${OBJECTIVE_C_GRAMMAR_VERSION}`;
expect(OBJECTIVE_C_PROVIDER_FEATURE.id).toBe(expectedId);
const objcFeatures = resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, [
'Sources/SYModuleCaller.m',
'Sources/SYModuleCaller.mm',
'Headers/SYModuleCaller.h',
]);
expect(objcFeatures).toMatchObject({
[OBJECTIVE_C_PROVIDER_FEATURE.id]: OBJECTIVE_C_PROVIDER_FEATURE.version,
});
expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['include/plain.hpp'])).not.toHaveProperty(
OBJECTIVE_C_PROVIDER_FEATURE.id,
);
expect(
findAnalysisFeatureMismatches(
{ [OBJECTIVE_C_PROVIDER_FEATURE.id]: OBJECTIVE_C_PROVIDER_FEATURE.version - 1 },
{ [OBJECTIVE_C_PROVIDER_FEATURE.id]: OBJECTIVE_C_PROVIDER_FEATURE.version },
),
).toEqual([`version:${OBJECTIVE_C_PROVIDER_FEATURE.id}`]);
});
it('rejects invalid or duplicate descriptors', () => {
const invalid: AnalysisFeatureDescriptor = {
id: 'invalid',

View file

@ -4,22 +4,24 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { characterChunk } from '../../src/core/embeddings/character-chunk.js';
const { createParserForLanguage } = vi.hoisted(() => ({
const { createParserForLanguage, resolveLanguageKey } = vi.hoisted(() => ({
createParserForLanguage: vi.fn(),
resolveLanguageKey: vi.fn((language: string, filePath?: string) =>
language === 'typescript' && filePath?.endsWith('.tsx') ? 'typescript:tsx' : language,
),
}));
const { getLanguageFromFilename } = vi.hoisted(() => ({
getLanguageFromFilename: vi.fn((filePath: string) =>
filePath.endsWith('.rs') ? 'rust' : 'typescript',
),
getLanguageFromFilename: vi.fn((filePath: string) => {
if (filePath.endsWith('.m') || filePath.endsWith('.mm')) return 'objective-c';
return filePath.endsWith('.rs') ? 'rust' : 'typescript';
}),
}));
vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({
createParserForLanguage,
isLanguageAvailable: vi.fn().mockReturnValue(true),
resolveLanguageKey: vi.fn((language: string, filePath?: string) =>
language === 'typescript' && filePath?.endsWith('.tsx') ? 'typescript:tsx' : language,
),
resolveLanguageKey,
}));
// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply
@ -120,6 +122,28 @@ const makeDeclarationTree = (
};
};
const makeObjectiveCDeclarationTree = (
nodeType: 'protocol_declaration' | 'class_interface',
content: string,
memberTexts: string[],
) => {
const headerName = nodeType === 'protocol_declaration' ? 'Worker' : 'Worker (Tracing)';
const headerNode = makeFakeNode(
'identifier',
content.indexOf(headerName),
content.indexOf(headerName) + headerName.length,
);
let searchFrom = 0;
const memberNodes = memberTexts.map((text) => {
const startIndex = content.indexOf(text, searchFrom);
if (startIndex < 0) throw new Error(`Unable to locate member text: ${text}`);
searchFrom = startIndex + text.length;
return makeFakeNode('method_declaration', startIndex, startIndex + text.length);
});
const declNode = makeFakeNode(nodeType, 0, content.length, [headerNode, ...memberNodes]);
return { rootNode: makeFakeNode('program', 0, content.length, [declNode]) };
};
describe('characterChunk', () => {
it('returns single chunk when content fits', () => {
const result = characterChunk('short content', 1, 5, 1200, 120);
@ -177,9 +201,14 @@ describe('characterChunk', () => {
describe('chunkNode', () => {
beforeEach(() => {
createParserForLanguage.mockReset();
getLanguageFromFilename.mockImplementation((filePath: string) =>
filePath.endsWith('.rs') ? 'rust' : 'typescript',
resolveLanguageKey.mockReset();
resolveLanguageKey.mockImplementation(
(language: string, filePath?: string) => `${language}:${filePath ?? ''}`,
);
getLanguageFromFilename.mockImplementation((filePath: string) => {
if (filePath.endsWith('.m') || filePath.endsWith('.mm')) return 'objective-c';
return filePath.endsWith('.rs') ? 'rust' : 'typescript';
});
});
it('returns single chunk for short content', async () => {
@ -279,6 +308,183 @@ describe('chunkNode', () => {
expect(result[0].startLine).toBe(40);
});
it.each([
{
label: 'Protocol',
nodeType: 'protocol_declaration' as const,
filePath: 'Worker.m',
content: [
'@protocol Worker',
'- (void)startWithConfiguration:(id)configuration;',
'- (void)stopWithCompletion:(id)completion;',
'- (void)reloadWithOptions:(id)options;',
'@end',
].join('\n'),
},
{
label: 'Category',
nodeType: 'class_interface' as const,
filePath: 'Worker.mm',
content: [
'@interface Worker (Tracing)',
'- (void)startWithConfiguration:(id)configuration;',
'- (void)stopWithCompletion:(id)completion;',
'- (void)reloadWithOptions:(id)options;',
'@end',
].join('\n'),
},
])(
'chunks Objective-C $label declarations at member boundaries',
async ({ label, nodeType, filePath, content }) => {
const members = [
'- (void)startWithConfiguration:(id)configuration;',
'- (void)stopWithCompletion:(id)completion;',
'- (void)reloadWithOptions:(id)options;',
];
createParserForLanguage.mockResolvedValue({
parse: vi.fn().mockReturnValue(makeObjectiveCDeclarationTree(nodeType, content, members)),
});
const result = await chunkNode(label, content, filePath, 1, 5, 90, 0);
expect(result).toHaveLength(2);
expect(result[0].text).toContain(members[0]);
expect(result.slice(1).every((chunk) => chunk.text.startsWith('- (void)'))).toBe(true);
expect(createParserForLanguage).toHaveBeenCalledWith('objective-c', filePath);
},
);
it('expands Objective-C protocol optional and required sections', async () => {
const content = [
'@protocol P',
'@optional',
'- (void)first;',
'- (void)second;',
'@required',
'- (void)third;',
'- (void)fourth;',
'@end',
].join('\n');
const members = ['- (void)first;', '- (void)second;', '- (void)third;', '- (void)fourth;'];
let searchFrom = 0;
const methodNodes = members.map((text) => {
const startIndex = content.indexOf(text, searchFrom);
searchFrom = startIndex + text.length;
return makeFakeNode('method_declaration', startIndex, startIndex + text.length);
});
const optionalStart = content.indexOf('@optional');
const requiredStart = content.indexOf('@required');
const optional = makeFakeNode(
'qualified_protocol_interface_declaration',
optionalStart,
methodNodes[1].endIndex,
methodNodes.slice(0, 2),
);
const required = makeFakeNode(
'qualified_protocol_interface_declaration',
requiredStart,
methodNodes[3].endIndex,
methodNodes.slice(2),
);
const header = makeFakeNode('identifier', content.indexOf('P'), content.indexOf('P') + 1);
const declaration = makeFakeNode('protocol_declaration', 0, content.length, [
header,
optional,
required,
]);
createParserForLanguage.mockResolvedValue({
parse: vi.fn().mockReturnValue({
rootNode: makeFakeNode('program', 0, content.length, [declaration]),
}),
});
const result = await chunkNode('Protocol', content, 'ProtocolSections.m', 1, 8, 36, 0);
const combined = result.map((chunk) => chunk.text).join('\n');
const requiredChunk = result.find((chunk) => chunk.text.includes(members[2]));
expect(result.length).toBeGreaterThan(1);
for (const member of members) expect(combined).toContain(member);
expect(
result.some((chunk) => chunk.text.includes(members[0]) && chunk.text.includes(members[1])),
).toBe(false);
expect(requiredChunk?.text).toContain('@required');
expect(requiredChunk?.text).not.toContain(members[1]);
expect(createParserForLanguage).toHaveBeenCalledWith('objective-c', 'ProtocolSections.m');
});
it('expands Objective-C instance variables before chunking a class declaration', async () => {
const content = [
'@interface Worker {',
' id _first;',
' id _second;',
'}',
'- (void)run;',
'@end',
].join('\n');
const firstIvar = 'id _first;';
const secondIvar = 'id _second;';
const method = '- (void)run;';
const firstIvarStart = content.indexOf(firstIvar);
const secondIvarStart = content.indexOf(secondIvar);
const methodStart = content.indexOf(method);
const instanceVariables = makeFakeNode(
'instance_variables',
content.indexOf('{'),
content.indexOf('}') + 1,
[
makeFakeNode('field_definition', firstIvarStart, firstIvarStart + firstIvar.length),
makeFakeNode('field_definition', secondIvarStart, secondIvarStart + secondIvar.length),
],
);
const declaration = makeFakeNode('class_interface', 0, content.length, [
makeFakeNode('identifier', content.indexOf('Worker'), content.indexOf('Worker') + 'Worker'.length),
instanceVariables,
makeFakeNode('method_declaration', methodStart, methodStart + method.length),
]);
createParserForLanguage.mockResolvedValue({
parse: vi.fn().mockReturnValue({
rootNode: makeFakeNode('program', 0, content.length, [declaration]),
}),
});
const result = await chunkNode('Class', content, 'Worker.m', 1, 6, 48, 0);
const combined = result.map((chunk) => chunk.text).join('\n');
expect(result.length).toBeGreaterThan(1);
expect(combined).toContain(firstIvar);
expect(combined).toContain(secondIvar);
expect(combined).toContain(method);
expect(result.some((chunk) => chunk.text.includes(firstIvar) && chunk.text.includes(secondIvar))).toBe(
true,
);
});
it('keeps Objective-C protocol inheritance in the declaration prefix', async () => {
const content = ['@protocol Worker <Runnable, Observable>', '- (void)run;', '@end'].join('\n');
const protocolNameStart = content.indexOf('Worker');
const inheritanceStart = content.indexOf('<Runnable, Observable>');
const methodStart = content.indexOf('- (void)run;');
const declaration = makeFakeNode('protocol_declaration', 0, content.length, [
makeFakeNode('identifier', protocolNameStart, protocolNameStart + 'Worker'.length),
makeFakeNode(
'protocol_reference_list',
inheritanceStart,
inheritanceStart + '<Runnable, Observable>'.length,
),
makeFakeNode('method_declaration', methodStart, methodStart + '- (void)run;'.length),
]);
createParserForLanguage.mockResolvedValue({
parse: vi.fn().mockReturnValue({
rootNode: makeFakeNode('program', 0, content.length, [declaration]),
}),
});
const result = await chunkNode('Protocol', content, 'Worker.m', 1, 3, 50, 0);
expect(result[0].text).toContain('- (void)');
expect(result[0].text).not.toBe('@protocol Worker <Runnable, Observable>');
});
it('splits a function into multiple AST-aware chunks using snippet offsets', async () => {
const content = [
'function example() {',

View file

@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SupportedLanguages } from 'gitnexus-shared';
import { classifyContentLanguages } from '../../src/core/ingestion/content-language-classification.js';
describe('content language classification', () => {
let repoDir = '';
afterEach(async () => {
if (repoDir) await fs.rm(repoDir, { recursive: true, force: true });
});
it('retains only reusable language decisions and skips unreadable files', async () => {
repoDir = await fs.mkdtemp(path.join(os.tmpdir(), 'content-language-classification-'));
await fs.writeFile(
path.join(repoDir, 'ObjectiveC.h'),
'@interface ObjectiveC : NSObject\n@end\n',
);
await fs.writeFile(
path.join(repoDir, 'CoreFoundation.h'),
'#import <CoreFoundation/CoreFoundation.h>\nclass NativeHeader {};\n',
);
const classifications = await classifyContentLanguages(repoDir, [
'ObjectiveC.h',
'CoreFoundation.h',
'missing.h',
]);
expect([...classifications.entries()]).toEqual([
['ObjectiveC.h', SupportedLanguages.ObjectiveC],
['CoreFoundation.h', SupportedLanguages.CPlusPlus],
]);
expect(classifications.has('missing.h')).toBe(false);
expect(classifications.get('ObjectiveC.h')).not.toContain('@interface');
});
});

View file

@ -7,12 +7,33 @@ import {
} from '../../src/core/embeddings/embedding-pipeline.js';
import { generateEmbeddingText } from '../../src/core/embeddings/text-generator.js';
import type { EmbeddableNode, EmbeddingProgress } from '../../src/core/embeddings/types.js';
import { DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS } from '../../src/core/embeddings/types.js';
import {
DEFAULT_EMBEDDING_CONFIG,
EMBEDDABLE_LABELS,
LABEL_CATEGORY,
LABEL_PROTOCOL,
LABELS_WITH_EXPORTED,
STRUCTURAL_LABELS,
} from '../../src/core/embeddings/types.js';
import { STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js';
const CLASS_CHUNK_SIZE = 90;
const CLASS_OVERLAP = 10;
describe('embedding schema column contracts', () => {
it('does not query Objective-C protocol/category tables for an isExported column', () => {
expect(LABELS_WITH_EXPORTED.has(LABEL_PROTOCOL)).toBe(false);
expect(LABELS_WITH_EXPORTED.has(LABEL_CATEGORY)).toBe(false);
});
it('keeps Objective-C protocol/category declaration chunking without unsupported structural extraction', () => {
expect(STRUCTURAL_LABELS.has(LABEL_PROTOCOL)).toBe(false);
expect(STRUCTURAL_LABELS.has(LABEL_CATEGORY)).toBe(false);
expect(EMBEDDABLE_LABELS).toContain(LABEL_PROTOCOL);
expect(EMBEDDABLE_LABELS).toContain(LABEL_CATEGORY);
});
});
// ────────────────────────────────────────────────────────────────────────────
// resolveEmbeddingInstallPolicy (offline-first, #1153)
// ────────────────────────────────────────────────────────────────────────────

View file

@ -73,11 +73,12 @@ describe('COMPATIBLE_ABI gate', () => {
});
describe('GRAMMARS registry', () => {
it('covers all six grammars (swift/kotlin/zig npm, dart/proto github, c npm)', () => {
it('covers all seven vendored grammars, including Objective-C and Zig', () => {
expect(Object.keys(mod.GRAMMARS).sort()).toEqual([
'c',
'dart',
'kotlin',
'objc',
'proto',
'swift',
'zig',
@ -87,9 +88,11 @@ describe('GRAMMARS registry', () => {
expect(mod.GRAMMARS.zig.npm).toBe('@tree-sitter-grammars/tree-sitter-zig');
});
it('marks c and kotlin report-only (holds); swift/dart/proto/zig are auto-updatable', () => {
it('marks c, kotlin, and objc report-only; swift/dart/proto/zig are auto-updatable', () => {
expect(mod.GRAMMARS.c.npm).toBe('tree-sitter-c');
expect(mod.GRAMMARS.c.hold).toBeTruthy(); // ABI-pinned: detected/reported, never auto-applied
expect(mod.GRAMMARS.objc.npm).toBe('tree-sitter-objc');
expect(mod.GRAMMARS.objc.hold).toBeTruthy();
// kotlin is pinned to an unreleased fwcd main commit for `fun interface`
// support (#169); npm latest (0.3.8) lacks it, so the strict-inequality
// isNewer would auto-revert the pin without this hold.

View file

@ -260,12 +260,14 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 92 -> 93 for #3161 (Zig static gating): call captures inside a
// comptime-false branch gain the `@reference.static-gated` marker, a
// parse-time fact a warm cache from an earlier head would replay without.
it('pins SCHEMA_BUMP to 93 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(93);
// Moved 94 -> 95 for #3179: Objective-C framework-import-only header
// classification changed parse-worker output for the same file content.
it('pins SCHEMA_BUMP to 95 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(95);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
for (const taken of [
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -26,6 +26,7 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', async (importOriginal) =>
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { SupportedLanguages } from 'gitnexus-shared';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js';
import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js';
@ -63,6 +64,21 @@ describe('native parser availability — unavailable language is skipped, not cr
);
};
const runWithObjectiveCHeader = () => {
const rel = 'App.h';
fs.writeFileSync(path.join(repoDir, rel), '@interface App : NSObject\n@end\n');
const scanned = [{ path: rel, size: fs.statSync(path.join(repoDir, rel)).size }];
return runChunkedParseAndResolve(
createKnowledgeGraph(),
scanned,
[rel],
1,
repoDir,
Date.now(),
() => {},
);
};
it('skips the Swift file without crashing (and without spawning a pool) when its parser is unavailable', async () => {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
// The only file is filtered out before dispatch, so the parse phase
@ -86,4 +102,14 @@ describe('native parser availability — unavailable language is skipped, not cr
);
expect(warned).toBe(true);
});
it('admits a content-classified Objective-C header when only C++ is unavailable', async () => {
vi.mocked(parserLoader.isLanguageAvailable).mockImplementation(
(language) => language === SupportedLanguages.ObjectiveC,
);
const result = await runWithObjectiveCHeader();
expect(result.usedWorkerPool).toBe(true);
});
});

View file

@ -78,9 +78,11 @@ describe('NodeLabel taxonomy coverage', () => {
expect(CALLABLE_ONLY_LABELS.has('Delegate')).toBe(true);
});
it('DISPATCH_LABELS includes all 10 routed kinds', () => {
it('DISPATCH_LABELS includes all 12 routed kinds', () => {
const expected = [
'Class',
'Protocol',
'Category',
'Struct',
'Interface',
'Enum',
@ -138,8 +140,17 @@ describe('BasicBlock taint/PDG substrate label (issue #2080)', () => {
// reference-equality assertions on the hook functions themselves.
// ---------------------------------------------------------------------------
describe('class-like behavior group — all 6 labels route to types.registerClass', () => {
const CLASS_LIKE_LABELS = ['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Trait'] as const;
describe('class-like behavior group — all 8 labels route to types.registerClass', () => {
const CLASS_LIKE_LABELS = [
'Class',
'Protocol',
'Category',
'Struct',
'Interface',
'Enum',
'Record',
'Trait',
] as const;
for (const label of CLASS_LIKE_LABELS) {
it(`${label} writes to types.registerClass`, () => {

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { preprocessObjectiveCMacroMarkers } from '../../src/core/ingestion/languages/objective-c/macro-marker-preprocess.js';
describe('preprocessObjectiveCMacroMarkers', () => {
it('elides bare file-scope markers and preserves positions', () => {
const source = [
'#define RCT_EXTERN_C_BEGIN',
'#define RCT_EXTERN_C_END',
'RCT_EXTERN_C_BEGIN',
'typedef struct RCTMethodInfo {',
' const char *const jsName;',
'} RCTMethodInfo;',
'RCT_EXTERN_C_END',
'@protocol RCTBridgeModule <NSObject>',
'- (void)run;',
'@end',
'',
].join('\r\n');
const normalized = preprocessObjectiveCMacroMarkers(source, 'RCTBridgeModule.h');
expect(normalized).toHaveLength(source.length);
expect(normalized.split('\r\n')).toHaveLength(source.split('\r\n').length);
expect(normalized).toContain('#define RCT_EXTERN_C_BEGIN');
expect(normalized).toContain('@protocol RCTBridgeModule <NSObject>');
expect(normalized).toContain(' '.repeat('RCT_EXTERN_C_BEGIN'.length));
expect(normalized).toContain(' '.repeat('RCT_EXTERN_C_END'.length));
expect(preprocessObjectiveCMacroMarkers(normalized, 'RCTBridgeModule.h')).toBe(normalized);
});
it('leaves non-marker syntax, strings, and comments untouched', () => {
const source = [
'void marker(void) {',
' RCT_EXTERN_C_BEGIN',
'}',
'RCT_EXTERN_C_END()',
'RCT_EXTERN_C_END;',
'#define RCT_EXTERN_C_END',
'#define RCT_MARKER_SEQUENCE \\',
'RCT_EXTERN_C_END',
'const char *value = "RCT_EXTERN_C_END";',
'// RCT_EXTERN_C_END',
'/*',
'RCT_EXTERN_C_END',
'*/',
'',
].join('\n');
expect(preprocessObjectiveCMacroMarkers(source, 'Example.m')).toBe(source);
});
it('does not rewrite markers inside a continued line comment', () => {
const source = [
'// The following token remains part of this comment \\',
'RCT_EXTERN_C_END',
'RCT_EXTERN_C_BEGIN',
'',
].join('\n');
const normalized = preprocessObjectiveCMacroMarkers(source, 'CommentedMarker.h');
expect(normalized).toHaveLength(source.length);
expect(normalized).toContain('RCT_EXTERN_C_END');
expect(normalized.split('\n')[2]).toBe(' '.repeat('RCT_EXTERN_C_BEGIN'.length));
});
});

View file

@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
describe('Objective-C parser-loader failure path', () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock('../../src/core/logger.js');
vi.doUnmock('../../src/core/tree-sitter/vendored-grammars.js');
});
it('reports a clean unavailable Objective-C grammar with an actionable diagnostic', async () => {
const errorLog = vi.fn();
const warnLog = vi.fn();
vi.doMock('../../src/core/logger.js', () => ({
logger: {
error: errorLog,
warn: warnLog,
},
}));
vi.doMock('../../src/core/tree-sitter/vendored-grammars.js', () => ({
requireVendoredGrammar: (name: string) => {
if (name === 'tree-sitter-objc') throw new Error('synthetic missing objc grammar');
return {};
},
}));
const { getLanguageGrammar, isGrammarRuntimeSkipped, isLanguageAvailable } =
await import('../../src/core/tree-sitter/parser-loader.js');
expect(isLanguageAvailable(SupportedLanguages.ObjectiveC)).toBe(false);
expect(isGrammarRuntimeSkipped(SupportedLanguages.ObjectiveC)).toBe(false);
expect(() => getLanguageGrammar(SupportedLanguages.ObjectiveC)).toThrow(
/Unsupported language: objective-c/,
);
expect(warnLog).not.toHaveBeenCalled();
expect(String(errorLog.mock.calls[0]?.[0] ?? '')).toMatch(
/Objective-C parsing disabled[\s\S]*tree-sitter-objc[\s\S]*synthetic missing objc grammar/,
);
});
});

View file

@ -0,0 +1,518 @@
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import {
getLanguageFromFilename,
getSyntaxLanguageFromFilename,
SupportedLanguages,
} from 'gitnexus-shared';
import { getLanguageForFileContent } from '../../src/core/ingestion/languages/index.js';
import {
classifyObjectiveCFileContent,
objectiveCProvider,
} from '../../src/core/ingestion/languages/objective-c.js';
import {
buildObjectiveCScopeCaptures,
buildObjectiveCSemanticGraph,
collectObjectiveCFacts,
objcCategoryQualifiedName,
objcClassQualifiedName,
objcFunctionQualifiedName,
objcMethodQualifiedName,
} from '../../src/core/ingestion/languages/objective-c/facts.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js';
import { objectiveCScopeResolver } from '../../src/core/ingestion/languages/objective-c/scope-resolver.js';
const FIXTURE = `#import "SYModuleCaller.h"
#include "SYModuleSupport.h"
@import Foundation;
@protocol SYModuleRunnable <NSObject>
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYBaseCaller : NSObject
- (void)loadData:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYModuleCaller : SYBaseCaller <SYModuleRunnable> {
SYBaseCaller *_base;
}
@property (nonatomic, strong) SYBaseCaller *helper;
+ (instancetype)sharedCaller;
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYModuleCaller ()
@property (nonatomic, strong) SYBaseCaller *privateHelper;
@end
@interface SYModuleCaller (Tracing)
- (void)traceEvent:(NSString *)name;
@end
@implementation SYModuleCaller
+ (instancetype)sharedCaller { return [SYModuleCaller new]; }
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion {
SYBaseCaller *typed = self.helper;
id dynamic = typed;
[self traceEvent:name];
[super loadData:name completion:completion];
[typed loadData:name completion:completion];
[dynamic loadData:name completion:completion];
}
- (void)runProtocol:(id<SYModuleRunnable>)runner {
[runner runTask:@"x" completion:^(BOOL ok) {}];
}
@end
@implementation SYModuleCaller (Tracing)
- (void)traceEvent:(NSString *)name {}
@end
static int SYModuleCompute(int value) { return value + 1; }
`;
function parseFixture() {
const parser = new Parser();
parser.setLanguage(requireVendoredGrammar('tree-sitter-objc'));
return parser.parse(FIXTURE);
}
function parseSource(source: string) {
const parser = new Parser();
parser.setLanguage(requireVendoredGrammar('tree-sitter-objc'));
return parser.parse(source);
}
describe('Objective-C provider', () => {
it('loads the vendored grammar and maps unambiguous Objective-C extensions', () => {
expect(isLanguageAvailable(SupportedLanguages.ObjectiveC)).toBe(true);
expect(getLanguageFromFilename('SYModuleCaller.m')).toBe(SupportedLanguages.ObjectiveC);
expect(getLanguageFromFilename('SYModuleCaller.mm')).toBe(SupportedLanguages.ObjectiveC);
expect(getSyntaxLanguageFromFilename('SYModuleCaller.m')).toBe('objectivec');
});
it('classifies Objective-C headers only from explicit Objective-C syntax', () => {
expect(
classifyObjectiveCFileContent(
'SYModuleCaller.h',
'@interface SYModuleCaller : NSObject\n@end',
),
).toBe(true);
expect(getLanguageForFileContent('SYModuleCaller.h', '@protocol SYModuleRunnable\n@end')).toBe(
SupportedLanguages.ObjectiveC,
);
expect(
getLanguageForFileContent('plain.h', '#ifndef PLAIN_H\nint add(int a, int b);\n#endif\n'),
).toBe(SupportedLanguages.CPlusPlus);
expect(
getLanguageForFileContent(
'core-foundation-cpp.h',
'#import <CoreFoundation/CoreFoundation.h>\nclass Widget { int value; };\n',
),
).toBe(SupportedLanguages.CPlusPlus);
expect(
classifyObjectiveCFileContent('framework.h', '#import <Foundation/Foundation.h>\n'),
).toBe(false);
expect(classifyObjectiveCFileContent('plain-cpp.h', 'class Widget { int value; };\n')).toBe(
false,
);
expect(classifyObjectiveCFileContent('forward.h', '@class Widget;\n')).toBe(true);
});
it('extracts nested C function declarators without claiming function pointers', () => {
const facts = collectObjectiveCFacts(
parseSource(`
int add(int value);
int *returnsPointer(int value);
int (*callback)(int value);
int first(void), second(void);
`),
'functions.h',
);
expect(facts.functions.map((fn) => fn.name)).toEqual(
expect.arrayContaining(['add', 'returnsPointer', 'first', 'second']),
);
expect(facts.functions.map((fn) => fn.name)).not.toContain('callback');
});
it('extracts C helper functions declared inside an Objective-C implementation', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@implementation Worker
static int helper(void) { return 1; }
@end
`),
'Worker.m',
);
expect(facts.functions).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'helper',
linkage: 'internal',
qualifiedName: objcFunctionQualifiedName('helper', 'internal', 'Worker.m'),
returnType: 'int',
parameterTypes: ['void'],
}),
]),
);
});
it('keeps internal C function identities file-local while preserving external identities', () => {
const first = collectObjectiveCFacts(
parseSource('static int helper(void) { return 1; }\nint shared(void);'),
'First.m',
);
const second = collectObjectiveCFacts(
parseSource('static int helper(void) { return 2; }\nint shared(void);'),
'Second.m',
);
const firstStatic = first.functions.find((fn) => fn.name === 'helper');
const secondStatic = second.functions.find((fn) => fn.name === 'helper');
const firstExternal = first.functions.find((fn) => fn.name === 'shared');
const secondExternal = second.functions.find((fn) => fn.name === 'shared');
expect(firstStatic?.qualifiedName).toBe(
objcFunctionQualifiedName('helper', 'internal', 'First.m'),
);
expect(secondStatic?.qualifiedName).toBe(
objcFunctionQualifiedName('helper', 'internal', 'Second.m'),
);
expect(firstStatic?.qualifiedName).not.toBe(secondStatic?.qualifiedName);
expect(firstExternal?.qualifiedName).toBe(objcFunctionQualifiedName('shared'));
expect(secondExternal?.qualifiedName).toBe(firstExternal?.qualifiedName);
});
it('collects guarded headers and protocol members in optional and required sections', () => {
const facts = collectObjectiveCFacts(
parseSource(`
#ifndef WORKER_H
#define WORKER_H
#import "Dep.h"
@interface Worker
- (void)run;
@end
#endif
@protocol P
@optional
- (void)ping;
@property Helper *optionalHelper;
@required
- (void)pong;
@end
`),
'Worker.h',
);
expect(facts.imports).toContainEqual(expect.objectContaining({ targetRaw: 'Dep.h' }));
expect(facts.containers).toContainEqual(
expect.objectContaining({ kind: 'class', name: 'Worker' }),
);
expect(facts.methods.map((method) => `${method.ownerName}:${method.selector}`)).toEqual(
expect.arrayContaining(['Worker:run', 'P:ping', 'P:pong']),
);
expect(facts.members).toContainEqual(
expect.objectContaining({ name: 'optionalHelper', declaredType: 'Helper' }),
);
});
it('uses lexical bindings at each message position and keeps bare id bindings dynamic', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@protocol P
- (void)ping;
@end
@interface First
- (void)ping;
@end
@interface Second
- (void)ping;
@end
@interface A
+ (void)ping;
@end
@interface Worker
- (void)run:(First *)value;
@end
@implementation Worker
- (void)run:(First *)value {
id<P> worker;
id A;
[value ping];
{ Second *value = nil; [value ping]; }
[value ping];
[worker ping];
[A ping];
}
@end
`),
'Worker.m',
);
const valueMessages = facts.messages.filter(
(message) => message.receiverText === 'value' && message.selector === 'ping',
);
expect(valueMessages.map((message) => message.receiverType?.name)).toEqual([
'First',
'Second',
'First',
]);
expect(facts.messages).toContainEqual(
expect.objectContaining({
receiverText: 'worker',
receiverKind: 'local',
receiverType: { kind: 'protocol', name: 'P', raw: 'id<P>' },
}),
);
expect(facts.messages).toContainEqual(
expect.objectContaining({
receiverText: 'A',
receiverKind: 'dynamic',
receiverType: { kind: 'dynamic', raw: 'id' },
}),
);
});
it('does not treat protocol-qualified parameter types as conformance', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@protocol P <NSObject>
- (void)run:(id<Q>)value;
@end
@interface Child : Base <P>
- (void)run:(id<Q>)value;
@end
`),
'protocols.h',
);
expect(facts.containers.find((container) => container.name === 'P')?.protocols).toEqual([
'NSObject',
]);
expect(facts.containers.find((container) => container.name === 'Child')?.protocols).toEqual([
'P',
]);
});
it('keeps explicit class receivers and macro receivers separate', () => {
const facts = collectObjectiveCFacts(
parseSource(`
#define RECEIVER_MACRO(x) x
@interface A
+ (void)run;
@end
@interface Base
- (void)ping;
@end
@interface Child : Base
- (void)call;
@end
@implementation Child
- (void)call {
[A run];
[self ping];
[RECEIVER_MACRO(self) ping];
}
@end
`),
'receivers.m',
);
expect(
facts.messages.map((message) => `${message.receiverKind}:${message.receiverText}`),
).toEqual(expect.arrayContaining(['class:A', 'self:self', 'dynamic:RECEIVER_MACRO(self)']));
expect(facts.unresolvedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({
receiverText: 'RECEIVER_MACRO(self)',
reason: 'macro receiver RECEIVER_MACRO is dynamic',
}),
]),
);
});
it('resolves a property declared after its caller in a class extension', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@interface LaterOwner
@end
@implementation LaterOwner
- (void)run {
[self.helper performWork];
}
@end
@interface LaterOwner (Private)
@property (nonatomic, strong) Worker *helper;
@end
`),
'LaterOwner.m',
);
expect(facts.messages).toContainEqual(
expect.objectContaining({
receiverText: 'self.helper',
selector: 'performWork',
receiverKind: 'property',
receiverType: { kind: 'class', name: 'Worker', raw: 'Worker' },
}),
);
expect(facts.unresolvedMessages).not.toContainEqual(
expect.objectContaining({ receiverText: 'self.helper' }),
);
});
it('resolves extensionless local imports to Objective-C source/header files', () => {
expect(
objectiveCScopeResolver.resolveImportTarget(
'./NestedHeader',
'src/Caller.m',
new Set(['src/NestedHeader.h']),
),
).toBe('src/NestedHeader.h');
expect(
objectiveCScopeResolver.resolveImportTarget(
'./NestedImpl',
'src/Caller.m',
new Set(['src/NestedImpl.mm']),
),
).toBe('src/NestedImpl.mm');
expect(
objectiveCScopeResolver.resolveImportTarget(
'Foundation',
'src/Caller.m',
new Set(['src/Foundation.h']),
),
).toBeNull();
});
it('keeps angle-bracket system headers out of local import resolution', () => {
const tree = parseSource('#import "Local.h"\n#import <Foundation/Foundation.h>\n');
const facts = collectObjectiveCFacts(tree, 'src/Caller.m');
const captures = buildObjectiveCScopeCaptures(facts, tree.rootNode).filter(
(capture) => capture['@import.source'] !== undefined,
);
const parsed = captures.map((capture) => objectiveCProvider.interpretImport?.(capture));
expect(parsed.map((entry) => entry?.targetRaw)).toEqual([
'./Local.h',
'<Foundation/Foundation.h>',
]);
expect(
objectiveCScopeResolver.resolveImportTarget(
parsed[1]?.targetRaw ?? '',
'src/Caller.m',
new Set(['src/Foundation/Foundation.h']),
),
).toBeNull();
});
it('extracts first-version Objective-C semantic facts and unresolved evidence', () => {
const facts = collectObjectiveCFacts(parseFixture(), 'SYModuleCaller.m');
expect(facts.containers.map((c) => `${c.kind}:${c.name}`)).toEqual(
expect.arrayContaining([
'protocol:SYModuleRunnable',
'class:SYBaseCaller',
'class:SYModuleCaller',
'extension:SYModuleCaller ()',
'category:SYModuleCaller (Tracing)',
]),
);
expect(
facts.containers.find((c) => c.name === 'SYModuleCaller' && c.kind === 'class'),
).toMatchObject({
superclass: 'SYBaseCaller',
protocols: ['SYModuleRunnable'],
});
expect(
facts.methods.map((m) => ({
kind: m.methodKind,
selector: m.selector,
owner: m.ownerQualifiedName,
})),
).toEqual(
expect.arrayContaining([
{
kind: '-',
selector: 'runTask:completion:',
owner: objcClassQualifiedName('SYModuleCaller'),
},
{
kind: '+',
selector: 'sharedCaller',
owner: objcClassQualifiedName('SYModuleCaller'),
},
{
kind: '-',
selector: 'traceEvent:',
owner: objcCategoryQualifiedName('SYModuleCaller', 'Tracing'),
},
{
kind: '-',
selector: 'runTask:completion:',
owner: 'objc:protocol:SYModuleRunnable',
},
]),
);
expect(facts.members.map((m) => `${m.kind}:${m.name}:${m.declaredType ?? ''}`)).toEqual(
expect.arrayContaining(['property:helper:SYBaseCaller', 'ivar:_base:SYBaseCaller']),
);
expect(facts.functions.map((fn) => fn.name)).toContain('SYModuleCompute');
expect(facts.imports.map((imp) => `${imp.kind}:${imp.targetRaw}`)).toEqual(
expect.arrayContaining([
'import:SYModuleCaller.h',
'include:SYModuleSupport.h',
'module:Foundation',
]),
);
expect(
facts.messages.map((msg) => `${msg.receiverKind}:${msg.receiverText}:${msg.selector}`),
).toEqual(
expect.arrayContaining([
'self:self:traceEvent:',
'super:super:loadData:completion:',
'local:typed:loadData:completion:',
'dynamic:dynamic:loadData:completion:',
'local:runner:runTask:completion:',
]),
);
expect(facts.unresolvedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({
receiverText: 'dynamic',
selector: 'loadData:completion:',
reason: 'id receiver is dynamic',
}),
]),
);
});
it('uses owner, selector, and method kind in stable method identities', () => {
const facts = collectObjectiveCFacts(parseFixture(), 'SYModuleCaller.m');
const graph = buildObjectiveCSemanticGraph(facts);
const methodIds = new Set(
graph.nodes.filter((node) => node.label === 'Method').map((node) => node.id),
);
expect(methodIds).toContain(
`Method:${objcMethodQualifiedName(objcClassQualifiedName('SYModuleCaller'), '-', 'runTask:completion:')}`,
);
expect(methodIds).toContain(
`Method:${objcMethodQualifiedName(objcClassQualifiedName('SYModuleCaller'), '+', 'sharedCaller')}`,
);
expect(methodIds).toContain(
`Method:${objcMethodQualifiedName(
objcCategoryQualifiedName('SYModuleCaller', 'Tracing'),
'-',
'traceEvent:',
)}`,
);
expect(methodIds.size).toBeGreaterThan(4);
});
});

View file

@ -73,6 +73,12 @@ const SMOKE_CASES: Record<string, SmokeCase> = {
snippet: 'int main() { return 0; }\n',
rootType: 'translation_unit',
},
[SupportedLanguages.ObjectiveC]: {
language: SupportedLanguages.ObjectiveC,
snippet:
'@interface ObjcSmoke\n- (void)run;\n@end\n@implementation ObjcSmoke\n- (void)run {}\n@end\n',
rootType: 'translation_unit',
},
[SupportedLanguages.Go]: {
language: SupportedLanguages.Go,
snippet: 'package main\nfunc main() {}\n',
@ -140,6 +146,10 @@ describe('parser-loader ABI load-smoke (#1922)', () => {
expect(sources.some((s) => s.key === SupportedLanguages.Swift)).toBe(true);
});
it('includes Objective-C in the smoke matrix', () => {
expect(sources.some((s) => s.key === SupportedLanguages.ObjectiveC)).toBe(true);
});
for (const { key, optional } of sources) {
const testCase = SMOKE_CASES[key];
if (!testCase) continue; // covered by the "every entry" assertion above

View file

@ -48,6 +48,20 @@ const FIXTURES: Partial<Record<SupportedLanguages, { filePath: string; source: s
filePath: 'meters.dart',
source: ['extension type Meters(int value) {', ' int get raw => value;', '}', ''].join('\n'),
},
[SupportedLanguages.ObjectiveC]: {
filePath: 'Marker.m',
source: [
'RCT_EXTERN_C_BEGIN',
'typedef struct {',
' int value;',
'} GNMarker;',
'RCT_EXTERN_C_END',
'@protocol GNMarkerProtocol',
'- (void)run;',
'@end',
'',
].join('\n'),
},
};
const languagesWithHook = Object.entries(providers)

View file

@ -75,8 +75,8 @@ describe('LadybugDB Schema', () => {
});
it('has expected total count', () => {
// 9 core + 19 multi-language + Route + Tool + Destination + BasicBlock = 33
expect(NODE_TABLES).toHaveLength(33);
// 9 core + 21 multi-language + Route + Tool + Destination + BasicBlock = 35
expect(NODE_TABLES).toHaveLength(35);
});
});
@ -308,8 +308,8 @@ describe('LadybugDB Schema', () => {
describe('schema query ordering', () => {
it('NODE_SCHEMA_QUERIES has correct count', () => {
// 31 + Destination + BasicBlock = 33
expect(NODE_SCHEMA_QUERIES).toHaveLength(33);
// 33 + Protocol + Category = 35
expect(NODE_SCHEMA_QUERIES).toHaveLength(35);
});
it('REL_SCHEMA_QUERIES has one relation table', () => {
@ -317,8 +317,8 @@ describe('LadybugDB Schema', () => {
});
it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => {
// 33 node + 1 rel + 1 embedding = 35
expect(SCHEMA_QUERIES).toHaveLength(35);
// 35 node + 1 rel + 1 embedding = 37
expect(SCHEMA_QUERIES).toHaveLength(37);
});
it('node schemas come before relation schemas in SCHEMA_QUERIES', () => {

View file

@ -21,6 +21,7 @@ import type {
PhaseResult,
PipelineContext,
} from '../../src/core/ingestion/pipeline-phases/types.js';
import { SupportedLanguages } from 'gitnexus-shared';
const phaseResult = <T>(phaseName: string, output: T): PhaseResult<T> => ({
phaseName,
@ -78,6 +79,7 @@ describe('scopeResolutionPhase failure reconciliation', () => {
const parse = {
model: createSemanticModel(),
parsedFiles: [],
contentLanguageByPath: new Map(),
scopeExtractionFailures: ['broken.py'],
} as unknown as ParseOutput;
const deps = new Map<string, PhaseResult<unknown>>([
@ -91,4 +93,65 @@ describe('scopeResolutionPhase failure reconciliation', () => {
expect(runScopeResolutionMock).toHaveBeenCalledOnce();
expect(output.scopeExtractionFailures).toEqual(['broken.py']);
});
it('uses the parse phase content classification for ambiguous headers', async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-phase-header-language-'));
fs.writeFileSync(
path.join(repoDir, 'ObjectiveC.h'),
'@interface ObjectiveC : NSObject\n@end\n',
);
runScopeResolutionMock.mockReturnValue({
filesProcessed: 1,
filesSkipped: 0,
scopeExtractionFailedPaths: [],
importsEmitted: 0,
resolve: { unresolved: 0 },
referenceEdgesEmitted: 0,
referenceSkipped: 0,
propertyDispatchSkippedKeys: 0,
importedValueRefEdges: 0,
uniqueNamePropertyEdges: 0,
uniqueNamePropertyNarrowed: 0,
uniqueNamePropertyAmbiguous: 0,
uniqueNamePropertyAmbiguousNames: [],
uniqueNamePropertyCrossLanguage: 0,
uniqueNamePropertyCrossLanguageNames: [],
resolutionOutcomes: [],
undecidedSatisfaction: [],
functionSummaries: [],
callSummaries: [],
});
const ctx: PipelineContext = {
repoPath: repoDir,
graph: createKnowledgeGraph(),
onProgress: () => {},
pipelineStart: Date.now(),
};
const structure: StructureOutput = {
scannedFiles: [{ path: 'ObjectiveC.h', size: 37 }],
allPaths: ['ObjectiveC.h'],
allPathSet: new Set(['ObjectiveC.h']),
totalFiles: 1,
};
const parse = {
model: createSemanticModel(),
parsedFiles: [],
contentLanguageByPath: new Map([['ObjectiveC.h', SupportedLanguages.ObjectiveC]]),
scopeExtractionFailures: [],
} as unknown as ParseOutput;
const deps = new Map<string, PhaseResult<unknown>>([
['structure', phaseResult('structure', structure)],
['parse', phaseResult('parse', parse)],
['crossFile', phaseResult('crossFile', {})],
]);
await scopeResolutionPhase.execute(ctx, deps);
expect(runScopeResolutionMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ language: SupportedLanguages.ObjectiveC }),
);
});
});

View file

@ -378,6 +378,17 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
reachesDecoy: 'std.zig',
},
],
[
SupportedLanguages.ObjectiveC,
{
files: ['Headers/Foundation.h', 'Headers/Widget.h', 'Sources/main.m'],
fromFile: 'Sources/main.m',
resolutionConfig: undefined,
external: 'Foundation',
decoy: 'Headers/Foundation.h',
reachesDecoy: 'Foundation.h',
},
],
]);
/**

View file

@ -547,6 +547,19 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.ObjectiveC,
{
files: ['Headers/Widget.h', 'Sources/main.m'],
fromFile: 'Sources/main.m',
resolutionConfig: undefined,
missTarget: (i) => `ghost${i}.h`,
hitTarget: 'Widget.h',
parsedImport: IGNORES_CONTEXT,
minimumScans: 1,
minimumParsedFileReads: 0,
},
],
]);
/**

View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2023 Amaan Qureshi <amaanq12@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,20 @@
# tree-sitter-objc
[![CI][ci]](https://github.com/tree-sitter-grammars/tree-sitter-objc/actions/workflows/ci.yml)
[![discord][discord]](https://discord.gg/w7nTvsVJhm)
[![matrix][matrix]](https://matrix.to/#/#tree-sitter-chat:matrix.org)
[![crates][crates]](https://crates.io/crates/tree-sitter-objc)
[![npm][npm]](https://www.npmjs.com/package/tree-sitter-objc)
[![pypi][pypi]](https://pypi.org/project/tree-sitter-objc)
[Objective C](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjectiveC/Introduction/introObjectiveC.html)
grammar for [tree-sitter](https://tree-sitter.github.io)
[ci]: https://img.shields.io/github/actions/workflow/status/tree-sitter-grammars/tree-sitter-objc/ci.yml?logo=github&label=CI
[discord]: https://img.shields.io/discord/1063097320771698699?logo=discord&label=discord
[matrix]: https://img.shields.io/matrix/tree-sitter-chat%3Amatrix.org?logo=matrix&label=matrix
[npm]: https://img.shields.io/npm/v/tree-sitter-objc?logo=npm
[crates]: https://img.shields.io/crates/v/tree-sitter-objc?logo=rust
[pypi]: https://img.shields.io/pypi/v/tree-sitter-objc?logo=pypi&logoColor=ffd242

View file

@ -0,0 +1,35 @@
{
"targets": [
{
"target_name": "tree_sitter_objc_binding",
"dependencies": [
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
],
"include_dirs": [
"src",
],
"sources": [
"bindings/node/binding.cc",
"src/parser.c",
],
"variables": {
"has_scanner": "<!(node -p \"fs.existsSync('src/scanner.c')\")"
},
"conditions": [
["has_scanner=='true'", {
"sources+": ["src/scanner.c"],
}],
["OS!='win'", {
"cflags_c": [
"-std=c11",
],
}, { # OS == "win"
"cflags_c": [
"/std:c11",
"/utf-8",
],
}],
],
}
]
}

View file

@ -0,0 +1,19 @@
#include <napi.h>
typedef struct TSLanguage TSLanguage;
extern "C" TSLanguage *tree_sitter_objc();
// "tree-sitter", "language" hashed with BLAKE2
const napi_type_tag LANGUAGE_TYPE_TAG = {
0x8AF2E5212AD58ABF, 0xD5006CAD83ABBA16
};
Napi::Object Init(Napi::Env env, Napi::Object exports) {
auto language = Napi::External<TSLanguage>::New(env, tree_sitter_objc());
language.TypeTag(&LANGUAGE_TYPE_TAG);
exports["language"] = language;
return exports;
}
NODE_API_MODULE(tree_sitter_objc_binding, Init)

View file

@ -0,0 +1,9 @@
const assert = require("node:assert");
const { test } = require("node:test");
const Parser = require("tree-sitter");
test("can load grammar", () => {
const parser = new Parser();
assert.doesNotThrow(() => parser.setLanguage(require(".")));
});

View file

@ -0,0 +1,27 @@
type BaseNode = {
type: string;
named: boolean;
};
type ChildNode = {
multiple: boolean;
required: boolean;
types: BaseNode[];
};
type NodeInfo =
| (BaseNode & {
subtypes: BaseNode[];
})
| (BaseNode & {
fields: { [name: string]: ChildNode };
children: ChildNode[];
});
type Language = {
language: unknown;
nodeTypeInfo: NodeInfo[];
};
declare const language: Language;
export = language;

View file

@ -0,0 +1,11 @@
const root = require("path").join(__dirname, "..", "..");
module.exports =
typeof process.versions.bun === "string"
// Support `bun build --compile` by being statically analyzable enough to find the .node file at build-time
? require(`../../prebuilds/${process.platform}-${process.arch}/tree-sitter-objc.node`)
: require("node-gyp-build")(root);
try {
module.exports.nodeTypeInfo = require("../../src/node-types.json");
} catch (_) {}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,56 @@
{
"name": "tree-sitter-objc",
"version": "3.0.2",
"description": "Objective-C grammar for tree-sitter",
"repository": "https://github.com/tree-sitter-grammars/tree-sitter-objc",
"license": "MIT",
"author": {
"name": "Amaan Qureshi",
"email": "amaanq12@gmail.com"
},
"main": "bindings/node",
"types": "bindings/node",
"keywords": [
"incremental",
"parsing",
"tree-sitter",
"objective-c",
"objc"
],
"files": [
"grammar.js",
"tree-sitter.json",
"binding.gyp",
"prebuilds/**",
"bindings/node/*",
"queries/*",
"src/**",
"*.wasm"
],
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4",
"tree-sitter-c": "^0.23.4"
},
"devDependencies": {
"eslint": "^9.17.0",
"eslint-config-treesitter": "^1.0.2",
"prebuildify": "^6.0.1",
"tree-sitter-cli": "^0.24.5"
},
"peerDependencies": {
"tree-sitter": "^0.22.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
},
"scripts": {
"install": "node-gyp-build",
"lint": "eslint grammar.js",
"prestart": "tree-sitter build --wasm",
"start": "tree-sitter playground",
"test": "node --test bindings/node/*_test.js"
}
}

View file

@ -0,0 +1,20 @@
; inherits: c
[
(class_declaration)
(class_interface)
(class_implementation)
(protocol_declaration)
(property_declaration)
(method_declaration)
(struct_declaration)
(struct_declarator)
(try_statement)
(catch_clause)
(finally_clause)
(throw_statement)
(block_literal)
(ms_asm_block)
(dictionary_literal)
(array_literal)
] @fold

View file

@ -0,0 +1,216 @@
; inherits: c
; Preprocs
(preproc_undef
name: (_) @constant) @preproc
; Includes
(module_import "@import" @include path: (identifier) @namespace)
((preproc_include
_ @include path: (_))
(#any-of? @include "#include" "#import"))
; Type Qualifiers
[
"@optional"
"@required"
"__covariant"
"__contravariant"
(visibility_specification)
] @type.qualifier
; Storageclasses
[
"@autoreleasepool"
"@synthesize"
"@dynamic"
"volatile"
(protocol_qualifier)
] @storageclass
; Keywords
[
"@protocol"
"@interface"
"@implementation"
"@compatibility_alias"
"@property"
"@selector"
"@defs"
"availability"
"@end"
] @keyword
(class_declaration "@" @keyword "class" @keyword) ; I hate Obj-C for allowing "@ class" :)
(method_definition ["+" "-"] @keyword.function)
(method_declaration ["+" "-"] @keyword.function)
[
"__typeof__"
"__typeof"
"typeof"
"in"
] @keyword.operator
[
"@synchronized"
"oneway"
] @keyword.coroutine
; Exceptions
[
"@try"
"__try"
"@catch"
"__catch"
"@finally"
"__finally"
"@throw"
] @exception
; Variables
((identifier) @variable.builtin
(#any-of? @variable.builtin "self" "super"))
; Functions & Methods
[
"objc_bridge_related"
"@available"
"__builtin_available"
"va_arg"
"asm"
] @function.builtin
(method_definition (identifier) @method)
(method_declaration (identifier) @method)
(method_identifier (identifier)? @method ":" @method (identifier)? @method)
(message_expression method: (identifier) @method.call)
; Constructors
((message_expression method: (identifier) @constructor)
(#eq? @constructor "init"))
; Attributes
(availability_attribute_specifier
[
"CF_FORMAT_FUNCTION" "NS_AVAILABLE" "__IOS_AVAILABLE" "NS_AVAILABLE_IOS"
"API_AVAILABLE" "API_UNAVAILABLE" "API_DEPRECATED" "NS_ENUM_AVAILABLE_IOS"
"NS_DEPRECATED_IOS" "NS_ENUM_DEPRECATED_IOS" "NS_FORMAT_FUNCTION" "DEPRECATED_MSG_ATTRIBUTE"
"__deprecated_msg" "__deprecated_enum_msg" "NS_SWIFT_NAME" "NS_SWIFT_UNAVAILABLE"
"NS_EXTENSION_UNAVAILABLE_IOS" "NS_CLASS_AVAILABLE_IOS" "NS_CLASS_DEPRECATED_IOS" "__OSX_AVAILABLE_STARTING"
"NS_ROOT_CLASS" "NS_UNAVAILABLE" "NS_REQUIRES_NIL_TERMINATION" "CF_RETURNS_RETAINED"
"CF_RETURNS_NOT_RETAINED" "DEPRECATED_ATTRIBUTE" "UI_APPEARANCE_SELECTOR" "UNAVAILABLE_ATTRIBUTE"
]) @attribute
; Macros
(type_qualifier
[
"_Complex"
"_Nonnull"
"_Nullable"
"_Nullable_result"
"_Null_unspecified"
"__autoreleasing"
"__block"
"__bridge"
"__bridge_retained"
"__bridge_transfer"
"__complex"
"__kindof"
"__nonnull"
"__nullable"
"__ptrauth_objc_class_ro"
"__ptrauth_objc_isa_pointer"
"__ptrauth_objc_super_pointer"
"__strong"
"__thread"
"__unsafe_unretained"
"__unused"
"__weak"
]) @function.macro.builtin
[ "__real" "__imag" ] @function.macro.builtin
((call_expression function: (identifier) @function.macro)
(#eq? @function.macro "testassert"))
; Types
(class_declaration (identifier) @type)
(class_interface "@interface" . (identifier) @type superclass: _? @type category: _? @namespace)
(class_implementation "@implementation" . (identifier) @type superclass: _? @type category: _? @namespace)
(protocol_forward_declaration (identifier) @type) ; @interface :(
(protocol_reference_list (identifier) @type) ; ^
[
"BOOL"
"IMP"
"SEL"
"Class"
"id"
] @type.builtin
; Constants
(property_attribute (identifier) @constant "="?)
[ "__asm" "__asm__" ] @constant.macro
; Properties
(property_implementation "@synthesize" (identifier) @property)
((identifier) @property
(#has-ancestor? @property struct_declaration))
; Parameters
(method_parameter ":" @method (identifier) @parameter)
(method_parameter declarator: (identifier) @parameter)
(parameter_declaration
declarator: (function_declarator
declarator: (parenthesized_declarator
(block_pointer_declarator
declarator: (identifier) @parameter))))
"..." @parameter.builtin
; Operators
[
"^"
] @operator
; Literals
(platform) @string.special
(version_number) @text.uri @number
; Punctuation
"@" @punctuation.special
[ "<" ">" ] @punctuation.bracket

View file

@ -0,0 +1 @@
; inherits: c

Some files were not shown because too many files have changed in this diff Show more