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.
- package/dist/core/group/cross-impact.d.ts +3 -0
- package/dist/core/group/cross-impact.js +26 -4
- package/dist/core/group/normalization.d.ts +38 -1
- package/dist/core/group/normalization.js +59 -3
- package/dist/core/group/service.d.ts +11 -0
- package/dist/core/group/service.js +8 -0
- package/dist/core/group/sync.d.ts +21 -0
- package/dist/core/group/sync.js +15 -3
- package/dist/core/group/types.d.ts +13 -0
- package/dist/mcp/local/local-backend.js +11 -0
- package/dist/mcp/tools.js +1 -1
- package/dist/server/api.js +19 -63
- package/dist/server/grep-params.d.ts +18 -0
- package/dist/server/grep-params.js +83 -0
- package/dist/server/grep-scan.d.ts +23 -0
- package/dist/server/grep-scan.js +107 -0
- package/dist/server/grep-worker.d.ts +1 -0
- package/dist/server/grep-worker.js +12 -0
- package/package.json +1 -1
- package/web/assets/{agent-BWAXPjC5.js → agent-CFqT4hjR.js} +5 -3
- package/web/assets/{index-DDRie6_8.js → index-BMIniRtX.js} +3 -3
- package/web/index.html +1 -1
|
@@ -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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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,
|
package/dist/core/group/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/server/api.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
|
|
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
|
-
|
|
1141
|
+
const filePaths = [];
|
|
1174
1142
|
for (const row of fileRows) {
|
|
1175
|
-
if (results.length >= limit)
|
|
1176
|
-
break;
|
|
1177
1143
|
const filePath = row.filePath || '';
|
|
1178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|