gitnexus 1.6.13-rc.73 → 1.6.13-rc.74

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.
@@ -72,6 +72,7 @@ export interface SpringAopMetadata {
72
72
  * shared decoder. Other DECLARES edges (for example Spring Bean factories)
73
73
  * and malformed/forward-version evidence are ignored. Query failures are
74
74
  * fail-soft because older or partially upgraded indexes must remain readable.
75
+ * Corrupt identities propagate to the context/impact integrity error boundary.
75
76
  */
76
77
  export declare function querySpringAopMetadata(lbugPath: string, symbolId: string, symbolType: string): Promise<SpringAopMetadata | undefined>;
77
78
  export {};
@@ -1,4 +1,5 @@
1
1
  import { executeParameterized } from '../../core/lbug/pool-adapter.js';
2
+ import { assertSymbolIdentity, assertIdentityFields, rethrowSymbolIdentityError, } from './query-result-integrity.js';
2
3
  import { decodeSpringAopReason, } from '../../core/ingestion/frameworks/spring/aop.js';
3
4
  const SUPPORTED_SYMBOL_TYPES = new Set(['Class', 'Interface', 'Method', 'CodeElement']);
4
5
  const QUERY_RESULT_LIMIT = 1_000;
@@ -60,6 +61,7 @@ const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, s
60
61
  * shared decoder. Other DECLARES edges (for example Spring Bean factories)
61
62
  * and malformed/forward-version evidence are ignored. Query failures are
62
63
  * fail-soft because older or partially upgraded indexes must remain readable.
64
+ * Corrupt identities propagate to the context/impact integrity error boundary.
63
65
  */
64
66
  export async function querySpringAopMetadata(lbugPath, symbolId, symbolType) {
65
67
  if (!SUPPORTED_SYMBOL_TYPES.has(symbolType))
@@ -91,6 +93,18 @@ export async function querySpringAopMetadata(lbugPath, symbolId, symbolType) {
91
93
  ${DETERMINISTIC_RELATIONSHIP_ORDER}
92
94
  LIMIT 1001`, { symbolId }),
93
95
  ]);
96
+ for (const rows of [
97
+ outgoingAdviceRows,
98
+ incomingAdviceRows,
99
+ outgoingPointcutRows,
100
+ incomingPointcutRows,
101
+ ]) {
102
+ for (const row of rows) {
103
+ assertSymbolIdentity(readRowValue(row, 'sourceId', 0));
104
+ assertSymbolIdentity(readRowValue(row, 'targetId', 3));
105
+ assertIdentityFields(readRowValue(row, 'sourceName', 1), readRowValue(row, 'sourceFilePath', 2), readRowValue(row, 'targetName', 4), readRowValue(row, 'targetFilePath', 5));
106
+ }
107
+ }
94
108
  const behaviors = [];
95
109
  const advices = [];
96
110
  const resolvedPointcuts = [];
@@ -221,7 +235,8 @@ export async function querySpringAopMetadata(lbugPath, symbolId, symbolType) {
221
235
  unresolvedPointcuts: dedupedPointcuts,
222
236
  };
223
237
  }
224
- catch {
238
+ catch (error) {
239
+ rethrowSymbolIdentityError(error);
225
240
  return undefined;
226
241
  }
227
242
  }
@@ -660,9 +660,10 @@ export declare class LocalBackend {
660
660
  * "unknown kind" and, worse, makes the `kind` disambiguation hint unable to
661
661
  * filter it out (#2687).
662
662
  *
663
- * Failures are swallowed: label enrichment is an optimisation for
663
+ * Ordinary query failures are swallowed: label enrichment is an optimisation for
664
664
  * downstream scoring and #480 Class/Interface BFS seeding; if it fails
665
665
  * the symbol still resolves, just without the kind-priority bonus.
666
+ * Corrupt identities propagate to the context/impact error envelope.
666
667
  */
667
668
  private enrichCandidateLabels;
668
669
  /**
@@ -13,6 +13,7 @@ import { scoreImpactRisk, unusedAxesForImpactWalk, getLanguageFromFilename, } fr
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';
16
+ import { SYMBOL_IDENTITY_RECOVERY_SUGGESTION, SymbolIdentityError, assertSymbolIdentity, queryRowValue, assertIdentityFields, assertQueryIdentity, rethrowSymbolIdentityError, } from './query-result-integrity.js';
16
17
  import { queryConvexDispatchMetadata } from './convex-metadata.js';
17
18
  import { isValidQueryParams } from '../../core/lbug/query-params.js';
18
19
  import { toDisplayLine } from './line-display.js';
@@ -190,20 +191,69 @@ function resolveAliasString(canonical, legacy) {
190
191
  function nonBlankUid(value) {
191
192
  return typeof value === 'string' ? value.trim() || undefined : undefined;
192
193
  }
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';
194
+ function assertSymbolRowIdentity(row) {
195
+ assertQueryIdentity(row, 'id', 0, [
196
+ ['name', 1],
197
+ ['type', 2],
198
+ ['filePath', 3],
199
+ ]);
200
+ }
201
+ function assertContextRefs(rows) {
202
+ for (const row of rows) {
203
+ assertQueryIdentity(row, 'uid', 1, [
204
+ ['name', 2],
205
+ ['filePath', 3],
206
+ ['kind', 4],
207
+ ]);
208
+ assertSymbolIdentity(queryRowValue(row, 'relType', 0));
198
209
  }
199
210
  }
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();
211
+ const RESPONSE_IDENTITY_FIELDS = new Set([
212
+ 'id',
213
+ 'uid',
214
+ 'name',
215
+ 'filePath',
216
+ 'label',
217
+ 'kind',
218
+ 'type',
219
+ 'relationType',
220
+ 'processType',
221
+ 'url',
222
+ 'method',
223
+ 'sourceId',
224
+ 'targetId',
225
+ 'symbolId',
226
+ 'symbolName',
227
+ 'symbolFilePath',
228
+ 'adviceId',
229
+ 'adviceName',
230
+ 'adviceFilePath',
231
+ 'advisedId',
232
+ 'advisedName',
233
+ 'advisedFilePath',
234
+ 'evidenceId',
235
+ ]);
236
+ /** Cover nested additive identity fields while leaving source/metadata text alone. */
237
+ function assertResponseIdentities(value) {
238
+ if (Array.isArray(value)) {
239
+ for (const item of value)
240
+ assertResponseIdentities(item);
241
+ }
242
+ else if (value !== null && typeof value === 'object') {
243
+ for (const [key, field] of Object.entries(value)) {
244
+ if (key === 'seedBlocks' || key === 'reachableBlocks' || key === 'intraReachableBlocks') {
245
+ if (!Array.isArray(field))
246
+ throw new SymbolIdentityError();
247
+ for (const id of field)
248
+ assertSymbolIdentity(id);
249
+ continue;
250
+ }
251
+ if (RESPONSE_IDENTITY_FIELDS.has(key))
252
+ assertIdentityFields(field);
253
+ if (key !== 'content' && key !== 'methodMetadata' && key !== 'bean') {
254
+ assertResponseIdentities(field);
255
+ }
256
+ }
207
257
  }
208
258
  }
209
259
  const TOOL_STRING_ALIASES = {
@@ -3467,9 +3517,10 @@ export class LocalBackend {
3467
3517
  * "unknown kind" and, worse, makes the `kind` disambiguation hint unable to
3468
3518
  * filter it out (#2687).
3469
3519
  *
3470
- * Failures are swallowed: label enrichment is an optimisation for
3520
+ * Ordinary query failures are swallowed: label enrichment is an optimisation for
3471
3521
  * downstream scoring and #480 Class/Interface BFS seeding; if it fails
3472
3522
  * the symbol still resolves, just without the kind-priority bonus.
3523
+ * Corrupt identities propagate to the context/impact error envelope.
3473
3524
  */
3474
3525
  async enrichCandidateLabels(repo, candidates) {
3475
3526
  const ids = candidates.filter((c) => c.type === '' && c.id).map((c) => c.id);
@@ -3497,6 +3548,7 @@ export class LocalBackend {
3497
3548
  `, { ids });
3498
3549
  const labelById = new Map();
3499
3550
  for (const r of rows) {
3551
+ assertQueryIdentity(r, 'id', 0, [['label', 1]]);
3500
3552
  const id = (r.id ?? r[0]);
3501
3553
  const label = (r.label ?? r[1]);
3502
3554
  if (id && label && !labelById.has(id))
@@ -3507,7 +3559,8 @@ export class LocalBackend {
3507
3559
  c.type = labelById.get(c.id);
3508
3560
  }
3509
3561
  }
3510
- catch {
3562
+ catch (error) {
3563
+ rethrowSymbolIdentityError(error);
3511
3564
  /* best-effort — downstream resolvers still work without the label */
3512
3565
  }
3513
3566
  }
@@ -3578,6 +3631,7 @@ export class LocalBackend {
3578
3631
  const rows = await executeParameterized(repo.lbugPath, `MATCH (n {id: $uid}) RETURN ${selectClause} LIMIT 1`, { uid });
3579
3632
  if (rows.length === 0)
3580
3633
  return { kind: 'not_found' };
3634
+ assertSymbolRowIdentity(rows[0]);
3581
3635
  const r = rows[0];
3582
3636
  const symbol = {
3583
3637
  id: (r.id ?? r[0]),
@@ -3700,6 +3754,10 @@ export class LocalBackend {
3700
3754
  }
3701
3755
  if (rows.length === 0)
3702
3756
  return { kind: 'not_found' };
3757
+ // Reject every raw candidate before narrowing/scoring can hide a corrupt row.
3758
+ for (const row of rows) {
3759
+ assertSymbolRowIdentity(row);
3760
+ }
3703
3761
  // Normalise row shape across object / tuple returns from LadybugDB.
3704
3762
  let normalized = rows.map((r) => ({
3705
3763
  id: (r.id ?? r[0]),
@@ -3710,10 +3768,6 @@ export class LocalBackend {
3710
3768
  endLine: (r.endLine ?? r[5]),
3711
3769
  ...(include_content ? { content: (r.content ?? r[6]) } : {}),
3712
3770
  }));
3713
- // Reject the whole result before narrowing or scoring: dropping a corrupt
3714
- // candidate could make an unrelated surviving symbol look unambiguous.
3715
- for (const candidate of normalized)
3716
- assertSymbolIdentity(candidate.id);
3717
3771
  // An exact File path wins over anchored suffix candidates. Without this,
3718
3772
  // `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an
3719
3773
  // otherwise unambiguous exact target into `ambiguous` (#3084 review P2).
@@ -3839,7 +3893,10 @@ export class LocalBackend {
3839
3893
  */
3840
3894
  async context(repo, params) {
3841
3895
  try {
3842
- return await this._contextImpl(repo, params);
3896
+ const result = await this._contextImpl(repo, params);
3897
+ if (!result.error)
3898
+ assertResponseIdentities(result);
3899
+ return result;
3843
3900
  }
3844
3901
  catch (err) {
3845
3902
  const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed';
@@ -3930,6 +3987,8 @@ export class LocalBackend {
3930
3987
  LIMIT 30
3931
3988
  `, { symId }),
3932
3989
  ]);
3990
+ assertContextRefs(incomingRows);
3991
+ assertContextRefs(incomingAdvisedRows);
3933
3992
  incomingRows.push(...incomingAdvisedRows);
3934
3993
  let typedPropertyRows = [];
3935
3994
  // Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges —
@@ -4013,6 +4072,16 @@ export class LocalBackend {
4013
4072
  genericArg: `<${sym.name}>`,
4014
4073
  }),
4015
4074
  ]);
4075
+ assertContextRefs(ctorIncoming);
4076
+ assertContextRefs(fileIncoming);
4077
+ assertContextRefs(typedPropertyIncoming);
4078
+ for (const row of typedProperties) {
4079
+ assertQueryIdentity(row, 'uid', 0, [
4080
+ ['name', 1],
4081
+ ['filePath', 2],
4082
+ ['kind', 3],
4083
+ ]);
4084
+ }
4016
4085
  typedPropertyRows = typedProperties;
4017
4086
  // Deduplicate by (relType, uid) — a caller can have multiple relation
4018
4087
  // types to the same target (e.g. both IMPORTS and CALLS), and each
@@ -4027,6 +4096,7 @@ export class LocalBackend {
4027
4096
  }
4028
4097
  }
4029
4098
  catch (e) {
4099
+ rethrowSymbolIdentityError(e);
4030
4100
  logQueryError('context:class-incoming-expansion', e);
4031
4101
  }
4032
4102
  }
@@ -4048,6 +4118,8 @@ export class LocalBackend {
4048
4118
  LIMIT 30
4049
4119
  `, { symId }),
4050
4120
  ]);
4121
+ assertContextRefs(outgoingRows);
4122
+ assertContextRefs(outgoingAdvisedRows);
4051
4123
  outgoingRows.push(...outgoingAdvisedRows);
4052
4124
  // Process participation.
4053
4125
  //
@@ -4066,8 +4138,15 @@ export class LocalBackend {
4066
4138
  RETURN p.id AS pid, p.heuristicLabel AS label, MIN(r.step) AS step, p.stepCount AS stepCount, p.entryPointId AS entryPointId
4067
4139
  ORDER BY pid
4068
4140
  `, { symId });
4141
+ for (const row of processRows) {
4142
+ assertQueryIdentity(row, 'pid', 0, [
4143
+ ['label', 1],
4144
+ ['entryPointId', 4],
4145
+ ]);
4146
+ }
4069
4147
  }
4070
4148
  catch (e) {
4149
+ rethrowSymbolIdentityError(e);
4071
4150
  logQueryError('context:process-participation', e);
4072
4151
  }
4073
4152
  // Route lookup — HTTP endpoints this symbol handles, plus entry-point
@@ -4099,6 +4178,7 @@ export class LocalBackend {
4099
4178
  // (GET/POST pair). URL-only dedup would drop the second endpoint.
4100
4179
  const seenRoutes = new Set();
4101
4180
  for (const r of routeRows) {
4181
+ assertIdentityFields(queryRowValue(r, 'url', 0), queryRowValue(r, 'method', 1));
4102
4182
  const url = r.url ?? r[0];
4103
4183
  const method = r.method ?? r[1];
4104
4184
  const dedupKey = routeEnrichmentKey(method ? String(method) : undefined, url);
@@ -4109,7 +4189,8 @@ export class LocalBackend {
4109
4189
  }
4110
4190
  }
4111
4191
  catch (e) {
4112
- // Best-effort enrichment — never fail the context call.
4192
+ rethrowSymbolIdentityError(e);
4193
+ // Ordinary query failures leave this best-effort enrichment unavailable.
4113
4194
  logQueryError('context:route-lookup', e);
4114
4195
  }
4115
4196
  // Helper to categorize refs
@@ -4151,6 +4232,7 @@ export class LocalBackend {
4151
4232
  const epistemicPromise = this.computeEpistemicBoundary(repo, symId, epistemicSymType, (sym.name || sym[1]));
4152
4233
  const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType);
4153
4234
  const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType);
4235
+ void aopMetadataPromise.catch(() => undefined);
4154
4236
  // R3-1. A `Property` whose name the analyzer declined to link — because
4155
4237
  // every definition of it lives in another language — otherwise returns an
4156
4238
  // incoming list byte-identical to a genuinely unread field. Those demand
@@ -4237,6 +4319,7 @@ export class LocalBackend {
4237
4319
  chain = await this._computeContextChain(repo, symId, requestedDepth);
4238
4320
  }
4239
4321
  catch (e) {
4322
+ rethrowSymbolIdentityError(e);
4240
4323
  logQueryError('context:chain-bfs', e);
4241
4324
  }
4242
4325
  }
@@ -4276,8 +4359,8 @@ export class LocalBackend {
4276
4359
  processes: processRows.map((r) => ({
4277
4360
  id: r.pid || r[0],
4278
4361
  name: r.label || r[1],
4279
- step_index: r.step || r[2],
4280
- step_count: r.stepCount || r[3],
4362
+ step_index: r.step ?? r[2],
4363
+ step_count: r.stepCount ?? r[3],
4281
4364
  })),
4282
4365
  };
4283
4366
  }
@@ -4342,6 +4425,13 @@ export class LocalBackend {
4342
4425
  });
4343
4426
  if (rows.length === 0)
4344
4427
  return { nextFrontier: [] };
4428
+ for (const row of rows) {
4429
+ assertQueryIdentity(row, 'uid', 0, [
4430
+ ['name', 1],
4431
+ ['filePath', 2],
4432
+ ['kind', 3],
4433
+ ]);
4434
+ }
4345
4435
  // MATCH is one row per CALLS edge. Cypher `WITH DISTINCT` applies
4346
4436
  // LIMIT 50 to unique neighbors; this second pass still collapses
4347
4437
  // twins if a driver/engine ever returns duplicate rows.
@@ -4364,6 +4454,7 @@ export class LocalBackend {
4364
4454
  return { nodes, nextFrontier: fresh.map((r) => r.uid) };
4365
4455
  }
4366
4456
  catch (e) {
4457
+ rethrowSymbolIdentityError(e);
4367
4458
  logQueryError(logLabel, e);
4368
4459
  return { nextFrontier: [] };
4369
4460
  }
@@ -5778,7 +5869,10 @@ export class LocalBackend {
5778
5869
  }
5779
5870
  async impact(repo, params) {
5780
5871
  try {
5781
- return await this._impactImpl(repo, params);
5872
+ const result = await this._impactImpl(repo, params);
5873
+ if (!result.error)
5874
+ assertResponseIdentities(result);
5875
+ return result;
5782
5876
  }
5783
5877
  catch (err) {
5784
5878
  // Return structured error instead of crashing (#321)
@@ -6058,6 +6152,7 @@ export class LocalBackend {
6058
6152
  catch (e) {
6059
6153
  probeFailed = true;
6060
6154
  candidateProbeFailed = true;
6155
+ rethrowSymbolIdentityError(e);
6061
6156
  logQueryError('impact:ambiguous-candidate', e);
6062
6157
  }
6063
6158
  return {
@@ -6298,6 +6393,7 @@ export class LocalBackend {
6298
6393
  return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult);
6299
6394
  }
6300
6395
  catch (e) {
6396
+ rethrowSymbolIdentityError(e);
6301
6397
  logQueryError('impact:pdg-interprocedural-reach', e);
6302
6398
  return composeUnifiedPdgImpactResult(pdgResult, null, e);
6303
6399
  }
@@ -6330,6 +6426,7 @@ export class LocalBackend {
6330
6426
  try {
6331
6427
  const rows = await executeParameterized(repo.lbugPath, `MATCH (b:BasicBlock) WHERE b.id IN $ids RETURN b.callees AS callees`, { ids: blockIds });
6332
6428
  for (const r of rows) {
6429
+ assertIdentityFields(r.callees ?? r[0]);
6333
6430
  const raw = String(r.callees ?? r[0] ?? '');
6334
6431
  for (const n of raw.split(' '))
6335
6432
  if (n)
@@ -6337,6 +6434,7 @@ export class LocalBackend {
6337
6434
  }
6338
6435
  }
6339
6436
  catch (e) {
6437
+ rethrowSymbolIdentityError(e);
6340
6438
  logQueryError('impact:pdg-slice-callees', e);
6341
6439
  }
6342
6440
  return names;
@@ -6370,6 +6468,7 @@ export class LocalBackend {
6370
6468
  }
6371
6469
  }
6372
6470
  catch (e) {
6471
+ rethrowSymbolIdentityError(e);
6373
6472
  logQueryError('impact:pdg-slice-callee-ids', e);
6374
6473
  }
6375
6474
  return ids;
@@ -6488,7 +6587,10 @@ export class LocalBackend {
6488
6587
  WHERE x.id = $symId AND r.type IN $heritage
6489
6588
  RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
6490
6589
  ORDER BY id
6491
- LIMIT 25`, { symId, heritage: HERITAGE_TYPES }).catch(() => []);
6590
+ LIMIT 25`, { symId, heritage: HERITAGE_TYPES }).catch((error) => {
6591
+ rethrowSymbolIdentityError(error);
6592
+ return [];
6593
+ });
6492
6594
  const undecidedSummary = meta?.undecidedInterfaceSatisfaction;
6493
6595
  const undecidedDrops = undecidedSummary === undefined
6494
6596
  ? { notes: [], undecided: 0 }
@@ -6526,6 +6628,10 @@ export class LocalBackend {
6526
6628
  }
6527
6629
  const ifaceRows = await interfaceRowsPromise;
6528
6630
  for (const r of ifaceRows) {
6631
+ assertQueryIdentity(r, 'id', 0, [
6632
+ ['name', 1],
6633
+ ['label', 2],
6634
+ ]);
6529
6635
  const id = (r.id ?? r[0]);
6530
6636
  if (id && !boundary.has(id)) {
6531
6637
  boundary.set(id, {
@@ -6546,7 +6652,10 @@ export class LocalBackend {
6546
6652
  await Promise.all(ifaceIds.map(async (ifaceId) => {
6547
6653
  const rows = await executeParameterized(repo.lbugPath, `MATCH (other)-[r:CodeRelation]->(iface)
6548
6654
  WHERE iface.id = $ifaceId AND r.type IN $types
6549
- RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }).catch(() => []);
6655
+ RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }).catch((error) => {
6656
+ rethrowSymbolIdentityError(error);
6657
+ return [];
6658
+ });
6550
6659
  const cnt = rows.length > 0 ? Number(rows[0].cnt ?? rows[0][0] ?? 0) : 0;
6551
6660
  m.set(ifaceId, cnt);
6552
6661
  }));
