@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
165 lines • 7.24 kB
JavaScript
// ========= 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