This commit is contained in:
mengkaka 2026-08-28 13:02:51 +08:00 • committed by GitHub
commit 6ba407a950
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 714073 additions and 237 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 six 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)
@ -25,7 +27,7 @@ name: Build tree-sitter prebuilds
# 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 6 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
@ -55,7 +57,7 @@ on:
workflow_dispatch:
inputs:
grammars:
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift), or "all".'
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,objc,swift), or "all".'
required: false
type: string
default: 'all'
@ -86,7 +88,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 five grammars are now fully vendored (kotlin included).
# all six grammars are now fully vendored (kotlin and objc included).
- 'gitnexus/package.json'
# Self-test: re-run the guard (normally a no-op) when the recipe changes.
- '.github/workflows/build-tree-sitter-prebuilds.yml'
@ -153,6 +155,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.

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

@ -70,6 +70,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

@ -514,6 +514,8 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
| `GITNEXUS_STORAGE_PATH` | `<repo>/.gitnexus/` | Absolute directory for one repository index, including graph data, metadata, caches, locks, and branch indexes. The resolved path is registered so `status`, MCP, and `serve` can reopen the index later. | An external runner manages short-lived checkouts or wants indexes outside the source tree. |
| `GITNEXUS_CONTENT_RETENTION` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. | You need to reduce persisted source text while preserving graph structure. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
@ -579,6 +581,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Objective-C | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
@ -589,7 +592,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo by default (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`; `GITNEXUS_STORAGE_PATH` can redirect one index to an absolute external directory. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
<details>
<summary><strong>Architecture diagram</strong></summary>
@ -1007,7 +1010,7 @@ Built by the community — not officially maintained, but worth checking out.
## Security & Privacy
- **CLI**: everything runs locally on your machine. No network calls. Index stored in `.gitnexus/` (gitignored). Global registry at `~/.gitnexus/` stores only paths and metadata.
- **CLI**: everything runs locally on your machine. No network calls. Index stored in `.gitnexus/` by default (gitignored), or in `GITNEXUS_STORAGE_PATH` when configured. Global registry at `~/.gitnexus/` stores only paths and metadata.
- **Web**: everything runs in your browser. No code uploaded to any server. API keys stored in localStorage only.
- Open source — audit the code yourself.

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',
@ -110,6 +111,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)
*/
@ -31,6 +32,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

@ -16,7 +16,7 @@ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useAppState } from '../hooks/useAppState';
import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared';
import { NODE_COLORS } from '../lib/constants';
import { readFile, type ReadFileResult } from '../services/backend-client';
import { BackendError, readFile, type ReadFileResult } from '../services/backend-client';
import { useTranslation } from 'react-i18next';
const getSyntaxLanguage = (filePath: string | undefined): string => {
@ -205,6 +205,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const CONTEXT_LINES = 50; // lines of context above and below the symbol
const [fileResult, setFileResult] = useState<ReadFileResult | null>(null);
const [sourceUnavailable, setSourceUnavailable] = useState(false);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const selectedViewerRef = useRef<HTMLDivElement>(null);
@ -214,12 +215,14 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
useEffect(() => {
if (!selectedFilePath) {
setFileResult(null);
setSourceUnavailable(false);
return;
}
let cancelled = false;
setIsLoadingFile(true);
setFileResult(null);
setSourceUnavailable(false);
// Determine read range: full file for File nodes, buffered for symbols
const startLine = selectedNode?.properties?.startLine as number | undefined;
@ -242,9 +245,12 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
setIsLoadingFile(false);
}
})
.catch(() => {
.catch((error) => {
if (!cancelled) {
setFileResult(null);
setSourceUnavailable(
error instanceof BackendError && error.code === 'source_unavailable',
);
setIsLoadingFile(false);
}
});
@ -384,7 +390,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
<X className="h-4 w-4" />
</button>
</div>
<div ref={selectedViewerRef} className="scrollbar-thin min-h-0 flex-1 overflow-auto">
<div ref={selectedViewerRef} className="min-h-0 flex-1 scrollbar-thin overflow-auto">
{isLoadingFile ? (
<div className="flex items-center justify-center gap-2 py-8 text-text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
@ -426,7 +432,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
</SyntaxHighlighter>
) : (
<div className="px-3 py-3 text-sm text-text-muted">
{selectedIsFile ? (
{sourceUnavailable ? (
<>{t('graph:codePanel.sourceUnavailable')}</>
) : selectedIsFile ? (
<>{t('graph:codePanel.codeNotAvailable', { path: selectedFilePath })}</>
) : (
<>{t('graph:codePanel.selectFile')}</>
@ -457,7 +465,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
{t('graph:codePanel.references', { count: aiReferences.length })}
</span>
</div>
<div className="scrollbar-thin min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<div className="min-h-0 flex-1 scrollbar-thin space-y-3 overflow-y-auto p-3">
{refsWithSnippets.map(
({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
const nodeColor = ref.label

View file

@ -389,7 +389,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
{/* File tree */}
<div className="scrollbar-thin flex-1 overflow-y-auto py-2">
<div className="flex-1 scrollbar-thin overflow-y-auto py-2">
{fileTree.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-text-muted">
{t('graph:fileTree.noFilesLoaded')}
@ -413,7 +413,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
)}
{activeTab === 'filters' && (
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
<div className="flex-1 scrollbar-thin overflow-y-auto p-3">
<div className="mb-3">
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
{t('graph:fileTree.nodeTypes')}

View file

@ -247,7 +247,7 @@ export const Header = ({
/>
</div>
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pb-1">
<div className="min-h-0 flex-1 scrollbar-thin overflow-y-auto pb-1">
{filteredRepos.length === 0 ? (
<div className="px-4 py-3 text-sm text-text-muted">
{t('header:noRepositoriesFound', { query: repoSearchQuery })}

View file

@ -364,7 +364,7 @@ export const ProcessesPanel = () => {
</div>
{/* Process list */}
<div className="scrollbar-thin flex-1 overflow-y-auto">
<div className="flex-1 scrollbar-thin overflow-y-auto">
{/* View All Processes Card */}
<div className="px-4 py-3">
<button

View file

@ -343,7 +343,7 @@ export const QueryFAB = () => {
</div>
{showResults && queryResult.rows.length > 0 && (
<div className="scrollbar-thin max-h-48 overflow-auto border-t border-border-subtle">
<div className="max-h-48 scrollbar-thin overflow-auto border-t border-border-subtle">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-surface">
<tr>

View file

@ -301,7 +301,7 @@ export const RightPanel = () => {
)}
{/* Messages */}
<div ref={scrollContainerRef} className="scrollbar-thin flex-1 overflow-y-auto p-4">
<div ref={scrollContainerRef} className="flex-1 scrollbar-thin overflow-y-auto p-4">
{chatMessages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-node-interface text-2xl shadow-glow">
@ -426,7 +426,7 @@ export const RightPanel = () => {
onKeyDown={handleKeyDown}
placeholder={t('chat:input.placeholder')}
rows={1}
className="scrollbar-thin min-h-[36px] flex-1 resize-none border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
className="min-h-[36px] flex-1 resize-none scrollbar-thin border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
style={{ height: '36px', overflowY: 'hidden' }}
/>
<button

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)
@ -50,6 +52,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
@ -113,6 +117,8 @@ export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [
'Folder',
'File',
'Class',
'Protocol',
'Category',
'Function',
'Method',
'Property', // Kotlin/Java fields (HAS_PROPERTY + DEFINES File→Property)

View file

@ -110,7 +110,8 @@
"references_other": "{{count}} references",
"lines_one": "{{count}} line",
"lines_other": "{{count}} lines",
"codeNotAvailable": "Code not available in memory for {{path}}"
"codeNotAvailable": "Code not available in memory for {{path}}",
"sourceUnavailable": "Full source is unavailable for this index."
},
"canvas": {
"viewModes": {

View file

@ -110,7 +110,8 @@
"references_other": "{{count}} 条引用",
"lines_one": "{{count}} 行",
"lines_other": "{{count}} 行",
"codeNotAvailable": "内存中没有 {{path}} 的代码内容"
"codeNotAvailable": "内存中没有 {{path}} 的代码内容",
"sourceUnavailable": "此索引无法提供完整源码。"
},
"canvas": {
"viewModes": {

View file

@ -91,6 +91,7 @@ export class BackendError extends Error {
| 'server'
| 'client'
| 'not_found'
| 'source_unavailable'
| 'timeout'
| 'rate_limited'
// The write-route same-host Origin guard rejected this request (HTTP 403
@ -522,21 +523,23 @@ const assertOk = async (response: Response): Promise<void> => {
}
const code =
response.status === 404
? 'not_found'
: response.status === 429
? 'rate_limited'
: // The public edge's token gate returns 401 with this discriminator;
// surface it as a distinct code so the UI can prompt for the token.
bodyCode === 'unauthorized'
? 'unauthorized'
: // The write-route Origin guard returns 403 with this discriminator;
// surface it as a distinct code so the UI can give actionable guidance.
bodyCode === 'origin_not_allowed'
? 'origin_blocked'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
bodyCode === 'source-unavailable'
? 'source_unavailable'
: response.status === 404
? 'not_found'
: response.status === 429
? 'rate_limited'
: // The public edge's token gate returns 401 with this discriminator;
// surface it as a distinct code so the UI can prompt for the token.
bodyCode === 'unauthorized'
? 'unauthorized'
: // The write-route Origin guard returns 403 with this discriminator;
// surface it as a distinct code so the UI can give actionable guidance.
bodyCode === 'origin_not_allowed'
? 'origin_blocked'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
// Retry-After is the standard HTTP signal for when the client may try again.
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.

View file

@ -1,9 +1,9 @@
import { render } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import type { GraphNode } from 'gitnexus-shared';
import { CodeReferencesPanel } from '../../src/components/CodeReferencesPanel';
import { readFile } from '../../src/services/backend-client';
import { BackendError, readFile } from '../../src/services/backend-client';
const fileNode: GraphNode = {
id: 'File:src/foo.ts',
@ -31,6 +31,15 @@ vi.mock('../../src/hooks/useAppState', () => ({
vi.mock('../../src/services/backend-client', () => ({
readFile: vi.fn(),
BackendError: class BackendError extends Error {
constructor(
message: string,
_status: number,
public readonly code: string,
) {
super(message);
}
},
}));
vi.mock('react-syntax-highlighter', () => ({
@ -70,4 +79,16 @@ describe('CodeReferencesPanel repo identity (#2420)', () => {
expect(readFile).toHaveBeenCalledWith('src/foo.ts', { repo: 'reels' });
});
it('renders the dedicated source-unavailable state for retained indexes without a checkout', async () => {
vi.mocked(readFile).mockRejectedValue(
new BackendError('source unavailable', 410, 'source_unavailable'),
);
render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('graph:codePanel.sourceUnavailable')).toBeInTheDocument();
});
});
});

View file

@ -161,7 +161,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
5. **Processes** — Traces execution flows from entry points through call chains
6. **Search** — Builds hybrid search indexes for fast retrieval
The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with full-text search and semantic embeddings.
The result is a **LadybugDB graph database** stored locally in `.gitnexus/` by default, with full-text search and semantic embeddings.
### Experimental community detection engine
@ -342,7 +342,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart
TypeScript, JavaScript, Python, Java, C, C++, Objective-C, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart
### Language Feature Matrix
@ -361,6 +361,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Objective-C | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
@ -566,6 +567,8 @@ Configure the behavior with these environment variables:
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. |
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
| `GITNEXUS_STORAGE_PATH` | absolute, non-empty directory | `<repo>/.gitnexus/` | Absolute directory for one repository index, including graph data, metadata, caches, locks, and branch indexes. The resolved path is registered so `status`, MCP, and `serve` can reopen the index later. |
| `GITNEXUS_CONTENT_RETENTION` | `full`, `symbol`, `none` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. |
| `GITNEXUS_STREAM_GRAPH_EMIT` | `0`, `1` | `1` (on) | **On by default** on a full rebuild (`--force`); incremental runs ignore it. Holds structural relationships (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) as CSV-on-disk plus compact in-memory columns instead of as objects in three overlapping indexes, cutting peak in-memory graph heap by ~1.4x at no measurable CPU cost (measured A/B on a synthetic 400k-node / 1.08M-edge graph: 819 MB -> 584 MB, iteration at parity, scaling verified linear from 100k to 800k nodes, with every edge still visible through the graph interface; no end-to-end measurement on a real repository yet). Nothing is traded away — community detection, process extraction, PDG taint summaries and the local-symbol pruner all read a complete relationship set and behave identically. Set to `0` only to bisect a suspected streaming-related fault. |
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` is the supported default. `icebug` and `auto` are **experimental** and currently behave identically: both try the optional `@ladybugmem/icebug` native Leiden over a CSR export and fall back to Graphology if it is not installed, cannot load, or lacks the deterministic thread/seed controls. Experimental engines partition differently, so community IDs are not comparable across engines. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
@ -771,7 +774,7 @@ only — the hook's structured stdout (the JSON the agent consumes) is unaffecte
- All processing happens locally on your machine
- No code is sent to any server
- Index stored in `.gitnexus/` inside your repo (gitignored)
- Index stored in `.gitnexus/` inside your repo by default (gitignored), or in `GITNEXUS_STORAGE_PATH` when configured
- Global registry at `~/.gitnexus/` stores only paths and metadata
## Web UI

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

@ -146,7 +146,7 @@ export const cleanCommand = async (options?: {
// through the rest of the registry (preserves the existing
// per-repo error-tolerance semantics of `clean --all`).
try {
assertSafeStoragePath(entry);
await assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);

View file

@ -249,7 +249,8 @@ program
program
.command('status')
.description('Show index status for current repo')
.description('Show index status for the current repo or a registered index')
.option('-r, --repo <name>', 'Registered repository alias or path (works after checkout removal)')
.option('--json', 'Emit machine-readable index and analyzer provenance')
.addHelpText('after', () => t('help.identityCache.environment'))
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
@ -362,7 +363,10 @@ program
.option('-c, --context <text>', 'Task context to improve ranking')
.option('-g, --goal <text>', 'What you want to find')
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
.option('--content', 'Include full symbol source code')
.option(
'--content',
'Include retained symbol source text (reports availability when disabled by retention)',
)
.action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand'));
program
@ -373,7 +377,10 @@ program
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
.option('-f, --file <path>', 'File path to disambiguate common names')
.option('-l, --limit <n>', 'Max callers/callees/processes to return')
.option('--content', 'Include full symbol source code')
.option(
'--content',
'Include retained symbol source text (reports availability when disabled by retention)',
)
.action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
program

View file

@ -89,7 +89,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
// any of those would be a runtime disaster. Bail before touching
// disk, with an actionable hint for recovering a broken registry.
try {
assertSafeStoragePath(entry);
await assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
cliError(t('common.error', { message: err.message }));

View file

@ -5,7 +5,16 @@
*/
import path from 'path';
import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/repo-manager.js';
import {
findRepo,
getStoragePaths,
loadMeta,
hasKuzuIndex,
readRegistry,
resolveRegistryEntry,
RegistryNotFoundError,
RegistryAmbiguousTargetError,
} from '../storage/repo-manager.js';
import {
getCurrentCommit,
getCurrentBranch,
@ -22,9 +31,91 @@ import { t } from './i18n/index.js';
export interface StatusOptions {
json?: boolean;
/** Resolve a registered index without requiring its original checkout to remain on disk. */
repo?: string;
}
export const statusCommand = async (options: StatusOptions = {}) => {
if (options.repo) {
let entry;
try {
entry = resolveRegistryEntry(await readRegistry(), options.repo);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
if (options.json) {
console.log(
JSON.stringify({ schemaVersion: 1, repository: options.repo, error: 'not-indexed' }),
);
} else if (
err instanceof RegistryNotFoundError ||
err instanceof RegistryAmbiguousTargetError
) {
console.log(error);
} else {
throw err;
}
return;
}
const meta = await loadMeta(entry.storagePath);
if (!meta) {
if (options.json) {
console.log(
JSON.stringify({
schemaVersion: 1,
repository: entry.path,
storagePath: entry.storagePath,
error: 'not-indexed',
}),
);
} else {
console.log(`No readable index metadata at ${entry.storagePath}`);
}
return;
}
const currentRunnerIdentity = resolveAnalyzerRunnerIdentity(import.meta.url);
const runnerIdentityIsCurrent = analyzerRunnerIdentitiesEqual(
meta.runnerIdentity,
currentRunnerIdentity,
);
const incompleteReasons = getIndexIncompleteReasons(meta);
const sourceAvailable = isGitRepo(entry.path);
const payload = {
schemaVersion: 1,
repository: entry.path,
storagePath: entry.storagePath,
sourceAvailable,
index: {
indexedAt: meta.indexedAt,
commit: meta.lastCommit,
runnerIdentity: meta.runnerIdentity ?? null,
runnerIdentityStatus: runnerIdentityIsCurrent ? 'current' : 'stale-or-unknown',
incompleteReasons,
contentRetention: meta.contentRetention ?? 'full',
},
current: sourceAvailable ? { commit: getCurrentCommit(entry.path) } : null,
// Without a checkout GitNexus can prove the index is readable, but cannot
// certify that it is current relative to source. Keep that distinction in
// the machine-readable status instead of reporting a false all-clear.
status: sourceAvailable ? 'registered' : 'source-unavailable',
};
if (options.json) {
console.log(JSON.stringify(payload));
} else {
console.log(`Repository: ${entry.path}`);
console.log(`Index storage: ${entry.storagePath}`);
console.log(`Indexed: ${new Date(meta.indexedAt).toLocaleString()}`);
console.log(`Indexed commit: ${meta.lastCommit?.slice(0, 7)}`);
console.log(
sourceAvailable
? 'Status: registered index (use status without --repo for working-tree freshness)'
: 'Status: source checkout unavailable; graph index remains queryable through the registry',
);
}
return;
}
const cwd = process.cwd();
if (!isGitRepo(cwd)) {

View file

@ -0,0 +1,64 @@
import type { KnowledgeGraph } from './graph/types.js';
import type { ContentRetention, FtsProfile, RepoMeta } from '../storage/repo-meta.js';
export const CONTENT_RETENTION_ENV = 'GITNEXUS_CONTENT_RETENTION';
export const contentRetentionFromEnvironment = (): ContentRetention => {
const raw = process.env[CONTENT_RETENTION_ENV];
if (raw === undefined || raw.trim() === '') return 'full';
const value = raw.trim();
if (value === 'full' || value === 'symbol' || value === 'none') return value;
throw new Error(
`Invalid ${CONTENT_RETENTION_ENV} "${raw}". Expected one of: full, symbol, none.`,
);
};
export const contentRetentionFromMeta = (
meta: Pick<RepoMeta, 'contentRetention'> | null | undefined,
): ContentRetention =>
meta?.contentRetention === 'symbol' || meta?.contentRetention === 'none'
? meta.contentRetention
: 'full';
export const ftsProfileForContentRetention = (retention: ContentRetention): FtsProfile => {
switch (retention) {
case 'symbol':
return 'symbol-no-file-content';
case 'none':
return 'name-only';
default:
return 'full';
}
};
/**
* Legacy metadata predates retention fields and is therefore semantically full.
* It remains incrementally readable under the default profile; explicit newer
* stamps must match exactly because an FTS/layout change requires a fresh DB.
*/
export const contentRetentionMismatch = (
meta: Pick<RepoMeta, 'contentRetention' | 'contentRetentionSchemaVersion' | 'ftsProfile'>,
requested: ContentRetention,
): boolean => {
if (meta.contentRetention === undefined) return requested !== 'full';
return (
meta.contentRetention !== requested ||
meta.contentRetentionSchemaVersion !== 1 ||
meta.ftsProfile !== ftsProfileForContentRetention(requested)
);
};
/** Remove text that the active index profile is not allowed to persist. */
export const applyContentRetention = (graph: KnowledgeGraph, retention: ContentRetention): void => {
if (retention === 'full') return;
graph.forEachNode((node) => {
if (retention === 'symbol' && node.label !== 'File') return;
delete node.properties.content;
if (retention === 'none') {
delete node.properties.description;
if (node.label === 'BasicBlock') delete node.properties.text;
}
});
};

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,
@ -108,6 +112,8 @@ export const isShortLabel = (label: string): boolean =>
*/
export const STRUCTURAL_LABELS: ReadonlySet<string> = new Set([
LABEL_CLASS,
LABEL_PROTOCOL,
LABEL_CATEGORY,
LABEL_STRUCT,
LABEL_INTERFACE,
]);
@ -165,6 +171,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

@ -11,7 +11,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.
@ -20,7 +20,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

@ -36,7 +36,7 @@ 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 { NodeLabel } from 'gitnexus-shared';
import type { 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 {
@ -51,6 +51,68 @@ import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
export type CaptureMap = Record<string, SyntaxNode | undefined>;
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[];
}
export interface DefinitionPropertiesContext {
readonly nodeLabel: NodeLabel;
readonly nodeName: string;
@ -192,6 +254,29 @@ interface LanguageProviderConfig {
*/
readonly preprocessSource?: (sourceText: string, filePath: string) => string;
/**
* 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;
@ -402,6 +487,23 @@ interface LanguageProviderConfig {
filePath: string,
) => SharedSpringType[];
/**
* 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;
/**
* Harvest this file's module-level string constants (#2391 core, #2980 Java
* parity) into the language-agnostic {@link ModuleConstants} shape, so the

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';
@ -37,6 +38,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,
@ -68,3 +70,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,175 @@
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 {
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',
]);
const OBJECTIVE_C_FRAMEWORK_NAMES = [
'AppKit',
'Foundation',
'UIKit',
'CoreData',
'CoreFoundation',
'QuartzCore',
'Swift',
];
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;
if (node.type === 'preproc_include' && node.text.trimStart().startsWith('#import')) {
const pathNode = node.namedChildren[0];
if (
pathNode !== undefined &&
OBJECTIVE_C_FRAMEWORK_NAMES.some((name) => pathNode.text.includes(name))
) {
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, 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();
return {
kind: 'side-effect',
// Scope resolution needs to distinguish a quoted header path from a bare
// @import module name, while the semantic graph retains the original raw
// import spelling in ObjCImportFact.
targetRaw: 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,
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,592 @@
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,
objectiveCFactsFromParsedFiles,
type ObjCContainerFact,
type ObjCFileFacts,
type ObjCMessageFact,
type ObjCMethodFact,
} 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 classProtocols: 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);
emitObjectiveCImplementationEvidence(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 classProtocols = 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);
} 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);
}
}
}
return {
containersByQualifiedName,
classByName,
protocolsByName,
categoriesByHost,
methodsByDispatchOwner,
methodsByExactOwner,
classProtocols,
superclassByClass,
};
}
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): void {
for (const container of facts.containers) {
if (container.hostClass === undefined) continue;
addRelationship(
graph,
'MEMBER_OF',
graphNodeId('Category', container.qualifiedName),
graphNodeId('Class', objcClassQualifiedName(container.hostClass)),
'objc: category host class',
);
}
}
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' || message.receiverKind === 'unknown') {
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'
? findExactOwnerMethods(
workspace,
owner.qualifiedName,
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;
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 protocolQn = objcProtocolQualifiedName(receiverType.name);
const methods = findExactOwnerMethods(workspace, protocolQn, '-', message.selector);
const candidates = findProtocolImplementationCandidates(
workspace,
receiverType.name,
message.selector,
);
return {
kind: 'protocol',
protocolName: receiverType.name,
methods,
candidates,
};
}
return { kind: 'none', methods: [] };
}
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 findProtocolImplementationCandidates(
workspace: ObjCWorkspaceFacts,
protocolName: string,
selector: string,
): readonly ObjCMethodFact[] {
const out: ObjCMethodFact[] = [];
for (const [className, protocols] of workspace.classProtocols) {
if (!protocols.has(protocolName)) continue;
out.push(...findDispatchMethods(workspace, className, '-', selector));
}
return 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}] candidates`,
qualifiedName,
filePath: facts.filePath,
startLine: message.startLine,
endLine: message.startLine,
language: SupportedLanguages.ObjectiveC,
isExported: false,
objectiveCKind: 'protocol-candidate-implementations',
protocolName,
selector: message.selector,
sourceMethod: message.sourceMethodQualifiedName,
candidateImplementations: candidates.map((candidate) => candidate.qualifiedName).sort(),
},
};
graph.addNode(node);
addRelationship(
graph,
'DEFINES',
graphNodeId('File', facts.filePath),
nodeId,
'objc: protocol receiver candidate evidence',
1,
);
addRelationship(
graph,
'USES',
message.sourceMethodId,
nodeId,
'objc-message: protocol receiver candidates',
0.7,
);
}
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;
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

@ -19,7 +19,7 @@
*
* | 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',
@ -268,8 +270,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);
@ -308,11 +310,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';
@ -222,7 +222,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

@ -60,7 +60,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 {
getLanguageForFileContent,
getProvider,
getProviderForFile,
needsContentLanguageClassification,
providers,
} from '../languages/index.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';
@ -486,15 +492,28 @@ export async function runChunkedParseAndResolve(
const model = createSemanticModel();
const symbolTable = model.symbols;
const contentClassifiedPaths = scannedFiles
.map((file) => file.path)
.filter(needsContentLanguageClassification);
const contentClassificationMap =
contentClassifiedPaths.length > 0
? await readFileContents(repoPath, contentClassifiedPaths)
: new Map<string, string>();
const languageForScannedFile = (file: (typeof scannedFiles)[number]) => {
const content = contentClassificationMap.get(file.path);
return content === undefined
? getLanguageFromFilename(file.path)
: getLanguageForFileContent(file.path, content);
};
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);

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

@ -61,6 +61,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

@ -34,6 +34,10 @@ import { runScopeResolution, type ScopeResolutionSubPhase } from './run.js';
import { isLanguageAvailable } from '../../../tree-sitter/parser-loader.js';
import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { SCOPE_RESOLVERS } from './registry.js';
import {
getLanguageForFileContent,
needsContentLanguageClassification,
} from '../../languages/index.js';
import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js';
import { logHeapProbe } from '../../utils/heap-probe.js';
import {
@ -242,6 +246,13 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
let totalScopeFiles = 0;
let totalScopeLangs = 0;
const allScannedPaths = new Set(scannedFiles.map((f) => f.path));
const contentClassifiedPaths = scannedFiles
.map((f) => f.path)
.filter(needsContentLanguageClassification);
const contentClassificationMap =
contentClassifiedPaths.length > 0
? await readFileContents(ctx.repoPath, contentClassifiedPaths)
: new Map<string, string>();
// Partition scanned files by language ONCE (O(F)). The previous code
// re-filtered all scannedFiles per language for the precount AND again in the
// per-language loop below — O(languages × files), ~2.3M getLanguageFromFilename
@ -252,7 +263,11 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
(typeof scannedFiles)[number][]
>();
for (const f of scannedFiles) {
const fileLang = getLanguageFromFilename(f.path);
const classifiedContent = contentClassificationMap.get(f.path);
const fileLang =
classifiedContent !== undefined
? getLanguageForFileContent(f.path, classifiedContent)
: 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

@ -27,6 +27,7 @@ import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js';
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 { 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
@ -51,4 +52,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Swift, swiftScopeResolver],
[SupportedLanguages.Dart, dartScopeResolver],
[SupportedLanguages.Vue, vueScopeResolver],
[SupportedLanguages.ObjectiveC, objectiveCScopeResolver],
]);

View file

@ -180,6 +180,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

@ -2449,12 +2449,15 @@ export const DART_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,
@ -82,6 +82,11 @@ let C: TreeSitterLanguage | null = null;
try {
C = requireVendoredGrammar('tree-sitter-c') as TreeSitterLanguage;
} catch {}
let ObjectiveC: TreeSitterLanguage | null = null;
try {
ObjectiveC = requireVendoredGrammar('tree-sitter-objc') as TreeSitterLanguage;
} catch {}
import { getLanguageFromFilename } from 'gitnexus-shared';
import {
buildDefinitionPreScan,
@ -246,7 +251,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;
}
@ -546,6 +551,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,
@ -1215,7 +1221,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) {
@ -1663,6 +1669,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

@ -18,6 +18,7 @@ import path from 'path';
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import { NodeTableName, RELATION_SCHEMA } from './schema.js';
import type { ContentRetention } from '../../storage/repo-meta.js';
import { VALID_NODE_TABLES, parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js';
import { parseTruthyEnv } from '../ingestion/utils/env.js';
import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js';
@ -275,7 +276,16 @@ const formatFtsDescription = (description: string): string =>
// 0-based line invariant. Kept as a named alias to read intent at the use site.
const EXACT_SYMBOL_CONTENT_LABELS = SYMBOL_NODE_LABELS;
const extractContent = async (node: GraphNode, contentCache: FileContentCache): Promise<string> => {
const extractContent = async (
node: GraphNode,
contentCache: FileContentCache,
contentRetention: ContentRetention,
): Promise<string> => {
// File content is intentionally lazy-read for full indexes. Do not let this
// compatibility path recreate source text that the selected profile forbids.
if (contentRetention === 'none' || (contentRetention === 'symbol' && node.label === 'File')) {
return '';
}
const filePath = node.properties.filePath;
const content = await contentCache.get(filePath);
if (!content) return '';
@ -452,6 +462,7 @@ export const streamAllCSVsToDisk = async (
repoPath: string,
csvDir: string,
onNodePhaseComplete?: (nodeFiles: Map<NodeTableName, { csvPath: string; rows: number }>) => void,
contentRetention: ContentRetention = 'full',
): Promise<StreamedCSVResult> => {
// Deterministic (id-sorted) node/relationship row order when enabled;
// default off = today's graph-insertion order (byte-identical).
@ -538,6 +549,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',
@ -594,7 +607,7 @@ export const streamAllCSVsToDisk = async (
let pending: Promise<void> | undefined;
switch (node.label) {
case 'File': {
const content = await extractContent(node, contentCache);
const content = await extractContent(node, contentCache, contentRetention);
pending = fileWriter.addRow(
[
escapeCSVField(node.id),
@ -649,7 +662,7 @@ export const streamAllCSVsToDisk = async (
break;
}
case 'Method': {
const content = await extractContent(node, contentCache);
const content = await extractContent(node, contentCache, contentRetention);
pending = methodWriter.addRow(
[
escapeCSVField(node.id),
@ -667,7 +680,7 @@ export const streamAllCSVsToDisk = async (
break;
}
case 'Section': {
const content = await extractContent(node, contentCache);
const content = await extractContent(node, contentCache, contentRetention);
pending = sectionWriter.addRow(
[
escapeCSVField(node.id),
@ -722,7 +735,7 @@ export const streamAllCSVsToDisk = async (
// Code element nodes (Function, Class, Interface, CodeElement)
const writer = codeWriterMap[node.label];
if (writer) {
const content = await extractContent(node, contentCache);
const content = await extractContent(node, contentCache, contentRetention);
const row = [
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''),
@ -743,7 +756,7 @@ export const streamAllCSVsToDisk = async (
// Multi-language node types (Struct, Impl, Trait, Macro, etc.)
const mlWriter = multiLangWriters.get(node.label);
if (mlWriter) {
const content = await extractContent(node, contentCache);
const content = await extractContent(node, contentCache, contentRetention);
pending = mlWriter.addRow(
[
escapeCSVField(node.id),

View file

@ -12,6 +12,7 @@ import { escapeCypherString } from './cypher-escape.js';
import { withConnLock } from './conn-lock.js';
import { isWalDriverActive } from './wal-driver-state.js';
import { KnowledgeGraph } from '../graph/types.js';
import type { ContentRetention } from '../../storage/repo-meta.js';
import {
NODE_TABLES,
REL_TABLE_NAME,
@ -1105,6 +1106,8 @@ export const loadGraphToLbug = async (
* which holds one CSV per pair and would silently drop one of them).
*/
graphEmitManifest?: GraphEmitManifest,
/** Content profile for CSV emission; default preserves the historical full index. */
contentRetention: ContentRetention = 'full',
) => {
if (!conn) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
@ -1185,8 +1188,8 @@ export const loadGraphToLbug = async (
let csvResult: StreamedCSVResult;
try {
csvResult = SERIAL
? await streamAllCSVsToDisk(graph, repoPath, csvDir)
: await streamAllCSVsToDisk(graph, repoPath, csvDir, beginNodeCopy);
? await streamAllCSVsToDisk(graph, repoPath, csvDir, undefined, contentRetention)
: await streamAllCSVsToDisk(graph, repoPath, csvDir, beginNodeCopy, contentRetention);
} catch (emitErr) {
// Relationship emit failed. In overlap mode a node COPY may be in flight —
// settle it (the .catch above means this never rejects) before rethrowing so

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,
@ -522,6 +546,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,
@ -653,6 +680,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

@ -68,6 +68,13 @@ import {
initialiseSearchFTSStemmer,
verifySearchFTSIndexes,
} from './search/fts-indexes.js';
import { getFtsIndexes } from './search/fts-schema.js';
import {
applyContentRetention,
contentRetentionFromEnvironment,
contentRetentionMismatch,
ftsProfileForContentRetention,
} from './content-retention.js';
import {
cjkSegmentationModeMismatch,
getSearchFTSCjkSegmentation,
@ -105,9 +112,11 @@ import {
isRepoRegistered,
cleanupOldKuzuFiles,
reconcileMetadataFiles,
ensureStoragePathWritable,
isMissingFilesystemError,
INDEX_METADATA_FILE,
type AnalyzerRunnerIdentity,
type ContentRetention,
type RepoMeta,
} from '../storage/repo-manager.js';
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
@ -178,6 +187,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';
import {
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
findAnalysisFeatureMismatches,
@ -233,6 +243,7 @@ const ANALYSIS_FEATURES = [
SPRING_CONFIG_BINDINGS_FEATURE,
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
OBJECTIVE_C_PROVIDER_FEATURE,
] as const;
interface PersistedFrameworkAnnotationRow {
@ -1010,6 +1021,7 @@ export async function runFullAnalysis(
// cached value via getSearchFTSStemmer.)
initialiseSearchFTSStemmer();
initialiseSearchFTSCjkSegmentation();
const contentRetention = contentRetentionFromEnvironment();
// Scope the degraded-parse log throttle to this run (module-level counter
// would otherwise stay saturated on a reused process).
resetDegradedParseCounter();
@ -1022,6 +1034,7 @@ export async function runFullAnalysis(
};
let writeTarget = await resolveWriteTarget(repoPath, options);
await ensureStoragePathWritable(writeTarget.storagePath);
let lock = await acquireIndexLock(writeTarget.metaDir, acquireOpts);
try {
// #2658 review H2: acquireIndexLock can wait up to the timeout ceiling,
@ -1059,6 +1072,7 @@ export async function runFullAnalysis(
options,
callbacks,
writeTarget,
contentRetention,
runnerIdentityAtBootstrap,
);
} finally {
@ -1071,6 +1085,7 @@ async function runFullAnalysisInner(
options: AnalyzeOptions,
callbacks: AnalyzeCallbacks,
writeTarget: WriteTarget,
contentRetention: ContentRetention,
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
): Promise<AnalyzeResult> {
const log = (msg: string) => callbacks.onLog?.(stripControlCharacters(msg));
@ -1088,6 +1103,8 @@ async function runFullAnalysisInner(
// does not own the flat slot. See resolveWriteTarget for the full contract.
const { storagePath, repoHasGit, currentCommit, branchLabel, placement, lbugPath, metaDir } =
writeTarget;
const ftsProfile = ftsProfileForContentRetention(contentRetention);
const ftsIndexes = getFtsIndexes(ftsProfile);
// Start each analyze with a clean buffer-pool hint: any pre-pipeline DB open
// (e.g. the embeddings-cache open) falls back to the default until the hint is
@ -1115,6 +1132,16 @@ async function runFullAnalysisInner(
const existingMeta = await loadMeta(metaDir);
// ── FTS-only repair path ────────────────────────────────────────────
if (
options.repairFts &&
existingMeta &&
contentRetentionMismatch(existingMeta, contentRetention)
) {
log(
'content retention or FTS profile changed; forcing a full rebuild before rebuilding search indexes.',
);
options = { ...options, force: true, repairFts: false };
}
if (options.repairFts) {
if (!existingMeta) {
throw new Error(
@ -1205,6 +1232,7 @@ async function runFullAnalysisInner(
}
progress('fts', 85, 'Repairing search indexes...');
const repairFailures = await createSearchFTSIndexes({
indexes: ftsIndexes,
onIndexStart: options.verbose
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
: undefined,
@ -1212,7 +1240,7 @@ async function runFullAnalysisInner(
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
: undefined,
});
const missing = await verifySearchFTSIndexes(executeQuery);
const missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes);
if (missing.length > 0) {
// #2889: name WHY each index is missing when the build itself said so.
// Repair now rebuilds every table it can before reporting, so the tables
@ -1221,7 +1249,9 @@ async function runFullAnalysisInner(
// only ever list "missing", never a reason. Same sentence the analyze
// degrade path prints, so one failure does not read two ways.
const reasons =
repairFailures.length > 0 ? ` ${summarizeFtsIndexBuildFailures(repairFailures)}.` : '';
repairFailures.length > 0
? ` ${summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes)}.`
: '';
throw new Error(
`FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}.${reasons} ` +
'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' +
@ -1435,6 +1465,18 @@ async function runFullAnalysisInner(
options = { ...options, force: true };
}
// Retention controls the DB's persisted text and FTS columns. Incremental
// writeback only touches changed files, so changing it in place would leave
// old source text and index pages behind. Rebuild the database instead.
if (existingMeta && contentRetentionMismatch(existingMeta, contentRetention)) {
const recorded = existingMeta.contentRetention ?? 'full (legacy)';
log(
`content retention changed (index built with ${recorded}, this run uses ${contentRetention}); ` +
'forcing a full rebuild so stored text and FTS indexes are recreated.',
);
options = { ...options, force: true };
}
// ── schema mismatch forces full rebuild (#2289 P1, #2798) ─────────
// Mirrors the pdg-mode block above: an index whose tables were created from
// a different DDL cannot be reconciled by an incremental top-up — a
@ -1841,8 +1883,10 @@ async function runFullAnalysisInner(
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
// (force === true) so the incremental writeback never reads back an
// offloaded BasicBlock layer. Memory-only; byte-identical output.
streamPdgEmit: resolveStreamPdgEmit(options),
// offloaded BasicBlock layer. The `none` profile must strip BasicBlock
// text before persistence, so it keeps that layer in memory until the
// retention pass below.
streamPdgEmit: contentRetention !== 'none' && resolveStreamPdgEmit(options),
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
// Streamed structural emit (#2680) — same full-rebuild gate as the PDG
// toggle above, for the same incremental-writeback reason.
@ -1861,6 +1905,11 @@ async function runFullAnalysisInner(
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
progress('lbug', 60, 'Loading into LadybugDB...');
// Parsing and graph construction always see the original source. Apply the
// retention boundary only after all semantic phases have completed and
// before any graph rows, FTS values, or embeddings are persisted.
applyContentRetention(pipelineResult.graph, contentRetention);
// Compute current per-file content hashes from the pipeline's File nodes.
// Used both to drive the incremental DB writeback (when eligible) and to
// populate meta.json.fileHashes for the next run.
@ -2574,11 +2623,19 @@ async function runFullAnalysisInner(
await wipeLbugDbFiles(buildPath);
await initLbug(buildPath);
walCheckpointDriver = startWalCheckpointDriver();
await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
lbugMsgCount++;
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
progress('lbug', pct, msg);
});
await loadGraphToLbug(
pipelineResult.graph,
pipelineResult.repoPath,
storagePath,
(msg) => {
lbugMsgCount++;
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
progress('lbug', pct, msg);
},
undefined,
undefined,
contentRetention,
);
} else {
// 1a. Drop every FTS index before touching a single row (#2589).
// `deleteNodesForFiles` below DETACH DELETEs rows out of tables
@ -2596,7 +2653,7 @@ async function runFullAnalysisInner(
// same connection, and nothing on this branch creates or drops an index
// in between — so re-reading would only weaken the one-read invariant
// the snapshot type exists to enforce.
await dropSearchFTSIndexes(indexCatalogRows);
await dropSearchFTSIndexes(indexCatalogRows, ftsIndexes);
// 1b. Remove the write set's existing rows — batched (#2409): one
// DETACH DELETE per table per 200-file chunk. The former per-file
// loop issued a count + delete per table per FILE — ~13k
@ -2677,11 +2734,19 @@ async function runFullAnalysisInner(
effectiveWriteCount: effectiveWriteSet.size,
deleteCount: filesToDelete.length,
});
await loadGraphToLbug(subgraph, pipelineResult.repoPath, storagePath, (msg) => {
lbugMsgCount++;
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
progress('lbug', pct, msg);
});
await loadGraphToLbug(
subgraph,
pipelineResult.repoPath,
storagePath,
(msg) => {
lbugMsgCount++;
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
progress('lbug', pct, msg);
},
undefined,
undefined,
contentRetention,
);
}
// Boundary drain (#2409): checkpoint at the end of the incremental
@ -2707,6 +2772,7 @@ async function runFullAnalysisInner(
},
pipelineResult.pdgEmitManifest,
pipelineResult.graphEmitManifest,
contentRetention,
);
}
@ -2741,6 +2807,7 @@ async function runFullAnalysisInner(
// pre-existing row (#2544/#2546) must not discard this run's otherwise-
// successful graph/embeddings work — only keyword search degrades.
const ftsResult = await buildSearchIndexesOrDegrade(executeQuery, {
indexes: ftsIndexes,
onIndexStart: options.verbose
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
: undefined,
@ -3458,8 +3525,12 @@ async function runFullAnalysisInner(
// honesty contract silently decays to "whatever interpolates".
const meta: RepoMeta = {
repoPath,
storagePath,
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
contentRetention,
contentRetentionSchemaVersion: 1,
ftsProfile,
runnerIdentity,
// Branch identity this index represents (#2106). Recorded for the flat
// slot too (so resolveBranchPlacement knows which branch owns it). When

View file

@ -9,7 +9,7 @@ import {
} from '../lbug/lbug-adapter.js';
import { getFtsCapability } from '../lbug/extension-loader.js';
import { classifyExtensionLoadError } from '../lbug/extension-load-error.js';
import { FTS_INDEXES } from './fts-schema.js';
import { FTS_INDEXES, type FTSIndexDefinition } from './fts-schema.js';
/**
* Strip filesystem paths from a LadybugDB error before it reaches the HTTP
@ -156,6 +156,7 @@ export const SUPPORTED_FTS_STEMMERS: ReadonlySet<string> = new Set<string>([
]);
export interface CreateSearchFTSIndexesOptions {
indexes?: readonly FTSIndexDefinition[];
onIndexStart?: (table: string, indexName: string) => void;
onIndexReady?: (table: string, indexName: string) => void;
}
@ -219,7 +220,10 @@ export function getSearchFTSStemmer(): string {
* contract, and the same one-shared-`SHOW_INDEXES`-read purpose, as the gates in
* `lbug-adapter.ts`. Omit it to have the sweep read the catalog itself.
*/
export async function dropSearchFTSIndexes(indexRows?: IndexCatalogSnapshot): Promise<void> {
export async function dropSearchFTSIndexes(
indexRows?: IndexCatalogSnapshot,
indexes: readonly FTSIndexDefinition[] = FTS_INDEXES,
): Promise<void> {
// One catalog read for the whole sweep, decided PER CONFIGURED INDEX on
// IDENTITY (#2841 cleanup review). `undefined` = the catalog could not be
// read, which proves nothing — attempt every drop rather than skip a real one,
@ -239,7 +243,7 @@ export async function dropSearchFTSIndexes(indexRows?: IndexCatalogSnapshot): Pr
// so an index left over from an older, differently-named set was never dropped
// whether the sweep ran or not.
const rows = await resolveGateRows(indexRows);
for (const { table, indexName } of FTS_INDEXES) {
for (const { table, indexName } of indexes) {
// Skip only what the catalog POSITIVELY proves absent. Without this, a
// machine whose FTS extension cannot load, analyzing a DB that never carried
// an FTS index, pays one failed `CALL DROP_FTS_INDEX` per configured table on
@ -289,7 +293,7 @@ export async function createSearchFTSIndexes(
): Promise<FtsIndexBuildFailure[]> {
const stemmer = getSearchFTSStemmer();
const failures: FtsIndexBuildFailure[] = [];
for (const { table, indexName, properties } of FTS_INDEXES) {
for (const { table, indexName, properties } of options?.indexes ?? FTS_INDEXES) {
options?.onIndexStart?.(table, indexName);
// Drop first so the live `properties` always win. `createFTSIndex` is
// idempotent-by-name (skips when the index already exists), so without the
@ -328,12 +332,16 @@ export async function createSearchFTSIndexes(
* Anything heading for a network response has to pass it through
* {@link redactPaths} first, the same rule the query-side warnings follow.
*/
export const summarizeFtsIndexBuildFailures = (failures: readonly FtsIndexBuildFailure[]): string =>
`FTS index build failed for ${failures.length} of ${FTS_INDEXES.length} tables: ` +
export const summarizeFtsIndexBuildFailures = (
failures: readonly FtsIndexBuildFailure[],
indexes: readonly FTSIndexDefinition[] = FTS_INDEXES,
): string =>
`FTS index build failed for ${failures.length} of ${indexes.length} tables: ` +
failures.map((f) => `${f.table}.${f.indexName} (${f.error})`).join(', ');
export async function verifySearchFTSIndexes(
executeQuery: (cypher: string) => Promise<unknown[]>,
indexes: readonly FTSIndexDefinition[] = FTS_INDEXES,
): Promise<string[]> {
// Read the catalog once and check each configured index both EXISTS and
// covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX
@ -363,7 +371,7 @@ export async function verifySearchFTSIndexes(
}
const missing: string[] = [];
for (const { table, indexName, properties } of FTS_INDEXES) {
for (const { table, indexName, properties } of indexes) {
const actual = propsByIndex.get(indexName);
// Absent from the catalog, or present but not covering every expected column.
if (!actual || !properties.every((p) => actual.includes(p))) {
@ -471,7 +479,8 @@ export async function buildSearchIndexesOrDegrade(
// name+content-only index is invisible to the build (it succeeds) yet still
// means description search is broken (#2299).
const failures = await createSearchFTSIndexes(options);
const missing = await verifySearchFTSIndexes(executeQuery);
const indexes = options?.indexes ?? FTS_INDEXES;
const missing = await verifySearchFTSIndexes(executeQuery, indexes);
if (failures.length === 0 && missing.length === 0) return { ok: true };
// A table that failed to build is necessarily missing too — report it once,
@ -479,7 +488,7 @@ export async function buildSearchIndexesOrDegrade(
const named = new Set(failures.map((f) => `${f.table}.${f.indexName}`));
const unexplained = missing.filter((name) => !named.has(name));
const error = [
failures.length > 0 ? summarizeFtsIndexBuildFailures(failures) : '',
failures.length > 0 ? summarizeFtsIndexBuildFailures(failures, indexes) : '',
// Structural incompleteness with no thrown error — classified capability
// (degrade) below, matching prior behavior; a broken *write* surfaces as
// a thrown IO/checkpoint error and is classified integrity there.

View file

@ -1,3 +1,5 @@
import type { FtsProfile } from '../../storage/repo-meta.js';
export interface FTSIndexDefinition {
readonly table: string;
readonly indexName: string;
@ -25,6 +27,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
@ -45,3 +49,16 @@ export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
{ table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES },
{ table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
];
const NAME_ONLY_PROPERTIES = ['name'] as const;
/** Return the FTS definitions compatible with one persisted content profile. */
export const getFtsIndexes = (profile: FtsProfile = 'full'): readonly FTSIndexDefinition[] => {
if (profile === 'full') return FTS_INDEXES;
if (profile === 'symbol-no-file-content') {
return FTS_INDEXES.map((index) =>
index.table === 'File' ? { ...index, properties: NAME_ONLY_PROPERTIES } : index,
);
}
return FTS_INDEXES.map((index) => ({ ...index, properties: NAME_ONLY_PROPERTIES }));
};

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',
]);
/** Absolute directory of a vendored grammar package under `vendor/`. */
@ -42,7 +43,7 @@ export const vendoredGrammarDir = (packageName: string): string =>
/**
* Load a vendored tree-sitter grammar by its absolute path under `vendor/`.
*
* GitNexus vendors five grammars (c/dart/proto/swift/kotlin) inside its own
* GitNexus vendors six grammars (c/dart/proto/swift/kotlin/objc) 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

@ -95,6 +95,7 @@ import {
isSupportedCjkSegmentationMode,
MAX_CJK_SEGMENTATION_QUERY_LENGTH,
} from '../../core/search/cjk-segmentation.js';
import { contentRetentionFromMeta } from '../../core/content-retention.js';
import {
checkStalenessAsync,
checkCwdMatch,
@ -166,6 +167,29 @@ const VALUE_CANDIDATE_TYPES: ReadonlySet<string> = new Set(['Const', 'Variable',
*/
const CANDIDATE_WINDOW = 20;
/**
* `content` is an index capability rather than a promise that every symbol has
* text. Retention `none` intentionally omits it, while `symbol` retains only
* symbol spans. Keep this response additive and emit it only when requested so
* callers relying on the legacy response shape remain compatible.
*/
const requestedContentAvailability = (
requested: boolean,
meta: Awaited<ReturnType<typeof loadMeta>>,
) => {
if (!requested) return undefined;
const profile = contentRetentionFromMeta(meta);
return {
requested: true as const,
profile,
available: profile !== 'none',
scope: profile,
...(profile === 'none'
? { reason: 'Source-derived content is not retained by this index.' }
: {}),
};
};
/**
* The pieces every ambiguous-resolution payload shares, derived once.
*
@ -342,6 +366,8 @@ export const VALID_NODE_LABELS = new Set([
'Folder',
'Function',
'Class',
'Protocol',
'Category',
'Interface',
'Method',
'CodeElement',
@ -2568,7 +2594,16 @@ export class LocalBackend {
const processLimit = params.limit || 5;
const maxSymbolsPerProcess = params.max_symbols || 10;
const includeContent = params.include_content ?? false;
const requestedContent = params.include_content ?? false;
// Do not trust a lingering graph property when the metadata contract says
// source-derived text is unavailable. A full rebuild normally removes the
// column values; this guard keeps a partially migrated/corrupt index from
// disclosing text merely because a caller asked for it. `query` already
// reads this metadata for CJK and embedding-dimension drift diagnostics,
// so keep that legacy read unconditional.
const meta = await loadMeta(path.dirname(repo.lbugPath));
const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none';
const contentAvailability = requestedContentAvailability(requestedContent, meta);
const searchQuery = rawQuery.trim();
// Per-phase timing instrumentation (#553). Records wall time for each
@ -2971,7 +3006,6 @@ export class LocalBackend {
// GITNEXUS_FTS_CJK_SEGMENTATION (the only thing that actually throws in
// there) cannot take an unrelated diagnostic down with it. Needs no guard
// of its own: loadMeta() returns null on any read/parse failure.
const meta = await loadMeta(path.dirname(repo.lbugPath));
try {
// meta.json is on-disk state inside the analyzed repo, read via a
// schema-less JSON.parse — not trusted input. Validate before
@ -3065,6 +3099,7 @@ export class LocalBackend {
process_symbols: dedupedSymbols,
definitions: definitions.slice(0, 20), // cap standalone definitions
timing,
...(contentAvailability ? { contentAvailability } : {}),
...(warnings.length > 0 && { warning: warnings.join(' ') }),
...((enrichmentDegraded || ftsPartial) && { partial: true }),
};
@ -4054,6 +4089,12 @@ export class LocalBackend {
await this.ensureInitialized(repo);
const { name, uid, file_path, kind, include_content } = params;
const requestedContent = include_content ?? false;
// Content retention matters only to the opt-in content response. Avoid a
// metadata dependency for the long-standing default context operation.
const meta = requestedContent ? await loadMeta(path.dirname(repo.lbugPath)) : null;
const contentAvailability = requestedContentAvailability(requestedContent, meta);
const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none';
if (!name && !uid) {
return { error: 'Either "name" or "uid" parameter is required.' };
@ -4061,7 +4102,7 @@ export class LocalBackend {
const outcome = await this.resolveSymbolCandidates(
repo,
{ uid, name, include_content },
{ uid, name, include_content: includeContent },
{ file_path, kind },
);
@ -4108,7 +4149,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', '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
@ -4263,7 +4304,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', '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
@ -4442,6 +4483,7 @@ export class LocalBackend {
return {
status: 'found',
...(contentAvailability ? { contentAvailability } : {}),
symbol: {
uid: sym.id || sym[0],
name: sym.name || sym[1],
@ -4449,7 +4491,7 @@ export class LocalBackend {
filePath: sym.filePath || sym[3],
startLine: toDisplayLine(sym.startLine ?? sym[4]),
endLine: toDisplayLine(sym.endLine ?? sym[5]),
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
...(includeContent && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
...(methodMetadata ? { methodMetadata } : {}),
...(beanMetadata ? { bean: beanMetadata } : {}),
...(aopMetadata ? { aop: aopMetadata } : {}),

View file

@ -173,7 +173,8 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W
},
include_content: {
type: 'boolean',
description: 'Include full symbol source code (default: false)',
description:
'Include source text retained for matching symbols (default: false). The response reports contentAvailability; indexes built with content retention "none" explicitly report unavailable content.',
default: false,
},
maxTokens: {
@ -319,7 +320,8 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
},
include_content: {
type: 'boolean',
description: 'Include full symbol source code (default: false)',
description:
'Include source text retained for this symbol (default: false). The response reports contentAvailability; indexes built with content retention "none" explicitly report unavailable content.',
default: false,
},
maxTokens: {

View file

@ -21,6 +21,7 @@ import {
listRegisteredRepos,
getStoragePath,
registryPathEquals,
assertSafeStoragePath,
type RegistryEntry,
} from '../storage/repo-manager.js';
import {
@ -38,6 +39,7 @@ import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-sh
import { searchFTSFromLbug } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';
import { ftsDegradedWarning } from '../core/search/fts-indexes.js';
import { contentRetentionFromMeta } from '../core/content-retention.js';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { mountMCPEndpoints } from './mcp-http.js';
import { fileURLToPath } from 'url';
@ -559,6 +561,41 @@ export const resolveRegisteredRepoEntry = (
);
};
export interface SourceAvailability {
available: boolean;
reason?: 'content-retention' | 'checkout-missing';
}
/** Full-file endpoints require a live checkout; normalized index text is not source-viewer data. */
export const getSourceAvailability = async (
entry: Pick<RegistryEntry, 'path' | 'storagePath'>,
): Promise<SourceAvailability> => {
const meta = await loadMeta(entry.storagePath);
if (contentRetentionFromMeta(meta) !== 'full') {
return { available: false, reason: 'content-retention' };
}
try {
return (await fs.stat(entry.path)).isDirectory()
? { available: true }
: { available: false, reason: 'checkout-missing' };
} catch {
return { available: false, reason: 'checkout-missing' };
}
};
const sendSourceUnavailable = (
res: { status: (code: number) => { json: (body: any) => void } },
availability: SourceAvailability,
): void => {
const reason =
availability.reason === 'content-retention' ? 'content retention' : 'source checkout';
res.status(410).json({
error: `Full source is unavailable because the ${reason} is unavailable.`,
code: 'source-unavailable',
reason: availability.reason,
});
};
/**
* Handle a GET /api/file request body. Extracted from createServer's route
* registration so it can be unit-tested without spinning up an HTTP server
@ -578,6 +615,7 @@ export const handleFileRequest = async (
json: (body: any) => void;
},
repoPath: string,
availability: SourceAvailability = { available: true },
): Promise<void> => {
try {
// Type-confusion guard — req.query.path is `string | string[] | ParsedQs`.
@ -591,6 +629,11 @@ export const handleFileRequest = async (
}
const filePath = assertString(rawFilePath, 'path');
if (!availability.available) {
sendSourceUnavailable(res, availability);
return;
}
// Path-injection containment — inline at the sink with the canonical
// path.relative idiom that CodeQL's js/path-injection sanitizer
// recognizes. assertSafePath in validation.ts performs the equivalent
@ -993,9 +1036,15 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
try {
await assertSafeStoragePath(entry);
} catch (err: any) {
res.status(400).json({ error: err.message || 'Unsafe index storage path' });
return;
}
// Acquire repo lock — prevents deleting while analyze/embed is in flight
const lockKey = getStoragePath(entry.path);
const lockKey = entry.storagePath;
const lockErr = acquireRepoLock(lockKey);
if (lockErr) {
res.status(409).json({ error: lockErr });
@ -1009,7 +1058,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
} catch {}
// 1. Delete the .gitnexus index/storage directory
const storagePath = getStoragePath(entry.path);
const storagePath = entry.storagePath;
await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {});
// 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/.
@ -1319,7 +1368,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
await handleFileRequest(req, res, entry.path);
await handleFileRequest(req, res, entry.path, await getSourceAvailability(entry));
});
// Grep — regex search across file contents in the indexed repo
@ -1334,6 +1383,11 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
const sourceAvailability = await getSourceAvailability(entry);
if (!sourceAvailability.available) {
sendSourceUnavailable(res, sourceAvailability);
return;
}
// Type-confusion guard (CodeQL js/type-confusion-through-parameter-tampering):
// req.query.pattern is `string | string[] | ParsedQs` — without an explicit
// type check, the `.length` guard below counts array elements instead of

View file

@ -534,6 +534,11 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// stale 2), which is the rule above: above every claim, not above origin/main.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
//
// 68 -> 69 for Objective-C semantic graph extraction plus the objective-c
// ParsedFile side-channel carrying provider facts. A warm v68 cache would replay
// `.m`/`.mm`/classified `.h` chunks without Class/Protocol/Category/Method
// nodes, unresolved-message evidence, or post-resolution ObjC call facts.
//
// 68 -> 69 added #2969's JS/TS data-route-table decoratorRoutes. A warm v68
// cache would replay unchanged worker results without those routes. Version 70
// then adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so Java
@ -650,7 +655,12 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// `route-extractors/` and `workers/` module content — would close the missing-
// bump axis without invalidating on unrelated churn, and is the real follow-up.
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 77;
//
// 77 -> 78 merges the fork Objective-C semantic graph side-channel with
// upstream's v77 cache payload. A warm cache from either parent must miss:
// v69 lacks upstream route/Convex/object-literal captures, and v77 lacks the
// Objective-C provider-owned graph facts.
const SCHEMA_BUMP = 78;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -40,6 +40,12 @@ import {
type AnalyzerRunnerIdentity,
type RepoMeta,
} from './repo-meta.js';
import {
defaultStoragePath,
ensureStoragePathWritable,
InvalidStoragePathError,
validateConfiguredStoragePath,
} from './storage-resolver.js';
// Re-export the #2106 branch primitives (extracted to branch-index.ts, R10) so
// existing `repo-manager` import sites and tests keep working unchanged.
@ -52,6 +58,9 @@ export type { BranchSummary };
// cycle that made the extraction necessary. `LEGACY_METADATA_FILE` and
// `tryReadMetaFile` stay module-private here, exactly as before.
export { getStoragePath, INDEX_METADATA_FILE, isMissingFilesystemError, loadMeta };
export { ensureStoragePathWritable, InvalidStoragePathError };
export { CONTENT_RETENTION_SCHEMA_VERSION } from './repo-meta.js';
export type { ContentRetention, FtsProfile } from './repo-meta.js';
export type { AnalyzerRunnerIdentity, RepoMeta };
/**
@ -700,18 +709,33 @@ const readRegistryFile = async (strict: boolean): Promise<RegistryEntry[]> => {
}
return [];
}
// `storagePath` was not present in pre-external-storage registry files.
// Normalize only that legacy absence at the read boundary; malformed values
// remain visible to the strict destructive-operation safety checks below.
const entries = data.map((entry) =>
entry &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
typeof (entry as Record<string, unknown>).path === 'string' &&
(entry as Record<string, unknown>).storagePath === undefined
? {
...(entry as Record<string, unknown>),
storagePath: defaultStoragePath((entry as Record<string, unknown>).path as string),
}
: entry,
) as RegistryEntry[];
if (strict) {
// Reject the WHOLE registry, never filter the bad rows out. Dropping them
// would report the repos they name as unregistered, which is precisely
// the unreadable-as-missing answer this mode refuses to give.
const bad = data.findIndex((entry) => !isResolvableEntry(entry));
const bad = entries.findIndex((entry) => !isResolvableEntry(entry));
if (bad !== -1) {
throw new Error(
`${getGlobalRegistryPath()} entry ${bad} does not identify a repo — name and storagePath must be non-empty strings and path must be a string (registry is corrupt)`,
);
}
}
return sanitizeEntries(data as RegistryEntry[]);
return sanitizeEntries(entries);
} catch (err) {
if (strict) throw err;
return [];
@ -1365,9 +1389,10 @@ export class UnsafeStoragePathError extends Error {
/**
* Guard rail for destructive CLI paths (`remove` #664,
* `clean --all` #258, future MCP `remove` tool): verify that a
* registry entry's `storagePath` is the canonical `<repo>/.gitnexus`
* subfolder of its `path`. If not, throw {@link UnsafeStoragePathError}
* so the caller exits without touching disk.
* registry entry's `storagePath` names the registered index. Repository-local
* indexes are validated by their canonical `<repo>/.gitnexus` path; external
* slots must additionally prove their ownership through matching persisted
* metadata before a recursive deletion is allowed.
*
* Why this exists (#1003 review — @magyargergo):
* - `~/.gitnexus/registry.json` is a plain-text user-writable file.
@ -1384,21 +1409,41 @@ export class UnsafeStoragePathError extends Error {
* the registry field. But `clean --all` DOES iterate the registry
* and trust each entry's stored storagePath (same shape as
* `remove`), so this helper must be wired into that loop too.
* - `server/api.ts` recomputes storagePath from `getStoragePath(entry.path)`
* and so is likewise safe-by-construction.
* - An external slot is intentionally not constrained under a checkout. Its
* own metadata must bind both the source checkout path and the resolved
* storage path before it may be removed.
*
* Pure string check — does NOT require the paths to exist on disk.
* Windows: case-insensitive; POSIX: case-sensitive. Matches the
* comparison shape used elsewhere in this module.
* The local-path branch stays a pure string check for legacy entries. The
* external branch reads metadata so a hand-edited registry cannot redirect a
* destructive command to an arbitrary directory.
*/
export const assertSafeStoragePath = (entry: RegistryEntry): void => {
const expected = path.join(path.resolve(entry.path), '.gitnexus');
const actual = path.resolve(entry.storagePath);
const matches =
process.platform === 'win32'
? expected.toLowerCase() === actual.toLowerCase()
: expected === actual;
if (!matches) {
export const assertSafeStoragePath = async (entry: RegistryEntry): Promise<void> => {
const expected = defaultStoragePath(entry.path);
let actual: string;
try {
actual = validateConfiguredStoragePath(entry.storagePath);
} catch {
throw new UnsafeStoragePathError(entry, expected, path.resolve(entry.storagePath));
}
if (registryPathEquals(expected, actual)) return;
const root = path.parse(actual).root;
if (
registryPathEquals(actual, root) ||
registryPathEquals(actual, path.resolve(entry.path)) ||
registryPathEquals(path.dirname(actual), actual)
) {
throw new UnsafeStoragePathError(entry, expected, actual);
}
const meta = await loadMeta(actual);
if (
!meta ||
!meta.storagePath ||
!registryPathEquals(canonicalizePath(meta.repoPath), canonicalizePath(entry.path)) ||
!registryPathEquals(meta.storagePath, actual)
) {
throw new UnsafeStoragePathError(entry, expected, actual);
}
};

View file

@ -29,6 +29,7 @@ import fs from 'fs/promises';
import path from 'path';
import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js';
import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js';
import { resolveStoragePath } from './storage-resolver.js';
/** The `.gitnexus` directory name, relative to a repo root. */
export const GITNEXUS_DIR = '.gitnexus';
@ -37,6 +38,10 @@ export const INDEX_METADATA_FILE = 'gitnexus.json';
// with consumers that only know the pre-rename filename (see MIGRATION.md).
export const LEGACY_METADATA_FILE = 'meta.json';
export type ContentRetention = 'full' | 'symbol' | 'none';
export type FtsProfile = 'full' | 'symbol-no-file-content' | 'name-only';
export const CONTENT_RETENTION_SCHEMA_VERSION = 1;
/**
* Versioned receipt for the analyzer process that produced an index.
*
@ -83,8 +88,14 @@ export interface AnalyzerRunnerIdentity {
export interface RepoMeta {
repoPath: string;
/** Complete index directory selected for this successful analysis. */
storagePath?: string;
lastCommit: string;
indexedAt: string;
/** Missing on legacy metadata means the upstream-compatible `full` profile. */
contentRetention?: ContentRetention;
contentRetentionSchemaVersion?: number;
ftsProfile?: FtsProfile;
/**
* Analyzer/runtime receipt for the successful run represented by this
* metadata. Optional so indexes written by older GitNexus releases remain
@ -506,7 +517,7 @@ export interface RepoMeta {
* Used for local metadata and caches that are not committed.
*/
export const getStoragePath = (repoPath: string): string => {
return path.join(path.resolve(repoPath), GITNEXUS_DIR);
return resolveStoragePath(repoPath);
};
/**

View file

@ -0,0 +1,96 @@
import fs from 'fs';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';
export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
interface RegistryStorageEntry {
path?: unknown;
storagePath?: unknown;
}
export class InvalidStoragePathError extends Error {
readonly kind = 'InvalidStoragePathError' as const;
constructor(message: string) {
super(message);
this.name = 'InvalidStoragePathError';
}
}
const registryPath = (): string =>
path.join(process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'), 'registry.json');
const samePath = (left: string, right: string): boolean =>
process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
export const defaultStoragePath = (repoPath: string): string =>
path.join(path.resolve(repoPath), '.gitnexus');
export const validateConfiguredStoragePath = (value: string): string => {
if (value.length === 0) {
throw new InvalidStoragePathError(
`${STORAGE_PATH_ENV} must be an absolute, non-empty directory path when set.`,
);
}
if (value.includes('\0')) {
throw new InvalidStoragePathError(`${STORAGE_PATH_ENV} must not contain a NUL character.`);
}
if (!path.isAbsolute(value)) {
throw new InvalidStoragePathError(`${STORAGE_PATH_ENV} must be an absolute directory path.`);
}
return path.resolve(value);
};
const configuredStoragePath = (): string | undefined => {
const value = process.env[STORAGE_PATH_ENV];
return value === undefined ? undefined : validateConfiguredStoragePath(value);
};
const registeredStoragePath = (repoPath: string): string | undefined => {
let entries: unknown[];
try {
const data = JSON.parse(fs.readFileSync(registryPath(), 'utf-8'));
if (!Array.isArray(data)) return undefined;
entries = data;
} catch {
return undefined;
}
const resolvedRepoPath = path.resolve(repoPath);
for (const entry of entries) {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue;
const registryEntry = entry as RegistryStorageEntry;
if (typeof registryEntry.path !== 'string' || typeof registryEntry.storagePath !== 'string') {
continue;
}
if (!samePath(path.resolve(registryEntry.path), resolvedRepoPath)) continue;
try {
return validateConfiguredStoragePath(registryEntry.storagePath);
} catch {
return undefined;
}
}
return undefined;
};
/**
* Resolve one repository's complete index directory. The resolver is synchronous
* because it is used by path-only helpers throughout storage, branch placement,
* and CLI command setup. Registry reads are deliberately best-effort; a missing
* or legacy entry falls back to the established repository-local layout.
*/
export const resolveStoragePath = (repoPath: string): string =>
configuredStoragePath() ?? registeredStoragePath(repoPath) ?? defaultStoragePath(repoPath);
/** Ensure a selected index directory is usable before an analysis takes its lock. */
export const ensureStoragePathWritable = async (storagePath: string): Promise<void> => {
const resolved = validateConfiguredStoragePath(storagePath);
await fsp.mkdir(resolved, { recursive: true });
const stat = await fsp.stat(resolved);
if (!stat.isDirectory()) {
throw new InvalidStoragePathError(`Index storage path is not a directory: ${resolved}`);
}
await fsp.access(resolved, fs.constants.R_OK | fs.constants.W_OK);
};

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,23 @@
#import <Foundation/Foundation.h>
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

@ -105,7 +105,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) are NOT in node_modules — they
// Vendored grammars (c/dart/proto/swift/kotlin/objc) 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,217 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { statusCommand } from '../../src/cli/status.js';
import { getStoragePaths, loadMeta } from '../../src/storage/repo-manager.js';
const savedStoragePath = process.env.GITNEXUS_STORAGE_PATH;
const savedRetention = process.env.GITNEXUS_CONTENT_RETENTION;
const savedHome = process.env.GITNEXUS_HOME;
const temporaryPaths: string[] = [];
const makeTempDir = async (prefix: string): Promise<string> => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
temporaryPaths.push(directory);
return directory;
};
const restoreEnvironment = () => {
if (savedStoragePath === undefined) delete process.env.GITNEXUS_STORAGE_PATH;
else process.env.GITNEXUS_STORAGE_PATH = savedStoragePath;
if (savedRetention === undefined) delete process.env.GITNEXUS_CONTENT_RETENTION;
else process.env.GITNEXUS_CONTENT_RETENTION = savedRetention;
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
};
afterEach(async () => {
restoreEnvironment();
await Promise.all(
temporaryPaths.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })),
);
});
const recursiveSize = async (target: string): Promise<number> => {
const stat = await fs.lstat(target);
if (!stat.isDirectory()) return stat.size;
const entries = await fs.readdir(target);
return (
await Promise.all(entries.map((entry) => recursiveSize(path.join(target, entry))))
).reduce((total, size) => total + size, 0);
};
const readGraph = async (lbugPath: string) => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
try {
return await adapter.withLbugDb(
lbugPath,
async () => {
const [nodes, edges, files, functions, basicBlocks, basicBlockCount] = await Promise.all([
adapter.executeQuery('MATCH (n) RETURN count(n) AS count'),
adapter.executeQuery(
'MATCH (source)-[r:CodeRelation]->(target) RETURN count(r) AS count',
),
adapter.executeQuery(
"MATCH (n:File) WHERE n.filePath = 'src/fixture.ts' RETURN n.content AS content",
),
adapter.executeQuery(
"MATCH (n:Function) WHERE n.name = 'retentionFixture' RETURN n.content AS content, n.description AS description",
),
adapter.executeQuery('MATCH (n:BasicBlock) RETURN n.text AS text LIMIT 1'),
adapter.executeQuery('MATCH (n:BasicBlock) RETURN count(n) AS count'),
]);
const count = (rows: any[]) => Number(rows[0]?.count ?? rows[0]?.[0] ?? 0);
return {
nodes: count(nodes),
edges: count(edges),
fileContent: files[0]?.content ?? files[0]?.[0],
functionContent: functions[0]?.content ?? functions[0]?.[0],
functionDescription: functions[0]?.description ?? functions[0]?.[1],
basicBlockText: basicBlocks[0]?.text ?? basicBlocks[0]?.[0],
basicBlockCount: count(basicBlockCount),
};
},
{ readOnly: true },
);
} finally {
await adapter.closeLbug();
}
};
describe('external storage and content retention', () => {
it('keeps the full index outside the checkout, rebuilds on retention changes, and remains queryable after checkout removal', async () => {
const repo = await makeTempDir('gitnexus-run-analyze-retention-repo-');
const storage = await makeTempDir('gitnexus-run-analyze-retention-storage-');
const home = await makeTempDir('gitnexus-run-analyze-retention-home-');
await fs.mkdir(path.join(repo, 'src'));
await fs.writeFile(
path.join(repo, 'src/fixture.ts'),
`/** retention fixture documentation */\nexport function retentionFixture(enabled = true) {\n const payload = '${'fullRetentionPayload '.repeat(20_000)}';\n if (enabled) return payload;\n return '';\n}\n`,
);
process.env.GITNEXUS_HOME = home;
process.env.GITNEXUS_STORAGE_PATH = storage;
process.env.GITNEXUS_CONTENT_RETENTION = 'full';
const logs: string[] = [];
const options = {
force: true,
skipGit: true,
skipAgentsMd: true,
skipSkills: true,
workerPoolSize: 1,
pdg: true,
streamPdgEmit: true,
registryName: 'retention-fixture',
};
await runFullAnalysis(repo, options, {
onProgress: () => undefined,
onLog: (message) => logs.push(message),
});
const initialMeta = await loadMeta(storage);
const { lbugPath } = getStoragePaths(repo);
const fullGraph = await readGraph(lbugPath);
const fullDatabaseSize = await recursiveSize(lbugPath);
expect(initialMeta).toMatchObject({
repoPath: repo,
storagePath: storage,
contentRetention: 'full',
contentRetentionSchemaVersion: 1,
ftsProfile: 'full',
});
await expect(fs.access(path.join(repo, '.gitnexus'))).rejects.toThrow();
expect(fullGraph.fileContent).toContain('fullRetentionPayload');
expect(fullGraph.functionContent).toContain('retentionFixture');
expect(fullGraph.basicBlockCount).toBeGreaterThan(0);
process.env.GITNEXUS_CONTENT_RETENTION = 'symbol';
await runFullAnalysis(
repo,
{ ...options, force: false },
{
onProgress: () => undefined,
onLog: (message) => logs.push(message),
},
);
const symbolMeta = await loadMeta(storage);
const symbolGraph = await readGraph(lbugPath);
const symbolDatabaseSize = await recursiveSize(lbugPath);
expect(logs.join('\n')).toContain('forcing a full rebuild');
expect(symbolMeta).toMatchObject({
contentRetention: 'symbol',
contentRetentionSchemaVersion: 1,
ftsProfile: 'symbol-no-file-content',
});
expect(symbolGraph).toMatchObject({ nodes: fullGraph.nodes, edges: fullGraph.edges });
expect(symbolGraph.fileContent).toBeUndefined();
expect(symbolGraph.functionContent).toContain('retentionFixture');
expect(symbolGraph.basicBlockCount).toBe(fullGraph.basicBlockCount);
expect(symbolDatabaseSize).toBeLessThan(fullDatabaseSize);
process.env.GITNEXUS_CONTENT_RETENTION = 'none';
await runFullAnalysis(
repo,
{ ...options, force: false },
{
onProgress: () => undefined,
onLog: (message) => logs.push(message),
},
);
const noneMeta = await loadMeta(storage);
expect(noneMeta).toMatchObject({
contentRetention: 'none',
contentRetentionSchemaVersion: 1,
ftsProfile: 'name-only',
});
await fs.rm(repo, { recursive: true, force: true });
const output: string[] = [];
const logSpy = vi
.spyOn(console, 'log')
.mockImplementation((value) => output.push(String(value)));
try {
await statusCommand({ repo: 'retention-fixture', json: true });
} finally {
logSpy.mockRestore();
}
expect(JSON.parse(output[0])).toMatchObject({
storagePath: storage,
sourceAvailable: false,
status: 'source-unavailable',
index: { contentRetention: 'none' },
});
const backend = new LocalBackend();
try {
await backend.init();
const context = await backend.callTool('context', {
name: 'retentionFixture',
repo: 'retention-fixture',
include_content: true,
});
expect(context).toMatchObject({
status: 'found',
contentAvailability: {
requested: true,
profile: 'none',
available: false,
scope: 'none',
},
});
expect(context.symbol.content).toBeUndefined();
} finally {
await backend.disconnect();
}
const noneGraph = await readGraph(lbugPath);
expect(noneGraph).toMatchObject({ nodes: fullGraph.nodes, edges: fullGraph.edges });
expect(noneGraph.fileContent).toBeUndefined();
expect(noneGraph.functionContent).toBeUndefined();
expect(noneGraph.functionDescription).toBeUndefined();
expect(noneGraph.basicBlockCount).toBe(fullGraph.basicBlockCount);
expect(noneGraph.basicBlockText).toBeUndefined();
}, 120_000);
});

View file

@ -5,9 +5,10 @@
* instance, verifying cypher, context, impact, and query tools work
* end-to-end against seeded graph data with FTS indexes.
*/
import fs from 'fs/promises';
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { listRegisteredRepos, saveMeta } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import {
LOCAL_BACKEND_SEED_DATA,
@ -208,6 +209,59 @@ withTestLbugDB(
expect(validate.content).toBe('function validate() {}');
});
it('reports content capability for the default full profile', async () => {
const query = await backend.callTool('query', { query: 'login', include_content: true });
const context = await backend.callTool('context', { name: 'login', include_content: true });
expect(query.contentAvailability).toEqual({
requested: true,
profile: 'full',
available: true,
scope: 'full',
});
expect(context.contentAvailability).toEqual(query.contentAvailability);
expect(context.symbol.content).toBe('function login() {}');
});
it('does not disclose lingering source text when metadata says retention is none', async () => {
const storagePath = handle.tmpHandle.dbPath;
await saveMeta(storagePath, {
repoPath: '/test/repo',
storagePath,
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
contentRetention: 'none',
contentRetentionSchemaVersion: 1,
ftsProfile: 'name-only',
});
try {
const query = await backend.callTool('query', { query: 'login', include_content: true });
const context = await backend.callTool('context', {
name: 'login',
include_content: true,
});
const login = (query.process_symbols ?? []).find(
(symbol: any) => symbol.id === 'func:login',
);
expect(query.contentAvailability).toEqual({
requested: true,
profile: 'none',
available: false,
scope: 'none',
reason: 'Source-derived content is not retained by this index.',
});
expect(login?.content).toBeUndefined();
expect(context.contentAvailability).toEqual(query.contentAvailability);
expect(context.symbol.content).toBeUndefined();
} finally {
await Promise.all([
fs.rm(`${storagePath}/gitnexus.json`, { force: true }),
fs.rm(`${storagePath}/meta.json`, { force: true }),
]);
}
});
// PR #222 port: a symbol in MULTIPLE processes is what fully exercises the
// +1 positional shift in the batched STEP_IN_PROCESS aggregation — with a
// single process row, `row.pid ?? row[1]` succeeds whether the shift is

View file

@ -0,0 +1,504 @@
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 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 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,
});
return {
classContext: normalizeContext(classContext),
runTaskContext: normalizeContext(runTaskContext),
queryDefinitions: normalizeRows(queryResult.definitions),
protocolAndCategoryResult,
categoryHostResult,
};
} 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('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: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);
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.reason).toBe('macro receiver SY_OBJC_RECEIVER is dynamic');
const candidates = result.graph.nodes.find(
(node) =>
node.label === 'CodeElement' &&
node.properties.objectiveCKind === 'protocol-candidate-implementations',
);
expect(candidates?.properties.candidateImplementations).toEqual(
expect.arrayContaining(['objc:method:objc:class:SYModuleCaller:-:runTask:completion:']),
);
});
});
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);
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',
}),
]),
},
},
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'),
}),
});
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);
it('preserves Objective-C symbols and method snippets with symbol retention', async () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-provider-symbol-'));
const priorRetention = process.env.GITNEXUS_CONTENT_RETENTION;
try {
writeObjectiveCRepo(repoRoot);
git(repoRoot, 'git init');
gitCommitAll(repoRoot, 'Objective-C symbol retention fixture');
process.env.GITNEXUS_CONTENT_RETENTION = 'symbol';
await analyzeObjectiveCRepo(repoRoot, { force: true });
const surface = await readPersistedObjectiveCSurface(repoRoot);
expect(surface).toMatchObject({
classContext: {
status: 'found',
symbol: { uid: 'Class:objc:class:SYModuleCaller' },
incoming: {
declares: expect.arrayContaining([
expect.objectContaining({ filePath: 'SYModuleCaller.m' }),
]),
},
},
runTaskContext: {
status: 'found',
outgoing: {
calls: expect.arrayContaining([
expect.objectContaining({
uid: 'Method:objc:method:objc:class:SYBaseCaller:-:loadData:completion:',
}),
]),
},
},
protocolAndCategoryResult: expect.objectContaining({
markdown: expect.stringContaining('Protocol:objc:protocol:SYModuleRunnable'),
}),
categoryHostResult: expect.objectContaining({
markdown: expect.stringContaining('Category:objc:category:SYModuleCaller:Tracing'),
}),
});
const backend = new LocalBackend();
try {
const [methodContext, fileContext] = await Promise.all([
backend.callTool('context', {
uid: 'Method:objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
repo: repoRoot,
include_content: true,
}),
backend.callTool('context', {
uid: 'File:SYModuleCaller.m',
repo: repoRoot,
include_content: true,
}),
]);
expect(methodContext.symbol.content).toContain('runTask');
expect(fileContext.symbol.content).toBeUndefined();
} finally {
await backend.disconnect();
}
} finally {
if (priorRetention === undefined) delete process.env.GITNEXUS_CONTENT_RETENTION;
else process.env.GITNEXUS_CONTENT_RETENTION = priorRetention;
fs.rmSync(repoRoot, { recursive: true, force: true });
}
}, 180000);
});

View file

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

View file

@ -5,55 +5,61 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { FIXTURES } from './helpers.js';
const javacAvailable = spawnSync('javac', ['-version'], { stdio: 'ignore' }).status === 0;
const javacProbe = spawnSync('javac', ['-version'], { encoding: 'utf8' });
const javacMajorVersion = `${javacProbe.stdout}\n${javacProbe.stderr}`.match(/javac\s+(\d+)/)?.[1];
// Local records and enum declarations used by this fixture require Java 16+.
const javacSupportsLocalTypes = javacProbe.status === 0 && Number(javacMajorVersion) >= 16;
describe('Java local-type names emitted by javac', () => {
it.runIf(javacAvailable)('matches the identities asserted by the resolver fixture', () => {
const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-local-types-'));
const output = path.join(temp, 'classes');
mkdirSync(output);
it.runIf(javacSupportsLocalTypes)(
'matches the identities asserted by the resolver fixture',
() => {
const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-local-types-'));
const output = path.join(temp, 'classes');
mkdirSync(output);
try {
const sourceDir = path.join(FIXTURES, 'java-local-class-naming', 'src');
const sources = readdirSync(sourceDir)
.filter((name) => name.endsWith('.java'))
.map((name) => path.join(sourceDir, name));
execFileSync('javac', ['-d', output, ...sources]);
try {
const sourceDir = path.join(FIXTURES, 'java-local-class-naming', 'src');
const sources = readdirSync(sourceDir)
.filter((name) => name.endsWith('.java'))
.map((name) => path.join(sourceDir, name));
execFileSync('javac', ['-d', output, ...sources]);
expect(readdirSync(output).sort()).toEqual([
'Compact$1.class',
'Compact$1Local.class',
'Compact.class',
'Outer$1.class',
'Outer$1CtorHost$1Local.class',
'Outer$1CtorHost.class',
'Outer$1Cyclic.class',
'Outer$1InstanceLocal.class',
'Outer$1LambdaLocal.class',
'Outer$1Local$1.class',
'Outer$1Local.class',
'Outer$1NestedHost$Member$1Local.class',
'Outer$1NestedHost$Member.class',
'Outer$1NestedHost.class',
'Outer$1StaticLocal.class',
'Outer$2.class',
'Outer$2Local.class',
'Outer$3$1Local.class',
'Outer$3.class',
'Outer$3Local.class',
'Outer$4Local.class',
'Outer$Cyclic.class',
'Outer$MemberHost$1Local.class',
'Outer$MemberHost.class',
'Outer.class',
'Types$1.class',
'Types$1E.class',
'Types$1I.class',
'Types$1R.class',
'Types.class',
]);
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
expect(readdirSync(output).sort()).toEqual([
'Compact$1.class',
'Compact$1Local.class',
'Compact.class',
'Outer$1.class',
'Outer$1CtorHost$1Local.class',
'Outer$1CtorHost.class',
'Outer$1Cyclic.class',
'Outer$1InstanceLocal.class',
'Outer$1LambdaLocal.class',
'Outer$1Local$1.class',
'Outer$1Local.class',
'Outer$1NestedHost$Member$1Local.class',
'Outer$1NestedHost$Member.class',
'Outer$1NestedHost.class',
'Outer$1StaticLocal.class',
'Outer$2.class',
'Outer$2Local.class',
'Outer$3$1Local.class',
'Outer$3.class',
'Outer$3Local.class',
'Outer$4Local.class',
'Outer$Cyclic.class',
'Outer$MemberHost$1Local.class',
'Outer$MemberHost.class',
'Outer.class',
'Types$1.class',
'Types$1E.class',
'Types$1I.class',
'Types$1R.class',
'Types.class',
]);
} finally {
rmSync(temp, { recursive: true, force: true });
}
},
);
});

View file

@ -16,6 +16,12 @@ import {
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
SPRING_CONFIG_BINDINGS_FEATURE,
} from '../../src/core/ingestion/languages/java/analysis-features.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';
const FEATURES = [
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
@ -26,6 +32,7 @@ const FEATURES = [
SPRING_CONFIG_BINDINGS_FEATURE,
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
OBJECTIVE_C_PROVIDER_FEATURE,
] as const;
describe('analysis feature versions', () => {
@ -98,6 +105,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(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(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

@ -1,6 +1,15 @@
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { link, mkdir, readFile, readdir, symlink, unlink, writeFile } from 'node:fs/promises';
import {
link,
mkdir,
readFile,
readdir,
realpath,
symlink,
unlink,
writeFile,
} from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
@ -34,6 +43,10 @@ describe('analyzer runner identity', () => {
await writeFile(path.join(fixture.dbPath, 'package-lock.json'), '{"lockfileVersion":3}\n');
await writeFile(modulePath, 'export const analyzer = 1;\n');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
const resolvedModulePath = await realpath(modulePath);
const resolvedSourceRoot = await realpath(sourceRoot);
const resolvedManifestPath = await realpath(path.join(fixture.dbPath, 'package.json'));
const resolvedLockfilePath = await realpath(path.join(fixture.dbPath, 'package-lock.json'));
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
@ -50,18 +63,18 @@ describe('analyzer runner identity', () => {
libc: expect.any(String),
},
invokedArtifact: {
path: modulePath,
path: resolvedModulePath,
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
build: {
kind: 'source',
rootPath: sourceRoot,
rootPath: resolvedSourceRoot,
canonicalization: 'gitnexus-analyzer-build-v2',
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
dependencyRuntime: {
manifestPath: path.join(fixture.dbPath, 'package.json'),
lockfilePath: path.join(fixture.dbPath, 'package-lock.json'),
manifestPath: resolvedManifestPath,
lockfilePath: resolvedLockfilePath,
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4',
packageCount: 1,
artifactCount: 0,
@ -312,7 +325,7 @@ describe('analyzer runner identity', () => {
return bytes;
};
process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = protectedCache.dbPath;
process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = await realpath(protectedCache.dbPath);
_clearAnalyzerIdentityProcessCacheForTests();
expect(hashedBytes()).toBeGreaterThanOrEqual(64 * 1024);
_clearAnalyzerIdentityProcessCacheForTests();
@ -602,7 +615,7 @@ describe('analyzer runner identity', () => {
const withLock = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(withLock.dependencyRuntime.lockfilePath).toBe(ancestorLock);
expect(withLock.dependencyRuntime.lockfilePath).toBe(await realpath(ancestorLock));
expect(withLock.dependencyRuntime.digest).not.toBe(withoutLock.dependencyRuntime.digest);
} finally {
await fixture.cleanup();
@ -1095,7 +1108,9 @@ describe('analyzer runner identity', () => {
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(first.dependencyRuntime.lockfilePath).toBe(lockLink);
expect(first.dependencyRuntime.lockfilePath).toBe(
path.join(await realpath(path.dirname(lockLink)), path.basename(lockLink)),
);
await writeFile(lockTarget, '{"lockfileVersion":4,"changed":true}\n');
const targetChanged = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {

View file

@ -24,7 +24,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { handleFileRequest } from '../../src/server/api.js';
import { handleFileRequest, type SourceAvailability } from '../../src/server/api.js';
let tmpRoot: string;
@ -42,7 +42,10 @@ afterAll(async () => {
// Minimal express-shaped mock that captures status() / json() calls in a
// shape compatible with the handler's expected interface. Returns the
// final status (default 200 for naked res.json) and JSON body.
const invoke = async (query: Record<string, unknown>): Promise<{ status: number; body: any }> => {
const invoke = async (
query: Record<string, unknown>,
availability?: SourceAvailability,
): Promise<{ status: number; body: any }> => {
let capturedStatus = 200;
let capturedBody: any = undefined;
const res = {
@ -54,7 +57,7 @@ const invoke = async (query: Record<string, unknown>): Promise<{ status: number;
capturedBody = body;
},
};
await handleFileRequest({ query }, res, tmpRoot);
await handleFileRequest({ query }, res, tmpRoot, availability);
return { status: capturedStatus, body: capturedBody };
};
@ -71,6 +74,15 @@ describe('handleFileRequest — security wiring', () => {
expect(body.content).toBe('nested\n');
});
it('returns 410 when the selected retention profile cannot provide full source', async () => {
const { status, body } = await invoke(
{ path: 'hello.txt' },
{ available: false, reason: 'content-retention' },
);
expect(status).toBe(410);
expect(body).toMatchObject({ code: 'source-unavailable', reason: 'content-retention' });
});
it('returns 400 when path is missing', async () => {
const { status, body } = await invoke({});
expect(status).toBe(400);

View file

@ -152,16 +152,16 @@ describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => {
lastCommit: 'deadbee',
};
it('accepts an entry whose path and storagePath share the prefix', () => {
expect(() =>
it('accepts an entry whose path and storagePath share the prefix', async () => {
await expect(
assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }),
).not.toThrow();
).resolves.toBeUndefined();
});
it('rejects a mixed-form entry instead of deleting through it', () => {
expect(() =>
it('rejects a mixed-form entry instead of deleting through it', async () => {
await expect(
assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }),
).toThrow();
).rejects.toThrow();
});
});

View file

@ -0,0 +1,148 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
applyContentRetention,
contentRetentionFromEnvironment,
contentRetentionFromMeta,
contentRetentionMismatch,
ftsProfileForContentRetention,
} from '../../src/core/content-retention.js';
import { getFtsIndexes } from '../../src/core/search/fts-schema.js';
import { buildTestGraph } from '../helpers/test-graph.js';
const savedRetention = process.env.GITNEXUS_CONTENT_RETENTION;
afterEach(() => {
if (savedRetention === undefined) delete process.env.GITNEXUS_CONTENT_RETENTION;
else process.env.GITNEXUS_CONTENT_RETENTION = savedRetention;
});
const contentGraph = () =>
buildTestGraph([
{
id: 'File:src/index.ts',
label: 'File',
name: 'index.ts',
filePath: 'src/index.ts',
extra: { content: 'const retainedFileText = true;' },
},
{
id: 'Function:src/index.ts:run:1',
label: 'Function',
name: 'run',
filePath: 'src/index.ts',
startLine: 1,
endLine: 3,
extra: {
content: 'function run() { return retainedSymbolText; }',
description: 'source comment',
},
},
{
id: 'BasicBlock:src/index.ts:run:1:0',
label: 'BasicBlock',
name: 'block',
filePath: 'src/index.ts',
startLine: 1,
endLine: 1,
extra: { text: 'return retainedBlockText;', description: 'block annotation' },
},
]);
describe('content retention profiles', () => {
it('uses full when the environment is absent or blank and rejects explicit invalid values', () => {
delete process.env.GITNEXUS_CONTENT_RETENTION;
expect(contentRetentionFromEnvironment()).toBe('full');
process.env.GITNEXUS_CONTENT_RETENTION = ' ';
expect(contentRetentionFromEnvironment()).toBe('full');
process.env.GITNEXUS_CONTENT_RETENTION = 'archive';
expect(() => contentRetentionFromEnvironment()).toThrow(/GITNEXUS_CONTENT_RETENTION/);
});
it('keeps every existing text field in the full profile', () => {
const graph = contentGraph();
applyContentRetention(graph, 'full');
expect(graph.getNode('File:src/index.ts')?.properties.content).toContain('retainedFileText');
expect(graph.getNode('Function:src/index.ts:run:1')?.properties.content).toContain(
'retainedSymbolText',
);
expect(graph.getNode('BasicBlock:src/index.ts:run:1:0')?.properties.text).toContain(
'retainedBlockText',
);
});
it('removes file text but preserves symbol spans in the symbol profile', () => {
const graph = contentGraph();
applyContentRetention(graph, 'symbol');
expect(graph.getNode('File:src/index.ts')?.properties.content).toBeUndefined();
expect(graph.getNode('Function:src/index.ts:run:1')?.properties.content).toContain(
'retainedSymbolText',
);
expect(graph.getNode('Function:src/index.ts:run:1')?.properties.description).toBe(
'source comment',
);
});
it('removes every source-derived text field in the none profile', () => {
const graph = contentGraph();
applyContentRetention(graph, 'none');
for (const node of graph.nodes) {
expect(node.properties.content).toBeUndefined();
expect(node.properties.description).toBeUndefined();
}
expect(graph.getNode('BasicBlock:src/index.ts:run:1:0')?.properties.text).toBeUndefined();
});
it('treats legacy metadata as full and forces a rebuild for changed retention metadata', () => {
expect(contentRetentionFromMeta({})).toBe('full');
expect(contentRetentionMismatch({}, 'full')).toBe(false);
expect(contentRetentionMismatch({}, 'symbol')).toBe(true);
expect(contentRetentionMismatch({ contentRetention: 'full' }, 'full')).toBe(true);
expect(contentRetentionMismatch({ contentRetention: 'symbol' }, 'symbol')).toBe(true);
expect(
contentRetentionMismatch(
{
contentRetention: 'symbol',
contentRetentionSchemaVersion: 1,
ftsProfile: 'symbol-no-file-content',
},
'symbol',
),
).toBe(false);
expect(
contentRetentionMismatch(
{
contentRetention: 'symbol',
contentRetentionSchemaVersion: 2,
ftsProfile: 'symbol-no-file-content',
},
'symbol',
),
).toBe(true);
expect(
contentRetentionMismatch(
{
contentRetention: 'symbol',
contentRetentionSchemaVersion: 1,
},
'symbol',
),
).toBe(true);
});
it('selects FTS columns that never require discarded text', () => {
expect(ftsProfileForContentRetention('full')).toBe('full');
expect(ftsProfileForContentRetention('symbol')).toBe('symbol-no-file-content');
expect(ftsProfileForContentRetention('none')).toBe('name-only');
expect(getFtsIndexes('full').find((index) => index.table === 'File')?.properties).toEqual([
'name',
'content',
]);
expect(
getFtsIndexes('symbol-no-file-content').find((index) => index.table === 'File')?.properties,
).toEqual(['name']);
expect(getFtsIndexes('name-only').every((index) => index.properties.length === 1)).toBe(true);
});
});

View file

@ -7,12 +7,25 @@ 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,
} 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);
});
});
// ────────────────────────────────────────────────────────────────────────────
// resolveEmbeddingInstallPolicy (offline-first, #1153)
// ────────────────────────────────────────────────────────────────────────────

View file

@ -73,8 +73,15 @@ describe('COMPATIBLE_ABI gate', () => {
});
describe('GRAMMARS registry', () => {
it('covers all five grammars (swift/kotlin npm, dart/proto github, c npm)', () => {
expect(Object.keys(mod.GRAMMARS).sort()).toEqual(['c', 'dart', 'kotlin', 'proto', 'swift']);
it('covers all six grammars, including the vendored Objective-C grammar', () => {
expect(Object.keys(mod.GRAMMARS).sort()).toEqual([
'c',
'dart',
'kotlin',
'objc',
'proto',
'swift',
]);
expect(mod.GRAMMARS.swift.npm).toBe('tree-sitter-swift');
expect(mod.GRAMMARS.dart.github).toContain('tree-sitter-dart');
});
@ -82,6 +89,8 @@ describe('GRAMMARS registry', () => {
it('marks c and kotlin report-only (holds); swift/dart/proto 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

@ -2166,7 +2166,12 @@ describe.skipIf(SKIP_LSOF_PATH)(
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
{
// Owner classification is the subject here. Timeout-wrapper
// containment has dedicated coverage and can race these fake
// macOS process-table fixtures.
env: { ...hookEnv(binDir), GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled' },
},
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
@ -2651,7 +2656,12 @@ describe.skipIf(SKIP_LSOF_PATH)(
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
{
// Owner classification is the subject here. Timeout-wrapper
// containment has dedicated coverage and can race these fake
// macOS process-table fixtures.
env: { ...hookEnv(binDir), GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled' },
},
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();

View file

@ -218,6 +218,7 @@ describe('PARSE_CACHE_VERSION', () => {
// the pre-fix fan-out. This branch staged 64 above the claims live at the
// time (61, 62, 63); all three landed and cascaded main to 67, so 68 is the
// next free value above every claim at merge — the rule, re-applied.
// Version 69 also exists on the fork for Objective-C semantic graph facts.
// Version 69 added #2969's JS/TS data-route-table decoratorRoutes. Version 70
// adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so it is
// the next free value after both cache payload changes.
@ -240,14 +241,18 @@ describe('PARSE_CACHE_VERSION', () => {
// collided, because each re-checked once and neither re-checked after the
// other moved — which is why the rule is re-applied AT MERGE, not when the
// number is picked.
it('pins SCHEMA_BUMP to 77 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(77);
// Version 78 merges upstream's v77 payload with the fork's Objective-C
// semantic graph side-channel, so warm caches from either parent miss.
it('pins SCHEMA_BUMP to 78 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(78);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
// while being wrong, which is exactly how the 37/38 exact clashes landed.
// Every nearby historical or in-flight value is rejected, including 69,
// which carried the route-table payload before this merge.
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76]) {
for (const taken of [
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77,
]) {
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',

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,345 @@
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 } from '../../src/core/ingestion/languages/objective-c.js';
import {
buildObjectiveCSemanticGraph,
collectObjectiveCFacts,
objcCategoryQualifiedName,
objcClassQualifiedName,
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 by content without stealing plain C headers', () => {
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(
classifyObjectiveCFileContent('framework.h', '#import <Foundation/Foundation.h>\n'),
).toBe(true);
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);
`),
'functions.h',
);
expect(facts.functions.map((fn) => fn.name)).toEqual(
expect.arrayContaining(['add', 'returnsPointer']),
);
expect(facts.functions.map((fn) => fn.name)).not.toContain('callback');
});
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('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

@ -72,6 +72,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',
@ -134,6 +140,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

@ -1712,9 +1712,8 @@ describe('resolveRegistryEntry backward-compat with non-canonical stored paths (
// Guard rail against destroying more than the `.gitnexus/` subfolder.
// `~/.gitnexus/registry.json` is user-writable plain text, so a
// corrupted or hand-edited entry could put storagePath anywhere.
// These tests use synthetic `RegistryEntry` fixtures (no disk I/O)
// because the guard is a pure string check — it must not depend on
// the paths existing.
// Repository-local paths remain pure string checks. External slots require
// metadata ownership proof before they may be recursively deleted.
describe('assertSafeStoragePath (#1003)', () => {
const prefix = process.platform === 'win32' ? 'D:\\' : '/tmp/';
@ -1726,61 +1725,61 @@ describe('assertSafeStoragePath (#1003)', () => {
lastCommit: 'deadbee',
};
it('accepts the canonical <repo>/.gitnexus storage path', () => {
it('accepts the canonical <repo>/.gitnexus storage path', async () => {
const entry: RegistryEntry = {
...base,
storagePath: path.join(repoPath, '.gitnexus'),
};
expect(() => assertSafeStoragePath(entry)).not.toThrow();
await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined();
});
it('rejects when storagePath equals the repo path itself (would delete the code)', () => {
it('rejects when storagePath equals the repo path itself (would delete the code)', async () => {
const entry: RegistryEntry = {
...base,
storagePath: repoPath, // catastrophic: rm the working tree
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
});
it('rejects when storagePath is a parent of the repo path', () => {
it('rejects when storagePath is a parent of the repo path', async () => {
const entry: RegistryEntry = {
...base,
storagePath: path.dirname(repoPath), // also catastrophic
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
});
it('rejects when storagePath is empty (path.resolve falls back to cwd)', () => {
it('rejects when storagePath is empty (path.resolve falls back to cwd)', async () => {
const entry: RegistryEntry = {
...base,
storagePath: '', // path.resolve('') === process.cwd() — would rm cwd
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
});
it('rejects when storagePath points somewhere totally unrelated', () => {
it('rejects when storagePath points somewhere totally unrelated', async () => {
const entry: RegistryEntry = {
...base,
storagePath: `${prefix}some${path.sep}other${path.sep}place`,
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
});
it('rejects when storagePath is a sibling .gitnexus (right basename, wrong parent)', () => {
it('rejects when storagePath is a sibling .gitnexus (right basename, wrong parent)', async () => {
const entry: RegistryEntry = {
...base,
storagePath: path.join(`${prefix}different${path.sep}repo`, '.gitnexus'),
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
});
it('UnsafeStoragePathError carries the original entry + expected + actual paths', () => {
it('UnsafeStoragePathError carries the original entry + expected + actual paths', async () => {
const entry: RegistryEntry = {
...base,
storagePath: `${prefix}evil${path.sep}path`,
};
try {
assertSafeStoragePath(entry);
await assertSafeStoragePath(entry);
} catch (e) {
expect(e).toBeInstanceOf(UnsafeStoragePathError);
const err = e as UnsafeStoragePathError;
@ -1795,14 +1794,56 @@ describe('assertSafeStoragePath (#1003)', () => {
}
});
it('Windows: storagePath match is case-insensitive to match register/unregister semantics', () => {
it('Windows: storagePath match is case-insensitive to match register/unregister semantics', async () => {
if (process.platform !== 'win32') return;
const entry: RegistryEntry = {
...base,
storagePath: path.join(repoPath.toUpperCase(), '.GITNEXUS'),
};
// Should accept because Windows paths are case-insensitive.
expect(() => assertSafeStoragePath(entry)).not.toThrow();
await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined();
});
it('accepts an external slot only when its metadata binds it to the registry entry', async () => {
const repo = await createTempDir('gitnexus-external-repo-');
const storage = await createTempDir('gitnexus-external-storage-');
const entry: RegistryEntry = {
...base,
path: repo.dbPath,
storagePath: storage.dbPath,
};
try {
await saveMeta(storage.dbPath, {
repoPath: repo.dbPath,
storagePath: storage.dbPath,
lastCommit: 'deadbee',
indexedAt: new Date(0).toISOString(),
});
await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined();
} finally {
await Promise.all([repo.cleanup(), storage.cleanup()]);
}
});
it('rejects an external slot whose metadata belongs to another checkout', async () => {
const repo = await createTempDir('gitnexus-external-repo-');
const storage = await createTempDir('gitnexus-external-storage-');
const entry: RegistryEntry = {
...base,
path: repo.dbPath,
storagePath: storage.dbPath,
};
try {
await saveMeta(storage.dbPath, {
repoPath: path.join(repo.dbPath, 'other'),
storagePath: storage.dbPath,
lastCommit: 'deadbee',
indexedAt: new Date(0).toISOString(),
});
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
} finally {
await Promise.all([repo.cleanup(), storage.cleanup()]);
}
});
});

View file

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

View file

@ -362,6 +362,17 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
reachesDecoy: 'CUSTREC',
},
],
[
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

@ -528,6 +528,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

@ -66,6 +66,10 @@ describe('--skip-git CLI flag', () => {
...process.env,
HOME: gitnexusHome,
GITNEXUS_HOME: gitnexusHome,
// This suite tests repository-root selection, not extension installation.
// Keep child CLI runs offline so an unavailable FTS download cannot consume
// Vitest's per-test timeout.
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
};
try {
@ -128,6 +132,7 @@ describe('--skip-git CLI flag', () => {
...process.env,
HOME: gitnexusHome,
GITNEXUS_HOME: gitnexusHome,
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
};
try {
@ -194,6 +199,7 @@ describe('--skip-git CLI flag', () => {
...process.env,
HOME: gitnexusHome,
GITNEXUS_HOME: gitnexusHome,
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
};
}

View file

@ -0,0 +1,109 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import {
InvalidStoragePathError,
defaultStoragePath,
ensureStoragePathWritable,
resolveStoragePath,
validateConfiguredStoragePath,
} from '../../src/storage/storage-resolver.js';
const temporaryPaths: string[] = [];
const savedStoragePath = process.env.GITNEXUS_STORAGE_PATH;
const savedHome = process.env.GITNEXUS_HOME;
const makeTempDir = async (prefix: string): Promise<string> => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
temporaryPaths.push(dir);
return dir;
};
afterEach(async () => {
if (savedStoragePath === undefined) delete process.env.GITNEXUS_STORAGE_PATH;
else process.env.GITNEXUS_STORAGE_PATH = savedStoragePath;
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await Promise.all(
temporaryPaths.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe('storage resolver', () => {
it('keeps the repository-local default when no override or registration exists', async () => {
const repo = await makeTempDir('gitnexus-storage-resolver-repo-');
delete process.env.GITNEXUS_STORAGE_PATH;
process.env.GITNEXUS_HOME = await makeTempDir('gitnexus-storage-resolver-home-');
expect(resolveStoragePath(repo)).toBe(defaultStoragePath(repo));
});
it('uses an explicit absolute slot before the registered slot', async () => {
const repo = await makeTempDir('gitnexus-storage-resolver-repo-');
const home = await makeTempDir('gitnexus-storage-resolver-home-');
const registered = path.join(home, 'registered-index');
const explicit = path.join(home, 'explicit-index');
process.env.GITNEXUS_HOME = home;
await fs.writeFile(
path.join(home, 'registry.json'),
JSON.stringify([{ path: repo, storagePath: registered }]),
);
process.env.GITNEXUS_STORAGE_PATH = explicit;
expect(resolveStoragePath(repo)).toBe(explicit);
});
it('uses a registered external slot after the explicit override is absent', async () => {
const repo = await makeTempDir('gitnexus-storage-resolver-repo-');
const home = await makeTempDir('gitnexus-storage-resolver-home-');
const registered = path.join(home, 'registered-index');
delete process.env.GITNEXUS_STORAGE_PATH;
process.env.GITNEXUS_HOME = home;
await fs.writeFile(
path.join(home, 'registry.json'),
JSON.stringify([{ path: repo, storagePath: registered }]),
);
expect(resolveStoragePath(repo)).toBe(registered);
});
it('ignores malformed registry rows and falls back to the local layout', async () => {
const repo = await makeTempDir('gitnexus-storage-resolver-repo-');
const home = await makeTempDir('gitnexus-storage-resolver-home-');
delete process.env.GITNEXUS_STORAGE_PATH;
process.env.GITNEXUS_HOME = home;
await fs.writeFile(
path.join(home, 'registry.json'),
JSON.stringify([null, 1, [], { path: repo, storagePath: 1 }]),
);
expect(resolveStoragePath(repo)).toBe(defaultStoragePath(repo));
});
it.each(['', 'relative/index', `bad\0index`])(
'rejects invalid configured storage path %j',
(value) => {
expect(() => validateConfiguredStoragePath(value)).toThrow(InvalidStoragePathError);
},
);
it('creates independent external slots and verifies they are writable', async () => {
const root = await makeTempDir('gitnexus-storage-resolver-slots-');
const first = path.join(root, 'first');
const second = path.join(root, 'second');
await Promise.all([ensureStoragePathWritable(first), ensureStoragePathWritable(second)]);
await expect(fs.stat(first)).resolves.toMatchObject({ isDirectory: expect.any(Function) });
await expect(fs.stat(second)).resolves.toMatchObject({ isDirectory: expect.any(Function) });
});
it('fails before analysis when the target names a file instead of a writable directory', async () => {
const root = await makeTempDir('gitnexus-storage-resolver-file-');
const target = path.join(root, 'not-a-directory');
await fs.writeFile(target, 'not a directory');
await expect(ensureStoragePathWritable(target)).rejects.toThrow();
});
});

View file

@ -84,6 +84,19 @@ function writeExecutable(filePath: string, content: string) {
fs.writeFileSync(filePath, content, { mode: 0o755 });
}
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function writeImmediateShellExecutable(filePath: string, stdout: string, markerPath?: string) {
writeExecutable(
filePath,
`#!/bin/sh\n` +
(markerPath ? `: > ${shellQuote(markerPath)}\n` : '') +
`printf %s ${shellQuote(stdout)}\n`,
);
}
export function createHookToolDir(options: {
gitnexusStderr?: string;
gitnexusMarkerPath?: string;
@ -129,6 +142,12 @@ export function createHookToolDir(options: {
options.lsofOutputLines != null
? options.lsofOutputLines.join('\n') + (options.lsofOutputLines.length ? '\n' : '')
: (options.lsofOutput ?? '');
// The owner probe gives lsof one second. Use a shell fixture for immediate
// responses so a Node cold start cannot be misclassified as a timed-out
// (therefore fail-closed) owner under a heavily parallel test run. The
// delay, signal, and PID fixtures below still need a Node process.
const immediateLsof =
options.lsofSleepMs == null && options.lsofPidFile == null && !options.lsofIgnoreSigterm;
// Composable prologue: pidFile write MUST stay the first statement (see the
// option docs above); SIGTERM trap MUST be installed before any sleep.
const lsofPrologue =
@ -144,7 +163,11 @@ export function createHookToolDir(options: {
options.lsofSleepMs != null
? `${lsofPrologue}setTimeout(() => {}, ${Number(options.lsofSleepMs)});\n`
: `${lsofPrologue}process.stdout.write(${JSON.stringify(lsofOutput)});\nprocess.exit(0);\n`;
writeExecutable(path.join(binDir, 'lsof'), lsofBody);
if (immediateLsof) {
writeImmediateShellExecutable(path.join(binDir, 'lsof'), lsofOutput, options.lsofMarkerPath);
} else {
writeExecutable(path.join(binDir, 'lsof'), lsofBody);
}
const psBody =
options.psOutputByPid != null
@ -156,7 +179,11 @@ process.stdout.write(byPid[p] ?? '');
process.exit(0);
`
: `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(options.psOutput ?? '')});\nprocess.exit(0);\n`;
writeExecutable(path.join(binDir, 'ps'), psBody);
if (options.psOutputByPid == null) {
writeImmediateShellExecutable(path.join(binDir, 'ps'), options.psOutput ?? '');
} else {
writeExecutable(path.join(binDir, 'ps'), psBody);
}
return binDir;
}

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