@ikon85/agent-workflow-kit 0.37.0 → 0.39.0

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 (44) hide show
  1. package/.agents/skills/kit-update/SKILL.md +33 -1
  2. package/.agents/skills/orchestrate-wave/SKILL.md +5 -5
  3. package/.agents/skills/setup-workflow/SKILL.md +42 -4
  4. package/.agents/skills/setup-workflow/board-sync.md +6 -2
  5. package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
  6. package/.agents/skills/setup-workflow/worktree-lifecycle.md +47 -1
  7. package/.agents/skills/wrapup/SKILL.md +46 -0
  8. package/.claude/hooks/drift-guard.py +212 -21
  9. package/.claude/skills/kit-update/SKILL.md +33 -1
  10. package/.claude/skills/orchestrate-wave/SKILL.md +5 -5
  11. package/.claude/skills/setup-workflow/SKILL.md +42 -4
  12. package/.claude/skills/setup-workflow/board-sync.md +6 -2
  13. package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
  14. package/.claude/skills/setup-workflow/worktree-lifecycle.md +47 -1
  15. package/.claude/skills/wrapup/SKILL.md +46 -0
  16. package/README.md +73 -0
  17. package/agent-workflow-kit.package.json +49 -25
  18. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
  19. package/docs/agents/workflow-capabilities.json +11 -1
  20. package/package.json +1 -1
  21. package/scripts/board_bootstrap.py +367 -0
  22. package/scripts/kit-update-pr.mjs +20 -7
  23. package/scripts/kit-update-pr.test.mjs +29 -0
  24. package/scripts/profile_globs.py +347 -0
  25. package/scripts/test_board_bootstrap.py +348 -0
  26. package/scripts/test_drift_guard_diagnostics.py +295 -0
  27. package/scripts/test_orchestrate_wave_contract.py +9 -0
  28. package/scripts/test_profile_globs.py +280 -0
  29. package/scripts/test_skill_setup_workflow_seeds.py +87 -0
  30. package/scripts/test_worktree_wrapup_contract.py +1592 -3
  31. package/scripts/workflow-advisories/core.py +29 -4
  32. package/scripts/worktree-lifecycle/README.md +139 -0
  33. package/scripts/worktree-lifecycle/capabilities.json +9 -1
  34. package/scripts/worktree-lifecycle/cleanup.py +22 -51
  35. package/scripts/worktree-lifecycle/core.py +1206 -5
  36. package/scripts/worktree-lifecycle/profile.py +38 -3
  37. package/scripts/worktree-lifecycle/session.py +1857 -0
  38. package/scripts/worktree-lifecycle/setup.py +15 -0
  39. package/scripts/wrapup-land.py +461 -14
  40. package/src/cli.mjs +32 -1
  41. package/src/commands/update.mjs +30 -21
  42. package/src/consumer-migrations.json +19 -0
  43. package/src/lib/bundle.mjs +11 -0
  44. package/src/lib/consumerMigrations.mjs +161 -0
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python3
2
+ """Diagnostics contract for the handoff drift guard.
3
+
4
+ Three independent gaps, one guard:
5
+
6
+ * the issue anchor may only come from the handoff repository's OWN issues —
7
+ a link to a foreign repository's issue must never become the anchor;
8
+ * a census block must name the checkout it evaluated, and say so explicitly
9
+ when the session sits in a different worktree of the same repository;
10
+ * `--census-status` must report WHAT drifted, bounded, not only that the
11
+ topology fingerprint moved.
12
+ """
13
+ import importlib.util
14
+ import json
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import unittest
19
+ from pathlib import Path
20
+ from unittest.mock import patch
21
+
22
+
23
+ ROOT = Path(__file__).resolve().parents[1]
24
+ HOOKS = ROOT / ".claude" / "hooks"
25
+ sys.path.insert(0, str(HOOKS))
26
+ SPEC = importlib.util.spec_from_file_location("drift_guard", HOOKS / "drift-guard.py")
27
+ DRIFT_GUARD = importlib.util.module_from_spec(SPEC)
28
+ SPEC.loader.exec_module(DRIFT_GUARD)
29
+
30
+ GIT_IDENTITY = [
31
+ "-c", "user.name=drift-guard-test",
32
+ "-c", "user.email=drift-guard@example.invalid",
33
+ "-c", "commit.gpgsign=false",
34
+ ]
35
+
36
+
37
+ class DriftGuardDiagnosticsTest(unittest.TestCase):
38
+ def make_repo(self, remote=None):
39
+ temporary = tempfile.TemporaryDirectory(prefix="awk-drift-guard-")
40
+ root = Path(temporary.name)
41
+ self.addCleanup(temporary.cleanup)
42
+ (root / "src").mkdir()
43
+ (root / "package.json").write_text('{"name":"consumer"}\n', encoding="utf-8")
44
+ (root / "src" / "index.mjs").write_text("export const ready = true;\n", encoding="utf-8")
45
+ subprocess.run(["git", "init", "--quiet"], cwd=root, check=True)
46
+ subprocess.run(["git", "add", "."], cwd=root, check=True)
47
+ if remote:
48
+ subprocess.run(["git", "remote", "add", "origin", remote], cwd=root, check=True)
49
+ return root
50
+
51
+ def enable(self, root):
52
+ census = root / ".census"
53
+ census.mkdir(exist_ok=True)
54
+ (census / "profile.json").write_text(json.dumps({
55
+ "schemaVersion": 1,
56
+ "enabled": True,
57
+ "decisions": [],
58
+ "localScanners": [],
59
+ "overrides": [],
60
+ }) + "\n", encoding="utf-8")
61
+
62
+ def activate_current(self, root):
63
+ fresh = DRIFT_GUARD.scan_census_status(root)["fresh"]
64
+ (root / ".census" / "active.json").write_text(
65
+ json.dumps(fresh) + "\n", encoding="utf-8"
66
+ )
67
+
68
+ def write_payload(self, root, name, content):
69
+ handoff = root / ".handoff"
70
+ handoff.mkdir(exist_ok=True)
71
+ return {
72
+ "tool_name": "Write",
73
+ "tool_input": {"file_path": str(handoff / name), "content": content},
74
+ }
75
+
76
+ def census_status(self, root, *extra):
77
+ completed = subprocess.run(
78
+ [sys.executable, str(HOOKS / "drift-guard.py"), "--census-status", *extra],
79
+ cwd=root, capture_output=True, check=True, text=True,
80
+ )
81
+ return json.loads(completed.stdout)
82
+
83
+ # --- anchor extraction ------------------------------------------------
84
+
85
+ def test_foreign_repository_issue_link_never_becomes_the_anchor(self):
86
+ root = self.make_repo(remote="git@github.com:acme/consumer.git")
87
+ content = (
88
+ "Refresh landed as chore PR "
89
+ "[#2281](https://github.com/acme/consumer/pull/2281).\n"
90
+ "Reported upstream: "
91
+ "[kit#276](https://github.com/iKon85/agent-workflow-kit/issues/276)\n"
92
+ )
93
+
94
+ issue = DRIFT_GUARD.extract_issue(
95
+ self.write_payload(root, "2026-07-26-2279.md", content), content, root
96
+ )
97
+
98
+ self.assertEqual(issue, 2279)
99
+
100
+ def test_own_repository_content_anchor_still_wins_over_the_filename(self):
101
+ for remote in (
102
+ "git@github.com:acme/consumer.git",
103
+ "https://github.com/acme/consumer.git",
104
+ "ssh://git@github.com/acme/consumer",
105
+ ):
106
+ with self.subTest(remote=remote):
107
+ root = self.make_repo(remote=remote)
108
+ content = (
109
+ "Anchor [#2280](https://github.com/acme/consumer/issues/2280) — "
110
+ "upstream [#276](https://github.com/iKon85/agent-workflow-kit/issues/276)\n"
111
+ )
112
+
113
+ issue = DRIFT_GUARD.extract_issue(
114
+ self.write_payload(root, "2026-07-26-2279.md", content), content, root
115
+ )
116
+
117
+ self.assertEqual(issue, 2280)
118
+
119
+ def test_without_a_parsable_remote_the_filename_anchor_is_preferred(self):
120
+ root = self.make_repo()
121
+ content = "Upstream [#276](https://github.com/iKon85/agent-workflow-kit/issues/276)\n"
122
+
123
+ issue = DRIFT_GUARD.extract_issue(
124
+ self.write_payload(root, "2026-07-26-2279.md", content), content, root
125
+ )
126
+
127
+ self.assertEqual(issue, 2279)
128
+
129
+ def test_without_remote_and_without_filename_anchor_the_content_anchor_remains(self):
130
+ root = self.make_repo()
131
+ content = "Anchor [#276](https://github.com/iKon85/agent-workflow-kit/issues/276)\n"
132
+
133
+ issue = DRIFT_GUARD.extract_issue(
134
+ self.write_payload(root, "session-notes.md", content), content, root
135
+ )
136
+
137
+ self.assertEqual(issue, 276)
138
+
139
+ def test_a_foreign_only_handoff_without_a_filename_anchor_fails_open(self):
140
+ root = self.make_repo(remote="git@github.com:acme/consumer.git")
141
+ content = "Upstream [#276](https://github.com/iKon85/agent-workflow-kit/issues/276)\n"
142
+ payload = self.write_payload(root, "session-notes.md", content)
143
+
144
+ self.assertIsNone(DRIFT_GUARD.extract_issue(payload, content, root))
145
+ with patch.object(DRIFT_GUARD, "run_check") as check:
146
+ self.assertEqual(DRIFT_GUARD.should_block(payload), (False, ""))
147
+ check.assert_not_called()
148
+
149
+ def test_block_message_names_the_own_anchor_not_the_foreign_issue(self):
150
+ root = self.make_repo(remote="git@github.com:acme/consumer.git")
151
+ content = (
152
+ "Refresh landed as chore PR "
153
+ "[#2281](https://github.com/acme/consumer/pull/2281).\n"
154
+ "Reported upstream: "
155
+ "[kit#276](https://github.com/iKon85/agent-workflow-kit/issues/276)\n"
156
+ )
157
+ payload = self.write_payload(root, "2026-07-26-2279.md", content)
158
+ checked = []
159
+
160
+ def check(issue, intent):
161
+ checked.append(issue)
162
+ return {"deny_recommended": True, "violations": [f"#{issue}: plan_revision missing"]}
163
+
164
+ with patch.object(DRIFT_GUARD, "run_check", side_effect=check):
165
+ blocked, message = DRIFT_GUARD.should_block(payload)
166
+
167
+ self.assertEqual(checked, [2279])
168
+ self.assertTrue(blocked)
169
+ self.assertIn("#2279", message)
170
+ self.assertNotIn("276", message)
171
+
172
+ # --- evaluated checkout -----------------------------------------------
173
+
174
+ def test_census_block_message_names_the_evaluated_checkout(self):
175
+ root = self.make_repo()
176
+ result = {"state": "refresh_required", "reasons": ["topology"], "overrides": []}
177
+
178
+ message = DRIFT_GUARD.build_census_block_message(2279, result, root, root)
179
+
180
+ self.assertIn(str(root), message)
181
+ self.assertIn("evaluated checkout", message)
182
+ self.assertIn("worktree", message)
183
+ self.assertIn("$census-update", message)
184
+
185
+ def test_a_sibling_worktree_working_directory_is_named_explicitly(self):
186
+ root = self.make_repo()
187
+ subprocess.run(["git", *GIT_IDENTITY, "commit", "--quiet", "-m", "init"],
188
+ cwd=root, check=True)
189
+ sibling = root / "sibling-worktree"
190
+ subprocess.run(["git", "worktree", "add", "--quiet", str(sibling), "-b", "slice"],
191
+ cwd=root, check=True)
192
+ result = {"state": "refresh_required", "reasons": ["topology"], "overrides": []}
193
+
194
+ from_sibling = DRIFT_GUARD.build_census_block_message(2279, result, root, sibling)
195
+ from_root = DRIFT_GUARD.build_census_block_message(2279, result, root, root)
196
+ from_elsewhere = DRIFT_GUARD.build_census_block_message(
197
+ 2279, result, root, self.make_repo()
198
+ )
199
+
200
+ self.assertIn(str(sibling), from_sibling)
201
+ self.assertIn("different worktree", from_sibling)
202
+ self.assertNotIn("different worktree", from_root)
203
+ self.assertNotIn("different worktree", from_elsewhere)
204
+
205
+ def test_a_blocked_build_handoff_reports_the_checkout_it_evaluated(self):
206
+ root = self.make_repo()
207
+ payload = self.write_payload(
208
+ root, "2279.md", "Build [#2279](https://github.com/acme/consumer/issues/2279)\n"
209
+ )
210
+ refresh = {
211
+ "state": "refresh_required", "block_handoff": True,
212
+ "reasons": ["topology"], "overrides": [],
213
+ }
214
+
215
+ with patch.object(DRIFT_GUARD, "run_check", return_value={"deny_recommended": False}), \
216
+ patch.object(DRIFT_GUARD, "evaluate_census", return_value=refresh):
217
+ blocked, message = DRIFT_GUARD.should_block(payload)
218
+
219
+ self.assertTrue(blocked)
220
+ self.assertIn("evaluated checkout", message)
221
+ self.assertIn(str(root), message)
222
+
223
+ # --- drift delta -------------------------------------------------------
224
+
225
+ def test_census_status_reports_what_drifted(self):
226
+ root = self.make_repo()
227
+ self.enable(root)
228
+ self.activate_current(root)
229
+ (root / "src" / "added.mjs").write_text("export const added = true;\n", encoding="utf-8")
230
+ (root / "test").mkdir()
231
+ (root / "test" / "proof.test.mjs").write_text("// evidence\n", encoding="utf-8")
232
+ (root / "src" / "index.mjs").write_text("export const ready = false;\n", encoding="utf-8")
233
+ subprocess.run(["git", "add", "."], cwd=root, check=True)
234
+
235
+ status = self.census_status(root)
236
+
237
+ self.assertEqual(status["state"], "refresh_required")
238
+ self.assertIn("topology", status["reasons"])
239
+ delta = status["delta"]
240
+ self.assertIn("src/added.mjs", delta["denominator"]["added"])
241
+ self.assertIn("src/index.mjs", delta["denominator"]["changed"])
242
+ self.assertEqual(delta["denominator"]["removed"], [])
243
+ self.assertIn("test/proof.test.mjs", delta["evidence"]["added"])
244
+ self.assertEqual(delta["families"]["added"], [])
245
+
246
+ def test_a_new_surface_and_a_removed_file_are_named_in_the_delta(self):
247
+ root = self.make_repo()
248
+ self.enable(root)
249
+ self.activate_current(root)
250
+ (root / "packages" / "api" / "src").mkdir(parents=True)
251
+ (root / "packages" / "api" / "src" / "index.mjs").write_text(
252
+ "export const api = true;\n", encoding="utf-8"
253
+ )
254
+ (root / "src" / "index.mjs").unlink()
255
+ subprocess.run(["git", "add", "--all"], cwd=root, check=True)
256
+
257
+ status = self.census_status(root)
258
+ delta = status["delta"]
259
+
260
+ self.assertIn("packages/api/src/index.mjs", delta["denominator"]["added"])
261
+ self.assertIn("src/index.mjs", delta["denominator"]["removed"])
262
+ self.assertIn("surface:packages/api", delta["families"]["added"])
263
+ self.assertIn("surface:src", delta["families"]["removed"])
264
+
265
+ def test_a_large_delta_stays_bounded_and_verbose_reports_everything(self):
266
+ root = self.make_repo()
267
+ self.enable(root)
268
+ self.activate_current(root)
269
+ grown = DRIFT_GUARD.CENSUS_DELTA_LIMIT + 7
270
+ for index in range(grown):
271
+ (root / "src" / f"grown-{index:03d}.mjs").write_text(
272
+ f"export const grown{index} = true;\n", encoding="utf-8"
273
+ )
274
+ subprocess.run(["git", "add", "."], cwd=root, check=True)
275
+
276
+ capped = self.census_status(root)["delta"]["denominator"]["added"]
277
+ verbose = self.census_status(root, "--verbose")["delta"]["denominator"]["added"]
278
+
279
+ self.assertEqual(len(capped), DRIFT_GUARD.CENSUS_DELTA_LIMIT + 1)
280
+ self.assertEqual(capped[-1], "…and 7 more")
281
+ self.assertEqual(len(verbose), grown)
282
+ self.assertIn("src/grown-000.mjs", verbose)
283
+
284
+ def test_a_census_without_an_active_snapshot_reports_no_delta(self):
285
+ root = self.make_repo()
286
+ self.enable(root)
287
+
288
+ status = self.census_status(root)
289
+
290
+ self.assertEqual(status["state"], "bootstrap")
291
+ self.assertIsNone(status.get("delta"))
292
+
293
+
294
+ if __name__ == "__main__":
295
+ unittest.main()
@@ -95,6 +95,15 @@ BEHAVIORAL_PARITY = {
95
95
  "Program-PRD",
96
96
  "program sync",
97
97
  ),
