sourcecode 5.2.0__py3-none-any.whl → 5.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__ = "5.2.0"
7
+ __version__ = "5.3.0"
sourcecode/cache_model.py CHANGED
@@ -368,6 +368,7 @@ COMMANDS: tuple[CommandCache, ...] = (
368
368
  "`no_ris` instead of a snapshot.",
369
369
  "0.2 s either way", analysis_class="core", cold_seconds=0.2, warm_seconds=0.2,
370
370
  field_seconds=0.4, field_cache_state="mixed", field_measured_version="5.1.0"),
371
+ CommandCache("timeline", (), "none", False, "Each sample is a fresh materialisation of a different commit, so nothing is shared between samples: two commits are two trees, and a cache keyed on a tree state can only ever serve one of them. The per-sample cost is measured and published in the payload.", analysis_class="ir"),
371
372
  CommandCache("trend", (), "none", False, "Reads stored baseline artifacts from disk; analyses no source, so no cache layer applies. Same command as `baseline trend`.", analysis_class="none"),
372
373
  CommandCache("baseline", ("parse",), "shared", False,
373
374
  "`capture`/`diff`/`trend` over architectural metrics. The field figure is `capture`, "
sourcecode/cli.py CHANGED
@@ -201,6 +201,9 @@ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
201
201
  ("experimental", "shape may change in a minor — do not gate CI on it", (
202
202
  "risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
203
203
  "archetype",
204
+ # F-BJ: the series is one release old and its sampling contract (which
205
+ # window a transition is attributed to) is the part most likely to move.
206
+ "timeline",
204
207
  )),
205
208
  # `retrieve` publishes 15+ intents whose answers the other commands already
206
209
  # give better: measured on the battery, `security-surface` merely re-states
@@ -6298,6 +6301,13 @@ def endpoints_cmd(
6298
6301
  help="Where the client lives, when it is not the whole repository "
6299
6302
  "(implies --client-usage).",
6300
6303
  ),
6304
+ consumer: Optional[list[Path]] = typer.Option(
6305
+ None, "--consumer",
6306
+ help="A consumer repository to join against (repeatable). Turns the census "
6307
+ "into a surface-reduction plan: of the routes a rule leaves open, which "
6308
+ "ones somebody calls (fix now) and which nobody calls (delete rather "
6309
+ "than protect).",
6310
+ ),
6301
6311
  progress_mode: Optional[str] = _progress_option(),
6302
6312
  jobs: Optional[int] = _jobs_option(),
6303
6313
  ) -> None:
@@ -6447,6 +6457,17 @@ def endpoints_cmd(
6447
6457
  target, _selected,
6448
6458
  client_dirs=[client_dir] if client_dir is not None else None,
6449
6459
  )
6460
+ # F-BH. The consumers of a monolith are usually other repositories, so the
6461
+ # question that decides the work — what does anybody call — cannot be answered
6462
+ # from this tree alone. Given them, the plan inverts: an open route nobody
6463
+ # calls is surface to delete rather than surface to guard. A third declared
6464
+ # population, never merged into the census or the servlet surface.
6465
+ if not servlets and consumer:
6466
+ from sourcecode.consumer_join import build_consumer_join
6467
+
6468
+ data["consumer_join"] = build_consumer_join(
6469
+ target, _selected, consumer, access=_endpoint_access_index(target),
6470
+ )
6450
6471
  if compact and not servlets:
6451
6472
  from sourcecode.degradation import cap_effect
6452
6473
 
@@ -7984,6 +8005,139 @@ def trend_cmd(
7984
8005
  )
7985
8006
 
7986
8007
 
