dsh-code-index 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -800,9 +800,9 @@ function rustBindings(node) {
800
800
  return name2 ? [{ imported: name2, local: name2, kind: "named" }] : [];
801
801
  }
802
802
  case "use_as_clause": {
803
- const path10 = node.childForFieldName("path");
803
+ const path11 = node.childForFieldName("path");
804
804
  const alias = node.childForFieldName("alias")?.text.trim();
805
- const imported = path10 ? rustLeafName(path10) : null;
805
+ const imported = path11 ? rustLeafName(path11) : null;
806
806
  return imported && alias ? [{ imported, local: alias, kind: "named" }] : [];
807
807
  }
808
808
  case "scoped_use_list": {
@@ -823,8 +823,8 @@ function rustLeafName(node) {
823
823
  return node.childForFieldName("name")?.text.trim() ?? null;
824
824
  }
825
825
  function javaImport(node, line) {
826
- const path10 = node.namedChildren.find((child) => child?.type === "scoped_identifier");
827
- const specifier = dottedToPath(path10?.text);
826
+ const path11 = node.namedChildren.find((child) => child?.type === "scoped_identifier");
827
+ const specifier = dottedToPath(path11?.text);
828
828
  if (!specifier) return [];
829
829
  const wildcard = node.namedChildren.some((child) => child?.type === "asterisk");
830
830
  if (wildcard) {
@@ -832,7 +832,7 @@ function javaImport(node, line) {
832
832
  { specifier, line, kind: "import", bindings: [{ imported: "*", local: "*", kind: "glob" }] }
833
833
  ];
834
834
  }
835
- const imported = path10?.childForFieldName("name")?.text.trim() ?? lastSlashSegment(specifier);
835
+ const imported = path11?.childForFieldName("name")?.text.trim() ?? lastSlashSegment(specifier);
836
836
  const isStatic = /^import\s+static\b/.test(node.text);
837
837
  return [
838
838
  {
@@ -1056,32 +1056,38 @@ var SUPPORTED_EXTS = /* @__PURE__ */ new Set([
1056
1056
  ".inl",
1057
1057
  ".c"
1058
1058
  ]);
1059
- function isGitIgnoredPath(root, relativePath, cache = /* @__PURE__ */ new Map()) {
1060
- const rel = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
1059
+ function ignoredByGitLayers(layers, relativePath, isDirectory) {
1060
+ let ignored = false;
1061
+ for (const { base, matcher } of layers) {
1062
+ const scoped = base ? path2.posix.relative(base, relativePath) : relativePath;
1063
+ if (!scoped || scoped === ".." || scoped.startsWith("../")) continue;
1064
+ const result = matcher.test(isDirectory ? `${scoped.replace(/\/$/, "")}/` : scoped);
1065
+ if (result.ignored || result.unignored) ignored = result.ignored;
1066
+ }
1067
+ return ignored;
1068
+ }
1069
+ function isGitIgnoredPath(root, relativePath, cache = /* @__PURE__ */ new Map(), isDirectory = relativePath.endsWith("/")) {
1070
+ const rel = relativePath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
1061
1071
  if (!rel) return false;
1062
- const parentParts = rel.split("/").slice(0, -1);
1063
- for (let depth = 0; depth <= parentParts.length; depth += 1) {
1064
- const base = parentParts.slice(0, depth).join("/");
1072
+ const parts = rel.split("/");
1073
+ const layers = [];
1074
+ for (let depth = 0; depth < parts.length; depth += 1) {
1075
+ const base = parts.slice(0, depth).join("/");
1065
1076
  const ignoreFile = path2.join(root, base, ".gitignore");
1066
- let fileStat;
1067
1077
  try {
1068
- fileStat = statSync(ignoreFile);
1069
- } catch {
1070
- continue;
1071
- }
1072
- const previous = cache.get(ignoreFile);
1073
- let matcher = previous?.matcher;
1074
- if (!previous || previous.mtimeMs !== fileStat.mtimeMs || previous.size !== fileStat.size) {
1075
- try {
1078
+ const fileStat = statSync(ignoreFile);
1079
+ const previous = cache.get(ignoreFile);
1080
+ let matcher = previous?.matcher;
1081
+ if (!previous || previous.mtimeMs !== fileStat.mtimeMs || previous.size !== fileStat.size) {
1076
1082
  matcher = ignore().add(readFileSync(ignoreFile, "utf8"));
1077
1083
  cache.set(ignoreFile, { mtimeMs: fileStat.mtimeMs, size: fileStat.size, matcher });
1078
- } catch {
1079
- cache.delete(ignoreFile);
1080
- continue;
1081
1084
  }
1085
+ if (matcher) layers.push({ base, matcher });
1086
+ } catch {
1087
+ cache.delete(ignoreFile);
1082
1088
  }
1083
- const scoped = base ? path2.posix.relative(base, rel) : rel;
1084
- if (matcher?.ignores(scoped)) return true;
1089
+ const probe = parts.slice(0, depth + 1).join("/");
1090
+ if (ignoredByGitLayers(layers, probe, depth < parts.length - 1 || isDirectory)) return true;
1085
1091
  }
1086
1092
  return false;
1087
1093
  }
@@ -1097,10 +1103,6 @@ async function scanRepo(root, options = {}) {
1097
1103
  layers = [...inherited, { base: relDir, matcher: ignore().add(rules) }];
1098
1104
  } catch {
1099
1105
  }
1100
- const ignoredByGit = (relativePath) => layers.some(({ base, matcher }) => {
1101
- const scoped = base ? path2.posix.relative(base, relativePath) : relativePath;
1102
- return scoped !== ".." && !scoped.startsWith("../") && matcher.ignores(scoped);
1103
- });
1104
1106
  let entries;
1105
1107
  try {
1106
1108
  entries = await readdir(absDir, { withFileTypes: true });
@@ -1112,8 +1114,8 @@ async function scanRepo(root, options = {}) {
1112
1114
  const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
1113
1115
  if (entry.isDirectory()) {
1114
1116
  if (excluded.has(entry.name)) continue;
1115
- if (!ignoredByGit(rel)) queue.push([abs, rel, layers]);
1116
- } else if (entry.isFile() && !ignoredByGit(rel) && SUPPORTED_EXTS.has(path2.extname(entry.name).toLowerCase())) {
1117
+ if (!ignoredByGitLayers(layers, rel, true)) queue.push([abs, rel, layers]);
1118
+ } else if (entry.isFile() && !ignoredByGitLayers(layers, rel, false) && SUPPORTED_EXTS.has(path2.extname(entry.name).toLowerCase())) {
1117
1119
  try {
1118
1120
  const st = await stat(abs);
1119
1121
  results.push({ abs, rel, mtimeMs: st.mtimeMs });
@@ -1835,7 +1837,7 @@ function findOrphanModules(index) {
1835
1837
  index.files.filter((file) => file.symbols.length > 0).map((file) => file.path)
1836
1838
  );
1837
1839
  return nodes.filter(
1838
- (path10) => withSymbols.has(path10) && (incoming.get(path10) ?? 0) === 0 && (edges.get(path10)?.size ?? 0) === 0 && !ENTRY_RE.test(path10) && !TEST_RE.test(path10)
1840
+ (path11) => withSymbols.has(path11) && (incoming.get(path11) ?? 0) === 0 && (edges.get(path11)?.size ?? 0) === 0 && !ENTRY_RE.test(path11) && !TEST_RE.test(path11)
1839
1841
  );
1840
1842
  }
1841
1843
 
@@ -2040,6 +2042,12 @@ function runGit(args, cwd) {
2040
2042
  });
2041
2043
  });
2042
2044
  }
2045
+ async function resolveGitRef(root, ref) {
2046
+ requireSafeRef(ref, "baseRef");
2047
+ const commit = (await runGit(["rev-parse", "--verify", `${ref}^{commit}`], root)).trim();
2048
+ if (!/^[a-f0-9]{40,64}$/i.test(commit)) throw new Error("invalid baseline commit");
2049
+ return commit;
2050
+ }
2043
2051
  async function readWorkingTreeDiff(root, baseRef) {
2044
2052
  requireSafeRef(baseRef, "baseRef");
2045
2053
  const tracked = await runGit(
@@ -2190,9 +2198,15 @@ async function buildChangeContext(index, input, options = {}) {
2190
2198
  `command root ${path7.resolve(commandRoot)} differs from indexed root ${index.root}; using the cached index`
2191
2199
  );
2192
2200
  }
2193
- const addChanged = (symbol, change, side) => {
2201
+ const addChanged = (symbol, change, side, focusLine) => {
2194
2202
  const key = `${side}:${symbol.id}`;
2195
- if (!changedByKey.has(key)) changedByKey.set(key, { symbol, change, side, resolution: "exact" });
2203
+ if (!changedByKey.has(key)) changedByKey.set(key, {
2204
+ symbol,
2205
+ change,
2206
+ side,
2207
+ resolution: "exact",
2208
+ ...focusLine === void 0 ? {} : { focusLine }
2209
+ });
2196
2210
  };
2197
2211
  const addUnmapped = (file, side, ranges, reason) => {
2198
2212
  if (ranges.length > 0) unmapped.push({ file, side, ranges, reason });
@@ -2222,11 +2236,17 @@ async function buildChangeContext(index, input, options = {}) {
2222
2236
  } else {
2223
2237
  let text;
2224
2238
  if (input.kind === "git") {
2225
- baseRef = input.baseRef;
2226
- text = await readWorkingTreeDiff(input.root, input.baseRef);
2239
+ baseRef = await resolveGitRef(input.root, input.baseRef);
2240
+ text = await readWorkingTreeDiff(input.root, baseRef);
2227
2241
  } else {
2228
2242
  text = input.text;
2229
- baseRef = input.baseRef;
2243
+ if (input.baseRef !== void 0) {
2244
+ try {
2245
+ baseRef = await resolveGitRef(input.root, input.baseRef);
2246
+ } catch {
2247
+ warnings.push(`baseline unavailable: ${input.baseRef}`);
2248
+ }
2249
+ }
2230
2250
  }
2231
2251
  const changes = parseUnifiedDiff(text).map((change) => ({
2232
2252
  ...change,
@@ -2380,6 +2400,13 @@ async function mapDiffChanges(changes, symbolsByPath, addChanged, addUnmapped, c
2380
2400
  const { oldPath, newPath, hunks } = change;
2381
2401
  if (newPath) changedFiles.add(newPath);
2382
2402
  if (oldPath) changedFiles.add(oldPath);
2403
+ const identity = (symbol) => JSON.stringify([
2404
+ symbol.kind,
2405
+ symbol.name,
2406
+ symbol.scope.map((owner) => [owner.kind, owner.name])
2407
+ ]);
2408
+ const oldSymbols = oldPath === null ? [] : await readBaseSymbols(oldPath);
2409
+ const count = (symbols, symbol) => symbols.filter((candidate) => identity(candidate) === identity(symbol)).length;
2383
2410
  const currentSymbols = newPath !== null && change.status !== "deleted" ? symbolsByPath.get(newPath) : void 0;
2384
2411
  if (newPath !== null && change.status !== "deleted") {
2385
2412
  const currentRanges = hunks.filter((hunk) => hunk.newLines > 0).map((hunk) => ({
@@ -2389,7 +2416,14 @@ async function mapDiffChanges(changes, symbolsByPath, addChanged, addUnmapped, c
2389
2416
  if (currentRanges.length > 0 && currentSymbols) {
2390
2417
  for (const range of currentRanges) {
2391
2418
  for (const symbol of currentSymbols) {
2392
- if (intersects(symbol, range)) addChanged(symbol, change.status, "current");
2419
+ if (!intersects(symbol, range)) continue;
2420
+ let classification = "unclassified";
2421
+ if (change.status === "added") classification = "added";
2422
+ else if (oldSymbols !== null && count(currentSymbols, symbol) === 1) {
2423
+ const matches = count(oldSymbols, symbol);
2424
+ classification = matches === 0 ? "added" : matches === 1 ? "modified" : "unclassified";
2425
+ }
2426
+ addChanged(symbol, classification, "current", Math.max(symbol.line, range.startLine));
2393
2427
  }
2394
2428
  }
2395
2429
  const missed = currentRanges.filter(
@@ -2402,29 +2436,36 @@ async function mapDiffChanges(changes, symbolsByPath, addChanged, addUnmapped, c
2402
2436
  if (change.status === "renamed" && hunks.length === 0) {
2403
2437
  for (const symbol of currentSymbols ?? []) addChanged(symbol, "renamed", "current");
2404
2438
  if (oldPath !== null) {
2405
- const oldSymbols = await readBaseSymbols(oldPath);
2406
- if (oldSymbols === null) {
2439
+ const oldSymbols2 = await readBaseSymbols(oldPath);
2440
+ if (oldSymbols2 === null) {
2407
2441
  warnings.push(`rename baseline unavailable: ${oldPath}`);
2408
2442
  } else {
2409
- for (const symbol of oldSymbols) addChanged(symbol, "renamed", "base");
2443
+ for (const symbol of oldSymbols2) addChanged(symbol, "renamed", "base");
2410
2444
  }
2411
2445
  }
2412
2446
  }
2413
2447
  }
2414
- const deletionHunks = hunks.filter((hunk) => hunk.newLines === 0);
2448
+ const deletionHunks = hunks.filter((hunk) => hunk.oldLines > 0);
2415
2449
  if (deletionHunks.length > 0 && oldPath !== null) {
2416
2450
  const oldRanges = deletionHunks.map((hunk) => ({
2417
2451
  startLine: hunk.oldStart,
2418
2452
  endLine: hunk.oldStart + Math.max(hunk.oldLines, 1) - 1
2419
2453
  }));
2420
- const oldSymbols = await readBaseSymbols(oldPath);
2421
2454
  if (oldSymbols === null) {
2422
2455
  addUnmapped(oldPath, "base", oldRanges, "base-content-unavailable");
2423
2456
  } else {
2424
- const baseChange = change.status === "deleted" ? "deleted" : "modified";
2425
2457
  for (const range of oldRanges) {
2426
2458
  for (const symbol of oldSymbols) {
2427
- if (intersects(symbol, range)) addChanged(symbol, baseChange, "base");
2459
+ if (!intersects(symbol, range)) continue;
2460
+ const matches = count(currentSymbols ?? [], symbol);
2461
+ if (change.status === "deleted" || matches === 0 && count(oldSymbols, symbol) === 1) {
2462
+ addChanged(symbol, "deleted", "base", Math.max(symbol.line, range.startLine));
2463
+ } else if (matches > 1 || count(oldSymbols, symbol) > 1) {
2464
+ addChanged(symbol, "unclassified", "base");
2465
+ } else if (hunks.some((hunk) => hunk.newLines === 0 && intersects(symbol, {
2466
+ startLine: hunk.oldStart,
2467
+ endLine: hunk.oldStart + hunk.oldLines - 1
2468
+ }))) addChanged(symbol, "modified", "base");
2428
2469
  }
2429
2470
  }
2430
2471
  const missed = oldRanges.filter(
@@ -2582,6 +2623,15 @@ var TEST_RANK = {
2582
2623
  "references-symbol-name": 3,
2583
2624
  "path-convention": 4
2584
2625
  };
2626
+ function collectSeedTests(index, seeds) {
2627
+ return collectAffectedTests(
2628
+ index,
2629
+ buildModuleGraph(index),
2630
+ new Set(seeds.map((seed) => seed.file)),
2631
+ seeds.map((symbol) => ({ symbol, side: "current", change: "unclassified", resolution: "exact" })),
2632
+ []
2633
+ );
2634
+ }
2585
2635
  function collectAffectedTests(index, moduleGraph, changedFiles, changedSymbols, impactRows) {
2586
2636
  const changedNames = new Set(changedSymbols.map((changed) => changed.symbol.name));
2587
2637
  const impactFileDepth = /* @__PURE__ */ new Map();
@@ -2755,6 +2805,293 @@ function renderChangeContext(result, maxChars = 12e3) {
2755
2805
  return out;
2756
2806
  }
2757
2807
 
2808
+ // src/source-excerpts.ts
2809
+ import { readFile as readFile6, realpath, stat as stat3 } from "fs/promises";
2810
+ import path8 from "path";
2811
+ function sourceReader(index) {
2812
+ const cache = /* @__PURE__ */ new Map();
2813
+ return async (file, side, ref) => {
2814
+ const key = `${side}:${ref ?? ""}:${file}`;
2815
+ if (!cache.has(key)) cache.set(key, (async () => {
2816
+ if (side === "base") {
2817
+ if (!ref) return null;
2818
+ const code = await readGitFileAtRef(index.root, ref, file);
2819
+ return code === null ? null : code.split(/\r?\n/);
2820
+ }
2821
+ const indexed = index.files.find((entry) => entry.path === file);
2822
+ if (!indexed) return null;
2823
+ try {
2824
+ const root = await realpath(index.root);
2825
+ const target = await realpath(path8.join(root, file));
2826
+ const relative = path8.relative(root, target);
2827
+ if (relative.startsWith("..") || path8.isAbsolute(relative)) return null;
2828
+ const before = await stat3(target);
2829
+ if (before.size > 1e6 || before.mtimeMs !== indexed.mtimeMs) return null;
2830
+ const code = await readFile6(target, "utf8");
2831
+ const after = await stat3(target);
2832
+ if (after.mtimeMs !== before.mtimeMs || after.size !== before.size) return null;
2833
+ return code.split(/\r?\n/);
2834
+ } catch {
2835
+ return null;
2836
+ }
2837
+ })());
2838
+ return cache.get(key);
2839
+ };
2840
+ }
2841
+ function excerptVariants(lines, symbol, side = "current", ref, focusLine = symbol.line) {
2842
+ const base = { file: symbol.file, side, ...ref ? { ref } : {} };
2843
+ const signature = {
2844
+ ...base,
2845
+ startLine: symbol.line,
2846
+ endLine: symbol.line,
2847
+ mode: "signature",
2848
+ signature: symbol.signature
2849
+ };
2850
+ if (!lines || symbol.line < 1 || symbol.endLine > lines.length) return [signature];
2851
+ const ranges = [
2852
+ { start: symbol.line, end: symbol.endLine, mode: "complete" },
2853
+ { start: Math.max(symbol.line, focusLine - 3), end: Math.min(symbol.endLine, focusLine + 5), mode: "window" },
2854
+ { start: symbol.line, end: Math.min(symbol.endLine, symbol.line + 2), mode: "window" }
2855
+ ];
2856
+ const seen = /* @__PURE__ */ new Set();
2857
+ const variants = [];
2858
+ for (const range of ranges) {
2859
+ const key = `${range.start}:${range.end}`;
2860
+ if (seen.has(key) || range.end < range.start) continue;
2861
+ seen.add(key);
2862
+ const code = lines.slice(range.start - 1, range.end).join("\n");
2863
+ if (code.length > 1600 || range.end - range.start >= 80) continue;
2864
+ variants.push({
2865
+ ...base,
2866
+ startLine: range.start,
2867
+ endLine: range.end,
2868
+ mode: range.mode,
2869
+ code,
2870
+ ...range.mode === "window" ? { signature: symbol.signature } : {}
2871
+ });
2872
+ }
2873
+ return [...variants, signature];
2874
+ }
2875
+ function subtractExcerpt(excerpt, selected) {
2876
+ if (excerpt.code === void 0) return [excerpt];
2877
+ const covered = selected.filter((item) => item.code !== void 0 && item.file === excerpt.file && item.side === excerpt.side && item.ref === excerpt.ref);
2878
+ const lines = excerpt.code.split("\n");
2879
+ const fragments = [];
2880
+ let start = null;
2881
+ for (let line = excerpt.startLine; line <= excerpt.endLine + 1; line++) {
2882
+ const include = line <= excerpt.endLine && !covered.some((item) => line >= item.startLine && line <= item.endLine);
2883
+ if (include && start === null) start = line;
2884
+ if (!include && start !== null) {
2885
+ fragments.push({
2886
+ ...excerpt,
2887
+ startLine: start,
2888
+ endLine: line - 1,
2889
+ mode: start === excerpt.startLine && line - 1 === excerpt.endLine ? excerpt.mode : "window",
2890
+ code: lines.slice(start - excerpt.startLine, line - excerpt.startLine).join("\n")
2891
+ });
2892
+ start = null;
2893
+ }
2894
+ }
2895
+ return fragments;
2896
+ }
2897
+
2898
+ // src/context-pack.ts
2899
+ function renderContextPack(pack) {
2900
+ const rows = [`Task context \u2014 ${pack.route}`, `Repo/worktree: ${pack.root}`, `Task: ${pack.task}`];
2901
+ for (const item of pack.items) {
2902
+ const location = item.file ? ` ${item.file}:${item.startLine ?? 1}-${item.endLine ?? item.startLine ?? 1}` : "";
2903
+ rows.push(`
2904
+ ${item.kind}: ${item.name ?? item.text ?? ""}${location}${item.side ? ` [${item.side}${item.ref ? ` @${item.ref}` : ""}]` : ""}`);
2905
+ rows.push(`Reason: ${item.reason}`);
2906
+ if (item.change) rows.push(`Change: ${item.change}`);
2907
+ if (item.resolution) rows.push(`Relationship provenance: ${item.resolution}${item.provenance ? `; ${item.provenance}` : ""}`);
2908
+ if (item.code !== void 0) {
2909
+ if (item.signature) rows.push(`Declaration: ${item.signature}`);
2910
+ rows.push(`Source (${item.mode}):
2911
+ ${item.code}`);
2912
+ } else if (item.mode === "signature") rows.push(`Signature only: ${item.signature || "(source unavailable within budget)"}`);
2913
+ }
2914
+ if (pack.gaps.length) rows.push(`
2915
+ Gaps: ${pack.gaps.join("; ")}`);
2916
+ rows.push(`Budget: ${pack.budget.usedChars} / ${pack.budget.budgetChars} chars${pack.budget.truncated ? " (trimmed low-priority context)" : ""}`);
2917
+ return rows.join("\n");
2918
+ }
2919
+ function account(pack) {
2920
+ for (let i = 0; i < 8; i++) {
2921
+ const textChars = renderContextPack(pack).length;
2922
+ const packChars = JSON.stringify(pack).length;
2923
+ if (pack.budget.usedChars === textChars && pack.budget.packChars === packChars) break;
2924
+ pack.budget.usedChars = textChars;
2925
+ pack.budget.packChars = packChars;
2926
+ }
2927
+ }
2928
+ async function selectContextPack(index, context, budgetChars, maxFiles = 12) {
2929
+ const read = sourceReader(index);
2930
+ const graph = buildReferenceGraph(index);
2931
+ const candidates = [];
2932
+ const seen = /* @__PURE__ */ new Set();
2933
+ const gaps = [];
2934
+ const unavailable = /* @__PURE__ */ new Set();
2935
+ const source = async (symbol, kind, reason, extra = {}, focus) => {
2936
+ const side = extra.side ?? "current";
2937
+ const key = `${side}:${extra.ref ?? ""}:${kind}:${symbol.file}:${symbol.line}:${symbol.name}`;
2938
+ if (seen.has(key)) return;
2939
+ seen.add(key);
2940
+ const lines = await read(symbol.file, side, extra.ref);
2941
+ if (!lines) unavailable.add(`${side}:${symbol.file}`);
2942
+ candidates.push(excerptVariants(lines, symbol, side, extra.ref, focus).map((excerpt) => ({
2943
+ ...excerpt,
2944
+ ...symbol.id === "" && excerpt.mode === "complete" ? { mode: "window" } : {},
2945
+ kind,
2946
+ name: symbol.name,
2947
+ reason,
2948
+ ...extra
2949
+ })));
2950
+ };
2951
+ for (const primary of context.primarySymbols) {
2952
+ const changed = context.changeContext?.changed.find((row) => row.side === "current" && row.symbol.id === primary.symbol.id);
2953
+ await source(primary.symbol, "primary", primary.reason, changed ? { change: changed.change } : {}, changed?.focusLine);
2954
+ }
2955
+ for (const changed of context.changeContext?.changed ?? []) {
2956
+ if (changed.side === "current" && context.primarySymbols.some((seed) => seed.symbol.id === changed.symbol.id)) continue;
2957
+ await source(changed.symbol, "change", "declaration intersects Git change", {
2958
+ side: changed.side,
2959
+ ...changed.side === "base" ? { ref: context.changeContext.baseRef } : {},
2960
+ change: changed.change
2961
+ }, changed.focusLine);
2962
+ }
2963
+ for (const test of context.testEvidence) {
2964
+ const file = index.files.find((row) => row.path === test.file);
2965
+ const seedNames = new Set(context.primarySymbols.map((seed) => seed.symbol.name));
2966
+ const call = file.calls?.find((call2) => seedNames.has(call2.name));
2967
+ const lines = await read(file.path, "current");
2968
+ const symbol = file.symbols.find((symbol2) => call && symbol2.line <= call.line && symbol2.endLine >= call.line);
2969
+ await source(
2970
+ symbol ?? {
2971
+ id: "",
2972
+ name: test.file,
2973
+ file: test.file,
2974
+ line: Math.max(1, (call?.line ?? 1) - 3),
2975
+ endLine: Math.min(lines?.length ?? 1, (call?.line ?? 1) + 5),
2976
+ signature: "",
2977
+ scope: [],
2978
+ ordinal: 1,
2979
+ kind: "function",
2980
+ exported: false
2981
+ },
2982
+ "test",
2983
+ test.reason === "imports-changed-file" ? "test imports primary seed file" : test.reason,
2984
+ { resolution: test.resolution, provenance: test.reason },
2985
+ call?.line
2986
+ );
2987
+ }
2988
+ for (const primary of context.primarySymbols.slice(0, 4)) {
2989
+ for (const direction of ["caller", "callee"]) {
2990
+ const edges = direction === "caller" ? graph.incoming.get(primary.symbol.id) : graph.outgoing.get(primary.symbol.id);
2991
+ const strongEdges = (edges ?? []).filter((edge) => edge.resolution !== "name-only").sort((a, b) => a.resolution.localeCompare(b.resolution) || a.callSite.file.localeCompare(b.callSite.file) || a.callSite.line - b.callSite.line);
2992
+ for (const edge of strongEdges.slice(0, 4)) {
2993
+ const symbol = graph.symbolsById.get(direction === "caller" ? edge.sourceId ?? "" : edge.targetId);
2994
+ if (symbol && !context.primarySymbols.some((seed) => seed.symbol.id === symbol.id)) {
2995
+ await source(
2996
+ symbol,
2997
+ direction,
2998
+ `direct ${direction} of ${primary.symbol.name}`,
2999
+ { resolution: edge.resolution, provenance: edge.provenance },
3000
+ direction === "caller" ? edge.callSite.line : symbol.line
3001
+ );
3002
+ }
3003
+ }
3004
+ }
3005
+ const file = index.files.find((file2) => file2.path === primary.symbol.file);
3006
+ const lines = await read(file.path, "current");
3007
+ const body = lines?.slice(primary.symbol.line - 1, primary.symbol.endLine).join("\n") ?? primary.symbol.signature;
3008
+ const relevantImports = (file.importDetails ?? []).filter((imported) => imported.bindings.some((binding) => {
3009
+ const escaped = binding.local.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3010
+ return new RegExp(`(^|[^A-Za-z0-9_$])${escaped}(?=$|[^A-Za-z0-9_$])`).test(body);
3011
+ }));
3012
+ for (const imported of relevantImports.slice(0, 3)) {
3013
+ const offset = lines?.slice(imported.line - 1, imported.line + 11).findIndex((line) => line.includes(imported.specifier)) ?? -1;
3014
+ const endLine = offset < 0 ? imported.line : imported.line + offset;
3015
+ await source(
3016
+ {
3017
+ ...primary.symbol,
3018
+ ...offset < 0 ? { id: "" } : {},
3019
+ name: imported.specifier,
3020
+ line: imported.line,
3021
+ endLine,
3022
+ signature: ""
3023
+ },
3024
+ "import",
3025
+ `import binding referenced in primary source ${primary.symbol.file}`,
3026
+ { resolution: "import-scoped", provenance: "import statement" }
3027
+ );
3028
+ }
3029
+ }
3030
+ for (const relation of context.relationships) candidates.push([{
3031
+ kind: "relationship",
3032
+ text: relation.text,
3033
+ reason: "bounded primary graph neighborhood",
3034
+ resolution: relation.resolution,
3035
+ provenance: relation.provenance
3036
+ }]);
3037
+ if (!context.primarySymbols.length) for (const file of context.relevantFiles.slice(0, 4)) {
3038
+ candidates.push([{ kind: "file", file: file.path, reason: file.reason }]);
3039
+ }
3040
+ if (!context.primarySymbols.length) gaps.push("no strong primary symbol; specify a symbol, path, or failing behavior");
3041
+ if (!context.testEvidence.length) gaps.push("no related indexed test found");
3042
+ gaps.push(...context.warnings);
3043
+ const pack = {
3044
+ version: 1,
3045
+ root: index.root,
3046
+ task: context.task.slice(0, 120),
3047
+ route: context.route.kind,
3048
+ items: [],
3049
+ gaps: ["budget omitted some evidence"],
3050
+ budget: { budgetChars, usedChars: 0, packChars: 0, truncated: context.task.length > 120 }
3051
+ };
3052
+ const fits = () => {
3053
+ account(pack);
3054
+ return Math.max(pack.budget.usedChars, pack.budget.packChars) <= budgetChars;
3055
+ };
3056
+ if (!fits()) {
3057
+ pack.task = "";
3058
+ pack.gaps = [];
3059
+ fits();
3060
+ }
3061
+ if (!fits()) throw new Error("budget too small for repository identity");
3062
+ for (const variants of candidates) {
3063
+ let added = false;
3064
+ for (const candidate of variants) {
3065
+ const fragments = candidate.code !== void 0 ? subtractExcerpt(candidate, pack.items.filter((item) => item.code !== void 0)).map((fragment) => ({ ...candidate, ...fragment })) : [candidate];
3066
+ if (!fragments.length) continue;
3067
+ pack.items.push(...fragments);
3068
+ const fileCount = new Set(pack.items.flatMap((item) => item.file ? [item.file] : [])).size;
3069
+ if (fits() && fileCount <= maxFiles) {
3070
+ added = true;
3071
+ if (candidate.mode === "signature") pack.budget.truncated = true;
3072
+ break;
3073
+ }
3074
+ pack.items.splice(pack.items.length - fragments.length);
3075
+ }
3076
+ if (!added) pack.budget.truncated = true;
3077
+ }
3078
+ if (!pack.budget.truncated) pack.gaps = [];
3079
+ for (const item of pack.items) if (item.file && unavailable.has(`${item.side}:${item.file}`)) {
3080
+ gaps.push(`source unavailable or changed after indexing: ${item.file} (${item.side})`);
3081
+ }
3082
+ for (const gap of [...new Set(gaps)].slice(0, 4)) {
3083
+ pack.gaps.push(gap.slice(0, 140));
3084
+ if (!fits()) pack.gaps.pop();
3085
+ }
3086
+ account(pack);
3087
+ while (Math.max(pack.budget.usedChars, pack.budget.packChars) > budgetChars && pack.items.length) {
3088
+ pack.items.pop();
3089
+ pack.budget.truncated = true;
3090
+ account(pack);
3091
+ }
3092
+ return pack;
3093
+ }
3094
+
2758
3095
  // src/context.ts
2759
3096
  var TEST_PATH_RE2 = /(^|\/)(tests?|__tests__)(\/|$)|\.(?:spec|test)\.[cm]?[jt]sx?$|(^|\/)(?:test_[^/]+\.py|[^/]+_test\.(?:py|go))$/i;
2760
3097
  var FILE_TOKEN_RE = /[A-Za-z0-9_./\\-]+\.[A-Za-z0-9]+/g;
@@ -2784,7 +3121,20 @@ var STOP_WORDS = /* @__PURE__ */ new Set(
2784
3121
  "what",
2785
3122
  "where",
2786
3123
  "why",
2787
- "with"
3124
+ "with",
3125
+ "src",
3126
+ "lib",
3127
+ "test",
3128
+ "tests",
3129
+ "spec",
3130
+ "fix",
3131
+ "explain",
3132
+ "please",
3133
+ "help",
3134
+ "current",
3135
+ "change",
3136
+ "changes",
3137
+ "code"
2788
3138
  ]
2789
3139
  );
2790
3140
  var CHANGE_RE = /\b(fix|bug|broken|breaks?|change|changed|current|working\s+tree|regression|impact|modified|duplicate|fails?|failure)\b/i;
@@ -2809,9 +3159,6 @@ function clamp2(value, min, max, fallback) {
2809
3159
  function wordBoundary(name2) {
2810
3160
  return new RegExp(`(^|[^A-Za-z0-9_$])${name2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[^A-Za-z0-9_$])`);
2811
3161
  }
2812
- function symbolLabel2(symbol) {
2813
- return `${symbol.kind} ${symbol.signature || symbol.name} \u2014 ${symbol.file}:${symbol.line}`;
2814
- }
2815
3162
  function isTestFile(file) {
2816
3163
  return TEST_PATH_RE2.test(file);
2817
3164
  }
@@ -2822,6 +3169,13 @@ function fileMentionMatches(file, mentions) {
2822
3169
  return lower === normalized || lower.endsWith(`/${normalized}`) || lower.includes(normalized);
2823
3170
  });
2824
3171
  }
3172
+ function contextTerms(text) {
3173
+ return unique(text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length >= 3 && !STOP_WORDS.has(term)).flatMap((term) => [
3174
+ term,
3175
+ ...term.length > 5 && term.endsWith("ing") ? [term.slice(0, -3)] : [],
3176
+ ...term.length > 4 && term.endsWith("s") ? [term.slice(0, -1)] : []
3177
+ ]), (term) => term);
3178
+ }
2825
3179
  function routeTask(task, index) {
2826
3180
  const text = task.trim();
2827
3181
  const mentionedFiles = unique(text.match(FILE_TOKEN_RE) ?? [], (value) => value.toLowerCase()).map((value) => value.replace(/\\/g, "/")).filter((value) => !index || index.files.some((file) => fileMentionMatches(file.path, [value])));
@@ -2834,12 +3188,12 @@ function routeTask(task, index) {
2834
3188
  (token) => token.toLowerCase()
2835
3189
  );
2836
3190
  const queries = unique(
2837
- [...exactSymbolNames, ...codeTokens.filter((token) => !exactSymbolNames.includes(token))],
3191
+ [...exactSymbolNames, ...contextTerms(text), ...codeTokens.filter((token) => !exactSymbolNames.includes(token))],
2838
3192
  (value) => value.toLowerCase()
2839
3193
  ).slice(0, 12);
2840
3194
  const kind = TEST_RE3.test(text) || mentionedFiles.some(isTestFile) ? "test" : CHANGE_RE.test(text) ? "change" : exactSymbolNames.length > 0 ? "symbol" : EXPLORE_RE.test(text) ? "exploration" : ARCH_RE.test(text) ? "architecture" : "ambiguous";
2841
3195
  const includeChangeContext = kind === "change" || kind === "test";
2842
- const includeRelationships = kind === "change" || kind === "test" || kind === "symbol";
3196
+ const includeRelationships = kind !== "ambiguous";
2843
3197
  const sources = /* @__PURE__ */ new Set();
2844
3198
  if (kind === "change" || kind === "test") sources.add("change context");
2845
3199
  if (kind === "architecture" || kind === "exploration" || kind === "ambiguous") {
@@ -2868,34 +3222,61 @@ function routeScore(route, hit) {
2868
3222
  }
2869
3223
  function gatherPrimarySymbols(index, route, maxSymbols, changedIds, changedFiles, refs) {
2870
3224
  const hits = [];
3225
+ const queryTerms = new Set(route.queries.flatMap(contextTerms));
3226
+ for (const file of index.files) for (const symbol of file.symbols) {
3227
+ const terms = contextTerms(`${symbol.name} ${symbol.signature} ${file.path}`);
3228
+ const matched = terms.filter((term) => queryTerms.has(term)).length;
3229
+ if (route.exactSymbolNames.includes(symbol.name) || matched > 0 || fileMentionMatches(file.path, route.mentionedFiles)) {
3230
+ hits.push({ ...symbol, score: route.exactSymbolNames.includes(symbol.name) ? 1 : Math.min(0.8, matched * 0.2) });
3231
+ }
3232
+ }
2871
3233
  for (const query of route.queries) {
2872
3234
  hits.push(...searchSymbols(index, { query }, Math.max(maxSymbols * 3, 20), refs));
2873
3235
  }
2874
3236
  const byId = /* @__PURE__ */ new Map();
2875
3237
  for (const hit of hits) {
2876
3238
  const exact = route.exactSymbolNames.includes(hit.name);
3239
+ const overlap = contextTerms(`${hit.name} ${hit.signature} ${hit.file}`).filter((term) => queryTerms.has(term)).length;
3240
+ if (!exact && route.kind === "ambiguous" && queryTerms.size > 1 && overlap < 2) continue;
2877
3241
  let score = routeScore(route, hit);
2878
3242
  if (changedIds.has(hit.id)) score += 80;
2879
3243
  else if (changedFiles.has(hit.file)) score += 55;
2880
3244
  if (route.kind === "test" && isTestFile(hit.file)) score += 35;
2881
- const candidate = { symbol: hit, score, provenance: exact ? "exact" : "lexical" };
3245
+ const candidate = {
3246
+ symbol: hit,
3247
+ score,
3248
+ provenance: exact ? "exact" : "lexical",
3249
+ reason: exact ? "task names this symbol" : "matches task terms"
3250
+ };
2882
3251
  const previous = byId.get(hit.id);
2883
3252
  if (!previous || candidate.score > previous.score) byId.set(hit.id, candidate);
2884
3253
  }
3254
+ if (!byId.size && (route.kind === "architecture" || route.kind === "exploration")) {
3255
+ for (const entry of rankRepoMap(index, { topFiles: 2, symbolsPerFile: 2 })) {
3256
+ for (const symbol of index.files.find((file) => file.path === entry.path)?.symbols.slice(0, 2) ?? []) {
3257
+ byId.set(symbol.id, { symbol, score: 5, provenance: "lexical", reason: "bounded architecture entry lead" });
3258
+ }
3259
+ }
3260
+ }
2885
3261
  if (route.includeChangeContext) {
3262
+ const matchedFiles = new Set([...byId.values()].map((candidate) => candidate.symbol.file));
2886
3263
  for (const file of index.files) {
2887
- if (!changedFiles.has(file.path)) continue;
3264
+ if (matchedFiles.size && !matchedFiles.has(file.path)) continue;
2888
3265
  for (const symbol of file.symbols) {
3266
+ if (!changedIds.has(symbol.id)) continue;
2889
3267
  if (byId.has(symbol.id)) continue;
2890
3268
  byId.set(symbol.id, {
2891
3269
  symbol,
2892
3270
  score: 70,
2893
- provenance: "lexical"
3271
+ provenance: "lexical",
3272
+ reason: "declaration intersects current change"
2894
3273
  });
2895
3274
  }
2896
3275
  }
2897
3276
  }
2898
- return [...byId.values()].sort(
3277
+ const definitionMatch = (candidate) => contextTerms(`${candidate.symbol.name} ${candidate.symbol.signature}`).some((term) => queryTerms.has(term));
3278
+ const hasDefinitionMatch = [...byId.values()].some(definitionMatch);
3279
+ return [...byId.values()].filter((candidate) => !hasDefinitionMatch || definitionMatch(candidate) || candidate.reason === "declaration intersects current change" || fileMentionMatches(candidate.symbol.file, route.mentionedFiles)).sort(
2899
3280
  (a, b) => b.score - a.score || Number(b.provenance === "exact") - Number(a.provenance === "exact") || a.symbol.file.localeCompare(b.symbol.file) || a.symbol.line - b.symbol.line || a.symbol.id.localeCompare(b.symbol.id)
2900
3281
  ).slice(0, maxSymbols);
2901
3282
  }
@@ -2918,20 +3299,22 @@ function collectRelationships(graph, primary) {
2918
3299
  key,
2919
3300
  text: edgeText(graph, edge, direction),
2920
3301
  resolution: edge.resolution,
3302
+ provenance: edge.provenance,
2921
3303
  priority
2922
3304
  });
2923
3305
  };
2924
3306
  for (const candidate of primary) {
2925
- for (const edge of graph.incoming.get(candidate.symbol.id) ?? []) add(edge, "caller", 100);
2926
- for (const edge of graph.outgoing.get(candidate.symbol.id) ?? []) add(edge, "callee", 90);
3307
+ const bounded = (edges) => [...edges].sort((a, b) => a.resolution.localeCompare(b.resolution) || a.callSite.file.localeCompare(b.callSite.file) || a.callSite.line - b.callSite.line).slice(0, 8);
3308
+ for (const edge of bounded(graph.incoming.get(candidate.symbol.id) ?? [])) add(edge, "caller", 100);
3309
+ for (const edge of bounded(graph.outgoing.get(candidate.symbol.id) ?? [])) add(edge, "callee", 90);
2927
3310
  }
2928
3311
  return rows.sort(
2929
3312
  (a, b) => b.priority - a.priority || a.resolution.localeCompare(b.resolution) || a.text.localeCompare(b.text)
2930
3313
  );
2931
3314
  }
2932
- function addFile(files, path10, score, reason) {
2933
- const previous = files.get(path10);
2934
- if (!previous || score > previous.score) files.set(path10, { path: path10, score, reason });
3315
+ function addFile(files, path11, score, reason) {
3316
+ const previous = files.get(path11);
3317
+ if (!previous || score > previous.score) files.set(path11, { path: path11, score, reason });
2935
3318
  }
2936
3319
  function gatherFiles(index, route, primary, change, maxFiles) {
2937
3320
  const files = /* @__PURE__ */ new Map();
@@ -2954,7 +3337,7 @@ function gatherFiles(index, route, primary, change, maxFiles) {
2954
3337
  addFile(files, pathRow.changed.file, 90, "changed path");
2955
3338
  }
2956
3339
  }
2957
- for (const entry of rankRepoMap(index, { topFiles: maxFiles * 2, symbolsPerFile: 8 })) {
3340
+ if (!primary.length) for (const entry of rankRepoMap(index, { topFiles: maxFiles * 2, symbolsPerFile: 8 })) {
2958
3341
  addFile(files, entry.path, Math.min(35, entry.score + 10), "repo map");
2959
3342
  }
2960
3343
  return [...files.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, maxFiles);
@@ -2970,118 +3353,6 @@ function confidenceOf(primary, relationships, change) {
2970
3353
  }
2971
3354
  return out;
2972
3355
  }
2973
- function renderRows(result, budgetChars) {
2974
- const lines = [
2975
- `Task context \u2014 ${result.route.kind}`,
2976
- `Task: ${result.task}`,
2977
- `Sources: ${result.route.sources.join(", ")}`,
2978
- "",
2979
- "Primary symbols:"
2980
- ];
2981
- if (result.primarySymbols.length === 0) lines.push("- (none found)");
2982
- const optional = [];
2983
- for (const candidate2 of result.primarySymbols) {
2984
- optional.push({
2985
- priority: 1e3 + candidate2.score,
2986
- key: `symbol:${candidate2.symbol.id}`,
2987
- section: "primary",
2988
- line: `- [${candidate2.provenance}] ${symbolLabel2(candidate2.symbol)}`
2989
- });
2990
- }
2991
- optional.push({ priority: 700, key: "files-header", section: "files", line: "\nRelevant files:" });
2992
- for (const file of result.relevantFiles) {
2993
- optional.push({ priority: 500 + file.score, key: `file:${file.path}`, section: "files", line: `- ${file.path} (${file.reason})` });
2994
- }
2995
- optional.push({ priority: 400, key: "relationships-header", section: "relationships", line: "\nRelationships:" });
2996
- for (const relation of result.relationships) {
2997
- optional.push({ priority: relation.priority, key: `relation:${relation.key}`, section: "relationships", line: `- ${relation.text}` });
2998
- }
2999
- if (result.changeContext) {
3000
- optional.push({ priority: 850, key: "changes-header", section: "changes", line: "\nCurrent changes:" });
3001
- for (const changed of result.changeContext.changed) {
3002
- optional.push({
3003
- priority: 840,
3004
- key: `changed:${changed.side}:${changed.symbol.id}`,
3005
- section: "changes",
3006
- line: `- ${changed.change} ${symbolLabel2(changed.symbol)} [${changed.resolution}]`
3007
- });
3008
- }
3009
- if (result.changeContext.unmapped.length > 0) {
3010
- optional.push({
3011
- priority: 830,
3012
- key: "changes-unmapped",
3013
- section: "changes",
3014
- line: `- unmapped: ${result.changeContext.unmapped.map((entry) => entry.file).join(", ")}`
3015
- });
3016
- }
3017
- }
3018
- if (result.tests.length > 0) {
3019
- optional.push({ priority: 350, key: "tests-header", section: "tests", line: "\nLikely affected tests:" });
3020
- for (const test of result.tests) optional.push({ priority: 340, key: `test:${test}`, section: "tests", line: `- ${test}` });
3021
- }
3022
- if (result.warnings.length > 0) {
3023
- optional.push({ priority: 200, key: "warnings-header", section: "warnings", line: "\nWarnings:" });
3024
- for (const warning of result.warnings) optional.push({ priority: 190, key: `warning:${warning}`, section: "warnings", line: `- ${warning}` });
3025
- }
3026
- const ordered = optional.map((item, index) => ({ ...item, index })).sort((a, b) => b.priority - a.priority || a.index - b.index || a.key.localeCompare(b.key));
3027
- const footerReserve = 100;
3028
- let truncated = false;
3029
- const seen = /* @__PURE__ */ new Set();
3030
- for (const item of ordered) {
3031
- if (seen.has(item.key)) continue;
3032
- const next = lines.join("\n") + `
3033
- ${item.line}`;
3034
- if (next.length + footerReserve > budgetChars) {
3035
- truncated = true;
3036
- continue;
3037
- }
3038
- seen.add(item.key);
3039
- lines.push(item.line);
3040
- }
3041
- lines.push(
3042
- "",
3043
- `Confidence: exact ${result.confidence.exact}; import-scoped ${result.confidence["import-scoped"]}; name-only ${result.confidence["name-only"]}`
3044
- );
3045
- const body = lines.join("\n");
3046
- const withFooter = (bodyText, wasTruncated) => {
3047
- let usedChars = bodyText.length + 1;
3048
- for (let iteration = 0; iteration < 10; iteration++) {
3049
- const footer2 = `Budget: ${usedChars} / ${budgetChars} chars${wasTruncated ? " (trimmed low-priority context)" : ""}`;
3050
- const text2 = `${bodyText}
3051
- ${footer2}`;
3052
- const nextUsedChars = text2.length;
3053
- if (nextUsedChars === usedChars) return { text: text2, usedChars: nextUsedChars };
3054
- usedChars = nextUsedChars;
3055
- }
3056
- const footer = `Budget: ${usedChars} / ${budgetChars} chars${wasTruncated ? " (trimmed low-priority context)" : ""}`;
3057
- const text = `${bodyText}
3058
- ${footer}`;
3059
- return { text, usedChars: text.length };
3060
- };
3061
- const complete = withFooter(body, truncated);
3062
- if (complete.usedChars <= budgetChars) {
3063
- return { text: complete.text, usedChars: complete.usedChars, truncated };
3064
- }
3065
- truncated = true;
3066
- const candidate = (prefixLength) => {
3067
- const prefix = `${body.slice(0, prefixLength).trimEnd()}\u2026`;
3068
- return withFooter(prefix, true);
3069
- };
3070
- let low = 0;
3071
- let high = body.length;
3072
- let best = candidate(0);
3073
- while (low <= high) {
3074
- const middle = Math.floor((low + high) / 2);
3075
- const current = candidate(middle);
3076
- if (current.usedChars <= budgetChars) {
3077
- best = current;
3078
- low = middle + 1;
3079
- } else {
3080
- high = middle - 1;
3081
- }
3082
- }
3083
- return { text: best.text, usedChars: best.usedChars, truncated };
3084
- }
3085
3356
  async function buildTaskContext(index, task, options = {}) {
3086
3357
  const budgetChars = clamp2(options.budgetChars, 300, 2e4, 5e3);
3087
3358
  const maxFiles = clamp2(options.maxFiles, 1, 50, 12);
@@ -3111,6 +3382,19 @@ async function buildTaskContext(index, task, options = {}) {
3111
3382
  changedFiles,
3112
3383
  callerCounts(index)
3113
3384
  );
3385
+ const taskFiles = new Set(primarySymbols.filter((seed) => seed.reason !== "declaration intersects current change").map((seed) => seed.symbol.file));
3386
+ if (changeContext && taskFiles.size) {
3387
+ const changed = changeContext.changed.filter((row) => taskFiles.has(row.symbol.file));
3388
+ const changedIds2 = new Set(changed.map((row) => row.symbol.id));
3389
+ changeContext = {
3390
+ ...changeContext,
3391
+ changed,
3392
+ directCallers: changeContext.directCallers.filter((row) => changedIds2.has(row.via.toId)),
3393
+ tests: collectSeedTests(index, changed.map((row) => row.symbol)),
3394
+ importDependents: changeContext.importDependents.filter((row) => taskFiles.has(row.changedFile)),
3395
+ paths: changeContext.paths.filter((row) => changedIds2.has(row.changed.id))
3396
+ };
3397
+ }
3114
3398
  const graph = route.includeRelationships ? buildReferenceGraph(index) : null;
3115
3399
  const relationships = graph ? collectRelationships(graph, primarySymbols) : [];
3116
3400
  if (changeContext) {
@@ -3123,13 +3407,18 @@ async function buildTaskContext(index, task, options = {}) {
3123
3407
  key,
3124
3408
  text: `${row.symbol.name} (${row.symbol.file}:${row.symbol.line}) \u2192 changed symbol at ${row.via.callSite.file}:${row.via.callSite.line} [${row.resolution}; ${row.via.reason}]`,
3125
3409
  resolution: row.resolution,
3410
+ provenance: row.via.reason,
3126
3411
  priority: 110
3127
3412
  });
3128
3413
  }
3129
3414
  relationships.sort((a, b) => b.priority - a.priority || a.text.localeCompare(b.text));
3130
3415
  }
3131
3416
  const relevantFiles = gatherFiles(index, route, primarySymbols, changeContext, maxFiles);
3132
- const tests = unique(changeContext?.tests.map((test) => test.file) ?? [], (file) => file);
3417
+ const testEvidence = unique([
3418
+ ...collectSeedTests(index, primarySymbols.filter((seed) => seed.provenance === "exact" || seed.score >= 20).map((seed) => seed.symbol)),
3419
+ ...changeContext?.tests ?? []
3420
+ ], (test) => test.file).slice(0, 8);
3421
+ const tests = testEvidence.map((test) => test.file);
3133
3422
  const confidence = confidenceOf(primarySymbols, relationships, changeContext);
3134
3423
  const partial = {
3135
3424
  root: index.root,
@@ -3140,27 +3429,50 @@ async function buildTaskContext(index, task, options = {}) {
3140
3429
  relationships,
3141
3430
  changeContext,
3142
3431
  tests,
3432
+ testEvidence,
3143
3433
  confidence,
3144
3434
  warnings
3145
3435
  };
3146
- const rendered = renderRows(partial, budgetChars);
3436
+ const pack = await selectContextPack(index, partial, budgetChars, maxFiles);
3437
+ const selected = (file, name2, kind) => pack.items.some((item) => item.file === file && (!name2 || item.name === name2) && (!kind || item.kind === kind));
3438
+ const selectedRelationships = relationships.filter((row) => pack.items.some((item) => item.text === row.text));
3439
+ const selectedChange = changeContext ? {
3440
+ ...changeContext,
3441
+ changed: changeContext.changed.filter((row) => pack.items.some((item) => item.file === row.symbol.file && item.name === row.symbol.name && item.side === row.side && item.change === row.change)),
3442
+ unmapped: [],
3443
+ directCallers: [],
3444
+ importDependents: [],
3445
+ paths: [],
3446
+ impact: [],
3447
+ tests: changeContext.tests.filter((test) => selected(test.file, void 0, "test")),
3448
+ warnings: pack.gaps
3449
+ } : null;
3147
3450
  return {
3148
3451
  ...partial,
3149
- budget: {
3150
- usedChars: rendered.usedChars,
3151
- budgetChars,
3152
- truncated: rendered.truncated
3153
- }
3452
+ pack,
3453
+ route: {
3454
+ ...route,
3455
+ exactSymbolNames: route.exactSymbolNames.filter((name2) => pack.items.some((item) => item.name === name2)),
3456
+ mentionedFiles: route.mentionedFiles.filter((file) => pack.items.some((item) => item.file && fileMentionMatches(item.file, [file])))
3457
+ },
3458
+ primarySymbols: primarySymbols.filter((seed) => selected(seed.symbol.file, seed.symbol.name, "primary")),
3459
+ relevantFiles: relevantFiles.filter((file) => selected(file.path)),
3460
+ relationships: selectedRelationships,
3461
+ changeContext: selectedChange,
3462
+ tests: tests.filter((file) => selected(file, void 0, "test")),
3463
+ testEvidence: testEvidence.filter((test) => selected(test.file, void 0, "test")),
3464
+ confidence: confidenceOf([], selectedRelationships, null),
3465
+ warnings: pack.gaps,
3466
+ budget: { usedChars: pack.budget.usedChars, budgetChars, truncated: pack.budget.truncated }
3154
3467
  };
3155
3468
  }
3156
3469
  function renderTaskContext(result) {
3157
- const rendered = renderRows(result, result.budget.budgetChars);
3158
- return rendered.text;
3470
+ return renderContextPack(result.pack);
3159
3471
  }
3160
3472
 
3161
3473
  // src/tools.ts
3162
3474
  import { defineTool } from "@deepseek-ai/dsh-tools";
3163
- import path9 from "path";
3475
+ import path10 from "path";
3164
3476
 
3165
3477
  // src/config.ts
3166
3478
  import Schema from "@deepseek-ai/schemastery";
@@ -3225,8 +3537,8 @@ function indexOptions() {
3225
3537
  }
3226
3538
 
3227
3539
  // src/repo-context.ts
3228
- import { realpath } from "fs/promises";
3229
- import path8 from "path";
3540
+ import { realpath as realpath2 } from "fs/promises";
3541
+ import path9 from "path";
3230
3542
  import chokidar from "chokidar";
3231
3543
  function ignoredRelativePath(rel, options) {
3232
3544
  const components = rel.split(/[\\/]+/).filter(Boolean);
@@ -3234,8 +3546,8 @@ function ignoredRelativePath(rel, options) {
3234
3546
  return components.some((component) => excluded.has(component));
3235
3547
  }
3236
3548
  async function canonicalRepoRoot(root) {
3237
- const resolved = await realpath(path8.resolve(root));
3238
- return path8.normalize(resolved);
3549
+ const resolved = await realpath2(path9.resolve(root));
3550
+ return path9.normalize(resolved);
3239
3551
  }
3240
3552
  var RepoContextManager = class {
3241
3553
  contexts = /* @__PURE__ */ new Map();
@@ -3285,7 +3597,7 @@ var RepoContextManager = class {
3285
3597
  return index;
3286
3598
  }
3287
3599
  getContext(root) {
3288
- const key = cacheKeyForRoot(path8.resolve(root));
3600
+ const key = cacheKeyForRoot(path9.resolve(root));
3289
3601
  return this.contexts.get(key);
3290
3602
  }
3291
3603
  async dispose() {
@@ -3335,9 +3647,9 @@ var RepoContextManager = class {
3335
3647
  attachWatcher(context) {
3336
3648
  if (!this.options.watch) return;
3337
3649
  const options = this.options.indexOptions();
3338
- const ignored = (absolute) => {
3339
- const rel = path8.relative(context.root, absolute);
3340
- return rel === ".." || rel.startsWith(`..${path8.sep}`) || path8.isAbsolute(rel) || ignoredRelativePath(rel, options) || isGitIgnoredPath(context.root, rel, context.ignoreMatcherCache);
3650
+ const ignored = (absolute, stats) => {
3651
+ const rel = path9.relative(context.root, absolute);
3652
+ return rel === ".." || rel.startsWith(`..${path9.sep}`) || path9.isAbsolute(rel) || ignoredRelativePath(rel, options) || stats !== void 0 && isGitIgnoredPath(context.root, rel, context.ignoreMatcherCache, stats.isDirectory());
3341
3653
  };
3342
3654
  const watcher = chokidar.watch(context.root, {
3343
3655
  ignored,
@@ -3349,9 +3661,9 @@ var RepoContextManager = class {
3349
3661
  context.watcher = watcher;
3350
3662
  watcher.on("all", (event, absolute) => {
3351
3663
  if (context.disposed || !["add", "change", "unlink"].includes(event)) return;
3352
- const rel = path8.relative(context.root, absolute).split(path8.sep).join("/");
3664
+ const rel = path9.relative(context.root, absolute).split(path9.sep).join("/");
3353
3665
  if (!rel || ignoredRelativePath(rel, this.options.indexOptions()) || isGitIgnoredPath(context.root, rel, context.ignoreMatcherCache)) return;
3354
- if (!SUPPORTED_EXTS.has(path8.extname(rel).toLowerCase())) return;
3666
+ if (!SUPPORTED_EXTS.has(path9.extname(rel).toLowerCase())) return;
3355
3667
  context.dirtyFiles.add(rel);
3356
3668
  this.scheduleRefresh(context);
3357
3669
  });
@@ -3409,7 +3721,7 @@ async function resolveRoot(arg, exec) {
3409
3721
  const cwd = exec.agent?.session?.header?.cwd ?? exec.session?.header?.cwd ?? exec.cwd;
3410
3722
  const base = arg ?? cwd ?? process.cwd();
3411
3723
  const root = await findRepoRoot(base);
3412
- if (!root) throw new Error(`no git repository found from ${path9.resolve(base)}`);
3724
+ if (!root) throw new Error(`no git repository found from ${path10.resolve(base)}`);
3413
3725
  return root;
3414
3726
  }
3415
3727
  function hitsToSearchMeta(hits) {
@@ -3424,7 +3736,7 @@ function hitsToSearchMeta(hits) {
3424
3736
  }
3425
3737
  return {
3426
3738
  shape: "matches",
3427
- files: [...byFile.entries()].map(([path10, matches]) => ({ path: path10, matches })),
3739
+ files: [...byFile.entries()].map(([path11, matches]) => ({ path: path11, matches })),
3428
3740
  truncated: false,
3429
3741
  total: hits.length
3430
3742
  };
@@ -3756,6 +4068,11 @@ var tools = [
3756
4068
  name: "code_context",
3757
4069
  description: "Return the smallest useful structural context for a code task. Pass the task in plain language; the deterministic router combines ranked symbols, relevant files, call/import relationships, repository structure, current Git changes, affected tests, provenance, and a hard character budget. Existing specialized tools remain available for focused follow-up queries.",
3758
4070
  parameters: {
4071
+ outputFormat: {
4072
+ type: "string",
4073
+ enum: ["text", "pack"],
4074
+ description: "Default text preserves the canonical string return; pack explicitly requests the selected ContextPack DTO."
4075
+ },
3759
4076
  task: {
3760
4077
  type: "string",
3761
4078
  required: true,
@@ -3779,8 +4096,8 @@ var tools = [
3779
4096
  }
3780
4097
  },
3781
4098
  output: {
3782
- schema: { type: "string" },
3783
- render: (_args, value) => [{ type: "text", text: value }]
4099
+ schema: { oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }] },
4100
+ render: (_args, value) => [{ type: "text", text: typeof value === "string" ? value : renderContextPack(value) }]
3784
4101
  },
3785
4102
  presentCall: (args) => ({
3786
4103
  card: "generic",
@@ -3800,9 +4117,9 @@ var tools = [
3800
4117
  maxFiles: args.maxFiles,
3801
4118
  maxSymbols: args.maxSymbols
3802
4119
  });
3803
- return renderTaskContext(result);
4120
+ return args.outputFormat === "pack" ? JSON.parse(JSON.stringify(result.pack)) : renderTaskContext(result);
3804
4121
  } catch (error) {
3805
- return `code_context: ${error.message ?? String(error)}`;
4122
+ return `code_context: ${(error.message ?? String(error)).slice(0, 240)}`;
3806
4123
  }
3807
4124
  }
3808
4125
  }),
@@ -4017,6 +4334,7 @@ export {
4017
4334
  readGitFileAtRef,
4018
4335
  readWorkingTreeDiff,
4019
4336
  renderChangeContext,
4337
+ renderContextPack,
4020
4338
  renderHit,
4021
4339
  renderRepoMap,
4022
4340
  renderTaskContext,