Repository Public Research preview
1// Deterministic manuscript audits. Every finding carries a code, a severity, a2// location (file, line), a message and the source line as evidence. Checks marked3// `heuristic: true` are pattern-based guesses that a reader must confirm.4import { CITE, REF, countWords, lineAt } from "./parse.mjs";56export const DISCLAIMER = "Deterministic text checks only. They do not read figures, run code or judge the science, and an empty list never means the manuscript is correct.";78export const ROLES = {9 referee: "Referee: method, evidence, strength of claims",10 editor: "Editor: structure, exposition, readiness",11 table_figure_editor: "Table and figure editor: exhibits, captions, cross-references",12 integrity_auditor: "Citation and integrity auditor: references, numbers, internal consistency",13 replication_auditor: "Replication auditor: data, code, what a replicator needs",14};1516export function roleOf(code) {17 if (code === "stat-stars-without-legend" || code === "stat-no-uncertainty") return "table_figure_editor";18 const p = code.split("-")[0];19 if (p === "cite" || p === "num" || p === "integrity") return "integrity_auditor";20 if (p === "xref" || p === "table" || p === "figure") return "table_figure_editor";21 if (p === "stat" || p === "claim") return "referee";22 if (p === "repl") return "replication_auditor";23 return "editor";24}2526const excerpt = (s) => { const t = (s ?? "").trim().replace(/\s+/g, " "); return t.length > 220 ? `${t.slice(0, 217)}...` : t; };2728function mk(doc, code, severity, i, message, extra = {}) {29 const l = doc.L[i] ?? null;30 const { heuristic = false, ...rest } = extra;31 return { code, severity, role: roleOf(code), heuristic, location: { file: l?.file ?? doc.file, line: l?.line ?? 1 }, message, evidence: excerpt(l?.raw), ...rest, _o: i };32}33function mkBib(code, severity, entry, message, extra = {}) {34 return { code, severity, role: roleOf(code), heuristic: false, location: { file: entry.file, line: entry.line }, message, evidence: excerpt(entry.raw), ...extra, _o: 1e9 + entry.line };35}3637export function finish(doc, tool, findings, extra = {}) {38 const sorted = [...findings].sort((a, b) => a._o - b._o || a.code.localeCompare(b.code));39 const out = sorted.map(({ _o, ...f }) => f);40 const count = (s) => out.filter((f) => f.severity === s).length;41 return {42 tool, file: doc.file, format: doc.format,43 summary: { total: out.length, errors: count("error"), warnings: count("warning"), infos: count("info"), heuristic: out.filter((f) => f.heuristic).length },44 findings: out, ...extra, disclaimer: DISCLAIMER,45 };46}4748const where = (doc, i) => ({ file: doc.L[i].file, line: doc.L[i].line });4950// ------------------------------------------------------------------ outline5152export function outline(doc, opts = {}) {53 const maxAbstract = opts.max_abstract_words ?? 250;54 const f = [];55 const secs = doc.sections.filter((s) => !s.isTitle);56 if (!doc.abstract) f.push(mk(doc, "outline-abstract-missing", "warning", doc.bodyRange[0], "No abstract was found (LaTeX abstract environment, Markdown front-matter key, Abstract heading or Abstract paragraph)."));57 else if (doc.abstract.words > maxAbstract) f.push(mk(doc, "outline-abstract-long", "info", doc.abstract.first, `The abstract has ${doc.abstract.words} words, above the limit of ${maxAbstract}.`, { words: doc.abstract.words, limit: maxAbstract }));58 for (const u of doc.unresolvedIncludes) {59 const i = doc.L.findIndex((l) => l.file === u.file && l.line === u.line);60 f.push(mk(doc, "outline-include-not-read", "warning", i, `Included file "${u.name}" was not read (${u.reason}). Its content is missing from every audit.`, { include: u.name }));61 }62 for (let k = 0; k < doc.sections.length; k++) {63 const s = doc.sections[k];64 if (s.isTitle) continue;65 const next = doc.sections.slice(k + 1).find((x) => x.level <= s.level);66 const endI = next ? next.i : doc.bodyRange[1];67 let other = false;68 for (let i = s.i + 1; i < endI; i++) { const l = doc.L[i]; if (l.body && !l.prose && (l.float || (l.t && l.t.length > 0))) { other = true; break; } }69 if (s.wordsTotal < 10 && !other) f.push(mk(doc, "outline-empty-section", "warning", s.i, `Section "${s.title}" has ${s.wordsTotal} words of text and no exhibit.`, { words: s.wordsTotal }));70 const prev = doc.sections.slice(0, k).reverse().find((x) => !x.isTitle);71 if (prev && s.level > prev.level + 1 && !s.minor) f.push(mk(doc, "outline-heading-level-skip", "info", s.i, `Heading "${s.title}" jumps from level ${prev.level} to level ${s.level}.`));72 const children = [];73 for (let q = k + 1; q < doc.sections.length && doc.sections[q].level > s.level; q++) if (doc.sections[q].level === s.level + 1 && !doc.sections[q].minor) children.push(doc.sections[q]);74 if (children.length === 1) f.push(mk(doc, "outline-single-subsection", "info", children[0].i, `Section "${s.title}" has a single subsection ("${children[0].title}").`));75 }76 return finish(doc, "outline", f, {77 title: doc.title,78 abstract: doc.abstract ? { present: true, words: doc.abstract.words, start: where(doc, doc.abstract.first), end: where(doc, doc.abstract.last) } : { present: false, words: 0 },79 sections: secs.map((s) => ({ level: s.level, title: s.title, ...where(doc, s.i), words: s.words, words_with_subsections: s.wordsTotal, appendix: !!s.appendix })),80 totals: {81 words: doc.words, sentences: doc.sentences.filter((s) => !s.para.abs).length, sections: secs.length,82 figures: doc.counts.figures, tables: doc.counts.tables, numbered_equations: doc.counts.equations, footnotes: doc.counts.footnotes,83 labels: doc.labels.length, citation_occurrences: doc.cites.length + doc.numCites.length,84 distinct_cited_keys: new Set([...doc.cites.map((c) => c.key), ...doc.numCites.map((c) => `#${c.n}`)]).size,85 bibliography_entries: doc.bib ? doc.bib.entries.length : doc.bibitems.length + doc.numericRefs.length,86 },87 unresolved_includes: doc.unresolvedIncludes,88 note: "Word counts are computed on text with markup removed; floats, display equations, headings and the bibliography are excluded. They approximate, and will not exactly equal, texcount or a word processor.",89 });90}9192// ---------------------------------------------------------------- citations9394const DOI_OK = /^10\.\d{4,9}\/\S+$/;95const normTitle = (t) => t.toLowerCase().replace(/\\[a-z]+/g, "").replace(/[^a-z0-9]+/g, "");9697export function auditCitations(doc) {98 const f = [];99 const bib = doc.bib;100 for (const c of doc.cites) if (c.key === "") f.push(mk(doc, "cite-empty", "error", c.i, `A \\${c.cmd} command has no key.`));101 const cited = new Map();102 for (const c of doc.cites) { if (!c.key) continue; if (!cited.has(c.key)) cited.set(c.key, []); cited.get(c.key).push(c.i); }103 const defined = new Map();104 if (bib) for (const e of bib.entries) { if (!defined.has(e.key)) defined.set(e.key, []); defined.get(e.key).push(e); }105 const items = new Map();106 for (const b of doc.bibitems) { if (!items.has(b.key)) items.set(b.key, []); items.get(b.key).push(b.i); }107 const haveList = !!bib || doc.bibitems.length > 0;108 if (bib) for (const p of bib.problems) f.push({ code: "cite-bib-parse-error", severity: "error", role: roleOf("cite-x"), heuristic: false, location: { file: p.file, line: p.line }, message: p.message, evidence: "", _o: 1e9 + p.line });109 if (doc.bib?.missingFiles?.length) for (const name of doc.bib.missingFiles) f.push(mk(doc, "cite-bibliography-file-missing", "error", doc.bodyRange[0], `Bibliography file "${name}" is declared but could not be read inside the allowed root.`));110 if (cited.size && !haveList && !doc.numeric) {111 const first = Math.min(...[...cited.values()].map((v) => v[0]));112 f.push(mk(doc, "cite-no-bibliography", "info", first, `${cited.size} distinct keys are cited but no bibliography was provided or found, so keys could not be checked. Pass bib_path or bib_text.`));113 }114 if (haveList) {115 for (const [key, at] of cited) {116 if (!defined.has(key) && !items.has(key)) f.push(mk(doc, "cite-missing-entry", "error", at[0], `Cited key "${key}" has no bibliography entry.`, { key, occurrences: at.length }));117 }118 if (!doc.citeAll) {119 const targets = new Set(bib ? bib.entries.map((e) => e.fields.crossref).filter(Boolean) : []);120 for (const [key, es] of defined) if (!cited.has(key) && !targets.has(key)) f.push(mkBib("cite-unused-entry", "info", es[0], `Bibliography entry "${key}" is never cited.`, { key }));121 for (const [key, at] of items) if (!cited.has(key)) f.push(mk(doc, "cite-unused-entry", "info", at[0], `Bibliography item "${key}" is never cited.`, { key }));122 }123 for (const [key, at] of items) if (at.length > 1) f.push(mk(doc, "cite-duplicate-key", "error", at[1], `Bibliography key "${key}" is defined ${at.length} times.`, { key }));124 }125 if (bib) {126 const crossTargets = new Set(bib.entries.map((e) => e.fields.crossref).filter(Boolean));127 for (const [key, es] of defined) if (es.length > 1) f.push(mkBib("cite-duplicate-key", "error", es[1], `Bibliography key "${key}" is defined ${es.length} times (first at line ${es[0].line}).`, { key }));128 const byDoi = new Map(), byTitle = new Map();129 for (const e of bib.entries) {130 if (e.type === "set" || e.type === "xdata") continue;131 const fl = e.fields;132 if (!fl.crossref && !fl.xdata) {133 const missing = [];134 if (!fl.author && !fl.editor && !fl.organization && !fl.institution && !fl.key) missing.push("author");135 if (!fl.title && !fl.booktitle) missing.push("title");136 if (!fl.year && !fl.date && !/forthcoming|in press/i.test(`${fl.note ?? ""} ${fl.pubstate ?? ""}`)) missing.push("year");137 if (missing.length) f.push(mkBib("cite-entry-missing-field", "warning", e, `Entry "${e.key}" has no ${missing.join(", no ")}.`, { key: e.key, missing }));138 }139 if (fl.doi !== undefined) {140 const bare = fl.doi.trim().replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, "").replace(/^doi:\s*/i, "");141 if (!DOI_OK.test(bare) || /[.,;]$/.test(bare)) f.push(mkBib("cite-malformed-doi", "warning", e, `Entry "${e.key}" has a DOI that does not look like one: "${fl.doi}". A DOI starts with "10.", a registrant code, a slash and a suffix, with no trailing punctuation.`, { key: e.key, doi: fl.doi }));142 else { const k = bare.toLowerCase(); if (!byDoi.has(k)) byDoi.set(k, []); byDoi.get(k).push(e); }143 }144 if (fl.title && normTitle(fl.title).length >= 20 && !crossTargets.has(e.key)) { const k = `${normTitle(fl.title)}|${fl.year ?? fl.date ?? ""}`; if (!byTitle.has(k)) byTitle.set(k, []); byTitle.get(k).push(e); }145 }146 const reported = new Set();147 for (const [doi, es] of byDoi) {148 const keys = [...new Set(es.map((e) => e.key))];149 if (keys.length > 1) { const e = es.find((x) => x.key !== es[0].key); reported.add(keys.join("|")); f.push(mkBib("cite-duplicate-entry", "warning", e, `Entries ${keys.map((k) => `"${k}"`).join(" and ")} share the DOI ${doi}.`, { keys })); }150 }151 for (const [, es] of byTitle) {152 const keys = [...new Set(es.map((e) => e.key))];153 if (keys.length > 1 && !reported.has(keys.join("|"))) { const e = es.find((x) => x.key !== es[0].key); f.push(mkBib("cite-duplicate-entry", "warning", e, `Entries ${keys.map((k) => `"${k}"`).join(" and ")} have the same title and year.`, { keys })); }154 }155 }156 if (doc.numeric) {157 const defs = new Map();158 for (const r of doc.numericRefs) { if (defs.has(r.n)) f.push(mk(doc, "cite-duplicate-key", "error", r.i, `Reference number ${r.n} appears twice in the reference list.`, { key: String(r.n) })); else defs.set(r.n, r.i); }159 const used = new Map();160 for (const c of doc.numCites) { if (!used.has(c.n)) used.set(c.n, []); used.get(c.n).push(c.i); }161 for (const [n, at] of used) if (!defs.has(n)) f.push(mk(doc, "cite-missing-entry", "error", at[0], `Citation [${n}] has no entry in the reference list.`, { key: String(n), occurrences: at.length }));162 for (const [n, i] of defs) if (!used.has(n)) f.push(mk(doc, "cite-unused-entry", "info", i, `Reference [${n}] is never cited in the text.`, { key: String(n) }));163 }164 return finish(doc, "audit_citations", f, {165 stats: {166 citation_occurrences: doc.cites.length + doc.numCites.length, distinct_cited_keys: cited.size + new Set(doc.numCites.map((c) => c.n)).size,167 bibliography_entries: (bib ? bib.entries.length : 0) + doc.bibitems.length + doc.numericRefs.length,168 bibliography_files: bib ? bib.files : [], style: doc.numeric ? "numeric" : doc.format === "latex" ? "latex-keys" : "pandoc-keys",169 },170 note: "Keys are matched as text. Nothing is looked up online: a well-formed DOI or a complete entry can still point to a paper that does not exist or does not say what the text claims.",171 });172}173174// --------------------------------------------------------- cross-references175176export function auditCrossReferences(doc) {177 const f = [];178 const defs = new Map();179 for (const l of doc.labels) {180 if (defs.has(l.name)) f.push(mk(doc, "xref-duplicate-label", "error", l.i, `Label "${l.name}" is defined more than once (first at ${doc.L[defs.get(l.name).i].file}:${doc.L[defs.get(l.name).i].line}).`, { label: l.name }));181 else defs.set(l.name, l);182 }183 const used = new Map();184 for (const r of doc.refs) { if (!used.has(r.name)) used.set(r.name, []); used.get(r.name).push(r.i); }185 for (const [name, at] of used) if (!defs.has(name)) f.push(mk(doc, "xref-undefined", "error", at[0], `Reference to "${name}" has no matching label.`, { label: name, occurrences: at.length }));186 const seenFloat = new Set();187 for (const [name, l] of defs) {188 if (l.float) {189 if (seenFloat.has(l.float)) continue;190 seenFloat.add(l.float);191 if (!l.float.labels.some((x) => used.has(x))) f.push(mk(doc, `xref-unreferenced-${l.float.kind}`, "warning", l.i, `The ${l.float.kind} labelled "${l.float.labels[0]}" is never referenced in the text.`, { label: l.float.labels[0] }));192 } else if (!used.has(name)) {193 if (l.kind === "figure" || l.kind === "table") f.push(mk(doc, `xref-unreferenced-${l.kind}`, "warning", l.i, `The ${l.kind} labelled "${name}" is never referenced in the text.`, { label: name }));194 else if (l.kind === "equation") f.push(mk(doc, "xref-unreferenced-equation", "info", l.i, `Equation label "${name}" is never referenced. Number only the equations the text refers to.`, { label: name }));195 }196 }197 if (doc.format === "markdown") {198 const defsN = new Map();199 for (const d of doc.exhibitDefs) { const k = `${d.kind} ${d.n}`; if (defsN.has(k)) f.push(mk(doc, "xref-duplicate-label", "error", d.i, `There are two captions numbered "${k}".`, { label: k })); else defsN.set(k, d); }200 const men = new Map();201 for (const m of doc.exhibitMentions) { const k = `${m.kind} ${m.n}`; if (!men.has(k)) men.set(k, []); men.get(k).push(m.i); }202 for (const [k, d] of defsN) if (!men.has(k)) f.push(mk(doc, "xref-exhibit-not-called-out", "warning", d.i, `"${k[0].toUpperCase()}${k.slice(1)}" has a caption but the text never mentions it.`, { label: k, heuristic: true }));203 if (defsN.size) for (const [k, at] of men) if (!defsN.has(k)) f.push(mk(doc, "xref-undefined", "error", at[0], `The text mentions "${k[0].toUpperCase()}${k.slice(1)}" but no caption carries that number.`, { label: k, occurrences: at.length, heuristic: true }));204 } else {205 for (let i = doc.bodyRange[0]; i < doc.bodyRange[1]; i++) {206 const l = doc.L[i];207 if (!l.prose || !l.t) continue;208 const m = /\b(Tables?|Figures?|Figs?\.|Equations?|Eqs?\.)\s+(\d+)\b/.exec(l.t);209 if (m) f.push(mk(doc, "xref-hardcoded-reference", "info", i, `"${m[0]}" is typed by hand. A \\ref keeps the number right when exhibits move.`, { heuristic: true }));210 }211 }212 const byKind = {};213 for (const l of doc.labels) byKind[l.kind] = (byKind[l.kind] ?? 0) + 1;214 return finish(doc, "audit_cross_references", f, {215 stats: { labels: doc.labels.length, labels_by_kind: byKind, references: doc.refs.length, numbered_captions: doc.exhibitDefs.length, numbered_mentions: doc.exhibitMentions.length },216 note: doc.format === "markdown" ? "Markdown: pandoc-crossref ids ({#fig:x}, @fig:x) are matched exactly; plain numbered captions (\"Table 2:\") are matched to mentions by number, which is a heuristic." : "LaTeX: labels and references are matched as text across the main file and the included files that could be read. One-argument macros that wrap \\ref are recognised when defined with \\newcommand in those files. Labels created by other macros, or defined in files that were not read (a single chapter of a larger document), show up as undefined.",217 });218}219220// ------------------------------------------------------------------ numbers221222const NUM = /(?<![A-Za-z0-9_.,\u27E6^])(-?)(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)(?![\d]|[A-Za-z_]|\.\d|,\d{3}\b)(\s?%|\s+(?:percent|per cent)\b)?/g;223const EXHIBIT_WORD = /(?:Tables?|Figures?|Figs?\.?|Sections?|Sec\.|Appendix|Chapters?|Equations?|Eqs?\.?|Panels?|Columns?|Models?|Hypothes[ie]s|Steps?|Phases?|Study|Studies|Experiments?|Waves?|Grades?|Version|Rows?|Footnotes?|Proposition|Theorem|Lemma|Assumption|Specifications?|and|,|-|\u2013)\s*\(?$/i;224const EXHIBIT_HEAD = /(?:Tables?|Figures?|Figs?\.?|Sections?|Sec\.|Appendix|Chapters?|Equations?|Eqs?\.?|Panels?|Columns?|Models?|Hypothes[ie]s|Steps?|Phases?|Study|Studies|Experiments?|Waves?|Grades?|Version|Rows?|Footnotes?|Proposition|Theorem|Lemma|Assumption|Specifications?)\s*\(?(?:\d+\s*(?:,|and|-|\u2013)\s*)*$/i;225226export function extractNumbers(text) {227 const out = [];228 NUM.lastIndex = 0;229 let m;230 while ((m = NUM.exec(text))) {231 const digits = m[2].replace(/,/g, "");232 const dec = digits.includes(".") ? digits.split(".")[1].length : 0;233 const value = parseFloat(digits);234 const percent = !!m[3];235 const plainInt = dec === 0 && !percent && !m[2].includes(",");236 out.push({237 raw: (m[1] + m[2] + (percent ? "%" : "")), value, decimals: dec, percent, pos: m.index, len: m[0].length,238 year: plainInt && value >= 1500 && value <= 2100,239 exhibit: plainInt && EXHIBIT_HEAD.test(text.slice(Math.max(0, m.index - 40), m.index)),240 });241 }242 return out;243}244245const tol = (dec) => 0.5 * 10 ** -dec + 1e-9;246function supported(a, corpus) {247 for (const b of corpus) {248 if (b.decimals >= a.decimals && Math.abs(Math.abs(b.value) - Math.abs(a.value)) <= (b.decimals === a.decimals ? 1e-9 : tol(a.decimals))) return true;249 if (a.percent && !b.percent && b.decimals > a.decimals && Math.abs(b.value) <= 1 && Math.abs(Math.abs(b.value) * 100 - a.value) <= tol(a.decimals)) return true;250 }251 return false;252}253254const COUNT_NOUN = "participants|observations|respondents|subjects|firms|patients|households|students|individuals|articles|documents|countries|schools|listings|transactions|trials|records|cases|users|papers|interviews|sites|firm-years|person-years";255const SAMPLE_STMT = new RegExp(`\\b(?:sample|total|dataset|data set|panel|corpus|cohort)\\b[^.;:]{0,60}?(?<![\\d.,])(\\d[\\d,]*)\\s+((?:[a-z]+-[a-z-]+\\s)?(?:${COUNT_NOUN}))\\b`, "gi");256const WHOLE_SAMPLE = /\b(?:full|final|total|overall|whole|entire|analytic(?:al)?|estimation) sample\b|\bin total\b|\ba total of\b|\bsample size\b/i;257const NAMED_STAT = /\b((?:response|attrition|retention|completion|dropout|participation|accuracy|error|turnover|default) rate|mean age|median age|average age)\s+(?:of|was|is|were|=|at)\s+(\d+(?:\.\d+)?\s?%?)/gi;258const PCT_FORMS = [259 /(?<![\d.,])(\d[\d,]*)\s+(?:out\s+)?of\s+(?:the\s+)?(\d[\d,]*)(?:\s+[A-Za-z-]+){0,3}\s*\(\s*(\d+(?:\.\d+)?)\s?%/g,260 /(?<![\d.,])(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s*[;,]\s*(\d+(?:\.\d+)?)\s?%/g,261];262const PCT_REVERSE = /(\d+(?:\.\d+)?)\s?%\s*\(\s*(?:[nN]\s*=\s*)?(\d[\d,]*)\s*(?:\/|of|out of)\s*(\d[\d,]*)\s*\)/g;263const toInt = (s) => parseInt(s.replace(/,/g, ""), 10);264265export function auditNumbers(doc, opts = {}) {266 const minInt = opts.min_integer ?? 10;267 const f = [];268 const top = Math.min(...doc.sections.filter((s) => !s.isTitle).map((s) => s.level), 9);269 const introIdx = new Set();270 doc.sections.forEach((s, k) => {271 if (s.level === top && /^(?:\d+\.?\s*)?introduction\b/i.test(s.title)) {272 introIdx.add(k);273 for (let q = k + 1; q < doc.sections.length && doc.sections[q].level > s.level; q++) introIdx.add(q);274 }275 });276 const corpus = [];277 for (let i = doc.bodyRange[0]; i < doc.bodyRange[1]; i++) {278 const l = doc.L[i];279 if (!l.body || l.abs || introIdx.has(l.sec) || !l.t || l.kind === "refs") continue;280 for (const n of extractNumbers(l.t)) if (!n.year) corpus.push(n);281 }282 const seen = new Set();283 let checked = 0;284 for (const s of doc.sentences) {285 const zone = s.para.abs ? "abstract" : introIdx.has(s.para.sec) ? "intro" : null;286 if (!zone) continue;287 const levelTalk = /\b(?:level|confidence|significan)/i.test(s.text);288 for (const n of extractNumbers(s.text)) {289 if (n.year || n.exhibit) continue;290 if (n.decimals === 0 && !n.percent && Math.abs(n.value) < minInt) continue;291 if (levelTalk && n.percent && [1, 5, 10, 90, 95, 99].includes(n.value)) continue;292 checked++;293 const key = `${zone}|${n.raw}`;294 if (seen.has(key) || supported(n, corpus)) continue;295 seen.add(key);296 const i = lineAt(s, n.pos);297 if (zone === "abstract") f.push(mk(doc, "num-abstract-not-in-body", "warning", i, `"${n.raw}" is stated in the abstract but no matching value appears in the body, tables or captions.`, { value: n.raw, heuristic: true }));298 else f.push(mk(doc, "num-intro-not-in-body", "info", i, `"${n.raw}" is stated in the introduction but no matching value appears later in the body, tables or captions.`, { value: n.raw, heuristic: true }));299 }300 }301 const groups = new Map();302 const add = (key, value, i, label) => { if (!groups.has(key)) groups.set(key, { label, items: [] }); groups.get(key).items.push({ value, i }); };303 for (const s of doc.sentences) {304 const ns = [...s.text.matchAll(/(?<![A-Za-z])N\s*=\s*(\d[\d,]*)(?![\d.])/g)];305 if (WHOLE_SAMPLE.test(s.text) && new Set(ns.map((m) => toInt(m[1]))).size === 1) add("N", toInt(ns[0][1]), lineAt(s, ns[0].index), "N");306 for (const m of s.text.matchAll(SAMPLE_STMT)) add(`count:${m[2].toLowerCase()}`, toInt(m[1]), lineAt(s, m.index + m[0].lastIndexOf(m[1])), m[2].toLowerCase());307 for (const m of s.text.matchAll(NAMED_STAT)) add(`stat:${m[1].toLowerCase()}`, m[2].replace(/\s/g, ""), lineAt(s, m.index), m[1].toLowerCase());308 for (const re of PCT_FORMS) for (const m of s.text.matchAll(re)) pct(doc, f, s, m, toInt(m[1]), toInt(m[2]), m[3]);309 for (const m of s.text.matchAll(PCT_REVERSE)) pct(doc, f, s, m, toInt(m[2]), toInt(m[3]), m[1]);310 }311 for (const [key, g] of groups) {312 const distinct = [...new Set(g.items.map((x) => String(x.value)))];313 if (distinct.length < 2) continue;314 const odd = g.items.find((x) => String(x.value) !== String(g.items[0].value));315 const values = g.items.map((x) => ({ value: x.value, ...where(doc, x.i) }));316 if (key.startsWith("stat:")) f.push(mk(doc, "num-inconsistent-statistic", "warning", odd.i, `The ${g.label} is given as ${distinct.join(" and ")} in different places.`, { values, heuristic: true }));317 else f.push(mk(doc, "num-inconsistent-count", "warning", odd.i, key === "N" ? `N is given as ${distinct.join(" and ")} in different places.` : `The sample is described with ${distinct.join(" and ")} ${g.label} in different places.`, { values, heuristic: true }));318 }319 return finish(doc, "audit_numbers", f, {320 stats: { abstract_and_intro_numbers_checked: checked, body_numbers: corpus.length },321 note: "A number in the abstract or introduction counts as supported when the body holds the same value, a more precise value that rounds to it, or the matching fraction for a percentage. Years, exhibit numbers and integers below the minimum are skipped. Sample sizes are compared only across sentences that speak of the whole sample (\"sample of\", \"a total of\", \"full sample\", N = ... with such wording). Subsamples can legitimately differ: confirm each inconsistency before reporting it.",322 });323}324325function pct(doc, f, s, m, a, b, stated) {326 if (!b || a > b) return;327 const dec = stated.includes(".") ? stated.split(".")[1].length : 0;328 const actual = (a / b) * 100;329 if (Math.abs(actual - parseFloat(stated)) <= tol(dec)) return;330 f.push(mk(doc, "num-percent-count-mismatch", "error", lineAt(s, m.index), `${a} of ${b} is ${actual.toFixed(Math.max(dec, 1))}%, not the stated ${stated}%.`, { numerator: a, denominator: b, stated: parseFloat(stated), computed: Number(actual.toFixed(4)) }));331}332333// ------------------------------------------------------ statistics reporting334335const PVAL = /(?<![A-Za-z])[pP]\s*(?:-\s*values?\s*)?(<=|>=|[<>=])\s*(0?\.\d+|0(?![\d.]))/g;336const TEST_A = /(?<![A-Za-z])(?:[tFzZrRdbBUWHQG]|chi2?|chi-squared?|\u03C7\s*\^?2?|beta|\u03B2|OR|HR|RR|IRR)\s*(?:\^?2)?\s*(?:\([^)]{0,24}\))?\s*=\s*-?[\d.]/;337const TEST_B = /coefficient|t-?tests?|t-?stat|z-?stat|z-?tests?|F-?stat|F-?tests?|ANOVA|regression|Wilcoxon|Mann|Fisher|log-?rank|Kruskal|McNemar|Welch|likelihood[- ]ratio|Wald|permutation|bootstrap|chi-?squared?|\btests?\b|correlation/i;338const EXHIBIT_REF = new RegExp(`${REF}|\\b(?:Tables?|Figures?|Figs?\\.|Panels?|Columns?|Appendix|Sections?)\\s*(?:\\d|[A-Z]\\b)`);339const SUPPORT_NUM = /(?<![A-Za-z])[pP]\s*(?:-\s*values?\s*)?(?:<=|>=|[<>=])|\bCI\b|confidence interval|=\s*-?[\d.]|\d\s?% level/;340const SIG = /\b(?:statistically\s+)?(?:in)?significant(?:ly)?\b(?!\s+(?:challenge|role|contribution|attention|body|progress|interest|amount|number|portion|share|part|implication|debate|literature|advance|step|gap|burden|barrier|concern|investment|resource|effort|time|risk|opportunit|limitation|potential|policy|practical|clinical|economic(?:ally)? importan))/i;341const OBSERVATIONAL = /\b(?:observational|cross-sectional|correlational|survey data|retrospective|secondary data|administrative data|associated with|association between)\b/i;342const DESIGN = /\b(?:randomi[sz]ed|random assignment|randomly assigned|experiment(?:al|s)?|instrumental variables?|difference-in-differences|regression discontinuity|natural experiment|quasi-experiment(?:al)?|RCT|event study|synthetic control|identification strategy)\b/i;343const CAUSAL = /\b(?:causes?|caused|causal (?:effect|impact)|leads? to|led to|results? in|resulted in|drives|is driven by|are driven by|the (?:effect|impact) of|(?:effect|impact) on)\b/i;344const HEDGED = /\b(?:may|might|could|suggests?|associated|cannot|not (?:imply|establish|interpret)|correlat|hypothesi[sz]e)/i;345const N_EVIDENCE = new RegExp(`(?<![A-Za-z])[Nn]\\s*=\\s*\\d|\\b\\d[\\d,]*\\s+(?:[a-z-]+\\s)?(?:${COUNT_NOUN})\\b|sample size|\\bObservations\\b`);346const STARS = /\d\)?\s*\$?\s*(?:\^\s*\{?\s*)?(?:\\?\*){1,3}(?![*\w])|\\sym\{\*+\}/;347const STAR_LEGEND = /\*+\s*[,:]?\s*\\?\(?\$?\s*p\s*(?:<|\\leq?|\\le\b|<=)|\*+[^\n]{0,50}(?:significan|percent|\\%|%|level)|significan[^\n]{0,80}\*/i;348const UNCERT_CELL = /[(\[]\s*-?\d*\.\d+\s*(?:[,;]\s*-?\d*\.\d+\s*)?[)\]]/;349const UNCERT_WORD = /[Ss]td\.?\s*[Ee]rr|[Ss]tandard errors?|\b[Ss]\.[Ee]\.|\bSEs?\b|[Cc]onfidence|\bCIs?\b|t-?stat|z-?stat|[Pp]-?values?/;350const COEF_HEAD = /coef|estimate|\\beta\b|\u03B2|odds ratio|hazard ratio|marginal effect|\b[Bb]eta\b|\bOR\b|\bHR\b/;351352export function auditStatistics(doc) {353 const f = [];354 let pCount = 0, firstP = -1, hasN = false, uncertainty = false;355 for (let i = doc.bodyRange[0]; i < doc.bodyRange[1]; i++) {356 const l = doc.L[i];357 if (!l.body || !l.t) continue;358 if (N_EVIDENCE.test(l.t)) hasN = true;359 if (UNCERT_WORD.test(l.t)) uncertainty = true;360 }361 const prose = doc.sentences.map((s) => s.text).join(" ");362 const observational = OBSERVATIONAL.test(prose) && !DESIGN.test(prose);363 for (const s of doc.sentences) {364 const ps = [...s.text.matchAll(PVAL)];365 const exhibit = EXHIBIT_REF.test(s.text);366 if (ps.length) {367 pCount += ps.length;368 const i = lineAt(s, ps[0].index);369 if (firstP < 0) firstP = i;370 if (!TEST_A.test(s.text) && !TEST_B.test(s.text) && !exhibit) f.push(mk(doc, "stat-p-without-test", "warning", i, `A p-value ("${ps[0][0].replace(/\s+/g, " ")}") is reported without naming the test or giving its statistic in the same sentence.`, { heuristic: true }));371 for (const p of ps) if (p[1] === "=" && /^0?\.?0*$/.test(p[2])) f.push(mk(doc, "stat-p-exact-zero", "warning", lineAt(s, p.index), `"${p[0].replace(/\s+/g, " ")}" reports a p-value of exactly zero. Report an inequality such as p < 0.001.`));372 }373 const sg = SIG.exec(s.text);374 if (sg && !ps.length && !SUPPORT_NUM.test(s.text) && !exhibit && !s.text.includes(CITE)) {375 f.push(mk(doc, "stat-significant-without-statistic", "warning", lineAt(s, sg.index), `"${sg[0]}" is claimed without a statistic, an interval, an exhibit reference or a citation in the same sentence.`, { heuristic: true }));376 }377 if (observational && !HEDGED.test(s.text)) {378 const c = CAUSAL.exec(s.text);379 if (c) f.push(mk(doc, "stat-causal-language", "info", lineAt(s, c.index), `Causal wording ("${c[0]}") in a manuscript that describes its data as observational and names no identification design.`, { heuristic: true }));380 }381 }382 if (pCount && !hasN) f.push(mk(doc, "stat-n-not-reported", "warning", firstP, "P-values are reported but no sample size (N = ..., a count of observations or participants) was found anywhere in the manuscript.", { heuristic: true }));383 const tables = doc.format === "latex"384 ? doc.tabulars.filter((t) => t.end != null).map((t) => ({ at: t.start, from: t.float ? t.float.start : t.start, to: t.float ? t.float.end : Math.min(doc.L.length - 1, t.end + 8), a: t.start, z: t.end }))385 : doc.mdTables.map((t) => ({ at: t.start, from: Math.max(0, t.start - 3), to: Math.min(doc.L.length - 1, t.end + 5), a: t.start, z: t.end }));386 for (const t of tables) {387 const inner = doc.L.slice(t.a, t.z + 1).map((l) => l.text).join("\n");388 const around = doc.L.slice(t.from, t.to + 1).map((l) => l.text).join(" ");389 const stars = STARS.test(inner);390 if (stars && !STAR_LEGEND.test(around)) f.push(mk(doc, "stat-stars-without-legend", "warning", t.at, "The table marks estimates with stars but no legend states the significance levels they stand for.", { heuristic: true }));391 if ((stars || COEF_HEAD.test(inner)) && (inner.match(/\d\.\d/g) || []).length >= 4 && !UNCERT_CELL.test(inner) && !UNCERT_WORD.test(around)) f.push(mk(doc, "stat-no-uncertainty", "warning", t.at, "The table reports estimates without standard errors, confidence intervals, t-statistics or p-values.", { heuristic: true }));392 }393 return finish(doc, "audit_statistics_reporting", f, {394 stats: { p_values: pCount, sample_size_stated: hasN, uncertainty_terms_present: uncertainty, observational_framing_without_design: observational, tables_checked: tables.length },395 note: "All checks here are heuristics on wording. A sentence can report a test correctly in a neighbouring sentence, and causal wording can be justified by a design this tool does not recognise.",396 });397}398399// ------------------------------------------------------------------- tables400401const NOTES = /\bNotes?\s*[:.]|\\begin\{tablenotes\}|\\tablefootnote|\\floatfoot|\\figurenote|\\tablenote|\bSources?\s*:/i;402const UNITS = /%|\\%|\$|\\\$|\u20AC|\u00A3|\b(?:USD|EUR|CAD|GBP|percent(?:age)?|per cent|points?|thousands?|millions?|billions?|years?|months?|weeks?|days?|hours?|minutes?|seconds?|kg|km|cm|mm|meters?|metres?|bps|basis|log(?:arithm)?|ratio|index|share|rate|counts?|number of|proportion|standardi[sz]ed|z-?scores?|units?|dollars?|euros?|coefficients?|standard errors?|odds|mean|median|std|frequency|N)\b/i;403404export function auditTables(doc) {405 const f = [];406 let ragged = 0;407 if (doc.format === "latex") {408 for (const fl of doc.floats) {409 const kind = fl.kind;410 const text = doc.L.slice(fl.start, fl.end + 1).map((l) => l.text).join("\n");411 if (!fl.closed) continue;412 if (!fl.captions.length) f.push(mk(doc, `${kind}-missing-caption`, "warning", fl.start, `This ${kind} has no caption.`));413 if (!fl.labels.length) f.push(mk(doc, `${kind}-missing-label`, "warning", fl.start, `This ${kind} has no label, so the text cannot refer to it.`));414 if (kind === "table" && fl.tabulars.length) {415 if (!NOTES.test(text)) f.push(mk(doc, "table-missing-notes", "info", fl.start, "The table has no notes (no \"Notes:\" or \"Source:\" text, no tablenotes environment). Notes should define the sample, the variables and any symbol.", { heuristic: true }));416 const numeric = fl.tabulars.some((t) => (t.rows ?? []).some((r) => /\d/.test(r.text)));417 if (numeric && !UNITS.test(text.replace(/\\(?:begin|end)\{[^}]*\}(?:\{[^}]*\})?|\\[a-zA-Z]+rule\b|\\label\{[^}]*\}/g, " "))) f.push(mk(doc, "table-missing-units", "info", fl.start, "No unit or scale (percent, currency, thousands, years, a named statistic) was found in the caption, the headers or the notes.", { heuristic: true }));418 }419 }420 for (const t of doc.tabulars) {421 if (t.end == null) { f.push(mk(doc, "table-unclosed", "error", t.start, "This tabular environment is never closed.")); continue; }422 if (!t.float) f.push(mk(doc, "table-outside-float", "info", t.start, "This tabular is not inside a table environment, so it has no caption or number."));423 if (t.nested) continue;424 if (t.cols == null) { f.push(mk(doc, "table-unparsed-spec", "info", t.start, "The column specification could not be read on the line that opens the tabular; rows were not checked.")); continue; }425 const widest = Math.max(0, ...t.rows.map((r) => r.cells));426 if (t.rows.length && widest < t.cols) f.push(mk(doc, "table-unused-columns", "info", t.start, `The tabular declares ${t.cols} columns ("${t.spec}") but no row has more than ${widest} cells.`, { columns: t.cols, widest }));427 const width = Math.min(t.cols, widest);428 for (const r of t.rows) {429 if (r.cells > t.cols) { ragged++; f.push(mk(doc, "table-ragged-row", "error", r.i, `This row has ${r.cells} cells but the tabular declares ${t.cols} columns ("${t.spec}"). LaTeX stops on an extra alignment tab.`, { cells: r.cells, columns: t.cols })); }430 else if (r.cells > 1 && r.cells < width) { ragged++; f.push(mk(doc, "table-short-row", "warning", r.i, `This row has ${r.cells} cells while the other rows of the tabular have ${width}. It compiles, but the last columns stay empty.`, { cells: r.cells, columns: width })); }431 }432 }433 } else {434 for (const t of doc.mdTables) {435 if (t.caption == null) f.push(mk(doc, "table-missing-caption", "info", t.start, "This table has no caption line (\"Table N: ...\" or \": ...\") next to it.", { heuristic: true }));436 for (const r of t.rows) if (r.cells !== t.cols) { ragged++; f.push(mk(doc, "table-ragged-row", "error", r.i, `This row has ${r.cells} cells but the header separator declares ${t.cols} columns.`, { cells: r.cells, columns: t.cols })); }437 }438 for (const g of doc.mdFigures) {439 const next = doc.L.slice(g.i + 1, g.i + 4).some((l) => l.kind === "caption");440 const prev = g.i > 0 && doc.L.slice(Math.max(0, g.i - 2), g.i).some((l) => l.kind === "caption");441 if (!g.alt && !next && !prev) f.push(mk(doc, "figure-missing-caption", "warning", g.i, "This image has no caption (empty alt text and no \"Figure N:\" line next to it)."));442 }443 }444 return finish(doc, "audit_tables", f, {445 stats: {446 tables: doc.format === "latex" ? doc.floats.filter((x) => x.kind === "table").length : doc.mdTables.length,447 figures: doc.format === "latex" ? doc.floats.filter((x) => x.kind === "figure").length : doc.mdFigures.length,448 tabulars: doc.format === "latex" ? doc.tabulars.length : doc.mdTables.length,449 rows_checked: (doc.format === "latex" ? doc.tabulars : doc.mdTables).reduce((a, t) => a + (t.rows?.length ?? 0), 0), ragged_rows: ragged,450 },451 note: "LaTeX rows are split on \\\\ and cells on & outside braces; \\multicolumn spans are counted and every letter of the column specification counts as one column. More cells than columns is an error; fewer is a warning; a row with a single cell (a panel title, a macro) is accepted. Tabulars nested in another tabular and rows built by macros are not checked. Markdown: pipe tables only.",452 });453}454455// ------------------------------------------------------------------- claims456457const PRIORITY = /\b(?:(?:are|is|as|be) the first|the first (?:to|study|paper|work|evidence|systematic|large-scale|comprehensive|attempt|analysis)|first-ever|first ever|for the first time|novel|unprecedented|never been (?:studied|examined|documented|shown|explored|tested|investigated)|no (?:prior|previous|existing|other) (?:study|studies|work|paper|papers|research|literature)|state-of-the-art|groundbreaking|pioneering)\b/i;458const ABSOLUTE = /\b(?:always|never|guarantees?|in (?:all|every) cases?|without exception)\b/i;459const CERTAINTY = /\b(?:proves?|proven|proved|undoubtedly|unquestionably|indisputabl[ey]|definitively|conclusively|without (?:a |any )?doubt|beyond (?:a |any )?doubt|clearly (?:shows?|demonstrates?|establish(?:es)?)|irrefutabl[ey])\b/i;460const ROBUST = /\b(?:robust(?:ly|ness)?|holds? (?:across|in|for) all)\b/i;461const CLAIM_SUPPORT = new RegExp(`${CITE}|${REF}|\\b(?:Tables?|Figures?|Figs?\\.|Sections?|Appendix|Theorems?|Propositions?|Lemmas?|Panels?|Equations?|Eqs?\\.|Columns?)\\s*(?:\\d|[A-Z]\\b)`);462const NEGATED = /(?:\bnot|n't|\brarely|\bhardly|\bseldom)\s+(?:\w+\s+)?$/i;463464export function auditClaims(doc) {465 const f = [];466 let scanned = 0;467 // In a paper with theorems and proofs, "we prove" is a description, not an overclaim.468 const formal = doc.L.some((l) => l.body && /\\begin\{(?:theorem|lemma|proof|proposition|corollary)\}|\b(?:Theorem|Lemma|Proof)\b/.test(l.text ?? ""));469 for (const s of doc.sentences) {470 scanned++;471 if (CLAIM_SUPPORT.test(s.text)) continue;472 let rest = s.text;473 const p = PRIORITY.exec(rest);474 if (p) {475 f.push(mk(doc, "claim-priority-unsupported", "warning", lineAt(s, p.index), `Priority or novelty claim ("${p[0]}") with no citation and no exhibit reference in the sentence. Such claims need the literature that was searched.`, { term: p[0].toLowerCase(), heuristic: true }));476 rest = rest.slice(0, p.index) + " ".repeat(p[0].length) + rest.slice(p.index + p[0].length);477 }478 const c = CERTAINTY.exec(rest);479 if (c && !NEGATED.test(rest.slice(0, c.index)) && !(formal && /^prov/i.test(c[0]))) f.push(mk(doc, "claim-certainty-unsupported", "warning", lineAt(s, c.index), `Absolute wording ("${c[0]}") with no citation and no exhibit reference in the sentence.`, { term: c[0].toLowerCase(), heuristic: true }));480 const ab = ABSOLUTE.exec(rest);481 if (ab && !NEGATED.test(rest.slice(0, ab.index))) f.push(mk(doc, "claim-absolute-wording", "info", lineAt(s, ab.index), `Absolute wording ("${ab[0]}") with no citation and no exhibit reference in the sentence. Check that no exception exists.`, { term: ab[0].toLowerCase(), heuristic: true }));482 const r = ROBUST.exec(rest);483 if (r && !NEGATED.test(rest.slice(0, r.index))) f.push(mk(doc, "claim-robustness-unsupported", "info", lineAt(s, r.index), `Robustness is asserted ("${r[0]}") without pointing to the table, figure or section that shows it.`, { term: r[0].toLowerCase(), heuristic: true }));484 }485 return finish(doc, "audit_claims", f, {486 stats: { sentences_scanned: scanned },487 note: "Support is looked for in the same sentence only. A claim backed in the next sentence is still listed: check before reporting it.",488 });489}490491// -------------------------------------------------------------------- style492493const TODO_STRICT = /\b(?:TODO|FIXME|XXX|TBD|TKTK)\b|\\todo(?![a-zA-Z])|\\fixme(?![a-zA-Z])|\[(?:citation needed|CITE|REF|cite|ref|\?+)\]|\?\?\?|\(\?\)/;494const PLACEHOLDER = /lorem ipsum|dolor sit amet|\\lipsum|\\blindtext|\binsert [^.]{0,40} here\b|\byour (?:title|name|abstract|text) here\b|\bX+\.X+\b|\[placeholder\]|\bAuthor Name\b|\bplaceholder (?:text|figure|table)\b/i;495const KNOWN = new Set(["US", "USA", "UK", "EU", "UN", "OK", "PDF", "URL", "DOI", "ISBN", "ISSN", "ID", "SE", "SD", "CI", "AM", "PM", "TV", "USD", "EUR", "CAD", "GBP", "PHD", "MBA", "CEO", "II", "III", "IV", "VI", "VII", "VIII", "IX", "XI", "XII", "TODO", "FIXME", "XXX", "TBD", "TKTK"]);496const HEDGES = /\b(?:may|might|could|possibly|perhaps|appears? to|seems? to|suggests?|likely|arguably|somewhat|potentially|presumably|tends? to|to some extent|relatively|apparently|we believe|it is possible)\b/gi;497const STEERING = /\b(?:give|write|provide|produce)\s+(?:only\s+)?(?:a\s+)?(?:positive|favou?rable|glowing)\s+(?:review|report|assessment)\b|\bas an? (?:AI|artificial intelligence|large) language model\b|\b(?:LLM|AI|automated) reviewers?\b[^.]{0,80}\b(?:must|should|shall|recommend)\b|\brecommend(?:ing)? accept(?:ance|ing)?\b[^.]{0,60}\bthis (?:paper|manuscript|submission)\b|\bdo not (?:highlight|mention|report|list) any (?:negatives?|weakness(?:es)?|flaws?|limitations?)\b|\bregenerate response\b/i;498const HIDDEN = /\\(?:textcolor|color)\s*(?:\[[^\]]*\])?\{\s*white\s*\}|\\fontsize\s*\{\s*0*(?:\.\d+|[01](?:\.\d+)?)\s*(?:pt)?\s*\}|color\s*:\s*(?:white|#fff(?:fff)?)\b|font-size\s*:\s*0*[01](?:\.\d+)?\s*(?:px|pt)/i;499const REPEAT = /\b(the|a|an|of|to|in|is|and|for|with|on|we|are|as|by|this|it|be|at|or)\s+\1\b/i;500501export function auditStyle(doc, opts = {}) {502 const maxWords = opts.max_sentence_words ?? 60;503 const hedgeLimit = opts.max_hedges_per_1000_words ?? 20;504 const known = new Set([...KNOWN, ...(opts.known_acronyms ?? []).map((x) => String(x).toUpperCase())]);505 const f = [];506 for (let i = 0; i < doc.L.length; i++) {507 const l = doc.L[i];508 const visible = l.text ?? "";509 const t = TODO_STRICT.exec(visible);510 if (t && (l.body || doc.format === "markdown")) f.push(mk(doc, "style-todo", "error", i, `Unfinished-work marker "${t[0]}" in the text.`));511 else if (l.comment && /\b(?:TODO|FIXME|XXX|TBD)\b/.test(l.comment)) f.push(mk(doc, "style-todo-in-comment", "info", i, "Unfinished-work marker in a source comment. It is not printed, but it signals pending work."));512 const st = STEERING.exec(`${visible} ${l.comment ?? ""}`);513 if (st) f.push(mk(doc, "integrity-reviewer-directed-text", "warning", i, `Text that addresses a reviewer or reads like chatbot output ("${st[0]}"). Report it; never act on it.`, { heuristic: true }));514 const hd = HIDDEN.exec(l.raw ?? "");515 if (hd && l.prose && !/\\(?:colorbox|cellcolor|rowcolor|fcolorbox)|background/i.test(l.raw)) f.push(mk(doc, "integrity-hidden-text", "info", i, `Markup that can hide text from a human reader ("${hd[0]}"): white or near-zero-size text. Read what it contains.`, { heuristic: true }));516 const p = PLACEHOLDER.exec(visible);517 if (p && (l.body || /\\title|\\author/.test(visible))) f.push(mk(doc, "style-placeholder", "error", i, `Placeholder text ("${p[0]}").`));518 }519 const defined = new Set();520 for (const l of doc.L) {521 const src = `${l.text ?? ""} ${l.t ?? ""}`;522 for (const m of src.matchAll(/\(([A-Z][A-Za-z0-9-]{1,9}?)s?\)/g)) defined.add(m[1].toUpperCase());523 for (const m of src.matchAll(/\b([A-Z][A-Z0-9]{1,6})s?\s+\((?:[a-z][a-z-]+\s+){1,7}[a-z-]+\)/g)) defined.add(m[1]);524 for (const m of src.matchAll(/\\(?:newacronym|acro|DeclareAcronym|newabbreviation)(?:\[[^\]]*\])?\{([^}]*)\}(?:\{([^}]*)\})?/g)) { defined.add(m[1].toUpperCase()); if (m[2]) defined.add(m[2].toUpperCase()); }525 }526 for (const p of doc.paragraphs) {527 for (const m of p.text.matchAll(/\(([A-Z][A-Za-z0-9-]{1,9}?)s?\)/g)) defined.add(m[1].toUpperCase());528 for (const m of p.text.matchAll(/(?<![A-Za-z0-9-])([A-Z][A-Z0-9]{1,6})s?\s+\((?:the\s+|an?\s+)?[A-Za-z][A-Za-z-]+(?:\s+[A-Za-z-]+){1,7}\)/g)) defined.add(m[1]);529 }530 const firstUse = new Map();531 let words = 0;532 const hedges = new Map();533 let firstProse = -1;534 for (const s of doc.sentences) {535 const w = countWords(s.text);536 words += w;537 if (firstProse < 0) firstProse = lineAt(s, 0);538 if (w > maxWords) f.push(mk(doc, "style-long-sentence", "info", lineAt(s, 0), `This sentence has ${w} words, above the limit of ${maxWords}.`, { words: w, limit: maxWords, heuristic: true }));539 for (const m of s.text.matchAll(HEDGES)) hedges.set(m[0].toLowerCase(), (hedges.get(m[0].toLowerCase()) ?? 0) + 1);540 const rp = REPEAT.exec(s.text);541 if (rp) f.push(mk(doc, "style-repeated-word", "warning", lineAt(s, rp.index), `Repeated word: "${rp[0]}".`));542 if (s.text === s.text.toUpperCase()) continue;543 for (const m of s.text.matchAll(/(?<![A-Za-z0-9\u27E6-])([A-Z][A-Z0-9]{1,5})s?(?![A-Za-z0-9-])/g)) {544 const a = m[1];545 if ((a.match(/[A-Z]/g) || []).length < 2 || known.has(a) || /^[IVX]+$/.test(a)) continue;546 if (!firstUse.has(a)) firstUse.set(a, { i: lineAt(s, m.index), n: 0 });547 firstUse.get(a).n++;548 }549 }550 for (const [a, u] of firstUse) if (!defined.has(a)) f.push(mk(doc, "style-undefined-acronym", "info", u.i, `"${a}" is used ${u.n} time${u.n > 1 ? "s" : ""} but never spelled out with the short form in parentheses.`, { acronym: a, occurrences: u.n, heuristic: true }));551 const hedgeCount = [...hedges.values()].reduce((a, b) => a + b, 0);552 const density = words ? (hedgeCount / words) * 1000 : 0;553 if (words >= 200 && density > hedgeLimit) f.push(mk(doc, "style-hedging-density", "info", firstProse, `${hedgeCount} hedging terms in ${words} words (${density.toFixed(1)} per 1,000, above ${hedgeLimit}).`, { per_1000_words: Number(density.toFixed(1)), top: [...hedges].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([term, n]) => ({ term, n })), heuristic: true }));554 return finish(doc, "audit_style", f, {555 stats: { sentences: doc.sentences.length, words, hedging_terms: hedgeCount, hedges_per_1000_words: Number(density.toFixed(1)), acronyms_seen: firstUse.size, acronyms_defined: defined.size },556 note: "An acronym counts as defined when its short form appears in parentheses, or is followed by its long form in parentheses, anywhere in the manuscript. Common abbreviations (US, UK, SE, CI, SD ...) and short Roman numerals are ignored.",557 });558}559560// -------------------------------------------------------------- replication561562const DATA_STMT = /data (?:availability|access)|\b(?:data|dataset|datasets)\b[^.]{0,60}\b(?:are|is|will be)\b[^.]{0,20}\bavailable\b|(?:data|dataset|datasets|data set) (?:are|is|will be|used in this (?:paper|study) (?:are|is)) (?:publicly |freely |openly |made )?available|available (?:from|at|on|upon|in) (?:the (?:corresponding )?authors?|request|reasonable request|https?|zenodo|osf|github|dataverse|figshare|icpsr)|replication (?:package|files|materials|archive|data)|proprietary|data (?:were|was) obtained from|restricted[- ]access|cannot be shared|confidential data/i;563const CODE_STMT = /code (?:availability|and data)|(?:source )?code[^.]{0,60}(?:github|gitlab|zenodo|osf|repository|available|archive)|replication (?:package|code|files|scripts|materials|archive)|scripts? (?:are|is|will be) (?:publicly )?available|analysis (?:code|scripts)/i;564const SOFTWARE = /\b(Stata|Python|MATLAB|SAS|SPSS|Julia|EViews|Mplus|JASP|RStudio|NumPy|PyTorch|TensorFlow|scikit-learn|statsmodels)\b|(?<![A-Za-z.])R(?=\s+(?:version|package|software|\(|v?\d))/;565const VERSION = /\b(?:version|v\.?)\s*\d+(?:\.\d+)*|\b(?:Stata|Python|MATLAB|SAS|SPSS|Julia|EViews|R)\s+\d+(?:\.\d+)*/i;566const STOCHASTIC = /\b(?:bootstrap(?:ped|ping)?|simulat(?:ion|ions|ed)|monte carlo|random(?:ly)? (?:split|sampl\w*|initiali\w*|forest|search|draws?)|cross-validation|stochastic|MCMC|permutation test|random seed)\b/i;567const TRIAL = /\b(?:randomi[sz]ed|random assignment|randomly assigned|experiment(?:al)? (?:design|condition|group)|clinical trial|treatment group|control group)\b/i;568569export function auditReplication(doc) {570 const f = [];571 let data = false, code = false, software = false, version = false, seed = false, prereg = false;572 let stochAt = -1, trialAt = -1, swAt = -1, last = doc.bodyRange[0];573 let empirical = doc.tabulars.length > 0 || doc.mdTables.length > 0;574 for (let i = doc.bodyRange[0]; i < doc.bodyRange[1]; i++) {575 const l = doc.L[i];576 if (!l.body || !l.t || l.kind === "refs") continue;577 last = i;578 const t = l.t;579 if (DATA_STMT.test(t)) data = true;580 if (CODE_STMT.test(t)) code = true;581 if (SOFTWARE.test(t)) { software = true; if (swAt < 0) swAt = i; if (VERSION.test(t)) version = true; }582 if (/\bseeds?\b/i.test(t)) seed = true;583 if (/pre-?regist/i.test(t)) prereg = true;584 if (stochAt < 0 && STOCHASTIC.test(t)) stochAt = i;585 if (trialAt < 0 && TRIAL.test(t)) trialAt = i;586 if (/(?<![A-Za-z])[pP]\s*[<=>]\s*0?\.\d|\bregression|\bestimat/i.test(t)) empirical = true;587 if (/available (?:up)?on (?:reasonable )?request|from the (?:corresponding )?authors? (?:up)?on request/i.test(t)) f.push(mk(doc, "repl-available-on-request", "info", i, "Materials are offered \"on request\". Requests of this kind are often not honoured: a public archive with a persistent identifier is verifiable.", { heuristic: true }));588 }589 const anchor = doc.bodyRange[0];590 const sev = empirical ? "warning" : "info";591 if (!data) f.push(mk(doc, "repl-no-data-statement", sev, anchor, "No statement says where the data come from or how a reader can obtain them (data availability, source, access restrictions).", { scope: "document", heuristic: true }));592 if (!code) f.push(mk(doc, "repl-no-code-statement", sev, anchor, "No statement says whether the analysis code or a replication package is available.", { scope: "document", heuristic: true }));593 if (empirical && !software) f.push(mk(doc, "repl-no-software", "info", anchor, "No statistical software or programming language is named.", { scope: "document", heuristic: true }));594 else if (software && !version) f.push(mk(doc, "repl-software-without-version", "info", swAt, "Software is named without a version number.", { heuristic: true }));595 if (stochAt >= 0 && !seed) f.push(mk(doc, "repl-no-seed", "info", stochAt, "The analysis uses random draws (bootstrap, simulation, random splits ...) but no seed is mentioned, so results cannot be reproduced exactly.", { heuristic: true }));596 if (trialAt >= 0 && !prereg) f.push(mk(doc, "repl-no-preregistration", "info", trialAt, "An experimental design is described but no preregistration or trial registration is mentioned.", { heuristic: true }));597 return finish(doc, "audit_replication", f, {598 stats: { data_statement: data, code_statement: code, software_named: software, software_version: version, seed_mentioned: seed, preregistration_mentioned: prereg, empirical },599 note: "This looks for statements, not for the materials. It does not open any link and cannot tell whether an archive exists or reproduces the results.",600 });601}602