psyclaw 0.28.0 → 0.28.2

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.
Files changed (167) hide show
  1. package/README.md +5 -5
  2. package/agents/recommended/catalog.json +99 -0
  3. package/dist/apps/panel/index.html +5 -5
  4. package/dist/src/adapters/pi/extension.js +164 -18
  5. package/dist/src/adapters/pi/extension.js.map +1 -1
  6. package/dist/src/adapters/pi/rpc.d.ts +2 -0
  7. package/dist/src/adapters/pi/rpc.js +9 -2
  8. package/dist/src/adapters/pi/rpc.js.map +1 -1
  9. package/dist/src/agents/recommended-personas.d.ts +42 -0
  10. package/dist/src/agents/recommended-personas.js +135 -0
  11. package/dist/src/agents/recommended-personas.js.map +1 -0
  12. package/dist/src/ars/mode-editor.d.ts +20 -0
  13. package/dist/src/ars/mode-editor.js +57 -0
  14. package/dist/src/ars/mode-editor.js.map +1 -0
  15. package/dist/src/ars/profile.d.ts +4 -1
  16. package/dist/src/ars/profile.js +23 -6
  17. package/dist/src/ars/profile.js.map +1 -1
  18. package/dist/src/cli.js +6 -3
  19. package/dist/src/cli.js.map +1 -1
  20. package/dist/src/creation/contracts.d.ts +3 -0
  21. package/dist/src/creation/service.js +17 -5
  22. package/dist/src/creation/service.js.map +1 -1
  23. package/dist/src/index.d.ts +1 -0
  24. package/dist/src/index.js +1 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/orchestration/bundled-personas.d.ts +16 -0
  27. package/dist/src/orchestration/bundled-personas.js +101 -0
  28. package/dist/src/orchestration/bundled-personas.js.map +1 -0
  29. package/dist/src/orchestration/effects.d.ts +8 -0
  30. package/dist/src/orchestration/effects.js +39 -0
  31. package/dist/src/orchestration/effects.js.map +1 -0
  32. package/dist/src/orchestration/personas.d.ts +2 -1
  33. package/dist/src/orchestration/personas.js +20 -7
  34. package/dist/src/orchestration/personas.js.map +1 -1
  35. package/dist/src/orchestration/pi-executor.d.ts +9 -5
  36. package/dist/src/orchestration/pi-executor.js +55 -22
  37. package/dist/src/orchestration/pi-executor.js.map +1 -1
  38. package/dist/src/orchestration/research-agents.d.ts +3 -7
  39. package/dist/src/orchestration/research-agents.js +5 -69
  40. package/dist/src/orchestration/research-agents.js.map +1 -1
  41. package/dist/src/panel/server.js +6 -0
  42. package/dist/src/panel/server.js.map +1 -1
  43. package/dist/src/style/cli-ui.d.ts +14 -0
  44. package/dist/src/style/cli-ui.js +45 -0
  45. package/dist/src/style/cli-ui.js.map +1 -1
  46. package/dist/src/tui/skill-manager.js +1 -1
  47. package/dist/src/tui/skill-manager.js.map +1 -1
  48. package/dist/src/updates/update.js +9 -3
  49. package/dist/src/updates/update.js.map +1 -1
  50. package/package.json +11 -3
  51. package/vendor/academic-paper-skills/LICENSE +21 -0
  52. package/vendor/academic-paper-skills/NOTICE.md +7 -0
  53. package/vendor/academic-paper-skills/PSYCLAW_SOURCE.json +14 -0
  54. package/vendor/academic-paper-skills/academic-paper-composer/SKILL.md +840 -0
  55. package/vendor/academic-paper-skills/academic-paper-composer/references/section_guides.md +675 -0
  56. package/vendor/academic-paper-skills/academic-paper-composer/references/writing_standards.md +629 -0
  57. package/vendor/academic-paper-skills/academic-paper-composer/scripts/chapter_quality_check.py +470 -0
  58. package/vendor/academic-paper-skills/academic-paper-composer/scripts/final_evaluation.py +550 -0
  59. package/vendor/academic-paper-skills/academic-paper-strategist/SKILL.md +670 -0
  60. package/vendor/academic-paper-skills/academic-paper-strategist/references/quality_standards.md +336 -0
  61. package/vendor/academic-paper-skills/academic-paper-strategist/references/search_strategy.md +459 -0
  62. package/vendor/academic-paper-skills/academic-paper-strategist/scripts/evaluate_samples.py +300 -0
  63. package/vendor/academic-paper-skills/academic-paper-strategist/scripts/gap_analysis.py +399 -0
  64. package/vendor/ars/pi/wrapper.js +9 -0
  65. package/vendor/nature-skills/LICENSE +201 -0
  66. package/vendor/nature-skills/NOTICE.md +7 -0
  67. package/vendor/nature-skills/PSYCLAW_SOURCE.json +19 -0
  68. package/vendor/nature-skills/skills/nature-figure/README.md +107 -0
  69. package/vendor/nature-skills/skills/nature-figure/README_EN.md +107 -0
  70. package/vendor/nature-skills/skills/nature-figure/SKILL.md +154 -0
  71. package/vendor/nature-skills/skills/nature-figure/agents/openai.yaml +4 -0
  72. package/vendor/nature-skills/skills/nature-figure/evals/evals.json +325 -0
  73. package/vendor/nature-skills/skills/nature-figure/manifest.yaml +103 -0
  74. package/vendor/nature-skills/skills/nature-figure/references/ai-graphical-abstract-workflow.md +129 -0
  75. package/vendor/nature-skills/skills/nature-figure/references/api.md +576 -0
  76. package/vendor/nature-skills/skills/nature-figure/references/asset-adaptation.md +72 -0
  77. package/vendor/nature-skills/skills/nature-figure/references/backend-selection.md +118 -0
  78. package/vendor/nature-skills/skills/nature-figure/references/chart-types.md +348 -0
  79. package/vendor/nature-skills/skills/nature-figure/references/common-patterns.md +371 -0
  80. package/vendor/nature-skills/skills/nature-figure/references/demos.md +58 -0
  81. package/vendor/nature-skills/skills/nature-figure/references/design-theory.md +467 -0
  82. package/vendor/nature-skills/skills/nature-figure/references/figure-contract.md +122 -0
  83. package/vendor/nature-skills/skills/nature-figure/references/figure-legend-conventions.md +90 -0
  84. package/vendor/nature-skills/skills/nature-figure/references/multipanel-evidence-architecture.md +268 -0
  85. package/vendor/nature-skills/skills/nature-figure/references/nature-2026-observations.md +124 -0
  86. package/vendor/nature-skills/skills/nature-figure/references/nature-article-requirements.md +149 -0
  87. package/vendor/nature-skills/skills/nature-figure/references/openrouter-image-generation.md +141 -0
  88. package/vendor/nature-skills/skills/nature-figure/references/qa-contract.md +344 -0
  89. package/vendor/nature-skills/skills/nature-figure/references/r-template-index.md +66 -0
  90. package/vendor/nature-skills/skills/nature-figure/references/r-workflow.md +234 -0
  91. package/vendor/nature-skills/skills/nature-figure/references/template-catalog.md +33 -0
  92. package/vendor/nature-skills/skills/nature-figure/references/tutorials.md +260 -0
  93. package/vendor/nature-skills/skills/nature-figure/requirements.txt +1 -0
  94. package/vendor/nature-skills/skills/nature-figure/scripts/audit_figure_collisions.py +742 -0
  95. package/vendor/nature-skills/skills/nature-figure/scripts/audit_panel_alignment.py +933 -0
  96. package/vendor/nature-skills/skills/nature-figure/scripts/audit_pdf_text.py +152 -0
  97. package/vendor/nature-skills/skills/nature-figure/scripts/figure_safety.py +50 -0
  98. package/vendor/nature-skills/skills/nature-figure/scripts/generate_openrouter_schematic.py +260 -0
  99. package/vendor/nature-skills/skills/nature-figure/scripts/nature_figure_backend.py +93 -0
  100. package/vendor/nature-skills/skills/nature-figure/scripts/panel_alignment.R +188 -0
  101. package/vendor/nature-skills/skills/nature-figure/scripts/plot_templates.py +604 -0
  102. package/vendor/nature-skills/skills/nature-figure/scripts/validate_figure.py +835 -0
  103. package/vendor/nature-skills/skills/nature-figure/static/core/contract.md +55 -0
  104. package/vendor/nature-skills/skills/nature-figure/static/core/stance.md +41 -0
  105. package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/python.md +56 -0
  106. package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/r.md +63 -0
  107. package/vendor/nature-skills/skills/nature-figure/tests/test_figure_safety.py +209 -0
  108. package/vendor/nature-skills/skills/nature-polishing/README.md +58 -0
  109. package/vendor/nature-skills/skills/nature-polishing/README_EN.md +58 -0
  110. package/vendor/nature-skills/skills/nature-polishing/SKILL.md +111 -0
  111. package/vendor/nature-skills/skills/nature-polishing/agents/openai.yaml +4 -0
  112. package/vendor/nature-skills/skills/nature-polishing/manifest.yaml +101 -0
  113. package/vendor/nature-skills/skills/nature-polishing/references/latex-layout.md +211 -0
  114. package/vendor/nature-skills/skills/nature-polishing/references/nat-comms-2025-diction.md +73 -0
  115. package/vendor/nature-skills/skills/nature-polishing/references/phrasebank-playbook.md +175 -0
  116. package/vendor/nature-skills/skills/nature-polishing/references/published-article-patterns.md +126 -0
  117. package/vendor/nature-skills/skills/nature-polishing/references/section-moves.md +252 -0
  118. package/vendor/nature-skills/skills/nature-polishing/references/style-guardrails.md +94 -0
  119. package/vendor/nature-skills/skills/nature-polishing/references/writing-strategy.md +160 -0
  120. package/vendor/nature-skills/skills/nature-polishing/static/core/failure-modes.md +29 -0
  121. package/vendor/nature-skills/skills/nature-polishing/static/core/output-format.md +21 -0
  122. package/vendor/nature-skills/skills/nature-polishing/static/core/stance.md +26 -0
  123. package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/generic.md +17 -0
  124. package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-comms.md +38 -0
  125. package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-mach-intell.md +102 -0
  126. package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nature.md +19 -0
  127. package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/en.md +19 -0
  128. package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/zh-to-en.md +19 -0
  129. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/algorithmic.md +18 -0
  130. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/hypothesis.md +17 -0
  131. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/methods.md +35 -0
  132. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/research.md +29 -0
  133. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/review.md +17 -0
  134. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/abstract.md +26 -0
  135. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/conclusion.md +15 -0
  136. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/discussion.md +48 -0
  137. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/intro.md +33 -0
  138. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/methods.md +26 -0
  139. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/results.md +61 -0
  140. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/title.md +17 -0
  141. package/vendor/nature-skills/skills/nature-ref-verifier/README.md +44 -0
  142. package/vendor/nature-skills/skills/nature-ref-verifier/README_EN.md +44 -0
  143. package/vendor/nature-skills/skills/nature-ref-verifier/SKILL.md +196 -0
  144. package/vendor/nature-skills/skills/nature-ref-verifier/agents/openai.yaml +4 -0
  145. package/vendor/nature-skills/skills/nature-ref-verifier/manifest.yaml +19 -0
  146. package/vendor/nature-skills/skills/nature-ref-verifier/references/common-patterns.md +96 -0
  147. package/vendor/nature-skills/skills/nature-shared/README.md +43 -0
  148. package/vendor/nature-skills/skills/nature-shared/README_EN.md +43 -0
  149. package/vendor/nature-skills/skills/nature-shared/SKILL.md +33 -0
  150. package/vendor/nature-skills/skills/nature-shared/agents/openai.yaml +6 -0
  151. package/vendor/nature-skills/skills/nature-shared/core/consistency-sweep.md +122 -0
  152. package/vendor/nature-skills/skills/nature-shared/core/discussion-argument-language.md +192 -0
  153. package/vendor/nature-skills/skills/nature-shared/core/ethics.md +88 -0
  154. package/vendor/nature-skills/skills/nature-shared/core/main-text-discipline.md +179 -0
  155. package/vendor/nature-skills/skills/nature-shared/core/nature-abstract.md +171 -0
  156. package/vendor/nature-skills/skills/nature-shared/core/nature-introduction.md +164 -0
  157. package/vendor/nature-skills/skills/nature-shared/core/nature-results-discussion.md +215 -0
  158. package/vendor/nature-skills/skills/nature-shared/core/paper-type-taxonomy.md +41 -0
  159. package/vendor/nature-skills/skills/nature-shared/core/reader-workflow.md +21 -0
  160. package/vendor/nature-skills/skills/nature-shared/core/research-compliance.md +192 -0
  161. package/vendor/nature-skills/skills/nature-shared/core/terminology-ledger.md +58 -0
  162. package/vendor/nature-skills/skills/nature-shared/journal-formats/nat-comms.md +139 -0
  163. package/vendor/nature-skills/skills/nature-shared/journal-formats/nature-machine-intelligence.md +431 -0
  164. package/vendor/nature-skills/skills/nature-shared/journal-formats/nature.md +313 -0
  165. package/vendor/nature-skills/skills/nature-shared/manifest.yaml +52 -0
  166. package/vendor/nature-skills/skills/nature-shared/scripts/check_consistency.py +273 -0
  167. 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())