sourcecode 1.33.7__tar.gz → 1.33.8__tar.gz

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.
Files changed (97) hide show
  1. {sourcecode-1.33.7 → sourcecode-1.33.8}/PKG-INFO +2 -2
  2. {sourcecode-1.33.7 → sourcecode-1.33.8}/README.md +1 -1
  3. {sourcecode-1.33.7 → sourcecode-1.33.8}/pyproject.toml +1 -1
  4. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/cli.py +131 -67
  6. sourcecode-1.33.8/src/sourcecode/error_schema.py +56 -0
  7. sourcecode-1.33.8/src/sourcecode/mcp/registry.py +862 -0
  8. sourcecode-1.33.8/src/sourcecode/mcp/runner.py +80 -0
  9. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp/server.py +113 -12
  10. sourcecode-1.33.7/src/sourcecode/cache.tmp_new +0 -772
  11. sourcecode-1.33.7/src/sourcecode/mcp/runner.py +0 -47
  12. {sourcecode-1.33.7 → sourcecode-1.33.8}/.github/workflows/build-windows.yml +0 -0
  13. {sourcecode-1.33.7 → sourcecode-1.33.8}/.gitignore +0 -0
  14. {sourcecode-1.33.7 → sourcecode-1.33.8}/.ruff.toml +0 -0
  15. {sourcecode-1.33.7 → sourcecode-1.33.8}/CHANGELOG.md +0 -0
  16. {sourcecode-1.33.7 → sourcecode-1.33.8}/CONTRIBUTING.md +0 -0
  17. {sourcecode-1.33.7 → sourcecode-1.33.8}/LICENSE +0 -0
  18. {sourcecode-1.33.7 → sourcecode-1.33.8}/SECURITY.md +0 -0
  19. {sourcecode-1.33.7 → sourcecode-1.33.8}/raw +0 -0
  20. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/adaptive_scanner.py +0 -0
  21. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/architecture_analyzer.py +0 -0
  22. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/architecture_summary.py +0 -0
  23. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/ast_extractor.py +0 -0
  24. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/cache.py +0 -0
  25. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/canonical_ir.py +0 -0
  26. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/classifier.py +0 -0
  27. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/code_notes_analyzer.py +0 -0
  28. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/confidence_analyzer.py +0 -0
  29. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/context_scorer.py +0 -0
  30. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/context_summarizer.py +0 -0
  31. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/contract_model.py +0 -0
  32. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/contract_pipeline.py +0 -0
  33. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/coverage_parser.py +0 -0
  34. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/dependency_analyzer.py +0 -0
  35. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/__init__.py +0 -0
  36. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/base.py +0 -0
  37. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/csproj_parser.py +0 -0
  38. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/dart.py +0 -0
  39. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/dotnet.py +0 -0
  40. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/elixir.py +0 -0
  41. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/go.py +0 -0
  42. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/heuristic.py +0 -0
  43. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/hybrid.py +0 -0
  44. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/java.py +0 -0
  45. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/jvm_ext.py +0 -0
  46. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/nodejs.py +0 -0
  47. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/parsers.py +0 -0
  48. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/php.py +0 -0
  49. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/project.py +0 -0
  50. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/python.py +0 -0
  51. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/ruby.py +0 -0
  52. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/rust.py +0 -0
  53. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/systems.py +0 -0
  54. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/terraform.py +0 -0
  55. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/detectors/tooling.py +0 -0
  56. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/doc_analyzer.py +0 -0
  57. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/entrypoint_classifier.py +0 -0
  58. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/env_analyzer.py +0 -0
  59. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/file_classifier.py +0 -0
  60. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/flow_analyzer.py +0 -0
  61. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/git_analyzer.py +0 -0
  62. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/graph_analyzer.py +0 -0
  63. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/license.py +0 -0
  64. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp/__init__.py +0 -0
  65. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  66. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  67. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  68. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  69. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  70. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp/orchestrator.py +0 -0
  71. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/mcp_nudge.py +0 -0
  72. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/metrics_analyzer.py +0 -0
  73. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/output_budget.py +0 -0
  74. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/path_filters.py +0 -0
  75. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/pr_comment_renderer.py +0 -0
  76. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/prepare_context.py +0 -0
  77. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/progress.py +0 -0
  78. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/ranking_engine.py +0 -0
  79. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/redactor.py +0 -0
  80. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/relevance_scorer.py +0 -0
  81. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/repo_classifier.py +0 -0
  82. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/repository_ir.py +0 -0
  83. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/ris.py +0 -0
  84. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/runtime_classifier.py +0 -0
  85. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/scanner.py +0 -0
  86. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/schema.py +0 -0
  87. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/semantic_analyzer.py +0 -0
  88. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/serializer.py +0 -0
  89. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/summarizer.py +0 -0
  90. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/telemetry/__init__.py +0 -0
  91. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/telemetry/config.py +0 -0
  92. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/telemetry/consent.py +0 -0
  93. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/telemetry/events.py +0 -0
  94. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/telemetry/filters.py +0 -0
  95. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/telemetry/transport.py +0 -0
  96. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/tree_utils.py +0 -0
  97. {sourcecode-1.33.7 → sourcecode-1.33.8}/src/sourcecode/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.33.7
