Skip to content

feat: ergonomics grab-bag — SearchResult edit targets, ParagraphInfo structure, workspace reset (ISSUES.md #52 + #60) - #73

Merged
pablospe merged 3 commits into
mainfrom
task/f2ab793f-Ergonomics-grab-bag--SearchRes
Jul 30, 2026
Merged

pablospe merged 3 commits into
mainfrom
task/f2ab793f-Ergonomics-grab-bag--SearchRes

Conversation

@pablospe

@pablospe pablospe commented Jul 30, 2026 •

Copy link
Copy Markdown
Owner

Six code items plus two documentation items from the round-3/4 dogfood papercut
list. Tracked as ISSUES.md #52 and #60 (repo-local item numbers, not GitHub
issues — deliberately not auto-closing anything here).

What changed

1. A SearchResult can stand in for its own three fields. replace,
delete, insert_after, insert_before, add_comment and the four
EditOperation constructors accept a match where they expect target text, and
take paragraph/occurrence from it:

doc.replace(doc.find_text("30 days"), "60 days")            # was: match.text + paragraph= + occurrence=
doc.batch_edit([EditOperation.replace(m, "60 days") for m in reversed(doc.find_all("30 days"))])

Passing paragraph=/occurrence= as well raises ValueError rather than
picking a winner, and a stale match raises HashMismatchError like any other
stale ref. The dataclass fields stay plain strings, so an op built from a match
is indistinguishable from a hand-written one.

2. ParagraphInfo gains in_table / style / outline_level. A table of
contents is now one list_paragraphs_structured() call instead of a second
list_paragraph_locations() pass. The semantics are ParagraphLocation's,
reusing its extractors — no new extraction logic — and one factory builds the
record for all three producers so they cannot drift.

3. EditValidationResult.current_ref — dry-run rows that failed on a stale
hash carry the ref for the paragraph's current content, so recovery stops
regexing the hash out of the error prose. None on every other outcome, which
is also how you tell a stale hash from a target that no longer exists.

4. Document.discard_workspace(path) — the crashed-script reset, replacing
the deep Workspace import (or a hand-deleted cache directory) the docs used to
prescribe. Idempotent, clears the lock sidecar, honours workspace_dir.

5. ExecResult.started / EvalResult.started — tells a timeout of your own
running code apart from one that never left the kernel queue, taken from the
kernel's execute_input broadcast for our own msg_id. The CLI prints
different advice for each; status enum and exit codes are unchanged.

6. Whitespace-only replaces are minimal revisions — "clause 2" →
"clause 2" is now a pure deletion instead of an invisible del+ins pair.
Character-level trimming is gated on both trimmed sides being whitespace, so
"30 days" → "60 days" stays one whole-word replacement.

7–8. Docs only. Pagination overshoot is documented as a valid request with
an empty answer — no behaviour change, deliberately, since raising would break
the loop-until-empty idiom — and get_paragraph()/context() are marked
O(document) with the "list once, index the result" alternative.

Behaviour change beyond #52/#60 (please look at this one)

Review of item 6 turned up a fidelity bug it exposed: w:delText never carried
xml:space="preserve". A conforming consumer trims the edges of an unpreserved
w:delText, and _restore_deletion copies that element's attributes onto the
w:t it rebuilds — so rejecting a whitespace deletion restored the wrong
spacing after a Word round-trip. Reproduced before the fix:

after replace: <w:delText> </w:delText>                     # no preserve
after reject:  <w:t xml:space="preserve">The quick </w:t>
               <w:t> </w:t>                                 # <- trimmed away by Word
               <w:t>brown fox jumps over the lazy dog.</w:t>

get_visible_text() reads the DOM directly, which is why the tests passed over
it. Item 6 makes whitespace-only delText the normal output shape, so the fix
ships here: the editor's existing xml:space injection now covers w:delText
as well as w:t. This also repairs the latent pre-existing case on ordinary
deletions (delete("brown "), replace("quick brown", "quick")), so every
tracked deletion whose text has a whitespace edge now carries the attribute
—
visible in output XML.

Performance

get_paragraph() needs the style maps now, which naively meant a
word/styles.xml parse per call — a 31x regression on a 3300-paragraph
document. _style_maps() is therefore memoized per Document (nothing in this
library ever writes styles.xml) and parsed without XMLEditor's line-tracking
parser, whose parse_position metadata the map builders never read.

call (doc3000.docx, 438 KB styles.xml) before after (1st) after (2nd+)
list_paragraphs_structured(limit=None) 475 ms 347 ms ~250–290 ms
get_paragraph(1500) 7.8 ms 133 ms 7.6 ms
get_paragraph_location(...) ~193 ms every call 277 ms 17 ms

The structured listing is faster than before even on its first call (one text
map now serves both hash and text); the one honest regression is the first
get_paragraph() on a document with a large styles part, which pays a parse any
location call would have paid anyway. list_paragraphs() — the string API, the
known #56 bottleneck — is deliberately untouched.

