diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index ee6f85511..26f14c3c6 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -162,7 +162,7 @@ jobs:
steps:
- id: gen
run: |
- TOTAL=3 # cross-platform (windows/macOS) shards per OS
+ TOTAL=4 # cross-platform (windows/macOS) shards per OS
COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds)
if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then
echo "shard totals must be >= 1" >&2; exit 1
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8a13abd94..6b4c35b2d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -15,7 +15,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
## End-to-end flow: index → graph → tools
-1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). The default DAG of 19 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
+1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). The default DAG of 21 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
2. **Persistence** — `repo-manager.ts` (paths, registry, LadybugDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
@@ -82,11 +82,11 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th
## Pipeline Phase DAG
-19 default phases are defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. `--pdg` adds `taintSummaries` and `callSummaries` (21 total).
+21 default phases are defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. `--pdg` adds `taintSummaries` and `callSummaries` (23 total).
```
-scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm]
- → crossFile → scopeResolution → [springAutoConfiguration, springAop]
+scan → structure → [springConfig, markdown, documents, cobol] → parse → [routes, tools, orm]
+ → crossFile → scopeResolution → [springAutoConfiguration, springAop, springDestinations]
→ pruneLocalSymbols → mro → springAopInheritance → di → communities → processes
```
@@ -97,7 +97,8 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
| `springConfig` | `spring-config.ts` | `structure` | Spring configuration-property nodes and metadata |
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
-| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
+| `documents` | `documents.ts` | `structure` | Section nodes and containment edges for static XAML declarations; no runtime call or inheritance edges |
+| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `documents`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS static route sources — see below) |
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
@@ -105,6 +106,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
| `springAutoConfiguration` | `spring-auto-configuration.ts` | `structure`, `scopeResolution` | DECLARES and CONDITIONAL_ON metadata for Spring configuration candidates |
| `springAop` | `spring-aop.ts` | `scopeResolution` | Direct declarative/advice ADVISED_BY edges and pointcut evidence |
+| `springDestinations` | `spring-destinations.ts` | `parse`, `scopeResolution`, `springConfig` | Destination nodes and publish/consume edges for Spring messaging |
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
| `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions |
diff --git a/README.md b/README.md
index bd426b15f..0e91344e6 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# GitNexus
+# GitNexus
@@ -681,6 +681,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
### Supported Languages
+XAML files are indexed as documents. Literal `x:Name`, `x:Key`, and `x:Class` declarations become searchable `Section` nodes with source ranges and file containment. Namespace aliases are supported. Runtime bindings, event-handler resolution, markup-extension keys, entity-containing declaration values, DTDs, and documents deeper than 100 elements are not interpreted; the file remains indexed when declaration extraction is unavailable. Run `gitnexus analyze --force` to add declaration nodes and their search indexes to an existing index.
+
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
| ---------- | ------- | -------------- | ------- | -------- | ---------------- | --------------------- | ------ | ---------- | ------------ |
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
diff --git a/gitnexus/scripts/cross-platform-shard.ts b/gitnexus/scripts/cross-platform-shard.ts
index 9e8f2d8e2..e10b13d04 100644
--- a/gitnexus/scripts/cross-platform-shard.ts
+++ b/gitnexus/scripts/cross-platform-shard.ts
@@ -47,6 +47,10 @@ export const WINDOWS_WEIGHTS_SEC: Readonly
> = {
// Re-measured after the analyze --watch e2e landed in #3072. The previous
// 361 s entry undercharged this suite and left shard 1 close to the watchdog.
'test/integration/cli-e2e.test.ts': 621,
+ // Measured on windows-latest run 35636340549 (#3210): all 11 assertions
+ // passed, but the previously unweighted suite consumed 593 s and pushed
+ // shard 3 past the unchanged 20-minute watchdog.
+ 'test/integration/xaml-search.test.ts': 593,
'test/integration/worker-pool.test.ts': 222,
'test/unit/incremental-vector-extension-ordering.test.ts': 87,
// Measured on Windows in run 34014266125 (#3190, 2026-09-06). These DB
diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts
index be3adf782..eaf44f278 100644
--- a/gitnexus/scripts/cross-platform-tests.ts
+++ b/gitnexus/scripts/cross-platform-tests.ts
@@ -157,6 +157,7 @@ const PLATFORM_LOGIC = [
// N-API addon which has known platform-specific behavior (Windows
// file-lock lag after close, macOS N-API destructor segfaults)
const LBUG_NATIVE = [
+ 'test/integration/xaml-search.test.ts',
'test/integration/skip-fts.test.ts',
'test/integration/lbug-core-adapter.test.ts',
'test/integration/lbug-vector-extension.test.ts',
diff --git a/gitnexus/src/core/ingestion/documents/registry.ts b/gitnexus/src/core/ingestion/documents/registry.ts
new file mode 100644
index 000000000..7c6d68e30
--- /dev/null
+++ b/gitnexus/src/core/ingestion/documents/registry.ts
@@ -0,0 +1,6 @@
+import type { DocumentParser } from './types.js';
+import { extractXamlDeclarations } from './xaml.js';
+
+export const DOCUMENT_PARSERS: ReadonlyMap = new Map([
+ ['.xaml', extractXamlDeclarations],
+]);
diff --git a/gitnexus/src/core/ingestion/documents/types.ts b/gitnexus/src/core/ingestion/documents/types.ts
new file mode 100644
index 000000000..b2818be26
--- /dev/null
+++ b/gitnexus/src/core/ingestion/documents/types.ts
@@ -0,0 +1,10 @@
+export interface DocumentDeclaration {
+ name: string;
+ description: string;
+ startIndex: number;
+ startLine: number;
+ endLine: number;
+ level: number;
+}
+
+export type DocumentParser = (source: string) => readonly DocumentDeclaration[];
diff --git a/gitnexus/src/core/ingestion/documents/xaml.ts b/gitnexus/src/core/ingestion/documents/xaml.ts
new file mode 100644
index 000000000..75ef7a495
--- /dev/null
+++ b/gitnexus/src/core/ingestion/documents/xaml.ts
@@ -0,0 +1,119 @@
+import { XMLParser, XMLValidator, type XMLMetaData } from 'fast-xml-parser';
+import type { DocumentDeclaration } from './types.js';
+
+const XAML_NAMESPACE = 'http://schemas.microsoft.com/winfx/2006/xaml';
+const DIRECTIVES = new Set(['Name', 'Key', 'Class']);
+const META = XMLParser.getMetaDataSymbol() as symbol;
+type XmlNode = Record & { [key: symbol]: XMLMetaData | undefined };
+
+function containsDoctypeDeclaration(source: string): boolean {
+ let cursor = 0;
+ while ((cursor = source.indexOf('<', cursor)) !== -1) {
+ if (source.startsWith('', cursor + 4);
+ if (end === -1) return false;
+ cursor = end + 3;
+ continue;
+ }
+ if (source.startsWith('', cursor + 9);
+ if (end === -1) return false;
+ cursor = end + 3;
+ continue;
+ }
+ if (source.startsWith('', cursor)) {
+ const end = source.indexOf('?>', cursor + 2);
+ if (end === -1) return false;
+ cursor = end + 2;
+ continue;
+ }
+ if (/^ {
+ let lo = 0;
+ let hi = lineStarts.length;
+ while (lo + 1 < hi) {
+ const mid = (lo + hi) >>> 1;
+ if (lineStarts[mid] <= offset) lo = mid;
+ else hi = mid;
+ }
+ return lo;
+ };
+ const declarations: DocumentDeclaration[] = [];
+ const stack = roots
+ .map((node) => ({ node, namespaces: new Map(), level: 1 }))
+ .reverse();
+ while (stack.length) {
+ const { node, namespaces: inherited, level } = stack.pop()!;
+ const tag = Object.keys(node).find((key) => key !== ':@' && !key.startsWith('#'));
+ if (!tag || !Array.isArray(node[tag])) continue;
+ if (level > 100) throw new Error('Maximum document depth exceeded');
+ const attributes = Object.entries((node[':@'] ?? {}) as Record);
+ const namespaceAttributes = attributes.filter(([key]) => key.startsWith('@_xmlns:'));
+ const namespaces = namespaceAttributes.length ? new Map(inherited) : inherited;
+ for (const [key, value] of namespaceAttributes) namespaces.set(key.slice(8), value);
+ const span = node[META];
+ for (const [attribute, value] of attributes) {
+ const parts = attribute.slice(2).split(':');
+ if (
+ parts.length !== 2 ||
+ namespaces.get(parts[0]) !== XAML_NAMESPACE ||
+ !DIRECTIVES.has(parts[1])
+ )
+ continue;
+ if (typeof value !== 'string' || value.includes('&')) continue;
+ // A leading {} escapes the entire remaining value, including further braces.
+ const escaped = value.startsWith('{}');
+ const name = escaped ? value.slice(2) : value;
+ if (!name.trim() || (!escaped && /[{}]/.test(name))) continue;
+ if (span?.startIndex === undefined || span.endIndex === undefined) continue;
+ declarations.push({
+ name,
+ description: `${tag} x:${parts[1]} declaration`,
+ startIndex: span.startIndex,
+ startLine: lineAt(span.startIndex),
+ endLine: lineAt(Math.max(span.startIndex, span.endIndex - 1)),
+ level,
+ });
+ }
+ const children = node[tag] as XmlNode[];
+ for (let i = children.length - 1; i >= 0; i--) {
+ stack.push({ node: children[i], namespaces, level: level + 1 });
+ }
+ }
+ return declarations;
+}
diff --git a/gitnexus/src/core/ingestion/pipeline-phases/documents.ts b/gitnexus/src/core/ingestion/pipeline-phases/documents.ts
new file mode 100644
index 000000000..99cd721d7
--- /dev/null
+++ b/gitnexus/src/core/ingestion/pipeline-phases/documents.ts
@@ -0,0 +1,66 @@
+import path from 'node:path';
+import { generateId } from '../../../lib/utils.js';
+import { logger } from '../../logger.js';
+import { DOCUMENT_PARSERS } from '../documents/registry.js';
+import { READ_CONCURRENCY, readFileContents } from '../filesystem-walker.js';
+import { getPhaseOutput, type PipelinePhase } from './types.js';
+import type { StructureOutput } from './structure.js';
+
+export const documentsPhase: PipelinePhase<{ sections: number; failures: number }> = {
+ name: 'documents',
+ deps: ['structure'],
+ async execute(ctx, deps) {
+ const { scannedFiles } = getPhaseOutput(deps, 'structure');
+ const files = scannedFiles.filter(
+ (file) =>
+ DOCUMENT_PARSERS.has(path.extname(file.path).toLowerCase()) &&
+ ctx.graph.getNode(generateId('File', file.path)),
+ );
+ let sections = 0;
+ let failures = 0;
+ // Fill the reader's concurrent window without retaining every document's text.
+ for (let start = 0; start < files.length; start += READ_CONCURRENCY) {
+ const batch = files.slice(start, start + READ_CONCURRENCY);
+ const contents = await readFileContents(
+ ctx.repoPath,
+ batch.map((file) => file.path),
+ );
+ for (const file of batch) {
+ const fileId = generateId('File', file.path);
+ const content = contents.get(file.path);
+ if (content === undefined) continue;
+ const parser = DOCUMENT_PARSERS.get(path.extname(file.path).toLowerCase())!;
+ let declarations;
+ try {
+ declarations = parser(content);
+ } catch {
+ failures++;
+ logger.warn(
+ { filePath: file.path },
+ 'Document declarations omitted: invalid or unsupported document',
+ );
+ continue;
+ }
+ for (const declaration of declarations) {
+ const { startIndex, ...properties } = declaration;
+ const id = generateId('Section', `${file.path}:${startIndex}:${declaration.description}`);
+ ctx.graph.addNode({
+ id,
+ label: 'Section',
+ properties: { ...properties, filePath: file.path },
+ });
+ ctx.graph.addRelationship({
+ id: generateId('CONTAINS', `${fileId}->${id}`),
+ type: 'CONTAINS',
+ sourceId: fileId,
+ targetId: id,
+ confidence: 1,
+ reason: 'document-declaration',
+ });
+ sections++;
+ }
+ }
+ }
+ return { sections, failures };
+ },
+};
diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts
index 99b7cc253..0b471bc73 100644
--- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts
+++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts
@@ -10,6 +10,7 @@
export { scanPhase, type ScanOutput } from './scan.js';
export { structurePhase, type StructureOutput } from './structure.js';
export { markdownPhase, type MarkdownOutput } from './markdown.js';
+export { documentsPhase } from './documents.js';
export { cobolPhase, type CobolOutput } from './cobol.js';
export { parsePhase, type ParseOutput } from './parse.js';
export { routesPhase, type RoutesOutput, type RouteEntry } from './routes.js';
diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts
index 503f3be75..4c711e8d9 100644
--- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts
+++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts
@@ -9,7 +9,7 @@
* from the original pipeline. The chunk loop is a memory optimization
* internal to this phase, not a phase boundary.
*
- * @deps structure, markdown, cobol
+ * @deps structure, markdown, documents, cobol
* @reads scannedFiles, allPaths, totalFiles (from structure)
* @writes graph (Symbol nodes, IMPORTS/CALLS/EXTENDS/IMPLEMENTS/ACCESSES edges)
* @output exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes,
@@ -113,7 +113,7 @@ export interface ParseOutput {
export const parsePhase: PipelinePhase = {
name: 'parse',
- deps: ['structure', 'markdown', 'cobol'],
+ deps: ['structure', 'markdown', 'documents', 'cobol'],
async execute(
ctx: PipelineContext,
diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts
index 6a4f3456e..e36345f69 100644
--- a/gitnexus/src/core/ingestion/pipeline.ts
+++ b/gitnexus/src/core/ingestion/pipeline.ts
@@ -27,6 +27,7 @@ import {
scanPhase,
structurePhase,
markdownPhase,
+ documentsPhase,
cobolPhase,
parsePhase,
routesPhase,
@@ -312,7 +313,7 @@ export interface PipelineOptions {
*
* Phase dependency graph:
*
- * scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm]
+ * scan → structure → [springConfig, markdown, documents, cobol] → parse → [routes, tools, orm]
* → crossFile → scopeResolution → [springAutoConfiguration, springAop,
* springDestinations] → pruneLocalSymbols
* → mro → springAopInheritance → di → communities → processes
@@ -334,6 +335,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
.register(structurePhase)
.register(springConfigPhase)
.register(markdownPhase)
+ .register(documentsPhase)
.register(cobolPhase)
.register(parsePhase)
.register(routesPhase)
diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts
index 99c7c3da5..f5a541243 100644
--- a/gitnexus/src/core/run-analyze.ts
+++ b/gitnexus/src/core/run-analyze.ts
@@ -2248,6 +2248,19 @@ async function runFullAnalysisInner(
// fast path because the previous analyze just wrote them
// (regression vs PR #1233 behavior).
const dirty = isWorkingTreeDirty(repoPath);
+ // Git can report clean after reverting/removing an indexed dirty file.
+ // Hidden Git flags also persist in dirtyPaths, so compare content rather
+ // than repeatedly reanalyzing unchanged candidates.
+ const indexedDirtyPaths = existingMeta.indexCoverage?.dirtyPaths ?? [];
+ let indexedContentChanged = false;
+ if (!dirty && indexedDirtyPaths.length > 0) {
+ const currentHashes = await computeFileHashes(repoPath, indexedDirtyPaths);
+ indexedContentChanged = indexedDirtyPaths.some(
+ (filePath) =>
+ !currentHashes.has(filePath) ||
+ currentHashes.get(filePath) !== existingMeta.fileHashes?.[filePath],
+ );
+ }
// Registration wrinkle around the fast path (#2264). A prior
// `analyze --name X` that hit a name collision writes meta.json (meta-save
// runs before registerRepo) then fails before registering, leaving the
@@ -2277,7 +2290,7 @@ async function runFullAnalysisInner(
// re-analysis whenever an index authored where FTS was unavailable was
// later read on a host where it loads — which is a legitimate, common
// state, and the invariant `analyzer-identity-cli.test.ts` pins.
- if (!dirty && !healUnregistered) {
+ if (!dirty && !indexedContentChanged && !healUnregistered) {
const processDetectionStamp =
existingMeta.processDetection ?? toProcessDetectionStamp(processDetectionBudget);
if (options.registryName) {
@@ -3654,6 +3667,18 @@ async function runFullAnalysisInner(
effectiveWriteCount: effectiveWriteSet.size,
deleteCount: filesToDelete.length,
});
+ // PARALLEL=false serializes CSV reading, not native COPY worker state.
+ // On a 32-thread host that scratch allocation exhausts the 256 MiB pool
+ // alongside retained FTS indexes, even for a one-row incremental write.
+ // Bound COPY only; restore the caller's setting before FTS construction.
+ // A failed COPY goes through the outer connection-cleanup handler.
+ const copyThreads = Number(
+ (await executeQuery("CALL current_setting('threads') RETURN *"))[0]?.threads,
+ );
+ if (!Number.isSafeInteger(copyThreads) || copyThreads < 1) {
+ throw new Error('Could not read the LadybugDB execution thread count before COPY');
+ }
+ await executeQuery('CALL threads=1');
await loadGraphToLbug(
subgraph,
pipelineResult.repoPath,
@@ -3667,6 +3692,7 @@ async function runFullAnalysisInner(
undefined,
contentRetention,
);
+ await executeQuery(`CALL threads=${copyThreads}`);
if (preserveDerivedLayer && derivedSnapshot.length > 0) {
await restoreDerivedRels(derivedSnapshot);
}
diff --git a/gitnexus/src/core/search/fts-schema.ts b/gitnexus/src/core/search/fts-schema.ts
index ae0688bce..09abac3dd 100644
--- a/gitnexus/src/core/search/fts-schema.ts
+++ b/gitnexus/src/core/search/fts-schema.ts
@@ -52,6 +52,8 @@ export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
{ table: 'Union', indexName: 'union_fts', properties: FTS_PROPERTIES },
{ table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES },
{ table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
+ // Markdown headings and static document declarations must be discoverable by name.
+ { table: 'Section', indexName: 'section_fts', properties: FTS_PROPERTIES },
// Route URLs (e.g. "/trpc/admin.setSettings") are keyword-
// searchable so agents can find a procedure from its URL without first
// resolving the URL → handlerSymbolId → Function node. Route has no
diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts
index 37da889de..fe996ffac 100644
--- a/gitnexus/src/mcp/local/local-backend.ts
+++ b/gitnexus/src/mcp/local/local-backend.ts
@@ -3820,7 +3820,7 @@ export class LocalBackend {
`
MATCH (n)
WHERE n.id IN $nodeIds
- RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
+ RETURN n.id AS id, n.name AS name, labels(n) AS nodeLabels, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
ORDER BY startLine, id
`,
{ nodeIds },
@@ -3835,7 +3835,7 @@ export class LocalBackend {
MATCH (n)
WHERE n.filePath = $filePath
AND NOT n.id STARTS WITH 'BasicBlock:'
- RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
+ RETURN n.id AS id, n.name AS name, labels(n) AS nodeLabels, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
ORDER BY startLine, id
LIMIT 3
`,
@@ -3844,10 +3844,11 @@ export class LocalBackend {
if (symbols.length > 0) {
for (const sym of symbols) {
+ const nodeLabels = sym.nodeLabels ?? sym[2];
results.push({
nodeId: sym.id || sym[0],
name: sym.name || sym[1],
- type: sym.type || sym[2],
+ type: Array.isArray(nodeLabels) ? nodeLabels[0] : nodeLabels,
filePath: sym.filePath || sym[3],
// Raw 0-based here — `bm25Search` is only called from `query()`,
// whose aggregation loop applies `toDisplayLine` once (see below).
diff --git a/gitnexus/src/storage/storage-resolver.ts b/gitnexus/src/storage/storage-resolver.ts
index 3df2df7b7..75384bd66 100644
--- a/gitnexus/src/storage/storage-resolver.ts
+++ b/gitnexus/src/storage/storage-resolver.ts
@@ -689,6 +689,20 @@ export const requireDeletableStoragePath = async (entry: {
const expectedStoragePath = defaultStoragePath(repoPath);
const storageIsLocal = isRepositoryLocalStoragePath(repoPath, actualStoragePath);
+ // Lookup may normalize extended-length paths, but deletion keeps the legacy
+ // fail-closed rule for a local registry entry with mixed namespace spellings.
+ if (
+ process.platform === 'win32' &&
+ storageIsLocal &&
+ expectedStoragePath.startsWith('\\\\?\\') !== actualStoragePath.startsWith('\\\\?\\')
+ ) {
+ throw new StorageDeletionError(
+ expectedStoragePath,
+ actualStoragePath,
+ undefined,
+ 'repository-local storage must use the same extended-length path spelling as the repository',
+ );
+ }
const comparableStorage = comparablePath(actualStoragePath);
const comparableRepo = comparablePath(repoPath);
const comparableRoot = comparablePath(path.parse(actualStoragePath).root);
diff --git a/gitnexus/test/integration/xaml-documents.test.ts b/gitnexus/test/integration/xaml-documents.test.ts
new file mode 100644
index 000000000..5575c506f
--- /dev/null
+++ b/gitnexus/test/integration/xaml-documents.test.ts
@@ -0,0 +1,70 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { createTempDir } from '../helpers/test-db.js';
+import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
+import { generateId } from '../../src/lib/utils.js';
+
+const NS = 'http://schemas.microsoft.com/winfx/2006/xaml';
+const fixtures: Awaited>[] = [];
+afterEach(async () => {
+ for (const fixture of fixtures.splice(0)) await fixture.cleanup();
+});
+
+describe('XAML pipeline documents (#3202)', () => {
+ it('indexes literal names and resource keys, with no invented runtime edges', async () => {
+ const repo = await createTempDir();
+ fixtures.push(repo);
+ await fs.writeFile(
+ path.join(repo.dbPath, 'Home.XAML'),
+ `\n\n\n\n\n\n`,
+ );
+ await fs.writeFile(
+ path.join(repo.dbPath, 'Broken.xaml'),
+ ``,
+ );
+ const result = await runPipelineFromRepo(repo.dbPath, () => {}, { skipGraphPhases: true });
+ const nodes = [...result.graph.iterNodes()];
+ expect(
+ nodes
+ .filter((node) => node.label === 'File')
+ .map((node) => node.properties.name)
+ .sort(),
+ ).toEqual(['Broken.xaml', 'Home.XAML']);
+ const declarations = nodes.filter((node) => node.label === 'Section');
+ expect(declarations.map((node) => node.properties.name).sort()).toEqual([
+ 'Accent',
+ 'App.Home',
+ 'EscapedAccent',
+ 'Save',
+ '{x:Type Button}',
+ ]);
+ expect(declarations.find((node) => node.properties.name === 'Save')?.properties.startLine).toBe(
+ 1,
+ );
+ expect(
+ [...result.graph.iterRelationships()].filter((edge) => edge.type !== 'CONTAINS'),
+ ).toEqual([]);
+ const fileId = generateId('File', 'Home.XAML');
+ expect(
+ [...result.graph.iterRelationshipsByType('CONTAINS')]
+ .filter((edge) => edge.sourceId === fileId)
+ .map((edge) => edge.targetId)
+ .sort(),
+ ).toEqual(declarations.map((node) => node.id).sort());
+
+ await fs.writeFile(
+ path.join(repo.dbPath, 'Home.XAML'),
+ ``,
+ );
+ const changed = await runPipelineFromRepo(repo.dbPath, () => {}, { skipGraphPhases: true });
+ expect(
+ [...changed.graph.iterNodes()]
+ .filter((node) => node.label === 'Section')
+ .map((node) => node.properties.name),
+ ).toEqual(['Renamed']);
+ await fs.unlink(path.join(repo.dbPath, 'Home.XAML'));
+ const deleted = await runPipelineFromRepo(repo.dbPath, () => {}, { skipGraphPhases: true });
+ expect([...deleted.graph.iterNodes()].filter((node) => node.label === 'Section')).toEqual([]);
+ }, 60_000);
+});
diff --git a/gitnexus/test/integration/xaml-search.test.ts b/gitnexus/test/integration/xaml-search.test.ts
new file mode 100644
index 000000000..cc071a443
--- /dev/null
+++ b/gitnexus/test/integration/xaml-search.test.ts
@@ -0,0 +1,573 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { createTempDir } from '../helpers/test-db.js';
+import { setupMiniRepo } from '../helpers/mini-repo.js';
+import { runFullAnalysis } from '../../src/core/run-analyze.js';
+import {
+ closeLbug,
+ dropFTSIndex,
+ executePrepared,
+ initLbug,
+} from '../../src/core/lbug/lbug-adapter.js';
+import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
+import { searchFTSFromLbug } from '../../src/core/search/bm25-index.js';
+import * as bm25 from '../../src/core/search/bm25-index.js';
+import * as pool from '../../src/core/lbug/pool-adapter.js';
+import { LocalBackend } from '../../src/mcp/local/local-backend.js';
+import * as adapter from '../../src/core/lbug/lbug-adapter.js';
+import * as ftsIndexes from '../../src/core/search/fts-indexes.js';
+import { batchInsertEmbeddings } from '../../src/core/embeddings/embedding-pipeline.js';
+import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js';
+
+const fixtures: Array<{ cleanup(): Promise }> = [];
+afterEach(async () => {
+ await closeLbug();
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ for (const fixture of fixtures.splice(0)) await fixture.cleanup();
+});
+
+describe('persisted XAML declarations (#3202)', () => {
+ it('bounds incremental COPY threads and restores the setting without dropping untouched FTS indexes', async () => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ const file = path.join(repo.dbPath, 'Threaded.xaml');
+ const document = (name: string) =>
+ ``;
+ await fs.writeFile(file, document('OriginalThreadedView'));
+ const options = { skipAgentsMd: true, registryName: 'copy-thread-fixture' };
+ const originalCopy = adapter.loadGraphToLbug;
+ const copyThreads: number[] = [];
+ vi.spyOn(adapter, 'loadGraphToLbug').mockImplementation(async (...args) => {
+ const rows = await adapter.executeQuery("CALL current_setting('threads') RETURN *");
+ copyThreads.push(Number(rows[0].threads));
+ return originalCopy(...args);
+ });
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ const statements = vi.spyOn(adapter, 'executeQuery');
+ const drops = vi.spyOn(ftsIndexes, 'dropSearchFTSIndexes');
+ await fs.writeFile(file, document('ChangedThreadedView'));
+ const result = await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ expect(result.incrementalStats?.writeMode).toBe('incremental');
+ expect(result.ftsSkipped).toBe(false);
+ expect(copyThreads).toEqual([expect.any(Number), 1]);
+ expect(copyThreads[0]).toBeGreaterThan(0);
+ expect(
+ statements.mock.calls
+ .map(([query]) => query)
+ .filter((query) => query.startsWith('CALL threads=')),
+ ).toEqual(['CALL threads=1', `CALL threads=${copyThreads[0]}`]);
+ expect(drops).toHaveBeenCalledOnce();
+ const tables = drops.mock.calls[0][2];
+ expect(tables).toBeDefined();
+ expect(tables?.has('Section')).toBe(true);
+ expect(tables?.has('Function')).toBe(false);
+ const { lbugPath } = getStoragePaths(repo.dbPath);
+ await initLbug(lbugPath);
+ expect((await searchFTSFromLbug('ChangedThreadedView')).results).toEqual(
+ expect.arrayContaining([expect.objectContaining({ filePath: 'Threaded.xaml' })]),
+ );
+ expect((await searchFTSFromLbug('OriginalThreadedView')).results).toEqual([]);
+ const rows = await adapter.executeQuery('CALL SHOW_INDEXES() RETURN *');
+ expect(rows.some((row) => adapter.indexRowName(row) === 'function_fts')).toBe(true);
+ }, 180_000);
+
+ it('closes the capped COPY connection and preserves the dirty receipt on failure', async () => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ const file = path.join(repo.dbPath, 'CopyFailure.xaml');
+ await fs.writeFile(
+ file,
+ '',
+ );
+ const options = { skipAgentsMd: true, registryName: 'copy-thread-failure-fixture' };
+ let initialThreads = 0;
+ const originalCopy = adapter.loadGraphToLbug;
+ const copy = vi.spyOn(adapter, 'loadGraphToLbug').mockImplementationOnce(async (...args) => {
+ const rows = await adapter.executeQuery("CALL current_setting('threads') RETURN *");
+ initialThreads = Number(rows[0].threads);
+ return originalCopy(...args);
+ });
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ expect(initialThreads).toBeGreaterThan(0);
+ copy.mockClear();
+ const { storagePath } = getStoragePaths(repo.dbPath);
+ const before = await loadMeta(storagePath);
+ expect(before).not.toBeNull();
+ const failure = new Error('Injected incremental COPY failure');
+ copy.mockImplementationOnce(async () => {
+ const rows = await adapter.executeQuery("CALL current_setting('threads') RETURN *");
+ expect(Number(rows[0].threads)).toBe(1);
+ throw failure;
+ });
+ await fs.writeFile(
+ file,
+ '',
+ );
+ await expect(runFullAnalysis(repo.dbPath, options, { onProgress() {} })).rejects.toBe(failure);
+ expect(copy).toHaveBeenCalledOnce();
+ expect(adapter.isLbugReady()).toBe(false);
+ expect(await loadMeta(storagePath)).toMatchObject({
+ incrementalInProgress: { phase: 'load-graph' },
+ indexedAt: before?.indexedAt,
+ fileHashes: before?.fileHashes,
+ });
+ copy.mockImplementationOnce(async (...args) => {
+ const rows = await adapter.executeQuery("CALL current_setting('threads') RETURN *");
+ expect(Number(rows[0].threads)).toBe(initialThreads);
+ return originalCopy(...args);
+ });
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ expect(copy).toHaveBeenCalledTimes(2);
+ expect((await loadMeta(storagePath))?.incrementalInProgress).toBeUndefined();
+ await initLbug(getStoragePaths(repo.dbPath).lbugPath);
+ expect((await searchFTSFromLbug('AfterFailure')).results).toEqual(
+ expect.arrayContaining([expect.objectContaining({ filePath: 'CopyFailure.xaml' })]),
+ );
+ }, 180_000);
+
+ it('persists and searches escaped literal resource keys without the escape prefix', async () => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ await fs.writeFile(
+ path.join(repo.dbPath, 'Escaped.xaml'),
+ '' +
+ '' +
+ '' +
+ '',
+ );
+ await runFullAnalysis(
+ repo.dbPath,
+ { skipAgentsMd: true, registryName: 'escaped-key-fixture' },
+ { onProgress() {} },
+ );
+ await initLbug(getStoragePaths(repo.dbPath).lbugPath);
+ const rows = await executePrepared(
+ 'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name ORDER BY name',
+ { file: 'Escaped.xaml' },
+ );
+ expect(rows).toEqual([
+ { id: expect.any(String), name: 'EscapedResource' },
+ { id: expect.any(String), name: '{LiteralResource}' },
+ ]);
+ for (const [index, term] of ['EscapedResource', 'LiteralResource'].entries()) {
+ expect((await searchFTSFromLbug(term)).results.flatMap((hit) => hit.nodeIds ?? [])).toContain(
+ rows[index].id,
+ );
+ }
+ }, 180_000);
+
+ it.each(['node-id', 'file-path'])(
+ 'preserves existing code symbol types through %s lookup',
+ async (lookup) => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ // Both symbols fit the existing file-path fallback's LIMIT 3.
+ await fs.writeFile(
+ path.join(repo.dbPath, 'src/search-shape.ts'),
+ 'export interface SearchShape {}\nexport function createSearchShape(): SearchShape { return {}; }\n',
+ );
+ const registryName = 'xaml-code-search-fixture';
+ await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, registryName }, { onProgress() {} });
+ if (lookup === 'file-path') {
+ const originalSearch = bm25.searchFTSFromLbug;
+ // Exercise the legacy file-only response using real FTS and graph queries.
+ vi.spyOn(bm25, 'searchFTSFromLbug').mockImplementation(async (...args) => {
+ const result = await originalSearch(...args);
+ return {
+ ...result,
+ results: result.results.map((hit) => ({ ...hit, nodeIds: undefined })),
+ };
+ });
+ }
+ const backend = new LocalBackend();
+ const queries = vi.spyOn(pool, 'executeParameterized');
+ try {
+ expect(await backend.init()).toBe(true);
+ for (const [name, type] of [
+ ['createSearchShape', 'Function'],
+ ['SearchShape', 'Interface'],
+ ]) {
+ const result = await backend.callTool('query', {
+ repo: registryName,
+ search_query: name,
+ });
+ expect(result.error).toBeUndefined();
+ expect([...result.definitions, ...result.process_symbols]).toContainEqual(
+ expect.objectContaining({ name, type, filePath: 'src/search-shape.ts' }),
+ );
+ }
+ const predicate =
+ lookup === 'node-id' ? 'WHERE n.id IN $nodeIds' : 'WHERE n.filePath = $filePath';
+ expect(
+ queries.mock.calls.some(
+ ([, statement]) =>
+ statement.includes(predicate) && statement.includes('labels(n) AS nodeLabels'),
+ ),
+ ).toBe(true);
+ } finally {
+ await backend.dispose();
+ }
+ },
+ 180_000,
+ );
+
+ it('keeps graph and embedding rows intact when dropping FTS indexes fails', async () => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ const file = path.join(repo.dbPath, 'Checkpoint.xaml');
+ await fs.writeFile(
+ file,
+ '',
+ );
+ const options = { skipAgentsMd: true, registryName: 'document-checkpoint-fixture' };
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
+ await initLbug(lbugPath);
+ const originalSections = await executePrepared(
+ 'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
+ { file: 'Checkpoint.xaml' },
+ );
+ expect(originalSections).toEqual([{ id: expect.any(String), name: 'OriginalView' }]);
+ const functions = await adapter.executeQuery(
+ 'MATCH (n:Function) RETURN n.id AS id ORDER BY id',
+ );
+ expect(functions.length).toBeGreaterThan(0);
+ const embedding = {
+ nodeId: String(functions[0].id),
+ chunkIndex: 0,
+ startLine: 0,
+ endLine: 1,
+ embedding: Array.from({ length: EMBEDDING_DIMS }, (_, i) => (i === 0 ? 1 : 0)),
+ contentHash: 'checkpoint-preserved',
+ };
+ await batchInsertEmbeddings(adapter.executeWithReusedStatement, [embedding]);
+ await adapter.flushWAL();
+ await closeLbug();
+ const meta = await loadMeta(storagePath);
+ if (!meta) throw new Error('Initial analysis did not persist metadata');
+ await saveMeta(storagePath, {
+ ...meta,
+ stats: { ...meta.stats, embeddings: 1 },
+ embeddingDims: EMBEDDING_DIMS,
+ });
+
+ const originalDrop = ftsIndexes.dropSearchFTSIndexes;
+ let dropped = false;
+ const failure = new Error('Injected FTS index drop failure');
+ vi.spyOn(ftsIndexes, 'dropSearchFTSIndexes').mockImplementation(async (...args) => {
+ await originalDrop(...args);
+ dropped = true;
+ throw failure;
+ });
+ const deletes = vi.spyOn(adapter, 'deleteNodesForFiles');
+ const copies = vi.spyOn(adapter, 'loadGraphToLbug');
+ await fs.writeFile(
+ file,
+ '',
+ );
+ await expect(runFullAnalysis(repo.dbPath, options, { onProgress() {} })).rejects.toBe(failure);
+ expect(dropped).toBe(true);
+ expect(deletes).not.toHaveBeenCalled();
+ expect(copies).not.toHaveBeenCalled();
+ expect(await loadMeta(storagePath)).toMatchObject({
+ incrementalInProgress: {
+ phase: 'effective-write-set',
+ toWriteCount: 1,
+ startedAt: expect.any(Number),
+ },
+ indexedAt: meta.indexedAt,
+ fileHashes: meta.fileHashes,
+ stats: { embeddings: 1 },
+ });
+ await initLbug(lbugPath);
+ expect(
+ await executePrepared(
+ 'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
+ { file: 'Checkpoint.xaml' },
+ ),
+ ).toEqual(originalSections);
+ expect(await adapter.executeQuery('MATCH (n:Function) RETURN n.id AS id ORDER BY id')).toEqual(
+ functions,
+ );
+ expect((await adapter.loadCachedEmbeddings()).embeddings).toEqual([embedding]);
+ }, 180_000);
+
+ it('repairs a missing Section index and reconciles Markdown headings sharing that index', async () => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ const file = path.join(repo.dbPath, 'Guide.md');
+ await fs.writeFile(file, '# InvoiceRecoveryGuide\n\nRecover invoices.\n');
+ const options = { skipAgentsMd: true, registryName: 'document-repair-fixture' };
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ const { lbugPath } = getStoragePaths(repo.dbPath);
+ await initLbug(lbugPath);
+ const before = await executePrepared(
+ 'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
+ { file: 'Guide.md' },
+ );
+ expect(before).toEqual([{ id: expect.any(String), name: 'InvoiceRecoveryGuide' }]);
+ await dropFTSIndex('Section', 'section_fts');
+ await closeLbug();
+ const repaired = await runFullAnalysis(
+ repo.dbPath,
+ { ...options, repairFts: true },
+ { onProgress() {} },
+ );
+ expect(repaired.ftsRepairedOnly).toBe(true);
+ await initLbug(lbugPath);
+ expect(
+ await executePrepared(
+ 'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
+ { file: 'Guide.md' },
+ ),
+ ).toEqual(before);
+ expect(
+ (await searchFTSFromLbug('InvoiceRecoveryGuide')).results.flatMap(
+ (result) => result.nodeIds ?? [],
+ ),
+ ).toContain(before[0].id);
+ await closeLbug();
+ await fs.writeFile(file, '# PaymentRecoveryGuide\n\nRecover payments.\n');
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ await initLbug(lbugPath);
+ expect((await searchFTSFromLbug('InvoiceRecoveryGuide')).results).toEqual([]);
+ const renamed = await executePrepared(
+ 'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
+ { file: 'Guide.md' },
+ );
+ expect(renamed).toEqual([{ id: expect.any(String), name: 'PaymentRecoveryGuide' }]);
+ expect(
+ (await searchFTSFromLbug('PaymentRecoveryGuide')).results.flatMap(
+ (result) => result.nodeIds ?? [],
+ ),
+ ).toContain(renamed[0].id);
+ await closeLbug();
+ await fs.unlink(file);
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ await initLbug(lbugPath);
+ expect((await searchFTSFromLbug('PaymentRecoveryGuide')).results).toEqual([]);
+ expect(
+ await executePrepared('MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id', {
+ file: 'Guide.md',
+ }),
+ ).toEqual([]);
+ await closeLbug();
+ expect((await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate).toBe(
+ true,
+ );
+ }, 180_000);
+
+ it.each(['--assume-unchanged', '--skip-worktree'])(
+ 'hashes %s candidates without repeatedly reanalyzing unchanged content',
+ async (flag) => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ const file = path.join(repo.dbPath, 'Flagged.xaml');
+ const original =
+ '';
+ await fs.writeFile(file, original);
+ execFileSync('git', ['add', 'Flagged.xaml'], { cwd: repo.dbPath });
+ execFileSync(
+ 'git',
+ [
+ '-c',
+ 'user.name=test',
+ '-c',
+ 'user.email=t@t',
+ '-c',
+ 'commit.gpgsign=false',
+ 'commit',
+ '-qm',
+ 'Add view',
+ ],
+ { cwd: repo.dbPath },
+ );
+ execFileSync('git', ['update-index', flag, 'Flagged.xaml'], { cwd: repo.dbPath });
+ const options = { skipAgentsMd: true, registryName: 'flagged-xaml-fixture' };
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
+ expect((await loadMeta(storagePath))?.indexCoverage?.dirtyPaths).toContain('Flagged.xaml');
+ expect(
+ (await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate,
+ ).toBe(true);
+ await fs.writeFile(file, original.replace('FlaggedView', 'ChangedView'));
+ expect(
+ (await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate,
+ ).not.toBe(true);
+ await initLbug(lbugPath);
+ try {
+ expect(
+ await executePrepared(
+ 'MATCH (n:Section) WHERE n.filePath = $file RETURN n.name AS name',
+ { file: 'Flagged.xaml' },
+ ),
+ ).toEqual([{ name: 'ChangedView' }]);
+ } finally {
+ await closeLbug();
+ }
+ expect(
+ (await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate,
+ ).toBe(true);
+ },
+ 180_000,
+ );
+
+ it('reconciles an indexed edit restored to HEAD and keeps legacy clean metadata eligible for the fast path', async () => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ const file = path.join(repo.dbPath, 'Tracked.xaml');
+ const original =
+ '';
+ await fs.writeFile(file, original);
+ execFileSync('git', ['add', 'Tracked.xaml'], { cwd: repo.dbPath });
+ execFileSync(
+ 'git',
+ [
+ '-c',
+ 'user.name=test',
+ '-c',
+ 'user.email=t@t',
+ '-c',
+ 'commit.gpgsign=false',
+ 'commit',
+ '-qm',
+ 'Add view',
+ ],
+ { cwd: repo.dbPath },
+ );
+ const options = { skipAgentsMd: true, registryName: 'tracked-xaml-fixture' };
+ await fs.writeFile(file, original.replace('OriginalView', 'EditedView'));
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
+ expect((await loadMeta(storagePath))?.indexCoverage?.dirtyPaths).toContain('Tracked.xaml');
+ await fs.writeFile(file, original);
+ const restored = await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ expect(restored.alreadyUpToDate).not.toBe(true);
+ await initLbug(lbugPath);
+ try {
+ expect(
+ await executePrepared('MATCH (n:Section) WHERE n.filePath = $file RETURN n.name AS name', {
+ file: 'Tracked.xaml',
+ }),
+ ).toEqual([{ name: 'OriginalView' }]);
+ } finally {
+ await closeLbug();
+ }
+ const meta = await loadMeta(storagePath);
+ expect(meta?.indexCoverage?.dirtyPaths).toEqual([]);
+ // Older snapshots did not record dirtyPaths. Their clean fast path is unchanged.
+ if (!meta?.indexCoverage) throw new Error('Expected coverage metadata');
+ delete meta.indexCoverage.dirtyPaths;
+ await saveMeta(storagePath, meta);
+ expect((await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate).toBe(
+ true,
+ );
+ }, 180_000);
+
+ it('supports FTS and MCP context, then removes stale declarations on incremental change and delete', async () => {
+ const repo = await setupMiniRepo();
+ const home = await createTempDir();
+ fixtures.push(repo, home);
+ vi.stubEnv('GITNEXUS_HOME', home.dbPath);
+ const file = path.join(repo.dbPath, 'View.xaml');
+ const document = (name: string) =>
+ ``;
+ await fs.writeFile(file, document('SubmitInvoiceButton'));
+ const options = { skipAgentsMd: true, registryName: 'xaml-fixture' };
+ const first = await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ expect(first.ftsSkipped).toBe(false);
+ const { lbugPath } = getStoragePaths(repo.dbPath);
+ const readSections = async () => {
+ await initLbug(lbugPath);
+ try {
+ return await executePrepared(
+ 'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name, n.startLine AS startLine',
+ { file: 'View.xaml' },
+ );
+ } finally {
+ await closeLbug();
+ }
+ };
+ const sections = await readSections();
+ expect(sections).toEqual([
+ { id: expect.any(String), name: 'SubmitInvoiceButton', startLine: 0 },
+ ]);
+ await initLbug(lbugPath);
+ const search = await searchFTSFromLbug('SubmitInvoiceButton');
+ expect(search.ftsAvailable).toBe(true);
+ expect(search.results.some((result) => result.filePath === 'View.xaml')).toBe(true);
+ expect(search.results.flatMap((result) => result.nodeIds ?? [])).toContain(sections[0].id);
+ await closeLbug();
+ const backend = new LocalBackend();
+ try {
+ expect(await backend.init()).toBe(true);
+ const query = await backend.callTool('query', {
+ repo: options.registryName,
+ search_query: 'SubmitInvoiceButton',
+ });
+ expect(query.error).toBeUndefined();
+ expect(query.definitions).toContainEqual(
+ expect.objectContaining({
+ id: sections[0].id,
+ name: 'SubmitInvoiceButton',
+ type: 'Section',
+ filePath: 'View.xaml',
+ startLine: 1,
+ endLine: 1,
+ }),
+ );
+ const declaration = query.definitions.find(
+ (definition: { name: string }) => definition.name === 'SubmitInvoiceButton',
+ );
+ const context = await backend.callTool('context', {
+ repo: options.registryName,
+ uid: declaration.id,
+ });
+ expect(context.error).toBeUndefined();
+ expect(JSON.stringify(context)).toContain('SubmitInvoiceButton');
+ } finally {
+ await backend.dispose();
+ }
+
+ await fs.writeFile(file, document('CancelInvoiceButton'));
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ expect(await readSections()).toEqual([
+ { id: expect.any(String), name: 'CancelInvoiceButton', startLine: 0 },
+ ]);
+ await initLbug(lbugPath);
+ expect((await searchFTSFromLbug('SubmitInvoiceButton')).results).toEqual([]);
+ expect(
+ (await searchFTSFromLbug('CancelInvoiceButton')).results.flatMap(
+ (result) => result.nodeIds ?? [],
+ ),
+ ).toContain(sections[0].id);
+ await closeLbug();
+ await fs.unlink(file);
+ await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ expect(await readSections()).toEqual([]);
+ await initLbug(lbugPath);
+ expect((await searchFTSFromLbug('CancelInvoiceButton')).results).toEqual([]);
+ await closeLbug();
+ const clean = await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
+ expect(clean.alreadyUpToDate).toBe(true);
+ }, 180_000);
+});
diff --git a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts
index 0e4730a00..35bf15915 100644
--- a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts
+++ b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts
@@ -74,6 +74,7 @@ import {
type RegistryEntry,
} from '../../src/storage/repo-manager.js';
import { resolveRegisteredRepoEntry } from '../../src/server/api.js';
+import { requireDeletableStoragePath } from '../../src/storage/storage-resolver.js';
/**
* The lookup every registry consumer performs — `resolveRegistryEntry`,
@@ -164,6 +165,19 @@ describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => {
assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }),
).rejects.toThrow();
});
+
+ itOnWindows.each([
+ ['D:\\Projects\\repo', '\\\\?\\D:\\Projects\\repo\\.gitnexus'],
+ ['\\\\server\\share\\repo', '\\\\?\\UNC\\server\\share\\repo\\.gitnexus'],
+ ['\\\\?\\UNC\\server\\share\\repo', '\\\\server\\share\\repo\\.gitnexus'],
+ ])(
+ 'rejects mixed namespace spellings in the shared deletion guard: %s',
+ async (repoPath, storagePath) => {
+ await expect(requireDeletableStoragePath({ path: repoPath, storagePath })).rejects.toThrow(
+ 'same extended-length path spelling',
+ );
+ },
+ );
});
// The consumer surface the fix exists for: an MCP `repo` argument or an
diff --git a/gitnexus/test/unit/cross-platform-shard.test.ts b/gitnexus/test/unit/cross-platform-shard.test.ts
index 66ea6a430..1cd9aacde 100644
--- a/gitnexus/test/unit/cross-platform-shard.test.ts
+++ b/gitnexus/test/unit/cross-platform-shard.test.ts
@@ -22,7 +22,7 @@ import {
} from '../../scripts/cross-platform-shard.js';
import { ALL_CROSS_PLATFORM } from '../../scripts/cross-platform-tests.js';
-const SHARD_TOTAL = 3;
+const SHARD_TOTAL = 4;
/** Every shard of a split, as file lists. */
const allShards = (files: readonly string[], total: number): readonly (readonly string[])[] =>
@@ -34,6 +34,9 @@ describe('cross-platform shard partition', () => {
// in the scheduling table. Keep the observed profile independent of the
// table so deleting a weight cannot make this regression pass again.
const observed: Readonly> = {
+ // Run 35636340549: all 11 tests passed in 593 s, but the missing weight
+ // packed this suite into a shard that reached the 20-minute watchdog.
+ 'test/integration/xaml-search.test.ts': 593,
'test/integration/skills-e2e.test.ts': 550,
'test/unit/incremental-index-extension-dml-gate.test.ts': 414,
'test/integration/fts-extension-e2e.test.ts': 380,
@@ -50,6 +53,7 @@ describe('cross-platform shard partition', () => {
const shards = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL);
const heavyweightLocations = [
'test/integration/cli-e2e.test.ts',
+ 'test/integration/xaml-search.test.ts',
'test/integration/skills-e2e.test.ts',
'test/unit/incremental-index-extension-dml-gate.test.ts',
].map((file) => shards.findIndex((files) => files.includes(file)));
diff --git a/gitnexus/test/unit/fts-schema.test.ts b/gitnexus/test/unit/fts-schema.test.ts
index 37abcba70..b6bc5e328 100644
--- a/gitnexus/test/unit/fts-schema.test.ts
+++ b/gitnexus/test/unit/fts-schema.test.ts
@@ -23,6 +23,14 @@ const COLUMNS_BY_TABLE = new Map>(
);
describe('FTS_INDEXES', () => {
+ it('indexes document declarations as individually discoverable sections', () => {
+ expect(FTS_INDEXES.find((index) => index.table === 'Section')).toEqual({
+ table: 'Section',
+ indexName: 'section_fts',
+ properties: ['name', 'content', 'description'],
+ });
+ });
+
it('keeps File at name+content only (File has no description column)', () => {
const file = FTS_INDEXES.find((i) => i.table === 'File');
expect(file).toMatchObject({ properties: ['name', 'content'] });
diff --git a/gitnexus/test/unit/ingestion/documents-phase.test.ts b/gitnexus/test/unit/ingestion/documents-phase.test.ts
new file mode 100644
index 000000000..55fcf2431
--- /dev/null
+++ b/gitnexus/test/unit/ingestion/documents-phase.test.ts
@@ -0,0 +1,113 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
+import * as walker from '../../../src/core/ingestion/filesystem-walker.js';
+import { documentsPhase } from '../../../src/core/ingestion/pipeline-phases/documents.js';
+import type { StructureOutput } from '../../../src/core/ingestion/pipeline-phases/structure.js';
+import type { PipelineContext } from '../../../src/core/ingestion/pipeline-phases/types.js';
+import { generateId } from '../../../src/lib/utils.js';
+import { createTempDir } from '../../helpers/test-db.js';
+
+describe('document phase read batching', () => {
+ let fixture: Awaited>;
+ let ctx: PipelineContext;
+
+ beforeEach(async () => {
+ fixture = await createTempDir();
+ ctx = {
+ repoPath: fixture.dbPath,
+ graph: createKnowledgeGraph(),
+ onProgress: () => {},
+ pipelineStart: Date.now(),
+ };
+ });
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await fixture.cleanup();
+ });
+
+ function structure(paths: string[]) {
+ const output: StructureOutput = {
+ scannedFiles: paths.map((file) => ({ path: file, size: 128 })),
+ allPaths: paths,
+ allPathSet: new Set(paths),
+ totalFiles: paths.length,
+ };
+ for (const file of paths.filter((file) => file !== 'excluded.xaml')) {
+ ctx.graph.addNode({
+ id: generateId('File', file),
+ label: 'File',
+ properties: { name: file, filePath: file },
+ });
+ }
+ return new Map([['structure', { phaseName: 'structure', output, durationMs: 0 }]]);
+ }
+
+ it('reads bounded concurrent batches and preserves exact output across failures', async () => {
+ const files = Array.from({ length: 65 }, (_, i) => `View${i}.XAML`);
+ for (const [i, file] of files.entries()) {
+ await fs.writeFile(
+ path.join(fixture.dbPath, file),
+ ``,
+ );
+ }
+ await fs.writeFile(path.join(fixture.dbPath, 'broken.xaml'), '');
+ const read = vi.spyOn(walker, 'readFileContents');
+ const readFile = fs.readFile;
+ let activeReads = 0;
+ let peakReads = 0;
+ vi.spyOn(fs, 'readFile').mockImplementation(async (...args) => {
+ activeReads++;
+ peakReads = Math.max(peakReads, activeReads);
+ try {
+ return await readFile(...args);
+ } finally {
+ activeReads--;
+ }
+ });
+ const result = await documentsPhase.execute(
+ ctx,
+ structure([...files, 'broken.xaml', 'missing.xaml', 'excluded.xaml', 'note.txt']),
+ );
+
+ expect(read.mock.calls.map(([, paths]) => paths.length)).toEqual([32, 32, 3]);
+ expect(read.mock.calls.flatMap(([, paths]) => paths)).toEqual([
+ ...files,
+ 'broken.xaml',
+ 'missing.xaml',
+ ]);
+ expect(peakReads).toBeGreaterThan(1);
+ expect(peakReads).toBeLessThanOrEqual(walker.READ_CONCURRENCY);
+ expect(activeReads).toBe(0);
+ expect(result).toEqual({ sections: 65, failures: 1 });
+ const sections = [...ctx.graph.iterNodes()].filter((node) => node.label === 'Section');
+ expect(sections.map((node) => node.properties.name).sort()).toEqual(
+ Array.from({ length: 65 }, (_, i) => `View${i}`).sort(),
+ );
+ expect([...ctx.graph.iterRelationships()].map((edge) => edge.targetId).sort()).toEqual(
+ sections.map((node) => node.id).sort(),
+ );
+ for (const node of sections) {
+ const fileId = generateId('File', node.properties.filePath!);
+ expect([...ctx.graph.iterRelationships()]).toContainEqual({
+ id: generateId('CONTAINS', `${fileId}->${node.id}`),
+ type: 'CONTAINS',
+ sourceId: fileId,
+ targetId: node.id,
+ confidence: 1,
+ reason: 'document-declaration',
+ });
+ }
+ });
+
+ it('does not invoke the reader when no eligible document file remains', async () => {
+ const read = vi.spyOn(walker, 'readFileContents');
+ expect(await documentsPhase.execute(ctx, structure(['note.txt', 'excluded.xaml']))).toEqual({
+ sections: 0,
+ failures: 0,
+ });
+ expect(read).not.toHaveBeenCalled();
+ });
+});
diff --git a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts
index 8ee3dcefd..39022ceb2 100644
--- a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts
+++ b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts
@@ -68,6 +68,7 @@ const FULL_ORDER = [
'structure',
'springConfig',
'markdown',
+ 'documents',
'cobol',
'parse',
'routes',
diff --git a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts
index 7164caada..eba29a5ac 100644
--- a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts
+++ b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts
@@ -112,7 +112,9 @@ const mockLbugAdapter = async () => {
initLbug: vi.fn(async () => undefined),
loadGraphToLbug: vi.fn(async () => undefined),
getLbugStats: vi.fn(async () => ({ nodes: 1, edges: 0, communities: 0, processes: 0 })),
- executeQuery: vi.fn(async () => []),
+ executeQuery: vi.fn(async (query: string) =>
+ query === "CALL current_setting('threads') RETURN *" ? [{ threads: '4' }] : [],
+ ),
executeWithReusedStatement: vi.fn(async () => []),
closeLbug: vi.fn(async () => undefined),
wipeLbugDbFiles: vi.fn(async () => undefined),
diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts
index 2f0304ef4..79ec0c4b1 100644
--- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts
+++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts
@@ -2216,6 +2216,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => {
});
it('preserves lastCommit / fileHashes / the dirty flag, and never restates a stale count', async () => {
+ vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '0');
const STALE_COMMIT = '1111111111111111111111111111111111111111';
const STALE_HASHES = { 'src/app.ts': 'stale-hash' };
const LIVE_EMBEDDING_COUNT = 42;
@@ -2307,6 +2308,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => {
_existingEmbeddings: unknown,
pipelineOptions: EmbeddingPipelineOptions,
): Promise => {
+ snapshots.beforeCheckpoint = await loadMeta(storagePath);
// Window 1 — fires before ANY embedding row exists.
await pipelineOptions.onCheckpointWindowStart?.({
nodesProcessed: 0,
@@ -2354,6 +2356,21 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => {
expect(runEmbeddingPipeline).toHaveBeenCalledTimes(1);
+ // Windows writes in place and advances the dirty marker to FTS; a
+ // staged rebuild retains full-rebuild until publication. Neither marker
+ // may be changed or cleared by an embedding checkpoint.
+ const dirtyBeforeCheckpoint = snapshots.beforeCheckpoint?.incrementalInProgress;
+ expect(dirtyBeforeCheckpoint).toMatchObject({
+ phase: process.platform === 'win32' ? 'fts' : 'full-rebuild',
+ });
+ for (const snapshot of [
+ snapshots.windowStart,
+ snapshots.postWindow,
+ snapshots.secondWindow,
+ ]) {
+ expect(snapshot?.incrementalInProgress).toEqual(dirtyBeforeCheckpoint);
+ }
+
// ── Window 1: the checkpoint landed… ──────────────────────────────
expect(snapshots.windowStart).toMatchObject({
embeddingCheckpoint: {
@@ -2370,7 +2387,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => {
expect(snapshots.windowStart).toMatchObject({
lastCommit: STALE_COMMIT,
fileHashes: STALE_HASHES,
- incrementalInProgress: { phase: 'full-rebuild' },
+ incrementalInProgress: dirtyBeforeCheckpoint,
stats: { embeddings: 7 },
});
expect(snapshots.windowStart?.lastCommit).not.toBe(currentCommit);
@@ -2379,7 +2396,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => {
expect(snapshots.postWindow).toMatchObject({
lastCommit: STALE_COMMIT,
fileHashes: STALE_HASHES,
- incrementalInProgress: { phase: 'full-rebuild' },
+ incrementalInProgress: dirtyBeforeCheckpoint,
stats: { embeddings: LIVE_EMBEDDING_COUNT },
});
diff --git a/gitnexus/test/unit/xaml-declarations.test.ts b/gitnexus/test/unit/xaml-declarations.test.ts
new file mode 100644
index 000000000..694d80f71
--- /dev/null
+++ b/gitnexus/test/unit/xaml-declarations.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest';
+import { extractXamlDeclarations } from '../../src/core/ingestion/documents/xaml.js';
+
+const NS = 'http://schemas.microsoft.com/winfx/2006/xaml';
+
+describe('XAML document declarations (#3202)', () => {
+ it.each(['\n', '\r\n', '\r'])('preserves source lines with %j line endings', (newline) => {
+ const source = [
+ ``,
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '',
+ ].join(newline);
+ expect(extractXamlDeclarations(source)).toEqual([
+ {
+ name: 'App.Home',
+ description: 'Page x:Class declaration',
+ startIndex: 0,
+ startLine: 0,
+ endLine: 8,
+ level: 1,
+ },
+ {
+ name: 'SaveButton',
+ description: 'Button x:Name declaration',
+ startIndex: source.replace(/\r\n?/g, '\n').indexOf('