multicz 1.2.1__tar.gz → 1.3.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 (85) hide show
  1. {multicz-1.2.1 → multicz-1.3.0}/PKG-INFO +1 -1
  2. {multicz-1.2.1 → multicz-1.3.0}/pyproject.toml +8 -1
  3. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/changelog/markdown.py +34 -1
  4. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/__init__.py +1 -0
  5. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/bump.py +16 -0
  6. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/plan.py +13 -0
  7. multicz-1.3.0/src/multicz/cli/commands/plugins.py +29 -0
  8. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/release_notes.py +7 -0
  9. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/status.py +10 -0
  10. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/presenters.py +145 -0
  11. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/results.py +6 -0
  12. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/config/models.py +6 -0
  13. multicz-1.3.0/src/multicz/plugins/__init__.py +47 -0
  14. multicz-1.3.0/src/multicz/plugins/builtin/__init__.py +9 -0
  15. multicz-1.3.0/src/multicz/plugins/builtin/deprecation/__init__.py +17 -0
  16. multicz-1.3.0/src/multicz/plugins/builtin/deprecation/plugin.py +216 -0
  17. multicz-1.3.0/src/multicz/plugins/builtin/deprecation/scanner.py +142 -0
  18. multicz-1.3.0/src/multicz/plugins/protocol.py +137 -0
  19. multicz-1.3.0/src/multicz/plugins/registry.py +90 -0
  20. multicz-1.3.0/src/multicz/plugins/runner.py +148 -0
  21. {multicz-1.2.1 → multicz-1.3.0}/README.md +0 -0
  22. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/__init__.py +0 -0
  23. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/__main__.py +0 -0
  24. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/changelog/__init__.py +0 -0
  25. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/changelog/bucket.py +0 -0
  26. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/changelog/debian.py +0 -0
  27. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/_shared.py +0 -0
  28. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/__init__.py +0 -0
  29. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/artifacts.py +0 -0
  30. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/changed.py +0 -0
  31. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/changelog.py +0 -0
  32. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/check.py +0 -0
  33. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/config.py +0 -0
  34. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/explain.py +0 -0
  35. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/get.py +0 -0
  36. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/graph.py +0 -0
  37. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/init.py +0 -0
  38. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/state.py +0 -0
  39. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/cli/commands/validate.py +0 -0
  40. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/commits/__init__.py +0 -0
  41. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/commits/git.py +0 -0
  42. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/commits/parse.py +0 -0
  43. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/config/__init__.py +0 -0
  44. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/config/components.py +0 -0
  45. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/config/sources.py +0 -0
  46. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/__init__.py +0 -0
  47. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/_common.py +0 -0
  48. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/cargo.py +0 -0
  49. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/context.py +0 -0
  50. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/debian.py +0 -0
  51. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/go.py +0 -0
  52. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/gradle.py +0 -0
  53. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/helm.py +0 -0
  54. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/node.py +0 -0
  55. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/python.py +0 -0
  56. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/registry.py +0 -0
  57. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/discovery/relations.py +0 -0
  58. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/__init__.py +0 -0
  59. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/_base.py +0 -0
  60. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/_common.py +0 -0
  61. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/json.py +0 -0
  62. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/plain.py +0 -0
  63. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/properties.py +0 -0
  64. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/regex.py +0 -0
  65. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/toml.py +0 -0
  66. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/formats/yaml.py +0 -0
  67. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/planner/__init__.py +0 -0
  68. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/planner/build.py +0 -0
  69. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/planner/plan.py +0 -0
  70. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/planner/reasons.py +0 -0
  71. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/state/__init__.py +0 -0
  72. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/__init__.py +0 -0
  73. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/_base.py +0 -0
  74. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/_cycle.py +0 -0
  75. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/_git.py +0 -0
  76. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/bump_files.py +0 -0
  77. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/changelog.py +0 -0
  78. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/cycles.py +0 -0
  79. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/mirrors.py +0 -0
  80. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/overlap.py +0 -0
  81. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/state.py +0 -0
  82. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/validation/versions.py +0 -0
  83. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/writers/__init__.py +0 -0
  84. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/writers/_base.py +0 -0
  85. {multicz-1.2.1 → multicz-1.3.0}/src/multicz/writers/debian_changelog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: multicz
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Multi-component versioning for monorepos: bump apps, charts, and images independently from conventional commits.
5
5
  Keywords: semver,monorepo,helm,conventional-commits,release,versioning
6
6
  Author: Chris
@@ -3,7 +3,7 @@
3
3
 
