sciwrite-lint 0.2.0__py3-none-any.whl

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 (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,646 @@
1
+ """TOML-based configuration for sciwrite-lint.
2
+
3
+ Looks for .sciwrite-lint.toml in the current directory, then parent
4
+ directories (like .eslintrc). Falls back to built-in defaults.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tomllib
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
14
+
15
+
16
+ _DEFAULT_WE_VERBS = [
17
+ "propose",
18
+ "argue",
19
+ "present",
20
+ "define",
21
+ "describe",
22
+ "introduce",
23
+ "summarize",
24
+ "examine",
25
+ "suggest",
26
+ "discuss",
27
+ "note",
28
+ "show",
29
+ "demonstrate",
30
+ "identify",
31
+ "call",
32
+ "use",
33
+ "further",
34
+ "consider",
35
+ "begin",
36
+ "turn",
37
+ "return",
38
+ ]
39
+
40
+
41
+ class PaperWorkspace(BaseModel):
42
+ """Per-paper directory structure for references and parsed files.
43
+
44
+ Each paper gets an isolated workspace under references/{paper_name}/.
45
+ The directory is created lazily on first pipeline run via ensure_dirs().
46
+ Citation metadata is stored in workspace.db (parsed/workspace.db).
47
+ """
48
+
49
+ model_config = ConfigDict(frozen=True)
50
+
51
+ root: Path # references/{paper_name}/
52
+ parsed: Path # references/{paper_name}/parsed/
53
+ source: Path # references/{paper_name}/source/
54
+
55
+ def ensure_dirs(self) -> None:
56
+ """Create the workspace directories if they don't exist."""
57
+ self.root.mkdir(parents=True, exist_ok=True)
58
+ self.parsed.mkdir(exist_ok=True)
59
+ self.source.mkdir(exist_ok=True)
60
+
61
+ def sub_workspace(self, ref_key: str) -> PaperWorkspace:
62
+ """Workspace for a reference's own references (depth+1)."""
63
+ sub = self.root / ref_key
64
+ return PaperWorkspace(
65
+ root=sub,
66
+ parsed=sub / "parsed",
67
+ source=sub / "source",
68
+ )
69
+
70
+ @property
71
+ def source_manifest(self) -> Path:
72
+ """Path to source.json — records what was analyzed."""
73
+ return self.root / "source.json"
74
+
75
+ def check_source(
76
+ self, tex_path: Path, bib_path: Path | None = None
77
+ ) -> tuple[bool, str]:
78
+ """Check if the workspace source matches the current input.
79
+
80
+ Returns (ok, reason). ok=True means workspace is compatible.
81
+ Reasons for incompatibility:
82
+ - Source type changed (tex→pdf or pdf→tex)
83
+ - First run (no source.json yet) — always ok
84
+ """
85
+ import json
86
+
87
+ if not self.source_manifest.exists():
88
+ return True, "first_run"
89
+
90
+ try:
91
+ manifest = json.loads(self.source_manifest.read_text(encoding="utf-8"))
92
+ except (json.JSONDecodeError, OSError):
93
+ return True, "corrupt_manifest"
94
+
95
+ old_type = manifest.get("source_type", "")
96
+ new_type = "pdf" if tex_path.suffix.lower() == ".pdf" else "tex"
97
+ if old_type and old_type != new_type:
98
+ return False, f"source_type_changed:{old_type}→{new_type}"
99
+
100
+ return True, "ok"
101
+
102
+ def save_source(self, tex_path: Path, bib_path: Path | None = None) -> None:
103
+ """Copy source files into workspace and write source.json manifest.
104
+
105
+ For LaTeX input, also copies all images referenced via
106
+ \\includegraphics (respecting \\graphicspath), preserving the
107
+ directory structure relative to the .tex file so paths still resolve.
108
+ """
109
+ import hashlib
110
+ import json
111
+ import shutil
112
+
113
+ source_type = "pdf" if tex_path.suffix.lower() == ".pdf" else "tex"
114
+
115
+ # Copy source file
116
+ shutil.copy2(tex_path, self.source / tex_path.name)
117
+
118
+ # Copy .bib if present
119
+ bib_hash = ""
120
+ if bib_path and bib_path.exists():
121
+ shutil.copy2(bib_path, self.source / bib_path.name)
122
+ bib_hash = hashlib.sha256(bib_path.read_bytes()).hexdigest()[:16]
123
+
124
+ # Copy referenced images (LaTeX only)
125
+ image_count = 0
126
+ if source_type == "tex":
127
+ from sciwrite_lint.vision.image_extraction import collect_image_paths
128
+
129
+ for abs_path, rel_path in collect_image_paths(tex_path):
130
+ dest = self.source / rel_path
131
+ dest.parent.mkdir(parents=True, exist_ok=True)
132
+ shutil.copy2(abs_path, dest)
133
+ image_count += 1
134
+
135
+ manifest = {
136
+ "source_type": source_type,
137
+ "source_file": tex_path.name,
138
+ "source_hash": hashlib.sha256(tex_path.read_bytes()).hexdigest()[:16],
139
+ "bib_file": bib_path.name if bib_path else "",
140
+ "bib_hash": bib_hash,
141
+ "image_count": image_count,
142
+ }
143
+ self.source_manifest.write_text(
144
+ json.dumps(manifest, indent=2), encoding="utf-8"
145
+ )
146
+
147
+
148
+ class PaperConfig(BaseModel):
149
+ """Configuration for a single paper in the project."""
150
+
151
+ name: str
152
+ file_path: Path
153
+ bib: Path | None = None # explicit .bib path; None = auto-detect from .tex
154
+ prohibited_terms: list[str] = Field(default_factory=list)
155
+
156
+
157
+ class LintConfig(BaseModel):
158
+ """Resolved configuration for a sciwrite-lint run."""
159
+
160
+ # Paths — the 'before' validator resolves None → CWD-relative defaults
161
+ # before field validation, so these are always Path after construction.
162
+ config_path: Path | None = None # where the TOML was found (None if no TOML)
163
+ project_dir: Path = Field(default=None) # type: ignore[assignment]
164
+ references_dir: Path = Field(default=None) # type: ignore[assignment]
165
+ results_dir: Path = Field(default=None) # type: ignore[assignment]
166
+ calibration_dir: Path = Field(default=None) # type: ignore[assignment]
167
+ benchmarks_dir: Path = Field(default=None) # type: ignore[assignment]
168
+ local_pdfs_dir: Path = Field(default=None) # type: ignore[assignment]
169
+
170
+ @model_validator(mode="before")
171
+ @classmethod
172
+ def _resolve_default_paths(cls, data: dict) -> dict: # type: ignore[override]
173
+ """Resolve default paths relative to project_dir."""
174
+ if not isinstance(data, dict):
175
+ return data
176
+ project_dir = data.get("project_dir") or Path.cwd()
177
+ data["project_dir"] = project_dir
178
+ refs = data.get("references_dir")
179
+ if refs is None:
180
+ refs = (project_dir / "references").resolve()
181
+ data["references_dir"] = refs
182
+ if data.get("results_dir") is None:
183
+ data["results_dir"] = (project_dir / "results").resolve()
184
+ if data.get("calibration_dir") is None:
185
+ data["calibration_dir"] = (refs / "scilint-calibration").resolve()
186
+ if data.get("benchmarks_dir") is None:
187
+ data["benchmarks_dir"] = (project_dir / "benchmarks").resolve()
188
+ if data.get("local_pdfs_dir") is None:
189
+ data["local_pdfs_dir"] = (project_dir / "local_pdfs").resolve()
190
+ return data
191
+
192
+ # Papers
193
+ papers: list[PaperConfig] = Field(default_factory=list)
194
+
195
+ # Style thresholds
196
+ emdash_threshold: float = 3.0
197
+ sentence_length_max: int = 50
198
+ passive_voice_threshold: float = 0.4
199
+ prohibited_terms: list[str] = Field(default_factory=list)
200
+ we_allowed_verbs: list[str] = Field(default_factory=lambda: list(_DEFAULT_WE_VERBS))
201
+
202
+ # Document size limit for LLM checks (~50 pages at ~3 500 chars/page).
203
+ # Documents larger than this skip consistency and contribution checks.
204
+ max_document_chars: int = 175_000
205
+
206
+ # Rules
207
+ disabled_rules: set[str] = Field(default_factory=set)
208
+ severity_overrides: dict[str, str] = Field(default_factory=dict)
209
+
210
+ # API
211
+ polite_email: str = ""
212
+ openalex_interval: float = 0.22
213
+ s2_interval: float = 1.6
214
+ crossref_interval: float = 0.12
215
+ core_interval: float = 1.0
216
+ unpaywall_interval: float = 0.1
217
+ rw_cache_hours: int = 24 # Retraction Watch CSV re-download interval
218
+
219
+ # Output
220
+ output_format: str = "terminal"
221
+ color: bool = True
222
+
223
+ # Logging
224
+ log_level: str = "DEBUG" # file sink level; stderr sink stays at INFO
225
+
226
+ # LLM (required — fail if not available)
227
+ llm_endpoint: str = "http://localhost:5001/v1"
228
+ llm_model: str = "qwen3"
229
+ llm_timeout: float = 300.0 # per-request timeout; batches queue in vLLM
230
+
231
+ # Embeddings (for reference store semantic retrieval)
232
+ embedding_model: str = "Snowflake/snowflake-arctic-embed-m-v2.0"
233
+ embedding_dim: int = 768
234
+ embedding_device: str = "auto" # "auto" (cuda if available), "cpu", "cuda"
235
+
236
+ # Runtime context — set by pipeline before running checks.
237
+ # Registered checks only receive (tex_path, config), so this is
238
+ # how they know which paper's workspace to use.
239
+ current_paper: str = ""
240
+
241
+ # Manuscript context — set by build_pdf_context() for PDF input,
242
+ # or by get_or_create_manuscript_context() for LaTeX.
243
+ # Typed as Any to avoid circular import with manuscript_store.
244
+ manuscript_context: Any = Field(default=None, exclude=True)
245
+
246
+ # Last pipeline run stats — set by run_full_check(), read by eval runner.
247
+ last_run_stats: Any = Field(default=None, exclude=True)
248
+
249
+ @property
250
+ def is_pdf(self) -> bool:
251
+ """True when the current manuscript is a PDF (GROBID-parsed)."""
252
+ ctx = self.manuscript_context
253
+ return ctx is not None and getattr(ctx, "source_type", None) == "pdf"
254
+
255
+ # Container config
256
+ grobid_version: str = "0.8.2.1-crf"
257
+ grobid_memory: str = "8g"
258
+ vllm_version: str = "v0.18.0"
259
+ vllm_memory: str = "4g"
260
+
261
+ @property
262
+ def grobid_image(self) -> str:
263
+ return f"docker.io/grobid/grobid:{self.grobid_version}"
264
+
265
+ @property
266
+ def vllm_image(self) -> str:
267
+ return f"docker.io/vllm/vllm-openai:{self.vllm_version}"
268
+
269
+ def is_check_enabled(self, check_id: str) -> bool:
270
+ """Check if a check is enabled."""
271
+ return check_id not in self.disabled_rules
272
+
273
+ def is_rule_enabled(self, check_id: str) -> bool:
274
+ """Legacy alias for is_check_enabled."""
275
+ return self.is_check_enabled(check_id)
276
+
277
+ def effective_severity(self, check_id: str, default: str) -> str:
278
+ """Return overridden severity or the check's default."""
279
+ return self.severity_overrides.get(check_id, default)
280
+
281
+ def get_paper(self, name: str) -> PaperConfig | None:
282
+ """Look up a paper by name."""
283
+ for p in self.papers:
284
+ if p.name == name:
285
+ return p
286
+ return None
287
+
288
+ def paper_workspace(self, paper_name: str) -> PaperWorkspace:
289
+ """Return the per-paper workspace under references_dir."""
290
+ root = self.references_dir / paper_name
291
+ return PaperWorkspace(
292
+ root=root,
293
+ parsed=root / "parsed",
294
+ source=root / "source",
295
+ )
296
+
297
+ def effective_references_dir(self) -> Path:
298
+ """Per-paper references dir if current_paper is set, else global."""
299
+ if self.current_paper:
300
+ return self.paper_workspace(self.current_paper).root
301
+ return self.references_dir
302
+
303
+
304
+ def is_wsl2() -> bool:
305
+ """Detect WSL2 via /proc/version (contains 'microsoft' or 'WSL2')."""
306
+ try:
307
+ return "microsoft" in Path("/proc/version").read_text().lower()
308
+ except OSError:
309
+ return False
310
+
311
+
312
+ def find_config(start: Path | None = None) -> Path | None:
313
+ """Walk up from start (default: cwd) looking for .sciwrite-lint.toml."""
314
+ current = (start or Path.cwd()).resolve()
315
+ while True:
316
+ candidate = current / ".sciwrite-lint.toml"
317
+ if candidate.is_file():
318
+ return candidate
319
+ parent = current.parent
320
+ if parent == current:
321
+ return None
322
+ current = parent
323
+
324
+
325
+ def load_config(path: Path | None = None) -> LintConfig:
326
+ """Load configuration from a TOML file, or use defaults.
327
+
328
+ If path is None, searches for .sciwrite-lint.toml via find_config().
329
+ """
330
+ config = LintConfig()
331
+
332
+ toml_path = path or find_config()
333
+ if toml_path is None:
334
+ return config
335
+
336
+ config.config_path = toml_path
337
+ config.project_dir = toml_path.parent
338
+ with open(toml_path, "rb") as f:
339
+ data = tomllib.load(f)
340
+
341
+ project_dir = toml_path.parent
342
+
343
+ # Papers — [[papers]] array
344
+ for paper_data in data.get("papers", []):
345
+ fp = paper_data.get("file_path", "")
346
+ if not fp:
347
+ continue
348
+ fp_resolved = (project_dir / fp).resolve()
349
+ bib_path = None
350
+ if paper_data.get("bib"):
351
+ bib_path = (project_dir / paper_data["bib"]).resolve()
352
+ config.papers.append(
353
+ PaperConfig(
354
+ name=paper_data.get("name", fp_resolved.stem),
355
+ file_path=fp_resolved,
356
+ bib=bib_path,
357
+ prohibited_terms=paper_data.get("prohibited_terms", []),
358
+ )
359
+ )
360
+
361
+ # Style section
362
+ style = data.get("style", {})
363
+ if "emdash_threshold" in style:
364
+ config.emdash_threshold = float(style["emdash_threshold"])
365
+ if "sentence_length_max" in style:
366
+ config.sentence_length_max = int(style["sentence_length_max"])
367
+ if "passive_voice_threshold" in style:
368
+ config.passive_voice_threshold = float(style["passive_voice_threshold"])
369
+ if "prohibited_terms" in style:
370
+ config.prohibited_terms = list(style["prohibited_terms"])
371
+ if "we_allowed_verbs" in style:
372
+ config.we_allowed_verbs = list(style["we_allowed_verbs"])
373
+ if "max_document_chars" in style:
374
+ config.max_document_chars = int(style["max_document_chars"])
375
+
376
+ # Rules section
377
+ rules = data.get("rules", {})
378
+ if "disable" in rules:
379
+ config.disabled_rules = set(rules["disable"])
380
+ if "severity_overrides" in rules:
381
+ config.severity_overrides = dict(rules["severity_overrides"])
382
+
383
+ # API section
384
+ api = data.get("api", {})
385
+ for key in (
386
+ "polite_email",
387
+ "openalex_interval",
388
+ "s2_interval",
389
+ "crossref_interval",
390
+ "core_interval",
391
+ "unpaywall_interval",
392
+ "rw_cache_hours",
393
+ ):
394
+ if key in api:
395
+ setattr(config, key, api[key])
396
+
397
+ # Output section
398
+ output = data.get("output", {})
399
+ if "format" in output:
400
+ config.output_format = output["format"]
401
+ if "color" in output:
402
+ config.color = output["color"]
403
+
404
+ # Logging section
405
+ logging = data.get("logging", {})
406
+ if "level" in logging:
407
+ config.log_level = logging["level"].upper()
408
+
409
+ # LLM section
410
+ llm = data.get("llm", {})
411
+ if "endpoint" in llm:
412
+ config.llm_endpoint = llm["endpoint"]
413
+ if "model" in llm:
414
+ config.llm_model = llm["model"]
415
+
416
+ # Embeddings section
417
+ _EMBEDDING_PRESETS = {
418
+ "snowflake-m-v2": ("Snowflake/snowflake-arctic-embed-m-v2.0", 768),
419
+ "snowflake-l-v2": ("Snowflake/snowflake-arctic-embed-l-v2.0", 1024),
420
+ }
421
+ emb = data.get("embeddings", {})
422
+ if "model" in emb:
423
+ model_val = emb["model"]
424
+ if model_val in _EMBEDDING_PRESETS:
425
+ config.embedding_model, config.embedding_dim = _EMBEDDING_PRESETS[model_val]
426
+ else:
427
+ config.embedding_model = model_val
428
+ if "dimension" in emb:
429
+ config.embedding_dim = int(emb["dimension"])
430
+ if "dimension" in emb and "model" not in emb:
431
+ config.embedding_dim = int(emb["dimension"])
432
+ if "device" in emb:
433
+ config.embedding_device = emb["device"]
434
+
435
+ # Containers section
436
+ containers = data.get("containers", {})
437
+ if "grobid_version" in containers:
438
+ config.grobid_version = containers["grobid_version"]
439
+ if "grobid_memory" in containers:
440
+ config.grobid_memory = containers["grobid_memory"]
441
+ if "vllm_version" in containers:
442
+ config.vllm_version = containers["vllm_version"]
443
+ if "vllm_memory" in containers:
444
+ config.vllm_memory = containers["vllm_memory"]
445
+
446
+ # Paths (relative to config file location)
447
+ if data.get("references_dir"):
448
+ config.references_dir = (project_dir / data["references_dir"]).resolve()
449
+ else:
450
+ config.references_dir = (project_dir / "references").resolve()
451
+ if data.get("results_dir"):
452
+ config.results_dir = (project_dir / data["results_dir"]).resolve()
453
+ else:
454
+ config.results_dir = (project_dir / "results").resolve()
455
+ if data.get("calibration_dir"):
456
+ config.calibration_dir = (project_dir / data["calibration_dir"]).resolve()
457
+ else:
458
+ config.calibration_dir = (
459
+ config.references_dir / "scilint-calibration"
460
+ ).resolve()
461
+ if data.get("benchmarks_dir"):
462
+ config.benchmarks_dir = (project_dir / data["benchmarks_dir"]).resolve()
463
+ else:
464
+ config.benchmarks_dir = (project_dir / "benchmarks").resolve()
465
+ if data.get("local_pdfs_dir"):
466
+ config.local_pdfs_dir = (project_dir / data["local_pdfs_dir"]).resolve()
467
+ else:
468
+ config.local_pdfs_dir = (project_dir / "local_pdfs").resolve()
469
+
470
+ return config
471
+
472
+
473
+ def generate_default_toml(papers: list[dict[str, str]] | None = None) -> str:
474
+ """Generate a default .sciwrite-lint.toml for `sciwrite-lint init`.
475
+
476
+ If papers is provided, include [[papers]] entries for each.
477
+ """
478
+ lines = [
479
+ "# sciwrite-lint configuration",
480
+ "# See: https://github.com/authentic-research-partners/sciwrite-lint",
481
+ "",
482
+ '# local_pdfs_dir = "local_pdfs" # folder for user-provided PDFs (e.g. paywalled content)',
483
+ "",
484
+ ]
485
+
486
+ # Papers section
487
+ if papers:
488
+ for p in papers:
489
+ lines.append("[[papers]]")
490
+ lines.append(f'name = "{p["name"]}"')
491
+ lines.append(f'file_path = "{p["file_path"]}"')
492
+ if p.get("bib"):
493
+ lines.append(f'bib = "{p["bib"]}"')
494
+ lines.append("# prohibited_terms = []")
495
+ lines.append("")
496
+ else:
497
+ lines.extend(
498
+ [
499
+ "# Register your papers here. Each [[papers]] entry is one manuscript.",
500
+ "# sciwrite-lint check — checks all papers",
501
+ "# sciwrite-lint check --paper name — checks one paper",
502
+ "",
503
+ "# [[papers]]",
504
+ '# name = "my-paper"',
505
+ '# file_path = "paper.tex" # path to .tex or .pdf file',
506
+ '# bib = "references.bib" # optional, auto-detected from \\bibliography{}',
507
+ '# prohibited_terms = ["CompanyName"] # terms that must not appear in body',
508
+ "",
509
+ ]
510
+ )
511
+
512
+ lines.extend(
513
+ [
514
+ "[llm]",
515
+ 'endpoint = "http://localhost:5001/v1"',
516
+ 'model = "qwen3" # or "gemma3"',
517
+ "",
518
+ "[api]",
519
+ '# polite_email = "" # recommended for CrossRef/Unpaywall polite pool',
520
+ "# Manage email and API keys interactively: sciwrite-lint config show",
521
+ "",
522
+ "[style]",
523
+ "emdash_threshold = 3.0 # max em-dashes per 1000 words",
524
+ "sentence_length_max = 50 # max words per sentence",
525
+ "max_document_chars = 175000 # skip LLM checks for docs larger than this (~50 pages)",
526
+ "",
527
+ "[rules]",
528
+ '# disable = ["style-001"] # rule IDs to disable',
529
+ "",
530
+ "[output]",
531
+ 'format = "terminal" # "terminal" or "json"',
532
+ "color = true",
533
+ "",
534
+ "[logging]",
535
+ 'level = "DEBUG" # file sink: DEBUG, INFO, WARNING, ERROR',
536
+ "",
537
+ ]
538
+ )
539
+
540
+ return "\n".join(lines) + "\n"
541
+
542
+
543
+ def init_project(force: bool = False) -> tuple[bool, str]:
544
+ """Initialize a sciwrite-lint project in the current directory.
545
+
546
+ Creates .sciwrite-lint.toml and references/ directory structure.
547
+ Never overwrites existing files or directories.
548
+
549
+ Returns (success, message).
550
+ """
551
+ defaults = LintConfig()
552
+ toml_path = Path(".sciwrite-lint.toml")
553
+ refs_dir = defaults.references_dir
554
+
555
+ created: list[str] = []
556
+ skipped: list[str] = []
557
+
558
+ # Config file
559
+ if toml_path.exists():
560
+ if force:
561
+ # Scan for .tex files to populate papers
562
+ papers = _detect_papers()
563
+ toml_path.write_text(generate_default_toml(papers or None))
564
+ created.append(str(toml_path) + " (overwritten)")
565
+ else:
566
+ skipped.append(str(toml_path) + " (already exists)")
567
+ else:
568
+ papers = _detect_papers()
569
+ toml_path.write_text(generate_default_toml(papers or None))
570
+ created.append(str(toml_path))
571
+
572
+ # References root directory (per-paper subdirs created on first pipeline run)
573
+ if refs_dir.exists():
574
+ skipped.append(str(refs_dir) + "/ (already exists)")
575
+ else:
576
+ refs_dir.mkdir()
577
+ created.append(str(refs_dir) + "/")
578
+
579
+ # Local PDFs directory (user-provided paywalled content)
580
+ local_pdfs = defaults.local_pdfs_dir
581
+ if local_pdfs.exists():
582
+ skipped.append(str(local_pdfs) + "/ (already exists)")
583
+ else:
584
+ local_pdfs.mkdir()
585
+ created.append(str(local_pdfs) + "/")
586
+
587
+ # Build message
588
+ parts: list[str] = []
589
+ if created:
590
+ parts.append("Created:\n" + "\n".join(f" {c}" for c in created))
591
+ if skipped:
592
+ parts.append(
593
+ "Skipped (not overwritten):\n" + "\n".join(f" {s}" for s in skipped)
594
+ )
595
+
596
+ if not created and skipped:
597
+ parts.append("\nProject already initialized. Use --force to overwrite config.")
598
+
599
+ if created:
600
+ parts.append("\nNext steps:")
601
+ parts.append(
602
+ " 1. Edit .sciwrite-lint.toml — register your papers in [[papers]]"
603
+ )
604
+ parts.append(
605
+ " 2. Set your email (required for full-text download + retraction checks):"
606
+ )
607
+ parts.append(" sciwrite-lint config set-email you@example.com")
608
+ parts.append(
609
+ " 3. Drop paywalled PDFs into local_pdfs/ (filename ~ reference title)"
610
+ )
611
+ parts.append(" 4. Start containers: sciwrite-lint containers start")
612
+ parts.append(" 5. Run: sciwrite-lint check")
613
+ parts.append("\nOptional — faster API rate limits: sciwrite-lint config show")
614
+
615
+ return bool(created), "\n".join(parts)
616
+
617
+
618
+ def _detect_papers() -> list[dict[str, str]]:
619
+ """Scan current directory for .tex files and build paper entries."""
620
+ papers = []
621
+ for tex in sorted(Path(".").glob("**/*.tex")):
622
+ # Skip common non-paper files
623
+ if any(part.startswith(".") for part in tex.parts):
624
+ continue
625
+ if "references" in tex.parts or "archive" in tex.parts:
626
+ continue
627
+
628
+ name = tex.stem
629
+ entry: dict[str, str] = {"name": name, "file_path": str(tex)}
630
+
631
+ # Check for matching .bib file
632
+ tex_text = tex.read_text(encoding="utf-8", errors="ignore")
633
+ import re
634
+
635
+ bib_match = re.search(r"\\bibliography\{([^}]+)\}", tex_text)
636
+ if bib_match:
637
+ bib_name = bib_match.group(1)
638
+ if not bib_name.endswith(".bib"):
639
+ bib_name += ".bib"
640
+ bib_path = tex.parent / bib_name
641
+ if bib_path.exists():
642
+ entry["bib"] = str(bib_path)
643
+
644
+ papers.append(entry)
645
+
646
+ return papers