Repository Public Research preview
1#!/usr/bin/env node2/**3 * Deterministic audits of a LaTeX or Markdown manuscript. No dependencies, no network, no model.4 *5 * node manuscript.mjs audit <file> every check, findings sorted by severity6 * node manuscript.mjs outline <file> sections, word counts, abstract length, totals7 * node manuscript.mjs citations <file> cited keys against the bibliography8 * node manuscript.mjs refs <file> labels against references9 * node manuscript.mjs numbers <file> abstract numbers, sample sizes, percentages10 * node manuscript.mjs stats <file> how statistics are reported11 * node manuscript.mjs tables <file> tables and figures12 * node manuscript.mjs claims <file> priority, certainty and robustness claims13 * node manuscript.mjs style <file> leftovers, acronyms, long sentences, hedging14 * node manuscript.mjs replication <file> data, code, software, seed statements15 * node manuscript.mjs scaffold <file> referee-report skeleton in Markdown16 * node manuscript.mjs compare <old> <new> what changed between two versions17 * node manuscript.mjs codes every finding code and how to verify it18 *19 * Options:20 * --root DIR only directory files are read from (default: the directory of <file>).21 * Set it to the project directory when the manuscript includes ../tables/x.22 * --bib FILE bibliography (.bib or CSL JSON) inside the root; default: the one declared23 * --format latex|markdown24 * --text short text listing instead of JSON25 * --min-severity info|warning|error lowest severity listed (counts cover everything)26 * --fail-on error|warning|never exit 2 when such findings exist (default: error)27 * --include-info scaffold: list info-level notes too28 * --json scaffold: JSON instead of Markdown29 *30 * Output is JSON unless --text (scaffold: Markdown unless --json).31 * Exit code 0 ok, 1 usage or read error, 2 findings at the --fail-on level.32 * Findings marked heuristic are pattern matches: read the line before reporting them.33 * An empty list never means the manuscript is correct.34 */35import { readFileSync } from "node:fs";36import { dirname, relative, resolve } from "node:path";37import { fileURLToPath } from "node:url";38import {39 CODES, auditCitations, auditClaims, auditCrossReferences, auditNumbers, auditReplication, auditStatistics, auditStyle, auditTables,40 compareVersions, fullAudit, loadDocument, outline, reviewScaffold,41} from "./lib/index.mjs";4243const AUDITS = {44 audit: fullAudit, outline, citations: auditCitations, refs: auditCrossReferences, numbers: auditNumbers, stats: auditStatistics,45 tables: auditTables, claims: auditClaims, style: auditStyle, replication: auditReplication,46};47const BOOL = new Set(["text", "json", "include-info", "help"]);4849function usage() {50 const src = readFileSync(fileURLToPath(import.meta.url), "utf8");51 return src.slice(src.indexOf("/**") + 3, src.indexOf("*/")).split("\n").map((l) => l.replace(/^ \* ?/, "")).join("\n").trim();52}53function die(code, message) {54 process.stderr.write(`${JSON.stringify({ error: { code, message } })}\n`);55 process.exit(1);56}5758const [cmd, ...rest] = process.argv.slice(2);59const flags = {}, args = [];60for (let i = 0; i < rest.length; i++) {61 const a = rest[i];62 if (!a.startsWith("--")) { args.push(a); continue; }63 const k = a.slice(2);64 if (BOOL.has(k)) flags[k] = true;65 else { if (rest[i + 1] === undefined) die("usage", `--${k} needs a value`); flags[k] = rest[++i]; }66}67if (!cmd || cmd === "help" || cmd === "--help" || flags.help) { console.log(usage()); process.exit(cmd ? 0 : 1); }6869const rank = { info: 0, warning: 1, error: 2 };70if (flags["min-severity"] && !(flags["min-severity"] in rank)) die("usage", "--min-severity must be info, warning or error");71const failOn = flags["fail-on"] ?? "error";72if (!["error", "warning", "never"].includes(failOn)) die("usage", "--fail-on must be error, warning or never");7374function load(file) {75 const abs = resolve(file);76 const root = flags.root ? resolve(flags.root) : dirname(abs);77 return loadDocument({ root, path: relative(root, abs) || file, format: flags.format, bib_path: flags.bib });78}79const print = (data) => console.log(JSON.stringify(data, null, 2));80function listing(r) {81 const s = r.summary;82 const lines = [`${r.file} (${r.format}): ${s.errors} errors, ${s.warnings} warnings, ${s.infos} notes${s.hygiene_score !== undefined ? `, hygiene score ${s.hygiene_score}/100 (not a quality score)` : ""}`];83 for (const f of r.findings) lines.push(`${f.severity.toUpperCase().padEnd(7)} ${f.location.file}:${f.location.line} ${f.code}${f.heuristic ? " (heuristic)" : ""} ${f.message}`);84 lines.push(r.disclaimer);85 return lines.join("\n");86}8788try {89 if (cmd === "codes") {90 print(Object.entries(CODES).map(([code, c]) => ({ code, ...c })));91 } else if (cmd === "compare") {92 if (args.length !== 2) die("usage", "compare needs two files: <old> <new>");93 const [a, b] = args.map((x) => resolve(x));94 const root = flags.root ? resolve(flags.root) : null;95 const one = (abs) => { const r = root ?? dirname(abs); return loadDocument({ root: r, path: relative(r, abs), format: flags.format }); };96 print(compareVersions(one(a), one(b)));97 } else if (cmd === "scaffold") {98 if (args.length !== 1) die("usage", "scaffold needs one file");99 const r = reviewScaffold(load(args[0]), { include_info: !!flags["include-info"] });100 if (flags.json) print(r); else console.log(r.markdown);101 } else if (AUDITS[cmd]) {102 if (args.length !== 1) die("usage", `${cmd} needs one file`);103 const full = AUDITS[cmd](load(args[0]), {});104 const min = rank[flags["min-severity"] ?? "info"];105 const r = { ...full, findings: full.findings.filter((f) => rank[f.severity] >= min) };106 if (flags.text) console.log(listing(r)); else print(r);107 const bad = failOn === "never" ? 0 : failOn === "warning" ? full.summary.errors + full.summary.warnings : full.summary.errors;108 if (bad > 0) process.exitCode = 2;109 } else {110 die("usage", `Unknown command "${cmd}". Run with --help.`);111 }112} catch (e) {113 die(e.code ?? "error", String(e.message ?? e));114}115