@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.35 → 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.
- package/bin/adl +2 -2
- package/package.json +1 -1
- package/packages/agent-hooks/deploy-profile.json +1 -1
- package/packages/agent-hooks/src/pre-tool-use/handler.ts +2 -2
- package/packages/agent-hooks/src/rules/impl/adl-build-run-role.ts +116 -0
- package/packages/agent-hooks/src/rules/impl/adl-git-user-override-block.ts +159 -0
- package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +12 -6
- package/packages/agent-hooks/src/rules/impl/adl-pr-complete-pending.ts +9 -9
- package/packages/agent-hooks/src/rules/impl/adl-pr-role.ts +6 -5
- package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +15 -14
- package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
- package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +1 -1
- package/packages/agent-hooks/src/rules/registry.ts +11 -2
- package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +8 -4
- package/packages/agent-skills/assets/code-review/SKILL.md +84 -66
- package/packages/agent-skills/assets/diagnosing-bugs/SKILL.md +23 -2
- package/packages/agent-skills/assets/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +1 -1
- package/packages/agent-skills/assets/explore-codebase/SKILL.md +3 -2
- package/packages/agent-skills/assets/finalize-feature/SKILL.md +63 -0
- package/packages/agent-skills/assets/fix-ci/SKILL.md +58 -0
- package/packages/agent-skills/assets/handoff/SKILL.md +1 -1
- package/packages/agent-skills/assets/implement-feature/SKILL.md +153 -0
- package/packages/agent-skills/assets/implement-feature/references/briefs.md +94 -0
- package/packages/agent-skills/assets/implement-item/SKILL.md +71 -0
- package/packages/agent-skills/assets/mechanics/SKILL.md +38 -0
- package/packages/agent-skills/assets/plan-feature/SKILL.md +141 -0
- package/packages/agent-skills/assets/prepare-promotion/SKILL.md +91 -0
- package/packages/agent-skills/assets/ralplan/SKILL.md +149 -0
- package/packages/agent-skills/assets/refactor-safely/SKILL.md +4 -3
- package/packages/agent-skills/assets/report-bug/SKILL.md +72 -0
- package/packages/agent-skills/assets/review-change/SKILL.md +82 -0
- package/packages/agent-skills/assets/review-plan/SKILL.md +124 -0
- package/packages/agent-skills/assets/triage-workitems/SKILL.md +99 -0
- package/packages/agent-skills/assets/ultragoal/SKILL.md +77 -0
- package/packages/agent-skills/assets/validate-design/SKILL.md +70 -0
- package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
- package/packages/agent-skills/assets/workitems-process/SKILL.md +147 -0
- package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +194 -0
- package/packages/agent-skills/assets/workitems-process/references/evidence.md +74 -0
- package/packages/agent-skills/assets/workitems-process/references/workitem-states.md +92 -0
- package/packages/agent-skills/assets/write-feature/SKILL.md +95 -0
- package/packages/agent-skills/dist/deploy.d.ts +7 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +52 -5
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/src/deploy.ts +48 -4
- package/packages/azdo/scripts/probe-ado-rules.ts +1 -1
- package/packages/azdo/src/activity-log.ts +118 -0
- package/packages/azdo/src/azdo-attachments.ts +108 -0
- package/packages/azdo/src/azdo-pl.ts +100 -3
- package/packages/azdo/src/azdo-pr.ts +194 -11
- package/packages/azdo/src/azdo-wit.ts +329 -18
- package/packages/azdo/src/cli.ts +42 -2
- package/packages/azdo/src/lib/client.ts +7 -1
- package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
- package/packages/azdo/src/mcp-server.ts +53 -5
- package/packages/azdo/src/tool-dispatch.ts +53 -4
- package/packages/azdo/src/workflow-ladder.csv +125 -107
- package/packages/azdo/src/workflow-rules.ts +254 -97
- package/packages/repo-controls/README.md +12 -4
- package/packages/repo-controls/assets/scaffold/docs/AGENTS.md +5 -5
- package/packages/repo-controls/assets/scaffold/plans/AGENTS.md +42 -13
- package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +5 -5
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/closed_set/test_check.py +0 -11
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/filename/test_check.py +4 -6
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/ledger/test_check.py +15 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/docs-okf-profiles.yaml +1 -17
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/test_check.py +3 -22
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/check.py +39 -182
- 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
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +90 -321
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +31 -5
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +282 -54
- 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
- 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
- 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
- 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
- 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
- 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
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +646 -53
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/cli.py +3 -3
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/docs_shape.py +1 -12
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.alt.yaml +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.yaml +1 -1
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
"""LAY-016 —
|
|
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
|
|
31
|
-
return f"---\ntype:
|
|
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}/
|
|
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
|
-
|
|
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
|
|
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()}/
|
|
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", ("
|
|
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/
|
|
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
|
|
102
|
+
def test_a_plan_intent_must_belong_to_the_git_visible_file_set(
|
|
123
103
|
tmp_path: pathlib.Path,
|
|
124
104
|
) -> None:
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
item_path:
|
|
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
|
|
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:
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
185
|
+
def test_the_retired_open_gaps_declaration_is_ignored(
|
|
321
186
|
tmp_path: pathlib.Path,
|
|
322
187
|
) -> None:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
#
|
|
335
|
-
# `[controls.compliance.limits].
|
|
336
|
-
# `--declare open-
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
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
|
|
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,
|
|
275
|
+
root = build(tmp_path, features(5))
|
|
453
276
|
assert grade(root).outcome is Outcome.CLEAN
|
|
454
|
-
result = grade_declared(root, {
|
|
277
|
+
result = grade_declared(root, {PLANS_DECLARATION: "4"})
|
|
455
278
|
assert result.outcome is Outcome.FINDINGS, result.render()
|
|
456
|
-
|
|
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,
|
|
463
|
-
declared = grade_declared(root, {
|
|
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
|
|
472
|
-
root = build(tmp_path,
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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,
|
|
531
|
-
assert len(result.findings) ==
|
|
532
|
-
|
|
533
|
-
|
|
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,
|
|
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,
|
|
324
|
+
root = build(tmp_path, features(1))
|
|
547
325
|
with pytest.raises(CouldNotRun) as raised:
|
|
548
|
-
grade_declared(root, {
|
|
326
|
+
grade_declared(root, {PLANS_DECLARATION: value})
|
|
549
327
|
message = str(raised.value)
|
|
550
|
-
assert
|
|
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,
|
|
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({
|
|
351
|
+
declared = run({PLANS_DECLARATION: "11"})
|
|
583
352
|
assert declared.outcome is Outcome.CLEAN, declared.render()
|
|
584
|
-
refused = run({
|
|
353
|
+
refused = run({PLANS_DECLARATION: "abc"})
|
|
585
354
|
assert refused.outcome is Outcome.COULD_NOT_RUN, refused.render()
|
|
586
|
-
assert
|
|
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
|
-
|
|
12
|
+
ITEM_STATUSES: ClassVar[tuple[str, ...]] = (
|
|
13
13
|
"New",
|
|
14
14
|
"In Planning",
|
|
15
15
|
"Awaiting Approval",
|
|
16
16
|
"Approved",
|
|
17
|
-
"
|
|
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
|
-
|
|
27
|
-
|
|
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
|