mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
Merge 5fb3e01b44 into 233ca28492
This commit is contained in:
commit
502b7925ba
27 changed files with 1199 additions and 18 deletions
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
6
gitnexus/src/core/ingestion/documents/registry.ts
Normal file
6
gitnexus/src/core/ingestion/documents/registry.ts
Normal 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],
|
||||
]);
|
||||
10
gitnexus/src/core/ingestion/documents/types.ts
Normal file
10
gitnexus/src/core/ingestion/documents/types.ts
Normal 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[];
|
||||
119
gitnexus/src/core/ingestion/documents/xaml.ts
Normal file
119
gitnexus/src/core/ingestion/documents/xaml.ts
Normal 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;
|
||||
}
|
||||
66
gitnexus/src/core/ingestion/pipeline-phases/documents.ts
Normal file
66
gitnexus/src/core/ingestion/pipeline-phases/documents.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
70
gitnexus/test/integration/xaml-documents.test.ts
Normal file
70
gitnexus/test/integration/xaml-documents.test.ts
Normal 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);
|
||||
});
|
||||
573
gitnexus/test/integration/xaml-search.test.ts
Normal file
573
gitnexus/test/integration/xaml-search.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
|
|
|||
113
gitnexus/test/unit/ingestion/documents-phase.test.ts
Normal file
113
gitnexus/test/unit/ingestion/documents-phase.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -68,6 +68,7 @@ const FULL_ORDER = [
|
|||
'structure',
|
||||
'springConfig',
|
||||
'markdown',
|
||||
'documents',
|
||||
'cobol',
|
||||
'parse',
|
||||
'routes',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
121
gitnexus/test/unit/xaml-declarations.test.ts
Normal file
121
gitnexus/test/unit/xaml-declarations.test.ts
Normal 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&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&B', '{}{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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue