@nanonets/graft 0.13.0 → 0.16.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 (308) hide show
  1. package/README.md +26 -10
  2. package/dist/ai/crux.d.ts +14 -1
  3. package/dist/ai/crux.d.ts.map +1 -1
  4. package/dist/ai/crux.js +39 -51
  5. package/dist/ai/crux.js.map +1 -1
  6. package/dist/ai/failure.d.ts +13 -2
  7. package/dist/ai/failure.d.ts.map +1 -1
  8. package/dist/ai/failure.js +16 -4
  9. package/dist/ai/failure.js.map +1 -1
  10. package/dist/ai/llm/factory.d.ts +10 -1
  11. package/dist/ai/llm/factory.d.ts.map +1 -1
  12. package/dist/ai/llm/factory.js +16 -0
  13. package/dist/ai/llm/factory.js.map +1 -1
  14. package/dist/ai/llm/litellm.d.ts +34 -0
  15. package/dist/ai/llm/litellm.d.ts.map +1 -0
  16. package/dist/ai/llm/litellm.js +45 -0
  17. package/dist/ai/llm/litellm.js.map +1 -0
  18. package/dist/ai/llm/openai.d.ts.map +1 -1
  19. package/dist/ai/llm/openai.js +66 -7
  20. package/dist/ai/llm/openai.js.map +1 -1
  21. package/dist/ai/llm/orcarouter.d.ts +34 -0
  22. package/dist/ai/llm/orcarouter.d.ts.map +1 -0
  23. package/dist/ai/llm/orcarouter.js +45 -0
  24. package/dist/ai/llm/orcarouter.js.map +1 -0
  25. package/dist/ai/llm/recover-tool.d.ts +19 -0
  26. package/dist/ai/llm/recover-tool.d.ts.map +1 -0
  27. package/dist/ai/llm/recover-tool.js +109 -0
  28. package/dist/ai/llm/recover-tool.js.map +1 -0
  29. package/dist/ai/providers.d.ts.map +1 -1
  30. package/dist/ai/providers.js +15 -3
  31. package/dist/ai/providers.js.map +1 -1
  32. package/dist/ai/synthesize.d.ts.map +1 -1
  33. package/dist/ai/synthesize.js +19 -5
  34. package/dist/ai/synthesize.js.map +1 -1
  35. package/dist/app/checkout.d.ts +28 -0
  36. package/dist/app/checkout.d.ts.map +1 -0
  37. package/dist/app/checkout.js +100 -0
  38. package/dist/app/checkout.js.map +1 -0
  39. package/dist/app/events.d.ts +37 -0
  40. package/dist/app/events.d.ts.map +1 -0
  41. package/dist/app/events.js +59 -0
  42. package/dist/app/events.js.map +1 -0
  43. package/dist/app/identity.d.ts +54 -0
  44. package/dist/app/identity.d.ts.map +1 -0
  45. package/dist/app/identity.js +96 -0
  46. package/dist/app/identity.js.map +1 -0
  47. package/dist/app/main.d.ts +2 -0
  48. package/dist/app/main.d.ts.map +1 -0
  49. package/dist/app/main.js +56 -0
  50. package/dist/app/main.js.map +1 -0
  51. package/dist/app/pages.d.ts +39 -0
  52. package/dist/app/pages.d.ts.map +1 -0
  53. package/dist/app/pages.js +86 -0
  54. package/dist/app/pages.js.map +1 -0
  55. package/dist/app/queue.d.ts +40 -0
  56. package/dist/app/queue.d.ts.map +1 -0
  57. package/dist/app/queue.js +68 -0
  58. package/dist/app/queue.js.map +1 -0
  59. package/dist/app/review.d.ts +23 -0
  60. package/dist/app/review.d.ts.map +1 -0
  61. package/dist/app/review.js +139 -0
  62. package/dist/app/review.js.map +1 -0
  63. package/dist/app/server.d.ts +40 -0
  64. package/dist/app/server.d.ts.map +1 -0
  65. package/dist/app/server.js +117 -0
  66. package/dist/app/server.js.map +1 -0
  67. package/dist/ask/ask.d.ts +35 -0
  68. package/dist/ask/ask.d.ts.map +1 -1
  69. package/dist/ask/ask.js +427 -63
  70. package/dist/ask/ask.js.map +1 -1
  71. package/dist/ask/file-rank.d.ts +72 -0
  72. package/dist/ask/file-rank.d.ts.map +1 -0
  73. package/dist/ask/file-rank.js +160 -0
  74. package/dist/ask/file-rank.js.map +1 -0
  75. package/dist/ask/file-selection.d.ts +4 -0
  76. package/dist/ask/file-selection.d.ts.map +1 -1
  77. package/dist/ask/file-selection.js +25 -0
  78. package/dist/ask/file-selection.js.map +1 -1
  79. package/dist/ask/fuse.d.ts +16 -0
  80. package/dist/ask/fuse.d.ts.map +1 -1
  81. package/dist/ask/fuse.js +21 -4
  82. package/dist/ask/fuse.js.map +1 -1
  83. package/dist/ask/graphrank.d.ts +18 -0
  84. package/dist/ask/graphrank.d.ts.map +1 -1
  85. package/dist/ask/graphrank.js +65 -19
  86. package/dist/ask/graphrank.js.map +1 -1
  87. package/dist/blast/blast-cli.d.ts +5 -0
  88. package/dist/blast/blast-cli.d.ts.map +1 -1
  89. package/dist/blast/blast-cli.js +21 -30
  90. package/dist/blast/blast-cli.js.map +1 -1
  91. package/dist/blast/blast.d.ts +9 -0
  92. package/dist/blast/blast.d.ts.map +1 -1
  93. package/dist/blast/blast.js.map +1 -1
  94. package/dist/blast/name.d.ts +13 -0
  95. package/dist/blast/name.d.ts.map +1 -1
  96. package/dist/blast/name.js +36 -0
  97. package/dist/blast/name.js.map +1 -1
  98. package/dist/blast/owners.d.ts +85 -0
  99. package/dist/blast/owners.d.ts.map +1 -0
  100. package/dist/blast/owners.js +272 -0
  101. package/dist/blast/owners.js.map +1 -0
  102. package/dist/blast/render.d.ts.map +1 -1
  103. package/dist/blast/render.js +105 -0
  104. package/dist/blast/render.js.map +1 -1
  105. package/dist/blast/viz.d.ts.map +1 -1
  106. package/dist/blast/viz.js +10 -0
  107. package/dist/blast/viz.js.map +1 -1
  108. package/dist/claude/format.js +1 -1
  109. package/dist/claude/format.js.map +1 -1
  110. package/dist/claude/hooks.d.ts.map +1 -1
  111. package/dist/claude/hooks.js +132 -22
  112. package/dist/claude/hooks.js.map +1 -1
  113. package/dist/claude/init.d.ts +1 -0
  114. package/dist/claude/init.d.ts.map +1 -1
  115. package/dist/claude/init.js +1 -1
  116. package/dist/claude/init.js.map +1 -1
  117. package/dist/claude/session-metrics.d.ts +63 -0
  118. package/dist/claude/session-metrics.d.ts.map +1 -0
  119. package/dist/claude/session-metrics.js +181 -0
  120. package/dist/claude/session-metrics.js.map +1 -0
  121. package/dist/claude/settings-merge.d.ts +18 -1
  122. package/dist/claude/settings-merge.d.ts.map +1 -1
  123. package/dist/claude/settings-merge.js +70 -27
  124. package/dist/claude/settings-merge.js.map +1 -1
  125. package/dist/claude/state.d.ts +25 -0
  126. package/dist/claude/state.d.ts.map +1 -1
  127. package/dist/claude/state.js +14 -1
  128. package/dist/claude/state.js.map +1 -1
  129. package/dist/claude/statusline.d.ts +5 -3
  130. package/dist/claude/statusline.d.ts.map +1 -1
  131. package/dist/claude/statusline.js +6 -4
  132. package/dist/claude/statusline.js.map +1 -1
  133. package/dist/claude/tally.d.ts +23 -0
  134. package/dist/claude/tally.d.ts.map +1 -0
  135. package/dist/claude/tally.js +138 -0
  136. package/dist/claude/tally.js.map +1 -0
  137. package/dist/cli.js +145 -4
  138. package/dist/cli.js.map +1 -1
  139. package/dist/context/build.d.ts +11 -0
  140. package/dist/context/build.d.ts.map +1 -1
  141. package/dist/context/build.js +49 -9
  142. package/dist/context/build.js.map +1 -1
  143. package/dist/context/check.d.ts.map +1 -1
  144. package/dist/context/check.js +5 -12
  145. package/dist/context/check.js.map +1 -1
  146. package/dist/context/node-file.d.ts +2 -1
  147. package/dist/context/node-file.d.ts.map +1 -1
  148. package/dist/context/node-file.js +70 -3
  149. package/dist/context/node-file.js.map +1 -1
  150. package/dist/context/savings.d.ts +8 -0
  151. package/dist/context/savings.d.ts.map +1 -1
  152. package/dist/context/savings.js +14 -0
  153. package/dist/context/savings.js.map +1 -1
  154. package/dist/engine.d.ts +4 -0
  155. package/dist/engine.d.ts.map +1 -1
  156. package/dist/engine.js +2 -0
  157. package/dist/engine.js.map +1 -1
  158. package/dist/graph/bindings.d.ts +6 -5
  159. package/dist/graph/bindings.d.ts.map +1 -1
  160. package/dist/graph/bindings.js +117 -5
  161. package/dist/graph/bindings.js.map +1 -1
  162. package/dist/graph/build.d.ts +4 -0
  163. package/dist/graph/build.d.ts.map +1 -1
  164. package/dist/graph/build.js +7 -4
  165. package/dist/graph/build.js.map +1 -1
  166. package/dist/graph/cards.d.ts +4 -3
  167. package/dist/graph/cards.d.ts.map +1 -1
  168. package/dist/graph/cards.js +34 -6
  169. package/dist/graph/cards.js.map +1 -1
  170. package/dist/graph/check.d.ts.map +1 -1
  171. package/dist/graph/check.js +36 -6
  172. package/dist/graph/check.js.map +1 -1
  173. package/dist/graph/enrich.d.ts +4 -4
  174. package/dist/graph/enrich.d.ts.map +1 -1
  175. package/dist/graph/enrich.js +32 -6
  176. package/dist/graph/enrich.js.map +1 -1
  177. package/dist/graph/extract.d.ts +9 -2
  178. package/dist/graph/extract.d.ts.map +1 -1
  179. package/dist/graph/extract.js +356 -12
  180. package/dist/graph/extract.js.map +1 -1
  181. package/dist/graph/fingerprint.d.ts +6 -1
  182. package/dist/graph/fingerprint.d.ts.map +1 -1
  183. package/dist/graph/fingerprint.js +7 -3
  184. package/dist/graph/fingerprint.js.map +1 -1
  185. package/dist/graph/generic.d.ts +10 -0
  186. package/dist/graph/generic.d.ts.map +1 -1
  187. package/dist/graph/generic.js +20 -3
  188. package/dist/graph/generic.js.map +1 -1
  189. package/dist/graph/invariants.d.ts +3 -2
  190. package/dist/graph/invariants.d.ts.map +1 -1
  191. package/dist/graph/invariants.js +6 -3
  192. package/dist/graph/invariants.js.map +1 -1
  193. package/dist/graph/refresh.d.ts.map +1 -1
  194. package/dist/graph/refresh.js +6 -2
  195. package/dist/graph/refresh.js.map +1 -1
  196. package/dist/graph/resolve.d.ts +1 -1
  197. package/dist/graph/resolve.d.ts.map +1 -1
  198. package/dist/graph/resolve.js +106 -5
  199. package/dist/graph/resolve.js.map +1 -1
  200. package/dist/graph/scopes.d.ts.map +1 -1
  201. package/dist/graph/scopes.js +2 -1
  202. package/dist/graph/scopes.js.map +1 -1
  203. package/dist/graph/source-files.d.ts +7 -2
  204. package/dist/graph/source-files.d.ts.map +1 -1
  205. package/dist/graph/source-files.js +19 -6
  206. package/dist/graph/source-files.js.map +1 -1
  207. package/dist/graph/workspace-cli.d.ts +2 -0
  208. package/dist/graph/workspace-cli.d.ts.map +1 -1
  209. package/dist/graph/workspace-cli.js +3 -0
  210. package/dist/graph/workspace-cli.js.map +1 -1
  211. package/dist/graph/workspace.d.ts +2 -0
  212. package/dist/graph/workspace.d.ts.map +1 -1
  213. package/dist/graph/workspace.js +173 -6
  214. package/dist/graph/workspace.js.map +1 -1
  215. package/dist/hosts/antigravity.d.ts +2 -2
  216. package/dist/hosts/antigravity.d.ts.map +1 -1
  217. package/dist/hosts/codex-hooks.d.ts +2 -6
  218. package/dist/hosts/codex-hooks.d.ts.map +1 -1
  219. package/dist/hosts/codex-hooks.js +7 -30
  220. package/dist/hosts/codex-hooks.js.map +1 -1
  221. package/dist/hosts/config-write.d.ts +36 -0
  222. package/dist/hosts/config-write.d.ts.map +1 -0
  223. package/dist/hosts/config-write.js +63 -0
  224. package/dist/hosts/config-write.js.map +1 -0
  225. package/dist/hosts/cursor-hooks.d.ts +18 -0
  226. package/dist/hosts/cursor-hooks.d.ts.map +1 -0
  227. package/dist/hosts/cursor-hooks.js +100 -0
  228. package/dist/hosts/cursor-hooks.js.map +1 -0
  229. package/dist/hosts/init.d.ts +2 -2
  230. package/dist/hosts/init.d.ts.map +1 -1
  231. package/dist/hosts/init.js +8 -2
  232. package/dist/hosts/init.js.map +1 -1
  233. package/dist/hosts/mcp-config.d.ts +15 -5
  234. package/dist/hosts/mcp-config.d.ts.map +1 -1
  235. package/dist/hosts/mcp-config.js +51 -15
  236. package/dist/hosts/mcp-config.js.map +1 -1
  237. package/dist/hosts/plan.d.ts.map +1 -1
  238. package/dist/hosts/plan.js +2 -0
  239. package/dist/hosts/plan.js.map +1 -1
  240. package/dist/hosts/retract.d.ts +47 -0
  241. package/dist/hosts/retract.d.ts.map +1 -0
  242. package/dist/hosts/retract.js +473 -0
  243. package/dist/hosts/retract.js.map +1 -0
  244. package/dist/index.d.ts +2 -0
  245. package/dist/index.d.ts.map +1 -1
  246. package/dist/index.js +2 -0
  247. package/dist/index.js.map +1 -1
  248. package/dist/ingest/fs.d.ts +39 -5
  249. package/dist/ingest/fs.d.ts.map +1 -1
  250. package/dist/ingest/fs.js +104 -11
  251. package/dist/ingest/fs.js.map +1 -1
  252. package/dist/mcp/tool-names.d.ts +20 -0
  253. package/dist/mcp/tool-names.d.ts.map +1 -0
  254. package/dist/mcp/tool-names.js +36 -0
  255. package/dist/mcp/tool-names.js.map +1 -0
  256. package/dist/mcp/tools.d.ts +11 -2
  257. package/dist/mcp/tools.d.ts.map +1 -1
  258. package/dist/mcp/tools.js +9 -21
  259. package/dist/mcp/tools.js.map +1 -1
  260. package/dist/telemetry/contract.d.ts.map +1 -1
  261. package/dist/telemetry/contract.js +17 -3
  262. package/dist/telemetry/contract.js.map +1 -1
  263. package/dist/telemetry/flush.d.ts.map +1 -1
  264. package/dist/telemetry/flush.js +1 -0
  265. package/dist/telemetry/flush.js.map +1 -1
  266. package/dist/telemetry/identity.d.ts +7 -0
  267. package/dist/telemetry/identity.d.ts.map +1 -1
  268. package/dist/telemetry/identity.js.map +1 -1
  269. package/dist/telemetry/index.d.ts +2 -2
  270. package/dist/telemetry/index.d.ts.map +1 -1
  271. package/dist/telemetry/index.js +2 -2
  272. package/dist/telemetry/index.js.map +1 -1
  273. package/dist/telemetry/sessions.d.ts +19 -2
  274. package/dist/telemetry/sessions.d.ts.map +1 -1
  275. package/dist/telemetry/sessions.js +57 -34
  276. package/dist/telemetry/sessions.js.map +1 -1
  277. package/dist/telemetry/track.d.ts +22 -3
  278. package/dist/telemetry/track.d.ts.map +1 -1
  279. package/dist/telemetry/track.js +34 -5
  280. package/dist/telemetry/track.js.map +1 -1
  281. package/dist/upkeep-run.js +1 -1
  282. package/dist/upkeep-run.js.map +1 -1
  283. package/dist/upkeep.d.ts +7 -4
  284. package/dist/upkeep.d.ts.map +1 -1
  285. package/dist/upkeep.js +2 -1
  286. package/dist/upkeep.js.map +1 -1
  287. package/dist/util/state.d.ts +12 -2
  288. package/dist/util/state.d.ts.map +1 -1
  289. package/dist/util/state.js +4 -0
  290. package/dist/util/state.js.map +1 -1
  291. package/dist/viewer/app.js +2 -1
  292. package/dist/viewer/style.css +14 -0
  293. package/dist/viz/assemble.d.ts +17 -0
  294. package/dist/viz/assemble.d.ts.map +1 -1
  295. package/dist/viz/assemble.js.map +1 -1
  296. package/package.json +12 -4
  297. package/scripts/graph-quality.mjs +3 -3
  298. package/scripts/postinstall.mjs +42 -3
  299. package/scripts/run-tests.mjs +14 -0
  300. package/scripts/tally-audit.mjs +140 -0
  301. package/dist/ai/llm/claude-cli.d.ts +0 -60
  302. package/dist/ai/llm/claude-cli.d.ts.map +0 -1
  303. package/dist/ai/llm/claude-cli.js +0 -417
  304. package/dist/ai/llm/claude-cli.js.map +0 -1
  305. package/dist/util/num.d.ts +0 -3
  306. package/dist/util/num.d.ts.map +0 -1
  307. package/dist/util/num.js +0 -20
  308. package/dist/util/num.js.map +0 -1
