kiira
Version:
Command-line interface for Kiira: validate TypeScript and JavaScript code fences in your Markdown docs.
1,057 lines (1,041 loc) • 36.3 kB
JavaScript
import { appendFileSync, constants, existsSync, readFileSync, statSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { checkMarkdownFiles, closeNativeEngine, collectExternalPackages, discoverMarkdownFiles, ensureExternalPackages, findConfigFile, loadConfig, loadConfigFile, resolveConfig } from "kiira-core";
import { randomBytes } from "node:crypto";
import { access, chmod, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { Chalk } from "chalk";
//#region src/args.ts
const REPORTERS = [
"pretty",
"json",
"github"
];
const COMMANDS = new Set(["check", "init"]);
const RULE_LEVELS = [
"off",
"warn",
"error"
];
/** Parse one `--rule` value (`<id>=<off|warn|error>`). Unknown ids are rejected later, by the config. */
function parseRule(value) {
const eq = value.indexOf("=");
const id = value.slice(0, eq);
const level = value.slice(eq + 1);
if (eq <= 0 || !RULE_LEVELS.includes(level)) throw new Error(`Invalid --rule "${value}". Expected <id>=<${RULE_LEVELS.join("|")}>.`);
return [id, level];
}
function isReporter(value) {
return REPORTERS.includes(value);
}
/** Parse `process.argv.slice(2)` into a structured command invocation. */
function parseArgs(argv) {
let command;
let config;
let reporter = "pretty";
let fix = false;
let dryRun = false;
let verbose = false;
let raw = false;
let staticOutput = false;
const files = [];
const entry = [];
const ignore = [];
const rules = {};
const base = () => ({
files,
config,
reporter,
fix,
dryRun,
verbose,
raw,
entry,
ignore,
static: staticOutput,
rules
});
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
if (arg === "--help" || arg === "-h") return {
command: "help",
...base()
};
if (arg === "--version" || arg === "-v") return {
command: "version",
...base()
};
if (arg === "--entry" || arg.startsWith("--entry=")) {
const value = arg.includes("=") ? arg.slice(8) : argv[++i] ?? "";
if (value) entry.push(value);
continue;
}
if (arg === "--ignore" || arg.startsWith("--ignore=")) {
const value = arg.includes("=") ? arg.slice(9) : argv[++i] ?? "";
if (value) ignore.push(value);
continue;
}
if (arg === "--rule" || arg.startsWith("--rule=")) {
const [id, level] = parseRule(arg.includes("=") ? arg.slice(7) : argv[++i] ?? "");
rules[id] = level;
continue;
}
if (arg === "--fix") {
fix = true;
continue;
}
if (arg === "--dry-run") {
dryRun = true;
continue;
}
if (arg === "--verbose") {
verbose = true;
continue;
}
if (arg === "--raw") {
raw = true;
continue;
}
if (arg === "--static") {
staticOutput = true;
continue;
}
if (arg === "--config" || arg.startsWith("--config=")) {
config = arg.includes("=") ? arg.slice(9) : argv[++i];
continue;
}
if (arg === "--reporter" || arg.startsWith("--reporter=")) {
const value = arg.includes("=") ? arg.slice(11) : argv[++i] ?? "";
if (!isReporter(value)) throw new Error(`Unknown reporter "${value}". Expected one of: ${REPORTERS.join(", ")}.`);
reporter = value;
continue;
}
if (arg.startsWith("-")) throw new Error(`Unknown option "${arg}".`);
if (command === void 0 && COMMANDS.has(arg)) command = arg;
else files.push(arg);
}
if (dryRun && !fix) throw new Error("--dry-run only works with --fix.");
return {
command: command ?? "check",
...base()
};
}
//#endregion
//#region src/diff.ts
const CONTEXT = 3;
/** Shortest edit script between two line lists (Myers), after trimming the shared head and tail. */
function diffLines(a, b) {
let head = 0;
while (head < a.length && head < b.length && a[head] === b[head]) head += 1;
let tail = 0;
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1;
const x0 = a.slice(head, a.length - tail);
const y0 = b.slice(head, b.length - tail);
const n = x0.length;
const m = y0.length;
const offset = n + m + 1;
const v = new Int32Array(2 * offset + 1);
const trace = [];
search: for (let d = 0; d <= n + m; d += 1) {
trace.push(v.slice());
for (let k = -d; k <= d; k += 2) {
let x = k === -d || k !== d && v[offset + k - 1] < v[offset + k + 1] ? v[offset + k + 1] : v[offset + k - 1] + 1;
let y = x - k;
while (x < n && y < m && x0[x] === y0[y]) {
x += 1;
y += 1;
}
v[offset + k] = x;
if (x >= n && y >= m) break search;
}
}
const middle = [];
let x = n;
let y = m;
for (let d = trace.length - 1; d >= 0; d -= 1) {
const before = trace[d];
const k = x - y;
const prevK = k === -d || k !== d && before[offset + k - 1] < before[offset + k + 1] ? k + 1 : k - 1;
const prevX = before[offset + prevK];
const prevY = prevX - prevK;
while (x > prevX && y > prevY) {
middle.push({
type: " ",
line: x0[x - 1]
});
x -= 1;
y -= 1;
}
if (d > 0) if (x === prevX) {
middle.push({
type: "+",
line: y0[y - 1]
});
y -= 1;
} else {
middle.push({
type: "-",
line: x0[x - 1]
});
x -= 1;
}
}
middle.reverse();
return [
...a.slice(0, head).map((line) => ({
type: " ",
line
})),
...middle,
...a.slice(a.length - tail).map((line) => ({
type: " ",
line
}))
];
}
const splitLines = (text) => text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
function formatLine(op) {
const text = op.line.endsWith("\n") ? op.line.slice(0, -1) : `${op.line}\n\\ No newline at end of file`;
return `${op.type}${text}\n`;
}
const range = (start, count) => `${count === 0 ? start - 1 : start},${count}`;
/** A unified diff (`--- a/` / `+++ b/` headers, 3 lines of context) between two texts, or "" when they are equal. */
function unifiedDiff(file, before, after) {
if (before === after) return "";
const ops = diffLines(splitLines(before), splitLines(after));
const regions = [];
ops.forEach((op, index) => {
if (op.type === " ") return;
const start = Math.max(0, index - CONTEXT);
const end = Math.min(ops.length, index + CONTEXT + 1);
const last = regions[regions.length - 1];
if (last && start <= last[1]) last[1] = end;
else regions.push([start, end]);
});
let out = `--- a/${file}\n+++ b/${file}\n`;
for (const [start, end] of regions) {
const hunk = ops.slice(start, end);
const consumed = ops.slice(0, start);
const oldCount = hunk.filter((op) => op.type !== "+").length;
const newCount = hunk.filter((op) => op.type !== "-").length;
const oldStart = consumed.filter((op) => op.type !== "+").length + 1;
const newStart = consumed.filter((op) => op.type !== "-").length + 1;
out += `@@ -${range(oldStart, oldCount)} +${range(newStart, newCount)} @@\n${hunk.map(formatLine).join("")}`;
}
return out;
}
//#endregion
//#region src/entries.ts
const GLOB_MAGIC = /[*?{}[\]()!]/;
function toPosix(path) {
return path.split("\\").join("/");
}
function isDirectory(cwd, path) {
try {
return statSync(isAbsolute(path) ? path : join(cwd, path)).isDirectory();
} catch {
return false;
}
}
/**
* Turn `--entry` / positional arguments into include globs. A glob is used as-is;
* a directory becomes `<dir>/**\/*.{md,mdx}`; a file path is used as-is.
*/
function toIncludeGlobs(cwd, entries) {
return entries.map((entry) => {
const value = toPosix(entry);
if (GLOB_MAGIC.test(value)) return value;
if (isDirectory(cwd, entry)) return `${value.replace(/\/+$/, "")}/**/*.{md,mdx}`;
return value;
});
}
/**
* Turn `--ignore` arguments into exclude globs. A glob is used as-is; a file path
* (with an extension) is used as-is; anything else is treated as a directory
* subtree (`<dir>/**`), so `--ignore docs/api` excludes the whole directory.
*/
function toIgnoreGlobs(cwd, ignores) {
return ignores.map((entry) => {
const value = toPosix(entry);
if (GLOB_MAGIC.test(value)) return value;
if (!isDirectory(cwd, entry) && /\.[a-z0-9]+$/i.test(value)) return value;
return `${value.replace(/\/+$/, "")}/**`;
});
}
//#endregion
//#region src/fix.ts
const FENCE_LANG = /^(\s*(?:`{3,}|~{3,})\s*)([A-Za-z0-9_-]+)/;
function textEdit(file, line, from, to, newText) {
return {
file,
range: {
start: {
line,
character: from
},
end: {
line,
character: to
}
},
newText
};
}
/** Turn a fence fix into an edit, using the fence line as the check read it. `undefined` when there is nothing to change. */
function fenceFixToEdit(file, fix, source) {
const line = source.split("\n")[fix.line]?.replace(/\r$/, "");
if (line === void 0) return;
if (fix.kind === "fence-language") {
const match = FENCE_LANG.exec(line);
if (!match || match[2] === fix.language) return;
const start = match[1].length;
return textEdit(file, fix.line, start, start + match[2].length, fix.language);
}
if (line.includes(fix.append)) return;
return textEdit(file, fix.line, line.trimEnd().length, line.length, ` ${fix.append}`);
}
const editKey = (edit) => JSON.stringify([edit.range, edit.newText]);
const countUnique = (candidates) => new Set(candidates.map(({ edit }) => editKey(edit))).size;
const isForbidden = (relativePath) => relativePath.split(sep).some((part) => part === ".git" || part === "node_modules");
const isOutside = (relativePath) => relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath);
/** `file` as a cwd-relative posix path, the way the check keys `sources`. Absolute paths stay as written. */
const normalizePath = (cwd, file) => isAbsolute(file) ? file : relative(cwd, resolve(cwd, file)).split(sep).join("/");
/** Why `file` is refused as written: it must be inside `cwd` and outside `.git` and `node_modules`. */
function unsafePath(cwd, file) {
if (isAbsolute(file)) return "the path must be relative to the project";
const lexical = relative(cwd, resolve(cwd, file));
if (lexical === "" || isOutside(lexical)) return "the path is outside the project";
if (isForbidden(lexical)) return "the path is inside .git or node_modules";
}
/**
* The real path to write for `file`, or why it is refused. Like `unsafePath`, but
* after following every symlink on the way (`realpath` resolves each component).
*/
async function resolveTarget(cwd, file) {
let target;
let root;
try {
target = await realpath(resolve(cwd, file));
root = await realpath(cwd);
} catch {
return { reason: "the file cannot be read" };
}
const real = relative(root, target);
if (real === "" || isOutside(real)) return { reason: "the path resolves outside the project (symlink)" };
if (isForbidden(real)) return { reason: "the path resolves into .git or node_modules (symlink)" };
return { target };
}
function lineStarts(text) {
const starts = [0];
for (let i = 0; i < text.length; i += 1) if (text[i] === "\n") starts.push(i + 1);
return starts;
}
/** A position's offset into `text`. A line's characters stop before its `\r\n`. */
function toOffset(text, starts, position) {
const start = starts[position.line];
if (start === void 0) return;
const next = starts[position.line + 1];
let end = next === void 0 ? text.length : next - 1;
if (next !== void 0 && text[end - 1] === "\r") end -= 1;
return position.character >= 0 && start + position.character <= end ? start + position.character : void 0;
}
/** `text` with `candidates` applied, or the labels of the fixes whose edits overlap, or a bad range. */
function applyEdits(text, candidates) {
const starts = lineStarts(text);
const crlf = text.includes("\r\n");
const seen = /* @__PURE__ */ new Set();
const resolved = [];
for (const { edit, label } of candidates) {
const key = editKey(edit);
if (seen.has(key)) continue;
seen.add(key);
const start = toOffset(text, starts, edit.range.start);
const end = toOffset(text, starts, edit.range.end);
if (start === void 0 || end === void 0 || end < start) return { reason: `an edit from ${label} has a range outside the file` };
resolved.push({
start,
end,
newText: crlf ? edit.newText.replace(/\r?\n/g, "\r\n") : edit.newText,
label
});
}
resolved.sort((a, b) => a.start - b.start || a.end - b.end);
const conflicts = /* @__PURE__ */ new Set();
let widest = resolved[0];
for (const edit of resolved.slice(1)) {
if (edit.start < widest.end) {
conflicts.add(widest.label);
conflicts.add(edit.label);
}
if (edit.end > widest.end) widest = edit;
}
if (conflicts.size > 0) return { reason: `overlapping edits from ${[...conflicts].join(" and ")}; none applied` };
let out = "";
let cursor = 0;
for (const edit of resolved) {
out += text.slice(cursor, edit.start) + edit.newText;
cursor = edit.end;
}
return { text: out + text.slice(cursor) };
}
/** Why `target` must not be replaced with edits to `before`, or `undefined` when it can be. */
async function staleReason(target, before) {
let bytes;
try {
bytes = await readFile(target);
} catch {
return "the file cannot be read";
}
const text = bytes.toString("utf8");
if (!Buffer.from(text, "utf8").equals(bytes)) return "the file is not valid UTF-8";
if (text !== before) return "the file changed since the check read it";
try {
await access(target, constants.W_OK);
} catch {
return "the file is not writable";
}
}
/** The checks on `file` before any edit is planned: a safe path, read by the check, and unchanged since. */
async function inspectFile(cwd, file, sources) {
const unsafe = unsafePath(cwd, file);
if (unsafe) return { reason: unsafe };
const before = sources[file];
if (before === void 0) return { reason: "the check did not read this file" };
const resolved = await resolveTarget(cwd, file);
if ("reason" in resolved) return resolved;
const reason = await staleReason(resolved.target, before);
return reason ? { reason } : {
target: resolved.target,
before
};
}
async function writeAtomic(target, before, content) {
const { mode } = await stat(target);
const temp = join(dirname(target), `.${basename(target)}.${randomBytes(6).toString("hex")}.tmp`);
try {
await writeFile(temp, content, {
flag: "wx",
mode
});
await chmod(temp, mode & 4095);
const reason = await staleReason(target, before);
if (reason) throw new Error(reason);
await rename(temp, target);
} catch (error) {
await rm(temp, { force: true });
throw error;
}
}
/**
* Apply the text fixes in `diagnostics`: `edits` fixes, and the built-in fence
* fixes (rewrite a mistagged language, append `group=`), which become edits first.
*
* `sources` is what the check read. A file is refused, and left untouched, when
* the check did not read it, it changed since, it is not UTF-8 or not writable,
* its path is unsafe, or its edits overlap. A fix is all or nothing: when one of
* its files is refused, its edits to the other files are dropped too. Other fixes
* still apply. Every check runs before the first write. With `dryRun` nothing is written.
*/
async function applyFixes(cwd, diagnostics, sources, options = {}) {
const candidates = [];
const refused = /* @__PURE__ */ new Map();
diagnostics.forEach((d, index) => {
const fix = d.fix;
if (!fix || fix.kind === "config-override") return;
const label = `${d.code ?? d.source} at ${d.markdownFile}:${d.markdownRange.start.line + 1}:${d.markdownRange.start.character + 1}`;
if (fix.kind === "edits") {
for (const edit of fix.edits) candidates.push({
edit: {
...edit,
file: normalizePath(cwd, edit.file)
},
label,
fence: false,
fix: index
});
return;
}
const source = sources[d.markdownFile];
if (source === void 0) {
refused.set(d.markdownFile, "the check did not read this file");
return;
}
const edit = fenceFixToEdit(d.markdownFile, fix, source);
if (edit) candidates.push({
edit,
label,
fence: true,
fix: index
});
});
const files = /* @__PURE__ */ new Map();
for (const file of new Set(candidates.map(({ edit }) => edit.file))) {
const inspected = refused.has(file) ? void 0 : await inspectFile(cwd, file, sources);
if (inspected && "reason" in inspected) refused.set(file, inspected.reason);
else if (inspected) files.set(file, inspected);
}
const dropped = /* @__PURE__ */ new Set();
let planned = [];
for (let replan = true; replan;) {
replan = false;
for (const c of candidates) if (refused.has(c.edit.file)) dropped.add(c.fix);
planned = [];
for (const [file, { target, before }] of files) {
const mine = candidates.filter((c) => c.edit.file === file && !dropped.has(c.fix));
if (refused.has(file) || mine.length === 0) continue;
const applied = applyEdits(before, mine);
if ("reason" in applied) {
refused.set(file, applied.reason);
replan = true;
} else if (applied.text !== before) planned.push({
file,
before,
after: applied.text,
target,
candidates: mine
});
}
}
const refusals = [...refused].map(([file, reason]) => ({
file,
reason
}));
for (const fix of dropped) {
const edits = candidates.filter((c) => c.fix === fix);
const cause = edits.find((c) => refused.has(c.edit.file))?.edit.file;
for (const file of new Set(edits.map(({ edit }) => edit.file))) if (!refused.has(file)) refusals.push({
file,
reason: `the fix from ${edits[0].label} also edits ${cause}, which was skipped`
});
}
const changes = [];
let editsApplied = 0;
let fenceEditsApplied = 0;
for (const { target, candidates, ...change } of planned) {
if (!options.dryRun) try {
await writeAtomic(target, change.before, change.after);
} catch (error) {
refusals.push({
file: change.file,
reason: `could not write the file: ${error.message}`
});
continue;
}
changes.push(change);
editsApplied += countUnique(candidates);
fenceEditsApplied += countUnique(candidates.filter((c) => c.fence));
}
return {
filesChanged: changes.length,
editsApplied,
fenceEditsApplied,
refusals,
changes
};
}
/**
* Apply `config-override` fixes by merging per-glob compiler-option overrides into
* a JSON Kiira config (with `dryRun`, only report what would be added). Non-JSON configs (or none) can't be safely edited, so
* those fixes are returned as `manual` for the caller to surface.
*/
async function applyConfigOverrides(configPath, diagnostics, options = {}) {
const fixes = [];
const seen = /* @__PURE__ */ new Set();
for (const d of diagnostics) {
if (d.fix?.kind !== "config-override") continue;
const key = `${d.fix.include}