prose-preflight 0.1.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.
- prose_preflight/__init__.py +0 -0
- prose_preflight/checks/__init__.py +25 -0
- prose_preflight/checks/_text.py +60 -0
- prose_preflight/checks/acronym.py +46 -0
- prose_preflight/checks/claim.py +34 -0
- prose_preflight/checks/readability.py +60 -0
- prose_preflight/checks/sentence_length.py +30 -0
- prose_preflight/checks/structure.py +46 -0
- prose_preflight/checks/terminology.py +38 -0
- prose_preflight/checks/units.py +66 -0
- prose_preflight/checks/vale.py +92 -0
- prose_preflight/default.yaml +161 -0
- prose_preflight/extract/__init__.py +17 -0
- prose_preflight/extract/document.py +59 -0
- prose_preflight/extract/latex.py +89 -0
- prose_preflight/extract/markdown.py +51 -0
- prose_preflight/finding.py +16 -0
- prose_preflight/run_all.py +190 -0
- prose_preflight-0.1.0.dist-info/METADATA +215 -0
- prose_preflight-0.1.0.dist-info/RECORD +23 -0
- prose_preflight-0.1.0.dist-info/WHEEL +4 -0
- prose_preflight-0.1.0.dist-info/entry_points.txt +2 -0
- prose_preflight-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Checker registry. Adding a checker is adding a module and one line here."""
|
|
2
|
+
|
|
3
|
+
from prose_preflight.checks import (
|
|
4
|
+
acronym,
|
|
5
|
+
claim,
|
|
6
|
+
readability,
|
|
7
|
+
sentence_length,
|
|
8
|
+
structure,
|
|
9
|
+
terminology,
|
|
10
|
+
units,
|
|
11
|
+
vale,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
CHECKERS = {
|
|
15
|
+
"acronym": acronym.check,
|
|
16
|
+
"claim": claim.check,
|
|
17
|
+
"readability": readability.check,
|
|
18
|
+
"sentence_length": sentence_length.check,
|
|
19
|
+
"structure": structure.check,
|
|
20
|
+
"terminology": terminology.check,
|
|
21
|
+
"units": units.check,
|
|
22
|
+
"vale": vale.check,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
__all__ = ["CHECKERS"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared paragraph and sentence helpers. Not a checker, not registered."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from prose_preflight.extract import Document
|
|
6
|
+
|
|
7
|
+
# ponytail: regex sentence split -- misses "e.g. Foo" and "Fig. 3 shows". No spaCy for
|
|
8
|
+
# this; upgrade the lookbehind when a fixture shows a real miss.
|
|
9
|
+
SENTENCE_END = re.compile(r"(?<=[.!?])[\"')\]]*\s+(?=[\"'(\[]*[A-Z0-9])")
|
|
10
|
+
WORD = re.compile(r"\S+")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def alternation(words, ignore_case: bool = True):
|
|
14
|
+
"""Compile `words` into one word-bounded alternation. Longest first, so a variant
|
|
15
|
+
that starts another one still matches in full."""
|
|
16
|
+
return re.compile(
|
|
17
|
+
r"\b(?:{})\b".format(
|
|
18
|
+
"|".join(re.escape(w) for w in sorted(words, key=len, reverse=True))
|
|
19
|
+
),
|
|
20
|
+
re.IGNORECASE if ignore_case else 0,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def paragraphs(doc: Document):
|
|
25
|
+
"""Yield (text, positions) per paragraph: masked text joined with single spaces,
|
|
26
|
+
plus a (line, col) per character, so a match offset maps back to the source."""
|
|
27
|
+
for first, last in doc.paragraphs:
|
|
28
|
+
chunks, positions = [], []
|
|
29
|
+
for n in range(first, last + 1):
|
|
30
|
+
line = doc.masked[n - 1]
|
|
31
|
+
if positions:
|
|
32
|
+
chunks.append(" ")
|
|
33
|
+
positions.append(positions[-1])
|
|
34
|
+
chunks.append(line)
|
|
35
|
+
positions += [(n, col) for col in range(1, len(line) + 1)]
|
|
36
|
+
yield "".join(chunks), positions
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sentences(text: str):
|
|
40
|
+
"""Yield (offset, sentence) over `text`."""
|
|
41
|
+
start = 0
|
|
42
|
+
for split in SENTENCE_END.finditer(text):
|
|
43
|
+
yield start, text[start : split.start()]
|
|
44
|
+
start = split.end()
|
|
45
|
+
if text[start:].strip():
|
|
46
|
+
yield start, text[start:]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def truncate(text: str, limit: int) -> str:
|
|
50
|
+
"""First `limit` characters, with an ellipsis when something was cut."""
|
|
51
|
+
return text[:limit].strip() + ("\u2026" if len(text) > limit else "")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def excerpt(line: str, start: int, end: int, pad: int = 30) -> str:
|
|
55
|
+
left, right = max(0, start - pad), min(len(line), end + pad)
|
|
56
|
+
return (
|
|
57
|
+
("…" if left else "")
|
|
58
|
+
+ line[left:right].strip()
|
|
59
|
+
+ ("…" if right < len(line) else "")
|
|
60
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""First-use expansion tracking. An acronym is defined by a parenthesized pairing."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from prose_preflight.checks._text import excerpt
|
|
6
|
+
from prose_preflight.extract import Document
|
|
7
|
+
from prose_preflight.finding import Finding
|
|
8
|
+
|
|
9
|
+
ACRONYM = re.compile(r"\b([A-Z]{2,})s?\b")
|
|
10
|
+
# "Deep Neural Network (DNN)" -- the words before the parenthesis define it.
|
|
11
|
+
DEFINED_AFTER = re.compile(r"(?:\b[\w-]+\s+){1,6}\(([A-Z]{2,})s?\)")
|
|
12
|
+
# "DNN (deep neural network)" -- the parenthesis expands it.
|
|
13
|
+
DEFINED_BEFORE = re.compile(r"\b([A-Z]{2,})s?\s*\((?=[^)]*[a-z])[^)]{4,}\)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check(doc: Document, rule: dict) -> list[Finding]:
|
|
17
|
+
allow = {form.upper() for form in rule.get("allowlist", [])}
|
|
18
|
+
minimum = rule.get("min_uses", 1)
|
|
19
|
+
severity = rule.get("severity", "warning")
|
|
20
|
+
uses, defined = {}, set()
|
|
21
|
+
for n, line in doc.prose():
|
|
22
|
+
defined |= {m.group(1) for m in DEFINED_AFTER.finditer(line)}
|
|
23
|
+
defined |= {m.group(1) for m in DEFINED_BEFORE.finditer(line)}
|
|
24
|
+
for m in ACRONYM.finditer(line):
|
|
25
|
+
form = m.group(1)
|
|
26
|
+
if form in allow or form in defined:
|
|
27
|
+
continue
|
|
28
|
+
uses.setdefault(form, []).append((n, m))
|
|
29
|
+
findings = []
|
|
30
|
+
for form, hits in uses.items():
|
|
31
|
+
if len(hits) < minimum:
|
|
32
|
+
continue
|
|
33
|
+
n, m = hits[0]
|
|
34
|
+
findings.append(
|
|
35
|
+
Finding(
|
|
36
|
+
check="acronym.undefined",
|
|
37
|
+
category="acronym",
|
|
38
|
+
severity=severity,
|
|
39
|
+
line=n,
|
|
40
|
+
col=m.start() + 1,
|
|
41
|
+
excerpt=excerpt(doc.lines[n - 1], m.start(), m.end()),
|
|
42
|
+
message=f"{form!r} is used {len(hits)}× but never expanded; spell it out on first use.",
|
|
43
|
+
section=doc.section_at(n),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return findings
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Hedges and boosters. Emits `review` only -- never auto-applied, by design."""
|
|
2
|
+
|
|
3
|
+
from prose_preflight.checks._text import alternation, excerpt
|
|
4
|
+
from prose_preflight.extract import Document
|
|
5
|
+
from prose_preflight.finding import Finding
|
|
6
|
+
|
|
7
|
+
KINDS = {
|
|
8
|
+
"hedge": "Hedge -- check the claim is not weaker than the evidence.",
|
|
9
|
+
"booster": "Booster -- check the evidence supports a claim this strong.",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check(doc: Document, rule: dict) -> list[Finding]:
|
|
14
|
+
findings = []
|
|
15
|
+
for kind, message in KINDS.items():
|
|
16
|
+
words = rule.get(f"{kind}s", [])
|
|
17
|
+
if not words:
|
|
18
|
+
continue
|
|
19
|
+
pattern = alternation(words)
|
|
20
|
+
for n, line in doc.prose():
|
|
21
|
+
for m in pattern.finditer(line):
|
|
22
|
+
findings.append(
|
|
23
|
+
Finding(
|
|
24
|
+
check=f"claim.{kind}",
|
|
25
|
+
category="claim",
|
|
26
|
+
severity="review", # load-bearing: never auto-applied
|
|
27
|
+
line=n,
|
|
28
|
+
col=m.start() + 1,
|
|
29
|
+
excerpt=excerpt(doc.lines[n - 1], m.start(), m.end()),
|
|
30
|
+
message=f"{message} ({m.group()!r})",
|
|
31
|
+
section=doc.section_at(n),
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
return findings
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Per-section readability scores from textstat, against YAML thresholds."""
|
|
2
|
+
|
|
3
|
+
from bisect import bisect_right
|
|
4
|
+
|
|
5
|
+
import textstat
|
|
6
|
+
|
|
7
|
+
from prose_preflight.extract import Document
|
|
8
|
+
from prose_preflight.finding import Finding
|
|
9
|
+
|
|
10
|
+
METRICS = { # rule suffix: (textstat function, comparison, threshold key)
|
|
11
|
+
"flesch": (textstat.flesch_reading_ease, "min", "min_flesch"),
|
|
12
|
+
"fog": (textstat.gunning_fog, "max", "max_fog"),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check(doc: Document, rule: dict) -> list[Finding]:
|
|
17
|
+
severity = rule.get("severity", "warning")
|
|
18
|
+
min_words = rule.get("min_words", 50)
|
|
19
|
+
findings = []
|
|
20
|
+
for line, title, text, words in _sections(doc):
|
|
21
|
+
if words < min_words: # short sections score as noise
|
|
22
|
+
continue
|
|
23
|
+
for name, (score_of, direction, key) in METRICS.items():
|
|
24
|
+
limit = rule.get(key)
|
|
25
|
+
score = round(score_of(text), 1)
|
|
26
|
+
if limit is None or (
|
|
27
|
+
score >= limit if direction == "min" else score <= limit
|
|
28
|
+
):
|
|
29
|
+
continue
|
|
30
|
+
findings.append(
|
|
31
|
+
Finding(
|
|
32
|
+
check=f"readability.{name}",
|
|
33
|
+
category="readability",
|
|
34
|
+
severity=severity,
|
|
35
|
+
line=line,
|
|
36
|
+
col=1,
|
|
37
|
+
excerpt=title or str(doc.path.name),
|
|
38
|
+
message=(
|
|
39
|
+
f"{name} score {score} is "
|
|
40
|
+
f"{'below' if direction == 'min' else 'above'} the {limit} "
|
|
41
|
+
f"threshold ({words} words); shorten sentences or simplify wording."
|
|
42
|
+
),
|
|
43
|
+
section=title,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return findings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sections(doc: Document):
|
|
50
|
+
"""Yield (line, title, text, word count) per section; the whole file if unheaded."""
|
|
51
|
+
bounds = [(line, title) for line, _, title in doc.headings] or [(1, None)]
|
|
52
|
+
starts = [line for line, _ in bounds]
|
|
53
|
+
chunks = [[] for _ in bounds]
|
|
54
|
+
for first, last in doc.paragraphs:
|
|
55
|
+
owner = bisect_right(starts, first) - 1
|
|
56
|
+
if owner >= 0: # paragraphs above the first heading belong to no section
|
|
57
|
+
chunks[owner] += [doc.masked[n - 1].strip() for n in range(first, last + 1)]
|
|
58
|
+
for (start, title), parts in zip(bounds, chunks):
|
|
59
|
+
text = " ".join(parts)
|
|
60
|
+
yield start, title, text, len(text.split())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Sentence-length outliers. Regex segmentation, word count against a YAML maximum."""
|
|
2
|
+
|
|
3
|
+
from prose_preflight.checks._text import WORD, paragraphs, sentences, truncate
|
|
4
|
+
from prose_preflight.extract import Document
|
|
5
|
+
from prose_preflight.finding import Finding
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check(doc: Document, rule: dict) -> list[Finding]:
|
|
9
|
+
maximum = rule.get("max_words", 40)
|
|
10
|
+
severity = rule.get("severity", "warning")
|
|
11
|
+
findings = []
|
|
12
|
+
for text, positions in paragraphs(doc):
|
|
13
|
+
for offset, sentence in sentences(text):
|
|
14
|
+
words = len(WORD.findall(sentence))
|
|
15
|
+
if words <= maximum:
|
|
16
|
+
continue
|
|
17
|
+
line, col = positions[offset]
|
|
18
|
+
findings.append(
|
|
19
|
+
Finding(
|
|
20
|
+
check="sentence_length.long",
|
|
21
|
+
category="readability",
|
|
22
|
+
severity=severity,
|
|
23
|
+
line=line,
|
|
24
|
+
col=col,
|
|
25
|
+
excerpt=truncate(sentence, 40),
|
|
26
|
+
message=f"Sentence runs {words} words (limit {maximum}); split it.",
|
|
27
|
+
section=doc.section_at(line),
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
return findings
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Required sections and their order, from the YAML list. Titles match case-insensitively."""
|
|
2
|
+
|
|
3
|
+
from itertools import pairwise
|
|
4
|
+
|
|
5
|
+
from prose_preflight.extract import Document
|
|
6
|
+
from prose_preflight.finding import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check(doc: Document, rule: dict) -> list[Finding]:
|
|
10
|
+
required = rule.get("required_sections", [])
|
|
11
|
+
if not required:
|
|
12
|
+
return []
|
|
13
|
+
found = {} # required title (as configured) -> source line of its first match
|
|
14
|
+
for line, _, title in doc.headings:
|
|
15
|
+
for want in required:
|
|
16
|
+
if title.strip().lower() == want.lower() and want not in found:
|
|
17
|
+
found[want] = line
|
|
18
|
+
findings = [
|
|
19
|
+
Finding(
|
|
20
|
+
check="structure.missing_section",
|
|
21
|
+
category="structure",
|
|
22
|
+
severity=rule.get("severity", "error"),
|
|
23
|
+
line=1,
|
|
24
|
+
col=1,
|
|
25
|
+
excerpt=doc.path.name,
|
|
26
|
+
message=f"Required section {want!r} is missing.",
|
|
27
|
+
)
|
|
28
|
+
for want in required
|
|
29
|
+
if want not in found
|
|
30
|
+
]
|
|
31
|
+
present = [want for want in required if want in found]
|
|
32
|
+
for earlier, later in pairwise(present):
|
|
33
|
+
if found[later] < found[earlier]:
|
|
34
|
+
findings.append(
|
|
35
|
+
Finding(
|
|
36
|
+
check="structure.section_order",
|
|
37
|
+
category="structure",
|
|
38
|
+
severity=rule.get("order_severity", "warning"),
|
|
39
|
+
line=found[later],
|
|
40
|
+
col=1,
|
|
41
|
+
excerpt=later,
|
|
42
|
+
message=f"Section {later!r} appears before {earlier!r}; expected order is {' → '.join(required)}.",
|
|
43
|
+
section=later,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return findings
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Preferred-form enforcement. The table is data; this module is the matcher."""
|
|
2
|
+
|
|
3
|
+
from prose_preflight.checks._text import alternation, excerpt
|
|
4
|
+
from prose_preflight.extract import Document
|
|
5
|
+
from prose_preflight.finding import Finding
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check(doc: Document, rule: dict) -> list[Finding]:
|
|
9
|
+
severity = rule.get("severity", "warning")
|
|
10
|
+
findings = []
|
|
11
|
+
for preferred, variants in rule.get("terms", {}).items():
|
|
12
|
+
pattern = alternation(variants, rule.get("ignore_case", True))
|
|
13
|
+
for n, line in doc.prose():
|
|
14
|
+
for m in pattern.finditer(line):
|
|
15
|
+
if m.group() == preferred:
|
|
16
|
+
continue
|
|
17
|
+
findings.append(
|
|
18
|
+
Finding(
|
|
19
|
+
check="terminology.variant",
|
|
20
|
+
category="terminology",
|
|
21
|
+
severity=severity,
|
|
22
|
+
line=n,
|
|
23
|
+
col=m.start() + 1,
|
|
24
|
+
excerpt=excerpt(doc.lines[n - 1], m.start(), m.end()),
|
|
25
|
+
message=f"Use {preferred!r}, not {m.group()!r}.",
|
|
26
|
+
section=doc.section_at(n),
|
|
27
|
+
suggestion=_match_case(m.group(), preferred),
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
return findings
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _match_case(found: str, preferred: str) -> str:
|
|
34
|
+
"""Carry the variant's case over to the suggestion. A single capital letter is
|
|
35
|
+
sentence case, not an acronym, so only multi-character ALL CAPS becomes ALL CAPS."""
|
|
36
|
+
if len(found) > 1 and found.isupper():
|
|
37
|
+
return preferred.upper()
|
|
38
|
+
return preferred[:1].upper() + preferred[1:] if found[:1].isupper() else preferred
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Number and unit *formatting* only -- spacing and dashes. Regex, not Pint."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from prose_preflight.checks._text import excerpt
|
|
6
|
+
from prose_preflight.extract import Document
|
|
7
|
+
from prose_preflight.finding import Finding
|
|
8
|
+
|
|
9
|
+
NBSP = " "
|
|
10
|
+
RULES = { # rule suffix: (pattern template, replacement, message)
|
|
11
|
+
"space_before_unit": (
|
|
12
|
+
r"(?<![\w.])(\d+(?:[.,]\d+)?)[ ]({units})\b",
|
|
13
|
+
lambda m: f"{m.group(1)}{NBSP}{m.group(2)}",
|
|
14
|
+
"Use a non-breaking space between value and unit.",
|
|
15
|
+
),
|
|
16
|
+
"percent_spacing": (
|
|
17
|
+
r"(?<![\w.])(\d+(?:[.,]\d+)?)\s+%",
|
|
18
|
+
lambda m: f"{m.group(1)}%",
|
|
19
|
+
"Write the percent sign attached to the number.",
|
|
20
|
+
),
|
|
21
|
+
"range_dash": (
|
|
22
|
+
r"(?<![\w.-])(\d+(?:[.,]\d+)?)-(\d+(?:[.,]\d+)?)(?![\w-])",
|
|
23
|
+
lambda m: f"{m.group(1)}–{m.group(2)}",
|
|
24
|
+
"Use an en dash for numeric ranges.",
|
|
25
|
+
),
|
|
26
|
+
"p_value_spacing": (
|
|
27
|
+
r"\bp\s*([<>=≤≥])\s*(\.\d+|0\.\d+)",
|
|
28
|
+
lambda m: (
|
|
29
|
+
f"p {m.group(1)} {m.group(2) if m.group(2)[0] == '0' else '0' + m.group(2)}"
|
|
30
|
+
),
|
|
31
|
+
"Write p-values as 'p < 0.05', with spaces and a leading zero.",
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check(doc: Document, rule: dict) -> list[Finding]:
|
|
37
|
+
severity = rule.get("severity", "warning")
|
|
38
|
+
units = "|".join(
|
|
39
|
+
re.escape(u) for u in sorted(rule.get("units", []), key=len, reverse=True)
|
|
40
|
+
)
|
|
41
|
+
findings = []
|
|
42
|
+
for name, (template, replace, message) in RULES.items():
|
|
43
|
+
if name in rule.get("disabled_rules", []) or (
|
|
44
|
+
"{units}" in template and not units
|
|
45
|
+
):
|
|
46
|
+
continue
|
|
47
|
+
pattern = re.compile(template.format(units=units))
|
|
48
|
+
for n, line in doc.prose():
|
|
49
|
+
for m in pattern.finditer(line):
|
|
50
|
+
suggestion = replace(m)
|
|
51
|
+
if suggestion == m.group():
|
|
52
|
+
continue
|
|
53
|
+
findings.append(
|
|
54
|
+
Finding(
|
|
55
|
+
check=f"units.{name}",
|
|
56
|
+
category="units",
|
|
57
|
+
severity=severity,
|
|
58
|
+
line=n,
|
|
59
|
+
col=m.start() + 1,
|
|
60
|
+
excerpt=excerpt(doc.lines[n - 1], m.start(), m.end()),
|
|
61
|
+
message=message,
|
|
62
|
+
section=doc.section_at(n),
|
|
63
|
+
suggestion=suggestion,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return findings
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Vale, behind the checker interface. An external binary that must never abort the run."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from prose_preflight.extract import Document
|
|
9
|
+
from prose_preflight.finding import Finding
|
|
10
|
+
|
|
11
|
+
# The `vale` dependency ships the binary, so reaching this message means the
|
|
12
|
+
# environment blocked it: no network on first run, or a binary quarantined by the OS.
|
|
13
|
+
MISSING = (
|
|
14
|
+
"The Vale binary could not be found, so grammar and style were not checked. "
|
|
15
|
+
"It is normally installed with this package; a blocked first-run download is the "
|
|
16
|
+
"usual cause. Install it manually from https://vale.sh/docs/install and it will be "
|
|
17
|
+
"picked up from PATH."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check(doc: Document, rule: dict) -> list[Finding]:
|
|
22
|
+
try:
|
|
23
|
+
alerts = _run(doc, rule)
|
|
24
|
+
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
|
|
25
|
+
return [
|
|
26
|
+
Finding(
|
|
27
|
+
check="vale.unavailable",
|
|
28
|
+
category="style",
|
|
29
|
+
severity="warning",
|
|
30
|
+
line=1,
|
|
31
|
+
col=1,
|
|
32
|
+
excerpt="",
|
|
33
|
+
message=MISSING
|
|
34
|
+
if isinstance(exc, FileNotFoundError)
|
|
35
|
+
else f"Vale failed: {exc}",
|
|
36
|
+
section=None,
|
|
37
|
+
suggestion=None,
|
|
38
|
+
)
|
|
39
|
+
]
|
|
40
|
+
return [
|
|
41
|
+
Finding(
|
|
42
|
+
# ponytail: every Vale rule lands in `style`. Split into grammar/spelling
|
|
43
|
+
# by rule prefix once a config needs to weight them differently.
|
|
44
|
+
check=f"vale.{alert['Check']}",
|
|
45
|
+
category="style",
|
|
46
|
+
severity="error" if alert["Severity"].lower() == "error" else "warning",
|
|
47
|
+
line=alert["Line"],
|
|
48
|
+
col=alert["Span"][0],
|
|
49
|
+
excerpt=alert["Match"],
|
|
50
|
+
message=alert["Message"],
|
|
51
|
+
section=doc.section_at(alert["Line"]),
|
|
52
|
+
suggestion=next(iter(alert.get("Action", {}).get("Params") or []), None),
|
|
53
|
+
)
|
|
54
|
+
for alert in alerts
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run(doc: Document, rule: dict) -> list[dict]:
|
|
59
|
+
"""Invoke Vale on the document. Vale exits nonzero when it finds alerts, so only
|
|
60
|
+
the JSON on stdout decides success."""
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
62
|
+
ini = rule.get("config_file") or _write_ini(Path(tmp), rule)
|
|
63
|
+
path = _readable(doc, Path(tmp))
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["vale", "--output=JSON", f"--config={ini}", str(path)],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
check=False, # nonzero just means "alerts found"
|
|
68
|
+
text=True,
|
|
69
|
+
timeout=rule.get("timeout", 60),
|
|
70
|
+
)
|
|
71
|
+
if not result.stdout.strip():
|
|
72
|
+
raise subprocess.SubprocessError(result.stderr.strip() or "no output")
|
|
73
|
+
report = json.loads(result.stdout)
|
|
74
|
+
return [alert for alerts in report.values() for alert in alerts]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _readable(doc: Document, tmp: Path) -> Path:
|
|
78
|
+
"""Vale parses Markdown and plain text; it has no LaTeX reader, and pointed at a
|
|
79
|
+
.tex file it would flag the preamble and every command. `doc.masked` is already
|
|
80
|
+
the prose alone, line for line, so any other format goes to Vale as masked text."""
|
|
81
|
+
if doc.path.suffix.lower() in {".md", ".markdown", ".txt"}:
|
|
82
|
+
return doc.path
|
|
83
|
+
masked = tmp / f"{doc.path.stem}.txt"
|
|
84
|
+
masked.write_text("\n".join(doc.masked))
|
|
85
|
+
return masked
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _write_ini(tmp: Path, rule: dict) -> Path:
|
|
89
|
+
"""Vale's own config is an INI string in `default.yaml`, so it stays data."""
|
|
90
|
+
ini = tmp / ".vale.ini"
|
|
91
|
+
ini.write_text(rule.get("ini", ""))
|
|
92
|
+
return ini
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Bundled config. A user file passed with --config is deep-merged over this one.
|
|
2
|
+
# Rule keys land here alongside their checkers; a new document convention is a
|
|
3
|
+
# YAML edit, never a code change.
|
|
4
|
+
checks:
|
|
5
|
+
terminology:
|
|
6
|
+
enabled: true
|
|
7
|
+
severity: warning
|
|
8
|
+
ignore_case: true
|
|
9
|
+
# Preferred form: variants that should become it. Empty by default -- the right
|
|
10
|
+
# spellings are your journal's, not ours. Uncomment and edit:
|
|
11
|
+
terms: {}
|
|
12
|
+
# dataset: [data set, data-set]
|
|
13
|
+
# machine learning: [machine-learning]
|
|
14
|
+
# ground truth: [ground-truth, groundtruth]
|
|
15
|
+
readability:
|
|
16
|
+
enabled: true
|
|
17
|
+
severity: warning
|
|
18
|
+
min_words: 50 # sections shorter than this score as noise
|
|
19
|
+
min_flesch: 30.0 # below: hard to read
|
|
20
|
+
max_fog: 18.0 # above: too many long words per sentence
|
|
21
|
+
sentence_length:
|
|
22
|
+
enabled: true
|
|
23
|
+
severity: warning
|
|
24
|
+
max_words: 40
|
|
25
|
+
acronym:
|
|
26
|
+
enabled: true
|
|
27
|
+
severity: warning
|
|
28
|
+
min_uses: 1 # raise to ignore one-off mentions
|
|
29
|
+
# never expanded: too common, or not really acronyms
|
|
30
|
+
allowlist:
|
|
31
|
+
[
|
|
32
|
+
API,
|
|
33
|
+
CPU,
|
|
34
|
+
GPU,
|
|
35
|
+
RAM,
|
|
36
|
+
PDF,
|
|
37
|
+
HTML,
|
|
38
|
+
JSON,
|
|
39
|
+
YAML,
|
|
40
|
+
XML,
|
|
41
|
+
CSV,
|
|
42
|
+
URL,
|
|
43
|
+
USA,
|
|
44
|
+
EU,
|
|
45
|
+
UK,
|
|
46
|
+
ID,
|
|
47
|
+
OK,
|
|
48
|
+
TODO,
|
|
49
|
+
FAQ,
|
|
50
|
+
PhD,
|
|
51
|
+
DOI,
|
|
52
|
+
ISBN,
|
|
53
|
+
GDP,
|
|
54
|
+
AM,
|
|
55
|
+
PM,
|
|
56
|
+
UTC,
|
|
57
|
+
ISO,
|
|
58
|
+
IEEE,
|
|
59
|
+
ACM,
|
|
60
|
+
]
|
|
61
|
+
structure:
|
|
62
|
+
enabled: true
|
|
63
|
+
severity: error # a missing section
|
|
64
|
+
order_severity: warning # a section out of order
|
|
65
|
+
# Empty by default: most documents are fragments, not whole papers. Set the list to
|
|
66
|
+
# opt in, e.g. [Abstract, Introduction, Methods, Results, Discussion, References].
|
|
67
|
+
required_sections: []
|
|
68
|
+
units:
|
|
69
|
+
enabled: true
|
|
70
|
+
severity: warning
|
|
71
|
+
disabled_rules: [] # any of: space_before_unit, percent_spacing, range_dash, p_value_spacing
|
|
72
|
+
units:
|
|
73
|
+
[
|
|
74
|
+
mm,
|
|
75
|
+
cm,
|
|
76
|
+
m,
|
|
77
|
+
km,
|
|
78
|
+
mg,
|
|
79
|
+
g,
|
|
80
|
+
kg,
|
|
81
|
+
ms,
|
|
82
|
+
s,
|
|
83
|
+
min,
|
|
84
|
+
h,
|
|
85
|
+
Hz,
|
|
86
|
+
kHz,
|
|
87
|
+
MHz,
|
|
88
|
+
GHz,
|
|
89
|
+
kB,
|
|
90
|
+
MB,
|
|
91
|
+
GB,
|
|
92
|
+
TB,
|
|
93
|
+
W,
|
|
94
|
+
kW,
|
|
95
|
+
V,
|
|
96
|
+
A,
|
|
97
|
+
K,
|
|
98
|
+
°C,
|
|
99
|
+
°F,
|
|
100
|
+
px,
|
|
101
|
+
dpi,
|
|
102
|
+
bp,
|
|
103
|
+
mol,
|
|
104
|
+
N,
|
|
105
|
+
Pa,
|
|
106
|
+
J,
|
|
107
|
+
kJ,
|
|
108
|
+
]
|
|
109
|
+
claim:
|
|
110
|
+
enabled: true # severity is always `review`; never auto-applied
|
|
111
|
+
hedges:
|
|
112
|
+
[
|
|
113
|
+
may,
|
|
114
|
+
might,
|
|
115
|
+
could,
|
|
116
|
+
possibly,
|
|
117
|
+
perhaps,
|
|
118
|
+
arguably,
|
|
119
|
+
seems to,
|
|
120
|
+
appears to,
|
|
121
|
+
somewhat,
|
|
122
|
+
relatively,
|
|
123
|
+
fairly,
|
|
124
|
+
rather,
|
|
125
|
+
quite,
|
|
126
|
+
suggests that,
|
|
127
|
+
tends to,
|
|
128
|
+
]
|
|
129
|
+
boosters:
|
|
130
|
+
[
|
|
131
|
+
clearly,
|
|
132
|
+
obviously,
|
|
133
|
+
undoubtedly,
|
|
134
|
+
certainly,
|
|
135
|
+
definitely,
|
|
136
|
+
significantly,
|
|
137
|
+
dramatically,
|
|
138
|
+
drastically,
|
|
139
|
+
vastly,
|
|
140
|
+
extremely,
|
|
141
|
+
profoundly,
|
|
142
|
+
unprecedented,
|
|
143
|
+
groundbreaking,
|
|
144
|
+
revolutionary,
|
|
145
|
+
novel,
|
|
146
|
+
optimal,
|
|
147
|
+
perfect,
|
|
148
|
+
proves,
|
|
149
|
+
proven,
|
|
150
|
+
]
|
|
151
|
+
vale:
|
|
152
|
+
enabled: true
|
|
153
|
+
timeout: 60
|
|
154
|
+
# Vale's own config, kept as data. Built-in `Vale.*` rules only: no `vale sync`,
|
|
155
|
+
# no downloaded styles, so runs stay offline and deterministic. Point
|
|
156
|
+
# `config_file` at your own .vale.ini to use styles you have synced.
|
|
157
|
+
config_file: null
|
|
158
|
+
ini: |
|
|
159
|
+
MinAlertLevel = suggestion
|
|
160
|
+
[*.{md,markdown,txt}]
|
|
161
|
+
BasedOnStyles = Vale
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Extraction: pick a reader by suffix, return one normalized `Document`."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from prose_preflight.extract import document, latex, markdown
|
|
6
|
+
from prose_preflight.extract.document import Document
|
|
7
|
+
|
|
8
|
+
__all__ = ["Document", "read"]
|
|
9
|
+
|
|
10
|
+
READERS = {".tex": latex.read, ".md": markdown.read, ".markdown": markdown.read}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read(path: str | Path) -> Document:
|
|
14
|
+
"""Anything without a known suffix is read as plain text."""
|
|
15
|
+
path = Path(path)
|
|
16
|
+
reader = READERS.get(path.suffix.lower(), document.read)
|
|
17
|
+
return reader(path, path.read_text(encoding="utf-8"))
|