@ikon85/agent-workflow-kit 0.38.0 → 0.40.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.
- package/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +33 -1
- package/.agents/skills/orchestrate-wave/SKILL.md +4 -4
- package/.agents/skills/setup-workflow/SKILL.md +84 -3
- package/.agents/skills/setup-workflow/board-sync.md +6 -2
- package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +54 -3
- package/.agents/skills/wrapup/SKILL.md +24 -0
- package/.claude/hooks/drift-guard.py +212 -21
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/kit-update/SKILL.md +33 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +4 -4
- package/.claude/skills/setup-workflow/SKILL.md +84 -3
- package/.claude/skills/setup-workflow/board-sync.md +6 -2
- package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +54 -3
- package/.claude/skills/wrapup/SKILL.md +24 -0
- package/README.md +62 -0
- package/agent-workflow-kit.package.json +57 -25
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +84 -0
- package/package.json +1 -1
- package/scripts/board_bootstrap.py +367 -0
- package/scripts/kit-update-pr.mjs +20 -7
- package/scripts/kit-update-pr.test.mjs +29 -0
- package/scripts/profile_globs.py +347 -0
- package/scripts/test_board_bootstrap.py +348 -0
- package/scripts/test_drift_guard_diagnostics.py +295 -0
- package/scripts/test_profile_globs.py +280 -0
- package/scripts/test_skill_setup_workflow_seeds.py +87 -0
- package/scripts/test_worktree_ignore_seed.py +320 -0
- package/scripts/test_worktree_wrapup_contract.py +588 -0
- package/scripts/workflow-advisories/core.py +29 -4
- package/scripts/worktree-lifecycle/README.md +53 -4
- package/scripts/worktree-lifecycle/core.py +211 -60
- package/scripts/worktree-lifecycle/ignore_seed.py +226 -0
- package/scripts/worktree-lifecycle/plan-artifacts.json +37 -0
- package/scripts/wrapup-land.py +179 -34
- package/src/cli.mjs +32 -1
- package/src/commands/update.mjs +30 -21
- package/src/consumer-migrations.json +19 -0
- package/src/lib/bundle.mjs +16 -0
- 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()
|
|
@@ -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()
|
|
@@ -57,6 +57,31 @@ def update_workflow_action(
|
|
|
57
57
|
return "create" if pull_requests_allowed else "skip"
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def board_branch_action(tracker, boards_found, project_scope, user_choice):
|
|
61
|
+
"""Reference decision table for Section D's board branch (#24).
|
|
62
|
+
|
|
63
|
+
tracker: the Section A choice.
|
|
64
|
+
boards_found: how many owner projects the discovery read found, or None when
|
|
65
|
+
the read itself failed.
|
|
66
|
+
project_scope: does `gh` carry the write scope board creation needs?
|
|
67
|
+
user_choice: the answer to the creation offer ("yes" / "no" / None).
|
|
68
|
+
|
|
69
|
+
"discover" fills the profile from an existing board, "create" runs the
|
|
70
|
+
bootstrap helper after an explicit yes, "stub" is the retryable stub path.
|
|
71
|
+
Creation is offered on exactly one branch — no board, with the scope — and a
|
|
72
|
+
decline, a missing scope, ambiguity, or a failed read all stay on the stub.
|
|
73
|
+
"""
|
|
74
|
+
if tracker != "github":
|
|
75
|
+
return "not-applicable"
|
|
76
|
+
if boards_found is None or boards_found > 1:
|
|
77
|
+
return "stub"
|
|
78
|
+
if boards_found == 1:
|
|
79
|
+
return "discover"
|
|
80
|
+
if not project_scope:
|
|
81
|
+
return "stub"
|
|
82
|
+
return "create" if user_choice == "yes" else "stub"
|
|
83
|
+
|
|
84
|
+
|
|
60
85
|
def load_census_setup_effects():
|
|
61
86
|
"""Parse the shipped seed's executable census transition contract."""
|
|
62
87
|
seed = (SKILL / "census.md").read_text(encoding="utf-8")
|
|
@@ -559,6 +584,68 @@ class SeedTemplatesValid(unittest.TestCase):
|
|
|
559
584
|
self.assertIn("Board status is authoritative", triage_skill)
|
|
560
585
|
|
|
561
586
|
|
|
587
|
+
class BoardCreationOffer(unittest.TestCase):
|
|
588
|
+
"""Section D offers board creation instead of refusing it (#24).
|
|
589
|
+
|
|
590
|
+
The mechanics live in `scripts/board_bootstrap.py` (spec:
|
|
591
|
+
`scripts/test_board_bootstrap.py`); what is pinned here is the prose
|
|
592
|
+
contract: an explicit user gate, one creation path, and every other branch
|
|
593
|
+
landing on the unchanged stub.
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
FIXTURES = [
|
|
597
|
+
# tracker, boards, scope, choice, expected
|
|
598
|
+
("github", 0, True, "yes", "create"),
|
|
599
|
+
("github", 0, True, "no", "stub"),
|
|
600
|
+
("github", 0, True, None, "stub"),
|
|
601
|
+
("github", 0, False, "yes", "stub"),
|
|
602
|
+
("github", 1, True, None, "discover"),
|
|
603
|
+
("github", 2, True, "yes", "stub"),
|
|
604
|
+
("github", None, True, "yes", "stub"),
|
|
605
|
+
("gitlab", 0, True, "yes", "not-applicable"),
|
|
606
|
+
("local", 0, True, "yes", "not-applicable"),
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
def test_decision_table(self):
|
|
610
|
+
for tracker, boards, scope, choice, expected in self.FIXTURES:
|
|
611
|
+
self.assertEqual(
|
|
612
|
+
board_branch_action(tracker, boards, scope, choice), expected,
|
|
613
|
+
f"{tracker}/{boards}/{scope}/{choice}",
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
def test_skill_offers_creation_on_both_surfaces(self):
|
|
617
|
+
for surface in (".claude", ".agents"):
|
|
618
|
+
skill = (REPO / surface / "skills/setup-workflow/SKILL.md").read_text(
|
|
619
|
+
encoding="utf-8")
|
|
620
|
+
for token in (
|
|
621
|
+
"python3 scripts/board_bootstrap.py create",
|
|
622
|
+
"--dry-run",
|
|
623
|
+
"never create a board without an explicit yes",
|
|
624
|
+
"Create it now",
|
|
625
|
+
"Not now",
|
|
626
|
+
"gh auth refresh -s project,read:project",
|
|
627
|
+
"state=filled",
|
|
628
|
+
):
|
|
629
|
+
self.assertIn(token, skill, f"{surface} missing {token!r}")
|
|
630
|
+
# The refuted rationale must be gone: the CLI *can* provision the
|
|
631
|
+
# Status options via `--single-select-options`.
|
|
632
|
+
self.assertNotIn("cannot provision the Status options", skill)
|
|
633
|
+
|
|
634
|
+
def test_skill_routes_every_other_branch_to_the_unchanged_stub(self):
|
|
635
|
+
skill = (SKILL / "SKILL.md").read_text(encoding="utf-8")
|
|
636
|
+
fallback = skill.split("**Fallback", 1)
|
|
637
|
+
self.assertEqual(len(fallback), 2, "the stub fallback must survive")
|
|
638
|
+
for token in ("declined", "scope", "read failure", "state=stub", "Retryable"):
|
|
639
|
+
self.assertIn(token, fallback[1][:1200], f"stub path missing {token!r}")
|
|
640
|
+
self.assertIn("never hand-write a `state=filled` profile", skill)
|
|
641
|
+
|
|
642
|
+
def test_seed_documents_the_offer_and_keeps_the_manual_path(self):
|
|
643
|
+
seed = (SKILL / "board-sync.md").read_text(encoding="utf-8")
|
|
644
|
+
self.assertIn("board_bootstrap.py", seed)
|
|
645
|
+
self.assertIn("gh auth refresh -s project,read:project", seed)
|
|
646
|
+
self.assertIn("Re-run `/setup-workflow`", seed)
|
|
647
|
+
|
|
648
|
+
|
|
562
649
|
class SentinelProtectsFilledProfile(unittest.TestCase):
|
|
563
650
|
"""A consumer's filled board profile must never be rewritten on rerun."""
|
|
564
651
|
|