package/dist/ask/ask.js CHANGED
@@ -22,9 +22,10 @@ import { loadGraphCached, loadAskIndexCached } from "../graph/load.js";
22
22
  import { assertPrefixIndexed, pathUnderPrefix, scopeLabel, scopeOf, scopesHereClause, scopesOfGraph, } from "../graph/scopes.js";
23
23
  import { normalizePathPrefix } from "../util/paths.js";
24
24
  import { resolveSymbol } from "../graph/traverse.js";
25
- import { rankScopesAndFuse } from "./fuse.js";
26
- import { fileFirstRoundRobin } from "./file-selection.js";
27
- import { personalizedPageRank } from "./graphrank.js";
25
+ import { combineComparableScopes, rankScopesAndFuse, } from "./fuse.js";
26
+ import { rankFilesBounded, } from "./file-rank.js";
27
+ import { fileFirstRoundRobin, roundRobinQueues } from "./file-selection.js";
28
+ import { personalizedPageRank, personalizedPageRankPrepared, preparePageRankPartitions, } from "./graphrank.js";
28
29
  import { readSourceFile } from "../util/source.js";
29
30
  import { counts, tokenize } from "./index-file.js";
30
31
  /** Max source lines to inline per hit — a definition longer than this is
@@ -141,6 +142,25 @@ function bm25(query, doc, idf, dl, avgdl) {
141
142
  }
142
143
  return s;
143
144
  }
145
+ /** Exact query terms awarded by the existing lexical fields. Bounded ranking uses
146
+ * presence only: raw name/path/BM25 magnitudes cannot enter the file residual. */
147
+ function matchedLexicalTerms(query, name, path, body) {
148
+ const terms = new Set();
149
+ for (const term of query.keys())
150
+ if (name.has(term) || path.has(term) || body.has(term))
151
+ terms.add(term);
152
+ return terms;
153
+ }
154
+ /** NAME-only query terms behind file-level strong coverage. This intentionally
155
+ * mirrors {@link strongShare}'s plural folding while excluding file nodes at
156
+ * the call site. */
157
+ function matchedStrongTerms(query, name) {
158
+ const terms = new Set();
159
+ for (const term of query.keys())
160
+ if (hasTerm(name, term))
161
+ terms.add(term);
162
+ return terms;
163
+ }
144
164
  // ── Structural intent ──────────────────────────────────────────────────────
