@stacknyu/pi-check 0.0.0-stage → 1.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.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/cli.mjs +348 -0
- package/extension.ts +215 -0
- package/lib/adapters/custom.mjs +35 -0
- package/lib/adapters/go.mjs +358 -0
- package/lib/adapters/python.mjs +320 -0
- package/lib/adapters/ts.mjs +445 -0
- package/lib/broker.mjs +1189 -0
- package/lib/client.mjs +131 -0
- package/lib/commands.mjs +27 -0
- package/lib/repo.mjs +185 -0
- package/lib/util.mjs +395 -0
- package/package.json +46 -3
- package/skills/pi-check/SKILL.md +69 -0
package/lib/client.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Talk to the repo's broker, starting it when needed.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { chmodSync, mkdirSync, openSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { HOME_DIR, SHIM_DIR, STATE_DIR, VERSION, loadJson, pidAlive, repoRoots } from "./util.mjs";
|
|
8
|
+
|
|
9
|
+
// Worktree plugin links must not publish a temporary worktree path in the global CLI registry.
|
|
10
|
+
const HERE = realpathSync(dirname(fileURLToPath(import.meta.url)));
|
|
11
|
+
export const BROKER = join(HERE, "broker.mjs");
|
|
12
|
+
export const CLI = join(HERE, "..", "cli.mjs");
|
|
13
|
+
export { SHIM_DIR };
|
|
14
|
+
|
|
15
|
+
export function cleanPath(p = process.env.PATH ?? "") {
|
|
16
|
+
return p
|
|
17
|
+
.split(delimiter)
|
|
18
|
+
.filter((d) => d && resolve(d) !== resolve(SHIM_DIR))
|
|
19
|
+
.join(delimiter);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function request(info, path, body, onEvent, timeoutMs, signal) {
|
|
23
|
+
return new Promise((res, rej) => {
|
|
24
|
+
const data = body === undefined ? undefined : JSON.stringify(body);
|
|
25
|
+
const req = http.request({ host: "127.0.0.1", port: info.port, path, method: data === undefined ? "GET" : "POST", signal, headers: { "x-pi-check-token": info.token, "content-type": "application/json" } }, (r) => {
|
|
26
|
+
r.on("error", rej);
|
|
27
|
+
let buf = "";
|
|
28
|
+
let last;
|
|
29
|
+
const nd = String(r.headers["content-type"] ?? "").includes("ndjson");
|
|
30
|
+
r.on("data", (d) => {
|
|
31
|
+
buf += d;
|
|
32
|
+
if (!nd) return;
|
|
33
|
+
let i;
|
|
34
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
35
|
+
const line = buf.slice(0, i);
|
|
36
|
+
buf = buf.slice(i + 1);
|
|
37
|
+
if (!line.trim()) continue;
|
|
38
|
+
try {
|
|
39
|
+
const ev = JSON.parse(line);
|
|
40
|
+
if (ev.type === "result" || ev.type === "error") last = ev;
|
|
41
|
+
else onEvent?.(ev);
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
r.on("end", () => {
|
|
46
|
+
if (nd) {
|
|
47
|
+
if (last?.type === "error") return rej(new Error(last.error));
|
|
48
|
+
return last ? res(last) : rej(new Error("broker closed the connection without a result"));
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const j = JSON.parse(buf || "{}");
|
|
52
|
+
if (r.statusCode >= 400) rej(new Error(j.error ?? `HTTP ${r.statusCode}`));
|
|
53
|
+
else res(j);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
rej(e);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
req.on("error", rej);
|
|
60
|
+
if (timeoutMs) req.setTimeout(timeoutMs, () => req.destroy(new Error("timeout")));
|
|
61
|
+
if (data !== undefined) req.write(data);
|
|
62
|
+
req.end();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function ping(info) {
|
|
67
|
+
try {
|
|
68
|
+
const r = await request(info, "/ping", undefined, undefined, 2000);
|
|
69
|
+
return r?.ok ? r : undefined;
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
76
|
+
|
|
77
|
+
/** -> { info, root, main } ; starts the broker unless start=false. */
|
|
78
|
+
export async function connect(cwd, { start = true } = {}) {
|
|
79
|
+
const rr = repoRoots(cwd);
|
|
80
|
+
if (!rr) throw new Error(`${cwd} is not inside a git repository`);
|
|
81
|
+
const dir = join(rr.main, STATE_DIR);
|
|
82
|
+
const file = join(dir, "broker.json");
|
|
83
|
+
let info = loadJson(file);
|
|
84
|
+
if (info && pidAlive(info.pid)) {
|
|
85
|
+
const p = await ping(info);
|
|
86
|
+
if (!p) throw new Error("pi-check broker is alive but unresponsive; no duplicate broker was started");
|
|
87
|
+
if (p.version === VERSION) return { info, ...rr };
|
|
88
|
+
if (p && p.version !== VERSION) {
|
|
89
|
+
const status = await request(info, "/status", undefined, undefined, 3000);
|
|
90
|
+
if (status.running?.length || status.pending?.requested) {
|
|
91
|
+
throw new Error("pi-check upgrade pending: existing checks are still running; let them finish before reloading");
|
|
92
|
+
}
|
|
93
|
+
if (!start) return { info, ...rr };
|
|
94
|
+
// Only replace an idle broker; never kill another session's verification.
|
|
95
|
+
try {
|
|
96
|
+
await request(info, "/stop", {}, undefined, 3000);
|
|
97
|
+
} catch {}
|
|
98
|
+
await sleep(800);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!start) return { info: undefined, ...rr };
|
|
102
|
+
mkdirSync(dir, { recursive: true });
|
|
103
|
+
const log = openSync(join(dir, "broker.out"), "a");
|
|
104
|
+
const env = { ...process.env, PATH: cleanPath() };
|
|
105
|
+
delete env.PI_CHECK_SHIM_DIR;
|
|
106
|
+
spawn(process.execPath, [BROKER, rr.main], { cwd: rr.main, detached: true, stdio: ["ignore", log, log], env }).unref();
|
|
107
|
+
for (let i = 0; i < 100; i++) {
|
|
108
|
+
await sleep(150);
|
|
109
|
+
info = loadJson(file);
|
|
110
|
+
if (info && pidAlive(info.pid) && (await ping(info))) return { info, ...rr };
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`pi-check broker did not start (see ${join(dir, "broker.out")})`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function call(cwd, path, body, onEvent, opts = {}) {
|
|
116
|
+
opts.signal?.throwIfAborted();
|
|
117
|
+
const c = await connect(cwd, opts);
|
|
118
|
+
opts.signal?.throwIfAborted();
|
|
119
|
+
if (!c.info) return undefined;
|
|
120
|
+
return request(c.info, path, body === undefined ? undefined : { root: c.root, ...body }, onEvent, opts.timeoutMs, opts.signal);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `go` shim that routes `go test` / `go vet` through the broker. */
|
|
124
|
+
export function installShims(node = process.execPath) {
|
|
125
|
+
mkdirSync(SHIM_DIR, { recursive: true });
|
|
126
|
+
const go = join(SHIM_DIR, "go");
|
|
127
|
+
writeFileSync(go, `#!/bin/sh\n# pi-check shim: go test / go vet go through the shared check broker; everything else is the real go.\nPI_CHECK_SHIM_DIR='${SHIM_DIR}' exec '${node}' '${CLI}' shim go "$@"\n`);
|
|
128
|
+
chmodSync(go, 0o755);
|
|
129
|
+
writeFileSync(join(HOME_DIR, "cli.json"), JSON.stringify({ cli: CLI, node, shims: SHIM_DIR, version: VERSION }, null, 2));
|
|
130
|
+
return SHIM_DIR;
|
|
131
|
+
}
|
package/lib/commands.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Which bash commands are checks (routed through the broker) and which run go test/vet (go shim).
|
|
2
|
+
// Commands whose results depend only on the files: safe to share and replay.
|
|
3
|
+
const RUNNER = /^(npx|bunx|pnpm(\s+(exec|dlx))?|yarn(\s+exec)?|bun\s+x)\s+/;
|
|
4
|
+
const CHECK_TOOL = /^(vitest|jest|pytest|tsc|vue-tsc|eslint|oxlint|ruff|mypy|pyright)(\s|$)/;
|
|
5
|
+
const PY_MODULE = /^((python3?|uv\s+run(\s+python3?)?|poetry\s+run(\s+python3?)?)\s+-m\s+(pytest|mypy|ruff)|(uv|poetry)\s+run\s+(pytest|mypy|ruff|pyright))(\s|$)/;
|
|
6
|
+
const PKG_SCRIPT = /^(pnpm|npm|yarn|bun)\s+((-r|--recursive|-w|--filter\s+\S+|-F\s+\S+|--workspace\s+\S+|--prefix\s+\S+|-C\s+\S+|--dir\s+\S+|--silent|-s)\s+)*(run\s+)?(test|typecheck|type-check|lint|check|tsc|test:[\w:-]+|lint:[\w:-]+)(\s|$)/;
|
|
7
|
+
// …unless they change files, watch, or install.
|
|
8
|
+
const SIDE_EFFECTS = /--fix\b|--write\b|\s-u(\s|$)|--update(Snapshot)?\b|--watch\b|\bvitest\s+(watch|dev)\b|(npm|pnpm|yarn|bun|pip3?|uv|poetry)\s+(install|add|i|sync)\b|--init\b|\bformat\b|\bprettier\b|--generate|--emit\b|--build\b|\btsc\s+(-b|--build)\b|>\s*[\w./-]+\.(ts|tsx|js|py|go|json)\b/;
|
|
9
|
+
|
|
10
|
+
export function segments(command) {
|
|
11
|
+
return command
|
|
12
|
+
.split(/&&|\|\||;|\||\n|\(|\)/)
|
|
13
|
+
.map((s) => s.trim().replace(/^(\w+=\S*\s+)+/, "").replace(/^(time|env|nice(\s+-n\s*-?\d+)?)\s+/, ""))
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isCheckCommand(command) {
|
|
18
|
+
if (SIDE_EFFECTS.test(command)) return false;
|
|
19
|
+
return segments(command).some((seg) => {
|
|
20
|
+
const s = seg.replace(RUNNER, "");
|
|
21
|
+
return CHECK_TOOL.test(s) || PY_MODULE.test(seg) || PKG_SCRIPT.test(seg);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isGoCommand(command) {
|
|
26
|
+
return segments(command).some((s) => /^go\s+(test|vet)(\s|$)/.test(s) || /^(make|just|task|mage)(\s|$)/.test(s));
|
|
27
|
+
}
|
package/lib/repo.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// A checkout (main repo or a worktree): its file listing, content digests, changed files, and the repo config.
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CPUS, STATE_DIR, FileHasher, git, isUnder, loadJson, matchAny, sha } from "./util.mjs";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_FILE = "pi-check.json";
|
|
7
|
+
|
|
8
|
+
const ALWAYS_SKIP = [".pi-queue/", ".pi-check/", ".pi-flow/", ".tasks/", ".git/"];
|
|
9
|
+
|
|
10
|
+
export const DEFAULTS = {
|
|
11
|
+
maxJobs: 0, // 0 = available CPUs; independent from the number of agent sessions
|
|
12
|
+
cpuBudget: 0, // 0 = all available CPUs; a shared budget, not a utilization target
|
|
13
|
+
threads: 0, // 0 = divide spare budget among runnable jobs; positive = per-job ceiling
|
|
14
|
+
fastLane: true, // reserve one job slot and CPU for short checks when capacity permits
|
|
15
|
+
nice: 10,
|
|
16
|
+
watch: true,
|
|
17
|
+
speculate: true, // run affected checks ahead of time when files change
|
|
18
|
+
warmBase: false, // opt in to a full baseline warm-up; normal speculation is affected-only
|
|
19
|
+
speculateMaxLoad: 0.75, // only speculate while load average < cores * this
|
|
20
|
+
debounceMs: 2500,
|
|
21
|
+
intercept: "route", // route | block | off: what happens to raw test commands agents run in bash
|
|
22
|
+
shimGo: true, // `go test` / `go vet` inside agent shells go through the broker
|
|
23
|
+
base: "", // base branch for "changed" scope; default: the branch the main checkout is on
|
|
24
|
+
ignore: [], // globs never treated as check inputs (generated output, data dirs)
|
|
25
|
+
languages: { go: true, ts: true, python: true },
|
|
26
|
+
projects: {}, // "<dir>": { inputs: [...], exclude: [...], <kind>: false | { args, env, timeout, parallel, cmd, inputs } }
|
|
27
|
+
custom: [], // [{ name, cwd, cmd, inputs: [globs], timeout, env, parallel }]
|
|
28
|
+
groups: {}, // concurrency per group name, overrides defaults
|
|
29
|
+
idleMinutes: 180,
|
|
30
|
+
cacheDays: 21,
|
|
31
|
+
strict: false, // true: failures that also fail on the base branch still fail the check
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function loadConfig(main) {
|
|
35
|
+
const shared = loadJson(join(main, CONFIG_FILE), {}) ?? {};
|
|
36
|
+
const local = loadJson(join(main, STATE_DIR, "config.json"), {}) ?? {};
|
|
37
|
+
const c = { ...DEFAULTS, ...shared, ...local };
|
|
38
|
+
c.languages = { ...DEFAULTS.languages, ...(shared.languages ?? {}), ...(local.languages ?? {}) };
|
|
39
|
+
c.projects = { ...(shared.projects ?? {}), ...(local.projects ?? {}) };
|
|
40
|
+
c.groups = { ...(shared.groups ?? {}), ...(local.groups ?? {}) };
|
|
41
|
+
c.custom = [...(shared.custom ?? []), ...(local.custom ?? [])];
|
|
42
|
+
for (const key of ["cpuBudget", "maxJobs", "threads"]) {
|
|
43
|
+
if (!Number.isInteger(c[key]) || c[key] < 0) throw new Error(`${key} must be a non-negative integer`);
|
|
44
|
+
}
|
|
45
|
+
c.cpuBudget = c.cpuBudget || CPUS;
|
|
46
|
+
c.maxJobs = Math.min(c.maxJobs || c.cpuBudget, c.cpuBudget);
|
|
47
|
+
if (typeof c.fastLane !== "boolean") throw new Error("fastLane must be a boolean");
|
|
48
|
+
const laneOptions = [...c.custom, ...Object.values(c.projects).flatMap((p) => [p.test, p.vet, p.lint, p.typecheck, p.custom])];
|
|
49
|
+
for (const opts of laneOptions) if (opts?.lane !== undefined && !["fast", "heavy"].includes(opts.lane)) throw new Error("lane must be fast or heavy");
|
|
50
|
+
return c;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Per-project, per-kind options. `false` disables the kind. */
|
|
54
|
+
export function kindOptions(config, projectDir, kind) {
|
|
55
|
+
const p = config.projects?.[projectDir] ?? config.projects?.[projectDir === "." ? "" : projectDir] ?? {};
|
|
56
|
+
const k = p[kind];
|
|
57
|
+
if (k === false) return false;
|
|
58
|
+
return { ...(k && typeof k === "object" ? k : {}), inputs: [...(p.inputs ?? []), ...((k && k.inputs) || [])], exclude: [...(p.exclude ?? []), ...((k && k.exclude) || [])] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class RootCtx {
|
|
62
|
+
constructor(abs, main, hasher = new FileHasher(), config = loadConfig(main)) {
|
|
63
|
+
this.abs = abs;
|
|
64
|
+
this.main = main;
|
|
65
|
+
this.isMain = abs === main;
|
|
66
|
+
this.hasher = hasher;
|
|
67
|
+
this.config = config;
|
|
68
|
+
this._listing = undefined;
|
|
69
|
+
this.version = 0;
|
|
70
|
+
this.memo = new Map(); // adapter memos, cleared on change
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
invalidate(structural = true) {
|
|
74
|
+
this.version++;
|
|
75
|
+
if (structural) this._listing = undefined;
|
|
76
|
+
this.memo.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
path(r) {
|
|
80
|
+
return r === "." ? this.abs : join(this.abs, r);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** All files git would see (tracked + untracked, not ignored, not deleted), repo-relative. */
|
|
84
|
+
listing() {
|
|
85
|
+
if (this._listing) return this._listing;
|
|
86
|
+
const a = git(this.abs, ["ls-files", "-co", "--exclude-standard", "-z"]);
|
|
87
|
+
const d = git(this.abs, ["ls-files", "-d", "-z"]);
|
|
88
|
+
const deleted = new Set(d.out.split("\0").filter(Boolean));
|
|
89
|
+
const ignore = this.config.ignore ?? [];
|
|
90
|
+
const files = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const f of a.out.split("\0")) {
|
|
93
|
+
if (!f || deleted.has(f) || seen.has(f)) continue;
|
|
94
|
+
if (ALWAYS_SKIP.some((s) => f.startsWith(s))) continue;
|
|
95
|
+
if (ignore.length && matchAny(ignore, f)) continue;
|
|
96
|
+
seen.add(f);
|
|
97
|
+
files.push(f);
|
|
98
|
+
}
|
|
99
|
+
files.sort();
|
|
100
|
+
this._listing = { files, set: seen, at: Date.now() };
|
|
101
|
+
return this._listing;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
has(r) {
|
|
105
|
+
return this.listing().set.has(r);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
filesUnder(dir, filter) {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const f of this.listing().files) if (isUnder(f, dir) && (!filter || filter(f))) out.push(f);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
hash(r) {
|
|
115
|
+
if (this._hc) {
|
|
116
|
+
let h = this._hc.get(r);
|
|
117
|
+
if (h === undefined) this._hc.set(r, (h = this.hasher.hash(this.path(r))));
|
|
118
|
+
return h;
|
|
119
|
+
}
|
|
120
|
+
return this.hasher.hash(this.path(r));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Within fn, each file is stat'ed at most once (many units share inputs). */
|
|
124
|
+
batch(fn) {
|
|
125
|
+
const outer = this._hc;
|
|
126
|
+
if (!outer) this._hc = new Map();
|
|
127
|
+
try {
|
|
128
|
+
return fn();
|
|
129
|
+
} finally {
|
|
130
|
+
if (!outer) this._hc = undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
text(r) {
|
|
135
|
+
return this.hasher.text(this.path(r));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Digest of a set of repo-relative files: same contents => same digest, in any checkout. */
|
|
139
|
+
digest(files) {
|
|
140
|
+
const list = [...new Set(files)].sort();
|
|
141
|
+
const h = [];
|
|
142
|
+
for (const f of list) h.push(`${f}\0${this.hash(f)}`);
|
|
143
|
+
return sha(h.join("\n"));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Branch the main checkout has out (the queue's integration branch), or config.base. */
|
|
147
|
+
baseRef() {
|
|
148
|
+
if (this.config.base) return this.config.base;
|
|
149
|
+
const b = git(this.main, ["symbolic-ref", "--short", "-q", "HEAD"]);
|
|
150
|
+
return b.ok && b.out.trim() ? b.out.trim() : "HEAD";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Files changed vs the merge base with the base branch, plus uncommitted and untracked ones. */
|
|
154
|
+
changedFiles(baseRef = this.baseRef()) {
|
|
155
|
+
const mb = git(this.abs, ["merge-base", "HEAD", baseRef]);
|
|
156
|
+
const from = mb.ok ? mb.out.trim() : "HEAD";
|
|
157
|
+
const d = git(this.abs, ["diff", "--name-only", "-z", from]);
|
|
158
|
+
const u = git(this.abs, ["ls-files", "-o", "--exclude-standard", "-z"]);
|
|
159
|
+
const out = new Set();
|
|
160
|
+
for (const f of `${d.out}\0${u.out}`.split("\0")) if (f && !ALWAYS_SKIP.some((s) => f.startsWith(s))) out.add(f);
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Uncommitted changes only (vs HEAD). */
|
|
165
|
+
dirtyFiles() {
|
|
166
|
+
const d = git(this.abs, ["diff", "--name-only", "-z", "HEAD"]);
|
|
167
|
+
const u = git(this.abs, ["ls-files", "-o", "--exclude-standard", "-z"]);
|
|
168
|
+
return new Set(`${d.out}\0${u.out}`.split("\0").filter((f) => f && !ALWAYS_SKIP.some((s) => f.startsWith(s))));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
head() {
|
|
172
|
+
const h = git(this.abs, ["rev-parse", "HEAD"]);
|
|
173
|
+
return h.ok ? h.out.trim() : "";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb", "bun.lock", "go.work", "go.work.sum", "uv.lock", "poetry.lock", "Pipfile.lock"];
|
|
178
|
+
|
|
179
|
+
export function rootLockfiles(rc) {
|
|
180
|
+
return LOCKFILES.filter((f) => rc.has(f));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function exists(rc, r) {
|
|
184
|
+
return rc.has(r) || existsSync(rc.path(r));
|
|
185
|
+
}
|