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

344 lines (343 loc) • 14.8 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/. /** * Table-aware insertion-point selection for markdown auto-fixers. * * Background: an earlier `lint --fix` rule that auto-inserted a callout note * landed it INSIDE a markdown table in `knowledge/akm-cli-reference.md`, which * split the table fence and broke rendering. This module centralises the * "where is it safe to insert a new block?" decision so any current or future * fixer that wants to inject content into a markdown body can route through * `findSafeInsertionPoint` and avoid the same class of bug. * * The helper is intentionally pure: it takes a `string[]` of body lines plus a * proposed insertion line, and returns an adjusted insertion line that is * guaranteed to fall outside of any of the following no-insert regions: * * - Markdown pipe tables (header row + `|---|---|` separator + data rows) * - HTML tables (`<table>…</table>`) * - Fenced code blocks (``` or ~~~ fences) * - Indented code blocks (4+ leading spaces or a tab, after a blank line) * * Frontmatter is intentionally NOT detected here — callers should already * strip the frontmatter and operate on the body, or pass the full content * including frontmatter (in which case the helper treats it like prose and * will not detect it as a no-insert region; the existing * `fixMissingUpdated` flow injects into the frontmatter via regex without * needing this helper). * * Line numbers are 0-based throughout this module to match `Array.splice` * semantics. Callers using 1-based line numbers (e.g. from * `parseMarkdownToc`) must subtract 1 before passing in. */ // ── Pipe-table detection ───────────────────────────────────────────────────── /** * Pattern matching a markdown table separator row, e.g. `|---|---|`, * `| :--- | ---: |`, or `:---|---:` (pipe-less style). * * Allows optional leading/trailing whitespace, optional outer pipes, and * alignment colons. Requires at least two cells (i.e. at least one inner * pipe between dash sequences) so we don't false-positive on a horizontal * rule like `---`. */ const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/; /** * Pattern matching a plausible markdown table header/data row. Must contain * at least one pipe character that is not at the very start AND not part of * an inline code span. We don't try to be perfect here — the existence of a * matching separator row on the next line is the real signal that this is a * table. */ function looksLikeTableRow(line) { const trimmed = line.trim(); if (trimmed === "") return false; // Must contain at least one pipe. if (!trimmed.includes("|")) return false; // Exclude lines that are obviously not table rows: headings, list items // starting with `- |` are rare but possible; we lean permissive here // because the separator-row check below is the real gate. if (/^#{1,6}\s/.test(trimmed)) return false; return true; } /** * Given the start line of a candidate table (the header row), return the * **exclusive** end line — the first line after the table that is NOT part * of it (either a blank line, EOF, or a line that doesn't look like a table * row). Returns -1 if the candidate is not actually a table. * * @param lines Full body as a `string[]`. * @param headerLine 0-based index of the candidate header row. */ export function findEndOfTable(lines, headerLine) { if (headerLine < 0 || headerLine >= lines.length) return -1; if (!looksLikeTableRow(lines[headerLine])) return -1; const sepLine = headerLine + 1; if (sepLine >= lines.length) return -1; if (!TABLE_SEPARATOR_RE.test(lines[sepLine])) return -1; // Walk forward through data rows. A blank line, EOF, or a line that does // not look like a table row terminates the table. let i = sepLine + 1; while (i < lines.length) { if (lines[i].trim() === "") break; if (!looksLikeTableRow(lines[i])) break; i += 1; } return i; } /** * If `lineIdx` falls inside a markdown pipe table, return the exclusive end * line of that table. Otherwise return -1. * * "Inside" includes the header row, the separator row, and any data row. */ export function isInsideTable(lines, lineIdx) { if (lineIdx < 0 || lineIdx >= lines.length) return -1; // Walk backwards from lineIdx looking for a plausible table header // (i.e. a line followed by a separator row), up to the nearest blank // line or start-of-file. for (let i = lineIdx; i >= 0; i -= 1) { if (lines[i].trim() === "") return -1; // blank line — out of any table if (!looksLikeTableRow(lines[i])) return -1; const end = findEndOfTable(lines, i); if (end !== -1 && lineIdx < end) return end; // Continue scanning backwards — this row looks like a table row but // the table doesn't start here (could be a data row). } return -1; } // ── Fenced code block detection ─────────────────────────────────────────────── /** * Match a fenced code block opener/closer: ```` ``` ```` or `~~~`, with * optional leading whitespace and optional language identifier. The fence * character must repeat at least three times; the matched group is the * fence character + repeat count so we can detect matching closers. */ const FENCE_RE = /^(\s*)(`{3,}|~{3,})(.*)$/; /** * Return all fenced-code-block regions in `lines`. A fence is considered * unterminated if EOF is reached without a matching closer — in that case * the region extends to the last line. This matches CommonMark behaviour * and means "EOF closes any open fence" so we still treat the tail as a * no-insert region (otherwise a fixer could inject content into what the * author meant as a multi-line code sample). */ export function findFenceRegions(lines) { const regions = []; let openIdx = -1; let openFence = ""; for (let i = 0; i < lines.length; i += 1) { const match = lines[i].match(FENCE_RE); if (!match) continue; const fence = match[2]; if (openIdx === -1) { // Opening fence openIdx = i; openFence = fence[0]; // ``` or ~~~ continue; } // Inside a fence — only a matching fence character closes it, and the // closer must be at least as long. Per CommonMark we ignore any info // string on the closer (`match[3]` is allowed but typically empty). if (fence[0] === openFence && fence.length >= openFence.length) { regions.push({ start: openIdx, end: i }); openIdx = -1; openFence = ""; } } if (openIdx !== -1) { // Unterminated fence — extends to EOF. regions.push({ start: openIdx, end: lines.length - 1 }); } return regions; } /** * If `lineIdx` falls inside any fenced code block, return the exclusive * end line (one past the closing fence). Otherwise return -1. */ export function isInsideCodeFence(lines, lineIdx) { if (lineIdx < 0 || lineIdx >= lines.length) return -1; for (const region of findFenceRegions(lines)) { if (lineIdx >= region.start && lineIdx <= region.end) { return region.end + 1; } } return -1; } // ── HTML table detection ───────────────────────────────────────────────────── /** * If `lineIdx` falls inside an HTML `<table>…</table>` block, return the * exclusive end line (one past the `</table>`). Otherwise return -1. * * We do a deliberately simple scan: detect `<table` on any prior line (case * insensitive, allowing attributes) and require a `</table>` on or after * `lineIdx`. Nested tables are NOT supported — that's a markdown * anti-pattern and we'd rather under-detect than over-detect. */ export function isInsideHtmlTable(lines, lineIdx) { if (lineIdx < 0 || lineIdx >= lines.length) return -1; let openIdx = -1; for (let i = 0; i <= lineIdx; i += 1) { if (/<table[\s>]/i.test(lines[i])) openIdx = i; if (/<\/table\s*>/i.test(lines[i]) && openIdx !== -1 && i >= openIdx) { // Closing tag before lineIdx — table already finished, reset. if (i < lineIdx) openIdx = -1; else return i + 1; } } if (openIdx === -1) return -1; // We're after a `<table` opener — find the matching `</table>`. for (let i = lineIdx; i < lines.length; i += 1) { if (/<\/table\s*>/i.test(lines[i])) return i + 1; } // Unterminated table — extend to EOF so we don't inject into malformed HTML. return lines.length; } // ── Indented code block detection ──────────────────────────────────────────── /** * Per CommonMark, an indented code block is a sequence of lines indented by * 4+ spaces (or one tab), preceded by a blank line. We use a simplified * detection: if `lineIdx` is indented 4+ spaces / starts with a tab AND * either is the first line of the body or follows a blank line, treat it * as part of an indented code block and skip to the next non-indented * non-blank line. * * Returns the exclusive end of the code block if `lineIdx` is inside one, * otherwise -1. */ export function isInsideIndentedCode(lines, lineIdx) { if (lineIdx < 0 || lineIdx >= lines.length) return -1; const isIndented = (s) => /^( {4}|\t)/.test(s); if (!isIndented(lines[lineIdx])) return -1; // Walk backwards: every line above must be either indented or blank, and // we must eventually hit a blank line (or BOF) before any non-indented // non-blank line. If we find a non-indented non-blank line first, this // isn't an indented code block (it's just a continuation of a list item // or paragraph). let foundBlankBoundary = false; for (let i = lineIdx - 1; i >= 0; i -= 1) { if (lines[i].trim() === "") { foundBlankBoundary = true; break; } if (!isIndented(lines[i])) { return -1; // probably a list continuation, not a code block } } if (!foundBlankBoundary && lineIdx > 0) { // Walked all the way to BOF without a blank line — but lineIdx > 0 // means there was a non-indented non-blank line above, which would // have returned -1 already. This branch is for safety only. return -1; } // Walk forwards to find the end of the block. let i = lineIdx + 1; while (i < lines.length) { if (lines[i].trim() === "") { // A blank line MAY terminate the block, but per CommonMark a single // blank line followed by more indented lines is still part of the // same block. Peek ahead. let j = i + 1; while (j < lines.length && lines[j].trim() === "") j += 1; if (j >= lines.length || !isIndented(lines[j])) { break; // block ends at the blank line } i = j; continue; } if (!isIndented(lines[i])) break; i += 1; } return i; } // ── Composite: find a safe insertion point ─────────────────────────────────── /** * Given a proposed 0-based insertion line, return an adjusted 0-based line * that is guaranteed to fall outside of any markdown table, HTML table, * fenced code block, or indented code block. * * Strategy: if the proposed line falls inside a no-insert region, push it * to the line immediately AFTER that region. We never push it before — * most callouts are forward references to surrounding content, so * post-region is the safer choice (and prevents the very bug this helper * exists to fix: a callout landing between the header separator and the * first data row). * * The check is iterative: pushing past one region may land inside another * (e.g. table immediately followed by code fence), so we re-check until a * stable safe point is reached or we hit EOF. The iteration is bounded by * line count to guarantee termination. * * @param lines Body as a `string[]`. * @param proposedLineNumber 0-based index where the caller wants to insert. * @returns 0-based safe insertion index (may equal `lines.length`). */ export function findSafeInsertionPoint(lines, proposedLineNumber) { if (lines.length === 0) return 0; let target = Math.max(0, Math.min(proposedLineNumber, lines.length)); // Iterate at most `lines.length` times — each iteration that finds a // region only moves `target` forward, so we cannot loop forever. for (let guard = 0; guard <= lines.length; guard += 1) { if (target >= lines.length) return lines.length; const tableEnd = isInsideTable(lines, target); if (tableEnd !== -1) { target = tableEnd; continue; } const fenceEnd = isInsideCodeFence(lines, target); if (fenceEnd !== -1) { target = fenceEnd; continue; } const htmlEnd = isInsideHtmlTable(lines, target); if (htmlEnd !== -1) { target = htmlEnd; continue; } const indentedEnd = isInsideIndentedCode(lines, target); if (indentedEnd !== -1) { target = indentedEnd; continue; } return target; } // Defensive fallback — should be unreachable given the guard above. return Math.min(target, lines.length); } /** * Convenience wrapper that operates on a raw string (splits on `\r?\n` and * accepts 0-based line numbers). Returns the adjusted 0-based line. * * Useful when a caller has the markdown as a single string and only wants * to know "where can I safely splice in N more lines?" */ export function findSafeInsertionPointInText(content, proposedLineNumber) { return findSafeInsertionPoint(content.split(/\r?\n/), proposedLineNumber); }