@@ -6600,7 +6709,8 @@ export class LocalBackend {
6600
6709
  },
6601
6710
  };
6602
6711
  }
6603
- catch {
6712
+ catch (error) {
6713
+ rethrowSymbolIdentityError(error);
6604
6714
  // Never let the heritage probe's failure suppress a drop we already know
6605
6715
  // about — the whole point is that silence must not read as certainty.
6606
6716
  return epistemicFrom(droppedBoundaries);
@@ -6648,6 +6758,7 @@ export class LocalBackend {
6648
6758
  if (!symId) {
6649
6759
  throw new Error(`Impact target '${sym.name || sym[1] || '?'}' resolved without a node id; refusing to report a blast radius`);
6650
6760
  }
6761
+ assertSymbolRowIdentity(sym);
6651
6762
  // #1858 — kick off the epistemic boundary probe concurrently with the BFS.
6652
6763
  // It depends only on symId/symType/symName (all known now) and touches no
6653
6764
  // shared state, so its extra round-trip overlaps the traversal instead of
@@ -6670,6 +6781,7 @@ export class LocalBackend {
6670
6781
  const aopMetadataPromise = opts.skipEpistemic || summaryOnly
6671
6782
  ? Promise.resolve(undefined)
6672
6783
  : querySpringAopMetadata(repo.lbugPath, symId, symType);
6784
+ void aopMetadataPromise.catch(() => undefined);
6673
6785
  const impacted = [];
6674
6786
  const visited = new Set([symId]);
6675
6787
  const pdgBridgeEvidenceById = new Map();
@@ -6704,6 +6816,7 @@ export class LocalBackend {
6704
6816
  `, { symId }),
6705
6817
  ]);
6706
6818
  for (const r of ctorRows) {
6819
+ assertSymbolRowIdentity(r);
6707
6820
  const rid = r.id || r[0];
6708
6821
  if (rid && !visited.has(rid)) {
6709
6822
  visited.add(rid);
@@ -6711,6 +6824,7 @@ export class LocalBackend {
6711
6824
  }
6712
6825
  }
6713
6826
  for (const r of fileRows) {
6827
+ assertSymbolRowIdentity(r);
6714
6828
  const rid = r.id || r[0];
6715
6829
  if (rid && !visited.has(rid)) {
6716
6830
  visited.add(rid);
@@ -6729,6 +6843,7 @@ export class LocalBackend {
6729
6843
  genericArg: `<${sym.name}>`,
6730
6844
  });
6731
6845
  for (const r of typedPropertyRows) {
6846
+ assertSymbolRowIdentity(r);
6732
6847
  const rid = r.id || r[0];
6733
6848
  if (rid && !visited.has(rid)) {
6734
6849
  visited.add(rid);
@@ -6737,6 +6852,7 @@ export class LocalBackend {
6737
6852
  }
6738
6853
  }
6739
6854
  catch (e) {
6855
+ rethrowSymbolIdentityError(e);
6740
6856
  logQueryError('impact:class-node-expansion', e);
6741
6857
  traversalComplete = false;
6742
6858
  }
@@ -6767,6 +6883,9 @@ export class LocalBackend {
6767
6883
  ORDER BY id
6768
6884
  LIMIT ${OBJECT_CALLABLE_MEMBER_CAP + 1}
6769
6885
  `, { symId });
6886
+ for (const row of memberRows) {
6887
+ assertSymbolRowIdentity(row);
6888
+ }
6770
6889
  memberRows.sort((a, b) => compareCodeUnits(String(a.id ?? a[0]), String(b.id ?? b[0])));
6771
6890
  if (memberRows.length > OBJECT_CALLABLE_MEMBER_CAP)
6772
6891
  traversalComplete = false;
@@ -6788,6 +6907,7 @@ export class LocalBackend {
6788
6907
  }
6789
6908
  }
6790
6909
  catch (e) {
6910
+ rethrowSymbolIdentityError(e);
6791
6911
  logQueryError('impact:object-callable-expansion', e);
6792
6912
  traversalComplete = false;
6793
6913
  }
@@ -6840,6 +6960,17 @@ export class LocalBackend {
6840
6960
  relTypes: relationTypes,
6841
6961
  ...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}),
6842
6962
  });
