Skills › Scientific Agent Skills › scientific-writing › lint_manuscript.py
lint_manuscript.py
171 lines · skills/scientific-writing/scripts/lint_manuscript.py at 5f2bc03
1"""Lint manuscript Markdown for placeholders, language risks, and sensitive content."""
3from __future__ import annotations
5import argparse
6import re
7from typing import Any
9from _common import (
10 Issue,
11 emit_report,
12 issue,
13 read_json,
14 read_text,
15 require_object,
16 run,
17)
19TOOL = "lint_manuscript"
20PLACEHOLDER_PATTERNS = (
21 re.compile(r"\[\[\s*TODO\b", re.IGNORECASE),
22 re.compile(r"\b(?:TODO|TBD|TK)\b", re.IGNORECASE),
23 re.compile(r"\[\s*(?:insert|add|describe|replace)[^\]]*\]", re.IGNORECASE),
24 re.compile(r"\blorem ipsum\b", re.IGNORECASE),
25 re.compile(r"\bX{4,}\b"),
26)
27SENSITIVE_PATTERNS = (
28 re.compile(r"\b(?:MRN|medical record number)\s*[:#]", re.IGNORECASE),
29 re.compile(r"\b(?:SSN|social security number)\s*[:#]", re.IGNORECASE),
30 re.compile(r"\b(?:DOB|date of birth)\s*[:#]", re.IGNORECASE),
31 re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE),
32 re.compile(
33 r"\b(?:PHI|protected health information|patient name|participant name)\b",
34 re.IGNORECASE,
35 ),
36 re.compile(r"\b(?:confidential|proprietary|trade secret)\b", re.IGNORECASE),
37 re.compile(
38 r"\b(?:peer[- ]review material|unpublished manuscript)\b", re.IGNORECASE
39 ),
40)
41DECLARATION_RE = re.compile(
42 r"\b(?:ethics (?:approval|committee)|IRB|informed consent|funded by|"
43 r"conflict[s]? of interest|data (?:are|is) available|code (?:is|are) available)\b",
44 re.IGNORECASE,
45)
46OVERSTATEMENT_RE = re.compile(
47 r"\b(?:proves?|definitively|guarantees?|no limitations|universally|"
48 r"highly significant|no effect)\b",
49 re.IGNORECASE,
50)
51CAUSAL_RE = re.compile(
52 r"\b(?:causes?|caused|proves?|demonstrates? that)\b", re.IGNORECASE
53)
54CLAIM_MARKER_RE = re.compile(r"\[claim:C[0-9]{3,8}\]")
55EVIDENCE_MARKER_RE = re.compile(r"(?:\[evidence:E[0-9]|\[@E[0-9])")
56OBSERVATIONAL_DESIGNS = {
57 "observational",
58 "cohort",
59 "case_control",
60 "cross_sectional",
61 "routinely_collected_health_data",
62}
65def load_manifest(path: str | None) -> tuple[dict[str, Any] | None, list[Issue]]:
66 if path is None:
67 return None, [
68 issue(
69 "warning",
70 "CONFIDENTIALITY_GATE_NOT_CHECKED",
71 location="manifest",
72 )
73 ]
74 data = require_object(read_json(path), "manuscript_manifest")
75 issues: list[Issue] = []
76 review = require_object(
77 data.get("confidentiality_review"), "confidentiality_review"
78 )
79 if review.get("completed") is not True:
80 issues.append(
81 issue("error", "CONFIDENTIALITY_REVIEW_INCOMPLETE", location="manifest")
82 )
83 if review.get("policy_checked") is not True:
84 issues.append(issue("error", "POLICY_REVIEW_INCOMPLETE", location="manifest"))
85 return data, issues
88def lint_text(text: str, manifest: dict[str, Any] | None) -> list[Issue]:
89 issues: list[Issue] = []
90 study_design = str((manifest or {}).get("study_design", "")).casefold()
91 submission_ready = (manifest or {}).get("submission_ready") is True
92 in_fence = False
93 for line_number, line in enumerate(text.splitlines(), start=1):
94 stripped = line.strip()
95 if stripped.startswith(("```", "~~~")):
96 in_fence = not in_fence
97 continue
98 if in_fence:
99 continue
100 location = f"line:{line_number}"
101 for pattern in PLACEHOLDER_PATTERNS:
102 if pattern.search(line):
103 issues.append(
104 issue("error", "UNRESOLVED_PLACEHOLDER", location=location)
105 )
106 break
107 for pattern in SENSITIVE_PATTERNS:
108 if pattern.search(line):
109 issues.append(
110 issue("warning", "POTENTIAL_RESTRICTED_CONTENT", location=location)
111 )
112 break
113 if DECLARATION_RE.search(line) and not (
114 CLAIM_MARKER_RE.search(line) and EVIDENCE_MARKER_RE.search(line)
115 ):
116 issues.append(
117 issue(
118 "error", "DECLARATION_WITHOUT_EVIDENCE_MARKERS", location=location
119 )
120 )
121 if OVERSTATEMENT_RE.search(line):
122 issues.append(
123 issue("warning", "POTENTIAL_OVERSTATEMENT", location=location)
124 )
125 if study_design in OBSERVATIONAL_DESIGNS and CAUSAL_RE.search(line):
126 issues.append(
127 issue(
128 "warning",
129 "CAUSAL_LANGUAGE_FOR_OBSERVATIONAL_DESIGN",
130 location=location,
131 )
132 )
133 if submission_ready and "NOT FOR SUBMISSION" in line.upper():
134 issues.append(
135 issue("error", "DRAFT_BANNER_ON_READY_MANUSCRIPT", location=location)
136 )
137 if not text.strip():
138 issues.append(issue("error", "EMPTY_MANUSCRIPT", location="document"))
139 return issues
142def build_parser() -> argparse.ArgumentParser:
143 parser = argparse.ArgumentParser(
144 description=(
145 "Lint local Markdown without echoing source text. Flags unresolved "
146 "placeholders, risky declarations, possible sensitive content, and "
147 "language that needs human review."
148 )
149 )
150 parser.add_argument("manuscript", help="UTF-8 Markdown manuscript")
151 parser.add_argument(
152 "--manifest",
153 help="optional UTF-8 JSON manuscript manifest with confidentiality gates",
154 )
155 return parser
158def cli() -> int:
159 args = build_parser().parse_args()
160 manifest, issues = load_manifest(args.manifest)
161 text = read_text(args.manuscript, {".md", ".markdown"})
162 issues.extend(lint_text(text, manifest))
163 return emit_report(
164 TOOL,
165 issues,
166 summary={"lines_checked": len(text.splitlines()), "raw_text_echoed": False},
167 )
170if __name__ == "__main__":
171 run(TOOL, cli)