4
4
  [project]
5
5
  name = "multicz"
6
- version = "1.2.1"
6
+ version = "1.3.0"
7
7
  description = "Multi-component versioning for monorepos: bump apps, charts, and images independently from conventional commits."
8
8
  readme = "README.md"
9
9
  authors = [
@@ -34,6 +34,13 @@ dependencies = [
34
34
  [project.scripts]
35
35
  multicz = "multicz.cli:app"
36
36
 
37
+ # Built-in plugins registered via the same entry-point group third-
38
+ # party plugins use. Disable a built-in by setting
39
+ # `[plugins.<name>] enabled = false` in multicz.toml — there is no
40
+ # privileged loader path.
41
+ [project.entry-points."multicz.plugins"]
42
+ deprecation = "multicz.plugins.builtin.deprecation:DeprecationPlugin"
43
+
37
44
  [project.urls]
38
45
  Homepage = "https://github.com/goabonga/multicz"
39
46
  Repository = "https://github.com/goabonga/multicz"
@@ -35,11 +35,15 @@ from collections.abc import Iterable, Mapping, Sequence
35
35
  from dataclasses import dataclass
36
36
  from datetime import date
37
37
  from pathlib import Path
38
+ from typing import TYPE_CHECKING
38
39
 
39
40
  from ..commits import BumpRule, Commit
40
41
  from ..config import ChangelogSection, _default_changelog_sections
41
42
  from .bucket import bucket_commits
42
43
 
44
+ if TYPE_CHECKING:
45
+ from ..plugins import ChangelogEntry as PluginChangelogEntry
46
+
43
47
 
44
48
  @dataclass(frozen=True)
45
49
  class CascadeEntry:
@@ -81,6 +85,7 @@ def render_body(
81
85
  cascades: Sequence[CascadeEntry] | None = None,
82
86
  cascade_title: str = "Dependencies",
83
87
  cascade_format: str = "Track `{upstream}` `{upstream_version}`",
88
+ plugin_sections: Sequence[PluginChangelogEntry] | None = None,
84
89
  ) -> str:
85
90
  """Render the section bodies (no leading H2).
86
91
 
@@ -125,7 +130,17 @@ def render_body(
125
130
  )
126
131
  cascade_groups.setdefault(section_name, []).append(line)
127
132
 
128
- if bucketed.is_empty and not cascade_groups:
133
+ # Plugin-contributed sections (e.g. "Deprecated", "Removed" from the
134
+ # built-in deprecation plugin) — grouped by section name, merged with
135
+ # any matching commit/cascade bucket.
136
+ plugin_groups: dict[str, list[str]] = {}
137
+ if plugin_sections:
138
+ for entry in plugin_sections:
139
+ if not entry.section or not entry.lines:
140
+ continue
141
+ plugin_groups.setdefault(entry.section, []).extend(entry.lines)
142
+
143
+ if bucketed.is_empty and not cascade_groups and not plugin_groups:
129
144
  return "_No notable changes._\n"
130
145
 
131
146
  def _commit_line(commit: Commit) -> str:
@@ -174,6 +189,20 @@ def render_body(
174
189
  for title, lines in cascade_groups.items():
175
190
  ordered.append((title, list(lines)))
176
191
 
192
+ # Plugin sections land last (after commits + cascades) so they read
193
+ # as a clear "meta" block — typically Deprecated/Removed notices.
194
+ # If a plugin reuses an existing title (e.g. "Features"), merge
195
+ # rather than create a duplicate H3.
196
+ existing_titles = {title for title, _ in ordered}
197
+ for title, lines in plugin_groups.items():
198
+ if title in existing_titles:
199
+ for entry in ordered:
200
+ if entry[0] == title:
201
+ entry[1].extend(lines)
202
+ break
203
+ else:
204
+ ordered.append((title, list(lines)))
205
+
177
206
  if not ordered:
178
207
  return "_No notable changes._\n"
179
208
 
@@ -199,6 +228,7 @@ def render_section(
199
228
  cascades: Sequence[CascadeEntry] | None = None,
200
229
  cascade_title: str = "Dependencies",
201
230
  cascade_format: str = "Track `{upstream}` `{upstream_version}`",
231
+ plugin_sections: Sequence[PluginChangelogEntry] | None = None,
202
232
  ) -> str:
203
233
  """Render the markdown for a single release section."""
204
234
  when = (today or date.today()).isoformat()
@@ -211,6 +241,7 @@ def render_section(
211
241
  cascades=cascades,
212
242
  cascade_title=cascade_title,
213
243
  cascade_format=cascade_format,
244
+ plugin_sections=plugin_sections,
214
245
  )
215
246
  return f"## [{version}] - {when}\n\n" + body
216
247
 
@@ -265,6 +296,7 @@ def update_changelog_file(
265
296
  cascades: Sequence[CascadeEntry] | None = None,
266
297
  cascade_title: str = "Dependencies",
267
298
  cascade_format: str = "Track `{upstream}` `{upstream_version}`",
299
+ plugin_sections: Sequence[PluginChangelogEntry] | None = None,
268
300
  ) -> None:
269
301
  """Render a new section and merge it into ``path`` (creating the file if needed).
270
302
 
@@ -283,6 +315,7 @@ def update_changelog_file(
283
315
  cascades=cascades,
284
316
  cascade_title=cascade_title,
285
317
  cascade_format=cascade_format,
318
+ plugin_sections=plugin_sections,
286
319
  )
287
320
  existing = path.read_text(encoding="utf-8") if path.exists() else ""
288
321
  if drop_prereleases:
@@ -57,6 +57,7 @@ from .commands import ( # noqa: E402, F401
57
57
  graph,
58
58
  init,
59
59
  plan,
60
+ plugins,
60
61
  release_notes,
61
62
  state,
62
63
  status,
@@ -15,6 +15,7 @@ import typer
15
15
  from ...changelog import update_changelog_file
16
16
  from ...config import ComponentMatcher
17
17
  from ...formats import write_value
18
+ from ...plugins import has_errors, run_enrich_changelog, run_post_plan
18
19
  from ...state import (
19
20
  STATE_SCHEMA_VERSION,
20
21
  ComponentState,
@@ -230,6 +231,15 @@ def bump(
230
231
  presenters.render_bump_empty(output=output)
231
232
  return
232
233
 
234
+ # Plugin hook — gate the bump on installed plugins (deprecation
235
+ # removal policy, license check, etc.). Errors abort here BEFORE
236
+ # any write touches the repo. Warnings + infos surface but proceed.
237
+ violations = run_post_plan(config, repo, plan)
238
+ if violations:
239
+ presenters.render_plugin_violations(violations, output=output)
240
+ if has_errors(violations):
241
+ raise typer.Exit(code=1)
242
+
233
243
  matcher = ComponentMatcher(config.components)
234
244
  applied: list[AppliedBump] = []
235
245
  written: list[Path] = []
@@ -268,6 +278,11 @@ def bump(
268
278
  # this is the only thing that explains *why* the
269
279
  # release exists.
270
280
  cascade_entries = _cascade_entries_for(planned, plan, config)
281
+ # Let plugins (e.g. deprecation) contribute custom sections
282
+ # — Deprecated / Removed / SECURITY / etc.
283
+ plugin_entries = run_enrich_changelog(
284
+ config, repo, plan, planned.component
285
+ )
271
286
  changelog_path = repo / comp.changelog
272
287
  update_changelog_file(
273
288
  changelog_path,
@@ -281,6 +296,7 @@ def bump(
281
296
  cascades=cascade_entries,
282
297
  cascade_title=config.project.cascade_section_title,
283
298
  cascade_format=config.project.cascade_changelog_format,
299
+ plugin_sections=plugin_entries,
284
300
  )
285
301
  if changelog_path not in written:
286
302
  written.append(changelog_path)
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  import typer
11
11
 
12
+ from ...plugins import run_post_plan, run_status_lines
12
13
  from .. import app, err, presenters
13
14
  from .._shared import (
14
15
  _build_plan_or_exit,
@@ -85,3 +86,15 @@ def plan_cmd(
85
86
  presenters.append_plan_summary(summary, plan_obj, header="Release plan")
86
87
 
87
88
  presenters.render_plan(plan_obj, config, output=output)
89
+
90
+ # Preview the post_plan hook so users see violations BEFORE running
91
+ # `multicz bump` — same contract as the real gate, but never aborts.
92
+ violations = run_post_plan(config, repo, plan_obj)
93
+ if violations:
94
+ presenters.render_plugin_violations(violations, output=output)
95
+
96
+ # status_lines surface actionable advice from each plugin
97
+ # (e.g. "remove deprecation X before bumping to 3.0").
98
+ advice = run_status_lines(config, repo, plan_obj)
99
+ if advice:
100
+ presenters.render_plugin_advice(advice, output=output)
@@ -0,0 +1,29 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
4
+ """``multicz plugins`` - list discovered plugins and their config state."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import typer
9
+
10
+ from ...plugins import DEFAULT_REGISTRY
11
+ from .. import app, presenters
12
+ from .._shared import _load
13
+
14
+
15
+ @app.command(name="plugins")
16
+ def plugins_cmd(
17
+ output: str = typer.Option("text", "--output", "-o", help="text | json"),
18
+ ) -> None:
19
+ """List every plugin discovered via the multicz.plugins entry-point
20
+ group with its enabled/disabled state and config section.
21
+
22
+ Useful to verify a third-party plugin is picked up after install,
23
+ confirm one is disabled via ``enabled = false``, or see at a glance
24
+ which plugins gate the next ``multicz bump``.
25
+ """
26
+ _, config = _load()
27
+ plugins = list(DEFAULT_REGISTRY)
28
+ plugins_table: dict[str, dict] = getattr(config, "plugins", {}) or {}
29
+ presenters.render_plugins_list(plugins, plugins_table, output=output)
@@ -18,6 +18,7 @@ from ...commits import (
18
18
  tag_prefix,
19
19
  )
20
20
  from ...config import ComponentMatcher
21
+ from ...plugins import run_enrich_changelog
21
22
  from .. import app, err, presenters
22
23
  from .._shared import (
23
24
  _build_plan_or_exit,
@@ -159,6 +160,12 @@ def release_notes_cmd(
159
160
  # Cascades only attach to upcoming bumps - past tag mode
160
161
  # has no plan reasons to draw from.
161
162
  cascades=tuple(_cascade_entries_for(bump, plan_obj, config)),
163
+ # Plugin contributions for this component (Deprecated /
164
+ # Removed sections from the built-in deprecation plugin,
165
+ # plus anything third-party plugins add).
166
+ plugin_sections=tuple(
167
+ run_enrich_changelog(config, repo, plan_obj, name)
168
+ ),
162
169
  ))
163
170
 
164
171
  if not sections:
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import typer
9
9
 
10
+ from ...plugins import run_post_plan, run_status_lines
10
11
  from .. import app, presenters
11
12
  from .._shared import _build_plan_or_exit, _load
12
13
 
@@ -24,3 +25,12 @@ def status(
24
25
  repo, config = _load()
25
26
  plan_obj = _build_plan_or_exit(repo, config, since=since)
26
27
  presenters.render_status_table(plan_obj)
28
+
29
+ # Surface plugin violations + advice so `status` doubles as a
30
+ # "what would break if I ran bump right now" probe.
31
+ violations = run_post_plan(config, repo, plan_obj)
32
+ if violations:
33
+ presenters.render_plugin_violations(violations, output="text")
34
+ advice = run_status_lines(config, repo, plan_obj)
35
+ if advice:
36
+ presenters.render_plugin_advice(advice, output="text")
@@ -149,6 +149,150 @@ def render_bump_empty(*, output: str) -> None:
149
149
  )
150
150
 
151
151
 
152
+ def render_plugins_list(plugins, plugins_config, *, output: str) -> None:
153
+ """Render the ``multicz plugins`` listing.
154
+
155
+ ``plugins`` is the iterable of discovered :class:`Plugin` instances
156
+ (typically ``list(DEFAULT_REGISTRY)``). ``plugins_config`` is
157
+ ``config.plugins`` — the raw TOML ``[plugins.<name>]`` map keyed by
158
+ plugin name.
159
+
160
+ Three discrete states are surfaced:
161
+
162
+ * **active** — ``[plugins.<name>]`` declared in multicz.toml and not
163
+ explicitly disabled. The runner will invoke it.
164
+ * **disabled** — section declared but ``enabled = false``. The user
165
+ has opted out on purpose.
166
+ * **inactive** — the plugin is discovered via its entry point but
167
+ no ``[plugins.<name>]`` section is declared. It will *not* run;
168
+ the project hasn't opted in.
169
+
170
+ Text output is a 4-column table; JSON is the machine-readable
171
+ shape for CI consumers.
172
+ """
173
+ rows = []
174
+ for plugin in plugins:
175
+ configured = plugin.name in plugins_config
176
+ section = plugins_config.get(plugin.name, {}) or {}
177
+ if not configured:
178
+ status = "inactive"
179
+ elif section.get("enabled", True):
180
+ status = "active"
181
+ else:
182
+ status = "disabled"
183
+ impl_cls = type(plugin)
184
+ module = impl_cls.__module__
185
+ rows.append(
186
+ {
187
+ "name": plugin.name,
188
+ "status": status,
189
+ "configured": configured,
190
+ "enabled": status == "active",
191
+ "module": module,
192
+ "class": impl_cls.__name__,
193
+ "config": section,
194
+ }
195
+ )
196
+ if output == "json":
197
+ console.print_json(data={"plugins": rows})
198
+ return
199
+ if not rows:
200
+ console.print("[dim]no plugins discovered[/]")
201
+ console.print(
202
+ "[dim]install a plugin or check it declares the "
203
+ "[bold]multicz.plugins[/] entry-point group[/]"
204
+ )
205
+ return
206
+ table = Table()
207
+ table.add_column("Plugin", style="bold")
208
+ table.add_column("Status")
209
+ table.add_column("Module")
210
+ table.add_column("Config section")
211
+ status_render = {
212
+ "active": "[green]active[/]",
213
+ "disabled": "[yellow]disabled[/]",
214
+ "inactive": "[dim]inactive[/]",
215
+ }
216
+ for row in rows:
217
+ status_cell = status_render[row["status"]]
218
+ cfg = row["config"]
219
+ # Square brackets in TOML section names trigger Rich's markup
220
+ # parser — escape them so they render literally in the table.
221
+ section_label = f"\\[plugins.{row['name']}]"
222
+ if row["status"] == "inactive":
223
+ cfg_cell = (
224
+ f"[dim](not in multicz.toml — add {section_label} "
225
+ "to activate)[/]"
226
+ )
227
+ elif cfg:
228
+ keys = ", ".join(f"{k}={v!r}" for k, v in cfg.items())
229
+ cfg_cell = f"{section_label} {keys}"
230
+ else:
231
+ cfg_cell = f"{section_label} [dim](defaults)[/]"
232
+ table.add_row(row["name"], status_cell, row["module"], cfg_cell)
233
+ console.print(table)
234
+
235
+
236
+ def render_plugin_advice(lines, *, output: str) -> None:
237
+ """Render :meth:`Plugin.status_lines` output — free-form actionable
238
+ text plugins return to inform the user (e.g. "3 deprecations marked
239
+ for removal in v3.0").
240
+
241
+ Text output prefixes each line with a magenta arrow so it stands
242
+ out from the bump table without screaming like a violation.
243
+ """
244
+ if output == "json":
245
+ console.print_json(data={"advice": list(lines)})
246
+ return
247
+ if not lines:
248
+ return
249
+ console.print()
250
+ for line in lines:
251
+ console.print(f" [magenta]→[/] {line}")
252
+
253
+
254
+ def render_plugin_violations(violations, *, output: str) -> None:
255
+ """Render the list of :class:`multicz.plugins.Violation` raised by
256
+ installed plugins after :meth:`Plugin.post_plan`.
257
+
258
+ Text output groups by severity (errors first, then warnings, then
259
+ infos) and prefixes each line with the plugin name. JSON output
260
+ emits an array suited for CI consumers.
261
+ """
262
+ if output == "json":
263
+ console.print_json(
264
+ data={
265
+ "violations": [
266
+ {
267
+ "severity": v.severity.value,
268
+ "message": v.message,
269
+ "plugin": v.plugin,
270
+ "component": v.component,
271
+ "file": str(v.file) if v.file else None,
272
+ "line": v.line,
273
+ }
274
+ for v in violations
275
+ ]
276
+ }
277
+ )
278
+ return
279
+ colour = {"error": "red", "warning": "yellow", "info": "cyan"}
280
+ glyph = {"error": "✗", "warning": "!", "info": "·"}
281
+ by_severity: dict[str, list] = {"error": [], "warning": [], "info": []}
282
+ for v in violations:
283
+ by_severity[v.severity.value].append(v)
284
+ for sev in ("error", "warning", "info"):
285
+ for v in by_severity[sev]:
286
+ loc = ""
287
+ if v.file:
288
+ loc = f" [dim]({v.file}{':' + str(v.line) if v.line else ''})[/]"
289
+ comp = f"[dim]\\[{v.component}][/] " if v.component else ""
290
+ console.print(
291
+ f" [{colour[sev]}]{glyph[sev]}[/] {comp}{v.message}"
292
+ f" [dim](from {v.plugin})[/]" + loc
293
+ )
294
+
295
+
152
296
  def _git_summary_to_json(git: GitSummary) -> dict:
153
297
  """Project a GitSummary back to the legacy ``git_summary`` dict shape.
154
298
 
@@ -455,6 +599,7 @@ def render_release_notes(
455
599
  cascades=list(s.cascades) if s.cascades else None,
456
600
  cascade_title=config.project.cascade_section_title,
457
601
  cascade_format=config.project.cascade_changelog_format,
602
+ plugin_sections=list(s.plugin_sections) if s.plugin_sections else None,
458
603
  )
459
604
  if multi:
460
605
  range_label = (
@@ -75,6 +75,11 @@ class ReleaseNotesSection:
75
75
  Replaces the per-section dict carried through ``sections: list[dict]``.
76
76
  ``cascades`` is empty for the ``--tag`` retrospective mode (no plan
77
77
  reasons exist).
78
+
79
+ ``plugin_sections`` carries the output of every plugin's
80
+ :meth:`enrich_changelog` for this component — Deprecated/Removed
81
+ notices, security advisories, etc. — appended below the commit-driven
82
+ sections at render time.
78
83
  """
79
84
 
80
85
  component: str
@@ -82,6 +87,7 @@ class ReleaseNotesSection:
82
87
  to_version: str
83
88
  commits: tuple[Commit, ...]
84
89
  cascades: tuple[CascadeEntry, ...] = ()
90
+ plugin_sections: tuple = ()
85
91
 
86
92
 
87
93
  # ============ validate ============
@@ -318,6 +318,12 @@ class Config(BaseModel):
318
318
 
319
319
  project: ProjectSettings = Field(default_factory=ProjectSettings)
320
320
  components: dict[str, Component]
321
+ # Free-form sub-tables consumed by installed plugins. multicz itself
322
+ # never validates the inner shape — each plugin is responsible for
323
+ # parsing its own ``plugins[<plugin-name>]`` slice. Allows
324
+ # ``[plugins.deprecation]`` in multicz.toml without the core needing
325
+ # to know what fields the deprecation plugin uses.
326
+ plugins: dict[str, dict[str, Any]] = Field(default_factory=dict)
321
327
 
322
328
  @model_validator(mode="before")
323
329
  @classmethod
@@ -0,0 +1,47 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
4
+ """multicz plugin system.
5
+
6
+ Plugins extend the bump / planning / changelog pipeline without touching
7
+ the core. They are discovered via the ``multicz.plugins`` entry-point
8
+ group (``importlib.metadata``), so any package on PYTHONPATH that
9
+ registers an entry point participates automatically.
10
+
11
+ Built-in plugins live under :mod:`multicz.plugins.builtin` and are
12
+ registered in multicz's own pyproject.toml.
13
+
14
+ See :mod:`multicz.plugins.protocol` for the hook contract.
15
+ """
16
+
17
+ from .protocol import (
18
+ BasePlugin,
19
+ ChangelogEntry,
20
+ Plugin,
21
+ PluginContext,
22
+ Severity,
23
+ Violation,
24
+ )
25
+ from .registry import DEFAULT_REGISTRY, ENTRY_POINT_GROUP, PluginRegistry
26
+ from .runner import (
27
+ has_errors,
28
+ run_enrich_changelog,
29
+ run_post_plan,
30
+ run_status_lines,
31
+ )
32
+
33
+ __all__ = [
34
+ "DEFAULT_REGISTRY",
35
+ "ENTRY_POINT_GROUP",
36
+ "BasePlugin",
37
+ "ChangelogEntry",
38
+ "Plugin",
39
+ "PluginContext",
40
+ "PluginRegistry",
41
+ "Severity",
42
+ "Violation",
43
+ "has_errors",
44
+ "run_enrich_changelog",
45
+ "run_post_plan",
46
+ "run_status_lines",
47
+ ]
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
4
+ """Built-in multicz plugins shipped alongside the core.
5
+
6
+ Each built-in lives in its own submodule and registers via
7
+ ``[project.entry-points."multicz.plugins"]`` in multicz's pyproject.toml
8
+ — same path third-party plugins use, so there's no privileged loader.
9
+ """
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
4
+ """Built-in deprecation-policy plugin.
5
+
6
+ Detects deprecation markers in component sources and enforces a
7
+ "remove by version N" policy at bump time. Also contributes
8
+ ``Deprecated`` / ``Removed`` sections to changelogs and release notes
9
+ when relevant.
10
+
11
+ See :mod:`multicz.plugins.builtin.deprecation.plugin` for the public
12
+ :class:`DeprecationPlugin` entry point.
13
+ """
14
+
15
+ from .plugin import DeprecationPlugin
16
+
17
+ __all__ = ["DeprecationPlugin"]