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
273 lines (272 loc) • 13.1 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/.
/**
* Shared helpers for the concrete `BundleAdapter` implementations under
* `src/core/adapter/adapters/` — akm 0.9.0 chunk-2, WI-A (the `okf` adapter).
*
* ── Cycle-safety (chunk-2 "cycle-safety watch") ──
*
* The base-check logic below is a PORT, not an import, of
* `src/commands/lint/base-linter.ts`'s `runBaseChecks` (the 4 checks every
* non-`DefaultLinter` class inherits: unquoted-colon, missing-updated,
* stale-path, missing-ref). Importing `base-linter.ts` directly would add a
* new `src/core/adapter` -> `src/commands/lint` edge; `base-linter.ts`
* itself imports `core/asset/asset-spec.ts` (a baseline cycle participant,
* `scripts/lint-import-cycles.ts`) and `commands/lint/markdown-insertion.ts`
* — pulling either into `src/core/adapter/`'s import graph risks adding a
* 19th cycle participant against the zero-tolerance ratchet (baseline 18).
* Per the brief's explicit instruction ("copy the pure logic to a leaf ...
* do NOT use dynamic import to launder"), the pure regex/string logic is
* duplicated here, adapted to `Diagnostic`/`ValidateContext`. Verified
* cycle-safe: `bun scripts/lint-import-cycles.ts` stays at 18 with this file
* present (see the WI-A report).
*
* ── What did NOT get ported ──
*
* `bundle-adapter.ts`'s own doc comment is explicit that `validate()`
* "MUST NOT write and MUST NOT read the live filesystem" — reads go through
* `ValidateContext`, which serves the run snapshot WITH pending changes
* overlaid. `stale-path`'s legacy check called `fs.existsSync` directly on
* arbitrary absolute paths found in content; here it goes through
* `ctx.readFile(candidate)` instead (a `null` result means "does not
* exist") — the interface-compliant translation of the same check, not a
* behavior change for any path that genuinely exists or doesn't.
*
* The `refs:`-frontmatter-array authoritative-list carve-out (session-
* checkpoint memories; `base-linter.ts`'s `extractFrontmatterRefs`) is NOT
* ported here — it is memory/session-specific and no OKF concept uses it. A
* later memory/note adapter should extend `extractProseRefTokens`'s caller with
* that carve-out when it ports memory/session.
*
* The fence-strip (`stripFencedBlocksSimple` below) is a simplified,
* same-INTENT reimplementation of `markdown-insertion.ts`'s
* `findFenceRegions` (which is also table/HTML-region aware, because it
* additionally powers safe line INSERTION, a concern this module doesn't
* have) — not byte-identical on every edge case, but sufficient for "don't
* flag a ref/path inside a ``` example," which is the only thing this
* module needs the strip for.
*/
import { createHash } from "node:crypto";
import { parseFrontmatter } from "../../asset/frontmatter.js";
import { checkUnquotedDescriptionColon } from "../../asset/frontmatter-lint.js";
// ── Small pure helpers, reused across the concrete adapters ──────────────────
/** Content hash feeding `IndexDocument.hash` (incrementality/fingerprints, `types.ts` doc comment). */
export function hashContent(content) {
return createHash("sha256").update(content, "utf8").digest("hex");
}
/** Trim a possibly-non-string frontmatter value down to a non-empty string or `undefined` (mirrors `core/common.ts#asNonEmptyString`, duplicated locally to avoid importing a cycle-participant module for a 4-line helper). */
export function nonEmptyString(value) {
if (typeof value !== "string")
return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/** Read a frontmatter `tags:` array down to its non-empty string entries, or `undefined` when there are none (mirrors `output/renderers.ts#readFrontmatterTags`). */
export function readTags(value) {
if (!Array.isArray(value))
return undefined;
const tags = value.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
return tags.length > 0 ? tags : undefined;
}
/** Reserved files (case-insensitive) every concrete adapter recognizes but never indexes as a concept. */
export const RESERVED_FILES = new Set(["index.md", "log.md"]);
// ── Base validate checks (port of `BaseLinter.runBaseChecks`) ────────────────
function checkMissingUpdated(data, frontmatterText) {
return frontmatterText !== null && !("updated" in data);
}
function findStalePathCandidates(body) {
const pathRe = /(?:\/home\/|\/tmp\/|\/var\/|\/root\/|\/opt\/)[^\s"'`)\]>,\n]+/g;
const out = [];
let match;
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
while ((match = pathRe.exec(body)) !== null)
out.push(match[0]);
return out;
}
/** See file header: same intent as `markdown-insertion.ts#findFenceRegions`, not byte-identical. */
function stripFencedBlocksSimple(body) {
const lines = body.split(/\r?\n/);
let inFence = false;
const out = lines.map((line) => {
if (/^\s*```/.test(line)) {
const wasInFence = inFence;
inFence = !inFence;
return wasInFence || inFence ? "" : line;
}
return inFence ? "" : line;
});
return out.join("\n");
}
// Ref-token grammar — copied from `base-linter.ts`'s `BUNDLE_REF_RE`
// (`core/asset/asset-ref.ts`, spec §11.1 / ref-grammar decision D-R3) as a VALUE,
// not an import (same cycle-avoidance rationale as the file header — the source
// string is duplicated so no new `core/adapter` -> `core/asset` edge is added for
// this scan). Recognizes the 0.9.0 FULLY-QUALIFIED `<bundle>//<concept-id>
// [#fragment]` prose body-ref anchored form ONLY. Per D-R3, a bare short
// conceptId in prose is NOT a ref, so it is never flagged here (it is a ref only
// in the ref-LIST channels — {@link refTokenFromListValue}). The pre-0.9.0
// `<type>:<slug>` colon grammar is gone from every recognition surface.
const BUNDLE_BODY_REF_RE_SRC = "(?:^|[\\s`\"'(,[])([^\\s:.#/`\"'()[\\],<>]+\\/\\/[^\\s\"'`)\\]>,\\n]+)";
/**
* A conceptId that is a lint false positive rather than a real ref — mirrors the
* `isNonRefName` guards `base-linter.ts` applies deep in its existence check:
* template placeholders (`skills/<name>`) and degenerate/incomplete tokens.
*/
function isNonRefConceptId(conceptId) {
const id = conceptId.split("#", 1)[0]; // drop the export #fragment selector
if (!id || id.length <= 1 || id === "**")
return true;
if (id.includes("<"))
return true; // template placeholder, e.g. `skills/<name>`
return false;
}
/**
* Extract the fully-qualified `bundle//conceptId` refs from PROSE — the flipped
* `base-linter.ts#checkMissingRefs` semantics (`BUNDLE_REF_RE`, qualified-only;
* bare short conceptIds are NOT refs, D-R3). Fenced code is stripped first so
* example refs inside ``` are not flagged; shell-substitution (`$(`/`${`) and
* ACP (`::`) false positives are dropped.
*/
function extractProseRefTokens(text) {
const re = new RegExp(BUNDLE_BODY_REF_RE_SRC, "gm");
const scanBody = stripFencedBlocksSimple(text);
const refs = [];
let match;
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
while ((match = re.exec(scanBody)) !== null) {
const token = match[1];
if (token.includes("$(") || token.includes("${") || token.includes("::"))
continue;
if (isNonRefConceptId(token.slice(token.indexOf("//") + 2)))
continue;
refs.push(token);
}
return refs;
}
/**
* Recognize a single frontmatter ref-LIST value (`xrefs:`/`supersededBy:`/
* `contradictedBy:`) as a whole ref — the flipped `base-linter.ts#
* checkMissingRefsInList` semantics. Unlike prose, a bare short `conceptId` IS a
* ref here (the value's whole purpose is to name one asset). Returns the ref
* token, or `null` for a false positive or a legacy `origin//type:name` value
* (the colon-grammar is retired — D-R3).
*/
function refTokenFromListValue(value) {
const trimmed = value.trim();
if (!trimmed || trimmed.includes("$(") || trimmed.includes("${") || trimmed.includes("::"))
return null;
const boundary = trimmed.indexOf("//");
// Qualified `bundle//conceptId`: a colon in the tail marks a legacy/remote
// `origin//type:name` — not the 0.9.0 grammar — so skip it. Un-prefixed values
// are a 0.9.0 short conceptId; a colon there is likewise the retired grammar.
const conceptId = boundary >= 0 ? trimmed.slice(boundary + 2) : trimmed;
if (conceptId.includes(":") || isNonRefConceptId(conceptId))
return null;
return trimmed;
}
const XREF_FRONTMATTER_KEYS = ["xrefs", "supersededBy", "contradictedBy"];
function readRefStringOrArray(value) {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : null;
}
if (!Array.isArray(value))
return null;
const out = value.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
return out.length > 0 ? out : null;
}
/**
* Port of `BaseLinter.runBaseChecks`, adapted to the read-only
* `ValidateContext`/`Diagnostic` shapes (adapter spec §12.1). Reproduces
* unquoted-colon / missing-updated / stale-path / missing-ref for ONE file's
* already-parsed content.
*
* `fixed` is always `false`: `BundleAdapter.validate` MUST NOT write the
* filesystem, so there is no fix-apply mechanism at this layer — a documented,
* intentional behavior narrowing from the legacy `--fix`-capable linter.
*
* `componentRoot` is `BundleComponent.root` — used only for the stale-path
* "portable form" hint (mirrors legacy `ctx.stashRoot`-relative substitution;
* no I/O).
*/
export async function runBaseValidateChecks(relPath, parsed, componentRoot, ctx) {
const diagnostics = [];
const { data, content: body, frontmatter } = parsed;
const unquotedColonDetail = checkUnquotedDescriptionColon(frontmatter);
if (unquotedColonDetail) {
diagnostics.push({ file: relPath, issue: "unquoted-colon", detail: unquotedColonDetail, fixed: false });
}
if (checkMissingUpdated(data, frontmatter)) {
diagnostics.push({
file: relPath,
issue: "missing-updated",
detail: "no updated field in frontmatter",
fixed: false,
});
}
const staleCandidates = [
...findStalePathCandidates(body),
...(frontmatter ? findStalePathCandidates(frontmatter) : []),
];
for (const candidate of staleCandidates) {
const found = await ctx.readFile(candidate);
if (found !== null)
continue;
const portableHint = candidate.startsWith(componentRoot)
? ` (portable form: $AKM_BUNDLE_DIR${candidate.slice(componentRoot.length)})`
: "";
diagnostics.push({
file: relPath,
issue: "stale-path",
detail: `nonexistent path: ${candidate}${portableHint}`,
fixed: false,
});
}
for (const ref of extractProseRefTokens(body)) {
const { exists } = await ctx.resolveRef(ref);
if (!exists)
diagnostics.push({ file: relPath, issue: "missing-ref", detail: `missing ref: ${ref}`, fixed: false });
}
if (frontmatter !== null) {
for (const key of XREF_FRONTMATTER_KEYS) {
const values = readRefStringOrArray(data[key]);
if (values === null)
continue;
for (const value of values) {
const ref = refTokenFromListValue(value);
if (ref === null)
continue;
const { exists } = await ctx.resolveRef(ref);
if (!exists) {
diagnostics.push({
file: relPath,
issue: "missing-ref",
detail: `missing ref: ${ref} (frontmatter ${key})`,
fixed: false,
});
}
}
}
}
return diagnostics;
}
/**
* Shared per-change loop: for every non-delete change with readable content,
* parse frontmatter and run {@link runBaseValidateChecks}. Adapters with extra
* per-type checks (e.g. `okf`'s missing-type / OKF-link warnings) run their own
* loop and call {@link runBaseValidateChecks} directly instead.
*/
export async function validateChangesWithBaseChecks(c, changes, ctx) {
const diagnostics = [];
for (const change of changes) {
if (change.op === "delete")
continue;
const raw = change.after ?? (await ctx.readFile(change.path));
if (typeof raw !== "string")
continue;
const parsed = parseFrontmatter(raw);
diagnostics.push(...(await runBaseValidateChecks(change.path, parsed, c.root, ctx)));
}
return diagnostics;
}