Skip to content

Plan publishable crates before publish - #16

Merged
leynos merged 21 commits into
mainfrom
codex/implement-lading-publish-subcommand
Oct 24, 2025
Merged

Plan publishable crates before publish#16
leynos merged 21 commits into
mainfrom
codex/implement-lading-publish-subcommand

Conversation

@leynos

@leynos leynos commented Oct 20, 2025

Copy link
Copy Markdown
Owner

Summary

  • add a publish planning dataclass that filters crates based on manifest publish flags and configuration excludes
  • surface the publish plan through the CLI output, behavioural tests, and refreshed documentation
  • replace the placeholder publish unit tests with coverage for the new planner and update the roadmap

Testing

  • make check-fmt
  • make typecheck
  • make lint
  • make test

https://chatgpt.com/codex/tasks/task_e_68f62fc6b8c883229ed9ccadb82a5411

Summary by Sourcery

Implement crate publication planning in the publish command by filtering on manifest and configuration excludes, rendering a structured plan in CLI output, and updating docs, Makefile and tests to reflect the new behavior.

New Features:

  • Add PublishPlan dataclass and plan_publication function to compute publishable, skipped, and missing crates

Enhancements:

  • Replace placeholder publish command with real planning logic and integrate configuration/workspace loading in run()
  • Update Makefile lint/format/typecheck targets and adjust ruff config rules

Documentation:

  • Update usage guide, design doc, roadmap, and AGENTS.md to describe new publish planning feature and revised testing commands

Tests:

  • Introduce unit tests for publish planner and run() behavior, plus BDD fixtures and step definitions for publish command scenarios

Summary by CodeRabbit

  • New Features

    • Publish now generates and displays a structured publication plan up front, showing crates to publish, crates skipped (by manifest or configuration), and unmatched/stale exclusions.
  • Documentation

    • Usage, design, and roadmap updated with publish plan examples, semantics, and testing guidance.
  • Tests

    • Added unit tests and extensive BDD fixtures/steps for publish and bump flows; removed legacy placeholder test module and obsolete step implementations.
  • Chores

    • Build/tooling targets standardized and lint config adjusted.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @leynos, you have reached your weekly rate limit of 2500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Oct 20, 2025

Copy link
Copy Markdown

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds publication planning to the publish command: introduces a PublishPlan model and plan_publication() to classify crates as publishable, manifest-skipped, configuration-excluded, and report missing exclusions; integrates plan formatting into run, updates docs, and adds/rewires BDD and unit tests and fixtures.

Changes

Cohort / File(s) Summary
Docs
docs/lading-design.md, docs/roadmap.md, docs/usage-guide.md
Document the new publish planning flow, example plan output, handling of publish.exclude and publish = false, mark roadmap task complete, and update testing hooks documentation.
Publish command implementation
lading/commands/publish.py
Add PublishPlan dataclass, plan_publication(...), PublishPlan.publishable_names property, internal helpers (_format_plan, _format_section, _ensure_configuration, _ensure_workspace), use normalise_workspace_root, and replace placeholder run logic to compute and render the publish plan with error handling.
Unit tests (added)
tests/unit/test_publish_command.py
Add tests covering plan computation, sorting, manifest/config excludes, missing exclusions, run formatting, and error propagation.
Unit tests (removed)
tests/unit/test_commands_placeholder.py
Remove previous placeholder command unit tests.
BDD feature updates
tests/bdd/features/cli.feature
Update scenarios to assert structured publish plan output and sections for skipped crates and missing exclusions.
BDD fixtures & helpers (new)
tests/bdd/steps/fixtures.py, tests/bdd/steps/test_common_steps.py
Add fixtures to build test workspaces and cargo metadata stubs, helpers for editing lading.toml excludes, _run_cli test runner, shared assertion steps, and dependency-check utilities.
BDD publish & bump steps (new)
tests/bdd/steps/test_publish_steps.py, tests/bdd/steps/test_bump_steps.py
Add step definitions to invoke publish/bump and assert publish plan sections and bump outputs.
BDD cleanup (removed)
tests/bdd/steps/test_cli_steps.py
Remove a large legacy BDD steps module (replaced by focused modules).
BDD package doc
tests/bdd/steps/__init__.py
Add module docstring for step packages.
Pytest plugin registration
tests/conftest.py
Register new BDD step modules in pytest_plugins.
Makefile / tooling docs
Makefile, AGENTS.md, pyproject.toml
Simplify Makefile nixie invocation, switch quality-gate commands to Makefile targets, and add Ruff ignore entries with explanatory comments.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as "lading publish CLI"
    participant Workspace as "WorkspaceGraph / cargo metadata"
    participant Config as "LadingConfig (lading.toml)"
    participant Formatter as "Plan Formatter"

    User->>CLI: invoke publish
    CLI->>Workspace: load workspace graph / cargo metadata
    CLI->>Config: load configuration (publish.exclude)
    CLI->>CLI: plan_publication(workspace, configuration)
    Note right of CLI #f7f7d9: classify crates into\n- publishable\n- manifest-skipped\n- config-excluded\n- collect missing exclusions
    CLI->>Formatter: _format_plan(plan, strip_patches)
    Formatter-->>CLI: formatted plan text
    CLI-->>User: print plan summary (stdout)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Files/areas to pay extra attention to:

  • lading/commands/publish.py — classification logic, deterministic sorting, and error propagation.
  • tests/bdd/steps/fixtures.py — filesystem mutation, cargo metadata stubs, and fixture isolation.
  • tests/unit/test_publish_command.py — coverage of edge cases and formatting assertions.
  • Removal of tests/bdd/steps/test_cli_steps.py — ensure no remaining imports reference deleted step definitions.

Poem

🐇 I hopped through crates and counted strings,
Plans in paw and tiny things,
Some say "publish", some say "no",
Missing names that dance below,
I nibble docs and leave bright springs.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "Plan publishable crates before publish" accurately reflects the main change in this changeset. The primary feature added is a PublishPlan dataclass and plan_publication function that determines which workspace crates should be published while honoring manifest flags and configuration excludes. The title clearly communicates that the publish command now generates and displays a plan before execution, capturing the core intent of the changes. The language is specific and descriptive, avoiding vague terms and clearly indicating the developer's focus on planning functionality.
Docstring Coverage ✅ Passed Docstring coverage is 89.55% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/implement-lading-publish-subcommand

📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e7f3966 and b7f9b63.

📒 Files selected for processing (1)
  • tests/bdd/steps/fixtures.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (.rules/python-00.md)

**/*.py: Python files must use snake_case filenames (e.g., http_client.py, task_queue.py)
Classes must use PascalCase
Variables and functions must use snake_case
Module-level constants must use UPPER_SNAKE_CASE
Prefix non-exported helpers or internal APIs with a single leading underscore
Use typing everywhere and maintain full static type coverage
Use TypedDict or @DataClass for structured data; prefer @DataClass(slots=True) for internal-only usage
Avoid Any; prefer precise types (TypeVar, Protocol, Literal, Union); use typing.cast only when necessary with justification; use object for unknown opaque values
Be explicit with return types (e.g., -> None, -> str) for all public functions and methods
Favor immutability: prefer tuples to lists and MappingProxyType for read-only mappings; document third-party frozendict if used
Use # pyright: ignore sparingly and include an explanation comment when used
Avoid side effects at import time (no global state mutation or actions on import)
Use docstrings (NumPy format) for public functions, classes, and modules
Explain tricky code with inline comments

**/*.py: Prefer context managers to encapsulate setup/teardown for resources (files, locks, connections) instead of manual try/finally blocks
Use contextlib.contextmanager (@contextmanager) to implement simple, linear setup/teardown context managers
Implement a class with enter and exit for context managers that require internal state or more complex lifecycle handling
For file I/O, prefer with open(...) as ... over open()/try/finally/close patterns
Choose @contextmanager when control flow is linear and no persistent state is needed
Choose a class-based context manager when there is internal state, lifecycle methods, re-entry, or advanced context needs

