@takara-ai/miru-code 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/bin/miru.cjs +41 -0
  4. package/package.json +72 -0
  5. package/src/agents/claude.md +41 -0
  6. package/src/agents/copilot.md +41 -0
  7. package/src/agents/cursor.md +40 -0
  8. package/src/agents/gemini.md +43 -0
  9. package/src/agents/kiro.md +43 -0
  10. package/src/agents/opencode.md +44 -0
  11. package/src/agents.ts +30 -0
  12. package/src/cache.ts +141 -0
  13. package/src/chunking/chunking.ts +36 -0
  14. package/src/chunking/lines.ts +79 -0
  15. package/src/chunking/structural.ts +244 -0
  16. package/src/cli.ts +245 -0
  17. package/src/concurrency.ts +58 -0
  18. package/src/embeddings/openai.ts +442 -0
  19. package/src/env-files.ts +44 -0
  20. package/src/env.ts +50 -0
  21. package/src/git.ts +52 -0
  22. package/src/index/bm25-types.ts +18 -0
  23. package/src/index/bm25-worker.ts +48 -0
  24. package/src/index/bm25.ts +238 -0
  25. package/src/index/create.ts +195 -0
  26. package/src/index/dense.ts +105 -0
  27. package/src/index/file-walker.ts +113 -0
  28. package/src/index/files.ts +126 -0
  29. package/src/index/persistence.ts +100 -0
  30. package/src/index/quantize.ts +168 -0
  31. package/src/index/semantic-index.ts +11 -0
  32. package/src/index/sparse.ts +25 -0
  33. package/src/index/topk.ts +52 -0
  34. package/src/index/vector-storage.ts +27 -0
  35. package/src/index.ts +3 -0
  36. package/src/mcp/index-cache.ts +95 -0
  37. package/src/mcp/serve.ts +24 -0
  38. package/src/mcp/server.ts +100 -0
  39. package/src/miru-index.ts +269 -0
  40. package/src/ranking/boosting.ts +329 -0
  41. package/src/ranking/penalties.ts +110 -0
  42. package/src/ranking/weighting.ts +11 -0
  43. package/src/search.ts +180 -0
  44. package/src/tokens.ts +27 -0
  45. package/src/types.ts +47 -0
  46. package/src/utils.ts +64 -0
