@takara-ai/miru-code 1.7.8 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -11
- package/docs/kiro-transform-research-synopsis.md +79 -0
- package/docs/miru-aws-transform-concept.md +74 -0
- package/docs/miru-aws-transform-kiro-power-spec.md +98 -0
- package/docs/self-hosted-sagemaker-identity-center.md +109 -0
- package/docs/self-hosted-sagemaker.md +107 -24
- package/package.json +7 -1
- package/src/benchmark/grep.ts +9 -0
- package/src/benchmark/locate-compare.ts +33 -12
- package/src/benchmark/rg-literal.ts +130 -24
- package/src/embeddings/sagemaker.ts +166 -3
- package/src/literal.ts +1 -1
- package/src/mcp/index-cache.ts +52 -7
- package/src/mcp/server.ts +77 -16
- package/src/miru-index.ts +4 -1
- package/src/ranking/boosting.ts +6 -9
- package/src/ranking/config.ts +35 -0
- package/src/ranking/location.ts +5 -4
- package/src/ranking/penalties.ts +4 -3
- package/src/ranking/weighting.ts +4 -1
- package/src/search.ts +13 -8
- package/src/setup.ts +1 -0
- package/src/snippet.ts +343 -6
- package/src/utils.ts +8 -2
- package/src/version.ts +4 -2
package/src/snippet.ts
CHANGED
|
@@ -24,6 +24,15 @@ export function resolveSnippetLines(): number {
|
|
|
24
24
|
return envOptionalInt(["MIRU_SNIPPET_LINES"], 3) ?? 15;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function resolveSupportingSnippetLines(): number {
|
|
28
|
+
return envOptionalInt(["MIRU_SUPPORTING_SNIPPET_LINES"], 3) ?? 12;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Keep normal best hits complete; outline only unusually large implementations. */
|
|
32
|
+
export function resolvePrimarySnippetMaxLines(): number {
|
|
33
|
+
return envOptionalInt(["MIRU_PRIMARY_SNIPPET_MAX_LINES"], 3) ?? 48;
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
export function searchSnippetsEnabled(): boolean {
|
|
28
37
|
const value = process.env.MIRU_SEARCH_SNIPPETS;
|
|
29
38
|
if (value === "0" || value === "false") {
|
|
@@ -62,6 +71,306 @@ function scoreLine(line: string, terms: Set<string>): number {
|
|
|
62
71
|
return score;
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
const DECLARATION_RE =
|
|
75
|
+
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|enum|type|struct|trait|impl|record|namespace|contract|library|object)\b|^\s*(?:export\s+)?(?:const|let|var)\s+[$\w]+\s*=\s*[{[(]|^\s*(?:async\s+)?def\s+\w+|^\s*def\s+\w+|^\s*fn\s+\w+|^\s*func\s+\w+|^\s*defmodule\b|^\s*def\w*\b|^\s*(?:(?:public|private|protected|internal|static|final|abstract|virtual|override|sealed|partial|unsafe|extern)\s+)+(?:[A-Za-z_$][\w:<>[\]?.,\s]*\s+)+[A-Za-z_$]\w*\s*\(|^\s*(?:void|bool|boolean|char|short|int|long|float|double|string|[A-Z][\w:<>[\]?.,]*)\s+[A-Za-z_$]\w*\s*\(|^\s*(?:function\s+)?[A-Za-z_]\w*\s*\(\)\s*\{/;
|
|
76
|
+
const BRACE_BODY_RE =
|
|
77
|
+
/\b(?:function|class|interface|enum|struct|trait|impl|record|namespace|contract|library|object|fn|func)\b|=\s*[{[(]|[A-Za-z_$]\w*\s*\([^)]*\)\s*\{/;
|
|
78
|
+
const RUBY_OR_ELIXIR_BODY_RE = /^\s*def\w*\b|^\s*defmodule\b/;
|
|
79
|
+
const MAX_DECLARATION_LOOKBACK = 40;
|
|
80
|
+
const INDENTED_BLOCK_LANGUAGES = new Set(["python", "haskell", "ocaml"]);
|
|
81
|
+
const END_BLOCK_LANGUAGES = new Set(["ruby", "elixir"]);
|
|
82
|
+
const TAG_LANGUAGES = new Set(["html", "embeddedtemplate", "xml", "vue", "svelte"]);
|
|
83
|
+
const BRACE_BLOCK_LANGUAGES = new Set([
|
|
84
|
+
"bash",
|
|
85
|
+
"c",
|
|
86
|
+
"cpp",
|
|
87
|
+
"csharp",
|
|
88
|
+
"css",
|
|
89
|
+
"dart",
|
|
90
|
+
"go",
|
|
91
|
+
"java",
|
|
92
|
+
"javascript",
|
|
93
|
+
"json",
|
|
94
|
+
"kotlin",
|
|
95
|
+
"php",
|
|
96
|
+
"rust",
|
|
97
|
+
"scala",
|
|
98
|
+
"solidity",
|
|
99
|
+
"swift",
|
|
100
|
+
"typescript",
|
|
101
|
+
]);
|
|
102
|
+
const HTML_TAG_RE = /<\/?([A-Za-z][\w:-]*)\b[^>]*>/g;
|
|
103
|
+
const HTML_VOID_TAGS = new Set([
|
|
104
|
+
"area",
|
|
105
|
+
"base",
|
|
106
|
+
"br",
|
|
107
|
+
"col",
|
|
108
|
+
"embed",
|
|
109
|
+
"hr",
|
|
110
|
+
"img",
|
|
111
|
+
"input",
|
|
112
|
+
"link",
|
|
113
|
+
"meta",
|
|
114
|
+
"param",
|
|
115
|
+
"source",
|
|
116
|
+
"track",
|
|
117
|
+
"wbr",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
function withoutStrings(line: string): string {
|
|
121
|
+
return line.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, "");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function indentation(line: string): number {
|
|
125
|
+
return line.length - line.trimStart().length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function completeIndentedBlock(lines: string[], start: number, end: number): [number, number] {
|
|
129
|
+
const baseIndent = indentation(lines[start] ?? "");
|
|
130
|
+
for (let i = Math.max(start + 1, end); i < lines.length; i++) {
|
|
131
|
+
const line = lines[i] ?? "";
|
|
132
|
+
if (line.trim() && indentation(line) <= baseIndent) {
|
|
133
|
+
return [start, i];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return [start, lines.length];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isIndentedDeclaration(line: string, language: string | null): boolean {
|
|
140
|
+
if (language === "python") {
|
|
141
|
+
return /^\s*(?:async\s+)?(?:def|class)\b/.test(line);
|
|
142
|
+
}
|
|
143
|
+
if (language === "haskell") {
|
|
144
|
+
return /^\s*[A-Za-z_][\w']*\b.*=/.test(line);
|
|
145
|
+
}
|
|
146
|
+
return /^\s*(?:let|type|module)\b/.test(line);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isEndBlockOpen(line: string, language: string | null): boolean {
|
|
150
|
+
const trimmed = line.trimStart();
|
|
151
|
+
if (language === "ruby") {
|
|
152
|
+
return (
|
|
153
|
+
/^(?:def|class|module|if|unless|case|begin|while|until|for)\b/.test(trimmed) ||
|
|
154
|
+
/\bdo\b/.test(trimmed)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
/^(?:def\w*|defmodule|if|unless|case|cond|receive|try|for|with)\b/.test(trimmed) ||
|
|
159
|
+
/\b(?:do|fn)\b/.test(trimmed)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function completeEndBlock(
|
|
164
|
+
lines: string[],
|
|
165
|
+
start: number,
|
|
166
|
+
end: number,
|
|
167
|
+
language: string | null,
|
|
168
|
+
): [number, number] {
|
|
169
|
+
let depth = 0;
|
|
170
|
+
for (let i = start; i < lines.length; i++) {
|
|
171
|
+
const line = withoutStrings(lines[i] ?? "");
|
|
172
|
+
if (isEndBlockOpen(line, language)) {
|
|
173
|
+
depth++;
|
|
174
|
+
}
|
|
175
|
+
if (/^\s*end\b/.test(line)) {
|
|
176
|
+
depth--;
|
|
177
|
+
}
|
|
178
|
+
if (i + 1 >= end && depth <= 0) {
|
|
179
|
+
return [start, i + 1];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return [start, lines.length];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function completeHtmlTag(
|
|
186
|
+
lines: string[],
|
|
187
|
+
start: number,
|
|
188
|
+
end: number,
|
|
189
|
+
anchor: number,
|
|
190
|
+
): [number, number] {
|
|
191
|
+
const openTags: Array<{ name: string; start: number }> = [];
|
|
192
|
+
for (let i = Math.max(0, start - MAX_DECLARATION_LOOKBACK); i <= anchor; i++) {
|
|
193
|
+
const line = lines[i] ?? "";
|
|
194
|
+
for (const match of line.matchAll(HTML_TAG_RE)) {
|
|
195
|
+
const name = match[1]?.toLowerCase();
|
|
196
|
+
if (!name) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
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
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} else if (!match[0].endsWith("/>") && !HTML_VOID_TAGS.has(name)) {
|
|
207
|
+
openTags.push({ name, start: i });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const container = openTags.at(-1);
|
|
212
|
+
if (!container) {
|
|
213
|
+
return [start, end];
|
|
214
|
+
}
|
|
215
|
+
const closeTag = new RegExp(`</${container.name}\\s*>`, "i");
|
|
216
|
+
for (let i = Math.max(end - 1, container.start); i < lines.length; i++) {
|
|
217
|
+
if (closeTag.test(lines[i] ?? "")) {
|
|
218
|
+
return [container.start, i + 1];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return [container.start, lines.length];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Keep a query-centred snippet readable without relying on a parser at response time.
|
|
226
|
+
*
|
|
227
|
+
* Chunks are already AST-derived when a grammar is available. This final guard avoids
|
|
228
|
+
* cutting through the declaration/object that contains the selected line: it moves the
|
|
229
|
+
* start to a nearby declaration and extends the end through its closing delimiter.
|
|
230
|
+
* Unknown syntaxes retain the original fixed window.
|
|
231
|
+
*/
|
|
232
|
+
function completeStructure(
|
|
233
|
+
lines: string[],
|
|
234
|
+
start: number,
|
|
235
|
+
end: number,
|
|
236
|
+
anchor: number,
|
|
237
|
+
language: string | null,
|
|
238
|
+
): [number, number] {
|
|
239
|
+
if (TAG_LANGUAGES.has(language ?? "")) {
|
|
240
|
+
return completeHtmlTag(lines, start, end, anchor);
|
|
241
|
+
}
|
|
242
|
+
let structuralStart = start;
|
|
243
|
+
const lookback = Math.max(0, anchor - MAX_DECLARATION_LOOKBACK);
|
|
244
|
+
let foundDeclaration = false;
|
|
245
|
+
for (let i = anchor; i >= lookback; i--) {
|
|
246
|
+
const line = lines[i] ?? "";
|
|
247
|
+
if (DECLARATION_RE.test(line)) {
|
|
248
|
+
structuralStart = i;
|
|
249
|
+
foundDeclaration = true;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
// Include a doc block only when the candidate already begins inside it.
|
|
253
|
+
if (line.trimStart().startsWith("/**")) {
|
|
254
|
+
structuralStart = i;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!foundDeclaration && BRACE_BLOCK_LANGUAGES.has(language ?? "")) {
|
|
260
|
+
for (let i = anchor; i >= lookback; i--) {
|
|
261
|
+
if ((lines[i] ?? "").includes("{")) {
|
|
262
|
+
structuralStart = i;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const declaration = lines[structuralStart] ?? "";
|
|
269
|
+
if (
|
|
270
|
+
INDENTED_BLOCK_LANGUAGES.has(language ?? "") &&
|
|
271
|
+
isIndentedDeclaration(declaration, language)
|
|
272
|
+
) {
|
|
273
|
+
return completeIndentedBlock(lines, structuralStart, end);
|
|
274
|
+
}
|
|
275
|
+
if (END_BLOCK_LANGUAGES.has(language ?? "") && RUBY_OR_ELIXIR_BODY_RE.test(declaration)) {
|
|
276
|
+
return completeEndBlock(lines, structuralStart, end, language);
|
|
277
|
+
}
|
|
278
|
+
const braceBody = BRACE_BODY_RE.test(declaration);
|
|
279
|
+
const rubyOrElixirBody = RUBY_OR_ELIXIR_BODY_RE.test(declaration);
|
|
280
|
+
let braces = 0;
|
|
281
|
+
let parens = 0;
|
|
282
|
+
let blockComment = false;
|
|
283
|
+
let sawBraceBody = false;
|
|
284
|
+
let rubyEnds = 0;
|
|
285
|
+
|
|
286
|
+
for (let i = structuralStart; i < lines.length; i++) {
|
|
287
|
+
const raw = lines[i] ?? "";
|
|
288
|
+
const line = withoutStrings(raw);
|
|
289
|
+
if (line.includes("/*")) {
|
|
290
|
+
blockComment = true;
|
|
291
|
+
}
|
|
292
|
+
if (line.includes("*/")) {
|
|
293
|
+
blockComment = false;
|
|
294
|
+
}
|
|
295
|
+
braces += (line.match(/{/g) ?? []).length - (line.match(/}/g) ?? []).length;
|
|
296
|
+
parens += (line.match(/\(/g) ?? []).length - (line.match(/\)/g) ?? []).length;
|
|
297
|
+
if (line.includes("{")) {
|
|
298
|
+
sawBraceBody = true;
|
|
299
|
+
}
|
|
300
|
+
if (rubyOrElixirBody && /^\s*end\b/.test(line)) {
|
|
301
|
+
rubyEnds++;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (i + 1 < end) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (blockComment || parens > 0) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (rubyOrElixirBody) {
|
|
311
|
+
if (rubyEnds > 0) {
|
|
312
|
+
return [structuralStart, i + 1];
|
|
313
|
+
}
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (braceBody && (!sawBraceBody || braces > 0)) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
// A non-declaration snippet is still made safe if its window opened a block.
|
|
320
|
+
if (!braceBody && braces > 0) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
return [structuralStart, i + 1];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return [structuralStart, lines.length];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Turn a long supporting hit into an explicit outline instead of emitting a
|
|
331
|
+
* syntactically broken fragment. The best hit remains complete; this is only
|
|
332
|
+
* for lower-ranked context that helps navigation but rarely needs a full body.
|
|
333
|
+
*/
|
|
334
|
+
function compactSupportingSnippet(snippet: SnippetResult, maxLines: number): SnippetResult {
|
|
335
|
+
const lines = snippet.chunk.content.split("\n");
|
|
336
|
+
if (lines.length <= maxLines) {
|
|
337
|
+
return snippet;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const declarationIndex = lines.findIndex((line) => DECLARATION_RE.test(line));
|
|
341
|
+
const anchorOffset = Math.max(
|
|
342
|
+
0,
|
|
343
|
+
Math.min(lines.length - 1, snippet.meta.anchor_line - snippet.chunk.start_line),
|
|
344
|
+
);
|
|
345
|
+
const declaration =
|
|
346
|
+
declarationIndex >= 0
|
|
347
|
+
? lines
|
|
348
|
+
.slice(declarationIndex, Math.min(lines.length, declarationIndex + 4))
|
|
349
|
+
.join("\n")
|
|
350
|
+
.trim()
|
|
351
|
+
: null;
|
|
352
|
+
const anchor = (lines[anchorOffset] ?? "").trim();
|
|
353
|
+
const outline = [
|
|
354
|
+
"[Supporting context — complete outline]",
|
|
355
|
+
declaration ? `Symbol:\n${declaration}` : null,
|
|
356
|
+
anchor ? `Matched line ${snippet.meta.anchor_line}: ${anchor}` : null,
|
|
357
|
+
"Implementation omitted; call expand only if you need its full body.",
|
|
358
|
+
]
|
|
359
|
+
.filter((line): line is string => line !== null)
|
|
360
|
+
.join("\n");
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
chunk: {
|
|
364
|
+
...snippet.chunk,
|
|
365
|
+
content: outline,
|
|
366
|
+
},
|
|
367
|
+
meta: {
|
|
368
|
+
...snippet.meta,
|
|
369
|
+
truncated: true,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
65
374
|
/** Pick the 0-based line index inside `content` that best matches the query. */
|
|
66
375
|
export function anchorLineOffset(content: string, query: string): number {
|
|
67
376
|
const lines = content.split("\n");
|
|
@@ -89,6 +398,7 @@ export function trimChunkToSnippet(
|
|
|
89
398
|
chunk: Chunk,
|
|
90
399
|
query: string,
|
|
91
400
|
linesEachSide = resolveSnippetLines(),
|
|
401
|
+
anchorLine?: number,
|
|
92
402
|
): SnippetResult {
|
|
93
403
|
const lines = chunk.content.split("\n");
|
|
94
404
|
if (lines.length === 0) {
|
|
@@ -103,9 +413,20 @@ export function trimChunkToSnippet(
|
|
|
103
413
|
};
|
|
104
414
|
}
|
|
105
415
|
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
416
|
+
const queryAnchorOffset = anchorLineOffset(chunk.content, query);
|
|
417
|
+
const anchorOffset =
|
|
418
|
+
anchorLine == null
|
|
419
|
+
? queryAnchorOffset
|
|
420
|
+
: Math.max(0, Math.min(lines.length - 1, anchorLine - chunk.start_line));
|
|
421
|
+
const requestedStart = Math.max(0, anchorOffset - linesEachSide);
|
|
422
|
+
const requestedEnd = Math.min(lines.length, anchorOffset + linesEachSide + 1);
|
|
423
|
+
const [startOffset, endOffset] = completeStructure(
|
|
424
|
+
lines,
|
|
425
|
+
requestedStart,
|
|
426
|
+
requestedEnd,
|
|
427
|
+
anchorOffset,
|
|
428
|
+
chunk.language,
|
|
429
|
+
);
|
|
109
430
|
const truncated = startOffset > 0 || endOffset < lines.length;
|
|
110
431
|
|
|
111
432
|
if (!truncated) {
|
|
@@ -141,11 +462,27 @@ export function applySnippetsToResults(
|
|
|
141
462
|
results: SearchResult[],
|
|
142
463
|
query: string,
|
|
143
464
|
linesEachSide?: number,
|
|
465
|
+
sourceChunks?: ReadonlyMap<string, Chunk>,
|
|
144
466
|
): Array<{ result: SearchResult; meta: SnippetMeta }> {
|
|
145
467
|
const radius = linesEachSide ?? resolveSnippetLines();
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
468
|
+
const supportingMaxLines = resolveSupportingSnippetLines();
|
|
469
|
+
const primaryMaxLines = resolvePrimarySnippetMaxLines();
|
|
470
|
+
const contextualResults = results.map((result) => {
|
|
471
|
+
const source = sourceChunks?.get(result.chunk.file_path);
|
|
472
|
+
return {
|
|
473
|
+
result: source ? { ...result, chunk: source } : result,
|
|
474
|
+
// Select the anchor from the ranked chunk, not from the full source file.
|
|
475
|
+
// The same query may occur elsewhere in that file.
|
|
476
|
+
anchorLine: result.chunk.start_line + anchorLineOffset(result.chunk.content, query),
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
return contextualResults.map(({ result, anchorLine }, rank) => {
|
|
480
|
+
const { chunk, meta } = trimChunkToSnippet(result.chunk, query, radius, anchorLine);
|
|
481
|
+
const snippet = compactSupportingSnippet(
|
|
482
|
+
{ chunk, meta },
|
|
483
|
+
rank === 0 ? primaryMaxLines : supportingMaxLines,
|
|
484
|
+
);
|
|
485
|
+
return { result: { chunk: snippet.chunk, score: result.score }, meta: snippet.meta };
|
|
149
486
|
});
|
|
150
487
|
}
|
|
151
488
|
|
package/src/utils.ts
CHANGED
|
@@ -196,11 +196,17 @@ export function formatRelevanceScore(score: number, maxScore: number): string {
|
|
|
196
196
|
export function formatResults(
|
|
197
197
|
query: string,
|
|
198
198
|
results: SearchResult[],
|
|
199
|
-
options?: {
|
|
199
|
+
options?: {
|
|
200
|
+
repoRoot?: string | null;
|
|
201
|
+
snippet?: boolean;
|
|
202
|
+
snippetSourceChunks?: ReadonlyMap<string, Chunk>;
|
|
203
|
+
},
|
|
200
204
|
): { query: string; results: Record<string, unknown>[] } {
|
|
201
205
|
const repoRoot = options?.repoRoot ?? null;
|
|
202
206
|
const useSnippet = options?.snippet ?? searchSnippetsEnabled();
|
|
203
|
-
const payload = useSnippet
|
|
207
|
+
const payload = useSnippet
|
|
208
|
+
? applySnippetsToResults(results, query, undefined, options?.snippetSourceChunks)
|
|
209
|
+
: null;
|
|
204
210
|
const maxScore = results.reduce((max, result) => Math.max(max, result.score), 0);
|
|
205
211
|
|
|
206
212
|
return {
|
package/src/version.ts
CHANGED
|
@@ -9,6 +9,8 @@ const UPDATE_CHECK_FILENAME = "update-check.json";
|
|
|
9
9
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
10
10
|
const FETCH_TIMEOUT_MS = 2_000;
|
|
11
11
|
|
|
12
|
+
type Fetcher = (input: string, init?: RequestInit) => Promise<Response>;
|
|
13
|
+
|
|
12
14
|
interface UpdateCheckCache {
|
|
13
15
|
checkedAt: number;
|
|
14
16
|
latest: string;
|
|
@@ -93,8 +95,8 @@ async function writeUpdateCheckCache(latest: string): Promise<void> {
|
|
|
93
95
|
await Bun.write(updateCheckPath(), `${JSON.stringify(payload)}\n`);
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
export async function fetchLatestPublishedVersion(): Promise<string> {
|
|
97
|
-
const response = await
|
|
98
|
+
export async function fetchLatestPublishedVersion(fetchImpl: Fetcher = fetch): Promise<string> {
|
|
99
|
+
const response = await fetchImpl(REGISTRY_URL, {
|
|
98
100
|
headers: { accept: "application/json" },
|
|
99
101
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
100
102
|
});
|