This commit is contained in:
azizur100389 2026-09-25 12:56:39 +01:00 • committed by GitHub
commit 502b7925ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1199 additions and 18 deletions

View file

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

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

@ -1,4 +1,4 @@
# GitNexus
# GitNexus
<div align="center">
@ -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 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |

View file

@ -47,6 +47,10 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
// 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

View file

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

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,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<string, unknown> & { [key: symbol]: XMLMetaData | undefined };
function containsDoctypeDeclaration(source: string): boolean {
let cursor = 0;
while ((cursor = source.indexOf('<', cursor)) !== -1) {
if (source.startsWith('<!--', cursor)) {
const end = source.indexOf('-->', cursor + 4);
if (end === -1) return false;
cursor = end + 3;
continue;
}
if (source.startsWith('<![CDATA[', cursor)) {
const end = source.indexOf(']]>', 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 (/^<!DOCTYPE\b/i.test(source.slice(cursor))) return true;
cursor++;
}
return false;
}
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 (containsDoctypeDeclaration(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.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;
}

View file

@ -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<StructureOutput>(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 };
},
};

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,
@ -113,7 +113,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

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

View file

@ -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);
}

View file

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

View file

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

View file

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

View file

@ -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<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<Style x:Key="{}EscapedAccent" />\n<Style x:Key="{}{x:Type Button}" />\n<Style x:Key="{x:Type Button}" />\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',
'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'),
`<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,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<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('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) =>
`<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="${name}" />`;
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,
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="BeforeFailure" />',
);
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,
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="AfterFailure" />',
);
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'),
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">' +
'<Style x:Key="{}EscapedResource" />' +
'<Style x:Key="{}{LiteralResource}" />' +
'<Style x:Key="{x:Type Button}" /></Grid>',
);
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,
'<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;
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,
'<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

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

View file

@ -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<Record<string, number>> = {
// 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)));

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

@ -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<ReturnType<typeof createTempDir>>;
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),
`<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="View${i}" />`,
);
}
await fs.writeFile(path.join(fixture.dbPath, 'broken.xaml'), '<Grid><Button></Grid>');
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();
});
});

View file

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

View file

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

View file

@ -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<EmbeddingPipelineResult> => {
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 },
});

View file

@ -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 = [
`<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}" />
<Button x:Name="" /><Style x:Key="A&amp;B" />
<![CDATA[<Button x:Name="Fake" />]]>
</Grid>`),
).toEqual([]);
expect(extractXamlDeclarations('<Button x:Name="Undeclared" />')).toEqual([]);
});
it('does not mistake DOCTYPE text inside CDATA, comments, or processing instructions for a DTD', () => {
const declarations = extractXamlDeclarations(`<Grid xmlns:x="${NS}" x:Name="ActualView">
<![CDATA[<!DOCTYPE example>]]>
<!-- <!DOCTYPE example> -->
<?example <!DOCTYPE example>?>
</Grid>`);
expect(declarations).toEqual([
expect.objectContaining({ name: 'ActualView', description: 'Grid x:Name declaration' }),
]);
});
it.each([
['{}escaped', 'escaped'],
['{}{x:Type Button}', '{x:Type Button}'],
['{}{}nested', '{}nested'],
['{}left{right}', 'left{right}'],
])('indexes escaped literal resource key %s as %s', (value, name) => {
const source = `<Style xmlns:q="${NS}" q:Key="${value}" />`;
expect(extractXamlDeclarations(source)).toEqual([
{
name,
description: 'Style x:Key declaration',
startIndex: 0,
startLine: 0,
endLine: 0,
level: 1,
},
]);
});
it.each(['{}', '{} ', '{}A&amp;B', '{}&#123;key'])(
'does not index empty or unresolved entity values after an escape: %s',
(value) => {
expect(extractXamlDeclarations(`<Style xmlns:x="${NS}" x:Key="${value}" />`)).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

@ -66,6 +66,7 @@ export default defineConfig({
test: {
name: 'lbug-db',
include: [
'test/integration/xaml-search.test.ts',
'test/integration/skip-fts.test.ts',
'test/integration/impact-callable-value-references.test.ts',
'test/integration/impact-epistemic-lower-bound.test.ts',
@ -157,6 +158,7 @@ export default defineConfig({
sequence: { groupOrder: 3 },
include: ['test/**/*.test.ts'],
exclude: [
'test/integration/xaml-search.test.ts',
'test/integration/skip-fts.test.ts',
'test/integration/impact-callable-value-references.test.ts',
'test/integration/impact-epistemic-lower-bound.test.ts',