akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
213 lines (212 loc) • 9.65 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* The ONE core {@link ValidateContext} implementation — akm 0.9.0 chunk-2/lint
* follow-up ("make `validate()` genuinely load-bearing").
*
* `BundleAdapter.validate()` (`./bundle-adapter.ts`) is a REQUIRED interface
* method whose contract is explicit and normative (`./bundle-adapter.ts:96-100`
* / the format-neutral spec §12.1): the adapter MUST NOT write and MUST NOT
* read the live filesystem — `ctx` serves the run's on-disk SNAPSHOT WITH the
* caller's pending {@link FileChange}s overlaid, plus a read-only `resolveRef`
* for link/xref existence. That overlay is meant to be built ONCE, centrally —
* "one core overlay implementation, not one per adapter" (the interface's own
* words) — so every `validate()` caller shares identical overlay semantics
* rather than each hand-rolling its own (as every adapter's own test suite has
* done up to now, see e.g. `tests/core/adapter/okf-adapter.test.ts`'s
* `overlayCtx`/`diskCtx`).
*
* The two production callers wired onto this factory:
* - `akm lint`'s non-akm adapter dispatch (`commands/lint/index.ts`) — a
* plain sweep with NO pending changes, so the overlay is empty and every
* read falls straight through to disk.
* - the proposal promotion preflight (`commands/proposal/repository.ts`
* `preflightProposalPromotion`) — the one proposal's own `changes` are the
* overlay, so `validate()` sees the bundle AS IT WOULD LOOK the instant
* after the transaction commits, without that transaction ever touching
* disk.
*
* ── resolveRef ──
*
* A bare (unqualified) `conceptId` is resolved as BOTH:
* 1. the AKM placement-derived path (`typeNameFromConceptId` + `stashDirFor`
* + `assetPathForName` — the exact derivation `commands/lint/base-linter.ts
* #refToRelPath` already uses for the akm-native `missing-ref` check); and
* 2. the DIRECT component-relative spelling (`<conceptId>.md`, and the bare
* `<conceptId>` for an extensionless asset) — the form every non-akm
* adapter's OWN conceptId already IS (OKF: path − `.md`; llm-wiki: same).
* A `bundle//conceptId`-qualified ref has its bundle prefix stripped before
* resolution — mirroring `base-linter.ts#classifyConceptRef`'s existing
* behavior (the legacy resolver has never scoped-by-bundle-name either; it
* searches every configured stash root for the bare conceptId). Existence is
* checked against the PRIMARY root (with the pending-changes overlay applied)
* first, then each extra root (disk only — a pending transaction never
* targets more than one bundle).
*/
import fs from "node:fs";
import path from "node:path";
import { assetPathForName, stashDirFor } from "../asset/asset-placement.js";
import { typeNameFromConceptId } from "../asset/resolve-ref.js";
import { toPosix } from "../common.js";
/** Build the overlay map, keyed by POSIX path relative to `root`. */
function buildOverlay(root, changes) {
const overlay = new Map();
for (const change of changes) {
const relKey = toPosix(path.isAbsolute(change.path) ? path.relative(root, change.path) : change.path);
if (!relKey || relKey.startsWith(".."))
continue; // outside root — not this context's concern
if (change.op === "delete") {
overlay.set(relKey, null);
continue;
}
if (change.after !== undefined)
overlay.set(relKey, change.after);
}
return overlay;
}
function readDisk(absPath) {
try {
return fs.readFileSync(absPath, "utf8");
}
catch {
return null;
}
}
function existsOnDiskOrOverlay(relPath, root, overlay) {
const key = toPosix(relPath);
// Overlay entries are pending file contents by construction, so a present
// non-null entry is always a file.
if (overlay?.has(key))
return overlay.get(key) !== null;
try {
// `isFile()`, not `existsSync()`: a ref naming a DIRECTORY (e.g. a
// `pages/foo/` dir alongside no `pages/foo.md`) must not count as a
// resolved target — that would silently suppress a real
// `missing-ref`/`broken-xref` diagnostic.
return fs.statSync(path.join(root, relPath)).isFile();
}
catch {
return false;
}
}
/**
* Refs come from bundle CONTENT (link targets, xrefs, `sources:` entries), so
* they are untrusted input. A ref must stay inside the bundle root: reject
* absolute paths and any `..` segment before joining, so content can never
* probe for existence outside its own bundle (nor report a resolved `path`
* pointing there). Mirrors the `isWithin` containment rule the write path
* already enforces in `core/write-source.ts`.
*/
function refEscapesBundle(conceptId) {
if (path.isAbsolute(conceptId) || conceptId.startsWith("/"))
return true;
return toPosix(conceptId)
.split("/")
.some((segment) => segment === "..");
}
/**
* Every on-disk relative path a bare `conceptId` might resolve to: the AKM
* placement-derived path (when the leading segment names a known placement
* stash-subdir) and the direct component-relative spellings every non-akm
* adapter's own conceptId already uses. Order doesn't matter — the caller
* checks all of them.
*/
function candidateRelPaths(conceptId) {
const candidates = [];
const parts = typeNameFromConceptId(conceptId);
if (parts !== undefined) {
const typeDir = stashDirFor(parts.type);
if (typeDir !== undefined)
candidates.push(assetPathForName(parts.type, typeDir, parts.name));
}
candidates.push(`${conceptId}.md`);
candidates.push(conceptId);
return candidates;
}
/** Strip an optional `#fragment` (export selector) — never part of the on-disk identity. */
function stripFragment(ref) {
const hashIdx = ref.indexOf("#");
return hashIdx >= 0 ? ref.slice(0, hashIdx) : ref;
}
/**
* Strip an optional `bundle//` qualifier. Mirrors `base-linter.ts
* #classifyConceptRef`'s existing behavior: the legacy missing-ref checker has
* never resolved a qualifier against a NAMED bundle either — it searches every
* configured stash root for the bare conceptId. Keeping that same leniency
* here means this resolver's answers agree with today's `akm lint` for every
* ref shape that already worked.
*/
function stripBundlePrefix(ref) {
const boundary = ref.indexOf("//");
return boundary >= 0 ? ref.slice(boundary + 2) : ref;
}
/**
* Build the ONE core {@link ValidateContext}: reads/lookups served from the
* on-disk snapshot rooted at `options.root` (+ `options.extraRoots` for
* cross-bundle ref existence) WITH `options.changes` overlaid on `options.root`
* — never a write, never a live-FS read beyond that snapshot.
*/
export function createValidateContext(options) {
const root = options.root;
const extraRoots = options.extraRoots ?? [];
const overlay = buildOverlay(root, options.changes ?? []);
async function readFile(p) {
// An absolute candidate (e.g. a `stale-path` scan hit, which is always an
// absolute host path) bypasses the overlay — it can never name a pending
// change's stash-relative path, and reads straight from disk (the base
// checks' existing "does this literal absolute path exist" question).
if (path.isAbsolute(p))
return readDisk(p);
const key = toPosix(p);
if (overlay.has(key))
return overlay.get(key) ?? null;
return readDisk(path.join(root, p));
}
async function list(dir) {
const abs = path.isAbsolute(dir) ? dir : path.join(root, dir);
const relDir = toPosix(path.isAbsolute(dir) ? path.relative(root, dir) : dir);
const names = new Set();
try {
for (const entry of fs.readdirSync(abs))
names.add(entry);
}
catch {
// directory may not exist on disk yet — the overlay can still populate it
}
const prefix = relDir === "." || relDir === "" ? "" : `${relDir}/`;
for (const [key, value] of overlay) {
if (prefix.length > 0 && !key.startsWith(prefix))
continue;
const rest = prefix.length > 0 ? key.slice(prefix.length) : key;
if (rest.length === 0 || rest.includes("/"))
continue; // only direct children
if (value === null)
names.delete(rest);
else
names.add(rest);
}
return [...names];
}
async function resolveRef(ref) {
const conceptId = stripBundlePrefix(stripFragment(ref)).trim();
if (!conceptId)
return { exists: false };
if (refEscapesBundle(conceptId))
return { exists: false };
const candidates = candidateRelPaths(conceptId);
const roots = [
{ root, usesOverlay: true },
...extraRoots.map((r) => ({ root: r, usesOverlay: false })),
];
for (const { root: candidateRoot, usesOverlay } of roots) {
for (const relPath of candidates) {
if (existsOnDiskOrOverlay(relPath, candidateRoot, usesOverlay ? overlay : null)) {
return { exists: true, path: path.join(candidateRoot, relPath) };
}
}
}
return { exists: false };
}
return { readFile, list, resolveRef };
}