gitnexus 1.6.10-rc.217 → 1.6.10-rc.219

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/cli/group.js +124 -11
  2. package/dist/core/group/bridge-db.d.ts +173 -0
  3. package/dist/core/group/bridge-db.js +345 -4
  4. package/dist/core/group/completeness.d.ts +112 -0
  5. package/dist/core/group/completeness.js +60 -0
  6. package/dist/core/group/cross-impact.d.ts +25 -1
  7. package/dist/core/group/cross-impact.js +98 -19
  8. package/dist/core/group/cross-trace.d.ts +32 -11
  9. package/dist/core/group/cross-trace.js +86 -4
  10. package/dist/core/group/extractors/http-patterns/node.js +283 -39
  11. package/dist/core/group/group-lock.d.ts +31 -0
  12. package/dist/core/group/group-lock.js +166 -0
  13. package/dist/core/group/service.js +184 -14
  14. package/dist/core/group/storage.d.ts +3 -0
  15. package/dist/core/group/storage.js +5 -1
  16. package/dist/core/group/sync.d.ts +40 -0
  17. package/dist/core/group/sync.js +0 -0
  18. package/dist/core/group/types.d.ts +135 -2
  19. package/dist/core/group/types.js +16 -1
  20. package/dist/core/ingestion/route-extractors/constant-resolver.d.ts +2 -2
  21. package/dist/core/ingestion/route-extractors/constant-resolver.js +10 -6
  22. package/dist/core/ingestion/route-extractors/js-const-resolver.d.ts +192 -0
  23. package/dist/core/ingestion/route-extractors/js-const-resolver.js +1033 -0
  24. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +1 -1
  25. package/dist/mcp/resources.js +19 -2
  26. package/dist/mcp/tools.js +10 -4
  27. package/dist/storage/index-lock.d.ts +19 -0
  28. package/dist/storage/index-lock.js +8 -2
  29. package/dist/storage/repo-manager.d.ts +25 -1
  30. package/dist/storage/repo-manager.js +126 -6
  31. package/package.json +1 -1
  32. package/scripts/cross-platform-shard.ts +10 -0
  33. package/scripts/cross-platform-tests.ts +34 -0
