whykit 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. whykit/__init__.py +21 -0
  2. whykit/adopt.py +776 -0
  3. whykit/backlinks.py +106 -0
  4. whykit/check.py +320 -0
  5. whykit/ci_formats.py +283 -0
  6. whykit/cli.py +1719 -0
  7. whykit/completion.py +193 -0
  8. whykit/config.py +233 -0
  9. whykit/console.py +217 -0
  10. whykit/context.py +204 -0
  11. whykit/contract.py +210 -0
  12. whykit/contract_schemas/backlinks-report.schema.json +61 -0
  13. whykit/contract_schemas/context-bundle.schema.json +27 -0
  14. whykit/contract_schemas/context-pack.schema.json +29 -0
  15. whykit/contract_schemas/impact-report.schema.json +122 -0
  16. whykit/contract_schemas/query-result.schema.json +105 -0
  17. whykit/contract_schemas/status-report.schema.json +176 -0
  18. whykit/contract_schemas/trace-report.schema.json +211 -0
  19. whykit/diff.py +823 -0
  20. whykit/evidence.py +178 -0
  21. whykit/explorer_index.py +554 -0
  22. whykit/graph.py +318 -0
  23. whykit/immutability.py +624 -0
  24. whykit/impact.py +272 -0
  25. whykit/init_layout.py +205 -0
  26. whykit/io.py +563 -0
  27. whykit/lint.py +2190 -0
  28. whykit/lsp.py +1241 -0
  29. whykit/markdown_code.py +107 -0
  30. whykit/mcp_server.py +2289 -0
  31. whykit/messages.py +46 -0
  32. whykit/pack.py +294 -0
  33. whykit/parse_cache.py +752 -0
  34. whykit/placeholders.py +84 -0
  35. whykit/promote.py +651 -0
  36. whykit/py.typed +0 -0
  37. whykit/query.py +174 -0
  38. whykit/review.py +527 -0
  39. whykit/rule_policy.py +1063 -0
  40. whykit/rules.py +274 -0
  41. whykit/scaffold.py +784 -0
  42. whykit/sensitivity.py +95 -0
  43. whykit/snapshot.py +319 -0
  44. whykit/status.py +167 -0
  45. whykit/tables.py +120 -0
  46. whykit/template/00-context/README.md +17 -0
  47. whykit/template/00-context/company.md +52 -0
  48. whykit/template/00-context/evidence-register.md +34 -0
  49. whykit/template/00-context/goals.md +38 -0
  50. whykit/template/00-context/review-log.md +22 -0
  51. whykit/template/00-context/terminology.md +34 -0
  52. whykit/template/01-strategy/README.md +7 -0
  53. whykit/template/02-discoverability/README.md +10 -0
  54. whykit/template/03-website/README.md +7 -0
  55. whykit/template/04-automation/README.md +10 -0
  56. whykit/template/05-operations/README.md +19 -0
  57. whykit/template/06-decisions/README.md +11 -0
  58. whykit/template/06-decisions/decision-log.md +38 -0
  59. whykit/template/07-research/README.md +10 -0
  60. whykit/template/AGENTS.md +163 -0
  61. whykit/template/Home.md +57 -0
  62. whykit/template/INTEROP.md +117 -0
  63. whykit/template/OBSIDIAN.md +92 -0
  64. whykit/template/README.md +51 -0
  65. whykit/template/assets/README.md +6 -0
  66. whykit/template/reports/README.md +7 -0
  67. whykit/template/templates/README.md +7 -0
  68. whykit/template/templates/automation-spec-template.md +70 -0
  69. whykit/template/templates/decision-record-template.md +65 -0
  70. whykit/template/templates/document-template.md +55 -0
  71. whykit/template/templates/experiment-template.md +62 -0
  72. whykit/template/templates/meeting-note-template.md +47 -0
  73. whykit/template/templates/page-brief-template.md +63 -0
  74. whykit/template/templates/research-note-template.md +53 -0
  75. whykit/template/templates/source-ingestion-template.md +43 -0
  76. whykit/template/whykit.toml +39 -0
  77. whykit/trace.py +272 -0
  78. whykit/vault_index.py +190 -0
  79. whykit-0.3.1.dist-info/METADATA +162 -0
  80. whykit-0.3.1.dist-info/RECORD +84 -0
  81. whykit-0.3.1.dist-info/WHEEL +4 -0
  82. whykit-0.3.1.dist-info/entry_points.txt +3 -0
  83. whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
  84. whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/rule_policy.py ADDED