@@ -0,0 +1,238 @@
1
+ /** Okapi BM25 with inverted postings; optional worker-parallel scoring. */
2
+
3
+ import { resolveWorkerConcurrency } from "../concurrency.ts";
4
+ import type {
5
+ Bm25ScoreJob,
6
+ Bm25ScoreResult,
7
+ PostingsList,
8
+ SerializablePostings,
9
+ } from "./bm25-types.ts";
10
+
11
+ const K1 = 1.5;
12
+ const B = 0.75;
13
+
14
+ /** Use workers when doc count exceeds this (overhead not worth it below). */
15
+ const PARALLEL_SCORE_MIN_DOCS = 256;
16
+
17
+ function docRanges(numDocs: number, parts: number): [number, number][] {
18
+ const n = Math.max(1, Math.min(parts, numDocs));
19
+ const ranges: [number, number][] = [];
20
+ const chunk = Math.ceil(numDocs / n);
21
+ for (let start = 0; start < numDocs; start += chunk) {
22
+ ranges.push([start, Math.min(numDocs, start + chunk)]);
23
+ }
24
+ return ranges;
25
+ }
26
+
27
+ function runScoreWorker(job: Bm25ScoreJob): Promise<Bm25ScoreResult> {
28
+ return new Promise((resolve, reject) => {
29
+ const worker = new Worker(new URL("./bm25-worker.ts", import.meta.url).href, {
30
+ type: "module",
31
+ });
32
+ worker.onmessage = (event: MessageEvent<Bm25ScoreResult>) => {
33
+ worker.terminate();
34
+ resolve(event.data);
35
+ };
36
+ worker.onerror = (err) => {
37
+ worker.terminate();
38
+ reject(err);
39
+ };
40
+ worker.postMessage(job);
41
+ });
42
+ }
43
+
44
+ export class BM25Index {
45
+ private docFreq = new Map<string, number>();
46
+ private docLengths: number[] = [];
47
+ private postings = new Map<string, PostingsList>();
48
+ private avgDocLength = 0;
49
+ private numDocs = 0;
50
+
51
+ index(tokenizedDocs: string[][]): void {
52
+ this.numDocs = tokenizedDocs.length;
53
+ this.docFreq.clear();
54
+ this.docLengths = [];
55
+ this.postings.clear();
56
+
57
+ let totalLen = 0;
58
+ for (let docIndex = 0; docIndex < tokenizedDocs.length; docIndex++) {
59
+ const doc = tokenizedDocs[docIndex];
60
+ if (!doc) {
61
+ continue;
62
+ }
63
+ this.docLengths.push(doc.length);
64
+ totalLen += doc.length;
65
+
66
+ const tf = new Map<string, number>();
67
+ for (const term of doc) {
68
+ tf.set(term, (tf.get(term) ?? 0) + 1);
69
+ }
70
+ for (const [term, count] of tf) {
71
+ this.docFreq.set(term, (this.docFreq.get(term) ?? 0) + 1);
72
+ let list = this.postings.get(term);
73
+ if (!list) {
74
+ list = [];
75
+ this.postings.set(term, list);
76
+ }
77
+ list.push([docIndex, count]);
78
+ }
79
+ }
80
+ this.avgDocLength = this.numDocs > 0 ? totalLen / this.numDocs : 0;
81
+ }
82
+
83
+ private queryTerms(queryTokens: string[]): { term: string; idf: number }[] {
84
+ const unique = [...new Set(queryTokens)];
85
+ const terms: { term: string; idf: number }[] = [];
86
+ for (const term of unique) {
87
+ const df = this.docFreq.get(term) ?? 0;
88
+ if (df === 0) {
89
+ continue;
90
+ }
91
+ terms.push({
92
+ term,
93
+ idf: Math.log(1 + (this.numDocs - df + 0.5) / (df + 0.5)),
94
+ });
95
+ }
96
+ return terms;
97
+ }
98
+
99
+ private postingsForTerms(terms: { term: string; idf: number }[]): SerializablePostings {
100
+ const out: SerializablePostings = {};
101
+ for (const { term } of terms) {
102
+ const list = this.postings.get(term);
103
+ if (list) {
104
+ out[term] = list;
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ getScores(queryTokens: string[], weightMask?: boolean[]): number[] {
111
+ const scores = new Array<number>(this.numDocs).fill(0);
112
+ if (this.numDocs === 0 || queryTokens.length === 0) {
113
+ return scores;
114
+ }
115
+
116
+ const terms = this.queryTerms(queryTokens);
117
+ const avg = this.avgDocLength || 1;
118
+
119
+ for (const { term, idf } of terms) {
120
+ const list = this.postings.get(term);
121
+ if (!list) {
122
+ continue;
123
+ }
124
+ for (const [docIndex, tf] of list) {
125
+ if (weightMask && !weightMask[docIndex]) {
126
+ continue;
127
+ }
128
+ const dl = this.docLengths[docIndex];
129
+ if (dl === undefined) {
130
+ continue;
131
+ }
132
+ const denom = tf + K1 * (1 - B + (B * dl) / avg);
133
+ scores[docIndex] = (scores[docIndex] ?? 0) + idf * ((tf * (K1 + 1)) / denom);
134
+ }
135
+ }
136
+
137
+ return scores;
138
+ }
139
+
140
+ async getScoresAsync(queryTokens: string[], weightMask?: boolean[]): Promise<number[]> {
141
+ const scores = new Array<number>(this.numDocs).fill(0);
142
+ if (this.numDocs === 0 || queryTokens.length === 0) {
143
+ return scores;
144
+ }
145
+
146
+ const terms = this.queryTerms(queryTokens);
147
+ if (terms.length === 0) {
148
+ return scores;
149
+ }
150
+
151
+ const concurrency = resolveWorkerConcurrency();
152
+ if (this.numDocs < PARALLEL_SCORE_MIN_DOCS || concurrency <= 1) {
153
+ return this.getScores(queryTokens, weightMask);
154
+ }
155
+
156
+ const postings = this.postingsForTerms(terms);
157
+ const ranges = docRanges(this.numDocs, concurrency);
158
+ const baseJob = {
159
+ docLengths: this.docLengths,
160
+ avgDocLength: this.avgDocLength,
161
+ terms,
162
+ postings,
163
+ weightMask,
164
+ };
165
+
166
+ const partials = await Promise.all(
167
+ ranges.map(([startDoc, endDoc]) => runScoreWorker({ ...baseJob, startDoc, endDoc })),
168
+ );
169
+
170
+ for (const { startDoc, scores: partial } of partials) {
171
+ for (let i = 0; i < partial.length; i++) {
172
+ const value = partial[i];
173
+ if (value !== undefined) {
174
+ scores[startDoc + i] = value;
175
+ }
176
+ }
177
+ }
178
+
179
+ return scores;
180
+ }
181
+
182
+ toJSON(): {
183
+ postings: SerializablePostings;
184
+ docLengths: number[];
185
+ avgDocLength: number;
186
+ numDocs: number;
187
+ } {
188
+ const postings: SerializablePostings = {};
189
+ for (const [term, list] of this.postings.entries()) {
190
+ postings[term] = list;
191
+ }
192
+ return {
193
+ postings,
194
+ docLengths: this.docLengths,
195
+ avgDocLength: this.avgDocLength,
196
+ numDocs: this.numDocs,
197
+ };
198
+ }
199
+
200
+ static fromJSON(
201
+ data:
202
+ | {
203
+ // Legacy cache format
204
+ docs: string[][];
205
+ docLengths: number[];
206
+ docFreq: [string, number][];
207
+ }
208
+ | {
209
+ // Current cache format
210
+ postings: SerializablePostings;
211
+ docLengths: number[];
212
+ avgDocLength: number;
213
+ numDocs: number;
214
+ },
215
+ ): BM25Index {
216
+ const idx = new BM25Index();
217
+
218
+ // New cache: restore postings directly (no need to re-index tokenized docs).
219
+ if ("postings" in data) {
220
+ idx.postings.clear();
221
+ idx.docFreq.clear();
222
+ idx.docLengths = data.docLengths;
223
+ idx.avgDocLength = data.avgDocLength;
224
+ idx.numDocs = data.numDocs;
225
+
226
+ for (const [term, list] of Object.entries(data.postings)) {
227
+ idx.postings.set(term, list);
228
+ idx.docFreq.set(term, list.length);
229
+ }
230
+
231
+ return idx;
232
+ }
233
+
234
+ // Legacy cache: re-index from stored tokenized documents.
235
+ idx.index(data.docs);
236
+ return idx;
237
+ }
238
+ }
@@ -0,0 +1,195 @@
1
+ import { relative, resolve } from "node:path";
2
+ import { chunkSource } from "../chunking/chunking.ts";
3
+ import { resolveWorkerConcurrency } from "../concurrency.ts";
4
+ import type { EmbeddingBackend } from "../embeddings/openai.ts";
5
+ import { envOptionalInt } from "../env.ts";
6
+ import { tokenize } from "../tokens.ts";
7
+ import type { Chunk, ContentType } from "../types.ts";
8
+ import { BM25Index } from "./bm25.ts";
9
+ import { walkFiles } from "./file-walker.ts";
10
+ import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
11
+ import type { SemanticIndex } from "./semantic-index.ts";
12
+ import { enrichForBm25 } from "./sparse.ts";
13
+ import { buildSemanticIndex } from "./vector-storage.ts";
14
+
15
+ const DEFAULT_PIPELINE_EMBED_BATCH = 64;
16
+ const DEFAULT_PIPELINE_EMBED_INFLIGHT = 8;
17
+
18
+ function resolvePipelineEmbedBatch(): number {
19
+ return (
20
+ envOptionalInt(["MIRU_PIPELINE_EMBED_BATCH", "SEMBLE_PIPELINE_EMBED_BATCH"], 1) ??
21
+ DEFAULT_PIPELINE_EMBED_BATCH
22
+ );
23
+ }
24
+
25
+ function resolvePipelineEmbedInflight(): number {
26
+ return (
27
+ envOptionalInt(["MIRU_PIPELINE_EMBED_INFLIGHT", "SEMBLE_PIPELINE_EMBED_INFLIGHT"], 1) ??
28
+ DEFAULT_PIPELINE_EMBED_INFLIGHT
29
+ );
30
+ }
31
+
32
+ export async function createIndexFromPath(
33
+ path: string,
34
+ embeddings: EmbeddingBackend,
35
+ content: ContentType[] = ["code"],
36
+ displayRoot?: string,
37
+ ): Promise<{ bm25: BM25Index; semantic: SemanticIndex; chunks: Chunk[] }> {
38
+ const profile = process.env.MIRU_PROFILE === "1" || process.env.SEMBLE_PROFILE === "1";
39
+ const started = performance.now();
40
+ let fileProcessMs = 0;
41
+ let embedBackpressureWaitMs = 0;
42
+ let fileEnumerated = 0;
43
+ let fileTasks = 0;
44
+ let emptyFileTasks = 0;
45
+
46
+ if (profile && "resetStats" in embeddings && typeof embeddings.resetStats === "function") {
47
+ embeddings.resetStats();
48
+ }
49
+
50
+ const resolved = resolve(path);
51
+ const root = displayRoot ? resolve(displayRoot) : resolved;
52
+ const extensions = getExtensions(content);
53
+ const fileConcurrency = resolveWorkerConcurrency();
54
+ const embedBatchSize = resolvePipelineEmbedBatch();
55
+ const maxEmbedInflight = resolvePipelineEmbedInflight();
56
+ const readyBySeq = new Map<number, Chunk[]>();
57
+ const pendingEmbedChunks: Chunk[] = [];
58
+ const emittedChunkBatches: Chunk[][] = [];
59
+ const embedInFlight = new Set<Promise<Float32Array[]>>();
60
+ const embedPromises: Promise<Float32Array[]>[] = [];
61
+ let fileSeq = 0;
62
+ let nextEmitSeq = 0;
63
+
64
+ const processFile = async (filePath: string): Promise<Chunk[]> => {
65
+ const t = performance.now();
66
+ const language = detectLanguage(filePath);
67
+ try {
68
+ const status = await getFileStatus(filePath);
69
+ if (status !== "valid") {
70
+ emptyFileTasks++;
71
+ return [] as Chunk[];
72
+ }
73
+ const source = await readFileText(filePath);
74
+ const chunkPath = displayRoot ? relative(root, filePath).replace(/\\/g, "/") : filePath;
75
+ return chunkSource(source, chunkPath, language);
76
+ } catch {
77
+ emptyFileTasks++;
78
+ return [];
79
+ } finally {
80
+ fileProcessMs += performance.now() - t;
81
+ }
82
+ };
83
+
84
+ const flushReadySequentialChunks = (): void => {
85
+ while (readyBySeq.has(nextEmitSeq)) {
86
+ const chunks = readyBySeq.get(nextEmitSeq);
87
+ readyBySeq.delete(nextEmitSeq);
88
+ if (chunks) {
89
+ pendingEmbedChunks.push(...chunks);
90
+ }
91
+ nextEmitSeq++;
92
+ }
93
+ };
94
+
95
+ const maybeScheduleEmbed = async (force = false): Promise<void> => {
96
+ while (
97
+ pendingEmbedChunks.length > 0 &&
98
+ (force || pendingEmbedChunks.length >= embedBatchSize)
99
+ ) {
100
+ if (embedInFlight.size >= maxEmbedInflight) {
101
+ const waitStart = performance.now();
102
+ await Promise.race(embedInFlight);
103
+ embedBackpressureWaitMs += performance.now() - waitStart;
104
+ continue;
105
+ }
106
+
107
+ const take = force
108
+ ? pendingEmbedChunks.length
109
+ : Math.min(embedBatchSize, pendingEmbedChunks.length);
110
+ const batch = pendingEmbedChunks.splice(0, take);
111
+ emittedChunkBatches.push(batch);
112
+ const promise = embeddings.embedDocuments(batch.map((chunk) => chunk.content));
113
+ embedPromises.push(promise);
114
+ embedInFlight.add(promise);
115
+ promise.finally(() => {
116
+ embedInFlight.delete(promise);
117
+ });
118
+ }
119
+ };
120
+
121
+ const runningFiles = new Set<Promise<void>>();
122
+
123
+ const startFileTask = (filePath: string, seq: number): void => {
124
+ const task = (async () => {
125
+ const chunks = await processFile(filePath);
126
+ readyBySeq.set(seq, chunks);
127
+ flushReadySequentialChunks();
128
+ await maybeScheduleEmbed(false);
129
+ })().finally(() => {
130
+ runningFiles.delete(task);
131
+ });
132
+ runningFiles.add(task);
133
+ };
134
+
135
+ for await (const filePath of walkFiles(resolved, extensions)) {
136
+ fileEnumerated++;
137
+ while (runningFiles.size >= fileConcurrency) {
138
+ await Promise.race(runningFiles);
139
+ }
140
+ fileTasks++;
141
+ startFileTask(filePath, fileSeq++);
142
+ }
143
+
144
+ while (runningFiles.size > 0) {
145
+ await Promise.race(runningFiles);
146
+ }
147
+
148
+ flushReadySequentialChunks();
149
+ await maybeScheduleEmbed(true);
150
+ await Promise.all(embedPromises);
151
+
152
+ const chunks = emittedChunkBatches.flat();
153
+
154
+ if (chunks.length === 0) {
155
+ throw new Error(`No supported files found under ${path}.`);
156
+ }
157
+
158
+ const vectors = (await Promise.all(embedPromises)).flat();
159
+ const bm25 = new BM25Index();
160
+ bm25.index(chunks.map((c) => tokenize(enrichForBm25(c))));
161
+ const semantic = buildSemanticIndex(vectors);
162
+
163
+ if (profile) {
164
+ const finished = performance.now();
165
+ const embedStats =
166
+ "getStats" in embeddings && typeof embeddings.getStats === "function"
167
+ ? embeddings.getStats()
168
+ : null;
169
+ console.error(
170
+ JSON.stringify({
171
+ profile: "index_build",
172
+ path: resolved,
173
+ elapsed_ms: finished - started,
174
+ file_enumerated: fileEnumerated,
175
+ file_tasks: fileTasks,
176
+ empty_or_skipped_files: emptyFileTasks,
177
+ chunks: chunks.length,
178
+ vectors: vectors.length,
179
+ pipeline: {
180
+ file_concurrency: fileConcurrency,
181
+ embed_batch_size: embedBatchSize,
182
+ embed_inflight: maxEmbedInflight,
183
+ emitted_batches: emittedChunkBatches.length,
184
+ },
185
+ stage_ms: {
186
+ file_process_total: fileProcessMs,
187
+ embed_backpressure_wait: embedBackpressureWaitMs,
188
+ },
189
+ embedding_transport: embedStats,
190
+ }),
191
+ );
192
+ }
193
+
194
+ return { bm25, semantic, chunks };
195
+ }
@@ -0,0 +1,105 @@
1
+ /** Cosine-distance vector index with optional chunk selector. */
2
+ import { mkdir } from "node:fs/promises";
3
+ import type { SemanticIndex } from "./semantic-index.ts";
4
+ import { selectTopKByDistance } from "./topk.ts";
5
+
6
+ export class VectorIndex implements SemanticIndex {
7
+ private vectors: Float32Array[];
8
+
9
+ constructor(vectors: Float32Array[]) {
10
+ this.vectors = vectors;
11
+ }
12
+
13
+ get size(): number {
14
+ return this.vectors.length;
15
+ }
16
+
17
+ get dimensions(): number {
18
+ return this.vectors[0]?.length ?? 0;
19
+ }
20
+
21
+ getVectors(): readonly Float32Array[] {
22
+ return this.vectors;
23
+ }
24
+
25
+ memoryBytes(): number {
26
+ return this.vectors.length * this.dimensions * 4;
27
+ }
28
+
29
+ query(
30
+ queryVector: Float32Array,
31
+ k: number,
32
+ selector?: number[],
33
+ ): { indices: number[]; distances: number[] } {
34
+ if (k < 1) {
35
+ throw new Error(`k should be >= 1, is now ${k}`);
36
+ }
37
+
38
+ const numVectors = this.vectors.length;
39
+ if (numVectors === 0) {
40
+ return { indices: [], distances: [] };
41
+ }
42
+
43
+ const indices = selector ?? Array.from({ length: numVectors }, (_, i) => i);
44
+ const effectiveK = Math.min(k, indices.length);
45
+ if (effectiveK === 0) {
46
+ return { indices: [], distances: [] };
47
+ }
48
+
49
+ const top = selectTopKByDistance(
50
+ indices.flatMap((idx) => {
51
+ const vec = this.vectors[idx];
52
+ if (!vec) {
53
+ return [];
54
+ }
55
+ return [{ index: idx, distance: cosineDistance(queryVector, vec) }];
56
+ }),
57
+ effectiveK,
58
+ );
59
+
60
+ return {
61
+ indices: top.map((t) => t.index),
62
+ distances: top.map((t) => t.distance),
63
+ };
64
+ }
65
+
66
+ async save(dir: string): Promise<void> {
67
+ await mkdir(dir, { recursive: true });
68
+
69
+ const dim = this.dimensions;
70
+ const count = this.size;
71
+ const buffer = new Float32Array(count * dim);
72
+ for (let i = 0; i < count; i++) {
73
+ const vec = this.vectors[i];
74
+ if (vec) {
75
+ buffer.set(vec, i * dim);
76
+ }
77
+ }
78
+ await Bun.write(`${dir}/vectors.bin`, buffer);
79
+ await Bun.write(
80
+ `${dir}/meta.json`,
81
+ JSON.stringify({ count, dimensions: dim, storage: "float32" }),
82
+ );
83
+ }
84
+
85
+ static async load(dir: string): Promise<VectorIndex> {
86
+ const meta = JSON.parse(await Bun.file(`${dir}/meta.json`).text()) as {
87
+ count: number;
88
+ dimensions: number;
89
+ };
90
+ const raw = new Float32Array(await Bun.file(`${dir}/vectors.bin`).arrayBuffer());
91
+ const vectors: Float32Array[] = [];
92
+ for (let i = 0; i < meta.count; i++) {
93
+ vectors.push(raw.subarray(i * meta.dimensions, (i + 1) * meta.dimensions));
94
+ }
95
+ return new VectorIndex(vectors);
96
+ }
97
+ }
98
+
99
+ function cosineDistance(a: Float32Array, b: Float32Array): number {
100
+ let dot = 0;
101
+ for (let i = 0; i < a.length; i++) {
102
+ dot += (a[i] ?? 0) * (b[i] ?? 0);
103
+ }
104
+ return 1 - dot;
105
+ }
@@ -0,0 +1,113 @@
1
+ import type { Dirent } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+ import ignore, { type Ignore } from "ignore";
5
+
6
+ const DEFAULT_IGNORED_DIRS = new Set([
7
+ ".git",
8
+ ".hg",
9
+ ".svn",
10
+ "__pycache__",
11
+ "node_modules",
12
+ ".venv",
13
+ "venv",
14
+ ".tox",
15
+ ".mypy_cache",
16
+ ".pytest_cache",
17
+ ".ruff_cache",
18
+ ".cache",
19
+ ".miru",
20
+ ".next",
21
+ "dist",
22
+ "build",
23
+ ".eggs",
24
+ ]);
25
+
26
+ interface IgnoreSpec {
27
+ base: string;
28
+ spec: Ignore;
29
+ }
30
+
31
+ async function loadIgnoreForDir(directory: string): Promise<IgnoreSpec | null> {
32
+ const paths = [join(directory, ".gitignore"), join(directory, ".miruignore")];
33
+ const lines: string[] = [];
34
+
35
+ for (const p of paths) {
36
+ const file = Bun.file(p);
37
+ if (await file.exists()) {
38
+ lines.push(...(await file.text()).split("\n"));
39
+ }
40
+ }
41
+
42
+ if (lines.length === 0) {
43
+ return null;
44
+ }
45
+ return {
46
+ base: directory,
47
+ spec: ignore().add(lines),
48
+ };
49
+ }
50
+
51
+ function isIgnoredBySpecs(fullPath: string, isDir: boolean, specs: IgnoreSpec[]): boolean {
52
+ for (const spec of specs) {
53
+ const relToSpec = relative(spec.base, fullPath).replace(/\\/g, "/");
54
+ if (!relToSpec || relToSpec.startsWith("..")) {
55
+ continue;
56
+ }
57
+ if (spec.spec.ignores(isDir ? `${relToSpec}/` : relToSpec)) {
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ export async function* walkFiles(root: string, extensions: string[]): AsyncGenerator<string> {
65
+ const extSet = new Set(extensions.map((e) => e.toLowerCase()));
66
+ const loadedSpecs = new Map<string, IgnoreSpec | null>();
67
+
68
+ async function* walk(dir: string, inheritedSpecs: IgnoreSpec[]): AsyncGenerator<string> {
69
+ let entries: Dirent[];
70
+ try {
71
+ entries = await readdir(dir, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+
76
+ let spec = loadedSpecs.get(dir);
77
+ if (spec === undefined) {
78
+ spec = (await loadIgnoreForDir(dir)) ?? null;
79
+ loadedSpecs.set(dir, spec);
80
+ }
81
+ const activeSpecs = spec ? [...inheritedSpecs, spec] : inheritedSpecs;
82
+
83
+ for (const entry of entries) {
84
+ const fullPath = join(dir, entry.name);
85
+
86
+ if (entry.isDirectory()) {
87
+ if (DEFAULT_IGNORED_DIRS.has(entry.name)) {
88
+ continue;
89
+ }
90
+ if (isIgnoredBySpecs(fullPath, true, activeSpecs)) {
91
+ continue;
92
+ }
93
+ yield* walk(fullPath, activeSpecs);
94
+ continue;
95
+ }
96
+
97
+ if (!entry.isFile()) {
98
+ continue;
99
+ }
100
+
101
+ if (isIgnoredBySpecs(fullPath, false, activeSpecs)) {
102
+ continue;
103
+ }
104
+
105
+ const ext = entry.name.includes(".") ? `.${entry.name.split(".").pop()?.toLowerCase()}` : "";
106
+ if (entry.name.toLowerCase() === "dockerfile" || extSet.has(ext)) {
107
+ yield fullPath;
108
+ }
109
+ }
110
+ }
111
+
112
+ yield* walk(root, []);
113
+ }