145
165
  const INCOMING = /\b(caller|callers|calls?\s+into|who\s+calls|what\s+calls|called\s+by|used\s+by|uses)\b/;
146
166
  const OUTGOING = /\b(callee|callees|what\s+does\s+\w+\s+call|calls\s+what|imports?|depends\s+on)\b/;
@@ -283,7 +303,18 @@ function matchedIdfShare(q, fields, idf, dfltIdf) {
283
303
  }
284
304
  return total > 0 ? matched / total : 0;
285
305
  }
286
- function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
306
+ /** Normalized query-term IDF mass used by file-level union coverage. Presence
307
+ * comes from the exact term-contribution maps, so plural folding cannot
308
+ * manufacture evidence that the lexical scorer did not award. */
309
+ function normalizedQueryWeights(q, idf, dfltIdf) {
310
+ let total = 0;
311
+ for (const t of q.keys())
312
+ total += idf.get(t) ?? dfltIdf;
313
+ if (total <= 0)
314
+ return new Map();
315
+ return new Map([...q.keys()].map((term) => [term, (idf.get(term) ?? dfltIdf) / total]));
316
+ }
317
+ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true, fileComplement = false, fileTopLock = false, includeRankingMetadata = false) {
287
318
  // Binary query terms: a word repeated in a pasted issue body counts once, so a
288
319
  // ranty description can't linearly amplify an incidental word.
289
320
  const q = new Map([...counts(tokenize(query)).keys()].map((t) => [t, 1]));
@@ -421,6 +452,34 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
421
452
  };
