diff --git a/.changeset/indented-mdx-jsx-bridge-fidelity.md b/.changeset/indented-mdx-jsx-bridge-fidelity.md new file mode 100644 index 00000000..dd37f8a2 --- /dev/null +++ b/.changeset/indented-mdx-jsx-bridge-fidelity.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Fix a round-trip fidelity bug for documents that use indented MDX-JSX container components (``, ``, and similar nested components). Editing one of these documents no longer risks silent indentation rewrites, content reordering, or duplication: the editor's bridge now recognizes the serializer's container formatting as equivalent to the authored source, so the document settles to a stable state in a single pass and what you typed is what gets stored. diff --git a/docs/next-env.d.ts b/docs/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/docs/next-env.d.ts +++ b/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/app/tests/fidelity/invariant-i13.test.ts b/packages/app/tests/fidelity/invariant-i13.test.ts index 99cc8aaa..76781bd9 100644 --- a/packages/app/tests/fidelity/invariant-i13.test.ts +++ b/packages/app/tests/fidelity/invariant-i13.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from 'bun:test'; import { normalizeBridge } from '@inkeep/open-knowledge-core'; import type { JSONContent } from '@tiptap/core'; import * as fc from 'fast-check'; -import { loadBuiltInFixtures } from '../../../core/src/markdown/fixtures/index.ts'; +import { + loadBuiltInFixtures, + loadIndentedJsxFixtures, +} from '../../../core/src/markdown/fixtures/index.ts'; import { assertAcrossSeeds, mdManager, NUM_RUNS } from './helpers'; function walkJsxComponents(node: JSONContent, mutate: (n: JSONContent) => void): void { @@ -189,6 +192,7 @@ const INDENTED_JSX_CLASS: IndentedJsxCase[] = [ '', ].join('\n'), }, + ...loadIndentedJsxFixtures(), ]; describe('I13 — indented-children MDX JSX bridge fixed-point (PRD-7110)', () => { @@ -201,9 +205,154 @@ describe('I13 — indented-children MDX JSX bridge fixed-point (PRD-7110)', () = const once = dirtyRoundTrip(source); expect(dirtyRoundTrip(once)).toBe(once); }); + + test(`${name}: repeated dirty drains stay a within-tolerance fixed point (no byte growth)`, () => { + const normalizedSource = normalizeBridge(source); + const first = dirtyRoundTrip(source); + expect(normalizeBridge(first)).toBe(normalizedSource); + let cur = first; + for (let i = 1; i < 6; i++) { + cur = dirtyRoundTrip(cur); + expect(normalizeBridge(cur)).toBe(normalizedSource); + expect(cur).toBe(first); + } + }); } }); +function findJsxContainer(root: JSONContent, componentName: string): JSONContent | undefined { + let found: JSONContent | undefined; + const visit = (n: JSONContent): void => { + if (found) return; + if (n.type === 'jsxComponent' && n.attrs?.componentName === componentName) found = n; + else n.content?.forEach(visit); + }; + visit(root); + return found; +} + +function structurallyEditAndSerialize(md: string, edit: (root: JSONContent) => void): string { + const json = mdManager.parse(md); + edit(json); + walkJsxComponents(json, (node) => { + if (node.attrs) node.attrs.sourceDirty = true; + }); + return mdManager.serialize(json); +} + +function occurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +function markerOrder(md: string, markers: readonly string[]): string[] { + return markers + .map((m) => ({ m, idx: md.indexOf(m) })) + .filter(({ idx }) => idx >= 0) + .sort((a, b) => a.idx - b.idx) + .map(({ m }) => m); +} + +const O3_THREE_STEP = [ + '', + '', + '', + '', + 'Alpha marker body.', + '', + '', + '', + '', + '', + 'Bravo marker body.', + '', + '', + '', + '', + '', + 'Charlie marker body.', + '', + '', + '', + '', + '', +].join('\n'); + +const O3_MARKERS = ['Alpha marker body.', 'Bravo marker body.', 'Charlie marker body.'] as const; + +describe('I13 — structural child-edit invariant (PRD-7110)', () => { + function assertEditedFixedPoint(editedMd: string): void { + expect(dirtyRoundTrip(editedMd)).toBe(editedMd); + expect(normalizeBridge(dirtyRoundTrip(editedMd))).toBe(normalizeBridge(editedMd)); + } + + test('swap: siblings reorder, each marker once, correct order, fixed point', () => { + const edited = structurallyEditAndSerialize(O3_THREE_STEP, (root) => { + const kids = findJsxContainer(root, 'Steps')?.content; + if (!kids) throw new Error('Steps container not found'); + [kids[0], kids[2]] = [kids[2], kids[0]]; + }); + for (const m of O3_MARKERS) expect(occurrences(edited, m)).toBe(1); + expect(markerOrder(edited, O3_MARKERS)).toEqual([ + 'Charlie marker body.', + 'Bravo marker body.', + 'Alpha marker body.', + ]); + assertEditedFixedPoint(edited); + }); + + test('delete: one sibling removed, remaining markers once, order preserved, fixed point', () => { + const edited = structurallyEditAndSerialize(O3_THREE_STEP, (root) => { + const kids = findJsxContainer(root, 'Steps')?.content; + if (!kids) throw new Error('Steps container not found'); + kids.splice(1, 1); + }); + expect(occurrences(edited, 'Bravo marker body.')).toBe(0); + expect(occurrences(edited, 'Alpha marker body.')).toBe(1); + expect(occurrences(edited, 'Charlie marker body.')).toBe(1); + expect(markerOrder(edited, O3_MARKERS)).toEqual(['Alpha marker body.', 'Charlie marker body.']); + assertEditedFixedPoint(edited); + }); + + test('insert: new sibling appended, all markers once, order preserved, fixed point', () => { + const all = [...O3_MARKERS, 'Delta marker body.']; + const edited = structurallyEditAndSerialize(O3_THREE_STEP, (root) => { + const kids = findJsxContainer(root, 'Steps')?.content; + if (!kids) throw new Error('Steps container not found'); + kids.push({ + type: 'jsxComponent', + attrs: { + componentName: 'Step', + kind: 'element', + attributes: [], + props: {}, + sourceDirty: true, + }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Delta marker body.' }] }], + }); + }); + for (const m of all) expect(occurrences(edited, m)).toBe(1); + expect(markerOrder(edited, all)).toEqual(all); + assertEditedFixedPoint(edited); + }); + + test('closing-tag edit (componentName rename) re-emits open+close, no sibling reorder/duplication', () => { + const edited = structurallyEditAndSerialize(O3_THREE_STEP, (root) => { + const target = findJsxContainer(root, 'Steps')?.content?.[1]; + if (!target?.attrs) throw new Error('Step[1] not found'); + target.attrs.componentName = 'Stage'; + }); + expect(edited).toContain(''); + expect(edited).toContain(''); + for (const m of O3_MARKERS) expect(occurrences(edited, m)).toBe(1); + expect(markerOrder(edited, O3_MARKERS)).toEqual([ + 'Alpha marker body.', + 'Bravo marker body.', + 'Charlie marker body.', + ]); + assertEditedFixedPoint(edited); + }); +}); + describe('I13 — reconstructAttrs overlays non-JSON preserved expressions (PRD-7110)', () => { test('a genuine edit overlays a non-JSON preserved expression attr (not kept)', () => { const source = '\n\n Body paragraph.\n\n\n'; diff --git a/packages/app/tests/integration/c14-indented-jsx-concurrent.test.ts b/packages/app/tests/integration/c14-indented-jsx-concurrent.test.ts new file mode 100644 index 00000000..b5826944 --- /dev/null +++ b/packages/app/tests/integration/c14-indented-jsx-concurrent.test.ts @@ -0,0 +1,129 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { setTimeout as wait } from 'node:timers/promises'; +import * as Y from 'yjs'; +import { HARNESS_BOOT_TIMEOUT_MS } from './harness-boot-timeout'; +import { + agentWriteMd, + assertBridgeInvariant, + createTestClients, + createTestServer, + pollUntil, + type TestClient, + type TestServer, +} from './test-harness'; + +let server: TestServer; + +beforeAll(async () => { + server = await createTestServer(); +}, HARNESS_BOOT_TIMEOUT_MS); + +afterAll(async () => { + await server.cleanup(); +}); + +const STEP_MARKERS = ['STEP-ONE-BODY', 'STEP-TWO-BODY', 'STEP-THREE-BODY', 'STEP-FOUR-BODY']; + +const FOUR_STEP_SEED = [ + '', + '', + '', + '', + 'STEP-ONE-BODY first instruction.', + '', + '', + '', + '', + '', + 'STEP-TWO-BODY second instruction.', + '', + '', + '', + '', + '', + 'STEP-THREE-BODY third instruction.', + '', + '', + '', + '', + '', + 'STEP-FOUR-BODY fourth instruction.', + '', + '', + '', + '', + '', +].join('\n'); + +function appendParagraph(client: TestClient, text: string): void { + const paragraph = new Y.XmlElement('paragraph'); + const ytext = new Y.XmlText(); + ytext.applyDelta([{ insert: text }]); + paragraph.insert(0, [ytext]); + client.fragment.push([paragraph]); +} + +async function awaitAllContain(clients: TestClient[], markers: string[]): Promise { + for (const marker of markers) { + for (const client of clients) { + await pollUntil(() => client.ytext.toString().includes(marker), 5000); + } + } + await wait(600); +} + +describe('C14: concurrent edits on a 4-Step indented-JSX doc', () => { + test('two clients + agent write across a divergence window converge to a bounded, in-order fixed point', async () => { + const docName = `c14-4step-${crypto.randomUUID()}`; + const clients = await createTestClients(server.port, { + count: 2, + docName, + perClientOptions: { skipInvariantWatcher: true, syncControl: true }, + }); + try { + await agentWriteMd(server.port, FOUR_STEP_SEED, { docName, position: 'replace' }); + await awaitAllContain(clients, STEP_MARKERS); + + clients[0].pauseSync(); + appendParagraph(clients[0], 'C14-WYSIWYG-A'); + appendParagraph(clients[1], 'C14-WYSIWYG-B'); + await agentWriteMd(server.port, '\n\nC14-AGENT-EDIT trailing.\n', { + docName, + position: 'append', + }); + await wait(300); + + clients[0].resumeSync(); + await awaitAllContain(clients, [ + ...STEP_MARKERS, + 'C14-WYSIWYG-A', + 'C14-WYSIWYG-B', + 'C14-AGENT-EDIT', + ]); + + const ytexts = clients.map((c) => c.ytext.toString()); + expect(ytexts[1]).toBe(ytexts[0]); + for (const client of clients) assertBridgeInvariant(client.ytext, client.fragment); + + const converged = ytexts[0]; + + const positions = STEP_MARKERS.map((m) => { + expect(converged.split(m).length - 1).toBe(1); + return converged.indexOf(m); + }); + for (let i = 1; i < positions.length; i++) { + expect(positions[i]).toBeGreaterThan(positions[i - 1]); + } + for (const m of ['C14-WYSIWYG-A', 'C14-WYSIWYG-B', 'C14-AGENT-EDIT']) { + expect(converged.split(m).length - 1).toBe(1); + } + + const authoredBytes = + Buffer.byteLength(FOUR_STEP_SEED) + + Buffer.byteLength('C14-WYSIWYG-A C14-WYSIWYG-B C14-AGENT-EDIT trailing.'); + expect(Buffer.byteLength(converged)).toBeLessThanOrEqual(authoredBytes * 3); + } finally { + for (const c of clients) await c.cleanup(); + } + }, 30_000); +}); diff --git a/packages/app/tests/integration/c15-dual-embed-divergence.test.ts b/packages/app/tests/integration/c15-dual-embed-divergence.test.ts new file mode 100644 index 00000000..94712302 --- /dev/null +++ b/packages/app/tests/integration/c15-dual-embed-divergence.test.ts @@ -0,0 +1,137 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { setTimeout as wait } from 'node:timers/promises'; +import * as Y from 'yjs'; +import { HARNESS_BOOT_TIMEOUT_MS } from './harness-boot-timeout'; +import { + agentWriteMd, + assertBridgeInvariant, + createTestClients, + createTestServer, + pollUntil, + type TestClient, + type TestServer, +} from './test-harness'; + +let server: TestServer; + +beforeAll(async () => { + server = await createTestServer(); +}, HARNESS_BOOT_TIMEOUT_MS); + +afterAll(async () => { + await server.cleanup(); +}); + +const DUAL_EMBED_SEED = [ + '# C15 chart doc', + '', + '```html h=400px preview', + '
', + '', + '```', + '', + 'Prose between the two embeds.', + '', + '```html h=640px preview', + '
', + '', + '```', + '', +].join('\n'); + +const BRACE_INJECTION_RE = /\{onst|\{on\{|\{ons\{|\{var\{/; + +function appendParagraph(client: TestClient, text: string): void { + const paragraph = new Y.XmlElement('paragraph'); + const ytext = new Y.XmlText(); + ytext.applyDelta([{ insert: text }]); + paragraph.insert(0, [ytext]); + client.fragment.push([paragraph]); +} + +function extractScriptBodies(doc: string): string[] { + const bodies: string[] = []; + const re = /]*>([\s\S]*?)<\/script>/g; + let m: RegExpExecArray | null = re.exec(doc); + while (m !== null) { + const body = (m[1] ?? '').trim(); + if (body.length > 0) bodies.push(body); + m = re.exec(doc); + } + return bodies; +} + +function jsParses(code: string): boolean { + try { + new Function(code); + return true; + } catch { + return false; + } +} + +async function awaitAllContain(clients: TestClient[], markers: string[]): Promise { + for (const marker of markers) { + for (const client of clients) { + await pollUntil(() => client.ytext.toString().includes(marker), 5000); + } + } + await wait(600); +} + +describe('C15: dual html-preview embeds under divergence', () => { + test('embeds survive concurrent edits + reconnect — scripts intact, no injection, bounded', async () => { + const docName = `c15-dual-embed-${crypto.randomUUID()}`; + const clients = await createTestClients(server.port, { + count: 2, + docName, + perClientOptions: { skipInvariantWatcher: true, syncControl: true }, + }); + try { + await agentWriteMd(server.port, DUAL_EMBED_SEED, { docName, position: 'replace' }); + await awaitAllContain(clients, ['C15-FIRST-SCRIPT', 'C15-SECOND-SCRIPT']); + + clients[0].pauseSync(); + appendParagraph(clients[0], 'C15-WYSIWYG-A'); + appendParagraph(clients[1], 'C15-WYSIWYG-B'); + await wait(300); + clients[0].resumeSync(); + await awaitAllContain(clients, [ + 'C15-FIRST-SCRIPT', + 'C15-SECOND-SCRIPT', + 'C15-WYSIWYG-A', + 'C15-WYSIWYG-B', + ]); + + const ytexts = clients.map((c) => c.ytext.toString()); + expect(ytexts[1]).toBe(ytexts[0]); + for (const client of clients) assertBridgeInvariant(client.ytext, client.fragment); + + const converged = ytexts[0]; + + expect(BRACE_INJECTION_RE.test(converged)).toBe(false); + expect(converged).toContain('const DATA = {'); + const scripts = extractScriptBodies(converged); + expect(scripts.length).toBeGreaterThanOrEqual(2); + for (const body of scripts) { + expect(jsParses(body)).toBe(true); + } + + for (const m of ['C15-FIRST-SCRIPT', 'C15-SECOND-SCRIPT']) { + expect(converged.split(m).length - 1).toBe(1); + } + + const authoredBytes = + Buffer.byteLength(DUAL_EMBED_SEED) + Buffer.byteLength('C15-WYSIWYG-A C15-WYSIWYG-B'); + expect(Buffer.byteLength(converged)).toBeLessThanOrEqual(authoredBytes * 3); + } finally { + for (const c of clients) await c.cleanup(); + } + }, 30_000); +}); diff --git a/packages/app/tests/integration/jsx-undo-roundtrip.test.ts b/packages/app/tests/integration/jsx-undo-roundtrip.test.ts new file mode 100644 index 00000000..aaecc40c --- /dev/null +++ b/packages/app/tests/integration/jsx-undo-roundtrip.test.ts @@ -0,0 +1,138 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { setTimeout as wait } from 'node:timers/promises'; +import { normalizeBridge } from '@inkeep/open-knowledge-core'; +import { HARNESS_BOOT_TIMEOUT_MS } from './harness-boot-timeout'; +import { + agentUndo, + agentWriteMd, + assertBridgeInvariant, + createTestServer, + type TestServer, +} from './test-harness'; + +let server: TestServer; + +beforeAll(async () => { + server = await createTestServer(); +}, HARNESS_BOOT_TIMEOUT_MS); + +afterAll(async () => { + await server.cleanup(); +}); + +const STEP_MARKERS = ['STEP-ONE-BODY', 'STEP-TWO-BODY', 'STEP-THREE-BODY']; + +const MULTI_STEP_SEED = [ + '', + '', + '', + '', + 'STEP-ONE-BODY first.', + '', + '', + '', + '', + '', + 'STEP-TWO-BODY second.', + '', + '', + '', + '', + '', + 'STEP-THREE-BODY third.', + '', + '', + '', + '', + '', +].join('\n'); + +describe('O4 — undo round-trip on an indented multi-Step doc', () => { + test('undo returns the source within tolerance, with no re-dirty after settle', async () => { + const docName = `o4-jsx-${crypto.randomUUID()}`; + const agentSuffix = `o4-${crypto.randomUUID().slice(0, 8)}`; + const connectionId = `agent-${agentSuffix}`; + const sm = server.instance.sessionManager; + try { + await agentWriteMd(server.port, MULTI_STEP_SEED, { + docName, + agentId: agentSuffix, + agentName: `A-${agentSuffix}`, + position: 'replace', + }); + await wait(600); + + const sess = await sm.getSession(docName, connectionId); + const ytext = sess.dc.document.getText('source'); + const preEdit = ytext.toString(); + for (const m of STEP_MARKERS) expect(preEdit).toContain(m); + + await agentWriteMd(server.port, '\n\nO4-UNDOABLE-EDIT paragraph.\n', { + docName, + agentId: agentSuffix, + agentName: `A-${agentSuffix}`, + position: 'append', + }); + await wait(600); + expect(ytext.toString()).toContain('O4-UNDOABLE-EDIT'); + + await agentUndo(server.port, { docName, connectionId, scope: 'last' }); + await wait(400); + + const afterUndo = ytext.toString(); + expect(afterUndo).not.toContain('O4-UNDOABLE-EDIT'); + expect(normalizeBridge(afterUndo)).toBe(normalizeBridge(preEdit)); + for (const m of STEP_MARKERS) expect(afterUndo).toContain(m); + + await wait(600); + expect(ytext.toString()).not.toContain('O4-UNDOABLE-EDIT'); + expect(normalizeBridge(ytext.toString())).toBe(normalizeBridge(preEdit)); + assertBridgeInvariant(ytext, sess.dc.document.getXmlFragment('default')); + } finally { + await sm.closeSession(docName, connectionId).catch(() => {}); + } + }, 30_000); + + test('a concurrent peer edit survives the agent undo', async () => { + const docName = `o4-peer-${crypto.randomUUID()}`; + const agentSuffix = `o4p-${crypto.randomUUID().slice(0, 8)}`; + const connectionId = `agent-${agentSuffix}`; + const sm = server.instance.sessionManager; + try { + await agentWriteMd(server.port, MULTI_STEP_SEED, { + docName, + agentId: agentSuffix, + agentName: `A-${agentSuffix}`, + position: 'replace', + }); + await wait(600); + + await agentWriteMd(server.port, '\n\nO4-AGENT-EDIT paragraph.\n', { + docName, + agentId: agentSuffix, + agentName: `A-${agentSuffix}`, + position: 'append', + }); + await wait(400); + + const sess = await sm.getSession(docName, connectionId); + const ytext = sess.dc.document.getText('source'); + sess.dc.document.transact(() => { + ytext.insert(ytext.length, '\n\nO4-PEER-KEYSTROKE survives.\n'); + }); + await wait(400); + expect(ytext.toString()).toContain('O4-AGENT-EDIT'); + expect(ytext.toString()).toContain('O4-PEER-KEYSTROKE'); + + await agentUndo(server.port, { docName, connectionId, scope: 'last' }); + await wait(400); + + const finalText = ytext.toString(); + expect(finalText).not.toContain('O4-AGENT-EDIT'); + expect(finalText).toContain('O4-PEER-KEYSTROKE'); + for (const m of STEP_MARKERS) expect(finalText).toContain(m); + } finally { + await sm.closeSession(docName, connectionId).catch(() => {}); + } + }, 30_000); +}); diff --git a/packages/app/tests/integration/map-driven-observer-a-crdt.test.ts b/packages/app/tests/integration/map-driven-observer-a-crdt.test.ts index 99308585..c05d7f17 100644 --- a/packages/app/tests/integration/map-driven-observer-a-crdt.test.ts +++ b/packages/app/tests/integration/map-driven-observer-a-crdt.test.ts @@ -3,6 +3,7 @@ import { setTimeout as wait } from 'node:timers/promises'; import * as Y from 'yjs'; import { HARNESS_BOOT_TIMEOUT_MS } from './harness-boot-timeout'; import { + agentWriteMd, assertBridgeInvariant, createTestClients, createTestServer, @@ -96,4 +97,47 @@ describe('map-driven Observer A — cross-CRDT integration', () => { for (const c of clients) await c.cleanup(); } }); + + test('(c) indented-JSX doc converges within byte budget, no Step duplication (PRD-7110 amplifier)', async () => { + const docName = `map-driven-jsx-${crypto.randomUUID()}`; + const clients = await createTestClients(server.port, { + count: 2, + docName, + perClientOptions: { skipInvariantWatcher: true }, + }); + try { + const seed = + '\n\n\n\nSTEP-ALPHA-BODY paragraph.\n\n\n\n\n\nSTEP-BRAVO-BODY paragraph.\n\n\n\n\n'; + await agentWriteMd(server.port, seed, { docName, position: 'replace' }); + await awaitConvergence(clients, ['STEP-ALPHA-BODY', 'STEP-BRAVO-BODY']); + + appendParagraph(clients[0], 'MAP-JSX-EDIT-A'); + appendParagraph(clients[1], 'MAP-JSX-EDIT-B'); + await awaitConvergence(clients, [ + 'STEP-ALPHA-BODY', + 'STEP-BRAVO-BODY', + 'MAP-JSX-EDIT-A', + 'MAP-JSX-EDIT-B', + ]); + + const ytexts = clients.map((c) => c.ytext.toString()); + expect(ytexts[1]).toBe(ytexts[0]); + for (const client of clients) assertBridgeInvariant(client.ytext, client.fragment); + + const converged = ytexts[0]; + for (const marker of [ + 'STEP-ALPHA-BODY', + 'STEP-BRAVO-BODY', + 'MAP-JSX-EDIT-A', + 'MAP-JSX-EDIT-B', + ]) { + expect(converged.split(marker).length - 1).toBe(1); + } + const authoredBytes = + Buffer.byteLength(seed) + Buffer.byteLength('MAP-JSX-EDIT-A MAP-JSX-EDIT-B'); + expect(Buffer.byteLength(converged)).toBeLessThanOrEqual(authoredBytes * 3); + } finally { + for (const c of clients) await c.cleanup(); + } + }, 30_000); }); diff --git a/packages/app/tests/integration/test-harness.ts b/packages/app/tests/integration/test-harness.ts index 793295dc..d4b87871 100644 --- a/packages/app/tests/integration/test-harness.ts +++ b/packages/app/tests/integration/test-harness.ts @@ -506,7 +506,11 @@ export async function agentWriteMd( colorSeed: opts?.colorSeed, }), }); - if (!res.ok) throw new Error(`agent-write-md failed: ${res.status}`); + if (!res.ok) { + const err: Error & { status?: number } = new Error(`agent-write-md failed: ${res.status}`); + err.status = res.status; + throw err; + } } /** POST to agent-patch endpoint (find-and-replace). diff --git a/packages/app/tests/stress/bridge-convergence.fuzz.test.ts b/packages/app/tests/stress/bridge-convergence.fuzz.test.ts index 2da2c53c..f9cb72c6 100644 --- a/packages/app/tests/stress/bridge-convergence.fuzz.test.ts +++ b/packages/app/tests/stress/bridge-convergence.fuzz.test.ts @@ -115,6 +115,8 @@ type Op = text: string; marker: string; } + | { kind: 'jsx-block'; text: string; marker: string } + | { kind: 'large-embed'; text: string; marker: string } | { kind: 'sync-pause'; clientIdx: number } | { kind: 'sync-resume'; clientIdx: number } | { kind: 'wait'; ms: number }; @@ -165,7 +167,15 @@ function generateOps(rng: Rng, clientCount: number, opCount: number): Op[] { const text = `${marker}\n\n${filler}\n`; ops.push({ kind: 'chunked-source-paste', clientIdx, text, marker }); ops.push({ kind: 'wait', ms: 500 }); + } else if (roll < 0.8) { + const marker = `M${markerIdx++}-jsx-${randomShortText(rng)}`; + const text = `\n\n\n\n${marker} step body.\n\n\n\n`; + ops.push({ kind: 'jsx-block', text, marker }); } else if (roll < 0.83) { + const marker = `M${markerIdx++}-embed-${randomShortText(rng)}`; + const text = `\`\`\`html h=300px preview\n\n\`\`\``; + ops.push({ kind: 'large-embed', text, marker }); + } else if (roll < 0.89) { if (paused.size < clientCount - 1) { const target = clientIdx % clientCount; if (!paused.has(target)) { @@ -177,7 +187,7 @@ function generateOps(rng: Rng, clientCount: number, opCount: number): Op[] { } else { ops.push({ kind: 'wait', ms: rng.nextInt(40) + 20 }); } - } else if (roll < 0.95) { + } else if (roll < 0.97) { if (paused.size > 0) { const target = rng.pick([...paused]); paused.delete(target); @@ -245,6 +255,16 @@ async function applyOp( } break; } + case 'jsx-block': + case 'large-embed': { + try { + await agentWriteMd(server.port, `\n\n${op.text}\n`, { docName, position: 'append' }); + } catch (err) { + if ((err as { status?: number })?.status === 409) return false; + throw err; + } + break; + } case 'agent-patch': { try { await agentPatch(server.port, op.find, op.replace, docName); @@ -383,6 +403,8 @@ const ALL_OP_KINDS = [ 'agent-undo', 'external-change', 'chunked-source-paste', + 'jsx-block', + 'large-embed', 'sync-pause', 'sync-resume', 'wait', @@ -397,6 +419,8 @@ const WRITE_SURFACE_TO_OP_KIND: Record = { 'observer-b-sync': ['source-type'], 'file-watcher': ['external-change'], 'chunked-source-paste': ['chunked-source-paste'], + 'indented-jsx-construct': ['jsx-block'], + 'large-embed-construct': ['large-embed'], rollback: ['agent-write', 'agent-patch'], }; @@ -520,6 +544,7 @@ describe('bridge-convergence fuzzer (FR-17)', () => { const livePrefixes = new Set(); let expectedBody = 'seed paragraph'; // post-seed, pre-op initial state + let authoredBytes = Buffer.byteLength('seed paragraph'); const updateExpectedBody = (op: Op): void => { switch (op.kind) { case 'wysiwyg-type': @@ -588,6 +613,9 @@ describe('bridge-convergence fuzzer (FR-17)', () => { } updateExpectedBody(op); + + if ('text' in op) authoredBytes += Buffer.byteLength(op.text); + else if (op.kind === 'external-change') authoredBytes += Buffer.byteLength(op.newContent); } for (const c of clients) { @@ -610,6 +638,14 @@ describe('bridge-convergence fuzzer (FR-17)', () => { for (const c of clients) { assertBridgeInvariant(c.ytext, c.fragment); + const bytes = Buffer.byteLength(c.ytext.toString()); + const budget = authoredBytes * 3 + 4096; + if (bytes > budget) { + throw new Error( + `O1 byte-budget violated: converged ${bytes}B > budget ${budget}B ` + + `(cumulative authored ${authoredBytes}B x3 + 4096 slack) — the unbounded-growth amplifier signature.`, + ); + } } for (const probe of agentProbes) { diff --git a/packages/app/tests/stress/oracle-e-expectations.test-helper.ts b/packages/app/tests/stress/oracle-e-expectations.test-helper.ts index 80f69279..22f3dc12 100644 --- a/packages/app/tests/stress/oracle-e-expectations.test-helper.ts +++ b/packages/app/tests/stress/oracle-e-expectations.test-helper.ts @@ -6,6 +6,8 @@ export type OracleEOp = | { kind: 'agent-undo' } | { kind: 'external-change'; marker: string } | { kind: 'chunked-source-paste'; marker: string } + | { kind: 'jsx-block'; marker: string } + | { kind: 'large-embed'; marker: string } | { kind: 'sync-pause' } | { kind: 'sync-resume' } | { kind: 'wait' }; diff --git a/packages/app/tests/stress/server-authoritative-stress.test.ts b/packages/app/tests/stress/server-authoritative-stress.test.ts index c32faf90..6b1d0201 100644 --- a/packages/app/tests/stress/server-authoritative-stress.test.ts +++ b/packages/app/tests/stress/server-authoritative-stress.test.ts @@ -138,6 +138,7 @@ describe('server-authoritative stress (US-013)', () => { try { const allMarkers = new Set(); let editCount = 0; + let authoredBytes = 0; const testStart = Date.now(); while (Date.now() - testStart < durationMs) { @@ -146,6 +147,7 @@ describe('server-authoritative stress (US-013)', () => { const editType = rng.next() < 0.8 ? 'wysiwyg' : 'source'; const marker = `s-${editCount}-c${clientIdx}-${editType === 'wysiwyg' ? 'w' : 's'}-${rng.nextInt(10000)}`; allMarkers.add(marker); + authoredBytes += Buffer.byteLength(marker) + 4; if (editType === 'wysiwyg') { wysiwygAppend(client, marker); @@ -222,6 +224,8 @@ describe('server-authoritative stress (US-013)', () => { ); } expect(dupes).toEqual([]); + + expect(Buffer.byteLength(ytextStr)).toBeLessThanOrEqual(authoredBytes * 2 + 512); } console.log( diff --git a/packages/core/src/bridge/growth-engine.test.ts b/packages/core/src/bridge/growth-engine.test.ts new file mode 100644 index 00000000..a61768b5 --- /dev/null +++ b/packages/core/src/bridge/growth-engine.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test'; +import type { JSONContent } from '@tiptap/core'; +import { sharedExtensions } from '../extensions/shared.ts'; +import { + loadIndentedJsxFixtures, + loadLargeEmbedFixtures, + loadPrd6955Before, + loadPrd6955CorruptedTriplicated, +} from '../markdown/fixtures/index.ts'; +import { MarkdownManager } from '../markdown/index.ts'; +import { assertContentPreservation } from './merge-three-way.ts'; + +const mm = new MarkdownManager({ extensions: sharedExtensions }); + +function dirtyRoundTrip(md: string): string { + const json = mm.parse(md); + const walk = (node: JSONContent): void => { + if (node.type === 'jsxComponent' && node.attrs) node.attrs.sourceDirty = true; + if (node.content) for (const c of node.content) walk(c); + }; + walk(json); + return mm.serialize(json); +} + +function maxBodyLineOccurrence(doc: string): number { + const counts = new Map(); + for (const raw of doc.split('\n')) { + const line = raw.trim(); + if (line.length < 16) continue; + if (line.startsWith('<') || line.startsWith('```') || line.startsWith('|')) continue; + counts.set(line, (counts.get(line) ?? 0) + 1); + } + let max = 0; + for (const c of counts.values()) if (c > max) max = c; + return max; +} + +function countJsxComponents(md: string): number { + let count = 0; + const walk = (node: JSONContent): void => { + if (node.type === 'jsxComponent') count++; + if (node.content) for (const c of node.content) walk(c); + }; + walk(mm.parse(md)); + return count; +} + +describe('O1 — indented-JSX dirty drain stays within byte budget + no duplication', () => { + for (const { name, source } of loadIndentedJsxFixtures()) { + test(`${name}: a dirty drain stays within a small byte budget (no bloat)`, () => { + const out = dirtyRoundTrip(source); + expect(Buffer.byteLength(out)).toBeLessThanOrEqual( + Math.ceil(Buffer.byteLength(source) * 1.5) + 64, + ); + }); + + test(`${name}: a dirty drain duplicates no substantive body line`, () => { + expect(maxBodyLineOccurrence(dirtyRoundTrip(source))).toBeLessThanOrEqual(1); + }); + } +}); + +describe('O1 — large-embed clean round-trip budget (the dirty flip is a no-op here)', () => { + for (const { name, source } of loadLargeEmbedFixtures()) { + test(`${name}: has no jsxComponent node, so the dirty flip is a no-op`, () => { + expect(countJsxComponents(source)).toBe(0); + expect(dirtyRoundTrip(source)).toBe(mm.serialize(mm.parse(source))); + }); + + test(`${name}: a clean round-trip stays within budget and duplicates no body line`, () => { + const out = mm.serialize(mm.parse(source)); + expect(Buffer.byteLength(out)).toBeLessThanOrEqual( + Math.ceil(Buffer.byteLength(source) * 1.5) + 64, + ); + expect(maxBodyLineOccurrence(out)).toBeLessThanOrEqual(1); + }); + } +}); + +describe('O1 static occurrence-count oracle (AC-C3) — pins the PRD-6955 triplication', () => { + test('the clean BEFORE capture has no triplicated body line', () => { + expect(maxBodyLineOccurrence(loadPrd6955Before())).toBeLessThan(3); + }); + + test('the CORRUPTED capture trips the oracle (a body line occurs >= 3x)', () => { + const corrupted = maxBodyLineOccurrence(loadPrd6955CorruptedTriplicated()); + expect(corrupted).toBeGreaterThanOrEqual(3); + expect(corrupted).toBeGreaterThan(maxBodyLineOccurrence(loadPrd6955Before())); + }); +}); + +describe('assertContentPreservation is BLIND to pure duplication (documents the deferred B4 gap)', () => { + test('a 2x-duplicated merge result does NOT throw today (the blindness)', () => { + const body = 'alpha line one\nbravo line two\ncharlie line three'; + const duplicated = `${body}\n${body}`; + expect(() => assertContentPreservation(body, body, body, duplicated)).not.toThrow(); + }); + + test.skip('a 2x-duplicated merge result throws once the B4 growth guard lands', () => { + const body = 'alpha line one\nbravo line two\ncharlie line three'; + const duplicated = `${body}\n${body}`; + expect(() => assertContentPreservation(body, body, body, duplicated)).toThrow(); + }); +}); diff --git a/packages/core/src/bridge/normalize.test.ts b/packages/core/src/bridge/normalize.test.ts index 226942bf..461901f0 100644 --- a/packages/core/src/bridge/normalize.test.ts +++ b/packages/core/src/bridge/normalize.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'bun:test'; +import type { JSONContent } from '@tiptap/core'; import { sharedExtensions } from '../extensions/shared.ts'; +import { loadIndentedJsxFixtures, loadLargeEmbedFixtures } from '../markdown/fixtures/index.ts'; import { MarkdownManager } from '../markdown/index.ts'; import { BRIDGE_TOLERANCE_CLASSES, @@ -513,6 +515,7 @@ describe('detectAppliedToleranceClasses (FR-41)', () => { 'list-indent-canonical', 'ordered-list-marker-number', 'paragraph-continuation-indent', + 'jsx-container-boundary-blank', 'trailing-whitespace', 'blank-line-collapse', 'trailing-newline', @@ -700,6 +703,31 @@ describe('detectAppliedToleranceClasses (FR-41)', () => { expect(BRIDGE_TOLERANCE_CLASSES).toContain(cls); } }); + + test('detects jsx-container-boundary-blank when a boundary blank adjacent to a JSX tag is present', () => { + expect( + detectAppliedToleranceClasses( + '\n\n \n a\n \n', + '\n \n a\n \n', + ), + ).toContain('jsx-container-boundary-blank'); + }); + + test('does not detect jsx-container-boundary-blank for plain blank-separated content', () => { + expect(detectAppliedToleranceClasses('para\n\ntext', 'para\n\ntext')).not.toContain( + 'jsx-container-boundary-blank', + ); + }); + + test('jsx-container-boundary-blank detection is linear, not quadratic, on large angle-bracket-heavy input', () => { + const genericHeavy = 'Promise]* path + const start = performance.now(); + detectAppliedToleranceClasses(genericHeavy, genericHeavy); + detectAppliedToleranceClasses(attributeHeavy, attributeHeavy); + const elapsedMs = performance.now() - start; + expect(elapsedMs).toBeLessThan(250); + }); }); describe('paragraph lazy-continuation indent (step 7f)', () => { @@ -756,6 +784,108 @@ describe('paragraph lazy-continuation indent (step 7f)', () => { }); }); +describe('JSX-container boundary-blank fold (step 7g)', () => { + test('blank inside a single Step folds to the single-newline form (M2)', () => { + const authored = '\n\n \n\n Install the package.\n\n \n\n\n'; + const serialized = '\n \n Install the package.\n \n\n'; + expect(normalizeBridge(authored)).toBe(normalizeBridge(serialized)); + }); + + test('heading-less flush-left multi-Step (github-sync shape) folds', () => { + const authored = + '\n\n\n\nClone the repo.\n\n\n\n\n\nInstall dependencies.\n\n\n\n\n'; + const serialized = + '\n \n Clone the repo.\n \n \n Install dependencies.\n \n\n'; + expect(normalizeBridge(authored)).toBe(normalizeBridge(serialized)); + }); + + test('Tabs/Tab container folds (tag name independent)', () => { + const authored = '\n\n \n\n npm install\n\n \n\n\n'; + const serialized = '\n \n npm install\n \n\n'; + expect(normalizeBridge(authored)).toBe(normalizeBridge(serialized)); + }); + + test('depth-2 nested containers fold', () => { + const authored = + '\n\n \n\n \n\n \n\n body\n\n \n\n \n\n \n\n\n'; + const serialized = + '\n \n \n \n body\n \n \n \n\n'; + expect(normalizeBridge(authored)).toBe(normalizeBridge(serialized)); + }); + + test('a closing tag indented after a fenced code block folds (tag de-indent is neighbor-independent)', () => { + const indentedClose = '\n\n```ts\nconst x = 1;\n```\n \n\n'; + const flushClose = '\n\n```ts\nconst x = 1;\n```\n\n\n'; + expect(normalizeBridge(indentedClose)).toBe(normalizeBridge(flushClose)); + }); + + test('GUARD (a): a doc-level CommonMark 4.4 indented-code block (depth 0) is NOT folded', () => { + expect(normalizeBridge('intro\n\n code line\n')).not.toBe( + normalizeBridge('intro\n\n code line\n'), + ); + }); + + test('GUARD (a2): a depth-0 INDENTED line that trims to a JSX tag is doc-level indented code, NOT a container open', () => { + expect(normalizeBridge('intro\n\n \n\n body')).not.toBe( + normalizeBridge('intro\n\n \n body'), + ); + }); + + test('GUARD (d): a self-closing tag does NOT open a container depth (the !endsWith("/>") guard)', () => { + expect(normalizeBridge('\n\nParagraph after icon.\n')).not.toBe( + normalizeBridge('\nParagraph after icon.\n'), + ); + }); + + test('GUARD (b): 7g folds the scaffolding but a nested blockquote continuation indent stays divergent', () => { + const authored = '\n\n\n\n> quote line\n continuation\n\n\n\n\n'; + const scaffoldFolded = '\n\n> quote line\n continuation\n\n\n'; + expect(normalizeBridge(authored)).toBe(normalizeBridge(scaffoldFolded)); + const tight = '\n\n\n\n> quote line\ncontinuation\n\n\n\n\n'; + expect(normalizeBridge(authored)).not.toBe(normalizeBridge(tight)); + expect(normalizeBridge('> quote line\n continuation\n')).not.toBe( + normalizeBridge('> quote line\ncontinuation\n'), + ); + }); + + test('GUARD (c): 7g folds the scaffolding but a nested loose-list continuation indent stays divergent', () => { + const authored = '\n\n\n\n- item\n continuation\n\n\n\n\n'; + const scaffoldFolded = '\n\n- item\n continuation\n\n\n'; + expect(normalizeBridge(authored)).toBe(normalizeBridge(scaffoldFolded)); + const tight = '\n\n\n\n- item\ncontinuation\n\n\n\n\n'; + expect(normalizeBridge(authored)).not.toBe(normalizeBridge(tight)); + expect(normalizeBridge('- item\n continuation\n')).not.toBe( + normalizeBridge('- item\ncontinuation\n'), + ); + }); + + test('lowercase
is NOT a JSX container (boundary blanks preserved)', () => { + expect(normalizeBridge('
\n\nT\n\nbody\n\n
\n')).not.toBe( + normalizeBridge('
\nT\nbody\n
\n'), + ); + }); + + test('does NOT drop a blank line inside a fenced-code interior within a container', () => { + const withBlank = + '\n\n \n\n ```ts\n const a = 1;\n\n const b = 2;\n ```\n\n \n\n\n'; + const noBlank = + '\n\n \n\n ```ts\n const a = 1;\n const b = 2;\n ```\n\n \n\n\n'; + expect(normalizeBridge(withBlank)).not.toBe(normalizeBridge(noBlank)); + }); + + test('residual is correct: inside-container deep-indented content folds (container consumes relative indent, parses to a paragraph)', () => { + const eight = '\n\n \n\n code\n\n \n\n\n'; + const twelve = '\n\n \n\n code\n\n \n\n\n'; + expect(normalizeBridge(eight)).toBe(normalizeBridge(twelve)); + }); + + test('a large consecutive-blank run inside a container folds identically to a single boundary blank (linear scan, no quadratic re-walk)', () => { + const big = `\n${'\n'.repeat(4000)}\n\nbody paragraph.\n\n\n\n\n`; + const single = '\n\n\n\nbody paragraph.\n\n\n\n\n'; + expect(normalizeBridge(big)).toBe(normalizeBridge(single)); + }); +}); + describe('fence-opener tracking (CommonMark 4.5: closer must match opener char)', () => { const FENCED_A = '```ts\n~~~\n| a | b |\n| - | - |\n| 1 | 2\n```\n'; const FENCED_B = '```ts\n~~~\n| a | b |\n| - | - |\n| 1 | 2 |\n```\n'; @@ -915,3 +1045,70 @@ describe('table-row trailing-pipe tolerance (row-no-trailing-pipe)', () => { }); }); }); + +const g2Manager = new MarkdownManager({ extensions: sharedExtensions }); + +function dirtySerialize(md: string): string { + const json = g2Manager.parse(md); + const visit = (n: JSONContent): void => { + if (n.type === 'jsxComponent' && n.attrs) n.attrs.sourceDirty = true; + n.content?.forEach(visit); + }; + visit(json); + return g2Manager.serialize(json); +} + +const JSX_EMBED_CORPUS = [...loadIndentedJsxFixtures(), ...loadLargeEmbedFixtures()]; + +function assertFoldsWithinTolerance(authored: string, serialized: string): void { + expect(normalizeBridge(serialized)).toBe(normalizeBridge(authored)); + if (serialized !== authored) { + expect(detectAppliedToleranceClasses(authored, serialized).length).toBeGreaterThan(0); + } +} + +describe('G2 forward-regression guard — dirty serializer output is a within-tolerance fixed point folded by a named class', () => { + for (const { name, source } of JSX_EMBED_CORPUS) { + test(`${name}: dirty round-trip folds within tolerance by a named class`, () => { + assertFoldsWithinTolerance(source, dirtySerialize(source)); + }); + } + + test('planted beyond-tolerance output trips the guard (discriminating, not tautological)', () => { + const [first] = loadIndentedJsxFixtures(); + if (!first) throw new Error('indented-jsx corpus is empty'); + const authored = first.source; + const planted = `${authored}\nAn injected paragraph the serializer never emitted.\n`; + expect(normalizeBridge(planted)).not.toBe(normalizeBridge(authored)); + expect(() => assertFoldsWithinTolerance(authored, planted)).toThrow(); + }); +}); + +describe('O7 — normalizeBridge idempotence over the JSX/embed corpus', () => { + for (const { name, source } of JSX_EMBED_CORPUS) { + test(`${name}: normalizeBridge is idempotent`, () => { + const once = normalizeBridge(source); + expect(normalizeBridge(once)).toBe(once); + }); + } +}); + +describe('O7c — MDX normalizing-construct convergence under the real comparator', () => { + const mm = new MarkdownManager({ extensions: sharedExtensions }); + const roundTrip = (md: string): string => mm.serialize(mm.parse(md)); + + test('CommonMark: heading→paragraph tight separator normalizes and converges', () => { + const source = '## Heading\nFollowing paragraph.\n'; + const serialized = roundTrip(source); + expect(serialized).not.toBe(source); + expect(normalizeBridge(serialized)).toBe(normalizeBridge(source)); + }); + + test('MDX: indented-children dirty round-trip normalizes and converges', () => { + const [first] = loadIndentedJsxFixtures(); + if (!first) throw new Error('indented-jsx corpus is empty'); + const serialized = dirtySerialize(first.source); + expect(serialized).not.toBe(first.source); + expect(normalizeBridge(serialized)).toBe(normalizeBridge(first.source)); + }); +}); diff --git a/packages/core/src/bridge/normalize.ts b/packages/core/src/bridge/normalize.ts index eb8891d4..e67f33b4 100644 --- a/packages/core/src/bridge/normalize.ts +++ b/packages/core/src/bridge/normalize.ts @@ -78,17 +78,88 @@ function findFenceInteriorLines(lines: readonly string[]): Set { return interior; } +const JSX_CONTAINER_TAG_LINE_RE = /^<\/?[A-Z][A-Za-z0-9.]*(?:\s[^>]*)?>$/; +const JSX_CONTAINER_OPEN_RE = /^<[A-Z][A-Za-z0-9.]*(?:\s[^>]*)?>$/; +const JSX_CONTAINER_CLOSE_RE = /^<\/[A-Z][A-Za-z0-9.]*\s*>$/; +const CAPITALIZED_JSX_TAG_RE = /<\/?[A-Z]/; + +function foldJsxContainerBoundaryBlanks(lines: readonly string[]): string[] { + let hasCapitalizedJsxTag = false; + for (let i = 0; i < lines.length; i++) { + if (CAPITALIZED_JSX_TAG_RE.test(lines[i] ?? '')) { + hasCapitalizedJsxTag = true; + break; + } + } + if (!hasCapitalizedJsxTag) return lines as string[]; + + const fenceInterior = findFenceInteriorLines(lines); + const isTag: boolean[] = []; + const depthAt: number[] = []; + let depth = 0; + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] ?? ''; + const t = raw.trim(); + if (fenceInterior.has(i)) { + isTag.push(false); + depthAt.push(depth); + continue; + } + const containerContext = depth >= 1 || !/^[ \t]/.test(raw); + isTag.push(containerContext && JSX_CONTAINER_TAG_LINE_RE.test(t)); + const close = containerContext && JSX_CONTAINER_CLOSE_RE.test(t); + const open = containerContext && !close && JSX_CONTAINER_OPEN_RE.test(t) && !t.endsWith('/>'); + if (close) { + depth = Math.max(0, depth - 1); + depthAt.push(depth); + } else { + depthAt.push(depth); + if (open) depth += 1; + } + } + const prevNonBlank: number[] = new Array(lines.length); + for (let i = 0, last = -1; i < lines.length; i++) { + prevNonBlank[i] = last; + if ((lines[i] ?? '').trim() !== '') last = i; + } + const nextNonBlank: number[] = new Array(lines.length); + for (let i = lines.length - 1, next = lines.length; i >= 0; i--) { + nextNonBlank[i] = next; + if ((lines[i] ?? '').trim() !== '') next = i; + } + const drop = new Set(); + for (let i = 0; i < lines.length; i++) { + if ((lines[i] ?? '').trim() !== '' || fenceInterior.has(i) || (depthAt[i] ?? 0) < 1) { + continue; + } + const p = prevNonBlank[i] ?? -1; + const n = nextNonBlank[i] ?? lines.length; + if ((p >= 0 && isTag[p] === true) || (n < lines.length && isTag[n] === true)) { + drop.add(i); + } + } + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + if (drop.has(i)) continue; + const line = lines[i] ?? ''; + out.push(isTag[i] === true && (depthAt[i] ?? 0) >= 1 ? line.replace(/^[ \t]+/, '') : line); + } + return out; +} + export function normalizeBridge(s: string): string { - const lines = s - .replace(/^/, '') - .replace(/\r/g, '') - .replace(COMMONMARK_ESCAPE_RE, '$1') - .replace(EMPHASIS_AROUND_CODE_RE, '$1') - .replace(/^\n+/, '') - .replace(/^[*-]{3,}(?=\n|$)/, '---') - .replace(/(\n)([#>+-]|\d+[.)]|`{3,}|~{3,})/g, '\n\n$2') - .replace(/^([#>+-].*|\d+[.)].*|`{3,}.*|~{3,}.*)\n([^\n])/gm, '$1\n\n$2') - .split('\n'); + const lines = foldJsxContainerBoundaryBlanks( + s + .replace(/^/, '') + .replace(/\r/g, '') + .replace(COMMONMARK_ESCAPE_RE, '$1') + .replace(EMPHASIS_AROUND_CODE_RE, '$1') + .replace(/^\n+/, '') + .replace(/^[*-]{3,}(?=\n|$)/, '---') + .replace(/(\n)([#>+-]|\d+[.)]|`{3,}|~{3,})/g, '\n\n$2') + .replace(/^([#>+-].*|\d+[.)].*|`{3,}.*|~{3,}.*)\n([^\n])/gm, '$1\n\n$2') + .split('\n'), + ); const tableRowLines = findTableRowLines(lines); const fenceInterior = findFenceInteriorLines(lines); return lines @@ -140,6 +211,7 @@ export const BRIDGE_TOLERANCE_CLASSES = [ 'list-indent-canonical', 'ordered-list-marker-number', 'paragraph-continuation-indent', + 'jsx-container-boundary-blank', 'trailing-whitespace', 'blank-line-collapse', 'trailing-newline', @@ -218,12 +290,27 @@ export function detectAppliedToleranceClasses(left: string, right: string): Brid classes.push('paragraph-continuation-indent'); } + const leftLines = leftLf.split('\n'); + const rightLines = rightLf.split('\n'); + + const hasJsxBoundaryBlank = (ls: string[]): boolean => { + for (let i = 0; i < ls.length - 1; i++) { + const a = (ls[i] ?? '').trim(); + const b = (ls[i + 1] ?? '').trim(); + const adjacentTagBlank = a !== '' && b === '' && JSX_CONTAINER_TAG_LINE_RE.test(a); + const adjacentBlankTag = a === '' && b !== '' && JSX_CONTAINER_TAG_LINE_RE.test(b); + if (adjacentTagBlank || adjacentBlankTag) return true; + } + return false; + }; + if (hasJsxBoundaryBlank(leftLines) || hasJsxBoundaryBlank(rightLines)) { + classes.push('jsx-container-boundary-blank'); + } + const canonMarkerLine = (line: string): string => line .replace(/^[ \t]+/, '') .replace(ORDERED_LIST_MARKER_RE, `${ORDERED_LIST_MARKER_CANONICAL}$1`); - const leftLines = leftLf.split('\n'); - const rightLines = rightLf.split('\n'); const markerLineCount = Math.min(leftLines.length, rightLines.length); for (let i = 0; i < markerLineCount; i++) { const l = leftLines[i] ?? ''; diff --git a/packages/core/src/bridge/tolerance-telemetry.test.ts b/packages/core/src/bridge/tolerance-telemetry.test.ts index 7e892370..1e9e9b27 100644 --- a/packages/core/src/bridge/tolerance-telemetry.test.ts +++ b/packages/core/src/bridge/tolerance-telemetry.test.ts @@ -29,6 +29,7 @@ describe('classifySeverity', () => { 'row-no-trailing-pipe', 'ordered-list-marker-number', 'list-indent-canonical', + 'jsx-container-boundary-blank', 'trailing-whitespace', 'blank-line-collapse', 'trailing-newline', diff --git a/packages/core/src/bridge/tolerance-telemetry.ts b/packages/core/src/bridge/tolerance-telemetry.ts index 2153c06e..03504053 100644 --- a/packages/core/src/bridge/tolerance-telemetry.ts +++ b/packages/core/src/bridge/tolerance-telemetry.ts @@ -25,6 +25,7 @@ const SEVERITY_BY_CLASS = { 'row-no-trailing-pipe': 'serializer-caused', 'ordered-list-marker-number': 'serializer-caused', 'list-indent-canonical': 'serializer-caused', + 'jsx-container-boundary-blank': 'serializer-caused', 'trailing-whitespace': 'serializer-caused', 'blank-line-collapse': 'serializer-caused', 'trailing-newline': 'serializer-caused', diff --git a/packages/core/src/markdown/embed-script-fidelity.test.ts b/packages/core/src/markdown/embed-script-fidelity.test.ts new file mode 100644 index 00000000..ff056c29 --- /dev/null +++ b/packages/core/src/markdown/embed-script-fidelity.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { sharedExtensions } from '../extensions/shared.ts'; +import { + loadLargeEmbedFixtures, + loadPrd6955Before, + loadPrd6955CorruptedTriplicated, +} from './fixtures/index.ts'; +import { MarkdownManager } from './index.ts'; + +const mm = new MarkdownManager({ extensions: sharedExtensions }); +const mdRoundTrip = (md: string): string => mm.serialize(mm.parse(md)); + +function extractScriptBodies(doc: string): string[] { + const bodies: string[] = []; + const re = /]*>([\s\S]*?)<\/script>/g; + let m: RegExpExecArray | null = re.exec(doc); + while (m !== null) { + const body = (m[1] ?? '').trim(); + if (body.length > 0) bodies.push(body); + m = re.exec(doc); + } + return bodies; +} + +function jsParses(code: string): boolean { + try { + new Function(code); + return true; + } catch { + return false; + } +} + +const BRACE_INJECTION_RE = /\{onst|\{on\{|\{ons\{|\{var\{|\{onst\b/; + +describe('O6 — large-embed fixtures: every script is valid JS and survives a round-trip', () => { + const fixtures = loadLargeEmbedFixtures().filter((f) => f.scriptsMustParse); + + for (const { name, source } of fixtures) { + test(`${name}: every \n```\n\nSome prose between the two embeds.\n\n```html h=640px preview\n
\n\n```\n", + "scriptsMustParse": true, + "notes": "The PRD-6955 trigger shape (reduced): two `html preview` embeds, the second declaring `const DATA={...}` (the brace-injection target, const DATA= -> {onst). O6 extracts every \n```\n", + "scriptsMustParse": true, + "notes": "A single html preview embed with const declarations in its script — single-client round-trip + JS-parse baseline." + } +] diff --git a/packages/core/src/markdown/fixtures/regression/prd-6955-before.md b/packages/core/src/markdown/fixtures/regression/prd-6955-before.md new file mode 100644 index 00000000..a39c2d54 --- /dev/null +++ b/packages/core/src/markdown/fixtures/regression/prd-6955-before.md @@ -0,0 +1,225 @@ +--- +title: Shilshole Bay — Resident Coho & Lingcod, June 7 2026 +description: Morning kayak trip at Shilshole Bay (Marine Area 10), Seattle, ~7 AM–noon, targeting resident coho and lingcod. Trolled a green/red/white spoon on the downrigger at 45 ft (one undersize Chinook to hand, released) and worked two rocky structures in the south bay for lingcod — hung up twice, lost gear, no ling. Dock X sea lion haul-out. Partial GPX, 5.63 mi. +tags: + - fishing-log + - trip + - shilshole-bay + - saltwater + - coho + - lingcod + - chinook + - salmon + - kayak +--- +# Shilshole Bay — 2026-06-07 + +> [!NOTE] Trip summary +> Morning kayak trip targeting **resident coho and lingcod**. Trolled a small green/red/white spoon and got **one Chinook to hand at 8:49 AM — undersize, released** (incidental on the coho gear, on the downrigger at 45 ft). Then worked **two rocky structures in the south bay for lingcod for ~2 hours — hung up twice and lost gear in the rocks, no ling landed.** Fished ~7 AM to noon; overcast at launch, clearing to bright sun by midday. Dock X still packed with sea lions. + +## Photos + +![An undersize Chinook salmon just under the surface inside an orange landing net, hooked on a green/red/white striped spoon, grey flat morning water around it](./assets/2026-06-07-undersize-chinook-net.jpg) + +*The incidental Chinook in the net, \~8:49 AM — hooked on the green/red/white spoon at 45 ft on the downrigger while trolling for coho, under the minimum and released. (Original)* + +![The Shilshole Dock X float packed end to end with dozens of hauled-out sea lions in bright midday sun, sailboat masts and a green forested bluff behind, calm blue water in front](./assets/2026-06-07-shilshole-sealion-dock-x.jpg) + +*Dock X covered with sea lions, \~12:21 PM in full sun — same haul-out as the [June 1](./2026-06-01-shilshole-bay-coho-opener.md) and [June 2](./2026-06-02-shilshole-bay-evening.md) sessions. (Original)* + +## Track Map + +The morning's GPS track (partial recording, 8:37 AM–12:27 PM), **speed-colored** slow → fast, with the 🎣 Chinook catch, the two ⛰️ rocky structures worked for lingcod, and the 🦭 Dock X sea lion haul-out. Interactive — drag, zoom, and tap a pin. Same map as [`trip-viewer.html`](./trip-viewer.html), which also carries the photo gallery. + +```html h=521px preview w=1003px +
+
+
+ slowfast +
+
+ + + +
+``` + +## Currents — how I fished the tide + +Real modeled **surface current** (NOAA SSCOFS) over the fishing area, animated against the GPS track. Hit ▶ to watch the boat work the morning. The current stayed weak (**0.3–0.5 kt**) but **rotated clockwise** — SSE → ESE → NE through the trip — the back-eddy behind West Point while the main channel ebbed north. The **8:49 AM Chinook** came near slack, on the last of the weak flood. + +> [!NOTE] Data & fidelity +> Surface current from NOAA **SSCOFS** (FVCOM operational model), regular-grid output, hourly 15:00–20:00 UTC (08:00–13:00 PDT), **~0.5 km cells** interpolated in time — the model's regular-grid native resolution. Renders with no external map tiles (the route outline is the geography). Map-backed standalone viewer: `fishing-log/current-playback.html`; full-resolution extraction: `scripts/route_currents.py`. + +```html h=640px preview +
+
+ current speed + + 0 → 1+ kt · arrow points the way water flows +
+
+ +
+
--:--
+
+
current · set
+
+
~0.5 km grid · no basemap
+
+
+ + + +
+ +
+``` + +## Where & When + +- **Date:** 2026-06-07 (Sunday) +- **Water:** Shilshole Bay, Seattle (Puget Sound) — Marine Area 10 +- **Type:** saltwater +- **Time on water:** \~7:00 AM – noon +- **Platform:** Hobie pedal kayak +- **Track (Strava — partial):** GPS recording ran **8:37 AM – 12:27 PM** (started after launch, so it covers the back \~3.5 hrs of the \~7 AM–noon outing). Partial totals: **5.63 mi**, 3:50:09 elapsed, **\~1.5 mph average** (trolling, plus \~2 hrs sitting on structure). The full GPX (`Morning_Activity.gpx`) and an interactive speed-colored map with the catch + structure waypoints live in [`trip-viewer.html`](./trip-viewer.html). +- **Logged by:** Andrew + +## Conditions + +- **Weather:** **Overcast/cloudy at launch, clearing to mostly sunny by midday** — the 8:49 AM catch photo shows flat grey water; the 12:21 PM sea lion photo is in full sun. KSEA (nearest hourly station) recorded **\~48°F at 7 AM warming to \~57°F by late morning**, **light-to-moderate southerly wind \~7–13 mph**, and a **steady barometer near 1017–1018 hPa**. Full observed hourly table in the [June 7 conditions snapshot](../external-sources/2026-06-07-seattle-shilshole-conditions.md). +- **Water:** Temp / clarity not recorded *(TBD)*. +- **Tide:** **Weak incoming morning tide** (NOAA Seattle 9447130) — low **5:27 AM (5.75 ft)** rising to a modest high **9:55 AM (7.43 ft)**, then ebbing to low **4:19 PM (1.30 ft)**. The **8:49 AM Chinook came on the last of the incoming**, \~1 hr before slack high. See the [conditions snapshot](../external-sources/2026-06-07-seattle-shilshole-conditions.md). +- **Sun:** Sunrise **5:10 AM**, sunset **9:06 PM** — the \~7 AM launch was \~2 hrs into full daylight. +- **Solunar:** Day rated **Low** (coefficient 49), waning crescent \~54% lit. Morning **major period 6:28–8:28 AM** — the **8:49 AM catch landed right at its tail**, the strongest solunar window of the daylight hours. + +## Target & Method + +Two targets this trip: + +- **Resident coho — trolling.** Small **green / red / white striped spoon** (likely the same 3" striped spoon from the [June 2 evening session](./2026-06-02-shilshole-bay-evening.md)). This is what the undersize **Chinook** ate, mid-bay, while trolling **on the downrigger at 45 ft**. Full leader / flasher setup not recorded. +- **Lingcod — working rocky structure.** Fished a **slider (sliding-weight) rig** with bait over two rocky structures at the south end of the bay (see below), the same structure-and-bait approach as the [Edmonds opener](./2026-05-01-edmonds-bottomfish-opener.md). Offerings included an **imitation (soft-plastic) flounder** and a **herring**. + +## Catch + +**Landed: 1 (released — undersize Chinook). Lingcod: 0.** + +| Species | Count | Size | Kept / Released | Notes | +| --- | --- | --- | --- | --- | +| Chinook salmon | 1 | Undersize (< MA-10 minimum) | Released | Incidental on the coho troll — hooked **8:49 AM** on the green/red/white spoon at 45 ft on the downrigger, mid-bay | +| Resident coho | 0 | — | — | Targeted on the troll; none landed | +| Lingcod | 0 | — | — | Worked two rocky structures \~2 hrs; hung up twice, no ling | + +### Catch location + +The GPS point at the catch photo's timestamp (8:49:48 AM) puts the Chinook hookup at **47.68079, -122.42075** — out in the open bay, 45 ft down on the downrigger, not on the southern structure. Tap the 🎣 pin in [`trip-viewer.html`](./trip-viewer.html) for the exact spot. + +## Rocky Structure & Snags + +Spent roughly **9:13–11:00 AM** working the **south end of the bay**, where the track collapses into two tight dwell clusters — a couple of **rocky structures** held on for lingcod: + +| Structure | Approx. location | Notes | +| --- | --- | --- | +| North rock | \~47.6740, -122.4227 | Hard structure; worked the slider rig + bait | +| South rock | \~47.6730, -122.4219 | Tightest dwell knot of the morning | + +**Hung up twice in the rocks.** Lost gear to snags: + +- a **slider** (sliding-weight rig component) +- a **weight** +- an **imitation flounder** (soft-plastic lingcod offering) +- a **herring** (bait) + +The two structures are marked with ⛰️ pins in [`trip-viewer.html`](./trip-viewer.html). + +## Bait & Forage + +- **Sea lions:** The Dock X float was **still packed with sea lions** at midday (photo + two videos), same haul-out logged on [June 1](./2026-06-01-shilshole-bay-coho-opener.md) and [June 2](./2026-06-02-shilshole-bay-evening.md). A haul-out that size keeps signalling plenty of forage in the bay — and serious competition working the same water. + +## What Worked / What Didn't + +- **First salmon contact here** after two skunked Shilshole sessions ([June 1](./2026-06-01-shilshole-bay-coho-opener.md), [June 2](./2026-06-02-shilshole-bay-evening.md)) — the small green/red/white spoon got the eat (a Chinook, not the targeted coho) where the big flashers hadn't. +- **Lingcod were a bust** — \~2 hrs over two rocky structures produced no ling, just two snags and lost terminal gear. The slider rig + soft-plastic flounder / herring hung in the rocks. +- The Chinook came **out in the open bay** on the troll, well north of the structure — salmon and ling water were in clearly different parts of the bay. + +## Notes for Next Time + +- **Keep running the small striped spoon on the downrigger** for salmon — it out-fished the big-flasher presentations from June 1–2 here, and **45 ft got the Chinook** this trip. +- **Lighten up / make terminal gear breakaway on the rocks.** Two hang-ups cost a slider, weight, flounder, and herring. A rotten-leader / breakaway-weight setup (like the 3 oz rubber-band breakaway on the [Edmonds rig](./2026-05-01-edmonds-bottomfish-opener.md)) would have saved the rig and the bait. +- **Mark the two south-bay rocks** (47.6740/-122.4227 and 47.6730/-122.4219) as known ling structure — worth a return with snag-resistant gear. +- Record start/end times, tide, and water temp next outing so the salmon bite and the structure work can be tied to a tide stage. + diff --git a/packages/core/src/markdown/fixtures/regression/prd-6955-corrupted-triplicated.md b/packages/core/src/markdown/fixtures/regression/prd-6955-corrupted-triplicated.md new file mode 100644 index 00000000..c1450817 --- /dev/null +++ b/packages/core/src/markdown/fixtures/regression/prd-6955-corrupted-triplicated.md @@ -0,0 +1,661 @@ +--- +title: Shilshole Bay — Resident Coho & Lingcod, June 7 2026 +description: Morning kayak trip at Shilshole Bay (Marine Area 10), Seattle, ~7 AM–noon, targeting resident coho and lingcod. Trolled a green/red/white spoon on the downrigger at 45 ft (one undersize Chinook to hand, released) and worked two rocky structures in the south bay for lingcod — hung up twice, lost gear, no ling. Dock X sea lion haul-out. Partial GPX, 5.63 mi. +tags: + - fishing-log + - trip + - shilshole-bay + - saltwater + - coho + - lingcod + - chinook + - salmon + - kayak +--- +# Shilshole Bay — 2026-06-07 + +> [!NOTE] Trip summary +> Morning kayak trip targeting **resident coho and lingcod**. Trolled a small green/red/white spoon and got **one Chinook to hand at 8:49 AM — undersize, released** (incidental on the coho gear, on the downrigger at 45 ft). Then worked **two rocky structures in the south bay for lingcod for ~2 hours — hung up twice and lost gear in the rocks, no ling landed.** Fished ~7 AM to noon; overcast at launch, clearing to bright sun by midday. Dock X still packed with sea lions. + +## Photos + +![An undersize Chinook salmon just under the surface inside an orange landing net, hooked on a green/red/white striped spoon, grey flat morning water around it](./assets/2026-06-07-undersize-chinook-net.jpg) + +*The incidental Chinook in the net, \~8:49 AM — hooked on the green/red/white spoon at 45 ft on the downrigger while trolling for coho, under the minimum and released. (Original)* + +![The Shilshole Dock X float packed end to end with dozens of hauled-out sea lions in bright midday sun, sailboat masts and a green forested bluff behind, calm blue water in front](./assets/2026-06-07-shilshole-sealion-dock-x.jpg) + +*Dock X covered with sea lions, \~12:21 PM in full sun — same haul-out as the [June 1](./2026-06-01-shilshole-bay-coho-opener.md) and [June 2](./2026-06-02-shilshole-bay-evening.md) sessions. (Original)* + +## Track Map + +The morning's GPS track (partial recording, 8:37 AM–12:27 PM), **speed-colored** slow → fast, with the 🎣 Chinook catch, the two ⛰️ rocky structures worked for lingcod, and the 🦭 Dock X sea lion haul-out. Interactive — drag, zoom, and tap a pin. Same map as [`trip-viewer.html`](./trip-viewer.html), which also carries the photo gallery. + +```html h=521px preview w=1003px +
+
+
+ slowfast +
+
+ + + +
+``` + +## Currents — how I fished the tide + +Real modeled **surface current** (NOAA SSCOFS) over the fishing area, animated against the GPS track. Hit ▶ to watch the boat work the morning. The current stayed weak (**0.3–0.5 kt**) but **rotated clockwise** — SSE → ESE → NE through the trip — the back-eddy behind West Point while the main channel ebbed north. The **8:49 AM Chinook** came near slack, on the last of the weak flood. + +> [!NOTE] Data & fidelity +> Surface current from NOAA **SSCOFS** (FVCOM operational model), regular-grid output, hourly 15:00–20:00 UTC (08:00–13:00 PDT), **~0.5 km cells** interpolated in time — the model's regular-grid native resolution. Renders with no external map tiles (the route outline is the geography). Map-backed standalone viewer: `fishing-log/current-playback.html`; full-resolution extraction: `scripts/route_currents.py`. + +```html h=640px preview +
+
+ current speed + + 0 → 1+ kt · arrow points the way water flows +
+
+ +
+
--:--
+
+
current · set
+
+
~0.5 km grid · no basemap
+
+
+ + + +
+ +
+``` + +## Where & When + +- **Date:** 2026-06-07 (Sunday) +- **Water:** Shilshole Bay, Seattle (Puget Sound) — Marine Area 10 +- **Type:** saltwater +- **Time on water:** \~7:00 AM – noon +- **Platform:** Hobie pedal kayak +- **Track (Strava — partial):** GPS recording ran **8:37 AM – 12:27 PM** (started after launch, so it covers the back \~3.5 hrs of the \~7 AM–noon outing). Partial totals: **5.63 mi**, 3:50:09 elapsed, **\~1.5 mph average** (trolling, plus \~2 hrs sitting on structure). The full GPX (`Morning_Activity.gpx`) and an interactive speed-colored map with the catch + structure waypoints live in [`trip-viewer.html`](./trip-viewer.html). +- **Logged by:** Andrew + +## Conditions + +- **Weather:** **Overcast/cloudy at launch, clearing to mostly sunny by midday** — the 8:49 AM catch photo shows flat grey water; the 12:21 PM sea lion photo is in full sun. KSEA (nearest hourly station) recorded **\~48°F at 7 AM warming to \~57°F by late morning**, **light-to-moderate southerly wind \~7–13 mph**, and a **steady barometer near 1017–1018 hPa**. Full observed hourly table in the [June 7 conditions snapshot](../external-sources/2026-06-07-seattle-shilshole-conditions.md). +- **Water:** Temp / clarity not recorded *(TBD)*. +- **Tide:** **Weak incoming morning tide** (NOAA Seattle 9447130) — low **5:27 AM (5.75 ft)** rising to a modest high **9:55 AM (7.43 ft)**, then ebbing to low **4:19 PM (1.30 ft)**. The **8:49 AM Chinook came on the last of the incoming**, \~1 hr before slack high. See the [conditions snapshot](../external-sources/2026-06-07-seattle-shilshole-conditions.md). +- **Sun:** Sunrise **5:10 AM**, sunset **9:06 PM** — the \~7 AM launch was \~2 hrs into full daylight. +- **Solunar:** Day rated **Low** (coefficient 49), waning crescent \~54% lit. Morning **major period 6:28–8:28 AM** — the **8:49 AM catch landed right at its tail**, the strongest solunar window of the daylight hours. + +## Target & Method + +Two targets this trip: + +- **Resident coho — trolling.** Small **green / red / white striped spoon** (likely the same 3" striped spoon from the [June 2 evening session](./2026-06-02-shilshole-bay-evening.md)). This is what the undersize **Chinook** ate, mid-bay, while trolling **on the downrigger at 45 ft**. Full leader / flasher setup not recorded. +- **Lingcod — working rocky structure.** Fished a **slider (sliding-weight) rig** with bait over two rocky structures at the south end of the bay (see below), the same structure-and-bait approach as the [Edmonds opener](./2026-05-01-edmonds-bottomfish-opener.md). Offerings included an **imitation (soft-plastic) flounder** and a **herring**. + +## Catch + +**Landed: 1 (released — undersize Chinook). Lingcod: 0.** + +| Species | Count | Size | Kept / Released | Notes | +| --- | --- | --- | --- | --- | +| Chinook salmon | 1 | Undersize (< MA-10 minimum) | Released | Incidental on the coho troll — hooked **8:49 AM** on the green/red/white spoon at 45 ft on the downrigger, mid-bay | +| Resident coho | 0 | — | — | Targeted on the troll; none landed | +| Lingcod | 0 | — | — | Worked two rocky structures \~2 hrs; hung up twice, no ling | + +### Catch location + +The GPS point at the catch photo's timestamp (8:49:48 AM) puts the Chinook hookup at **47.68079, -122.42075** — out in the open bay, 45 ft down on the downrigger, not on the southern structure. Tap the 🎣 pin in [`trip-viewer.html`](./trip-viewer.html) for the exact spot. + +## Rocky Structure & Snags + +Spent roughly **9:13–11:00 AM** working the **south end of the bay**, where the track collapses into two tight dwell clusters — a couple of **rocky structures** held on for lingcod: + +| Structure | Approx. location | Notes | +| --- | --- | --- | +| North rock | \~47.6740, -122.4227 | Hard structure; worked the slider rig + bait | +| South rock | \~47.6730, -122.4219 | Tightest dwell knot of the morning | + +**Hung up twice in the rocks.** Lost gear to snags: + +- a **slider** (sliding-weight rig component) +- a **weight** +- an **imitation flounder** (soft-plastic lingcod offering) +- a **herring** (bait) + +The two structures are marked with ⛰️ pins in [`trip-viewer.html`](./trip-viewer.html). + +## Bait & Forage + +- **Sea lions:** The Dock X float was **still packed with sea lions** at midday (photo + two videos), same haul-out logged on [June 1](./2026-06-01-shilshole-bay-coho-opener.md) and [June 2](./2026-06-02-shilshole-bay-evening.md). A haul-out that size keeps signalling plenty of forage in the bay — and serious competition working the same water. + +## What Worked / What Didn't + +- **First salmon contact here** after two skunked Shilshole sessions ([June 1](./2026-06-01-shilshole-bay-coho-opener.md), [June 2](./2026-06-02-shilshole-bay-evening.md)) — the small green/red/white spoon got the eat (a Chinook, not the targeted coho) where the big flashers hadn't. +- **Lingcod were a bust** — \~2 hrs over two rocky structures produced no ling, just two snags and lost terminal gear. The slider rig + soft-plastic flounder / herring hung in the rocks. +- The Chinook came **out in the open bay** on the troll, well north of the structure — salmon and ling water were in clearly different parts of the bay. + +## Notes for Next Time + +- **Keep running the small striped spoon on the downrigger** for salmon — it out-fished the big-flasher presentations from June 1–2 here, and **45 ft got the Chinook** this trip. +- **Lighten up / make terminal gear breakaway on the rocks.** Two hang-ups cost a slider, weight, flounder, and herring. A rotten-leader / breakaway-weight setup (like the 3 oz rubber-band breakaway on the [Edmonds rig](./2026-05-01-edmonds-bottomfish-opener.md)) would have saved the rig and the bait. +- **Mark the two south-bay rocks** (47.6740/-122.4227 and 47.6730/-122.4219) as known ling structure — worth a return with snag-resistant gear. +- Record start/end times, tide, and water temp next outing so the salmon bite and the structure work can be tied to a tide stage. + +# Shilshole Bay — 2026-06-07 + +> [!NOTE] Trip summary +> Morning kayak trip targeting **resident coho and lingcod**. Trolled a small green/red/white spoon and got **one Chinook to hand at 8:49 AM — undersize, released** (incidental on the coho gear, on the downrigger at 45 ft). Then worked **two rocky structures in the south bay for lingcod for ~2 hours — hung up twice and lost gear in the rocks, no ling landed.** Fished ~7 AM to noon; overcast at launch, clearing to bright sun by midday. Dock X still packed with sea lions. + +## Photos + +![An undersize Chinook salmon just under the surface inside an orange landing net, hooked on a green/red/white striped spoon, grey flat morning water around it](./assets/2026-06-07-undersize-chinook-net.jpg) + +*The incidental Chinook in the net, \~8:49 AM — hooked on the green/red/white spoon at 45 ft on the downrigger while trolling for coho, under the minimum and released. (Original)* + +![The Shilshole Dock X float packed end to end with dozens of hauled-out sea lions in bright midday sun, sailboat masts and a green forested bluff behind, calm blue water in front](./assets/2026-06-07-shilshole-sealion-dock-x.jpg) + +*Dock X covered with sea lions, \~12:21 PM in full sun — same haul-out as the [June 1](./2026-06-01-shilshole-bay-coho-opener.md) and [June 2](./2026-06-02-shilshole-bay-evening.md) sessions. (Original)* + +## Track Map + +The morning's GPS track (partial recording, 8:37 AM–12:27 PM), **speed-colored** slow → fast, with the 🎣 Chinook catch, the two ⛰️ rocky structures worked for lingcod, and the 🦭 Dock X sea lion haul-out. Interactive — drag, zoom, and tap a pin. Same map as [`trip-viewer.html`](./trip-viewer.html), which also carries the photo gallery. + +```html h=521px preview w=1003px +
+
+
+ slowfast +
+
+ + + +
+``` + +## Currents — how I fished the tide + +Real modeled **surface current** (NOAA SSCOFS) over the fishing area, animated against the GPS track. Hit ▶ to watch the boat work the morning. The current stayed weak (**0.3–0.5 kt**) but **rotated clockwise** — SSE → ESE → NE through the trip — the back-eddy behind West Point while the main channel ebbed north. The **8:49 AM Chinook** came near slack, on the last of the weak flood. + +> [!NOTE] Data & fidelity +> Surface current from NOAA **SSCOFS** (FVCOM operational model), regular-grid output, hourly 15:00–20:00 UTC (08:00–13:00 PDT), **~0.5 km cells** interpolated in time — the model's regular-grid native resolution. Renders with no external map tiles (the route outline is the geography). Map-backed standalone viewer: `fishing-log/current-playback.html`; full-resolution extraction: `scripts/route_currents.py`. + +```html h=640px preview +
+
+ current speed + + 0 → 1+ kt · arrow points the way water flows +
+
+ +
+
--:--
+
+
current · set
+
+
~0.5 km grid · no basemap
+
+
+ + + +
+ +
+``` + +## Where & When + +- **Date:** 2026-06-07 (Sunday) +- **Water:** Shilshole Bay, Seattle (Puget Sound) — Marine Area 10 +- **Type:** saltwater +- **Time on water:** \~7:00 AM – noon +- **Platform:** Hobie pedal kayak +- **Track (Strava — partial):** GPS recording ran **8:37 AM – 12:27 PM** (started after launch, so it covers the back \~3.5 hrs of the \~7 AM–noon outing). Partial totals: **5.63 mi**, 3:50:09 elapsed, **\~1.5 mph average** (trolling, plus \~2 hrs sitting on structure). The full GPX (`Morning_Activity.gpx`) and an interactive speed-colored map with the catch + structure waypoints live in [`trip-viewer.html`](./trip-viewer.html). +- **Logged by:** Andrew + +## Conditions + +- **Weather:** **Overcast/cloudy at launch, clearing to mostly sunny by midday** — the 8:49 AM catch photo shows flat grey water; the 12:21 PM sea lion photo is in full sun. KSEA (nearest hourly station) recorded **\~48°F at 7 AM warming to \~57°F by late morning**, **light-to-moderate southerly wind \~7–13 mph**, and a **steady barometer near 1017–1018 hPa**. Full observed hourly table in the [June 7 conditions snapshot](../external-sources/2026-06-07-seattle-shilshole-conditions.md). +- **Water:** Temp / clarity not recorded *(TBD)*. +- **Tide:** **Weak incoming morning tide** (NOAA Seattle 9447130) — low **5:27 AM (5.75 ft)** rising to a modest high **9:55 AM (7.43 ft)**, then ebbing to low **4:19 PM (1.30 ft)**. The **8:49 AM Chinook came on the last of the incoming**, \~1 hr before slack high. See the [conditions snapshot](../external-sources/2026-06-07-seattle-shilshole-conditions.md). +- **Sun:** Sunrise **5:10 AM**, sunset **9:06 PM** — the \~7 AM launch was \~2 hrs into full daylight. +- **Solunar:** Day rated **Low** (coefficient 49), waning crescent \~54% lit. Morning **major period 6:28–8:28 AM** — the **8:49 AM catch landed right at its tail**, the strongest solunar window of the daylight hours. + +## Target & Method + +Two targets this trip: + +- **Resident coho — trolling.** Small **green / red / white striped spoon** (likely the same 3" striped spoon from the [June 2 evening session](./2026-06-02-shilshole-bay-evening.md)). This is what the undersize **Chinook** ate, mid-bay, while trolling **on the downrigger at 45 ft**. Full leader / flasher setup not recorded. +- **Lingcod — working rocky structure.** Fished a **slider (sliding-weight) rig** with bait over two rocky structures at the south end of the bay (see below), the same structure-and-bait approach as the [Edmonds opener](./2026-05-01-edmonds-bottomfish-opener.md). Offerings included an **imitation (soft-plastic) flounder** and a **herring**. + +## Catch + +**Landed: 1 (released — undersize Chinook). Lingcod: 0.** + +| Species | Count | Size | Kept / Released | Notes | +| --- | --- | --- | --- | --- | +| Chinook salmon | 1 | Undersize (< MA-10 minimum) | Released | Incidental on the coho troll — hooked **8:49 AM** on the green/red/white spoon at 45 ft on the downrigger, mid-bay | +| Resident coho | 0 | — | — | Targeted on the troll; none landed | +| Lingcod | 0 | — | — | Worked two rocky structures \~2 hrs; hung up twice, no ling | + +### Catch location + +The GPS point at the catch photo's timestamp (8:49:48 AM) puts the Chinook hookup at **47.68079, -122.42075** — out in the open bay, 45 ft down on the downrigger, not on the southern structure. Tap the 🎣 pin in [`trip-viewer.html`](./trip-viewer.html) for the exact spot. + +## Rocky Structure & Snags + +Spent roughly **9:13–11:00 AM** working the **south end of the bay**, where the track collapses into two tight dwell clusters — a couple of **rocky structures** held on for lingcod: + +| Structure | Approx. location | Notes | +| --- | --- | --- | +| North rock | \~47.6740, -122.4227 | Hard structure; worked the slider rig + bait | +| South rock | \~47.6730, -122.4219 | Tightest dwell knot of the morning | + +**Hung up twice in the rocks.** Lost gear to snags: + +- a **slider** (sliding-weight rig component) +- a **weight** +- an **imitation flounder** (soft-plastic lingcod offering) +- a **herring** (bait) + +The two structures are marked with ⛰️ pins in [`trip-viewer.html`](./trip-viewer.html). + +## Bait & Forage + +- **Sea lions:** The Dock X float was **still packed with sea lions** at midday (photo + two videos), same haul-out logged on [June 1](./2026-06-01-shilshole-bay-coho-opener.md) and [June 2](./2026-06-02-shilshole-bay-evening.md). A haul-out that size keeps signalling plenty of forage in the bay — and serious competition working the same water. + +## What Worked / What Didn't + +- **First salmon contact here** after two skunked Shilshole sessions ([June 1](./2026-06-01-shilshole-bay-coho-opener.md), [June 2](./2026-06-02-shilshole-bay-evening.md)) — the small green/red/white spoon got the eat (a Chinook, not the targeted coho) where the big flashers hadn't. +- **Lingcod were a bust** — \~2 hrs over two rocky structures produced no ling, just two snags and lost terminal gear. The slider rig + soft-plastic flounder / herring hung in the rocks. +- The Chinook came **out in the open bay** on the troll, well north of the structure — salmon and ling water were in clearly different parts of the bay. + +## Notes for Next Time + +- **Keep running the small striped spoon on the downrigger** for salmon — it out-fished the big-flasher presentations from June 1–2 here, and **45 ft got the Chinook** this trip. +- **Lighten up / make terminal gear breakaway on the rocks.** Two hang-ups cost a slider, weight, flounder, and herring. A rotten-leader / breakaway-weight setup (like the 3 oz rubber-band breakaway on the [Edmonds rig](./2026-05-01-edmonds-bottomfish-opener.md)) would have saved the rig and the bait. +- **Mark the two south-bay rocks** (47.6740/-122.4227 and 47.6730/-122.4219) as known ling structure — worth a return with snag-resistant gear. +- Record start/end times, tide, and water temp next outing so the salmon bite and the structure work can be tied to a tide stage. + +--- +title: Shilshole Bay — Resident Coho & Lingcod, June 7 2026 +description: Morning kayak trip at Shilshole Bay (Marine Area 10), Seattle, ~7 AM–noon, targeting resident coho and lingcod. Trolled a green/red/white spoon on the downrigger at 45 ft (one undersize Chinook to hand, released) and worked two rocky structures in the south bay for lingcod — hung up twice, lost gear, no ling. Dock X sea lion haul-out. Partial GPX, 5.63 mi. +tags: + - fishing-log + - trip + - shilshole-bay + - saltwater + - coho + - lingcod + - chinook + - salmon + - kayak +--- +# Shilshole Bay — 2026-06-07 + +> [!NOTE] Trip summary +> Morning kayak trip targeting **resident coho and lingcod**. Trolled a small green/red/white spoon and got **one Chinook to hand at 8:49 AM — undersize, released** (incidental on the coho gear, on the downrigger at 45 ft). Then worked **two rocky structures in the south bay for lingcod for ~2 hours — hung up twice and lost gear in the rocks, no ling landed.** Fished ~7 AM to noon; overcast at launch, clearing to bright sun by midday. Dock X still packed with sea lions. + +## Photos + +![An undersize Chinook salmon just under the surface inside an orange landing net, hooked on a green/red/white striped spoon, grey flat morning water around it](./assets/2026-06-07-undersize-chinook-net.jpg) + +*The incidental Chinook in the net, \~8:49 AM — hooked on the green/red/white spoon at 45 ft on the downrigger while trolling for coho, under the minimum and released. (Original)* + +![The Shilshole Dock X float packed end to end with dozens of hauled-out sea lions in bright midday sun, sailboat masts and a green forested bluff behind, calm blue water in front](./assets/2026-06-07-shilshole-sealion-dock-x.jpg) + +*Dock X covered with sea lions, \~12:21 PM in full sun — same haul-out as the [June 1](./2026-06-01-shilshole-bay-coho-opener.md) and [June 2](./2026-06-02-shilshole-bay-evening.md) sessions. (Original)* + +## Track Map + +The morning's GPS track (partial recording, 8:37 AM–12:27 PM), **speed-colored** slow → fast, with the 🎣 Chinook catch, the two ⛰️ rocky structures worked for lingcod, and the 🦭 Dock X sea lion haul-out. Interactive — drag, zoom, and tap a pin. Same map as [`trip-viewer.html`](./trip-viewer.html), which also carries the photo gallery. + +```html h=521px preview w=1003px +
+
+
+ slowfast +
+
+ + + +
+``` + +## Currents — how I fished the tide + +Real modeled **surface current** (NOAA SSCOFS) over the fishing area, animated against the GPS track. Hit ▶ to watch the boat work the morning. The current stayed weak (**0.3–0.5 kt**) but **rotated clockwise** — SSE → ESE → NE through the trip — the back-eddy behind West Point while the main channel ebbed north. The **8:49 AM Chinook** came near slack, on the last of the weak flood. + +> [!NOTE] Data & fidelity +> Surface current from NOAA **SSCOFS** (FVCOM operational model), regular-grid output, hourly 15:00–20:00 UTC (08:00–13:00 PDT), **~0.5 km cells** interpolated in time — the model's regular-grid native resolution. Renders with no external map tiles (the route outline is the geography). Map-backed standalone viewer: `fishing-log/current-playback.html`; full-resolution extraction: `scripts/route_currents.py`. + +```html h=640px preview +
+
+ current speed + + 0 → 1+ kt · arrow points the way water flows +
+
+ +
+
--:--
+
+
current · set
+
+
~0.5 km grid · no basemap
+
+
+ + + +
+ +
+``` + +## Where & When + +- **Date:** 2026-06-07 (Sunday) +- **Water:** Shilshole Bay, Seattle (Puget Sound) — Marine Area 10 +- **Type:** saltwater +- **Time on water:** \~7:00 AM – noon +- **Platform:** Hobie pedal kayak +- **Track (Strava — partial):** GPS recording ran **8:37 AM – 12:27 PM** (started after launch, so it covers the back \~3.5 hrs of the \~7 AM–noon outing). Partial totals: **5.63 mi**, 3:50:09 elapsed, **\~1.5 mph average** (trolling, plus \~2 hrs sitting on structure). The full GPX (`Morning_Activity.gpx`) and an interactive speed-colored map with the catch + structure waypoints live in [`trip-viewer.html`](./trip-viewer.html). +- **Logged by:** Andrew + +## Conditions + +- **Weather:** **Overcast/cloudy at launch, clearing to mostly sunny by midday** — the 8:49 AM catch photo shows flat grey water; the 12:21 PM sea lion photo is in full sun. KSEA (nearest hourly station) recorded **\~48°F at 7 AM warming to \~57°F by late morning**, **light-to-moderate southerly wind \~7–13 mph**, and a **steady barometer near 1017–1018 hPa**. Full observed hourly table in the [June 7 conditions snapshot](../external-sources/2026-06-07-seattle-shilshole-conditions.md). +- **Water:** Temp / clarity not recorded *(TBD)*. +- **Tide:** **Weak incoming morning tide** (NOAA Seattle 9447130) — low **5:27 AM (5.75 ft)** rising to a modest high **9:55 AM (7.43 ft)**, then ebbing to low **4:19 PM (1.30 ft)**. The **8:49 AM Chinook came on the last of the incoming**, \~1 hr before slack high. See the [conditions snapshot](../external-sources/2026-06-07-seattle-shilshole-conditions.md). +- **Sun:** Sunrise **5:10 AM**, sunset **9:06 PM** — the \~7 AM launch was \~2 hrs into full daylight. +- **Solunar:** Day rated **Low** (coefficient 49), waning crescent \~54% lit. Morning **major period 6:28–8:28 AM** — the **8:49 AM catch landed right at its tail**, the strongest solunar window of the daylight hours. + +## Target & Method + +Two targets this trip: + +- **Resident coho — trolling.** Small **green / red / white striped spoon** (likely the same 3" striped spoon from the [June 2 evening session](./2026-06-02-shilshole-bay-evening.md)). This is what the undersize **Chinook** ate, mid-bay, while trolling **on the downrigger at 45 ft**. Full leader / flasher setup not recorded. +- **Lingcod — working rocky structure.** Fished a **slider (sliding-weight) rig** with bait over two rocky structures at the south end of the bay (see below), the same structure-and-bait approach as the [Edmonds opener](./2026-05-01-edmonds-bottomfish-opener.md). Offerings included an **imitation (soft-plastic) flounder** and a **herring**. + +## Catch + +**Landed: 1 (released — undersize Chinook). Lingcod: 0.** + +| Species | Count | Size | Kept / Released | Notes | +| --- | --- | --- | --- | --- | +| Chinook salmon | 1 | Undersize (< MA-10 minimum) | Released | Incidental on the coho troll — hooked **8:49 AM** on the green/red/white spoon at 45 ft on the downrigger, mid-bay | +| Resident coho | 0 | — | — | Targeted on the troll; none landed | +| Lingcod | 0 | — | — | Worked two rocky structures \~2 hrs; hung up twice, no ling | + +### Catch location + +The GPS point at the catch photo's timestamp (8:49:48 AM) puts the Chinook hookup at **47.68079, -122.42075** — out in the open bay, 45 ft down on the downrigger, not on the southern structure. Tap the 🎣 pin in [`trip-viewer.html`](./trip-viewer.html) for the exact spot. + +## Rocky Structure & Snags + +Spent roughly **9:13–11:00 AM** working the **south end of the bay**, where the track collapses into two tight dwell clusters — a couple of **rocky structures** held on for lingcod: + +| Structure | Approx. location | Notes | +| --- | --- | --- | +| North rock | \~47.6740, -122.4227 | Hard structure; worked the slider rig + bait | +| South rock | \~47.6730, -122.4219 | Tightest dwell knot of the morning | + +**Hung up twice in the rocks.** Lost gear to snags: + +- a **slider** (sliding-weight rig component) +- a **weight** +- an **imitation flounder** (soft-plastic lingcod offering) +- a **herring** (bait) + +The two structures are marked with ⛰️ pins in [`trip-viewer.html`](./trip-viewer.html). + +## Bait & Forage + +- **Sea lions:** The Dock X float was **still packed with sea lions** at midday (photo + two videos), same haul-out logged on [June 1](./2026-06-01-shilshole-bay-coho-opener.md) and [June 2](./2026-06-02-shilshole-bay-evening.md). A haul-out that size keeps signalling plenty of forage in the bay — and serious competition working the same water. + +## What Worked / What Didn't + +- **First salmon contact here** after two skunked Shilshole sessions ([June 1](./2026-06-01-shilshole-bay-coho-opener.md), [June 2](./2026-06-02-shilshole-bay-evening.md)) — the small green/red/white spoon got the eat (a Chinook, not the targeted coho) where the big flashers hadn't. +- **Lingcod were a bust** — \~2 hrs over two rocky structures produced no ling, just two snags and lost terminal gear. The slider rig + soft-plastic flounder / herring hung in the rocks. +- The Chinook came **out in the open bay** on the troll, well north of the structure — salmon and ling water were in clearly different parts of the bay. + +## Notes for Next Time + +- **Keep running the small striped spoon on the downrigger** for salmon — it out-fished the big-flasher presentations from June 1–2 here, and **45 ft got the Chinook** this trip. +- **Lighten up / make terminal gear breakaway on the rocks.** Two hang-ups cost a slider, weight, flounder, and herring. A rotten-leader / breakaway-weight setup (like the 3 oz rubber-band breakaway on the [Edmonds rig](./2026-05-01-edmonds-bottomfish-opener.md)) would have saved the rig and the bait. +- **Mark the two south-bay rocks** (47.6740/-122.4227 and 47.6730/-122.4219) as known ling structure — worth a return with snag-resistant gear. +- Record start/end times, tide, and water temp next outing so the salmon bite and the structure work can be tied to a tide stage. + diff --git a/packages/server/src/bridge-watchdog.ts b/packages/server/src/bridge-watchdog.ts index ac790af1..549880d1 100644 --- a/packages/server/src/bridge-watchdog.ts +++ b/packages/server/src/bridge-watchdog.ts @@ -56,12 +56,12 @@ const lastEmitMs = new Map(); const MAX_VIOLATION_RATE_TUPLES = 1024; /** Map for the bridge-tolerance-applied event. - * rateKey = `${site}::${class}`. Bounded cardinality: 15 classes × 3 sites = - * 45 entries max globally. Per-(site, class) windows let operators see how + * rateKey = `${site}::${class}`. Bounded cardinality: 16 classes × 3 sites = + * 48 entries max globally. Per-(site, class) windows let operators see how * often each site relies on each tolerance class — observer-b CRLF rates * vs persistence CRLF rates surface separately. * - * WARN: same module-level state caveat as `lastEmitMs` above. The 45-entry + * WARN: same module-level state caveat as `lastEmitMs` above. The 48-entry * bound is global; under multi-server-per-process, a single server's * tolerance event would suppress another server's same-class event in * the same window. Less concerning than the violation rate-limiter