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.

Potentially problematic release.


This version of sourcecode might be problematic. Click here for more details.

@@ -315,12 +315,16 @@ class ConfidenceAnalyzer:
315
315
  _test_exclude_tokens = frozenset({"test", "tests", "spec", "specs", "it", "testing"})
316
316
  _tests_deliberately_excluded = bool(_extra_exc & _test_exclude_tokens)
317
317
 
318
+ # One authority for which files are tests (ADR-0008 R1): the substring
319
+ # rule this replaces counted production classes living in a package
320
+ # named `test` as tests, so a repository with none reported "2 test
321
+ # files" while `has_tests` in the same document said the same wrong
322
+ # thing from a second derivation.
323
+ from sourcecode.test_sources import analyze_test_sources
324
+
318
325
  _java_all = [p for p in sm.file_paths if p.endswith(".java")]
319
- _java_tests = [
320
- p for p in _java_all
321
- if "/test/" in p.replace("\\", "/") or "/tests/" in p.replace("\\", "/")
322
- or Path(p).stem.endswith(("Test", "Tests", "IT", "Spec"))
323
- ]
326
+ _java_test_facts = analyze_test_sources(_java_all, extensions=(".java",))
327
+ _java_tests = list(_java_test_facts.test_files)
324
328
  _java_prod = [p for p in _java_all if p not in set(_java_tests)]
325
329
  if _java_prod and len(_java_prod) >= 10:
326
330
  _ratio = len(_java_tests) / len(_java_prod)
@@ -341,7 +345,7 @@ class ConfidenceAnalyzer:
341
345
  reason=(
342
346
  f"Backend test coverage critical: {len(_java_tests)} test files "
343
347
  f"for {len(_java_prod)} Java files "
344
- f"({_ratio:.1%})"
348
+ f"({_ratio:.1%}) — {_java_test_facts.basis}"
345
349
  ),
346
350
  impact="high",
347
351
  ))
@@ -190,34 +190,14 @@ def _git_head(repo_root: Path) -> str:
190
190
  def _worktree_signature(repo_root: Path) -> str:
191
191
  """Deterministic fingerprint of the *exact* tree state.
192
192
 
193
- committed HEAD, plus — only when the tree is dirty — a hash of the porcelain
194
- status and the diff against HEAD. Two identical tree states yield the same
195
- signature (correct reuse); any tracked edit changes it (correct invalidation).
196
- Returns ``""`` when the path is not a git repo (caller disables caching).
193
+ Thin alias of the one authority, :func:`sourcecode.cache.worktree_signature`.
194
+ It used to be a second implementation living here, which is how the two caches
195
+ ended up invalidating on different facts: this one on the tree, the snapshot
196
+ cache on the committed HEAD alone.
197
197
  """
198
- head = _git_head(repo_root)
199
- if not head:
200
- return ""
201
- try:
202
- status = subprocess.run(
203
- ["git", "-C", str(repo_root), "status", "--porcelain"],
204
- capture_output=True, text=True, timeout=5,
205
- )
206
- porcelain = status.stdout if status.returncode == 0 else ""
207
- except Exception:
208
- porcelain = ""
209
- if not porcelain.strip():
210
- return head # clean tree — HEAD fully describes it
211
- try:
212
- diff = subprocess.run(
213
- ["git", "-C", str(repo_root), "diff", "HEAD"],
214
- capture_output=True, text=True, timeout=10,
215
- )
216
- diff_txt = diff.stdout if diff.returncode == 0 else ""
217
- except Exception:
218
- diff_txt = ""
219
- dirty = hashlib.sha256((porcelain + "\x00" + diff_txt).encode("utf-8", "replace")).hexdigest()[:16]
220
- return f"{head}+dirty:{dirty}"
198
+ from sourcecode.cache import worktree_signature
199
+
200
+ return worktree_signature(repo_root)
221
201
 
222
202
 
223
203
  def _options_fingerprint(options: Optional[dict[str, Any]]) -> str:
