@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.
Files changed (54) hide show
  1. package/README.md +20 -0
  2. package/dist/ask/ask.d.ts +49 -1
  3. package/dist/ask/ask.d.ts.map +1 -1
  4. package/dist/ask/ask.js +267 -66
  5. package/dist/ask/ask.js.map +1 -1
  6. package/dist/ask/fuse.d.ts +59 -17
  7. package/dist/ask/fuse.d.ts.map +1 -1
  8. package/dist/ask/fuse.js +51 -25
  9. package/dist/ask/fuse.js.map +1 -1
  10. package/dist/ask/graphrank.d.ts +6 -0
  11. package/dist/ask/graphrank.d.ts.map +1 -1
  12. package/dist/ask/graphrank.js +5 -1
  13. package/dist/ask/graphrank.js.map +1 -1
  14. package/dist/claude/format.js +1 -1
  15. package/dist/claude/format.js.map +1 -1
  16. package/dist/claude/hooks.d.ts +16 -0
  17. package/dist/claude/hooks.d.ts.map +1 -1
  18. package/dist/claude/hooks.js +57 -2
  19. package/dist/claude/hooks.js.map +1 -1
  20. package/dist/claude/skill-template.d.ts.map +1 -1
  21. package/dist/claude/skill-template.js +4 -0
  22. package/dist/claude/skill-template.js.map +1 -1
  23. package/dist/cli.d.ts +3 -15
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +78 -44
  26. package/dist/cli.js.map +1 -1
  27. package/dist/engine.d.ts +1 -0
  28. package/dist/engine.d.ts.map +1 -1
  29. package/dist/engine.js +7 -1
  30. package/dist/engine.js.map +1 -1
  31. package/dist/graph/build.d.ts.map +1 -1
  32. package/dist/graph/build.js +33 -0
  33. package/dist/graph/build.js.map +1 -1
  34. package/dist/graph/map.d.ts +34 -2
  35. package/dist/graph/map.d.ts.map +1 -1
  36. package/dist/graph/map.js +107 -23
  37. package/dist/graph/map.js.map +1 -1
  38. package/dist/graph/types.d.ts +12 -0
  39. package/dist/graph/types.d.ts.map +1 -1
  40. package/dist/graph/workspace-cli.d.ts +37 -0
  41. package/dist/graph/workspace-cli.d.ts.map +1 -0
  42. package/dist/graph/workspace-cli.js +70 -0
  43. package/dist/graph/workspace-cli.js.map +1 -0
  44. package/dist/graph/workspace.d.ts +116 -124
  45. package/dist/graph/workspace.d.ts.map +1 -1
  46. package/dist/graph/workspace.js +353 -297
  47. package/dist/graph/workspace.js.map +1 -1
  48. package/dist/hosts/instructions.d.ts.map +1 -1
  49. package/dist/hosts/instructions.js +3 -0
  50. package/dist/hosts/instructions.js.map +1 -1
  51. package/dist/mcp/tools.d.ts.map +1 -1
  52. package/dist/mcp/tools.js +61 -1
  53. package/dist/mcp/tools.js.map +1 -1
  54. package/package.json +1 -1
package/README.md CHANGED
@@ -189,6 +189,7 @@ graft build --extensions .ts .py # only include these code extensions
189
189
 
190
190
  graft ask "<task>" [dir] # query the graph — ranked nodes + exact file:line (no LLM, no key)
191
191
  graft ask "<task>" --json # machine-readable result
192
+ graft ask "<task>" --in <scope> # narrow to one sub-project of a monorepo/multi-repo folder (see below)
192
193
 
193
194
  graft callers <symbol> [dir] # who calls/references/imports/implements/extends a symbol (no LLM, no key)
194
195
  graft callers <symbol> --direction out # the reverse: what the symbol itself calls/references (was `graft callees`)
@@ -259,6 +260,25 @@ scripts/ 2 files · 0 symbols
259
260
  hotspots: set · method · src/graph/bindings.ts:L21-L23 · 38← contextDirFor · function · src/context/node-file.ts:L100-L103 · 11← wiringPath · function · src/graph/write.ts:L20-L22 · 10← ...
260
261
  ```
261
262
 
263
+ ## Monorepos & multi-repo folders
264
+
265
+ Graft handles two shapes without any config:
266
+
267
+ - **A monorepo with one `.git`** (a `pnpm-workspace.yaml`/`package.json`
268
+ `workspaces`, or per-package `go.mod`/`pyproject.toml`/`Cargo.toml`) —
269
+ `graft build` discovers each sub-project as a ranking scope. `ask`/`map`
270
+ rank every scope on its own terms and fuse the results, so the biggest
271
+ sub-project can't drown a small one; hits carry `[scope/]` labels, and
272
+ `graft map` groups its directory clusters by scope first.
273
+ - **A folder of separate git repos** (no `.git` at the top) — `graft build`
274
+ auto-splits: each child keeps its own committable `graft/`, and the parent
275
+ gets a `graft/workspace.json` index. Queries from the parent federate across
276
+ every child, always labeled `<child>/`. Run `graft build` inside a child to
277
+ work on just that repo.
278
+
279
+ Either way, narrow to one sub-project with `graft ask "<task>" --in <scope>/`
280
+ once you know where you're working.
281
+
262
282
  ## Visualize it (`graft viz`)
263
283
 
264
284
  `graft viz` opens a local, interactive view of both graphs — no install, no dev
package/dist/ask/ask.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Savings } from "../context/savings.js";
2
- import type { Relation } from "../graph/types.js";
2
+ import type { NodeV1, Relation } from "../graph/types.js";
3
3
  export interface AskHit {
4
4
  kind: "concept" | "symbol" | "caller" | "callee";
5
5
  title: string;
@@ -13,6 +13,10 @@ export interface AskHit {
13
13
  * This is what makes `ask` substitutive — the agent reads the span here
14
14
  * instead of opening the file, so no source read happens on top of the query. */
15
15
  code?: string;
16
+ /** Multi-scope repos only: the ranking scope (path prefix, "" = root) this
17
+ * hit was ranked within — answers "which sub-project is this from?". Drives
18
+ * the `[scope/] ` label in formatAsk. Absent on single-scope repos. */
19
+ scope?: string;
16
20
  }
17
21
  export interface AskResult {
18
22
  query: string;
@@ -34,7 +38,45 @@ export interface AskResult {
34
38
  * best result, i.e. the pack is probably noise for this prompt. Absent in
35
39
  * structural mode — a resolved "who calls X" is itself the relevance signal. */
36
40
  coverage?: number;
41
+ /** Like {@link coverage}, but the top hit's idf-weighted matched share over
42
+ * its NAME + PATH fields ONLY (body excluded) — a match-STRENGTH signal:
43
+ * "did the query hit a high-value field, or only incidental body tokens?".
44
+ * A body-only collision (a variable that happens to share a query word) has
45
+ * `coverageStrong === 0` while `coverage` can still look respectable. Used by
46
+ * workspace federation to gate a weak child out of the cross-repo ranking.
47
+ * Absent in structural mode; same lexical-only contract as `coverage`. */
48
+ coverageStrong?: number;
49
+ /** Multi-scope lexical results only: which scopes federated into the fused
50
+ * ranking, plus scopes that matched too weakly to federate (with their best
51
+ * doc id). Drives formatAsk's `matched in:` / `also matched:` footer.
52
+ * Absent on single-scope repos — zero output change there. */
53
+ scopes?: {
54
+ federated: string[];
55
+ alsoMatched: {
56
+ scope: string;
57
+ bestId: string;
58
+ }[];
59
+ };
37
60
  }
61
+ /** Idf-weighted share (0..1) of the query a document matches: each query term
62
+ * counts by its rarity, so this separates task prompts from chatter in a way a
63
+ * raw term count can't. A conversational prompt's words are either off-corpus
64
+ * (rare → heavy, unmatched → sinks the ratio) or generic code words ("list",
65
+ * "write": common → near-weightless even when they collide with a symbol name);
66
+ * a task prompt matches exactly its rare, discriminating identifiers. Terms the
67
+ * corpus has never seen take `dfltIdf` (the df=0 weight). */
68
+ /** The match-STRENGTH share behind `coverageStrong`: idf-weighted share of the
69
+ * query matched in the node's NAME field ONLY. The PATH field is deliberately
70
+ * excluded — a coincidental generic directory (`gateway/`) or file basename
71
+ * (`gateway.ts`) in an unrelated repo would otherwise clear the strength gate
72
+ * and float that repo to the top; an on-topic path-organized repo is still
73
+ * rescued by the broad-coverage HIGH_FLOOR clause instead. For the same reason
74
+ * a `kind:"file"` node contributes zero strength: its `name` is a basename (a
75
+ * path component), not a symbol name, so a file whose basename happens to share
76
+ * a query word is not a strong match. */
77
+ export declare function strongShare(q: Map<string, number>, node: NodeV1, doc: {
78
+ name: Map<string, number>;
79
+ }, idf: Map<string, number>, dfltIdf: number): number;
38
80
  export interface AskOptions {
39
81
  contextDir?: string;
40
82
  limit?: number;
@@ -51,6 +93,12 @@ export interface AskOptions {
51
93
  * decision point, ~10× cheaper than the full span — and the pack marks each
52
94
  * crux so the agent knows `--full` (or the file itself) has the rest. */
53
95
  full?: boolean;
96
+ /** Narrow the doc/node set to this path prefix BEFORE scoring (segment-aware,
97
+ * like `scopeOf` — "frontend" never matches "frontend-utils"). Per-scope
98
+ * IDF/BM25/walk come free: the filtered set is usually one scope, so the
99
+ * existing multi-scope machinery degrades to its single-scope passthrough
100
+ * with no scope labels. A prefix matching nothing indexed throws. */
101
+ in?: string;
54
102
  }
55
103
  /** Answer a query from the graft/ graph at `dir`. Deterministic, $0. */
56
104
  export declare function ask(dir: string, query: string, opts?: AskOptions): AskResult;
@@ -1 +1 @@
1
- {"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/ask/ask.ts"],"names":[],"mappings":"AAmBA,OAAO,EAA6B,KAAK,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAGhF,OAAO,KAAK,EAA2B,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAI3E,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd;;qFAEiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAMD,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,GAAG,SAAS,GAAG,OAAO,CAAC;IACzC,kEAAkE;IAClE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;sFAEkF;IAClF,KAAK,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD;;;;oFAIgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA2aD,MAAM,WAAW,UAAU;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;qFACiF;IACjF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;sFAEkF;IAClF,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;6EAIyE;IACzE,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AA0ED,wEAAwE;AACxE,wBAAgB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,UAAe,GAAG,SAAS,CA8BhF;AAID,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,+DAA+D;IAC/D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;2EAE2E;AAC3E,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,cAAc,CA8BtG;AAED,2DAA2D;AAC3D,wBAAgB,cAAc,CAAC,CAAC,EAAE,cAAc,GAAG,MAAM,CAUxD;AAOD,sEAAsE;AACtE,wBAAgB,SAAS,CAAC,CAAC,EAAE,SAAS,GAAG,MAAM,CAsC9C"}
1
+ {"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/ask/ask.ts"],"names":[],"mappings":"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"}
package/dist/ask/ask.js CHANGED
@@ -19,7 +19,9 @@ import matter from "gray-matter";
19
19
  import { contextDirFor } from "../context/node-file.js";
20
20
  import { savingsFooter, savingsFor } from "../context/savings.js";
21
21
  import { loadGraphCached, loadAskIndexCached } from "../graph/load.js";
22
+ import { pathUnderPrefix, scopeLabel, scopeOf, scopesHereClause, scopesOfGraph } from "../graph/scopes.js";
22
23
  import { resolveSymbol } from "../graph/traverse.js";
24
+ import { rankScopesAndFuse } from "./fuse.js";
23
25
  import { personalizedPageRank } from "./graphrank.js";
24
26
  import { counts, tokenize } from "./index-file.js";
25
27
  /** Max source lines to inline per hit — a definition longer than this is
@@ -144,11 +146,22 @@ function subjectWords(query) {
144
146
  * last regardless of outcome, so a caller can name *something* in a fallthrough
145
147
  * note even when nothing resolved (e.g. `subjects.length === 0`). This is what
146
148
  * makes a qualified/dotted subject like `Cache.get` resolve, where the old
147
- * plain name-equality lookup (`findSubject`) never matched it. */
148
- function findSubjectNodes(query, graph) {
149
+ * plain name-equality lookup (`findSubject`) never matched it.
150
+ *
151
+ * `inPrefix`, when given, filters each word's `resolveSymbol` matches down to
152
+ * nodes under that path prefix BEFORE checking whether any survived — same
153
+ * "narrow the subject resolution" semantics `callers`/`callees`/`impact --in`
154
+ * already have (segment-aware here, via `pathUnderPrefix`). A word that
155
+ * resolves globally but has nothing under the prefix does NOT count as a
156
+ * match; the next (shorter) word is tried instead, same as an unresolved word
157
+ * today — and if nothing survives for ANY word, `subjects.length === 0`
158
+ * naturally falls through to the existing loud fallthrough note. */
159
+ function findSubjectNodes(query, graph, inPrefix) {
149
160
  const words = subjectWords(query).sort((a, b) => b.length - a.length);
150
161
  for (const w of words) {
151
- const nodes = resolveSymbol(graph, w);
162
+ let nodes = resolveSymbol(graph, w);
163
+ if (inPrefix)
164
+ nodes = nodes.filter((n) => pathUnderPrefix(n.path, inPrefix));
152
165
  if (nodes.length > 0)
153
166
  return { nodes, tried: w };
154
167
  }
@@ -158,12 +171,12 @@ function fallthroughNoteFor(subject) {
158
171
  return (`structural index: no entries for '${subject}' — showing lexical matches; ` +
159
172
  `for exhaustive callers use grep -rn '${subject}'`);
160
173
  }
161
- function structural(query, graph, limit) {
174
+ function structural(query, graph, limit, inPrefix) {
162
175
  const wantsIn = INCOMING.test(query);
163
176
  const wantsOut = OUTGOING.test(query);
164
177
  if (!wantsIn && !wantsOut)
165
178
  return null;
166
- const { nodes: subjects, tried } = findSubjectNodes(query, graph);
179
+ const { nodes: subjects, tried } = findSubjectNodes(query, graph, inPrefix);
167
180
  if (subjects.length === 0)
168
181
  return { fallthroughNote: fallthroughNoteFor(tried) };
169
182
  const ids = new Set(subjects.map((n) => n.id));
@@ -232,6 +245,20 @@ function hasTerm(f, t) {
232
245
  * "write": common → near-weightless even when they collide with a symbol name);
233
246
  * a task prompt matches exactly its rare, discriminating identifiers. Terms the
234
247
  * corpus has never seen take `dfltIdf` (the df=0 weight). */
248
+ /** The match-STRENGTH share behind `coverageStrong`: idf-weighted share of the
249
+ * query matched in the node's NAME field ONLY. The PATH field is deliberately
250
+ * excluded — a coincidental generic directory (`gateway/`) or file basename
251
+ * (`gateway.ts`) in an unrelated repo would otherwise clear the strength gate
252
+ * and float that repo to the top; an on-topic path-organized repo is still
253
+ * rescued by the broad-coverage HIGH_FLOOR clause instead. For the same reason
254
+ * a `kind:"file"` node contributes zero strength: its `name` is a basename (a
255
+ * path component), not a symbol name, so a file whose basename happens to share
256
+ * a query word is not a strong match. */
257
+ export function strongShare(q, node, doc, idf, dfltIdf) {
258
+ if (node.kind === "file")
259
+ return 0;
260
+ return matchedIdfShare(q, [doc.name], idf, dfltIdf);
261
+ }
235
262
  function matchedIdfShare(q, fields, idf, dfltIdf) {
236
263
  let matched = 0;
237
264
  let total = 0;
@@ -243,14 +270,19 @@ function matchedIdfShare(q, fields, idf, dfltIdf) {
243
270
  }
244
271
  return total > 0 ? matched / total : 0;
245
272
  }
246
- function lexical(query, corpus, limit, graphRank) {
273
+ function lexical(query, corpus, limit, graphRank, inPrefix) {
247
274
  // Binary query terms: a word repeated in a pasted issue body counts once, so a
248
275
  // ranty description can't linearly amplify an incidental word.
249
276
  const q = new Map([...counts(tokenize(query)).keys()].map((t) => [t, 1]));
250
277
  const graph = corpus.graph;
251
278
  // ── Pass 1: tokenize every scored field once, and collect per-document token
252
- // bags so IDF can down-weight words that occur across the whole corpus. ──
253
- const conceptDocs = corpus.concepts.map((c) => ({
279
+ // bags so IDF can down-weight words that occur across the whole corpus. `--in`
280
+ // drops a concept unless at least one of its sources falls under the prefix —
281
+ // filtering happens here, before any scoring, so a narrowed query never sees
282
+ // an unrelated concept from outside the prefix. ──
283
+ const conceptDocs = corpus.concepts
284
+ .filter((c) => !inPrefix || c.sources.some((p) => pathUnderPrefix(p, inPrefix)))
285
+ .map((c) => ({
254
286
  c,
255
287
  name: counts(tokenize(c.name)),
256
288
  body: counts(tokenize(c.text)),
@@ -262,6 +294,18 @@ function lexical(query, corpus, limit, graphRank) {
262
294
  // that — missing, unparseable, unknown version, wrong count, mismatched ids —
263
295
  // falls back to live tokenization so results never depend on the sidecar
264
296
  // existing; see `readAskIndex`'s own null-on-anything-off contract.
297
+ //
298
+ // `--in` does NOT disable the sidecar here: `writeGraph` strips `body_text`
299
+ // from every node it serializes (see `write.ts`) — the sidecar's per-doc
300
+ // `docs[].body` bags are the ONLY place a symbol's body tokens survive on a
301
+ // graph loaded from disk. Bypassing the sidecar under `--in` would silently
302
+ // empty out `body` for every node (falling back to `n.body_text ?? ""`,
303
+ // always `""` post-slim-serialization), losing every body-only match under
304
+ // the filtered prefix. What DOES need bypassing under `--in` is the
305
+ // sidecar's GLOBAL `df`/`docCount`/`avgBodyLen` — those cover the whole
306
+ // corpus, not the filtered subset — so each read of those three fields below
307
+ // is separately gated on `!inPrefix`, while the per-doc bags (used to build
308
+ // `symbolDocs` right after this) are used unconditionally.
265
309
  let docById = null;
266
310
  if (corpus.askIndex && graph && corpus.askIndex.docs.length === graph.nodes.length) {
267
311
  const map = new Map(corpus.askIndex.docs.map((d) => [d.id, d]));
@@ -272,8 +316,12 @@ function lexical(query, corpus, limit, graphRank) {
272
316
  // Symbols AND file nodes are scored: a symbol's body_text is its definition;
273
317
  // a file's body_text is the module-level residual (imports/constants not in
274
318
  // any symbol). Including files closes the recall gap where a gold file's only
275
- // query-relevant text lives outside every function/class.
276
- const symbolDocs = (graph?.nodes ?? []).map((n) => {
319
+ // query-relevant text lives outside every function/class. `--in` filters the
320
+ // node set here, at the very top, before any scoring — every downstream stat
321
+ // (idf, avgdl, candidates, the per-scope partition below) then naturally
322
+ // reflects only the filtered set with no other code needing to know about it.
323
+ const graphNodes = inPrefix ? (graph?.nodes ?? []).filter((n) => pathUnderPrefix(n.path, inPrefix)) : (graph?.nodes ?? []);
324
+ const symbolDocs = graphNodes.map((n) => {
277
325
  const d = docById?.get(n.id);
278
326
  if (d)
279
327
  return { n, name: new Map(d.name), path: new Map(d.path), body: new Map(d.body) };
@@ -296,18 +344,25 @@ function lexical(query, corpus, limit, graphRank) {
296
344
  // IDF must see the SAME inputs either way: with a sidecar, its `df` (stored
297
345
  // WITHOUT concepts) plus these live concept bags reproduces exactly what
298
346
  // `computeIdf` below would compute from concept+symbol bags tokenized live.
299
- const idf = askIndex
347
+ // `askIndex.df` is a corpus-GLOBAL count, so `--in` can't use it — the
348
+ // filtered doc set needs its own idf, computed from (already-filtered)
349
+ // `symbolDocs`' own bags, regardless of where those bags came from.
350
+ const idf = askIndex && !inPrefix
300
351
  ? computeIdfFromIndex(askIndex, conceptBags)
301
352
  : computeIdf([...conceptBags, ...symbolDocs.map((d) => new Set([...d.name.keys(), ...d.path.keys(), ...d.body.keys()]))]);
302
353
  // Per-hit idf-weighted coverage, kept out of the public AskHit shape; only
303
354
  // the top hit's value survives, as `coverage` on the result. The df=0 weight
304
355
  // mirrors idfFromDf's formula at df=0 for whichever corpus size idf was
305
- // computed from.
306
- const nDocs = askIndex
356
+ // computed from. Same `--in` reasoning as `idf` above: `askIndex.docCount`
357
+ // is global.
358
+ const nDocs = askIndex && !inPrefix
307
359
  ? askIndex.docCount + conceptBags.length
308
360
  : conceptBags.length + symbolDocs.length;
309
361
  const dfltIdf = Math.log(1 + nDocs);
310
362
  const matchedOf = new Map();
363
+ // Parallel to matchedOf, but over NAME+PATH only (body dropped) — the
364
+ // match-strength signal exported as `coverageStrong`.
365
+ const matchedStrongOf = new Map();
311
366
  // ── Concepts (prose nodes; not in the wiring graph) ──
312
367
  const conceptHits = [];
313
368
  let maxConcept = 0;
@@ -324,6 +379,7 @@ function lexical(query, corpus, limit, graphRank) {
324
379
  score: total,
325
380
  };
326
381
  matchedOf.set(hit, matchedIdfShare(q, [name, body], idf, dfltIdf));
382
+ matchedStrongOf.set(hit, matchedIdfShare(q, [name], idf, dfltIdf)); // concepts have no path field
327
383
  conceptHits.push(hit);
328
384
  }
329
385
  }
@@ -335,56 +391,159 @@ function lexical(query, corpus, limit, graphRank) {
335
391
  s += v;
336
392
  return s;
337
393
  };
338
- const avgBodyLen = askIndex
339
- ? askIndex.avgBodyLen
340
- : symbolDocs.length
341
- ? symbolDocs.reduce((a, d) => a + bodyLen(d.body), 0) / symbolDocs.length
342
- : 0;
343
- const lex = new Map(); // node id → lexical score (>0 only)
344
- let maxLex = 0;
345
- for (const { n, name, path, body } of symbolDocs) {
346
- // Name and path are short identifiers → plain idf-weighted overlap; the body
347
- // is length-normalized via BM25 so long definitions don't win on bulk.
348
- const total = score(q, name, idf) * 3 +
349
- score(q, path, idf) * 2 +
350
- bm25(q, body, idf, bodyLen(body), avgBodyLen);
351
- if (total > 0) {
352
- lex.set(n.id, total);
353
- maxLex = Math.max(maxLex, total);
354
- }
355
- }
356
- // ── Graph-rank: let connectivity to the matched set reorder and rescue ──
357
- const pr = graphRank && graph && lex.size > 0
358
- ? personalizedPageRank(graph, lex)
359
- : new Map();
360
- // Candidate symbols = everything the query word-matched, plus nodes the walk
361
- // found strongly central even without a word match (RESCUE_FLOOR).
362
- const candidates = new Set(lex.keys());
363
- for (const [id, p] of pr)
364
- if (p >= RESCUE_FLOOR)
365
- candidates.add(id);
366
394
  const docsById = new Map(symbolDocs.map((d) => [d.n.id, d]));
367
395
  const symbolHits = [];
368
- for (const id of candidates) {
369
- const n = byId.get(id);
370
- if (!n)
371
- continue;
372
- const lexN = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
373
- const blended = lexN + GRAPH_WEIGHT * (pr.get(id) ?? 0);
374
- if (blended <= 0)
375
- continue;
376
- const hit = {
377
- kind: "symbol",
378
- title: `${n.name} · ${n.kind}`,
379
- // A file node points at the whole file (locator, no span) so `--source`
380
- // never inlines an entire file; symbol nodes keep their exact span.
381
- pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
382
- snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
383
- score: blended,
384
- };
385
- const d = docsById.get(id);
386
- matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
387
- symbolHits.push(hit);
396
+ // The single-vs-multi-scope branch keys on `scopesOfGraph` ONLY: one scope
397
+ // (or no graph) takes the existing path below completely untouched — that
398
+ // branch is a byte-level regression guarantee for single-scope repos.
399
+ const scopes = graph ? scopesOfGraph(graph) : null;
400
+ let scopeMeta;
401
+ if (scopes && scopes.length > 1) {
402
+ // ── Multi-scope repo: rank each sub-project against its OWN corpus (its
403
+ // IDF, its BM25 length prior, its subgraph walk), then fuse the per-scope
404
+ // orderings by reciprocal rank — the big scope can't drown the small one.
405
+ // Same scoring functions and blend as the single-scope path, just fed
406
+ // per-scope inputs; the fusion itself lives in fuse.ts. ──
407
+ const byScope = new Map();
408
+ for (const d of symbolDocs) {
409
+ const s = scopeOf(d.n.path, scopes).prefix;
410
+ const list = byScope.get(s);
411
+ if (list)
412
+ list.push(d);
413
+ else
414
+ byScope.set(s, [d]);
415
+ }
416
+ const idfOf = new Map();
417
+ const fusion = rankScopesAndFuse([...byScope.keys()].sort(), {
418
+ lex: (s) => {
419
+ const docs = byScope.get(s);
420
+ // Per-scope IDF/BM25 stats: concepts are repo-level so their bags
421
+ // fold in everywhere, symbol bags come from this scope only. (The
422
+ // build sidecar's df/avgBodyLen are corpus-global, so multi-scope
423
+ // always computes these live — from the sidecar's own token maps.)
424
+ const sIdf = computeIdf([
425
+ ...conceptBags,
426
+ ...docs.map((d) => new Set([...d.name.keys(), ...d.path.keys(), ...d.body.keys()])),
427
+ ]);
428
+ idfOf.set(s, { idf: sIdf, dflt: Math.log(1 + conceptBags.length + docs.length) });
429
+ const avg = docs.reduce((a, d) => a + bodyLen(d.body), 0) / docs.length;
430
+ const out = new Map();
431
+ for (const { n, name, path, body } of docs) {
432
+ const total = score(q, name, sIdf) * 3 +
433
+ score(q, path, sIdf) * 2 +
434
+ bm25(q, body, sIdf, bodyLen(body), avg);
435
+ if (total > 0)
436
+ out.set(n.id, total);
437
+ }
438
+ return out;
439
+ },
440
+ // The participation gate's match-STRENGTH signal, evaluated once per
441
+ // scope on that scope's raw-lex top doc — same `strongShare`/
442
+ // `matchedIdfShare` functions and per-scope idf `lex` (above) just
443
+ // computed, so this reads the SAME strength `federateAsk` gates on
444
+ // (see fuse.ts's STRONG_FLOOR/HIGH_FLOOR).
445
+ strength: (s, id) => {
446
+ const d = docsById.get(id);
447
+ const si = idfOf.get(s);
448
+ if (!d || !si)
449
+ return { coverage: 0, coverageStrong: 0 };
450
+ return {
451
+ coverage: matchedIdfShare(q, [d.name, d.path, d.body], si.idf, si.dflt),
452
+ coverageStrong: strongShare(q, d.n, d, si.idf, si.dflt),
453
+ };
454
+ },
455
+ walk: (s, seeds) => graphRank
456
+ ? personalizedPageRank(graph, seeds, {
457
+ nodeFilter: (id) => {
458
+ const path = byId.get(id)?.path ?? "";
459
+ return scopeOf(path, scopes).prefix === s && (!inPrefix || pathUnderPrefix(path, inPrefix));
460
+ },
461
+ })
462
+ : new Map(),
463
+ }, GRAPH_WEIGHT, RESCUE_FLOOR);
464
+ for (const rd of fusion.ranked) {
465
+ const n = byId.get(rd.id);
466
+ if (!n)
467
+ continue;
468
+ const hit = {
469
+ kind: "symbol",
470
+ title: `${n.name} · ${n.kind}`,
471
+ pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
472
+ snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
473
+ score: rd.score,
474
+ scope: rd.scope,
475
+ };
476
+ const d = docsById.get(rd.id);
477
+ const si = idfOf.get(rd.scope);
478
+ matchedOf.set(hit, d && si ? matchedIdfShare(q, [d.name, d.path, d.body], si.idf, si.dflt) : 0);
479
+ matchedStrongOf.set(hit, d && si ? strongShare(q, n, d, si.idf, si.dflt) : 0);
480
+ symbolHits.push(hit);
481
+ }
482
+ // Label + footer only when federation actually happened (or a scope was
483
+ // gated out and is worth mentioning) — a query matching one scope of a
484
+ // multi-scope repo reads exactly like today.
485
+ if (fusion.federated.length > 1 || fusion.alsoMatched.length > 0)
486
+ scopeMeta = { federated: fusion.federated, alsoMatched: fusion.alsoMatched };
487
+ }
488
+ else {
489
+ // Single-scope: the pre-scopes ranking path, byte-identical output when
490
+ // `--in` is absent (Task 3's regression guarantee: `askIndex && !inPrefix`
491
+ // reduces to plain `askIndex` when `inPrefix` is undefined, so this line is
492
+ // a no-op change for every non-`--in` caller). Under `--in`, `askIndex`'s
493
+ // `avgBodyLen` is corpus-global and must not be reused for the filtered set.
494
+ const avgBodyLen = askIndex && !inPrefix
495
+ ? askIndex.avgBodyLen
496
+ : symbolDocs.length
497
+ ? symbolDocs.reduce((a, d) => a + bodyLen(d.body), 0) / symbolDocs.length
498
+ : 0;
499
+ const lex = new Map(); // node id → lexical score (>0 only)
500
+ let maxLex = 0;
501
+ for (const { n, name, path, body } of symbolDocs) {
502
+ // Name and path are short identifiers → plain idf-weighted overlap; the body
503
+ // is length-normalized via BM25 so long definitions don't win on bulk.
504
+ const total = score(q, name, idf) * 3 +
505
+ score(q, path, idf) * 2 +
506
+ bm25(q, body, idf, bodyLen(body), avgBodyLen);
507
+ if (total > 0) {
508
+ lex.set(n.id, total);
509
+ maxLex = Math.max(maxLex, total);
510
+ }
511
+ }
512
+ // ── Graph-rank: let connectivity to the matched set reorder and rescue ──
513
+ // `--in` restricts the walk itself, not just the seeds: without a nodeFilter
514
+ // here, a RESCUE_FLOOR-qualifying neighbour OUTSIDE the prefix could still
515
+ // surface, defeating the "filters before scoring" contract.
516
+ const pr = graphRank && graph && lex.size > 0
517
+ ? personalizedPageRank(graph, lex, inPrefix ? { nodeFilter: (id) => pathUnderPrefix(byId.get(id)?.path ?? "", inPrefix) } : {})
518
+ : new Map();
519
+ // Candidate symbols = everything the query word-matched, plus nodes the walk
520
+ // found strongly central even without a word match (RESCUE_FLOOR).
521
+ const candidates = new Set(lex.keys());
522
+ for (const [id, p] of pr)
523
+ if (p >= RESCUE_FLOOR)
524
+ candidates.add(id);
525
+ for (const id of candidates) {
526
+ const n = byId.get(id);
527
+ if (!n)
528
+ continue;
529
+ const lexN = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
530
+ const blended = lexN + GRAPH_WEIGHT * (pr.get(id) ?? 0);
531
+ if (blended <= 0)
532
+ continue;
533
+ const hit = {
534
+ kind: "symbol",
535
+ title: `${n.name} · ${n.kind}`,
536
+ // A file node points at the whole file (locator, no span) so `--source`
537
+ // never inlines an entire file; symbol nodes keep their exact span.
538
+ pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
539
+ snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
540
+ score: blended,
541
+ };
542
+ const d = docsById.get(id);
543
+ matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
544
+ matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
545
+ symbolHits.push(hit);
546
+ }
388
547
  }
389
548
  // Concepts and symbols live on comparable 0..~1.5 scales so they merge fairly:
390
549
  // concept scores are normalized to their own max, symbol scores are the
@@ -397,8 +556,14 @@ function lexical(query, corpus, limit, graphRank) {
397
556
  query,
398
557
  mode: scored.length ? "lexical" : "empty",
399
558
  hits: scored.slice(0, limit),
559
+ scopes: scopeMeta,
400
560
  coverage: scored.length && q.size > 0 ? matchedOf.get(scored[0]) ?? 0 : undefined,
401
- note: scored.length ? undefined : "no matching nodes — try different words, or `graft build` if graft/ is empty",
561
+ coverageStrong: scored.length && q.size > 0 ? matchedStrongOf.get(scored[0]) ?? 0 : undefined,
562
+ // Zero hits on a genuinely multi-scope graph names the scopes that exist,
563
+ // so a query that missed everywhere still tells the caller where to look.
564
+ note: scored.length
565
+ ? undefined
566
+ : `no matching nodes — try different words, or \`graft build\` if graft/ is empty${scopesHereClause(scopes ?? [])}`,
402
567
  };
403
568
  }
404
569
  /** Parse a `path:Lx-Ly` pointer into its parts, or null if it isn't one
@@ -485,14 +650,26 @@ export function ask(dir, query, opts = {}) {
485
650
  const limit = opts.limit ?? 8;
486
651
  const corpus = loadCorpus(outDir);
487
652
  const graphRank = opts.graphRank ?? true;
653
+ // Trailing slash(es) stripped ONCE, up front, and this normalized value used
654
+ // everywhere below (validation, filtering, structural). `ask --in frontend/`
655
+ // must work exactly like `--in frontend` — `scopeLabel`/the footer's own
656
+ // `--in <scope>/` suggestion always includes the slash, so rejecting it
657
+ // would make the tool's own suggested next command fail.
658
+ const inPrefix = opts.in ? opts.in.replace(/\/+$/, "") : undefined;
659
+ // `--in` validated up front, before any mode runs: a prefix matching no
660
+ // indexed node is a caller mistake (typo, wrong sub-project), not a
661
+ // legitimate zero-hit query, so it's a loud error rather than an empty pack.
662
+ if (inPrefix && corpus.graph && !corpus.graph.nodes.some((n) => pathUnderPrefix(n.path, inPrefix))) {
663
+ throw new Error(`nothing indexed under "${inPrefix}/"${scopesHereClause(scopesOfGraph(corpus.graph))} (or any path prefix)`);
664
+ }
488
665
  let result;
489
666
  if (corpus.graph) {
490
- const outcome = structural(query, corpus.graph, limit);
667
+ const outcome = structural(query, corpus.graph, limit, inPrefix);
491
668
  if (outcome && "result" in outcome) {
492
669
  result = outcome.result;
493
670
  }
494
671
  else {
495
- result = lexical(query, corpus, limit, graphRank);
672
+ result = lexical(query, corpus, limit, graphRank, inPrefix);
496
673
  // A structural-intent query that couldn't be answered structurally still
497
674
  // gets a prominent note on the lexical fallback result — never silent.
498
675
  if (outcome && "fallthroughNote" in outcome) {
@@ -501,7 +678,7 @@ export function ask(dir, query, opts = {}) {
501
678
  }
502
679
  }
503
680
  else {
504
- result = lexical(query, corpus, limit, graphRank);
681
+ result = lexical(query, corpus, limit, graphRank, inPrefix);
505
682
  }
506
683
  if (opts.source) {
507
684
  inlineSource(root, result.hits, corpus.graph, opts.full ?? false);
@@ -592,7 +769,10 @@ export function formatAsk(r) {
592
769
  }
593
770
  else {
594
771
  r.hits.forEach((h, i) => {
595
- lines.push(`${i + 1}. ${h.title} [${h.kind}]`);
772
+ // Multi-scope results label each hit with its sub-project (root scope
773
+ // "" stays unlabeled) so a reviewer can tell WHICH codebase answered.
774
+ const label = r.scopes && h.scope ? `[${h.scope}/] ` : "";
775
+ lines.push(`${i + 1}. ${label}${h.title} [${h.kind}]`);
596
776
  lines.push(` ${h.pointer}`);
597
777
  if (h.snippet)
598
778
  lines.push(` ${h.snippet}`);
@@ -602,6 +782,7 @@ export function formatAsk(r) {
602
782
  lines.push("", "```", h.code, "```");
603
783
  lines.push("");
604
784
  });
785
+ lines.push(...scopeFooterLines(r));
605
786
  }
606
787
  const body = lines.join("\n").trimEnd();
607
788
  return body + askSavingsFooter(r, body) + escalationNudge(r) + "\n";
@@ -634,4 +815,24 @@ function askSavingsFooter(r, body) {
634
815
  `${pack.toLocaleString()} tok vs reading the ${r.saved.files} source file(s) whole ≈ ` +
635
816
  `${base.toLocaleString()} tok. Estimate (baseline = those files read in full).`);
636
817
  }
818
+ /** Multi-scope footer: answers a reviewer's two questions — "which
819
+ * sub-projects did this pack come from?" (`matched in:`, displayed-hit counts,
820
+ * biggest first) and "did anything else match that I should know about?"
821
+ * (`also matched:` for scopes gated out of fusion, with the flag to narrow).
822
+ * Empty (zero output) whenever the result isn't a federated multi-scope one. */
823
+ function scopeFooterLines(r) {
824
+ if (!r.scopes)
825
+ return [];
826
+ const perScope = new Map();
827
+ for (const h of r.hits)
828
+ if (h.scope !== undefined)
829
+ perScope.set(h.scope, (perScope.get(h.scope) ?? 0) + 1);
830
+ const parts = [...perScope]
831
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
832
+ .map(([s, c]) => `${scopeLabel(s)} (${c})`);
833
+ const out = parts.length ? [`matched in: ${parts.join(" · ")}`] : [];
834
+ for (const m of r.scopes.alsoMatched)
835
+ out.push(`also matched: ${scopeLabel(m.scope)} — narrow with --in ${scopeLabel(m.scope)}`);
836
+ return out;
837
+ }
637
838
  //# sourceMappingURL=ask.js.map