Skip to content

feat: best-effort markdown → Slack Block Kit conversion (13 LLM-input repairs + surface-uniform routing + text fallback) - #9

Merged
hishamkaram merged 10 commits into
mainfrom
feat/best-effort-conversion
May 20, 2026
Merged

feat: best-effort markdown → Slack Block Kit conversion (13 LLM-input repairs + surface-uniform routing + text fallback)#9
hishamkaram merged 10 commits into
mainfrom
feat/best-effort-conversion

Conversation

@hishamkaram

Copy link
Copy Markdown
Owner

Why

A production Slack screenshot from a downstream bot showed the
familiar failure mode: every ## Header, every **Bold:**, every
[label](url) rendered as literal markdown text. Root-cause
investigation found three reinforcing causes — malformed LLM input
(links split across lines, ATX headers without spaces, etc.),
surface-degraded rendering of the markdown block on push
notifications / search / screen readers / email digest, and the
ubiquitous "caller wired the result into text= instead of
blocks=" footgun.

This PR makes the library do its best-effort conversion regardless of
LLM input quality or caller wiring choices, so the bot's output looks
right on every Slack surface out of the box.

What changed

Eight atomic commits (each independently revertable, all
Conventional Commits, all hooks green):

Commit What
f2ae535 feat(normalizer): new package skeleton with fence walker
e464dcf feat(normalizer): repair V8 split-link, V5 ATX header, C3 bullet, C4 numbered
9f71094 feat(normalizer): repair V7 URL unicode, C5/C9 trailing whitespace, C1 single-tilde, C6 borderless table, R8 br tag
2176ce1 feat(normalizer): repair V4 one-line fence, V3 unclosed fence, V2 unclosed inline code, V1 unclosed emphasis
09ca610 feat(normalizer): repair V6 mismatched asterisks (opt-in)
7d182f0 feat(converter): wire normalizer + PreferRichText + DecodeHTMLEntities + surface advisory
d7e80c1 feat(converter): DeriveTextFallback, table pad-up, public re-exports
1a3826e feat(server): expose normalizer + best-effort knobs; CLI flags, docs, format_for_slack prompt

1. Pre-parse normalizer (new internal/converter/normalizer/)

13 always-on repairs + 2 opt-in, each with cited evidence in
docs/llm-input-recovery.md:

Code Repair Default
V1 Unclosed emphasis (**bold) on
V2 Unclosed inline code (`code) on
V3 Unclosed fenced code (```, ~~~) on
V4 One-line ```go code``` split to canonical 3 lines on
V5 #Title# Title on
V6 Mismatched asterisks (**italic**italic*) opt-in
V7 Unicode in URL paths (en-dash, em-dash, smart quotes → ASCII) on
V8 [label]\n(url)[label](url) on
V11 Whitelisted HTML entity decode opt-in
C1 Stray ~ between word chars escaped on
C3/C4 Bullet/numbered marker without space on
C5/C9 Trailing whitespace stripped (preserves hard-break marker) on
C6 Borderless GFM tables get edge pipes on
C7 Short table rows padded to header column count on
R8 <br> → newline (or space inside table cells) on

Every repair: idempotent, code-fence-aware, O(n), broadcast-safe
(no repair can introduce literal <!channel> etc. that wasn't in
the input). All proven by per-pattern unit tests + four property
tests + a FuzzNormalize target.

2. New Options knobs

  • PreferRichText (default false) — biases auto-mode toward
    rich_text decomposition. rich_text renders identically on
    every Slack surface; the single markdown block can show literal
    characters on push notifications, search, screen readers, and
    email digest. Planned to flip to true in the next major
    release
    (documented in CHANGELOG Deprecated).
  • DecodeHTMLEntities (default false) — gates V11's
    whitelisted entity decoder.

3. End-to-end fallback closure

  • MarkdownBlockFallbackSurfacesWarning constant — auto-mode
    emits this advisory whenever it picks a markdown block.
  • DeriveTextFallback([]slack.Block) string + new
    ConvertOutput.text_fallback field — gives the caller a clean
    plain-text summary suitable for chat.postMessage(text=).
  • format_for_slack MCP prompt body rewritten to teach the
    LLM the recipe: pass blocks as blocks=, pass text_fallback
    as text=, surface warnings.

4. User-facing surfaces

  • MCP convert tool gains prefer_rich_text /
    decode_html_entities inputs and text_fallback output.
  • CLI (./bin/mcp-slack-block-kit convert) gains
    --prefer-rich-text and --decode-html-entities flags; JSON
    output now includes text_fallback and warnings.
  • block_kit/ public surface re-exports the new constants and
    helper (Options.PreferRichText/DecodeHTMLEntities ride the
    existing type alias).
  • Cheatsheet (the block-kit-cheatsheet MCP resource) and
    README gain a "Best-effort posting recipe" + "Troubleshooting:
    literal ##/**/[label](url) appear in Slack" section walking
    through the three diagnostic causes.

End-to-end against the original screenshot

$ printf '%s\n' '- [Project Folder]' '(https://drive.google.com/drive/folders/abc)' \
    | ./bin/mcp-slack-block-kit convert --mode auto --prefer-rich-text --pretty

Now produces (abridged):

{
  "blocks": [{
    "type": "rich_text",
    "elements": [{
      "type": "rich_text_list",
      "elements": [{
        "type": "rich_text_section",
        "elements": [{
          "type": "link",
          "url": "https://drive.google.com/drive/folders/abc",
          "text": "Project Folder"
        }]
      }],
      "style": "bullet"
    }]
  }],
  "text_fallback": "Project Folder",
  "warnings": [
    "normalized input (LLM-mistake repairs fired: V8). See docs/llm-input-recovery.md for codes."
  ]
}

The malformed split-link is now repaired automatically AND surfaces
in warnings for caller-side visibility, AND the text_fallback is
ready for chat.postMessage(text=).

Validation

