Skip to content

Stop double-logging external command invocations (#104) - #111

Merged
leynos merged 8 commits into
mainfrom
issue-104-single-command-log
Jun 10, 2026
Merged

Stop double-logging external command invocations (#104)#111
leynos merged 8 commits into
mainfrom
issue-104-single-command-log

Conversation

@lodyai

@lodyai lodyai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #104

  • Remove _log_subprocess_spawn from lading/runtime/subprocess_runner.py; the same command was already rendered and logged at INFO by subprocess_runner via log_command_invocation.
  • Keep the INFO-level record (operationally visible, includes cwd) and the separate DEBUG record for redacted environment overrides, which carries information the INFO line does not.
  • Add a comment at the spawn site explaining where the single invocation log lives.

Testing

  • New tests/unit/test_subprocess_runner_logging.py pins that one command produces exactly one invocation log record, at INFO, with and without a working directory.
  • make check-fmt, make lint, make typecheck, and make test (557 passed) all green.

🤖 Generated with Claude Code

Summary by Sourcery

Ensure external command invocations are logged only once while preserving useful context in logs and adding regression coverage.

Bug Fixes:

  • Eliminate duplicate logging of external command invocations at different levels for the same subprocess run.

Enhancements:

  • Clarify logging behavior around subprocess invocation by documenting that the command line is logged once at INFO and environment overrides at DEBUG.

Tests:

  • Add regression tests to assert that each external command produces exactly one invocation log record at INFO, including when a working directory is specified.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5b75b420-136f-4a49-a8a7-959c1359a71e

📥 Commits

Reviewing files that changed from the base of the PR and between e483c41 and ce79449.

📒 Files selected for processing (1)
  • lading/runtime/subprocess_runner.py
💤 Files with no reviewable changes (1)
  • lading/runtime/subprocess_runner.py

This pull request closes #104 by removing duplicate logging of external command invocations: the DEBUG-level _log_subprocess_spawn helper in lading/runtime/subprocess_runner.py was removed so the single INFO-level invocation record (emitted via log_command_invocation and including cwd when present) is the authoritative log line. DEBUG logging is retained only for redacted environment diffs when overrides exist. A clarifying in-code comment was added at the subprocess spawn site.

Key changes

  • lading/runtime/subprocess_runner.py

    • Remove _log_subprocess_spawn and the now-unused format_command import.
    • Keep INFO-level invocation logging (includes cwd) and a separate DEBUG log that only emits when environment overrides are present.
    • Added comment at spawn site pointing to the single invocation log emitter.
  • Tests

    • tests/unit/test_subprocess_runner_logging.py: add regression tests asserting exactly one INFO-level "Running external command" record (one test with default cwd; one with an explicit cwd). Tests filter for the INFO invocation line, assert absence of the old DEBUG "Spawning subprocess:" message, include descriptive assertion messages, and use syrupy snapshots with cwd redacted to "" for deterministic assertions.
    • tests/unit/publish/test_command_logging.py: refactor test_cmd_mox_passthrough_streams_output to accept a pytest.FixtureRequest and fetch fixtures via request.getfixturevalue, then assert the passthrough path emits exactly one INFO invocation record.
  • lading/testing/cmd_mox_runner.py

    • Add module logger and emit an INFO-level invocation log for cmd-mox passthrough execution so externally-invoked commands are logged consistently.
  • Documentation

    • docs/developers-guide.md: new subsection describing the subprocess_runner logging contract (single INFO invocation record; optional DEBUG env-diff).
    • docs/roadmap.md: note completion of the duplicate-logging task and reference the regression tests.
  • Snapshots

    • tests/unit/snapshots/test_subprocess_runner_logging.ambr: include expected outputs with tmpdir redaction.

Notes and provenance

  • Regression coverage pins the contract: exactly one INFO invocation record and no "Spawning subprocess:" DEBUG record.
  • CI: make check-fmt, lint, typecheck and the full test suite passed (557 tests).
  • PR content and some summaries/patches were generated with AI tooling (as noted in the PR description).

No new execplan documents were added.

Walkthrough

Remove the DEBUG-level subprocess spawn log and unused formatter; rely on a single INFO-level "Running external command" invocation log. Add explicit passthrough invocation logging for cmd-mox, introduce regression tests that assert exactly one invocation record and absence of the old DEBUG spawn line, and update docs and roadmap.

Changes

Logging Deduplication and Test Coverage

Layer / File(s) Summary
Subprocess spawn logging simplification
lading/runtime/subprocess_runner.py
Remove the private _log_subprocess_spawn helper and the format_command import; adjust invoke_via_subprocess to no longer emit the redundant spawn log while keeping environment-override DEBUG logging.
cmd-mox passthrough invocation logging
lading/testing/cmd_mox_runner.py
Add logging import, module logger _LOGGER, and call log_command_invocation(_LOGGER, passthrough_command, cwd) before invoking passthrough subprocesses so these executions produce the INFO invocation record.
Invocation logging contract tests
tests/unit/test_subprocess_runner_logging.py, tests/unit/__snapshots__/test_subprocess_runner_logging.ambr, tests/unit/publish/test_command_logging.py
Add helpers _invocation_records and _assert_no_spawn_record and two tests asserting exactly one INFO-level "Running external command" record per invocation, cwd rendering with redaction, absence of the DEBUG "Spawning subprocess:" record; update existing passthrough test assertions and add snapshots.
Developer guide and roadmap
docs/developers-guide.md, docs/roadmap.md
Document the logging contract (one INFO invocation record per external command, optional DEBUG environment-diff) and mark the roadmap task completed.

Suggested labels

Issue

Sing once, not twice, each spawned command,
Let INFO keep time and DEBUG trim the cloak;
Tests stand sentinel to guard the single band,
Snapshots record the line and seal the yoke.

🚥 Pre-merge checks | ✅ 19 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Testing (Property / Proof) ⚠️ Warning PR enforces invariant across unbounded inputs (command, cwd, environment). Only two deterministic scenarios tested; property-based testing recommended for comprehensive coverage. Add property-based tests using Hypothesis with varied commands, working directories, and environment variables to verify the single-log invariant across all inputs.
✅ Passed checks (19 passed)
Check name Status Explanation
Title check ✅ Passed The title directly references the issue (#104) being closed and clearly describes the main change: eliminating duplicate logging of external command invocations.
Description check ✅ Passed The description is directly related to the changeset, explaining what was removed (the duplicate logging mechanism), what was preserved (INFO-level records and DEBUG environment logs), and detailing the regression tests added.
Linked Issues check ✅ Passed All coding requirements from #104 are met: the duplicate spawn logging is eliminated, the INFO invocation log is preserved with context, a regression test asserts exactly one invocation record at INFO with and without cwd, and the test uses caplog as specified.
Out of Scope Changes check ✅ Passed All changes align with issue #104 objectives: removing duplicate logging, preserving single invocation logs, adding regression tests, and documenting the logging contract. The cmd_mox_runner.py update ensures logging parity for passthrough commands.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Testing (Overall) ✅ Passed Tests run actual commands, verify output and exit codes, assert exactly one INFO-level invocation record, and explicitly assert "Spawning subprocess:" is absent to prevent regression.
User-Facing Documentation ✅ Passed Logging output reduction is an internal quality improvement, not new user-facing functionality requiring adoption. Documented correctly in developers-guide; users-guide updates unnecessary.
Developer Documentation ✅ Passed Documentation complete: developers-guide documents logging contract; roadmap task marked completed; no multi-language docs to sync; code comments explain decisions.
Module-Level Documentation ✅ Passed All Python modules carry docstrings explaining purpose, utility, function and relationships. The newly added test_subprocess_runner_logging.py includes comprehensive module documentation.
Testing (Unit And Behavioural) ✅ Passed Two behavioural tests exercise real subprocess_runner and verify exactly one INFO-level invocation record, with regression assertions against old "Spawning subprocess:" record.
Testing (Compile-Time / Ui) ✅ Passed Snapshot tests encode meaningful behavioural expectations with explicit cwd redaction, focused snapshots, stable string serialisation, and small semantic assertions around each snapshot assertion.
Unit Architecture ✅ Passed Logging side-effects are explicit via injected _LOGGER parameters. Tests verify boundaries through caplog fixtures. Responsibilities remain cohesive and visible.
Domain Architecture ✅ Passed Changes maintain clear separation of concerns: logging isolated to infrastructure layers; domain modules depend only on CommandRunner protocol; no business logic leaks into infrastructure.
Observability ✅ Passed Maintains observability: single INFO log includes command and cwd, redacted DEBUG environment log when present, regression tests prevent reintroduction, full logging contract documented.
Security And Privacy ✅ Passed Logging properly implements security: safe shlex.join for commands, environment redacted at DEBUG, no secrets/credentials exposed in logs or snapshots, shell=False prevents injection.
Performance And Resource Use ✅ Passed Removes duplicate logging work. No unbounded allocations, repeated I/O or hot-path blocking. Test helpers iterate small log collections. cmd_mox logging called once per invocation.
Concurrency And State ✅ Passed PR introduces no concurrency concerns: changes are logging-only, pre-existing threading with isolated buffer ownership remains unchanged, no shared state or synchronisation primitives added.
Architectural Complexity And Maintainability ✅ Passed PR reduces complexity by eliminating duplicate logging and consolidating on pre-existing log_command_invocation utility. No new abstractions introduced. Test helpers are minimal regression fixtures.
Rust Compiler Lint Integrity ✅ Passed No Rust code changes present in PR; custom check for Rust compiler lint integrity is not applicable to this Python-only PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📋 Issue Planner

Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).

View plan for ticket: #104

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-104-single-command-log

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

@sourcery-ai

sourcery-ai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR removes a redundant DEBUG-level subprocess invocation log in favor of the existing INFO-level log from subprocess_runner, documents the logging behavior at the spawn site, and adds regression tests to ensure each external command is logged exactly once (with cwd when provided).

Sequence diagram for updated subprocess invocation logging

sequenceDiagram
    actor Caller
    participant invoke_via_subprocess
    participant log_command_invocation
    participant _log_subprocess_environment
    participant _LOGGER

    Caller->>invoke_via_subprocess: invoke_via_subprocess(program, args, context)
    activate invoke_via_subprocess

    invoke_via_subprocess->>log_command_invocation: log_command_invocation(command, context.cwd)
    activate log_command_invocation
    log_command_invocation->>_LOGGER: info(rendered_command_with_cwd)
    deactivate log_command_invocation

    invoke_via_subprocess->>_log_subprocess_environment: _log_subprocess_environment(context.env)
    activate _log_subprocess_environment
    _log_subprocess_environment->>_LOGGER: debug(redacted_env_overrides)
    deactivate _log_subprocess_environment

    invoke_via_subprocess->>invoke_via_subprocess: _spawn_process(program, command, context, normalised_env)

    deactivate invoke_via_subprocess
Loading

File-Level Changes

Change Details Files
Stop double-logging subprocess command invocations by removing the local spawn logger and relying solely on the shared invocation logger.
  • Remove the helper that logged subprocess spawn commands at DEBUG level
  • Stop importing and using the command-formatting helper tied to the removed logger
  • Keep the environment logging helper to log redacted environment overrides at DEBUG level
lading/runtime/subprocess_runner.py
Document where the single invocation log is produced to clarify logging responsibilities.
  • Add an inline comment at the subprocess spawn site explaining that the command line is logged once at INFO by subprocess_runner and that only environment overrides are logged additionally at DEBUG
lading/runtime/subprocess_runner.py
Add regression tests to enforce the single-log-per-command contract, including cwd handling.
  • Introduce a new test module focused on subprocess runner logging behavior
  • Capture log records during subprocess_runner invocations and filter for command-line messages
  • Assert that each command produces exactly one INFO-level invocation record
  • Verify that the single invocation log line includes the working directory when cwd is specified
tests/unit/test_subprocess_runner_logging.py

Assessment against linked issues

Issue Objective Addressed Explanation
#104 Ensure each external command invocation is logged exactly once (not twice) by choosing a single logging site/level for the rendered command.
#104 Add a regression test that asserts a single invocation log record is emitted at the chosen level, with the rendered command (including cwd when provided).

Possibly linked issues


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

codescene-delta-analysis[bot]

This comment was marked as outdated.

@leynos
leynos marked this pull request as ready for review June 10, 2026 00:12
sourcery-ai[bot]

This comment was marked as resolved.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8e6ceae97

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lading/runtime/subprocess_runner.py
codescene-delta-analysis[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot added the Issue label Jun 10, 2026

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/test_subprocess_runner_logging.py`:
- Around line 41-43: The assertion checking that no log record contains
"Spawning subprocess:" in tests/unit/test_subprocess_runner_logging.py should
include a descriptive failure message; update the assert that uses
caplog.records so it reads assert all("Spawning subprocess:" not in
record.getMessage() for record in caplog.records), "Unexpected 'Spawning
subprocess:' log emitted" (or similar) to give clear context on failure and
follow the guideline to use assert …, "message".
- Around line 46-79: Add informative assertion messages to every assert in
test_command_logged_exactly_once and test_command_logged_exactly_once_with_cwd
so failures explain context; update assertions that check exit_code, stdout,
stderr, records length, log level, message contents, cwd inclusion and the
_assert_no_spawn_record call to include a trailing ", '...message...'"
describing what was expected (e.g., "expected exit code 0", "expected stdout to
contain \"hello\"", "expected exactly one invocation record", "expected
invocation at INFO level", "expected message to contain 'Running external
command' / 'echo hello'", "expected cwd to appear in invocation message",
"expected no spawn record"), and similarly annotate any uses of
_invocation_records(caplog) to clarify the expectation when it fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9d3a3c37-b94a-4ef6-8973-070dfa415976

📥 Commits

Reviewing files that changed from the base of the PR and between a07962b and 0c0de64.

📒 Files selected for processing (2)
  • lading/runtime/subprocess_runner.py
  • tests/unit/test_subprocess_runner_logging.py

Comment thread tests/unit/test_subprocess_runner_logging.py Outdated
Comment thread tests/unit/test_subprocess_runner_logging.py Outdated
codescene-delta-analysis[bot]

This comment was marked as outdated.

leynos and others added 3 commits June 10, 2026 02:29
Every external command was logged twice: once at INFO by
subprocess_runner via log_command_invocation, and again at DEBUG by
invoke_via_subprocess via _log_subprocess_spawn, with two different
renderings of the same command line.

Keep the INFO-level record (it is the operationally visible one and
already includes the working directory) and delete the redundant DEBUG
spawn line. The DEBUG record for environment overrides is retained
because it carries information the INFO line does not.

Add regression tests pinning that a command produces exactly one
invocation log record, at INFO, with and without a cwd.

Closes #104
Address review feedback on the single-invocation-log regression test:

- Loosen the INFO message assertion so it checks the stable prefix
  ("Running external command") and the command ("echo hello") rather
  than the full formatted string, keeping the test robust to wording
  or spacing changes.
- Restrict `_invocation_records` to the INFO command line only, and add
  an explicit `_assert_no_spawn_record` check that the removed DEBUG
  "Spawning subprocess:" log is absent, tying the regression directly
  to #104.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cmd-mox passthrough path in `_handle_cmd_mox_passthrough` calls
`invoke_via_subprocess` directly rather than going through
`subprocess_runner`, so it never reached the `log_command_invocation`
call. Once the redundant DEBUG spawn log was removed for #104, these
passthrough external commands emitted no command/cwd invocation record
at all, regressing command observability.

Emit the single INFO invocation record at the passthrough call site via
`log_command_invocation`, mirroring `subprocess_runner`. Routing through
`subprocess_runner` is not viable here because that wrapper does not
accept `stdin_data`, which the passthrough path needs.

Extend the passthrough streaming test to assert exactly one INFO
invocation record is logged for the passthrough command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lodyai
lodyai Bot force-pushed the issue-104-single-command-log branch from 5b76c1b to f5ab030 Compare June 10, 2026 00:31
codescene-delta-analysis[bot]

This comment was marked as outdated.

@leynos

leynos commented Jun 10, 2026

Copy link
Copy Markdown
Owner

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix. Include the file and symbol names indicated in the issue at the head of your response.

Excess Number of Function Arguments

tests/unit/publish/test_command_logging.py: test_cmd_mox_passthrough_streams_output

What lead to degradation?

test_cmd_mox_passthrough_streams_output has 5 arguments, max arguments = 4

Why does this problem occur?

Functions with many arguments indicate either a) low cohesion where the function has too many responsibilities, or b) a missing abstraction that encapsulates those arguments.
The threshold for the Python language is 4 function arguments.

How to fix it?

Start by investigating the responsibilities of the function. Make sure it doesn't do too many things, in which case it should be split into smaller and more cohesive functions. Consider the refactoring INTRODUCE PARAMETER OBJECT to encapsulate arguments that refer to the same logical concept.

Helpful refactoring examples

This code health issue has been solved before in this project. Here are some examples for inspiration:

undefined

# conftest.py (114b752)
 def publish_fixtures(
-    tmp_path: Path,
-    make_crate: typ.Callable[[Path, str, _CrateSpec | None], WorkspaceCrate],
-    make_workspace: typ.Callable[[Path, WorkspaceCrate], WorkspaceGraph],
-    make_config: typ.Callable[..., config_module.LadingConfig],
-    make_dependency: typ.Callable[[str], WorkspaceDependency],
-    publish_options: publish.PublishOptions,
+    request: pytest.FixtureRequest, publish_options: publish.PublishOptions
 ) -> PublishFixtures:
     """Return the composite publish fixtures used across unit suites."""
+    tmp_path: Path = request.getfixturevalue("tmp_path")
+    make_crate = request.getfixturevalue("make_crate")
+    make_workspace = request.getfixturevalue("make_workspace")
+    make_config = request.getfixturevalue("make_config")
+    make_dependency = request.getfixturevalue("make_dependency")
     return PublishFixtures(

@coderabbitai

This comment was marked as resolved.

@leynos

leynos commented Jun 10, 2026

Copy link
Copy Markdown
Owner

@coderabbitai Have the following now been resolved?

Annul any requirements that violate the en-GB-oxendict spelling (-ize / -yse / -our) conventions (for example a request to replace "normalize" with "normalise" or "artefact" with "artifact"), or where the requirement unnecessarily increases cyclomatic complexity.

## Individual Comments

### Comment 1
<location path="tests/unit/test_subprocess_runner_logging.py" line_range="49-52" />
<code_context>
+    ]
+
+
+def test_command_logged_exactly_once(caplog: LogCaptureFixture) -> None:
+    """A command produces a single invocation log record at INFO."""
+    caplog.set_level(logging.DEBUG, logger=_RUNNER_LOGGER)
+
+    exit_code, stdout, stderr = subprocess_runner(("echo", "hello"), echo_stdout=False)
+
+    assert exit_code == 0
+    assert stdout.strip() == "hello"
+    assert stderr == ""
+    records = _invocation_records(caplog)
+    assert len(records) == 1
+    assert records[0].levelno == logging.INFO
+    assert "Running external command: echo hello" in records[0].getMessage()
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Avoid over-specifying the exact log message string to keep the regression test robust

This assertion couples the test to the full log string format, so minor wording or spacing changes will break the test even if we still log one INFO record with the command. Instead, assert on more stable aspects: the INFO level, that the message contains the command (e.g. `"echo hello"`), and optionally a stable prefix fragment like `"Running external command"`, rather than the full formatted string.

```suggestion
    records = _invocation_records(caplog)
    assert len(records) == 1
    assert records[0].levelno == logging.INFO
    message = records[0].getMessage()
    assert "Running external command" in message
    assert "echo hello" in message
```
</issue_to_address>

### Comment 2
<location path="tests/unit/test_subprocess_runner_logging.py" line_range="28-35" />
<code_context>
+_RUNNER_LOGGER = "lading.runtime.subprocess_runner"
+
+
+def _invocation_records(
+    caplog: LogCaptureFixture,
+) -> list[logging.LogRecord]:
+    """Return records that render the external command line."""
+    return [
+        record
+        for record in caplog.records
+        if "Running external command" in record.getMessage()
+        or "Spawning subprocess:" in record.getMessage()
+    ]
</code_context>
<issue_to_address>
**suggestion (testing):** Consider explicitly asserting that the removed DEBUG spawn log is absent

Since `_invocation_records` filters on both "Running external command" and "Spawning subprocess:", the existing `assert len(records) == 1` only indirectly checks that no DEBUG "Spawning subprocess:" log is emitted. To make the regression more explicit and tied to #104, consider either adding a separate assertion that no record message contains "Spawning subprocess:" or removing that substring from the filter and asserting its absence independently.

Suggested implementation:

```python
def _invocation_records(
    caplog: LogCaptureFixture,
) -> list[logging.LogRecord]:
    """Return records that render the external command line."""
    return [
        record
        for record in caplog.records
        if "Running external command" in record.getMessage()
    ]

```

```python
def test_command_logged_exactly_once(caplog: LogCaptureFixture) -> None:
    """A command produces a single invocation log record at INFO."""
    caplog.set_level(logging.DEBUG, logger=_RUNNER_LOGGER)

    exit_code, stdout, stderr = subprocess_runner(("echo", "hello"), echo_stdout=False)

    assert exit_code == 0
    assert stdout.strip() == "hello"

    # Exactly one invocation log record at INFO
    records = _invocation_records(caplog)
    assert len(records) == 1

    # Explicitly assert that the DEBUG spawn log is absent (regression for #104)
    assert all(
        "Spawning subprocess:" not in record.getMessage()
        for record in caplog.records
    )

```

If other tests in this file rely on `_invocation_records` also matching `"Spawning subprocess:"`, they may now fail. Those tests should be reviewed and either:
1. Updated to use `_invocation_records` only for `"Running external command"` logs, or
2. Directly inspect `caplog.records` when they specifically care about `"Spawning subprocess:"` messages.
</issue_to_address>

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the current state of the test file on the PR branch
gh pr view 111 --repo leynos/lading --json headRefName -q '.headRefName'
git fetch origin issue-104-single-command-log 2>/dev/null || true
git show origin/issue-104-single-command-log:tests/unit/test_subprocess_runner_logging.py 2>/dev/null || \
  fd 'test_subprocess_runner_logging.py' --exec cat {}

Length of output: 2878



@leynos Both comments are fully resolved on the current branch.

Comment 1 (test_command_logged_exactly_once — over-specified assertion): the single combined assert "Running external command: echo hello" in … has been replaced with two independent assertions on a stable prefix and the command token:

message = records[0].getMessage()
assert "Running external command" in message
assert "echo hello" in message

Comment 2 (_invocation_records — implicit absence check): "Spawning subprocess:" has been removed from the filter predicate, and a dedicated _assert_no_spawn_record helper now explicitly asserts its absence. The helper is called in both tests, making the regression pin for #104 explicit.

Neither suggestion touches spelling (no en-GB-oxendict violations introduced) nor increases cyclomatic complexity, so no requirements need annulling.

`test_cmd_mox_passthrough_streams_output` took five parameters, four of
which were pytest infrastructure fixtures. Collapse `capsys`, `caplog`,
`monkeypatch`, and `use_real_invoke` into a single `request` parameter
and resolve them via `request.getfixturevalue` at the top of the body,
keeping `cmd_mox` as the only explicit fixture.

The `pytest.FixtureRequest` annotation stays lazy thanks to
`from __future__ import annotations`, so it does not trigger a runtime
lookup of the `TYPE_CHECKING`-only `pytest` import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
codescene-delta-analysis[bot]

This comment was marked as outdated.

Annotate every assertion in the subprocess runner logging regression
tests with a trailing message so failures explain the expected context:
exit code, stdout/stderr contents, invocation record count, log level,
message substrings, cwd inclusion, and the absence of the removed
"Spawning subprocess:" DEBUG record. The spawn-absence message lives in
`_assert_no_spawn_record` itself, since that helper raises the failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
codescene-delta-analysis[bot]

This comment was marked as outdated.

@leynos

leynos commented Jun 10, 2026

Copy link
Copy Markdown
Owner

@coderabbitai Have the following now been resolved?

Annul any requirements that violate the en-GB-oxendict spelling (-ize / -yse / -our) conventions (for example a request to replace "normalize" with "normalise" or "artefact" with "artifact"), or where the requirement unnecessarily increases cyclomatic complexity.

## Individual Comments

### Comment 1
<location path="tests/unit/test_subprocess_runner_logging.py" line_range="49-52" />
<code_context>
+    ]
+
+
+def test_command_logged_exactly_once(caplog: LogCaptureFixture) -> None:
+    """A command produces a single invocation log record at INFO."""
+    caplog.set_level(logging.DEBUG, logger=_RUNNER_LOGGER)
+
+    exit_code, stdout, stderr = subprocess_runner(("echo", "hello"), echo_stdout=False)
+
+    assert exit_code == 0
+    assert stdout.strip() == "hello"
+    assert stderr == ""
+    records = _invocation_records(caplog)
+    assert len(records) == 1
+    assert records[0].levelno == logging.INFO
+    assert "Running external command: echo hello" in records[0].getMessage()
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Avoid over-specifying the exact log message string to keep the regression test robust

This assertion couples the test to the full log string format, so minor wording or spacing changes will break the test even if we still log one INFO record with the command. Instead, assert on more stable aspects: the INFO level, that the message contains the command (e.g. `"echo hello"`), and optionally a stable prefix fragment like `"Running external command"`, rather than the full formatted string.

```suggestion
    records = _invocation_records(caplog)
    assert len(records) == 1
    assert records[0].levelno == logging.INFO
    message = records[0].getMessage()
    assert "Running external command" in message
    assert "echo hello" in message
```
</issue_to_address>

### Comment 2
<location path="tests/unit/test_subprocess_runner_logging.py" line_range="28-35" />
<code_context>
+_RUNNER_LOGGER = "lading.runtime.subprocess_runner"
+
+
+def _invocation_records(
+    caplog: LogCaptureFixture,
+) -> list[logging.LogRecord]:
+    """Return records that render the external command line."""
+    return [
+        record
+        for record in caplog.records
+        if "Running external command" in record.getMessage()
+        or "Spawning subprocess:" in record.getMessage()
+    ]
</code_context>
<issue_to_address>
**suggestion (testing):** Consider explicitly asserting that the removed DEBUG spawn log is absent

Since `_invocation_records` filters on both "Running external command" and "Spawning subprocess:", the existing `assert len(records) == 1` only indirectly checks that no DEBUG "Spawning subprocess:" log is emitted. To make the regression more explicit and tied to #104, consider either adding a separate assertion that no record message contains "Spawning subprocess:" or removing that substring from the filter and asserting its absence independently.

Suggested implementation:

```python
def _invocation_records(
    caplog: LogCaptureFixture,
) -> list[logging.LogRecord]:
    """Return records that render the external command line."""
    return [
        record
        for record in caplog.records
        if "Running external command" in record.getMessage()
    ]

```

```python
def test_command_logged_exactly_once(caplog: LogCaptureFixture) -> None:
    """A command produces a single invocation log record at INFO."""
    caplog.set_level(logging.DEBUG, logger=_RUNNER_LOGGER)

    exit_code, stdout, stderr = subprocess_runner(("echo", "hello"), echo_stdout=False)

    assert exit_code == 0
    assert stdout.strip() == "hello"

    # Exactly one invocation log record at INFO
    records = _invocation_records(caplog)
    assert len(records) == 1

    # Explicitly assert that the DEBUG spawn log is absent (regression for #104)
    assert all(
        "Spawning subprocess:" not in record.getMessage()
        for record in caplog.records
    )

```

If other tests in this file rely on `_invocation_records` also matching `"Spawning subprocess:"`, they may now fail. Those tests should be reviewed and either:
1. Updated to use `_invocation_records` only for `"Running external command"` logs, or
2. Directly inspect `caplog.records` when they specifically care about `"Spawning subprocess:"` messages.
</issue_to_address>

@leynos

leynos commented Jun 10, 2026

Copy link
Copy Markdown
Owner

@coderabbitai Have the following failed checks now been resolved?

If further work is required, please provide an AI agent prompt for the remaining work to be done to address these failures.

Do not treat warnings as optional or aspirational. Where a change is out of scope for this PR, propose a GitHub issue unless one exists already.

❌ Failed checks (2 errors, 3 warnings)

Check name Status Explanation Resolution
Testing (Overall) ❌ Error Tests substantively exercise the logging change (single INFO log, no "Spawning subprocess:" record), but lack explanatory messages on assertions per coding guidelines. Add explanatory messages to all assertions (lines 41-43, 52-60, 74-78) per pattern: assert condition, "message explaining failure".
Unit Architecture ❌ Error All 12 assertions in test_subprocess_runner_logging.py lack explanatory messages, violating the guideline: assert …, "message". Add explanatory messages to all assertions in _assert_no_spawn_record (line 41–43) and both test functions (lines 46–79), as specified in review comments.
Developer Documentation ⚠️ Warning PR addresses logging behaviour change (#104) but does not update docs/developers-guide.md to document the new single-invocation logging contract or check off the roadmap item. Update docs/developers-guide.md to document subprocess_runner's single INFO-level invocation logging; check off #104 completion in docs/roadmap.md.
Testing (Unit And Behavioural) ⚠️ Warning Behavioural tests properly exercise the functional boundary and verify logging behaviour, but all 12 assertions lack required explanatory messages per coding guidelines. Add explanatory messages to all assertions: e.g. assert exit_code == 0, "echo should succeed" and update _assert_no_spawn_record with a message parameter.
Testing (Compile-Time / Ui) ⚠️ Warning Logging output (text-based) tested with substring assertions instead of snapshots. Codebase uses syrupy; snapshots with redaction better encode log format expectations. Use syrupy snapshots with nondeterministic field redaction; the cwd value in test_command_logged_exactly_once_with_cwd should be redacted (e.g. <tmpdir>).

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the current state of the test file on the PR branch
gh pr view 111 --repo leynos/lading --json headRefName -q '.headRefName'

Length of output: 216


🏁 Script executed:

#!/bin/bash
git show origin/issue-104-single-command-log:tests/unit/test_subprocess_runner_logging.py 2>/dev/null || \
  fd 'test_subprocess_runner_logging.py' --exec cat {}

Length of output: 3151



@leynos Both comments are fully resolved on the current branch.

Comment 1 — The single over-specified assertion has been replaced with two independent checks on stable substrings:

message = records[0].getMessage()
assert "Running external command" in message
assert "echo hello" in message

Comment 2_invocation_records now filters solely on "Running external command", and a dedicated _assert_no_spawn_record helper explicitly asserts that no "Spawning subprocess:" record is emitted. The helper is called at the end of both tests, making the regression pin for #104 explicit.

Neither suggestion introduces en-GB-oxendict spelling violations nor increases cyclomatic complexity, so no requirements need annulling.

@coderabbitai

This comment was marked as resolved.

@leynos

leynos commented Jun 10, 2026

Copy link
Copy Markdown
Owner

@coderabbitai syrupy is an approved dependency.

Please suggest a fix for the following concern and provide an AI coding agent prompt for the fix:

Testing (Compile-Time / Ui) ⚠️ Warning Logging output (text-based) tested with substring assertions instead of snapshots. Codebase uses syrupy; snapshots with redaction better encode log format expectations. Use syrupy snapshots with nondeterministic field redaction; the cwd value in test_command_logged_exactly_once_with_cwd should be redacted (e.g. <tmpdir>).

Record the subprocess invocation logging contract introduced by #111 in
developer-facing docs:

- developers-guide: add a "Subprocess invocation logging" sub-section to
  the command runners section, describing the single INFO record (with
  optional cwd suffix) and the separate DEBUG environment-overrides
  record, and pointing at the pinning regression tests.
- roadmap: mark "Stop double-logging external command invocations" as
  complete under Phase 4 → Step 4.1 with outcome and completion criteria.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

This comment was marked as resolved.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@leynos

leynos commented Jun 10, 2026

Copy link
Copy Markdown
Owner

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews resumed.

Replace the substring assertions on the invocation log message with
syrupy snapshot assertions in the subprocess runner logging regression
tests. The cwd test redacts the temporary directory to the literal
``<tmpdir>`` token before comparing so the snapshot stays stable across
runs. Add the corresponding amber snapshot file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
codescene-delta-analysis[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot added Roadmap and removed Issue labels Jun 10, 2026

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lading/runtime/subprocess_runner.py`:
- Around line 193-196: The DEBUG helper _log_subprocess_environment(context.env)
is currently emitting "Spawning subprocess with inherited environment" even when
no env overrides are provided; change the behavior so only override-specific
info is logged: modify either the call site in subprocess_runner or the
implementation of _log_subprocess_environment to first detect whether
context.env is None/empty and return without logging that inherited-environment
message, and only emit DEBUG records when there are actual environment overrides
to report (use the symbols _log_subprocess_environment and context.env to locate
the change).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 506be94d-45c1-4899-83a8-9936ad066623

📥 Commits

Reviewing files that changed from the base of the PR and between 6891e75 and e483c41.

📒 Files selected for processing (7)
  • docs/developers-guide.md
  • docs/roadmap.md
  • lading/runtime/subprocess_runner.py
  • lading/testing/cmd_mox_runner.py
  • tests/unit/__snapshots__/test_subprocess_runner_logging.ambr
  • tests/unit/publish/test_command_logging.py
  • tests/unit/test_subprocess_runner_logging.py

Comment thread lading/runtime/subprocess_runner.py
@leynos

leynos commented Jun 10, 2026

Copy link
Copy Markdown
Owner

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews resumed.

`_log_subprocess_environment` emitted a DEBUG "Spawning subprocess with
inherited environment" record on every invocation without overrides,
adding noise that carried no information beyond the INFO invocation line.
Return early when no overrides are present so a DEBUG record is only
emitted when there are actual environment overrides to report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai coderabbitai Bot added Issue and removed Roadmap labels Jun 10, 2026
@leynos

leynos commented Jun 10, 2026

Copy link
Copy Markdown
Owner

@coderabbitai this one just feels like a tryhard. What are your thoughts?

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Testing (Property / Proof) ⚠️ Warning PR enforces invariant across unbounded inputs (command, cwd, environment). Only two deterministic scenarios tested; property-based testing recommended for comprehensive coverage. Add property-based tests using Hypothesis with varied commands, working directories, and environment variables to verify the single-log invariant across all inputs.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Tip

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

@leynos — your instinct is right; dismiss it.

The "emit exactly one INFO log" invariant is a structural property of the code, not a computational one. Whether the count equals one is determined entirely by static control flow — a single call to log_command_invocation, with _log_subprocess_spawn removed. It cannot vary based on which command string, working directory, or environment dictionary is passed in.

Property-based testing with Hypothesis earns its keep when correctness is a non-trivial function of inputs — parsers, serialisers, arithmetic, protocol encoding. Here, Hypothesis would simply spin up real subprocesses for hundreds of arbitrary input combinations and count to one every time. That is expensive, potentially unsafe (arbitrary shell commands), and adds no meaningful coverage beyond what the two existing snapshot tests already provide.

The two tests already cover both code paths that affect the log message: without cwd and with cwd. That is sufficient.

Reject this warning.

@leynos
leynos merged commit 2b67bc2 into main Jun 10, 2026
5 checks passed
@leynos
leynos deleted the issue-104-single-command-log branch June 10, 2026 12:21
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.

Stop double-logging every external command invocation

1 participant