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
911 lines (910 loc) • 44.3 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/.
// CONTRACT: ref-resolver
// ----------------------------------------------------------------------------
// The `refExistsInAnyStash` and `refToRelPath` helpers below are contract-
// locked: a sister copy lives in the akm-plugins repo at
// `shared/ref-extraction.ts` (and the runtime-shipped duplicate at
// `claude/shared/ref-extraction.ts`). Both implementations resolve the same
// `<type>:<slug>` -> on-disk-asset question and MUST agree on the set of
// reachable refs for any given stash layout.
//
// The lock is enforced by `tests/integration/contracts/ref-resolver-contract.test.ts`,
// which drives this implementation through a canonical fixture set. The
// akm-plugins repo ships an equivalent test that drives its copy through the
// SAME inputs and asserts identical outcomes. Any change to the resolver
// behavior on either side MUST update both contract tests in lockstep, or one
// will fail.
//
// Cases the contract covers (see fixture in the contract test):
// - existing memory / knowledge / agent / workflow / skill refs
// - namespaced knowledge paths (knowledge/<category>/<slug>.md)
// - skill multi-file layout (skills/<slug>/SKILL.md)
// - namespaced slugs containing `/`
// - env (`env/.env`, `env/<name>.env`) and secret (`secrets/<name>`) refs
// - non-existent refs
// - script paths with explicit extensions
//
// As of 0.9 the path mapping in `refToRelPath` is DERIVED FROM THE PLACEMENT
// SPECS (`assetPathForName` in `src/core/asset/asset-placement.ts`) rather than
// hand-encoded, so it can no longer drift from the placement layer. `env`/
// `secret`, `script`, and `task` refs are path-resolved.
// ----------------------------------------------------------------------------
import fs from "node:fs";
import path from "node:path";
import { isScalar, parseDocument } from "yaml";
import { assetPathCandidatesForName, assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
import { BUNDLE_REF_RE } from "../../core/asset/asset-ref.js";
import { removeFrontmatterListValues, rewriteFrontmatterListValue, spliceFrontmatterLine, } from "../../core/asset/frontmatter.js";
import { checkUnquotedDescriptionColon } from "../../core/asset/frontmatter-lint.js";
import { isArchivedRelPath } from "../../core/asset/memory-archive.js";
import { conceptIdFromTypeName, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
import { localDateStamp } from "../../core/common.js";
import { containsRedactedContent, REDACTED_CONTENT_MARKER } from "../../core/content-safety.js";
import { DERIVED_SUFFIX } from "../../core/recognition-util.js";
import { findFenceRegions } from "./markdown-insertion.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Fold physically wrapped prose the same way a YAML plain scalar does. */
function foldDescriptionLines(lines) {
let value = "";
let blankLines = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
blankLines++;
continue;
}
if (value)
value += blankLines > 0 ? "\n".repeat(blankLines) : " ";
value += trimmed;
blankLines = 0;
}
return value;
}
/**
* Recover a description from malformed wrapped YAML.
*
* Older producers emitted a valid quoted first physical line followed by
* indented prose outside the quote. The full document cannot be parsed, but
* the first line can still be decoded independently and the continuation can
* be folded without guessing at escape sequences in that first segment.
*/
function recoverMalformedDescription(firstSegment, continuation) {
const firstLine = parseDocument(`description: ${firstSegment}`);
const firstValue = firstLine.get("description", true);
const decodedFirst = firstLine.errors.length === 0 && isScalar(firstValue) && typeof firstValue.value === "string"
? firstValue.value
: firstSegment.trim();
const value = foldDescriptionLines([decodedFirst, ...continuation]);
return value || null;
}
/**
* Quote the complete description scalar, including physical continuation
* lines, and verify that the replacement is valid YAML before returning it.
*/
function fixUnquotedColon(raw) {
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
const lines = raw.split(/\r?\n/);
if (lines[0]?.trim() !== "---")
return null;
const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
if (closeIdx === -1)
return null;
for (let i = 1; i < closeIdx; i++) {
const m = lines[i]?.match(/^(description:\s*)(.*)/);
if (!m)
continue;
const [, prefix, firstSegment] = m;
if (prefix === undefined || firstSegment === undefined)
continue;
let continuationEnd = i + 1;
while (continuationEnd < closeIdx) {
const continuationLine = lines[continuationEnd];
if (continuationLine === undefined || (!/^[ \t]/.test(continuationLine) && continuationLine.trim()))
break;
continuationEnd++;
}
const frontmatter = lines.slice(1, closeIdx).join("\n");
const document = parseDocument(frontmatter);
const description = document.get("description", true);
const value = document.errors.length === 0 && isScalar(description) && typeof description.value === "string"
? description.value
: recoverMalformedDescription(firstSegment.trim(), lines.slice(i + 1, continuationEnd));
if (value === null)
return null;
lines.splice(i, continuationEnd - i, `${prefix}${JSON.stringify(value)}`);
const candidate = lines.join(eol);
const candidateCloseIdx = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
const candidateDocument = parseDocument(lines.slice(1, candidateCloseIdx).join("\n"));
const candidateDescription = candidateDocument.get("description", true);
if (candidateDocument.errors.length > 0 ||
!isScalar(candidateDescription) ||
candidateDescription.value !== value) {
return null;
}
return candidate;
}
return null;
}
function checkMissingUpdated(data, frontmatterText) {
return frontmatterText !== null && !("updated" in data);
}
function fixMissingUpdated(raw, mtime) {
return spliceFrontmatterLine(raw, `updated: ${localDateStamp(mtime)}`) ?? raw;
}
// ── stale-path helpers ────────────────────────────────────────────────────────
/**
* A path segment shaped like a run-time filename template rather than a
* literal reference: `<timestamp>`-style angle brackets, `{stamp}`/`${VAR}`
* braces, a `YYYYMMDD`/`HHMMSS`-style run of date-format letters, or a glob
* character. Such a path never exists under its literal spelling, so
* `stale-path` skips it instead of flagging it.
*/
const PATH_PLACEHOLDER_PATTERN = /[<{]|[*?]|[YMDHS]{4,}/;
function checkStalePath(body) {
const pathRe = /(?:\/home\/|\/tmp\/|\/var\/|\/root\/|\/opt\/)[^\s"'`)\]>,\n]+/g;
let match;
const stale = [];
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
while ((match = pathRe.exec(body)) !== null) {
const candidate = match[0];
if (PATH_PLACEHOLDER_PATTERN.test(candidate))
continue;
if (!fs.existsSync(candidate)) {
stale.push(candidate);
}
}
return stale;
}
// ── fence-strip helper ────────────────────────────────────────────────────────
/**
* Returns `body` with all fenced code block lines replaced by empty strings,
* so that ref-shaped tokens inside ``` examples are not flagged as broken refs.
*/
function stripFencedBlocks(body) {
const lines = body.split(/\r?\n/);
const regions = findFenceRegions(lines);
if (regions.length === 0)
return body;
for (const { start, end } of regions) {
for (let i = start; i <= end; i++)
lines[i] = "";
}
return lines.join("\n");
}
// ── missing-ref helpers ───────────────────────────────────────────────────────
/**
* Map from ref type to relative path pattern within stashRoot. Returns null to
* skip (type is unresolvable by the slug walker).
*
* Path layout is owned by the placement layer: we resolve through
* `assetPathForName(type, stashDirFor(type), name)` so the linter and the
* rest of the CLI agree on where an asset lives.
*
* Exported for contract testing — see header CONTRACT block.
*/
export function refToRelPath(refType, refName) {
const typeDir = stashDirFor(refType);
if (!typeDir)
return null; // unknown type — skip
// assetPathForName returns a path rooted at the type dir we pass in,
// i.e. "<typeDir>/<...>" — exactly the stash-relative path this helper has
// always returned.
return assetPathForName(refType, typeDir, refName);
}
/**
* Returns true if `relPath` resolves to a real file (or multi-file directory
* primary) in ANY of the provided stash roots.
*
* Exported for contract testing — see header CONTRACT block.
*/
export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
for (const root of stashRoots) {
if (resolveRefPathInStash(relPath, refType, refName, root) !== null)
return true;
}
// #884: an asset `analyzeMemoryCleanup` pruned, or (alpha.9) a consolidate
// pair-pass `retire` proposal or a promotion's source memory retired, was
// ARCHIVED, not deleted — its bytes and identity live on under
// `.akm/memory-cleanup/archive`. Inbound refs to it are satisfied, not
// dangling, so resolve the tombstone rather than reporting `missing-ref`.
// Checked only after every live location misses: a tombstone must never
// shadow a real file, and the scan then costs one directory read per root
// instead of one per ref.
//
// Existence ONLY. `resolveRefPathInStash` deliberately does NOT consult the
// archive: it hands back a path callers MUTATE (SPEC-5 `--supersedes`
// demotion), and writing into an archived tombstone would corrupt the audit
// record while leaving the live stash untouched.
return memoryArchiveHasRef(refType, refName, stashRoots);
}
/**
* The stash-relative files that satisfy a ref, in preference order: its own
* placement spellings, then, for a memory, the `<name>.derived.md` child (#882),
* so an edge to a parent whose plain `.md` is gone still reaches the child it was
* distilled into. This is lint's reachability rule only; the child owns
* `memories/<name>.derived`, never `memories/<name>`.
*/
function refPathCandidates(refType, typeDir, refName) {
const candidates = assetPathCandidatesForName(refType, typeDir, refName);
if (refType !== "memory" || refName.endsWith(DERIVED_SUFFIX))
return candidates;
return [...candidates, assetPathForName(refType, typeDir, `${refName}${DERIVED_SUFFIX}`)];
}
/**
* True when `(refType, refName)` names an asset the cleanup archive holds a
* tombstone for, in any root. Mirrors `resolveRefPathInStash`'s candidate set
* so a memory ref that resolved through the `.derived.md` child (#882) still
* resolves once archived.
*
* Originally memory-only (#884: only `.derived` memories were ever pruned).
* 0.9.17-alpha.9 generalized `archiveCleanupCandidate` to any memory,
* knowledge or lesson file (a consolidate pair-pass `retire` proposal, or an
* accepted promotion's source memory), so this must check every type, not
* just `memory` — otherwise an xref to a retired knowledge or lesson asset
* reports `missing-ref` even though it resolves fine through the tombstone.
*/
function memoryArchiveHasRef(refType, refName, stashRoots) {
const typeDir = stashDirFor(refType);
if (typeDir === undefined)
return false;
const candidates = refPathCandidates(refType, typeDir, refName);
for (const root of stashRoots) {
for (const candidate of candidates) {
if (isArchivedRelPath(candidate, root))
return true;
}
}
return false;
}
/**
* Resolve the on-disk primary file for a ref within a SINGLE stash root, using
* the same reachability rules (in the same order) as
* {@link refExistsInAnyStash}, which delegates here. Returns the absolute path
* of the file that makes the ref "exist" — for a multi-file skill directory
* that is its `SKILL.md` primary, for a `memory` ref its `.derived.md` child
* when the plain `.md` is absent (#882, see {@link refPathCandidates}) —
* or `null` when the ref does not resolve in this root.
*
* Extracted for SPEC-5 (`--supersedes` demotion): write commands need the
* superseded asset's actual file to mutate, and forking a second resolver
* would drift from lint's. NOT part of the akm-plugins ref-resolver contract
* (the contract pins `refToRelPath` + `refExistsInAnyStash`; this is the
* shared internal both build on).
*/
export function resolveRefPathInStash(relPath, refType, refName, root) {
const typeDir = stashDirFor(refType);
const candidates = typeDir === undefined ? [relPath] : refPathCandidates(refType, typeDir, refName);
for (const candidate of candidates) {
const absPath = path.join(root, candidate);
if (fs.existsSync(absPath))
return absPath;
}
return null;
}
/**
* A `(refType, refName)` pair that is not a lint-checkable local asset ref —
* shared skip-guard for recognized `bundle//conceptId` refs. Filters the
* false-positive patterns:
* - Shell variables: memory:$(cmd) or knowledge:${VAR} (guarded by callers on
* the raw token, before it is split).
* - Empty names or names that look like absolute paths / home dirs / URLs.
* - Incomplete/placeholder refs: single-character slug or "**".
* - Template placeholder refs like skill:<name> / workflow:<my-workflow>.
*/
function isNonRefName(refName) {
if (!refName || refName.startsWith("/") || refName.startsWith("~") || refName.startsWith("http"))
return true;
if (refName.length <= 1 || refName === "**")
return true;
if (refName.startsWith("<") || refName.includes("<"))
return true;
return false;
}
/**
* Resolve a `(refType, refName)` pair against `allRoots`. Returns the resolved
* stash-relative path when the ref is MISSING (no file under any root), or
* `null` when it resolves, is a skipped/unresolvable type, or is a
* non-ref-shaped name. The single existence check both grammars route through.
*/
function localRefMissingRelPath(refType, refName, allRoots) {
if (isNonRefName(refName))
return null;
const relPath = refToRelPath(refType, refName);
if (relPath === null)
return null; // type is skipped / unresolvable
return refExistsInAnyStash(relPath, refType, refName, allRoots) ? null : relPath;
}
/**
* 0.9.0 grammar recognition: fully-qualified `bundle//conceptId` body-refs
* (`BUNDLE_REF_RE`, the anchored prose form — spec §11.1 / ref-grammar decision
* D-R3). The conceptId is reverse-translated to its legacy `type`/`name` via the
* D-R2 static table (`typeNameFromConceptId`) so the SAME on-disk existence
* check applies; a conceptId whose leading segment names no known stash-subdir
* is not a local asset ref and is skipped (foreign-adapter / cross-bundle prose).
*/
function scanBundleRefs(scanBody, allRoots) {
const missing = [];
const re = new RegExp(BUNDLE_REF_RE.source, BUNDLE_REF_RE.flags);
let match;
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
while ((match = re.exec(scanBody)) !== null) {
const token = match[1]; // e.g. "core//memories/foo"
if (token.includes("$(") || token.includes("${") || token.includes("::"))
continue;
const boundary = token.indexOf("//");
if (boundary < 0)
continue;
const found = classifyConceptRef(token.slice(boundary + 2), allRoots);
if (found !== null)
missing.push({ ref: token, resolvedRelPath: found });
}
return missing;
}
/**
* Map a bare 0.9.0 conceptId (`<stash-subdir>/<name>`, e.g. `memories/foo`) to
* its legacy `type`/`name` and run the shared existence check. Returns the
* missing relPath, or `null` when it resolves or is not a known local
* asset-type prefix. Drops a trailing `#fragment` (export selector) before
* mapping.
*/
function classifyConceptRef(rawConceptId, allRoots) {
const conceptId = rawConceptId.split("#", 1)[0];
const parts = typeNameFromConceptId(conceptId) ?? legacyTypeSlugParts(conceptId);
if (parts === undefined)
return null; // foreign type / not a local asset ref
return localRefMissingRelPath(parts.type, parts.name, allRoots);
}
/**
* Read-only fallback for the retired `type:slug` xref grammar (see
* resolve-ref.ts Q-02 — the write boundary no longer emits or accepts it).
* Stash content written before that retirement still carries it in frontmatter
* `xrefs:` lists, and `typeNameFromConceptId` (conceptId-only) returns
* `undefined` for it, so without this it was silently skipped rather than
* validated (#882). `refToRelPath`/`refExistsInAnyStash` already key off a bare
* `(type, slug)` pair — the same shape `type:slug` already is — so this only
* needs to split the token; no new resolution logic.
*/
function legacyTypeSlugParts(rawConceptId) {
const colon = rawConceptId.indexOf(":");
if (colon <= 0)
return undefined;
const type = rawConceptId.slice(0, colon);
const name = rawConceptId.slice(colon + 1);
if (!name || name.includes("/") || name.includes(":"))
return undefined;
return stashDirFor(type) === undefined ? undefined : { type, name };
}
/**
* Returns an array of {ref, resolvedRelPath} for every local AKM ref in the
* PROSE body that does not resolve to a real file under any of the provided
* stash roots. Recognizes the 0.9.0 fully-qualified `bundle//conceptId` grammar
* ({@link scanBundleRefs}). Bare short conceptIds are NOT refs in prose (D-R3) —
* those are recognized only in the ref-list channels
* ({@link checkMissingRefsInList}).
*/
function checkMissingRefs(body, stashRoot, extraStashRoots = []) {
const allRoots = [stashRoot, ...extraStashRoots];
// C1: Strip fenced code blocks so example refs inside ``` are not flagged.
const scanBody = stripFencedBlocks(body);
return dedupeMissing(scanBundleRefs(scanBody, allRoots));
}
/**
* Missing-ref check for the REF-LIST channels (frontmatter `refs:` /
* `xrefs:` / `supersededBy:` / `contradictedBy:`) where EACH value is a whole
* ref, not prose. Unlike the body scan, a bare short conceptId (`memories/foo`)
* IS a ref here (the value's whole purpose is to name one asset), so the flipped
* short-conceptId frontmatter the 0.9.0 output emits is no longer invisible.
* Recognizes, per value:
* - fully-qualified `bundle//conceptId`;
* - bare short `conceptId` (`<stash-subdir>/<name>`).
*/
function checkMissingRefsInList(values, stashRoot, extraStashRoots = []) {
const allRoots = [stashRoot, ...extraStashRoots];
const missing = [];
for (const raw of values) {
const value = raw.trim();
if (!value || value.includes("$(") || value.includes("${") || value.includes("::"))
continue;
const boundary = value.indexOf("//");
if (boundary >= 0) {
// Qualified: `bundle//conceptId` (0.9.0). A colon in the tail marks a
// legacy/remote `origin//type:name` — not the new grammar, so skip it.
const tail = value.slice(boundary + 2);
if (tail.includes(":"))
continue;
const rel = classifyConceptRef(tail, allRoots);
if (rel !== null)
missing.push({ ref: value, resolvedRelPath: rel });
continue;
}
// Un-prefixed: a 0.9.0 short `conceptId`. (Post-Chunk-8 the durable
// frontmatter xref channel is conceptId-spelled — the legacy `type:name`
// ref-list arm is retired.)
const rel = classifyConceptRef(value, allRoots);
if (rel !== null)
missing.push({ ref: value, resolvedRelPath: rel });
}
return dedupeMissing(missing);
}
/** Dedupe missing-ref records by their `ref` token (both arms can flag one ref). */
function dedupeMissing(rows) {
const seen = new Set();
const out = [];
for (const row of rows) {
if (seen.has(row.ref))
continue;
seen.add(row.ref);
out.push(row);
}
return out;
}
// ── frontmatter refs ─────────────────────────────────────────────────────────
/**
* Frontmatter keys that carry cross-reference lists per the stash
* organization conventions: `xrefs:` (provenance / associative links),
* `supersededBy:` and `contradictedBy:` (belief-state correction links).
* The missing-ref check validates each of these in ADDITION to the body /
* `refs:` scan — they are the channel the conventions mandate, and a rename
* would otherwise dangle them silently.
*
* `sources:` is deliberately excluded (non-wiki `sources:` was rejected as a
* typed channel; wiki `sources:` is checked by lintWiki). `evidenceSources:` is
* excluded because it can point at merged-away or pruned assets.
*/
const XREF_FRONTMATTER_KEYS = ["xrefs", "supersededBy", "contradictedBy"];
/**
* The belief-graph subset of {@link XREF_FRONTMATTER_KEYS} — the only channels
* `--prune-dangling-edges` will repair (#884). Typed as `readonly string[]` so
* `.includes` accepts any xref key without a cast.
*/
const BELIEF_EDGE_KEYS = ["supersededBy", "contradictedBy"];
/**
* Return the `refs:` array from frontmatter when it is present and is an
* array of strings; otherwise return `null` to signal the caller should
* fall back to scanning the body. An empty array (`refs: []`) is also
* treated as authoritative — it explicitly declares "this asset has no
* outbound refs" and suppresses the body scan.
*
* The `refs:` frontmatter key is used by the claude-code session-capture
* hook (see `shared/ref-extraction.ts` in the akm-plugins repo) to
* persist a validated outbound-ref list alongside the raw transcript.
* Hand-written memories rarely populate this key — for those the body
* scan remains the source of truth.
*
* Session-checkpoint memories use a nested frontmatter pattern: `akm
* remember` wraps the file in `---\n…\n---` and the hook's own
* `---\nakm_memory_kind: session_checkpoint\n…\n---` block is preserved
* inside the body. We look in both places so the `refs:` key works
* regardless of where the producer wrote it.
*/
function extractFrontmatterRefs(data, body) {
const fromOuter = readRefsArray(data.refs);
if (fromOuter !== null)
return fromOuter;
const innerData = parseInnerFrontmatterBlock(body);
if (innerData) {
const fromInner = readRefsArray(innerData.refs);
if (fromInner !== null)
return fromInner;
// Session-checkpoint bodies are raw transcripts; ref-shaped tokens in the
// body are literals (grep patterns, JSON, tool output), not live refs.
// Return [] so missing-ref skips the body scan entirely.
if (typeof innerData.akm_memory_kind === "string")
return [];
}
// Same guard for outer frontmatter (e.g. opencode session files).
if (typeof data.akm_memory_kind === "string")
return [];
return null;
}
function readRefsArray(value) {
if (!Array.isArray(value))
return null;
const out = [];
for (const entry of value) {
if (typeof entry === "string" && entry.trim())
out.push(entry.trim());
}
return out;
}
/**
* Like {@link readRefsArray} but also accepts a single scalar string,
* normalizing it to a one-element list. The indexer's
* `normalizeNonEmptyStringList` treats `supersededBy: memory:x` and
* `supersededBy: [memory:x]` identically — both are live data — so the
* frontmatter xref-channel check must validate both shapes; the array-only
* reader silently skipped dangling scalar refs. Returns `null` for any other
* type (missing key, number, object) and for a blank scalar.
*/
function readRefStringOrArray(value) {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : null;
}
return readRefsArray(value);
}
/**
* Detect a leading nested frontmatter block in `body` (i.e. a `---\n…\n---`
* pair that opens within the first few lines of the body). When present,
* parse a minimal subset of YAML — top-level scalars and block-list
* arrays — sufficient to recognise the `refs:` key. Anything fancier is
* silently ignored.
*
* This is a deliberately narrow parser: lint must never throw on
* unexpected YAML, and the only key we care about here is `refs:`.
*/
function parseInnerFrontmatterBlock(body) {
// Skip up to three blank/header lines, then require `---` to open the block.
const lines = body.split(/\r?\n/);
let i = 0;
while (i < lines.length && i < 3 && lines[i].trim() === "")
i += 1;
if (lines[i] !== "---")
return null;
const open = i;
let close = -1;
for (let j = open + 1; j < lines.length; j += 1) {
if (lines[j] === "---") {
close = j;
break;
}
}
if (close === -1)
return null;
const block = lines.slice(open + 1, close);
const data = {};
let currentKey = null;
let currentList = null;
for (const line of block) {
const listItem = line.match(/^(?: {2})?- (.*)$/);
if (listItem && currentList) {
currentList.push(listItem[1].trim().replace(/^["'](.*)["']$/, "$1"));
continue;
}
const inlineFlow = line.match(/^(\w[\w-]*):\s*\[(.*)\]\s*$/);
if (inlineFlow) {
currentKey = inlineFlow[1];
const items = inlineFlow[2]
.split(",")
.map((s) => s.trim().replace(/^["'](.*)["']$/, "$1"))
.filter(Boolean);
data[currentKey] = items;
currentList = null;
continue;
}
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (!kv)
continue;
currentKey = kv[1];
const value = kv[2].trim();
if (value === "") {
currentList = [];
data[currentKey] = currentList;
}
else {
data[currentKey] = value.replace(/^["'](.*)["']$/, "$1");
currentList = null;
}
}
return data;
}
// ── Base checks ─────────────────────────────────────────────────────────────
/**
* The cross-type checks every asset linter runs first: `unquoted-colon`,
* `missing-updated`, `stale-path`, and `missing-ref`.
*
* akm 0.9.0 chunk-3 (plan §12): this was the `BaseLinter.runBaseChecks`
* protected method every per-type linter class inherited. Those classes are
* gone — the format-generic checks live here as ONE shared function (this), and
* the per-`type` extra rules moved to the `akm` adapter's `validate`
* (`core/adapter/adapters/akm-lint.ts`). The live `akmLint` command
* (`commands/lint/index.ts`) calls this directly, then appends the adapter's
* per-type findings.
*
* File mutations triggered by the fixable base checks (`unquoted-colon`,
* `missing-updated`) are flushed to disk here when `ctx.fix` is set, and
* `ctx.raw` is updated in place so a caller can re-parse the post-fix content.
* A failed flush (read-only file, full disk) DOWNGRADES the optimistic
* `fixed: true` findings this call made to `fixed: "failed"` rather than
* throwing — an uncaught throw here used to kill the whole sweep and hide
* which earlier files had already been rewritten (issue #761).
*/
export function runBaseChecks(ctx) {
const issues = [];
let currentRaw = ctx.raw;
let modified = false;
/** Findings whose `fixed: true` is only true once the flush below succeeds. */
const pendingFixes = [];
// M8: Parse lint_skip from frontmatter for per-file rule suppression.
// Accept both an array (`lint_skip: [missing-ref, stale-path]`) and a
// single scalar (`lint_skip: missing-ref`). Non-string entries are coerced
// and trimmed so loosely-typed YAML still gates correctly.
const rawLintSkip = ctx.data?.lint_skip;
const lintSkip = (Array.isArray(rawLintSkip) ? rawLintSkip : rawLintSkip != null ? [rawLintSkip] : [])
.map((v) => String(v).trim())
.filter(Boolean);
const shouldRun = (issueType) => !lintSkip.includes(issueType);
if (shouldRun("redacted-content") && containsRedactedContent(currentRaw)) {
issues.push({
file: ctx.relPath,
issue: "redacted-content",
detail: `asset contains ${REDACTED_CONTENT_MARKER}; restore the original non-secret prose from version history`,
fixed: false,
});
}
// ── 1. unquoted-colon ──────────────────────────────────────────────────
if (shouldRun("unquoted-colon")) {
const unquotedColonDetail = checkUnquotedDescriptionColon(ctx.frontmatter);
if (unquotedColonDetail) {
if (ctx.fix) {
const fixedRaw = fixUnquotedColon(currentRaw);
if (fixedRaw === null) {
issues.push({
file: ctx.relPath,
issue: "unquoted-colon",
detail: `${unquotedColonDetail} — could not construct a valid YAML replacement`,
fixed: "failed",
});
}
else {
currentRaw = fixedRaw;
modified = true;
const issue = {
file: ctx.relPath,
issue: "unquoted-colon",
detail: unquotedColonDetail,
fixed: true,
};
issues.push(issue);
pendingFixes.push(issue);
}
}
else {
issues.push({
file: ctx.relPath,
issue: "unquoted-colon",
detail: unquotedColonDetail,
fixed: false,
});
}
}
} // end shouldRun("unquoted-colon")
// ── 2. missing-updated ─────────────────────────────────────────────────
if (shouldRun("missing-updated") && checkMissingUpdated(ctx.data, ctx.frontmatter)) {
if (ctx.fix) {
let mtime;
try {
mtime = fs.statSync(ctx.filePath).mtime;
}
catch {
mtime = new Date();
}
currentRaw = fixMissingUpdated(currentRaw, mtime);
modified = true;
const issue = {
file: ctx.relPath,
issue: "missing-updated",
detail: `stamped updated: ${localDateStamp(mtime)}`,
fixed: true,
};
issues.push(issue);
pendingFixes.push(issue);
}
else {
issues.push({
file: ctx.relPath,
issue: "missing-updated",
detail: "no updated field in frontmatter",
fixed: false,
});
}
}
/**
* Flush accumulated mutations to disk. Called after the fixable checks above
* AND again at the very end, because the `missing-ref` section below can also
* mutate (`--prune-dangling-edges`, #884) and used to run PAST this point —
* its edits were computed and then silently dropped.
*
* Mirrors the two stub-delete call sites in `commands/lint/index.ts`
* (`appendMemoryStubIssue`/`appendWorkflowStubIssue`), which already report
* a failed mutation as `fixed: "failed"` instead of throwing. Without this
* the sweep aborted mid-run on the first unwritable file and the caller got
* an exception instead of a result naming the fixes that HAD landed.
*/
const flushIfModified = () => {
if (!modified)
return;
modified = false;
try {
fs.writeFileSync(ctx.filePath, currentRaw, "utf8");
// Propagate the mutated raw back so subclasses can re-parse if needed
ctx.raw = currentRaw;
}
catch (e) {
const reason = e instanceof Error ? e.message : String(e);
for (const issue of pendingFixes) {
issue.fixed = "failed";
issue.detail = `${issue.detail} — could not write fix: ${reason}`;
}
}
};
flushIfModified();
// ── 3. stale-path ──────────────────────────────────────────────────────
// M3: checkStalePath returns all stale matches; push one issue per path.
// M4: Also scan ctx.frontmatter for stale paths (absolute paths in frontmatter).
if (shouldRun("stale-path")) {
const staleInBody = checkStalePath(ctx.body);
const staleInFrontmatter = ctx.frontmatter ? checkStalePath(ctx.frontmatter) : [];
for (const candidate of [...staleInBody, ...staleInFrontmatter]) {
// M4: Suggest portable replacement when path is under stashRoot.
const portableHint = candidate.startsWith(ctx.stashRoot)
? ` (portable form: $AKM_BUNDLE_DIR${candidate.slice(ctx.stashRoot.length)})`
: "";
issues.push({
file: ctx.relPath,
issue: "stale-path",
detail: `nonexistent path: ${candidate}${portableHint}`,
fixed: false,
});
}
}
// ── 4. missing-ref ─────────────────────────────────────────────────────
const missingRefPass = runMissingRefChecks(ctx, currentRaw, shouldRun, pendingFixes);
issues.push(...missingRefPass.issues);
if (missingRefPass.modified) {
currentRaw = missingRefPass.raw;
modified = true;
}
flushIfModified();
return issues;
}
/**
* The `missing-ref` pass, extracted from {@link runBaseChecks} so that function
* stays under the src fn-size ratchet after #884 gave this pass its own
* (opt-in) mutation path. Pure move plus the explicit state hand-off: `raw` in,
* `{raw, modified}` out, `issues`/`pendingFixes` appended in place.
*/
function runMissingRefChecks(ctx, raw, shouldRun, pendingFixes) {
const issues = [];
let currentRaw = raw;
let modified = false;
// ── 4. missing-ref ─────────────────────────────────────────────────────
// Carve-out for assets that declare an explicit `refs:` array in
// frontmatter (e.g. session-checkpoint memories captured by the
// claude-code hook). The frontmatter array is the *authoritative*
// ref list — any ref-shaped tokens in the body are treated as
// literal strings (heredocs, grep patterns, JSON values, regex
// patterns embedded in tool transcripts). Without this carve-out
// every session capture produces a fresh batch of `missing-ref`
// flags on every literal `<type>:<slug>` token in a transcript.
//
// The producer guarantees that entries in `refs:` already resolve
// (it validates against the live stash before writing), so we
// still run `checkMissingRefs` against the array itself to catch
// refs that were valid at capture time but later removed from the
// stash.
if (!shouldRun("missing-ref"))
return { issues, raw, modified };
{
const explicitRefs = extractFrontmatterRefs(ctx.data, ctx.body);
// An explicit `refs:` array is a REF LIST (each value is a whole ref —
// short conceptIds included); a bare body is PROSE (anchored refs only).
const missingRefs = explicitRefs !== null
? checkMissingRefsInList(explicitRefs, ctx.stashRoot, ctx.extraStashRoots)
: checkMissingRefs(ctx.body, ctx.stashRoot, ctx.extraStashRoots);
for (const { ref, resolvedRelPath } of missingRefs) {
issues.push({
file: ctx.relPath,
issue: "missing-ref",
detail: `missing ref: ${ref} (resolved to ${resolvedRelPath})`,
fixed: false,
});
}
// Frontmatter xref channels (xrefs / supersededBy / contradictedBy).
// Runs regardless of the `refs:` body-scan carve-out above — that
// carve-out governs only the BODY scan (`refs: []` declares "no
// outbound refs in the body", not "skip my correction links").
// Non-ref-shaped values (URLs, `raw/<slug>`, `<placeholder>`
// templates, shell vars) fall out via checkMissingRefs' guards.
//
// Gate: runs when the file has a frontmatter block OR when an
// authoritative `refs:` list was extracted. On the task/YAML path
// (lint/index.ts) ctx.frontmatter is always null and the whole file
// IS the body (`body === raw`); the top-level YAML keys land in
// ctx.data. Without `refs:` the body scan above already catches ref
// values under these keys, so running the pass would double-report —
// skip it. With `refs:` present the body scan is suppressed
// (refSource is the refs list), so this pass is the ONLY thing that
// validates the xref keys — it must run or dangling task xrefs go
// unreported. The two cases are mutually exclusive, so no ref is
// ever double-reported. Md files without a frontmatter block and
// without `refs:` land in the skip branch with empty ctx.data, so
// nothing is lost for them either.
if (ctx.frontmatter !== null || explicitRefs !== null) {
for (const key of XREF_FRONTMATTER_KEYS) {
const values = readRefStringOrArray(ctx.data?.[key]);
if (values === null)
continue;
// `lint --fix` migration for the retired `type:slug` xref grammar
// (see legacyTypeSlugParts): rewrite a value to its conceptId form
// ONLY when the rewritten spelling still resolves to a real asset —
// a dangling legacy ref must stay reported as `missing-ref` in its
// original spelling, never rewritten into a differently-spelled
// dangling ref. Runs before the missing-ref scan below so a value
// this loop rewrites is checked (and reported, if still dangling)
// under its ORIGINAL spelling by that scan, and never double-fixed.
if (ctx.fix) {
const legacyRewrites = new Map();
for (const raw of values) {
const value = raw.trim();
const parts = legacyTypeSlugParts(value);
if (parts === undefined)
continue;
const relPath = refToRelPath(parts.type, parts.name);
if (relPath === null)
continue;
if (!refExistsInAnyStash(relPath, parts.type, parts.name, [ctx.stashRoot, ...(ctx.extraStashRoots ?? [])]))
continue; // dangling — leave reported as missing-ref, unrewritten
legacyRewrites.set(value, conceptIdFromTypeName(parts.type, parts.name));
}
if (legacyRewrites.size > 0) {
const rewritten = rewriteFrontmatterListValue(currentRaw, key, legacyRewrites);
if (rewritten !== null) {
currentRaw = rewritten;
modified = true;
for (const [oldValue, newValue] of legacyRewrites) {
const issue = {
file: ctx.relPath,
issue: "missing-ref",
detail: `legacy xref grammar migrated: ${key} ${oldValue} -> ${newValue}`,
fixed: true,
};
issues.push(issue);
pendingFixes.push(issue);
}
}
}
}
const missingXrefs = checkMissingRefsInList(values, ctx.stashRoot, ctx.extraStashRoots);
// #884 opt-in repair. Scoped to the BELIEF channels only: an edge whose
// target has neither a file nor a prune tombstone asserts a
// relationship to a memory that no longer exists in any form, and
// carrying it forward corrupts every belief-graph read. `xrefs` is
// excluded — a stale xref is an ordinary broken link (the 3 skill refs
// in #884), and repairing it by DELETION would throw away a pointer the
// author may simply need to re-target.
const repairable = ctx.pruneDanglingEdges === true && BELIEF_EDGE_KEYS.includes(key) && missingXrefs.length > 0;
if (repairable) {
const dropped = missingXrefs.map(({ ref }) => ref);
const rewritten = removeFrontmatterListValues(currentRaw, key, dropped);
if (rewritten !== null) {
currentRaw = rewritten;
modified = true;
for (const { ref, resolvedRelPath } of missingXrefs) {
const issue = {
file: ctx.relPath,
issue: "missing-ref",
detail: `dangling ${key} edge dropped: ${ref} (no file and no prune tombstone at ${resolvedRelPath})`,
fixed: true,
};
issues.push(issue);
pendingFixes.push(issue);
}
continue;
}
}
for (const { ref, resolvedRelPath } of missingXrefs) {
issues.push({
file: ctx.relPath,
issue: "missing-ref",
detail: `missing ref: ${ref} (frontmatter ${key}; resolved to ${resolvedRelPath})`,
fixed: false,
});
}
}
}
}
return { issues, raw: currentRaw, modified };
}