gitnexus 1.6.10-rc.185 → 1.6.10-rc.186

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.
@@ -53,6 +53,29 @@ export declare const shouldFlushCSVBuffer: (byteCount: number) => boolean;
53
53
  export declare const sanitizeUTF8: (str: string) => string;
54
54
  export declare const escapeCSVField: (value: string | number | undefined | null) => string;
55
55
  export declare const escapeCSVNumber: (value: number | undefined | null, defaultValue?: number) => string;
56
+ /**
57
+ * Did this text come from a binary payload? Density of non-printables over the
58
+ * first {@link BINARY_SAMPLE_CHARS} characters, above 10%.
59
+ *
60
+ * U+FFFD counts, and it is the character that matters most here (#2889). Every
61
+ * source file enters the pipeline through a `utf-8` decode — the content cache
62
+ * below reads with `fs.readFile(path, 'utf-8')`, and the parse worker decodes
63
+ * the same way. That decode is lossy and total: an invalid byte sequence never
64
+ * survives as invalid bytes, it is REPLACED with U+FFFD. So the embedded-binary
65
+ * payloads #2889 describes (webpack bundles, class-file constant pools,
66
+ * serialized objects inside .js/.vue sources) arrive here as long runs of
67
+ * U+FFFD, not as the control bytes this scan was originally written to count —
68
+ * charCode 0xFFFD is neither `< 9`, nor between 13 and 32, nor 127, so the
69
+ * detector scored a wholly corrupt payload as clean text and every caller waved
70
+ * it through.
71
+ *
72
+ * The threshold stays at 10%: a legitimate source file carries no replacement
73
+ * characters at all unless it was mis-decoded, and a handful (a stray latin-1
74
+ * comment, one bad byte in a license header) still scores far under the bar.
75
+ * Density rather than "contains any binary run" is deliberate — a description
76
+ * that is mostly real prose with one stray replacement character is worth more
77
+ * indexed than dropped.
78
+ */
56
79
  export declare const isBinaryContent: (content: string) => boolean;
57
80
  /**
58
81
  * Flatten newlines and tabs to single spaces for FTS-indexed text columns
@@ -113,17 +113,44 @@ const formatCSVStringArray = (value) => {
113
113
  // ============================================================================
114
114
  // CONTENT EXTRACTION (lazy — reads from disk on demand)
115
115
  // ============================================================================
116
+ const BINARY_SAMPLE_CHARS = 1000;
117
+ const UNICODE_REPLACEMENT_CHAR = 0xfffd;
118
+ /**
119
+ * Did this text come from a binary payload? Density of non-printables over the
120
+ * first {@link BINARY_SAMPLE_CHARS} characters, above 10%.
121
+ *
122
+ * U+FFFD counts, and it is the character that matters most here (#2889). Every
123
+ * source file enters the pipeline through a `utf-8` decode — the content cache
124
+ * below reads with `fs.readFile(path, 'utf-8')`, and the parse worker decodes
125
+ * the same way. That decode is lossy and total: an invalid byte sequence never
126
+ * survives as invalid bytes, it is REPLACED with U+FFFD. So the embedded-binary
127
+ * payloads #2889 describes (webpack bundles, class-file constant pools,
128
+ * serialized objects inside .js/.vue sources) arrive here as long runs of
129
+ * U+FFFD, not as the control bytes this scan was originally written to count —
130
+ * charCode 0xFFFD is neither `< 9`, nor between 13 and 32, nor 127, so the
131
+ * detector scored a wholly corrupt payload as clean text and every caller waved
132
+ * it through.
133
+ *
134
+ * The threshold stays at 10%: a legitimate source file carries no replacement
135
+ * characters at all unless it was mis-decoded, and a handful (a stray latin-1
136
+ * comment, one bad byte in a license header) still scores far under the bar.
137
+ * Density rather than "contains any binary run" is deliberate — a description
138
+ * that is mostly real prose with one stray replacement character is worth more
139
+ * indexed than dropped.
140
+ */
116
141
  export const isBinaryContent = (content) => {
117
- if (!content || content.length === 0)
142
+ // `content &&` keeps the original tolerance for a null/undefined caller —
143
+ // `strict` is off in this package, so the type alone does not rule it out.
144
+ const end = content ? Math.min(content.length, BINARY_SAMPLE_CHARS) : 0;
145
+ if (end === 0)
118
146
  return false;
119
- const sample = content.slice(0, 1000);
120
147
  let nonPrintable = 0;
121
- for (let i = 0; i < sample.length; i++) {
122
- const code = sample.charCodeAt(i);
123
- if (code < 9 || (code > 13 && code < 32) || code === 127)
148
+ for (let i = 0; i < end; i++) {
149
+ const code = content.charCodeAt(i);
150
+ if (code < 9 || (code > 13 && code < 32) || code === 127 || code === UNICODE_REPLACEMENT_CHAR)
124
151
  nonPrintable++;
125
152
  }
126
- return nonPrintable / sample.length > 0.1;
153
+ return nonPrintable / end > 0.1;
127
154
  };
128
155
  /**
129
156
  * LRU content cache — avoids re-reading the same source file for every
@@ -200,8 +227,20 @@ class FileContentCache {
200
227
  * otherwise fail to match whitespace-normalized indexed content.
201
228
  */
202
229
  export const normalizeFtsText = (text) => text.replace(/[\r\n\t]+/g, ' ');
203
- /** Composes both FTS-text transforms for the `description` column — one place for the six emission sites below to call, instead of repeating the composition. */
204
- const formatFtsDescription = (description) => normalizeFtsText(applyCjkSegmentationIfEnabled(description));
230
+ /**
231
+ * Composes both FTS-text transforms for the `description` column — one place for
232
+ * the six emission sites below to call, instead of repeating the composition.
233
+ *
234
+ * Binary-looking descriptions are dropped rather than transformed (#2889). The
235
+ * `content` column has always been gated on {@link isBinaryContent} inside
236
+ * {@link extractContent}; `description` never was, so a symbol whose "doc
237
+ * comment" is really a slice of an embedded binary payload had that payload
238
+ * copied verbatim into an FTS-indexed column. Dropping it here rather than at
239
+ * each of the six call sites keeps the gate in the same place as the transforms
240
+ * it guards. Empty string, not a sentinel: unlike `content`, a description has
241
+ * no reader that needs to be told why it is missing.
242
+ */
243
+ const formatFtsDescription = (description) => isBinaryContent(description) ? '' : normalizeFtsText(applyCjkSegmentationIfEnabled(description));
205
244
  // Labels that get exact source-span content (no ±2 window). Single source of
206
245
  // truth in `symbol-labels.ts` — see there for why the exactness depends on the
207
246
  // 0-based line invariant. Kept as a named alias to read intent at the use site.
@@ -21,7 +21,7 @@ import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
21
21
  import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, closeLbugBeforeExit, loadCachedEmbeddings, deleteNodesForFiles, ensureEmbeddingRowDmlSafe, ensureFtsRowDmlSafe, readIndexCatalogSnapshot, INDEX_CATALOG_UNREADABLE, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, deleteAllAdvisedBy, deleteSpringAopEvidenceNodes, deleteSpringAutoConfigurationDeclarations, deleteSpringAutoConfigurationSyntheticClasses, queryImportersBatch, loadFTSExtension, wipeLbugDbFiles, LbugWipeError, DELETE_FILES_CHUNK_SIZE, } from './lbug/lbug-adapter.js';
22
22
  import { estimateBufferPool, setBufferPoolSizeHint, resolveNativeSafeStorageDir, } from './lbug/lbug-config.js';
23
23
  import { escapeCypherString } from './lbug/cypher-escape.js';
