docwrap 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {docwrap-0.1.0 → docwrap-0.2.0}/.gitignore +1 -0
  2. {docwrap-0.1.0 → docwrap-0.2.0}/PKG-INFO +20 -20
  3. {docwrap-0.1.0 → docwrap-0.2.0}/README.md +11 -11
  4. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/tests/test_agy_adapter.py +1 -1
  5. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/.claude-plugin/plugin.json +1 -1
  6. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/_docwrap/_adapter.py +30 -5
  7. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/tests/test_claude_adapter.py +91 -32
  8. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/.codex-plugin/plugin.json +1 -1
  9. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/_docwrap/_adapter.py +30 -5
  10. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/tests/test_codex_adapter.py +36 -1
  11. {docwrap-0.1.0 → docwrap-0.2.0}/pyproject.toml +23 -20
  12. docwrap-0.2.0/src/docwrap/__init__.py +91 -0
  13. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/__main__.py +1 -1
  14. docwrap-0.2.0/src/docwrap/_arguments.py +20 -0
  15. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_cli.py +221 -249
  16. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_color.py +17 -0
  17. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_config.py +35 -76
  18. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_convert.py +0 -2
  19. docwrap-0.2.0/src/docwrap/_core/__init__.py +26 -0
  20. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_discover.py +113 -94
  21. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_incremental.py +16 -12
  22. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_normalize.py +1 -1
  23. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_parse.py +1 -1
  24. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_process.py +2 -2
  25. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_wrap.py +34 -59
  26. docwrap-0.2.0/src/docwrap/_display/__init__.py +9 -0
  27. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_diff.py +4 -2
  28. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_json.py +9 -26
  29. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_print.py +2 -2
  30. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_recommend.py +9 -22
  31. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_skips.py +2 -2
  32. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_stats.py +11 -10
  33. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_summary.py +45 -32
  34. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_downstream.py +28 -51
  35. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_files.py +89 -77
  36. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_git.py +103 -133
  37. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_git_utils.py +57 -0
  38. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_hook.py +139 -317
  39. docwrap-0.2.0/src/docwrap/_hook_front.py +405 -0
  40. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_hosts/__init__.py +2 -2
  41. docwrap-0.1.0/src/docwrap/__init__.py → docwrap-0.2.0/src/docwrap/_library.py +1 -7
  42. docwrap-0.2.0/src/docwrap/_main.py +45 -0
  43. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_stats.py +45 -40
  44. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_styles/__init__.py +12 -9
  45. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_styles/_google.py +18 -6
  46. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_styles/_numpy.py +15 -3
  47. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_styles/_sphinx.py +17 -15
  48. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_types.py +39 -53
  49. docwrap-0.2.0/src/docwrap/_vendor/__init__.py +1 -0
  50. docwrap-0.2.0/src/docwrap/_vendor/hookshell/__init__.py +7 -0
  51. {docwrap-0.1.0/src/docwrap → docwrap-0.2.0/src/docwrap/_vendor/hookshell}/_classify.py +30 -12
  52. docwrap-0.2.0/src/docwrap/_vendor/hookshell/_detector.py +201 -0
  53. {docwrap-0.1.0/src/docwrap → docwrap-0.2.0/src/docwrap/_vendor/hookshell}/_grammar.py +50 -36
  54. {docwrap-0.1.0/src/docwrap → docwrap-0.2.0/src/docwrap/_vendor/hookshell}/_shellparse.py +93 -44
  55. docwrap-0.2.0/src/docwrap/_vendor/hookshell/manifest.json +18 -0
  56. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/hooks/__init__.py +1 -1
  57. docwrap-0.1.0/src/docwrap/_core/__init__.py +0 -27
  58. docwrap-0.1.0/src/docwrap/_detector.py +0 -166
  59. docwrap-0.1.0/src/docwrap/_display/__init__.py +0 -17
  60. {docwrap-0.1.0 → docwrap-0.2.0}/LICENSE +0 -0
  61. {docwrap-0.1.0 → docwrap-0.2.0}/_build.py +0 -0
  62. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/_docwrap/__init__.py +0 -0
  63. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/_docwrap/_adapter.py +0 -0
  64. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/adapter.toml +0 -0
  65. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/hooks.json +0 -0
  66. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/agy/plugin.json +0 -0
  67. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/_docwrap/__init__.py +0 -0
  68. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/adapter.toml +0 -0
  69. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/claude/hooks/hooks.json +0 -0
  70. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/_docwrap/__init__.py +0 -0
  71. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/adapter.toml +0 -0
  72. {docwrap-0.1.0 → docwrap-0.2.0}/plugins/codex/hooks/hooks.json +0 -0
  73. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_canonical.py +0 -0
  74. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_codeblock.py +0 -0
  75. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_core/_escape.py +0 -0
  76. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_detect.py +0 -0
  77. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_display/_text.py +0 -0
  78. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_exceptions.py +0 -0
  79. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/_validate.py +0 -0
  80. {docwrap-0.1.0/src/docwrap → docwrap-0.2.0/src/docwrap/_vendor/hookshell}/_shell.py +0 -0
  81. {docwrap-0.1.0 → docwrap-0.2.0}/src/docwrap/py.typed +0 -0
