@takara-ai/miru-code 1.8.2 → 1.10.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 (60) hide show
  1. package/README.md +65 -53
  2. package/docs/coverage-badge.json +6 -0
  3. package/grammars/manifest.json +10 -2
  4. package/grammars/tree-sitter-astro.wasm +0 -0
  5. package/grammars/tree-sitter-php.wasm +0 -0
  6. package/grammars/tree-sitter-php_only.wasm +0 -0
  7. package/grammars/tree-sitter-sql.wasm +0 -0
  8. package/grammars/tree-sitter-svelte.wasm +0 -0
  9. package/grammars/tree-sitter-vue.wasm +0 -0
  10. package/package.json +10 -2
  11. package/src/agents.ts +4 -4
  12. package/src/auth/device.ts +26 -21
  13. package/src/auth/providers.ts +26 -11
  14. package/src/benchmark/compare.ts +3 -10
  15. package/src/benchmark/grep.ts +64 -37
  16. package/src/benchmark/locate-compare.ts +8 -1
  17. package/src/benchmark/rg-literal.ts +9 -4
  18. package/src/cache.ts +3 -3
  19. package/src/chunking/ast.ts +21 -3
  20. package/src/chunking/grammars.ts +8 -3
  21. package/src/chunking/structural.ts +13 -24
  22. package/src/cli-mode.ts +18 -0
  23. package/src/cli.ts +54 -98
  24. package/src/credentials.ts +14 -14
  25. package/src/embeddings/openai.ts +6 -3
  26. package/src/embeddings/sagemaker.ts +18 -3
  27. package/src/embeddings/validate.ts +10 -7
  28. package/src/git.ts +6 -2
  29. package/src/help.ts +7 -7
  30. package/src/index/bm25.ts +66 -6
  31. package/src/index/chunk-selector.ts +39 -11
  32. package/src/index/create.ts +5 -9
  33. package/src/index/files.ts +5 -1
  34. package/src/index/incremental.ts +48 -8
  35. package/src/index/sparse.ts +14 -2
  36. package/src/installer/agents.ts +85 -65
  37. package/src/installer/benchmark-mode.ts +40 -11
  38. package/src/installer/config.ts +105 -42
  39. package/src/installer/installer.ts +59 -34
  40. package/src/installer/legacy-hooks.ts +131 -0
  41. package/src/installer/prompt.ts +20 -22
  42. package/src/installer/search-policy.ts +10 -10
  43. package/src/mcp/index-cache.ts +16 -3
  44. package/src/mcp/server.ts +65 -46
  45. package/src/mcp/stdio.ts +17 -2
  46. package/src/miru-index.ts +90 -22
  47. package/src/prompt.ts +51 -14
  48. package/src/ranking/boosting.ts +5 -6
  49. package/src/ranking/location.ts +6 -10
  50. package/src/ranking/penalties.ts +14 -22
  51. package/src/search.ts +8 -12
  52. package/src/setup.ts +25 -13
  53. package/src/snippet.ts +8 -26
  54. package/src/spinner.ts +26 -16
  55. package/src/tokenizer/bert-wordpiece.ts +0 -6
  56. package/src/utils.ts +1 -15
  57. package/src/version.ts +4 -4
  58. package/src/installer/hooks/install.ts +0 -549
  59. package/src/installer/hooks/opencode-plugin.ts +0 -54
  60. package/src/installer/hooks/search-guard.ts +0 -272
package/src/miru-index.ts CHANGED
@@ -14,13 +14,43 @@ import { createIndexFromPath } from "./index/create.ts";
14
14
  import { applyIncrementalFileChanges, normalizeRelativePath } from "./index/incremental.ts";
15
15
  import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
16
16
  import type { SemanticIndex } from "./index/semantic-index.ts";
17
- import { type LiteralLocateOptions, type LiteralLocateResult, locateLiteral } from "./literal.ts";
17
+ import {
18
+ filterChunksByGlob,
19
+ type LiteralLocateOptions,
20
+ type LiteralLocateResult,
21
+ locateLiteral,
22
+ } from "./literal.ts";
18
23
  import { hybridSearch, searchSemanticOnly } from "./search.ts";