Testing

1406 passed, 2 skipped (baseline 1345 → +61: 59 for the feature work, 2 for
the xml:space regression). ruff check / ruff format --check clean,
ty check exit 0 with only the 6 pre-existing possibly-missing-attribute
warnings in files this branch does not touch, mkdocs build -s clean.

Docs snippets were verified by execution against purpose-built documents, and
the trim invariants were brute-forced over 225 find/replace pairs (affixes never
overlap, are always genuinely shared, and reconstruct both strings).

Notes

  • Document.discard_workspace() will delete the workspace of a Document still
    open in this process. Documented; open(force_recreate=True) has always had
    the identical hazard.
  • The dry-run recovery field is named current_ref (a ready-to-use ref) rather
    than the stale_hash the issue text suggested, because the value consumers
    currently regex out of the prose is the current hash and a ref removes the
    string surgery entirely. HashMismatchError.actual_hash still exposes the raw
    hash on the apply path.
  • The queued-timeout tests are wall-clock sequenced (occupy the kernel, then
    time out behind it) and each get their own kernel — the likeliest flake source
    here if CI is loaded.
  • No version bump; the 0.7.2 release commit is separate.

Summary by CodeRabbit

  • New Features

    • Edit and comment operations can now target SearchResult objects directly (eliminating extra paragraph/occurrence wiring).
    • Added workspace recovery via discard_workspace.
    • Structured paragraph listings/context now include table membership, style, and outline level.
    • Batch validation now returns a current_ref to recover from stale edits.
    • Session command results now report whether execution actually started (started).
  • Bug Fixes

    • Improved tracked-change whitespace handling for replacements and deletions.
    • Hardened workspace recovery and stale/locked workflow behavior.
  • Documentation

    • Updated API, quick-start, and usage guides with new examples and stale/workspace retry instructions.

