sourcecode 4.2.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 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.2.0"
7
+ __version__ = "4.3.0"
@@ -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 "