19
24
  import type { Chunk, ContentType, SearchResult } from "./types.ts";
20
25
  import { chunkKey, chunkToDict, defaultContentTypes } from "./types.ts";
21
26
  import { computeSourceCacheKey, isGitUrl } from "./utils.ts";
22
27
  import { indexCacheEpoch } from "./version.ts";
23
28
 
29
+ interface FactoryDependencies {
30
+ getValidatedCache: typeof getValidatedCache;
31
+ resolveEmbeddingModel: typeof resolveEmbeddingModel;
32
+ getEmbeddingBackend: (model: string) => EmbeddingBackend;
33
+ createIndexFromPath: typeof createIndexFromPath;
34
+ cloneGitRepository: typeof cloneGitRepository;
35
+ findIndexCachePath: typeof findIndexCachePath;
36
+ loadCachedIndex: typeof loadCachedIndex;
37
+ saveBuiltIndex: (index: MiruIndex, path: string) => Promise<void>;
38
+ }
39
+
40
+ function factoryDependencies(overrides: Partial<FactoryDependencies> = {}): FactoryDependencies {
41
+ return {
42
+ getValidatedCache,
43
+ resolveEmbeddingModel,
44
+ getEmbeddingBackend,
45
+ createIndexFromPath,
46
+ cloneGitRepository,
47
+ findIndexCachePath,
48
+ loadCachedIndex,
49
+ saveBuiltIndex: (index, path) => index.save(path),
50
+ ...overrides,
51
+ };
52
+ }
53
+
24
54
  /**
25
55
  * In-memory search index over a codebase: BM25 keyword scores plus semantic
26
56
  * embeddings, with optional disk cache for reuse across runs.
@@ -166,18 +196,20 @@ export class MiruIndex {
166
196
  content: ContentType[] = defaultContentTypes(),
167
197
  embeddingModel?: string,
168
198
  ref?: string | null,
199
+ dependencies?: Partial<FactoryDependencies>,
169
200
  ): Promise<MiruIndex> {
201
+ const deps = factoryDependencies(dependencies);
170
202
  const cacheKey = computeSourceCacheKey(url, ref);
171
- const model = embeddingModel ?? resolveEmbeddingModel();
172
- const cached = await getValidatedCache(cacheKey, model, content);
203
+ const model = embeddingModel ?? deps.resolveEmbeddingModel();
204
+ const cached = await deps.getValidatedCache(cacheKey, model, content);
173
205
  if (cached) {
174
- return MiruIndex.loadFromDisk(cached, model);
206
+ return MiruIndex.loadFromDisk(cached, model, deps);
175
207
  }
176
208
 
177
- const cloneDir = await cloneGitRepository(url, ref);
209
+ const cloneDir = await deps.cloneGitRepository(url, ref);
178
210
  try {
179
- const embeddings = getEmbeddingBackend(model);
180
- const { bm25, semantic, chunks } = await createIndexFromPath(
211
+ const embeddings = deps.getEmbeddingBackend(model);
212
+ const { bm25, semantic, chunks } = await deps.createIndexFromPath(
181
213
  cloneDir,
182
214
  embeddings,
183
215
  content,
@@ -193,7 +225,7 @@ export class MiruIndex {
193
225
  root: null,
194
226
  content,
195
227
  });
196
- await index.save(findIndexCachePath(cacheKey));
228
+ await deps.saveBuiltIndex(index, deps.findIndexCachePath(cacheKey));
197
229
  return index;
198
230
  } finally {
199
231
  await rm(cloneDir, { recursive: true, force: true });
@@ -206,11 +238,12 @@ export class MiruIndex {
206
238
  content: ContentType[] = defaultContentTypes(),
207
239
  embeddingModel?: string,
208
240
  ref?: string | null,
241
+ dependencies?: Partial<FactoryDependencies>,
209
242
  ): Promise<MiruIndex> {
210
243
  if (isGitUrl(source)) {
211
- return MiruIndex.fromGit(source, content, embeddingModel, ref);
244
+ return MiruIndex.fromGit(source, content, embeddingModel, ref, dependencies);
212
245
  }
213
- return MiruIndex.fromPath(source, content, embeddingModel);
246
+ return MiruIndex.fromPath(source, content, embeddingModel, dependencies);
214
247
  }
215
248
 
216
249
  /**
@@ -224,7 +257,9 @@ export class MiruIndex {
224
257
  path: string,
225
258
  content: ContentType[] = defaultContentTypes(),
226
259
  embeddingModel?: string,
260
+ dependencies?: Partial<FactoryDependencies>,
227
261
  ): Promise<MiruIndex> {
262
+ const deps = factoryDependencies(dependencies);
228
263
  const resolved = resolve(path);
229
264
  try {
230
265
  const st = await Bun.file(resolved).stat();
@@ -238,14 +273,14 @@ export class MiruIndex {
238
273
  throw new Error(`Path does not exist: ${path}`);
239
274
  }
240
275
 
241
- const model = embeddingModel ?? resolveEmbeddingModel();
242
- const cached = await getValidatedCache(resolved, model, content);
276
+ const model = embeddingModel ?? deps.resolveEmbeddingModel();
277
+ const cached = await deps.getValidatedCache(resolved, model, content);
243
278
  if (cached) {
244
- return MiruIndex.loadFromDisk(cached, model);
279
+ return MiruIndex.loadFromDisk(cached, model, deps);
245
280
  }
246
281
 
247
- const embeddings = getEmbeddingBackend(model);
248
- const { bm25, semantic, chunks } = await createIndexFromPath(
282
+ const embeddings = deps.getEmbeddingBackend(model);
283
+ const { bm25, semantic, chunks } = await deps.createIndexFromPath(
249
284
  resolved,
250
285
  embeddings,
251
286
  content,
@@ -269,15 +304,20 @@ export class MiruIndex {
269
304
  * Validates embedding model compatibility via cache metadata. Sets
270
305
  * `loadedFromDisk` so callers can avoid redundant cache writes.
271
306
  */
