psyclaw 0.28.1 → 0.28.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/agents/recommended/catalog.json +1 -1
- package/dist/src/adapters/pi/extension.js +79 -25
- package/dist/src/adapters/pi/extension.js.map +1 -1
- package/dist/src/ars/keybindings.d.ts +13 -0
- package/dist/src/ars/keybindings.js +50 -0
- package/dist/src/ars/keybindings.js.map +1 -0
- package/dist/src/ars/mode-editor.d.ts +25 -0
- package/dist/src/ars/mode-editor.js +78 -0
- package/dist/src/ars/mode-editor.js.map +1 -0
- package/dist/src/ars/profile.d.ts +4 -1
- package/dist/src/ars/profile.js +23 -6
- package/dist/src/ars/profile.js.map +1 -1
- package/dist/src/chat.js +3 -0
- package/dist/src/chat.js.map +1 -1
- package/dist/src/panel/server.js +1 -1
- package/dist/src/panel/server.js.map +1 -1
- package/package.json +10 -3
- package/vendor/academic-paper-skills/LICENSE +21 -0
- package/vendor/academic-paper-skills/NOTICE.md +7 -0
- package/vendor/academic-paper-skills/PSYCLAW_SOURCE.json +14 -0
- package/vendor/academic-paper-skills/academic-paper-composer/SKILL.md +840 -0
- package/vendor/academic-paper-skills/academic-paper-composer/references/section_guides.md +675 -0
- package/vendor/academic-paper-skills/academic-paper-composer/references/writing_standards.md +629 -0
- package/vendor/academic-paper-skills/academic-paper-composer/scripts/chapter_quality_check.py +470 -0
- package/vendor/academic-paper-skills/academic-paper-composer/scripts/final_evaluation.py +550 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/SKILL.md +670 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/references/quality_standards.md +336 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/references/search_strategy.md +459 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/scripts/evaluate_samples.py +300 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/scripts/gap_analysis.py +399 -0
- package/vendor/ars/pi/wrapper.js +9 -0
- package/vendor/nature-skills/LICENSE +201 -0
- package/vendor/nature-skills/NOTICE.md +7 -0
- package/vendor/nature-skills/PSYCLAW_SOURCE.json +19 -0
- package/vendor/nature-skills/skills/nature-figure/README.md +107 -0
- package/vendor/nature-skills/skills/nature-figure/README_EN.md +107 -0
- package/vendor/nature-skills/skills/nature-figure/SKILL.md +154 -0
- package/vendor/nature-skills/skills/nature-figure/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-figure/evals/evals.json +325 -0
- package/vendor/nature-skills/skills/nature-figure/manifest.yaml +103 -0
- package/vendor/nature-skills/skills/nature-figure/references/ai-graphical-abstract-workflow.md +129 -0
- package/vendor/nature-skills/skills/nature-figure/references/api.md +576 -0
- package/vendor/nature-skills/skills/nature-figure/references/asset-adaptation.md +72 -0
- package/vendor/nature-skills/skills/nature-figure/references/backend-selection.md +118 -0
- package/vendor/nature-skills/skills/nature-figure/references/chart-types.md +348 -0
- package/vendor/nature-skills/skills/nature-figure/references/common-patterns.md +371 -0
- package/vendor/nature-skills/skills/nature-figure/references/demos.md +58 -0
- package/vendor/nature-skills/skills/nature-figure/references/design-theory.md +467 -0
- package/vendor/nature-skills/skills/nature-figure/references/figure-contract.md +122 -0
- package/vendor/nature-skills/skills/nature-figure/references/figure-legend-conventions.md +90 -0
- package/vendor/nature-skills/skills/nature-figure/references/multipanel-evidence-architecture.md +268 -0
- package/vendor/nature-skills/skills/nature-figure/references/nature-2026-observations.md +124 -0
- package/vendor/nature-skills/skills/nature-figure/references/nature-article-requirements.md +149 -0
- package/vendor/nature-skills/skills/nature-figure/references/openrouter-image-generation.md +141 -0
- package/vendor/nature-skills/skills/nature-figure/references/qa-contract.md +344 -0
- package/vendor/nature-skills/skills/nature-figure/references/r-template-index.md +66 -0
- package/vendor/nature-skills/skills/nature-figure/references/r-workflow.md +234 -0
- package/vendor/nature-skills/skills/nature-figure/references/template-catalog.md +33 -0
- package/vendor/nature-skills/skills/nature-figure/references/tutorials.md +260 -0
- package/vendor/nature-skills/skills/nature-figure/requirements.txt +1 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_figure_collisions.py +742 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_panel_alignment.py +933 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_pdf_text.py +152 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/figure_safety.py +50 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/generate_openrouter_schematic.py +260 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/nature_figure_backend.py +93 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/panel_alignment.R +188 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/plot_templates.py +604 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/validate_figure.py +835 -0
- package/vendor/nature-skills/skills/nature-figure/static/core/contract.md +55 -0
- package/vendor/nature-skills/skills/nature-figure/static/core/stance.md +41 -0
- package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/python.md +56 -0
- package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/r.md +63 -0
- package/vendor/nature-skills/skills/nature-figure/tests/test_figure_safety.py +209 -0
- package/vendor/nature-skills/skills/nature-polishing/README.md +58 -0
- package/vendor/nature-skills/skills/nature-polishing/README_EN.md +58 -0
- package/vendor/nature-skills/skills/nature-polishing/SKILL.md +111 -0
- package/vendor/nature-skills/skills/nature-polishing/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-polishing/manifest.yaml +101 -0
- package/vendor/nature-skills/skills/nature-polishing/references/latex-layout.md +211 -0
- package/vendor/nature-skills/skills/nature-polishing/references/nat-comms-2025-diction.md +73 -0
- package/vendor/nature-skills/skills/nature-polishing/references/phrasebank-playbook.md +175 -0
- package/vendor/nature-skills/skills/nature-polishing/references/published-article-patterns.md +126 -0
- package/vendor/nature-skills/skills/nature-polishing/references/section-moves.md +252 -0
- package/vendor/nature-skills/skills/nature-polishing/references/style-guardrails.md +94 -0
- package/vendor/nature-skills/skills/nature-polishing/references/writing-strategy.md +160 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/failure-modes.md +29 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/output-format.md +21 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/stance.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/generic.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-comms.md +38 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-mach-intell.md +102 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nature.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/en.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/zh-to-en.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/algorithmic.md +18 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/hypothesis.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/methods.md +35 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/research.md +29 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/review.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/abstract.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/conclusion.md +15 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/discussion.md +48 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/intro.md +33 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/methods.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/results.md +61 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/title.md +17 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/README.md +44 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/README_EN.md +44 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/SKILL.md +196 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/manifest.yaml +19 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/references/common-patterns.md +96 -0
- package/vendor/nature-skills/skills/nature-shared/README.md +43 -0
- package/vendor/nature-skills/skills/nature-shared/README_EN.md +43 -0
- package/vendor/nature-skills/skills/nature-shared/SKILL.md +33 -0
- package/vendor/nature-skills/skills/nature-shared/agents/openai.yaml +6 -0
- package/vendor/nature-skills/skills/nature-shared/core/consistency-sweep.md +122 -0
- package/vendor/nature-skills/skills/nature-shared/core/discussion-argument-language.md +192 -0
- package/vendor/nature-skills/skills/nature-shared/core/ethics.md +88 -0
- package/vendor/nature-skills/skills/nature-shared/core/main-text-discipline.md +179 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-abstract.md +171 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-introduction.md +164 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-results-discussion.md +215 -0
- package/vendor/nature-skills/skills/nature-shared/core/paper-type-taxonomy.md +41 -0
- package/vendor/nature-skills/skills/nature-shared/core/reader-workflow.md +21 -0
- package/vendor/nature-skills/skills/nature-shared/core/research-compliance.md +192 -0
- package/vendor/nature-skills/skills/nature-shared/core/terminology-ledger.md +58 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nat-comms.md +139 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nature-machine-intelligence.md +431 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nature.md +313 -0
- package/vendor/nature-skills/skills/nature-shared/manifest.yaml +52 -0
- package/vendor/nature-skills/skills/nature-shared/scripts/check_consistency.py +273 -0
- package/vendor/nature-skills/skills/nature-shared/tests/test_check_consistency.py +63 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Static preflight for publication-figure Python and R source files.
|
|
3
|
+
|
|
4
|
+
The validator is intentionally dependency-free. It checks portable source-level
|
|
5
|
+
requirements before the selected backend renders the figure; it does not claim
|
|
6
|
+
to validate statistics or replace visual inspection.
|
|
7
|
+
|
|
8
|
+
The rule structure incorporates and refactors ideas from the Apache-2.0
|
|
9
|
+
academic-figure-skill QA workflow without retaining its path-bound runners.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import ast
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Iterable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
LEVEL_ORDER = {"PASS": 0, "WARN": 1, "FAIL": 2}
|
|
25
|
+
PYTHON_SUFFIXES = {".py"}
|
|
26
|
+
R_SUFFIXES = {".r", ".rmd", ".qmd"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Finding:
|
|
31
|
+
check_id: str
|
|
32
|
+
level: str
|
|
33
|
+
message: str
|
|
34
|
+
evidence: list[str]
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def passed(self) -> bool:
|
|
38
|
+
return self.level == "PASS"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def finding(check_id: str, level: str, message: str, evidence: Iterable[str] = ()) -> Finding:
|
|
42
|
+
if level not in LEVEL_ORDER:
|
|
43
|
+
raise ValueError(f"unknown finding level: {level}")
|
|
44
|
+
return Finding(check_id, level, message, list(evidence))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect_backend(path: Path, requested: str) -> str:
|
|
48
|
+
if requested != "auto":
|
|
49
|
+
return requested
|
|
50
|
+
suffix = path.suffix.lower()
|
|
51
|
+
if suffix in PYTHON_SUFFIXES:
|
|
52
|
+
return "python"
|
|
53
|
+
if suffix in R_SUFFIXES:
|
|
54
|
+
return "r"
|
|
55
|
+
raise ValueError(f"cannot infer backend from {path.name}; pass --backend python or --backend r")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def regex_hits(patterns: Iterable[str], source: str, flags: int = re.IGNORECASE) -> list[str]:
|
|
59
|
+
hits: list[str] = []
|
|
60
|
+
for pattern in patterns:
|
|
61
|
+
match = re.search(pattern, source, flags)
|
|
62
|
+
if match:
|
|
63
|
+
hits.append(match.group(0).strip())
|
|
64
|
+
return hits
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_syntax(source: str, backend: str) -> Finding:
|
|
68
|
+
if backend == "python":
|
|
69
|
+
try:
|
|
70
|
+
ast.parse(source)
|
|
71
|
+
except SyntaxError as exc:
|
|
72
|
+
location = f"line {exc.lineno}" if exc.lineno else "unknown line"
|
|
73
|
+
return finding("SOURCE-SYNTAX", "FAIL", f"Python syntax error at {location}: {exc.msg}")
|
|
74
|
+
return finding("SOURCE-SYNTAX", "PASS", "Python source parses successfully")
|
|
75
|
+
|
|
76
|
+
pairs = {"(": ")", "[": "]", "{": "}"}
|
|
77
|
+
stack: list[str] = []
|
|
78
|
+
quote: str | None = None
|
|
79
|
+
escaped = False
|
|
80
|
+
comment = False
|
|
81
|
+
for char in source:
|
|
82
|
+
if comment:
|
|
83
|
+
if char == "\n":
|
|
84
|
+
comment = False
|
|
85
|
+
continue
|
|
86
|
+
if escaped:
|
|
87
|
+
escaped = False
|
|
88
|
+
continue
|
|
89
|
+
if char == "\\":
|
|
90
|
+
escaped = True
|
|
91
|
+
continue
|
|
92
|
+
if quote:
|
|
93
|
+
if char == quote:
|
|
94
|
+
quote = None
|
|
95
|
+
continue
|
|
96
|
+
if char == "#":
|
|
97
|
+
comment = True
|
|
98
|
+
elif char in {'"', "'"}:
|
|
99
|
+
quote = char
|
|
100
|
+
elif char in pairs:
|
|
101
|
+
stack.append(char)
|
|
102
|
+
elif char in pairs.values():
|
|
103
|
+
if not stack or pairs[stack.pop()] != char:
|
|
104
|
+
return finding("SOURCE-SYNTAX", "FAIL", "R source has unbalanced brackets")
|
|
105
|
+
if stack or quote:
|
|
106
|
+
return finding("SOURCE-SYNTAX", "FAIL", "R source has unbalanced brackets or quotes")
|
|
107
|
+
return finding(
|
|
108
|
+
"SOURCE-SYNTAX",
|
|
109
|
+
"WARN",
|
|
110
|
+
"Basic R delimiter check passed; run Rscript parse() when R is available",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_font_family(source: str, backend: str) -> Finding:
|
|
115
|
+
families = regex_hits(
|
|
116
|
+
[r"Arial", r"Helvetica", r"Liberation Sans", r"sans-serif", r"base_family\s*=\s*['\"]sans['\"]"],
|
|
117
|
+
source,
|
|
118
|
+
)
|
|
119
|
+
if families:
|
|
120
|
+
return finding("FONT-FAMILY", "PASS", "A publication-safe sans-serif family is configured", families)
|
|
121
|
+
label = "matplotlib rcParams" if backend == "python" else "ggplot/theme or graphics device"
|
|
122
|
+
return finding("FONT-FAMILY", "FAIL", f"No explicit publication-safe font family found in {label}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def explicit_font_sizes(source: str) -> list[float]:
|
|
126
|
+
patterns = [
|
|
127
|
+
r"(?:font\.size|fontsize|labelsize|titlesize|legend\.fontsize)\s*['\"]?\s*[:=]\s*(\d+(?:\.\d+)?)",
|
|
128
|
+
r"base_size\s*=\s*(\d+(?:\.\d+)?)",
|
|
129
|
+
r"element_text\s*\([^)]*?size\s*=\s*(\d+(?:\.\d+)?)",
|
|
130
|
+
]
|
|
131
|
+
values: list[float] = []
|
|
132
|
+
for pattern in patterns:
|
|
133
|
+
values.extend(float(value) for value in re.findall(pattern, source, re.IGNORECASE | re.DOTALL))
|
|
134
|
+
return values
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_font_sizes(source: str, _backend: str) -> Finding:
|
|
138
|
+
sizes = explicit_font_sizes(source)
|
|
139
|
+
if not sizes:
|
|
140
|
+
return finding("FONT-SIZE", "WARN", "No explicit text sizes found; verify readability at final physical size")
|
|
141
|
+
minimum = min(sizes)
|
|
142
|
+
if minimum < 5:
|
|
143
|
+
return finding("FONT-SIZE", "FAIL", f"Explicit text size falls below the 5 pt floor: {minimum:g} pt")
|
|
144
|
+
return finding("FONT-SIZE", "PASS", f"All detected text sizes are at least 5 pt (minimum {minimum:g} pt)")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_mathtext_glyph_floor(source: str, backend: str) -> Finding:
|
|
148
|
+
if backend != "python":
|
|
149
|
+
return finding("FONT-GLYPH-FLOOR", "PASS", "Python mathtext superscript scaling is not applicable")
|
|
150
|
+
hits = regex_hits([r"\$[^$\n]*[\^_](?:\{[^}]+\}|[^$\s])[^$\n]*\$"], source, flags=0)
|
|
151
|
+
if not hits:
|
|
152
|
+
return finding("FONT-GLYPH-FLOOR", "PASS", "No mathtext superscript/subscript expression detected")
|
|
153
|
+
sizes = explicit_font_sizes(source)
|
|
154
|
+
if not sizes:
|
|
155
|
+
return finding(
|
|
156
|
+
"FONT-GLYPH-FLOOR",
|
|
157
|
+
"WARN",
|
|
158
|
+
"Mathtext superscript/subscript detected without an auditable parent font size; inspect the exported PDF",
|
|
159
|
+
hits,
|
|
160
|
+
)
|
|
161
|
+
minimum_parent = min(sizes)
|
|
162
|
+
estimated_script = minimum_parent * 0.7
|
|
163
|
+
if estimated_script < 5:
|
|
164
|
+
return finding(
|
|
165
|
+
"FONT-GLYPH-FLOOR",
|
|
166
|
+
"WARN",
|
|
167
|
+
f"Mathtext may shrink script glyphs below 5 pt (about {estimated_script:.2f} pt from a {minimum_parent:g} pt parent); use a Unicode glyph or audit PDF Tf operators",
|
|
168
|
+
hits,
|
|
169
|
+
)
|
|
170
|
+
return finding(
|
|
171
|
+
"FONT-GLYPH-FLOOR",
|
|
172
|
+
"PASS",
|
|
173
|
+
f"Detected mathtext parent sizes imply script glyphs of about {estimated_script:.2f} pt or larger; confirm in the PDF",
|
|
174
|
+
hits,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def literal_legend_labels(source: str, backend: str) -> list[str]:
|
|
179
|
+
if backend == "python":
|
|
180
|
+
try:
|
|
181
|
+
tree = ast.parse(source)
|
|
182
|
+
except SyntaxError:
|
|
183
|
+
return []
|
|
184
|
+
labels: list[str] = []
|
|
185
|
+
for node in ast.walk(tree):
|
|
186
|
+
if not isinstance(node, ast.Call):
|
|
187
|
+
continue
|
|
188
|
+
call_name = ""
|
|
189
|
+
if isinstance(node.func, ast.Attribute):
|
|
190
|
+
call_name = node.func.attr
|
|
191
|
+
elif isinstance(node.func, ast.Name):
|
|
192
|
+
call_name = node.func.id
|
|
193
|
+
label_calls = {
|
|
194
|
+
"plot",
|
|
195
|
+
"bar",
|
|
196
|
+
"barh",
|
|
197
|
+
"scatter",
|
|
198
|
+
"errorbar",
|
|
199
|
+
"fill_between",
|
|
200
|
+
"hist",
|
|
201
|
+
"axhline",
|
|
202
|
+
"axvline",
|
|
203
|
+
"legend",
|
|
204
|
+
}
|
|
205
|
+
for keyword in node.keywords:
|
|
206
|
+
if (
|
|
207
|
+
call_name in label_calls
|
|
208
|
+
and keyword.arg == "label"
|
|
209
|
+
and isinstance(keyword.value, ast.Constant)
|
|
210
|
+
and isinstance(keyword.value.value, str)
|
|
211
|
+
):
|
|
212
|
+
labels.append(keyword.value.value)
|
|
213
|
+
elif call_name == "legend" and keyword.arg == "labels" and isinstance(keyword.value, (ast.List, ast.Tuple)):
|
|
214
|
+
labels.extend(
|
|
215
|
+
item.value
|
|
216
|
+
for item in keyword.value.elts
|
|
217
|
+
if isinstance(item, ast.Constant) and isinstance(item.value, str)
|
|
218
|
+
)
|
|
219
|
+
return labels
|
|
220
|
+
|
|
221
|
+
labels: list[str] = []
|
|
222
|
+
for content in re.findall(r"labels\s*=\s*c\(([^)]*)\)", source, re.IGNORECASE | re.DOTALL):
|
|
223
|
+
labels.extend(re.findall(r"['\"]([^'\"]+)['\"]", content))
|
|
224
|
+
return labels
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def check_legend_label_case(source: str, backend: str) -> Finding:
|
|
228
|
+
labels = [label for label in literal_legend_labels(source, backend) if label and label != "_nolegend_"]
|
|
229
|
+
lower_initial = []
|
|
230
|
+
for label in labels:
|
|
231
|
+
first_alpha = re.search(r"[A-Za-z]", re.sub(r"^\s*\+\s*", "", label))
|
|
232
|
+
if first_alpha and first_alpha.group(0).islower():
|
|
233
|
+
lower_initial.append(label)
|
|
234
|
+
if lower_initial:
|
|
235
|
+
return finding(
|
|
236
|
+
"LEGEND-LABEL-CASE",
|
|
237
|
+
"WARN",
|
|
238
|
+
"Literal legend labels should start with display-style capitalization while preserving canonical model names",
|
|
239
|
+
lower_initial,
|
|
240
|
+
)
|
|
241
|
+
if labels:
|
|
242
|
+
return finding("LEGEND-LABEL-CASE", "PASS", "Detected literal legend labels start with display-style capitalization")
|
|
243
|
+
return finding("LEGEND-LABEL-CASE", "PASS", "No auditable literal legend labels detected")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def check_interpolation_monotonicity(source: str, backend: str) -> Finding:
|
|
247
|
+
if backend != "python" or not re.search(r"\bnp\.interp\s*\(", source):
|
|
248
|
+
return finding("INTERP-MONOTONIC", "PASS", "No NumPy interpolation requiring an increasing x-grid detected")
|
|
249
|
+
guards = regex_hits(
|
|
250
|
+
[
|
|
251
|
+
r"interp_(?:need_dec|monotone|strict)",
|
|
252
|
+
r"np\.diff\s*\([^)]*\)\s*[><]=?\s*0",
|
|
253
|
+
r"np\.argsort\s*\(",
|
|
254
|
+
r"\[\s*::\s*-1\s*\]",
|
|
255
|
+
],
|
|
256
|
+
source,
|
|
257
|
+
)
|
|
258
|
+
if guards:
|
|
259
|
+
return finding(
|
|
260
|
+
"INTERP-MONOTONIC",
|
|
261
|
+
"PASS",
|
|
262
|
+
"NumPy interpolation has an explicit monotonicity/reordering safeguard",
|
|
263
|
+
guards,
|
|
264
|
+
)
|
|
265
|
+
return finding(
|
|
266
|
+
"INTERP-MONOTONIC",
|
|
267
|
+
"WARN",
|
|
268
|
+
"np.interp requires an increasing xp grid and can silently return plausible wrong values for decreasing curves; assert monotonicity or reverse/sort xp and fp together",
|
|
269
|
+
["np.interp("],
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def check_uncertainty_encoding(source: str, _backend: str) -> Finding:
|
|
274
|
+
stochastic = regex_hits(
|
|
275
|
+
[
|
|
276
|
+
r"\bseed(?:s|_[A-Za-z0-9_]+)?\b",
|
|
277
|
+
r"random_state",
|
|
278
|
+
r"\b(?:cv|cross_validation|validation)_folds?\b",
|
|
279
|
+
r"\bfold_(?:id|index|score|scores|metric|metrics|mean|median)\b",
|
|
280
|
+
r"\bn_folds?\b",
|
|
281
|
+
r"\bn_splits\b",
|
|
282
|
+
r"\b(?:train|test|validation|data|random)_split\b",
|
|
283
|
+
],
|
|
284
|
+
source,
|
|
285
|
+
)
|
|
286
|
+
summaries = regex_hits([r"\bmedian\b", r"\bmean\b", r"\baverage\b"], source)
|
|
287
|
+
if not stochastic or not summaries:
|
|
288
|
+
return finding("UNCERTAINTY-ENCODING", "PASS", "No stochastic aggregate requiring a variability audit was inferred")
|
|
289
|
+
encodings = regex_hits(
|
|
290
|
+
[
|
|
291
|
+
r"\b(?:xerr|yerr)\s*=",
|
|
292
|
+
r"\.errorbar\s*\(",
|
|
293
|
+
r"\.fill_between\s*\(",
|
|
294
|
+
r"geom_(?:errorbar|ribbon|linerange|pointrange)\s*\(",
|
|
295
|
+
],
|
|
296
|
+
source,
|
|
297
|
+
)
|
|
298
|
+
if encodings:
|
|
299
|
+
return finding(
|
|
300
|
+
"UNCERTAINTY-ENCODING",
|
|
301
|
+
"PASS",
|
|
302
|
+
"A stochastic aggregate and uncertainty encoding are both present; still verify every comparable panel",
|
|
303
|
+
encodings,
|
|
304
|
+
)
|
|
305
|
+
return finding(
|
|
306
|
+
"UNCERTAINTY-ENCODING",
|
|
307
|
+
"WARN",
|
|
308
|
+
"Seed/fold/split aggregates are present without an obvious error bar or uncertainty band",
|
|
309
|
+
[*stochastic, *summaries],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def check_rotation_anchor(source: str, backend: str) -> Finding:
|
|
314
|
+
if backend != "python":
|
|
315
|
+
return finding("ROTATION-ANCHOR", "PASS", "Matplotlib rotation anchoring is not applicable")
|
|
316
|
+
try:
|
|
317
|
+
tree = ast.parse(source)
|
|
318
|
+
except SyntaxError:
|
|
319
|
+
return finding("ROTATION-ANCHOR", "WARN", "Rotation anchoring could not be audited until Python syntax is valid")
|
|
320
|
+
|
|
321
|
+
anchored_receivers: set[str] = set()
|
|
322
|
+
for node in ast.walk(tree):
|
|
323
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
324
|
+
continue
|
|
325
|
+
if node.func.attr != "set_rotation_mode" or not node.args:
|
|
326
|
+
continue
|
|
327
|
+
argument = node.args[0]
|
|
328
|
+
if isinstance(argument, ast.Constant) and argument.value == "anchor":
|
|
329
|
+
anchored_receivers.add(ast.dump(node.func.value, include_attributes=False))
|
|
330
|
+
|
|
331
|
+
rotated: list[str] = []
|
|
332
|
+
unanchored: list[str] = []
|
|
333
|
+
for node in ast.walk(tree):
|
|
334
|
+
if not isinstance(node, ast.Call):
|
|
335
|
+
continue
|
|
336
|
+
line = getattr(node, "lineno", "?")
|
|
337
|
+
rotation_keyword = next((keyword for keyword in node.keywords if keyword.arg == "rotation"), None)
|
|
338
|
+
if rotation_keyword is not None:
|
|
339
|
+
evidence = f"line {line}: rotation=..."
|
|
340
|
+
rotated.append(evidence)
|
|
341
|
+
mode_keyword = next((keyword for keyword in node.keywords if keyword.arg == "rotation_mode"), None)
|
|
342
|
+
anchored = (
|
|
343
|
+
mode_keyword is not None
|
|
344
|
+
and isinstance(mode_keyword.value, ast.Constant)
|
|
345
|
+
and mode_keyword.value.value == "anchor"
|
|
346
|
+
)
|
|
347
|
+
if not anchored:
|
|
348
|
+
unanchored.append(evidence)
|
|
349
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "set_rotation":
|
|
350
|
+
evidence = f"line {line}: set_rotation(...)"
|
|
351
|
+
rotated.append(evidence)
|
|
352
|
+
receiver = ast.dump(node.func.value, include_attributes=False)
|
|
353
|
+
if receiver not in anchored_receivers:
|
|
354
|
+
unanchored.append(evidence)
|
|
355
|
+
|
|
356
|
+
if not rotated:
|
|
357
|
+
return finding("ROTATION-ANCHOR", "PASS", "No rotated Matplotlib text detected")
|
|
358
|
+
if not unanchored:
|
|
359
|
+
return finding("ROTATION-ANCHOR", "PASS", "Every detected rotated text call uses anchor-based placement", rotated)
|
|
360
|
+
return finding(
|
|
361
|
+
"ROTATION-ANCHOR",
|
|
362
|
+
"WARN",
|
|
363
|
+
"Some rotated text calls lack rotation_mode='anchor' and may cross their intended anchors after rotation",
|
|
364
|
+
unanchored,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def check_annotation_workarounds(source: str, backend: str) -> Finding:
|
|
369
|
+
if backend != "python":
|
|
370
|
+
return finding("ANNOTATION-WORKAROUND", "PASS", "No Matplotlib annotation workaround audit required")
|
|
371
|
+
hits = regex_hits(
|
|
372
|
+
[
|
|
373
|
+
r"(?:LABEL|ANNOTATION)_Y\s*=\s*[-+]?\d+(?:\.\d+)?",
|
|
374
|
+
r"bbox\s*=\s*dict\s*\([^)]*(?:facecolor|fc)\s*=\s*['\"](?:white|#fff(?:fff)?)['\"]",
|
|
375
|
+
],
|
|
376
|
+
source,
|
|
377
|
+
)
|
|
378
|
+
if hits:
|
|
379
|
+
return finding(
|
|
380
|
+
"ANNOTATION-WORKAROUND",
|
|
381
|
+
"WARN",
|
|
382
|
+
"Hard-coded annotation heights or opaque white text masks can collide with uncertainty or cut visible gaps in curves; position from rendered/data bounds instead",
|
|
383
|
+
hits,
|
|
384
|
+
)
|
|
385
|
+
return finding("ANNOTATION-WORKAROUND", "PASS", "No high-risk hard-coded label height or opaque white text mask detected")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def check_colormaps(source: str, _backend: str) -> Finding:
|
|
389
|
+
hits = regex_hits(
|
|
390
|
+
[
|
|
391
|
+
r"(?:cmap|palette|colormap)\s*=\s*['\"](?:jet|rainbow|hsv)['\"]",
|
|
392
|
+
r"plt\.cm\.(?:jet|rainbow|hsv)\b",
|
|
393
|
+
r"scale_(?:color|fill)_(?:gradientn|distiller)\s*\([^)]*(?:rainbow|jet|hsv)",
|
|
394
|
+
r"\brainbow\s*\(",
|
|
395
|
+
],
|
|
396
|
+
source,
|
|
397
|
+
)
|
|
398
|
+
if hits:
|
|
399
|
+
return finding("COLOR-MAP", "FAIL", "Rainbow/jet/hsv color mapping is not publication-safe", hits)
|
|
400
|
+
return finding("COLOR-MAP", "PASS", "No rainbow/jet/hsv color mapping detected")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def check_editable_text(source: str, backend: str) -> Finding:
|
|
404
|
+
if backend == "python":
|
|
405
|
+
has_svg = bool(re.search(r"svg\.fonttype['\"]?\s*[:=]\s*['\"]none['\"]", source, re.IGNORECASE))
|
|
406
|
+
has_pdf = bool(re.search(r"pdf\.fonttype['\"]?\s*[:=]\s*42\b", source, re.IGNORECASE))
|
|
407
|
+
if has_svg and has_pdf:
|
|
408
|
+
return finding("EDITABLE-TEXT", "PASS", "SVG and PDF editable-text settings are configured")
|
|
409
|
+
missing = []
|
|
410
|
+
if not has_svg:
|
|
411
|
+
missing.append("svg.fonttype='none'")
|
|
412
|
+
if not has_pdf:
|
|
413
|
+
missing.append("pdf.fonttype=42")
|
|
414
|
+
return finding("EDITABLE-TEXT", "FAIL", "Missing editable-text settings", missing)
|
|
415
|
+
|
|
416
|
+
has_svg = bool(re.search(r"(?:svglite::)?svglite\s*\(", source))
|
|
417
|
+
has_pdf = bool(re.search(r"(?:grDevices::)?cairo_pdf\s*\(", source))
|
|
418
|
+
if has_svg and has_pdf:
|
|
419
|
+
return finding("EDITABLE-TEXT", "PASS", "svglite and cairo_pdf editable-text devices are configured")
|
|
420
|
+
missing = []
|
|
421
|
+
if not has_svg:
|
|
422
|
+
missing.append("svglite")
|
|
423
|
+
if not has_pdf:
|
|
424
|
+
missing.append("cairo_pdf")
|
|
425
|
+
return finding("EDITABLE-TEXT", "FAIL", "Missing preferred editable-vector devices", missing)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def check_vector_exports(source: str, _backend: str) -> Finding:
|
|
429
|
+
has_svg = bool(re.search(r"\.svg\b|svglite\s*\(", source, re.IGNORECASE))
|
|
430
|
+
has_pdf = bool(re.search(r"\.pdf\b|cairo_pdf\s*\(|\bpdf\s*\(", source, re.IGNORECASE))
|
|
431
|
+
if has_svg and has_pdf:
|
|
432
|
+
return finding("EXPORT-VECTOR", "PASS", "Both SVG and PDF exports are present")
|
|
433
|
+
missing = [name for name, present in (("SVG", has_svg), ("PDF", has_pdf)) if not present]
|
|
434
|
+
return finding("EXPORT-VECTOR", "FAIL", f"Missing required vector export: {', '.join(missing)}")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def check_raster_exports(source: str, _backend: str) -> Finding:
|
|
438
|
+
has_tiff = bool(re.search(r"\.tiff?\b|agg_tiff\s*\(|\btiff\s*\(", source, re.IGNORECASE))
|
|
439
|
+
has_png = bool(re.search(r"\.png\b|agg_png\s*\(|\bpng\s*\(", source, re.IGNORECASE))
|
|
440
|
+
if has_tiff:
|
|
441
|
+
return finding("EXPORT-RASTER", "PASS", "TIFF raster export is present")
|
|
442
|
+
if has_png:
|
|
443
|
+
return finding("EXPORT-RASTER", "WARN", "PNG preview is present but no TIFF submission raster was found")
|
|
444
|
+
return finding("EXPORT-RASTER", "FAIL", "No TIFF or PNG raster export found")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def check_resolution(source: str, _backend: str) -> Finding:
|
|
448
|
+
values = [
|
|
449
|
+
int(value)
|
|
450
|
+
for value in re.findall(r"(?:dpi|res)\s*[:=]\s*(\d+)", source, re.IGNORECASE)
|
|
451
|
+
]
|
|
452
|
+
if not values:
|
|
453
|
+
return finding("RASTER-DPI", "WARN", "No explicit raster DPI/resolution found")
|
|
454
|
+
below_minimum = sorted({value for value in values if value < 300})
|
|
455
|
+
if below_minimum:
|
|
456
|
+
return finding("RASTER-DPI", "FAIL", "Raster resolution below 300 dpi", [str(v) for v in below_minimum])
|
|
457
|
+
if max(values) < 600:
|
|
458
|
+
return finding("RASTER-DPI", "WARN", "Raster export meets the 300 dpi floor but not the 600 dpi default", [str(max(values))])
|
|
459
|
+
return finding("RASTER-DPI", "PASS", "A raster export of at least 600 dpi is configured", [str(max(values))])
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def candidate_widths_mm(source: str, backend: str) -> list[float]:
|
|
463
|
+
widths: list[float] = []
|
|
464
|
+
for value in re.findall(r"(?:fig_)?width_mm\s*[:=]\s*(\d+(?:\.\d+)?)", source, re.IGNORECASE):
|
|
465
|
+
widths.append(float(value))
|
|
466
|
+
if backend == "python":
|
|
467
|
+
for value in re.findall(r"figsize\s*=\s*\(\s*(\d+(?:\.\d+)?)", source, re.IGNORECASE):
|
|
468
|
+
widths.append(float(value) * 25.4)
|
|
469
|
+
else:
|
|
470
|
+
mm_calls = re.findall(
|
|
471
|
+
r"(?:ggsave|agg_tiff|svglite|cairo_pdf)\s*\([^)]*?width\s*=\s*(\d+(?:\.\d+)?)[^)]*?units\s*=\s*['\"]mm['\"]",
|
|
472
|
+
source,
|
|
473
|
+
re.IGNORECASE | re.DOTALL,
|
|
474
|
+
)
|
|
475
|
+
widths.extend(float(value) for value in mm_calls)
|
|
476
|
+
return widths
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def check_dimensions(source: str, backend: str) -> Finding:
|
|
480
|
+
widths = candidate_widths_mm(source, backend)
|
|
481
|
+
if not widths:
|
|
482
|
+
return finding("FINAL-WIDTH", "WARN", "No static final width detected; verify the target journal's current specification")
|
|
483
|
+
width = widths[0]
|
|
484
|
+
if abs(width - 89) <= 4 or abs(width - 183) <= 6:
|
|
485
|
+
return finding("FINAL-WIDTH", "PASS", f"Detected width {width:.1f} mm matches a common journal column width")
|
|
486
|
+
return finding(
|
|
487
|
+
"FINAL-WIDTH",
|
|
488
|
+
"WARN",
|
|
489
|
+
f"Detected width {width:.1f} mm is not near the common 89/183 mm defaults; verify the target journal",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def check_sampling(source: str, _backend: str) -> Finding:
|
|
494
|
+
hits = regex_hits(
|
|
495
|
+
[
|
|
496
|
+
r"np\.random\.choice\s*\(",
|
|
497
|
+
r"\.sample\s*\(",
|
|
498
|
+
r"\bsample_n\s*\(",
|
|
499
|
+
r"\bsample_frac\s*\(",
|
|
500
|
+
r"\bslice_sample\s*\(",
|
|
501
|
+
],
|
|
502
|
+
source,
|
|
503
|
+
)
|
|
504
|
+
if not hits:
|
|
505
|
+
return finding("DATA-SAMPLING", "PASS", "No high-confidence row-sampling operation detected")
|
|
506
|
+
documented = bool(re.search(r"sampling_(?:reason|rationale)|sampling\s+rationale|sample_size|random_state|set\.seed", source, re.IGNORECASE))
|
|
507
|
+
if documented:
|
|
508
|
+
return finding("DATA-SAMPLING", "WARN", "Sampling is present; confirm it is user-requested or scientifically justified and report its effect", hits)
|
|
509
|
+
return finding("DATA-SAMPLING", "WARN", "Potential silent sampling detected; do not reduce data for rendering convenience", hits)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def check_exclusions(source: str, _backend: str) -> Finding:
|
|
513
|
+
hits = regex_hits(
|
|
514
|
+
[r"\.dropna\s*\(", r"\bna\.omit\s*\(", r"\bcomplete\.cases\s*\(", r"drop_na\s*\("],
|
|
515
|
+
source,
|
|
516
|
+
)
|
|
517
|
+
if not hits:
|
|
518
|
+
return finding("DATA-EXCLUSION", "PASS", "No high-confidence missing-data exclusion detected")
|
|
519
|
+
reported = bool(re.search(r"n_before|n_after|before_count|after_count|excluded_count|dropped_count", source, re.IGNORECASE))
|
|
520
|
+
if reported:
|
|
521
|
+
return finding("DATA-EXCLUSION", "PASS", "Missing-data exclusion includes count-tracking markers", hits)
|
|
522
|
+
return finding("DATA-EXCLUSION", "WARN", "Missing-data exclusion found without explicit before/after count tracking", hits)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def check_demo_data(source: str, _backend: str) -> Finding:
|
|
526
|
+
hits = regex_hits(
|
|
527
|
+
[
|
|
528
|
+
r"np\.random\.(?:normal|uniform|rand|randn|poisson)\s*\(",
|
|
529
|
+
r"(?:np\.random\.)?default_rng\s*\(",
|
|
530
|
+
r"\brng\.(?:normal|uniform|poisson|choice)\s*\(",
|
|
531
|
+
r"\b(?:rnorm|runif|rpois)\s*\(",
|
|
532
|
+
r"make_(?:classification|regression|blobs)\s*\(",
|
|
533
|
+
],
|
|
534
|
+
source,
|
|
535
|
+
)
|
|
536
|
+
if not hits:
|
|
537
|
+
return finding("DEMO-DATA", "PASS", "No obvious simulated-data generator detected")
|
|
538
|
+
isolated = bool(re.search(r"--demo|demo_mode|if\s*\([^)]*demo|if\s+.*demo", source, re.IGNORECASE))
|
|
539
|
+
if isolated:
|
|
540
|
+
return finding("DEMO-DATA", "WARN", "Simulated data is present behind an apparent demo path; keep it out of production runs", hits)
|
|
541
|
+
return finding("DEMO-DATA", "WARN", "Simulated data appears in the plotting source without an explicit demo boundary", hits)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def check_log_guards(source: str, _backend: str) -> Finding:
|
|
545
|
+
log_hits = regex_hits(
|
|
546
|
+
[r"\bnp\.log(?:2|10)?\s*\(", r"\blog(?:2|10)?\s*\(", r"set_[xy]scale\s*\(\s*['\"]log", r"scale_[xy]_log10\s*\("],
|
|
547
|
+
source,
|
|
548
|
+
)
|
|
549
|
+
if not log_hits:
|
|
550
|
+
return finding("LOG-GUARD", "PASS", "No logarithmic transform or axis detected")
|
|
551
|
+
guards = regex_hits(
|
|
552
|
+
[
|
|
553
|
+
r"clip\s*\([^)]*lower\s*=",
|
|
554
|
+
r"pmax\s*\(",
|
|
555
|
+
r"not\s+0\s*<[^\n]{0,80}",
|
|
556
|
+
r"np\.any\s*\([^)]*<=\s*0",
|
|
557
|
+
r"(?:padj|pval|p_value|pvalue)[^\n]{0,50}<=\s*0",
|
|
558
|
+
r"non.?positive",
|
|
559
|
+
r"strictly\s+positive|pseudocount|epsilon|eps\b",
|
|
560
|
+
],
|
|
561
|
+
source,
|
|
562
|
+
)
|
|
563
|
+
if guards:
|
|
564
|
+
return finding("LOG-GUARD", "PASS", "A logarithmic transform/axis and a positivity or pseudocount guard were both detected", guards)
|
|
565
|
+
return finding("LOG-GUARD", "WARN", "Logarithmic operation found without an obvious positivity/pseudocount guard", log_hits)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _python_multipanel_signals(source: str) -> list[str]:
|
|
569
|
+
try:
|
|
570
|
+
tree = ast.parse(source)
|
|
571
|
+
except SyntaxError:
|
|
572
|
+
return []
|
|
573
|
+
signals: list[str] = []
|
|
574
|
+
repeated_axis_calls = {name: 0 for name in ("add_subplot", "add_axes", "subplot", "subplot2grid")}
|
|
575
|
+
for node in ast.walk(tree):
|
|
576
|
+
if not isinstance(node, ast.Call):
|
|
577
|
+
continue
|
|
578
|
+
if isinstance(node.func, ast.Attribute):
|
|
579
|
+
name = node.func.attr
|
|
580
|
+
elif isinstance(node.func, ast.Name):
|
|
581
|
+
name = node.func.id
|
|
582
|
+
else:
|
|
583
|
+
name = ""
|
|
584
|
+
if name in repeated_axis_calls:
|
|
585
|
+
repeated_axis_calls[name] += 1
|
|
586
|
+
if name == "subplot_mosaic":
|
|
587
|
+
signals.append("subplot_mosaic(...)")
|
|
588
|
+
if name not in {"subplots", "GridSpec", "add_gridspec"}:
|
|
589
|
+
continue
|
|
590
|
+
values: dict[str, int] = {}
|
|
591
|
+
for index, argument in enumerate(node.args[:2]):
|
|
592
|
+
if isinstance(argument, ast.Constant) and isinstance(argument.value, int):
|
|
593
|
+
values["nrows" if index == 0 else "ncols"] = int(argument.value)
|
|
594
|
+
for keyword in node.keywords:
|
|
595
|
+
if keyword.arg in {"nrows", "ncols"} and isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, int):
|
|
596
|
+
values[keyword.arg] = int(keyword.value.value)
|
|
597
|
+
if values.get("nrows", 1) * values.get("ncols", 1) >= 2:
|
|
598
|
+
signals.append(f"{name}({values.get('nrows', 1)}, {values.get('ncols', 1)})")
|
|
599
|
+
for name, count in repeated_axis_calls.items():
|
|
600
|
+
if count >= 2:
|
|
601
|
+
signals.append(f"{count} {name} calls")
|
|
602
|
+
return signals
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def check_panel_alignment_gate(source: str, backend: str) -> Finding:
|
|
606
|
+
if backend == "python":
|
|
607
|
+
signals = _python_multipanel_signals(source)
|
|
608
|
+
gate = regex_hits(
|
|
609
|
+
[r"require_matplotlib_panel_alignment\s*\(", r"audit_layout_manifest\s*\("],
|
|
610
|
+
source,
|
|
611
|
+
)
|
|
612
|
+
else:
|
|
613
|
+
signals = regex_hits(
|
|
614
|
+
[
|
|
615
|
+
r"plot_layout\s*\(",
|
|
616
|
+
r"wrap_plots\s*\(",
|
|
617
|
+
r"patchworkGrob\s*\(",
|
|
618
|
+
r"[A-Za-z][A-Za-z0-9_.]*\s*\|\s*[A-Za-z][A-Za-z0-9_.]*",
|
|
619
|
+
],
|
|
620
|
+
source,
|
|
621
|
+
)
|
|
622
|
+
gate = regex_hits(
|
|
623
|
+
[r"require_patchwork_panel_alignment\s*\(", r"audit_panel_alignment\.py"],
|
|
624
|
+
source,
|
|
625
|
+
)
|
|
626
|
+
if signals and not gate:
|
|
627
|
+
return finding(
|
|
628
|
+
"PANEL-ALIGNMENT-GATE",
|
|
629
|
+
"FAIL",
|
|
630
|
+
"Multi-panel layout detected without the mandatory render-time panel-alignment gate",
|
|
631
|
+
signals,
|
|
632
|
+
)
|
|
633
|
+
if gate:
|
|
634
|
+
return finding(
|
|
635
|
+
"PANEL-ALIGNMENT-GATE",
|
|
636
|
+
"PASS",
|
|
637
|
+
"Render-time panel-alignment gate is wired into the plotting source",
|
|
638
|
+
gate,
|
|
639
|
+
)
|
|
640
|
+
return finding("PANEL-ALIGNMENT-GATE", "PASS", "No statically auditable multi-panel construct detected")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def check_backend_exclusivity(source: str, backend: str) -> Finding:
|
|
644
|
+
if backend == "python":
|
|
645
|
+
hits = regex_hits([r"\bRscript\b", r"\brpy2\b", r"ggplot2|ComplexHeatmap|patchwork"], source)
|
|
646
|
+
else:
|
|
647
|
+
hits = regex_hits([r"matplotlib|seaborn|plotly|reticulate::py_run"], source)
|
|
648
|
+
if hits:
|
|
649
|
+
return finding("BACKEND-EXCLUSIVE", "WARN", f"Possible cross-backend plotting reference found in {backend} source", hits)
|
|
650
|
+
return finding("BACKEND-EXCLUSIVE", "PASS", f"No obvious cross-backend plotting reference found for {backend}")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
CHECKS: tuple[Callable[[str, str], Finding], ...] = (
|
|
654
|
+
check_syntax,
|
|
655
|
+
check_font_family,
|
|
656
|
+
check_font_sizes,
|
|
657
|
+
check_mathtext_glyph_floor,
|
|
658
|
+
check_legend_label_case,
|
|
659
|
+
check_colormaps,
|
|
660
|
+
check_editable_text,
|
|
661
|
+
check_vector_exports,
|
|
662
|
+
check_raster_exports,
|
|
663
|
+
check_resolution,
|
|
664
|
+
check_dimensions,
|
|
665
|
+
check_sampling,
|
|
666
|
+
check_exclusions,
|
|
667
|
+
check_demo_data,
|
|
668
|
+
check_log_guards,
|
|
669
|
+
check_interpolation_monotonicity,
|
|
670
|
+
check_uncertainty_encoding,
|
|
671
|
+
check_rotation_anchor,
|
|
672
|
+
check_annotation_workarounds,
|
|
673
|
+
check_panel_alignment_gate,
|
|
674
|
+
check_backend_exclusivity,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def validate_source(source: str, backend: str) -> list[Finding]:
|
|
679
|
+
return [check(source, backend) for check in CHECKS]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def summarize(findings: Iterable[Finding], strict: bool = False) -> dict[str, object]:
|
|
683
|
+
rows = list(findings)
|
|
684
|
+
counts = {level: sum(row.level == level for row in rows) for level in ("PASS", "WARN", "FAIL")}
|
|
685
|
+
ready = counts["FAIL"] == 0 and (not strict or counts["WARN"] == 0)
|
|
686
|
+
return {"ready": ready, "strict": strict, "counts": counts}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def render_text(path: Path, backend: str, findings: list[Finding], strict: bool) -> str:
|
|
690
|
+
summary = summarize(findings, strict)
|
|
691
|
+
lines = [
|
|
692
|
+
"Nature Figure Static Preflight",
|
|
693
|
+
f"source: {path}",
|
|
694
|
+
f"backend: {backend}",
|
|
695
|
+
"",
|
|
696
|
+
]
|
|
697
|
+
for row in findings:
|
|
698
|
+
lines.append(f"[{row.level}] {row.check_id}: {row.message}")
|
|
699
|
+
for item in row.evidence:
|
|
700
|
+
lines.append(f" - {item}")
|
|
701
|
+
counts = summary["counts"]
|
|
702
|
+
lines.extend(
|
|
703
|
+
[
|
|
704
|
+
"",
|
|
705
|
+
f"summary: {counts['PASS']} pass, {counts['WARN']} warn, {counts['FAIL']} fail",
|
|
706
|
+
f"verdict: {'READY FOR VISUAL QA' if summary['ready'] else 'FIX BEFORE DELIVERY'}",
|
|
707
|
+
"note: static preflight does not validate statistics, source-data truth, or rendered appearance",
|
|
708
|
+
]
|
|
709
|
+
)
|
|
710
|
+
return "\n".join(lines)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def run_self_tests() -> None:
|
|
714
|
+
good_python = '''
|
|
715
|
+
import matplotlib as mpl
|
|
716
|
+
import matplotlib.pyplot as plt
|
|
717
|
+
mpl.rcParams.update({
|
|
718
|
+
"font.family": "sans-serif",
|
|
719
|
+
"font.sans-serif": ["Arial", "Helvetica"],
|
|
720
|
+
"font.size": 7,
|
|
721
|
+
"svg.fonttype": "none",
|
|
722
|
+
"pdf.fonttype": 42,
|
|
723
|
+
})
|
|
724
|
+
width_mm = 183
|
|
725
|
+
values = values[values > 0]
|
|
726
|
+
fig, ax = plt.subplots(figsize=(width_mm / 25.4, 120 / 25.4))
|
|
727
|
+
ax.set_yscale("log")
|
|
728
|
+
fig.savefig("figure.svg", bbox_inches="tight")
|
|
729
|
+
fig.savefig("figure.pdf", bbox_inches="tight")
|
|
730
|
+
fig.savefig("figure.tiff", dpi=600, bbox_inches="tight")
|
|
731
|
+
'''
|
|
732
|
+
good_r = '''
|
|
733
|
+
library(ggplot2)
|
|
734
|
+
width_mm <- 183
|
|
735
|
+
p <- ggplot(df, aes(x, y)) + theme_classic(base_size = 7, base_family = "Arial")
|
|
736
|
+
svglite::svglite("figure.svg", width = width_mm / 25.4, height = 4)
|
|
737
|
+
print(p)
|
|
738
|
+
dev.off()
|
|
739
|
+
grDevices::cairo_pdf("figure.pdf", width = width_mm / 25.4, height = 4, family = "Arial")
|
|
740
|
+
print(p)
|
|
741
|
+
dev.off()
|
|
742
|
+
ragg::agg_tiff("figure.tiff", width = width_mm / 25.4, height = 4, units = "in", res = 600)
|
|
743
|
+
print(p)
|
|
744
|
+
dev.off()
|
|
745
|
+
'''
|
|
746
|
+
bad_python = '''
|
|
747
|
+
import numpy as np
|
|
748
|
+
import matplotlib.pyplot as plt
|
|
749
|
+
x = np.random.normal(size=100)
|
|
750
|
+
x = np.random.choice(x, 12)
|
|
751
|
+
fig, ax = plt.subplots(figsize=(4, 3))
|
|
752
|
+
ax.scatter(x, np.log(x), c=x, cmap="jet")
|
|
753
|
+
ax.tick_params(labelsize=4)
|
|
754
|
+
fig.savefig("figure.png", dpi=72)
|
|
755
|
+
'''
|
|
756
|
+
risky_rendered_python = good_python + '''
|
|
757
|
+
seed_values = np.median(seed_scores, axis=0)
|
|
758
|
+
equivalent_n = np.interp(target_error, errors_decreasing, sample_counts)
|
|
759
|
+
ax.plot(x, seed_values, label="+ semantic guidance")
|
|
760
|
+
ax.text(0.5, 0.5, "$R^2$", fontsize=7, rotation=45,
|
|
761
|
+
bbox=dict(facecolor="white", edgecolor="none"))
|
|
762
|
+
LABEL_Y = 96.4
|
|
763
|
+
'''
|
|
764
|
+
|
|
765
|
+
good_py_findings = validate_source(good_python, "python")
|
|
766
|
+
good_py_failures = [row for row in good_py_findings if row.level == "FAIL"]
|
|
767
|
+
assert not good_py_failures, good_py_failures
|
|
768
|
+
|
|
769
|
+
good_r_findings = validate_source(good_r, "r")
|
|
770
|
+
good_r_failures = [row for row in good_r_findings if row.level == "FAIL"]
|
|
771
|
+
assert not good_r_failures, good_r_failures
|
|
772
|
+
|
|
773
|
+
bad = {row.check_id: row for row in validate_source(bad_python, "python")}
|
|
774
|
+
for check_id in ("FONT-FAMILY", "FONT-SIZE", "COLOR-MAP", "EDITABLE-TEXT", "EXPORT-VECTOR", "RASTER-DPI"):
|
|
775
|
+
assert bad[check_id].level == "FAIL", (check_id, bad[check_id])
|
|
776
|
+
for check_id in ("DATA-SAMPLING", "DEMO-DATA", "LOG-GUARD"):
|
|
777
|
+
assert bad[check_id].level == "WARN", (check_id, bad[check_id])
|
|
778
|
+
|
|
779
|
+
risky = {row.check_id: row for row in validate_source(risky_rendered_python, "python")}
|
|
780
|
+
for check_id in (
|
|
781
|
+
"FONT-GLYPH-FLOOR",
|
|
782
|
+
"LEGEND-LABEL-CASE",
|
|
783
|
+
"INTERP-MONOTONIC",
|
|
784
|
+
"UNCERTAINTY-ENCODING",
|
|
785
|
+
"ROTATION-ANCHOR",
|
|
786
|
+
"ANNOTATION-WORKAROUND",
|
|
787
|
+
):
|
|
788
|
+
assert risky[check_id].level == "WARN", (check_id, risky[check_id])
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
792
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
793
|
+
parser.add_argument("source", nargs="?", type=Path, help="Python or R plotting source")
|
|
794
|
+
parser.add_argument("--backend", choices=("auto", "python", "r"), default="auto")
|
|
795
|
+
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
796
|
+
parser.add_argument("--strict", action="store_true", help="treat warnings as not ready")
|
|
797
|
+
parser.add_argument("--self-test", action="store_true", help="run dependency-free validator tests")
|
|
798
|
+
return parser
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def main(argv: list[str] | None = None) -> int:
|
|
802
|
+
args = build_parser().parse_args(argv)
|
|
803
|
+
if args.self_test:
|
|
804
|
+
run_self_tests()
|
|
805
|
+
print("validate_figure.py self-test: PASS")
|
|
806
|
+
return 0
|
|
807
|
+
if args.source is None:
|
|
808
|
+
build_parser().error("source is required unless --self-test is used")
|
|
809
|
+
if not args.source.is_file():
|
|
810
|
+
print(f"error: source file not found: {args.source}", file=sys.stderr)
|
|
811
|
+
return 2
|
|
812
|
+
try:
|
|
813
|
+
backend = detect_backend(args.source, args.backend)
|
|
814
|
+
source = args.source.read_text(encoding="utf-8-sig")
|
|
815
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
816
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
817
|
+
return 2
|
|
818
|
+
|
|
819
|
+
findings = validate_source(source, backend)
|
|
820
|
+
summary = summarize(findings, args.strict)
|
|
821
|
+
if args.json:
|
|
822
|
+
payload = {
|
|
823
|
+
"source": str(args.source),
|
|
824
|
+
"backend": backend,
|
|
825
|
+
"summary": summary,
|
|
826
|
+
"findings": [asdict(row) for row in findings],
|
|
827
|
+
}
|
|
828
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
829
|
+
else:
|
|
830
|
+
print(render_text(args.source, backend, findings, args.strict))
|
|
831
|
+
return 0 if summary["ready"] else 1
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
if __name__ == "__main__":
|
|
835
|
+
raise SystemExit(main())
|