@stylusnexus/work-plan 2026.6.10 → 2026.6.11

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.
Files changed (42) hide show
  1. package/README.md +13 -7
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +6 -4
  5. package/skills/work-plan/commands/canonicalize.py +7 -92
  6. package/skills/work-plan/commands/handoff.py +15 -6
  7. package/skills/work-plan/commands/init.py +13 -3
  8. package/skills/work-plan/commands/init_repo.py +8 -2
  9. package/skills/work-plan/commands/new_track.py +7 -0
  10. package/skills/work-plan/commands/notes_vcs.py +172 -0
  11. package/skills/work-plan/commands/refresh_md.py +106 -37
  12. package/skills/work-plan/commands/rename_track.py +243 -0
  13. package/skills/work-plan/commands/set_notes_root.py +8 -4
  14. package/skills/work-plan/commands/suggest_priorities.py +12 -2
  15. package/skills/work-plan/lib/config.py +11 -0
  16. package/skills/work-plan/lib/frontmatter.py +12 -3
  17. package/skills/work-plan/lib/git_state.py +61 -52
  18. package/skills/work-plan/lib/github_state.py +46 -13
  19. package/skills/work-plan/lib/notes_vcs.py +276 -0
  20. package/skills/work-plan/lib/prompts.py +12 -1
  21. package/skills/work-plan/lib/status_table.py +95 -5
  22. package/skills/work-plan/lib/tracks.py +9 -4
  23. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  24. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  25. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  26. package/skills/work-plan/tests/test_config.py +12 -12
  27. package/skills/work-plan/tests/test_github_state.py +3 -3
  28. package/skills/work-plan/tests/test_init_repo.py +12 -7
  29. package/skills/work-plan/tests/test_new_track.py +7 -7
  30. package/skills/work-plan/tests/test_notes_vcs.py +426 -0
  31. package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
  32. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  33. package/skills/work-plan/tests/test_refresh_md.py +159 -61
  34. package/skills/work-plan/tests/test_rename_track.py +351 -0
  35. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  36. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  37. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  38. package/skills/work-plan/tests/test_status_table.py +61 -0
  39. package/skills/work-plan/tests/test_track_resolution.py +2 -2
  40. package/skills/work-plan/tests/test_tracks.py +4 -4
  41. package/skills/work-plan/work_plan.py +97 -17
  42. /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
