katalyst-taxonomy-cli 2.0.1a2__tar.gz → 2.0.1a3__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 (88) hide show
  1. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/PKG-INFO +8 -1
  2. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/pyproject.toml +13 -1
  3. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/_wiring.py +4 -2
  4. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/app.py +55 -1
  5. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/create.py +4 -4
  6. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/init.py +1 -1
  7. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/lint.py +45 -18
  8. katalyst_taxonomy_cli-2.0.1a3/src/katalyst_taxonomy_cli/commands/migrate.py +210 -0
  9. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/relate.py +31 -5
  10. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/relocate.py +16 -5
  11. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/status.py +45 -0
  12. katalyst_taxonomy_cli-2.0.1a3/tests/test_backend_option.py +135 -0
  13. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_config_schemas_command.py +3 -3
  14. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_core_schema.py +6 -6
  15. katalyst_taxonomy_cli-2.0.1a3/tests/test_lint_profile_graph.py +155 -0
  16. katalyst_taxonomy_cli-2.0.1a3/tests/test_migrate.py +142 -0
  17. katalyst_taxonomy_cli-2.0.1a3/tests/test_optional_extras.py +69 -0
  18. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/.gitignore +0 -0
  19. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/__init__.py +0 -0
  20. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/__main__.py +0 -0
  21. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/__init__.py +0 -0
  22. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/api.py +0 -0
  23. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/backend.py +0 -0
  24. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/bundle.py +0 -0
  25. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/clone.py +0 -0
  26. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/config.py +0 -0
  27. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/config_filters.py +0 -0
  28. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/config_home.py +0 -0
  29. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/config_plugins.py +0 -0
  30. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/config_profile.py +0 -0
  31. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/config_schemas.py +0 -0
  32. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/config_sources.py +0 -0
  33. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/core.py +0 -0
  34. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/deps.py +0 -0
  35. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/diff.py +0 -0
  36. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/drafts.py +0 -0
  37. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/export.py +0 -0
  38. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/kinds.py +0 -0
  39. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/lifecycle.py +0 -0
  40. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/plugin.py +0 -0
  41. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/promote.py +0 -0
  42. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/query.py +0 -0
  43. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/remove.py +0 -0
  44. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/resolve.py +0 -0
  45. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/rollback.py +0 -0
  46. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/self_update.py +0 -0
  47. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/show.py +0 -0
  48. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/sync.py +0 -0
  49. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/update.py +0 -0
  50. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/commands/versions.py +0 -0
  51. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/context.py +0 -0
  52. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/discovery.py +0 -0
  53. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/fields.py +0 -0
  54. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/install.py +0 -0
  55. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/options.py +0 -0
  56. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/output.py +0 -0
  57. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/src/katalyst_taxonomy_cli/py.typed +0 -0
  58. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/conftest.py +0 -0
  59. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_add.py +0 -0
  60. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_add_bundle.py +0 -0
  61. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_api_command.py +0 -0
  62. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_cli_version.py +0 -0
  63. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_clone.py +0 -0
  64. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_command_signatures.py +0 -0
  65. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_config_command.py +0 -0
  66. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_config_explain.py +0 -0
  67. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_config_home.py +0 -0
  68. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_config_plugins_command.py +0 -0
  69. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_config_profiles.py +0 -0
  70. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_config_sources.py +0 -0
  71. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_create.py +0 -0
  72. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_create_typed_and_variants.py +0 -0
  73. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_deps.py +0 -0
  74. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_git_backend_cli.py +0 -0
  75. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_github_backend_cli.py +0 -0
  76. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_install.py +0 -0
  77. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_kinds.py +0 -0
  78. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_kinds_plugin_settings.py +0 -0
  79. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_lint_output.py +0 -0
  80. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_lint_strict.py +0 -0
  81. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_output.py +0 -0
  82. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_plugin_command.py +0 -0
  83. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_plugin_gating.py +0 -0
  84. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_relate_update.py +0 -0
  85. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_relocate.py +0 -0
  86. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_remove.py +0 -0
  87. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_repository_targets.py +0 -0
  88. {katalyst_taxonomy_cli-2.0.1a2 → katalyst_taxonomy_cli-2.0.1a3}/tests/test_self_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: katalyst-taxonomy-cli
