* feat(desktop): read XML with a plain DOCTYPE, and check an element's whole text - Both readers accept a DOCTYPE with only a name and an optional PUBLIC or SYSTEM identifier, record it as the view's doctype, and never fetch the DTD or expand entities beyond XML's five; an internal subset, an external entity and a parameter entity stay refused alike. - text_equals and text_contains compare the concatenated text of the element (or text node or attribute) a pointer reaches, as XPath string() does, with optional normalize_whitespace; both evaluators agree, and a text check on a truncated element is undecided. - Whitespace-only text between elements is dropped only when the element holds no other text, so text split into runs keeps its spaces. - The draft suggests text_equals for an element whose text changed. So an AbiWord .abw reads as XML, and its paragraph's text is checked whole however AbiWord splits it into <c> runs, as Writer's spans are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): draft whole-text checks on the paragraph, read only text as text, and skip asctime stamps - A changed value is read at both ends, so text that became a run still marks its paragraph, and the drafted text_equals moves up out of mixed content to the nearest ancestor named by an attribute, such as AbiWord's p by its xid. - Text checks accept only a text node or an attribute value; element names and doctype fields fail, in both evaluators. - isTimestamp also recognises asctime dates (Www Mmm dd hh:mm:ss yyyy). - XML files are drafted with the keyed diff the archive entries use, so no suggestion reaches an element by position; the shared logic moves to appTaskDraftNamed.ts. - Text-check tests assert facts, not whole sentences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): keep checks on a changed attribute inside a paragraph whose text check replaces its runs' A drafted whole-text check on a paragraph hid every change inside it, so a link whose label and href both changed drafted only the paragraph's text and the new destination went unchecked. The text check now covers text and runs added or removed whole; a changed name or attribute of an element both versions hold keeps its own check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): read an attribute called name or attributes as an attribute when the draft keeps its check The owner of `.../attributes/name` was taken as `.../attributes`, so a changed `name` attribute inside a paragraph with a text check was dropped again. The owner is now read by segment, attributes first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): draft text checks from text in runs added whole, and name a text element by its own id before its container Text typed into a run the diff lists whole was never read, so the draft kept only a presence check on the run, which wrong text also passed. Such text now marks the nearest element both ends hold, or, when none can take the check, the added element's own text. The climb also names an element by an identifier of its own (id, xml:id, xid) before a larger container, so a headline under a long layer keeps a whole-text check that survives re-split runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): expect the whole-text check where #8988's pairing test meets text checks On this branch a changed element's text is drafted as `text_equals` on the element, so the identity pairing test now expects that check, under the same `[@role=body]` key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): keep checks for elements without text beside a text edit, and normalize DOCTYPE system id line endings A review found two gaps. - A paragraph's whole-text check covered every element added or removed inside it, including ones holding no text: an image frame, a line break. A solver who typed the text but never inserted the image passed. Such an element now keeps its own presence or absence check. - The Python reader kept a DOCTYPE system literal's CRLF where TypeScript normalizes it to LF. Both now report LF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): import textSyntaxFormat once in the state format tests main's mixed-case JSON suffix fix and this branch both added the import, and the merge kept both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
10 KiB
Verification
Run on 2026-11-05 on top of the namespaced XML views branch, on macOS with Node v22.23.1 and Python 3.11.14 (expat 2.7.4).
Gates
npm run typecheck,format:check,build:cachedthenknip,jscpd,docs:check,docs:build,verify:test-discovery,verify:architecture-guardsandverify:module-boundaries: passed.lintandlint:typedon the touched source and test files: no diagnostics; jscpd lists no clone in a touched file.npx vitest run src/domain/desktop src/application/desktop src/desktop src/domain/xmlDocument.test.tswith the eighttests/boundary/providers/desktop*files and the CLI boundary testscliStartupImports,desktopApplicationCli,cliInputSchemaRequiredandcliToolCoverage: 492 passed and 18 skipped (Linux-only cases).- The parity files
desktopXmlTextPython,desktopXmlPython,desktopTaskPython,desktopZipPythonanddesktopExportPython: 34 passed under each of Python 3.9.6 (expat 2.2.8), 3.10, 3.11, 3.12, 3.13 and 3.14.
Tests
Each new test fails on the code before this change, run from an export of that commit with only the new tests, fixtures and test support added: 31 failed and the 112 tests already there passed.
- AbiWord, in
desktopXmlTextPython.test.ts: a synthetic.abwin the form AbiWord 3.0 saves, with its AWML DOCTYPE, before and after the meeting time changed to3:30, which the edit split intocruns. Both readers give the samexmlview with the samedoctype; before this change it was text. Both evaluators give the same results and descriptions:text_equalsandtext_containson[name=p][@xid=3]fail before the edit and pass after it, whilevalue_equalson itschildren/0fails after it too. - Writer runs: a Writer
content.xmlderived from the real 24.2 save, its body paragraph split intoT1spans with a bare space between two of them, zipped into an.odt. Both readers give the same view, the space kept as mixed content;text_equals,text_containswithnormalize_whitespaceand a reflowedtext_equalsfail before and pass after in both evaluators, andchildren/0does not pass. - DOCTYPE: built documents both readers read alike (a name only,
SYSTEMwith[in its literal,PUBLICwith whitespace to normalize, empty literals, a prefixed name, a name that differs from the root's) and refuse alike (an empty internal subset, entity declarations with and without a reference, an external general entity, a parameter entity, an undeclared entity in content and in an attribute value with an external DTD, aPUBLICwithout a system literal, an illegal public identifier character, a DOCTYPE after the root, two DOCTYPEs). A DTD written to disk and named by itsfile:URL is not read: its entity stays undeclared and its default attribute absent, in both readers. - Whitespace: mixed content keeps the space between runs and element content drops formatting, in both readers; a no-break space, U+0085 and U+FEFF between elements are kept as text in both. Before, both dropped the no-break space, REA also U+FEFF and the exported module U+0085, so the two disagreed on those.
- Shared check cases both evaluators run: the whole text of a split paragraph, with nested elements, empty, a text node, an attribute value, exact and collapsed, equal and contained; an absent file, JSON and text files, a non-element value, a selector matching nothing, an element of a truncated view and a selector over it (undecided) beside a text node there (decided), an archive's XML entry, its JSON entry, its binary entry, a pointer to an entry's name, and a truncated entry.
- Parser: the
externalpolicy reads a DOCTYPE without an internal subset and refuses one with it,]in a literal included, anddeclaredDocumentTypereports the identifiers unquoted, the public one normalized. - Task file: the empty pointer, a pointer without
/, an empty or whitespace-onlytext_containsvalue, a time-like value and a non-booleannormalize_whitespaceare refused; the export refuses a text check into a text file. - Draft: an element's whole text by selector in an SVG, in an archive's
namespaced XML and in the AbiWord note, with nothing suggested inside the
paragraph, and no text check where only whitespace text changed; through
the application, a capture whose
.odtparagraph an edit split into runs draftstext_equalson it, and the export keeps a task with it, failing on the seed, whilevalue_equalson its first run fails at the end.
Review fixes
Run on 2026-11-05 with the same toolchain. Each fix has tests that fail on the reviewed commit, run from an export of it with only the changed tests and fixtures added (8 failed, 64 passed), and pass after it. The gates above pass again: lint and typed lint report nothing in the touched files, and the desktop set gives 497 passed and 18 skipped, the parity files 34 under each of Python 3.9 to 3.14.
- A drafted text check names the paragraph, not a run. A changed value is read
at both ends, so text an app wraps in a run (
<p>old</p>becoming<p><c>new</c></p>) still marks its paragraph; the check goes up from an element in mixed content, and from one named by its tag or position alone to the nearest ancestor named by an attribute, as AbiWord'spbyxid. Edits that replace text with a run, change the text of a paragraph's only run, or of a run among the paragraph's own text all drafttext_equalson[name=p][@xid=6], which passes on the edited save and on one splitting the text into other runs; in an archive, text turned into two spans drafts thetext:p. A link whose label andhrefboth change drafts the paragraph's text and keepsvalue_equalson the newhref. - Text checks read only an element, a text node or an attribute's value: an
element's
nameand thedoctype's fields fail in both evaluators, in a file or an archive entry, while an attribute's value in an entry passes. - The tests of text checks assert outcomes and the facts in a description (the pointer, the text found, the value compared) rather than its wording. Rewording the descriptions in both evaluators passes them; showing the value uncollapsed fails them.
- The draft leaves out times in C's
asctimeform, as AbiWord stampsabiword.date_last_changed, which the task parser also refuses, and drafts an XML file as an archive's entries are: by selector, never through an element by position, and without whitespace text between elements. For the AbiWord note it suggests the paragraph's text first and no save time;history'sversion,edit-timeandlast-savedremain, named.
Second review fixes
Run on 2026-11-05 with the same toolchain. The new draft tests fail on the
reviewed commit, run with only the changed test file (6 failed, 23 passed),
and pass after it. npm run typecheck, docs:check, and lint and format on
the touched files pass; the desktop set (src/domain/desktop,
src/domain/xmlDocument.test.ts and the tests/boundary/providers/desktop*
files) gives 329 passed, and src/application/desktop with src/desktop
141 passed and 18 skipped. The drafts for the Inkscape, AbiWord, Writer and
FreeCAD fixtures are the same as on the reviewed commit.
- A run added or removed whole marks its paragraph, the nearest ancestor both
ends hold, by the text anywhere in it.
Meeting atgaining<c>3:30</c>draftstext_equalsMeeting at 3:30on[name=p][@xid=6]and no check that the run is there: a save splitting the text into other runs passes it and13:30in the run fails it. So does a bold run added to a paragraph made only of runs, though itspropsname it. Removing a run drafts the paragraph's remaining text instead of the run's absence.<p>Hello</p>gaining<span> world</span>, orworldin abinside the span, draftsHello worldon the paragraph, in a body and as the document's root, whichWRONGin the span fails. - A new paragraph
p[@xid=7]drafts its section's text where the section is short enough to check, and its own text where it is not, instead of a check that it is there; other words in it and the seed fail both. - An element the diff names by its tag alone, and an identifier of its own
names, takes the check ahead of the ancestor named by another attribute: an
SVG headline, the only
textin a layer, draftstext_equalson[name=text][@id=headline]rather than the layer, thetext'sidchosen over itstspan's, and Inkscape's own save keeps its check on thetext, which the diff already names by itsid. Another attribute names it only where the layer cannot take the check: with onlyxml:space, the check stays on a short layer, and on thetextbyxml:spacepast 2,000 characters, where it had fallen back tovalue_equalson thetspan's first text node. A save splitting the headline across twotspans passes it and the old headline fails it. When the edit also changed theid, no text check is drafted.
Third review fixes
Run on 2026-11-06 with the same toolchain. Each new case fails on the
reviewed commit and passes after it. The desktop set gives 873 passed, with
only desktopExportSqliteMetadata.test.ts failing, as it does on main in
this environment; the XML parity files give 16 passed under each of Python 3.9
to 3.14. The drafts for the Inkscape, AbiWord, Writer and FreeCAD fixtures,
as files and as archive entries, are the same as on the reviewed commit.
- A paragraph's text check no longer stands in for an element holding no text
that an edit adds or removes in it. In an archive,
HellobecomingHello therewith adraw:frameholding an image drafts the paragraph's text and the frame's presence, and the frame removed drafts its absence; in an AbiWord paragraph, animageadded or abrremoved beside the text edit does the same. A save with the new text alone fails the presence or absence check, and a run holding text added in the same edit still gets no check of its own. - The exported module reports a DOCTYPE's system identifier with each line
ending a line feed, as REA's parser does:
"x\r\ny\rz\n"reads asx\ny\nz\nin both, whereexpathad passed it as written.