**/*.py: Exception classes must end with the suffix 'Error' (N818)
Prefer specific built-ins (e.g., TypeError, ValueError) or domain exceptions over raising Exception directly (TRY003/TRY004)
Preserve causal chains...

Files:

  • tests/bdd/steps/fixtures.py
{**/unittests/test_*.py,tests/**/*.py}

📄 CodeRabbit inference engine (.rules/python-00.md)

{**/unittests/test_*.py,tests/**/*.py}: Use pytest idioms: prefer fixtures over setup/teardown, parametrize broadly, avoid unnecessary mocks
Group related tests using classes with method names prefixed by test_
Write tests from a user's perspective: test public behaviour, not internals

Files:

  • tests/bdd/steps/fixtures.py
tests/**/*.py

📄 CodeRabbit inference engine (.rules/python-exception-design-raising-handling-and-logging.md)

In tests, assert specific exception types (and optional message via regex) rather than using broad Exception (B017)

Files:

  • tests/bdd/steps/fixtures.py
🧬 Code graph analysis (1)
tests/bdd/steps/fixtures.py (1)
tests/helpers/workspace_helpers.py (1)
  • install_cargo_stub (15-38)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Sourcery review
🔇 Additional comments (5)
tests/bdd/steps/fixtures.py (5)

60-95: Excellent refactoring: manifest update helpers.

The extraction of _update_manifest_version and _update_crate_manifests successfully addresses the complexity issue flagged in earlier reviews. The separation of concerns is clean, error handling is robust, and the iteration over all crate manifests (rather than hardcoding alpha) resolves the past concern about updating only a single crate.


98-111: Complexity issue resolved.

The refactoring successfully reduced cyclomatic complexity from 9 to 1 by delegating to _update_manifest_version and _update_crate_manifests. The function is now straightforward and maintainable.


290-306: Excess arguments issue resolved.

The refactoring from 5 explicit parameters to name, manifest_path, version="0.1.0", **metadata successfully brings the function within the 4-argument threshold while maintaining flexibility for test scenarios. The noqa: ANN401 annotation with explanation is appropriate for fixture code accepting arbitrary metadata fields.


432-471: Duplicate code eliminated.

The extraction of _add_exclude_to_config successfully removes duplication between given_bump_exclude_contains and given_publish_exclude_contains. The generic helper with a table_name parameter is clean, idempotent, and handles all edge cases (missing config, missing table, missing array).


1-471: Well-structured fixture module with successful refactoring.

The file demonstrates high code quality and successfully addresses all issues raised in previous reviews:

  • Complexity reduction in given_workspace_versions_match through helper extraction
  • Argument count reduction in _build_package_metadata via **metadata pattern
  • Duplicate code elimination via _add_exclude_to_config

Type hints are comprehensive, docstrings follow NumPy format, error messages are descriptive, and the code adheres to Python best practices. The fixtures provide clear, reusable building blocks for BDD scenarios.


Comment @coderabbitai help to get the list of available commands and usage tips.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@leynos

leynos commented Oct 20, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

lading/commands/publish.py

Comment on lines +73 to +100

def _format_plan(
    plan: PublishPlan, *, strip_patches: config_module.StripPatchesSetting
) -> str:
    """Render ``plan`` to a human-readable summary for CLI output."""
    lines = [
        f"Publish plan for {plan.workspace_root}",
        f"Strip patch strategy: {strip_patches}",
    ]

    if plan.publishable:
        lines.append(f"Crates to publish ({len(plan.publishable)}):")
        lines.extend(f"- {crate.name} @ {crate.version}" for crate in plan.publishable)
    else:
        lines.append("Crates to publish: none")

    if plan.skipped_manifest:
        lines.append("Skipped (publish = false):")
        lines.extend(f"- {crate.name}" for crate in plan.skipped_manifest)

    if plan.skipped_configuration:
        lines.append("Skipped via publish.exclude:")
        lines.extend(f"- {crate.name}" for crate in plan.skipped_configuration)

    if plan.missing_configuration_exclusions:
        lines.append("Configured exclusions not found in workspace:")
        lines.extend(f"- {name}" for name in plan.missing_configuration_exclusions)

    return "\n".join(lines)

❌ New issue: Complex Method
_format_plan has a cyclomatic complexity of 10, threshold = 9

@coderabbitai

This comment was marked as resolved.

@leynos

leynos commented Oct 21, 2025

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@sourcery-ai

sourcery-ai Bot commented Oct 21, 2025

Copy link
Copy Markdown

Reviewer's Guide

This PR implements a full publish planning workflow by introducing a PublishPlan model and planning function, integrating it into the CLI command in place of a placeholder implementation, updating documentation and configuration scripts, and adding comprehensive unit and BDD tests to verify the new behavior.

Sequence diagram for the new publish planning workflow in CLI

sequenceDiagram
    participant User as actor User
    participant CLI as "lading publish CLI"
    participant Config as "LadingConfig"
    participant Workspace as "WorkspaceGraph"
    participant Planner as "plan_publication()"
    participant Formatter as "_format_plan()"
    User->>CLI: Run publish command
    CLI->>Config: Ensure configuration loaded
    CLI->>Workspace: Ensure workspace loaded
    CLI->>Planner: Plan publication (workspace, config)
    Planner->>Planner: Filter crates by manifest and config
    Planner-->>CLI: Return PublishPlan
    CLI->>Formatter: Format PublishPlan for output
    Formatter-->>CLI: Return formatted plan
    CLI-->>User: Display publication plan
Loading

Class diagram for the new PublishPlan and planning workflow

classDiagram
    class PublishPlan {
        +Path workspace_root
        +tuple[WorkspaceCrate] publishable
        +tuple[WorkspaceCrate] skipped_manifest
        +tuple[WorkspaceCrate] skipped_configuration
        +tuple[str] missing_configuration_exclusions
        +publishable_names(): tuple[str]
    }
    class WorkspaceCrate {
        +str name
        +str version
        +bool publish
    }
    class WorkspaceGraph {
        +Path workspace_root
        +list[WorkspaceCrate] crates
    }
    class LadingConfig {
        +PublishConfig publish
    }
    class PublishConfig {
        +list[str] exclude
        +str strip_patches
    }
    PublishPlan "1" -- "*" WorkspaceCrate : publishable
    PublishPlan "1" -- "*" WorkspaceCrate : skipped_manifest
    PublishPlan "1" -- "*" WorkspaceCrate : skipped_configuration
    WorkspaceGraph "1" -- "*" WorkspaceCrate : crates
    LadingConfig "1" -- "1" PublishConfig : publish
Loading

File-Level Changes

Change Details Files
Implement publish planning API and CLI integration
  • Add PublishPlan dataclass with fields for publishable, skipped crates, and missing exclusions, plus a publishable_names property
  • Implement plan_publication to filter crates by manifest.publish and publish.exclude, compute missing exclusions, and sort results
  • Introduce _format_plan and _format_section to render the plan to CLI output
  • Add _ensure_configuration and _ensure_workspace helpers to lazily load config and workspace
  • Update run() to invoke plan_publication and return formatted plan, removing the placeholder behavior
lading/commands/publish.py
Update usage and design documentation to describe publish planning
  • Revise usage guide to explain new publish plan output and include an example
  • Augment design doc with details on skipped crates reporting and missing exclusions
  • Mark the 'Determine Publishable Crates' roadmap task as completed
docs/usage-guide.md
docs/lading-design.md
docs/roadmap.md
Align configuration and build tooling for testing and linting
  • Update AGENTS.md to use make lint, check-fmt, typecheck, markdownlint, and nixie targets
  • Adjust pyproject.toml to extend-ignore conflicting D203/D213 rules
  • Simplify Makefile nixie invocation to use --no-sandbox
AGENTS.md
pyproject.toml
Makefile
Add unit and BDD tests for publish planning logic and CLI
  • Create unit tests for plan_publication, _format_plan, and run() error cases
  • Add BDD fixtures and steps to assert plan output, skipped crates, and missing exclusions
  • Update conftest to include new publish step modules and remove placeholder test modules
