@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.
- package/README.md +13 -7
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +6 -4
- package/skills/work-plan/commands/canonicalize.py +7 -92
- package/skills/work-plan/commands/handoff.py +15 -6
- package/skills/work-plan/commands/init.py +13 -3
- package/skills/work-plan/commands/init_repo.py +8 -2
- package/skills/work-plan/commands/new_track.py +7 -0
- package/skills/work-plan/commands/notes_vcs.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +106 -37
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- package/skills/work-plan/lib/config.py +11 -0
- package/skills/work-plan/lib/frontmatter.py +12 -3
- package/skills/work-plan/lib/git_state.py +61 -52
- package/skills/work-plan/lib/github_state.py +46 -13
- package/skills/work-plan/lib/notes_vcs.py +276 -0
- package/skills/work-plan/lib/prompts.py +12 -1
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/lib/tracks.py +9 -4
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
- package/skills/work-plan/tests/test_config.py +12 -12
- package/skills/work-plan/tests/test_github_state.py +3 -3
- package/skills/work-plan/tests/test_init_repo.py +12 -7
- package/skills/work-plan/tests/test_new_track.py +7 -7
- package/skills/work-plan/tests/test_notes_vcs.py +426 -0
- package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
- package/skills/work-plan/tests/test_refresh_md.py +159 -61
- package/skills/work-plan/tests/test_rename_track.py +351 -0
- package/skills/work-plan/tests/test_repo_filter.py +6 -6
- package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
- package/skills/work-plan/tests/test_set_notes_root.py +6 -2
- package/skills/work-plan/tests/test_status_table.py +61 -0
- package/skills/work-plan/tests/test_track_resolution.py +2 -2
- package/skills/work-plan/tests/test_tracks.py +4 -4
- package/skills/work-plan/work_plan.py +97 -17
- /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": {"
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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
|
-
|
|
107
|
-
|
|
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@
|
|
134
|
+
name, repo = parse_track_repo_arg("foo@myproject")
|
|
135
135
|
self.assertEqual(name, "foo")
|
|
136
|
-
self.assertEqual(repo, "
|
|
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": {"
|
|
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, "
|
|
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": {"
|
|
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": {"
|
|
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]
|