@@ -0,0 +1,351 @@
1
+ """Tests for the rename-track command (issue #174).
2
+
3
+ Covers:
4
+ - Renames a private track → file moved (write new, unlink old), frontmatter
5
+ rewritten with track=new_slug + bumped last_touched, rc 0.
6
+ - old@repo / --repo disambiguation flows through find_track_by_name.
7
+ - Invalid new slug → rc 2, no move.
8
+ - new_slug == old slug → rc 2, no move.
9
+ - Target already exists (same repo/tier) → rc 2, no move.
10
+ - Unknown old slug → rc 1, no move.
11
+ - Public repo, no token → needs_confirm JSON, no move, rc 0; token ==
12
+ make_token(repo, new_slug). Valid token → renames.
13
+ - Shared track: --commit stages old+new and commits; without --commit prints
14
+ the 'commit to share' hint and makes no git calls; git failure is non-fatal.
15
+ - Cross-references: sibling depends_on warned by default; --fix-refs rewrites
16
+ them.
17
+ - "rename-track" in SUBCOMMANDS and DESCRIPTIONS.
18
+ """
19
+ import io
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ import unittest
24
+ from contextlib import redirect_stdout
25
+ from pathlib import Path
26
+ from unittest.mock import patch, MagicMock
27
+
28
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
29
+ sys.path.insert(0, str(SKILL_ROOT))
30
+
31
+ from commands import rename_track
32
+ from lib.tracks import Track, AmbiguousTrackError
33
+ from lib.write_guard import make_token
34
+ import work_plan
35
+
36
+
37
+ NOTES_ROOT = "/tmp/fake-notes"
38
+ CLONE_ROOT = "/tmp/fake-clone"
39
+
40
+
41
+ def _cfg():
42
+ return {
43
+ "notes_root": NOTES_ROOT,
44
+ "repos": {"myrepo": {"github": "org/myrepo", "local": None}},
45
+ }
46
+
47
+
48
+ def _track(name, *, repo="org/myrepo", folder="myrepo", tier="private",
49
+ path=None, depends_on=None, meta=None):
50
+ """Build a Track with frontmatter for use as a discover_tracks() return."""
51
+ base_meta = {
52
+ "track": name,
53
+ "status": "active",
54
+ "github": {"repo": repo, "issues": [], "branches": []},
55
+ "depends_on": depends_on or [],
56
+ "last_touched": "2026-01-01T00:00",
57
+ }
58
+ if meta:
59
+ base_meta.update(meta)
60
+ if path is None:
61
+ root = CLONE_ROOT + "/.work-plan" if tier == "shared" else NOTES_ROOT + "/" + folder
62
+ path = f"{root}/{name}.md"
63
+ return Track(
64
+ path=Path(path),
65
+ name=name,
66
+ has_frontmatter=True,
67
+ needs_init=False,
68
+ needs_filing=False,
69
+ repo=repo,
70
+ folder=folder,
71
+ meta=base_meta,
72
+ body=f"# {name}\n",
73
+ tier=tier,
74
+ )
75
+
76
+
77
+ def _drive(args, *, tracks=None, vis="PRIVATE", new_path_exists=False):
78
+ """Run rename_track.run(args) with all external I/O mocked."""
79
+ if tracks is None:
80
+ tracks = [_track("old-feature")]
81
+
82
+ def _path_exists(self):
83
+ if self.suffix == ".md":
84
+ return new_path_exists
85
+ return True
86
+
87
+ with patch("commands.rename_track.load_config", return_value=_cfg()), \
88
+ patch("commands.rename_track.discover_tracks", return_value=tracks), \
89
+ patch("commands.rename_track.write_file") as mw, \
90
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
91
+ patch("pathlib.Path.exists", _path_exists), \
92
+ patch("pathlib.Path.unlink") as munlink:
93
+ buf = io.StringIO()
94
+ with redirect_stdout(buf):
95
+ rc = rename_track.run(args)
96
+ return rc, mw, munlink, buf.getvalue()
97
+
98
+
99
+ class RenameTrackTest(unittest.TestCase):
100
+
101
+ # -- registry --------------------------------------------------------
102
+ def test_registered_in_subcommands(self):
103
+ self.assertIn("rename-track", work_plan.SUBCOMMANDS)
104
+
105
+ def test_appears_in_descriptions(self):
106
+ names = [e[0] for e in work_plan.DESCRIPTIONS]
107
+ self.assertIn("rename-track", names)
108
+
109
+ # -- happy path ------------------------------------------------------
110
+ def test_private_rename_moves_file_and_rewrites_frontmatter(self):
111
+ rc, mw, munlink, out = _drive(["old-feature", "new-feature"])
112
+ self.assertEqual(rc, 0)
113
+ munlink.assert_called_once()
114
+ mw.assert_called_once()
115
+ meta = mw.call_args[0][1]
116
+ self.assertEqual(meta["track"], "new-feature")
117
+ # write_file targets the new path
118
+ self.assertTrue(str(mw.call_args[0][0]).endswith("new-feature.md"))
119
+ self.assertIn("Renamed track", out)
120
+
121
+ def test_last_touched_is_bumped(self):
122
+ rc, mw, munlink, out = _drive(["old-feature", "new-feature"])
123
+ self.assertEqual(rc, 0)
124
+ meta = mw.call_args[0][1]
125
+ self.assertNotEqual(meta["last_touched"], "2026-01-01T00:00")
126
+
127
+ def test_rename_uses_same_directory(self):
128
+ """The new file lands in the same dir as the old (rename, not relocate)."""
129
+ rc, mw, munlink, out = _drive(["old-feature", "new-feature"])
130
+ self.assertEqual(rc, 0)
131
+ # write_file targets the new path in the original's directory; the old
132
+ # file is then unlinked (write-new-then-remove-old, no data-loss window).
133
+ # Compare via Path (not str) so the assertion holds on Windows, where
134
+ # str(Path("/tmp/x")) uses backslashes.
135
+ write_target = Path(mw.call_args[0][0])
136
+ self.assertEqual(write_target, Path(NOTES_ROOT) / "myrepo" / "new-feature.md")
137
+ munlink.assert_called_once()
138
+
139
+ # -- validation ------------------------------------------------------
140
+ def test_invalid_new_slug_rc2(self):
141
+ rc, mw, munlink, out = _drive(["old-feature", "New_Feature"])
142
+ self.assertEqual(rc, 2)
143
+ munlink.assert_not_called()
144
+ mw.assert_not_called()
145
+
146
+ def test_same_slug_rc2(self):
147
+ rc, mw, munlink, out = _drive(["old-feature", "old-feature"])
148
+ self.assertEqual(rc, 2)
149
+ munlink.assert_not_called()
150
+
151
+ def test_target_exists_rc2(self):
152
+ rc, mw, munlink, out = _drive(
153
+ ["old-feature", "taken"], new_path_exists=True
154
+ )
155
+ self.assertEqual(rc, 2)
156
+ munlink.assert_not_called()
157
+ mw.assert_not_called()
158
+
159
+ def test_unknown_old_slug_rc1(self):
160
+ rc, mw, munlink, out = _drive(["does-not-exist", "new-feature"])
161
+ self.assertEqual(rc, 1)
162
+ munlink.assert_not_called()
163
+
164
+ def test_missing_positionals_rc2(self):
165
+ rc, mw, munlink, out = _drive(["only-one"])
166
+ self.assertEqual(rc, 2)
167
+ munlink.assert_not_called()
168
+
169
+ def test_ambiguous_old_slug_rc1(self):
170
+ with patch("commands.rename_track.load_config", return_value=_cfg()), \
171
+ patch("commands.rename_track.discover_tracks", return_value=[]), \
172
+ patch("commands.rename_track.find_track_by_name",
173
+ side_effect=AmbiguousTrackError("old-feature", [])), \
174
+ patch("commands.rename_track.write_file") as mw:
175
+ buf = io.StringIO()
176
+ with redirect_stdout(buf):
177
+ rc = rename_track.run(["old-feature", "new-feature"])
178
+ self.assertEqual(rc, 1)
179
+ mw.assert_not_called()
180
+
181
+ # -- public-repo confirm gate ---------------------------------------
182
+ def test_public_no_token_needs_confirm(self):
183
+ rc, mw, munlink, out = _drive(["old-feature", "new-feature"], vis="PUBLIC")
184
+ self.assertEqual(rc, 0)
185
+ munlink.assert_not_called()
186
+ mw.assert_not_called()
187
+ data = json.loads(out.strip())
188
+ self.assertTrue(data["needs_confirm"])
189
+ self.assertEqual(data["token"], make_token("org/myrepo", "new-feature"))
190
+
191
+ def test_public_valid_token_renames(self):
192
+ tok = make_token("org/myrepo", "new-feature")
193
+ rc, mw, munlink, out = _drive(
194
+ ["old-feature", "new-feature", f"--confirm={tok}"], vis="PUBLIC"
195
+ )
196
+ self.assertEqual(rc, 0)
197
+ munlink.assert_called_once()
198
+ mw.assert_called_once()
199
+
200
+ def test_public_wrong_token_blocked(self):
201
+ rc, mw, munlink, out = _drive(
202
+ ["old-feature", "new-feature", "--confirm=nope"], vis="PUBLIC"
203
+ )
204
+ self.assertEqual(rc, 0)
205
+ munlink.assert_not_called()
206
+
207
+ # -- disambiguation --------------------------------------------------
208
+ def test_repo_qualifier_passed_to_finder(self):
209
+ tracks = [_track("old-feature")]
210
+ with patch("commands.rename_track.load_config", return_value=_cfg()), \
211
+ patch("commands.rename_track.discover_tracks", return_value=tracks), \
212
+ patch("commands.rename_track.find_track_by_name",
213
+ return_value=tracks[0]) as mfind, \
214
+ patch("commands.rename_track.write_file"), \
215
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
216
+ patch("pathlib.Path.exists", lambda self: not str(self).endswith(".md")), \
217
+ patch("pathlib.Path.unlink"):
218
+ buf = io.StringIO()
219
+ with redirect_stdout(buf):
220
+ rename_track.run(["old-feature@myrepo", "new-feature"])
221
+ self.assertEqual(mfind.call_args.kwargs.get("repo"), "myrepo")
222
+
223
+ # -- cross-references ------------------------------------------------
224
+ def test_dependents_warned_by_default(self):
225
+ tracks = [
226
+ _track("old-feature"),
227
+ _track("sibling", depends_on=["old-feature"]),
228
+ ]
229
+ rc, mw, munlink, out = _drive(
230
+ ["old-feature", "new-feature"], tracks=tracks
231
+ )
232
+ self.assertEqual(rc, 0)
233
+ self.assertIn("depend on 'old-feature'", out)
234
+ self.assertIn("--fix-refs", out)
235
+ # Only the renamed track was written, not the sibling.
236
+ self.assertEqual(mw.call_count, 1)
237
+
238
+ def test_fix_refs_rewrites_dependents(self):
239
+ tracks = [
240
+ _track("old-feature"),
241
+ _track("sibling", depends_on=["old-feature", "other"]),
242
+ ]
243
+ rc, mw, munlink, out = _drive(
244
+ ["old-feature", "new-feature", "--fix-refs"], tracks=tracks
245
+ )
246
+ self.assertEqual(rc, 0)
247
+ # Renamed track + the one sibling rewritten.
248
+ self.assertEqual(mw.call_count, 2)
249
+ sibling_write = [c for c in mw.call_args_list
250
+ if str(c[0][0]).endswith("sibling.md")][0]
251
+ self.assertEqual(sibling_write[0][1]["depends_on"], ["new-feature", "other"])
252
+
253
+ def test_dependents_in_other_repo_ignored(self):
254
+ tracks = [
255
+ _track("old-feature"),
256
+ _track("sibling", repo="org/elsewhere", folder="elsewhere",
257
+ depends_on=["old-feature"]),
258
+ ]
259
+ rc, mw, munlink, out = _drive(
260
+ ["old-feature", "new-feature", "--fix-refs"], tracks=tracks
261
+ )
262
+ self.assertEqual(rc, 0)
263
+ # Different repo → not a referrer; only the renamed track written.
264
+ self.assertEqual(mw.call_count, 1)
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Shared-track --commit behavior
269
+ # ---------------------------------------------------------------------------
270
+
271
+ class RenameTrackCommitTest(unittest.TestCase):
272
+
273
+ def _drive_shared(self, args, *, git_returncode=0):
274
+ track = _track("old-feature", tier="shared")
275
+
276
+ def _path_exists(self):
277
+ s = str(self)
278
+ if s == f"{CLONE_ROOT}/.git":
279
+ return True
280
+ if s == CLONE_ROOT:
281
+ return True
282
+ if self.suffix == ".md":
283
+ return False
284
+ return True
285
+
286
+ def _is_dir(self):
287
+ return not str(self).endswith(".md")
288
+
289
+ git_results = [
290
+ MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse
291
+ MagicMock(returncode=git_returncode, stdout="", stderr="err"), # add
292
+ MagicMock(returncode=git_returncode, stdout="", stderr=""), # commit
293
+ ]
294
+ idx = {"n": 0}
295
+
296
+ def _git_run(cmd, **kwargs):
297
+ i = idx["n"]
298
+ idx["n"] += 1
299
+ if git_returncode != 0 and i > 0:
300
+ raise subprocess.CalledProcessError(git_returncode, cmd, stderr="err")
301
+ return git_results[min(i, len(git_results) - 1)]
302
+
303
+ with patch("commands.rename_track.load_config", return_value=_cfg()), \
304
+ patch("commands.rename_track.discover_tracks", return_value=[track]), \
305
+ patch("commands.rename_track.write_file") as mw, \
306
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
307
+ patch("pathlib.Path.exists", _path_exists), \
308
+ patch("pathlib.Path.is_dir", _is_dir), \
309
+ patch("pathlib.Path.unlink"), \
310
+ patch("commands.rename_track.subprocess.run", side_effect=_git_run) as msub:
311
+ buf = io.StringIO()
312
+ with redirect_stdout(buf):
313
+ rc = rename_track.run(args)
314
+ return rc, mw, msub, buf.getvalue()
315
+
316
+ def test_commit_stages_old_and_new_then_commits(self):
317
+ rc, mw, msub, out = self._drive_shared(
318
+ ["old-feature", "new-feature", "--commit"]
319
+ )
320
+ self.assertEqual(rc, 0)
321
+ git_cmds = [c[0][0] for c in msub.call_args_list]
322
+ add_calls = [c for c in git_cmds if "add" in c]
323
+ commit_calls = [c for c in git_cmds if "commit" in c]
324
+ self.assertEqual(len(add_calls), 1)
325
+ self.assertEqual(len(commit_calls), 1)
326
+ # Path-scoped: stages exactly two .md paths (old + new), never ".".
327
+ add_argv = add_calls[0]
328
+ self.assertNotIn(".", add_argv)
329
+ md_args = [a for a in add_argv if a.endswith(".md")]
330
+ self.assertEqual(len(md_args), 2)
331
+ # Commit message names both slugs.
332
+ msg = commit_calls[0][commit_calls[0].index("-m") + 1]
333
+ self.assertIn("old-feature", msg)
334
+ self.assertIn("new-feature", msg)
335
+
336
+ def test_no_commit_flag_prints_hint_no_git(self):
337
+ rc, mw, msub, out = self._drive_shared(["old-feature", "new-feature"])
338
+ self.assertEqual(rc, 0)
339
+ msub.assert_not_called()
340
+ self.assertIn("commit + push to share", out)
341
+
342
+ def test_commit_git_failure_non_fatal(self):
343
+ rc, mw, msub, out = self._drive_shared(
344
+ ["old-feature", "new-feature", "--commit"], git_returncode=1
345
+ )
346
+ self.assertEqual(rc, 0)
347
+ self.assertIn("⚠", out)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ unittest.main()
@@ -15,23 +15,23 @@ class FilterTracksByRepoTest(unittest.TestCase):
15
15
  def setUp(self):