8008
+ def _endpoint_access_index(target: Path) -> "dict[str, str]":
8009
+ """`METHOD path` → the access verdict `posture` publishes, for the consumer join.
8010
+
8011
+ Read from `posture`, which is the authority for it: the plan's whole value is
8012
+ the crossing of *open* with *uncalled*, and deciding "open" a second way here
8013
+ would be a second answer to a question one command already owns. Failure is an
8014
+ empty index rather than an exception — the join still splits called from
8015
+ uncalled, it just cannot say which half is urgent.
8016
+ """
8017
+ try:
8018
+ from sourcecode.posture import access_resolution, access_verdict
8019
+
8020
+ by_endpoint, _decides = access_resolution(target)
8021
+ except Exception:
8022
+ return {}
8023
+ index: "dict[str, str]" = {}
8024
+ for identity, decision in by_endpoint.items():
8025
+ parts = str(identity).split(":")
8026
+ if len(parts) < 2:
8027
+ continue
8028
+ index[f"{parts[0].upper()} {parts[1]}"] = access_verdict(decision)
8029
+ return index
8030
+
8031
+
8032
+ # ── timeline: since when, and in which window (F-BJ) ──────────────────────────
8033
+ @app.command("timeline")
8034
+ def timeline_cmd(
8035
+ path: Path = typer.Argument(
8036
+ Path("."), help="Repository to walk (default: current directory).",
8037
+ ),
8038
+ since: str = typer.Option(
8039
+ ...,
8040
+ "--since",
8041
+ help="The ref the series starts after: a tag, a branch, a sha (e.g. v1.0).",
8042
+ ),
8043
+ watch: str = typer.Option(
8044
+ "posture",
8045
+ "--watch",
8046
+ help="Comma-separated metrics to follow: posture, data-exposure, "
8047
+ "endpoints, spring-audit.",
8048
+ ),
8049
+ max_commits: int = typer.Option(
8050
+ 10,
8051
+ "--max-commits",
8052
+ min=1,
8053
+ help="How many commits to analyse. A larger number narrows the window a "
8054
+ "transition is attributed to; at one commit per sample the window "
8055
+ "closes to a single commit.",
8056
+ ),
8057
+ first_parent: bool = typer.Option(
8058
+ True,
8059
+ "--first-parent/--all-parents",
8060
+ help="Walk the trunk only (default). A merge's second parent is another "
8061
+ "branch's history.",
8062
+ ),
8063
+ output_path: Optional[Path] = typer.Option(
8064
+ None, "--output", "-o", help="Write the series to a file instead of stdout.",
8065
+ ),
8066
+ format: str = _format_option(help="Output format: json (default) or yaml."),
8067
+ copy: bool = _copy_option(),
8068
+ progress_mode: Optional[str] = _progress_option(),
8069
+ ) -> None:
8070
+ """[EXPERIMENTAL] When an exposure appeared, and in which commit window.
8071
+
8072
+ \b
8073
+ EXPERIMENTAL: the sampling contract — which commit window a transition is
8074
+ attributed to — is the part most likely to move in a minor. Do not gate CI
8075
+ on it yet.
8076
+
8077
+ \b
8078
+ `data-exposure` says 2 453 routes reach labelled data and nothing decides
8079
+ access to them. The question that follows — and the one a regulator asks
8080
+ first — is SINCE WHEN. This walks the commit series, runs the analyses that
8081
+ already ship at each sampled tree, and publishes where a number MOVED.
8082
+
8083
+ \b
8084
+ Nothing here is a new analysis: every axis is a projection of a payload
8085
+ another command emits (`metrics_read` names it per metric), measured on a
8086
+ read-only materialisation of each commit — the repository is never written
8087
+ to. Re-run that command at that commit and you get the same number.
8088
+
8089
+ \b
8090
+ A sampled commit that could not be analysed is `null` with a reason, never
8091
+ 0. With sampling on, a transition names the commit it was OBSERVED at and
8092
+ publishes the window it could have happened in; it is never attributed to a
8093
+ single commit it was not measured at.
8094
+
8095
+ \b
8096
+ Examples:
8097
+ ask timeline . --since v1.0 --watch posture,data-exposure
8098
+ ask timeline . --since HEAD~50 --watch data-exposure --max-commits 50
8099
+ ask timeline . --since v2.0 --watch spring-audit -o series.json
8100
+ """
8101
+ from sourcecode.timeline import METRICS, TimelineError, build_timeline
8102
+
8103
+ target = _admit_path(path)
8104
+ watched = [name.strip() for name in watch.split(",") if name.strip()]
8105
+ if not watched:
8106
+ _emit_error_json(
8107
+ INVALID_INPUT_CODE,
8108
+ "--watch names at least one metric.",
8109
+ hint=f"--watch {','.join(sorted(METRICS))}",
8110
+ )
8111
+ raise typer.Exit(code=1)
8112
+
8113
+ # Each sample is a full analysis of one tree, so this is the command in the
8114
+ # catalogue whose wait is longest and least bounded by the repository alone.
8115
+ # The spinner names the commit being sampled, because "which of the ten" is
8116
+ # the only progress fact a caller can act on.
8117
+ _prog = Progress()
8118
+ _prog.start(f"walking {len(watched)} metric(s) since {since}")
8119
+ try:
8120
+ data = build_timeline(
8121
+ target, since, watched,
8122
+ max_samples=max_commits,
8123
+ first_parent=first_parent,
8124
+ progress=lambda line: _prog.update(line),
8125
+ )
8126
+ except TimelineError as exc:
8127
+ _prog.stop()
8128
+ _emit_error_json(exc.code, exc.message, **exc.context)
8129
+ raise typer.Exit(code=1)
8130
+ finally:
8131
+ _prog.stop()
8132
+
8133
+ _emit_command_output(
8134
+ _serialize_dict(data, format),
8135
+ output_path,
8136
+ copy,
8137
+ success_msg=f"timeline written to {output_path}",
8138
+ )
8139
+
8140
+
7987
8141
  # ── Spring Semantic Audit ─────────────────────────────────────────────────────
