SPB Git

Repository Public Research preview

audits.mjs 49.7 KB 602 lines javascript

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
Files main
Menu

Theme