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 CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "3.2.0"
7
+ __version__ = "3.2.2"
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
- 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."""
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
- if rule_matches(rule, method, path):
288
- return rule
289
- return None
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]