3
+ Version: 1.33.8
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -39,7 +39,7 @@ Description-Content-Type: text/markdown
39
39
 
40
40
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
41
41
 
42
- ![Version](https://img.shields.io/badge/version-1.33.7-blue)
42
+ ![Version](https://img.shields.io/badge/version-1.33.8-blue)
43
43
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
44
44
 
45
45
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
4
4
 
5
- ![Version](https://img.shields.io/badge/version-1.33.7-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.33.8-blue)
6
6
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
7
7
 
8
8
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.33.7"
7
+ version = "1.33.8"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.33.7"
3
+ __version__ = "1.33.8"
@@ -10,6 +10,7 @@ from typing import Any, Optional, cast
10
10
  import typer
11
11
 
12
12
  from sourcecode import __version__
13
+ from sourcecode.error_schema import INVALID_INPUT_CODE, build_error_envelope
13
14
  from sourcecode.entrypoint_classifier import is_production_entry_point, normalize_entry_point
14
15
  from sourcecode.progress import Progress
15
16
  from sourcecode.repository_ir import extract_java_endpoints as _extract_java_endpoints
@@ -305,14 +306,13 @@ def _preprocess_argv() -> None:
305
306
  def _emit_error_json(error: str, message: str, **context: object) -> None:
306
307
  """Write a structured JSON error envelope to stderr.
307
308
 
308
- Format: {"error": "<code>", "message": "<human text>", ...<context>}
309
+ Format: {"error": {"code": "<code>", "message": "<human text>", ...}, ...<context>}
309
310
  All CLI validation and runtime errors must go through this helper so that
310
311
  agents and tools can parse stderr reliably regardless of error type.
311
312
  """
312
313
  import json as _json
313
314
  import sys as _sys
314
- payload: dict[str, object] = {"error": error, "message": message}
315
- payload.update(context)
315
+ payload = build_error_envelope(error, message, **context)
316
316
  _sys.stderr.write(_json.dumps(payload, ensure_ascii=False) + "\n")
317
317
  _sys.stderr.flush()
318
318
 
@@ -326,18 +326,15 @@ try:
326
326
  def _json_click_usage_error_show(self: Any, file: Any = None) -> None: # type: ignore[override]
327
327
  import json as _je
328
328
  import sys as _jse
329
- _code_map = {
330
- "NoSuchOption": "invalid_option",
331
- "BadOptionUsage": "invalid_option",
332
- "BadParameter": "bad_parameter",
333
- "MissingParameter": "missing_required",
334
- "BadArgumentUsage": "bad_argument",
335
- }
336
- code = _code_map.get(type(self).__name__, "invalid_option")
337
- payload: dict[str, object] = {"error": code, "message": self.format_message()}
338
- _opt = getattr(self, "option_name", None) or getattr(self, "param_hint", None)
339
- if _opt:
340
- payload["flag"] = str(_opt).strip("'\"")
329
+ _flag = str((getattr(self, "option_name", None) or getattr(self, "param_hint", None)) or "").strip("'\"")
330
+ _context: dict[str, object] = {}
331
+ if _flag:
332
+ _context["flag"] = _flag
333
+ payload = build_error_envelope(
334
+ INVALID_INPUT_CODE,
335
+ self.format_message(),
336
+ **_context,
337
+ )
341
338
  _jse.stderr.write(_je.dumps(payload, ensure_ascii=False) + "\n")
342
339
  _jse.stderr.flush()
343
340
 
@@ -798,35 +795,59 @@ def main(
798
795
  )
799
796
  mode = fallback
800
797
  elif mode not in _MODE_CHOICES:
801
- typer.echo(
802
- f"Error: invalid value '{mode}' for --mode. Valid options: {', '.join(_MODE_CHOICES)}",
803
- err=True,
798
+ _emit_error_json(
799
+ INVALID_INPUT_CODE,
800
+ f"Invalid value '{mode}' for --mode. Valid options: {', '.join(_MODE_CHOICES)}",
801
+ flag="--mode",
802
+ value=mode,
803
+ valid_values=list(_MODE_CHOICES),
804
+ hint="Choose one of the supported --mode values.",
805
+ expected=f"One of: {', '.join(_MODE_CHOICES)}",
804
806
  )
805
807
  raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
806
808
  _RANK_CHOICES = ("relevance", "centrality", "git-churn")
807
809
  if rank_by not in _RANK_CHOICES:
808
- typer.echo(
809
- f"Error: invalid value '{rank_by}' for --rank-by. Valid options: {', '.join(_RANK_CHOICES)}",
810
- err=True,
810
+ _emit_error_json(
811
+ INVALID_INPUT_CODE,
812
+ f"Invalid value '{rank_by}' for --rank-by. Valid options: {', '.join(_RANK_CHOICES)}",
813
+ flag="--rank-by",
814
+ value=rank_by,
815
+ valid_values=list(_RANK_CHOICES),
816
+ hint="Choose one of the supported --rank-by values.",
817
+ expected=f"One of: {', '.join(_RANK_CHOICES)}",
811
818
  )
812
819
  raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
813
820
 
814
821
  if symbol is not None and not symbol.strip():
815
- typer.echo("symbol query cannot be empty", err=True)
822
+ _emit_error_json(
823
+ INVALID_INPUT_CODE,
824
+ "symbol query cannot be empty",
825
+ flag="--symbol",
826
+ hint="Pass a non-empty symbol or omit --symbol.",
827
+ expected="A non-empty symbol query.",
828
+ )
816
829
  raise typer.Exit(code=2)
817
830
 
818
831
  if symbol and mode not in ("contract", "standard"):
819
- typer.echo(
820
- f"Error: --symbol requires --mode contract or standard (got '{mode}'). "
832
+ _emit_error_json(
833
+ INVALID_INPUT_CODE,
834
+ f"--symbol requires --mode contract or standard (got '{mode}'). "
821
835
  "Symbol search uses the contract pipeline which does not run in raw mode.",
822
- err=True,
836
+ flag="--symbol",
837
+ mode=mode,
838
+ hint="Switch to --mode contract or --mode standard.",
839
+ expected="A contract or standard analysis mode.",
823
840
  )
824
841
  raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
825
842
 
826
843
  if entrypoints_only and mode not in ("contract", "standard"):
827
- typer.echo(
828
- f"Error: --entrypoints-only requires --mode contract or standard (got '{mode}').",
829
- err=True,
844
+ _emit_error_json(
845
+ INVALID_INPUT_CODE,
846
+ f"--entrypoints-only requires --mode contract or standard (got '{mode}').",
847
+ flag="--entrypoints-only",
848
+ mode=mode,
849
+ hint="Switch to --mode contract or --mode standard.",
850
+ expected="A contract or standard analysis mode.",
830
851
  )
831
852
  raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
832
853
 
@@ -855,15 +876,15 @@ def main(
855
876
  # compact is designed to be a bounded summary; --full removes truncation limits,
856
877
  # which contradicts compact's purpose. Use --agent --full for expanded output.
857
878
  if compact and full:
858
- import json as _json_flags, sys as _sys_flags
859
- _sys_flags.stdout.write(_json_flags.dumps({
860
- "error": "incompatible_flags",
861
- "message": "--compact and --full are mutually exclusive. "
862
- "--compact produces a bounded summary; --full removes truncation limits "
863
- "and is meant for --agent mode. Use --agent --full for expanded output.",
864
- "exit_code": 1,
865
- }, ensure_ascii=False) + "\n")
866
- _sys_flags.stdout.flush()
879
+ _emit_error_json(
880
+ INVALID_INPUT_CODE,
881
+ "--compact and --full are mutually exclusive. "
882
+ "--compact produces a bounded summary; --full removes truncation limits "
883
+ "and is meant for --agent mode. Use --agent --full for expanded output.",
884
+ hint="Remove one of the conflicting flags.",
885
+ expected="Exactly one of --compact or --full.",
886
+ flag_conflict=["--compact", "--full"],
887
+ )
867
888
  raise typer.Exit(code=1)
868
889
 
869
890
  # P0-2 FIX: --full without --compact or --agent has no effect in contract/raw mode.
@@ -889,29 +910,35 @@ def main(
889
910
  # Validate format choices
890
911
  if format not in FORMAT_CHOICES:
891
912
  _emit_error_json(
892
- "invalid_flag_value",
913
+ INVALID_INPUT_CODE,
893
914
  f"Invalid value '{format}' for --format. Valid values: {', '.join(FORMAT_CHOICES)}.",
894
915
  flag="--format",
895
916
  value=format,
896
917
  valid_values=list(FORMAT_CHOICES),
918
+ hint="Choose one of the supported --format values.",
919
+ expected=f"One of: {', '.join(FORMAT_CHOICES)}",
897
920
  )
898
921
  raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
899
922
  if graph_detail not in GRAPH_DETAIL_CHOICES:
900
923
  _emit_error_json(
901
- "invalid_flag_value",
924
+ INVALID_INPUT_CODE,
902
925
  f"Invalid value '{graph_detail}' for --graph-detail. Valid values: {', '.join(GRAPH_DETAIL_CHOICES)}.",
903
926
  flag="--graph-detail",
904
927
  value=graph_detail,
905
928
  valid_values=list(GRAPH_DETAIL_CHOICES),
929
+ hint="Choose one of the supported --graph-detail values.",
930
+ expected=f"One of: {', '.join(GRAPH_DETAIL_CHOICES)}",
906
931
  )
907
932
  raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
908
933
  if docs_depth not in DOCS_DEPTH_CHOICES:
909
934
  _emit_error_json(
910
- "invalid_flag_value",
935
+ INVALID_INPUT_CODE,
911
936
  f"Invalid value '{docs_depth}' for --docs-depth. Valid values: {', '.join(DOCS_DEPTH_CHOICES)}.",
912
937
  flag="--docs-depth",
913
938
  value=docs_depth,
914
939
  valid_values=list(DOCS_DEPTH_CHOICES),
940
+ hint="Choose one of the supported --docs-depth values.",
941
+ expected=f"One of: {', '.join(DOCS_DEPTH_CHOICES)}",
915
942
  )
916
943
  raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
917
944
 
@@ -922,16 +949,20 @@ def main(
922
949
  target = Path(_raw_path_input).resolve()
923
950
  if not target.exists():
924
951
  _emit_error_json(
925
- "directory_not_found",
952
+ INVALID_INPUT_CODE,
926
953
  f"Directory '{_raw_path_input}' does not exist.",
927
954
  path=_raw_path_input,
955
+ hint="Pass an existing repository directory.",
956
+ expected="An existing directory path.",
928
957
  )
929
958
  raise typer.Exit(code=1)
930
959
  if not target.is_dir():
931
960
  _emit_error_json(
932
- "not_a_directory",
961
+ INVALID_INPUT_CODE,
933
962
  f"Path '{_raw_path_input}' is not a directory.",
934
963
  path=_raw_path_input,
964
+ hint="Pass a repository directory, not a file.",
965
+ expected="A directory path.",
935
966
  )
936
967
  raise typer.Exit(code=1)
937
968
 
@@ -1027,12 +1058,15 @@ def main(
1027
1058
  effective_depth = max(depth, _java_min_depth) if _is_java and depth < _java_min_depth else depth
1028
1059
 
1029
1060
  if symbol is not None and _is_java:
1030
- typer.echo(
1031
- f"Error: --symbol is not supported for Java/JVM repositories. "
1061
+ _emit_error_json(
1062
+ INVALID_INPUT_CODE,
1063
+ "--symbol is not supported for Java/JVM repositories. "
1032
1064
  "Per-file AST extraction is unavailable for JVM — symbol search only works with Python, TypeScript, and JavaScript. "
1033
1065
  "Alternatives: use --agent --compact to get file relevance scores, "
1034
1066
  "or use --git-context to find recently changed files.",
1035
- err=True,
1067
+ flag="--symbol",
1068
+ hint="Use a non-Java repository or omit --symbol.",
1069
+ expected="A repository where symbol extraction is supported.",
1036
1070
  )
1037
1071
  raise typer.Exit(code=1)
1038
1072
 
@@ -1329,10 +1363,15 @@ def main(
1329
1363
  if parsed_graph_edges is not None:
1330
1364
  invalid_edges = sorted(parsed_graph_edges - GRAPH_EDGE_CHOICES)
1331
1365
  if invalid_edges:
1332
- typer.echo(
1333
- f"Error: invalid values for --graph-edges: "
1366
+ _emit_error_json(
1367
+ INVALID_INPUT_CODE,
1368
+ f"invalid values for --graph-edges: "
1334
1369
  f"{', '.join(invalid_edges)}. Valid options: {', '.join(sorted(GRAPH_EDGE_CHOICES))}",
1335
- err=True,
1370
+ flag="--graph-edges",
1371
+ value=invalid_edges,
1372
+ valid_values=sorted(GRAPH_EDGE_CHOICES),
1373
+ hint="Choose one or more supported graph edge types.",
1374
+ expected=f"One or more of: {', '.join(sorted(GRAPH_EDGE_CHOICES))}",
1336
1375
  )
1337
1376
  raise typer.Exit(code=1)
1338
1377
  graph_detail_typed = cast(GraphDetail, graph_detail)
@@ -2413,17 +2452,24 @@ def prepare_context_cmd(
2413
2452
  raise typer.Exit()
2414
2453
 
2415
2454
  if task is None:
2416
- typer.echo(
2417
- f"Error: task is required. Available: {', '.join(TASKS)}\n"
2455
+ _emit_error_json(
2456
+ INVALID_INPUT_CODE,
2457
+ f"task is required. Available: {', '.join(TASKS)}\n"
2418
2458
  "Use --task-help for descriptions.",
2419
- err=True,
2459
+ flag="task",
2460
+ hint="Pass one of the documented prepare-context tasks.",
2461
+ expected=f"One of: {', '.join(TASKS)}",
2420
2462
  )
2421
2463
  raise typer.Exit(code=1)
2422
2464
 
2423
2465
  if task not in TASKS:
2424
- typer.echo(
2425
- f"Error: unknown task '{task}'. Available: {', '.join(TASKS)}",
2426
- err=True,
2466
+ _emit_error_json(
2467
+ INVALID_INPUT_CODE,
2468
+ f"unknown task '{task}'. Available: {', '.join(TASKS)}",
2469
+ flag="task",
2470
+ value=task,
2471
+ hint="Choose one of the supported prepare-context tasks.",
2472
+ expected=f"One of: {', '.join(TASKS)}",
2427
2473
  )
2428
2474
  raise typer.Exit(code=1)
2429
2475
 
@@ -2438,10 +2484,15 @@ def prepare_context_cmd(
2438
2484
  # Invalid values must error loudly — silently falling through to JSON is a lie.
2439
2485
  _PC_FORMAT_CHOICES = ("json", "github-comment")
2440
2486
  if format is not None and format not in _PC_FORMAT_CHOICES:
2441
- typer.echo(
2442
- f"Error: invalid value '{format}' for --format. "
2487
+ _emit_error_json(
2488
+ INVALID_INPUT_CODE,
2489
+ f"invalid value '{format}' for --format. "
2443
2490
  f"Valid options: {', '.join(_PC_FORMAT_CHOICES)}.",
2444
- err=True,
2491
+ flag="--format",
2492
+ value=format,
2493
+ valid_values=list(_PC_FORMAT_CHOICES),
2494
+ hint="Choose one of the supported prepare-context output formats.",
2495
+ expected=f"One of: {', '.join(_PC_FORMAT_CHOICES)}",
2445
2496
  )
2446
2497
  raise typer.Exit(code=2)
2447
2498
  # github-comment only renders for review-pr; warn and normalize for other tasks.
@@ -2456,9 +2507,11 @@ def prepare_context_cmd(
2456
2507
  target = path.resolve()
2457
2508
  if not target.exists() or not target.is_dir():
2458
2509
  _emit_error_json(
2459
- "invalid_path",
2510
+ INVALID_INPUT_CODE,
2460
2511
  f"'{target}' is not a valid directory.",
2461
2512
  path=str(target),
2513
+ hint="Pass an existing repository directory.",
2514
+ expected="A directory path.",
2462
2515
  )
2463
2516
  raise typer.Exit(code=1)
2464
2517
 
@@ -3080,9 +3133,11 @@ def repo_ir_cmd(
3080
3133
  root = path.resolve()
3081
3134
  if not root.is_dir():
3082
3135
  _emit_error_json(
3083
- "invalid_path",
3136
+ INVALID_INPUT_CODE,
3084
3137
  f"'{root}' is not a valid directory.",
3085
3138
  path=str(root),
3139
+ hint="Pass an existing repository directory.",
3140
+ expected="A directory path.",
3086
3141
  )
3087
3142
  raise typer.Exit(1)
3088
3143
 
@@ -3239,9 +3294,11 @@ def impact_cmd(
3239
3294
  root = path.resolve()
3240
3295
  if not root.is_dir():
3241
3296
  _emit_error_json(
3242
- "invalid_path",
3297
+ INVALID_INPUT_CODE,
3243
3298
  f"'{root}' is not a valid directory.",
3244
3299
  path=str(root),
3300
+ hint="Pass an existing repository directory.",
3301
+ expected="A directory path.",
3245
3302
  )
3246
3303
  raise typer.Exit(1)
3247
3304
 
@@ -3348,9 +3405,11 @@ def endpoints_cmd(
3348
3405
  target = path.resolve()
3349
3406
  if not target.exists() or not target.is_dir():
3350
3407
  _emit_error_json(
3351
- "invalid_path",
3408
+ INVALID_INPUT_CODE,
3352
3409
  f"'{target}' is not a valid directory.",
3353
3410
  path=str(target),
3411
+ hint="Pass an existing repository directory.",
3412
+ expected="A directory path.",
3354
3413
  )
3355
3414
  raise typer.Exit(code=1)
3356
3415
 
@@ -3635,19 +3694,24 @@ def modernize_cmd(
3635
3694
  root = path.resolve()
3636
3695
  if not root.is_dir():
3637
3696
  _emit_error_json(
3638
- "invalid_path",
3697
+ INVALID_INPUT_CODE,
3639
3698
  f"'{root}' is not a valid directory.",
3640
3699
  path=str(root),
3700
+ hint="Pass an existing repository directory.",
3701
+ expected="A directory path.",
3641
3702
  )
3642
3703
  raise typer.Exit(1)
3643
3704
 
3644
3705
  file_list = find_java_files(root)
3645
3706
  if not file_list:
3646
- typer.echo(_json.dumps({
3647
- "error": "No Java files found in repository.",
3648
- "path": str(root),
3649
- }, indent=2))
3650
- return
3707
+ _emit_error_json(
3708
+ INVALID_INPUT_CODE,
3709
+ "No Java files found in repository.",
3710
+ path=str(root),
3711
+ hint="Pass a repository containing Java source files.",
3712
+ expected="At least one Java file.",
3713
+ )
3714
+ raise typer.Exit(1)
3651
3715
 
3652
3716
  _prog = Progress()
3653
3717
  _prog.start(f"building IR ({len(file_list)} files) for modernization analysis")
@@ -0,0 +1,56 @@
1
+ """Unified structured error schema for CLI and MCP surfaces."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ INVALID_INPUT_CODE = "INVALID_INPUT"
7
+ EXECUTION_FAILED_CODE = "EXECUTION_FAILED"
8
+ INTERNAL_ERROR_CODE = "INTERNAL_ERROR"
9
+
10
+
11
+ def default_error_hint(code: str) -> str:
12
+ if code == INVALID_INPUT_CODE:
13
+ return "Check the input value, path, or flag and try again."
14
+ if code == EXECUTION_FAILED_CODE:
15
+ return "Run the underlying CLI command directly to inspect stderr."
16
+ if code == INTERNAL_ERROR_CODE:
17
+ return "Retry the command. If it persists, capture the stack trace for debugging."
18
+ return "Inspect the command input and retry."
19
+
20
+
21
+ def default_error_expected(code: str) -> str:
22
+ if code == INVALID_INPUT_CODE:
23
+ return "A supported value, path, or argument shape."
24
+ if code == EXECUTION_FAILED_CODE:
25
+ return "Successful CLI execution."
26
+ if code == INTERNAL_ERROR_CODE:
27
+ return "A successful internal operation."
28
+ return "A valid command result."
29
+
30
+
31
+ def build_error_object(
32
+ code: str,
33
+ message: str,
34
+ *,
35
+ hint: str | None = None,
36
+ expected: str | None = None,
37
+ ) -> dict[str, str]:
38
+ return {
39
+ "code": code,
40
+ "message": message,
41
+ "hint": hint or default_error_hint(code),
42
+ "expected": expected or default_error_expected(code),
43
+ }
44
+
45
+
46
+ def build_error_envelope(
47
+ code: str,
48
+ message: str,
49
+ *,
50
+ hint: str | None = None,
51
+ expected: str | None = None,
52
+ **context: Any,
53
+ ) -> dict[str, Any]:
54
+ payload: dict[str, Any] = {"error": build_error_object(code, message, hint=hint, expected=expected)}
55
+ payload.update(context)
56
+ return payload