gitnexus 1.6.13-rc.62 → 1.6.13-rc.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/cli/detect-changes-format.js +6 -0
  2. package/dist/cli/format-path.d.ts +2 -0
  3. package/dist/cli/format-path.js +4 -0
  4. package/dist/cli/i18n/en.d.ts +2 -1
  5. package/dist/cli/i18n/en.js +2 -1
  6. package/dist/cli/i18n/resources.d.ts +3 -1
  7. package/dist/cli/i18n/zh-CN.d.ts +1 -0
  8. package/dist/cli/i18n/zh-CN.js +2 -1
  9. package/dist/cli/status.js +1 -2
  10. package/dist/core/incremental/write-reconciliation.d.ts +23 -0
  11. package/dist/core/incremental/write-reconciliation.js +80 -0
  12. package/dist/core/ingestion/cobol/file-types.d.ts +6 -0
  13. package/dist/core/ingestion/cobol/file-types.js +12 -0
  14. package/dist/core/ingestion/cobol-processor.d.ts +1 -5
  15. package/dist/core/ingestion/cobol-processor.js +2 -10
  16. package/dist/core/ingestion/pipeline-phases/routes.d.ts +1 -1
  17. package/dist/core/ingestion/pipeline-phases/routes.js +2 -9
  18. package/dist/core/ingestion/utils/template-file.d.ts +2 -0
  19. package/dist/core/ingestion/utils/template-file.js +10 -0
  20. package/dist/core/lbug/csv-generator.js +6 -1
  21. package/dist/core/lbug/lbug-adapter.js +46 -10
  22. package/dist/core/run-analyze.js +114 -61
  23. package/dist/mcp/local/local-backend.js +58 -5
  24. package/dist/mcp/tools.js +1 -1
  25. package/dist/server/api.js +1 -1
  26. package/dist/storage/git.d.ts +2 -0
  27. package/dist/storage/git.js +6 -1
  28. package/package.json +2 -2
  29. package/vendor/lbug-fts/manifest.json +2 -2
  30. package/vendor/lbug-fts/prebuilds/SHA256SUMS +5 -5
  31. package/vendor/lbug-fts/prebuilds/darwin-arm64/libfts.lbug_extension +0 -0
  32. package/vendor/lbug-fts/prebuilds/darwin-x64/libfts.lbug_extension +0 -0
  33. package/vendor/lbug-fts/prebuilds/linux-arm64/libfts.lbug_extension +0 -0
  34. package/vendor/lbug-fts/prebuilds/linux-x64/libfts.lbug_extension +0 -0
  35. package/vendor/lbug-fts/prebuilds/win32-x64/libfts.lbug_extension +0 -0
@@ -1,5 +1,6 @@
1
1
  import { t } from './i18n/index.js';
2
2
  import { formatSymbolLine } from './format-symbol.js';
