feat(indexing): index XAML declarations for search

This commit is contained in:
azizur100389 2026-09-07 18:15:06 +01:00
parent eba42d2994
commit 62b519fc5c
19 changed files with 803 additions and 11 deletions

View file

@ -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 |

View file

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

View file

@ -130,6 +130,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/lbug-core-adapter.test.ts',
'test/integration/lbug-vector-extension.test.ts',
'test/integration/lbug-pool.test.ts',

View file

@ -0,0 +1,6 @@
import type { DocumentParser } from './types.js';
import { extractXamlDeclarations } from './xaml.js';
export const DOCUMENT_PARSERS: ReadonlyMap<string, DocumentParser> = new Map([
['.xaml', extractXamlDeclarations],
]);

View file

@ -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[];

View file

@ -0,0 +1,88 @@
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<string, unknown> & { [key: symbol]: XMLMetaData | undefined };
export function extractXamlDeclarations(source: string): DocumentDeclaration[] {
// fast-xml-parser normalizes newlines before computing its metadata offsets.
source = source.replace(/\r\n?/g, '\n');
// Repository documents are untrusted. Never resolve DTDs or expand entities.
if (/<!DOCTYPE\b/i.test(source)) throw new Error('DTD declarations are not supported');
if (XMLValidator.validate(source) !== true) {
throw new Error('Invalid XML');
}
const parser = new XMLParser({
preserveOrder: true,
ignoreAttributes: false,
captureMetaData: true,
processEntities: false,
parseAttributeValue: false,
trimValues: false,
ignoreDeclaration: true,
ignorePiTags: true,
maxNestedTags: 100,
});
// This is fast-xml-parser, not tree-sitter; its API accepts XML text,
// not the chunked input callback supplied by parseSourceSafe.
// eslint-disable-next-line gitnexus/require-safe-parse
const roots = parser.parse(source) as XmlNode[];
const lineStarts = [0];
for (let i = 0; i < source.length; i++) {
if (source[i] === '\r') {
if (source[i + 1] === '\n') i++;
lineStarts.push(i + 1);
} else if (source[i] === '\n') lineStarts.push(i + 1);
}
const lineAt = (offset: number): number => {
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<string, string>(), 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<string, string>);
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.trim() || /[{}&]/.test(value)) continue;
if (span?.startIndex === undefined || span.endIndex === undefined) continue;
declarations.push({
name: value,
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;
}

View file

@ -0,0 +1,57 @@
import path from 'node:path';
import { generateId } from '../../../lib/utils.js';
import { logger } from '../../logger.js';
import { DOCUMENT_PARSERS } from '../documents/registry.js';
import { 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<StructureOutput>(deps, 'structure');
const files = scannedFiles.filter((file) =>
DOCUMENT_PARSERS.has(path.extname(file.path).toLowerCase()),
);
let sections = 0;
let failures = 0;
for (const file of files) {
const fileId = generateId('File', file.path);
if (!ctx.graph.getNode(fileId)) continue;
const content = (await readFileContents(ctx.repoPath, [file.path])).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 };
},
};

View file

@ -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';

View file

@ -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,
@ -107,7 +107,7 @@ export interface ParseOutput {
export const parsePhase: PipelinePhase<ParseOutput> = {
name: 'parse',
deps: ['structure', 'markdown', 'cobol'],
deps: ['structure', 'markdown', 'documents', 'cobol'],
async execute(
ctx: PipelineContext,

View file

@ -25,6 +25,7 @@ import {
scanPhase,
structurePhase,
markdownPhase,
documentsPhase,
cobolPhase,
parsePhase,
routesPhase,
@ -301,7 +302,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
@ -323,6 +324,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
.register(structurePhase)
.register(springConfigPhase)
.register(markdownPhase)
.register(documentsPhase)
.register(cobolPhase)
.register(parsePhase)
.register(routesPhase)

View file

@ -1791,6 +1791,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
@ -1820,7 +1833,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) {
if (options.registryName) {
await registerRepo(repoPath, existingMeta, {
name: options.registryName,
@ -2984,6 +2997,8 @@ async function runFullAnalysisInner(
? await snapshotDerivedRelsForFiles(filesToDelete, [...tablesWithRows])
: [];
await dropSearchFTSIndexes(indexCatalogRows, incrementalFtsRebuildTables);
// Release dropped search-index storage before the next bulk COPY.
await checkpointOnce();
// 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

View file

@ -44,4 +44,6 @@ 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 },
];

View file

@ -3242,7 +3242,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 type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
ORDER BY startLine, id
`,
{ nodeIds },
@ -3257,7 +3257,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 type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
ORDER BY startLine, id
LIMIT 3
`,

View file

@ -0,0 +1,68 @@
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<ReturnType<typeof createTempDir>>[] = [];
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'),
`<Page xmlns:x="${NS}" x:Class="App.Home">\n<Button x:Name="Save" Click="SaveClicked" />\n<Style x:Key="Accent" />\n</Page>`,
);
await fs.writeFile(
path.join(repo.dbPath, 'Broken.xaml'),
`<Grid xmlns:x="${NS}" x:Name="Bad"><Button></Grid>`,
);
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',
'Save',
]);
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'),
`<Grid xmlns:x="${NS}"><Button x:Name="Renamed" /></Grid>`,
);
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);
});

View file

