brainclaw 1.23.0 → 1.25.0
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/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-cloud.js +121 -13
- package/dist/cli/register-code-map.js +9 -2
- package/dist/commands/cloud.js +534 -39
- package/dist/commands/code-map.js +119 -2
- package/dist/commands/mcp-catalog.js +46 -0
- package/dist/commands/mcp.js +54 -2
- package/dist/core/code-map/backend.js +158 -1
- package/dist/core/code-map/export.js +212 -0
- package/dist/core/code-map/freshness.js +3 -2
- package/dist/core/code-map/impact.js +377 -0
- package/dist/core/code-map/indexes.js +27 -3
- package/dist/core/code-map/lang/typescript/config.js +271 -0
- package/dist/core/code-map/lang/typescript/index.js +20 -4
- package/dist/core/code-map/query.js +76 -13
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +1 -0
- package/dist/core/code-map/types.js +15 -0
- package/dist/core/federation-emit.js +283 -0
- package/dist/core/federation-grant-transport.js +196 -0
- package/dist/core/federation-grant.js +223 -0
- package/dist/core/federation-keyring.js +39 -0
- package/dist/core/federation-opaque-ids.js +111 -0
- package/dist/core/federation-outbox-v2.js +36 -2
- package/dist/core/federation-pairing.js +87 -12
- package/dist/core/federation-pull.js +523 -0
- package/dist/core/federation-push.js +287 -0
- package/dist/core/federation-rotation.js +124 -0
- package/dist/core/federation-state.js +81 -6
- package/dist/core/protocol-tool-policy.js +3 -0
- package/dist/core/worktree.js +89 -2
- package/dist/facts.js +14 -11
- package/dist/facts.json +13 -10
- package/docs/cli.md +8 -0
- package/docs/code-map.md +24 -1
- package/docs/design/federation-onboarding-usecases.md +254 -0
- package/docs/design/pairing-v3-brief.md +80 -0
- package/docs/integrations/mcp.md +5 -2
- package/docs/mcp-schema-changelog.md +11 -1
- package/package.json +1 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `brainclaw code-map <subcommand>` — CLI surface over the Code Map backend
|
|
3
3
|
* (spec §9). Mirrors plan-resource.ts: a switch over the subcommand delegating
|
|
4
|
-
* to a JsonlBackend (status | refresh | find | brief). The backend owns
|
|
4
|
+
* to a JsonlBackend (status | refresh | find | brief | impact | export | outline). The backend owns
|
|
5
5
|
* query logic; this file only adapts it to argv + stdout (text or --json), and
|
|
6
6
|
* every output carries the freshness_badge.
|
|
7
7
|
*/
|
|
8
8
|
import { JsonlBackend } from '../core/code-map/backend.js';
|
|
9
|
-
const KNOWN_SUBCOMMANDS = new Set(['status', 'refresh', 'find', 'brief']);
|
|
9
|
+
const KNOWN_SUBCOMMANDS = new Set(['status', 'refresh', 'find', 'brief', 'impact', 'export', 'outline']);
|
|
10
10
|
function backend() {
|
|
11
11
|
return new JsonlBackend();
|
|
12
12
|
}
|
|
@@ -57,6 +57,49 @@ export async function runCodeMap(subcommand, args, options = {}) {
|
|
|
57
57
|
printBrief(result, options);
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
+
if (normalized === 'impact') {
|
|
61
|
+
const target = args.join(' ').trim();
|
|
62
|
+
if (!target) {
|
|
63
|
+
console.error('Error: code-map impact requires <symbol-or-path>.');
|
|
64
|
+
console.error(' Usage: brainclaw code-map impact <symbol-or-path> [--depth 2]');
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const result = await be.impact({ target, depth: options.depth, limit: options.limit, cwd });
|
|
68
|
+
printImpact(result, options);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (normalized === 'export') {
|
|
72
|
+
const target = args.join(' ').trim();
|
|
73
|
+
if (!target) {
|
|
74
|
+
console.error('Error: code-map export requires <symbol-or-path>.');
|
|
75
|
+
console.error(' Usage: brainclaw code-map export <symbol-or-path> [--direction both] [--depth 1] [--max-nodes 100] [--max-edges 200]');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const result = await be.exportGraph({
|
|
79
|
+
target,
|
|
80
|
+
targetKind: options.targetKind,
|
|
81
|
+
direction: options.direction,
|
|
82
|
+
depth: options.depth,
|
|
83
|
+
maxNodes: options.maxNodes,
|
|
84
|
+
maxEdges: options.maxEdges,
|
|
85
|
+
minConfidence: options.minConfidence,
|
|
86
|
+
format: options.format,
|
|
87
|
+
cwd,
|
|
88
|
+
});
|
|
89
|
+
printExport(result, options);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (normalized === 'outline') {
|
|
93
|
+
const target = args.join(' ').trim();
|
|
94
|
+
if (!target) {
|
|
95
|
+
console.error('Error: code-map outline requires <file>.');
|
|
96
|
+
console.error(' Usage: brainclaw code-map outline <file>');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const result = await be.outline({ path: target, limit: options.limit, cwd });
|
|
100
|
+
printOutline(result, options);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
60
103
|
console.error(`Error: unknown code-map subcommand "${subcommand}".`);
|
|
61
104
|
console.error(` Available: ${[...KNOWN_SUBCOMMANDS].join(', ')}`);
|
|
62
105
|
process.exit(1);
|
|
@@ -144,4 +187,78 @@ function printBrief(result, options) {
|
|
|
144
187
|
}
|
|
145
188
|
}
|
|
146
189
|
}
|
|
190
|
+
function printImpact(result, options) {
|
|
191
|
+
if (options.json) {
|
|
192
|
+
console.log(JSON.stringify(result, null, 2));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const cause = (row) => row.causes.map((item) => `${item.kind}${item.module ? ` ${item.module}` : ''}${item.source_line ? `:${item.source_line}` : ''}`).join(', ');
|
|
196
|
+
console.log(`Code Map impact: "${result.target}"`);
|
|
197
|
+
console.log(` ${badgeLine(result.freshness_badge)}`);
|
|
198
|
+
console.log(` Definition: ${result.definition.entries.length} (${result.definition.match_kind})`);
|
|
199
|
+
for (const entry of result.definition.entries)
|
|
200
|
+
console.log(` ${entry.name} — ${entry.path}`);
|
|
201
|
+
console.log(` Direct dependents: ${result.direct_dependents.length}${result.limits.direct_truncated ? '+' : ''}`);
|
|
202
|
+
for (const dependent of result.direct_dependents)
|
|
203
|
+
console.log(` ${dependent.path} — ${cause(dependent)}`);
|
|
204
|
+
if (result.limits.max_depth > 1) {
|
|
205
|
+
console.log(` Transitive dependents: ${result.transitive_dependents.length}${result.limits.transitive_truncated ? '+' : ''}`);
|
|
206
|
+
for (const dependent of result.transitive_dependents)
|
|
207
|
+
console.log(` [depth ${dependent.depth}] ${dependent.path} — ${cause(dependent)}`);
|
|
208
|
+
}
|
|
209
|
+
console.log(` Tests: ${result.risk.counters.resolved_test_files} resolved, ${result.risk.counters.suggested_test_files} naming suggestion(s)`);
|
|
210
|
+
for (const test of result.tests_for)
|
|
211
|
+
console.log(` [${test.relation}, confidence=${test.confidence}] ${test.path} — ${test.reason}`);
|
|
212
|
+
console.log(` Risk: ${result.risk.score} (${result.risk.formula}; direct=${result.risk.counters.direct_dependents}, transitive=${result.risk.counters.transitive_dependents})`);
|
|
213
|
+
}
|
|
214
|
+
function printExport(result, options) {
|
|
215
|
+
if (options.json) {
|
|
216
|
+
console.log(JSON.stringify(result, null, 2));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
console.log(`Code Map export: "${result.target}" (${result.target_kind})`);
|
|
220
|
+
console.log(` ${badgeLine(result.freshness_badge)}`);
|
|
221
|
+
console.log(` Roots: ${result.root_node_ids.length}; nodes=${result.nodes.length}/${result.limits.max_nodes}; edges=${result.edges.length}/${result.limits.max_edges}; depth=${result.limits.max_depth}; direction=${result.limits.direction}; min-confidence=${result.limits.min_confidence}`);
|
|
222
|
+
const truncation = Object.entries(result.truncated).filter(([, value]) => value).map(([key]) => key);
|
|
223
|
+
if (truncation.length > 0)
|
|
224
|
+
console.log(` Truncated: ${truncation.join(', ')}`);
|
|
225
|
+
if (result.format === 'mermaid' && result.mermaid) {
|
|
226
|
+
console.log(result.mermaid);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
for (const edge of result.edges) {
|
|
230
|
+
const source = edge.source ? ` @ ${edge.source.path}${edge.source.line === null || edge.source.line === undefined ? '' : `:${edge.source.line}`}` : '';
|
|
231
|
+
console.log(` ${edge.from} -[${edge.kind}, confidence=${edge.confidence}${source}]-> ${edge.to}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function printOutline(result, options) {
|
|
235
|
+
if (options.json) {
|
|
236
|
+
console.log(JSON.stringify(result, null, 2));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
console.log(`Code Map outline: "${result.path}"`);
|
|
240
|
+
console.log(` Index: ${result.index_status}`);
|
|
241
|
+
console.log(` ${badgeLine(result.freshness_badge)}`);
|
|
242
|
+
if (!result.file_indexed) {
|
|
243
|
+
console.log(' Symbols: (file not indexed)');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
console.log(` Parse: ${result.parse_status}`);
|
|
247
|
+
if (result.symbols.length === 0) {
|
|
248
|
+
console.log(' Symbols: (none)');
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
for (const symbol of result.symbols) {
|
|
252
|
+
const subtype = symbol.subtype ? ` ${symbol.subtype}` : '';
|
|
253
|
+
const span = symbol.span ? `${symbol.span.start_line}:${symbol.span.start_col}` : 'unknown';
|
|
254
|
+
const exported = symbol.exported ? ' export' : '';
|
|
255
|
+
console.log(` [${span}] ${symbol.kind}${subtype} ${symbol.name}${exported} confidence=${symbol.confidence}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (result.truncated)
|
|
259
|
+
console.log(` … ${result.symbol_count - result.symbols.length} more indexed symbol(s)`);
|
|
260
|
+
if (result.diagnostics.length > 0) {
|
|
261
|
+
console.log(` Diagnostics: ${result.diagnostics.length}${result.diagnostics_truncated ? '+' : ''}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
147
264
|
//# sourceMappingURL=code-map.js.map
|
|
@@ -409,6 +409,52 @@ export const MCP_READ_TOOLS = [
|
|
|
409
409
|
required: ['target'],
|
|
410
410
|
},
|
|
411
411
|
},
|
|
412
|
+
{
|
|
413
|
+
name: 'bclaw_code_impact',
|
|
414
|
+
description: 'Explain a symbol or source file\'s local blast radius from existing resolved imports: definition, direct dependents with edge causes, optional bounded transitives, affected test files, low-confidence naming suggestions kept separate, and a count-based risk score. Read-only; never refreshes.',
|
|
415
|
+
annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'auto' },
|
|
416
|
+
inputSchema: {
|
|
417
|
+
type: 'object',
|
|
418
|
+
properties: {
|
|
419
|
+
target: { type: 'string', description: 'Symbol name or source-file path whose impact to inspect.' },
|
|
420
|
+
depth: { type: 'number', description: 'Maximum graph depth. 1 (default) returns direct dependents only; 2+ opts into transitives. Clamped to 4.' },
|
|
421
|
+
limit: { type: 'number', description: 'Maximum rows in each dependent section and in naming suggestions. Clamped to 100.' },
|
|
422
|
+
},
|
|
423
|
+
required: ['target'],
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
name: 'bclaw_code_export',
|
|
428
|
+
description: 'Export a compact, deterministic, confidence-filtered local Code Map subgraph around one symbol or file. Requires a target; defaults to one hop and hard caps nodes (100), edges (200), and depth (4), so it never exports a whole graph. JSON retains every edge kind, source, and confidence. format="mermaid" adds a projection of the same JSON nodes/edges. Read-only; never refreshes or calls an external service.',
|
|
429
|
+
annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'auto' },
|
|
430
|
+
inputSchema: {
|
|
431
|
+
type: 'object',
|
|
432
|
+
properties: {
|
|
433
|
+
target: { type: 'string', description: 'Symbol name or source-file path around which to export a local subgraph.' },
|
|
434
|
+
targetKind: { type: 'string', enum: ['symbol', 'file'], description: 'Optional explicit target kind; otherwise a source path is detected safely.' },
|
|
435
|
+
direction: { type: 'string', enum: ['outgoing', 'incoming', 'both'], description: 'Which edge direction(s) to follow. Default: both.' },
|
|
436
|
+
depth: { type: 'number', description: 'Maximum graph distance from the root(s). Default 1; clamped to 0..4.' },
|
|
437
|
+
maxNodes: { type: 'number', description: 'Maximum nodes returned. Default 100; hard-capped at 100.' },
|
|
438
|
+
maxEdges: { type: 'number', description: 'Maximum edges returned. Default 200; hard-capped at 200.' },
|
|
439
|
+
minConfidence: { type: 'number', description: 'Persisted confidence threshold for nodes and relationships. Default/minimum 0.5.' },
|
|
440
|
+
format: { type: 'string', enum: ['json', 'mermaid'], description: 'Default json; mermaid adds a projection derived from the same bounded graph.' },
|
|
441
|
+
},
|
|
442
|
+
required: ['target'],
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: 'bclaw_code_outline',
|
|
447
|
+
description: 'Return a bounded source-ordered outline for one indexed file: symbol name, kind, subtype, span, exported flag, and persisted confidence. Read-only: it reads the existing file shard only, never parses or refreshes. `index_status` distinguishes a missing index, a file not indexed, and an indexed file with zero symbols.',
|
|
448
|
+
annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'auto' },
|
|
449
|
+
inputSchema: {
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: {
|
|
452
|
+
path: { type: 'string', description: 'Workspace-relative source file path (for example `src/app/App.tsx`).' },
|
|
453
|
+
limit: { type: 'number', description: 'Maximum symbols to return; clamped to the hard cap of 200.' },
|
|
454
|
+
},
|
|
455
|
+
required: ['path'],
|
|
456
|
+
},
|
|
457
|
+
},
|
|
412
458
|
];
|
|
413
459
|
const MCP_WRITE_TOOLS = [
|
|
414
460
|
{
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1089,8 +1089,8 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1089
1089
|
}
|
|
1090
1090
|
// Code Map tools (spec §9). These delegate to the async JsonlBackend, so
|
|
1091
1091
|
// they are handled here rather than via the synchronous read-tool path.
|
|
1092
|
-
// status/find/brief are reads; refresh is a write (prompt approval).
|
|
1093
|
-
if (name === 'bclaw_code_status' || name === 'bclaw_code_find' || name === 'bclaw_code_brief' || name === 'bclaw_code_refresh') {
|
|
1092
|
+
// status/find/brief/impact/export/outline are reads; refresh is a write (prompt approval).
|
|
1093
|
+
if (name === 'bclaw_code_status' || name === 'bclaw_code_find' || name === 'bclaw_code_brief' || name === 'bclaw_code_impact' || name === 'bclaw_code_export' || name === 'bclaw_code_outline' || name === 'bclaw_code_refresh') {
|
|
1094
1094
|
const { JsonlBackend } = await import('../core/code-map/backend.js');
|
|
1095
1095
|
const be = new JsonlBackend();
|
|
1096
1096
|
if (name === 'bclaw_code_status') {
|
|
@@ -1127,6 +1127,58 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1127
1127
|
}),
|
|
1128
1128
|
};
|
|
1129
1129
|
}
|
|
1130
|
+
// bclaw_code_export
|
|
1131
|
+
if (name === 'bclaw_code_export') {
|
|
1132
|
+
const target = typeof args.target === 'string' ? args.target : '';
|
|
1133
|
+
if (!target.trim()) {
|
|
1134
|
+
return { response: createToolErrorResponse('validation_error', 'bclaw_code_export requires a non-empty target.') };
|
|
1135
|
+
}
|
|
1136
|
+
const targetKind = args.targetKind === 'symbol' || args.targetKind === 'file' ? args.targetKind : undefined;
|
|
1137
|
+
const direction = args.direction === 'outgoing' || args.direction === 'incoming' || args.direction === 'both' ? args.direction : undefined;
|
|
1138
|
+
const depth = typeof args.depth === 'number' ? args.depth : undefined;
|
|
1139
|
+
const maxNodes = typeof args.maxNodes === 'number' ? args.maxNodes : undefined;
|
|
1140
|
+
const maxEdges = typeof args.maxEdges === 'number' ? args.maxEdges : undefined;
|
|
1141
|
+
const minConfidence = typeof args.minConfidence === 'number' ? args.minConfidence : undefined;
|
|
1142
|
+
const format = args.format === 'mermaid' ? 'mermaid' : args.format === 'json' ? 'json' : undefined;
|
|
1143
|
+
const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd });
|
|
1144
|
+
return {
|
|
1145
|
+
response: toolResponse({
|
|
1146
|
+
content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.status}` }],
|
|
1147
|
+
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1148
|
+
}),
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
// bclaw_code_impact
|
|
1152
|
+
if (name === 'bclaw_code_impact') {
|
|
1153
|
+
const target = typeof args.target === 'string' ? args.target : '';
|
|
1154
|
+
if (!target.trim()) {
|
|
1155
|
+
return { response: createToolErrorResponse('validation_error', 'bclaw_code_impact requires a non-empty target.') };
|
|
1156
|
+
}
|
|
1157
|
+
const depth = typeof args.depth === 'number' ? args.depth : undefined;
|
|
1158
|
+
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
1159
|
+
const result = await be.impact({ target, depth, limit, cwd });
|
|
1160
|
+
return {
|
|
1161
|
+
response: toolResponse({
|
|
1162
|
+
content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.status}` }],
|
|
1163
|
+
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1164
|
+
}),
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
// bclaw_code_outline
|
|
1168
|
+
if (name === 'bclaw_code_outline') {
|
|
1169
|
+
const outlinePath = typeof args.path === 'string' ? args.path : '';
|
|
1170
|
+
if (!outlinePath.trim()) {
|
|
1171
|
+
return { response: createToolErrorResponse('validation_error', 'bclaw_code_outline requires a non-empty path.') };
|
|
1172
|
+
}
|
|
1173
|
+
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
1174
|
+
const result = await be.outline({ path: outlinePath, limit, cwd });
|
|
1175
|
+
return {
|
|
1176
|
+
response: toolResponse({
|
|
1177
|
+
content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.status}` }],
|
|
1178
|
+
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1179
|
+
}),
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1130
1182
|
// bclaw_code_brief
|
|
1131
1183
|
const target = typeof args.target === 'string' ? args.target : '';
|
|
1132
1184
|
if (!target.trim()) {
|
|
@@ -10,21 +10,70 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { execFileSync } from 'node:child_process';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
-
import { readManifest, storeExists } from './store.js';
|
|
13
|
+
import { readManifest, readShard, storeExists } from './store.js';
|
|
14
14
|
import { refresh as runRefresh } from './refresh.js';
|
|
15
15
|
import { applyGitHeadDrift, withCoarse } from './freshness.js';
|
|
16
16
|
import { brief as runBrief, find as runFind } from './query.js';
|
|
17
|
+
import { impact as runImpact } from './impact.js';
|
|
18
|
+
import { exportSubgraph } from './export.js';
|
|
19
|
+
import { fileId } from './ids.js';
|
|
17
20
|
import { resolveTraversal, aggregateFind, aggregateBrief } from './aggregate.js';
|
|
18
21
|
import { defaultMemoryReader } from './memory-reader.js';
|
|
19
22
|
import { listNestedProjects, refreshWorkspaceCascade } from './cascade.js';
|
|
20
23
|
import { loadConfig } from '../config.js';
|
|
21
24
|
/** spec §9 caps the brief reading list at 12 files. */
|
|
22
25
|
export const BRIEF_FILE_CAP = 12;
|
|
26
|
+
/**
|
|
27
|
+
* Agent-facing file outline (P2b). The symbol count is deliberately bounded:
|
|
28
|
+
* an outline is a navigation aid, not a replacement for opening a generated
|
|
29
|
+
* source file. `symbol_count` always records the complete indexed count.
|
|
30
|
+
*/
|
|
31
|
+
export const OUTLINE_SYMBOL_CAP = 200;
|
|
32
|
+
/** Diagnostics are useful context, but unbounded provider facts are not. */
|
|
33
|
+
export const OUTLINE_DIAGNOSTIC_CAP = 20;
|
|
23
34
|
function badge(status, details = {}) {
|
|
24
35
|
// pln#601 — stamp the coarse rollup at construction so every backend-built badge
|
|
25
36
|
// (status, missing_index fallbacks, find/brief base) carries it uniformly.
|
|
26
37
|
return withCoarse({ status, details });
|
|
27
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Convert a user path into the POSIX project-relative identity used by shards.
|
|
41
|
+
* This is pure path arithmetic: outline must not stat, parse, or otherwise touch
|
|
42
|
+
* the live source file on its read path.
|
|
43
|
+
*/
|
|
44
|
+
function normalizeOutlinePath(requestedPath, projectRoot) {
|
|
45
|
+
const root = path.resolve(projectRoot);
|
|
46
|
+
const absolute = path.resolve(root, requestedPath);
|
|
47
|
+
const relative = path.relative(root, absolute);
|
|
48
|
+
if (!relative || relative === '.' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return relative.replace(/\\/g, '/');
|
|
52
|
+
}
|
|
53
|
+
function outlineLimit(limit) {
|
|
54
|
+
if (limit === undefined || !Number.isFinite(limit))
|
|
55
|
+
return OUTLINE_SYMBOL_CAP;
|
|
56
|
+
return Math.min(Math.max(Math.floor(limit), 0), OUTLINE_SYMBOL_CAP);
|
|
57
|
+
}
|
|
58
|
+
function compareOutlineSymbols(a, b) {
|
|
59
|
+
// Symbols normally always have spans. Keep malformed/legacy span-less nodes
|
|
60
|
+
// deterministic and at the end instead of trusting shard append order.
|
|
61
|
+
const as = a.span;
|
|
62
|
+
const bs = b.span;
|
|
63
|
+
if (as && bs) {
|
|
64
|
+
return as.start_line - bs.start_line
|
|
65
|
+
|| as.start_col - bs.start_col
|
|
66
|
+
|| as.end_line - bs.end_line
|
|
67
|
+
|| as.end_col - bs.end_col
|
|
68
|
+
|| a.name.localeCompare(b.name)
|
|
69
|
+
|| a.node_id.localeCompare(b.node_id);
|
|
70
|
+
}
|
|
71
|
+
if (as)
|
|
72
|
+
return -1;
|
|
73
|
+
if (bs)
|
|
74
|
+
return 1;
|
|
75
|
+
return a.name.localeCompare(b.name) || a.node_id.localeCompare(b.node_id);
|
|
76
|
+
}
|
|
28
77
|
/**
|
|
29
78
|
* Read the working tree's current commit at `root` (read-path git-HEAD drift,
|
|
30
79
|
* trp_42688015). Returns null on any failure or a non-git project (also detached
|
|
@@ -244,6 +293,114 @@ export class JsonlBackend {
|
|
|
244
293
|
freshness_badge: this.withHeadDrift(base, manifest, input.cwd),
|
|
245
294
|
};
|
|
246
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Explain a target's resolved blast radius. Unlike brief(), this deliberately
|
|
298
|
+
* stays store-local: impact only traverses persisted P1c/P1d edges and reports
|
|
299
|
+
* their concrete causes, with an opt-in bounded transitive walk.
|
|
300
|
+
*/
|
|
301
|
+
async impact(input) {
|
|
302
|
+
const ctx = this.queryContext(input);
|
|
303
|
+
const out = runImpact(input.target, { depth: input.depth, limit: input.limit }, ctx);
|
|
304
|
+
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
305
|
+
const base = badge(out.freshness_badge.status, out.freshness_badge.details);
|
|
306
|
+
return {
|
|
307
|
+
...out,
|
|
308
|
+
freshness_badge: this.withHeadDrift(base, manifest, input.cwd),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Export only a caller-selected, hard-bounded local graph. This stays
|
|
313
|
+
* store-local even at a multi-project root: implicit workspace graph dumps
|
|
314
|
+
* are intentionally unsupported.
|
|
315
|
+
*/
|
|
316
|
+
async exportGraph(input) {
|
|
317
|
+
const ctx = this.queryContext(input);
|
|
318
|
+
const out = exportSubgraph(input.target, input, ctx);
|
|
319
|
+
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
320
|
+
const base = badge(out.freshness_badge.status, out.freshness_badge.details);
|
|
321
|
+
return {
|
|
322
|
+
...out,
|
|
323
|
+
freshness_badge: this.withHeadDrift(base, manifest, input.cwd),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Return an indexed file's symbols in source order. This reads exactly one
|
|
328
|
+
* manifest and one deterministic shard; it never calls refresh, extractor, or
|
|
329
|
+
* the lazy live-file validator, so the response is a snapshot of the index.
|
|
330
|
+
*/
|
|
331
|
+
async outline(input) {
|
|
332
|
+
const cwd = input.cwd ?? process.cwd();
|
|
333
|
+
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
334
|
+
const root = manifest?.project_root ?? cwd;
|
|
335
|
+
const normalizedPath = normalizeOutlinePath(input.path.trim(), root) ?? input.path.trim().replace(/\\/g, '/');
|
|
336
|
+
const missing = () => ({
|
|
337
|
+
path: normalizedPath,
|
|
338
|
+
index_status: 'missing_index',
|
|
339
|
+
file_indexed: false,
|
|
340
|
+
parse_status: null,
|
|
341
|
+
symbol_count: 0,
|
|
342
|
+
symbols: [],
|
|
343
|
+
truncated: false,
|
|
344
|
+
diagnostics: [],
|
|
345
|
+
diagnostics_truncated: false,
|
|
346
|
+
freshness_badge: badge('missing_index', { hint: 'run refresh' }),
|
|
347
|
+
});
|
|
348
|
+
if (!manifest || manifest.freshness.status === 'missing_index')
|
|
349
|
+
return missing();
|
|
350
|
+
const freshnessBadge = this.withHeadDrift(badge(manifest.freshness.status, {
|
|
351
|
+
stale_file_count: manifest.freshness.stale_file_count,
|
|
352
|
+
partial_reason: manifest.freshness.partial_reason,
|
|
353
|
+
}), manifest, input.cwd);
|
|
354
|
+
const safePath = normalizeOutlinePath(input.path.trim(), root);
|
|
355
|
+
if (!safePath) {
|
|
356
|
+
return {
|
|
357
|
+
...missing(),
|
|
358
|
+
index_status: 'file_not_indexed',
|
|
359
|
+
freshness_badge: freshnessBadge,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const shard = readShard(fileId(manifest.project_id, safePath), input.cwd, input.preferredDirName);
|
|
363
|
+
if (!shard || shard.path !== safePath) {
|
|
364
|
+
return {
|
|
365
|
+
path: safePath,
|
|
366
|
+
index_status: 'file_not_indexed',
|
|
367
|
+
file_indexed: false,
|
|
368
|
+
parse_status: null,
|
|
369
|
+
symbol_count: 0,
|
|
370
|
+
symbols: [],
|
|
371
|
+
truncated: false,
|
|
372
|
+
diagnostics: [],
|
|
373
|
+
diagnostics_truncated: false,
|
|
374
|
+
freshness_badge: freshnessBadge,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
const allSymbols = shard.nodes
|
|
378
|
+
.filter((node) => node.kind === 'symbol')
|
|
379
|
+
.map((node) => ({
|
|
380
|
+
node_id: node.id,
|
|
381
|
+
name: node.name,
|
|
382
|
+
kind: node.kind,
|
|
383
|
+
subtype: node.subtype ?? null,
|
|
384
|
+
span: node.span ?? null,
|
|
385
|
+
exported: node.exported,
|
|
386
|
+
confidence: node.confidence,
|
|
387
|
+
}))
|
|
388
|
+
.sort(compareOutlineSymbols);
|
|
389
|
+
const limit = outlineLimit(input.limit);
|
|
390
|
+
const diagnostics = shard.diagnostics.slice(0, OUTLINE_DIAGNOSTIC_CAP);
|
|
391
|
+
return {
|
|
392
|
+
path: safePath,
|
|
393
|
+
index_status: 'indexed',
|
|
394
|
+
file_indexed: true,
|
|
395
|
+
parse_status: shard.parse_status,
|
|
396
|
+
symbol_count: allSymbols.length,
|
|
397
|
+
symbols: allSymbols.slice(0, limit).map(({ node_id: _nodeId, ...symbol }) => symbol),
|
|
398
|
+
truncated: allSymbols.length > limit,
|
|
399
|
+
diagnostics,
|
|
400
|
+
diagnostics_truncated: shard.diagnostics.length > diagnostics.length,
|
|
401
|
+
freshness_badge: freshnessBadge,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
247
404
|
/**
|
|
248
405
|
* Annotate a read badge with git-HEAD drift vs the commit the index was built
|
|
249
406
|
* against (`manifest.git.head`). trp_42688015 — a branch switch (whole-tree move)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded Code Map subgraph export. Mermaid is rendered from this module's JSON
|
|
3
|
+
* model; it never takes a second, potentially different graph traversal.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { withCoarse } from './freshness.js';
|
|
7
|
+
import { listShards, readManifest } from './store.js';
|
|
8
|
+
/** Absolute traversal and response ceilings: a whole-graph export is impossible. */
|
|
9
|
+
export const CODE_EXPORT_MAX_DEPTH = 4;
|
|
10
|
+
export const CODE_EXPORT_NODE_CAP = 100;
|
|
11
|
+
export const CODE_EXPORT_EDGE_CAP = 200;
|
|
12
|
+
/** Low-confidence extracted data is never included by default or opt-out. */
|
|
13
|
+
export const CODE_EXPORT_MIN_CONFIDENCE = 0.5;
|
|
14
|
+
function clampInteger(value, fallback, min, max) {
|
|
15
|
+
if (value === undefined || !Number.isFinite(value))
|
|
16
|
+
return fallback;
|
|
17
|
+
return Math.min(Math.max(Math.floor(value), min), max);
|
|
18
|
+
}
|
|
19
|
+
function clampConfidence(value) {
|
|
20
|
+
if (value === undefined || !Number.isFinite(value))
|
|
21
|
+
return CODE_EXPORT_MIN_CONFIDENCE;
|
|
22
|
+
return Math.min(Math.max(value, CODE_EXPORT_MIN_CONFIDENCE), 1);
|
|
23
|
+
}
|
|
24
|
+
function normalizePath(value) {
|
|
25
|
+
return value.replace(/\\/g, '/');
|
|
26
|
+
}
|
|
27
|
+
function normalizeDirection(value) {
|
|
28
|
+
return value === 'outgoing' || value === 'incoming' || value === 'both' ? value : 'both';
|
|
29
|
+
}
|
|
30
|
+
function normalizeFormat(value) {
|
|
31
|
+
return value === 'mermaid' || value === 'json' ? value : 'json';
|
|
32
|
+
}
|
|
33
|
+
function normalizeTargetKind(value, target) {
|
|
34
|
+
return value === 'symbol' || value === 'file' ? value : inferTargetKind(target);
|
|
35
|
+
}
|
|
36
|
+
function inferTargetKind(target) {
|
|
37
|
+
return /[\\/]/.test(target)
|
|
38
|
+
|| /\.(?:[cm]?[jt]sx?|py|php|java|go|rs|cs|rb|c|cc|cpp|cxx|h|hpp)$/i.test(target)
|
|
39
|
+
? 'file'
|
|
40
|
+
: 'symbol';
|
|
41
|
+
}
|
|
42
|
+
/** No selector may address the project root itself or escape it. */
|
|
43
|
+
function normalizeFileTarget(target, projectRoot) {
|
|
44
|
+
const root = path.resolve(projectRoot);
|
|
45
|
+
const absolute = path.resolve(root, target);
|
|
46
|
+
const relative = path.relative(root, absolute);
|
|
47
|
+
if (!relative || relative === '.' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
48
|
+
return null;
|
|
49
|
+
return normalizePath(relative);
|
|
50
|
+
}
|
|
51
|
+
function compareNodes(a, b) {
|
|
52
|
+
const as = a.span;
|
|
53
|
+
const bs = b.span;
|
|
54
|
+
return a.path.localeCompare(b.path)
|
|
55
|
+
|| (as?.start_line ?? -1) - (bs?.start_line ?? -1)
|
|
56
|
+
|| (as?.start_col ?? -1) - (bs?.start_col ?? -1)
|
|
57
|
+
|| a.kind.localeCompare(b.kind)
|
|
58
|
+
|| a.name.localeCompare(b.name)
|
|
59
|
+
|| a.id.localeCompare(b.id);
|
|
60
|
+
}
|
|
61
|
+
function compareEdges(a, b) {
|
|
62
|
+
return a.from.localeCompare(b.from)
|
|
63
|
+
|| a.to.localeCompare(b.to)
|
|
64
|
+
|| a.kind.localeCompare(b.kind)
|
|
65
|
+
|| (a.source?.path ?? '').localeCompare(b.source?.path ?? '')
|
|
66
|
+
|| (a.source?.line ?? -1) - (b.source?.line ?? -1)
|
|
67
|
+
|| a.id.localeCompare(b.id);
|
|
68
|
+
}
|
|
69
|
+
function graphNode(node) {
|
|
70
|
+
return { id: node.id, kind: node.kind, subtype: node.subtype ?? null, lang: node.lang, name: node.name, path: node.path,
|
|
71
|
+
span: node.span ?? null, exported: node.exported, confidence: node.confidence };
|
|
72
|
+
}
|
|
73
|
+
function graphEdge(edge) {
|
|
74
|
+
return { id: edge.id, from: edge.from, to: edge.to, kind: edge.kind, confidence: edge.confidence, source: edge.source ?? null };
|
|
75
|
+
}
|
|
76
|
+
function matchesRoot(node, target, targetKind, normalizedPath) {
|
|
77
|
+
return targetKind === 'file'
|
|
78
|
+
? node.kind === 'file' && normalizedPath !== null && normalizePath(node.path) === normalizedPath
|
|
79
|
+
: node.kind === 'symbol' && node.name === target;
|
|
80
|
+
}
|
|
81
|
+
function usableEdge(edge, nodes, minConfidence) {
|
|
82
|
+
return edge.confidence >= minConfidence
|
|
83
|
+
&& (nodes.get(edge.from)?.confidence ?? -Infinity) >= minConfidence
|
|
84
|
+
&& (nodes.get(edge.to)?.confidence ?? -Infinity) >= minConfidence;
|
|
85
|
+
}
|
|
86
|
+
function touches(edge, nodeId, direction) {
|
|
87
|
+
return ((direction === 'outgoing' || direction === 'both') && edge.from === nodeId)
|
|
88
|
+
|| ((direction === 'incoming' || direction === 'both') && edge.to === nodeId);
|
|
89
|
+
}
|
|
90
|
+
function neighbor(edge, nodeId) {
|
|
91
|
+
return edge.from === nodeId ? edge.to : edge.from;
|
|
92
|
+
}
|
|
93
|
+
function emptyOutput(target, targetKind, limits, freshness, format) {
|
|
94
|
+
const graph = {
|
|
95
|
+
target, target_kind: targetKind, root_node_ids: [], nodes: [], edges: [], limits,
|
|
96
|
+
truncated: { roots: false, nodes: false, edges: false, depth: false }, freshness_badge: freshness,
|
|
97
|
+
};
|
|
98
|
+
return format === 'mermaid' ? { ...graph, format, mermaid: toMermaid(graph) } : { ...graph, format };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Select a deterministic, confidence-filtered neighborhood from persisted shards.
|
|
102
|
+
* No refresh, parse, inference, service call, or unbounded response occurs here.
|
|
103
|
+
*/
|
|
104
|
+
export function exportSubgraph(targetInput, options, ctx) {
|
|
105
|
+
const target = targetInput.trim();
|
|
106
|
+
const targetKind = normalizeTargetKind(options?.targetKind, target);
|
|
107
|
+
const format = normalizeFormat(options?.format);
|
|
108
|
+
const limits = {
|
|
109
|
+
direction: normalizeDirection(options?.direction),
|
|
110
|
+
max_depth: clampInteger(options?.depth, 1, 0, CODE_EXPORT_MAX_DEPTH),
|
|
111
|
+
max_nodes: clampInteger(options?.maxNodes, CODE_EXPORT_NODE_CAP, 1, CODE_EXPORT_NODE_CAP),
|
|
112
|
+
max_edges: clampInteger(options?.maxEdges, CODE_EXPORT_EDGE_CAP, 0, CODE_EXPORT_EDGE_CAP),
|
|
113
|
+
min_confidence: clampConfidence(options?.minConfidence),
|
|
114
|
+
};
|
|
115
|
+
const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
|
|
116
|
+
const missing = withCoarse({ status: 'missing_index', details: { hint: 'run refresh' } });
|
|
117
|
+
if (!manifest || manifest.freshness.status === 'missing_index' || !target)
|
|
118
|
+
return emptyOutput(target, targetKind, limits, missing, format);
|
|
119
|
+
const targetPath = targetKind === 'file' ? normalizeFileTarget(target, manifest.project_root) : null;
|
|
120
|
+
if (targetKind === 'file' && !targetPath) {
|
|
121
|
+
return emptyOutput(target, targetKind, limits, withCoarse({
|
|
122
|
+
status: manifest.freshness.status, details: { invalid_target: 'file path must be inside the indexed project' },
|
|
123
|
+
}), format);
|
|
124
|
+
}
|
|
125
|
+
const allNodes = new Map();
|
|
126
|
+
const allEdges = new Map();
|
|
127
|
+
for (const shard of listShards(ctx.cwd, ctx.preferredDirName).sort((a, b) => a.path.localeCompare(b.path) || a.file_id.localeCompare(b.file_id))) {
|
|
128
|
+
for (const node of shard.nodes)
|
|
129
|
+
if (!allNodes.has(node.id))
|
|
130
|
+
allNodes.set(node.id, node);
|
|
131
|
+
for (const edge of shard.edges)
|
|
132
|
+
if (!allEdges.has(edge.id))
|
|
133
|
+
allEdges.set(edge.id, edge);
|
|
134
|
+
}
|
|
135
|
+
const nodes = new Map([...allNodes.entries()].filter(([, node]) => node.confidence >= limits.min_confidence));
|
|
136
|
+
const edges = [...allEdges.values()].filter((edge) => usableEdge(edge, nodes, limits.min_confidence)).sort(compareEdges);
|
|
137
|
+
const roots = [...nodes.values()].filter((node) => matchesRoot(node, target, targetKind, targetPath)).sort(compareNodes);
|
|
138
|
+
const selected = new Set();
|
|
139
|
+
const rootNodeIds = [];
|
|
140
|
+
let rootsTruncated = false;
|
|
141
|
+
for (const root of roots) {
|
|
142
|
+
if (selected.size >= limits.max_nodes) {
|
|
143
|
+
rootsTruncated = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
selected.add(root.id);
|
|
147
|
+
rootNodeIds.push(root.id);
|
|
148
|
+
}
|
|
149
|
+
const selectedEdges = new Map();
|
|
150
|
+
const visited = new Set(rootNodeIds);
|
|
151
|
+
let nodesTruncated = false;
|
|
152
|
+
let edgesTruncated = false;
|
|
153
|
+
let depthTruncated = false;
|
|
154
|
+
let frontier = [...rootNodeIds];
|
|
155
|
+
for (let currentDepth = 0; frontier.length > 0; currentDepth++) {
|
|
156
|
+
const next = new Set();
|
|
157
|
+
frontier.sort((a, b) => compareNodes(nodes.get(a), nodes.get(b)));
|
|
158
|
+
for (const nodeId of frontier) {
|
|
159
|
+
const incident = edges.filter((edge) => touches(edge, nodeId, limits.direction));
|
|
160
|
+
if (currentDepth >= limits.max_depth) {
|
|
161
|
+
if (incident.some((edge) => !selectedEdges.has(edge.id)))
|
|
162
|
+
depthTruncated = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
for (const edge of incident) {
|
|
166
|
+
const adjacent = neighbor(edge, nodeId);
|
|
167
|
+
if (!selected.has(adjacent) && selected.size >= limits.max_nodes) {
|
|
168
|
+
nodesTruncated = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (selectedEdges.size >= limits.max_edges && !selectedEdges.has(edge.id)) {
|
|
172
|
+
edgesTruncated = true;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
selected.add(adjacent);
|
|
176
|
+
selectedEdges.set(edge.id, edge);
|
|
177
|
+
if (!visited.has(adjacent)) {
|
|
178
|
+
visited.add(adjacent);
|
|
179
|
+
next.add(adjacent);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
frontier = [...next];
|
|
184
|
+
}
|
|
185
|
+
const graph = {
|
|
186
|
+
target, target_kind: targetKind, root_node_ids: rootNodeIds,
|
|
187
|
+
nodes: [...selected].map((id) => nodes.get(id)).filter((node) => node !== undefined).sort(compareNodes).map(graphNode),
|
|
188
|
+
edges: [...selectedEdges.values()].sort(compareEdges).map(graphEdge),
|
|
189
|
+
limits, truncated: { roots: rootsTruncated, nodes: nodesTruncated, edges: edgesTruncated, depth: depthTruncated },
|
|
190
|
+
freshness_badge: withCoarse({ status: manifest.freshness.status,
|
|
191
|
+
details: { stale_file_count: manifest.freshness.stale_file_count, partial_reason: manifest.freshness.partial_reason } }),
|
|
192
|
+
};
|
|
193
|
+
return format === 'mermaid' ? { ...graph, format, mermaid: toMermaid(graph) } : { ...graph, format };
|
|
194
|
+
}
|
|
195
|
+
function mermaidLabel(node) {
|
|
196
|
+
return JSON.stringify(`${node.name} (${node.kind})`.replace(/[\r\n]/g, ' '));
|
|
197
|
+
}
|
|
198
|
+
/** Deterministic textual projection of a graph already selected by exportSubgraph. */
|
|
199
|
+
export function toMermaid(graph) {
|
|
200
|
+
const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
|
|
201
|
+
const lines = ['flowchart TD'];
|
|
202
|
+
for (const node of graph.nodes)
|
|
203
|
+
lines.push(` ${ids.get(node.id)}[${mermaidLabel(node)}]`);
|
|
204
|
+
for (const edge of graph.edges) {
|
|
205
|
+
const from = ids.get(edge.from);
|
|
206
|
+
const to = ids.get(edge.to);
|
|
207
|
+
if (from && to)
|
|
208
|
+
lines.push(` ${from} -->|${edge.kind} · ${edge.confidence}| ${to}`);
|
|
209
|
+
}
|
|
210
|
+
return lines.join('\n');
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=export.js.map
|