7988
8142
 
7989
8143
 
@@ -8478,6 +8632,14 @@ def spring_audit_cmd(
8478
8632
  `summary.total_defects` is the number of things to fix. Measured:
8479
8633
  openmrs-core 38 findings / 29 defects, BroadleafCommerce 19 / 14.
8480
8634
 
8635
+ \b
8636
+ Waivers: a repository can adjudicate a finding as not-a-defect-here in
8637
+ `.ask/waivers.yml`, keyed on the stable `finding_id`/`defect_id` with a
8638
+ required reason (optional `by`, `expires`). Waived rows leave `findings` and
8639
+ are published in full under `waived`, with `summary.waived_findings` and a
8640
+ `waivers` block naming what was declared, applied, expired and matched
8641
+ nothing. A rule family cannot be waived. See docs/contracts.md.
8642
+
8481
8643
  \b
8482
8644
  CI/CD usage — this gate is ABSOLUTE (any finding fails, including debt that
8483
8645
  was already there). For a baseline-relative gate that fails only on findings
@@ -8677,6 +8839,27 @@ def spring_audit_cmd(
8677
8839
 
8678
8840
  combined = SpringAuditResult.merge(results, scope=scope)
8679
8841
 
8842
+ # F-BI: adjudication, applied over the whole population and never
8843
+ # inside one scope. A malformed waivers file stops the command
8844
+ # rather than reading as "nothing was waived" — a broken file must
8845
+ # not decide a gate in either direction.
8846
+ from sourcecode.waivers import WaiversError as _WaiversError
8847
+ from sourcecode.waivers import apply_to_result as _apply_waivers
8848
+
8849
+ try:
8850
+ combined = _apply_waivers(combined, target)
8851
+ except _WaiversError as _exc:
8852
+ _prog.stop()
8853
+ _emit_error_json(
8854
+ INVALID_INPUT_CODE,
8855
+ f"The waivers this repository declares could not be read: {_exc}",
8856
+ hint=(
8857
+ "Each entry needs one of `finding_id`/`defect_id` and a "
8858
+ "`reason`. See docs/contracts.md."
8859
+ ),
8860
+ )
8861
+ raise typer.Exit(code=1)
8862
+
8680
8863
  # C2-31: a phase that never *started* is a cut this envelope cannot
8681
8864
  # learn from its own scopes — no auditor ran to record it. The run
8682
8865
  # knows, so it says so here, and `finalize()` applies the same ceiling
@@ -9005,6 +9188,13 @@ def verify_cmd(
9005
9188
  Contracts live in `.ask/contracts.yml` (see `ask verify-edit --help` for the
9006
9189
  rule kinds). No contracts declared → nothing was verified, exit 2.
9007
9190
 
9191
+ \b
9192
+ Waivers: `.ask/waivers.yml` adjudicates a finding as not-a-defect-here, keyed
9193
+ on its stable finding/defect id with a required reason. A waived finding does
9194
+ not hold this gate red; `ask spring-audit` publishes what was waived, by whom
9195
+ and why. A baseline records accepted DEBT — a waiver says it was never a
9196
+ defect. See docs/contracts.md.
9197
+
9008
9198
  \b
9009
9199
  Exit codes (with --ci, the default): 0 = pass, 1 = violations blocked,
9010
9200
  2 = unverified (no contracts declared, contracts unreadable, or the
@@ -9973,6 +10163,16 @@ def posture_cmd(
9973
10163
  "spring.profiles.active from an explicit deployment env/dotenv file."
9974
10164
  ),
9975
10165
  ),
10166
+ profile_from: Optional[list[Path]] = typer.Option(
10167
+ None,
10168
+ "--profile-from",
10169
+ help=(
10170
+ "Take the active profile set from the artefact that decides it — a "
10171
+ "Kubernetes manifest, compose file, WebLogic deployment plan, "
10172
+ "Dockerfile, dotenv or launch script (repeatable). If it decides "
10173
+ "nothing, the repository's own verdict stands."
10174
+ ),
10175
+ ),
9976
10176
  property_overrides: Optional[list[str]] = typer.Option(
9977
10177
  None,
9978
10178
  "--property",
@@ -10107,6 +10307,44 @@ def posture_cmd(
10107
10307
  )
10108
10308
  raise typer.Exit(code=1)
10109
10309
 
10310
+ # F-BG. `--profile-from` names the artefact that decides the profile set, which
10311
+ # is the hop the flagship used to end at. It cannot be combined with a flag
10312
+ # that *states* the set: --profile and --diff both name one, and reading a
10313
+ # manifest to then ignore it would be a payload that describes two answers and
10314
+ # gives one.
10315
+ profile_from_block: "Optional[dict]" = None
10316
+ decided_profiles: "Optional[set[str]]" = None
10317
+ artefact_signals: list = []
10318
+ if profile_from:
10319
+ if profile or diff:
10320
+ _emit_error_json(
10321
+ INVALID_INPUT_CODE,
10322
+ "--profile-from reads the profile set from a deployment artefact; "
10323
+ "--profile and --diff state one. Pass one or the other.",
10324
+ hint="ask posture . --profile-from k8s/deployment-prod.yaml",
10325
+ )
10326
+ raise typer.Exit(code=1)
10327
+ from sourcecode.environment_resolution import (
10328
+ collect_deployment_artefact_signals,
10329
+ decide_profiles_from_artefacts,
10330
+ )
10331
+
10332
+ for artefact in profile_from:
10333
+ artefact_path = Path(artefact).expanduser()
10334
+ if not artefact_path.is_file():
10335
+ _emit_error_json(
10336
+ INVALID_INPUT_CODE,
10337
+ f"--profile-from must name a readable file (got {str(artefact)!r}).",
10338
+ hint="ask posture . --profile-from k8s/deployment-prod.yaml",
10339
+ )
10340
+ raise typer.Exit(code=1)
10341
+ artefact_signals.extend(
10342
+ collect_deployment_artefact_signals(artefact_path, root=path)
10343
+ )
10344
+ profile_from_block = decide_profiles_from_artefacts(profile_from, root=path)
10345
+ if profile_from_block.get("decided"):
10346
+ decided_profiles = set(profile_from_block["profiles"] or [])
10347
+
10110
10348
  # One spinner over the whole dispatch: every branch below resolves the
10111
10349
  # conditional bean graph, and `--diff-ref` resolves it twice (once per ref).
10112
10350
  # try/finally, because several branches leave through typer.Exit and a
@@ -10124,7 +10362,7 @@ def posture_cmd(
10124
10362
  "`--env-file prod.env` as deployment evidence.",
10125
10363
  )
10126
10364
  raise typer.Exit(code=1)
10127
- deployment_signals = None
10365
+ deployment_signals = list(artefact_signals)
10128
10366
  if env_file is not None:
10129
10367
  from sourcecode.environment_resolution import collect_env_file_signals
10130
10368
 
@@ -10136,7 +10374,8 @@ def posture_cmd(
10136
10374
  hint="ask posture . --resolve-environments --env-file prod.env",
10137
10375
  )
10138
10376
  raise typer.Exit(code=1)
10139
- deployment_signals = collect_env_file_signals(env_path, root=path)
10377
+ deployment_signals.extend(collect_env_file_signals(env_path, root=path))
10378
+ deployment_signals = deployment_signals or None
10140
10379
  data = resolve_environments(
10141
10380
  path, overrides, deployment_signals=deployment_signals,
10142
10381
  )
@@ -10165,7 +10404,8 @@ def posture_cmd(
10165
10404
  try:
10166
10405
  data = diff_posture_refs(
10167
10406
  path,
10168
- _parse_set(profile) if profile else set(),
10407
+ decided_profiles if decided_profiles is not None
10408
+ else (_parse_set(profile) if profile else set()),
10169
10409
  base_ref.strip(),
10170
10410
  head_ref.strip() if head_ref is not None else None,
10171
10411
  overrides,
@@ -10194,10 +10434,22 @@ def posture_cmd(
10194
10434
  left_raw, right_raw = diff.split(":", 1)
10195
10435
  data = diff_posture(path, _parse_set(left_raw), _parse_set(right_raw), overrides)
10196
10436
  else:
10197
- data = build_posture(path, _parse_set(profile) if profile else set(), overrides)
10437
+ data = build_posture(
10438
+ path,
10439
+ decided_profiles if decided_profiles is not None
10440
+ else (_parse_set(profile) if profile else set()),
10441
+ overrides,
10442
+ )
10198
10443
  finally:
10199
10444
  _prog.stop()
10200
10445
 
10446
+ # F-BG. The block travels with every shape the command can answer in, and it
10447
+ # says whether it changed the answer: a `decided: false` block beside an
10448
+ # unchanged verdict is the honest outcome of pointing at a file that does not
10449
+ # decide, not a silent no-op.
10450
+ if profile_from_block is not None and isinstance(data, dict):
10451
+ data["profile_from"] = profile_from_block
10452
+
10201
10453
  _emit_command_output(
10202
10454
  _serialize_dict(data, format),
10203
10455
  output_path,
@@ -14178,7 +14430,7 @@ HELP_PANELS: "tuple[tuple[str, tuple[str, ...]], ...]" = (
14178
14430
  )),
14179
14431
  ("Experimental — shape may change", (
14180
14432
  "risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
14181
- "archetype", "retrieve",
14433
+ "archetype", "timeline", "retrieve",
14182
14434
  )),
14183
14435
  )
14184
14436