16
16
  self.cfg = {
17
17
  "notes_root": str(FIXTURES),
18
- "repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
18
+ "repos": {"myproject": {"github": "your-org/myproject", "local": None}},
19
19
  }
20
20
  self.tracks = discover_tracks(self.cfg)
21
21
 
22
22
  def test_matches_folder_key(self):
23
- scoped = filter_tracks_by_repo(self.tracks, "critforge")
23
+ scoped = filter_tracks_by_repo(self.tracks, "myproject")
24
24
  names = {t.name for t in scoped}
25
25
  self.assertIn("example", names)
26
26
  self.assertNotIn("loose_at_root", names)
27
27
 
28
28
  def test_matches_github_slug(self):
29
- scoped = filter_tracks_by_repo(self.tracks, "stylusnexus/CritForge")
29
+ scoped = filter_tracks_by_repo(self.tracks, "your-org/myproject")
30
30
  names = {t.name for t in scoped}
31
31
  self.assertIn("example", names)
32
32
 
33
33
  def test_case_insensitive(self):
34
- scoped = filter_tracks_by_repo(self.tracks, "CRITFORGE")
34
+ scoped = filter_tracks_by_repo(self.tracks, "MYPROJECT")
35
35
  names = {t.name for t in scoped}
36
36
  self.assertIn("example", names)
