@ikon85/agent-workflow-kit 0.43.0 → 0.44.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 (126) hide show
  1. package/.agents/skills/grill-me/SKILL.md +3 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +3 -1
  3. package/.agents/skills/kit-update/SKILL.md +9 -11
  4. package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
  5. package/.agents/skills/setup-workflow/SKILL.md +20 -18
  6. package/.agents/skills/setup-workflow/board-sync.md +4 -1
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
  9. package/.agents/skills/to-issues/SKILL.md +43 -0
  10. package/.agents/skills/wrapup/SKILL.md +98 -54
  11. package/.claude/skills/grill-me/SKILL.md +3 -1
  12. package/.claude/skills/grill-me-codex/SKILL.md +3 -1
  13. package/.claude/skills/grill-with-docs/SKILL.md +3 -1
  14. package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
  15. package/.claude/skills/kit-update/SKILL.md +9 -11
  16. package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
  17. package/.claude/skills/setup-workflow/SKILL.md +20 -18
  18. package/.claude/skills/setup-workflow/board-sync.md +4 -1
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
  20. package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
  21. package/.claude/skills/to-issues/SKILL.md +43 -0
  22. package/.claude/skills/wrapup/SKILL.md +98 -54
  23. package/README.md +249 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -12
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +26 -9
  29. package/scripts/worktree-lifecycle/README.md +57 -160
  30. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  31. package/scripts/worktree-lifecycle/classify.py +480 -0
  32. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  33. package/scripts/worktree-lifecycle/core.py +68 -1429
  34. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  35. package/scripts/worktree-lifecycle/profile.py +46 -29
  36. package/scripts/worktree-lifecycle/setup.py +7 -19
  37. package/scripts/wrapup-land.py +1292 -545
  38. package/src/consumer-migrations.json +1 -16
  39. package/src/lib/bundle.mjs +31 -1
  40. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  41. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  42. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  43. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  44. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  45. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  46. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  47. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  48. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  49. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  50. package/docs/agents/board-sync.md +0 -74
  51. package/docs/agents/code-review.md +0 -14
  52. package/docs/agents/workflow-capabilities.json +0 -28
  53. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  54. package/docs/research/benchlm-routing-source.md +0 -198
  55. package/docs/research/consumer-owned-protocol-files.md +0 -238
  56. package/docs/research/frontend-agent-benchmarks.md +0 -282
  57. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  58. package/docs/research/provider-neutral-agent-routing.md +0 -207
  59. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  60. package/docs/research/wave-43-script-hook-census.md +0 -128
  61. package/scripts/build-kit.mjs +0 -117
  62. package/scripts/build-kit.test.mjs +0 -207
  63. package/scripts/census/census.test.mjs +0 -252
  64. package/scripts/census/state.test.mjs +0 -15
  65. package/scripts/census/transaction.test.mjs +0 -103
  66. package/scripts/census-contract.test.mjs +0 -227
  67. package/scripts/check-kit-staleness.mjs +0 -45
  68. package/scripts/check-kit-staleness.test.mjs +0 -19
  69. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  70. package/scripts/codex-exec.test.mjs +0 -871
  71. package/scripts/grill-census-wiring-guard.mjs +0 -87
  72. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  73. package/scripts/kit-release.test.mjs +0 -273
  74. package/scripts/kit-update-pr.test.mjs +0 -151
  75. package/scripts/lib/audit-refs.mjs +0 -146
  76. package/scripts/lib/scrub.mjs +0 -113
  77. package/scripts/lib/scrub.test.mjs +0 -187
  78. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  79. package/scripts/portability_profile_scan.py +0 -53
  80. package/scripts/release-delta-guard.test.mjs +0 -174
  81. package/scripts/release-parity.test.mjs +0 -90
  82. package/scripts/release-state.test.mjs +0 -360
  83. package/scripts/test_anchor_table.py +0 -69
  84. package/scripts/test_board_bootstrap.py +0 -348
  85. package/scripts/test_board_sync.py +0 -208
  86. package/scripts/test_board_sync_create_idempotency.py +0 -117
  87. package/scripts/test_board_sync_wave_title.py +0 -120
  88. package/scripts/test_census_backstop.py +0 -712
  89. package/scripts/test_census_forward_contract.py +0 -91
  90. package/scripts/test_census_update_contract.test.mjs +0 -454
  91. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  92. package/scripts/test_dist_kit_smoke.py +0 -220
  93. package/scripts/test_drift_guard_diagnostics.py +0 -295
  94. package/scripts/test_issue_claim_contract.py +0 -169
  95. package/scripts/test_kit_docs_language_census.py +0 -178
  96. package/scripts/test_marker_lib.py +0 -162
  97. package/scripts/test_orchestrate_wave_contract.py +0 -381
  98. package/scripts/test_pr_body_check.py +0 -264
  99. package/scripts/test_profile_globs.py +0 -280
  100. package/scripts/test_program_planning_contract.py +0 -133
  101. package/scripts/test_release_authorization_contract.py +0 -101
  102. package/scripts/test_render_anchor.py +0 -267
  103. package/scripts/test_retro_wrapup_contract.py +0 -153
  104. package/scripts/test_skill_code_review_seed.py +0 -89
  105. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  106. package/scripts/test_skill_frontmatter_lint.py +0 -234
  107. package/scripts/test_skill_gh_lint.py +0 -124
  108. package/scripts/test_skill_language_census.py +0 -254
  109. package/scripts/test_skill_optional_readiness.py +0 -171
  110. package/scripts/test_skill_portability_lint.py +0 -866
  111. package/scripts/test_skill_precommit_template.py +0 -30
  112. package/scripts/test_skill_publish_audit.py +0 -358
  113. package/scripts/test_skill_readiness_contract.py +0 -264
  114. package/scripts/test_skill_readiness_preflight.py +0 -180
  115. package/scripts/test_skill_required_readiness.py +0 -233
  116. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  117. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  118. package/scripts/test_skill_stale_name_lint.py +0 -126
  119. package/scripts/test_skill_surface_refs.py +0 -300
  120. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  121. package/scripts/test_tdd_contract.py +0 -78
  122. package/scripts/test_worktree_ignore_seed.py +0 -320
  123. package/scripts/test_worktree_setup_base_guard.py +0 -140
  124. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  125. package/scripts/test_wrapup_land.py +0 -428
  126. package/scripts/worktree-lifecycle/session.py +0 -1857