6963
+ // Validate before filtering/deduplication; a discarded corrupt edge is
6964
+ // still evidence that this result's counts cannot be trusted.
6965
+ for (const row of related) {
6966
+ assertQueryIdentity(row, 'id', 1, [
6967
+ ['sourceId', 0],
6968
+ ['name', 2],
6969
+ ['type', 3],
6970
+ ['filePath', 4],
6971
+ ]);
6972
+ assertSymbolIdentity(queryRowValue(row, 'relType', 5));
6973
+ }
6843
6974
  const edges = related.map((rel) => ({
6844
6975
  id: rel.id || rel[1],
6845
6976
  name: rel.name || rel[2],
@@ -6931,6 +7062,7 @@ export class LocalBackend {
6931
7062
  }
6932
7063
  }
6933
7064
  catch (e) {
7065
+ rethrowSymbolIdentityError(e);
6934
7066
  logQueryError('impact:depth-traversal', e);
6935
7067
  // Break out of depth loop on query failure but return partial results
6936
7068
  // collected so far, rather than silently swallowing the error (#321)
@@ -7031,12 +7163,21 @@ export class LocalBackend {
7031
7163
  ep.name AS epName, labels(ep)[0] AS epType, ep.filePath AS epFilePath
7032
7164
  ORDER BY pId
7033
7165
  `, { ids }).catch((err) => {
7166
+ rethrowSymbolIdentityError(err);
7034
7167
  processQueryFailed = true;
7035
7168
  enrichmentDegraded = true;
7036
7169
  logQueryError('impact:process-chunk', err);
7037
7170
  return [];
7038
7171
  });
7039
7172
  for (const row of rows) {
7173
+ assertQueryIdentity(row, 'pId', 0, [
7174
+ ['name', 1],
7175
+ ['processType', 2],
7176
+ ['entryPointId', 3],
7177
+ ['epName', 7],
7178
+ ['epType', 8],
7179
+ ['epFilePath', 9],
7180
+ ]);
7040
7181
  const pId = row.pId ?? row[0];
7041
7182
  const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0];
7042
7183
  // Track mapping from process -> entryPoint so we can backfill missing minStep
@@ -7083,6 +7224,7 @@ export class LocalBackend {
7083
7224
  }
7084
7225
  }
7085
7226
  catch (e) {
7227
+ rethrowSymbolIdentityError(e);
7086
7228
  processQueryFailed = true;
7087
7229
  enrichmentDegraded = true;
7088
7230
  logQueryError('impact:process-chunk', e);
@@ -7100,11 +7242,13 @@ export class LocalBackend {
7100
7242
  WHERE p.id IN $pIds AND s.id IN $ids
7101
7243
  RETURN p.id AS pid, MIN(r.step) AS minStep
7102
7244
  `, { pIds, ids: allImpactedIds }).catch((err) => {
7245
+ rethrowSymbolIdentityError(err);
7103
7246
  enrichmentDegraded = true;
7104
7247
  logQueryError('impact:process-chunk-backfill', err);
7105
7248
  return [];
7106
7249
  });
7107
7250
  for (const mr of missingRows) {
7251
+ assertQueryIdentity(mr, 'pid', 0, []);
7108
7252
  const pid = mr.pid ?? mr[0];
7109
7253
  const minStep = mr.minStep ?? mr[1];
7110
7254
  const epId = processToEntryPoint.get(String(pid));
@@ -7119,6 +7263,7 @@ export class LocalBackend {
7119
7263
  }
7120
7264
  }
7121
7265
  catch (e) {
7266
+ rethrowSymbolIdentityError(e);
7122
7267
  enrichmentDegraded = true;
7123
7268
  logQueryError('impact:process-chunk-backfill', e);
7124
7269
  }
@@ -7168,12 +7313,14 @@ export class LocalBackend {
7168
7313
  ORDER BY hits DESC, name
7169
7314
  LIMIT 20
7170
7315
  `, { ids: idsChunk }).catch((err) => {
7316
+ rethrowSymbolIdentityError(err);
7171
7317
  moduleQueryFailed = true;
7172
7318
  enrichmentDegraded = true;
7173
7319
  logQueryError('impact:module-chunk', err);
7174
7320
  return [];
7175
7321
  });
7176
7322
  for (const r of rows) {
7323
+ assertIdentityFields(queryRowValue(r, 'name', 0));
7177
7324
  const name = r.name ?? r[0] ?? null;
7178
7325
  const hits = (r.hits ?? r[1]) || 0;
7179
7326
  if (!name)
@@ -7182,6 +7329,7 @@ export class LocalBackend {
7182
7329
  }
7183
7330
  }
7184
7331
  catch (e) {
7332
+ rethrowSymbolIdentityError(e);
7185
7333
  moduleQueryFailed = true;
7186
7334
  enrichmentDegraded = true;
7187
7335
  logQueryError('impact:module-chunk', e);
@@ -7205,18 +7353,21 @@ export class LocalBackend {
7205
7353
  WHERE s.id IN $ids
7206
7354
  RETURN DISTINCT c.heuristicLabel AS name
7207
7355
  `, { ids: idsChunk }).catch((err) => {
7356
+ rethrowSymbolIdentityError(err);
7208
7357
  enrichmentDegraded = true;
7209
7358
  moduleClassificationFailed = true;
7210
7359
  logQueryError('impact:direct-module-chunk', err);
7211
7360
  return [];
7212
7361
  });
7213
7362
  for (const r of rows) {
7363
+ assertIdentityFields(queryRowValue(r, 'name', 0));
7214
7364
  const name = r.name ?? r[0] ?? null;
7215
7365
  if (name)
7216
7366
  directModuleSet.add(name);
7217
7367
  }
7218
7368
  }
7219
7369
  catch (e) {
7370
+ rethrowSymbolIdentityError(e);
7220
7371
  enrichmentDegraded = true;
7221
7372
  moduleClassificationFailed = true;
7222
7373
  logQueryError('impact:direct-module-chunk', e);
@@ -7269,11 +7420,14 @@ export class LocalBackend {
7269
7420
  RETURN h.id AS hid, route.name AS url, route.method AS method
7270
7421
  ORDER BY url, method
7271
7422
  `, { ids: chunkIds }).catch((err) => {
7423
+ rethrowSymbolIdentityError(err);
7272
7424
  enrichmentDegraded = true;
7273
7425
  logQueryError('impact:route-chunk', err);
7274
7426
  return [];
7275
7427
  });
7276
7428
  for (const row of rows) {
7429
+ assertSymbolIdentity(queryRowValue(row, 'hid', 0));
7430
+ assertIdentityFields(queryRowValue(row, 'url', 1), queryRowValue(row, 'method', 2));
7277
7431
  const hid = String(row.hid ?? row[0] ?? '');
7278
7432
  const url = row.url ?? row[1];
7279
7433
  if (!hid || typeof url !== 'string')
@@ -7421,8 +7575,16 @@ export class LocalBackend {
7421
7575
  WHERE s.id IN $ids
7422
7576
  RETURN s.id AS sid, p.id AS pid, p.heuristicLabel AS pName,
7423
7577
  p.processType AS pType, MIN(r.step) AS step
7424
- `, { ids: chunkIds }).catch(() => []);
7578
+ `, { ids: chunkIds }).catch((err) => {
7579
+ rethrowSymbolIdentityError(err);
7580
+ return [];
7581
+ });
7425
7582
  for (const row of rows) {
7583
+ assertQueryIdentity(row, 'sid', 0, []);
7584
+ assertQueryIdentity(row, 'pid', 1, [
7585
+ ['pName', 2],
7586
+ ['pType', 3],
7587
+ ]);
7426
7588
  const sid = row.sid ?? row[0];
7427
7589
  if (!sid)
7428
7590
  continue;
@@ -7440,6 +7602,7 @@ export class LocalBackend {
7440
7602
  }
7441
7603
  }
7442
7604
  catch (e) {
7605
+ rethrowSymbolIdentityError(e);
7443
7606
  logQueryError('impact:per-symbol-process-chunk', e);
7444
7607
  }
7445
7608
  }
@@ -22,6 +22,7 @@ import { toDisplayLine } from './line-display.js';
22
22
  import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js';
23
23
  import { decodeCallSummary } from '../../core/ingestion/taint/call-summary-codec.js';
24
24
  import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js';
25
+ import { assertSymbolIdentity, assertIdentityFields, assertQueryIdentity, } from './query-result-integrity.js';
25
26
  /**
26
27
  * Parse the `<fnLine>` segment out of a `BasicBlock` id (1-based function start
27
28
  * line). The id template is
@@ -81,16 +82,21 @@ const INTERPROC_NODE_BUDGET = 5000;
81
82
  * `classifyPdgBridgeEvidence`); this is the same fact, read at the descent side.
82
83
  */
83
84
  function parseCalleeIdsCell(raw) {
85
+ assertIdentityFields(raw);
84
86
  const ids = [];
85
87
  let truncated = false;
88
+ if (!String(raw ?? '').trim())
89
+ return { ids, truncated };
86
90
  // Split on the SHARED CALLEE_ID_SEP (tab) — ids embed file paths / multi-word
87
91
  // C++ type tokens that can contain a space, so a space split would fragment
88
92
  // them. Producer (calleeIdsOfBlock) joins with the same constant.
89
93
  for (const id of String(raw ?? '').split(CALLEE_ID_SEP)) {
90
94
  if (id === CALLEES_TRUNCATED_SENTINEL)
91
95
  truncated = true;
92
- else if (id)
96
+ else {
97
+ assertSymbolIdentity(id);
93
98
  ids.push(id);
99
+ }
94
100
  }
95
101
  return { ids, truncated };
96
102
  }
@@ -179,6 +185,7 @@ async function selfReachingDefEdgesByBlock(lbugPath, blockIds, exec) {
179
185
  WHERE r.type = 'REACHING_DEF' AND a.id IN $ids
180
186
  RETURN a.id AS id, r.reason AS reason`, { ids: blockIds });
181
187
  for (const r of rows) {
188
+ assertQueryIdentity(r, 'id', 0);
182
189
  const id = String(r['id'] ?? '');
183
190
  if (!id)
184
191
  continue;
@@ -251,6 +258,7 @@ chainWalkBlocks = new Map()) {
251
258
  // Narrow the awaited rows ONCE at the boundary to a typed record shape; read
252
259
  // the aliased cells via bracket access with String()/Number() coercion.
253
260
  for (const r of rows) {
261
+ assertQueryIdentity(r, 'id', 0);
254
262
  const id = String(r['id'] ?? '');
255
263
  const line = Number(r['line'] ?? 0);
256
264
  if (!id || !Number.isFinite(line) || line <= 0)
@@ -416,6 +424,10 @@ async function projectBlocksToSymbols(deps) {
416
424
  // non-aliased row shape) — no per-field `as any`, matching the typed-row
417
425
  // pattern used elsewhere in this file (e.g. lines ~264, ~1309, ~1386).
418
426
  for (const r of rows) {
427
+ assertQueryIdentity(r, 'id', 0, [
428
+ ['name', 1],
429
+ ['label', 2],
430
+ ]);
419
431
  resolved.push({
420
432
  id: String(r['id'] ?? r['0'] ?? ''),
421
433
  name: String(r['name'] ?? r['1'] ?? ''),
@@ -1078,6 +1090,8 @@ async function bfsReachableBlocks(input) {
1078
1090
  // Narrow the awaited rows ONCE at the boundary (executeParameterized returns
1079
1091
  // any[]) to a typed record shape, then read the aliased `id` via bracket
1080
1092
  // access — no `as any` sprayed per field.
1093
+ for (const row of rawRows)
1094
+ assertQueryIdentity(row, 'id', 0);
1081
1095
  const rows = rawRows.slice(0, stepLimit);
1082
1096
  depthReached = depth + 1;
1083
1097
  if (rawRows.length > stepLimit)
@@ -1134,6 +1148,10 @@ async function calleeIdsByBlock(lbugPath, blockIds, exec) {
1134
1148
  // Narrow the awaited rows ONCE at the boundary to a typed record shape; read
1135
1149
  // the aliased cells via bracket access — no per-field `as any`.
1136
1150
  for (const r of rows) {
1151
+ assertQueryIdentity(r, 'id', 0, [
1152
+ ['calleeIds', 1],
1153
+ ['callees', 2],
1154
+ ]);
1137
1155
  const blockId = String(r['id'] ?? '');
1138
1156
  if (!blockId)
1139
1157
  continue;
@@ -1184,6 +1202,7 @@ async function calleesWithReturnFlow(lbugPath, calleeIds, exec) {
1184
1202
  WHERE r.type = 'CALL_SUMMARY' AND c.id IN $ids
1185
1203
  RETURN c.id AS id, r.reason AS reason`, { ids: calleeIds });
1186
1204
  for (const r of rows) {
1205
+ assertQueryIdentity(r, 'id', 0);
1187
1206
  const id = String(r['id'] ?? '');
1188
1207
  if (!id)
1189
1208
  continue;
@@ -1232,6 +1251,7 @@ async function resolveCalleeSpans(lbugPath, calleeIds, exec) {
1232
1251
  // the aliased columns via bracket access with Number()/String() coercion —
1233
1252
  // no per-field `as any` (the same boundary-narrowing the typed helpers use).
1234
1253
  for (const r of rows) {
1254
+ assertQueryIdentity(r, 'id', 0, [['filePath', 1]]);
1235
1255
  const id = String(r['id'] ?? '');
1236
1256
  const filePath = String(r['filePath'] ?? '');
1237
1257
  const startLine = Number(r['startLine']);
@@ -1408,6 +1428,8 @@ async function interproceduralDescent(input) {
1408
1428
  const spanSeeds = await Promise.all(spans.map(async (span) => {
1409
1429
  const { anchorClause, queryParams } = blockAnchorForResolvedSymbol(span);
1410
1430
  const rawSeedRows = await exec(lbugPath, seedBlockQuery(anchorClause, probeLimit), queryParams);
1431
+ for (const row of rawSeedRows)
1432
+ assertQueryIdentity(row, 'id', 0);
1411
1433
  const exceeded = rawSeedRows.length > stepLimit;
1412
1434
  const seeds = rawSeedRows
1413
1435
  .slice(0, stepLimit)
@@ -1559,6 +1581,8 @@ export async function runImpactPDG(deps) {
1559
1581
  : blockAnchorForResolvedSymbol(sym);
1560
1582
  const probeLimit = stepLimit + 1;
1561
1583
  const rawSeedRows = await exec(repo.lbugPath, seedBlockQuery(anchorClause, probeLimit), queryParams);
1584
+ for (const row of rawSeedRows)
1585
+ assertQueryIdentity(row, 'id', 0);
1562
1586
  const seedRows = rawSeedRows.slice(0, stepLimit);
1563
1587
  let seedBlocks = seedRows
1564
1588
  .map((r) => String(r['id'] ?? ''))
@@ -0,0 +1,14 @@
1
+ /** Shared integrity boundary for identities returned by impact/context queries. */
2
+ export declare const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = "Run gitnexus analyze --force from the affected repository root to rebuild the index.";
3
+ export declare class SymbolIdentityError extends Error {
4
+ constructor();
5
+ }
6
+ /** Validate database identities before using them as graph traversal anchors. */
7
+ export declare function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string;
8
+ /** Read either native row shape without turning an absent row into a TypeError. */
9
+ export declare function queryRowValue(row: unknown, key: string, index: number): unknown;
10
+ /** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */
11
+ export declare function assertIdentityFields(...values: unknown[]): void;
12
+ export declare function assertQueryIdentity(row: unknown, idKey: string, idIndex: number, fields?: ReadonlyArray<readonly [string, number]>): void;
13
+ /** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */
14
+ export declare function rethrowSymbolIdentityError(error: unknown): void;
@@ -0,0 +1,44 @@
1
+ /** Shared integrity boundary for identities returned by impact/context queries. */
2
+ export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = 'Run gitnexus analyze --force from the affected repository root to rebuild the index.';
3
+ export class SymbolIdentityError extends Error {
4
+ constructor() {
5
+ super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
6
+ this.name = 'SymbolIdentityError';
7
+ }
8
+ }
9
+ /** Validate database identities before using them as graph traversal anchors. */
10
+ export function assertSymbolIdentity(id, expectedUid) {
11
+ if (typeof id !== 'string' ||
12
+ !id.trim() ||
13
+ id.includes('\0') ||
14
+ (expectedUid !== undefined && id !== expectedUid)) {
15
+ throw new SymbolIdentityError();
16
+ }
17
+ }
18
+ /** Read either native row shape without turning an absent row into a TypeError. */
19
+ export function queryRowValue(row, key, index) {
20
+ if (typeof row !== 'object' || row === null)
21
+ return undefined;
22
+ const value = row;
23
+ return value[key] ?? value[index];
24
+ }
25
+ /** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */
26
+ export function assertIdentityFields(...values) {
27
+ for (const value of values) {
28
+ if (value !== null &&
29
+ value !== undefined &&
30
+ (typeof value !== 'string' || value.includes('\0'))) {
31
+ throw new SymbolIdentityError();
32
+ }
33
+ }
34
+ }
35
+ export function assertQueryIdentity(row, idKey, idIndex, fields = []) {
36
+ assertSymbolIdentity(queryRowValue(row, idKey, idIndex));
37
+ for (const [key, index] of fields)
38
+ assertIdentityFields(queryRowValue(row, key, index));
39
+ }
40
+ /** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */
41
+ export function rethrowSymbolIdentityError(error) {
42
+ if (error instanceof SymbolIdentityError)
43
+ throw error;
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.73",
3
+ "version": "1.6.13-rc.74",
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",