sourcecode 4.1.0__py3-none-any.whl → 4.2.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 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__ = "4.1.0"
7
+ __version__ = "4.2.0"
@@ -127,13 +127,21 @@ def bfs_callers(
127
127
  max_depth: int,
128
128
  impl_graph: object | None = None,
129
129
  method_scoped: bool = False,
130
- ) -> tuple[list[str], list[str], bool, int]:
130
+ ) -> tuple[list[str], list[str], bool, int, int]:
131
131
  """Walk the reverse graph from *seed_fqns*.
132
132
 
133
- Returns ``(direct, indirect, was_truncated, self_excluded)``. ``direct`` is
134
- depth 1 — the callers of the seeds themselves; ``indirect`` is depth 2 and
135
- beyond, up to *max_depth*. ``was_truncated`` says the hub-class guard capped
136
- the walk, which makes the result a floor.
133
+ Returns ``(direct, indirect, was_truncated, self_excluded, guard_population)``.
134
+ ``direct`` is depth 1 — the callers of the seeds themselves; ``indirect`` is
135
+ depth 2 and beyond, up to *max_depth*. ``was_truncated`` says the hub-class
136
+ guard capped the walk, which makes the result a floor.
137
+
138
+ ``guard_population`` is the number the guard actually read: unique caller
139
+ **reference sites**, method-level keys included, before anything is
140
+ normalised to a class. It is returned rather than left implicit because the
141
+ figure the payload publishes is the class count, and a message that names
142
+ the cap without naming its own population contradicts the payload beside it —
143
+ measured on openmrs-core, *"symbol has > 500 direct callers"* printed next to
144
+ `direct_caller_count: 215` (C2-21).
137
145
 
138
146
  A class's own members are members, not external callers, so they are dropped
139
147
  and counted (BUG #2 — leaving them in inflated a blast radius ~12×). For a
@@ -170,7 +178,8 @@ def bfs_callers(
170
178
  if etype not in SKIP_EDGE_TYPES:
171
179
  unique_direct.update(c for c in fqn_list if not _is_self_reference(c))
172
180
 
173
- effective_depth = 1 if len(unique_direct) > CALLER_CAP else max_depth
181
+ guard_population = len(unique_direct)
182
+ effective_depth = 1 if guard_population > CALLER_CAP else max_depth
174
183
  if effective_depth < max_depth:
175
184
  was_truncated = True
176
185
 
@@ -202,4 +211,4 @@ def bfs_callers(
202
211
  else:
203
212
  _add_caller(caller, depth)
204
213
 
205
- return direct, indirect, was_truncated, self_excluded
214
+ return direct, indirect, was_truncated, self_excluded, guard_population
@@ -115,6 +115,30 @@ class CanonicalSecurity:
115
115
  # CanonicalEndpoint
116
116
  # ---------------------------------------------------------------------------
117
117
 
118
+ def request_identity(method: str, path: str) -> str:
119
+ """The identity of the **request**, not of the handler that serves it.
120
+
121
+ An endpoint `id` identifies a handler: it carries the controller FQN and the
122
+ handler symbol, so moving a mapping to another class produces a different
123
+ `id` for the same URL. That is the right identity inside one tree and the
124
+ wrong one across two — a comparison keyed on it reports every refactored
125
+ handler as one endpoint removed and another added, and the reader asking
126
+ "did this change what a caller can reach" has to subtract the noise by hand.
127
+
128
+ Two states of one repository — `contract-diff` between refs, `posture
129
+ --diff-ref` — compare what a client can call, which is METHOD + path. One
130
+ authority for it, because two spellings of one identity is how two commands
131
+ come to disagree about the same endpoint (ADR-0008 R11).
132
+
133
+ Note what this deliberately does *not* do: it does not normalise path
134
+ variable names or inline regexes. `/{id:.*}` and `/{id:[^0-9].*}` are
135
+ different URLs to a client, and equating them would hide a real change in
136
+ the surface — measured on a fleet repository where exactly that edit moved
137
+ 93 requests.
138
+ """
139
+ return f"{str(method).upper()} {path}"
140
+
141
+
118
142
  @dataclass
119
143
  class CanonicalEndpoint:
120
144
  """Canonical endpoint entity — single source of truth for REST endpoint data.
@@ -159,6 +183,11 @@ class CanonicalEndpoint:
159
183
  """Deterministic endpoint ID — stable across formatting/body changes."""