@@ -1,169 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Issue-claim-on-pickup contract (#231).
3
-
4
- A skill that ACCEPTS a tracked issue for building must leave a claim on the
5
- issue itself before it builds — the local worktree/branch/PR guard only sees
6
- the same machine, and only until someone pushes. The claim is what a second
7
- session, a second machine, or a cloud agent can actually see.
8
-
9
- Enforced here:
10
-
11
- - `implement` and `diagnose` carry one byte-identical claim block on both
12
- surfaces, and it sits before their first build instruction.
13
- - The block covers all three legs: check for a foreign claim (STOP), plant a
14
- claim naming branch + worktree, release it.
15
- - `orchestrate-wave` claims each slice issue at builder-launch time and
16
- releases only its own slice claims.
17
- - Every tracker seed and this repo's own tracker layer document the concrete
18
- claim / check / release operations, so the tracker-neutral skill prose
19
- resolves to real commands.
20
-
21
- Run: python3 scripts/test_issue_claim_contract.py
22
- """
23
- from __future__ import annotations
24
-
25
- import unittest
26
- from pathlib import Path
27
-
28
- ROOT = Path(__file__).resolve().parents[1]
29
- SURFACES = (".claude/skills", ".agents/skills")
30
- PICKUP_SKILLS = ("implement", "diagnose")
31
- START = "<!-- issue-claim:start -->"
32
- END = "<!-- issue-claim:end -->"
33
- MARKER = "<!-- agent-claim:"
34
- TRACKER_DOC = "docs/agents/issue-tracker.md"
35
- TRACKER_SEEDS = (
36
- "setup-workflow/issue-tracker-github.md",
37
- "setup-workflow/issue-tracker-gitlab.md",
38
- "setup-workflow/issue-tracker-local.md",
39
- )
40
- # The first instruction that already assumes the build has started.
41
- FIRST_BUILD_ANCHOR = {
42
- "implement": "Use /tdd where possible",
43
- "diagnose": "## Phase 1 — Build a feedback loop",
44
- }
45
-
46
-
47
- def skill_body(surface: str, skill: str) -> str:
48
- return (ROOT / surface / skill / "SKILL.md").read_text(encoding="utf-8")
49
-
50
-
51
- def claim_block(body: str) -> str:
52
- return body.split(START, 1)[1].split(END, 1)[0]
53
-
54
-
55
- def section(body: str, heading: str) -> str:
56
- """Return one second-level Markdown section, excluding the next one."""
57
- start = body.index(heading) + len(heading)
58
- end = body.find("\n## ", start)
59
- return body[start:] if end == -1 else body[start:end]
60
-
61
-
62
- class ClaimBlockContract(unittest.TestCase):
63
- def test_pickup_skills_carry_exactly_one_claim_block_on_both_surfaces(self):
64
- for skill in PICKUP_SKILLS:
65
- for surface in SURFACES:
66
- with self.subTest(skill=skill, surface=surface):
67
- body = skill_body(surface, skill)
68
- self.assertEqual(body.count(START), 1)
69
- self.assertEqual(body.count(END), 1)
70
- self.assertLess(body.index(START), body.index(END))
71
-
72
- def test_claim_block_is_byte_identical_across_skills_and_surfaces(self):
73
- blocks = {
74
- (skill, surface): claim_block(skill_body(surface, skill))
75
- for skill in PICKUP_SKILLS
76
- for surface in SURFACES
77
- }
78
- first = blocks[(PICKUP_SKILLS[0], SURFACES[0])]
79
- for key, block in blocks.items():
80
- with self.subTest(key=key):
81
- self.assertEqual(block, first)
82
-
83
- def test_claim_block_covers_check_plant_and_release(self):
84
- block = claim_block(skill_body(SURFACES[0], PICKUP_SKILLS[0]))
85
- # Check leg — a foreign claim stops the pickup, and is never removed.
86
- self.assertIn("foreign claim", block)
87
- self.assertIn("STOP", block)
88
- self.assertIn("never delete a foreign claim", block)
89
- # Plant leg — the marker must carry branch AND worktree, or a colliding
90
- # session cannot find the work in progress.
91
- self.assertIn(MARKER, block)
92
- self.assertIn("branch=", block)
93
- self.assertIn("worktree=", block)
94
- # Release leg — the PR supersedes it; an abandoned claim is removed.
95
- self.assertIn("supersedes", block)
96
- self.assertIn("abandon", block)
97
-
98
- def test_claim_block_resolves_through_the_tracker_layer_with_a_fallback(self):
99
- block = claim_block(skill_body(SURFACES[0], PICKUP_SKILLS[0]))
100
- self.assertIn(TRACKER_DOC, block)
101
- self.assertIn("fall back", block)
102
-
103
- def test_claim_is_planted_before_the_first_build_instruction(self):
104
- for skill in PICKUP_SKILLS:
105
- for surface in SURFACES:
106
- with self.subTest(skill=skill, surface=surface):
107
- body = skill_body(surface, skill)
108
- self.assertLess(
109
- body.index(END), body.index(FIRST_BUILD_ANCHOR[skill])
110
- )
111
-
112
-
113
- class OrchestrateWaveSliceClaimContract(unittest.TestCase):
114
- def test_dispatch_claims_each_slice_issue_at_builder_launch(self):
115
- for surface in SURFACES:
116
- with self.subTest(surface=surface):
117
- dispatch = section(
118
- skill_body(surface, "orchestrate-wave"),
119
- "## Phase 2 — Dispatch one wave in parallel",
120
- )
121
- self.assertIn(MARKER, dispatch)
122
- self.assertIn("slice issue", dispatch)
123
- self.assertIn("branch=", dispatch)
124
- self.assertIn("worktree=", dispatch)
125
-
126
- def test_wave_claim_is_not_treated_as_a_slice_claim(self):
127
- for surface in SURFACES:
128
- with self.subTest(surface=surface):
129
- dispatch = section(
130
- skill_body(surface, "orchestrate-wave"),
131
- "## Phase 2 — Dispatch one wave in parallel",
132
- )
133
- self.assertIn("wave claim", dispatch)
134
-
135
- def test_cleanup_releases_only_this_runs_slice_claims(self):
136
- for surface in SURFACES:
137
- with self.subTest(surface=surface):
138
- cleanup = section(
139
- skill_body(surface, "orchestrate-wave"),
140
- "## Phase 6 — Cleanup + close",
141
- )
142
- self.assertIn("slice claim", cleanup)
143
- self.assertIn("this run", cleanup)
144
-
145
-
146
- class TrackerLayerClaimOperations(unittest.TestCase):
147
- def documents(self) -> dict[str, str]:
148
- docs = {TRACKER_DOC: (ROOT / TRACKER_DOC).read_text(encoding="utf-8")}
149
- for surface in SURFACES:
150
- for seed in TRACKER_SEEDS:
151
- path = ROOT / surface / seed
152
- docs[str(path.relative_to(ROOT))] = path.read_text(encoding="utf-8")
153
- return docs
154
-
155
- def test_every_tracker_layer_defines_claim_check_and_release(self):
156
- for name, body in self.documents().items():
157
- with self.subTest(document=name):
158
- self.assertIn("## Pickup claim", body)
159
- claim = section(body, "## Pickup claim")
160
- self.assertIn(MARKER, claim)
161
- self.assertIn("branch=", claim)
162
- self.assertIn("worktree=", claim)
163
- # a read side (detect a foreign claim) and a release side
164
- self.assertIn("Check", claim)
165
- self.assertIn("Release", claim)
166
-
167
-
168
- if __name__ == "__main__":
169
- unittest.main()
@@ -1,178 +0,0 @@
1
- #!/usr/bin/env python3
2
- """dist-kit doc language census (#1953) — a coarse net over EVERY shipped
3
- `.md` file (docs/templates, README, SKILL.md bodies alike), not just
4
- `publish: true` skill bodies (that zero-tolerance census already exists in
5
- `test_skill_language_census.py`). This guard is a percentage smoke test: a
6
- kit doc is red once ≥10% of its lines carry German prose, so an
7
- English-speaking consumer never again silently gets a majority-German
8
- template the way `docs/agents/wave-anchor-template.md` shipped before this
9
- slice (23/100 lines German — the census #1953 was written to catch).
10
-
11
- Reuses `test_skill_language_census.py`'s wordlist + line-classification
12
- helpers (`_line_hits`, `strip_frontmatter`, the bilingual
13
- `<user-story-example lang="de">` block skip) rather than a parallel
14
- heuristic — same STRONG/WEAK wordlist, same allowlist exemptions (contract
15
- literals, `Welle <N>` title format, the inline `language-census: ok`
16
- marker).
17
-
18
- Two test classes:
19
- - `RatioFixtures` — pure-function unit tests, no build. Proves the ratio
20
- computation goes RED on an artificially-German fixture and GREEN on an
21
- all-English one, independent of the real dist-kit build.
22
- - `DistKitDocsCensus` — builds the REAL dist-kit (subprocess, like
23
- `test_dist_kit_smoke.py`) and asserts every shipped `.md` file is under
24
- the threshold.
25
-
26
- Run: python3 scripts/test_kit_docs_language_census.py
27
- """
28
- import subprocess
29
- import sys
30
- import unittest
31
- from pathlib import Path
32
-
33
- from test_skill_language_census import (
34
- BILINGUAL_BLOCK_END,
35
- BILINGUAL_BLOCK_START,
36
- _line_hits,
37
- strip_frontmatter,
38
- )
39
-
40
- REPO = Path(__file__).resolve().parent.parent
41
- DIST = REPO / "dist-kit"
42
-
43
- GERMAN_LINE_THRESHOLD_PCT = 10.0
44
-
45
-
46
- def german_line_ratio(text: str) -> tuple[int, int]:
47
- """(german_hit_lines, total_lines) for a doc body — frontmatter stripped,
48
- bilingual `<user-story-example lang="de">...</user-story-example>` blocks
49
- skipped (same exemptions as the publish:true skill census)."""
50
- body = strip_frontmatter(text)
51
- lines = body.splitlines()
52
- total = len(lines)
53
- hits = 0
54
- in_bilingual_de_block = False
55
- for line in lines:
56
- if BILINGUAL_BLOCK_START.search(line):
57
- in_bilingual_de_block = True
58
- if in_bilingual_de_block:
59
- if BILINGUAL_BLOCK_END.search(line):
60
- in_bilingual_de_block = False
61
- continue
62
- if _line_hits(line):
63
- hits += 1
64
- return hits, total
65
-
66
-
67
- def is_below_threshold(text: str) -> tuple[bool, float]:
68
- """(clean, pct) — clean is False once pct >= GERMAN_LINE_THRESHOLD_PCT."""
69
- hits, total = german_line_ratio(text)
70
- if total == 0:
71
- return True, 0.0
72
- pct = hits / total * 100
73
- return pct < GERMAN_LINE_THRESHOLD_PCT, pct
74
-
75
-
76
- class RatioFixtures(unittest.TestCase):
77
- """Pure-function proof: the census heuristic actually catches German
78
- prose (RED) and does not false-positive on clean English (GREEN) —
79
- independent of whether dist-kit can be built in this environment."""
80
-
81
- def test_mostly_german_doc_is_red(self):
82
- # Mirrors the pre-translation wave-anchor-template.md shape: a title
83
- # line + a handful of English structural lines + a majority of dense
84
- # German prose paragraphs — comfortably over the 10% line threshold.
85
- german_doc = "\n".join(
86
- ["# Title", "", "## Section", ""]
87
- + ["Diese Zeile beschreibt eine Änderung und ist auf Deutsch geschrieben."] * 3
88
- + ["Der Anker wird für die Wellen-Planung genutzt und muss gepflegt werden."] * 3
89
- + [""] * 14 # padding so German lines are a clear >10% share, not 100%
90
- )
91
- clean, pct = is_below_threshold(german_doc)
92
- self.assertFalse(clean, f"expected RED (>= {GERMAN_LINE_THRESHOLD_PCT}%), got {pct:.1f}%")
93
- self.assertGreaterEqual(pct, GERMAN_LINE_THRESHOLD_PCT)
94
-
95
- def test_all_english_doc_is_green(self):
96
- english_doc = "\n".join(
97
- ["# Title", "", "## Section", "",
98
- "This line describes a change and is written in English.",
99
- "The anchor is used for wave planning and must be maintained.",
100
- ""]
101
- )
102
- clean, pct = is_below_threshold(english_doc)
103
- self.assertTrue(clean, f"expected GREEN (< {GERMAN_LINE_THRESHOLD_PCT}%), got {pct:.1f}%")
104
-
105
- def test_frontmatter_excluded_from_ratio(self):
106
- # A German `description:` inside frontmatter (legitimate — quotes a
107
- # German trigger phrase) must not count toward the body ratio.
108
- text = (
109
- "---\n"
110
- "name: x\n"
111
- 'description: Use when "lass uns das Board durchgehen und die Wellen planen".\n'
112
- "---\n\n"
113
- "This body is English only, nothing else in here at all.\n"
114
- )
115
- clean, pct = is_below_threshold(text)
116
- self.assertTrue(clean, f"frontmatter leaked into ratio: {pct:.1f}%")
117
-
118
- def test_exempt_marker_line_does_not_count_as_a_hit(self):
119
- text = "\n".join(
120
- ["# Title", "Dies ist deutsch und bleibt es. <!-- language-census: ok -->"]
121
- + ["English line."] * 10
122
- )
123
- clean, pct = is_below_threshold(text)
124
- self.assertTrue(clean, f"exempt marker line still counted: {pct:.1f}%")
125
-
126
-
127
- def _build_dist_kit():
128
- """Run the real build (subprocess), mirroring test_dist_kit_smoke.py.
129
- Returns (CompletedProcess, skip_reason); skip_reason set only when node
130
- itself is unavailable in this environment."""
131
- build = REPO / "scripts/build-kit.mjs"
132
- try:
133
- r = subprocess.run(["node", str(build)], cwd=REPO,
134
- capture_output=True, text=True, timeout=120)
135
- except (FileNotFoundError, subprocess.TimeoutExpired) as e:
136
- return None, str(e)
137
- return r, None
138
-
139
-
140
- class DistKitDocsCensus(unittest.TestCase):
141
- """Smoke test over the actually-shipped dist-kit/**/*.md — the ONLY
142
- thing a consumer ever reads, so this is what must stay clean."""
143
-
144
- @classmethod
145
- def setUpClass(cls):
146
- r, skip_reason = _build_dist_kit()
147
- if skip_reason:
148
- raise unittest.SkipTest(f"node/build unavailable: {skip_reason}")
149
- if r.returncode != 0:
150
- raise AssertionError(f"build-kit.mjs failed: {r.stderr}")
151
- if not DIST.is_dir():
152
- raise AssertionError("dist-kit/ missing after build")
153
-
154
- def test_every_shipped_doc_is_under_the_german_line_threshold(self):
155
- violations = []
156
- checked = 0
157
- for path in sorted(DIST.rglob("*.md")):
158
- if not path.is_file():
159
- continue
160
- checked += 1
161
- text = path.read_text(encoding="utf-8", errors="ignore")
162
- clean, pct = is_below_threshold(text)
163
- if not clean:
164
- violations.append((str(path.relative_to(DIST)), pct))
165
-
166
- self.assertGreater(checked, 0, "no dist-kit/**/*.md found — build produced nothing?")
167
- msg = "\n".join(f" {p}: {pct:.1f}% German lines" for p, pct in violations)
168
- self.assertEqual(
169
- violations, [],
170
- f"Census: {checked - len(violations)} of {checked} shipped docs under "
171
- f"{GERMAN_LINE_THRESHOLD_PCT}% German lines.\n\n"
172
- "Translate the residual German prose (or, for a genuine intentional "
173
- "literal, mark the line `<!-- language-census: ok -->`):\n" + msg,
174
- )
175
-
176
-
177
- if __name__ == "__main__":
178
- unittest.main(verbosity=2)
@@ -1,162 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Behavior tests for marker identity lookup and reconciliation."""
3
-
4
- from __future__ import annotations
5
-
6
- import json
7
- import importlib.util
8
- import io
9
- import subprocess
10
- import unittest
11
- from contextlib import redirect_stderr, redirect_stdout
12
- from pathlib import Path
13
- from unittest.mock import patch
14
-
15
- from scripts.marker_lib import find_by_marker, reconcile_after_create
16
-
17
-
18
- CLI_SPEC = importlib.util.spec_from_file_location(
19
- "find_by_marker_cli_test", Path(__file__).with_name("find-by-marker.py"))
20
- cli = importlib.util.module_from_spec(CLI_SPEC)
21
- assert CLI_SPEC.loader is not None
22
- CLI_SPEC.loader.exec_module(cli)
23
-
24
-
25
- class MarkerLookupTest(unittest.TestCase):
26
- def test_pagination_joins_all_pages(self):
27
- calls: list[list[str]] = []
28
-
29
- def fake_gh(args: list[str]) -> str:
30
- calls.append(args)
31
- return json.dumps([
32
- [{"number": 11, "state": "open", "body":
33
- "<!-- prd-source-id: alpha -->"}],
34
- [{"number": 12, "state": "closed", "body":
35
- "<!-- prd-source-id: alpha -->"}],
36
- ])
37
-
38
- result = find_by_marker("example/repo", "prd-source-id", "alpha", fake_gh)
39
-
40
- self.assertEqual(result["count"], 2)
41
- self.assertEqual(result["issues"], [
42
- {"number": 11, "state": "open"},
43
- {"number": 12, "state": "closed"},
44
- ])
45
- self.assertIn("--paginate", calls[0])
46
- self.assertIn("state=all", calls[0])
47
-
48
- def test_pull_requests_are_discarded_before_matching(self):
49
- marker = "<!-- wave-stub-source: same -->"
50
- payload = [[
51
- {"number": 21, "state": "open", "body": marker,
52
- "pull_request": {"url": "https://api.example/pr/21"}},
53
- {"number": 22, "state": "open", "body": marker},
54
- ]]
55
-
56
- result = find_by_marker(
57
- "example/repo", "wave-stub-source", "same",
58
- lambda _args: json.dumps(payload),
59
- )
60
-
61
- self.assertEqual(result["issues"], [{"number": 22, "state": "open"}])
62
-
63
- def test_closed_only_match_requires_user_decision(self):
64
- payload = [[{"number": 31, "state": "closed", "body":
65
- "<!-- prd-source-id: retired -->"}]]
66
-
67
- result = find_by_marker(
68
- "example/repo", "prd-source-id", "retired",
69
- lambda _args: json.dumps(payload),
70
- )
71
-
72
- self.assertEqual(result["verdict"], "user-decision")
73
-
74
- def test_marker_value_is_exact_not_a_prefix_match(self):
75
- payload = [[
76
- {"number": 41, "state": "open", "body":
77
- "<!-- prd-source-id: alpha-long -->"},
78
- {"number": 42, "state": "open", "body":
79
- "<!-- prd-source-id: alpha -->"},
80
- ]]
81
-
82
- result = find_by_marker(
83
- "example/repo", "prd-source-id", "alpha",
84
- lambda _args: json.dumps(payload),
85
- )
86
-
87
- self.assertEqual(result["issues"], [{"number": 42, "state": "open"}])
88
- self.assertEqual(result["verdict"], "update")
89
-
90
- def test_post_create_rescan_stops_and_reports_both_duplicate_numbers(self):
91
- marker = "<!-- program-leaf-source: program-x/1a -->"
92
- responses = iter([
93
- [[]],
94
- [[
95
- {"number": 101, "state": "open", "body": marker},
96
- {"number": 102, "state": "open", "body": marker},
97
- ]],
98
- ])
99
- calls: list[list[str]] = []
100
-
101
- def fake_gh(args: list[str]) -> str:
102
- calls.append(args)
103
- return json.dumps(next(responses))
104
-
105
- before = find_by_marker(
106
- "example/repo", "program-leaf-source", "program-x/1a",
107
- fake_gh,
108
- )
109
- after = reconcile_after_create(
110
- "example/repo", "program-leaf-source", "program-x/1a", 101,
111
- fake_gh,
112
- )
113
-
114
- self.assertEqual(before["verdict"], "create")
115
- self.assertEqual(after["verdict"], "STOP")
116
- self.assertEqual([issue["number"] for issue in after["issues"]], [101, 102])
117
- self.assertEqual(len(calls), 2)
118
-
119
- def test_post_create_rescan_rejects_a_different_single_issue(self):
120
- payload = [[{"number": 102, "state": "open", "body":
121
- "<!-- prd-source-id: alpha -->"}]]
122
-
123
- result = reconcile_after_create(
124
- "example/repo", "prd-source-id", "alpha", 101,
125
- lambda _args: json.dumps(payload),
126
- )
127
-
128
- self.assertEqual(result["verdict"], "STOP")
129
- self.assertEqual(result["issues"], [{"number": 102, "state": "open"}])
130
-
131
- def test_unknown_marker_kind_has_distinct_cli_error(self):
132
- out, err = io.StringIO(), io.StringIO()
133
- with redirect_stdout(out), redirect_stderr(err):
134
- code = cli.main(
135
- ["--kind", "unknown-source", "--slug", "x"],
136
- repo="example/repo",
137
- gh=lambda _args: self.fail("unknown kinds must fail before scanning"),
138
- )
139
-
140
- self.assertEqual(code, 2)
141
- self.assertEqual(out.getvalue(), "")
142
- self.assertIn("unknown marker kind", err.getvalue())
143
-
144
- def test_cli_bounds_a_hanging_github_call(self):
145
- with patch.object(
146
- cli.subprocess, "run",
147
- side_effect=subprocess.TimeoutExpired("gh", 30),
148
- ):
149
- with self.assertRaisesRegex(RuntimeError, "timed out after 30s"):
150
- cli._gh(["api", "--paginate", "repos/example/repo/issues"])
151
-
152
- def test_cli_classifies_a_missing_github_binary(self):
153
- with patch.object(
154
- cli.subprocess, "run",
155
- side_effect=FileNotFoundError(2, "No such file or directory", "gh"),
156
- ):
157
- with self.assertRaisesRegex(RuntimeError, "gh CLI unavailable"):
158
- cli._gh(["api", "--paginate", "repos/example/repo/issues"])
159
-
160
-
161
- if __name__ == "__main__":
162
- unittest.main()