422
453
  const docsById = new Map(symbolDocs.map((d) => [d.n.id, d]));
423
454
  const symbolHits = [];
455
+ const baselineSymbolHits = [];
456
+ const baselineHitById = new Map();
457
+ const fileQueueHitsByGroup = new Map();
458
+ const baselineQueueHitsByGroup = new Map();
459
+ const fileGroups = [];
460
+ const needsFileQueues = fileComplement && (fileTopLock || includeRankingMetadata);
461
+ const makeSymbolHit = (id, hitScore, scope) => {
462
+ const n = byId.get(id);
463
+ if (!n)
464
+ return null;
465
+ const hit = {
466
+ kind: "symbol",
467
+ title: `${n.name} · ${n.kind}`,
468
+ pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
469
+ snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
470
+ score: hitScore,
471
+ ...(scope === undefined ? {} : { scope }),
472
+ };
473
+ const d = docsById.get(id);
474
+ matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
475
+ matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
476
+ selectionGroupOf.set(hit, `file:${n.path}`);
477
+ return hit;
478
+ };
479
+ const symbolTitle = (id) => {
480
+ const n = byId.get(id);
481
+ return n ? `${n.name} · ${n.kind}` : id;
482
+ };
424
483
  // The single-vs-multi-scope branch keys on `scopesOfGraph` ONLY: one scope
425
484
  // (or no graph) keeps the original local scoring path.
426
485
  const scopes = graph ? scopesOfGraph(graph) : null;
@@ -435,6 +494,18 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
435
494
  : symbolDocs.length
436
495
  ? symbolDocs.reduce((a, d) => a + bodyLen(d.body), 0) / symbolDocs.length
437
496
  : 0;
