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/__init__.py +1 -1
- sourcecode/cache.py +44 -3
- sourcecode/cli.py +439 -33
- sourcecode/context_cache.py +9 -0
- sourcecode/contract_pipeline.py +14 -3
- sourcecode/detectors/java.py +7 -1
- sourcecode/envelope.py +193 -0
- sourcecode/format_contract.py +11 -5
- sourcecode/git_analyzer.py +74 -12
- sourcecode/posture.py +458 -0
- sourcecode/prepare_context.py +126 -35
- sourcecode/repository_ir.py +70 -1
- sourcecode/schemas/envelope-v1.schema.json +74 -0
- sourcecode/semantic_integration_engine.py +11 -0
- sourcecode/spring_impact.py +44 -10
- sourcecode/spring_profiles.py +11 -1
- sourcecode/spring_security_audit.py +5 -3
- sourcecode/verify_repo.py +252 -0
- sourcecode/verify_rules.py +135 -12
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/METADATA +3 -1
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/RECORD +24 -20
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/licenses/LICENSE +0 -0
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 —
|
|
1552
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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;
|
|
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
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
6285
|
-
help="Output format:
|
|
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
|
|
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=
|
|
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
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)"
|
sourcecode/context_cache.py
CHANGED
|
@@ -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 "",
|