sourcecode 3.2.0__py3-none-any.whl → 3.2.2__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.
- sourcecode/__init__.py +1 -1
- sourcecode/cache.py +104 -0
- sourcecode/cache_model.py +295 -0
- sourcecode/chain_rules.py +49 -8
- sourcecode/cli.py +419 -205
- sourcecode/confidence_analyzer.py +10 -6
- sourcecode/context_cache.py +7 -27
- sourcecode/deployment_prefix.py +85 -0
- sourcecode/facts/__init__.py +71 -0
- sourcecode/facts/registry.json +158 -0
- sourcecode/license.py +1 -1
- sourcecode/mcp/registry.py +1 -1
- sourcecode/mcp/server.py +3 -3
- sourcecode/metrics_analyzer.py +8 -5
- sourcecode/posture.py +78 -6
- sourcecode/prepare_context.py +51 -26
- sourcecode/reference_facts.py +307 -0
- sourcecode/ris.py +27 -1
- sourcecode/serializer.py +36 -9
- sourcecode/spring_profiles.py +93 -13
- sourcecode/telemetry/__init__.py +4 -3
- sourcecode/telemetry/config.py +31 -23
- sourcecode/telemetry/consent.py +20 -16
- sourcecode/test_sources.py +178 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/METADATA +128 -14
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/RECORD +29 -24
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/WHEEL +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cache.py
CHANGED
|
@@ -178,6 +178,110 @@ def _get_git_head(repo_root: Path) -> str:
|
|
|
178
178
|
return ""
|
|
179
179
|
|
|
180
180
|
|
|
181
|
+
#: Directories never read as source, so their contents cannot change an answer.
|
|
182
|
+
#: Used only by the non-git fingerprint below (git ignores them by .gitignore).
|
|
183
|
+
_SIG_SKIP_DIRS: frozenset[str] = frozenset({
|
|
184
|
+
".git", ".hg", ".svn", "target", "build", "out", "dist", "bin",
|
|
185
|
+
"node_modules", ".gradle", ".idea", ".vscode", "__pycache__", ".venv",
|
|
186
|
+
"venv", ".sourcecode-cache", ".mypy_cache", ".pytest_cache",
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _untracked_tree_fingerprint(target: Path) -> str:
|
|
191
|
+
"""Fingerprint of a tree git cannot describe (no repository, or no commit).
|
|
192
|
+
|
|
193
|
+
Hashes ``(relative path, size, mtime_ns)`` of every non-ignored file. It is a
|
|
194
|
+
fingerprint of the tree, not of a commit — which is the point: without it the
|
|
195
|
+
key for a non-git tree is constant, and the cache answers forever with the
|
|
196
|
+
first analysis it ever ran.
|
|
197
|
+
"""
|
|
198
|
+
h = hashlib.sha256()
|
|
199
|
+
try:
|
|
200
|
+
root = Path(target).resolve()
|
|
201
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
202
|
+
dirnames[:] = sorted(d for d in dirnames if d not in _SIG_SKIP_DIRS)
|
|
203
|
+
for name in sorted(filenames):
|
|
204
|
+
p = Path(dirpath) / name
|
|
205
|
+
try:
|
|
206
|
+
st = p.stat()
|
|
207
|
+
except OSError:
|
|
208
|
+
continue
|
|
209
|
+
h.update(str(p.relative_to(root)).encode("utf-8", "replace"))
|
|
210
|
+
h.update(f"\x00{st.st_size}\x00{st.st_mtime_ns}\x00".encode())
|
|
211
|
+
except Exception:
|
|
212
|
+
return ""
|
|
213
|
+
return f"ff{h.hexdigest()[:14]}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def worktree_signature(repo_root: Path, scope: Optional[Path] = None) -> str:
|
|
217
|
+
"""Hex fingerprint of the **exact tree state an analysis would read**.
|
|
218
|
+
|
|
219
|
+
This is the single authority for cache freshness (ADR-0008): every layer that
|
|
220
|
+
stores an answer about the tree keys on this, so one rule describes them all —
|
|
221
|
+
*any change to the analysed files invalidates, committed or not*. Keying on the
|
|
222
|
+
committed HEAD alone made the root command serve an answer for a tree it had
|
|
223
|
+
not read: a dependency added to an uncommitted ``pom.xml`` stayed invisible
|
|
224
|
+
while the payload reported ``is_stale: false``.
|
|
225
|
+
|
|
226
|
+
Clean tree → the HEAD sha, so repeated runs hit. Dirty tree → HEAD plus a hash
|
|
227
|
+
of the porcelain status, the diff against HEAD, and the size/mtime of untracked
|
|
228
|
+
files (whose bytes the diff does not carry). *scope* restricts the status and
|
|
229
|
+
the diff to a subdirectory, so an edit in a sibling module does not invalidate
|
|
230
|
+
the module actually analysed.
|
|
231
|
+
|
|
232
|
+
Returns hex only — cache file names embed it and the GC parses them. Empty
|
|
233
|
+
string when the tree cannot be described at all; callers then skip the cache.
|
|
234
|
+
"""
|
|
235
|
+
root = Path(repo_root)
|
|
236
|
+
head = _get_git_head(root)
|
|
237
|
+
if not head:
|
|
238
|
+
return _untracked_tree_fingerprint(scope if scope is not None else root)
|
|
239
|
+
|
|
240
|
+
pathspec: list[str] = []
|
|
241
|
+
try:
|
|
242
|
+
if scope is not None and Path(scope).resolve() != root.resolve():
|
|
243
|
+
pathspec = ["--", str(Path(scope).resolve())]
|
|
244
|
+
except Exception:
|
|
245
|
+
pathspec = []
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
st = subprocess.run(
|
|
249
|
+
["git", "-C", str(root), "status", "--porcelain", *pathspec],
|
|
250
|
+
capture_output=True, text=True, timeout=10,
|
|
251
|
+
)
|
|
252
|
+
porcelain = st.stdout if st.returncode == 0 else ""
|
|
253
|
+
except Exception:
|
|
254
|
+
porcelain = ""
|
|
255
|
+
if not porcelain.strip():
|
|
256
|
+
return head # clean — HEAD fully describes the tree
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
df = subprocess.run(
|
|
260
|
+
["git", "-C", str(root), "diff", "HEAD", *pathspec],
|
|
261
|
+
capture_output=True, text=True, timeout=20,
|
|
262
|
+
)
|
|
263
|
+
diff_txt = df.stdout if df.returncode == 0 else ""
|
|
264
|
+
except Exception:
|
|
265
|
+
diff_txt = ""
|
|
266
|
+
|
|
267
|
+
h = hashlib.sha256()
|
|
268
|
+
h.update(porcelain.encode("utf-8", "replace"))
|
|
269
|
+
h.update(b"\x00")
|
|
270
|
+
h.update(diff_txt.encode("utf-8", "replace"))
|
|
271
|
+
# Untracked files appear in the porcelain by name only; their bytes are in no
|
|
272
|
+
# diff, so an edit to one would be invisible without their stat.
|
|
273
|
+
for line in porcelain.splitlines():
|
|
274
|
+
if not line.startswith("?? "):
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
p = root / line[3:].strip().strip('"')
|
|
278
|
+
st_u = p.stat()
|
|
279
|
+
h.update(f"\x00{line[3:]}\x00{st_u.st_size}\x00{st_u.st_mtime_ns}".encode("utf-8", "replace"))
|
|
280
|
+
except OSError:
|
|
281
|
+
continue
|
|
282
|
+
return f"{head}d{h.hexdigest()[:12]}"
|
|
283
|
+
|
|
284
|
+
|
|
181
285
|
# ---------------------------------------------------------------------------
|
|
182
286
|
# Public API — location helpers
|
|
183
287
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""What each cache layer stores, what invalidates it, and which commands a warm helps.
|
|
2
|
+
|
|
3
|
+
Field evaluation #3 could not predict the cache: `cache warm` took 103 s and made
|
|
4
|
+
`--compact` return in 1 s, then `--agent --full --env-map --depth 20` missed anyway
|
|
5
|
+
and took 171 s, and most of `prepare-context` was unaffected. The verdict was
|
|
6
|
+
*"el warm no es un warm general — el modelo de invalidación no está documentado y
|
|
7
|
+
no es predecible"* (defect C4-3), and the cold cost was nowhere a user meets it
|
|
8
|
+
(C4-5).
|
|
9
|
+
|
|
10
|
+
This module is the answer, and it is the only place the answer lives: `ask cache
|
|
11
|
+
model` renders it, the user guide embeds the same rendering, and the battery fails
|
|
12
|
+
if a registered command is missing from it or if the guide has drifted from it. A
|
|
13
|
+
curated list beside a generated one is how `endpoints` went missing from `--help`
|
|
14
|
+
(C4-1); the same mistake is not repeated here.
|
|
15
|
+
|
|
16
|
+
The rows are **measured, not asserted**. Which layers a command reads was derived by
|
|
17
|
+
wrapping the entry points of each layer and running every command; what a warm is
|
|
18
|
+
worth was then timed per command **in isolation** — caches cleared, `cache warm`,
|
|
19
|
+
then the command. Measuring them in sequence instead reports a benefit the user
|
|
20
|
+
will not get, because one command warms the next: run in a batch, `impact-chain`
|
|
21
|
+
looked unaffected by a warm; run in isolation it goes from 9.9 s to 1.7 s.
|
|
22
|
+
|
|
23
|
+
Two results are worth stating because they contradict the natural assumption: a
|
|
24
|
+
warm buys `endpoints` and `migrate-check` **nothing**, and on a small diff it makes
|
|
25
|
+
`review-pr` slower than no cache at all.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Layer:
|
|
34
|
+
"""One cache layer: what it holds, where, and what makes it invalid."""
|
|
35
|
+
|
|
36
|
+
id: str
|
|
37
|
+
stores: str
|
|
38
|
+
location: str
|
|
39
|
+
invalidated_by: str
|
|
40
|
+
warmed: str # what `ask cache warm` does to this layer
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class CommandCache:
|
|
45
|
+
"""What caching does for one command."""
|
|
46
|
+
|
|
47
|
+
command: str
|
|
48
|
+
reads: tuple[str, ...]
|
|
49
|
+
warm: str # "answer" | "shared" | "none"
|
|
50
|
+
repeat: bool # does a second identical run serve a stored answer?
|
|
51
|
+
note: str
|
|
52
|
+
#: Wall time on the reference repository, or "" where it was not timed.
|
|
53
|
+
#: Every figure below was taken in isolation — caches cleared, then `cache warm`,
|
|
54
|
+
#: then the command — because measuring them in sequence lets one command warm the
|
|
55
|
+
#: next and reports a benefit the user will not get.
|
|
56
|
+
measured: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: Where every `measured` figure comes from.
|
|
60
|
+
REFERENCE_REPOSITORY = (
|
|
61
|
+
"BroadleafCommerce (2 000+ Java files, dirty tree), warm machine, 3.2.2"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
#: Every layer keys on `cache.worktree_signature` — the exact tree state (C1-9).
|
|
66
|
+
#: One sentence covers all of them: **any change to the analysed files invalidates,
|
|
67
|
+
#: committed or not.** The rows below say what *else* invalidates each layer.
|
|
68
|
+
LAYERS: tuple[Layer, ...] = (
|
|
69
|
+
Layer(
|
|
70
|
+
id="snapshot",
|
|
71
|
+
stores="the rendered answer of the root command (L1 core analysis + L2 view)",
|
|
72
|
+
location="~/.sourcecode/cache/<repo>/core-*.json.gz, view-*.json.gz",
|
|
73
|
+
invalidated_by=(
|
|
74
|
+
"tree state · analyzer fingerprint · analysis flags "
|
|
75
|
+
"(--env-map, --depth, --docs, --exclude, …) for the core; "
|
|
76
|
+
"presentation flags (--compact, --agent, --full, --format, …) for the view"
|
|
77
|
+
),
|
|
78
|
+
warmed="built for the compact view (`--agent` also builds the agent view)",
|
|
79
|
+
),
|
|
80
|
+
Layer(
|
|
81
|
+
id="ris",
|
|
82
|
+
stores="the Repository Intelligence Snapshot — structural index, endpoint index, summaries",
|
|
83
|
+
location="~/.sourcecode/cache/<repo>/ris.json.gz",
|
|
84
|
+
invalidated_by="tree state (the snapshot records the tree it describes)",
|
|
85
|
+
warmed="rebuilt on every warm",
|
|
86
|
+
),
|
|
87
|
+
Layer(
|
|
88
|
+
id="cir",
|
|
89
|
+
stores="the shared Canonical IR — the Java parse every knowledge command reuses",
|
|
90
|
+
location="~/.sourcecode/ctx-*.json.gz (context cache)",
|
|
91
|
+
invalidated_by="tree state · analyzer fingerprint · schema version",
|
|
92
|
+
warmed="built",
|
|
93
|
+
),
|
|
94
|
+
Layer(
|
|
95
|
+
id="task",
|
|
96
|
+
stores="one `prepare-context` task answer, per task and per option set",
|
|
97
|
+
location="~/.sourcecode/cache/<repo>/snapshot-pctx-*.json.gz",
|
|
98
|
+
invalidated_by="tree state · task · --symptom / --all / --include-config / --format",
|
|
99
|
+
warmed="never — a warm does not run any task",
|
|
100
|
+
),
|
|
101
|
+
Layer(
|
|
102
|
+
id="parse",
|
|
103
|
+
stores="the symbol extraction of one file, keyed by its bytes",
|
|
104
|
+
location="~/.sourcecode/parse-cache-v1/",
|
|
105
|
+
invalidated_by=(
|
|
106
|
+
"the file's own bytes and the extractor's source — content-addressed, "
|
|
107
|
+
"so it is never stale and never needs invalidating"
|
|
108
|
+
),
|
|
109
|
+
warmed="filled for every Java file the warm parses",
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
#: What a warm does for each command.
|
|
114
|
+
#:
|
|
115
|
+
#: ``answer`` — a warm stores the answer this command returns; it comes back in
|
|
116
|
+
#: about a second.
|
|
117
|
+
#: ``shared`` — a warm removes the expensive shared work (the Java parse, the CIR);
|
|
118
|
+
#: the command still computes its own answer.
|
|
119
|
+
#: ``none`` — a warm does nothing for it. Some of these still hit on a *repeat*
|
|
120
|
+
#: run because they cache their own answer (`repeat`), which a warm cannot do for
|
|
121
|
+
#: them because it never runs them.
|
|
122
|
+
COMMANDS: tuple[CommandCache, ...] = (
|
|
123
|
+
CommandCache("ask (root)", ("snapshot", "ris", "parse"), "answer", True,
|
|
124
|
+
"`--compact` is what a warm stores by default; `--agent` needs `cache warm --agent`. "
|
|
125
|
+
"`--env-map`, `--depth N` and `--exclude` change the *analysis*, so they miss the "
|
|
126
|
+
"warmed core and rescan — this is the 171 s the field measured after a 103 s warm.",
|
|
127
|
+
"--compact 17.7 s → 0.3 s; --agent --full --env-map --depth 20 34.7 s → 33.9 s (no gain)"),
|
|
128
|
+
CommandCache("posture", ("parse",), "shared", False,
|
|
129
|
+
"Resolves the conditional bean graph on every run; a warm saves it the Java parse, "
|
|
130
|
+
"which is the smaller half of its work.",
|
|
131
|
+
"24.5 s → 19.3 s"),
|
|
132
|
+
CommandCache("endpoints", ("ris", "parse"), "none", False,
|
|
133
|
+
"Recomputes the endpoint surface on every run and refreshes the RIS endpoint index. "
|
|
134
|
+
"Measured: a warm buys it nothing.",
|
|
135
|
+
"2.8 s → 2.9 s"),
|
|
136
|
+
CommandCache("spring-audit", ("ris", "parse"), "shared", False,
|
|
137
|
+
"Recomputes every run, but over a parse a warm has already paid for.",
|
|
138
|
+
"8.8 s → 3.7 s"),
|
|
139
|
+
CommandCache("migrate-check", ("cir",), "none", False,
|
|
140
|
+
"Computes its own inventory and shares nothing a warm builds. Only `--blast-radius` "
|
|
141
|
+
"reuses the shared CIR.",
|
|
142
|
+
"4.8 s → 4.8 s"),
|
|
143
|
+
CommandCache("impact-chain", ("cir", "parse"), "shared", False,
|
|
144
|
+
"The CIR is the expensive half — this is where a warm pays most.",
|
|
145
|
+
"9.9 s → 1.7 s"),
|
|
146
|
+
CommandCache("impact", ("parse",), "shared", False, "", "4.9 s → 2.8 s"),
|
|
147
|
+
CommandCache("pr-impact", ("parse",), "shared", False,
|
|
148
|
+
"Diff-dependent: the answer itself is never stored."),
|
|
149
|
+
CommandCache("verify", (), "none", False, "Runs the contracts against a fresh reading."),
|
|
150
|
+
CommandCache("verify-edit", ("parse",), "shared", True,
|
|
151
|
+
"Built for the edit loop: the parse cache is what keeps an unchanged file out of the "
|
|
152
|
+
"next run. Its own second run is faster again.",
|
|
153
|
+
"14.6 s → 9.6 s → 5.6 s on repeat"),
|
|
154
|
+
CommandCache("review-pr", ("cir",), "none", False,
|
|
155
|
+
"Diff-dependent, and it reuses the CIR only if one exists. On a small diff, loading "
|
|
156
|
+
"the warmed CIR costs more than the work it saves.",
|
|
157
|
+
"1.1 s → 2.3 s (slower)"),
|
|
158
|
+
CommandCache("plan", ("parse",), "shared", False, "", "9.3 s → 3.8 s"),
|
|
159
|
+
CommandCache("compare", ("parse",), "shared", False, ""),
|
|
160
|
+
CommandCache("delta", (), "none", False, "Analyses two checkouts; neither is the tree the cache describes."),
|
|
161
|
+
CommandCache("contract-diff", (), "none", False, "Analyses two checkouts."),
|
|
162
|
+
CommandCache("fix-bug", ("task",), "none", True,
|
|
163
|
+
"Shorthand for `prepare-context fix-bug`; caches its own answer, which a warm never runs."),
|
|
164
|
+
CommandCache("rename-class", (), "none", False, ""),
|
|
165
|
+
CommandCache("prepare-context", ("task", "ris", "cir", "parse"), "answer", True,
|
|
166
|
+
"Per task: `onboard` and `explain` are served from the RIS a warm rebuilds; "
|
|
167
|
+
"`refactor`, `fix-bug` and `generate-tests` cache their own answer, but a warm does "
|
|
168
|
+
"not run them, so their first call pays full price; `delta` and `review-pr` are "
|
|
169
|
+
"diff-dependent and never cached.",
|
|
170
|
+
"onboard 6.5 s → 0.3 s · refactor 7.7 s → 7.7 s (0.3 s on repeat) · "
|
|
171
|
+
"generate-tests 12.4 s → 11.0 s (0.3 s on repeat)"),
|
|
172
|
+
CommandCache("onboard", ("task", "ris"), "answer", True, "Shorthand for `prepare-context onboard`.",
|
|
173
|
+
"6.5 s → 0.3 s"),
|
|
174
|
+
CommandCache("explain", ("cir",), "shared", False, "Serves from the shared CIR a warm builds.",
|
|
175
|
+
"9.8 s → 1.6 s"),
|
|
176
|
+
CommandCache("export", ("parse",), "shared", False, "", "8.8 s → 3.7 s"),
|
|
177
|
+
CommandCache("repo-ir", ("parse",), "shared", False, "", "5.1 s → 2.9 s"),
|
|
178
|
+
CommandCache("validation", ("parse",), "shared", False, "", "11.6 s → 6.4 s"),
|
|
179
|
+
CommandCache("modernize", ("parse",), "shared", False, "", "5.2 s → 3.0 s"),
|
|
180
|
+
CommandCache("chunk-file", (), "none", False, "Reads one file; nothing to cache."),
|
|
181
|
+
CommandCache("cold-start", ("ris",), "answer", True,
|
|
182
|
+
"Reads the RIS a warm rebuilds — that is all it does. Without one it answers "
|
|
183
|
+
"`no_ris` instead of a snapshot.",
|
|
184
|
+
"0.2 s either way"),
|
|
185
|
+
CommandCache("baseline", ("parse",), "shared", False, "`capture`/`diff`/`trend` over architectural metrics.",
|
|
186
|
+
"capture 8.7 s → 3.6 s"),
|
|
187
|
+
CommandCache("retrieve", ("cir", "parse"), "shared", False,
|
|
188
|
+
"Every query builds or reuses the shared CIR a warm builds."),
|
|
189
|
+
CommandCache("archetype", ("parse",), "shared", False, "", "9.3 s → 5.6 s"),
|
|
190
|
+
CommandCache("activate", (), "none", False, "Not an analysis."),
|
|
191
|
+
CommandCache("auth", (), "none", False, "Not an analysis."),
|
|
192
|
+
CommandCache("cache", (), "none", False, "Operates on the caches themselves."),
|
|
193
|
+
CommandCache("config", (), "none", False, "Not an analysis."),
|
|
194
|
+
CommandCache("mcp", ("snapshot", "ris", "cir", "parse"), "shared", True,
|
|
195
|
+
"Serves the same commands over MCP, with the same layers."),
|
|
196
|
+
CommandCache("schema", (), "none", False, "Prints registries; reads no repository."),
|
|
197
|
+
CommandCache("telemetry", (), "none", False, "Not an analysis."),
|
|
198
|
+
CommandCache("version", (), "none", False, "Not an analysis."),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
_WARM_LABEL = {
|
|
202
|
+
"answer": "the answer",
|
|
203
|
+
"shared": "the shared work",
|
|
204
|
+
"none": "nothing",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def layer(layer_id: str) -> Layer:
|
|
209
|
+
for entry in LAYERS:
|
|
210
|
+
if entry.id == layer_id:
|
|
211
|
+
return entry
|
|
212
|
+
raise KeyError(layer_id)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def as_dict() -> dict:
|
|
216
|
+
"""The whole model as data — what `ask cache model --json` emits."""
|
|
217
|
+
return {
|
|
218
|
+
"invalidation": (
|
|
219
|
+
"Every layer keys on the exact tree state: any change to the analysed files "
|
|
220
|
+
"invalidates it, committed or not. A clean tree keys on the commit, so a repeat "
|
|
221
|
+
"run hits."
|
|
222
|
+
),
|
|
223
|
+
"layers": [
|
|
224
|
+
{
|
|
225
|
+
"id": lyr.id,
|
|
226
|
+
"stores": lyr.stores,
|
|
227
|
+
"location": lyr.location,
|
|
228
|
+
"invalidated_by": lyr.invalidated_by,
|
|
229
|
+
"warmed_by_cache_warm": lyr.warmed,
|
|
230
|
+
}
|
|
231
|
+
for lyr in LAYERS
|
|
232
|
+
],
|
|
233
|
+
"reference_repository": REFERENCE_REPOSITORY,
|
|
234
|
+
"commands": [
|
|
235
|
+
{
|
|
236
|
+
"command": cmd.command,
|
|
237
|
+
"reads": list(cmd.reads),
|
|
238
|
+
"warm_provides": cmd.warm,
|
|
239
|
+
"repeat_run_is_cached": cmd.repeat,
|
|
240
|
+
"note": cmd.note,
|
|
241
|
+
"measured": cmd.measured or None,
|
|
242
|
+
}
|
|
243
|
+
for cmd in COMMANDS
|
|
244
|
+
],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def render_markdown() -> str:
|
|
249
|
+
"""The model as the tables published in the user guide."""
|
|
250
|
+
out: list[str] = []
|
|
251
|
+
out.append("| Layer | What it stores | What invalidates it | `cache warm` |")
|
|
252
|
+
out.append("|---|---|---|---|")
|
|
253
|
+
for lyr in LAYERS:
|
|
254
|
+
out.append(f"| `{lyr.id}` | {lyr.stores} | {lyr.invalidated_by} | {lyr.warmed} |")
|
|
255
|
+
out.append("")
|
|
256
|
+
out.append(f"Measured on {REFERENCE_REPOSITORY}, each command in isolation.")
|
|
257
|
+
out.append("")
|
|
258
|
+
out.append("| Command | A warm gives it | Measured (nothing cached → after a warm) | Repeat run cached | Layers | Notes |")
|
|
259
|
+
out.append("|---|---|---|---|---|---|")
|
|
260
|
+
for cmd in COMMANDS:
|
|
261
|
+
layers = ", ".join(f"`{name}`" for name in cmd.reads) or "—"
|
|
262
|
+
out.append(
|
|
263
|
+
f"| `{cmd.command}` | {_WARM_LABEL[cmd.warm]} | {cmd.measured or 'not timed'} | "
|
|
264
|
+
f"{'yes' if cmd.repeat else 'no'} | {layers} | {cmd.note or '—'} |"
|
|
265
|
+
)
|
|
266
|
+
return "\n".join(out)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def render_text() -> str:
|
|
270
|
+
"""The model as `ask cache model` prints it."""
|
|
271
|
+
lines: list[str] = []
|
|
272
|
+
lines.append("Invalidation — one rule for every layer")
|
|
273
|
+
lines.append(" Every layer keys on the exact tree state. Any change to the analysed files")
|
|
274
|
+
lines.append(" invalidates it, committed or not, tracked or not. A clean tree keys on the")
|
|
275
|
+
lines.append(" commit, so a repeat run with no edits hits.")
|
|
276
|
+
lines.append("")
|
|
277
|
+
lines.append("Layers")
|
|
278
|
+
for lyr in LAYERS:
|
|
279
|
+
lines.append(f" {lyr.id}")
|
|
280
|
+
lines.append(f" stores {lyr.stores}")
|
|
281
|
+
lines.append(f" location {lyr.location}")
|
|
282
|
+
lines.append(f" invalidated {lyr.invalidated_by}")
|
|
283
|
+
lines.append(f" cache warm {lyr.warmed}")
|
|
284
|
+
lines.append("")
|
|
285
|
+
lines.append("What a warm gives each command")
|
|
286
|
+
lines.append(f" Timings: {REFERENCE_REPOSITORY}, each command measured in isolation.")
|
|
287
|
+
width = max(len(c.command) for c in COMMANDS)
|
|
288
|
+
for cmd in COMMANDS:
|
|
289
|
+
repeat = "repeat cached" if cmd.repeat else "recomputes"
|
|
290
|
+
lines.append(f" {cmd.command.ljust(width)} {_WARM_LABEL[cmd.warm]:<16} ({repeat})")
|
|
291
|
+
if cmd.measured:
|
|
292
|
+
lines.append(f" {' ' * width} measured: {cmd.measured}")
|
|
293
|
+
if cmd.note:
|
|
294
|
+
lines.append(f" {' ' * width} {cmd.note}")
|
|
295
|
+
return "\n".join(lines)
|
sourcecode/chain_rules.py
CHANGED
|
@@ -274,16 +274,57 @@ def rule_matches(rule: AccessRule, method: str, path: str) -> bool:
|
|
|
274
274
|
return any(ant_matches(pattern, path) for pattern in rule.patterns)
|
|
275
275
|
|
|
276
276
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
277
|
+
#: Matchers that see the DispatcherServlet path. An ant matcher matches the URL
|
|
278
|
+
#: the filter chain receives — servlet path included, servlet CONTEXT path never,
|
|
279
|
+
#: because the container has already stripped it. An mvc matcher matches the path
|
|
280
|
+
#: relative to the servlet mapping, so the servlet path is not part of it.
|
|
281
|
+
#: `requestMatchers` is either one depending on the Spring Security version and
|
|
282
|
+
#: what is on the classpath — undecidable from source, so both readings are tried
|
|
283
|
+
#: and the pattern is honoured if it covers the request under either.
|
|
284
|
+
_SERVLET_PATH_MATCHERS = ("antMatchers", "regexMatchers", "requestMatchers")
|
|
285
|
+
_SERVLET_RELATIVE_MATCHERS = ("mvcMatchers", "requestMatchers")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def candidate_paths(rule: AccessRule, path: str, servlet_prefix: str = "") -> "tuple[str, ...]":
|
|
289
|
+
"""The path(s) `rule`'s matcher compares against a mapping-relative `path`.
|
|
290
|
+
|
|
291
|
+
Without a declared servlet path the two readings coincide and this is just
|
|
292
|
+
`(path,)` — which is every repository that never set `spring.mvc.servlet.path`.
|
|
293
|
+
"""
|
|
294
|
+
if not servlet_prefix or rule.is_any_request:
|
|
295
|
+
return (path,)
|
|
296
|
+
prefixed = f"{servlet_prefix.rstrip('/')}/{path.lstrip('/')}".rstrip("/") or "/"
|
|
297
|
+
out: list[str] = []
|
|
298
|
+
if rule.matcher in _SERVLET_PATH_MATCHERS:
|
|
299
|
+
out.append(prefixed)
|
|
300
|
+
if rule.matcher in _SERVLET_RELATIVE_MATCHERS or not out:
|
|
301
|
+
out.append(path)
|
|
302
|
+
return tuple(dict.fromkeys(out))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def first_match(
|
|
306
|
+
rules: "list[AccessRule]", method: str, path: str, servlet_prefix: str = ""
|
|
307
|
+
) -> "tuple[Optional[AccessRule], str]":
|
|
308
|
+
"""`(rule Spring would apply, the path it matched)` — first declared wins.
|
|
309
|
+
|
|
310
|
+
The matched path is returned because with a servlet path declared it is not
|
|
311
|
+
the mapping-relative path the endpoint is keyed by, and a reader checking the
|
|
312
|
+
claim against the source line needs to see the URL the rule actually covers.
|
|
313
|
+
"""
|
|
281
314
|
for rule in rules:
|
|
282
315
|
if rule.paths_unknown:
|
|
283
316
|
# A rule whose paths could not be read may cover this request, and
|
|
284
317
|
# everything after it is only reachable if it does not. Stopping here
|
|
285
318
|
# is what keeps a later `permitAll` from being reported as the answer.
|
|
286
|
-
return rule
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
319
|
+
return rule, path
|
|
320
|
+
for candidate in candidate_paths(rule, path, servlet_prefix):
|
|
321
|
+
if rule_matches(rule, method, candidate):
|
|
322
|
+
return rule, candidate
|
|
323
|
+
return None, path
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def first_matching_rule(
|
|
327
|
+
rules: "list[AccessRule]", method: str, path: str, servlet_prefix: str = ""
|
|
328
|
+
) -> "Optional[AccessRule]":
|
|
329
|
+
"""The rule Spring would apply: the first declared one that matches."""
|
|
330
|
+
return first_match(rules, method, path, servlet_prefix)[0]
|