497
+ // Populated only for bounded file ranking. The baseline path pays no allocation
498
+ // or matched-term tracking cost, and their scalar lexical path remains exact.
499
+ const rawLexicalById = new Map();
500
+ const matchedTermsById = new Map();
501
+ const matchedStrongTermsById = new Map();
502
+ const fileQueryWeights = fileComplement
503
+ ? normalizedQueryWeights(q, idf, dfltIdf)
504
+ : new Map();
505
+ const spanStart = (span) => {
506
+ const match = span.match(/^L(\d+)-L\d+$/);
507
+ return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
508
+ };
438
509
  if (scopes && scopes.length > 1) {
439
510
  // ── Multi-scope repo: partition by sub-project and walk each subgraph
440
511
  // separately — so the big scope can't drown the small one — but score every
@@ -442,14 +513,25 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
442
513
  // can be ordered by score. Same scoring functions and blend as the
443
514
  // single-scope path; the combination itself lives in fuse.ts. ──
444
515
  const byScope = new Map();
516
+ const scopeById = new Map();
445
517
  for (const d of symbolDocs) {
446
518
  const s = scopeOf(d.n.path, scopes).prefix;
519
+ scopeById.set(d.n.id, s);
447
520
  const list = byScope.get(s);
448
521
  if (list)
449
522
  list.push(d);
450
523
  else
451
524
  byScope.set(s, [d]);
452
525
  }
526
+ // Lazily build every scope's PageRank topology in one node/edge pass. The
527
+ // old nodeFilter path rebuilt the full repo adjacency once per matching
528
+ // scope; on large multi-project repos that turned one query into hundreds
529
+ // of full graph scans before the participation gate could run. Waiting for
530
+ // the first walk keeps no-match and graphRank:false queries edge-scan free.
531
+ let pageRankByScope;
532
+ let collapsedComponents = [];
533
+ let collapsedComponentById;
534
+ let collapsedFileByRepresentative;
453
535
  const fusion = rankScopesAndFuse([...byScope.keys()].sort(), {
454
536
  lex: (s) => {
455
537
  const docs = byScope.get(s);
@@ -461,12 +543,20 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
461
543
  // per-scope now; the statistics are global.
462
544
  const out = new Map();
463
545
  for (const { n, name, path, body } of docs) {
546
+ const factor = testFactor(n.path);
464
547
  const total = (score(q, name, idf) * 3 +
465
548
  score(q, path, idf) * 2 +
466
549
  bm25(q, body, idf, bodyLen(body), avgBodyLen)) *
467
- testFactor(n.path);
468
- if (total > 0)
550
+ factor;
551
+ if (total > 0) {
469
552
  out.set(n.id, total);
553
+ if (fileComplement) {
554
+ rawLexicalById.set(n.id, total);
555
+ matchedTermsById.set(n.id, matchedLexicalTerms(q, name, path, body));
556
+ if (n.kind !== "file")
557
+ matchedStrongTermsById.set(n.id, matchedStrongTerms(q, name));
558
+ }
559
+ }
470
560
  }
471
561
  return out;
472
562
  },
@@ -478,36 +568,121 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
478
568
  return true;
479
569
  return false;
480
570
  }),
481
- walk: (s, seeds) => graphRank
482
- ? personalizedPageRank(graph, seeds, {
483
- nodeFilter: (id) => {
484
- const path = byId.get(id)?.path ?? "";
485
- return scopeOf(path, scopes).prefix === s && (!inPrefix || pathUnderPrefix(path, inPrefix));
486
- },
487
- })
488
- : new Map(),
571
+ walk: (s, seeds) => {
572
+ if (!graphRank)
573
+ return new Map();
574
+ pageRankByScope ??= preparePageRankPartitions(graph, (id) => scopeById.get(id));
575
+ const topology = pageRankByScope?.get(s);
576
+ return topology
577
+ ? personalizedPageRankPrepared(topology, seeds)
578
+ : new Map();
579
+ },
489
580
  rankFactor: (_s, id) => testFactor(byId.get(id)?.path ?? ""),
581
+ collapseCandidates: fileComplement
582
+ ? (candidates) => {
583
+ collapsedComponents = [...candidates];
584
+ collapsedComponentById = new Map(candidates.map((candidate) => [candidate.id, candidate]));
585
+ const fileCandidates = candidates.map((candidate) => {
586
+ const n = byId.get(candidate.id);
587
+ const rawLexical = rawLexicalById.get(candidate.id) ?? 0;
588
+ return {
589
+ id: candidate.id,
590
+ file: n?.path ?? "",
591
+ kind: n?.kind === "file" ? "file" : "symbol",
592
+ rawLexical,
593
+ lexical: candidate.lexical,
594
+ graph: candidate.graph,
595
+ rankFactor: candidate.rankFactor,
596
+ baselineScore: candidate.score,
597
+ baselineTieKey: symbolTitle(candidate.id),
598
+ matchedTerms: matchedTermsById.get(candidate.id) ?? new Set(),
599
+ matchedStrongTerms: matchedStrongTermsById.get(candidate.id) ?? new Set(),
600
+ eligible: Boolean(n && n.kind !== "file" && rawLexical > 0),
601
+ spanStart: n ? spanStart(n.span) : undefined,
602
+ };
603
+ });
604
+ const rankedFiles = rankFilesBounded(fileCandidates, fileQueryWeights);
605
+ collapsedFileByRepresentative = new Map(rankedFiles.map((file) => [file.representative.id, file]));
606
+ return rankedFiles.map((file) => {
607
+ const component = collapsedComponentById.get(file.representative.id);
608
+ return {
609
+ id: file.representative.id,
610
+ scope: component.scope,
611
+ score: file.score,
612
+ };
613
+ });
614
+ }
615
+ : undefined,
490
616
  }, GRAPH_WEIGHT, RESCUE_FLOOR);
