diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts index 451359e6a..c1ddacf1b 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts @@ -1,4 +1,6 @@ -import { describe, it, expect } from "vitest" +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" +import { describe, it, expect, vi } from "vitest" import { tasks, type ParsedTask, @@ -268,7 +270,9 @@ describe("tasks.extractTasks", () => { ]) expect(() => tasks.charForStatus("todo", registry)).toThrow( - 'no checkbox symbol for status "todo" in the Tasks plugin registry (the default " " is typed non_task)', + new Error( + 'no checkbox symbol for status "todo" in the Tasks plugin registry (the default " " is typed non_task)', + ), ) }) @@ -290,7 +294,9 @@ describe("tasks.extractTasks", () => { ]) expect(() => tasks.charForStatus("cancelled", registry)).toThrow( - 'no checkbox symbol for status "cancelled" in the Tasks plugin registry (the default "-" is typed todo)', + new Error( + 'no checkbox symbol for status "cancelled" in the Tasks plugin registry (the default "-" is typed todo)', + ), ) }) @@ -535,6 +541,18 @@ describe("tasks.extractTasks", () => { expect(extracted).toEqual([task({ description: "T", priority })]) }) + it.each([ + { signifierCount: 21, description: "T" }, + { signifierCount: 22, description: "T ⏫" }, + ])( + "leaves '$description' after stripping $signifierCount trailing priority signifiers", + ({ signifierCount, description }) => { + const extracted = tasks.extractTasks(`- [ ] T${" ⏫".repeat(signifierCount)}`) + + expect(extracted).toEqual([task({ description, priority: "high" })]) + }, + ) + it("leaves priority null when no signifier is present", () => { const extracted = tasks.extractTasks("- [ ] T") expect(extracted).toEqual([task({ description: "T" })]) @@ -580,6 +598,131 @@ describe("tasks.extractTasks", () => { ]) }) + it.each([ + { name: "emoji", field: (value: string) => `⛔ ${value}`, idCount: 60, trailingSpaces: 0 }, + { + name: "Dataview", + field: (value: string) => `[dependsOn:: ${value}]`, + idCount: 60, + trailingSpaces: 0, + }, + { + name: "Dataview with a long whitespace suffix", + field: (value: string) => `[dependsOn:: ${value}]`, + idCount: 2, + // A long run exposes retries between the list and its surrounding field grammar. + trailingSpaces: 60000, + }, + ])( + "rejects an adversarial $name dependency list in under 50 ms", + ({ field, idCount, trailingSpaces }) => { + const ids = Array.from({ length: idCount }, () => "a").join(" , ") + const dependencies = `${ids}${" ".repeat(trailingSpaces)}` + const line = `- [ ] x ${field(`${dependencies}!`)}` + const parserUrl = new URL("../tasks.ts", import.meta.url).href + // The child deadline can interrupt a synchronous regex that blocks the event loop. + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + `import { tasks } from ${JSON.stringify(parserUrl)}; + const started = performance.now(); + const extracted = tasks.extractTasks(${JSON.stringify(line)}); + const elapsed = performance.now() - started; + process.stdout.write(JSON.stringify({ extracted, elapsed }));`, + ], + { encoding: "utf8", timeout: 10000 }, + ) + const result = JSON.parse(output) + + expect(result.extracted).toEqual([ + task({ description: `x ${field(`${dependencies}!`)}`, dependsOn: [] }), + ]) + expect(result.elapsed).toBeLessThan(50) + }, + ) + + it("preserves dependency matches and group 1 across fixed-seed lists and existing fixtures", () => { + /** The former dependency grammar, retained as the compatibility oracle. */ + const oldSequence = /[a-zA-Z0-9_-]+( *, *[a-zA-Z0-9_-]+ *)*/ + const observedPatterns: RegExp[] = [] + const originalExec = RegExp.prototype.exec + // A regular function preserves exec's receiver so the real private patterns are compared. + const execSpy = vi.spyOn(RegExp.prototype, "exec").mockImplementation(function ( + this: RegExp, + text, + ) { + if (this.source.includes("⛔") || this.source.includes("dependsOn::")) { + observedPatterns.push(this) + } + return originalExec.call(this, text) + }) + + try { + tasks.extractTasks("- [ ] x") + } finally { + execSpy.mockRestore() + } + + const dependencyPatterns = observedPatterns.filter((pattern) => + pattern.source.includes("[a-zA-Z0-9_-]+"), + ) + expect(dependencyPatterns.length).toBe(2) + + // State advances once per draw so this corpus is reproducible. + let randomState = 0x5eed1234 + const draw = (limit: number): number => { + randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0 + return randomState % limit + } + const ids = ["a", "B2", "db-setup", "cache_setup", "0", "_-", ""] + const punctuation = ["", "!", ".", ":", ",", "é"] + // At most five IDs keeps failures of the exponential oracle short. + const randomLists = Array.from({ length: 1000 }, () => { + const values = Array.from({ length: 1 + draw(5) }, () => { + return `${" ".repeat(draw(3))}${ids[draw(ids.length)]}${" ".repeat(draw(3))}` + }) + return `${values.join(",")}${punctuation[draw(punctuation.length)]}` + }) + const testSource = readFileSync(new URL("./tasks.test.ts", import.meta.url), "utf8") + /** Quoted strings containing dependency fields include all existing literal fixtures. */ + const dependencyFixture = /["`]([^"`\n]*(?:⛔|dependsOn::)[^"`\n]*)["`]/gu + const fixtureLines = Array.from( + testSource.matchAll(dependencyFixture), + (match) => match[1] ?? "", + ) + const generatedLines = randomLists.flatMap((value) => [ + `- [ ] x ⛔ ${value}`, + `- [ ] x [dependsOn:: ${value}]`, + `- [ ] x (dependsOn:: ${value}),`, + ]) + const lines = [...fixtureLines, ...generatedLines] + + for (const pattern of dependencyPatterns) { + const oldPattern = new RegExp( + pattern.source.replace( + "[a-zA-Z0-9_-]+(?:(?: *, *[a-zA-Z0-9_-]+)+ *(?! ))?", + oldSequence.source, + ), + pattern.flags, + ) + expect(oldPattern.source).not.toBe(pattern.source) + + for (const line of lines) { + const oldMatch = oldPattern.exec(line) + const newMatch = pattern.exec(line) + expect({ line, match: newMatch?.[0], group1: newMatch?.[1] }).toEqual({ + line, + match: oldMatch?.[0], + group1: oldMatch?.[1], + }) + } + } + }) + it("parses 🆔 id and ⛔ dependsOn with spaces around commas", () => { const extracted = tasks.extractTasks("- [ ] Build API ⛔ db-setup , cache-setup 🆔 api-build") expect(extracted).toEqual([ @@ -687,9 +830,12 @@ describe("tasks.extractTasks", () => { }) it("excludes task lines inside a tilde fence", () => { - const content = ["~~~", "- [ ] Hidden", "~~~"].join("\n") + const content = ["- [ ] Before", "~~~", "- [ ] Hidden", "~~~", "- [ ] After"].join("\n") const extracted = tasks.extractTasks(content) - expect(extracted).toEqual([]) + expect(extracted).toEqual([ + task({ line: 1, description: "Before" }), + task({ line: 5, description: "After" }), + ]) }) it("skips frontmatter and reports file-relative 1-based line numbers", () => { @@ -1489,6 +1635,18 @@ describe("task line mutations", () => { expect(result).toBe("- [ ] New description ⏫ ➕ 2026-08-01 ^my-task") }) + it("separates a new description from a bare checkbox with one space", () => { + expect(tasks.replaceTaskLineDescription({ taskLine: "- [ ]", newDescription: "New" })).toBe( + "- [ ] New", + ) + }) + + it("adds the space the Tasks plugin writes after a checkbox glued to its text", () => { + expect( + tasks.replaceTaskLineDescription({ taskLine: "- [ ]Old ^my-task", newDescription: "New" }), + ).toBe("- [ ] New ^my-task") + }) + it("replaces description when only a block_id follows", () => { const line = "- [ ] Old text ^my-task" const result = tasks.replaceTaskLineDescription({ @@ -1888,6 +2046,30 @@ describe("task line mutations", () => { // ── updateTaskLineTaskId ────────────────────────────────────── describe("updateTaskLineTaskId", () => { + it.each([ + { name: "a newline", lineBreak: "\n" }, + { name: "a carriage return", lineBreak: "\r" }, + { name: "a line separator", lineBreak: "\u2028" }, + { name: "a paragraph separator", lineBreak: "\u2029" }, + ])("does not mutate a body containing $name", ({ lineBreak }) => { + const taskLine = `- [ ] First${lineBreak}second` + + expect(tasks.isTaskLine(taskLine)).toBe(false) + expect(tasks.updateTaskLineTaskId({ taskLine, taskId: "abc123", config: EMOJI_CONFIG })).toBe( + taskLine, + ) + }) + + it.each([ + { name: "without a trailing space", taskLine: "- [ ]", expected: "- [ ] 🆔 abc123" }, + { name: "with a trailing space", taskLine: "- [ ] ", expected: "- [ ] 🆔 abc123" }, + ])("sets an ID on an empty task body $name", ({ taskLine, expected }) => { + expect(tasks.isTaskLine(taskLine)).toBe(true) + expect(tasks.updateTaskLineTaskId({ taskLine, taskId: "abc123", config: EMOJI_CONFIG })).toBe( + expected, + ) + }) + it("sets a task ID (emoji format)", () => { const line = "- [ ] My task ➕ 2026-08-01 ^my-task" const result = tasks.updateTaskLineTaskId({ @@ -2054,7 +2236,7 @@ describe("task line mutations", () => { expect(result).toBe("- [ ] My task 🏁 delete ➕ 2026-08-01 📅 2026-09-01 ^my-task") }) - it("clearing strips all copies of both formats", () => { + it("clearing strips duplicated emoji-format fields", () => { const line = "- [ ] My task 🏁 delete 🏁 keep ➕ 2026-08-01 ^my-task" const result = tasks.updateTaskLineOnCompletion({ taskLine: line, @@ -2624,7 +2806,7 @@ describe("task line mutations", () => { "- [ ] Task A ^a", " - [ ] Sub A ^sa", "## Up Next", - "- [ ] Task B ^b", + " - [ ] Task B ^b", ].join("\n") + "\n" const parsed = tasks.extractTasks(content) expect(parsed).toEqual([ diff --git a/src/vault-mcp/obsidian-markdown/tasks.ts b/src/vault-mcp/obsidian-markdown/tasks.ts index b2a78defe..ead030f77 100644 --- a/src/vault-mcp/obsidian-markdown/tasks.ts +++ b/src/vault-mcp/obsidian-markdown/tasks.ts @@ -5,7 +5,7 @@ * **Parsing:** a faithful reimplementation of the plugin's own parser * (obsidian-tasks-group/obsidian-tasks, `DefaultTaskSerializer` + * `DataviewTaskSerializer`): metadata is stripped off the END of the line, one - * `$`-anchored field at a time, in repeated passes until nothing matches. Any + * `$`-anchored field at a time, until nothing matches or 21 passes finish. Any * order of fields parses; unrecognized trailing text stops the scan and leaves * everything to its left as description — exactly the plugin's behavior. * Unlike the plugin (which reads one configured format per vault), both @@ -64,8 +64,8 @@ export type ParsedTask = Readonly<{ doneDate: string | null cancelledDate: string | null priority: TaskPriority | null - /** Verbatim recurrence rule text after 🔁 / `repeat::` (e.g. "every week - * when done"). Completing the task spawns the next occurrence from it. */ + /** Recurrence rule text after 🔁 / `repeat::` (e.g. "every week when done"). + * Completion uses it to compute the next occurrence, if one exists. */ recurrence: string | null /** Raw word after 🏁 / `onCompletion::` (the plugin accepts "delete" and * "keep"). */ @@ -84,8 +84,7 @@ export type ParsedTask = Readonly<{ heading: string | null /** Nesting depth: 0 for top-level tasks, 1+ for sub-tasks. Derived from * structural indentation relative to ancestor task lines; a plain list - * item at a task's indent ends its scope, so a task nested under a - * non-task bullet is top-level. */ + * item closes tasks at its indent or deeper without becoming a parent. */ depth: number /** 1-based file line number of the parent task, or null for top-level * tasks. The index resolves it to the parent's block_id; the wire shape @@ -162,8 +161,12 @@ const TASK_ID = /[a-zA-Z0-9_-]+/ * anything else lands on the line as prose the parser never reads back. */ const TASK_ID_WHOLE_RE = new RegExp(`^${TASK_ID.source}$`) const isTaskId = (candidate: string): boolean => TASK_ID_WHOLE_RE.test(candidate) -/** A comma-separated sequence of task IDs, as accepted after ⛔ / `dependsOn::`. */ -const TASK_ID_SEQUENCE = new RegExp(`${TASK_ID.source}( *, *${TASK_ID.source} *)*`) +/** A comma-separated sequence of task IDs, as accepted after ⛔ / `dependsOn::`. + * The Tasks plugin builds this as `ID( *, *ID *)*`; each repetition's trailing + * spaces can also match the next repetition's leading spaces, so a space before + * each comma causes exponential backtracking. This form gives each space run + * one place to match. */ +const TASK_ID_SEQUENCE = new RegExp(`${TASK_ID.source}(?:(?: *, *${TASK_ID.source})+ *(?! ))?`) /** Builds an emoji field regex: signifier + optional VS16 (U+FE0F, matched * via escape so no invisible character hides in this source) + spaces + @@ -174,8 +177,14 @@ const emojiField = ({ symbols, valuePattern }: { symbols: string; valuePattern: /** Builds a Dataview inline-field regex: `[key:: value]` or `(key:: value)` * with matched brackets and an optional trailing comma, anchored to line end * (see block comment above). */ -const dataviewField = (innerPattern: string): RegExp => - new RegExp(`(?:(?=[^\\]]+\\])\\[|(?=[^)]+\\))\\() *${innerPattern} *[)\\]](?: *,)?$`) +const dataviewField = (innerPattern: string): RegExp => { + const squareBracketOpener = String.raw`(?=[^\]]+\])\[` + const parenthesisOpener = String.raw`(?=[^)]+\))\(` + + return new RegExp( + `(?:${squareBracketOpener}|${parenthesisOpener}) *${innerPattern} *[)\\]](?: *,)?$`, + ) +} /** `YYYY-MM-DD` — the only date format the plugin recognizes on task lines. */ const DATE_VALUE = "(\\d{4}-\\d{2}-\\d{2})" @@ -315,9 +324,10 @@ type TaskMetadata = Pick< | "tags" > -/** Passes cap for the stripping loop — the plugin's own runaway guard. A - * well-formed line finishes in one pass; 20 covers any field permutation. */ -const MAX_STRIPPING_PASSES = 20 +/** The Tasks plugin continues while runs <= maxRuns after incrementing runs. */ +const PLUGIN_MAX_RUNS = 20 +/** The plugin's do/while executes once before checking its limit, allowing 21 passes. */ +const MAX_STRIPPING_PASSES = PLUGIN_MAX_RUNS + 1 /** Extracts a regex capture group, throwing if absent. Capture groups are * guaranteed by the engine when the regex matches, but noUncheckedIndexedAccess @@ -348,7 +358,7 @@ const capturedPriority = ( /** Strips metadata fields off the end of a task body, mirroring the plugin's * deserialize(): * - each pass tries every field regex (all `$`-anchored) and removes what - * matches; the loop repeats until a pass matches nothing; + * matches; the loop repeats until a pass matches nothing or the cap is reached; * - a field written twice keeps its leftmost value, the one stripped last; * - tags interleaved with signifiers are stripped too, then re-appended, so * they stay part of the description without blocking fields to their left. */ @@ -577,9 +587,8 @@ const scanBodyLinesForTasks = ({ const statusChar = capturedGroup(taskLineMatch, 1) const resolvedStatus = statusForChar(statusChar, statusRegistry) - // NON_TASK lines are invisible to the task system — prune the indent - // stack (closing any open task at the same or deeper indent) so tasks - // nested below become top-level, then skip the line. + // NON_TASK lines cannot parent tasks. Close tasks at the same or deeper + // indent while retaining shallower task ancestors for the next task. // After this guard, resolvedStatus is narrowed to TaskStatus // (StatusClassification is TaskStatus | "non_task"). if (resolvedStatus === "non_task") { @@ -793,8 +802,7 @@ const removeLastMetadataMatch = (metadata: string, regex: RegExp): string => { } /** Removes EVERY occurrence of a field regex from a metadata tail. - * Used by clear paths (field set to null) where no copy should survive; - * the set paths use removeLastMetadataMatch to preserve a description signifier. */ + * Clear paths, onCompletion sets, and completion stamps discard every duplicate. */ const removeAllMetadataMatches = (metadata: string, regex: RegExp): string => { const stripped = removeLastMetadataMatch(metadata, regex) return stripped === metadata ? metadata : removeAllMetadataMatches(stripped, regex) @@ -1005,8 +1013,8 @@ const updateTaskLineRecurrence = ({ }) } -/** Sets or clears the 🏁 / `[onCompletion:: ]` field on a task line. - * Inserted after recurrence and before dates — the plugin's field order. */ +/** Sets or clears 🏁 / `[onCompletion:: ]`, replacing every duplicate action. + * New actions follow recurrence and precede dates — the plugin's field order. */ const updateTaskLineOnCompletion = ({ taskLine, onCompletion, @@ -1093,6 +1101,9 @@ type TaskLineParts = { * checkbox — the two must stay in sync on what counts as the prefix. */ const TASK_PREFIX_RE = /^([\s>]*(?:[-*+]|[0-9]+[.)]) +\[.\] *)/u +/** Matches line terminators excluded by TASK_LINE_RE's body capture. */ +const TASK_BODY_LINE_BREAK_RE = /[\r\n\u2028\u2029]/u + /** Splits a task line at the parser's description/metadata boundary. * Returns null when the line is not a task line. */ const splitTaskLine = (taskLine: string): TaskLineParts | null => { @@ -1101,6 +1112,9 @@ const splitTaskLine = (taskLine: string): TaskLineParts | null => { if (!prefix) return null const afterCheckbox = taskLine.slice(prefix.length) + + if (TASK_BODY_LINE_BREAK_RE.test(afterCheckbox)) return null + // Capture trailing whitespace before splitTrailingBlockLink trims it — // a markdown hard break (two+ trailing spaces) must survive the round-trip. const trimmedAfterCheckbox = afterCheckbox.trimEnd() @@ -1134,7 +1148,11 @@ const joinTaskLine = ({ trailingWhitespace, }: TaskLineParts): string => { const body = [description.trim(), metadata.trim()].filter(Boolean).join(" ") - return `${prefix}${body}${blockLink}${trailingWhitespace}` + + // A checkbox needs a space before its text to render as one, and the Tasks + // plugin always writes that space, so text added to a bare `- [ ]` gets it. + const checkboxSeparator = body && prefix.endsWith("]") ? " " : "" + return `${prefix}${checkboxSeparator}${body}${blockLink}${trailingWhitespace}` } /** Applies a field mutation to the metadata tail only — field-like text @@ -1157,11 +1175,8 @@ const appendMetadataField = ({ fieldText: string }): string => [metadata, fieldText].filter(Boolean).join(" ") -/** Inserts a field at its correct position in the task-line metadata by - * placing it before the first field in `laterFieldRegexes` that already - * exists, or appending when none of them is present. Each caller passes the - * fields that come after the one being inserted, so the canonical ordering - * (priority → recurrence → dates → id → depends_on) is maintained. */ +/** Inserts before the first present field in the caller's canonical field order. + * Existing fields retain their order, even when it differs from the canonical order. */ const insertFieldAtPosition = ({ metadata, fieldText, @@ -1369,9 +1384,8 @@ const consumedDescriptionTail = ({ /** Description divergence uses two comparison modes with different * representations: - * - Submitted this call: trimmed submitted text vs the after SLOT — the - * slot excludes tags the parser re-appends from the metadata tail, which - * would otherwise flag every description edit on a tagged line. + * - Submitted this call: trimmed submitted text may match either the raw + * description slot or the parser's description with tags re-appended. * - Not submitted: prior PARSER view vs after PARSER view — both sides * tag-enriched identically, so a trailing tag migrating into the slot * when the last metadata field is cleared reads as no change. @@ -1389,9 +1403,6 @@ const descriptionDivergences = ({ const submittedDescription = submitted.description?.trim() if (submittedDescription !== undefined) { - // The slot triggers the divergence check (the parser view re-appends - // tags and would flag every edit on a tagged line), but the advisory - // quotes storedDescription — what vault_list_tasks actually returns. if (afterReading.descriptionSlot === submittedDescription) return [] const storedDescription = afterReading.metadata.description === "" ? null : afterReading.metadata.description @@ -1568,9 +1579,8 @@ const removeBlockId = (taskLine: string): string => { return `${trimmedLine.slice(0, blockLinkMatch.index)}${trailingWhitespace}` } -/** Removes the trailing `^block-id` from a task line, if it has one, and - * drops trailing whitespace either way — unlike removeBlockId, which keeps a - * hard break. Used to start a spawned occurrence's line. */ +/** Removes the trailing ` ^block-id` after trimming the line's end. + * Spaces before that link remain. Used to start a spawned occurrence's line. */ const stripBlockLink = (taskLine: string): string => { const trimmedLine = taskLine.trimEnd() const blockLinkMatch = BLOCK_LINK_RE.exec(trimmedLine) @@ -1579,9 +1589,8 @@ const stripBlockLink = (taskLine: string): string => { return trimmedLine.slice(0, blockLinkMatch.index) } -/** Extracts the structural indent of a line — leading whitespace after - * stripping blockquote `>` markers. A top-level task returns 0; an - * indented sub-task returns the whitespace count before its list marker. */ +/** Counts leading whitespace characters after removing column-zero blockquote prefixes. + * Each tab counts as one; whitespace before `>` remains part of the indent. */ const getStructuralIndent = (line: string): number => { // Strip leading blockquote markers: each `> ` (one `>` + one optional // space) is a blockquote level. Only the marker's own space is consumed; @@ -1710,7 +1719,7 @@ const updateTaskLineStatus = (params: { /** The next-occurrence line for a completed recurring task — the plugin's * createNextOccurrence as a line transform. The completed line's copy keeps * its description, priority, tags, recurrence rule, and indentation, and: - * - becomes `[ ]` with done/cancelled dates stripped; + * - becomes the configured todo checkbox with done/cancelled dates stripped; * - loses its block link, 🆔 id, and ⛔ dependencies (a new occurrence * cannot share identity with the completed one); * - has start/scheduled/due replaced by the next occurrence's dates; @@ -1815,10 +1824,7 @@ const updateTaskLinePriority = ({ const COMPLETE_MARKER = "**Complete**" /** Extracts heading names whose body starts with a `**Complete**` marker - * paragraph — the Kanban plugin's per-lane completion signal. Relies on - * `parseHeadings` (which is fence/comment-aware) to define section - * boundaries, so markers inside code blocks are excluded by the heading - * parser's span computation — not re-checked here. + * paragraph — the Kanban plugin's per-lane completion signal. * * @param bodyLines Note body lines (frontmatter stripped) * @param headings Pre-parsed headings from `parseHeadings(bodyLines)` */ @@ -1826,8 +1832,8 @@ const extractDoneLanes = ( bodyLines: readonly string[], headings: readonly HeadingInfo[], ): string[] => { - // The marker must be the very first content paragraph after the heading — - // blank lines are skipped, anything else means the lane has no marker. + // An opening fence or comment is content, so a marker inside it cannot + // be the first content line after the heading. const startsWithCompleteMarker = (heading: HeadingInfo): boolean => { const firstContentLine = bodyLines .slice(heading.bodyStartLine, heading.bodyEndLine) @@ -1845,9 +1851,9 @@ type KanbanCardInsertionMethod = "prepend" | "append" /** Matches a backtick fence delimiter (with or without a language tag). */ const BACKTICK_FENCE_RE = /^`{3,}/ -/** Extracts `new-card-insertion-method` from the `%% kanban:settings %%` - * comment block. Returns `undefined` when the block is absent, the JSON - * is malformed, or the value is not a recognized insertion method. */ +/** Reads `new-card-insertion-method` after a `%% kanban:settings` marker. + * - The next two backtick fences bound the JSON, even beyond a closing `%%`. + * - Returns `undefined` for missing fences, malformed JSON, or an unrecognized value. */ const parseKanbanCardInsertionMethod = ( bodyLines: readonly string[], ): KanbanCardInsertionMethod | undefined => { @@ -1857,8 +1863,6 @@ const parseKanbanCardInsertionMethod = ( if (settingsLineIndex === -1) return undefined - // The block structure is fixed: %% kanban:settings → backtick fence → - // JSON → closing fence → %%. Extract the lines between the fences. const blockLines = bodyLines.slice(settingsLineIndex + 1) const fenceStart = blockLines.findIndex((line) => BACKTICK_FENCE_RE.test(line.trim()))