…structure, workspace reset (ISSUES.md #52 + #60)

Six code items plus two documentation items from the round-3/4 dogfood
papercut list.

- SearchResult stands in for (text, paragraph, occurrence) on replace,
  delete, insert_after, insert_before, add_comment and the four
  EditOperation constructors. Passing paragraph=/occurrence= alongside one
  raises rather than picking a winner.
- ParagraphInfo gains in_table / style / outline_level, so a table of
  contents needs one list_paragraphs_structured() call instead of a second
  list_paragraph_locations() pass. One factory now builds the record for all
  three producers, and its single text map serves both hash and text.
- EditValidationResult.current_ref gives dry-run stale-hash rows the ref for
  the paragraph's current content, so recovery no longer parses the error
  prose.
- Document.discard_workspace() replaces the deep Workspace import (or a
  hand-deleted cache directory) for resetting after a crashed script.
- ExecResult/EvalResult gain started, distinguishing a timeout of your own
  running code from one that never left the kernel queue; the CLI prints
  different advice for each. Exit codes unchanged.
- replace() trims whitespace-only spans character-wise, so a spacing fix is a
  pure insertion or deletion instead of an invisible del+ins pair. Word spans
  are never char-trimmed.
- Docs: pagination overshoot is documented as a valid empty answer (no
  behaviour change), and get_paragraph()/context() are marked O(document).

Also fixes a fidelity bug this exposed: w:delText never carried
xml:space="preserve", so a conforming consumer trimmed the edges and
rejecting a whitespace deletion restored the wrong spacing. The editor's
existing xml:space injection now covers w:delText as well as w:t.

word/styles.xml is memoized per Document and parsed without the
line-tracking parser, keeping get_paragraph() at its previous per-call cost
now that it needs the style maps; the location APIs get the same win.

1406 passed, 2 skipped.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pablospe, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 29aff0bf-bf3c-4556-bfdb-7385a6347c70

📥 Commits

Reviewing files that changed from the base of the PR and between 5503b86 and 2eeb840.

📒 Files selected for processing (4)
  • docs/quickstart.md
  • docx_editor/session.py
  • skills/docx/SKILL.md
  • tests/test_session.py
📝 Walkthrough

Walkthrough

The PR adds direct SearchResult edit targeting, structured paragraph metadata, workspace cleanup, stale-hash recovery data, whitespace revision handling, and execution-start reporting for session timeouts. Documentation and tests cover the expanded APIs and behaviors.

Changes

Document editing and batch recovery

Layer / File(s) Summary
SearchResult edit targeting
README.md, docs/api.md, docs/quickstart.md, docx_editor/document.py, docx_editor/track_changes.py, tests/test_batch_edit.py, tests/test_comments.py, tests/test_track_changes.py, skills/docx/SKILL.md
Edit and comment methods plus EditOperation constructors accept SearchResult targets and resolve paragraph and occurrence automatically.
Batch validation recovery
docx_editor/track_changes.py, docx_editor/document.py, docs/api.md, tests/test_batch_edit.py, skills/docx/SKILL.md
Dry-run validation returns current_ref for stale-hash failures and documents retrying with that reference.
Whitespace revision handling
docx_editor/track_changes.py, docx_editor/xml_editor.py, tests/test_track_changes.py, skills/docx/SKILL.md
Whitespace-only replacement trimming and deletion-text whitespace preservation are updated and tested.

Paragraph and workspace behavior

Layer / File(s) Summary
Structured paragraph metadata
docx_editor/document.py, docx_editor/xml_editor.py, docs/api.md, tests/test_paragraph_location.py, skills/docx/SKILL.md
ParagraphInfo now exposes table membership, style, and outline level, with cached secure style parsing used by paragraph APIs.
Workspace discard recovery
docx_editor/document.py, docs/api.md, tests/test_workspace.py, skills/docx/SKILL.md
Document.discard_workspace() removes workspace state and supports idempotent cleanup and custom workspace directories.

Session execution reporting

Layer / File(s) Summary
Execution-start timeout reporting
docx_editor/session.py, tests/test_session.py, skills/docx/SKILL.md
ExecResult and EvalResult report whether execution started, and CLI timeout messages distinguish queued requests from running code.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Document
  participant SearchResult
  participant EditOperation
  participant TrackChanges
  Caller->>Document: find_text or find_all
  Document-->>Caller: SearchResult
  Caller->>Document: edit using SearchResult
  Document->>TrackChanges: resolve and validate target
  TrackChanges-->>Document: edit result
  Document-->>Caller: completed operation
Loading
sequenceDiagram
  participant CLI
  participant Session
  participant Kernel
  CLI->>Session: submit exec request
  Session->>Kernel: execute code
  Kernel-->>Session: execute_input or timeout
  Session-->>CLI: started flag and status
  CLI-->>CLI: emit matching timeout guidance
Loading

Possibly related PRs

Poem

A rabbit found a match in the text,
And hopped through edits, next and next.
Fresh refs mend a stale old trail,
Whitespace revisions leave no veil.
“Queued or running?” kernels say—
Then carrot cheers complete the day!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is clearly related to the PR and names the main themes: SearchResult edit targets, ParagraphInfo changes, and workspace reset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/f2ab793f-Ergonomics-grab-bag--SearchRes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: 4

🤖 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 `@docs/api.md`:
- Around line 1476-1480: Update the Document.discard_workspace reference in the
surrounding documentation to use the heading’s actual anchor fragment,
document-discard_workspace-path-workspace_dirnone, so the internal link resolves
correctly.

In `@docx_editor/session.py`:
- Line 562: Add direct assertions for EvalResult.started in the evaluation tests
covering successful execution, execution errors, and queued timeouts. Verify it
is true when evaluation starts running and false when a timeout occurs before
leaving the queue, alongside the existing ExecResult.started checks.

In `@docx_editor/track_changes.py`:
- Around line 431-478: Replace the bare tuple result of _resolve_search_target
with a named internal result object, following the existing _ValidationOutcome
pattern, while preserving the same text, paragraph, and occurrence values and
validation behavior. Update all eight callers in Document and EditOperation to
use the named fields instead of positional unpacking.

In `@tests/test_session.py`:
- Around line 135-136: In the hog execution setup around hog.execute, replace
the fixed one-second time.sleep with a bounded wait for the hog client to
receive the matching execute_input message. Synchronize on that kernel message
before sending the test request, while preserving the existing timeout behavior
and ensuring the request is confirmed started before asserting started=False.
🪄 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: f1e16061-0ee1-4a28-bb9a-4701f9f35699

📥 Commits

Reviewing files that changed from the base of the PR and between 090e4c4 and 4720370.

📒 Files selected for processing (16)
  • README.md
  • docs/api.md
  • docs/quickstart.md
  • docx_editor/document.py
  • docx_editor/session.py
  • docx_editor/track_changes.py
  • docx_editor/xml_editor.py
  • skills/docx/SKILL.md
  • tests/conftest.py
  • tests/test_batch_edit.py
  • tests/test_comments.py
  • tests/test_error_quality.py
  • tests/test_paragraph_location.py
  • tests/test_session.py
  • tests/test_track_changes.py
  • tests/test_workspace.py

Comment thread docs/api.md
Comment on lines +1476 to +1480
`Document.open(path, force_recreate=True)` and
[`Document.discard_workspace(path)`](#documentdiscard_workspacepath-workspace_dirnone)
both recover but discard the workspace's unsaved edits. To rescue them first,
save the orphaned workspace to a new file (`Workspace` is not exported at the
package root — use the deep import):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Extract the actual heading text and compare against the anchor used.
rg -n '#### `Document.discard_workspace' docs/api.md
rg -n 'documentdiscard_workspacepath' docs/api.md

Repository: pablospe/docx-editor

Length of output: 316


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant surrounding lines:"
sed -n '33,43p' docs/api.md
sed -n '1474,1481p' docs/api.md

echo
echo "Markdown link anchor(s):"
python3 - <<'PY'
import re
text = open('docs/api.md', encoding='utf-8').read()
for m in re.finditer(r'(?P<prefix></[^>]+>")(?P<link>\[(?P<display>.*?)\]\((?P<fragment>#[^)\s]+)\))', text):
    if 'discard_workspace' in m.group('link') or 'Document.discard' in m.group('display'):
        print(f"line={text[:m.start()].count(chr(10))+1} display={m.group('display')!r} fragment={m.group('fragment')!r}")
PY

echo
echo "Headings containing discard_workspace:"
python3 - <<'PY'
import re
text = open('docs/api.md', encoding='utf-8').read()
for i, line in enumerate(text.splitlines(), 1):
    if 'discard_workspace' in line:
        print(f"{i}: {line}")
PY

Repository: pablospe/docx-editor

Length of output: 1560


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
def anchor_from_heading(heading):
    # Mirrors GitHub Markdown heading slugifier behavior for visible trailing
    # punctuation/removal is not required by this concern; only normalize the
    # provided heading characters to the claimed fragment form.
    text = heading
    # Common slug behavior: lowercased, whitespace/commas → hyphen, optional trailing
    # punctuation stripped, collapsing multiple hyphens, no parentheses by default.
    slug = ""
    prev_hyphen = False
    for ch in text[:100].lower():
        if ch in "  \t," or ch in ".,!?;:'\"()[]{}|\\*&^%$#~":
            if not prev_hyphen:
                slug += "-"
                prev_hyphen = True
        elif ch.isalnum() or ch == "_":
            slug += ch
            prev_hyphen = False
        else:
            if slug and not prev_hyphen:
                prev_hyphen = True
    return slug.strip("-")

heading = "`Document.discard_workspace(path, *, workspace_dir=None)`"
print("heading:", heading)
print("slug:", anchor_from_heading(heading))
print("link fragment:", "`#documentdiscard_workspacepath-workspace_dirnone`")
PY

echo
echo "Check whether Markdown/HTML processors resolve this heading and anchor, if available:"
python3 - <<'PY'
import subprocess, sys
programs = ["python3 -m markdown --extension mdx_tricky_sane_lists", "python3 -c 'import markdown; print(markdown.markdown(\"#### \`Document.discard_workspace(path, *, workspace_dir=None)\`\"))'"]
text = "#### `Document.discard_workspace(path, *, workspace_dir=None)`\n\n[link](`#documentdiscard_workspacepath-workspace_dirnone`)"
for p in programs:
    print("command:", p)
    try:
        out = subprocess.run(p.split(), input=text.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
        print("stdout:", out.stdout.decode(errors="replace").strip())
        print("stderr:", out.stderr.decode(errors="replace").strip())
    except Exception as e:
        print("Error:", repr(e))
PY

Repository: pablospe/docx-editor

Length of output: 800


Fix the discard_workspace anchor link.

The heading anchors as document-discard_workspace-path-workspace_dirnone, but the link uses #documentdiscard_workspacepath-workspace_dirnone, so this internal reference is broken. Update the fragment or add an explicit id on the heading.

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 1477-1477: Link fragments should be valid

(MD051, link-fragments)

🤖 Prompt for 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.

In `@docs/api.md` around lines 1476 - 1480, Update the Document.discard_workspace
reference in the surrounding documentation to use the heading’s actual anchor
fragment, document-discard_workspace-path-workspace_dirnone, so the internal
link resolves correctly.

Source: Linters/SAST tools

Comment thread docx_editor/session.py
Comment on lines +431 to +478
def _resolve_search_target(
target: "str | SearchResult",
paragraph: str | None,
occurrence: int | None,
*,
ctx: str,
arg: str,
) -> tuple[str, str | None, int | None]:
"""Normalize a text-or-SearchResult edit target into ``(text, paragraph, occurrence)``.

A plain string passes through untouched, so every existing call site keeps
its exact semantics. A :class:`SearchResult` supplies all three values —
its ``text``, its ``paragraph_ref`` and its ``paragraph_occurrence`` —
which is what makes ``doc.replace(match, "60 days")`` mean exactly what
spelling those three fields out by hand means.

Passing a SearchResult *and* ``paragraph=``/``occurrence=`` is a
contradiction rather than a merge (which of the two paragraphs wins?), so
it raises instead of silently preferring one.

Args:
target: Text to locate, or a SearchResult that already located it.
paragraph: The call's ``paragraph=`` argument (None when omitted).
occurrence: The call's ``occurrence=`` argument (None when omitted).
ctx: Message prefix naming the caller, e.g. ``"replace(): "``.
arg: Display name of the target parameter, e.g. ``"'find'"``.

Returns:
``(text, paragraph, occurrence)``, for the caller to validate exactly
as it validates hand-written arguments.

Raises:
ValueError: If ``target`` is a SearchResult and ``paragraph`` or
``occurrence`` was given too.
"""
if not isinstance(target, SearchResult):
return target, paragraph, occurrence
redundant = [
name
for name, given in (("paragraph=", paragraph is not None), ("occurrence=", occurrence is not None))
if given
]
if redundant:
raise ValueError(
f"{ctx}{arg} is a SearchResult, which already pins the paragraph ({target.paragraph_ref!r}) "
f"and the occurrence ({target.paragraph_occurrence}) — drop {' and '.join(redundant)}"
)
return target.text, target.paragraph_ref, target.paragraph_occurrence

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Return a named result instead of a bare 3-tuple.

_resolve_search_target returns tuple[str, str | None, int | None], unpacked identically at 8 call sites (Document.replace/delete/insert_after/insert_before/add_comment and EditOperation.replace/delete/_insert). As per coding guidelines, "Use named objects (dataclasses) instead of tuples for functions returning 3 or more values." _ValidationOutcome (added in this same PR, just below) already follows this convention for a similar internal result — worth applying the same pattern here so the meaning of each position isn't left to memorizing call-site order.

♻️ Proposed refactor
+@dataclass(frozen=True)
+class _ResolvedTarget:
+    text: str
+    paragraph: str | None
+    occurrence: int | None
+
+
 def _resolve_search_target(
     target: "str | SearchResult",
     paragraph: str | None,
     occurrence: int | None,
     *,
     ctx: str,
     arg: str,
-) -> tuple[str, str | None, int | None]:
+) -> _ResolvedTarget:
     ...
     if not isinstance(target, SearchResult):
-        return target, paragraph, occurrence
+        return _ResolvedTarget(target, paragraph, occurrence)
     redundant = [...]
     if redundant:
         raise ValueError(...)
-    return target.text, target.paragraph_ref, target.paragraph_occurrence
+    return _ResolvedTarget(target.text, target.paragraph_ref, target.paragraph_occurrence)

Call sites become e.g.:

-        find, paragraph, occurrence = _resolve_search_target(
+        resolved = _resolve_search_target(
             find, paragraph, occurrence, ctx="replace(): ", arg="'find'"
         )
+        find, paragraph, occurrence = resolved.text, resolved.paragraph, resolved.occurrence
🧰 Tools
🪛 Ruff (0.16.0)

[warning] 474-477: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for 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.

In `@docx_editor/track_changes.py` around lines 431 - 478, Replace the bare tuple
result of _resolve_search_target with a named internal result object, following
the existing _ValidationOutcome pattern, while preserving the same text,
paragraph, and occurrence values and validation behavior. Update all eight
callers in Document and EditOperation to use the named fields instead of
positional unpacking.

Source: Coding guidelines

Comment thread tests/test_session.py
Comment on lines +135 to +136
hog.execute("import time; time.sleep(30)") # never waited on
time.sleep(1.0) # let the kernel pick it up and go busy

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching test_session.py:"
fd -a 'test_session\.py$' . || true

file="$(fd 'test_session\.py$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  echo
  echo "File: $file"
  wc -l "$file"
  echo
  echo "Relevant lines:"
  sed -n '80,180p' "$file" | nl -ba -v80
  echo
  echo "Search for hog/started/execute_input patterns:"
  rg -n "hog|started|execute_input|get\(|poll|timeout" "$file" || true
fi

Repository: pablospe/docx-editor

Length of output: 348


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Also inspect nearby tests and imports to understand the execution model.
file="$(fd 'test_session\.py$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  echo "Top 80 lines:"
  sed -n '1,90p' "$file" | nl -ba -v1
fi

Repository: pablospe/docx-editor

Length of output: 212


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'test_session\.py$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  echo "File: $file"
  wc -l "$file"
  echo
  echo "Relevant lines 80-180:"
  sed -n '80,180p' "$file" | awk '{printf "%6d\t%s\n", NR+79, $0}'
  echo
  echo "Search for hog/started/execute_input patterns:"
  rg -n "hog|started|execute_input|get\(|poll|timeout" "$file" || true
  echo
  echo "Top 80 lines:"
  sed -n '1,80p' "$file" | awk '{printf "%6d\t%s\n", NR, $0}'
fi

Repository: pablospe/docx-editor

Length of output: 9865


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "session.py file candidate:"
fd -a 'session\.py$' . || true
file="$(fd 'session\.py$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  echo "File: $file"
  wc -l "$file"
  echo
  echo "Outline:"
  ast-grep outline "$file" --view expanded || true
  echo
  echo "Relevant symbols:"
  rg -n "class Client|_client|def execute|def stop_channels|def get|poll|execute_input|execute_reply|ExecResult|started|timeout" "$file" || true
  echo
  echo "Read session.py lines 1-260:"
  sed -n '1,260p' "$file" | awk '{printf "%6d\t%s\n", NR, $0}'
fi

Repository: pablospe/docx-editor

Length of output: 19875


Synchronize on the hog request instead of sleeping.

The one-second delay does not prove that the hog execute request has been started by the kernel. Replace it with a bounded wait for the hog client’s matching execute_input message before sending the test request, so started=False cannot fail nondeterministically under CI load.

🤖 Prompt for 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.

In `@tests/test_session.py` around lines 135 - 136, In the hog execution setup
around hog.execute, replace the fixed one-second time.sleep with a bounded wait
for the hog client to receive the matching execute_input message. Synchronize on
that kernel message before sending the test request, while preserving the
existing timeout behavior and ensuring the request is confirmed started before
asserting started=False.

@codecov

codecov Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.3%. Comparing base (090e4c4) to head (2eeb840).

Additional details and impacted files
@@           Coverage Diff           @@
##            main     #73     +/-   ##
=======================================
+ Coverage   96.1%   96.3%   +0.1%     
=======================================
  Files         11      11             
  Lines       4355    4418     +63     
  Branches     859     869     +10     
=======================================
+ Hits        4187    4256     +69     
+ Misses        78      73      -5     
+ Partials      90      89      -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…s boundary, doc corrections

Round of independent review (3 reviewers + codex, red-teamed): 11 findings
confirmed, 3 killed as false positives, 6 downgraded.

Correctness:

- session.py: ExecResult's docstring claimed "ok/error cannot happen without
  executing". False — ipykernel aborts everything still queued behind a command
  that raises, so a request sent while an earlier one was failing returns
  status="ok", started=False, empty stdout, having run nothing. Reproduced end
  to end (exit code 0, and the variable it set was never bound). The docstring
  now describes it, the CLI prints a loud warning instead of a silent success,
  and two tests pin both. Exit code stays EXIT_OK — changing it is a contract
  change, so it is left for a separate decision.

- tests/test_batch_edit.py: the two new classes were inserted inside
  TestBatchEditDryRun's body, so six pre-existing dry-run tests had silently
  become methods of TestEditOperationSearchResultTarget. They still ran, but
  `-k TestBatchEditDryRun` collected 5 instead of 11. Classes moved below them.

Documentation (all verified by execution):

- api.md's "Equivalent, spelled out" SearchResult example raised
  HashMismatchError as written — the second call reused the match the first one
  made stale. Commented out with the reason, matching SKILL.md's treatment.
- SKILL.md's "the paragraph argument is required for all edit methods"
  contradicted the SearchResult form this PR adds (and was already false for
  document-wide add_comment).
- api.md said a None target raises ValueError for the whole method family;
  add_comment raises CommentError, which is not a ValueError.
- api.md over-promised current_ref: it clears the hash check only, so a row can
  carry one and still have a target that moved. Says so now, including how a
  stale retry fails (loudly and atomically).
- quickstart.md piped an unchecked find_text() straight into replace(), the
  exact pattern api.md tells readers to avoid.
- quickstart.md and api.md's save section still named force_recreate=True as
  the only workspace recovery; both now mention discard_workspace().
- SKILL.md documents that exit code 0 does not always mean the code ran.

Consistency and dead code:

- Renamed the new helpers' `arg` parameter to `field`, matching
  _reject_control_chars(value, *, field, ctx).
- _ValidationOutcome is now constructed with keywords — its own docstring cites
  transposition as the reason it exists.
- _build_paragraph_info's `style_outlines=None` default was unreachable (both
  call sites pass _style_maps()[0], which is already {} when styles.xml is
  absent); made required.
- _stale_row returned a tuple whose first element no caller read.
- _style_maps' docstring notes the cached maps are handed out live, so callers
  must treat them as read-only.
- _resolve_search_target's docstring explains why it returns a plain tuple.
- Dropped the package's only RST literal-block marker.

Skipped, with reasons: setting `started` on any parent-matched traffic (both
proposed variants destroy the discard signal above — the abort publishes
status busy/idle with no execute_input); moving the defusedxml call out of
document.py (ooxml/unpack.py and ooxml/pack.py already parse directly); making
workspace_dir positional (keyword-only matches the newer APIs).

1408 passed, 2 skipped.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/api.md (1)

1173-1176: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the original operation when repairing stale refs.

These examples rebuild every stale row as replace("old", "new", ...); a stale delete or insert would become a different edit. They also apply immediately even though current_ref only refreshes the hash. Preserve the original operation fields (for example, with dataclasses.replace(..., paragraph=row.current_ref)), then dry-run the repaired batch before applying it.

  • docs/api.md#L1173-L1176: update the recovery snippet to retain the existing operation and revalidate before batch_edit.
  • docx_editor/document.py#L1366-L1371: make the API docstring example use the same action-preserving, revalidated flow.
🤖 Prompt for 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.

In `@docs/api.md` around lines 1173 - 1176, Update the recovery examples in
docs/api.md lines 1173-1176 and docx_editor/document.py lines 1366-1371 to
preserve each original operation’s action and fields while replacing only its
paragraph reference, then run the repaired operations through a second dry-run
before applying batch_edit. Use dataclasses.replace or the established
equivalent rather than rebuilding every stale row as replace.
🤖 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 `@docs/quickstart.md`:
- Around line 85-87: Update the quickstart guidance around find_text() and
add_comment() to correct the exception claim: state that the four edit methods
raise ValueError when given a None target, while add_comment() raises
CommentError for a None anchor.

---

Outside diff comments:
In `@docs/api.md`:
- Around line 1173-1176: Update the recovery examples in docs/api.md lines
1173-1176 and docx_editor/document.py lines 1366-1371 to preserve each original
operation’s action and fields while replacing only its paragraph reference, then
run the repaired operations through a second dry-run before applying batch_edit.
Use dataclasses.replace or the established equivalent rather than rebuilding
every stale row as replace.
🪄 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: 1abf16b5-6884-4997-90c5-1a4bd3a88eda

📥 Commits

Reviewing files that changed from the base of the PR and between 4720370 and 5503b86.

📒 Files selected for processing (9)
  • docs/api.md
  • docs/quickstart.md
  • docx_editor/document.py
  • docx_editor/session.py
  • docx_editor/track_changes.py
  • docx_editor/xml_editor.py
  • skills/docx/SKILL.md
  • tests/test_batch_edit.py
  • tests/test_session.py

Comment thread docs/quickstart.md Outdated
Comment on lines +85 to +87
Check the result first: `find_text()` returns `None` when there is no match, and
passing that `None` as a target raises `ValueError` rather than quietly doing
nothing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the add_comment(None, ...) exception claim.

add_comment() is included among the five methods above, but it raises CommentError, not ValueError, for a None anchor. Qualify this as applying to the four edit methods or document the exception split.

🤖 Prompt for 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.

In `@docs/quickstart.md` around lines 85 - 87, Update the quickstart guidance
around find_text() and add_comment() to correct the exception claim: state that
the four edit methods raise ValueError when given a None target, while
add_comment() raises CommentError for a None anchor.

Second review pass caught the previous commit overstating its own fix: the
SKILL.md bullet claimed a discarded request shows up as "started": false in
eval's JSON envelope. It does not. eval_code sees result=None and raised
"eval transport failed: kernel returned no result for the eval wrapper" —
exit 1, no envelope, and a message that sends the caller debugging the
transport instead of re-sending the command.

- eval_code now distinguishes the two: with started=False it says the kernel
  discarded the request without running it; the transport message is kept for
  the case it was written for (the wrapper ran and still returned nothing).
- SKILL.md describes both CLI paths as they actually behave — exec exits 0 with
  a stderr warning, eval exits 1 with the discard message and no envelope.
- quickstart.md mirrors api.md's caveat that add_comment raises CommentError,
  not ValueError, for a None target.
- test_eval_transport_no_result_raises now builds its ExecResult with
  started=True, which is what makes that fixture a transport bug rather than a
  discard; it caught this branch immediately, so its intent is worth pinning.

Exit codes and the envelope contract are unchanged.
@pablospe
pablospe merged commit b5a4bfc into main Jul 30, 2026
10 checks passed
@pablospe
pablospe deleted the task/f2ab793f-Ergonomics-grab-bag--SearchRes branch August 1, 2026 05:47
pablospe added a commit that referenced this pull request Aug 30, 2026
The backlog lived in an untracked ISSUES.md that existed only on one
machine. It is now ROADMAP.md: current status, the design commitments
every future item inherits, an ordered Next list (#73 decomposition of
track_changes.py first), demand-gated Later items, the deliberate
refusals, and a per-release Shipped list generated from the old Done
entries. Item numbers are unchanged; PR titles and docs now cite
(ROADMAP.md #N).
pablospe added a commit that referenced this pull request Aug 30, 2026
…f modules (ROADMAP.md #73) (#83)

* refactor: split track_changes into a package with models, dom and diff modules (ROADMAP.md #73)

* test: add scripts/check_pure_move.py, the pure-move gate for ROADMAP.md #73
pablospe added a commit that referenced this pull request Aug 30, 2026
…DMAP.md #73) (#84)

* refactor: move the registry methods into a RevisionManager mixin (ROADMAP.md #73)

* ci: exclude track_changes/base.py from coverage (stub bodies are overridden by construction)

* test: cover the registry edge cases moved in ROADMAP.md #73 step 2
pablospe added a commit that referenced this pull request Aug 30, 2026
…AP.md #73) (#85)

* refactor: move the locate methods into a RevisionManager mixin (ROADMAP.md #73)

* test: cover the locate edge cases moved in ROADMAP.md #73 step 3
pablospe added a commit that referenced this pull request Aug 30, 2026
…ixin (ROADMAP.md #73) (#87)

* refactor: move the batch and rewrite methods into a RevisionManager mixin (ROADMAP.md #73)

Cut batch_edit, _resolve_action_target, _apply_single_edit, validate_batch,
_validate_single, batch_rewrite, rewrite_paragraph, _rewrite_paragraph_inner,
_find_match_at_position and _rewrite_insert_at out of manager.py into a new
_BatchMixin in batch.py, byte for byte. base.py gains the twenty stubs the
cluster calls across mixin boundaries (four registry, two locate, fourteen
still on RevisionManager) so ty resolves them without an override.

* test: cover the batch edge cases moved in ROADMAP.md #73 step 4

batch_rewrite must surface the original apply error when the rollback
re-parse itself fails, and _find_match_at_position must reject a hit past
the expected offset and tolerate an empty search.
pablospe added a commit that referenced this pull request Aug 30, 2026
…(ROADMAP.md #73) (#89)

* refactor: move the replace-site methods into a RevisionManager mixin (ROADMAP.md #73)

Cut the replace cluster out of manager.py into replace.py as
_ReplaceMixin: replace_text, _get_run_info, _get_node_text,
_set_node_text, _build_cross_boundary_parts, _majority_rPr,
_classify_segments, _replace_across_nodes, _replace_same_context,
_replace_within_own_ins, _replace_mixed_state, _find_ancestor,
_owns_ins and _ins_identity_attrs. The bodies are byte-identical;
manager.py only loses the two imports that became unused
(_trim_replace_affixes, _node_depth) and gains the mixin base.

base.py grows six stubs for the cross-cluster callees the moved code
reaches through self: _locate_document_wide, _group_positions_by_ins,
_delete_from_ins_positions, _split_ins_after_child,
_delete_regular_segment and _split_replace.

scripts/check_pure_move.py --base main: 3/3 ok.

* test: cover the replace edge cases moved in ROADMAP.md #73 step 5

Eight tests for the defensive branches of _ReplaceMixin that nothing
exercised before: _majority_rPr on empty parts, _set_node_text keeping
a non-text child, a run-less <w:t> in _build_cross_boundary_parts and
_replace_mixed_state, a detached run in _replace_same_context, an
own-insertion-only match in _replace_mixed_state, and the two editor
stubs that hand back no <w:ins>.

replace.py: 100% lines and branches under the replace-related test set.
pablospe added a commit that referenced this pull request Aug 30, 2026
pablospe added a commit that referenced this pull request Aug 30, 2026
…ROADMAP.md #73) (#90)

* refactor: move the delete-site methods into a RevisionManager mixin (ROADMAP.md #73)

Cut the delete cluster out of manager.py into delete.py as
_DeleteMixin: suggest_deletion, _group_positions_by_ins,
_delete_from_ins_positions, _split_ins_after_child,
_split_foreign_ins_at, _insert_own_ins_within_foreign_ins,
_remove_from_insertion, _remove_wt_and_maybe_run,
_content_nodes_in_ancestor, _delete_regular_segment,
_delete_across_nodes, _delete_same_context and _delete_mixed_state.
The bodies are byte-identical; manager.py only loses the four imports
that became unused (OrderedDict, TextPosition, is_tab_node,
_validate_edit_target) and gains the mixin base.

base.py grows four stubs for the cross-cluster callees the moved code
reaches through self: _build_cross_boundary_parts, _classify_segments,
_ins_identity_attrs and _adopt_split_tail.

scripts/check_pure_move.py --base main: 3/3 ok.

* test: cover the delete edge cases moved in ROADMAP.md #73 step 6

Eight tests for the defensive branches of _DeleteMixin that nothing
exercised before: _remove_from_insertion on a detached <w:ins> (whole
insertion and whole-node matches) and on a run-less <w:t> middle
split, _remove_wt_and_maybe_run on an unattached <w:t>,
_delete_regular_segment skipping a run-less <w:t> and tolerating a run
the editor already detached, and _delete_same_context with a detached
matched run and with an editor that hands back no <w:del>.

delete.py: 100% lines and branches under the delete-related test set.
pablospe added a commit that referenced this pull request Aug 30, 2026
…Manager mixin (ROADMAP.md #73) (#92)

* refactor: move the insert and paragraph-split methods into a RevisionManager mixin (ROADMAP.md #73)

* test: cover the insert edge cases moved in ROADMAP.md #73 step 7
pablospe added a commit that referenced this pull request Aug 30, 2026
pablospe added a commit that referenced this pull request Aug 30, 2026
…ompleting the package split (ROADMAP.md #73) (#94)

* refactor: move the resolution methods into a RevisionManager mixin (ROADMAP.md #73)

Completes the track_changes package split: manager.py keeps only the
RevisionManager class header and __init__, and the 21 accept/reject
methods move verbatim into _ResolutionMixin. base.py gains six stubs for
the cross-cluster callees the cluster reaches (_find_revision_element,
list_revisions, _unhandled_elements, group_revisions, changeset_groups,
_paragraph_mark_rPr).

scripts/check_pure_move.py --base main: 3/3 ok.

* test: cover rejecting a w:pPrChange placed outside w:pPr (ROADMAP.md #73)

The schema-invalid-placement guard in _restore_paragraph_properties had
no test: a record whose parent is not w:pPr is dropped rather than
restored into whatever parent it has. Takes resolution.py to 100%
statement and branch coverage.
pablospe added a commit that referenced this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant