Claude Skill

sync-submission

Audit SSOT-to-submission drift and create journal submission manifests from canonical manuscript artifacts.

LLM Mart · 0 points · 2 views 2 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download aperivue-medsci-skills-skills_sync-submission-815765c.zip · 170 KB
Part of aperivue/medsci-skills — 47 skills

Install

skills CLI npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/sync-submission
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
Git git clone https://github.com/Aperivue/medsci-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aperivue/medsci-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Sync Submission

You help keep the canonical manuscript and journal-specific submission packages from drifting apart. The skill treats submission/{journal}/ as derived output and records whether it is current, stale, or frozen.

When to Use

  • Before submitting a journal package.
  • After a journal portal or Word editor changed a submission manuscript.
  • After rejection, before retargeting to another journal.
  • Before /orchestrate --e2e marks a project as submission-ready.

Inputs

  1. Project root containing project.yaml, or a direct canonical manuscript path.
  2. Journal short name, e.g. chest, ryai, academic_radiology.
  3. Optional mode:
    • audit: compare existing submission against canonical source.
    • build: copy canonical source and optional declared final artifacts, preserving file bytes, and write metadata.
    • freeze: freeze the chosen byte snapshot with its available check context (not submission approval).

Deterministic Script

python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" audit --project-root . --journal chest
python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" build --project-root . --journal chest
python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" freeze --project-root . --journal chest --status submitted

For double-blind journals, sweep author identifiers across all upload artifacts:

python "${CLAUDE_SKILL_DIR}/scripts/blind_sweep.py" \
  --registry _shared/authors/author_registry.yaml \
  --files submission/{journal}/supplementary/*.md submission/{journal}/cover_letter.md \
  --backup-dir .cache/blind_sweep_backup

The registry is a project-local YAML mapping author identifiers (full names, native scripts, initials with/without periods, email, ORCID) to role labels (e.g., "Reviewer 1"). See scripts/author_registry_example.yaml for schema. Never commit a populated registry to a public repository — keep it next to the manuscript.

Output Contract

Artifact Path Purpose
Submission metadata submission/{journal}/.journal_meta.json Source hash, status, canonical path
Sync audit qc/submission_sync_{journal}.json Drift result consumed by orchestrator
Manifest update artifact_manifest.json Submission package registry
Pre-flight gate qc/preflight_gate_report.json Aggregated halt-on-failure manifest (see "Pre-flight gate" below)
Supplement structure qc/supplement_structure.json Gate 14: index↔file 1:1, sub-section gaps, callout coverage

For a complete bundle, use build --bundle-spec bundle.json after running the existing renderers. The declaration adds final Word/PDF, supplement, cover-letter, table/figure and notice files with pinned render-input hashes and reuse-rights records. Copies preserve file bytes; content and visual fidelity remain not_assessed until separately reviewed. Build refuses edited or frozen outputs and destructive path collisions. See bundle workflow for the schema, a runnable synthetic example and the limits of each recorded check.

Pre-flight gate (single command — last step before freeze)

Run this once, right before freeze/submission. It orchestrates the existing deterministic checks and the /verify-refs audit into one halt-on-failure gate, writes a single aggregated manifest (qc/preflight_gate_report.json), and exits non-zero so a build wrapper or CI step can stop the freeze. It shells out to the per-check scripts and reimplements none of them — the halt decision is driven by each sub-check's normalized exit code.

The report distinguishes executed, skipped and errored checks. Its legacy submission_safe field means no configured blocker/error, not submission approval; readiness remains not_assessed. The optional bundle hash binding identifies the package present during the run, not per-file visual inspection or all external check inputs. Run audit again after preflight to expose current, stale or unbound evidence. Freeze records a byte snapshot and its available check context; it does not run preflight or approve source fidelity or reuse permissions.

python "${CLAUDE_SKILL_DIR}/scripts/preflight_gate.py" --project-root . --journal chest
# add --strict to also halt on the heuristic/conditional (P1) checks
# add --online to make fabricated / author-mismatched references halt (PubMed/CrossRef)
# add --double-blind to make the asset-anonymization scan halt

By default the gate halts only on the unambiguous, deterministic errors (P0): leftover placeholder/markers (check_placeholders.py), undefined [@key] citations (check_citation_keys.py), duplicate references (verify_refs.py, offline-deterministic), a canonical-vs-submission hash mismatch (sync_submission.py audit), and an internal-audit dump leaked into a reviewer-facing file (check_checklist_dump_leak.py — see below). The heuristic or conditional checks — check_xref, detect_copy_divergence, scope_drift_check, cover_letter_drift_check, cross_document_n_check, check_cross_artifact_stale — run and report as P1 warn but do not halt unless promoted with --strict or --require ID; check_asset_anonymization is P1 unless --double-blind. A check whose inputs are absent (no rendered docx, no cover letter, no copies, no journal) is recorded skipped, never a blocker. Exit codes: 0 clean, 1 halt (≥1 blocker), 2 gate config error (e.g. a --require'd check could not run).

The gate's offline references pass is the deterministic subset (duplicates + pagination placeholders); an online /verify-refs --strict against PubMed/CrossRef remains the authoritative fabrication and author-name check before submission.

Audit-dump leak check (P0). A /check-reporting or /self-review report is an internal working audit — it carries auto-fix annotations, a raw JSON block (compliance_pct, fixable_by_ai, check_reporting_version), pipeline-log paths, and "Action Items". It is NOT the official reporting checklist a journal expects, and must never reach a reviewer. A near-miss: a prior project's STROBE_checklist_v4.pdf was actually this dump, reused by filename into a later submission and compiled into the reviewer-visible proof. scripts/check_checklist_dump_leak.py --dir submission/ scans every .md/.docx/.pdf in the package for these tokens; any hit is a P0 leak. Run it (the pre-flight gate already does, over the journal asset directory) before freeze and confirm submission_safe: true. Writes qc/checklist_dump_leak.json.

Disclosure & availability check (standalone). Top medical-AI journals require, before review, an AI-use disclosure carrying four tokens (version + access channel + date/date-range + responsible party — the tool name only triggers the check) and Data/Code Availability statements. Run python3 ${CLAUDE_SKILL_DIR}/scripts/check_disclosure_availability.py --manuscript <file> --journal <stem> [--ai-study] [--require data_availability ...] [--strict] (reads references/journal_availability_policy.json). It blocks on a missing required statement or an AI disclosure that is present but missing a token / carrying a placeholder; "available on reasonable request" where the journal expects a repository is a P1 warning. Writes qc/disclosure_availability_report.json.

Workflow

  1. Resolve canonical manuscript from project.yaml or explicit input.
  2. Run the script in the requested mode.
  3. If audit reports DRIFT, do not retarget or freeze until the user either patches the canonical manuscript or records the difference as journal-only.
  4. If build succeeds, run /verify-refs before final submission.

Quality Gates

  • Gate 0 (pre-flight, last step before freeze): run scripts/preflight_gate.py --project-root . --journal {journal} to aggregate the deterministic checks below into one halt-on-failure manifest (qc/preflight_gate_report.json). Non-zero exit blocks the freeze. See "Pre-flight gate" above for the P0/P1 tiering and flags. This orchestrates Gates 1–3, 5b, 8, 9, 11 plus the placeholder and citation-key checks; the individual gates remain runnable on their own.

  • Gate 1: block freezing when canonical manuscript is missing.

  • Gate 2: block retargeting when the previous submission has unresolved drift.

  • Gate 3: require /verify-refs audit before marking a package submission-safe.

  • Gate 4: docx audits must use a recursive walk (paragraphs + tables + nested-table cells); a flat document.paragraphs scan is insufficient.

  • Gate 5: before freeze, confirm portal free-text fields (cover letter, data availability, acknowledgements, abstract, author contributions) match the manuscript body.

  • Gate 5c (portal-field markdown residue): portal paste-verbatim .txt fields (abstract.txt, keywords.txt, …) are cut from the markdown but never stripped of it, so a trailing ---, a **bold**, or a cm^2^ superscript pastes into — and publishes in — the field literally. The pre-flight gate runs scripts/check_portal_field_residue.py --dir portal_fields/ (P1, --strict-promotable) over portal_fields/; only .txt is scanned (a .md is meant to carry markdown), and the emphasis/super/sub patterns require paired markers so significance stars and approximation tildes do not fire. It also carries a Minor char_expansion advisory: ≥/≤ in a paste-verbatim field are verbose-expanded by ScholarOne to "" (five words), inflating the word count — pre-substitute >=/<= (only ≥/≤; × and the en-dash paste cleanly).

  • Gate 5d (figure portal readiness): a figure bounces at the upload button for reasons decidable from the file on disk — a byte size (JACC: Asia caps a figure at 25 MB) and an extension (SNAPP accepts only .tiff/.jpeg/.eps, rejecting .png). The pre-flight gate runs scripts/figure_portal_readiness_check.py --figures-dir <dir> (P1) over submission/<journal>/figures (or ./figures), emitting FIGURE_OVERSIZE and — when the portal's formats are supplied via --figure-accept tiff jpeg eps — FIGURE_FORMAT_REJECTED. Fix by regenerating with /make-figures export_portal_tiff.py (LZW + RGBA→RGB flatten). The check is skipped (never an error) when there is no figures directory.

  • Gate 6 (double-blind journals): before freeze, export the portal's blinded review PDF and grep for all author identifiers across the entire upload set — manuscript, supplementary, cover letter, registry record PDFs (PROSPERO/ClinicalTrials), portal Letter-field text. A clean manuscript blind does not imply a clean portal blind.

  • Gate 7 (text-only docx rebuilds): never use pandoc --reference-doc=manuscript.docx for response/cover/supplementary text-only docx — the reference docx ships its embedded media (figure files) into the new docx, bloating size 50–100×. Use plain pandoc input.md -o output.docx for text-only artifacts.

  • Gate 5b (Phase 4 cover-letter free-text drift): before freeze, run scripts/cover_letter_drift_check.py to verify the cover letter's word-count / reference-count / table-figure-count claims still match the manuscript. Cover letters routinely go stale across v_N → v_(N+1) branching and are not covered by any docx-level audit. See "Phase 4 — Cover-letter free-text drift" below.

  • Gate 8 (Phase 5 cross-document N consistency): before freeze, run scripts/cross_document_n_check.py over the manuscript bundle (abstract, body, PROSPERO record, cover letter, supplementary, INDEX, PRISMA flow caption). Any N category with >1 distinct integer value is a P0 drift. When a FINAL_POOL_LOCK.yaml is present, supply --pool-lock to make the locked counts the authoritative baseline. See "Phase 5 — Cross-document N consistency" below.

  • Gate 9 (Phase 6 intra-manuscript scope drift): run scripts/scope_drift_check.py against the manuscript (and optionally the PROSPERO record). Numeric anchors (AUC, OR/HR/RR, sensitivity/specificity) appearing in Limitations / Discussion but absent from Methods + Results are P0 SCOPE_DRIFT. PROSPERO ↔ Methods synthesis-method disagreement is a P0 PROSPERO_DRIFT.

  • Gate 10 (Phase 7 v_(N+1) docx regeneration): when building a new submission from a frozen prior version, run scripts/verify_package_integrity.py --assert-vN-docx-changed --vN-docx <prev>.docx --new-docx <next>.docx. Identical MD5 = unmodified seed copy = block submission. Defense-in-depth — required even when the upstream pipeline appears to have regenerated the docx.

  • Gate 11 (Phase 8 multi-copy divergence): when the project hand-maintains more than one manuscript copy (working SSOT, circulation, portal), run scripts/detect_copy_divergence.py --ssot <ssot>.md --copy <copy>.md ... before freeze or circulation. Any STALE_COPY (an SSOT numeric claim or heading that did not propagate to a copy) is a P0 drift. See "Phase 8 — Multi-copy manuscript divergence" below.

  • Gate 11b (reframe / headline-change survivor scan): after a revision that reframes a claim class (e.g. retires "location-stratified benchmark" for "overall pooled") or changes a headline number, a stale copy commonly survives in an un-touched body paragraph, a figure/table legend, the supplement, or the response letter — the response letter often claims the change was applied "throughout" while a sidecar still carries the old term/value. Pass the retired vocabulary and superseded values from the reframe diff to the cross-artifact gate, which scans the body and every aux artifact:

    python3 "${CLAUDE_SKILL_DIR}/scripts/check_cross_artifact_stale.py" \
        --manuscript manuscript.md --aux supplement/ --aux figures/legends.md --aux revision/response_to_reviewers.md \
        --retired-term "location-stratified benchmark" --old-value 1.72
    

    A retired_framing_survivor / stale_old_value finding is a P1 stale claim-site; this automates the claim-site grep of manuscript-versioning.md §6.1 across all artifacts rather than a sample. (Numeric survivors are digit-bounded, so 1.72 never matches 11.723.)

  • Gate 12 (target-journal metadata drift): on build / retarget, cross-check the target the manuscript is written for against the target the project is being submitted to. Compare project.yaml target (and any in-manuscript header/footer "for submission to X" string) against the journal the package is built for, and check the structural metadata the target dictates — abstract heading structure (4- vs 5-heading), body word limit, citation style (Vancouver / AMA), required elements (Highlights / Central Illustration / Key Points). A mismatch (e.g., a header still reading the previous journal after a cascade retarget, or a 4-heading abstract for a 5-heading target) is a target-restructure trigger — branch to v_(N+1) per manuscript-versioning.md §2 and sync every sidecar (cover letter, title page, ICMJE COI list) — not a silent build.

    # header target vs project.yaml target
    TGT=$(python3 -c "import yaml;print(yaml.safe_load(open('project.yaml')).get('target',''))" 2>/dev/null)
    grep -niE 'for submission to|submitted to|prepared for' manuscript/manuscript.md   # compare against "$TGT"
    
  • Gate 13 (body word count vs journal cap — the revision-inflation trap): resolving reviewer majors monotonically adds words, so a revised body silently breaches the target journal's limit. Before freeze (and after every /revise pass), run scripts/check_wordcount_cap.py against the target journal profile's body cap. WORDCOUNT_OVER_CAP is a P0 (relocate methods/sensitivity detail to the Supplement); WORDCOUNT_NEAR_CAP (>0.95×) warns that the next pass will breach. The binding number is the rendered count (citeproc expands [@key] → "(Author Year)"), so prefer the built DOCX count with --rendered-words N; otherwise the script estimates it from the markdown body + inline-citation expansion.

    python3 "${CLAUDE_SKILL_DIR}/scripts/check_wordcount_cap.py" \
      --manuscript manuscript/manuscript.md \
      --journal-profile "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/find-journal/references/journal_profiles/<Journal>.md" \
      --article-type "Original Article" --out qc/wordcount_cap.json --strict
    # or, deterministic: --limit 4000   (and --rendered-words N from the built DOCX when available)
    
  • Gate 14 (supplement structure — the numbering lock): a cohort/SR supplement is a directory of S{N}_*.md sections plus an index, hand-concatenated into _combined.md. Across revision rounds that set desynchronizes silently: an index row with no file, a file the index never lists, two files claiming the same S{N}, or a sub-section gap after an insert (S6.3 then S6.5). A reviewer opening "Supplementary Table S9" and finding the wrong content is the failure mode. Before freeze, run scripts/assemble_supplement.py to validate index↔file 1:1, rebuild _combined.md in index order (so the assembly is reproducible rather than hand-maintained), and — with --manuscript — report callout coverage: body callouts with no section file (CALLOUT_WITHOUT_SECTION) and section files the body never cites (SECTION_UNCITED). The four structural kinds are P0 under --strict; coverage findings are advisory.

    python3 "${CLAUDE_SKILL_DIR}/scripts/assemble_supplement.py" \
      --dir submission/{journal}/supplementary --index 00_index.md \
      --manuscript manuscript/manuscript.md \
      --out submission/{journal}/supplementary/_combined.md \
      --json qc/supplement_structure.json --strict
    

Phase 3b — Portal fields that REPLACE the manuscript

Some portals publish the box, not the paper. SNAPP prints it on the form itself, at Author Contributions, Competing Interests, Data Availability and Acknowledgements:

"This replaces any statement written within the manuscript and is the one that we will publish."

So the manuscript file is the copy reviewers read and the portal box is the copy the world gets. A declaration that lives only in the manuscript is not a harmless duplicate — it will not exist in the published record, and nothing warns you, because neither document is wrong on its own. Two sentences that came one click from vanishing this way:

  • Co-first authorship. A † footnote on the title page. There is no equal-contribution checkbox on the author page — unless "X and Y contributed equally to this work" is typed into the Author Contributions box, the published paper has no co-first authors.
  • "The funder had no role in study design…" It lived in the manuscript's Acknowledgements. The structured Research funding field takes a funder and a grant ID and has nowhere to put a role disclaimer, so pasting only an AI-use note into the Acknowledgements box drops it.

Do not hand-compose the boxes. Generate them from the manuscript, then check:

SS="${CLAUDE_SKILL_DIR}/scripts"
# scaffold every replacing field straight from the manuscript (lifts the equal-contribution
# sentence in from the title page, which is the one place --emit cannot copy it from)
python3 "$SS/check_portal_mirror.py" --manuscript manuscript/manuscript.md \
  --profile "<...>/journal_profiles/npj_Digital_Medicine.md" --emit portal_fields/

# then verify nothing was lost on the way to the box
python3 "$SS/check_portal_mirror.py" --manuscript manuscript/manuscript.md \
  --portal-dir portal_fields/ --profile "<...>/npj_Digital_Medicine.md" \
  --out qc/portal_mirror.json
Verdict Fires when
PORTAL_FIELD_NOT_MIRRORED A sentence in a replacing manuscript section has no home in that field's paste artifact.
PORTAL_FIELD_MISSING The manuscript has the section, the journal replaces it, and no artifact exists — the field publishes empty or as the portal's auto-extraction guessed it.
EQUAL_CONTRIBUTION_NOT_IN_PORTAL The manuscript asserts equal / co-first contribution and the Author Contributions text does not.

All three are major and exit 1; the pre-flight runs this as P1 (--strict-promotable).

Which fields replace is a journal fact, not a guess. It is read from the journal profile's ## Portal Mechanics block (Fields that REPLACE the manuscript: …). A journal whose portal contract has never been recorded makes this check exit 2 and assert nothing — record the block at first submission rather than letting the gate invent a contract. Matching is graded through _quote_match.py, so re-flowing a sentence while pasting is not reported as a loss.

This is the complement of Gate 5c, not a duplicate: 5c asks whether what you paste is clean, this asks whether what you did not paste is quietly gone.

Phase 3c — CRediT integrity (not author order)

A contribution taxonomy is a factual claim, published with the paper, and every co-author reads it. Nothing ties a term to anything. During one byline negotiation three terms were requested in sequence — Visualization, Methodology, Formal analysis — each unsupported by the project record; a fourth, Conceptualization, was entirely legitimate and had no repository artifact at all, because it lived in email and in a critique that drove a restructure.

That asymmetry is the design. The taxonomy is checkable; the work behind it often is not.

python3 "${CLAUDE_SKILL_DIR}/scripts/check_credit_integrity.py" \
  --manuscript manuscript/manuscript.md --out qc/credit_integrity.json
Verdict Severity Fires when
CREDIT_TERM_INVALID major A term outside the official fourteen in a section that says CRediT — "Statistical analysis", "Manuscript writing", "Study design" all read as CRediT and are not. The message names the term that was meant.
CREDIT_INITIALS_UNRESOLVED major Initials matching no author, or two. This is the residue a byline edit leaves: the removed author's initials keep reading as valid.
CREDIT_AUTHOR_UNLISTED major A byline author with no contribution attributed. Under ICMJE that is either an authorship question or a dropped clause.
CREDIT_UNCORROBORATED prompt A term whose footprint is absent — Visualization on a paper with no figures, Software with no Code Availability statement, or (only if the project keeps one, passed with --contribution-record) a contributor absent from the record.

Author order and equal-contribution designation are never gated. They are negotiated, and negotiation is legitimate; conflating them with the taxonomy is why they get edited as one block. Corroboration is a prompt and can be answered with an attestation — a gate that failed the build on an off-repo contribution would be wrong, and would teach its user to disable it.

Two things it declines to guess: with fewer than two resolvable byline names the author/initials cross-check is skipped and says so (a wrong byline would accuse every author at once), and with no contributions section it exits 2 and asserts nothing.

Phase 4 — Cover-letter free-text drift

Cover letters live outside the submission docx files but are read by the editor side-by-side with the manuscript. Their ## Article details block — body word count, abstract word count, reference count, table/figure count — is a sidecar SSOT that routinely goes stale when a manuscript branches v_N → v_(N+1) (word limit retarget, abstract restructure, late reference batch).

scripts/cover_letter_drift_check.py measures the manuscript truth and compares it to the cover letter's numeric claims:

python "${CLAUDE_SKILL_DIR}/scripts/cover_letter_drift_check.py" \
    --manuscript manuscript.md \
    --cover-letter cover_letter.md \
    --refs refs.bib \
    --out qc/cover_letter_drift.json

Body words are matched with a 5% tolerance ("approximately N words" phrasing). Abstract words tolerate ±5. Reference / table / figure counts require exact match.

Example qc/cover_letter_drift.json (synthetic values):

{
  "submission_safe": false,
  "truth": {"body_words": 2400, "abstract_words": 210, "references": 10,
            "tables": 3, "figures": 4},
  "claims": {"body_words": 2800, "abstract_words": 250, "references": 10},
  "drifts": [
    {"field": "body_words", "truth": 2400, "cover_letter_claim": 2800,
     "severity": "MAJOR",
     "note": "|claim - truth| = 400 > tolerance 120"}
  ]
}

Drift resolution: regenerate the cover letter from the manuscript at v_(N+1) build time. The script never edits the cover letter — that is left to the manuscript build pipeline so the cover letter stays a deliberate authored artifact.

Phase 5 — Cross-document N consistency

Multi-document cohort-size drift is a high-frequency desk-reject pattern. Manuscript abstracts, body prose, PROSPERO records, supplementary extraction sheets, and PRISMA flow captions all repeat the same k included / k excluded / N patients totals — and any disagreement between them is read by reviewers as either a data-integrity failure or a late-edit failure. Either reading ends the round.

scripts/cross_document_n_check.py scans the submission package, extracts every "N

python "${CLAUDE_SKILL_DIR}/scripts/cross_document_n_check.py" \
    --root . \
    --out qc/cross_document_n.json

When the project has frozen a 2_Data/FINAL_POOL_LOCK.yaml from /meta-analysis Phase 3f.5, pass it as the authoritative anchor:

python "${CLAUDE_SKILL_DIR}/scripts/cross_document_n_check.py" \
    --root . \
    --pool-lock 2_Data/FINAL_POOL_LOCK.yaml \
    --out qc/cross_document_n.json

Output qc/cross_document_n.json:

{
  "submission_safe": false,
  "drift_count": 1,
  "drifts": [
    {
      "category": "included",
      "values": [63, 64],
      "locations": [
        {"file": "abstract.md", "line": 4, "value": 63, "context": "..."},
        {"file": "supplementary/s1.md", "line": 12, "value": 64, "context": "..."}
      ],
      "severity": "MAJOR"
    }
  ],
  "lock_violations": []
}

Treat submission_safe: false as a halt. Resolve drift by tracing each location to its data artifact (extraction sheet, PRISMA cascade TSVs) and correcting the document(s) that disagree with the locked count.

Phase 6 — Intra-manuscript scope drift

Late-revision sensitivity analyses sometimes get introduced in the Discussion or Limitations subsection without ever propagating back to Methods + Results. The manuscript then makes claims (with explicit AUC, OR, sensitivity numbers) whose primary report never exists. Reviewers read this as a fabrication-grade red flag, and editors desk-reject.

A second variant of the same anti-pattern: the PROSPERO record commits to a synthesis method (Freeman-Tukey, random-effects DerSimonian-Laird, bivariate, HSROC, Bayesian, etc.) but the Methods section uses a different one — or the PROSPERO record was updated and Methods stayed behind. When accompanied by a Methods line saying "no amendment lodged", this becomes a documented silent protocol deviation.

scripts/scope_drift_check.py detects both patterns:

python "${CLAUDE_SKILL_DIR}/scripts/scope_drift_check.py" \
    --manuscript manuscript.md \
    --prospero prospero/prospero_v2.md \
    --out qc/scope_drift.json

Output:

{
  "submission_safe": false,
  "limitations_only_anchors": [
    {
      "anchor": "0.869",
      "kind": "AUC",
      "found_in": ["Limitations:31"],
      "missing_from": ["Methods", "Results"]
    }
  ],
  "synthesis_method_drift": [
    {"method": "Freeman-Tukey", "prospero": true, "methods": false}
  ]
}

Resolution: either (a) propagate the anchor into Methods + Results as a primary report or (b) remove it from Limitations / Discussion. For synthesis-method drift, file a PROSPERO amendment and update Methods to match — both must agree before submission.

Phase 7 — v_(N+1) docx regeneration gate

When a v_N submission package was frozen and a v_(N+1) is being built (after a markdown body edit, reviewer round, or cascade-rejection re-target), the v_(N+1) docx MUST differ from the v_N docx. The most common silent-revert pattern is a cp v_N/manuscript.docx v_(N+1)/manuscript.docx step that skips the pandoc / Zotero CWYW regeneration entirely. The markdown body is then edited, but the docx the portal receives is the frozen v_N — the change silently reverts at peer review.

Run the byte-identity assertion at the top of the v_(N+1) submission gate:

python3 /path/to/medsci-skills/scripts/verify_package_integrity.py \
    --assert-vN-docx-changed \
    --vN-docx SUBMISSION/<journal>/v<N>/manuscript.docx \
    --new-docx SUBMISSION/<journal>/v<N+1>/manuscript.docx

Identical MD5 → exit 1 with explanatory error. Block submission until the regeneration step is fixed.

Phase 8 — Multi-copy manuscript divergence

When a project keeps several hand-maintained manuscript copies — manuscript.md (the working SSOT), manuscript_circulation.md (co-author feedback), and submission/<journal>/manuscript.md (portal) — a batch of edits applied to the SSOT routinely lands in only some of the copies. The portal then receives a copy missing a subset of the edits, and the divergence surfaces (if at all) only when a reviewer notices the inconsistency.

Before freezing a package or sending a circulation round, run the directional detector (SSOT → each copy):

python3 ${CLAUDE_SKILL_DIR}/scripts/detect_copy_divergence.py \
  --ssot manuscript.md \
  --copy manuscript_circulation.md \
  --copy submission/<journal>/manuscript.md \
  --out qc/copy_divergence.json --strict

It reports, per copy, the SSOT claims (numeric assertions — n = N, percentages, p, OR/HR/RR, 95% CI — and section headings) that did not propagate. A STALE_COPY (DIVERGENT overall) is a P0 blocker: re-propagate the unpropagated claims, or — better — stop hand-maintaining parallel copies and generate the circulation / submission variants from the single SSOT via a build step (pandoc transform), so there is only one editable source. Claims are matched as normalized strings, so wording differences do not register — only a changed or absent number/heading does; legitimately copy-specific content (a circulation cover note) shows up as copy_only and can be ignored.

Phase 9 — Springer Editorial Manager packaging (no title-page slot)

Some Springer Editorial Manager journals offer only Manuscript / Figure / Table / Supplementary / LaTeX upload item types — no separate Title Page or Cover Letter slot, and sometimes no Graphical Abstract slot. Common for observational / cohort submissions.

  • Title page → page 1 of the Manuscript file. Build via pandoc: title-page markdown (strip internal-only blocks such as a "Manuscript Metrics" QC block, plus any Funding / Author Contributions / Keywords that also appear later) + a real docx page break (raw OpenXML <w:br w:type="page"/>; a bare \newpage is silently dropped in docx output) + the manuscript body with its byline / affiliations / corresponding-author footnote removed so the title page is not duplicated.
    • Verify: at least one page break; the affiliation block appears once; the article title is followed directly by the Abstract (no repeated byline); no internal QC strings leak.
  • Cover letter → paste into the "comments to the publication office" free-text field.
  • Graphical Abstract (no dedicated slot) → upload as a Figure with Description = "Graphical Abstract".
  • Declarations completeness (portal hard checkbox). The manuscript "Statements and Declarations" must carry all seven Springer subheadings: Funding; Competing Interests; Ethics Approval; Consent to Participate; Consent for Publication; Author Contributions; Data Availability. For de-identified observational / registry studies, Consent to Participate = waived (existing de-identified records) and Consent for Publication = "Not applicable; only de-identified data, no individual person's identifying details, images, or videos".
for s in Funding "Competing Interests" "Ethics Approval" "Consent to Participate" "Consent for Publication" "Author Contributions" "Data Availability"; do
  unzip -p manuscript.docx word/document.xml | sed 's/<[^>]*>//g' | grep -q "$s" && echo "OK $s" || echo "MISSING $s"; done
  • Ethics approval / exemption number (observational or exempt cohort). State the IRB approval or exemption reference number in the ethics statement. Institutional exemption notices carry the reference in the document body; filename digits are usually a receipt number, not the approval number — open the notice before writing the ethics block.
  • Word limit "including references". When the limit counts references, the binding constraint is body+references words, not the reference-count ceiling. Measure body+refs on the rendered docx before adding references; each Vancouver reference is roughly 25–33 rendered words.
  • Submitting via a co-author's account. Editorial Manager auto-adds the account holder at the top of the author list, tagged first/corresponding. De-duplicate, reorder to the intended position, reassign the first-author tag to the true first author, and fill missing co-author email/ORCID.
  • Re-read the EM-compiled submission PDF before Approve — author order, degrees, ethics number, references, declarations, and figures.

Phase 10 — Marked (tracked-changes) manuscript for a revision round

Every revision round asks for a marked manuscript: the revised paper with tracked changes against the version the reviewers saw. Two rules, both load-bearing.

The baseline is R0, not the previous round. The base of the diff is always the originally reviewed submission; only the target advances each round. An editor wants every change made since the version under review, so do not diff v7 against v8.

Word's Compare is the only safe producer — but it is scriptable. pandiff and LibreOffice --compare corrupt OOXML on real manuscripts (tables collapse, affiliation superscripts are lost); do not use them. Word for Mac exposes compare through AppleScript with author name, so the build needs no GUI pass and no post-hoc rewriting of w:author:

python3 "${CLAUDE_SKILL_DIR}/scripts/build_marked_manuscript.py" \
  --original submission/{journal}/R0/manuscript.docx \
  --revised  submission/{journal}/R1/manuscript_clean.docx \
  --out      submission/{journal}/R1/manuscript_marked.docx \
  --author   "Submitting Author" --line-numbers

(macOS + Word only. On any other platform, produce the marked file in Word by hand — then still run the gate below.)

The gate: a round trip, not a grep

Confirming that "the marked file contains sentence X" passes even when Compare has dropped a paragraph, duplicated one, or split the revisions between two authors. Verify it the only way that is correct by construction — accepting every revision must reproduce the revised manuscript exactly, and rejecting every revision must reproduce the original:

python3 "${CLAUDE_SKILL_DIR}/scripts/check_marked_manuscript.py" \
  --marked   submission/{journal}/R1/manuscript_marked.docx \
  --original submission/{journal}/R0/manuscript.docx \
  --revised  submission/{journal}/R1/manuscript_clean.docx \
  --author   "Submitting Author" --strict

Verdicts: MARKED_ACCEPT_MISMATCH, MARKED_REJECT_MISMATCH (content dropped, duplicated, or invented), MARKED_NO_REVISIONS (Compare produced a clean copy), MARKED_AUTHOR_MIXED, MARKED_TABLE_LOSS, MARKED_BASE_TRACKED (a baseline still carrying live tracked changes, which makes the comparison ill-defined — accept or reject them first).

A move is not an insert plus a delete. Word encodes relocated content as w:moveFrom / w:moveTo, and a verifier that knows only w:ins / w:del reconstructs the original with the moved paragraph in it twice — reporting a perfectly good file as corrupt. The gate resolves revised = unchanged + w:ins + w:moveTo and original = unchanged + w:delText + w:moveFrom. Any docx probe written here must walk exact w:t / w:delText elements: the regex <w:t[^>]*> also matches <w:tbl>, <w:tc> and <w:tr>, silently swallowing table markup as prose.

Upload failure on a large marked file

The marked file carries the baseline's embedded images as deleted content, so it can exceed a portal's size cap even when the clean file is small. Before re-encoding, rule out the ordinary causes: the file is still open in Word (a ~$…docx lock), the portal session expired, or the upload is transient — retry. If it is genuinely too large, downsample only word/media/* and repackage; tracked changes live in word/document.xml and are untouched. Re-run the gate afterwards and keep the full-resolution original as *.full.docx.

Verification Blind Spots

Post-submission learnings (npj Digital Medicine R1, 2026-05): a clean docx-level audit still missed several stale artifacts that surfaced only at the portal review stage. Apply these whenever auditing a submission package.

B1. docx scanning must be recursive

python-docx paragraph.runs does not expose runs inside <w:hyperlink>; document.paragraphs skips table cells; document.tables does not recurse into nested tables. Figures, captions, and reporting checklists are routinely wrapped in 1×1 or nested tables, so flat scans silently miss them.

  • Walk paragraphs + tables + nested-table cells recursively for every stale-string scan.
  • For run-level edits near hyperlinks or fields, inspect the paragraph XML, not just .runs — a missing inline element can be misread as an empty () artifact and "fixed" into a real defect.

B2. Portal input fields are a separate SSOT

Cover letter, Data Availability, Acknowledgements, Abstract, and Author Contributions are often typed directly into the journal portal, outside any docx this skill audits. A clean docx audit does not imply a clean portal.

  • Before final submission, diff the portal's final review page against the manuscript body 1:1.
  • Treat each portal free-text field as its own drift target.

B3a. Double-blind compliance must cover ALL upload artifacts

A clean manuscript-level blind sweep does not imply a clean portal-level blind. Author identifiers commonly leak through:

  • Supplementary materials (per-material .md/.docx files, especially methodology logs, agreement metrics, amendment logs)
  • Cover letter (separately-uploaded file is portal-default visible to reviewers unless explicitly toggled "Don't show in review PDF")
  • Registry record PDFs (PROSPERO, ClinicalTrials.gov, IRB approval PDFs)
  • Portal free-text Letter field if cover-letter signature was pasted
  • Response-to-reviewers (revision rounds)

Blind sweep regex coverage must include both period and no-period initial forms (e.g., Y.N. and YN), full names in roman + native scripts, institution names, ORCID IDs, and submission email domains. The first blind PDF export from the portal is the authoritative drift detector — always export and grep before final submit.

B3b. PROSPERO public-record PDF shows only current amendment

PROSPERO's "Print/PDF" export from the public record renders only the current amendment narrative. Previous versions are accessible only by selecting older versions in the public-record version-history dropdown. When citing PROSPERO version state, never rely on a single PDF export to verify cross-version consistency — record each published version's PDF independently and clarify in cover/supplementary which version anchors the methodology vs. which version reflects documentation-only erratum.

For documentation-only PROSPERO errata (correcting a narrative fact without changing methods/eligibility/synthesis), prefer a single Revision-Note append over a new structured amendment entry. Preserves historical audit trail and minimizes portal edit surface.

B3c. Text-only docx rebuilds must not inherit manuscript media

If response_to_reviewers.docx / cover_letter.docx / supplementary text-only docx grow to >100 KB after a rebuild, suspect --reference-doc pulling manuscript figure media. Verify with unzip -l output.docx | grep word/media/ — should be empty for text-only artifacts.

B3. Verify change propagation across the whole SSOT tree

A tone, wording, or number change applied to one file (e.g. the abstract) must propagate to every file that repeats it — discussion, response-to-reviewers quotes, reporting checklists, supplementary captions, title page.

  • grep the OLD string across the entire SSOT tree, never a subset of files.
  • Watch for substring near-misses (expertise-dependent patterns vs expertise-dependent evaluation patterns) — an exact-match grep on the short form passes while the long form remains stale.

What This Skill Does NOT Do

  • Does not invent journal formatting rules.
  • Does not silently merge submission edits back into the SSOT.
  • Does not replace /write-paper; it packages already canonical content.

Anti-Hallucination

  • Never claim a submission package is current without matching source hashes.
  • Never mark a package as submitted without writing .journal_meta.json.
  • Never hide journal-only differences; record them as drift or explicit exceptions.
Files (medsci-skills)
  • examples
    • build_synthetic_bundle.py 3.6 KB
      #!/usr/bin/env python3
      """Original synthetic bundle demo; creates files only in a new/empty directory.
      
      Usage: python build_synthetic_bundle.py --project-root /tmp/submission-demo [--pdf]
      Requires pandoc; --pdf also requires render-pdf-doc and its XeLaTeX dependencies.
      """
      import argparse
      import json
      from pathlib import Path
      import subprocess
      import sys
      
      SKILL = Path(__file__).resolve().parents[1]
      sys.path.insert(0, str(SKILL / "scripts"))
      from sync_submission import sha256_file
      
      
      def main():
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("--project-root", required=True)
          parser.add_argument("--pdf", action="store_true")
          args = parser.parse_args()
          root = Path(args.project_root).resolve()
          if root.exists() and any(root.iterdir()):
              parser.error("Demo requires a new or empty directory")
          root.mkdir(parents=True, exist_ok=True)
          for directory in ("manuscript", "build"):
              (root / directory).mkdir()
          sources = {
              "manuscript/manuscript.md": "# Synthetic study\n\nThis is original demonstration text, not research evidence.\n\n"
              "| Group | Count |\n|---|---:|\n| A | 12 |\n| B | 18 |\n\nTotal: 30 synthetic observations.\n",
              "manuscript/supplement.md": "# Synthetic supplement\n\nGroup A: 12. Group B: 18. Total: 30.\n",
              "manuscript/cover_letter.md": "Dear Editor,\n\nThis is a synthetic packaging demonstration.\n",
          }
          for name, content in sources.items():
              (root / name).write_text(content, encoding="utf-8")
          before = {name: sha256_file(root / name) for name in sources}
          version = subprocess.run(["pandoc", "--version"], check=True, capture_output=True, text=True).stdout.splitlines()[0]
          entries = []
          for name, stem, role in (("manuscript/manuscript.md", "final", "manuscript"),
                                   ("manuscript/supplement.md", "supplement", "supplement"),
                                   ("manuscript/cover_letter.md", "cover_letter", "cover_letter")):
              formats = ["docx"] + (["pdf"] if args.pdf else [])
              for extension in formats:
                  output = f"build/{stem}.{extension}"
                  target = f"{role}/{stem}.{extension}"
                  command = ["pandoc", name, "-o", output]
                  recorded = list(command)
                  if extension == "pdf":
                      renderer = SKILL.parent / "render-pdf-doc/scripts/render_pdf.sh"
                      command = ["bash", str(renderer), "-i", name, "-o", output]
                      recorded = ["render-pdf-doc/scripts/render_pdf.sh", "-i", name, "-o", output]
                  subprocess.run(command, cwd=root, check=True)
                  entries.append({"id": f"{stem}-{extension}", "role": f"{role}_{extension}",
                                  "source": output, "target": target,
                                  "derived_from": [{"path": name, "sha256": before[name]}],
                                  "transformation": {"kind": "rendered", "command": recorded, "pandoc_version": version},
                                  "rights": {"status": "original", "changes": "Rendered original synthetic example"}})
          if before != {name: sha256_file(root / name) for name in sources}:
              raise RuntimeError("Sources changed during rendering")
          (root / "bundle.json").write_text(json.dumps({"schema_version": 1, "artifacts": entries}, indent=2) + "\n")
          subprocess.run([sys.executable, str(SKILL / "scripts/sync_submission.py"), "build",
                          "--project-root", str(root), "--journal", "example", "--bundle-spec", "bundle.json"], check=True)
          print("Synthetic bundle built. Visual, semantic, metadata and preflight checks remain separate.")
      
      
      if __name__ == "__main__":
          main()
      
  • references
    • bundle_workflow.md 7.2 KB
      # Preserve sources while assembling a submission bundle
      
      `sync_submission.py build` copies files byte for byte. It does not rewrite prose,
      reformat third-party templates, render documents, or certify a submission. Use
      the existing manuscript/figure/supplement renderers first, inspect their output,
      then declare exactly which files belong in this journal package.
      
      ## From changed sources to final files
      
      1. Preserve the previous submitted/frozen package. Work from canonical sources in
         a new revision directory. Do not run side-effecting checks against the only
         copy of a submitted package; use an isolated project copy for that audit.
      2. Before rendering, record SHA-256 hashes of the manuscript, supplement sources,
         bibliography, tables, figures, configuration and any reference template actually
         used. Run the existing renderer and confirm those inputs did not change during
         the run. Record its command/version and the output in the bundle declaration.
      3. Inspect the actual final DOCX/PDF, including tables, figures, equations, Unicode,
         captions, pagination, tracked changes and hidden metadata. A source-text check
         does not establish that those survived conversion. Keep review notes local.
      4. Build using the declaration, run the existing preflight with explicit final
         file paths where discovery would select only one file, then audit again to
         connect its report to the bundle. Only freeze the chosen byte snapshot after
         the existing skill's submission review steps. Freeze is not approval.
      
      ```bash
      python scripts/sync_submission.py build --project-root . --journal example \
        --bundle-spec bundle.json
      python scripts/preflight_gate.py --project-root . --journal example \
        --docx submission/example/manuscript/final.docx
      python scripts/sync_submission.py audit --project-root . --journal example
      python scripts/sync_submission.py freeze --project-root . --journal example
      ```
      
      Paths above are relative to this skill directory for the scripts and to the
      chosen project root for the declaration's inputs. Use absolute script paths when
      running from a research project. `--bundle-spec` itself is project-relative.
      
      The declaration is an input to the existing build, not a second output ledger.
      The canonical manuscript is always included as `manuscript/manuscript.md`.
      Additional entries in `bundle.json` use schema version 1:
      
      ```json
      {
        "schema_version": 1,
        "artifacts": [{
          "id": "final-word",
          "role": "manuscript_docx",
          "source": "build/final.docx",
          "target": "manuscript/final.docx",
          "derived_from": [{
            "path": "manuscript/manuscript.md",
            "sha256": "sha256:REPLACE_WITH_THE_HASH_OBSERVED_BEFORE_RENDERING"
          }],
          "transformation": {
            "kind": "rendered",
            "command": ["pandoc", "manuscript/manuscript.md", "-o", "build/final.docx"],
            "version": "RECORD_THE_RENDERER_VERSION"
          },
          "rights": {"status": "original"}
        }]
      }
      ```
      
      Repeat entries for the final PDF, supplement, cover letter, title page, tables,
      figures and required notices. List every render dependency, not just the main
      manuscript. Commands are recorded declarations; build never executes them. Do
      not attach today's source hashes to an old output without actually rebuilding.
      If a declared dependency changed, build stops and asks for the existing renderer
      to be rerun. A declared link is not authenticated proof of how an output was made.
      
      For a runnable example using only original synthetic text and a synthetic table:
      
      ```bash
      python examples/build_synthetic_bundle.py --project-root /tmp/synthetic-submission
      # Optional real PDF, using the installed render-pdf-doc skill and XeLaTeX:
      python examples/build_synthetic_bundle.py --project-root /tmp/synthetic-submission-pdf --pdf
      ```
      
      ## Output contract and limits
      
      - `.journal_meta.json` schema 2 records each file's source/output hashes, pinned
        render inputs, declared transformation and rights, plus explicit unassessed
        content/visual review. `artifact_manifest.json` retains its existing schema and
        other fields; its selected `submissions` entry carries the same artifacts.
      - `qc/submission_sync_{journal}.json` schema 2 reports changed/missing source,
        output or dependency files and unregistered package files. A clean legacy
        manuscript-only audit does not claim full-package coverage. Exit 0 means no
        tracked drift, 1 means drift, and 2 means missing/invalid input.
      - `preflight_gate_report.json` schema 2 lists executed/skipped/error checks and
        their invocation targets. Its byte binding covers the declared sources,
        dependencies and package present before and after the run, **not every input
        of every check**. `package_bytes_current` in sync audit means only that this
        binding still matches. Changes to an undeclared bibliography or external
        profile require rerunning the relevant checks even if that binding matches.
      - Preflight's compatibility field `submission_safe` means no configured blocker
        or error. Read `coverage`, warnings and `readiness: not_assessed` as well. An
        exit-zero check may have its own partial-coverage limitations. No aggregated
        pass is propagated to an individual PDF's visual review or semantic fidelity.
      - Missing reports remain `not_run`, legacy/unbound reports remain `unbound`, and
        changed bundles make recorded checks `stale` on every audit, including reruns.
      - Build refuses frozen/submitted packages, edited outputs, undeclared files that
        would be lost, removed registered files, symlinks, hidden input/target paths,
        traversal, hard-link source aliases and overlapping/case-colliding targets.
        Use a new revision journal slug for a deliberately different package.
      - Builds stage copies and roll back ordinary replacement failures. A cooperative
        project lock serializes build/freeze manifest mutations. This is not a filesystem
        transaction or protection against external concurrent editors or power loss.
        After an interrupted process, inspect its lock/staging directory before recovery.
      
      ## Reuse rights and fidelity
      
      The default `rights.status` is `unknown`; nothing infers permission from download
      success, a DOI, a journal logo, or an unchanged hash. `original` and `documented`
      are user declarations, not legal determinations. For `documented`, provide
      `source`, `license_or_permission`, `attribution`, and `changes`; retain permission
      evidence locally and include required notices in the actual distributed files.
      Do not put private permission correspondence or identifiers in a public example.
      
      Keep an official source unmodified where required. If an adaptation is permitted,
      retain the source/version and describe changes; do not label your adapted summary
      as the official checklist. This repository's MIT license does not relicense
      third-party templates or papers. Its index is `THIRD-PARTY-NOTICES.md`.
      
      CC BY 4.0 requires attribution, a license link and an indication of changes.
      CC BY-NC-ND 4.0 also restricts commercial use and distribution of adaptations;
      its deed distinguishes a mere format change from an adaptation. Consult the
      specific material's actual terms and intended use rather than treating every
      file conversion as an adaptation or every open-access item as redistributable.
      Sources: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) and
      [CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/).
      
    • journal_availability_policy.json 1.2 KB
      {
        "_comment": "PUBLIC author-guideline facts expressed as booleans — NOT verbatim journal text. data_required: a Data Availability statement is expected; code_required_if_ai: a Code Availability statement is expected for AI/ML studies; repository_required: data/code sharing should point to a repository rather than only 'available on reasonable request'. These are conventions; verify current instructions-to-authors at the journal site before relying on a value.",
        "schema_version": 1,
        "default": {
          "data_required": true,
          "code_required_if_ai": true,
          "repository_required": false
        },
        "journals": {
          "radiology": {"data_required": true, "code_required_if_ai": true, "repository_required": true},
          "radiology-ai": {"data_required": true, "code_required_if_ai": true, "repository_required": true},
          "ryai": {"data_required": true, "code_required_if_ai": true, "repository_required": true},
          "npj-digital-medicine": {"data_required": true, "code_required_if_ai": true, "repository_required": false},
          "nature-medicine": {"data_required": true, "code_required_if_ai": true, "repository_required": true},
          "lancet-digital-health": {"data_required": true, "code_required_if_ai": true, "repository_required": false}
        }
      }
      
  • scripts
    • check_portal_field_residue_challenge
      • fixture
        • negative
          • abstract.txt 327 B
            Background: Coronary artery calcium (CAC) predicts events beyond traditional risk factors.
            Methods: We measured CAC in a screening cohort and reported the area under the curve.
            Results: The primary endpoint improved; the mean lesion area was 2.4 cm2.
            Conclusion: CAC adds value. Data are available at https://example.org/data.
            
          • keywords.txt 327 B
            coronary artery calcium; risk stratification; screening; well-known risk-factor
            Significance markers such as * p<0.05 and ** p<0.01 were noted; approximately ~5% of subjects differed over a range of 1~2 days.
            A 2 × 2 contingency table was used over study days 1–2, a multiplication sign and en-dash the portal leaves alone.
            
        • positive
          • abstract.txt 404 B
            Background: Coronary artery calcium (CAC) predicts events beyond traditional risk factors.
            Methods: We measured CAC in a screening cohort and reported the area under the curve. Adults with eGFR ≥ 45 and BMI ≤ 30 were eligible.
            Results: The **primary** endpoint improved; the mean lesion area was 2.4 cm^2^.
            Conclusion: CAC adds value. See [our repository](https://example.org/data) for details.
            
            ---
            
          • take_home.txt 104 B
            # Take-home points
            
            - CAC refines risk beyond traditional factors; H~2~O-based contrast was unaffected.
            
      • problem.md 1.5 KB
        # Challenge — portal-field markdown residue
        
        ## The defect this gate catches
        
        A portal-field text file (`abstract.txt`, `keywords.txt`, …) is cut from the
        manuscript markdown so the author can paste it straight into an Editorial Manager /
        ScholarOne free-text field. Nothing strips the markdown at that boundary, so a
        trailing `---`, a stray `**bold**`, or a `cm^2^` superscript pastes into — and is
        published in — the field literally.
        
        Real instance: three portal-field files each ended with a `---` line; the author is
        told to paste the file verbatim, so `---` would have printed in the published abstract.
        
        Only `.txt` files are scanned — a `.md` is *meant* to carry markdown, so it is out of
        scope. That single scope decision is what keeps the gate precise.
        
        ## Fixtures
        
        **Positive** (`fixture/positive/`): two paste-verbatim files carrying, between them,
        all six residue kinds — a trailing `---` (hr), `**primary**` (bold), `cm^2^`
        (superscript), `[our repository](url)` (link), `# Take-home points` (heading), and
        `H~2~O` (subscript). Exit 1.
        
        **Negative** (`fixture/negative/`): clean plain text that deliberately includes the
        false-positive traps — significance stars `* p<0.05 and ** p<0.01` (space after the
        pair), an approximation tilde `~5%`, a numeric range `1~2 days`, a hyphenated
        `risk-factor`, and a bare `https://` URL. None of these is paired markdown, so nothing
        fires. Exit 0.
        
        ## Verify
        
        `bash verify.sh` — deterministic, network-free. Asserts the positive fixture flags all
        six kinds (exit 1) and the negative fixture is clean (exit 0).
        
      • verify.sh 1.8 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the portal-field-residue challenge card.
        #   Positive: two paste-verbatim .txt files carry all six residue kinds + the "≥/≤"
        #             char-expansion advisory -> exit 1.
        #   Negative: clean text with the FP traps (significance stars, ~approx, 1~2 range,
        #             bare URL, and a "×"/en-dash the portal leaves alone) -> nothing fires, exit 0.
        # No network. Exit 0 = both stages match expectations.
        set -euo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        DET="$HERE/../check_portal_field_residue.py"
        tmp="$(mktemp -d)"
        trap 'rm -rf "$tmp"' EXIT
        
        # --- Positive: every residue kind must be flagged ---
        set +e
        python3 "$DET" --dir "$HERE/fixture/positive" --quiet --out "$tmp/pos.json"
        pos_rc=$?
        set -e
        
        for k in hr bold heading link superscript subscript char_expansion; do
          if ! grep -q "\"kind\": \"$k\"" "$tmp/pos.json"; then
            echo "FAIL: positive fixture did not flag residue kind '$k'" >&2
            cat "$tmp/pos.json" >&2
            exit 1
          fi
        done
        if [ "$pos_rc" -ne 1 ]; then
          echo "FAIL: positive fixture must exit 1 (residue found); got $pos_rc" >&2
          exit 1
        fi
        if ! grep -q '"detector": "check_portal_field_residue"' "$tmp/pos.json"; then
          echo "FAIL: JSON envelope does not self-identify the detector" >&2
          exit 1
        fi
        
        # --- Negative: the FP traps must NOT fire ---
        set +e
        python3 "$DET" --dir "$HERE/fixture/negative" --quiet --out "$tmp/neg.json"
        neg_rc=$?
        set -e
        
        if [ "$neg_rc" -ne 0 ]; then
          echo "FAIL: negative fixture must exit 0; got $neg_rc" >&2
          cat "$tmp/neg.json" >&2
          exit 1
        fi
        if grep -qE '"kind":' "$tmp/neg.json"; then
          echo "FAIL: negative fixture flagged residue (false positive)" >&2
          cat "$tmp/neg.json" >&2
          exit 1
        fi
        
        echo "PASS: positive flags all six residue kinds + the ≥/≤ char-expansion advisory (exit 1); negative with FP traps (incl. × and en-dash) is clean (exit 0)."
        
    • credit_integrity_challenge
      • fixture
        • manuscript_bad.md 400 B
          # Deferred release of automated advice in a reporting workflow
          
          Jane Doe, Alex Roe, Sam Poe
          
          ## Abstract
          
          A synthetic manuscript with no figures at all.
          
          ## Methods
          
          Readers entered a provisional impression before the automated result was released.
          
          ## Author Contributions
          
          CRediT: J.D. Conceptualization, Methodology, Visualization. A.R. Statistical analysis,
          Manuscript writing. K.W. Supervision.
          
        • manuscript_good.md 525 B
          # Deferred release of automated advice in a reporting workflow
          
          Jane Doe, Alex Roe, Sam Poe
          
          ## Abstract
          
          A synthetic manuscript that does have a figure.
          
          ## Methods
          
          Readers entered a provisional impression before the automated result was released.
          
          ![Study flow](figures/flow.png)
          
          Figure 1. Participant flow through the reporting workflow.
          
          ## Author Contributions
          
          CRediT: J.D. Conceptualization, Methodology, Visualization. A.R. Formal analysis,
          Writing - original draft. S.P. Data curation, Writing - review & editing.
          
      • verify.sh 7.6 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the CRediT-integrity challenge card.
        #
        # CRediT terms are published with the paper and every co-author reads them, but nothing ties a
        # term to anything. During one byline negotiation three terms were requested in sequence —
        # Visualization, Methodology, Formal analysis — each unsupported by the project record; a
        # fourth, Conceptualization, was entirely legitimate and had no repository artifact at all,
        # because it lived in email and in a critique that drove a restructure.
        #
        # That asymmetry is the whole design. The taxonomy is checkable; the work behind it often is
        # not. So the manuscript-only verdicts are majors, and corroboration is a prompt that can be
        # answered with an attestation.
        #
        # The positive fixture carries four separable defects:
        #   "Statistical analysis" / "Manuscript writing"  -> CREDIT_TERM_INVALID  (not the fourteen)
        #   K.W., who is in no byline                      -> CREDIT_INITIALS_UNRESOLVED
        #   Sam Poe, credited nowhere                      -> CREDIT_AUTHOR_UNLISTED
        #   Visualization on a paper with no figures       -> CREDIT_UNCORROBORATED (prompt)
        #
        # The negatives are what keep it usable:
        #   a correct CRediT section on a paper WITH a figure -> silent
        #   a manuscript with no contributions section at all -> exit 2, asserts nothing
        #   a byline it cannot resolve -> the author/initials cross-check is SKIPPED, not guessed;
        #     a wrong byline would otherwise accuse every author at once
        #   author ORDER and equal-contribution are never mentioned by any verdict — they are
        #     negotiated, and gating them is what this deliberately does not do
        set -uo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        DET="$HERE/../check_credit_integrity.py"
        FIX="$HERE/fixture"
        
        TMP="$(mktemp -d)"
        trap 'rm -rf "$TMP"' EXIT
        
        pass=0; fail=0
        ck() { if [ "$2" = "$3" ]; then printf '  PASS  %-56s exit=%s\n' "$1" "$3"; pass=$((pass+1));
               else printf '  FAIL  %-56s want=%s got=%s\n' "$1" "$2" "$3"; fail=$((fail+1)); fi; }
        has() { if echo "$2" | grep -q "$3"; then ck "$1" 0 0; else ck "$1" 0 1; fi; }
        hasnt() { if echo "$2" | grep -q "$3"; then ck "$1" 0 1; else ck "$1" 0 0; fi; }
        
        echo "== positive: a taxonomy that does not describe the work =="
        python3 "$DET" --manuscript "$FIX/manuscript_bad.md" --quiet >/dev/null 2>&1
        ck "a defective CRediT section exits 1" 1 "$?"
        OUT="$(python3 "$DET" --manuscript "$FIX/manuscript_bad.md" 2>&1)"
        has "a non-CRediT term is named"             "$OUT" "CREDIT_TERM_INVALID"
        has "and the right replacement is offered"   "$OUT" "closest is Formal analysis"
        has "orphan initials are named"              "$OUT" "CREDIT_INITIALS_UNRESOLVED"
        has "an uncredited byline author is named"   "$OUT" "CREDIT_AUTHOR_UNLISTED"
        has "Visualization with no figure is queried" "$OUT" "CREDIT_UNCORROBORATED"
        # a section that says "CRediT" means the fourteen, so an invalid term there is major
        has "an invalid term is MAJOR when CRediT is declared" "$OUT" "major] CREDIT_TERM_INVALID"
        
        echo "== the line this gate must never cross =="
        hasnt "author order is never mentioned"      "$OUT" "[Oo]rder"
        hasnt "equal contribution is never mentioned" "$OUT" "equal"
        
        echo "== negative: a correct section on a paper that has a figure =="
        python3 "$DET" --manuscript "$FIX/manuscript_good.md" --quiet >/dev/null 2>&1
        ck "a correct CRediT section -> silent" 0 "$?"
        OUT="$(python3 "$DET" --manuscript "$FIX/manuscript_good.md" 2>&1)"
        has   "and it really did read the terms"     "$OUT" "7 CRediT term"
        hasnt "with nothing invented"                "$OUT" "CREDIT_"
        
        echo "== negative: nothing to check asserts nothing =="
        cat > "$TMP/no_credit.md" <<'EOF'
        # A manuscript with no contributions section
        
        Jane Doe, Alex Roe
        
        ## Methods
        
        Nothing here declares who did what.
        EOF
        python3 "$DET" --manuscript "$TMP/no_credit.md" --quiet >/dev/null 2>&1
        ck "no contributions section -> skipped" 2 "$?"
        
        echo "== an unresolvable byline is skipped, not guessed =="
        # One name only: the bijection would accuse every initial in the section. It must stand down.
        cat > "$TMP/thin_byline.md" <<'EOF'
        # A manuscript whose byline cannot be parsed
        
        ## Author Contributions
        
        CRediT: J.D. Conceptualization. A.R. Methodology. S.P. Supervision.
        EOF
        OUT="$(python3 "$DET" --manuscript "$TMP/thin_byline.md" 2>&1)"
        has   "it says the cross-check was skipped"  "$OUT" "cross-check skipped"
        hasnt "and accuses nobody"                   "$OUT" "CREDIT_INITIALS_UNRESOLVED"
        hasnt "nor invents an unlisted author"       "$OUT" "CREDIT_AUTHOR_UNLISTED"
        
        echo "== an optional contribution record, when the project keeps one =="
        printf 'J.D.: figures/flow.png, analysis/model.R\nA.R.: analysis/model.R\n' > "$TMP/record.yaml"
        OUT="$(python3 "$DET" --manuscript "$FIX/manuscript_good.md" --contribution-record "$TMP/record.yaml" 2>&1)"
        has "an author absent from the record is queried" "$OUT" "S.P. is credited"
        has "and only as a prompt"                        "$OUT" "minor] CREDIT_UNCORROBORATED"
        # without the record that half must not run at all
        OUT="$(python3 "$DET" --manuscript "$FIX/manuscript_good.md" 2>&1)"
        hasnt "no record -> that half does not run"       "$OUT" "is credited in the manuscript but"
        
        echo "== a contributions paragraph that is not a CRediT block is not graded as one =="
        # Found by running this detector over 12 accepted papers. The docstring says the term check is for
        # "a section that calls itself CRediT"; the code ran it on any Authors' Contributions heading and
        # merely graded the result `minor` — and a minor finding still exits non-zero. So free prose fired:
        # "analysis" and "writing" reported as invalid CRediT terms in papers carrying NO CRediT statement.
        cat > "$TMP/prose_contrib.md" <<'EOF'
        # A manuscript with a plain-prose contributions statement
        
        Jane Doe, Alan Roe
        
        ## Author Contributions
        
        Study design, J.D. Data collection, A.R. Statistical analysis, J.D. Manuscript writing, A.R.
        Both authors approved the final version.
        EOF
        OUT="$(python3 "$DET" --manuscript "$TMP/prose_contrib.md" 2>&1)"
        hasnt "prose is not scanned for the fourteen terms" "$OUT" "CREDIT_TERM_INVALID"
        
        echo "== ...but a CRediT block that never says CRediT still is =="
        # Three official terms is evidence enough. One is not: "Investigation", "Validation" and "Software"
        # are ordinary English and turn up in prose by accident.
        cat > "$TMP/unnamed_credit.md" <<'EOF'
        # A manuscript using the taxonomy without naming it
        
        Jane Doe, Alan Roe
        
        ## Author Contributions
        
        Conceptualization, J.D.; Methodology, J.D.; Formal analysis, A.R.; Statistical analysis, A.R.
        EOF
        OUT="$(python3 "$DET" --manuscript "$TMP/unnamed_credit.md" 2>&1)"
        has "a real CRediT block is still graded" "$OUT" "CREDIT_TERM_INVALID"
        
        echo "== the em dash belongs inside the term, not between two of them =="
        # MDPI renders the taxonomy as "Writing—original draft preparation". Without U+2014 in the term
        # class the phrase was shredded at the dash and the fragment "writing" reported as invalid — twice,
        # against accepted papers that had written the term correctly for their publisher.
        cat > "$TMP/mdpi.md" <<'EOF'
        # A manuscript in MDPI house style
        
        Jane Doe, Alan Roe
        
        ## Author Contributions
        
        Conceptualization, J.D. and A.R.; methodology, J.D.; investigation, A.R.;
        writing—original draft preparation, J.D.; writing—review and editing, A.R.
        EOF
        OUT="$(python3 "$DET" --manuscript "$TMP/mdpi.md" 2>&1)"
        hasnt "MDPI's em-dash rendering is the official term" "$OUT" "CREDIT_TERM_INVALID"
        
        echo "== the artifact names its own author =="
        python3 "$DET" --manuscript "$FIX/manuscript_bad.md" --out "$TMP/r.json" >/dev/null 2>&1
        has "qc JSON carries the detector key" "$(cat "$TMP/r.json")" '"detector": "check_credit_integrity"'
        
        echo
        printf 'credit-integrity challenge: %d passed, %d failed\n' "$pass" "$fail"
        [ "$fail" -eq 0 ] || exit 1
        
    • figure_portal_readiness_challenge
      • problem.md 1.5 KB
        # Challenge — figure portal readiness (size + accepted format)
        
        A figure bounces at the upload button after a long submission session, for one of two
        deterministic reasons the author could have caught beforehand:
        
        1. **Size cap** — JACC: Asia rejects a figure over **25 MB**, which a raw uncompressed
           600-dpi RGBA TIFF sails straight past.
        2. **Format allowlist** — Springer Nature's SNAPP accepts only `.tiff` / `.jpeg` / `.eps`
           and **rejects the `.png`** a figure was rendered as.
        
        Both are decidable from the file on disk — a byte size and an extension — so
        `figure_portal_readiness_check.py` catches them at pre-flight. (The *fix* is to regenerate
        with `/make-figures export_portal_tiff.py`: LZW + RGBA→RGB flatten.)
        
        ## What `verify.sh` asserts (network-free, stdlib, no committed binaries)
        
        Fixtures are generated at runtime as byte files with figure extensions — the check reads
        size and extension, never image content, so no real images are needed.
        
        - **Format**: with `--accept tiff jpeg eps` (SNAPP), a `.png` is `FIGURE_FORMAT_REJECTED`
          while a `.tiff` is accepted; a non-image `.txt` in the same directory is ignored.
        - **Size**: with a cap below a figure's size, that figure is `FIGURE_OVERSIZE`.
        - **Clean** (no false positive): accepting `png`+`tiff` under the 25 MB default leaves every
          figure silent (exit 0).
        - **Skip semantics** (via the pre-flight gate): with no figures directory the `figure_readiness`
          check is recorded `skipped`, never an error; it warns (P1) by default and only halts under
          `--strict`.
        
      • verify.sh 4.2 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the figure portal-readiness challenge card.
        # Network-free, stdlib-only, NO committed binaries — fixtures are byte files with figure
        # extensions (the check reads size + extension, never image content). Also exercises the
        # preflight-gate wiring (skip when no figures dir; warn P1 vs halt under --strict).
        # Exit 0 = every stage matches expectations.
        set -euo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        DET="$HERE/../figure_portal_readiness_check.py"
        GATE="$HERE/../preflight_gate.py"
        tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
        
        [ -f "$DET" ] || { echo "ENV-ERR: figure_portal_readiness_check.py missing" >&2; exit 2; }
        
        figs="$tmp/figures"; mkdir -p "$figs"
        python3 - "$figs" <<'PY'
        import sys, pathlib
        d = pathlib.Path(sys.argv[1])
        (d / "fig1.png").write_bytes(b"x" * 3000)   # ~3 KB PNG
        (d / "fig2.tiff").write_bytes(b"y" * 1500)  # ~1.5 KB TIFF
        (d / "notes.txt").write_text("not a figure")  # must be ignored
        PY
        
        # (1) FORMAT: SNAPP accepts tiff/jpeg/eps -> the .png is rejected, the .tiff is not,
        #     the .txt is ignored (2 figures scanned).
        python3 "$DET" --figures-dir "$figs" --accept tiff --accept jpeg --accept eps \
          --quiet --out "$tmp/fmt.json" && { echo "FAIL: a .png under SNAPP formats did not flag" >&2; exit 1; }
        python3 - "$tmp/fmt.json" <<'PY'
        import json, os, sys
        d = json.load(open(sys.argv[1]))
        assert d["scanned"]["figures"] == 2, d["scanned"]           # .txt ignored
        kinds = {(os.path.basename(f["path"]), f["kind"]) for f in d["findings"]}
        assert ("fig1.png", "FIGURE_FORMAT_REJECTED") in kinds, d["findings"]
        assert not any(f["path"].endswith("fig2.tiff") for f in d["findings"]), "an accepted .tiff was flagged"
        assert d["detector"] == "figure_portal_readiness_check", "envelope does not self-identify"
        print("OK-FORMAT: .png rejected, .tiff accepted, .txt ignored")
        PY
        
        # (2) SIZE: a cap below the .png size flags it OVERSIZE.
        python3 "$DET" --figures-dir "$figs" --max-mb 0.002 \
          --quiet --out "$tmp/size.json" && { echo "FAIL: a figure over the cap did not flag" >&2; exit 1; }
        grep -q '"kind": "FIGURE_OVERSIZE"' "$tmp/size.json" || { echo "FAIL: no FIGURE_OVERSIZE" >&2; cat "$tmp/size.json" >&2; exit 1; }
        echo "OK-SIZE: a figure over --max-mb flags FIGURE_OVERSIZE"
        
        # (3) CLEAN: accepting png+tiff under the 25 MB default is silent (no false positive).
        python3 "$DET" --figures-dir "$figs" --accept png --accept tiff --quiet --out "$tmp/clean.json"
        grep -qE '"kind":' "$tmp/clean.json" && { echo "FAIL: clean fixture flagged (false positive)" >&2; cat "$tmp/clean.json" >&2; exit 1; }
        echo "OK-CLEAN: png+tiff under the default cap is silent"
        
        # (4) PREFLIGHT WIRING: warn (P1) by default vs halt under --strict; skip with no figures dir.
        if [ -f "$GATE" ]; then
          echo "y" > "$tmp/manuscript.md"
          # figures present, tiny cap + accept tiff -> warn, gate does NOT halt (exit 0)
          python3 "$GATE" --project-root "$tmp" --figure-max-mb 0.002 --figure-accept tiff \
            --quiet --out "$tmp/pf.json" || { echo "FAIL: preflight halted on a P1 warn (should not)" >&2; exit 1; }
          python3 - "$tmp/pf.json" <<'PY'
        import json, sys
        c = [x for x in json.load(open(sys.argv[1]))["checks"] if x["id"] == "figure_readiness"][0]
        assert c["status"] == "warn", c
        print("OK-PREFLIGHT-WARN: figure_readiness warns (P1) without halting")
        PY
          # under --strict the same becomes a blocker -> gate halts (exit 1)
          if python3 "$GATE" --project-root "$tmp" --figure-max-mb 0.002 --figure-accept tiff \
               --strict --quiet --out "$tmp/pfs.json" >/dev/null 2>&1; then
            echo "FAIL: --strict did not halt on an over-cap figure" >&2; exit 1
          fi
          # no figures dir -> skipped (never an error)
          empty="$tmp/empty"; mkdir -p "$empty"; echo "y" > "$empty/manuscript.md"
          python3 "$GATE" --project-root "$empty" --quiet --out "$empty/pf.json" >/dev/null 2>&1 || true
          python3 - "$empty/pf.json" <<'PY'
        import json, sys
        c = [x for x in json.load(open(sys.argv[1]))["checks"] if x["id"] == "figure_readiness"][0]
        assert c["status"] == "skipped", c
        print("OK-PREFLIGHT-SKIP: no figures dir -> skipped, not error")
        PY
        fi
        
        echo "PASS: figure readiness flags wrong-format + over-cap figures, stays clean on good ones, and wires into the preflight gate (warn P1 / halt --strict / skip when absent)."
        
    • portal_mirror_challenge
      • fixture
        • portal_complete
          • acknowledgements.txt 241 B
            We thank the reporting radiologists who took part. This work was supported by the
            Synthetic Research Council under grant SRC-0000. The funder had no role in study
            design, data collection, analysis, interpretation, or the decision to submit.
            
          • author_contributions.txt 230 B
            Jane Doe and Alex Roe contributed equally to this work. J.D. and A.R. designed the study
            and wrote the manuscript. S.P. curated the data and performed the statistical analysis.
            All authors reviewed and approved the final version.
            
          • competing_interests.txt 71 B
            The authors declare no competing financial or non-financial interests.
            
          • data_availability.txt 293 B
            The de-identified analysis dataset is available from the corresponding author on
            reasonable request. Restrictions apply to the underlying imaging, which cannot be
            shared publicly under the terms of the institutional approval. Analysis code is
            available at the repository named in the Methods.
            
        • portal_dropped
          • acknowledgements.txt 131 B
            We thank the reporting radiologists who took part. This work was supported by the
            Synthetic Research Council under grant SRC-0000.
            
          • author_contributions.txt 174 B
            J.D. and A.R. designed the study and wrote the manuscript. S.P. curated the data and
            performed the statistical analysis. All authors reviewed and approved the final version.
            
          • data_availability.txt 293 B
            The de-identified analysis dataset is available from the corresponding author on
            reasonable request. Restrictions apply to the underlying imaging, which cannot be
            shared publicly under the terms of the institutional approval. Analysis code is
            available at the repository named in the Methods.
            
        • portal_reflowed
          • acknowledgements.txt 243 B
            We thank the reporting radiologists who took part.  This work was supported by the Synthetic Research Council under grant SRC-0000.  The funder had no role in study design, data collection, analysis, interpretation, or the decision to submit.
            
          • author_contributions.txt 231 B
            Jane Doe and Alex Roe contributed equally to this work.
            
            J.D. and A.R. designed the study and wrote the manuscript.
            S.P. curated the data and performed the statistical analysis.
            All authors reviewed and approved the final version.
            
          • competing_interests.txt 71 B
            The authors declare no competing financial or non-financial interests.
            
          • data_availability.txt 293 B
            The de-identified analysis dataset is available from the corresponding author on reasonable request. Restrictions apply to the underlying imaging, which cannot be shared publicly under the terms of the institutional approval. Analysis code is available at the repository named in the Methods.
            
        • manuscript.md 1.2 KB
          # Deferred release of automated advice in a reporting workflow
          
          *Synthetic manuscript. Not a real study.*
          
          Jane Doe^1†^, Alex Roe^2†^, Sam Poe^1^
          
          † Jane Doe and Alex Roe contributed equally to this work.
          
          ## Abstract
          
          We report a single-centre workflow study of deferred automated advice.
          
          ## Methods
          
          Readers entered a provisional impression before the automated result was released.
          
          ## Data Availability
          
          The de-identified analysis dataset is available from the corresponding author on
          reasonable request. Restrictions apply to the underlying imaging, which cannot be
          shared publicly under the terms of the institutional approval. Analysis code is
          available at the repository named in the Methods.
          
          ## Acknowledgements
          
          We thank the reporting radiologists who took part. This work was supported by the
          Synthetic Research Council under grant SRC-0000. The funder had no role in study
          design, data collection, analysis, interpretation, or the decision to submit.
          
          ## Author Contributions
          
          J.D. and A.R. designed the study and wrote the manuscript. S.P. curated the data and
          performed the statistical analysis. All authors reviewed and approved the final version.
          
          ## Competing Interests
          
          The authors declare no competing financial or non-financial interests.
          
      • verify.sh 6.6 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the portal-mirror challenge card.
        #
        # The contract this gate enforces is printed on the submission form itself. SNAPP says, at
        # Author Contributions, Competing Interests, Data Availability and Acknowledgements:
        #
        #     "This replaces any statement written within the manuscript and is the one that we will
        #      publish."
        #
        # So the manuscript is the copy reviewers read and the portal box is the copy the world gets,
        # and a sentence that never reaches the box is never published. Nothing warns you, because
        # nothing is wrong with either document on its own.
        #
        # The positive fixture reproduces the two real near-losses:
        #   an Acknowledgements box pasted without "The funder had no role in study design…"
        #     -> PORTAL_FIELD_NOT_MIRRORED
        #   a title-page dagger footnote naming two co-first authors, and an Author Contributions box
        #     that does not repeat it. There is no equal-contribution checkbox anywhere in the portal.
        #     -> EQUAL_CONTRIBUTION_NOT_IN_PORTAL
        #   plus a replacing field with no paste artifact at all
        #     -> PORTAL_FIELD_MISSING
        #
        # The negatives are what decide whether the gate is usable:
        #   a complete paste                            -> silent
        #   a RE-FLOWED paste (same sentences, rewrapped, blank lines moved) -> silent. This is the
        #     one that matters: authors re-wrap when they paste, and a substring test would report
        #     text the author DID paste as dropped.
        #   a journal whose portal contract was never recorded -> asserts nothing, exits 2. The gate
        #     may not invent a contract it was not told.
        #   --emit -> check must come out CLEAN, which is the whole point: the author never
        #     hand-composes the box, and hand-composition is how both sentences above were lost.
        set -uo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        DET="$HERE/../check_portal_mirror.py"
        FIX="$HERE/fixture"
        PROFILE="$HERE/../../../write-paper/references/journal_profiles/npj_Digital_Medicine.md"
        
        TMP="$(mktemp -d)"
        trap 'rm -rf "$TMP"' EXIT
        
        pass=0; fail=0
        ck() { if [ "$2" = "$3" ]; then printf '  PASS  %-56s exit=%s\n' "$1" "$3"; pass=$((pass+1));
               else printf '  FAIL  %-56s want=%s got=%s\n' "$1" "$2" "$3"; fail=$((fail+1)); fi; }
        has() { if echo "$2" | grep -q "$3"; then ck "$1" 0 0; else ck "$1" 0 1; fi; }
        hasnt() { if echo "$2" | grep -q "$3"; then ck "$1" 0 1; else ck "$1" 0 0; fi; }
        run() { python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" "$@" 2>&1; }
        
        echo "== the journal contract is read from the profile, not guessed =="
        OUT="$(run --portal-dir "$FIX/portal_complete")"
        has "all four replacing fields are found" "$OUT" "4 replacing field(s)"
        
        echo "== positive: declarations that never reach the box that replaces them =="
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" \
          --portal-dir "$FIX/portal_dropped" --strict >/dev/null 2>&1
        ck "a dropped declaration -> --strict fails" 1 "$?"
        # ...and WITHOUT --strict too. preflight_gate reads this exit code and never passes --strict,
        # so a --strict-gated exit reports a dropped declaration as a clean check. It did, once.
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" \
          --portal-dir "$FIX/portal_dropped" --quiet >/dev/null 2>&1
        ck "and fails WITHOUT --strict (preflight reads this)" 1 "$?"
        OUT="$(run --portal-dir "$FIX/portal_dropped")"
        has "the funder-role sentence is named"    "$OUT" "PORTAL_FIELD_NOT_MIRRORED"
        has "the missing field is named"           "$OUT" "PORTAL_FIELD_MISSING"
        has "co-first authorship is named"         "$OUT" "EQUAL_CONTRIBUTION_NOT_IN_PORTAL"
        # the whole sentence, not a clause: a hard-wrapped sentence must not be reported in halves
        has "the sentence is quoted intact"        "$OUT" "decision to submit"
        hasnt "and is reported once, not per line" "$OUT" "\"The funder had no role in study\"\."
        
        echo "== negative: a complete paste, and a RE-FLOWED one =="
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" \
          --portal-dir "$FIX/portal_complete" --strict >/dev/null 2>&1
        ck "a complete paste -> silent" 0 "$?"
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" \
          --portal-dir "$FIX/portal_reflowed" --strict >/dev/null 2>&1
        ck "a re-flowed paste -> silent (the FP that would kill it)" 0 "$?"
        OUT="$(run --portal-dir "$FIX/portal_reflowed")"
        has   "and it really did examine them"     "$OUT" "checked 10 sentence"
        hasnt "with no invented loss"              "$OUT" "NOT_MIRRORED"
        
        echo "== author initials are not sentence ends =="
        # Author Contributions is the one section guaranteed to be written in initials. If "J.D." ends
        # a sentence, the section splits into fragments too short to judge and a real omission is
        # reported against a clause with no subject.
        OUT="$(python3 - "$HERE/.." "$FIX/manuscript.md" <<'PY_EOF'
        import sys, pathlib
        sys.path.insert(0, sys.argv[1])
        from check_portal_mirror import find_section, statements
        md = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")
        for s in statements(find_section(md, "author_contributions")):
            print(s)
        PY_EOF
        )"
        ck "the contributions section is 3 sentences" 3 "$(echo "$OUT" | grep -c .)"
        has "and initials stay with their clause"    "$OUT" "^J.D. and A.R. designed"
        
        echo "== a journal with no recorded portal contract asserts nothing =="
        cat > "$TMP/no_mechanics.md" <<'EOF'
        # Journal of Synthetic Submissions
        
        ## Special Notes
        
        - Nothing about the portal has been recorded yet.
        EOF
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$TMP/no_mechanics.md" \
          --portal-dir "$FIX/portal_dropped" --strict >/dev/null 2>&1
        ck "no Portal Mechanics block -> skipped, not invented" 2 "$?"
        
        echo "== --emit produces a scaffold that passes its own gate =="
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" --emit "$TMP/emitted" >/dev/null 2>&1
        ck "--emit succeeds" 0 "$?"
        OUT="$(cat "$TMP/emitted/author_contributions.txt")"
        has "the title-page equal-contribution line is lifted in" "$OUT" "contributed equally"
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" \
          --portal-dir "$TMP/emitted" --strict >/dev/null 2>&1
        ck "emit -> check round-trips clean" 0 "$?"
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" --emit "$TMP/emitted" 2>&1 \
          | grep -q "left alone" && ck "--emit will not clobber an edited file" 0 0 \
          || ck "--emit will not clobber an edited file" 0 1
        
        echo "== the artifact names its own author =="
        python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROFILE" \
          --portal-dir "$FIX/portal_dropped" --out "$TMP/report.json" >/dev/null 2>&1
        has "qc JSON carries the detector key" "$(cat "$TMP/report.json")" '"detector": "check_portal_mirror"'
        
        echo
        printf 'portal-mirror challenge: %d passed, %d failed\n' "$pass" "$fail"
        [ "$fail" -eq 0 ] || exit 1
        
    • assemble_supplement.py 8.8 KB
      #!/usr/bin/env python3
      """Supplement assembler + structural validator (cohort/SR supplements).
      
      Cohort and SR/MA supplements are a directory of per-section `S{N}_*.md` files +
      an `00_index.md`, hand-concatenated into `_combined.md` and re-rendered. Adding
      and extending sections across revisions desynchronizes the set: a declared `S{N}`
      with no file (or a file with no index row), a duplicate `S{N}`, or a skipped
      sub-section number after an insert (`S6.3` then `S6.5`, no `S6.4`). This script
      validates that structure and rebuilds `_combined.md` in index order so the
      assembly is reproducible rather than hand-maintained.
      
      NOT an integrity detector — deliberately named `assemble_supplement.py` (not
      check_/detect_/derive_) so the catalog glob does not count it. It is a build/QA
      helper, run before a submission package is frozen.
      
      INPUTS
        --dir         supplement directory holding `S{N}_*.md` (or `supplement_S{N}_*`/
                      `suppl_S{N}_*`) section files and an index (`00_index.md` by
                      default; override with --index).
        --index       index filename within --dir (default 00_index.md).
        --manuscript  optional manuscript .md; cross-checks which `Supplementary
                      (Table|Figure|Material|Methods…) N` / `S{N}` are cited in the body
                      (coverage: uncited sections + body callouts with no section file).
        --out         optional path to write the rebuilt `_combined.md` (index order).
      
      OUTPUT
        stdout report and, with --json, a JSON artifact:
          {dir, declared[], present[], problems[{kind, detail}], coverage{...}, summary}
        problem kinds: INDEX_WITHOUT_FILE, FILE_WITHOUT_INDEX, DUPLICATE_SECTION,
        SUBSECTION_GAP, CALLOUT_WITHOUT_SECTION, SECTION_UNCITED.
        Exit 1 (with --strict) when any STRUCTURAL problem exists (the first four kinds;
        coverage findings are advisory and never fail --strict).
      
      Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean (or report-only),
      1 structural problem(s) with --strict, 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      FILE_RE = re.compile(r"^(?:supplement_|suppl_)?S(\d+)(?:[_.]|$)", re.IGNORECASE)
      # Top-level S-number tokens in the index (S1, S2 …), not sub-sections (S6.3).
      INDEX_TOKEN_RE = re.compile(r"\bS(\d+)\b(?!\.\d)")
      # Sub-section headings inside a file: "## S6.3 …", "### **S6.4** …".
      SUBSEC_RE = re.compile(r"^#{1,4}\s*\*{0,2}\s*S(\d+)\.(\d+)\b", re.IGNORECASE | re.MULTILINE)
      STRUCTURAL = {"INDEX_WITHOUT_FILE", "FILE_WITHOUT_INDEX", "DUPLICATE_SECTION", "SUBSECTION_GAP"}
      # Body callouts: "Supplementary Table S3", "Supplementary Methods 2", "Table S3", "§S3", "(S3)".
      CALLOUT_RE = re.compile(
          r"(?:Supplementary\s+(?:Table|Figure|Material|Methods|Appendix|Note|Data|File)s?\s*|"
          r"(?:Table|Figure|Fig\.?)\s+|§\s*|\(\s*)S(\d+)\b", re.IGNORECASE)
      
      
      def scan_files(d: Path) -> dict:
          """Map S-number -> [filenames] for section files (excludes the index/_combined)."""
          out: dict[int, list[str]] = {}
          for p in sorted(d.glob("*.md")):
              if p.name.startswith("00_") or p.name.startswith("_combined"):
                  continue
              m = FILE_RE.match(p.name)
              if m:
                  out.setdefault(int(m.group(1)), []).append(p.name)
          return out
      
      
      def declared_order(index_text: str) -> list[int]:
          """S-numbers in the order they first appear in the index."""
          seen, order = set(), []
          for m in INDEX_TOKEN_RE.finditer(index_text):
              n = int(m.group(1))
              if n not in seen:
                  seen.add(n)
                  order.append(n)
          return order
      
      
      def subsection_gaps(text: str, n: int) -> list[str]:
          subs = sorted({int(b) for a, b in SUBSEC_RE.findall(text) if int(a) == n})
          if len(subs) < 2:
              return []
          gaps = [s for s in range(subs[0], subs[-1] + 1) if s not in subs]
          return [f"S{n}.{g}" for g in gaps]
      
      
      def analyze(d: Path, index_name: str, manuscript: Path | None, out_path: Path | None) -> dict:
          if not d.is_dir():
              sys.stderr.write(f"ERROR: --dir not found: {d}\n")
              sys.exit(2)
          index_path = d / index_name
          if not index_path.is_file():
              sys.stderr.write(f"ERROR: index not found: {index_path}\n")
              sys.exit(2)
          index_text = index_path.read_text(encoding="utf-8")
          declared = declared_order(index_text)
          files = scan_files(d)
          present = sorted(files)
      
          problems = []
          for n in declared:
              if n not in files:
                  problems.append({"kind": "INDEX_WITHOUT_FILE",
                                   "detail": f"index declares S{n} but no S{n}_*.md file exists"})
          for n in present:
              if n not in declared:
                  problems.append({"kind": "FILE_WITHOUT_INDEX",
                                   "detail": f"file(s) {files[n]} present for S{n} but the index does not list it"})
              if len(files[n]) > 1:
                  problems.append({"kind": "DUPLICATE_SECTION",
                                   "detail": f"S{n} has {len(files[n])} files: {files[n]}"})
          for n in present:
              text = (d / files[n][0]).read_text(encoding="utf-8")
              for g in subsection_gaps(text, n):
                  problems.append({"kind": "SUBSECTION_GAP",
                                   "detail": f"{files[n][0]}: sub-section {g} is missing (numbering gap after an insert)"})
      
          coverage = {}
          if manuscript is not None:
              if not manuscript.is_file():
                  sys.stderr.write(f"ERROR: --manuscript not found: {manuscript}\n")
                  sys.exit(2)
              body = manuscript.read_text(encoding="utf-8")
              cited = sorted({int(m.group(1)) for m in CALLOUT_RE.finditer(body)})
              uncited = [n for n in present if n not in cited]
              callout_no_section = [n for n in cited if n not in files]
              coverage = {"cited": cited, "uncited_sections": uncited,
                          "callout_without_section": callout_no_section}
              for n in callout_no_section:
                  problems.append({"kind": "CALLOUT_WITHOUT_SECTION",
                                   "detail": f"body cites Supplementary S{n} but no S{n}_*.md section exists"})
              for n in uncited:
                  problems.append({"kind": "SECTION_UNCITED",
                                   "detail": f"S{n} section file exists but is never cited in the manuscript body"})
      
          rebuilt = None
          if out_path is not None:
              parts = []
              for n in declared:
                  if n in files:
                      parts.append((d / files[n][0]).read_text(encoding="utf-8").rstrip())
              rebuilt = "\n\n---\n\n".join(parts) + "\n"
              out_path.parent.mkdir(parents=True, exist_ok=True)
              out_path.write_text(rebuilt, encoding="utf-8")
      
          n_structural = sum(1 for p in problems if p["kind"] in STRUCTURAL)
          return {
              "dir": str(d),
              "declared": declared,
              "present": present,
              "problems": problems,
              "coverage": coverage,
              "rebuilt_to": str(out_path) if out_path else None,
              "summary": {"n_problems": len(problems), "n_structural": n_structural,
                          "verdict": "STRUCTURAL_PROBLEM" if n_structural else "OK"},
          }
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Supplement assembler + structural validator.")
          ap.add_argument("--dir", required=True, help="supplement directory")
          ap.add_argument("--index", default="00_index.md", help="index filename (default 00_index.md)")
          ap.add_argument("--manuscript", help="manuscript .md for callout coverage")
          ap.add_argument("--out", help="write rebuilt _combined.md to this path (index order)")
          ap.add_argument("--json", help="write JSON artifact to this path")
          ap.add_argument("--strict", action="store_true", help="exit 1 on any structural problem")
          ap.add_argument("--quiet", action="store_true", help="suppress stdout report")
          args = ap.parse_args()
      
          result = analyze(Path(args.dir), args.index,
                           Path(args.manuscript) if args.manuscript else None,
                           Path(args.out) if args.out else None)
      
          if not args.quiet:
              print("=" * 41)
              print(" Supplement Assembly / Structure")
              print("=" * 41)
              print(f"declared (index): {result['declared']}")
              print(f"present (files):  {result['present']}")
              for p in result["problems"]:
                  print(f"  [{p['kind']}] {p['detail']}")
              if result["rebuilt_to"]:
                  print(f"rebuilt _combined → {result['rebuilt_to']}")
              s = result["summary"]
              print(f"\n{'STRUCTURAL PROBLEM: ' + str(s['n_structural']) if s['n_structural'] else 'OK: supplement structure consistent.'}")
      
          if args.json:
              Path(args.json).parent.mkdir(parents=True, exist_ok=True)
              Path(args.json).write_text(json.dumps(result, indent=2), encoding="utf-8")
              if not args.quiet:
                  print(f"wrote {args.json}")
      
          return 1 if (args.strict and result["summary"]["n_structural"]) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • author_registry_example.yaml 1.4 KB
      # Author registry — example for blind_sweep.py
      #
      # Each reviewer entry maps an author's identifiers to their double-blind role label
      # (e.g., "Reviewer 1", "Reviewer 2"). The blind_sweep.py script reads this YAML
      # and performs phased substitution across supplementary, cover, and response files.
      #
      # Place a project-local copy under your manuscript root (e.g.,
      # `<project>/_shared/authors/author_registry.yaml`) and pass its path to the script.
      # Do NOT commit real author identifiers to a public repository — keep the populated
      # registry alongside the manuscript, not inside this skill.
      
      reviewers:
        - role: "Reviewer 1"
          full_names: ["Given Surname"]              # roman script, primary author
          native_names: []                            # e.g., ["성명"] for hangul / kanji
          initials_with_period: ["G.S."]
          initials_no_period: ["GS"]
          email: "first.author@example.org"
          orcid: "0000-0000-0000-0000"
      
        - role: "Reviewer 2"
          full_names: ["Coauthor Name"]
          native_names: []
          initials_with_period: ["C.N."]
          initials_no_period: ["CN"]
          email: null
          orcid: null
      
      institutions:
        - replace: "Hospital A / University B"
          with: "the review team's affiliated institutions"
      
      references:
        # Self-cited PROSPERO record or preprint
        - replace: "Given Surname, Coauthor Name. Title of Registered Review"
          with: "[Authors]. Title of Registered Review"
      
    • blind_sweep.py 7.7 KB
      #!/usr/bin/env python3
      """Blind sweep — redact author identifiers across submission artifacts for double-blind review.
      
      Usage:
        python blind_sweep.py --registry path/to/author_registry.yaml \
                              --files file1.md file2.md ... \
                              [--inplace | --out-dir staging/blinded]
      
      Reads an author registry (YAML) describing identifiers and their role-label
      replacements, then performs phased substitution (specific patterns first,
      then bare forms, then regex word-boundary forms). Reports residual identifier
      counts for each file after blinding.
      
      Registry schema (YAML):
        reviewers:
          - role: "Reviewer 1"
            full_names: ["Given Surname"]      # roman script
            native_names: ["성명"]              # native script (e.g., hangul)
            initials_with_period: ["G.S."]
            initials_no_period: ["GS"]
            email: "user@example.com"
            orcid: "0000-0000-0000-0000"
          - role: "Reviewer 2"
            ...
        institutions:
          - replace: "Institution A / University B"
            with: "the review team's affiliated institutions"
        references:
          - replace: "Given Surname, Other Author. Title"
            with: "[Authors]. Title"
      
      Order of substitution (per file):
        1. Institution and reference patterns (longest specific strings first).
        2. Role-combination patterns (e.g., "Given Surname (GS, 1st reviewer)").
        3. Bare full names and native names.
        4. Regex word-boundary patterns for initials (period and no-period forms),
           emails, ORCIDs, and combined initial patterns.
      
      The script does NOT hard-code any author identifiers — all PII is sourced
      from the registry the caller provides. This keeps the tool PII-free for OSS
      distribution.
      """
      from __future__ import annotations
      
      import argparse
      import pathlib
      import re
      import shutil
      import sys
      from dataclasses import dataclass, field
      from typing import Iterable
      
      try:
          import yaml  # type: ignore
      except ImportError:
          print("blind_sweep requires PyYAML. Install: pip install pyyaml", file=sys.stderr)
          sys.exit(2)
      
      
      @dataclass
      class Reviewer:
          role: str
          full_names: list[str] = field(default_factory=list)
          native_names: list[str] = field(default_factory=list)
          initials_with_period: list[str] = field(default_factory=list)
          initials_no_period: list[str] = field(default_factory=list)
          email: str | None = None
          orcid: str | None = None
      
      
      def load_registry(path: pathlib.Path) -> tuple[list[Reviewer], list[tuple[str, str]], list[tuple[str, str]]]:
          data = yaml.safe_load(path.read_text(encoding="utf-8"))
          reviewers = [Reviewer(**r) for r in data.get("reviewers", [])]
          institutions = [(i["replace"], i["with"]) for i in data.get("institutions", [])]
          references = [(r["replace"], r["with"]) for r in data.get("references", [])]
          return reviewers, institutions, references
      
      
      def build_substitutions(reviewers: list[Reviewer]) -> tuple[list[tuple[str, str]], list[tuple[str, str]], list[tuple[str, str]]]:
          """Return (phase2_combined, phase3_bare, phase4_regex)."""
          phase2_combined: list[tuple[str, str]] = []
          phase3_bare: list[tuple[str, str]] = []
          phase4_regex: list[tuple[str, str]] = []
      
          for r in reviewers:
              for name in r.full_names:
                  for init in r.initials_no_period + r.initials_with_period:
                      for label in (
                          f"{name} ({init}, 1st reviewer)", f"{name} ({init}, 2nd reviewer)",
                          f"{name} ({init}, 1st)", f"{name} ({init}, 2nd)",
                          f"{name} ({init})",
                      ):
                          phase2_combined.append((label, r.role))
                  phase3_bare.append((name, r.role))
              for name in r.native_names:
                  phase3_bare.append((name, r.role))
              for init in r.initials_with_period:
                  esc = re.escape(init)
                  phase4_regex.append((rf"\b{esc}", r.role))
              for init in r.initials_no_period:
                  phase4_regex.append((rf"\b{re.escape(init)}\b", r.role))
              if r.email:
                  phase4_regex.append((re.escape(r.email), "[redacted email]"))
              if r.orcid:
                  phase4_regex.append((re.escape(r.orcid), "[redacted ORCID]"))
      
          # Sort phase2/phase3 by length desc so longer-specific patterns win
          phase2_combined.sort(key=lambda kv: -len(kv[0]))
          phase3_bare.sort(key=lambda kv: -len(kv[0]))
          return phase2_combined, phase3_bare, phase4_regex
      
      
      def blind_text(text: str, phase1: Iterable[tuple[str, str]],
                     phase2: Iterable[tuple[str, str]], phase3: Iterable[tuple[str, str]],
                     phase4_regex: Iterable[tuple[str, str]]) -> tuple[str, int]:
          changes = 0
          for old, new in list(phase1) + list(phase2) + list(phase3):
              if old in text:
                  count = text.count(old)
                  text = text.replace(old, new)
                  changes += count
          for pat, repl in phase4_regex:
              matches = re.findall(pat, text)
              if matches:
                  text = re.sub(pat, repl, text)
                  changes += len(matches)
          return text, changes
      
      
      def residual_scan(text: str, reviewers: list[Reviewer]) -> list[str]:
          residual: list[str] = []
          for r in reviewers:
              for name in r.full_names + r.native_names:
                  c = text.count(name)
                  if c > 0:
                      residual.append(f"{name}={c}")
              for init in r.initials_with_period:
                  c = len(re.findall(rf"\b{re.escape(init)}", text))
                  if c > 0:
                      residual.append(f"{init}={c}")
              for init in r.initials_no_period:
                  c = len(re.findall(rf"\b{re.escape(init)}\b", text))
                  if c > 0:
                      residual.append(f"{init}={c}")
              if r.email:
                  c = text.count(r.email)
                  if c > 0:
                      residual.append(f"{r.email}={c}")
              if r.orcid:
                  c = text.count(r.orcid)
                  if c > 0:
                      residual.append(f"{r.orcid}={c}")
          return residual
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          ap.add_argument("--registry", required=True, type=pathlib.Path, help="Author registry YAML")
          ap.add_argument("--files", nargs="+", required=True, type=pathlib.Path, help="Files to blind")
          ap.add_argument("--inplace", action="store_true", help="Overwrite in place (default)")
          ap.add_argument("--out-dir", type=pathlib.Path, help="Write blinded copies under this directory")
          ap.add_argument("--backup-dir", type=pathlib.Path, help="Copy originals here before in-place edit")
          args = ap.parse_args()
      
          if not args.inplace and not args.out_dir:
              args.inplace = True
      
          reviewers, institutions, references = load_registry(args.registry)
          phase1 = institutions + references
          phase2, phase3, phase4_regex = build_substitutions(reviewers)
      
          if args.backup_dir:
              args.backup_dir.mkdir(parents=True, exist_ok=True)
          if args.out_dir:
              args.out_dir.mkdir(parents=True, exist_ok=True)
      
          print(f"{'FILE':<55} {'CHANGES':<8} RESIDUAL")
          overall_residual = False
          for src in args.files:
              if not src.exists():
                  print(f"{src.name:<55} {'-':<8} MISSING")
                  continue
              text = src.read_text(encoding="utf-8")
              if args.backup_dir:
                  shutil.copy(src, args.backup_dir / src.name)
              new_text, changes = blind_text(text, phase1, phase2, phase3, phase4_regex)
              residual = residual_scan(new_text, reviewers)
      
              if args.out_dir:
                  (args.out_dir / src.name).write_text(new_text, encoding="utf-8")
              else:
                  src.write_text(new_text, encoding="utf-8")
      
              flag = "clean" if not residual else " ".join(residual) + "  WARN"
              if residual:
                  overall_residual = True
              print(f"{src.name:<55} {changes:<8} {flag}")
      
          return 1 if overall_residual else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • build_marked_manuscript.py 8 KB
      #!/usr/bin/env python3
      """Build a marked (tracked-changes) manuscript by driving Microsoft Word's own
      Compare, then prove the result with `check_marked_manuscript.py`.
      
          build_marked_manuscript.py --original R0.docx --revised v8_clean.docx \\
              --out marked.docx --author "Submitting Author" [--line-numbers]
      
      WHY WORD. `pandiff` and LibreOffice `--compare` corrupt OOXML on real
      manuscripts — tables collapse and affiliation superscripts are lost. Word's
      Compare is the only producer safe enough for a submission. It does *not* follow
      that a human must click through it: Word for Mac's AppleScript dictionary
      exposes `compare` with `author name`, `detect format changes` and `ignore all
      comparison warnings`, so the whole build is scriptable and every revision is
      attributed correctly at source — no post-hoc rewriting of `w:author`.
      
      Two traps that defeat naive automation, both handled here:
      
        1. SANDBOX. `save as` to a *new* path makes Word raise a modal "Grant File
           Access" sheet, and AppleScript then blocks until a human dismisses it — the
           script appears to hang. Avoided by seeding the destination with a copy of
           the original, letting Word OPEN that file (Word may always write a file it
           opened itself), comparing in place, and calling a plain `save`.
      
        2. OTHER DOCUMENTS. The user may have unrelated documents open in Word. Only
           the document this script opened is closed, by name.
      
      This is a macOS + Microsoft Word tool and is therefore NOT a portable detector:
      it is deliberately excluded from the detector catalog. The verification half —
      `check_marked_manuscript.py` — is stdlib-only, runs anywhere, and can audit a
      marked file produced by any means (including a Word GUI pass).
      """
      
      from __future__ import annotations
      
      import argparse
      import platform
      import re
      import shutil
      import subprocess
      import sys
      import zipfile
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).resolve().parent))
      from check_marked_manuscript import check  # noqa: E402
      
      APPLESCRIPT = """
      with timeout of {timeout} seconds
        tell application "Microsoft Word"
          open POSIX file "{out}"
          set d to active document
          compare d path "{revised}" author name "{author}" ¬
            target compare target current ¬
            detect format changes false ¬
            ignore all comparison warnings true
          delay 3
          save d
          delay 2
          close d saving no
          return "ok"
        end tell
      end timeout
      """
      
      
      def _as_literal(s: str) -> str:
          """Escape a value for interpolation into an AppleScript string literal."""
          return s.replace("\\", "\\\\").replace('"', '\\"')
      
      
      def run_compare(original: Path, revised: Path, out: Path, author: str, timeout: int) -> None:
          if platform.system() != "Darwin":
              raise SystemExit(
                  "build_marked_manuscript.py drives Microsoft Word via AppleScript and runs on "
                  "macOS only. Produce the marked file with Word's Compare on a Mac (or by hand), "
                  "then verify it anywhere with check_marked_manuscript.py."
              )
      
          # Seed the destination with the original so Word opens — and may therefore write — it.
          #
          # That seeding is why every failure below must delete `out`. A Compare that dies leaves this
          # copy behind: a plausible .docx of plausible size, carrying zero tracked changes, sitting at
          # exactly the path the user asked the marked manuscript to be written to. Observed on an AJNR
          # major revision, where a near-total rewrite blew past the old 180-second default and the
          # AppleEvent failed -1712 — the run reported the failure, but the file it left was
          # indistinguishable by inspection from a marked manuscript with nothing to mark.
          shutil.copyfile(original, out)
      
          def _abandon(message: str) -> "SystemExit":
              out.unlink(missing_ok=True)
              return SystemExit(message)
      
          script = APPLESCRIPT.format(
              timeout=timeout,
              out=_as_literal(str(out)),
              revised=_as_literal(str(revised)),
              author=_as_literal(author),
          )
          try:
              p = subprocess.run(
                  ["osascript", "-e", script], capture_output=True, text=True, timeout=timeout + 30
              )
          except subprocess.TimeoutExpired:
              raise _abandon(
                  "Word did not respond. It is most likely showing a modal sheet — check for a "
                  '"Grant File Access" dialog and dismiss it, then re-run. '
                  f"({out.name} was removed; it held no comparison.)"
              )
          if p.returncode != 0:
              err = p.stderr.strip()
              hint = ""
              if "-1712" in err or "timed out" in err.lower():
                  hint = (
                      f"\nThat is the AppleEvent timeout: Compare needed longer than --timeout "
                      f"({timeout}s). A whole-manuscript revision routinely does. Re-run with a "
                      f"larger --timeout."
                  )
              raise _abandon(f"Word Compare failed: {err}{hint}\n({out.name} was removed.)")
      
      
      def inject_line_numbers(path: Path) -> None:
          """Continuous line numbers — most journals require them on a revision."""
          ln = '<w:lnNumType w:countBy="1" w:restart="continuous"/>'
          tmp = path.with_suffix(".tmp.docx")
          with zipfile.ZipFile(path) as zin, zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
              for item in zin.namelist():
                  data = zin.read(item)
                  if item == "word/document.xml":
                      xml = data.decode("utf-8")
                      if "w:lnNumType" not in xml:
                          xml, n = re.subn(r"<w:pgMar\b[^>]*/>", lambda m: m.group(0) + ln, xml)
                          if n == 0:
                              xml = xml.replace("</w:sectPr>", ln + "</w:sectPr>")
                      data = xml.encode("utf-8")
                  zout.writestr(item, data)
          shutil.move(str(tmp), str(path))
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(
              description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
          )
          ap.add_argument(
              "--original",
              required=True,
              type=Path,
              help="baseline = the version the reviewers saw (R0), NOT the previous round's clean copy",
          )
          ap.add_argument("--revised", required=True, type=Path, help="the new clean manuscript")
          ap.add_argument("--out", required=True, type=Path, help="marked (tracked-changes) file to write")
          ap.add_argument(
              "--author", required=True, help="name to attribute every revision to (the submitting author)"
          )
          ap.add_argument("--line-numbers", action="store_true", help="inject continuous line numbering")
          # 180 was the old default and it is not enough. A major revision is measured in whole
          # sections moved, not sentences edited, and Word's Compare on one (149 paragraphs and no
          # tables against 193 paragraphs and two) ran past 180s and failed; the same pair completed
          # in well under 600. Waiting is cheap here — the cost of the low default was a failed run
          # and a file that looked like a result.
          ap.add_argument(
              "--timeout",
              type=int,
              default=600,
              help="seconds Word may spend comparing (default 600; a whole-manuscript revision needs "
                   "minutes, and the old 180 failed on one)",
          )
          a = ap.parse_args()
      
          for f in (a.original, a.revised):
              if not f.is_file():
                  raise SystemExit(f"not found: {f}")
          a.out.parent.mkdir(parents=True, exist_ok=True)
      
          run_compare(a.original.resolve(), a.revised.resolve(), a.out.resolve(), a.author, a.timeout)
          if a.line_numbers:
              inject_line_numbers(a.out)
      
          findings, summary = check(a.out, a.original, a.revised, a.author)
          m = summary["revision_marks"]
          print(
              f"{a.out.name}: ins {m['ins']}, del {m['del']}, "
              f"moveTo {m['moveTo']}, moveFrom {m['moveFrom']}"
          )
          for f in findings:
              print(f"  [{f['severity'].upper()}] {f['verdict']}: {f['detail']}")
          if findings:
              raise SystemExit("\nverification FAILED — do not upload this file")
      
          print("  OK — accept-all == revised, reject-all == original; marked manuscript verified")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_asset_anonymization.py 14.6 KB
      #!/usr/bin/env python3
      """
      check_asset_anonymization.py — submission-stage asset/peripheral anonymization gate.
      
      Body-text anonymization is mature; the blind spot is *peripheral artifacts*. A
      double-anonymized submission was nearly broken because a flow-diagram figure,
      generated by a script with a hardcoded institution label, carried the real
      hospital name — invisible to any docx text scan. File metadata (docx
      `dc:creator`, PDF `/Author`) is a second blind spot.
      
      This detector scans a submission/project directory for four deterministic
      classes of leak:
      
        1. **figure-script hardcoded institution** — a figure-generating script
           (`figures/**/*.R|*.py`, or any `*.R|*.py` under a `figures*` dir) contains
           an institution-like token (Hospital / University / Medical Center / IRB /
           병원 / 의료원 …) or a name supplied via --names-file.
        2. **figure/asset PDF rendered text** — a figure PDF's extracted text carries
           an institution token or a --names-file name (de-anonymization risk). When a
           figure carries *any* rendered text, a `visual_check` advisory is emitted
           (text scanning cannot see rasterized labels).
        3. **document metadata author** — a `.docx` `dc:creator`/`cp:lastModifiedBy`
           or a `.pdf` Author/Creator is a real person/identifier (not empty / not a
           known tool).
        4. **docx embedded absolute path** — any XML part or relationship carries
           an absolute home-dir path (Unix or Windows), including drawing descriptions
           and custom properties. Pandoc can retain local CSL/bibliography source paths
           in `docProps/custom.xml` even when the rendered document contains no path.
           Use relative image paths and strip source metadata after citation processing.
      
      Severity:
        - `leak`   — metadata author present, or a --names-file name found anywhere.
        - `review` — institution-token hit, or a figure carries rendered text.
      
      Exit: 0 = clean, 1 = findings (any `leak`; also `review` under --strict),
      2 = usage/error. Degrades gracefully when poppler (pdftotext/pdfinfo) is absent:
      script-grep and docx-metadata checks still run; PDF text/metadata checks are
      reported as skipped (poppler_available:false) rather than silently passing.
      
      Patterns are generic — no real names are baked in. Supply institution/author
      names locally with --names-file (one per line); that file is never committed.
      
      Stdlib-only.
      
      Usage:
          python3 check_asset_anonymization.py --dir submission/ [--names-file names.txt]
              [--out qc/asset_anon.json] [--strict] [--quiet]
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import shutil
      import subprocess
      import sys
      import zipfile
      from dataclasses import dataclass, field, asdict
      from pathlib import Path
      
      # Generic institution / ethics-board tokens (English + Korean). No proper names.
      INSTITUTION_RE = re.compile(
          r"\b(?:Hospital|Hospitals|University|Universit[äe]t|College of Medicine|"
          r"School of Medicine|Faculty of Medicine|Medical Cent(?:er|re)|Health System|"
          r"Cancer Cent(?:er|re)|Infirmary|Institutional Review Board|"
          r"IRB(?:[\s.:#-]*(?:No|Number|#)|\s*approval)?)\b"
          r"|병원|의료원|의과대학|대학교|연구윤리",
          re.IGNORECASE,
      )
      
      # docx/pdf metadata authorship values that are NOT a real person (tools/blanks).
      TOOL_AUTHORS = (
          "unknown", "microsoft office user", "pandoc", "libreoffice", "writer",
          "openoffice", "word", "microsoft word", "google", "overleaf", "latex",
          "pdftex", "pdflatex", "xelatex", "lualatex", "quarto", "rmarkdown",
          "knitr", "wps", "author",
      )
      
      
      def _is_tool_author(value: str) -> bool:
          """True if a metadata author value is blank or a known tool (not a person)."""
          v = value.strip().lower()
          if not v:
              return True
          return any(v == t or v.startswith(t) for t in TOOL_AUTHORS)
      
      FIG_SCRIPT_GLOBS = ("*.R", "*.r", "*.py")
      DC_CREATOR_RE = re.compile(r"<dc:creator>([^<]*)</dc:creator>")
      LAST_MOD_RE = re.compile(r"<cp:lastModifiedBy>([^<]*)</cp:lastModifiedBy>")
      # Absolute home-dir path leaked into an OOXML attribute (e.g. a drawing's
      # <pic:cNvPr descr="/Users/<user>/.../fig.png">). pandoc embeds the source image
      # path as the picture description when given an absolute path; it carries the
      # username into the docx body XML, invisible to a rendered-text scan.
      DOCX_ABS_PATH_RE = re.compile(
          r'(/(?:Users|home)/[^\s<>"\']+|[A-Za-z]:[\\/]+Users[\\/]+[^\s<>"\']+)'
      )
      
      
      @dataclass
      class Finding:
          type: str
          severity: str  # "leak" | "review"
          path: str
          detail: str
      
      
      @dataclass
      class Report:
          findings: list[Finding] = field(default_factory=list)
          scanned: dict[str, int] = field(default_factory=dict)
          poppler_available: bool = False
          skipped: list[str] = field(default_factory=list)
      
          @property
          def has_leak(self) -> bool:
              return any(f.severity == "leak" for f in self.findings)
      
          def submission_safe(self, strict: bool) -> bool:
              if self.has_leak:
                  return False
              if strict and any(f.severity == "review" for f in self.findings):
                  return False
              return True
      
          def as_dict(self, strict: bool) -> dict:
              return {
                  "submission_safe": self.submission_safe(strict),
                  "strict": strict,
                  "poppler_available": self.poppler_available,
                  "scanned": self.scanned,
                  "skipped": self.skipped,
                  "summary": {
                      "leak": sum(1 for f in self.findings if f.severity == "leak"),
                      "review": sum(1 for f in self.findings if f.severity == "review"),
                  },
                  "findings": [asdict(f) for f in self.findings],
              }
      
      
      def _is_under_figures(p: Path) -> bool:
          return any(part.lower().startswith(("figure", "fig", "graphic")) for part in p.parts)
      
      
      def _load_names(names_file: Path | None) -> list[str]:
          if not names_file:
              return []
          out = []
          for line in names_file.read_text(encoding="utf-8", errors="replace").splitlines():
              s = line.strip()
              if s and not s.startswith("#"):
                  out.append(s)
          return out
      
      
      def _name_hits(text: str, names: list[str]) -> list[str]:
          low = text.lower()
          return [n for n in names if n.lower() in low]
      
      
      def _pdftotext(pdf: Path) -> str | None:
          try:
              r = subprocess.run(["pdftotext", "-q", str(pdf), "-"],
                                 capture_output=True, text=True, timeout=60)
              return r.stdout
          except Exception:
              return None
      
      
      def _pdf_author(pdf: Path) -> str | None:
          try:
              r = subprocess.run(["pdfinfo", str(pdf)], capture_output=True, text=True, timeout=30)
          except Exception:
              return None
          author = creator = ""
          for line in r.stdout.splitlines():
              if line.startswith("Author:"):
                  author = line.split(":", 1)[1].strip()
              elif line.startswith("Creator:"):
                  creator = line.split(":", 1)[1].strip()
          for v in (author, creator):
              if v and not _is_tool_author(v):
                  return v
          return None
      
      
      def _docx_authors(docx: Path) -> list[str]:
          try:
              with zipfile.ZipFile(docx) as z:
                  if "docProps/core.xml" not in z.namelist():
                      return []
                  core = z.read("docProps/core.xml").decode("utf-8", errors="replace")
          except Exception:
              return []
          vals = []
          for m in (*DC_CREATOR_RE.finditer(core), *LAST_MOD_RE.finditer(core)):
              v = m.group(1).strip()
              if v and not _is_tool_author(v):
                  vals.append(v)
          return vals
      
      
      def _docx_embedded_abs_paths(docx: Path) -> list[str]:
          """Home paths in OOXML content, custom properties, or relationships.
      
          A rendered-text scan misses custom CSL/bibliography properties, and a
          word/*.xml-only scan misses both docProps/ and external .rels targets.
          """
          hits: list[str] = []
          try:
              with zipfile.ZipFile(docx) as z:
                  parts = [n for n in z.namelist() if n.endswith((".xml", ".rels"))]
                  for name in parts:
                      xml = z.read(name).decode("utf-8", errors="replace")
                      for m in DOCX_ABS_PATH_RE.finditer(xml):
                          hits.append(m.group(1))
          except Exception:
              return []
          # de-dup, preserve order
          seen: set[str] = set()
          out = []
          for h in hits:
              if h not in seen:
                  seen.add(h)
                  out.append(h)
          return out
      
      
      def build_report(root: Path, names: list[str], poppler: bool) -> Report:
          rep = Report(poppler_available=poppler)
          scripts = docx_files = pdf_files = 0
      
          for p in sorted(root.rglob("*")):
              if not p.is_file() or "__pycache__" in p.parts:
                  continue
              suffix = p.suffix.lower()
              rel = str(p.relative_to(root))
      
              # 1. figure-generating scripts AND the config files that drive them.
              #
              # A figure builder does not have to be a script. `/make-figures` documents its STROBE
              # builder as `build_strobe_template.py --config figures/figure1_strobe.yaml`, and the first
              # box of a STROBE flow diagram is exactly where "Patients screened at <Hospital>" lives.
              # Scanning only .r/.py meant the identical institution string blocked in one file and
              # passed in the other:
              #
              #   figures/figure1_strobe.py    -> FAIL: institution-like token in figure script
              #   figures/figure1_strobe.yaml  -> PASS: no anonymization leak  ({'figure_scripts': 0})
              #
              # For a double-blind submission that is an anonymity leak which the anonymisation gate
              # declared clean. The builders in this repo parse YAML and JSON alike, so both are now read
              # the way the scripts already were — as text, line by line, which is all this check needs.
              if suffix in (".r", ".py", ".yaml", ".yml", ".json") and _is_under_figures(p):
                  scripts += 1
                  text = p.read_text(encoding="utf-8", errors="replace")
                  for i, line in enumerate(text.splitlines(), 1):
                      if INSTITUTION_RE.search(line):
                          rep.findings.append(Finding(
                              "figure_script_institution", "review", f"{rel}:{i}",
                              f"institution-like token in figure script: {line.strip()[:120]}"))
                      for n in _name_hits(line, names):
                          rep.findings.append(Finding(
                              "figure_script_name", "leak", f"{rel}:{i}",
                              f"name '{n}' hardcoded in figure script"))
      
              # 2 + 3. docx metadata
              elif suffix == ".docx":
                  docx_files += 1
                  for a in _docx_authors(p):
                      rep.findings.append(Finding(
                          "docx_metadata_author", "leak", rel,
                          f"docx author metadata: '{a}'"))
                  # 4. absolute home-dir path embedded in word/*.xml (e.g. pic descr)
                  for ap_ in _docx_embedded_abs_paths(p):
                      rep.findings.append(Finding(
                          "docx_embedded_abs_path", "leak", rel,
                          f"absolute path in docx XML (username leak; use a relative "
                          f"image path + pandoc --resource-path): {ap_}"))
      
              # PDFs: metadata + (figure) rendered-text
              elif suffix == ".pdf":
                  pdf_files += 1
                  if poppler:
                      a = _pdf_author(p)
                      if a:
                          rep.findings.append(Finding(
                              "pdf_metadata_author", "leak", rel, f"pdf author/creator: '{a}'"))
                      if _is_under_figures(p):
                          txt = _pdftotext(p)
                          if txt is None:
                              rep.skipped.append(f"pdftotext failed: {rel}")
                          elif txt.strip():
                              rep.findings.append(Finding(
                                  "figure_rendered_text", "review", rel,
                                  "figure PDF carries rendered text — visual-check for "
                                  "institution/IRB#/author labels a text scan cannot see"))
                              if INSTITUTION_RE.search(txt):
                                  rep.findings.append(Finding(
                                      "figure_text_institution", "review", rel,
                                      "institution-like token in figure PDF text"))
                              for n in _name_hits(txt, names):
                                  rep.findings.append(Finding(
                                      "figure_text_name", "leak", rel,
                                      f"name '{n}' in figure PDF text"))
                  else:
                      rep.skipped.append(f"poppler unavailable, PDF not scanned: {rel}")
      
          rep.scanned = {"figure_scripts": scripts, "docx": docx_files, "pdf": pdf_files}
          return rep
      
      
      def main(argv: list[str] | None = None) -> int:
          ap = argparse.ArgumentParser(
              description="Submission-stage asset/figure/metadata anonymization gate.")
          ap.add_argument("--dir", type=Path, default=Path.cwd(),
                          help="Root directory to scan (default: cwd).")
          ap.add_argument("--names-file", type=Path, default=None,
                          help="Newline-separated institution/author names to flag (local only).")
          ap.add_argument("--out", type=Path, default=None, help="Write JSON report here.")
          ap.add_argument("--strict", action="store_true",
                          help="Also fail on 'review' findings (institution tokens, rendered text).")
          ap.add_argument("--quiet", action="store_true", help="Suppress stdout summary.")
          args = ap.parse_args(argv)
      
          if not args.dir.is_dir():
              print(f"ERROR: --dir not a directory: {args.dir}", file=sys.stderr)
              return 2
          if args.names_file is not None and not args.names_file.is_file():
              print(f"ERROR: --names-file not a file: {args.names_file}", file=sys.stderr)
              return 2
      
          poppler = shutil.which("pdftotext") is not None and shutil.which("pdfinfo") is not None
          names = _load_names(args.names_file)
          rep = build_report(args.dir, names, poppler)
          safe = rep.submission_safe(args.strict)
      
          if args.out is not None:
              args.out.parent.mkdir(parents=True, exist_ok=True)
              args.out.write_text(json.dumps({"detector": "check_asset_anonymization", **rep.as_dict(args.strict)}, indent=2), encoding="utf-8")
      
          if not args.quiet:
              if not poppler:
                  print("NOTE: poppler (pdftotext/pdfinfo) not found — PDF text/metadata "
                        "checks skipped; install poppler-utils for full coverage.")
              if safe:
                  n = len(rep.findings)
                  print(f"PASS: no anonymization leak ({rep.scanned}; {n} advisory finding(s)).")
              else:
                  print(f"FAIL: anonymization findings — {rep.as_dict(args.strict)['summary']}")
                  for f in rep.findings:
                      print(f"  - [{f.severity}] {f.type} {f.path}: {f.detail}")
      
          return 0 if safe else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_checklist_dump_leak.py 8.6 KB
      #!/usr/bin/env python3
      """
      check_checklist_dump_leak.py — catch an internal audit dump that leaked into a
      reviewer-facing submission file.
      
      `/check-reporting` and `/self-review` emit an *internal audit* report: an
      item-by-item working document carrying auto-fix annotations, a raw JSON block
      (`compliance_pct`, `fixable_by_ai`, `check_reporting_version`), pipeline-log
      paths, and "Action Items". That report is a development artifact — it is NOT the
      official reporting checklist a journal expects ("Item | Recommendation | Reported
      in page/section").
      
      A near-miss: a prior project's `STROBE_checklist_v4.pdf` was actually the
      `/check-reporting` audit dump, reused by filename into a later submission and
      compiled into the reviewer-visible proof — exposing auto-fix notes, the raw JSON,
      and a stale old title. Body-text PII scans miss it (the tokens are tooling
      jargon, not names); the stale-checklist detector misses it (it checks version
      metadata, not whether the file is a dump). This detector closes that gap.
      
      It scans a submission directory for files whose extracted text carries
      audit-dump tokens that must never reach a reviewer:
      
        - check-reporting JSON keys: ``compliance_pct``, ``fixable_by_ai``,
          ``check_reporting_version``, ``checked_items``
        - auto-fix annotations: ``Auto-fix:``, ``auto-fixed``, ``[PARTIAL→auto-fixed]``,
          ``suggested_fix``
        - working-doc headers: ``Action Items``
        - pipeline/tooling paths: ``_pipeline_log``, ``qc/`` audit JSON references,
          ``check_reporting`` / ``check-reporting`` self-reference
        - from-memory / contract markers: ``NON-AUTHORITATIVE``,
          ``MISSING_CHECKLIST_CONTRACT_VIOLATION``
      
      Every hit is severity ``leak`` — these tokens are tooling output, never legitimate
      content of a submission-facing checklist or supplement. Exit 0 = clean, 1 =
      leak(s) found, 2 = usage/error. Degrades gracefully without poppler: .md/.txt and
      .docx are always scanned; .pdf is reported as skipped (poppler_available:false)
      rather than silently passing.
      
      Stdlib-only.
      
      Usage:
          python3 check_checklist_dump_leak.py --dir submission/ [--out qc/checklist_dump_leak.json] [--quiet]
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import shutil
      import subprocess
      import sys
      import zipfile
      from dataclasses import dataclass, field, asdict
      from pathlib import Path
      
      # Audit-dump tokens. Each is tooling output that must never appear in a
      # reviewer-facing file. Patterns are specific (underscore JSON keys, arrow
      # annotations, header phrases) to keep false positives near zero against a
      # legitimate official checklist ("Item | Recommendation | Reported in …").
      DUMP_PATTERNS: list[tuple[str, re.Pattern]] = [
          ("compliance_pct", re.compile(r"\bcompliance_pct\b")),
          ("fixable_by_ai", re.compile(r"\bfixable_by_ai\b")),
          ("check_reporting_version", re.compile(r"\bcheck_reporting_version\b")),
          ("checked_items_json", re.compile(r'"checked_items"\s*:')),
          ("auto_fix_annotation", re.compile(r"Auto-fix\s*:", re.IGNORECASE)),
          ("auto_fixed_marker", re.compile(r"auto-?fixed", re.IGNORECASE)),
          ("partial_autofix_marker", re.compile(r"\[\s*PARTIAL\s*[→\-]+\s*auto", re.IGNORECASE)),
          ("suggested_fix", re.compile(r"\bsuggested_fix\b")),
          ("action_items_header", re.compile(r"^#{0,6}\s*Action Items\b", re.IGNORECASE | re.MULTILINE)),
          ("pipeline_log_path", re.compile(r"_pipeline_log(?:\.md)?\b")),
          ("check_reporting_selfref", re.compile(r"\bcheck[_-]reporting\b", re.IGNORECASE)),
          ("non_authoritative", re.compile(r"\bNON-AUTHORITATIVE\b")),
          ("missing_checklist_contract", re.compile(r"\bMISSING_CHECKLIST_CONTRACT_VIOLATION\b")),
      ]
      
      TEXT_SUFFIXES = (".md", ".txt", ".markdown")
      
      
      @dataclass
      class Finding:
          type: str
          severity: str  # always "leak"
          path: str
          detail: str
      
      
      @dataclass
      class Report:
          findings: list[Finding] = field(default_factory=list)
          scanned: dict[str, int] = field(default_factory=dict)
          poppler_available: bool = False
          skipped: list[str] = field(default_factory=list)
      
          @property
          def has_leak(self) -> bool:
              return any(f.severity == "leak" for f in self.findings)
      
          def as_dict(self) -> dict:
              return {
                  "submission_safe": not self.has_leak,
                  "poppler_available": self.poppler_available,
                  "scanned": self.scanned,
                  "skipped": self.skipped,
                  "summary": {"leak": sum(1 for f in self.findings if f.severity == "leak")},
                  "findings": [asdict(f) for f in self.findings],
              }
      
      
      def _first_snippet(text: str, pat: re.Pattern) -> str:
          m = pat.search(text)
          if not m:
              return ""
          line_start = text.rfind("\n", 0, m.start()) + 1
          line_end = text.find("\n", m.end())
          if line_end == -1:
              line_end = len(text)
          return text[line_start:line_end].strip()[:140]
      
      
      def _scan_text(text: str, rel: str, rep: Report) -> None:
          for name, pat in DUMP_PATTERNS:
              if pat.search(text):
                  rep.findings.append(Finding(
                      "checklist_dump_token", "leak", rel,
                      f"audit-dump token '{name}' present (internal /check-reporting "
                      f"or /self-review output, not a submission checklist): "
                      f"{_first_snippet(text, pat)}"))
      
      
      def _pdftotext(pdf: Path) -> str | None:
          try:
              r = subprocess.run(["pdftotext", "-q", str(pdf), "-"],
                                 capture_output=True, text=True, timeout=60)
              return r.stdout
          except Exception:
              return None
      
      
      def _docx_text(docx: Path) -> str | None:
          try:
              with zipfile.ZipFile(docx) as z:
                  names = [n for n in z.namelist()
                           if n.startswith("word/") and n.endswith(".xml")]
                  chunks = []
                  for n in names:
                      xml = z.read(n).decode("utf-8", errors="replace")
                      chunks.append(re.sub(r"<[^>]+>", " ", xml))
                  return " ".join(chunks)
          except Exception:
              return None
      
      
      def build_report(root: Path, poppler: bool) -> Report:
          rep = Report(poppler_available=poppler)
          text_files = docx_files = pdf_files = 0
      
          for p in sorted(root.rglob("*")):
              if not p.is_file() or "__pycache__" in p.parts:
                  continue
              suffix = p.suffix.lower()
              rel = str(p.relative_to(root))
      
              if suffix in TEXT_SUFFIXES:
                  text_files += 1
                  _scan_text(p.read_text(encoding="utf-8", errors="replace"), rel, rep)
      
              elif suffix == ".docx":
                  docx_files += 1
                  txt = _docx_text(p)
                  if txt is None:
                      rep.skipped.append(f"docx unreadable: {rel}")
                  else:
                      _scan_text(txt, rel, rep)
      
              elif suffix == ".pdf":
                  pdf_files += 1
                  if poppler:
                      txt = _pdftotext(p)
                      if txt is None:
                          rep.skipped.append(f"pdftotext failed: {rel}")
                      else:
                          _scan_text(txt, rel, rep)
                  else:
                      rep.skipped.append(f"poppler unavailable, PDF not scanned: {rel}")
      
          rep.scanned = {"text": text_files, "docx": docx_files, "pdf": pdf_files}
          return rep
      
      
      def main(argv: list[str] | None = None) -> int:
          ap = argparse.ArgumentParser(
              description="Catch an internal /check-reporting or /self-review audit dump "
                          "leaked into a reviewer-facing submission file.")
          ap.add_argument("--dir", type=Path, default=Path.cwd(),
                          help="Submission directory to scan (default: cwd).")
          ap.add_argument("--out", type=Path, default=None, help="Write JSON report here.")
          ap.add_argument("--quiet", action="store_true", help="Suppress stdout summary.")
          args = ap.parse_args(argv)
      
          if not args.dir.is_dir():
              print(f"ERROR: --dir not a directory: {args.dir}", file=sys.stderr)
              return 2
      
          poppler = shutil.which("pdftotext") is not None
          rep = build_report(args.dir, poppler)
          safe = not rep.has_leak
      
          if args.out is not None:
              args.out.parent.mkdir(parents=True, exist_ok=True)
              args.out.write_text(json.dumps({"detector": "check_checklist_dump_leak", **rep.as_dict()}, indent=2), encoding="utf-8")
      
          if not args.quiet:
              if not poppler:
                  print("NOTE: poppler (pdftotext) not found — PDF files not scanned; "
                        "install poppler-utils for full coverage.")
              if safe:
                  print(f"PASS: no audit-dump leak ({rep.scanned}).")
              else:
                  print(f"FAIL: audit-dump leak — {rep.as_dict()['summary']}")
                  for f in rep.findings:
                      print(f"  - [{f.severity}] {f.type} {f.path}: {f.detail}")
      
          return 0 if safe else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_credit_integrity.py 18.2 KB
      #!/usr/bin/env python3
      """CRediT integrity — a contribution taxonomy is a factual claim, published with the paper.
      
      WHY THIS EXISTS
      
      CRediT terms are printed in the published record and every co-author sees them, but nothing
      ties a term to anything. During one byline negotiation three terms were requested for an
      author in sequence — Visualization, then Methodology, then Formal analysis — each after the
      previous was checked against the project record and found unsupported: the author's
      tracked-changes file contained zero embedded images and left the figure legends untouched,
      the analysis protocol was frozen two days before they joined, and the coding was recorded as
      two named coders whose blind passes predated their arrival. A fourth term, Conceptualization,
      turned out to be *entirely* legitimate — it rested on work that happens off-repo, in email
      and meetings. The pattern was a taxonomy expanding to justify a position rather than to
      describe work, and the honest version of it is indistinguishable from the legitimate one
      without evidence.
      
      WHAT IS DELIBERATELY OUT OF SCOPE
      
      **Author order and equal-contribution designation are not gated here and never will be.**
      Those are negotiated, and negotiation is legitimate. Only the taxonomy — a factual claim
      about who did what — is checked. Conflating the two is why they get edited as one block.
      
      WHAT IS CHECKED, AND WHY EACH IS SAFE
      
      Three of the four verdicts need nothing but the manuscript, so they hold on any project:
      
        CREDIT_TERM_INVALID (major)
            A section that calls itself CRediT uses a term outside the official fourteen.
            "Statistical analysis", "Manuscript writing" and "Study design" are the usual ones —
            they read as CRediT and are not, and a journal that asks for CRediT wants the fourteen.
      
        CREDIT_INITIALS_UNRESOLVED (major)
            Initials in the contributions section that match no author, or match two. This is what
            a byline change actually leaves behind: an author is removed or reordered and their
            initials keep working somewhere in the paragraph.
      
        CREDIT_AUTHOR_UNLISTED (major)
            A byline author with no contribution attributed anywhere. Under ICMJE every author must
            have contributed; a name with no term is either an authorship problem or an edit that
            dropped a clause.
      
        CREDIT_UNCORROBORATED (prompt — never a blocker)
            A term whose footprint is absent. Two sources, both optional and neither invented:
            the manuscript itself (Visualization claimed on a paper with no figures; Software with
            no code availability statement) and, if the project keeps one, a contribution record
            passed with --contribution-record. A missing record simply means this half does not run.
      
      WHY THE LAST ONE CANNOT BE A BLOCKER
      
      Real contributions happen off-repo. The one term in the incident above that WAS supported —
      Conceptualization — had no artifact in the repository at all; it lived in email and in a
      critique that drove a restructure. A gate that failed the build on that would be wrong, and
      would teach its user to disable it. So it asks for the artifact or an attestation, and gets
      out of the way.
      
      WHAT IS NOT BUILT, AND WHY
      
      The original proposal wanted Visualization corroborated against a *figure provenance table*
      and Formal analysis against an *analysis record*. Neither convention exists in this toolkit.
      Building against them would mean inventing the convention and then gating against our own
      invention. Instead --contribution-record accepts a record if the project already keeps one,
      and asserts nothing when it does not.
      
      Usage:
          check_credit_integrity.py --manuscript m.md [--contribution-record contributions.yaml]
              [--out qc/credit_integrity.json] [--quiet]
      
      Exit 0 clean, 1 any finding (majors and prompts alike — the caller decides severity from the
      JSON), 2 no CRediT section to check. Stdlib only; .docx via python-docx when available.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      DETECTOR = "check_credit_integrity"
      
      # The fourteen. https://credit.niso.org/ — this list is the standard, not a house style.
      CREDIT_TERMS = {
          "conceptualization": "Conceptualization",
          "data curation": "Data curation",
          "formal analysis": "Formal analysis",
          "funding acquisition": "Funding acquisition",
          "investigation": "Investigation",
          "methodology": "Methodology",
          "project administration": "Project administration",
          "resources": "Resources",
          "software": "Software",
          "supervision": "Supervision",
          "validation": "Validation",
          "visualization": "Visualization",
          "writing original draft": "Writing – original draft",
          "writing review editing": "Writing – review & editing",
      }
      # Near-misses that read as CRediT but are not, mapped to what was meant. Naming them is the
      # difference between "invalid" and a usable message.
      NEAR_MISS = {
          "statistical analysis": "Formal analysis",
          "statistics": "Formal analysis",
          "analysis": "Formal analysis",
          "study design": "Conceptualization or Methodology",
          "design": "Conceptualization or Methodology",
          "manuscript writing": "Writing – original draft",
          "writing": "Writing – original draft or Writing – review & editing",
          "drafting": "Writing – original draft",
          "revision": "Writing – review & editing",
          "critical revision": "Writing – review & editing",
          "data collection": "Investigation or Data curation",
          "data analysis": "Formal analysis",
          "interpretation": "Formal analysis or Validation",
          "literature search": "Investigation",
          "figure preparation": "Visualization",
          "final approval": "Writing – review & editing",
      }
      
      CREDIT_HEADING_RE = re.compile(
          r"^#{1,6}\s*\*{0,2}\s*(?:Authors?[’'\s]*\s*[Cc]ontributions?|CRediT[^\n]*)\*{0,2}\s*:?\s*$",
          re.M)
      IMG_LINK_RE = re.compile(r"!\[[^\]]*\]\([^)]+\)")
      FIGURE_CAPTION_RE = re.compile(r"^\s*\**\s*(?:Figure|Fig\.?)\s*\d+\b", re.M | re.I)
      CODE_AVAIL_RE = re.compile(r"^#{1,6}\s*\*{0,2}\s*Code Availability", re.M | re.I)
      # "J.D.", "Y.N.", "A.B.C." — the form contributions sections are written in.
      INITIALS_RE = re.compile(r"\b(?:[A-Z]\.){2,4}")
      # A byline name: "Jane Doe", "Jane A. Doe", "Mary Anne Roe" — two to four capitalised words,
      # optionally carrying a superscript/affiliation marker.
      NAME_RE = re.compile(r"\b([A-Z][a-z]+(?:\s+[A-Z]\.?)?(?:\s+[A-Z][a-z]+){1,2})\b")
      
      
      def read_text(path: Path) -> str:
          if path.suffix.lower() == ".docx":
              try:
                  import docx  # type: ignore
              except ImportError:
                  print(f"ERROR: reading {path.name} needs python-docx", file=sys.stderr)
                  raise SystemExit(2)
              return "\n".join(p.text for p in docx.Document(str(path)).paragraphs)
          return path.read_text(encoding="utf-8", errors="replace")
      
      
      def credit_section(md: str) -> str | None:
          m = CREDIT_HEADING_RE.search(md)
          if not m:
              return None
          rest = md[m.end():]
          nxt = re.search(r"^#{1,6}\s", rest, re.M)
          return (rest[: nxt.start()] if nxt else rest).strip()
      
      
      def norm_term(t: str) -> str:
          n = re.sub(r"\s+", " ", re.sub(r"[^a-z ]", " ", t.lower())).strip()
          # "Writing—original draft preparation" is MDPI's rendering of "Writing – original draft", not a
          # fifteenth term. The trailing noun is house style, so it is normalised away rather than
          # reported as a near miss.
          return re.sub(r" preparation$", "", n)
      
      
      def initials_of(name: str) -> str:
          parts = [p for p in re.split(r"\s+", name.strip()) if p]
          return "".join(p[0].upper() + "." for p in parts)
      
      
      def byline_names(md: str) -> list[str]:
          """Author names from the manuscript head — before the first section heading.
      
          Confidence matters more than recall here: the bijection checks are skipped entirely when
          fewer than two names are found, because a wrong byline produces wrong accusations about
          every author at once.
          """
          head = md[: md.index("\n## ")] if "\n## " in md else md[:1500]
          head = re.sub(r"^#\s+.*$", "", head, count=1, flags=re.M)   # drop the title line
          head = re.sub(r"[*_`^~†‡§¶\d]", " ", head)
          stop = {"Abstract", "Keywords", "Background", "Methods", "Results", "Conclusion",
                  "Introduction", "Discussion", "Synthetic", "Not", "Corresponding", "Author"}
          out: list[str] = []
          for m in NAME_RE.finditer(head):
              n = re.sub(r"\s+", " ", m.group(1)).strip()
              if n.split()[0] in stop or n in out:
                  continue
              out.append(n)
          return out
      
      
      def load_record(path: Path) -> dict[str, set[str]]:
          """Optional contribution record: {initials or name: [artifact, ...]}.
      
          Accepts JSON, or the trivial `key: a, b` YAML subset, so a project can keep one without
          taking a dependency. Absent -> this half of the check simply does not run.
          """
          raw = path.read_text(encoding="utf-8", errors="replace")
          try:
              data = json.loads(raw)
          except json.JSONDecodeError:
              data = {}
              for line in raw.splitlines():
                  line = line.split("#", 1)[0].strip()
                  if not line or ":" not in line or line.startswith("-"):
                      continue
                  k, v = line.split(":", 1)
                  data[k.strip().strip("'\"")] = [x.strip() for x in v.split(",") if x.strip()]
          return {str(k).upper(): {str(x).lower() for x in (v if isinstance(v, list) else [v])}
                  for k, v in data.items()}
      
      
      # Terms whose absence the MANUSCRIPT alone can speak to. Anything else needs a record.
      def manuscript_footprint(md: str) -> dict[str, bool]:
          has_figure = bool(IMG_LINK_RE.search(md) or FIGURE_CAPTION_RE.search(md))
          return {
              "Visualization": has_figure,
              "Software": bool(CODE_AVAIL_RE.search(md)),
          }
      
      
      def build_report(manuscript: Path, record_path: Path | None) -> dict | None:
          md = read_text(manuscript)
          body = credit_section(md)
          if body is None:
              return None
      
          findings: list[dict] = []
          # "CRediT" may be declared in the heading ("## CRediT authorship statement") or in the
          # body ("CRediT: J.D. Conceptualization…"). Scoping the test to the heading alone made a
          # section that says CRediT in its first word grade its invalid terms as merely minor.
          heading_text = md[CREDIT_HEADING_RE.search(md).start(): CREDIT_HEADING_RE.search(md).end()]
          section_says_credit = bool(re.search(r"CRediT", heading_text + "\n" + body, re.I))
      
          # --- 1. terms outside the fourteen
          #
          # Only for a section that IS a CRediT block. The docstring above already says so — "a section
          # that calls itself CRediT" — and the code had drifted from it: the scan ran on any
          # "Authors' Contributions" heading and merely graded the result `minor`. A minor finding still
          # exits non-zero, so a free-prose contributions paragraph fired.
          #
          # Measured against 12 accepted papers, that is exactly what happened: "analysis" and "writing"
          # reported as invalid CRediT terms in papers carrying NO CRediT statement at all — zero of the
          # fourteen present. A journal that never asked for CRediT has not asked its authors to use the
          # fourteen. A challenge card always contains the block being validated, so no fixture in this
          # repo could have shown it.
          #
          # A block counts as CRediT if it says so, or carries at least three of the official terms.
          # Three, not one: "Investigation", "Validation" and "Software" are ordinary English words that
          # turn up in prose by accident; three of them together do not.
          # Count against the NORMALISED body, because CREDIT_TERMS is keyed normalised: the key is
          # "writing original draft" and the page says "Writing – original draft". Matching the raw text
          # undercounts every hyphenated term and lets a real CRediT block fall below the bar.
          body_norm = norm_term(body)
          official_present = {t for t in CREDIT_TERMS if t and t in body_norm}
          is_credit_block = section_says_credit or len(official_present) >= 3
      
          # The em dash belongs in this class. MDPI renders the taxonomy as "Writing—original draft
          # preparation"; without U+2014 the term is shredded at the dash and the fragment "writing" is
          # reported as an invalid CRediT term — against a block that had written the term correctly for
          # its publisher. Two accepted MDPI papers failed this way.
          used_raw = re.findall(r"[A-Za-z][A-Za-z—–\-&' ]{3,40}", body) if is_credit_block else []
          seen: set[str] = set()
          for raw in used_raw:
              n = norm_term(raw)
              if n in CREDIT_TERMS or not n or n in seen:
                  continue
              if n in NEAR_MISS:
                  seen.add(n)
                  findings.append({
                      "verdict": "CREDIT_TERM_INVALID",
                      "severity": "major" if section_says_credit else "minor",
                      "term": raw.strip(),
                      "suggest": NEAR_MISS[n],
                      "message": (
                          f"\"{raw.strip()}\" is not a CRediT term; the taxonomy has fourteen and "
                          f"this is not one of them. The closest is {NEAR_MISS[n]}."
                      ),
                  })
      
          # --- 2/3. initials <-> byline
          names = byline_names(md)
          used_initials = sorted(set(INITIALS_RE.findall(body)))
          if len(names) >= 2 and used_initials:
              by_init: dict[str, list[str]] = {}
              for n in names:
                  by_init.setdefault(initials_of(n), []).append(n)
              for ini in used_initials:
                  owners = by_init.get(ini, [])
                  if len(owners) == 1:
                      continue
                  findings.append({
                      "verdict": "CREDIT_INITIALS_UNRESOLVED",
                      "severity": "major",
                      "initials": ini,
                      "matches": owners,
                      "message": (
                          f"\"{ini}\" in the contributions section matches "
                          + (f"{len(owners)} authors ({', '.join(owners)})" if owners
                             else "no author in the byline")
                          + ". A byline edit leaves initials behind that still read as valid."
                      ),
                  })
              for ini, owners in sorted(by_init.items()):
                  if ini in used_initials:
                      continue
                  findings.append({
                      "verdict": "CREDIT_AUTHOR_UNLISTED",
                      "severity": "major",
                      "author": owners[0],
                      "initials": ini,
                      "message": (
                          f"{owners[0]} ({ini}) is in the byline but has no contribution attributed. "
                          f"Under ICMJE every author must have contributed — this is either an "
                          f"authorship question or an edit that dropped a clause."
                      ),
                  })
      
          # --- 4. corroboration (prompt only)
          footprint = manuscript_footprint(md)
          claimed = {CREDIT_TERMS[norm_term(r)] for r in used_raw if norm_term(r) in CREDIT_TERMS}
          for term, present in footprint.items():
              if term in claimed and not present:
                  why = ("the manuscript has no figures — no image embeds and no figure captions"
                         if term == "Visualization" else
                         "the manuscript has no Code Availability statement")
                  findings.append({
                      "verdict": "CREDIT_UNCORROBORATED",
                      "severity": "minor",
                      "term": term,
                      "message": (
                          f"{term} is claimed, but {why}. Point at the artifact, or record an "
                          f"explicit attestation — contributions do happen off-repo."
                      ),
                  })
      
          record = load_record(record_path) if record_path and record_path.is_file() else None
          if record is not None:
              for ini in used_initials:
                  artifacts = record.get(ini)
                  if artifacts is None:
                      findings.append({
                          "verdict": "CREDIT_UNCORROBORATED",
                          "severity": "minor",
                          "initials": ini,
                          "message": (
                              f"{ini} is credited in the manuscript but appears nowhere in the "
                              f"contribution record. Add the artifact, or an attestation that the "
                              f"contribution was off-repo."
                          ),
                      })
      
          return {
              "detector": DETECTOR,
              "manuscript": str(manuscript),
              "contribution_record": str(record_path) if record is not None else None,
              "byline_authors": names,
              "initials_used": used_initials,
              "credit_terms_claimed": sorted(claimed),
              "bijection_checked": bool(len(names) >= 2 and used_initials),
              "findings": findings,
          }
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="CRediT taxonomy integrity (not author order).")
          ap.add_argument("--manuscript", required=True, type=Path)
          ap.add_argument("--contribution-record", type=Path,
                          help="optional {initials: [artifact, ...]} JSON/simple-YAML; absent = that "
                               "half of the check does not run")
          ap.add_argument("--out", type=Path)
          ap.add_argument("--quiet", action="store_true")
          ap.add_argument("--strict", action="store_true",
                          help="accepted for call-site symmetry; a finding exits 1 either way")
          args = ap.parse_args()
      
          if not args.manuscript.is_file():
              print(f"ERROR: manuscript not found: {args.manuscript}", file=sys.stderr)
              return 2
      
          report = build_report(args.manuscript, args.contribution_record)
          if report is None:
              if not args.quiet:
                  print(f"{DETECTOR}: no author-contributions / CRediT section — skipped.")
              return 2
      
          if args.out:
              args.out.parent.mkdir(parents=True, exist_ok=True)
              args.out.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
      
          if not args.quiet:
              print(f"{DETECTOR}: {len(report['credit_terms_claimed'])} CRediT term(s), "
                    f"{len(report['initials_used'])} contributor initial(s)"
                    + ("" if report["bijection_checked"]
                       else "; byline not resolvable, author/initials cross-check skipped"))
              for f in report["findings"]:
                  print(f"  [{f['severity']}] {f['verdict']}: {f['message']}")
              if not report["findings"]:
                  print("OK: the taxonomy is CRediT, resolves to the byline, and nothing is "
                        "claimed against an absent footprint.")
      
          return 1 if report["findings"] else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_cross_artifact_stale.py 11.5 KB
      #!/usr/bin/env python3
      """
      check_cross_artifact_stale.py — submission-stage cross-artifact staleness gate.
      
      Body-text QC is mature; peripheral artifacts lag. A late correction fixed in the
      manuscript body can persist — sometimes *reversed* — in a supplement footnote,
      and a reporting checklist is often generated against an older manuscript version
      (stale section/line references and a stale version label). Both reach reviewers.
      
      Two deterministic checks:
      
        1. **labeled-value drift** — for a small set of reconciliation-prone labels
           (missingness, complete-case, kappa/κ, agreement, prevalence, incidence,
           response rate, follow-up, pack-years, mortality), collect every numeric
           value the *body* attaches to each label, and every value an *auxiliary*
           file (supplement, e-table, caption, checklist) attaches to the same label.
           An auxiliary value for a label the body also reports, but which the body
           never states, is a `labeled_value_drift` (the supplement disagrees with the
           corrected body).
      
        2. **checklist version staleness** — a reporting checklist (file name contains
           `checklist`/`strobe`/`prisma`/`consort`/`stard`/`tripod`/`claim`) that
           embeds a manuscript-version marker (`manuscript_v6`, `v6 (2026-04-20)`,
           `Target manuscript: ... v6`) which differs from the current version
           (`--manuscript-version`, or a `vN` in the manuscript filename) is flagged
           `checklist_version_stale` — its line/section refs no longer match.
      
        3. **retired-term / old-value survivors** (opt-in, `--retired-term` /
           `--old-value`) — after a revision *reframes* a claim class or *changes* a
           headline number, stale copies survive in un-touched body paragraphs, figure
           / table legends, the supplement, and the response letter. Given the retired
           framing vocabulary or the superseded value(s) from the reframe diff, this
           scans the **body AND every aux file** and flags each survivor
           (`retired_framing_survivor` / `stale_old_value`). This automates the
           claim-site grep of `manuscript-versioning.md` §6.1: a reframe the body
           claims to have made "throughout" is verified across all artifacts, not a
           sample.
      
      Exit: 0 = clean, 1 = findings, 2 = usage/error. Stdlib-only.
      
      Usage:
          python3 check_cross_artifact_stale.py --manuscript manuscript.md \
              --aux supplement/ --aux qc/ [--manuscript-version v8] \
              [--retired-term "location-stratified benchmark"] [--old-value 1.72] \
              [--out qc/cross_artifact.json] [--strict] [--quiet]
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from dataclasses import dataclass, field, asdict
      from pathlib import Path
      
      # Reconciliation-prone labels → a regex fragment matching the label.
      LABELS: dict[str, str] = {
          "missingness": r"missing(?:ness)?",
          "complete_case": r"complete[-\s]?case",
          "kappa": r"κ|kappa",
          "agreement": r"agreement",
          "prevalence": r"prevalence",
          "incidence": r"incidence",
          "response_rate": r"response\s+rate",
          "follow_up": r"follow[-\s]?up",
          "pack_years": r"pack[-\s]?years?",
          "mortality": r"mortality",
      }
      
      # A number near a label: label … (within 40 chars) … value, optional %.
      VALUE_RE = r"[^\n.]{0,40}?(\d+(?:\.\d+)?)\s*(%?)"
      
      CHECKLIST_NAME_RE = re.compile(
          r"(checklist|strobe|prisma|consort|stard|tripod|claim|squire|arrive|care)",
          re.IGNORECASE,
      )
      # Manuscript-version markers a checklist might embed.
      VERSION_MARKER_RE = re.compile(
          r"(?:manuscript[_\s]*|target\s+manuscript[^\n]*?\bv|version[^\n]*?\bv|\bv)"
          r"(\d{1,3})\b",
          re.IGNORECASE,
      )
      FILENAME_VERSION_RE = re.compile(r"[_\-.]v(\d{1,3})\b", re.IGNORECASE)
      
      
      @dataclass
      class Finding:
          type: str
          severity: str  # "stale" | "version_stale"
          path: str
          detail: str
      
      
      @dataclass
      class Report:
          findings: list[Finding] = field(default_factory=list)
          scanned: dict[str, int] = field(default_factory=dict)
      
          @property
          def submission_safe(self) -> bool:
              return not self.findings
      
          def as_dict(self) -> dict:
              return {
                  "submission_safe": self.submission_safe,
                  "scanned": self.scanned,
                  "summary": {
                      "stale": sum(1 for f in self.findings if f.severity == "stale"),
                      "version_stale": sum(1 for f in self.findings if f.severity == "version_stale"),
                      "survivor": sum(1 for f in self.findings if f.severity == "survivor"),
                  },
                  "findings": [asdict(f) for f in self.findings],
              }
      
      
      def _survivor_pattern(needle: str, numeric: bool) -> re.Pattern:
          """Case-insensitive term match, or a digit-bounded numeric match."""
          if numeric:
              return re.compile(r"(?<![\d.])" + re.escape(needle) + r"(?!\d)")
          return re.compile(re.escape(needle), re.IGNORECASE)
      
      
      def scan_survivors(text: str, path: str, retired_terms: list[str],
                         old_values: list[str]) -> list["Finding"]:
          """Flag any retired framing term / superseded value that survives in text."""
          out: list[Finding] = []
          for term in retired_terms:
              m = _survivor_pattern(term, numeric=False).search(text)
              if m:
                  snippet = re.sub(r"\s+", " ", text[max(0, m.start() - 30): m.start() + len(term) + 30]).strip()
                  out.append(Finding(
                      "retired_framing_survivor", "survivor", path,
                      f"retired term {term!r} still present (…{snippet}…) — reframe not applied here"))
          for val in old_values:
              m = _survivor_pattern(val, numeric=bool(re.fullmatch(r"\d+(?:\.\d+)?", val))).search(text)
              if m:
                  snippet = re.sub(r"\s+", " ", text[max(0, m.start() - 30): m.start() + len(val) + 30]).strip()
                  out.append(Finding(
                      "stale_old_value", "survivor", path,
                      f"superseded value {val!r} still present (…{snippet}…) — headline change not propagated here"))
          return out
      
      
      def label_values(text: str) -> dict[str, set[str]]:
          """Map each known label to the set of numeric values stated near it."""
          out: dict[str, set[str]] = {}
          for key, frag in LABELS.items():
              vals: set[str] = set()
              for m in re.finditer(frag + VALUE_RE, text, re.IGNORECASE):
                  num, pct = m.group(1), m.group(2)
                  vals.add(num + ("%" if pct else ""))
              if vals:
                  out[key] = vals
          return out
      
      
      def _iter_files(paths: list[Path]) -> list[Path]:
          files: list[Path] = []
          for p in paths:
              if p.is_dir():
                  files += [q for q in sorted(p.rglob("*"))
                            if q.is_file() and q.suffix.lower() in (".md", ".txt", ".csv", ".tsv", ".yaml", ".yml")]
              elif p.is_file():
                  files.append(p)
          return files
      
      
      def _manuscript_version(manuscript: Path, explicit: str | None) -> int | None:
          if explicit:
              m = re.search(r"\d+", explicit)
              if m:
                  return int(m.group(0))
          m = FILENAME_VERSION_RE.search(manuscript.name)
          return int(m.group(1)) if m else None
      
      
      def build_report(manuscript: Path, aux_paths: list[Path], version: int | None,
                       retired_terms: list[str] | None = None,
                       old_values: list[str] | None = None) -> Report:
          rep = Report()
          retired_terms = retired_terms or []
          old_values = old_values or []
          body = manuscript.read_text(encoding="utf-8", errors="replace")
          body_labels = label_values(body)
      
          # 3. retired-term / old-value survivors in the BODY itself (un-touched paragraphs)
          if retired_terms or old_values:
              rep.findings += scan_survivors(body, str(manuscript), retired_terms, old_values)
      
          aux_files = [f for f in _iter_files(aux_paths) if f.resolve() != manuscript.resolve()]
          for f in aux_files:
              text = f.read_text(encoding="utf-8", errors="replace")
              rel = str(f)
      
              # 3. retired-term / old-value survivors in this aux artifact
              if retired_terms or old_values:
                  rep.findings += scan_survivors(text, rel, retired_terms, old_values)
      
              # 1. labeled-value drift vs the body
              for key, vals in label_values(text).items():
                  if key not in body_labels:
                      continue  # body does not report this label — not a reconciliation target
                  drift = vals - body_labels[key]
                  for v in sorted(drift):
                      rep.findings.append(Finding(
                          "labeled_value_drift", "stale", rel,
                          f"'{key}' = {v} here, but the body reports "
                          f"{sorted(body_labels[key])} — possible stale value"))
      
              # 2. checklist version staleness
              if version is not None and CHECKLIST_NAME_RE.search(f.name):
                  embedded = {int(m.group(1)) for m in VERSION_MARKER_RE.finditer(text)}
                  older = sorted(v for v in embedded if v < version)
                  if older:
                      rep.findings.append(Finding(
                          "checklist_version_stale", "version_stale", rel,
                          f"references manuscript version(s) v{older} but current is v{version}"))
      
          rep.scanned = {"aux_files": len(aux_files), "body_labels": len(body_labels)}
          return rep
      
      
      def main(argv: list[str] | None = None) -> int:
          ap = argparse.ArgumentParser(
              description="Cross-artifact staleness gate (labeled-value drift + checklist version).")
          ap.add_argument("--manuscript", type=Path, required=True, help="Body manuscript markdown.")
          ap.add_argument("--aux", type=Path, action="append", default=[],
                          help="Auxiliary file or directory (supplement/checklist/captions). Repeatable.")
          ap.add_argument("--manuscript-version", default=None,
                          help="Current manuscript version, e.g. v8 (else inferred from filename).")
          ap.add_argument("--retired-term", action="append", default=[], metavar="TERM",
                          help="A framing term the revision retired; flag any survivor in body/aux. Repeatable.")
          ap.add_argument("--old-value", action="append", default=[], metavar="VALUE",
                          help="A superseded headline value; flag any survivor in body/aux. Repeatable.")
          ap.add_argument("--out", type=Path, default=None, help="Write JSON report here.")
          ap.add_argument("--strict", action="store_true",
                          help="(Reserved) all findings already fail; flag kept for interface parity.")
          ap.add_argument("--quiet", action="store_true", help="Suppress stdout summary.")
          args = ap.parse_args(argv)
      
          if not args.manuscript.is_file():
              print(f"ERROR: --manuscript not a file: {args.manuscript}", file=sys.stderr)
              return 2
          if not args.aux and not args.retired_term and not args.old_value:
              print("ERROR: pass at least one of --aux, --retired-term, --old-value", file=sys.stderr)
              return 2
      
          version = _manuscript_version(args.manuscript, args.manuscript_version)
          rep = build_report(args.manuscript, args.aux, version,
                             retired_terms=args.retired_term, old_values=args.old_value)
      
          if args.out is not None:
              args.out.parent.mkdir(parents=True, exist_ok=True)
              args.out.write_text(json.dumps({"detector": "check_cross_artifact_stale", **rep.as_dict()}, indent=2), encoding="utf-8")
      
          if not args.quiet:
              if rep.submission_safe:
                  print(f"PASS: no cross-artifact staleness ({rep.scanned}).")
              else:
                  print(f"FAIL: cross-artifact staleness — {rep.as_dict()['summary']}")
                  for f in rep.findings:
                      print(f"  - [{f.severity}] {f.type} {f.path}: {f.detail}")
      
          return 0 if rep.submission_safe else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_disclosure_availability.py 12.3 KB
      #!/usr/bin/env python3
      """AI-disclosure + data/code-availability statement detector (sync-submission).
      
      Top medical-AI journals (Lancet Digital Health, Radiology / Radiology:AI, npj
      Digital Medicine, Nature Medicine) now require, before peer review:
        - an AI/LLM-use disclosure that itself names the tool **version**, the **access
          channel**, the **date / date-range**, and the **responsible party** (the four
          tokens FLAIR F1.6 / TRIPOD-LLM / MI-CLEAR-LLM demand; the tool NAME, e.g.
          ChatGPT/Claude, is the applicability identifier that triggers the check, NOT
          one of the four required tokens), with no unresolved placeholders;
        - a Data Availability statement (not a hollow "available on reasonable request"
          when the journal expects a repository);
        - a Code Availability statement with a resolvable URL/DOI for an AI/ML study.
      
      This detector scans the manuscript for those statements and checks them against
      references/journal_availability_policy.json (public facts, journal-keyed). It is
      deterministic and stdlib-only.
      
      INPUTS
        --manuscript   markdown file (required).
        --journal      journal stem (selects the policy row; falls back to "default").
        --policy       path to journal_availability_policy.json (default: alongside skill).
        --ai-study     treat as an AI/ML study (code availability becomes expected).
        --require      repeatable hard-required statement(s): ai_disclosure |
                       data_availability | code_availability | funding | coi. An absent
                       required statement is a BLOCKER regardless of --strict.
        --strict       promote advisory (P1) findings to blockers.
        --out          JSON report path (default: qc/disclosure_availability_report.json).
      
      VERDICT / EXIT
        CLEAN        no findings.
        ADVISORY     only P1 (warn) findings.
        BLOCKER      a hard rule failed: a --require'd statement absent, OR an AI
                     disclosure present but missing a required token / carrying a
                     placeholder.
        Exit: 0 clean/advisory (or report-only); 1 BLOCKER (or ADVISORY under --strict);
              2 input/usage error.
      
      Stdlib-only.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      AI_TRIGGER = re.compile(
          r"generative ai|large language model|\bLLM\b|ai[- ]assisted|"
          r"assisted (?:the|with|in) (?:writing|drafting|editing)|"
          r"\bChatGPT\b|\bGPT-?[0-9]|\bClaude\b|\bCopilot\b|\bGemini\b|\bLlama\b",
          re.IGNORECASE,
      )
      TOKEN_VERSION = re.compile(r"\b\d+\.\d+\b|\bGPT-?\d|\b(?:Claude|Gemini|Llama|GPT)\s+\d", re.IGNORECASE)
      TOKEN_CHANNEL = re.compile(r"\bAPI\b|\bchat\b|\bweb\b|\bBedrock\b|\bAzure\b|\binterface\b|\bapp\b", re.IGNORECASE)
      TOKEN_DATE = re.compile(r"\b20\d{2}\b")
      TOKEN_RESPONSIBLE = re.compile(
          r"\bby [A-Z]\.\s?[A-Z]\.|the authors|reviewed by|deployed by|operated by|under the supervision",
          re.IGNORECASE,
      )
      # What separates "this paper discloses that its authors used an AI tool" from "this paper is about
      # AI". Only the first owes the four tokens above.
      AI_AUTHORIAL_USE = re.compile(
          r"\b(?:we|the authors?|author)\b[^.\n]{0,80}\b(?:used|employed|utili[sz]ed|engaged)\b|"
          r"\b(?:used|employed|utili[sz]ed)\b[^.\n]{0,60}\b(?:to (?:assist|help|draft|edit|improve|"
          r"polish|refine)|for (?:language|writing|editing|drafting))|"
          r"writing assistance|language editing|during the preparation of (?:this|the) manuscript|"
          r"in (?:the )?preparation of (?:this|the) manuscript|"
          r"no (?:generative )?ai (?:tools? )?(?:was|were) used",
          re.IGNORECASE,
      )
      PLACEHOLDER = re.compile(r"\[(?:version|date|tool|name|model|n)\]|\bTODO\b|XXXX|\bTBD\b", re.IGNORECASE)
      
      REASONABLE_REQUEST = re.compile(r"available (?:from the (?:corresponding )?author )?on (?:reasonable )?request", re.IGNORECASE)
      RESOLVABLE = re.compile(r"https?://|doi\.org/|\bgithub\.com\b|\bzenodo\b|\bosf\.io\b|10\.\d{4,}/", re.IGNORECASE)
      
      # House style is not optional vocabulary — it is what the target journal's own author instructions
      # tell the author to write. Measured against 12 accepted papers, this detector reported "no Data
      # Availability statement found" for a JAMA trial carrying "Data Sharing Statement: See Supplement 3"
      # and "no COI statement found" for an Elsevier review carrying "Declaration of competing interest".
      # Both statements were present, correctly worded for their journal, and called missing. A fixture
      # authored beside a detector always uses the phrasing that detector expects, so this class of defect
      # cannot surface until the detector meets a journal that words it differently.
      SECTION_LABELS = {
          "data_availability": r"data availability|availability of data|data sharing",
          "code_availability": r"code availability|availability of code|software availability",
          "funding": r"funding|financial support|grant support",
          "coi": (r"conflicts? of interest|competing interests?|"
                  r"declarations? of (?:competing |conflicting )?interests?|disclosure"),
      }
      
      
      def _err(msg: str) -> int:
          print(f"ERROR: {msg}", file=sys.stderr)
          return 2
      
      
      def load_policy(path: Path, journal: str | None) -> dict:
          data = json.loads(path.read_text(encoding="utf-8"))
          if journal:
              row = data.get("journals", {}).get(journal.strip().lower())
              if row:
                  return row
          return data.get("default", {})
      
      
      def find_section(text: str, pattern: str) -> str | None:
          """Return the block of text under a heading/bold label matching `pattern`."""
          lines = text.splitlines()
          head = re.compile(r"^\s*(?:#{1,6}\s*|\*\*\s*)?(?:" + pattern + r")\b", re.IGNORECASE)
          start = None
          for i, ln in enumerate(lines):
              if head.search(ln):
                  start = i
                  break
          if start is None:
              return None
          out = [lines[start]]
          for ln in lines[start + 1:]:
              if re.match(r"^\s*#{1,6}\s+\S", ln):
                  break
              out.append(ln)
          return "\n".join(out).strip()
      
      
      def ai_disclosure_block(text: str) -> str | None:
          """Find the paragraph that carries the AI-use disclosure (the one that trips
          AI_TRIGGER), preferring an explicit AI-disclosure-style heading if present."""
          for pat in (r"ai (?:use )?disclosure|use of (?:generative )?ai|artificial intelligence",):
              blk = find_section(text, pat)
              if blk and AI_TRIGGER.search(blk):
                  return blk
          # else: the first paragraph in which the AUTHORS disclose using an AI tool.
          #
          # The bar is authorship, not mention. This fallback used to accept any paragraph containing an
          # AI term, which in a paper whose SUBJECT is AI is the abstract — and then demanded a writing
          # tool's version, access channel, date and responsible party from it. Measured against an
          # accepted reader study on AI-assisted pneumothorax detection, that is exactly what happened:
          # a hard finding, guaranteed, against a paper that never claimed to have used AI to write.
          # Study-component AI and editorial-writing AI are different disclosures governed by different
          # rules, and only the second one owes these four tokens.
          for para in re.split(r"\n\s*\n", text):
              if AI_TRIGGER.search(para) and AI_AUTHORIAL_USE.search(para):
                  return para.strip()
          return None
      
      
      def check(text: str, policy: dict, ai_study: bool, require: set[str], strict: bool) -> dict:
          findings: list[dict] = []
      
          # --- AI disclosure (only when the manuscript actually used/mentioned an AI tool) ---
          blk = ai_disclosure_block(text)
          if blk is not None:
              tokens = {
                  "version": bool(TOKEN_VERSION.search(blk)),
                  "access channel": bool(TOKEN_CHANNEL.search(blk)),
                  "date": bool(TOKEN_DATE.search(blk)),
                  "responsible party": bool(TOKEN_RESPONSIBLE.search(blk)),
              }
              missing = [k for k, v in tokens.items() if not v]
              if missing:
                  findings.append({"rule": "ai_disclosure_tokens", "severity": "hard",
                                   "detail": f"AI disclosure missing required token(s): {', '.join(missing)}"})
              if PLACEHOLDER.search(blk):
                  findings.append({"rule": "ai_disclosure_placeholder", "severity": "hard",
                                   "detail": "AI disclosure contains an unresolved placeholder ([version]/[date]/TODO/...)"})
          elif "ai_disclosure" in require:
              findings.append({"rule": "ai_disclosure_present", "severity": "hard",
                               "detail": "no AI-use disclosure found, but --require ai_disclosure was set"})
      
          # --- Data availability ---
          data_blk = find_section(text, SECTION_LABELS["data_availability"])
          data_required = policy.get("data_required", False) or ("data_availability" in require)
          if data_blk is None:
              if data_required:
                  findings.append({"rule": "data_availability_present", "severity": "hard",
                                   "detail": "no Data Availability statement found"})
          else:
              if policy.get("repository_required") and REASONABLE_REQUEST.search(data_blk) and not RESOLVABLE.search(data_blk):
                  findings.append({"rule": "data_availability_hollow", "severity": "soft",
                                   "detail": "Data Availability is 'available on request' but the journal expects a repository/DOI"})
      
          # --- Code availability (AI/ML studies) ---
          code_blk = find_section(text, SECTION_LABELS["code_availability"])
          code_required = ("code_availability" in require) or (ai_study and policy.get("code_required_if_ai", False))
          if code_blk is None:
              if code_required:
                  findings.append({"rule": "code_availability_present", "severity": "hard",
                                   "detail": "no Code Availability statement found for an AI/ML study"})
          elif not RESOLVABLE.search(code_blk):
              findings.append({"rule": "code_availability_resolvable", "severity": "soft",
                               "detail": "Code Availability statement has no resolvable URL/DOI (github/zenodo/doi.org)"})
      
          # --- Funding / COI presence ---
          for key in ("funding", "coi"):
              blk2 = find_section(text, SECTION_LABELS[key])
              if blk2 is None:
                  sev = "hard" if key in require else "soft"
                  findings.append({"rule": f"{key}_present", "severity": sev,
                                   "detail": f"no {key.upper() if key == 'coi' else key.title()} statement found"})
      
          hard = any(f["severity"] == "hard" for f in findings)
          if hard:
              verdict = "BLOCKER"
          elif findings:
              verdict = "BLOCKER" if strict else "ADVISORY"
          else:
              verdict = "CLEAN"
          return {"verdict": verdict, "ai_disclosure_found": blk is not None, "findings": findings}
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Check AI-disclosure + data/code-availability statements.")
          ap.add_argument("--manuscript", required=True)
          ap.add_argument("--journal")
          ap.add_argument("--policy")
          ap.add_argument("--ai-study", action="store_true")
          ap.add_argument("--require", action="append", default=[],
                          choices=["ai_disclosure", "data_availability", "code_availability", "funding", "coi"])
          ap.add_argument("--strict", action="store_true")
          ap.add_argument("--out")
          args = ap.parse_args()
      
          man = Path(args.manuscript)
          if not man.is_file():
              return _err(f"manuscript not found: {man}")
          policy_path = Path(args.policy) if args.policy else \
              Path(__file__).resolve().parent.parent / "references" / "journal_availability_policy.json"
          if not policy_path.is_file():
              return _err(f"policy not found: {policy_path}")
      
          policy = load_policy(policy_path, args.journal)
          report = check(man.read_text(encoding="utf-8"), policy, args.ai_study, set(args.require), args.strict)
      
          out_path = Path(args.out) if args.out else Path("qc") / "disclosure_availability_report.json"
          out_path.parent.mkdir(parents=True, exist_ok=True)
          out_path.write_text(json.dumps({"detector": "check_disclosure_availability", **report}, indent=2) + "\n", encoding="utf-8")
      
          print("=" * 41)
          print(" Disclosure & Availability")
          print("=" * 41)
          print(f"journal: {args.journal or 'default'}   ai-study: {args.ai_study}")
          print(f"verdict: {report['verdict']}")
          for f in report["findings"]:
              print(f"  [{f['severity']}] {f['rule']}: {f['detail']}")
          print(f"report: {out_path}")
      
          if report["verdict"] == "BLOCKER":
              print("\nDISCLOSURE_AVAILABILITY_BLOCKER", file=sys.stderr)
              return 1
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_marked_manuscript.py 12.2 KB
      #!/usr/bin/env python3
      """Marked (tracked-changes) manuscript round-trip gate.
      
      Every journal revision round asks for a *marked* manuscript: the revised paper
      with tracked changes against the version the reviewers saw. It is produced by
      Microsoft Word's Compare (see `build_marked_manuscript.py` — pandiff and
      LibreOffice `--compare` corrupt OOXML tables and superscript runs), and its
      correctness has traditionally been "checked" by grepping the file for a couple
      of sentences that ought to be inserted or deleted. That check is far too weak:
      it passes even when Compare has silently dropped a paragraph, duplicated one, or
      attributed half the revisions to a different author.
      
      This gate replaces the grep with a round trip that is correct *by construction*:
      
          accept every revision  -> must reproduce the revised document, exactly
          reject every revision  -> must reproduce the original document, exactly
      
      If both hold, no content was invented, dropped, or duplicated — there is nothing
      left for the marked file to get wrong.
      
      MOVES ARE NOT INSERT+DELETE. Word encodes relocated content as `w:moveFrom` /
      `w:moveTo`, not `w:ins` / `w:del`. A resolver that only knows ins/del sees a
      moved paragraph in *both* halves of the round trip and reports an untracked
      duplicate — a false "Word corrupted the document" alarm on a perfectly good
      file. The resolution below is move-aware:
      
          revised  = unchanged + w:ins     + w:moveTo
          original = unchanged + w:delText + w:moveFrom
      
      TEXT EXTRACTION. Text is read by walking exact `w:t` / `w:delText` elements.
      The tempting regex `<w:t[^>]*>(.*?)</w:t>` also matches `<w:tbl>`, `<w:tc>` and
      `<w:tr>`, swallowing table markup as prose and inflating the character count
      (roughly doubling it on a table-heavy manuscript) — which then reads as a
      mismatch. Do not reintroduce it.
      
      Verdicts (all major):
        MARKED_ACCEPT_MISMATCH   accepting all revisions does not reproduce --revised
        MARKED_REJECT_MISMATCH   rejecting all revisions does not reproduce --original
        MARKED_NO_REVISIONS      the file carries no tracked changes at all
        MARKED_AUTHOR_MIXED      revisions are attributed to someone other than --author
        MARKED_TABLE_LOSS        the marked file has fewer/more tables than --revised
        MARKED_BASE_TRACKED      --original / --revised themselves carry tracked changes,
                                 which makes the round trip ill-defined
      
      Usage:
          check_marked_manuscript.py --marked marked.docx \\
              --original R0.docx --revised v8_clean.docx \\
              [--author "Submitting Author"] [--strict] [--out qc/marked.json]
      
      Exit 0 when the round trip holds (or, without --strict, whenever the file could
      be read). With --strict, exit 1 if any verdict fires. Stdlib only.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      import xml.etree.ElementTree as ET
      import zipfile
      from pathlib import Path
      
      W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
      
      INS, DEL = W + "ins", W + "del"
      MOVE_TO, MOVE_FROM = W + "moveTo", W + "moveFrom"
      TEXT, DEL_TEXT = W + "t", W + "delText"
      TBL = W + "tbl"
      
      
      def document_xml(path: Path) -> bytes:
          """The main document part of a .docx (headers/footers are out of scope)."""
          try:
              with zipfile.ZipFile(path) as z:
                  return z.read("word/document.xml")
          except (KeyError, zipfile.BadZipFile) as exc:
              raise SystemExit(f"not a readable .docx: {path} ({exc})")
      
      
      def _norm(s: str) -> str:
          """Collapse runs of whitespace. Word splits a sentence across runs freely and
          re-splits it when comparing; only the character stream is meaningful."""
          return re.sub(r"\s+", " ", s).strip()
      
      
      def resolve(root: ET.Element, accept: bool) -> str:
          """Text of a document with every revision accepted (or rejected).
      
          Walks the tree carrying the revision state of the enclosing elements, so a
          run nested inside `w:ins` / `w:del` / `w:moveTo` / `w:moveFrom` is resolved
          by the region it lives in rather than by its own tag.
          """
          out: list[str] = []
      
          def visit(el: ET.Element, added: bool, gone: bool) -> None:
              if el.tag in (INS, MOVE_TO):
                  added = True
              elif el.tag in (DEL, MOVE_FROM):
                  gone = True
      
              if el.tag == TEXT:
                  # Present in the revised text unless it was deleted or moved away.
                  # Present in the original text unless it was inserted or moved in.
                  if (not gone) if accept else (not added):
                      out.append(el.text or "")
              elif el.tag == DEL_TEXT:
                  # Deleted/moved-away text: belongs to the original only.
                  if not accept:
                      out.append(el.text or "")
      
              for child in el:
                  visit(child, added, gone)
      
          visit(root, False, False)
          return _norm("".join(out))
      
      
      def plain_text(root: ET.Element) -> str:
          """Text of an ordinary (untracked) document."""
          return _norm("".join(e.text or "" for e in root.iter(TEXT)))
      
      
      def revision_marks(root: ET.Element) -> dict[str, int]:
          counts = {"ins": 0, "del": 0, "moveTo": 0, "moveFrom": 0}
          for el in root.iter():
              if el.tag == INS:
                  counts["ins"] += 1
              elif el.tag == DEL:
                  counts["del"] += 1
              elif el.tag == MOVE_TO:
                  counts["moveTo"] += 1
              elif el.tag == MOVE_FROM:
                  counts["moveFrom"] += 1
          return counts
      
      
      def revision_authors(root: ET.Element) -> set[str]:
          authors: set[str] = set()
          for el in root.iter():
              if el.tag in (INS, DEL, MOVE_TO, MOVE_FROM):
                  a = el.get(W + "author")
                  if a:
                      authors.add(a)
          return authors
      
      
      def n_tables(root: ET.Element) -> int:
          return sum(1 for _ in root.iter(TBL))
      
      
      def first_divergence(a: str, b: str, window: int = 60) -> str:
          """Where two texts first differ, with context — a mismatch must be actionable."""
          i = 0
          for i, (ca, cb) in enumerate(zip(a, b)):
              if ca != cb:
                  break
          else:
              i = min(len(a), len(b))
          lo = max(0, i - window)
          return (
              f"first differs at char {i}\n"
              f"      round-trip: ...{a[lo:i + window]!r}\n"
              f"      expected:   ...{b[lo:i + window]!r}"
          )
      
      
      def check(
          marked: Path, original: Path, revised: Path, author: str | None
      ) -> tuple[list[dict], dict]:
          m_root = ET.fromstring(document_xml(marked))
          o_root = ET.fromstring(document_xml(original))
          r_root = ET.fromstring(document_xml(revised))
      
          marks = revision_marks(m_root)
          authors = sorted(revision_authors(m_root))
          tbl_marked, tbl_revised = n_tables(m_root), n_tables(r_root)
      
          findings: list[dict] = []
      
          # A baseline that itself carries revisions makes the round trip meaningless:
          # its plain text would contain both the inserted and the deleted wording.
          dirty = [
              name
              for name, root in (("original", o_root), ("revised", r_root))
              if any(revision_marks(root).values())
          ]
          if dirty:
              findings.append(
                  {
                      "verdict": "MARKED_BASE_TRACKED",
                      "severity": "major",
                      "detail": (
                          f"{' and '.join(dirty)} still carries tracked changes; accept or "
                          "reject them in Word first — the round trip compares against the "
                          "plain text of these files."
                      ),
                  }
              )
      
          if not any(marks.values()):
              findings.append(
                  {
                      "verdict": "MARKED_NO_REVISIONS",
                      "severity": "major",
                      "detail": (
                          "no w:ins / w:del / w:moveTo / w:moveFrom in the marked file — "
                          "Compare produced a clean copy, not a marked manuscript."
                      ),
                  }
              )
      
          accepted, want_revised = resolve(m_root, accept=True), plain_text(r_root)
          if accepted != want_revised:
              findings.append(
                  {
                      "verdict": "MARKED_ACCEPT_MISMATCH",
                      "severity": "major",
                      "detail": (
                          f"accepting every revision yields {len(accepted)} chars, but the "
                          f"revised manuscript has {len(want_revised)}; content was dropped, "
                          f"duplicated, or invented.\n      "
                          + first_divergence(accepted, want_revised)
                      ),
                  }
              )
      
          rejected, want_original = resolve(m_root, accept=False), plain_text(o_root)
          if rejected != want_original:
              findings.append(
                  {
                      "verdict": "MARKED_REJECT_MISMATCH",
                      "severity": "major",
                      "detail": (
                          f"rejecting every revision yields {len(rejected)} chars, but the "
                          f"original manuscript has {len(want_original)}; the marked file is "
                          f"not a faithful diff of the version the reviewers saw.\n      "
                          + first_divergence(rejected, want_original)
                      ),
                  }
              )
      
          if author is not None and authors and set(authors) != {author}:
              findings.append(
                  {
                      "verdict": "MARKED_AUTHOR_MIXED",
                      "severity": "major",
                      "detail": (
                          f"revisions are attributed to {authors} — expected only {author!r}. "
                          "Pass the submitting author to Word's Compare (`author name`) rather "
                          "than rewriting w:author afterwards."
                      ),
                  }
              )
      
          if tbl_marked != tbl_revised:
              findings.append(
                  {
                      "verdict": "MARKED_TABLE_LOSS",
                      "severity": "major",
                      "detail": (
                          f"marked file has {tbl_marked} tables, the revised manuscript has "
                          f"{tbl_revised}; Compare mangled the table structure."
                      ),
                  }
              )
      
          summary = {
              "marked": str(marked),
              "original": str(original),
              "revised": str(revised),
              "revision_marks": marks,
              "revision_authors": authors,
              "tables": {"marked": tbl_marked, "revised": tbl_revised},
              "accept_roundtrip_ok": accepted == want_revised,
              "reject_roundtrip_ok": rejected == want_original,
              "chars": {
                  "accepted": len(accepted),
                  "revised": len(want_revised),
                  "rejected": len(rejected),
                  "original": len(want_original),
              },
          }
          return findings, summary
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(
              description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
          )
          ap.add_argument("--marked", required=True, type=Path, help="the tracked-changes docx to verify")
          ap.add_argument(
              "--original", required=True, type=Path, help="baseline: the version the reviewers saw (R0)"
          )
          ap.add_argument("--revised", required=True, type=Path, help="the new clean manuscript")
          ap.add_argument("--author", help="the one name every revision must be attributed to")
          ap.add_argument("--out", type=Path, help="write the JSON audit record here")
          ap.add_argument("--strict", action="store_true", help="exit 1 if any verdict fires")
          a = ap.parse_args()
      
          for f in (a.marked, a.original, a.revised):
              if not f.is_file():
                  raise SystemExit(f"not found: {f}")
      
          findings, summary = check(a.marked, a.original, a.revised, a.author)
      
          m = summary["revision_marks"]
          print(
              f"{a.marked.name}: ins {m['ins']}, del {m['del']}, "
              f"moveTo {m['moveTo']}, moveFrom {m['moveFrom']}, "
              f"tables {summary['tables']['marked']}"
          )
          print(f"  {'PASS' if summary['accept_roundtrip_ok'] else 'FAIL'}  accept-all reproduces the revised manuscript")
          print(f"  {'PASS' if summary['reject_roundtrip_ok'] else 'FAIL'}  reject-all reproduces the original manuscript")
          for f in findings:
              print(f"  [{f['severity'].upper()}] {f['verdict']}: {f['detail']}")
          if not findings:
              print("  OK — marked manuscript verified; safe to upload")
      
          if a.out:
              a.out.parent.mkdir(parents=True, exist_ok=True)
              a.out.write_text(
                  json.dumps({"detector": "check_marked_manuscript", "summary": summary, "findings": findings}, indent=2) + "\n",
                  encoding="utf-8",
              )
      
          if a.strict and findings:
              return 1
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_portal_field_residue.py 7.8 KB
      #!/usr/bin/env python3
      """Portal-field paste-verbatim gate — a "paste this verbatim" artifact must survive the
      paste: markdown lands in the published field literally, and a few characters are silently
      EXPANDED to words by some portals.
      
      Portal-field text files (`abstract.txt`, `keywords.txt`, `take_home_points.txt`, …)
      are cut from the manuscript markdown so an author can paste them straight into an
      Editorial Manager / ScholarOne free-text field. Nothing strips the markdown at that
      boundary, so a trailing `---`, a stray `**bold**`, or a `cm^2^` superscript pastes
      into — and is published in — the abstract/keyword field literally.
      
      Real instance: three portal-field files each ended with a `---` line; the author is
      told to paste the file verbatim, so `---` would have printed in the published abstract.
      
      This gate scans only `.txt` files (the paste-verbatim artifacts — a `.md` is *meant*
      to carry markdown, so it is out of scope) for residue that would render literally in a
      plain-text field:
      
        hr           a line that is only `---` / `***` / `___`  (rule / frontmatter delimiter)
        bold         paired `**bold**`
        heading      a line beginning with `#` … `######`
        link         inline `[text](url)`
        superscript  paired `^x^`   (e.g. `cm^2^`)
        subscript    paired `~x~`   (e.g. `H~2~O`, also `~~strike~~`)
      
      Plus one advisory (Minor) — a valid character a portal EXPANDS rather than publishes:
      
        char_expansion  `≥` / `≤`  (ScholarOne expands "≥" to "{greater than or equal to}",
                        five words, inflating the word count — pre-substitute `>=` / `<=`;
                        `×` and the en-dash are left alone, as they usually paste cleanly)
      
      Deterministic and precision-tuned: the emphasis/super/sub patterns require *paired*
      markers with non-space content, so significance stars (`* p<0.05, ** p<0.01`),
      approximation tildes (`~5%`), numeric ranges (`1~2`), and `C#` do not fire; headings
      require the `#` at line start followed by whitespace, so `#1` does not fire.
      
      INPUT
        --dir DIR      directory of portal-field artifacts; scans `*.txt` recursively.
        --files ...    explicit `.txt` file list (alternative to --dir).
      
      OUTPUT  (--out path)
        {"detector": "check_portal_field_residue", "scanned", "findings":
           [{path, kind, label, line, snippet, severity}], "summary": {"residue": N},
         "submission_safe": bool}
      
      Stdlib-only. Exit codes: 0 clean, 1 residue found, 2 input/usage error.
      (Exit-1-on-finding by default so the sync-submission pre-flight gate halts on it,
      matching check_checklist_dump_leak.)
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      # Each: (kind, compiled pattern, human label). Multiline patterns are line-anchored;
      # inline patterns require PAIRED markers with non-space content to stay precise.
      RESIDUE: list[tuple[str, re.Pattern, str]] = [
          ("hr", re.compile(r"^\s*([-*_])\1{2,}\s*$", re.MULTILINE),
           "horizontal rule / frontmatter delimiter"),
          ("bold", re.compile(r"\*\*(?!\s)[^*\n]+?(?<!\s)\*\*"),
           "bold emphasis"),
          ("heading", re.compile(r"^\s{0,3}#{1,6}\s+\S", re.MULTILINE),
           "heading marker"),
          ("link", re.compile(r"\[[^\]\n]+\]\([^)\n]+\)"),
           "inline link"),
          ("superscript", re.compile(r"\^(?!\s)[^\s^]+\^"),
           "superscript marker"),
          ("subscript", re.compile(r"~(?!\s)[^\s~]+~"),
           "subscript / strikethrough marker"),
      ]
      
      # Characters some portals (ScholarOne / Editorial Manager) verbose-EXPAND in a
      # paste-verbatim field: "≥" becomes "{greater than or equal to}" (five words),
      # silently inflating the field's word count and mangling the notation. This is a
      # different failure from markdown residue — the character is valid, it just does not
      # survive the paste — so it is advisory (Minor): pre-substitute ">=" / "<=" before
      # pasting. Only "≥"/"≤" are flagged; "×" and the en-dash are usually left alone.
      EXPANSION: list[tuple[str, re.Pattern, str]] = [
          ("char_expansion", re.compile(r"[≥≤]"),
           "portal may expand this to words (pre-substitute >= / <=)"),
      ]
      
      
      def scan_text(text: str) -> list[dict]:
          findings: list[dict] = []
          for source, severity in ((RESIDUE, "Major"), (EXPANSION, "Minor")):
              for kind, pat, label in source:
                  for m in pat.finditer(text):
                      line_no = text.count("\n", 0, m.start()) + 1
                      ls = text.rfind("\n", 0, m.start()) + 1
                      le = text.find("\n", m.end())
                      le = len(text) if le == -1 else le
                      findings.append({
                          "kind": kind,
                          "label": label,
                          "line": line_no,
                          "snippet": text[ls:le].strip()[:120],
                          "severity": severity,
                      })
          seen: set[tuple[str, int]] = set()
          uniq: list[dict] = []
          for f in sorted(findings, key=lambda x: (x["line"], x["kind"])):
              key = (f["kind"], f["line"])
              if key in seen:
                  continue
              seen.add(key)
              uniq.append(f)
          return uniq
      
      
      def collect_files(dir_arg: str | None, files_arg: list[str]) -> list[Path]:
          if files_arg:
              return [Path(f) for f in files_arg]
          if dir_arg:
              d = Path(dir_arg)
              if not d.is_dir():
                  sys.stderr.write(f"ERROR: --dir not a directory: {dir_arg}\n")
                  sys.exit(2)
              return sorted(d.rglob("*.txt"))
          sys.stderr.write("ERROR: pass --dir DIR or --files FILE ...\n")
          sys.exit(2)
      
      
      def analyze(dir_arg: str | None, files_arg: list[str]) -> dict:
          paths = collect_files(dir_arg, files_arg)
          findings: list[dict] = []
          scanned = 0
          for p in paths:
              if not p.is_file():
                  sys.stderr.write(f"ERROR: file not found: {p}\n")
                  sys.exit(2)
              if p.suffix.lower() != ".txt":
                  continue
              scanned += 1
              for f in scan_text(p.read_text(encoding="utf-8", errors="replace")):
                  findings.append({"path": str(p), **f})
          return {
              "scanned": {"txt": scanned},
              "findings": findings,
              "summary": {"residue": len(findings)},
              "submission_safe": not findings,
          }
      
      
      def render(result: dict) -> str:
          lines = ["| File | Line | Kind | Snippet |", "|---|---|---|---|"]
          for f in result["findings"]:
              lines.append(f"| {Path(f['path']).name} | {f['line']} | {f['kind']} | {f['snippet']} |")
          if len(lines) == 2:
              lines.append("| (none) | — | — | no markdown residue in any portal-field .txt |")
          return "\n".join(lines)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Portal-field markdown-residue gate (pre-freeze).")
          ap.add_argument("--dir", help="directory of portal-field artifacts (scans *.txt recursively)")
          ap.add_argument("--files", nargs="+", default=[], help="explicit .txt file list")
          ap.add_argument("--out", help="write JSON artifact to this path")
          ap.add_argument("--quiet", action="store_true", help="suppress stdout table")
          args = ap.parse_args()
      
          result = analyze(args.dir, args.files)
      
          if not args.quiet:
              print("=" * 42)
              print(" Portal-Field Markdown Residue")
              print("=" * 42)
              print(render(result))
              print()
              n = result["summary"]["residue"]
              if n:
                  print(f"RESIDUE: {n} markdown token(s) in a paste-verbatim portal field — "
                        "strip them or they publish literally.")
              else:
                  print(f"OK: {result['scanned']['txt']} portal-field .txt file(s) are markdown-free.")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(
                  json.dumps({"detector": "check_portal_field_residue", **result}, indent=2, ensure_ascii=False),
                  encoding="utf-8")
              if not args.quiet:
                  print(f"\nwrote {args.out}")
      
          return 1 if result["findings"] else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_portal_mirror.py 19.5 KB
      #!/usr/bin/env python3
      """Portal-field mirror gate — the portal field REPLACES the manuscript section, so a
      sentence that never reaches the field is never published.
      
      WHAT THIS IS NOT
      
      `check_portal_field_residue.py` asks whether what you paste is *clean* (markdown leaking
      into a plain-text field). This asks the opposite question: whether what you did *not* paste
      is quietly lost. A residue-clean portal field can still be missing the one sentence that
      mattered.
      
      THE CONTRACT THAT MAKES THIS A DEFECT AND NOT A STYLE NOTE
      
      Springer Nature's SNAPP states it on the submission form itself, at four fields — Author
      Contributions, Competing Interests, Data Availability, Acknowledgements:
      
          "This replaces any statement written within the manuscript and is the one that we will
           publish."
      
      So the manuscript file is the copy the reviewers read, and the portal box is the copy the
      world gets. A statement that lives only in the manuscript is not a redundant duplicate; it
      is a statement that will not exist in the published record. Nothing warns you, because
      nothing is wrong with either document on its own.
      
      TWO SENTENCES THIS WAS BUILT FROM, BOTH ONE CLICK FROM VANISHING
      
        * **Equal contribution.** The title page carried a † footnote naming two co-first authors.
          The author page has no equal-contribution checkbox. Unless "X and Y contributed equally
          to this work" is typed into the Author Contributions box, the published paper has no
          co-first authors — and the people it was negotiated for never see it.
        * **"The funder had no role in study design…"** It sat in the manuscript's
          Acknowledgements. The portal's structured *Research funding* field takes a funder name
          and a grant ID and has nowhere to put a role disclaimer, so pasting only an AI-use note
          into the Acknowledgements box drops it.
      
      Both are invisible to every other gate: the manuscript is complete, the portal fields are
      clean, and the loss only appears in the galley.
      
      VERDICTS
      
        PORTAL_FIELD_NOT_MIRRORED (major)   a sentence in a replacing manuscript section has no
                                            home in that field's portal artifact.
        PORTAL_FIELD_MISSING (major)        the manuscript has the section, the journal says the
                                            field replaces it, and no portal artifact exists at
                                            all — the published field will be empty or whatever
                                            the portal's auto-extraction guessed.
        EQUAL_CONTRIBUTION_NOT_IN_PORTAL (major)
                                            the manuscript asserts equal / co-first contribution
                                            somewhere, and the Author Contributions portal text
                                            does not. There is no checkbox for this.
      
      WHY MATCHING IS GRADED, NOT LITERAL
      
      Comparison runs through `_quote_match.py`, which grades EXACT / INTERLEAVED / PARTIAL /
      ABSENT rather than answering yes/no. An author legitimately re-flows sentences when pasting,
      and a manuscript read out of .docx arrives with its own extraction noise; only ABSENT — not
      one ordered run of the sentence's words — is called a loss. The alternative, a substring
      test, reports a sentence the author *did* paste (differently punctuated) as dropped, and a
      gate that cries wolf about pasted text is a gate that gets ignored at exactly the moment it
      finally has something true to say.
      
      WHICH FIELDS REPLACE IS A JOURNAL FACT, NOT A GUESS
      
      Read from the journal profile's `## Portal Mechanics` block (`Fields that REPLACE the
      manuscript: ...`), or given explicitly with `--fields`. With neither, this exits 2 (skipped)
      and asserts nothing — a portal contract that has not been recorded is not a contract this
      gate may invent.
      
      Usage:
          check_portal_mirror.py --manuscript m.md --portal-dir portal_fields/ \\
              --profile .../npj_Digital_Medicine.md [--out qc/portal_mirror.json] [--strict]
          check_portal_mirror.py --manuscript m.md --profile P --emit portal_fields/   # scaffold
      
      Exit 0 clean, 1 finding (with --strict), 2 inputs absent / no portal contract recorded.
      Stdlib only; .docx via python-docx when available.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).resolve().parent))
      from _quote_match import match_quality, tokens  # noqa: E402  (vendored, same-dir)
      
      DETECTOR = "check_portal_mirror"
      
      # A fragment shorter than this ("None.", "Not applicable.") carries too few tokens for an
      # ordered-run match to mean anything, so it is counted rather than judged.
      MIN_SENTENCE_TOKENS = 5
      
      # Canonical field id -> heading synonyms in the manuscript. The id is also the artifact stem
      # the portal directory is searched for.
      FIELDS: dict[str, tuple[str, ...]] = {
          "author_contributions": ("author contributions", "authors' contributions",
                                   "authors contributions", "contributions", "credit"),
          "competing_interests": ("competing interests", "competing financial interests",
                                  "conflict of interest", "conflicts of interest",
                                  "declaration of interests", "declaration of competing interest"),
          "data_availability": ("data availability", "data availability statement",
                                "availability of data", "availability of data and materials"),
          "code_availability": ("code availability", "code availability statement"),
          "acknowledgements": ("acknowledgements", "acknowledgments", "acknowledgement",
                               "acknowledgment"),
          "funding": ("funding", "funding statement", "financial support", "research funding"),
      }
      # Accepted spellings of a field name as it may be written in a journal profile.
      ALIAS_TO_ID = {syn: fid for fid, syns in FIELDS.items() for syn in syns}
      
      EQUAL_CONTRIB = re.compile(
          r"\b(contributed equally|equal contribution|equally to this work|"
          r"co-?first author|joint first author|share[d]? first authorship)\b",
          re.IGNORECASE,
      )
      
      HEADING = re.compile(r"^(#{1,6})\s*\**\s*([^*#\n]+?)\s*\**\s*$", re.M)
      ABBREV = re.compile(r"\b(?:et al|e\.g|i\.e|cf|vs|Fig|No|Dr|Prof|approx)\.$", re.IGNORECASE)
      # Author initials are not sentence ends — and Author Contributions is the one section
      # guaranteed to be written in them. Without this, "J.D. and A.R. designed the study" splits
      # into "J.D.", "and A.R.", "designed the study…": three fragments, two of them too short to
      # judge, and a real omission would be reported against a clause with no subject.
      INITIALS = re.compile(r"(?:\b[A-Z]\.){1,4}\s*$")
      
      
      # ------------------------------------------------------------------------------- reading
      
      def read_text(path: Path) -> str:
          if path.suffix.lower() == ".docx":
              try:
                  import docx  # type: ignore
              except ImportError:
                  print(f"ERROR: reading {path.name} needs python-docx", file=sys.stderr)
                  raise SystemExit(2)
              d = docx.Document(str(path))
              return "\n".join(p.text for p in d.paragraphs)
          return path.read_text(encoding="utf-8", errors="replace")
      
      
      def normalize_heading(t: str) -> str:
          return re.sub(r"[^a-z ]", "", t.lower()).strip()
      
      
      def sections(md: str) -> list[tuple[str, str]]:
          """(normalized heading, body) for every heading, body running to the next heading."""
          out: list[tuple[str, str]] = []
          marks = list(HEADING.finditer(md))
          for i, m in enumerate(marks):
              end = marks[i + 1].start() if i + 1 < len(marks) else len(md)
              out.append((normalize_heading(m.group(2)), md[m.end(): end].strip()))
          return out
      
      
      def find_section(md: str, field_id: str) -> str | None:
          wanted = FIELDS[field_id]
          for head, body in sections(md):
              if head in wanted:
                  return body
          # a heading like "Data availability statement" that carries extra words
          for head, body in sections(md):
              if any(head.startswith(w) or w.startswith(head) for w in wanted if len(head) > 6):
                  return body
          return None
      
      
      LIST_ITEM = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+")
      
      
      def blocks(body: str) -> list[str]:
          """Paragraphs and list items, each with its wrapped lines rejoined.
      
          Splitting on newlines first is wrong and was: a sentence hard-wrapped across two lines
          ("The funder had no role in study" / "design, data collection…") becomes two fragments,
          which the gate then reports as two separate losses with both halves truncated mid-clause.
          Markdown separates paragraphs by blank lines, so a block ends at a blank line or at the
          start of a list item — a CRediT block is written as a list, and losing one item loses one
          author's credit, so items stay individual.
          """
          out: list[str] = []
          buf: list[str] = []
      
          def flush() -> None:
              if buf:
                  out.append(" ".join(buf).strip())
                  buf.clear()
      
          for raw in body.splitlines():
              line = raw.strip()
              if not line or line.startswith(("|", ">", "```", "#")):
                  flush()
                  continue
              if LIST_ITEM.match(raw):
                  flush()
                  buf.append(LIST_ITEM.sub("", raw).strip())
              else:
                  buf.append(line)
          flush()
          return [b for b in out if b]
      
      
      def statements(body: str) -> list[str]:
          """Sentence-ish units of a declaration section, over rejoined blocks."""
          out: list[str] = []
          for block in blocks(body):
              buf = ""
              for piece in re.split(r"(?<=[.!?])\s+", block):
                  buf = f"{buf} {piece}".strip() if buf else piece
                  if ABBREV.search(buf) or INITIALS.search(buf):
                      continue
                  out.append(buf)
                  buf = ""
              if buf:
                  out.append(buf)
          return [s for s in out if s.strip()]
      
      
      # ---------------------------------------------------------------- the journal's contract
      
      def replacing_fields_from_profile(path: Path) -> list[str]:
          """Read `## Portal Mechanics` -> `Fields that REPLACE the manuscript: A · B · C`.
      
          Unrecognised names are dropped rather than guessed at; a profile naming a field this
          gate has no heading synonyms for would otherwise silently assert nothing.
          """
          text = path.read_text(encoding="utf-8", errors="replace")
          m = re.search(r"^##\s*Portal Mechanics\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
          if not m:
              return []
          line = re.search(r"Fields that REPLACE the manuscript\**\s*:\s*(.+)$", m.group(1), re.M)
          if not line:
              return []
          out: list[str] = []
          for part in re.split(r"[·,;|]", re.sub(r"[*_`]", "", line.group(1))):
              fid = ALIAS_TO_ID.get(normalize_heading(part))
              if fid and fid not in out:
                  out.append(fid)
          return out
      
      
      def portal_artifact(portal_dir: Path, field_id: str) -> Path | None:
          """Find this field's paste artifact by stem, tolerating the names authors actually use."""
          if not portal_dir.is_dir():
              return None
          wanted = {field_id} | {normalize_heading(s).replace(" ", "_") for s in FIELDS[field_id]}
          for p in sorted(portal_dir.rglob("*")):
              if not p.is_file() or p.suffix.lower() not in (".txt", ".md"):
                  continue
              stem = re.sub(r"[^a-z0-9]+", "_", p.stem.lower()).strip("_")
              if stem in wanted or any(stem.startswith(w) for w in wanted):
                  return p
          return None
      
      
      # --------------------------------------------------------------------------------- check
      
      def build_report(manuscript: Path, portal_dir: Path | None, fields: list[str]) -> dict:
          md = read_text(manuscript)
          findings: list[dict] = []
          checked = skipped_short = 0
          examined: list[str] = []
      
          for fid in fields:
              body = find_section(md, fid)
              if body is None:
                  continue  # the manuscript has no such section: nothing to lose
              examined.append(fid)
              art = portal_artifact(portal_dir, fid) if portal_dir else None
              if art is None:
                  findings.append({
                      "verdict": "PORTAL_FIELD_MISSING",
                      "severity": "major",
                      "field": fid,
                      "message": (
                          f"The manuscript has a {fid.replace('_', ' ')} section and this journal "
                          f"publishes the PORTAL field in its place, but no paste artifact for it "
                          f"exists. Whatever is typed into that box — or left empty — is what gets "
                          f"published. Generate one with --emit and check it."
                      ),
                  })
                  continue
      
              portal_text = read_text(art)
              for sent in statements(body):
                  if len(tokens(sent)) < MIN_SENTENCE_TOKENS:
                      skipped_short += 1
                      continue
                  checked += 1
                  if match_quality(sent, portal_text)["grade"] != "ABSENT":
                      continue
                  findings.append({
                      "verdict": "PORTAL_FIELD_NOT_MIRRORED",
                      "severity": "major",
                      "field": fid,
                      "portal_artifact": art.name,
                      "sentence": sent[:300],
                      "message": (
                          f"This sentence is in the manuscript's {fid.replace('_', ' ')} section but "
                          f"not in {art.name}, which replaces it in the published record: "
                          f"\"{sent[:120]}\". Copy the section rather than re-composing it."
                      ),
                  })
      
          # Equal contribution has no checkbox anywhere in the portal; if it is not typed into the
          # contributions box it does not survive, so it is checked against the WHOLE manuscript
          # rather than against one section (it is usually a title-page footnote, not a sentence in
          # Author Contributions at all).
          if "author_contributions" in fields and EQUAL_CONTRIB.search(md):
              art = portal_artifact(portal_dir, "author_contributions") if portal_dir else None
              portal_text = read_text(art) if art else ""
              if not EQUAL_CONTRIB.search(portal_text):
                  findings.append({
                      "verdict": "EQUAL_CONTRIBUTION_NOT_IN_PORTAL",
                      "severity": "major",
                      "field": "author_contributions",
                      "portal_artifact": art.name if art else None,
                      "message": (
                          "The manuscript states that authors contributed equally, but the Author "
                          "Contributions portal text does not. There is no equal-contribution "
                          "checkbox on the author page: if this sentence is not in that box, the "
                          "published paper has no co-first authors."
                      ),
                  })
      
          return {
              "detector": DETECTOR,
              "manuscript": str(manuscript),
              "portal_dir": str(portal_dir) if portal_dir else None,
              "replacing_fields": fields,
              "fields_examined": examined,
              "sentences_checked": checked,
              "fragments_too_short_to_judge": skipped_short,
              "findings": findings,
          }
      
      
      def do_emit(manuscript: Path, out_dir: Path, fields: list[str], force: bool) -> int:
          """Write one paste-ready .txt per replacing field, straight from the manuscript.
      
          The point is that the author never hand-composes the box. Both sentences this gate was
          built from were lost to re-composition, not to carelessness.
          """
          md = read_text(manuscript)
          out_dir.mkdir(parents=True, exist_ok=True)
          wrote = 0
          for fid in fields:
              body = find_section(md, fid)
              if body is None:
                  continue
              dest = out_dir / f"{fid}.txt"
              if dest.exists() and not force:
                  print(f"  exists, left alone: {dest.name} (use --force to overwrite)")
                  continue
              text = re.sub(r"[*_`]", "", body).strip()
              # The equal-contribution sentence is a TITLE-PAGE footnote, not part of the
              # contributions section, so copying the section alone reproduces exactly the omission
              # this gate exists to catch. Lift it in, since the portal box is the only place it can
              # survive: there is no checkbox for it.
              if fid == "author_contributions" and not EQUAL_CONTRIB.search(text):
                  lifted = next((s for s in statements(md) if EQUAL_CONTRIB.search(s)), None)
                  if lifted:
                      lifted = re.sub(r"^[\W_]+", "", re.sub(r"[*_`]", "", lifted)).strip()
                      text = f"{lifted}\n\n{text}"
                      print(f"  lifted equal-contribution statement into {dest.name}")
              dest.write_text(text + "\n", encoding="utf-8")
              print(f"  wrote {dest.name}  ({len(text.split())} words)")
              wrote += 1
          print(f"{DETECTOR}: emitted {wrote} paste-ready field(s) into {out_dir}")
          return 0
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Portal fields replace the manuscript — mirror gate.")
          ap.add_argument("--manuscript", required=True, type=Path)
          ap.add_argument("--portal-dir", type=Path, help="directory of portal paste artifacts")
          ap.add_argument("--profile", type=Path, help="journal profile .md with a Portal Mechanics block")
          ap.add_argument("--fields", help="comma-separated field names, overriding the profile")
          ap.add_argument("--emit", type=Path, metavar="DIR",
                          help="write paste-ready field files from the manuscript instead of checking")
          ap.add_argument("--force", action="store_true", help="with --emit, overwrite existing files")
          ap.add_argument("--out", type=Path)
          ap.add_argument("--quiet", action="store_true")
          ap.add_argument("--strict", action="store_true",
                          help="accepted for call-site symmetry; every verdict here is major, so a "
                               "finding exits 1 with or without it")
          args = ap.parse_args()
      
          if not args.manuscript.is_file():
              print(f"ERROR: manuscript not found: {args.manuscript}", file=sys.stderr)
              return 2
      
          if args.fields:
              fields = [f for f in (ALIAS_TO_ID.get(normalize_heading(x))
                                    for x in args.fields.split(",")) if f]
          elif args.profile and args.profile.is_file():
              fields = replacing_fields_from_profile(args.profile)
          else:
              fields = []
          if not fields:
              if not args.quiet:
                  print(f"{DETECTOR}: no replacing-field contract for this journal "
                        "(profile has no 'Portal Mechanics' block and --fields not given) — skipped.")
              return 2
      
          if args.emit:
              return do_emit(args.manuscript, args.emit, fields, args.force)
      
          if not args.portal_dir or not args.portal_dir.is_dir():
              if not args.quiet:
                  print(f"{DETECTOR}: --portal-dir absent — skipped.")
              return 2
      
          report = build_report(args.manuscript, args.portal_dir, fields)
          if args.out:
              args.out.parent.mkdir(parents=True, exist_ok=True)
              args.out.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
      
          if not args.quiet:
              print(f"{DETECTOR}: {len(report['fields_examined'])} replacing field(s) present in the "
                    f"manuscript; checked {report['sentences_checked']} sentence(s)")
              if report["fragments_too_short_to_judge"]:
                  print(f"  {report['fragments_too_short_to_judge']} fragment(s) too short to judge")
              for f in report["findings"]:
                  print(f"  [{f['severity']}] {f['verdict']}: {f['message']}")
              if not report["findings"]:
                  print("OK: every manuscript declaration has a home in the field that replaces it.")
      
          # Exit 1 on ANY finding, NOT only under --strict. preflight_gate drives its halt decision
          # from this exit code and does not pass --strict, so a --strict-gated exit would report a
          # dropped declaration as a clean check — which it did, until this line.
          return 1 if report["findings"] else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_wordcount_cap.py 11 KB
      #!/usr/bin/env python3
      """Body-word-count vs journal cap gate (the revision-inflation trap).
      
      A revise loop monotonically *adds* words — resolving reviewer majors appends
      sentences, sensitivity analyses, and caveats — and silently pushes the body over
      the target journal's word limit. It is caught, if at all, only by a manual
      measurement late in the cycle. This gate makes the measurement deterministic and
      cheap enough to re-run after every `/revise` pass.
      
      It counts the manuscript **body** (Introduction → Discussion), excluding YAML
      front matter, the abstract, references, tables/figures, supplementary, and the
      declaration sections (the same skip set as the cover-letter drift check, vendored
      here so this script is self-contained), and compares it to a word cap.
      
      THE BINDING NUMBER IS THE RENDERED WORD COUNT. pandoc citeproc expands each
      `[@key]` to "(Author Year)", so the rendered DOCX counts higher than the markdown.
      This gate approximates the rendered count as `body_words + n_inline_citations *
      --citation-expansion` (default 1.6). When you have the authoritative rendered
      count (e.g. Word's count on the built DOCX), pass it with `--rendered-words N` and
      that is used verbatim.
      
      CAP SOURCE
        --limit N                 the body word cap (deterministic; preferred).
        --journal-profile P       a find-journal profile .md; the cap is parsed from the
          [--article-type T]      article-type line (default match: "Original"). If the
                                  cap cannot be parsed to a single integer, the script
                                  errors and asks for --limit (no fuzzy guessing).
      
      OUTPUT
        stdout summary and, with --out, a JSON artifact:
          {manuscript, body_words, n_inline_citations, rendered_words_est, limit,
           near_threshold, ratio, verdict}
        WORDCOUNT_OVER_CAP (Major) when the effective count exceeds the cap;
        WORDCOUNT_NEAR_CAP (Minor) when it exceeds near_threshold * cap (default 0.95).
        Exit 1 (with --strict) when WORDCOUNT_OVER_CAP fires.
      
      Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean / near (or
      report-only), 1 over cap (with --strict), 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      from _yaml_frontmatter import split_yaml_front_matter
      
      # --- measurement (shares the skip set + YAML splitter with cover_letter_drift_check.py) -----
      
      SKIP_SECTION_RE = re.compile(
          r"^#{1,6}\s+\*{0,2}\s*("
          r"Abstract|References?|Table\s+Captions?|Table\s+Legends?|Figure\s+Legends?|"
          r"Tables?|Figures?|Supplementary\s+(Materials?|Tables?|Figures?|Appendix)|"
          r"Acknowled[gd]e?ments?|Funding|Conflicts?\s+of\s+Interest|COI|"
          r"Author\s+Contributions?|Data\s+Availability|Code\s+Availability|"
          r"AI\s+Disclosure|Artificial\s+Intelligence\s+Disclosure"
          r")\s*\*{0,2}\s*:?\s*$",
          re.IGNORECASE,
      )
      WORD_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9'./%\-]*")
      # pandoc inline citations: [@key], [@k1; @k2], [-@k]. Count each @key.
      CITE_RE = re.compile(r"@[A-Za-z0-9_][A-Za-z0-9_:.\-]*")
      # Markdown has two heading syntaxes and pandoc accepts both. This recognised only ATX, and only to
      # depth 3 — so under setext ("References" on one line, "==========" under it) the heading was never
      # seen, `in_skip` never turned on, and the ENTIRE References section was counted as body prose.
      # Byte-identical prose measured 480 words as ATX and 1,002 as setext, and this gate blocks a
      # submission on a journal's word cap.
      HEADER_RE = re.compile(r"^#{1,6}\s")
      # A setext underline: at least two `=` or `-` alone on the line. Two, not one, so a stray "-" is not
      # a heading; and the caller additionally requires the line ABOVE to be non-blank body text, which is
      # what separates a setext heading from a horizontal rule.
      SETEXT_UNDERLINE_RE = re.compile(r"^\s{0,3}(={2,}|-{2,})\s*$")
      # The same section names as SKIP_SECTION_RE, with no leading hashes, for the setext form.
      SETEXT_SKIP_RE = re.compile(
          SKIP_SECTION_RE.pattern.replace(r"^#{1,6}\s+", "^", 1), re.IGNORECASE)
      
      
      def measure_body(manuscript_path: Path) -> tuple[int, int]:
          """Return (body_words, n_inline_citations) over the non-skipped body."""
          lines = manuscript_path.read_text(encoding="utf-8").splitlines()
          _, body_lines = split_yaml_front_matter(lines)
          in_skip = False
          in_code_fence = False
          words = 0
          cites = 0
          for idx, line in enumerate(body_lines):
              stripped = line.rstrip()
              if stripped.startswith("```"):
                  in_code_fence = not in_code_fence
                  continue
              if in_code_fence:
                  continue
              # A setext underline belonging to the line above: consume it, never count it as prose.
              if SETEXT_UNDERLINE_RE.match(stripped) and idx and body_lines[idx - 1].strip():
                  continue
              # Setext heading: this line is titled by the underline beneath it.
              nxt = body_lines[idx + 1].rstrip() if idx + 1 < len(body_lines) else ""
              if stripped.strip() and SETEXT_UNDERLINE_RE.match(nxt):
                  in_skip = bool(SETEXT_SKIP_RE.match(stripped.strip()))
                  continue
              if HEADER_RE.match(stripped):
                  in_skip = bool(SKIP_SECTION_RE.match(stripped))
                  continue
              if in_skip:
                  continue
              if stripped.startswith("|") or stripped.startswith("<!--"):
                  continue
              cites += len(CITE_RE.findall(stripped))
              # Don't count the citation tokens themselves as prose words.
              prose = CITE_RE.sub(" ", stripped)
              words += len(WORD_RE.findall(prose))
          return words, cites
      
      
      # --- cap from a journal profile --------------------------------------------
      
      # "Original Article (4,000 words ...)" / "Original Research Article (≤ 5,000 words ...)"
      PROFILE_LIMIT_RE = re.compile(r"(?:≤|<=|<|up to|max(?:imum)?)?\s*([0-9][0-9,]{2,})\s*[- ]?words?",
                                    re.IGNORECASE)
      
      
      def parse_cap_from_profile(profile: Path, article_type: str) -> int:
          if not profile.is_file():
              sys.stderr.write(f"ERROR: journal profile not found: {profile}\n")
              sys.exit(2)
          want = article_type.lower()
          candidates: list[int] = []
          for line in profile.read_text(encoding="utf-8").splitlines():
              if want in line.lower():
                  nums = [int(m.group(1).replace(",", "")) for m in PROFILE_LIMIT_RE.finditer(line)]
                  # the first "N words" on the article-type line is the body cap
                  if nums:
                      candidates.append(nums[0])
          uniq = sorted(set(candidates))
          if len(uniq) != 1:
              sys.stderr.write(
                  f"ERROR: could not parse a single body word cap for article type "
                  f"'{article_type}' from {profile.name} (found {uniq or 'none'}). "
                  f"Pass --limit N explicitly.\n")
              sys.exit(2)
          return uniq[0]
      
      
      # --- core ------------------------------------------------------------------
      
      def analyze(manuscript: Path, limit: int, citation_expansion: float,
                  near_threshold: float, rendered_words: int | None) -> dict:
          if not manuscript.is_file():
              sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n")
              sys.exit(2)
          body_words, n_cites = measure_body(manuscript)
          if rendered_words is not None:
              effective = rendered_words
              basis = "rendered_words (authoritative)"
          else:
              effective = body_words + round(n_cites * citation_expansion)
              basis = f"body_words + {n_cites} citations x {citation_expansion}"
          ratio = effective / limit if limit else 0.0
          if effective > limit:
              verdict, severity = "WORDCOUNT_OVER_CAP", "Major"
          elif effective > near_threshold * limit:
              verdict, severity = "WORDCOUNT_NEAR_CAP", "Minor"
          else:
              verdict, severity = "OK", None
          return {
              "manuscript": str(manuscript),
              "body_words": body_words,
              "n_inline_citations": n_cites,
              "rendered_words_est": effective,
              "rendered_basis": basis,
              "limit": limit,
              "near_threshold": near_threshold,
              "ratio": round(ratio, 4),
              "verdict": verdict,
              "severity": severity,
          }
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Body word count vs journal cap gate.")
          ap.add_argument("--manuscript", required=True, help="manuscript markdown")
          ap.add_argument("--limit", type=int, help="body word cap (preferred; deterministic)")
          ap.add_argument("--journal-profile", help="find-journal profile .md to parse the cap from")
          ap.add_argument("--article-type", default="Original",
                          help="article-type label to match in the profile (default: 'Original')")
          ap.add_argument("--rendered-words", type=int,
                          help="authoritative rendered (DOCX) body word count; overrides the estimate")
          ap.add_argument("--citation-expansion", type=float, default=1.6,
                          help="rendered words added per inline citation (citeproc expansion; default 1.6)")
          ap.add_argument("--near-threshold", type=float, default=0.95,
                          help="fraction of the cap that triggers WORDCOUNT_NEAR_CAP (default 0.95)")
          ap.add_argument("--out", help="write JSON artifact to this path")
          ap.add_argument("--strict", action="store_true", help="exit 1 if over cap")
          ap.add_argument("--quiet", action="store_true", help="suppress stdout summary")
          args = ap.parse_args()
      
          if args.limit is None and not args.journal_profile:
              sys.stderr.write("ERROR: pass --limit N or --journal-profile <path>\n")
              return 2
          limit = args.limit
          if limit is None:
              limit = parse_cap_from_profile(Path(args.journal_profile), args.article_type)
      
          result = analyze(Path(args.manuscript), limit, args.citation_expansion,
                           args.near_threshold, args.rendered_words)
      
          if not args.quiet:
              print("=" * 41)
              print(" Word-Count vs Journal Cap")
              print("=" * 41)
              print(f"body words (md)      : {result['body_words']:,}")
              print(f"inline citations     : {result['n_inline_citations']:,}")
              print(f"rendered est         : {result['rendered_words_est']:,}  [{result['rendered_basis']}]")
              print(f"journal cap          : {result['limit']:,}  (ratio {result['ratio']:.2f})")
              if result["verdict"] == "WORDCOUNT_OVER_CAP":
                  print(f"\nMAJOR: body exceeds the cap by {result['rendered_words_est'] - result['limit']:,} "
                        f"words. Relocate methods/sensitivity detail to the Supplement; the binding "
                        f"number is the rendered DOCX count.")
              elif result["verdict"] == "WORDCOUNT_NEAR_CAP":
                  print(f"\nMINOR: body is within {round((1 - result['ratio']) * 100)}% of the cap — a "
                        f"further revise pass will likely breach it.")
              else:
                  print("\nOK: body is within the journal cap.")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(json.dumps({"detector": "check_wordcount_cap", **result}, indent=2), encoding="utf-8")
              if not args.quiet:
                  print(f"\nwrote {args.out}")
      
          return 1 if (args.strict and result["verdict"] == "WORDCOUNT_OVER_CAP") else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • cover_letter_drift_check.py 18.7 KB
      #!/usr/bin/env python3
      """
      cover_letter_drift_check.py — Phase 4 cover-letter free-text drift gate.
      
      Compares the numeric claims embedded in a cover letter (body word count,
      abstract word count, reference count, table/figure count, reporting-guideline
      status) — and the manuscript TITLE — against the artifacts that should be their
      source of truth. Emits a drift report when the cover letter (or the project
      config) has gone stale relative to the manuscript. The title check requires the
      manuscript title to appear verbatim in the cover letter and to match an optional
      `--config` (SSOT.yaml/project.yaml) `title`/`title_working` field — three live
      titles at once is a guaranteed desk/technical-check flag.
      
      Why this gate exists
      ====================
      Cover letters are submission-portal sidecar artifacts that the docx scanners
      in this skill do not touch. When a manuscript branches v_N → v_(N+1) (word
      limit retarget, abstract restructure, new reference batch), the cover letter
      is routinely forgotten. The free-text claims in `## Article details` or the
      opening paragraph remain frozen at the v_N counts.
      
      A cover letter can retain obsolete body, abstract, and reference counts after
      the manuscript changes. Comparing those claims with the current source
      artifacts catches drift that a body-text scan cannot see.
      
      Usage
      =====
      
          python cover_letter_drift_check.py \\
              --manuscript manuscript.md \\
              --cover-letter cover_letter.md \\
              --abstract abstract.md \\
              --refs refs.bib \\
              --out qc/cover_letter_drift.json
      
      If `--abstract` is omitted, the abstract is extracted from the manuscript
      front matter (heuristics: H1 "Abstract" section, or YAML `abstract:` field).
      
      The script never edits the cover letter — it only reports drift. Resolution
      is to update the cover letter (and optionally re-anchor the claims to a
      computed-at-build-time helper).
      
      Exit codes
      ==========
      - 0: no drift detected.
      - 2: drift detected (any reported value disagrees with the manuscript).
      - 1: usage error (input files missing, malformed).
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      from typing import Optional
      
      from _yaml_frontmatter import split_yaml_front_matter
      
      
      # ---------------------------------------------------------------------------
      # Manuscript measurement helpers
      # ---------------------------------------------------------------------------
      
      # Section heading patterns to skip when counting "body" words.
      SKIP_SECTION_RE = re.compile(
          r"^#{1,3}\s+\*{0,2}\s*("
          r"Abstract|References?|Table\s+Captions?|Table\s+Legends?|Figure\s+Legends?|"
          r"Tables?|Figures?|Supplementary\s+(Materials?|Tables?|Figures?|Appendix)|"
          r"Acknowled[gd]e?ments?|Funding|Conflicts?\s+of\s+Interest|COI|"
          r"Author\s+Contributions?|Data\s+Availability|Code\s+Availability|"
          r"AI\s+Disclosure|Artificial\s+Intelligence\s+Disclosure"
          r")\s*\*{0,2}\s*:?\s*$",
          re.IGNORECASE,
      )
      
      # Section heading that starts the abstract.
      ABSTRACT_START_RE = re.compile(
          r"^#{1,3}\s+\*{0,2}\s*Abstract\s*\*{0,2}\s*:?\s*$", re.IGNORECASE
      )
      
      # Word-counting tokenizer: splits on whitespace, drops markdown punctuation-only
      # tokens (e.g., "—", "•", standalone "1." numbering) so prose density isn't
      # inflated.
      WORD_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9'./%\-]*")
      
      
      def _next_section_boundary(lines: list[str], start: int) -> int:
          """Return index of the next `^#{1,3}\\s` line at or after `start`, or len(lines)."""
          pat = re.compile(r"^#{1,3}\s")
          for i in range(start, len(lines)):
              if pat.match(lines[i]):
                  return i
          return len(lines)
      
      
      def count_body_words(manuscript_path: Path) -> int:
          """Count words in manuscript body, excluding YAML front matter, abstract,
          references, tables, figures, supplementary, acknowledgments, and
          declaration sections."""
          lines = manuscript_path.read_text(encoding="utf-8").splitlines()
          _, body_lines = split_yaml_front_matter(lines)
      
          in_skip = False
          in_code_fence = False
          total = 0
          for line in body_lines:
              stripped = line.rstrip()
              # Toggle code fence (don't count code).
              if stripped.startswith("```"):
                  in_code_fence = not in_code_fence
                  continue
              if in_code_fence:
                  continue
              # Section header?
              if re.match(r"^#{1,3}\s", stripped):
                  in_skip = bool(SKIP_SECTION_RE.match(stripped))
                  continue
              if in_skip:
                  continue
              # Skip table rows (pipe-leading) and HTML comments.
              if stripped.startswith("|") or stripped.startswith("<!--"):
                  continue
              total += len(WORD_RE.findall(stripped))
          return total
      
      
      def extract_abstract_text(manuscript_path: Path) -> str:
          """Extract abstract section text from manuscript (best-effort)."""
          lines = manuscript_path.read_text(encoding="utf-8").splitlines()
          yaml_lines, body_lines = split_yaml_front_matter(lines)
      
          # First try YAML `abstract:` field.
          yaml_text = "\n".join(yaml_lines)
          yaml_match = re.search(
              r"^abstract:\s*(?:\||>)?\s*\n((?:[ \t]+.+\n?)+)", yaml_text, re.MULTILINE
          )
          if yaml_match:
              block = yaml_match.group(1)
              return "\n".join(ln.lstrip() for ln in block.splitlines())
      
          # Otherwise locate "## Abstract" section.
          for i, line in enumerate(body_lines):
              if ABSTRACT_START_RE.match(line.rstrip()):
                  end = _next_section_boundary(body_lines, i + 1)
                  return "\n".join(body_lines[i + 1 : end])
          return ""
      
      
      def count_abstract_words(manuscript_path: Path, abstract_path: Optional[Path]) -> int:
          if abstract_path is not None and abstract_path.exists():
              text = abstract_path.read_text(encoding="utf-8")
          else:
              text = extract_abstract_text(manuscript_path)
          # Drop subheaders like "**Objectives:**" — keep the prose only.
          text = re.sub(r"\*{1,3}[^*]+\*{1,3}\s*:?", " ", text)
          return len(WORD_RE.findall(text))
      
      
      # ---------------------------------------------------------------------------
      # Reference / figure / table counts
      # ---------------------------------------------------------------------------
      
      BIB_ENTRY_RE = re.compile(r"^@[A-Za-z]+\s*\{", re.MULTILINE)
      
      
      def count_bib_entries(refs_path: Path) -> int:
          text = refs_path.read_text(encoding="utf-8", errors="ignore")
          return len(BIB_ENTRY_RE.findall(text))
      
      
      def count_used_citations(manuscript_path: Path) -> int:
          """Count unique pandoc-style [@key] citations actually used in the manuscript."""
          text = manuscript_path.read_text(encoding="utf-8")
          keys = re.findall(r"\[-?@([A-Za-z0-9_:.\-]+)", text)
          return len(set(keys))
      
      
      def count_table_labels(manuscript_path: Path) -> int:
          """Count distinct `Table N` labels in manuscript body."""
          text = manuscript_path.read_text(encoding="utf-8")
          nums = set()
          for m in re.finditer(r"\bTable\s+(\d+)\b", text):
              nums.add(int(m.group(1)))
          return len(nums)
      
      
      def count_figure_labels(manuscript_path: Path) -> int:
          """Count distinct `Figure N` labels in manuscript body."""
          text = manuscript_path.read_text(encoding="utf-8")
          nums = set()
          for m in re.finditer(r"\bFigure\s+(\d+)\b", text):
              nums.add(int(m.group(1)))
          return len(nums)
      
      
      # ---------------------------------------------------------------------------
      # Cover-letter claim extraction
      # ---------------------------------------------------------------------------
      
      # Optional approximation markers and thousands separators in a word count.
      BODY_WORDS_RE = re.compile(
          r"(?:approximately|approx\.?|about|roughly|~)?\s*"
          r"([0-9][0-9,]*)\s*(?:body\s+)?words?\b",
          re.IGNORECASE,
      )
      # "250-word abstract" / "abstract: 250 words"
      ABSTRACT_WORDS_RE = re.compile(
          r"(?:abstract[^.\n]*?([0-9][0-9,]*)\s*words?"
          r"|([0-9][0-9,]*)[\s-]+word\s+abstract)",
          re.IGNORECASE,
      )
      # "12 references" / "12 verified references" / "references: 12"
      REF_COUNT_RE = re.compile(
          r"(?:([0-9][0-9,]*)\s+(?:verified\s+)?references?\b"
          r"|references?\s*[:\-]\s*([0-9][0-9,]*))",
          re.IGNORECASE,
      )
      # "3 tables and 4 figures" / "Tables: 3" / "Figures: 4"
      TABLE_COUNT_RE = re.compile(
          r"(?:([0-9]+)\s+tables?\b|tables?\s*[:\-]\s*([0-9]+))",
          re.IGNORECASE,
      )
      FIGURE_COUNT_RE = re.compile(
          r"(?:([0-9]+)\s+figures?\b|figures?\s*[:\-]\s*([0-9]+))",
          re.IGNORECASE,
      )
      
      
      def _coalesce_match(match: re.Match) -> Optional[int]:
          for group in match.groups():
              if group:
                  return int(group.replace(",", ""))
          return None
      
      
      def extract_claims(cover_letter_path: Path) -> dict:
          """Pull all numeric claims out of the cover letter body."""
          text = cover_letter_path.read_text(encoding="utf-8")
      
          claims: dict = {}
      
          body_matches = [_coalesce_match(m) for m in BODY_WORDS_RE.finditer(text)]
          body_matches = [v for v in body_matches if v is not None and v >= 500]
          if body_matches:
              # Take the largest figure that could plausibly be body word count.
              # (Cover letters sometimes also mention "250 words" for abstract — the
              # abstract regex picks that up separately.)
              claims["body_words"] = max(body_matches)
      
          abstract_matches = [_coalesce_match(m) for m in ABSTRACT_WORDS_RE.finditer(text)]
          abstract_matches = [v for v in abstract_matches if v is not None and v <= 600]
          if abstract_matches:
              claims["abstract_words"] = abstract_matches[0]
      
          ref_matches = [_coalesce_match(m) for m in REF_COUNT_RE.finditer(text)]
          ref_matches = [v for v in ref_matches if v is not None and v <= 500]
          if ref_matches:
              claims["references"] = ref_matches[0]
      
          table_matches = [_coalesce_match(m) for m in TABLE_COUNT_RE.finditer(text)]
          table_matches = [v for v in table_matches if v is not None and v <= 20]
          if table_matches:
              claims["tables"] = table_matches[0]
      
          figure_matches = [_coalesce_match(m) for m in FIGURE_COUNT_RE.finditer(text)]
          figure_matches = [v for v in figure_matches if v is not None and v <= 20]
          if figure_matches:
              claims["figures"] = figure_matches[0]
      
          return claims
      
      
      # ---------------------------------------------------------------------------
      # Title / running-head drift
      # ---------------------------------------------------------------------------
      
      
      def _norm_title(s: str) -> str:
          """Lowercase, collapse whitespace, strip surrounding quotes and a trailing period."""
          s = s.strip().strip("\"'“”‘’").strip()
          s = re.sub(r"\s+", " ", s).strip()
          return s.rstrip(".").lower()
      
      
      def _title_from_yaml_key(text: str, keys: tuple[str, ...]) -> str:
          for key in keys:
              m = re.search(rf"^{key}:\s*(.+?)\s*$", text, re.MULTILINE)
              if m:
                  return m.group(1).strip().strip("\"'")
          return ""
      
      
      def extract_manuscript_title(manuscript_path: Path) -> str:
          lines = manuscript_path.read_text(encoding="utf-8").splitlines()
          yaml_lines, body_lines = split_yaml_front_matter(lines)
          t = _title_from_yaml_key("\n".join(yaml_lines), ("title",))
          if t:
              return t
          for ln in body_lines:  # fallback: first H1
              hm = re.match(r"^#\s+(.+)", ln.strip())
              if hm:
                  return hm.group(1).strip()
          return ""
      
      
      def extract_config_title(config_path: Path) -> str:
          text = config_path.read_text(encoding="utf-8")
          return _title_from_yaml_key(text, ("title_working", "title"))
      
      
      def evaluate_title_drift(manuscript_path: Path, cover_letter_path: Path,
                               config_path: Optional[Path]) -> list[dict]:
          """The manuscript title must appear verbatim in the cover letter and match the
          project config. A title that differs across these sidecars is a guaranteed
          desk/technical-check flag (the running head and portal metadata are next)."""
          drifts: list[dict] = []
          ms_title = extract_manuscript_title(manuscript_path)
          if not ms_title:
              return drifts
          needle = _norm_title(ms_title)
          haystack = _norm_title(cover_letter_path.read_text(encoding="utf-8"))
          if needle not in haystack:
              drifts.append({
                  "field": "title",
                  "truth": ms_title,
                  "cover_letter_claim": "(manuscript title not found verbatim in cover letter)",
                  "severity": "MAJOR",
                  "note": "the cover letter does not state the manuscript title verbatim — a desk-check mismatch",
              })
          if config_path is not None and config_path.exists():
              cfg_title = extract_config_title(config_path)
              if cfg_title and _norm_title(cfg_title) != needle:
                  drifts.append({
                      "field": "title(config)",
                      "truth": ms_title,
                      "cover_letter_claim": cfg_title,
                      "severity": "MAJOR",
                      "note": "the project config title disagrees with the manuscript title",
                  })
          return drifts
      
      
      # ---------------------------------------------------------------------------
      # Drift evaluation
      # ---------------------------------------------------------------------------
      
      DEFAULT_BODY_TOLERANCE_PCT = 5  # cover letter "approximately" allows ~5% slack
      DEFAULT_ABSTRACT_TOLERANCE = 5  # words
      
      
      def evaluate_drift(
          truth: dict,
          claims: dict,
          *,
          body_tolerance_pct: float = DEFAULT_BODY_TOLERANCE_PCT,
          abstract_tolerance: int = DEFAULT_ABSTRACT_TOLERANCE,
      ) -> list[dict]:
          """Compare claims to truth and emit a list of drift records."""
          drifts: list[dict] = []
      
          def _record(field: str, truth_val, claim_val, severity: str, note: str = ""):
              drifts.append(
                  {
                      "field": field,
                      "truth": truth_val,
                      "cover_letter_claim": claim_val,
                      "severity": severity,
                      "note": note,
                  }
              )
      
          # Body words — tolerate small "approximately" slack.
          if "body_words" in claims and "body_words" in truth:
              cw = claims["body_words"]
              tw = truth["body_words"]
              if tw > 0:
                  slack = max(50, int(tw * body_tolerance_pct / 100))
                  if abs(cw - tw) > slack:
                      _record(
                          "body_words",
                          tw,
                          cw,
                          "MAJOR",
                          f"|claim - truth| = {abs(cw - tw)} > tolerance {slack}",
                      )
      
          # Abstract words.
          if "abstract_words" in claims and "abstract_words" in truth:
              cw = claims["abstract_words"]
              tw = truth["abstract_words"]
              if abs(cw - tw) > abstract_tolerance:
                  _record(
                      "abstract_words",
                      tw,
                      cw,
                      "MAJOR",
                      f"|claim - truth| = {abs(cw - tw)} > tolerance {abstract_tolerance}",
                  )
      
          # Reference count — exact match.
          if "references" in claims and "references" in truth:
              if claims["references"] != truth["references"]:
                  _record(
                      "references",
                      truth["references"],
                      claims["references"],
                      "MAJOR",
                  )
      
          # Tables — exact match.
          if "tables" in claims and "tables" in truth:
              if claims["tables"] != truth["tables"]:
                  _record(
                      "tables",
                      truth["tables"],
                      claims["tables"],
                      "MAJOR",
                  )
      
          # Figures — exact match.
          if "figures" in claims and "figures" in truth:
              if claims["figures"] != truth["figures"]:
                  _record(
                      "figures",
                      truth["figures"],
                      claims["figures"],
                      "MAJOR",
                  )
      
          return drifts
      
      
      # ---------------------------------------------------------------------------
      # CLI
      # ---------------------------------------------------------------------------
      
      
      def main() -> int:
          p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          p.add_argument("--manuscript", required=True, type=Path)
          p.add_argument("--cover-letter", required=True, type=Path)
          p.add_argument("--abstract", type=Path, default=None,
                         help="Optional separate abstract file. If absent, extracted from manuscript.")
          p.add_argument("--refs", type=Path, default=None,
                         help="refs.bib path. Used for reference count truth. "
                              "If absent, falls back to counting unique [@key] in manuscript.")
          p.add_argument("--config", type=Path, default=None,
                         help="Optional project config (SSOT.yaml / project.yaml) with a `title`/"
                              "`title_working` field to cross-check against the manuscript title.")
          p.add_argument("--out", type=Path, default=Path("qc/cover_letter_drift.json"))
          p.add_argument("--body-tolerance-pct", type=float, default=DEFAULT_BODY_TOLERANCE_PCT,
                         help="Allowed slack on body word count (percent). Default %(default)s.")
          p.add_argument("--abstract-tolerance", type=int, default=DEFAULT_ABSTRACT_TOLERANCE,
                         help="Allowed slack on abstract word count (words). Default %(default)s.")
          args = p.parse_args()
      
          if not args.manuscript.exists():
              print(f"ERROR: manuscript not found: {args.manuscript}", file=sys.stderr)
              return 1
          if not args.cover_letter.exists():
              print(f"ERROR: cover letter not found: {args.cover_letter}", file=sys.stderr)
              return 1
      
          truth = {
              "body_words": count_body_words(args.manuscript),
              "abstract_words": count_abstract_words(args.manuscript, args.abstract),
              "tables": count_table_labels(args.manuscript),
              "figures": count_figure_labels(args.manuscript),
          }
          if args.refs is not None and args.refs.exists():
              truth["references"] = count_bib_entries(args.refs)
          else:
              truth["references"] = count_used_citations(args.manuscript)
      
          claims = extract_claims(args.cover_letter)
          drifts = evaluate_drift(
              truth,
              claims,
              body_tolerance_pct=args.body_tolerance_pct,
              abstract_tolerance=args.abstract_tolerance,
          )
          drifts += evaluate_title_drift(args.manuscript, args.cover_letter, args.config)
      
          report = {
              "submission_safe": len(drifts) == 0,
              "manuscript": str(args.manuscript),
              "cover_letter": str(args.cover_letter),
              "truth": truth,
              "claims": claims,
              "drifts": drifts,
          }
      
          args.out.parent.mkdir(parents=True, exist_ok=True)
          args.out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
      
          if drifts:
              print(f"DRIFT: {len(drifts)} cover-letter field(s) disagree with manuscript")
              for d in drifts:
                  print(f"  - {d['field']}: claim={d['cover_letter_claim']} vs truth={d['truth']}"
                        + (f" — {d['note']}" if d.get("note") else ""))
              return 2
      
          print(f"OK: cover letter agrees with manuscript ({len(truth)} fields checked)")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • cross_document_n_check.py 15.3 KB
      #!/usr/bin/env python3
      """
      cross_document_n_check.py — Phase 5 cross-document N consistency gate.
      
      Scans a submission package for cohort-size claims ("N patients", "k studies
      included", "n excluded", "M nodules", etc.) across manuscript body, abstract,
      PROSPERO record, cover letter, supplementary materials, INDEX, and PRISMA flow
      caption. Emits a drift report when the same logical quantity disagrees between
      documents.
      
      Why this gate exists
      ====================
      Multi-document N drift is a high-frequency reviewer/editor desk-reject pattern.
      When a manuscript ships with k=63 in the abstract but k=64 in the supplementary
      extraction sheet, reviewers treat it as either a data-integrity failure or a
      late-edit failure. Either reading is fatal at peer review.
      
      Cross-project observations (anonymized):
      - Project (LLM reporting-quality SR example): five documents disagreed
        INCLUDE=63 vs 64, EXCLUDE=108/109/111. Three EXCLUDE entries existed in the
        extraction sheet without matching INCLUDE.
      - Project (DTA-MA example): Results prose PRISMA cascade
        151+108+39+1+1+4=304 vs prose total "305" — off-by-one in the same paragraph.
      - Project (outcome-MA example): TS denominator 331 in prose vs 326 computed
        from extraction table; Major complications 434 vs 439.
      - Project (intervention-MA example): "1,847 nodules" hallucinated in v3
        against Results "881 + 402".
      
      Usage
      =====
      
          python cross_document_n_check.py \\
              --root path/to/project \\
              --out qc/cross_document_n.json
      
          python cross_document_n_check.py \\
              --files manuscript.md abstract.md supplementary/s1.md \\
              --out qc/cross_document_n.json
      
      Optional pool-lock anchor:
      
          python cross_document_n_check.py \\
              --root path/to/project \\
              --pool-lock 2_Data/FINAL_POOL_LOCK.yaml \\
              --out qc/cross_document_n.json
      
      When --pool-lock is supplied, every N value tied to a "locked" category
      (include_count / exclude_count / mixed_count) is asserted to match the lock
      exactly. Mismatches are P0 failures.
      
      Output (qc/cross_document_n.json):
      
          {
            "submission_safe": false,
            "drift_count": 3,
            "drifts": [
              {
                "category": "included",
                "values": [63, 64],
                "locations": [
                  {"file": "abstract.md",     "line": 4,  "value": 63, "context": "..."},
                  {"file": "supplementary/s1.md", "line": 12, "value": 64, "context": "..."}
                ],
                "severity": "MAJOR"
              }
            ],
            "categories_scanned": ["patients", "studies", "included", "excluded", ...],
            "files_scanned": ["abstract.md", "manuscript.md", ...]
          }
      
      Exit codes:
          0 = no drift
          1 = drift detected
          2 = invocation error (missing files, bad arguments)
      
      This script does not modify source files. It is read-only.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from dataclasses import asdict, dataclass, field
      from pathlib import Path
      from typing import Iterable
      
      
      # --------------------------------------------------------------------------
      # Pattern catalog
      # --------------------------------------------------------------------------
      
      # Each pattern maps to a normalized category label. The capture group is the
      # numeric value (commas removed downstream). We intentionally keep the unit
      # noun in the same alternation block so a single regex captures both "studies
      # included" and "included studies" variants.
      #
      # Category keys are stable and downstream consumers (lock files, drift
      # reports) reference them by name.
      PATTERNS: list[tuple[str, re.Pattern[str]]] = [
          (
              "included",
              re.compile(
                  r"(?:\b(?:included|including|we\s+included)\s+(\d{1,3}(?:,\d{3})*|\d+)\s+(?:studies|records|reports|articles|trials|papers)\b"
                  r"|\b(\d{1,3}(?:,\d{3})*|\d+)\s+(?:studies?\s+(?:were\s+)?included|included\s+studies?|"
                  r"records?\s+(?:were\s+)?included|included\s+records?|"
                  r"reports?\s+(?:were\s+)?included|included\s+reports?|"
                  r"articles?\s+(?:were\s+)?included|included\s+articles?)\b)",
                  re.IGNORECASE,
              ),
          ),
          (
              "excluded",
              re.compile(
                  r"(?:\b(?:excluded|excluding|we\s+excluded)\s+(\d{1,3}(?:,\d{3})*|\d+)\s+(?:studies|records|reports|articles|trials|papers)\b"
                  r"|\b(\d{1,3}(?:,\d{3})*|\d+)\s+(?:studies?\s+(?:were\s+)?excluded|excluded\s+studies?|"
                  r"records?\s+(?:were\s+)?excluded|excluded\s+records?|"
                  r"reports?\s+(?:were\s+)?excluded|excluded\s+reports?|"
                  r"articles?\s+(?:were\s+)?excluded|excluded\s+articles?)\b)",
                  re.IGNORECASE,
              ),
          ),
          (
              "patients",
              re.compile(
                  r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+patients?\b",
                  re.IGNORECASE,
              ),
          ),
          (
              "cases",
              re.compile(
                  r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+cases?\b",
                  re.IGNORECASE,
              ),
          ),
          (
              "nodules",
              re.compile(
                  r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+nodules?\b",
                  re.IGNORECASE,
              ),
          ),
          (
              "tumors",
              re.compile(
                  r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+(?:tumou?rs?|lesions?)\b",
                  re.IGNORECASE,
              ),
          ),
          (
              "studies_total",
              re.compile(
                  r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+studies\b(?!\s+(?:were\s+)?(?:included|excluded))",
                  re.IGNORECASE,
              ),
          ),
      ]
      
      # File globs to scan when --root is supplied. Order is for output
      # determinism only; the algorithm is glob-then-sort.
      DEFAULT_GLOBS = (
          "manuscript.md",
          "manuscript/*.md",
          "abstract.md",
          "abstract/*.md",
          "cover_letter.md",
          "*cover_letter*.md",
          "prospero/*.md",
          "supplementary/*.md",
          "supplementary/**/*.md",
          "INDEX.md",
          "submission/**/manuscript*.md",
          "submission/**/abstract*.md",
      )
      
      
      # --------------------------------------------------------------------------
      # Data classes
      # --------------------------------------------------------------------------
      
      
      @dataclass
      class Hit:
          file: str
          line: int
          value: int
          context: str
      
          def as_dict(self) -> dict:
              return asdict(self)
      
      
      @dataclass
      class Drift:
          category: str
          values: list[int]
          locations: list[Hit]
          severity: str = "MAJOR"
      
          def as_dict(self) -> dict:
              return {
                  "category": self.category,
                  "values": sorted(self.values),
                  "locations": [h.as_dict() for h in self.locations],
                  "severity": self.severity,
              }
      
      
      @dataclass
      class Report:
          submission_safe: bool
          drift_count: int
          drifts: list[Drift]
          categories_scanned: list[str]
          files_scanned: list[str]
          lock_violations: list[dict] = field(default_factory=list)
      
          def as_dict(self) -> dict:
              return {
                  "submission_safe": self.submission_safe,
                  "drift_count": self.drift_count,
                  "drifts": [d.as_dict() for d in self.drifts],
                  "categories_scanned": self.categories_scanned,
                  "files_scanned": self.files_scanned,
                  "lock_violations": self.lock_violations,
              }
      
      
      # --------------------------------------------------------------------------
      # Core
      # --------------------------------------------------------------------------
      
      
      def _to_int(raw: str) -> int:
          return int(raw.replace(",", ""))
      
      
      def scan_file(path: Path) -> list[tuple[str, Hit]]:
          """Return (category, Hit) tuples for every matched N claim in path."""
          out: list[tuple[str, Hit]] = []
          try:
              text = path.read_text(encoding="utf-8")
          except (OSError, UnicodeDecodeError):
              return out
          for lineno, line in enumerate(text.splitlines(), start=1):
              for category, pat in PATTERNS:
                  for m in pat.finditer(line):
                      # Patterns with alternation may capture into group 1 or 2;
                      # take whichever group fired.
                      raw = next((g for g in m.groups() if g is not None), None)
                      if raw is None:
                          continue
                      try:
                          value = _to_int(raw)
                      except ValueError:
                          continue
                      # Skip implausibly small mentions like "2 patients" inside an
                      # example table heading. Threshold is intentionally generous —
                      # this gate cares about full-cohort drift, not in-text examples.
                      if value < 5:
                          continue
                      context = line.strip()
                      if len(context) > 200:
                          context = context[:200] + "..."
                      out.append((category, Hit(str(path), lineno, value, context)))
          return out
      
      
      def collect_files(root: Path, extra_files: Iterable[Path] = ()) -> list[Path]:
          seen: set[Path] = set()
          files: list[Path] = []
          for pattern in DEFAULT_GLOBS:
              for hit in sorted(root.glob(pattern)):
                  if hit.is_file() and hit.suffix.lower() in {".md", ".tex", ".txt"}:
                      rp = hit.resolve()
                      if rp not in seen:
                          seen.add(rp)
                          files.append(hit)
          for f in extra_files:
              rp = f.resolve()
              if rp not in seen and f.is_file():
                  seen.add(rp)
                  files.append(f)
          return files
      
      
      def detect_drifts(hits_by_cat: dict[str, list[Hit]]) -> list[Drift]:
          """For each category, group hits by value. >1 distinct value = DRIFT."""
          drifts: list[Drift] = []
          for category, hits in hits_by_cat.items():
              # group by value
              by_value: dict[int, list[Hit]] = {}
              for h in hits:
                  by_value.setdefault(h.value, []).append(h)
              if len(by_value) <= 1:
                  continue
              # collapse for report
              all_hits = [h for hs in by_value.values() for h in hs]
              drifts.append(
                  Drift(
                      category=category,
                      values=list(by_value.keys()),
                      locations=all_hits,
                      severity="MAJOR",
                  )
              )
          return drifts
      
      
      def check_pool_lock(
          lock_path: Path,
          hits_by_cat: dict[str, list[Hit]],
      ) -> list[dict]:
          """If a pool-lock yaml is supplied, assert each locked count matches."""
          try:
              import yaml  # type: ignore
          except ImportError:
              return [
                  {
                      "violation": "pyyaml-missing",
                      "detail": "Install PyYAML to enable --pool-lock checks.",
                  }
              ]
          try:
              lock = yaml.safe_load(lock_path.read_text(encoding="utf-8"))
          except (OSError, yaml.YAMLError) as exc:
              return [{"violation": "lock-read-error", "detail": str(exc)}]
          if not isinstance(lock, dict):
              return [{"violation": "lock-format", "detail": "lock root must be mapping"}]
      
          violations: list[dict] = []
          # Map lock keys to scan categories.
          pairs = [
              ("include_count", "included"),
              ("exclude_count", "excluded"),
              ("final_pool_n", "studies_total"),
          ]
          for lock_key, scan_cat in pairs:
              if lock_key not in lock:
                  continue
              try:
                  expected = int(lock[lock_key])
              except (TypeError, ValueError):
                  violations.append(
                      {
                          "violation": "lock-non-integer",
                          "key": lock_key,
                          "raw": lock[lock_key],
                      }
                  )
                  continue
              hits = hits_by_cat.get(scan_cat, [])
              for h in hits:
                  if h.value != expected:
                      violations.append(
                          {
                              "violation": "pool-lock-mismatch",
                              "lock_key": lock_key,
                              "expected": expected,
                              "actual": h.value,
                              "file": h.file,
                              "line": h.line,
                              "context": h.context,
                          }
                      )
          return violations
      
      
      def build_report(
          files: list[Path],
          pool_lock: Path | None = None,
      ) -> Report:
          hits_by_cat: dict[str, list[Hit]] = {}
          for path in files:
              for cat, hit in scan_file(path):
                  hits_by_cat.setdefault(cat, []).append(hit)
      
          drifts = detect_drifts(hits_by_cat)
          lock_violations: list[dict] = []
          if pool_lock is not None:
              lock_violations = check_pool_lock(pool_lock, hits_by_cat)
      
          submission_safe = not drifts and not lock_violations
          return Report(
              submission_safe=submission_safe,
              drift_count=len(drifts),
              drifts=drifts,
              categories_scanned=sorted(hits_by_cat.keys()),
              files_scanned=[str(p) for p in files],
              lock_violations=lock_violations,
          )
      
      
      # --------------------------------------------------------------------------
      # CLI
      # --------------------------------------------------------------------------
      
      
      def main(argv: list[str] | None = None) -> int:
          parser = argparse.ArgumentParser(
              description=(
                  "Phase 5 cross-document N consistency gate. Scans manuscript, "
                  "abstract, PROSPERO record, cover letter, and supplementary "
                  "materials for cohort-size disagreement."
              )
          )
          parser.add_argument(
              "--root",
              type=Path,
              default=None,
              help="Project root. When supplied, scans default glob set.",
          )
          parser.add_argument(
              "--files",
              type=Path,
              nargs="*",
              default=[],
              help="Explicit file list (in addition to --root glob results).",
          )
          parser.add_argument(
              "--pool-lock",
              type=Path,
              default=None,
              help=(
                  "Path to FINAL_POOL_LOCK.yaml. When supplied, asserts every "
                  "locked count matches in scanned documents."
              ),
          )
          parser.add_argument(
              "--out",
              type=Path,
              default=None,
              help="Write JSON report to this path (in addition to stdout summary).",
          )
          parser.add_argument(
              "--quiet",
              action="store_true",
              help="Suppress per-drift stdout summary; rely on --out / exit code.",
          )
          args = parser.parse_args(argv)
      
          if args.root is None and not args.files:
              parser.error("must supply --root or --files")
      
          files: list[Path] = []
          if args.root is not None:
              if not args.root.is_dir():
                  parser.error(f"--root not a directory: {args.root}")
              files.extend(collect_files(args.root, args.files))
          else:
              files.extend(p for p in args.files if p.is_file())
      
          if not files:
              parser.error("no readable files matched")
      
          report = build_report(files, pool_lock=args.pool_lock)
      
          if args.out is not None:
              args.out.parent.mkdir(parents=True, exist_ok=True)
              args.out.write_text(json.dumps(report.as_dict(), indent=2), encoding="utf-8")
      
          if not args.quiet:
              if report.submission_safe:
                  print(
                      f"PASS: scanned {len(files)} files, "
                      f"{len(report.categories_scanned)} categories, no drift."
                  )
              else:
                  print(
                      f"FAIL: {report.drift_count} drift(s), "
                      f"{len(report.lock_violations)} lock violation(s)."
                  )
                  for d in report.drifts:
                      print(f"  - {d.category}: values={sorted(d.values)}")
                      for h in d.locations:
                          print(f"      {h.file}:{h.line}  N={h.value}  {h.context[:80]}")
                  for v in report.lock_violations:
                      print(f"  - LOCK {v}")
      
          return 0 if report.submission_safe else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • detect_copy_divergence.py 5.1 KB
      #!/usr/bin/env python3
      """Multi-copy manuscript divergence detector (sync-submission Phase 8).
      
      When a project keeps several hand-maintained manuscript copies — `manuscript.md`
      (the working SSOT), `manuscript_circulation.md` (co-author feedback), and
      `submission/<journal>/manuscript.md` (portal) — a batch of edits applied to the
      SSOT routinely lands in only some of the copies. The portal then receives a stale
      copy missing a subset of the edits, and the divergence surfaces (if at all) only
      when a reviewer notices an inconsistency.
      
      This detector is directional: it treats one file as the SSOT and reports, for each
      copy, the SSOT *claims* (numeric assertions and section headings) that did not
      propagate into the copy. A claim present in the SSOT but absent from a copy is an
      unpropagated edit; a claim present only in a copy is a copy-side divergence.
      
      INPUTS
        --ssot   the canonical manuscript file.
        --copy   a copy to check against the SSOT (repeatable).
      
      OUTPUT  (--out path)
        {ssot, copies: [{copy, unpropagated_to_copy, copy_only, verdict}], verdict}
        STALE_COPY (a copy missing SSOT claims) is the Major finding. Exit 1 (with
        --strict) when any copy is stale.
      
      Claims are matched as normalized strings, so wording differences do not register —
      only a changed/absent number or heading does. Review the lists; legitimately
      copy-specific sections (e.g. a circulation cover note) will show up as `copy_only`
      and can be ignored.
      
      Stdlib-only (re / json / argparse). Exit codes: 0 in sync (or report-only),
      1 a stale copy (with --strict), 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      CLAIM_PATTERNS = [
          re.compile(r"\bn\s*=\s*[0-9][0-9,]*", re.I),                       # n = 1,284
          re.compile(r"[0-9]+\.[0-9]+\s*%|\b[0-9]+\s*%"),                    # 12.5% / 30%
          re.compile(r"\bp\s*[=<>]\s*0?\.[0-9]+", re.I),                     # p = 0.034
          re.compile(r"\b(?:a?OR|a?HR|RR|sHR)\s*[=:]?\s*[0-9]+\.[0-9]+", re.I),  # OR 1.34
          re.compile(r"\b95%\s*CI[^)]*[0-9]\.[0-9]+", re.I),                 # 95% CI ... 1.02
      ]
      HEADING_RE = re.compile(r"^#{1,4}\s+\**([^\n*]+)", re.M)
      
      
      def _norm(s: str) -> str:
          return re.sub(r"\s+", " ", s.strip().lower()).replace(" ", "")
      
      
      def claims(text: str) -> set[str]:
          out: set[str] = set()
          for pat in CLAIM_PATTERNS:
              out.update(_norm(m.group(0)) for m in pat.finditer(text))
          for m in HEADING_RE.finditer(text):
              out.add("h:" + _norm(m.group(1)))
          return out
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Multi-copy manuscript divergence detector.")
          ap.add_argument("--ssot", required=True, help="canonical manuscript file")
          ap.add_argument("--copy", action="append", default=[], help="copy to check (repeatable)")
          ap.add_argument("--out", help="write JSON artifact to this path")
          ap.add_argument("--strict", action="store_true", help="exit 1 if any copy is stale")
          args = ap.parse_args()
      
          sp = Path(args.ssot)
          if not sp.is_file():
              sys.stderr.write(f"ERROR: SSOT not found: {args.ssot}\n")
              return 2
          if not args.copy:
              sys.stderr.write("ERROR: provide at least one --copy\n")
              return 2
      
          ssot_claims = claims(sp.read_text(encoding="utf-8"))
          copies = []
          n_stale = 0
          for c in args.copy:
              cp = Path(c)
              if not cp.is_file():
                  sys.stderr.write(f"WARN: copy not found, skipping: {c}\n")
                  continue
              cc = claims(cp.read_text(encoding="utf-8"))
              unprop = sorted(ssot_claims - cc)
              copy_only = sorted(cc - ssot_claims)
              verdict = "STALE_COPY" if unprop else "OK"
              if unprop:
                  n_stale += 1
              copies.append({
                  "copy": str(cp),
                  "unpropagated_to_copy": unprop,
                  "copy_only": copy_only,
                  "verdict": verdict,
              })
      
          result = {
              "ssot": str(sp),
              "copies": copies,
              "verdict": "DIVERGENT" if n_stale else "OK",
              "suggested_fix": (
                  "Re-propagate the unpropagated SSOT claims into each stale copy, or "
                  "generate the copies from the SSOT via a build step instead of hand-maintaining them."
              ) if n_stale else None,
          }
      
          print("=" * 41)
          print(" Multi-copy manuscript divergence (Phase 8)")
          print("=" * 41)
          print(f"SSOT: {sp}")
          for c in copies:
              mark = "✗" if c["verdict"] == "STALE_COPY" else "✓"
              print(f"{mark} {c['copy']}")
              if c["unpropagated_to_copy"]:
                  print(f"    unpropagated SSOT claims ({len(c['unpropagated_to_copy'])}): "
                        f"{c['unpropagated_to_copy'][:6]}")
          if n_stale:
              print(f"\nDIVERGENT: {n_stale} stale copy(ies). {result['suggested_fix']}")
          else:
              print("\nOK: every SSOT claim propagated to all copies.")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(json.dumps({"detector": "detect_copy_divergence", **result}, indent=2), encoding="utf-8")
              print(f"wrote {args.out}")
      
          return 1 if (args.strict and n_stale) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • figure_portal_readiness_check.py 6.2 KB
      #!/usr/bin/env python3
      """Figure portal-readiness gate — catch an over-cap or wrong-format figure BEFORE upload.
      
      Two portal facts bounce a figure at the upload button, after a long submission session:
      
        * a size cap — JACC: Asia rejects a figure over 25 MB, which a raw uncompressed
          600-dpi RGBA TIFF sails straight past;
        * a format allowlist — Springer Nature's SNAPP accepts only `.tiff` / `.jpeg` / `.eps`
          and REJECTS the `.png` a figure was rendered as.
      
      Both are deterministic from the file on disk — a byte size and an extension — so they can be
      caught at pre-flight instead of at the portal. This is the DETECTION half; the fix is to
      regenerate the figure with `/make-figures export_portal_tiff.py` (LZW + RGBA→RGB flatten).
      
      This is a stdlib pre-flight sub-check (like scope_drift_check.py / cover_letter_drift_check.py),
      not a manuscript-integrity detector — its filename intentionally avoids the `check_`/`detect_`
      prefix so it is not counted in the MedSci-Audit detector suite.
      
      Verdicts:
        FIGURE_OVERSIZE (Major)         a figure file exceeds --max-mb (default 25).
        FIGURE_FORMAT_REJECTED (Major)  a figure's extension is not in the portal's --accept set.
                                        Only evaluated when --accept is given (a portal-specific
                                        allowlist, e.g. `--accept tiff --accept jpeg --accept eps`
                                        for SNAPP); without it, format is not judged.
      
      INPUT
        --figures-dir DIR   directory of figure files (scanned recursively for image extensions).
        --accept EXT ...    portal-accepted extensions (repeatable; dot optional; tif==tiff, jpg==jpeg).
        --max-mb N          size cap in MB (default 25; a figure strictly over this is flagged).
      
      OUTPUT (--out PATH)
        {"detector": "figure_portal_readiness_check", "scanned", "findings":
           [{path, kind, size_mb, ext, label, severity}], "summary", "submission_safe"}
      
      Stdlib-only. Exit codes: 0 clean, 1 finding, 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      from pathlib import Path
      
      # Files treated as figures. A portal-field .txt or a manuscript .md is not a figure.
      IMAGE_EXTS = {".png", ".tif", ".tiff", ".jpg", ".jpeg", ".eps", ".pdf", ".gif", ".bmp", ".svg"}
      MB = 1024 * 1024
      
      
      def _norm_ext(e: str) -> str:
          """Lowercase, strip a leading dot, and canonicalize tif->tiff / jpg->jpeg."""
          e = e.lower().lstrip(".")
          return {"tif": "tiff", "jpg": "jpeg"}.get(e, e)
      
      
      def analyze(figures_dir: str, accept, max_mb: float) -> dict:
          d = Path(figures_dir)
          if not d.is_dir():
              sys.stderr.write(f"ERROR: --figures-dir not a directory: {figures_dir}\n")
              sys.exit(2)
          accept_set = {_norm_ext(a) for a in accept} if accept else None
          findings: list[dict] = []
          scanned = 0
          for p in sorted(d.rglob("*")):
              if not p.is_file() or p.suffix.lower() not in IMAGE_EXTS:
                  continue
              scanned += 1
              size_mb = p.stat().st_size / MB
              ext = _norm_ext(p.suffix)
              if size_mb > max_mb:
                  findings.append({
                      "path": str(p), "kind": "FIGURE_OVERSIZE", "size_mb": round(size_mb, 2),
                      "ext": ext, "severity": "Major",
                      "label": (f"{size_mb:.1f} MB exceeds the {max_mb:g} MB portal cap — re-export "
                                f"LZW-compressed / flattened (make-figures export_portal_tiff.py)"),
                  })
              if accept_set is not None and ext not in accept_set:
                  findings.append({
                      "path": str(p), "kind": "FIGURE_FORMAT_REJECTED", "size_mb": round(size_mb, 2),
                      "ext": ext, "severity": "Major",
                      "label": (f".{p.suffix.lstrip('.')} is not accepted by this portal "
                                f"(accepts: {', '.join(sorted(accept_set))}) — convert before upload"),
                  })
          return {
              "scanned": {"figures": scanned},
              "findings": findings,
              "summary": {"oversize": sum(1 for f in findings if f["kind"] == "FIGURE_OVERSIZE"),
                          "format_rejected": sum(1 for f in findings if f["kind"] == "FIGURE_FORMAT_REJECTED")},
              "submission_safe": not findings,
          }
      
      
      def render(result: dict) -> str:
          lines = ["| Figure | Size (MB) | Kind | Detail |", "|---|---|---|---|"]
          for f in result["findings"]:
              lines.append(f"| {Path(f['path']).name} | {f['size_mb']} | {f['kind']} | {f['label']} |")
          if len(lines) == 2:
              lines.append("| (none) | — | — | every figure is under the cap and in an accepted format |")
          return "\n".join(lines)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Figure portal-readiness gate (size + accepted format).")
          ap.add_argument("--figures-dir", required=True, help="directory of figure files (scanned recursively)")
          ap.add_argument("--accept", action="append", default=[], metavar="EXT",
                          help="portal-accepted extension (repeatable; dot optional). Omit to skip the format check.")
          ap.add_argument("--max-mb", type=float, default=25.0, help="size cap in MB (default 25)")
          ap.add_argument("--out", help="write JSON artifact to this path")
          ap.add_argument("--quiet", action="store_true", help="suppress stdout table")
          args = ap.parse_args()
      
          result = analyze(args.figures_dir, args.accept, args.max_mb)
      
          if not args.quiet:
              print("=" * 44)
              print(" Figure Portal Readiness")
              print("=" * 44)
              print(render(result))
              print()
              s = result["summary"]
              n = s["oversize"] + s["format_rejected"]
              if n:
                  print(f"NOT PORTAL-READY: {s['oversize']} over-cap, {s['format_rejected']} wrong-format "
                        f"figure(s). Re-export before upload.")
              else:
                  print(f"OK: {result['scanned']['figures']} figure(s) are portal-ready.")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(
                  json.dumps({"detector": "figure_portal_readiness_check", **result}, indent=2, ensure_ascii=False),
                  encoding="utf-8")
              if not args.quiet:
                  print(f"\nwrote {args.out}")
      
          return 1 if result["findings"] else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • preflight_gate.py 28.4 KB
      #!/usr/bin/env python3
      """Submission pre-flight gate — the single last-step-before-freeze halt check.
      
      medsci-skills already ships deterministic detectors + /verify-refs, but they are
      invoked piecemeal. This orchestrator runs the submission-risk checks together,
      once, before freeze/submission, aggregates them into one audit manifest
      (qc/preflight_gate_report.json), and EXITS NON-ZERO on any blocker so a CI step
      or build wrapper can halt. It composes existing scripts via subprocess and
      reimplements none of them; the halt decision is driven by each sub-check's
      normalized exit code (not by parsing its JSON), so a sub-check schema change
      cannot silently weaken the gate.
      
      DEFAULT TIERS — only the unambiguous, deterministic errors halt by default:
        P0 (halt):  placeholders (blocker markers), citation_keys (UNDEFINED [@key]),
                    references (duplicate PMID/DOI, offline-deterministic; fabricated/
                    author-mismatch too under --online), sync_drift (canonical hash),
                    checklist_dump_leak (internal /check-reporting audit dump in a
                    reviewer-facing file).
        P1 (warn):  xref, copy_divergence, scope_drift, cover_letter_drift,
                    cross_document_n, cross_artifact_stale (heuristic / conditional —
                    they RUN and REPORT but do not halt unless promoted). Promote with
                    --strict (all P1 -> P0), --double-blind (asset_anonymization -> P0),
                    or --require ID. Drop a check with --skip ID.
      
      A check whose inputs are absent is recorded "skipped" (NA), never a blocker — so
      the gate is tolerant of projects that lack a cover letter, rendered docx, copies,
      etc. The offline references pass is deterministic and catches duplicates +
      pagination placeholders; an online /verify-refs --strict (PubMed/CrossRef) remains
      the authoritative fabrication/author check.
      
      Stdlib-only. Exit codes: 0 clean (no blocker), 1 halt (>=1 blocker), 2 gate
      config/IO error (e.g. a --require'd check could not run).
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import subprocess
      import sys
      from pathlib import Path
      
      # Repo root: skills/sync-submission/scripts/preflight_gate.py -> parents[3].
      REPO_ROOT = Path(__file__).resolve().parents[3]
      SCRIPTS_DIR = Path(__file__).resolve().parent
      sys.path.insert(0, str(SCRIPTS_DIR))
      from sync_submission import resolve_canonical, submission_md_path, bundle_binding  # noqa: E402
      
      PY = sys.executable
      
      S = {
          "placeholders": REPO_ROOT / "skills/write-paper/scripts/check_placeholders.py",
          "citation_keys": REPO_ROOT / "skills/manage-refs/scripts/check_citation_keys.py",
          "verify_refs": REPO_ROOT / "skills/verify-refs/scripts/verify_refs.py",
          "sync_submission": REPO_ROOT / "skills/sync-submission/scripts/sync_submission.py",
          "cross_artifact_stale": REPO_ROOT / "skills/sync-submission/scripts/check_cross_artifact_stale.py",
          "cross_document_n": REPO_ROOT / "skills/sync-submission/scripts/cross_document_n_check.py",
          "xref": REPO_ROOT / "skills/manage-refs/scripts/check_xref.py",
          "copy_divergence": REPO_ROOT / "skills/sync-submission/scripts/detect_copy_divergence.py",
          "scope_drift": REPO_ROOT / "skills/sync-submission/scripts/scope_drift_check.py",
          "cover_letter_drift": REPO_ROOT / "skills/sync-submission/scripts/cover_letter_drift_check.py",
          "asset_anonymization": REPO_ROOT / "skills/sync-submission/scripts/check_asset_anonymization.py",
          "checklist_dump_leak": REPO_ROOT / "skills/sync-submission/scripts/check_checklist_dump_leak.py",
          "portal_field_residue": REPO_ROOT / "skills/sync-submission/scripts/check_portal_field_residue.py",
          "portal_mirror": REPO_ROOT / "skills/sync-submission/scripts/check_portal_mirror.py",
          "credit_integrity": REPO_ROOT / "skills/sync-submission/scripts/check_credit_integrity.py",
          "figure_readiness": REPO_ROOT / "skills/sync-submission/scripts/figure_portal_readiness_check.py",
      }
      
      
      class Ctx:
          """Resolved input paths (convention from --project-root/--journal, flags override)."""
      
          def __init__(self, args):
              self.root = Path(args.project_root).resolve()
              self.journal = args.journal
              self.online = args.online
              self.qc = self.root / "qc"
              self.manuscript = self._manuscript(args)
              self.bib = self._first(args.bib, [
                  self.root / "references/library.bib",
                  self.root / "refs.bib",
                  self.root / "references.bib",
                  (self.manuscript.parent / "refs.bib") if self.manuscript else None,
                  (self.manuscript.parent / "_src/refs.bib") if self.manuscript else None,
              ])
              self.docx = self._first(args.docx, sorted(
                  (self.root / "submission" / self.journal / "manuscript").glob("*.docx")
              ) if self.journal else [])
              self.copies = [Path(c).resolve() for c in (args.copy or [])]
              self.cover_letter = self._first(args.cover_letter, [
                  (self.root / "submission" / self.journal / "cover_letter.md") if self.journal else None,
              ])
              self.asset_dir = self._first_dir(args.asset_dir, [
                  (self.root / "submission" / self.journal) if self.journal else None,
              ])
              self.prospero = self._first(args.prospero, sorted((self.root / "prospero").glob("*.md")))
              self.pool_lock = self._first(args.pool_lock, [
                  self.root / "FINAL_POOL_LOCK.yaml",
                  self.root / "2_Data/FINAL_POOL_LOCK.yaml",
              ])
              self.aux = [d for d in [self.root / "supplement", self.root / "supplementary", self.qc]
                          if d.is_dir()]
              self.portal_fields = self._first_dir(getattr(args, "portal_fields", None), [
                  self.root / "portal_fields",
                  (self.root / "submission" / self.journal / "portal_fields") if self.journal else None,
              ])
              self.figures_dir = self._first_dir(getattr(args, "figures_dir", None), [
                  (self.root / "submission" / self.journal / "figures") if self.journal else None,
                  self.root / "figures",
                  self.root / "manuscript" / "figures",
              ])
              self.journal_profile = self._journal_profile(getattr(args, "journal_profile", None))
              self.figure_accept = getattr(args, "figure_accept", []) or []
              self.figure_max_mb = getattr(args, "figure_max_mb", 25.0)
      
          def _journal_profile(self, explicit):
              """The profile carrying this journal's `## Portal Mechanics` block.
      
              Resolved from the --journal slug against the shipped profile library by normalized
              name, so `npj-dm`, `npj_digital_medicine` and `npjDigitalMedicine` all land on the
              same file. Unresolved is fine: the mirror check exits 2 (skipped) without a profile
              rather than inventing a portal contract.
              """
              if explicit:
                  p = Path(explicit).resolve()
                  return p if p.is_file() else None
              if not self.journal:
                  return None
              norm = lambda t: re.sub(r"[^a-z0-9]", "", t.lower())
              want = norm(self.journal)
              d = REPO_ROOT / "skills/write-paper/references/journal_profiles"
              if not d.is_dir():
                  return None
              for cand in sorted(d.glob("*.md")):
                  if norm(cand.stem) == want:
                      return cand
              return None
      
          def _manuscript(self, args):
              if args.manuscript:
                  return Path(args.manuscript).resolve()
              c = resolve_canonical(self.root, None)
              return c if c.exists() else None
      
          @staticmethod
          def _first(explicit, candidates):
              if explicit:
                  return Path(explicit).resolve()
              for c in candidates:
                  if c and Path(c).is_file():
                      return Path(c).resolve()
              return None
      
          @staticmethod
          def _first_dir(explicit, candidates):
              if explicit:
                  return Path(explicit).resolve()
              for c in candidates:
                  if c and Path(c).is_dir():
                      return Path(c).resolve()
              return None
      
      
      # --- check registry ----------------------------------------------------------
      # Each check: build_argv(ctx) -> argv list or None (None => skipped, missing input);
      # exit_map maps a return code to ok/finding/skipped/error; tier P0/P1; promote flags;
      # artifact relative to qc/ (for the human message). Halt is decided ONLY by the
      # normalized status, never by parsing the artifact.
      
      def _argv_placeholders(c):
          if not c.manuscript:
              return None
          return [PY, str(S["placeholders"]), "--manuscript", str(c.manuscript),
                  "--quiet", "--out", str(c.qc / "placeholder_audit.json")]
      
      def _argv_citation_keys(c):
          if not (c.manuscript and c.bib):
              return None
          return [PY, str(S["citation_keys"]), str(c.manuscript), str(c.bib)]
      
      def _argv_references(c):
          src = c.bib or c.manuscript
          if not src:
              return None
          argv = [PY, str(S["verify_refs"]), str(src), "--project-root", str(c.root)]
          if not c.online:
              argv.append("--offline")
          return argv
      
      def _argv_sync_drift(c):
          if not c.journal:
              return None
          if not resolve_canonical(c.root, None).exists():
              return None
          return [PY, str(S["sync_submission"]), "audit",
                  "--project-root", str(c.root), "--journal", c.journal]
      
      def _argv_cross_artifact(c):
          if not (c.manuscript and c.aux):
              return None
          argv = [PY, str(S["cross_artifact_stale"]), "--manuscript", str(c.manuscript),
                  "--quiet", "--out", str(c.qc / "cross_artifact.json")]
          for d in c.aux:
              argv += ["--aux", str(d)]
          return argv
      
      def _argv_cross_document_n(c):
          if not c.manuscript:
              return None
          argv = [PY, str(S["cross_document_n"]), "--root", str(c.root),
                  "--out", str(c.qc / "cross_document_n.json")]
          if c.pool_lock:
              argv += ["--pool-lock", str(c.pool_lock)]
          return argv
      
      def _argv_xref(c):
          if not (c.manuscript and c.docx):
              return None
          return [PY, str(S["xref"]), "--md", str(c.manuscript), "--docx", str(c.docx),
                  "--quiet", "--strict", "--out", str(c.qc / "xref_audit.json")]
      
      def _argv_copy_divergence(c):
          if not (c.manuscript and c.copies):
              return None
          argv = [PY, str(S["copy_divergence"]), "--ssot", str(c.manuscript),
                  "--strict", "--out", str(c.qc / "copy_divergence.json")]
          for cp in c.copies:
              argv += ["--copy", str(cp)]
          return argv
      
      def _argv_scope_drift(c):
          if not c.manuscript:
              return None
          argv = [PY, str(S["scope_drift"]), "--manuscript", str(c.manuscript),
                  "--quiet", "--out", str(c.qc / "scope_drift.json")]
          if c.prospero:
              argv += ["--prospero", str(c.prospero)]
          return argv
      
      def _argv_cover_letter(c):
          if not (c.manuscript and c.cover_letter):
              return None
          argv = [PY, str(S["cover_letter_drift"]), "--manuscript", str(c.manuscript),
                  "--cover-letter", str(c.cover_letter), "--out", str(c.qc / "cover_letter_drift.json")]
          if c.bib:
              argv += ["--refs", str(c.bib)]
          return argv
      
      def _argv_asset_anon(c):
          if not c.asset_dir:
              return None
          return [PY, str(S["asset_anonymization"]), "--dir", str(c.asset_dir),
                  "--quiet", "--out", str(c.qc / "asset_anon.json")]
      
      def _argv_checklist_dump(c):
          if not c.asset_dir:
              return None
          return [PY, str(S["checklist_dump_leak"]), "--dir", str(c.asset_dir),
                  "--quiet", "--out", str(c.qc / "checklist_dump_leak.json")]
      
      def _argv_portal_residue(c):
          if not c.portal_fields:
              return None
          return [PY, str(S["portal_field_residue"]), "--dir", str(c.portal_fields),
                  "--quiet", "--out", str(c.qc / "portal_field_residue.json")]
      
      def _argv_portal_mirror(c):
          # Needs BOTH the paste artifacts and the journal profile that records which fields
          # replace the manuscript; without the profile the check exits 2 (skipped) on its own.
          if not c.portal_fields or not c.manuscript:
              return None
          argv = [PY, str(S["portal_mirror"]), "--manuscript", str(c.manuscript),
                  "--portal-dir", str(c.portal_fields), "--quiet",
                  "--out", str(c.qc / "portal_mirror.json")]
          if c.journal_profile:
              argv += ["--profile", str(c.journal_profile)]
          return argv
      
      
      def _argv_credit_integrity(c):
          if not c.manuscript:
              return None
          argv = [PY, str(S["credit_integrity"]), "--manuscript", str(c.manuscript),
                  "--quiet", "--out", str(c.qc / "credit_integrity.json")]
          rec = c.root / "contributions.yaml"
          if rec.is_file():
              argv += ["--contribution-record", str(rec)]
          return argv
      
      
      def _argv_figure_readiness(c):
          if not c.figures_dir:
              return None
          argv = [PY, str(S["figure_readiness"]), "--figures-dir", str(c.figures_dir),
                  "--max-mb", str(c.figure_max_mb), "--quiet",
                  "--out", str(c.qc / "figure_readiness.json")]
          for ext in c.figure_accept:
              argv += ["--accept", ext]
          return argv
      
      
      CHECKS = [
          {"id": "placeholders", "tier": "P0", "build": _argv_placeholders,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "placeholder_audit.json"},
          {"id": "citation_keys", "tier": "P0", "build": _argv_citation_keys,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": None},
          {"id": "references", "tier": "P0", "build": _argv_references,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped", 3: "skipped"},
           "artifact": "reference_audit.json", "post": "references"},
          {"id": "sync_drift", "tier": "P0", "build": _argv_sync_drift,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": None},
          {"id": "cross_artifact_stale", "tier": "P1", "build": _argv_cross_artifact,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "cross_artifact.json",
           "strict_promote": True},
          {"id": "cross_document_n", "tier": "P1", "build": _argv_cross_document_n,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "cross_document_n.json",
           "strict_promote": True},
          {"id": "xref", "tier": "P1", "build": _argv_xref,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "xref_audit.json",
           "strict_promote": True},
          {"id": "copy_divergence", "tier": "P1", "build": _argv_copy_divergence,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "copy_divergence.json",
           "strict_promote": True},
          {"id": "scope_drift", "tier": "P1", "build": _argv_scope_drift,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "scope_drift.json",
           "strict_promote": True},
          # cover_letter_drift has INVERTED exit codes: 0 clean, 2 drift, 1 missing input.
          {"id": "cover_letter_drift", "tier": "P1", "build": _argv_cover_letter,
           "exit_map": {0: "ok", 2: "finding", 1: "skipped"}, "artifact": "cover_letter_drift.json",
           "strict_promote": True},
          {"id": "asset_anonymization", "tier": "P1", "build": _argv_asset_anon,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "asset_anon.json",
           "double_blind_promote": True},
          # A leaked /check-reporting or /self-review audit dump in a reviewer-facing
          # file is never acceptable (exposes auto-fix notes, raw JSON, stale content)
          # — P0 blocker, independent of blinding.
          {"id": "checklist_dump_leak", "tier": "P0", "build": _argv_checklist_dump,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "checklist_dump_leak.json"},
          # Markdown residue (---, **bold**, ^x^, [text](url)) in a paste-verbatim portal
          # .txt field would print literally in the published abstract/keyword field.
          {"id": "portal_field_residue", "tier": "P1", "build": _argv_portal_residue,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "portal_field_residue.json",
           "strict_promote": True},
          # A portal field that REPLACES the manuscript section is the copy that gets published,
          # so a declaration that never reaches the box is never published. P1 because it depends
          # on a journal profile recording the contract; promote with --strict.
          {"id": "portal_mirror", "tier": "P1", "build": _argv_portal_mirror,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "portal_mirror.json",
           "strict_promote": True},
          # CRediT is published with the paper; a term outside the fourteen, an initial that
          # matches no author, or a byline author credited nowhere is a factual defect. Author
          # ORDER and equal-contribution are deliberately not gated.
          {"id": "credit_integrity", "tier": "P1", "build": _argv_credit_integrity,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "credit_integrity.json",
           "strict_promote": True},
          # A figure over the portal's size cap (25 MB) or in a rejected format (SNAPP takes no
          # .png) bounces at the upload button — deterministic from the file's bytes + extension.
          {"id": "figure_readiness", "tier": "P1", "build": _argv_figure_readiness,
           "exit_map": {0: "ok", 1: "finding", 2: "skipped"}, "artifact": "figure_readiness.json",
           "strict_promote": True},
      ]
      
      
      def _load(path: Path):
          try:
              return json.loads(path.read_text(encoding="utf-8"))
          except (OSError, json.JSONDecodeError):
              return None
      
      
      def _message(check_id, status, artifact_path, stdout):
          """Compact human message from the sub-check artifact (best effort, never gates)."""
          j = _load(artifact_path) if artifact_path else None
          if check_id == "references" and j:
              c = j.get("counts", {}) if isinstance(j.get("counts"), dict) else {}
              dups = j.get("duplicate_findings") or []
              bits = []
              if dups:
                  bits.append(f"{len(dups)} duplicate ref(s)")
              for k in ("FABRICATED", "MISMATCH", "UNVERIFIED"):
                  if c.get(k):
                      bits.append(f"{c[k]} {k.lower()}")
              return ", ".join(bits) or "references verified"
          if check_id == "placeholders" and j:
              s = j.get("summary", {})
              return f"{s.get('blocker', 0)} blocker, {s.get('warn', 0)} warn marker(s)"
          if check_id == "cross_document_n" and j:
              return f"{j.get('drift_count', 0)} N-drift(s)"
          if check_id == "cover_letter_drift" and j:
              return f"{len(j.get('drifts', []))} cover-letter drift(s)"
          if check_id == "scope_drift" and j:
              return f"{len(j.get('limitations_only_anchors', []))} limitations-only anchor(s)"
          if check_id == "copy_divergence" and j:
              return str(j.get("verdict", "")) or "copies checked"
          if check_id in ("cross_artifact_stale", "asset_anonymization", "checklist_dump_leak",
                          "portal_field_residue", "figure_readiness") and j:
              return ", ".join(f"{k}={v}" for k, v in (j.get("summary") or {}).items()) or "scanned"
          if check_id == "sync_drift" and artifact_path is None and stdout:
              try:
                  return json.loads(stdout).get("status", "")
              except json.JSONDecodeError:
                  return ""
          # citation_keys / sync_drift: last non-empty stdout line
          if stdout:
              lines = [ln for ln in stdout.splitlines() if ln.strip()]
              return lines[-1][:120] if lines else ""
          return ""
      
      
      def run_check(spec, ctx, args):
          cid = spec["id"]
          in_skip = cid in args.skip
          in_require = cid in args.require
          if in_skip:
              return {"id": cid, "tier": spec["tier"], "script": _rel(S[_script_key(cid)]),
                      "ran": False, "exit_code": None, "status": "skipped",
                      "blocker": False, "artifact": None, "message": "skipped by --skip"}
      
          # effective tier
          effective_p0 = (spec["tier"] == "P0"
                          or in_require
                          or (spec.get("strict_promote") and args.strict)
                          or (spec.get("double_blind_promote") and args.double_blind))
      
          argv = spec["build"](ctx)
          artifact_path = ctx.qc / spec["artifact"] if spec.get("artifact") else None
          if argv is None:
              status = "error" if in_require else "skipped"
              msg = "required check could not run (missing input)" if in_require else "missing input"
              return {"id": cid, "tier": "P0" if effective_p0 else "P1",
                      "script": _rel(S[_script_key(cid)]), "ran": False, "exit_code": None,
                      "status": status, "blocker": False, "artifact": None, "message": msg}
      
          ctx.qc.mkdir(parents=True, exist_ok=True)
          proc = subprocess.run(argv, capture_output=True, text=True)
          rc = proc.returncode
          base = spec["exit_map"].get(rc, "error")
      
          # references offline: exit 0 can still carry UNVERIFIED -> advisory warn
          if base == "ok" and spec.get("post") == "references":
              j = _load(artifact_path)
              if j and j.get("requires_manual_reference_check"):
                  base = "warn_post"
      
          if base == "finding":
              status = "blocker" if effective_p0 else "warn"
          elif base == "warn_post":
              status = "warn"
          elif base == "skipped" and in_require:
              status = "error"
          else:
              status = base  # ok / skipped / error
      
          msg = _message(cid, status, artifact_path, proc.stdout)
          if status == "error":
              msg = (proc.stderr.strip().splitlines() or ["unexpected exit"])[-1][:160] if proc.stderr else f"unexpected exit {rc}"
          if base == "warn_post" and not msg:
              msg = "unverified references — run online /verify-refs"
      
          return {"id": cid, "tier": "P0" if effective_p0 else "P1",
                  "script": _rel(S[_script_key(cid)]), "ran": True, "exit_code": rc,
                  "status": status, "blocker": status == "blocker",
                  "artifact": _rel(artifact_path) if (artifact_path and artifact_path.exists()) else None,
                  "invocation": [_rel(Path(a)) if Path(a).is_absolute() else a for a in argv],
                  "message": msg}
      
      
      _SCRIPT_KEY = {
          "placeholders": "placeholders", "citation_keys": "citation_keys", "references": "verify_refs",
          "sync_drift": "sync_submission", "cross_artifact_stale": "cross_artifact_stale",
          "cross_document_n": "cross_document_n", "xref": "xref", "copy_divergence": "copy_divergence",
          "scope_drift": "scope_drift", "cover_letter_drift": "cover_letter_drift",
          "asset_anonymization": "asset_anonymization",
          "checklist_dump_leak": "checklist_dump_leak",
          "portal_field_residue": "portal_field_residue",
          "portal_mirror": "portal_mirror",
          "credit_integrity": "credit_integrity",
          "figure_readiness": "figure_readiness",
      }
      
      
      def _script_key(cid):
          return _SCRIPT_KEY[cid]
      
      
      def _rel(p):
          try:
              return str(Path(p).resolve().relative_to(REPO_ROOT))
          except ValueError:
              return str(p)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(
              description="Submission pre-flight gate — run submission-risk checks and halt on any blocker.")
          ap.add_argument("--project-root", default=".", help="project root (default: .)")
          ap.add_argument("--journal", default=None, help="journal slug under submission/ (for sync/cover/asset checks)")
          ap.add_argument("--journal-profile", default=None,
                          help="journal profile .md carrying the '## Portal Mechanics' block "
                               "(default: resolved from --journal against the shipped profile library)")
          ap.add_argument("--strict", action="store_true", help="promote all P1 checks to halting (P0)")
          ap.add_argument("--online", action="store_true",
                          help="run the references check online (PubMed/CrossRef) so fabricated/mismatched refs halt too")
          ap.add_argument("--double-blind", action="store_true", help="promote asset_anonymization to halting (P0)")
          ap.add_argument("--require", action="append", default=[], metavar="ID",
                          help="force a check to halting; error if it cannot run (repeatable)")
          ap.add_argument("--skip", action="append", default=[], metavar="ID", help="drop a check (repeatable)")
          ap.add_argument("--manuscript", default=None)
          ap.add_argument("--bib", default=None)
          ap.add_argument("--docx", default=None)
          ap.add_argument("--copy", action="append", default=[], help="hand-maintained copy to check (repeatable)")
          ap.add_argument("--cover-letter", default=None)
          ap.add_argument("--asset-dir", default=None)
          ap.add_argument("--portal-fields", default=None,
                          help="directory of portal paste-verbatim .txt fields (abstract.txt, keywords.txt, …)")
          ap.add_argument("--figures-dir", default=None,
                          help="directory of figure files to size/format-check (default: submission/<journal>/figures or ./figures)")
          ap.add_argument("--figure-accept", action="append", default=[], metavar="EXT",
                          help="portal-accepted figure extension (repeatable; e.g. tiff jpeg eps for SNAPP). Omit to size-check only.")
          ap.add_argument("--figure-max-mb", type=float, default=25.0,
                          help="figure size cap in MB for the readiness check (default 25)")
          ap.add_argument("--prospero", default=None)
          ap.add_argument("--pool-lock", default=None)
          ap.add_argument("--out", default=None, help="report path (default: <project-root>/qc/preflight_gate_report.json)")
          ap.add_argument("--quiet", action="store_true")
          args = ap.parse_args()
      
          known = {c["id"] for c in CHECKS}
          bad = (set(args.require) | set(args.skip)) - known
          if bad:
              sys.stderr.write(f"ERROR: unknown check id(s): {', '.join(sorted(bad))}\n"
                               f"known: {', '.join(sorted(known))}\n")
              return 2
      
          ctx = Ctx(args)
          if not ctx.root.is_dir():
              sys.stderr.write(f"ERROR: project root not found: {ctx.root}\n")
              return 2
      
          # This identifies the declared bundle present during the run. It is not
          # per-file inspection coverage (some checks inspect only a selected DOCX).
          def snapshot():
              try:
                  return bundle_binding(ctx.root, ctx.journal)
              except (OSError, ValueError, KeyError, TypeError):
                  return None
          binding_before = snapshot()
          checks = [run_check(spec, ctx, args) for spec in CHECKS]
          binding_after = snapshot()
      
          summary = {k: 0 for k in ("ok", "warn", "blocker", "skipped", "error")}
          for c in checks:
              summary[c["status"]] = summary.get(c["status"], 0) + 1
          halt = any(c["blocker"] for c in checks)
          gate_error = any(c["status"] == "error" for c in checks)
      
          report = {
              "schema_version": 2,
              "generated_by": "preflight_gate.py",
              "project_root": str(ctx.root),
              "journal": ctx.journal,
              "strict": args.strict,
              "online": args.online,
              "double_blind": args.double_blind,
              # Compatibility field: no configured blocker/error, NOT submission approval.
              "submission_safe": not halt and not gate_error,
              "submission_safe_scope": "configured_checks_only",
              "readiness": "not_assessed",
              "coverage": {
                  "status": "incomplete" if summary["skipped"] or summary["error"] else "executed",
                  "invoked": [c["id"] for c in checks if c["ran"]],
                  "executed": [c["id"] for c in checks if c["status"] in {"ok", "warn", "blocker"}],
                  "skipped": [c["id"] for c in checks if c["status"] == "skipped"],
                  "errors": [c["id"] for c in checks if c["status"] == "error"],
                  "visual_review": "not_assessed",
                  "source_claim_fidelity": "not_assessed",
                  "reuse_permissions": "not_assessed",
              },
              "bundle_binding": binding_before,
              "bundle_unchanged_during_checks": bool(binding_before and binding_before == binding_after),
              "bundle_binding_scope": "declared sources, render dependencies and package bytes; other check inputs are not bound",
              "halt": halt,
              "summary": summary,
              "checks": checks,
          }
      
          out = Path(args.out).resolve() if args.out else (ctx.qc / "preflight_gate_report.json")
          out.parent.mkdir(parents=True, exist_ok=True)
          out.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
      
          if not args.quiet:
              print("=" * 49)
              print(" Submission Pre-Flight Gate")
              print("=" * 49)
              print("| Check | Tier | Status | Detail |")
              print("|---|---|---|---|")
              for c in checks:
                  print(f"| {c['id']} | {c['tier']} | {c['status']} | {c['message']} |")
              print()
              print(f"summary: {summary}")
              print(f"wrote {_rel(out)}")
              if gate_error:
                  print("\nERROR: a required check could not run — fix inputs or adjust --require.")
              elif halt:
                  blockers = [c["id"] for c in checks if c["blocker"]]
                  print(f"\nHALT: {len(blockers)} blocker(s) — {', '.join(blockers)}. Submission is NOT safe.")
              else:
                  print("\nNo configured blockers. "
                        f"{summary['skipped']} checks skipped; {summary['warn']} warnings. "
                        "Submission readiness, visual fidelity and permissions are not assessed.")
      
          if gate_error:
              return 2
          return 1 if halt else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • scope_drift_check.py 11.8 KB
      #!/usr/bin/env python3
      """
      scope_drift_check.py — Phase 6 intra-manuscript scope drift detection.
      
      Detects two related failure modes:
      
      1. **Numeric anchor in Limitations only**: an AUC / OR / HR / RR /
         sensitivity / specificity value appears in the Limitations or Discussion
         section but is absent from Methods + Results. This is a strong indicator
         that a late-revision sensitivity analysis was introduced without
         propagating to the primary report, leaving the manuscript's stated scope
         inconsistent with its prose-level claims.
      
      2. **PROSPERO ↔ Methods synthesis-method drift**: the PROSPERO record
         commits to a synthesis method (e.g., Freeman-Tukey transformation,
         random-effects DerSimonian-Laird, bivariate, HSROC, Bayesian) but the
         Methods section silently uses a different one — or vice versa. This
         is a documented "silent protocol deviation" pattern that reviewers
         flag as fabrication-grade if accompanied by a "no amendment lodged"
         PROSPERO note.
      
      Why this gate exists
      ====================
      Cross-project precedents (anonymized):
      - Project (DTA-MA example, reporting-quality SR variant): a leave-pair-out
        sensitivity envelope appeared in Limitations with five AUC values and
        four CIs. The primary pool AUC `0.869` was not reported in Methods or
        Results.
      - Project (intervention-MA example): PROSPERO committed to Freeman-Tukey
        pooled-proportion; Methods said descriptive-only Python with no R; the
        manuscript line "no amendment lodged" turned the silent change into a
        documented violation.
      
      P0 active-fix pattern when caught.
      
      Usage
      =====
      
          python scope_drift_check.py \\
              --manuscript manuscript.md \\
              --prospero prospero/prospero_v2.md \\
              --out qc/scope_drift.json
      
          python scope_drift_check.py \\
              --manuscript manuscript.md \\
              --out qc/scope_drift.json
      
      Output (qc/scope_drift.json):
      
          {
            "submission_safe": false,
            "limitations_only_anchors": [
              {"anchor": "0.869", "kind": "AUC",
               "found_in": ["Limitations:31"], "missing_from": ["Methods", "Results"]}
            ],
            "synthesis_method_drift": [
              {"method": "Freeman-Tukey", "prospero": true, "methods": false}
            ]
          }
      
      Exit codes:
          0 — no drift
          1 — drift detected
          2 — invocation error
      
      Read-only script. No file modification.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from dataclasses import asdict, dataclass, field
      from pathlib import Path
      
      
      # Section detection — case-insensitive header match. We deliberately accept
      # common variants seen in radiology / MA manuscripts: bolded all-caps
      # (`## **METHODS**`), Title Case (`## Methods`), and Markdown subsections.
      SECTION_HEADERS: dict[str, re.Pattern[str]] = {
          "Methods": re.compile(
              r"^#{1,3}\s*\*{0,2}(?:METHODS?|Method[s]?|Materials and Methods)\*{0,2}\s*$",
              re.IGNORECASE | re.MULTILINE,
          ),
          "Results": re.compile(
              r"^#{1,3}\s*\*{0,2}(?:RESULTS?|Result[s]?|Findings)\*{0,2}\s*$",
              re.IGNORECASE | re.MULTILINE,
          ),
          "Discussion": re.compile(
              r"^#{1,3}\s*\*{0,2}(?:DISCUSSION|Discussion)\*{0,2}\s*$",
              re.IGNORECASE | re.MULTILINE,
          ),
          "Limitations": re.compile(
              r"^#{1,3}\s*\*{0,2}(?:LIMITATIONS?|Limitations?|Study Limitations)\*{0,2}\s*$",
              re.IGNORECASE | re.MULTILINE,
          ),
          "Conclusion": re.compile(
              r"^#{1,3}\s*\*{0,2}(?:CONCLUSIONS?|Conclusion[s]?)\*{0,2}\s*$",
              re.IGNORECASE | re.MULTILINE,
          ),
      }
      
      
      # Numeric anchor patterns. We bias toward conservative matchers: false
      # positives (e.g. "0.50%" from a table) are easier to triage than false
      # negatives (a missed AUC). Each entry yields a canonical "anchor" string.
      ANCHOR_PATTERNS = [
          # AUC: 0.600-0.999 (typical study range, excludes test-set 0.5 floor)
          ("AUC", re.compile(r"\b(0\.[6-9]\d{2})\b")),
          # OR / HR / RR / aOR / aHR with explicit label
          (
              "RiskRatio",
              re.compile(
                  r"\b(?:aOR|aHR|OR|HR|RR)\s*[=:]\s*(\d+\.\d+)\b",
                  re.IGNORECASE,
              ),
          ),
          # Sensitivity / specificity with explicit label
          (
              "SnSp",
              re.compile(
                  r"\b(?:Sn|Sp|sens(?:itivity)?|spec(?:ificity)?)\s*[=:]\s*"
                  r"(\d+(?:\.\d+)?%?)\b",
                  re.IGNORECASE,
              ),
          ),
      ]
      
      
      # Synthesis method keywords (case-insensitive substring search). When the
      # PROSPERO record names a method that does NOT appear in Methods (or
      # vice-versa), emit a drift.
      SYNTHESIS_METHODS = [
          "Freeman-Tukey",
          "Mantel-Haenszel",
          "DerSimonian-Laird",
          "REML",
          "bivariate",
          "HSROC",
          "Bayesian",
          "random-effects",
          "fixed-effect",
          "fixed-effects",
          "Egger",
          "I²",
          "Wilson",
          "Clopper-Pearson",
      ]
      
      
      @dataclass
      class LimitsOnlyAnchor:
          anchor: str
          kind: str
          found_in: list[str]
          missing_from: list[str]
      
      
      @dataclass
      class SynthesisDrift:
          method: str
          prospero: bool
          methods: bool
      
      
      @dataclass
      class Report:
          submission_safe: bool
          limitations_only_anchors: list[LimitsOnlyAnchor] = field(default_factory=list)
          synthesis_method_drift: list[SynthesisDrift] = field(default_factory=list)
      
          def as_dict(self) -> dict:
              return {
                  "submission_safe": self.submission_safe,
                  "limitations_only_anchors": [asdict(a) for a in self.limitations_only_anchors],
                  "synthesis_method_drift": [asdict(s) for s in self.synthesis_method_drift],
              }
      
      
      def split_sections(text: str) -> dict[str, str]:
          """Return mapping of section name → body text. Sections without a header
          return empty string. Body extends from the matched header to the next
          matched header in the document."""
          # Compute (name, start, end_of_header_line) per match across the doc.
          hits: list[tuple[str, int, int]] = []
          for name, pat in SECTION_HEADERS.items():
              for m in pat.finditer(text):
                  hits.append((name, m.start(), m.end()))
          hits.sort(key=lambda t: t[1])
      
          out: dict[str, str] = {name: "" for name in SECTION_HEADERS}
          for i, (name, start, hdr_end) in enumerate(hits):
              body_start = hdr_end
              body_end = hits[i + 1][1] if i + 1 < len(hits) else len(text)
              out[name] = (out[name] + "\n" + text[body_start:body_end]).strip()
          return out
      
      
      def find_anchors(text: str) -> list[tuple[str, str]]:
          """Return (anchor_string, kind) tuples found in text."""
          out: list[tuple[str, str]] = []
          for kind, pat in ANCHOR_PATTERNS:
              for m in pat.finditer(text):
                  anchor = m.group(1)
                  out.append((anchor, kind))
          return out
      
      
      def line_numbers(text: str, needle: str) -> list[int]:
          """Return 1-based line numbers in text containing the literal needle."""
          out: list[int] = []
          for i, line in enumerate(text.splitlines(), start=1):
              if needle in line:
                  out.append(i)
          return out
      
      
      def detect_limits_only(text: str) -> list[LimitsOnlyAnchor]:
          sections = split_sections(text)
          limits = sections.get("Limitations", "")
          discussion = sections.get("Discussion", "")
          methods = sections.get("Methods", "")
          results = sections.get("Results", "")
      
          # Anchors that appear in Limitations OR Discussion but NOT in Methods or
          # Results. We include Discussion because in many manuscripts the
          # Limitations subsection is folded into Discussion without an explicit
          # `## Limitations` header.
          out: list[LimitsOnlyAnchor] = []
          seen: set[tuple[str, str]] = set()
          for region_name, region_text in (("Limitations", limits), ("Discussion", discussion)):
              for anchor, kind in find_anchors(region_text):
                  if (anchor, kind) in seen:
                      continue
                  in_methods = anchor in methods
                  in_results = anchor in results
                  if not in_methods and not in_results:
                      lineno = line_numbers(text, anchor)
                      found_label = f"{region_name}:{lineno[0]}" if lineno else region_name
                      out.append(
                          LimitsOnlyAnchor(
                              anchor=anchor,
                              kind=kind,
                              found_in=[found_label],
                              missing_from=["Methods", "Results"],
                          )
                      )
                      seen.add((anchor, kind))
          return out
      
      
      def detect_synthesis_drift(
          manuscript_text: str,
          prospero_text: str | None,
      ) -> list[SynthesisDrift]:
          if prospero_text is None:
              return []
          sections = split_sections(manuscript_text)
          methods_text = sections.get("Methods", "")
          out: list[SynthesisDrift] = []
          for method in SYNTHESIS_METHODS:
              # case-insensitive presence
              in_prospero = method.lower() in prospero_text.lower()
              in_methods = method.lower() in methods_text.lower()
              if in_prospero != in_methods:
                  out.append(
                      SynthesisDrift(
                          method=method,
                          prospero=in_prospero,
                          methods=in_methods,
                      )
                  )
          return out
      
      
      def build_report(
          manuscript_path: Path,
          prospero_path: Path | None,
      ) -> Report:
          text = manuscript_path.read_text(encoding="utf-8")
          prospero_text: str | None = None
          if prospero_path is not None and prospero_path.is_file():
              prospero_text = prospero_path.read_text(encoding="utf-8")
      
          limits_only = detect_limits_only(text)
          synth_drift = detect_synthesis_drift(text, prospero_text)
          submission_safe = not limits_only and not synth_drift
          return Report(
              submission_safe=submission_safe,
              limitations_only_anchors=limits_only,
              synthesis_method_drift=synth_drift,
          )
      
      
      def main(argv: list[str] | None = None) -> int:
          parser = argparse.ArgumentParser(
              description=(
                  "Phase 6 intra-manuscript scope drift detection. Flags numeric "
                  "anchors that appear only in Limitations/Discussion (not Methods/"
                  "Results) and PROSPERO↔Methods synthesis-method disagreement."
              )
          )
          parser.add_argument(
              "--manuscript",
              type=Path,
              required=True,
              help="Path to manuscript.md (markdown).",
          )
          parser.add_argument(
              "--prospero",
              type=Path,
              default=None,
              help=(
                  "Path to PROSPERO record markdown. When supplied, also performs "
                  "synthesis-method cross-check vs Methods."
              ),
          )
          parser.add_argument(
              "--out",
              type=Path,
              default=None,
              help="Write JSON report to this path.",
          )
          parser.add_argument(
              "--quiet",
              action="store_true",
              help="Suppress stdout summary.",
          )
          args = parser.parse_args(argv)
      
          if not args.manuscript.is_file():
              parser.error(f"--manuscript not a file: {args.manuscript}")
          if args.prospero is not None and not args.prospero.is_file():
              parser.error(f"--prospero not a file: {args.prospero}")
      
          report = build_report(args.manuscript, args.prospero)
      
          if args.out is not None:
              args.out.parent.mkdir(parents=True, exist_ok=True)
              args.out.write_text(json.dumps(report.as_dict(), indent=2), encoding="utf-8")
      
          if not args.quiet:
              if report.submission_safe:
                  print("PASS: no scope drift detected.")
              else:
                  print(
                      f"FAIL: {len(report.limitations_only_anchors)} limits-only anchor(s), "
                      f"{len(report.synthesis_method_drift)} synthesis drift(s)."
                  )
                  for a in report.limitations_only_anchors:
                      print(f"  - SCOPE_DRIFT {a.kind} {a.anchor!r} found in {a.found_in}, "
                            f"missing from {a.missing_from}")
                  for s in report.synthesis_method_drift:
                      print(f"  - PROSPERO_DRIFT {s.method} prospero={s.prospero} methods={s.methods}")
      
          return 0 if report.submission_safe else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • sync_submission.py 21.3 KB
      #!/usr/bin/env python3
      """Copy and audit declared submission artifacts without rewriting source files.
      
      Usage: sync_submission.py build --project-root . --journal example
             sync_submission.py build --project-root . --journal example --bundle-spec bundle.json
             sync_submission.py audit --project-root . --journal example
      
      Rendering stays with the existing renderers. A bundle spec records the input
      hashes observed for that render; build never refreshes those hashes for you.
      """
      from __future__ import annotations
      
      import argparse
      from contextlib import contextmanager
      from datetime import date
      import hashlib
      import json
      import os
      from pathlib import Path, PurePosixPath
      import re
      import shutil
      import sys
      import tempfile
      
      
      def sha256_file(path: Path) -> str:
          h = hashlib.sha256()
          with path.open("rb") as fh:
              for chunk in iter(lambda: fh.read(1024 * 1024), b""):
                  h.update(chunk)
          return "sha256:" + h.hexdigest()
      
      
      def digest(payload) -> str:
          return "sha256:" + hashlib.sha256(json.dumps(
              payload, sort_keys=True, ensure_ascii=False, separators=(",", ":")
          ).encode()).hexdigest()
      
      
      def read_project_yaml(path: Path) -> dict:
          if not path.exists():
              return {}
          # Use the repository's declared YAML dependency, not indentation-blind parsing.
          import yaml
          try:
              value = yaml.safe_load(path.read_text(encoding="utf-8"))
          except yaml.YAMLError as exc:
              raise ValueError("project.yaml is invalid YAML") from exc
          if not isinstance(value, dict):
              raise ValueError("project.yaml must be a mapping")
          return value
      
      
      def resolve_canonical(project_root: Path, explicit: str | None) -> Path:
          rel = explicit or read_project_yaml(project_root / "project.yaml").get(
              "canonical_manuscript", "manuscript/manuscript.md")
          path = Path(rel)
          return path if path.is_absolute() else project_root / path
      
      
      def safe_path(root: Path, relative: str) -> Path:
          """Confine named files and reject symlinks, hidden components and traversal."""
          if not isinstance(relative, str) or not relative or "\\" in relative or ":" in relative:
              raise ValueError("Expected a relative POSIX path")
          parts = PurePosixPath(relative).parts
          if relative.startswith("/") or any(p.startswith(".") for p in parts):
              raise ValueError("Hidden, absolute or traversing paths are not supported")
          path = root
          for part in parts:
              path = path / part
              if path.is_symlink():
                  raise ValueError("Symlink paths are not supported")
          path.resolve().relative_to(root.resolve())
          return path
      
      
      def journal_root(root: Path, journal: str) -> Path:
          if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", journal):
              raise ValueError("Journal must be a single letter/digit/underscore/hyphen slug")
          return safe_path(root, f"submission/{journal}")
      
      
      def submission_md_path(project_root: Path, journal: str) -> Path:
          return journal_root(project_root, journal) / "manuscript/manuscript.md"
      
      
      def load_json(path: Path) -> dict:
          if path.is_symlink():
              raise ValueError("JSON metadata must not be a symlink")
          if not path.exists():
              return {}
          payload = json.loads(path.read_text(encoding="utf-8"))
          if not isinstance(payload, dict):
              raise ValueError("JSON metadata must be an object")
          return payload
      
      
      def write_json(path: Path, payload: dict) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          if path.is_symlink():
              raise ValueError("Refusing to overwrite a symlink")
          fd, temp = tempfile.mkstemp(prefix=".sync-", dir=path.parent)
          try:
              with os.fdopen(fd, "w", encoding="utf-8") as fh:
                  json.dump(payload, fh, indent=2, ensure_ascii=False)
                  fh.write("\n")
              os.replace(temp, path)
          finally:
              Path(temp).unlink(missing_ok=True)
      
      
      @contextmanager
      def mutation_lock(root: Path):
          """Serialize cooperating builds/freezes, including the shared manifest update."""
          lock = root / ".submission-sync.lock"
          try:
              fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
          except FileExistsError:
              raise ValueError("Another sync mutation is active; inspect .submission-sync.lock") from None
          try:
              os.close(fd)
              yield
          finally:
              lock.unlink()
      
      
      def manifest_payload(root: Path, journal: str, meta: dict) -> dict:
          manifest = load_json(safe_path(root, "artifact_manifest.json")) or {"schema_version": 1}
          submissions = manifest.setdefault("submissions", {})
          if not isinstance(submissions, dict) or not isinstance(submissions.get(journal, {}), dict):
              raise ValueError("Manifest submissions must contain objects")
          entry = submissions.setdefault(journal, {})
          entry.update({
              "path": f"submission/{journal}",
              "metadata": f"submission/{journal}/.journal_meta.json",
              "status": meta["status"], "source_hash": meta["source_hash"],
              "artifacts": meta.get("artifacts", []),
              "readiness": "not_assessed",  # Neither copying nor freezing is a submission approval.
          })
          return manifest
      
      
      def inventory(directory: Path) -> list[str]:
          """Only the selected submission directory; never discover project-wide inputs."""
          if not directory.exists():
              return []
          result = []
          for base, dirs, files in os.walk(directory, followlinks=False):
              for name in dirs + files:
                  if (Path(base) / name).is_symlink():
                      raise ValueError("Submission package contains a symlink")
              result.extend((Path(base) / name).relative_to(directory).as_posix() for name in files)
          return sorted(result)
      
      
      def bundle_binding(root: Path, journal: str | None) -> dict | None:
          """Byte snapshot, not a statement that every file was inspected by every check."""
          if not journal:
              return None
          directory = journal_root(root, journal)
          meta = load_json(directory / ".journal_meta.json")
          if not meta.get("artifacts"):
              return None
          package_paths = {f"submission/{journal}/{name}" for name in inventory(directory)
                   if name != ".journal_meta.json"}
          paths = set(package_paths)
          for row in meta["artifacts"]:
              paths.add(row["source"])
              paths.update(d["path"] for d in row.get("derived_from", []))
          files = {}
          for relative in sorted(paths):
              path = root / relative if relative in package_paths else safe_path(root, relative)
              files[relative] = sha256_file(path) if path.is_file() else None
          # Exclude status/freeze dates so freezing doesn't invalidate its own evidence.
          data = {"files": files, "declaration_hash": digest(meta["artifacts"])}
          return {**data, "sha256": digest(data)}
      
      
      def verification_context(root: Path, journal: str) -> dict:
          report_path = safe_path(root, "qc/preflight_gate_report.json")
          report = load_json(report_path)
          if not report or report.get("journal") != journal:
              return {"status": "not_run", "checks": [], "readiness": "not_assessed"}
          now = bundle_binding(root, journal)
          recorded = report.get("bundle_binding")
          state = "unbound" if not now or not recorded else (
              "package_bytes_current" if recorded == now and report.get("bundle_unchanged_during_checks") is True else "stale")
          return {"status": state, "report": "qc/preflight_gate_report.json",
                  "report_hash": sha256_file(report_path), "checks": report.get("checks", []),
                  "coverage": report.get("coverage", {}), "readiness": "not_assessed",
                  "scope": "Bundle byte binding only; other check inputs are not bound. No visual, semantic or rights approval."}
      
      
      def prepare_artifacts(root: Path, journal: str, canonical: Path, spec: dict) -> list[dict]:
          if spec and spec.get("schema_version") != 1:
              raise ValueError("Bundle spec schema_version must be 1")
          extra = spec.get("artifacts", [])
          if not isinstance(extra, list):
              raise ValueError("Bundle artifacts must be a list")
          rows = [{"id": "canonical", "role": "manuscript_source",
                   "source": canonical.relative_to(root).as_posix(),
                   "target": "manuscript/manuscript.md"}] + extra
          directory = journal_root(root, journal)
          prepared, ids, targets, inputs = [], set(), [], set()
          for row in rows:
              if not isinstance(row, dict) or not isinstance(row.get("id"), str) or not row["id"]:
                  raise ValueError("Each artifact requires an id")
              if row["id"] in ids:
                  raise ValueError("Duplicate artifact id")
              ids.add(row["id"])
              source = safe_path(root, row["source"])
              target = safe_path(directory, row["target"])
              if not source.is_file() or source.is_relative_to(directory):
                  raise ValueError("Artifact source must exist outside the destination package")
              key = target.relative_to(directory).as_posix().casefold()
              if any(key == old or key.startswith(old + "/") or old.startswith(key + "/") for old in targets):
                  raise ValueError("Overlapping or case-colliding artifact targets")
              targets.append(key)
              deps = row.get("derived_from", [])
              if not isinstance(deps, list):
                  raise ValueError("derived_from must be a list")
              for dep in deps:
                  path = safe_path(root, dep["path"])
                  if path.is_relative_to(directory) or not path.is_file() or sha256_file(path) != dep["sha256"]:
                      raise ValueError("Render dependency missing or changed; rebuild with the existing renderer")
                  inputs.add(path)
              transformation = row.get("transformation", {"kind": "not_recorded"})
              if not isinstance(transformation, dict):
                  raise ValueError("transformation must be an object")
              if transformation.get("kind") == "rendered" and not deps:
                  raise ValueError("Rendered artifacts require pinned derived_from inputs")
              rights = row.get("rights", {"status": "unknown"})
              if not isinstance(rights, dict) or rights.get("status") not in {"unknown", "original", "documented"}:
                  raise ValueError("rights.status must be unknown, original or documented")
              if rights["status"] == "documented" and not all(rights.get(k) for k in (
                      "source", "license_or_permission", "attribution", "changes")):
                  raise ValueError("Documented rights require source, license_or_permission, attribution and changes")
              inputs.add(source)
              prepared.append({"id": row["id"], "role": row.get("role", "unspecified"),
                               "source": row["source"], "target": row["target"],
                               "source_hash": sha256_file(source), "derived_from": deps,
                               "transformation": transformation, "rights": rights,
                               "copy_fidelity": "byte_identical",
                               "content_fidelity": "not_assessed", "visual_review": "not_assessed"})
          controls = [root / "artifact_manifest.json", root / "project.yaml"]
          for source in inputs:
              if source in controls or source.is_relative_to(root / "qc"):
                  raise ValueError("Source aliases a mutable control/report path")
              for row in prepared:
                  target = directory / row["target"]
                  if target.exists() and source.samefile(target):
                      raise ValueError("Source and output alias the same file")
          return prepared
      
      
      def audit(project_root: Path, journal: str, canonical: Path) -> int:
          directory = journal_root(project_root, journal)
          qc_path = safe_path(project_root, f"qc/submission_sync_{journal}.json")
          sub_path = submission_md_path(project_root, journal)
          meta = load_json(directory / ".journal_meta.json")
          if not canonical.is_file():
              write_json(qc_path, {"schema_version": 2, "journal": journal, "status": "ERROR",
                                   "message": "canonical manuscript missing"})
              return 2
          source_hash = sha256_file(canonical)
          current_hash = sha256_file(sub_path) if sub_path.is_file() else None
          problems, artifacts = [], []
          if current_hash is not None and current_hash != source_hash:
              problems.append("canonical_copy_differs")
          if meta.get("canonical") and meta["canonical"] != canonical.relative_to(project_root).as_posix():
              problems.append("canonical_source_changed")
          if meta.get("source_hash") and meta["source_hash"] != source_hash:
              problems.append("recorded_canonical_changed")
          for row in meta.get("artifacts", []):
              source = safe_path(project_root, row["source"])
              output = safe_path(directory, row["target"])
              actual_source = sha256_file(source) if source.is_file() else None
              actual_output = sha256_file(output) if output.is_file() else None
              state = []
              if actual_source != row["source_hash"]:
                  state.append("source_changed_or_missing")
              if actual_output != row["output_hash"]:
                  state.append("output_changed_or_missing")
              for dep in row.get("derived_from", []):
                  path = safe_path(project_root, dep["path"])
                  if not path.is_file() or sha256_file(path) != dep["sha256"]:
                      state.append("render_input_changed_or_missing")
              if state:
                  problems.append(row["id"])
              artifacts.append({**row, "current_source_hash": actual_source,
                                "current_output_hash": actual_output, "drift": state})
          expected = {r["target"] for r in meta.get("artifacts", [])} | {".journal_meta.json"}
          unregistered = sorted(set(inventory(directory)) - expected) if artifacts else []
          if unregistered:
              problems.append("unregistered_files")
          status = "MISSING_SUBMISSION" if current_hash is None else "DRIFT" if problems else "CURRENT"
          payload = {"schema_version": 2, "journal": journal, "status": status,
                     "canonical": canonical.relative_to(project_root).as_posix(),
                     "submission_manuscript": sub_path.relative_to(project_root).as_posix(),
                     "source_hash": source_hash, "submission_hash": current_hash,
                     "recorded_source_hash": meta.get("source_hash"), "artifacts": artifacts,
                     "unregistered_files": unregistered, "issues": problems,
                     "verification": verification_context(project_root, journal),
                     "readiness": "not_assessed", "message": "; ".join(problems)}
          write_json(qc_path, payload)
          print(json.dumps(payload, indent=2))
          # Missing input is distinct from detected drift (preflight already understands exit 2).
          return 2 if current_hash is None else 1 if problems else 0
      
      
      def build(project_root: Path, journal: str, canonical: Path, spec: dict | None = None) -> int:
          directory = journal_root(project_root, journal)
          old_meta = load_json(directory / ".journal_meta.json")
          # Validate a pre-existing report before modifying the package; malformed
          # evidence must not turn a completed build into a late parse failure.
          load_json(safe_path(project_root, "qc/preflight_gate_report.json"))
          if old_meta.get("frozen") or old_meta.get("status") in {"submitted", "accepted", "published"}:
              raise ValueError("Frozen/submitted package is immutable; use a new revision journal slug")
          rows = prepare_artifacts(project_root, journal, canonical, spec or {})
          old_rows = old_meta.get("artifacts", [])
          allowed = {r["target"] for r in old_rows} | {".journal_meta.json"}
          if old_meta and not old_rows:
              allowed.add("manuscript/manuscript.md")
              if submission_md_path(project_root, journal).is_file() and sha256_file(
                      submission_md_path(project_root, journal)) != old_meta.get("source_hash"):
                  raise ValueError("Legacy submission was edited; preserve it and reconcile before building")
          if set(inventory(directory)) - allowed:
              raise ValueError("Unregistered package files would be lost; use a new revision slug")
          if {r["target"] for r in old_rows} - {r["target"] for r in rows}:
              raise ValueError("Build would remove a registered artifact; use a new revision slug")
          for row in old_rows:
              target = safe_path(directory, row["target"])
              if target.is_file() and sha256_file(target) != row["output_hash"]:
                  raise ValueError("Submission output was edited; preserve it and reconcile before building")
          meta = {**old_meta, "schema_version": 2, "journal": journal, "status": "built",
                  "canonical": canonical.relative_to(project_root).as_posix(),
                  "submission_manuscript": f"submission/{journal}/manuscript/manuscript.md",
                  "source_hash": rows[0]["source_hash"], "built_date": date.today().isoformat(),
                  "frozen": False, "artifacts": rows, "readiness": "not_assessed"}
          for row in rows:
              row["output_hash"] = row["source_hash"]
          manifest = manifest_payload(project_root, journal, meta)  # Validate before any replacement.
          safe_path(project_root, "qc/submission_sync_" + journal + ".json")
          directory.parent.mkdir(parents=True, exist_ok=True)
          with tempfile.TemporaryDirectory(prefix=".sync-build-", dir=directory.parent) as temp:
              stage, backup = Path(temp) / "stage", Path(temp) / "backup"
              stage.mkdir()
              for row in rows:
                  output = stage / row["target"]
                  output.parent.mkdir(parents=True, exist_ok=True)
                  shutil.copy2(project_root / row["source"], output)
                  if sha256_file(output) != row["source_hash"]:
                      raise ValueError("Source changed during copy; package not replaced")
              # Recheck pinned sources, including dependencies, before installing the staged package.
              if prepare_artifacts(project_root, journal, canonical, spec or {}) != [
                      {k: v for k, v in r.items() if k != "output_hash"} for r in rows]:
                  raise ValueError("Source changed during build; package not replaced")
              write_json(stage / ".journal_meta.json", meta)
              existed = directory.exists()
              if existed:
                  directory.rename(backup)
              try:
                  stage.rename(directory)
                  write_json(project_root / "artifact_manifest.json", manifest)
              except BaseException:
                  if directory.exists():
                      shutil.rmtree(directory)
                  if existed:
                      backup.rename(directory)
                  raise
          return audit(project_root, journal, canonical)
      
      
      def freeze(project_root: Path, journal: str, canonical: Path, status: str) -> int:
          code = audit(project_root, journal, canonical)
          if code:
              print("Cannot freeze a missing, drifted or invalid submission package.", file=sys.stderr)
              return code
          meta_path = journal_root(project_root, journal) / ".journal_meta.json"
          meta = load_json(meta_path)
          if not meta or not meta.get("source_hash"):
              raise ValueError("Build metadata is required before freeze")
          if meta.get("frozen"):
              if meta.get("status") != status:
                  raise ValueError("Frozen status cannot be overwritten")
              return 0
          meta.update({"status": status, "frozen": True, "frozen_date": date.today().isoformat(),
                       "verification_at_freeze": verification_context(project_root, journal),
                       "readiness": "not_assessed"})
          manifest = manifest_payload(project_root, journal, meta)
          previous = meta_path.read_bytes()
          try:
              write_json(meta_path, meta)
              write_json(project_root / "artifact_manifest.json", manifest)
          except BaseException:
              write_json(meta_path, json.loads(previous))
              raise
          print("Frozen byte snapshot; visual, semantic, permissions and submission approval remain separate.")
          return 0
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("mode", choices=["audit", "build", "freeze"])
          parser.add_argument("--project-root", default=".")
          parser.add_argument("--journal", required=True)
          parser.add_argument("--canonical")
          parser.add_argument("--bundle-spec", help="JSON declaration of additional files and pinned render inputs (build only)")
          parser.add_argument("--status", default="submitted")
          args = parser.parse_args()
          try:
              root = Path(args.project_root).resolve()
              if not root.is_dir():
                  raise ValueError("Project root must exist")
              journal_root(root, args.journal)
              canonical = resolve_canonical(root, args.canonical)
              canonical = safe_path(root, canonical.relative_to(root).as_posix())
              if canonical.is_relative_to(journal_root(root, args.journal)):
                  raise ValueError("Canonical manuscript must be outside its submission package")
              if canonical.is_relative_to(root / "qc") or canonical in {root / "artifact_manifest.json", root / "project.yaml"}:
                  raise ValueError("Canonical manuscript aliases a mutable control/report path")
              if args.bundle_spec and args.mode != "build":
                  raise ValueError("--bundle-spec is only supported by build")
              if args.mode == "audit":
                  return audit(root, args.journal, canonical)
              with mutation_lock(root):
                  if args.mode == "build":
                      spec = load_json(safe_path(root, args.bundle_spec)) if args.bundle_spec else {}
                      if args.bundle_spec and not spec:
                          raise ValueError("Bundle spec is missing or empty")
                      return build(root, args.journal, canonical, spec)
                  return freeze(root, args.journal, canonical, args.status)
          except (OSError, ValueError, KeyError, TypeError) as exc:
              print(f"Submission sync error: {exc}", file=sys.stderr)
              return 2
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • _quote_match.py 7.7 KB
      """Quote matching that survives an extraction layer — the substrate under quote gates.
      
      WHY THIS EXISTS (the failure it removes)
      
      Verifying "the manuscript contains this quoted sentence" by searching a CONTIGUOUS string
      is wrong whenever the haystack came out of an extractor, because extractors interleave
      tokens the source never had. In one submission-day session that single assumption produced
      thirteen false positives, all the same shape:
      
        * a two-column PDF bled reference-list text into the middle of a sentence
          ("learners form independent" | "civile." | "assessments before seeing AI output");
        * a line-numbered supplement PDF put the line number inside the sentence
          ("were" | "86" | "performed");
        * superscript markers and footnote references landed mid-clause;
        * hyphenation across a line break split one word into two ("assess-" + "ments").
      
      Every one of those quotes was CORRECT and present. The contiguous check called them absent.
      It came within one step of instructing an author to delete two accurate verbatim quotes.
      
      THE RULE THIS ENCODES
      
      A quote that cannot be matched contiguously is not thereby "not in the source". It is
      UNRESOLVED until something stronger says otherwise. So this module grades a match instead
      of answering yes/no:
      
        EXACT        the normalized quote is a contiguous substring — verified, no doubt.
        INTERLEAVED  every quote token appears IN ORDER, with only a bounded number of foreign
                     tokens wedged between them — the text is there and the extraction is dirty.
        PARTIAL      most quote tokens appear in order but some are missing — consistent with
                     extraction damage (hyphen splits, dropped glyphs); too weak to call absent.
        ABSENT       not even a partial ordered run — the text really is not there.
      
      Only ABSENT justifies a "you claimed an edit you did not make" verdict. INTERLEAVED and
      PARTIAL are reported as unresolved so a human looks, rather than as a defect.
      
      WHY THE GAPS ARE BOUNDED (the precision that makes this safe)
      
      An unbounded subsequence match is worthless: the tokens of almost any short sentence appear
      "in order" somewhere in a long document if you allow arbitrary distance. The bound that works
      is not a token budget but an INTERRUPTION COUNT, because the two cases differ in shape:
      
          a real extraction artifact interrupts a sentence once or twice, and each interruption can
          be long (a bled reference line is a dozen tokens);
      
          a spurious "match" interrupts at nearly every token, each time by a little.
      
      So the limits are: at most MAX_GAP foreign tokens at any single join, at most
      MAX_INTERRUPTIONS joins that are interrupted at all, and a total-insertion sanity cap. A
      quote whose words are scattered one-by-one across a Discussion section needs an interruption
      at every join and fails, while a quote split once by a column bleed passes.
      
      Not a detector: a helper imported by the gates that need it (leading underscore keeps it out
      of the detector catalog glob). Stdlib only.
      """
      
      from __future__ import annotations
      
      import re
      import unicodedata
      
      # At most this many foreign tokens may sit at ONE join. A bled reference line ("civile. Rev
      # Med Suisse 2019;15:1122.") is around a dozen tokens; a running header a handful.
      MAX_GAP = 25
      # At most this many joins may be interrupted AT ALL. This is the limit that separates a dirty
      # extraction (one or two interruptions) from a spurious scatter (an interruption per token).
      MAX_INTERRUPTIONS = 4
      # Sanity cap on total foreign tokens, so a short quote cannot absorb an entire paragraph.
      MAX_TOTAL_INSERT_FRAC = 5.0
      MIN_TOTAL_INSERT = 20
      # A PARTIAL match must still account for this share of the quote's tokens; below it, ABSENT.
      PARTIAL_COVERAGE = 0.80
      
      _TOKEN_RE = re.compile(r"[0-9a-z]+(?:'[a-z]+)?", re.IGNORECASE)
      
      
      def normalize(s: str) -> str:
          """Casefold, unify quotes/dashes, drop markdown emphasis, repair line-break hyphenation,
          and collapse whitespace. Hyphenation repair matters: an extractor that wraps "assess-
          ments" across a line otherwise destroys the token the quote is looking for."""
          s = unicodedata.normalize("NFKC", s)
          s = s.replace("’", "'").replace("‘", "'")
          s = s.replace("“", '"').replace("”", '"')
          # join a word split by a hyphen at a line break: "assess-\n  ments" -> "assessments"
          s = re.sub(r"(\w)[-‐‑]\s*\n\s*(\w)", r"\1\2", s)
          s = re.sub(r"[*_`]", "", s)
          s = re.sub(r"\s+", " ", s)
          return s.casefold().strip()
      
      
      def tokens(s: str) -> list[str]:
          """Normalized word/number tokens. Punctuation is dropped, so an injected '.' or a stray
          bracket never breaks a match on its own."""
          return _TOKEN_RE.findall(normalize(s))
      
      
      def _ordered_run(needle: list[str], hay: list[str], allow_missing: bool):
          """Best ordered match of `needle` inside `hay`.
      
          Walks every candidate start and consumes needle tokens in order, skipping at most
          MAX_GAP foreign tokens per join, at most MAX_INTERRUPTIONS interrupted joins, and a
          total-insertion sanity cap. With allow_missing, a needle token that cannot be found
          within the gap window is skipped (counted as missing) instead of failing the run.
      
          Returns (matched_count, inserted_count) for the best run, or (0, 0)."""
          if not needle or not hay:
              return (0, 0)
          budget = max(MIN_TOTAL_INSERT, int(len(needle) * MAX_TOTAL_INSERT_FRAC))
          max_missing = len(needle) - int(len(needle) * PARTIAL_COVERAGE)
          best = (0, 0)
          first = needle[0]
          starts = [i for i, t in enumerate(hay) if t == first]
          if allow_missing and not starts:
              # the opening token itself may be the damaged one — try any token of the quote
              wanted = set(needle)
              starts = [i for i, t in enumerate(hay) if t in wanted]
          for start in starts:
              hi = start
              matched = inserted = missing = interruptions = 0
              for tok in needle:
                  found = -1
                  for j in range(hi, min(hi + MAX_GAP + 1, len(hay))):
                      if hay[j] == tok:
                          found = j
                          break
                  if found < 0:
                      if not allow_missing:
                          break
                      missing += 1
                      if missing > max_missing:
                          break
                      continue
                  gap = found - hi
                  if gap:
                      interruptions += 1
                      if interruptions > MAX_INTERRUPTIONS:
                          break
                  inserted += gap
                  if inserted > budget:
                      break
                  matched += 1
                  hi = found + 1
              if matched > best[0]:
                  best = (matched, inserted)
              if matched == len(needle):
                  break
          return best
      
      
      def match_quality(quote: str, haystack: str) -> dict:
          """Grade how well `quote` is present in `haystack`.
      
          Returns {"grade": EXACT|INTERLEAVED|PARTIAL|ABSENT, "matched", "total", "inserted",
                   "coverage"}. Only ABSENT means "this text is not in the document"."""
          nq, nh = normalize(quote), normalize(haystack)
          q_tok = tokens(quote)
          total = len(q_tok)
          if total == 0:
              return {"grade": "ABSENT", "matched": 0, "total": 0, "inserted": 0, "coverage": 0.0}
          if nq and nq in nh:
              return {"grade": "EXACT", "matched": total, "total": total, "inserted": 0, "coverage": 1.0}
      
          h_tok = tokens(haystack)
          matched, inserted = _ordered_run(q_tok, h_tok, allow_missing=False)
          if matched == total:
              return {"grade": "INTERLEAVED", "matched": matched, "total": total,
                      "inserted": inserted, "coverage": 1.0}
      
          matched, inserted = _ordered_run(q_tok, h_tok, allow_missing=True)
          coverage = matched / total
          grade = "PARTIAL" if coverage >= PARTIAL_COVERAGE else "ABSENT"
          return {"grade": grade, "matched": matched, "total": total,
                  "inserted": inserted, "coverage": round(coverage, 3)}
      
    • _yaml_frontmatter.py 1.6 KB
      """Shared YAML front-matter splitter for sync-submission scripts.
      
      Both `check_wordcount_cap.py` and `cover_letter_drift_check.py` need to peel the
      `---`-fenced YAML front matter off a manuscript before counting body words. The
      two had drifted (one returned just the body as a list, the other returned a
      (yaml, body) tuple, with subtly different unclosed-fence handling) despite a
      "keep in sync" intent. This is the single canonical implementation, imported by
      both. It is a private helper (leading underscore) so the detector-catalog glob
      (`check_*` / `detect_*` / `derive_*` / `verify_refs`) never counts it.
      
      Self-contained within this skill's scripts/ dir: both consumers run with their
      own directory on sys.path[0] (invoked as `python3 .../scripts/<name>.py`), so a
      sibling import resolves wherever the skill is installed/vendored.
      """
      from __future__ import annotations
      
      import re
      
      YAML_FENCE_RE = re.compile(r"^---\s*$")
      
      
      def split_yaml_front_matter(lines: list[str]) -> tuple[list[str], list[str]]:
          """Split ``lines`` into (yaml_lines, body_lines) on the first two ``---`` fences.
      
          If there is no opening fence, or the front matter is never closed, the whole
          input is treated as body and ``([], lines)`` is returned. Callers that only
          need the body can discard the first element: ``_, body = split_yaml_front_matter(lines)``.
          """
          if not lines or not YAML_FENCE_RE.match(lines[0].rstrip()):
              return [], lines
          for i, line in enumerate(lines[1:], start=1):
              if YAML_FENCE_RE.match(line.rstrip()):
                  return lines[1:i], lines[i + 1:]
          # Unclosed front matter — treat as no front matter.
          return [], lines
      
  • tests
    • fixtures
      • suppl_bad
        • 00_index.md 91 B
          # Supplementary Material — Index
          - S1. Methods
          - S2. Analyses
          - S3. Tables
          - S4. Figures
          
        • S1_methods.md 43 B
          ## S1. Methods
          
          ### S1.1 a
          x
          
          ### S1.3 c
          x
          
        • S2_a.md 30 B
          ## S2. Analyses (version a)
          x
          
        • S2_b.md 30 B
          ## S2. Analyses (version b)
          x
          
        • S4_figs.md 17 B
          ## S4. Figures
          x
          
        • S6_orphan.md 16 B
          ## S6. Orphan
          x
          
      • suppl_clean
        • 00_index.md 119 B
          # Supplementary Material — Index
          - S1. Supplementary Methods
          - S2. Supplementary Analyses
          - S3. Supplementary Tables
          
        • S1_methods.md 46 B
          ## S1. Supplementary Methods
          
          Design details.
          
        • S2_analyses.md 81 B
          ## S2. Supplementary Analyses
          
          ### S2.1 Sensitivity
          text
          
          ### S2.2 Subgroup
          text
          
        • S3_tables.md 44 B
          ## S3. Supplementary Tables
          
          Table content.
          
      • copy_ok.md 249 B
        ## Methods
        After re-lock the analytic cohort comprised n = 998 participants. Emphysema was
        associated with mortality (HR 1.34), not significant (p = 0.074); prevalence 12.5%.
        ## Results
        The adjusted estimate was OR 2.25 in the exploratory analysis.
        
      • copy_stale.md 239 B
        ## Methods
        The analytic cohort comprised n = 998 participants. Emphysema was associated with
        mortality (HR 1.34) but this was not significant. Prevalence was 12.5%.
        ## Results
        The adjusted estimate was OR 2.25 in the exploratory analysis.
        
      • ssot.md 251 B
        ## Methods
        The analytic cohort comprised n = 998 participants. Emphysema was associated with
        mortality (HR 1.34) but this was not significant (p = 0.074). Prevalence was 12.5%.
        ## Results
        The adjusted estimate was OR 2.25 in the exploratory analysis.
        
      • suppl_manuscript.md 63 B
        Body. See Supplementary Methods S1 and Supplementary Table S2.
        
      • wc_body.md 1.1 KB
        ---
        title: A worked example manuscript
        ---
        
        ## Abstract
        
        This abstract sentence contains words that must not be counted toward the body
        limit because the abstract has its own separate cap.
        
        ## Introduction
        
        Coronary artery calcium scoring is a widely used marker of subclinical disease,
        and prior work has linked it to downstream events in screening populations
        [@smith2020]. We examined whether an additional report-derived finding adds value
        beyond the calcium score in a cross-sectional screening cohort.
        
        ## Discussion
        
        The association we observed was modest and its confidence interval excluded a
        clinically meaningful incremental effect. We interpret the result as a precision
        statement rather than an absence of any effect, and we situate it against prior
        cohort evidence [@doe2019].
        
        ## References
        
        1. Smith J. A long reference list entry whose many words must be excluded from
           the body word count entirely, along with all the other entries below it.
        2. Doe A. Another reference entry with still more words that should not inflate
           the measured body length in any way whatsoever.
        
      • wc_journal_profile.md 329 B
        # Example Journal (synthetic profile fixture)
        
        ## Article Types
        
        - Original Article (4,000 words, abstract <250, refs ≤50, ≤6 figures/tables)
        - Brief Report (1,500 words, abstract <180, refs ≤20)
        - Letter to the Editor (1,000 words, refs ≤10)
        
        ## Scope
        
        Synthetic profile used only by the word-count-cap regression test.
        
    • test_anonymization_config_scan.sh 3.2 KB
      #!/usr/bin/env bash
      # Regression test: a figure builder's CONFIG is as much a figure source as its script.
      #
      # `check_asset_anonymization` scanned `.r` and `.py` under `figures/`. But `/make-figures` documents
      # its STROBE builder as `build_strobe_template.py --config figures/figure1_strobe.yaml`, and the
      # first box of a STROBE flow diagram is exactly where "Patients screened at <Hospital>" lives. So
      # the identical institution string blocked in one file and passed in the other:
      #
      #   figures/figure1_strobe.py    ->  FAIL: institution-like token in figure script
      #   figures/figure1_strobe.yaml  ->  PASS: no anonymization leak   ({'figure_scripts': 0})
      #
      # For a double-blind submission that is an anonymity leak which the anonymisation gate declared
      # clean — and the `figure_scripts: 0` in that PASS line is the tell: a gate reporting success over
      # an empty input set looks exactly like a gate that passed.
      set -u
      
      REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
      A="$REPO_ROOT/skills/sync-submission/scripts/check_asset_anonymization.py"
      WORK="$(mktemp -d -t anon_config_test.XXXXXX)"
      trap 'rm -rf "$WORK"' EXIT
      
      pass=0
      fail=0
      ck() {
        local label="$1" expected="$2" actual="$3"
        if [ "$expected" = "$actual" ]; then
          printf '  PASS  %-52s %s\n' "$label" "$actual"
          pass=$((pass + 1))
        else
          printf '  FAIL  %-52s expected=%s actual=%s\n' "$label" "$expected" "$actual"
          fail=$((fail + 1))
        fi
      }
      
      LEAK='Patients screened at Seoul National University Hospital (n = 4,102)'
      
      run_on() {  # run_on <relative path> <content> -> exit code; output at $WORK/out
        local rel="$1" content="$2" d="$WORK/p$RANDOM"
        mkdir -p "$d/$(dirname "$rel")"
        printf '%s\n' "$content" > "$d/$rel"
        python3 "$A" --dir "$d" --strict > "$WORK/out" 2>&1
        local rc=$?
        cp "$WORK/out" "$WORK/last.out"
        echo $rc
      }
      
      echo "==== a config under figures/ is scanned, whatever its format ===="
      ck "figures/*.yaml with an institution"  1 \
         "$(run_on figures/f1.yaml "spine:
        - {text: \"$LEAK\"}")"
      ck "  and the file is named" yes \
         "$(grep -q 'figures/f1.yaml' "$WORK/last.out" && echo yes || echo no)"
      ck "figures/*.yml with an institution"   1 "$(run_on figures/f1.yml "text: \"$LEAK\"")"
      ck "figures/*.json with an institution"  1 "$(run_on figures/panel.json "{\"title\": \"$LEAK\"}")"
      
      echo "==== the path that already worked must keep working ===="
      ck "figures/*.py with an institution"    1 "$(run_on figures/f1.py "title = \"$LEAK\"")"
      ck "figures/*.R with an institution"     1 "$(run_on figures/f1.R "title <- \"$LEAK\"")"
      
      echo "==== NEGATIVE CONTROLS ===="
      ck "a clean config passes"               0 \
         "$(run_on figures/clean.yaml "spine:
        - {text: \"Analytic cohort (n = 3,655)\"}")"
      # The counter is the difference between "looked and found nothing" and "looked at nothing".
      ck "  and the run reports it scanned it" yes \
         "$(grep -q "'figure_scripts': 1" "$WORK/last.out" && echo yes || echo no)"
      # Scope is still figures/: a config elsewhere in the tree is not a figure source.
      ck "same leak outside figures/ is not scanned" 0 \
         "$(run_on config/app.yaml "site: \"$LEAK\"")"
      
      echo
      echo "  passed=$pass failed=$fail"
      [ "$fail" -eq 0 ] || { echo "--- last run ---"; cat "$WORK/last.out"; exit 1; }
      echo "OK: the config that draws the figure is scanned like the script that draws it."
      
    • test_assemble_supplement.sh 2.4 KB
      #!/usr/bin/env bash
      # Regression test for the supplement assembler / structural validator (G42).
      # Synthetic, PII-free fixtures: a clean supplement (index↔file 1:1, contiguous
      # sub-sections) and a broken one (index declares S3 with no file, S2 duplicated,
      # S1.2 sub-section gap, S6 orphan not in index). Stdlib-only (python3).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/assemble_supplement.py"
      CLEAN="$HERE/fixtures/suppl_clean"
      BAD="$HERE/fixtures/suppl_bad"
      MS="$HERE/fixtures/suppl_manuscript.md"
      OUT="$(mktemp -t supp_XXXX).json"
      COMB="$(mktemp -t comb_XXXX).md"
      trap 'rm -f "$OUT" "$COMB"' EXIT
      
      fail=0
      check() { local label="$1"; shift
          if "$@" >/dev/null 2>&1; then printf '  PASS  %s\n' "$label"
          else printf '  FAIL  %s\n' "$label"; fail=$((fail+1)); fi
      }
      has_kind() { python3 -c "
      import json,sys
      d=json.load(open('$OUT'))
      assert any(p['kind']=='$1' for p in d['problems']), '$1 not found'
      "; }
      
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      
      # (1) clean supplement -> exit 0, rebuilds _combined in index order
      python3 "$SCRIPT" --dir "$CLEAN" --out "$COMB" --strict --quiet >/dev/null 2>&1
      check "exit 0 on clean supplement" test "$?" -eq 0
      check "_combined rebuilt + non-empty" test -s "$COMB"
      check "rebuild is in index order (S1 before S3)" python3 -c "
      t=open('$COMB').read(); assert t.index('S1.')<t.index('S3.'), 'order wrong'"
      
      # (2) broken supplement -> exit 1 with all four structural problem kinds
      python3 "$SCRIPT" --dir "$BAD" --json "$OUT" --strict --quiet >/dev/null 2>&1
      check "exit 1 on broken supplement" test "$?" -eq 1
      check "INDEX_WITHOUT_FILE (S3 declared, no file)" has_kind INDEX_WITHOUT_FILE
      check "FILE_WITHOUT_INDEX (S6 orphan)"            has_kind FILE_WITHOUT_INDEX
      check "DUPLICATE_SECTION (S2 a+b)"                has_kind DUPLICATE_SECTION
      check "SUBSECTION_GAP (S1.2 missing)"             has_kind SUBSECTION_GAP
      
      # (3) coverage: clean sections cited only S1/S2 -> S3 SECTION_UNCITED (advisory, no --strict fail)
      python3 "$SCRIPT" --dir "$CLEAN" --manuscript "$MS" --json "$OUT" --quiet >/dev/null 2>&1
      check "SECTION_UNCITED flagged for S3" has_kind SECTION_UNCITED
      check "coverage is advisory (exit 0 without structural problem)" bash -c "
        python3 '$SCRIPT' --dir '$CLEAN' --manuscript '$MS' --strict --quiet >/dev/null 2>&1; test \$? -eq 0"
      
      echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
    • test_asset_anonymization.sh 6 KB
      #!/usr/bin/env bash
      # Test scripts/check_asset_anonymization.py — the A2 asset-anonymization gate.
      # Synthetic, PII-free fixtures: a figure script with a generic institution token,
      # a docx with a (synthetic) author metadata, a --names-file name hit, and clean
      # counterparts. Stdlib-only; does not require poppler (PDF paths are exercised
      # only opportunistically).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_asset_anonymization.py"
      PASS=0
      FAIL=0
      ok()  { echo "  PASS: $1"; PASS=$((PASS+1)); }
      bad() { echo "  FAIL: $1"; FAIL=$((FAIL+1)); }
      
      WORK="$(mktemp -d)"
      trap 'rm -rf "$WORK"' EXIT
      
      # --- fixtures ---
      mkdir -p "$WORK/leaky/figures" "$WORK/clean/figures" "$WORK/pathleak"
      mkdir -p "$WORK/custompath" "$WORK/relationshippath"
      
      # figure script with an institution token (review-severity)
      cat > "$WORK/leaky/figures/flow.R" <<'EOF'
      # CONSORT flow diagram
      label <- "Recruited at General Hospital, 2020-2024"
      plot(1:10)
      EOF
      # clean figure script
      cat > "$WORK/clean/figures/plot.py" <<'EOF'
      import matplotlib.pyplot as plt
      plt.plot([1, 2, 3]); plt.savefig("fig1.png")
      EOF
      # figure script with a name that only --names-file knows (leak-severity)
      cat > "$WORK/leaky/figures/panel.py" <<'EOF'
      caption = "Data from Mercy Riverside Clinic cohort"
      EOF
      cat > "$WORK/names.txt" <<'EOF'
      Mercy Riverside Clinic
      EOF
      
      # build a docx with synthetic author metadata (leak) and a clean one (tool author)
      python3 - "$WORK" <<'PY'
      import sys, zipfile, os
      work = sys.argv[1]
      def make_docx(path, creator, body="<w:document/>"):
          core = (
              '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
              '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
              'xmlns:dc="http://purl.org/dc/elements/1.1/">'
              f'<dc:creator>{creator}</dc:creator>'
              f'<cp:lastModifiedBy>{creator}</cp:lastModifiedBy>'
              '</cp:coreProperties>'
          )
          with zipfile.ZipFile(path, "w") as z:
              z.writestr("docProps/core.xml", core)
              z.writestr("word/document.xml", body)
      make_docx(os.path.join(work, "leaky", "manuscript.docx"), "Alex P. Investigator")
      make_docx(os.path.join(work, "clean", "manuscript.docx"), "Microsoft Office User")
      # clean metadata but an absolute home path leaked into a pic descr (pandoc pattern)
      make_docx(
          os.path.join(work, "pathleak", "supplement.docx"),
          "Microsoft Office User",
          body='<w:document><pic:pic><pic:nvPicPr>'
               '<pic:cNvPr id="1" name="Picture" '
               'descr="/Users/testuser/proj/figures/funnel.png"/>'
               '</pic:nvPicPr></pic:pic></w:document>',
      )
      # Build-input properties and external relationships live outside word/*.xml.
      make_docx(os.path.join(work, "custompath", "manuscript.docx"), "Pandoc")
      with zipfile.ZipFile(os.path.join(work, "custompath", "manuscript.docx"), "a") as z:
          z.writestr("docProps/custom.xml", '<Properties><property name="csl">'
                     '<value>/Users/testuser/styles/journal.csl</value></property></Properties>')
      make_docx(os.path.join(work, "relationshippath", "manuscript.docx"), "Pandoc")
      with zipfile.ZipFile(os.path.join(work, "relationshippath", "manuscript.docx"), "a") as z:
          z.writestr("word/_rels/document.xml.rels", '<Relationships><Relationship '
                     'Target="C:\\Users\\testuser\\figures\\plot.png"/></Relationships>')
      PY
      
      run() { python3 "$SCRIPT" "$@" 2>/dev/null; }
      
      # 1. leaky dir (no names file): docx author leak -> exit 1
      run --dir "$WORK/leaky" --quiet
      [ $? -eq 1 ] && ok "leaky dir fails (docx author leak)" || bad "leaky dir should fail"
      
      # 2. JSON reports the docx author + institution token finding types
      out="$(run --dir "$WORK/leaky" --names-file "$WORK/names.txt" --out "$WORK/r.json"; cat "$WORK/r.json")"
      echo "$out" | python3 -c "
      import json,sys
      d=json.load(open('$WORK/r.json'))
      types={f['type'] for f in d['findings']}
      need={'docx_metadata_author','figure_script_institution','figure_script_name'}
      sys.exit(0 if need <= types and d['submission_safe'] is False else 1)
      " && ok "JSON: docx author + institution + name-file findings" || bad "JSON findings incomplete"
      
      # 3. name-file hit is a 'leak'
      run --dir "$WORK/leaky" --names-file "$WORK/names.txt" --out "$WORK/r2.json" --quiet
      python3 -c "
      import json,sys
      d=json.load(open('$WORK/r2.json'))
      sys.exit(0 if d['summary']['leak'] >= 2 else 1)  # docx author + name hit
      " && ok "name-file hit counts as leak" || bad "name-file hit should be leak"
      
      # 4. clean dir -> exit 0
      run --dir "$WORK/clean" --quiet
      [ $? -eq 0 ] && ok "clean dir passes (tool author, no tokens)" || bad "clean dir should pass"
      
      # 5. review-only dir under --strict fails; default passes
      mkdir -p "$WORK/reviewonly/figures"
      cp "$WORK/leaky/figures/flow.R" "$WORK/reviewonly/figures/flow.R"
      run --dir "$WORK/reviewonly" --quiet
      [ $? -eq 0 ] && ok "review-only passes by default" || bad "review-only should pass by default"
      run --dir "$WORK/reviewonly" --strict --quiet
      [ $? -eq 1 ] && ok "review-only fails under --strict" || bad "review-only should fail under --strict"
      
      # 6. docx with an absolute home path in pic descr -> leak (even with tool author)
      run --dir "$WORK/pathleak" --quiet
      [ $? -eq 1 ] && ok "docx embedded abs-path fails (pic descr leak)" || bad "abs-path docx should fail"
      run --dir "$WORK/pathleak" --out "$WORK/r3.json" --quiet
      python3 -c "
      import json,sys
      d=json.load(open('$WORK/r3.json'))
      types={f['type'] for f in d['findings']}
      sys.exit(0 if 'docx_embedded_abs_path' in types and d['submission_safe'] is False else 1)
      " && ok "JSON: docx_embedded_abs_path finding present" || bad "abs-path finding missing"
      
      for case in custompath relationshippath; do
        run --dir "$WORK/$case" --out "$WORK/$case.json" --quiet
        [ $? -eq 1 ] && ok "$case fails with clean author metadata" || bad "$case leak was missed"
        python3 - "$WORK/$case.json" <<'PY'
      import json, sys
      d = json.load(open(sys.argv[1]))
      assert not d["submission_safe"]
      assert any(f["type"] == "docx_embedded_abs_path" for f in d["findings"])
      PY
        [ $? -eq 0 ] && ok "$case reports the path leak" || bad "$case finding missing"
      done
      
      echo ""
      echo "test_asset_anonymization: $PASS passed, $FAIL failed"
      [ "$FAIL" -eq 0 ]
      
    • test_checklist_dump_leak.sh 3.5 KB
      #!/usr/bin/env bash
      # Test scripts/check_checklist_dump_leak.py — the audit-dump leak gate.
      # Synthetic, PII-free fixtures: a markdown "checklist" that is actually a
      # /check-reporting audit dump (positive), a docx carrying the same tokens, and a
      # legitimate official STROBE-style checklist (negative). Stdlib-only; PDF paths
      # are exercised only opportunistically (poppler may be absent).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_checklist_dump_leak.py"
      PASS=0
      FAIL=0
      ok()  { echo "  PASS: $1"; PASS=$((PASS+1)); }
      bad() { echo "  FAIL: $1"; FAIL=$((FAIL+1)); }
      
      WORK="$(mktemp -d)"
      trap 'rm -rf "$WORK"' EXIT
      
      mkdir -p "$WORK/leaky" "$WORK/clean" "$WORK/docxleak"
      
      # --- positive: a markdown file that is really a /check-reporting audit dump ---
      cat > "$WORK/leaky/STROBE_checklist_v4.md" <<'EOF'
      # STROBE Reporting Checklist — Audit
      
      ## Action Items
      - Item 7 [PARTIAL→auto-fixed]: methods location added.
      - Auto-fix: harmonised abbreviation.
      
      ```json
      {"compliance_pct": 81.8, "fixable_by_ai": 3, "check_reporting_version": "1.2",
       "checked_items": 22, "suggested_fix": "add page refs", "log": "qc/_pipeline_log.md"}
      ```
      EOF
      
      # --- negative: a legitimate official STROBE checklist ---
      cat > "$WORK/clean/STROBE_checklist.md" <<'EOF'
      # STROBE Statement — Checklist of items for cohort studies
      
      | Item | Recommendation | Reported in (page/section) |
      |------|----------------|----------------------------|
      | 1 | Indicate the study design in the title or abstract | Title; Abstract |
      | 6 | Give eligibility criteria, sources and methods of selection | Methods, Participants |
      | 13 | Report numbers of individuals at each stage | Results, Figure 1 |
      | 16 | Give unadjusted and adjusted estimates with CIs | Results, Table 2 |
      EOF
      
      run() { python3 "$SCRIPT" "$@" 2>/dev/null; }
      
      # 1. dump-as-checklist markdown -> exit 1
      run --dir "$WORK/leaky" --quiet
      [ $? -eq 1 ] && ok "audit-dump markdown fails" || bad "audit-dump markdown should fail"
      
      # 2. JSON reports dump tokens and submission_safe:false
      run --dir "$WORK/leaky" --out "$WORK/r.json" --quiet
      python3 -c "
      import json,sys
      d=json.load(open('$WORK/r.json'))
      types={f['type'] for f in d['findings']}
      sys.exit(0 if 'checklist_dump_token' in types and d['submission_safe'] is False
               and d['summary']['leak'] >= 3 else 1)
      " && ok "JSON: dump tokens flagged, not submission_safe" || bad "JSON findings incomplete"
      
      # 3. legitimate official checklist -> exit 0 (no false positive)
      run --dir "$WORK/clean" --quiet
      [ $? -eq 0 ] && ok "official STROBE checklist passes (no FP)" || bad "official checklist should pass"
      
      # 4. docx carrying audit-dump tokens -> exit 1
      python3 - "$WORK" <<'PY'
      import sys, os, zipfile
      work = sys.argv[1]
      body = ("<w:document><w:body><w:p><w:r><w:t>compliance_pct: 81.8 "
              "fixable_by_ai Auto-fix: stale</w:t></w:r></w:p></w:body></w:document>")
      with zipfile.ZipFile(os.path.join(work, "docxleak", "checklist.docx"), "w") as z:
          z.writestr("word/document.xml", body)
      PY
      run --dir "$WORK/docxleak" --quiet
      [ $? -eq 1 ] && ok "docx audit-dump fails" || bad "docx audit-dump should fail"
      
      # 5. empty/clean dir with only an official checklist stays clean under repeat run
      run --dir "$WORK/clean" --out "$WORK/r2.json" --quiet
      python3 -c "
      import json,sys
      d=json.load(open('$WORK/r2.json'))
      sys.exit(0 if d['submission_safe'] is True and not d['findings'] else 1)
      " && ok "clean dir reports no findings" || bad "clean dir should report none"
      
      echo ""
      echo "test_checklist_dump_leak: $PASS passed, $FAIL failed"
      [ "$FAIL" -eq 0 ]
      
    • test_copy_divergence.sh 1.8 KB
      #!/usr/bin/env bash
      # Regression test for the multi-copy manuscript divergence detector (Phase 8).
      # Synthetic fixtures: an SSOT and two copies — one reworded but claim-complete
      # (OK), one missing an SSOT claim (p = 0.074, an unpropagated edit). Stdlib-only.
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/detect_copy_divergence.py"
      SSOT="$HERE/fixtures/ssot.md"
      OKC="$HERE/fixtures/copy_ok.md"
      STALE="$HERE/fixtures/copy_stale.md"
      OUT="$(mktemp -t cd_XXXX).json"
      trap 'rm -f "$OUT"' EXIT
      
      fail=0
      check() { local label="$1"; shift
          if "$@" >/dev/null 2>&1; then printf '  PASS  %s\n' "$label"
          else printf '  FAIL  %s\n' "$label"; fail=$((fail+1)); fi
      }
      
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      
      # OK copy alone -> exit 0.
      python3 "$SCRIPT" --ssot "$SSOT" --copy "$OKC" --strict >/dev/null 2>&1
      check "exit 0 when the copy carries every SSOT claim" test "$?" -eq 0
      
      # Stale copy -> exit 1, flags the unpropagated claim.
      python3 "$SCRIPT" --ssot "$SSOT" --copy "$OKC" --copy "$STALE" --out "$OUT" --strict >/dev/null 2>&1
      check "exit 1 when a copy is missing an SSOT claim" test "$?" -eq 1
      check "verdict DIVERGENT" python3 -c "
      import json; assert json.load(open('$OUT'))['verdict']=='DIVERGENT'"
      check "stale copy flags the unpropagated p-value" python3 -c "
      import json
      d=json.load(open('$OUT'))
      stale=[c for c in d['copies'] if c['verdict']=='STALE_COPY']
      assert stale and any('p=0.074' in u for u in stale[0]['unpropagated_to_copy'])"
      check "OK copy not flagged" python3 -c "
      import json
      d=json.load(open('$OUT'))
      ok=[c for c in d['copies'] if c['copy'].endswith('copy_ok.md')]
      assert ok and ok[0]['verdict']=='OK'"
      
      echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
    • test_cover_letter_title_drift.sh 2.6 KB
      #!/usr/bin/env bash
      # Regression for cover_letter_drift_check.py TITLE_DRIFT (Phase 4 cover-letter gate).
      # A manuscript title must appear verbatim in the cover letter and match the project
      # config; three different live titles at once is a guaranteed desk-check flag.
      set -u
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/cover_letter_drift_check.py"
      T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
      OUT="$T/o.json"
      fail=0
      check() { local label="$1"; shift
        if "$@" >/dev/null 2>&1; then printf '  PASS  %s\n' "$label"
        else printf '  FAIL  %s\n' "$label"; fail=$((fail+1)); fi; }
      has_field() { python3 -c "
      import json
      d=json.load(open('$OUT'))
      assert any(x['field']=='$1' for x in d['drifts']), '$1 not in drifts'
      "; }
      no_drift() { python3 -c "
      import json
      d=json.load(open('$OUT'))
      assert d['submission_safe'] and not d['drifts'], d['drifts']
      "; }
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      
      cat > "$T/manuscript.md" <<'MD'
      ---
      title: Adjunctive ablation halves local recurrence
      ---
      ## Introduction
      Body text citing Table 1 and Figure 1.
      MD
      
      # POSITIVE: cover letter states a DRIFTED title + config carries a THIRD title.
      cat > "$T/cover_bad.md" <<'MD'
      Dear Editor, we submit our manuscript entitled "Adjunctive ablation reduces local recurrence".
      MD
      cat > "$T/ssot_bad.yaml" <<'MD'
      title_working: Adjunctive thermal ablation and recurrence
      MD
      python3 "$SCRIPT" --manuscript "$T/manuscript.md" --cover-letter "$T/cover_bad.md" \
          --config "$T/ssot_bad.yaml" --out "$OUT" >/dev/null 2>&1
      check "exit 2 on title drift" test "$?" -eq 2
      check "TITLE_DRIFT (cover letter) reported"  has_field title
      check "TITLE_DRIFT (config) reported"        has_field "title(config)"
      
      # NEGATIVE: cover letter states the title verbatim, config matches -> silent.
      cat > "$T/cover_ok.md" <<'MD'
      Dear Editor, we submit our manuscript entitled "Adjunctive ablation halves local recurrence".
      MD
      cat > "$T/ssot_ok.yaml" <<'MD'
      title_working: Adjunctive ablation halves local recurrence
      MD
      python3 "$SCRIPT" --manuscript "$T/manuscript.md" --cover-letter "$T/cover_ok.md" \
          --config "$T/ssot_ok.yaml" --out "$OUT" >/dev/null 2>&1
      check "exit 0 when title agrees everywhere" test "$?" -eq 0
      check "no drifts when title agrees"         no_drift
      
      # NEGATIVE: no --config given, title verbatim in cover letter -> silent (no config FP).
      python3 "$SCRIPT" --manuscript "$T/manuscript.md" --cover-letter "$T/cover_ok.md" \
          --out "$OUT" >/dev/null 2>&1
      check "exit 0 without --config when cover letter carries the title" test "$?" -eq 0
      
      echo "test_cover_letter_title_drift: $((6-fail)) passed, $fail failed"
      [[ "$fail" -eq 0 ]] || exit 1
      
    • test_cross_artifact_stale.sh 5.7 KB
      #!/usr/bin/env bash
      # Test scripts/check_cross_artifact_stale.py — the A3 cross-artifact staleness gate.
      # Synthetic, PII-free fixtures: a body that corrects two labeled values, a stale
      # supplement that disagrees, a checklist built against an older manuscript version,
      # and a clean supplement that agrees.
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_cross_artifact_stale.py"
      PASS=0
      FAIL=0
      ok()  { echo "  PASS: $1"; PASS=$((PASS+1)); }
      bad() { echo "  FAIL: $1"; FAIL=$((FAIL+1)); }
      
      WORK="$(mktemp -d)"
      trap 'rm -rf "$WORK"' EXIT
      
      # body (v8 in filename) — corrected values
      cat > "$WORK/manuscript_v8.md" <<'EOF'
      ## Results
      The complete-case analysis included 95.7% of the cohort.
      Inter-rater agreement was substantial (kappa = 0.871).
      EOF
      
      # stale supplement: disagreeing values for the same labels
      mkdir -p "$WORK/aux"
      cat > "$WORK/aux/supplement.md" <<'EOF'
      Footnote: complete-case retention was 7.5% after exclusions.
      Reliability sub-analysis: kappa = 0.842.
      EOF
      
      # checklist built against an older manuscript version
      cat > "$WORK/aux/strobe_checklist.md" <<'EOF'
      Target manuscript: cohort study v6 (2026-04-20)
      Item 13: see Results, line 42.
      EOF
      
      # clean supplement that agrees with the body
      mkdir -p "$WORK/clean"
      cat > "$WORK/clean/supplement.md" <<'EOF'
      Footnote: complete-case retention was 95.7%.
      Reliability: kappa = 0.871.
      EOF
      
      run() { python3 "$SCRIPT" "$@" 2>/dev/null; }
      
      # 1. drift + version-stale -> exit 1
      run --manuscript "$WORK/manuscript_v8.md" --aux "$WORK/aux" --quiet
      [ $? -eq 1 ] && ok "stale aux + old checklist version -> exit 1" || bad "should fail"
      
      # 2. JSON reports both finding types
      run --manuscript "$WORK/manuscript_v8.md" --aux "$WORK/aux" --out "$WORK/r.json" --quiet
      python3 -c "
      import json,sys
      d=json.load(open('$WORK/r.json'))
      types={f['type'] for f in d['findings']}
      sys.exit(0 if {'labeled_value_drift','checklist_version_stale'} <= types
               and d['summary']['stale'] >= 2 and d['summary']['version_stale'] >= 1 else 1)
      " && ok "JSON: labeled_value_drift (kappa+complete_case) + version_stale" || bad "JSON findings incomplete"
      
      # 3. explicit --manuscript-version overrides filename inference
      run --manuscript "$WORK/manuscript_v8.md" --aux "$WORK/aux/strobe_checklist.md" \
          --manuscript-version v8 --out "$WORK/r2.json" --quiet
      python3 -c "
      import json,sys
      d=json.load(open('$WORK/r2.json'))
      sys.exit(0 if d['summary']['version_stale'] == 1 else 1)
      " && ok "explicit --manuscript-version flags stale checklist" || bad "version flag failed"
      
      # 4. clean supplement that agrees -> exit 0
      run --manuscript "$WORK/manuscript_v8.md" --aux "$WORK/clean" --quiet
      [ $? -eq 0 ] && ok "agreeing supplement passes" || bad "agreeing supplement should pass"
      
      # 5. missing --aux -> usage error (exit 2)
      run --manuscript "$WORK/manuscript_v8.md" --quiet
      [ $? -eq 2 ] && ok "missing --aux -> exit 2" || bad "missing --aux should be usage error"
      
      # --- retired-term / old-value survivor scan (reframe-drift + claim-site propagation) ---
      mkdir -p "$WORK/reframe"
      # body reframed to "overall pooled"; a superseded headline value 1.72 -> 2.03
      cat > "$WORK/reframe/manuscript.md" <<'EOF'
      ## Results
      The overall pooled estimate was 2.03.
      EOF
      # supplement kept the retired framing AND the old value
      cat > "$WORK/reframe/supplement.md" <<'EOF'
      Supplementary Table S3. Location-stratified benchmark.
      The dome benchmark hazard ratio was 1.72.
      EOF
      # a clean supplement matching the reframed body
      cat > "$WORK/reframe/clean_suppl.md" <<'EOF'
      Supplementary Table S3. Overall pooled estimate 2.03.
      EOF
      
      # 6. retired framing term survives in the supplement -> exit 1
      run --manuscript "$WORK/reframe/manuscript.md" --aux "$WORK/reframe/supplement.md" \
          --retired-term "location-stratified benchmark" --out "$WORK/r3.json" --quiet
      [ $? -eq 1 ] && ok "retired framing survivor in supplement -> exit 1" || bad "retired-term survivor should fail"
      python3 -c "
      import json,sys
      d=json.load(open('$WORK/r3.json'))
      sys.exit(0 if any(f['type']=='retired_framing_survivor' for f in d['findings']) else 1)
      " && ok "JSON: retired_framing_survivor" || bad "missing retired_framing_survivor finding"
      
      # 7. superseded value survives in the supplement -> exit 1
      run --manuscript "$WORK/reframe/manuscript.md" --aux "$WORK/reframe/supplement.md" \
          --old-value 1.72 --out "$WORK/r4.json" --quiet
      python3 -c "
      import json,sys
      d=json.load(open('$WORK/r4.json'))
      sys.exit(0 if any(f['type']=='stale_old_value' for f in d['findings']) else 1)
      " && ok "JSON: stale_old_value" || bad "missing stale_old_value finding"
      
      # 8. retired term survives in the BODY itself (un-touched paragraph) -> exit 1
      cat > "$WORK/reframe/body_stale.md" <<'EOF'
      ## Results
      The overall pooled estimate was 2.03. As shown in the location-stratified benchmark, the dome stratum led.
      EOF
      run --manuscript "$WORK/reframe/body_stale.md" --retired-term "location-stratified benchmark" --quiet
      [ $? -eq 1 ] && ok "retired survivor in body paragraph -> exit 1" || bad "body survivor should fail"
      
      # 9. numeric boundary: --old-value 1.72 must NOT match 11.723 -> exit 0
      cat > "$WORK/reframe/numbound.md" <<'EOF'
      The value is 11.723 throughout.
      EOF
      run --manuscript "$WORK/reframe/numbound.md" --old-value 1.72 --quiet
      [ $? -eq 0 ] && ok "old-value 1.72 does not match 11.723 (numeric boundary)" || bad "numeric boundary false positive"
      
      # 10. reframed body + matching supplement, retired term absent -> exit 0
      run --manuscript "$WORK/reframe/manuscript.md" --aux "$WORK/reframe/clean_suppl.md" \
          --retired-term "location-stratified benchmark" --old-value 1.72 --quiet
      [ $? -eq 0 ] && ok "no survivors after full reframe -> exit 0" || bad "clean reframe should pass"
      
      echo ""
      echo "test_cross_artifact_stale: $PASS passed, $FAIL failed"
      [ "$FAIL" -eq 0 ]
      
    • test_cross_document_n.sh 4.3 KB
      #!/usr/bin/env bash
      # Regression tests for sync-submission cross_document_n_check.py.
      #
      # Synthetic fixtures only (no project paths, no manuscript IDs).
      # Skip with NETWORK=0 — this script does not use the network.
      #
      # Usage:
      #   skills/sync-submission/tests/test_cross_document_n.sh
      # Exit codes:
      #   0 — all tests passed
      #   1 — at least one test regressed
      #   2 — environment problem
      
      set -uo pipefail
      
      REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
      SCRIPT="$REPO_ROOT/skills/sync-submission/scripts/cross_document_n_check.py"
      TMP="$(mktemp -d -t cross_doc_n.XXXXXX)"
      trap 'rm -rf "$TMP"' EXIT
      
      if [[ ! -f "$SCRIPT" ]]; then
          echo "ENV-ERR: script not found at $SCRIPT" >&2
          exit 2
      fi
      command -v python3 >/dev/null 2>&1 || { echo "ENV-ERR: python3 missing" >&2; exit 2; }
      
      fail=0
      ran=0
      
      assert_exit() {
          local label="$1" expected="$2" actual="$3"
          ran=$((ran + 1))
          if [[ "$expected" == "$actual" ]]; then
              printf '  PASS  %-50s exit=%s\n' "$label" "$actual"
          else
              printf '  FAIL  %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual"
              fail=$((fail + 1))
          fi
      }
      
      # --------------------------------------------------------------------------
      # Case 1: two documents agreeing on N. Should PASS (exit 0).
      # --------------------------------------------------------------------------
      PROJ1="$TMP/case1_consistent"
      mkdir -p "$PROJ1"
      cat > "$PROJ1/abstract.md" <<'EOF'
      We included 42 studies after screening.
      EOF
      cat > "$PROJ1/manuscript.md" <<'EOF'
      Results section. Across 42 included studies, the pooled estimate was...
      EOF
      python3 "$SCRIPT" --root "$PROJ1" --out "$PROJ1/qc/n.json" --quiet
      assert_exit "case 1: consistent N (42 = 42)" 0 $?
      
      # --------------------------------------------------------------------------
      # Case 2: drift. Should FAIL (exit 1).
      # --------------------------------------------------------------------------
      PROJ2="$TMP/case2_drift"
      mkdir -p "$PROJ2"
      cat > "$PROJ2/abstract.md" <<'EOF'
      We included 63 studies after dual-reviewer screening.
      EOF
      cat > "$PROJ2/manuscript.md" <<'EOF'
      A total of 64 included studies were extracted for synthesis.
      EOF
      python3 "$SCRIPT" --root "$PROJ2" --out "$PROJ2/qc/n.json" --quiet
      assert_exit "case 2: drift (63 vs 64)" 1 $?
      # Verify the JSON content is well-formed and flags the right category.
      python3 - "$PROJ2/qc/n.json" <<'PY' || fail=$((fail + 1))
      import json, sys
      with open(sys.argv[1]) as fh:
          rep = json.load(fh)
      assert rep["submission_safe"] is False, rep
      assert rep["drift_count"] == 1, rep
      assert rep["drifts"][0]["category"] == "included", rep
      vs = set(rep["drifts"][0]["values"])
      assert vs == {63, 64}, rep
      PY
      
      # --------------------------------------------------------------------------
      # Case 3: pool-lock mismatch.
      # --------------------------------------------------------------------------
      if python3 -c "import yaml" 2>/dev/null; then
          PROJ3="$TMP/case3_lock"
          mkdir -p "$PROJ3"
          cat > "$PROJ3/abstract.md" <<'EOF'
      We included 50 studies after screening.
      EOF
          cat > "$PROJ3/manuscript.md" <<'EOF'
      Across 50 included studies, the pooled estimate was ...
      EOF
          cat > "$PROJ3/lock.yaml" <<'EOF'
      freeze_date: 2026-01-01
      final_pool_n: 48
      include_count: 48
      exclude_count: 100
      EOF
          python3 "$SCRIPT" --root "$PROJ3" --pool-lock "$PROJ3/lock.yaml" \
              --out "$PROJ3/qc/n.json" --quiet
          assert_exit "case 3: pool-lock mismatch (50 vs locked 48)" 1 $?
          python3 - "$PROJ3/qc/n.json" <<'PY' || fail=$((fail + 1))
      import json, sys
      with open(sys.argv[1]) as fh:
          rep = json.load(fh)
      assert rep["lock_violations"], rep
      v = rep["lock_violations"][0]
      assert v["violation"] == "pool-lock-mismatch", v
      assert v["expected"] == 48, v
      assert v["actual"] == 50, v
      PY
      else
          printf '  SKIP  %-50s reason=pyyaml unavailable\n' "case 3: pool-lock"
      fi
      
      # --------------------------------------------------------------------------
      # Case 4: explicit --files. Should also work.
      # --------------------------------------------------------------------------
      PROJ4="$TMP/case4_files"
      mkdir -p "$PROJ4/sub"
      cat > "$PROJ4/sub/a.md" <<'EOF'
      We included 30 patients in the analysis.
      EOF
      cat > "$PROJ4/sub/b.md" <<'EOF'
      30 patients were enrolled.
      EOF
      python3 "$SCRIPT" --files "$PROJ4/sub/a.md" "$PROJ4/sub/b.md" \
          --out "$PROJ4/qc/n.json" --quiet
      assert_exit "case 4: explicit --files PASS (30 = 30)" 0 $?
      
      echo ""
      echo "ran=$ran fail=$fail"
      [[ $fail -eq 0 ]]
      
    • test_disclosure_availability.sh 5.3 KB
      #!/usr/bin/env bash
      # Regression/challenge test for check_disclosure_availability.py — deterministic,
      # network-free, synthetic fixtures built at runtime.
      set -u
      
      HERE="$(cd "$(dirname "$0")" && pwd)"
      SCRIPT="$HERE/../scripts/check_disclosure_availability.py"
      TMP="$(mktemp -d)"
      trap 'rm -rf "$TMP"' EXIT
      
      pass=0
      fail=0
      ck() {
        local label="$1" expected="$2" actual="$3"
        if [ "$expected" = "$actual" ]; then
          printf '  PASS  %-52s exit=%s\n' "$label" "$actual"
          pass=$((pass + 1))
        else
          printf '  FAIL  %-52s expected=%s actual=%s\n' "$label" "$expected" "$actual"
          fail=$((fail + 1))
        fi
      }
      run() { python3 "$SCRIPT" --out "$TMP/r.json" "$@" > /dev/null 2>&1; echo $?; }
      
      # A complete, conformant manuscript tail.
      cat > "$TMP/good.md" <<'EOF'
      ## AI use disclosure
      During preparation the authors used Claude 3.5 (Anthropic) via the API in 2026 for
      language editing; all output was reviewed by the authors.
      
      ## Data Availability
      The dataset is available at https://doi.org/10.5281/zenodo.123456.
      
      ## Code Availability
      Analysis code is at https://github.com/example/repo.
      
      ## Funding
      Supported by a institutional grant.
      
      ## Competing Interests
      The authors declare no competing interests.
      EOF
      ck "complete statements (ai-study) -> clean" 0 "$(run --manuscript "$TMP/good.md" --journal radiology --ai-study --strict)"
      
      # AI disclosure missing the date + responsible-party tokens -> BLOCKER
      cat > "$TMP/ai_bad.md" <<'EOF'
      ## AI use disclosure
      The authors used ChatGPT via the web interface.
      
      ## Data Availability
      Available at https://github.com/example/repo.
      
      ## Code Availability
      https://github.com/example/repo
      
      ## Funding
      None.
      
      ## Competing Interests
      None.
      EOF
      ck "AI disclosure missing tokens -> blocker" 1 "$(run --manuscript "$TMP/ai_bad.md" --journal radiology --ai-study --strict)"
      
      # AI disclosure with a placeholder -> BLOCKER
      cat > "$TMP/ai_ph.md" <<'EOF'
      ## AI use disclosure
      The authors used Claude [version] via the API in 2026; reviewed by the authors.
      
      ## Data Availability
      https://doi.org/10.5281/zenodo.1
      
      ## Funding
      None.
      
      ## Competing Interests
      None.
      EOF
      ck "AI disclosure placeholder -> blocker" 1 "$(run --manuscript "$TMP/ai_ph.md" --journal npj-digital-medicine --strict)"
      
      # Missing Data Availability on a journal that requires it -> BLOCKER
      cat > "$TMP/no_data.md" <<'EOF'
      ## Funding
      None.
      
      ## Competing Interests
      None.
      EOF
      ck "missing Data Availability (required journal) -> blocker" 1 "$(run --manuscript "$TMP/no_data.md" --journal radiology)"
      
      # Hollow 'on reasonable request' where a repository is expected -> advisory (exit 0 w/o --strict)
      cat > "$TMP/hollow.md" <<'EOF'
      ## Data Availability
      The data are available from the corresponding author on reasonable request.
      
      ## Funding
      None.
      
      ## Competing Interests
      None.
      EOF
      ck "hollow data statement -> advisory (no --strict)" 0 "$(run --manuscript "$TMP/hollow.md" --journal radiology)"
      ck "hollow data statement -> blocker under --strict" 1 "$(run --manuscript "$TMP/hollow.md" --journal radiology --strict)"
      
      # No AI tool mentioned at all -> AI disclosure not required (clean if others present)
      ck "no AI mention -> no ai-disclosure finding" 0 "$(run --manuscript "$TMP/good.md" --journal lancet-digital-health --strict)"
      
      # --- house style is not optional vocabulary (found by measuring 12 accepted papers) -------------
      # Each case below was reported MISSING against a real accepted paper that carried the statement,
      # correctly worded for its own journal. A fixture written beside a detector always uses the
      # phrasing that detector expects, which is exactly why none of this could surface here before.
      
      cat > "$TMP/jama.md" <<'EOF'
      # Trial
      
      ## Article Information
      
      Conflict of Interest Disclosures: None reported.
      
      Funding/Support: This trial was funded by a company.
      
      Data Sharing Statement: See Supplement 3.
      EOF
      ck "JAMA 'Data Sharing Statement' counts as data availability" 0 \
        "$(run --manuscript "$TMP/jama.md" --journal radiology --require data_availability)"
      
      cat > "$TMP/elsevier.md" <<'EOF'
      # Review
      
      ## Data availability
      No data were generated.
      
      ## Declaration of competing interest
      The authors declare none.
      
      ## Funding sources
      None.
      EOF
      ck "Elsevier 'Declaration of competing interest' counts as COI" 0 \
        "$(run --manuscript "$TMP/elsevier.md" --journal radiology --require coi)"
      
      # A paper whose SUBJECT is AI has not thereby disclosed using AI to write itself. Demanding a
      # writing tool's version / channel / date / responsible party from its abstract is a category error.
      cat > "$TMP/ai_subject.md" <<'EOF'
      # Reader study
      
      ## Abstract
      We evaluated whether AI-assisted reading with a large language model improves detection.
      
      ## Data Availability
      Data are at https://zenodo.org/record/1.
      
      ## Funding
      None.
      
      ## Competing Interests
      None.
      EOF
      ck "AI as the study's subject is not an AI-use disclosure" 0 \
        "$(run --manuscript "$TMP/ai_subject.md" --journal radiology --strict)"
      
      # ...but an actual authorial disclosure still owes its tokens.
      cat > "$TMP/ai_used.md" <<'EOF'
      # Paper
      
      ## Data Availability
      Data are at https://zenodo.org/record/1.
      
      ## Funding
      None.
      
      ## Competing Interests
      None.
      
      ## AI Disclosure
      The authors used ChatGPT for language editing.
      EOF
      ck "an authorial AI-use disclosure still owes its tokens" 1 \
        "$(run --manuscript "$TMP/ai_used.md" --journal radiology)"
      
      echo "----"
      echo "test_disclosure_availability: $pass passed, $fail failed"
      [ "$fail" -eq 0 ]
      
    • test_marked_build_failure.sh 4.9 KB
      #!/usr/bin/env bash
      # Regression test: a Compare that fails must not leave a file where the marked manuscript goes.
      #
      # `run_compare` seeds `--out` with a copy of the original so Word has something to open. When the
      # comparison then dies, that copy stays: a plausible .docx, of plausible size, at exactly the path
      # the user asked the marked manuscript to be written to — and carrying zero tracked changes. On an
      # AJNR major revision (149 paragraphs and no tables against 193 and two, i.e. a rewrite) Compare
      # ran past the then-default 180-second timeout and failed -1712. The run said so, but the artifact
      # it left behind could not be told apart by inspection from a revision with nothing to revise.
      #
      # Two things are pinned here: the failure removes the seed, and the default timeout is no longer
      # the one that failed. Word itself is never invoked — `subprocess.run` and `platform.system` are
      # both substituted — so this runs on CI's Linux exactly as it does on a Mac.
      set -u
      
      REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
      B="$REPO_ROOT/skills/sync-submission/scripts/build_marked_manuscript.py"
      
      pass=0
      fail=0
      ck() {
        local label="$1" expected="$2" actual="$3"
        if [ "$expected" = "$actual" ]; then
          printf '  PASS  %-52s %s\n' "$label" "$actual"
          pass=$((pass + 1))
        else
          printf '  FAIL  %-52s expected=%s actual=%s\n' "$label" "$expected" "$actual"
          fail=$((fail + 1))
        fi
      }
      
      out="$(python3 - "$B" <<'PY'
      import importlib.util, json, re, subprocess, sys, tempfile, types
      from pathlib import Path
      
      spec = importlib.util.spec_from_file_location("bmm", sys.argv[1])
      m = importlib.util.module_from_spec(spec)
      sys.modules["bmm"] = m
      spec.loader.exec_module(m)
      
      results = {}
      
      # The default a user gets when they do not pass --timeout. 180 is the value that failed.
      src = Path(sys.argv[1]).read_text(encoding="utf-8")
      mo = re.search(r'"--timeout",\s*\n?\s*type=int,\s*\n?\s*default=(\d+)', src)
      results["default_timeout"] = mo.group(1) if mo else "NOT FOUND"
      
      m.platform = types.SimpleNamespace(system=lambda: "Darwin")
      
      
      def _run_fake(returncode, stderr):
          def _fake(*a, **k):
              return subprocess.CompletedProcess(a[0] if a else [], returncode, "", stderr)
          return _fake
      
      
      with tempfile.TemporaryDirectory() as td:
          td = Path(td)
          original = td / "r0.docx"
          revised = td / "r1.docx"
          original.write_bytes(b"PK\x03\x04original")
          revised.write_bytes(b"PK\x03\x04revised")
      
          # 1. AppleEvent timeout: Compare needed longer than --timeout.
          out1 = td / "marked_timeout.docx"
          m.subprocess = types.SimpleNamespace(
              run=_run_fake(1, "execution error: Microsoft Word got an error: AppleEvent timed out. (-1712)"),
              TimeoutExpired=subprocess.TimeoutExpired,
          )
          try:
              m.run_compare(original, revised, out1, "A Author", 180)
              results["timeout_raised"] = "false"
              results["timeout_message"] = ""
          except SystemExit as e:
              results["timeout_raised"] = "true"
              results["timeout_message"] = str(e)
          results["timeout_left_a_file"] = str(out1.exists()).lower()
      
          # 2. Any other Compare failure — the seed must go too.
          out2 = td / "marked_other.docx"
          m.subprocess = types.SimpleNamespace(
              run=_run_fake(1, "execution error: Word could not open the document."),
              TimeoutExpired=subprocess.TimeoutExpired,
          )
          try:
              m.run_compare(original, revised, out2, "A Author", 600)
          except SystemExit:
              pass
          results["other_failure_left_a_file"] = str(out2.exists()).lower()
      
          # 3. NEGATIVE CONTROL — a Compare that SUCCEEDS must leave the file alone.
          out3 = td / "marked_ok.docx"
          m.subprocess = types.SimpleNamespace(
              run=_run_fake(0, ""), TimeoutExpired=subprocess.TimeoutExpired
          )
          m.run_compare(original, revised, out3, "A Author", 600)
          results["success_kept_the_file"] = str(out3.exists()).lower()
      
      print(json.dumps(results))
      PY
      )"
      
      get() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$out" "$1"; }
      
      echo "==== the default is no longer the one that failed ===="
      ck "--timeout default"                       "600"   "$(get default_timeout)"
      
      echo "==== a failed Compare leaves nothing to mistake for a result ===="
      ck "AppleEvent timeout raises"               "true"  "$(get timeout_raised)"
      ck "timeout left a file at --out"            "false" "$(get timeout_left_a_file)"
      ck "other failure left a file at --out"      "false" "$(get other_failure_left_a_file)"
      
      echo "==== NEGATIVE CONTROL — success is untouched ===="
      ck "successful Compare kept its output"      "true"  "$(get success_kept_the_file)"
      
      echo "==== the timeout failure says what to do about it ===="
      msg="$(get timeout_message)"
      case "$msg" in
        *--timeout*) ck "message names --timeout" "yes" "yes" ;;
        *)           ck "message names --timeout" "yes" "no  ($msg)" ;;
      esac
      
      echo
      echo "  passed=$pass failed=$fail"
      [ "$fail" -eq 0 ] || exit 1
      echo "OK: a Compare that fails removes its seed, and the default timeout fits a real revision."
      
    • test_marked_manuscript.sh 9.9 KB
      #!/usr/bin/env bash
      # Regression test for skills/sync-submission/scripts/check_marked_manuscript.py — the
      # marked (tracked-changes) manuscript round-trip gate.
      #
      # The fixtures are synthetic OOXML written at run time (stdlib zipfile; no Word, no
      # python-docx), so the gate is exercised on the exact revision encodings Word emits:
      # w:ins / w:del for edits and w:moveFrom / w:moveTo for relocated content.
      #
      # The moved-paragraph case is the point of the test. It is a real discriminator: a
      # verifier that knows only ins/del reconstructs the *original* as containing the moved
      # paragraph twice (once from the moveFrom delText, once from the moveTo run it fails to
      # recognise as an insertion) and reports a good file as corrupt. The test asserts both
      # halves — the naive resolution fails, the move-aware gate passes.
      set -u
      
      REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
      V="$REPO_ROOT/skills/sync-submission/scripts/check_marked_manuscript.py"
      TMP="$(mktemp -d)"
      trap 'rm -rf "$TMP"' EXIT
      
      pass=0
      fail=0
      ck() {
        local label="$1" expected="$2" actual="$3"
        if [ "$expected" = "$actual" ]; then
          printf '  PASS  %-54s exit=%s\n' "$label" "$actual"
          pass=$((pass + 1))
        else
          printf '  FAIL  %-54s expected=%s actual=%s\n' "$label" "$expected" "$actual"
          fail=$((fail + 1))
        fi
      }
      
      # --- fixtures -------------------------------------------------------------------
      python3 - "$TMP" <<'PY'
      import sys, zipfile
      from pathlib import Path
      
      TMP = Path(sys.argv[1])
      NS = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
      AUTHOR, OTHER = "Submitting Author", "Other Person"
      D = 'w:date="2026-07-13T00:00:00Z"'
      _id = iter(range(100, 999))
      
      def run(t):        return f'<w:r><w:t xml:space="preserve">{t}</w:t></w:r>'
      def drun(t):       return f'<w:r><w:delText xml:space="preserve">{t}</w:delText></w:r>'
      def ins(t, a=AUTHOR):      return f'<w:ins w:id="{next(_id)}" w:author="{a}" {D}>{run(t)}</w:ins>'
      def dele(t, a=AUTHOR):     return f'<w:del w:id="{next(_id)}" w:author="{a}" {D}>{drun(t)}</w:del>'
      def move_from(t, a=AUTHOR): return f'<w:moveFrom w:id="{next(_id)}" w:author="{a}" {D}>{drun(t)}</w:moveFrom>'
      def move_to(t, a=AUTHOR):   return f'<w:moveTo w:id="{next(_id)}" w:author="{a}" {D}>{run(t)}</w:moveTo>'
      def p(*inner):     return "<w:p>" + "".join(inner) + "</w:p>"
      def table(t):      return f'<w:tbl><w:tr><w:tc>{p(run(t))}</w:tc></w:tr></w:tbl>'
      
      def docx(name, *blocks):
          body = "".join(blocks)
          doc = (f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
                 f'<w:document {NS}><w:body>{body}'
                 f'<w:sectPr><w:pgMar w:top="1440" w:bottom="1440"/></w:sectPr>'
                 f'</w:body></w:document>')
          ct = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
                '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
                '<Default Extension="xml" ContentType="application/xml"/>'
                '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
                '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
                '</Types>')
          rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
                  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
                  '<Relationship Id="rId1" Target="word/document.xml" '
                  'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"/>'
                  '</Relationships>')
          with zipfile.ZipFile(TMP / name, "w", zipfile.ZIP_DEFLATED) as z:
              z.writestr("[Content_Types].xml", ct)
              z.writestr("_rels/.rels", rels)
              z.writestr("word/document.xml", doc)
      
      A, B, C = "Alpha. ", "Bravo. ", "Charlie. "
      B2 = "Bravo revised. "
      
      # 1) baseline pair for the edit + move cases
      docx("original.docx", p(run(A)), p(run(B)), p(run(C)))
      
      # an ordinary edit: Bravo rewritten
      docx("revised_edit.docx", p(run(A)), p(run(B2)), p(run(C)))
      docx("marked_edit.docx", p(run(A)), p(dele(B), ins(B2)), p(run(C)))
      
      # same edit, but half the revisions attributed to someone else
      docx("marked_author.docx", p(run(A)), p(dele(B), ins(B2, OTHER)), p(run(C)))
      
      # Compare dropped a paragraph: Charlie is simply gone from the marked file
      docx("marked_dropped.docx", p(run(A)), p(dele(B), ins(B2)))
      
      # a MOVE: Bravo relocated after Charlie. Word encodes this as moveFrom/moveTo,
      # NOT as del/ins — the whole reason this gate must be move-aware.
      docx("revised_move.docx", p(run(A)), p(run(C)), p(run(B)))
      docx("marked_move.docx", p(run(A)), p(move_from(B)), p(run(C)), p(move_to(B)))
      
      # a clean copy of the revised file — no tracked changes at all
      docx("marked_clean.docx", p(run(A)), p(run(B2)), p(run(C)))
      
      # 2) table-loss pair: identical text, but Compare flattened the table to a paragraph
      CELL = "Cell one "
      docx("original_tbl.docx", p(run(A)), table(CELL))
      docx("revised_tbl.docx", p(run(B2)), table(CELL))
      docx("marked_tbl.docx", p(dele(A), ins(B2)), p(run(CELL)))
      
      # 3) a baseline that itself still carries tracked changes
      docx("original_dirty.docx", p(run(A)), p(ins(B)), p(run(C)))
      PY
      
      # --- 1) a moved paragraph must PASS (move-aware) ---------------------------------
      python3 "$V" --marked "$TMP/marked_move.docx" --original "$TMP/original.docx" \
        --revised "$TMP/revised_move.docx" --author "Submitting Author" --strict \
        --out "$TMP/move.json" > /dev/null 2>&1
      ck "moved paragraph passes the round trip" 0 "$?"
      
      python3 - "$TMP/move.json" <<'PY'
      import json, sys
      s = json.load(open(sys.argv[1]))["summary"]
      m = s["revision_marks"]
      assert m["moveTo"] and m["moveFrom"], f"fixture is not a move fixture: {m}"
      assert not m["ins"] and not m["del"], f"move must not be encoded as ins/del: {m}"
      PY
      ck "fixture really encodes a move (moveFrom/moveTo, no ins/del)" 0 "$?"
      
      # ... and the same file must FAIL a naive ins/del-only resolver — otherwise the
      # fixture would not discriminate and the move-awareness could silently regress.
      python3 - "$TMP/marked_move.docx" "$TMP/original.docx" <<'PY'
      import re, sys, xml.etree.ElementTree as ET, zipfile
      W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
      def dom(p): return ET.fromstring(zipfile.ZipFile(p).read("word/document.xml"))
      def norm(s): return re.sub(r"\s+", " ", s).strip()
      
      def naive_reject(root):
          """Knows only w:ins / w:del: emits every delText and every w:t outside w:ins.
          The moveTo run is not inside w:ins, so the moved paragraph comes back twice."""
          out, parents = [], {c: p for p in root.iter() for c in p}
          for el in root.iter():
              if el.tag not in (W + "t", W + "delText"):
                  continue
              anc, cur = set(), el
              while cur in parents:
                  cur = parents[cur]
                  anc.add(cur.tag)
              if el.tag == W + "delText" or (W + "ins") not in anc:
                  out.append(el.text or "")
          return norm("".join(out))
      
      def plain(root): return norm("".join(e.text or "" for e in root.iter(W + "t")))
      
      got, want = naive_reject(dom(sys.argv[1])), plain(dom(sys.argv[2]))
      assert got != want, "naive resolver passed — fixture does not discriminate"
      assert got.count("Bravo") == 2, f"expected the naive duplicate, got {got!r}"
      PY
      ck "naive ins/del-only resolver fails the same file (duplicate)" 0 "$?"
      
      # --- 2) an ordinary edit passes ---------------------------------------------------
      python3 "$V" --marked "$TMP/marked_edit.docx" --original "$TMP/original.docx" \
        --revised "$TMP/revised_edit.docx" --author "Submitting Author" --strict > /dev/null 2>&1
      ck "ordinary ins/del edit passes the round trip" 0 "$?"
      
      # --- 3) a dropped paragraph must FAIL ---------------------------------------------
      python3 "$V" --marked "$TMP/marked_dropped.docx" --original "$TMP/original.docx" \
        --revised "$TMP/revised_edit.docx" --strict > /dev/null 2>&1
      ck "dropped paragraph fails (--strict)" 1 "$?"
      
      OUT="$(python3 "$V" --marked "$TMP/marked_dropped.docx" --original "$TMP/original.docx" \
        --revised "$TMP/revised_edit.docx" 2>&1)"
      echo "$OUT" | grep -q MARKED_ACCEPT_MISMATCH
      ck "dropped paragraph reports MARKED_ACCEPT_MISMATCH" 0 "$?"
      
      ck "drift tolerated without --strict" 0 "$(
        python3 "$V" --marked "$TMP/marked_dropped.docx" --original "$TMP/original.docx" \
          --revised "$TMP/revised_edit.docx" > /dev/null 2>&1; echo $?)"
      
      # --- 4) mixed revision authors ----------------------------------------------------
      OUT="$(python3 "$V" --marked "$TMP/marked_author.docx" --original "$TMP/original.docx" \
        --revised "$TMP/revised_edit.docx" --author "Submitting Author" 2>&1)"
      echo "$OUT" | grep -q MARKED_AUTHOR_MIXED
      ck "revisions by a second author report MARKED_AUTHOR_MIXED" 0 "$?"
      
      # the same file is clean when no author is asserted (the round trip still holds)
      python3 "$V" --marked "$TMP/marked_author.docx" --original "$TMP/original.docx" \
        --revised "$TMP/revised_edit.docx" --strict > /dev/null 2>&1
      ck "author check does not fire when --author is omitted" 0 "$?"
      
      # --- 5) a clean copy is not a marked manuscript -------------------------------------
      OUT="$(python3 "$V" --marked "$TMP/marked_clean.docx" --original "$TMP/original.docx" \
        --revised "$TMP/revised_edit.docx" 2>&1)"
      echo "$OUT" | grep -q MARKED_NO_REVISIONS
      ck "clean copy reports MARKED_NO_REVISIONS" 0 "$?"
      
      # --- 6) a flattened table (identical text) ------------------------------------------
      OUT="$(python3 "$V" --marked "$TMP/marked_tbl.docx" --original "$TMP/original_tbl.docx" \
        --revised "$TMP/revised_tbl.docx" 2>&1)"
      echo "$OUT" | grep -q MARKED_TABLE_LOSS
      ck "flattened table reports MARKED_TABLE_LOSS" 0 "$?"
      echo "$OUT" | grep -q MARKED_ACCEPT_MISMATCH
      ck "table loss is isolated (text round trip still holds)" 1 "$?"
      
      # --- 7) a baseline that still carries tracked changes --------------------------------
      OUT="$(python3 "$V" --marked "$TMP/marked_edit.docx" --original "$TMP/original_dirty.docx" \
        --revised "$TMP/revised_edit.docx" 2>&1)"
      echo "$OUT" | grep -q MARKED_BASE_TRACKED
      ck "baseline with live tracked changes reports MARKED_BASE_TRACKED" 0 "$?"
      
      echo "----"
      echo "test_marked_manuscript: $pass passed, $fail failed"
      [ "$fail" -eq 0 ]
      
    • test_preflight_gate.sh 6.2 KB
      #!/usr/bin/env bash
      # Test scripts/preflight_gate.py — the A6 submission pre-flight orchestrator.
      # Builds synthetic, PII-free fixture projects in a temp dir and asserts the gate
      # halts on a deterministic blocker, passes a clean package, tolerates absent
      # inputs (skipped, not blocker), normalizes the inverted cover_letter exit code,
      # and honors --require / --skip. Stdlib-only; offline (no network).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/preflight_gate.py"
      PASS=0
      FAIL=0
      ok()  { echo "  PASS: $1"; PASS=$((PASS+1)); }
      bad() { echo "  FAIL: $1"; FAIL=$((FAIL+1)); }
      
      WORK="$(mktemp -d)"
      trap 'rm -rf "$WORK"' EXIT
      
      DIRTY="$WORK/dirty"
      CLEAN="$WORK/clean"
      mkdir -p "$DIRTY/manuscript" "$CLEAN/manuscript" "$CLEAN/submission/chest/manuscript"
      
      # dirty: an unresolved [@NEW:] placeholder + a TODO; bib does not define the marker
      cat > "$DIRTY/manuscript/manuscript.md" <<'EOF'
      # Introduction
      
      We follow prior work [@smith2020] and a competing-risk model [@NEW:competing-risk].
      
      ## Methods
      
      Analysis used R. TODO: cite the package.
      
      ## References
      EOF
      cat > "$DIRTY/refs.bib" <<'EOF'
      @article{smith2020, title={A real paper}, author={Smith, John}, journal={NEJM}, year={2020}, doi={10.1056/x}}
      EOF
      
      # clean: no markers, every cited key defined
      cat > "$CLEAN/manuscript/manuscript.md" <<'EOF'
      # Introduction
      
      We follow prior work [@smith2020] using a competing-risk model.
      
      ## Methods
      
      Analysis used R version 4.3.
      
      ## References
      EOF
      cat > "$CLEAN/refs.bib" <<'EOF'
      @article{smith2020, title={A real paper}, author={Smith, John}, journal={NEJM}, year={2020}, doi={10.1056/x}}
      EOF
      
      J() { python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d[sys.argv[2]] if sys.argv[2] in d else json.dumps(d))" "$@"; }
      STATUS() { python3 -c "import json,sys; d=json.load(open(sys.argv[1])); c=[x for x in d['checks'] if x['id']==sys.argv[2]][0]; print(c['status'])" "$@"; }
      
      # 1. dirty halts with exit 1
      python3 "$SCRIPT" --project-root "$DIRTY" --quiet
      [ $? -eq 1 ] && ok "dirty package halts (exit 1)" || bad "dirty package should halt"
      
      # 2. report: halt true, placeholders blocker, absent inputs skipped (tolerance)
      RPT="$DIRTY/qc/preflight_gate_report.json"
      [ "$(J "$RPT" halt)" = "True" ] && ok "report halt=true" || bad "report halt should be true"
      [ "$(STATUS "$RPT" placeholders)" = "blocker" ] && ok "placeholders is a blocker" || bad "placeholders should block"
      [ "$(STATUS "$RPT" cover_letter_drift)" = "skipped" ] && ok "cover_letter_drift skipped (no cover letter)" || bad "cover_letter_drift should skip"
      [ "$(STATUS "$RPT" copy_divergence)" = "skipped" ] && ok "copy_divergence skipped (no copies)" || bad "copy_divergence should skip"
      [ "$(STATUS "$RPT" sync_drift)" = "skipped" ] && ok "sync_drift skipped (no journal)" || bad "sync_drift should skip"
      
      # 3. references is a P1 warn (offline unverified), never a blocker by itself
      [ "$(STATUS "$RPT" references)" = "warn" ] && ok "references offline -> warn (non-halting)" || bad "references should warn offline"
      
      # 4. clean package passes (exit 0)
      python3 "$SCRIPT" --project-root "$CLEAN" --quiet
      [ $? -eq 0 ] && ok "clean package passes (exit 0)" || bad "clean package should pass"
      [ "$(J "$CLEAN/qc/preflight_gate_report.json" submission_safe)" = "True" ] && ok "clean report submission_safe=true" || bad "clean should be submission_safe"
      python3 - "$CLEAN/qc/preflight_gate_report.json" <<'PY'
      import json, sys
      r = json.load(open(sys.argv[1]))
      assert r['readiness'] == 'not_assessed'
      assert r['submission_safe_scope'] == 'configured_checks_only'
      assert r['coverage']['status'] == 'incomplete'
      assert r['coverage']['skipped']
      assert r['coverage']['visual_review'] == 'not_assessed'
      PY
      [ $? -eq 0 ] && ok "no blockers does not hide skipped/visual checks" || bad "coverage must remain explicit"
      
      # 5. inverted cover-letter exit code normalized to warn (default), blocker under --require
      cat > "$CLEAN/submission/chest/cover_letter.md" <<'EOF'
      Dear Editor, the manuscript is approximately 8000 words with 99 references. Sincerely,
      EOF
      python3 "$SCRIPT" --project-root "$CLEAN" --journal chest --quiet
      [ $? -eq 0 ] && ok "cover-letter drift warns (P1, non-halting by default)" || bad "cover drift should not halt by default"
      RC="$CLEAN/qc/preflight_gate_report.json"
      python3 -c "
      import json,sys
      d=json.load(open('$RC')); c=[x for x in d['checks'] if x['id']=='cover_letter_drift'][0]
      sys.exit(0 if c['exit_code']==2 and c['status']=='warn' else 1)
      " && ok "cover_letter inverted exit 2 normalized to warn" || bad "cover_letter exit normalization wrong"
      
      python3 "$SCRIPT" --project-root "$CLEAN" --journal chest --require cover_letter_drift --quiet
      [ $? -eq 1 ] && ok "--require cover_letter_drift promotes to blocker (halt)" || bad "--require should halt on cover drift"
      
      # 6. --strict promotes all P1 to halting (cover drift now blocks)
      python3 "$SCRIPT" --project-root "$CLEAN" --journal chest --strict --quiet
      [ $? -eq 1 ] && ok "--strict promotes P1 cover drift to halt" || bad "--strict should halt on cover drift"
      
      # 7. --skip removes a check
      python3 "$SCRIPT" --project-root "$CLEAN" --journal chest --strict --skip cover_letter_drift --quiet
      [ $? -eq 0 ] && ok "--skip cover_letter_drift drops it (clean pass under --strict)" || bad "--skip should drop the check"
      
      # 8. --require on a check that cannot run -> gate error (exit 2)
      python3 "$SCRIPT" --project-root "$DIRTY" --require sync_drift --quiet
      [ $? -eq 2 ] && ok "--require sync_drift (no journal) -> gate error exit 2" || bad "required-but-unrunnable should exit 2"
      [ "$(J "$DIRTY/qc/preflight_gate_report.json" submission_safe)" = "False" ] && ok "required check error cannot be safe" || bad "gate error marked safe"
      
      # Canonical exists, but the submission is missing: child exit 2 is still an
      # error under --require, rather than silently becoming a skipped clean pass.
      python3 "$SCRIPT" --project-root "$CLEAN" --journal chest --require sync_drift --quiet
      [ $? -eq 2 ] && ok "required child exit 2 cannot pass" || bad "required child skip should be an error"
      
      # 9. unknown check id -> exit 2
      python3 "$SCRIPT" --project-root "$CLEAN" --require nonsense --quiet 2>/dev/null
      [ $? -eq 2 ] && ok "unknown --require id -> exit 2" || bad "unknown id should exit 2"
      
      echo ""
      echo "test_preflight_gate: $PASS passed, $FAIL failed"
      [ "$FAIL" -eq 0 ]
      
    • test_scope_drift.sh 4 KB
      #!/usr/bin/env bash
      # Regression tests for sync-submission scope_drift_check.py.
      #
      # Synthetic fixtures only. No network needed.
      
      set -uo pipefail
      
      REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
      SCRIPT="$REPO_ROOT/skills/sync-submission/scripts/scope_drift_check.py"
      TMP="$(mktemp -d -t scope_drift.XXXXXX)"
      trap 'rm -rf "$TMP"' EXIT
      
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      command -v python3 >/dev/null 2>&1 || { echo "ENV-ERR: python3 missing" >&2; exit 2; }
      
      fail=0
      ran=0
      assert_exit() {
          local label="$1" expected="$2" actual="$3"
          ran=$((ran + 1))
          if [[ "$expected" == "$actual" ]]; then
              printf '  PASS  %-50s exit=%s\n' "$label" "$actual"
          else
              printf '  FAIL  %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual"
              fail=$((fail + 1))
          fi
      }
      
      # --------------------------------------------------------------------------
      # Case 1: well-formed manuscript. AUC appears in Methods, Results, Limitations.
      # --------------------------------------------------------------------------
      MS1="$TMP/case1.md"
      cat > "$MS1" <<'EOF'
      ## **METHODS**
      We computed the area under the curve (AUC) on the held-out test set. The
      primary metric is AUC = 0.820.
      
      ## **RESULTS**
      The pooled AUC was 0.820 (95% CI 0.78 to 0.86).
      
      ## **DISCUSSION**
      
      ### Limitations
      A sensitivity analysis on the primary AUC of 0.820 was not pre-specified.
      EOF
      python3 "$SCRIPT" --manuscript "$MS1" --out "$TMP/case1.json" --quiet
      assert_exit "case 1: consistent anchor (0.820 in all)" 0 $?
      
      # --------------------------------------------------------------------------
      # Case 2: AUC 0.869 appears only in Limitations. Should FAIL.
      # --------------------------------------------------------------------------
      MS2="$TMP/case2.md"
      cat > "$MS2" <<'EOF'
      ## **METHODS**
      We computed AUC on the held-out test set.
      
      ## **RESULTS**
      The pooled AUC was 0.820 (95% CI 0.78 to 0.86).
      
      ## **DISCUSSION**
      
      ### Limitations
      A leave-pair-out sensitivity analysis produced an envelope of AUC values:
      primary pool of 0.869 (95% CI 0.81 to 0.92), with neighborhood values
      0.851, 0.872, 0.881, and 0.890.
      EOF
      python3 "$SCRIPT" --manuscript "$MS2" --out "$TMP/case2.json" --quiet
      assert_exit "case 2: 0.869 limits-only anchor" 1 $?
      python3 - "$TMP/case2.json" <<'PY' || fail=$((fail + 1))
      import json, sys
      with open(sys.argv[1]) as fh: rep = json.load(fh)
      anchors = [a["anchor"] for a in rep["limitations_only_anchors"]]
      assert "0.869" in anchors, anchors
      PY
      
      # --------------------------------------------------------------------------
      # Case 3: PROSPERO commits to Freeman-Tukey but Methods does not.
      # --------------------------------------------------------------------------
      MS3="$TMP/case3.md"
      PR3="$TMP/case3_prospero.md"
      cat > "$MS3" <<'EOF'
      ## **METHODS**
      Pooled estimates were computed in Python with descriptive statistics only.
      
      ## **RESULTS**
      Pooled value 0.50.
      EOF
      cat > "$PR3" <<'EOF'
      # PROSPERO Record
      
      Synthesis: Freeman-Tukey transformation followed by random-effects pooling
      (DerSimonian-Laird estimator).
      EOF
      python3 "$SCRIPT" --manuscript "$MS3" --prospero "$PR3" --out "$TMP/case3.json" --quiet
      assert_exit "case 3: PROSPERO Freeman-Tukey, methods absent" 1 $?
      python3 - "$TMP/case3.json" <<'PY' || fail=$((fail + 1))
      import json, sys
      with open(sys.argv[1]) as fh: rep = json.load(fh)
      methods_listed = [s["method"] for s in rep["synthesis_method_drift"]]
      assert "Freeman-Tukey" in methods_listed, methods_listed
      PY
      
      # --------------------------------------------------------------------------
      # Case 4: no prospero supplied + clean manuscript = PASS.
      # --------------------------------------------------------------------------
      MS4="$TMP/case4.md"
      cat > "$MS4" <<'EOF'
      ## **METHODS**
      AUC = 0.700 on the validation set.
      
      ## **RESULTS**
      External AUC = 0.700 (95% CI 0.65 to 0.75).
      
      ## **DISCUSSION**
      The external AUC of 0.700 demonstrates ...
      EOF
      python3 "$SCRIPT" --manuscript "$MS4" --out "$TMP/case4.json" --quiet
      assert_exit "case 4: clean, no prospero" 0 $?
      
      echo ""
      echo "ran=$ran fail=$fail"
      [[ $fail -eq 0 ]]
      
    • test_submission_bundle.py 16 KB
      #!/usr/bin/env python3
      """Synthetic submission-bundle controls; no private or third-party fixture files."""
      import contextlib
      import io
      import json
      import os
      from pathlib import Path
      import shutil
      import subprocess
      import sys
      import tempfile
      import unittest
      from unittest.mock import patch
      import zipfile
      
      SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
      sys.path.insert(0, str(SCRIPTS))
      import sync_submission as sync
      
      
      class BundleTests(unittest.TestCase):
          def setUp(self):
              self.temp = tempfile.TemporaryDirectory()
              self.addCleanup(self.temp.cleanup)
              self.root = Path(self.temp.name).resolve()
              self.canonical = self.put("manuscript/manuscript.md", "# Synthetic study\n\nSample size: 30.\n")
              self.put("build/supplement.md", "# Synthetic supplement\n\nSample size: 30.\n")
              self.put("build/final.pdf", b"%PDF-1.4\nsynthetic byte-copy fixture; not a render\n")
              self.put("artifact_manifest.json", json.dumps({"schema_version": 1, "artifacts": ["keep"],
                                                            "submissions": {"other": {"status": "submitted"}}}))
              self.spec = {"schema_version": 1, "artifacts": [
                  {"id": "supplement", "role": "supplement", "source": "build/supplement.md",
                   "target": "supplement/supplement.md", "derived_from": [self.dep(self.canonical)]},
                  {"id": "pdf", "role": "manuscript_pdf", "source": "build/final.pdf",
                   "target": "manuscript/final.pdf", "transformation": {"kind": "rendered", "command": ["record-only"]},
                   "derived_from": [self.dep(self.canonical)], "rights": {"status": "original"}}]}
              self.directory = self.root / "submission/example"
      
          def put(self, relative, data):
              path = self.root / relative
              path.parent.mkdir(parents=True, exist_ok=True)
              path.write_bytes(data if isinstance(data, bytes) else data.encode())
              return path
      
          def dep(self, path):
              return {"path": path.relative_to(self.root).as_posix(), "sha256": sync.sha256_file(path)}
      
          def build(self, spec=None):
              with contextlib.redirect_stdout(io.StringIO()):
                  return sync.build(self.root, "example", self.canonical, self.spec if spec is None else spec)
      
          def audit(self):
              with contextlib.redirect_stdout(io.StringIO()):
                  code = sync.audit(self.root, "example", self.canonical)
              return code, sync.load_json(self.root / "qc/submission_sync_example.json")
      
          def snapshot(self):
              return {p.relative_to(self.root).as_posix(): (p.read_bytes(), p.stat().st_mtime_ns)
                      for p in self.root.rglob("*") if p.is_file()}
      
          def test_copy_preserves_bytes_mtime_sources_and_manifest_fields(self):
              before = self.snapshot()
              self.assertEqual(self.build(), 0)
              for path in ("manuscript/manuscript.md", "build/supplement.md", "build/final.pdf"):
                  self.assertEqual(self.snapshot()[path], before[path])
              meta = sync.load_json(self.directory / ".journal_meta.json")
              for row in meta["artifacts"]:
                  self.assertEqual((self.root / row["source"]).read_bytes(), (self.directory / row["target"]).read_bytes())
              manifest = sync.load_json(self.root / "artifact_manifest.json")
              self.assertEqual(manifest["artifacts"], ["keep"])
              self.assertEqual(manifest["submissions"]["other"], {"status": "submitted"})
              self.assertEqual(manifest["submissions"]["example"]["artifacts"], meta["artifacts"])
      
          def test_rerun_is_current_but_not_reviewed(self):
              self.build()
              binding = sync.bundle_binding(self.root, "example")
              self.build()
              self.assertEqual(binding, sync.bundle_binding(self.root, "example"))
              code, report = self.audit()
              self.assertEqual(code, 0)
              self.assertEqual(report["readiness"], "not_assessed")
              self.assertEqual(report["verification"]["status"], "not_run")
              self.assertEqual(report["artifacts"][2]["visual_review"], "not_assessed")
      
          def test_stale_supplement_not_reblessed_after_main_changes(self):
              self.build()
              self.canonical.write_text("# Synthetic study\n\nSample size: 40.\n")
              before = self.snapshot()
              with self.assertRaisesRegex(ValueError, "dependency"):
                  self.build()
              self.assertEqual(before, self.snapshot())
              code, report = self.audit()
              self.assertEqual(code, 1)
              self.assertIn("render_input_changed_or_missing", report["artifacts"][1]["drift"])
              self.assertEqual(self.audit()[0], 1)
      
          def test_edited_output_is_not_overwritten_or_frozen(self):
              self.build()
              edited = self.put("submission/example/manuscript/final.pdf", b"manually edited")
              before = edited.read_bytes()
              with self.assertRaisesRegex(ValueError, "edited"):
                  self.build()
              with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
                  self.assertEqual(sync.freeze(self.root, "example", self.canonical, "submitted"), 1)
              self.assertEqual(edited.read_bytes(), before)
      
          def test_missing_output_and_source_are_drift(self):
              self.build()
              (self.directory / "supplement/supplement.md").unlink()
              (self.root / "build/final.pdf").unlink()
              code, report = self.audit()
              self.assertEqual(code, 1)
              self.assertIn("output_changed_or_missing", report["artifacts"][1]["drift"])
              self.assertIn("source_changed_or_missing", report["artifacts"][2]["drift"])
      
          def test_missing_submission_cannot_freeze(self):
              with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
                  self.assertEqual(sync.freeze(self.root, "example", self.canonical, "submitted"), 2)
              self.assertFalse(self.directory.exists())
      
          def test_frozen_bundle_immutable_and_freeze_idempotent(self):
              self.build()
              with contextlib.redirect_stdout(io.StringIO()):
                  self.assertEqual(sync.freeze(self.root, "example", self.canonical, "submitted"), 0)
                  original = (self.directory / ".journal_meta.json").read_bytes()
                  self.assertEqual(sync.freeze(self.root, "example", self.canonical, "submitted"), 0)
              self.assertEqual((self.directory / ".journal_meta.json").read_bytes(), original)
              with self.assertRaisesRegex(ValueError, "immutable"):
                  self.build()
      
          def test_unknown_rights_preserved_no_legal_approval(self):
              self.build()
              row = self.audit()[1]["artifacts"][1]
              self.assertEqual(row["rights"]["status"], "unknown")
              self.assertEqual(row["content_fidelity"], "not_assessed")
      
          def test_incomplete_documented_rights_rejected(self):
              self.spec["artifacts"][0]["rights"] = {"status": "documented", "license_or_permission": "CC BY 4.0"}
              with self.assertRaisesRegex(ValueError, "Documented rights"):
                  self.build()
              self.assertFalse(self.directory.exists())
      
          def test_rights_attribution_and_modification_notice_roundtrip(self):
              rights = {"status": "documented", "source": "synthetic rights example",
                        "license_or_permission": "synthetic permission record", "attribution": "Synthetic creator",
                        "changes": "Example wording adapted"}
              self.spec["artifacts"][0]["rights"] = rights
              self.build()
              self.assertEqual(self.audit()[1]["artifacts"][1]["rights"], rights)
      
          def test_hidden_and_unregistered_files_remain_visible(self):
              self.build()
              self.put("submission/example/.hidden.txt", "synthetic")
              code, report = self.audit()
              self.assertEqual(code, 1)
              self.assertEqual(report["unregistered_files"], [".hidden.txt"])
              with self.assertRaisesRegex(ValueError, "Unregistered"):
                  self.build()
      
          def test_paths_reject_traversal_symlinks_and_hidden_inputs(self):
              for value in ("../outside", "/tmp/absolute", ".hidden", "nested/../outside", "C:/absolute", "x\\y"):
                  with self.subTest(value=value), self.assertRaises(ValueError):
                      sync.safe_path(self.root, value)
              (self.root / "alias").symlink_to(self.root / "build", target_is_directory=True)
              with self.assertRaises(ValueError):
                  sync.safe_path(self.root, "alias/final.pdf")
      
          def test_bad_journal_is_rejected_before_writes(self):
              for journal in ("../bad", "nested/path", ".hidden", ""):
                  with self.subTest(journal=journal), self.assertRaises(ValueError):
                      sync.journal_root(self.root, journal)
      
          def test_case_duplicate_ancestor_and_reserved_target_rejected(self):
              for target in ("manuscript/MANUSCRIPT.md", "MANUSCRIPT", ".journal_meta.json"):
                  self.spec["artifacts"][0]["target"] = target
                  with self.subTest(target=target), self.assertRaises(ValueError):
                      self.build()
      
          def test_hardlink_source_output_alias_rejected(self):
              self.directory.mkdir(parents=True)
              dest = self.directory / "manuscript/manuscript.md"
              dest.parent.mkdir()
              os.link(self.canonical, dest)
              before = self.canonical.read_bytes()
              with self.assertRaisesRegex(ValueError, "alias"):
                  self.build()
              self.assertEqual(self.canonical.read_bytes(), before)
      
          def test_missing_declared_input_leaves_prior_package_unchanged(self):
              self.build()
              self.spec["artifacts"][0]["source"] = "build/missing.md"
              before = self.snapshot()
              with self.assertRaises(ValueError):
                  self.build()
              self.assertEqual(before, self.snapshot())
      
          def test_removed_registered_artifact_rejected(self):
              self.build()
              self.spec["artifacts"].pop()
              with self.assertRaisesRegex(ValueError, "remove"):
                  self.build()
      
          def test_malformed_manifest_not_silently_replaced(self):
              self.put("artifact_manifest.json", "{broken")
              before = self.snapshot()
              with self.assertRaises(ValueError):
                  self.build()
              self.assertEqual(before, self.snapshot())
      
          def test_manifest_failure_rolls_back_package(self):
              self.build()
              before = {p: b for p, (b, _) in self.snapshot().items()}
              original = sync.write_json
              def failing(path, payload):
                  if path == self.root / "artifact_manifest.json":
                      raise OSError("synthetic disk failure")
                  return original(path, payload)
              with patch.object(sync, "write_json", side_effect=failing), self.assertRaises(OSError):
                  self.build()
              self.assertEqual(before, {p: b for p, (b, _) in self.snapshot().items()})
      
          def test_lock_does_not_remove_another_writers_lock(self):
              with sync.mutation_lock(self.root):
                  with self.assertRaises(ValueError):
                      with sync.mutation_lock(self.root):
                          pass
                  self.assertTrue((self.root / ".submission-sync.lock").exists())
              self.assertFalse((self.root / ".submission-sync.lock").exists())
      
          def test_rendered_inputs_must_be_pinned(self):
              self.spec["artifacts"][1]["derived_from"] = []
              with self.assertRaisesRegex(ValueError, "pinned"):
                  self.build()
      
          def test_source_change_during_copy_keeps_prior_bundle(self):
              self.build()
              before = (self.directory / "manuscript/manuscript.md").read_bytes()
              original = sync.shutil.copy2
              def changing(src, dst):
                  result = original(src, dst)
                  if src == self.canonical:
                      self.canonical.write_text("Changed during copy")
                  return result
              with patch.object(sync.shutil, "copy2", side_effect=changing), self.assertRaises(ValueError):
                  self.build()
              self.assertEqual((self.directory / "manuscript/manuscript.md").read_bytes(), before)
      
          def test_copied_hidden_docx_metadata_reaches_existing_check(self):
              from docx import Document
              source = self.root / "build/final.docx"
              document = Document()
              document.add_paragraph("Synthetic metadata control")
              document.save(source)
              with zipfile.ZipFile(source, "a") as archive:
                  archive.writestr("docProps/custom.xml", '<Properties><property name="source">'
                                   '<value>/Users/testuser/styles/journal.csl</value></property></Properties>')
              self.spec["artifacts"][1].update({"source": "build/final.docx", "target": "manuscript/final.docx"})
              self.build()
              self.assertEqual(source.read_bytes(), (self.directory / "manuscript/final.docx").read_bytes())
              report = self.root / "metadata-check.json"
              proc = subprocess.run([sys.executable, str(SCRIPTS / "check_asset_anonymization.py"),
                                     "--dir", str(self.directory), "--out", str(report), "--quiet"], capture_output=True)
              self.assertEqual(proc.returncode, 1, proc.stderr)
              findings = json.loads(report.read_text())["findings"]
              self.assertTrue(any(f["type"] == "docx_embedded_abs_path" for f in findings))
      
          def test_preflight_binding_stays_stale_on_repeated_audit(self):
              self.build()
              self.put("qc/preflight_gate_report.json", json.dumps({"journal": "example",
                  "bundle_binding": sync.bundle_binding(self.root, "example"),
                  "bundle_unchanged_during_checks": True, "checks": [{"id": "synthetic", "status": "skipped"}]}))
              self.assertEqual(self.audit()[1]["verification"]["status"], "package_bytes_current")
              self.put("submission/example/manuscript/final.pdf", b"changed")
              for _ in range(2):
                  self.assertEqual(self.audit()[1]["verification"]["status"], "stale")
      
          def test_legacy_preflight_is_unbound_not_verified(self):
              self.build()
              self.put("qc/preflight_gate_report.json", json.dumps({"journal": "example", "submission_safe": True}))
              self.assertEqual(self.audit()[1]["verification"]["status"], "unbound")
      
          def test_yaml_comments_and_nested_paths_do_not_retarget_canonical(self):
              self.put("project.yaml", 'canonical_manuscript: "manuscript/manuscript.md" # source\nother:\n  canonical_manuscript: ignored.md\n')
              self.assertEqual(sync.resolve_canonical(self.root, None), self.canonical)
      
          def test_explicit_canonical_must_match_recorded_path(self):
              self.build()
              alternate = self.put("alternate.md", self.canonical.read_bytes())
              with contextlib.redirect_stdout(io.StringIO()):
                  self.assertEqual(sync.audit(self.root, "example", alternate), 1)
      
          @unittest.skipUnless(shutil.which("pandoc"), "pandoc required for real DOCX control")
          def test_actual_docx_copy_and_manual_word_edit(self):
              from docx import Document
              docx = self.root / "build/final.docx"
              subprocess.run(["pandoc", str(self.canonical), "-o", str(docx)], check=True)
              self.spec["artifacts"][1].update({"source": "build/final.docx", "target": "manuscript/final.docx"})
              self.build()
              output = self.directory / "manuscript/final.docx"
              self.assertEqual(docx.read_bytes(), output.read_bytes())
              document = Document(output)
              document.add_paragraph("Synthetic manual edit")
              document.save(output)
              self.assertEqual(self.audit()[0], 1)
      
          def test_cli_build_audit_freeze_contract(self):
              self.put("bundle.json", json.dumps(self.spec))
              command = [sys.executable, str(SCRIPTS / "sync_submission.py"), "build", "--project-root", str(self.root),
                         "--journal", "example", "--bundle-spec", "bundle.json"]
              proc = subprocess.run(command, capture_output=True, text=True)
              self.assertEqual(proc.returncode, 0, proc.stderr)
              command[2] = "audit"
              proc = subprocess.run(command[:-2], capture_output=True, text=True)
              self.assertEqual(proc.returncode, 0, proc.stderr)
      
          def test_missing_spec_fails_without_writing_package(self):
              command = [sys.executable, str(SCRIPTS / "sync_submission.py"), "build", "--project-root", str(self.root),
                         "--journal", "example", "--bundle-spec", "missing.json"]
              self.assertEqual(subprocess.run(command, capture_output=True).returncode, 2)
              self.assertFalse(self.directory.exists())
      
      
      if __name__ == "__main__":
          unittest.main()
      
    • test_vN_docx_assertion.sh 1.7 KB
      #!/usr/bin/env bash
      # Regression tests for verify_package_integrity.py --assert-vN-docx-changed.
      
      set -uo pipefail
      
      REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
      SCRIPT="$REPO_ROOT/scripts/verify_package_integrity.py"
      TMP="$(mktemp -d -t vNdocx.XXXXXX)"
      trap 'rm -rf "$TMP"' EXIT
      
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      
      fail=0
      ran=0
      assert_exit() {
          local label="$1" expected="$2" actual="$3"
          ran=$((ran + 1))
          if [[ "$expected" == "$actual" ]]; then
              printf '  PASS  %-50s exit=%s\n' "$label" "$actual"
          else
              printf '  FAIL  %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual"
              fail=$((fail + 1))
          fi
      }
      
      # Case 1: identical bytes => FAIL
      printf 'identical bytes\n' > "$TMP/vN.docx"
      cp "$TMP/vN.docx" "$TMP/vNplus1.docx"
      python3 "$SCRIPT" --assert-vN-docx-changed \
          --vN-docx "$TMP/vN.docx" --new-docx "$TMP/vNplus1.docx" >/dev/null 2>&1
      assert_exit "case 1: identical bytes (FAIL)" 1 $?
      
      # Case 2: different bytes => PASS
      printf 'different bytes for v_(N+1)\n' > "$TMP/vNplus1.docx"
      python3 "$SCRIPT" --assert-vN-docx-changed \
          --vN-docx "$TMP/vN.docx" --new-docx "$TMP/vNplus1.docx" >/dev/null 2>&1
      assert_exit "case 2: different bytes (PASS)" 0 $?
      
      # Case 3: missing v_N => exit 2
      python3 "$SCRIPT" --assert-vN-docx-changed \
          --vN-docx "$TMP/nonexistent.docx" --new-docx "$TMP/vNplus1.docx" >/dev/null 2>&1
      assert_exit "case 3: missing v_N (exit 2)" 2 $?
      
      # Case 4: missing --new-docx arg => exit 2
      python3 "$SCRIPT" --assert-vN-docx-changed \
          --vN-docx "$TMP/vN.docx" >/dev/null 2>&1
      assert_exit "case 4: missing --new-docx (exit 2)" 2 $?
      
      echo ""
      echo "ran=$ran fail=$fail"
      [[ $fail -eq 0 ]]
      
    • test_wordcount_cap.sh 2.8 KB
      #!/usr/bin/env bash
      # Regression test for the body-word-count vs journal-cap gate (the revision-
      # inflation trap). Synthetic, PII-free fixtures. Limits are computed from the
      # fixture's own measured count so the test does not hardcode a fragile number.
      # Stdlib-only (python3).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_wordcount_cap.py"
      FIX="$HERE/fixtures/wc_body.md"
      PROFILE="$HERE/fixtures/wc_journal_profile.md"
      OUT="$(mktemp -t wc_XXXX).json"
      trap 'rm -f "$OUT"' EXIT
      
      fail=0
      check() { local label="$1"; shift
          if "$@" >/dev/null 2>&1; then printf '  PASS  %s\n' "$label"
          else printf '  FAIL  %s\n' "$label"; fail=$((fail+1)); fi
      }
      jget() { python3 -c "import json,sys; print(json.load(open('$OUT'))['$1'])"; }
      verdict_is() { python3 -c "import json,sys; sys.exit(0 if json.load(open('$OUT'))['verdict']=='$1' else 1)"; }
      limit_is()   { python3 -c "import json,sys; sys.exit(0 if json.load(open('$OUT'))['limit']==$1 else 1)"; }
      
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      
      # Measure the fixture once (report-only) to get the effective rendered estimate.
      python3 "$SCRIPT" --manuscript "$FIX" --limit 100000 --out "$OUT" --quiet >/dev/null 2>&1
      E=$(jget rendered_words_est)
      B=$(jget body_words)
      C=$(jget n_inline_citations)
      check "body_words > 0"                    test "$B" -gt 0
      check "abstract/refs excluded (body < 90)" test "$B" -lt 90
      check "2 inline citations counted"        test "$C" -eq 2
      check "rendered est > body (citations expand)" test "$E" -gt "$B"
      
      # (1) limit just below the estimate -> OVER cap, exit 1 under --strict
      python3 "$SCRIPT" --manuscript "$FIX" --limit "$((E-1))" --out "$OUT" --strict --quiet >/dev/null 2>&1
      check "exit 1 over cap" test "$?" -eq 1
      check "verdict WORDCOUNT_OVER_CAP" verdict_is WORDCOUNT_OVER_CAP
      
      # (2) limit == estimate -> within cap but above 0.95x -> NEAR (Minor), exit 0
      python3 "$SCRIPT" --manuscript "$FIX" --limit "$E" --out "$OUT" --strict --quiet >/dev/null 2>&1
      check "exit 0 at near cap (Minor)" test "$?" -eq 0
      check "verdict WORDCOUNT_NEAR_CAP" verdict_is WORDCOUNT_NEAR_CAP
      
      # (3) generous limit -> OK
      python3 "$SCRIPT" --manuscript "$FIX" --limit "$((E*100))" --out "$OUT" --quiet >/dev/null 2>&1
      check "verdict OK under generous limit" verdict_is OK
      
      # (4) cap parsed from a journal profile (Original Article = 4,000 words)
      python3 "$SCRIPT" --manuscript "$FIX" --journal-profile "$PROFILE" \
          --article-type "Original Article" --out "$OUT" --quiet >/dev/null 2>&1
      check "cap parsed from profile == 4000" limit_is 4000
      
      # (5) neither --limit nor --journal-profile -> usage error (exit 2)
      python3 "$SCRIPT" --manuscript "$FIX" --quiet >/dev/null 2>&1
      check "exit 2 when no cap source given" test "$?" -eq 2
      
      echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
    • test_wordcount_heading_forms.sh 3.6 KB
      #!/usr/bin/env bash
      # Regression test: the same manuscript must measure the same length in either heading syntax.
      #
      # Markdown has two heading forms and pandoc accepts both. `check_wordcount_cap` recognised only ATX,
      # and only to depth 3. So under setext —
      #
      #     References
      #     ==========
      #
      # — the heading was never seen, `in_skip` never turned on, and the ENTIRE References section was
      # counted as body prose. Byte-identical prose measured 480 words as ATX and 1,002 as setext. This
      # gate blocks a submission against a journal's word cap, so the syntax an author happened to use
      # decided whether their paper was over the limit.
      #
      # `#### References` had the same problem for a different reason: `#{1,3}` stops at three.
      set -u
      
      REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
      W="$REPO_ROOT/skills/sync-submission/scripts/check_wordcount_cap.py"
      WORK="$(mktemp -d -t wordcount_heading_test.XXXXXX)"
      trap 'rm -rf "$WORK"' EXIT
      
      pass=0
      fail=0
      ck() {
        local label="$1" expected="$2" actual="$3"
        if [ "$expected" = "$actual" ]; then
          printf '  PASS  %-50s %s\n' "$label" "$actual"
          pass=$((pass + 1))
        else
          printf '  FAIL  %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual"
          fail=$((fail + 1))
        fi
      }
      
      words_of() {  # words_of <file> -> body word count as a bare integer
        python3 "$W" --manuscript "$WORK/$1" --limit 100000 2>/dev/null \
          | sed -n 's/.*body words (md)  *: *//p' | tr -d ', '
      }
      
      python3 - "$WORK" <<'PY'
      import pathlib, sys
      w = pathlib.Path(sys.argv[1])
      body = "The intervention reduced mortality in the enrolled cohort. " * 60
      refs = "Smith J, Doe A. A representative reference title with several words. Journal. 2023. " * 40
      fm = "---\ntitle: Example\n---\n\n"
      
      (w / "atx.md").write_text(f"{fm}## Introduction\n\n{body}\n\n## References\n\n{refs}\n")
      (w / "setext.md").write_text(
          f"{fm}Introduction\n============\n\n{body}\n\nReferences\n==========\n\n{refs}\n")
      (w / "setext_dash.md").write_text(
          f"{fm}Introduction\n------------\n\n{body}\n\nReferences\n----------\n\n{refs}\n")
      (w / "deep.md").write_text(f"{fm}## Introduction\n\n{body}\n\n#### References\n\n{refs}\n")
      # A horizontal rule is NOT a setext underline: it follows a blank line.
      (w / "hrule.md").write_text(f"{fm}## Introduction\n\n{body}\n\n---\n\n{body}\n")
      PY
      
      atx="$(words_of atx.md)"
      
      echo "==== the same prose measures the same length in either syntax ===="
      ck "ATX baseline is a real number"        yes "$([ "${atx:-0}" -gt 100 ] && echo yes || echo no)"
      ck "setext '=' equals ATX"                "$atx" "$(words_of setext.md)"
      ck "setext '-' equals ATX"                "$atx" "$(words_of setext_dash.md)"
      ck "#### References is skipped too"       "$atx" "$(words_of deep.md)"
      
      echo "==== NEGATIVE CONTROLS ===="
      # A horizontal rule after a blank line must not turn the paragraph above it into a heading; both
      # paragraphs stay in the body, so the count is twice the Introduction.
      hr="$(words_of hrule.md)"
      ck "horizontal rule is not a heading"     yes \
         "$([ "$hr" -gt "$atx" ] && echo yes || echo no)"
      # The References text must genuinely be excluded, not merely equal by coincidence: a manuscript with
      # no References section at all must match too.
      python3 - "$WORK" <<'PY'
      import pathlib, sys
      w = pathlib.Path(sys.argv[1])
      body = "The intervention reduced mortality in the enrolled cohort. " * 60
      (w / "norefs.md").write_text(f"---\ntitle: Example\n---\n\n## Introduction\n\n{body}\n")
      PY
      ck "no-References manuscript matches"     "$atx" "$(words_of norefs.md)"
      
      echo
      echo "  passed=$pass failed=$fail"
      [ "$fail" -eq 0 ] || exit 1
      echo "OK: the heading syntax an author chose no longer decides whether they are over the cap."
      
  • SKILL.md 41 KB
    ---
    name: sync-submission
    description: Audit SSOT-to-submission drift and create journal submission manifests from canonical manuscript artifacts.
    triggers: sync submission, build submission, submission drift, SSOT sync, journal package, retarget journal, freeze submission
    tools: Read, Write, Edit, Bash, Grep, Glob
    model: inherit
    ---
    
    # Sync Submission
    
    You help keep the canonical manuscript and journal-specific submission packages
    from drifting apart. The skill treats `submission/{journal}/` as derived output
    and records whether it is current, stale, or frozen.
    
    ## When to Use
    
    - Before submitting a journal package.
    - After a journal portal or Word editor changed a submission manuscript.
    - After rejection, before retargeting to another journal.
    - Before `/orchestrate --e2e` marks a project as submission-ready.
    
    ## Inputs
    
    1. Project root containing `project.yaml`, or a direct canonical manuscript path.
    2. Journal short name, e.g. `chest`, `ryai`, `academic_radiology`.
    3. Optional mode:
       - `audit`: compare existing submission against canonical source.
       - `build`: copy canonical source and optional declared final artifacts, preserving file bytes, and write metadata.
       - `freeze`: freeze the chosen byte snapshot with its available check context (not submission approval).
    
    ## Deterministic Script
    
    ```bash
    python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" audit --project-root . --journal chest
    python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" build --project-root . --journal chest
    python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" freeze --project-root . --journal chest --status submitted
    ```
    
    For double-blind journals, sweep author identifiers across all upload artifacts:
    
    ```bash
    python "${CLAUDE_SKILL_DIR}/scripts/blind_sweep.py" \
      --registry _shared/authors/author_registry.yaml \
      --files submission/{journal}/supplementary/*.md submission/{journal}/cover_letter.md \
      --backup-dir .cache/blind_sweep_backup
    ```
    
    The registry is a project-local YAML mapping author identifiers (full names, native scripts, initials with/without periods, email, ORCID) to role labels (e.g., "Reviewer 1"). See `scripts/author_registry_example.yaml` for schema. Never commit a populated registry to a public repository — keep it next to the manuscript.
    
    ## Output Contract
    
    | Artifact | Path | Purpose |
    |---|---|---|
    | Submission metadata | `submission/{journal}/.journal_meta.json` | Source hash, status, canonical path |
    | Sync audit | `qc/submission_sync_{journal}.json` | Drift result consumed by orchestrator |
    | Manifest update | `artifact_manifest.json` | Submission package registry |
    | Pre-flight gate | `qc/preflight_gate_report.json` | Aggregated halt-on-failure manifest (see "Pre-flight gate" below) |
    | Supplement structure | `qc/supplement_structure.json` | Gate 14: index↔file 1:1, sub-section gaps, callout coverage |
    
    For a complete bundle, use `build --bundle-spec bundle.json` after running the
    existing renderers. The declaration adds final Word/PDF, supplement, cover-letter,
    table/figure and notice files with pinned render-input hashes and reuse-rights
    records. Copies preserve file bytes; content and visual fidelity remain
    `not_assessed` until separately reviewed. Build refuses edited or frozen outputs
    and destructive path collisions. See [bundle workflow](references/bundle_workflow.md)
    for the schema, a runnable synthetic example and the limits of each recorded check.
    
    ## Pre-flight gate (single command — last step before freeze)
    
    Run this once, right before `freeze`/submission. It orchestrates the existing
    deterministic checks and the `/verify-refs` audit into one halt-on-failure gate,
    writes a single aggregated manifest (`qc/preflight_gate_report.json`), and exits
    **non-zero** so a build wrapper or CI step can stop the freeze. It shells out to
    the per-check scripts and reimplements none of them — the halt decision is driven
    by each sub-check's normalized exit code.
    
    The report distinguishes executed, skipped and errored checks. Its legacy
    `submission_safe` field means no configured blocker/error, not submission
    approval; `readiness` remains `not_assessed`. The optional bundle hash binding
    identifies the package present during the run, not per-file visual inspection or
    all external check inputs. Run `audit` again after preflight to expose current,
    stale or unbound evidence. Freeze records a byte snapshot and its available check
    context; it does not run preflight or approve source fidelity or reuse permissions.
    
    ```bash
    python "${CLAUDE_SKILL_DIR}/scripts/preflight_gate.py" --project-root . --journal chest
    # add --strict to also halt on the heuristic/conditional (P1) checks
    # add --online to make fabricated / author-mismatched references halt (PubMed/CrossRef)
    # add --double-blind to make the asset-anonymization scan halt
    ```
    
    By default the gate **halts only on the unambiguous, deterministic errors** (P0):
    leftover placeholder/markers (`check_placeholders.py`), undefined `[@key]`
    citations (`check_citation_keys.py`), duplicate references (`verify_refs.py`,
    offline-deterministic), a canonical-vs-submission hash mismatch
    (`sync_submission.py audit`), and an internal-audit dump leaked into a
    reviewer-facing file (`check_checklist_dump_leak.py` — see below). The heuristic or conditional checks — `check_xref`,
    `detect_copy_divergence`, `scope_drift_check`, `cover_letter_drift_check`,
    `cross_document_n_check`, `check_cross_artifact_stale` — **run and report as P1
    `warn` but do not halt** unless promoted with `--strict` or `--require ID`;
    `check_asset_anonymization` is P1 unless `--double-blind`. A check whose inputs are
    absent (no rendered docx, no cover letter, no copies, no journal) is recorded
    `skipped`, never a blocker. Exit codes: `0` clean, `1` halt (≥1 blocker), `2` gate
    config error (e.g. a `--require`'d check could not run).
    
    The gate's offline references pass is the deterministic subset (duplicates +
    pagination placeholders); an online `/verify-refs --strict` against PubMed/CrossRef
    remains the authoritative fabrication and author-name check before submission.
    
    **Audit-dump leak check (P0).** A `/check-reporting` or `/self-review` report is an *internal working audit* — it carries auto-fix annotations, a raw JSON block (`compliance_pct`, `fixable_by_ai`, `check_reporting_version`), pipeline-log paths, and "Action Items". It is NOT the official reporting checklist a journal expects, and must never reach a reviewer. A near-miss: a prior project's `STROBE_checklist_v4.pdf` was actually this dump, reused by filename into a later submission and compiled into the reviewer-visible proof. `scripts/check_checklist_dump_leak.py --dir submission/` scans every `.md`/`.docx`/`.pdf` in the package for these tokens; any hit is a P0 `leak`. Run it (the pre-flight gate already does, over the journal asset directory) before freeze and confirm `submission_safe: true`. Writes `qc/checklist_dump_leak.json`.
    
    **Disclosure & availability check (standalone).** Top medical-AI journals require, before review, an AI-use disclosure carrying four tokens (version + access channel + date/date-range + responsible party — the tool name only *triggers* the check) and Data/Code Availability statements. Run `python3 ${CLAUDE_SKILL_DIR}/scripts/check_disclosure_availability.py --manuscript <file> --journal <stem> [--ai-study] [--require data_availability ...] [--strict]` (reads `references/journal_availability_policy.json`). It blocks on a missing required statement or an AI disclosure that is present but missing a token / carrying a placeholder; "available on reasonable request" where the journal expects a repository is a P1 warning. Writes `qc/disclosure_availability_report.json`.
    
    ## Workflow
    
    1. Resolve canonical manuscript from `project.yaml` or explicit input.
    2. Run the script in the requested mode.
    3. If `audit` reports `DRIFT`, do not retarget or freeze until the user either
       patches the canonical manuscript or records the difference as journal-only.
    4. If `build` succeeds, run `/verify-refs` before final submission.
    
    ## Quality Gates
    
    - Gate 0 (pre-flight, last step before freeze): run `scripts/preflight_gate.py --project-root . --journal {journal}` to aggregate the deterministic checks below into one halt-on-failure manifest (`qc/preflight_gate_report.json`). Non-zero exit blocks the freeze. See "Pre-flight gate" above for the P0/P1 tiering and flags. This orchestrates Gates 1–3, 5b, 8, 9, 11 plus the placeholder and citation-key checks; the individual gates remain runnable on their own.
    - Gate 1: block freezing when canonical manuscript is missing.
    - Gate 2: block retargeting when the previous submission has unresolved drift.
    - Gate 3: require `/verify-refs` audit before marking a package submission-safe.
    - Gate 4: docx audits must use a recursive walk (paragraphs + tables + nested-table cells); a flat `document.paragraphs` scan is insufficient.
    - Gate 5: before freeze, confirm portal free-text fields (cover letter, data availability, acknowledgements, abstract, author contributions) match the manuscript body.
    - Gate 5c (portal-field markdown residue): portal paste-verbatim `.txt` fields (`abstract.txt`, `keywords.txt`, …) are cut from the markdown but never stripped of it, so a trailing `---`, a `**bold**`, or a `cm^2^` superscript pastes into — and publishes in — the field literally. The pre-flight gate runs `scripts/check_portal_field_residue.py --dir portal_fields/` (P1, `--strict`-promotable) over `portal_fields/`; only `.txt` is scanned (a `.md` is meant to carry markdown), and the emphasis/super/sub patterns require paired markers so significance stars and approximation tildes do not fire. It also carries a Minor `char_expansion` advisory: `≥`/`≤` in a paste-verbatim field are verbose-expanded by ScholarOne to "{greater than or equal to}" (five words), inflating the word count — pre-substitute `>=`/`<=` (only `≥`/`≤`; `×` and the en-dash paste cleanly).
    - Gate 5d (figure portal readiness): a figure bounces at the upload button for reasons decidable from the file on disk — a byte size (JACC: Asia caps a figure at **25 MB**) and an extension (SNAPP accepts only `.tiff`/`.jpeg`/`.eps`, rejecting `.png`). The pre-flight gate runs `scripts/figure_portal_readiness_check.py --figures-dir <dir>` (P1) over `submission/<journal>/figures` (or `./figures`), emitting `FIGURE_OVERSIZE` and — when the portal's formats are supplied via `--figure-accept tiff jpeg eps` — `FIGURE_FORMAT_REJECTED`. Fix by regenerating with `/make-figures export_portal_tiff.py` (LZW + RGBA→RGB flatten). The check is skipped (never an error) when there is no figures directory.
    - Gate 6 (double-blind journals): before freeze, export the portal's blinded review PDF and grep for all author identifiers across the entire upload set — manuscript, supplementary, cover letter, registry record PDFs (PROSPERO/ClinicalTrials), portal Letter-field text. A clean manuscript blind does not imply a clean portal blind.
    - Gate 7 (text-only docx rebuilds): never use `pandoc --reference-doc=manuscript.docx` for response/cover/supplementary text-only docx — the reference docx ships its embedded media (figure files) into the new docx, bloating size 50–100×. Use plain `pandoc input.md -o output.docx` for text-only artifacts.
    - Gate 5b (Phase 4 cover-letter free-text drift): before freeze, run `scripts/cover_letter_drift_check.py` to verify the cover letter's word-count / reference-count / table-figure-count claims still match the manuscript. Cover letters routinely go stale across v_N → v_(N+1) branching and are not covered by any docx-level audit. See "Phase 4 — Cover-letter free-text drift" below.
    - Gate 8 (Phase 5 cross-document N consistency): before freeze, run `scripts/cross_document_n_check.py` over the manuscript bundle (abstract, body, PROSPERO record, cover letter, supplementary, INDEX, PRISMA flow caption). Any N category with >1 distinct integer value is a P0 drift. When a `FINAL_POOL_LOCK.yaml` is present, supply `--pool-lock` to make the locked counts the authoritative baseline. See "Phase 5 — Cross-document N consistency" below.
    - Gate 9 (Phase 6 intra-manuscript scope drift): run `scripts/scope_drift_check.py` against the manuscript (and optionally the PROSPERO record). Numeric anchors (AUC, OR/HR/RR, sensitivity/specificity) appearing in Limitations / Discussion but absent from Methods + Results are P0 SCOPE_DRIFT. PROSPERO ↔ Methods synthesis-method disagreement is a P0 PROSPERO_DRIFT.
    - Gate 10 (Phase 7 v_(N+1) docx regeneration): when building a new submission from a frozen prior version, run `scripts/verify_package_integrity.py --assert-vN-docx-changed --vN-docx <prev>.docx --new-docx <next>.docx`. Identical MD5 = unmodified seed copy = block submission. Defense-in-depth — required even when the upstream pipeline appears to have regenerated the docx.
    - Gate 11 (Phase 8 multi-copy divergence): when the project hand-maintains more than one manuscript copy (working SSOT, circulation, portal), run `scripts/detect_copy_divergence.py --ssot <ssot>.md --copy <copy>.md ...` before freeze or circulation. Any `STALE_COPY` (an SSOT numeric claim or heading that did not propagate to a copy) is a P0 drift. See "Phase 8 — Multi-copy manuscript divergence" below.
    - Gate 11b (reframe / headline-change survivor scan): after a revision that **reframes a claim class** (e.g. retires "location-stratified benchmark" for "overall pooled") or **changes a headline number**, a stale copy commonly survives in an un-touched body paragraph, a figure/table legend, the supplement, or the response letter — the response letter often claims the change was applied "throughout" while a sidecar still carries the old term/value. Pass the retired vocabulary and superseded values from the reframe diff to the cross-artifact gate, which scans the **body and every aux artifact**:
      ```bash
      python3 "${CLAUDE_SKILL_DIR}/scripts/check_cross_artifact_stale.py" \
          --manuscript manuscript.md --aux supplement/ --aux figures/legends.md --aux revision/response_to_reviewers.md \
          --retired-term "location-stratified benchmark" --old-value 1.72
      ```
      A `retired_framing_survivor` / `stale_old_value` finding is a P1 stale claim-site; this automates the claim-site grep of `manuscript-versioning.md` §6.1 across all artifacts rather than a sample. (Numeric survivors are digit-bounded, so `1.72` never matches `11.723`.)
    - Gate 12 (target-journal metadata drift): on `build` / retarget, cross-check the target the manuscript is written *for* against the target the project is being submitted *to*. Compare `project.yaml` `target` (and any in-manuscript header/footer "for submission to X" string) against the journal the package is built for, and check the structural metadata the target dictates — abstract heading structure (4- vs 5-heading), body word limit, citation style (Vancouver / AMA), required elements (Highlights / Central Illustration / Key Points). A mismatch (e.g., a header still reading the previous journal after a cascade retarget, or a 4-heading abstract for a 5-heading target) is a target-restructure trigger — branch to v_(N+1) per `manuscript-versioning.md` §2 and sync every sidecar (cover letter, title page, ICMJE COI list) — not a silent build.
    
      ```bash
      # header target vs project.yaml target
      TGT=$(python3 -c "import yaml;print(yaml.safe_load(open('project.yaml')).get('target',''))" 2>/dev/null)
      grep -niE 'for submission to|submitted to|prepared for' manuscript/manuscript.md   # compare against "$TGT"
      ```
    
    - Gate 13 (body word count vs journal cap — the revision-inflation trap): resolving reviewer majors monotonically *adds* words, so a revised body silently breaches the target journal's limit. Before freeze (and after **every** `/revise` pass), run `scripts/check_wordcount_cap.py` against the target journal profile's body cap. `WORDCOUNT_OVER_CAP` is a P0 (relocate methods/sensitivity detail to the Supplement); `WORDCOUNT_NEAR_CAP` (>0.95×) warns that the next pass will breach. The binding number is the **rendered** count (citeproc expands `[@key]` → "(Author Year)"), so prefer the built DOCX count with `--rendered-words N`; otherwise the script estimates it from the markdown body + inline-citation expansion.
    
      ```bash
      python3 "${CLAUDE_SKILL_DIR}/scripts/check_wordcount_cap.py" \
        --manuscript manuscript/manuscript.md \
        --journal-profile "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/find-journal/references/journal_profiles/<Journal>.md" \
        --article-type "Original Article" --out qc/wordcount_cap.json --strict
      # or, deterministic: --limit 4000   (and --rendered-words N from the built DOCX when available)
      ```
    
    - Gate 14 (supplement structure — the numbering lock): a cohort/SR supplement is a directory of `S{N}_*.md` sections plus an index, hand-concatenated into `_combined.md`. Across revision rounds that set desynchronizes silently: an index row with no file, a file the index never lists, two files claiming the same `S{N}`, or a sub-section gap after an insert (`S6.3` then `S6.5`). A reviewer opening "Supplementary Table S9" and finding the wrong content is the failure mode. Before freeze, run `scripts/assemble_supplement.py` to validate index↔file 1:1, rebuild `_combined.md` in index order (so the assembly is reproducible rather than hand-maintained), and — with `--manuscript` — report callout coverage: body callouts with no section file (`CALLOUT_WITHOUT_SECTION`) and section files the body never cites (`SECTION_UNCITED`). The four structural kinds are P0 under `--strict`; coverage findings are advisory.
    
      ```bash
      python3 "${CLAUDE_SKILL_DIR}/scripts/assemble_supplement.py" \
        --dir submission/{journal}/supplementary --index 00_index.md \
        --manuscript manuscript/manuscript.md \
        --out submission/{journal}/supplementary/_combined.md \
        --json qc/supplement_structure.json --strict
      ```
    
    ## Phase 3b — Portal fields that REPLACE the manuscript
    
    Some portals publish the box, not the paper. SNAPP prints it on the form itself, at Author
    Contributions, Competing Interests, Data Availability and Acknowledgements:
    
    > "This replaces any statement written within the manuscript and is the one that we will publish."
    
    So the manuscript file is the copy reviewers read and the portal box is the copy the world
    gets. A declaration that lives only in the manuscript is not a harmless duplicate — it will
    not exist in the published record, and nothing warns you, because neither document is wrong
    on its own. Two sentences that came one click from vanishing this way:
    
    - **Co-first authorship.** A `†` footnote on the title page. There is **no equal-contribution
      checkbox** on the author page — unless "X and Y contributed equally to this work" is typed
      into the Author Contributions box, the published paper has no co-first authors.
    - **"The funder had no role in study design…"** It lived in the manuscript's Acknowledgements.
      The structured *Research funding* field takes a funder and a grant ID and has nowhere to put
      a role disclaimer, so pasting only an AI-use note into the Acknowledgements box drops it.
    
    **Do not hand-compose the boxes.** Generate them from the manuscript, then check:
    
    ```bash
    SS="${CLAUDE_SKILL_DIR}/scripts"
    # scaffold every replacing field straight from the manuscript (lifts the equal-contribution
    # sentence in from the title page, which is the one place --emit cannot copy it from)
    python3 "$SS/check_portal_mirror.py" --manuscript manuscript/manuscript.md \
      --profile "<...>/journal_profiles/npj_Digital_Medicine.md" --emit portal_fields/
    
    # then verify nothing was lost on the way to the box
    python3 "$SS/check_portal_mirror.py" --manuscript manuscript/manuscript.md \
      --portal-dir portal_fields/ --profile "<...>/npj_Digital_Medicine.md" \
      --out qc/portal_mirror.json
    ```
    
    | Verdict | Fires when |
    |---|---|
    | `PORTAL_FIELD_NOT_MIRRORED` | A sentence in a replacing manuscript section has no home in that field's paste artifact. |
    | `PORTAL_FIELD_MISSING` | The manuscript has the section, the journal replaces it, and no artifact exists — the field publishes empty or as the portal's auto-extraction guessed it. |
    | `EQUAL_CONTRIBUTION_NOT_IN_PORTAL` | The manuscript asserts equal / co-first contribution and the Author Contributions text does not. |
    
    All three are major and exit 1; the pre-flight runs this as P1 (`--strict`-promotable).
    
    **Which fields replace is a journal fact, not a guess.** It is read from the journal profile's
    `## Portal Mechanics` block (`Fields that REPLACE the manuscript: …`). A journal whose portal
    contract has never been recorded makes this check exit 2 and assert nothing — record the block
    at first submission rather than letting the gate invent a contract. Matching is graded through
    `_quote_match.py`, so re-flowing a sentence while pasting is not reported as a loss.
    
    This is the complement of Gate 5c, not a duplicate: 5c asks whether what you paste is *clean*,
    this asks whether what you did *not* paste is quietly gone.
    
    ## Phase 3c — CRediT integrity (not author order)
    
    A contribution taxonomy is a factual claim, published with the paper, and every co-author
    reads it. Nothing ties a term to anything. During one byline negotiation three terms were
    requested in sequence — Visualization, Methodology, Formal analysis — each unsupported by the
    project record; a fourth, Conceptualization, was **entirely legitimate** and had no repository
    artifact at all, because it lived in email and in a critique that drove a restructure.
    
    That asymmetry is the design. The taxonomy is checkable; the work behind it often is not.
    
    ```bash
    python3 "${CLAUDE_SKILL_DIR}/scripts/check_credit_integrity.py" \
      --manuscript manuscript/manuscript.md --out qc/credit_integrity.json
    ```
    
    | Verdict | Severity | Fires when |
    |---|---|---|
    | `CREDIT_TERM_INVALID` | major | A term outside the official fourteen in a section that says CRediT — "Statistical analysis", "Manuscript writing", "Study design" all read as CRediT and are not. The message names the term that was meant. |
    | `CREDIT_INITIALS_UNRESOLVED` | major | Initials matching no author, or two. This is the residue a byline edit leaves: the removed author's initials keep reading as valid. |
    | `CREDIT_AUTHOR_UNLISTED` | major | A byline author with no contribution attributed. Under ICMJE that is either an authorship question or a dropped clause. |
    | `CREDIT_UNCORROBORATED` | **prompt** | A term whose footprint is absent — Visualization on a paper with no figures, Software with no Code Availability statement, or (only if the project keeps one, passed with `--contribution-record`) a contributor absent from the record. |
    
    **Author order and equal-contribution designation are never gated.** They are negotiated, and
    negotiation is legitimate; conflating them with the taxonomy is why they get edited as one
    block. Corroboration is a prompt and can be answered with an attestation — a gate that failed
    the build on an off-repo contribution would be wrong, and would teach its user to disable it.
    
    Two things it declines to guess: with fewer than two resolvable byline names the
    author/initials cross-check is **skipped and says so** (a wrong byline would accuse every
    author at once), and with no contributions section it exits 2 and asserts nothing.
    
    ## Phase 4 — Cover-letter free-text drift
    
    Cover letters live outside the submission docx files but are read by the
    editor side-by-side with the manuscript. Their `## Article details`
    block — body word count, abstract word count, reference count,
    table/figure count — is a sidecar SSOT that routinely goes stale when a
    manuscript branches v_N → v_(N+1) (word limit retarget, abstract
    restructure, late reference batch).
    
    `scripts/cover_letter_drift_check.py` measures the manuscript truth and
    compares it to the cover letter's numeric claims:
    
    ```bash
    python "${CLAUDE_SKILL_DIR}/scripts/cover_letter_drift_check.py" \
        --manuscript manuscript.md \
        --cover-letter cover_letter.md \
        --refs refs.bib \
        --out qc/cover_letter_drift.json
    ```
    
    Body words are matched with a 5% tolerance ("approximately N words"
    phrasing). Abstract words tolerate ±5. Reference / table / figure counts
    require exact match.
    
    Example `qc/cover_letter_drift.json` (synthetic values):
    
    ```json
    {
      "submission_safe": false,
      "truth": {"body_words": 2400, "abstract_words": 210, "references": 10,
                "tables": 3, "figures": 4},
      "claims": {"body_words": 2800, "abstract_words": 250, "references": 10},
      "drifts": [
        {"field": "body_words", "truth": 2400, "cover_letter_claim": 2800,
         "severity": "MAJOR",
         "note": "|claim - truth| = 400 > tolerance 120"}
      ]
    }
    ```
    
    Drift resolution: regenerate the cover letter from the manuscript at
    v_(N+1) build time. The script never edits the cover letter — that is
    left to the manuscript build pipeline so the cover letter stays a
    deliberate authored artifact.
    
    ## Phase 5 — Cross-document N consistency
    
    Multi-document cohort-size drift is a high-frequency desk-reject pattern.
    Manuscript abstracts, body prose, PROSPERO records, supplementary extraction
    sheets, and PRISMA flow captions all repeat the same `k included` / `k excluded`
    / `N patients` totals — and any disagreement between them is read by reviewers
    as either a data-integrity failure or a late-edit failure. Either reading
    ends the round.
    
    `scripts/cross_document_n_check.py` scans the submission package, extracts
    every "N <noun>" claim by category (patients, cases, included, excluded,
    nodules, tumors, studies_total), and groups them by category. A category with
    more than one distinct integer value is a P0 drift.
    
    ```bash
    python "${CLAUDE_SKILL_DIR}/scripts/cross_document_n_check.py" \
        --root . \
        --out qc/cross_document_n.json
    ```
    
    When the project has frozen a `2_Data/FINAL_POOL_LOCK.yaml` from `/meta-analysis`
    Phase 3f.5, pass it as the authoritative anchor:
    
    ```bash
    python "${CLAUDE_SKILL_DIR}/scripts/cross_document_n_check.py" \
        --root . \
        --pool-lock 2_Data/FINAL_POOL_LOCK.yaml \
        --out qc/cross_document_n.json
    ```
    
    Output `qc/cross_document_n.json`:
    
    ```json
    {
      "submission_safe": false,
      "drift_count": 1,
      "drifts": [
        {
          "category": "included",
          "values": [63, 64],
          "locations": [
            {"file": "abstract.md", "line": 4, "value": 63, "context": "..."},
            {"file": "supplementary/s1.md", "line": 12, "value": 64, "context": "..."}
          ],
          "severity": "MAJOR"
        }
      ],
      "lock_violations": []
    }
    ```
    
    Treat `submission_safe: false` as a halt. Resolve drift by tracing each
    location to its data artifact (extraction sheet, PRISMA cascade TSVs) and
    correcting the document(s) that disagree with the locked count.
    
    ## Phase 6 — Intra-manuscript scope drift
    
    Late-revision sensitivity analyses sometimes get introduced in the
    Discussion or Limitations subsection without ever propagating back to
    Methods + Results. The manuscript then makes claims (with explicit AUC,
    OR, sensitivity numbers) whose primary report never exists. Reviewers
    read this as a fabrication-grade red flag, and editors desk-reject.
    
    A second variant of the same anti-pattern: the PROSPERO record commits to
    a synthesis method (Freeman-Tukey, random-effects DerSimonian-Laird,
    bivariate, HSROC, Bayesian, etc.) but the Methods section uses a
    different one — or the PROSPERO record was updated and Methods stayed
    behind. When accompanied by a Methods line saying "no amendment lodged",
    this becomes a documented silent protocol deviation.
    
    `scripts/scope_drift_check.py` detects both patterns:
    
    ```bash
    python "${CLAUDE_SKILL_DIR}/scripts/scope_drift_check.py" \
        --manuscript manuscript.md \
        --prospero prospero/prospero_v2.md \
        --out qc/scope_drift.json
    ```
    
    Output:
    
    ```json
    {
      "submission_safe": false,
      "limitations_only_anchors": [
        {
          "anchor": "0.869",
          "kind": "AUC",
          "found_in": ["Limitations:31"],
          "missing_from": ["Methods", "Results"]
        }
      ],
      "synthesis_method_drift": [
        {"method": "Freeman-Tukey", "prospero": true, "methods": false}
      ]
    }
    ```
    
    Resolution: either (a) propagate the anchor into Methods + Results as a
    primary report or (b) remove it from Limitations / Discussion. For
    synthesis-method drift, file a PROSPERO amendment and update Methods to
    match — both must agree before submission.
    
    ## Phase 7 — v_(N+1) docx regeneration gate
    
    When a v_N submission package was frozen and a v_(N+1) is being built
    (after a markdown body edit, reviewer round, or cascade-rejection
    re-target), the v_(N+1) docx MUST differ from the v_N docx. The most
    common silent-revert pattern is a `cp v_N/manuscript.docx
    v_(N+1)/manuscript.docx` step that skips the pandoc / Zotero CWYW
    regeneration entirely. The markdown body is then edited, but the docx
    the portal receives is the frozen v_N — the change silently reverts at
    peer review.
    
    Run the byte-identity assertion at the top of the v_(N+1) submission
    gate:
    
    ```bash
    python3 /path/to/medsci-skills/scripts/verify_package_integrity.py \
        --assert-vN-docx-changed \
        --vN-docx SUBMISSION/<journal>/v<N>/manuscript.docx \
        --new-docx SUBMISSION/<journal>/v<N+1>/manuscript.docx
    ```
    
    Identical MD5 → exit 1 with explanatory error. Block submission until
    the regeneration step is fixed.
    
    ## Phase 8 — Multi-copy manuscript divergence
    
    When a project keeps several hand-maintained manuscript copies — `manuscript.md`
    (the working SSOT), `manuscript_circulation.md` (co-author feedback), and
    `submission/<journal>/manuscript.md` (portal) — a batch of edits applied to the
    SSOT routinely lands in only some of the copies. The portal then receives a copy
    missing a subset of the edits, and the divergence surfaces (if at all) only when a
    reviewer notices the inconsistency.
    
    Before freezing a package or sending a circulation round, run the directional
    detector (SSOT → each copy):
    
    ```bash
    python3 ${CLAUDE_SKILL_DIR}/scripts/detect_copy_divergence.py \
      --ssot manuscript.md \
      --copy manuscript_circulation.md \
      --copy submission/<journal>/manuscript.md \
      --out qc/copy_divergence.json --strict
    ```
    
    It reports, per copy, the SSOT *claims* (numeric assertions — `n = N`, percentages,
    `p`, OR/HR/RR, 95% CI — and section headings) that did not propagate. A `STALE_COPY`
    (`DIVERGENT` overall) is a **P0 blocker**: re-propagate the unpropagated claims, or —
    better — stop hand-maintaining parallel copies and **generate the circulation /
    submission variants from the single SSOT via a build step** (pandoc transform), so
    there is only one editable source. Claims are matched as normalized strings, so
    wording differences do not register — only a changed or absent number/heading does;
    legitimately copy-specific content (a circulation cover note) shows up as `copy_only`
    and can be ignored.
    
    ## Phase 9 — Springer Editorial Manager packaging (no title-page slot)
    
    Some Springer Editorial Manager journals offer only **Manuscript / Figure / Table / Supplementary / LaTeX** upload item types — no separate Title Page or Cover Letter slot, and sometimes no Graphical Abstract slot. Common for observational / cohort submissions.
    
    - **Title page → page 1 of the Manuscript file.** Build via pandoc: title-page markdown (strip internal-only blocks such as a "Manuscript Metrics" QC block, plus any Funding / Author Contributions / Keywords that also appear later) + a real docx page break (raw OpenXML `<w:br w:type="page"/>`; a bare `\newpage` is silently dropped in docx output) + the manuscript body **with its byline / affiliations / corresponding-author footnote removed** so the title page is not duplicated.
      - Verify: at least one page break; the affiliation block appears once; the article title is followed directly by the Abstract (no repeated byline); no internal QC strings leak.
    - **Cover letter → paste into the "comments to the publication office" free-text field.**
    - **Graphical Abstract (no dedicated slot) → upload as a Figure with Description = "Graphical Abstract".**
    - **Declarations completeness (portal hard checkbox).** The manuscript "Statements and Declarations" must carry all seven Springer subheadings: Funding; Competing Interests; Ethics Approval; Consent to Participate; Consent for Publication; Author Contributions; Data Availability. For de-identified observational / registry studies, Consent to Participate = waived (existing de-identified records) and Consent for Publication = "Not applicable; only de-identified data, no individual person's identifying details, images, or videos".
    
    ```bash
    for s in Funding "Competing Interests" "Ethics Approval" "Consent to Participate" "Consent for Publication" "Author Contributions" "Data Availability"; do
      unzip -p manuscript.docx word/document.xml | sed 's/<[^>]*>//g' | grep -q "$s" && echo "OK $s" || echo "MISSING $s"; done
    ```
    
    - **Ethics approval / exemption number (observational or exempt cohort).** State the IRB approval or exemption reference number in the ethics statement. Institutional exemption notices carry the reference in the document body; filename digits are usually a receipt number, not the approval number — open the notice before writing the ethics block.
    - **Word limit "including references".** When the limit counts references, the binding constraint is body+references words, not the reference-count ceiling. Measure body+refs on the rendered docx before adding references; each Vancouver reference is roughly 25–33 rendered words.
    - **Submitting via a co-author's account.** Editorial Manager auto-adds the account holder at the top of the author list, tagged first/corresponding. De-duplicate, reorder to the intended position, reassign the first-author tag to the true first author, and fill missing co-author email/ORCID.
    - **Re-read the EM-compiled submission PDF before Approve** — author order, degrees, ethics number, references, declarations, and figures.
    
    ## Phase 10 — Marked (tracked-changes) manuscript for a revision round
    
    Every revision round asks for a **marked** manuscript: the revised paper with tracked changes against the version the reviewers saw. Two rules, both load-bearing.
    
    **The baseline is R0, not the previous round.** The base of the diff is always the *originally reviewed* submission; only the target advances each round. An editor wants every change made since the version under review, so do not diff v7 against v8.
    
    **Word's Compare is the only safe producer — but it is scriptable.** `pandiff` and LibreOffice `--compare` corrupt OOXML on real manuscripts (tables collapse, affiliation superscripts are lost); do not use them. Word for Mac exposes `compare` through AppleScript with `author name`, so the build needs no GUI pass and no post-hoc rewriting of `w:author`:
    
    ```bash
    python3 "${CLAUDE_SKILL_DIR}/scripts/build_marked_manuscript.py" \
      --original submission/{journal}/R0/manuscript.docx \
      --revised  submission/{journal}/R1/manuscript_clean.docx \
      --out      submission/{journal}/R1/manuscript_marked.docx \
      --author   "Submitting Author" --line-numbers
    ```
    
    (macOS + Word only. On any other platform, produce the marked file in Word by hand — then still run the gate below.)
    
    ### The gate: a round trip, not a grep
    
    Confirming that "the marked file contains sentence X" passes even when Compare has dropped a paragraph, duplicated one, or split the revisions between two authors. Verify it the only way that is correct by construction — **accepting every revision must reproduce the revised manuscript exactly, and rejecting every revision must reproduce the original**:
    
    ```bash
    python3 "${CLAUDE_SKILL_DIR}/scripts/check_marked_manuscript.py" \
      --marked   submission/{journal}/R1/manuscript_marked.docx \
      --original submission/{journal}/R0/manuscript.docx \
      --revised  submission/{journal}/R1/manuscript_clean.docx \
      --author   "Submitting Author" --strict
    ```
    
    Verdicts: `MARKED_ACCEPT_MISMATCH`, `MARKED_REJECT_MISMATCH` (content dropped, duplicated, or invented), `MARKED_NO_REVISIONS` (Compare produced a clean copy), `MARKED_AUTHOR_MIXED`, `MARKED_TABLE_LOSS`, `MARKED_BASE_TRACKED` (a baseline still carrying live tracked changes, which makes the comparison ill-defined — accept or reject them first).
    
    **A move is not an insert plus a delete.** Word encodes relocated content as `w:moveFrom` / `w:moveTo`, and a verifier that knows only `w:ins` / `w:del` reconstructs the original with the moved paragraph in it *twice* — reporting a perfectly good file as corrupt. The gate resolves `revised = unchanged + w:ins + w:moveTo` and `original = unchanged + w:delText + w:moveFrom`. Any docx probe written here must walk exact `w:t` / `w:delText` elements: the regex `<w:t[^>]*>` also matches `<w:tbl>`, `<w:tc>` and `<w:tr>`, silently swallowing table markup as prose.
    
    ### Upload failure on a large marked file
    
    The marked file carries the baseline's embedded images as deleted content, so it can exceed a portal's size cap even when the clean file is small. Before re-encoding, rule out the ordinary causes: the file is still open in Word (a `~$…docx` lock), the portal session expired, or the upload is transient — retry. If it is genuinely too large, downsample only `word/media/*` and repackage; tracked changes live in `word/document.xml` and are untouched. Re-run the gate afterwards and keep the full-resolution original as `*.full.docx`.
    
    ## Verification Blind Spots
    
    Post-submission learnings (npj Digital Medicine R1, 2026-05): a clean docx-level audit still missed several stale artifacts that surfaced only at the portal review stage. Apply these whenever auditing a submission package.
    
    ### B1. docx scanning must be recursive
    
    `python-docx` `paragraph.runs` does not expose runs inside `<w:hyperlink>`; `document.paragraphs` skips table cells; `document.tables` does not recurse into nested tables. Figures, captions, and reporting checklists are routinely wrapped in 1×1 or nested tables, so flat scans silently miss them.
    
    - Walk `paragraphs + tables + nested-table cells` recursively for every stale-string scan.
    - For run-level edits near hyperlinks or fields, inspect the paragraph XML, not just `.runs` — a missing inline element can be misread as an empty `()` artifact and "fixed" into a real defect.
    
    ### B2. Portal input fields are a separate SSOT
    
    Cover letter, Data Availability, Acknowledgements, Abstract, and Author Contributions are often typed directly into the journal portal, outside any docx this skill audits. A clean docx audit does not imply a clean portal.
    
    - Before final submission, diff the portal's final review page against the manuscript body 1:1.
    - Treat each portal free-text field as its own drift target.
    
    ### B3a. Double-blind compliance must cover ALL upload artifacts
    
    A clean manuscript-level blind sweep does not imply a clean portal-level blind. Author identifiers commonly leak through:
    
    - Supplementary materials (per-material `.md`/`.docx` files, especially methodology logs, agreement metrics, amendment logs)
    - Cover letter (separately-uploaded file is portal-default visible to reviewers unless explicitly toggled "Don't show in review PDF")
    - Registry record PDFs (PROSPERO, ClinicalTrials.gov, IRB approval PDFs)
    - Portal free-text Letter field if cover-letter signature was pasted
    - Response-to-reviewers (revision rounds)
    
    Blind sweep regex coverage must include both period and no-period initial forms (e.g., `Y.N.` and `YN`), full names in roman + native scripts, institution names, ORCID IDs, and submission email domains. The first blind PDF export from the portal is the authoritative drift detector — always export and grep before final submit.
    
    ### B3b. PROSPERO public-record PDF shows only current amendment
    
    PROSPERO's "Print/PDF" export from the public record renders only the current amendment narrative. Previous versions are accessible only by selecting older versions in the public-record version-history dropdown. When citing PROSPERO version state, never rely on a single PDF export to verify cross-version consistency — record each published version's PDF independently and clarify in cover/supplementary which version anchors the methodology vs. which version reflects documentation-only erratum.
    
    For documentation-only PROSPERO errata (correcting a narrative fact without changing methods/eligibility/synthesis), prefer a single Revision-Note append over a new structured amendment entry. Preserves historical audit trail and minimizes portal edit surface.
    
    ### B3c. Text-only docx rebuilds must not inherit manuscript media
    
    If `response_to_reviewers.docx` / `cover_letter.docx` / supplementary text-only docx grow to >100 KB after a rebuild, suspect `--reference-doc` pulling manuscript figure media. Verify with `unzip -l output.docx | grep word/media/` — should be empty for text-only artifacts.
    
    ### B3. Verify change propagation across the whole SSOT tree
    
    A tone, wording, or number change applied to one file (e.g. the abstract) must propagate to every file that repeats it — discussion, response-to-reviewers quotes, reporting checklists, supplementary captions, title page.
    
    - grep the OLD string across the entire SSOT tree, never a subset of files.
    - Watch for substring near-misses (`expertise-dependent patterns` vs `expertise-dependent evaluation patterns`) — an exact-match grep on the short form passes while the long form remains stale.
    
    ## What This Skill Does NOT Do
    
    - Does not invent journal formatting rules.
    - Does not silently merge submission edits back into the SSOT.
    - Does not replace `/write-paper`; it packages already canonical content.
    
    ## Anti-Hallucination
    
    - Never claim a submission package is current without matching source hashes.
    - Never mark a package as submitted without writing `.journal_meta.json`.
    - Never hide journal-only differences; record them as drift or explicit exceptions.
    
  • skill.yml 2.7 KB
    schema_version: 2
    name: sync-submission
    layer: A
    owner_domain: submission_packaging
    maturity: official
    when_to_use:
      - Auditing SSOT-to-submission drift before freezing a journal package
      - Building a journal-specific submission manifest from canonical manuscript artifacts
      - Retargeting an existing submission to a new journal (cascade rejection)
      - Refreshing artifact_manifest.json to reflect the current canonical state
    when_NOT_to_use:
      - Drafting or editing the canonical manuscript (use /write-paper or /revise)
      - Choosing the target journal (use /find-journal)
      - Freezing a submission while drift is detected (forbidden — fix drift first)
    inputs:
      - project.yaml
      - manuscript/manuscript.md
      - "optional bundle.json declaring final files and pinned render dependencies"
    outputs:
      - submission/{journal}/.journal_meta.json
      - qc/submission_sync_{journal}.json
      - artifact_manifest.json
    deterministic_scripts:
      - scripts/check_credit_integrity.py
      - scripts/check_portal_mirror.py
      - scripts/sync_submission.py
    side_effects:
      - writes_project_artifacts
    downstream_consumers:
      - orchestrate
      - find-journal
    forbidden_actions:
      - silently_edit_canonical_manuscript
      - freeze_drifted_submission
    
    # v2.1 quality card
    purpose: "Audit SSOT-to-submission drift and build journal submission manifests from canonical manuscript artifacts."
    safety_boundaries:
      - "Never silently edits the canonical manuscript; a drifted submission is not frozen until reconciled."
      - "Submission packages are derived from canonical sources, not hand-assembled."
    known_limitations:
      - "Build copies declared final files byte for byte; rendering remains with existing renderers. Hashes do not establish visual or semantic fidelity or reuse permission."
      - "Preflight bundle binding covers declared sources/dependencies and package bytes, not every external check input. Skipped checks and unassessed readiness remain explicit."
      - "Detects drift it is configured to scan (counts, cover-letter fields, scope); portal free-text fields still need a human check."
      - "A clean audit is necessary, not sufficient, for acceptance."
      - "Building a marked (tracked-changes) manuscript drives Microsoft Word and therefore needs macOS + Word; the round-trip verification of a marked file is portable and runs anywhere."
    validation_commands:
      - "python3 scripts/sync_submission.py"
      - "python3 scripts/cross_document_n_check.py"
      - "bash tests/test_marked_manuscript.sh"
      - "bash tests/test_wordcount_cap.sh"
      - "bash tests/test_assemble_supplement.sh"
      - "bash tests/test_disclosure_availability.sh"
      - "bash scripts/check_portal_field_residue_challenge/verify.sh  # deterministic, network-free"
    evidence_surface: bundled_script
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related