gitnexus 1.6.11-rc.19 → 1.6.11-rc.20

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.
@@ -62,6 +62,9 @@ export declare function validateGroupImpactParams(params: Record<string, unknown
62
62
  name: string;
63
63
  repoPath: string;
64
64
  target: string;
65
+ target_uid?: string;
66
+ file_path?: string;
67
+ kind?: string;
65
68
  direction: 'upstream' | 'downstream';
66
69
  maxDepth: number;
67
70
  crossDepth: number;
@@ -103,13 +103,21 @@ export function clampTimeout(timeoutMs) {
103
103
  export function validateGroupImpactParams(params) {
104
104
  const name = String(params.name ?? '').trim();
105
105
  const repoPath = String(params.repo ?? '').trim();
106
- const target = String(params.target ?? '').trim();
106
+ // Optional string, same helper shape as cross-trace's `str()`: empty/blank
107
+ // counts as absent so `target_uid: ''` degrades to the name lookup rather
108
+ // than a zero-ambiguity lookup of the empty uid. Parsed before the required
109
+ // check so UID-only callers (MCP impact schema requires `direction`, not
110
+ // `target`) are accepted.
111
+ const str = (v) => typeof v === 'string' && v.trim() !== '' ? v : undefined;
112
+ const targetName = String(params.target ?? '').trim();
113
+ const target_uidEarly = str(params.target_uid);
107
114
  if (!name)
108
115
  return { ok: false, error: 'name is required' };
109
116
  if (!repoPath)
110
117
  return { ok: false, error: 'repo is required (group repo path, e.g. app/backend)' };
111
- if (!target)
112
- return { ok: false, error: 'target is required' };
118
+ if (!targetName && !target_uidEarly)
119
+ return { ok: false, error: 'target or target_uid is required' };
120
+ const target = targetName || target_uidEarly;
113
121
  if (params.service !== undefined &&
114
122
  params.service !== null &&
115
123
  String(params.service).trim() === '') {
@@ -133,6 +141,9 @@ export function validateGroupImpactParams(params) {
133
141
  minConfidence = 1;
134
142
  const service = normalizeServicePrefix(params.service);
135
143
  const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
144
+ const target_uid = target_uidEarly;
145
+ const file_path = str(params.file_path);
146
+ const kind = str(params.kind);
136
147
  // Clamp at the validate boundary so the downstream `deadline` (line
137
148
  // ~366) and `safeLocalImpact`'s `setTimeout` both see a single
138
149
  // bounded value. Without this, the outer deadline budgeted Phase-2
@@ -150,6 +161,9 @@ export function validateGroupImpactParams(params) {
150
161
  name,
151
162
  repoPath,
152
163
  target,
164
+ target_uid,
165
+ file_path,
166
+ kind,
153
167
  direction,
154
168
  maxDepth,
155
169
  crossDepth,
@@ -448,7 +462,7 @@ export async function runGroupImpact(deps, params) {
448
462
  const parsed = validateGroupImpactParams(params);
449
463
  if (parsed.ok === false)
450
464
  return { error: parsed.error };
451
- const { name, repoPath, target, direction, maxDepth, crossDepth: _crossDepth, crossDepthWarning, relationTypes, includeTests, minConfidence, service: servicePrefix, subgroup, timeoutMs, } = parsed;
465
+ const { name, repoPath, target, target_uid, file_path, kind, direction, maxDepth, crossDepth: _crossDepth, crossDepthWarning, relationTypes, includeTests, minConfidence, service: servicePrefix, subgroup, timeoutMs, } = parsed;
452
466
  const groupDir = getGroupDir(deps.gitnexusDir, name);
453
467
  let config;
454
468
  try {
@@ -464,6 +478,14 @@ export async function runGroupImpact(deps, params) {
464
478
  return { error: resolved.error };
465
479
  const impactParams = {
466
480
  target,
481
+ // Selector params pass through to the member repo's impact (the port
482
+ // contract in service.ts documents them), so the single-repo tool's
483
+ // "re-call with target_uid to disambiguate" loop works unchanged in
484
+ // group mode. `undefined` keeps the call shape flat — same convention
485
+ // as the relationTypes line below.
486
+ target_uid,
487
+ file_path,
488
+ kind,
467
489
  direction,
468
490
  maxDepth,
469
491
  relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined,
@@ -1,3 +1,40 @@
1
- import type { CrossLink, StoredContract } from './types.js';
1
+ import type { CrossLink, CrossLinkEndpoint, StoredContract } from './types.js';
2
+ /**
3
+ * True when a link endpoint carries no resolved graph symbol — empty
4
+ * `symbolUid` or a missing/empty `symbolRef`.
5
+ *
6
+ * Sync marks a cross-link `degraded: true` when this holds for the PROVIDER
7
+ * endpoint (`to`): the contract boundary is proven, but the empty uid can
8
+ * never match a Phase-1 impact symbol id, so cross-repo fan-out across the
9
+ * link silently yields nothing (the classic case is a provider whose handler
10
+ * failed to resolve, leaving `symbolName` degraded to the file name with one
11
+ * pseudo-symbol carrying every route in that file). Consumer-side (`from`)
12
+ * emptiness is deliberately NOT degraded — several extractors (topics, grpc)
13
+ * legitimately emit consumer contracts without a per-call symbol, and the
14
+ * anchor that matters for far-side fan-out is the provider's.
15
+ *
16
+ * Kept next to the endpoint merge logic because `dedupeCrossLinks` must
17
+ * re-derive the flag after a merge: `mergeEndpoints` backfills `symbolUid`
18
+ * from the losing twin, which can invalidate a flag carried in from the winner.
19
+ *
20
+ * NOT unresolved: a deterministic `manifest::<repo>::<contractId>` synthetic
21
+ * uid (see `manifestSymbolUid`). Manifest endpoints fall back to it precisely
22
+ * when the graph has no symbol for them — its empty `symbolRef.filePath` would
23
+ * otherwise trip the check below — yet cross-impact anchors those links by
24
+ * design (#2722: the crossing is preserved with `fanout_status:
25
+ * 'not_attempted'` instead of silently yielding cross=0). The prefix is the
26
+ * canonical discriminator — real indexer uids never start with `manifest::`
27
+ * — and `cross-impact.ts` branches on the same test. Encoding the exemption
28
+ * HERE (not at the sync marking call site) keeps marking and the post-merge
29
+ * re-derivation from drifting apart, and keeps the flag's meaning exactly what
30
+ * `types.ts` documents: "distinct from manifest::… synthetic UIDs".
31
+ */
32
+ export declare function isUnresolvedEndpoint(endpoint: CrossLinkEndpoint): boolean;
33
+ /**
34
+ * Derive `degraded` from the provider endpoint. Present (`true`) only when
35
+ * unresolved; deleted otherwise so contracts.json stays "carried only when
36
+ * meaningful" (`'degraded' in link === false` for anchored links).
37
+ */
38
+ export declare function applyDegradedFlag(link: CrossLink): CrossLink;
2
39
  export declare function dedupeContracts(items: StoredContract[]): StoredContract[];
3
40
  export declare function dedupeCrossLinks(items: CrossLink[]): CrossLink[];
@@ -83,6 +83,59 @@ function crossLinkKey(link) {
83
83
  endpointKey(link.to),
84
84
  ].join('\0');
85
85
  }
86
+ /**
87
+ * True when a link endpoint carries no resolved graph symbol — empty
88
+ * `symbolUid` or a missing/empty `symbolRef`.
89
+ *
90
+ * Sync marks a cross-link `degraded: true` when this holds for the PROVIDER
91
+ * endpoint (`to`): the contract boundary is proven, but the empty uid can
92
+ * never match a Phase-1 impact symbol id, so cross-repo fan-out across the
93
+ * link silently yields nothing (the classic case is a provider whose handler
94
+ * failed to resolve, leaving `symbolName` degraded to the file name with one
95
+ * pseudo-symbol carrying every route in that file). Consumer-side (`from`)
96
+ * emptiness is deliberately NOT degraded — several extractors (topics, grpc)
97
+ * legitimately emit consumer contracts without a per-call symbol, and the
98
+ * anchor that matters for far-side fan-out is the provider's.
99
+ *
100
+ * Kept next to the endpoint merge logic because `dedupeCrossLinks` must
101
+ * re-derive the flag after a merge: `mergeEndpoints` backfills `symbolUid`
102
+ * from the losing twin, which can invalidate a flag carried in from the winner.
103
+ *
104
+ * NOT unresolved: a deterministic `manifest::<repo>::<contractId>` synthetic
105
+ * uid (see `manifestSymbolUid`). Manifest endpoints fall back to it precisely
106
+ * when the graph has no symbol for them — its empty `symbolRef.filePath` would
107
+ * otherwise trip the check below — yet cross-impact anchors those links by
108
+ * design (#2722: the crossing is preserved with `fanout_status:
109
+ * 'not_attempted'` instead of silently yielding cross=0). The prefix is the
110
+ * canonical discriminator — real indexer uids never start with `manifest::`
111
+ * — and `cross-impact.ts` branches on the same test. Encoding the exemption
112
+ * HERE (not at the sync marking call site) keeps marking and the post-merge
113
+ * re-derivation from drifting apart, and keeps the flag's meaning exactly what
114
+ * `types.ts` documents: "distinct from manifest::… synthetic UIDs".
115
+ */
116
+ export function isUnresolvedEndpoint(endpoint) {
117
+ if (endpoint.symbolUid.startsWith('manifest::'))
118
+ return false;
119
+ return (!endpoint.symbolUid ||
120
+ !endpoint.symbolRef ||
121
+ !endpoint.symbolRef.filePath ||
122
+ !endpoint.symbolRef.name);
123
+ }
124
+ /**
125
+ * Derive `degraded` from the provider endpoint. Present (`true`) only when
126
+ * unresolved; deleted otherwise so contracts.json stays "carried only when
127
+ * meaningful" (`'degraded' in link === false` for anchored links).
128
+ */
129
+ export function applyDegradedFlag(link) {
130
+ const next = { ...link };
131
+ if (isUnresolvedEndpoint(next.to)) {
132
+ next.degraded = true;
133
+ }
134
+ else {
135
+ delete next.degraded;
136
+ }
137
+ return next;
138
+ }
86
139
  export function dedupeContracts(items) {
87
140
  const deduped = new Map();
88
141
  for (const contract of items) {
@@ -104,12 +157,15 @@ export function dedupeCrossLinks(items) {
104
157
  const keepIncoming = link.confidence > existing.confidence;
105
158
  const primary = keepIncoming ? link : existing;
106
159
  const secondary = keepIncoming ? existing : link;
107
- deduped.set(key, {
160
+ const merged = {
108
161
  ...primary,
109
162
  confidence: Math.max(existing.confidence, link.confidence),
110
163
  from: mergeEndpoints(primary.from, secondary.from),
111
164
  to: mergeEndpoints(primary.to, secondary.to),
112
- });
165
+ };
166
+ // Re-derive after mergeEndpoints: a richer twin can backfill `to.symbolUid`
167
+ // and must not leave a stale `degraded` flag on an now-anchored link.
168
+ deduped.set(key, applyDegradedFlag(merged));
113
169
  }
114
- return [...deduped.values()];
170
+ return [...deduped.values()].map(applyDegradedFlag);
115
171
  }
@@ -15,6 +15,17 @@ export interface GroupToolPort {
15
15
  resolveRepo(repoParam?: string): Promise<GroupRepoHandle>;
16
16
  impact(repo: GroupRepoHandle, params: {
17
17
  target: string;
18
+ /**
19
+ * Target-selector params, same semantics as the single-repo `impact`
20
+ * tool: `target_uid` is the zero-ambiguity lookup (it wins over the
21
+ * name), `file_path`/`kind` narrow a name shared by several symbols
22
+ * (e.g. same-named Api/Impl/Controller layers). The port implementation
23
+ * consumes them directly; the Phase-1 caller in cross-impact.ts is
24
+ * responsible for threading them from the MCP `impact` args.
25
+ */
26
+ target_uid?: string;
27
+ file_path?: string;
28
+ kind?: string;
18
29
  direction: 'upstream' | 'downstream';
19
30
  maxDepth?: number;
20
31
  relationTypes?: string[];
@@ -341,6 +341,14 @@ export class GroupService {
341
341
  // can otherwise see contract counts that disagree with this payload, with
342
342
  // nothing here explaining why the write was skipped.
343
343
  registryOutcome: result.registryOutcome,
344
+ // Data-quality signals surfaced from the sync run: links whose provider
345
+ // endpoint never resolved to a graph symbol, per-repo extraction
346
+ // failures with reasons, and operator warnings (e.g. bridge.lbug write
347
+ // failed after contracts.json was written). Always present so MCP
348
+ // consumers can branch on them without existence checks.
349
+ degradedLinks: result.degradedLinks,
350
+ failedRepos: result.failedRepos,
351
+ warnings: result.warnings,
344
352
  };
345
353
  }
346
354
  async groupContracts(params) {
@@ -47,6 +47,27 @@ export interface SyncResult {
47
47
  * none of that repo's contracts are in `contracts`.
48
48
  */
49
49
  unreadableRepos: string[];
50
+ /**
51
+ * Cross-links whose provider endpoint has no resolved graph symbol
52
+ * (`degraded: true` on the link — see `isUnresolvedEndpoint`). The boundary
53
+ * is proven but cross-impact fan-out cannot anchor it; the usual remedy is
54
+ * re-analyzing the provider repo so its handlers resolve.
55
+ */
56
+ degradedLinks: number;
57
+ /**
58
+ * Repos whose per-repo extraction threw (init, an extractor, or the
59
+ * snapshot read). Each still lands in `unreadableRepos` (group path) —
60
+ * unchanged downstream semantics — but carries its failure reason here: the
61
+ * catch used to swallow the exception, leaving contracts already pushed by
62
+ * earlier extractors in this iteration as silent half-repo data. `repo` is
63
+ * that same group path (e.g. `app/backend`), not the registry display name.
64
+ */
65
+ failedRepos: Array<{
66
+ repo: string;
67
+ reason: string;
68
+ }>;
69
+ /** Operator-facing run warnings (e.g. bridge.lbug write failed after contracts.json was written). */
70
+ warnings: string[];
50
71
  repoSnapshots: Record<string, RepoSnapshot>;
51
72
  /**
52
73
  * Matching stages this run was asked to skip. Populated on EVERY outcome,
@@ -13,6 +13,7 @@ import { ManifestExtractor } from './extractors/manifest-extractor.js';
13
13
  import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
14
14
  import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
15
15
  import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
16
+ import { applyDegradedFlag } from './normalization.js';
16
17
  import { getContractRegistryPath, readContractRegistry, writeContractRegistry } from './storage.js';
17
18
  import { markBridgeProvenanceUnknown, refreshPreservedBridgeMeta, writeBridgeUnlocked, } from './bridge-db.js';
18
19
  import { withGroupSyncLock } from './group-lock.js';
@@ -127,6 +128,8 @@ export function partitionManifestWindows(links, knownRepos, maxResident) {
127
128
  }
128
129
  export async function syncGroup(config, opts) {
129
130
  const missingRepos = [];
131
+ const failedRepos = [];
132
+ const warnings = [];
130
133
  // Repos that ARE registered but that we could not extract from — the index
131
134
  // would not open, or an extractor threw partway and the repo's staged
132
135
  // contracts were dropped. Kept separate from `missingRepos` because the two
@@ -309,6 +312,10 @@ export async function syncGroup(config, opts) {
309
312
  // stack before logging it defeats the point.
310
313
  logger.warn({ err, repo: regName, groupPath, lbugPath }, "⚠️ Could not read this repo's index; its contracts are omitted from this sync.");
311
314
  unreadableRepos.push(groupPath);
315
+ failedRepos.push({
316
+ repo: groupPath,
317
+ reason: err instanceof Error ? err.message : String(err),
318
+ });
312
319
  // Forget the handle recorded above (present only if the failure came
313
320
  // after initLbug). Deferred manifest resolution derives its known-repo
314
321
  // set from this map, so leaving the entry here re-opens a repo this
@@ -459,7 +466,7 @@ export async function syncGroup(config, opts) {
459
466
  // manifest-declared link can also emit a matchType:'exact' CrossLink with the
460
467
  // same endpoints. Prefer the manifest version — it reflects operator intent
461
468
  // and carries matchType:'manifest' which downstream consumers may rely on.
462
- const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]);
469
+ const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]).map(applyDegradedFlag);
463
470
  const allContracts = autoContracts;
464
471
  const registry = {
465
472
  version: 1,
@@ -671,10 +678,12 @@ export async function syncGroup(config, opts) {
671
678
  'a lower bound rather than as complete.'
672
679
  : 'Its metadata could NOT be marked provenance-unknown, so those answers may still ' +
673
680
  'report as complete despite describing an older sync.';
674
- logger.warn({ err: msg, groupDir, bridgeProvenanceWithdrawn: withdrawn }, '⚠️ writeBridge failed; contracts.json is intact and is the canonical copy, ' +
681
+ const writeBridgeWarn = '⚠️ writeBridge failed; contracts.json is intact and is the canonical copy, ' +
675
682
  'but bridge.lbug was not replaced: cross-repo queries may still answer from ' +
676
683
  `the previous sync's contracts. ${provenanceNote} ` +
677
- 'Re-run `gitnexus group sync` to retry.');
684
+ 'Re-run `gitnexus group sync` to retry.';
685
+ logger.warn({ err: msg, groupDir, bridgeProvenanceWithdrawn: withdrawn }, writeBridgeWarn);
686
+ warnings.push(writeBridgeWarn);
678
687
  }
679
688
  }
680
689
  });
@@ -686,6 +695,9 @@ export async function syncGroup(config, opts) {
686
695
  unmatched: wildcard.remaining,
687
696
  missingRepos,
688
697
  unreadableRepos,
698
+ failedRepos,
699
+ warnings,
700
+ degradedLinks: crossLinks.filter((l) => l.degraded === true).length,
689
701
  repoSnapshots,
690
702
  registryOutcome,
691
703
  };
@@ -80,6 +80,19 @@ export interface CrossLink {
80
80
  contractId: string;
81
81
  matchType: MatchType;
82
82
  confidence: number;
83
+ /**
84
+ * `true` when the PROVIDER endpoint (`to`) has no resolved graph symbol —
85
+ * empty `symbolUid` / `symbolRef` at sync time (e.g. the handler failed to
86
+ * resolve and `symbolName` degraded to the file name). The contract boundary
87
+ * is still proven, but the link cannot anchor a cross-impact fan-out: an
88
+ * empty provider uid never matches a Phase-1 symbol id, and a downstream
89
+ * fan-out into it has no neighbor symbol to resolve. Derived once at the
90
+ * sync persistence boundary (`isUnresolvedEndpoint` in normalization.ts) and
91
+ * re-derived by `dedupeCrossLinks` when a merge backfills the uid. Absent on
92
+ * fully-anchored links. Distinct from manifest `manifest::…` synthetic UIDs,
93
+ * which have their own `fanout_status: 'not_attempted'` channel downstream.
94
+ */
95
+ degraded?: boolean;
83
96
  }
84
97
  export interface RepoSnapshot {
85
98
  indexedAt: string;
@@ -6783,6 +6783,17 @@ export class LocalBackend {
6783
6783
  target: params.target,
6784
6784
  direction: params.direction,
6785
6785
  };
6786
+ // Forward the target-selector params like the trace branch above.
6787
+ // @group impact used to drop target_uid/file_path/kind here, so a name
6788
+ // shared by same-named Api/Impl/Controller layers resolved ambiguously
6789
+ // in the member repo and the documented "re-call with target_uid"
6790
+ // disambiguation loop (impact tool schema) never worked in group mode.
6791
+ if (typeof params.target_uid === 'string')
6792
+ impactArgs.target_uid = params.target_uid;
6793
+ if (typeof params.file_path === 'string')
6794
+ impactArgs.file_path = params.file_path;
6795
+ if (typeof params.kind === 'string')
6796
+ impactArgs.kind = params.kind;
6786
6797
  if (params.maxDepth !== undefined)
6787
6798
  impactArgs.maxDepth = params.maxDepth;
6788
6799
  if (params.crossDepth !== undefined)
package/dist/mcp/tools.js CHANGED
@@ -804,7 +804,7 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single
804
804
 
805
805
  WHEN TO USE: After changing group.yaml or re-indexing member repos.
806
806
 
807
- READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either. \`suppressedMatchStages\` names matching stages this sync was ASKED to skip, with the same three states as the repo lists: ABSENT means a registry written before the field existed, \`[]\` means this sync suppressed nothing, and a populated list means the cross-link set is a lower bound BY REQUEST — a later group_impact / group_contracts on it reports truncationReason 'suppressed-stage'.\n\nPARAMETERS ARE VALIDATED: \`exactOnly\` must be a real boolean — the string "false" is rejected, not coerced to true. The retired \`skipEmbeddings\` and \`allowStale\` parameters are refused by name; drop them from the call.`,
807
+ READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`degradedLinks\` is the count of persisted cross-links whose provider endpoint has no resolved graph symbol (\`degraded: true\`); re-analyze the provider so handlers resolve. \`failedRepos\` is \`{ repo, reason }[]\` for per-repo extraction throws — each also appears in \`unreadableRepos\`; \`repo\` is that group path (e.g. app/backend), not the registry display name. \`warnings\` are operator-facing run notes (e.g. bridge.lbug write failed after contracts.json was written); \`[]\` means none this run. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either. \`suppressedMatchStages\` names matching stages this sync was ASKED to skip, with the same three states as the repo lists: ABSENT means a registry written before the field existed, \`[]\` means this sync suppressed nothing, and a populated list means the cross-link set is a lower bound BY REQUEST — a later group_impact / group_contracts on it reports truncationReason 'suppressed-stage'.\n\nPARAMETERS ARE VALIDATED: \`exactOnly\` must be a real boolean — the string "false" is rejected, not coerced to true. The retired \`skipEmbeddings\` and \`allowStale\` parameters are refused by name; drop them from the call.`,
808
808
  // Usually writes contracts.json, so conservatively non-idempotent even
809
809
  // though output is deterministic for identical input. When no configured
810
810
  // repo could be read it still rewrites the file, keeping the previous
@@ -27,7 +27,9 @@ import { mountSSEProgress } from './sse-progress.js';
27
27
  import { resolveEmbedRunOutcome, withMeasuredEmbeddingCount, } from './embed-run-outcome.js';
28
28
  import { decideEmbeddingResume, mintInterruptedCheckpoint } from '../core/embedding-checkpoint.js';
29
29
  import { measurePersistedEmbeddingCount, persistedEmbeddingCountOrUndefined, } from '../core/embedding-count.js';
30
- import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
30
+ import { assertString, BadRequestError, createRouteLimiter } from './validation.js';
31
+ import { parseGrepQuery, GREP_TIME_BUDGET_MS } from './grep-params.js';
32
+ import { runGrepScanInWorker } from './grep-scan.js';
31
33
  import { extractRepoName, getCloneDir, cloneOrPull, warnIfInsecureAzureConfig, GITHUB_TOKEN_HOSTS, } from './git-clone.js';
32
34
  import { createAnalyzeUploadHandler } from './analyze-upload.js';
33
35
  import { assertServeAuthForPublicOrigin, createPublicOriginMatcher, createWriteOriginGuard, logOriginPolicy, PUBLIC_ORIGIN_ENV, resolveTrustProxy, TRUST_PROXY_ENV, warnIfRateLimitKeysCollapse, } from './middleware.js';
@@ -1129,75 +1131,29 @@ export const createServer = async (port, host = '127.0.0.1') => {
1129
1131
  res.status(404).json({ error: 'Repository not found' });
1130
1132
  return;
1131
1133
  }
1132
- // Type-confusion guard (CodeQL js/type-confusion-through-parameter-tampering):
1133
- // req.query.pattern is `string | string[] | ParsedQs` — without an explicit
1134
- // type check, the `.length` guard below counts array elements instead of
1135
- // characters, allowing arbitrarily long patterns through.
1136
- const rawPattern = req.query.pattern;
1137
- if (rawPattern === undefined) {
1138
- res.status(400).json({ error: 'Missing "pattern" query parameter' });
1139
- return;
1140
- }
1141
- const pattern = assertString(rawPattern, 'pattern');
1142
- if (pattern.length === 0) {
1143
- res.status(400).json({ error: 'Missing "pattern" query parameter' });
1144
- return;
1145
- }
1146
- // Length cap: applies to both literal and regex modes as a defense-in-depth
1147
- // bound against pathological input.
1148
- if (pattern.length > 200) {
1149
- res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
1150
- return;
1151
- }
1152
- // Treat user input as a literal substring in all cases to prevent
1153
- // regex-injection/ReDoS via attacker-controlled regex syntax.
1154
- const effectivePattern = escapeRegExp(pattern);
1155
- // Validate regex syntax (catches both opt-in user regex and any escapeRegExp bug)
1156
- let regex;
1157
- try {
1158
- regex = new RegExp(effectivePattern, 'gim');
1159
- }
1160
- catch {
1161
- res.status(400).json({ error: 'Invalid regex pattern' });
1162
- return;
1163
- }
1164
- const parsedLimit = Number(req.query.limit ?? 50);
1165
- const limit = Number.isFinite(parsedLimit)
1166
- ? Math.max(1, Math.min(200, Math.trunc(parsedLimit)))
1167
- : 50;
1168
- const results = [];
1134
+ // Pattern parsing lives in grep-params.ts (unit-testable without
1135
+ // Express + LadybugDB). Matching runs in a worker so terminate() can
1136
+ // cut a stuck regex.test() when the wall-clock budget expires.
1137
+ const { regex, fileFilter, limit } = parseGrepQuery(req.query);
1169
1138
  const repoRoot = path.resolve(entry.path);
1170
- // Get file paths from the graph (lightweight — no content loaded)
1171
1139
  const lbugPath = path.join(entry.storagePath, 'lbug');
1172
1140
  const fileRows = await withLbugDb(lbugPath, () => executeQuery(`MATCH (n:File) WHERE n.content IS NOT NULL RETURN n.filePath AS filePath`), { readOnly: true });
1173
- // Search files on disk one at a time (constant memory)
1141
+ const filePaths = [];
1174
1142
  for (const row of fileRows) {
1175
- if (results.length >= limit)
1176
- break;
1177
1143
  const filePath = row.filePath || '';
1178
- const fullPath = path.resolve(repoRoot, filePath);
1179
- // Path traversal guard
1180
- const safeRepoRoot = repoRoot.endsWith(path.sep) ? repoRoot : repoRoot + path.sep;
1181
- if (!fullPath.startsWith(safeRepoRoot) && fullPath !== repoRoot)
1144
+ if (fileFilter && !filePath.toLowerCase().includes(fileFilter))
1182
1145
  continue;
1183
- let content;
1184
- try {
1185
- content = await fs.readFile(fullPath, 'utf-8');
1186
- }
1187
- catch {
1188
- continue; // File may have been deleted since indexing
1189
- }
1190
- const lines = content.split('\n');
1191
- for (let i = 0; i < lines.length; i++) {
1192
- if (results.length >= limit)
1193
- break;
1194
- if (regex.test(lines[i])) {
1195
- results.push({ filePath, line: i + 1, text: lines[i].trim().slice(0, 200) });
1196
- }
1197
- regex.lastIndex = 0;
1198
- }
1146
+ filePaths.push(filePath);
1199
1147
  }
1200
- res.json({ results });
1148
+ const { results, timedOut } = await runGrepScanInWorker({
1149
+ repoRoot,
1150
+ filePaths,
1151
+ pattern: regex.source,
1152
+ flags: regex.flags,
1153
+ limit,
1154
+ deadlineMs: Date.now() + GREP_TIME_BUDGET_MS,
1155
+ });
1156
+ res.json({ results, ...(timedOut ? { timedOut: true } : {}) });
1201
1157
  }
1202
1158
  catch (err) {
1203
1159
  res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
@@ -0,0 +1,18 @@
1
+ /** Hard cap on pattern length — unchanged from the literal-only era. */
2
+ export declare const GREP_PATTERN_MAX_LENGTH = 200;
3
+ /** Wall-clock budget for one /api/grep call; parent terminate()s the scan worker. */
4
+ export declare const GREP_TIME_BUDGET_MS = 5000;
5
+ export declare const GREP_DEFAULT_LIMIT = 50;
6
+ export declare const GREP_MAX_LIMIT = 200;
7
+ export interface ParsedGrepQuery {
8
+ regex: RegExp;
9
+ /** Lowercased path substring; '' disables path filtering. */
10
+ fileFilter: string;
11
+ limit: number;
12
+ }
13
+ /**
14
+ * Parse /api/grep query parameters into a ready-to-use regex + filters.
15
+ * Throws BadRequestError (mapped to HTTP 400 by statusFromError) on
16
+ * missing/over-long patterns or invalid regex syntax.
17
+ */
18
+ export declare function parseGrepQuery(query: Record<string, unknown>): ParsedGrepQuery;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Query-parameter parsing for GET /api/grep.
3
+ *
4
+ * Extracted from api.ts so the contract is unit-testable without pulling
5
+ * Express + the LadybugDB native adapter into the test run (same rationale
6
+ * as the #2790 helper extraction).
7
+ *
8
+ * Contract fix (Patch 12): the grep tool schema in gitnexus-web has always
9
+ * promised regex search with an optional path-substring filter and
10
+ * case-sensitivity control, but the handler used to escapeRegExp() every
11
+ * pattern into a literal substring — an agent asking for "sign|Sign" got
12
+ * zero hits and concluded the code didn't exist, and the schema's own
13
+ * example ("console\.log") could never match. This restores the promised
14
+ * semantics. Matching runs in a worker_threads worker (`grep-worker.ts`) so
15
+ * `terminate()` can interrupt a catastrophic `regex.test()` when the
16
+ * wall-clock budget expires; the parent event loop stays responsive.
17
+ * Bounded mitigations:
18
+ * - pattern length cap (200 chars, unchanged from the literal-only era)
19
+ * - line-by-line matching (each regex.test call sees one source line)
20
+ * - a wall-clock budget the parent enforces via worker terminate()
21
+ * - result cap unchanged (limit, max 200)
22
+ * - literal=1 opt-out restores the old escaped-substring immunity
23
+ */
24
+ import { assertString, escapeRegExp, BadRequestError } from './validation.js';
25
+ /** Hard cap on pattern length — unchanged from the literal-only era. */
26
+ export const GREP_PATTERN_MAX_LENGTH = 200;
27
+ /** Wall-clock budget for one /api/grep call; parent terminate()s the scan worker. */
28
+ export const GREP_TIME_BUDGET_MS = 5_000;
29
+ export const GREP_DEFAULT_LIMIT = 50;
30
+ export const GREP_MAX_LIMIT = 200;
31
+ const isFlagTrue = (value, name) => {
32
+ const s = assertString(value ?? '', name).toLowerCase();
33
+ return s === '1' || s === 'true';
34
+ };
35
+ /**
36
+ * Parse /api/grep query parameters into a ready-to-use regex + filters.
37
+ * Throws BadRequestError (mapped to HTTP 400 by statusFromError) on
38
+ * missing/over-long patterns or invalid regex syntax.
39
+ */
40
+ export function parseGrepQuery(query) {
41
+ if (query.pattern === undefined) {
42
+ throw new BadRequestError('Missing "pattern" query parameter');
43
+ }
44
+ const pattern = assertString(query.pattern, 'pattern');
45
+ if (pattern.length === 0) {
46
+ throw new BadRequestError('Missing "pattern" query parameter');
47
+ }
48
+ if (pattern.length > GREP_PATTERN_MAX_LENGTH) {
49
+ throw new BadRequestError(`Pattern too long (max ${GREP_PATTERN_MAX_LENGTH} characters)`);
50
+ }
51
+ // Regex semantics by default — what the tool schema always promised.
52
+ // literal=1 opts back into the escaped-substring behaviour of the
53
+ // literal-only era for callers that want it verbatim.
54
+ const caseSensitive = isFlagTrue(query.caseSensitive, 'caseSensitive');
55
+ const flags = caseSensitive ? '' : 'i';
56
+ let regex;
57
+ try {
58
+ // Deliberately no 'g' flag: the handler tests line-by-line and a
59
+ // stateful lastIndex across lines would skip matches (the old handler
60
+ // had to reset it manually). No 'm' either: each test receives a
61
+ // single line, so ^/$ already anchor at string boundaries — 'm'
62
+ // would be a no-op.
63
+ if (isFlagTrue(query.literal, 'literal')) {
64
+ regex = new RegExp(escapeRegExp(pattern), flags);
65
+ }
66
+ else {
67
+ // Intentional: /api/grep advertises real regex (see file header + SECURITY.md).
68
+ // ReDoS is mitigated by running the scan in a worker and terminate()-ing it.
69
+ // codeql[js/regex-injection]
70
+ regex = new RegExp(pattern, flags);
71
+ }
72
+ }
73
+ catch {
74
+ throw new BadRequestError('Invalid regex pattern');
75
+ }
76
+ // Path-substring filter, case-insensitive ("Controller.java", "src/api").
77
+ const fileFilter = assertString(query.fileFilter ?? '', 'fileFilter').toLowerCase();
78
+ const parsedLimit = Number(query.limit ?? GREP_DEFAULT_LIMIT);
79
+ const limit = Number.isFinite(parsedLimit)
80
+ ? Math.max(1, Math.min(GREP_MAX_LIMIT, Math.trunc(parsedLimit)))
81
+ : GREP_DEFAULT_LIMIT;
82
+ return { regex, fileFilter, limit };
83
+ }
@@ -0,0 +1,23 @@
1
+ export interface GrepHit {
2
+ filePath: string;
3
+ line: number;
4
+ text: string;
5
+ }
6
+ export interface GrepScanInput {
7
+ repoRoot: string;
8
+ /** Repo-relative paths already filtered by fileFilter. */
9
+ filePaths: string[];
10
+ pattern: string;
11
+ flags: string;
12
+ limit: number;
13
+ /** Absolute Date.now() deadline. */
14
+ deadlineMs: number;
15
+ }
16
+ export interface GrepScanResult {
17
+ results: GrepHit[];
18
+ timedOut: boolean;
19
+ }
20
+ export type GrepProgress = (partial: GrepScanResult) => void;
21
+ export declare function scanGrepFiles(input: GrepScanInput, onProgress?: GrepProgress): Promise<GrepScanResult>;
22
+ export declare function grepWorkerPath(): string;
23
+ export declare function runGrepScanInWorker(input: GrepScanInput): Promise<GrepScanResult>;