37
37
 
@@ -39,13 +39,13 @@ class FilterTracksByRepoTest(unittest.TestCase):
39
39
  self.assertEqual(filter_tracks_by_repo(self.tracks, "nonexistent"), [])
40
40
 
41
41
  def test_excludes_loose_filing_track(self):
42
- scoped = filter_tracks_by_repo(self.tracks, "critforge")
42
+ scoped = filter_tracks_by_repo(self.tracks, "myproject")
43
43
  for t in scoped:
44
44
  self.assertFalse(t.needs_filing)
45
45
 
46
46
  def test_track_folder_field_populated(self):
47
47
  ex = next(t for t in self.tracks if t.name == "example")
48
- self.assertEqual(ex.folder, "critforge")
48
+ self.assertEqual(ex.folder, "myproject")
49
49
 
50
50
 
51
51
  if __name__ == "__main__":
@@ -0,0 +1,142 @@
1
+ """Security hardening regression tests for the CLI (#191, #192, #194, #195, #196).
2
+
3
+ Covers the guards added in the security-hardening pass:
4
+ - parse_flags honours a `--` end-of-options separator (#194)
5
+ - git_state.is_safe_ref rejects dash-led revs; commits_ahead/branch_exists
6
+ refuse to pass them to git (#192)
7
+ - write_file refuses to write through a symlink (#195)
8
+ - init refuses to write outside notes_root; new_track rejects an unsafe folder
9
+ segment (#195)
10
+ - discover_tracks skips dash-led track filenames (#194)
11
+
12
+ The yq-injection fix (#191) and its env-passing are covered in
13
+ test_set_notes_root / test_init_repo.
14
+ """
15
+ import io
16
+ import os
17
+ import sys
18
+ import tempfile
19
+ import unittest
20
+ from contextlib import redirect_stdout
21
+ from pathlib import Path
22
+ from unittest import mock
23
+
24
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
25
+ sys.path.insert(0, str(SKILL_ROOT))
26
+
27
+ from lib.prompts import parse_flags
28
+ from lib import git_state, frontmatter, tracks
29
+ from commands import init as init_cmd
30
+ from commands import new_track as new_track_cmd
31
+
32
+
33
+ class ParseFlagsEndOfOptionsTest(unittest.TestCase):
34
+ def test_double_dash_makes_following_args_positional(self):
35
+ flags, positional = parse_flags(["--repo=x", "--", "--repo", "foo"], {"--repo"})
36
+ self.assertEqual(flags, {"--repo": "x"})
37
+ # Everything after `--` is positional, even the flag-looking `--repo`.
38
+ self.assertEqual(positional, ["--repo", "foo"])
39
+
40
+ def test_double_dash_itself_is_consumed(self):
41
+ flags, positional = parse_flags(["set", "--", "mytrack"], {"--all"})
42
+ self.assertEqual(positional, ["set", "mytrack"])
43
+
44
+ def test_no_double_dash_unchanged(self):
45
+ flags, positional = parse_flags(["track", "--all"], {"--all"})
46
+ self.assertEqual(flags, {"--all": True})
47
+ self.assertEqual(positional, ["track"])
48
+
49
+
50
+ class IsSafeRefTest(unittest.TestCase):
51
+ def test_rejects_dash_led_and_empty(self):
52
+ for bad in ["--output=/tmp/x", "-rf", "--upload-pack=evil", ""]:
53
+ self.assertFalse(git_state.is_safe_ref(bad), bad)
54
+
55
+ def test_accepts_normal_refs(self):
56
+ for ok in ["main", "origin/main", "feat/123-x", "v1.2.3", "HEAD"]:
57
+ self.assertTrue(git_state.is_safe_ref(ok), ok)
58
+
59
+
60
+ class GitRefGuardTest(unittest.TestCase):
61
+ def test_commits_ahead_refuses_dash_led_branch_without_calling_git(self):
62
+ with mock.patch("lib.git_state.subprocess.run") as msub, \
63
+ mock.patch("lib.git_state.Path.exists", return_value=True):
64
+ out = git_state.commits_ahead("--output=/tmp/poc", "main", Path("/repo"))
65
+ self.assertEqual(out, 0)
66
+ msub.assert_not_called() # never reached git
67
+
68
+ def test_commits_ahead_refuses_dash_led_base(self):
69
+ with mock.patch("lib.git_state.subprocess.run") as msub, \
70
+ mock.patch("lib.git_state.Path.exists", return_value=True):
71
+ out = git_state.commits_ahead("main", "--all", Path("/repo"))
72
+ self.assertEqual(out, 0)
73
+ msub.assert_not_called()
74
+
75
+ def test_branch_exists_refuses_dash_led(self):
76
+ with mock.patch("lib.git_state.subprocess.run") as msub, \
77
+ mock.patch("lib.git_state.Path.exists", return_value=True):
78
+ self.assertFalse(git_state.branch_exists("--verbose", Path("/repo")))
79
+ msub.assert_not_called()
80
+
81
+
82
+ class WriteFileSymlinkGuardTest(unittest.TestCase):
83
+ def test_refuses_to_write_through_symlink(self):
84
+ with tempfile.TemporaryDirectory() as d:
85
+ target = Path(d) / "outside.md"
86
+ target.write_text("original\n", encoding="utf-8")
87
+ link = Path(d) / "track.md"
88
+ link.symlink_to(target)
89
+ with self.assertRaises(ValueError):
90
+ frontmatter.write_file(link, {"track": "x"}, "body")
91
+ # Target must be untouched.
92
+ self.assertEqual(target.read_text(encoding="utf-8"), "original\n")
93
+
94
+ def test_normal_write_still_works(self):
95
+ with tempfile.TemporaryDirectory() as d:
96
+ p = Path(d) / "track.md"
97
+ frontmatter.write_file(p, {}, "hello\n")
98
+ self.assertEqual(p.read_text(encoding="utf-8"), "hello\n")
99
+
100
+
101
+ class InitContainmentTest(unittest.TestCase):
102
+ def test_refuses_path_outside_notes_root(self):
103
+ with tempfile.TemporaryDirectory() as notes, tempfile.TemporaryDirectory() as outside:
104
+ target = Path(outside) / "victim.md"
105
+ target.write_text("do not clobber\n", encoding="utf-8")
106
+ cfg = {"notes_root": notes, "repos": {}}
107
+ with mock.patch("commands.init.load_config", return_value=cfg):
108
+ buf = io.StringIO()
109
+ with redirect_stdout(buf):
110
+ rc = init_cmd.run([str(target)])
111
+ self.assertEqual(rc, 1)
112
+ self.assertIn("not inside notes_root", buf.getvalue())
113
+ # File left untouched (no frontmatter prepended).
114
+ self.assertEqual(target.read_text(encoding="utf-8"), "do not clobber\n")
115
+
116
+
117
+ class NewTrackFolderGuardTest(unittest.TestCase):
118
+ def test_rejects_dotdot_folder_segment(self):
119
+ cfg = {"notes_root": "/tmp/does-not-matter", "repos": {}}
120
+ # new-track <repo> <slug>; repo arg "x/.." derives folder ".." → reject.
121
+ with mock.patch("commands.new_track.load_config", return_value=cfg):
122
+ buf = io.StringIO()
123
+ with redirect_stdout(buf):
124
+ rc = new_track_cmd.run(["x/..", "myslug"])
125
+ self.assertEqual(rc, 2)
126
+ self.assertIn("safe notes folder", buf.getvalue())
127
+
128
+
129
+ class DiscoverTracksDashLedTest(unittest.TestCase):
130
+ def test_dash_led_md_file_is_not_a_track(self):
131
+ with tempfile.TemporaryDirectory() as notes:
132
+ root = Path(notes)
133
+ (root / "good.md").write_text("---\ntrack: good\nstatus: active\n---\nbody\n", encoding="utf-8")
134
+ (root / "--repo.md").write_text("---\ntrack: x\nstatus: active\n---\nbody\n", encoding="utf-8")
135
+ cfg = {"notes_root": notes, "repos": {}}
136
+ found = {t.name for t in tracks.discover_tracks(cfg)}
137
+ self.assertIn("good", found)
138
+ self.assertNotIn("--repo", found)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ unittest.main()
@@ -103,8 +103,12 @@ class SetNotesRootTest(unittest.TestCase):
103
103
  # raw POSIX input string.
