From f7d22350f25f76fb95a8a61c900375e4f35fce2f Mon Sep 17 00:00:00 2001 From: budhash Date: Wed, 1 Jul 2026 16:09:57 -0700 Subject: [PATCH] fix: parse machine tags only from the trailing tag region (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag extraction and editing scanned the whole item line, so a `@key=value` mentioned in a task's descriptive title (e.g. a task titled after the `@tags=… → @milestone=…` migration) was parsed as a real tag. This miscounted such a task into a phantom `milestone` bucket, and made `migrate-tags-to-milestone` treat it as an already-assigned conflict and skip it. Anchor tag parsing to the trailing contiguous run of recognized `@…` tokens after the title: - split_item_tags() splits an item's rest into (title, trailing tag tokens); reads (_get_tag_value, get_all_items) and edits (_set_single_tag, _set_or_remove_tag) now operate on that region only. Whitespace normalization matches how the tool already writes tags, so tool-managed lines are unchanged. - Fixes the write path too: set/link no longer strip a look-alike @key=value out of the title prose. - Docstring + schema note the trailing-region rule. version 1.1.0 -> 1.1.1. - New e2e scenario 48 reproduces the issue (rollup, list filter, show, migrate, and title-preservation on write). --- CHANGELOG.md | 12 ++++++ schema.md | 4 ++ tasks.py | 82 +++++++++++++++++++++++++++-------------- tests/test_tasks_e2e.sh | 38 +++++++++++++++++++ 4 files changed, 109 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba4e8d..be79124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to `tasks`. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); this project uses [semantic versioning](https://semver.org/). +## [1.1.1] — 2026-07-01 + +### Fixed +- Machine tags (`@milestone=`, `@deps=`, `@tags=`, `@effort=`, …) are now parsed + and edited only in the trailing tag region of an item line, not by scanning the + whole line. A task whose descriptive title merely *mentions* `@milestone=foo` + (e.g. one titled after the `@tags=… → @milestone=…` migration) is no longer + miscounted into a phantom milestone bucket by `milestone`, and + `migrate-tags-to-milestone` no longer treats it as an already-assigned conflict + and skips it. The write path is fixed too: `set`/`link` no longer strip a + look-alike `@key=value` out of the title prose. (#9) + ## [1.1.0] — 2026-07-01 ### Added diff --git a/schema.md b/schema.md index 6224fb5..badff13 100644 --- a/schema.md +++ b/schema.md @@ -33,6 +33,10 @@ One line per item, exact format: ## Tags (optional) +Machine tags live in the **trailing run of `@…` tokens** after the title. A +`@key=value` written inside the descriptive title/prose is left as text, not +parsed as a tag. + | Tag | Meaning | |---|---| | `@deps=F-2,T-11` | Blocked by / depends on | diff --git a/tasks.py b/tasks.py index 88c5bdf..f1aa132 100755 --- a/tasks.py +++ b/tasks.py @@ -40,6 +40,9 @@ These are "detail lines" and are preserved during subtree moves. Tags (optional, machine-friendly): + Machine tags live in a trailing run of @… tokens AFTER the title. A bare + @key=value written inside the descriptive title/prose is NOT parsed as a tag + (so a task titled "convert @tags=x to @milestone=x" carries no such tags). @deps=F-0002,T-0011 Blocked by / depends on @rel=T-0100,F-0003 Related / associated @branch=feat/foo Implementing branch @@ -185,7 +188,7 @@ from pathlib import Path from typing import List, Tuple, Optional, Dict, Set, Iterator -__version__ = "1.1.0" +__version__ = "1.1.1" # Canonical source for `selfupdate` — the published raw URL. `selfupdate` # overwrites this very script with the fetched content, so a NON-default source @@ -655,20 +658,19 @@ def get_all_items(lines: List[str]) -> Dict[str, dict]: parsed = parse_item(line) if parsed: indent, box, iid, prio, status, rest = parsed - deps_match = TAG_DEPS_RE.search(rest) - milestone_match = TAG_MILESTONE_RE.search(rest) shadow = is_shadow(line) # Skip shadow entries — they duplicate primary IDs if shadow: continue + # Extract tags from the trailing tag region only (never from prose). items[iid] = { "line_num": i, "indent": len(indent), "prio": prio, "status": status, "rest": rest, - "deps": parse_id_list(deps_match.group(1)) if deps_match else [], - "milestone": milestone_match.group(1) if milestone_match else None, + "deps": parse_id_list(_get_tag_value(rest, TAG_DEPS_RE) or ""), + "milestone": _get_tag_value(rest, TAG_MILESTONE_RE), "is_shadow": shadow, } return items @@ -1280,38 +1282,64 @@ def _warn_unknown_milestone(value: str, lines: Optional[List[str]] = None) -> No # ------------------------- # Tag editing (@deps / @rel / @branch / @pr) # ------------------------- +# Machine tags live in a trailing run of `@…` tokens after the title. A bare +# `@key=value` mentioned in the descriptive title/prose is NOT a tag — so tags +# are parsed from (and edited in) only that trailing region, never free text. +_KNOWN_MACHINE_TAGS = ("deps", "rel", "branch", "pr", "issue", "tags", + "effort", "system", "milestone", "done") +_TAG_TOKEN_RE = re.compile(r'^@(?:' + "|".join(_KNOWN_MACHINE_TAGS) + r')=\S+$') + + +def _is_tag_token(tok: str) -> bool: + """True if `tok` is a recognized machine-tag token (`@shadow` or `@name=value`).""" + return tok == "@shadow" or bool(_TAG_TOKEN_RE.match(tok)) + + +def split_item_tags(rest: str) -> Tuple[str, List[str]]: + """Split an item's `rest` into (title, trailing_tag_tokens). + + Only the trailing contiguous run of recognized `@…` tokens counts as tags; + a `@key=value` that appears earlier (in the descriptive title) stays in the + title. Whitespace is normalized to single spaces, matching how the tool + already writes tags — so tool-managed lines are unaffected. + """ + tokens = rest.split() + i = len(tokens) + while i > 0 and _is_tag_token(tokens[i - 1]): + i -= 1 + return " ".join(tokens[:i]), tokens[i:] + + +def _tag_token_is(tok: str, tag: str) -> bool: + return tok == f"@{tag}" or tok.startswith(f"@{tag}=") + + +def _join_title_tags(title: str, tag_tokens: List[str]) -> str: + return " ".join(([title] if title else []) + tag_tokens) + + def _get_tag_value(rest: str, tag_re: re.Pattern) -> Optional[str]: - m = tag_re.search(rest) + # Search only the trailing tag region, so prose mentions aren't read as tags. + _title, tag_tokens = split_item_tags(rest) + m = tag_re.search(" ".join(tag_tokens)) return m.group(1) if m else None def _set_or_remove_tag(rest: str, tag: str, values: List[str]) -> str: - # remove existing tag first - rest = re.sub(rf'@{tag}=[^\s]+', '', rest).strip() - # normalize whitespace to single spaces - rest = " ".join(rest.split()) + title, tag_tokens = split_item_tags(rest) + tag_tokens = [t for t in tag_tokens if not _tag_token_is(t, tag)] if values: - suffix = f"@{tag}=" + ",".join(values) - if rest: - rest = rest + " " + suffix - else: - rest = suffix - return rest + tag_tokens.append(f"@{tag}=" + ",".join(values)) + return _join_title_tags(title, tag_tokens) def _set_single_tag(rest: str, tag: str, value: Optional[str]) -> str: - """Set a single-value tag like @branch or @pr.""" - # remove existing tag first - rest = re.sub(rf'@{tag}=[^\s]+', '', rest).strip() - # normalize whitespace to single spaces - rest = " ".join(rest.split()) + """Set a single-value tag like @branch or @pr (only within the tag region).""" + title, tag_tokens = split_item_tags(rest) + tag_tokens = [t for t in tag_tokens if not _tag_token_is(t, tag)] if value: - suffix = f"@{tag}={value}" - if rest: - rest = rest + " " + suffix - else: - rest = suffix - return rest + tag_tokens.append(f"@{tag}={value}") + return _join_title_tags(title, tag_tokens) def edit_links(item_id: str, diff --git a/tests/test_tasks_e2e.sh b/tests/test_tasks_e2e.sh index cca66a1..3735909 100755 --- a/tests/test_tasks_e2e.sh +++ b/tests/test_tasks_e2e.sh @@ -1386,6 +1386,44 @@ else echo -e " ${RED}FAIL${NC}: validate exited $rc on registry-health warnings (want 0)" fi +# ============================================================ +echo -e "\n${YELLOW}=== SCENARIO 48: tags are parsed from the trailing tag region, not prose (issue #9) ===${NC}" +# ============================================================ +setup_test_dir +fresh_init > /dev/null + +run new feature "Prose parsing" --prio P1 --section Now > /dev/null +run new task "Real work" --prio P0 --under F-0001 --milestone m1 > /dev/null +# Title MENTIONS @milestone= and @tags= in prose but carries NO real milestone tag. +run new task "Migrate @tags=foo -> @milestone=foo across tasks" --prio P1 --under F-0001 > /dev/null + +out=$(run milestone) +assert_contains "Real milestone m1 counted" "$out" "m1" +assert_not_contains "No phantom bucket from prose @milestone=foo" "$out" "foo" + +out=$(run list --milestone default) +assert_contains "Prose-only task falls into default bucket" "$out" "T-0002" + +out=$(run list --milestone foo) +assert_not_contains "Prose mention does not match a milestone filter" "$out" "T-0002" + +out=$(run show T-0002) +assert_not_contains "show does not report a prose milestone" "$out" "Milestone:" + +# migrate must not treat a prose @milestone= as an existing (conflicting) assignment. +run set T-0002 --tags realtag > /dev/null +out=$(run migrate-tags-to-milestone realtag) +assert_contains "Prose task migrates (not skipped as a false conflict)" "$out" "Migrated 1" +out=$(run show T-0002) +assert_contains "Migrated task now resolves to realtag" "$out" "Milestone: realtag" + +# write side: setting a tag must not strip a look-alike token out of the title prose. +out=$(grep "(T-0002)" TASKS.md) +assert_contains "Prose @milestone=foo preserved in the title" "$out" "@milestone=foo across tasks" + +out=$(run validate) +assert_contains "Validate still passes with prose look-alikes" "$out" "OK" + # ============================================================ # Summary # ============================================================