gitnexus 1.6.10-rc.225 → 1.6.10-rc.227

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.
package/dist/cli/group.js CHANGED
@@ -148,6 +148,16 @@ export function registerGroupCommands(program) {
148
148
  if ((st.missingRepos || []).length > 0) {
149
149
  console.log(`\n Last sync missing repos: ${st.missingRepos.join(', ')}`);
150
150
  }
151
+ // Only the populated case prints. Absent means a registry that predates
152
+ // the field, and empty is the ordinary clean sync — neither is worth a
153
+ // line, whereas a narrowed registry changes how every later answer
154
+ // should be read.
155
+ const skippedStages = st.suppressedMatchStages ?? [];
156
+ if (skippedStages.length > 0) {
157
+ console.log(`\n Last sync skipped matching stages: ${skippedStages.join(', ')}` +
158
+ `\n Cross-links those stages would have found are absent by request.` +
159
+ `\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`);
160
+ }
151
161
  }
152
162
  finally {
153
163
  await backend.dispose().catch(() => { });
@@ -156,10 +166,8 @@ export function registerGroupCommands(program) {
156
166
  group
157
167
  .command('sync <name>')
158
168
  .description('Sync Contract Registry — extract contracts and build cross-links')
159
- .option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)')
160
- .option('--exact-only', 'Exact match only')
161
- .option('--allow-stale', 'Skip stale index warnings')
162
- .option('--verbose', 'Show each cross-link detail')
169
+ .option('--exact-only', 'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)')
170
+ .option('--verbose', 'Show additional sync diagnostics')
163
171
  .option('--json', 'JSON output')
164
172
  .action(async (name, opts) => {
165
173
  const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
@@ -173,9 +181,7 @@ export function registerGroupCommands(program) {
173
181
  try {
174
182
  result = await syncGroup(config, {
175
183
  groupDir,
176
- allowStale: Boolean(opts.allowStale),
177
184
  verbose: Boolean(opts.verbose),
178
- skipEmbeddings: Boolean(opts.skipEmbeddings),
179
185
  exactOnly: Boolean(opts.exactOnly),
180
186
  });
181
187
  }
@@ -212,10 +218,41 @@ export function registerGroupCommands(program) {
212
218
  console.log(`\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` +
213
219
  `\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`);
214
220
  }
215
- console.log(`\nMatching cascade:`);
216
- const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
217
- console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
218
- console.log(` unmatched: ${result.unmatched.length} contracts`);
221
+ // Every stage that produced a link, not just `exact`. This used to print
222
+ // `Matching cascade:` and then count `exact` alone, while the `Wrote
223
+ // contracts.json (…)` line below reports `result.crossLinks.length` —
224
+ // which also includes `manifest` and `wildcard` links. For any group with
225
+ // those, the two numbers disagreed with nothing on screen explaining why.
226
+ // Summing the stages here makes them reconcile by construction.
227
+ console.log(`\nMatching:`);
228
+ // Exhaustive by construction, same idiom as OUTCOME_LINE below: adding a
229
+ // MatchType fails the build here instead of silently going uncounted and
230
+ // reopening the very mismatch this replaced. Every stage prints even at
231
+ // zero — a stage that is absent reads as "did not apply", not "found none".
232
+ const STAGE_COUNTS = {
233
+ exact: 0,
234
+ manifest: 0,
235
+ wildcard: 0,
236
+ };
237
+ for (const link of result.crossLinks)
238
+ STAGE_COUNTS[link.matchType] += 1;
239
+ // A stage the sync was told to skip is reported as skipped, not as a
240
+ // zero count. The two are different facts — "ran, matched nothing" and
241
+ // "never ran" — and printing both as `0` is the same conflation this
242
+ // block replaced. Driven by what the sync did (`suppressedMatchStages`)
243
+ // rather than by what the caller asked for, so it stays correct on the
244
+ // outcomes where the run ended without writing a registry.
245
+ for (const stage of Object.keys(STAGE_COUNTS)) {
246
+ const count = STAGE_COUNTS[stage];
247
+ const label = `${stage}:`.padEnd(10);
248
+ if (result.suppressedMatchStages.includes(stage)) {
249
+ console.log(` ${label} skipped (--exact-only)`);
250
+ continue;
251
+ }
252
+ const confidence = stage === 'exact' ? ' (confidence 1.0)' : '';
253
+ console.log(` ${label} ${count} cross-links${confidence}`);
254
+ }
255
+ console.log(` ${'unmatched:'.padEnd(10)} ${result.unmatched.length} contracts`);
219
256
  // Driven by what actually happened to the file. This line used to be
220
257
  // unconditional, so a run that deliberately preserved the previous
221
258
  // registry still announced `Wrote contracts.json (0 contracts, 0
@@ -330,9 +367,28 @@ export function registerGroupCommands(program) {
330
367
  // repos — reporting it as crossings understates a fan-out cap the
331
368
  // same way #2787's totals did.
332
369
  const dropped = raw?.truncatedRepos ?? [];
333
- console.log(dropped.length > 0
334
- ? ` risk is a LOWER BOUND — fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}`
335
- : ' risk is a LOWER BOUND — the local impact walk did not complete (every bridge crossing was traversed)');
370
+ const reason = raw?.truncationReason;
371
+ // Keyed on the REASON, not on which incidental fact happens to be
372
+ // non-empty. `truncatedRepos` is populated for a structural gap too
373
+ // — the bridge's incomplete repos are unioned into it even when ZERO
374
+ // crossings were attempted — so branching on its length first
375
+ // reported "fan-out stopped early" for a run where nothing stopped
376
+ // early, and omitted the only remedy that works. Same false-cause
377
+ // shape the contract listing was just re-gated for, one command over.
378
+ const floorReason = () => {
379
+ if (reason === 'suppressed-stage') {
380
+ return 'the last sync skipped a matching stage (--exact-only); re-run `gitnexus group sync` without it for the complete graph';
381
+ }
382
+ if (reason === 'incomplete-sync') {
383
+ return dropped.length > 0
384
+ ? `the last sync could not account for ${dropped.join(', ')}; their contracts are absent from every query against this bridge — re-run \`gitnexus group sync\``
385
+ : 'the last sync could not say which repos it read — re-run `gitnexus group sync`';
386
+ }
387
+ return dropped.length > 0
388
+ ? `fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}`
389
+ : 'the local impact walk did not complete (every bridge crossing was traversed)';
390
+ };
391
+ console.log(` risk is a LOWER BOUND — ${floorReason()}`);
336
392
  }
337
393
  }
338
394
  }
@@ -402,7 +458,7 @@ export function registerGroupCommands(program) {
402
458
  process.exitCode = 1;
403
459
  return;
404
460
  }
405
- const { contracts, crossLinks, truncated, unreadableRepos, missingRepos } = raw;
461
+ const { contracts, crossLinks, truncated, unreadableRepos, missingRepos, suppressedMatchStages, truncationReason, } = raw;
406
462
  if (opts.json) {
407
463
  // The whole payload, not a re-serialized subset. Destructuring the two
408
464
  // fields this command happens to print and rebuilding an object from
@@ -421,7 +477,25 @@ export function registerGroupCommands(program) {
421
477
  for (const l of crossLinks) {
422
478
  console.log(` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`);
423
479
  }
424
- if (truncated) {
480
+ // Separate from `truncated` below, and deliberately so: that one means
481
+ // the sync could not read something and the remedy is to fix the repo.
482
+ // This one means the sync was ASKED to skip a stage, and the remedy is
483
+ // to re-run without the flag. A listing narrowed on purpose is still
484
+ // narrowed, and without this the human view showed nothing at all.
485
+ if (suppressedMatchStages && suppressedMatchStages.length > 0) {
486
+ console.log(`\n⚠️ This listing is a lower bound: the last sync skipped ${suppressedMatchStages.join(', ')} matching` +
487
+ `\n (--exact-only), so cross-links that stage would have found are absent.` +
488
+ `\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`);
489
+ }
490
+ // Gated on the REASON, not just the flag. A suppressed stage sets
491
+ // `truncated` with both repo lists empty, which sent this block down
492
+ // its else-branch and printed "the last sync did not record which
493
+ // repos it could read" — a false statement, with the wrong remedy,
494
+ // about a sync that recorded them fine. The suppressed-stage warning
495
+ // above already said the true thing. When a repo gap co-occurs the
496
+ // reason is 'incomplete-sync' (the repo side takes precedence in
497
+ // `crossRepoCompleteness`), so this block still runs for it.
498
+ if (truncated && truncationReason !== 'suppressed-stage') {
425
499
  // Counts above are a floor, not a census. Name the repos when the
426
500
  // registry recorded them, and say so plainly when it did not — a
427
501
  // listing that cannot say what it is missing is still incomplete.
@@ -151,9 +151,7 @@ const OPTION_DESCRIPTION_KEYS = {
151
151
  'embeddings install|--cuda': 'help.option.embeddings.install.cuda',
152
152
  'embeddings install|--force': 'help.option.embeddings.install.force',
153
153
  'group create|--force': 'help.option.group.create.force',
154
- 'group sync|--skip-embeddings': 'help.option.group.sync.skipEmbeddings',
155
154
  'group sync|--exact-only': 'help.option.group.sync.exactOnly',
156
- 'group sync|--allow-stale': 'help.option.group.sync.allowStale',
157
155
  'group sync|--verbose': 'help.option.group.sync.verbose',
158
156
  'group sync|--json': 'help.option.json',
159
157
  'group impact|--target <symbol>': 'help.option.group.impact.target',
@@ -233,10 +233,8 @@ export declare const en: {
233
233
  readonly 'help.option.embeddings.install.cuda': "Also download the CUDA GPU binaries (runs onnxruntime-node's NuGet postinstall; set GLOBAL_AGENT_HTTPS_PROXY behind a proxy)";
234
234
  readonly 'help.option.embeddings.install.force': "Install into the runtime prefix even when the stack already resolves";
235
235
  readonly 'help.option.group.create.force': "Overwrite existing group";
236
- readonly 'help.option.group.sync.skipEmbeddings': "Exact + BM25 only (no embedding fallback)";
237
- readonly 'help.option.group.sync.exactOnly': "Exact match only";
238
- readonly 'help.option.group.sync.allowStale': "Skip stale index warnings";
239
- readonly 'help.option.group.sync.verbose': "Show each cross-link detail";
236
+ readonly 'help.option.group.sync.exactOnly': "Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)";
237
+ readonly 'help.option.group.sync.verbose': "Show additional sync diagnostics";
240
238
  readonly 'help.option.status.json': "Emit machine-readable index and analyzer provenance";
241
239
  readonly 'help.option.json': "JSON output";
242
240
  readonly 'help.option.group.impact.target': "Symbol or file name to analyze";
@@ -235,10 +235,8 @@ export const en = {
235
235
  'help.option.embeddings.install.cuda': "Also download the CUDA GPU binaries (runs onnxruntime-node's NuGet postinstall; set GLOBAL_AGENT_HTTPS_PROXY behind a proxy)",
236
236
  'help.option.embeddings.install.force': 'Install into the runtime prefix even when the stack already resolves',
237
237
  'help.option.group.create.force': 'Overwrite existing group',
238
- 'help.option.group.sync.skipEmbeddings': 'Exact + BM25 only (no embedding fallback)',
239
- 'help.option.group.sync.exactOnly': 'Exact match only',
240
- 'help.option.group.sync.allowStale': 'Skip stale index warnings',
241
- 'help.option.group.sync.verbose': 'Show each cross-link detail',
238
+ 'help.option.group.sync.exactOnly': 'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)',
239
+ 'help.option.group.sync.verbose': 'Show additional sync diagnostics',
242
240
  'help.option.status.json': 'Emit machine-readable index and analyzer provenance',
243
241
  'help.option.json': 'JSON output',
244
242
  'help.option.group.impact.target': 'Symbol or file name to analyze',
@@ -234,10 +234,8 @@ export declare const cliResources: {
234
234
  readonly 'help.option.embeddings.install.cuda': "Also download the CUDA GPU binaries (runs onnxruntime-node's NuGet postinstall; set GLOBAL_AGENT_HTTPS_PROXY behind a proxy)";
235
235
  readonly 'help.option.embeddings.install.force': "Install into the runtime prefix even when the stack already resolves";
236
236
  readonly 'help.option.group.create.force': "Overwrite existing group";
237
- readonly 'help.option.group.sync.skipEmbeddings': "Exact + BM25 only (no embedding fallback)";
238
- readonly 'help.option.group.sync.exactOnly': "Exact match only";
239
- readonly 'help.option.group.sync.allowStale': "Skip stale index warnings";
240
- readonly 'help.option.group.sync.verbose': "Show each cross-link detail";
237
+ readonly 'help.option.group.sync.exactOnly': "Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)";
238
+ readonly 'help.option.group.sync.verbose': "Show additional sync diagnostics";
241
239
  readonly 'help.option.status.json': "Emit machine-readable index and analyzer provenance";
242
240
  readonly 'help.option.json': "JSON output";
243
241
  readonly 'help.option.group.impact.target': "Symbol or file name to analyze";
@@ -490,9 +488,7 @@ export declare const cliResources: {
490
488
  'help.option.embeddings.install.cuda': string;
491
489
  'help.option.embeddings.install.force': string;
492
490
  'help.option.group.create.force': string;
493
- 'help.option.group.sync.skipEmbeddings': string;
494
491
  'help.option.group.sync.exactOnly': string;
495
- 'help.option.group.sync.allowStale': string;
496
492
  'help.option.group.sync.verbose': string;
497
493
  'help.option.status.json': string;
498
494
  'help.option.json': string;
@@ -233,9 +233,7 @@ export declare const zhCN: {
233
233
  'help.option.embeddings.install.cuda': string;
234
234
  'help.option.embeddings.install.force': string;
235
235
  'help.option.group.create.force': string;
236
- 'help.option.group.sync.skipEmbeddings': string;
237
236
  'help.option.group.sync.exactOnly': string;
238
- 'help.option.group.sync.allowStale': string;
239
237
  'help.option.group.sync.verbose': string;
240
238
  'help.option.status.json': string;
241
239
  'help.option.json': string;
@@ -233,10 +233,8 @@ export const zhCN = {
233
233
  'help.option.embeddings.install.cuda': '同时下载 CUDA GPU 二进制文件(运行 onnxruntime-node 的 NuGet postinstall;代理后请设置 GLOBAL_AGENT_HTTPS_PROXY)',
234
234
  'help.option.embeddings.install.force': '即使嵌入组件已可解析,也强制安装到运行时目录',
235
235
  'help.option.group.create.force': '覆盖现有仓库组',
236
- 'help.option.group.sync.skipEmbeddings': '仅使用 exact + BM25(不使用嵌入回退)',
237
- 'help.option.group.sync.exactOnly': '仅精确匹配',
238
- 'help.option.group.sync.allowStale': '跳过过期索引警告',
239
- 'help.option.group.sync.verbose': '显示每条跨仓库链接详情',
236
+ 'help.option.group.sync.exactOnly': '跳过通配符服务匹配,仅按契约 ID 精确匹配建立跨仓链接(清单声明的链接仍然生效)',
237
+ 'help.option.group.sync.verbose': '显示额外的同步诊断信息',
240
238
  'help.option.status.json': '输出机器可读的索引和分析器来源信息',
241
239
  'help.option.json': 'JSON 输出',
242
240
  'help.option.group.impact.target': '要分析的符号或文件名',
@@ -53,13 +53,33 @@ const DEFAULT_IGNORE_LIST = new Set([
53
53
  'obj',
54
54
  'target', // Java/Rust
55
55
  '.next',
56
+ // `.next` is Next.js's build CACHE; `_next` is the EMITTED output, and the two
57
+ // are different directories. A Capacitor/Cordova shell copies the emitted
58
+ // bundle to `<platform>/app/src/main/assets/public/_next/static/…`, where none
59
+ // of the path segments hit this list — so a mobile-wrapped Next.js app had its
60
+ // shipped bundle indexed as source, and every Route node it produced pointed at
61
+ // a webpack chunk rather than code anyone wrote (#3007).
62
+ //
63
+ // The name is deliberately unanchored. No `<web-root>/_next` form matches a
64
+ // root-level `_next/static/…`, which is the shape the reported repo has, so
65
+ // anchoring it would miss the case it was added for. The accepted cost is a
66
+ // hand-written directory literally named `_next`; recover one with a bare
67
+ // `!_next/` line in `.gitnexusignore`.
68
+ '_next',
56
69
  '.nuxt',
57
70
  '.output',
58
71
  '.vercel',
59
72
  '.netlify',
60
73
  '.serverless',
61
74
  '_build',
62
- 'public/build',
75
+ // `'public/build'` used to sit here. This set is tested one path SEGMENT at a
76
+ // time, and `isHardcodedIgnoredDirectory(name)` takes a bare directory name,
77
+ // so a slash-containing member could never match either — it was inert. Its
78
+ // paths were never unignored though: bare `'build'` above already prunes
79
+ // `public/build/**`, so removing the entry changes no behavior (#3007).
80
+ // `test/unit/ignore-build-output.test.ts` keeps the next slash-bearing entry
81
+ // in this set — or in IGNORED_FILES, ROOT_ARTIFACT_DIRECTORIES or
82
+ // IGNORED_EXTENSIONS — from dying the same way.
63
83
  '.parcel-cache',
64
84
  '.turbo',
65
85
  '.svelte-kit',
@@ -87,7 +107,6 @@ const DEFAULT_IGNORE_LIST = new Set([
87
107
  // remains covered by .gitignore/.gitnexusignore and the unambiguous names.
88
108
  'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime
89
109
  '.terraform',
90
- '.serverless',
91
110
  // Documentation (optional - might want to keep)
92
111
  // 'docs',
93
112
  // 'documentation',
@@ -1,5 +1,5 @@
1
1
  import type { LbugValue } from '@ladybugdb/core';
2
- import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
2
+ import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot, MatchType } from './types.js';
3
3
  /**
4
4
  * Serialize an operation on a cached handle's per-handle FIFO chain. Mirrors the
5
5
  * promise-chain mechanic of `lbug/conn-lock.ts` (install a fresh unresolved
@@ -222,6 +222,29 @@ export declare function refreshPreservedBridgeMeta(groupDir: string, diagnostics
222
222
  missingRepos: string[];
223
223
  unreadableRepos: string[];
224
224
  }): Promise<PreservedBridgeMetaOutcome>;
225
+ /**
226
+ * Withdraw the bridge's claim to be complete, without touching the database.
227
+ *
228
+ * The one path this exists for: `contracts.json` committed, then the bridge
229
+ * replacement failed. The old database is still physically usable and still
230
+ * answers queries, but it now describes an EARLIER sync than the canonical
231
+ * registry beside it — so `group_contracts` can report a narrowed or advanced
232
+ * contract set while `group_impact` traverses the old graph and calls its
233
+ * answer complete. Two public surfaces, contradictory epistemic claims, from
234
+ * one sync.
235
+ *
236
+ * Setting `provenanceUnknown` is the smallest thing that makes that safe:
237
+ * `bridgeMetaMatchesFile` gives it highest precedence and refuses to vouch for
238
+ * the pair, so every cross-repo answer downgrades to a floor until a sync
239
+ * succeeds. Deliberately NOT a re-stamp — the metadata still describes the
240
+ * database it was written for, and claiming otherwise is the mis-pairing the
241
+ * preserve path is careful to avoid. Deliberately not a delete either: the
242
+ * previous graph is better than nothing as long as nobody calls it complete.
243
+ *
244
+ * Best-effort by construction. It runs inside a failure handler, so a throw
245
+ * here would replace a reported bridge failure with an unrelated one.
246
+ */
247
+ export declare function markBridgeProvenanceUnknown(groupDir: string): Promise<boolean>;
225
248
  export interface WriteBridgeInput {
226
249
  contracts: StoredContract[];
227
250
  crossLinks: CrossLink[];
@@ -243,6 +266,13 @@ export interface WriteBridgeInput {
243
266
  * contract those repos own.
244
267
  */
245
268
  unreadableRepos?: string[];
269
+ /**
270
+ * Matching stages the sync was asked to skip. Recorded here for the same
271
+ * reason `unreadableRepos` is: a later cross-repo query reads this bridge
272
+ * with no access to the run that built it, and a graph narrowed by request
273
+ * looks exactly like a complete one.
274
+ */
275
+ suppressedMatchStages?: MatchType[];
246
276
  }
247
277
  /**
248
278
  * Non-fatal issues encountered during writeBridge. Callers can log these to
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import lbug from '@ladybugdb/core';
5
5
  import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
6
- import { recordedRepoList } from './completeness.js';
6
+ import { recordedMatchStages, recordedRepoList } from './completeness.js';
7
7
  import { closeLbugConnection, openLbugConnection, } from '../lbug/lbug-config.js';
8
8
  import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
9
9
  import { withGroupSyncLock } from './group-lock.js';
@@ -518,7 +518,15 @@ export async function closeBridgeDb(handle) {
518
518
  /* writeBridgeMeta / readBridgeMeta */
519
519
  /* ------------------------------------------------------------------ */
520
520
  export async function writeBridgeMeta(groupDir, meta) {
521
- await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(meta, null, 2));
521
+ // Strip the reader-only fields HERE rather than at each writer. `readBridgeMeta`
522
+ // sets both on what it returns, so any caller that reads-modifies-writes would
523
+ // round-trip them to disk — and `pairedWithDatabase` is the poisonous one:
524
+ // persisted, it tells every future reader the pair was verified when nothing
525
+ // verified it. That rule used to live in the body of the only such caller,
526
+ // which held exactly as long as there was one. There are now three writers and
527
+ // two of them read first. Enforced at the boundary, no writer can get it wrong.
528
+ const { repoListsUnreadable: _reader1, pairedWithDatabase: _reader2, ...persisted } = meta;
529
+ await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(persisted, null, 2));
522
530
  }
523
531
  /**
524
532
  * Does `meta` still describe the `bridge.lbug` sitting next to it?
@@ -697,6 +705,12 @@ export async function readBridgeMeta(groupDir) {
697
705
  meta.unreadableRepos = unreadableRepos;
698
706
  else
699
707
  delete meta.unreadableRepos;
708
+ // Same absent-vs-empty rule, through the one shared reader.
709
+ const suppressed = recordedMatchStages(raw.suppressedMatchStages);
710
+ if (suppressed)
711
+ meta.suppressedMatchStages = suppressed;
712
+ else
713
+ delete meta.suppressedMatchStages;
700
714
  if (repoListsUnreadable)
701
715
  meta.repoListsUnreadable = true;
702
716
  return meta;
@@ -754,7 +768,14 @@ async function fileExists(filePath) {
754
768
  * section ever appears, it needs the same treatment `writeBridge` got — a
755
769
  * wrapper, not a lock moved down here.
756
770
  */
757
- export async function refreshPreservedBridgeMeta(groupDir, diagnostics) {
771
+ export async function refreshPreservedBridgeMeta(groupDir,
772
+ // Deliberately NOT `suppressedMatchStages`. This path preserves an EARLIER
773
+ // sync's database, so stamping it with this run's request would claim the
774
+ // untouched bridge was built with a flag it never saw. The registry's own
775
+ // preserve write (`{ ...prior, missingRepos, unreadableRepos }`) omits it for
776
+ // exactly this reason, and the two artifacts have to agree about which run
777
+ // they describe.
778
+ diagnostics) {
758
779
  const dbPath = path.join(groupDir, 'bridge.lbug');
759
780
  const [metaOnDisk, dbOnDisk] = await Promise.all([
760
781
  fileExists(path.join(groupDir, 'meta.json')),
@@ -771,10 +792,8 @@ export async function refreshPreservedBridgeMeta(groupDir, diagnostics) {
771
792
  const refreshed = { ...existing, ...diagnostics };
772
793
  // NEVER PERSISTED (see `BridgeMeta`): both are things a READER computes ABOUT
773
794
  // a file, and this is the first code in the repo that reads metadata and
774
- // writes it back. `pairedWithDatabase` is the poisonous one — persisted, it
775
- // would tell every future reader that the pair had been verified.
776
- delete refreshed.repoListsUnreadable;
777
- delete refreshed.pairedWithDatabase;
795
+ // writes it back. The strip itself now lives in `writeBridgeMeta`, so every
796
+ // writer inherits it rather than each remembering.
778
797
  if (paired) {
779
798
  const stat = await fsp.stat(dbPath).catch(() => null);
780
799
  if (stat) {
@@ -791,6 +810,40 @@ export async function refreshPreservedBridgeMeta(groupDir, diagnostics) {
791
810
  await writeBridgeMeta(groupDir, refreshed);
792
811
  return 'provenance-unknown';
793
812
  }
813
+ /**
814
+ * Withdraw the bridge's claim to be complete, without touching the database.
815
+ *
816
+ * The one path this exists for: `contracts.json` committed, then the bridge
817
+ * replacement failed. The old database is still physically usable and still
818
+ * answers queries, but it now describes an EARLIER sync than the canonical
819
+ * registry beside it — so `group_contracts` can report a narrowed or advanced
820
+ * contract set while `group_impact` traverses the old graph and calls its
821
+ * answer complete. Two public surfaces, contradictory epistemic claims, from
822
+ * one sync.
823
+ *
824
+ * Setting `provenanceUnknown` is the smallest thing that makes that safe:
825
+ * `bridgeMetaMatchesFile` gives it highest precedence and refuses to vouch for
826
+ * the pair, so every cross-repo answer downgrades to a floor until a sync
827
+ * succeeds. Deliberately NOT a re-stamp — the metadata still describes the
828
+ * database it was written for, and claiming otherwise is the mis-pairing the
829
+ * preserve path is careful to avoid. Deliberately not a delete either: the
830
+ * previous graph is better than nothing as long as nobody calls it complete.
831
+ *
832
+ * Best-effort by construction. It runs inside a failure handler, so a throw
833
+ * here would replace a reported bridge failure with an unrelated one.
834
+ */
835
+ export async function markBridgeProvenanceUnknown(groupDir) {
836
+ try {
837
+ const existing = await readBridgeMeta(groupDir);
838
+ if (existing.version === 0)
839
+ return false;
840
+ await writeBridgeMeta(groupDir, { ...existing, provenanceUnknown: true });
841
+ return true;
842
+ }
843
+ catch {
844
+ return false;
845
+ }
846
+ }
794
847
  const MAX_SAMPLE_ERRORS = 10;
795
848
  function errMessage(err) {
796
849
  if (err instanceof Error)
@@ -1085,6 +1138,9 @@ export async function writeBridgeUnlocked(groupDir, input) {
1085
1138
  // different claim from a bridge that never recorded the field. Omitted
1086
1139
  // only when the caller passed nothing to record.
1087
1140
  ...(input.unreadableRepos ? { unreadableRepos: input.unreadableRepos } : {}),
1141
+ ...(input.suppressedMatchStages
1142
+ ? { suppressedMatchStages: input.suppressedMatchStages }
1143
+ : {}),
1088
1144
  });
1089
1145
  return report;
1090
1146
  }
@@ -13,7 +13,7 @@
13
13
  * Nothing here imports anything but types. Keep it that way: the moment this
14
14
  * file gains a runtime import, every consumer pays for it again.
15
15
  */
16
- import type { GroupImpactTruncationReason } from './types.js';
16
+ import type { GroupImpactTruncationReason, MatchType } from './types.js';
17
17
  /**
18
18
  * A union rather than `Pick<GroupImpactResult, ...>` so the two states are
19
19
  * distinguishable by their `truncated` discriminant: a caller that folds these
@@ -58,6 +58,12 @@ export interface CrossRepoCompletenessInput {
58
58
  */
59
59
  unreadableRepos?: readonly string[];
60
60
  missingRepos?: readonly string[];
61
+ /**
62
+ * Matching stages the sync was asked to skip. Absent or empty means it
63
+ * suppressed none; a populated list makes the answer a floor for a reason
64
+ * that is neither a runtime limit nor an unreadable repo.
65
+ */
66
+ suppressedMatchStages?: readonly string[];
61
67
  /** Computed by the caller; see `bridgeProvenanceUnknown` for the bridge one. */
62
68
  provenanceUnknown: boolean;
63
69
  /**
@@ -80,6 +86,18 @@ export type CrossRepoCompleteness = TruncationFields & {
80
86
  */
81
87
  incompleteRepos: string[];
82
88
  };
89
+ /**
90
+ * Read a persisted `suppressedMatchStages` list.
91
+ *
92
+ * Sibling of `recordedRepoList` and here for the same stated reason: it had
93
+ * lived in two files verbatim, so tightening one would silently leave the other.
94
+ * All-or-nothing like its sibling — a stale member (this repo has already
95
+ * retired `'bm25'` and `'embedding'`) makes the whole list unreadable rather
96
+ * than filtering down to `[]`, which on this field would mean "measured,
97
+ * nothing suppressed": a clean answer manufactured from a value we could not
98
+ * read.
99
+ */
100
+ export declare function recordedMatchStages(value: unknown): MatchType[] | undefined;
83
101
  /**
84
102
  * The ONE computation of "is this cross-repo answer complete?" (KTD10).
85
103
  *
@@ -16,6 +16,23 @@ reasonIfTruncated = 'partial') {
16
16
  return { truncated: false };
17
17
  return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
18
18
  }
19
+ /**
20
+ * Read a persisted `suppressedMatchStages` list.
21
+ *
22
+ * Sibling of `recordedRepoList` and here for the same stated reason: it had
23
+ * lived in two files verbatim, so tightening one would silently leave the other.
24
+ * All-or-nothing like its sibling — a stale member (this repo has already
25
+ * retired `'bm25'` and `'embedding'`) makes the whole list unreadable rather
26
+ * than filtering down to `[]`, which on this field would mean "measured,
27
+ * nothing suppressed": a clean answer manufactured from a value we could not
28
+ * read.
29
+ */
30
+ export function recordedMatchStages(value) {
31
+ if (!Array.isArray(value))
32
+ return undefined;
33
+ const known = ['exact', 'manifest', 'wildcard'];
34
+ return value.every((v) => known.includes(v)) ? value : undefined;
35
+ }
19
36
  /**
20
37
  * The ONE computation of "is this cross-repo answer complete?" (KTD10).
21
38
  *
@@ -34,8 +51,14 @@ export function crossRepoCompleteness(input) {
34
51
  const incompleteRepos = [
35
52
  ...new Set([...(input.unreadableRepos ?? []), ...(input.missingRepos ?? [])]),
36
53
  ].filter((repoPath) => input.inScope(repoPath));
54
+ // An unreadable or unaccounted repo outranks a suppressed stage: it is the
55
+ // more serious structural gap and its remedy (repair the repo, re-sync) has
56
+ // to be the one reported. A suppressed stage only decides the reason when
57
+ // the repo side is otherwise clean.
58
+ const suppressed = (input.suppressedMatchStages ?? []).length > 0;
59
+ const repoSideIncomplete = input.provenanceUnknown || incompleteRepos.length > 0;
37
60
  return {
38
- ...truncationFields(input.provenanceUnknown || incompleteRepos.length > 0, 'incomplete-sync'),
61
+ ...truncationFields(repoSideIncomplete || suppressed, repoSideIncomplete ? 'incomplete-sync' : 'suppressed-stage'),
39
62
  incompleteRepos,
40
63
  };
41
64
  }
@@ -25,15 +25,10 @@ const DEFAULT_DETECT = {
25
25
  grpc: true,
26
26
  thrift: true,
27
27
  topics: true,
28
- shared_libs: true,
29
- embedding_fallback: true,
30
28
  includes: false,
31
29
  workspace_deps: false,
32
30
  };
33
31
  const DEFAULT_MATCHING = {
34
- bm25_threshold: 0.7,
35
- embedding_threshold: 0.65,
36
- max_candidates_per_step: 3,
37
32
  exclude_links_paths: [],
38
33
  exclude_links_param_only_paths: false,
39
34
  };
@@ -696,6 +696,7 @@ export async function runGroupImpact(deps, params) {
696
696
  const bridge = crossRepoCompleteness({
697
697
  unreadableRepos: bridgePrep.meta.unreadableRepos,
698
698
  missingRepos: bridgePrep.meta.missingRepos,
699
+ suppressedMatchStages: bridgePrep.meta.suppressedMatchStages,
699
700
  provenanceUnknown,
700
701
  inScope: (candidate) => repoInSubgroup(candidate, subgroup) || repoInSubgroup(candidate, repoPath, true),
701
702
  });
@@ -716,13 +717,21 @@ export async function runGroupImpact(deps, params) {
716
717
  // and under-reporting a blast radius is the unsafe direction (an agent told
717
718
  // LOW proceeds; told CRITICAL it stops). Marking the floor keeps the
718
719
  // warning intact while making the incompleteness legible.
719
- // Runtime limits first — they are what the caller can retry. 'incomplete-sync'
720
- // is the remaining cause once nothing was merely cut short, and its remedy is
721
- // a different one: re-run `gitnexus group sync`, not the query. Computed
722
- // inline because `truncationFields` reads the reason ONLY on the truncated
723
- // branch — naming it in a variable invited reading it on the complete path,
724
- // where it would say 'incomplete-sync' about a complete result.
725
- ...truncationFields(truncated, fanoutTimedOut ? 'timeout' : runtimeTruncated ? 'partial' : 'incomplete-sync'),
720
+ // Runtime limits first — they are what the caller can retry. Past those, the
721
+ // BRIDGE's own reason wins: it already distinguished an unreadable repo
722
+ // ('incomplete-sync', remedy: re-sync) from a stage the sync was asked to
723
+ // skip ('suppressed-stage', remedy: re-sync WITHOUT the flag). Hardcoding
724
+ // the fallback here overrode that and told every caller to repair a repo
725
+ // that read fine — and made the second value unreachable from this surface
726
+ // while the tool description promised it. `cross-trace.ts` re-spreads the
727
+ // bridge's fields for the same reason.
728
+ ...truncationFields(truncated, fanoutTimedOut
729
+ ? 'timeout'
730
+ : runtimeTruncated
731
+ ? 'partial'
732
+ : bridge.truncated
733
+ ? bridge.truncationReason
734
+ : 'incomplete-sync'),
726
735
  truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])],
727
736
  summary: {
728
737
  direct: localSum.direct ?? 0,
@@ -102,6 +102,7 @@ function bridgeCompletenessFor(meta, inScope) {
102
102
  return crossRepoCompleteness({
103
103
  unreadableRepos: meta.unreadableRepos,
104
104
  missingRepos: meta.missingRepos,
105
+ suppressedMatchStages: meta.suppressedMatchStages,
105
106
  provenanceUnknown: bridgeProvenanceUnknown(meta),
106
107
  inScope,
107
108
  });