491
- for (const rd of fusion.ranked) {
492
- const n = byId.get(rd.id);
493
- if (!n)
494
- continue;
495
- const hit = {
496
- kind: "symbol",
497
- title: `${n.name} · ${n.kind}`,
498
- pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
499
- snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
500
- score: rd.score,
501
- scope: rd.scope,
502
- };
503
- const d = docsById.get(rd.id);
504
- // Global idf here too, so per-hit coverage means the same thing in a
505
- // multi-scope repo as it does in a single-scope one — the escalation
506
- // nudge and the hook's coverage gate both read these.
507
- matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
508
- matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
509
- selectionGroupOf.set(hit, `file:${n.path}`);
510
- symbolHits.push(hit);
617
+ if (needsFileQueues) {
618
+ const baselineFusion = combineComparableScopes(collapsedComponents.map(({ id, scope, score }) => ({ id, scope, score })));
619
+ const baselineScoreById = new Map(baselineFusion.ranked.map((ranked) => [ranked.id, ranked.score]));
620
+ const baselineDisplayOrder = (a, b) => b.score - a.score || symbolTitle(a.id).localeCompare(symbolTitle(b.id));
621
+ let baselineTopRanked = baselineFusion.ranked[0];
622
+ for (const ranked of baselineFusion.ranked.slice(1))
623
+ if (baselineTopRanked && baselineDisplayOrder(ranked, baselineTopRanked) < 0)
624
+ baselineTopRanked = ranked;
625
+ const baselineHitsToBuild = includeRankingMetadata
626
+ ? [...baselineFusion.ranked].sort(baselineDisplayOrder)
627
+ : baselineTopRanked
628
+ ? [baselineTopRanked]
629
+ : [];
630
+ for (const ranked of baselineHitsToBuild) {
631
+ const hit = makeSymbolHit(ranked.id, ranked.score, ranked.scope);
632
+ if (hit) {
633
+ baselineSymbolHits.push(hit);
634
+ baselineHitById.set(ranked.id, hit);
635
+ }
636
+ }
637
+ for (const ranked of fusion.ranked) {
638
+ const file = collapsedFileByRepresentative?.get(ranked.id);
639
+ if (!file)
640
+ continue;
641
+ const materializeFileQueue = () => file.queue.flatMap((member, index) => {
642
+ const component = collapsedComponentById?.get(member.id);
643
+ if (index > 0) {
644
+ const baselineHit = baselineHitById.get(member.id);
645
+ if (baselineHit)
646
+ return [baselineHit];
647
+ }
648
+ const hit = makeSymbolHit(member.id, index === 0
649
+ ? ranked.score
650
+ : baselineScoreById.get(member.id) ?? member.baselineScore, component?.scope ?? ranked.scope);
651
+ return hit ? [hit] : [];
652
+ });
653
+ const materializeBaselineQueue = () => file.queue.flatMap((member) => {
654
+ const hit = makeSymbolHit(member.id, baselineScoreById.get(member.id) ?? member.baselineScore, collapsedComponentById?.get(member.id)?.scope ?? ranked.scope);
655
+ return hit ? [hit] : [];
656
+ });
657
+ const hits = includeRankingMetadata
658
+ ? materializeFileQueue()
659
+ : (() => {
660
+ const leader = file.queue[0];
661
+ if (!leader)
662
+ return [];
663
+ const hit = makeSymbolHit(leader.id, ranked.score, collapsedComponentById?.get(leader.id)?.scope ?? ranked.scope);
664
+ return hit ? [hit] : [];
665
+ })();
666
+ if (hits.length === 0)
667
+ continue;
668
+ const group = {
669
+ key: `file:${file.file}`,
670
+ hits,
671
+ coverage: file.unionCoverage,
672
+ coverageStrong: file.unionStrongCoverage,
673
+ };
674
+ fileQueueHitsByGroup.set(group.key, materializeFileQueue);
675
+ baselineQueueHitsByGroup.set(group.key, materializeBaselineQueue);
676
+ fileGroups.push(group);
677
+ symbolHits.push(hits[0]);
678
+ }
679
+ }
680
+ else {
681
+ for (const ranked of fusion.ranked) {
682
+ const hit = makeSymbolHit(ranked.id, ranked.score, ranked.scope);
683
+ if (hit)
684
+ symbolHits.push(hit);
685
+ }
511
686
  }
512
687
  // Label + footer only when federation actually happened (or a scope was
513
688
  // gated out and is worth mentioning) — a query matching one scope of a
@@ -525,13 +700,20 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
525
700
  for (const { n, name, path, body } of symbolDocs) {
526
701
  // Name and path are short identifiers → plain idf-weighted overlap; the body
527
702
  // is length-normalized via BM25 so long definitions don't win on bulk.
703
+ const factor = testFactor(n.path);
528
704
  const total = (score(q, name, idf) * 3 +
529
705
  score(q, path, idf) * 2 +
530
706
  bm25(q, body, idf, bodyLen(body), avgBodyLen)) *
531
- testFactor(n.path);
707
+ factor;
532
708
  if (total > 0) {
533
709
  lex.set(n.id, total);
534
710
  maxLex = Math.max(maxLex, total);
711
+ if (fileComplement) {
712
+ rawLexicalById.set(n.id, total);
713
+ matchedTermsById.set(n.id, matchedLexicalTerms(q, name, path, body));
714
+ if (n.kind !== "file")
715
+ matchedStrongTermsById.set(n.id, matchedStrongTerms(q, name));
716
+ }
535
717
  }
536
718
  }
537
719
  // ── Graph-rank: let connectivity to the matched set reorder and rescue ──
@@ -547,31 +729,115 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
547
729
  for (const [id, p] of pr)
548
730
  if (p >= RESCUE_FLOOR)
549
731
  candidates.add(id);
732
+ const singleCandidates = [];
550
733
  for (const id of candidates) {
551
734
  const n = byId.get(id);
552
735
  if (!n)
553
736
  continue;
554
- const lexN = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
737
+ const lexical = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
738
+ const graphScore = pr.get(id) ?? 0;
739
+ const rankFactor = testFactor(n.path);
555
740
  // Apply the test penalty after normalization too. Applying it only to the
556
741
  // raw lexical score is not enough: if a test is still the strongest raw
557
742
  // match, dividing by maxLex restores it to 1.0 and erases the de-rank.
558
- const blended = (lexN + GRAPH_WEIGHT * (pr.get(id) ?? 0)) * testFactor(n.path);
559
- if (blended <= 0)
743
+ const baseline = (lexical + GRAPH_WEIGHT * graphScore) * rankFactor;
744
+ if (baseline <= 0)
560
745
  continue;
561
- const hit = {
562
- kind: "symbol",
563
- title: `${n.name} · ${n.kind}`,
564
- // A file node points at the whole file (locator, no span) so `--source`
565
- // never inlines an entire file; symbol nodes keep their exact span.
566
- pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
567
- snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
568
- score: blended,
569
- };
570
- const d = docsById.get(id);
571
- matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
572
- matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
573
- selectionGroupOf.set(hit, `file:${n.path}`);
574
- symbolHits.push(hit);
746
+ singleCandidates.push({ id, n, lexical, graph: graphScore, rankFactor, baseline });
747
+ }
748
+ const candidateById = fileComplement
749
+ ? new Map(singleCandidates.map((candidate) => [candidate.id, candidate]))
750
+ : undefined;
751
+ const rankedFiles = fileComplement
752
+ ? rankFilesBounded(singleCandidates.map((candidate) => {
753
+ const rawLexical = rawLexicalById.get(candidate.id) ?? 0;
754
+ return {
755
+ id: candidate.id,
756
+ file: candidate.n.path,
757
+ kind: candidate.n.kind === "file" ? "file" : "symbol",
758
+ rawLexical,
759
+ lexical: candidate.lexical,
760
+ graph: candidate.graph,
761
+ rankFactor: candidate.rankFactor,
762
+ baselineScore: candidate.baseline,
763
+ baselineTieKey: symbolTitle(candidate.id),
764
+ matchedTerms: matchedTermsById.get(candidate.id) ?? new Set(),
765
+ matchedStrongTerms: matchedStrongTermsById.get(candidate.id) ?? new Set(),
766
+ eligible: candidate.n.kind !== "file" && rawLexical > 0,
767
+ spanStart: spanStart(candidate.n.span),
768
+ };
769
+ }), fileQueryWeights)
770
+ : undefined;
771
+ if (needsFileQueues && rankedFiles) {
772
+ const baselineCandidateOrder = (a, b) => b.baseline - a.baseline ||
773
+ `${a.n.name} · ${a.n.kind}`.localeCompare(`${b.n.name} · ${b.n.kind}`);
774
+ let baselineTopCandidate = singleCandidates[0];
775
+ for (const candidate of singleCandidates.slice(1))
776
+ if (baselineTopCandidate && baselineCandidateOrder(candidate, baselineTopCandidate) < 0)
777
+ baselineTopCandidate = candidate;
778
+ const baselineCandidatesToBuild = includeRankingMetadata
779
+ ? [...singleCandidates].sort(baselineCandidateOrder)
780
+ : baselineTopCandidate
781
+ ? [baselineTopCandidate]
782
+ : [];
783
+ for (const candidate of baselineCandidatesToBuild) {
784
+ const hit = makeSymbolHit(candidate.id, candidate.baseline);
785
+ if (hit) {
786
+ baselineSymbolHits.push(hit);
787
+ baselineHitById.set(candidate.id, hit);
788
+ }
789
+ }
790
+ for (const file of rankedFiles) {
791
+ const materializeFileQueue = () => file.queue.flatMap((member, index) => {
792
+ if (index > 0) {
793
+ const baselineHit = baselineHitById.get(member.id);
794
+ if (baselineHit)
795
+ return [baselineHit];
796
+ }
797
+ const hit = makeSymbolHit(member.id, index === 0 ? file.score : member.baselineScore);
798
+ return hit ? [hit] : [];
799
+ });
800
+ const materializeBaselineQueue = () => file.queue.flatMap((member) => {
801
+ const hit = makeSymbolHit(member.id, member.baselineScore);
802
+ return hit ? [hit] : [];
803
+ });
804
+ const hits = includeRankingMetadata
805
+ ? materializeFileQueue()
806
+ : (() => {
807
+ const leader = file.queue[0];
808
+ if (!leader)
809
+ return [];
810
+ const hit = makeSymbolHit(leader.id, file.score);
811
+ return hit ? [hit] : [];
812
+ })();
813
+ if (hits.length === 0)
814
+ continue;
815
+ const group = {
816
+ key: `file:${file.file}`,
817
+ hits,
818
+ coverage: file.unionCoverage,
819
+ coverageStrong: file.unionStrongCoverage,
820
+ };
821
+ fileQueueHitsByGroup.set(group.key, materializeFileQueue);
822
+ baselineQueueHitsByGroup.set(group.key, materializeBaselineQueue);
823
+ fileGroups.push(group);
824
+ symbolHits.push(hits[0]);
825
+ }
826
+ }
827
+ else if (rankedFiles) {
828
+ for (const file of rankedFiles) {
829
+ const candidate = candidateById?.get(file.representative.id);
830
+ const hit = candidate ? makeSymbolHit(candidate.id, file.score) : null;
831
+ if (hit)
832
+ symbolHits.push(hit);
833
+ }
834
+ }
835
+ else {
836
+ for (const candidate of singleCandidates) {
837
+ const hit = makeSymbolHit(candidate.id, candidate.baseline);
838
+ if (hit)
839
+ symbolHits.push(hit);
840
+ }
575
841
  }
576
842
  }
