sourcecode 1.36.5__tar.gz → 1.38.0__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 (120) hide show
  1. {sourcecode-1.36.5 → sourcecode-1.38.0}/PKG-INFO +3 -3
  2. {sourcecode-1.36.5 → sourcecode-1.38.0}/README.md +2 -2
  3. {sourcecode-1.36.5 → sourcecode-1.38.0}/pyproject.toml +1 -1
  4. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/cli.py +36 -89
  6. sourcecode-1.38.0/src/sourcecode/format_contract.py +86 -0
  7. sourcecode-1.38.0/src/sourcecode/openapi_surface.py +431 -0
  8. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/repository_ir.py +75 -9
  9. {sourcecode-1.36.5 → sourcecode-1.38.0}/.github/workflows/build-windows.yml +0 -0
  10. {sourcecode-1.36.5 → sourcecode-1.38.0}/.gitignore +0 -0
  11. {sourcecode-1.36.5 → sourcecode-1.38.0}/.ruff.toml +0 -0
  12. {sourcecode-1.36.5 → sourcecode-1.38.0}/CHANGELOG.md +0 -0
  13. {sourcecode-1.36.5 → sourcecode-1.38.0}/CONTRIBUTING.md +0 -0
  14. {sourcecode-1.36.5 → sourcecode-1.38.0}/LICENSE +0 -0
  15. {sourcecode-1.36.5 → sourcecode-1.38.0}/SECURITY.md +0 -0
  16. {sourcecode-1.36.5 → sourcecode-1.38.0}/raw +0 -0
  17. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/adaptive_scanner.py +0 -0
  18. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/architecture_analyzer.py +0 -0
  19. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/architecture_summary.py +0 -0
  20. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/ast_extractor.py +0 -0
  21. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/cache.py +0 -0
  22. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/canonical_ir.py +0 -0
  23. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/cir_graphs.py +0 -0
  24. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/classifier.py +0 -0
  25. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  26. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/confidence_analyzer.py +0 -0
  27. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/context_scorer.py +0 -0
  28. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/context_summarizer.py +0 -0
  29. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/contract_model.py +0 -0
  30. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/contract_pipeline.py +0 -0
  31. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/coverage_parser.py +0 -0
  32. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/dependency_analyzer.py +0 -0
  33. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/__init__.py +0 -0
  34. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/base.py +0 -0
  35. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  36. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/dart.py +0 -0
  37. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/dotnet.py +0 -0
  38. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/elixir.py +0 -0
  39. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/go.py +0 -0
  40. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/heuristic.py +0 -0
  41. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/hybrid.py +0 -0
  42. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/java.py +0 -0
  43. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  44. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/nodejs.py +0 -0
  45. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/parsers.py +0 -0
  46. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/php.py +0 -0
  47. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/project.py +0 -0
  48. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/python.py +0 -0
  49. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/ruby.py +0 -0
  50. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/rust.py +0 -0
  51. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/systems.py +0 -0
  52. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/terraform.py +0 -0
  53. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/detectors/tooling.py +0 -0
  54. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/doc_analyzer.py +0 -0
  55. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  56. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/env_analyzer.py +0 -0
  57. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/error_schema.py +0 -0
  58. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/explain.py +0 -0
  59. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/file_chunker.py +0 -0
  60. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/file_classifier.py +0 -0
  61. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/flow_analyzer.py +0 -0
  62. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/fqn_utils.py +0 -0
  63. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/git_analyzer.py +0 -0
  64. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/graph_analyzer.py +0 -0
  65. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/license.py +0 -0
  66. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/__init__.py +0 -0
  67. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  68. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  69. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  70. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  71. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  72. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  73. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/registry.py +0 -0
  74. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/runner.py +0 -0
  75. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp/server.py +0 -0
  76. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/mcp_nudge.py +0 -0
  77. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/metrics_analyzer.py +0 -0
  78. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/migrate_check.py +0 -0
  79. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/output_budget.py +0 -0
  80. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/path_filters.py +0 -0
  81. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  82. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/pr_impact.py +0 -0
  83. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/prepare_context.py +0 -0
  84. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/progress.py +0 -0
  85. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/ranking_engine.py +0 -0
  86. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/redactor.py +0 -0
  87. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/relevance_scorer.py +0 -0
  88. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/rename_refactor.py +0 -0
  89. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/repo_classifier.py +0 -0
  90. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/ris.py +0 -0
  91. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/runtime_classifier.py +0 -0
  92. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/scanner.py +0 -0
  93. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/schema.py +0 -0
  94. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/security_config.py +0 -0
  95. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/semantic_analyzer.py +0 -0
  96. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/serializer.py +0 -0
  97. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/spring_event_topology.py +0 -0
  98. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/spring_findings.py +0 -0
  99. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/spring_impact.py +0 -0
  100. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/spring_model.py +0 -0
  101. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/spring_security_audit.py +0 -0
  102. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/spring_semantic.py +0 -0
  103. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
  104. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/summarizer.py +0 -0
  105. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/telemetry/__init__.py +0 -0
  106. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/telemetry/config.py +0 -0
  107. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/telemetry/consent.py +0 -0
  108. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/telemetry/events.py +0 -0
  109. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/telemetry/filters.py +0 -0
  110. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/telemetry/transport.py +0 -0
  111. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/tree_utils.py +0 -0
  112. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/version_check.py +0 -0
  113. {sourcecode-1.36.5 → sourcecode-1.38.0}/src/sourcecode/workspace.py +0 -0
  114. {sourcecode-1.36.5 → sourcecode-1.38.0}/supabase/.temp/cli-latest +0 -0
  115. {sourcecode-1.36.5 → sourcecode-1.38.0}/supabase/functions/README.md +0 -0
  116. {sourcecode-1.36.5 → sourcecode-1.38.0}/supabase/functions/get-license/index.ts +0 -0
  117. {sourcecode-1.36.5 → sourcecode-1.38.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  118. {sourcecode-1.36.5 → sourcecode-1.38.0}/supabase/functions/telemetry/index.ts +0 -0
  119. {sourcecode-1.36.5 → sourcecode-1.38.0}/supabase/sql/license_event_ordering.sql +0 -0
  120. {sourcecode-1.36.5 → sourcecode-1.38.0}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.36.5
3
+ Version: 1.38.0
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
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
42
42
 
43
- ![Version](https://img.shields.io/badge/version-1.36.5-blue)
43
+ ![Version](https://img.shields.io/badge/version-1.38.0-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.9%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,7 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.36.5
117
+ # sourcecode 1.38.0
118
118
  ```
119
119
 
120
120
  ---
@@ -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.36.5-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.38.0-blue)
6
6
  ![Python](https://img.shields.io/badge/python-3.9%2B-green)
7
7
 
8
8
  ---
@@ -76,7 +76,7 @@ pipx install sourcecode
76
76
 
77
77
  ```bash
78
78
  sourcecode version
79
- # sourcecode 1.36.5
79
+ # sourcecode 1.38.0
80
80
  ```
81
81
 
82
82
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.36.5"
7
+ version = "1.38.0"
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.36.5"
3
+ __version__ = "1.38.0"
@@ -401,6 +401,28 @@ def _emit_error_json(error: str, message: str, **context: object) -> None:
401
401
  sys.stderr.flush()
402
402
 
403
403
 
404
+ def _enforce_format(command: str, fmt: str) -> None:
405
+ """Validate ``--format`` for ``command`` against the central contract.
406
+
407
+ Single validation path for every command's ``--format`` option (see
408
+ ``sourcecode.format_contract``). On an invalid value it emits the
409
+ homogeneous JSON error envelope to stderr and exits with code 2
410
+ (argument-validation convention). Valid values are a no-op.
411
+ """
412
+ from sourcecode.format_contract import (
413
+ FORMAT_ERROR_EXIT_CODE,
414
+ format_error_context,
415
+ is_valid_format,
416
+ )
417
+
418
+ if is_valid_format(command, fmt):
419
+ return
420
+ ctx = format_error_context(command, fmt)
421
+ message = str(ctx.pop("message"))
422
+ _emit_error_json(INVALID_INPUT_CODE, message, **ctx)
423
+ raise typer.Exit(code=FORMAT_ERROR_EXIT_CODE)
424
+
425
+
404
426
  def _safe_write_file(path: "Path", content: str) -> None:
405
427
  """Write content to path, emitting a clean JSON error on I/O failure."""
406
428
  try:
@@ -631,7 +653,8 @@ def _active_flags(
631
653
  if fmt != "json": flags.append("--format")
632
654
  return flags
633
655
 
634
- FORMAT_CHOICES = ["json", "yaml"]
656
+ # Per-command output-format contracts now live in sourcecode.format_contract
657
+ # (validated via _enforce_format). No module-level FORMAT_CHOICES here.
635
658
  GRAPH_DETAIL_CHOICES = ["high", "medium", "full"]
636
659
  GRAPH_EDGE_CHOICES = {"imports", "calls", "contains", "extends"}
637
660
  DOCS_DEPTH_CHOICES = ["module", "symbols", "full"]
@@ -1138,17 +1161,7 @@ def main(
1138
1161
  )
1139
1162
 
1140
1163
  # Validate format choices
1141
- if format not in FORMAT_CHOICES:
1142
- _emit_error_json(
1143
- INVALID_INPUT_CODE,
1144
- f"Invalid value '{format}' for --format. Valid values: {', '.join(FORMAT_CHOICES)}.",
1145
- flag="--format",
1146
- value=format,
1147
- valid_values=list(FORMAT_CHOICES),
1148
- hint="Choose one of the supported --format values.",
1149
- expected=f"One of: {', '.join(FORMAT_CHOICES)}",
1150
- )
1151
- raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
1164
+ _enforce_format("main", format)
1152
1165
  if graph_detail not in GRAPH_DETAIL_CHOICES:
1153
1166
  _emit_error_json(
1154
1167
  INVALID_INPUT_CODE,
@@ -2834,19 +2847,9 @@ def prepare_context_cmd(
2834
2847
  # Validate --format: only "json" and "github-comment" are valid for prepare-context.
2835
2848
  # "yaml" is intentionally NOT supported here (use main command for yaml output).
2836
2849
  # Invalid values must error loudly — silently falling through to JSON is a lie.
2837
- _PC_FORMAT_CHOICES = ("json", "github-comment")
2838
- if format is not None and format not in _PC_FORMAT_CHOICES:
2839
- _emit_error_json(
2840
- INVALID_INPUT_CODE,
2841
- f"invalid value '{format}' for --format. "
2842
- f"Valid options: {', '.join(_PC_FORMAT_CHOICES)}.",
2843
- flag="--format",
2844
- value=format,
2845
- valid_values=list(_PC_FORMAT_CHOICES),
2846
- hint="Choose one of the supported prepare-context output formats.",
2847
- expected=f"One of: {', '.join(_PC_FORMAT_CHOICES)}",
2848
- )
2849
- raise typer.Exit(code=2)
2850
+ # None means "use default" (json); a concrete value is validated against the contract.
2851
+ if format is not None:
2852
+ _enforce_format("prepare-context", format)
2850
2853
  # github-comment only renders for review-pr; warn and normalize for other tasks.
2851
2854
  if format == "github-comment" and task != "review-pr":
2852
2855
  typer.echo(
@@ -3479,14 +3482,7 @@ def repo_ir_cmd(
3479
3482
 
3480
3483
  from sourcecode.repository_ir import apply_ir_size_limits, build_repo_ir, find_java_files
3481
3484
 
3482
- if format not in ("json", "yaml"):
3483
- _emit_error_json(
3484
- INVALID_INPUT_CODE,
3485
- f"Invalid format '{format}'.",
3486
- hint="Valid values: json, yaml.",
3487
- expected="json | yaml",
3488
- )
3489
- raise typer.Exit(code=1)
3485
+ _enforce_format("repo-ir", format)
3490
3486
 
3491
3487
  root = path.resolve()
3492
3488
  if not root.is_dir():
@@ -3712,14 +3708,7 @@ def impact_cmd(
3712
3708
  from sourcecode.license import require_repo_or_pro as _require_repo_or_pro
3713
3709
  _require_repo_or_pro(str(path.resolve()), "impact")
3714
3710
 
3715
- if format not in ("json", "yaml"):
3716
- _emit_error_json(
3717
- INVALID_INPUT_CODE,
3718
- f"Invalid format '{format}'.",
3719
- hint="format must be: json or yaml.",
3720
- expected="json | yaml",
3721
- )
3722
- raise typer.Exit(code=1)
3711
+ _enforce_format("impact", format)
3723
3712
 
3724
3713
  from sourcecode.repository_ir import (
3725
3714
  build_repo_ir, find_java_files, compute_blast_radius,
@@ -3875,14 +3864,7 @@ def endpoints_cmd(
3875
3864
  sourcecode endpoints . --controller LiquidacionJornada
3876
3865
  sourcecode endpoints . --limit 10
3877
3866
  """
3878
- if format not in ("json", "yaml"):
3879
- _emit_error_json(
3880
- INVALID_INPUT_CODE,
3881
- f"Invalid format '{format}'.",
3882
- hint="format must be: json or yaml.",
3883
- expected="json | yaml",
3884
- )
3885
- raise typer.Exit(code=1)
3867
+ _enforce_format("endpoints", format)
3886
3868
 
3887
3869
  target = path.resolve()
3888
3870
  if not target.exists() or not target.is_dir():
@@ -4116,14 +4098,7 @@ def spring_audit_cmd(
4116
4098
  )
4117
4099
  raise typer.Exit(code=1)
4118
4100
 
4119
- if format not in ("json", "yaml", "github-comment"):
4120
- _emit_error_json(
4121
- INVALID_INPUT_CODE,
4122
- f"Invalid format '{format}'.",
4123
- hint="format must be one of: json, yaml, github-comment.",
4124
- expected="json | yaml | github-comment",
4125
- )
4126
- raise typer.Exit(code=1)
4101
+ _enforce_format("spring-audit", format)
4127
4102
 
4128
4103
  _file_limitations: list[str] = []
4129
4104
  file_list = find_java_files(target, limitations=_file_limitations)
@@ -4274,14 +4249,7 @@ def migrate_check_cmd(
4274
4249
  )
4275
4250
  raise typer.Exit(code=1)
4276
4251
 
4277
- if format not in ("json", "text"):
4278
- _emit_error_json(
4279
- INVALID_INPUT_CODE,
4280
- f"Invalid format '{format}'.",
4281
- hint="format must be one of: json, text.",
4282
- expected="json | text",
4283
- )
4284
- raise typer.Exit(code=1)
4252
+ _enforce_format("migrate-check", format)
4285
4253
 
4286
4254
  if min_severity not in ("critical", "high", "medium", "low"):
4287
4255
  _emit_error_json(
@@ -4426,14 +4394,7 @@ def impact_chain_cmd(
4426
4394
  )
4427
4395
  raise typer.Exit(code=1)
4428
4396
 
4429
- if format not in ("json", "yaml"):
4430
- _emit_error_json(
4431
- INVALID_INPUT_CODE,
4432
- f"Invalid format '{format}'.",
4433
- hint="format must be: json or yaml.",
4434
- expected="json | yaml",
4435
- )
4436
- raise typer.Exit(code=1)
4397
+ _enforce_format("impact-chain", format)
4437
4398
 
4438
4399
  file_list = find_java_files(target)
4439
4400
  if not file_list:
@@ -4567,14 +4528,7 @@ def pr_impact_cmd(
4567
4528
  )
4568
4529
  raise typer.Exit(code=1)
4569
4530
 
4570
- if format not in ("text", "json"):
4571
- _emit_error_json(
4572
- INVALID_INPUT_CODE,
4573
- f"Invalid format '{format}'.",
4574
- hint="format must be: text or json.",
4575
- expected="text | json",
4576
- )
4577
- raise typer.Exit(code=1)
4531
+ _enforce_format("pr-impact", format)
4578
4532
 
4579
4533
  # Read changed-files list
4580
4534
  changed_files = [
@@ -4699,14 +4653,7 @@ def explain_cmd(
4699
4653
  )
4700
4654
  raise typer.Exit(code=1)
4701
4655
 
4702
- if format not in ("text", "json"):
4703
- _emit_error_json(
4704
- INVALID_INPUT_CODE,
4705
- f"Invalid format '{format}'.",
4706
- hint="format must be: text or json.",
4707
- expected="text | json",
4708
- )
4709
- raise typer.Exit(code=1)
4656
+ _enforce_format("explain", format)
4710
4657
 
4711
4658
  file_list = find_java_files(target)
4712
4659
  if not file_list:
@@ -0,0 +1,86 @@
1
+ """Single source of truth for per-command output-format contracts.
2
+
3
+ Every CLI command that emits machine-consumable output validates its
4
+ ``--format`` option through this registry so that:
5
+
6
+ * the set of allowed formats for each command lives in exactly one place,
7
+ * ``-f json`` is a strict contract on every command (pure JSON to stdout),
8
+ * invalid-format errors share an identical envelope shape and exit code.
9
+
10
+ The registry value is an *ordered* tuple; element ``0`` is the command's
11
+ default and matches its Typer option default. Defaults are intentionally NOT
12
+ changed when centralizing — ``explain`` and ``pr-impact`` keep their
13
+ human-facing ``text`` default — to avoid breaking existing scripts. The strict
14
+ guarantee is on ``-f json``, which every command supports.
15
+
16
+ Exit-code policy: an invalid ``--format`` is an argument-validation error and
17
+ exits with code ``2`` for every command (matching the documented
18
+ ``arg validation -> exit 2`` convention used by the root command).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ # Command name (as registered with ``@app.command``, or "main" for the root
24
+ # command) -> ordered tuple of allowed formats. Element 0 is the default.
25
+ FORMAT_REGISTRY: "dict[str, tuple[str, ...]]" = {
26
+ "main": ("json", "yaml"),
27
+ "repo-ir": ("json", "yaml"),
28
+ "impact": ("json", "yaml"),
29
+ "endpoints": ("json", "yaml"),
30
+ "impact-chain": ("json", "yaml"),
31
+ "pr-impact": ("text", "json"),
32
+ "migrate-check": ("json", "text"),
33
+ "spring-audit": ("json", "yaml", "github-comment"),
34
+ "explain": ("text", "json"),
35
+ "prepare-context": ("json", "github-comment"),
36
+ }
37
+
38
+ # Invalid --format is an argument-validation error.
39
+ FORMAT_ERROR_EXIT_CODE = 2
40
+
41
+ # The strict machine-readable format every command must support.
42
+ STRICT_FORMAT = "json"
43
+
44
+
45
+ def allowed_formats(command: str) -> "tuple[str, ...]":
46
+ """Return the ordered tuple of allowed formats for ``command``.
47
+
48
+ Raises ``KeyError`` if the command has no registered contract — a
49
+ programming error, surfaced loudly rather than silently allowing anything.
50
+ """
51
+ try:
52
+ return FORMAT_REGISTRY[command]
53
+ except KeyError as exc:
54
+ raise KeyError(
55
+ f"No format contract registered for command '{command}'. "
56
+ f"Add it to FORMAT_REGISTRY in sourcecode/format_contract.py."
57
+ ) from exc
58
+
59
+
60
+ def default_format(command: str) -> str:
61
+ """Return the default format for ``command`` (registry element 0)."""
62
+ return allowed_formats(command)[0]
63
+
64
+
65
+ def is_valid_format(command: str, fmt: str) -> bool:
66
+ """True iff ``fmt`` is allowed for ``command``."""
67
+ return fmt in FORMAT_REGISTRY.get(command, ())
68
+
69
+
70
+ def format_error_context(command: str, fmt: str) -> "dict[str, object]":
71
+ """Build the homogeneous error-envelope fields for an invalid ``--format``.
72
+
73
+ Returns a dict whose ``message`` key is the human message and whose
74
+ remaining keys are passed verbatim as the error-envelope context, so every
75
+ command produces an identically shaped ``--format`` error.
76
+ """
77
+ allowed = list(allowed_formats(command))
78
+ joined = ", ".join(allowed)
79
+ return {
80
+ "message": f"Invalid value '{fmt}' for --format. Valid values: {joined}.",
81
+ "flag": "--format",
82
+ "value": fmt,
83
+ "valid_values": allowed,
84
+ "hint": "Choose one of the supported --format values.",
85
+ "expected": f"One of: {joined}",
86
+ }