@stylusnexus/work-plan 2026.6.9-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +88 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +42 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +248 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +169 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_group_apply.py +348 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +220 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tests for the --repo=<key> filter shared by brief / refresh-md / reconcile / hygiene."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
|
|
9
|
+
from lib.tracks import discover_tracks, filter_tracks_by_repo
|
|
10
|
+
|
|
11
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FilterTracksByRepoTest(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.cfg = {
|
|
17
|
+
"notes_root": str(FIXTURES),
|
|
18
|
+
"repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
|
|
19
|
+
}
|
|
20
|
+
self.tracks = discover_tracks(self.cfg)
|
|
21
|
+
|
|
22
|
+
def test_matches_folder_key(self):
|
|
23
|
+
scoped = filter_tracks_by_repo(self.tracks, "critforge")
|
|
24
|
+
names = {t.name for t in scoped}
|
|
25
|
+
self.assertIn("example", names)
|
|
26
|
+
self.assertNotIn("loose_at_root", names)
|
|
27
|
+
|
|
28
|
+
def test_matches_github_slug(self):
|
|
29
|
+
scoped = filter_tracks_by_repo(self.tracks, "stylusnexus/CritForge")
|
|
30
|
+
names = {t.name for t in scoped}
|
|
31
|
+
self.assertIn("example", names)
|
|
32
|
+
|
|
33
|
+
def test_case_insensitive(self):
|
|
34
|
+
scoped = filter_tracks_by_repo(self.tracks, "CRITFORGE")
|
|
35
|
+
names = {t.name for t in scoped}
|
|
36
|
+
self.assertIn("example", names)
|
|
37
|
+
|
|
38
|
+
def test_unknown_key_returns_empty(self):
|
|
39
|
+
self.assertEqual(filter_tracks_by_repo(self.tracks, "nonexistent"), [])
|
|
40
|
+
|
|
41
|
+
def test_excludes_loose_filing_track(self):
|
|
42
|
+
scoped = filter_tracks_by_repo(self.tracks, "critforge")
|
|
43
|
+
for t in scoped:
|
|
44
|
+
self.assertFalse(t.needs_filing)
|
|
45
|
+
|
|
46
|
+
def test_track_folder_field_populated(self):
|
|
47
|
+
ex = next(t for t in self.tracks if t.name == "example")
|
|
48
|
+
self.assertEqual(ex.folder, "critforge")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
unittest.main()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Regression tests for the /tmp planting hardening (#18).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- lib.scratch.cache_dir() creates ~/.claude/work-plan/cache/ with mode 0700.
|
|
5
|
+
- commands.group._apply() rejects a batch whose `folder` is not in cfg.
|
|
6
|
+
- commands.suggest_priorities._apply() rejects a batch whose `repo` is not in cfg.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
19
|
+
|
|
20
|
+
from lib import scratch
|
|
21
|
+
from commands import group, suggest_priorities
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# POSIX file mode bits aren't honored on Windows NTFS — os.chmod(0o700) is a
|
|
25
|
+
# no-op for directories there and stat.S_IMODE reports 0o777 regardless. The
|
|
26
|
+
# /tmp planting hardening these tests cover is itself a POSIX concern.
|
|
27
|
+
_POSIX_MODE_ONLY = unittest.skipIf(
|
|
28
|
+
sys.platform == "win32", "POSIX file mode bits not honored on Windows"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CacheDirTest(unittest.TestCase):
|
|
33
|
+
@_POSIX_MODE_ONLY
|
|
34
|
+
def test_creates_with_mode_0700(self):
|
|
35
|
+
with tempfile.TemporaryDirectory() as td:
|
|
36
|
+
with mock.patch.object(scratch.Path, "home", return_value=Path(td)):
|
|
37
|
+
p = scratch.cache_dir()
|
|
38
|
+
self.assertTrue(p.is_dir())
|
|
39
|
+
self.assertEqual(p, Path(td) / ".claude" / "work-plan" / "cache")
|
|
40
|
+
mode = stat.S_IMODE(os.stat(p).st_mode)
|
|
41
|
+
self.assertEqual(mode, 0o700)
|
|
42
|
+
|
|
43
|
+
@_POSIX_MODE_ONLY
|
|
44
|
+
def test_tightens_existing_loose_perms(self):
|
|
45
|
+
with tempfile.TemporaryDirectory() as td:
|
|
46
|
+
existing = Path(td) / ".claude" / "work-plan" / "cache"
|
|
47
|
+
existing.mkdir(parents=True, mode=0o755)
|
|
48
|
+
self.assertEqual(stat.S_IMODE(os.stat(existing).st_mode), 0o755)
|
|
49
|
+
with mock.patch.object(scratch.Path, "home", return_value=Path(td)):
|
|
50
|
+
scratch.cache_dir()
|
|
51
|
+
self.assertEqual(stat.S_IMODE(os.stat(existing).st_mode), 0o700)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GroupApplyValidationTest(unittest.TestCase):
|
|
55
|
+
def test_rejects_folder_not_in_cfg(self):
|
|
56
|
+
with tempfile.TemporaryDirectory() as td:
|
|
57
|
+
cache = Path(td) / "cache"
|
|
58
|
+
cache.mkdir()
|
|
59
|
+
(cache / "groups.json").write_text(json.dumps({
|
|
60
|
+
"repo": "x/y", "folder": "../../etc",
|
|
61
|
+
"milestone": "v1", "issues": [],
|
|
62
|
+
}))
|
|
63
|
+
(cache / "groups.answers.json").write_text("[]")
|
|
64
|
+
with mock.patch.object(group, "_batch_path", return_value=cache / "groups.json"), \
|
|
65
|
+
mock.patch.object(group, "_answers_path", return_value=cache / "groups.answers.json"):
|
|
66
|
+
cfg = {"notes_root": td, "repos": {"legitrepo": {"github": "ok/ok"}}}
|
|
67
|
+
rc = group._apply(cfg)
|
|
68
|
+
self.assertEqual(rc, 1)
|
|
69
|
+
|
|
70
|
+
def test_accepts_folder_in_cfg(self):
|
|
71
|
+
with tempfile.TemporaryDirectory() as td:
|
|
72
|
+
cache = Path(td) / "cache"
|
|
73
|
+
cache.mkdir()
|
|
74
|
+
notes = Path(td) / "notes"
|
|
75
|
+
(notes / "legitrepo").mkdir(parents=True)
|
|
76
|
+
(cache / "groups.json").write_text(json.dumps({
|
|
77
|
+
"repo": "ok/ok", "folder": "legitrepo",
|
|
78
|
+
"milestone": "v1", "issues": [],
|
|
79
|
+
}))
|
|
80
|
+
(cache / "groups.answers.json").write_text("[]")
|
|
81
|
+
with mock.patch.object(group, "_batch_path", return_value=cache / "groups.json"), \
|
|
82
|
+
mock.patch.object(group, "_answers_path", return_value=cache / "groups.answers.json"):
|
|
83
|
+
cfg = {"notes_root": str(notes), "repos": {"legitrepo": {"github": "ok/ok"}}}
|
|
84
|
+
rc = group._apply(cfg)
|
|
85
|
+
self.assertEqual(rc, 0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SuggestPrioritiesApplyValidationTest(unittest.TestCase):
|
|
89
|
+
def test_rejects_repo_not_in_cfg(self):
|
|
90
|
+
with tempfile.TemporaryDirectory() as td:
|
|
91
|
+
cache = Path(td) / "cache"
|
|
92
|
+
cache.mkdir()
|
|
93
|
+
(cache / "priorities.json").write_text(json.dumps({
|
|
94
|
+
"repo": "attacker/target", "issues": [],
|
|
95
|
+
}))
|
|
96
|
+
(cache / "priorities.answers.json").write_text("[]")
|
|
97
|
+
with mock.patch.object(suggest_priorities, "_batch_path", return_value=cache / "priorities.json"):
|
|
98
|
+
cfg = {"repos": {"legitrepo": {"github": "ok/ok"}}}
|
|
99
|
+
rc = suggest_priorities._apply(cfg)
|
|
100
|
+
self.assertEqual(rc, 1)
|
|
101
|
+
|
|
102
|
+
def test_accepts_repo_in_cfg(self):
|
|
103
|
+
with tempfile.TemporaryDirectory() as td:
|
|
104
|
+
cache = Path(td) / "cache"
|
|
105
|
+
cache.mkdir()
|
|
106
|
+
(cache / "priorities.json").write_text(json.dumps({
|
|
107
|
+
"repo": "ok/ok", "issues": [],
|
|
108
|
+
}))
|
|
109
|
+
(cache / "priorities.answers.json").write_text("[]")
|
|
110
|
+
with mock.patch.object(suggest_priorities, "_batch_path", return_value=cache / "priorities.json"):
|
|
111
|
+
cfg = {"repos": {"legitrepo": {"github": "ok/ok"}}}
|
|
112
|
+
rc = suggest_priorities._apply(cfg)
|
|
113
|
+
self.assertEqual(rc, 0)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tests for session_log."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
|
|
9
|
+
from lib.session_log import append_session_log, SESSION_LOG_HEADER
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppendSessionLogTest(unittest.TestCase):
|
|
13
|
+
def test_appends_under_existing_section(self):
|
|
14
|
+
body = (
|
|
15
|
+
"# Track\n\nProse.\n\n"
|
|
16
|
+
f"{SESSION_LOG_HEADER}\n\n"
|
|
17
|
+
"### Session — 2026-04-23 22:14\n\n- Touched: prior\n"
|
|
18
|
+
)
|
|
19
|
+
new = append_session_log(
|
|
20
|
+
body, timestamp="2026-04-28 18:30",
|
|
21
|
+
touched=["#4254 polls"], next_up=["#925 wmsr"], blockers=[],
|
|
22
|
+
)
|
|
23
|
+
self.assertIn("### Session — 2026-04-28 18:30", new)
|
|
24
|
+
self.assertIn("### Session — 2026-04-23 22:14", new)
|
|
25
|
+
self.assertIn("- Touched: #4254 polls", new)
|
|
26
|
+
|
|
27
|
+
def test_creates_section_when_missing(self):
|
|
28
|
+
body = "# Track\n\nProse.\n"
|
|
29
|
+
new = append_session_log(
|
|
30
|
+
body, timestamp="2026-04-28 18:30",
|
|
31
|
+
touched=["#1 foo"], next_up=["#2 bar"],
|
|
32
|
+
blockers=[{"number": 3, "reason": "waiting"}],
|
|
33
|
+
)
|
|
34
|
+
self.assertIn(SESSION_LOG_HEADER, new)
|
|
35
|
+
self.assertIn("- Blocker: #3 — waiting", new)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
unittest.main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# tests/test_set_field.py
|
|
2
|
+
import io, sys, unittest
|
|
3
|
+
from contextlib import redirect_stdout
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
from commands import set_field
|
|
9
|
+
from lib.write_guard import make_token
|
|
10
|
+
|
|
11
|
+
def _t(name="ph", repo="o/r"):
|
|
12
|
+
return SimpleNamespace(name=name, repo=repo, path=Path(f"/tmp/{name}.md"),
|
|
13
|
+
has_frontmatter=True, meta={"status":"active","github":{"repo":repo}}, body="# b")
|
|
14
|
+
|
|
15
|
+
def _drive(args, vis="PRIVATE", cfg=None):
|
|
16
|
+
base_cfg = {"notes_root": "/tmp"}
|
|
17
|
+
if cfg is not None:
|
|
18
|
+
base_cfg.update(cfg)
|
|
19
|
+
with patch("commands.set_field.load_config", return_value=base_cfg), \
|
|
20
|
+
patch("commands.set_field.discover_tracks", return_value=[_t()]), \
|
|
21
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
22
|
+
patch("commands.set_field.write_file") as mw:
|
|
23
|
+
buf = io.StringIO()
|
|
24
|
+
with redirect_stdout(buf):
|
|
25
|
+
rc = set_field.run(args)
|
|
26
|
+
return rc, mw, buf.getvalue()
|
|
27
|
+
|
|
28
|
+
class SetFieldTest(unittest.TestCase):
|
|
29
|
+
def test_sets_status_private(self):
|
|
30
|
+
rc, mw, out = _drive(["ph", "status=parked"])
|
|
31
|
+
self.assertEqual(rc, 0); mw.assert_called_once()
|
|
32
|
+
self.assertEqual(mw.call_args[0][1]["status"], "parked")
|
|
33
|
+
def test_public_blocks_without_confirm(self):
|
|
34
|
+
rc, mw, out = _drive(["ph", "status=parked"], vis="PUBLIC")
|
|
35
|
+
self.assertEqual(rc, 0); mw.assert_not_called()
|
|
36
|
+
self.assertIn("needs_confirm", out)
|
|
37
|
+
def test_public_with_valid_confirm_writes(self):
|
|
38
|
+
tok = make_token("o/r", "ph")
|
|
39
|
+
rc, mw, out = _drive(["ph", "status=parked", f"--confirm={tok}"], vis="PUBLIC")
|
|
40
|
+
self.assertEqual(rc, 0); mw.assert_called_once()
|
|
41
|
+
self.assertEqual(mw.call_args[0][1]["status"], "parked")
|
|
42
|
+
def test_rejects_unknown_field(self):
|
|
43
|
+
rc, mw, out = _drive(["ph", "bogus=x"])
|
|
44
|
+
self.assertEqual(rc, 2); mw.assert_not_called()
|
|
45
|
+
def test_rejects_invalid_status(self):
|
|
46
|
+
rc, mw, out = _drive(["ph", "status=nonsense"])
|
|
47
|
+
self.assertEqual(rc, 2); mw.assert_not_called()
|
|
48
|
+
def test_rejects_non_integer_blockers(self):
|
|
49
|
+
rc, mw, out = _drive(["ph", "blockers=abc"])
|
|
50
|
+
self.assertEqual(rc, 2); mw.assert_not_called()
|
|
51
|
+
def test_repoless_track_writes_without_confirm(self):
|
|
52
|
+
with patch("commands.set_field.load_config", return_value={"notes_root":"/tmp"}), \
|
|
53
|
+
patch("commands.set_field.discover_tracks", return_value=[_t(repo=None)]), \
|
|
54
|
+
patch("commands.set_field.write_file") as mw:
|
|
55
|
+
buf = io.StringIO()
|
|
56
|
+
with redirect_stdout(buf):
|
|
57
|
+
rc = set_field.run(["ph", "status=parked"])
|
|
58
|
+
self.assertEqual(rc, 0); mw.assert_called_once()
|
|
59
|
+
|
|
60
|
+
# --- assume_private_when_unknown: caller-level integration ---
|
|
61
|
+
def test_unknown_vis_with_flag_writes(self):
|
|
62
|
+
"""Unknown visibility + assume_private_when_unknown=True → write proceeds."""
|
|
63
|
+
rc, mw, out = _drive(
|
|
64
|
+
["ph", "status=parked"],
|
|
65
|
+
vis=None,
|
|
66
|
+
cfg={"assume_private_when_unknown": True},
|
|
67
|
+
)
|
|
68
|
+
self.assertEqual(rc, 0)
|
|
69
|
+
mw.assert_called_once()
|
|
70
|
+
self.assertNotIn("needs_confirm", out)
|
|
71
|
+
|
|
72
|
+
def test_unknown_vis_without_flag_emits_needs_confirm(self):
|
|
73
|
+
"""Unknown visibility + no flag → still emits needs_confirm JSON."""
|
|
74
|
+
rc, mw, out = _drive(["ph", "status=parked"], vis=None)
|
|
75
|
+
self.assertEqual(rc, 0)
|
|
76
|
+
mw.assert_not_called()
|
|
77
|
+
self.assertIn("needs_confirm", out)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Tests for the non-interactive set-notes-root command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- set-notes-root /some/new/path → yq called with correct expression, mkdir
|
|
5
|
+
called, rc 0.
|
|
6
|
+
- Missing positional path → rc 2, no yq call.
|
|
7
|
+
- Orphan warning: current notes_root differs and discover_tracks returns ≥1
|
|
8
|
+
track → WARN line printed but rc 0 and yq still called.
|
|
9
|
+
- No warning when new path equals current notes_root.
|
|
10
|
+
- No warning when discover_tracks returns no tracks.
|
|
11
|
+
- yq failure (CalledProcessError) → rc 1.
|
|
12
|
+
- Subcommand registered in SUBCOMMANDS and DESCRIPTIONS.
|
|
13
|
+
- Non-interactive guard: input()/prompt_input patched to raise, must not fire.
|
|
14
|
+
"""
|
|
15
|
+
import io
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import unittest
|
|
19
|
+
from contextlib import redirect_stdout
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from unittest.mock import patch, MagicMock, call
|
|
22
|
+
|
|
23
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
25
|
+
|
|
26
|
+
import work_plan
|
|
27
|
+
from commands import set_notes_root
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Helpers
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _make_cfg(*, notes_root="/tmp/old-notes"):
|
|
35
|
+
return {"notes_root": notes_root, "repos": {}}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fake_track():
|
|
39
|
+
"""Return a minimal Track-like object (just needs to be truthy in a list)."""
|
|
40
|
+
t = MagicMock()
|
|
41
|
+
t.has_frontmatter = True
|
|
42
|
+
return t
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _drive(args, *, cfg_notes_root="/tmp/old-notes", tracks=None, yq_raises=False):
|
|
46
|
+
"""Run set_notes_root.run(args) with all external I/O mocked.
|
|
47
|
+
|
|
48
|
+
cfg_notes_root: the current notes_root recorded in config.
|
|
49
|
+
tracks: list returned by discover_tracks (default []).
|
|
50
|
+
yq_raises: if True, subprocess.run raises CalledProcessError.
|
|
51
|
+
"""
|
|
52
|
+
cfg = _make_cfg(notes_root=cfg_notes_root)
|
|
53
|
+
if tracks is None:
|
|
54
|
+
tracks = []
|
|
55
|
+
|
|
56
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
57
|
+
|
|
58
|
+
if yq_raises:
|
|
59
|
+
err = subprocess.CalledProcessError(1, ["yq"], stderr="yq error")
|
|
60
|
+
sub_side = err
|
|
61
|
+
else:
|
|
62
|
+
sub_side = None
|
|
63
|
+
|
|
64
|
+
with patch("commands.set_notes_root.load_config", return_value=cfg), \
|
|
65
|
+
patch("commands.set_notes_root.discover_tracks", return_value=tracks), \
|
|
66
|
+
patch("commands.set_notes_root.subprocess.run",
|
|
67
|
+
return_value=mock_proc,
|
|
68
|
+
side_effect=sub_side) as msub, \
|
|
69
|
+
patch("pathlib.Path.mkdir") as mmkdir:
|
|
70
|
+
buf = io.StringIO()
|
|
71
|
+
with redirect_stdout(buf):
|
|
72
|
+
rc = set_notes_root.run(args)
|
|
73
|
+
return rc, msub, mmkdir, buf.getvalue()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Test cases
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
class SetNotesRootTest(unittest.TestCase):
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Happy path: updates config and creates dir
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def test_happy_path_calls_yq_and_mkdir(self):
|
|
87
|
+
"""set-notes-root /some/new/path → yq -i called with the absolute path
|
|
88
|
+
expression, mkdir called, rc 0."""
|
|
89
|
+
rc, msub, mmkdir, out = _drive(
|
|
90
|
+
["/some/new/path"],
|
|
91
|
+
cfg_notes_root="/tmp/old-notes",
|
|
92
|
+
)
|
|
93
|
+
self.assertEqual(rc, 0)
|
|
94
|
+
|
|
95
|
+
# yq must have been called
|
|
96
|
+
msub.assert_called_once()
|
|
97
|
+
yq_args = msub.call_args[0][0]
|
|
98
|
+
self.assertEqual(yq_args[0], "yq")
|
|
99
|
+
self.assertEqual(yq_args[1], "-i")
|
|
100
|
+
# Expression must set .notes_root to the absolute path. The command
|
|
101
|
+
# resolves the input to an absolute path, which on Windows uses a drive
|
|
102
|
+
# letter + backslashes — so compare against the same resolution, not the
|
|
103
|
+
# raw POSIX input string.
|
|
104
|
+
expected = str(Path("/some/new/path").expanduser().resolve())
|
|
105
|
+
expr = yq_args[2]
|
|
106
|
+
self.assertIn(".notes_root", expr)
|
|
107
|
+
self.assertIn(expected, expr)
|
|
108
|
+
|
|
109
|
+
# mkdir must have been called (creates the dir)
|
|
110
|
+
mmkdir.assert_called_once()
|
|
111
|
+
|
|
112
|
+
# Success confirmation in output
|
|
113
|
+
self.assertIn("✓", out)
|
|
114
|
+
self.assertIn(expected, out)
|
|
115
|
+
|
|
116
|
+
def test_yq_receives_config_path_as_last_arg(self):
|
|
117
|
+
"""yq -i call passes DEFAULT_CONFIG_PATH as the file argument."""
|
|
118
|
+
from lib.config import DEFAULT_CONFIG_PATH
|
|
119
|
+
rc, msub, mmkdir, out = _drive(["/new/path"])
|
|
120
|
+
self.assertEqual(rc, 0)
|
|
121
|
+
yq_args = msub.call_args[0][0]
|
|
122
|
+
self.assertEqual(yq_args[-1], str(DEFAULT_CONFIG_PATH))
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Missing positional → rc 2
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def test_missing_path_returns_rc2(self):
|
|
129
|
+
"""No positional argument → rc 2, yq NOT called."""
|
|
130
|
+
rc, msub, mmkdir, out = _drive([])
|
|
131
|
+
self.assertEqual(rc, 2)
|
|
132
|
+
msub.assert_not_called()
|
|
133
|
+
self.assertIn("usage", out.lower())
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Orphan warning: tracks exist at old root
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_orphan_warning_when_tracks_exist_and_root_differs(self):
|
|
140
|
+
"""New path differs from current, discover_tracks returns 1 track →
|
|
141
|
+
WARN line printed, rc 0, yq still called."""
|
|
142
|
+
tracks = [_fake_track()]
|
|
143
|
+
rc, msub, mmkdir, out = _drive(
|
|
144
|
+
["/some/new/path"],
|
|
145
|
+
cfg_notes_root="/tmp/old-notes",
|
|
146
|
+
tracks=tracks,
|
|
147
|
+
)
|
|
148
|
+
self.assertEqual(rc, 0)
|
|
149
|
+
msub.assert_called_once()
|
|
150
|
+
self.assertIn("WARN", out)
|
|
151
|
+
|
|
152
|
+
def test_orphan_warning_names_count(self):
|
|
153
|
+
"""Orphan warning mentions the track count."""
|
|
154
|
+
tracks = [_fake_track(), _fake_track(), _fake_track()]
|
|
155
|
+
rc, msub, mmkdir, out = _drive(
|
|
156
|
+
["/brand/new/path"],
|
|
157
|
+
cfg_notes_root="/tmp/old-notes",
|
|
158
|
+
tracks=tracks,
|
|
159
|
+
)
|
|
160
|
+
self.assertEqual(rc, 0)
|
|
161
|
+
self.assertIn("3", out)
|
|
162
|
+
|
|
163
|
+
def test_orphan_warning_mentions_not_moved(self):
|
|
164
|
+
"""Orphan warning states tracks will NOT be moved."""
|
|
165
|
+
tracks = [_fake_track()]
|
|
166
|
+
rc, msub, mmkdir, out = _drive(
|
|
167
|
+
["/brand/new/path"],
|
|
168
|
+
cfg_notes_root="/tmp/old-notes",
|
|
169
|
+
tracks=tracks,
|
|
170
|
+
)
|
|
171
|
+
self.assertIn("WARN", out)
|
|
172
|
+
# The warning should communicate non-movement
|
|
173
|
+
out_lower = out.lower()
|
|
174
|
+
self.assertTrue(
|
|
175
|
+
"not" in out_lower or "won't" in out_lower or "manual" in out_lower,
|
|
176
|
+
f"Expected move-warning language in: {out!r}",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# No warning when new path equals current notes_root
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def test_no_warning_when_path_unchanged(self):
|
|
184
|
+
"""New path resolves to same location as current → no WARN, rc 0."""
|
|
185
|
+
tracks = [_fake_track()]
|
|
186
|
+
rc, msub, mmkdir, out = _drive(
|
|
187
|
+
["/tmp/old-notes"],
|
|
188
|
+
cfg_notes_root="/tmp/old-notes",
|
|
189
|
+
tracks=tracks,
|
|
190
|
+
)
|
|
191
|
+
self.assertEqual(rc, 0)
|
|
192
|
+
self.assertNotIn("WARN", out)
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# No warning when there are no tracks
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def test_no_warning_when_no_tracks(self):
|
|
199
|
+
"""Paths differ but no tracks → no WARN, rc 0."""
|
|
200
|
+
rc, msub, mmkdir, out = _drive(
|
|
201
|
+
["/some/new/path"],
|
|
202
|
+
cfg_notes_root="/tmp/old-notes",
|
|
203
|
+
tracks=[],
|
|
204
|
+
)
|
|
205
|
+
self.assertEqual(rc, 0)
|
|
206
|
+
self.assertNotIn("WARN", out)
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# yq failure → rc 1
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def test_yq_failure_returns_rc1(self):
|
|
213
|
+
"""CalledProcessError from yq → error message printed, rc 1."""
|
|
214
|
+
rc, msub, mmkdir, out = _drive(
|
|
215
|
+
["/some/new/path"],
|
|
216
|
+
yq_raises=True,
|
|
217
|
+
)
|
|
218
|
+
self.assertEqual(rc, 1)
|
|
219
|
+
self.assertIn("ERROR", out)
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# Subcommand registration
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def test_subcommand_registered_in_subcommands(self):
|
|
226
|
+
"""'set-notes-root' appears in work_plan.SUBCOMMANDS."""
|
|
227
|
+
self.assertIn("set-notes-root", work_plan.SUBCOMMANDS)
|
|
228
|
+
|
|
229
|
+
def test_subcommand_registered_in_descriptions(self):
|
|
230
|
+
"""'set-notes-root' appears in work_plan.DESCRIPTIONS."""
|
|
231
|
+
names = [entry[0] for entry in work_plan.DESCRIPTIONS]
|
|
232
|
+
self.assertIn("set-notes-root", names)
|
|
233
|
+
|
|
234
|
+
def test_subcommand_module_path(self):
|
|
235
|
+
"""SUBCOMMANDS['set-notes-root'] points to commands.set_notes_root."""
|
|
236
|
+
self.assertEqual(
|
|
237
|
+
work_plan.SUBCOMMANDS["set-notes-root"],
|
|
238
|
+
"commands.set_notes_root",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
# Non-interactive guard
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def test_no_input_called_happy_path(self):
|
|
246
|
+
"""Happy path must not call input() or prompt_input."""
|
|
247
|
+
cfg = _make_cfg(notes_root="/tmp/old-notes")
|
|
248
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
249
|
+
|
|
250
|
+
def _raise(*a, **kw):
|
|
251
|
+
raise AssertionError(
|
|
252
|
+
"input() must not be called — command must be non-interactive"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
256
|
+
patch("lib.prompts.prompt_input", side_effect=_raise), \
|
|
257
|
+
patch("lib.prompts.prompt_yes_no", side_effect=_raise), \
|
|
258
|
+
patch("commands.set_notes_root.load_config", return_value=cfg), \
|
|
259
|
+
patch("commands.set_notes_root.discover_tracks", return_value=[]), \
|
|
260
|
+
patch("commands.set_notes_root.subprocess.run", return_value=mock_proc), \
|
|
261
|
+
patch("pathlib.Path.mkdir"):
|
|
262
|
+
buf = io.StringIO()
|
|
263
|
+
with redirect_stdout(buf):
|
|
264
|
+
rc = set_notes_root.run(["/some/path"])
|
|
265
|
+
self.assertEqual(rc, 0)
|
|
266
|
+
|
|
267
|
+
def test_no_input_called_with_tracks(self):
|
|
268
|
+
"""Orphan warning path must also not call input()."""
|
|
269
|
+
cfg = _make_cfg(notes_root="/tmp/old-notes")
|
|
270
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
271
|
+
tracks = [_fake_track()]
|
|
272
|
+
|
|
273
|
+
def _raise(*a, **kw):
|
|
274
|
+
raise AssertionError(
|
|
275
|
+
"input() must not be called — command must be non-interactive"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
279
|
+
patch("lib.prompts.prompt_input", side_effect=_raise), \
|
|
280
|
+
patch("lib.prompts.prompt_yes_no", side_effect=_raise), \
|
|
281
|
+
patch("commands.set_notes_root.load_config", return_value=cfg), \
|
|
282
|
+
patch("commands.set_notes_root.discover_tracks", return_value=tracks), \
|
|
283
|
+
patch("commands.set_notes_root.subprocess.run", return_value=mock_proc), \
|
|
284
|
+
patch("pathlib.Path.mkdir"):
|
|
285
|
+
buf = io.StringIO()
|
|
286
|
+
with redirect_stdout(buf):
|
|
287
|
+
rc = set_notes_root.run(["/some/new/path"])
|
|
288
|
+
self.assertEqual(rc, 0)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
unittest.main()
|