sync-submission
Audit SSOT-to-submission drift and create journal submission manifests from canonical manuscript artifacts.
Install
npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/sync-submission
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
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 --e2emarks a project as submission-ready.
Inputs
- Project root containing
project.yaml, or a direct canonical manuscript path. - Journal short name, e.g.
chest,ryai,academic_radiology. - 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
- Resolve canonical manuscript from
project.yamlor explicit input. - Run the script in the requested mode.
- If
auditreportsDRIFT, do not retarget or freeze until the user either patches the canonical manuscript or records the difference as journal-only. - If
buildsucceeds, run/verify-refsbefore 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-refsaudit before marking a package submission-safe.Gate 4: docx audits must use a recursive walk (paragraphs + tables + nested-table cells); a flat
document.paragraphsscan 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
.txtfields (abstract.txt,keywords.txt, …) are cut from the markdown but never stripped of it, so a trailing---, a**bold**, or acm^2^superscript pastes into — and publishes in — the field literally. The pre-flight gate runsscripts/check_portal_field_residue.py --dir portal_fields/(P1,--strict-promotable) overportal_fields/; only.txtis scanned (a.mdis 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 Minorchar_expansionadvisory:≥/≤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 runsscripts/figure_portal_readiness_check.py --figures-dir <dir>(P1) oversubmission/<journal>/figures(or./figures), emittingFIGURE_OVERSIZEand — 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.docxfor response/cover/supplementary text-only docx — the reference docx ships its embedded media (figure files) into the new docx, bloating size 50–100×. Use plainpandoc input.md -o output.docxfor text-only artifacts.Gate 5b (Phase 4 cover-letter free-text drift): before freeze, run
scripts/cover_letter_drift_check.pyto 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.pyover 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 aFINAL_POOL_LOCK.yamlis present, supply--pool-lockto 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.pyagainst 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. AnySTALE_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.72A
retired_framing_survivor/stale_old_valuefinding is a P1 stale claim-site; this automates the claim-site grep ofmanuscript-versioning.md§6.1 across all artifacts rather than a sample. (Numeric survivors are digit-bounded, so1.72never matches11.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. Compareproject.yamltarget(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) permanuscript-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
/revisepass), runscripts/check_wordcount_cap.pyagainst the target journal profile's body cap.WORDCOUNT_OVER_CAPis 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}_*.mdsections 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 sameS{N}, or a sub-section gap after an insert (S6.3thenS6.5). A reviewer opening "Supplementary Table S9" and finding the wrong content is the failure mode. Before freeze, runscripts/assemble_supplement.pyto validate index↔file 1:1, rebuild_combined.mdin 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\newpageis 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 cellsrecursively 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/.docxfiles, 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 patternsvsexpertise-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.  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.
Reviews (0)
No reviews yet.
No comments yet.