@@ -0,0 +1,1063 @@
1
+ """Team policy declared in ``whykit.toml``: custom rules and rule overrides.
2
+
3
+ Two optional tables extend the built-in linter without forking it::
4
+
5
+ [[rules.custom]] # a team convention, reported as custom.<name>
6
+ id = "custom.decision_evidence"
7
+ level = "error"
8
+ summary = "Approved decisions cite at least two sources."
9
+ min_evidence = 2
10
+ [rules.custom.applies_to]
11
+ type = "decision"
12
+ status = "approved"
13
+
14
+ [rules.overrides."note.orphan"] # retune a rule, optionally for some paths only
15
+ level = "off"
16
+ paths = ["99-archive/**"]
17
+
18
+ Everything here is validated up front, and every error names the key that is
19
+ wrong (``rules.custom[0].min_evidence``), because a policy that half-applies is
20
+ worse than none. Two guards keep the policy itself from becoming a hole:
21
+
22
+ * patterns run on Python's backtracking engine, which has no timeout, so a
23
+ pattern that can backtrack catastrophically is rejected and the text it is
24
+ matched against is bounded (one line at a time, ``MAX_LINE_CHARS`` each);
25
+ * a security-relevant rule (``Rule.security``) cannot be lowered or switched
26
+ off without a written ``reason``, and every such override is reported in the
27
+ lint and check output together with the findings it suppressed.
28
+
29
+ This module must not import :mod:`whykit.lint` at import time: ``lint`` imports
30
+ ``config``, which imports this module.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import datetime as dt
35
+ import re
36
+ import time
37
+ from collections.abc import Iterable
38
+ from dataclasses import dataclass, field
39
+ from typing import TYPE_CHECKING, Any
40
+
41
+ from .rules import RULE_BY_CODE
42
+
43
+ if TYPE_CHECKING: # pragma: no cover
44
+ from .lint import Finding, Note
45
+
46
+ CUSTOM_ID_RE = re.compile(r"^custom\.[a-z0-9_]{1,60}$")
47
+ FRONT_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]{0,63}$")
48
+ LEVELS = ("error", "warning")
49
+ OVERRIDE_LEVELS = ("error", "warning", "off")
50
+ # A rule that guards the policy itself: overriding it could only hide that
51
+ # the policy is broken.
52
+ NOT_OVERRIDABLE = frozenset({"config.invalid"})
53
+
54
+ MAX_CUSTOM_RULES = 100
55
+ MAX_LIST_ITEMS = 50
56
+ MAX_PATTERNS = 20
57
+ MAX_PATTERN_LENGTH = 256
58
+ MAX_TEXT_LENGTH = 500
59
+ MAX_OVERRIDE_ENTRIES = 50
60
+ # Patterns are matched one line at a time. A longer line is checked on its
61
+ # first MAX_LINE_CHARS characters and the truncation is reported, so a pattern
62
+ # cannot be made to run for minutes and a skipped tail is never silent.
63
+ MAX_LINE_CHARS = 10_000
64
+ # Every accepted pattern is at most quadratic in the line length, but a vault
65
+ # can hold many long lines. Pattern checks share this wall-clock budget per
66
+ # lint run; once it is spent the remaining checks are reported as not run.
67
+ PATTERN_BUDGET_SECONDS = 10.0
68
+
69
+ APPLIES_TO_KEYS = ("type", "status", "paths", "workstream")
70
+ CHECK_KEYS = (
71
+ "required_keys",
72
+ "required_values",
73
+ "required_sections",
74
+ "required_patterns",
75
+ "forbidden_patterns",
76
+ "min_evidence",
77
+ "max_age_days",
78
+ )
79
+ CUSTOM_KEYS = frozenset({"id", "level", "summary", "why", "fix", "applies_to", *CHECK_KEYS})
80
+ RULES_KEYS = frozenset({"custom", "overrides"})
81
+ OVERRIDE_KEYS = frozenset({"level", "paths", "reason"})
82
+
83
+ DEFAULT_WHY = "A team convention defined in this vault's whykit.toml."
84
+ DEFAULT_FIX = "Follow the team policy stated in whykit.toml."
85
+
86
+
87
+ class PolicyError(ValueError):
88
+ """An invalid ``[rules]`` table. The message starts with the key path."""
89
+
90
+
91
+ # --------------------------------------------------------------------------
92
+ # Safe regular expressions
93
+ # --------------------------------------------------------------------------
94
+
95
+ _BRACE_RE = re.compile(r"\{(\d*)(?:(,)(\d*))?\}")
96
+
97
+
98
+ @dataclass
99
+ class _Frame:
100
+ repeats: bool = False # holds a quantifier that can match more than once
101
+ alternates: bool = False # holds a top-level or nested alternation
102
+
103
+
104
+ def _skip_group_prefix(pattern: str, at: int) -> tuple[int, str]:
105
+ """Return (index after the group's prefix, kind) for a group opening at *at*.
106
+
107
+ *kind* is ``group`` for a capturing/non-capturing/lookaround group,
108
+ ``flags`` for a flag-only group such as ``(?i)``, ``comment``,
109
+ ``backreference`` or ``conditional``.
110
+ """
111
+ i = at + 1
112
+ if pattern[i:i + 1] != "?":
113
+ return i, "group"
114
+ rest = pattern[i + 1:]
115
+ if rest.startswith("P="):
116
+ return i, "backreference"
117
+ if rest.startswith("("):
118
+ return i, "conditional"
119
+ if rest.startswith("#"):
120
+ end = pattern.find(")", i)
121
+ return (len(pattern) if end < 0 else end + 1), "comment"
122
+ if rest.startswith("P<"):
123
+ end = pattern.find(">", i)
124
+ return (len(pattern) if end < 0 else end + 1), "group"
125
+ if rest.startswith(("<=", "<!")):
126
+ return i + 3, "group"
127
+ if rest.startswith(("=", "!", ":", ">")):
128
+ return i + 2, "group"
129
+ j = i + 1
130
+ while j < len(pattern) and pattern[j] not in ":)":
131
+ j += 1
132
+ if j < len(pattern) and pattern[j] == ")":
133
+ return j + 1, "flags"
134
+ return j + 1, "group"
135
+
136
+
137
+ def _quantifier(pattern: str, at: int) -> tuple[int, bool] | None:
138
+ """Return (length, can_repeat) for a quantifier at *at*, else ``None``."""
139
+ char = pattern[at]
140
+ if char in "*+":
141
+ return 1, True
142
+ if char == "?":
143
+ return 1, False
144
+ if char == "{":
145
+ match = _BRACE_RE.match(pattern, at)
146
+ if match is None or not (match.group(1) or match.group(3)):
147
+ return None # a literal brace
148
+ low, comma, high = match.group(1), match.group(2), match.group(3)
149
+ if comma is None:
150
+ repeat = int(low) > 1
151
+ else:
152
+ repeat = not high or int(high) > 1
153
+ return match.end() - at, repeat
154
+ return None
155
+
156
+
157
+ def regex_problem(pattern: str) -> str | None:
158
+ """Why *pattern* is not acceptable in a policy, or ``None`` when it is.
159
+
160
+ Rejected: patterns longer than ``MAX_PATTERN_LENGTH``, patterns that do not
161
+ compile, backreferences, conditional groups, a repeated group that itself
162
+ holds a repeating quantifier (``(a+)+``), and a repeated group that holds
163
+ an alternation (``(a|aa)*``). Those are the shapes that make a backtracking
164
+ engine take exponential time on a short input.
165
+ """
166
+ if len(pattern) > MAX_PATTERN_LENGTH:
167
+ return f"is longer than {MAX_PATTERN_LENGTH} characters"
168
+ if not pattern:
169
+ return "is empty"
170
+ try:
171
+ re.compile(pattern)
172
+ except re.error as exc:
173
+ return f"is not a valid regular expression: {exc}"
174
+ frames = [_Frame()]
175
+ last_group: _Frame | None = None
176
+ last_is_atom = False
177
+ i = 0
178
+ n = len(pattern)
179
+ while i < n:
180
+ char = pattern[i]
181
+ if char == "\\":
182
+ follower = pattern[i + 1:i + 2]
183
+ if follower.isdigit() and follower != "0":
184
+ return "uses a backreference, which can take exponential time"
185
+ i += 2
186
+ last_group, last_is_atom = None, True
187
+ continue
188
+ if char == "[":
189
+ j = i + 1
190
+ if pattern[j:j + 1] == "^":
191
+ j += 1
192
+ if pattern[j:j + 1] == "]":
193
+ j += 1
194
+ while j < n and pattern[j] != "]":
195
+ j += 2 if pattern[j] == "\\" else 1
196
+ i = j + 1
197
+ last_group, last_is_atom = None, True
198
+ continue
199
+ if char == "(":
200
+ after, kind = _skip_group_prefix(pattern, i)
201
+ if kind == "backreference":
202
+ return "uses a backreference, which can take exponential time"
203
+ if kind == "conditional":
204
+ return "uses a conditional group, which refers back to another group"
205
+ if kind in ("comment", "flags"):
206
+ i = after
207
+ last_group, last_is_atom = None, False
208
+ continue
209
+ frames.append(_Frame())
210
+ i = after
211
+ last_group, last_is_atom = None, False
212
+ continue
213
+ if char == ")":
214
+ closed = frames.pop() if len(frames) > 1 else _Frame()
215
+ parent = frames[-1]
216
+ parent.repeats |= closed.repeats
217
+ parent.alternates |= closed.alternates
218
+ i += 1
219
+ last_group, last_is_atom = closed, True
220
+ continue
221
+ if char == "|":
222
+ frames[-1].alternates = True
223
+ i += 1
224
+ last_group, last_is_atom = None, False
225
+ continue
226
+ quantifier = _quantifier(pattern, i)
227
+ if quantifier is not None and last_is_atom:
228
+ length, repeats = quantifier
229
+ if repeats and last_group is not None:
230
+ if last_group.repeats:
231
+ return "has a nested quantifier (a quantifier inside a repeated group), which can take exponential time"
232
+ if last_group.alternates:
233
+ return "repeats a group that contains an alternation, which can take exponential time"
234
+ if repeats:
235
+ frames[-1].repeats = True
236
+ i += length
237
+ if i < n and pattern[i] in "?+": # lazy or possessive suffix
238
+ i += 1
239
+ last_group, last_is_atom = None, False
240
+ continue
241
+ i += 1
242
+ last_group, last_is_atom = None, True
243
+ return _structure_problem(pattern)
244
+
245
+
246
+ # The token scan above reads the pattern as written. Verbose mode (``(?x)``),
247
+ # inline comments and flag groups change what the engine sees without
248
+ # changing those characters, so the parsed form is checked as well, together
249
+ # with shapes a token scan cannot judge: a repeated body that can match empty
250
+ # (``(a?){25}``) and adjacent repeats that can match the same text
251
+ # (``\s*\s*x``, ``.*x.*y``), which take polynomial time on a long line.
252
+ _SAMPLE = frozenset(
253
+ [chr(code) for code in range(32, 127)]
254
+ + ["\t", "\n", "\r", "\x0b", "\x0c", "\u00a0", "\u00e9", "\u00c9", "\u4e2d", "\u0661", "\u2028", "\uff11", "\U0001f600"]
255
+ )
256
+ _CATEGORY_PROBES = {
257
+ "DIGIT": re.compile(r"\d"), "SPACE": re.compile(r"\s"), "WORD": re.compile(r"\w"),
258
+ "LINEBREAK": re.compile(r"\n"),
259
+ }
260
+ # A fixed count above this is treated like a variable one: `.*\s{200}` costs
261
+ # as much as two open-ended repeats.
262
+ _SMALL_FIXED_COUNT = 16
263
+ # At most this many optional parts may overlap in one stretch (`a?a?a?...a`
264
+ # is exponential in their number).
265
+ _MAX_OVERLAPPING_OPTIONALS = 6
266
+
267
+
268
+ class _PatternTooSlow(Exception):
269
+ pass
270
+
271
+
272
+ def _category_chars(category: object) -> frozenset[str]:
273
+ name = getattr(category, "name", str(category))
274
+ for key, probe in _CATEGORY_PROBES.items():
275
+ if key in name:
276
+ matched = frozenset(char for char in _SAMPLE if probe.match(char))
277
+ return _SAMPLE - matched if "NOT" in name else matched
278
+ return _SAMPLE
279
+
280
+
281
+ def _chars(op: object, av: Any, ignorecase: bool, dotall: bool) -> frozenset[str] | None:
282
+ """Sample characters a one-character matcher accepts, or None if *op* is not one."""
283
+ from re import _constants as sre # type: ignore[attr-defined]
284
+
285
+ def literal(code: int) -> set[str]:
286
+ char = chr(code)
287
+ return {char, char.lower(), char.upper()} if ignorecase else {char}
288
+
289
+ if op is sre.LITERAL:
290
+ return frozenset(literal(av))
291
+ if op is sre.NOT_LITERAL:
292
+ return _SAMPLE - literal(av)
293
+ if op is sre.ANY:
294
+ return _SAMPLE if dotall else _SAMPLE - {"\n"}
295
+ if op is sre.IN:
296
+ out: set[str] = set()
297
+ negate = False
298
+ for item_op, item_av in av:
299
+ if item_op is sre.NEGATE:
300
+ negate = True
301
+ elif item_op is sre.LITERAL:
302
+ out |= literal(item_av)
303
+ elif item_op is sre.RANGE:
304
+ low, high = item_av
305
+ out |= {char for char in _SAMPLE if low <= ord(char) <= high}
306
+ if ignorecase:
307
+ out |= {char.swapcase() for char in _SAMPLE if low <= ord(char) <= high}
308
+ elif item_op is sre.CATEGORY:
309
+ out |= _category_chars(item_av)
310
+ else:
311
+ out |= _SAMPLE
312
+ return _SAMPLE - out if negate else frozenset(out)
313
+ return None
314
+
315
+
316
+ @dataclass
317
+ class _Summary:
318
+ chars: frozenset[str] = frozenset()
319
+ nullable: bool = True
320
+ quantified: bool = False # holds a quantifier or an alternation somewhere
321
+
322
+
323
+ def _summarize(seq: Any, ignorecase: bool, dotall: bool) -> _Summary:
324
+ from re import _constants as sre # type: ignore[attr-defined]
325
+
326
+ total = _Summary()
327
+ for op, av in seq:
328
+ single = _chars(op, av, ignorecase, dotall)
329
+ if single is not None:
330
+ part = _Summary(single, False, False)
331
+ elif op in (sre.MAX_REPEAT, sre.MIN_REPEAT, sre.POSSESSIVE_REPEAT):
332
+ low, _high, body = av
333
+ inner = _summarize(body, ignorecase, dotall)
334
+ part = _Summary(inner.chars, low == 0 or inner.nullable, True)
335
+ elif op is sre.SUBPATTERN:
336
+ part = _summarize(av[3], ignorecase, dotall)
337
+ elif op is sre.ATOMIC_GROUP:
338
+ part = _summarize(av, ignorecase, dotall)
339
+ elif op is sre.BRANCH:
340
+ branches = [_summarize(branch, ignorecase, dotall) for branch in av[1]]
341
+ part = _Summary(
342
+ frozenset().union(*(b.chars for b in branches)),
343
+ any(b.nullable for b in branches),
344
+ True,
345
+ )
346
+ else: # anchors, lookarounds and anything zero-width
347
+ part = _Summary()
348
+ total = _Summary(total.chars | part.chars, total.nullable and part.nullable, total.quantified or part.quantified)
349
+ return total
350
+
351
+
352
+ def _check_nesting(seq: Any, ignorecase: bool, dotall: bool) -> None:
353
+ """Reject a group repeated more than once that holds a quantifier, an
354
+ alternation or a body that can match the empty string."""
355
+ from re import _constants as sre # type: ignore[attr-defined]
356
+
357
+ for op, av in seq:
358
+ if op in (sre.MAX_REPEAT, sre.MIN_REPEAT, sre.POSSESSIVE_REPEAT):
359
+ _low, high, body = av
360
+ if high > 1 and op is not sre.POSSESSIVE_REPEAT:
361
+ inner = _summarize(body, ignorecase, dotall)
362
+ if inner.quantified:
363
+ raise _PatternTooSlow(
364
+ "has a nested quantifier (a quantifier inside a repeated group), which can take exponential time"
365
+ )
366
+ if inner.nullable:
367
+ raise _PatternTooSlow(
368
+ "has a nested quantifier (a repeated group that can match nothing), which can take exponential time"
369
+ )
370
+ _check_nesting(body, ignorecase, dotall)
371
+ elif op is sre.SUBPATTERN:
372
+ _check_nesting(av[3], ignorecase, dotall)
373
+ elif op is sre.ATOMIC_GROUP:
374
+ _check_nesting(av, ignorecase, dotall)
375
+ elif op is sre.BRANCH:
376
+ for branch in av[1]:
377
+ _check_nesting(branch, ignorecase, dotall)
378
+ elif op in (sre.ASSERT, sre.ASSERT_NOT):
379
+ _check_nesting(av[1], ignorecase, dotall)
380
+
381
+
382
+ _OVERLAP = (
383
+ "has overlapping repeated parts (such as \\s*\\s* or .*x.*), which take polynomial time on a long line; "
384
+ "patterns are searched anywhere in a line, so a leading or trailing .* is never needed"
385
+ )
386
+
387
+
388
+ def _scan(seq: Any, chain: list[tuple[frozenset[str], bool]], ignorecase: bool, dotall: bool) -> list[tuple[frozenset[str], bool]]:
389
+ """Walk a sequence, tracking the repeats that can still give text back.
390
+
391
+ *chain* holds (characters, open-ended) for every quantified part since the
392
+ last element that none of them can match. A new open-ended part that
393
+ overlaps an open-ended part in the chain means the engine can split the
394
+ same text between them in quadratically many ways.
395
+ """
396
+ from re import _constants as sre # type: ignore[attr-defined]
397
+
398
+ def add(chars: frozenset[str], wide: bool) -> None:
399
+ overlapping = [entry for entry in chain if entry[0] & chars]
400
+ if wide and any(entry[1] for entry in overlapping):
401
+ raise _PatternTooSlow(_OVERLAP)
402
+ if len(overlapping) >= _MAX_OVERLAPPING_OPTIONALS:
403
+ raise _PatternTooSlow(_OVERLAP)
404
+ chain.append((chars, wide))
405
+
406
+ def mandatory(chars: frozenset[str]) -> None:
407
+ if not any(entry[0] & chars for entry in chain):
408
+ chain.clear()
409
+
410
+ for op, av in seq:
411
+ single = _chars(op, av, ignorecase, dotall)
412
+ if single is not None:
413
+ mandatory(single)
414
+ elif op in (sre.MAX_REPEAT, sre.MIN_REPEAT):
415
+ low, high, body = av
416
+ inner = _summarize(body, ignorecase, dotall)
417
+ if high > 1 and (high != low or high > _SMALL_FIXED_COUNT):
418
+ _scan(body, [], ignorecase, dotall)
419
+ add(inner.chars, True)
420
+ elif high == 1 and low == 0:
421
+ # An optional part: what it holds joins the chain.
422
+ chain[:] = _scan(body, list(chain), ignorecase, dotall) if inner.quantified else chain
423
+ add(inner.chars, False)
424
+ else:
425
+ for _ in range(max(low, 1) if not inner.quantified else 1):
426
+ chain[:] = _scan(body, chain, ignorecase, dotall)
427
+ elif op is sre.POSSESSIVE_REPEAT or op is sre.ATOMIC_GROUP:
428
+ body = av[2] if op is sre.POSSESSIVE_REPEAT else av
429
+ _scan(body, [], ignorecase, dotall)
430
+ inner = _summarize(body, ignorecase, dotall)
431
+ if not inner.nullable:
432
+ mandatory(inner.chars)
433
+ elif op is sre.SUBPATTERN:
434
+ chain[:] = _scan(av[3], chain, ignorecase, dotall)
435
+ elif op is sre.BRANCH:
436
+ merged: list[tuple[frozenset[str], bool]] = []
437
+ for branch in av[1]:
438
+ for entry in _scan(branch, list(chain), ignorecase, dotall):
439
+ if entry not in merged:
440
+ merged.append(entry)
441
+ chain[:] = merged
442
+ elif op in (sre.ASSERT, sre.ASSERT_NOT):
443
+ _scan(av[1], [], ignorecase, dotall)
444
+ # anchors and other zero-width items neither add nor separate
445
+ return chain
446
+
447
+
448
+ def _structure_problem(pattern: str) -> str | None:
449
+ try:
450
+ from re import _parser as sre_parse # type: ignore[attr-defined]
451
+ except ImportError: # pragma: no cover - CPython 3.11+ ships it
452
+ return None
453
+ tree = sre_parse.parse(pattern)
454
+ flags = tree.state.flags
455
+ ignorecase = bool(flags & re.IGNORECASE)
456
+ dotall = bool(flags & re.DOTALL)
457
+ try:
458
+ _check_nesting(tree, ignorecase, dotall)
459
+ _scan(tree, [], ignorecase, dotall)
460
+ except _PatternTooSlow as exc:
461
+ return str(exc)
462
+ except RecursionError:
463
+ return "is nested too deeply to check"
464
+ return None
465
+
466
+
467
+ # --------------------------------------------------------------------------
468
+ # Path globs
469
+ # --------------------------------------------------------------------------
470
+
471
+ def _glob_problem(pattern: str) -> str | None:
472
+ if not pattern.strip():
473
+ return "must be a non-empty glob"
474
+ if len(pattern) > MAX_TEXT_LENGTH:
475
+ return f"is longer than {MAX_TEXT_LENGTH} characters"
476
+ if pattern.startswith("/") or "\\" in pattern or re.match(r"^[A-Za-z]:", pattern):
477
+ return "must be a vault-relative POSIX glob such as 07-research/**"
478
+ if ".." in pattern.split("/"):
479
+ return "may not leave the vault with '..'"
480
+ return None
481
+
482
+
483
+ def compile_glob(pattern: str) -> re.Pattern[str]:
484
+ """Translate a vault-relative glob into a regular expression.
485
+
486
+ ``*`` and ``?`` stay within one path segment, ``**`` crosses segments, and
487
+ ``dir/`` means everything below ``dir``.
488
+ """
489
+ if pattern.endswith("/"):
490
+ pattern += "**"
491
+ out: list[str] = []
492
+ i = 0
493
+ while i < len(pattern):
494
+ if pattern.startswith("**/", i):
495
+ out.append("(?:[^/]*/)*")
496
+ i += 3
497
+ elif pattern.startswith("**", i):
498
+ out.append(".*")
499
+ i += 2
500
+ elif pattern[i] == "*":
501
+ out.append("[^/]*")
502
+ i += 1
503
+ elif pattern[i] == "?":
504
+ out.append("[^/]")
505
+ i += 1
506
+ else:
507
+ out.append(re.escape(pattern[i]))
508
+ i += 1
509
+ return re.compile("".join(out) + r"\Z")
510
+
511
+
512
+ # --------------------------------------------------------------------------
513
+ # Compiled policy
514
+ # --------------------------------------------------------------------------
515
+
516
+ @dataclass
517
+ class CustomRule:
518
+ code: str
519
+ level: str
520
+ summary: str
521
+ why: str
522
+ fix: str
523
+ applies_to: dict[str, list[str]]
524
+ checks: dict[str, Any]
525
+ path_globs: tuple[re.Pattern[str], ...] = ()
526
+ required: tuple[tuple[str, re.Pattern[str]], ...] = ()
527
+ forbidden: tuple[tuple[str, re.Pattern[str]], ...] = ()
528
+
529
+ def catalog_entry(self) -> dict[str, Any]:
530
+ return {
531
+ "code": self.code,
532
+ "default_level": self.level,
533
+ "summary": self.summary,
534
+ "why": self.why,
535
+ "fix": self.fix,
536
+ "security": False,
537
+ "custom": True,
538
+ "applies_to": self.applies_to,
539
+ "checks": self.checks,
540
+ }
541
+
542
+
543
+ @dataclass
544
+ class Override:
545
+ rule: str
546
+ level: str
547
+ paths: list[str]
548
+ reason: str | None
549
+ security: bool
550
+ globs: tuple[re.Pattern[str], ...] = ()
551
+ matched: int = 0
552
+ suppressed: list[Finding] = field(default_factory=list)
553
+ # Set when a run switched the whole secret scan off (``--no-secrets`` or a
554
+ # profile with ``secrets = false``) rather than a whykit.toml override.
555
+ skipped_by: str | None = None
556
+
557
+ def applies(self, path: str) -> bool:
558
+ return not self.globs or any(glob.match(path) for glob in self.globs)
559
+
560
+ def report(self) -> dict[str, Any]:
561
+ entry: dict[str, Any] = {
562
+ "rule": self.rule,
563
+ "level": self.level,
564
+ "paths": list(self.paths),
565
+ "reason": self.reason,
566
+ "security": self.security,
567
+ "matched": self.matched,
568
+ }
569
+ if self.security:
570
+ from dataclasses import asdict
571
+
572
+ entry["suppressed"] = [asdict(item) for item in self.suppressed]
573
+ if self.skipped_by:
574
+ entry["skipped_by"] = self.skipped_by
575
+ return entry
576
+
577
+ def describe(self) -> str:
578
+ return describe_override(self.report())
579
+
580
+
581
+ def describe_override(entry: dict[str, Any]) -> str:
582
+ """One line for an override report entry, as lint and check print it."""
583
+ if entry.get("skipped_by"):
584
+ return f"secret scan skipped by {entry['skipped_by']}; secret.* findings were not collected"
585
+ scope = ", ".join(entry["paths"]) if entry["paths"] else "the whole vault"
586
+ verb = "switched off" if entry["level"] == "off" else f"set to {entry['level']}"
587
+ counted = "suppressed" if entry["level"] == "off" else "changed"
588
+ noun = "finding" if entry["matched"] == 1 else "findings"
589
+ text = f"{entry['rule']} {verb} for {scope} ({entry['matched']} {noun} {counted})"
590
+ return text + (f" — reason: {entry['reason']}" if entry["reason"] else "")
591
+
592
+
593
+ SECRET_SCAN_RULE = "secret.*"
594
+
595
+
596
+ def secret_scan_skipped(source: str) -> Override:
597
+ """The report entry for a run whose secret scan was switched off.
598
+
599
+ Disabling the scan is the bluntest way to lower a security-relevant rule,
600
+ so it is reported like a security override: printed with every run and
601
+ listed in the JSON ``overrides`` array, never silent.
602
+ """
603
+ return Override(
604
+ rule=SECRET_SCAN_RULE,
605
+ level="off",
606
+ paths=[],
607
+ reason=f"secret scan skipped by {source}",
608
+ security=True,
609
+ skipped_by=source,
610
+ )
611
+
612
+
613
+ @dataclass
614
+ class RulePolicy:
615
+ custom: list[CustomRule]
616
+ overrides: list[Override]
617
+
618
+ def apply_overrides(self, findings: list[Finding]) -> list[Finding]:
619
+ """Return *findings* with override levels applied and 'off' entries removed.
620
+
621
+ Override entries for one rule are tried in the order they are written;
622
+ the first whose ``paths`` match the finding (or that has no ``paths``)
623
+ decides. Counters on each entry record what it changed.
624
+ """
625
+ if not self.overrides:
626
+ return findings
627
+ by_rule: dict[str, list[Override]] = {}
628
+ for item in self.overrides:
629
+ item.matched = 0
630
+ item.suppressed = []
631
+ by_rule.setdefault(item.rule, []).append(item)
632
+ kept: list[Finding] = []
633
+ for finding in findings:
634
+ entries = by_rule.get(finding.code, ())
635
+ entry = next((e for e in entries if e.applies(finding.path)), None)
636
+ if entry is None:
637
+ kept.append(finding)
638
+ continue
639
+ if entry.level == "off":
640
+ entry.matched += 1
641
+ entry.suppressed.append(finding)
642
+ continue
643
+ if entry.level != finding.level:
644
+ entry.matched += 1
645
+ finding.level = entry.level
646
+ kept.append(finding)
647
+ return kept
648
+
649
+
650
+ EMPTY_POLICY = RulePolicy([], [])
651
+
652
+
653
+ # --------------------------------------------------------------------------
654
+ # Validation
655
+ # --------------------------------------------------------------------------
656
+
657
+ def _quote(code: str) -> str:
658
+ return f'"{code}"'
659
+
660
+
661
+ def _strings(value: object, where: str, *, allow_scalar: bool = True) -> list[str]:
662
+ if allow_scalar and isinstance(value, str):
663
+ items: list[object] = [value]
664
+ indexed = False
665
+ elif isinstance(value, list):
666
+ items = list(value)
667
+ indexed = True
668
+ else:
669
+ raise PolicyError(f"{where} must be a string or a list of strings")
670
+ if not items:
671
+ raise PolicyError(f"{where} must not be empty")
672
+ if len(items) > MAX_LIST_ITEMS:
673
+ raise PolicyError(f"{where} may hold at most {MAX_LIST_ITEMS} items")
674
+ out: list[str] = []
675
+ for index, item in enumerate(items):
676
+ at = f"{where}[{index}]" if indexed else where
677
+ if not isinstance(item, str) or not item.strip():
678
+ raise PolicyError(f"{at} must be a non-empty string")
679
+ if len(item) > MAX_TEXT_LENGTH:
680
+ raise PolicyError(f"{at} is longer than {MAX_TEXT_LENGTH} characters")
681
+ out.append(item)
682
+ return out
683
+
684
+
685
+ def _indexed(where: str, index: int, single: bool) -> str:
686
+ return where if single else f"{where}[{index}]"
687
+
688
+
689
+ def _scalar_text(value: object) -> str | None:
690
+ if isinstance(value, bool):
691
+ return "true" if value else "false"
692
+ if isinstance(value, (str, int)) and not (isinstance(value, str) and not value.strip()):
693
+ return str(value)
694
+ return None
695
+
696
+
697
+ def _check_vocabulary(values: list[str], allowed: Iterable[str], where: str, single: bool, noun: str) -> None:
698
+ allowed_set = set(allowed)
699
+ for index, value in enumerate(values):
700
+ if value not in allowed_set:
701
+ at = _indexed(where, index, single)
702
+ raise PolicyError(f"{at}: unknown {noun} {value!r}; use one of {', '.join(sorted(allowed_set))}")
703
+
704
+
705
+ def _custom_rule(raw: object, index: int, seen: set[str]) -> CustomRule:
706
+ from .lint import ALLOWED_STATUS, ALLOWED_TYPE
707
+
708
+ where = f"rules.custom[{index}]"
709
+ if not isinstance(raw, dict):
710
+ raise PolicyError(f"{where} must be a table ([[rules.custom]])")
711
+ unknown = set(raw) - CUSTOM_KEYS
712
+ if unknown:
713
+ raise PolicyError(f"{where}: unknown keys: {', '.join(sorted(unknown))}")
714
+ code = raw.get("id")
715
+ if code is None:
716
+ raise PolicyError(f"{where}.id is required")
717
+ if not isinstance(code, str) or not CUSTOM_ID_RE.match(code):
718
+ raise PolicyError(f"{where}.id must look like custom.<name> (lowercase letters, digits and _), got {code!r}")
719
+ if code in seen:
720
+ raise PolicyError(f"{where}.id: {code} is defined twice")
721
+ seen.add(code)
722
+ label = f"{where} ({code})"
723
+
724
+ level = raw.get("level", "warning")
725
+ if level not in LEVELS:
726
+ raise PolicyError(f"{where}.level must be error or warning")
727
+ texts: dict[str, str] = {}
728
+ for key, default in (("summary", None), ("why", DEFAULT_WHY), ("fix", DEFAULT_FIX)):
729
+ value = raw.get(key, default)
730
+ if not isinstance(value, str) or not value.strip():
731
+ raise PolicyError(f"{where}.{key} must be a non-empty string")
732
+ if len(value) > MAX_TEXT_LENGTH:
733
+ raise PolicyError(f"{where}.{key} is longer than {MAX_TEXT_LENGTH} characters")
734
+ texts[key] = value.strip()
735
+
736
+ applies_to: dict[str, list[str]] = {}
737
+ raw_scope = raw.get("applies_to", {})
738
+ if not isinstance(raw_scope, dict):
739
+ raise PolicyError(f"{where}.applies_to must be a table")
740
+ unknown = set(raw_scope) - set(APPLIES_TO_KEYS)
741
+ if unknown:
742
+ raise PolicyError(f"{where}.applies_to: unknown keys: {', '.join(sorted(unknown))}")
743
+ for key in APPLIES_TO_KEYS:
744
+ if key not in raw_scope:
745
+ continue
746
+ at = f"{where}.applies_to.{key}"
747
+ values = _strings(raw_scope[key], at)
748
+ single = isinstance(raw_scope[key], str)
749
+ if key == "type":
750
+ _check_vocabulary(values, ALLOWED_TYPE, at, single, "type")
751
+ elif key == "status":
752
+ _check_vocabulary(values, ALLOWED_STATUS, at, single, "status")
753
+ elif key == "paths":
754
+ for position, glob in enumerate(values):
755
+ problem = _glob_problem(glob)
756
+ if problem:
757
+ raise PolicyError(f"{_indexed(at, position, single)} {problem}")
758
+ applies_to[key] = values
759
+
760
+ checks: dict[str, Any] = {}
761
+ if "required_keys" in raw:
762
+ at = f"{where}.required_keys"
763
+ keys = _strings(raw["required_keys"], at, allow_scalar=False)
764
+ for position, key in enumerate(keys):
765
+ if not FRONT_KEY_RE.match(key):
766
+ raise PolicyError(f"{at}[{position}] is not a front-matter key: {key!r}")
767
+ checks["required_keys"] = keys
768
+ if "required_values" in raw:
769
+ at = f"{where}.required_values"
770
+ table = raw["required_values"]
771
+ if not isinstance(table, dict) or not table:
772
+ raise PolicyError(f"{at} must be a non-empty table of key = value(s)")
773
+ values_out: dict[str, list[str]] = {}
774
+ for key, value in table.items():
775
+ if not FRONT_KEY_RE.match(key):
776
+ raise PolicyError(f"{at}: {key!r} is not a front-matter key")
777
+ items = value if isinstance(value, list) else [value]
778
+ texts_out = [_scalar_text(item) for item in items]
779
+ if not items or len(items) > MAX_LIST_ITEMS or any(text is None for text in texts_out):
780
+ raise PolicyError(f"{at}.{key} must be a value or a non-empty list of values (strings, numbers or booleans)")
781
+ values_out[key] = [text for text in texts_out if text is not None]
782
+ checks["required_values"] = values_out
783
+ if "required_sections" in raw:
784
+ at = f"{where}.required_sections"
785
+ sections = _strings(raw["required_sections"], at, allow_scalar=False)
786
+ for position, section in enumerate(sections):
787
+ if _parse_section(section)[1] == "":
788
+ raise PolicyError(f"{at}[{position}] must name a heading, e.g. \"## Context\" or \"Context\"")
789
+ checks["required_sections"] = sections
790
+ compiled: dict[str, tuple[tuple[str, re.Pattern[str]], ...]] = {}
791
+ for key in ("required_patterns", "forbidden_patterns"):
792
+ if key not in raw:
793
+ continue
794
+ at = f"{where}.{key}"
795
+ patterns = _strings(raw[key], at, allow_scalar=False)
796
+ if len(patterns) > MAX_PATTERNS:
797
+ raise PolicyError(f"{at} may hold at most {MAX_PATTERNS} patterns")
798
+ for position, pattern in enumerate(patterns):
799
+ problem = regex_problem(pattern)
800
+ if problem:
801
+ raise PolicyError(f"{at}[{position}] {problem}: /{pattern[:60]}/")
802
+ compiled[key] = tuple((pattern, re.compile(pattern)) for pattern in patterns)
803
+ checks[key] = patterns
804
+ for key, minimum in (("min_evidence", 1), ("max_age_days", 0)):
805
+ if key not in raw:
806
+ continue
807
+ value = raw[key]
808
+ if not isinstance(value, int) or isinstance(value, bool) or value < minimum or value > 100_000:
809
+ raise PolicyError(f"{where}.{key} must be an integer >= {minimum}")
810
+ checks[key] = value
811
+ if not checks:
812
+ raise PolicyError(f"{label} defines no checks; add at least one of {', '.join(CHECK_KEYS)}")
813
+
814
+ return CustomRule(
815
+ code=code,
816
+ level=level,
817
+ summary=texts["summary"],
818
+ why=texts["why"],
819
+ fix=texts["fix"],
820
+ applies_to=applies_to,
821
+ checks=checks,
822
+ path_globs=tuple(compile_glob(glob) for glob in applies_to.get("paths", [])),
823
+ required=compiled.get("required_patterns", ()),
824
+ forbidden=compiled.get("forbidden_patterns", ()),
825
+ )
826
+
827
+
828
+ def _override_entries(code: str, raw: object, custom_codes: set[str]) -> list[Override]:
829
+ where = f"rules.overrides.{_quote(code)}"
830
+ if code in NOT_OVERRIDABLE:
831
+ raise PolicyError(f"{where}: {code} cannot be overridden; fix whykit.toml instead")
832
+ builtin = RULE_BY_CODE.get(code)
833
+ if builtin is None and code not in custom_codes:
834
+ raise PolicyError(f"{where}: unknown rule code; see `whykit rules` for the catalog")
835
+ security = bool(builtin and builtin.security)
836
+ if isinstance(raw, dict):
837
+ tables: list[tuple[str, object]] = [(where, raw)]
838
+ elif isinstance(raw, list) and raw:
839
+ if len(raw) > MAX_OVERRIDE_ENTRIES:
840
+ raise PolicyError(f"{where} may hold at most {MAX_OVERRIDE_ENTRIES} entries")
841
+ tables = [(f"{where}[{index}]", item) for index, item in enumerate(raw)]
842
+ else:
843
+ raise PolicyError(f"{where} must be a table or an array of tables")
844
+ entries: list[Override] = []
845
+ for at, table in tables:
846
+ if not isinstance(table, dict):
847
+ raise PolicyError(f"{at} must be a table")
848
+ unknown = set(table) - OVERRIDE_KEYS
849
+ if unknown:
850
+ raise PolicyError(f"{at}: unknown keys: {', '.join(sorted(unknown))}")
851
+ level = table.get("level")
852
+ if level not in OVERRIDE_LEVELS:
853
+ raise PolicyError(f"{at}.level must be error, warning or off")
854
+ paths: list[str] = []
855
+ if "paths" in table:
856
+ paths = _strings(table["paths"], f"{at}.paths", allow_scalar=False)
857
+ for position, glob in enumerate(paths):
858
+ problem = _glob_problem(glob)
859
+ if problem:
860
+ raise PolicyError(f"{at}.paths[{position}] {problem}")
861
+ reason = table.get("reason")
862
+ if reason is not None:
863
+ if not isinstance(reason, str):
864
+ raise PolicyError(f"{at}.reason must be a string")
865
+ if len(reason) > MAX_TEXT_LENGTH:
866
+ raise PolicyError(f"{at}.reason is longer than {MAX_TEXT_LENGTH} characters")
867
+ reason = reason.strip() or None
868
+ default_level = builtin.default_level if builtin else None
869
+ lowers = level == "off" or (level == "warning" and default_level == "error")
870
+ if security and lowers and not reason:
871
+ raise PolicyError(
872
+ f"{at}.reason is required: {code} is security-relevant, so lowering or switching it off "
873
+ "must say why (the reason is printed with every lint and check run)"
874
+ )
875
+ entries.append(Override(
876
+ rule=code,
877
+ level=level,
878
+ paths=paths,
879
+ reason=reason,
880
+ security=security,
881
+ globs=tuple(compile_glob(glob) for glob in paths),
882
+ ))
883
+ return entries
884
+
885
+
886
+ def build_policy(rules: object) -> RulePolicy:
887
+ """Validate a ``[rules]`` table and compile it. Raises :class:`PolicyError`."""
888
+ if rules is None:
889
+ return EMPTY_POLICY
890
+ if not isinstance(rules, dict):
891
+ raise PolicyError("rules must be a table ([rules])")
892
+ unknown = set(rules) - RULES_KEYS
893
+ if unknown:
894
+ raise PolicyError(f"rules: unknown keys: {', '.join(sorted(unknown))}")
895
+ custom_raw = rules.get("custom", [])
896
+ if not isinstance(custom_raw, list):
897
+ raise PolicyError("rules.custom must be an array of tables ([[rules.custom]])")
898
+ if len(custom_raw) > MAX_CUSTOM_RULES:
899
+ raise PolicyError(f"rules.custom may define at most {MAX_CUSTOM_RULES} rules")
900
+ seen: set[str] = set()
901
+ custom = [_custom_rule(raw, index, seen) for index, raw in enumerate(custom_raw)]
902
+ overrides_raw = rules.get("overrides", {})
903
+ if not isinstance(overrides_raw, dict):
904
+ raise PolicyError('rules.overrides must be a table ([rules.overrides."<code>"])')
905
+ overrides: list[Override] = []
906
+ for code, raw in overrides_raw.items():
907
+ overrides.extend(_override_entries(code, raw, seen))
908
+ return RulePolicy(custom, overrides)
909
+
910
+
911
+ def policy_from_config(config: dict[str, Any]) -> RulePolicy:
912
+ """The compiled policy of an already validated config."""
913
+ return build_policy(config.get("rules"))
914
+
915
+
916
+ # --------------------------------------------------------------------------
917
+ # Checking notes
918
+ # --------------------------------------------------------------------------
919
+
920
+ HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*(?:#+[ \t]*)?$")
921
+
922
+
923
+ def _parse_section(text: str) -> tuple[int | None, str]:
924
+ match = re.match(r"^(#{1,6})[ \t]+(.*)$", text.strip())
925
+ if match:
926
+ level, title = len(match.group(1)), match.group(2)
927
+ else:
928
+ level, title = None, text
929
+ title = re.sub(r"[ \t]+#+$", "", title.strip()).strip()
930
+ return level, title.casefold()
931
+
932
+
933
+ def _front_text(value: object) -> list[str]:
934
+ if isinstance(value, list):
935
+ return [text for text in (_scalar_text(item) for item in value) if text is not None]
936
+ text = _scalar_text(value)
937
+ return [text] if text is not None else []
938
+
939
+
940
+ def _matches_scope(rule: CustomRule, note: Note, path: str) -> bool:
941
+ if not note.has_front or note.front_error:
942
+ return False
943
+ front = note.front
944
+ statuses = rule.applies_to.get("status")
945
+ status = str(front.get("status", ""))
946
+ if statuses is None:
947
+ if status == "template":
948
+ return False
949
+ elif status not in statuses:
950
+ return False
951
+ types = rule.applies_to.get("type")
952
+ if types is not None and str(front.get("type", "")) not in types:
953
+ return False
954
+ streams = rule.applies_to.get("workstream")
955
+ if streams is not None and not set(_front_text(front.get("workstream"))) & set(streams):
956
+ return False
957
+ if rule.path_globs and not any(glob.match(path) for glob in rule.path_globs):
958
+ return False
959
+ return True
960
+
961
+
962
+ def _body_lines(note: Note) -> list[tuple[int, str]]:
963
+ """(1-based file line, masked text) for every body line."""
964
+ lines = note.masked.split("\n")
965
+ start = note.body_offset if note.has_front else 0
966
+ return [(number + 1, lines[number]) for number in range(start, len(lines))]
967
+
968
+
969
+ def check_custom_rules(
970
+ policy: RulePolicy,
971
+ notes: Iterable[Note],
972
+ findings: list[Finding],
973
+ today: dt.date,
974
+ relative: Any,
975
+ ) -> None:
976
+ """Run every custom rule against the notes it applies to.
977
+
978
+ *relative* maps a note path to its vault-relative POSIX path (``lint.rel``
979
+ bound to the root) so findings and path globs use the same spelling.
980
+ """
981
+ if not policy.custom:
982
+ return
983
+ from .lint import Finding, _parse_date
984
+
985
+ deadline = time.monotonic() + PATTERN_BUDGET_SECONDS
986
+ for note in notes:
987
+ path = relative(note.path)
988
+ rules = [rule for rule in policy.custom if _matches_scope(rule, note, path)]
989
+ if not rules:
990
+ continue
991
+ lines: list[tuple[int, str]] | None = None
992
+ headings: list[tuple[int, str]] | None = None
993
+ for rule in rules:
994
+ def report(line: int | None, message: str, rule: CustomRule = rule, path: str = path) -> None:
995
+ findings.append(Finding(path, line, rule.level, rule.code, message))
996
+
997
+ checks = rule.checks
998
+ front = note.front
999
+ for key in checks.get("required_keys", ()):
1000
+ value = front.get(key)
1001
+ if key not in front or value is None or value == "" or value == []:
1002
+ report(1, f"front matter needs {key!r} ({rule.summary})")
1003
+ for key, allowed in checks.get("required_values", {}).items():
1004
+ actual = _front_text(front.get(key))
1005
+ if not actual:
1006
+ report(1, f"front matter needs {key!r} set to {' or '.join(allowed)} ({rule.summary})")
1007
+ elif not set(actual) & set(allowed):
1008
+ report(1, f"{key} is {', '.join(actual)}; this rule allows {' or '.join(allowed)} ({rule.summary})")
1009
+ if "min_evidence" in checks:
1010
+ cited = len(note.cited_evidence)
1011
+ if cited < checks["min_evidence"]:
1012
+ report(1, f"cites {cited} of {checks['min_evidence']} required evidence sources (E-NNN) ({rule.summary})")
1013
+ if "max_age_days" in checks:
1014
+ updated = _parse_date(front.get("last_updated"))
1015
+ if updated is not None:
1016
+ age = (today - updated).days
1017
+ if age > checks["max_age_days"]:
1018
+ report(1, f"last_updated is {age} days old; this rule allows {checks['max_age_days']} ({rule.summary})")
1019
+ needs_body = bool(rule.required or rule.forbidden or "required_sections" in checks)
1020
+ if not needs_body:
1021
+ continue
1022
+ if lines is None:
1023
+ lines = _body_lines(note)
1024
+ if "required_sections" in checks:
1025
+ if headings is None:
1026
+ headings = []
1027
+ for _, text in lines:
1028
+ match = HEADING_RE.match(text)
1029
+ if match:
1030
+ headings.append((len(match.group(1)), match.group(2).strip().casefold()))
1031
+ for section in checks["required_sections"]:
1032
+ level, title = _parse_section(section)
1033
+ if not any(title == found and (level is None or level == depth) for depth, found in headings):
1034
+ report(None, f"missing required section \"{section.strip()}\" ({rule.summary})")
1035
+ if not (rule.required or rule.forbidden):
1036
+ continue
1037
+ if time.monotonic() > deadline:
1038
+ report(None, f"pattern checks did not run: this run's {PATTERN_BUDGET_SECONDS:g} s time budget for custom patterns is spent ({rule.summary})")
1039
+ continue
1040
+ remaining_required = dict(rule.required)
1041
+ forbidden_hits: dict[str, int] = {}
1042
+ stopped = False
1043
+ for number, text in lines:
1044
+ if time.monotonic() > deadline:
1045
+ report(number, f"pattern checks stopped at this line: this run's {PATTERN_BUDGET_SECONDS:g} s time budget for custom patterns is spent ({rule.summary})")
1046
+ stopped = True
1047
+ break
1048
+ if len(text) > MAX_LINE_CHARS:
1049
+ report(number, f"line is longer than {MAX_LINE_CHARS} characters; pattern checks read only its first {MAX_LINE_CHARS} ({rule.summary})")
1050
+ text = text[:MAX_LINE_CHARS]
1051
+ if remaining_required:
1052
+ for source, compiled in list(remaining_required.items()):
1053
+ if compiled.search(text):
1054
+ del remaining_required[source]
1055
+ for source, compiled in rule.forbidden:
1056
+ if source not in forbidden_hits and compiled.search(text):
1057
+ forbidden_hits[source] = number
1058
+ if stopped:
1059
+ remaining_required = {}
1060
+ for source in remaining_required:
1061
+ report(None, f"no line matches required pattern /{source}/ ({rule.summary})")
1062
+ for source, number in forbidden_hits.items():
1063
+ report(number, f"matches forbidden pattern /{source}/ ({rule.summary})")