@ -0,0 +1,443 @@
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 * as checkpoints from '../../src/core/lbug/wal-checkpoint-driver.js';
import { batchInsertEmbeddings } from '../../src/core/embeddings/embedding-pipeline.js';
import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js';
const fixtures: Array<{ cleanup(): Promise<void> }> = [];
afterEach(async () => {
await closeLbug();
vi.restoreAllMocks();
vi.unstubAllEnvs();
for (const fixture of fixtures.splice(0)) await fixture.cleanup();
});
describe('persisted XAML declarations (#3202)', () => {
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 type'),
),
).toBe(true);
} finally {
await backend.dispose();
}
},
180_000,
);
it('keeps graph and embedding rows intact when the post-drop checkpoint 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,
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="OriginalView" />',
);
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;
vi.spyOn(ftsIndexes, 'dropSearchFTSIndexes').mockImplementation(async (...args) => {
await originalDrop(...args);
dropped = true;
});
const failure = new Error('Injected post-drop checkpoint failure');
const originalCheckpoint = checkpoints.checkpointOnce;
vi.spyOn(checkpoints, 'checkpointOnce').mockImplementation(async () => {
if (dropped) throw failure;
await originalCheckpoint();
});
const deletes = vi.spyOn(adapter, 'deleteNodesForFiles');
const copies = vi.spyOn(adapter, 'loadGraphToLbug');
await fs.writeFile(
file,
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="ChangedView" />',
);
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 =
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="FlaggedView" />';
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 =
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="OriginalView" />';
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) =>
`<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"><Button x:Name="${name}" /></Grid>`;
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);
});

View file

@ -23,6 +23,14 @@ const COLUMNS_BY_TABLE = new Map<string, Set<string>>(
);
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'] });

View file

@ -68,6 +68,7 @@ const FULL_ORDER = [
'structure',
'springConfig',
'markdown',
'documents',
'cobol',
'parse',
'routes',

View file

@ -0,0 +1,84 @@
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 = [
`<Page xmlns:x="${NS}" x:Class="App.Home">`,
' <!-- <Button x:Name="Fake" /> -->',
' <Grid>',
' <Button x:Name="SaveButton" Content="Save" />',
' <Style x:Key="PrimaryButton">',
' <Setter Property="Width" Value="20" />',
' </Style>',
' </Grid>',
'</Page>',
].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('<Button x:Name="SaveButton"'),
startLine: 3,
endLine: 3,
level: 3,
},
{
name: 'PrimaryButton',
description: 'Style x:Key declaration',
startIndex: source.replace(/\r\n?/g, '\n').indexOf('<Style'),
startLine: 4,
endLine: 6,
level: 3,
},
]);
});
it('uses namespace identity, including alternate prefixes and scoped rebinding', () => {
const declarations = extractXamlDeclarations(`<Grid xmlns:d="${NS}" xmlns:x="urn:not-xaml">
<Button d:Name="Real" x:Name="WrongNamespace" Name="Unqualified" />
<Panel xmlns:d="urn:other"><Button d:Name="Shadowed" /></Panel>
<Panel xmlns:q="${NS}"><Button q:Name="Nested" /></Panel>
<Button d:Name="AfterScope" />
</Grid>`);
expect(declarations.map((d) => d.name)).toEqual(['Real', 'Nested', 'AfterScope']);
});
it('does not infer bindings, handlers, dynamic resource keys or undeclared x prefixes', () => {
expect(
extractXamlDeclarations(`<Grid xmlns:x="${NS}">
<Button Content="{Binding User}" Click="Save" />
<Style x:Key="{x:Type Button}" /><Style x:Key="{}escaped" />
<Button x:Name="" /><Style x:Key="A&amp;B" />
<![CDATA[<Button x:Name="Fake" />]]>
</Grid>`),
).toEqual([]);
expect(extractXamlDeclarations('<Button x:Name="Undeclared" />')).toEqual([]);
});
it('keeps duplicate names distinct and preserves Unicode names', () => {
const declarations = extractXamlDeclarations(
`<Grid xmlns:x="${NS}"><Button x:Name="Save" /><Button x:Name="Save" /><Button x:Name="\u03a9" /></Grid>`,
);
expect(declarations.map((d) => d.name)).toEqual(['Save', 'Save', '\u03a9']);
expect(new Set(declarations.map((d) => d.startIndex)).size).toBe(3);
});
it.each([
'<Grid><Button></Grid>',
`<!DOCTYPE Grid [<!ENTITY label SYSTEM "file:///private">]><Grid xmlns:x="${NS}" x:Name="&label;" />`,
`${'<Grid>'.repeat(101)}${'</Grid>'.repeat(101)}`,
])('rejects malformed or unsupported input without partial declarations', (source) => {
expect(() => extractXamlDeclarations(source)).toThrow();
});
});

View file

@ -64,6 +64,7 @@ export default defineConfig({
test: {
name: 'lbug-db',
include: [
'test/integration/xaml-search.test.ts',
'test/integration/impact-epistemic-lower-bound.test.ts',
'test/integration/impact-scope-omission-persistence.test.ts',
'test/integration/lbug-core-adapter.test.ts',
@ -143,6 +144,7 @@ export default defineConfig({
sequence: { groupOrder: 3 },
include: ['test/**/*.test.ts'],
exclude: [
'test/integration/xaml-search.test.ts',
'test/integration/impact-epistemic-lower-bound.test.ts',
'test/integration/impact-scope-omission-persistence.test.ts',
'test/integration/lbug-core-adapter.test.ts',