@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.34 → 0.1.37

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 (104) hide show
  1. package/bin/adl +2 -2
  2. package/bin/reconcile +4 -3
  3. package/package.json +1 -1
  4. package/packages/agent-hooks/README.md +14 -0
  5. package/packages/agent-hooks/assets/opencode-plugin.template.ts +140 -104
  6. package/packages/agent-hooks/deploy-profile.json +1 -1
  7. package/packages/agent-hooks/src/deploy/opencode-template.ts +5 -18
  8. package/packages/agent-hooks/src/deploy/render.ts +12 -3
  9. package/packages/agent-hooks/src/pre-tool-use/handler.ts +2 -2
  10. package/packages/agent-hooks/src/rules/impl/adl-build-run-role.ts +116 -0
  11. package/packages/agent-hooks/src/rules/impl/adl-git-user-override-block.ts +159 -0
  12. package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +12 -6
  13. package/packages/agent-hooks/src/rules/impl/adl-pr-complete-pending.ts +9 -9
  14. package/packages/agent-hooks/src/rules/impl/adl-pr-role.ts +6 -5
  15. package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +15 -14
  16. package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
  17. package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +1 -1
  18. package/packages/agent-hooks/src/rules/impl/tool-native-search.ts +8 -7
  19. package/packages/agent-hooks/src/rules/registry.ts +11 -2
  20. package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +9 -5
  21. package/packages/agent-settings/README.md +10 -4
  22. package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +40 -0
  23. package/packages/agent-settings/src/global-agents.ts +43 -8
  24. package/packages/agent-skills/assets/adl-mechanics/SKILL.md +38 -0
  25. package/packages/agent-skills/assets/agent-search-tool-audit/scripts/audit.sh +1 -1
  26. package/packages/agent-skills/assets/code-review/SKILL.md +84 -66
  27. package/packages/agent-skills/assets/diagnosing-bugs/SKILL.md +23 -2
  28. package/packages/agent-skills/assets/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +1 -1
  29. package/packages/agent-skills/assets/explore-codebase/SKILL.md +3 -2
  30. package/packages/agent-skills/assets/finalize-feature/SKILL.md +63 -0
  31. package/packages/agent-skills/assets/fix-ci/SKILL.md +58 -0
  32. package/packages/agent-skills/assets/handoff/SKILL.md +1 -1
  33. package/packages/agent-skills/assets/implement-feature/SKILL.md +153 -0
  34. package/packages/agent-skills/assets/implement-feature/references/briefs.md +94 -0
  35. package/packages/agent-skills/assets/implement-item/SKILL.md +71 -0
  36. package/packages/agent-skills/assets/mechanics/SKILL.md +38 -0
  37. package/packages/agent-skills/assets/plan-feature/SKILL.md +141 -0
  38. package/packages/agent-skills/assets/prepare-promotion/SKILL.md +91 -0
  39. package/packages/agent-skills/assets/ralplan/SKILL.md +149 -0
  40. package/packages/agent-skills/assets/refactor-safely/SKILL.md +4 -3
  41. package/packages/agent-skills/assets/report-bug/SKILL.md +72 -0
  42. package/packages/agent-skills/assets/review-change/SKILL.md +82 -0
  43. package/packages/agent-skills/assets/review-plan/SKILL.md +124 -0
  44. package/packages/agent-skills/assets/to-feature/SKILL.md +1 -1
  45. package/packages/agent-skills/assets/to-workitems/SKILL.md +1 -1
  46. package/packages/agent-skills/assets/to-workitems/references/AZURE-DEVOPS-HIERARCHY.md +3 -0
  47. package/packages/agent-skills/assets/triage-workitems/SKILL.md +99 -0
  48. package/packages/agent-skills/assets/ultragoal/SKILL.md +77 -0
  49. package/packages/agent-skills/assets/validate-design/SKILL.md +70 -0
  50. package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
  51. package/packages/agent-skills/assets/workitems-process/SKILL.md +147 -0
  52. package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +194 -0
  53. package/packages/agent-skills/assets/workitems-process/references/evidence.md +74 -0
  54. package/packages/agent-skills/assets/workitems-process/references/workitem-states.md +92 -0
  55. package/packages/agent-skills/assets/write-feature/SKILL.md +95 -0
  56. package/packages/agent-skills/dist/deploy.d.ts +7 -0
  57. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  58. package/packages/agent-skills/dist/deploy.js +52 -5
  59. package/packages/agent-skills/dist/deploy.js.map +1 -1
  60. package/packages/agent-skills/src/deploy.ts +48 -4
  61. package/packages/azdo/scripts/probe-ado-rules.ts +1 -1
  62. package/packages/azdo/src/activity-log.ts +118 -0
  63. package/packages/azdo/src/azdo-attachments.ts +108 -0
  64. package/packages/azdo/src/azdo-pl.ts +100 -3
  65. package/packages/azdo/src/azdo-pr.ts +194 -11
  66. package/packages/azdo/src/azdo-wit.ts +329 -18
  67. package/packages/azdo/src/cli.ts +42 -2
  68. package/packages/azdo/src/lib/agent-identity.ts +1 -1
  69. package/packages/azdo/src/lib/client.ts +7 -1
  70. package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
  71. package/packages/azdo/src/mcp-server.ts +53 -5
  72. package/packages/azdo/src/tool-dispatch.ts +53 -4
  73. package/packages/azdo/src/workflow-ladder.csv +125 -107
  74. package/packages/azdo/src/workflow-rules.ts +254 -97
  75. package/packages/repo-controls/README.md +24 -11
  76. package/packages/repo-controls/assets/scaffold/docs/AGENTS.md +5 -5
  77. package/packages/repo-controls/assets/scaffold/plans/AGENTS.md +42 -13
  78. package/packages/repo-controls/assets/scaffold/root/AGENTS.md +60 -11
  79. package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +28 -0
  80. package/packages/repo-controls/assets/scaffold/root/INVARIANTS.md +17 -0
  81. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/closed_set/test_check.py +0 -11
  82. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/filename/test_check.py +4 -6
  83. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/ledger/test_check.py +15 -0
  84. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/docs-okf-profiles.yaml +1 -17
  85. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/test_check.py +3 -22
  86. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/check.py +39 -182
  87. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/fixtures/open-work-cap-clean/plans/features/az601-clean/intent.md +17 -0
  88. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +90 -321
  89. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +31 -5
  90. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +282 -54
  91. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/design.md +35 -0
  92. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/file-map-reference.md +19 -0
  93. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/intent.md +25 -0
  94. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/items/az351-restore-entrypoint.md +25 -0
  95. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/requirements.md +44 -0
  96. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-violation/plans/features/az347-test-coverage/requirements.md +44 -0
  97. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +646 -53
  98. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/cli.py +3 -3
  99. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/docs_shape.py +1 -12
  100. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.alt.yaml +1 -1
  101. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.yaml +1 -1
  102. package/packages/repo-controls/src/cli.ts +23 -5
  103. package/packages/repo-controls/src/constants.ts +2 -15
  104. package/packages/repo-controls/src/install.ts +15 -6
