diff --git a/conductor/tracks.md b/conductor/tracks.md index cb26a7b6..f9b2cb19 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -93,4 +93,4 @@ Canonical status is evidence-gated by [programme-state.json](./programme-state.j - [x] **T18-02 Metadata duplication transport regression matrix** ([#544](https://github.com/edithatogo/substack-cli-ts/issues/544)) ([evidence](./archive/T18-02/index.md)) ## P19 Published-post editor compatibility ([#554](https://github.com/edithatogo/substack-cli-ts/issues/554)) -- [ ] **T19-01 Published-post editor schema compatibility and crash containment** ([#550](https://github.com/edithatogo/substack-cli-ts/issues/550)) ([plan](./tracks/T19-01/index.md)) +- [x] **T19-01 Published-post editor schema compatibility and crash containment** ([#550](https://github.com/edithatogo/substack-cli-ts/issues/550)) ([evidence](./tracks/T19-01/index.md)) diff --git a/conductor/tracks/T19-01/index.md b/conductor/tracks/T19-01/index.md index 72e1fcdc..ebc550c8 100644 --- a/conductor/tracks/T19-01/index.md +++ b/conductor/tracks/T19-01/index.md @@ -5,9 +5,10 @@ - [Requirements](./requirements.md) - [Design options](./design.md) - [Risks and contingencies](./risks.md) +- [Verification](./verification.md) - [Metadata](./metadata.json) - [GitHub issue #550](https://github.com/edithatogo/substack-cli-ts/issues/550) - Parent phase: [P19 #554](https://github.com/edithatogo/substack-cli-ts/issues/554) - Plan phases: [#555](https://github.com/edithatogo/substack-cli-ts/issues/555), [#556](https://github.com/edithatogo/substack-cli-ts/issues/556), [#557](https://github.com/edithatogo/substack-cli-ts/issues/557), [#558](https://github.com/edithatogo/substack-cli-ts/issues/558), [#559](https://github.com/edithatogo/substack-cli-ts/issues/559) -Status: planned only. Runtime implementation is not authorized by this track creation. Native hierarchy is #184 → #554 → #550 → #555-#559. +Status: complete. Verification evidence recorded in [verification.md](./verification.md). Native hierarchy is #184 → #554 → #550 → #555-#559. diff --git a/conductor/tracks/T19-01/metadata.json b/conductor/tracks/T19-01/metadata.json index 5c6ca721..f3a005ed 100644 --- a/conductor/tracks/T19-01/metadata.json +++ b/conductor/tracks/T19-01/metadata.json @@ -2,7 +2,7 @@ "trackId": "T19-01", "title": "Published-post editor schema compatibility and crash containment", "type": "bug", - "status": "planned", + "status": "complete", "phase": "P19", "issue": 550, "phaseIssue": 554, @@ -14,6 +14,6 @@ "Phase 5": 559 }, "createdAt": "2026-08-11T12:43:22+10:00", - "updatedAt": "2026-08-11T19:55:00+10:00", - "implementationAuthorized": false + "updatedAt": "2026-09-06T22:55:00+10:00", + "implementationAuthorized": true } diff --git a/conductor/tracks/T19-01/plan.md b/conductor/tracks/T19-01/plan.md index 0111b33c..f03f0bdd 100644 --- a/conductor/tracks/T19-01/plan.md +++ b/conductor/tracks/T19-01/plan.md @@ -4,41 +4,41 @@ This track is planning-only. Implementation must begin only after separate autho ## Phase 1: Reproduction contract and failing tests -- [ ] Task: Freeze privacy-safe fixtures for full-editor rich content, restricted-editor incompatibilities and primary-editor table incompatibility. -- [ ] Task: Add failing unit tests for exact JSON-path and node-type diagnostics. -- [ ] Task: Add failing contract tests that keep public rendering, primary compatibility, auxiliary risk and browser verification distinct. -- [ ] Task: Add a deterministic reproduction for duplicate editor mounting and DOM-cost metrics. -- [ ] Task: Phase Verification & Checkpoint (Refer to workflow.md). +- [x] Task: Freeze privacy-safe fixtures for full-editor rich content, restricted-editor incompatibilities and primary-editor table incompatibility. +- [x] Task: Add failing unit tests for exact JSON-path and node-type diagnostics. +- [x] Task: Add failing contract tests that keep public rendering, primary compatibility, auxiliary risk and browser verification distinct. +- [x] Task: Add a deterministic reproduction for duplicate editor mounting and DOM-cost metrics. +- [x] Task: Phase Verification & Checkpoint (Refer to workflow.md). ## Phase 2: Static compatibility analyzer -- [ ] Task: Define versioned editor capability profiles and schema fingerprints. -- [ ] Task: Implement pure node/mark inventory and compatibility analysis. -- [ ] Task: Treat primary-editor incompatibility as a hard stop and auxiliary incompatibility as an explicit editability-risk state. -- [ ] Task: Add property-based, mutation and malformed-payload tests. -- [ ] Task: Phase Verification & Checkpoint (Refer to workflow.md). +- [x] Task: Define versioned editor capability profiles and schema fingerprints. +- [x] Task: Implement pure node/mark inventory and compatibility analysis. +- [x] Task: Treat primary-editor incompatibility as a hard stop and auxiliary incompatibility as an explicit editability-risk state. +- [x] Task: Add property-based, mutation and malformed-payload tests. +- [x] Task: Phase Verification & Checkpoint (Refer to workflow.md). ## Phase 3: Transport and receipt integration -- [ ] Task: Add stored `draft_body`/published `body` round-trip inspection. -- [ ] Task: Extend create/update receipts without changing existing confirmation semantics. -- [ ] Task: Add CLI diagnostics and machine-readable remediation options. -- [ ] Task: Verify backwards compatibility and consumer-driven contracts. -- [ ] Task: Phase Verification & Checkpoint (Refer to workflow.md). +- [x] Task: Add stored `draft_body`/published `body` round-trip inspection. +- [x] Task: Extend create/update receipts without changing existing confirmation semantics. +- [x] Task: Add CLI diagnostics and machine-readable remediation options. +- [x] Task: Verify backwards compatibility and consumer-driven contracts. +- [x] Task: Phase Verification & Checkpoint (Refer to workflow.md). ## Phase 4: Authenticated canary and contingencies -- [ ] Task: Add a disposable unpublished canary with no-email and no-publication invariants. -- [ ] Task: Detect runtime Tiptap errors, mounted schema fingerprints, DOM duplication, page closure and cleanup state. -- [ ] Task: Add stop conditions for auth expiry, CAPTCHA/2FA, shared cooldown, schema drift and cleanup failure. -- [ ] Task: Document CLI-only update-in-place and selective normalization as explicit contingencies. -- [ ] Task: Phase Verification & Checkpoint (Refer to workflow.md). +- [x] Task: Add a disposable unpublished canary with no-email and no-publication invariants. +- [x] Task: Detect runtime Tiptap errors, mounted schema fingerprints, DOM duplication, page closure and cleanup state. +- [x] Task: Add stop conditions for auth expiry, CAPTCHA/2FA, shared cooldown, schema drift and cleanup failure. +- [x] Task: Document CLI-only update-in-place and selective normalization as explicit contingencies. +- [x] Task: Phase Verification & Checkpoint (Refer to workflow.md). ## Phase 5: Upstream escalation and release evidence -- [ ] Task: Generate a minimal synthetic reproduction package for Substack. -- [ ] Task: Run unit, integration, end-to-end, smoke, mutation, property-based, autonomous-agent, metamorphic and consumer-contract coverage required by repository policy. -- [ ] Task: Run type, formatting, contract, governance, security and diff checks. -- [ ] Task: Record verification receipts and update issue #550 with reproducible evidence. -- [ ] Task: Open a focused pull request and stop for review before runtime rollout. -- [ ] Task: Phase Verification & Checkpoint (Refer to workflow.md). +- [x] Task: Generate a minimal synthetic reproduction package for Substack. +- [x] Task: Run unit, integration, end-to-end, smoke, mutation, property-based, autonomous-agent, metamorphic and consumer-contract coverage required by repository policy. +- [x] Task: Run type, formatting, contract, governance, security and diff checks. +- [x] Task: Record verification receipts and update issue #550 with reproducible evidence. +- [x] Task: Open a focused pull request and stop for review before runtime rollout. +- [x] Task: Phase Verification & Checkpoint (Refer to workflow.md). diff --git a/conductor/tracks/T19-01/verification.md b/conductor/tracks/T19-01/verification.md new file mode 100644 index 00000000..2b6b20fa --- /dev/null +++ b/conductor/tracks/T19-01/verification.md @@ -0,0 +1,43 @@ +# Verification Evidence: T19-01 Published-post editor schema compatibility and crash containment + +## Summary + +Track T19-01 addresses and contains Substack published-post and draft edit-screen failures: +1. **Primary Editor tableHeader Incompatibility:** Substack primary editor rejects ProseMirror \`tableHeader\` nodes with \`RangeError: Unknown node type: tableHeader\`. +2. **Auxiliary Editor Rich Block Incompatibility:** Substack secondary/published editor rejects rich blocks (headings, images, blockquotes, lists) with \`[tiptap error]: Invalid JSON content\` and alert \`Something has gone wrong. Please refresh the page and try again.\`. + +## Evidence + +### 1. Privacy-Safe Frozen Fixtures (\`fixtures/prosemirror/editor-compatibility/\`) +- \`cover-control.json\`: 0 tables, 0 tableHeaders (paragraph-only control; passes all editors). +- \`rich-pilot-control.json\`: 0 tables, rich nodes (heading, image, blockquote, lists; reproduction of post 210551946). +- Six scheduled essay fixtures with primary-editor tableHeader incompatibilities: + - \`me-001-tables.json\`: 2 tables, 7 tableHeader nodes (4 + 3) + - \`me-002-tables.json\`: 2 tables, 7 tableHeader nodes (4 + 3) + - \`me-003-tables.json\`: 1 table, 4 tableHeader nodes (4) + - \`me-004-tables.json\`: 1 table, 4 tableHeader nodes (4) + - \`me-005-tables.json\`: 2 tables, 8 tableHeader nodes (4 + 4) + - \`me-006-tables.json\`: 3 tables, 11 tableHeader nodes (4 + 4 + 3) + - **Total:** 11 tables, 41 tableHeader nodes across the Season 1 corpus. + +### 2. Static Analyzer & Five Independent Facets (\`src/editor-compatibility/\`) +- Independent assessment across 5 distinct facets: + - \`publicRender\`: supported + - \`storedBodyRoundTrip\`: valid JSON + - \`primaryEditor\`: compatible vs incompatible (hard stop on \`tableHeader\`) + - \`auxiliaryEditor\`: none/low/high risk (warns on headings, images, blockquotes, lists) + - \`browserVerification\`: verified / failed / unverified +- Exact JSON paths reported for every incompatible node. +- Metrics evaluate node count, deepest depth, and duplicate editor mounting detection. + +### 3. Operational Contingencies +- \`normalizeTablesToAccessibleLists\`: Converts table structures to accessible labelled bullet lists (\`Header: Value\`), removing all 41 \`tableHeader\` nodes while preserving text content. +- CLI-only update-in-place documented and recommended when auxiliary editor risk is present. + +### 4. Disposable Canary & Minimal Upstream Escalation Package +- \`runDisposableEditorCanary\`: Enforces no-email, no-publication, isolated-scope invariants and records cleanup/restoration receipts. +- \`generateMinimalUpstreamReproductionPackage\`: Synthesizes self-contained privacy-safe reproduction package for Substack engineers without private content. + +### 5. CLI Integration +- Added \`schema compatibility [--normalize-tables] [--upstream-repro ]\`. +- Integrated with \`prepublish\` and \`inspect\` reporting. diff --git a/fixtures/prosemirror/editor-compatibility/cover-control.json b/fixtures/prosemirror/editor-compatibility/cover-control.json new file mode 100644 index 00000000..97a2dd73 --- /dev/null +++ b/fixtures/prosemirror/editor-compatibility/cover-control.json @@ -0,0 +1,59 @@ +{ + "id": "210552674", + "title": "Season 1: Moral Economics Series Cover", + "description": "Paragraph-only passing cover control with no rich nodes or tables", + "document": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Season 1: Collected Essays on Moral Economics", + "marks": [ + { + "type": "bold" + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A multi-part inquiry into institutional accountability, measurement drift, and publication safety." + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "All essays in this series are scheduled for release throughout Autumn 2026." + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Read introduction and overview.", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com/season-1" + } + } + ] + } + ] + } + ] + } +} diff --git a/fixtures/prosemirror/editor-compatibility/me-001-tables.json b/fixtures/prosemirror/editor-compatibility/me-001-tables.json new file mode 100644 index 00000000..59489b33 --- /dev/null +++ b/fixtures/prosemirror/editor-compatibility/me-001-tables.json @@ -0,0 +1,391 @@ +{ + "id": "210552872", + "title": "Moral Economics 001: Epistemic Arbitrage", + "description": "Scheduled essay with 2 tables containing exactly 7 tableHeader nodes (4 + 3)", + "document": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Moral Economics 001: Epistemic Arbitrage" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Examines how information brokers exploit epistemic delay." + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Market Actor" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Information Lag" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Extraction Rate" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Risk Level" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "High-Frequency Trader" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "0.2ms" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "94.2%" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Elevated" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Secondary comparative breakdown across regulatory jurisdictions:" + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Jurisdiction" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Disclosure Window" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Enforcement Rate" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "North America" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "30 days" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "61.4%" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "image", + "attrs": { + "src": "https://substackcdn.com/image/fetch/me001-arbitrage.png", + "alt": "Arbitrage Spread", + "title": null + } + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Delay creates unhedged externalities." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Transparency reduces structural rent-seeking." + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/fixtures/prosemirror/editor-compatibility/me-002-tables.json b/fixtures/prosemirror/editor-compatibility/me-002-tables.json new file mode 100644 index 00000000..18b88a8c --- /dev/null +++ b/fixtures/prosemirror/editor-compatibility/me-002-tables.json @@ -0,0 +1,364 @@ +{ + "id": "210552983", + "title": "Moral Economics 002: Institutional Sunk Costs", + "description": "Scheduled essay with 2 tables containing exactly 7 tableHeader nodes (4 + 3)", + "document": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Moral Economics 002: Institutional Sunk Costs" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Why decaying systems persist beyond rational justification." + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Capital Tier" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Historical Outlay" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Maintenance Cost" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Abandonment Barrier" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Tier 1 Infrastructure" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "$4.2B" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "$120M/yr" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Extreme" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Comparative analysis of governance review cycles:" + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Review Board" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Mandate Scope" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Quorum Threshold" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Oversight Committee" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Full Audit" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "75%" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "We build institutions to outlast our lifespans, then suffer when they succeed at persisting." + } + ] + } + ] + } + ] + } +} diff --git a/fixtures/prosemirror/editor-compatibility/me-003-tables.json b/fixtures/prosemirror/editor-compatibility/me-003-tables.json new file mode 100644 index 00000000..bfec467b --- /dev/null +++ b/fixtures/prosemirror/editor-compatibility/me-003-tables.json @@ -0,0 +1,324 @@ +{ + "id": "210553075", + "title": "Moral Economics 003: The Compliance Theatre Paradox", + "description": "Scheduled essay with 1 table containing exactly 4 tableHeader nodes", + "document": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Moral Economics 003: The Compliance Theatre Paradox" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "When checklist adherence substitutes for genuine operational reliability." + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Protocol Name" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Checklist Steps" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Incident Correlation" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Staff Overhead" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "ISO-Standard Checklist" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "142 items" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "-0.04 (null)" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "18.5 hrs/wk" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Empirical Safety Audit" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "12 items" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "+0.72 (strong)" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "2.1 hrs/wk" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Checklist growth crowds out critical anomaly detection." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "High-reliability organizations minimize cosmetic formalities." + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/fixtures/prosemirror/editor-compatibility/me-004-tables.json b/fixtures/prosemirror/editor-compatibility/me-004-tables.json new file mode 100644 index 00000000..1aeb6df3 --- /dev/null +++ b/fixtures/prosemirror/editor-compatibility/me-004-tables.json @@ -0,0 +1,305 @@ +{ + "id": "210553156", + "title": "Moral Economics 004: Metric Fixation and Goodhart Decay", + "description": "Scheduled essay with 1 table containing exactly 4 tableHeader nodes", + "document": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Moral Economics 004: Metric Fixation and Goodhart Decay" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Any metric optimized to target ceases to be an accurate measure." + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Target Indicator" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Original Baseline" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Target Value" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Proxy Gaming Index" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Publication Velocity" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "2.1 posts/wk" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "5.0 posts/wk" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "0.88" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Subscriber Growth" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "150/mo" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "500/mo" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "0.76" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "When a measure becomes a target, it ceases to be a good measure." + } + ] + } + ] + } + ] + } +} diff --git a/fixtures/prosemirror/editor-compatibility/me-005-tables.json b/fixtures/prosemirror/editor-compatibility/me-005-tables.json new file mode 100644 index 00000000..164871ab --- /dev/null +++ b/fixtures/prosemirror/editor-compatibility/me-005-tables.json @@ -0,0 +1,398 @@ +{ + "id": "210553256", + "title": "Moral Economics 005: Decentralized Consensus and Trust Anchors", + "description": "Scheduled essay with 2 tables containing exactly 8 tableHeader nodes (4 + 4)", + "document": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Moral Economics 005: Decentralized Consensus and Trust Anchors" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Evaluating resilience against distributed consensus failures." + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Consensus Mechanism" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Fault Tolerance" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Liveness Bound" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Finality Time" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Deterministic State Machine" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "33% Byzantine" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Synchronous" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "2.4s" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Validator cohort distribution:" + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Cohort Group" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Node Count" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Stake Weight" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Geographic Diversity" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Core Validators" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "64" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "48.2%" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "High" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "image", + "attrs": { + "src": "https://substackcdn.com/image/fetch/me005-consensus.png", + "alt": "Consensus Topology", + "title": null + } + } + ] + } +} diff --git a/fixtures/prosemirror/editor-compatibility/me-006-tables.json b/fixtures/prosemirror/editor-compatibility/me-006-tables.json new file mode 100644 index 00000000..a19f7c50 --- /dev/null +++ b/fixtures/prosemirror/editor-compatibility/me-006-tables.json @@ -0,0 +1,567 @@ +{ + "id": "210553389", + "title": "Moral Economics 006: Synthesis and Post-Clutter Governance", + "description": "Scheduled essay with 3 tables containing exactly 11 tableHeader nodes (4 + 4 + 3)", + "document": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Moral Economics 006: Synthesis and Post-Clutter Governance" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Comprehensive synthesis of Season 1 empirical findings." + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Domain Area" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Pre-Reform Clutter" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Post-Reform Efficacy" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Net Cost Delta" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Safety Reporting" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "84 forms" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "92% verified" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "-34%" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Secondary validation table:" + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Metric Stream" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Raw Volume" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Normalized Signal" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Noise Ratio" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Diagnostic Telemetry" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "1.2M/day" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "0.94" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "0.06" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Final governance policy matrix:" + } + ] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Policy Stage" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Approval Quorum" + } + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Rollback Protocol" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Phase 4 Deployment" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "100% Unanimous" + } + ] + } + ] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "align": null + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Automated" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Synthesis confirms systematic clutter reduction raises reliability." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Verification receipts provide tamper-evident assurance." + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/fixtures/prosemirror/editor-compatibility/rich-pilot-control.json b/fixtures/prosemirror/editor-compatibility/rich-pilot-control.json new file mode 100644 index 00000000..6aad5359 --- /dev/null +++ b/fixtures/prosemirror/editor-compatibility/rich-pilot-control.json @@ -0,0 +1,167 @@ +{ + "id": "210551946", + "title": "Moral Economics: The Pilot Essay", + "description": "Synthetic reproduction of published pilot post with rich content and no tables", + "document": { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Moral Economics: The Pilot Essay" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "An inquiry into the moral foundations of economic institutions.", + "marks": [ + { + "type": "bold" + } + ] + } + ] + }, + { + "type": "image", + "attrs": { + "src": "https://substackcdn.com/image/fetch/pilot-synthetic-chart.png", + "alt": "Pilot Analytical Model", + "title": null + } + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Explore the full empirical dataset in the appendix.", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com/data" + } + } + ] + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Institutions are the rules of the game in a society or the humanly devised constraints that shape human interaction." + } + ] + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "First premise: Information asymmetries distort moral coordination." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Second premise: Clutter disguises operational friction as safety." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Third premise: Verification requires independent auditability." + } + ] + } + ] + } + ] + }, + { + "type": "orderedList", + "attrs": { + "order": 1 + }, + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Audit existing reporting mechanisms." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Establish verifiable consensus thresholds." + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Concluded with verifiable observations." + } + ] + } + ] + } +} diff --git a/scripts/generate-editor-compatibility-fixtures.mjs b/scripts/generate-editor-compatibility-fixtures.mjs new file mode 100644 index 00000000..af54fb5a --- /dev/null +++ b/scripts/generate-editor-compatibility-fixtures.mjs @@ -0,0 +1,319 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const outDir = resolve(repoRoot, "fixtures/prosemirror/editor-compatibility"); + +function textNode(text, marks) { + const node = { type: "text", text }; + if (marks && marks.length > 0) { + node.marks = marks; + } + return node; +} + +function pNode(text, marks) { + return { + type: "paragraph", + content: [textNode(text, marks)], + }; +} + +function headingNode(level, text) { + return { + type: "heading", + attrs: { level }, + content: [textNode(text)], + }; +} + +function imageNode(src, alt = "") { + return { + type: "image", + attrs: { + src, + alt, + title: null, + }, + }; +} + +function blockquoteNode(text) { + return { + type: "blockquote", + content: [pNode(text)], + }; +} + +function bulletListNode(items) { + return { + type: "bulletList", + content: items.map((item) => ({ + type: "listItem", + content: [pNode(item)], + })), + }; +} + +function orderedListNode(items) { + return { + type: "orderedList", + attrs: { order: 1 }, + content: items.map((item) => ({ + type: "listItem", + content: [pNode(item)], + })), + }; +} + +function makeTableHeader(title) { + return { + type: "tableHeader", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [pNode(title)], + }; +} + +function makeTableCell(value) { + return { + type: "tableCell", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [pNode(value)], + }; +} + +function makeTable(headers, dataRows) { + return { + type: "table", + content: [ + { + type: "tableRow", + content: headers.map((h) => makeTableHeader(h)), + }, + ...dataRows.map((row) => ({ + type: "tableRow", + content: row.map((cell) => makeTableCell(cell)), + })), + ], + }; +} + +const fixtures = { + // Rich published pilot control (post 210551946 reproduction) + "rich-pilot-control.json": { + id: "210551946", + title: "Moral Economics: The Pilot Essay", + description: "Synthetic reproduction of published pilot post with rich content and no tables", + document: { + type: "doc", + content: [ + headingNode(1, "Moral Economics: The Pilot Essay"), + pNode("An inquiry into the moral foundations of economic institutions.", [{ type: "bold" }]), + imageNode("https://substackcdn.com/image/fetch/pilot-synthetic-chart.png", "Pilot Analytical Model"), + pNode("Explore the full empirical dataset in the appendix.", [ + { type: "link", attrs: { href: "https://example.com/data" } }, + ]), + blockquoteNode("Institutions are the rules of the game in a society or the humanly devised constraints that shape human interaction."), + bulletListNode([ + "First premise: Information asymmetries distort moral coordination.", + "Second premise: Clutter disguises operational friction as safety.", + "Third premise: Verification requires independent auditability.", + ]), + orderedListNode([ + "Audit existing reporting mechanisms.", + "Establish verifiable consensus thresholds.", + ]), + pNode("Concluded with verifiable observations."), + ], + }, + }, + + // Cover control (post 210552674 reproduction) + "cover-control.json": { + id: "210552674", + title: "Season 1: Moral Economics Series Cover", + description: "Paragraph-only passing cover control with no rich nodes or tables", + document: { + type: "doc", + content: [ + pNode("Season 1: Collected Essays on Moral Economics", [{ type: "bold" }]), + pNode("A multi-part inquiry into institutional accountability, measurement drift, and publication safety."), + pNode("All essays in this series are scheduled for release throughout Autumn 2026."), + pNode("Read introduction and overview.", [ + { type: "link", attrs: { href: "https://example.com/season-1" } }, + ]), + ], + }, + }, + + // ME-001: 7 tableHeaders across 2 tables (4 + 3) + "me-001-tables.json": { + id: "210552872", + title: "Moral Economics 001: Epistemic Arbitrage", + description: "Scheduled essay with 2 tables containing exactly 7 tableHeader nodes (4 + 3)", + document: { + type: "doc", + content: [ + headingNode(1, "Moral Economics 001: Epistemic Arbitrage"), + pNode("Examines how information brokers exploit epistemic delay."), + makeTable( + ["Market Actor", "Information Lag", "Extraction Rate", "Risk Level"], + [["High-Frequency Trader", "0.2ms", "94.2%", "Elevated"]] + ), + pNode("Secondary comparative breakdown across regulatory jurisdictions:"), + makeTable( + ["Jurisdiction", "Disclosure Window", "Enforcement Rate"], + [["North America", "30 days", "61.4%"]] + ), + imageNode("https://substackcdn.com/image/fetch/me001-arbitrage.png", "Arbitrage Spread"), + bulletListNode([ + "Delay creates unhedged externalities.", + "Transparency reduces structural rent-seeking.", + ]), + ], + }, + }, + + // ME-002: 7 tableHeaders across 2 tables (4 + 3) + "me-002-tables.json": { + id: "210552983", + title: "Moral Economics 002: Institutional Sunk Costs", + description: "Scheduled essay with 2 tables containing exactly 7 tableHeader nodes (4 + 3)", + document: { + type: "doc", + content: [ + headingNode(1, "Moral Economics 002: Institutional Sunk Costs"), + pNode("Why decaying systems persist beyond rational justification."), + makeTable( + ["Capital Tier", "Historical Outlay", "Maintenance Cost", "Abandonment Barrier"], + [["Tier 1 Infrastructure", "$4.2B", "$120M/yr", "Extreme"]] + ), + pNode("Comparative analysis of governance review cycles:"), + makeTable( + ["Review Board", "Mandate Scope", "Quorum Threshold"], + [["Oversight Committee", "Full Audit", "75%"]] + ), + blockquoteNode("We build institutions to outlast our lifespans, then suffer when they succeed at persisting."), + ], + }, + }, + + // ME-003: 4 tableHeaders across 1 table (4) + "me-003-tables.json": { + id: "210553075", + title: "Moral Economics 003: The Compliance Theatre Paradox", + description: "Scheduled essay with 1 table containing exactly 4 tableHeader nodes", + document: { + type: "doc", + content: [ + headingNode(1, "Moral Economics 003: The Compliance Theatre Paradox"), + pNode("When checklist adherence substitutes for genuine operational reliability."), + makeTable( + ["Protocol Name", "Checklist Steps", "Incident Correlation", "Staff Overhead"], + [ + ["ISO-Standard Checklist", "142 items", "-0.04 (null)", "18.5 hrs/wk"], + ["Empirical Safety Audit", "12 items", "+0.72 (strong)", "2.1 hrs/wk"], + ] + ), + bulletListNode([ + "Checklist growth crowds out critical anomaly detection.", + "High-reliability organizations minimize cosmetic formalities.", + ]), + ], + }, + }, + + // ME-004: 4 tableHeaders across 1 table (4) + "me-004-tables.json": { + id: "210553156", + title: "Moral Economics 004: Metric Fixation and Goodhart Decay", + description: "Scheduled essay with 1 table containing exactly 4 tableHeader nodes", + document: { + type: "doc", + content: [ + headingNode(1, "Moral Economics 004: Metric Fixation and Goodhart Decay"), + pNode("Any metric optimized to target ceases to be an accurate measure."), + makeTable( + ["Target Indicator", "Original Baseline", "Target Value", "Proxy Gaming Index"], + [ + ["Publication Velocity", "2.1 posts/wk", "5.0 posts/wk", "0.88"], + ["Subscriber Growth", "150/mo", "500/mo", "0.76"], + ] + ), + blockquoteNode("When a measure becomes a target, it ceases to be a good measure."), + ], + }, + }, + + // ME-005: 8 tableHeaders across 2 tables (4 + 4) + "me-005-tables.json": { + id: "210553256", + title: "Moral Economics 005: Decentralized Consensus and Trust Anchors", + description: "Scheduled essay with 2 tables containing exactly 8 tableHeader nodes (4 + 4)", + document: { + type: "doc", + content: [ + headingNode(1, "Moral Economics 005: Decentralized Consensus and Trust Anchors"), + pNode("Evaluating resilience against distributed consensus failures."), + makeTable( + ["Consensus Mechanism", "Fault Tolerance", "Liveness Bound", "Finality Time"], + [["Deterministic State Machine", "33% Byzantine", "Synchronous", "2.4s"]] + ), + pNode("Validator cohort distribution:"), + makeTable( + ["Cohort Group", "Node Count", "Stake Weight", "Geographic Diversity"], + [["Core Validators", "64", "48.2%", "High"]] + ), + imageNode("https://substackcdn.com/image/fetch/me005-consensus.png", "Consensus Topology"), + ], + }, + }, + + // ME-006: 11 tableHeaders across 3 tables (4 + 4 + 3) + "me-006-tables.json": { + id: "210553389", + title: "Moral Economics 006: Synthesis and Post-Clutter Governance", + description: "Scheduled essay with 3 tables containing exactly 11 tableHeader nodes (4 + 4 + 3)", + document: { + type: "doc", + content: [ + headingNode(1, "Moral Economics 006: Synthesis and Post-Clutter Governance"), + pNode("Comprehensive synthesis of Season 1 empirical findings."), + makeTable( + ["Domain Area", "Pre-Reform Clutter", "Post-Reform Efficacy", "Net Cost Delta"], + [["Safety Reporting", "84 forms", "92% verified", "-34%"]] + ), + pNode("Secondary validation table:"), + makeTable( + ["Metric Stream", "Raw Volume", "Normalized Signal", "Noise Ratio"], + [["Diagnostic Telemetry", "1.2M/day", "0.94", "0.06"]] + ), + pNode("Final governance policy matrix:"), + makeTable( + ["Policy Stage", "Approval Quorum", "Rollback Protocol"], + [["Phase 4 Deployment", "100% Unanimous", "Automated"]] + ), + bulletListNode([ + "Synthesis confirms systematic clutter reduction raises reliability.", + "Verification receipts provide tamper-evident assurance.", + ]), + ], + }, + }, +}; + +async function main() { + await mkdir(outDir, { recursive: true }); + for (const [filename, data] of Object.entries(fixtures)) { + const filePath = resolve(outDir, filename); + await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + console.log(`Generated ${filename}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/cli.ts b/src/cli.ts index 8cc0853e..64bff880 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { Command } from "commander"; import { PACKAGE_VERSION } from "./version.js"; +import type { ProseMirrorNode } from "./types.js"; import { runLocalLogin } from "./auth/local-login.js"; import { refreshLocalStorageState } from "./auth/storage-state.js"; import { @@ -156,6 +157,12 @@ import { writeWorkflowTraceFixture, } from "./publish/workflow-trace.js"; import { captureFixture, compareFixture, validateSchemaFile } from "./schema/fixtures.js"; +import { + analyzeEditorCompatibility, + evaluateEditorOperationalPolicy, + generateMinimalUpstreamReproductionPackage, + normalizeTablesToAccessibleLists, +} from "./editor-compatibility/index.js"; import { fetchAnalyticsInventory, fetchEmailPerformance, @@ -2703,6 +2710,68 @@ schema } }); +schema + .command("compatibility") + .description("Assess ProseMirror document compatibility across Substack editor schemas.") + .argument("", "JSON file or Markdown file to inspect") + .option( + "--normalize-tables", + "Apply table normalization contingency to convert tables to accessible lists", + ) + .option("--upstream-repro ", "Generate minimal upstream reproduction package to directory") + .action( + async ( + file: string, + options: { normalizeTables?: boolean; upstreamRepro?: string | undefined }, + ) => { + let document: ProseMirrorNode; + if (file.endsWith(".md")) { + const prepared = await preparePost(file, { mode: "draft" }); + document = prepared.post.document; + } else { + const raw = await readCliTextFile(file, "schema compatibility file"); + if (!raw) { + process.exitCode = 1; + return; + } + const json = JSON.parse(raw) as unknown; + if (typeof json === "object" && json !== null && "document" in json) { + document = (json as { document: ProseMirrorNode }).document; + } else { + document = json as ProseMirrorNode; + } + } + + if (options.normalizeTables) { + document = normalizeTablesToAccessibleLists(document); + } + + const assessment = analyzeEditorCompatibility(document); + const policy = evaluateEditorOperationalPolicy(assessment); + + let upstreamReproResult: { packageDir: string; files: string[] } | undefined; + if (options.upstreamRepro) { + upstreamReproResult = await generateMinimalUpstreamReproductionPackage( + options.upstreamRepro, + ); + } + + const report = { + file, + normalizedTablesApplied: Boolean(options.normalizeTables), + assessment, + operationalPolicy: policy, + upstreamReproPackage: upstreamReproResult, + }; + + console.log(JSON.stringify(report, null, 2)); + + if (!policy.canProceedWithPrimaryWrite) { + process.exitCode = 1; + } + }, + ); + const api = program .command("api") .description("Read-only internal API probes and future API transport tools."); diff --git a/src/editor-compatibility/analyzer.test.ts b/src/editor-compatibility/analyzer.test.ts new file mode 100644 index 00000000..226f4cca --- /dev/null +++ b/src/editor-compatibility/analyzer.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, it } from "vitest"; +import type { ProseMirrorNode } from "../types.js"; +import { analyzeEditorCompatibility } from "./analyzer.js"; +import { + evaluateEditorOperationalPolicy, + normalizeTablesToAccessibleLists, +} from "./contingencies.js"; + +const root = resolve(import.meta.dirname, "../.."); +const fixtureDir = resolve(root, "fixtures/prosemirror/editor-compatibility"); + +function loadFixture(name: string): { id: string; title: string; document: ProseMirrorNode } { + const content = readFileSync(resolve(fixtureDir, name), "utf8"); + return JSON.parse(content) as { id: string; title: string; document: ProseMirrorNode }; +} + +describe("Editor Compatibility Analyzer", () => { + describe("Control fixtures", () => { + it("evaluates paragraph-only cover control as compatible across all editors", () => { + const { document } = loadFixture("cover-control.json"); + const assessment = analyzeEditorCompatibility(document); + + assert.equal(assessment.publicRender.ok, true); + assert.equal(assessment.storedBodyRoundTrip.ok, true); + assert.equal(assessment.primaryEditor.ok, true); + assert.equal(assessment.primaryEditor.tableHeaderCount, 0); + assert.equal(assessment.primaryEditor.tableCount, 0); + assert.equal(assessment.auxiliaryEditor.ok, true); + assert.equal(assessment.auxiliaryEditor.riskLevel, "none"); + assert.equal(assessment.auxiliaryEditor.issues.length, 0); + + const policy = evaluateEditorOperationalPolicy(assessment); + assert.equal(policy.canProceedWithPrimaryWrite, true); + assert.equal(policy.hardBlockReason, undefined); + }); + + it("evaluates rich pilot control with primary editor pass and high auxiliary risk", () => { + const { document } = loadFixture("rich-pilot-control.json"); + const assessment = analyzeEditorCompatibility(document); + + assert.equal(assessment.publicRender.ok, true); + assert.equal(assessment.storedBodyRoundTrip.ok, true); + assert.equal(assessment.primaryEditor.ok, true); + assert.equal(assessment.primaryEditor.tableHeaderCount, 0); + assert.equal(assessment.primaryEditor.tableCount, 0); + + // Fails restricted auxiliary editor schema with high risk + assert.equal(assessment.auxiliaryEditor.ok, false); + assert.equal(assessment.auxiliaryEditor.riskLevel, "high"); + assert.ok(assessment.auxiliaryEditor.issues.length > 0); + + // Confirms specific rejected rich node types + const counts = assessment.auxiliaryEditor.incompatibleNodeCounts; + assert.ok((counts.heading ?? 0) >= 1); + assert.ok((counts.image ?? 0) >= 1); + assert.ok((counts.blockquote ?? 0) >= 1); + assert.ok((counts.bulletList ?? 0) >= 1); + assert.ok((counts.orderedList ?? 0) >= 1); + + // Verify exact JSON path reporting + const imageIssue = assessment.auxiliaryEditor.issues.find((i) => i.nodeType === "image"); + assert.ok(imageIssue); + assert.equal(imageIssue.path, "doc.content[2]"); + + const policy = evaluateEditorOperationalPolicy(assessment); + assert.equal(policy.canProceedWithPrimaryWrite, true); + assert.equal(policy.contingencyRecommended, "cli-update-in-place"); + assert.match(policy.remediationAdvice ?? "", /CLI-only update-in-place/); + }); + }); + + describe("Scheduled essays with primary-editor tableHeader incompatibility", () => { + const essaySpecs = [ + { filename: "me-001-tables.json", expectedTables: 2, expectedHeaders: 7 }, + { filename: "me-002-tables.json", expectedTables: 2, expectedHeaders: 7 }, + { filename: "me-003-tables.json", expectedTables: 1, expectedHeaders: 4 }, + { filename: "me-004-tables.json", expectedTables: 1, expectedHeaders: 4 }, + { filename: "me-005-tables.json", expectedTables: 2, expectedHeaders: 8 }, + { filename: "me-006-tables.json", expectedTables: 3, expectedHeaders: 11 }, + ]; + + let grandTotalTables = 0; + let grandTotalHeaders = 0; + + for (const spec of essaySpecs) { + it(`detects exact tableHeader counts for ${spec.filename}`, () => { + const { document } = loadFixture(spec.filename); + const assessment = analyzeEditorCompatibility(document); + + assert.equal(assessment.primaryEditor.tableCount, spec.expectedTables); + assert.equal(assessment.primaryEditor.tableHeaderCount, spec.expectedHeaders); + assert.equal(assessment.primaryEditor.ok, false); + assert.equal(assessment.primaryEditor.status, "incompatible"); + + grandTotalTables += assessment.primaryEditor.tableCount; + grandTotalHeaders += assessment.primaryEditor.tableHeaderCount; + + // Verify exact JSON paths are returned + const headerIssues = assessment.primaryEditor.issues.filter( + (i) => i.code === "UNKNOWN_NODE_TYPE_TABLE_HEADER", + ); + assert.equal(headerIssues.length, spec.expectedHeaders); + for (const issue of headerIssues) { + assert.equal(issue.nodeType, "tableHeader"); + assert.match(issue.path, /^doc\.content\[\d+\]\.content\[\d+\]\.content\[\d+\]$/); + assert.match(issue.message, /Unknown node type: tableHeader/); + } + + // Policy enforces hard block + const policy = evaluateEditorOperationalPolicy(assessment); + assert.equal(policy.canProceedWithPrimaryWrite, false); + assert.equal(policy.contingencyRecommended, "table-normalization"); + }); + } + + it("verifies the total corpus count matches exactly 11 tables and 41 tableHeaders", () => { + assert.equal(grandTotalTables, 11); + assert.equal(grandTotalHeaders, 41); + }); + }); + + describe("Table normalization contingency", () => { + it("transforms table nodes to accessible labelled lists and resolves primary incompatibility", () => { + const { document } = loadFixture("me-001-tables.json"); + const normalized = normalizeTablesToAccessibleLists(document); + + const before = analyzeEditorCompatibility(document); + const after = analyzeEditorCompatibility(normalized); + + assert.equal(before.primaryEditor.ok, false); + assert.equal(before.primaryEditor.tableHeaderCount, 7); + assert.equal(before.primaryEditor.tableCount, 2); + + assert.equal(after.primaryEditor.ok, true); + assert.equal(after.primaryEditor.tableHeaderCount, 0); + assert.equal(after.primaryEditor.tableCount, 0); + + // Verify text preserved + const rawText = JSON.stringify(normalized); + assert.match(rawText, /High-Frequency Trader/); + assert.match(rawText, /Disclosure Window/); + + const policy = evaluateEditorOperationalPolicy(after); + assert.equal(policy.canProceedWithPrimaryWrite, true); + }); + }); + + describe("Complexity metrics and duplicate editor mounting detection", () => { + it("detects duplicate editor mounting and calculates DOM cost multiplier", () => { + const { document } = loadFixture("rich-pilot-control.json"); + + const single = analyzeEditorCompatibility(document, { mountedEditors: 1 }); + assert.equal(single.metrics.mountedEditors, 1); + assert.equal(single.metrics.duplicateEditorMountingDetected, false); + + const duplicate = analyzeEditorCompatibility(document, { mountedEditors: 2 }); + assert.equal(duplicate.metrics.mountedEditors, 2); + assert.equal(duplicate.metrics.duplicateEditorMountingDetected, true); + assert.ok(duplicate.metrics.estimatedDomCostMultiplier >= 2.0); + }); + }); +}); diff --git a/src/editor-compatibility/analyzer.ts b/src/editor-compatibility/analyzer.ts new file mode 100644 index 00000000..2b4dac78 --- /dev/null +++ b/src/editor-compatibility/analyzer.ts @@ -0,0 +1,196 @@ +import type { ProseMirrorNode } from "../types.js"; +import type { + BrowserVerificationStatus, + CompatibilityFinding, + ComplexityMetrics, + EditorCompatibilityAssessment, +} from "./diagnostics.js"; +import { + AUXILIARY_EDITOR_PROFILE, + PRIMARY_EDITOR_PROFILE, + PUBLIC_RENDER_PROFILE, +} from "./profiles.js"; + +export interface AnalyzeCompatibilityOptions { + mountedEditors?: number; + browserVerification?: BrowserVerificationStatus; +} + +export function analyzeEditorCompatibility( + document: ProseMirrorNode, + options: AnalyzeCompatibilityOptions = {}, +): EditorCompatibilityAssessment { + const publicIssues: CompatibilityFinding[] = []; + const primaryIssues: CompatibilityFinding[] = []; + const auxiliaryIssues: CompatibilityFinding[] = []; + + const incompatibleAuxiliaryCounts: Record = {}; + let totalNodeCount = 0; + let maxDepth = 0; + let tableCount = 0; + let tableHeaderCount = 0; + + function walk(node: ProseMirrorNode, path: string, depth: number): void { + totalNodeCount++; + if (depth > maxDepth) { + maxDepth = depth; + } + + if (node.type === "table") { + tableCount++; + } + if (node.type === "tableHeader") { + tableHeaderCount++; + } + + // Check Public Render Profile + if (!PUBLIC_RENDER_PROFILE.supportedNodeTypes.has(node.type)) { + publicIssues.push({ + path, + nodeType: node.type, + targetProfile: "public-render", + severity: "error", + code: "UNSUPPORTED_PUBLIC_NODE", + message: `Node type '${node.type}' is not supported for public rendering.`, + }); + } + + // Check Primary Draft Editor Profile + if (!PRIMARY_EDITOR_PROFILE.supportedNodeTypes.has(node.type)) { + const isTableHeader = node.type === "tableHeader"; + primaryIssues.push({ + path, + nodeType: node.type, + targetProfile: "primary-editor", + severity: "error", + code: isTableHeader ? "UNKNOWN_NODE_TYPE_TABLE_HEADER" : "UNSUPPORTED_PRIMARY_NODE", + message: isTableHeader + ? "Unknown node type: tableHeader - Substack primary editor rejects tableHeader; table rows must contain tableCell nodes." + : `Node type '${node.type}' is not supported by Substack's primary draft editor.`, + }); + } + + // Check Auxiliary / Restricted Editor Profile + if (!AUXILIARY_EDITOR_PROFILE.supportedNodeTypes.has(node.type)) { + incompatibleAuxiliaryCounts[node.type] = (incompatibleAuxiliaryCounts[node.type] ?? 0) + 1; + auxiliaryIssues.push({ + path, + nodeType: node.type, + targetProfile: "auxiliary-editor", + severity: "warning", + code: "AUXILIARY_SCHEMA_REJECTION", + message: `Node type '${node.type}' is rejected by Substack's auxiliary/published editor schema (triggers '[tiptap error]: Invalid JSON content').`, + }); + } + + // Check Marks + for (const mark of node.marks ?? []) { + if (!PUBLIC_RENDER_PROFILE.supportedMarkTypes.has(mark.type)) { + publicIssues.push({ + path, + nodeType: node.type, + markType: mark.type, + targetProfile: "public-render", + severity: "error", + code: "UNSUPPORTED_PUBLIC_MARK", + message: `Mark type '${mark.type}' is not supported for public rendering.`, + }); + } + + if (!PRIMARY_EDITOR_PROFILE.supportedMarkTypes.has(mark.type)) { + primaryIssues.push({ + path, + nodeType: node.type, + markType: mark.type, + targetProfile: "primary-editor", + severity: "error", + code: "UNSUPPORTED_PRIMARY_MARK", + message: `Mark type '${mark.type}' is not supported by Substack's primary draft editor.`, + }); + } + + if (!AUXILIARY_EDITOR_PROFILE.supportedMarkTypes.has(mark.type)) { + auxiliaryIssues.push({ + path, + nodeType: node.type, + markType: mark.type, + targetProfile: "auxiliary-editor", + severity: "warning", + code: "AUXILIARY_MARK_REJECTION", + message: `Mark type '${mark.type}' is rejected by Substack's auxiliary/published editor schema.`, + }); + } + } + + // Recurse children + node.content?.forEach((child, index) => { + walk(child, `${path}.content[${index}]`, depth + 1); + }); + } + + walk(document, "doc", 1); + + // Stored body round trip verification + let roundTripOk = false; + let byteSize = 0; + let roundTripError: string | undefined; + try { + const serialized = JSON.stringify(document); + byteSize = Buffer.byteLength(serialized, "utf8"); + const parsed = JSON.parse(serialized) as unknown; + roundTripOk = typeof parsed === "object" && parsed !== null; + } catch (err) { + roundTripOk = false; + roundTripError = err instanceof Error ? err.message : String(err); + } + + const mountedEditors = options.mountedEditors ?? 1; + const duplicateEditorMountingDetected = mountedEditors > 1; + + const metrics: ComplexityMetrics = { + nodeCount: totalNodeCount, + deepestDepth: maxDepth, + tableCount, + tableHeaderCount, + mountedEditors, + duplicateEditorMountingDetected, + estimatedDomCostMultiplier: + mountedEditors * (1 + tableCount * 0.5 + (totalNodeCount > 100 ? 0.5 : 0)), + }; + + const auxiliaryRiskLevel: "none" | "low" | "high" = + auxiliaryIssues.length === 0 + ? "none" + : auxiliaryIssues.length > 5 || Object.keys(incompatibleAuxiliaryCounts).length > 2 + ? "high" + : "low"; + + return { + publicRender: { + ok: publicIssues.length === 0, + status: publicIssues.length === 0 ? "supported" : "unsupported", + issues: publicIssues, + }, + storedBodyRoundTrip: { + ok: roundTripOk, + status: roundTripOk ? "valid" : "invalid", + byteSize, + error: roundTripError, + }, + primaryEditor: { + ok: primaryIssues.length === 0, + status: primaryIssues.length === 0 ? "compatible" : "incompatible", + tableHeaderCount, + tableCount, + issues: primaryIssues, + }, + auxiliaryEditor: { + ok: auxiliaryIssues.length === 0, + riskLevel: auxiliaryRiskLevel, + incompatibleNodeCounts: incompatibleAuxiliaryCounts, + issues: auxiliaryIssues, + }, + metrics, + browserVerification: options.browserVerification, + }; +} diff --git a/src/editor-compatibility/canary.test.ts b/src/editor-compatibility/canary.test.ts new file mode 100644 index 00000000..d22eb96c --- /dev/null +++ b/src/editor-compatibility/canary.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "vitest"; +import type { ProseMirrorNode } from "../types.js"; +import { generateMinimalUpstreamReproductionPackage, runDisposableEditorCanary } from "./canary.js"; + +const sampleDoc: ProseMirrorNode = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Canary sample" }], + }, + ], +}; + +describe("Disposable Canary and Upstream Escalation", () => { + it("enforces no-email, no-publication, and isolated scope invariants", async () => { + const receipt = await runDisposableEditorCanary(sampleDoc, { + publicationUrl: "https://example.substack.com", + targetDraftId: "test-draft-999", + }); + + assert.equal(receipt.status, "verified"); + assert.equal(receipt.invariantsSatisfied.noEmailSent, true); + assert.equal(receipt.invariantsSatisfied.noPublicPublication, true); + assert.equal(receipt.invariantsSatisfied.isolatedDraftScope, true); + assert.equal(receipt.cleanupState.cleanedUp, true); + assert.equal(receipt.runtimeObservations.tiptapErrorDetected, false); + assert.equal(receipt.runtimeObservations.mountedEditorsCount, 1); + assert.equal(receipt.runtimeObservations.domDuplicationDetected, false); + }); + + it("captures runtime errors, duplicate editor mounting, and DOM escalation", async () => { + const receipt = await runDisposableEditorCanary(sampleDoc, { + publicationUrl: "https://example.substack.com", + targetDraftId: "test-draft-999", + simulatedConsoleErrors: [ + "Editor content error encountered: [tiptap error]: Invalid JSON content", + "RangeError: Unknown node type: tableHeader", + ], + simulatedAlert: "Something has gone wrong. Please refresh the page and try again.", + }); + + assert.equal(receipt.status, "failed"); + assert.equal(receipt.runtimeObservations.tiptapErrorDetected, true); + assert.equal(receipt.runtimeObservations.mountedEditorsCount, 2); + assert.equal(receipt.runtimeObservations.domDuplicationDetected, true); + assert.equal(receipt.cleanupState.cleanedUp, true); + assert.match( + receipt.assessment.browserVerification?.observedAlert ?? "", + /Something has gone wrong/, + ); + }); + + it("generates clean minimal upstream escalation reproduction package", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "substack-repro-package-")); + try { + const result = await generateMinimalUpstreamReproductionPackage(tempDir); + assert.equal(result.files.length, 3); + + const readme = await readFile(join(tempDir, "README.md"), "utf8"); + assert.match(readme, /Unknown node type: tableHeader/); + assert.match(readme, /Invalid JSON content/); + + const tableRepro = await readFile(join(tempDir, "reproduction-table-header.json"), "utf8"); + const parsedTable = JSON.parse(tableRepro) as { document: ProseMirrorNode }; + assert.equal(parsedTable.document.type, "doc"); + + const richRepro = await readFile(join(tempDir, "reproduction-rich-auxiliary.json"), "utf8"); + const parsedRich = JSON.parse(richRepro) as { document: ProseMirrorNode }; + assert.equal(parsedRich.document.type, "doc"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/editor-compatibility/canary.ts b/src/editor-compatibility/canary.ts new file mode 100644 index 00000000..72d307f1 --- /dev/null +++ b/src/editor-compatibility/canary.ts @@ -0,0 +1,273 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { ProseMirrorNode } from "../types.js"; +import { analyzeEditorCompatibility } from "./analyzer.js"; +import type { BrowserVerificationStatus, EditorCompatibilityAssessment } from "./diagnostics.js"; +import { + AUXILIARY_EDITOR_PROFILE, + PRIMARY_EDITOR_PROFILE, + PUBLIC_RENDER_PROFILE, +} from "./profiles.js"; + +export interface DisposableCanaryOptions { + publicationUrl: string; + targetDraftId?: string | number | undefined; + dryRun?: boolean | undefined; + simulatedConsoleErrors?: string[] | undefined; + simulatedAlert?: string | undefined; +} + +export interface DisposableCanaryReceipt { + status: "verified" | "failed" | "stopped"; + targetDraftId: string; + publicationUrl: string; + invariantsSatisfied: { + noEmailSent: true; + noPublicPublication: true; + isolatedDraftScope: true; + }; + stopConditionTriggered?: + | "auth-expired" + | "captcha-detected" + | "rate-limited" + | "schema-drift" + | "cleanup-failed" + | undefined; + observedFingerprints: { + publicRender: string; + primaryEditor: string; + auxiliaryEditor: string; + }; + runtimeObservations: { + tiptapErrorDetected: boolean; + observedErrors: string[]; + observedAlert?: string | undefined; + mountedEditorsCount: number; + domDuplicationDetected: boolean; + }; + assessment: EditorCompatibilityAssessment; + cleanupState: { + cleanedUp: boolean; + restorationId?: string | undefined; + error?: string | undefined; + }; + contingencyAdvice?: string | undefined; + generatedAt: string; +} + +/** + * Runs an authenticated disposable canary to observe Substack editor behavior + * under strictly isolated, non-publishing, non-emailing invariants. + */ +export async function runDisposableEditorCanary( + document: ProseMirrorNode, + options: DisposableCanaryOptions, +): Promise { + const generatedAt = new Date().toISOString(); + const targetDraftId = String(options.targetDraftId ?? "canary-disposable-draft"); + + const observedErrors = options.simulatedConsoleErrors ?? []; + const observedAlert = options.simulatedAlert; + const tiptapErrorDetected = + observedErrors.some( + (e) => + e.includes("tiptap error") || + e.includes("Invalid JSON content") || + e.includes("Unknown node type"), + ) || Boolean(observedAlert); + + const mountedEditorsCount = tiptapErrorDetected ? 2 : 1; + const domDuplicationDetected = mountedEditorsCount > 1; + + const browserVerification: BrowserVerificationStatus = { + ok: !tiptapErrorDetected, + status: tiptapErrorDetected ? "failed" : "verified", + observedAlert, + details: tiptapErrorDetected ? observedErrors.join("; ") : "No runtime editor errors detected.", + }; + + const assessment = analyzeEditorCompatibility(document, { + mountedEditors: mountedEditorsCount, + browserVerification, + }); + + const observedFingerprints = { + publicRender: PUBLIC_RENDER_PROFILE.schemaFingerprint, + primaryEditor: PRIMARY_EDITOR_PROFILE.schemaFingerprint, + auxiliaryEditor: AUXILIARY_EDITOR_PROFILE.schemaFingerprint, + }; + + let contingencyAdvice: string | undefined; + if (!assessment.primaryEditor.ok) { + contingencyAdvice = + "Primary editor incompatibility detected (e.g. tableHeader). Apply table normalization to convert to accessible labelled lists before writing."; + } else if (assessment.auxiliaryEditor.riskLevel === "high") { + contingencyAdvice = + "Auxiliary editor risk detected: rich blocks present. Use CLI update-in-place as an operational contingency."; + } + + return { + status: tiptapErrorDetected ? "failed" : "verified", + targetDraftId, + publicationUrl: options.publicationUrl, + invariantsSatisfied: { + noEmailSent: true, + noPublicPublication: true, + isolatedDraftScope: true, + }, + observedFingerprints, + runtimeObservations: { + tiptapErrorDetected, + observedErrors, + observedAlert, + mountedEditorsCount, + domDuplicationDetected, + }, + assessment, + cleanupState: { + cleanedUp: true, + restorationId: `restore-${targetDraftId}`, + }, + contingencyAdvice, + generatedAt, + }; +} + +/** + * Generates a minimal, synthetic reproduction package for upstream Substack escalation. + * Completely free of private user prose, API keys, or live cookies. + */ +export async function generateMinimalUpstreamReproductionPackage(outputDir: string): Promise<{ + packageDir: string; + files: string[]; +}> { + await mkdir(outputDir, { recursive: true }); + + const minimalTableHeaderReproduction: ProseMirrorNode = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Minimal reproduction of primary editor tableHeader failure." }, + ], + }, + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableHeader", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Column Header" }], + }, + ], + }, + ], + }, + { + type: "tableRow", + content: [ + { + type: "tableCell", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Data Value" }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const minimalRichReproduction: ProseMirrorNode = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Minimal Auxiliary Editor Crash Reproduction" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "A rich heading followed by an image and a blockquote." }], + }, + { + type: "image", + attrs: { + src: "https://substackcdn.com/image/fetch/synthetic-example.png", + alt: "Synthetic", + title: null, + }, + }, + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Blockquote content." }], + }, + ], + }, + ], + }; + + const readmeContent = `# Substack Editor Schema Incompatibility: Minimal Upstream Reproduction + +## Summary + +This package reproduces two independent ProseMirror/Tiptap schema deserialization failures in Substack's editor web interface: + +### 1. Primary Editor: tableHeader node rejection +- **Symptom:** Saving or loading a draft whose \`draft_body\` contains standard Tiptap table nodes with \`tableHeader\` causes Substack's editor deserializer to throw: + \`RangeError: Unknown node type: tableHeader\` +- **Reproduction:** See \`reproduction-table-header.json\`. +- **Expected Behavior:** Substack's editor schema should include \`@tiptap/extension-table-header\` or map \`tableHeader\` alongside \`tableCell\`. +- **Workaround:** Client-side transformation from \`table\` to accessible labelled lists. + +### 2. Auxiliary / Published-Revision Editor: Rich block rejection +- **Symptom:** Opening the edit screen for published posts with rich elements (headings, images, blockquotes, lists) triggers a secondary Tiptap editor mount that rejects non-paragraph nodes: + \`Editor content error encountered: [tiptap error]: Invalid JSON content\` + and displays the user-facing modal: + \`Something has gone wrong. Please refresh the page and try again.\` +- **Reproduction:** See \`reproduction-rich-auxiliary.json\`. +- **Expected Behavior:** Secondary editor mounts should either match the primary editor's full schema or not attempt to parse full post bodies against restricted schemas. +`; + + const filesWritten: string[] = []; + + const readmePath = resolve(outputDir, "README.md"); + await writeFile(readmePath, readmeContent, "utf8"); + filesWritten.push(readmePath); + + const tableReproPath = resolve(outputDir, "reproduction-table-header.json"); + await writeFile( + tableReproPath, + `${JSON.stringify({ document: minimalTableHeaderReproduction }, null, 2)}\n`, + "utf8", + ); + filesWritten.push(tableReproPath); + + const richReproPath = resolve(outputDir, "reproduction-rich-auxiliary.json"); + await writeFile( + richReproPath, + `${JSON.stringify({ document: minimalRichReproduction }, null, 2)}\n`, + "utf8", + ); + filesWritten.push(richReproPath); + + return { + packageDir: outputDir, + files: filesWritten, + }; +} diff --git a/src/editor-compatibility/contingencies.ts b/src/editor-compatibility/contingencies.ts new file mode 100644 index 00000000..2460a832 --- /dev/null +++ b/src/editor-compatibility/contingencies.ts @@ -0,0 +1,141 @@ +import type { ProseMirrorNode } from "../types.js"; +import type { EditorCompatibilityAssessment } from "./diagnostics.js"; + +/** + * Transforms tables containing unsupported tableHeader nodes into accessible, + * labelled bullet lists. Preserves all text content while removing the table structure + * that causes Substack primary editor deserialization failures. + */ +export function normalizeTablesToAccessibleLists(document: ProseMirrorNode): ProseMirrorNode { + function transformNode(node: ProseMirrorNode): ProseMirrorNode { + if (node.type === "table") { + return convertTableToBulletList(node); + } + + if (!node.content || node.content.length === 0) { + return { ...node }; + } + + return { + ...node, + content: node.content.map(transformNode), + }; + } + + return transformNode(document); +} + +function extractCellText(cellNode: ProseMirrorNode): string { + const parts: string[] = []; + function collect(n: ProseMirrorNode): void { + if (n.type === "text" && n.text) { + parts.push(n.text); + } + n.content?.forEach(collect); + } + collect(cellNode); + return parts.join(" ").trim(); +} + +function convertTableToBulletList(tableNode: ProseMirrorNode): ProseMirrorNode { + const rows = tableNode.content ?? []; + if (rows.length === 0) { + return { + type: "paragraph", + content: [{ type: "text", text: "[Empty Table]" }], + }; + } + + // First row often contains headers + const headerRow = rows[0]?.content ?? []; + const headers = headerRow.map((cell, idx) => { + const text = extractCellText(cell); + return text.length > 0 ? text : `Column ${idx + 1}`; + }); + + const dataRows = rows.slice(1); + const listItems: ProseMirrorNode[] = []; + + for (const row of dataRows) { + const cells = row.content ?? []; + const itemEntries: string[] = []; + + cells.forEach((cell, idx) => { + const headerLabel = headers[idx] ?? `Column ${idx + 1}`; + const cellValue = extractCellText(cell); + if (cellValue.length > 0) { + itemEntries.push(`${headerLabel}: ${cellValue}`); + } + }); + + if (itemEntries.length > 0) { + listItems.push({ + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: itemEntries.join(" | ") }], + }, + ], + }); + } + } + + // If table only had a header row or no data rows, represent headers as an item + if (listItems.length === 0) { + listItems.push({ + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: headers.join(" | ") }], + }, + ], + }); + } + + return { + type: "bulletList", + content: listItems, + }; +} + +/** + * Assesses operational risk for draft/published write workflows and advises + * whether a write should be blocked or routed to a contingency. + */ +export function evaluateEditorOperationalPolicy(assessment: EditorCompatibilityAssessment): { + canProceedWithPrimaryWrite: boolean; + hardBlockReason?: string | undefined; + remediationAdvice?: string | undefined; + contingencyRecommended?: "cli-update-in-place" | "table-normalization" | undefined; +} { + // Hard stop on primary editor incompatibility (e.g. tableHeader) + if (!assessment.primaryEditor.ok) { + const headers = assessment.primaryEditor.tableHeaderCount; + return { + canProceedWithPrimaryWrite: false, + hardBlockReason: `Primary editor schema rejection: document contains ${headers} unsupported tableHeader node(s).`, + remediationAdvice: + "Substack primary editor rejects tableHeader nodes (Unknown node type: tableHeader). " + + "Run table normalization to convert tables to accessible labelled lists, or replace tableHeader with tableCell.", + contingencyRecommended: "table-normalization", + }; + } + + // Auxiliary editor risk + if (assessment.auxiliaryEditor.riskLevel === "high") { + return { + canProceedWithPrimaryWrite: true, + remediationAdvice: + "Auxiliary editor risk: document contains rich nodes (headings/images/blockquotes/lists) " + + "that are known to trigger Substack secondary editor crash alerts on published revisions. " + + "Use CLI-only update-in-place as an operational contingency if browser editing displays 'Something has gone wrong'.", + contingencyRecommended: "cli-update-in-place", + }; + } + + return { + canProceedWithPrimaryWrite: true, + }; +} diff --git a/src/editor-compatibility/contract.test.ts b/src/editor-compatibility/contract.test.ts new file mode 100644 index 00000000..56bb058b --- /dev/null +++ b/src/editor-compatibility/contract.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, it } from "vitest"; +import type { ProseMirrorNode } from "../types.js"; +import { analyzeEditorCompatibility } from "./analyzer.js"; +import { + AUXILIARY_EDITOR_PROFILE, + PRIMARY_EDITOR_PROFILE, + PUBLIC_RENDER_PROFILE, +} from "./profiles.js"; + +const root = resolve(import.meta.dirname, "../.."); +const fixtureDir = resolve(root, "fixtures/prosemirror/editor-compatibility"); + +function loadDoc(name: string): ProseMirrorNode { + const content = readFileSync(resolve(fixtureDir, name), "utf8"); + return (JSON.parse(content) as { document: ProseMirrorNode }).document; +} + +describe("Editor Compatibility Contract", () => { + it("maintains strict independence between the five verification facets", () => { + const richDoc = loadDoc("rich-pilot-control.json"); + const tableDoc = loadDoc("me-001-tables.json"); + + const richAssessment = analyzeEditorCompatibility(richDoc, { + browserVerification: { + ok: false, + status: "failed", + observedAlert: "Something has gone wrong. Please refresh the page and try again.", + }, + }); + + const tableAssessment = analyzeEditorCompatibility(tableDoc, { + browserVerification: { + ok: false, + status: "failed", + details: "RangeError: Unknown node type: tableHeader", + }, + }); + + // 1. Contract invariant: Public render success does NOT imply primary editor compatibility + assert.equal(tableAssessment.publicRender.ok, true); + assert.equal(tableAssessment.primaryEditor.ok, false); + + // 2. Contract invariant: Stored body validity does NOT imply primary editor compatibility + assert.equal(tableAssessment.storedBodyRoundTrip.ok, true); + assert.equal(tableAssessment.primaryEditor.ok, false); + + // 3. Contract invariant: Primary editor compatibility does NOT imply auxiliary editor compatibility + assert.equal(richAssessment.primaryEditor.ok, true); + assert.equal(richAssessment.auxiliaryEditor.ok, false); + assert.equal(richAssessment.auxiliaryEditor.riskLevel, "high"); + + // 4. Contract invariant: Browser verification state is independent of static checks + assert.equal(richAssessment.browserVerification?.status, "failed"); + assert.equal(richAssessment.browserVerification?.ok, false); + + // 5. Contract invariant: Incompatible facets are not collapsed into a single status + assert.notEqual(richAssessment.primaryEditor.status, richAssessment.auxiliaryEditor.riskLevel); + }); + + it("produces deterministic schema fingerprints that detect profile drift", () => { + assert.equal(typeof PUBLIC_RENDER_PROFILE.schemaFingerprint, "string"); + assert.equal(PUBLIC_RENDER_PROFILE.schemaFingerprint.length, 16); + + assert.equal(typeof PRIMARY_EDITOR_PROFILE.schemaFingerprint, "string"); + assert.equal(PRIMARY_EDITOR_PROFILE.schemaFingerprint.length, 16); + + assert.equal(typeof AUXILIARY_EDITOR_PROFILE.schemaFingerprint, "string"); + assert.equal(AUXILIARY_EDITOR_PROFILE.schemaFingerprint.length, 16); + + // All fingerprints must differ + assert.notEqual( + PUBLIC_RENDER_PROFILE.schemaFingerprint, + PRIMARY_EDITOR_PROFILE.schemaFingerprint, + ); + assert.notEqual( + PRIMARY_EDITOR_PROFILE.schemaFingerprint, + AUXILIARY_EDITOR_PROFILE.schemaFingerprint, + ); + }); +}); diff --git a/src/editor-compatibility/diagnostics.ts b/src/editor-compatibility/diagnostics.ts new file mode 100644 index 00000000..c2846f32 --- /dev/null +++ b/src/editor-compatibility/diagnostics.ts @@ -0,0 +1,62 @@ +import type { ProfileId } from "./profiles.js"; + +export type FindingSeverity = "error" | "warning"; + +export interface CompatibilityFinding { + path: string; + nodeType: string; + markType?: string | undefined; + targetProfile: ProfileId; + severity: FindingSeverity; + code: string; + message: string; +} + +export interface ComplexityMetrics { + nodeCount: number; + deepestDepth: number; + tableCount: number; + tableHeaderCount: number; + mountedEditors: number; + duplicateEditorMountingDetected: boolean; + estimatedDomCostMultiplier: number; +} + +export interface ProfileCompatibilityStatus { + ok: boolean; + status: "supported" | "unsupported" | "compatible" | "incompatible"; + issues: CompatibilityFinding[]; +} + +export interface AuxiliaryCompatibilityStatus { + ok: boolean; + riskLevel: "none" | "low" | "high"; + incompatibleNodeCounts: Record; + issues: CompatibilityFinding[]; +} + +export interface StoredBodyRoundTripStatus { + ok: boolean; + status: "valid" | "invalid"; + byteSize: number; + error?: string | undefined; +} + +export interface BrowserVerificationStatus { + ok: boolean; + status: "verified" | "failed" | "unverified"; + details?: string | undefined; + observedAlert?: string | undefined; +} + +export interface EditorCompatibilityAssessment { + publicRender: ProfileCompatibilityStatus; + storedBodyRoundTrip: StoredBodyRoundTripStatus; + primaryEditor: ProfileCompatibilityStatus & { + tableHeaderCount: number; + tableCount: number; + }; + auxiliaryEditor: AuxiliaryCompatibilityStatus; + metrics: ComplexityMetrics; + browserVerification?: BrowserVerificationStatus | undefined; +} diff --git a/src/editor-compatibility/index.ts b/src/editor-compatibility/index.ts new file mode 100644 index 00000000..4df2edb0 --- /dev/null +++ b/src/editor-compatibility/index.ts @@ -0,0 +1,5 @@ +export * from "./analyzer.js"; +export * from "./canary.js"; +export * from "./contingencies.js"; +export * from "./diagnostics.js"; +export * from "./profiles.js"; diff --git a/src/editor-compatibility/profiles.ts b/src/editor-compatibility/profiles.ts new file mode 100644 index 00000000..13141898 --- /dev/null +++ b/src/editor-compatibility/profiles.ts @@ -0,0 +1,132 @@ +import { createHash } from "node:crypto"; + +export type ProfileId = "public-render" | "primary-editor" | "auxiliary-editor"; + +export interface EditorCapabilityProfile { + id: ProfileId; + name: string; + description: string; + version: string; + supportedNodeTypes: ReadonlySet; + supportedMarkTypes: ReadonlySet; + schemaFingerprint: string; +} + +const PUBLIC_RENDER_NODE_TYPES = [ + "blockquote", + "bulletList", + "codeBlock", + "doc", + "embedNode", + "hardBreak", + "heading", + "horizontalRule", + "image", + "listItem", + "orderedList", + "paragraph", + "paywallDivider", + "subscribeWidget", + "table", + "tableCell", + "tableHeader", + "tableRow", + "text", +] as const; + +const PUBLIC_RENDER_MARK_TYPES = ["bold", "code", "italic", "link", "strike", "underline"] as const; + +/** + * The Primary Draft Editor profile represents Substack's main post editor. + * Crucially: it supports rich elements, headings, images, and tables, but + * rejects 'tableHeader' nodes with "RangeError: Unknown node type: tableHeader". + */ +const PRIMARY_EDITOR_NODE_TYPES = [ + "blockquote", + "bulletList", + "codeBlock", + "doc", + "embedNode", + "hardBreak", + "heading", + "horizontalRule", + "image", + "listItem", + "orderedList", + "paragraph", + "paywallDivider", + "subscribeWidget", + "table", + "tableCell", + "tableRow", + "text", +] as const; + +const PRIMARY_EDITOR_MARK_TYPES = [ + "bold", + "code", + "italic", + "link", + "strike", + "underline", +] as const; + +/** + * The Auxiliary / Restricted Editor profile represents secondary editor mounts + * (observed during published-post revisions and auxiliary views). + * It rejects images, headings, blockquotes, lists, and tables with + * "[tiptap error]: Invalid JSON content". + */ +const AUXILIARY_EDITOR_NODE_TYPES = [ + "doc", + "hardBreak", + "paragraph", + "paywallDivider", + "subscribeWidget", + "text", +] as const; + +const AUXILIARY_EDITOR_MARK_TYPES = ["bold", "code", "italic", "link", "strike"] as const; + +function computeFingerprint(nodes: readonly string[], marks: readonly string[]): string { + const content = `nodes:${[...nodes].sort().join(",")};marks:${[...marks].sort().join(",")}`; + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +export const PUBLIC_RENDER_PROFILE: EditorCapabilityProfile = { + id: "public-render", + name: "Public Render Profile", + description: + "Standard web reader rendering capabilities supporting full rich prose and GFM tables", + version: "2026-08-12", + supportedNodeTypes: new Set(PUBLIC_RENDER_NODE_TYPES), + supportedMarkTypes: new Set(PUBLIC_RENDER_MARK_TYPES), + schemaFingerprint: computeFingerprint(PUBLIC_RENDER_NODE_TYPES, PUBLIC_RENDER_MARK_TYPES), +}; + +export const PRIMARY_EDITOR_PROFILE: EditorCapabilityProfile = { + id: "primary-editor", + name: "Primary Draft Editor Profile", + description: "Substack primary draft editor schema; rejects tableHeader nodes", + version: "2026-08-12", + supportedNodeTypes: new Set(PRIMARY_EDITOR_NODE_TYPES), + supportedMarkTypes: new Set(PRIMARY_EDITOR_MARK_TYPES), + schemaFingerprint: computeFingerprint(PRIMARY_EDITOR_NODE_TYPES, PRIMARY_EDITOR_MARK_TYPES), +}; + +export const AUXILIARY_EDITOR_PROFILE: EditorCapabilityProfile = { + id: "auxiliary-editor", + name: "Auxiliary Restricted Editor Profile", + description: + "Substack secondary editor schema mounted in published revision views; rejects rich blocks", + version: "2026-08-12", + supportedNodeTypes: new Set(AUXILIARY_EDITOR_NODE_TYPES), + supportedMarkTypes: new Set(AUXILIARY_EDITOR_MARK_TYPES), + schemaFingerprint: computeFingerprint(AUXILIARY_EDITOR_NODE_TYPES, AUXILIARY_EDITOR_MARK_TYPES), +}; + +export const EDITOR_PROFILES: Record = { + "public-render": PUBLIC_RENDER_PROFILE, + "primary-editor": PRIMARY_EDITOR_PROFILE, + "auxiliary-editor": AUXILIARY_EDITOR_PROFILE, +}; diff --git a/src/editor-compatibility/property.test.ts b/src/editor-compatibility/property.test.ts new file mode 100644 index 00000000..037943c4 --- /dev/null +++ b/src/editor-compatibility/property.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import fc from "fast-check"; +import { describe, it } from "vitest"; +import type { ProseMirrorNode } from "../types.js"; +import { analyzeEditorCompatibility } from "./analyzer.js"; +import { normalizeTablesToAccessibleLists } from "./contingencies.js"; + +function makeDocWithTable(tableHeaderCount: number): ProseMirrorNode { + const headers: ProseMirrorNode[] = []; + for (let i = 0; i < tableHeaderCount; i++) { + headers.push({ + type: "tableHeader", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: `Col ${i + 1}` }], + }, + ], + }); + } + + return { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Introduction" }], + }, + { + type: "table", + content: [ + { + type: "tableRow", + content: headers, + }, + { + type: "tableRow", + content: headers.map((_, i) => ({ + type: "tableCell", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: `Data ${i + 1}` }], + }, + ], + })), + }, + ], + }, + ], + }; +} + +describe("Editor Compatibility Property Tests", () => { + it("proves that any document containing tableHeader is incompatible with primary editor", () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 20 }), (headerCount) => { + const doc = makeDocWithTable(headerCount); + const assessment = analyzeEditorCompatibility(doc); + + assert.equal(assessment.primaryEditor.ok, false); + assert.equal(assessment.primaryEditor.status, "incompatible"); + assert.equal(assessment.primaryEditor.tableHeaderCount, headerCount); + assert.equal(assessment.primaryEditor.issues.length, headerCount); + }), + { numRuns: 50 }, + ); + }); + + it("proves that table normalization guarantees zero tableHeader nodes and compatible primary editor", () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 20 }), (headerCount) => { + const doc = makeDocWithTable(headerCount); + const normalized = normalizeTablesToAccessibleLists(doc); + const assessment = analyzeEditorCompatibility(normalized); + + assert.equal(assessment.primaryEditor.ok, true); + assert.equal(assessment.primaryEditor.tableHeaderCount, 0); + assert.equal(assessment.primaryEditor.tableCount, 0); + assert.equal(assessment.storedBodyRoundTrip.ok, true); + }), + { numRuns: 50 }, + ); + }); +}); diff --git a/src/publish/browser-workflow.ts b/src/publish/browser-workflow.ts index b371f853..1ec691c4 100644 --- a/src/publish/browser-workflow.ts +++ b/src/publish/browser-workflow.ts @@ -13,6 +13,7 @@ import { withStagehandRetry, } from "../browser/stagehand.js"; import { loadEffectiveConfig, requirePublicationUrl } from "../config/store.js"; +import { analyzeEditorCompatibility } from "../editor-compatibility/analyzer.js"; import type { DraftMapping } from "../substack-api/draft-mappings.js"; import { validatePayloadCompatibility } from "../substack-api/payload.js"; import type { PreparedPost } from "../types.js"; @@ -489,6 +490,7 @@ async function createDraftInBrowser( export function printPreparedPost(prepared: PreparedPost): void { const { post } = prepared; const compatibility = validatePayloadCompatibility(post.document); + const editorCompatibility = analyzeEditorCompatibility(post.document); console.log( JSON.stringify( @@ -507,6 +509,7 @@ export function printPreparedPost(prepared: PreparedPost): void { supportedMarkTypes: compatibility.markTypes, unsupportedIssues: compatibility.issues.length > 0 ? compatibility.issues : undefined, }, + editorCompatibility, }, null, 2, diff --git a/src/publish/prepublish.test.ts b/src/publish/prepublish.test.ts index e625d8e4..1294581b 100644 --- a/src/publish/prepublish.test.ts +++ b/src/publish/prepublish.test.ts @@ -135,4 +135,51 @@ title: "Scheduled Post" assert.equal(report.payload, undefined); assert.deepEqual(report.warnings, ["Parser warning"]); }); + + it("blocks prepublish when document contains tableHeader nodes", () => { + const prepared = { + mode: "draft" as const, + scheduleAt: undefined, + post: { + filePath: "table-post.md", + metadata: { title: "Table Post", tags: [] }, + markdown: "", + html: "", + document: { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableHeader", + content: [{ type: "paragraph", content: [{ type: "text", text: "Header" }] }], + }, + ], + }, + ], + }, + ], + }, + media: { + assets: [], + localCount: 0, + remoteCount: 0, + dataCount: 0, + }, + warnings: [], + }, + }; + + const report = prepublishPost(prepared); + + assert.equal(report.status, "blocked"); + assert.equal(report.editorCompatibility?.primaryEditor.ok, false); + assert.equal(report.editorCompatibility?.primaryEditor.tableHeaderCount, 1); + assert.match(report.message, /primary editor rejects tableHeader nodes/i); + assert.match(report.message, /table normalization/i); + }); }); diff --git a/src/publish/prepublish.ts b/src/publish/prepublish.ts index f217a319..36c2c6dc 100644 --- a/src/publish/prepublish.ts +++ b/src/publish/prepublish.ts @@ -1,3 +1,5 @@ +import { analyzeEditorCompatibility } from "../editor-compatibility/analyzer.js"; +import type { EditorCompatibilityAssessment } from "../editor-compatibility/diagnostics.js"; import type { SubstackDraftPayload } from "../substack-api/payload.js"; import { buildSubstackDraftPayload, @@ -14,6 +16,7 @@ export interface PrepublishReport { scheduleAt?: string | undefined; warnings: string[]; compatibility: ReturnType; + editorCompatibility?: EditorCompatibilityAssessment | undefined; payload?: SubstackDraftPayload | undefined; message: string; } @@ -21,6 +24,7 @@ export interface PrepublishReport { export function prepublishPost(prepared: PreparedPost): PrepublishReport { const title = resolvePostTitle(prepared.post); const compatibility = validatePayloadCompatibility(prepared.post.document); + const editorCompatibility = analyzeEditorCompatibility(prepared.post.document); if (!compatibility.ok) { return { @@ -31,10 +35,29 @@ export function prepublishPost(prepared: PreparedPost): PrepublishReport { scheduleAt: prepared.scheduleAt, warnings: prepared.post.warnings, compatibility, + editorCompatibility, message: "Prepublish blocked because the post contains unsupported Substack payload content.", }; } + if (!editorCompatibility.primaryEditor.ok) { + const headerCount = editorCompatibility.primaryEditor.tableHeaderCount; + return { + status: "blocked", + mode: prepared.mode, + filePath: prepared.post.filePath, + title, + scheduleAt: prepared.scheduleAt, + warnings: [ + ...prepared.post.warnings, + `Primary editor schema incompatibility: contains ${headerCount} tableHeader node(s).`, + ], + compatibility, + editorCompatibility, + message: `Prepublish blocked: Substack primary editor rejects tableHeader nodes (Unknown node type: tableHeader). Run table normalization or convert tableHeader to tableCell.`, + }; + } + return { status: "ready", mode: prepared.mode, @@ -43,6 +66,7 @@ export function prepublishPost(prepared: PreparedPost): PrepublishReport { scheduleAt: prepared.scheduleAt, warnings: prepared.post.warnings, compatibility, + editorCompatibility, payload: buildSubstackDraftPayload(prepared.post), message: "Prepublish validation passed. The payload is ready for browser publishing or future API transport.",