@@ -24,6 +24,7 @@ ENV/
24
24
  *.spec
25
25
 
26
26
  # Unit test / coverage reports
27
+ .hypothesis/
27
28
  htmlcov/
28
29
  .tox/
29
30
  .nox/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docwrap
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Wrap Python docstrings with support for Google, NumPy, and Sphinx styles
5
5
  Project-URL: Documentation, https://github.com/dactylo/docwrap/tree/main/docs
6
6
  Project-URL: Homepage, https://github.com/dactylo/docwrap
@@ -19,24 +19,22 @@ Description-Content-Type: text/markdown
19
19
 
20
20
  # docwrap
21
21
 
22
- [![CI](https://github.com/dactylo/docwrap/actions/workflows/ci.yml/badge.svg)](https://github.com/dactylo/docwrap/actions/workflows/ci.yml)
23
-
24
- Docwrap applies configurable formatting rules to Python docstrings. It makes wrapping and layout
22
+ docwrap applies configurable formatting rules to Python docstrings. It makes wrapping and layout
25
23
  deterministic, with controls for line length, paragraph reflow, and summary placement. It supports
26
24
  Google, NumPy, and Sphinx/reST conventions.
27
25
 
28
26
  Run `docwrap` to see which docstrings would change across your project:
29
27
 
30
- ![docwrap summary of six Python files, with 10 of 32 docstrings requiring wrapping](images/docwrap-summary.png)
28
+ ![docwrap summary of six Python files, with 10 of 32 docstrings requiring wrapping](https://raw.githubusercontent.com/dactylo/docwrap/v0.2.0/images/docwrap-summary.png)
31
29
 
32
30
  Then use `docwrap --preview` to compare the original and proposed formatting. For example, using
33
31
  the default 79-column limit:
34
32
 
35
- ![docwrap preview showing prose and an argument description before and after reflow](images/docwrap-preview.png)
33
+ ![docwrap preview showing prose and an argument description before and after reflow](https://raw.githubusercontent.com/dactylo/docwrap/v0.2.0/images/docwrap-preview.png)
36
34
 
37
35
  ## Installation
38
36
 
39
- Docwrap requires CPython 3.13 or later. From a checkout, install the command with
37
+ docwrap requires CPython 3.13 or later. From a checkout, install the command with
40
38
  [uv](https://docs.astral.sh/uv/):
41
39
 
42
40
  ```bash
@@ -78,17 +76,17 @@ line-length = 88
78
76
  ```
79
77
 
80
78
  Configuration is discovered from each file's `pyproject.toml`. The
81
- [wrapping policy guide](docs/design.md#choosing-a-wrapping-policy) explains the tradeoffs between
82
- reflowing prose and preserving authored layout. See the [CLI reference](docs/cli.md) for output
79
+ [wrapping policy guide](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/design.md#choosing-a-wrapping-policy) explains the tradeoffs between
80
+ reflowing prose and preserving authored layout. See the [CLI reference](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/cli.md) for output
83
81
  modes, configuration precedence, and advanced options.
84
82
 
85
83
  ## Integrations and library use
86
84
 
87
85
  A local [pre-commit](https://pre-commit.com/) hook can run docwrap when you commit. See the
88
- [pre-commit setup](docs/hook.md#pre-commit) for a writing hook and its report-only variant. Agent
86
+ [pre-commit setup](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/hook.md#pre-commit) for a writing hook and its report-only variant. Agent
89
87
  hooks for Claude Code, Codex, and Antigravity can run before commits or after edits. Their
90
88
  installation, host settings, commit behavior, and composition limits are in the
91
- [agent hook guide](docs/hook.md).
89
+ [agent hook guide](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/hook.md).
92
90
 
93
91
  For Python callers, `wrap_source()` returns rewritten source, `check()` reports whether it would
94
92
  change, and `wrap_source_report()` supplies structured results:
@@ -101,21 +99,23 @@ result = docwrap.check(source)
101
99
  wrapped = docwrap.wrap_source(source)
102
100
  ```
103
101
 
104
- See the [library reference](docs/library.md) for file operations, configuration, result types, and
102
+ See the [library reference](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/library.md) for file operations, configuration, result types, and
105
103
  safety skips.
106
104
 
107
105
  ## Documentation
108
106
 
109
- - [CLI reference](docs/cli.md): commands, output, configuration, and exit codes.
110
- - [Agent hook guide](docs/hook.md): setup, commit handling, and host responses.
111
- - [Incremental mode](docs/incremental.md): wrap only changed files or definitions.
112
- - [Library reference](docs/library.md): supported Python API and result contracts.
113
- - [Concepts and safety](docs/design.md): styles, wrapping choices, and preservation rules.
114
- - [Platform compatibility](docs/platform.md): supported environments and file behavior.
107
+ | If you want to... | Read |
108
+ | ------------------------------------------------------ | ------------------------------------------ |
109
+ | Look up commands, output, configuration, or exit codes | [CLI reference](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/cli.md) |
110
+ | Set up agent hooks, commit handling, or host responses | [Agent hook guide](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/hook.md) |
111
+ | Wrap only changed files or definitions | [Incremental mode](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/incremental.md) |
112
+ | Call docwrap from Python | [Library reference](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/library.md) |
113
+ | Choose a style and understand what docwrap preserves | [Concepts and safety](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/design.md) |
114
+ | Check supported environments and file behavior | [Platform compatibility](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/platform.md) |
115
115
 
116
116
  ## Development
117
117
 
118
- See the [development guide](docs/development.md) for pinned CI dependencies and lock updates.
118
+ See the [development guide](https://github.com/dactylo/docwrap/blob/v0.2.0/docs/development.md) for pinned CI dependencies and lock updates.
119
119
 
120
120
  From the checkout, run `make check` for repository checks. Run `make format` to apply the
121
121
  repository's formatters. The Makefile also provides `make install`, `make reinstall`, and
@@ -123,4 +123,4 @@ repository's formatters. The Makefile also provides `make install`, `make reinst
123
123
 
124
124
  ## License
125
125
 
126
- Docwrap is available under the [MIT License](LICENSE).
126
+ docwrap is available under the [MIT License](https://github.com/dactylo/docwrap/blob/v0.2.0/LICENSE).
@@ -1,8 +1,6 @@
1
1
  # docwrap
2
2
 
3
- [![CI](https://github.com/dactylo/docwrap/actions/workflows/ci.yml/badge.svg)](https://github.com/dactylo/docwrap/actions/workflows/ci.yml)
4
-
5
- Docwrap applies configurable formatting rules to Python docstrings. It makes wrapping and layout
3
+ docwrap applies configurable formatting rules to Python docstrings. It makes wrapping and layout
6
4
  deterministic, with controls for line length, paragraph reflow, and summary placement. It supports
7
5
  Google, NumPy, and Sphinx/reST conventions.
8
6
 
@@ -17,7 +15,7 @@ the default 79-column limit:
17
15
 
18
16
  ## Installation
19
17
 
20
- Docwrap requires CPython 3.13 or later. From a checkout, install the command with
18
+ docwrap requires CPython 3.13 or later. From a checkout, install the command with
21
19
  [uv](https://docs.astral.sh/uv/):
22
20
 
23
21
  ```bash
@@ -87,12 +85,14 @@ safety skips.
87
85
 
88
86
  ## Documentation
89
87
 
90
- - [CLI reference](docs/cli.md): commands, output, configuration, and exit codes.
91
- - [Agent hook guide](docs/hook.md): setup, commit handling, and host responses.
92
- - [Incremental mode](docs/incremental.md): wrap only changed files or definitions.
93
- - [Library reference](docs/library.md): supported Python API and result contracts.
94
- - [Concepts and safety](docs/design.md): styles, wrapping choices, and preservation rules.
95
- - [Platform compatibility](docs/platform.md): supported environments and file behavior.
88
+ | If you want to... | Read |
89
+ | ------------------------------------------------------ | ------------------------------------------ |
90
+ | Look up commands, output, configuration, or exit codes | [CLI reference](docs/cli.md) |
91
+ | Set up agent hooks, commit handling, or host responses | [Agent hook guide](docs/hook.md) |
92
+ | Wrap only changed files or definitions | [Incremental mode](docs/incremental.md) |
93
+ | Call docwrap from Python | [Library reference](docs/library.md) |
94
+ | Choose a style and understand what docwrap preserves | [Concepts and safety](docs/design.md) |
95
+ | Check supported environments and file behavior | [Platform compatibility](docs/platform.md) |
96
96
 
97
97
  ## Development
98
98
 
@@ -104,4 +104,4 @@ repository's formatters. The Makefile also provides `make install`, `make reinst
104
104
 
105
105
  ## License
106
106
 
107
- Docwrap is available under the [MIT License](LICENSE).
107
+ docwrap is available under the [MIT License](LICENSE).
@@ -8,7 +8,7 @@ from typing import Literal
8
8
 
9
9
  import pytest
10
10
 
11
- from docwrap._cli import _build_hook_parser
11
+ from docwrap._hook_front import _build_hook_parser
12
12
  from docwrap._hosts import load_adapter
13
13
  from docwrap.hooks import API_VERSION, HookAdapter, HookResult, HostInput, HostResponse
14
14
  from plugins.agy._docwrap._adapter import AntigravityAdapter
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docwrap",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Install the docwrap commit-time docstring wrapping hook for Claude Code.",
5
5
  "author": {
6
6
  "name": "docwrap contributors"
@@ -36,11 +36,16 @@ def _permission_deny(reason: str, stderr_prefix: str = '') -> HostResponse:
36
36
 
37
37
 
38
38
  class ClaudeAdapter:
39
- """Translate Claude Code `PreToolUse` and `PostToolUse` payloads."""
39
+ """Translate Claude Code `PreToolUse` and `PostToolUse` payloads.
40
+
41
+ Call `parse()` before `render()` on the same instance. Parsing each payload
42
+ replaces the event context used to choose the response format.
43
+ """
40
44
 
41
45
  name = 'claude'
42
46
  api_version = API_VERSION
43
47
  requires_event = False
48
+ _post_tool = False
44
49
 
45
50
  def parse(self, payload: Mapping[str, object], event: str | None) -> HostInput | None:
46
51
  """Read `hook_event_name`, `tool_name`, `tool_input`, and `cwd`.
@@ -58,6 +63,7 @@ class ClaudeAdapter:
58
63
  `None`.
59
64
  """
60
65
  del event
66
+ self._post_tool = False
61
67
  hook_event = payload.get('hook_event_name')
62
68
  tool_name = payload.get('tool_name')
63
69
  tool_input = object_field(payload, 'tool_input')
@@ -72,6 +78,7 @@ class ClaudeAdapter:
72
78
  command_cwd,
73
79
  )
74
80
  if hook_event == 'PostToolUse':
81
+ self._post_tool = True
75
82
  editing = isinstance(tool_name, str) and tool_name in _EDIT_TOOLS
76
83
  path = tool_input.get('file_path') if editing else None
77
84
  paths = (path,) if isinstance(path, str) and path else ()
@@ -79,16 +86,20 @@ class ClaudeAdapter:
79
86
  return None
80
87
 
81
88
  def render(self, result: HookResult) -> HostResponse:
82
- """Render allow feedback or a denial.
89
+ """Render feedback using the event recorded by `parse()`.
83
90
 
84
91
  Args:
85
92
  result: Decision, reason, and notes from docwrap.
86
93
 
87
94
  Returns:
88
- Empty stdout for a silent allow, or one system message for allow
89
- feedback; notes on stderr and exit 0. For deny, the deny JSON on
90
- stdout, the reason on stderr, and exit 0.
95
+ For pre-tool input, empty stdout for a silent allow, a system
96
+ message for allow feedback, or deny JSON with the denial reason on
97
+ stderr. For post-tool input, distinct nonempty diagnostic lines as
98
+ additional context, or empty stdout when there are none.
99
+ Notes remain on stderr and the exit status is 0.
91
100
  """
101
+ if self._post_tool:
102
+ return _post_tool_response(result)
92
103
  notes = notes_text(result)
93
104
  if result.decision == 'deny':
94
105
  return _permission_deny(result.reason or 'docwrap denied the operation', notes)
@@ -104,3 +115,17 @@ class ClaudeAdapter:
104
115
  The deny JSON on stdout, the reason on stderr, and exit 0.
105
116
  """
106
117
  return _permission_deny(reason)
118
+
119
+
120
+ def _post_tool_response(result: HookResult) -> HostResponse:
121
+ """Render distinct diagnostic lines as post-tool context."""
122
+ lines = '\n'.join((*result.notes, result.reason or '')).split('\n')
123
+ context = '\n'.join(dict.fromkeys(line for line in lines if line))
124
+ payload = {
125
+ 'hookSpecificOutput': {
126
+ 'hookEventName': 'PostToolUse',
127
+ 'additionalContext': context,
128
+ },
129
+ }
130
+ stdout = f'{json.dumps(payload)}\n' if context else ''
131
+ return HostResponse(stdout, notes_text(result), 0)
@@ -8,8 +8,7 @@ from typing import Literal
8
8
 
9
9
  import pytest
10
10
 
11
- from docwrap._cli import _build_hook_parser
12
- from docwrap._hook import run_hook
11
+ from docwrap._hook_front import _build_hook_parser, run_hook
13
12
  from docwrap._hosts import load_adapter
14
13
  from docwrap.hooks import (
15
14
  API_VERSION,
@@ -20,9 +19,9 @@ from docwrap.hooks import (
20
19
  HostResponse,
21
20
  )
22
21
  from plugins.claude._docwrap._adapter import ClaudeAdapter
22
+ from tests.helpers import decode_text, git_commit_all, run_setup
23
23
 
24
24
  ROOT = Path(__file__).resolve().parents[3]
25
- CORPUS = ROOT / 'tests' / 'data' / 'commit_classification.json'
26
25
  DENY_JSON = {
27
26
  'hookSpecificOutput': {
28
27
  'hookEventName': 'PreToolUse',
@@ -43,6 +42,8 @@ def _decision(stdout: str) -> str | None:
43
42
  if not stdout:
44
43
  return None
45
44
  data = json.loads(stdout)
45
+ if 'systemMessage' in data:
46
+ return None
46
47
  if 'hookSpecificOutput' in data:
47
48
  return str(data['hookSpecificOutput']['permissionDecision'])
48
49
  return str(data['example']['decision'])
@@ -65,39 +66,63 @@ class TestClaudeAdapter:
65
66
  False,
66
67
  )
67
68
 
68
- def test_decisions_match_the_third_party_example_over_the_corpus(
69
+ @pytest.mark.usefixtures('example_hosts')
70
+ @pytest.mark.parametrize('host', ['claude', 'example'])
71
+ @pytest.mark.parametrize(
72
+ ('command', 'outcome'),
73
+ [
74
+ pytest.param('ls', 'unrelated', id='unrelated'),
75
+ pytest.param('git commit --future-option -m x', 'denied', id='denied'),
76
+ pytest.param('git commit -m x', 'wrapped', id='supported'),
77
+ ],
78
+ )
79
+ def test_hook_decisions_preserve_adapter_independence(
69
80
  self,
70
81
  tmp_path: Path,
71
- monkeypatch: pytest.MonkeyPatch,
72
82
  capsys: pytest.CaptureFixture[str],
73
- example_hosts: Path,
83
+ host: str,
84
+ command: str,
85
+ outcome: Literal['unrelated', 'denied', 'wrapped'],
74
86
  ) -> None:
75
- del example_hosts
76
- (tmp_path / 'repo').mkdir()
77
- (tmp_path / ';').mkdir()
78
-
79
- def enabled(_cwd: str | None = None) -> bool:
80
- return True
81
-
82
- monkeypatch.setattr('docwrap._hook.is_hook_enabled', enabled)
83
- entries = json.loads(CORPUS.read_text(encoding='utf-8'))['entries']
84
- for entry in entries:
85
- payload = json.dumps(
86
- _payload('PreToolUse', 'Bash', {'command': entry['command']})
87
- | {'cwd': str(tmp_path)}
88
- )
89
- decisions = [
90
- (run_hook(payload, host=host), _decision(capsys.readouterr().out))
91
- for host in ('claude', 'example')
92
- ]
93
- assert decisions[0] == decisions[1], entry['id']
94
- # This native payload establishes cwd, unlike the unknown-cwd corpus variants.
95
- if (
96
- entry['outcome'] != 'supported'
97
- and entry.get('command_cwd', str(tmp_path)) is not None
98
- ):
99
- expected = 'deny' if entry['outcome'] == 'unsupported' else None
100
- assert decisions[0] == (0, expected), entry['id']
87
+ run_setup(['git', 'init'], cwd=tmp_path)
88
+ run_setup(['git', 'config', 'docwrap.hook', 'true'], cwd=tmp_path)
89
+ (tmp_path / 'pyproject.toml').write_text(
90
+ '[tool.docwrap]\nline-length = 40\nfill-style = "greedy"\n', encoding='utf-8'
91
+ )
92
+ target = tmp_path / 'sample.py'
93
+ target.write_text('pass\n', encoding='utf-8')
94
+ git_commit_all(tmp_path)
95
+ source = (
96
+ 'def f():\n """Summary.\n\n'
97
+ ' one two three four five six seven eight nine ten eleven twelve\n'
98
+ ' """\n'
99
+ )
100
+ target.write_text(source, encoding='utf-8')
101
+ run_setup(['git', 'add', 'sample.py'], cwd=tmp_path)
102
+ payload = json.dumps(
103
+ _payload('PreToolUse', 'Bash', {'command': command}) | {'cwd': str(tmp_path)}
104
+ )
105
+
106
+ status = run_hook(payload, host=host)
107
+
108
+ captured = capsys.readouterr()
109
+ expected_decision = 'deny' if outcome == 'denied' else None
110
+ assert (status, _decision(captured.out)) == (0, expected_decision)
111
+ if expected_decision == 'deny':
112
+ assert 'unsupported commit command' in captured.err
113
+ else:
114
+ assert captured.err == ''
115
+ expected = (
116
+ 'def f():\n """Summary.\n\n'
117
+ ' one two three four five six seven\n'
118
+ ' eight nine ten eleven twelve\n'
119
+ ' """\n'
120
+ if outcome == 'wrapped'
121
+ else source
122
+ )
123
+ staged = run_setup(['git', 'show', ':sample.py'], cwd=tmp_path)
124
+ assert decode_text(staged.stdout) == expected
125
+ assert target.read_text(encoding='utf-8') == expected
101
126
 
102
127
  def test_parse_missing_cwd_uses_process_directory(
103
128
  self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -169,6 +194,40 @@ class TestClaudeAdapter:
169
194
  assert response.stderr == 'docwrap: reason\n'
170
195
  assert response.exit_code == 0
171
196
 
197
+ @pytest.mark.parametrize(
198
+ ('reason', 'notes', 'context'),
199
+ [
200
+ (None, ('Cannot preserve layout.',), 'Cannot preserve layout.'),
201
+ ('Cannot wrap a.py: invalid syntax.', (), 'Cannot wrap a.py: invalid syntax.'),
202
+ ('first\nsecond', ('first', 'diagnostic'), 'first\ndiagnostic\nsecond'),
203
+ ],
204
+ )
205
+ def test_render_post_tool_context(
206
+ self, reason: str | None, notes: tuple[str, ...], context: str
207
+ ) -> None:
208
+ adapter = ClaudeAdapter()
209
+ assert adapter.parse(_payload('PostToolUse', 'Edit', {'file_path': 'a.py'}), None)
210
+ response = adapter.render(_result('allow', reason, notes))
211
+ assert json.loads(response.stdout) == {
212
+ 'hookSpecificOutput': {
213
+ 'hookEventName': 'PostToolUse',
214
+ 'additionalContext': context,
215
+ }
216
+ }
217
+ assert response.stderr == ''.join(f'{note}\n' for note in notes)
218
+ assert response.exit_code == 0
219
+
220
+ @pytest.mark.parametrize('event', ['PreToolUse', 'Notification', None])
221
+ def test_render_resets_post_tool_context(self, event: str | None) -> None:
222
+ adapter = ClaudeAdapter()
223
+ adapter.parse(_payload('PostToolUse', 'Edit', {'file_path': 'a.py'}), None)
224
+ assert adapter.render(_result('allow')) == HostResponse('', '', 0)
225
+ adapter.parse({'hook_event_name': event}, None)
226
+ response = adapter.render(_result('deny', 'docwrap: reason'))
227
+ assert json.loads(response.stdout) == DENY_JSON
228
+ receipt = adapter.render(_result('allow', 'staged rewrite receipt'))
229
+ assert json.loads(receipt.stdout) == {'systemMessage': 'staged rewrite receipt'}
230
+
172
231
 
173
232
  @pytest.mark.usefixtures('registered_hosts')
174
233
  def test_native_manifest_command_loads_this_adapter() -> None:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docwrap",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Install the docwrap commit-time docstring wrapping hook for Codex.",
5
5
  "author": {
6
6
  "name": "docwrap contributors"
@@ -54,11 +54,16 @@ def _permission_deny(reason: str, stderr_prefix: str = '') -> HostResponse:
54
54
 
55
55
 
56
56
  class CodexAdapter:
57
- """Translate Codex `PreToolUse` and `PostToolUse` payloads."""
57
+ """Translate Codex PreToolUse and PostToolUse payloads.
58
+
59
+ Call `parse()` before `render()` on the same instance. Parsing each payload
60
+ replaces the event context used to choose the response format.
61
+ """
58
62
 
59
63
  name = 'codex'
60
64
  api_version = API_VERSION
61
65
  requires_event = False
66
+ _post_tool = False
62
67
 
63
68
  def parse(self, payload: Mapping[str, object], event: str | None) -> HostInput | None:
64
69
  """Read `hook_event_name`, `tool_name`, `tool_input`, and `cwd`.
@@ -76,6 +81,7 @@ class CodexAdapter:
76
81
  `None`. Post-tool paths come from the `apply_patch` command text.
77
82
  """
78
83
  del event
84
+ self._post_tool = False
79
85
  hook_event = payload.get('hook_event_name')
80
86
  tool_name = payload.get('tool_name')
81
87
  tool_input = object_field(payload, 'tool_input')
@@ -84,22 +90,27 @@ class CodexAdapter:
84
90
  command = tool_input.get('command') if tool_name == 'Bash' else None
85
91
  return HostInput('pre_tool', command if isinstance(command, str) else None, cwd, None)
86
92
  if hook_event == 'PostToolUse':
93
+ self._post_tool = True
87
94
  command = tool_input.get('command') if tool_name == _EDIT_TOOL else None
88
95
  paths = _apply_patch_paths(command) if isinstance(command, str) else ()
89
96
  return HostInput('post_tool', None, cwd, None, paths)
90
97
  return None
91
98
 
92
99
  def render(self, result: HookResult) -> HostResponse:
93
- """Render allow feedback or a denial.
100
+ """Render feedback using the event recorded by `parse()`.
94
101
 
95
102
  Args:
96
103
  result: Decision, reason, and notes from docwrap.
97
104
 
98
105
  Returns:
99
- Empty stdout for a silent allow, or one system message for allow
100
- feedback; notes on stderr and exit 0. For deny, the deny JSON on
101
- stdout, the reason on stderr, and exit 0.
106
+ For pre-tool input, empty stdout for a silent allow, a system
107
+ message for allow feedback, or deny JSON with the denial reason on
108
+ stderr. For post-tool input, distinct nonempty diagnostic lines as
109
+ additional context, or empty stdout when there are none.
110
+ Notes remain on stderr and the exit status is 0.
102
111
  """
112
+ if self._post_tool:
113
+ return _post_tool_response(result)
103
114
  notes = notes_text(result)
104
115
  if result.decision == 'deny':
105
116
  return _permission_deny(result.reason or 'docwrap denied the operation', notes)
@@ -115,3 +126,17 @@ class CodexAdapter:
115
126
  The deny JSON on stdout, the reason on stderr, and exit 0.
116
127
  """
117
128
  return _permission_deny(reason)
129
+
130
+
131
+ def _post_tool_response(result: HookResult) -> HostResponse:
132
+ """Render distinct diagnostic lines as post-tool context."""
133
+ lines = '\n'.join((*result.notes, result.reason or '')).split('\n')
134
+ context = '\n'.join(dict.fromkeys(line for line in lines if line))
135
+ payload = {
136
+ 'hookSpecificOutput': {
137
+ 'hookEventName': 'PostToolUse',
138
+ 'additionalContext': context,
139
+ },
140
+ }
141
+ stdout = f'{json.dumps(payload)}\n' if context else ''
142
+ return HostResponse(stdout, notes_text(result), 0)
@@ -8,7 +8,7 @@ from typing import Literal
8
8
 
9
9
  import pytest
10
10
 
11
- from docwrap._cli import _build_hook_parser
11
+ from docwrap._hook_front import _build_hook_parser
12
12
  from docwrap._hosts import load_adapter
13
13
  from docwrap.hooks import API_VERSION, HookAdapter, HookResult, HostInput, HostResponse
14
14
  from plugins.codex._docwrap._adapter import CodexAdapter
@@ -71,6 +71,41 @@ class TestCodexAdapter:
71
71
  assert json.loads(response.stdout) == DENY_JSON
72
72
  assert (response.stderr, response.exit_code) == ('docwrap: reason\n', 0)
73
73
 
74
+ @pytest.mark.parametrize(
75
+ ('reason', 'notes', 'context'),
76
+ [
77
+ (None, ('Cannot preserve layout.',), 'Cannot preserve layout.'),
78
+ ('Cannot wrap a.py: invalid syntax.', (), 'Cannot wrap a.py: invalid syntax.'),
79
+ ('first\nsecond', ('first', 'diagnostic'), 'first\ndiagnostic\nsecond'),
80
+ ],
81
+ )
82
+ def test_render_post_tool_context(
83
+ self, reason: str | None, notes: tuple[str, ...], context: str
84
+ ) -> None:
85
+ adapter = CodexAdapter()
86
+ payload = _payload('PostToolUse', 'apply_patch', {'command': '*** Update File: a.py\n'})
87
+ assert adapter.parse(payload, None)
88
+ response = adapter.render(_result('allow', reason, notes))
89
+ assert json.loads(response.stdout) == {
90
+ 'hookSpecificOutput': {
91
+ 'hookEventName': 'PostToolUse',
92
+ 'additionalContext': context,
93
+ }
94
+ }
95
+ assert response.stderr == ''.join(f'{note}\n' for note in notes)
96
+ assert response.exit_code == 0
97
+
98
+ @pytest.mark.parametrize('event', ['PreToolUse', 'Notification', None])
99
+ def test_render_resets_post_tool_context(self, event: str | None) -> None:
100
+ adapter = CodexAdapter()
101
+ adapter.parse(_payload('PostToolUse', 'apply_patch', {}), None)
102
+ assert adapter.render(_result('allow')) == HostResponse('', '', 0)
103
+ adapter.parse({'hook_event_name': event}, None)
104
+ response = adapter.render(_result('deny', 'docwrap: reason'))
105
+ assert json.loads(response.stdout) == DENY_JSON
106
+ receipt = adapter.render(_result('allow', 'staged rewrite receipt'))
107
+ assert json.loads(receipt.stdout) == {'systemMessage': 'staged rewrite receipt'}
108
+
74
109
 
75
110
  @dataclass(frozen=True)
76
111
  class _Result: