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 +1 -1
- sourcecode/caller_reach.py +16 -7
- sourcecode/canonical_ir.py +29 -0
- sourcecode/cli.py +235 -3
- sourcecode/contract_diff.py +5 -4
- sourcecode/environment_resolution.py +100 -6
- sourcecode/facts/registry.json +15 -0
- sourcecode/git_checkout.py +5 -2
- sourcecode/non_coverage.py +25 -0
- sourcecode/posture.py +393 -7
- sourcecode/remedies.py +18 -1
- sourcecode/risk.py +73 -15
- sourcecode/spring_impact.py +14 -3
- sourcecode/verify_repo.py +5 -0
- {sourcecode-4.1.0.dist-info → sourcecode-4.2.0.dist-info}/METADATA +15 -1
- {sourcecode-4.1.0.dist-info → sourcecode-4.2.0.dist-info}/RECORD +19 -19
- {sourcecode-4.1.0.dist-info → sourcecode-4.2.0.dist-info}/WHEEL +0 -0
- {sourcecode-4.1.0.dist-info → sourcecode-4.2.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.1.0.dist-info → sourcecode-4.2.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/caller_reach.py
CHANGED
|
@@ -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)``.
|
|
134
|
-
depth 1 — the callers of the seeds themselves; ``indirect`` is
|
|
135
|
-
beyond, up to *max_depth*. ``was_truncated`` says the hub-class
|
|
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
|
-
|
|
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
|
sourcecode/canonical_ir.py
CHANGED
|
@@ -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
|
|
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 ───────────────────────────────────────────────
|
sourcecode/contract_diff.py
CHANGED
|
@@ -140,16 +140,17 @@ def diff_contract(
|
|
|
140
140
|
else:
|
|
141
141
|
breaking.append(f)
|
|
142
142
|
|
|
143
|
-
# --- endpoints (keyed by
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
sourcecode/facts/registry.json
CHANGED
|
@@ -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
|
}
|
sourcecode/git_checkout.py
CHANGED
|
@@ -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
|
|