24
- import { buildSearchIndexesOrDegrade, ftsFailureIsFatal, createSearchFTSIndexes, dropSearchFTSIndexes, initialiseSearchFTSStemmer, verifySearchFTSIndexes, } from './search/fts-indexes.js';
24
+ import { buildSearchIndexesOrDegrade, ftsFailureIsFatal, createSearchFTSIndexes, summarizeFtsIndexBuildFailures, dropSearchFTSIndexes, initialiseSearchFTSStemmer, verifySearchFTSIndexes, } from './search/fts-indexes.js';
25
25
  import { cjkSegmentationModeMismatch, getSearchFTSCjkSegmentation, initialiseSearchFTSCjkSegmentation, } from './search/cjk-segmentation.js';
26
26
  import { getExtensionCapability, getExtensionCapabilities, getFtsCapability, resolveAnalyzeInstallPolicy, } from './lbug/extension-loader.js';
27
27
  import { diagnoseExtensionLoad } from './lbug/extension-load-error.js';
@@ -716,7 +716,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
716
716
  remedyTail);
717
717
  }
718
718
  progress('fts', 85, 'Repairing search indexes...');
719
- await createSearchFTSIndexes({
719
+ const repairFailures = await createSearchFTSIndexes({
720
720
  onIndexStart: options.verbose
721
721
  ? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
722
722
  : undefined,
@@ -726,7 +726,14 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
726
726
  });
727
727
  const missing = await verifySearchFTSIndexes(executeQuery);
728
728
  if (missing.length > 0) {
729
- throw new Error(`FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}. ` +
729
+ // #2889: name WHY each index is missing when the build itself said so.
730
+ // Repair now rebuilds every table it can before reporting, so the tables
731
+ // absent from this list were genuinely repaired even on a failed run —
732
+ // previously the first failure aborted the sweep and the message could
733
+ // only ever list "missing", never a reason. Same sentence the analyze
734
+ // degrade path prints, so one failure does not read two ways.
735
+ const reasons = repairFailures.length > 0 ? ` ${summarizeFtsIndexBuildFailures(repairFailures)}.` : '';
736
+ throw new Error(`FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}.${reasons} ` +
730
737
  'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' +
731
738
  'if that also fails, verify FTS extension availability via `gitnexus doctor`.');
732
739
  }
@@ -99,7 +99,46 @@ export declare function getSearchFTSStemmer(): string;
99
99
  * `lbug-adapter.ts`. Omit it to have the sweep read the catalog itself.
100
100
  */
101
101
  export declare function dropSearchFTSIndexes(indexRows?: IndexCatalogSnapshot): Promise<void>;
102
- export declare function createSearchFTSIndexes(options?: CreateSearchFTSIndexesOptions): Promise<void>;
102
+ /** One configured index that could not be (re)built, and why. */
103
+ export interface FtsIndexBuildFailure {
104
+ table: string;
105
+ indexName: string;
106
+ /** The raw LadybugDB message, unmodified — it is the only row-level evidence there is. */
107
+ error: string;
108
+ }
109
+ /**
110
+ * Build every configured FTS index, and keep going when one of them fails
111
+ * (#2889).
112
+ *
113
+ * The loop used to let the first failure propagate, which made a single
114
+ * untokenizable row far more expensive than it looks: `dropFTSIndex` has
115
+ * already run for the failing table, so that table ends with NO index, and
116
+ * every table after it in {@link FTS_INDEXES} order is never reached — on a
117
+ * fresh build, or on the incremental path where `dropSearchFTSIndexes` cleared
118
+ * them all up front, those tables end with no index either. One bad `Method`
119
+ * row therefore cost keyword search on Namespace, Property, Record, Union,
120
+ * Static and Variable as well, and `verifySearchFTSIndexes` never ran to say
121
+ * so. The blast radius was an artifact of loop control flow, not of the data.
122
+ *
123
+ * Isolating per index bounds the damage to the table that actually holds the
124
+ * bad row, and makes `--repair-fts` able to recover everything else. Failures
125
+ * are returned rather than thrown so the caller can decide — degrade or abort —
126
+ * with every failure in hand instead of only the first.
127
+ */
128
+ export declare function createSearchFTSIndexes(options?: CreateSearchFTSIndexesOptions): Promise<FtsIndexBuildFailure[]>;
129
+ /**
130
+ * One sentence naming every table that failed and why, e.g. `FTS index build
131
+ * failed for 2 of 21 tables: Method.method_fts (Runtime exception: …), …`.
132
+ *
133
+ * Lives here rather than at the call sites because only this module knows the
134
+ * denominator. Both the analyze degrade path and `--repair-fts` render it, so
135
+ * one failure reads the same way whichever command produced it.
136
+ *
137
+ * Embeds the raw LadybugDB message UNREDACTED — CLI and log surfaces only.
138
+ * Anything heading for a network response has to pass it through
139
+ * {@link redactPaths} first, the same rule the query-side warnings follow.
140
+ */
141
+ export declare const summarizeFtsIndexBuildFailures: (failures: readonly FtsIndexBuildFailure[]) => string;
103
142
  export declare function verifySearchFTSIndexes(executeQuery: (cypher: string) => Promise<unknown[]>): Promise<string[]>;
104
143
  /**
105
144
  * Why an FTS build failed, so the caller can react correctly (#2658):
@@ -209,8 +209,28 @@ export async function dropSearchFTSIndexes(indexRows) {
209
209
  await dropFTSIndex(table, indexName);
210
210
  }
211
211
  }
212
+ /**
213
+ * Build every configured FTS index, and keep going when one of them fails
214
+ * (#2889).
215
+ *
216
+ * The loop used to let the first failure propagate, which made a single
217
+ * untokenizable row far more expensive than it looks: `dropFTSIndex` has
218
+ * already run for the failing table, so that table ends with NO index, and
219
+ * every table after it in {@link FTS_INDEXES} order is never reached — on a
220
+ * fresh build, or on the incremental path where `dropSearchFTSIndexes` cleared
221
+ * them all up front, those tables end with no index either. One bad `Method`
222
+ * row therefore cost keyword search on Namespace, Property, Record, Union,
223
+ * Static and Variable as well, and `verifySearchFTSIndexes` never ran to say
224
+ * so. The blast radius was an artifact of loop control flow, not of the data.
225
+ *
226
+ * Isolating per index bounds the damage to the table that actually holds the
227
+ * bad row, and makes `--repair-fts` able to recover everything else. Failures
228
+ * are returned rather than thrown so the caller can decide — degrade or abort —
229
+ * with every failure in hand instead of only the first.
230
+ */
212
231
  export async function createSearchFTSIndexes(options) {
213
232
  const stemmer = getSearchFTSStemmer();
233
+ const failures = [];
214
234
  for (const { table, indexName, properties } of FTS_INDEXES) {
215
235
  options?.onIndexStart?.(table, indexName);
216
236
  // Drop first so the live `properties` always win. `createFTSIndex` is
@@ -224,11 +244,34 @@ export async function createSearchFTSIndexes(options) {
224
244
  // skipping when present; FTS build is proportional to symbol-table size and
225
245
  // runs inside the existing FTS phase. Gate on a stored schema fingerprint if
226
246
  // this rebuild cost ever shows up in analyze profiles.
227
- await dropFTSIndex(table, indexName);
228
- await createFTSIndex(table, indexName, [...properties], stemmer);
247
+ try {
248
+ await dropFTSIndex(table, indexName);
249
+ await createFTSIndex(table, indexName, [...properties], stemmer);
250
+ }
251
+ catch (e) {
252
+ // The message, never the Error: holding the Error would pin its stack and
253
+ // whatever the native binding attached to it, for up to one per table.
254
+ failures.push({ table, indexName, error: e instanceof Error ? e.message : String(e) });
255
+ continue;
256
+ }
229
257
  options?.onIndexReady?.(table, indexName);
230
258
  }
259
+ return failures;
231
260
  }
261
+ /**
262
+ * One sentence naming every table that failed and why, e.g. `FTS index build
263
+ * failed for 2 of 21 tables: Method.method_fts (Runtime exception: …), …`.
264
+ *
265
+ * Lives here rather than at the call sites because only this module knows the
266
+ * denominator. Both the analyze degrade path and `--repair-fts` render it, so
267
+ * one failure reads the same way whichever command produced it.
268
+ *
269
+ * Embeds the raw LadybugDB message UNREDACTED — CLI and log surfaces only.
270
+ * Anything heading for a network response has to pass it through
271
+ * {@link redactPaths} first, the same rule the query-side warnings follow.
272
+ */
273
+ export const summarizeFtsIndexBuildFailures = (failures) => `FTS index build failed for ${failures.length} of ${FTS_INDEXES.length} tables: ` +
274
+ failures.map((f) => `${f.table}.${f.indexName} (${f.error})`).join(', ');
232
275
  export async function verifySearchFTSIndexes(executeQuery) {
233
276
  // Read the catalog once and check each configured index both EXISTS and
234
277
  // covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX
@@ -328,16 +371,35 @@ export const ftsFailureIsFatal = (failureClass, useAtomicSwap) => failureClass =
328
371
  */
329
372
  export async function buildSearchIndexesOrDegrade(executeQuery, options) {
330
373
  try {
331
- await createSearchFTSIndexes(options);
374
+ // Verify ALWAYS runs, failures or not (#2889). Reporting must not jump the
375
+ // queue ahead of verification: a partial build is exactly when "the other
376
+ // tables are fine" needs proving rather than asserting, and a stale
377
+ // name+content-only index is invisible to the build (it succeeds) yet still
378
+ // means description search is broken (#2299).
379
+ const failures = await createSearchFTSIndexes(options);
332
380
  const missing = await verifySearchFTSIndexes(executeQuery);
333
- if (missing.length > 0) {
334
- // Structural incompleteness with no thrown error — treat as capability
335
- // (degrade), matching prior behavior; a broken *write* surfaces as a
336
- // thrown IO/checkpoint error below and is classified integrity there.
337
- const error = `missing indexes after build: ${missing.join(', ')}`;
338
- return { ok: false, error, failureClass: classifyFtsBuildError(error) };
339
- }
340
- return { ok: true };
381
+ if (failures.length === 0 && missing.length === 0)
382
+ return { ok: true };
383
+ // A table that failed to build is necessarily missing too — report it once,
384
+ // with its reason, and keep `missing` for indexes nothing explains.
385
+ const named = new Set(failures.map((f) => `${f.table}.${f.indexName}`));
386
+ const unexplained = missing.filter((name) => !named.has(name));
387
+ const error = [
388
+ failures.length > 0 ? summarizeFtsIndexBuildFailures(failures) : '',
389
+ // Structural incompleteness with no thrown error — classified capability
390
+ // (degrade) below, matching prior behavior; a broken *write* surfaces as
391
+ // a thrown IO/checkpoint error and is classified integrity there.
392
+ unexplained.length > 0 ? `missing indexes after build: ${unexplained.join(', ')}` : '',
393
+ ]
394
+ .filter((part) => part.length > 0)
395
+ .join('; ');
396
+ // Classify per failure, not over the joined text: capability signatures are
397
+ // checked first, so folding the messages together would let an untokenizable
398
+ // row mask a genuinely broken write and downgrade an abort into a degrade.
399
+ const failureClass = failures.some((f) => classifyFtsBuildError(f.error) === 'integrity')
400
+ ? 'integrity'
401
+ : classifyFtsBuildError(error);
402
+ return { ok: false, error, failureClass };
341
403
  }
342
404
  catch (e) {
343
405
  const error = e instanceof Error ? e.message : String(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.10-rc.185",
3
+ "version": "1.6.10-rc.186",
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",