3
- Version: 2.0.1a2
3
+ Version: 2.0.1a3
4
4
  Summary: CLI for katalyst-taxonomy — the kata command
5
5
  Author: eSimplicity
6
6
  License: Proprietary
@@ -8,5 +8,12 @@ Requires-Python: >=3.12
8
8
  Requires-Dist: click>=8.1
9
9
  Requires-Dist: katalyst-core<1,>=0.1
10
10
  Requires-Dist: katalyst-engine<6,>=5
11
+ Requires-Dist: katalyst-plugin-context<1,>=0.2
12
+ Requires-Dist: katalyst-plugin-harness<1,>=0.1
13
+ Requires-Dist: katalyst-plugin-scaffolding<2,>=1
11
14
  Requires-Dist: katalyst-taxonomy-mcp<5,>=4
12
15
  Requires-Dist: katalyst-taxonomy<6,>=5
16
+ Provides-Extra: bard
17
+ Requires-Dist: katalyst-plugin-bard<1,>=0.1; extra == 'bard'
18
+ Provides-Extra: tui
19
+ Requires-Dist: katalyst-taxonomy-tui<3,>=2; extra == 'tui'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-taxonomy-cli"
3
- version = "2.0.1a2"
3
+ version = "2.0.1a3"
4
4
  description = "CLI for katalyst-taxonomy — the kata command"
5
5
  license = { text = "Proprietary" }
6
6
  requires-python = ">=3.12"
@@ -15,8 +15,20 @@ dependencies = [
15
15
  "katalyst-engine>=5,<6",
16
16
  "click>=8.1",
17
17
  "katalyst-core>=0.1,<1",
18
+ # The plugins that ship with kata: `kata tax context|harness|add`. Bounded to the
19
+ # config-rooted runtime, like the plugins' own floors on this package (a cycle pip
20
+ # and uv both resolve: a plugin's command imports this package's context and options).
21
+ "katalyst-plugin-context>=0.2,<1",
22
+ "katalyst-plugin-harness>=0.1,<1",
23
+ "katalyst-plugin-scaffolding>=1,<2",
18
24
  ]
19
25
 
26
+ [project.optional-dependencies]
27
+ # `kata tax bard`: the web UI plugin.
28
+ bard = ["katalyst-plugin-bard>=0.1,<1"]
29
+ # `kata tui`: the terminal UI.
30
+ tui = ["katalyst-taxonomy-tui>=2,<3"]
31
+
20
32
  [project.scripts]
21
33
  kata = "katalyst_taxonomy_cli.__main__:main"
22
34
 
@@ -123,7 +123,9 @@ def _named(registry: BackendRegistry, target: str | None, address: str) -> tuple
123
123
  A prefix and ``--backend`` that disagree is an error rather than one silently
124
124
  winning: the command was told two different stores.
125
125
  """
126
- if address:
126
+ from katalyst_taxonomy.config.routing import split_backend_prefix
127
+
128
+ if address and (target is None or split_backend_prefix(address)[0] is not None):
127
129
  parsed = registry.address(address)
128
130
  if target and target != parsed.backend:
129
131
  raise click.ClickException(
@@ -135,7 +137,7 @@ def _named(registry: BackendRegistry, target: str | None, address: str) -> tuple
135
137
  from katalyst_taxonomy.config.routing import UnknownBackendError
136
138
 
137
139
  raise UnknownBackendError(target, registry.profile_backends())
138
- return target, ""
140
+ return target, address
139
141
  return None, ""
140
142
 
141
143
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import importlib.util
5
6
  import subprocess
6
7
  import sys
7
8
  from pathlib import Path
@@ -77,6 +78,42 @@ class KataGroup(click.Group):
77
78
  raise click.ClickException(str(exc)) from exc
78
79
 
79
80
 
81
+ def _missing_extra(name: str) -> str | None:
82
+ """The extra of this package named *name* whose requirements are not installed.
83
+
84
+ A command group a plugin contributes is offered as an extra of the same name
85
+ (``katalyst-taxonomy-cli[<name>]``). Read from this distribution's own metadata, so
86
+ the CLI names no plugin: ``None`` when there is no such extra, or it is installed.
87
+ """
88
+ import importlib.metadata as metadata
89
+ import re
90
+
91
+ try:
92
+ requirements = metadata.requires("katalyst-taxonomy-cli") or []
93
+ except metadata.PackageNotFoundError:
94
+ return None
95
+ marker = re.compile(rf"""extra\s*==\s*['"]{re.escape(name)}['"]""")
96
+ wanted = [r for r in requirements if marker.search(r)]
97
+ if not wanted:
98
+ return None
99
+ for requirement in wanted:
100
+ distribution = re.split(r"[\s;<>=!~\[(]", requirement, maxsplit=1)[0]
101
+ try:
102
+ metadata.distribution(distribution)
103
+ except metadata.PackageNotFoundError:
104
+ return name
105
+ return None
106
+
107
+
108
+ def _not_installed_message(extra: str, label: str) -> str:
109
+ return (
110
+ f"{label} is not installed. Install it with:\n"
111
+ f" pip install 'katalyst-taxonomy-cli[{extra}]'\n"
112
+ f"or, for a uv tool install:\n"
113
+ f" uv tool install 'katalyst-taxonomy-cli[{extra}]'"
114
+ )
115
+
116
+
80
117
  class TaxGroup(click.Group):