104
104
  expected = str(Path("/some/new/path").expanduser().resolve())
105
105
  expr = yq_args[2]
106
- self.assertIn(".notes_root", expr)
107
- self.assertIn(expected, expr)
106
+ # Hardened (#191): the path travels as an OPAQUE env value via strenv(),
107
+ # never interpolated into the yq expression — so a path containing `"`
108
+ # or yq operators can't break out and rewrite arbitrary config keys.
109
+ self.assertEqual(expr, ".notes_root = strenv(WP_NEW_ROOT)")
110
+ self.assertNotIn(expected, expr)
111
+ self.assertEqual(msub.call_args.kwargs["env"]["WP_NEW_ROOT"], expected)
108
112
 
109
113
  # mkdir must have been called (creates the dir)
110
114
  mmkdir.assert_called_once()
@@ -10,6 +10,7 @@ from lib.status_table import (
10
10
  find_status_table, update_row_status, ISSUE_NUM_RE,
11
11
  render_issue_row, append_rows, sync_missing_rows,
12
12
  find_canonical_status_tables, CANONICAL_MARKER,
13
+ render_canonical_table, strip_canonical_block, insert_canonical_block,
13
14
  )
14
15
 
15
16
  FIXTURES = Path(__file__).parent / "fixtures"
@@ -60,6 +61,66 @@ class RenderIssueRowTest(unittest.TestCase):
60
61
  row = render_issue_row(487, "fix the thing", "@alice", "🔲 Open")
61
62
  self.assertEqual(row, "| #487 | fix the thing | @alice | 🔲 Open |")
62
63
 
64
+ def test_milestone_arg_adds_fifth_column(self):
65
+ row = render_issue_row(487, "fix", "@alice", "🔲 Open", milestone="v0.4.0")
66
+ self.assertEqual(row, "| #487 | fix | v0.4.0 | @alice | 🔲 Open |")
67
+
68
+ def test_empty_milestone_still_renders_column(self):
69
+ # "" is distinct from None: the column is present but blank.
70
+ row = render_issue_row(487, "fix", "@alice", "🔲 Open", milestone="")
71
+ self.assertEqual(row, "| #487 | fix | | @alice | 🔲 Open |")
72
+
73
+
74
+ class RenderCanonicalTableTest(unittest.TestCase):
75
+ def _gh(self, num, title, state="OPEN", milestone=None):
76
+ d = {"number": num, "title": title, "state": state, "assignees": []}
77
+ if milestone:
78
+ d["milestone"] = {"title": milestone}
79
+ return d
80
+
81
+ def test_single_milestone_renders_one_table_no_divider(self):
82
+ by = {1: self._gh(1, "a"), 2: self._gh(2, "b")}
83
+ md = render_canonical_table([1, 2], by)
84
+ self.assertIn("| # | Title | Milestone | Assignee | Status |", md)
85
+ self.assertNotIn("| | | | | |", md) # no divider when one group
86
+
87
+ def test_active_milestone_first_with_divider(self):
88
+ by = {
89
+ 10: self._gh(10, "near", milestone="v0.4.0 — MVP"),
90
+ 20: self._gh(20, "far", milestone="v2.0.0 — Post-Launch"),
91
+ 30: self._gh(30, "none"),
92
+ }
93
+ md = render_canonical_table([10, 20, 30], by, milestone_alignment="v2.0.0")
94
+ # Active milestone (v2.0.0 → #20) precedes v0.4.0 (#10) precedes none (#30).
95
+ self.assertLess(md.index("#20"), md.index("#10"))
96
+ self.assertLess(md.index("#10"), md.index("#30"))
97
+ # Divider rows separate the three groups (2 dividers).
98
+ self.assertEqual(md.count("| | | | | |"), 2)
99
+ self.assertIn("| #20 | far | v2.0.0 |", md)
100
+
101
+ def test_strip_and_insert_round_trip(self):
102
+ by = {1: self._gh(1, "a")}
103
+ table = render_canonical_table([1], by)
104
+ body = insert_canonical_block("## Notes\n\nkeep\n", table)
105
+ self.assertTrue(body.startswith("## Issues (canonical)"))
106
+ self.assertIn("## Notes\n\nkeep", body)
107
+ # Stripping removes the canonical block, leaving the narrative.
108
+ stripped = strip_canonical_block(body)
109
+ self.assertNotIn(CANONICAL_MARKER, stripped)
110
+ self.assertIn("## Notes", stripped)
111
+
112
+ def test_insert_replace_swaps_existing_block(self):
113
+ by1 = {1: self._gh(1, "a")}
114
+ body = insert_canonical_block("## Notes\n", render_canonical_table([1], by1))
115
+ by2 = {2: self._gh(2, "b")}
116
+ body2 = insert_canonical_block(body, render_canonical_table([2], by2),
117
+ replace=True)
118
+ # Old table gone, new table present, narrative preserved, exactly one block.
119
+ self.assertNotIn("#1", body2)
120
+ self.assertIn("#2", body2)
121
+ self.assertEqual(body2.count(CANONICAL_MARKER), 1)
122
+ self.assertIn("## Notes", body2)
123
+
63
124
 
64
125
  class AppendRowsTest(unittest.TestCase):
65
126
  def test_inserts_after_last_row_before_narrative(self):
@@ -131,9 +131,9 @@ class FindTrackByNameTest(unittest.TestCase):
131
131
  class ParseTrackRepoArgTest(unittest.TestCase):
132
132
 
133
133
  def test_name_at_repo_splits_correctly(self):
134
- name, repo = parse_track_repo_arg("foo@critforge")
134
+ name, repo = parse_track_repo_arg("foo@myproject")
135
135
  self.assertEqual(name, "foo")
136
- self.assertEqual(repo, "critforge")
136
+ self.assertEqual(repo, "myproject")
137
137
 
138
138
  def test_no_at_returns_original_none(self):
139
139
  name, repo = parse_track_repo_arg("foo")
@@ -41,7 +41,7 @@ class DiscoverTracksTest(unittest.TestCase):
41
41
  def setUp(self):
42
42
  self.cfg = {
43
43
  "notes_root": str(FIXTURES),
44
- "repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
44
+ "repos": {"myproject": {"github": "your-org/myproject", "local": None}},
45
45
  }
46
46
 
47
47
  def test_active_track_discovered(self):
@@ -50,7 +50,7 @@ class DiscoverTracksTest(unittest.TestCase):
50
50
 