@@ -1,4 +1,12 @@
1
- """LAY-016 — the exact active-work lifecycle and the ten-item boundary."""
1
+ """LAY-016 — at most ten open plans, by default.
2
+
3
+ R7 (ADL process handover, 2026-10-12) retired `plans/GAPS-AND-FINDINGS.md`
4
+ and with it the open-gaps half of this gate: the register columns, the
5
+ at-most-ten-open-gaps rule and the `open-gaps` declaration are gone. The
6
+ open-plans cap stays. A Feature's state is read from its `intent.md` — the one
7
+ plan file that exists from the Feature's first day (R2); plan files carry the
8
+ five Feature states (R1).
9
+ """
2
10
 
3
11
  from __future__ import annotations
4
12
 
@@ -17,44 +25,19 @@ from adl_compliance.policy.registry import load
17
25
  ROW = load().by_id("LAY-016")
18
26
  AZ = "https://dev.azure.com/org/project/_workitems/edit"
19
27
  CLEAN = pathlib.Path(__file__).parent / "fixtures" / "open-work-cap-clean"
20
- HEADER = (
21
- "| Found | Finding | From plan | Item file | Work item |\n"
22
- "| --- | --- | --- | --- | --- |\n"
23
- )
24
-
25
-
26
- def spec(status: str, item: int) -> str:
27
- return f"---\ntype: feature\nstatus: {status}\nwork_item: {AZ}/{item}\n---\n"
28
28
 
29
29
 
30
- def item(status: str, item_id: int) -> str:
31
- return f"---\ntype: bug\nstatus: {status}\nwork_item: {AZ}/{item_id}\n---\n"
30
+ def intent(status: str, item: int) -> str:
31
+ return f"---\ntype: intent\nstatus: {status}\nwork_item: {AZ}/{item}\n---\n"
32
32
 
33
33
 
34
34
  def features(count: int, status: str = "New") -> dict[str, str]:
35
35
  return {
36
- f"plans/features/az{600 + n}-feature-{n}/spec.md": spec(status, 600 + n)
36
+ f"plans/features/az{600 + n}-feature-{n}/intent.md": intent(status, 600 + n)
37
37
  for n in range(1, count + 1)
38
38
  }
39
39
 
40
40
 
41
- def register(*rows: str) -> dict[str, str]:
42
- return {"plans/GAPS-AND-FINDINGS.md": HEADER + "".join(rows)}
43
-
44
-
45
- def unraised(n: int, finding: str | None = None) -> str:
46
- return (
47
- f"| 2026-09-17 | {finding or f'Unraised gap {n}'} | spec.md | TBD | TBD |\n"
48
- )
49
-
50
-
51
- def linked(n: int, path: str) -> str:
52
- return (
53
- f"| 2026-09-17 | Linked gap {n} | spec.md | `{path}` | "
54
- f"[{n}]({AZ}/{n}) |\n"
55
- )
56
-
57
-
58
41
  def build(root: pathlib.Path, files: dict[str, str]) -> pathlib.Path:
59
42
  for relative, body in files.items():
60
43
  target = root / relative
@@ -79,11 +62,8 @@ def test_the_committed_clean_corpus_is_silent() -> None:
79
62
  assert verdict.outcome is Outcome.CLEAN, verdict.render()
80
63
 
81
64
 
