mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
feat(indexing): index XAML declarations for search
This commit is contained in:
parent
eba42d2994
commit
62b519fc5c
19 changed files with 803 additions and 11 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -642,6 +642,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
|||
|
||||
### Supported Languages
|
||||
|
||||
XAML files are indexed as documents. Literal `x:Name`, `x:Key`, and `x:Class` declarations become searchable `Section` nodes with source ranges and file containment. Namespace aliases are supported. Runtime bindings, event-handler resolution, markup-extension keys, entity-containing declaration values, DTDs, and documents deeper than 100 elements are not interpreted; the file remains indexed when declaration extraction is unavailable. Run `gitnexus analyze --force` to add declaration nodes and their search indexes to an existing index.
|
||||
|
||||
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|
||||
| ---------- | ------- | -------------- | ------- | -------- | ---------------- | --------------------- | ------ | ---------- | ------------ |
|
||||
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ const PLATFORM_LOGIC = [
|
|||
// N-API addon which has known platform-specific behavior (Windows
|
||||
// file-lock lag after close, macOS N-API destructor segfaults)
|
||||
const LBUG_NATIVE = [
|
||||
'test/integration/xaml-search.test.ts',
|
||||
'test/integration/lbug-core-adapter.test.ts',
|
||||
'test/integration/lbug-vector-extension.test.ts',
|
||||
'test/integration/lbug-pool.test.ts',
|
||||
|
|
|
|||
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[];
|
||||
88
gitnexus/src/core/ingestion/documents/xaml.ts
Normal file
88
gitnexus/src/core/ingestion/documents/xaml.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { XMLParser, XMLValidator, type XMLMetaData } from 'fast-xml-parser';
|
||||
import type { DocumentDeclaration } from './types.js';
|
||||
|
||||
const XAML_NAMESPACE = 'http://schemas.microsoft.com/winfx/2006/xaml';
|
||||
const DIRECTIVES = new Set(['Name', 'Key', 'Class']);
|
||||
const META = XMLParser.getMetaDataSymbol() as symbol;
|
||||
type XmlNode = Record<string, unknown> & { [key: symbol]: XMLMetaData | undefined };
|
||||
|
||||
export function extractXamlDeclarations(source: string): DocumentDeclaration[] {
|
||||
// fast-xml-parser normalizes newlines before computing its metadata offsets.
|
||||
source = source.replace(/\r\n?/g, '\n');
|
||||
// Repository documents are untrusted. Never resolve DTDs or expand entities.
|
||||
if (/<!DOCTYPE\b/i.test(source)) throw new Error('DTD declarations are not supported');
|
||||
if (XMLValidator.validate(source) !== true) {
|
||||
throw new Error('Invalid XML');
|
||||
}
|
||||
const parser = new XMLParser({
|
||||
preserveOrder: true,
|
||||
ignoreAttributes: false,
|
||||
captureMetaData: true,
|
||||
processEntities: false,
|
||||
parseAttributeValue: false,
|
||||
trimValues: false,
|
||||
ignoreDeclaration: true,
|
||||
ignorePiTags: true,
|
||||
maxNestedTags: 100,
|
||||
});
|
||||
// This is fast-xml-parser, not tree-sitter; its API accepts XML text,
|
||||
// not the chunked input callback supplied by parseSourceSafe.
|
||||
// eslint-disable-next-line gitnexus/require-safe-parse
|
||||
const roots = parser.parse(source) as XmlNode[];
|
||||
const lineStarts = [0];
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
if (source[i] === '\r') {
|
||||
if (source[i + 1] === '\n') i++;
|
||||
lineStarts.push(i + 1);
|
||||
} else if (source[i] === '\n') lineStarts.push(i + 1);
|
||||
}
|
||||
const lineAt = (offset: number): number => {
|
||||
let lo = 0;
|
||||
let hi = lineStarts.length;
|
||||
while (lo + 1 < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (lineStarts[mid] <= offset) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
};
|
||||
const declarations: DocumentDeclaration[] = [];
|
||||
const stack = roots
|
||||
.map((node) => ({ node, namespaces: new Map<string, string>(), level: 1 }))
|
||||
.reverse();
|
||||
while (stack.length) {
|
||||
const { node, namespaces: inherited, level } = stack.pop()!;
|
||||
const tag = Object.keys(node).find((key) => key !== ':@' && !key.startsWith('#'));
|
||||
if (!tag || !Array.isArray(node[tag])) continue;
|
||||
if (level > 100) throw new Error('Maximum document depth exceeded');
|
||||
const attributes = Object.entries((node[':@'] ?? {}) as Record<string, string>);
|
||||
const namespaceAttributes = attributes.filter(([key]) => key.startsWith('@_xmlns:'));
|
||||
const namespaces = namespaceAttributes.length ? new Map(inherited) : inherited;
|
||||
for (const [key, value] of namespaceAttributes) namespaces.set(key.slice(8), value);
|
||||
const span = node[META];
|
||||
for (const [attribute, value] of attributes) {
|
||||
const parts = attribute.slice(2).split(':');
|
||||
if (
|
||||
parts.length !== 2 ||
|
||||
namespaces.get(parts[0]) !== XAML_NAMESPACE ||
|
||||
!DIRECTIVES.has(parts[1])
|
||||
)
|
||||
continue;
|
||||
if (typeof value !== 'string' || !value.trim() || /[{}&]/.test(value)) continue;
|
||||
if (span?.startIndex === undefined || span.endIndex === undefined) continue;
|
||||
declarations.push({
|
||||
name: value,
|
||||
description: `${tag} x:${parts[1]} declaration`,
|
||||
startIndex: span.startIndex,
|
||||
startLine: lineAt(span.startIndex),
|
||||
endLine: lineAt(Math.max(span.startIndex, span.endIndex - 1)),
|
||||
level,
|
||||
});
|
||||
}
|
||||
const children = node[tag] as XmlNode[];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
stack.push({ node: children[i], namespaces, level: level + 1 });
|
||||
}
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
57
gitnexus/src/core/ingestion/pipeline-phases/documents.ts
Normal file
57
gitnexus/src/core/ingestion/pipeline-phases/documents.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import path from 'node:path';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { DOCUMENT_PARSERS } from '../documents/registry.js';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { getPhaseOutput, type PipelinePhase } from './types.js';
|
||||
import type { StructureOutput } from './structure.js';
|
||||
|
||||
export const documentsPhase: PipelinePhase<{ sections: number; failures: number }> = {
|
||||
name: 'documents',
|
||||
deps: ['structure'],
|
||||
async execute(ctx, deps) {
|
||||
const { scannedFiles } = getPhaseOutput<StructureOutput>(deps, 'structure');
|
||||
const files = scannedFiles.filter((file) =>
|
||||
DOCUMENT_PARSERS.has(path.extname(file.path).toLowerCase()),
|
||||
);
|
||||
let sections = 0;
|
||||
let failures = 0;
|
||||
for (const file of files) {
|
||||
const fileId = generateId('File', file.path);
|
||||
if (!ctx.graph.getNode(fileId)) continue;
|
||||
const content = (await readFileContents(ctx.repoPath, [file.path])).get(file.path);
|
||||
if (content === undefined) continue;
|
||||
const parser = DOCUMENT_PARSERS.get(path.extname(file.path).toLowerCase())!;
|
||||
let declarations;
|
||||
try {
|
||||
declarations = parser(content);
|
||||
} catch {
|
||||
failures++;
|
||||
logger.warn(
|
||||
{ filePath: file.path },
|
||||
'Document declarations omitted: invalid or unsupported document',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const declaration of declarations) {
|
||||
const { startIndex, ...properties } = declaration;
|
||||
const id = generateId('Section', `${file.path}:${startIndex}:${declaration.description}`);
|
||||
ctx.graph.addNode({
|
||||
id,
|
||||
label: 'Section',
|
||||
properties: { ...properties, filePath: file.path },
|
||||
});
|
||||
ctx.graph.addRelationship({
|
||||
id: generateId('CONTAINS', `${fileId}->${id}`),
|
||||
type: 'CONTAINS',
|
||||
sourceId: fileId,
|
||||
targetId: id,
|
||||
confidence: 1,
|
||||
reason: 'document-declaration',
|
||||
});
|
||||
sections++;
|
||||
}
|
||||
}
|
||||
return { sections, failures };
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
@ -107,7 +107,7 @@ export interface ParseOutput {
|
|||
|
||||
export const parsePhase: PipelinePhase<ParseOutput> = {
|
||||
name: 'parse',
|
||||
deps: ['structure', 'markdown', 'cobol'],
|
||||
deps: ['structure', 'markdown', 'documents', 'cobol'],
|
||||
|
||||
async execute(
|
||||
ctx: PipelineContext,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
scanPhase,
|
||||
structurePhase,
|
||||
markdownPhase,
|
||||
documentsPhase,
|
||||
cobolPhase,
|
||||
parsePhase,
|
||||
routesPhase,
|
||||
|
|
@ -301,7 +302,7 @@ export interface PipelineOptions {
|
|||
*
|
||||
* Phase dependency graph:
|
||||
*
|
||||
* scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm]
|
||||
* scan → structure → [springConfig, markdown, documents, cobol] → parse → [routes, tools, orm]
|
||||
* → crossFile → scopeResolution → [springAutoConfiguration, springAop,
|
||||
* springDestinations] → pruneLocalSymbols
|
||||
* → mro → springAopInheritance → di → communities → processes
|
||||
|
|
@ -323,6 +324,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
|
|||
.register(structurePhase)
|
||||
.register(springConfigPhase)
|
||||
.register(markdownPhase)
|
||||
.register(documentsPhase)
|
||||
.register(cobolPhase)
|
||||
.register(parsePhase)
|
||||
.register(routesPhase)
|
||||
|
|
|
|||
|
|
@ -1791,6 +1791,19 @@ async function runFullAnalysisInner(
|
|||
// fast path because the previous analyze just wrote them
|
||||
// (regression vs PR #1233 behavior).
|
||||
const dirty = isWorkingTreeDirty(repoPath);
|
||||
// Git can report clean after reverting/removing an indexed dirty file.
|
||||
// Hidden Git flags also persist in dirtyPaths, so compare content rather
|
||||
// than repeatedly reanalyzing unchanged candidates.
|
||||
const indexedDirtyPaths = existingMeta.indexCoverage?.dirtyPaths ?? [];
|
||||
let indexedContentChanged = false;
|
||||
if (!dirty && indexedDirtyPaths.length > 0) {
|
||||
const currentHashes = await computeFileHashes(repoPath, indexedDirtyPaths);
|
||||
indexedContentChanged = indexedDirtyPaths.some(
|
||||
(filePath) =>
|
||||
!currentHashes.has(filePath) ||
|
||||
currentHashes.get(filePath) !== existingMeta.fileHashes?.[filePath],
|
||||
);
|
||||
}
|
||||
// Registration wrinkle around the fast path (#2264). A prior
|
||||
// `analyze --name X` that hit a name collision writes meta.json (meta-save
|
||||
// runs before registerRepo) then fails before registering, leaving the
|
||||
|
|
@ -1820,7 +1833,7 @@ async function runFullAnalysisInner(
|
|||
// re-analysis whenever an index authored where FTS was unavailable was
|
||||
// later read on a host where it loads — which is a legitimate, common
|
||||
// state, and the invariant `analyzer-identity-cli.test.ts` pins.
|
||||
if (!dirty && !healUnregistered) {
|
||||
if (!dirty && !indexedContentChanged && !healUnregistered) {
|
||||
if (options.registryName) {
|
||||
await registerRepo(repoPath, existingMeta, {
|
||||
name: options.registryName,
|
||||
|
|
@ -2984,6 +2997,8 @@ async function runFullAnalysisInner(
|
|||
? await snapshotDerivedRelsForFiles(filesToDelete, [...tablesWithRows])
|
||||
: [];
|
||||
await dropSearchFTSIndexes(indexCatalogRows, incrementalFtsRebuildTables);
|
||||
// Release dropped search-index storage before the next bulk COPY.
|
||||
await checkpointOnce();
|
||||
// 1b. Remove the write set's existing rows — batched (#2409): one
|
||||
// DETACH DELETE per table per 200-file chunk. The former per-file
|
||||
// loop issued a count + delete per table per FILE — ~13k
|
||||
|
|
|
|||
|
|
@ -44,4 +44,6 @@ export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
|
|||
{ table: 'Union', indexName: 'union_fts', properties: FTS_PROPERTIES },
|
||||
{ table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES },
|
||||
{ table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
|
||||
// Markdown headings and static document declarations must be discoverable by name.
|
||||
{ table: 'Section', indexName: 'section_fts', properties: FTS_PROPERTIES },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3242,7 +3242,7 @@ export class LocalBackend {
|
|||
`
|
||||
MATCH (n)
|
||||
WHERE n.id IN $nodeIds
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
RETURN n.id AS id, n.name AS name, labels(n) AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
ORDER BY startLine, id
|
||||
`,
|
||||
{ nodeIds },
|
||||
|
|
@ -3257,7 +3257,7 @@ export class LocalBackend {
|
|||
MATCH (n)
|
||||
WHERE n.filePath = $filePath
|
||||
AND NOT n.id STARTS WITH 'BasicBlock:'
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
RETURN n.id AS id, n.name AS name, labels(n) AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
ORDER BY startLine, id
|
||||
LIMIT 3
|
||||
`,
|
||||
|
|
|
|||
68
gitnexus/test/integration/xaml-documents.test.ts
Normal file
68
gitnexus/test/integration/xaml-documents.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
|
||||
const NS = 'http://schemas.microsoft.com/winfx/2006/xaml';
|
||||
const fixtures: Awaited<ReturnType<typeof createTempDir>>[] = [];
|
||||
afterEach(async () => {
|
||||
for (const fixture of fixtures.splice(0)) await fixture.cleanup();
|
||||
});
|
||||
|
||||
describe('XAML pipeline documents (#3202)', () => {
|
||||
it('indexes literal names and resource keys, with no invented runtime edges', async () => {
|
||||
const repo = await createTempDir();
|
||||
fixtures.push(repo);
|
||||
await fs.writeFile(
|
||||
path.join(repo.dbPath, 'Home.XAML'),
|
||||
`<Page xmlns:x="${NS}" x:Class="App.Home">\n<Button x:Name="Save" Click="SaveClicked" />\n<Style x:Key="Accent" />\n</Page>`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(repo.dbPath, 'Broken.xaml'),
|
||||
`<Grid xmlns:x="${NS}" x:Name="Bad"><Button></Grid>`,
|
||||
);
|
||||
const result = await runPipelineFromRepo(repo.dbPath, () => {}, { skipGraphPhases: true });
|
||||
const nodes = [...result.graph.iterNodes()];
|
||||
expect(
|
||||
nodes
|
||||
.filter((node) => node.label === 'File')
|
||||
.map((node) => node.properties.name)
|
||||
.sort(),
|
||||
).toEqual(['Broken.xaml', 'Home.XAML']);
|
||||
const declarations = nodes.filter((node) => node.label === 'Section');
|
||||
expect(declarations.map((node) => node.properties.name).sort()).toEqual([
|
||||
'Accent',
|
||||
'App.Home',
|
||||
'Save',
|
||||
]);
|
||||
expect(declarations.find((node) => node.properties.name === 'Save')?.properties.startLine).toBe(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
[...result.graph.iterRelationships()].filter((edge) => edge.type !== 'CONTAINS'),
|
||||
).toEqual([]);
|
||||
const fileId = generateId('File', 'Home.XAML');
|
||||
expect(
|
||||
[...result.graph.iterRelationshipsByType('CONTAINS')]
|
||||
.filter((edge) => edge.sourceId === fileId)
|
||||
.map((edge) => edge.targetId)
|
||||
.sort(),
|
||||
).toEqual(declarations.map((node) => node.id).sort());
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(repo.dbPath, 'Home.XAML'),
|
||||
`<Grid xmlns:x="${NS}"><Button x:Name="Renamed" /></Grid>`,
|
||||
);
|
||||
const changed = await runPipelineFromRepo(repo.dbPath, () => {}, { skipGraphPhases: true });
|
||||
expect(
|
||||
[...changed.graph.iterNodes()]
|
||||
.filter((node) => node.label === 'Section')
|
||||
.map((node) => node.properties.name),
|
||||
).toEqual(['Renamed']);
|
||||
await fs.unlink(path.join(repo.dbPath, 'Home.XAML'));
|
||||
const deleted = await runPipelineFromRepo(repo.dbPath, () => {}, { skipGraphPhases: true });
|
||||
expect([...deleted.graph.iterNodes()].filter((node) => node.label === 'Section')).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
443
gitnexus/test/integration/xaml-search.test.ts
Normal file
443
gitnexus/test/integration/xaml-search.test.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
import { setupMiniRepo } from '../helpers/mini-repo.js';
|
||||
import { runFullAnalysis } from '../../src/core/run-analyze.js';
|
||||
import {
|
||||
closeLbug,
|
||||
dropFTSIndex,
|
||||
executePrepared,
|
||||
initLbug,
|
||||
} from '../../src/core/lbug/lbug-adapter.js';
|
||||
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import { searchFTSFromLbug } from '../../src/core/search/bm25-index.js';
|
||||
import * as bm25 from '../../src/core/search/bm25-index.js';
|
||||
import * as pool from '../../src/core/lbug/pool-adapter.js';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
|
||||
import * as ftsIndexes from '../../src/core/search/fts-indexes.js';
|
||||
import * as checkpoints from '../../src/core/lbug/wal-checkpoint-driver.js';
|
||||
import { batchInsertEmbeddings } from '../../src/core/embeddings/embedding-pipeline.js';
|
||||
import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js';
|
||||
|
||||
const fixtures: Array<{ cleanup(): Promise<void> }> = [];
|
||||
afterEach(async () => {
|
||||
await closeLbug();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
for (const fixture of fixtures.splice(0)) await fixture.cleanup();
|
||||
});
|
||||
|
||||
describe('persisted XAML declarations (#3202)', () => {
|
||||
it.each(['node-id', 'file-path'])(
|
||||
'preserves existing code symbol types through %s lookup',
|
||||
async (lookup) => {
|
||||
const repo = await setupMiniRepo();
|
||||
const home = await createTempDir();
|
||||
fixtures.push(repo, home);
|
||||
vi.stubEnv('GITNEXUS_HOME', home.dbPath);
|
||||
// Both symbols fit the existing file-path fallback's LIMIT 3.
|
||||
await fs.writeFile(
|
||||
path.join(repo.dbPath, 'src/search-shape.ts'),
|
||||
'export interface SearchShape {}\nexport function createSearchShape(): SearchShape { return {}; }\n',
|
||||
);
|
||||
const registryName = 'xaml-code-search-fixture';
|
||||
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, registryName }, { onProgress() {} });
|
||||
if (lookup === 'file-path') {
|
||||
const originalSearch = bm25.searchFTSFromLbug;
|
||||
// Exercise the legacy file-only response using real FTS and graph queries.
|
||||
vi.spyOn(bm25, 'searchFTSFromLbug').mockImplementation(async (...args) => {
|
||||
const result = await originalSearch(...args);
|
||||
return {
|
||||
...result,
|
||||
results: result.results.map((hit) => ({ ...hit, nodeIds: undefined })),
|
||||
};
|
||||
});
|
||||
}
|
||||
const backend = new LocalBackend();
|
||||
const queries = vi.spyOn(pool, 'executeParameterized');
|
||||
try {
|
||||
expect(await backend.init()).toBe(true);
|
||||
for (const [name, type] of [
|
||||
['createSearchShape', 'Function'],
|
||||
['SearchShape', 'Interface'],
|
||||
]) {
|
||||
const result = await backend.callTool('query', {
|
||||
repo: registryName,
|
||||
search_query: name,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect([...result.definitions, ...result.process_symbols]).toContainEqual(
|
||||
expect.objectContaining({ name, type, filePath: 'src/search-shape.ts' }),
|
||||
);
|
||||
}
|
||||
const predicate =
|
||||
lookup === 'node-id' ? 'WHERE n.id IN $nodeIds' : 'WHERE n.filePath = $filePath';
|
||||
expect(
|
||||
queries.mock.calls.some(
|
||||
([, statement]) =>
|
||||
statement.includes(predicate) && statement.includes('labels(n) AS type'),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await backend.dispose();
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
it('keeps graph and embedding rows intact when the post-drop checkpoint fails', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
const home = await createTempDir();
|
||||
fixtures.push(repo, home);
|
||||
vi.stubEnv('GITNEXUS_HOME', home.dbPath);
|
||||
const file = path.join(repo.dbPath, 'Checkpoint.xaml');
|
||||
await fs.writeFile(
|
||||
file,
|
||||
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="OriginalView" />',
|
||||
);
|
||||
const options = { skipAgentsMd: true, registryName: 'document-checkpoint-fixture' };
|
||||
await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
|
||||
await initLbug(lbugPath);
|
||||
const originalSections = await executePrepared(
|
||||
'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
|
||||
{ file: 'Checkpoint.xaml' },
|
||||
);
|
||||
expect(originalSections).toEqual([{ id: expect.any(String), name: 'OriginalView' }]);
|
||||
const functions = await adapter.executeQuery(
|
||||
'MATCH (n:Function) RETURN n.id AS id ORDER BY id',
|
||||
);
|
||||
expect(functions.length).toBeGreaterThan(0);
|
||||
const embedding = {
|
||||
nodeId: String(functions[0].id),
|
||||
chunkIndex: 0,
|
||||
startLine: 0,
|
||||
endLine: 1,
|
||||
embedding: Array.from({ length: EMBEDDING_DIMS }, (_, i) => (i === 0 ? 1 : 0)),
|
||||
contentHash: 'checkpoint-preserved',
|
||||
};
|
||||
await batchInsertEmbeddings(adapter.executeWithReusedStatement, [embedding]);
|
||||
await adapter.flushWAL();
|
||||
await closeLbug();
|
||||
const meta = await loadMeta(storagePath);
|
||||
if (!meta) throw new Error('Initial analysis did not persist metadata');
|
||||
await saveMeta(storagePath, {
|
||||
...meta,
|
||||
stats: { ...meta.stats, embeddings: 1 },
|
||||
embeddingDims: EMBEDDING_DIMS,
|
||||
});
|
||||
|
||||
const originalDrop = ftsIndexes.dropSearchFTSIndexes;
|
||||
let dropped = false;
|
||||
vi.spyOn(ftsIndexes, 'dropSearchFTSIndexes').mockImplementation(async (...args) => {
|
||||
await originalDrop(...args);
|
||||
dropped = true;
|
||||
});
|
||||
const failure = new Error('Injected post-drop checkpoint failure');
|
||||
const originalCheckpoint = checkpoints.checkpointOnce;
|
||||
vi.spyOn(checkpoints, 'checkpointOnce').mockImplementation(async () => {
|
||||
if (dropped) throw failure;
|
||||
await originalCheckpoint();
|
||||
});
|
||||
const deletes = vi.spyOn(adapter, 'deleteNodesForFiles');
|
||||
const copies = vi.spyOn(adapter, 'loadGraphToLbug');
|
||||
await fs.writeFile(
|
||||
file,
|
||||
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="ChangedView" />',
|
||||
);
|
||||
await expect(runFullAnalysis(repo.dbPath, options, { onProgress() {} })).rejects.toBe(failure);
|
||||
expect(dropped).toBe(true);
|
||||
expect(deletes).not.toHaveBeenCalled();
|
||||
expect(copies).not.toHaveBeenCalled();
|
||||
expect(await loadMeta(storagePath)).toMatchObject({
|
||||
incrementalInProgress: {
|
||||
phase: 'effective-write-set',
|
||||
toWriteCount: 1,
|
||||
startedAt: expect.any(Number),
|
||||
},
|
||||
indexedAt: meta.indexedAt,
|
||||
fileHashes: meta.fileHashes,
|
||||
stats: { embeddings: 1 },
|
||||
});
|
||||
await initLbug(lbugPath);
|
||||
expect(
|
||||
await executePrepared(
|
||||
'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
|
||||
{ file: 'Checkpoint.xaml' },
|
||||
),
|
||||
).toEqual(originalSections);
|
||||
expect(await adapter.executeQuery('MATCH (n:Function) RETURN n.id AS id ORDER BY id')).toEqual(
|
||||
functions,
|
||||
);
|
||||
expect((await adapter.loadCachedEmbeddings()).embeddings).toEqual([embedding]);
|
||||
}, 180_000);
|
||||
|
||||
it('repairs a missing Section index and reconciles Markdown headings sharing that index', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
const home = await createTempDir();
|
||||
fixtures.push(repo, home);
|
||||
vi.stubEnv('GITNEXUS_HOME', home.dbPath);
|
||||
const file = path.join(repo.dbPath, 'Guide.md');
|
||||
await fs.writeFile(file, '# InvoiceRecoveryGuide\n\nRecover invoices.\n');
|
||||
const options = { skipAgentsMd: true, registryName: 'document-repair-fixture' };
|
||||
await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
const { lbugPath } = getStoragePaths(repo.dbPath);
|
||||
await initLbug(lbugPath);
|
||||
const before = await executePrepared(
|
||||
'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
|
||||
{ file: 'Guide.md' },
|
||||
);
|
||||
expect(before).toEqual([{ id: expect.any(String), name: 'InvoiceRecoveryGuide' }]);
|
||||
await dropFTSIndex('Section', 'section_fts');
|
||||
await closeLbug();
|
||||
const repaired = await runFullAnalysis(
|
||||
repo.dbPath,
|
||||
{ ...options, repairFts: true },
|
||||
{ onProgress() {} },
|
||||
);
|
||||
expect(repaired.ftsRepairedOnly).toBe(true);
|
||||
await initLbug(lbugPath);
|
||||
expect(
|
||||
await executePrepared(
|
||||
'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
|
||||
{ file: 'Guide.md' },
|
||||
),
|
||||
).toEqual(before);
|
||||
expect(
|
||||
(await searchFTSFromLbug('InvoiceRecoveryGuide')).results.flatMap(
|
||||
(result) => result.nodeIds ?? [],
|
||||
),
|
||||
).toContain(before[0].id);
|
||||
await closeLbug();
|
||||
await fs.writeFile(file, '# PaymentRecoveryGuide\n\nRecover payments.\n');
|
||||
await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
await initLbug(lbugPath);
|
||||
expect((await searchFTSFromLbug('InvoiceRecoveryGuide')).results).toEqual([]);
|
||||
const renamed = await executePrepared(
|
||||
'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name',
|
||||
{ file: 'Guide.md' },
|
||||
);
|
||||
expect(renamed).toEqual([{ id: expect.any(String), name: 'PaymentRecoveryGuide' }]);
|
||||
expect(
|
||||
(await searchFTSFromLbug('PaymentRecoveryGuide')).results.flatMap(
|
||||
(result) => result.nodeIds ?? [],
|
||||
),
|
||||
).toContain(renamed[0].id);
|
||||
await closeLbug();
|
||||
await fs.unlink(file);
|
||||
await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
await initLbug(lbugPath);
|
||||
expect((await searchFTSFromLbug('PaymentRecoveryGuide')).results).toEqual([]);
|
||||
expect(
|
||||
await executePrepared('MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id', {
|
||||
file: 'Guide.md',
|
||||
}),
|
||||
).toEqual([]);
|
||||
await closeLbug();
|
||||
expect((await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate).toBe(
|
||||
true,
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
it.each(['--assume-unchanged', '--skip-worktree'])(
|
||||
'hashes %s candidates without repeatedly reanalyzing unchanged content',
|
||||
async (flag) => {
|
||||
const repo = await setupMiniRepo();
|
||||
const home = await createTempDir();
|
||||
fixtures.push(repo, home);
|
||||
vi.stubEnv('GITNEXUS_HOME', home.dbPath);
|
||||
const file = path.join(repo.dbPath, 'Flagged.xaml');
|
||||
const original =
|
||||
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="FlaggedView" />';
|
||||
await fs.writeFile(file, original);
|
||||
execFileSync('git', ['add', 'Flagged.xaml'], { cwd: repo.dbPath });
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.name=test',
|
||||
'-c',
|
||||
'user.email=t@t',
|
||||
'-c',
|
||||
'commit.gpgsign=false',
|
||||
'commit',
|
||||
'-qm',
|
||||
'Add view',
|
||||
],
|
||||
{ cwd: repo.dbPath },
|
||||
);
|
||||
execFileSync('git', ['update-index', flag, 'Flagged.xaml'], { cwd: repo.dbPath });
|
||||
const options = { skipAgentsMd: true, registryName: 'flagged-xaml-fixture' };
|
||||
await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
|
||||
expect((await loadMeta(storagePath))?.indexCoverage?.dirtyPaths).toContain('Flagged.xaml');
|
||||
expect(
|
||||
(await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate,
|
||||
).toBe(true);
|
||||
await fs.writeFile(file, original.replace('FlaggedView', 'ChangedView'));
|
||||
expect(
|
||||
(await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate,
|
||||
).not.toBe(true);
|
||||
await initLbug(lbugPath);
|
||||
try {
|
||||
expect(
|
||||
await executePrepared(
|
||||
'MATCH (n:Section) WHERE n.filePath = $file RETURN n.name AS name',
|
||||
{ file: 'Flagged.xaml' },
|
||||
),
|
||||
).toEqual([{ name: 'ChangedView' }]);
|
||||
} finally {
|
||||
await closeLbug();
|
||||
}
|
||||
expect(
|
||||
(await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate,
|
||||
).toBe(true);
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
it('reconciles an indexed edit restored to HEAD and keeps legacy clean metadata eligible for the fast path', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
const home = await createTempDir();
|
||||
fixtures.push(repo, home);
|
||||
vi.stubEnv('GITNEXUS_HOME', home.dbPath);
|
||||
const file = path.join(repo.dbPath, 'Tracked.xaml');
|
||||
const original =
|
||||
'<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Name="OriginalView" />';
|
||||
await fs.writeFile(file, original);
|
||||
execFileSync('git', ['add', 'Tracked.xaml'], { cwd: repo.dbPath });
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.name=test',
|
||||
'-c',
|
||||
'user.email=t@t',
|
||||
'-c',
|
||||
'commit.gpgsign=false',
|
||||
'commit',
|
||||
'-qm',
|
||||
'Add view',
|
||||
],
|
||||
{ cwd: repo.dbPath },
|
||||
);
|
||||
const options = { skipAgentsMd: true, registryName: 'tracked-xaml-fixture' };
|
||||
await fs.writeFile(file, original.replace('OriginalView', 'EditedView'));
|
||||
await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
|
||||
expect((await loadMeta(storagePath))?.indexCoverage?.dirtyPaths).toContain('Tracked.xaml');
|
||||
await fs.writeFile(file, original);
|
||||
const restored = await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
expect(restored.alreadyUpToDate).not.toBe(true);
|
||||
await initLbug(lbugPath);
|
||||
try {
|
||||
expect(
|
||||
await executePrepared('MATCH (n:Section) WHERE n.filePath = $file RETURN n.name AS name', {
|
||||
file: 'Tracked.xaml',
|
||||
}),
|
||||
).toEqual([{ name: 'OriginalView' }]);
|
||||
} finally {
|
||||
await closeLbug();
|
||||
}
|
||||
const meta = await loadMeta(storagePath);
|
||||
expect(meta?.indexCoverage?.dirtyPaths).toEqual([]);
|
||||
// Older snapshots did not record dirtyPaths. Their clean fast path is unchanged.
|
||||
if (!meta?.indexCoverage) throw new Error('Expected coverage metadata');
|
||||
delete meta.indexCoverage.dirtyPaths;
|
||||
await saveMeta(storagePath, meta);
|
||||
expect((await runFullAnalysis(repo.dbPath, options, { onProgress() {} })).alreadyUpToDate).toBe(
|
||||
true,
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
it('supports FTS and MCP context, then removes stale declarations on incremental change and delete', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
const home = await createTempDir();
|
||||
fixtures.push(repo, home);
|
||||
vi.stubEnv('GITNEXUS_HOME', home.dbPath);
|
||||
const file = path.join(repo.dbPath, 'View.xaml');
|
||||
const document = (name: string) =>
|
||||
`<Grid xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"><Button x:Name="${name}" /></Grid>`;
|
||||
await fs.writeFile(file, document('SubmitInvoiceButton'));
|
||||
const options = { skipAgentsMd: true, registryName: 'xaml-fixture' };
|
||||
const first = await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
expect(first.ftsSkipped).toBe(false);
|
||||
const { lbugPath } = getStoragePaths(repo.dbPath);
|
||||
const readSections = async () => {
|
||||
await initLbug(lbugPath);
|
||||
try {
|
||||
return await executePrepared(
|
||||
'MATCH (n:Section) WHERE n.filePath = $file RETURN n.id AS id, n.name AS name, n.startLine AS startLine',
|
||||
{ file: 'View.xaml' },
|
||||
);
|
||||
} finally {
|
||||
await closeLbug();
|
||||
}
|
||||
};
|
||||
const sections = await readSections();
|
||||
expect(sections).toEqual([
|
||||
{ id: expect.any(String), name: 'SubmitInvoiceButton', startLine: 0 },
|
||||
]);
|
||||
await initLbug(lbugPath);
|
||||
const search = await searchFTSFromLbug('SubmitInvoiceButton');
|
||||
expect(search.ftsAvailable).toBe(true);
|
||||
expect(search.results.some((result) => result.filePath === 'View.xaml')).toBe(true);
|
||||
expect(search.results.flatMap((result) => result.nodeIds ?? [])).toContain(sections[0].id);
|
||||
await closeLbug();
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
expect(await backend.init()).toBe(true);
|
||||
const query = await backend.callTool('query', {
|
||||
repo: options.registryName,
|
||||
search_query: 'SubmitInvoiceButton',
|
||||
});
|
||||
expect(query.error).toBeUndefined();
|
||||
expect(query.definitions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: sections[0].id,
|
||||
name: 'SubmitInvoiceButton',
|
||||
type: 'Section',
|
||||
filePath: 'View.xaml',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
}),
|
||||
);
|
||||
const declaration = query.definitions.find(
|
||||
(definition: { name: string }) => definition.name === 'SubmitInvoiceButton',
|
||||
);
|
||||
const context = await backend.callTool('context', {
|
||||
repo: options.registryName,
|
||||
uid: declaration.id,
|
||||
});
|
||||
expect(context.error).toBeUndefined();
|
||||
expect(JSON.stringify(context)).toContain('SubmitInvoiceButton');
|
||||
} finally {
|
||||
await backend.dispose();
|
||||
}
|
||||
|
||||
await fs.writeFile(file, document('CancelInvoiceButton'));
|
||||
await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
expect(await readSections()).toEqual([
|
||||
{ id: expect.any(String), name: 'CancelInvoiceButton', startLine: 0 },
|
||||
]);
|
||||
await initLbug(lbugPath);
|
||||
expect((await searchFTSFromLbug('SubmitInvoiceButton')).results).toEqual([]);
|
||||
expect(
|
||||
(await searchFTSFromLbug('CancelInvoiceButton')).results.flatMap(
|
||||
(result) => result.nodeIds ?? [],
|
||||
),
|
||||
).toContain(sections[0].id);
|
||||
await closeLbug();
|
||||
await fs.unlink(file);
|
||||
await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
expect(await readSections()).toEqual([]);
|
||||
await initLbug(lbugPath);
|
||||
expect((await searchFTSFromLbug('CancelInvoiceButton')).results).toEqual([]);
|
||||
await closeLbug();
|
||||
const clean = await runFullAnalysis(repo.dbPath, options, { onProgress() {} });
|
||||
expect(clean.alreadyUpToDate).toBe(true);
|
||||
}, 180_000);
|
||||
});
|
||||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const FULL_ORDER = [
|
|||
'structure',
|
||||
'springConfig',
|
||||
'markdown',
|
||||
'documents',
|
||||
'cobol',
|
||||
'parse',
|
||||
'routes',
|
||||
|
|
|
|||
84
gitnexus/test/unit/xaml-declarations.test.ts
Normal file
84
gitnexus/test/unit/xaml-declarations.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { extractXamlDeclarations } from '../../src/core/ingestion/documents/xaml.js';
|
||||
|
||||
const NS = 'http://schemas.microsoft.com/winfx/2006/xaml';
|
||||
|
||||
describe('XAML document declarations (#3202)', () => {
|
||||
it.each(['\n', '\r\n', '\r'])('preserves source lines with %j line endings', (newline) => {
|
||||
const source = [
|
||||
`<Page xmlns:x="${NS}" x:Class="App.Home">`,
|
||||
' <!-- <Button x:Name="Fake" /> -->',
|
||||
' <Grid>',
|
||||
' <Button x:Name="SaveButton" Content="Save" />',
|
||||
' <Style x:Key="PrimaryButton">',
|
||||
' <Setter Property="Width" Value="20" />',
|
||||
' </Style>',
|
||||
' </Grid>',
|
||||
'</Page>',
|
||||
].join(newline);
|
||||
expect(extractXamlDeclarations(source)).toEqual([
|
||||
{
|
||||
name: 'App.Home',
|
||||
description: 'Page x:Class declaration',
|
||||
startIndex: 0,
|
||||
startLine: 0,
|
||||
endLine: 8,
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
name: 'SaveButton',
|
||||
description: 'Button x:Name declaration',
|
||||
startIndex: source.replace(/\r\n?/g, '\n').indexOf('<Button x:Name="SaveButton"'),
|
||||
startLine: 3,
|
||||
endLine: 3,
|
||||
level: 3,
|
||||
},
|
||||
{
|
||||
name: 'PrimaryButton',
|
||||
description: 'Style x:Key declaration',
|
||||
startIndex: source.replace(/\r\n?/g, '\n').indexOf('<Style'),
|
||||
startLine: 4,
|
||||
endLine: 6,
|
||||
level: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses namespace identity, including alternate prefixes and scoped rebinding', () => {
|
||||
const declarations = extractXamlDeclarations(`<Grid xmlns:d="${NS}" xmlns:x="urn:not-xaml">
|
||||
<Button d:Name="Real" x:Name="WrongNamespace" Name="Unqualified" />
|
||||
<Panel xmlns:d="urn:other"><Button d:Name="Shadowed" /></Panel>
|
||||
<Panel xmlns:q="${NS}"><Button q:Name="Nested" /></Panel>
|
||||
<Button d:Name="AfterScope" />
|
||||
</Grid>`);
|
||||
expect(declarations.map((d) => d.name)).toEqual(['Real', 'Nested', 'AfterScope']);
|
||||
});
|
||||
|
||||
it('does not infer bindings, handlers, dynamic resource keys or undeclared x prefixes', () => {
|
||||
expect(
|
||||
extractXamlDeclarations(`<Grid xmlns:x="${NS}">
|
||||
<Button Content="{Binding User}" Click="Save" />
|
||||
<Style x:Key="{x:Type Button}" /><Style x:Key="{}escaped" />
|
||||
<Button x:Name="" /><Style x:Key="A&B" />
|
||||
<![CDATA[<Button x:Name="Fake" />]]>
|
||||
</Grid>`),
|
||||
).toEqual([]);
|
||||
expect(extractXamlDeclarations('<Button x:Name="Undeclared" />')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps duplicate names distinct and preserves Unicode names', () => {
|
||||
const declarations = extractXamlDeclarations(
|
||||
`<Grid xmlns:x="${NS}"><Button x:Name="Save" /><Button x:Name="Save" /><Button x:Name="\u03a9" /></Grid>`,
|
||||
);
|
||||
expect(declarations.map((d) => d.name)).toEqual(['Save', 'Save', '\u03a9']);
|
||||
expect(new Set(declarations.map((d) => d.startIndex)).size).toBe(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<Grid><Button></Grid>',
|
||||
`<!DOCTYPE Grid [<!ENTITY label SYSTEM "file:///private">]><Grid xmlns:x="${NS}" x:Name="&label;" />`,
|
||||
`${'<Grid>'.repeat(101)}${'</Grid>'.repeat(101)}`,
|
||||
])('rejects malformed or unsupported input without partial declarations', (source) => {
|
||||
expect(() => extractXamlDeclarations(source)).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -64,6 +64,7 @@ export default defineConfig({
|
|||
test: {
|
||||
name: 'lbug-db',
|
||||
include: [
|
||||
'test/integration/xaml-search.test.ts',
|
||||
'test/integration/impact-epistemic-lower-bound.test.ts',
|
||||
'test/integration/impact-scope-omission-persistence.test.ts',
|
||||
'test/integration/lbug-core-adapter.test.ts',
|
||||
|
|
@ -143,6 +144,7 @@ export default defineConfig({
|
|||
sequence: { groupOrder: 3 },
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: [
|
||||
'test/integration/xaml-search.test.ts',
|
||||
'test/integration/impact-epistemic-lower-bound.test.ts',
|
||||
'test/integration/impact-scope-omission-persistence.test.ts',
|
||||
'test/integration/lbug-core-adapter.test.ts',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue