@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/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;
|
package/dist/ask/ask.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|