package/dist/cli/group.js CHANGED
@@ -100,7 +100,24 @@ export function registerGroupCommands(program) {
100
100
  console.log(' Repo index / contracts staleness:');
101
101
  for (const [repoPath, row] of Object.entries(st.repos || {})) {
102
102
  if (row.missing) {
103
- console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
103
+ // Two different facts with two different remedies: a repo the
104
+ // registry never heard of is fixed by indexing it, while an entry
105
+ // the resolver choked on is fixed by repairing the registry.
106
+ // Printing "no entry in the registry" for the second one states a
107
+ // cause that was never measured, and points at the wrong repair.
108
+ if (row.unresolvable) {
109
+ // The reason can be multi-line — an ambiguous registry names
110
+ // every colliding clone. Fold it onto this row's line rather
111
+ // than truncating it: those paths are what the operator acts on,
112
+ // and a table row that swallows half its own explanation is the
113
+ // failure this label exists to stop.
114
+ const why = (row.unresolvableReason ?? 'the registry entry could not be resolved')
115
+ .replace(/\s+/g, ' ')
116
+ .trim();
117
+ console.log(` ${repoPath.padEnd(25)} UNRESOLVABLE (${why})`);
118
+ continue;
119
+ }
120
+ console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
104
121
  continue;
105
122
  }
106
123
  const idx = row.indexStale
@@ -109,6 +126,25 @@ export function registerGroupCommands(program) {
109
126
  const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
110
127
  console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
111
128
  }
129
+ // `undefined` and `[]` are different answers here: a registry written
130
+ // before this was tracked has no opinion, while an empty array is a
131
+ // measurement. Printing nothing for both would let an unmeasured sync
132
+ // read as evidence that every index opened cleanly.
133
+ //
134
+ // `undefined` covers two ways of not knowing — the field is absent, or
135
+ // it held something that was not a list of repo paths and `getStatus`
136
+ // declined to guess. Naming only the first would make a corrupt
137
+ // registry read as a merely old one, which is the same shape of wrong
138
+ // answer this command exists to stop giving.
139
+ const unreadable = st.unreadableRepos;
140
+ if (unreadable === undefined) {
141
+ console.log(`\n Last sync unreadable repos: not recorded` +
142
+ `\n (the registry predates this field, or its value could not be read)` +
143
+ `\n Re-run \`gitnexus group sync\` to record it.`);
144
+ }
145
+ else if (unreadable.length > 0) {
146
+ console.log(`\n Last sync unreadable repos: ${unreadable.join(', ')}`);
147
+ }
112
148
  if ((st.missingRepos || []).length > 0) {
113
149
  console.log(`\n Last sync missing repos: ${st.missingRepos.join(', ')}`);
114
150
  }
@@ -129,25 +165,85 @@ export function registerGroupCommands(program) {
129
165
  const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
130
166
  const { loadGroupConfig } = await import('../core/group/config-parser.js');
131
167
  const { syncGroup } = await import('../core/group/sync.js');
168
+ const { GroupSyncLockError } = await import('../core/group/group-lock.js');
132
169
  const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
133
170
  const config = await loadGroupConfig(groupDir);
134
171
  console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
135
- const result = await syncGroup(config, {
136
- groupDir,
137
- allowStale: Boolean(opts.allowStale),
138
- verbose: Boolean(opts.verbose),
139
- skipEmbeddings: Boolean(opts.skipEmbeddings),
140
- exactOnly: Boolean(opts.exactOnly),
141
- });
172
+ let result;
173
+ try {
174
+ result = await syncGroup(config, {
175
+ groupDir,
176
+ allowStale: Boolean(opts.allowStale),
177
+ verbose: Boolean(opts.verbose),
178
+ skipEmbeddings: Boolean(opts.skipEmbeddings),
179
+ exactOnly: Boolean(opts.exactOnly),
180
+ });
181
+ }
182
+ catch (err) {
183
+ // A sync that could not take the group's lock did NOT run and wrote
184
+ // nothing (R9 fails closed). That is an operator-actionable outcome, not
185
+ // a crash, so report it as a failed command rather than letting it
186
+ // surface as an unhandled rejection with a stack trace — commander's
187
+ // async actions have no error handler, so an uncaught throw here would
188
+ // print exactly that.
189
+ if (!(err instanceof GroupSyncLockError))
190
+ throw err;
191
+ logger.error(`⚠️ Did not sync group "${name}": ${err.message}`);
192
+ process.exitCode = 1;
193
+ return;
194
+ }
142
195
  if (opts.json) {
143
196
  console.log(JSON.stringify(result, null, 2));
144
197
  }
145
198
  else {
199
+ // Repos we could not read are the most likely explanation for a small
200
+ // or empty contract count, so they are reported before the counts —
201
+ // otherwise a run that read nothing looks exactly like a clean run.
202
+ if (result.unreadableRepos.length > 0) {
203
+ // No "re-run with GITNEXUS_LOG_LEVEL=warn" hint: the default level is
204
+ // `info`, and pino emits `warn` (40) at `info` (30), so the reason was
205
+ // already printed by this same run — raising the level to `warn` would
206
+ // only suppress the surrounding `info` output.
207
+ console.log(`\n ⚠️ Could not extract contracts from: ${result.unreadableRepos.join(', ')}` +
208
+ `\n None of their contracts are included in this sync (the warning above says why),` +
209
+ `\n or check \`gitnexus doctor\` in the affected repo.`);
210
+ }
211
+ if (result.missingRepos.length > 0) {
212
+ console.log(`\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` +
213
+ `\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`);
214
+ }
146
215
  console.log(`\nMatching cascade:`);
147
216
  const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
148
217
  console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
149
218
  console.log(` unmatched: ${result.unmatched.length} contracts`);
150
- console.log(`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`);
219
+ // Driven by what actually happened to the file. This line used to be
220
+ // unconditional, so a run that deliberately preserved the previous
221
+ // registry still announced `Wrote contracts.json (0 contracts, 0
222
+ // cross-links)` — a confident false statement about persisted state, on
223
+ // the exact path this command exists to make legible.
224
+ // Exhaustive by construction: a `Record` keyed on the union means a
225
+ // new outcome fails the build here instead of printing nothing, which
226
+ // is what previously pushed a distinct state into `preserved` and made
227
+ // this summary false on one of the two branches it then covered.
228
+ const OUTCOME_LINE = {
229
+ written: `\nWrote contracts.json (${result.contracts.length} contracts, ` +
230
+ `${result.crossLinks.length} cross-links)`,
231
+ preserved: `\nKept the previous contracts.json — no repo in this group could be read.` +
232
+ `\n Its contracts and cross-links are unchanged; only the unreadable/missing` +
233
+ `\n repo lists were refreshed to describe THIS run. Fix the repos above and re-run.`,
234
+ superseded: `\nDid NOT touch contracts.json — no repo in this group could be read, and another` +
235
+ `\n sync replaced the file while this one waited for the group lock. That sync's` +
236
+ `\n result stands and this run's repo lists were NOT recorded: they describe a` +
237
+ `\n group state older than what is on disk. Fix the repos above and re-run.`,
238
+ 'no-prior-registry': `\nDid NOT write contracts.json — no repo in this group could be read,` +
239
+ `\n and there is no previous contracts.json to fall back on. Fix the repos` +
240
+ `\n above and re-run.`,
241
+ // Nothing to say: the caller asked for no write.
242
+ 'not-attempted': null,
243
+ };
244
+ const line = OUTCOME_LINE[result.registryOutcome];
245
+ if (line)
246
+ console.log(line);
151
247
  }
152
248
  });
153
249
  group
@@ -306,9 +402,15 @@ export function registerGroupCommands(program) {
306
402
  process.exitCode = 1;
307
403
  return;
308
404
  }
309
- const { contracts, crossLinks } = raw;
405
+ const { contracts, crossLinks, truncated, unreadableRepos, missingRepos } = raw;
310
406
  if (opts.json) {
311
- console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
407
+ // The whole payload, not a re-serialized subset. Destructuring the two
408
+ // fields this command happens to print and rebuilding an object from
409
+ // them dropped everything else the service returned — which is how the
410
+ // completeness fields were invisible here while the MCP tool carried
411
+ // them. Printing `raw` means a field added to the service reaches
412
+ // `--json` without a matching edit in this file.
413
+ console.log(JSON.stringify(raw, null, 2));
312
414
  }
313
415
  else {
314
416
  console.log(`Contracts (${contracts.length}):`);
@@ -319,6 +421,17 @@ export function registerGroupCommands(program) {
319
421
  for (const l of crossLinks) {
320
422
  console.log(` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`);
321
423
  }
424
+ if (truncated) {
425
+ // Counts above are a floor, not a census. Name the repos when the
426
+ // registry recorded them, and say so plainly when it did not — a
427
+ // listing that cannot say what it is missing is still incomplete.
428
+ const absent = [...(unreadableRepos ?? []), ...(missingRepos ?? [])];
429
+ console.log(absent.length > 0
430
+ ? `\n⚠️ This listing is incomplete: the last sync could not account for ${absent.join(', ')}.` +
431
+ `\n Contracts from those repos are absent, so the counts above are a lower bound.`
432
+ : `\n⚠️ This listing is incomplete: the last sync did not record which repos it could` +
433
+ `\n read, so the counts above are a lower bound. Re-run group sync.`);
434
+ }
322
435
  }
323
436
  }
324
437
  finally {
@@ -111,12 +111,138 @@ export declare function queryBridge<T>(handle: BridgeHandle, cypher: string, par
111
111
  */
112
112
  export declare function closeBridgeDb(handle: BridgeHandle): Promise<void>;
113
113
  export declare function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void>;
114
+ /**
115
+ * Does `meta` still describe the `bridge.lbug` sitting next to it?
116
+ *
117
+ * `writeBridge` stamps the database's size and mtime into the metadata it
118
+ * writes, so a metadata file left over from an earlier sync cannot match a
119
+ * database that was replaced after it. Callers whose answer depends on the
120
+ * metadata being true of THIS database (cross-repo impact reads completeness
121
+ * from it) must not treat a mismatch as fact.
122
+ *
123
+ * When BOTH halves of the stamp are absent the metadata predates stamping, and
124
+ * it is judged on the write order of the two files instead — see
125
+ * {@link unstampedMetaPairsByWriteOrder}. Failing every unstamped metadata
126
+ * closed would mark all pre-existing bridges as incomplete until re-synced,
127
+ * trading a narrow window for a repo-wide regression; accepting them all hands
128
+ * back "verified" for the very window this pairing exists to catch.
129
+ *
130
+ * A stamp is a PAIR, so exactly one half present is rejected rather than waved
131
+ * through. That is not the legacy shape: something wrote a stamp and did not
132
+ * finish, which is the very condition stamping was added to detect. Joining the
133
+ * two `undefined` checks with `||` returned "verified" for precisely the shape
134
+ * that most deserves suspicion.
135
+ *
136
+ * Returns `false` when the database itself cannot be stat'd, on either path,
137
+ * since metadata describing a file that is not there describes nothing.
138
+ *
139
+ * The checks are ORDERED by how strong their evidence is, strongest first, and
140
+ * each later one is reached only because every earlier one had nothing to say.
141
+ * `provenanceUnknown` therefore comes first: a metadata file whose own writer
142
+ * says it cannot vouch for the database beside it has settled the question, and
143
+ * neither the stamp nor the write-order heuristic may overturn that.
144
+ *
145
+ * The marker is not decoration. `refreshPreservedBridgeMeta` rewrites this file
146
+ * atomically without touching the database, which leaves `meta.mtime` newer —
147
+ * the write order a paired write produces, and the one the unstamped branch
148
+ * ACCEPTS. Reading the marker after that branch (or not at all) hands back
149
+ * "verified" for a pair the same code path had just found broken.
150
+ */
151
+ export declare function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta): Promise<boolean>;
152
+ /**
153
+ * Read `meta.json`, validating the SHAPE of what it holds.
154
+ *
155
+ * The read and the parse have always been guarded — an absent or unparseable
156
+ * file answers `version: 0`, which every caller already treats as "no
157
+ * provenance". What was not guarded is a file that parses into something that
158
+ * is not this shape: `runGroupImpact` spread both repo lists directly into a
159
+ * `Set`, so a non-iterable there threw a TypeError out of the whole cross-repo
160
+ * query, from a point where the bridge lease had been taken and not yet
161
+ * released. A malformed file is a reason to answer "provenance unknown", never
162
+ * a reason to crash the question.
163
+ */
114
164
  export declare function readBridgeMeta(groupDir: string): Promise<BridgeMeta>;
165
+ /**
166
+ * What a refresh did to `meta.json`.
167
+ *
168
+ * - `restamped` — the pair still matched, so the lists were refreshed
169
+ * and the stamp re-taken from the database on disk.
170
+ * - `provenance-unknown` — the pair did NOT match (or there is no database to
171
+ * match), so the lists were refreshed and the metadata
172
+ * marked as unable to vouch for the file beside it.
173
+ * - `no-bridge` — neither `meta.json` nor `bridge.lbug` exists, so
174
+ * there is no pair to keep honest and nothing written.
175
+ */
176
+ export type PreservedBridgeMetaOutcome = 'restamped' | 'provenance-unknown' | 'no-bridge';
177
+ /**
178
+ * Bring `meta.json`'s diagnostic lists up to date with a sync that PRESERVED
179
+ * the bridge instead of rebuilding it, without ever making the metadata claim
180
+ * more about the database than it did before.
181
+ *
182
+ * `syncGroup`'s total-failure path keeps the previous run's contracts and
183
+ * deliberately leaves `bridge.lbug` alone — the contracts that bridge holds are
184
+ * the ones being preserved. But `runGroupImpact` reads completeness from
185
+ * `meta.json`, not from `contracts.json`, so leaving the metadata alone too left
186
+ * the two files telling different stories: the registry said "this sync could
187
+ * not read svc/users" while a cross-repo query answered "complete, nothing
188
+ * depends on this" (R4/R6).
189
+ *
190
+ * The refresh is the whole difficulty. It rewrites `meta.json` atomically, so
191
+ * the file's mtime becomes now while the database's stays old — which is the
192
+ * write order a paired write produces, and precisely what
193
+ * `unstampedMetaPairsByWriteOrder` accepts. Three rules follow, and each of them
194
+ * is load-bearing:
195
+ *
196
+ * 1. Ask `bridgeMetaMatchesFile` FIRST, on the file as it stands. After the
197
+ * write the question is unanswerable, because the write is what destroys
198
+ * the evidence.
199
+ * 2. Re-stamp only when that answer was yes. Re-stamping a pair that already
200
+ * failed would MANUFACTURE the provenance the failure just denied — the
201
+ * same metadata/database mis-pairing stamping exists to prevent (KTD6).
202
+ * 3. When it was no, record `provenanceUnknown` explicitly and carry the
203
+ * existing stamp fields through verbatim. Writing "no stamp" instead is
204
+ * worse, not better: an unstamped file is judged on the two file times,
205
+ * and this write has just put them in the accepting order.
206
+ *
207
+ * Nothing here opens, reads, or writes the database. The only `stat` of it
208
+ * happens on the branch where the pair was just verified.
209
+ *
210
+ * NOT SPLIT into locked/unlocked halves the way {@link writeBridge} is, and
211
+ * deliberately. Its one caller is `syncGroup`'s preserve branch, which is
212
+ * already inside `withGroupSyncLock` — so this write is ALREADY serialized
213
+ * against every other sync of the group, and taking the lock here would be the
214
+ * second acquisition of a non-reentrant primitive that the split exists to
215
+ * avoid. An acquiring wrapper would therefore have zero production callers,
216
+ * and no test calls this function at all: it would be dead code standing in for
217
+ * a guarantee the caller already provides. If a caller outside the critical
218
+ * section ever appears, it needs the same treatment `writeBridge` got — a
219
+ * wrapper, not a lock moved down here.
220
+ */
221
+ export declare function refreshPreservedBridgeMeta(groupDir: string, diagnostics: {
222
+ missingRepos: string[];
223
+ unreadableRepos: string[];
224
+ }): Promise<PreservedBridgeMetaOutcome>;
115
225
  export interface WriteBridgeInput {
116
226
  contracts: StoredContract[];
117
227
  crossLinks: CrossLink[];
118
228
  repoSnapshots: Record<string, RepoSnapshot>;
119
229
  missingRepos: string[];
230
+ /**
231
+ * Repos this sync could not extract from — see
232
+ * `ContractRegistry.unreadableRepos` for the full definition, which this
233
+ * field carries unchanged.
234
+ *
235
+ * Deliberately not restated here. The narrower wording this once had ("whose
236
+ * index could not be opened") described one of the two causes and silently
237
+ * excluded the other, an extractor that threw partway through — so the same
238
+ * field meant one thing on the registry, another on the bridge input, and a
239
+ * third on the result. One definition, referenced twice, cannot drift.
240
+ *
241
+ * Recorded in meta.json so cross-repo impact can tell "nothing depends on
242
+ * this" from "we could not look": the bridge built here is missing every
243
+ * contract those repos own.
244
+ */
245
+ unreadableRepos?: string[];
120
246
  }
121
247
  /**
122
248
  * Non-fatal issues encountered during writeBridge. Callers can log these to
@@ -138,6 +264,53 @@ export interface WriteBridgeReport {
138
264
  message: string;
139
265
  }>;
140
266
  }
267
+ /**
268
+ * Rebuild `bridge.lbug` and its `meta.json`, ASSUMING THE CALLER ALREADY HOLDS
269
+ * THE GROUP SYNC LOCK for `groupDir` (R9).
270
+ *
271
+ * PRECONDITION — the group lock is held. There is exactly one production call
272
+ * site, `syncGroup` in sync.ts, and it is already inside
273
+ * `withGroupSyncLock(groupDir, …)` when it gets here. Enforced by this comment
274
+ * rather than by a type, matching `registerRepoUnlocked` / `withRegistryLock`
275
+ * in repo-manager.ts, which splits the same shape for the same reason.
276
+ *
277
+ * WHY THE SPLIT EXISTS AT ALL. The swap this function performs — old database
278
+ * aside, temp database into place, then `meta.json` written as a SECOND
279
+ * operation — is the write two concurrent syncs can interleave into a pairing
280
+ * that never existed: one sync's metadata beside the other's database. That
281
+ * needs mutual exclusion. But taking the lock HERE would be a second
282
+ * acquisition of a non-reentrant primitive inside a region that already holds
283
+ * it, and it would hang every single sync on the happy path, not some rare
284
+ * interleave. So the exclusion is the caller's, and this function only states
285
+ * the precondition. {@link writeBridge} is the acquiring wrapper for callers
286
+ * who are not already inside that region.
287
+ *
288
+ * SCOPE — writer-writer only. The reader-side promotion of a leftover
289
+ * `bridge.lbug.bak` runs on ordinary reads, outside anybody's critical section;
290
+ * `bridgeMetaMatchesFile` remains the reader's defense there and is not
291
+ * replaced by this lock.
292
+ */
293
+ export declare function writeBridgeUnlocked(groupDir: string, input: WriteBridgeInput): Promise<WriteBridgeReport>;
294
+ /**
295
+ * Rebuild `bridge.lbug` and its `meta.json` as the only writer of `groupDir`.
296
+ *
297
+ * The acquiring half of the split described on {@link writeBridgeUnlocked}: for
298
+ * callers that are NOT already inside the group's critical section, this takes
299
+ * the group sync lock around the whole swap and releases it afterwards. Two
300
+ * concurrent calls therefore run one after the other, so the `meta.json` left
301
+ * on disk is stamped for the `bridge.lbug` left on disk instead of for the
302
+ * loser's, which is the pairing the swap-plus-metadata sequence would otherwise
303
+ * let them interleave into.
304
+ *
305
+ * NOT used by `syncGroup`, and it must not be: that path already holds this
306
+ * lock, and `acquireIndexLock` is not reentrant, so routing it here would make
307
+ * every ordinary sync wait out the full `GROUP_SYNC_LOCK_TIMEOUT_MS` ceiling
308
+ * against itself. It calls {@link writeBridgeUnlocked} directly.
309
+ *
310
+ * Fails closed exactly as `withGroupSyncLock` does: if the lock cannot be
311
+ * acquired, a `GroupSyncLockError` is thrown and NOTHING is written —
312
+ * `bridge.lbug` and `meta.json` are left as they were.
313
+ */
141
314
  export declare function writeBridge(groupDir: string, input: WriteBridgeInput): Promise<WriteBridgeReport>;
142
315
  export declare function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null>;
143
316
  export declare function bridgeExists(groupDir: string): Promise<boolean>;