diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts
index cdf644a..7d137c9 100644
--- a/src/markdown/portable/adf-to-portable-markdown.test.ts
+++ b/src/markdown/portable/adf-to-portable-markdown.test.ts
@@ -491,3 +491,31 @@ test('writes a text node holding content the same in either order beside its nei
assert.equal(portable(paragraph(holding, text('b'))), 'ab\n')
assert.equal(portable(paragraph(text('b'), holding)), 'ba\n')
})
+
+test('writes a task list nested to the deepest level the reader carries, and refuses one more at the item holding it', () => {
+ const task = (): AdfNode => node('taskItem', { state: 'TODO' }, text('a'))
+ const nested = (levels: number): AdfNode => {
+ let list: AdfNode = { content: [task()], type: 'taskList' }
+ for (let level = 1; level < levels; level += 1) list = { content: [task(), list], type: 'taskList' }
+ return list
+ }
+ const written = portable(nested(largestNesting))
+ assert.ok(portableMarkdownToAdf(written).ok)
+ assert.equal(written.split('\n').length, largestNesting + 1)
+ const refused = adfToPortableMarkdown(document(nested(largestNesting + 1)))
+ assert.deepEqual(refused.ok ? undefined : [refused.error.code, refused.error.path], ['unsupported-nesting-depth', ['content', 0, ...Array.from({ length: largestNesting }, () => ['content', 1]).flat(), 'content', 0]])
+ const quoted = (levels: number): string => portable(node('panel', { panelType: 'info' }, nested(levels)))
+ assert.ok(portableMarkdownToAdf(quoted(largestNesting - 1)).ok)
+ assert.match(quoted(largestNesting), /^unsupported-nesting-depth at \//)
+})
+
+test('writes a task list nested in the task before it, and more than one beside each other in that task', () => {
+ const done = (value: string): AdfNode => node('taskItem', { state: 'DONE' }, text(value))
+ const list = (...content: AdfNode[]): AdfNode => ({ content, type: 'taskList' })
+ assert.equal(portable(list(done('Parent'), list(done('Child')), done('Next'))), '- [x] Parent\n - [x] Child\n- [x] Next\n')
+ assert.equal(portable(list(done('Parent'), list(done('A')), list(done('B')))), '- [x] Parent\n - [x] A\n * [x] B\n')
+})
+
+test('writes a task list opening with a list as that list in an item of its own', () => {
+ assert.equal(portable({ content: [{ content: [node('taskItem', { state: 'TODO' }, text('a'))], type: 'taskList' }], type: 'taskList' }), '- - [ ] a\n')
+})
diff --git a/src/markdown/portable/portable-markdown-to-adf.test.ts b/src/markdown/portable/portable-markdown-to-adf.test.ts
index 4d79614..901e830 100644
--- a/src/markdown/portable/portable-markdown-to-adf.test.ts
+++ b/src/markdown/portable/portable-markdown-to-adf.test.ts
@@ -218,6 +218,71 @@ test('refuses a task list nested one level past the 500 the parser carries, at i
assert.equal(quoted.ok ? undefined : quoted.error.code, 'unsupported-nesting-depth')
})
+test('keeps a backslash a title line ends in as text, a break only before a following line', () => {
+ assert.deepEqual(read('> [!NOTE]- Trail\\\n'), [node('expand', { title: 'Trail\\' }, paragraph())])
+ assert.deepEqual(read('> [!NOTE]- a \\* b\n> body\n'), [node('expand', { title: 'a * b' }, said('body'))])
+ assert.deepEqual(read('> [!NOTE]- x\\\\\n> body\n'), [node('expand', { title: 'x\\' }, said('body'))])
+})
+
+test('keeps the target of a link in a title whichever marks sit beside the link', () => {
+ const titled = (markdown: string): unknown => {
+ const blocks = read(markdown)
+ return typeof blocks === 'string' ? blocks : blocks[0]?.attrs?.['title']
+ }
+ assert.equal(titled('> [!faq]- ~~[x](http://y)~~ b\n'), 'x (http://y) b')
+ assert.equal(titled('> [!faq]- ==[x](http://y)== b\n'), 'x (http://y) b')
+ assert.equal(titled('> [!faq]- *[x](http://y)* and [**z**](http://w)\n'), 'x (http://y) and z (http://w)')
+ assert.equal(titled('> [!faq]- **[x](http://y)** and [*z*](http://w)\n'), 'x (http://y) and z (http://w)')
+ assert.equal(titled('> [!faq]- [x](http://y)~~z~~\n'), 'x (http://y)z')
+ assert.equal(titled('> [!faq]- \n'), 'a (http://u)')
+})
+
+test('refuses what a callout title or body holds that no node spells, at the path of the paragraph or the callout', () => {
+ const refused = (markdown: string): string => {
+ const parsed = portableMarkdownToAdf(markdown)
+ return parsed.ok ? 'read' : `${parsed.error.code} ${parsed.error.path.map((step) => '/' + step).join('')}`
+ }
+ assert.equal(refused('> [!faq]- a x\n'), 'unmappable-html /content/0')
+ assert.equal(refused('> [!NOTE]\n>\n> a x\n'), 'unmappable-html /content/0/content/0')
+ assert.equal(refused('> [!NOTE] first\n>\n> a x\n'), 'unmappable-html /content/0/content/1')
+ assert.equal(refused('> [!NOTE]-\n>\n> a x\n'), 'unmappable-html /content/0/content/0')
+ assert.equal(refused('> [!NOTE]- t\n>\n> > [!NOTE]-\n> >\n> > a x\n'), 'unmappable-html /content/0/content/0/content/0')
+ assert.equal(refused('p\n\n> [!NOTE]\n>\n> a x\n'), 'unmappable-html /content/1/content/0')
+})
+
+test('refuses a callout nested one level past the 500 the parser carries, a panel and an expand alike', () => {
+ const nested = (marker: string, levels: number): string => Array.from({ length: levels }, (_, level) => `${'> '.repeat(level + 1)}${marker}\n${'> '.repeat(level + 1)}\n`).join('')
+ for (const marker of ['[!NOTE]', '[!NOTE]-', '[!NOTE]- t']) {
+ assert.ok(portableMarkdownToAdf(nested(marker, largestNesting)).ok, marker)
+ const refused = portableMarkdownToAdf(nested(marker, largestNesting + 1))
+ assert.equal(refused.ok ? undefined : refused.error.code, 'unsupported-nesting-depth', marker)
+ }
+})
+
+test('places a refusal in a bullet list by the list and the item holding it', () => {
+ const refused = (markdown: string): string => {
+ const parsed = portableMarkdownToAdf(markdown)
+ return parsed.ok ? 'read' : parsed.error.path.map((step) => '/' + step).join('')
+ }
+ assert.equal(refused('p\n\n- a\n\n- b x\n'), '/content/1/content/1/content/0')
+ assert.equal(refused('- [x] a\n- b x\n'), '/content/0/content/1/content/0')
+ assert.equal(refused('q\n\n- [x] a\n- [ ] b x\n'), '/content/1/content/1')
+})
+
+test('reads a task whose marker line holds only breaks and spaces by the lines after it', () => {
+ assert.deepEqual(read('- [ ] a\n b\n'), [bare('taskList', task('TODO', text('a b')))])
+ assert.deepEqual(read('- [ ] \\\n \\\n x\n'), [bare('taskList', task('TODO', text('x')))])
+ assert.deepEqual(read('- [x]\n\n Second.\n- [ ] Next\n'), [bare('taskList', task('DONE', text('Second.')), task('TODO', text('Next')))])
+ assert.deepEqual(read('- [x]\n\n First.\n\n Second.\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')))])
+ assert.deepEqual(read('- [x]\n- [ ]\n'), [bare('taskList', task('DONE'), task('TODO'))])
+})
+
+test('reads a lone image after a task marker as its alt text linked', () => {
+ const link: AdfMark = { attrs: { href: 'http://u' }, type: 'link' }
+ assert.deepEqual(read('- [ ] \n'), [bare('taskList', task('TODO', text('alt', link)))])
+ assert.deepEqual(read('- [ ]  b\n'), [bare('taskList', task('TODO', text('alt', link), text(' b')))])
+})
+
test('reads a task list beside a list of another bullet character as two lists', () => {
const tasks = (state: string, value: string): AdfNode => bare('taskList', node('taskItem', { state }, text(value)))
assert.deepEqual(read('- [x] a\n* b\n'), [tasks('DONE', 'a'), bare('bulletList', bare('listItem', bare('paragraph', text('b'))))])
diff --git a/todo.md b/todo.md
index 5e6a330..3dcda69 100644
--- a/todo.md
+++ b/todo.md
@@ -6,18 +6,18 @@
`Bar = 0.1`
-`Next ID = 175`
+`Next ID = 176`
## Items
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 |
-| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 |
| 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 |
| 154 | 0.4.0 | | | **Pair highlights over the inline reader's pieces by their offsets before building nodes, so no placeholder text node stands in for a delimiter.** | 2 | 1 | 2 | 3 | 4 | code health by 4 | 17.4 |
| 155 | 0.4.0 | | | **Have `closeBracket` return the link, image or literal bracket it builds, and apply it to the scan in one caller.** | 2 | 1 | 2 | 3 | 4 | code health by 4 | 17.4 |
| 162 | 0.4.0 | | | **Pin each rule of the emphasis sweep with a hand-worked line, so every mutant that lets a pair fall where the one-run-per-pass reference keeps it fails a test.** | 2 | 1 | 1 | 2 | 4 | code health by 4 | 17.4 |
+| 175 | 0.4.0 | | | **Delete the guards in `tryTaskList` that `reduceToPortable` makes unreachable, once a test shows `adfToPortableMarkdown` reaches none of them.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 |
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
| 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 |
| 159 | 0.4.0 | | | **Delete `taskBlocks`'s branch that writes the task marker as its own paragraph, once a test shows no block task item reaches it.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 10.5 |
@@ -75,14 +75,6 @@ a paragraph opening with a spellable `!adf:link` (§Nothing recurses unbounded).
`src/markdown/**` bans only `html/`; banning `emit/`, `parse/` and `portable/` for the root's own
files, with its `ci.sh` probe, extends the decision.
-### 128. Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.
-
-Mutation testing left about 40 mutants alive in code moved verbatim into `src/markdown/portable/`
-on 2026-10-22, so the gaps predate the move: `tryTaskList` and `taskBlocks` in `src/markdown/portable/block-spelling.ts`, and
-`markerLine`, `quoteReader`, `titleText`, `taskBlocks` and `readTaskBlock` in
-`src/markdown/portable/portable-markdown-to-adf.ts`. Each test takes its expected value from
-`spec/flavour.md` or `docs/decisions.md`, never from the code.
-
### 75. Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.
`blockCarry` and `hostileBlockCarry` (`src/conformance/markdown-property.test.ts`) write a fence
@@ -119,6 +111,15 @@ random seed. Write one line per rule of that entry, its expected markdown worked
never from the code, then run Stryker on the file. A mutant that only takes fewer runs back costs a
pass and may survive.
+### 175. Delete the guards in `tryTaskList` that `reduceToPortable` makes unreachable, once a test shows `adfToPortableMarkdown` reaches none of them.
+
+`reduceToPortable` runs first and turns a task list opening with a non-task into a bullet list. So
+`tryTaskList` (`portable/block-spelling.ts`) never sees `previous === undefined`, and its
+`'a task holds blocks no list item spells'` failure has no test. The `path` it passes to
+`taskBlocks` and `placedBlock`, and its `Math.min` of `headroom`, change no output, since the
+nesting refusal's path comes from `reduceToPortable`. Found by mutation testing for item 128,
+2026-11-08.
+
### 45. Replace `isAdfDocument` with a reader returning `Result`.
Lands with item 77: the reader returns item 77's copy. Goal 1 has every call return a result; the