81
118
  """The ``tax`` group, which resolves plugin-contributed commands **lazily**.
82
119
 
@@ -114,6 +151,16 @@ class TaxGroup(click.Group):
114
151
  self._ensure_plugin_commands(ctx)
115
152
  return super().get_command(ctx, cmd_name)
116
153
 
154
+ def resolve_command(
155
+ self, ctx: click.Context, args: list[str]
156
+ ) -> tuple[str | None, click.Command | None, list[str]]:
157
+ name = args[0] if args else None
158
+ if name and self.get_command(ctx, name) is None:
159
+ extra = _missing_extra(name)
160
+ if extra is not None:
161
+ raise click.ClickException(_not_installed_message(extra, f"`kata tax {name}`"))
162
+ return super().resolve_command(ctx, args)
163
+
117
164
  def _ensure_plugin_commands(self, ctx: click.Context) -> None:
118
165
  if self._plugin_commands_resolved:
119
166
  return
@@ -295,7 +342,12 @@ def _configure_logging() -> None:
295
342
 
296
343
  @cli.command("tui")
297
344
  def tui_cmd() -> None:
298
- """Launch the interactive terminal UI."""
345
+ """Launch the interactive terminal UI.
346
+
347
+ The UI is an extra: ``pip install 'katalyst-taxonomy-cli[tui]'``.
348
+ """
349
+ if importlib.util.find_spec("katalyst_taxonomy_tui") is None:
350
+ raise click.ClickException(_not_installed_message("tui", "the terminal UI"))
299
351
  try:
300
352
  subprocess.run(
301
353
  [sys.executable, "-m", "katalyst_taxonomy_tui"],
@@ -466,6 +518,7 @@ def _register_core_commands() -> None:
466
518
  kinds,
467
519
  lifecycle,
468
520
  lint,
521
+ migrate,
469
522
  plugin,
470
523
  promote,
471
524
  query,
@@ -505,6 +558,7 @@ def _register_core_commands() -> None:
505
558
  tax.add_command(show.show)
506
559
  tax.add_command(kinds.kinds)
507
560
  tax.add_command(lint.lint)
561
+ tax.add_command(migrate.migrate)
508
562
  tax.add_command(deps.deps)
509
563
  tax.add_command(status.status)
510
564
  tax.add_command(rollback.rollback)
@@ -98,13 +98,13 @@ def create(
98
98
 
99
99
  Examples:
100
100
 
101
- kata tax create stage verify --set halt_on_failure=false --set concern_order=maintainability
101
+ kata tax create layer api --under shop.backend --set audited=false --set tier=gold
102
102
 
103
- kata tax create operation probe --set order=10 --set scope.regions=eu-west
103
+ kata tax create layer worker --under shop.backend --set priority=10 --set limits.region=eu-west
104
104
 
105
- kata tax create venue ci --set 'triggers=[{"on":"push","environments":["dev"]}]'
105
+ kata tax create capability checkout --under shop --set 'hooks=[{"on":"push","branches":["main"]}]'
106
106
 
107
- kata tax create environment qa --set template_replacements.log_level=debug
107
+ kata tax create stack web --under shop --set limits.log_level=debug
108
108
  """
109
109
  from katalyst_taxonomy.adapters.orchestration import run_create