Check Result
go test ./... all packages pass
go test -race ./... all packages pass
make lint 0 issues
make vuln no vulnerabilities
Fuzz (60s, all-pipeline) 55k+ execs, no failures
Pre-push hooks (build-smoke, govulncheck, test-race, fuzz-smoke) all green

Per-package coverage:

Package Coverage
internal/validator 95.9%
internal/converter/normalizer 94.1%
internal/converter 87.5%
internal/reverse 87.5%
internal/server 86.7%
block_kit/ 85.7%
internal/splitter 85.1%
cmd/mcp-slack-block-kit 83.5%
internal/preview 80.0%

All above the ≥80% project gate.

Migration

This release is fully backward-compatible. Every new field
defaults to its existing behavior; Options.PreferRichText and
Options.DecodeHTMLEntities are explicit opt-ins. Two future-facing
notes:

  • Auto-mode now emits an always-on MarkdownBlockFallbackSurfacesWarning
    in warnings whenever it picks a markdown block. Callers that
    asserted len(warnings) == 0 on short auto-mode inputs need to
    update (the only in-tree test that did so has already been
    adjusted).
  • Options.PreferRichText will default to true in the next
    major release
    . To pin current behavior across the flip, set it
    explicitly to false.

Compatibility

  • No new dependencies.
  • No removed or renamed public symbols.
  • 6-tool MCP surface unchanged (the new fields are additive on the
    existing convert_markdown_to_block_kit tool).
  • format_for_slack prompt body rewritten — the prompt name and
    argument shape are unchanged, only the instruction text changed.

🤖 Generated with Claude Code

Introduces internal/converter/normalizer/, the entry point for repairing
LLM-emitted markdown malformations before goldmark parses. This commit
ships only the orchestration shell — the concrete per-pattern repairs
land in subsequent commits, one or two patterns at a time.

Files:

- normalizer.go: Normalize(src, opts) entry point with the documented
  pipeline order (structural-space first, then URL hygiene, then
  inline structure, then opt-in passes, then paragraph-level balance
  for unclosed constructs). The pipeline body is currently a no-op so
  downstream wiring can land independently of any individual repair.
  Also contains the firedSet helper that deduplicates catalog codes
  while preserving pipeline order.
- fence_state.go: classify(src) walks the source once and tags every
  line with its CommonMark context (prose, fence-open/content/close,
  indented code, table, blank). Every repair will consult this state
  before touching bytes so we never corrupt code spans, fenced blocks,
  or table delimiter rows.
- options.go: gates the two opt-in repairs (V11 entity decode, V6
  asterisk balance) that touch broadcast-safety or false-positive-
  risky paragraphs.

Tests:

- normalizer_test.go: empty-input handling, no-op passthrough across
  five well-formed inputs, opt-in flag plumbing, firedSet dedup
  ordering.
- fence_state_test.go: 12-row table-driven classifier suite covering
  fences (backtick and tilde, mismatched, with language tags),
  indented code (4-space and tab), GFM tables (with and without
  leading pipes), and blank-line boundaries. Round-trip invariant for
  reassemble.
- normalizer_property_test.go: four safety-invariant property tests
  using testing/quick — idempotence under composition, 4×+1KiB length
  bound, no broadcast-token smuggling (with the opt-in entity decoder
  on), and byte-for-byte preservation of well-formed fenced code
  block contents.
- normalizer_fuzz_test.go: FuzzNormalize with one seed per catalog
  code plus a broadcast-token seed; asserts the same invariants
  (idempotence + length bound) across arbitrary input. Fuzzed 791k
  inputs in 10s locally with no failures.

Coverage on the new package: 98.8%.

No new dependencies. No behavior change to the converter — the package
is unused this commit.

Catalog reference: docs/llm-input-recovery.md (lands with the
user-facing surfaces in the final commit).
…numbered

Four catalog repairs that, taken together, fully recover the user's
production screenshot (a HubSpot deal summary where every header, every
bullet, and every Drive link rendered as literal markdown text in
Slack).

Repairs:

- V5 (atx_header_space.go): inserts a space between the `#` run and
  heading text when missing. Regex anchored at line start with
  `( {0,3})(#{1,6})([^\s#])` so it never touches mid-paragraph
  `#hashtag` or `####` closer lines. Guards verified by
  TestApplyATXHeaderSpace_FalsePositiveGuard.

- C3 (bullet_no_space.go): inserts a space after `-`/`*`/`+` when
  missing. False-positive guard requires (1) the next character is
  non-digit (so `-1 means undefined` passes through) AND (2) at least
  one adjacent line shares the same indent + marker (so single-line
  prose like "-loner" passes through).

- C4 (numbered_no_space.go): same shape for `1.item` / `1)item`.
  Same peer-presence guard.

- V8 (split_link.go): merges `[label]\n(url)` into `[label](url)`.
  Operates on line pairs; consumes blank lines between bracket and
  paren (paragraph-break variant). False-positive guard restricts the
  (url) content to URL-ish strings (must contain `:`, `/`, `@`, or
  `#`), so prose parentheticals like "[Bar]\n(actually Baz)" are
  not collapsed.

Pipeline order (normalizer.go):

  V8 → V5 → C3 → C4

V8 runs first because the merge changes line count; line-local repairs
then operate on the final layout. This ordering is required for
idempotence — the test
TestNormalize_ScreenshotInput_RepairsAllExpectedPatterns demonstrates
why: without it, V8's merge creates new C3 peer relationships that
fire on a second pass.

Tests:

- 4 per-pattern test files, each covering positive cases,
  false-positive guards, fence-safety, and idempotence (16 test
  functions total).
