UNPKG

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

762 lines (761 loc) • 37.4 kB
// 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/. import fs from "node:fs"; import path from "node:path"; import { factDiagnostics, matchWorkflowPlaceholder, memoryOrphanStubApplies, nameOrTypeDiagnostics, ORPHANED_STUB_DETAIL, taskDiagnostics, workflowFrontendDiagnostics, workflowYamlSourceDiagnostics, } from "../../core/adapter/adapters/akm-lint.js"; import { detectAdapterId } from "../../core/adapter/detect-adapter.js"; import { adapterForId } from "../../core/adapter/registry.js"; import { createValidateContext } from "../../core/adapter/validate-context.js"; import { stashDirFor } from "../../core/asset/asset-placement.js"; import { parseFrontmatter } from "../../core/asset/frontmatter.js"; import { conceptIdForStashFile, displayRefForConceptId } from "../../core/asset/resolve-ref.js"; import { deriveBundleIds } from "../../core/bundle-id.js"; import { compareCodePoints, isAkmRegistryCachePath, resolveStashDir } from "../../core/common.js"; import { loadConfig, primaryBundlePath } from "../../core/config/config.js"; import { UsageError } from "../../core/errors.js"; import { warn } from "../../core/warn.js"; import { resolveSourceEntries } from "../../indexer/search/search-source.js"; import { TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail } from "../../tasks/source-v3.js"; import { resolveWorkflowSourceDomains } from "../../workflows/source-files.js"; import { runBaseChecks } from "./base-linter.js"; import { checkEnvForDangerousKeys } from "./env-key-rules.js"; import { isAdvisoryLintIssue } from "./types.js"; // ── Constants ───────────────────────────────────────────────────────────────── const STASH_SUBDIRS = [ "agents", "commands", "memories", "skills", "workflows", "lessons", "tasks", "knowledge", "facts", ]; // ── Helpers ─────────────────────────────────────────────────────────────────── /** * Every task-shaped file under `tasks/`: the recognized `.yml` spelling AND the * `.yaml` near-miss. A `.yaml` file is not a runnable task — it is invisible to * the indexer's `tasks` matcher — but collecting it here is what lets the sweep * SAY so (`invalid-task-yaml`, see {@link taskExtensionDetail}) instead of * walking past it and reporting a clean scan (issue #760). */ function collectTaskFiles(dir) { if (!fs.existsSync(dir)) return []; const results = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...collectTaskFiles(full)); } else if (entry.isFile() && (isTaskFileName(entry.name) || isNearMissTaskFileName(entry.name))) { results.push(full); } } return results; } function isTaskFileName(fileName) { return fileName.toLowerCase().endsWith(TASK_EXTENSION); } function isNearMissTaskFileName(fileName) { return fileName.toLowerCase().endsWith(TASK_NEAR_MISS_EXTENSION); } function collectMarkdownFiles(dir, caseInsensitive = false) { if (!fs.existsSync(dir)) return []; const results = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === ".git" || entry.isSymbolicLink()) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...collectMarkdownFiles(full, caseInsensitive)); } else if (entry.isFile() && (caseInsensitive ? entry.name.toLowerCase() : entry.name).endsWith(".md")) { results.push(full); } } return results; } /** Peer workflow sources accepted by the source-IR compiler; `.yaml` remains unsupported. */ function collectWorkflowFiles(dir) { if (!fs.existsSync(dir)) return []; const results = []; for (const entry of fs .readdirSync(dir, { withFileTypes: true }) .sort((left, right) => compareCodePoints(left.name, right.name))) { if (entry.name === ".git") continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (isAkmRegistryCachePath(full)) continue; results.push(...collectWorkflowFiles(full)); continue; } if (!entry.isFile() && !entry.isSymbolicLink()) continue; const extension = path.extname(entry.name).toLowerCase(); if (extension === ".md" || extension === ".yml") results.push(full); } return results.sort(compareCodePoints); } /** * Resolve the already-enumerated candidates through the same workflow-source * authority used by index/show/run. The bulk surface inspects every canonical * domain without re-reading its parent directory once per workflow. A rejected * domain produces one deterministic finding and none of its sources is parsed. */ function resolveWorkflowLintOwnership(stashRoot, files) { const ownedFiles = []; const issues = []; for (const resolution of resolveWorkflowSourceDomains(stashRoot, "akm", files)) { if (resolution.source) { ownedFiles.push(resolution.source.path); continue; } if (!resolution.rejection) continue; const representative = resolution.sourcePaths[0]; if (!representative) continue; issues.push({ file: representative, issue: "invalid-workflow-structure", detail: resolution.rejection.message, fixed: false, }); } return { files: ownedFiles.sort(compareCodePoints), issues }; } // ── Non-akm adapter dispatch (real `adapter.validate()`, not a re-implementation) ── // // akm 0.9.0 lint/adapter-dispatch wiring: `akm lint` used to special-case // exactly one non-akm adapter (`okf`, via a hand-rolled `missing-type`-only // `lintOkfBundle` re-implementation this change deletes) and silently route // every OTHER non-akm bundle (llm-wiki, dotenv, claude, opencode, // agent-skills, website-snapshot, generic-files, akm-task, akm-workflow) // through the AKM-shaped STASH_SUBDIRS sweep — the wrong linter for the wrong // format, and the reason those adapters' own `validate()` checks (OKF's // `missing-ref`; llm-wiki's `uncited-raw`/`missing-description`/`broken-xref`/ // `broken-source`) were unreachable dead code. Every bundle is now linted by // its OWN configured/detected adapter's `validate()` — the single definition // of that format's rules, shared with the (now also wired, advisory-only) // change-transaction pre-commit gate in `commands/proposal/repository.ts`. // The adapter-dispatch work intentionally stays outside the `akm` sweep below. // The sweep's workflow branch separately supports authoritative peer `.md` and // `.yml` sources while preserving the pinned Markdown findings/`--fix` bytes. /** * Case-insensitive SUFFIX match against an adapter's declared `extensions` * hint. Deliberately NOT `path.extname()`: Node's `extname(".env")` is `""` * (a leading-dot-only basename has no "extension" by that definition), which * would silently skip every bare `env/.env` file — exactly the shape * `dotenvAdapter`'s own `classify()` (and the akm adapter's env recognition) * match by plain `endsWith`, not `path.extname`. A suffix match is also a * strict superset of the `path.extname` behavior for a normal `name.ext` * file, so nothing that matched before stops matching. */ function matchesAdapterExtension(fileName, extensions) { const lower = fileName.toLowerCase(); return extensions.some((candidate) => lower.endsWith(candidate.toLowerCase())); } /** Walk the whole bundle tree, collecting every file whose extension the adapter recognizes (skip `.git`, symlinks, cache/registry copies). */ function collectAdapterFiles(root, extensions) { if (!fs.existsSync(root)) return []; const results = []; const walk = (dir) => { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.name === ".git" || entry.isSymbolicLink()) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); } else if (entry.isFile() && matchesAdapterExtension(entry.name, extensions)) { // Skip akm's OWN resolved registry-cache copies, not any user // directory that happens to be named ".cache" or "registry". if (isAkmRegistryCachePath(full)) continue; results.push(full); } } }; walk(root); return results; } /** * Every closed {@link LintIssueType} member a current adapter `validate()` can * legitimately emit. Anything outside this set folds onto `"adapter-diagnostic"` * (see `types.ts`'s doc comment on that member) rather than being dropped. */ const KNOWN_ADAPTER_ISSUE_TYPES = new Set([ "unquoted-colon", "missing-updated", "stale-path", "missing-ref", "missing-type", "missing-name-or-type", "missing-skill-md", // The `akm-task` adapter's own code. Now reachable from `akm lint` for a // malformed or misnamed task file (issue #760); without it here, a genuine // task finding would arrive folded onto `adapter-diagnostic`. "invalid-task-yaml", "dangerous-env-key", "uncited-raw", "missing-description", "broken-xref", "broken-source", "invalid-workflow-structure", "workflow-warning", ]); /** Map one adapter {@link Diagnostic} onto a {@link LintIssue} — see `types.ts`'s `"adapter-diagnostic"` doc comment for the open→closed reconciliation. */ export function diagnosticToLintIssue(diag) { // `line` is optional on both shapes: carry it only when the adapter set one, // so whole-file findings keep their exact existing serialization. const location = typeof diag.line === "number" ? { line: diag.line } : {}; if (KNOWN_ADAPTER_ISSUE_TYPES.has(diag.issue)) { return { file: diag.file, issue: diag.issue, detail: diag.detail, fixed: diag.fixed, ...location }; } return { file: diag.file, issue: "adapter-diagnostic", detail: `[${diag.issue}] ${diag.detail}`, fixed: diag.fixed, ...location, }; } /** * Lint a bundle through its OWN adapter's `validate()` (spec §12.1): the * adapter never writes, so every finding lands in `flagged` — `fixed` is * always `false`/`"failed"` for a non-akm bundle regardless of `--fix` * (the CLI option is silently a no-op here, exactly as it already was for * `okf` before this change). */ async function lintViaAdapter(adapterId, stashRoot, extraStashRoots, sources, cfg, options) { const adapter = adapterForId(adapterId); // Defensive fallback (shouldn't happen via `detectAdapterId`/a valid config // — both only ever name a registered built-in): an unregistered adapter id // falls back to the akm-shaped sweep, the same default `akm lint` has // always applied to a bundle it can't otherwise place. if (!adapter) return lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options); // `--type` names an AKM stash subdir; every other adapter has its own type // vocabulary and `validate()` sees the whole bundle regardless. That is not a // correctness problem (full-bundle validation is a superset of the requested // scope), but a user narrowing a run deserves to hear the flag did nothing // rather than infer it from identical output (issue #762). Warn, don't throw: // a hard error would break scripts passing one `--type` across mixed-adapter // bundle sets. if (options.typeFilter) { warn(`Warning: lint --type "${options.typeFilter}" is not supported for the "${adapterId}" adapter — ` + "type scoping applies to akm bundles only; the whole bundle was validated."); } const files = collectAdapterFiles(stashRoot, adapter.extensions); const changes = files.map((filePath) => ({ path: path.relative(stashRoot, filePath).replace(/\\/g, "/"), op: "update", })); const ids = deriveBundleIds(sources); const sourceIndex = sources.findIndex((s) => path.resolve(s.path) === path.resolve(stashRoot)); const componentId = sourceIndex >= 0 ? ids[sourceIndex] : stashRoot; const ctx = createValidateContext({ root: stashRoot, extraRoots: extraStashRoots }); const diagnostics = await adapter.validate({ id: componentId, adapter: adapterId, root: stashRoot, writable: true }, changes, ctx); const mapped = diagnostics.map(diagnosticToLintIssue); // Advisory diagnostics travel in their own channel — never `flagged`, so a // `--fail-on-flagged` gate is not tripped by a non-fatal warning. Classified // by the shared `ADVISORY_LINT_ISSUES` set rather than a code spelled out // here, so this and the sweep below can never disagree about a code. const warnings = mapped.filter(isAdvisoryLintIssue); const flagged = mapped.filter((issue) => !isAdvisoryLintIssue(issue)); // The cross-bundle env dangerous-key sweep (see `runEnvDangerousKeyPass`'s // doc comment) ran for every non-akm adapter via the STASH_SUBDIRS // fallthrough this dispatch replaces — EXCEPT `okf`, which the old code // special-cased out before ever reaching that pass. Preserve both halves of // that history exactly: skip only for `okf`. Some adapters (`dotenv`) ALSO // find the same `dangerous-env-key` findings natively through their own // `validate()` (reusing the same `dangerousEnvKeyDiagnostics` rule) — dedupe // by `(file, issue, detail)` so a bundle covered both ways reports each // finding once, not twice. if (adapterId !== "okf") { const seen = new Set(flagged.map(lintIssueDedupeKey)); for (const issue of runEnvDangerousKeyPass(stashRoot, extraStashRoots, sources, cfg)) { const key = lintIssueDedupeKey(issue); if (seen.has(key)) continue; seen.add(key); flagged.push(issue); } } return { ok: true, fixed: [], flagged, warnings, summary: { fixed: 0, flagged: flagged.length, warnings: warnings.length }, }; } function lintIssueDedupeKey(issue) { return `${issue.file} ${issue.issue} ${issue.detail}`; } function collectEnvFiles(dir) { const results = []; try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) results.push(...collectEnvFiles(full)); else if (entry.isFile() && entry.name.endsWith(".env")) results.push(full); } } catch { /* dir may not exist */ } return results; } /** * Scan every `env/`/`secrets/` `.env` file across `[stashRoot, ...extraStashRoots]` * for keys that are known to enable process-execution hijacking. This is a * cross-bundle SECURITY sweep, not per-adapter validation — it has always run * regardless of which format family a given root's OWN files are (verbatim * extraction of the pass `lintAkmSweep` still runs inline; kept byte-identical * there per the CRITICAL akm-path constraint, and reused here for the * non-akm dispatch path so a non-akm PRIMARY bundle keeps the exact * cross-bundle coverage it already had — the pass previously reached every * non-akm adapter's bundle via the accidental STASH_SUBDIRS fallthrough this * change replaces with real dispatch). */ function runEnvDangerousKeyPass(stashRoot, extraStashRoots, sources, cfg) { const flagged = []; const envRoots = [stashRoot, ...extraStashRoots]; const bundleIdByRoot = new Map(sources.map((source) => [path.resolve(source.path), source.registryId])); for (const root of envRoots) { const bundleId = bundleIdByRoot.get(path.resolve(root)); // `env` assets live under `env/`, whole-file `secret` assets under // `secrets/`. `displayRefForConceptId` owns the short-default / // qualified-secondary `Ref:` spelling `akm show` emits — the old // hand-built `env:<base>` colon grammar is rejected by the 0.9.0 ref // parser, which dead-ended a user copying the ref off a security finding. for (const assetType of ["env", "secret"]) { const dir = path.join(root, stashDirFor(assetType)); if (!fs.existsSync(dir)) continue; for (const envPath of collectEnvFiles(dir)) { const conceptId = conceptIdForStashFile(assetType, root, envPath); const ref = displayRefForConceptId(conceptId, bundleId, cfg.defaultBundle); const relPath = path.relative(root, envPath); for (const issue of checkEnvForDangerousKeys(envPath, relPath, ref)) { flagged.push(issue); } } } } return flagged; } /** * Refuse `--fix` against a bundle the config marks `writable: false`, BEFORE * the sweep touches a single file (issue #761). * * Every other mutating command routes through `core/write-source.ts`'s * `ensureWritable`/`resolveWritable` pair; `akm lint --fix` writes and deletes * directly and never consulted the flag, so it happily rewrote frontmatter in a * bundle explicitly configured read-only. `SearchSource.writable` is already the * EFFECTIVE policy (`resolveWritable` applied — see `resolveSourceEntries`), so * this reads the same answer the write path would, without a second resolver. * * A root that is not a configured source at all (an ad-hoc `--dir`) carries no * policy and stays fixable, exactly as today. */ function assertFixTargetWritable(stashRoot, sources) { const target = sources.find((source) => path.resolve(source.path) === path.resolve(stashRoot)); if (target?.writable !== false) return; // Same error kind and code `write-source.ts#ensureWritable` raises for the // identical refusal, so a scripted caller classifies both the same way. throw new UsageError(`lint: bundle "${stashRoot}" is configured \`writable: false\`; refusing to modify it. ` + "Run `akm lint` without --fix / --prune-dangling-edges to report findings, or set `writable: true` on the bundle.", "INVALID_FLAG_VALUE"); } /** True when the issue represents a file deletion that was successfully applied. */ function isFileDeletion(issue) { return issue.fixed === true && (issue.issue === "orphaned-stub" || issue.issue === "placeholder-stub"); } // ── Per-file lint dispatch (was registry.ts + the 9 per-type linter classes) ── // // akm 0.9.0 chunk-3 (plan §12): the 9 `BaseLinter` subclasses + `LINTER_MAP`/ // `getLinterForType` are gone. The format-generic checks are the shared // `runBaseChecks` (`./base-linter`); the per-`type` RULES are the `akm` // adapter's `validate` surface (`core/adapter/adapters/akm-lint.ts`), imported // here so both the read-only adapter and this fix-capable CLI sweep share ONE // definition of each finding. The adapter never writes; the delete-fix for the // two stub types is applied HERE (a core/CLI concern), reproducing the old // MemoryLinter/WorkflowLinter `--fix` behavior byte-for-byte. /** * Reproduce `SkillLinter.lintDirectory`: a skill subdirectory with no * `SKILL.md` is flagged `missing-skill-md` (never auto-fixable). Exported for * the lint-parity golden (`tests/integration/goldens-lint-output.test.ts`). */ export function lintSkillDirectory(subdirPath, stashRoot) { if (fs.existsSync(path.join(subdirPath, "SKILL.md"))) return []; const relDir = path.relative(stashRoot, subdirPath); return [{ file: relDir, issue: "missing-skill-md", detail: `no SKILL.md in ${relDir}/`, fixed: false }]; } /** MemoryLinter's `orphaned-stub` check WITH its `--fix` delete (memory-linter.ts:19-65). */ function appendMemoryStubIssue(ctx, issues) { if (!memoryOrphanStubApplies(ctx.data, ctx.body)) return; const derivedPath = `${ctx.filePath.replace(/\.md$/i, "")}.derived.md`; if (fs.existsSync(derivedPath)) return; if (ctx.fix) { try { fs.unlinkSync(ctx.filePath); issues.push({ file: ctx.relPath, issue: "orphaned-stub", detail: "deleted orphaned stub", fixed: true }); } catch (e) { issues.push({ file: ctx.relPath, issue: "orphaned-stub", detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`, fixed: "failed", }); } return; } issues.push({ file: ctx.relPath, issue: "orphaned-stub", detail: ORPHANED_STUB_DETAIL, fixed: false }); } /** * WorkflowLinter's `placeholder-stub` check WITH its `--fix` delete * (workflow-linter.ts:22-79). Its sibling `invalid-workflow-structure` check is * deliberately NOT here: parse+compile is a single pass shared with the * advisory channel, so {@link lintAkmSweep} runs it once per file and routes * both halves. */ function appendWorkflowStubIssue(ctx, issues) { const placeholder = matchWorkflowPlaceholder(ctx.body); if (!placeholder) return; if (ctx.fix) { try { fs.unlinkSync(ctx.filePath); issues.push({ file: ctx.relPath, issue: "placeholder-stub", detail: `deleted: found "${placeholder}"`, fixed: true, }); } catch (e) { issues.push({ file: ctx.relPath, issue: "placeholder-stub", detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`, fixed: "failed", }); } return; } issues.push({ file: ctx.relPath, issue: "placeholder-stub", detail: `placeholder text: "${placeholder}"`, fixed: false, }); } /** * Lint ONE asset file: the shared base checks, then the winning stash subdir's * per-`type` extra rules. Replaces `getLinterForType(subdir).lint(ctx)`. * `--fix` mutations (frontmatter rewrites inside `runBaseChecks`; stub deletes * here) are applied when `ctx.fix` is set. * * The workflow parse/compile frontend is NOT one of these rules — it is one * pass feeding two channels, so {@link lintAkmSweep} owns it (see * {@link appendWorkflowStubIssue}). */ export function lintAssetFile(ctx, subdir) { const issues = runBaseChecks(ctx); switch (subdir) { case "agents": issues.push(...nameOrTypeDiagnostics(ctx.relPath, ctx.data, ctx.frontmatter, ["agent"])); break; case "commands": issues.push(...nameOrTypeDiagnostics(ctx.relPath, ctx.data, ctx.frontmatter, ["command"])); break; case "facts": issues.push(...factDiagnostics(ctx.relPath, ctx.data)); break; case "tasks": issues.push(...taskDiagnostics(ctx.relPath, ctx.raw, ctx.stashRoot)); break; case "memories": appendMemoryStubIssue(ctx, issues); break; case "workflows": appendWorkflowStubIssue(ctx, issues); break; // knowledge / lessons / skills: base checks only (skill directory-level // `missing-skill-md` runs separately, per-subdir, in the sweep loop). } return issues; } // ── Main ────────────────────────────────────────────────────────────────────── /** * The `akm`-adapter sweep: STASH_SUBDIRS walk + per-file `lintAssetFile` + * the env dangerous-key pass. UNTOUCHED by the adapter-dispatch wiring above * (CRITICAL CONSTRAINT — the overwhelming majority of real bundles use `akm`, * and its findings / `--fix` behavior / exact `LintIssueType` codes are * pinned by goldens + a large test suite). Only reached when the resolved * adapter id is `"akm"` (or, defensively, an unregistered adapter id — * see {@link lintViaAdapter}'s fallback). */ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) { const fix = options.fix === true; // #884: the dangling-edge repair writes too, so it clears the same // read-only gate `--fix` does. if (fix || options.pruneDanglingEdges === true) assertFixTargetWritable(stashRoot, sources); const fixed = []; const flagged = []; const warnings = []; const dirsToScan = options.typeFilter ? STASH_SUBDIRS.filter((d) => d === options.typeFilter) : STASH_SUBDIRS; for (const subdir of dirsToScan) { const dirPath = path.join(stashRoot, subdir); // Tasks have their own `.yml` plus near-miss collector. Workflows accept // peer `.md`/`.yml` sources; every remaining AKM subdir is Markdown. const files = subdir === "tasks" ? collectTaskFiles(dirPath) : subdir === "workflows" ? collectWorkflowFiles(dirPath) : collectMarkdownFiles(dirPath, true); let assetFiles = subdir === "workflows" ? files.filter((file) => path.basename(file).toLowerCase() !== "readme.md") : files; if (subdir === "workflows") { assetFiles = assetFiles.filter((file) => !isAkmRegistryCachePath(file)); const ownership = resolveWorkflowLintOwnership(stashRoot, assetFiles); assetFiles = ownership.files; flagged.push(...ownership.issues); } // Directory-level check: skills require a SKILL.md entry point (was // SkillLinter.lintDirectory). Run once per direct subdirectory before the // per-file loop. if (subdir === "skills" && fs.existsSync(dirPath)) { for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { if (!entry.isDirectory()) continue; for (const issue of lintSkillDirectory(path.join(dirPath, entry.name), stashRoot)) { // Tristate-safe: only `true` counts as fixed; `false` and "failed" // are both flagged. if (issue.fixed === true) { fixed.push(issue); } else { flagged.push(issue); } } } } for (const filePath of assetFiles) { // Skip registry-cached read-only files — --fix must not mutate them. // Compare on a separator-normalized copy: on Windows these paths carry // backslashes, so the forward-slash substring never matched and --fix // rewrote files inside the registry cache. if (isAkmRegistryCachePath(filePath)) continue; const relPath = path.relative(stashRoot, filePath); let raw; try { raw = fs.readFileSync(filePath, "utf8"); } catch { continue; } // GitHub-shaped YAML is a peer workflow source, not a Markdown document: // no frontmatter/base/stub checks and no `--fix` mutation apply. Compile // once through the shared source-IR frontend and route its findings. if (subdir === "workflows" && path.extname(filePath).toLowerCase() === ".yml") { const frontend = workflowYamlSourceDiagnostics(relPath, raw, filePath, stashRoot); for (const finding of [...frontend.errors, ...frontend.warnings]) { if (isAdvisoryLintIssue(finding)) { warnings.push(finding); } else { flagged.push(finding); } } continue; } let data; let body; let frontmatter; // File-identity findings the per-type rules cannot produce: they describe // the FILE (its extension, whether it parsed at all), not its fields. const fileIssues = []; if (subdir === "tasks") { // Task files are pure YAML. The canonical task-v3 parser runs once in // taskDiagnostics below; base checks need no parsed frontmatter data. data = {}; if (isNearMissTaskFileName(relPath)) { fileIssues.push({ file: relPath, issue: "invalid-task-yaml", detail: taskExtensionDetail(relPath), fixed: false, }); } body = raw; frontmatter = null; } else { ({ data, content: body, frontmatter } = parseFrontmatter(raw)); } // One file's checks — including its `--fix` mutations — must never abort // the sweep: an uncaught throw here left the caller with an exception and // no record of which earlier files had ALREADY been rewritten on disk // (issue #761). A failure is reported per-file, in-band, and the sweep // continues so the rest of the bundle is still linted. let issues; try { issues = [ ...fileIssues, ...lintAssetFile({ filePath, relPath, raw, data, body, frontmatter, fix, pruneDanglingEdges: options.pruneDanglingEdges === true, stashRoot, extraStashRoots, }, subdir), ]; } catch (e) { flagged.push(...fileIssues, { file: relPath, issue: "lint-failed", detail: `lint ${fix ? "--fix " : ""}failed for this file: ${e instanceof Error ? e.message : String(e)}`, fixed: fix ? "failed" : false, }); continue; } let fileDeleted = false; for (const issue of issues) { if (isFileDeletion(issue)) { fileDeleted = true; fixed.push(issue); } else if (isAdvisoryLintIssue(issue)) { // `lintAssetFile` returns errors only today, so this branch is // reached by no current producer — it is here so that an advisory // added to a per-type check later cannot silently become a // `--fail-on-flagged` failure, the way the unclassified default does. warnings.push(issue); } else if (issue.fixed === true) { fixed.push(issue); } else { // fixed === false (not fixable / no fix requested) or "failed" (fix attempted but threw) flagged.push(issue); } } if (fileDeleted) continue; // file is gone — skip any remaining checks // The workflow frontend is ONE parse+compile whose output feeds BOTH // channels, so it runs here — once per file — rather than inside // `lintAssetFile`, which is an errors-only surface (pinned by the lint // golden). Which channel a finding lands in is decided by // `ADVISORY_LINT_ISSUES`, never by which half of the pass produced it, so // a future compile-warning kind carrying a fatal code cannot slip past // `--fail-on-flagged`. // The CLI passes the absolute filePath to the shared source-IR frontend, // whereas the adapter passes the change relPath. if (subdir === "workflows") { const frontend = workflowFrontendDiagnostics(relPath, raw, filePath); for (const finding of [...frontend.errors, ...frontend.warnings]) { if (isAdvisoryLintIssue(finding)) { warnings.push(finding); } else { flagged.push(finding); } } } } } // ── Env dangerous-key pass ───────────────────────────────────────────────── // Scan every `.env` file under <stashRoot>/env/ across all stash roots for // keys that are known to enable process-execution hijacking. Warn-only — // findings go into `flagged`, never `fixed`. flagged.push(...runEnvDangerousKeyPass(stashRoot, extraStashRoots, sources, cfg)); // `ok` reflects whether the lint run completed successfully — NOT whether // it found anything. Findings are surfaced via `summary.flagged`; the CLI // gates its exit code on `--fail-on-flagged`. Conflating "issues exist" // with "command failed" caused two downstream problems: // 1. `akm lint --json | jq …` saw stdout-flush races on Bun's non-zero // exit, intermittently truncating the JSON the consumer read. // 2. `ok` is the shared `{ok, error, code}` failure indicator across the // whole CLI; reusing it for "found stuff" forced callers to disambiguate // a successful-but-flagged run from a hard error by inspecting fields. return { ok: true, fixed, flagged, warnings, summary: { fixed: fixed.length, flagged: flagged.length, warnings: warnings.length }, }; } /** * Lint the resolved bundle at `options.dir` (default: the primary bundle). * Dispatches to the bundle's OWN adapter: the `akm` sweep for `"akm"` * (unchanged), or {@link lintViaAdapter} — a real `adapter.validate()` call — * for every other configured/detected adapter id (OKF, llm-wiki, dotenv, * claude, opencode, agent-skills, website-snapshot, generic-files, akm-task, * akm-workflow). `async` because `BundleAdapter.validate()` is async by * interface contract (`core/adapter/bundle-adapter.ts`); every existing * caller already runs inside an async context (`commands/agent/contribute-cli.ts`, * `commands/improve/preparation.ts`) or is a test that can `await` it. */ export async function akmLint(options = {}) { // Fail closed on a mistyped invocation (§24.2 "Lint" release gate): a // nonexistent --dir used to walk nothing and report a clean // `ok:true, flagged:0`, silently passing scripted --fail-on-flagged gates. if (options.dir !== undefined && !fs.statSync(options.dir, { throwIfNoEntry: false })?.isDirectory()) { throw new UsageError(`lint: --dir "${options.dir}" is not a directory.`, "INVALID_FLAG_VALUE"); } // Collect secondary stash roots from configured filesystem sources so that // cross-stash refs (e.g. referencing assets in dimm-city/agent-stash) are // not falsely flagged as missing-ref. const cfg = options.config ?? loadConfig(); // 0.9.0 (spec §10.1): the primary stash is the defaultBundle's path. const stashRoot = options.dir ?? primaryBundlePath(cfg) ?? resolveStashDir(); const sources = resolveSourceEntries(stashRoot, cfg); const configuredAdapter = sources.find((source) => path.resolve(source.path) === path.resolve(stashRoot))?.adapterId; const adapterId = configuredAdapter ?? detectAdapterId(stashRoot); const extraStashRoots = sources.map((s) => s.path).filter((p) => p !== stashRoot && fs.existsSync(p)); if (adapterId !== "akm") return lintViaAdapter(adapterId, stashRoot, extraStashRoots, sources, cfg, options); // Same fail-closed rule for --type on the akm sweep: an unknown value used // to filter the walk to ZERO directories — a false-clean result on the // classic singular/plural typo ("workflow" for "workflows"). Non-akm // adapters keep their own type vocabularies (see lintViaAdapter). if (options.typeFilter && !STASH_SUBDIRS.includes(options.typeFilter)) { warn(`Warning: lint --type "${options.typeFilter}" is not a recognized akm stash subdirectory — ` + `valid types: ${STASH_SUBDIRS.join(", ")}. The whole bundle was validated.`); options = { ...options, typeFilter: undefined }; } return lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options); }