diff --git a/CHANGELOG.md b/CHANGELOG.md index be79124..3e815b4 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.2] — 2026-07-13 + +### Fixed +- Field edits landed on the first line carrying an ID in file order — which for + a feature with a `@shadow` copy can be the shadow, not the canonical line. + `set F-N --milestone m1` on such a feature tagged only the shadow, so the + feature showed up in both its milestone and the `default` bucket (#17). The + same first-match bug affected `set` (all tags), `link --deps/--rel`, `prio`, + and the status verbs. All of these now apply to the primary line **and** every + shadow copy, keeping the derived copies in sync — consistent with how shadows + are created (copied from the primary, tags included). + ## [1.1.1] — 2026-07-01 ### Fixed diff --git a/schema.md b/schema.md index badff13..ea87faa 100644 --- a/schema.md +++ b/schema.md @@ -62,6 +62,8 @@ When a task is skipped/scattered into a section its parent feature isn't in, `tasks` leaves a lightweight copy of the parent feature line tagged `@shadow` in that section, so the hierarchy stays readable across sections. Shadows are created and cleaned up automatically — treat them as derived; don't hand-edit. +Field edits (`set`/`prio`/`link`/status verbs) apply to the primary line **and** +all shadow copies, keeping them in sync. ## Milestones (optional, opt-in) diff --git a/tasks.py b/tasks.py index f1aa132..aad4ba6 100755 --- a/tasks.py +++ b/tasks.py @@ -79,6 +79,8 @@ - `tree` and `show F-N` display a merged view with [Section] labels - `backlog F-N` / `now F-N` collapses all shadows back into one section - Empty shadows are auto-cleaned after moves + - Field edits (set/prio/link/status verbs) apply to the primary line AND all + shadow copies, keeping them in sync - `validate` warns about empty shadows and errors on orphaned shadows Milestones (optional, opt-in) @@ -188,7 +190,7 @@ from pathlib import Path from typing import List, Tuple, Optional, Dict, Set, Iterator -__version__ = "1.1.1" +__version__ = "1.1.2" # Canonical source for `selfupdate` — the published raw URL. `selfupdate` # overwrites this very script with the fetched content, so a NON-default source @@ -1342,76 +1344,89 @@ def _set_single_tag(rest: str, tag: str, value: Optional[str]) -> str: return _join_title_tags(title, tag_tokens) +def find_all_item_lines(lines: List[str], item_id: str) -> List[int]: + """All line indices carrying item_id — primary line first, then shadows. + + Shadow lines are derived copies of the primary (see Shadow Features), so + field edits (tags, priority, status, links) must be applied to every copy + to keep them in sync — editing only the first match in file order can land + on a shadow and leave the canonical line untouched (#17). + """ + primary: List[int] = [] + shadows: List[int] = [] + for i, line in iter_content_lines(lines): + p = parse_item(line) + if p and p[2] == item_id: + (shadows if is_shadow(line) else primary).append(i) + return primary + shadows + + def edit_links(item_id: str, deps_op: Optional[str] = None, deps_list: Optional[str] = None, rel_op: Optional[str] = None, rel_list: Optional[str] = None) -> None: item_id = normalize_id(item_id) lines = load() - for i, line in iter_content_lines(lines): - parsed = parse_item(line) - if not parsed: - continue - indent, box, iid, prio, status, rest = parsed - if iid != item_id: - continue - - # current - cur_deps = parse_id_list(_get_tag_value(rest, TAG_DEPS_RE) or "") - cur_rel = parse_id_list(_get_tag_value(rest, TAG_REL_RE) or "") - - def apply(op: Optional[str], cur: List[str], arg: Optional[str]) -> List[str]: - if not op: - return cur - op = op.lower() - if op == "clear": - return [] - if op == "set": - return parse_id_list(arg or "") - if op == "add": - return sort_ids(unique_preserve(cur + parse_id_list(arg or ""))) - if op == "rm": - rm = set(parse_id_list(arg or "")) - return [x for x in cur if x not in rm] - raise SystemExit(f"Unknown link op '{op}' (use add|rm|set|clear)") - - new_deps = apply(deps_op, cur_deps, deps_list) - new_rel = apply(rel_op, cur_rel, rel_list) + idxs = find_all_item_lines(lines, item_id) + if not idxs: + raise SystemExit(f"Item {item_id} not found") + # Current values come from the primary (first) line; shadows are copies. + src = parse_item(lines[idxs[0]]) + assert src is not None # indices come from parsed item lines + cur_deps = parse_id_list(_get_tag_value(src[5], TAG_DEPS_RE) or "") + cur_rel = parse_id_list(_get_tag_value(src[5], TAG_REL_RE) or "") + + def apply(op: Optional[str], cur: List[str], arg: Optional[str]) -> List[str]: + if not op: + return cur + op = op.lower() + if op == "clear": + return [] + if op == "set": + return parse_id_list(arg or "") + if op == "add": + return sort_ids(unique_preserve(cur + parse_id_list(arg or ""))) + if op == "rm": + rm = set(parse_id_list(arg or "")) + return [x for x in cur if x not in rm] + raise SystemExit(f"Unknown link op '{op}' (use add|rm|set|clear)") + + new_deps = apply(deps_op, cur_deps, deps_list) + new_rel = apply(rel_op, cur_rel, rel_list) + + for i in idxs: # primary + shadow copies stay in sync + parsed = parse_item(lines[i]) + assert parsed is not None + indent, box, iid, prio, status, rest = parsed new_rest = rest if deps_op: new_rest = _set_or_remove_tag(new_rest, "deps", new_deps) if rel_op: new_rest = _set_or_remove_tag(new_rest, "rel", new_rel) - lines[i] = build_item_line(indent, iid, prio, status, new_rest) - save(lines) - print(f"Linked {item_id}") - return - raise SystemExit(f"Item {item_id} not found") + save(lines) + print(f"Linked {item_id}") def set_tag(item_id: str, tag: str, value: str) -> None: - """Set a single-value tag on an item.""" + """Set a single-value tag on an item (primary line and any shadow copies).""" item_id = normalize_id(item_id) lines = load() - for i, line in iter_content_lines(lines): - parsed = parse_item(line) - if not parsed: - continue - indent, box, iid, prio, status, rest = parsed - if iid != item_id: - continue + idxs = find_all_item_lines(lines, item_id) + if not idxs: + raise SystemExit(f"Item {item_id} not found") - new_rest = _set_single_tag(rest, tag, value) - lines[i] = build_item_line(indent, iid, prio, status, new_rest) - save(lines) - print(f"Set @{tag}={value} on {item_id}") - return + for i in idxs: # primary + shadow copies stay in sync (#17) + parsed = parse_item(lines[i]) + assert parsed is not None + indent, box, iid, prio, status, rest = parsed + lines[i] = build_item_line(indent, iid, prio, status, _set_single_tag(rest, tag, value)) - raise SystemExit(f"Item {item_id} not found") + save(lines) + print(f"Set @{tag}={value} on {item_id}") # ------------------------- @@ -2103,18 +2118,15 @@ def cmd_update(item_id: str, *, status: Optional[str] = None, prio: Optional[str if parsed and parsed[2] != item_id and parsed[4] == "doing": lines[i] = normalize_item_line(line, status="todo") - for i, line in iter_content_lines(lines): - parsed = parse_item(line) - if not parsed: - continue - if parsed[2] != item_id: - continue - lines[i] = normalize_item_line(line, status=status, prio=prio) - save(lines) - print(f"Updated {item_id}") - return + idxs = find_all_item_lines(lines, item_id) + if not idxs: + raise SystemExit(f"Item {item_id} not found") - raise SystemExit(f"Item {item_id} not found") + for i in idxs: # primary + shadow copies stay in sync (#17) + lines[i] = normalize_item_line(lines[i], status=status, prio=prio) + + save(lines) + print(f"Updated {item_id}") def cmd_new(args: List[str]): diff --git a/tests/test_tasks_e2e.sh b/tests/test_tasks_e2e.sh index 3735909..e446633 100755 --- a/tests/test_tasks_e2e.sh +++ b/tests/test_tasks_e2e.sh @@ -1424,6 +1424,68 @@ assert_contains "Prose @milestone=foo preserved in the title" "$out" "@milestone out=$(run validate) assert_contains "Validate still passes with prose look-alikes" "$out" "OK" +# ============================================================ +echo -e "\n${YELLOW}=== SCENARIO 49: edits land on the primary line, shadows stay in sync (issue #17) ===${NC}" +# ============================================================ +setup_test_dir +fresh_init > /dev/null + +cat >> TASKS.md << 'MS' + +# Milestones + +- M1 alias=alpha status=active North star +MS + +# Feature in Backlog; starting its task creates a @shadow in Now — which +# PRECEDES the primary in file order (Now section comes first). +run new feature "Shadowed feature" --prio P1 --section Backlog > /dev/null +run new task "Child task" --prio P0 --under F-0001 > /dev/null +run start T-0001 > /dev/null + +out=$(cat TASKS.md) +assert_contains "Shadow exists (precondition)" "$out" "@shadow" + +run set F-0001 --milestone m1 > /dev/null + +# Anchor greps on the real title — the init template's # Meta code block also +# contains a literal (F-0001) example line that must not pollute assertions. +primary=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep -v "@shadow") +shadow=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep "@shadow") +assert_contains "Primary (canonical) line carries the milestone" "$primary" "@milestone=m1" +assert_contains "Shadow copy stays in sync" "$shadow" "@milestone=m1" + +out=$(run list --milestone default) +assert_not_contains "Feature no longer leaks into the default bucket" "$out" "F-0001" + +out=$(run milestone --table) +if echo "$out" | grep -F "M1 (alpha)" | grep -qF "F-0001"; then + PASS=$((PASS + 1)); echo -e " ${GREEN}PASS${NC}: table puts F-0001 on the M1 row" +else + FAIL=$((FAIL + 1)); ERRORS="${ERRORS}\n FAIL: F-0001 missing from M1 table row" + echo -e " ${RED}FAIL${NC}: table puts F-0001 on the M1 row" +fi +if echo "$out" | grep -E "^default" | grep -qF "F-0001"; then + FAIL=$((FAIL + 1)); ERRORS="${ERRORS}\n FAIL: F-0001 still on the default table row" + echo -e " ${RED}FAIL${NC}: F-0001 not on the default table row" +else + PASS=$((PASS + 1)); echo -e " ${GREEN}PASS${NC}: F-0001 not on the default table row" +fi + +# Same first-match bug class: prio and link on a shadowed feature. +run prio F-0001 P0 > /dev/null +primary=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep -v "@shadow") +shadow=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep "@shadow") +assert_contains "prio updates the primary line" "$primary" "[P0]" +assert_contains "prio keeps the shadow in sync" "$shadow" "[P0]" + +run link F-0001 --rel add T-0001 > /dev/null +primary=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep -v "@shadow") +assert_contains "link edits the primary line" "$primary" "@rel=T-0001" + +out=$(run validate) +assert_contains "Validate passes after synced edits" "$out" "OK" + # ============================================================ # Summary # ============================================================