sourcecode 4.1.0__py3-none-any.whl → 4.3.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.
- sourcecode/__init__.py +1 -1
- sourcecode/architectural_baseline.py +19 -0
- sourcecode/baseline_autocapture.py +375 -0
- sourcecode/cache_model.py +19 -0
- sourcecode/caller_reach.py +16 -7
- sourcecode/canonical_ir.py +29 -0
- sourcecode/cli.py +787 -26
- sourcecode/client_calls.py +305 -0
- sourcecode/context_graph.py +11 -0
- sourcecode/contract_diff.py +5 -4
- sourcecode/data_exposure.py +353 -0
- sourcecode/data_labels.py +200 -0
- sourcecode/dynamic_argument_surface.py +12 -5
- sourcecode/environment_resolution.py +100 -6
- sourcecode/facts/registry.json +15 -0
- sourcecode/format_contract.py +1 -0
- sourcecode/git_checkout.py +5 -2
- sourcecode/non_coverage.py +70 -0
- sourcecode/openrewrite_recipe.py +278 -0
- sourcecode/posture.py +425 -7
- sourcecode/remedies.py +18 -1
- sourcecode/repository_ir.py +24 -3
- sourcecode/risk.py +507 -88
- sourcecode/sarif.py +618 -0
- sourcecode/spring_impact.py +14 -3
- sourcecode/validation_inference.py +33 -4
- sourcecode/verify_repo.py +22 -2
- {sourcecode-4.1.0.dist-info → sourcecode-4.3.0.dist-info}/METADATA +41 -8
- {sourcecode-4.1.0.dist-info → sourcecode-4.3.0.dist-info}/RECORD +32 -26
- {sourcecode-4.1.0.dist-info → sourcecode-4.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-4.1.0.dist-info → sourcecode-4.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.1.0.dist-info → sourcecode-4.3.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -312,6 +312,25 @@ def load_baseline(path: Path) -> dict:
|
|
|
312
312
|
return data
|
|
313
313
|
|
|
314
314
|
|
|
315
|
+
def latest_baseline_path(directory: Path) -> "Path | None":
|
|
316
|
+
"""The most recently captured baseline in `directory`, or None if there is none.
|
|
317
|
+
|
|
318
|
+
Ordered exactly as `load_baselines_dir` orders its series (capture time, commit
|
|
319
|
+
as the tie-break), so "the last point in the trend" and "the base a diff uses by
|
|
320
|
+
default" can never disagree about which artifact that is.
|
|
321
|
+
"""
|
|
322
|
+
dated: list[tuple[str, str, Path]] = []
|
|
323
|
+
for p in sorted(Path(directory).glob("*.json")):
|
|
324
|
+
try:
|
|
325
|
+
b = load_baseline(p)
|
|
326
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
327
|
+
continue
|
|
328
|
+
dated.append((str(b.get("captured_at", "")), str(b.get("commit", "")), p))
|
|
329
|
+
if not dated:
|
|
330
|
+
return None
|
|
331
|
+
return max(dated, key=lambda t: (t[0], t[1]))[2]
|
|
332
|
+
|
|
333
|
+
|
|
315
334
|
def load_baselines_dir(directory: Path) -> list[dict]:
|
|
316
335
|
"""Load every architectural baseline in `directory`, sorted by capture time."""
|
|
317
336
|
out: list[dict] = []
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""baseline_autocapture.py — the half of the architectural history that is not a command.
|
|
2
|
+
|
|
3
|
+
`ask baseline capture` has shipped since D5 and produces a history exactly as long as
|
|
4
|
+
the number of times a human remembered to type it. Field audit #8 named that plainly:
|
|
5
|
+
the artifact a competitor cannot backfill is *three years of a specific monolith's
|
|
6
|
+
architectural baselines*, and **a history that depends on someone remembering to invoke
|
|
7
|
+
it will not exist**. So capture has to be a by-product of a run that was going to happen
|
|
8
|
+
anyway — the CI gate, which already builds the same IR the baseline is extracted from.
|
|
9
|
+
|
|
10
|
+
This module is the single authority for the question *"should this run leave a baseline
|
|
11
|
+
behind, and what happened when it tried?"*. It is deliberately small and conservative,
|
|
12
|
+
because every branch here is a side effect inside somebody else's repository:
|
|
13
|
+
|
|
14
|
+
* **It never starts a history nobody asked for.** A piggybacked run captures only where
|
|
15
|
+
a history already exists (`.ask/baselines/`) or where the operator opted in explicitly
|
|
16
|
+
(`--auto`, `ASK_BASELINE_AUTOCAPTURE=1`). The read-only complaint that `--dir` closed
|
|
17
|
+
is not going to be re-opened by a command that writes on its own initiative.
|
|
18
|
+
* **One commit, one baseline.** The history is keyed by commit; re-capturing a commit
|
|
19
|
+
would silently relabel a point already in the series (and, after a tool upgrade, change
|
|
20
|
+
its metrics under the same key — the comparability contract exists precisely so that
|
|
21
|
+
cannot pass unnoticed). An already-captured commit is a skip, not an overwrite.
|
|
22
|
+
* **A dirty tree is not a commit.** Writing `<commit>.json` from a modified working tree
|
|
23
|
+
attributes measurements to a state that commit never had. Skip and say so.
|
|
24
|
+
* **It never fails the run it rode in on.** A read-only checkout, a full disk, a `.ask`
|
|
25
|
+
owned by another user: the host command's verdict is about the host command's question.
|
|
26
|
+
The capture reports what it could not do; it does not change an exit code.
|
|
27
|
+
|
|
28
|
+
Every outcome is published (`action`, `reason`, `statement`) rather than being silent —
|
|
29
|
+
I-8: a run declares the effect of what it did and did not do, and "no history was written"
|
|
30
|
+
is exactly the kind of non-event that is invisible until someone needs the series.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import subprocess
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import TYPE_CHECKING, Iterable, Optional
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
42
|
+
from sourcecode.canonical_ir import CanonicalRepositoryIR
|
|
43
|
+
|
|
44
|
+
#: Where a history lives when nobody said otherwise — inside the repository, so it
|
|
45
|
+
#: travels with the code (same default as `ask baseline capture`).
|
|
46
|
+
HISTORY_DIRNAME: Path = Path(".ask") / "baselines"
|
|
47
|
+
|
|
48
|
+
#: Explicit operator switch. "1"/"true"/"yes"/"on" enables piggybacked capture even
|
|
49
|
+
#: without an existing history; "0"/"false"/"no"/"off" disables it even with one.
|
|
50
|
+
ENV_FLAG: str = "ASK_BASELINE_AUTOCAPTURE"
|
|
51
|
+
|
|
52
|
+
_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
53
|
+
_FALSE = frozenset({"0", "false", "no", "off"})
|
|
54
|
+
|
|
55
|
+
# Actions.
|
|
56
|
+
CAPTURED: str = "captured"
|
|
57
|
+
SKIPPED: str = "skipped"
|
|
58
|
+
|
|
59
|
+
# Reasons to capture.
|
|
60
|
+
HISTORY_PRESENT: str = "history_present"
|
|
61
|
+
EXPLICITLY_ENABLED: str = "explicitly_enabled"
|
|
62
|
+
|
|
63
|
+
# Reasons to skip.
|
|
64
|
+
NO_HISTORY: str = "no_history"
|
|
65
|
+
DISABLED: str = "disabled"
|
|
66
|
+
NO_COMMIT_IDENTITY: str = "no_commit_identity"
|
|
67
|
+
WORKTREE_DIRTY: str = "worktree_dirty"
|
|
68
|
+
WORKTREE_STATE_UNKNOWN: str = "worktree_state_unknown"
|
|
69
|
+
COMMIT_ALREADY_CAPTURED: str = "commit_already_captured"
|
|
70
|
+
WRITE_FAILED: str = "write_failed"
|
|
71
|
+
NO_ANALYSIS: str = "no_analysis"
|
|
72
|
+
|
|
73
|
+
_STATEMENTS: dict[str, str] = {
|
|
74
|
+
HISTORY_PRESENT: (
|
|
75
|
+
"This repository already keeps an architectural history, so this run added "
|
|
76
|
+
"the current commit to it."
|
|
77
|
+
),
|
|
78
|
+
EXPLICITLY_ENABLED: (
|
|
79
|
+
"Automatic capture was requested, so this run added the current commit to the "
|
|
80
|
+
"architectural history."
|
|
81
|
+
),
|
|
82
|
+
NO_HISTORY: (
|
|
83
|
+
"No architectural history exists here and none was requested, so nothing was "
|
|
84
|
+
"written. A series cannot be reconstructed later: start one with "
|
|
85
|
+
"`ask baseline capture <path>` (or `--auto` in the pipeline that already runs)."
|
|
86
|
+
),
|
|
87
|
+
DISABLED: (
|
|
88
|
+
f"Automatic capture is switched off ({ENV_FLAG}), so this run added nothing to "
|
|
89
|
+
"the architectural history."
|
|
90
|
+
),
|
|
91
|
+
NO_COMMIT_IDENTITY: (
|
|
92
|
+
"The path has no resolvable git commit, so a captured baseline could not be "
|
|
93
|
+
"joined to anything later. Nothing was written."
|
|
94
|
+
),
|
|
95
|
+
WORKTREE_DIRTY: (
|
|
96
|
+
"The working tree carries uncommitted changes, so measurements taken now do not "
|
|
97
|
+
"describe the commit they would be filed under. Nothing was written."
|
|
98
|
+
),
|
|
99
|
+
WORKTREE_STATE_UNKNOWN: (
|
|
100
|
+
"Whether the working tree is clean could not be determined, so the measurements "
|
|
101
|
+
"could not be attributed to the commit. Nothing was written."
|
|
102
|
+
),
|
|
103
|
+
COMMIT_ALREADY_CAPTURED: (
|
|
104
|
+
"This commit is already in the architectural history; the stored baseline was "
|
|
105
|
+
"kept as captured (re-capturing would relabel a point in the series)."
|
|
106
|
+
),
|
|
107
|
+
WRITE_FAILED: (
|
|
108
|
+
"The architectural history could not be written (see `error`). The verdict of "
|
|
109
|
+
"this run is unaffected — capture never changes what the command reports."
|
|
110
|
+
),
|
|
111
|
+
NO_ANALYSIS: (
|
|
112
|
+
"This run did not analyse the repository, so there were no measurements to add "
|
|
113
|
+
"to the architectural history. Nothing was written."
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class CaptureDecision:
|
|
120
|
+
"""What a run should do about the architectural history, and why."""
|
|
121
|
+
|
|
122
|
+
action: str # CAPTURED (intent, before execution) | SKIPPED
|
|
123
|
+
reason: str
|
|
124
|
+
history_dir: Optional[str] = None
|
|
125
|
+
commit: Optional[str] = None
|
|
126
|
+
baseline_file: Optional[str] = None
|
|
127
|
+
history_size: Optional[int] = None
|
|
128
|
+
error: Optional[str] = None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def statement(self) -> str:
|
|
132
|
+
return _STATEMENTS.get(self.reason, "")
|
|
133
|
+
|
|
134
|
+
def to_dict(self) -> dict:
|
|
135
|
+
out = {
|
|
136
|
+
"action": self.action,
|
|
137
|
+
"reason": self.reason,
|
|
138
|
+
"statement": self.statement,
|
|
139
|
+
"history_dir": self.history_dir,
|
|
140
|
+
"commit": self.commit,
|
|
141
|
+
"baseline_file": self.baseline_file,
|
|
142
|
+
"history_size": self.history_size,
|
|
143
|
+
}
|
|
144
|
+
if self.error is not None:
|
|
145
|
+
out["error"] = self.error
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def env_opt_in(environ: "Optional[dict[str, str]]" = None) -> Optional[bool]:
|
|
150
|
+
"""Tri-state read of the operator switch: True / False / None (never asked).
|
|
151
|
+
|
|
152
|
+
None is a distinct answer on purpose — the same rule the telemetry consent and
|
|
153
|
+
the fact registry follow. "Nobody configured this" is not "somebody said no".
|
|
154
|
+
"""
|
|
155
|
+
raw = (environ if environ is not None else os.environ).get(ENV_FLAG)
|
|
156
|
+
if raw is None:
|
|
157
|
+
return None
|
|
158
|
+
value = raw.strip().lower()
|
|
159
|
+
if value in _TRUE:
|
|
160
|
+
return True
|
|
161
|
+
if value in _FALSE:
|
|
162
|
+
return False
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def history_dir_for(root: Path, out_dir: "Optional[Path]" = None) -> Path:
|
|
167
|
+
"""The directory a repository's baselines live in."""
|
|
168
|
+
return Path(out_dir).resolve() if out_dir is not None else Path(root) / HISTORY_DIRNAME
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _porcelain_path(line: str) -> str:
|
|
172
|
+
"""The path a `git status --porcelain` line refers to (the destination on renames)."""
|
|
173
|
+
body = line[3:] if len(line) > 3 else ""
|
|
174
|
+
if " -> " in body: # rename/copy: `R old -> new`
|
|
175
|
+
body = body.split(" -> ", 1)[1]
|
|
176
|
+
return body.strip().strip('"')
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def worktree_dirty(
|
|
180
|
+
root: Path, ignore: "Optional[Path | Iterable[Path]]" = None
|
|
181
|
+
) -> Optional[bool]:
|
|
182
|
+
"""True/False for a git worktree, None when the state could not be determined.
|
|
183
|
+
|
|
184
|
+
`git status --porcelain` honours `.gitignore`, so build output does not count as
|
|
185
|
+
a modification — but an untracked `.java` file does, because it is in the IR the
|
|
186
|
+
baseline measures.
|
|
187
|
+
|
|
188
|
+
`ignore` drops directories from the answer, and callers pass exactly one thing:
|
|
189
|
+
**this tool's own footprint** (`.ask/`, and the history directory when `--dir`
|
|
190
|
+
put it elsewhere). Two measured reasons, not a convenience:
|
|
191
|
+
|
|
192
|
+
* the first capture leaves an untracked file inside the repository, so without
|
|
193
|
+
this the history would report a dirty tree from its second run onward — a
|
|
194
|
+
series of exactly one;
|
|
195
|
+
* `.ask/contracts.yml` is what a gate run is *for*, and in a repository that has
|
|
196
|
+
not committed it yet, counting it as a modification of the code would mean the
|
|
197
|
+
history never starts.
|
|
198
|
+
|
|
199
|
+
Nothing else is excused, because everything else is what the baseline measures.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
out = subprocess.run(
|
|
203
|
+
# `-uall` because git collapses an untracked directory to one entry
|
|
204
|
+
# (`?? .ask/`), and the whole point here is to tell OUR untracked file
|
|
205
|
+
# apart from the source file next to it.
|
|
206
|
+
["git", "-C", str(root), "status", "--porcelain", "-uall"],
|
|
207
|
+
capture_output=True, text=True, timeout=30,
|
|
208
|
+
)
|
|
209
|
+
except (OSError, subprocess.SubprocessError):
|
|
210
|
+
return None
|
|
211
|
+
if out.returncode != 0:
|
|
212
|
+
return None
|
|
213
|
+
lines = [ln for ln in out.stdout.splitlines() if ln.strip()]
|
|
214
|
+
if ignore is None:
|
|
215
|
+
return bool(lines)
|
|
216
|
+
candidates = [ignore] if isinstance(ignore, (str, Path)) else list(ignore)
|
|
217
|
+
prefixes: list[str] = []
|
|
218
|
+
for candidate in candidates:
|
|
219
|
+
try:
|
|
220
|
+
rel = Path(candidate).resolve().relative_to(Path(root).resolve()).as_posix()
|
|
221
|
+
except ValueError: # outside the repo (`--dir`): nothing of it to drop
|
|
222
|
+
continue
|
|
223
|
+
prefixes.append(rel.rstrip("/") + "/")
|
|
224
|
+
remaining = [
|
|
225
|
+
ln for ln in lines
|
|
226
|
+
if not any(
|
|
227
|
+
(_porcelain_path(ln).rstrip("/") + "/").startswith(p) for p in prefixes
|
|
228
|
+
)
|
|
229
|
+
]
|
|
230
|
+
return bool(remaining)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def history_size(history_dir: Path) -> Optional[int]:
|
|
234
|
+
"""How many baselines the history holds, or None when there is no history."""
|
|
235
|
+
try:
|
|
236
|
+
if not history_dir.is_dir():
|
|
237
|
+
return None
|
|
238
|
+
return sum(1 for _ in history_dir.glob("*.json"))
|
|
239
|
+
except OSError:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def decide(
|
|
244
|
+
*,
|
|
245
|
+
history_dir: Path,
|
|
246
|
+
history_exists: bool,
|
|
247
|
+
opt_in: Optional[bool],
|
|
248
|
+
commit: Optional[str],
|
|
249
|
+
dirty: Optional[bool],
|
|
250
|
+
already_captured: Optional[Path],
|
|
251
|
+
size: Optional[int] = None,
|
|
252
|
+
) -> CaptureDecision:
|
|
253
|
+
"""Pure policy: given the facts, capture or skip — with the reason named.
|
|
254
|
+
|
|
255
|
+
Split from execution so the policy is testable without a repository, and so the
|
|
256
|
+
order of the checks is visible in one place: consent first, then identity, then
|
|
257
|
+
attribution, then idempotency.
|
|
258
|
+
"""
|
|
259
|
+
common = {
|
|
260
|
+
"history_dir": str(history_dir),
|
|
261
|
+
"commit": commit,
|
|
262
|
+
"history_size": size,
|
|
263
|
+
}
|
|
264
|
+
if opt_in is False:
|
|
265
|
+
return CaptureDecision(SKIPPED, DISABLED, **common)
|
|
266
|
+
if opt_in is not True and not history_exists:
|
|
267
|
+
return CaptureDecision(SKIPPED, NO_HISTORY, **common)
|
|
268
|
+
if not commit:
|
|
269
|
+
return CaptureDecision(SKIPPED, NO_COMMIT_IDENTITY, **common)
|
|
270
|
+
if dirty is None:
|
|
271
|
+
return CaptureDecision(SKIPPED, WORKTREE_STATE_UNKNOWN, **common)
|
|
272
|
+
if dirty:
|
|
273
|
+
return CaptureDecision(SKIPPED, WORKTREE_DIRTY, **common)
|
|
274
|
+
if already_captured is not None:
|
|
275
|
+
return CaptureDecision(
|
|
276
|
+
SKIPPED, COMMIT_ALREADY_CAPTURED,
|
|
277
|
+
baseline_file=str(already_captured), **common
|
|
278
|
+
)
|
|
279
|
+
return CaptureDecision(
|
|
280
|
+
CAPTURED, HISTORY_PRESENT if history_exists else EXPLICITLY_ENABLED, **common
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _existing_baseline(history_dir: Path, commit: str) -> Optional[Path]:
|
|
285
|
+
candidate = history_dir / f"{commit}.json"
|
|
286
|
+
try:
|
|
287
|
+
return candidate if candidate.is_file() else None
|
|
288
|
+
except OSError:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def plan(
|
|
293
|
+
root: Path,
|
|
294
|
+
*,
|
|
295
|
+
out_dir: "Optional[Path]" = None,
|
|
296
|
+
explicit: bool = False,
|
|
297
|
+
environ: "Optional[dict[str, str]]" = None,
|
|
298
|
+
) -> CaptureDecision:
|
|
299
|
+
"""Gather the repository facts and apply `decide` to them."""
|
|
300
|
+
root = Path(root)
|
|
301
|
+
from sourcecode.architectural_baseline import git_commit
|
|
302
|
+
|
|
303
|
+
hist = history_dir_for(root, out_dir)
|
|
304
|
+
exists = hist.is_dir()
|
|
305
|
+
opt_in = True if explicit else env_opt_in(environ)
|
|
306
|
+
|
|
307
|
+
# Consent is the cheapest question and the most common answer, so it is asked
|
|
308
|
+
# first: a command that piggybacks on every run must cost nothing at all in a
|
|
309
|
+
# repository that keeps no history. `git status -uall` on a monolith is not
|
|
310
|
+
# nothing.
|
|
311
|
+
if opt_in is False or (opt_in is not True and not exists):
|
|
312
|
+
return decide(
|
|
313
|
+
history_dir=hist, history_exists=exists, opt_in=opt_in,
|
|
314
|
+
commit=None, dirty=None, already_captured=None, size=history_size(hist),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
commit = git_commit(root)
|
|
318
|
+
# The tool's own footprint, and only that.
|
|
319
|
+
own = (hist, Path(root) / HISTORY_DIRNAME.parts[0])
|
|
320
|
+
dirty = worktree_dirty(root, ignore=own) if commit else None
|
|
321
|
+
already = _existing_baseline(hist, commit) if commit else None
|
|
322
|
+
return decide(
|
|
323
|
+
history_dir=hist,
|
|
324
|
+
history_exists=exists,
|
|
325
|
+
opt_in=opt_in,
|
|
326
|
+
commit=commit,
|
|
327
|
+
dirty=dirty,
|
|
328
|
+
already_captured=already,
|
|
329
|
+
size=history_size(hist),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def run(
|
|
334
|
+
root: Path,
|
|
335
|
+
cir: "Optional[CanonicalRepositoryIR]" = None,
|
|
336
|
+
*,
|
|
337
|
+
out_dir: "Optional[Path]" = None,
|
|
338
|
+
explicit: bool = False,
|
|
339
|
+
ref: "Optional[str]" = None,
|
|
340
|
+
environ: "Optional[dict[str, str]]" = None,
|
|
341
|
+
decision: "Optional[CaptureDecision]" = None,
|
|
342
|
+
) -> CaptureDecision:
|
|
343
|
+
"""Decide, and when the decision is to capture, write the baseline.
|
|
344
|
+
|
|
345
|
+
`cir` is the IR the host command already built — capture adds a metric extraction
|
|
346
|
+
and a file write, never a second analysis. Passing None with a capture decision is
|
|
347
|
+
a caller bug, reported as a skip rather than raised: this function's contract is
|
|
348
|
+
that it cannot fail the run that called it.
|
|
349
|
+
"""
|
|
350
|
+
plan_ = decision if decision is not None else plan(
|
|
351
|
+
root, out_dir=out_dir, explicit=explicit, environ=environ
|
|
352
|
+
)
|
|
353
|
+
if plan_.action != CAPTURED:
|
|
354
|
+
return plan_
|
|
355
|
+
|
|
356
|
+
from sourcecode.architectural_baseline import build_baseline, write_baseline
|
|
357
|
+
|
|
358
|
+
hist = history_dir_for(Path(root), out_dir)
|
|
359
|
+
try:
|
|
360
|
+
if cir is None:
|
|
361
|
+
raise ValueError("no IR was available to capture from")
|
|
362
|
+
baseline = build_baseline(cir, ref=ref, commit=plan_.commit)
|
|
363
|
+
written = write_baseline(baseline, hist)
|
|
364
|
+
except Exception as exc: # a side effect never decides the host command's verdict
|
|
365
|
+
return CaptureDecision(
|
|
366
|
+
SKIPPED, WRITE_FAILED,
|
|
367
|
+
history_dir=str(hist), commit=plan_.commit,
|
|
368
|
+
history_size=plan_.history_size, error=f"{type(exc).__name__}: {exc}",
|
|
369
|
+
)
|
|
370
|
+
return CaptureDecision(
|
|
371
|
+
CAPTURED, plan_.reason,
|
|
372
|
+
history_dir=str(hist), commit=plan_.commit,
|
|
373
|
+
baseline_file=str(written),
|
|
374
|
+
history_size=(plan_.history_size or 0) + 1,
|
|
375
|
+
)
|
sourcecode/cache_model.py
CHANGED
|
@@ -138,6 +138,25 @@ COMMANDS: tuple[CommandCache, ...] = (
|
|
|
138
138
|
"within the run.",
|
|
139
139
|
"not measured on the battery yet — the composition is bounded by the "
|
|
140
140
|
"`spring-audit` + `impact-chain` costs listed here, not by new analysis"),
|
|
141
|
+
CommandCache("enrich", ("cir", "parse"), "shared", False,
|
|
142
|
+
"Runs the same composition as `risk` over the repository, then joins a SARIF "
|
|
143
|
+
"log to it. Reading the log is negligible; everything a warm helps with is the "
|
|
144
|
+
"repository side, so what it buys is what it buys `risk`.",
|
|
145
|
+
"not measured on the battery yet — bounded by the `risk` composition, plus "
|
|
146
|
+
"reading one JSON file"),
|
|
147
|
+
CommandCache("migrate-recipe", ("parse",), "shared", False,
|
|
148
|
+
"Runs the same scan as `migrate-check` and projects its findings into an "
|
|
149
|
+
"OpenRewrite recipe, so it buys exactly what a warm buys `migrate-check`: "
|
|
150
|
+
"the parse, not the rule pass.",
|
|
151
|
+
"not measured on the battery yet — bounded by `migrate-check` on the same "
|
|
152
|
+
"repository (openmrs-core ~2 s)"),
|
|
153
|
+
CommandCache("data-exposure", ("cir", "parse"), "shared", False,
|
|
154
|
+
"Walks the same call reach as `impact-chain` once per declared type and "
|
|
155
|
+
"reads the endpoint security surface, both over the shared CIR a warm "
|
|
156
|
+
"builds. Cost scales with the number of declared types, not with the "
|
|
157
|
+
"size of the label.",
|
|
158
|
+
"not measured on the battery yet — one `impact-chain` traversal per "
|
|
159
|
+
"declared seed type over a CIR the warm already paid for"),
|
|
141
160
|
CommandCache("endpoints", ("ris", "parse"), "shared", False,
|
|
142
161
|
"Recomputes the endpoint surface on every run, over a parse a warm has already "
|
|
143
162
|
"paid for. Until 3.7.0 the extractor parsed every file itself instead of reading "
|
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
|