160
184
  return f"{method}:{path}:{controller_class}:{handler_symbol}"
161
185
 
186
+ @property
187
+ def request_key(self) -> str:
188
+ """This endpoint's request identity — see `request_identity`."""
189
+ return request_identity(self.method, self.path)
190
+
162
191
 
163
192
  # ---------------------------------------------------------------------------
164
193
  # CanonicalRepositoryIR
sourcecode/cli.py CHANGED
@@ -357,6 +357,7 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
357
357
  impact-chain <Class> . [dim]# blast radius w/ TX + security per hop[/dim]
358
358
  impact <Class> . [dim]# reverse deps → endpoints reached[/dim]
359
359
  pr-impact . --files - [dim]# same, scoped to a diff on stdin; gating codes[/dim]
360
+ posture . --since main [dim]# did this branch open an endpoint? (exp.)[/dim]
360
361
  verify . [dim]# contract gate, baseline-relative[/dim]
361
362
  verify-edit . [dim]# did working-tree edits change behaviour?[/dim]
362
363
  [dim]modernize · explain <Class> · validation · export · repo-ir[/dim]
@@ -4726,6 +4727,71 @@ def impact_cmd(
4726
4727
  # canonical single-source-of-truth endpoint extractor.
4727
4728
 
4728
4729
 
4730
+ #: How many rows each `endpoints --compact` rollup lists. The counts beside them are
4731
+ #: measured over the whole population, never over what survives this cut (R5-R8).
4732
+ _ENDPOINT_ROLLUP_CAP = 25
4733
+
4734
+
4735
+ def _endpoint_exposure_summary(endpoints: "list[dict]") -> dict:
4736
+ """How exposed this repository is, without listing every endpoint (C2-22).
4737
+
4738
+ Field evaluation #9 wanted the answer and got 1.9 MB of JSON for 3 574
4739
+ endpoints, then aggregated it by hand in PowerShell. The list is the evidence;
4740
+ it is not the answer, and a command whose product is a census should be able
4741
+ to publish the census.
4742
+
4743
+ Every figure here is counted over the **full** population that reached this
4744
+ function. The rollups are ranked and cut, and each states its own total, so
4745
+ nobody derives a count from the rows they were handed — the mechanism by which
4746
+ a truncated list becomes a wrong number (C2-1, C2-20).
4747
+ """
4748
+ from collections import Counter
4749
+
4750
+ from sourcecode.degradation import cap_effect
4751
+
4752
+ by_method = Counter(str(e.get("method") or "UNKNOWN").upper() for e in endpoints)
4753
+ by_policy = Counter(
4754
+ str((e.get("security") or {}).get("policy") or "none_detected") for e in endpoints
4755
+ )
4756
+ by_controller = Counter(
4757
+ str(e.get("controller_class") or e.get("controller") or "unknown") for e in endpoints
4758
+ )
4759
+ # The first path segment: the coarsest grouping a reader can act on, and the
4760
+ # one that answers "which area of the API is open" without 3 574 rows.
4761
+ by_area = Counter(
4762
+ ("/" + str(e.get("path") or "").lstrip("/").split("/")[0]) if e.get("path") else "/"
4763
+ for e in endpoints
4764
+ )
4765
+
4766
+ def _rollup(counter: "Counter[str]", name: str) -> dict:
4767
+ ranked = counter.most_common()
4768
+ block: dict = {
4769
+ "distinct": len(ranked),
4770
+ "listed": [{name: key, "endpoints": count} for key, count in ranked[:_ENDPOINT_ROLLUP_CAP]],
4771
+ }
4772
+ if len(ranked) > _ENDPOINT_ROLLUP_CAP:
4773
+ block["cap"] = {
4774
+ "total": len(ranked),
4775
+ "shown": _ENDPOINT_ROLLUP_CAP,
4776
+ "omitted": len(ranked) - _ENDPOINT_ROLLUP_CAP,
4777
+ **cap_effect("display_list", limit=_ENDPOINT_ROLLUP_CAP, total=len(ranked)),
4778
+ }
4779
+ return block
4780
+
4781
+ return {
4782
+ "endpoints": len(endpoints),
4783
+ "by_method": dict(sorted(by_method.items())),
4784
+ "by_security_policy": dict(sorted(by_policy.items())),
4785
+ "by_controller": _rollup(by_controller, "controller"),
4786
+ "by_path_area": _rollup(by_area, "area"),
4787
+ "basis": (
4788
+ "every count is measured over all "
4789
+ f"{len(endpoints)} endpoints in this answer; the rollups below are "
4790
+ "ranked and cut, and each states its own total"
4791
+ ),
4792
+ }
4793
+
4794
+
4729
4795
  def _group_endpoints_by_controller(endpoints: "list[dict]") -> "dict":
4730
4796
  """Group endpoints by their controller FQN into a structured API surface.
4731
4797
 
@@ -4791,6 +4857,12 @@ def endpoints_cmd(
4791
4857
  None, "--limit", "-n",
4792
4858
  help="Maximum number of endpoints to return.",
4793
4859
  ),
4860
+ compact: bool = typer.Option(
4861
+ False, "--compact",
4862
+ help="Answer how exposed the repository is without listing every endpoint: "
4863
+ "counts by method, by security policy and by controller. Every count is "
4864
+ "measured over the full population.",
4865
+ ),
4794
4866
  no_cache: bool = typer.Option(
4795
4867
  False, "--no-cache",
4796
4868
  help=_NO_CACHE_SUBCOMMAND_HELP,
@@ -4828,6 +4900,7 @@ def endpoints_cmd(
4828
4900
  ask endpoints . --path-prefix /v1/liquidacion
4829
4901
  ask endpoints . --controller LiquidacionJornada
4830
4902
  ask endpoints . --limit 10
4903
+ ask endpoints . --compact
4831
4904
  """
4832
4905
  _enforce_format("endpoints", format)
4833
4906
 
@@ -4894,6 +4967,41 @@ def endpoints_cmd(
4894
4967
  data["by_controller"] = _grouped["by_controller"]
4895
4968
  data["controller_count"] = _grouped["controller_count"]
4896
4969
 
4970
+ # C2-22. The census, and — when asked for — the census *instead of* the list:
4971
+ # the product of this command for a reader asking "how exposed is this
4972
+ # repository" is the aggregate, and 1.9 MB of rows is the evidence for it.
4973
+ # `_selected` is the population the filters chose, before `--limit` cut the
4974
+ # rendering: counting the rendered list here would be the defect this block
4975
+ # exists to remove, one line into its own implementation (R5-R8).
4976
+ data["exposure"] = _endpoint_exposure_summary(_selected)
4977
+ if compact:
4978
+ from sourcecode.degradation import cap_effect
4979
+
4980
+ _listed = len(data.get("endpoints", []))
4981
+ data["endpoints"] = []
4982
+ data["endpoints_omitted"] = {
4983
+ "total": _listed,
4984
+ "shown": 0,
4985
+ "omitted": _listed,
4986
+ **cap_effect("display_list", limit=0, total=_listed),
4987
+ "note": (
4988
+ "--compact answers with the census; every count in this document "
4989
+ "was measured before the list was dropped. Re-run without --compact, "
4990
+ "or narrow with --path-prefix / --controller / --limit, for the rows."
4991
+ ),
4992
+ }
4993
+
4994
+ # CL-12 / NC-007. The population here is Spring handler mappings, and six
4995
+ # published non-coverage rows are exactly what lets a reader treat this list
4996
+ # as the whole HTTP surface. A servlet mounted in `web.xml` is reachable and
4997
+ # is not in this model — field evaluation #9 lost a monitoring console that
4998
+ # way, at a path behind a credential the same run reported.
4999
+ from sourcecode import non_coverage as _non_coverage
5000
+
5001
+ _endpoint_limits = _non_coverage.block("endpoints")
5002
+ if _endpoint_limits:
5003
+ data["non_coverage"] = _endpoint_limits
5004
+
4897
5005
  output = _serialize_dict(data, format)
4898
5006
 
4899
5007
  _emit_command_output(output, output_path, copy,
@@ -6935,6 +7043,12 @@ def risk_cmd(
6935
7043
  from, so a reader can disagree with one and keep the rest. An axis that could
6936
7044
  not be measured is `unknown`, multiplies by 1.0, and is named in `blind_axes`.
6937
7045
 
7046
+ \b
7047
+ One row is one **defect** — the same identity `spring-audit` groups by, so the
7048
+ two commands report the same count. The observations behind it travel with it
7049
+ as `witness_count` and `witness_sites`, and `total_findings` publishes the
7050
+ observation census under its own name.
7051
+
6938
7052
  \b
6939
7053
  Examples:
6940
7054
  ask risk .
@@ -6981,6 +7095,24 @@ def posture_cmd(
6981
7095
  "--diff",
6982
7096
  help="Compare two profile sets: --diff dev:prod (each side comma-separated).",
6983
7097
  ),
7098
+ diff_ref: Optional[str] = typer.Option(
7099
+ None,
7100
+ "--diff-ref",
7101
+ help="Compare two git refs under ONE profile set: --diff-ref origin/main:HEAD "
7102
+ "(read with git archive; the repository is not written to).",
7103
+ ),
7104
+ since: Optional[str] = typer.Option(
7105
+ None,
7106
+ "--since",
7107
+ help="Compare a git ref against the working tree, uncommitted edits included: "
7108
+ "--since origin/main.",
7109
+ ),
7110
+ fail_on: str = typer.Option(
7111
+ "never",
7112
+ "--fail-on",
7113
+ help="Exit 1 when a ref comparison finds: opened (requests newly reachable "
7114
+ "unauthenticated) | any (any access change) | never (default).",
7115
+ ),
6984
7116
  resolve_environments_flag: bool = typer.Option(
6985
7117
  False,
6986
7118
  "--resolve-environments",
@@ -7021,15 +7153,29 @@ def posture_cmd(
7021
7153
  property value or a condition class is never assumed active and never
7022
7154
  assumed absent — a posture answer that guesses is a confident falsehood.
7023
7155
 
7156
+ \b
7157
+ Two axes, and they are different questions. `--diff` holds the tree fixed
7158
+ and moves the profile set. `--diff-ref` (and `--since`) hold the profile set
7159
+ fixed and move the tree: *does this change what a client can reach without
7160
+ authenticating?* That one takes `--fail-on` and is the per-PR gate.
7161
+
7024
7162
  \b
7025
7163
  Examples:
7026
7164
  ask posture . --profile prod
7027
7165
  ask posture . --profile prod,metrics
7028
7166
  ask posture . --diff dev:prod
7167
+ ask posture . --profile prod --diff-ref origin/main:HEAD
7168
+ ask posture . --since origin/main --fail-on opened
7029
7169
  ask posture . --profile prod --property app.security.enabled=true
7030
7170
  ask posture . --resolve-environments
7031
7171
  """
7032
- from sourcecode.posture import build_posture, diff_posture, resolve_environments
7172
+ from sourcecode.posture import (
7173
+ FAIL_ON_CHOICES,
7174
+ build_posture,
7175
+ diff_posture,
7176
+ diff_posture_refs,
7177
+ resolve_environments,
7178
+ )
7033
7179
 
7034
7180
  # A repository that could not be read has an unknown posture, not a posture
7035
7181
  # of zero beans (R9/I-3: never `0` where the honest answer is `unknown`).
@@ -7059,16 +7205,96 @@ def posture_cmd(
7059
7205
  raise typer.Exit(code=1)
7060
7206
  overrides[key.strip()] = value.strip()
7061
7207
 
7208
+ if fail_on not in FAIL_ON_CHOICES:
7209
+ _emit_error_json(
7210
+ INVALID_INPUT_CODE,
7211
+ f"--fail-on expects {' | '.join(FAIL_ON_CHOICES)} (got {fail_on!r}).",
7212
+ hint="Example: --since origin/main --fail-on opened",
7213
+ expected="|".join(FAIL_ON_CHOICES),
7214
+ )
7215
+ raise typer.Exit(code=1)
7216
+ if diff_ref and since:
7217
+ _emit_error_json(
7218
+ INVALID_INPUT_CODE,
7219
+ "--diff-ref names both states; --since names one and compares it with the "
7220
+ "working tree. Pass one of them.",
7221
+ hint="ask posture . --diff-ref origin/main:HEAD | ask posture . --since origin/main",
7222
+ )
7223
+ raise typer.Exit(code=1)
7224
+ ref_comparison = bool(diff_ref or since)
7225
+ if fail_on != "never" and not ref_comparison:
7226
+ # A gate needs two states to compare. Failing on a single posture run would
7227
+ # have to gate on pre-existing debt, which is the absolute-gate confusion
7228
+ # `spring-audit --ci` already documents.
7229
+ _emit_error_json(
7230
+ INVALID_INPUT_CODE,
7231
+ "--fail-on gates a comparison between two trees; a single posture run has "
7232
+ "nothing to compare against.",
7233
+ hint="ask posture . --since origin/main --fail-on opened",
7234
+ )
7235
+ raise typer.Exit(code=1)
7236
+ if ref_comparison and diff:
7237
+ _emit_error_json(
7238
+ INVALID_INPUT_CODE,
7239
+ "--diff moves the profile set and --diff-ref/--since move the tree. Asking "
7240
+ "for both at once names four states and answers about none of them: pass "
7241
+ "--profile with --diff-ref, or --diff on its own.",
7242
+ hint="ask posture . --profile prod --diff-ref origin/main:HEAD",
7243
+ )
7244
+ raise typer.Exit(code=1)
7245
+
7062
7246
  if resolve_environments_flag:
7063
- if diff or profile:
7247
+ if diff or profile or ref_comparison:
7064
7248
  _emit_error_json(
7065
7249
  INVALID_INPUT_CODE,
7066
7250
  "--resolve-environments answers which profile set runs; it cannot be "
7067
- "combined with --profile or --diff, which state one.",
7251
+ "combined with --profile, --diff or --diff-ref/--since, which state one.",
7068
7252
  hint="Run `ask posture . --resolve-environments` on its own.",
7069
7253
  )
7070
7254
  raise typer.Exit(code=1)
7071
7255
  data = resolve_environments(path, overrides)
7256
+ elif ref_comparison:
7257
+ from sourcecode.git_checkout import RefError
7258
+
7259
+ base_ref, head_ref = (since, None)
7260
+ if diff_ref:
7261
+ if ":" not in diff_ref:
7262
+ _emit_error_json(
7263
+ INVALID_INPUT_CODE,
7264
+ f"--diff-ref needs two refs separated by ':' (got {diff_ref!r}).",
7265
+ hint="Example: --diff-ref origin/main:HEAD",
7266
+ expected="base:head",
7267
+ )
7268
+ raise typer.Exit(code=1)
7269
+ base_ref, head_ref = diff_ref.split(":", 1)
7270
+ if not (base_ref or "").strip() or (head_ref is not None and not head_ref.strip()):
7271
+ _emit_error_json(
7272
+ INVALID_INPUT_CODE,
7273
+ "A ref cannot be empty: both sides of --diff-ref name a commit.",
7274
+ hint="Example: --diff-ref origin/main:HEAD",
7275
+ expected="base:head",
7276
+ )
7277
+ raise typer.Exit(code=1)
7278
+ try:
7279
+ data = diff_posture_refs(
7280
+ path,
7281
+ _parse_set(profile) if profile else set(),
7282
+ base_ref.strip(),
7283
+ head_ref.strip() if head_ref is not None else None,
7284
+ overrides,
7285
+ fail_on=fail_on,
7286
+ )
7287
+ except RefError as exc:
7288
+ # Never borrowed from a parent or a remote: the answer names the ref
7289
+ # that failed, and nothing was fetched (C3-27).
7290
+ _emit_error_json(
7291
+ exc.code, exc.message, ref=exc.ref,
7292
+ hint=(
7293
+ "ask posture <repo> --since <ref>" if since
7294
+ else "ask posture <repo> --diff-ref <base>:<head>"
7295
+ ),
7296
+ )
7297
+ raise typer.Exit(code=1)
7072
7298
  elif diff:
7073
7299
  if ":" not in diff:
7074
7300
  _emit_error_json(
@@ -7089,6 +7315,12 @@ def posture_cmd(
7089
7315
  False,
7090
7316
  success_msg=f"posture written to {output_path}",
7091
7317
  )
7318
+ # The payload is emitted first and the exit code follows it: a gate that
7319
+ # exits before answering leaves the reviewer with a red pipeline and no
7320
+ # statement of what opened.
7321
+ gate = data.get("gate") if isinstance(data, dict) else None
7322
+ if isinstance(gate, dict) and gate.get("triggered"):
7323
+ raise typer.Exit(code=int(gate.get("exit_code") or 1))
7092
7324
 
7093
7325
 
7094
7326
  # ── Spring Boot Migration Check ───────────────────────────────────────────────
@@ -140,16 +140,17 @@ def diff_contract(
140
140
  else:
141
141
  breaking.append(f)
142
142
 
143
- # --- endpoints (keyed by METHOD path) ---
143
+ # --- endpoints (keyed by the request identity, see `request_identity`) ---
144
+ from sourcecode.canonical_ir import request_identity as ident
144
145
  b_eps, h_eps = base.endpoints, head.endpoints
145
146
  for key in b_eps.keys() - h_eps.keys():
146
- breaking.append(_finding("endpoint", f"{key[0]} {key[1]}", "removed", "", "", "breaking"))
147
+ breaking.append(_finding("endpoint", ident(*key), "removed", "", "", "breaking"))
147
148
  for key in h_eps.keys() - b_eps.keys():
148
- additive.append(_finding("endpoint", f"{key[0]} {key[1]}", "added", "", "", "additive"))
149
+ additive.append(_finding("endpoint", ident(*key), "added", "", "", "additive"))
149
150
  for key in b_eps.keys() & h_eps.keys():
150
151
  if b_eps[key] != h_eps[key]:
151
152
  breaking.append(_finding(
152
- "endpoint", f"{key[0]} {key[1]}", "signature_changed",
153
+ "endpoint", ident(*key), "signature_changed",
153
154
  b_eps[key], h_eps[key], "breaking",
154
155
  ))
155
156
 
@@ -50,6 +50,19 @@ _PROPERTY_KEY = "spring.profiles.active"
50
50
  _ENV_KEY = "SPRING_PROFILES_ACTIVE"
51
51
  _PLACEHOLDER = re.compile(r"\$\{[^}]*\}|@[\w.]+@")
52
52
 
53
+ #: What a *consumer* of the build property looks like (C1-22). A Maven
54
+ #: `<properties>` entry is a build-time value and nothing else: it reaches a
55
+ #: running application only if a resource the build filters carries a token that
56
+ #: substitutes it. `@key@` is the Spring Boot parent's filtering delimiter and
57
+ #: means nothing else, so it counts on its own; `${key}` is ambiguous — in a
58
+ #: config file it is usually Spring's own placeholder syntax resolved at run time
59
+ #: — so it counts only where the build declares the resources filtered.
60
+ _MAVEN_FILTER_TOKEN = f"@{_PROPERTY_KEY}@"
61
+ _SPRING_REFERENCE = "${" + _PROPERTY_KEY + "}"
62
+ _FILTERING_DECLARED = re.compile(
63
+ r"<filtering>\s*true\s*</filtering>|filesMatching|expand\s*\(", re.IGNORECASE
64
+ )
65
+
53
66
  _MAX_FILES = 20_000
54
67
  _MAX_BYTES = 2_000_000
55
68
 
@@ -69,9 +82,16 @@ class Signal:
69
82
  raw: str
70
83
  value: Optional[str] # None when the artefact defers to something else
71
84
  note: str
85
+ #: Whether this artefact's value reaches a *running* application. False for a
86
+ #: build property no filtered resource consumes: it states a value, and the
87
+ #: value stops at the build (C1-22). The value is kept rather than nulled —
88
+ #: it is still the set a build of that profile would produce, which is what
89
+ #: makes it a candidate worth ranking.
90
+ activates: bool = True
91
+ why_not: str = ""
72
92
 
73
93
  def to_dict(self) -> dict:
74
- return {
94
+ out = {
75
95
  "kind": self.kind,
76
96
  "file": self.file,
77
97
  "line": self.line,
@@ -79,6 +99,10 @@ class Signal:
79
99
  "value": self.value,
80
100
  "note": self.note,
81
101
  }
102
+ if not self.activates:
103
+ out["activates_at_runtime"] = False
104
+ out["why_not"] = self.why_not
105
+ return out
82
106
 
83
107
 
84
108
  @dataclass
@@ -99,6 +123,12 @@ class EnvironmentResolution:
99
123
  "profile_set_if_decided": list(self.values) if self.verdict == DECIDED else None,
100
124
  "signals": [s.to_dict() for s in self.signals],
101
125
  "deferred": [s.to_dict() for s in self.deferred],
126
+ # C1-22, published as a count a gate can read: a build value that
127
+ # reaches nothing is the difference between "two deployments, pick
128
+ # one" and "nobody decided, so the permissive default runs".
129
+ "build_properties_not_consumed": sum(
130
+ 1 for s in self.deferred if s.value and not s.activates
131
+ ),
102
132
  "note": (
103
133
  "A value found here is the default this repository ships, not a "
104
134
  "guarantee: SPRING_PROFILES_ACTIVE in the process environment "
@@ -193,9 +223,15 @@ def _clean(raw: str) -> str:
193
223
 
194
224
 
195
225
  def collect_signals(root: Path) -> list[Signal]:
196
- """Every artefact in *root* that names the active profile set, with evidence."""
226
+ """Every artefact in *root* that names the active profile set, with evidence.
227
+
228
+ A build property that no filtered resource consumes is collected, kept, and
229
+ marked as not activating (C1-22): it states a value the build has, not one
230
+ the application starts with.
231
+ """
197
232
  root = Path(root).resolve()
198
233
  signals: list[Signal] = []
234
+ filtering_declared = False
199
235
  for path in _iter_files(root):
200
236
  kind = _classify(path)
201
237
  if kind is None:
@@ -206,6 +242,8 @@ def collect_signals(root: Path) -> list[Signal]:
206
242
  text = path.read_text(encoding="utf-8", errors="ignore")
207
243
  except OSError:
208
244
  continue
245
+ if kind == "build_property" and _FILTERING_DECLARED.search(text):
246
+ filtering_declared = True
209
247
  if _PROPERTY_KEY not in text and _ENV_KEY not in text:
210
248
  continue
211
249
  try:
@@ -234,7 +272,49 @@ def collect_signals(root: Path) -> list[Signal]:
234
272
  signals.append(_signal(
235
273
  kind, rel, index, line, _next_param_value(lines, index),
236
274
  ))
237
- return signals
275
+ return _mark_unconsumed_build_properties(signals, filtering_declared)
276
+
277
+
278
+ def _consumes_build_property(signal: Signal, filtering_declared: bool) -> bool:
279
+ """Whether this artefact hands the *build* property to the running application.
280
+
281
+ The evidence is already collected: a resource carrying `@key@` reads here as
282
+ a placeholder signal, which is exactly what a consumer looks like.
283
+ """
284
+ if signal.kind == "build_property":
285
+ return False # a build file cannot be its own consumer
286
+ if _MAVEN_FILTER_TOKEN in signal.raw:
287
+ return True
288
+ return filtering_declared and _SPRING_REFERENCE in signal.raw
289
+
290
+
291
+ def _mark_unconsumed_build_properties(
292
+ signals: list[Signal], filtering_declared: bool
293
+ ) -> list[Signal]:
294
+ """C1-22. A `<properties>` entry is a build value; without a filtered resource
295
+ referencing it, no profile is active at run time and the honest verdict is that
296
+ nothing here decides — which is a *fail-open*, not a choice between deployments.
297
+
298
+ Field evaluation #9 measured the cost of not doing this: two Maven profiles
299
+ setting the property produced `artefacts_disagree` (*"different deployments,
300
+ pick one"*) on a repository where nothing activated a profile at all, so the
301
+ permissive default ran and the report read as an ambiguity to resolve rather
302
+ than as the exposure it was.
303
+ """
304
+ if any(_consumes_build_property(s, filtering_declared) for s in signals):
305
+ return signals
306
+ return [
307
+ s if not (s.kind == "build_property" and s.value) else Signal(
308
+ s.kind, s.file, s.line, s.raw, s.value, s.note,
309
+ activates=False,
310
+ why_not=(
311
+ f"sets {_PROPERTY_KEY!r} as a build property; no resource in this "
312
+ f"repository references it ('{_MAVEN_FILTER_TOKEN}'), so the value "
313
+ "stops at the build and no profile is active because of it"
314
+ ),
315
+ )
316
+ for s in signals
317
+ ]
238
318
 
239
319
 
240
320
  def _next_k8s_value(lines: list[str], after: int) -> Optional[str]:
@@ -274,8 +354,9 @@ def _signal(kind: str, rel: str, line: int, raw: str, value: Optional[str]) -> S
274
354
  def resolve_environment(root: Path) -> EnvironmentResolution:
275
355
  """What decides the active profile set in *root* — or the fact that nothing does."""
276
356
  signals = collect_signals(root)
277
- deciding = [s for s in signals if s.value]
278
- deferred = [s for s in signals if not s.value]
357
+ deciding = [s for s in signals if s.value and s.activates]
358
+ deferred = [s for s in signals if not (s.value and s.activates)]
359
+ inert = [s for s in signals if s.value and not s.activates]
279
360
 
280
361
  if not deciding:
281
362
  return EnvironmentResolution(
@@ -284,6 +365,16 @@ def resolve_environment(root: Path) -> EnvironmentResolution:
284
365
  statement=(
285
366
  "Your security configuration is not decided by this repository. "
286
367
  + (
368
+ # The sentence a field evaluator wrote by hand after ASK
369
+ # published `artefacts_disagree` over exactly this shape.
370
+ f"{len(inert)} build propert{'y' if len(inert) == 1 else 'ies'} "
371
+ f"set `{_PROPERTY_KEY}` "
372
+ f"({', '.join(sorted({repr(s.value) for s in inert}))}), and no "
373
+ "resource here consumes them, so they set nothing at run time. "
374
+ "The profile set is whatever the runtime is given — and with "
375
+ "none given, Spring activates `default` alone, which is the "
376
+ "permissive case, not a neutral one."
377
+ if inert else
287
378
  f"{len(deferred)} artefact(s) name the setting and none of them "
288
379
  "supplies a value: it comes from the process environment at "
289
380
  "start-up."
@@ -345,7 +436,10 @@ def candidate_profile_sets(
345
436
  if resolution.verdict == DECIDED:
346
437
  ordered.append(resolution.values)
347
438
  ordered.append(()) # `default` alone — the no-variable case
348
- for signal in resolution.signals:
439
+ # Both lists: a build property that activates nothing is still the profile set
440
+ # *a build of it* would produce, so it stays a candidate worth ranking. C1-22
441
+ # corrects the verdict; it must not narrow the worst case.
442
+ for signal in list(resolution.signals) + list(resolution.deferred):
349
443
  if signal.value:
350
444
  candidate = _split_profiles(signal.value)
351
445
  if candidate not in ordered:
@@ -185,6 +185,21 @@
185
185
  "was": "Two traversals. `impact` looked up the exact reverse-graph key, so the walk died at every class-level node — which is what a DI injects edge normalises to — and it admitted implements/extends as caller edges under a comment claiming its edge set was consistent with the other's. On BroadleafCommerce's Money it reached 241 classes and no controller at all, publishing endpoints_affected_count: 0 beside 21 endpoints from impact-chain for the same symbol at the same depth.",
186
186
  "unresolved_answer": null,
187
187
  "unresolved_note": "Reach over the analysed graph is decidable: an edge is present or it is not. What the graph could not resolve (reflective dispatch, configuration-driven wiring) is stated as analysis scope and as a hub-guard truncation flag, which makes the answer a floor rather than an unknown."
188
+ },
189
+ {
190
+ "fact": "endpoint_request_identity",
191
+ "authority": "sourcecode.canonical_ir:request_identity",
192
+ "definition": "The identity of an HTTP request across two states of one repository: METHOD plus the mapping path, with no normalisation of path-variable names or inline regexes. It is deliberately not the endpoint id, which carries the controller FQN and the handler symbol and therefore changes when a handler moves without the URL changing.",
193
+ "stats_key": "base_requests",
194
+ "consumers": [
195
+ "sourcecode.contract_diff",
196
+ "sourcecode.posture"
197
+ ],
198
+ "parity_test": "tests/test_posture_diff_ref.py",
199
+ "seam": "M10.5 item 1 (posture --diff-ref)",
200
+ "was": "Each command comparing two trees spelled the identity itself: `contract_diff` built a (METHOD, path) tuple and rendered it, and a second consumer keyed on the endpoint id, which reports a handler moved between controllers as one request removed and another added. Two spellings of one identity is how two commands come to disagree about the same endpoint.",
201
+ "unresolved_answer": null,
202
+ "unresolved_note": "A mapping always has a method and a path, so the identity itself is never undecided. What can be undecided is the access decision attached to it, and where two mappings share one request identity and disagree, the request is reported `undecided` rather than resolved by picking one."
188
203
  }
189
204
  ]
190
205
  }
@@ -68,10 +68,13 @@ def repo_root(path: Path) -> Path:
68
68
  """
69
69
  result = _git(path, "rev-parse", "--show-toplevel")
70
70
  if result.returncode != 0 or not result.stdout.strip():
71
+ # The fact only. What to do instead differs per command — `delta` takes
72
+ # two checkouts, `posture --since` takes none — so the remedy is added by
73
+ # the caller through its own `hint`, and this message stopped naming one
74
+ # that half its callers do not offer.
71
75
  raise RefError(
72
76
  NOT_A_REPOSITORY,
73
- f"{path} is not inside a git repository, so a ref cannot be resolved "
74
- "there. Pass two checkouts positionally instead.",
77
+ f"{path} is not inside a git repository, so a ref cannot be resolved there.",
75
78
  )
76
79
  return Path(result.stdout.strip())
77
80