110
110
  from katalyst_taxonomy.config.routing import split_backend_prefix
@@ -66,7 +66,7 @@ def init(
66
66
 
67
67
  Writes the taxonomy root (.global/taxonomy/) and version.yaml — together the
68
68
  "initialized" marker — and registers the repository as a filesystem backend of a
69
- profile in the user config file (<user config home>/config.yaml): the --profile
69
+ profile in the active home's config (`kata config path` prints it): the --profile
70
70
  profile (created when it is not declared), else the active one; a file with no
71
71
  profiles gets a new `local`, made active. The backend is named --backend, else for
72
72
  the directory; a name the profile already declares for another directory is refused
@@ -16,21 +16,39 @@ from katalyst_taxonomy_cli.context import CliContext
16
16
  is_flag=True,
17
17
  help=(
18
18
  "Treat advisory findings (governance id collisions, duplicate names, undeclared "
19
- "spec keys, misplaced documents) as errors."
19
+ "spec keys, misplaced documents, shape findings and shape warnings) as errors."
20
+ ),
21
+ )
22
+ @click.option(
23
+ "--backend",
24
+ default=None,
25
+ help=(
26
+ "Report only this backend's findings. Its documents are still checked against "
27
+ "the graph of every backend the profile reads."
20
28
  ),
21
29
  )
22
30
  @click.pass_obj
23
- def lint(ctx: CliContext, verbose: bool, strict: bool) -> None:
31
+ def lint(ctx: CliContext, verbose: bool, strict: bool, backend: str | None) -> None:
24
32
  """Validate taxonomy against schema — everything the active profile reads.
25
33
 
26
- Document checks run over every backend the profile reads. Repository-local checks
27
- (plugin rules, plugin-document schemas, version constraints) run once per
28
- filesystem backend, and each finding is labelled [backend].
34
+ Every check runs over one graph: the documents of every backend the profile reads,
35
+ so a reference into another backend (other:shop.storefront.checkout) is checked
36
+ against the node it names. Each finding is labelled [backend] when the profile
37
+ reads more than one. Repository-local checks (plugin rules, plugin-document schemas,
38
+ version constraints) run once per filesystem backend. A backend that cannot be read
39
+ is an error naming it.
29
40
  """
30
41
  from katalyst_taxonomy.adapters.orchestration import run_lint
42
+ from katalyst_taxonomy.config.routing import BackendUnavailableError
31
43
 
32
44
  try:
33
- result = run_lint(profile=ctx.profile)
45
+ result = run_lint(profile=ctx.profile, backend=backend)
46
+ except BackendUnavailableError as exc:
47
+ raise click.ClickException(
48
+ f"{exc}\nLint checks the graph of every backend the profile reads, so each "
49
+ "must be readable: make it reachable, or lint with a profile that does not "
50
+ "read it."
51
+ ) from exc
34
52
  except ValueError as exc:
35
53
  # A `filters` block that cannot be honoured — `include: []`, a clause
36
54
  # declaring `match.backend` — is a user error (#217), and lint does not go
@@ -39,6 +57,17 @@ def lint(ctx: CliContext, verbose: bool, strict: bool) -> None:
39
57
  # it still reaches the root group and still exits 7.
40
58
  raise click.ClickException(str(exc)) from exc
41
59
 
60
+ labelled = len(result.graph_backends) > 1
61
+
62
+ def _where(path: object) -> str:
63
+ """``[backend] path: `` for a finding about a file, as much as is known."""
64
+ label = ""
65
+ if labelled and isinstance(path, Path):
66
+ owner = result.backend_of(path)
67
+ label = f"[{owner}] " if owner else ""
68
+ shown = f"{path}: " if path is not None and str(path) not in ("", ".") else ""
69
+ return f"{label}{shown}"
70
+
42
71
  # Version constraint warnings are advisory (they do not make the taxonomy
43
72
  # invalid) but must still be surfaced — previously they were unreachable
44
73
  # when the taxonomy was otherwise valid, and only shown under --verbose.
@@ -99,21 +128,15 @@ def lint(ctx: CliContext, verbose: bool, strict: bool) -> None:
99
128
  ("Misplaced documents", result.misplaced_documents),
100
129
  ("Relationships no kind carries", result.uncarried_relationships),
101
130
  ("Shape findings", result.shape_findings),
131
+ ("Shape warnings", result.shape_warnings),
102
132
  ):
103
133
  if not found:
104
134
  continue
105
135
  ctx.output.echo(f"\n{title} ({len(found)}):")
106
136
  for violation in found:
107
- where = f"{violation.path}: " if str(violation.path) not in ("", ".") else ""
108
- ctx.output.echo(f" {severity}: {where}{violation.message}")
137
+ ctx.output.echo(f" {severity}: {_where(violation.path)}{violation.message}")
109
138
  if not strict:
110
139
  ctx.output.echo(" Re-run with --strict to fail on these.")
111
- # A shape's warnings (e.g. coverage) are a report, strict or not.
112
- if result.shape_warnings:
113
- ctx.output.echo(f"\nShape warnings ({len(result.shape_warnings)}):")
114
- for violation in result.shape_warnings:
115
- where = f"{violation.path}: " if str(violation.path) not in ("", ".") else ""
116
- ctx.output.echo(f" WARNING: {where}{violation.message}")
117
140
 
118
141
  def _print_history_notes() -> None:
119
142
  found = result.removed_history_targets
@@ -121,8 +144,7 @@ def lint(ctx: CliContext, verbose: bool, strict: bool) -> None:
121
144
  return
122
145
  ctx.output.echo(f"\nHistory references to removed nodes ({len(found)}):")
123
146
  for violation in found:
124
- where = f"{violation.path}: " if str(violation.path) not in ("", ".") else ""
125
- ctx.output.echo(f" INFO: {where}{violation.message}")
147
+ ctx.output.echo(f" INFO: {_where(violation.path)}{violation.message}")
126
148
 
127
149
  strict_findings = (
128
150
  len(result.governance_id_collisions)
@@ -131,6 +153,7 @@ def lint(ctx: CliContext, verbose: bool, strict: bool) -> None:
131
153
  + len(result.misplaced_documents)
132
154
  + len(result.uncarried_relationships)
133
155
  + len(result.shape_findings)
156
+ + len(result.shape_warnings)
134
157
  )
135
158
  strict_failure = strict and strict_findings > 0
136
159
 
@@ -142,6 +165,10 @@ def lint(ctx: CliContext, verbose: bool, strict: bool) -> None:
142
165
  for repo in result.repositories
143
166
  )
144
167
  ctx.output.echo(f" Repositories checked: {repositories or '(none)'}")
168
+ if result.focus is not None:
169
+ ctx.output.echo(
170
+ f" Findings for: {result.focus}; graph: {', '.join(result.graph_backends)}"
171
+ )
145
172
  # Show the receipts: a gate that verified nothing must not look identical
146
173
  # to one that verified everything.