- integration_test.go runs the full pipeline against
  testdata/screenshot_hubspot_summary.md (the actual bot output from
  the user's production Slack screenshot), asserts the expected three
  repair codes fire (V8, V5, C3) and the resulting markdown contains
  every header, bullet, and collapsed-link fragment we expect.
- All five existing property tests still pass (idempotence, length
  bound, no broadcast smuggling, code-block preservation).
- FuzzNormalize seeded with one input per catalog code; 906k
  iterations in 15s with no failures.

Coverage on the normalizer package: 94.1%.

Catalog reference: docs/llm-input-recovery.md (lands in commit 8).
…1 single-tilde, C6 borderless table, R8 br tag

Five more catalog repairs. Coverage rises to 95.2% on the normalizer
package; lint clean; 30s fuzz with no failures.

- V7 (url_unicode.go): rewrites Unicode look-alike characters
  (en-dash, em-dash, curly quotes, ellipsis) to their ASCII
  equivalents inside markdown-link URLs and autolinks ONLY. Em-dashes
  in prose stay untouched (legitimate typographic choice). Uses
  utf8.AppendRune; gosec G115 clean.

- C5/C9 (trailing_whitespace.go): strips trailing whitespace per line
  EXCEPT the two-trailing-spaces CommonMark hard line-break marker
  (§6.7). Universally safe; rolls in C5 (delimiter row) and C9 (list
  item) since the repair shape is identical.

- C1 (tilde_in_word.go): escapes `<word>~<word>` so downstream
  parsers don't treat the stray tilde as a broken GFM strikethrough
  opener. Replaces a regex-based implementation with a single
  byte-walk that handles overlapping matches in one pass — a fuzz
  regression on the input `~0~0~0` proved the regex pipeline was
  not idempotent (ReplaceAllString consumes the trailing word
  character, so the second tilde only got caught on the second
  Normalize pass). Skips lines that already contain `~~` (preserves
  authorial strikethrough intent).

- C6 (borderless_table.go): adds leading and trailing pipes to GFM
  table rows missing them. Operates only on lines the fence walker
  has classified as LineTable; preserves the original leading indent.

- R8 (br_tag.go): replaces `<br>`/`<br/>`/`<br />` (any case)
  with newlines OUTSIDE table cells and with spaces INSIDE table
  cells. Slack renders neither raw HTML nor in-cell line breaks
  natively; this gives the visual break the author intended without
  leaving `<br>` text visible.

Pipeline ordering in normalizer.go:

  R8 → V8 → C5 → V5 → C3 → C4 → V7 → C1 → C6

R8 first because it can split one line into many. V8 next so split-
link merges settle before whitespace and space-insertion repairs see
the final line layout. C5 (trailing whitespace) precedes the
line-pattern regexes because trailing whitespace would break their
end anchors. V7/C1/C6 are line-local and order-independent among
themselves.

Tests: 5 per-pattern test files with positive/false-positive/fence-
safety/idempotence shapes. All four property tests still pass
(idempotence, length bound, no broadcast smuggling, code preservation).
30s fuzz run; the surviving crash from a previous run (~0~0~0) is now
fixed and the corpus seed exercises it explicitly.
…losed inline code, V1 unclosed emphasis

Four paragraph-level balance repairs for unclosed CommonMark
constructs. These are the catalog's highest-severity patterns —
unclosed fenced code in particular consumes the rest of the document.

- V3 (unclosed_fence.go): detects via the fence walker that the input
  ended inside a fenced code block (last LineFenceOpen had no matching
  LineFenceClose) and appends a closer matching the opener's character
  (backtick/tilde), length, and indent. The single highest-severity
  repair in the catalog — without it an orphaned triple-backtick
  swallows every trailing header, list, and paragraph into one giant
  rich_text_preformatted block.

- V4 (fence_lang_newline.go): splits a one-line ```go fmt.Println("hi")```
  into the canonical three-line form. Two regexes (backtick and tilde)
  because Go's regexp engine doesn't support backreferences.
  Conservative knownLanguages whitelist (~50 entries) prevents false
  positives on inputs like ```hello world``` where the first token
  isn't a real language tag. Operates on both LineProse AND
  LineFenceOpen lines because the fence walker treats one-line fences
  as openers with the body+closer crammed into the info string.

- V2 (unclosed_inline_code.go): appends a closer for an unmatched
  backtick opener. Pair-matching algorithm follows CommonMark 6.1:
  greedy leftmost-leftmost pairing where intervening backticks become
  span content (so ``a `b`` with runs [2,1,2] is correctly recognized
  as balanced). Appends exactly ONE closer of the first unmatched
  run's length — subsequent unmatched runs collapse into the new
  span's content. Two idempotence guards:
    1. Skip lines that already end in a backtick (an append would
       merge into a larger run and re-arm the repair).
    2. Skip lines whose unmatched opener has no non-backtick content
       after it (no content to wrap, would just keep growing).

- V1 (unclosed_emphasis.go): appends a matching closer when a
  paragraph contains EXACTLY one unmatched **double-star** (bold) or
  standalone *single-star* (italic) run with word content after it.
  Deliberately conservative — the general balancer is V6's job
  (gated behind RepairMismatchedEmphasis). Inputs with mixed or
  ambiguous asterisk patterns are intentionally untouched: repairing
  them risks non-idempotent output.

Pipeline order in normalizer.go (full order now):

  R8 -> V8 -> C5 -> V5 -> C3 -> C4 -> V7 -> C1 -> C6 -> V4 -> V3 -> V1 -> V2

V1 runs BEFORE V2: an emphasis appender that turns a line ending in a
backtick into one ending in an asterisk would re-arm V2 on the next
pass. Pinned by FuzzNormalize against the *0000000` regression that
forced this ordering.

Tests: 4 per-pattern test files with positive / false-positive /
fence-safety / idempotence shapes. End-to-end test
TestApplyUnclosedFence_DoesNotConsumeRestOfDocument demonstrates V3's
business value: prose after an orphaned fence emerges as its own
block instead of being swallowed.

Validation:
- All package tests pass.
- All five property tests still hold (idempotence, length bound,
  no broadcast smuggling, code-block preservation).
- 60s fuzz: 35k execs, no failures (multiple intermediate fuzz
  failures were caught and fixed during development).
- make lint clean.
- go test -race ./... all green.

Coverage: 94.3% on the normalizer package.
Adds the catalog's trickiest repair: collapsing mismatched
emphasis-marker pairs like **italic* and *bold** to the smaller
count. Gated behind Options.RepairMismatchedEmphasis (default false)
because the algorithm is the most failure-prone in the catalog and
false positives corrupt prose with deliberate asymmetric asterisks.

Implementation (asterisk_balance.go):

- Per-paragraph processing.
- Reject paragraphs with an odd number of asterisk runs, any run
  of length >= 3 (bold-italic territory), or any pair whose inner
  content contains an asterisk (signal that the author meant a
  larger bold span with literal inner asterisks).
- hasInnerAsteriskOverlap rejects paragraphs where consecutive pairs
  have no whitespace between them — the **a*b*c** shape where each
  "pair" actually shares content with its neighbor.

Pipeline placement: V6 runs BEFORE V1 in the tail-balance phase.
Collapsing a mismatched pair to *italic* can remove an unmatched
opener that V1 would otherwise have to close.

Fuzz-driven fixes that landed here:

- V2 now skips LineTable (a backtick inside a table cell is
  content, not an inline-code opener). Caught by the fuzz seed
  "0\n---\n0`".

- R8 now emits empty trailing segments as LineBlank rather than
  LineProse so V1's paragraph collection matches the classifier's
  post-pass tagging — making the R8 -> V1 -> classify chain
  idempotent across passes. Caught by the fuzz seed "*000<Br>".

- V1 now appends only when the unmatched emphasis opener is on the
  LAST line of the paragraph (multi-line emphasis where the opener
  sits on an earlier line is genuinely ambiguous, and appending to
  the last line could land the closer adjacent to a structural
  char like '#', re-arming V5 on the next pass). Caught by the fuzz
  seed "*0\n# ".

Tests: per-pattern test file with positive / disabled-by-default /
false-positive-guard / fence-safety / idempotence coverage. All
property tests still hold. 60s fuzz: 85k execs, zero failures.

Coverage: 94.8% on the normalizer package.
…s + surface advisory

End-to-end integration of the normalizer pipeline into the converter,
plus two new opt-in routing knobs and the always-on auto-mode
fallback-surface advisory.

Changes:

- internal/converter/options.go
  - Adds Options.PreferRichText (default false). When true, auto-mode
    biases toward rich_text decomposition over the single Slack
    markdown block. rich_text renders identically on push
    notifications, search, screen readers, and the email digest,
    where the markdown block's fallback rendering may show literal
    ##/**/[label](url) characters. Default flip planned for the
    next major release.
  - Adds Options.DecodeHTMLEntities (default false). Threaded through
    to the normalizer's V11 (HTML entities) repair. Default off
    because the decoded chars re-escape through sanitizeBroadcasts —
    safe outcome but worth explicit opt-in.

- internal/converter/markdown_block.go
  - shouldUseMarkdownBlock now early-returns false when
    opts.PreferRichText is set, skipping the AST walk.

- internal/converter/renderer.go
  - Adds MarkdownBlockFallbackSurfacesWarning constant: the
    deterministic advisory auto-mode emits whenever it picks a
    markdown block.
  - ConvertWithWarnings now runs the normalizer pipeline after the
    Slack mrkdwn URL-form rewrite and before goldmark parses. The
    fired-codes are surfaced as a single "normalized input
    (LLM-mistake repairs fired: V8, C3)" warning so the caller can
    audit the repair chain.
  - Auto-mode markdown-block picks now prepend the surface advisory
    to the warnings slice; explicit ModeMarkdownBlock callers see
    no advisory (preserves the existing contract).

- internal/converter/nested_test.go
  - TestNested_LooseList_AutoMode_StillUsesMarkdownBlock updated to
    expect the surface advisory (the only auto-mode test that
    asserted len(warnings)==0 on short input).
  - Adds expectSurfaceWarningOnly helper for shared use.

- internal/converter/prefer_rich_text_test.go (new)
  - TestPreferRichText_ShortProseRoutesToRichText
  - TestPreferRichText_DefaultFalse_PreservesExistingBehavior
  - TestSurfaceWarning_AutoMarkdownBlock_Emitted
  - TestSurfaceWarning_PreferRichText_Suppressed
  - TestSurfaceWarning_ExplicitMarkdownBlockMode_NoWarning
  - TestNormalization_AppliedInline (end-to-end: malformed split-link
    produces a typed link element AND a V8 warning)

Fuzz-driven fixes that landed here:

- V2 (unclosed inline code) now skips any line containing a backtick
  run of length 3+ — those are fenced-code-block boundaries, not
  inline-code openers. Fixes a regression on inputs like
  "> ```go" where the line is inside a blockquote so the fence
  walker can't classify the fence directly.

- V1 (unclosed emphasis) now checks CommonMark left-flanking: an
  asterisk preceded by a word character (e.g. intra-word "10*x" in
  a header) is NOT an emphasis opener and must not get a closer
  appended. Fixes TestHandleHeading_BoldFallback_EscapesEmphasisChars
  which was inadvertently rewriting "Pricing: 10*x + 5_y" into a
  malformed italic span.

Validation:
- go test ./... all green.
- make lint clean.
- go test -race ./... all green.
- Converter coverage: 89.4% (the new option/advisory paths are fully
  exercised; the slight dip vs the prior 90%+ is the additional
  surface area that the new walk helpers introduce).
Three production-grade additions that close the loop on best-effort
Slack rendering:

- internal/converter/text_fallback.go (new): DeriveTextFallback walks
  a converted []slack.Block slice and returns a short plain-text
  summary suitable for chat.postMessage(text=). Slack uses that
  string verbatim on push notifications, search results, screen
  reader output, and the email digest — the surfaces where the
  markdown block's own rendering degrades. The summary strips Slack
  mrkdwn formatting (*bold*, _italic_, ~strike~, `code`, <URL|label>,
  :emoji:), CommonMark links ([label](url)), ATX header hashes, and
  leading blockquote prefixes, then collapses whitespace and clamps
  to TextFallbackMaxChars (150) runes with a U+2026 suffix when
  clipped. Header blocks dominate the candidate order (notification
  previews lead with the title).

- internal/converter/tables.go: data rows shorter than the header are
  now padded with empty rich_text cells. Slack's chat.postMessage
  rejects table blocks with mismatched column counts, and LLM-emitted
  tables routinely under-fill the trailing cell — this is the
  layer-B half of the catalog's C7 repair. The existing column-
  truncation path is untouched.

- block_kit/block_kit.go: re-exports the three new public symbols
  (MarkdownBlockFallbackSurfacesWarning, TextFallbackMaxChars,
  DeriveTextFallback). The Options.PreferRichText and
  Options.DecodeHTMLEntities fields ride the existing type alias
  for converter.Options automatically.

Tests:

- internal/converter/text_fallback_test.go: 10-row table-driven
  TableCases covering header dominance, mrkdwn stripping, link
  stripping (both Slack and CommonMark forms), image placeholders,
  whitespace collapse, divider yielding empty, and edge cases.
  Truncation test asserts a 200-char input clips to exactly 150
  runes with the ellipsis suffix. RichTextWithLink test confirms
  the rich_text walker extracts link labels while dropping URLs.

- internal/converter/table_pad_test.go: end-to-end test feeding a
  3-column header with short data rows; asserts every emitted row
  has exactly 3 cells.

- block_kit/block_kit_test.go: three additions cover the new
  public surface (DeriveTextFallback through the facade, the
  warning constant string-shape, and the cap constant value
  pinned at 150 for semver stability).

Validation:
- go test ./... all green
- go test -race ./... all green
- make lint clean
- Converter coverage 87.5%, block_kit coverage 85.7% (both above
  the >=80% gate)
… format_for_slack prompt

Wires the v0.4-track best-effort surface into every user touchpoint
and lands all docs + CHANGELOG.

MCP convert tool (internal/server/convert_tool.go):

- ConvertInput gains `prefer_rich_text` and `decode_html_entities`
  bool fields, threaded through convertInputToOptions into the
  corresponding converter.Options.
- ConvertOutput gains `text_fallback string`, populated via
  converter.DeriveTextFallback after conversion. Empty-string is
  omitted via omitempty so payloads stay clean.
- The Warnings field description is updated to reflect that it
  now also carries normalization repair codes and the
  fallback-surface advisory.

CLI (cmd/mcp-slack-block-kit/convert.go):

- New flags: --prefer-rich-text and --decode-html-entities.
- Convert now uses ConvertWithWarnings so warnings flow through
  to JSON output; the JSON payload also includes text_fallback for
  shell pipelines (e.g. jq).

MCP prompt (internal/server/prompts.go):

- format_for_slack instructions rewritten into a stable
  formatForSlackInstructions constant. The new body explicitly
  tells the LLM caller to:
    1. pass `blocks` as chat.postMessage(blocks=)
    2. pass `text_fallback` as chat.postMessage(text=)
    3. surface response.warnings to the user
    4. set prefer_rich_text=true for accessibility-sensitive channels
  The mention-sanitization guidance from the previous body is
  preserved verbatim. The mentioned deprecation (default flip in
  the next major release) is called out so callers can adapt early.

Docs:

- New docs/llm-input-recovery.md: public catalog of every repair
  code (V*/C*/R*) with description, default state, evidence link
  list, and the "deliberately not repaired" rationale. Codes are
  documented as semver-stable.
- internal/server/cheatsheet.md (the block-kit-cheatsheet MCP
  resource) gains "Best-effort posting recipe" and
  "Troubleshooting: literal `##`/`**`/`[label](url)` appear in
  Slack" sections.
- README.md gains a footnote on the modes table about the
  auto-mode fallback-surface caveat, a new "LLM input repairs"
  section enumerating the normalizer patterns, and a top-level
  "Troubleshooting" section walking through the three diagnostic
  causes.

CHANGELOG: every behavior change in the v0.4 track documented,
organized under Added / Changed / Deprecated / Docs. The default
flip of PreferRichText (planned for the next major) is called out
in Deprecated with migration guidance.

Tests:

- TestConvertTool_PreferRichText_RoutesToRichText
- TestConvertTool_TextFallback_PopulatedInResponse
- TestConvertTool_AutoMarkdownBlock_SurfaceWarningInResponse
- TestConvertTool_Normalization_RepairsSplitLink (end-to-end:
  malformed split-link in input → typed link element in output AND
  V8 warning code surfaced)
- TestPrompts_FormatForSlack_ListedAndRenders extended to assert
  the body contains blocks:, text:, text_fallback, and
  prefer_rich_text.

End-to-end verification with the actual screenshot input:

  $ printf '%s\n' '- [Project Folder]' '(https://drive.google.com/drive/folders/abc)' \
      | ./bin/mcp-slack-block-kit convert --mode auto --prefer-rich-text --pretty

  Output (abridged): a rich_text_list with a typed
  rich_text_section_link element carrying the full URL,
  text_fallback="Project Folder", and warnings naming V8. The
  failure mode in the user's production Slack screenshot is now
  fully handled by the library out of the box.

Validation:
- go test ./... all green
- go test -race ./... all green
- make lint clean
- make vuln: no vulnerabilities
- Coverage stays above the 80% gate across every package
…ds + V11/V6 wiring)

Addresses all six ultrareview findings against PR #9.

### bug_009 (nit) — emptyTableCell wire shape
internal/converter/tables.go: emptyTableCell() now constructs the
inner RichTextSection with a single zero-length text element so its
JSON shape is `[{"type":"text","text":""}]` instead of `"null"`.
Mirrors renderRowCells' established empty-cell fallback. Dead code
today (goldmark pre-pads short rows) but future-proof against any
wiring change. Pinned by TestEmptyTableCell_WireShapeNeverNull.

### merged_bug_005 — C3/C4 peer guards too permissive
internal/converter/normalizer/bullet_no_space.go: applyBulletNoSpace
skips when the marker character appears more than once on the line
(emphasis like `**bold**` or `*italic*` between two bullets is no
longer mis-rewritten into `* *bold**`). `-` exempt because hyphens
are common in prose; digit/whitespace regex guards still keep `-`
safe for genuine lists.

internal/converter/normalizer/numbered_no_space.go:
- regex now requires `[^\s\d]` after the marker (was `[^\s]`),
  matching the bullet repair's character class. Without this the
  fuzz found `0.A\n0.0` non-idempotence: the `0.0` line looked
  like a malformed list item.
- hasAdjacentNumberedPeer's catch-all `return true` replaced with a
  digit-class guard so `1.5 GB free\n2.3 GB used` no longer
  mutually validates as a numbered list.

New false-positive test cases pin the regressions.

### merged_bug_001 — C5 strips inside code, only preserved exactly-2-space hard breaks
internal/converter/normalizer/trailing_whitespace.go:
applyTrailingWhitespace now skips LineFenceOpen/Content/Close and
LineIndentedCode (matches every sibling repair; honors the
package's documented "code-preserving" invariant and CommonMark
§4.5). Hard-break preservation is now len-based and context-aware:
preserves 2+ trailing spaces only on prose lines followed by more
prose (real CommonMark §6.7 context), strips on list items / table
rows / end-of-paragraph (overwhelmingly LLM sloppiness, matches
original C5/C9 intent against marked PR #2201).

New tests pin both fixes: InsideFenceUnchanged,
InsideIndentedCodeUnchanged, and HardBreakPreserved expanded to 3-
and 4-space cases.

### bug_010 — V4 idempotence violation
internal/converter/normalizer/fence_state.go: classify() now treats
a fenceOpen whose info string already contains a matching closing
run as LineProse rather than LineFenceOpen. CommonMark §4.5 forbids
the fence char inside the info string, so this is spec-alignment.
Without the change, a one-liner ` ```go body``` ` followed by an
unclosed-emphasis paragraph violated idempotence: pass 1 tagged the
tail as LineFenceContent (V1 skipped), pass 2 re-classified post-
split and V1 fired.

New regression test TestNormalize_V4ThenV1_IsIdempotent and added
fuzz seed.

### merged_bug_014 — R8/V7 corrupt content inside inline code spans
internal/converter/normalizer/fence_state.go: new inlineCodeMask
helper computes a per-byte mask of positions falling inside
CommonMark §6.1 inline code spans, using the same backtick pair-
matching algorithm V2 uses.

internal/converter/normalizer/br_tag.go: applyBRTag skips `<br>`
matches whose start position is masked; surviving matches use new
replaceRangesWith / splitOnRanges helpers so positions stay stable.
Example that previously corrupted: `Use \`<br>\` for HTML breaks`
turned into `Use \`\\n\` for HTML breaks\`` (V2 cascaded). Now
unchanged.

internal/converter/normalizer/url_unicode.go: applyURLUnicode
replaces ReplaceAllStringFunc with rewriteOutsideMask which honors
the inline-code mask. Example that previously corrupted:
`` `array[1](https://x.com/v2—doc)` `` had its em-dash silently
rewritten to a hyphen. Now unchanged.

New TestApplyBRTag_InsideInlineCodeUnchanged and
TestApplyURLUnicode_InsideInlineCodeUnchanged pin both.

### merged_bug_002 — V11 unimplemented + V6 unreachable
internal/converter/normalizer/html_entities.go: new file implements
V11 entity decode. Decodes the five whitelisted XML named entities
(&amp; &lt; &gt; &quot; &apos;) plus decimal and hex numeric
character references. Skips code contexts (fenced, indented, inline).
Wired into the pipeline at the top (before all other repairs) so
subsequent repairs see decoded characters.

Broadcast-safety contract: V11 honestly decodes `&lt;!channel&gt;`
to `<!channel>` here; the converter's sanitizeBroadcasts pass
re-escapes it before rich_text emission, so the broadcast token
cannot survive the round-trip to Slack unless the caller has also
set AllowBroadcasts. Documented in the file header.

internal/converter/options.go: adds RepairMismatchedEmphasis bool
field with godoc and default in DefaultOptions().

internal/converter/renderer.go: renderer passes
r.opts.RepairMismatchedEmphasis into the normalizer (was hardcoded
to false, masking the existing V6 implementation).

internal/server/convert_tool.go: ConvertInput gains
repair_mismatched_emphasis JSON field, threaded through
convertInputToOptions.

cmd/mcp-slack-block-kit/convert.go: adds --repair-mismatched-emphasis
CLI flag, threaded through.

internal/converter/normalizer/normalizer_property_test.go: new
TestProperty_V11_EntityDecodeIsLive sentinel pins that V11 actually
decoded — guards against TestProperty_NoBroadcastSmuggling passing
vacuously (it would always pass green if V11 ran as a no-op).

### Validation
- go test ./... all green
- go test -race ./... all green
- make lint clean
- make vuln: no vulnerabilities
- 90s FuzzNormalize with extra regression seeds: 194k execs, no failures
- Per-package coverage all above the 80% gate (normalizer 93.8%,
  converter 87.6%, server 86.8%, block_kit 85.7%, cli 83.8%)

End-to-end smoke with V11 + V6 both enabled:
```
$ printf '%s\n' '**italic*' 'Tom &amp; Jerry' \
  | ./bin/mcp-slack-block-kit convert --mode rich_text \
      --decode-html-entities --repair-mismatched-emphasis --pretty
```
Output: V6 collapses `**italic*` to italic span; V11 decodes
`Tom &amp; Jerry` and the result re-escapes through sanitization;
warnings reports `V11, V6` fired.
@hishamkaram

Copy link
Copy Markdown
Owner Author

Ultrareview triage — all 6 findings addressed in 4db8202

Finding Severity Status Fix
bug_009emptyTableCell emits "elements":null nit ✅ Fixed Mirror renderRowCells' single zero-length text element + new TestEmptyTableCell_WireShapeNeverNull marshal assertion.
merged_bug_005 — C3/C4 peer guards corrupt prose normal ✅ Fixed C3 skips when marker character repeats on line (emphasis like **bold** between bullets). C4 regex + peer-check require [^\s\d] after marker (decimal pairs like 1.5 GB\n2.3 GB no longer collapse).
merged_bug_001 — C5 strips inside code blocks; only-exactly-2-space hard break normal ✅ Fixed C5 adds the standard LineFenceOpen/Content/Close/IndentedCode Kind switch every sibling repair has. Hard-break guard is now context-aware: preserves 2+ trailing spaces only on prose lines followed by more prose (genuine §6.7 context), strips on list items / table delimiters / end-of-paragraph (LLM sloppiness, matches original C5/C9 intent against marked PR #2201).
bug_010 — V4 idempotence violation normal ✅ Fixed classify() now treats a fence opener whose info string contains a matching closer as LineProse (CommonMark §4.5 spec-alignment — info strings cannot contain the fence character). Kills the stale-tag cascade. New regression test + fuzz seed pin it.
merged_bug_014 — R8/V7 corrupt inline code spans normal ✅ Fixed New inlineCodeMask helper computes per-byte mask of CommonMark §6.1 code-span positions, using the same backtick pair algorithm V2 uses. R8 and V7 skip matches whose start position is masked. New TestApplyBRTag_InsideInlineCodeUnchanged + TestApplyURLUnicode_InsideInlineCodeUnchanged pin both.
merged_bug_002 — V11 unimplemented, V6 unreachable normal ✅ Fixed V11 (internal/converter/normalizer/html_entities.go, new) decodes the five whitelisted XML entities + numeric refs; results re-escape through sanitizeBroadcasts (broadcast-safety contract documented in the file header). V6 now reachable via new Options.RepairMismatchedEmphasis, repair_mismatched_emphasis MCP field, and --repair-mismatched-emphasis CLI flag. New TestProperty_V11_EntityDecodeIsLive sentinel guards TestProperty_NoBroadcastSmuggling against passing vacuously.

Bonus fuzz-driven finds during fixup

The expanded FuzzNormalize corpus (seeded with one input per ultrareview bug + new opt-in flag coverage) ran 90s × 194k execs and found one additional non-idempotence case: "0.A\n0.0". Same root cause as merged_bug_005's C4 shape — fixed in the same commit by tightening the numberedNoSpace regex from [^\s] to [^\s\d], matching the bullet repair.

Validation snapshot

Check Result
go test ./... all green
go test -race ./... all green
make lint 0 issues
make vuln no vulnerabilities
90s fuzz with regression seeds 194k execs, no failures
Coverage (all packages above 80% gate) normalizer 93.8%, validator 95.9%, converter 87.6%, server 86.8%, reverse 87.5%, block_kit 85.7%, splitter 85.1%, cli 83.8%, preview 80.0%
Pre-push hooks (build-smoke, govulncheck, test-race, fuzz-smoke) all green

End-to-end smoke with V11 + V6 newly reachable

$ printf '%s\n' '**italic*' 'Tom &amp; Jerry' \
  | ./bin/mcp-slack-block-kit convert --mode rich_text \
      --decode-html-entities --repair-mismatched-emphasis --pretty

→ V6 collapses **italic* to a typed italic span; V11 decodes Tom &amp; Jerry and the result re-escapes through broadcast sanitization (so the rendered Slack text is Tom &amp; Jerry, broadcast-safe); warnings reports V11, V6 fired. Both opt-in repairs are now genuinely shipped, matching what CHANGELOG/README/docs already advertised.

…en regressions

Addresses both findings from the second ultrareview pass against
PR #9, plus two additional idempotence regressions the regression-
seeded fuzz uncovered.

### bug_002 — V11 decoded C0 controls / surrogates / NUL
internal/converter/normalizer/html_entities.go: new safeDecodedRune
guard rejects numeric entities that would decode to C0 controls
(including TAB and LF), DEL (0x7F), or UTF-16 surrogate halves
(0xD800–0xDFFF). Mirrors HTML5's character-reference algorithm.

Without the guard, `&#10;` decoded to a literal LF mid-Line.Text,
which (a) violates the package's per-line invariant, (b) breaks
idempotence because pass 2's classify() splits on the LF, and
(c) lets LLM input smuggle paragraph/blockquote structure that
wasn't in the rendered source. NUL (`&#0;`) is worse — Slack
rejects messages containing NUL outright.

Pinned by new TestApplyHTMLEntities_ControlCharsRejected (10 cases:
NUL/LF/CR/TAB decimal+hex, DEL, BEL, both surrogate halves) and
TestApplyHTMLEntities_StructuralSmugglingBlocked.

### bug_001 — R8 whitespace-only segments tagged LineProse
internal/converter/normalizer/br_tag.go: changes the post-split
kind check from `seg == ""` to `strings.TrimSpace(seg) == ""`,
matching classify()'s exact rule. A whitespace-only segment between
two <br> tags now correctly becomes LineBlank.

Without the fix, C5 (applyTrailingWhitespace) stripped a
whitespace-only LineProse to empty, leaving the state
{Text:"", Kind:LineProse} — a state classify() never produces.
Pass 2 re-classified the empty line as LineBlank, flipping V1's
paragraph boundaries (one paragraph became two) and the unclosed-
emphasis appender fired on different lines than pass 1.

Pinned by new TestApplyBRTag_WhitespaceOnlySegmentIsBlank and
TestNormalize_BRWhitespaceMiddle_IsIdempotent regression tests.

### Bonus fuzz-driven regressions (same triage pass)

**C5 vs C3/C4 ordering.** The fuzz seed `*A  <br>*A` revealed that
C5 ran BEFORE C3/C4 in the pipeline. C5's isHardBreakContext
consults startsWithListMarker; on pass 1 the line `*A  ` is not
yet a list item so C5 preserves the trailing hard-break, then C3
inserts a space producing `* A  `. On pass 2 the line is already
a list item, isHardBreakContext returns false, and C5 strips —
breaking idempotence.

internal/converter/normalizer/normalizer.go: moved
applyTrailingWhitespace (C5) to run AFTER V5/C3/C4 so the
list-marker context is stable. None of the line-local repairs
need trailing-whitespace cleanup (their regexes anchor at line
start).

**R8 promoted ~~~ tail to fence opener.** The fuzz seed `<Br>~~~`
revealed that R8 emitted the post-split `~~~` segment as
LineProse, but classify() on the same string would have tagged
it LineFenceOpen. V3 (unclosed-fence repair) missed the opener
on pass 1; pass 2 re-classified and V3 fired, appending a closer.

internal/converter/normalizer/normalizer.go: the orchestrator now
re-runs classify() on R8's output (`classify(reassemble(l))`) so
post-split fence boundaries are recognized immediately. Same
shape and same fix as bug_010's V4-classify recipe.

**V8 false positive on bracket-less input.** The fuzz seed
`]\n(]\n(#)` had no `[` anywhere but V8 still merged the trailing
`]` with the following `(#)`. Pass 2 re-merged the already-merged
result, producing different output.

internal/converter/normalizer/split_link.go: new
hasPrecedingOpenBracket guard rejects merge candidates whose `]`-
ending line contains no preceding `[`. Without a preceding `[`,
the `]` can't possibly close a link label.

### Validation

- go test ./... all green
- go test -race ./... all green
- make lint clean
- make vuln: no vulnerabilities
- 3-minute FuzzNormalize (1.1M execs) on the refreshed pipeline:
  zero idempotence failures
- Per-package coverage: normalizer 93.5%, validator 95.9%,
  converter 87.6%, server 87.2%, reverse 87.5%, block_kit 85.7%,
  splitter 85.1%, cli 83.8%, preview 80.0% — all above the 80% gate

### Updated fuzz seeds

FuzzNormalize now seeds: bug_002 cases (`&#10;`, `&#x0A;`, `&#0;`,
`paragraph&#10;> quote`), bug_001 cases (`*hello<br>   <br>**unclosed`,
`a<br>\t<br>b`), the fuzz-discovered shapes (`*A  <br>*A`,
`<Br>~~~`, `]\n(]\n(#)`).
@hishamkaram

Copy link
Copy Markdown
Owner Author

Second-pass ultrareview triage — both findings + 3 fuzz-driven bonus fixes in b82e4d2

Finding Severity Status Fix
bug_002 — V11 decoded C0 controls / surrogates into raw bytes normal ✅ Fixed New safeDecodedRune guard rejects C0 controls (incl. TAB/LF), DEL (0x7F), and UTF-16 surrogate halves. Mirrors HTML5 character-reference algorithm. Pinned by 10-case TestApplyHTMLEntities_ControlCharsRejected + structural-smuggling sentinel.
bug_001 — R8 whitespace-only segments tagged LineProse normal ✅ Fixed strings.TrimSpace(seg) == "" matches classify()'s exact LineBlank rule. New regression tests TestApplyBRTag_WhitespaceOnlySegmentIsBlank + TestNormalize_BRWhitespaceMiddle_IsIdempotent.

Bonus fuzz-driven regressions found in the same triage pass

The regression-seeded FuzzNormalize (1.1M execs) surfaced three additional idempotence violations introduced/exposed by the first fixup. All fixed in the same commit:

Shape Root cause Fix
*A <br>*A C5 ran BEFORE C3/C4; C3 inserted a space mid-pipeline, changing startsWithListMarker's answer between passes Moved applyTrailingWhitespace to run AFTER applyBulletNoSpace/applyNumberedNoSpace so the list-marker context is stable
<Br>~~~ R8 split tagged the post-split ~~~ tail as LineProse, but classify() would have tagged it LineFenceOpen — V3 missed it pass 1, fired pass 2 Re-run classify(reassemble(l)) after R8 fires (same recipe as bug_010's V4 fix)
]\n(]\n(#) V8 merged a trailing ] with (#) even though no [ opener existed; pass 2 re-merged the result New hasPrecedingOpenBracket guard requires the line ending in ] to contain a preceding [

Validation snapshot

Check Result
go test ./... all green
go test -race ./... all green
make lint 0 issues
make vuln no vulnerabilities
3-min FuzzNormalize with refreshed regression seeds 1.1M execs, zero idempotence failures
Per-package coverage (all above 80% gate) normalizer 93.5%, validator 95.9%, converter 87.6%, server 87.2%, reverse 87.5%, block_kit 85.7%, splitter 85.1%, cli 83.8%, preview 80.0%
Pre-push hooks (build-smoke, govulncheck, test-race, fuzz-smoke) all green

Updated fuzz corpus

FuzzNormalize now seeds: bug_002 (&#10;, &#x0A;, &#0;, paragraph&#10;> quote), bug_001 (*hello<br> <br>**unclosed, a<br>\t<br>b), and the in-triage discoveries (*A <br>*A, <Br>~~~, ]\n(]\n(#)). The expanded corpus exercises every pattern that has historically broken idempotence on this branch.

@hishamkaram
hishamkaram merged commit c0d3d2c into main May 20, 2026
7 checks passed
@hishamkaram
hishamkaram deleted the feat/best-effort-conversion branch May 20, 2026 08:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant