MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-2043

Bare-key normalization is Markdown-structure-blind: it rewrites work-item keys inside inline code spans, fenced code blocks and link destinations — and PERSISTS the corruption into the stored body

Done
Description

Type: Bug (code) · write-side content corruption. Follow-on to the bare-key normalization shipped for bug MOTIR-1440 (normalizeWorkItemRefs / normalizeBodyRefs).

Parent: MOTIR-1464 — the open "Planner self-improvement · auto-reported quality bugs" epic, the de-facto home for agent-discovered quality bugs (Yue's call, 2026-08-03; filed at root first). NOT parented by where the defective code lives: that would be Story 5.8 (MOTIR-1399) under Epic 5 (MOTIR-263) — done — so per the bug-parent rule the bug is not re-attached into a sealed epic. Nothing open is blocked by it.

Discovered in: out-of-band manual dogfooding on 2026-08-03, while filing MOTIR-2039 through the MCP create_work_item / update_work_item path.

Symptom (observed, reproduced twice)

I deliberately wrapped a work-item key in backticks — `[MOTIR-2039](motir:cmsdw94cf000i04kvb0ncz5hs)` — precisely so it would stay literal text. What you are reading in that sentence IS the bug: the backticks were around a bare key, and the stored body came back with a motir: link injected inside the code span. See the comment on this card. The API response's stored descriptionMd looked like:

`[MOTIR-<n>](motir:cmsboa8hm004004la11a8m7v6)`

A code span renders its content verbatim, so the reader sees the raw [KEY](motir:cms…) token — a visible corruption, not a chip.

The same rewrite hit a key sitting in a link destination: the plain path /items/MOTIR-<n> became /items/[MOTIR-<n>](motir:cms…) — a nested-bracket mess that is not a valid link and does not render as one.

The critical part: this happens at WRITE time and is persisted. It is not a render-layer decoration that could be ignored — the author's source text is mutated in the DB, and the editor round-trip now shows the token. The original literal is gone.

Root cause (VERIFIED against origin/main @ c4ec51b1)

normalizeWorkItemRefs (lib/mentions/workItemRefs.ts, the normalizeWorkItemRefs export, ~line 110-130) does ONE String.replace over the raw body with:

const tokenSrc = '\\[[^\\]]*\\]\\(motir:[A-Za-z0-9_-]+\\)';
const keySrc   = `\\b${escapeRe(projectIdentifier)}-(\\d+)\\b`;
const re = new RegExp(`(${tokenSrc})|${keySrc}`, 'gi');

The alternation has exactly two branches — an already-explicit motir: token (left verbatim, which is what makes it idempotent) and a bare key (rewritten). There is no branch for Markdown structure, so the scan is blind to:

  • inline code spans — `KEY`
  • fenced code blocks — `````` — and indented code blocks
  • a Markdown link destination[text](/items/KEY) — and bare URLs / autolinks
  • an image destination, and (arguably) a <pre>/HTML block

The module's own doc comment claims the rewrite is "Idempotent + non-destructive", and its skip-list names only three cases: an existing token, an unresolved key, and a foreign project prefix. Code context was never in the list. tests/work-items/work-item-refs.test.ts:74-… (describe('normalizeWorkItemRefs (bug [MOTIR-1440](motir:cmqwzpoh9000004k0k7tqkxeg))')) covers exactly those three — there is no code-span, code-fence or link-destination case, which is why the gap shipped.

Where it bites (blast radius) — normalizeBodyRefs (lib/workItems/normalizeBodyRefs.ts) runs inside the caller's write transaction at four sites:

  • lib/services/workItemsService.ts:781 — work-item create (descriptionMd + explanationMd)
  • lib/services/workItemsService.ts:1193 — work-item update (same two fields)
  • lib/services/plansService.ts:562 and :796plan materialize (descriptionMd / explanationMd)

So AI-generated plan bodies are corrupted the same way: any planner output that quotes a card key inside a fenced block or a path gets a link spliced into it. Comments are unaffected — commentsService does not call it.

This is an implementation gap in a shipped fix, not a design defect — the intent ("a bare key should both relate AND chip") is right; the executor is context-blind. No notes.html entry warranted.

Reproduce

  1. Create or edit a work item whose description contains a resolvable key of the same project inside an inline code span, and again inside a fenced code block, and again as a path segment such as /items/<key>.
  2. Save, then read the item back (get_work_item, or reopen the editor).
  3. Expected: all three stay literal — a key inside code is documentation, a key inside a link destination is a path. Only a bare key in prose becomes a chip token.
  4. Actual: all three are rewritten to [KEY](motir:<id>) in the stored body; the code span and code block then render the raw token text, and the path becomes a malformed nested link.

Fix direction (for the close-out subtask)

  • Make the rewrite Markdown-aware instead of adding more regex branches — the durable fix is to walk the mdast (remark-parse + remark-gfm are already dependencies, see lib/markdown/render.tsx) and rewrite only inside text nodes, which by construction excludes inlineCode, code, link.url, image.url, html and definition. If a full AST round-trip is judged too invasive for a write-path helper, the minimum viable alternative is to extend the alternation with skip-branches matched before the key branch — fenced/indented code, inline code (n-backtick-delimited, honouring the run length), a link/image destination, and an autolink — mirroring how the existing token branch already earns its "left verbatim".
  • Keep the current three skip rules intact and keep idempotency: re-normalising a stored body must remain a no-op.
  • Tests: add the missing cases to tests/work-items/work-item-refs.test.ts — key in an inline code span (including a double-backtick span), key in a fenced block, key in a link destination, key in a bare URL, and a mixed body where a prose key in the SAME string still normalizes. Write the failing repro first.
  • Consider (call it out in the close-out, do not silently scope it in): existing rows already carry the corruption — including this card's own description. A one-off repair is only safe if it can distinguish an injected token inside code from an intentional one — worth a note in the card rather than a blind migration.

Resolution: (open — the close-out subtask fills this)