98
+ "session-owned teardown": (
99
+ "session.py begin",
100
+ "session.py create",
101
+ "refuses reused/pre-existing targets",
102
+ "seal the receipt",
103
+ "compare-deleted",
104
+ "foreign targets",
105
+ "recovery-OID evidence",
106
+ ),
98
107
  "program authorization continuity": (
99
108
  "explicit whole-Program mandate",
100
109
  "all planned Waves",
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """One repository-relative glob dialect backs every consumer-profile glob."""
3
+
4
+ import importlib.util
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ REPO = Path(__file__).resolve().parent.parent
13
+ DIALECT_MODULE = REPO / "scripts/profile_globs.py"
14
+ ADVISORIES_CORE = REPO / "scripts/workflow-advisories/core.py"
15
+ LIFECYCLE_CORE = REPO / "scripts/worktree-lifecycle/core.py"
16
+
17
+
18
+ def load(name, path):
19
+ spec = importlib.util.spec_from_file_location(name, path)
20
+ module = importlib.util.module_from_spec(spec)
21
+ sys.modules[name] = module
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ def load_lifecycle_core():
27
+ module_dir = str(LIFECYCLE_CORE.parent)
28
+ if module_dir not in sys.path:
29
+ sys.path.insert(0, module_dir)
30
+ return load("profile_globs_lifecycle_core", LIFECYCLE_CORE)
31
+
32
+
33
+ dialect = load("profile_globs_under_test", DIALECT_MODULE)
34
+
35
+
36
+ class MatcherContract(unittest.TestCase):
37
+ """The documented dialect, exercised on the axes the contract names."""
38
+
39
+ def test_star_stays_inside_one_segment(self):
40
+ self.assertTrue(dialect.path_glob_matches("build.log", "*.log"))
41
+ self.assertFalse(dialect.path_glob_matches("logs/build.log", "*.log"))
42
+ self.assertTrue(dialect.path_glob_matches("logs/build.log", "logs/*.log"))
43
+ self.assertFalse(dialect.path_glob_matches("logs/a/build.log", "logs/*.log"))
44
+
45
+ def test_question_mark_matches_one_character_inside_one_segment(self):
46
+ self.assertTrue(dialect.path_glob_matches("a1.tmp", "a?.tmp"))
47
+ self.assertFalse(dialect.path_glob_matches("a/1.tmp", "a?1.tmp"))
48
+
49
+ def test_character_classes_stay_per_segment(self):
50
+ self.assertTrue(dialect.path_glob_matches("cache/7.tmp", "cache/[0-9].tmp"))
51
+ self.assertFalse(dialect.path_glob_matches("cache/x.tmp", "cache/[0-9].tmp"))
52
+ self.assertTrue(dialect.path_glob_matches("cache/x.tmp", "cache/[!0-9].tmp"))
53
+
54
+ def test_leading_globstar_covers_the_repository_root_and_nested_paths(self):
55
+ self.assertTrue(dialect.path_glob_matches("__pycache__/a.pyc", "**/__pycache__/**"))
56
+ self.assertTrue(dialect.path_glob_matches("src/__pycache__/a.pyc", "**/__pycache__/**"))
57
+ self.assertTrue(dialect.path_glob_matches("notes.md", "**/notes.md"))
58
+ self.assertTrue(dialect.path_glob_matches("a/b/notes.md", "**/notes.md"))
59
+
60
+ def test_directory_roots_match_every_depth_below_them(self):
61
+ self.assertTrue(dialect.path_glob_matches("dist-kit/a", "dist-kit/**"))
62
+ self.assertTrue(dialect.path_glob_matches("dist-kit/a/b", "dist-kit/**"))
63
+ self.assertTrue(dialect.path_glob_matches("dist-kit", "dist-kit/**"))
64
+ self.assertFalse(dialect.path_glob_matches("dist-kit/a/b", "dist-kit/*"))
65
+ self.assertFalse(dialect.path_glob_matches("dist-kitten/a", "dist-kit/**"))
66
+
67
+ def test_matching_is_case_sensitive_on_every_host(self):
68
+ self.assertTrue(dialect.path_glob_matches("PLAN.md", "PLAN.md"))
69
+ self.assertFalse(dialect.path_glob_matches("plan.md", "PLAN.md"))
70
+ self.assertFalse(dialect.path_glob_matches("Logs/a.log", "logs/*.log"))
71
+
72
+ def test_whole_path_must_match(self):
73
+ self.assertFalse(dialect.path_glob_matches("src/index.mjs", "src"))
74
+ self.assertFalse(dialect.path_glob_matches("src", "src/index.mjs"))
75
+
76
+
77
+ class SharedMatcherContract(unittest.TestCase):
78
+ """Both shipped cores resolve to the one shared matcher, not a copy."""
79
+
80
+ def test_both_cores_share_one_matcher_object(self):
81
+ advisories = load("profile_globs_advisories_core", ADVISORIES_CORE)
82
+ lifecycle = load_lifecycle_core()
83
+ self.assertIs(advisories.path_glob_matches, lifecycle.path_glob_matches)
84
+
85
+ def test_the_shared_matcher_comes_from_the_shipped_dialect_module(self):
86
+ advisories = load("profile_globs_advisories_core", ADVISORIES_CORE)
87
+ shared = advisories.load_profile_globs()
88
+ self.assertEqual(Path(shared.__file__).resolve(), DIALECT_MODULE.resolve())
89
+ self.assertIs(advisories.path_glob_matches, shared.path_glob_matches)
90
+
91
+ def test_both_cores_name_the_same_shared_module(self):
92
+ advisories = load("profile_globs_advisories_core", ADVISORIES_CORE)
93
+ lifecycle = load_lifecycle_core()
94
+ self.assertEqual(
95
+ advisories.PROFILE_GLOBS_MODULE, lifecycle.PROFILE_GLOBS_MODULE,
96
+ )
97
+
98
+ def test_neither_core_keeps_a_second_matcher(self):
99
+ for core in (ADVISORIES_CORE, LIFECYCLE_CORE):
100
+ body = core.read_text(encoding="utf-8")
101
+ self.assertNotIn("fnmatch.fnmatch(", body)
102
+ self.assertNotIn("def path_glob_matches", body)
103
+
104
+
105
+ class AdvisorySurfaceContract(unittest.TestCase):
106
+ """Advisory globs select the same paths the lifecycle globs would."""
107
+
108
+ def decision_for(self, globs, changed):
109
+ core = load("profile_globs_advisories_surface", ADVISORIES_CORE)
110
+ profile = {
111
+ "stopChecks": {
112
+ "surfaces": [{"globs": globs, "command": ["python3", "-c", "pass"]}],
113
+ "timeoutSeconds": 3,
114
+ "outputBudget": 300,
115
+ },
116
+ }
117
+ with tempfile.TemporaryDirectory() as root:
118
+ return core.stop_check_decision(
119
+ profile, {"changed_files": changed}, Path(root),
120
+ )
121
+
122
+ def test_single_segment_glob_no_longer_crosses_a_directory(self):
123
+ self.assertIsNone(self.decision_for(["*.py"], ["pkg/mod.py"]).context)
124
+ self.assertIsNotNone(self.decision_for(["*.py"], ["mod.py"]).context)
125
+
126
+ def test_globstar_covers_root_and_nested_paths(self):
127
+ self.assertIsNotNone(self.decision_for(["**/*.py"], ["pkg/mod.py"]).context)
128
+ self.assertIsNotNone(self.decision_for(["**/*.py"], ["mod.py"]).context)
129
+
130
+ def test_directory_root_glob_covers_every_depth(self):
131
+ self.assertIsNotNone(self.decision_for(["src/**"], ["src/a/b.mjs"]).context)
132
+ self.assertIsNone(self.decision_for(["src/*"], ["src/a/b.mjs"]).context)
133
+
134
+ def test_advisory_matching_is_case_sensitive(self):
135
+ self.assertIsNone(self.decision_for(["src/**"], ["SRC/a.mjs"]).context)
136
+
137
+
138
+ class MigrationClassifier(unittest.TestCase):
139
+ """Legacy patterns whose match set changes are named, never rewritten."""
140
+
141
+ def test_literal_pattern_is_stable(self):
142
+ self.assertEqual(dialect.classify_pattern("PLAN.md").effects, ())
143
+
144
+ def test_single_segment_wildcard_narrows(self):
145
+ migration = dialect.classify_pattern("*.log")
146
+ self.assertIn(dialect.NARROWS, migration.effects)
147
+ self.assertIn("/", migration.witnesses[dialect.NARROWS])
148
+
149
+ def test_nested_wildcard_without_globstar_narrows(self):
150
+ self.assertIn(
151
+ dialect.NARROWS, dialect.classify_pattern("dist-kit/*").effects,
152
+ )
153
+
154
+ def test_leading_globstar_widens_at_the_repository_root(self):
155
+ migration = dialect.classify_pattern("**/__pycache__/**")
156
+ self.assertIn(dialect.WIDENS, migration.effects)
157
+ self.assertEqual(migration.witnesses[dialect.WIDENS], "__pycache__")
158
+
159
+ def test_directory_root_globstar_widens_onto_the_directory_itself(self):
160
+ migration = dialect.classify_pattern("dist-kit/**")
161
+ self.assertIn(dialect.WIDENS, migration.effects)
162
+ self.assertEqual(migration.witnesses[dialect.WIDENS], "dist-kit")
163
+
164
+ def test_bare_globstar_is_stable(self):
165
+ self.assertEqual(dialect.classify_pattern("**").effects, ())
166
+
167
+ def test_character_class_pattern_is_stable(self):
168
+ self.assertEqual(dialect.classify_pattern("cache/[0-9].tmp").effects, ())
169
+
170
+ def test_case_effect_only_applies_to_the_case_normalizing_legacy(self):
171
+ insensitive = dialect.classify_pattern("PLAN.md", case_insensitive_legacy=True)
172
+ self.assertIn(dialect.CASE_NARROWS, insensitive.effects)
173
+ self.assertEqual(dialect.classify_pattern("PLAN.md").effects, ())
174
+
175
+
176
+ class ProfileScan(unittest.TestCase):
177
+ """Every shipped consumer-profile glob key is reachable from one scan."""
178
+
179
+ document = {
180
+ "worktreeLifecycle": {"scratchPatterns": ["PLAN.md", "*.log"]},
181
+ "wrapup": {"landingGeneratedArtifactPatterns": ["dist-kit/**"]},
182
+ "workflowAdvisories": {
183
+ "baseline": {"sourceGlobs": ["src/**"]},
184
+ "preRefactor": {"surfaces": [{"globs": ["frontend/*"]}]},
185
+ "stopChecks": {"surfaces": [{"globs": ["backend/**"]}]},
186
+ },
187
+ }
188
+
189
+ def locations(self):
190
+ return [finding.location for finding in dialect.scan_profile(self.document)]
191
+
192
+ def test_scan_reaches_every_shipped_glob_key(self):
193
+ self.assertEqual(self.locations(), [
194
+ "worktreeLifecycle.scratchPatterns[0]",
195
+ "worktreeLifecycle.scratchPatterns[1]",
196
+ "wrapup.landingGeneratedArtifactPatterns[0]",
197
+ "workflowAdvisories.baseline.sourceGlobs[0]",
198
+ "workflowAdvisories.preRefactor.surfaces[0].globs[0]",
199
+ "workflowAdvisories.stopChecks.surfaces[0].globs[0]",
200
+ ])
201
+
202
+ def test_cleanup_keys_are_marked_as_deletion_authority(self):
203
+ authority = {
204
+ finding.location: finding.deletion_authority
205
+ for finding in dialect.scan_profile(self.document)
206
+ }
207
+ self.assertTrue(authority["worktreeLifecycle.scratchPatterns[0]"])
208
+ self.assertTrue(authority["wrapup.landingGeneratedArtifactPatterns[0]"])
209
+ self.assertFalse(authority["workflowAdvisories.baseline.sourceGlobs[0]"])
210
+
211
+ def test_advisory_globs_carry_the_case_normalizing_legacy(self):
212
+ legacy = {
213
+ finding.location: finding.migration.effects
214
+ for finding in dialect.scan_profile(self.document)
215
+ }
216
+ self.assertIn(
217
+ dialect.CASE_NARROWS,
218
+ legacy["workflowAdvisories.baseline.sourceGlobs[0]"],
219
+ )
220
+ self.assertNotIn(
221
+ dialect.CASE_NARROWS,
222
+ legacy["worktreeLifecycle.scratchPatterns[0]"],
223
+ )
224
+
225
+ def test_malformed_sections_are_skipped_without_raising(self):
226
+ self.assertEqual(dialect.scan_profile({"worktreeLifecycle": []}), ())
227
+ self.assertEqual(dialect.scan_profile("not a profile"), ())
228
+
229
+
230
+ class MigrationReport(unittest.TestCase):
231
+ """The review command reports; it never edits the consumer profile."""
232
+
233
+ def run_check(self, document, *args):
234
+ with tempfile.TemporaryDirectory() as root:
235
+ path = Path(root) / "workflow-capabilities.json"
236
+ body = json.dumps(document, indent=2) + "\n"
237
+ path.write_text(body, encoding="utf-8")
238
+ result = subprocess.run(
239
+ [sys.executable, str(DIALECT_MODULE), str(path), *args],
240
+ capture_output=True, text=True,
241
+ )
242
+ self.assertEqual(path.read_text(encoding="utf-8"), body)
243
+ return result
244
+
245
+ def test_stable_profile_exits_zero(self):
246
+ result = self.run_check({
247
+ "worktreeLifecycle": {"scratchPatterns": ["PLAN.md"]},
248
+ })
249
+ self.assertEqual(result.returncode, 0, result.stderr)
250
+
251
+ def test_changed_pattern_exits_one_and_names_witness_and_authority(self):
252
+ result = self.run_check({
253
+ "wrapup": {"landingGeneratedArtifactPatterns": ["**/__pycache__/**"]},
254
+ })
255
+ self.assertEqual(result.returncode, 1, result.stderr)
256
+ self.assertIn("wrapup.landingGeneratedArtifactPatterns[0]", result.stdout)
257
+ self.assertIn(dialect.WIDENS, result.stdout)
258
+ self.assertIn("__pycache__", result.stdout)
259
+ self.assertIn("deletion authority", result.stdout)
260
+
261
+ def test_json_output_is_machine_readable(self):
262
+ result = self.run_check(
263
+ {"worktreeLifecycle": {"scratchPatterns": ["*.log"]}}, "--json",
264
+ )
265
+ report = json.loads(result.stdout)
266
+ self.assertEqual(report["reviewed"], 1)
267
+ self.assertEqual(report["changed"], 1)
268
+ self.assertEqual(report["findings"][0]["effects"], [dialect.NARROWS])
269
+
270
+ def test_unreadable_profile_exits_two(self):
271
+ result = subprocess.run(
272
+ [sys.executable, str(DIALECT_MODULE), "does/not/exist.json"],
273
+ capture_output=True, text=True,
274
+ )
275
+ self.assertEqual(result.returncode, 2)
276
+ self.assertNotEqual(result.stderr.strip(), "")
277
+
278
+
279
+ if __name__ == "__main__":
280
+ unittest.main()