147
174
  ctx.output.echo(
@@ -179,14 +206,14 @@ def lint(ctx: CliContext, verbose: bool, strict: bool) -> None:
179
206
  if result.document_errors:
180
207
  ctx.output.echo(f"\nDocument errors ({len(result.document_errors)}):")
181
208
  for err in result.document_errors:
182
- ctx.output.echo(f" {err.path}: {err.message}")
209
+ ctx.output.echo(f" {_where(err.path)}{err.message}")
183
210
 
184
211
  # Positioned between document and plugin-schema errors so block order is
185
212
  # fixed across runs and corpora.
186
213
  if result.reference_errors:
187
214
  ctx.output.echo(f"\nReference errors ({len(result.reference_errors)}):")
188
215
  for ref_err in result.reference_errors:
189
- ctx.output.echo(f" {ref_err.path}: {ref_err.message}")
216
+ ctx.output.echo(f" {_where(ref_err.path)}{ref_err.message}")
190
217
 
191
218
  if result.plugin_schema_errors:
192
219
  ctx.output.echo(f"\nSchema errors ({len(result.plugin_schema_errors)}):")
@@ -0,0 +1,210 @@
1
+ """kata tax migrate — move a repository's documents to the loaded schema versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import click
8
+
9
+ from katalyst_taxonomy_cli.context import CliContext
10
+
11
+ if TYPE_CHECKING:
12
+ from katalyst_taxonomy.services.schema_migration import MigrationReport
13
+
14
+
15
+ @click.command("migrate")
16
+ @click.option(
17
+ "--backend",
18
+ "backend",
19
+ default=None,
20
+ help=(
21
+ "The repository (a filesystem backend of the active profile) to migrate. "
22
+ "Default: every repository the profile reads."
23
+ ),
24
+ )
25
+ @click.option("--dry-run", is_flag=True, help="Show what would change, as a diff; write nothing.")
26
+ @click.option(
27
+ "--from",
28
+ "starts",
29
+ multiple=True,
30
+ metavar="SCHEMA=VERSION",
31
+ help=(
32
+ "The version a schema's documents are at, instead of what the repository "
33
+ "records (or, when it records nothing, the schema's first release). Repeatable."
34
+ ),
35
+ )
36
+ @click.option(
37
+ "--allow-dirty",
38
+ is_flag=True,
39
+ help="Migrate even when files the migration writes have uncommitted changes.",
40
+ )
41
+ @click.option(
42
+ "--allow-lint-errors",
43
+ is_flag=True,
44
+ help="Write even when the migrated documents do not lint clean (strict).",
45
+ )
46
+ @click.option("--json", "as_json", is_flag=True, help="Print the report as JSON.")
47
+ @click.pass_obj
48
+ def migrate(
49
+ ctx: CliContext,
50
+ backend: str | None,
51
+ dry_run: bool,
52
+ starts: tuple[str, ...],
53
+ allow_dirty: bool,
54
+ allow_lint_errors: bool,
55
+ as_json: bool,
56
+ ) -> None:
57
+ """Move a repository's documents to the schema versions this kata loads.
58
+
59
+ A repository records the version of each schema its documents were written for
60
+ (.global/taxonomy/schema.lock). When a schema's major is behind the loaded one, the
61
+ repository is read-only until it is migrated: this runs the schema's migrations,
62
+ each major in turn, then lints the result strictly and records the new versions.
63
+
64
+ Nothing is committed: review the change with `git diff`, then commit it. A
65
+ repository already at the loaded majors is not changed (its lock is written when it
66
+ has none, or records an older minor), so running it twice is safe.
67
+ """
68
+ from katalyst_engine.evolution.chain import MigrationChainError
69
+ from katalyst_engine.evolution.lock import SchemaLockError
70
+ from katalyst_engine.evolution.migration import MigrationFileError, MigrationStepError
71
+ from katalyst_taxonomy.adapters.filesystem.schema_migration import MigrationWriteError
72
+ from katalyst_taxonomy.adapters.permission_gate import unwrap_backend
73
+ from katalyst_taxonomy.adapters.schema_migrate import MigrationRefused, migrate_repository
74
+ from katalyst_taxonomy.services.schema_migration import parse_starts
75
+
76
+ from katalyst_taxonomy_cli._wiring import load_registry
77
+
78
+ try:
79
+ start_versions = parse_starts(starts)
80
+ except ValueError as exc:
81
+ raise click.BadParameter(str(exc), param_hint="--from") from exc
82
+
83
+ registry = load_registry(ctx)
84
+ if backend is not None:
85
+ names = [backend]
86
+ if backend not in registry.profile_backends():
87
+ reads = ", ".join(registry.profile_backends()) or "none"
88
+ raise click.ClickException(
89
+ f"backend {backend!r} is not one the active profile reads ({reads})"
90
+ )
91
+ else:
92
+ names = list(registry.profile_backends())
93
+ repositories: list[tuple[str, Any]] = []
94
+ for name in names:
95
+ inner = unwrap_backend(registry.get(name))
96
+ if getattr(inner, "repository_root", None) is None or not callable(
97
+ getattr(inner, "read_schema_lock", None)
98
+ ):
99
+ if backend is not None:
100
+ raise click.ClickException(
101
+ f"backend {name!r} is not a repository; `kata tax migrate` moves "
102
+ "repositories (filesystem backends) forward"
103
+ )
104
+ continue
105
+ repositories.append((name, inner))
106
+ if not repositories:
107
+ raise click.ClickException("the active profile reads no repository to migrate")
108
+
109
+ payloads: list[dict[str, object]] = []
110
+ failed = False
111
+ for name, inner in repositories:
112
+ root = inner.repository_root
113
+ try:
114
+ report = migrate_repository(
115
+ name,
116
+ root,
117
+ dry_run=dry_run,
118
+ allow_dirty=allow_dirty,
119
+ allow_lint_errors=allow_lint_errors,
120
+ starts=start_versions,
121
+ excluded_subtrees=tuple(getattr(inner, "excluded_subtrees", ())),
122
+ )
123
+ except (
124
+ MigrationRefused,
125
+ MigrationChainError,
126
+ MigrationFileError,
127
+ MigrationStepError,
128
+ MigrationWriteError,
129
+ SchemaLockError,
130
+ ValueError,
131
+ ) as exc:
132
+ message = str(exc) if str(exc).startswith(f"{name}:") else f"{name}: {exc}"
133
+ payloads.append({"backend": name, "error": message})
134
+ if not as_json:
135
+ ctx.output.error(message)
136
+ failed = True
137
+ continue
138
+ payloads.append(report.to_dict())
139
+ if not as_json:
140
+ _print(ctx, report, dry_run=dry_run)
141
+ if as_json:
142
+ ctx.output.json_out(payloads[0] if len(payloads) == 1 else payloads)
143
+ if failed:
144
+ raise SystemExit(1)
145
+
146
+
147
+ def _versions(report: MigrationReport) -> str:
148
+ moving = report.moving
149
+ if moving:
150
+ return ", ".join(f"{c.name} {c.start} → {c.target}" for c in moving)
151
+ return ", ".join(f"{c.name} {c.target}" for c in report.chains)
152
+
153
+
154
+ def _print(ctx: CliContext, report: MigrationReport, *, dry_run: bool) -> None:
155
+ out = ctx.output
156
+ if report.rolled_back:
157
+ out.echo(
158
+ f"{report.backend}: an interrupted migration was rolled back "
159
+ f"({len(report.rolled_back)} file(s) put back)."
160
+ )
161
+ if report.nothing_to_do:
162
+ out.echo(f"{report.backend}: current ({_versions(report)}). Nothing to migrate.")
163
+ return
164
+ count = sum(len(c.migrations) for c in report.moving)
165
+ if report.moving:
166
+ taken = (
167
+ "" if report.status.recorded else " (no schema.lock: taken to be at the first release)"
168
+ )
169
+ out.echo(f"{report.backend}: {_versions(report)} ({count} migration(s)){taken}")
170
+ else:
171
+ out.echo(f"{report.backend}: current; recording its schema versions")
172
+ for chain in report.moving:
173
+ for migration in chain.migrations:
174
+ out.echo(f" {chain.name} {migration.from_}-to-{migration.to}")
175
+ for outcome in report.outcomes:
176
+ if outcome.migration is migration:
177
+ counts = f"{outcome.written} document(s)"
178
+ if outcome.removed:
179
+ counts += f", {outcome.removed} removed"
180
+ out.echo(f" {outcome.label:<52} {counts}")
181
+ actions: dict[str, int] = {}
182
+ for planned in report.files:
183
+ actions[planned.action] = actions.get(planned.action, 0) + 1
184
+ summary = ", ".join(f"{n} {a}" for a, n in sorted(actions.items()))
185
+ out.echo(f"Files ({len(report.files)}): {summary}")
186
+ for planned in report.files:
187
+ where = f"{planned.moved_from} → {planned.path}" if planned.moved_from else planned.path
188
+ out.echo(f" {planned.action:<7} {where}")
189
+ after = ", ".join(f"{k} {v}" for k, v in report.lock_after.as_strings().items())
190
+ out.echo(f"schema.lock: {after}")
191
+ if report.lint_clean:
192
+ out.echo(f"lint after: clean (strict, {report.lint_documents} document(s))")
193
+ else:
194
+ out.echo(f"lint after: {len(report.lint_problems)} problem(s) (strict) in files it writes")
195
+ for problem in report.lint_problems[:20]:
196
+ out.echo(f" {problem}")
197
+ if report.lint_other:
198
+ out.echo(
199
+ f"lint after, elsewhere: {len(report.lint_other)} problem(s) the migration "
200
+ "does not touch (not stopping it)"
201
+ )
202
+ for problem in report.lint_other[:20]:
203
+ out.echo(f" {problem}")
204
+ if report.dirty:
205
+ out.echo(f"uncommitted changes to: {', '.join(report.dirty)}")
206
+ if dry_run:
207
+ out.echo("Nothing written (dry run). The changes, as a diff:")
208
+ out.echo(report.diff.rstrip("\n"))
209
+ elif report.applied:
210
+ out.echo("Written. Review the change with `git diff`, then commit it.")
@@ -6,14 +6,31 @@ import click
6
6
 
7
7
  from katalyst_taxonomy_cli.context import CliContext
8
8
 
9
+ _BACKEND = click.option(
10
+ "--backend",
11
+ "backend_target",
12
+ default=None,
13
+ help="The source's backend, when SOURCE_FQTN has no prefix.",
14
+ )
9
15
 
10
- def _endpoints(ctx: CliContext, source_address: str, target_address: str): # noqa: ANN202
16
+
17
+ def _endpoints( # noqa: ANN202
18
+ ctx: CliContext, source_address: str, target_address: str, backend_target: str | None = None
19
+ ):
11
20
  """The source and target ends; an unprefixed target is in the source's backend."""
12
- from katalyst_taxonomy.config.routing import BackendAddressError
21
+ from katalyst_taxonomy.config.routing import BackendAddressError, split_backend_prefix
13
22
  from katalyst_taxonomy.services.relationships import endpoints
14
23
 
15
24
  from .._wiring import load_registry
16
25
 
26
+ prefix, bare = split_backend_prefix(source_address)
27
+ if backend_target is not None:
28
+ if prefix is not None and prefix != backend_target:
29
+ raise click.ClickException(
30
+ f"{source_address!r} names backend {prefix!r} but --backend is {backend_target!r}."
31
+ )
32
+ if prefix is None:
33
+ source_address = f"{backend_target}:{bare}"
17
34
  try:
18
35
  return endpoints(load_registry(ctx), source_address, target_address)
19
36
  except BackendAddressError as exc:
@@ -51,6 +68,7 @@ def _service(): # noqa: ANN202
51
68
  "--before", default=None, metavar="TARGET", help="In an ordered field, before TARGET."
52
69
  )
