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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- 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})")
|