3
+ import { formatPathForTerminal } from './format-path.js';
3
4
  export function formatDetectChangesResult(result) {
4
5
  const payload = (result ?? {});
5
6
  if (payload.error)
@@ -14,6 +15,11 @@ export function formatDetectChangesResult(result) {
14
15
  const notes = [];
15
16
  if (payload.partial)
16
17
  notes.push(t('tool.detectChanges.partial'));
18
+ if (payload.unmapped_files?.length) {
19
+ notes.push(t('tool.detectChanges.unmappedSource', {
20
+ files: payload.unmapped_files.map(formatPathForTerminal).join(', '),
21
+ }));
22
+ }
17
23
  // The plain truncation note reassures that the counts are whole. That is only
18
24
  // true when the run did NOT also degrade — `changed_count` sums the batches
19
25
  // that succeeded — so the two flags together get a different sentence.
@@ -0,0 +1,2 @@
1
+ /** Quote control-bearing paths for terminal output; structured results retain raw paths. */
2
+ export declare const formatPathForTerminal: (filePath: string) => string;
@@ -0,0 +1,4 @@
1
+ /** Quote control-bearing paths for terminal output; structured results retain raw paths. */
2
+ export const formatPathForTerminal = (filePath) => /[\u0000-\u001f\u007f-\u009f]/.test(filePath)
3
+ ? JSON.stringify(filePath).replace(/[\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
4
+ : filePath;
@@ -106,7 +106,8 @@ export declare const en: {
106
106
  readonly 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.";
107
107
  readonly 'tool.detectChanges.noChanges': 'No changes detected.';
108
108
  readonly 'tool.detectChanges.noOverlappingSymbols': 'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.';
109
- readonly 'tool.detectChanges.partial': 'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.';
109
+ readonly 'tool.detectChanges.partial': 'PARTIAL RESULT: changed-symbol or process mapping is incomplete. Do not read this as a clean pre-commit check.';
110
+ readonly 'tool.detectChanges.unmappedSource': 'No symbols mapped for changed source files: {{files}}. Rebuild the index and inspect the diff; retry alone may not resolve missing or out-of-range symbols.';
110
111
  readonly 'tool.detectChanges.truncated': 'LISTING CAPPED: the changed-symbol list was capped, so it does not name every changed symbol. The counts and risk level still cover all of them.';
111
112
  readonly 'tool.detectChanges.truncatedDegraded': 'LISTING CAPPED: the changed-symbol list was capped. The run also degraded, so the counts are a lower bound, not a total.';
112
113
  readonly 'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols';
@@ -106,7 +106,8 @@ export const en = {
106
106
  'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",
107
107
  'tool.detectChanges.noChanges': 'No changes detected.',
108
108
  'tool.detectChanges.noOverlappingSymbols': 'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.',
109
- 'tool.detectChanges.partial': 'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.',
109
+ 'tool.detectChanges.partial': 'PARTIAL RESULT: changed-symbol or process mapping is incomplete. Do not read this as a clean pre-commit check.',
110
+ 'tool.detectChanges.unmappedSource': 'No symbols mapped for changed source files: {{files}}. Rebuild the index and inspect the diff; retry alone may not resolve missing or out-of-range symbols.',
110
111
  'tool.detectChanges.truncated': 'LISTING CAPPED: the changed-symbol list was capped, so it does not name every changed symbol. The counts and risk level still cover all of them.',
111
112
  // The reassurance above is only true on its own. When the run also degraded,
112
113
  // `changed_count` was summed from the batches that SUCCEEDED, so it is a floor.
@@ -107,7 +107,8 @@ export declare const cliResources: {
107
107
  readonly 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.";
108
108
  readonly 'tool.detectChanges.noChanges': 'No changes detected.';
109
109
  readonly 'tool.detectChanges.noOverlappingSymbols': 'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.';
110
- readonly 'tool.detectChanges.partial': 'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.';
110
+ readonly 'tool.detectChanges.partial': 'PARTIAL RESULT: changed-symbol or process mapping is incomplete. Do not read this as a clean pre-commit check.';
111
+ readonly 'tool.detectChanges.unmappedSource': 'No symbols mapped for changed source files: {{files}}. Rebuild the index and inspect the diff; retry alone may not resolve missing or out-of-range symbols.';
111
112
  readonly 'tool.detectChanges.truncated': 'LISTING CAPPED: the changed-symbol list was capped, so it does not name every changed symbol. The counts and risk level still cover all of them.';
112
113
  readonly 'tool.detectChanges.truncatedDegraded': 'LISTING CAPPED: the changed-symbol list was capped. The run also degraded, so the counts are a lower bound, not a total.';
113
114
  readonly 'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols';
@@ -435,6 +436,7 @@ export declare const cliResources: {
435
436
  'tool.detectChanges.noChanges': string;
436
437
  'tool.detectChanges.noOverlappingSymbols': string;
437
438
  'tool.detectChanges.partial': string;
439
+ 'tool.detectChanges.unmappedSource': string;
438
440
  'tool.detectChanges.truncated': string;
439
441
  'tool.detectChanges.truncatedDegraded': string;
440
442
  'tool.detectChanges.changesSummary': string;
@@ -107,6 +107,7 @@ export declare const zhCN: {
107
107
  'tool.detectChanges.noChanges': string;
108
108
  'tool.detectChanges.noOverlappingSymbols': string;
109
109
  'tool.detectChanges.partial': string;
110
+ 'tool.detectChanges.unmappedSource': string;
110
111
  'tool.detectChanges.truncated': string;
111
112
  'tool.detectChanges.truncatedDegraded': string;
112
113
  'tool.detectChanges.changesSummary': string;
@@ -106,7 +106,8 @@ export const zhCN = {
106
106
  'tool.warn.unknownKind': "--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。",
107
107
  'tool.detectChanges.noChanges': '未检测到变更。',
108
108
  'tool.detectChanges.noOverlappingSymbols': 'diff 触及 {{files}} 个文件,但没有索引符号与这些 hunk 重叠 — 并非干净工作区。',
109
- 'tool.detectChanges.partial': '结果不完整:图查询失败,可能遗漏已变更符号。请勿将其视为通过的提交前检查。',
109
+ 'tool.detectChanges.partial': '结果不完整:变更符号或流程映射不完整。请勿将其视为通过的提交前检查。',
110
+ 'tool.detectChanges.unmappedSource': '变更源码文件未映射到符号:{{files}}。请重建索引并检查 diff;仅重试可能无法解决符号缺失或源码范围不匹配。',
110
111
  'tool.detectChanges.truncated': '列表已截断:已变更符号列表被截断,未列出全部变更符号。计数与风险等级仍涵盖全部符号。',
111
112
  'tool.detectChanges.truncatedDegraded': '列表已截断:已变更符号列表被截断。本次运行同时不完整,因此计数为下限而非总数。',
112
113
  'tool.detectChanges.changesSummary': '变更:{{files}} 个文件,{{symbols}} 个符号',
@@ -16,6 +16,7 @@ import { getFtsDisabledReason, FTS_DISABLED_MESSAGE } from '../core/search/fts-p
16
16
  import { detectIndexContentDrift } from '../core/index-content-drift.js';
17
17
  import { checkoutIsDirectory, contentRetentionFromMeta, isFullSourceAvailable, } from '../core/content-retention.js';
18
18
  import { t } from './i18n/index.js';
19
+ import { formatPathForTerminal as formatDriftPath } from './format-path.js';
19
20
  /** How many drifted paths the report names before summarizing the rest. */
20
21
  const DRIFT_SAMPLE_LIMIT = 10;
21
22
  /**
@@ -50,8 +51,6 @@ const describeContentDrift = (drift) => {
50
51
  },
51
52
  };
52
53
  };
53
- /** Escape control characters in repo-relative paths before printing. */
54
- const formatDriftPath = (rel) => /[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel;
55
54
  const printDriftDetail = (drift) => {
56
55
  console.log(t('status.indexContentDrifted', {
57
56
  changed: drift.changed.length,
@@ -0,0 +1,23 @@
1
+ import type { KnowledgeGraph } from '../graph/types.js';
2
+ interface IdentityRow {
3
+ id: string;
4
+ name: string | null;
5
+ filePath: string | null;
6
+ startLine?: number | null;
7
+ endLine?: number | null;
8
+ }
9
+ /**
10
+ * Certify the identity fields used by context/impact/detect_changes, including
11
+ * retained rows. A write-set-only probe misses corruption of an unchanged row.
12
+ * Scan one label at a time, without path/range predicates: a corrupt field must
13
+ * not be able to hide its own row. No multi-label scans (#3139), N+1 ID lookups,
14
+ * source-content copies, or whole-graph row materialization.
15
+ *
16
+ * This certifies node identity, not relationships or native storage internals.
17
+ * Call after COPY/checkpoint and after FTS/embedding/checkpoint, before metadata.
18
+ */
19
+ export declare function reconcileGraphNodeIdentities(graph: KnowledgeGraph, query: (cypher: string) => Promise<IdentityRow[]>, phase: string): Promise<{
20
+ nodes: number;
21
+ tables: number;
22
+ }>;
23
+ export {};
@@ -0,0 +1,80 @@
1
+ import { NODE_TABLES } from '../lbug/schema.js';
2
+ import { sanitizeUTF8 } from '../lbug/csv-generator.js';
3
+ // These layers need a different oracle: folders can outlive their last file,
4
+ // derived nodes may be preserved without recomputation, and PDG can be streamed.
5
+ const EXCLUDED = new Set(['Folder', 'Community', 'Process', 'BasicBlock']);
6
+ const WITHOUT_RANGE = new Set(['File', 'Route', 'Tool']);
7
+ const storedString = (value) => sanitizeUTF8(value || '');
8
+ const storedIdentityField = (node, field) => {
9
+ const value = node.properties[field];
10
+ if (field === 'name' || field === 'filePath') {
11
+ return storedString(value);
12
+ }
13
+ if (node.label === 'Destination') {
14
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
15
+ }
16
+ return value ?? -1;
17
+ };
18
+ /**
19
+ * Certify the identity fields used by context/impact/detect_changes, including
20
+ * retained rows. A write-set-only probe misses corruption of an unchanged row.
21
+ * Scan one label at a time, without path/range predicates: a corrupt field must
22
+ * not be able to hide its own row. No multi-label scans (#3139), N+1 ID lookups,
23
+ * source-content copies, or whole-graph row materialization.
24
+ *
25
+ * This certifies node identity, not relationships or native storage internals.
26
+ * Call after COPY/checkpoint and after FTS/embedding/checkpoint, before metadata.
27
+ */
28
+ export async function reconcileGraphNodeIdentities(graph, query, phase) {
29
+ const expected = new Map();
30
+ for (const table of NODE_TABLES) {
31
+ if (!EXCLUDED.has(table))
32
+ expected.set(table, new Map());
33
+ }
34
+ for (const node of graph.iterNodes()) {
35
+ const table = expected.get(node.label);
36
+ if (!table)
37
+ continue;
38
+ const id = storedString(node.id);
39
+ if (!id || table.has(id)) {
40
+ throw new Error(`Graph identity reconciliation (${phase}): invalid or duplicate ID ${id}`);
41
+ }
42
+ table.set(id, node);
43
+ }
44
+ let nodes = 0;
45
+ for (const [table, remaining] of expected) {
46
+ const fields = WITHOUT_RANGE.has(table)
47
+ ? ['id', 'name', 'filePath']
48
+ : ['id', 'name', 'filePath', 'startLine', 'endLine'];
49
+ const identityFields = fields.slice(1);
50
+ const fail = (detail, cause) => {
51
+ throw new Error(`Graph identity reconciliation failed (${phase}, ${table}): ${detail}. ` +
52
+ 'Freshness was not advanced; run `gitnexus analyze --force` to rebuild the graph.', { cause });
53
+ };
54
+ let rows;
55
+ try {
56
+ rows = await query(`MATCH (n:\`${table}\`) RETURN ${fields.map((f) => `n.${f} AS ${f}`).join(', ')}`);
57
+ }
58
+ catch (error) {
59
+ fail(`could not read identity fields: ${error instanceof Error ? error.message : String(error)}`, error);
60
+ }
61
+ for (const row of rows) {
62
+ const node = remaining.get(row.id);
63
+ if (!node)
64
+ fail(`unexpected or duplicate ID ${JSON.stringify(row.id)}`);
65
+ for (const field of identityFields) {
66
+ const wanted = storedIdentityField(node, field);
67
+ const actual = field === 'name' || field === 'filePath' ? (row[field] ?? '') : row[field];
68
+ if (actual !== wanted) {
69
+ fail(`${row.id}.${field}: expected ${JSON.stringify(wanted)}, read ${JSON.stringify(actual)}`);
70
+ }
71
+ }
72
+ remaining.delete(row.id);
73
+ nodes++;
74
+ }
75
+ if (remaining.size) {
76
+ fail(`${remaining.size} missing ID(s), including ${JSON.stringify(remaining.keys().next().value)}`);
77
+ }
78
+ }
79
+ return { nodes, tables: expected.size };
80
+ }
@@ -0,0 +1,6 @@
1
+ export declare const COBOL_EXTENSIONS: Set<string>;
2
+ export declare const JCL_EXTENSIONS: Set<string>;
3
+ /** Includes COBOL programs and copybooks accepted by ingestion. */
4
+ export declare function isCobolFile(filePath: string): boolean;
5
+ /** Includes job and procedure aliases accepted by JCL ingestion. */
6
+ export declare function isJclFile(filePath: string): boolean;
@@ -0,0 +1,12 @@
1
+ import path from 'node:path';
2
+ // Keep detection shared without loading the analyze-only language providers.
3
+ export const COBOL_EXTENSIONS = new Set(['.cob', '.cbl', '.cobol', '.cpy', '.copybook']);
4
+ export const JCL_EXTENSIONS = new Set(['.jcl', '.job', '.proc']);
5
+ /** Includes COBOL programs and copybooks accepted by ingestion. */
6
+ export function isCobolFile(filePath) {
7
+ return COBOL_EXTENSIONS.has(path.extname(filePath).toLowerCase());
8
+ }
9
+ /** Includes job and procedure aliases accepted by JCL ingestion. */
10
+ export function isJclFile(filePath) {
11
+ return JCL_EXTENSIONS.has(path.extname(filePath).toLowerCase());
12
+ }
@@ -13,6 +13,7 @@
13
13
  * 5. Optionally process JCL files for job-step cross-references
14
14
  */
15
15
  import type { KnowledgeGraph } from '../graph/types.js';
16
+ export { isCobolFile, isJclFile } from './cobol/file-types.js';
16
17
  interface CobolFile {
17
18
  path: string;
18
19
  content: string;
@@ -39,10 +40,6 @@ export interface CobolProcessResult {
39
40
  initializes: number;
40
41
  arithmeticOps: number;
41
42
  }
42
- /** Returns true if the file is a COBOL or copybook file. */
43
- export declare function isCobolFile(filePath: string): boolean;
44
- /** Returns true if the file is a JCL file. */
45
- export declare function isJclFile(filePath: string): boolean;
46
43
  /**
47
44
  * Process COBOL and JCL files into the knowledge graph.
48
45
  *
@@ -52,4 +49,3 @@ export declare function isJclFile(filePath: string): boolean;
52
49
  * @returns Summary of what was extracted
53
50
  */
54
51
  export declare const processCobol: (graph: KnowledgeGraph, files: CobolFile[], allPathSet: ReadonlySet<string>) => CobolProcessResult;
55
- export {};
@@ -20,21 +20,13 @@ import { preprocessCobolSource, extractCobolSymbolsWithRegex, } from './cobol/co
20
20
  import { expandCopies } from './cobol/cobol-copy-expander.js';
21
21
  import { processJclFiles } from './cobol/jcl-processor.js';
22
22
  import { resolveCobolCopyTarget } from './languages/cobol/copy-target.js';
23
+ import { COBOL_EXTENSIONS, JCL_EXTENSIONS } from './cobol/file-types.js';
24
+ export { isCobolFile, isJclFile } from './cobol/file-types.js';
23
25
  import { logger } from '../logger.js';
24
26
  // ---------------------------------------------------------------------------
25
27
  // File detection
26
28
  // ---------------------------------------------------------------------------
27
- const COBOL_EXTENSIONS = new Set(['.cob', '.cbl', '.cobol', '.cpy', '.copybook']);
28
- const JCL_EXTENSIONS = new Set(['.jcl', '.job', '.proc']);
29
29
  const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
30
- /** Returns true if the file is a COBOL or copybook file. */
31
- export function isCobolFile(filePath) {
32
- return COBOL_EXTENSIONS.has(path.extname(filePath).toLowerCase());
33
- }
34
- /** Returns true if the file is a JCL file. */
35
- export function isJclFile(filePath) {
36
- return JCL_EXTENSIONS.has(path.extname(filePath).toLowerCase());
37
- }
38
30
  /** Returns true if the file is a COBOL copybook. */
39
31
  function isCopybook(filePath) {
40
32
  return COPYBOOK_EXTENSIONS.has(path.extname(filePath).toLowerCase());
@@ -11,6 +11,7 @@
11
11
  * @output routeRegistry, handlerContents
12
12
  */
13
13
  import type { PipelinePhase } from './types.js';
14
+ export { isTemplateRouteCandidate } from '../utils/template-file.js';
14
15
  export interface RouteEntry {
15
16
  filePath: string;
16
17
  source: string;
@@ -39,6 +40,5 @@ export interface TemplateFetchCall {
39
40
  fetchURL: string;
40
41
  lineNumber: number;
41
42
  }
42
- export declare const isTemplateRouteCandidate: (filePath: string) => boolean;
43
43
  export declare function extractTemplateStaticFetchCalls(filePath: string, content: string, namedRouteUrls?: ReadonlyMap<string, string>): TemplateFetchCall[];
44
44
  export declare const routesPhase: PipelinePhase<RoutesOutput>;
@@ -11,7 +11,7 @@
11
11
  * @output routeRegistry, handlerContents
12
12
  */
13
13
  import { getPhaseOutput } from './types.js';
14
- import { isBladeTemplateFilename } from '../../../_shared/index.js';
14
+ import { isTemplateRouteCandidate } from '../utils/template-file.js';
15
15
  import { nextjsFileToRouteURL, normalizeFetchURL } from '../route-extractors/nextjs.js';
16
16
  import { expoFileToRouteURL } from '../route-extractors/expo.js';
17
17
  import { phpFileToRouteURL } from '../route-extractors/php.js';
@@ -25,6 +25,7 @@ import { generateId } from '../../../lib/utils.js';
25
25
  import { readFileContents } from '../filesystem-walker.js';
26
26
  import { isDev } from '../utils/env.js';
27
27
  import { logger } from '../../logger.js';
28
+ export { isTemplateRouteCandidate } from '../utils/template-file.js';
28
29
  const EXPO_NAV_PATTERNS = [
29
30
  /router\.(push|replace|navigate)\(\s*['"`]([^'"`]+)['"`]/g,
30
31
  /<Link\s+[^>]*href=\s*['"`]([^'"`]+)['"`]/g,
@@ -65,14 +66,6 @@ const TEMPLATE_NAMED_ROUTE_PATTERNS = [
65
66
  function hasRouteParameters(routeUrl) {
66
67
  return /\{[^}]+\}/.test(routeUrl);
67
68
  }
68
- export const isTemplateRouteCandidate = (filePath) => {
69
- const normalized = filePath.replace(/\\/g, '/').toLowerCase();
70
- return (normalized.endsWith('.html') ||
71
- normalized.endsWith('.htm') ||
72
- normalized.endsWith('.ejs') ||
73
- normalized.endsWith('.hbs') ||
74
- isBladeTemplateFilename(normalized));
75
- };
76
69
  export function extractTemplateStaticFetchCalls(filePath, content, namedRouteUrls = new Map()) {
77
70
  const calls = [];
78
71
  const seen = new Set();
@@ -0,0 +1,2 @@
1
+ /** Templates whose URLs and fetches can contribute route relationships to the graph. */
2
+ export declare const isTemplateRouteCandidate: (filePath: string) => boolean;
@@ -0,0 +1,10 @@
1
+ import { isBladeTemplateFilename } from '../../../_shared/index.js';
2
+ /** Templates whose URLs and fetches can contribute route relationships to the graph. */
3
+ export const isTemplateRouteCandidate = (filePath) => {
4
+ const normalized = filePath.replace(/\\/g, '/').toLowerCase();
5
+ return (normalized.endsWith('.html') ||
6
+ normalized.endsWith('.htm') ||
7
+ normalized.endsWith('.ejs') ||
8
+ normalized.endsWith('.hbs') ||
9
+ isBladeTemplateFilename(normalized));
10
+ };
@@ -91,9 +91,14 @@ export const sanitizeUTF8 = (str) => {
91
91
  };
92
92
  export const escapeCSVField = (value) => {
93
93
  if (value === undefined || value === null)
94
- return '""';
94
+ return '';
95
95
  let str = String(value);
96
96
  str = sanitizeUTF8(str);
97
+ // Preserve the NULL meaning of absent strings from the 0.18.3 writer.
98
+ // Newer LadybugDB readers retain a quoted empty field as a STRING value;
99
+ // it must stay unquoted or unresolved destination addresses can join.
100
+ if (str.length === 0)
101
+ return '';
97
102
  return `"${str.replace(/"/g, '""')}"`;
98
103
  };
99
104
  export const escapeCSVNumber = (value, defaultValue = -1) => {
@@ -908,23 +908,59 @@ const doInitLbug = async (dbPath, options = {}) => {
908
908
  };
909
909
  /**
910
910
  * Run a COPY, retrying once with IGNORE_ERRORS=true (which skips row-level
911
- * errors) on first failure. On a second failure, hand the RAW retry error to
912
- * `onError` — each call site formats + slices its own message (#2226 F5: node
913
- * COPY slices to 200 chars and throws; relationship COPY slices to 80 and warns,
914
- * so the helper must not pre-format and lose that distinction). `onError` may
915
- * throw to propagate the failure.
911
+ * errors) on first failure. Log the original failure and native COPY/warning
912
+ * receipts even when the retry succeeds. Node COPY requires every row; a
913
+ * skipped-node receipt is a failure. Call sites retain their own message
914
+ * limits and relationship fallback policy.
916
915
  */
917
- const copyCsvWithRetry = async (targetConn, copyQuery, onError) => {
916
+ const copyCsvWithRetry = async (targetConn, copyQuery, onError, expectedRows) => {
918
917
  try {
919
918
  await queryAndDrain(targetConn, copyQuery);
920
919
  }
921
- catch {
920
+ catch (firstError) {
921
+ logger.warn({ err: firstError, copyQuery }, 'First COPY failure; retrying with IGNORE_ERRORS=true');
922
922
  try {
923
923
  const retryQuery = copyQuery.replace('auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)');
924
- await queryAndDrain(targetConn, retryQuery);
924
+ // Keep COPY and its connection-local warning receipt in one critical
925
+ // section. Only project diagnostics, never skipped_line_or_record: that
926
+ // column contains source text. Warnings are retained at a bounded native
927
+ // limit, so their count is a lower bound, not the exact skipped total.
928
+ const retry = async () => {
929
+ await drainQueryResult(await targetConn.query('CALL CLEAR_WARNINGS()'));
930
+ const result = await readQueryRows(await targetConn.query(retryQuery));
931
+ const warnings = await readQueryRows(await targetConn.query('CALL SHOW_WARNINGS() RETURN message, file_path, line_number'));
932
+ // The native warning limit can be zero; warning rows alone cannot
933
+ // prove that every CSV row landed. Node COPY exposes its copied count
934
+ // in the result receipt even when warning retention is disabled.
935
+ const countReceipt = result
936
+ .map((row) => String(row.result ?? ''))
937
+ .map((message) => /^(\d+) tuples have been copied/.exec(message))
938
+ .find(Boolean);
939
+ const copiedRows = countReceipt ? Number(countReceipt[1]) : undefined;
940
+ logger.warn({
941
+ copyQuery,
942
+ copyResult: result,
943
+ expectedRows,
944
+ copiedRows,
945
+ skippedRows: expectedRows !== undefined && copiedRows !== undefined
946
+ ? Math.max(0, expectedRows - copiedRows)
947
+ : undefined,
948
+ retainedWarnings: warnings.length,
949
+ warningSamples: warnings.slice(0, 5),
950
+ }, 'COPY retry completed; retained warnings describe skipped rows (a lower bound)');
951
+ if (expectedRows !== undefined && (copiedRows !== expectedRows || warnings.length > 0)) {
952
+ throw new Error(`COPY retry skipped rows or could not verify a complete node load ` +
953
+ `(copied ${copiedRows ?? 'unknown'} of ${expectedRows}; ${warnings.length} retained warning(s))`);
954
+ }
955
+ };
956
+ await (isSharedSingletonConn(targetConn) ? withConnLock(retry) : retry());
925
957
  }
926
958
  catch (retryErr) {
927
- onError(retryErr);
959
+ const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
960
+ const retryMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
961
+ onError(new Error(`COPY retry failed: ${retryMessage}; first failure: ${firstMessage}`, {
962
+ cause: retryErr,
963
+ }));
928
964
  }
929
965
  }
930
966
  };
@@ -992,7 +1028,7 @@ const copyNodeCSVs = async (targetConn, nodeFileEntries, log, totalSteps) => {
992
1028
  // sizing was calibrated for — name the knob and the mechanism.
993
1029
  const remedy = bufferPoolExhaustionRemedy(retryMsg);
994
1030
  throw new Error(`COPY failed for ${table}: ${retryMsg.slice(0, 200)}${remedy ? ` ${remedy}` : ''}`);
995
- });
1031
+ }, rows);
996
1032
  }
997
1033
  };
998
1034
  /**
@@ -24,6 +24,7 @@ import { runPipelineFromRepo } from './ingestion/pipeline.js';
24
24
  import { logUnresolvedReceiverFiles, summarizeUnresolvedReceivers, } from './ingestion/scope-resolution/unresolved-receivers.js';
25
25
  import { summarizeUndecidedSatisfaction } from './ingestion/scope-resolution/undecided-satisfaction.js';
26
26
  import { summarizeScopeExtractionFailures } from './ingestion/scope-resolution/scope-extraction-failures.js';
27
+ import { reconcileGraphNodeIdentities } from './incremental/write-reconciliation.js';
27
28
  import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
28
29
  import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, closeLbugBeforeExit, loadCachedEmbeddings, deleteNodesForFiles, nodeTablesWithRowsForFiles, snapshotDerivedRelsForFiles, restoreDerivedRels, ensureEmbeddingRowDmlSafe, ensureFtsRowDmlSafe, readIndexCatalogSnapshot, INDEX_CATALOG_UNREADABLE, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, deleteAllAdvisedBy, deleteAllDestinations, deleteSpringAopEvidenceNodes, deleteSpringAutoConfigurationDeclarations, deleteSpringAutoConfigurationSyntheticClasses, queryImportersBatch, loadFTSExtension, wipeLbugDbFiles, LbugWipeError, DELETE_FILES_CHUNK_SIZE, } from './lbug/lbug-adapter.js';
29
30
  import { estimateBufferPool, setBufferPoolSizeHint, resolveNativeSafeStorageDir, } from './lbug/lbug-config.js';
@@ -36,7 +37,7 @@ import { cjkSegmentationModeMismatch, getSearchFTSCjkSegmentation, initialiseSea
36
37
  import { getExtensionCapability, getExtensionCapabilities, getFtsCapability, resolveAnalyzeInstallPolicy, } from './lbug/extension-loader.js';
37
38
  import { diagnoseExtensionLoad, extractExtensionPath, usesClassifiedLoadRemedy, } from './lbug/extension-load-error.js';
38
39
  import { resolveFtsVersionPair } from './lbug/vendored-extension-path.js';
39
- import { startWalCheckpointDriver, checkpointOnce, } from './lbug/wal-checkpoint-driver.js';
40
+ import { startWalCheckpointDriver, checkpointOnce, isManualCheckpointEnabled, } from './lbug/wal-checkpoint-driver.js';
40
41
  import { ftsCrashParkFailureMessage, quarantineSidecarsForDirtyRecovery, inspectLbugSidecars, } from './lbug/sidecar-recovery.js';
41
42
  import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, setShareOptOut, adoptFlatBranchLabel, isReadOnlyFilesystemError, isRepoRegistered, cleanupOldKuzuFiles, reconcileMetadataFiles, ensureStoragePathWritable, isMissingFilesystemError, INDEX_METADATA_FILE, CONTENT_RETENTION_SCHEMA_VERSION, } from '../storage/repo-manager.js';
42
43
  import { ANALYZE_FORCE_STORAGE_REQUIREMENTS, ANALYZE_STORAGE_REQUIREMENTS, defaultStoragePath, requireRegisteredStoragePath, requireStoragePath, resolveStoragePath, } from '../storage/storage-resolver.js';
@@ -2046,7 +2047,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
2046
2047
  // (Bugbot review on PR #1479: a prediction that flipped post-pipeline
2047
2048
  // could skip the embedding cache load and then take the full-rebuild
2048
2049
  // path, silently losing embeddings).
2049
- const isIncremental = !options.force &&
2050
+ const incrementalEligible = !options.force &&
2050
2051
  !!existingMeta &&
2051
2052
  // Belt and braces, not a second gate: the guard above already set `force`
2052
2053
  // on exactly this condition, and `!options.force` short-circuits before
@@ -2057,6 +2058,12 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
2057
2058
  Object.keys(existingMeta.fileHashes).length > 0 &&
2058
2059
  repoHasGit &&
2059
2060
  allFilePaths.length > 0;
2061
+ // Select a full build before any selective mutation when the operator has
2062
+ // disabled the checkpoint needed to certify an incremental publication.
2063
+ const isIncremental = incrementalEligible && isManualCheckpointEnabled();
2064
+ if (incrementalEligible && !isIncremental) {
2065
+ log('Manual WAL checkpoints are disabled; switching to a full DB write before mutation.');
2066
+ }
2060
2067
  const hashDiff = isIncremental
2061
2068
  ? diffFileHashes(newFileHashes, existingMeta.fileHashes)
2062
2069
  : undefined;
@@ -2287,6 +2294,25 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
2287
2294
  // collapse check compares the whole in-memory graph against the whole DB,
2288
2295
  // which is only a like-for-like comparison on a full rebuild.
2289
2296
  let wroteChangedSubgraphOnly = false;
2297
+ const markIncrementalGraphVerification = async () => {
2298
+ if (buildPath !== lbugPath)
2299
+ return;
2300
+ const latest = await loadMeta(metaDir);
2301
+ if (!latest?.incrementalInProgress) {
2302
+ throw new Error('Cannot certify incremental graph without its dirty metadata marker.');
2303
+ }
2304
+ // A failed or aborted identity scan is a graph failure. FTS-only repair
2305
+ // and FTS crash recovery must not clear it while retaining these rows.
2306
+ await saveMeta(metaDir, {
2307
+ ...latest,
2308
+ incrementalInProgress: {
2309
+ ...latest.incrementalInProgress,
2310
+ phase: 'graph-reconciliation',
2311
+ updatedAt: Date.now(),
2312
+ checkpointSucceeded: false,
2313
+ },
2314
+ });
2315
+ };
2290
2316
  let incrementalFtsRebuildTables;
2291
2317
  if (isIncremental && hashDiff) {
2292
2318
  // ── Incremental DB writeback ───────────────────────────────────
@@ -2335,6 +2361,9 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
2335
2361
  // strict-equality consumer of the diagnostics shape.
2336
2362
  let droppedImporterChunks = 0;
2337
2363
  const saveIncrementalDirtyState = async (phase, extra = {}) => {
2364
+ // Do not stamp live metadata while writing a staging database.
2365
+ if (buildPath !== lbugPath)
2366
+ return;
2338
2367
  await saveMeta(metaDir, {
2339
2368
  ...existingMeta,
2340
2369
  incrementalInProgress: {
@@ -2995,6 +3024,10 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
2995
3024
  log(`Boundary WAL checkpoint failed on the in-place FTS path (best-effort): ${detail}. ` +
2996
3025
  'Continuing; recovery will treat the graph-boundary checkpoint as unsuccessful.');
2997
3026
  }
3027
+ if (wroteChangedSubgraphOnly) {
3028
+ await markIncrementalGraphVerification();
3029
+ await reconcileGraphNodeIdentities(pipelineResult.graph, executeQuery, 'post-COPY/checkpoint');
3030
+ }
2998
3031
  if (shouldStampFtsDirtyPhase(ftsWritePlan)) {
2999
3032
  // Lift the prior-meta precondition: a first-ever in-place run (Windows
3000
3033
  // full rebuild, or any in-place incremental) must stamp too. Staging
@@ -3083,6 +3116,8 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3083
3116
  ? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
3084
3117
  : undefined,
3085
3118
  });
3119
+ if (wroteChangedSubgraphOnly)
3120
+ await markIncrementalGraphVerification();
3086
3121
  if (ftsResult.ok) {
3087
3122
  progress('fts', 90, 'Search indexes ready');
3088
3123
  }
@@ -3127,6 +3162,11 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3127
3162
  : FTS_UNAVAILABLE_MESSAGE);
3128
3163
  progress('fts', 90, 'Search indexes skipped (FTS unavailable)');
3129
3164
  }
3165
+ if (wroteChangedSubgraphOnly) {
3166
+ // FTS has returned. Later embedding/finalization failures must require
3167
+ // graph recovery, since post-FTS node identities are not yet certified.
3168
+ await markIncrementalGraphVerification();
3169
+ }
3130
3170
  // ── Phase 3.5: Re-insert cached embeddings ────────────────────────
3131
3171
  // Runs on BOTH the full-rebuild path and the incremental path:
3132
3172
  // - Full rebuild / escalated write: DB was wiped, every cached row
@@ -3909,6 +3949,78 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3909
3949
  // object is fully computed at this point; only its write is deferred.
3910
3950
  // Parse-cache publish waits until after that swap + saveMeta so a failed
3911
3951
  // registerRepo / close / swap cannot replace live shards (#3153).
3952
+ if (wroteChangedSubgraphOnly) {
3953
+ // Registry freshness must not advance either. Include FTS, embedding
3954
+ // restoration and the final WAL drain in the certified boundary.
3955
+ await markIncrementalGraphVerification();
3956
+ await walCheckpointDriver.stop();
3957
+ if (!(await checkpointOnce())) {
3958
+ throw new Error('Graph identity reconciliation failed: final checkpoint could not be verified; run `gitnexus analyze --force`.');
3959
+ }
3960
+ await reconcileGraphNodeIdentities(pipelineResult.graph, executeQuery, 'pre-publish/checkpoint');
3961
+ }
3962
+ // Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
3963
+ await ensureGitNexusIgnored(repoPath, storagePath);
3964
+ // ── Close LadybugDB ──────────────────────────────────────────────
3965
+ // Stop the manual checkpoint driver before closeLbug so its
3966
+ // in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT.
3967
+ await walCheckpointDriver.stop();
3968
+ // CLI callers (about to process.exit) skip the native close to dodge a
3969
+ // LadybugDB destructor double-free after --pdg writes — closeLbugBeforeExit
3970
+ // CHECKPOINTs for durability then leaves the handles for process exit to
3971
+ // reclaim (#2264). Long-lived callers close for real.
3972
+ //
3973
+ // On Windows a swap must release the build handle before the rename (a
3974
+ // same-process open file can't be renamed), so it forces a real close —
3975
+ // safe because windowsSwapOk excludes --pdg (the #2264 case). POSIX renames
3976
+ // an open file, so it keeps the skip-native-close there.
3977
+ const forceRealCloseForSwap = useAtomicSwap && process.platform === 'win32';
3978
+ await (options.skipNativeCloseOnExit && !forceRealCloseForSwap
3979
+ ? closeLbugBeforeExit()
3980
+ : closeLbug());
3981
+ // #2 atomic publish: the fresh index was built at buildPath (a full rebuild,
3982
+ // or an opt-in atomic incremental that copied the live index in first). Swap
3983
+ // it over the live lbugPath in one rename so an MCP reader that opened
3984
+ // mid-build only ever saw the previous complete index — never a wiped/
3985
+ // half-built file. The close above checkpoint-consolidated buildPath to a
3986
+ // single file (no .wal), so the rename publishes a complete index; a reader
3987
+ // holding the old inode keeps a consistent stale snapshot until the pool
3988
+ // re-opens onto the new one (the pool staleness invalidation). Runs only on
3989
+ // success — a thrown error skips this, leaving the live index intact and the
3990
+ // temp build to be cleared by the next run's wipe.
3991
+ // Only publish if the build actually produced a DB at buildPath. A
3992
+ // degenerate run (empty repo, or a mocked pipeline that never opened the
3993
+ // store) leaves nothing to swap — skip rather than throw ENOENT.
3994
+ const builtDbExists = useAtomicSwap
3995
+ ? await fs.stat(buildPath).then(() => true, () => false)
3996
+ : false;
3997
+ if (useAtomicSwap && builtDbExists) {
3998
+ await retryRename(buildPath, lbugPath);
3999
+ liveIndexMutationStarted = true;
4000
+ // Clear any sidecars orphaned beside the replaced file. A cleanly-closed
4001
+ // prior index has none; a crashed one could, and it would be replay
4002
+ // poison next to the freshly published index. Best-effort.
4003
+ for (const suffix of ['.wal', '.shadow', '.wal.checkpoint']) {
4004
+ await fs.rm(`${lbugPath}${suffix}`, { force: true }).catch(() => { });
4005
+ }
4006
+ // #2614 F4: if the final checkpoint silently failed, the build may still
4007
+ // carry a residual .wal/.shadow under the temp name. MOVE it beside the
4008
+ // published index (not orphan/delete it) so the next open replays the
4009
+ // delta, rather than leaving it under a name LadybugDB never reconciles.
4010
+ for (const suffix of ['.wal', '.shadow']) {
4011
+ await fs.rename(`${buildPath}${suffix}`, `${lbugPath}${suffix}`).catch(() => { });
4012
+ }
4013
+ }
4014
+ // #2614 F1: stamp the freshness metadata now that the index is published.
4015
+ // When meta.indexedAt becomes visible, lbugPath already resolves to the new
4016
+ // inode, so a reader reiniting on the stamp opens the fresh graph rather
4017
+ // than latching on the old one. Leaving the dirty flag set across the swap
4018
+ // is a crash-safety improvement: a failed swap leaves the previous index
4019
+ // live and the next run recovers via the full-rebuild path.
4020
+ await saveMeta(metaDir, meta);
4021
+ // Registry freshness is published only after the graph and its metadata.
4022
+ // A failed close, swap, or metadata save must leave the previous registry
4023
+ // receipt intact, just as it leaves the cache unpublished.
3912
4024
  // Forward the --name alias and the registry-collision bypass bit.
3913
4025
  // `allowDuplicateName` is its own concern — independent from the
3914
4026
  // pipeline `force` above. The CLI maps it from
@@ -3946,8 +4058,6 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3946
4058
  log(`Warning: could not sync the workspace branch label (${e.message}); continuing.`);
3947
4059
  }
3948
4060
  }
3949
- // Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
3950
- await ensureGitNexusIgnored(repoPath, storagePath);
3951
4061
  // ── Generate AI context files (best-effort) ───────────────────────
3952
4062
  let aggregatedClusterCount = 0;
3953
4063
  if (pipelineResult.communityResult?.communities) {
@@ -3984,63 +4094,6 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3984
4094
  // Best-effort — don't fail the entire analysis for context file issues
3985
4095
  }
3986
4096
  }
3987
- // ── Close LadybugDB ──────────────────────────────────────────────
3988
- // Stop the manual checkpoint driver before closeLbug so its
3989
- // in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT.
3990
- await walCheckpointDriver.stop();
3991
- // CLI callers (about to process.exit) skip the native close to dodge a
3992
- // LadybugDB destructor double-free after --pdg writes — closeLbugBeforeExit
3993
- // CHECKPOINTs for durability then leaves the handles for process exit to
3994
- // reclaim (#2264). Long-lived callers close for real.
3995
- //
3996
- // On Windows a swap must release the build handle before the rename (a
3997
- // same-process open file can't be renamed), so it forces a real close —
3998
- // safe because windowsSwapOk excludes --pdg (the #2264 case). POSIX renames
3999
- // an open file, so it keeps the skip-native-close there.
4000
- const forceRealCloseForSwap = useAtomicSwap && process.platform === 'win32';
4001
- await (options.skipNativeCloseOnExit && !forceRealCloseForSwap
4002
- ? closeLbugBeforeExit()
4003
- : closeLbug());
4004
- // #2 atomic publish: the fresh index was built at buildPath (a full rebuild,
4005
- // or an opt-in atomic incremental that copied the live index in first). Swap
4006
- // it over the live lbugPath in one rename so an MCP reader that opened
4007
- // mid-build only ever saw the previous complete index — never a wiped/
4008
- // half-built file. The close above checkpoint-consolidated buildPath to a
4009
- // single file (no .wal), so the rename publishes a complete index; a reader
4010
- // holding the old inode keeps a consistent stale snapshot until the pool
4011
- // re-opens onto the new one (the pool staleness invalidation). Runs only on
4012
- // success — a thrown error skips this, leaving the live index intact and the
4013
- // temp build to be cleared by the next run's wipe.
4014
- // Only publish if the build actually produced a DB at buildPath. A
4015
- // degenerate run (empty repo, or a mocked pipeline that never opened the
4016
- // store) leaves nothing to swap — skip rather than throw ENOENT.
4017
- const builtDbExists = useAtomicSwap
4018
- ? await fs.stat(buildPath).then(() => true, () => false)
4019
- : false;
4020
- if (useAtomicSwap && builtDbExists) {
4021
- await retryRename(buildPath, lbugPath);
4022
- liveIndexMutationStarted = true;
4023
- // Clear any sidecars orphaned beside the replaced file. A cleanly-closed
4024
- // prior index has none; a crashed one could, and it would be replay
4025
- // poison next to the freshly published index. Best-effort.
4026
- for (const suffix of ['.wal', '.shadow', '.wal.checkpoint']) {
4027
- await fs.rm(`${lbugPath}${suffix}`, { force: true }).catch(() => { });
4028
- }
4029
- // #2614 F4: if the final checkpoint silently failed, the build may still
4030
- // carry a residual .wal/.shadow under the temp name. MOVE it beside the
4031
- // published index (not orphan/delete it) so the next open replays the
4032
- // delta, rather than leaving it under a name LadybugDB never reconciles.
4033
- for (const suffix of ['.wal', '.shadow']) {
4034
- await fs.rename(`${buildPath}${suffix}`, `${lbugPath}${suffix}`).catch(() => { });
4035
- }
4036
- }
4037
- // #2614 F1: stamp the freshness metadata now that the index is published.
4038
- // When meta.indexedAt becomes visible, lbugPath already resolves to the new
4039
- // inode, so a reader reiniting on the stamp opens the fresh graph rather
4040
- // than latching on the old one. Leaving the dirty flag set across the swap
4041
- // is a crash-safety improvement: a failed swap leaves the previous index
4042
- // live and the next run recovers via the full-rebuild path.
4043
- await saveMeta(metaDir, meta);
4044
4097
  // Persist the incremental parse cache only after a successful graph
4045
4098
  // publish (#3153). try/catch so a cache-write failure never breaks an
4046
4099
  // otherwise successful indexing run. Prune stale chunk-hash entries first
@@ -9,7 +9,7 @@ import { resolveGraphPath } from '../../storage/shared-store.js';
9
9
  import fs from 'fs/promises';
10
10
  import path from 'path';
11
11
  import { createHash } from 'crypto';
12
- import { scoreImpactRisk, unusedAxesForImpactWalk } from '../../_shared/index.js';
12
+ import { scoreImpactRisk, unusedAxesForImpactWalk, getLanguageFromFilename, } from '../../_shared/index.js';
13
13
  import { initLbug, executeQuery, executeParameterized, ensureVectorExtension, closeLbug, isLbugReady, statDbIdentity, dbIdentityChanged, } from '../../core/lbug/pool-adapter.js';
14
14
  import { queryClassBeanMetadata } from './bean-metadata.js';
15
15
  import { querySpringAopMetadata } from './aop-metadata.js';
@@ -20,8 +20,10 @@ import { shapeQueryProcessAttaches } from './query-process-attaches.js';
20
20
  import { LBUG_ID_PROBE_BATCH_SIZE, LBUG_QUERY_BATCH_SIZE } from '../../core/lbug/query-batch.js';
21
21
  import { chunk, mapConcurrent } from '../../lib/utils.js';
22
22
  import { pathSuffixOf } from './path-predicate.js';
23
+ import { isCobolFile, isJclFile } from '../../core/ingestion/cobol/file-types.js';
23
24
  import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js';
24
25
  import { isTestFilePath } from '../../core/ingestion/utils/test-file-path.js';
26
+ import { isTemplateRouteCandidate } from '../../core/ingestion/utils/template-file.js';
25
27
  import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
26
28
  // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
27
29
  // at MCP server startup — crashes on unsupported Node ABI versions (#89)
@@ -188,6 +190,22 @@ function resolveAliasString(canonical, legacy) {
188
190
  function nonBlankUid(value) {
189
191
  return typeof value === 'string' ? value.trim() || undefined : undefined;
190
192
  }
193
+ const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = 'Run gitnexus analyze --force from the affected repository root to rebuild the index.';
194
+ class SymbolIdentityError extends Error {
195
+ constructor() {
196
+ super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
197
+ this.name = 'SymbolIdentityError';
198
+ }
199
+ }
200
+ /** Validate database identities before using them as graph traversal anchors. */
201
+ function assertSymbolIdentity(id, expectedUid) {
202
+ if (typeof id !== 'string' ||
203
+ !id.trim() ||
204
+ id.includes('\0') ||
205
+ (expectedUid !== undefined && id !== expectedUid)) {
206
+ throw new SymbolIdentityError();
207
+ }
208
+ }
191
209
  const TOOL_STRING_ALIASES = {
192
210
  impact: [{ canonical: 'target', aliases: ['name', 'symbol'] }],
193
211
  context: [{ canonical: 'file_path', aliases: ['file'] }],
@@ -3560,6 +3578,7 @@ export class LocalBackend {
3560
3578
  endLine: (r.endLine ?? r[5]),
3561
3579
  ...(include_content ? { content: (r.content ?? r[6]) } : {}),
3562
3580
  };
3581
+ assertSymbolIdentity(symbol.id, uid);
3563
3582
  // Same LadybugDB label-enrichment as the name-based path: a UID
3564
3583
  // pointing at a Class must still surface `type: 'Class'` so impact's
3565
3584
  // Class/Interface BFS seed fires. No-op when type is already set.
@@ -3681,6 +3700,10 @@ export class LocalBackend {
3681
3700
  endLine: (r.endLine ?? r[5]),
3682
3701
  ...(include_content ? { content: (r.content ?? r[6]) } : {}),
3683
3702
  }));
3703
+ // Reject the whole result before narrowing or scoring: dropping a corrupt
3704
+ // candidate could make an unrelated surviving symbol look unambiguous.
3705
+ for (const candidate of normalized)
3706
+ assertSymbolIdentity(candidate.id);
3684
3707
  // An exact File path wins over anchored suffix candidates. Without this,
3685
3708
  // `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an
3686
3709
  // otherwise unambiguous exact target into `ambiguous` (#3084 review P2).
@@ -3810,6 +3833,9 @@ export class LocalBackend {
3810
3833
  }
3811
3834
  catch (err) {
3812
3835
  const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed';
3836
+ if (err instanceof SymbolIdentityError) {
3837
+ return { error: msg, recoverySuggestion: SYMBOL_IDENTITY_RECOVERY_SUGGESTION };
3838
+ }
3813
3839
  if (isWalCorruptionError(err)) {
3814
3840
  return {
3815
3841
  error: msg,
@@ -5193,6 +5219,7 @@ export class LocalBackend {
5193
5219
  // throwaway arrays the size of the row set (40k rows 11.4ms → 4.5ms, 200k
5194
5220
  // rows 71.3ms → 26.6ms).
5195
5221
  const exactlyMatchedPaths = new Set();
5222
+ const mappedPaths = new Set();
5196
5223
  for (const row of symbolRows) {
5197
5224
  if (row.filePath === row.diffPath)
5198
5225
  exactlyMatchedPaths.add(row.diffPath);
@@ -5204,6 +5231,9 @@ export class LocalBackend {
5204
5231
  const hunks = hunksByPath.get(diffPath) ?? [];
5205
5232
  if (!hunksOverlapRange(hunks, sym.startLine, sym.endLine))
5206
5233
  continue;
5234
+ // A suffix fallback is a hint, not proof that this is the changed file.
5235
+ if (sym.filePath === diffPath)
5236
+ mappedPaths.add(diffPath);
5207
5237
  if (changedSymbols.has(sym.id))
5208
5238
  continue;
5209
5239
  changedSymbols.set(sym.id, {
@@ -5214,6 +5244,21 @@ export class LocalBackend {
5214
5244
  change_type: 'touched',
5215
5245
  });
5216
5246
  }
5247
+ // An empty successful query cannot prove a source diff is safe: its rows
5248
+ // may be missing, outside indexed spans, or not yet indexed. Keep ordinary
5249
+ // docs/config diffs measurable, but withhold a ranked source-risk verdict.
5250
+ const isSourceFile = (file) => getLanguageFromFilename(file) !== null ||
5251
+ isCobolFile(file) ||
5252
+ isJclFile(file) ||
5253
+ isTemplateRouteCandidate(file);
5254
+ const unmappedFiles = [
5255
+ ...new Set(fileDiffs
5256
+ .filter(({ filePath, oldFilePath }) => !mappedPaths.has(filePath) &&
5257
+ (isSourceFile(filePath) || (oldFilePath !== undefined && isSourceFile(oldFilePath))))
5258
+ .map(({ filePath }) => filePath)),
5259
+ ];
5260
+ if (unmappedFiles.length > 0)
5261
+ queryDegraded = true;
5217
5262
  // Find affected processes -- batched queries instead of N+1
5218
5263
  const affectedProcesses = new Map();
5219
5264
  if (changedSymbols.size > 0) {
@@ -5305,8 +5350,9 @@ export class LocalBackend {
5305
5350
  },
5306
5351
  changed_symbols: listedSymbols,
5307
5352
  affected_processes: Array.from(affectedProcesses.values()),
5308
- // A swallowed query failure makes the counts/risk above incomplete — tell
5309
- // the caller so the safety gate isn't trusted as a clean result (#2283).
5353
+ ...(unmappedFiles.length > 0 && { unmapped_files: unmappedFiles }),
5354
+ // Failed queries or unmapped source files leave counts/risk incomplete;
5355
+ // the safety gate must not treat that as a clean result (#2283).
5310
5356
  ...(queryDegraded && { partial: true }),
5311
5357
  ...(listedSymbols.length < changedSymbols.size && { truncated: true }),
5312
5358
  };
@@ -5727,8 +5773,14 @@ export class LocalBackend {
5727
5773
  catch (err) {
5728
5774
  // Return structured error instead of crashing (#321)
5729
5775
  const message = (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed';
5730
- const suggestion = 'The graph query failed — try gitnexus context <symbol> as a fallback';
5731
- const recoverySuggestion = isWalCorruptionError(err) ? WAL_RECOVERY_SUGGESTION : undefined;
5776
+ const recoverySuggestion = err instanceof SymbolIdentityError
5777
+ ? SYMBOL_IDENTITY_RECOVERY_SUGGESTION
5778
+ : isWalCorruptionError(err)
5779
+ ? WAL_RECOVERY_SUGGESTION
5780
+ : undefined;
5781
+ const suggestion = err instanceof SymbolIdentityError
5782
+ ? SYMBOL_IDENTITY_RECOVERY_SUGGESTION
5783
+ : 'The graph query failed — try gitnexus context <symbol> as a fallback';
5732
5784
  if (params.mode === 'pdg') {
5733
5785
  // Symbol resolution never reached the catch with a resolved symbol (the
5734
5786
  // throw can originate before/within resolution), so the envelope carries
@@ -7474,6 +7526,7 @@ export class LocalBackend {
7474
7526
  'METHOD_IMPLEMENTS',
7475
7527
  ];
7476
7528
  try {
7529
+ assertSymbolIdentity(sym.id ?? sym[0], uid);
7477
7530
  // skipPerSymbolEnrichment suppresses ONLY the per-symbol STEP_IN_PROCESS
7478
7531
  // enrichment pass while preserving byDepth. Group-mode cross-repo fan-out
7479
7532
  // may fan across many repos; the per-symbol pass adds up to MAX_CHUNKS
package/dist/mcp/tools.js CHANGED
@@ -349,7 +349,7 @@ AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ
349
349
  GIT WORKTREE SUPPORT: GitNexus automatically detects when the MCP server was launched from inside a linked git worktree and runs git diff against that worktree — no extra parameters needed in the common case. Pass "worktree" explicitly only when the server was started from a different directory than the worktree you are editing (e.g., the server runs from the canonical root but your changes are in a linked worktree at a different path).
350
350
 
351
351
  Returns: changed symbols, affected processes, and a risk summary.
352
- - partial: true — a step failed and was swallowed, so the result is incomplete and risk_level is "unknown" instead of a ranked level. Two causes, with different blast radii: the symbol query (or an unparseable diff) degrades everything — changed_symbols, both counts, and the processes derived from them — while a failed process lookup degrades only affected_processes and the risk read off it, leaving the changed-symbol counts sound. changed_count:0 with partial:true is NOT a clean pre-commit check; re-run before treating the diff as safe.
352
+ - partial: true — mapping is incomplete, so risk_level is "unknown" instead of a ranked level. A failed symbol query (or an unparseable diff) degrades changed_symbols, both counts, and derived processes; a failed process lookup degrades only affected_processes and risk, leaving changed-symbol counts sound. unmapped_files lists changed supported source files with no mapped symbols, including source renames without hunks: their symbols may be missing, unindexed, or outside indexed ranges even when every query succeeded. Rebuild the index and inspect those diffs; retry alone may not resolve this state. changed_count:0 with partial:true is NOT a clean pre-commit check.
353
353
  - truncated: true — the changed_symbols LISTING was capped for this response. summary.changed_count counts every symbol the run observed: the true total normally, a LOWER BOUND when partial:true. Compare it with the array length rather than trusting the array.`,
354
354
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
355
355
  inputSchema: {
@@ -424,7 +424,7 @@ const mapGraphRelationshipRow = (row) => ({
424
424
  sourceId: row.sourceId,
425
425
  targetId: row.targetId,
426
426
  confidence: row.confidence,
427
- reason: row.reason,
427
+ reason: row.reason ?? '',
428
428
  step: row.step,
429
429
  });
430
430
  export const streamGraphNdjson = async (res, includeContent = false, signal) => {
@@ -285,6 +285,8 @@ export interface DiffHunk {
285
285
  }
286
286
  export interface FileDiff {
287
287
  filePath: string;
288
+ /** Decoded pre-rename path; either side can identify a source-file change. */
289
+ oldFilePath?: string;
288
290
  hunks: DiffHunk[];
289
291
  }
290
292
  /** `parseDiffHunks` plus how many `diff --git` headers could not be decoded. */
@@ -1005,7 +1005,12 @@ export function parseDiffHunksResult(diffOutput) {
1005
1005
  unparsedGitHeaders++;
1006
1006
  }
1007
1007
  }
1008
- else if (line.startsWith('rename to ')) {
1008
+ else if (!inHunk && line.startsWith('rename from ')) {
1009
+ const oldFilePath = decodeGitPathToken(line.slice('rename from '.length));
1010
+ if (current && oldFilePath)
1011
+ current.oldFilePath = oldFilePath;
1012
+ }
1013
+ else if (!inHunk && line.startsWith('rename to ')) {
1009
1014
  const filePath = pathFromRenameTo(line);
1010
1015
  if (!filePath)
1011
1016
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.62",
3
+ "version": "1.6.13-rc.64",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -65,7 +65,7 @@
65
65
  "version": "node scripts/sync-plugin-manifests.mjs"
66
66
  },
67
67
  "dependencies": {
68
- "@ladybugdb/core": "0.18.3",
68
+ "@ladybugdb/core": "0.21.1",
69
69
  "@modelcontextprotocol/sdk": "^1.0.0",
70
70
  "@scarf/scarf": "^1.4.0",
71
71
  "busboy": "^1.6.0",
@@ -1,6 +1,6 @@
1
1
  {
2
- "coreVersion": "0.18.3",
3
- "extensionVersion": "0.18.1",
2
+ "coreVersion": "0.21.1",
3
+ "extensionVersion": "0.21.0",
4
4
  "filename": "libfts.lbug_extension",
5
5
  "officialRepo": "https://extension.ladybugdb.com/",
6
6
  "urlTemplate": "{officialRepo}v{extensionVersion}/{upstreamPlatform}/fts/{filename}",
@@ -1,5 +1,5 @@
1
- 01cf0d4debae1b0c7c182bf78747ee1b148a76667bbaa5bb0cb3cc848998028d ./win32-x64/libfts.lbug_extension
2
- 4af2602007a4a02d5b18d0b6ecb20b1cc2f493242a915faf7df1c033136107b1 ./linux-arm64/libfts.lbug_extension
3
- cf687fda0f82bfbe116e775f2f17c39faf45be6300fd1937c2b1afd21142c121 ./linux-x64/libfts.lbug_extension
4
- d3564c05ec28a0293a5488eaff2c06591624ac01a44af183d8cfacd92a29d785 ./darwin-arm64/libfts.lbug_extension
5
- d8589c0a91c6667e959da6a81f712f69b330d0563602933da983edeebee60fc6 ./darwin-x64/libfts.lbug_extension
1
+ 248332de781a7781f612a55b5397e88f58f1f1ba2a973836ada199eab0f6bf10 ./darwin-arm64/libfts.lbug_extension
2
+ 742f2756c2f80bcd1886b6ee0446483038cff0ecf6cf47f698bc6fe855cbaef6 ./linux-x64/libfts.lbug_extension
3
+ 7b6132ae6a554e3eeb3c4b0da7e2eb5c483b258b31e848a0e3d6000ac29885ee ./win32-x64/libfts.lbug_extension
4
+ b8675b086664e3d6742330a517c5b619f0ac10dd094d250f12bef9822a8d5750 ./linux-arm64/libfts.lbug_extension
5
+ c86f427503555a3ccdc55eb66a8d82793a206aac599a6092f7d1d4eb50c21403 ./darwin-x64/libfts.lbug_extension