UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

165 lines • 7.24 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { PathSpec } from "../../types.js"; import { BINARY_EXTENSIONS, getExtension } from "../../utils/filetype.js"; import { mountKey, mountPrefixOf, rawPathOf } from "../../utils/key_prefix.js"; import { lstripSlash, stripSlash } from "../../utils/slash.js"; import { SCOPE_WARN, SEARCH_PAGE_SIZE } from "./constants.js"; import { countScopeFiles, searchSafe, unsearchableKeys } from "./pushdown.js"; // The literal is sent verbatim, so the answer has to vouch for itself: an // item is kept only when its repository.full_name names this repository // (compared case-insensitively, as GitHub resolves `repo:`), and the answer is // complete only when incomplete_results is false and total_count is an // integer no larger than the rows returned. A missing or malformed field // counts against it, which costs a full scan and never a missed file. export async function searchCode(transport, owner, repo, query, pathFilter) { let q = `${query} repo:${owner}/${repo}`; if (pathFilter !== undefined && pathFilter !== '') q += ` path:${pathFilter}`; const data = (await transport.get(`/search/code`, { q, per_page: String(SEARCH_PAGE_SIZE), })); const items = data.items ?? []; const total = data.total_count; const complete = data.incomplete_results === false && typeof total === 'number' && Number.isInteger(total) && total <= items.length; const want = `${owner}/${repo}`.toLowerCase(); const results = []; for (const it of items) { const name = it.repository?.full_name; if (typeof name === 'string' && name.toLowerCase() === want) { results.push({ path: it.path, sha: it.sha }); } } return { results, truncated: !complete }; } // Use GitHub code search to narrow grep/rg scopes to candidate files. // Returns null whenever the narrowed set cannot be trusted as a superset of // what a full scan would read (a search failure, or an answer that is not // the whole set), so the caller falls back to the full scan. A trusted set // also carries every file code search never indexes, which no answer can // name; those come from the accessor's tree. export async function narrowPaths(accessor, query, paths) { const first = paths[0]; if (first === undefined) return []; const mountPrefix = mountPrefixOf(first.virtual, first.vfsPath); const narrowed = []; for (const p of paths) { const key = rawPathOf(p); const pathFilter = stripSlash(key); let answer; try { answer = await searchCode(accessor.account.transport, accessor.owner, accessor.repo, query, pathFilter === '' ? undefined : pathFilter); } catch (err) { console.debug(`github code search failed (${String(err)}); falling back to per-file scan`); return null; } if (answer.truncated) return null; const scopePrefix = pathFilter === '' ? '' : `${pathFilter}/`; const hits = answer.results .map((r) => r.path) .filter((path) => path === pathFilter || path.startsWith(scopePrefix)); const seen = new Set(hits); for (const item of hits) narrowed.push(item); for (const k of unsearchableKeys(accessor.tree, key)) if (!seen.has(k)) narrowed.push(k); } return narrowed.map((n) => { const virtual = `${mountPrefix}/${lstripSlash(n)}`; return new PathSpec({ virtual, directory: '', vfsPath: mountKey(virtual, mountPrefix), resolved: true, }); }); } // The tree is the last listing's, fetched when the mount was built and // reseated by every listing since. function scopeFiles(accessor, under) { let count = 0; for (const p of under) count += countScopeFiles(accessor.tree, rawPathOf(p)); return count; } /** * The files under `under` that may hold the whole word `text`. * * Code search answers only where it can vouch for the whole scope: more * than `SCOPE_WARN` files (fewer are cheaper to read), a tree the API did * not truncate, the default branch (the only one code search indexes), a * word the search grammar reads as plain terms (`searchSafe`), and an * answer that is the whole set (`narrowPaths`, which adds back every file * the search never indexes). An empty answer is not trusted either: the * index trails a push. A file with a binary extension is left out: the walk * reads one only under `-a`, and then whatever the answer says. */ export async function filesContaining(accessor, text, under) { if (!searchSafe(text) || scopeFiles(accessor, under) <= SCOPE_WARN || accessor.truncated || !accessor.isDefaultBranch) { return null; } const narrowed = await narrowPaths(accessor, text, under); if (narrowed === null) return null; const texts = narrowed.filter((p) => !BINARY_EXTENSIONS.has(getExtension(p.virtual) ?? '')); return texts.length > 0 ? texts : null; } /** Fetch a bounded REST search, following the server's pagination. */ export async function search(transport, kind, query, limit, sort, order) { const params = { q: query, per_page: String(Math.min(limit, 100)), page: '1', }; if (sort !== undefined) params.sort = sort; if (order !== undefined) params.order = order; const rows = []; for (let page = 1; rows.length < limit; page += 1) { params.page = String(page); const headers = { Accept: kind === 'code' ? 'application/vnd.github.text-match+json' : 'application/vnd.github.v3+json', }; const response = transport.requestWithResponse === undefined ? { data: await transport.request('GET', `/search/${kind}`, undefined, params, headers), headers: {}, } : await transport.requestWithResponse('GET', `/search/${kind}`, undefined, params, headers); const body = response.data; const items = body.items ?? []; if (!Array.isArray(items)) throw new Error('invalid search response: items must be an array'); for (const item of items.slice(0, limit - rows.length)) rows.push(item); if (items.length === 0 || !(response.headers.link ?? '').includes('rel="next"')) break; } return rows; } //# sourceMappingURL=search.js.map