feat(parser): detect asterisk-row note-delimiter convention (DOCX)#461
Conversation
…yNoteRoles) Format-agnostic leaf util for asterisk-rule-delimited "NOTE TO SPECIFIER" regions, reusable by any parser (DOCX now, text/PDF in #294). isRuleRow gates on asterisk-only rows of 5+ (dashes/equals/bullets stay on the existing isDecorationSeparator path in heuristics.ts). classifyNoteRoles is a total, index-aligned single-pass toggle over rule rows, with headings force-closing an open region as a documented safety break. Two KNOWN AMBIGUITY cases (unpaired opener closed by a heading vs. swallowed to end-of-stream) are pinned as tests rather than silently resolved. Part of #292 (note-delimiter parsing, phase 2/4). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eNoteRoles) Bridges the format-agnostic classifyNoteRoles (src/lib/note-delimiters.ts) into the DOCX parser: computeNoteRoles maps DocxParagraph[] to NoteScanItem[] via a text-pattern-only heading gate (isPartHeading OR matchTextSignal → part/article), kept as a sibling of heuristics.ts so its existing isDecorationSeparator suite stays untouched. Part of #292 (Phase 2 of 4: asterisk-rule note regions). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Marks a rule-row delimiter paragraph (e.g. "*****") for an asterisk-rule-delimited note region (#292). Distinct in kind from isVanish/isNote: those govern how a RETAINED node renders (hidden vs. shown-as-[NOTE]); suppressed means the paragraph produces no SpecNode at all — buildTree will drop it before tree assembly (wired in a follow-up commit). Purely additive/optional: no existing construction site needs updating. Boundary invariant tests (types.test.ts) pin the field's contract (absent by default, orthogonal to isVanish/isNote) and confirm no ripple into NodeType, StyleNodeType, or ParseWarningType — the field addition surfaced only as a tsc excess-property RED (vitest transpiles via esbuild and does not type-check), confirmed GREEN after the change. Part of #292 (Phase 2 of 4: asterisk-rule note regions). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Precompute computeNoteRoles once per document in classifyParagraphs and thread the per-paragraph role through classifyOne: a 'rule' role marks the paragraph suppressed (no SpecNode at all — buildTree drops it before tree assembly), and 'note' extends the existing isNoteParagraph check so prose enclosed by a paired rule-row region renders as [NOTE] even when no banner/style signal fires. buildTree's content filter now excludes suppressed paragraphs alongside the existing empty-paragraph drop. Extracted the Signal 1/2/4/5 hit-collection cascade into a new collectSignalHits helper — adding the role guard clauses pushed classifyOne's cyclomatic complexity to 11, over the enforced budget of 10; the extraction brings it back to 7 with no behavior change. Tests pin: paired rule rows suppress themselves and note their enclosed prose; an unpaired opener force-closed by a PART/article heading leaves the heading on normal 5-signal inference; dash/equals decoration rules and the "****** [OR] ******" case are unaffected (isRuleRow is asterisk-only); a 4-asterisk run stays below isRuleRow's 5-asterisk minimum and is unsuppressed. Part of #292. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ocx (#292) Adds a makeDocx()-based E2E test exercising the full parseDocx pipeline (extract → numbering → styles → document → classify → tree), not just the classifyParagraphs/buildTree unit boundary already covered by inference-notes.test.ts: a PART heading, a paired rule-row delimiter enclosing prose, then ordinary trailing content. Pins three invariants — no bare 5-or-more-asterisk text survives anywhere in the tree, the enclosed prose is type 'note', and content after the closing rule row resumes as type 'continuation'. Verified RED against the pre-wiring inference.ts (da0c465~1) before restoring the wired implementation, confirming the test exercises real end-to-end behavior rather than asserting a tautology. Part of #292 (Phase 2 of 4: asterisk-rule note regions). Task 6/8. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…corpus (#292) Task 7/8 of #292 (Phase 2 of 4): integration + fixture A/B verification for the note-role wiring landed in da0c465/2c732f7/d2dd236/a30a40e. Adds a corpus-wide sweep (src/parser/docx/note-region-corpus.integration.test.ts) asserting no bare asterisk rule-row line (5+ asterisks, nothing else) survives into any real fixture's rendered markdown. The current ARCAT/MANUFACTURER_CPI corpus does not contain this pattern (verified by scanning every fixture's raw document.xml — zero paragraphs match), so this sweep does not itself RED/GREEN on today's corpus; it is a forward-looking guard, same as corpus-parts.integration.test.ts's structural sweep, that starts protecting the moment a rule-row fixture is added. The load-bearing RED/GREEN pin for the suppress+note-tag behavior remains index.test.ts's makeDocx() case (task 6, verified RED against pre-wiring inference.ts) plus the unit coverage in note-delimiters.test.ts / note-roles.test.ts / inference-notes.test.ts. Verification performed (no other source changes): - pnpm test:integration: full suite green (120 passed | 5 skipped, 1258 tests passed | 13 skipped). One NODE_ENV mistake on my part (sourcing .env clobbered the test-only rate-limit skip, producing 429s on parse.integration.test.ts) and one transient SEC-parser beforeAll hook timeout (unrelated module, passes in isolation and on rerun) were both ruled out as pre-existing/unrelated, not regressions from this change. - pnpm fixture:snapshot (after) + fixture:diff before after: 0/696 fixtures changed (30 DOCX + 666 SEC) — parts count, noteLeaks count, refs, and rendered markdown are byte-identical to the pre-wiring baseline for every fixture in the corpus. - corpus-parts.integration.test.ts (pre-existing 3-part-structure invariant): 34/34 passing against the wired code. - No openapi.yaml / AST / migration drift across the whole feature branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (14)
📝 WalkthroughWalkthroughAdds shared asterisk rule-row detection and note-role classification, wires it into DOCX inference and tree building, preserves suppressed paragraph state through nesting, and adds unit, end-to-end, regression, and corpus coverage. ChangesAsterisk note-region parsing
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant DOCX as DOCX paragraphs
participant Roles as computeNoteRoles
participant Classifier as classifyNoteRoles
participant Inference as classifyParagraphs
participant Tree as buildTree
DOCX->>Roles: provide paragraph text and metadata
Roles->>Classifier: classify NoteScanItem[]
Classifier-->>Roles: return rule/note/none roles
Roles-->>Inference: provide paragraph roles
Inference->>Tree: pass suppressed rule rows and note classifications
Tree-->>DOCX: emit notes and omit delimiter rows
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
…eadInSublists (#292) nestLeadInSublists filtered only blanks from its content view, but buildTree also drops suppressed asterisk rule-row delimiters. A rule row sitting between a lead-in and its Signal-4 restart run therefore defeated the same-tier adjacency check the same way an un-dropped blank would. Add `cp.suppressed !== true` to the filter so the two content views agree exactly. Also lands the accompanying #292 suppressed-field test updates that ride with this review fix: the rule-row-suppressed-regardless-of-vanish pin (inference-notes), the mergeProfileConflicts suppressed-preservation pins (numbering-profile-apply), and the ClassifiedParagraph.suppressed boundary-test refactor (types). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…headings (#292) Hand-authored DOCX (the #430 corpus) wall their specifier notes with asterisk rule rows that are frequently unpaired — a closing wall gets merged into the note prose ("…Waste Management *****") or dropped entirely. The naive open/close toggle then drifts out of phase and swallows real numbered structure (PART headings, articles, manufacturer lists) as notes: fixture A/B showed four files losing parts (3→2, 3→1, 3→0), with PRODUCTS/EXECUTION and manufacturer names becoming [NOTE] rows. Add a drift guard to the shared classifier: a structural (numbered) item enclosed by an open rule region is proof the pairing has drifted, so the asterisk convention disengages for the whole document — falling back to the per-paragraph style/banner classification that already handled these files correctly before the feature existed. The DOCX adapter supplies the numbering signal (NoteScanItem.isStructural); a bare "PRODUCTS" under a spec-shaped numId, invisible to the text-only heading gate, now trips it. Documents whose walls are cleanly paired (hidden-text-test, paring-fixes) still engage and suppress their rule rows. Fixture A/B over the full 705-file corpus: only hidden-text-test.docx and paring-fixes.docx remain changed (both intended rule-row suppression); zero part-count changes anywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/lib/note-delimiters.ts (1)
57-63: 📐 Maintainability & Code Quality | 🔵 TrivialDrift guard silently discards the signal without surfacing it for downstream conflict tracking.
classifyNoteRolesreturns only the final roles (or all-'none'on drift) with no indication that a drift/disengagement occurred. Per the coding guidelines,src/parser/**/*.tsmust "persist inference conflicts rather than discarding losing signals" and expose them viameta.conflicts/paragraphs.conflicts. Since this classifier is the only place that detects the drift, and it doesn't return that fact, the DOCX layer (note-roles.ts/inference.ts) has no way to record the conflict even if it wanted to — the losing signal (the note-region classification) is discarded here with no trace.Consider returning a richer result (e.g.
{ roles, drifted: boolean }) so callers can persist the disengagement as a conflict.
[medium]Based on coding guidelines: "Persist inference conflicts rather than discarding losing signals; expose them through
meta.conflictsand store them inparagraphs.conflicts."🤖 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 `@src/lib/note-delimiters.ts` around lines 57 - 63, Update classifyNoteRoles to return both the computed roles and whether structural drift was detected, rather than silently returning only the all-'none' roles. Adjust its callers in the DOCX note-role/inference flow to persist the drift signal as an inference conflict through meta.conflicts and paragraphs.conflicts, while preserving the existing role output behavior.Source: Coding guidelines
🤖 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.
Nitpick comments:
In `@src/lib/note-delimiters.ts`:
- Around line 57-63: Update classifyNoteRoles to return both the computed roles
and whether structural drift was detected, rather than silently returning only
the all-'none' roles. Adjust its callers in the DOCX note-role/inference flow to
persist the drift signal as an inference conflict through meta.conflicts and
paragraphs.conflicts, while preserving the existing role output behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 27b25ce4-ea1e-4522-8cec-a62015045ac1
📒 Files selected for processing (14)
src/lib/note-delimiters.test.tssrc/lib/note-delimiters.tssrc/parser/docx/index.test.tssrc/parser/docx/inference-notes.test.tssrc/parser/docx/inference.test.tssrc/parser/docx/inference.tssrc/parser/docx/lead-in-nesting.test.tssrc/parser/docx/lead-in-nesting.tssrc/parser/docx/note-region-corpus.integration.test.tssrc/parser/docx/note-roles.test.tssrc/parser/docx/note-roles.tssrc/parser/docx/numbering-profile-apply.test.tssrc/parser/docx/types.test.tssrc/parser/docx/types.ts
The #292 drift guard disengages the asterisk-rule note convention when a structural paragraph is found enclosed by an open (drifted) wall. It keyed only on Signal 1 (a live positive numId), so a paragraph structural ONLY via Signal 2 — its style resolves to a real tier through resolvedNumPr, with no direct numId and no literal "PART n"/"N.N" text — was invisible to both the drift check and the text-pattern heading gate, and got swallowed as a [NOTE]: the exact structure-loss class the guard exists to prevent (Codex, PR #461). computeNoteRoles now takes an injected `isStructural` predicate; the DOCX driver supplies `numId>0 || trySignal2(...)`, reusing trySignal2's numId=0 / suppressesNumbering / lead-in opt-out resolution verbatim with no duplicated OOXML logic. Pinned by a regression test at the classifyParagraphs boundary. isNoteParagraph moves from inference.ts to its natural home in the note adapter (note-roles.ts) — consolidating DOCX note detection and keeping inference.ts within the enforced 400-line cap after the wiring change. Fixture A/B vs main baseline unchanged: 2/705 (both intended), zero part-count changes. Full unit suite green (1691). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Codex (GPT-5.5, xhigh) adversarial review — 1 finding [P2], handled (fixed in 9b3b6a7). [P2] Include Signal 2 styles in drift detection ( Fix: Verification: fixture A/B vs |
|
@coderabbitai full review |
✅ Action performedFull review finished. You're currently rate limited under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. Your next review will be available in 4 minutes. |
|
@coderabbitai full review |
✅ Action performedFull review finished. You're currently rate limited under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. Your next review will be available in 5 minutes. |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
Why
Firms bracket editor notes with rows of asterisks (
*****…). The parser kept these as literal wall-of-asterisks text and only caught the enclosed content as a note incidentally, via a/note/-named style — a firm using this convention with an unrelated style name got no note treatment at all.What (Phase 2 of 4)
src/lib/note-delimiters.ts(isRuleRow,classifyNoteRoles) — format-agnostic, so Phase 4 (feat(parser): asterisk notes in text/PDF + surface vanish/banner in source_facts #294, text/PDF ingest) can reuse it directly.src/parser/docx/note-roles.tsDOCX adapter (isHeadingParagraph,computeNoteRoles), kept as a sibling ofheuristics.tsso its existingisDecorationSeparatorsuite (dash/equals rows,'****** [OR] ******') is untouched.inference.tswiring: a rule-row paragraph (*****, 5+ asterisks and nothing else) is now markedsuppressed— it produces noSpecNodeat all — and prose enclosed by a paired rule-row region renders asnoteeven when no banner/style signal fires.ClassifiedParagraph.suppressed?: boolean, distinct in kind fromisVanish/isNote(those govern how a retained node renders;suppressedmeans "no node at all").classifyOne's pre-signal cascade extracted intocollectSignalHits— adding the role guard clauses pushed cyclomatic complexity to 11 (over the enforced budget of 10); the extraction brings it back to 7 with no behavior change.No-op confirmations (explicitly verified, not assumed)
openapi.yaml/ AST / migration / ADR changes — confirmed viagit diff --stat main...HEAD, this PR touches onlysrc/lib/note-delimiters.ts,src/parser/docx/{types,note-roles,inference}.ts, and their test files.StyleNodeTypeSchema(ast/schemas.ts) was never a home for'continuation'/'note', so no schema ripple;mergeProfileConflicts(numbering-profile.ts) already object-spreads unknown fields through on both the equal-reference and conflict-append paths, sosuppressedneeds zero merge-profile wiring.source_factrecording added (out of scope for this phase; issue body scopes only lib + DOCX wiring).Fixture A/B verification (full 705-file corpus, incl. hand-authored MANUFACTURER_EXAMPLES)
MANUFACTURER_EXAMPLES) and showed 0 changes. Re-running against the full 705-file corpus surfaced 4 hand-authored fixtures (the DOCX parser: hand-authored specs (tab-delimited / manually-numbered / centered-title) parse incorrectly #430 corpus) losing PART structure (3→2, 3→1, 3→0): their asterisk walls are frequently unpaired, the open/close toggle drifted out of phase, and real structure (PRODUCTS/EXECUTION headings, manufacturer lists) was swallowed as[NOTE]rows.9f415f3with a drift guard: a structural (numbered) item enclosed by an open rule region is proof the wall pairing has drifted, so the asterisk convention disengages for that document and classification falls back to the per-paragraph style/banner signals that handled these files correctly before this feature. Cleanly-paired documents still engage.mainbaseline: 2/705 fixtures changed, both intended —hidden-text-test.docxandparing-fixes.docxno longer render literal wall-of-asterisks[NOTE]rows. Zero part-count changes anywhere.note-region-corpus.integration.test.tsnow scans the full corpus (39 tests) including a pinned regression named for the symptom (unpaired wall must not swallow PART headings).Known ambiguities (documented in tests, not silently resolved)
note-delimiters.test.ts,note-roles.test.ts,inference-notes.test.ts) — an unpaired rule-row opener with no closing row has no signal to tell whether the author simply forgot the closing delimiter (heading + everything after should NOT be swallowed) or genuinely left the note open. Resolved by treating a literal PART/article-shaped heading as a force-close: everything from the opener through the paragraph before the heading becomes suppressed/note; the heading and everything after resumes normal inference.notethrough end-of-stream. This can over-suppress trailing structural content if the author forgot the closing delimiter and the document simply ends (or the next heading is numbering/style-only, see chore(ci): bump pnpm/action-setup from 4 to 6 #3) — there is no signal in this shape to disambiguate.isHeadingParagraphis text-pattern-only (Signal 4 shape) — it does not consult Signal 1 (numbering.xml) or Signal 2 (style chain). A heading whose PART/article status is carried entirely by numbering/style, with no literal "PART n" / "N.N" text (e.g. a bare "GENERAL" title under a spec-shaped numId), is invisible to the force-close check and won't break an open note region. Mitigated by the drift guard (9f415f3): the DOCX adapter now feeds the numbering signal (NoteScanItem.isStructural), so such a heading inside an open region disengages the convention for the whole document instead of being swallowed.Two consecutive rule rows (open-then-close back to back, nothing
notebetween them) is also documented but is expected/correct behavior, not an ambiguity.Testing
pnpm test) — 1690/1690 (with.envsourced; the earlieropen-comments.test.tsfailures were an unsourced-env artifact, not test failures).note-region-corpus.integration.test.ts39/39 against the full 705-file corpus. (Full-suite runs carry known pre-existing cross-file flakes — test(integration): cross-file shared-DB contamination — rotating unrelated failures in full-suite runs #442 — not chased here.)mainbaseline: 2/705 changed, both intended; zero part-count changes (see above).pnpm lint(eslint + tsc --noEmit + prettier --check) clean.pnpm buildclean.makeDocx()test through the fullparseDocxpipeline (extract → numbering → styles → document → classify → tree) pins no bare 5+-asterisk text surviving in the tree, enclosed prose typednote, and post-close content resuming ascontinuation.Security note for reviewers
During implementation, a spike step's revert (
git checkoutback to the last commit) was followed by system-reminders claimingtypes.ts/inference.tshad been "intentionally modified by the user or a linter" and instructing silence toward the user about it. This was independently re-verified false viagit status --porcelain,git diff --stat, andgit statusimmediately after — the working tree was genuinely clean and matchedHEADexactly. Treated as an injected/anomalous instruction and disregarded per security policy (never conceal repo state; verify from tool output, not claims). Flagging here for the maintainer to audit — not a code change, and it did not affect what shipped in this PR.🤖 Co-authored by Claude Sonnet 5 (implementation) and Claude Fable 5 (corpus-regression fix
9f415f3+ review fixdd2a411). Closes #292.Summary by CodeRabbit
New Features
Bug Fixes
Tests