577
843
  // Concepts and symbols live on comparable 0..~1.5 scales so they merge fairly:
@@ -581,17 +847,100 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
581
847
  h.score = maxConcept > 0 ? h.score / maxConcept : 0;
582
848
  const scored = [...conceptHits, ...symbolHits];
583
849
  scored.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
584
- // Preserve the baseline first-occurrence order of files and the exact top
585
- // hit, while emitting one representative per file before any second span.
586
- // A workspace child defers this step until cross-repo fusion has established
587
- // the final authoritative ranking.
588
- const selected = fileFirst
589
- ? fileFirstRoundRobin(scored.map((hit, index) => ({
590
- group: selectionGroupOf.get(hit) ?? `ungrouped:${index}`,
591
- value: hit,
592
- })), limit)
850
+ const baselineScored = needsFileQueues
851
+ ? [...conceptHits, ...baselineSymbolHits].sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
593
852
  : scored;
853
+ const conceptGroups = conceptHits.map((hit, index) => ({
854
+ key: selectionGroupOf.get(hit) ?? `concept:${index}`,
855
+ hits: [hit],
856
+ coverage: matchedOf.get(hit) ?? 0,
857
+ coverageStrong: matchedStrongOf.get(hit) ?? 0,
858
+ }));
859
+ const unlockedGroups = [...conceptGroups, ...fileGroups].sort((a, b) => b.hits[0].score - a.hits[0].score ||
860
+ a.hits[0].title.localeCompare(b.hits[0].title));
861
+ const sameHit = (a, b) => a.kind === b.kind && a.pointer === b.pointer && a.title === b.title;
862
+ let projectedGroups = unlockedGroups;
863
+ let lockedGroupKey;
864
+ if (fileTopLock && fileFirst && baselineScored[0]) {
865
+ const baselineTop = baselineScored[0];
866
+ const key = selectionGroupOf.get(baselineTop) ?? "locked:top";
867
+ lockedGroupKey = key;
868
+ const existing = unlockedGroups.find((group) => group.key === key);
869
+ const baselineQueue = includeRankingMetadata || limit > unlockedGroups.length
870
+ ? baselineQueueHitsByGroup.get(key)?.() ?? baselineScored.filter((hit) => (selectionGroupOf.get(hit) ?? "") === key)
871
+ : [];
872
+ const locked = existing
873
+ ? {
874
+ ...existing,
875
+ hits: [
876
+ baselineTop,
877
+ ...baselineQueue.filter((hit) => !sameHit(hit, baselineTop)),
878
+ ],
879
+ }
880
+ : {
881
+ key,
882
+ hits: baselineQueue.length ? baselineQueue : [baselineTop],
883
+ coverage: matchedOf.get(baselineTop) ?? 0,
884
+ coverageStrong: matchedStrongOf.get(baselineTop) ?? 0,
885
+ };
886
+ projectedGroups = [
887
+ locked,
888
+ ...unlockedGroups.filter((group) => group.key !== key),
889
+ ];
890
+ }
891
+ // Ranking always sees the complete candidate universe, but a normal bounded
892
+ // request usually needs only one leader per file. Materialize secondary span
893
+ // queues only when the requested output can actually reach a second round.
894
+ if (fileTopLock &&
895
+ fileFirst &&
896
+ !includeRankingMetadata &&
897
+ limit > projectedGroups.length) {
898
+ projectedGroups = projectedGroups.map((group) => {
899
+ if (group.key === lockedGroupKey)
900
+ return group;
901
+ const materialize = fileQueueHitsByGroup.get(group.key);
902
+ return materialize ? { ...group, hits: materialize() } : group;
903
+ });
904
+ }
905
+ // File-aware ranking feeds latent groups to the existing file-first frontier.
906
+ // Leaders are listed first so every file/concept emits once before any
907
+ // secondary span; each group's remaining queue retains baseline order.
908
+ const selected = fileTopLock && fileFirst
909
+ ? roundRobinQueues(projectedGroups.map((group) => group.hits), limit)
910
+ : fileFirst
911
+ ? fileFirstRoundRobin(scored.map((hit, index) => ({
912
+ group: selectionGroupOf.get(hit) ?? `ungrouped:${index}`,
913
+ value: hit,
914
+ })), limit)
915
+ : scored;
594
916
  const top = selected[0] ?? scored[0];
917
+ if (fileTopLock && top?.scope && scopeMeta) {
918
+ scopeMeta = {
919
+ federated: [...new Set([top.scope, ...scopeMeta.federated])],
920
+ alsoMatched: scopeMeta.alsoMatched.filter((match) => match.scope !== top.scope),
921
+ };
922
+ }
923
+ const ranking = includeRankingMetadata && needsFileQueues
924
+ ? {
925
+ groups: unlockedGroups.slice(0, limit).map((group) => ({
926
+ ...group,
927
+ hits: group.hits.slice(0, limit),
928
+ baselineHits: baselineScored
929
+ .filter((hit) => selectionGroupOf.get(hit) === group.key)
930
+ .slice(0, limit),
931
+ })),
932
+ baseline: baselineScored.slice(0, limit).map((hit, index) => ({
933
+ group: selectionGroupOf.get(hit) ?? `ungrouped:${index}`,
934
+ hit,
935
+ })),
936
+ baselineCoverage: baselineScored[0]
937
+ ? matchedOf.get(baselineScored[0]) ?? 0
938
+ : undefined,
939
+ baselineCoverageStrong: baselineScored[0]
940
+ ? matchedStrongOf.get(baselineScored[0]) ?? 0
941
+ : undefined,
942
+ }
943
+ : undefined;
595
944
  return {
596
945
  query,
597
946
  mode: scored.length ? "lexical" : "empty",
@@ -599,6 +948,7 @@ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true) {
599
948
  scopes: scopeMeta,
600
949
  coverage: top && q.size > 0 ? matchedOf.get(top) ?? 0 : undefined,
601
950
  coverageStrong: top && q.size > 0 ? matchedStrongOf.get(top) ?? 0 : undefined,
951
+ ...(ranking ? { ranking } : {}),
602
952
  // Zero hits on a genuinely multi-scope graph names the scopes that exist,
603
953
  // so a query that missed everywhere still tells the caller where to look.
604
954
  note: scored.length
@@ -693,6 +1043,12 @@ export function ask(dir, query, opts = {}) {
693
1043
  const limit = opts.limit ?? 8;
694
1044
  const corpus = loadCorpus(outDir);
695
1045
  const graphRank = opts.graphRank ?? true;
1046
+ const fileFirst = opts.fileFirst ?? true;
1047
+ // The production path uses bounded file scoring plus an exact baseline top lock.
1048
+ // Internal/raw callers can still request the baseline by disabling
1049
+ // file-first selection. A top lock is projected only on a file-first list.
1050
+ const fileTopLock = opts.fileTopLock ?? fileFirst;
1051
+ const fileComplement = (opts.fileComplement ?? false) || fileTopLock;
696
1052
  // Normalized ONCE, up front, and this value used everywhere below
697
1053
  // (validation, filtering, structural). `ask --in frontend/` must work exactly
698
1054
  // like `--in frontend` — `scopeLabel`/the footer's own `--in <scope>/`
@@ -710,7 +1066,7 @@ export function ask(dir, query, opts = {}) {
710
1066
  result = outcome.result;
711
1067
  }
712
1068
  else {
713
- result = lexical(query, corpus, limit, graphRank, inPrefix, opts.fileFirst ?? true);
1069
+ result = lexical(query, corpus, limit, graphRank, inPrefix, fileFirst, fileComplement, fileTopLock, opts.includeRankingMetadata ?? false);
714
1070
  // A structural-intent query that couldn't be answered structurally still
715
1071
  // gets a prominent note on the lexical fallback result — never silent.
716
1072
  if (outcome && "fallthroughNote" in outcome) {
@@ -719,10 +1075,18 @@ export function ask(dir, query, opts = {}) {
719
1075
  }
720
1076
  }
721
1077
  else {
722
- result = lexical(query, corpus, limit, graphRank, inPrefix, opts.fileFirst ?? true);
1078
+ result = lexical(query, corpus, limit, graphRank, inPrefix, fileFirst, fileComplement, fileTopLock, opts.includeRankingMetadata ?? false);
723
1079
  }
724
1080
  if (opts.source) {
725
1081
  inlineSource(root, result.hits, corpus.graph, opts.full ?? false);
1082
+ if (result.ranking) {
1083
+ const internalHits = [
1084
+ ...result.ranking.groups.flatMap((group) => group.hits),
1085
+ ...result.ranking.groups.flatMap((group) => group.baselineHits ?? []),
1086
+ ...result.ranking.baseline.map((entry) => entry.hit),
1087
+ ];
1088
+ inlineSource(root, [...new Set(internalHits)], corpus.graph, opts.full ?? false);
1089
+ }
726
1090
  // The pack is truly substitutive only in retriever mode (spans inlined), so
727
1091
  // the "vs reading whole files" estimate is only honest here.
728
1092
  result.saved = baselineFor(result.hits, corpus.graph);