UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

130 lines (125 loc) • 5.38 kB
// The ONE structural replica of pi's own skill-loading walk (#2626 review // round 2, F1/F2). // // Two independent detectors used to answer "does this directory tree hold a // skill pi would load" with two DIFFERENT, hand-rolled heuristics that both // diverged from pi's real resolver: // - `clients/skills-resolver.ts` (a `SKILL.md` one level down only, no // recursion, no root `.md` acceptance, no symlink-follow, no dot/ // node_modules skip) // - `scripts/install-selftest.mjs`'s `countSkillFiles` (recursed // UNBOUNDED — would double-count a `SKILL.md` sitting inside another // skill's own resource subdirectory — with no dot/node_modules skip, no // root `.md` acceptance, no symlink handling) // Neither matched `loadSkillsFromDirInternal` in // `@earendil-works/pi-coding-agent` `dist/core/skills.js` (mirrored by // `collectSkillEntries` in `dist/core/package-manager.js`, byte-identical // walk, confirmed against the 0.85.1 tarball). This module is the fold: BOTH // consumers now derive from `collectSkillEntryPaths` below. // // Discovery rules, replicated from `loadSkillsFromDirInternal` (verified // against the extracted 0.85.1 `skills.js`): // 1. If `dir` directly contains a (non-symlink-broken) `SKILL.md`, `dir` IS // one skill root — return immediately, do NOT look at `dir`'s other // entries or recurse into its subdirectories (a `SKILL.md` inside a // skill's own reference/ subfolder is the skill's OWN resource, not a // second skill). // 2. Otherwise, skip dot-prefixed entries and `node_modules`; recurse into // every remaining subdirectory (same two rules, one level down); at the // OUTERMOST call only (`isRoot`), a loose `*.md` file directly in `dir` // also counts (pi's `includeRootFiles`, true only for the top call). // 3. A symlink is followed via `statSync` (broken symlink = skip) for BOTH // the `SKILL.md` check and the directory/file classification in step 2. // // Deliberately NOT replicated (out of #2626's scope — packaging/path // resolution, not skill-content validation): // - `.gitignore`/`.ignore`/`.fdignore` matching inside the skills tree. // - frontmatter parsing: pi additionally requires a non-empty `description` // field before a discovered file becomes a loaded skill. A `SKILL.md` // file that exists but fails THAT validation is a content-authoring // defect a maintainer would see in normal use, not a silent zero. // Both gaps mean this predicate can be a bit more OPTIMISTIC than pi's true // count in a pathological case (an ignored or description-less file still // counts as "found" here); it is never more PESSIMISTIC — every real skill // pi loads is found by this walk too — which is the direction #2626 cares // about (never claim "no skills" when pi would load one). import * as fs from "node:fs"; import * as path from "node:path"; /** * Symlink-aware entry classification, mirroring `loadSkillsFromDirInternal`'s * own `entry.isSymbolicLink() ? statSync(fullPath) : entry`. * @param {string} fullPath * @param {import("node:fs").Dirent} entry * @returns {"file" | "dir" | "other"} */ function resolvedEntryKind(fullPath, entry) { if (entry.isSymbolicLink()) { try { const stats = fs.statSync(fullPath); if (stats.isDirectory()) return "dir"; if (stats.isFile()) return "file"; return "other"; } catch { return "other"; // broken symlink } } if (entry.isDirectory()) return "dir"; if (entry.isFile()) return "file"; return "other"; } /** * The structural walk over an ALREADY-READ directory listing — split out * from `collectSkillEntryPaths` so a caller that already has `entries` (a * top-level ENOENT/EACCES distinction, say) is not forced into a second * `readdirSync`. * @param {string} dir * @param {import("node:fs").Dirent[]} entries * @param {boolean} isRoot * @returns {string[]} absolute paths of every `SKILL.md` (or, at the root, * loose `.md`) file pi's own resolver would treat as a skill entry point. */ export function scanEntriesForSkills(dir, entries, isRoot) { for (const entry of entries) { if ( entry.name === "SKILL.md" && resolvedEntryKind(path.join(dir, entry.name), entry) === "file" ) { return [path.join(dir, entry.name)]; } } const found = []; for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") { continue; } const fullPath = path.join(dir, entry.name); const kind = resolvedEntryKind(fullPath, entry); if (kind === "dir") { found.push(...collectSkillEntryPaths(fullPath, false)); continue; } if (isRoot && kind === "file" && entry.name.endsWith(".md")) { found.push(fullPath); } } return found; } /** * Every skill entry point under `dir`, recursively, per pi's discovery * rules. Fail-closed: a `readdirSync` error at `dir` (absent, unreadable) or * any recursed-into subdirectory returns `[]` for that branch rather than * throwing — matching `loadSkillsFromDirInternal`'s own bare `try { … } * catch {}` per directory. * @param {string} dir * @param {boolean} [isRoot] * @returns {string[]} */ export function collectSkillEntryPaths(dir, isRoot = true) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; } return scanEntriesForSkills(dir, entries, isRoot); }