From 1f352015077a148878066212318da2571e43fda7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:36:15 -0500 Subject: [PATCH 1/6] fix(tasks): parse task dependency lists in linear time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move trailing spaces outside the repeated comma-separated IDs to avoid exponential backtracking while preserving dependency matches in emoji and Dataview fields. Implemented-By: codex · gpt-6.1-sol --- .../obsidian-markdown/__tests__/tasks.test.ts | 113 +++++++++++++++++- src/vault-mcp/obsidian-markdown/tasks.ts | 2 +- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts index 451359e6a..026161c8f 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, @@ -580,6 +582,115 @@ describe("tasks.extractTasks", () => { ]) }) + it.each([ + { name: "emoji", field: (value: string) => `⛔ ${value}` }, + { name: "Dataview", field: (value: string) => `[dependsOn:: ${value}]` }, + ])("parses a 60-ID adversarial $name dependency list in under 50 ms", ({ field }) => { + const dependencies = Array.from({ length: 60 }, () => "a").join(" , ") + 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: 2000 }, + ) + 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([ diff --git a/src/vault-mcp/obsidian-markdown/tasks.ts b/src/vault-mcp/obsidian-markdown/tasks.ts index b2a78defe..3e9d943f9 100644 --- a/src/vault-mcp/obsidian-markdown/tasks.ts +++ b/src/vault-mcp/obsidian-markdown/tasks.ts @@ -163,7 +163,7 @@ const TASK_ID = /[a-zA-Z0-9_-]+/ 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} *)*`) +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 + From 6825a488fea31e40f36de90f3ca0502bdd7a8db7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:51:55 -0500 Subject: [PATCH 2/6] style: clarify task parser contracts and delimiter patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: code-quality · gpt-6.1-sol --- src/vault-mcp/obsidian-markdown/tasks.ts | 67 ++++++++++-------------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/tasks.ts b/src/vault-mcp/obsidian-markdown/tasks.ts index 3e9d943f9..aba412456 100644 --- a/src/vault-mcp/obsidian-markdown/tasks.ts +++ b/src/vault-mcp/obsidian-markdown/tasks.ts @@ -162,7 +162,8 @@ 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::`. */ +/** A comma-separated sequence of task IDs, as accepted after ⛔ / `dependsOn::`. + * Trailing spaces follow the repeated group so repetitions cannot divide them. */ const TASK_ID_SEQUENCE = new RegExp(`${TASK_ID.source}(?:(?: *, *${TASK_ID.source})+ *)?`) /** Builds an emoji field regex: signifier + optional VS16 (U+FE0F, matched @@ -174,8 +175,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,8 +322,7 @@ 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. */ +/** Matches the Tasks plugin's 20-pass limit; any unstripped prefix remains description text. */ const MAX_STRIPPING_PASSES = 20 /** Extracts a regex capture group, throwing if absent. Capture groups are @@ -348,7 +354,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 +583,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 +798,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 +1009,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, @@ -1157,11 +1161,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 +1370,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 +1389,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 @@ -1579,9 +1576,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; @@ -1815,10 +1811,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 +1819,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 +1838,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 +1850,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())) From 9171bfb76a8416b3f9b47227b8483362457df804 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:05:13 -0500 Subject: [PATCH 3/6] test: strengthen parser guard and error assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: test-audit · gpt-6.1-sol --- .../obsidian-markdown/__tests__/tasks.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts index 026161c8f..09afcc8be 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts @@ -270,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)', + ), ) }) @@ -292,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)', + ), ) }) @@ -798,9 +802,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", () => { @@ -2165,7 +2172,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, @@ -2735,7 +2742,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([ From dad398b9315c2df2fefc9b3bcc2c98becb772e16 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:18:40 -0500 Subject: [PATCH 4/6] fix: prevent dependency suffix whitespace retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the dependency list's final space run maximal so a malformed Dataview field cannot repartition it with the surrounding field matcher. Cover the long suffix through the bounded parser regression and correct factual task parser comments about termination, ancestry and recurring copies. Keep mutations from editing bodies the task-line recognizer already rejects. Ship-Check: bug-check · gpt-6.1-sol --- .../obsidian-markdown/__tests__/tasks.test.ts | 90 +++++++++++++------ src/vault-mcp/obsidian-markdown/tasks.ts | 26 +++--- 2 files changed, 80 insertions(+), 36 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts index 09afcc8be..783bc3231 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts @@ -587,35 +587,51 @@ describe("tasks.extractTasks", () => { }) it.each([ - { name: "emoji", field: (value: string) => `⛔ ${value}` }, - { name: "Dataview", field: (value: string) => `[dependsOn:: ${value}]` }, - ])("parses a 60-ID adversarial $name dependency list in under 50 ms", ({ field }) => { - const dependencies = Array.from({ length: 60 }, () => "a").join(" , ") - 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)}; + { 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: 2000 }, - ) - const result = JSON.parse(output) + ], + { encoding: "utf8", timeout: 2000 }, + ) + const result = JSON.parse(output) - expect(result.extracted).toEqual([ - task({ description: `x ${field(`${dependencies}!`)}`, dependsOn: [] }), - ]) - expect(result.elapsed).toBeLessThan(50) - }) + 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. */ @@ -676,7 +692,7 @@ describe("tasks.extractTasks", () => { for (const pattern of dependencyPatterns) { const oldPattern = new RegExp( pattern.source.replace( - "[a-zA-Z0-9_-]+(?:(?: *, *[a-zA-Z0-9_-]+)+ *)?", + "[a-zA-Z0-9_-]+(?:(?: *, *[a-zA-Z0-9_-]+)+ *(?! ))?", oldSequence.source, ), pattern.flags, @@ -2006,6 +2022,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({ diff --git a/src/vault-mcp/obsidian-markdown/tasks.ts b/src/vault-mcp/obsidian-markdown/tasks.ts index aba412456..40bc1647e 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 20 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 @@ -163,8 +162,8 @@ const TASK_ID = /[a-zA-Z0-9_-]+/ 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::`. - * Trailing spaces follow the repeated group so repetitions cannot divide them. */ -const TASK_ID_SEQUENCE = new RegExp(`${TASK_ID.source}(?:(?: *, *${TASK_ID.source})+ *)?`) + * The repeated suffix consumes trailing spaces as one maximal run. */ +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 + @@ -1097,6 +1096,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 => { @@ -1105,6 +1107,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() @@ -1565,9 +1570,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) @@ -1706,7 +1710,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; From 5f89226c424073821fff1b1988d9c6ace5a91b2f Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:46:50 -0500 Subject: [PATCH 5/6] fix(tasks): match the Tasks plugin's metadata pass limit and bound the timing tests' startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow 21 metadata stripping passes to match the Tasks plugin's loop boundary and cover the 21- and 22-signifier cases. Explain why dependency whitespace must have one matching position, and allow ten seconds for child startup while retaining the 50 ms parsing assertion. Implemented-By: codex · gpt-6.1-sol --- .../obsidian-markdown/__tests__/tasks.test.ts | 14 +++++++++++++- src/vault-mcp/obsidian-markdown/tasks.ts | 13 +++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts index 783bc3231..bcd15f227 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts @@ -541,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" })]) @@ -622,7 +634,7 @@ describe("tasks.extractTasks", () => { const elapsed = performance.now() - started; process.stdout.write(JSON.stringify({ extracted, elapsed }));`, ], - { encoding: "utf8", timeout: 2000 }, + { encoding: "utf8", timeout: 10000 }, ) const result = JSON.parse(output) diff --git a/src/vault-mcp/obsidian-markdown/tasks.ts b/src/vault-mcp/obsidian-markdown/tasks.ts index 40bc1647e..71c3c9faa 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, until nothing matches or 20 passes finish. 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 @@ -162,7 +162,10 @@ const TASK_ID = /[a-zA-Z0-9_-]+/ 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::`. - * The repeated suffix consumes trailing spaces as one maximal run. */ + * 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 @@ -321,8 +324,10 @@ type TaskMetadata = Pick< | "tags" > -/** Matches the Tasks plugin's 20-pass limit; any unstripped prefix remains description text. */ -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 From a014a32544df51447e14836c92297a6853308810 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:57:03 -0500 Subject: [PATCH 6/6] fix(tasks): keep a space between the checkbox and the text a task edit adds The Tasks plugin writes one space after the checkbox, and a checkbox glued to its text does not render as one, so setting a field or a description on a bare "- [ ]" now writes "- [ ] ...". Co-Authored-By: Claude Opus 5.5 --- .../obsidian-markdown/__tests__/tasks.test.ts | 14 +++++++++++++- src/vault-mcp/obsidian-markdown/tasks.ts | 6 +++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts index bcd15f227..c1ddacf1b 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/tasks.test.ts @@ -1635,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({ @@ -2049,7 +2061,7 @@ describe("task line mutations", () => { }) it.each([ - { name: "without a trailing space", taskLine: "- [ ]", expected: "- [ ]🆔 abc123" }, + { 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) diff --git a/src/vault-mcp/obsidian-markdown/tasks.ts b/src/vault-mcp/obsidian-markdown/tasks.ts index 71c3c9faa..ead030f77 100644 --- a/src/vault-mcp/obsidian-markdown/tasks.ts +++ b/src/vault-mcp/obsidian-markdown/tasks.ts @@ -1148,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