82
- @pytest.mark.parametrize(
83
- "status",
84
- ("New", "In Planning", "Awaiting Approval", "Approved", "Spec Validation", "In Implementation", "In Testing", "In Review", "Updating Docs"),
85
- )
86
- def test_each_exact_active_plan_state_counts_as_open(
65
+ @pytest.mark.parametrize("status", ("New", "In Progress", "Awaiting Release"))
66
+ def test_each_exact_active_feature_state_counts_as_open(
87
67
  tmp_path: pathlib.Path, status: str
88
68
  ) -> None:
89
69
  verdict = grade(build(tmp_path, features(11, status)))
@@ -92,60 +72,72 @@ def test_each_exact_active_plan_state_counts_as_open(
92
72
 
93
73
 
94
74
  @pytest.mark.parametrize("status", ("Done", "Removed"))
95
- def test_each_exact_terminal_plan_state_is_closed(
75
+ def test_each_exact_terminal_feature_state_is_closed(
96
76
  tmp_path: pathlib.Path, status: str
97
77
  ) -> None:
98
78
  tree = features(10)
99
79
  tree.update(
100
80
  {
101
- f"plans/features/az999-{status.casefold()}/spec.md": spec(status, 999),
81
+ f"plans/features/az999-{status.casefold()}/intent.md": intent(status, 999),
102
82
  }
103
83
  )
104
84
  verdict = grade(build(tmp_path, tree))
105
85
  assert verdict.outcome is Outcome.CLEAN, verdict.render()
106
86
 
107
87
 
108
- @pytest.mark.parametrize("status", ("Active", "Closed", "Completed", "new", ""))
88
+ @pytest.mark.parametrize("status", ("Approved", "In Implementation", "new", ""))
109
89
  def test_a_plan_status_outside_the_exact_board_vocabulary_is_could_not_run(
110
90
  tmp_path: pathlib.Path, status: str
111
91
  ) -> None:
112
92
  verdict = grade(
113
93
  build(
114
94
  tmp_path,
115
- {"plans/features/az601-invalid/spec.md": spec(status, 601)},
95
+ {"plans/features/az601-invalid/intent.md": intent(status, 601)},
116
96
  )
117
97
  )
118
98
  assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
119
99
  assert "work item state" in verdict.message
120
100
 
121
101
 
122
- def test_a_plan_spec_must_belong_to_the_git_visible_file_set(
102
+ def test_a_plan_intent_must_belong_to_the_git_visible_file_set(
123
103
  tmp_path: pathlib.Path,
124
104
  ) -> None:
125
- spec_path = "plans/features/az601-feature/spec.md"
105
+ intent_path = "plans/features/az601-feature/intent.md"
126
106
  item_path = "plans/features/az601-feature/items/az701-visible-item.md"
127
107
  build(
128
108
  tmp_path,
129
109
  {
130
- spec_path: spec("New", 601),
131
- item_path: item("New", 701),
110
+ intent_path: intent("New", 601),
111
+ item_path: "---\ntype: pbi\nstatus: New\n---\n",
132
112
  },
133
113
  )
134
114
  verdict = grade(tmp_path, paths=(item_path,))
135
115
  assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
136
- assert spec_path in verdict.message
116
+ assert intent_path in verdict.message
137
117
  assert "Git-visible file set" in verdict.message
138
118
 
139
119
 
140
120
  def test_status_text_in_the_body_cannot_set_a_plan_state(tmp_path: pathlib.Path) -> None:
141
- body = "---\ntype: feature\n---\n\n# Notes\n\nstatus: Done\n"
121
+ body = "---\ntype: intent\n---\n\n# Notes\n\nstatus: Done\n"
142
122
  verdict = grade(
143
- build(tmp_path, {"plans/features/az601-body-status/spec.md": body})
123
+ build(tmp_path, {"plans/features/az601-body-status/intent.md": body})
144
124
  )
145
125
  assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
146
126
  assert "missing status" in verdict.message
147
127
 
148
128
 
129
+ def test_a_feature_folder_without_an_intent_is_not_counted(
130
+ tmp_path: pathlib.Path,
131
+ ) -> None:
132
+ """A mid-migration Feature (no intent.md yet) neither counts as open nor
133
+ poisons the cap: LAY-009 reports the missing file."""
134
+ tree = features(11)
135
+ del tree["plans/features/az601-feature-1/intent.md"]
136
+ verdict = grade(build(tmp_path, tree))
137
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
138
+ assert "az601-feature-1" not in verdict.render()
139
+
140
+
149
141
  def test_exactly_ten_open_plans_pass_and_eleven_fail_with_every_name(
150
142
  tmp_path: pathlib.Path,
151
143
  ) -> None:
@@ -167,7 +159,7 @@ def test_a_partial_file_set_counts_the_complete_plan_tree(
167
159
  verdict = runner.run(
168
160
  ROW,
169
161
  root,
170
- ("plans/features/az601-feature-1/spec.md",),
162
+ ("plans/features/az601-feature-1/intent.md",),
171
163
  exhaustive=False,
172
164
  universe=universe,
173
165
  trees={"plans": "plans"},
@@ -177,167 +169,45 @@ def test_a_partial_file_set_counts_the_complete_plan_tree(
177
169
  assert "11 open plans, cap is 10" in verdict.render()
178
170
 
179
171
 
180
- @pytest.mark.parametrize(
181
- "status",
182
- ("New", "In Planning", "Awaiting Approval", "Approved", "Spec Validation", "In Implementation", "In Testing", "In Review", "Updating Docs"),
183
- )
184
- def test_unraised_and_each_linked_active_gap_state_both_count_as_open(
185
- tmp_path: pathlib.Path, status: str
186
- ) -> None:
187
- item_path = "plans/features/az601-feature/items/az701-active-gap.md"
188
- tree = {
189
- **register(unraised(1), *(linked(701, item_path) for _ in range(10))),
190
- "plans/features/az601-feature/spec.md": spec("Done", 601),
191
- item_path: item(status, 701),
192
- }
193
- verdict = grade(build(tmp_path, tree))
194
- assert verdict.outcome is Outcome.FINDINGS, verdict.render()
195
- assert "11 open gaps, cap is 10" in verdict.render()
196
-
197
-
198
- @pytest.mark.parametrize("status", ("Done", "Removed"))
199
- def test_each_linked_terminal_gap_state_must_be_deleted_from_the_register(
200
- tmp_path: pathlib.Path, status: str
201
- ) -> None:
202
- item_path = "plans/features/az601-feature/items/az701-closed-gap.md"
203
- tree = {
204
- **register(linked(701, item_path)),
205
- "plans/features/az601-feature/spec.md": spec("Done", 601),
206
- item_path: item(status, 701),
207
- }
208
- verdict = grade(build(tmp_path, tree))
209
- assert verdict.outcome is Outcome.FINDINGS, verdict.render()
210
- assert "closed gap" in verdict.render().casefold()
211
- assert "delete" in verdict.findings[0].fix.casefold()
212
-
213
-
214
- @pytest.mark.parametrize(
215
- "work_item",
216
- (
217
- "ticket 701",
218
- f"[701]({AZ}/702)",
219
- f"701 ({AZ}/701)",
220
- ),
221
- )
222
- def test_a_malformed_work_item_cell_is_could_not_run_not_a_raised_gap(
223
- tmp_path: pathlib.Path, work_item: str
224
- ) -> None:
225
- row = f"| 2026-09-17 | Gap | spec.md | `item.md` | {work_item} |\n"
226
- verdict = grade(build(tmp_path, register(row)))
227
- assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
228
- assert "work item link" in verdict.message
229
-
172
+ # --------------------------------------------------------------- R7: retired
230
173
 
231
- def test_a_linked_gap_requires_the_named_item_file(tmp_path: pathlib.Path) -> None:
232
- verdict = grade(
233
- build(
234
- tmp_path,
235
- register(linked(701, "plans/features/az601-feature/items/az701-missing.md")),
236
- )
237
- )
238
- assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
239
- assert "item file" in verdict.message
240
-
241
-
242
- @pytest.mark.parametrize("spelling", ("absolute", "parent-relative"))
243
- def test_a_linked_gap_rejects_an_item_path_outside_the_repository(
244
- tmp_path: pathlib.Path, spelling: str
245
- ) -> None:
246
- outside = tmp_path.parent / f"{tmp_path.name}-outside"
247
- outside_item = (
248
- outside
249
- / "plans/features/az601-feature/items/az701-outside-gap.md"
250
- )
251
- outside_item.parent.mkdir(parents=True, exist_ok=True)
252
- outside_item.write_text(item("New", 701), encoding="utf-8")
253
- if spelling == "absolute":
254
- item_path = str(outside_item)
255
- else:
256
- item_path = str(outside_item.relative_to(tmp_path.parent))
257
- item_path = f"../{item_path}"
258
- verdict = grade(build(tmp_path, register(linked(701, item_path))))
259
- assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
260
- assert "normalized repository-relative" in verdict.message
261
-
262
-
263
- def test_a_linked_gap_item_must_belong_to_the_git_visible_file_set(
264
- tmp_path: pathlib.Path,
265
- ) -> None:
266
- item_path = "plans/features/az601-feature/items/az701-untracked-gap.md"
267
- tree = {
268
- **register(linked(701, item_path)),
269
- "plans/features/az601-feature/spec.md": spec("Done", 601),
270
- item_path: item("New", 701),
271
- }
272
- build(tmp_path, tree)
273
- verdict = grade(tmp_path, paths=("plans/GAPS-AND-FINDINGS.md",))
274
- assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
275
- assert "Git-visible file set" in verdict.message
276
-
277
-
278
- def test_a_linked_gap_with_an_invalid_item_state_is_could_not_run(
174
+ def test_a_gaps_register_at_the_plans_root_is_not_read(
279
175
  tmp_path: pathlib.Path,
280
176
  ) -> None:
281
- item_path = "plans/features/az601-feature/items/az701-invalid-gap.md"
282
- tree = {
283
- **register(linked(701, item_path)),
284
- "plans/features/az601-feature/spec.md": spec("Done", 601),
285
- item_path: item("Closed", 701),
286
- }
177
+ """R7: `plans/GAPS-AND-FINDINGS.md` is retired -- its content grades nothing
178
+ and its absence is not required either."""
179
+ tree = features(1)
180
+ tree["plans/GAPS-AND-FINDINGS.md"] = "not a table at all\n"
287
181
  verdict = grade(build(tmp_path, tree))
288
- assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
289
- assert "work item state" in verdict.message
290
-
291
-
292
- def test_an_escaped_pipe_in_a_finding_does_not_break_the_table(
293
- tmp_path: pathlib.Path,
294
- ) -> None:
295
- rows = tuple(unraised(n, rf"Gap {n} uses a \| b") for n in range(1, 11))
296
- verdict = grade(build(tmp_path, register(*rows)))
297
- assert verdict.outcome is Outcome.CLEAN, verdict.render()
298
-
299
-
300
- def test_a_short_all_dash_divider_row_is_could_not_run(tmp_path: pathlib.Path) -> None:
301
- malformed = {"plans/GAPS-AND-FINDINGS.md": HEADER.splitlines()[0] + "\n| --- | --- |\n"}
302
- verdict = grade(build(tmp_path, malformed))
303
- assert verdict.outcome is Outcome.COULD_NOT_RUN, verdict.render()
304
- assert "2 cells" in verdict.message
305
-
306
-
307
- def test_an_on_disk_register_outside_the_git_visible_set_is_not_read(
308
- tmp_path: pathlib.Path,
309
- ) -> None:
310
- spec_path = "plans/features/az601-feature/spec.md"
311
- tree = {
312
- spec_path: spec("Done", 601),
313
- **register(*(unraised(n) for n in range(1, 12))),
314
- }
315
- build(tmp_path, tree)
316
- verdict = grade(tmp_path, paths=(spec_path,))
317
182
  assert verdict.outcome is Outcome.CLEAN, verdict.render()
318
183
 
319
184
 
320
- def test_exactly_ten_open_gaps_pass_and_eleven_fail_with_every_row(
185
+ def test_the_retired_open_gaps_declaration_is_ignored(
321
186
  tmp_path: pathlib.Path,
322
187
  ) -> None:
323
- ten = grade(build(tmp_path / "ten", register(*(unraised(n) for n in range(1, 11)))))
324
- eleven = grade(
325
- build(tmp_path / "eleven", register(*(unraised(n) for n in range(1, 12))))
188
+ """R7: `open-gaps` named a cap that no longer exists; an unreadable value
189
+ for it is not a could-not-run of the plan cap that does."""
190
+ root = build(tmp_path, features(1))
191
+ ctx = Context(
192
+ row=ROW,
193
+ root=root,
194
+ inputs={"plans_root": root / "plans"},
195
+ declarations={"open-gaps": "abc"},
326
196
  )
327
- assert ten.outcome is Outcome.CLEAN, ten.render()
328
- assert eleven.outcome is Outcome.FINDINGS, eleven.render()
329
- finding = next(f for f in eleven.findings if "open gaps" in f.message)
330
- for n in range(1, 12):
331
- assert f"Unraised gap {n}" in finding.message
197
+ verdict = decide(
198
+ ROW,
199
+ tuple(OpenWorkCap().run(ctx, FileSet(paths=tuple(features(1))))),
200
+ advisories=(),
201
+ )
202
+ assert verdict.outcome is Outcome.CLEAN, verdict.render()
332
203
 
333
204
 
334
- # ------------------------------------------------- the two caps are declarable
335
- # `[controls.compliance.limits].open_gaps` / `.open_plans` reach the engine as
336
- # `--declare open-gaps=N` / `--declare open-plans=N`. The engine default stays
337
- # ten for each; a repository that carries more open work today states its own
338
- # cap in its adl-config.toml, and a value the engine cannot read is a
339
- # could-not-run, never a quiet fall back to ten. The two caps are independent.
340
- GAPS_DECLARATION = "open-gaps"
205
+ # ------------------------------------------------------- the cap is declarable
206
+ # `[controls.compliance.limits].open_plans` reaches the engine as
207
+ # `--declare open-plans=N`. The engine default stays ten; a repository that
208
+ # carries more open Features today states its own cap in its adl-config.toml,
209
+ # and a value the engine cannot read is a could-not-run, never a quiet fall
210
+ # back to ten.
341
211
  PLANS_DECLARATION = "open-plans"
342
212
 
343
213
 
@@ -355,37 +225,15 @@ def grade_declared(
355
225
  return decide(ROW, tuple(OpenWorkCap().run(ctx, FileSet(paths=paths))), advisories=())
356
226
 
357
227
 
358
- def gaps(count: int) -> dict[str, str]:
359
- return register(*(unraised(n) for n in range(1, count + 1)))
360
-
361
-
362
228
  def test_no_declaration_keeps_the_engine_default_of_ten() -> None:
363
229
  ctx = Context(row=ROW, root=CLEAN)
364
- assert OpenWorkCap().open_gaps_limit(ctx) == 10
365
230
  assert OpenWorkCap().open_plans_limit(ctx) == 10
366
231
  assert OpenWorkCap.OPEN_LIMIT == 10
367
232
 
368
233
 
369
- def test_a_declared_gap_cap_is_the_effective_gap_cap_and_leaves_the_plan_cap() -> None:
370
- ctx = Context(row=ROW, root=CLEAN, declarations={GAPS_DECLARATION: "37"})
371
- assert OpenWorkCap().open_gaps_limit(ctx) == 37
372
- assert OpenWorkCap().open_plans_limit(ctx) == 10
373
-
374
-
375
- def test_a_declared_plan_cap_is_the_effective_plan_cap_and_leaves_the_gap_cap() -> None:
234
+ def test_a_declared_plan_cap_is_the_effective_plan_cap() -> None:
376
235
  ctx = Context(row=ROW, root=CLEAN, declarations={PLANS_DECLARATION: "11"})
377
236
  assert OpenWorkCap().open_plans_limit(ctx) == 11
378
- assert OpenWorkCap().open_gaps_limit(ctx) == 10
379
-
380
-
381
- def test_a_declared_gap_cap_accepts_gaps_the_default_would_refuse(
382
- tmp_path: pathlib.Path,
383
- ) -> None:
384
- root = build(tmp_path, gaps(14))
385
- assert grade(root).outcome is Outcome.FINDINGS
386
- result = grade_declared(root, {GAPS_DECLARATION: "14"})
387
- assert result.outcome is Outcome.CLEAN, result.render()
388
- assert result.findings == ()
389
237
 
390
238
 
391
239
  def test_a_declared_plan_cap_accepts_plans_the_default_would_refuse(
@@ -398,19 +246,6 @@ def test_a_declared_plan_cap_accepts_plans_the_default_would_refuse(
398
246
  assert result.findings == ()
399
247
 
400
248
 
401
- def test_over_a_declared_gap_cap_the_finding_names_the_declared_cap_not_ten(
402
- tmp_path: pathlib.Path,
403
- ) -> None:
404
- result = grade_declared(build(tmp_path, gaps(16)), {GAPS_DECLARATION: "15"})
405
- assert result.outcome is Outcome.FINDINGS, result.render()
406
- assert len(result.findings) == 1
407
- text = result.render()
408
- assert "16 open gaps, cap is 15" in text, text
409
- assert "cap is 10" not in text, text
410
- for n in range(1, 17):
411
- assert f"Unraised gap {n}" in text
412
-
413
-
414
249
  def test_over_a_declared_plan_cap_the_finding_names_the_declared_cap_not_ten(
415
250
  tmp_path: pathlib.Path,
416
251
  ) -> None:
@@ -422,18 +257,6 @@ def test_over_a_declared_plan_cap_the_finding_names_the_declared_cap_not_ten(
422
257
  assert "cap is 10" not in text, text
423
258
 
424
259
 
425
- @pytest.mark.parametrize("cap", (1, 3, 10, 25))
426
- def test_exactly_the_declared_gap_cap_passes_and_one_more_fails(
427
- tmp_path: pathlib.Path, cap: int
428
- ) -> None:
429
- declared = {GAPS_DECLARATION: str(cap)}
430
- at_cap = grade_declared(build(tmp_path / "at", gaps(cap)), declared)
431
- over = grade_declared(build(tmp_path / "over", gaps(cap + 1)), declared)
432
- assert at_cap.outcome is Outcome.CLEAN, at_cap.render()
433
- assert over.outcome is Outcome.FINDINGS, over.render()
434
- assert f"{cap + 1} open gaps, cap is {cap}" in over.render()
435
-
436
-
437
260
  @pytest.mark.parametrize("cap", (1, 3, 10, 25))
438
261
  def test_exactly_the_declared_plan_cap_passes_and_one_more_fails(
439
262
  tmp_path: pathlib.Path, cap: int
@@ -449,120 +272,66 @@ def test_exactly_the_declared_plan_cap_passes_and_one_more_fails(
449
272
  def test_a_declared_cap_below_ten_refuses_what_the_default_would_accept(
450
273
  tmp_path: pathlib.Path,
451
274
  ) -> None:
452
- root = build(tmp_path, {**gaps(5), **features(5)})
275
+ root = build(tmp_path, features(5))
453
276
  assert grade(root).outcome is Outcome.CLEAN
454
- result = grade_declared(root, {GAPS_DECLARATION: "4", PLANS_DECLARATION: "4"})
277
+ result = grade_declared(root, {PLANS_DECLARATION: "4"})
455
278
  assert result.outcome is Outcome.FINDINGS, result.render()
456
- text = result.render()
457
- assert "5 open gaps, cap is 4" in text, text
458
- assert "5 open plans, cap is 4" in text, text
279
+ assert "5 open plans, cap is 4" in result.render(), result.render()
459
280
 
460
281
 
461
282
  def test_declaring_ten_grades_exactly_like_no_declaration(tmp_path: pathlib.Path) -> None:
462
- root = build(tmp_path, {**gaps(11), **features(11)})
463
- declared = grade_declared(root, {GAPS_DECLARATION: "10", PLANS_DECLARATION: "10"})
283
+ root = build(tmp_path, features(11))
284
+ declared = grade_declared(root, {PLANS_DECLARATION: "10"})
464
285
  default = grade(root)
465
286
  assert declared.outcome is default.outcome is Outcome.FINDINGS
466
287
  assert [f.render() for f in declared.findings] == [f.render() for f in default.findings]
467
- assert "11 open gaps, cap is 10" in declared.render()
468
288
  assert "11 open plans, cap is 10" in declared.render()
469
289
 
470
290
 
471
- def test_the_gap_cap_does_not_move_the_plan_cap(tmp_path: pathlib.Path) -> None:
472
- root = build(tmp_path, {**gaps(15), **features(11)})
473
- result = grade_declared(root, {GAPS_DECLARATION: "15"})
474
- assert result.outcome is Outcome.FINDINGS, result.render()
475
- assert len(result.findings) == 1
476
- text = result.render()
477
- assert "11 open plans, cap is 10" in text, text
478
- assert "open gaps" not in text, text
479
-
480
-
481
- def test_the_plan_cap_does_not_move_the_gap_cap(tmp_path: pathlib.Path) -> None:
482
- root = build(tmp_path, {**gaps(11), **features(15)})
483
- result = grade_declared(root, {PLANS_DECLARATION: "15"})
484
- assert result.outcome is Outcome.FINDINGS, result.render()
485
- assert len(result.findings) == 1
486
- text = result.render()
487
- assert "11 open gaps, cap is 10" in text, text
488
- assert "open plans" not in text, text
489
-
490
-
491
- def test_both_caps_declared_together_each_apply_to_their_own_count(
492
- tmp_path: pathlib.Path,
493
- ) -> None:
494
- root = build(tmp_path, {**gaps(37), **features(11)})
495
- result = grade_declared(root, {GAPS_DECLARATION: "37", PLANS_DECLARATION: "11"})
496
- assert result.outcome is Outcome.CLEAN, result.render()
497
- one_below = grade_declared(root, {GAPS_DECLARATION: "36", PLANS_DECLARATION: "10"})
498
- assert one_below.outcome is Outcome.FINDINGS, one_below.render()
499
- text = one_below.render()
500
- assert "37 open gaps, cap is 36" in text, text
501
- assert "11 open plans, cap is 10" in text, text
502
-
503
-
504
- def test_an_unrelated_declaration_does_not_change_either_cap(tmp_path: pathlib.Path) -> None:
505
- root = build(tmp_path, {**gaps(11), **features(11)})
291
+ def test_an_unrelated_declaration_does_not_change_the_cap(tmp_path: pathlib.Path) -> None:
292
+ root = build(tmp_path, features(11))
506
293
  result = grade_declared(
507
294
  root,
508
295
  {"license": "Proprietary", "root-dirs": "apps", "scripts-direct-files": "20"},
509
296
  )
510
- text = result.render()
511
- assert "11 open gaps, cap is 10" in text, text
512
- assert "11 open plans, cap is 10" in text, text
297
+ assert "11 open plans, cap is 10" in result.render(), result.render()
513
298
 
514
299
 
515
300
  def test_the_remedy_states_the_effective_cap_and_never_hard_codes_ten(
516
301
  tmp_path: pathlib.Path,
517
302
  ) -> None:
518
- root = build(tmp_path, {**gaps(16), **features(13)})
519
- result = grade_declared(root, {GAPS_DECLARATION: "15", PLANS_DECLARATION: "12"})
520
- by_subject = {
521
- ("gaps" if "open gaps" in f.message else "plans"): f.fix for f in result.findings
522
- }
523
- assert "no more than 15 remain" in by_subject["gaps"], by_subject
524
- assert "no more than 12 remain" in by_subject["plans"], by_subject
525
- for fix in by_subject.values():
526
- assert re.search(r"\bten\b", fix, re.IGNORECASE) is None, fix
303
+ result = grade_declared(build(tmp_path, features(13)), {PLANS_DECLARATION: "12"})
304
+ fix = result.findings[0].fix
305
+ assert "no more than 12 remain" in fix, fix
306
+ assert re.search(r"\bten\b", fix, re.IGNORECASE) is None, fix
527
307
 
528
308
 
529
309
  def test_the_default_remedy_states_the_cap_as_a_number(tmp_path: pathlib.Path) -> None:
530
- result = grade(build(tmp_path, {**gaps(11), **features(11)}))
531
- assert len(result.findings) == 2
532
- for finding in result.findings:
533
- assert "no more than 10 remain" in finding.fix, finding.fix
534
- assert re.search(r"\bten\b", finding.fix, re.IGNORECASE) is None, finding.fix
310
+ result = grade(build(tmp_path, features(11)))
311
+ assert len(result.findings) == 1
312
+ assert "no more than 10 remain" in result.findings[0].fix, result.findings[0].fix
313
+ assert re.search(r"\bten\b", result.findings[0].fix, re.IGNORECASE) is None
535
314
 
536
315
 
537
316
  UNREADABLE = ["abc", "0", "-3", "2.5", "", "+20", "1_0", "20"]
538
317
 
539
318
 
540
319
  @pytest.mark.parametrize("value", UNREADABLE)
541
- @pytest.mark.parametrize("name", (GAPS_DECLARATION, PLANS_DECLARATION))
542
320
  def test_an_unreadable_declared_cap_could_not_run(
543
- tmp_path: pathlib.Path, name: str, value: str
321
+ tmp_path: pathlib.Path, value: str
544
322
  ) -> None:
545
323
  """Never a silent fall back to ten: not a base-10 positive integer is loud."""
546
- root = build(tmp_path, {**features(1), **gaps(1)})
324
+ root = build(tmp_path, features(1))
547
325
  with pytest.raises(CouldNotRun) as raised:
548
- grade_declared(root, {name: value})
326
+ grade_declared(root, {PLANS_DECLARATION: value})
549
327
  message = str(raised.value)
550
- assert name in message, message
328
+ assert PLANS_DECLARATION in message, message
551
329
  assert repr(value) in message, message
552
330
  assert could_not_run(ROW, message).outcome is Outcome.COULD_NOT_RUN
553
331
 
554
332
 
555
- def test_a_bad_declaration_for_the_cap_that_is_not_exceeded_is_still_could_not_run(
556
- tmp_path: pathlib.Path,
557
- ) -> None:
558
- """Eleven gaps exceed the gap cap; the unreadable plan cap must still not be skipped."""
559
- root = build(tmp_path, gaps(11))
560
- with pytest.raises(CouldNotRun, match=PLANS_DECLARATION):
561
- grade_declared(root, {PLANS_DECLARATION: "abc"})
562
-
563
-
564
333
  def test_a_declared_cap_reaches_the_check_through_the_runner(tmp_path: pathlib.Path) -> None:
565
- root = build(tmp_path, {**features(11), **gaps(12)})
334
+ root = build(tmp_path, features(11))
566
335
  universe = tuple(
567
336
  str(path.relative_to(root)) for path in root.rglob("*") if path.is_file()
568
337
  )
@@ -579,8 +348,8 @@ def test_a_declared_cap_reaches_the_check_through_the_runner(tmp_path: pathlib.P
579
348
 
580
349
  default = run(None)
581
350
  assert default.outcome is Outcome.FINDINGS, default.render()
582
- declared = run({GAPS_DECLARATION: "12", PLANS_DECLARATION: "11"})
351
+ declared = run({PLANS_DECLARATION: "11"})
583
352
  assert declared.outcome is Outcome.CLEAN, declared.render()
584
- refused = run({GAPS_DECLARATION: "abc"})
353
+ refused = run({PLANS_DECLARATION: "abc"})
585
354
  assert refused.outcome is Outcome.COULD_NOT_RUN, refused.render()
586
- assert GAPS_DECLARATION in refused.message
355
+ assert PLANS_DECLARATION in refused.message
@@ -7,14 +7,14 @@ from typing import ClassVar
7
7
 
8
8
 
9
9
  class PlanMetadata:
10
- """The plans tree's board-state vocabulary and frontmatter readers."""
10
+ """The plans tree's board-state vocabulary and frontmatter readers (R1: plan files take the FEATURE states, item files the twelve item states)."""
11
11
 
12
- WORK_ITEM_STATUSES: ClassVar[tuple[str, ...]] = (
12
+ ITEM_STATUSES: ClassVar[tuple[str, ...]] = (
13
13
  "New",
14
14
  "In Planning",
15
15
  "Awaiting Approval",
16
16
  "Approved",
17
- "Spec Validation",
17
+ "Design Validation",
18
18
  "In Implementation",
19
19
  "In Testing",
20
20
  "In Review",
@@ -23,11 +23,26 @@ class PlanMetadata:
23
23
  "Parked for Removal",
24
24
  "Removed",
25
25
  )
26
- ACTIVE_STATUSES: ClassVar[frozenset[str]] = frozenset(WORK_ITEM_STATUSES[:9])
27
- TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset(WORK_ITEM_STATUSES[9:])
26
+ ITEM_ACTIVE_STATUSES: ClassVar[frozenset[str]] = frozenset(ITEM_STATUSES[:9])
27
+ ITEM_TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset(ITEM_STATUSES[9:])
28
+
29
+ FEATURE_STATUSES: ClassVar[tuple[str, ...]] = (
30
+ "New",
31
+ "In Progress",
32
+ "Awaiting Release",
33
+ "Done",
34
+ "Removed",
35
+ )
36
+ FEATURE_ACTIVE_STATUSES: ClassVar[frozenset[str]] = frozenset(
37
+ FEATURE_STATUSES[:3]
38
+ )
39
+ FEATURE_TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset(
40
+ FEATURE_STATUSES[3:]
41
+ )
28
42
 
29
43
  STATUS_LINE: ClassVar[re.Pattern[str]] = re.compile(r"^status:\s*(.+)$")
30
44
  WORK_ITEM_LINE: ClassVar[re.Pattern[str]] = re.compile(r"^work_item:\s*(.+)$")
45
+ FEATURE_LINE: ClassVar[re.Pattern[str]] = re.compile(r"^feature:\s*(.+)$")
31
46
  WORK_ITEM_URL: ClassVar[re.Pattern[str]] = re.compile(
32
47
  r"^https://dev\.azure\.com/\S+/_workitems/edit/([0-9]+)/?$"
33
48
  )
@@ -56,3 +71,14 @@ class PlanMetadata:
56
71
  if not found:
57
72
  return None, "not a work item URL"
58
73
  return int(found.group(1)), None
74
+
75
+ @classmethod
76
+ def feature_id(cls, body: str) -> tuple[int | None, str | None]:
77
+ """Return the `feature:` link's Azure DevOps id, or why unreadable."""
78
+ raw = cls.field(body, cls.FEATURE_LINE)
79
+ if raw is None:
80
+ return None, "missing"
81
+ found = cls.WORK_ITEM_URL.match(raw)
82
+ if not found:
83
+ return None, "not a work item URL"
84
+ return int(found.group(1)), None