sourcecode 2.7.0__py3-none-any.whl → 3.0.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
@@ -268,6 +268,12 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
268
268
  "archetype",
269
269
  # Experimental Semantic Retrieval Engine (Layer 3.5)
270
270
  "retrieve",
271
+ # Published output contracts (JSON Schema)
272
+ "schema",
273
+ # Repository-wide contract gate (baseline-relative)
274
+ "verify",
275
+ # Effective configuration under a profile set
276
+ "posture",
271
277
  }
272
278
  )
273
279
 
@@ -298,7 +304,7 @@ _OPTIONS_WITH_VALUE: frozenset[str] = frozenset({
298
304
  "--depth",
299
305
  "--git-depth",
300
306
  "--git-days",
301
- "--since",
307
+ "--since", "--base",
302
308
  "--path", "-p",
303
309
  "--mode",
304
310
  "--max-symbols",
@@ -308,6 +314,11 @@ _OPTIONS_WITH_VALUE: frozenset[str] = frozenset({
308
314
  "--max-importers",
309
315
  "--exclude",
310
316
  "--files",
317
+ "--fail-on",
318
+ "--baseline",
319
+ "--profile",
320
+ "--diff",
321
+ "--min-severity",
311
322
  })
312
323
 
313
324
 
@@ -444,6 +455,29 @@ def _is_always_include_ref(p: str) -> bool:
444
455
  return any(seg in ("security", "seguridad", "constantes") for seg in parts)
445
456
 
446
457
 
458
+ def _changed_files_in_scan(
459
+ scan_paths: "Optional[list[str]]",
460
+ changed_files: "Optional[set[str]]",
461
+ ) -> "Optional[list[str]]":
462
+ """The changed files the scan actually saw, in the scan's own namespace.
463
+
464
+ `changed_files` comes from git and `scan_paths` from the file walk; both are
465
+ already scope-relative (see `git_analyzer.to_scope_relative`). Intersecting
466
+ them is what makes `--changed-only` say *what was analyzed* rather than what
467
+ git happened to report: a changed file that the walk excluded (vendored,
468
+ ignored, outside the scan) was never analyzed and must not be listed as if
469
+ it were. Security-const files ride along as read-only reference anchors.
470
+
471
+ Returns None when the scan path list is unavailable — the caller must then
472
+ omit the field rather than emit an empty list, which would read as "this
473
+ scope has no files".
474
+ """
475
+ if scan_paths is None:
476
+ return None
477
+ allowed = changed_files or set()
478
+ return [p for p in scan_paths if p in allowed or _is_always_include_ref(p)]
479
+
480
+
447
481
  def filter_sensitive_files(tree: dict[str, Any], redactor: Any) -> dict[str, Any]:
448
482
  """Recursively filter .env and *.secret entries from the file tree (SEC-02)."""
449
483
  filtered: dict[str, Any] = {}
@@ -549,19 +583,99 @@ def _safe_write_file(path: "Path", content: str) -> None:
549
583
  raise typer.Exit(code=1) from None
550
584
 
551
585
 
586
+ def _stamp_envelope(content: str) -> str:
587
+ """Add `_meta` identity to a JSON object payload; pass anything else through.
588
+
589
+ YAML output, plain text, JSON arrays and unparseable content are returned
590
+ untouched — the envelope is a claim about a JSON object, and stamping
591
+ anything else would corrupt it.
592
+ """
593
+ stripped = content.lstrip()
594
+ if not stripped.startswith("{"):
595
+ return content
596
+ try:
597
+ import json as _je
598
+
599
+ payload = _je.loads(content)
600
+ if not isinstance(payload, dict):
601
+ return content
602
+ from sourcecode.envelope import attach_meta, build_meta
603
+
604
+ command, target = _active_command_context()
605
+ if not command:
606
+ return content
607
+ meta = build_meta(
608
+ command,
609
+ target,
610
+ schema_version=payload.get("schema_version"),
611
+ )
612
+ return _je.dumps(attach_meta(payload, meta), indent=2, ensure_ascii=False)
613
+ except Exception:
614
+ return content
615
+
616
+
617
+ def _active_command_context() -> "tuple[str, Optional[Path]]":
618
+ """(command name, scope path) of the running invocation, from Click.
619
+
620
+ The name is the full path through the group tree ("retrieve
621
+ endpoint-inventory"), because that is what a consumer has to quote when it
622
+ asks where a payload came from.
623
+ """
624
+ try:
625
+ import click
626
+
627
+ ctx = click.get_current_context(silent=True)
628
+ except Exception:
629
+ return "", None
630
+ if ctx is None:
631
+ return "", None
632
+
633
+ names: list[str] = []
634
+ node: Any = ctx
635
+ while node is not None:
636
+ name = getattr(node, "info_name", None)
637
+ if name:
638
+ names.append(str(name))
639
+ node = getattr(node, "parent", None)
640
+ names.reverse()
641
+ if names and names[0] in ("ask", "sourcecode"):
642
+ names = names[1:]
643
+
644
+ target: "Optional[Path]" = None
645
+ params = getattr(ctx, "params", None) or {}
646
+ for key in ("path", "target", "repo", "repo_path", "directory"):
647
+ value = params.get(key)
648
+ if isinstance(value, Path):
649
+ target = value
650
+ break
651
+ if isinstance(value, str) and value:
652
+ target = Path(value)
653
+ break
654
+ if target is None:
655
+ target = Path.cwd()
656
+ return " ".join(names), target
657
+
658
+
552
659
  def _emit_command_output(
553
660
  content: str,
554
661
  output_path: "Optional[Path]",
555
662
  copy: bool,
556
663
  *,
557
664
  success_msg: "Optional[str]" = None,
665
+ stamp_envelope: bool = True,
558
666
  ) -> None:
559
667
  """Unified output pipeline: stdout | file | clipboard.
560
668
 
561
- Single call site for every command's final output step.
669
+ Single call site for every command's final output step — and therefore the
670
+ one seam where the response envelope is stamped, so no command can ship a
671
+ payload without saying which version, scope and analyzer produced it.
672
+ `stamp_envelope=False` is for output that is not a payload about a
673
+ repository — a schema document describes the envelope, it does not carry one.
562
674
  File write failures emit JSON to stderr via _safe_write_file.
563
675
  Clipboard fires only when no --output path is given.
564
676
  """
677
+ if stamp_envelope:
678
+ content = _stamp_envelope(content)
565
679
  if output_path is not None:
566
680
  _safe_write_file(output_path, content)
567
681
  if success_msg:
@@ -1507,6 +1621,7 @@ def main(
1507
1621
  from sourcecode import cache as _cache_mod
1508
1622
 
1509
1623
  _cache_hit_content: Optional[str] = None
1624
+ _core_dict_l1: Optional[dict] = None
1510
1625
  _git_sha = ""
1511
1626
  # HEAD SHA is diagnostic metadata — compute unconditionally, not tied to cache.
1512
1627
  try:
@@ -1548,10 +1663,14 @@ def main(
1548
1663
 
1549
1664
  # ── Core (analysis) flags: affect which analyzers run + scan config ──
1550
1665
  # Use effective_depth (not raw depth) so Java auto-adjustment is captured.
1551
- # acv = ANALYZER_CACHE_VERSION — bumped only when analysis logic/schema
1552
- # changes, NOT on every package release. Prevents patch-bump cache wipes.
1666
+ # acv = ANALYZER_CACHE_VERSION — manual escape hatch, NOT bumped on
1667
+ # package release, so patch bumps never wipe the cache.
1668
+ # acode = content hash of the analyzer itself: a change to analysis
1669
+ # logic must invalidate results the old logic produced, and nobody has
1670
+ # to remember to say so.
1553
1671
  _core_flags_str = (
1554
1672
  f"acv={_cache_mod.ANALYZER_CACHE_VERSION},"
1673
+ f"acode={_cache_mod.analyzer_fingerprint()},"
1555
1674
  f"dep={dependencies},gm={graph_modules},"
1556
1675
  f"docs={docs},fm={full_metrics},sem={semantics},"
1557
1676
  f"arch={architecture},gc={git_context},em={env_map},"
@@ -1742,7 +1861,11 @@ def main(
1742
1861
  _core_hash = ""
1743
1862
 
1744
1863
  if _cache_hit_content is not None:
1745
- _hit_source = "L2_view" if (_view_key and _core_hash) else "L1_core"
1864
+ # --changed-only never reads L2 (the stored view is a previous diff
1865
+ # snapshot), so its content always came from the L1 rebuild — report that.
1866
+ _hit_source = (
1867
+ "L2_view" if (_view_key and _core_hash and not changed_only) else "L1_core"
1868
+ )
1746
1869
 
1747
1870
  # P0-A/B/C: --changed-only fast path via warm cache.
1748
1871
  if changed_only:
@@ -1781,11 +1904,21 @@ def main(
1781
1904
  # Dirty repo — filter file_paths in cached compact, unified schema.
1782
1905
  try:
1783
1906
  _co_base = json.loads(_cache_hit_content)
1784
- _fps_all = _co_base.get("file_paths", [])
1785
- _co_base["file_paths"] = [
1786
- p for p in _fps_all
1787
- if p in _co_uc_files or _is_always_include_ref(p)
1788
- ]
1907
+ # The rebuilt compact view carries no `file_paths`; take the
1908
+ # scan namespace from the L1 core (stored with the full tree)
1909
+ # so the warm path lists the same analyzed files as a cold run.
1910
+ _co_scan: Optional[list[str]] = None
1911
+ if isinstance(_core_dict_l1, dict):
1912
+ _co_std = _core_dict_l1.get("_standard")
1913
+ if isinstance(_co_std, dict):
1914
+ _co_fps = _co_std.get("file_paths")
1915
+ if isinstance(_co_fps, list):
1916
+ _co_scan = _co_fps
1917
+ _co_changed = _changed_files_in_scan(_co_scan, _co_uc_files)
1918
+ if _co_changed is not None:
1919
+ _co_base["file_paths"] = _co_changed
1920
+ else:
1921
+ _co_base.pop("file_paths", None)
1789
1922
  _co_base.pop("_cache", None)
1790
1923
  _co_dirty = json.dumps({
1791
1924
  "schema_version": "1.0",
@@ -2626,13 +2759,15 @@ def main(
2626
2759
  # counts are correct; filter file_paths in the output dict, not in sm.
2627
2760
  data = compact_view(sm, no_tree=no_tree, full=full)
2628
2761
  if changed_only and _allowed_changed_files:
2629
- # GAP-5: preserve full entry_points; filter file_paths display only.
2762
+ # GAP-5: preserve full entry_points; the changed-file list is the only
2763
+ # file-level surface compact carries. compact_view never emits
2764
+ # `file_paths` (it is a summary view), so this list is built from the
2765
+ # scan namespace instead of filtered out of a key that is not there —
2766
+ # otherwise every --changed-only run reported `file_paths: []`.
2630
2767
  # ALWAYS-INCLUDE: security-const files stay for Java constant refs.
2631
- _fps_full = data.get("file_paths", [])
2632
- data["file_paths"] = [
2633
- p for p in _fps_full
2634
- if p in _allowed_changed_files or _is_always_include_ref(p)
2635
- ]
2768
+ _fps_changed = _changed_files_in_scan(sm.file_paths, _allowed_changed_files)
2769
+ if _fps_changed is not None:
2770
+ data["file_paths"] = _fps_changed
2636
2771
  if not no_redact:
2637
2772
  data = redact_dict(data)
2638
2773
  # P0-1: Apply output budget — safety net for large repos.
@@ -2949,7 +3084,8 @@ def prepare_context_cmd(
2949
3084
  since: Optional[str] = typer.Option(
2950
3085
  None,
2951
3086
  "--since",
2952
- help="Git ref for delta task (e.g. HEAD~3, main)",
3087
+ "--base",
3088
+ help="Git ref for delta task (e.g. HEAD~3, main). `--base` is an alias.",
2953
3089
  ),
2954
3090
  llm_prompt: bool = typer.Option(
2955
3091
  False,
@@ -3649,7 +3785,8 @@ def repo_ir_cmd(
3649
3785
  since: Optional[str] = typer.Option(
3650
3786
  None,
3651
3787
  "--since",
3652
- help="Git ref for symbol-level diff (e.g. HEAD~1, main)",
3788
+ "--base",
3789
+ help="Git ref for symbol-level diff (e.g. HEAD~1, main). `--base` is an alias.",
3653
3790
  ),
3654
3791
  files: Optional[str] = typer.Option(
3655
3792
  None,
@@ -4059,10 +4196,15 @@ def _group_endpoints_by_controller(endpoints: "list[dict]") -> "dict":
4059
4196
  Returns ``{"by_controller": {fqn: [{method, path, return_type}, ...]},
4060
4197
  "controller_count": int, "total": int}``. Controllers and their routes are
4061
4198
  ordered deterministically (controllers by name, routes by path then method).
4199
+
4200
+ Keyed by FQN, as the docstring always said and the code did not: grouping on
4201
+ the simple class name merged distinct controllers that share one — keycloak
4202
+ reported 120 groups for 123 controller classes, and the three merged groups
4203
+ listed another package's routes.
4062
4204
  """
4063
4205
  by_ctrl: "dict[str, list[dict]]" = {}
4064
4206
  for ep in endpoints:
4065
- ctrl = ep.get("controller", "") or "<unknown>"
4207
+ ctrl = ep.get("controller_class") or ep.get("controller", "") or "<unknown>"
4066
4208
  by_ctrl.setdefault(ctrl, []).append({
4067
4209
  "method": ep.get("method", ""),
4068
4210
  "path": ep.get("path", ""),
@@ -5775,7 +5917,7 @@ def verify_edit_cmd(
5775
5917
  output_path: Optional[Path] = typer.Option(
5776
5918
  None, "--output", "-o", help="Write the verdict JSON to a file instead of stdout."
5777
5919
  ),
5778
- format: str = typer.Option("json", "--format", help="Output format: json."),
5920
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json."),
5779
5921
  ci: bool = typer.Option(
5780
5922
  True,
5781
5923
  "--ci/--no-ci",
@@ -5842,6 +5984,190 @@ def verify_edit_cmd(
5842
5984
  raise typer.Exit(code=v.exit_code)
5843
5985
 
5844
5986
 
5987
+ @app.command("verify")
5988
+ def verify_cmd(
5989
+ path: Path = typer.Argument(
5990
+ Path("."),
5991
+ help="Repository path (default: current directory). Checks the tree as it stands.",
5992
+ ),
5993
+ output_path: Optional[Path] = typer.Option(
5994
+ None, "--output", "-o", help="Write the report JSON to a file instead of stdout."
5995
+ ),
5996
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json."),
5997
+ fail_on: str = typer.Option(
5998
+ "new",
5999
+ "--fail-on",
6000
+ help=(
6001
+ "What blocks: new (violations absent from the baseline — default) | "
6002
+ "any (every violation) | never (report only)."
6003
+ ),
6004
+ show_default=True,
6005
+ ),
6006
+ baseline: Optional[Path] = typer.Option(
6007
+ None,
6008
+ "--baseline",
6009
+ help="Baseline file (default: .ask/contracts-baseline.json under the repo).",
6010
+ ),
6011
+ capture_baseline: bool = typer.Option(
6012
+ False,
6013
+ "--capture-baseline",
6014
+ help="Record today's violations as accepted debt and exit. Does not gate.",
6015
+ ),
6016
+ ci: bool = typer.Option(
6017
+ True,
6018
+ "--ci/--no-ci",
6019
+ help="Exit non-zero on a failing/unverified report (default on — for pipelines).",
6020
+ ),
6021
+ ) -> None:
6022
+ """Check a repository against the contracts it declares.
6023
+
6024
+ \b
6025
+ `verify-edit` asks "did this edit break something?" by diffing the working
6026
+ tree against HEAD. This asks the other question a pipeline needs: does the
6027
+ repository satisfy its contracts right now — on a branch, a merge commit, a
6028
+ nightly run, where there is no edit to diff.
6029
+
6030
+ \b
6031
+ Baseline-relative by default. A codebase adopting contracts already violates
6032
+ them in places; capture that once and only NEW violations block, so the gate
6033
+ survives contact with a real repository instead of being switched off.
6034
+
6035
+ \b
6036
+ Contracts live in `.ask/contracts.yml` (see `ask verify-edit --help` for the
6037
+ rule kinds). No contracts declared → nothing to verify, exit 0.
6038
+
6039
+ \b
6040
+ Exit codes (with --ci, the default): 0 = pass, 1 = violations blocked,
6041
+ 2 = unverified (contracts unreadable or the repository could not be
6042
+ analysed — never a silent pass). Same codes as `verify-edit`.
6043
+
6044
+ \b
6045
+ Examples:
6046
+ ask verify # gate on new violations
6047
+ ask verify --capture-baseline # accept today's debt
6048
+ ask verify --fail-on any # gate on every violation
6049
+ ask verify --fail-on never # report without gating
6050
+ """
6051
+ from sourcecode import verify_repo as _vr
6052
+
6053
+ if capture_baseline:
6054
+ try:
6055
+ captured = _vr.capture_baseline(path, baseline)
6056
+ except _vr.VerifyError as exc:
6057
+ _emit_error_json(
6058
+ INVALID_INPUT_CODE,
6059
+ str(exc),
6060
+ hint="Fix the contracts file, then capture the baseline again.",
6061
+ )
6062
+ raise typer.Exit(code=2 if ci else 0)
6063
+ _emit_command_output(
6064
+ _serialize_dict(
6065
+ {
6066
+ "schema_version": _vr.BASELINE_SCHEMA,
6067
+ "baseline_file": captured["baseline_file"],
6068
+ "captured": len(captured["violations"]),
6069
+ "head_sha": captured["head_sha"],
6070
+ },
6071
+ format,
6072
+ ),
6073
+ output_path,
6074
+ False,
6075
+ success_msg=f"baseline written to {captured['baseline_file']}",
6076
+ )
6077
+ return
6078
+
6079
+ try:
6080
+ report = _vr.verify_repo(path, fail_on=fail_on, baseline_path=baseline)
6081
+ except _vr.VerifyError as exc:
6082
+ _emit_error_json(
6083
+ INVALID_INPUT_CODE,
6084
+ str(exc),
6085
+ hint=f"--fail-on accepts: {' | '.join(_vr.FAIL_ON_CHOICES)}.",
6086
+ )
6087
+ raise typer.Exit(code=2 if ci else 0)
6088
+
6089
+ _emit_command_output(
6090
+ _serialize_dict(report.to_dict(), format),
6091
+ output_path,
6092
+ False,
6093
+ success_msg=f"verify report written to {output_path}",
6094
+ )
6095
+ if ci and report.exit_code != 0:
6096
+ raise typer.Exit(code=report.exit_code)
6097
+
6098
+
6099
+ @app.command("posture")
6100
+ def posture_cmd(
6101
+ path: Path = typer.Argument(
6102
+ Path("."),
6103
+ help="Repository path (default: current directory).",
6104
+ ),
6105
+ profile: Optional[str] = typer.Option(
6106
+ None,
6107
+ "--profile",
6108
+ help="Active profile set, comma-separated (e.g. prod or prod,metrics).",
6109
+ ),
6110
+ diff: Optional[str] = typer.Option(
6111
+ None,
6112
+ "--diff",
6113
+ help="Compare two profile sets: --diff dev:prod (each side comma-separated).",
6114
+ ),
6115
+ output_path: Optional[Path] = typer.Option(
6116
+ None, "--output", "-o", help="Write the report to a file instead of stdout."
6117
+ ),
6118
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json or yaml."),
6119
+ ) -> None:
6120
+ """[EXPERIMENTAL] Which beans are wired under a profile set — and which cannot be decided.
6121
+
6122
+ \b
6123
+ Profile detection answers "we saw a profile". This answers the question a
6124
+ reviewer actually has: under `prod`, which conditional beans are registered,
6125
+ which are not, and which conditions this analyzer could not evaluate.
6126
+
6127
+ \b
6128
+ EXPERIMENTAL: `@Profile` is resolved completely; every other condition
6129
+ (`@ConditionalOnProperty` and the rest of the `@Conditional*` family) is
6130
+ named and reported unresolved, never decided. On a repository that gates
6131
+ beans on properties, most of the answer will be "unresolved".
6132
+
6133
+ \b
6134
+ Unresolved is a first-class outcome. A bean whose registration depends on a
6135
+ property value or a condition class is never assumed active and never
6136
+ assumed absent — a posture answer that guesses is a confident falsehood.
6137
+
6138
+ \b
6139
+ Examples:
6140
+ ask posture . --profile prod
6141
+ ask posture . --profile prod,metrics
6142
+ ask posture . --diff dev:prod
6143
+ """
6144
+ from sourcecode.posture import build_posture, diff_posture
6145
+
6146
+ def _parse_set(raw: str) -> set:
6147
+ return {p.strip() for p in raw.split(",") if p.strip()}
6148
+
6149
+ if diff:
6150
+ if ":" not in diff:
6151
+ _emit_error_json(
6152
+ INVALID_INPUT_CODE,
6153
+ f"--diff needs two profile sets separated by ':' (got {diff!r}).",
6154
+ hint="Example: --diff dev:prod",
6155
+ expected="left:right",
6156
+ )
6157
+ raise typer.Exit(code=1)
6158
+ left_raw, right_raw = diff.split(":", 1)
6159
+ data = diff_posture(path, _parse_set(left_raw), _parse_set(right_raw))
6160
+ else:
6161
+ data = build_posture(path, _parse_set(profile) if profile else set())
6162
+
6163
+ _emit_command_output(
6164
+ _serialize_dict(data, format),
6165
+ output_path,
6166
+ False,
6167
+ success_msg=f"posture written to {output_path}",
6168
+ )
6169
+
6170
+
5845
6171
  # ── Spring Boot Migration Check ───────────────────────────────────────────────
5846
6172
 
5847
6173
 
@@ -6281,8 +6607,8 @@ def pr_impact_cmd(
6281
6607
  help="Write output to a file instead of stdout.",
6282
6608
  ),
6283
6609
  format: str = typer.Option(
6284
- "text", "--format", "-f",
6285
- help="Output format: text (default) or json.",
6610
+ "json", "--format", "-f",
6611
+ help="Output format: json (default) or text.",
6286
6612
  show_default=True,
6287
6613
  ),
6288
6614
  fail_on: Optional[str] = typer.Option(
@@ -6321,7 +6647,7 @@ def pr_impact_cmd(
6321
6647
  \b
6322
6648
  Examples:
6323
6649
  ask pr-impact --files changed_files.txt
6324
- ask pr-impact /path/to/repo --files diff.txt --format json
6650
+ ask pr-impact /path/to/repo --files diff.txt --format text
6325
6651
  ask pr-impact --files A.java,B.java --fail-on high
6326
6652
  git diff --name-only origin/main | ask pr-impact --files -
6327
6653
  """
@@ -6667,15 +6993,18 @@ def review_pr_cmd(
6667
6993
  help="Repository root (default: current directory)",
6668
6994
  ),
6669
6995
  since: Optional[str] = typer.Option(
6670
- None, "--since",
6671
- help="Git ref baseline for diff (e.g. origin/main, HEAD~3).",
6996
+ None, "--since", "--base",
6997
+ help=(
6998
+ "Git ref baseline for diff (e.g. origin/main, HEAD~3). "
6999
+ "`--base` is an alias: reviewing a PR, the ref is its base branch."
7000
+ ),
6672
7001
  ),
6673
7002
  output_path: Optional[Path] = typer.Option(
6674
7003
  None, "--output", "-o",
6675
7004
  help="Write output to a file instead of stdout.",
6676
7005
  ),
6677
7006
  format: Optional[str] = typer.Option(
6678
- None, "--format",
7007
+ None, "--format", "-f",
6679
7008
  help="Output format: json (default) | github-comment",
6680
7009
  ),
6681
7010
  copy: bool = typer.Option(
@@ -7622,13 +7951,72 @@ def version_cmd() -> None:
7622
7951
  typer.echo(f"ask {__version__}")
7623
7952
  else:
7624
7953
  import json as _json_ver
7954
+ from sourcecode.envelope import ENVELOPE_VERSION, available_schemas
7625
7955
  typer.echo(_json_ver.dumps({
7626
7956
  "cli_version": __version__,
7627
7957
  "mcp_schema_version": __version__,
7628
7958
  "compatibility_schema_version": "1.0",
7959
+ # What a consumer should actually pin against: the release string
7960
+ # changes every release, the envelope version only when the output
7961
+ # contract does.
7962
+ "envelope_version": ENVELOPE_VERSION,
7963
+ "published_schemas": available_schemas(),
7629
7964
  }, ensure_ascii=False))
7630
7965
 
7631
7966
 
7967
+ @app.command("schema")
7968
+ def schema_cmd(
7969
+ name: Optional[str] = typer.Argument(
7970
+ None,
7971
+ help="Schema to print (e.g. envelope-v1). Omit to list what is published.",
7972
+ ),
7973
+ output_path: Optional[Path] = typer.Option(
7974
+ None, "--output", "-o", help="Write the schema to a file instead of stdout.",
7975
+ ),
7976
+ ) -> None:
7977
+ """Print a published JSON Schema for this tool's output.
7978
+
7979
+ \b
7980
+ The contract a consumer pins against, shipped with the version that emits
7981
+ it — so "which shape does this release produce?" is answered by the release
7982
+ itself, not by documentation that can drift from it.
7983
+
7984
+ \b
7985
+ Examples:
7986
+ ask schema # list published schemas
7987
+ ask schema envelope-v1 # print the response-envelope schema
7988
+ """
7989
+ from sourcecode.envelope import available_schemas, load_schema
7990
+
7991
+ names = available_schemas()
7992
+ if name is None:
7993
+ _emit_command_output(
7994
+ json.dumps({"schemas": names}, indent=2, ensure_ascii=False),
7995
+ output_path,
7996
+ False,
7997
+ )
7998
+ return
7999
+ try:
8000
+ schema = load_schema(name)
8001
+ except FileNotFoundError:
8002
+ _emit_error_json(
8003
+ INVALID_INPUT_CODE,
8004
+ f"No published schema named '{name}'.",
8005
+ available=names,
8006
+ hint="Run `ask schema` to list published schemas.",
8007
+ expected="A published schema name.",
8008
+ )
8009
+ raise typer.Exit(code=1)
8010
+ # A schema document describes the envelope; stamping one into it would put
8011
+ # an unknown keyword inside the contract a consumer validates against.
8012
+ _emit_command_output(
8013
+ json.dumps(schema, indent=2, ensure_ascii=False),
8014
+ output_path,
8015
+ False,
8016
+ stamp_envelope=False,
8017
+ )
8018
+
8019
+
7632
8020
  # ── config ────────────────────────────────────────────────────────────────────
7633
8021
 
7634
8022
  @app.command("config")
@@ -7747,11 +8135,14 @@ def _run_retrieval_intent(
7747
8135
  if trace:
7748
8136
  payload = {"result": payload, "runtime": run.report.to_dict()}
7749
8137
  out = _json.dumps(payload, indent=2, ensure_ascii=False)
7750
- if output_path:
7751
- _safe_write_file(output_path, out)
7752
- typer.echo(f"Retrieval result written to {output_path}")
7753
- else:
7754
- typer.echo(out)
8138
+ # Through the common pipeline so every retrieval intent carries the same
8139
+ # envelope as the rest of the CLI.
8140
+ _emit_command_output(
8141
+ out,
8142
+ output_path,
8143
+ False,
8144
+ success_msg=f"Retrieval result written to {output_path}" if output_path else None,
8145
+ )
7755
8146
 
7756
8147
 
7757
8148
  @retrieve_app.command("architectural-core")
@@ -8668,12 +9059,23 @@ def cache_status_cmd(
8668
9059
  ) -> None:
8669
9060
  """Show cache statistics for a repository."""
8670
9061
  from sourcecode import cache as _cm
8671
- target = _resolve_repo_root(Path(path))
9062
+ # Report on exactly the path given, like `cache warm` and every analysis
9063
+ # command. Resolving up to the enclosing git root reported on a different
9064
+ # cache than the one `ask ./service-a` reads and `cache warm ./service-a`
9065
+ # writes — so a module's cache always looked empty right after warming it.
9066
+ target = Path(path).resolve()
9067
+ _git_root = _resolve_repo_root(Path(path))
8672
9068
  stats = _cm.status(target)
9069
+ stats["scope"] = str(target)
9070
+ if _git_root != target:
9071
+ stats["git_root"] = str(_git_root)
8673
9072
  if json_output:
8674
9073
  import json as _j
8675
9074
  typer.echo(_j.dumps(stats, indent=2, ensure_ascii=False))
8676
9075
  else:
9076
+ typer.echo(f"Scope: {target}")
9077
+ if _git_root != target:
9078
+ typer.echo(f"Git root: {_git_root} (this cache covers the scope above, not the whole repository)")
8677
9079
  typer.echo(f"Cache dir: {stats['cache_dir']}")
8678
9080
  typer.echo(f"Cores: {stats['cores']}")
8679
9081
  typer.echo(f"Views: {stats['views']}")
@@ -8702,7 +9104,11 @@ def cache_clear_cmd(
8702
9104
  index used for cold-start bootstrapping. Use --all to also clear it.
8703
9105
  """
8704
9106
  from sourcecode import cache as _cm
8705
- target = _resolve_repo_root(Path(path))
9107
+ # Clear exactly the path given, for the same reason `cache warm` warms it:
9108
+ # resolving to the git root left a module's own cache untouched and
9109
+ # unreachable — `cache clear ./service-a` reported "Removed 0 file(s)" while
9110
+ # that module's cache stayed on disk with no way to evict it.
9111
+ target = Path(path).resolve()
8706
9112
  _clear_ris = include_ris or all_
8707
9113
  if not yes:
8708
9114
  _ris_note = " (including RIS)" if _clear_ris else " (RIS preserved — use --all to also clear it)"
@@ -84,6 +84,12 @@ ENVELOPE_VERSION: str = "1"
84
84
  #: Producer identity — the exact code that built the context. Package version
85
85
  #: bumps naturally participate in the key so a new release never reads context
86
86
  #: built by an older one.
87
+ #:
88
+ #: The package version alone is not the exact code: it does not move between
89
+ #: releases, so during development — and in any install running unreleased code —
90
+ #: context built by the old logic is served to the new logic. The analyzer content
91
+ #: hash closes that window; the version stays in the key because it is readable in
92
+ #: diagnostics and cheap.
87
93
  PRODUCER_VERSION: str = _PKG_VERSION
88
94
 
89
95
  _DEFAULT_TTL_DAYS: int = 30
@@ -288,10 +294,13 @@ class ContextCache:
288
294
  discriminator; for command-agnostic knowledge (the normal case) leave it
289
295
  unset and prefer :meth:`knowledge_key`.
290
296
  """
297
+ from sourcecode.cache import analyzer_fingerprint
298
+
291
299
  material = _UNIT_SEP.join([
292
300
  self._signature,
293
301
  KL_SCHEMA_VERSION,
294
302
  PRODUCER_VERSION,
303
+ analyzer_fingerprint(),
295
304
  ENVELOPE_VERSION,
296
305
  scope,
297
306
  target or "",