@nanonets/graft 0.6.0 → 0.7.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/README.md +20 -0
- package/dist/ask/ask.d.ts +49 -1
- package/dist/ask/ask.d.ts.map +1 -1
- package/dist/ask/ask.js +267 -66
- package/dist/ask/ask.js.map +1 -1
- package/dist/ask/fuse.d.ts +59 -17
- package/dist/ask/fuse.d.ts.map +1 -1
- package/dist/ask/fuse.js +51 -25
- package/dist/ask/fuse.js.map +1 -1
- package/dist/ask/graphrank.d.ts +6 -0
- package/dist/ask/graphrank.d.ts.map +1 -1
- package/dist/ask/graphrank.js +5 -1
- package/dist/ask/graphrank.js.map +1 -1
- package/dist/claude/format.js +1 -1
- package/dist/claude/format.js.map +1 -1
- package/dist/claude/hooks.d.ts +16 -0
- package/dist/claude/hooks.d.ts.map +1 -1
- package/dist/claude/hooks.js +57 -2
- package/dist/claude/hooks.js.map +1 -1
- package/dist/claude/skill-template.d.ts.map +1 -1
- package/dist/claude/skill-template.js +4 -0
- package/dist/claude/skill-template.js.map +1 -1
- package/dist/cli.d.ts +3 -15
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +78 -44
- package/dist/cli.js.map +1 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +7 -1
- package/dist/engine.js.map +1 -1
- package/dist/graph/build.d.ts.map +1 -1
- package/dist/graph/build.js +33 -0
- package/dist/graph/build.js.map +1 -1
- package/dist/graph/map.d.ts +34 -2
- package/dist/graph/map.d.ts.map +1 -1
- package/dist/graph/map.js +107 -23
- package/dist/graph/map.js.map +1 -1
- package/dist/graph/types.d.ts +12 -0
- package/dist/graph/types.d.ts.map +1 -1
- package/dist/graph/workspace-cli.d.ts +37 -0
- package/dist/graph/workspace-cli.d.ts.map +1 -0
- package/dist/graph/workspace-cli.js +70 -0
- package/dist/graph/workspace-cli.js.map +1 -0
- package/dist/graph/workspace.d.ts +116 -124
- package/dist/graph/workspace.d.ts.map +1 -1
- package/dist/graph/workspace.js +353 -297
- package/dist/graph/workspace.js.map +1 -1
- package/dist/hosts/instructions.d.ts.map +1 -1
- package/dist/hosts/instructions.js +3 -0
- package/dist/hosts/instructions.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +61 -1
- package/dist/mcp/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/graph/workspace.js
CHANGED
|
@@ -1,339 +1,395 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workspace federation — a parent directory
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* byte-identical to
|
|
6
|
-
*
|
|
7
|
-
* of child dir names, no nodes or edges.
|
|
2
|
+
* Workspace federation — a "workspace" is a parent directory that holds two or
|
|
3
|
+
* more immediate git-repo children and has no source graph of its own. Instead
|
|
4
|
+
* of one mega-graph pooling every repo, each child keeps its OWN committable
|
|
5
|
+
* `graft/` (byte-identical to building that child standalone), and the parent
|
|
6
|
+
* holds a single `graft/workspace.json` index:
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
* - `ask` merges the child graphs into one virtual corpus whose ranking
|
|
11
|
-
* scopes are `<child>/<scope>` (child root scope → `<child>`), so the
|
|
12
|
-
* existing per-scope RRF pipeline (`rankScopesAndFuse`) does the fair
|
|
13
|
-
* cross-repo fusion — a big repo can't drown a small one, and every hit
|
|
14
|
-
* is labeled with the repo it came from.
|
|
15
|
-
* - `grep` / `callers` / `callees` / `impact` run over the same merged
|
|
16
|
-
* graph: node ids and paths are prefixed `<child>/`, edges never cross
|
|
17
|
-
* children, so in-degree/coupling stays each child's own.
|
|
18
|
-
* - `map` and `check` run per child (see {@link buildWorkspaceMap} /
|
|
19
|
-
* {@link checkWorkspace}).
|
|
8
|
+
* { "version": 1, "children": ["repoA", "repoB"] }
|
|
20
9
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
10
|
+
* Queries run at the parent federate across the children: `ask` fuses every
|
|
11
|
+
* child's ranked hits with the same RRF `fuseScopes` used inside a single
|
|
12
|
+
* multi-scope repo (so the big repo can't drown the small one), `grep`/`map`/
|
|
13
|
+
* `check`/`callers` run per child and merge, always labeled `<child>/`.
|
|
25
14
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
15
|
+
* This module owns the pure/core pieces (the format, its readers/writers, graph
|
|
16
|
+
* loading, and the federated command bodies that return renderable data). The
|
|
17
|
+
* CLI print/exit wrappers and the per-child build orchestration live in
|
|
18
|
+
* `workspace-cli.ts`; `mcp/tools.ts` calls the federate* functions directly.
|
|
29
19
|
*/
|
|
30
20
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
31
|
-
import { join
|
|
32
|
-
import { contextDirFor
|
|
33
|
-
import { counts, tokenize } from "../ask/index-file.js";
|
|
34
|
-
import { buildGraph } from "./build.js";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { contextDirFor } from "../context/node-file.js";
|
|
35
23
|
import { checkGraph } from "./check.js";
|
|
36
|
-
import { loadGraphCached
|
|
24
|
+
import { loadGraphCached } from "./load.js";
|
|
37
25
|
import { buildRepoMap, formatRepoMap } from "./map.js";
|
|
38
|
-
import { discoverWorkspaceChildren
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
26
|
+
import { discoverWorkspaceChildren } from "./scopes.js";
|
|
27
|
+
import { callersSavings, headerOf, hitLine, looseNoteFor, } from "./traverse-cli.js";
|
|
28
|
+
import { edgeWalk, resolveSymbol } from "./traverse.js";
|
|
29
|
+
import { wiringPath } from "./write.js";
|
|
30
|
+
import { ask } from "../ask/ask.js";
|
|
31
|
+
import { fuseScopes, STRONG_FLOOR, HIGH_FLOOR } from "../ask/fuse.js";
|
|
32
|
+
import { grepGraph } from "../search/grep.js";
|
|
33
|
+
import { formatGrepResult, zeroHitNote } from "../search/grep-cli.js";
|
|
34
|
+
import { savingsFooter } from "../context/savings.js";
|
|
35
|
+
const WORKSPACE_FILE = "workspace.json";
|
|
36
|
+
/** Absolute path to the workspace index for a parent root (`<dir>/graft/workspace.json`). */
|
|
37
|
+
export function workspacePath(root, override) {
|
|
38
|
+
return join(contextDirFor(root, override), WORKSPACE_FILE);
|
|
44
39
|
}
|
|
45
|
-
/** Read the workspace
|
|
46
|
-
*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const path = workspacePath(contextDirFor(resolve(root), contextDirOverride));
|
|
50
|
-
if (!existsSync(path))
|
|
51
|
-
return null;
|
|
40
|
+
/** Read the workspace index, or null when the parent has none (not a workspace,
|
|
41
|
+
* or unparseable/foreign json — treated the same as absent). */
|
|
42
|
+
export function readWorkspace(root, override) {
|
|
43
|
+
const path = workspacePath(root, override);
|
|
52
44
|
try {
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
typeof raw !== "object" ||
|
|
56
|
-
raw.version !== 1 ||
|
|
57
|
-
!Array.isArray(raw.children) ||
|
|
58
|
-
!raw.children.every((c) => typeof c === "string" && c.length > 0)) {
|
|
45
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.children))
|
|
59
47
|
return null;
|
|
60
|
-
}
|
|
61
|
-
return { version: 1, children: raw.children };
|
|
48
|
+
return { version: 1, children: parsed.children.map(String) };
|
|
62
49
|
}
|
|
63
50
|
catch {
|
|
64
51
|
return null;
|
|
65
52
|
}
|
|
66
53
|
}
|
|
67
|
-
/** Write the
|
|
68
|
-
export function writeWorkspace(
|
|
69
|
-
const
|
|
70
|
-
mkdirSync(
|
|
54
|
+
/** Write the workspace index, sorting children for a stable, minimal git diff. */
|
|
55
|
+
export function writeWorkspace(root, ws, override) {
|
|
56
|
+
const dir = contextDirFor(root, override);
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
const path = join(dir, WORKSPACE_FILE);
|
|
71
59
|
const sorted = { version: 1, children: [...ws.children].sort() };
|
|
72
60
|
writeFileSync(path, JSON.stringify(sorted, null, 2) + "\n");
|
|
73
61
|
return path;
|
|
74
62
|
}
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
63
|
+
/** A parent is a workspace BUILD target when it has no own `.git` and ≥2 git
|
|
64
|
+
* children — or it was already split (a `workspace.json` is present). The
|
|
65
|
+
* no-own-`.git` guard keeps a normal repo with git submodules from being
|
|
66
|
+
* mistaken for a workspace. */
|
|
67
|
+
export function isWorkspaceBuildRoot(root, override) {
|
|
68
|
+
if (readWorkspace(root, override))
|
|
69
|
+
return true;
|
|
70
|
+
if (existsSync(join(root, ".git")))
|
|
71
|
+
return false;
|
|
72
|
+
return discoverWorkspaceChildren(root).length >= 2;
|
|
84
73
|
}
|
|
85
|
-
/**
|
|
86
|
-
*
|
|
87
|
-
export function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
74
|
+
/** True when the parent has a mega-graph from an older single-graph build
|
|
75
|
+
* (`graft/.graph/wiring.json`) — the thing a workspace build migrates away. */
|
|
76
|
+
export function hasMegaGraph(root, override) {
|
|
77
|
+
return existsSync(wiringPath(contextDirFor(root, override)));
|
|
78
|
+
}
|
|
79
|
+
/** The EXACT split warning printed once, when a mega-graph parent is first
|
|
80
|
+
* built as a workspace. Templated on the child list so it names the real repos. */
|
|
81
|
+
export function migrationNote(children) {
|
|
82
|
+
const dirs = children.map((c) => `${c}/graft/`).join(", ");
|
|
83
|
+
return (`⚠ this folder contains ${children.length} separate git repos — splitting: ` +
|
|
84
|
+
`each repo now gets its own committable graft/ (${dirs}); the combined graph ` +
|
|
85
|
+
`here is replaced by a workspace index. Queries from here now search all repos, fairly.`);
|
|
86
|
+
}
|
|
87
|
+
/** Remove the parent's entire `graft/` tree — the mega-graph, its `.cache`, and
|
|
88
|
+
* any stale cards — so after `writeWorkspace` the parent holds ONLY
|
|
89
|
+
* workspace.json. Child graphs live in sibling `<child>/graft/`, never under
|
|
90
|
+
* this dir, so they are untouched. */
|
|
91
|
+
export function clearParentGraft(root, override) {
|
|
92
|
+
rmSync(contextDirFor(root, override), { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
/** Load each child's graph via `loadGraphCached`. Children come from
|
|
95
|
+
* workspace.json when present, otherwise from live git-child discovery (a
|
|
96
|
+
* not-yet-built workspace). A child without a built graph is counted into
|
|
97
|
+
* `missing`, not dropped. */
|
|
98
|
+
export function loadWorkspaceGraphs(root, override) {
|
|
99
|
+
const ws = readWorkspace(root, override);
|
|
100
|
+
const children = (ws ? ws.children : discoverWorkspaceChildren(root)).slice().sort();
|
|
101
|
+
const loaded = [];
|
|
102
|
+
const missing = [];
|
|
103
|
+
for (const child of children) {
|
|
104
|
+
const graph = loadGraphCached(contextDirFor(join(root, child)));
|
|
99
105
|
if (graph)
|
|
100
|
-
|
|
106
|
+
loaded.push({ child, graph });
|
|
101
107
|
else
|
|
102
|
-
|
|
108
|
+
missing.push(child);
|
|
103
109
|
}
|
|
104
|
-
|
|
105
|
-
? `${built.length} of ${ws.children.length} workspace repos have graphs; run graft build to cover ${unbuilt.join(", ")}`
|
|
106
|
-
: undefined;
|
|
107
|
-
return { children: ws.children, built, unbuilt, note };
|
|
110
|
+
return { loaded, missing };
|
|
108
111
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
return {
|
|
117
|
-
nodes: g.nodes.map((n) => ({ ...n, id: `${child}/${n.id}`, path: `${child}/${n.path}` })),
|
|
118
|
-
edges: g.edges.map((e) => ({ ...e, source: pre(e.source), target: pre(e.target) })),
|
|
119
|
-
};
|
|
112
|
+
/** "2 of 3 workspace repos have graphs; run graft build to cover repoC" — the
|
|
113
|
+
* coverage line federated commands append when some listed child is unbuilt.
|
|
114
|
+
* Empty string when every child has a graph. */
|
|
115
|
+
export function coverageNote(g) {
|
|
116
|
+
if (g.missing.length === 0)
|
|
117
|
+
return "";
|
|
118
|
+
const total = g.loaded.length + g.missing.length;
|
|
119
|
+
return `${g.loaded.length} of ${total} workspace repos have graphs; run graft build to cover ${g.missing.join(", ")}`;
|
|
120
120
|
}
|
|
121
|
-
/**
|
|
122
|
-
*
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
121
|
+
/** Prefix a hit pointer with its child dir so a `path:span` (or concept path
|
|
122
|
+
* list) opens correctly from the parent. Leaves free-text fragments alone. */
|
|
123
|
+
function prefixPointer(child, pointer) {
|
|
124
|
+
return pointer
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((part) => {
|
|
127
|
+
const t = part.trim();
|
|
128
|
+
if (!t || t.includes(" "))
|
|
129
|
+
return part;
|
|
130
|
+
return `${child}/${t}`;
|
|
131
|
+
})
|
|
132
|
+
.join(", ");
|
|
133
|
+
}
|
|
134
|
+
// STRONG_FLOOR / HIGH_FLOOR (a child federates if its top hit matched a
|
|
135
|
+
// query term in a NAME/PATH field ≥ STRONG_FLOOR, OR its overall name+path+
|
|
136
|
+
// body coverage is broad enough to be real even body-only, ≥ HIGH_FLOOR) are
|
|
137
|
+
// defined ONCE in `../ask/fuse.js` and imported here — `rankScopesAndFuse`
|
|
138
|
+
// (single-graph multi-scope, `src/ask/fuse.ts`) gates on the exact same two
|
|
139
|
+
// floors, so the two paths that solve the identical "a weak scope must not
|
|
140
|
+
// federate beside a strong one" problem can never drift apart again.
|
|
141
|
+
/**
|
|
142
|
+
* Federated `ask` across a workspace: run each child's own per-scope ask
|
|
143
|
+
* pipeline, then fuse ALL the children's scope lists with the same
|
|
144
|
+
* `fuseScopes` used inside a single multi-scope repo. Each child contributes
|
|
145
|
+
* one fusion scope per intra-child scope, labeled `<child>/<scope>` (or just
|
|
146
|
+
* `<child>` for a child's root scope), so a big repo can't drown a small one —
|
|
147
|
+
* rank positions are comparable across repos where raw scores are not.
|
|
148
|
+
*
|
|
149
|
+
* Returns a normal `AskResult`; `formatAsk` renders it unchanged, labeling each
|
|
150
|
+
* hit `[<child>/…]` via the standard multi-scope path.
|
|
151
|
+
*/
|
|
152
|
+
export function federateAsk(root, override, query, opts = {}) {
|
|
153
|
+
const wg = loadWorkspaceGraphs(root, override);
|
|
154
|
+
const limit = opts.limit ?? 8;
|
|
155
|
+
// `--in` scopes to a single child (and, past the first segment, a sub-scope
|
|
156
|
+
// within it). A prefix naming no known child at all is a caller mistake.
|
|
157
|
+
let onlyChild;
|
|
158
|
+
let childIn;
|
|
159
|
+
if (opts.in) {
|
|
160
|
+
const prefix = opts.in.replace(/\/+$/, "");
|
|
161
|
+
const [name, ...rest] = prefix.split("/");
|
|
162
|
+
const allChildren = [...wg.loaded.map((l) => l.child), ...wg.missing].sort();
|
|
163
|
+
if (!allChildren.includes(name)) {
|
|
164
|
+
throw new Error(`no workspace repo "${name}" - repos: ${allChildren.join(", ")}`);
|
|
141
165
|
}
|
|
166
|
+
onlyChild = name;
|
|
167
|
+
childIn = rest.length ? rest.join("/") : undefined;
|
|
142
168
|
}
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
169
|
+
// Pass 1: run each child's ask; keep its hits + RAW top-hit coverage.
|
|
170
|
+
const runs = [];
|
|
171
|
+
for (const { child } of wg.loaded) {
|
|
172
|
+
if (onlyChild && child !== onlyChild)
|
|
173
|
+
continue;
|
|
174
|
+
let r;
|
|
175
|
+
try {
|
|
176
|
+
// Over-fetch per child so cross-child fusion has enough candidates to
|
|
177
|
+
// rank before the final `limit` slice.
|
|
178
|
+
r = ask(join(root, child), query, {
|
|
179
|
+
limit: Math.max(limit * 4, 20),
|
|
180
|
+
source: opts.source,
|
|
181
|
+
full: opts.full,
|
|
182
|
+
graphRank: opts.graphRank,
|
|
183
|
+
in: childIn,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
continue; // corrupt child, or a sub-scope --in matching nothing here
|
|
188
|
+
}
|
|
189
|
+
if (r.hits.length === 0)
|
|
190
|
+
continue;
|
|
191
|
+
runs.push({ child, hits: r.hits, coverage: r.coverage ?? 0, coverageStrong: r.coverageStrong ?? 0 });
|
|
192
|
+
}
|
|
193
|
+
// Cross-child participation gate on match STRENGTH, not a lenient ratio.
|
|
194
|
+
// Per-child hit scores are each per-child-normalized (top ~1.0 everywhere),
|
|
195
|
+
// so feeding them to fuseScopes makes its gate vacuous across repos — a junk
|
|
196
|
+
// body-token collision would federate beside another repo's genuine hits.
|
|
197
|
+
// Gate: a child federates iff its top hit matched a query term in a NAME/PATH
|
|
198
|
+
// field (coverageStrong ≥ STRONG_FLOOR — not just an incidental body token)
|
|
199
|
+
// OR its overall coverage is broad enough to be real (≥ HIGH_FLOOR). A
|
|
200
|
+
// body-only collision has coverageStrong 0 and coverage ~0.3 → gated to
|
|
201
|
+
// alsoMatched. Both floors are absolute + scale-invariant (see their docs).
|
|
202
|
+
// The gate is a cross-child FAIRNESS mechanism: it stops a weak body-token
|
|
203
|
+
// collision in one repo from federating beside another repo's genuine hits.
|
|
204
|
+
// An explicit `--in <child>` removes all cross-child competition — the caller
|
|
205
|
+
// has already chosen the scope — so the gate must NOT apply there, or the
|
|
206
|
+
// `also matched: <child> — narrow with --in <child>` hint would lead to an
|
|
207
|
+
// empty result for exactly the weak child it points at. With `onlyChild` set,
|
|
208
|
+
// every run (there is at most one) survives, matching a standalone child ask.
|
|
209
|
+
const gatedOut = [];
|
|
210
|
+
const survivors = [];
|
|
211
|
+
for (const run of runs) {
|
|
212
|
+
if (onlyChild || run.coverageStrong >= STRONG_FLOOR || run.coverage >= HIGH_FLOOR)
|
|
213
|
+
survivors.push(run);
|
|
214
|
+
else
|
|
215
|
+
gatedOut.push({ scope: run.child, bestId: prefixPointer(run.child, run.hits[0].pointer) });
|
|
177
216
|
}
|
|
217
|
+
// Fuse the survivors' scope lists (within-child fusion is left untouched —
|
|
218
|
+
// each child already ran its own per-scope RRF).
|
|
178
219
|
const docs = [];
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const doc = { id: `${child}/${d.id}`, name: d.name, path: [...path.entries()], body: d.body };
|
|
188
|
-
docs.push(doc);
|
|
189
|
-
const bag = new Set([...doc.name, ...doc.path, ...doc.body].map(([t]) => t));
|
|
190
|
-
for (const t of bag)
|
|
191
|
-
df.set(t, (df.get(t) ?? 0) + 1);
|
|
192
|
-
for (const [, c] of d.body)
|
|
193
|
-
bodyLenSum += c;
|
|
194
|
-
}
|
|
220
|
+
const back = new Map();
|
|
221
|
+
for (const { child, hits } of survivors) {
|
|
222
|
+
hits.forEach((hit, i) => {
|
|
223
|
+
const scope = hit.scope ? `${child}/${hit.scope}` : child;
|
|
224
|
+
const id = `${child} ${i}`;
|
|
225
|
+
docs.push({ id, scope, score: hit.score });
|
|
226
|
+
back.set(id, { child, hit });
|
|
227
|
+
});
|
|
195
228
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
export function loadFederatedGraph(root, contextDirOverride) {
|
|
208
|
-
const wg = loadWorkspaceGraphs(root, contextDirOverride);
|
|
209
|
-
if (!wg)
|
|
210
|
-
return null;
|
|
211
|
-
return { graph: mergeWorkspaceGraphs(wg.built), note: wg.note };
|
|
212
|
-
}
|
|
213
|
-
/** Federation inputs for `ask`: the merged graph + merged sidecar + each built
|
|
214
|
-
* child's context dir (for its concept nodes). `ask` feeds the merged graph to
|
|
215
|
-
* its existing multi-scope pipeline, so `rankScopesAndFuse` does the fair
|
|
216
|
-
* cross-repo fusion with `<child>/`-prefixed scope labels. */
|
|
217
|
-
export function federateAsk(root, contextDirOverride) {
|
|
218
|
-
const wg = loadWorkspaceGraphs(root, contextDirOverride);
|
|
219
|
-
if (!wg)
|
|
220
|
-
return null;
|
|
221
|
-
return {
|
|
222
|
-
graph: mergeWorkspaceGraphs(wg.built),
|
|
223
|
-
askIndex: mergeWorkspaceAskIndexes(wg.built),
|
|
224
|
-
built: wg.built.map(({ child, contextDir }) => ({ child, contextDir })),
|
|
225
|
-
note: wg.note,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
// ── Build + migration ────────────────────────────────────────────────────────
|
|
229
|
-
/** EXACT migration wording (asserted by tests) for a parent whose old combined
|
|
230
|
-
* graph is being replaced by the workspace manifest. */
|
|
231
|
-
export function migrationNote(children) {
|
|
232
|
-
return (`⚠ this folder contains ${children.length} separate git repos — splitting: each repo now gets ` +
|
|
233
|
-
`its own committable graft/ (${children.map((c) => `${c}/graft/`).join(", ")}); the combined ` +
|
|
234
|
-
`graph here is replaced by a workspace index. Queries from here now search all repos, fairly.`);
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* `graft build` at a workspace parent: build each child's graph into the child
|
|
238
|
-
* (plain `buildGraph(childDir)` — nothing parent-side touches it, which is what
|
|
239
|
-
* makes the result byte-identical to a standalone build), then write the
|
|
240
|
-
* parent manifest. A pre-existing mega-graph (`graft/.graph/wiring.json` at
|
|
241
|
-
* the parent) is replaced: its `.graph/` and `.cache/` are deleted AFTER the
|
|
242
|
-
* children built successfully, so a failed child build never destroys the old
|
|
243
|
-
* (still-working) graph.
|
|
244
|
-
*/
|
|
245
|
-
export async function buildWorkspace(root, opts = {}) {
|
|
246
|
-
const absRoot = resolve(root);
|
|
247
|
-
const children = discoverWorkspaceChildren(absRoot).sort();
|
|
248
|
-
const outDir = contextDirFor(absRoot, opts.contextDir);
|
|
249
|
-
const hadMegaGraph = existsSync(wiringPath(outDir));
|
|
250
|
-
const results = [];
|
|
251
|
-
for (const child of children) {
|
|
252
|
-
results.push({ child, result: await buildGraph(join(absRoot, child)) });
|
|
229
|
+
const fused = fuseScopes(docs);
|
|
230
|
+
const hits = fused.ranked.slice(0, limit).map((rd) => {
|
|
231
|
+
const { child, hit } = back.get(rd.id);
|
|
232
|
+
return { ...hit, score: rd.score, scope: rd.scope, pointer: prefixPointer(child, hit.pointer) };
|
|
233
|
+
});
|
|
234
|
+
const alsoMatched = [...fused.alsoMatched, ...gatedOut];
|
|
235
|
+
const note = coverageNote(wg);
|
|
236
|
+
const result = { query, mode: hits.length ? "lexical" : "empty", hits };
|
|
237
|
+
if (hits.length) {
|
|
238
|
+
const federated = fused.federated.length ? fused.federated : [...new Set(hits.map((h) => h.scope))];
|
|
239
|
+
result.scopes = { federated, alsoMatched };
|
|
253
240
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
rmSync(join(outDir, CACHE_DIR), { recursive: true, force: true });
|
|
241
|
+
else {
|
|
242
|
+
result.note = `no matching nodes across ${wg.loaded.length} workspace repo(s) — try different words, or \`graft build\` at a child`;
|
|
257
243
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
workspacePath: path,
|
|
262
|
-
migrated: hadMegaGraph,
|
|
263
|
-
migrationNote: hadMegaGraph ? migrationNote(children) : undefined,
|
|
264
|
-
};
|
|
244
|
+
if (note)
|
|
245
|
+
result.note = result.note ? `${result.note}\n${note}` : note;
|
|
246
|
+
return result;
|
|
265
247
|
}
|
|
266
|
-
/**
|
|
267
|
-
*
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
248
|
+
/** Merge every child's `GrepResult` into one, prefixing group paths with the
|
|
249
|
+
* child dir and re-sorting by coupling (inDegree desc, path asc) across repos.
|
|
250
|
+
* In-degree stays each child's own — it comes from that child's graph. */
|
|
251
|
+
export function federateGrep(root, override, pattern, opts = {}) {
|
|
252
|
+
const wg = loadWorkspaceGraphs(root, override);
|
|
253
|
+
const groups = [];
|
|
254
|
+
let filesSearched = 0;
|
|
255
|
+
let totalHits = 0;
|
|
256
|
+
const truncated = { files: 0, hits: 0 };
|
|
257
|
+
let savedFiles = 0;
|
|
258
|
+
let savedChars = 0;
|
|
259
|
+
for (const { child, graph } of wg.loaded) {
|
|
260
|
+
const r = grepGraph(graph, join(root, child), pattern, {
|
|
261
|
+
ignoreCase: opts.ignoreCase,
|
|
262
|
+
fixed: opts.fixed,
|
|
263
|
+
});
|
|
264
|
+
filesSearched += r.filesSearched;
|
|
265
|
+
totalHits += r.totalHits;
|
|
266
|
+
truncated.files += r.truncated.files;
|
|
267
|
+
truncated.hits += r.truncated.hits;
|
|
268
|
+
if (r.saved) {
|
|
269
|
+
savedFiles += r.saved.files;
|
|
270
|
+
savedChars += r.saved.baselineChars;
|
|
271
|
+
}
|
|
272
|
+
for (const g of r.groups) {
|
|
273
|
+
groups.push({
|
|
274
|
+
...g,
|
|
275
|
+
path: `${child}/${g.path}`,
|
|
276
|
+
symbol: g.symbol ? { ...g.symbol, path: `${child}/${g.symbol.path}` } : null,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
276
279
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
+
groups.sort((a, b) => b.inDegree - a.inDegree || a.path.localeCompare(b.path));
|
|
281
|
+
const saved = savedChars > 0 ? { files: savedFiles, baselineChars: savedChars } : undefined;
|
|
282
|
+
const result = { pattern, filesSearched, totalHits, groups, truncated, saved };
|
|
283
|
+
return { result, coverage: coverageNote(wg) };
|
|
280
284
|
}
|
|
281
|
-
/**
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
if (result.ok)
|
|
292
|
-
return { child, missing: false, ok: true, summary: "OK — wiring graph in sync", result };
|
|
293
|
-
const parts = [];
|
|
294
|
-
if (result.changed.length)
|
|
295
|
-
parts.push(`${result.changed.length} changed`);
|
|
296
|
-
if (result.added.length)
|
|
297
|
-
parts.push(`${result.added.length} added`);
|
|
298
|
-
if (result.removed.length)
|
|
299
|
-
parts.push(`${result.removed.length} removed`);
|
|
300
|
-
if (result.stale.length)
|
|
301
|
-
parts.push(`${result.stale.length} stale summaries`);
|
|
302
|
-
return { child, missing: false, ok: false, summary: `STALE — ${parts.join(", ")} (run \`graft build\` in ${child}/)`, result };
|
|
285
|
+
/** One `graft map` section per child (each child's own map, budget split evenly
|
|
286
|
+
* across the loaded children), joined under `<child>/` headers. */
|
|
287
|
+
export function federateMap(root, override, opts = {}) {
|
|
288
|
+
const wg = loadWorkspaceGraphs(root, override);
|
|
289
|
+
const perChild = opts.maxDirs !== undefined && wg.loaded.length > 0
|
|
290
|
+
? Math.max(1, Math.floor(opts.maxDirs / wg.loaded.length))
|
|
291
|
+
: undefined;
|
|
292
|
+
const sections = wg.loaded.map(({ child, graph }) => {
|
|
293
|
+
const map = buildRepoMap(graph, perChild !== undefined ? { maxDirs: perChild } : {});
|
|
294
|
+
return `## ${child}/\n${formatRepoMap(map).trimEnd()}`;
|
|
303
295
|
});
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
lines.push("");
|
|
311
|
-
lines.push(c.failed
|
|
312
|
-
? "workspace check: STALE — rebuild the repos above and commit their graft/."
|
|
313
|
-
: "workspace check: OK");
|
|
314
|
-
return lines.join("\n");
|
|
296
|
+
const head = `workspace map — ${wg.loaded.length} repo(s)`;
|
|
297
|
+
const parts = [head, "", sections.join("\n\n")];
|
|
298
|
+
const cov = coverageNote(wg);
|
|
299
|
+
if (cov)
|
|
300
|
+
parts.push("", cov);
|
|
301
|
+
return parts.join("\n") + "\n";
|
|
315
302
|
}
|
|
316
|
-
/** Per-child
|
|
317
|
-
*
|
|
318
|
-
export function
|
|
319
|
-
const wg = loadWorkspaceGraphs(root,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
303
|
+
/** Per-child drift status. `ok` is false when any BUILT child is stale — an
|
|
304
|
+
* unbuilt child is surfaced (coverage), never a failure. */
|
|
305
|
+
export function federateCheck(root, override) {
|
|
306
|
+
const wg = loadWorkspaceGraphs(root, override);
|
|
307
|
+
const lines = [`workspace check — ${wg.loaded.length + wg.missing.length} repo(s)`, ""];
|
|
308
|
+
let ok = true;
|
|
309
|
+
for (const { child } of wg.loaded) {
|
|
310
|
+
const g = checkGraph(join(root, child));
|
|
311
|
+
if (g.ok) {
|
|
312
|
+
lines.push(`${child}/: OK`);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
ok = false;
|
|
316
|
+
const bits = [];
|
|
317
|
+
if (g.added.length)
|
|
318
|
+
bits.push(`${g.added.length} added`);
|
|
319
|
+
if (g.removed.length)
|
|
320
|
+
bits.push(`${g.removed.length} removed`);
|
|
321
|
+
if (g.changed.length)
|
|
322
|
+
bits.push(`${g.changed.length} changed`);
|
|
323
|
+
if (g.stale.length)
|
|
324
|
+
bits.push(`${g.stale.length} stale`);
|
|
325
|
+
lines.push(`${child}/: STALE (${bits.join(", ")})`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const child of wg.missing)
|
|
329
|
+
lines.push(`${child}/: not built (run graft build)`);
|
|
330
|
+
const cov = coverageNote(wg);
|
|
331
|
+
if (cov)
|
|
332
|
+
lines.push("", cov);
|
|
333
|
+
return { text: lines.join("\n") + "\n", ok };
|
|
327
334
|
}
|
|
328
|
-
/**
|
|
329
|
-
*
|
|
330
|
-
export function
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
335
|
+
/** Resolve a symbol across every child, grouped per child. Reuses the shared
|
|
336
|
+
* traverse-cli formatters so each block reads exactly like `graft callers`. */
|
|
337
|
+
export function federateCallers(root, override, symbol, opts = {}) {
|
|
338
|
+
const wg = loadWorkspaceGraphs(root, override);
|
|
339
|
+
const direction = opts.direction ?? "in";
|
|
340
|
+
const depth = opts.depth && opts.depth >= 1 ? Math.floor(opts.depth) : 1;
|
|
341
|
+
const showDepth = depth > 1;
|
|
342
|
+
const blocks = [];
|
|
343
|
+
let found = false;
|
|
344
|
+
for (const { child, graph } of wg.loaded) {
|
|
345
|
+
const matches = resolveSymbol(graph, symbol, opts.in ? { in: opts.in } : {});
|
|
346
|
+
if (matches.length === 0)
|
|
347
|
+
continue;
|
|
348
|
+
found = true;
|
|
349
|
+
const results = matches.map((m) => ({ symbol: m, hits: edgeWalk(graph, m, direction, depth) }));
|
|
350
|
+
const lines = [`## ${child}/`];
|
|
351
|
+
for (const { symbol: sym, hits } of results) {
|
|
352
|
+
lines.push(headerOf(sym));
|
|
353
|
+
if (hits.length === 0)
|
|
354
|
+
lines.push(looseNoteFor(direction, sym.name));
|
|
355
|
+
else
|
|
356
|
+
for (const h of hits)
|
|
357
|
+
lines.push(hitLine(direction, h, showDepth));
|
|
358
|
+
}
|
|
359
|
+
const body = lines.join("\n");
|
|
360
|
+
blocks.push(body + savingsFooter(body, callersSavings(graph, results)));
|
|
361
|
+
}
|
|
362
|
+
const cov = coverageNote(wg);
|
|
363
|
+
if (!found) {
|
|
364
|
+
const base = `no symbol "${symbol}" in any of the ${wg.loaded.length} workspace repo(s) — check spelling or run graft build`;
|
|
365
|
+
return { text: cov ? `${base}\n${cov}` : base, found: false };
|
|
334
366
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
367
|
+
let text = blocks.join("\n\n");
|
|
368
|
+
if (cov)
|
|
369
|
+
text += `\n\n${cov}`;
|
|
370
|
+
return { text, found: true };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Split a parent into a workspace: build each git child (via the supplied
|
|
374
|
+
* `buildChild` callback, so this stays free of any engine/LLM dependency),
|
|
375
|
+
* then REPLACE the parent's `graft/` with just `workspace.json`. `onStart`
|
|
376
|
+
* fires once — before any child is built — carrying whether this build is a
|
|
377
|
+
* mega-graph migration, so the caller can print the one-time split warning
|
|
378
|
+
* first, exactly as the spec requires.
|
|
379
|
+
*
|
|
380
|
+
* The child build writes into `<child>/graft/` and is byte-identical to
|
|
381
|
+
* building that child standalone (`buildChild` is just `buildGraph(childDir)`),
|
|
382
|
+
* because nothing about the parent path enters the child's build.
|
|
383
|
+
*/
|
|
384
|
+
export async function splitWorkspace(root, override, buildChild, onStart) {
|
|
385
|
+
const children = discoverWorkspaceChildren(root).slice().sort();
|
|
386
|
+
const migrated = hasMegaGraph(root, override);
|
|
387
|
+
onStart?.({ children, migrated });
|
|
388
|
+
for (const child of children)
|
|
389
|
+
await buildChild(join(root, child), child);
|
|
390
|
+
clearParentGraft(root, override); // drop the mega-graph/.cache/cards…
|
|
391
|
+
writeWorkspace(root, { version: 1, children }, override); // …leaving ONLY workspace.json
|
|
392
|
+
return { children, migrated };
|
|
338
393
|
}
|
|
394
|
+
export { formatGrepResult, zeroHitNote };
|
|
339
395
|
//# sourceMappingURL=workspace.js.map
|