tests/unit/test_publish_command.py
tests/bdd/steps/fixtures.py
tests/bdd/steps/test_publish_steps.py
tests/bdd/features/cli.feature
tests/conftest.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

sourcery-ai[bot]

This comment was marked as resolved.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@leynos

leynos commented Oct 22, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Have the following now been resolved?

  • Consider sorting the publishable and skipped crate lists alphabetically to ensure stable, reproducible CLI output regardless of the workspace graph’s internal ordering.
  • There’s a lot of similar logic across the _format_*_section functions; you could extract a small helper that emits an optional heading plus a list of items to reduce boilerplate.

@leynos

leynos commented Oct 22, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

tests/bdd/steps/test_cli_steps.py

Comment on file

    version: str,
    manifest_path: Path,
    dependencies: list[dict[str, str]] | None = None,
    *,

❌ New issue: Low Cohesion
This module has at least 18 different responsibilities amongst its 32 functions, threshold = 4

@leynos

leynos commented Oct 22, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

tests/bdd/steps/test_cli_steps.py

Comment on lines +220 to +221

    *,
    publish: bool | tuple[str, ...] | None = None,

❌ New issue: Excess Number of Function Arguments
_build_package_metadata has 5 arguments, max arguments = 4

@coderabbitai

coderabbitai Bot commented Oct 22, 2025

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the _format_plan function and related helper functions from the file
cat lading/commands/publish.py | grep -A 200 "def _format"

Length of output: 2434


@leynos: Let me check the current state of the code:

Status Update:

  1. Boilerplate reductionRESOLVED: The code now uses a generic _format_section helper that accepts items, header, item_formatter, and optional empty_lines. This elegantly eliminates the duplication across all section formatting calls.

  2. Alphabetical sortingNOT RESOLVED: The items are still formatted in their original order from the tuples. No sorting is applied in either _format_section or _format_plan.

To resolve the sorting issue, you could modify _format_section to sort items before formatting:

def _format_section(
    items: tuple[T, ...],
    *,
    header: str,
    item_formatter: typ.Callable[[T], str],
    empty_lines: tuple[str, ...] = (),
    sort_key: typ.Callable[[T], typ.Any] | None = None,
) -> list[str]:
    """Return ``header`` and formatted ``items`` when any are present."""
    if not items:
        return list(empty_lines)

    sorted_items = sorted(items, key=sort_key) if sort_key else items
    formatted_items = [item_formatter(item) for item in sorted_items]
    return [header, *formatted_items]

Then update the calls in _format_plan to include sort_key=lambda crate: crate.name for crate lists and sort_key=lambda name: name for the missing exclusions list.

@coderabbitai

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@leynos

leynos commented Oct 23, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

tests/bdd/steps/fixtures.py

Comment on file

    version: str,
@given(parsers.parse('publish.exclude contains "{crate_name}"'))
def given_publish_exclude_contains(
    workspace_directory: Path,

❌ New issue: Code Duplication
The module contains 2 functions with similar structure: given_bump_exclude_contains,given_publish_exclude_contains

@coderabbitai

This comment was marked as resolved.

codescene-delta-analysis[bot]

This comment was marked as outdated.

codescene-delta-analysis[bot]

This comment was marked as outdated.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
tests/bdd/steps/fixtures.py (1)

370-377: Nice deduplication via _add_exclude_to_config.

This removes the previous code duplication flagged by CodeScene; cleaner and single source of truth.

Also applies to: 379-385

🧹 Nitpick comments (9)
lading/commands/publish.py (2)

83-97: Reduce _format_section argument count to satisfy CodeScene and simplify call sites.

_currently 5 params; CodeScene flags “Excess Number of Function Arguments” (max=4). Remove sort_key and pre‑sort at call sites. Output stays identical.

Apply:

-def _format_section(
-    items: tuple[T, ...],
-    *,
-    header: str,
-    item_formatter: typ.Callable[[T], str],
-    empty_lines: tuple[str, ...] = (),
-    sort_key: typ.Callable[[T], typ.Any] | None = None,
-) -> list[str]:
+def _format_section(
+    items: tuple[T, ...],
+    *,
+    header: str,
+    item_formatter: typ.Callable[[T], str],
+    empty_lines: tuple[str, ...] = (),
+) -> list[str]:
@@
-    ordered_items = sorted(items, key=sort_key) if sort_key else items
-    formatted_items = [item_formatter(item) for item in ordered_items]
+    formatted_items = [item_formatter(item) for item in items]
     return [header, *formatted_items]

And update call sites:

     lines.extend(
         _format_section(
-            plan.publishable,
+            plan.publishable,
             header=f"Crates to publish ({len(plan.publishable)}):",
             item_formatter=lambda crate: f"- {crate.name} @ {crate.version}",
             empty_lines=("Crates to publish: none",),
-            sort_key=lambda crate: crate.name,
         )
     )
@@
     lines.extend(
         _format_section(
-            plan.skipped_manifest,
+            plan.skipped_manifest,
             header="Skipped (publish = false):",
             item_formatter=lambda crate: f"- {crate.name}",
-            sort_key=lambda crate: crate.name,
         )
     )
@@
     lines.extend(
         _format_section(
-            plan.skipped_configuration,
+            plan.skipped_configuration,
             header="Skipped via publish.exclude:",
             item_formatter=lambda crate: f"- {crate.name}",
-            sort_key=lambda crate: crate.name,
         )
     )
@@
     lines.extend(
         _format_section(
-            plan.missing_configuration_exclusions,
+            tuple(sorted(plan.missing_configuration_exclusions)),
             header="Configured exclusions not found in workspace:",
             item_formatter=lambda name: f"- {name}",
-            sort_key=lambda name: name,
         )
     )

This addresses the CodeScene biomarker without changing behaviour. As per coding guidelines.

Also applies to: 109-141


50-51: Avoid redundant sorting; sort once at the end.

You sort workspace.crates (Line 50) and then sort the result lists again (Lines 66–73). One sort is enough.

Apply:

-    workspace_crates = tuple(sorted(workspace.crates, key=lambda crate: crate.name))
+    workspace_crates = workspace.crates

Keep the ordered_* sorts, which produce the stable output. Simpler and equivalent. As per coding guidelines.

Also applies to: 66-73

tests/bdd/steps/test_publish_steps.py (1)

31-37: Reuse the helper to trim lines; remove duplication.

Use _publish_plan_lines for consistency with other steps.

Apply:

-    workspace = cli_run["workspace"]
-    lines = [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
+    workspace = cli_run["workspace"]
+    lines = _publish_plan_lines(cli_run)
tests/bdd/steps/test_bump_steps.py (1)

88-91: Make expectation robust to the “- ” prefix.

If the feature inputs are bare paths, prefix them here to match CLI output.

Apply:

-    expected_lines = [first, second]
+    expected_lines = [f"- {first}", f"- {second}"]
tests/bdd/steps/test_common_steps.py (2)

24-49: Prefer a TypedDict for CLI run results; avoid Any.

Define a CLIRun TypedDict and use it for _run_cli and step params. Clearer types and better IDE help.

Apply:

-from pytest_bdd import parsers, scenarios, then
+from pytest_bdd import parsers, scenarios, then
@@
-if typ.TYPE_CHECKING:
-    from pathlib import Path
+from typing import TypedDict
+if typ.TYPE_CHECKING:
+    from pathlib import Path
+
+class CLIRun(TypedDict):
+    returncode: int
+    stdout: str
+    stderr: str
+    workspace: "Path"
@@
-def _run_cli(
+def _run_cli(
     repo_root: Path,
     workspace_directory: Path,
     *command_args: str,
-) -> dict[str, typ.Any]:
+) -> CLIRun:
@@
-    return {
+    return {
         "returncode": completed.returncode,
         "stdout": completed.stdout,
         "stderr": completed.stderr,
         "workspace": workspace_directory.resolve(),
     }

Then update step signatures in this module to use CLIRun instead of dict[str, typ.Any]. As per coding guidelines.


189-191: Avoid duplicate step registration.

These imports duplicate plugin loading already done in tests/conftest.py. Remove to reduce noise and potential double-import side effects.

Apply:

-# Import subcommand-specific steps so their definitions register with pytest-bdd.
-from . import test_bump_steps as _bump_steps  # noqa: E402,F401  # isort: skip
-from . import test_publish_steps as _publish_steps  # noqa: E402,F401  # isort: skip
+# Step modules are registered via pytest_plugins in tests/conftest.py.
tests/bdd/steps/fixtures.py (3)

33-46: Avoid KeyError when [workspace.package] is missing.

Accessing workspace_document["workspace"]["package"] can fail if the table isn’t present in scenarios that don’t pre-create it. Init tables defensively.

-    workspace_document = parse_toml(workspace_manifest.read_text(encoding="utf-8"))
-    workspace_document["workspace"]["package"]["version"] = version
+    workspace_document = parse_toml(workspace_manifest.read_text(encoding="utf-8"))
+    # Be tolerant if [workspace] or [workspace.package] is missing
+    ws = workspace_document.get("workspace") or table()
+    workspace_document["workspace"] = ws
+    pkg = ws.get("package") or table()
+    ws["package"] = pkg
+    pkg["version"] = version

209-226: Tighten types for cargo metadata; drop dict[str, Any].

Use TypedDicts for metadata payloads and refine _build_package_metadata types. This improves static coverage and avoids Any. As per coding guidelines.

Add near imports:

- from tomlkit import array, table
+ from tomlkit import array, table

Add TypedDicts (place after imports):

+class DependencyEntry(typ.TypedDict, total=False):
+    name: str
+    package: str
+    # Only when present in cargo metadata
+    kind: typ.Literal["dev", "build"]
+
+class PackageMetadata(typ.TypedDict):
+    name: str
+    version: str
+    id: str
+    manifest_path: str
+    dependencies: list[DependencyEntry]
+    publish: typ.NotRequired[bool | tuple[str, ...] | None]

Update signature and return type:

-def _build_package_metadata(
-    name: str,
-    manifest_path: Path,
-    version: str = "0.1.0",
-    dependencies: list[dict[str, str]] | None = None,
-    *,
-    publish: bool | tuple[str, ...] | None = None,
-) -> dict[str, typ.Any]:
+def _build_package_metadata(
+    name: str,
+    manifest_path: Path,
+    version: str = "0.1.0",
+    dependencies: list[DependencyEntry] | None = None,
+    *,
+    publish: bool | tuple[str, ...] | None = None,
+) -> PackageMetadata:

Optional (if you want to also reduce “argument count” metrics): introduce a small options object and make dependencies/publish fields of it; happy to draft that if desired.


349-368: Make _add_exclude_to_config resilient when config is absent.

Currently assumes the config file exists; calling after “workspace without configuration” would raise. Create a new TOML document when missing.

-from tomlkit import parse as parse_toml
+from tomlkit import parse as parse_toml
+from tomlkit import document as new_document
@@
 def _add_exclude_to_config(
@@
-    document = parse_toml(config_path.read_text(encoding="utf-8"))
+    if config_path.exists():
+        document = parse_toml(config_path.read_text(encoding="utf-8"))
+    else:
+        document = new_document()
@@
-    config_path.write_text(document.as_string(), encoding="utf-8")
+    config_path.write_text(document.as_string(), encoding="utf-8")
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 4a9ddfb and 1efbd61.

📒 Files selected for processing (10)
  • lading/commands/publish.py (1 hunks)
  • tests/bdd/features/cli.feature (1 hunks)
  • tests/bdd/steps/__init__.py (1 hunks)
  • tests/bdd/steps/fixtures.py (1 hunks)
  • tests/bdd/steps/test_bump_steps.py (1 hunks)
  • tests/bdd/steps/test_cli_steps.py (0 hunks)
  • tests/bdd/steps/test_common_steps.py (1 hunks)
  • tests/bdd/steps/test_publish_steps.py (1 hunks)
  • tests/conftest.py (1 hunks)
  • tests/unit/test_publish_command.py (1 hunks)
💤 Files with no reviewable changes (1)
  • tests/bdd/steps/test_cli_steps.py
✅ Files skipped from review due to trivial changes (1)
  • tests/bdd/steps/init.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/unit/test_publish_command.py
  • tests/bdd/features/cli.feature
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (.rules/python-00.md)

**/*.py: Python files must use snake_case filenames (e.g., http_client.py, task_queue.py)
Classes must use PascalCase
Variables and functions must use snake_case
Module-level constants must use UPPER_SNAKE_CASE
Prefix non-exported helpers or internal APIs with a single leading underscore
Use typing everywhere and maintain full static type coverage
Use TypedDict or @DataClass for structured data; prefer @DataClass(slots=True) for internal-only usage
Avoid Any; prefer precise types (TypeVar, Protocol, Literal, Union); use typing.cast only when necessary with justification; use object for unknown opaque values
Be explicit with return types (e.g., -> None, -> str) for all public functions and methods
Favor immutability: prefer tuples to lists and MappingProxyType for read-only mappings; document third-party frozendict if used
Use # pyright: ignore sparingly and include an explanation comment when used
Avoid side effects at import time (no global state mutation or actions on import)
Use docstrings (NumPy format) for public functions, classes, and modules
Explain tricky code with inline comments

**/*.py: Prefer context managers to encapsulate setup/teardown for resources (files, locks, connections) instead of manual try/finally blocks
Use contextlib.contextmanager (@contextmanager) to implement simple, linear setup/teardown context managers
Implement a class with enter and exit for context managers that require internal state or more complex lifecycle handling
For file I/O, prefer with open(...) as ... over open()/try/finally/close patterns
Choose @contextmanager when control flow is linear and no persistent state is needed
Choose a class-based context manager when there is internal state, lifecycle methods, re-entry, or advanced context needs

**/*.py: Exception classes must end with the suffix 'Error' (N818)
Prefer specific built-ins (e.g., TypeError, ValueError) or domain exceptions over raising Exception directly (TRY003/TRY004)
Preserve causal chains...

Files:

  • tests/conftest.py
  • tests/bdd/steps/test_bump_steps.py
  • tests/bdd/steps/fixtures.py
  • tests/bdd/steps/test_publish_steps.py
  • lading/commands/publish.py
  • tests/bdd/steps/test_common_steps.py
{**/unittests/test_*.py,tests/**/*.py}

📄 CodeRabbit inference engine (.rules/python-00.md)

{**/unittests/test_*.py,tests/**/*.py}: Use pytest idioms: prefer fixtures over setup/teardown, parametrize broadly, avoid unnecessary mocks
Group related tests using classes with method names prefixed by test_
Write tests from a user's perspective: test public behaviour, not internals

Files:

  • tests/conftest.py
  • tests/bdd/steps/test_bump_steps.py
  • tests/bdd/steps/fixtures.py
  • tests/bdd/steps/test_publish_steps.py
  • tests/bdd/steps/test_common_steps.py
tests/**/*.py

📄 CodeRabbit inference engine (.rules/python-exception-design-raising-handling-and-logging.md)

In tests, assert specific exception types (and optional message via regex) rather than using broad Exception (B017)

Files:

  • tests/conftest.py
  • tests/bdd/steps/test_bump_steps.py
  • tests/bdd/steps/fixtures.py
  • tests/bdd/steps/test_publish_steps.py
  • tests/bdd/steps/test_common_steps.py
🧬 Code graph analysis (5)
tests/bdd/steps/test_bump_steps.py (2)
tests/bdd/steps/test_common_steps.py (1)
  • _run_cli (24-49)
tests/conftest.py (1)
  • repo_root (21-23)
tests/bdd/steps/fixtures.py (1)
tests/helpers/workspace_helpers.py (1)
  • install_cargo_stub (15-38)
tests/bdd/steps/test_publish_steps.py (2)
tests/bdd/steps/test_common_steps.py (1)
  • _run_cli (24-49)
tests/conftest.py (1)
  • repo_root (21-23)
lading/commands/publish.py (3)
lading/utils/path.py (1)
  • normalise_workspace_root (10-16)
lading/config.py (4)
  • LadingConfig (82-103)
  • current_configuration (155-161)
  • ConfigurationNotLoadedError (27-28)
  • load_configuration (139-142)
lading/workspace/models.py (4)
  • WorkspaceCrate (31-41)
  • WorkspaceGraph (44-53)
  • WorkspaceModelError (18-19)
  • load_workspace (56-63)
tests/bdd/steps/test_common_steps.py (1)
tests/conftest.py (1)
  • repo_root (21-23)
🔇 Additional comments (4)
lading/commands/publish.py (1)

30-34: LGTM: publishable_names is clear and efficient.

Straightforward, typed, and matches the dataclass contract.

tests/conftest.py (1)

12-17: LGTM: plugin registration for BDD steps and fixtures.

The plugin list wires step modules and fixtures cleanly for discovery.

tests/bdd/steps/fixtures.py (2)

228-287: LGTM: internal-dependency stub covers dev/build/runtime kinds.

Good coverage for multiple sections; entries match cargo metadata shape.


23-31: Configuration placement of strip_patches is correct—no changes needed.

Verification confirms strip_patches belongs under [publish] and is correctly placed in the fixture. All usages across the codebase—config loading, tests, and documentation—consistently treat it as a [publish] setting. The fixture code at lines 23–31 is accurate.

Comment thread lading/commands/publish.py Outdated
Comment thread lading/commands/publish.py
@leynos

leynos commented Oct 23, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

lading/commands/publish.py

Comment on file

    workspace: WorkspaceGraph | None = None,
) -> str:
    """Return a placeholder message for the publish command."""
    """Plan crate publication for ``workspace_root``."""

❌ New issue: Bumpy Road Ahead
run has 2 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is 2 blocks per function

@coderabbitai

This comment was marked as resolved.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
lading/commands/publish.py (1)

50-72: Remove redundant sorting at line 50.

Line 50 sorts workspace.crates by name, but lines 66–72 re-sort the filtered lists before converting them to tuples. Since the intermediate lists are sorted again later, the initial sort at line 50 is unnecessary.

Apply this diff to remove the redundant sort:

-    workspace_crates = tuple(sorted(workspace.crates, key=lambda crate: crate.name))
+    workspace_crates = workspace.crates
     crate_names = {crate.name for crate in workspace_crates}
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1efbd61 and 8993999.

📒 Files selected for processing (1)
  • lading/commands/publish.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (.rules/python-00.md)

**/*.py: Python files must use snake_case filenames (e.g., http_client.py, task_queue.py)
Classes must use PascalCase
Variables and functions must use snake_case
Module-level constants must use UPPER_SNAKE_CASE
Prefix non-exported helpers or internal APIs with a single leading underscore
Use typing everywhere and maintain full static type coverage
Use TypedDict or @DataClass for structured data; prefer @DataClass(slots=True) for internal-only usage
Avoid Any; prefer precise types (TypeVar, Protocol, Literal, Union); use typing.cast only when necessary with justification; use object for unknown opaque values
Be explicit with return types (e.g., -> None, -> str) for all public functions and methods
Favor immutability: prefer tuples to lists and MappingProxyType for read-only mappings; document third-party frozendict if used
Use # pyright: ignore sparingly and include an explanation comment when used
Avoid side effects at import time (no global state mutation or actions on import)
Use docstrings (NumPy format) for public functions, classes, and modules
Explain tricky code with inline comments

**/*.py: Prefer context managers to encapsulate setup/teardown for resources (files, locks, connections) instead of manual try/finally blocks
Use contextlib.contextmanager (@contextmanager) to implement simple, linear setup/teardown context managers
Implement a class with enter and exit for context managers that require internal state or more complex lifecycle handling
For file I/O, prefer with open(...) as ... over open()/try/finally/close patterns
Choose @contextmanager when control flow is linear and no persistent state is needed
Choose a class-based context manager when there is internal state, lifecycle methods, re-entry, or advanced context needs

**/*.py: Exception classes must end with the suffix 'Error' (N818)
Prefer specific built-ins (e.g., TypeError, ValueError) or domain exceptions over raising Exception directly (TRY003/TRY004)
Preserve causal chains...

Files:

  • lading/commands/publish.py
🧬 Code graph analysis (1)
lading/commands/publish.py (3)
lading/utils/path.py (1)
  • normalise_workspace_root (10-16)
lading/config.py (4)
  • LadingConfig (82-103)
  • current_configuration (155-161)
  • ConfigurationNotLoadedError (27-28)
  • load_configuration (139-142)
lading/workspace/models.py (4)
  • WorkspaceCrate (31-41)
  • WorkspaceGraph (44-53)
  • WorkspaceModelError (18-19)
  • load_workspace (56-63)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Sourcery review

Comment thread lading/commands/publish.py
@leynos

leynos commented Oct 23, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Have the following now been resolved?

  • Consider sorting the publishable and skipped crate lists alphabetically to ensure stable, reproducible CLI output regardless of the workspace graph’s internal ordering.
  • There’s a lot of similar logic across the _format_*_section functions; you could extract a small helper that emits an optional heading plus a list of items to reduce boilerplate.

@coderabbitai

coderabbitai Bot commented Oct 23, 2025

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the publish.py file to check the current state of formatting functions
cat lading/commands/publish.py

Length of output: 6170


@leynos: Yes, both improvements have been fully resolved! ✅

Here's what was implemented:

1. Alphabetical Sorting ✅ RESOLVED

The _format_section helper now includes a sort_key parameter (line 94), and all calls in _format_plan use it:

  • Line 109: sort_key=lambda crate: crate.name for publishable crates
  • Line 116: sort_key=lambda crate: crate.name for manifest-skipped crates
  • Line 123: sort_key=lambda crate: crate.name for configuration-skipped crates
  • Line 130: sort_key=lambda name: name for missing exclusions

This ensures stable, reproducible CLI output regardless of workspace graph ordering.

2. Boilerplate Reduction ✅ RESOLVED

The code now uses a single generic _format_section helper (lines 91-100) that accepts:

  • items: the collection to format
  • header: the section heading
  • item_formatter: a callable to format each item
  • empty_lines: optional fallback when items is empty
  • sort_key: optional sorting function

This elegantly replaces the need for separate _format_publishable_section, _format_skipped_manifest_section, etc., eliminating all the duplication.

Minor note: Items are also sorted in plan_publication before being stored in the PublishPlan dataclass (lines 67-75), making the sorting in _format_section technically redundant—but this doesn't affect correctness and ensures the output is sorted even if the plan changes in the future.

@leynos
leynos force-pushed the codex/implement-lading-publish-subcommand branch from b5b9d4a to e7f3966 Compare October 24, 2025 22:03
codescene-delta-analysis[bot]

This comment was marked as outdated.

@leynos

leynos commented Oct 24, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

tests/bdd/steps/fixtures.py

Comment on lines +74 to +76

    crates_root = workspace_directory / "crates"
    if not crates_root.exists():
        message = f"Crates directory not found: {crates_root}"

❌ New issue: Complex Method
given_workspace_versions_match has a cyclomatic complexity of 9, threshold = 9

@leynos

leynos commented Oct 24, 2025

Copy link
Copy Markdown
Owner Author

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

tests/bdd/steps/fixtures.py

Comment on file

        "id": f"{name}-id",
        "manifest_path": str(manifest_path),
        "dependencies": [] if dependencies is None else dependencies,
        "publish": publish,

❌ New issue: Excess Number of Function Arguments
_build_package_metadata has 5 arguments, max arguments = 4

@coderabbitai

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

@leynos

leynos commented Oct 24, 2025

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@leynos
leynos merged commit 900adf7 into main Oct 24, 2025
4 checks passed
@leynos
leynos deleted the codex/implement-lading-publish-subcommand branch October 24, 2025 22:30

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and found some issues that need to be addressed.

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `tests/bdd/steps/test_publish_steps.py:28-36` </location>
<code_context>
+    return _run_cli(repo_root, workspace_directory, "publish")
+
+
+@then(parsers.parse('the publish command prints the publish plan for "{crate_name}"'))
+def then_publish_prints_plan(cli_run: dict[str, typ.Any], crate_name: str) -> None:
+    """Assert that the publish command emits a publication plan summary."""
+    assert cli_run["returncode"] == 0
+    workspace = cli_run["workspace"]
+    lines = [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
+    assert lines[0] == f"Publish plan for {workspace}"
+    assert "Strip patch strategy: all" in lines[1]
+    assert f"- {crate_name} @ 0.1.0" in lines
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding BDD steps for cases where no crates are publishable.

Add a test scenario where all crates are skipped, and check that the CLI outputs 'Crates to publish: none'.

```suggestion
@then(parsers.parse('the publish command prints the publish plan for "{crate_name}"'))
def then_publish_prints_plan(cli_run: dict[str, typ.Any], crate_name: str) -> None:
    """Assert that the publish command emits a publication plan summary."""
    assert cli_run["returncode"] == 0
    workspace = cli_run["workspace"]
    lines = [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
    assert lines[0] == f"Publish plan for {workspace}"
    assert "Strip patch strategy: all" in lines[1]
    assert f"- {crate_name} @ 0.1.0" in lines

@then('the publish command prints that no crates are publishable')
def then_publish_prints_none(cli_run: dict[str, typ.Any]) -> None:
    """Assert that the publish command emits 'Crates to publish: none' when no crates are publishable."""
    assert cli_run["returncode"] == 0
    lines = [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
    assert any("Crates to publish: none" in line for line in lines)
```
</issue_to_address>

### Comment 2
<location> `tests/bdd/steps/test_publish_steps.py:58-73` </location>
<code_context>
+        'the publish command reports configuration-skipped crate "{crate_name}"'
+    )
+)
+def then_publish_reports_configuration_skip(
+    cli_run: dict[str, typ.Any], crate_name: str
+) -> None:
+    """Assert the publish plan lists ``crate_name`` under configuration skips."""
+    lines = _publish_plan_lines(cli_run)
+    assert "Skipped via publish.exclude:" in lines
+    section_index = lines.index("Skipped via publish.exclude:")
+    skipped = lines[section_index + 1 :]
+    assert f"- {crate_name}" in skipped
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding a test for multiple configuration-skipped crates.

Testing with multiple crates in publish.exclude will verify that all are correctly listed as skipped in the CLI output.

```suggestion
@then(
    parsers.parse(
        'the publish command reports configuration-skipped crate "{crate_name}"'
    )
)
def then_publish_reports_configuration_skip(
    cli_run: dict[str, typ.Any], crate_name: str
) -> None:
    """Assert the publish plan lists ``crate_name`` under configuration skips."""
    lines = _publish_plan_lines(cli_run)
    assert "Skipped via publish.exclude:" in lines
    section_index = lines.index("Skipped via publish.exclude:")
    skipped = lines[section_index + 1 :]
    assert f"- {crate_name}" in skipped


@then(
    parsers.parse(
        'the publish command reports configuration-skipped crates {crate_names}'
    )
)
def then_publish_reports_multiple_configuration_skips(
    cli_run: dict[str, typ.Any], crate_names: str
) -> None:
    """Assert the publish plan lists all specified crate names under configuration skips."""
    lines = _publish_plan_lines(cli_run)
    assert "Skipped via publish.exclude:" in lines
    section_index = lines.index("Skipped via publish.exclude:")
    skipped = lines[section_index + 1 :]
    for crate_name in [name.strip() for name in crate_names.split(",")]:
        assert f"- {crate_name}" in skipped
```
</issue_to_address>

### Comment 3
<location> `tests/bdd/steps/test_bump_steps.py:47-53` </location>
<code_context>
+    return _run_cli(repo_root, workspace_directory, "bump", version, "--dry-run")
+
+
+@then(parsers.parse('the bump command reports manifest updates for "{version}"'))
+def then_command_reports_workspace(cli_run: dict[str, typ.Any], version: str) -> None:
+    """Assert that the bump command reports the updated manifests."""
+    assert cli_run["returncode"] == 0
+    stdout = cli_run["stdout"]
+    assert "Updated version to " in stdout
+    assert version in stdout
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding negative tests for invalid version strings.

Please add BDD steps to test how the CLI responds to invalid version strings, confirming that errors are reported appropriately.
</issue_to_address>

### Comment 4
<location> `lading/commands/publish.py:36` </location>
<code_context>
+        return tuple(crate.name for crate in self.publishable)
+
+
+def plan_publication(
+    workspace: WorkspaceGraph,
+    configuration: LadingConfig,
</code_context>

<issue_to_address>
**issue (review_instructions):** Add unit and behavioural tests for the new publication planning logic.

The new `plan_publication` function and related helpers implement non-trivial logic for determining publishable crates and exclusions. You must add both unit and behavioural tests to verify correct behaviour, including edge cases for manifest and configuration exclusions.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*`

**Instructions:**
For any new feature or change to an existing feature, both behavioural *and* unit tests are required.

</details>
</issue_to_address>

### Comment 5
<location> `lading/commands/publish.py:186` </location>
<code_context>
+        raise WorkspaceModelError(message) from exc


 def run(
</code_context>

<issue_to_address>
**issue (review_instructions):** Add tests for the new publish command output and planning behaviour.

The `run` function now produces a publication plan and formatted output. You must add behavioural and unit tests to ensure the output matches expectations for various workspace and configuration scenarios.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*`

**Instructions:**
For any new feature or change to an existing feature, both behavioural *and* unit tests are required.

</details>
</issue_to_address>

### Comment 6
<location> `docs/usage-guide.md:127` </location>
<code_context>
-count, and returns successfully. Publication planning and execution will arrive
-in later phases of the roadmap.
+`publish` now produces a publication plan for the workspace. The command reads
+`publish.exclude` from `lading.toml`, honours any crate manifests that declare
+`publish = false`, and prints a structured summary listing the crates that will
+be published. Additional sections document crates skipped by manifest flags or
</code_context>

<issue_to_address>
**suggestion (review_instructions):** The word 'honours' uses en-GB spelling; en-oxendic spelling is 'honors'.

Please update 'honours' to 'honors' to match en-oxendic spelling conventions.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*.md`

**Instructions:**
Use en-oxendic (-ize / -yse / -our) spelling and grammar.

</details>
</issue_to_address>

### Comment 7
<location> `tests/bdd/steps/fixtures.py:402` </location>
<code_context>
                publish=False if not publishable else None,

</code_context>

<issue_to_address>
**suggestion (code-quality):** Swap if/else branches of if expression to remove negation ([`swap-if-expression`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/swap-if-expression))

```suggestion
                publish=None if publishable else False,
```

<br/><details><summary>Explanation</summary>Negated conditions are more difficult to read than positive ones, so it is best
to avoid them where we can. By swapping the `if` and `else` conditions around we
can invert the condition and make it positive.
</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tests/bdd/steps/test_publish_steps.py
Comment thread tests/bdd/steps/test_publish_steps.py
Comment thread tests/bdd/steps/test_bump_steps.py
Comment thread docs/usage-guide.md
Comment thread tests/bdd/steps/fixtures.py

@SourceryAI SourceryAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and found some issues that need to be addressed.

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `Makefile:76` </location>
<code_context>
 nixie: $(NIXIE) ## Validate Mermaid diagrams
-	find . -type f -name '*.md' \
-	  -not -path './.venv/*' -print0 | xargs -0 $(NIXIE)
+	nixie --no-sandbox

 test: build uv pytest ## Run tests
</code_context>

<issue_to_address>
**🚨 question (security):** Switching to nixie --no-sandbox may reduce validation isolation.

Disabling sandboxing may expose the validation process to security risks if files are untrusted. Confirm that this is necessary and that all inputs are safe.
</issue_to_address>

### Comment 2
<location> `tests/bdd/steps/test_publish_steps.py:39-41` </location>
<code_context>
+    assert f"- {crate_name} @ 0.1.0" in lines
+
+
+def _publish_plan_lines(cli_run: dict[str, typ.Any]) -> list[str]:
+    """Return trimmed publish plan output lines for ``cli_run``."""
+    return [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding a BDD step for the case where no crates are publishable.

Please add a BDD scenario to verify the CLI output when no crates are publishable, ensuring this edge case is handled clearly.

Suggested implementation:

```python
def then_publish_prints_plan(cli_run: dict[str, typ.Any], crate_name: str) -> None:
    """Assert that the publish command emits a publication plan summary."""
    assert cli_run["returncode"] == 0
    workspace = cli_run["workspace"]
    lines = [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
    assert lines[0] == f"Publish plan for {workspace}"
    assert "Strip patch strategy: all" in lines[1]
    assert f"- {crate_name} @ 0.1.0" in lines


@then(parsers.parse('no crates are publishable'))
def then_publish_prints_no_crates_publishable(cli_run: dict[str, typ.Any]) -> None:
    """Assert that the publish command emits a message when no crates are publishable."""
    assert cli_run["returncode"] == 0
    lines = [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
    # Adjust the expected message to match your CLI's actual output
    assert any(
        "No crates are publishable" in line or "No crates to publish" in line
        for line in lines
    ), f"Expected a message indicating no crates are publishable, got: {lines}"

```

You will need to add a corresponding BDD scenario in your feature file (e.g., `publish.feature`) that uses the step: `Then no crates are publishable`.
Make sure your CLI emits a clear message like "No crates are publishable" or "No crates to publish" when this edge case occurs.
Adjust the expected message in the assertion if your CLI uses different wording.
</issue_to_address>

### Comment 3
<location> `lading/commands/publish.py:115` </location>
<code_context>
+    return [header, *formatted_items]
+
+
+def _format_plan(
+    plan: PublishPlan, *, strip_patches: config_module.StripPatchesSetting
+) -> str:
</code_context>

<issue_to_address>
**issue (complexity):** Consider inlining section formatting in _format_plan and removing helper indirection in run for improved clarity.

```suggestion
# Drop `_format_section` and inline per‐section formatting in `_format_plan`
# (also removes the generic `T` and `empty_lines` hack)

-def _format_plan(
-    plan: PublishPlan, *, strip_patches: config_module.StripPatchesSetting
-) -> str:
-    lines = [
-        f"Publish plan for {plan.workspace_root}",
-        f"Strip patch strategy: {strip_patches}",
-    ]
-
-    lines.extend(
-        _format_section(
-            plan.publishable,
-            header=f"Crates to publish ({len(plan.publishable)}):",
-            item_formatter=lambda crate: f"- {crate.name} @ {crate.version}",
-            empty_lines=("Crates to publish: none",),
-        )
-    )
-    # … other sections …
-    return "\n".join(lines)
+def _format_plan(
+    plan: PublishPlan,
+    *,
+    strip_patches: config_module.StripPatchesSetting
+) -> str:
+    lines: list[str] = [
+        f"Publish plan for {plan.workspace_root}",
+        f"Strip patch strategy: {strip_patches}",
+    ]
+
+    # Crates to publish
+    if plan.publishable:
+        lines.append(f"Crates to publish ({len(plan.publishable)}):")
+        lines.extend(f"- {c.name} @ {c.version}" for c in plan.publishable)
+    else:
+        lines.append("Crates to publish: none")
+
+    # Skipped (publish = false)
+    if plan.skipped_manifest:
+        lines.append("Skipped (publish = false):")
+        lines.extend(f"- {c.name}" for c in plan.skipped_manifest)
+
+    # Skipped via configuration
+    if plan.skipped_configuration:
+        lines.append("Skipped via publish.exclude:")
+        lines.extend(f"- {c.name}" for c in plan.skipped_configuration)
+
+    # Missing exclusions
+    if plan.missing_configuration_exclusions:
+        lines.append("Configured exclusions not found in workspace:")
+        lines.extend(f"- {name}" for name in plan.missing_configuration_exclusions)
+
+    return "\n".join(lines)
```

```suggestion
# Inline the two `_ensure_*` helpers directly in `run` to remove indirection

-def run(
-    workspace_root: Path,
-    configuration: LadingConfig | None = None,
-    workspace: WorkspaceGraph | None = None,
-) -> str:
-    root = normalise_workspace_root(workspace_root)
-    active_configuration = _ensure_configuration(configuration, root)
-    active_workspace = _ensure_workspace(workspace, root)
-    plan = plan_publication(active_workspace, active_configuration, workspace_root=root)
-    return _format_plan(plan, strip_patches=active_configuration.publish.strip_patches)
+def run(
+    workspace_root: Path,
+    configuration: LadingConfig | None = None,
+    workspace: WorkspaceGraph | None = None,
+) -> str:
+    root = normalise_workspace_root(workspace_root)
+
+    # load or fetch configuration
+    if configuration is None:
+        try:
+            configuration = config_module.current_configuration()
+        except config_module.ConfigurationNotLoadedError:
+            configuration = config_module.load_configuration(root)
+
+    # load or reuse workspace
+    if workspace is None:
+        from lading.workspace import load_workspace, WorkspaceModelError
+        try:
+            workspace = load_workspace(root)
+        except FileNotFoundError as e:
+            raise WorkspaceModelError(f"Workspace root not found: {root}") from e
+
+    plan = plan_publication(workspace, configuration, workspace_root=root)
+    return _format_plan(plan, strip_patches=configuration.publish.strip_patches)
```
</issue_to_address>

### Comment 4
<location> `lading/commands/publish.py:36` </location>
<code_context>
+        return tuple(crate.name for crate in self.publishable)
+
+
+def plan_publication(
+    workspace: WorkspaceGraph,
+    configuration: LadingConfig,
</code_context>

<issue_to_address>
**issue (review_instructions):** Add unit and behavioural tests for the new publication planning logic.

The new functions and logic for publication planning (e.g., plan_publication, PublishPlan, _format_plan, etc.) require both unit and behavioural tests to verify correct behaviour and edge cases. No new tests are present in the diff.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*`

**Instructions:**
For any new feature or change to an existing feature, both behavioural *and* unit tests are required.

</details>
</issue_to_address>

### Comment 5
<location> `lading/commands/publish.py:186` </location>
<code_context>
+        raise WorkspaceModelError(message) from exc


 def run(
</code_context>

<issue_to_address>
**issue (review_instructions):** Add tests for the new run function implementation.

The run function was substantially changed to implement publication planning. This new behaviour must be covered by both unit and behavioural tests, but no new tests are present in the diff.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*`

**Instructions:**
For any new feature or change to an existing feature, both behavioural *and* unit tests are required.

</details>
</issue_to_address>

### Comment 6
<location> `docs/usage-guide.md:127` </location>
<code_context>
-count, and returns successfully. Publication planning and execution will arrive
-in later phases of the roadmap.
+`publish` now produces a publication plan for the workspace. The command reads
+`publish.exclude` from `lading.toml`, honours any crate manifests that declare
+`publish = false`, and prints a structured summary listing the crates that will
+be published. Additional sections document crates skipped by manifest flags or
</code_context>

<issue_to_address>
**suggestion (review_instructions):** The word 'honours' uses en-GB spelling; en-oxendic spelling is preferred ('honors').

Please update 'honours' to 'honors' to match the en-oxendic spelling convention specified in the review instructions.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*.md`

**Instructions:**
Use en-oxendic (-ize / -yse / -our) spelling and grammar.

</details>
</issue_to_address>

### Comment 7
<location> `docs/usage-guide.md:148` </location>
<code_context>
+```
+
+When the configuration excludes additional crates, or a manifest sets
+`publish = false`, the plan prints dedicated sections so the operator can see
+why those crates were skipped.
+
</code_context>

<issue_to_address>
**suggestion (review_instructions):** The phrase 'so the operator can see why those crates were skipped' uses 2nd person construction; rephrase to avoid this.

Consider rephrasing to 'so the reasons for skipping crates are visible to the operator.'

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*.md`

**Instructions:**
Avoid 2nd person or 1st person pronouns ("I", "you", "we")

</details>
</issue_to_address>

### Comment 8
<location> `tests/bdd/steps/fixtures.py:402` </location>
<code_context>
                publish=False if not publishable else None,

</code_context>

<issue_to_address>
**suggestion (code-quality):** Swap if/else branches of if expression to remove negation ([`swap-if-expression`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/swap-if-expression))

```suggestion
                publish=None if publishable else False,
```

<br/><details><summary>Explanation</summary>Negated conditions are more difficult to read than positive ones, so it is best
to avoid them where we can. By swapping the `if` and `else` conditions around we
can invert the condition and make it positive.
</details>
</issue_to_address>

Hi @leynos! 👋

Thanks for trying out Sourcery by commenting with @sourcery-ai review! 🚀

Install the sourcery-ai bot to get automatic code reviews on every pull request ✨

Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread Makefile
Comment on lines +39 to +41
def _publish_plan_lines(cli_run: dict[str, typ.Any]) -> list[str]:
"""Return trimmed publish plan output lines for ``cli_run``."""
return [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a BDD step for the case where no crates are publishable.

Please add a BDD scenario to verify the CLI output when no crates are publishable, ensuring this edge case is handled clearly.

Suggested implementation:

def then_publish_prints_plan(cli_run: dict[str, typ.Any], crate_name: str) -> None:
    """Assert that the publish command emits a publication plan summary."""
    assert cli_run["returncode"] == 0
    workspace = cli_run["workspace"]
    lines = [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
    assert lines[0] == f"Publish plan for {workspace}"
    assert "Strip patch strategy: all" in lines[1]
    assert f"- {crate_name} @ 0.1.0" in lines


@then(parsers.parse('no crates are publishable'))
def then_publish_prints_no_crates_publishable(cli_run: dict[str, typ.Any]) -> None:
    """Assert that the publish command emits a message when no crates are publishable."""
    assert cli_run["returncode"] == 0
    lines = [line.strip() for line in cli_run["stdout"].splitlines() if line.strip()]
    # Adjust the expected message to match your CLI's actual output
    assert any(
        "No crates are publishable" in line or "No crates to publish" in line
        for line in lines
    ), f"Expected a message indicating no crates are publishable, got: {lines}"

You will need to add a corresponding BDD scenario in your feature file (e.g., publish.feature) that uses the step: Then no crates are publishable.
Make sure your CLI emits a clear message like "No crates are publishable" or "No crates to publish" when this edge case occurs.
Adjust the expected message in the assertion if your CLI uses different wording.

return [header, *formatted_items]


def _format_plan(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider inlining section formatting in _format_plan and removing helper indirection in run for improved clarity.

Suggested change
def _format_plan(
# Drop `_format_section` and inline per‐section formatting in `_format_plan`
# (also removes the generic `T` and `empty_lines` hack)
-def _format_plan(
- plan: PublishPlan, *, strip_patches: config_module.StripPatchesSetting
-) -> str:
- lines = [
- f"Publish plan for {plan.workspace_root}",
- f"Strip patch strategy: {strip_patches}",
- ]
-
- lines.extend(
- _format_section(
- plan.publishable,
- header=f"Crates to publish ({len(plan.publishable)}):",
- item_formatter=lambda crate: f"- {crate.name} @ {crate.version}",
- empty_lines=("Crates to publish: none",),
- )
- )
- # … other sections …
- return "\n".join(lines)
+def _format_plan(
+ plan: PublishPlan,
+ *,
+ strip_patches: config_module.StripPatchesSetting
+) -> str:
+ lines: list[str] = [
+ f"Publish plan for {plan.workspace_root}",
+ f"Strip patch strategy: {strip_patches}",
+ ]
+
+ # Crates to publish
+ if plan.publishable:
+ lines.append(f"Crates to publish ({len(plan.publishable)}):")
+ lines.extend(f"- {c.name} @ {c.version}" for c in plan.publishable)
+ else:
+ lines.append("Crates to publish: none")
+
+ # Skipped (publish = false)
+ if plan.skipped_manifest:
+ lines.append("Skipped (publish = false):")
+ lines.extend(f"- {c.name}" for c in plan.skipped_manifest)
+
+ # Skipped via configuration
+ if plan.skipped_configuration:
+ lines.append("Skipped via publish.exclude:")
+ lines.extend(f"- {c.name}" for c in plan.skipped_configuration)
+
+ # Missing exclusions
+ if plan.missing_configuration_exclusions:
+ lines.append("Configured exclusions not found in workspace:")
+ lines.extend(f"- {name}" for name in plan.missing_configuration_exclusions)
+
+ return "\n".join(lines)
Suggested change
def _format_plan(
# Inline the two `_ensure_*` helpers directly in `run` to remove indirection
-def run(
- workspace_root: Path,
- configuration: LadingConfig | None = None,
- workspace: WorkspaceGraph | None = None,
-) -> str:
- root = normalise_workspace_root(workspace_root)
- active_configuration = _ensure_configuration(configuration, root)
- active_workspace = _ensure_workspace(workspace, root)
- plan = plan_publication(active_workspace, active_configuration, workspace_root=root)
- return _format_plan(plan, strip_patches=active_configuration.publish.strip_patches)
+def run(
+ workspace_root: Path,
+ configuration: LadingConfig | None = None,
+ workspace: WorkspaceGraph | None = None,
+) -> str:
+ root = normalise_workspace_root(workspace_root)
+
+ # load or fetch configuration
+ if configuration is None:
+ try:
+ configuration = config_module.current_configuration()
+ except config_module.ConfigurationNotLoadedError:
+ configuration = config_module.load_configuration(root)
+
+ # load or reuse workspace
+ if workspace is None:
+ from lading.workspace import load_workspace, WorkspaceModelError
+ try:
+ workspace = load_workspace(root)
+ except FileNotFoundError as e:
+ raise WorkspaceModelError(f"Workspace root not found: {root}") from e
+
+ plan = plan_publication(workspace, configuration, workspace_root=root)
+ return _format_plan(plan, strip_patches=configuration.publish.strip_patches)

Comment thread docs/usage-guide.md
count, and returns successfully. Publication planning and execution will arrive
in later phases of the roadmap.
`publish` now produces a publication plan for the workspace. The command reads
`publish.exclude` from `lading.toml`, honours any crate manifests that declare

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (review_instructions): The word 'honours' uses en-GB spelling; en-oxendic spelling is preferred ('honors').

Please update 'honours' to 'honors' to match the en-oxendic spelling convention specified in the review instructions.

Review instructions:

Path patterns: **/*.md

Instructions:
Use en-oxendic (-ize / -yse / -our) spelling and grammar.

Comment thread docs/usage-guide.md
```

When the configuration excludes additional crates, or a manifest sets
`publish = false`, the plan prints dedicated sections so the operator can see

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (review_instructions): The phrase 'so the operator can see why those crates were skipped' uses 2nd person construction; rephrase to avoid this.

Consider rephrasing to 'so the reasons for skipping crates are visible to the operator.'

Review instructions:

Path patterns: **/*.md

Instructions:
Avoid 2nd person or 1st person pronouns ("I", "you", "we")

_build_package_metadata(
name,
manifest_path,
publish=False if not publishable else None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Swap if/else branches of if expression to remove negation (swap-if-expression)

Suggested change
publish=False if not publishable else None,
publish=None if publishable else False,


ExplanationNegated conditions are more difficult to read than positive ones, so it is best
to avoid them where we can. By swapping the if and else conditions around we
can invert the condition and make it positive.

@leynos

leynos commented Oct 25, 2025

Copy link
Copy Markdown
Owner Author

@sourcery-ai resolve

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants