writing-check-cli 1.0.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.
- provenance.json +1 -0
- writing_check/__init__.py +1 -0
- writing_check/cli.py +268 -0
- writing_check/sweep.py +120 -0
- writing_check/vale/.vale.ini +4 -0
- writing_check/vale/profiles.json +489 -0
- writing_check/vale/styles/GoogleDocs/AMPM.yml +9 -0
- writing_check/vale/styles/GoogleDocs/Acronyms.yml +64 -0
- writing_check/vale/styles/GoogleDocs/Anthropomorphism.yml +12 -0
- writing_check/vale/styles/GoogleDocs/Colons.yml +13 -0
- writing_check/vale/styles/GoogleDocs/Contractions.yml +30 -0
- writing_check/vale/styles/GoogleDocs/DateFormat.yml +9 -0
- writing_check/vale/styles/GoogleDocs/Ellipses.yml +9 -0
- writing_check/vale/styles/GoogleDocs/EmDash.yml +13 -0
- writing_check/vale/styles/GoogleDocs/ExcessiveClaims.yml +14 -0
- writing_check/vale/styles/GoogleDocs/Exclamation.yml +12 -0
- writing_check/vale/styles/GoogleDocs/FirstPerson.yml +15 -0
- writing_check/vale/styles/GoogleDocs/Gender.yml +9 -0
- writing_check/vale/styles/GoogleDocs/GenderBias.yml +43 -0
- writing_check/vale/styles/GoogleDocs/HeadingPunctuation.yml +13 -0
- writing_check/vale/styles/GoogleDocs/Headings.yml +32 -0
- writing_check/vale/styles/GoogleDocs/Jargon.yml +13 -0
- writing_check/vale/styles/GoogleDocs/Latin.yml +15 -0
- writing_check/vale/styles/GoogleDocs/LyHyphens.yml +14 -0
- writing_check/vale/styles/GoogleDocs/OptionalPlurals.yml +12 -0
- writing_check/vale/styles/GoogleDocs/Ordinal.yml +7 -0
- writing_check/vale/styles/GoogleDocs/OxfordComma.yml +28 -0
- writing_check/vale/styles/GoogleDocs/Parens.yml +15 -0
- writing_check/vale/styles/GoogleDocs/Passive.yml +184 -0
- writing_check/vale/styles/GoogleDocs/Periods.yml +7 -0
- writing_check/vale/styles/GoogleDocs/Quotes.yml +7 -0
- writing_check/vale/styles/GoogleDocs/Ranges.yml +7 -0
- writing_check/vale/styles/GoogleDocs/Semicolons.yml +8 -0
- writing_check/vale/styles/GoogleDocs/Slang.yml +11 -0
- writing_check/vale/styles/GoogleDocs/Spacing.yml +10 -0
- writing_check/vale/styles/GoogleDocs/Spelling.yml +10 -0
- writing_check/vale/styles/GoogleDocs/Timeless.yml +13 -0
- writing_check/vale/styles/GoogleDocs/Units.yml +10 -0
- writing_check/vale/styles/GoogleDocs/We.yml +11 -0
- writing_check/vale/styles/GoogleDocs/Will.yml +7 -0
- writing_check/vale/upstream-lock.json +19 -0
- writing_check/vale/vendor/Google-LICENSE +21 -0
- writing_check/vale/vendor/Google-NOTICE.md +7 -0
- writing_check_cli-1.0.0.dist-info/METADATA +34 -0
- writing_check_cli-1.0.0.dist-info/RECORD +48 -0
- writing_check_cli-1.0.0.dist-info/WHEEL +4 -0
- writing_check_cli-1.0.0.dist-info/entry_points.txt +3 -0
- writing_check_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
provenance.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema": 1, "decision": "This package contains the independently authored checker and reviewed Google Vale v0.7.1 policy data.", "entries": [{"path_prefix": "src/writing_check/", "origin": "same-author portable implementation; vale policy includes reviewed upstream Google rules", "license": "MIT"}, {"path": "src/writing_check/vale/vendor/Google-NOTICE.md", "origin": "same-author attribution of vale-cli/Google v0.7.1 and its source guide; see upstream-lock.json", "license": "MIT"}, {"path": "LICENSE", "origin": "standard MIT text", "license": "MIT"}, {"path": ".gitignore", "origin": "shared repository build exclusions", "license": "MIT"}, {"path": "README.md", "origin": "same-author package documentation", "license": "MIT"}, {"path": "pyproject.toml", "origin": "same-author package build and project metadata", "license": "MIT"}, {"path": "provenance.json", "origin": "package-scoped provenance inventory", "license": "MIT"}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Portable source-bound Writing Check package."""
|
writing_check/cli.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only source-bound advisory writing evidence with optional Vale JSON."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
EXIT_CLEAN, EXIT_FINDINGS, EXIT_INPUT, EXIT_UNAVAILABLE, EXIT_EXECUTION = range(5)
|
|
17
|
+
ROOT = pathlib.Path(__file__).resolve().parent
|
|
18
|
+
WORD = re.compile(r"[A-Za-z]+(?:['’][A-Za-z]+)?")
|
|
19
|
+
SENTENCE = re.compile(r"[.!?]+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PolicyError(ValueError):
|
|
23
|
+
"""Raised when the bundled public policy closure is not intact."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnknownProfile(ValueError):
|
|
27
|
+
"""Raised for the existing public input-error profile boundary."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, profiles: list[str]) -> None:
|
|
30
|
+
self.profiles = profiles
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def policy_file(root: pathlib.Path, relative: str) -> pathlib.Path:
|
|
34
|
+
path = (root / relative).resolve()
|
|
35
|
+
if root not in path.parents or not path.is_file():
|
|
36
|
+
raise PolicyError(f"missing policy file: {relative}")
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_policy(profile: str) -> tuple[dict[str, Any], str]:
|
|
41
|
+
vale = ROOT / "vale"
|
|
42
|
+
try:
|
|
43
|
+
manifest_bytes = policy_file(vale, "profiles.json").read_bytes()
|
|
44
|
+
lock_bytes = policy_file(vale, "upstream-lock.json").read_bytes()
|
|
45
|
+
manifest = json.loads(manifest_bytes)
|
|
46
|
+
lock = json.loads(lock_bytes)
|
|
47
|
+
if not isinstance(lock, dict):
|
|
48
|
+
raise PolicyError("policy lock has an invalid root")
|
|
49
|
+
except (OSError, json.JSONDecodeError, PolicyError) as error:
|
|
50
|
+
raise PolicyError(f"invalid policy metadata: {error}") from error
|
|
51
|
+
profiles, rules, config = manifest.get("profiles"), manifest.get("rules"), manifest.get("config")
|
|
52
|
+
if not isinstance(profiles, dict) or not isinstance(rules, dict):
|
|
53
|
+
raise PolicyError("policy manifest has an invalid profile or rule inventory")
|
|
54
|
+
if not isinstance(config, dict) or config.get("path") != ".vale.ini" or not isinstance(config.get("sha256"), str):
|
|
55
|
+
raise PolicyError("invalid policy config metadata")
|
|
56
|
+
config_bytes = policy_file(vale, config["path"]).read_bytes()
|
|
57
|
+
if hashlib.sha256(config_bytes).hexdigest() != config["sha256"]:
|
|
58
|
+
raise PolicyError("policy config digest mismatch")
|
|
59
|
+
if profile not in profiles:
|
|
60
|
+
raise UnknownProfile(sorted(profiles))
|
|
61
|
+
selected = profiles[profile]
|
|
62
|
+
if not isinstance(selected, dict) or not isinstance(selected.get("policy"), str):
|
|
63
|
+
raise PolicyError(f"invalid policy profile: {profile}")
|
|
64
|
+
upstream = lock.get("upstream")
|
|
65
|
+
if not isinstance(upstream, dict) or not isinstance(upstream.get("license_file"), str) or not isinstance(upstream.get("license_sha256"), str):
|
|
66
|
+
raise PolicyError("invalid policy license metadata")
|
|
67
|
+
license_path = policy_file(vale, upstream["license_file"])
|
|
68
|
+
license_bytes = license_path.read_bytes()
|
|
69
|
+
if hashlib.sha256(license_bytes).hexdigest() != upstream["license_sha256"]:
|
|
70
|
+
raise PolicyError("policy license digest mismatch")
|
|
71
|
+
declared: set[str] = set()
|
|
72
|
+
selected_files: list[tuple[str, bytes]] = []
|
|
73
|
+
for name, metadata in sorted(rules.items()):
|
|
74
|
+
if not isinstance(name, str) or not isinstance(metadata, dict):
|
|
75
|
+
raise PolicyError("invalid policy rule metadata")
|
|
76
|
+
relative, digest, rule_profiles = metadata.get("path"), metadata.get("sha256"), metadata.get("profiles")
|
|
77
|
+
if not isinstance(relative, str) or not isinstance(digest, str) or not isinstance(rule_profiles, list):
|
|
78
|
+
raise PolicyError(f"invalid policy rule metadata: {name}")
|
|
79
|
+
if profile not in rule_profiles or not all(isinstance(item, str) for item in rule_profiles):
|
|
80
|
+
raise PolicyError(f"invalid policy rule profile closure: {name}")
|
|
81
|
+
path = policy_file(vale, relative)
|
|
82
|
+
contents = path.read_bytes()
|
|
83
|
+
if hashlib.sha256(contents).hexdigest() != digest:
|
|
84
|
+
raise PolicyError(f"policy rule digest mismatch: {relative}")
|
|
85
|
+
declared.add(relative)
|
|
86
|
+
selected_files.append((relative, contents))
|
|
87
|
+
actual = {path.relative_to(vale).as_posix() for path in (vale / "styles" / "GoogleDocs").glob("*.yml")}
|
|
88
|
+
if not declared or actual != declared:
|
|
89
|
+
raise PolicyError("policy rule inventory mismatch")
|
|
90
|
+
identity = [(".vale.ini", config_bytes), ("profiles.json", manifest_bytes), ("upstream-lock.json", lock_bytes), (upstream["license_file"], license_bytes), *selected_files]
|
|
91
|
+
digest = hashlib.sha256()
|
|
92
|
+
for relative, contents in identity:
|
|
93
|
+
digest.update(relative.encode("utf-8")); digest.update(b"\0"); digest.update(contents); digest.update(b"\0")
|
|
94
|
+
return selected, digest.hexdigest()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def source(path: str) -> tuple[str, str, bytes]:
|
|
98
|
+
if path == "-":
|
|
99
|
+
return "stdin", "stdin", sys.stdin.buffer.read()
|
|
100
|
+
file = pathlib.Path(path).expanduser().resolve()
|
|
101
|
+
if not file.is_file():
|
|
102
|
+
raise OSError(f"input is not a regular file: {file}")
|
|
103
|
+
return "file", str(file), file.read_bytes()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def prose_parts(text: str) -> tuple[str, str]:
|
|
107
|
+
included, excluded = [], []
|
|
108
|
+
fence = False
|
|
109
|
+
for line in text.splitlines():
|
|
110
|
+
if re.match(r"^\s*(```|~~~)", line):
|
|
111
|
+
fence = not fence
|
|
112
|
+
continue
|
|
113
|
+
cleaned = re.sub(r"`[^`]*`|https?://\S+|!\[[^]]*\]\([^)]*\)", " ", line)
|
|
114
|
+
cleaned = re.sub(r"\[([^]]+)\]\([^)]*\)", r"\1", cleaned)
|
|
115
|
+
if fence or line.startswith("---") or re.match(r"^\s*(#|<!--|<[^>]+>)", line):
|
|
116
|
+
excluded.append(cleaned)
|
|
117
|
+
elif "|" in line or re.match(r"^\s*[-*+]\s+\S{1,4}\s*$", line):
|
|
118
|
+
excluded.append(cleaned)
|
|
119
|
+
else:
|
|
120
|
+
included.append(cleaned)
|
|
121
|
+
return " ".join(included), " ".join(excluded)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def readability(text: str, profile: str) -> dict[str, Any]:
|
|
125
|
+
included, excluded = prose_parts(text)
|
|
126
|
+
words, excluded_words = WORD.findall(included), WORD.findall(excluded)
|
|
127
|
+
sentences = len(SENTENCE.findall(included))
|
|
128
|
+
if profile == "commit":
|
|
129
|
+
return {"status": "disabled", "words": len(words), "sentences": sentences, "targets": None}
|
|
130
|
+
if profile in {"readme", "technical-doc-reference"} and len(excluded_words) > len(words):
|
|
131
|
+
return {"status": "composition_limited", "words": len(words), "sentences": sentences, "excluded_words": len(excluded_words), "targets": None}
|
|
132
|
+
minimum_words, minimum_sentences = (200, 8) if profile in {"pr-description", "user-facing-response"} else (300, 8)
|
|
133
|
+
state = "insufficient_sample" if len(words) < 100 or sentences < 5 else "small_sample" if len(words) < minimum_words or sentences < minimum_sentences else "normal"
|
|
134
|
+
result: dict[str, Any] = {"status": state, "words": len(words), "sentences": sentences, "targets": {"fre_min": 55, "fk_max": 10} if state == "normal" else None}
|
|
135
|
+
if state == "normal":
|
|
136
|
+
syllables = sum(max(1, len(re.findall(r"[aeiouy]+", word.lower()))) for word in words)
|
|
137
|
+
result["flesch_reading_ease"] = round(206.835 - 1.015 * len(words) / sentences - 84.6 * syllables / len(words), 2)
|
|
138
|
+
result["flesch_kincaid_grade"] = round(.39 * len(words) / sentences + 11.8 * syllables / len(words) - 15.59, 2)
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize_vale(raw: str) -> list[dict[str, Any]]:
|
|
143
|
+
try:
|
|
144
|
+
document = json.loads(raw)
|
|
145
|
+
except json.JSONDecodeError as error:
|
|
146
|
+
raise ValueError(f"invalid Vale JSON: {error}") from error
|
|
147
|
+
if not isinstance(document, dict):
|
|
148
|
+
raise ValueError("invalid Vale JSON root: expected an object")
|
|
149
|
+
alerts: list[dict[str, Any]] = []
|
|
150
|
+
for path, items in sorted(document.items()):
|
|
151
|
+
if not isinstance(items, list):
|
|
152
|
+
raise ValueError(f"invalid Vale alerts for {path}: expected a list")
|
|
153
|
+
for item in items:
|
|
154
|
+
if not isinstance(item, dict):
|
|
155
|
+
raise ValueError(f"invalid Vale alert for {path}: expected an object")
|
|
156
|
+
check, message, line, span = item.get("Check"), item.get("Message"), item.get("Line"), item.get("Span")
|
|
157
|
+
if not isinstance(check, str) or not check or not isinstance(message, str) or not message:
|
|
158
|
+
raise ValueError(f"invalid Vale alert fields for {path}")
|
|
159
|
+
if isinstance(line, bool) or not isinstance(line, int) or line < 1:
|
|
160
|
+
raise ValueError(f"invalid Vale alert line for {path}")
|
|
161
|
+
if not isinstance(span, list) or len(span) != 2 or any(isinstance(value, bool) or not isinstance(value, int) for value in span) or span[0] < 1 or span[1] < span[0]:
|
|
162
|
+
raise ValueError(f"invalid Vale alert span for {path}")
|
|
163
|
+
alerts.append({"source": "vale", "rule": check, "message": message, "severity": "suggestion", "line": line, "span": span, "path": path})
|
|
164
|
+
return alerts
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def print_record(record: dict[str, Any]) -> None:
|
|
168
|
+
print(json.dumps(record, indent=2))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_vale() -> str | None:
|
|
172
|
+
"""Prefer Vale installed beside this command's Python interpreter."""
|
|
173
|
+
environment_vale = pathlib.Path(sys.executable).parent / "vale"
|
|
174
|
+
if environment_vale.is_file() and os.access(environment_vale, os.X_OK):
|
|
175
|
+
return str(environment_vale)
|
|
176
|
+
return shutil.which("vale")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> int:
|
|
180
|
+
parser = argparse.ArgumentParser()
|
|
181
|
+
parser.add_argument("--profile", required=True)
|
|
182
|
+
parser.add_argument("--json", action="store_true")
|
|
183
|
+
parser.add_argument("input")
|
|
184
|
+
args = parser.parse_args()
|
|
185
|
+
try:
|
|
186
|
+
selected_policy, policy_digest = load_policy(args.profile)
|
|
187
|
+
except UnknownProfile as error:
|
|
188
|
+
print_record({"schema": 3, "status": "input_error", "error": {"code": "unknown_profile", "message": f"unknown profile: {args.profile}"}, "allowed_profiles": error.profiles})
|
|
189
|
+
return EXIT_INPUT
|
|
190
|
+
except PolicyError as error:
|
|
191
|
+
print_record({"schema": 3, "status": "execution_error", "error": {"code": "policy_validation_failed", "message": str(error)}})
|
|
192
|
+
return EXIT_EXECUTION
|
|
193
|
+
try:
|
|
194
|
+
kind, identifier, data = source(args.input)
|
|
195
|
+
text = data.decode("utf-8")
|
|
196
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
197
|
+
print_record({"schema": 3, "status": "input_error", "profile": args.profile, "error": {"code": "invalid_utf8" if isinstance(error, UnicodeDecodeError) else "input_read_failed", "message": str(error)}})
|
|
198
|
+
return EXIT_INPUT
|
|
199
|
+
record: dict[str, Any] = {"schema": 3, "status": None, "advisory": True, "profile": args.profile, "policy": {"name": selected_policy["policy"], "sha256": policy_digest}, "source": {"kind": kind, "identifier": identifier, "encoding": "utf-8", "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), "text": text}, "readability": readability(text, args.profile), "alerts": [], "vale": {}}
|
|
200
|
+
vale = resolve_vale()
|
|
201
|
+
if not vale:
|
|
202
|
+
record.update(status="unavailable", vale={"status": "unavailable", "reason": "Vale was not found; no substitute was run."})
|
|
203
|
+
print_record(record)
|
|
204
|
+
return EXIT_UNAVAILABLE
|
|
205
|
+
version_argv = [vale, "--version"]
|
|
206
|
+
try:
|
|
207
|
+
version_run = subprocess.run(version_argv, capture_output=True, text=False, timeout=30)
|
|
208
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
209
|
+
version: dict[str, Any] = {"argv": version_argv}
|
|
210
|
+
if isinstance(error, subprocess.TimeoutExpired):
|
|
211
|
+
if error.stdout is not None:
|
|
212
|
+
version["stdout"] = error.stdout.decode("utf-8", errors="replace")
|
|
213
|
+
if error.stderr is not None:
|
|
214
|
+
version["stderr"] = error.stderr.decode("utf-8", errors="replace")
|
|
215
|
+
record.update(status="execution_error", vale={"status": "execution_error", "version": version, "error": str(error)})
|
|
216
|
+
print_record(record)
|
|
217
|
+
return EXIT_EXECUTION
|
|
218
|
+
try:
|
|
219
|
+
version_stdout, version_stderr = version_run.stdout.decode("utf-8"), version_run.stderr.decode("utf-8")
|
|
220
|
+
except UnicodeDecodeError as error:
|
|
221
|
+
version = {"argv": version_argv, "exit": version_run.returncode, "stdout": version_run.stdout.decode("utf-8", errors="replace"), "stderr": version_run.stderr.decode("utf-8", errors="replace")}
|
|
222
|
+
record.update(status="execution_error", vale={"status": "execution_error", "version": version, "error": f"invalid UTF-8 in Vale version output: {error}"})
|
|
223
|
+
print_record(record)
|
|
224
|
+
return EXIT_EXECUTION
|
|
225
|
+
version = {"argv": version_argv, "exit": version_run.returncode, "stdout": version_stdout, "stderr": version_stderr}
|
|
226
|
+
if version_run.returncode != 0:
|
|
227
|
+
record.update(status="execution_error", vale={"status": "execution_error", "version": version, "error": f"Vale version exited {version_run.returncode}"})
|
|
228
|
+
print_record(record)
|
|
229
|
+
return EXIT_EXECUTION
|
|
230
|
+
lint_argv = [vale, "--no-global", f"--config={ROOT / 'vale' / '.vale.ini'}", "--no-exit", "--output=JSON", "--ext=.md", "-"]
|
|
231
|
+
lint: dict[str, Any] = {"argv": lint_argv}
|
|
232
|
+
record["vale"] = {"status": "ran", "version": version, "lint": lint}
|
|
233
|
+
try:
|
|
234
|
+
lint_run = subprocess.run(lint_argv, input=data, capture_output=True, text=False, timeout=30)
|
|
235
|
+
try:
|
|
236
|
+
stdout, stderr = lint_run.stdout.decode("utf-8"), lint_run.stderr.decode("utf-8")
|
|
237
|
+
except UnicodeDecodeError as error:
|
|
238
|
+
lint = {"argv": lint_argv, "exit": lint_run.returncode, "stdout": lint_run.stdout.decode("utf-8", errors="replace"), "stderr": lint_run.stderr.decode("utf-8", errors="replace")}
|
|
239
|
+
record["vale"] = {"status": "ran", "version": version, "lint": lint, **lint}
|
|
240
|
+
raise ValueError(f"invalid UTF-8 in Vale output: {error}") from error
|
|
241
|
+
lint = {"argv": lint_argv, "exit": lint_run.returncode, "stdout": stdout, "stderr": stderr}
|
|
242
|
+
record["vale"] = {"status": "ran", "version": version, "lint": lint, **lint}
|
|
243
|
+
if lint_run.returncode not in (0, 1):
|
|
244
|
+
raise RuntimeError(f"Vale exited {lint_run.returncode}")
|
|
245
|
+
record["alerts"] = normalize_vale(stdout)
|
|
246
|
+
except (OSError, UnicodeDecodeError, subprocess.TimeoutExpired, ValueError, RuntimeError) as error:
|
|
247
|
+
if isinstance(error, subprocess.TimeoutExpired):
|
|
248
|
+
if error.stdout is not None:
|
|
249
|
+
lint["stdout"] = error.stdout.decode("utf-8", errors="replace")
|
|
250
|
+
if error.stderr is not None:
|
|
251
|
+
lint["stderr"] = error.stderr.decode("utf-8", errors="replace")
|
|
252
|
+
vale_record = record.get("vale", {})
|
|
253
|
+
vale_record.update(status="execution_error", version=version, error=str(error))
|
|
254
|
+
record.update(status="execution_error", vale=vale_record)
|
|
255
|
+
print_record(record)
|
|
256
|
+
return EXIT_EXECUTION
|
|
257
|
+
record["status"] = "findings" if record["alerts"] else "no_findings"
|
|
258
|
+
print_record(record)
|
|
259
|
+
return EXIT_FINDINGS if record["alerts"] else EXIT_CLEAN
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def entrypoint() -> None:
|
|
263
|
+
"""Run the installed Writing Check command."""
|
|
264
|
+
raise SystemExit(main())
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
entrypoint()
|
writing_check/sweep.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run explicitly supplied Writing Check candidates in one Python process.
|
|
3
|
+
|
|
4
|
+
This caller intentionally does not discover repository files or alter the
|
|
5
|
+
single-candidate checker contract. It emits one unchanged schema-3 JSON record
|
|
6
|
+
per input candidate (JSON Lines).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import contextlib
|
|
12
|
+
import importlib
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import pathlib
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any, Callable, Protocol, TypeAlias, cast
|
|
19
|
+
|
|
20
|
+
CHECKER_PATH = pathlib.Path(__file__).with_name("cli.py")
|
|
21
|
+
ProbeFailure: TypeAlias = OSError | subprocess.TimeoutExpired
|
|
22
|
+
ProbeResult: TypeAlias = subprocess.CompletedProcess[bytes] | ProbeFailure
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _SubprocessModule(Protocol):
|
|
26
|
+
run: Callable[..., subprocess.CompletedProcess[bytes]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Checker(Protocol):
|
|
30
|
+
subprocess: _SubprocessModule
|
|
31
|
+
|
|
32
|
+
def main(self) -> int: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_checker() -> _Checker:
|
|
36
|
+
return cast(_Checker, importlib.import_module("writing_check.cli"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _input_error(message: str) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"schema": 3,
|
|
42
|
+
"status": "input_error",
|
|
43
|
+
"error": {"code": "invalid_candidates", "message": message},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _candidates(path: pathlib.Path) -> list[tuple[str, str]]:
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
51
|
+
raise ValueError(f"cannot read candidates: {error}") from error
|
|
52
|
+
if not isinstance(data, list) or not data:
|
|
53
|
+
raise ValueError("candidates must be a non-empty JSON array")
|
|
54
|
+
result: list[tuple[str, str]] = []
|
|
55
|
+
for index, item in enumerate(data):
|
|
56
|
+
if not isinstance(item, dict) or set(item) != {"source", "profile"}:
|
|
57
|
+
raise ValueError(f"candidate {index} must contain exactly source and profile")
|
|
58
|
+
source, profile = item["source"], item["profile"]
|
|
59
|
+
if not isinstance(source, str) or not source or source == "-" or not isinstance(profile, str):
|
|
60
|
+
raise ValueError(f"candidate {index} has an invalid source or profile")
|
|
61
|
+
result.append((source, profile))
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run(candidates: list[tuple[str, str]]) -> tuple[list[dict[str, Any]], int]:
|
|
66
|
+
"""Return independent checker records while sharing only Vale version probing."""
|
|
67
|
+
checker = _load_checker()
|
|
68
|
+
original_run = checker.subprocess.run
|
|
69
|
+
cached_versions: dict[tuple[str, ...], ProbeResult] = {}
|
|
70
|
+
|
|
71
|
+
def cached_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
|
72
|
+
if argv[1:] != ["--version"]:
|
|
73
|
+
return original_run(argv, **kwargs)
|
|
74
|
+
key = tuple(argv)
|
|
75
|
+
if key not in cached_versions:
|
|
76
|
+
try:
|
|
77
|
+
cached_versions[key] = original_run(argv, **kwargs)
|
|
78
|
+
except (OSError, subprocess.TimeoutExpired) as error: # checker serializes its own probe failure
|
|
79
|
+
cached_versions[key] = error
|
|
80
|
+
cached_version = cached_versions[key]
|
|
81
|
+
if isinstance(cached_version, (OSError, subprocess.TimeoutExpired)):
|
|
82
|
+
raise cached_version
|
|
83
|
+
return cached_version
|
|
84
|
+
|
|
85
|
+
checker.subprocess.run = cached_run
|
|
86
|
+
records: list[dict[str, Any]] = []
|
|
87
|
+
exit_code = 0
|
|
88
|
+
old_argv = sys.argv
|
|
89
|
+
try:
|
|
90
|
+
for source, profile in candidates:
|
|
91
|
+
output = io.StringIO()
|
|
92
|
+
sys.argv = [str(CHECKER_PATH), f"--profile={profile}", "--json", "--", source]
|
|
93
|
+
with contextlib.redirect_stdout(output):
|
|
94
|
+
code = checker.main()
|
|
95
|
+
records.append(json.loads(output.getvalue()))
|
|
96
|
+
exit_code = max(exit_code, code)
|
|
97
|
+
finally:
|
|
98
|
+
sys.argv = old_argv
|
|
99
|
+
checker.subprocess.run = original_run
|
|
100
|
+
return records, exit_code
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main() -> int:
|
|
104
|
+
parser = argparse.ArgumentParser(description="bounded explicit Writing Check sweep")
|
|
105
|
+
parser.add_argument("--candidates", required=True, metavar="JSON", help="JSON array of exact {source, profile} pairs")
|
|
106
|
+
parser.add_argument("--jsonl", action="store_true", required=True, help="emit one schema-3 record per line")
|
|
107
|
+
args = parser.parse_args()
|
|
108
|
+
try:
|
|
109
|
+
candidates = _candidates(pathlib.Path(args.candidates))
|
|
110
|
+
except ValueError as error:
|
|
111
|
+
print(json.dumps(_input_error(str(error))))
|
|
112
|
+
return 2
|
|
113
|
+
records, code = run(candidates)
|
|
114
|
+
for record in records:
|
|
115
|
+
print(json.dumps(record))
|
|
116
|
+
return code
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
raise SystemExit(main())
|