brainclaw 1.24.0 → 1.26.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-code-map.js +9 -2
- package/dist/commands/code-map.js +120 -6
- package/dist/commands/mcp-catalog.js +46 -0
- package/dist/commands/mcp.js +58 -6
- package/dist/commands/session-start.js +84 -13
- package/dist/core/bootstrap.js +28 -4
- package/dist/core/code-map/aggregate.js +36 -31
- package/dist/core/code-map/backend.js +162 -5
- package/dist/core/code-map/core.js +1 -0
- package/dist/core/code-map/export.js +212 -0
- package/dist/core/code-map/finalizer.js +57 -2
- package/dist/core/code-map/freshness.js +81 -15
- package/dist/core/code-map/impact.js +409 -0
- package/dist/core/code-map/indexes.js +64 -3
- package/dist/core/code-map/lang/python/index.js +4 -2
- package/dist/core/code-map/lang/query-runtime.js +2 -0
- package/dist/core/code-map/lang/typescript/config.js +271 -0
- package/dist/core/code-map/lang/typescript/index.js +24 -6
- package/dist/core/code-map/lang/usages.js +333 -0
- package/dist/core/code-map/memory-reader.js +15 -0
- package/dist/core/code-map/query.js +285 -71
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +28 -2
- package/dist/core/code-map/store.js +1 -0
- package/dist/core/code-map/types.js +70 -9
- package/dist/core/code-map/vocabulary.js +6 -0
- package/dist/core/code-map/work-section.js +12 -14
- package/dist/core/context-diff.js +17 -3
- package/dist/core/entity-operations.js +14 -2
- package/dist/core/federation-pull.js +151 -3
- package/dist/core/federation-push.js +16 -3
- package/dist/core/hint-aging.js +4 -1
- package/dist/core/identity.js +69 -17
- package/dist/core/io.js +27 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/protocol-tool-policy.js +3 -0
- package/dist/core/runtime.js +23 -0
- package/dist/core/worktree.js +89 -2
- package/dist/facts.js +15 -12
- package/dist/facts.json +14 -11
- package/docs/cli.md +8 -0
- package/docs/code-map.md +60 -28
- package/docs/integrations/mcp.md +5 -2
- package/docs/mcp-schema-changelog.md +11 -1
- package/package.json +1 -1
|
@@ -10,20 +10,69 @@
|
|
|
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
|
-
import { applyGitHeadDrift,
|
|
15
|
+
import { applyGitHeadDrift, withFreshness } 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
|
-
// pln#601 —
|
|
25
|
-
//
|
|
26
|
-
return
|
|
35
|
+
// pln#601 — build the uniform freshness envelope for every backend surface
|
|
36
|
+
// It always includes index and spot-check details.
|
|
37
|
+
return withFreshness({ status, details });
|
|
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);
|
|
27
76
|
}
|
|
28
77
|
/**
|
|
29
78
|
* Read the working tree's current commit at `root` (read-path git-HEAD drift,
|
|
@@ -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 { withFreshness } 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 = withFreshness({ 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, withFreshness({
|
|
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: withFreshness({ 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
|
|
@@ -82,6 +82,9 @@ export function finalize(draft, input) {
|
|
|
82
82
|
const byName = new Map();
|
|
83
83
|
// node id -> index in `nodes`, so an export clause can flip `exported` in place.
|
|
84
84
|
const nodeIndexById = new Map();
|
|
85
|
+
// P4 resolves provider draft ordinals to final symbol ids only here, after the
|
|
86
|
+
// identity authority has minted them. This keeps providers id-free.
|
|
87
|
+
const definitionIdsByOrdinal = new Map();
|
|
85
88
|
const pushSymbol = (subtype, name, span, exported, confidence) => {
|
|
86
89
|
const id = symNodeId(projectId, path, lang, subtype, name, span);
|
|
87
90
|
nodeIndexById.set(id, nodes.length);
|
|
@@ -127,7 +130,8 @@ export function finalize(draft, input) {
|
|
|
127
130
|
for (const item of items) {
|
|
128
131
|
if (item.kind === 'def') {
|
|
129
132
|
const d = item.ref;
|
|
130
|
-
pushSymbol(d.subtype, d.name, d.span, d.exported === true, d.confidence ?? 1.0);
|
|
133
|
+
const id = pushSymbol(d.subtype, d.name, d.span, d.exported === true, d.confidence ?? 1.0);
|
|
134
|
+
definitionIdsByOrdinal.set(d.ordinal, id);
|
|
131
135
|
}
|
|
132
136
|
else if (item.kind === 'import') {
|
|
133
137
|
const im = item.ref;
|
|
@@ -179,6 +183,46 @@ export function finalize(draft, input) {
|
|
|
179
183
|
});
|
|
180
184
|
}
|
|
181
185
|
}
|
|
186
|
+
// P4 lexical usages. Local targets are already proven by the provider's tree
|
|
187
|
+
// walk; imported bindings become candidates and are materialized only by the
|
|
188
|
+
// whole-project resolver when the target symbol is unique and importable.
|
|
189
|
+
const referenceCandidates = [];
|
|
190
|
+
for (const usage of draft.usages ?? []) {
|
|
191
|
+
const from = usage.fromDefinitionOrdinal === undefined
|
|
192
|
+
? fileNode
|
|
193
|
+
: definitionIdsByOrdinal.get(usage.fromDefinitionOrdinal);
|
|
194
|
+
if (!from)
|
|
195
|
+
continue;
|
|
196
|
+
const confidence = usage.confidence ?? 1.0;
|
|
197
|
+
const source = { path, line: usage.span.start_line };
|
|
198
|
+
if (usage.target.kind === 'import') {
|
|
199
|
+
// Textual hints are deliberately local-only. An imported binding gets no
|
|
200
|
+
// graph edge until `resolveProjectImports` proves its target symbol.
|
|
201
|
+
if (usage.kind === 'calls' || usage.kind === 'references') {
|
|
202
|
+
referenceCandidates.push({
|
|
203
|
+
from,
|
|
204
|
+
kind: usage.kind,
|
|
205
|
+
module: usage.target.module,
|
|
206
|
+
imported_name: usage.target.importedName,
|
|
207
|
+
confidence,
|
|
208
|
+
source,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const to = definitionIdsByOrdinal.get(usage.target.definitionOrdinal);
|
|
214
|
+
if (!to)
|
|
215
|
+
continue;
|
|
216
|
+
edges.push({
|
|
217
|
+
id: edgeId({ projectId, from, to, kind: usage.kind }),
|
|
218
|
+
from,
|
|
219
|
+
to,
|
|
220
|
+
kind: usage.kind,
|
|
221
|
+
confidence,
|
|
222
|
+
source,
|
|
223
|
+
origin: usage.kind === 'possible_textual_match' ? 'usage_textual' : 'usage_local',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
182
226
|
const parseStatus = draft.attributes?.parseStatus ?? 'parsed';
|
|
183
227
|
const diagnostics = draft.facts.map((f) => ({ ...f }));
|
|
184
228
|
// Validate the finalized output against the durable schemas (spec §6).
|
|
@@ -186,6 +230,17 @@ export function finalize(draft, input) {
|
|
|
186
230
|
NodeSchema.parse(n);
|
|
187
231
|
for (const e of edges)
|
|
188
232
|
EdgeSchema.parse(e);
|
|
189
|
-
|
|
233
|
+
const result = { parseStatus, nodes, edges, diagnostics };
|
|
234
|
+
// P1's oracle intentionally compares the enumerable JSON result. Keep the
|
|
235
|
+
// resolver hand-off available to refresh without changing that stable shape.
|
|
236
|
+
if (referenceCandidates.length > 0) {
|
|
237
|
+
Object.defineProperty(result, 'referenceCandidates', {
|
|
238
|
+
value: referenceCandidates,
|
|
239
|
+
enumerable: false,
|
|
240
|
+
configurable: false,
|
|
241
|
+
writable: false,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
190
245
|
}
|
|
191
246
|
//# sourceMappingURL=finalizer.js.map
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* extractor_config + the active language set + (P1a) the registry's
|
|
9
9
|
* `configHashInputs()` (provider versions + every query-asset hash). Changing
|
|
10
10
|
* ignore rules, size caps, supported extensions, query budget, active langs, a
|
|
11
|
-
* provider version, OR
|
|
11
|
+
* provider version, query assets, OR local resolver config => stale_extractor.
|
|
12
12
|
* NOTE: grammar/engine hashes are deliberately NOT folded in (spec §6.2):
|
|
13
13
|
* stale_grammar (changed parse binary) is kept separable from stale_extractor.
|
|
14
14
|
* - `shardFreshnessStatus` — classify a stored shard against the current
|
|
@@ -45,9 +45,72 @@ export function coarseFreshness(status) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Build the canonical, surface-uniform badge. `freshness` is derived solely from
|
|
50
|
+
* the index state supplied as `status`; a query's bounded spot-check is diagnostic
|
|
51
|
+
* evidence under `details.spot_check`, never a competing top-level badge.
|
|
52
|
+
*/
|
|
53
|
+
export function makeFreshnessBadge(status, options = {}) {
|
|
54
|
+
const spot = options.spotCheck ?? {};
|
|
55
|
+
return {
|
|
56
|
+
freshness: coarseFreshness(status),
|
|
57
|
+
status,
|
|
58
|
+
details: {
|
|
59
|
+
...(options.extra ?? {}),
|
|
60
|
+
index: {
|
|
61
|
+
status,
|
|
62
|
+
stale_file_count: options.staleFileCount ?? 0,
|
|
63
|
+
partial_reason: options.partialReason ?? null,
|
|
64
|
+
git_head_changed: options.gitHeadChanged ?? null,
|
|
65
|
+
},
|
|
66
|
+
spot_check: {
|
|
67
|
+
status: spot.status ?? 'not_run',
|
|
68
|
+
checked_files: spot.checked_files ?? 0,
|
|
69
|
+
stale_changed_files: spot.stale_changed_files ?? [],
|
|
70
|
+
deleted_files: spot.deleted_files ?? [],
|
|
71
|
+
unchecked_files: spot.unchecked_files ?? [],
|
|
72
|
+
budget_exhausted: spot.budget_exhausted ?? false,
|
|
73
|
+
partial_reason: spot.partial_reason ?? null,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Compatibility normalizer for internal callers that previously constructed a
|
|
80
|
+
* `{ status, details }` badge. It preserves non-freshness metadata while always
|
|
81
|
+
* adding the two canonical detail sections.
|
|
82
|
+
*/
|
|
83
|
+
export function withFreshness(b) {
|
|
84
|
+
const raw = b.details ?? {};
|
|
85
|
+
const index = raw.index;
|
|
86
|
+
const spot = raw.spot_check;
|
|
87
|
+
const known = new Set([
|
|
88
|
+
'index', 'spot_check', 'stale_file_count', 'partial_reason', 'git_head_changed',
|
|
89
|
+
'stale_changed_files', 'deleted_files', 'unchecked_files', 'budget',
|
|
90
|
+
]);
|
|
91
|
+
const extra = Object.fromEntries(Object.entries(raw).filter(([key]) => !known.has(key)));
|
|
92
|
+
const stringArray = (value) => Array.isArray(value) ? value.map(String).sort() : [];
|
|
93
|
+
const numberValue = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
94
|
+
const nullableString = (value) => typeof value === 'string' ? value : value === null ? null : undefined;
|
|
95
|
+
const git = (index?.git_head_changed ?? raw.git_head_changed);
|
|
96
|
+
const gitHeadChanged = git && typeof git.index_head === 'string' && typeof git.current_head === 'string'
|
|
97
|
+
? { index_head: git.index_head, current_head: git.current_head }
|
|
98
|
+
: null;
|
|
99
|
+
return makeFreshnessBadge(b.status, {
|
|
100
|
+
staleFileCount: numberValue(index?.stale_file_count ?? raw.stale_file_count),
|
|
101
|
+
partialReason: nullableString(index?.partial_reason ?? raw.partial_reason),
|
|
102
|
+
gitHeadChanged,
|
|
103
|
+
spotCheck: {
|
|
104
|
+
status: spot?.status,
|
|
105
|
+
checked_files: numberValue(spot?.checked_files),
|
|
106
|
+
stale_changed_files: stringArray(spot?.stale_changed_files ?? raw.stale_changed_files),
|
|
107
|
+
deleted_files: stringArray(spot?.deleted_files ?? raw.deleted_files),
|
|
108
|
+
unchecked_files: stringArray(spot?.unchecked_files ?? raw.unchecked_files),
|
|
109
|
+
budget_exhausted: spot?.budget_exhausted === true,
|
|
110
|
+
partial_reason: nullableString(spot?.partial_reason),
|
|
111
|
+
},
|
|
112
|
+
extra,
|
|
113
|
+
});
|
|
51
114
|
}
|
|
52
115
|
/** Stable serialization: sort object keys recursively so hashing is order-independent. */
|
|
53
116
|
function stableStringify(value) {
|
|
@@ -69,11 +132,12 @@ function stableStringify(value) {
|
|
|
69
132
|
* (dec#109 P0#3). Optional + omitted-vs-undefined hash the same so legacy callers
|
|
70
133
|
* (config-only) keep a stable hash for that input combination.
|
|
71
134
|
*/
|
|
72
|
-
export function computeExtractorConfigHash(config, activeLanguages, registryInputs) {
|
|
135
|
+
export function computeExtractorConfigHash(config, activeLanguages, registryInputs, resolverConfigFingerprint) {
|
|
73
136
|
const payload = {
|
|
74
137
|
extractor_config: config,
|
|
75
138
|
active_languages: [...activeLanguages].sort(),
|
|
76
139
|
registry: registryInputs ?? null,
|
|
140
|
+
resolver_config: resolverConfigFingerprint ?? null,
|
|
77
141
|
};
|
|
78
142
|
return `sha256:${crypto.createHash('sha256').update(stableStringify(payload)).digest('hex')}`;
|
|
79
143
|
}
|
|
@@ -164,16 +228,18 @@ export function summarizeFreshness(shards) {
|
|
|
164
228
|
* actionable status; only the cause detail is added.
|
|
165
229
|
*/
|
|
166
230
|
export function applyGitHeadDrift(badge, indexHead, currentHead) {
|
|
231
|
+
const normalized = withFreshness(badge);
|
|
232
|
+
const currentIndex = normalized.details.index;
|
|
167
233
|
if (!indexHead || !currentHead || indexHead === currentHead)
|
|
168
|
-
return
|
|
169
|
-
const status =
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
};
|
|
234
|
+
return normalized;
|
|
235
|
+
const status = normalized.status === 'fresh' ? 'stale_git_head' : normalized.status;
|
|
236
|
+
const extra = Object.fromEntries(Object.entries(normalized.details).filter(([key]) => key !== 'index' && key !== 'spot_check'));
|
|
237
|
+
return makeFreshnessBadge(status, {
|
|
238
|
+
staleFileCount: currentIndex.stale_file_count,
|
|
239
|
+
partialReason: currentIndex.partial_reason,
|
|
240
|
+
gitHeadChanged: { index_head: indexHead, current_head: currentHead },
|
|
241
|
+
spotCheck: normalized.details.spot_check,
|
|
242
|
+
extra,
|
|
243
|
+
});
|
|
178
244
|
}
|
|
179
245
|
//# sourceMappingURL=freshness.js.map
|