sourcecode 3.0.0__py3-none-any.whl → 3.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sourcecode might be problematic. Click here for more details.
- sourcecode/__init__.py +1 -1
- sourcecode/chain_rules.py +289 -0
- sourcecode/cli.py +64 -6
- sourcecode/parse_cache.py +24 -5
- sourcecode/posture.py +373 -31
- sourcecode/repository_ir.py +75 -15
- sourcecode/spring_properties.py +217 -0
- {sourcecode-3.0.0.dist-info → sourcecode-3.1.0.dist-info}/METADATA +1 -1
- {sourcecode-3.0.0.dist-info → sourcecode-3.1.0.dist-info}/RECORD +12 -10
- {sourcecode-3.0.0.dist-info → sourcecode-3.1.0.dist-info}/WHEEL +0 -0
- {sourcecode-3.0.0.dist-info → sourcecode-3.1.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.0.0.dist-info → sourcecode-3.1.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""chain_rules.py — the path rules a security filter chain declares.
|
|
2
|
+
|
|
3
|
+
`posture` could say *which* chain configuration a profile activates, never what
|
|
4
|
+
that chain permits. "Under `prod` the security configuration is active" is a fact
|
|
5
|
+
about a bean; "under `prod`, `POST /admin/**` requires ROLE_ADMIN and `/health` is
|
|
6
|
+
open to anyone" is a fact about the request, and only the second one is what a
|
|
7
|
+
reviewer is asking.
|
|
8
|
+
|
|
9
|
+
This is an EXTRACTOR: it reads Java source for the published `HttpSecurity`
|
|
10
|
+
authorization DSL (`requestMatchers` / `antMatchers` / `mvcMatchers` /
|
|
11
|
+
`anyRequest`, followed by the access method that decides them) and returns the
|
|
12
|
+
rules **in declaration order**, because Spring applies the first one that matches
|
|
13
|
+
and any other order would answer a different question.
|
|
14
|
+
|
|
15
|
+
What it deliberately does not do: evaluate `access(...)` expressions, follow
|
|
16
|
+
matchers built by a helper method, or read matchers passed as constructed
|
|
17
|
+
objects. Those are recorded as rules whose decision is *not evaluated*, so the
|
|
18
|
+
caller reports the endpoint undecided rather than silently open.
|
|
19
|
+
|
|
20
|
+
VAI: only the published Spring Security DSL vocabulary appears. Path patterns and
|
|
21
|
+
role names are DATA — carried as evidence, never branched on.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
#: Published matcher methods that introduce a rule.
|
|
31
|
+
_MATCHER_METHODS = ("requestMatchers", "antMatchers", "mvcMatchers", "regexMatchers")
|
|
32
|
+
_ANY_REQUEST = "anyRequest"
|
|
33
|
+
|
|
34
|
+
#: Published access methods → the decision they express. `access`/`hasIpAddress`
|
|
35
|
+
#: are listed so the rule is SEEN; their verdict is that they were not evaluated.
|
|
36
|
+
_ACCESS_DECISIONS = {
|
|
37
|
+
"permitAll": "permit_all",
|
|
38
|
+
"denyAll": "deny_all",
|
|
39
|
+
"authenticated": "authenticated",
|
|
40
|
+
"fullyAuthenticated": "authenticated",
|
|
41
|
+
"rememberMe": "authenticated",
|
|
42
|
+
"anonymous": "anonymous",
|
|
43
|
+
"hasRole": "role_required",
|
|
44
|
+
"hasAnyRole": "role_required",
|
|
45
|
+
"hasAuthority": "role_required",
|
|
46
|
+
"hasAnyAuthority": "role_required",
|
|
47
|
+
"access": "not_evaluated",
|
|
48
|
+
"hasIpAddress": "not_evaluated",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_QUOTED_RE = re.compile(r'"([^"]*)"')
|
|
52
|
+
_HTTP_METHOD_RE = re.compile(r"\bHttpMethod\s*\.\s*([A-Z]+)\b")
|
|
53
|
+
_MATCHER_CALL_RE = re.compile(
|
|
54
|
+
r"\.\s*(" + "|".join(_MATCHER_METHODS + (_ANY_REQUEST,)) + r")\s*\("
|
|
55
|
+
)
|
|
56
|
+
_ACCESS_CALL_RE = re.compile(
|
|
57
|
+
r"\s*\.\s*(" + "|".join(_ACCESS_DECISIONS) + r")\s*\("
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class AccessRule:
|
|
63
|
+
"""One `matcher → access` pair, positioned in the order Spring applies it."""
|
|
64
|
+
|
|
65
|
+
patterns: "tuple[str, ...]" # empty → anyRequest (matches everything)
|
|
66
|
+
methods: "tuple[str, ...]" # empty → any HTTP method
|
|
67
|
+
decision: str
|
|
68
|
+
authorities: "tuple[str, ...]" = ()
|
|
69
|
+
source_file: str = ""
|
|
70
|
+
line: int = 0
|
|
71
|
+
order: int = 0
|
|
72
|
+
matcher: str = "" # the DSL method that declared it
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_any_request(self) -> bool:
|
|
76
|
+
"""`anyRequest()` — the catch-all, and the only patternless rule that
|
|
77
|
+
Spring resolves. A matcher call with no readable pattern is a different
|
|
78
|
+
thing entirely: see `paths_unknown`."""
|
|
79
|
+
return self.matcher == _ANY_REQUEST
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def paths_unknown(self) -> bool:
|
|
83
|
+
"""A matcher whose paths this parser could not read. It may cover any
|
|
84
|
+
request, so nothing after it can be concluded from the rules alone."""
|
|
85
|
+
return self.matcher != _ANY_REQUEST and not self.patterns
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
out: dict = {
|
|
89
|
+
"matcher": self.matcher,
|
|
90
|
+
"patterns": (
|
|
91
|
+
["**"] if self.is_any_request
|
|
92
|
+
else (list(self.patterns) or ["(paths this parser could not read)"])
|
|
93
|
+
),
|
|
94
|
+
"decision": self.decision,
|
|
95
|
+
"source_file": self.source_file,
|
|
96
|
+
"line": self.line,
|
|
97
|
+
}
|
|
98
|
+
if self.methods:
|
|
99
|
+
out["methods"] = list(self.methods)
|
|
100
|
+
if self.authorities:
|
|
101
|
+
out["authorities"] = list(self.authorities)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _balanced(text: str, open_index: int) -> "tuple[str, int]":
|
|
106
|
+
"""(body, index after ')') for the parenthesis opening at `open_index`.
|
|
107
|
+
|
|
108
|
+
String literals are respected so a `)` inside a pattern does not close the
|
|
109
|
+
call early. Returns ("", -1) when the call is not balanced in this file.
|
|
110
|
+
"""
|
|
111
|
+
depth = 0
|
|
112
|
+
i = open_index
|
|
113
|
+
in_string = False
|
|
114
|
+
escaped = False
|
|
115
|
+
while i < len(text):
|
|
116
|
+
char = text[i]
|
|
117
|
+
if in_string:
|
|
118
|
+
if escaped:
|
|
119
|
+
escaped = False
|
|
120
|
+
elif char == "\\":
|
|
121
|
+
escaped = True
|
|
122
|
+
elif char == '"':
|
|
123
|
+
in_string = False
|
|
124
|
+
elif char == '"':
|
|
125
|
+
in_string = True
|
|
126
|
+
elif char == "(":
|
|
127
|
+
depth += 1
|
|
128
|
+
elif char == ")":
|
|
129
|
+
depth -= 1
|
|
130
|
+
if depth == 0:
|
|
131
|
+
return text[open_index + 1:i], i + 1
|
|
132
|
+
i += 1
|
|
133
|
+
return "", -1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def rules_from_source(source: str, source_file: str = "") -> "list[AccessRule]":
|
|
137
|
+
"""Every authorization rule in one file, in declaration order."""
|
|
138
|
+
rules: list[AccessRule] = []
|
|
139
|
+
position = 0
|
|
140
|
+
while True:
|
|
141
|
+
match = _MATCHER_CALL_RE.search(source, position)
|
|
142
|
+
if match is None:
|
|
143
|
+
break
|
|
144
|
+
matcher = match.group(1)
|
|
145
|
+
body, after = _balanced(source, match.end() - 1)
|
|
146
|
+
if after < 0:
|
|
147
|
+
break
|
|
148
|
+
access = _ACCESS_CALL_RE.match(source, after)
|
|
149
|
+
if access is None:
|
|
150
|
+
# A matcher whose access method is not adjacent (chained through a
|
|
151
|
+
# variable, or a form this parser does not read). Recorded so the
|
|
152
|
+
# caller knows the path is governed by something it could not read.
|
|
153
|
+
position = after
|
|
154
|
+
patterns = tuple(p for p in _QUOTED_RE.findall(body) if p)
|
|
155
|
+
if matcher != _ANY_REQUEST and not patterns:
|
|
156
|
+
continue
|
|
157
|
+
rules.append(AccessRule(
|
|
158
|
+
patterns=() if matcher == _ANY_REQUEST else patterns,
|
|
159
|
+
methods=tuple(sorted(set(_HTTP_METHOD_RE.findall(body)))),
|
|
160
|
+
decision="not_evaluated",
|
|
161
|
+
source_file=source_file,
|
|
162
|
+
line=source.count("\n", 0, match.start()) + 1,
|
|
163
|
+
order=len(rules),
|
|
164
|
+
matcher=matcher,
|
|
165
|
+
))
|
|
166
|
+
continue
|
|
167
|
+
method_name = access.group(1)
|
|
168
|
+
access_body, access_end = _balanced(source, access.end() - 1)
|
|
169
|
+
position = access_end if access_end > 0 else after
|
|
170
|
+
patterns = tuple(p for p in _QUOTED_RE.findall(body) if p)
|
|
171
|
+
if matcher != _ANY_REQUEST and not patterns:
|
|
172
|
+
# `requestMatchers(new AntPathRequestMatcher(...))` and friends: the
|
|
173
|
+
# rule exists, its paths are not literals in this call.
|
|
174
|
+
rules.append(AccessRule(
|
|
175
|
+
patterns=(), methods=(), decision="not_evaluated",
|
|
176
|
+
source_file=source_file,
|
|
177
|
+
line=source.count("\n", 0, match.start()) + 1,
|
|
178
|
+
order=len(rules), matcher=matcher,
|
|
179
|
+
))
|
|
180
|
+
continue
|
|
181
|
+
rules.append(AccessRule(
|
|
182
|
+
patterns=() if matcher == _ANY_REQUEST else patterns,
|
|
183
|
+
methods=tuple(sorted(set(_HTTP_METHOD_RE.findall(body)))),
|
|
184
|
+
decision=_ACCESS_DECISIONS[method_name],
|
|
185
|
+
authorities=tuple(_QUOTED_RE.findall(access_body)),
|
|
186
|
+
source_file=source_file,
|
|
187
|
+
line=source.count("\n", 0, match.start()) + 1,
|
|
188
|
+
order=len(rules),
|
|
189
|
+
matcher=matcher,
|
|
190
|
+
))
|
|
191
|
+
return rules
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def extract_chain_rules(
|
|
195
|
+
root: Path, java_files: "Optional[list[str]]" = None
|
|
196
|
+
) -> "dict[str, list[AccessRule]]":
|
|
197
|
+
"""`{repo-relative file: rules in declaration order}`. Never raises."""
|
|
198
|
+
root = Path(root)
|
|
199
|
+
if java_files is None:
|
|
200
|
+
try:
|
|
201
|
+
from sourcecode.repository_ir import find_java_files
|
|
202
|
+
|
|
203
|
+
java_files = find_java_files(root)
|
|
204
|
+
except Exception:
|
|
205
|
+
java_files = []
|
|
206
|
+
out: dict[str, list[AccessRule]] = {}
|
|
207
|
+
for rel in java_files or []:
|
|
208
|
+
try:
|
|
209
|
+
source = (root / rel).read_text(encoding="utf-8", errors="replace")
|
|
210
|
+
except OSError:
|
|
211
|
+
continue
|
|
212
|
+
# Cheap pre-check: the DSL always names one of these methods.
|
|
213
|
+
if not any(name in source for name in _MATCHER_METHODS + (_ANY_REQUEST,)):
|
|
214
|
+
continue
|
|
215
|
+
rules = rules_from_source(source, rel)
|
|
216
|
+
if rules:
|
|
217
|
+
out[rel] = rules
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def ant_matches(pattern: str, path: str) -> bool:
|
|
222
|
+
"""Spring Ant path semantics: `?` one char, `*` one segment, `**` many.
|
|
223
|
+
|
|
224
|
+
A trailing `/**` also matches the base path itself, as Spring's matcher does
|
|
225
|
+
(`/admin/**` covers `/admin`).
|
|
226
|
+
"""
|
|
227
|
+
if not pattern or not path:
|
|
228
|
+
return False
|
|
229
|
+
if pattern == path:
|
|
230
|
+
return True
|
|
231
|
+
regex = _ant_regex(pattern)
|
|
232
|
+
if regex.match(path):
|
|
233
|
+
return True
|
|
234
|
+
if pattern.endswith("/**"):
|
|
235
|
+
return _ant_regex(pattern[:-3] or "/").match(path) is not None
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_ANT_CACHE: "dict[str, re.Pattern]" = {}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ant_regex(pattern: str) -> "re.Pattern":
|
|
243
|
+
cached = _ANT_CACHE.get(pattern)
|
|
244
|
+
if cached is not None:
|
|
245
|
+
return cached
|
|
246
|
+
out: list[str] = ["^"]
|
|
247
|
+
i = 0
|
|
248
|
+
while i < len(pattern):
|
|
249
|
+
char = pattern[i]
|
|
250
|
+
if pattern.startswith("**", i):
|
|
251
|
+
out.append(".*")
|
|
252
|
+
i += 2
|
|
253
|
+
elif char == "*":
|
|
254
|
+
out.append("[^/]*")
|
|
255
|
+
i += 1
|
|
256
|
+
elif char == "?":
|
|
257
|
+
out.append("[^/]")
|
|
258
|
+
i += 1
|
|
259
|
+
else:
|
|
260
|
+
out.append(re.escape(char))
|
|
261
|
+
i += 1
|
|
262
|
+
out.append("$")
|
|
263
|
+
compiled = re.compile("".join(out))
|
|
264
|
+
_ANT_CACHE[pattern] = compiled
|
|
265
|
+
return compiled
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def rule_matches(rule: AccessRule, method: str, path: str) -> bool:
|
|
269
|
+
"""Does this rule govern `method path`? Method-less rules cover every method."""
|
|
270
|
+
if rule.methods and method and method.upper() not in rule.methods:
|
|
271
|
+
return False
|
|
272
|
+
if rule.is_any_request:
|
|
273
|
+
return True
|
|
274
|
+
return any(ant_matches(pattern, path) for pattern in rule.patterns)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def first_matching_rule(
|
|
278
|
+
rules: "list[AccessRule]", method: str, path: str
|
|
279
|
+
) -> "Optional[AccessRule]":
|
|
280
|
+
"""The rule Spring would apply: the first declared one that matches."""
|
|
281
|
+
for rule in rules:
|
|
282
|
+
if rule.paths_unknown:
|
|
283
|
+
# A rule whose paths could not be read may cover this request, and
|
|
284
|
+
# everything after it is only reachable if it does not. Stopping here
|
|
285
|
+
# is what keeps a later `permitAll` from being reported as the answer.
|
|
286
|
+
return rule
|
|
287
|
+
if rule_matches(rule, method, path):
|
|
288
|
+
return rule
|
|
289
|
+
return None
|
sourcecode/cli.py
CHANGED
|
@@ -6112,6 +6112,13 @@ def posture_cmd(
|
|
|
6112
6112
|
"--diff",
|
|
6113
6113
|
help="Compare two profile sets: --diff dev:prod (each side comma-separated).",
|
|
6114
6114
|
),
|
|
6115
|
+
property_overrides: Optional[list[str]] = typer.Option(
|
|
6116
|
+
None,
|
|
6117
|
+
"--property",
|
|
6118
|
+
"-D",
|
|
6119
|
+
help="State a deployment property: --property jobrunr.dashboard.enabled=true "
|
|
6120
|
+
"(repeatable; outranks the repository's own configuration).",
|
|
6121
|
+
),
|
|
6115
6122
|
output_path: Optional[Path] = typer.Option(
|
|
6116
6123
|
None, "--output", "-o", help="Write the report to a file instead of stdout."
|
|
6117
6124
|
),
|
|
@@ -6125,10 +6132,11 @@ def posture_cmd(
|
|
|
6125
6132
|
which are not, and which conditions this analyzer could not evaluate.
|
|
6126
6133
|
|
|
6127
6134
|
\b
|
|
6128
|
-
EXPERIMENTAL: `@Profile` is resolved completely
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6135
|
+
EXPERIMENTAL: `@Profile` is resolved completely, and `@ConditionalOnProperty`
|
|
6136
|
+
is resolved against the properties this repository's configuration sets under
|
|
6137
|
+
the same profile set (state the rest with --property). Every other condition
|
|
6138
|
+
(the remaining `@Conditional*` family) is named and reported unresolved,
|
|
6139
|
+
never decided.
|
|
6132
6140
|
|
|
6133
6141
|
\b
|
|
6134
6142
|
Unresolved is a first-class outcome. A bean whose registration depends on a
|
|
@@ -6140,12 +6148,34 @@ def posture_cmd(
|
|
|
6140
6148
|
ask posture . --profile prod
|
|
6141
6149
|
ask posture . --profile prod,metrics
|
|
6142
6150
|
ask posture . --diff dev:prod
|
|
6151
|
+
ask posture . --profile prod --property app.security.enabled=true
|
|
6143
6152
|
"""
|
|
6144
6153
|
from sourcecode.posture import build_posture, diff_posture
|
|
6145
6154
|
|
|
6146
6155
|
def _parse_set(raw: str) -> set:
|
|
6147
6156
|
return {p.strip() for p in raw.split(",") if p.strip()}
|
|
6148
6157
|
|
|
6158
|
+
overrides: dict = {}
|
|
6159
|
+
for item in property_overrides or []:
|
|
6160
|
+
if "=" not in item:
|
|
6161
|
+
_emit_error_json(
|
|
6162
|
+
INVALID_INPUT_CODE,
|
|
6163
|
+
f"--property needs key=value (got {item!r}).",
|
|
6164
|
+
hint="Example: --property jobrunr.dashboard.enabled=true",
|
|
6165
|
+
expected="key=value",
|
|
6166
|
+
)
|
|
6167
|
+
raise typer.Exit(code=1)
|
|
6168
|
+
key, _, value = item.partition("=")
|
|
6169
|
+
if not key.strip():
|
|
6170
|
+
_emit_error_json(
|
|
6171
|
+
INVALID_INPUT_CODE,
|
|
6172
|
+
f"--property needs a key before '=' (got {item!r}).",
|
|
6173
|
+
hint="Example: --property jobrunr.dashboard.enabled=true",
|
|
6174
|
+
expected="key=value",
|
|
6175
|
+
)
|
|
6176
|
+
raise typer.Exit(code=1)
|
|
6177
|
+
overrides[key.strip()] = value.strip()
|
|
6178
|
+
|
|
6149
6179
|
if diff:
|
|
6150
6180
|
if ":" not in diff:
|
|
6151
6181
|
_emit_error_json(
|
|
@@ -6156,9 +6186,9 @@ def posture_cmd(
|
|
|
6156
6186
|
)
|
|
6157
6187
|
raise typer.Exit(code=1)
|
|
6158
6188
|
left_raw, right_raw = diff.split(":", 1)
|
|
6159
|
-
data = diff_posture(path, _parse_set(left_raw), _parse_set(right_raw))
|
|
6189
|
+
data = diff_posture(path, _parse_set(left_raw), _parse_set(right_raw), overrides)
|
|
6160
6190
|
else:
|
|
6161
|
-
data = build_posture(path, _parse_set(profile) if profile else set())
|
|
6191
|
+
data = build_posture(path, _parse_set(profile) if profile else set(), overrides)
|
|
6162
6192
|
|
|
6163
6193
|
_emit_command_output(
|
|
6164
6194
|
_serialize_dict(data, format),
|
|
@@ -6231,6 +6261,10 @@ def migrate_check_cmd(
|
|
|
6231
6261
|
None, "--ref",
|
|
6232
6262
|
help="Label for a --snapshot capture (e.g. a version or sprint tag).",
|
|
6233
6263
|
),
|
|
6264
|
+
force: bool = typer.Option(
|
|
6265
|
+
False, "--force",
|
|
6266
|
+
help="Emit to stdout even when the report exceeds the output-size guard.",
|
|
6267
|
+
),
|
|
6234
6268
|
) -> None:
|
|
6235
6269
|
"""Spring Boot 2→3 migration readiness: detect javax→jakarta namespace blockers.
|
|
6236
6270
|
|
|
@@ -6349,6 +6383,30 @@ def migrate_check_cmd(
|
|
|
6349
6383
|
_snap_path = write_snapshot(_snap, _history_dir)
|
|
6350
6384
|
_snapshot_note = f"; snapshot → {_snap_path}"
|
|
6351
6385
|
|
|
6386
|
+
# Output-size guard, same contract as repo-ir. A default migrate-check run on
|
|
6387
|
+
# a large repository emits ~900KB (~227K tokens) — larger than any context
|
|
6388
|
+
# window it is meant to be pasted into, and the caller only learns that after
|
|
6389
|
+
# the wait. Writing to a file is exempt: the size is the point of a file.
|
|
6390
|
+
_mig_tokens_est = len(output.encode("utf-8")) // 4
|
|
6391
|
+
if output_path is None and not force and _mig_tokens_est > 50_000:
|
|
6392
|
+
_emit_error_json(
|
|
6393
|
+
"OUTPUT_TOO_LARGE",
|
|
6394
|
+
f"Estimated output is ~{_mig_tokens_est // 1000}K tokens — too large for "
|
|
6395
|
+
"most LLM context windows.",
|
|
6396
|
+
hint=(
|
|
6397
|
+
"Use --compact (bounded decision summary, same report), "
|
|
6398
|
+
"--min-severity high to drop low-severity findings, "
|
|
6399
|
+
"--output FILE to save to disk, or --force to bypass this guard."
|
|
6400
|
+
),
|
|
6401
|
+
expected="Output under 50K estimated tokens.",
|
|
6402
|
+
)
|
|
6403
|
+
raise typer.Exit(1)
|
|
6404
|
+
if output_path is None and _mig_tokens_est > 10_000:
|
|
6405
|
+
sys.stderr.write(
|
|
6406
|
+
f"[migrate-check] ~{_mig_tokens_est // 1000}K tokens — "
|
|
6407
|
+
"use --compact or --output FILE for smaller output.\n"
|
|
6408
|
+
)
|
|
6409
|
+
|
|
6352
6410
|
_total = report.summary.get("total_findings", 0)
|
|
6353
6411
|
_emit_command_output(
|
|
6354
6412
|
output, output_path, copy,
|
sourcecode/parse_cache.py
CHANGED
|
@@ -9,9 +9,12 @@ This cache stores the output of ``_extract_symbols`` — ``(package, imports, sy
|
|
|
9
9
|
keyed by a content hash of ``(rel_path, capture_signature, source)``. Properties that
|
|
10
10
|
make it safe:
|
|
11
11
|
|
|
12
|
-
* **Content-addressed ⇒ never stale.** The key *is* the content
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
* **Content-addressed ⇒ never stale.** The key *is* the content — the file's bytes AND
|
|
13
|
+
the analyzer's own source (``analyzer_fingerprint``). A changed file or a changed
|
|
14
|
+
extractor yields a different key and misses (re-parses); it never serves an old
|
|
15
|
+
parse. There is no invalidation to get wrong — unlike a signature-keyed cache.
|
|
16
|
+
(The analyzer half was missing until a field test caught a shipped extractor fix
|
|
17
|
+
reading as inert: the parse was right in process and stale on disk.)
|
|
15
18
|
* **Correctness never depends on it.** A miss, a corrupt entry, or an unwritable dir all
|
|
16
19
|
degrade to "parse it" — the CIR is byte-identical whether the cache hit or missed
|
|
17
20
|
(proven by the cir_hash-equivalence test, not assumed).
|
|
@@ -35,7 +38,10 @@ from sourcecode.context_cache import _atomic_write, _base_dir
|
|
|
35
38
|
if TYPE_CHECKING:
|
|
36
39
|
from sourcecode.repository_ir import SymbolRecord
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
#: The key carries `analyzer_fingerprint()`, so an extractor change invalidates
|
|
42
|
+
#: entries by itself — this constant only needs a bump for something the source
|
|
43
|
+
#: hash cannot see (a changed dependency, a corrected on-disk format).
|
|
44
|
+
_CACHE_SUBDIR = "parse-cache-v1"
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
def is_enabled() -> bool:
|
|
@@ -50,12 +56,25 @@ def _cache_root() -> Path:
|
|
|
50
56
|
|
|
51
57
|
def file_key(rel_path: str, source: str, capture_sig: str) -> str:
|
|
52
58
|
"""Content hash of everything ``_extract_symbols`` depends on. Two identical inputs
|
|
53
|
-
⇒ same key (reuse); any change ⇒ different key (miss, re-parse).
|
|
59
|
+
⇒ same key (reuse); any change ⇒ different key (miss, re-parse).
|
|
60
|
+
|
|
61
|
+
"Everything" includes the EXTRACTOR. Keying on the file's bytes alone made this
|
|
62
|
+
cache content-addressed with respect to the input and blind to the code reading
|
|
63
|
+
it: after a fix to symbol extraction, an updated install kept serving pre-fix
|
|
64
|
+
parses for every unchanged file, and the fix looked inert until the cache was
|
|
65
|
+
cleared by hand. That is the same failure the whole-CIR cache was fixed for; the
|
|
66
|
+
``_CACHE_SUBDIR`` comment asking a human to bump a constant is exactly the
|
|
67
|
+
discipline that fails. The analyzer fingerprint makes it structural instead.
|
|
68
|
+
"""
|
|
69
|
+
from sourcecode.cache import analyzer_fingerprint
|
|
70
|
+
|
|
54
71
|
h = hashlib.sha256()
|
|
55
72
|
h.update(rel_path.encode("utf-8", "replace"))
|
|
56
73
|
h.update(b"\x00")
|
|
57
74
|
h.update(capture_sig.encode("utf-8", "replace"))
|
|
58
75
|
h.update(b"\x00")
|
|
76
|
+
h.update(analyzer_fingerprint().encode("utf-8", "replace"))
|
|
77
|
+
h.update(b"\x00")
|
|
59
78
|
h.update(source.encode("utf-8", "replace"))
|
|
60
79
|
return h.hexdigest()[:32]
|
|
61
80
|
|