-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprose_lint.py
More file actions
executable file
·1787 lines (1591 loc) · 76.8 KB
/
Copy pathprose_lint.py
File metadata and controls
executable file
·1787 lines (1591 loc) · 76.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Enforce the GOVERNANCE.md "Documentation Style Conventions" rules no other linter checks.
markdownlint, cspell, actionlint, and editorconfig-checker all pass on prose that breaks
these rules, so nothing enforced them before this script. Rules implemented:
charset Non-ASCII judged against the three tiers the charset rule defines.
charset-unknown A non-ASCII character in no tier, so it is classified rather than assumed.
semicolon No semicolon in prose, outside a list that already carries commas.
dash No spaced hyphen joining or interrupting a sentence.
comment-wrap One sentence per comment line, never wrapped and never two on a line.
comment-case A comment sentence starts with a capital, not a lowercase word.
dupword No duplicated consecutive word.
sentence-split A sentence must not wrap across lines (one sentence per line).
sentence-length A Markdown prose sentence must not exceed the word cap.
spelling No British spelling, the repo-wide convention being US English.
home-path No absolute home path naming a real account, per the representative-data rule.
dead-path No mention of a path git once tracked and the tree no longer holds.
Exit 1 if any violation is found. Read-only, never edits.
"""
from __future__ import annotations
import argparse
import functools
import io
import json
import re
import subprocess
import sys
import tokenize
import unicodedata
from pathlib import Path
from typing import NamedTuple, TypedDict
# One source of truth for the rule names, so the CLI choices cannot drift from check_file.
RULES = {
"charset": "a non-ASCII character its tier does not permit here",
"charset-unknown": "a non-ASCII character in no tier",
"semicolon": "a semicolon in prose, outside a list that already carries commas",
"dash": "a spaced hyphen joining or interrupting a sentence",
"comment-wrap": "a comment sentence wrapped across lines, or two on one line",
"comment-case": "a comment sentence opening in lowercase",
"dupword": "a duplicated consecutive word",
"sentence-split": "a sentence wrapping across lines",
"sentence-length": "a sentence over the word cap",
"spelling": "a British spelling where the repo convention is US English",
"home-path": "an absolute home path naming a real account",
"dead-path": "a mention of a path git once tracked and the tree no longer holds",
}
DEFAULT_RULES = frozenset(
{
"charset",
"charset-unknown",
"semicolon",
"dash",
"dupword",
"spelling",
"comment-wrap",
"comment-case",
"home-path",
"dead-path",
}
)
# Trees this repo generates rather than authors, skipped when a wider scan expands into them.
# The gate then measures hand-written prose.
# `spec/audit.py` writes `reports/`, so a finding there is the engine's phrasing, not an author's.
# No edit to that tree can fix one.
# Naming one of these paths directly still reads it, so nothing becomes uncheckable.
GENERATED_TREES = frozenset({"reports"})
# Produced rather than authored trees, consulted only on the no-git fallback path.
# Where git can answer, its own ignore rules are the better answer.
GENERATED_ROOTS = frozenset(
{
".git",
".artifacts",
".mypy_cache",
".ruff_cache",
".pytest_cache",
".venv",
"__pycache__",
"node_modules",
"bin",
"obj",
"dist",
}
)
# A floor on what a healthy sweep of this repo reaches, asserted by the tests.
# A sweep that quietly stops finding files satisfies every rule by having nothing to read.
LEAST_PLAUSIBLE = 60
# The pattern-detectable half of the representative-data rule, and only that half.
# A real user segment is required, so a documented placeholder describes the shape unmatched.
# That is how the rule's own wording escapes its own gate, with no exemption naming files.
# A bare drive letter is deliberately not a shape here.
# Measured against this repo it matched 11 files and named a path in none of them.
# An escaped newline after a word ending in a letter and a colon reads as a drive letter.
# `Users` is matched case-insensitively on the Windows branch alone, since that filesystem is.
# The POSIX branches stay case-sensitive, since a lowercase `/users/` is a common REST path.
# An API route is not a home directory, and widening this would flag one in every doc.
HOME_PATH = re.compile(
r"(?:/home/|/Users/|[A-Za-z]:\\(?i:users)\\)(?P<user>[A-Za-z][A-Za-z0-9._-]*)"
)
# Accounts that belong to a container or a runner rather than to a person.
# Every one is a fixed name an image ships, so a path under it names no environment.
# `vscode` is the devcontainer user this repo's own snippets mount into.
# `runner` is the GitHub Actions user, and the rest are stock image accounts.
SERVICE_ACCOUNTS = frozenset({"vscode", "runner", "root", "ubuntu", "node", "shared", "public"})
def rel(path: Path) -> str:
"""The repo-relative posix key a git diff uses for this path.
`removeprefix` rather than `lstrip`, which takes a character set and ate the leading dot of
every dotfile - it turned `.github/workflows/x.yml` into `github/workflows/x.yml`, so --diff
could never match a path under a dot directory.
"""
return path.as_posix().removeprefix("./")
def repo_key(path: Path, root: Path) -> str:
"""The repository-relative posix key that joins a scanned file to a diff entry.
A diff names every file relative to the repository top level, while a path argument arrives
absolute, relative to a subdirectory, or dotted. The two are compared here and nowhere else,
since comparing them in whatever form each happened to arrive in is what let an absolute path
argument match no diff entry at all and report a clean run over a tree it had read in full.
A path outside `root` has no repository-relative form and keeps its own, so nothing in a diff
can match it. That is the honest answer rather than a coincidental one.
"""
try:
return path.resolve().relative_to(root.resolve()).as_posix()
except (ValueError, OSError):
return rel(path)
def all_lines(path: Path) -> set[int]:
"""Every line number in `path`, which is the changed scope of a file that is entirely new."""
try:
return set(range(1, len(path.read_bytes().splitlines()) + 1))
except OSError:
return set()
def untracked_paths(root: Path) -> list[str]:
"""Paths relative to `root` that git holds no history for and is not ignoring.
They are repository-relative only where `root` is the repository top level, which is how
`changed_lines` calls it, since a diff key is repository-relative. `discover` passes the
directory it was asked about and joins the names onto it, so both readings hold at once.
An untracked file is the whole of what a change adds and `git diff` never names one, so a
scope built from the diff alone reads a new file as absent rather than as new. `git ls-files`
omits it too, so this is not a diff-mode quirk: a whole-tree sweep passed over it as well.
Ignored paths stay out, since a build output is not authored text.
"""
try:
r = subprocess.run(
["git", "-C", str(root), "ls-files", "-z", "--others", "--exclude-standard"],
capture_output=True,
text=True,
check=False,
)
except (OSError, ValueError):
return []
if r.returncode != 0:
return []
return [name for name in r.stdout.split("\0") if name]
def changed_lines(base: str, root: Path) -> dict[str, set[int]] | None:
"""Map repository-relative path -> line numbers this working tree adds vs `base`.
None if git fails. The diff is taken at `root`, the repository being scanned, rather than
wherever the process happens to stand. Keys anchored on the working directory match nothing
once the two differ, and a `diff.relative` setting would re-anchor them the same way.
An untracked file counts as added in full, since a change whose whole point is adding a file
otherwise scopes to nothing and reports a clean run on exactly the file it added.
"""
try:
d = subprocess.run(
["git", "-C", str(root), "diff", "--unified=0", "--no-color", base, "--"],
capture_output=True,
text=True,
check=True,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError):
return None
out: dict[str, set[int]] = {}
cur = None
for line in d.split("\n"):
if line.startswith("+++ b/"):
cur = line[6:]
out.setdefault(cur, set())
elif line.startswith("@@") and cur:
m = re.search(r"\+(\d+)(?:,(\d+))?", line)
if m:
start = int(m.group(1))
count = int(m.group(2) or 1)
out[cur].update(range(start, start + count))
for name in untracked_paths(root):
target = root / name
if is_text(target):
out[name] = all_lines(target)
return out
def asked_about(key: str, paths: list[str]) -> bool:
"""Whether a repository-relative diff key falls under one of the requested paths.
The floor below compares the diff's file list against what the run matched, and a caller who
narrowed the scan on purpose must not be told the narrowing is a defect. Anything the request
did not cover is not a file this run failed to read.
"""
for raw in paths:
# `Path` drops a trailing separator, so the test appends one rather than stripping it.
# Comparing the bare prefix would let `catalog` claim `catalogue/x.md`.
r = rel(Path(raw))
if r in ("", "."):
return True
if key == r or key.startswith(r + "/"):
return True
return False
def unread_diff_files(
scope: dict[str, set[int]], paths: list[str], excludes: tuple[str, ...], root: Path
) -> list[str]:
"""Files the diff names that this run was asked about and could have read, in sorted order.
Both sides are read against `root`, the repository being scanned, because `git diff` reports
repository-relative paths while a request arrives in whatever form the caller typed. Reading
either against the working directory empties this list from a subdirectory and empties it for
an absolute path argument, which are the two places it most needs to be full.
"""
asked = [repo_key(Path(p), root) for p in paths]
out: list[str] = []
for key in sorted(scope):
if not asked_about(key, asked):
continue
if any(x in key for x in excludes):
continue
if not GENERATED_TREES.isdisjoint(Path(key).parts):
continue
target = root / key
if target.is_file() and is_text(target):
out.append(key)
return out
def scope_note(read: int, discovered: int, lines: int | None, base: str | None) -> str:
"""What the run actually read, stated on every verdict rather than only on a busy one.
Five routes to a false clean are on record and every one of them exits 0 in silence: an
unresolvable base widening to a whole-tree scan, a diff taken in one repository while scanning
another, a path under no repository, an absolute path argument whose keys matched no diff
entry, and an untracked file no diff names. Each guard so far closes the route a reviewer
happened to see, and the sixth is found that way or not at all. What every one of them shares
is that a scope of nothing prints exactly what a clean tree prints, which is a property of the
output and not of any single route. Stating the scope is what a reader needs to tell "read
nothing" from "found nothing", so it is printed even when the count is the whole tree.
"""
if base is None:
return f"scope: {read} file(s) read, whole tree"
return (
f"scope: {read} of {discovered} file(s) read, {lines} changed line(s), "
f"diff against {base!r}"
)
def home_path_findings(lineno: int, line: str) -> list[tuple[int, str, str]]:
"""Absolute home paths on this line that name a real account.
The exposure this gates was a maintainer's own path reaching a public comment, so the unit
is the raw line rather than stripped prose. A path is the same exposure in a JSON config
value, in a fenced transcript pasted from a terminal, and in a sentence.
"""
out = []
for m in HOME_PATH.finditer(line):
if m.group("user").lower() in SERVICE_ACCOUNTS:
continue
out.append(
(
lineno,
"home-path",
(
f"absolute home path {m.group(0)!r} -> use a constructed path, not an "
"observed one"
),
)
)
return out
# The named-path half of the stale-description class, and only that half (RESYNC.md section 4).
# The measured incident named no path at all, and no pattern reaches a description without one.
# A backtick span, an inline link target, and a reference definition each assert a path.
INLINE_SPAN = re.compile(r"`([^`\n]+)`")
LINK_TARGET = re.compile(r"\]\(([^)\s]+)\)")
REF_DEF = re.compile(r"^\s*\[[^\]]+\]:\s+(\S+)")
# A character that marks a token as a placeholder, a glob, an expansion, or a scheme.
# The colon covers every URL scheme, a drive letter, and an image tag in one stroke.
PATH_FOREIGN = frozenset("<>{}$*?\"'\\:!|,;")
def path_candidate(token: str, in_span: bool = True) -> str | None:
"""The relative path a token asserts, or None when it asserts none.
A backtick span holds prose as often as a path, so it qualifies only when it is shaped
like a file: a single word carrying a separator and a suffix, which is what tells
`spec/audit.py` from a ref like `origin/develop` and from a bare directory pattern like
`references/`, a shape docs use for any repository's layout rather than this one's. A
link target or a reference definition is a path by construction, so only a foreign
character disqualifies it there.
"""
token = token.split("#", 1)[0]
if not token or any(c.isspace() for c in token) or PATH_FOREIGN & set(token):
return None
if token.startswith(("/", "~", "-", "#")):
return None
if in_span:
if "/" not in token:
return None
last = token.rsplit("/", 1)[-1]
if "." not in last or not last.strip("."):
return None
return token.removeprefix("./")
# Paths the hub hosts and no repository carries, per GOVERNANCE.md "Hub-Hosted Tooling".
# A mention of one names the hub's copy rather than a file this tree lost.
# Carried text naming a tool is required to name it that way, so the mention is never a dead path.
# The manifest exemption cannot reach this class, since no repository carries `spec/files.json`.
# Downstream that set is empty, and a repository that retired its copy carries the full signature.
# It surfaces at the promotion, whose diff base brings the retirement and its prose into scope.
# That is the gate with the least room to fix it, and a ruleset bypass is the only local remedy.
# Held as a literal because the prose-gate action fetches this one file with no hub tree beside it.
# The `retire` dispositions in `spec/divergences.json` are the source, and a hub test asserts this.
HUB_HOSTED = frozenset({"repo-config/configure.sh"})
@functools.cache
def carried_paths(root: str) -> frozenset[str]:
"""Paths the manifest declares as carried, exempt because docs name them as fleet layout.
The hub's own instance of a carried file retires to a catalog snippet, so its history
reads as a deletion while every mention legitimately describes the file a repository
carries. Whether a repository actually carries one is the audit's finding, not prose's.
"""
try:
data = json.loads((Path(root) / "spec" / "files.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return frozenset()
return frozenset(
e["path"]
for e in data.get("baseline", [])
if isinstance(e, dict) and isinstance(e.get("path"), str)
)
@functools.cache
def once_tracked(root: str, rel_path: str) -> bool:
"""Whether git at `root` ever recorded `rel_path`, the deletion signature this rule keys on."""
try:
r = subprocess.run(
["git", "-C", root, "log", "-1", "--format=%H", "--", rel_path],
capture_output=True,
text=True,
check=False,
)
except (OSError, ValueError):
return False
return r.returncode == 0 and bool(r.stdout.strip())
def dead_path_findings(
root: Path, base: Path, lineno: int, line: str
) -> list[tuple[int, str, str]]:
"""Named paths on this line that git once tracked and the tree no longer holds.
Requiring a history is what scopes this to the deletion-sweep shape, a file removed with
its describing prose left standing. A path another repository holds, a proposed file a
backlog names, and a layout pattern each have no history here, so none is reported. A
carried path and a hub-hosted one are exempt with a history, since each names a file that
lives elsewhere by design rather than a description this tree left behind.
"""
m = REF_DEF.match(line)
if m:
# A definition line holds one target and no prose, so nothing else on it is read.
tokens = [(m.group(1), False)]
else:
tokens = [(s.group(1), True) for s in INLINE_SPAN.finditer(line)]
tokens += [(t.group(1), False) for t in LINK_TARGET.finditer(strip_inline_code(line))]
out = []
for token, in_span in tokens:
rel_path = path_candidate(token, in_span)
if rel_path is None:
continue
# A mention is anchored where it resolves, the file's own directory or the root.
if (root / rel_path).exists() or (base / rel_path).exists():
continue
for anchor in {root, base}:
try:
tracked_rel = (anchor / rel_path).resolve().relative_to(root.resolve())
except ValueError:
continue
# Both exemption sets are keyed by the posix path the manifest and the ledger hold.
# A git pathspec is posix too, which `rel` already relies on for the diff scope.
# So one key serves both, rather than the platform's separator reaching either.
key = tracked_rel.as_posix()
if key in carried_paths(str(root)) or key in HUB_HOSTED:
continue
if once_tracked(str(root), key):
out.append(
(
lineno,
"dead-path",
(
f"path {token!r} is deleted from this tree -> re-point, rewrite, "
"or remove the stale mention"
),
)
)
break
return out
def shallow_checkout(root: Path) -> bool:
"""Whether the checkout at `root` is shallow, which holds no deletion history to key on."""
try:
r = subprocess.run(
["git", "-C", str(root), "rev-parse", "--is-shallow-repository"],
capture_output=True,
text=True,
check=False,
)
except (OSError, ValueError):
return False
return r.returncode == 0 and r.stdout.strip() == "true"
def operational_checkout(root: Path) -> bool:
"""Whether this checkout is an operational repository, read from what it carries.
`spec/files.json` declares `repo-config/operational/develop.json` for the operational model
and `repo-config/develop.json` for the release one, so a repository states its own model and
nothing has to reach the hub registry to ask. The hub itself carries both payloads, being the
template for each, so carrying the release payload decides it.
"""
return (root / "repo-config" / "operational" / "develop.json").is_file() and not (
root / "repo-config" / "develop.json"
).is_file()
def quoted(paths) -> str:
"""Paths as a sorted, quoted, comma-joined list for an error message.
Quoted because a path holding a space or a comma is indistinguishable from two paths once
joined, which makes the message unreadable exactly when it names something unexpected.
"""
return ", ".join(repr(str(p)) for p in sorted(paths))
def repo_root(path: Path) -> str:
"""The repository top level containing `path`, or '' when git cannot say."""
start = path if path.is_dir() else path.parent
try:
r = subprocess.run(
["git", "-C", str(start), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
except (OSError, ValueError):
return ""
return r.stdout.strip() if r.returncode == 0 else ""
def tracked_paths(root: Path) -> list[Path] | None:
"""Paths git tracks under `root`, or None when git cannot answer.
Empty output is a None too. `git ls-files` succeeds with no output in an initialized but
empty checkout, and reading that as an empty file set would scan nothing and report success.
"""
try:
r = subprocess.run(
["git", "-C", str(root), "ls-files", "-z"], capture_output=True, text=True, check=False
)
except (OSError, ValueError):
return None
if r.returncode != 0 or not r.stdout.strip("\0"):
return None
return [root / name for name in r.stdout.split("\0") if name]
def walk_paths(root: Path) -> list[Path]:
"""Every file under `root` minus the generated trees, for a checkout git cannot describe.
`git check-ignore` fails on exactly the machine that has no git, so this path asserts the
generated-root rule by name instead of asking git which paths are ignored.
"""
return [
p
for p in root.rglob("*")
if p.is_file() and not GENERATED_ROOTS.intersection(p.relative_to(root).parts)
]
def is_text(path: Path) -> bool:
"""A NUL byte in the first block marks a binary, the test the line-endings rule prescribes."""
try:
with path.open("rb") as fh:
return b"\0" not in fh.read(8192)
except OSError:
return False
def discover(
paths: list[str], excludes: tuple[str, ...] = (), root: Path | None = None
) -> list[Path]:
"""Every authored text file the rules govern, scoped by git where git can answer.
Where it cannot, the fallback walk applies no ignore rules at all and asserts the generated
roots by name instead, so the scoping there is weaker than the paragraph below describes. It
warns on stderr, since a quieter file set that reads the same is how a sweep stops covering
what it claims to.
The line-endings rule already requires a repo-wide sweep be scoped to `git ls-files` rather
than a directory list, which covers what its author thought of and silently stops covering
whatever is added next. An extension allowlist has that same defect, so the filter here is
whether the file is text, not whether its suffix was thought of. An untracked file is authored
text the same way, and reading only the tracked list passed over a new file until it was
staged, so a clean sweep proved nothing about the one file a change existed to add.
`root` is the repository every path is judged against, passed in so that the caller's verdict
and this file set are keyed alike. Judging against the filesystem path instead lets a
directory *above* the checkout decide: a repository cloned under a parent named `reports` had
its own `reports/` tree scanned as authored, and an absolute argument carried its whole parent
chain into every `--exclude` test.
An explicit file argument bypasses discovery, so a single file can always be checked directly.
"""
found: list[tuple[Path, str]] = []
for raw in paths:
p = Path(raw)
base = p if p.is_dir() else (p.parent if p.is_file() else Path("."))
anchor = root if root is not None else Path(repo_root(base) or base)
if p.is_file():
found.append((p, repo_key(p, anchor)))
continue
candidates = tracked_paths(base)
# `tracked_paths` answers None for a tree git cannot describe and for an empty answer.
# Only the first of those justifies a walk.
# Whether git can describe a tree is settled by asking git, never by its answer's size.
# Read as emptiness, a subtree of new files fell back and scanned the ignored ones under it.
# It also printed that git could not describe a tree git describes fine.
if candidates is None and not repo_root(base):
print(
f"warning: git cannot describe {base}, falling back to a filesystem walk",
file=sys.stderr,
)
# A walk reports what is on disk, so it carries the untracked files already.
# It applies no ignore rules, which is why it is reserved for having no other answer.
candidates = walk_paths(base)
else:
candidates = (candidates or []) + [base / name for name in untracked_paths(base)]
# The path named is itself inside a generated tree, so that tree was asked for.
asked_inside_generated = not GENERATED_TREES.isdisjoint(Path(repo_key(base, anchor)).parts)
for q in candidates:
key = repo_key(q, anchor)
if GENERATED_TREES.isdisjoint(Path(key).parts) or asked_inside_generated:
found.append((q, key))
keep = [
q for q, key in found if not any(x in key for x in excludes) and q.is_file() and is_text(q)
]
return sorted(set(keep))
# A non-ASCII character is typography in one place and meaning in another, so it is read by tier.
# Escapes, never literals: this file is scanned by the rule it implements.
#
# Tier 1 carries no meaning its ASCII form loses, so it always flags.
TIER1 = {
"\u2014": "restructure",
"\u2013": "restructure",
"\u2018": "'",
"\u2019": "'",
"\u201c": '"',
"\u201d": '"',
"\u2026": "...",
"\u2022": "-",
"\u00a0": " ",
"\u2011": "-",
"\u2192": "->",
"\u21d2": "=>",
}
# Tier 2 is an operator, and only its use between two words is a finding.
TIER2 = {
"\u2264": "<=",
"\u2265": ">=",
"\u2260": "!=",
"\u00b1": "+/-",
"\u2212": "-",
"\u00d7": "x",
"\u00f7": "/",
"\u00b7": ".",
}
# Tier 3 is a unit symbol whose ASCII form would be a lie, so it never flags.
TIER3 = frozenset(
{
"\u00b5",
"\u00b0",
"\u2126",
"\u03c0",
"\u00b2",
"\u00b3",
"\u00a7",
}
)
# A digit, unit, or operator on either side makes a tier-2 character the range it describes.
NUMERIC = re.compile(r"[0-9]")
# The rule bans the construction, not a detectable subset, so a prose semicolon flags by default.
# A pronoun-keyed pattern found 170 of 493 and missed every imperative splice.
SEMICOLON = re.compile(r";")
# A spaced hyphen, the em-dash-style clause break and the paired aside alike.
# A compound word carries no spaces, a list marker nothing before it, and a range is digit-bounded.
DASH = re.compile(r"(?<=[^\s\d])\s+-\s+(?=[^\s\d])")
# `- **Label** - explanation` is a definition separator, structurally a colon.
# Flagging it would restructure the document format rather than the prose.
# The first dash on such a line is skipped, and any later one still counts.
# An ordered marker introduces the same construct, so `1. **Label** - ...` is one too.
LABEL_DASH = re.compile(r"^\s*(?:[-*]|[0-9]+\.)\s+\*\*[^*]+\*\*[.:]?\s+-\s+")
# The negative lookbehind keeps a word-joining character from starting a repetition:
# "either/or or must-pair" is one phrase followed by a conjunction, not a doubled word.
DUPWORD = re.compile(r"(?<![\w/-])(\w+)\s+\1\b", re.IGNORECASE)
SENT_END = re.compile(r'[.!?:]["\')\]]?\s*$')
# US English is a repo-wide rule, and the cspell gate reads README and HISTORY only.
# A British spelling anywhere else in the tree therefore reaches main unchallenged.
# Each family generates its own inflections, since an inflected spelling is as wrong as its base.
# A hand-listed family drifts the moment one form is added without the others.
# The cross product also generates forms no stem takes, which simply never match.
# `analyses` is omitted, being the US plural of `analysis` as much as a British verb form.
# `cancelled` is omitted, being the GitHub Actions job status rather than prose.
ISE_STEMS = (
"author",
"custom",
"initial",
"maxim",
"minim",
"normal",
"optim",
"organ",
"priorit",
"recogn",
"serial",
"special",
"standard",
"summar",
"synchron",
"util",
"visual",
)
ISE_ENDINGS = (
("ise", "ize"),
("ised", "ized"),
("ises", "izes"),
("ising", "izing"),
("isation", "ization"),
("isations", "izations"),
)
OUR_STEMS = ("behavi", "col", "fav", "flav", "hon", "lab", "neighb")
OUR_ENDINGS = ("", "s", "ed", "ing", "al", "ally", "ful", "ite", "ites")
RE_STEMS = ("cent", "fib", "lit", "met", "theat")
RE_ENDINGS = (("re", "er"), ("res", "ers"), ("red", "ered"))
# The spellings that follow no family, each with the US form that replaces it.
BRITISH_ODD = {
"analyse": "analyze",
"analysed": "analyzed",
"analysing": "analyzing",
"artefact": "artifact",
"artefacts": "artifacts",
"catalogue": "catalog",
"catalogues": "catalogs",
"catalogued": "cataloged",
"defence": "defense",
"defences": "defenses",
"fulfil": "fulfill",
"fulfils": "fulfills",
"fulfilment": "fulfillment",
"judgement": "judgment",
"judgements": "judgments",
"labelled": "labeled",
"labelling": "labeling",
"licence": "license",
"licences": "licenses",
"modelled": "modeled",
"modelling": "modeling",
"offence": "offense",
"offences": "offenses",
"practise": "practice",
"practised": "practiced",
"practising": "practicing",
"programme": "program",
"programmes": "programs",
"signalled": "signaled",
"signalling": "signaling",
"travelled": "traveled",
"travelling": "traveling",
"whilst": "while",
}
def british_spellings() -> dict[str, str]:
"""Every banned spelling mapped to the US form that replaces it."""
words = dict(BRITISH_ODD)
for stem in ISE_STEMS:
words.update({stem + gb: stem + us for gb, us in ISE_ENDINGS})
for stem in OUR_STEMS:
words.update({f"{stem}our{end}": f"{stem}or{end}" for end in OUR_ENDINGS})
for stem in RE_STEMS:
words.update({stem + gb: stem + us for gb, us in RE_ENDINGS})
return words
BRITISH = british_spellings()
# Longest alternative first, so an inflection is read whole rather than as its base word.
BRITISH_RE = re.compile(
r"\b(?:" + "|".join(sorted(BRITISH, key=len, reverse=True)) + r")\b", re.IGNORECASE
)
def us_form(found: str) -> str:
"""The US spelling for a match, carrying the case the source wrote it in."""
us = BRITISH[found.lower()]
if found.isupper():
return us.upper()
return us.capitalize() if found[0].isupper() else us
# Comment syntax per language, since the rule governs every comment the fleet's types carry.
# A `doc` marker opens a documentation comment, which CODESTYLE governs and may run to paragraphs.
# `raw` names the quotes whose strings embed the delimiter by doubling it.
# `quote_after` names what a quote must follow to delimit a string, empty where any quote does.
# `escape` is the character that escapes the next one.
# `escape_in` names the quotes it works inside, and `escape_out` whether it works outside one.
# `carry` names the forms that survive a newline, so a marker inside one is string content.
class Syntax(TypedDict):
line: tuple[str, ...]
block: tuple[tuple[str, str], ...]
doc: tuple[str, ...]
quotes: str
verbatim: bool
raw: str
quote_after: str
escape: str
escape_in: str
escape_out: bool
carry: frozenset[str]
PLAIN: Syntax = {
"line": (),
"block": (),
"doc": (),
"quotes": "\"'",
"verbatim": False,
"raw": "",
"quote_after": "",
"escape": "\\",
"escape_in": "\"'",
"escape_out": False,
"carry": frozenset(),
}
HASH: Syntax = {**PLAIN, "line": ("#",)}
# A shell single-quoted string takes no escape and cannot embed its own delimiter.
# It is neither doubling nor escaped, so `'a''b'` is two adjacent strings rather than one.
# Outside a string a backslash escapes the next character, which is how `'\''` embeds a quote.
# A heredoc runs from its label to the line that repeats it, and either quote form spans lines.
SHELL: Syntax = {
**HASH,
"escape_in": '"',
"escape_out": True,
"carry": frozenset({"quote", "label"}),
}
# A YAML block scalar is the multi-line form.
# A quote delimits a scalar only at the start of a value, so a plain scalar's apostrophe is text.
# Such a quote must also not carry, since one `don't` would blank the rest of the file.
# The dash leads `quote_after` so the set does not read as a character range.
YAML: Syntax = {
**HASH,
"raw": "'",
"quote_after": "-:,[{",
"escape_in": '"',
"carry": frozenset({"block"}),
}
# A TOML literal string is raw the same way, while its basic string keeps the backslash escape.
TOML: Syntax = {**HASH, "raw": "'", "escape_in": '"'}
C_LIKE: Syntax = {**PLAIN, "line": ("//",), "block": (("/*", "*/"),), "doc": ("///", "/**")}
# C# alone carries the verbatim string, where a backslash is ordinary and a doubled quote escapes.
CSHARP: Syntax = {**C_LIKE, "verbatim": True, "carry": frozenset({"verbatim"})}
XML_LIKE: Syntax = {**PLAIN, "block": (("<!--", "-->"),), "quotes": '"'}
# PowerShell escapes with a backtick, and both quote forms double the delimiter to embed it.
# Its double-quoted string is therefore escaped and doubling at once.
# Both forms span lines, and the here-string (`@"` to `"@`) is the delimited one.
POWERSHELL: Syntax = {
**PLAIN,
"line": ("#",),
"block": (("<#", "#>"),),
"raw": "\"'",
"escape": "`",
"escape_in": '"',
"escape_out": True,
"carry": frozenset({"quote", "here"}),
}
INI: Syntax = {**PLAIN, "line": ("#", ";")}
LISP_LIKE: Syntax = {**PLAIN, "line": ("#",), "quotes": '"'}
# CSS has block comments only, so a `//` in it is the scheme separator of a URL.
CSS: Syntax = {**PLAIN, "block": (("/*", "*/"),)}
SYNTAX: dict[str, Syntax] = {
# Python, shell, and the hash-commented configs
".py": HASH,
".sh": SHELL,
".bash": SHELL,
".yml": YAML,
".yaml": YAML,
".toml": TOML,
".tf": HASH,
".gitattributes": HASH,
".gitignore": HASH,
# C#, C, and C++
".cs": CSHARP,
".c": C_LIKE,
".cpp": C_LIKE,
".cc": C_LIKE,
".cxx": C_LIKE,
".h": C_LIKE,
".hpp": C_LIKE,
".jsonc": C_LIKE,
".json5": C_LIKE,
".js": C_LIKE,
".ts": C_LIKE,
".css": CSS,
".scss": CSS,
# JSON carries comments in practice, which is what JSONC names.
# VS Code tasks, launch, devcontainer, and workspace files ship them under a plain .json name.
".json": C_LIKE,
".code-workspace": C_LIKE,
# Markup and project files
".md": XML_LIKE,
".html": XML_LIKE,
".xml": XML_LIKE,
".csproj": XML_LIKE,
".props": XML_LIKE,
".targets": XML_LIKE,
".slnx": XML_LIKE,
".resx": XML_LIKE,
# PowerShell, INI, and EDA
".ps1": POWERSHELL,
".psm1": POWERSHELL,
".ini": INI,
".cfg": INI,
".conf": INI,
".editorconfig": INI,
".kicad_sch": LISP_LIKE,
".kicad_pcb": LISP_LIKE,
".kicad_mod": LISP_LIKE,
}
# Extensionless files whose name fixes the syntax.
# A Dockerfile, a makefile recipe, and a git hook all hold shell, heredocs included.
BY_NAME = {
"dockerfile": SHELL,
"makefile": SHELL,
"pre-commit": SHELL,
"gemfile": HASH,
"caddyfile": HASH,
".gitattributes": HASH,
".editorconfig": INI,
".gitignore": HASH,
}
# JSON proper carries no comments, so a `//` in one is data.
NO_COMMENTS = frozenset({".lock", ".csv", ".tsv", ".txt", ".svg", ".min"})
def syntax_for(path: Path) -> Syntax | None:
"""The comment syntax for this file, or None when it carries no comments."""
name = path.name.lower()
if name in BY_NAME:
return BY_NAME[name]
suffix = path.suffix.lower()
if suffix in NO_COMMENTS:
return None
if suffix in SYNTAX:
return SYNTAX[suffix]
return HASH if not suffix else None
class Carried(NamedTuple):
"""A string left open at the end of a line, and what it takes to close it.
`kind` is `quote` for an ordinary one still open, `verbatim` for the doubled-quote form,
`here` for a PowerShell here-string, `label` for a heredoc, and `block` for a YAML block
scalar. `text` holds the open quote or the closing token, `indent` a block scalar's parent
column, `dedent` whether a heredoc opened with `<<-`, and `queued` the (label, dedent) pairs
stacked behind this one on the same line.
"""
kind: str = ""
text: str = ""
indent: int = 0
dedent: bool = False
queued: tuple[tuple[str, bool], ...] = ()
CLEAR = Carried()
# The forms whose whole line is string content, judged before the line is scanned for a marker.
WHOLE_LINE = frozenset({"here", "label", "block"})
def opens_a_string(line: str, i: int, quote_after: str) -> bool:
"""Whether the quote at `i` delimits a string rather than sitting inside a bare word.
YAML is the case this exists for: a plain scalar's apostrophe is text, and a quote delimits
only at the start of a value. Reading one as an opener masks the rest of the line and hides a
real trailing comment. An empty `quote_after` means the syntax has no bare-word form, so every
quote delimits.
"""
if not quote_after:
return True
j = i - 1
while j >= 0 and line[j].isspace():
j -= 1
return j < 0 or line[j] in quote_after
def strip_strings(
line: str,
quotes: str,
verbatim: bool = False,
carried: Carried = CLEAR,
raw: str = "",
escape: str = "\\",
escape_in: str = "\"'",
escape_out: bool = False,
quote_after: str = "",
) -> tuple[str, Carried]:
"""Blank quoted spans so a comment marker inside a string is not read as one.
Length-preserving, so an offset into the result is an offset into the line.
Two properties are read per string as it opens, because they are independent. A **doubled**
string embeds its delimiter by repeating it, and C# spells one with an `@` prefix while shell,
PowerShell, YAML, and TOML have forms that always are. An **escaped** string reads one
character as escaping the next, which is a backslash almost everywhere and a backtick in
PowerShell. PowerShell's double-quoted string is both at once, and the C# verbatim string is
doubled and not escaped, so neither property implies the other. Reading an escape a string
does not have consumes its closing quote and blanks the rest of the line, and missing one it
does have ends the string early on the escaped quote. `carried` reopens a string the line
above left open, and the second return says what this line leaves open in turn.
"""
out = list(line)
quote = carried.text if carried.kind in ("quote", "verbatim") else ""
at_verbatim = carried.kind == "verbatim"
doubled = at_verbatim or (quote != "" and quote in raw)
escapes = quote != "" and not at_verbatim and quote in escape_in
escaped = False
i = 0
while i < len(line):
ch = line[i]
if escaped:
escaped = False
out[i] = " "
elif quote and escapes and ch == escape:
escaped = True
out[i] = " "
elif doubled:
if ch == quote and line[i + 1 : i + 2] == quote: # a doubled quote is one character
out[i] = out[i + 1] = " "
i += 2
continue
out[i] = " " if ch != quote else ch
if ch == quote:
quote, doubled, escapes, at_verbatim = "", False, False, False
elif quote: