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/cli.py CHANGED
@@ -183,7 +183,7 @@ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
183
183
  "cache", "auth", "mcp", "telemetry",
184
184
  )),
185
185
  ("experimental", "shape may change in a minor — do not gate CI on it", (
186
- "risk", "posture", "archetype",
186
+ "risk", "enrich", "data-exposure", "migrate-recipe", "posture", "archetype",
187
187
  )),
188
188
  # `retrieve` publishes 15+ intents whose answers the other commands already
189
189
  # give better: measured on the battery, `security-surface` merely re-states
@@ -814,6 +814,20 @@ def _admit_path(
814
814
  raise typer.Exit(code=exit_code)
815
815
 
816
816
 
817
+ def _profile_set(raw: "Optional[str]") -> "Optional[set[str]]":
818
+ """`--profile prod,metrics` → `{"prod", "metrics"}`; nothing given → None.
819
+
820
+ None and the empty set are different answers: no profile set was named, versus
821
+ a named set that is empty. The first leaves the access axis on the repository's
822
+ own security surface; the second would resolve the bean graph with nothing
823
+ active. One parser, so `posture`, `risk` and `enrich` cannot disagree about
824
+ what a profile string means.
825
+ """
826
+ if raw is None:
827
+ return None
828
+ return {part.strip() for part in raw.split(",") if part.strip()}
829
+
830
+
817
831
  def _exit_on_unresolved_target(resolution: "Optional[str]") -> None:
818
832
  """Close the run on a target the analysis never resolved — one exit code.
819
833
 
@@ -4871,6 +4885,18 @@ def endpoints_cmd(
4871
4885
  False, "--by-controller",
4872
4886
  help="Group endpoints by controller class (structured API surface for C4/Container synthesis).",
4873
4887
  ),
4888
+ client_usage: bool = typer.Option(
4889
+ False, "--client-usage",
4890
+ help="Join the routes against the HTTP calls written in the TypeScript/"
4891
+ "JavaScript client checked into this repository: which routes anybody "
4892
+ "here calls, which nothing here calls, and which client calls hit no "
4893
+ "route at all.",
4894
+ ),
4895
+ client_dir: Optional[Path] = typer.Option(
4896
+ None, "--client-dir",
4897
+ help="Where the client lives, when it is not the whole repository "
4898
+ "(implies --client-usage).",
4899
+ ),
4874
4900
  ) -> None:
4875
4901
  """Extract REST API endpoint surface from Java source files.
4876
4902
 
@@ -4974,6 +5000,18 @@ def endpoints_cmd(
4974
5000
  # rendering: counting the rendered list here would be the defect this block
4975
5001
  # exists to remove, one line into its own implementation (R5-R8).
4976
5002
  data["exposure"] = _endpoint_exposure_summary(_selected)
5003
+
5004
+ # M10.5 item 4. On a large surface the cheapest reduction of exposure is
5005
+ # deleting API nobody calls, and in a monorepo the caller is checked in next
5006
+ # to the callee. Joined over the population the filters chose, for the same
5007
+ # reason the census is.
5008
+ if client_usage or client_dir is not None:
5009
+ from sourcecode.client_calls import build_client_usage
5010
+
5011
+ data["client_usage"] = build_client_usage(
5012
+ target, _selected,
5013
+ client_dirs=[client_dir] if client_dir is not None else None,
5014
+ )
4977
5015
  if compact:
4978
5016
  from sourcecode.degradation import cap_effect
4979
5017
 
@@ -6100,6 +6138,30 @@ def _require_dir(target_dir: Path, cmd: str) -> None:
6100
6138
  _admit_path(target_dir, usage=f"ask baseline {cmd} <path> ...")
6101
6139
 
6102
6140
 
6141
+ def _resolve_history_dir(target_dir: Path) -> Path:
6142
+ """A repository path and its history directory are both valid ways to say it.
6143
+
6144
+ Capture writes to `<repo>/.ask/baselines`, so once a history exists the reader
6145
+ commands have to accept the thing the writer was pointed at: `ask trend .` is what
6146
+ someone types after `ask baseline capture .`, and making them name the storage
6147
+ directory is the same avoidable friction that kept the series empty.
6148
+ Directories holding baselines are used as they are, so an explicit `--dir`
6149
+ location keeps working unchanged.
6150
+ """
6151
+ from sourcecode.architectural_baseline import latest_baseline_path
6152
+
6153
+ if latest_baseline_path(target_dir) is not None:
6154
+ return target_dir
6155
+ nested = target_dir / _bac_history_dirname()
6156
+ return nested if nested.is_dir() else target_dir
6157
+
6158
+
6159
+ def _bac_history_dirname() -> Path:
6160
+ from sourcecode.baseline_autocapture import HISTORY_DIRNAME
6161
+
6162
+ return HISTORY_DIRNAME
6163
+
6164
+
6103
6165
  @baseline_app.command("capture")
6104
6166
  def baseline_capture_cmd(
6105
6167
  path: Path = typer.Argument(
@@ -6119,6 +6181,12 @@ def baseline_capture_cmd(
6119
6181
  "with a vendor — so an audit that must leave the tree untouched "
6120
6182
  "writes elsewhere with this flag.",
6121
6183
  ),
6184
+ auto: bool = typer.Option(
6185
+ False, "--auto",
6186
+ help="Pipeline mode: capture this commit once, skip when there is nothing new "
6187
+ "to record (dirty tree, commit already captured), and never fail the run. "
6188
+ "Safe to leave in a CI job that runs on every push.",
6189
+ ),
6122
6190
  ) -> None:
6123
6191
  """Capture a versioned architectural baseline of the current repo state.
6124
6192
 
@@ -6129,10 +6197,18 @@ def baseline_capture_cmd(
6129
6197
  replays it; `baseline trend` series several. Metric content is a pure function of
6130
6198
  the repo state; no verdict, no ROI.
6131
6199
 
6200
+ \b
6201
+ `--auto` is the form for a pipeline: the series is the asset nobody can
6202
+ reconstruct afterwards, and a history that depends on someone remembering to
6203
+ run this command will not exist. It captures one baseline per commit, refuses
6204
+ to attribute a dirty tree to the commit it would be filed under, and exits 0
6205
+ whatever happens — a build must not go red because a snapshot could not be
6206
+ written.
6132
6207
  \b
6133
6208
  Examples:
6134
6209
  ask baseline capture ./repo --ref v2.5.20
6135
6210
  ask baseline capture ./repo --dir ./repo/.ask/baselines
6211
+ ask baseline capture . --auto # in CI, on every push
6136
6212
  """
6137
6213
  target_dir = path.resolve()
6138
6214
  _require_dir(target_dir, "capture")
@@ -6140,6 +6216,30 @@ def baseline_capture_cmd(
6140
6216
  from sourcecode.context_graph import ContextGraph
6141
6217
  from sourcecode.architectural_baseline import build_baseline, git_commit, write_baseline
6142
6218
 
6219
+ if auto:
6220
+ from sourcecode import baseline_autocapture as _auto
6221
+
6222
+ # Decide BEFORE building the IR: a skip is the common case in a pipeline
6223
+ # that runs on every push, and it must cost nothing.
6224
+ plan = _auto.plan(target_dir, out_dir=out_dir, explicit=True)
6225
+ if plan.action != _auto.CAPTURED:
6226
+ typer.echo(f"Baseline not captured ({plan.reason}): {plan.statement}")
6227
+ return
6228
+ _prog = Progress()
6229
+ _prog.start("building IR")
6230
+ _cir = ContextGraph.build_from_root(target_dir).cir
6231
+ _prog.update("capturing baseline")
6232
+ done = _auto.run(target_dir, _cir, out_dir=out_dir, ref=ref, decision=plan)
6233
+ _prog.finish()
6234
+ if done.action == _auto.CAPTURED:
6235
+ typer.echo(
6236
+ f"Baseline written to {done.baseline_file} "
6237
+ f"(commit={done.commit}; history holds {done.history_size})"
6238
+ )
6239
+ else:
6240
+ typer.echo(f"Baseline not captured ({done.reason}): {done.statement}")
6241
+ return
6242
+
6143
6243
  _prog = Progress()
6144
6244
  _prog.start("building IR")
6145
6245
  _cir = ContextGraph.build_from_root(target_dir).cir
@@ -6164,9 +6264,11 @@ def baseline_diff_cmd(
6164
6264
  Path("."),
6165
6265
  help="Repository root to diff against the stored baseline (default: cwd).",
6166
6266
  ),
6167
- baseline_file: Path = typer.Option(
6168
- ..., "--baseline", "-b",
6169
- help="Path to a stored architectural-baseline-v1 JSON artifact.",
6267
+ baseline_file: Optional[Path] = typer.Option(
6268
+ None, "--baseline", "-b",
6269
+ help="Path to a stored architectural-baseline-v1 JSON artifact. Omit it to "
6270
+ "diff against the most recent baseline in the repository's own history "
6271
+ "(<path>/.ask/baselines) — the question a captured series exists for.",
6170
6272
  ),
6171
6273
  output_path: Optional[Path] = typer.Option(
6172
6274
  None, "--output", "-o",
@@ -6202,12 +6304,32 @@ def baseline_diff_cmd(
6202
6304
  or pass --allow-incomparable to override.
6203
6305
 
6204
6306
  \b
6205
- Example:
6307
+ Examples:
6308
+ ask baseline diff ./repo
6206
6309
  ask baseline diff ./repo --baseline ./repo/.ask/baselines/abc1234.json
6207
6310
  """
6208
6311
  _enforce_format("baseline-diff", format)
6209
6312
  target_dir = path.resolve()
6210
6313
  _require_dir(target_dir, "diff")
6314
+
6315
+ if baseline_file is None:
6316
+ from sourcecode.architectural_baseline import latest_baseline_path
6317
+
6318
+ history = _resolve_history_dir(target_dir)
6319
+ latest = latest_baseline_path(history)
6320
+ if latest is None:
6321
+ _emit_error_json(
6322
+ INVALID_INPUT_CODE,
6323
+ f"No captured baseline found for '{target_dir}'.",
6324
+ path=str(history),
6325
+ hint="Capture one first: ask baseline capture <path> "
6326
+ "(or --auto in the pipeline that already runs).",
6327
+ expected="A stored architectural-baseline-v1 JSON file, or a "
6328
+ "repository whose .ask/baselines holds one.",
6329
+ )
6330
+ raise typer.Exit(code=1)
6331
+ baseline_file = latest
6332
+
6211
6333
  if not baseline_file.exists():
6212
6334
  _emit_error_json(
6213
6335
  INVALID_INPUT_CODE,
@@ -6262,8 +6384,9 @@ def baseline_diff_cmd(
6262
6384
  @baseline_app.command("trend")
6263
6385
  def baseline_trend_cmd(
6264
6386
  directory: Path = typer.Argument(
6265
- ...,
6266
- help="Directory of stored architectural-baseline-v1 artifacts.",
6387
+ Path("."),
6388
+ help="A repository whose history to read, or a directory of stored "
6389
+ "architectural-baseline-v1 artifacts (default: current directory).",
6267
6390
  ),
6268
6391
  output_path: Optional[Path] = typer.Option(
6269
6392
  None, "--output", "-o",
@@ -6286,11 +6409,12 @@ def baseline_trend_cmd(
6286
6409
  fan-in tracks. Reports movement only — no improving/degrading label.
6287
6410
 
6288
6411
  \b
6289
- Example:
6412
+ Examples:
6413
+ ask baseline trend ./repo
6290
6414
  ask baseline trend ./repo/.ask/baselines
6291
6415
  """
6292
6416
  _enforce_format("baseline-trend", format)
6293
- trend_dir = directory.resolve()
6417
+ trend_dir = _resolve_history_dir(directory.resolve())
6294
6418
  _require_dir(trend_dir, "trend")
6295
6419
 
6296
6420
  from sourcecode.architectural_baseline import build_trend, load_baselines_dir
@@ -6301,8 +6425,10 @@ def baseline_trend_cmd(
6301
6425
  INVALID_INPUT_CODE,
6302
6426
  f"No architectural baselines found in '{trend_dir}'.",
6303
6427
  path=str(trend_dir),
6304
- hint="Capture some first: ask baseline capture <path> --dir <this dir>.",
6305
- expected="A directory containing architectural-baseline-v1 JSON files.",
6428
+ hint="Capture some first: ask baseline capture <path> "
6429
+ "(or --auto in the pipeline that already runs).",
6430
+ expected="A repository with a captured history, or a directory "
6431
+ "containing architectural-baseline-v1 JSON files.",
6306
6432
  )
6307
6433
  raise typer.Exit(code=1)
6308
6434
 
@@ -6321,8 +6447,9 @@ def baseline_trend_cmd(
6321
6447
  @app.command("trend")
6322
6448
  def trend_cmd(
6323
6449
  directory: Path = typer.Argument(
6324
- ...,
6325
- help="Directory of stored architectural-baseline-v1 artifacts.",
6450
+ Path("."),
6451
+ help="A repository whose history to read, or a directory of stored "
6452
+ "architectural-baseline-v1 artifacts (default: current directory).",
6326
6453
  ),
6327
6454
  output_path: Optional[Path] = typer.Option(
6328
6455
  None, "--output", "-o",
@@ -6351,7 +6478,8 @@ def trend_cmd(
6351
6478
  fan-in tracks. Reports movement only — no improving/degrading label.
6352
6479
 
6353
6480
  \b
6354
- Example:
6481
+ Examples:
6482
+ ask trend ./repo
6355
6483
  ask trend ./repo/.ask/baselines
6356
6484
  """
6357
6485
  baseline_trend_cmd(
@@ -6897,6 +7025,15 @@ def verify_cmd(
6897
7025
  repository could not be analysed — never a silent pass). Same codes as
6898
7026
  `verify-edit`.
6899
7027
 
7028
+ \b
7029
+ Where the repository already keeps an architectural history (`.ask/baselines`),
7030
+ this run adds the current commit to it and reports what it did under
7031
+ `architectural_history` — the gate already built the IR a baseline is extracted
7032
+ from, and a series that depends on somebody remembering will not exist. It never
7033
+ starts a history on its own, never captures from a dirty tree, and never changes
7034
+ this command's verdict. `--capture-baseline` above is a different artifact: the
7035
+ accepted *contract debt*, not the architectural series.
7036
+
6900
7037
  \b
6901
7038
  Examples:
6902
7039
  ask verify --init # derive contracts this repo satisfies
@@ -6985,8 +7122,24 @@ def verify_cmd(
6985
7122
  )
6986
7123
  return
6987
7124
 
7125
+ # The architectural history rides along with the gate (M10.5). This is the run
7126
+ # that already happens on every push and already builds the IR a baseline is
7127
+ # extracted from, so the series costs a file write instead of an analysis — and
7128
+ # a series nobody has to remember to capture is the one that exists. It writes
7129
+ # only where a history is already kept (or where the operator opted in), never
7130
+ # from a dirty tree, never twice for a commit, and never changes this command's
7131
+ # verdict or exit code.
7132
+ from sourcecode import baseline_autocapture as _bac
7133
+
7134
+ _history: dict = {}
7135
+
7136
+ def _capture(cir) -> None:
7137
+ _history["decision"] = _bac.run(path, cir)
7138
+
6988
7139
  try:
6989
- report = _vr.verify_repo(path, fail_on=fail_on, baseline_path=baseline)
7140
+ report = _vr.verify_repo(
7141
+ path, fail_on=fail_on, baseline_path=baseline, on_cir=_capture
7142
+ )
6990
7143
  except _vr.VerifyError as exc:
6991
7144
  _emit_error_json(
6992
7145
  INVALID_INPUT_CODE,
@@ -6995,8 +7148,17 @@ def verify_cmd(
6995
7148
  )
6996
7149
  raise typer.Exit(code=2 if ci else 0)
6997
7150
 
7151
+ payload = report.to_dict()
7152
+ # Published on every run, including the ones that captured nothing: a history
7153
+ # that silently stopped growing is invisible until the day somebody needs the
7154
+ # series (I-8 — a run declares the effect of what it did not do).
7155
+ payload["architectural_history"] = (
7156
+ _history.get("decision")
7157
+ or _bac.CaptureDecision(_bac.SKIPPED, _bac.NO_ANALYSIS)
7158
+ ).to_dict()
7159
+
6998
7160
  _emit_command_output(
6999
- _serialize_dict(report.to_dict(), format),
7161
+ _serialize_dict(payload, format),
7000
7162
  output_path,
7001
7163
  False,
7002
7164
  success_msg=f"verify report written to {output_path}",
@@ -7019,6 +7181,12 @@ def risk_cmd(
7019
7181
  "--min-band",
7020
7182
  help="Floor on the composed band: critical | high | medium | low.",
7021
7183
  ),
7184
+ profile: Optional[str] = typer.Option(
7185
+ None,
7186
+ "--profile",
7187
+ help="Active profile set, comma-separated (e.g. prod or prod,metrics). "
7188
+ "Binds the access axis to what THAT deployment leaves reachable.",
7189
+ ),
7022
7190
  output_path: Optional[Path] = typer.Option(
7023
7191
  None, "--output", "-o", help="Write the report to a file instead of stdout."
7024
7192
  ),
@@ -7035,13 +7203,16 @@ def risk_cmd(
7035
7203
  commands saying "medium".
7036
7204
 
7037
7205
  \b
7038
- severity_effective = defect_severity × reachability × auth_verdict × write_effect
7206
+ {{RISK_MODEL}}
7039
7207
 
7040
7208
  \b
7041
7209
  No new analysis: every factor is read from the command that already publishes
7042
- it, and every row publishes its four factors with the authority each came
7043
- from, so a reader can disagree with one and keep the rest. An axis that could
7044
- not be measured is `unknown`, multiplies by 1.0, and is named in `blind_axes`.
7210
+ it, and every row publishes every factor with the authority each came from, so
7211
+ a reader can disagree with one and keep the rest. An axis that could not be
7212
+ measured is `unknown`, multiplies by 1.0, and is named in `blind_axes`. The
7213
+ two input-path axes — a query built by concatenation, a route accepting an
7214
+ unconstrained body — are adjacencies: no dataflow is followed (NC-001, in the
7215
+ payload's `non_coverage`).
7045
7216
 
7046
7217
  \b
7047
7218
  One row is one **defect** — the same identity `spring-audit` groups by, so the
@@ -7049,10 +7220,18 @@ def risk_cmd(
7049
7220
  as `witness_count` and `witness_sites`, and `total_findings` publishes the
7050
7221
  observation census under its own name.
7051
7222
 
7223
+ \b
7224
+ `--profile prod` binds the access axis to what THAT deployment leaves
7225
+ reachable: `posture` resolves the conditional bean graph and the request
7226
+ chain, and its decision resolves an endpoint the repository-wide surface left
7227
+ at `coverage_unknown` — in either direction. Without it the axis answers for
7228
+ the repository as configured.
7229
+
7052
7230
  \b
7053
7231
  Examples:
7054
7232
  ask risk .
7055
7233
  ask risk . --min-band high
7234
+ ask risk . --profile prod
7056
7235
  ask risk . --limit 10 -o risk.json
7057
7236
  """
7058
7237
  from sourcecode.risk import build_risk
@@ -7067,7 +7246,7 @@ def risk_cmd(
7067
7246
  )
7068
7247
  raise typer.Exit(code=1)
7069
7248
 
7070
- data = build_risk(path, limit=limit, min_band=min_band)
7249
+ data = build_risk(path, limit=limit, min_band=min_band, profiles=_profile_set(profile))
7071
7250
  _emit_command_output(
7072
7251
  _serialize_dict(data, format),
7073
7252
  output_path,
@@ -7079,6 +7258,356 @@ def risk_cmd(
7079
7258
  )
7080
7259
 
7081
7260
 
7261
+ @app.command("enrich")
7262
+ def enrich_cmd(
7263
+ path: Path = typer.Argument(
7264
+ Path("."),
7265
+ help="Repository path (default: current directory).",
7266
+ ),
7267
+ sarif: Optional[Path] = typer.Option(
7268
+ None,
7269
+ "--sarif",
7270
+ help="SARIF 2.1.0 log from another scanner (Semgrep, CodeQL, Trivy…).",
7271
+ ),
7272
+ limit: int = typer.Option(
7273
+ 50, "--limit", help="How many enriched findings to publish (highest first)."
7274
+ ),
7275
+ min_band: str = typer.Option(
7276
+ "low",
7277
+ "--min-band",
7278
+ help="Floor on the composed band: critical | high | medium | low.",
7279
+ ),
7280
+ profile: Optional[str] = typer.Option(
7281
+ None,
7282
+ "--profile",
7283
+ help="Active profile set, comma-separated (e.g. prod or prod,metrics). "
7284
+ "Binds the access axis to what THAT deployment leaves reachable.",
7285
+ ),
7286
+ output_path: Optional[Path] = typer.Option(
7287
+ None, "--output", "-o", help="Write the report to a file instead of stdout."
7288
+ ),
7289
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json or yaml."),
7290
+ ) -> None:
7291
+ """[EXPERIMENTAL] Somebody else's findings, ranked by what this repository does with them.
7292
+
7293
+ \b
7294
+ This product does not detect injection, CVEs or secrets, and says so in every
7295
+ security payload (NC-001, NC-002). A scanner that does has no bean graph, no
7296
+ profile model and no endpoint id it shares with anything, so its output is a
7297
+ list nobody can order. `enrich` reads that list and answers the question the
7298
+ scanner cannot: which of these findings sits in code an unauthenticated
7299
+ request reaches.
7300
+
7301
+ \b
7302
+ {{RISK_MODEL}}
7303
+
7304
+ \b
7305
+ The same composition `ask risk` runs, with one difference: `defect_severity`
7306
+ is the scanner's own verdict, carried through with the scanner named as its
7307
+ authority. Nothing here re-rates, confirms or contradicts a finding.
7308
+
7309
+ \b
7310
+ A result located on a build manifest is a statement about a **library**, not
7311
+ about a line of this repository's code, so it is joined differently: the
7312
+ coordinate is read back out of the manifest the scanner named, and the reach is
7313
+ the worst of the types that import it. That is the CVE question crossed with
7314
+ exposure — of forty vulnerable dependencies, which ones does an unauthenticated
7315
+ route actually reach. A library nothing imports is `unresolved`, never
7316
+ "unreachable": reflection and service loaders leave no import edge.
7317
+
7318
+ \b
7319
+ A result this build cannot place — a file outside the population it read, a
7320
+ line with no declaration above it — is published under `unresolved` with the
7321
+ reason. It is never dropped, because a count of enriched findings that quietly
7322
+ omitted them is exactly the confident falsehood this product is built against.
7323
+
7324
+ \b
7325
+ Examples:
7326
+ ask enrich . --sarif semgrep.sarif
7327
+ ask enrich . --sarif codeql.sarif --min-band high
7328
+ ask enrich . --sarif trivy.sarif --profile prod
7329
+ ask enrich /path/to/repo --sarif trivy.sarif -o enriched.json
7330
+ """
7331
+ from sourcecode.sarif import enrich_sarif
7332
+
7333
+ path = _admit_path(path)
7334
+ if sarif is None:
7335
+ _emit_error_json(
7336
+ INVALID_INPUT_CODE,
7337
+ "--sarif is required: this command enriches another tool's findings, "
7338
+ "it does not detect any of its own.",
7339
+ hint="Example: ask enrich . --sarif semgrep.sarif",
7340
+ expected="a path to a SARIF 2.1.0 log",
7341
+ )
7342
+ raise typer.Exit(code=1)
7343
+ if min_band not in ("critical", "high", "medium", "low"):
7344
+ _emit_error_json(
7345
+ INVALID_INPUT_CODE,
7346
+ f"--min-band expects critical | high | medium | low (got {min_band!r}).",
7347
+ hint="Example: --min-band high",
7348
+ expected="critical|high|medium|low",
7349
+ )
7350
+ raise typer.Exit(code=1)
7351
+ if not Path(sarif).is_file():
7352
+ _emit_error_json(
7353
+ INVALID_INPUT_CODE,
7354
+ f"SARIF log not found: {sarif}",
7355
+ hint="Point --sarif at the file the scanner wrote.",
7356
+ expected="an existing SARIF 2.1.0 log",
7357
+ )
7358
+ raise typer.Exit(code=1)
7359
+
7360
+ try:
7361
+ data = enrich_sarif(
7362
+ Path(sarif), path, limit=limit, min_band=min_band,
7363
+ profiles=_profile_set(profile),
7364
+ )
7365
+ except (ValueError, json.JSONDecodeError) as exc:
7366
+ _emit_error_json(
7367
+ INVALID_INPUT_CODE,
7368
+ f"{sarif} is not a readable SARIF 2.1.0 log: {exc}",
7369
+ hint="Check that the file is the scanner's SARIF output, not its native format.",
7370
+ expected="a SARIF 2.1.0 JSON document with a `runs` array",
7371
+ )
7372
+ raise typer.Exit(code=1)
7373
+
7374
+ _emit_command_output(
7375
+ _serialize_dict(data, format),
7376
+ output_path,
7377
+ False,
7378
+ success_msg=(
7379
+ f"enriched findings written to {output_path} ({data['shown']} of "
7380
+ f"{data['defects']} defects composed, {len(data['unresolved'])} unplaced)"
7381
+ ),
7382
+ )
7383
+
7384
+
7385
+ @app.command("migrate-recipe")
7386
+ def migrate_recipe_cmd(
7387
+ path: Path = typer.Argument(
7388
+ Path("."),
7389
+ help="Repository path to scan (default: current directory).",
7390
+ ),
7391
+ write: bool = typer.Option(
7392
+ False, "--write",
7393
+ help="Write the recipe to <path>/rewrite.yml instead of only publishing it. "
7394
+ "Read-only by default: an audit of somebody else's repository must be "
7395
+ "able to run this.",
7396
+ ),
7397
+ force: bool = typer.Option(
7398
+ False, "--force",
7399
+ help="Overwrite an existing rewrite.yml (only with --write).",
7400
+ ),
7401
+ recipe_name: str = typer.Option(
7402
+ None, "--name",
7403
+ help="Name of the generated composite recipe (default: ask.MigrationFromMeasuredFindings).",
7404
+ ),
7405
+ min_severity: str = typer.Option(
7406
+ "low", "--min-severity",
7407
+ help="Minimum severity to include: critical | high | medium | low.",
7408
+ ),
7409
+ include_hygiene: bool = typer.Option(
7410
+ False, "--include-hygiene",
7411
+ help="Add the advisory hygiene recipes (java.util.Date → java.time and the "
7412
+ "rest of the best-practice bucket). Excluded by default: a migration "
7413
+ "diff that also modernises date handling cannot be reviewed as one "
7414
+ "change.",
7415
+ ),
7416
+ target_jdk: Optional[int] = typer.Option(
7417
+ None, "--target-jdk",
7418
+ help="JDK the migration targets (passed through to migrate-check).",
7419
+ ),
7420
+ output_path: Optional[Path] = typer.Option(
7421
+ None, "--output", "-o", help="Write the report to a file instead of stdout."
7422
+ ),
7423
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json or yaml."),
7424
+ ) -> None:
7425
+ """[EXPERIMENTAL] The migration report, in the form the executor takes.
7426
+
7427
+ \b
7428
+ `migrate-check` already names the OpenRewrite recipe each finding carries —
7429
+ 296 of 309 on openmrs-core, 144 of 170 on BroadleafCommerce — and a reader
7430
+ still had to copy them out of a JSON payload by hand. This emits the
7431
+ declarative recipe that runs exactly those, plus the invocation for the build
7432
+ this repository actually declares.
7433
+
7434
+ \b
7435
+ It does not rewrite anything, and that is a position rather than a gap
7436
+ (NC-005): OpenRewrite is the executor, this is its input. The recipe list is
7437
+ derived from findings — never from a catalogue of what OpenRewrite can do,
7438
+ because a recipe nobody's finding asked for would rewrite code nobody
7439
+ measured — and every blocking finding with no recipe is published under
7440
+ `manual_remainder`, because "run this and you are done" is the failure mode a
7441
+ 585-blocker report cannot afford twice.
7442
+
7443
+ \b
7444
+ Examples:
7445
+ ask migrate-recipe . # publish the recipe, write nothing
7446
+ ask migrate-recipe . --write # write <path>/rewrite.yml
7447
+ ask migrate-recipe . --min-severity high
7448
+ """
7449
+ from sourcecode.migrate_check import run_migrate_check
7450
+ from sourcecode.openrewrite_recipe import DEFAULT_FILENAME, DEFAULT_RECIPE_NAME, build_plan
7451
+ from sourcecode.repository_ir import find_java_files
7452
+
7453
+ target = _admit_path(path)
7454
+ _enforce_format("migrate-recipe", format)
7455
+ if min_severity not in ("critical", "high", "medium", "low"):
7456
+ _emit_error_json(
7457
+ INVALID_INPUT_CODE,
7458
+ f"Invalid min-severity '{min_severity}'.",
7459
+ hint="min-severity must be one of: critical, high, medium, low.",
7460
+ expected="critical | high | medium | low",
7461
+ )
7462
+ raise typer.Exit(code=1)
7463
+
7464
+ _prog = Progress()
7465
+ _prog.start("scanning for migration findings")
7466
+ try:
7467
+ report = run_migrate_check(
7468
+ find_java_files(target), target,
7469
+ min_severity=min_severity, target_jdk=target_jdk,
7470
+ )
7471
+ finally:
7472
+ _prog.finish()
7473
+
7474
+ plan = build_plan(
7475
+ report, target,
7476
+ recipe_name=recipe_name or DEFAULT_RECIPE_NAME,
7477
+ include_hygiene=include_hygiene,
7478
+ )
7479
+ data = plan.to_dict()
7480
+
7481
+ destination = target / DEFAULT_FILENAME
7482
+ if write:
7483
+ if destination.exists() and not force:
7484
+ _emit_error_json(
7485
+ INVALID_INPUT_CODE,
7486
+ f"{destination} already exists; --write would replace it.",
7487
+ path=str(destination),
7488
+ hint="Review that file, or re-run with --force to overwrite it.",
7489
+ expected="No rewrite.yml at the repository root, or --force.",
7490
+ )
7491
+ raise typer.Exit(code=1)
7492
+ if not plan.recipes:
7493
+ # Writing an empty recipe list produces a file the executor accepts and
7494
+ # that changes nothing — a green run that proves nothing was migrated.
7495
+ _emit_error_json(
7496
+ INVALID_INPUT_CODE,
7497
+ "No finding in this repository names an OpenRewrite recipe, so there "
7498
+ "is nothing to write.",
7499
+ path=str(target),
7500
+ hint="Run `ask migrate-check` to see what was found; the remainder is "
7501
+ "manual work, listed under `manual_remainder`.",
7502
+ expected="At least one finding carrying a recipe.",
7503
+ )
7504
+ raise typer.Exit(code=1)
7505
+ destination.write_text(plan.document, encoding="utf-8")
7506
+ data["written_to"] = str(destination)
7507
+ else:
7508
+ data["written_to"] = None
7509
+
7510
+ _emit_command_output(
7511
+ _serialize_dict(data, format),
7512
+ output_path,
7513
+ False,
7514
+ success_msg=(
7515
+ f"migration recipe written to {output_path} "
7516
+ f"({plan.covered_findings} of {plan.total_findings} findings covered by "
7517
+ f"{len(plan.recipes)} recipe(s))"
7518
+ ),
7519
+ )
7520
+
7521
+
7522
+ @app.command("data-exposure")
7523
+ def data_exposure_cmd(
7524
+ path: Path = typer.Argument(
7525
+ Path("."),
7526
+ help="Repository path (default: current directory).",
7527
+ ),
7528
+ profile: Optional[str] = typer.Option(
7529
+ None,
7530
+ "--profile",
7531
+ help="Active profile set, comma-separated (e.g. prod). Says what THAT "
7532
+ "deployment leaves reachable on the routes that carry labelled data.",
7533
+ ),
7534
+ output_path: Optional[Path] = typer.Option(
7535
+ None, "--output", "-o", help="Write the report to a file instead of stdout."
7536
+ ),
7537
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json or yaml."),
7538
+ ) -> None:
7539
+ """[EXPERIMENTAL] Which routes can carry the data you labelled, and who reaches them.
7540
+
7541
+ \b
7542
+ Nothing here decides what is sensitive: that is a judgement about a domain, and
7543
+ a field called `dni` is a national identifier in one repository and a column
7544
+ name in another. Declare it once in `sourcecode.config.json`:
7545
+
7546
+ \b
7547
+ {"dataLabels": [{"label": "pii", "types": ["com.example.Person"],
7548
+ "fields": ["com.example.Account#iban"]}]}
7549
+
7550
+ \b
7551
+ Everything after the declaration is measured, over authorities that already
7552
+ exist — the same call reach `impact-chain` walks, the same endpoint security
7553
+ surface `risk` weighs, the same profile resolution `posture` publishes. Two
7554
+ evidence classes, published apart and never added together:
7555
+ signature — the handler names the labelled type in its own signature
7556
+ call_reach — the handler's chain reaches it: a supremum, not a proof
7557
+
7558
+ \b
7559
+ A route absent from the answer is not evidence that it carries nothing:
7560
+ reflection, a runtime serializer, an untyped payload and a native query name no
7561
+ type (NC-008). No labels declared is reported as `answered: false` — never as
7562
+ zero exposed routes.
7563
+
7564
+ \b
7565
+ Examples:
7566
+ ask data-exposure .
7567
+ ask data-exposure . --profile prod
7568
+ ask data-exposure /path/to/repo -o exposure.json
7569
+ """
7570
+ from sourcecode.data_exposure import build_data_exposure
7571
+
7572
+ path = _admit_path(path)
7573
+ _prog = Progress()
7574
+ _prog.start("measuring labelled data exposure")
7575
+ data = build_data_exposure(path, profiles=_profile_set(profile))
7576
+ _prog.finish()
7577
+
7578
+ summary = data["summary"]
7579
+ exposed = summary["endpoints_exposed"]
7580
+ _emit_command_output(
7581
+ _serialize_dict(data, format),
7582
+ output_path,
7583
+ False,
7584
+ success_msg=(
7585
+ f"data exposure written to {output_path} "
7586
+ + (
7587
+ f"({exposed} route(s) carry {summary['labels']} label(s))"
7588
+ if data["answered"] else "(no labels declared — nothing measured)"
7589
+ )
7590
+ ),
7591
+ )
7592
+
7593
+
7594
+ def _render_risk_help(doc: str) -> str:
7595
+ """Fill the model line from the module that computes it.
7596
+
7597
+ The help screen carried its own copy of the composition and went on
7598
+ advertising four factors after a fifth and a sixth shipped — the same C4
7599
+ shape as `spring-audit`'s rule list, which is why this is rendered and not
7600
+ written twice.
7601
+ """
7602
+ from sourcecode.risk import RISK_MODEL
7603
+
7604
+ return doc.replace("{{RISK_MODEL}}", RISK_MODEL)
7605
+
7606
+
7607
+ risk_cmd.__doc__ = _render_risk_help(risk_cmd.__doc__ or "")
7608
+ enrich_cmd.__doc__ = _render_risk_help(enrich_cmd.__doc__ or "")
7609
+
7610
+
7082
7611
  @app.command("posture")
7083
7612
  def posture_cmd(
7084
7613
  path: Path = typer.Argument(
@@ -7182,7 +7711,7 @@ def posture_cmd(
7182
7711
  path = _admit_path(path)
7183
7712
 
7184
7713
  def _parse_set(raw: str) -> set:
7185
- return {p.strip() for p in raw.split(",") if p.strip()}
7714
+ return _profile_set(raw) or set()
7186
7715
 
7187
7716
  overrides: dict = {}
7188
7717
  for item in property_overrides or []:
@@ -10709,7 +11238,7 @@ HELP_PANELS: "tuple[tuple[str, tuple[str, ...]], ...]" = (
10709
11238
  "cache", "auth", "mcp", "telemetry", "baseline",
10710
11239
  )),
10711
11240
  ("Experimental — shape may change", (
10712
- "risk", "archetype", "retrieve",
11241
+ "risk", "enrich", "data-exposure", "migrate-recipe", "archetype", "retrieve",
10713
11242
  )),
10714
11243
  )
10715
11244