272
- static async loadFromDisk(path: string, embeddingModel?: string): Promise<MiruIndex> {
273
- const model = embeddingModel ?? resolveEmbeddingModel();
274
- const { bm25, semantic, chunks, metadata } = await loadCachedIndex(path);
307
+ static async loadFromDisk(
308
+ path: string,
309
+ embeddingModel?: string,
310
+ dependencies?: Partial<FactoryDependencies>,
311
+ ): Promise<MiruIndex> {
312
+ const deps = factoryDependencies(dependencies);
313
+ const model = embeddingModel ?? deps.resolveEmbeddingModel();
314
+ const { bm25, semantic, chunks, metadata } = await deps.loadCachedIndex(path);
275
315
  const content = (metadata.content_type as ContentType[]) ?? defaultContentTypes();
276
316
  const root = metadata.root_path ? String(metadata.root_path) : null;
277
317
  const storedFileMtimes = (metadata.file_mtimes as Record<string, number>) ?? {};
278
318
 
279
319
  return new MiruIndex({
280
- embeddings: getEmbeddingBackend(model),
320
+ embeddings: deps.getEmbeddingBackend(model),
281
321
  bm25Index: bm25,
282
322
  semanticIndex: semantic,
283
323
  chunks,
@@ -382,6 +422,7 @@ export class MiruIndex {
382
422
  embeddings: this.embeddings,
383
423
  chunks: this.chunksInternal,
384
424
  semanticIndex: this.semanticIndex,
425
+ bm25: this.bm25Index,
385
426
  relativePaths,
386
427
  });
387
428
 
@@ -407,11 +448,19 @@ export class MiruIndex {
407
448
  );
408
449
  }
409
450
 
451
+ private getPathSelector(include?: string[], exclude?: string[]): readonly number[] | undefined {
452
+ if (!include?.length && !exclude?.length) {
453
+ return undefined;
454
+ }
455
+ const included = new Set(filterChunksByGlob(this.chunks, include, exclude).map(chunkKey));
456
+ return this.chunks.flatMap((chunk, index) => (included.has(chunkKey(chunk)) ? [index] : []));
457
+ }
458
+
410
459
  /**
411
460
  * Hybrid BM25 + semantic search over indexed chunks.
412
461
  *
413
462
  * `alpha` blends keyword vs vector scores (default depends on content type).
414
- * `filterLanguages` / `filterPaths` restrict candidates before ranking.
463
+ * `include` / `exclude` glob patterns restrict candidate files before ranking.
415
464
  * `rerank` defaults to on for code indexes (cross-encoder style reranking).
416
465
  */
417
466
  async search(options: {
@@ -422,15 +471,34 @@ export class MiruIndex {
422
471
  alpha?: number | null;
423
472
  filterLanguages?: string[];
424
473
  filterPaths?: string[];
474
+ include?: string[];
475
+ exclude?: string[];
425
476
  rerank?: boolean;
426
477
  }): Promise<SearchResult[]> {
427
- const { query, queryVector, topK = 10, alpha, filterLanguages, filterPaths, rerank } = options;
478
+ const {
479
+ query,
480
+ queryVector,
481
+ topK = 10,
482
+ alpha,
483
+ filterLanguages,
484
+ filterPaths,
485
+ include,
486
+ exclude,
487
+ rerank,
488
+ } = options;
428
489
 
429
490
  if (!this.chunks.length || !query.trim()) {
430
491
  return [];
431
492
  }
432
493
 
433
494
  const resolvedRerank = rerank ?? this.content.includes("code");
495
+ const selector = this.getSelector(filterLanguages, filterPaths);
496
+ const pathSelector = this.getPathSelector(include, exclude);
497
+ let effectiveSelector = pathSelector ?? selector;
498
+ if (selector && pathSelector) {
499
+ const allowedIndices = new Set(selector);
500
+ effectiveSelector = pathSelector.filter((index) => allowedIndices.has(index));
501
+ }
434
502
 
435
503
  return hybridSearch({
436
504
  query,
@@ -441,7 +509,7 @@ export class MiruIndex {
441
509
  chunks: this.chunks,
442
510
  topK,
443
511
  alpha,
444
- selector: this.getSelector(filterLanguages, filterPaths),
512
+ selector: effectiveSelector,
445
513
  rerank: resolvedRerank,
446
514
  });
447
515
  }
package/src/prompt.ts CHANGED
@@ -62,22 +62,49 @@ export function applyHiddenPromptChar(
62
62
  }
63
63
 
64
64
  /** Visible-input prompt (not for secrets — use promptHidden for those). */
65
- export async function promptText(message: string, defaultValue = ""): Promise<string> {
66
- const rl = readline.createInterface({ input, output });
65
+ export async function promptText(
66
+ message: string,
67
+ defaultValue = "",
68
+ createInterface: typeof createPromptInterface = createPromptInterface,
69
+ ): Promise<string> {
70
+ const rl = createInterface({ input, output });
71
+ return promptTextWith(
72
+ message,
73
+ defaultValue,
74
+ (question) => rl.question(question),
75
+ () => rl.close(),
76
+ );
77
+ }
78
+
79
+ export function createPromptInterface(
80
+ options: { input: typeof input; output: typeof output },
81
+ factory: typeof readline.createInterface = readline.createInterface,
82
+ ): Pick<readline.Interface, "question" | "close"> {
83
+ return factory(options);
84
+ }
85
+
86
+ export async function promptTextWith(
87
+ message: string,
88
+ defaultValue: string,
89
+ ask: (question: string) => Promise<string>,
90
+ close: () => void = () => {},
91
+ ): Promise<string> {
67
92
  try {
68
93
  const suffix = defaultValue ? ` (${defaultValue})` : "";
69
- const answer = (await rl.question(`${message}${suffix}: `)).trim();
94
+ const answer = (await ask(`${message}${suffix}: `)).trim();
70
95
  return answer || defaultValue;
71
96
  } finally {
72
- rl.close();
97
+ close();
73
98
  }
74
99
  }
75
100
 
76
101
  export async function promptHidden(
77
102
  message: string,
78
- stream: NodeJS.WriteStream = output,
103
+ stream: { write(text: string): unknown } = output,
104
+ io: { input?: HiddenPromptInput; exit?: (code: number) => void } = {},
79
105
  ): Promise<string> {
80
- if (!input.isTTY) {
106
+ const inputStream = io.input ?? input;
107
+ if (!inputStream.isTTY) {
81
108
  throw new Error(
82
109
  "Cannot prompt for API key: stdin is not a TTY. Run `miru setup --key YOUR_KEY` or set TAKARA_API_KEY.",
83
110
  );
@@ -85,9 +112,9 @@ export async function promptHidden(
85
112
 
86
113
  return new Promise((resolve, reject) => {
87
114
  stream.write(message);
88
- input.setRawMode?.(true);
89
- input.resume();
90
- input.setEncoding("utf8");
115
+ inputStream.setRawMode?.(true);
116
+ inputStream.resume();
117
+ inputStream.setEncoding("utf8");
91
118
 
92
119
  let state = createHiddenPromptState();
93
120
 
@@ -100,7 +127,7 @@ export async function promptHidden(
100
127
  cleanup();
101
128
  stream.write("\n");
102
129
  reject(new Error("Setup cancelled."));
103
- process.exit(130);
130
+ (io.exit ?? process.exit)(130);
104
131
  }
105
132
 
106
133
  if (result.submit) {
@@ -117,11 +144,21 @@ export async function promptHidden(
117
144
  };
118
145
 
119
146
  const cleanup = () => {
120
- input.setRawMode?.(false);
121
- input.pause();
122
- input.removeListener("data", onData);
147
+ inputStream.setRawMode?.(false);
148
+ inputStream.pause();
149
+ inputStream.removeListener("data", onData);
123
150
  };
124
151
 
125
- input.on("data", onData);
152
+ inputStream.on("data", onData);
126
153
  });
127
154
  }
155
+
156
+ interface HiddenPromptInput {
157
+ isTTY?: boolean;
158
+ setRawMode?: (mode: boolean) => void;
159
+ resume(): void;
160
+ pause(): void;
161
+ setEncoding(encoding: "utf8"): unknown;
162
+ on(event: "data", listener: (chunk: string) => void): unknown;
163
+ removeListener(event: "data", listener: (chunk: string) => void): unknown;
164
+ }
@@ -209,12 +209,11 @@ function boostSymbolDefinitions(
209
209
  const stem = basename(chunk.file_path)
210
210
  .replace(/\.[^.]+$/, "")
211
211
  .toLowerCase();
212
- if (![...names].some((n) => stemMatches(stem, n.toLowerCase()))) {
213
- continue;
214
- }
215
- const tier = definitionTier(chunk, names, boostUnit);
216
- if (tier) {
217
- boosted.set(key, tier);
212
+ if ([...names].some((n) => stemMatches(stem, n.toLowerCase()))) {
213
+ const tier = definitionTier(chunk, names, boostUnit);
214
+ if (tier) {
215
+ boosted.set(key, tier);
216
+ }
218
217
  }
219
218
  }
220
219
  }
@@ -222,16 +222,12 @@ export function boostExactStemMatches(
222
222
  if (GENERIC_STEM_DENY.has(keyword)) {
223
223
  continue;
224
224
  }
225
- if (preferImplementation && stem === keyword && !IMPLEMENTATION_HINTS.has(stem)) {
226
- continue;
227
- }
228
- const keywordNorm = keyword.replace(/[-_]/g, "");
229
- if (stem === keyword || stemNorm === keywordNorm) {
230
- boosted.set(key, (boosted.get(key) ?? 0) + boost);
231
- break;
232
- }
233
- const parts = keyword.split("-").filter((p) => p.length > 2);
234
- if (parts.length > 1 && parts.some((p) => p === stem)) {
225
+ if (!(preferImplementation && stem === keyword && !IMPLEMENTATION_HINTS.has(stem))) {
226
+ const keywordNorm = keyword.replace(/[-_]/g, "");
227
+ if (stem === keyword || stemNorm === keywordNorm) {
228
+ boosted.set(key, (boosted.get(key) ?? 0) + boost);
229
+ break;
230
+ }
235
231
  }
236
232
  }
237
233
  }
@@ -72,7 +72,7 @@ export function rerankTopk(
72
72
  const ranked = [...penalised.entries()].sort((a, b) => b[1] - a[1]);
73
73
 
74
74
  const fileSelected = new Map<string, number>();
75
- const selected: { score: number; key: string }[] = [];
75
+ const selected: { score: number; key: string; chunk: Chunk }[] = [];
76
76
  let minSelected = Infinity;
77
77
 
78
78
  for (const [key, penScore] of ranked) {
@@ -81,31 +81,23 @@ export function rerankTopk(
81
81
  continue;
82
82
  }
83
83
 
84
- if (selected.length >= topK && penScore <= minSelected) {
85
- break;
86
- }
87
-
88
- const already = fileSelected.get(chunk.file_path) ?? 0;
89
- let effScore = penScore;
90
- if (already >= FILE_SATURATION_THRESHOLD) {
91
- const excess = already - FILE_SATURATION_THRESHOLD + 1;
92
- effScore *= tuning.fileSaturationDecay ** excess;
93
- }
84
+ if (selected.length < topK || penScore > minSelected) {
85
+ const already = fileSelected.get(chunk.file_path) ?? 0;
86
+ let effScore = penScore;
87
+ if (already >= FILE_SATURATION_THRESHOLD) {
88
+ const excess = already - FILE_SATURATION_THRESHOLD + 1;
89
+ effScore *= tuning.fileSaturationDecay ** excess;
90
+ }
94
91
 
95
- selected.push({ score: effScore, key });
96
- fileSelected.set(chunk.file_path, already + 1);
92
+ selected.push({ score: effScore, key, chunk });
93
+ fileSelected.set(chunk.file_path, already + 1);
97
94
 
98
- if (selected.length >= topK) {
99
- minSelected = Math.min(...selected.map((s) => s.score));
95
+ if (selected.length >= topK) {
96
+ minSelected = Math.min(...selected.map((s) => s.score));
97
+ }
100
98
  }
101
99
  }
102
100
 
103
101
  selected.sort((a, b) => b.score - a.score);
104
- return selected.slice(0, topK).flatMap(({ key, score }) => {
105
- const chunk = chunksByKey.get(key);
106
- if (!chunk) {
107
- return [];
108
- }
109
- return [[chunk, score] as [Chunk, number]];
110
- });
102
+ return selected.slice(0, topK).map(({ chunk, score }) => [chunk, score]);
111
103
  }
package/src/search.ts CHANGED
@@ -96,6 +96,10 @@ export async function hybridSearch(options: {
96
96
  rerank = true,
97
97
  } = options;
98
98
 
99
+ if (selector?.length === 0) {
100
+ return [];
101
+ }
102
+
99
103
  const alphaWeight = resolveAlpha(query, alpha);
100
104
  const tuning = rankingConfig();
101
105
  const candidateCount =
@@ -133,11 +137,9 @@ export async function hybridSearch(options: {
133
137
 
134
138
  const allKeys = new Set([...normalizedSemantic.keys(), ...normalizedBm25.keys()]);
135
139
  const sortedKeys = [...allKeys].sort((a, b) => {
136
- const ca = chunksByKey.get(a);
137
- const cb = chunksByKey.get(b);
138
- if (!ca || !cb) {
139
- return 0;
140
- }
140
+ // Every score key was produced from a chunk in this query.
141
+ const ca = chunksByKey.get(a) as Chunk;
142
+ const cb = chunksByKey.get(b) as Chunk;
141
143
  return ca.start_line - cb.start_line;
142
144
  });
143
145
 
@@ -160,13 +162,7 @@ export async function hybridSearch(options: {
160
162
  return [...combinedScores.entries()]
161
163
  .sort((a, b) => b[1] - a[1])
162
164
  .slice(0, topK)
163
- .flatMap(([key, score]) => {
164
- const chunk = chunksByKey.get(key);
165
- if (!chunk) {
166
- return [];
167
- }
168
- return [{ chunk, score }];
169
- });
165
+ .map(([key, score]) => ({ chunk: chunksByKey.get(key) as Chunk, score }));
170
166
  }
171
167
 
172
168
  export async function searchSemanticOnly(options: {
package/src/setup.ts CHANGED
@@ -123,25 +123,27 @@ export function parseSetupCliArgs(rest: string[]): {
123
123
  return { args };
124
124
  }
125
125
 
126
- async function promptSageMakerArn(): Promise<string> {
127
- while (true) {
128
- const arn = await promptText("SageMaker endpoint ARN");
129
- if (!arn) {
126
+ async function promptSageMakerArn(promptTextImpl: typeof promptText = promptText): Promise<string> {
127
+ let arn = "";
128
+ while (!arn) {
129
+ const candidate = await promptTextImpl("SageMaker endpoint ARN");
130
+ if (!candidate) {
130
131
  fail("Endpoint ARN cannot be empty.");
131
132
  continue;
132
133
  }
133
134
  try {
134
- parseSageMakerEndpointArn(arn);
135
- return arn;
135
+ parseSageMakerEndpointArn(candidate);
136
+ arn = candidate;
136
137
  } catch (err) {
137
138
  fail(err instanceof Error ? err.message : String(err));
138
139
  }
139
140
  }
141
+ return arn;
140
142
  }
141
143
 
142
- async function promptAwsProfile(): Promise<string> {
144
+ async function promptAwsProfile(promptTextImpl: typeof promptText = promptText): Promise<string> {
143
145
  while (true) {
144
- const profile = await promptText(
146
+ const profile = await promptTextImpl(
145
147
  "AWS profile name (must already exist in ~/.aws)",
146
148
  process.env.AWS_PROFILE || "miru",
147
149
  );
@@ -152,11 +154,20 @@ async function promptAwsProfile(): Promise<string> {
152
154
  }
153
155
  }
154
156
 
157
+ export interface SageMakerSetupDependencies {
158
+ promptText?: typeof promptText;
159
+ canPromptForCredentials?: typeof canPromptForCredentials;
160
+ validateConnection?: typeof validateSageMakerConnection;
161
+ }
162
+
155
163
  /**
156
164
  * Miru only ever inherits AWS credentials — it never creates, writes, or rotates an
157
165
  * AWS profile. Set one up yourself first (e.g. `aws configure --profile miru`).
158
166
  */
159
- export async function runSageMakerSetup(options: RunSetupOptions = {}): Promise<RunSetupResult> {
167
+ export async function runSageMakerSetup(
168
+ options: RunSetupOptions = {},
169
+ dependencies: SageMakerSetupDependencies = {},
170
+ ): Promise<RunSetupResult> {
160
171
  writeStdout("");
161
172
  printBrandBanner(process.stderr);
162
173
  divider("─", 48, process.stderr);
@@ -168,18 +179,19 @@ export async function runSageMakerSetup(options: RunSetupOptions = {}): Promise<
168
179
  );
169
180
  writeStdout("");
170
181
 
171
- const arnInput = options.sagemakerArn ?? (await promptSageMakerArn());
182
+ const promptTextImpl = dependencies.promptText ?? promptText;
183
+ const arnInput = options.sagemakerArn ?? (await promptSageMakerArn(promptTextImpl));
172
184
  const parsed = parseSageMakerEndpointArn(arnInput);
173
185
 
174
186
  let profile = options.profile;
175
187
  if (!profile) {
176
- if (!canPromptForCredentials()) {
188
+ if (!(dependencies.canPromptForCredentials ?? canPromptForCredentials)()) {
177
189
  throw new Error(
178
190
  "An AWS profile name is required. Pass --profile <name>, or run " +
179
191
  "`miru setup --sagemaker` interactively.",
180
192
  );
181
193
  }
182
- profile = await promptAwsProfile();
194
+ profile = await promptAwsProfile(promptTextImpl);
183
195
  }
184
196
 
185
197
  await beginModeSwitch("sagemaker");
@@ -197,7 +209,7 @@ export async function runSageMakerSetup(options: RunSetupOptions = {}): Promise<
197
209
  truncate: true,
198
210
  truncationDirection: "Right",
199
211
  };
200
- const result = await validateSageMakerConnection(config);
212
+ const result = await (dependencies.validateConnection ?? validateSageMakerConnection)(config);
201
213
  if (!result.valid) {
202
214
  spinner.stop();
203
215
  throw new Error(result.message);
package/src/snippet.ts CHANGED
@@ -79,7 +79,7 @@ const RUBY_OR_ELIXIR_BODY_RE = /^\s*def\w*\b|^\s*defmodule\b/;
79
79
  const MAX_DECLARATION_LOOKBACK = 40;
80
80
  const INDENTED_BLOCK_LANGUAGES = new Set(["python", "haskell", "ocaml"]);
81
81
  const END_BLOCK_LANGUAGES = new Set(["ruby", "elixir"]);
82
- const TAG_LANGUAGES = new Set(["html", "embeddedtemplate", "xml", "vue", "svelte"]);
82
+ const TAG_LANGUAGES = new Set(["html", "embeddedtemplate", "xml", "vue", "svelte", "astro"]);
83
83
  const BRACE_BLOCK_LANGUAGES = new Set([
84
84
  "bash",
85
85
  "c",
@@ -192,16 +192,14 @@ function completeHtmlTag(
192
192
  for (let i = Math.max(0, start - MAX_DECLARATION_LOOKBACK); i <= anchor; i++) {
193
193
  const line = lines[i] ?? "";
194
194
  for (const match of line.matchAll(HTML_TAG_RE)) {
195
- const name = match[1]?.toLowerCase();
196
- if (!name) {
197
- continue;
198
- }
195
+ const name = (match[1] as string).toLowerCase();
199
196
  if (match[0].startsWith("</")) {
200
- for (let j = openTags.length - 1; j >= 0; j--) {
201
- if (openTags[j]?.name === name) {
202
- openTags.splice(j, 1);
203
- break;
204
- }
197
+ const matchingIndex = openTags.reduce(
198
+ (last, tag, index) => (tag.name === name ? index : last),
199
+ -1,
200
+ );
201
+ if (matchingIndex >= 0) {
202
+ openTags.splice(matchingIndex, 1);
205
203
  }
206
204
  } else if (!match[0].endsWith("/>") && !HTML_VOID_TAGS.has(name)) {
207
205
  openTags.push({ name, start: i });
@@ -374,10 +372,6 @@ function compactSupportingSnippet(snippet: SnippetResult, maxLines: number): Sni
374
372
  /** Pick the 0-based line index inside `content` that best matches the query. */
375
373
  export function anchorLineOffset(content: string, query: string): number {
376
374
  const lines = content.split("\n");
377
- if (lines.length === 0) {
378
- return 0;
379
- }
380
-
381
375
  const terms = queryMatchTerms(query);
382
376
  let bestIndex = Math.floor(lines.length / 2);
383
377
  let bestScore = -1;
@@ -401,18 +395,6 @@ export function trimChunkToSnippet(
401
395
  anchorLine?: number,
402
396
  ): SnippetResult {
403
397
  const lines = chunk.content.split("\n");
404
- if (lines.length === 0) {
405
- return {
406
- chunk,
407
- meta: {
408
- truncated: false,
409
- anchor_line: chunk.start_line,
410
- full_start_line: chunk.start_line,
411
- full_end_line: chunk.end_line,
412
- },
413
- };
414
- }
415
-
416
398
  const queryAnchorOffset = anchorLineOffset(chunk.content, query);
417
399
  const anchorOffset =
418
400
  anchorLine == null