Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ These invariants are NOT SUGGESTIONS. These MUST be followed.
- **Comments are never anything but hints.** Every `<!-- … -->` comment `read` writes into Markdown — locators (`<!-- pN -->`) and the structural visibility annotations (`docx:section` — rendered at the section's START with an `applies-to="pX..pY (below)"` scope on deviating sections so the "which side of the boundary?" off-by-one is explicit, `docx:page`, `docx:table` (widths/borders + the `tables format` properties align/style/cell-margins/repeat-header/row-heights), `docx:cell` merge/shading/vAlign/halign/borders/margins, `docx:track-changes on|off pending=N` ALWAYS at the head (the one hint that states its default too — weak agents can't reliably read "no hint" as "off," and a wrong tracking guess is high-cost, so it's stated outright; `pending=N` likewise always, `pending=0` included, since the default accepted view hides a pending deletion outright, plus a compact `(hdr0: 1, fn2: 1)` when some pending changes live outside the body), `docx:header`/`docx:footer` for page headers/footers (content in a `text` attr, fields as `{page}`/`{date}`/… tokens, a `pending="tcN …"` attr naming the part's tracked changes in every view, and the text following the view — CriticMarkup under `--current`, safe inside the comment because `htmlAttr` escapes every `>` so a deletion's `--` can never form `-->` — authored via `docx headers`/`docx footers`; a UNIFORM marginal rides the head, a marginal that DIFFERS by section renders at that section's START, next to the content it governs), `docx:textbox tbxN anchor="pM"` … `docx:textbox-end tbxN` bracketing a text box's story, printed right after the paragraph that anchors it (the story's own paragraphs carry bare `tbxN:pK` locators), `docx:layout` flagging tab-aligned content that wraps in render — inside a multi-column section, OR a line whose trailing content rides a right-edge LEFT tab so a long value overflows the margin (the résumé `San`/`Francisco` split); the latter is also consolidated into ONE top-of-read `fix-all` summary naming the single-call cure `edit --at pN-pM --tabs right` (a render-only break Markdown can't show), and future image/style hints, all via `formatNote` in `cli/read/annotations.ts`) — is a READ-TIME VISIBILITY HINT. **Naming rule: a BARE comment is a locator (an address you pass to `--at`: `<!-- p0 -->`, `<!-- t0:r0c0:p0 -->`; a plain empty cell uses `<!-- t0:r0c0 -->` so it can be filled directly); a `docx:TYPE` comment is docx-cli METADATA** (structure/formatting). Metadata never rides a bare locator — it's its own `docx:` annotation, which may carry the relevant locator as a bare leading token (`<!-- docx:cell t0:r0c0 gridSpan="2" -->`). So `docx:` is greppable as "everything docx-cli added beyond addressing," and locators stay short. The Markdown importer DROPS them all (`walker.tsx`'s `case "html"` returns `[]`; the inline walker drops inline comments); they NEVER drive reconstruction or carry data through `read → create`. A comment can't silently corrupt the round-trip or smuggle structure back — what an agent sees is a hint, nothing more. The lossless view is `read --ast`; in-place `edit` preserves structure; the authoring verbs (`docx sections`, `docx tables …`) change it. Emit hints **deviation-only** (only what differs from the document default — even columns, default geometry, `single` borders, `align=left` emit nothing), with TWO deliberate exceptions that always emit even at their default: `docx:section` (every section gets a marker so the `sN` is always addressable) and `docx:track-changes` (states `off` and `pending=0` too, so a weak agent never infers state from a missing hint). Run FORMATTING is the one thing that's Markdown-representable and DOES round-trip, but it rides HTML ELEMENTS (`<span>`/`<mark>`/`<sup>`/`<u>`, via `gatherHtmlSpans`), NOT comments. Even `<!-- docx:base -->` (the dominant font/size note) is a hint: `read` omits the baseline from runs and shows the note so an agent can match it, but the importer drops it — a full `read → create` rebuild falls back to the template docDefaults (Calibri 11pt) for the dominant font/size. The footnote-shaped read annotations are hints too: the `[^tcN]` (`--current`) and `[^cN]` (`--comments`) labels and their definition lines are DROPPED on import (`remarkReadHintLabels` in `core/markdown/read-hint-labels.ts`; real notes always read as `[^fnN]`/`[^enN]`), so a pasted `--current` line behaves exactly like its label-free CriticMarkup. No comment, anywhere, is parse-back.
- **Everything we parse MUST be represented in the AST**. Markdown is always either a subset or equal to what is in the AST, never less than it. `RUN_BEARING_WRAPPER_TAGS` in `src/core/parser/run-ops.ts` is the AST↔XML offset bridge; every offset-aware walker reads it. See [src/core](src/core/CLAUDE.md).
- **`<mc:AlternateContent>` is transparent at EVERY level, and text boxes are first-class.** ECMA-376 Part 3 lets a producer wrap a block, a paragraph's runs, or a run's children in a markup-compatibility wrapper and nest wrappers inside branches; Word writes every modern shape/text box that way (`wps` under `<mc:Choice>`, VML under `<mc:Fallback>`, SAME story in both). Every walker resolves it through `alternateContentBranch` in `src/core/mc.ts` (first Choice, else Fallback) — the reader at block/paragraph/run level, the offset walkers via `wrapperContent(node)` in `parser/run-ops.ts` (NEVER `node.children` for a wrapper — that is what keeps a multi-Choice wrapper counted exactly once), and the text-box collector while descending a shape. Dropping the wrapper was issue #4: `replace --all` reported 2 and exited 0 while a third "Acme" sat in a text box a human reads first. A `<w:txbxContent>` story is a block container like a cell (`TextBoxRun` on the anchor paragraph, `tbxN:pK` ids, `blockReferences` parents inside the Choice copy), so `find`/`replace`/`edit`/`insert`/`delete`/`comments` reach it by default; `Document.save` (and the raw gate, BEFORE validating) mirrors each Choice story onto its Fallback twin (`syncTextBoxFallbacks`, post-order so nested boxes sync inner-first) — verified against Word for Mac 365: Word regenerates a box's Fallback when IT edits the box and leaves an untouched box's Fallback verbatim (a stale one stays stale forever), so mirroring on our writes is exactly its behavior. `wc` counts what `read` shows — text boxes included, everywhere. **Word discards comments and footnotes/endnotes inside a text box on its next save** (verified: the markers vanish from the story and the comment from comments.xml, regardless of the Fallback), so `comments add` (`--at` AND `--anchor`) and `footnotes`/`endnotes add` REFUSE a `tbxN` target with `UNSUPPORTED` + a pointer to the anchor paragraph; tracked changes and hyperlinks inside a box survive Word and are allowed. The box rides its anchor RUN: EVERY whole-paragraph `edit` lifts object-bearing runs (drawing/pict/object/AlternateContent) out and puts them back — `--text` around the token diff (`extractObjectRuns`), `--markdown`/`--runs` around the node replace (`liftObjectRuns` → `reattachObjectRuns` in `track-changes/replace.tsx`, untracked carriers even under tracking; a `--markdown` that re-states an image via `![alt](sha256.ext)` drops the lifted DRAWING so `img0` isn't duplicated, while a text box always returns), a `replace` on the anchor run's text keeps a trailing zero-width shape (`sliceRun`), and a tracked delete of the anchor wraps the run in `<w:del>` WITHOUT rewriting the story's `<w:t>` (`TextBoxRun.trackedChange` hides it in the accepted view; `iterateBlocks({ view })`). An UNTRACKED removal of the anchor paragraph (`delete --at pN`, `edit --at pN --text ""`) re-anchors the box on the neighboring paragraph — previous, else next — via `relocateObjectRuns` in `track-changes/replace.tsx`; deleting the invoice's Notes line used to take the stamp box with it, silently.
- **A field is atomic structure** (issue #42). A complex field is three zero-width `<w:fldChar>` markers (begin/separate/end, possibly in different paragraphs) plus `<w:instrText>`; `src/core/fields/parse.ts` pairs them per story with a stack in the span walkers' offset space. A mutation carries a field through intact, removes it whole, or refuses before writing: whole-paragraph rebuilds (`Edit.paragraph`/`Edit.range`) refuse a paragraph holding one (`FIELD_IN_PARAGRAPH`), span walkers refuse a span that CUTS one (`fieldSpanRefusal`) and take a COVERED one's markers out first (`removeCoveredFields` — a marker on a span edge otherwise stays behind), paragraph removal refuses to take one end of a multi-paragraph field (`fieldRemovalRefusal`, judged against a batch's whole removal set), a tracked deletion writes `<w:delInstrText>` (reject renames it back), and `validate` reports unbalanced markers. Carrying fields THROUGH a whole-paragraph edit (and the AST/read model) is #41 / #42 step 2 — until then the refusal is the contract. Never add an exit-0 path that drops a `fldChar`.
- **Every element must have stable positional ids for locators** (`p0`, `t0`, `c0`, `img0`, `link0`, `tc0`, `hdr0`/`ftr0` for page headers/footers, `tbx0` for a text box — its story chains like a cell: `tbx0:p1`, `tbx0:t0:r0c0:p0`). Block ids shift after structural edits. Re-read between non-trivial mutations. To not force agents to re-read on every modification, we create batch versions of any editing call such that the already made locators can be used.
- **Locators are the backbone of the system.** Agents use these to reliably identify elements, ranges (both bounded and one-side bounded). If you're introducing a new concept, it is CRITICAL that these work and work well.
- **The CLI surface should be thin.** The CLI surface is concerned with parsing user input. The core is where the actual domain logic happens.
Expand Down
4 changes: 3 additions & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,14 @@ The CLI is built for non-interactive agents. **Exit code is the success signal**
| `0` | success | — |
| `2` | usage / bad locator | `USAGE`, `INVALID_LOCATOR`, `INVALID_XML` |
| `3` | addressed thing not found | `FILE_NOT_FOUND`, `PART_NOT_FOUND`, `BLOCK_NOT_FOUND`, `COMMENT_NOT_FOUND`, `IMAGE_NOT_FOUND`, `HYPERLINK_NOT_FOUND`, `TRACKED_CHANGE_NOT_FOUND`, `MATCH_NOT_FOUND` |
| `1` | general failure | `NOT_A_ZIP`, `TRACKED_CHANGE_CONFLICT`, `TABLE_STRUCTURE`, `VALIDATION_FAILED`, `IMAGE_SOURCE`, `RENDER_ENGINE`, `RENDER_FAILED`, `UNSUPPORTED`, `UNHANDLED` |
| `1` | general failure | `NOT_A_ZIP`, `TRACKED_CHANGE_CONFLICT`, `TABLE_STRUCTURE`, `VALIDATION_FAILED`, `IMAGE_SOURCE`, `RENDER_ENGINE`, `RENDER_FAILED`, `UNSUPPORTED`, `FIELD_IN_PARAGRAPH`, `UNHANDLED` |

**Errors** print `{code, error, hint?}` JSON to stdout with a nonzero exit — note there is **no `ok` field**; the exit code plus `code` are the unambiguous signal.

**An equation is its `$…$` token in text.** Wherever text is matched or addressed — `find`/`replace` (single, `--batch`, cross-paragraph), `edit --at pN:S-E` spans, a whole-paragraph `edit --text`/markdown, `comments add --anchor`/`--at`, `hyperlinks add --at`, `wc pN:S-E` — an equation occupies exactly the token `read` prints for it: `$latex$` inline, `$$latex$$` for a display block (`Energy is $E=mc^2$ in physics.` puts `physics` at `p0:22-29`). An equation a view hides (a pending deletion in the default accepted view) occupies nothing. What a change does to an equation follows from how it meets the token: covering the WHOLE token deletes the equation (a `<w:del>` when tracked); staying strictly INSIDE it rewrites its LaTeX (`docx replace FILE 'mc^2' 'mc^3'`; tracked, an old/new equation pair like `equations edit --track`); CUTTING a `$` delimiter is refused with `UNSUPPORTED` and nothing is written. The replacement text is always literal — `$5` stays `$5`. A whole-paragraph `edit --text` (or a word-only markdown rewrite) is diffed against that token view: a token left in place keeps the original equation object untouched, a token left out deletes it, a `$…$` span where one stood with different LaTeX edits it, and a rewrite that can't be read one way (two spans where one equation was, an inline token turned `$$…$$`, a token moved to where another equation was) is refused. A comment range opening or closing inside a token snaps outward to the whole equation; a link may hold a whole equation, never part of one. `--runs`, and a `--text` that must rebuild from literal text (run-format flags, `--no-formatting`, or a tracked paragraph with pending revisions), refuse a paragraph holding an equation. `--dry-run` refuses exactly what the real call would.

**A field is one unit.** A Word field — a cross-reference, page number, TOC, merge field — is markup around the text `read` shows (its cached result: `[1, 3, 4]` in a citation is three `REF` fields whose results are `1`, `3`, `4]`). No mutation silently flattens one to plain text or leaves half of one: a whole-paragraph rebuild (`edit --at pN` with markdown, `--text`, `--runs`, or `code edit`; a `pN-pM` range edit; single or `--batch`, tracked or not) of a paragraph holding a field is refused with `FIELD_IN_PARAGRAPH`, naming the fields, and nothing is written — change the text around them with a span instead (`docx find` → `edit --at pN:S-E`, or `docx replace`). A span beside a field keeps it; a span inside its result edits the cached text and keeps the field; a span covering the whole field plus text on either side deletes the field whole (tracked: one `<w:del>` per marker with `<w:delInstrText>`, so reject restores it); a span covering only part of it is refused with `UNSUPPORTED`. Removing paragraphs (`delete --at pN`/`pN-pM`/`--batch`, `edit --at pN --text ""`) that hold one end of a field spanning paragraphs but not the other is refused too; removing every paragraph it spans takes it whole. `docx validate` reports an unbalanced field (an orphan `fldChar`, e.g. from an older build) as an error.

**`$` and the shell.** Bash expands `$` inside double quotes: `"Energy is $E=mc^2$ now"` reaches the CLI as `Energy is =mc^2$ now`, which reads as "delete the equation." `edit` (text and markdown, single and `--batch`) and `replace` refuse an edit that drops an equation AND carries the shell's fingerprint — a stray unpaired `$`, or the token as bash leaves it (`=mc^2$`, a PID where `$$` was) — with `USAGE`, and the hint shows the line to send instead. Single-quote `$`-bearing arguments, or put them in a `--batch` file (`{"at":"p0","markdown":"Energy is $E=mc^2$ now"}`), written with a QUOTED heredoc (`cat > edits.jsonl <<'EOF'` … `EOF`) — an unquoted heredoc expands `$` too.

**The `ok` field appears in exactly one place: the `--verbose` success ack** (`{ok:true, operation, path, …}`). Without `-v`, success output is shaped for the next command:
Expand Down
9 changes: 9 additions & 0 deletions src/cli/delete/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
LocatorResolveError,
type XmlNode,
} from "@core";
import { fieldRemovalRefusal } from "@core/fields";
import { removeParagraphLine } from "@core/track-changes/replace";
import { readJsonlObjects } from "../parse-helpers";
import {
Expand Down Expand Up @@ -123,6 +124,14 @@ export async function runDeleteBatch(
refs.push({ locator: at, ref: blockRef });
}

// Judged against the whole set, so a batch removing every paragraph a
// field spans (both of its ends) goes through.
const refusal = fieldRemovalRefusal(
document.body,
refs.map((entry) => entry.ref.node),
);
if (refusal) return fail(refusal.code, refusal.message, refusal.hint);

if (dryRun) {
await respond({
operation: "delete",
Expand Down
9 changes: 9 additions & 0 deletions src/cli/delete/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
removeInlineSectPr,
} from "@core";
import { findContainingParagraph } from "@core/comments";
import { fieldRemovalRefusal } from "@core/fields";
import { XmlNode } from "@core/parser";
import {
applyTrackedRangeDelete,
Expand Down Expand Up @@ -154,6 +155,12 @@ async function commitRangeDelete(
const rangeRef = await resolveBlockRangeOrFail(document, opts.locator);
if (typeof rangeRef === "number") return rangeRef;

const refusal = fieldRemovalRefusal(
document.body,
rangeRef.parent.slice(rangeRef.startIndex, rangeRef.endIndex + 1),
);
if (refusal) return fail(refusal.code, refusal.message, refusal.hint);

const tracked = resolveTracked(document, opts.trackFlag);
if (tracked) {
try {
Expand Down Expand Up @@ -255,6 +262,8 @@ async function commitBlockDelete(
"Block reference is stale (parent does not contain it)",
);
}
const refusal = fieldRemovalRefusal(document.body, [blockRef.node]);
if (refusal) return fail(refusal.code, refusal.message, refusal.hint);

if (opts.dryRun) return respondDryRun(opts);

Expand Down
13 changes: 13 additions & 0 deletions src/cli/edit/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
resolveCellParagraphReference,
} from "@core";
import type { ParagraphOptions } from "@core/blocks";
import { fieldRemovalRefusal } from "@core/fields";
import type { XmlNode } from "@core/parser";
import { ensureCellEndsWithParagraph, type Grid } from "@core/table";
import { removeParagraphLine } from "@core/track-changes/replace";
Expand Down Expand Up @@ -128,6 +129,14 @@ export async function runEditBatch(
throw error;
}

const fieldRefusal = fieldRemovalRefusal(
document.body,
resolved.filter((entry) => entry.removesLine).map((entry) => entry.node),
);
if (fieldRefusal) {
return fail(fieldRefusal.code, fieldRefusal.message, fieldRefusal.hint);
}

// Entries apply in memory even on --dry-run (only the save is skipped), so
// the preview refuses exactly what the real call would — as `replace
// --batch` already does.
Expand Down Expand Up @@ -285,6 +294,9 @@ type ResolvedEntry = {
paragraphLocator: string;
/** Performs the mutation via the `Edit` lens. Throws `EditError`. */
apply: () => void;
/** A line removal (empty `text` / `delete: true`) — checked as one set
* against fields that span paragraphs before anything applies. */
removesLine?: true;
/** Filled by `apply` for a whole-paragraph content edit: the other authors
* whose pending revisions it resolved (warned after the save, issue #17). */
resolvedAuthors: string[];
Expand Down Expand Up @@ -453,6 +465,7 @@ async function resolveEntry(
...(cellParent ? { cellParent } : {}),
paragraphLocator,
apply,
removesLine: true,
resolvedAuthors: [],
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/cli/edit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type XmlNode,
} from "@core";
import type { ParagraphOptions, TabStop } from "@core/blocks";
import { fieldRemovalRefusal } from "@core/fields";
import { ensureCellEndsWithParagraph } from "@core/table";
import { removeParagraphLine } from "@core/track-changes/replace";
import type { parseArgs } from "util";
Expand Down Expand Up @@ -482,6 +483,8 @@ async function commitBlockEdit(
// Empty `--text` on a whole paragraph removes the line (cell-safe — a
// table cell's last paragraph is blanked, not deleted). Same path as
// `docx delete --at pN`.
const refusal = fieldRemovalRefusal(document.body, [blockRef.node]);
if (refusal) return fail(refusal.code, refusal.message, refusal.hint);
removeParagraphLine(document, blockRef, {
track,
author: opts.authorFlag,
Expand Down
Loading
Loading