pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
322 lines (308 loc) • 12.2 kB
JavaScript
/**
* Install self-test — verifies pi-lens's runtime dependency graph actually
* resolves *as installed*, independent of the host model or auth.
*
* WHY THIS EXISTS
* Issues #285 (pnpm symlink store) and #335 (nested npm install) reported
* `ResolveMessage: Cannot find package '<dep>' from ...` — pi-lens failing to
* load because a third-party RUNTIME dep was unreachable under a non-default
* package-manager layout. (The original report named `typescript`; it's since a
* devDependency — #402 — so the probe now covers the remaining runtime deps.)
* Those did not reproduce on current bun/pi, but the failure class is real and
* silent in normal dev (a flat `node_modules` always resolves). This probe makes
* it loud and CI-catchable.
*
* WHAT IT DOES
* Force-imports the modules whose TOP-LEVEL bare imports are the documented
* failure points, plus the bare specifiers directly, and checks the two
* build-script-provided assets (ast-grep CLI binary + tree-sitter grammars)
* that pnpm/bun skip by default. It also resolves the `pi.skills` manifest
* entries the way pi does, from the installed package root (#2587). It runs no
* model and needs no credentials.
*
* WHAT IT NO LONGER COVERS (#1926)
* pi supplies `typebox` and `@earendil-works/pi-tui` from its own runtime, so
* they are optional peers and no install vendors them. This probe is not pi, so
* those specifiers cannot resolve here, and `dist/index.js` throws on the FIRST
* one it imports. A host-provided miss is therefore recorded as EXPECTED rather
* than failed. The cost is real and stated plainly: evaluation stops there, so
* the entry probe no longer proves the whole bundled graph resolves, which is
* what #285 and #335 asked it to prove. Any OTHER unresolved specifier still
* fails hard, and the per-module probes below (file-utils, complexity-client,
* bootstrap) still cover that graph directly. The surviving POSITIVE proof that
* the entry loads in full is the install-smoke `pi-load` job: it installs pi,
* installs pi-lens through pi, and confirms over RPC that pi-lens registered
* its commands.
*
* USAGE
* bun scripts/install-selftest.mjs # faithful: pi's runtime is bun
* node scripts/install-selftest.mjs # also works
* Exit 0 = all critical checks passed; non-zero = at least one failed.
* `--allow-soft` downgrades the build-script-asset checks (ast-grep/grammars)
* to warnings, so a pure *resolution* regression is still a hard failure.
*/
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { HOST_PROVIDED_PACKAGES } from "./lib/host-provided-deps.mjs";
import { collectSkillEntryPaths } from "./lib/skills-predicate.mjs";
import {
findCoreGrammarDir,
resolveWebTreeSitterPackageDir,
} from "./lib/web-tree-sitter-dir.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const pkgRoot = path.resolve(here, "..");
const require = createRequire(import.meta.url);
const allowSoft = process.argv.includes("--allow-soft");
const results = [];
const record = (name, kind, ok, detail = "") =>
results.push({ name, kind, ok, detail });
/**
* Which host-provided package, if any, a resolution error is about. Returns
* undefined for every other error, so an unrelated missing package still fails.
*/
function hostProvidedMiss(err) {
const text = `${err?.code || ""} ${err?.message || err}`;
if (
!/ERR_MODULE_NOT_FOUND|Cannot find (package|module)|ResolveMessage/.test(
text,
)
) {
return undefined;
}
return HOST_PROVIDED_PACKAGES.find((name) =>
new RegExp(
`['"\`]${name.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")}['"\`]`,
).test(text),
);
}
/** Import a module by URL and record whether its (eager) dep graph resolved. */
async function probeImport(name, relPath) {
const url = new URL(
`file://${path.resolve(pkgRoot, relPath).replace(/\\/g, "/")}`,
);
try {
await import(url.href);
record(name, "resolve", true);
} catch (err) {
// #1926: pi supplies these; outside pi they cannot resolve, and the entry
// throws on the first one. Expected, not a regression. Anything else is.
const host = hostProvidedMiss(err);
if (host) {
record(
name,
"expected",
true,
`stopped at host-provided '${host}' — pi supplies it; graph beyond this point unproven here (#1926)`,
);
return;
}
record(
name,
"resolve",
false,
`${err?.code || ""} ${err?.message || err}`.trim(),
);
}
}
/** Resolve a bare specifier the way the package itself would. */
function probeResolve(spec) {
try {
require.resolve(spec);
record(spec, "resolve", true);
} catch (err) {
record(
spec,
"resolve",
false,
`${err?.code || ""} ${err?.message || err}`.trim(),
);
}
}
// --- 1. The documented failure-point modules (eager bare imports) ----------
// #3219: the package ships only bundled entries. file-utils (→ minimatch),
// complexity-client (→ tree-sitter) and bootstrap (→ all analyzers) are inlined
// in dist/index.js, so its import probes them; the installer registry is the
// bundled dist/probes/installer.js the bins share chunks with.
await probeImport("dist/index.js (entry)", "dist/index.js");
await probeImport(
"probes/installer.js (bundled installer registry)",
"dist/probes/installer.js",
);
// --- 2. Direct bare-specifier resolution -----------------------------------
// Host-provided packages are subtracted, not listed as exceptions: pi resolves
// them from its own runtime, so nothing installs them and probing one here is
// guaranteed to fail (#1926). The subtraction reads the same list the bundler
// and the packaging tests use, so it cannot drift.
const BARE_SPECIFIERS = [
"minimatch",
"typebox",
"js-yaml",
"vscode-jsonrpc",
"web-tree-sitter",
"@ast-grep/napi",
].filter((spec) => !HOST_PROVIDED_PACKAGES.includes(spec));
for (const spec of BARE_SPECIFIERS) {
probeResolve(spec);
}
record(
"host-provided specifiers skipped",
"expected",
true,
`${HOST_PROVIDED_PACKAGES.join(", ")} — pi supplies these (#1926)`,
);
// --- 3. Spawned-binary tools (the universal net) ----------------------------
// For every tool that ships its binary in a per-platform npm package
// (platformPackage: ast-grep, biome, …), if it's actually a dependency of this
// install, resolve the native binary and RUN it. Reuses the installer's own
// resolver + TOOLS registry, so any future platform-CLI tool is covered with no
// change here. This is the exact class that pnpm/bun break (skipped postinstall /
// symlink store) — the binary exists but the launcher can't reach it.
try {
const installerUrl = `file://${path
.resolve(pkgRoot, "dist/probes/installer.js")
.replace(/\\/g, "/")}`;
const { TOOLS, resolvePlatformPackageBinary } = await import(installerUrl);
for (const tool of TOOLS.filter((t) => t.platformPackage)) {
const label = `tool ${tool.id} binary`;
// Only probe tools whose main package is installed in THIS layout.
try {
require.resolve(`${tool.packageName}/package.json`);
} catch {
record(label, "tool", true, "not a dep of this install — skipped");
continue;
}
const bin = resolvePlatformPackageBinary(tool);
if (!bin) {
record(
label,
"tool",
false,
`platform binary not resolved (${process.platform}-${process.arch})`,
);
continue;
}
try {
execFileSync(bin, tool.checkArgs ?? ["--version"], {
stdio: "ignore",
timeout: 15000,
});
record(label, "tool", true, bin);
} catch (err) {
record(
label,
"tool",
false,
`${bin} failed to run: ${err?.message || err}`,
);
}
}
} catch (err) {
record(
"spawned-tool probe",
"tool",
false,
`installer load failed: ${err?.message || err}`,
);
}
// tree-sitter grammars — the core set ships INSIDE the tarball, in
// `<pkgRoot>/grammars`, and the runtime reads that dir first
// (`bundledGrammarsDir` in clients/tree-sitter-client.ts). `web-tree-sitter/
// grammars` is only the lazy-fetch write target, nothing at install time fills
// it, so probing it alone warned in every layout and the strict npm gate (#1185)
// could never drop `--allow-soft`. Check the bundled dir, then fall back to the
// web-tree-sitter dir, which the extension runtime locates through the SAME
// shared ladder (#3409 round 1, R3418-2): a bare `web-tree-sitter` resolve
// throws MODULE_NOT_FOUND inside a `bun build --compile` host.
const bundledGrammarDir = path.join(pkgRoot, "grammars");
const webTreeSitterDir = resolveWebTreeSitterPackageDir({
resolve: (specifier) => require.resolve(specifier),
packageRoot: () => pkgRoot,
cwd: () => process.cwd(),
});
const grammarDir = findCoreGrammarDir({
packageRoot: pkgRoot,
webTreeSitterDir,
});
const hasCoreGrammar = grammarDir !== undefined;
const grammarDetail = hasCoreGrammar
? grammarDir
: `tree-sitter-typescript.wasm missing in ${bundledGrammarDir}${
webTreeSitterDir
? ` and ${path.join(webTreeSitterDir, "grammars")}`
: " (web-tree-sitter dir unresolvable)"
}`;
record("tree-sitter grammars", "asset", hasCoreGrammar, grammarDetail);
// --- 4. pi.skills manifest resolution, AS INSTALLED (#2587) ----------------
// Guards the recurrence of #2587: a `pi.skills` entry that resolves outside the
// installed package, so pi registers none of the shipped skills. This probe runs
// from the INSTALLED package (`require.resolve("pi-lens/scripts/…")`), so it sees
// the real layout — hoisted node_modules, pnpm's symlink store, bun, yarn — which
// is exactly where `../../skills` escaped and where a static check on the source
// tree cannot look. Replicates pi's own resolver, `PackageManager#collectFiles\
// FromManifestEntries` in `@earendil-works/pi-coding-agent`
// `dist/core/package-manager.js`: a non-glob entry is `resolve(packageRoot, entry)`
// (verified identical in 0.78.1 / 0.84.1 / 0.85.1).
//
// #2626 review round 2 (F2): the "does this dir have a skill" walk used to be
// a hand-rolled `countSkillFiles` here AND a second, differently-wrong one in
// `clients/skills-resolver.ts`. Both now derive from the ONE structural
// predicate in `./lib/skills-predicate.mjs`, which replicates pi's actual
// `loadSkillsFromDirInternal` walk (see that module's header for the exact
// discovery rules and the two documented scope gaps).
{
const pkgJson = JSON.parse(
fs.readFileSync(path.join(pkgRoot, "package.json"), "utf8"),
);
const entries = pkgJson.pi?.skills ?? [];
if (entries.length === 0) {
record("pi.skills declared", "manifest", false, "pi.skills is empty");
}
for (const entry of entries) {
const label = `pi.skills "${entry}"`;
// pi globs entries containing * or ? through a different branch; this
// probe only replicates the non-glob one.
if (/[*?]/.test(entry)) {
record(label, "manifest", true, "glob entry — not probed");
continue;
}
const resolved = path.resolve(pkgRoot, entry);
if (resolved !== pkgRoot && !resolved.startsWith(pkgRoot + path.sep)) {
record(label, "manifest", false, `escapes the package: ${resolved}`);
continue;
}
const found = collectSkillEntryPaths(resolved).length;
record(
label,
"manifest",
found > 0,
found > 0
? `${found} SKILL.md under ${resolved}`
: `no SKILL.md under ${resolved}`,
);
}
}
// --- Report ----------------------------------------------------------------
const pad = Math.max(...results.map((r) => r.name.length));
let hardFail = 0;
let softFail = 0;
for (const r of results) {
const soft = (r.kind === "asset" || r.kind === "tool") && allowSoft;
const status = r.ok ? "PASS" : soft ? "WARN" : "FAIL";
if (!r.ok) {
if (soft) softFail++;
else hardFail++;
}
console.log(
` [${status}] ${r.name.padEnd(pad)} ${r.detail ? "— " + r.detail : ""}`,
);
}
console.log(
`\nselftest: ${results.length - hardFail - softFail} passed, ${hardFail} failed${
softFail ? `, ${softFail} warned` : ""
} (runtime: ${process.versions.bun ? "bun " + process.versions.bun : "node " + process.versions.node})`,
);
process.exit(hardFail > 0 ? 1 : 0);