Skills › Scientific Agent Skills › peer-review › lint_review.py
lint_review.py
254 lines · skills/peer-review/scripts/lint_review.py at 5f2bc03
1#!/usr/bin/env python3
2"""Lint a structured review for channel separation, tone, and actionability."""
4from __future__ import annotations
6import argparse
7import re
8from typing import Any
10from _common import (
11 ValidationError,
12 error_exit,
13 issue,
14 read_markdown,
15 write_json_report,
16)
18AUTHOR_HEADING = "# Comments to authors"
19EDITOR_HEADING = "# Confidential comments to editor"
20COMMENT_HEADING_RE = re.compile(
21 r"^###\s+(?P<kind>Major|Minor)\s+comment(?:\s+(?P<id>[A-Za-z0-9._:-]+))?\s*$",
22 re.IGNORECASE,
23)
24FIELD_RE = re.compile(
25 r"^-\s*(?P<label>Location|Observation|Evidence or criterion|"
26 r"Why it matters|Requested action):\s*(?P<value>.*)$",
27 re.IGNORECASE,
28)
29REQUIRED_FIELDS = {
30 "location",
31 "observation",
32 "evidence or criterion",
33 "why it matters",
34 "requested action",
35}
36PLACEHOLDER_RE = re.compile(
37 r"^\s*(?:"
38 r"\[(?:write|state|describe|identify|add|cite|section|figure|table|line|"
39 r"explain|request|replace|if)\b[^\]]*\]"
40 r"|TBD|TODO|<[^>]+>)\s*$",
41 re.IGNORECASE,
42)
43UNRESOLVED_SCAFFOLD_RE = re.compile(
44 r"\[(?:write|state|describe|identify|add|cite|explain|request|replace)\b",
45 re.IGNORECASE,
46)
47ABUSIVE_RE = re.compile(
48 r"\b(?:idiot(?:ic)?|incompetent|ridiculous|nonsense|garbage|lazy|sloppy|"
49 r"clueless|amateurish|embarrassing|worthless)\b",
50 re.IGNORECASE,
51)
52PERSONAL_ATTACK_RE = re.compile(
53 r"\b(?:the\s+)?authors?\s+(?:do(?:es)?\s+not\s+understand|"
54 r"failed\s+to\s+understand|are\s+unaware|are\s+careless|"
55 r"are\s+dishonest)\b",
56 re.IGNORECASE,
57)
58EDITORIAL_DECISION_RE = re.compile(
59 r"\b(?:(?:i|we)\s+(?:recommend|would\s+recommend|have\s+decided|"
60 r"decided)\s+(?:accept(?:ance)?|reject(?:ion)?)|"
61 r"recommendation\s*:\s*(?:accept|reject|major\s+revision|"
62 r"minor\s+revision))\b",
63 re.IGNORECASE,
64)
65IMPERSONATION_RE = re.compile(
66 r"\b(?:i\s+am\s+(?:the\s+)?(?:editor|assigned\s+reviewer)|"
67 r"on\s+behalf\s+of\s+the\s+(?:journal|editor)|"
68 r"we\s+have\s+made\s+the\s+editorial\s+decision)\b",
69 re.IGNORECASE,
70)
71EXECUTION_CLAIM_RE = re.compile(
72 r"\bi\s+(?:ran|performed|replicated|reproduced|verified|confirmed)\s+"
73 r"(?:the|this|these)\s+(?:analysis|analyses|experiment|experiments|"
74 r"results|dataset|data)\b",
75 re.IGNORECASE,
76)
77CONFIDENTIAL_MARKER_RE = re.compile(
78 r"\b(?:confidential\s+to\s+(?:the\s+)?editor|editor[- ]only)\b",
79 re.IGNORECASE,
80)
83def _content_value(value: str) -> bool:
84 text = value.strip()
85 return bool(text) and not PLACEHOLDER_RE.fullmatch(text)
88def _comment_blocks(lines: list[str]) -> list[dict[str, Any]]:
89 starts: list[tuple[int, re.Match[str]]] = []
90 for index, line in enumerate(lines):
91 match = COMMENT_HEADING_RE.match(line.strip())
92 if match:
93 starts.append((index, match))
94 blocks: list[dict[str, Any]] = []
95 for position, (start, match) in enumerate(starts):
96 end = starts[position + 1][0] if position + 1 < len(starts) else len(lines)
97 for candidate in range(start + 1, end):
98 if lines[candidate].startswith("# ") or lines[candidate].startswith("## "):
99 end = candidate
100 break
101 blocks.append(
102 {
103 "start": start,
104 "end": end,
105 "kind": match.group("kind").lower(),
106 "id": match.group("id")
107 or f"{match.group('kind')[0].upper()}-line-{start + 1}",
108 }
109 )
110 return blocks
113def lint(markdown: str) -> dict[str, Any]:
114 lines = markdown.splitlines()
115 errors: list[dict[str, str]] = []
116 warnings: list[dict[str, str]] = []
118 author_lines = [
119 index for index, line in enumerate(lines) if line.strip() == AUTHOR_HEADING
120 ]
121 editor_lines = [
122 index for index, line in enumerate(lines) if line.strip() == EDITOR_HEADING
123 ]
124 if len(author_lines) != 1:
125 errors.append(issue("AUTHOR_CHANNEL_HEADING_REQUIRED_ONCE", "document"))
126 if len(editor_lines) != 1:
127 errors.append(issue("EDITOR_CHANNEL_HEADING_REQUIRED_ONCE", "document"))
128 if author_lines and editor_lines and author_lines[0] >= editor_lines[0]:
129 errors.append(issue("CHANNEL_ORDER_INVALID", "document"))
131 author_start = author_lines[0] if len(author_lines) == 1 else None
132 editor_start = editor_lines[0] if len(editor_lines) == 1 else None
134 for line_number, line in enumerate(lines, start=1):
135 subject = f"line:{line_number}"
136 if ABUSIVE_RE.search(line):
137 errors.append(issue("ABUSIVE_OR_DISMISSIVE_LANGUAGE", subject))
138 if PERSONAL_ATTACK_RE.search(line):
139 errors.append(issue("PERSONAL_ATTACK", subject))
140 if EDITORIAL_DECISION_RE.search(line):
141 errors.append(issue("EDITORIAL_DECISION_LANGUAGE", subject))
142 if IMPERSONATION_RE.search(line):
143 errors.append(issue("ROLE_IMPERSONATION_LANGUAGE", subject))
144 if EXECUTION_CLAIM_RE.search(line):
145 warnings.append(issue("EXECUTION_CLAIM_REQUIRES_PROVENANCE", subject))
146 if UNRESOLVED_SCAFFOLD_RE.search(line):
147 errors.append(issue("UNRESOLVED_SCAFFOLD_PLACEHOLDER", subject))
148 if (
149 author_start is not None
150 and editor_start is not None
151 and author_start < line_number - 1 < editor_start
152 and CONFIDENTIAL_MARKER_RE.search(line)
153 ):
154 errors.append(issue("EDITOR_ONLY_CONTENT_IN_AUTHOR_CHANNEL", subject))
156 blocks = _comment_blocks(lines)
157 if not blocks:
158 warnings.append(issue("NO_STRUCTURED_COMMENTS_FOUND", "document"))
159 comment_summaries: list[dict[str, Any]] = []
160 for block in blocks:
161 fields: dict[str, tuple[str, int]] = {}
162 for index in range(block["start"] + 1, block["end"]):
163 match = FIELD_RE.match(lines[index].strip())
164 if match:
165 fields[match.group("label").lower()] = (
166 match.group("value"),
167 index + 1,
168 )
169 missing_fields = sorted(REQUIRED_FIELDS - set(fields))
170 empty_fields = sorted(
171 label for label, (value, _) in fields.items() if not _content_value(value)
172 )
173 for label in missing_fields:
174 errors.append(
175 issue(
176 "ACTIONABILITY_FIELD_MISSING",
177 f"{block['id']}:{label.replace(' ', '_')}",
178 )
179 )
180 for label in empty_fields:
181 errors.append(
182 issue(
183 "ACTIONABILITY_FIELD_EMPTY",
184 f"{block['id']}:{label.replace(' ', '_')}",
185 )
186 )
187 if editor_start is not None and block["start"] > editor_start:
188 warnings.append(
189 issue("AUTHOR_COMMENT_BLOCK_IN_EDITOR_CHANNEL", block["id"])
190 )
191 comment_summaries.append(
192 {
193 "comment_id": block["id"],
194 "kind": block["kind"],
195 "heading_line": block["start"] + 1,
196 "missing_fields": missing_fields,
197 "empty_fields": empty_fields,
198 }
199 )
201 return {
202 "schema_version": "2.0",
203 "valid": not errors,
204 "status": "READY_FOR_HUMAN_REVIEW" if not errors else "REVISION_REQUIRED",
205 "errors": errors,
206 "warnings": warnings,
207 "line_count": len(lines),
208 "structured_comment_count": len(blocks),
209 "comments": comment_summaries,
210 "channel_separation": {
211 "author_heading_count": len(author_lines),
212 "editor_heading_count": len(editor_lines),
213 "author_before_editor": bool(
214 author_lines
215 and editor_lines
216 and author_lines[0] < editor_lines[0]
217 ),
218 },
219 "notice": (
220 "This deterministic lint uses structural and lexical rules. It does "
221 "not judge scientific validity, verify whether statements are true, "
222 "or replace accountable human review. Findings contain line numbers "
223 "and rule IDs, not review or manuscript text."
224 ),
225 }
228def build_parser() -> argparse.ArgumentParser:
229 parser = argparse.ArgumentParser(
230 description=(
231 "Lint a local structured review for author/editor channel separation, "
232 "professional tone, and actionable comment fields."
233 )
234 )
235 parser.add_argument("review", help="Local review Markdown")
236 parser.add_argument("-o", "--output", help="Optional local JSON report")
237 parser.add_argument(
238 "--force", action="store_true", help="Replace an existing output file"
239 )
240 return parser
243def main() -> int:
244 args = build_parser().parse_args()
245 try:
246 report = lint(read_markdown(args.review))
247 write_json_report(report, args.output, force=args.force)
248 return 0 if report["valid"] else 1
249 except ValidationError as exc:
250 return error_exit(exc)
253if __name__ == "__main__":
254 raise SystemExit(main())