53
70
  @click.option("--after", default=None, metavar="TARGET", help="In an ordered field, after TARGET.")
71
+ @_BACKEND
54
72
  @click.pass_obj
55
73
  def relate(
56
74
  ctx: CliContext,
@@ -61,6 +79,7 @@ def relate(
61
79
  index: int | None,
62
80
  before: str | None,
63
81
  after: str | None,
82
+ backend_target: str | None,
64
83
  ) -> None:
65
84
  """Relate two nodes, in the source's backend.
66
85
 
@@ -102,7 +121,7 @@ def relate(
102
121
  position = Position(index=index, before=before, after=after)
103
122
  except ValueError as exc:
104
123
  raise click.ClickException("give one of --index, --before and --after") from exc
105
- source, target = _endpoints(ctx, source_fqtn, target_fqtn)
124
+ source, target = _endpoints(ctx, source_fqtn, target_fqtn, backend_target)
106
125
  outcome = _service().relate(source, rel_type, target, properties=props, position=position)
107
126
  if not outcome.success or outcome.relationship is None:
108
127
  ctx.output.echo(f"Error: {outcome.error}")
@@ -118,15 +137,22 @@ def relate(
118
137
  @click.argument("source_fqtn")
119
138
  @click.argument("rel_type")
120
139
  @click.argument("target_fqtn")
140
+ @_BACKEND
121
141
  @click.pass_obj
122
- def unrelate(ctx: CliContext, source_fqtn: str, rel_type: str, target_fqtn: str) -> None:
142
+ def unrelate(
143
+ ctx: CliContext,
144
+ source_fqtn: str,
145
+ rel_type: str,
146
+ target_fqtn: str,
147
+ backend_target: str | None,
148
+ ) -> None:
123
149
  """Remove a relationship ``kata tax relate`` made, from the source's backend.
124
150
 
125
151
  Examples:
126
152
 
127
153
  kata tax unrelate local:payments.checkout DEPENDS_ON payments.billing
128
154
  """
129
- source, target = _endpoints(ctx, source_fqtn, target_fqtn)
155
+ source, target = _endpoints(ctx, source_fqtn, target_fqtn, backend_target)
130
156
  outcome = _service().unrelate(source, rel_type, target)
131
157
  if not outcome.success or outcome.relationship is None:
132
158
  ctx.output.echo(f"Error: {outcome.error}")