/** * Default `ResourceLoader` — deterministic discovery + precedence (ARCHITECTURE §4.9). * * Discovers typed resources from three injected origin groups — bundled roots, user * roots, and the project cwd plus its ancestors — and merges them with the pinned * precedence: bundled → user → project (project wins). Named resources (skills, agents, * rules, extensions) override by stable `key`; context files layer in source order with * same-key replacement. The result is a single wire-safe `ResourceSnapshot`. * * Foundation scope (saga d4ks8c.2): generic metadata discovery only. A record's `name` is * the file stem and `description` is empty; Pi/Agent-Skills frontmatter, SHARD.md/AGENTS.md, * and rule semantics arrive in d4ks8c.5. Only regular files in per-kind subdirectories are * discovered (directory-based skill conventions like `SKILL.md` are d4ks8c.5). * * Robustness guarantees: * - **Missing dirs are harmless**: a missing root or kind-subdir is skipped (an `info` * diagnostic for a missing *root* only); load never throws. * - **Unreadable/malformed diagnosed**: broken symlinks, unreadable paths, and empty * resource names produce `warning` diagnostics and are skipped. * - **Normalized paths**: every stored path is `/`-joined so stable keys match across * platforms. * - **Cycle/symlink safe**: directory symlinks are never followed (non-recursive scan); * per-directory realpath dedupe drops symlink loops and double-links with a `warning`. * * The filesystem is an injected seam (`ResourceFilesystem`) so tests run hermetically; the * real process supplies `nodeResourceFilesystem()`. */ import { join, basename, extname } from 'node:path'; import type { ResourceLoader, ResourceSnapshot, ResourceRecord, ResourceKind, ResourceOrigin, ResourceDiagnostic, SkillIndexEntry, } from '@/contracts/resource'; import { normalizePath, relativePosix, isDirectory, type ResourceFilesystem, } from './filesystem'; import { nodeResourceFilesystem } from './filesystem'; import { resolveProjectAncestors, type ProjectBoundaryResolver, } from '@/core/project-boundary'; import { makeDiagnostic } from './diagnostic'; import { mergeNamedLayers, mergeContextLayers, NAMED_KINDS, type ResourceLayer, } from './precedence'; import { formatSkillIndex } from './skill-index'; /** Default per-kind subdirectory names scanned under each root. */ const DEFAULT_KIND_DIRS: Record = { skill: ['skills'], agent: ['agents'], context: ['context'], rule: ['rules'], extension: ['extensions'], }; /** Default file extensions treated as resource files. */ const DEFAULT_EXTENSIONS = ['.md']; /** All kinds the loader discovers, in snapshot-array order. */ const ALL_KINDS: ReadonlyArray = [ 'skill', 'agent', 'context', 'rule', 'extension', ] as const; /** * Configuration for {@link DefaultResourceLoader}. Roots are injected (never hard-coded) * so the same loader serves bundled, user-config, and project layouts. Precedence is * fixed: `bundledRoots` (lowest) → `userRoots` → project (highest). */ export interface ResourceLoaderConfig { /** Bundled resource roots — ship defaults, lowest precedence. */ bundledRoots: string[]; /** User resource roots (e.g. config dir), middle precedence. */ userRoots: string[]; /** Project cwd. Ancestor dirs up to the git/fs root form the project layer (highest). */ cwd: string; /** Stop the ancestor walk after including the first dir containing `.git`. Default `true`. */ stopAtGitRoot?: boolean; /** * Injectable project-boundary resolver. When present, its canonical top-level is * authoritative for the ancestor walk (a linked worktree's worktree root wins; discovery * stops there and does not consult filesystem markers). When absent, the cheap filesystem * fallback recognizes a `.git` regular file (linked worktree) OR directory (main checkout). * Default `undefined` so hermetic fake-fs tests stay Git-free. */ boundaryResolver?: ProjectBoundaryResolver; /** Per-kind subdirectory names; defaults to {@link DEFAULT_KIND_DIRS}. */ kindDirs?: Partial>; /** File extensions treated as resource files (with leading `.`); default `['.md']`. */ extensions?: string[]; } /** One unit of work: scan a single root for one origin. */ interface ScanLayer { origin: ResourceOrigin; root: string; } /** * Resolve the project layer roots by walking up from `cwd`. Returns `[cwd, parent, …, * boundary | fsRoot]` inclusive. With `stopAtGitRoot` (default) the walk stops after the * project boundary: when a {@link ProjectBoundaryResolver} is injected its canonical * top-level is authoritative ("injected wins"); otherwise the cheap filesystem fallback * recognizes a `.git` regular file (linked worktree) OR directory (main checkout), fixing the * bug where a linked worktree's `.git` FILE did not stop ancestor scans. `stopAtGitRoot: false` * walks to the filesystem root without invoking or obeying the boundary. The caller reverses * this so the least-specific ancestor is processed first and `cwd` wins same-key overrides. */ export async function resolveProjectRoots( fs: ResourceFilesystem, cwd: string, opts: { stopAtGitRoot?: boolean; boundaryResolver?: ProjectBoundaryResolver } = {}, ): Promise { // Pass `fs.realpath` as the canonicalizer so a symlink-spelled cwd collapses to the same // canonical root an injected boundary resolver returns (realpath-resolved). For a non- // symlink path `realpath` returns the path itself, so hermetic fake-fs tests are unchanged. return resolveProjectAncestors(fs, cwd, { ...opts, canonicalize: (p) => fs.realpath(p) }); } /** Compute the stable key for a discovered file within its kind-directory. */ function keyOf(kind: ResourceKind, dirAbs: string, abs: string, stem: string): string { // Context keys by normalized relative path (filename) so distinct files accumulate and // same-named files across layers replace. Named kinds key by file stem. return kind === 'context' ? relativePosix(dirAbs, abs) : stem; } /** * Scan one root for all kinds. Returns per-kind layers (one per kind that yielded records) * plus diagnostics. Within a single kind-directory, same-key collisions and realpath * duplicates are diagnosed as `warning` and only the first (lowest sorted name) is kept, so * every returned layer has unique keys by construction. */ async function scanRoot( fs: ResourceFilesystem, origin: ResourceOrigin, rootAbs: string, kindDirs: Record, exts: string[], ): Promise<{ layers: Map; diagnostics: ResourceDiagnostic[] }> { const diagnostics: ResourceDiagnostic[] = []; const layers = new Map(); if (!(await isDirectory(fs, rootAbs))) { // Missing root is harmless but observable. diagnostics.push(makeDiagnostic('info', `root directory not found: ${normalizePath(rootAbs)}`, { origin })); return { layers, diagnostics }; } for (const kind of ALL_KINDS) { const records: ResourceRecord[] = []; const seenReal = new Set(); const seenKeys = new Set(); for (const kindDirName of kindDirs[kind]) { const dirAbs = join(rootAbs, kindDirName); const entries = await fs.readdir(dirAbs); if (entries === undefined) continue; // missing/unreadable kind-subdir: harmless, silent // Deterministic order: sort by entry name. const sorted = [...entries].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0, ); for (const ent of sorted) { // Resolve the target kind for symlinks (do NOT follow directory symlinks — non- // recursive scan, so no descent and no cycle risk; we still need the target kind // to decide whether the link is a file resource). let isFile = ent.isFile; const isSymlink = ent.isSymbolicLink; if (isSymlink) { const st = await fs.stat(join(dirAbs, ent.name)); if (st === undefined) { diagnostics.push( makeDiagnostic('warning', `broken symlink in ${kind} directory: ${ent.name}`, { kind, path: normalizePath(join(dirAbs, ent.name)), }), ); continue; } isFile = st.isFile; } if (!isFile) continue; // directories and other kinds are skipped (d4ks8c.5 handles dir skills) const ext = extname(ent.name); if (!exts.includes(ext)) continue; const stem = basename(ent.name, ext); const abs = join(dirAbs, ent.name); if (stem === '') { diagnostics.push( makeDiagnostic('warning', `resource file has empty name: ${ent.name}`, { kind, path: normalizePath(abs), }), ); continue; } const real = await fs.realpath(abs); if (real === undefined) { diagnostics.push( makeDiagnostic('warning', `unreadable resource path: ${ent.name}`, { kind, path: normalizePath(abs), }), ); continue; } if (seenReal.has(real)) { diagnostics.push( makeDiagnostic('warning', `duplicate resource (symlink resolves to same file): ${ent.name}`, { kind, path: normalizePath(abs), }), ); continue; } seenReal.add(real); const key = keyOf(kind, dirAbs, abs, stem); if (seenKeys.has(key)) { diagnostics.push( makeDiagnostic('warning', `duplicate ${kind} name '${key}' in ${kindDirName}/`, { kind, path: normalizePath(abs), }), ); continue; } seenKeys.add(key); records.push({ kind, name: stem, description: '', path: normalizePath(abs), origin, key, }); } } if (records.length > 0 || kind === 'context') { // Always register a context layer (even if empty) so source-order layering is // contiguous; named kinds only register when they produced records. layers.set(kind, { origin, records }); } } return { layers, diagnostics }; } /** * The default L2 `ResourceLoader`. Construct with injected roots and (optionally) a * filesystem seam; `load()` returns the merged snapshot. */ /** Normalized config held by {@link DefaultResourceLoader}. */ export interface ResolvedResourceLoaderConfig { bundledRoots: string[]; userRoots: string[]; cwd: string; stopAtGitRoot: boolean; boundaryResolver?: ProjectBoundaryResolver; kindDirs?: Partial>; extensions?: string[]; } export class DefaultResourceLoader implements ResourceLoader { private readonly fs: ResourceFilesystem; private readonly config: ResolvedResourceLoaderConfig; constructor(config: ResourceLoaderConfig, fs?: ResourceFilesystem) { this.fs = fs ?? nodeResourceFilesystem(); const resolved: ResolvedResourceLoaderConfig = { bundledRoots: config.bundledRoots, userRoots: config.userRoots, cwd: config.cwd, stopAtGitRoot: config.stopAtGitRoot ?? true, }; if (config.boundaryResolver !== undefined) resolved.boundaryResolver = config.boundaryResolver; if (config.kindDirs !== undefined) resolved.kindDirs = config.kindDirs; if (config.extensions !== undefined) resolved.extensions = config.extensions; this.config = resolved; } private kindDirsConfig(): Record { const custom = this.config.kindDirs ?? {}; const out = {} as Record; for (const k of ALL_KINDS) { out[k] = custom[k] ?? DEFAULT_KIND_DIRS[k]!; } return out; } private extensionsConfig(): string[] { return this.config.extensions ?? DEFAULT_EXTENSIONS; } async load(): Promise { const kindDirs = this.kindDirsConfig(); const exts = this.extensionsConfig(); // Ordered scan layers: bundled (config order) → user (config order) → project // (least-specific ancestor → cwd, so cwd wins same-key overrides). const scanLayers: ScanLayer[] = []; for (const root of this.config.bundledRoots) scanLayers.push({ origin: 'bundled', root }); for (const root of this.config.userRoots) scanLayers.push({ origin: 'user', root }); const projectRoots = await resolveProjectRoots(this.fs, this.config.cwd, { stopAtGitRoot: this.config.stopAtGitRoot, ...(this.config.boundaryResolver !== undefined ? { boundaryResolver: this.config.boundaryResolver } : undefined), }); // Reverse so the highest ancestor is scanned first and cwd is scanned last. for (const root of projectRoots.reverse()) scanLayers.push({ origin: 'project', root }); // Per-kind ordered layer lists for the merge, plus all scan diagnostics. const perKindLayers = new Map(); for (const k of ALL_KINDS) perKindLayers.set(k, []); const diagnostics: ResourceDiagnostic[] = []; for (const layer of scanLayers) { const { layers, diagnostics: layerDiags } = await scanRoot( this.fs, layer.origin, layer.root, kindDirs, exts, ); diagnostics.push(...layerDiags); for (const [kind, rl] of layers) { perKindLayers.get(kind)!.push(rl); } } // Merge: named kinds by override-by-key; context by source-order replacement. const merged: Record = { skill: [], agent: [], context: [], rule: [], extension: [], }; for (const kind of NAMED_KINDS) { const r = mergeNamedLayers(perKindLayers.get(kind)!); merged[kind] = r.records; diagnostics.push(...r.diagnostics); } const ctx = mergeContextLayers(perKindLayers.get('context')!); merged.context = ctx.records; diagnostics.push(...ctx.diagnostics); // Progressive-disclosure skill index: name/description/path only, sorted by name. const skillIndex: SkillIndexEntry[] = merged.skill .map((s) => ({ name: s.name, description: s.description, path: s.path })) .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); // Stable diagnostic order: sort by (severity rank, message, path) for deterministic // snapshots across runs. Errors first, then warnings, then info. const rank: Record = { error: 0, warning: 1, info: 2 }; diagnostics.sort((a, b) => { const s = (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9); if (s !== 0) return s; if (a.message !== b.message) return a.message < b.message ? -1 : 1; return (a.path ?? '') < (b.path ?? '') ? -1 : (a.path ?? '') > (b.path ?? '') ? 1 : 0; }); return { skills: merged.skill, agents: merged.agent, contextFiles: merged.context, rules: merged.rule, extensions: merged.extension, diagnostics, skillIndex, }; } } /** Render the skill-index section for the system prompt. Convenience export. */ export function renderSkillIndex(snapshot: ResourceSnapshot): string { return formatSkillIndex(snapshot.skillIndex); }