@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.35 → 0.1.46
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/bin/reconcile +3 -11
- package/bin/setup +15 -15
- package/package.json +1 -1
- package/packages/agent-hooks/deploy-profile.json +1 -1
- package/packages/agent-hooks/package.json +1 -1
- package/packages/agent-hooks/src/main.ts +23 -2
- 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 +22 -12
- 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/adl-run-required.ts +11 -5
- package/packages/agent-hooks/src/rules/impl/adl-workitem-role.ts +18 -5
- package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +30 -25
- package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
- package/packages/agent-hooks/src/rules/impl/git-target.ts +5 -0
- package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +4 -2
- package/packages/agent-hooks/src/rules/impl/tool-frontend.ts +230 -11
- package/packages/agent-hooks/src/rules/registry.ts +11 -2
- package/packages/agent-hooks/src/rules/shell/git-identity.ts +1 -1
- package/packages/agent-hooks/src/rules/shell/invocation.ts +14 -2
- package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +10 -7
- package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +2 -2
- package/packages/agent-settings/package.json +1 -1
- package/packages/agent-settings/src/deploy/agents-deploy.ts +19 -1
- package/packages/agent-settings/src/deploy/index.ts +1 -0
- package/packages/agent-skills/assets/agent-search-tool-audit/SKILL.md +4 -6
- 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 +150 -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 +90 -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 +64 -0
- package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
- package/packages/agent-skills/assets/wayfinder/SKILL.md +1 -1
- package/packages/agent-skills/assets/workitems-process/SKILL.md +145 -0
- package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +227 -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 +94 -0
- package/packages/agent-skills/dist/deploy.d.ts +52 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +218 -7
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/dist/index.d.ts +1 -1
- package/packages/agent-skills/dist/index.d.ts.map +1 -1
- package/packages/agent-skills/dist/index.js +1 -1
- package/packages/agent-skills/dist/index.js.map +1 -1
- package/packages/agent-skills/package.json +1 -1
- package/packages/agent-skills/src/deploy.ts +288 -6
- package/packages/agent-skills/src/index.ts +10 -1
- package/packages/azdo/README.md +13 -4
- package/packages/azdo/bunfig.toml +6 -0
- package/packages/azdo/package.json +1 -1
- 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 +109 -0
- package/packages/azdo/src/azdo-pl.ts +100 -3
- package/packages/azdo/src/azdo-pr.ts +376 -17
- package/packages/azdo/src/azdo-wit.ts +594 -34
- package/packages/azdo/src/cli.ts +119 -12
- package/packages/azdo/src/lib/client.ts +95 -17
- package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
- package/packages/azdo/src/mcp-server.ts +121 -8
- package/packages/azdo/src/tool-dispatch.ts +107 -23
- package/packages/azdo/src/workflow-ladder.csv +125 -107
- package/packages/azdo/src/workflow-rules.ts +254 -97
- package/packages/fleet/src/install-adl-package.ts +4 -3
- package/packages/repo-controls/README.md +12 -4
- package/packages/repo-controls/assets/promote.ts +1 -0
- 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/fixtures/okf-profile-untyped/docs/research/no-type.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/typed-experiment.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/typed-research.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-key.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-keys.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-created.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-description.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-last-update.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-status.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-title.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/optional-keys.md +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/research-ok.md +1 -1
- 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
- package/packages/repo-controls/package.json +1 -1
- package/packages/repo-controls/src/config.ts +21 -2
- package/packages/repo-controls/src/doctor.ts +9 -14
- package/packages/repo-controls/src/findings-ratchet.ts +12 -0
- package/packages/repo-controls/src/hook.ts +61 -4
|
@@ -3,17 +3,22 @@
|
|
|
3
3
|
Two committed corpora plus trees built at `tmp_path` for the individual
|
|
4
4
|
branches. Nothing reads the repository this package lives in.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
R1–R7 (ADL process handover, 2026-10-12) reshaped the tree this gate grades:
|
|
7
|
+
`spec.md` became `requirements.md`, `intent.md` is the one required plan file
|
|
8
|
+
(a Feature with only `intent.md` passes until planning), `design.md` and
|
|
9
|
+
`file-map-reference.md` joined the Feature-folder allow-list, plan files carry
|
|
10
|
+
the five Feature states while item files keep the twelve item states,
|
|
11
|
+
`GAPS-AND-FINDINGS.md` left the plans root, and each plan file and item file
|
|
12
|
+
now has a required-section list, with EARS acceptance criteria in
|
|
13
|
+
`requirements.md` and a `feature:` board URL on every item file.
|
|
11
14
|
"""
|
|
12
15
|
|
|
13
16
|
from __future__ import annotations
|
|
14
17
|
|
|
15
18
|
import pathlib
|
|
16
19
|
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
17
22
|
from helpers import corpus
|
|
18
23
|
|
|
19
24
|
from adl_compliance.checks.layout.plans_layout import PlansLayout
|
|
@@ -28,7 +33,34 @@ ROW = load().by_id("LAY-009")
|
|
|
28
33
|
|
|
29
34
|
AZ = "https://dev.azure.com/org/proj/_workitems/edit"
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
INTENT = """---
|
|
37
|
+
type: intent
|
|
38
|
+
title: "A feature"
|
|
39
|
+
status: New
|
|
40
|
+
work_item: {az}/{{work_item}}
|
|
41
|
+
last_update: 2026-09-15T03:20:00Z
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
# A feature
|
|
45
|
+
|
|
46
|
+
## Why
|
|
47
|
+
|
|
48
|
+
Because the board needs the file.
|
|
49
|
+
|
|
50
|
+
## Outcome
|
|
51
|
+
|
|
52
|
+
The file exists.
|
|
53
|
+
|
|
54
|
+
## Constraints
|
|
55
|
+
|
|
56
|
+
None.
|
|
57
|
+
|
|
58
|
+
## Non-goals
|
|
59
|
+
|
|
60
|
+
None.
|
|
61
|
+
""".format(az=AZ)
|
|
62
|
+
|
|
63
|
+
REQUIREMENTS = """---
|
|
32
64
|
type: feature
|
|
33
65
|
title: "A feature"
|
|
34
66
|
status: New
|
|
@@ -36,22 +68,107 @@ epic: {az}/320
|
|
|
36
68
|
work_item: {az}/{{work_item}}
|
|
37
69
|
last_update: 2026-09-15T03:20:00Z
|
|
38
70
|
sources:
|
|
39
|
-
-
|
|
71
|
+
- intent.md
|
|
40
72
|
---
|
|
41
73
|
|
|
42
74
|
# A feature
|
|
75
|
+
|
|
76
|
+
## Problem
|
|
77
|
+
|
|
78
|
+
## Acceptance criteria
|
|
79
|
+
|
|
80
|
+
- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.
|
|
81
|
+
|
|
82
|
+
## Scope
|
|
83
|
+
|
|
84
|
+
## Predecessors
|
|
85
|
+
|
|
86
|
+
## Items
|
|
87
|
+
|
|
88
|
+
## Open questions for G1
|
|
89
|
+
|
|
90
|
+
none
|
|
91
|
+
|
|
92
|
+
## Revision log
|
|
93
|
+
""".format(az=AZ)
|
|
94
|
+
|
|
95
|
+
DESIGN = """---
|
|
96
|
+
type: design
|
|
97
|
+
title: "A feature"
|
|
98
|
+
status: New
|
|
99
|
+
work_item: {az}/{{work_item}}
|
|
100
|
+
last_update: 2026-09-15T03:20:00Z
|
|
101
|
+
sources:
|
|
102
|
+
- requirements.md
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
# A feature
|
|
106
|
+
|
|
107
|
+
## Solution
|
|
108
|
+
|
|
109
|
+
## Decisions
|
|
110
|
+
|
|
111
|
+
## Verification
|
|
112
|
+
|
|
113
|
+
## Docs impact
|
|
114
|
+
|
|
115
|
+
## Size estimate
|
|
116
|
+
|
|
117
|
+
## Risks
|
|
118
|
+
""".format(az=AZ)
|
|
119
|
+
|
|
120
|
+
FILE_MAP = """---
|
|
121
|
+
type: file-map
|
|
122
|
+
title: "A feature"
|
|
123
|
+
status: New
|
|
124
|
+
work_item: {az}/{{work_item}}
|
|
125
|
+
last_update: 2026-09-15T03:20:00Z
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
# A feature
|
|
129
|
+
|
|
130
|
+
## Baseline
|
|
131
|
+
|
|
132
|
+
## Map
|
|
43
133
|
""".format(az=AZ)
|
|
44
134
|
|
|
45
135
|
ITEM = """---
|
|
46
|
-
type: {
|
|
136
|
+
type: {item_type}
|
|
47
137
|
title: "An item"
|
|
48
138
|
status: New
|
|
49
|
-
work_item: {az}/{
|
|
139
|
+
work_item: {az}/{work_item}
|
|
140
|
+
feature: {az}/{feature}
|
|
50
141
|
last_update: 2026-09-15T03:20:00Z
|
|
51
142
|
---
|
|
52
143
|
|
|
53
144
|
# An item
|
|
54
|
-
""".format(az=AZ)
|
|
145
|
+
""".format(az=AZ, item_type="{item_type}", work_item="{work_item}", feature="{feature}")
|
|
146
|
+
|
|
147
|
+
PBI_SECTIONS = "## What it delivers\n\n## Verification\n"
|
|
148
|
+
BUG_SECTIONS = (
|
|
149
|
+
"## Current behaviour\n\n## Expected behaviour\n\n"
|
|
150
|
+
"## Behaviour that must not change\n\n## Responsible Feature\n\n"
|
|
151
|
+
"## Predecessors\n\n## Verification\n"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def item_body(item_type: str, work_item: int, feature: int = 1) -> str:
|
|
156
|
+
body = ITEM.replace("{item_type}", item_type)
|
|
157
|
+
body = body.replace("{work_item}", str(work_item))
|
|
158
|
+
body = body.replace("{feature}", str(feature))
|
|
159
|
+
return body.rstrip("\n") + "\n\n" + (
|
|
160
|
+
BUG_SECTIONS if item_type == "bug" else PBI_SECTIONS
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def feature_files(work_item: int = 1) -> dict[str, str]:
|
|
165
|
+
"""A complete, conforming Feature folder (all four plan files, no items)."""
|
|
166
|
+
return {
|
|
167
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=work_item),
|
|
168
|
+
"plans/features/az1-x/requirements.md": REQUIREMENTS.format(work_item=work_item),
|
|
169
|
+
"plans/features/az1-x/design.md": DESIGN.format(work_item=work_item),
|
|
170
|
+
"plans/features/az1-x/file-map-reference.md": FILE_MAP.format(work_item=work_item),
|
|
171
|
+
}
|
|
55
172
|
|
|
56
173
|
|
|
57
174
|
def graded_directly(root: pathlib.Path) -> object:
|
|
@@ -85,8 +202,8 @@ def test_the_violation_corpus_reports_each_disagreement() -> None:
|
|
|
85
202
|
verdict = graded_directly(VIOLATION)
|
|
86
203
|
assert verdict.outcome is Outcome.FINDINGS
|
|
87
204
|
messages = " | ".join(f.message for f in verdict.findings)
|
|
88
|
-
#
|
|
89
|
-
# az351,
|
|
205
|
+
# requirements work_item 999 vs directory az347, item work_item 352 vs
|
|
206
|
+
# filename az351, and a bad item_type.
|
|
90
207
|
assert "work_item 999 disagrees with az347" in messages, verdict.render()
|
|
91
208
|
assert "work_item 352 disagrees with az351" in messages, verdict.render()
|
|
92
209
|
assert "'chore' is not pbi or bug" in messages, verdict.render()
|
|
@@ -112,7 +229,7 @@ def test_a_leftover_lifecycle_directory_is_reported(tmp_path: pathlib.Path) -> N
|
|
|
112
229
|
build(
|
|
113
230
|
tmp_path,
|
|
114
231
|
{
|
|
115
|
-
"plans/features/az1-x/
|
|
232
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
116
233
|
"plans/a_new/26-09-15-old-plan.md": "# old",
|
|
117
234
|
},
|
|
118
235
|
)
|
|
@@ -131,7 +248,7 @@ def test_an_unapproved_root_directory_is_reported(tmp_path: pathlib.Path) -> Non
|
|
|
131
248
|
build(
|
|
132
249
|
tmp_path,
|
|
133
250
|
{
|
|
134
|
-
"plans/features/az1-x/
|
|
251
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
135
252
|
"plans/archive/old.md": "# old",
|
|
136
253
|
},
|
|
137
254
|
)
|
|
@@ -144,7 +261,7 @@ def test_an_unapproved_root_file_is_reported(tmp_path: pathlib.Path) -> None:
|
|
|
144
261
|
build(
|
|
145
262
|
tmp_path,
|
|
146
263
|
{
|
|
147
|
-
"plans/features/az1-x/
|
|
264
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
148
265
|
"plans/NOTES.md": "# notes",
|
|
149
266
|
},
|
|
150
267
|
)
|
|
@@ -152,12 +269,30 @@ def test_an_unapproved_root_file_is_reported(tmp_path: pathlib.Path) -> None:
|
|
|
152
269
|
assert any("direct plans files must be" in f.message for f in verdict.findings)
|
|
153
270
|
|
|
154
271
|
|
|
272
|
+
def test_gaps_and_findings_is_no_longer_allowed_at_the_plans_root(
|
|
273
|
+
tmp_path: pathlib.Path,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""R7: the register is retired; findings enter only through report-bug."""
|
|
276
|
+
verdict = graded_directly(
|
|
277
|
+
build(
|
|
278
|
+
tmp_path,
|
|
279
|
+
{
|
|
280
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
281
|
+
"plans/GAPS-AND-FINDINGS.md": "# gaps\n",
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
finding = next(f for f in verdict.findings if f.path == "plans/GAPS-AND-FINDINGS.md")
|
|
286
|
+
assert "direct plans files must be" in finding.message
|
|
287
|
+
assert "GAPS-AND-FINDINGS.md" not in finding.message
|
|
288
|
+
|
|
289
|
+
|
|
155
290
|
def test_a_loose_file_in_features_is_reported(tmp_path: pathlib.Path) -> None:
|
|
156
291
|
verdict = graded_directly(
|
|
157
292
|
build(
|
|
158
293
|
tmp_path,
|
|
159
294
|
{
|
|
160
|
-
"plans/features/az1-x/
|
|
295
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
161
296
|
"plans/features/stray.md": "# stray",
|
|
162
297
|
},
|
|
163
298
|
)
|
|
@@ -183,7 +318,7 @@ def test_a_gitkeep_beside_a_feature_is_reported_and_names_the_deletion(
|
|
|
183
318
|
tmp_path: pathlib.Path,
|
|
184
319
|
) -> None:
|
|
185
320
|
files = {
|
|
186
|
-
"plans/features/az1-x/
|
|
321
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
187
322
|
"plans/features/.gitkeep": "",
|
|
188
323
|
}
|
|
189
324
|
verdict = graded_directly(build(tmp_path, {"plans/AGENTS.md": "x", **files}))
|
|
@@ -207,7 +342,7 @@ def test_any_other_loose_file_is_still_reported_beside_a_gitkeep(
|
|
|
207
342
|
def test_a_gitkeep_is_only_the_features_directorys_exemption(tmp_path: pathlib.Path) -> None:
|
|
208
343
|
"""Strictness: the allowance is for `features/` alone, not a Feature folder."""
|
|
209
344
|
files = {
|
|
210
|
-
"plans/features/az1-x/
|
|
345
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
211
346
|
"plans/features/az1-x/.gitkeep": "",
|
|
212
347
|
}
|
|
213
348
|
verdict = graded_directly(build(tmp_path, {"plans/AGENTS.md": "x", **files}))
|
|
@@ -216,31 +351,70 @@ def test_a_gitkeep_is_only_the_features_directorys_exemption(tmp_path: pathlib.P
|
|
|
216
351
|
|
|
217
352
|
def test_a_feature_directory_not_named_az_id_is_reported(tmp_path: pathlib.Path) -> None:
|
|
218
353
|
verdict = graded_directly(
|
|
219
|
-
build(tmp_path, {"plans/features/coverage/
|
|
354
|
+
build(tmp_path, {"plans/features/coverage/intent.md": INTENT.format(work_item=1)})
|
|
220
355
|
)
|
|
221
356
|
assert any(
|
|
222
357
|
"must be named az<work item id>-<kebab-slug>" in f.message for f in verdict.findings
|
|
223
358
|
)
|
|
224
359
|
|
|
225
360
|
|
|
226
|
-
|
|
361
|
+
# ------------------------------------------------------------------ R2: intent
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_a_feature_directory_without_an_intent_is_reported(tmp_path: pathlib.Path) -> None:
|
|
227
365
|
verdict = graded_directly(
|
|
228
366
|
build(
|
|
229
367
|
tmp_path,
|
|
230
368
|
{
|
|
231
|
-
"plans/features/az1-x/items/az2-y.md":
|
|
369
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2),
|
|
232
370
|
},
|
|
233
371
|
)
|
|
234
372
|
)
|
|
235
|
-
assert any("must hold
|
|
373
|
+
assert any("must hold an intent.md" in f.message for f in verdict.findings)
|
|
236
374
|
|
|
237
375
|
|
|
238
|
-
def
|
|
376
|
+
def test_a_feature_folder_holding_only_intent_passes_until_planning(
|
|
377
|
+
tmp_path: pathlib.Path,
|
|
378
|
+
) -> None:
|
|
379
|
+
"""R2: `requirements.md` arrives with the planning run, not before it."""
|
|
380
|
+
verdict = graded_directly(
|
|
381
|
+
build(tmp_path, {"plans/features/az1-x/intent.md": INTENT.format(work_item=1)})
|
|
382
|
+
)
|
|
383
|
+
assert verdict.outcome is Outcome.CLEAN, verdict.render()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_the_missing_intent_fix_points_at_the_workitems_process_skill(
|
|
387
|
+
tmp_path: pathlib.Path,
|
|
388
|
+
) -> None:
|
|
389
|
+
"""R5: templates ship with the workitems-process skill only."""
|
|
390
|
+
verdict = graded_directly(
|
|
391
|
+
build(
|
|
392
|
+
tmp_path,
|
|
393
|
+
{
|
|
394
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2),
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
finding = next(f for f in verdict.findings if "must hold an intent.md" in f.message)
|
|
399
|
+
assert "workitems-process" in finding.fix
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------- R3/R6: plan file siblings
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_design_and_file_map_are_permitted_beside_requirements(
|
|
406
|
+
tmp_path: pathlib.Path,
|
|
407
|
+
) -> None:
|
|
408
|
+
verdict = graded_directly(build(tmp_path, feature_files()))
|
|
409
|
+
assert verdict.outcome is Outcome.CLEAN, verdict.render()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_an_extra_file_beside_the_plan_files_is_reported(tmp_path: pathlib.Path) -> None:
|
|
239
413
|
verdict = graded_directly(
|
|
240
414
|
build(
|
|
241
415
|
tmp_path,
|
|
242
416
|
{
|
|
243
|
-
|
|
417
|
+
**feature_files(),
|
|
244
418
|
"plans/features/az1-x/notes.md": "# notes",
|
|
245
419
|
},
|
|
246
420
|
)
|
|
@@ -248,11 +422,16 @@ def test_an_extra_file_beside_spec_is_reported(tmp_path: pathlib.Path) -> None:
|
|
|
248
422
|
# The message ENUMERATES what is allowed, so whoever hits it can correct the
|
|
249
423
|
# file without opening the check. A bare "not allowed" sends the reader here.
|
|
250
424
|
finding = next(f for f in verdict.findings if "Feature directory holds" in f.message)
|
|
251
|
-
assert finding.message ==
|
|
252
|
-
|
|
425
|
+
assert finding.message == (
|
|
426
|
+
"a Feature directory holds intent.md, requirements.md, design.md, "
|
|
427
|
+
"file-map-reference.md, handoff.md, review.md, items/ only"
|
|
428
|
+
)
|
|
429
|
+
assert "rename it to one of handoff.md, review.md" in finding.fix
|
|
253
430
|
|
|
254
431
|
|
|
255
|
-
def
|
|
432
|
+
def test_supporting_documents_are_permitted_beside_the_plan_files(
|
|
433
|
+
tmp_path: pathlib.Path,
|
|
434
|
+
) -> None:
|
|
256
435
|
"""handoff.md and review.md are named by ROLE, so a feature's supporting documents
|
|
257
436
|
sit at the same path in every feature. A topic-named file (CHANNELS-HANDOVER.md)
|
|
258
437
|
states the topic a second time and differently each time, which is the drift the
|
|
@@ -261,7 +440,7 @@ def test_supporting_documents_are_permitted_beside_spec(tmp_path: pathlib.Path)
|
|
|
261
440
|
build(
|
|
262
441
|
tmp_path,
|
|
263
442
|
{
|
|
264
|
-
|
|
443
|
+
**feature_files(),
|
|
265
444
|
"plans/features/az1-x/handoff.md": "# handoff",
|
|
266
445
|
"plans/features/az1-x/review.md": "# review",
|
|
267
446
|
},
|
|
@@ -275,7 +454,7 @@ def test_a_topic_named_supporting_file_is_still_reported(tmp_path: pathlib.Path)
|
|
|
275
454
|
build(
|
|
276
455
|
tmp_path,
|
|
277
456
|
{
|
|
278
|
-
|
|
457
|
+
**feature_files(),
|
|
279
458
|
"plans/features/az1-x/CHANNELS-HANDOVER.md": "# handover",
|
|
280
459
|
},
|
|
281
460
|
)
|
|
@@ -288,8 +467,8 @@ def test_an_item_not_named_az_id_is_reported(tmp_path: pathlib.Path) -> None:
|
|
|
288
467
|
build(
|
|
289
468
|
tmp_path,
|
|
290
469
|
{
|
|
291
|
-
|
|
292
|
-
"plans/features/az1-x/items/26-09-15-old-name.md":
|
|
470
|
+
**feature_files(),
|
|
471
|
+
"plans/features/az1-x/items/26-09-15-old-name.md": item_body("pbi", 2),
|
|
293
472
|
},
|
|
294
473
|
)
|
|
295
474
|
)
|
|
@@ -302,23 +481,23 @@ def test_an_item_not_named_az_id_is_reported(tmp_path: pathlib.Path) -> None:
|
|
|
302
481
|
# ----------------------------------------------------------------- frontmatter
|
|
303
482
|
|
|
304
483
|
|
|
305
|
-
def
|
|
484
|
+
def test_a_requirements_file_without_an_epic_is_reported(tmp_path: pathlib.Path) -> None:
|
|
306
485
|
verdict = graded_directly(
|
|
307
486
|
build(
|
|
308
487
|
tmp_path,
|
|
309
488
|
{
|
|
310
|
-
"plans/features/az1-x/
|
|
489
|
+
"plans/features/az1-x/requirements.md": "---\ntype: feature\nwork_item: 1\n---\n\n# x\n",
|
|
311
490
|
},
|
|
312
491
|
)
|
|
313
492
|
)
|
|
314
493
|
assert any("must name the Epic" in f.message for f in verdict.findings)
|
|
315
494
|
|
|
316
495
|
|
|
317
|
-
def
|
|
496
|
+
def test_a_plan_work_item_disagreeing_with_its_directory_is_reported(
|
|
318
497
|
tmp_path: pathlib.Path,
|
|
319
498
|
) -> None:
|
|
320
499
|
verdict = graded_directly(
|
|
321
|
-
build(tmp_path, {"plans/features/az1-x/
|
|
500
|
+
build(tmp_path, {"plans/features/az1-x/intent.md": INTENT.format(work_item=2)})
|
|
322
501
|
)
|
|
323
502
|
assert any(
|
|
324
503
|
"work_item 2 disagrees with az1" in f.message for f in verdict.findings
|
|
@@ -334,8 +513,8 @@ def test_an_item_work_item_disagreeing_with_its_filename_is_reported(
|
|
|
334
513
|
build(
|
|
335
514
|
tmp_path,
|
|
336
515
|
{
|
|
337
|
-
|
|
338
|
-
"plans/features/az1-x/items/az2-y.md":
|
|
516
|
+
**feature_files(),
|
|
517
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 3),
|
|
339
518
|
},
|
|
340
519
|
)
|
|
341
520
|
)
|
|
@@ -350,7 +529,7 @@ def test_a_status_outside_the_board_vocabulary_is_reported(tmp_path: pathlib.Pat
|
|
|
350
529
|
build(
|
|
351
530
|
tmp_path,
|
|
352
531
|
{
|
|
353
|
-
"plans/features/az1-x/
|
|
532
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
|
|
354
533
|
"status: New", "status: draft"
|
|
355
534
|
)
|
|
356
535
|
},
|
|
@@ -359,6 +538,88 @@ def test_a_status_outside_the_board_vocabulary_is_reported(tmp_path: pathlib.Pat
|
|
|
359
538
|
assert any("is not a work item state" in f.message for f in verdict.findings)
|
|
360
539
|
|
|
361
540
|
|
|
541
|
+
@pytest.mark.parametrize(
|
|
542
|
+
"status", ("New", "In Progress", "Awaiting Release", "Done", "Removed")
|
|
543
|
+
)
|
|
544
|
+
def test_each_feature_state_is_accepted_on_every_plan_file(
|
|
545
|
+
tmp_path: pathlib.Path, status: str
|
|
546
|
+
) -> None:
|
|
547
|
+
"""R1: plan files mirror the FEATURE's board state, not an item's."""
|
|
548
|
+
files = feature_files()
|
|
549
|
+
for path in files:
|
|
550
|
+
files[path] = files[path].replace("status: New", f"status: {status}")
|
|
551
|
+
verdict = graded_directly(build(tmp_path, files))
|
|
552
|
+
assert verdict.outcome is Outcome.CLEAN, verdict.render()
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@pytest.mark.parametrize(
|
|
556
|
+
"status",
|
|
557
|
+
(
|
|
558
|
+
"In Planning", "Awaiting Approval", "Approved", "Design Validation",
|
|
559
|
+
"In Implementation", "In Testing", "In Review", "Updating Docs",
|
|
560
|
+
"Parked for Removal",
|
|
561
|
+
),
|
|
562
|
+
)
|
|
563
|
+
def test_an_item_state_on_a_plan_file_is_reported(
|
|
564
|
+
tmp_path: pathlib.Path, status: str
|
|
565
|
+
) -> None:
|
|
566
|
+
verdict = graded_directly(
|
|
567
|
+
build(
|
|
568
|
+
tmp_path,
|
|
569
|
+
{
|
|
570
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
|
|
571
|
+
"status: New", f"status: {status}"
|
|
572
|
+
)
|
|
573
|
+
},
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
assert any("is not a work item state" in f.message for f in verdict.findings)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_a_feature_state_on_an_item_file_is_reported(tmp_path: pathlib.Path) -> None:
|
|
580
|
+
"""R1 the other way: `Awaiting Release` is a Feature state, not an item's."""
|
|
581
|
+
verdict = graded_directly(
|
|
582
|
+
build(
|
|
583
|
+
tmp_path,
|
|
584
|
+
{
|
|
585
|
+
**feature_files(),
|
|
586
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
|
|
587
|
+
"status: New", "status: Awaiting Release"
|
|
588
|
+
),
|
|
589
|
+
},
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
assert any("is not a work item state" in f.message for f in verdict.findings)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def test_design_validation_is_the_renamed_item_state(tmp_path: pathlib.Path) -> None:
|
|
596
|
+
"""F8: Spec Validation became Design Validation in the item vocabulary."""
|
|
597
|
+
verdict = graded_directly(
|
|
598
|
+
build(
|
|
599
|
+
tmp_path,
|
|
600
|
+
{
|
|
601
|
+
**feature_files(),
|
|
602
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
|
|
603
|
+
"status: New", "status: Design Validation"
|
|
604
|
+
),
|
|
605
|
+
},
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
assert verdict.outcome is Outcome.CLEAN, verdict.render()
|
|
609
|
+
retired = graded_directly(
|
|
610
|
+
build(
|
|
611
|
+
tmp_path,
|
|
612
|
+
{
|
|
613
|
+
**feature_files(),
|
|
614
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
|
|
615
|
+
"status: New", "status: Spec Validation"
|
|
616
|
+
),
|
|
617
|
+
},
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
assert any("is not a work item state" in f.message for f in retired.findings)
|
|
621
|
+
|
|
622
|
+
|
|
362
623
|
def test_a_bare_work_item_id_is_reported(tmp_path: pathlib.Path) -> None:
|
|
363
624
|
# The id is already in the filename; the field earns its place by being the
|
|
364
625
|
# link a reader can click through to the board.
|
|
@@ -366,7 +627,7 @@ def test_a_bare_work_item_id_is_reported(tmp_path: pathlib.Path) -> None:
|
|
|
366
627
|
build(
|
|
367
628
|
tmp_path,
|
|
368
629
|
{
|
|
369
|
-
"plans/features/az1-x/
|
|
630
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
|
|
370
631
|
f"work_item: {AZ}/1", "work_item: 1"
|
|
371
632
|
)
|
|
372
633
|
},
|
|
@@ -375,26 +636,340 @@ def test_a_bare_work_item_id_is_reported(tmp_path: pathlib.Path) -> None:
|
|
|
375
636
|
assert any("must be a board URL" in f.message for f in verdict.findings)
|
|
376
637
|
|
|
377
638
|
|
|
378
|
-
|
|
639
|
+
# ------------------------------------------------------- R4: required sections
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def test_a_plan_file_missing_a_required_section_is_reported(tmp_path: pathlib.Path) -> None:
|
|
379
643
|
verdict = graded_directly(
|
|
380
644
|
build(
|
|
381
645
|
tmp_path,
|
|
382
646
|
{
|
|
383
|
-
"plans/features/az1-x/
|
|
384
|
-
"
|
|
647
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
|
|
648
|
+
"## Outcome\n", ""
|
|
385
649
|
)
|
|
386
650
|
},
|
|
387
651
|
)
|
|
388
652
|
)
|
|
653
|
+
assert any("missing section ## Outcome" in f.message for f in verdict.findings)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def test_each_required_section_of_requirements_is_reported_by_name(
|
|
657
|
+
tmp_path: pathlib.Path,
|
|
658
|
+
) -> None:
|
|
659
|
+
body = REQUIREMENTS.format(work_item=1)
|
|
660
|
+
for section in ("## Problem", "## Acceptance criteria", "## Items", "## Revision log"):
|
|
661
|
+
verdict = graded_directly(
|
|
662
|
+
build(
|
|
663
|
+
tmp_path,
|
|
664
|
+
{"plans/features/az1-x/requirements.md": body.replace(f"{section}\n", "")},
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
assert any(
|
|
668
|
+
f"missing section {section}" in f.message for f in verdict.findings
|
|
669
|
+
), f"{section} removed but not reported: {verdict.render()}"
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def test_each_required_section_of_design_is_reported_by_name(
|
|
673
|
+
tmp_path: pathlib.Path,
|
|
674
|
+
) -> None:
|
|
675
|
+
body = DESIGN.format(work_item=1)
|
|
676
|
+
for section in ("## Solution", "## Docs impact", "## Size estimate", "## Risks"):
|
|
677
|
+
verdict = graded_directly(
|
|
678
|
+
build(
|
|
679
|
+
tmp_path,
|
|
680
|
+
{"plans/features/az1-x/design.md": body.replace(f"{section}\n", "")},
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
assert any(
|
|
684
|
+
f"missing section {section}" in f.message for f in verdict.findings
|
|
685
|
+
), f"{section} removed but not reported: {verdict.render()}"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def test_each_required_section_of_the_file_map_is_reported_by_name(
|
|
689
|
+
tmp_path: pathlib.Path,
|
|
690
|
+
) -> None:
|
|
691
|
+
body = FILE_MAP.format(work_item=1)
|
|
692
|
+
for section in ("## Baseline", "## Map"):
|
|
693
|
+
verdict = graded_directly(
|
|
694
|
+
build(
|
|
695
|
+
tmp_path,
|
|
696
|
+
{"plans/features/az1-x/file-map-reference.md": body.replace(f"{section}\n", "")},
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
assert any(
|
|
700
|
+
f"missing section {section}" in f.message for f in verdict.findings
|
|
701
|
+
), f"{section} removed but not reported: {verdict.render()}"
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def test_a_pbi_item_missing_its_sections_is_reported(tmp_path: pathlib.Path) -> None:
|
|
705
|
+
verdict = graded_directly(
|
|
706
|
+
build(
|
|
707
|
+
tmp_path,
|
|
708
|
+
{
|
|
709
|
+
**feature_files(),
|
|
710
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
|
|
711
|
+
"## Verification\n", ""
|
|
712
|
+
),
|
|
713
|
+
},
|
|
714
|
+
)
|
|
715
|
+
)
|
|
716
|
+
assert any("missing section ## Verification" in f.message for f in verdict.findings)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def test_a_bug_item_uses_the_fixes_bug_section_set(tmp_path: pathlib.Path) -> None:
|
|
720
|
+
"""A type: bug item file is a Fixes Bug item file: Bugs are never planned,
|
|
721
|
+
so its sections are the bugfix shape, not the PBI's."""
|
|
722
|
+
verdict = graded_directly(
|
|
723
|
+
build(
|
|
724
|
+
tmp_path,
|
|
725
|
+
{
|
|
726
|
+
**feature_files(),
|
|
727
|
+
"plans/features/az1-x/items/az2-y.md": item_body("bug", 2).replace(
|
|
728
|
+
"## Responsible Feature\n", ""
|
|
729
|
+
),
|
|
730
|
+
},
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
messages = [f.message for f in verdict.findings if f.path.endswith("az2-y.md")]
|
|
734
|
+
assert "missing section ## Responsible Feature" in messages
|
|
735
|
+
assert not any("missing section ## What it delivers" in m for m in messages)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def test_a_pbi_item_with_bug_sections_is_reported(tmp_path: pathlib.Path) -> None:
|
|
739
|
+
verdict = graded_directly(
|
|
740
|
+
build(
|
|
741
|
+
tmp_path,
|
|
742
|
+
{
|
|
743
|
+
**feature_files(),
|
|
744
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
|
|
745
|
+
"## What it delivers\n", "## Current behaviour\n"
|
|
746
|
+
),
|
|
747
|
+
},
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
assert any(
|
|
751
|
+
"missing section ## What it delivers" in f.message for f in verdict.findings
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def test_optional_sections_are_not_required(tmp_path: pathlib.Path) -> None:
|
|
756
|
+
verdict = graded_directly(
|
|
757
|
+
build(
|
|
758
|
+
tmp_path,
|
|
759
|
+
{
|
|
760
|
+
**feature_files(),
|
|
761
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
|
|
762
|
+
"## Verification\n", "## Verification\n\n## Related\n"
|
|
763
|
+
),
|
|
764
|
+
},
|
|
765
|
+
)
|
|
766
|
+
)
|
|
767
|
+
assert verdict.outcome is Outcome.CLEAN, verdict.render()
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
# ------------------------------------------------------------------ R4: EARS
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def test_every_numbered_acceptance_criterion_must_be_an_ears_sentence(
|
|
774
|
+
tmp_path: pathlib.Path,
|
|
775
|
+
) -> None:
|
|
776
|
+
body = REQUIREMENTS.format(work_item=1).replace(
|
|
777
|
+
"- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.",
|
|
778
|
+
"- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.\n"
|
|
779
|
+
"- AC-2: keep the board in step.\n"
|
|
780
|
+
"- AC-3: THE SYSTEM SHALL also do this.\n",
|
|
781
|
+
)
|
|
782
|
+
verdict = graded_directly(
|
|
783
|
+
build(
|
|
784
|
+
tmp_path,
|
|
785
|
+
{
|
|
786
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
787
|
+
"plans/features/az1-x/requirements.md": body,
|
|
788
|
+
},
|
|
789
|
+
)
|
|
790
|
+
)
|
|
791
|
+
ac2 = [f for f in verdict.findings if "AC-2" in f.message]
|
|
792
|
+
assert len(ac2) == 1, verdict.render()
|
|
793
|
+
assert not [f for f in verdict.findings if "AC-3" in f.message], verdict.render()
|
|
794
|
+
assert not [f for f in verdict.findings if "AC-1" in f.message], verdict.render()
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@pytest.mark.parametrize(
|
|
798
|
+
"opener",
|
|
799
|
+
(
|
|
800
|
+
"WHEN the board moves",
|
|
801
|
+
"WHILE the run is open",
|
|
802
|
+
"IF the plan drifts THEN",
|
|
803
|
+
"WHERE the file maps",
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
def test_an_ears_opener_is_accepted_before_the_system_shall(
|
|
807
|
+
tmp_path: pathlib.Path, opener: str
|
|
808
|
+
) -> None:
|
|
809
|
+
body = REQUIREMENTS.format(work_item=1).replace(
|
|
810
|
+
"- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.",
|
|
811
|
+
f"- AC-1: {opener} THE SYSTEM SHALL keep the plan files in step with the board.",
|
|
812
|
+
)
|
|
813
|
+
verdict = graded_directly(
|
|
814
|
+
build(
|
|
815
|
+
tmp_path,
|
|
816
|
+
{
|
|
817
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
818
|
+
"plans/features/az1-x/requirements.md": body,
|
|
819
|
+
},
|
|
820
|
+
)
|
|
821
|
+
)
|
|
822
|
+
assert verdict.outcome is Outcome.CLEAN, verdict.render()
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def test_an_if_opener_without_then_is_not_ears(tmp_path: pathlib.Path) -> None:
|
|
826
|
+
body = REQUIREMENTS.format(work_item=1).replace(
|
|
827
|
+
"- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.",
|
|
828
|
+
"- AC-1: IF the plan drifts THE SYSTEM SHALL keep the plan files in step.",
|
|
829
|
+
)
|
|
830
|
+
verdict = graded_directly(
|
|
831
|
+
build(
|
|
832
|
+
tmp_path,
|
|
833
|
+
{
|
|
834
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
835
|
+
"plans/features/az1-x/requirements.md": body,
|
|
836
|
+
},
|
|
837
|
+
)
|
|
838
|
+
)
|
|
839
|
+
assert any("AC-1" in f.message for f in verdict.findings), verdict.render()
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def test_non_criterion_lines_are_not_ears_checked(tmp_path: pathlib.Path) -> None:
|
|
843
|
+
body = REQUIREMENTS.format(work_item=1).replace(
|
|
844
|
+
"- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.",
|
|
845
|
+
"AC-1 is the load-bearing criterion.\n\n"
|
|
846
|
+
"- AC-1: THE SYSTEM SHALL keep the plan files in step with the board.\n",
|
|
847
|
+
)
|
|
848
|
+
verdict = graded_directly(
|
|
849
|
+
build(
|
|
850
|
+
tmp_path,
|
|
851
|
+
{
|
|
852
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
853
|
+
"plans/features/az1-x/requirements.md": body,
|
|
854
|
+
},
|
|
855
|
+
)
|
|
856
|
+
)
|
|
857
|
+
assert verdict.outcome is Outcome.CLEAN, verdict.render()
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
# ------------------------------------------------------------ R4: feature link
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def test_an_item_without_a_feature_link_is_reported(tmp_path: pathlib.Path) -> None:
|
|
864
|
+
verdict = graded_directly(
|
|
865
|
+
build(
|
|
866
|
+
tmp_path,
|
|
867
|
+
{
|
|
868
|
+
**feature_files(),
|
|
869
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
|
|
870
|
+
f"feature: {AZ}/1\n", ""
|
|
871
|
+
),
|
|
872
|
+
},
|
|
873
|
+
)
|
|
874
|
+
)
|
|
875
|
+
assert any("missing feature link" in f.message for f in verdict.findings)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def test_an_item_feature_link_disagreeing_with_its_folder_is_reported(
|
|
879
|
+
tmp_path: pathlib.Path,
|
|
880
|
+
) -> None:
|
|
881
|
+
verdict = graded_directly(
|
|
882
|
+
build(
|
|
883
|
+
tmp_path,
|
|
884
|
+
{
|
|
885
|
+
**feature_files(),
|
|
886
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2, feature=999),
|
|
887
|
+
},
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
assert any("feature 999 disagrees with az1" in f.message for f in verdict.findings)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def test_an_item_feature_link_must_be_a_board_url(tmp_path: pathlib.Path) -> None:
|
|
894
|
+
verdict = graded_directly(
|
|
895
|
+
build(
|
|
896
|
+
tmp_path,
|
|
897
|
+
{
|
|
898
|
+
**feature_files(),
|
|
899
|
+
"plans/features/az1-x/items/az2-y.md": item_body("pbi", 2).replace(
|
|
900
|
+
f"feature: {AZ}/1", "feature: 1"
|
|
901
|
+
),
|
|
902
|
+
},
|
|
903
|
+
)
|
|
904
|
+
)
|
|
905
|
+
assert any("feature must be a board URL" in f.message for f in verdict.findings)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
# --------------------------------------------------------------- plan file keys
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def test_a_requirements_file_without_sources_is_reported(tmp_path: pathlib.Path) -> None:
|
|
912
|
+
verdict = graded_directly(
|
|
913
|
+
build(
|
|
914
|
+
tmp_path,
|
|
915
|
+
{
|
|
916
|
+
"plans/features/az1-x/requirements.md": REQUIREMENTS.format(
|
|
917
|
+
work_item=1
|
|
918
|
+
).replace("sources:\n - intent.md\n", "")
|
|
919
|
+
},
|
|
920
|
+
)
|
|
921
|
+
)
|
|
922
|
+
assert any("must link the documents it derives from" in f.message for f in verdict.findings)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def test_requirements_sources_must_list_intent_first(tmp_path: pathlib.Path) -> None:
|
|
926
|
+
verdict = graded_directly(
|
|
927
|
+
build(
|
|
928
|
+
tmp_path,
|
|
929
|
+
{
|
|
930
|
+
"plans/features/az1-x/requirements.md": REQUIREMENTS.format(
|
|
931
|
+
work_item=1
|
|
932
|
+
).replace(" - intent.md", " - docs/architecture/workflow-automation.md"),
|
|
933
|
+
},
|
|
934
|
+
)
|
|
935
|
+
)
|
|
936
|
+
assert any("must list intent.md first" in f.message for f in verdict.findings)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def test_a_design_file_without_sources_is_reported(tmp_path: pathlib.Path) -> None:
|
|
940
|
+
verdict = graded_directly(
|
|
941
|
+
build(
|
|
942
|
+
tmp_path,
|
|
943
|
+
{
|
|
944
|
+
"plans/features/az1-x/design.md": DESIGN.format(
|
|
945
|
+
work_item=1
|
|
946
|
+
).replace("sources:\n - requirements.md\n", "")
|
|
947
|
+
},
|
|
948
|
+
)
|
|
949
|
+
)
|
|
389
950
|
assert any("must link the documents it derives from" in f.message for f in verdict.findings)
|
|
390
951
|
|
|
391
952
|
|
|
953
|
+
def test_design_sources_must_list_requirements(tmp_path: pathlib.Path) -> None:
|
|
954
|
+
verdict = graded_directly(
|
|
955
|
+
build(
|
|
956
|
+
tmp_path,
|
|
957
|
+
{
|
|
958
|
+
"plans/features/az1-x/design.md": DESIGN.format(
|
|
959
|
+
work_item=1
|
|
960
|
+
).replace(" - requirements.md", " - intent.md"),
|
|
961
|
+
},
|
|
962
|
+
)
|
|
963
|
+
)
|
|
964
|
+
assert any("must list requirements.md" in f.message for f in verdict.findings)
|
|
965
|
+
|
|
966
|
+
|
|
392
967
|
def test_a_missing_last_update_is_reported(tmp_path: pathlib.Path) -> None:
|
|
393
968
|
verdict = graded_directly(
|
|
394
969
|
build(
|
|
395
970
|
tmp_path,
|
|
396
971
|
{
|
|
397
|
-
"plans/features/az1-x/
|
|
972
|
+
"plans/features/az1-x/intent.md": INTENT.format(work_item=1).replace(
|
|
398
973
|
"last_update: 2026-09-15T03:20:00Z\n", ""
|
|
399
974
|
)
|
|
400
975
|
},
|
|
@@ -408,14 +983,37 @@ def test_an_item_type_outside_pbi_and_bug_is_reported(tmp_path: pathlib.Path) ->
|
|
|
408
983
|
build(
|
|
409
984
|
tmp_path,
|
|
410
985
|
{
|
|
411
|
-
|
|
412
|
-
"plans/features/az1-x/items/az2-y.md":
|
|
986
|
+
**feature_files(),
|
|
987
|
+
"plans/features/az1-x/items/az2-y.md": item_body("task", 2),
|
|
413
988
|
},
|
|
414
989
|
)
|
|
415
990
|
)
|
|
416
991
|
assert any("type 'task' is not pbi or bug" in f.message for f in verdict.findings)
|
|
417
992
|
|
|
418
993
|
|
|
994
|
+
def test_each_plan_file_type_is_enforced(tmp_path: pathlib.Path) -> None:
|
|
995
|
+
templates = {
|
|
996
|
+
"intent.md": INTENT,
|
|
997
|
+
"requirements.md": REQUIREMENTS,
|
|
998
|
+
"design.md": DESIGN,
|
|
999
|
+
"file-map-reference.md": FILE_MAP,
|
|
1000
|
+
}
|
|
1001
|
+
cases = {
|
|
1002
|
+
"intent.md": ("intent", "feature"),
|
|
1003
|
+
"requirements.md": ("feature", "design"),
|
|
1004
|
+
"design.md": ("design", "file-map"),
|
|
1005
|
+
"file-map-reference.md": ("file-map", "intent"),
|
|
1006
|
+
}
|
|
1007
|
+
for name, (want, wrong) in cases.items():
|
|
1008
|
+
body = templates[name].format(work_item=1).replace(f"type: {want}", f"type: {wrong}")
|
|
1009
|
+
verdict = graded_directly(
|
|
1010
|
+
build(tmp_path / name.replace(".md", ""), {f"plans/features/az1-x/{name}": body})
|
|
1011
|
+
)
|
|
1012
|
+
assert any(
|
|
1013
|
+
f"type must be {want!r}" in f.message for f in verdict.findings
|
|
1014
|
+
), f"{name} with type {wrong}: {verdict.render()}"
|
|
1015
|
+
|
|
1016
|
+
|
|
419
1017
|
def test_a_supporting_document_must_carry_the_tree_s_frontmatter(tmp_path: pathlib.Path) -> None:
|
|
420
1018
|
"""A handoff without frontmatter is an undated wall of prose naming no work item
|
|
421
1019
|
— readable today, unattributable in a month. That is the state the channels
|
|
@@ -424,7 +1022,7 @@ def test_a_supporting_document_must_carry_the_tree_s_frontmatter(tmp_path: pathl
|
|
|
424
1022
|
build(
|
|
425
1023
|
tmp_path,
|
|
426
1024
|
{
|
|
427
|
-
|
|
1025
|
+
**feature_files(),
|
|
428
1026
|
"plans/features/az1-x/handoff.md": "# handoff\n\nprose, no frontmatter\n",
|
|
429
1027
|
},
|
|
430
1028
|
)
|
|
@@ -453,7 +1051,7 @@ def test_a_redeclared_plans_tree_still_catches_a_legacy_lifecycle_dir(
|
|
|
453
1051
|
build(
|
|
454
1052
|
root,
|
|
455
1053
|
{
|
|
456
|
-
"roadmap/features/az1-x/
|
|
1054
|
+
"roadmap/features/az1-x/intent.md": INTENT.format(work_item=1),
|
|
457
1055
|
"roadmap/a_new/leftover.md": "# a leftover plan from before the migration\n",
|
|
458
1056
|
},
|
|
459
1057
|
)
|
|
@@ -490,12 +1088,7 @@ def test_ledger_md_is_allowed_at_the_plans_root(tmp_path: pathlib.Path) -> None:
|
|
|
490
1088
|
|
|
491
1089
|
|
|
492
1090
|
def test_ledger_yaml_at_the_plans_root_is_reported(tmp_path: pathlib.Path) -> None:
|
|
493
|
-
"""The retired form is a finding, and the remedy names what IS allowed.
|
|
494
|
-
|
|
495
|
-
The message is derived from ROOT_ALLOWED_FILES rather than restated: the
|
|
496
|
-
hardcoded copy had drifted at both ends, naming `LEDGER.yaml` (no longer
|
|
497
|
-
allowed) and omitting `GAPS-AND-FINDINGS.md` (allowed all along).
|
|
498
|
-
"""
|
|
1091
|
+
"""The retired form is a finding, and the remedy names what IS allowed."""
|
|
499
1092
|
verdict = graded_directly(
|
|
500
1093
|
build(tmp_path, {
|
|
501
1094
|
"plans/AGENTS.md": "x",
|