51
51
  def test_repo_inferred_from_folder(self):
52
52
  ex = next(t for t in discover_tracks(self.cfg) if t.name == "example")
53
- self.assertEqual(ex.repo, "stylusnexus/CritForge")
53
+ self.assertEqual(ex.repo, "your-org/myproject")
54
54
 
55
55
  def test_no_frontmatter_flagged_needs_init(self):
56
56
  nf = next(t for t in discover_tracks(self.cfg) if t.path.name == "no_frontmatter.md")
@@ -76,7 +76,7 @@ class DiscoverArchivedTracksTest(unittest.TestCase):
76
76
  def setUp(self):
77
77
  self.cfg = {
78
78
  "notes_root": str(FIXTURES),
79
- "repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
79
+ "repos": {"myproject": {"github": "your-org/myproject", "local": None}},
80
80
  }
81
81
 
82
82
  def test_finds_shipped_track_in_archive(self):
@@ -374,7 +374,7 @@ class DiscoverArchivedSharedTest(unittest.TestCase):
374
374
  """Existing notes_root archives continue to be discovered."""
375
375
  cfg = {
376
376
  "notes_root": str(FIXTURES),
377
- "repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
377
+ "repos": {"myproject": {"github": "your-org/myproject", "local": None}},
378
378
  }
379
379
  archived = discover_archived_tracks(cfg)
380
380
  slugs = [a.meta.get("track") for a in archived]