@@ -35,6 +35,11 @@ _SERVLET_PATH_KEYS: tuple[str, ...] = (
35
35
  "server.servlet-path", # Boot 1.x spelling
36
36
  )
37
37
 
38
+ #: The same keys, exported: every consumer that needs to ask a resolved-property
39
+ #: authority for a prefix reads them from here rather than spelling them again.
40
+ CONTEXT_PATH_KEYS: tuple[str, ...] = _CONTEXT_PATH_KEYS
41
+ SERVLET_PATH_KEYS: tuple[str, ...] = _SERVLET_PATH_KEYS
42
+
38
43
  # Published multi-document / profile activation keys.
39
44
  _PROFILE_KEYS: tuple[str, ...] = (
40
45
  "spring.config.activate.on-profile",
@@ -227,3 +232,83 @@ def detect_deployment_prefix(root: Path) -> DeploymentPrefix:
227
232
 
228
233
  result.sources.sort(key=lambda s: (s.file, s.key, s.profile or "", s.value))
229
234
  return result
235
+
236
+
237
+ def normalize_prefix(value: str) -> str:
238
+ """`api/` → `/api`; empty or `/` → `""`. A prefix is a path segment chain."""
239
+ cleaned = re.sub(r"/+", "/", (value or "").strip()).strip()
240
+ cleaned = "/" + cleaned.strip("/")
241
+ return "" if cleaned == "/" else cleaned
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class ServletPrefix:
246
+ """The DispatcherServlet path, and whether it could be decided at all.
247
+
248
+ Security matchers never see the servlet CONTEXT path — the container strips
249
+ it before the filter chain runs — but a servlet path declared by
250
+ ``spring.mvc.servlet.path`` IS part of what an ant matcher matches. The two
251
+ prefixes are therefore different facts, and only this one belongs in a
252
+ request-chain answer.
253
+ """
254
+
255
+ value: str = ""
256
+ #: Set when a declaration exists but its value could not be decided
257
+ #: (placeholder, or profile documents that disagree). The prefix is then
258
+ #: unknown, never assumed empty.
259
+ undecidable_reason: Optional[str] = None
260
+ key: Optional[str] = None
261
+ source: Optional[str] = None
262
+
263
+ @property
264
+ def decided(self) -> bool:
265
+ return self.undecidable_reason is None
266
+
267
+ def to_dict(self) -> dict:
268
+ out: dict = {"servlet_path": self.value, "decided": self.decided}
269
+ if self.key:
270
+ out["key"] = self.key
271
+ if self.source:
272
+ out["source"] = self.source
273
+ if self.undecidable_reason:
274
+ out["undecidable_reason"] = self.undecidable_reason
275
+ return out
276
+
277
+
278
+ def servlet_prefix_from_properties(properties: Any) -> ServletPrefix:
279
+ """The servlet path a resolved-property authority states, or why it cannot.
280
+
281
+ `properties` is anything with `lookup(key)` / `blocked(key)` — the resolved
282
+ view for a profile set, so a servlet path declared only under `prod` applies
283
+ exactly when `prod` was asked about. An unresolved placeholder is reported
284
+ undecidable rather than treated as "no prefix": the difference between an
285
+ unknown prefix and no prefix is the difference between "cannot say" and a
286
+ confident wrong answer about which rule matches.
287
+ """
288
+ for key in SERVLET_PATH_KEYS:
289
+ blocked = None
290
+ try:
291
+ blocked = properties.blocked(key)
292
+ except Exception:
293
+ blocked = None
294
+ if blocked:
295
+ return ServletPrefix(undecidable_reason=str(blocked), key=key)
296
+ try:
297
+ found = properties.lookup(key)
298
+ except Exception:
299
+ found = None
300
+ if found is None:
301
+ continue
302
+ raw = str(getattr(found, "value", "") or "")
303
+ if _PLACEHOLDER_RE.search(raw):
304
+ return ServletPrefix(
305
+ undecidable_reason=f"value carries an unresolved placeholder: {raw}",
306
+ key=key,
307
+ source=str(getattr(found, "source", "") or "") or None,
308
+ )
309
+ return ServletPrefix(
310
+ value=normalize_prefix(raw),
311
+ key=key,
312
+ source=str(getattr(found, "source", "") or "") or None,
313
+ )
314
+ return ServletPrefix()
@@ -0,0 +1,71 @@
1
+ """facts — the registry of facts that have exactly one authority (ADR-0008 §4).
2
+
3
+ A fact in this registry is a question the product answers with a number, a name
4
+ or a verdict, and which more than one command emits. The registry states who
5
+ derives it, what it means, and which modules are allowed to emit it. R13: a new
6
+ consumer that emits a registered fact without binding to its authority fails
7
+ review, and a new fact is registered in the commit that introduces it.
8
+
9
+ The registry is data, shipped inside the package, so an installed version can be
10
+ asked what its own contract is — the same reason the response-envelope schema is
11
+ shipped rather than documented.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ REGISTRY_PATH = Path(__file__).resolve().parent / "registry.json"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Fact:
24
+ """One registered fact and the seam that made it one."""
25
+
26
+ fact: str
27
+ authority: str
28
+ definition: str
29
+ consumers: "tuple[str, ...]" = ()
30
+ parity_test: str = ""
31
+ stats_key: str = ""
32
+ seam: str = ""
33
+ was: str = ""
34
+
35
+ @property
36
+ def authority_module(self) -> str:
37
+ return self.authority.split(":", 1)[0]
38
+
39
+ @property
40
+ def authority_symbol(self) -> str:
41
+ return self.authority.split(":", 1)[1] if ":" in self.authority else ""
42
+
43
+
44
+ def load_registry() -> dict:
45
+ """The registry as published, unmodified."""
46
+ return json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
47
+
48
+
49
+ def registered_facts() -> "tuple[Fact, ...]":
50
+ """Every registered fact, in registry order."""
51
+ return tuple(
52
+ Fact(
53
+ fact=str(entry["fact"]),
54
+ authority=str(entry["authority"]),
55
+ definition=str(entry["definition"]),
56
+ consumers=tuple(entry.get("consumers") or ()),
57
+ parity_test=str(entry.get("parity_test") or ""),
58
+ stats_key=str(entry.get("stats_key") or ""),
59
+ seam=str(entry.get("seam") or ""),
60
+ was=str(entry.get("was") or ""),
61
+ )
62
+ for entry in load_registry().get("facts") or []
63
+ )
64
+
65
+
66
+ def fact(name: str) -> "Fact":
67
+ """One registered fact by name. Raises KeyError when it is not registered."""
68
+ for entry in registered_facts():
69
+ if entry.fact == name:
70
+ return entry
71
+ raise KeyError(name)
@@ -0,0 +1,158 @@
1
+ {
2
+ "registry_version": "facts-v1",
3
+ "note": "ADR-0008 R11. One authority per fact. `authority` is module:symbol; `consumers` are the modules allowed to emit the fact, and each MUST import the authority rather than re-derive it (R13). `parity_test` is the assertion that keeps every emitter agreeing (R12).",
4
+ "facts": [
5
+ {
6
+ "fact": "transactional_boundaries_touched",
7
+ "authority": "sourcecode.spring_semantic:boundaries_declared_within",
8
+ "definition": "Transaction boundaries declared within a symbol's own body plus those declared by its callers — the blast radius of changing it.",
9
+ "stats_key": "transactional_boundaries_touched",
10
+ "consumers": [
11
+ "sourcecode.repository_ir"
12
+ ],
13
+ "parity_test": "tests/test_tx_boundary_authority.py",
14
+ "seam": "M7 seam 1",
15
+ "was": "A graph node role `transaction_boundary` no producer ever assigns, so the figure was structurally always 0.",
16
+ "unresolved_answer": null,
17
+ "unresolved_note": "The tx index either declares a boundary within the scanned symbols or it does not; there is no third state to report. What is unknown is whether a boundary exists in code this analyzer never saw, and that is stated as analysis scope, not as this fact's value."
18
+ },
19
+ {
20
+ "fact": "transactional_boundaries_reaching",
21
+ "authority": "sourcecode.spring_semantic:boundaries_declared_within",
22
+ "definition": "Transaction boundaries declared by the callers that reach a symbol — the figure `retrieve transactions-reaching` reports. A different question from `_touched`, so it carries a different name (ADR-0008 R3).",
23
+ "stats_key": "transactional_boundaries_reaching",
24
+ "consumers": [
25
+ "sourcecode.repository_ir",
26
+ "sourcecode.retrieval.steps_graph"
27
+ ],
28
+ "parity_test": "tests/test_tx_boundary_authority.py",
29
+ "seam": "M7 seam 1",
30
+ "unresolved_answer": null,
31
+ "unresolved_note": "Same as `transactional_boundaries_touched`: the caller set is the scope, and a boundary is declared or absent within it."
32
+ },
33
+ {
34
+ "fact": "endpoint_population",
35
+ "authority": "sourcecode.security_posture:endpoint_population",
36
+ "definition": "The denominator for every endpoint statement, with the three units named apart: endpoints, handler methods, declarations. Every partition sums to it or declares its exclusion set.",
37
+ "stats_key": "population",
38
+ "consumers": [
39
+ "sourcecode.security_posture"
40
+ ],
41
+ "parity_test": "tests/test_endpoint_count_parity.py",
42
+ "seam": "M7 seam 2",
43
+ "was": "Three incompatible denominators in one document; only the rollup reconciled.",
44
+ "unresolved_answer": null,
45
+ "unresolved_note": "A population is a count of what was parsed. Where a route could not be modelled it is excluded and the exclusion set is published, so the denominator never carries an undecided member."
46
+ },
47
+ {
48
+ "fact": "endpoint_security_surface",
49
+ "authority": "sourcecode.security_posture:endpoint_security_surface",
50
+ "definition": "What guards an endpoint, as a verdict plus a confidence — `coverage_unknown` where a custom gate is undetermined, never `none_detected`.",
51
+ "consumers": [
52
+ "sourcecode.spring_impact"
53
+ ],
54
+ "parity_test": "tests/test_endpoint_security_surface.py",
55
+ "seam": "M7 seam 3",
56
+ "was": "`impact-chain` printed `none_detected` for endpoints the same run classified `protected_custom`.",
57
+ "unresolved_answer": "coverage_unknown",
58
+ "unresolved_note": "Absence of a posture entry is absence of evidence, never `none_detected`."
59
+ },
60
+ {
61
+ "fact": "integration_coordinates",
62
+ "authority": "sourcecode.integration_coordinates:declared_integration_coordinates",
63
+ "definition": "What the build declares the application integrates with, reported beside what the source scan observed — `declared_clients` and `kinds_declared_not_observed`.",
64
+ "consumers": [
65
+ "sourcecode.cli",
66
+ "sourcecode.retrieval.steps_intf"
67
+ ],
68
+ "parity_test": "tests/test_integration_coordinates.py",
69
+ "seam": "M7 seam 4",
70
+ "was": "`export --integrations` reported 0 LDAP while the stack block listed a declared LDAP client.",
71
+ "unresolved_answer": null,
72
+ "unresolved_note": "The authority answers what the build declares, which a manifest either states or does not. The undecided case is the comparison against what the source scan observed, and it is published by the consumers as `kinds_declared_not_observed` rather than resolved either way."
73
+ },
74
+ {
75
+ "fact": "test_sources",
76
+ "authority": "sourcecode.test_sources:analyze_test_sources",
77
+ "definition": "Whether a file is a test source: it sits under a declared test source root, or matches an ecosystem naming convention. A `test` package inside a main source root is production code.",
78
+ "stats_key": "has_tests",
79
+ "consumers": [
80
+ "sourcecode.serializer",
81
+ "sourcecode.confidence_analyzer",
82
+ "sourcecode.metrics_analyzer",
83
+ "sourcecode.prepare_context"
84
+ ],
85
+ "parity_test": "tests/test_test_sources_authority.py",
86
+ "seam": "M7 seam 5",
87
+ "was": "A substring rule counted production classes in a package named `test`, contradicting `analysis_gaps` in the same document.",
88
+ "unresolved_answer": null,
89
+ "unresolved_note": "Total by construction: every path gets a decided answer, because a layout either declares a test root or it does not. The unknown lives one level up — a repository with no test sources makes a COVERAGE question unanswerable, and the consumers emit `unknown` there rather than `low`."
90
+ },
91
+ {
92
+ "fact": "type_reference_status",
93
+ "authority": "sourcecode.reference_facts:analyze_type_references",
94
+ "definition": "Whether a type is referenced: `referenced` / `unknown_framework_dispatch` / `no_static_callers`. A partition summing to the classes examined, where `no_static_callers` is absence of evidence and never a dead-code claim.",
95
+ "stats_key": "reference_status",
96
+ "consumers": [
97
+ "sourcecode.cli"
98
+ ],
99
+ "parity_test": "tests/test_reference_facts.py",
100
+ "seam": "M7 seam 6",
101
+ "was": "`statically_unreferenced: 0` over 3 374 classes: a population that never matched its own definition, and no way to say `unknown`.",
102
+ "unresolved_answer": "unknown_framework_dispatch",
103
+ "unresolved_note": "No static caller plus a dispatch signal is unknown; a static call-graph cannot decide it."
104
+ },
105
+ {
106
+ "fact": "profile_conditional_declarations",
107
+ "authority": "sourcecode.spring_profiles:conditional_beans_from_sources_detailed",
108
+ "definition": "Which declaration each `@Profile` annotates, and the expression as written. A member-level annotation belongs to `Type#member`; an annotation whose target cannot be read is not reported at all.",
109
+ "consumers": [
110
+ "sourcecode.serializer"
111
+ ],
112
+ "parity_test": "tests/test_deployment_prefix_and_profiles.py",
113
+ "seam": "M7 seam 7",
114
+ "was": "A class was listed under a profile AND its negation, contradicting `posture` in the same run.",
115
+ "unresolved_answer": null,
116
+ "unresolved_note": "An annotation whose target cannot be read is not reported at all — the fact omits what it cannot attribute rather than reporting it undecided."
117
+ },
118
+ {
119
+ "fact": "profile_activation",
120
+ "authority": "sourcecode.posture:resolve_beans",
121
+ "definition": "Whether a profile set activates a bean. Resolution — as opposed to which profile a declaration mentions — is `posture`'s answer, and it is three-valued: active, inactive, unresolved.",
122
+ "consumers": [
123
+ "sourcecode.posture"
124
+ ],
125
+ "parity_test": "tests/test_deployment_prefix_and_profiles.py",
126
+ "seam": "M7 seam 7",
127
+ "unresolved_answer": "unresolved",
128
+ "unresolved_note": "A condition that cannot be evaluated is never folded into active or inactive."
129
+ },
130
+ {
131
+ "fact": "deployment_prefix",
132
+ "authority": "sourcecode.deployment_prefix:detect_deployment_prefix",
133
+ "definition": "The URL prefix the application is served under (`server.servlet.context-path` + `spring.mvc.servlet.path`), applied to mapping-relative paths as `effective_path`.",
134
+ "stats_key": "effective_path",
135
+ "consumers": [
136
+ "sourcecode.repository_ir",
137
+ "sourcecode.posture"
138
+ ],
139
+ "parity_test": "tests/test_deployment_prefix_and_profiles.py",
140
+ "seam": "M7 seam 8",
141
+ "unresolved_answer": "unconditional",
142
+ "unresolved_note": "A prefix declared only under a profile, or carrying an unresolved placeholder, is published with `unconditional: false` and never silently applied to every route."
143
+ },
144
+ {
145
+ "fact": "servlet_prefix",
146
+ "authority": "sourcecode.deployment_prefix:servlet_prefix_from_properties",
147
+ "definition": "The part of the prefix a security matcher sees: the servlet path for the profile set asked about. The servlet CONTEXT path is excluded — the container strips it before the filter chain runs — so this is a different fact from `deployment_prefix`, not a view of it.",
148
+ "consumers": [
149
+ "sourcecode.posture"
150
+ ],
151
+ "parity_test": "tests/test_posture.py",
152
+ "seam": "M7 seam 8",
153
+ "was": "`posture` matched chain rules against unprefixed paths and reported endpoints as covered by no rule — or, with a catch-all behind the rule, as open.",
154
+ "unresolved_answer": "undecidable_reason",
155
+ "unresolved_note": "A declared servlet path that cannot be resolved makes the matching basis unknown, and every path-dependent verdict becomes `undecided`."
156
+ }
157
+ ]
158
+ }
sourcecode/license.py CHANGED
@@ -396,7 +396,7 @@ _init()
396
396
  # ---------------------------------------------------------------------------
397
397
 
398
398
  def _emit_telemetry(event: str, **kw: object) -> None:
399
- """Best-effort telemetry emit. Respects the user's opt-out; never raises or blocks."""
399
+ """Best-effort telemetry emit. Sends nothing unless the user opted in; never raises or blocks."""
400
400
  try:
401
401
  from sourcecode import telemetry as _tel
402
402
  _tel.record(event, **kw) # type: ignore[arg-type]
@@ -800,7 +800,7 @@ depth: BFS depth for indirect caller traversal (1–8, default: 4).
800
800
  Analyzes codebase for modernization opportunities: dead zones, hotspot scores, upgrade candidates.
801
801
 
802
802
  Maps to: ask modernize <repo_path>
803
- Returns: hotspot_candidates (high fan-in + git churn), statically_unreferenced (zero-caller classes — NOT confirmed dead; verify no framework dispatch) + framework_dispatched,
803
+ Returns: hotspot_candidates (high fan-in + git churn), statically_unreferenced (no static caller AND no dispatch signal found — absence of evidence, NOT confirmed dead code) + framework_dispatched (no static caller but a framework may invoke them: status unknown). Counts live in summary.reference_status, which partitions every class,
804
804
  high_coupling_nodes, subsystem_summary, cross_module_tangles, recommendation.
805
805
 
806
806
  Best for: refactor planning, identifying where to start, finding safe removal candidates.
sourcecode/mcp/server.py CHANGED
@@ -33,8 +33,8 @@ def _record_tool_invocation(name: Any, success: bool, started: float) -> None:
33
33
 
34
34
  Aggregate only: which of our own tools ran, whether it succeeded, and a
35
35
  duration bucket. Never the arguments — those carry repository paths — and
36
- never any result content. Honours the same opt-out as every other event
37
- (`ask telemetry disable`, SOURCECODE_TELEMETRY=0, DO_NOT_TRACK=1); when
36
+ never any result content. Honours the same opt-in as every other event
37
+ (off until `ask telemetry enable` or SOURCECODE_TELEMETRY=1); when
38
38
  telemetry is off, `record` returns before building anything.
39
39
  """
40
40
  try:
@@ -1377,7 +1377,7 @@ def modernize_context(repo_path: str = ".", format: str = "json") -> dict:
1377
1377
  """Analyzes codebase for modernization opportunities: dead zones, hotspot scores, upgrade candidates.
1378
1378
 
1379
1379
  Maps to: ask modernize <repo_path>
1380
- Returns: hotspot_candidates (high fan-in + git churn), statically_unreferenced (zero-caller classes — NOT confirmed dead; verify no framework dispatch) + framework_dispatched,
1380
+ Returns: hotspot_candidates (high fan-in + git churn), statically_unreferenced (no static caller AND no dispatch signal found — absence of evidence, NOT confirmed dead code) + framework_dispatched (no static caller but a framework may invoke them: status unknown). Counts live in summary.reference_status, which partitions every class,
1381
1381
  high_coupling_nodes, subsystem_summary, cross_module_tangles, recommendation.
1382
1382
 
1383
1383
  Best for: refactor planning, identifying where to start, finding safe removal candidates.
@@ -60,13 +60,16 @@ _STEM_PATTERNS: list[tuple[re.Pattern[str], str]] = [
60
60
 
61
61
 
62
62
  def is_test_file(path: str) -> bool:
63
- """Return True if path corresponds to a test file based on ecosystem conventions.
63
+ """Return True if path corresponds to a test file.
64
64
 
65
- Normalizes Windows backslashes to forward slashes before matching.
66
- Patterns are anchored to avoid false positives (e.g., 'testdata/', '*_tested.py').
65
+ Delegates to `test_sources.is_test_path`, the one authority for this fact
66
+ (ADR-0008 R1): a declared test source root (`src/test`, `src/it`, `tests/`)
67
+ or an ecosystem naming convention — and never a `test` package that sits
68
+ inside a main source root.
67
69
  """
68
- normalized = path.replace("\\", "/")
69
- return any(p.search(normalized) for p in _TEST_FILE_PATTERNS)
70
+ from sourcecode.test_sources import is_test_path
71
+
72
+ return is_test_path(path)
70
73
 
71
74
 
72
75
  def infer_production_target(test_path: str) -> str | None:
sourcecode/posture.py CHANGED
@@ -542,8 +542,29 @@ _ACCESS_DECISIONS_ORDER = (
542
542
  )
543
543
 
544
544
 
545
+ def _servlet_prefix_from_root(root: Path) -> "ServletPrefix":
546
+ """The servlet path from the repository's own configuration.
547
+
548
+ Used when no resolved-property view is supplied. It applies only the
549
+ unconditional declaration — a servlet path declared under a profile is a fact
550
+ about that profile, and this caller did not name one.
551
+ """
552
+ from sourcecode.deployment_prefix import (
553
+ ServletPrefix, detect_deployment_prefix, normalize_prefix,
554
+ )
555
+
556
+ try:
557
+ detected = detect_deployment_prefix(Path(root))
558
+ except Exception:
559
+ return ServletPrefix()
560
+ return ServletPrefix(value=normalize_prefix(detected.servlet_path))
561
+
562
+
545
563
  def access_projection(
546
- cir: "CanonicalRepositoryIR", resolution: BeanResolution, root: Path
564
+ cir: "CanonicalRepositoryIR",
565
+ resolution: BeanResolution,
566
+ root: Path,
567
+ properties: "Optional[object]" = None,
547
568
  ) -> "tuple[dict, dict[str, str]]":
548
569
  """What the active chain configuration decides for each endpoint.
549
570
 
@@ -555,9 +576,20 @@ def access_projection(
555
576
  with different decisions, the answer is `undecided`: their relative order is
556
577
  an `@Order`/bean-ordering question this analyzer does not resolve, and picking
557
578
  one would state a security fact by coin flip.
579
+
580
+ Rules are matched against the path the filter chain receives, which is the
581
+ mapping-relative path prefixed by any declared `spring.mvc.servlet.path`.
582
+ Matching the unprefixed path instead reports `no_rule_matched` for endpoints
583
+ a declared matcher covers — a posture answer that under-reports protection.
558
584
  """
559
- from sourcecode.chain_rules import extract_chain_rules, first_matching_rule
585
+ from sourcecode.chain_rules import extract_chain_rules, first_match
586
+ from sourcecode.deployment_prefix import servlet_prefix_from_properties
560
587
 
588
+ servlet = (
589
+ servlet_prefix_from_properties(properties) if properties is not None
590
+ else _servlet_prefix_from_root(root)
591
+ )
592
+ prefix = servlet.value if servlet.decided else ""
561
593
  states = _chain_state_by_file(cir, resolution)
562
594
  rules_by_file = extract_chain_rules(root, sorted(states)) if states else {}
563
595
  active_files = sorted(f for f in rules_by_file if states.get(f) == "active")
@@ -573,12 +605,17 @@ def access_projection(
573
605
  continue
574
606
  decisions: dict[str, dict] = {}
575
607
  for source_file in active_files:
576
- rule = first_matching_rule(rules_by_file[source_file], endpoint.method, endpoint.path)
608
+ rule, matched = first_match(
609
+ rules_by_file[source_file], endpoint.method, endpoint.path, prefix
610
+ )
577
611
  if rule is None:
578
612
  continue
579
- decisions["undecided" if rule.paths_unknown else rule.decision] = rule.to_dict()
613
+ evidence = rule.to_dict()
614
+ if matched != endpoint.path:
615
+ evidence["matched_path"] = matched
616
+ decisions["undecided" if rule.paths_unknown else rule.decision] = evidence
580
617
  if any(
581
- first_matching_rule(rules_by_file[f], endpoint.method, endpoint.path) is not None
618
+ first_match(rules_by_file[f], endpoint.method, endpoint.path, prefix)[0] is not None
582
619
  for f in undecided_files
583
620
  ):
584
621
  decisions["undecided"] = {
@@ -599,6 +636,28 @@ def access_projection(
599
636
  by_endpoint[endpoint.id] = decision
600
637
  details[endpoint.id] = evidence
601
638
 
639
+ # A declared-but-undecidable servlet path means the URL the chain matches is
640
+ # unknown. Where any pattern rule is declared, that unknown decides which rule
641
+ # is reached — including whether a later `anyRequest` is reached at all — so
642
+ # no chain-derived verdict survives it. A handler's own guard does, and so
643
+ # does a chain whose only rule is `anyRequest`: it matches every URL.
644
+ path_dependent = any(
645
+ not rule.is_any_request
646
+ for source_file in active_files + undecided_files
647
+ for rule in rules_by_file[source_file]
648
+ )
649
+ if not servlet.decided and path_dependent:
650
+ for endpoint_id, decision in list(by_endpoint.items()):
651
+ if decision == "guarded_by_own_annotation":
652
+ continue
653
+ by_endpoint[endpoint_id] = "undecided"
654
+ details[endpoint_id] = {
655
+ "reason": "the declared servlet path could not be resolved, so the URL "
656
+ "these matchers compare against is unknown",
657
+ "servlet_path": servlet.to_dict(),
658
+ "decision_if_unprefixed": decision,
659
+ }
660
+
602
661
  summary = {
603
662
  key: sum(1 for d in by_endpoint.values() if d == key)
604
663
  for key in _ACCESS_DECISIONS_ORDER
@@ -612,6 +671,19 @@ def access_projection(
612
671
  },
613
672
  "summary": {k: v for k, v in summary.items() if v},
614
673
  }
674
+ # State the URL basis the rules were matched against. `endpoints` publishes
675
+ # `effective_path` (context path included, which the container strips before
676
+ # the chain runs); this is the same authority read for the part a matcher
677
+ # actually sees.
678
+ if servlet.value or not servlet.decided:
679
+ payload["path_basis"] = {
680
+ **servlet.to_dict(),
681
+ "note": (
682
+ "chain rules are matched against the servlet path + the "
683
+ "mapping-relative path; the servlet context path is not part of "
684
+ "it, because the container strips it before the filter chain runs"
685
+ ),
686
+ }
615
687
  for key in ("permit_all", "deny_all", "no_rule_matched", "undecided"):
616
688
  listed = sorted(e for e, d in by_endpoint.items() if d == key)
617
689
  if listed:
@@ -682,7 +754,7 @@ def _posture(
682
754
  payload["endpoints"] = endpoint_projection(cir, payload["security"])
683
755
  # Which chain is wired is a fact about a bean; what that chain permits is the
684
756
  # fact about the request, and it is the one a reviewer asked for.
685
- access, by_endpoint = access_projection(cir, resolution, root)
757
+ access, by_endpoint = access_projection(cir, resolution, root, resolved_properties)
686
758
  payload["endpoints"]["effective_access"] = access
687
759
  # A requested profile no repository artefact mentions is almost always a typo,
688
760
  # and silently resolving every `@Profile` against it would answer confidently