-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathxdlint.py
More file actions
3078 lines (2662 loc) · 115 KB
/
Copy pathxdlint.py
File metadata and controls
3078 lines (2662 loc) · 115 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
# ailevel: 7
# This code (and its unit tests in tests/) was written by Claude Code Opus 4.6
# at the behest of a human developer over the course of several hours of iteration.
# Each rule was specified, reviewed, tested, and refined by a human.
"""
xdlint.py - authoritative validator for the .xd crossword format.
Stdlib-only. The .xd format is specified in doc/xd-format.md; this script
enforces that spec and a curated set of style/quality rules derived from
patterns that have repeatedly required hand-fixing in our own xd file corpus.
Usage:
xdlint.py path [path ...] lint files / dirs (recursive)
xdlint.py --base BASE [--head HEAD] lint files changed in a git diff
xdlint.py --list-rules print the rule catalog
xdlint.py --fix path apply mechanical fixes in place
xdlint.py --fix --diff path print unified diff, don't write
Severity gate:
--max-severity {error,warning,info,debug} default: warning
Acts as both a print filter (findings below this level are suppressed)
and an exit gate (exit 1 if any finding meets or exceeds the level).
Use --max-severity info to surface feature-detection findings (XD3xx).
Rule selection:
--disable XD###[,XD###...]
--enable-only XD###[,XD###...]
Add a new rule:
@rule("XD###", Severity.WARNING, "rule-name")
def _(ctx):
if condition:
yield finding("XD###", Severity.WARNING, line, "message")
"""
from __future__ import annotations
import argparse
import difflib
import enum
import html
import os
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass, field
from typing import Callable, Dict, Iterator, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Core types
# ---------------------------------------------------------------------------
class Severity(enum.Enum):
ERROR = "error"
WARNING = "warning"
INFO = "info"
DEBUG = "debug"
@property
def rank(self) -> int:
return {"error": 4, "warning": 3, "info": 2, "debug": 1}[self.value]
@dataclass
class Finding:
code: str
severity: Severity
line: int # 1-indexed; 0 means file-level
message: str
@dataclass
class Header:
line: int
key: str
value: str
@dataclass
class GridRow:
line: int
cells: str # the row with leading/trailing whitespace stripped
@dataclass
class Clue:
line: int
pos: str # raw position string, e.g. "A1"
direction: str # "A", "D", or another single uppercase letter
number: int # parsed integer; -1 if non-numeric
body: str # clue text only
answer: str
raw: str
metadata: dict = field(default_factory=dict) # ^Key -> value (lowercased keys)
@dataclass
class ParsedXd:
text: str
lines: List[str] # 0-indexed; line N is lines[N-1]
section_mode: str # 'implicit' | 'explicit'
headers: List[Header]
grid: List[GridRow]
clues: List[Clue]
notes_text: str
parse_errors: List[Finding]
# Explicit-mode '## Foo' headers whose name isn't in EXPLICIT_SECTIONS.
# Spec says ignore the section, but XD207 surfaces them so unexpected
# sections (typos, tool-specific extensions) don't go unnoticed.
unknown_sections: List[Tuple[int, str]] = field(default_factory=list)
@dataclass
class Ctx:
filename: str
text: str
parsed: ParsedXd
# ---------------------------------------------------------------------------
# Parser - line-tracking; permissive about section structure
# ---------------------------------------------------------------------------
# Implicit-mode files lack '## Section' markers, so the parser identifies
# sections by content shape with a fallback to blank-line separators. When
# the spec separator (2+ blank lines) is missing, the parser recovers by
# auto-advancing the section and emits XD019 to flag the violation. This
# avoids a cascade where a single missing blank line turns into hundreds
# of downstream findings (XD001/XD002/XD004/...) about misclassified rows.
EXPLICIT_HEADER_RE = re.compile(r"^\s*##\s+([A-Za-z][A-Za-z0-9_-]*)")
EXPLICIT_SECTIONS = {"metadata": "metadata", "grid": "grid", "clues": "clues", "notes": "notes"}
CLUE_META_RE = re.compile(r"^([A-Za-z][\w-]*)\s+\^([A-Za-z][\w-]*)\s*:\s*(.*)$")
CLUE_POS_RE = re.compile(r"^([A-Za-z])(\d+)$")
_AUTO_CLUE_RE = re.compile(r"^[A-Za-z]?\d+\.\s")
def _looks_like_grid_row(stripped: str) -> bool:
"""Heuristic: stripped line whose every char is a letter, digit, block,
or wildcard, with no internal whitespace. Used by the implicit-mode
parser to detect when a missing blank-line separator has swallowed
grid rows into the metadata section."""
if not stripped or " " in stripped or "\t" in stripped:
return False
for c in stripped:
if c.isalpha() or c.isdigit():
continue
if c in BLOCK_CHARS or c == WILDCARD_CHAR:
continue
return False
return True
def _looks_like_clue(stripped: str) -> bool:
return bool(_AUTO_CLUE_RE.match(stripped))
def _classify_line(line: str) -> str:
"""Coarse shape category used for implicit-mode auto-recovery.
Order matters: clue check precedes header check because clue bodies
may contain ':' (e.g. 'A1. Greeting: hi ~ HELLO')."""
s = line.strip()
if not s:
return "blank"
if EXPLICIT_HEADER_RE.match(line):
return "explicit"
if _looks_like_clue(s):
return "clue"
if ":" in s:
return "header"
if _looks_like_grid_row(s):
return "grid"
return "other"
def parse(text: str) -> ParsedXd:
lines = text.splitlines()
parse_errors: List[Finding] = []
# Pass 1: detect mode (explicit if any '## metadata|grid|clues|notes')
section_mode = "implicit"
for raw in lines:
m = EXPLICIT_HEADER_RE.match(raw)
if m and m.group(1).lower() in EXPLICIT_SECTIONS:
section_mode = "explicit"
break
# Pass 2: classify each line for implicit-mode auto-recovery. Skipped
# in explicit mode (markers handle section transitions explicitly).
line_class = (
[_classify_line(raw) for raw in lines]
if section_mode == "implicit" else []
)
headers: List[Header] = []
grid: List[GridRow] = []
clues: List[Clue] = []
notes_lines: List[str] = []
unknown_sections: List[Tuple[int, str]] = []
section = "metadata"
blank_run = 0
started = False
last_clue: Optional[Clue] = None
implicit_order = ["metadata", "grid", "clues", "notes"]
def advance_implicit():
nonlocal section
idx = implicit_order.index(section) if section in implicit_order else len(implicit_order) - 1
if idx + 1 < len(implicit_order):
section = implicit_order[idx + 1]
for lineno0, raw in enumerate(lines):
lineno = lineno0 + 1
stripped = raw.strip()
# Explicit '## Section' overrides everything
if section_mode == "explicit":
m = EXPLICIT_HEADER_RE.match(raw)
if m:
raw_name = m.group(1)
name = raw_name.lower()
if name in EXPLICIT_SECTIONS:
section = EXPLICIT_SECTIONS[name]
started = True
blank_run = 0
last_clue = None
else:
# Per spec: unknown sections are ignored. Record the
# header line so XD207 can surface it.
section = "_unknown"
blank_run = 0
last_clue = None
unknown_sections.append((lineno, raw_name))
continue
if not stripped:
blank_run += 1
# Implicit-mode section advance: 2+ blank lines, but only after
# we've seen real content (otherwise leading blanks would skip
# past metadata).
if section_mode == "implicit" and started and blank_run == 2:
advance_implicit()
last_clue = None
continue
started = True
blank_run = 0
# Implicit-mode auto-recovery: if the current line's shape doesn't
# match the current section, advance the section and let the
# dispatch below process the line in the right place. Only fires
# in implicit mode (explicit '## Section' markers handle transitions
# unambiguously). Emits XD019 to record the structural violation.
if section_mode == "implicit":
cls = line_class[lineno0]
if section == "metadata" and cls == "grid":
# Lookahead guards against single-word stray metadata lines:
# only advance when the next non-blank line is also content
# ('grid' confirms a real grid; 'clue' covers tiny puzzles
# whose grid is a single row before clues start).
future = [c for c in line_class[lineno0 + 1:] if c != "blank"]
if future and future[0] in ("grid", "clue"):
parse_errors.append(Finding(
code="XD019", severity=Severity.WARNING, line=lineno,
message="line looks like a grid row; auto-advanced "
"to grid section (missing 2+ blank lines "
"between metadata and grid?)",
))
section = "grid"
last_clue = None
elif section == "grid" and cls == "clue":
parse_errors.append(Finding(
code="XD019", severity=Severity.WARNING, line=lineno,
message="line looks like a clue; auto-advanced to "
"clues section (missing 2+ blank lines "
"between grid and clues?)",
))
section = "clues"
last_clue = None
if section == "metadata":
if ":" in stripped:
k, _, v = stripped.partition(":")
headers.append(Header(line=lineno, key=k.strip(), value=v.strip()))
else:
# Non key:value line in metadata: typically caused by a
# missing section separator (need 2+ blank lines between
# metadata and grid, only 1 was found).
parse_errors.append(Finding(
code="XD019", severity=Severity.WARNING, line=lineno,
message=f"non-header line in metadata section "
f"(likely missing 2+ blank lines before grid): "
f"{stripped!r}",
))
elif section == "grid":
grid.append(GridRow(line=lineno, cells=stripped))
elif section == "clues":
# Clue metadata: "A1 ^Key: value"
mm = CLUE_META_RE.match(stripped)
if mm:
pos_ref = mm.group(1)
if last_clue is not None and pos_ref == last_clue.pos:
_, mkey, mval = mm.groups()
last_clue.metadata[mkey.lower()] = mval.strip()
continue
# Looks like metadata but isn't anchored to the preceding
# clue. Per spec, '^Key:' lines attach to the clue immediately
# above. Emit XD021 and skip the line.
if last_clue is None:
msg = (f"clue metadata for {pos_ref!r} appears with no "
f"preceding clue")
else:
msg = (f"clue metadata for {pos_ref!r} doesn't follow "
f"its referent (preceding clue is "
f"{last_clue.pos!r})")
parse_errors.append(Finding(
code="XD021", severity=Severity.ERROR,
line=lineno, message=msg,
))
continue
# Normal clue: "A1. Body ~ ANSWER". Prefer the spec separator
# ' ~ '; fall back to a bare '~' only when no spaced form is
# present, so a clue body containing '~' (math, ASCII art) still
# parses with the trailing answer correctly identified.
spaced_idx = stripped.rfind(" ~ ")
if spaced_idx > 0:
head_part = stripped[:spaced_idx]
answer = stripped[spaced_idx + 3:].strip()
else:
ans_idx = stripped.rfind("~")
if ans_idx > 0:
head_part = stripped[:ans_idx].rstrip()
answer = stripped[ans_idx + 1:].strip()
else:
head_part = stripped
answer = ""
dot = head_part.find(".")
if dot <= 0:
parse_errors.append(Finding(
code="XD012", severity=Severity.ERROR, line=lineno,
message=f"unrecognized line in clues section "
f"(no '.', likely an embedded newline): {stripped!r}",
))
continue
pos = head_part[:dot].strip()
body = head_part[dot + 1:].strip()
pm = CLUE_POS_RE.match(pos)
if pm:
direction = pm.group(1).upper()
number = int(pm.group(2))
else:
direction = ""
number = -1
c = Clue(line=lineno, pos=pos, direction=direction, number=number,
body=body, answer=answer, raw=stripped)
clues.append(c)
last_clue = c
elif section == "notes":
notes_lines.append(stripped)
# else: section == "_unknown" — per spec, ignored entirely.
return ParsedXd(
text=text, lines=lines, section_mode=section_mode,
headers=headers, grid=grid, clues=clues,
notes_text="\n".join(notes_lines), parse_errors=parse_errors,
unknown_sections=unknown_sections,
)
# ---------------------------------------------------------------------------
# Rule registry
# ---------------------------------------------------------------------------
RuleFn = Callable[[Ctx], Iterator[Finding]]
RULES: List[tuple] = [] # (code, severity, name, experimental, fn)
# Findings emitted directly by the parser (not via @rule). Listed here so
# --list-rules surfaces them. The actual emission sites are inside parse()
# and the file-decode helpers; severity/message there must match what's
# documented here.
PARSER_LEVEL_FINDINGS: List[tuple] = [
("XD012", Severity.ERROR, "embedded-newline-in-clue"),
("XD019", Severity.WARNING, "missing-section-separator"),
("XD021", Severity.ERROR, "misplaced-clue-metadata"),
("XD022", Severity.ERROR, "non-utf8-bytes"),
]
def rule(code: str, severity: Severity, name: str, experimental: bool = False):
"""Register a check.
experimental=True marks rules whose validation depends on conventions
not formalized in the spec (currently: rules that interpret quantum
or Schrödinger rebus syntax). Disabled by --no-experimental.
"""
def deco(fn: RuleFn) -> RuleFn:
RULES.append((code, severity, name, experimental, fn))
return fn
return deco
def finding(code: str, severity: Severity, line: int, message: str) -> Finding:
return Finding(code=code, severity=severity, line=line, message=message)
# ---------------------------------------------------------------------------
# Helpers used by rules
# ---------------------------------------------------------------------------
# The spec calls out '#' as block, '_' as open/non-cell, '.' as wildcard.
# Asian variants U+25A0 / U+FF3F also appear in the existing parser.
BLOCK_CHARS = {"#", "_", "■", "_"}
WILDCARD_CHAR = "."
@dataclass
class RebusExpansion:
"""Per-direction list of valid expansions for a rebus key.
Conventions (extension to xd-format spec, not yet formalized):
'/' separates across vs down readings: 1=IE/EI
'|' separates Schrödinger alternates: 1=A|B (any letter, any direction)
Both compose, '|' precedence within '/' halves: 1=SE/S|E
Empty halves of '/' mean literal slash: 1=/ (cell is the '/' character)
For non-quantum rebus, across == down == [single value].
"""
across: List[str] # acceptable expansions when slot is read across
down: List[str] # acceptable expansions when slot is read down
@property
def is_directional(self) -> bool:
return self.across != self.down
def is_schrodinger(self, direction_idx: int) -> bool:
return len((self.across, self.down)[direction_idx]) > 1
def _split_alternatives(s: str) -> List[str]:
"""Split on '|' if it acts as an operator (2+ non-empty parts).
Otherwise treat the whole string as a single literal value."""
if "|" in s:
parts = s.split("|")
if len(parts) >= 2 and all(parts):
return [p.upper() for p in parts]
return [s.upper()]
def parse_rebus_value(value: str) -> RebusExpansion:
"""Parse one rebus value (the part after '=' in 'key=value').
See RebusExpansion docstring for the convention this implements.
"""
if "/" in value:
slash_idx = value.index("/")
before = value[:slash_idx]
after = value[slash_idx + 1:]
if before and after:
# Directional split.
return RebusExpansion(
across=_split_alternatives(before),
down=_split_alternatives(after),
)
# Empty half(s): '/' is the literal cell content, not an operator.
alts = _split_alternatives(value)
return RebusExpansion(across=alts, down=alts)
def parse_rebus_header(value: str) -> Dict[str, RebusExpansion]:
"""Parse 'Rebus: 1=ONE 2=TWO 3=A/B' into {key: RebusExpansion}.
Tolerates comma separators ('1=ONE,2=TWO') alongside whitespace so
downstream rules don't choke on files that diverge from the spec.
XD109 flags the comma form as a style violation."""
out: Dict[str, RebusExpansion] = {}
for part in re.split(r"[,\s]+", value.strip()):
if "=" not in part:
continue
k, _, v = part.partition("=")
k = k.strip()
if len(k) != 1:
continue
out[k] = parse_rebus_value(v)
return out
def get_header(parsed: ParsedXd, key: str) -> Optional[str]:
for h in parsed.headers:
if h.key.lower() == key.lower():
return h.value
return None
def grid_get(grid: List[GridRow], r: int, c: int) -> str:
if r < 0 or r >= len(grid):
return "#"
row = grid[r].cells
if c < 0 or c >= len(row):
return "#"
return row[c]
def is_boundary(grid: List[GridRow], r: int, c: int) -> bool:
return grid_get(grid, r, c) in BLOCK_CHARS
def enumerate_slots(grid: List[GridRow]):
"""Yield slots from the grid in canonical xd numbering order.
Returns list of (direction, num, r, c, cells).
direction: 'A' or 'D'
cells: list of (r, c) covering the slot
Answer expansion is intentionally NOT baked in: under the quantum
rebus convention each cell may have multiple direction-dependent
expansions, so the validator computes them per-clue.
"""
slots = []
if not grid:
return slots
num = 1
for r in range(len(grid)):
row_len = len(grid[r].cells)
for c in range(row_len):
if is_boundary(grid, r, c):
continue
new_clue = False
# Across slot start
if is_boundary(grid, r, c - 1):
cc = c
cells = []
while not is_boundary(grid, r, cc):
cells.append((r, cc))
cc += 1
if len(cells) > 1:
new_clue = True
slots.append(("A", num, r, c, cells))
# Down slot start
if is_boundary(grid, r - 1, c):
rr = r
cells = []
while not is_boundary(grid, rr, c):
cells.append((rr, c))
rr += 1
if len(cells) > 1:
new_clue = True
slots.append(("D", num, r, c, cells))
if new_clue:
num += 1
return slots
# Memoize slot enumeration on the ctx so multiple structural rules don't
# repeat the work. Keyed by id(ctx) since Ctx isn't hashable.
_SLOT_CACHE: dict = {}
def slots_for(ctx: Ctx):
key = id(ctx)
if key in _SLOT_CACHE:
return _SLOT_CACHE[key]
slots = enumerate_slots(ctx.parsed.grid)
_SLOT_CACHE[key] = slots
return slots
def _validate_answer_against_slot(
declared: str,
cells: List[Tuple[int, int]],
grid: List[GridRow],
rebus_map: Dict[str, RebusExpansion],
direction_idx: int,
) -> Optional[Tuple[str, str]]:
"""Walk the declared answer and slot cells in lockstep.
Returns None on success. Returns (code, message) on failure where
code is 'XD006' for length problems and 'XD007' for letter problems.
Splits the declared answer on ' / ' (one space, slash, one space)
into candidate spellings and validates each against the slot. ALL
candidates must validate. This accepts the spelled-out Schrödinger
convention — e.g. 'TACK / RACK' for an across slot through a cell
whose rebus alts are {T, R}, or 'CIGAR / PENIS' for an all-cells-
Schrödinger answer — while rejecting inline-embedded forms like
'T/RACK' that paste both readings mid-word (those fail because the
bare '/' won't match the next grid cell). The space-flanked split
leaves answers with literal '/' content (rebus value '1=/') intact.
At each rebus cell, accepts any alt from either direction's
expansion (union of across and down). Quantum-vs-Schrödinger
distinction at the cell level is moot under this framing — the
answer-line spelling chooses, not the rebus syntax.
direction_idx is retained for callsite compatibility but no longer
constrains which alts are acceptable.
The xd-crossword-tools '|' word-split convention is stripped before
splitting on ' / '."""
declared = declared.replace("|", "")
candidates = declared.split(" / ")
for candidate in candidates:
result = _walk_one_answer(candidate, cells, grid, rebus_map)
if result is not None:
return result
return None
def _walk_one_answer(
declared: str,
cells: List[Tuple[int, int]],
grid: List[GridRow],
rebus_map: Dict[str, RebusExpansion],
) -> Optional[Tuple[str, str]]:
di = 0
for (r, c) in cells:
ch = grid_get(grid, r, c)
if ch in rebus_map:
rebus = rebus_map[ch]
# Union of across+down alts: any reading is acceptable for
# any direction, mirroring the spelled-out answer convention.
alts = list(rebus.across)
for a in rebus.down:
if a not in alts:
alts.append(a)
# Longest-first: with unequal-length alts (e.g. 1=A|AB or
# 1=KIT/KAT alongside 1=KITKAT), a greedy short match would
# consume too few characters and misalign the rest of the slot.
matched = False
for alt in sorted(alts, key=len, reverse=True):
if declared[di:di + len(alt)].upper() == alt:
di += len(alt)
matched = True
break
if not matched:
if di >= len(declared):
return ("XD006", "answer too short for slot")
expected = " or ".join(repr(a) for a in alts)
fragment = declared[di:di + max((len(a) for a in alts), default=1)]
return ("XD007", f"at position {di + 1}: expected {expected}, "
f"found {fragment!r}")
elif ch == WILDCARD_CHAR:
if di >= len(declared):
return ("XD006", "answer too short for slot")
di += 1 # wildcard accepts anything
else:
if di >= len(declared):
return ("XD006", "answer too short for slot")
if declared[di].upper() != ch.upper():
return ("XD007", f"at position {di + 1}: expected "
f"{ch.upper()!r}, found {declared[di]!r}")
di += 1
if di < len(declared):
return ("XD006", f"answer has {len(declared) - di} extra char(s) "
f"after position {di}")
return None
def grid_is_rectangular(parsed: ParsedXd) -> bool:
if not parsed.grid:
return True
widths = {len(g.cells) for g in parsed.grid}
return len(widths) == 1
# ---------------------------------------------------------------------------
# Rules - errors
# ---------------------------------------------------------------------------
@rule("XD001", Severity.ERROR, "rectangular-grid")
def _(ctx):
if not ctx.parsed.grid:
return
first_w = len(ctx.parsed.grid[0].cells)
for g in ctx.parsed.grid:
if len(g.cells) != first_w:
yield finding("XD001", Severity.ERROR, g.line,
f"row width {len(g.cells)} != first row width {first_w}")
@rule("XD002", Severity.ERROR, "unrecognized-grid-char")
def _(ctx):
"""Grid cell that isn't a letter, block, wildcard, or declared rebus key."""
rebus = parse_rebus_header(get_header(ctx.parsed, "Rebus") or "")
seen_pairs = set() # de-dup to one finding per (line, char)
for row in ctx.parsed.grid:
for col, ch in enumerate(row.cells, 1):
if ch in BLOCK_CHARS or ch == WILDCARD_CHAR or ch.isalpha():
continue
if ch in rebus:
continue
key = (row.line, ch)
if key in seen_pairs:
continue
seen_pairs.add(key)
yield finding("XD002", Severity.ERROR, row.line,
f"unrecognized grid character {ch!r} at col {col}")
@rule("XD003", Severity.WARNING, "rebus-key-not-in-grid")
def _(ctx):
rebus_header = next((h for h in ctx.parsed.headers
if h.key.lower() == "rebus"), None)
if rebus_header is None:
return
rebus = parse_rebus_header(rebus_header.value)
if not rebus:
return
used = set()
for row in ctx.parsed.grid:
used.update(row.cells)
for k in rebus:
if k not in used:
yield finding("XD003", Severity.WARNING, rebus_header.line,
f"rebus key {k!r} declared but never used in grid")
def _slot_index_or_none(ctx):
"""Build {pos: (direction, cells)} for the rectangular case.
Returns None when the answer-grid family of rules can't run.
Quantum/Schrödinger rebuses are *not* a skip path — the validator
handles them.
"""
if not ctx.parsed.grid or not ctx.parsed.clues:
return None
if not grid_is_rectangular(ctx.parsed):
return None # XD001 covers it; slot enumeration is unreliable
slots = slots_for(ctx)
return {f"{d}{n}": (d, cells) for (d, n, _r, _c, cells) in slots}
def _rebus_for(ctx) -> Dict[str, RebusExpansion]:
"""Memoized rebus map per ctx."""
cache = getattr(ctx, "_rebus_cache", None)
if cache is not None:
return cache
cache = parse_rebus_header(get_header(ctx.parsed, "Rebus") or "")
ctx._rebus_cache = cache
return cache
def _validations_for(ctx):
"""List of (clue, validation_result) for clues that map to a slot.
Memoized per ctx so XD006 and XD007 share the per-clue validation
work instead of each calling _validate_answer_against_slot
independently. result is None on success or (code, message) on
failure; the rules each filter on result[0]."""
cache = getattr(ctx, "_validations", None)
if cache is not None:
return cache
idx = _slot_index_or_none(ctx)
if idx is None:
ctx._validations = []
return ctx._validations
rebus_map = _rebus_for(ctx)
out = []
for clue in ctx.parsed.clues:
if not clue.answer or clue.direction not in ("A", "D"):
continue
if clue.pos not in idx:
continue # XD004 fires
direction, cells = idx[clue.pos]
direction_idx = 0 if direction == "A" else 1
result = _validate_answer_against_slot(
clue.answer, cells, ctx.parsed.grid, rebus_map, direction_idx,
)
out.append((clue, result))
ctx._validations = out
return out
@rule("XD004", Severity.WARNING, "missing-slot-for-clue")
def _(ctx):
idx = _slot_index_or_none(ctx)
if idx is None:
return
for clue in ctx.parsed.clues:
if clue.direction not in ("A", "D"):
continue
if clue.pos not in idx:
yield finding("XD004", Severity.WARNING, clue.line,
f"clue {clue.pos} has no corresponding slot in grid")
@rule("XD005", Severity.WARNING, "clue-count-mismatch")
def _(ctx):
"""Compares distinct clue *positions* (not total clues) to slot count.
Schrödinger puzzles legitimately have multiple clues at the same
position, so counting positions rather than clues handles that.
Only counts A/D clues: cluegroup positions ('X1.' etc.) don't map to
slots in `idx`, and a single XD017-malformed clue shouldn't suppress
the count check for the rest of the file."""
idx = _slot_index_or_none(ctx)
if idx is None:
return
n_slots = len(idx)
n_positions = len({c.pos for c in ctx.parsed.clues
if c.pos and c.direction in ("A", "D")})
if n_slots != n_positions:
line = ctx.parsed.clues[0].line if ctx.parsed.clues else 0
yield finding("XD005", Severity.WARNING, line,
f"distinct clue positions {n_positions} "
f"!= grid slot count {n_slots}")
@rule("XD006", Severity.WARNING, "answer-length-mismatch", experimental=True)
def _(ctx):
for clue, result in _validations_for(ctx):
if result is not None and result[0] == "XD006":
yield finding("XD006", Severity.WARNING, clue.line,
f"clue {clue.pos}: {result[1]} "
f"(declared={clue.answer!r})")
@rule("XD007", Severity.WARNING, "answer-grid-letter-mismatch", experimental=True)
def _(ctx):
for clue, result in _validations_for(ctx):
if result is not None and result[0] == "XD007":
yield finding("XD007", Severity.WARNING, clue.line,
f"clue {clue.pos}: {result[1]} "
f"(declared={clue.answer!r})")
@rule("XD008", Severity.WARNING, "duplicate-clue-position", experimental=True)
def _(ctx):
"""Two clues at the same position are legal only when the slot
contains a Schrödinger cell in that direction (the puzzle author
is providing a separate clue for each valid letter-choice reading)."""
idx = _slot_index_or_none(ctx)
rebus_map = _rebus_for(ctx)
seen = {}
for clue in ctx.parsed.clues:
if not clue.pos:
continue
if clue.pos not in seen:
seen[clue.pos] = clue.line
continue
# Duplicate. Allow if the slot has any Schrödinger cell for
# this direction.
allowed = False
if idx and clue.pos in idx and clue.direction in ("A", "D"):
direction, cells = idx[clue.pos]
direction_idx = 0 if direction == "A" else 1
for (r, c) in cells:
ch = grid_get(ctx.parsed.grid, r, c)
if ch in rebus_map and rebus_map[ch].is_schrodinger(direction_idx):
allowed = True
break
if not allowed:
yield finding("XD008", Severity.WARNING, clue.line,
f"clue position {clue.pos} duplicated "
f"(first seen at line {seen[clue.pos]})")
_C1_RE = re.compile(r"[\x80-\x9f]")
# Source-encoding candidates for each C1 codepoint. cp1252 covers most of our
# corpus (em dashes, smart quotes, š); some files were originally Mac Roman
# (0x8E='é' in Mac Roman, 'Ž' in cp1252); a few older Newsday/NYSun files
# came from cp437/cp850 (DOS) where 0x82='é', 0x89='ë'; and U+0080/U+0098
# sometimes stand for symbols (°, ÷) that no standard encoding maps to.
# Showing all three lets the user pick when --fix can't decide.
_CP1252_CANDIDATES = {}
_MAC_ROMAN_CANDIDATES = {}
_CP437_CANDIDATES = {}
for _b in range(0x80, 0xa0):
try:
_CP1252_CANDIDATES[_b] = bytes([_b]).decode("cp1252")
except UnicodeDecodeError:
pass
_MAC_ROMAN_CANDIDATES[_b] = bytes([_b]).decode("mac_roman")
_CP437_CANDIDATES[_b] = bytes([_b]).decode("cp437")
def _c1_candidates(cp: int) -> str:
cp1252 = (f"cp1252: {_CP1252_CANDIDATES[cp]!r}"
if cp in _CP1252_CANDIDATES else "cp1252: undefined")
mac = f"Mac Roman: {_MAC_ROMAN_CANDIDATES[cp]!r}"
cp437 = f"cp437: {_CP437_CANDIDATES[cp]!r}"
return f"{cp1252}, {mac}, {cp437}"
@rule("XD010", Severity.ERROR, "bad-codepoint")
def _(ctx):
for i, line in enumerate(ctx.parsed.lines, 1):
m = _C1_RE.search(line)
if m:
cp = ord(m.group(0))
yield finding("XD010", Severity.ERROR, i,
f"C1 control U+{cp:04X} at col {m.start() + 1} "
f"({_c1_candidates(cp)})")
# UTF-8 byte sequence \xc2\xXX or \xc3\xXX (latin-supplement block) misread
# as latin-1 leaves 'Â' or 'Ã' followed by a U+0080-U+00BF char. Re-encoding
# as latin-1 and decoding as UTF-8 reverses the corruption.
_LATIN1_UTF8_RE = re.compile(r"[\xc2\xc3][\x80-\xbf]")
@rule("XD009", Severity.ERROR, "latin1-utf8-mojibake")
def _(ctx):
"""UTF-8 latin-supplement bytes misread as latin-1 (e.g. 'ê' for 'ê',
'é' for 'é'). Common when a UTF-8 .puz file was decoded as ISO-8859-1
by an upstream converter."""
for i, line in enumerate(ctx.parsed.lines, 1):
for m in _LATIN1_UTF8_RE.finditer(line):
try:
fix = m.group(0).encode('latin-1').decode('utf-8')
except UnicodeDecodeError:
continue
yield finding("XD009", Severity.ERROR, i,
f"latin-1 misread of UTF-8 {m.group(0)!r} "
f"at col {m.start() + 1} (should be {fix!r})")
_HTML_ENTITY_RE = re.compile(r"&(?:[a-zA-Z]{1,8}|#\d+|#x[0-9a-fA-F]+);")
@rule("XD011", Severity.ERROR, "html-entity")
def _(ctx):
for i, line in enumerate(ctx.parsed.lines, 1):
m = _HTML_ENTITY_RE.search(line)
if m:
yield finding("XD011", Severity.ERROR, i,
f"HTML-entity-shaped token {m.group(0)!r} at col {m.start() + 1}")
@rule("XD020", Severity.ERROR, "missing-required-section")
def _(ctx):
"""Per spec, the file has metadata, grid, and clues sections.
Notes is optional."""
if not ctx.parsed.headers:
yield finding("XD020", Severity.ERROR, 0, "no metadata section found")
if not ctx.parsed.grid:
yield finding("XD020", Severity.ERROR, 0, "no grid section found")
if not ctx.parsed.clues:
yield finding("XD020", Severity.ERROR, 0, "no clues section found")
_ISO_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")
@rule("XD701", Severity.WARNING, "filename-date-mismatch")
def _(ctx):
"""Catches files saved under a date the puzzle wasn't published on
(e.g. a 2021 file containing a 2018 puzzle). Skips files whose name
has no date."""
fn = os.path.basename(ctx.filename)
fn_match = _ISO_DATE_RE.search(fn)
if not fn_match:
return
hdr_date = get_header(ctx.parsed, "Date") or ""
hdr_match = _ISO_DATE_RE.search(hdr_date)
if not hdr_match:
return # XD105 covers malformed Date headers
if fn_match.group(1) != hdr_match.group(1):
line = next((h.line for h in ctx.parsed.headers
if h.key.lower() == "date"), 0)
yield finding("XD701", Severity.WARNING, line,
f"filename date {fn_match.group(1)} doesn't match "
f"Date header {hdr_match.group(1)}")
@rule("XD703", Severity.WARNING, "deprecated-rebus-inline-embed")
def _(ctx):
"""Answer uses the deprecated inline-embed rebus form at a directional
rebus cell — '<across>/<down>' pasted mid-word, e.g. 'J/POKER',
'ALPH/FA', 'VIVIE/EINLEI/IEGH'. The canonical form spells out both
readings: 'JOKER / POKER', 'ALPHA / ALFA'.
The --fix expands inline-embed to spelled-out (information-preserving;
no commitment to quantum vs. Schrödinger). For confirmed-quantum
puzzles, a follow-up step collapses spelled-out to the directional
half. Only fires on cells with one distinct alt per direction."""
rebus_map = _rebus_for(ctx)
if not rebus_map:
return
if not any(len(set(r.across) | set(r.down)) >= 2
for r in rebus_map.values()):
return # no multi-alt rebus in this puzzle
idx = _slot_index_or_none(ctx)
if idx is None:
return
for clue in ctx.parsed.clues:
if clue.direction not in ("A", "D"):
continue
if clue.pos not in idx:
continue
_direction, cells = idx[clue.pos]