napkinstack 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {napkinstack-0.2.0 → napkinstack-0.3.0}/PKG-INFO +11 -6
- {napkinstack-0.2.0 → napkinstack-0.3.0}/README.md +10 -5
- {napkinstack-0.2.0 → napkinstack-0.3.0}/pyproject.toml +1 -1
- {napkinstack-0.2.0 → napkinstack-0.3.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/cli.py +21 -9
- napkinstack-0.3.0/src/napkinstack/discovery.py +53 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/doctor.py +41 -7
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/fitness/manifests.py +6 -0
- napkinstack-0.3.0/src/napkinstack/fitness/plan.py +221 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/modules.py +15 -8
- napkinstack-0.3.0/src/napkinstack/pull_request.py +242 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/MANIFEST.yaml +6 -1
- {napkinstack-0.2.0 → napkinstack-0.3.0}/LICENSE +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/fitness/pr_scope.sh +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/project.py +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/tests/.gitkeep +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: napkinstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -20,9 +20,10 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
20
20
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
21
21
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
22
22
|
|
|
23
|
-
> **Status: v0.
|
|
24
|
-
> A first pilot project, private, puts it to the test before
|
|
25
|
-
> [roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
23
|
+
> **Status: v0.2.0, published** ([PyPI](https://pypi.org/project/napkinstack/)), English
|
|
24
|
+
> throughout. A first pilot project, private, starts from it and puts it to the test before
|
|
25
|
+
> the rest. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
26
|
+
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
26
27
|
> [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
27
28
|
|
|
28
29
|
## A project's journey
|
|
@@ -84,10 +85,14 @@ the commit of this repository
|
|
|
84
85
|
|---|---|
|
|
85
86
|
| `nstack init <folder>` | Creates the project: skeleton, git repository, initial commit, GitHub checklist |
|
|
86
87
|
| `nstack doctor` | Checks the workstation and the GitHub settings, read-only |
|
|
87
|
-
| `nstack new-module <name> <
|
|
88
|
+
| `nstack new-module <name> <owner> <criticality>` | Creates a module, with no imposed stack |
|
|
88
89
|
| `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
|
|
89
|
-
| `nstack
|
|
90
|
+
| `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
|
|
91
|
+
| `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
|
|
92
|
+
| `nstack fitness` | Manifests, boundaries between modules, skills, plan |
|
|
90
93
|
| `nstack pr-scope` | One PR = one module, review budget |
|
|
94
|
+
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
95
|
+
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
91
96
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
92
97
|
| `nstack update` | Lays the new version on a branch to review |
|
|
93
98
|
|
|
@@ -5,9 +5,10 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
5
5
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
6
6
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
7
7
|
|
|
8
|
-
> **Status: v0.
|
|
9
|
-
> A first pilot project, private, puts it to the test before
|
|
10
|
-
> [roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
8
|
+
> **Status: v0.2.0, published** ([PyPI](https://pypi.org/project/napkinstack/)), English
|
|
9
|
+
> throughout. A first pilot project, private, starts from it and puts it to the test before
|
|
10
|
+
> the rest. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
11
|
+
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
11
12
|
> [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
12
13
|
|
|
13
14
|
## A project's journey
|
|
@@ -69,10 +70,14 @@ the commit of this repository
|
|
|
69
70
|
|---|---|
|
|
70
71
|
| `nstack init <folder>` | Creates the project: skeleton, git repository, initial commit, GitHub checklist |
|
|
71
72
|
| `nstack doctor` | Checks the workstation and the GitHub settings, read-only |
|
|
72
|
-
| `nstack new-module <name> <
|
|
73
|
+
| `nstack new-module <name> <owner> <criticality>` | Creates a module, with no imposed stack |
|
|
73
74
|
| `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
|
|
74
|
-
| `nstack
|
|
75
|
+
| `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
|
|
76
|
+
| `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
|
|
77
|
+
| `nstack fitness` | Manifests, boundaries between modules, skills, plan |
|
|
75
78
|
| `nstack pr-scope` | One PR = one module, review budget |
|
|
79
|
+
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
80
|
+
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
76
81
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
77
82
|
| `nstack update` | Lays the new version on a branch to review |
|
|
78
83
|
|
|
@@ -7,8 +7,8 @@ import os
|
|
|
7
7
|
import subprocess
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from napkinstack import __version__, doctor, modules, skills
|
|
11
|
-
from napkinstack.fitness import boundaries, manifests
|
|
10
|
+
from napkinstack import __version__, discovery, doctor, modules, pull_request, skills
|
|
11
|
+
from napkinstack.fitness import boundaries, manifests, plan
|
|
12
12
|
|
|
13
13
|
PACKAGE = Path(__file__).resolve().parent
|
|
14
14
|
|
|
@@ -26,7 +26,7 @@ def _script(relative: str, *args: str, root: Path) -> int:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _fitness(root: Path) -> int:
|
|
29
|
-
results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True)]
|
|
29
|
+
results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True), plan.run(root)]
|
|
30
30
|
return 1 if any(results) else 0
|
|
31
31
|
|
|
32
32
|
|
|
@@ -61,21 +61,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
61
61
|
lambda a: manifests.run(a.root))
|
|
62
62
|
_add(sub, "boundaries", "declared graph against real graph (B1-B5)",
|
|
63
63
|
lambda a: boundaries.run(a.root))
|
|
64
|
+
_add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
|
|
64
65
|
sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
|
|
65
66
|
lambda a: skills.run(a.root, check_only=a.check))
|
|
66
67
|
sk.add_argument("--check", action="store_true", help="check without writing")
|
|
67
|
-
_add(sub, "fitness", "manifests + boundaries + skills",
|
|
68
|
+
_add(sub, "fitness", "manifests + boundaries + skills + plan",
|
|
68
69
|
lambda a: _fitness(a.root))
|
|
69
70
|
_add(sub, "doctor", "diagnoses the workstation and the GitHub settings, read-only (PDR-0001)",
|
|
70
71
|
lambda a: doctor.run(a.root))
|
|
71
72
|
nm = _add(sub, "new-module", "creates a module and its guardrails, with no imposed stack",
|
|
72
|
-
lambda a: modules.create(a.root, a.name, a.owner, a.criticality))
|
|
73
|
+
lambda a: modules.create(a.root, a.name, a.owner, a.criticality, a.user_facing))
|
|
73
74
|
nm.add_argument("name", help="module name, kebab-case")
|
|
74
|
-
nm.add_argument("owner", help="GitHub team, organisation/team"
|
|
75
|
+
nm.add_argument("owner", help="GitHub team, organisation/team, or a user when the project has "
|
|
76
|
+
"no organisation")
|
|
75
77
|
nm.add_argument("criticality", choices=["prototype", "standard", "high", "critical"])
|
|
78
|
+
nm.add_argument("--user-facing", action="store_true",
|
|
79
|
+
help="a user sees this module: its pull requests carry a test sheet")
|
|
76
80
|
for verb, help_text in (("bootstrap", "prepares one module, or all of them (commands.bootstrap)"),
|
|
77
81
|
("check", "format, lint, types of one module, or all (commands.check)"),
|
|
78
|
-
("test", "tests of one module, or of all of them (commands.test)")
|
|
82
|
+
("test", "tests of one module, or of all of them (commands.test)"),
|
|
83
|
+
("e2e", "end-to-end scenarios of one module, or of all (commands.e2e)")):
|
|
79
84
|
vb = _add(sub, verb, help_text, lambda a, v=verb: modules.run_verb(a.root, v, a.module))
|
|
80
85
|
vb.add_argument("module", nargs="?", help="module name (default: all)")
|
|
81
86
|
rn = _add(sub, "run", "starts a module locally (commands.run)",
|
|
@@ -84,12 +89,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
84
89
|
ps = _add(sub, "pr-scope", "one PR = one module, review budget (P1-P2)",
|
|
85
90
|
lambda a: _script("fitness/pr_scope.sh", a.base, root=a.root))
|
|
86
91
|
ps.add_argument("--base", default="origin/main")
|
|
92
|
+
pc = _add(sub, "pr-check", "test sheet and cycle, read from the pull request description (T1-T5, K1-K4)",
|
|
93
|
+
lambda a: pull_request.run(a.root, a.base, a.body_file))
|
|
94
|
+
pc.add_argument("--base", default="origin/main")
|
|
95
|
+
pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
|
|
96
|
+
ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent (PDR-0002)",
|
|
97
|
+
lambda a: discovery.run(a.root, a.idea))
|
|
98
|
+
ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
|
|
87
99
|
ini = sub.add_parser("init", help="creates a project from the skeleton (PDR-0001)")
|
|
88
100
|
ini.add_argument("destination", type=Path, help="project folder, missing or empty")
|
|
89
101
|
ini.add_argument("--project-name", help="project name (asked when absent)")
|
|
90
102
|
ini.add_argument("--github-repo", help="GitHub repository, organisation/name (asked when absent)")
|
|
91
|
-
ini.add_argument("--owner-team", help="
|
|
92
|
-
"(asked when absent)")
|
|
103
|
+
ini.add_argument("--owner-team", help="owner of the foundation: organisation/team, or a user "
|
|
104
|
+
"when the project has no organisation (asked when absent)")
|
|
93
105
|
ini.add_argument("--source", help="template: URL or path (default: the NapkinStack repository)")
|
|
94
106
|
ini.add_argument("--ref", help="skeleton version, tag vX.Y.Z (default: the one of nstack)")
|
|
95
107
|
ini.set_defaults(func=_init)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nstack discover — starts a project's discovery from an idea file (PDR-0002, extension).
|
|
3
|
+
|
|
4
|
+
Deterministic, no model called: the idea is kept in docs/project/inputs/, the discovery
|
|
5
|
+
document is created from its template, and the instruction for the team's agent is
|
|
6
|
+
printed. The conversation itself happens in the agent (playbooks/discovery.md).
|
|
7
|
+
|
|
8
|
+
Usage : nstack discover <idea-file> [--root ROOT]
|
|
9
|
+
Output: 0 when the discovery is started, 1 otherwise.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from napkinstack.fitness.plan import DISCOVERY, PROJECT
|
|
18
|
+
|
|
19
|
+
INPUTS = PROJECT / "inputs"
|
|
20
|
+
TEMPLATE = PROJECT / "_DISCOVERY_TEMPLATE.md"
|
|
21
|
+
SUFFIXES = {".md", ".txt"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(root: Path, idea: Path) -> int:
|
|
25
|
+
if not (root / TEMPLATE).is_file():
|
|
26
|
+
print(f"FAIL [discover] {TEMPLATE} not found in {root}: not a project created by nstack init, "
|
|
27
|
+
"or one older than the discovery.\n Action: run it at the project root, or update "
|
|
28
|
+
"the project (nstack update).")
|
|
29
|
+
return 1
|
|
30
|
+
if not idea.is_file() or idea.suffix.lower() not in SUFFIXES:
|
|
31
|
+
print(f"FAIL [discover] {idea}: a Markdown or text file expected.\n Action: write the idea "
|
|
32
|
+
"in a .md or .txt file — a few lines are enough.")
|
|
33
|
+
return 1
|
|
34
|
+
if (root / DISCOVERY).exists():
|
|
35
|
+
print(f"FAIL [discover] {DISCOVERY} already exists: one discovery per project.\n Action: "
|
|
36
|
+
"continue it in your agent (playbooks/discovery.md), as a new round of the same document.")
|
|
37
|
+
return 1
|
|
38
|
+
kept = root / INPUTS / idea.name
|
|
39
|
+
if kept.exists() and kept.resolve() != idea.resolve():
|
|
40
|
+
print(f"FAIL [discover] {INPUTS / idea.name} already exists.\n Action: rename the idea file.")
|
|
41
|
+
return 1
|
|
42
|
+
kept.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
if kept.resolve() != idea.resolve():
|
|
44
|
+
shutil.copyfile(idea, kept)
|
|
45
|
+
source = (INPUTS / idea.name).as_posix()
|
|
46
|
+
template = (root / TEMPLATE).read_text(encoding="utf-8")
|
|
47
|
+
(root / DISCOVERY).write_text(template.replace("<idea file>", source), encoding="utf-8")
|
|
48
|
+
print(f"Discovery started: {source} kept, {DISCOVERY} created.")
|
|
49
|
+
print("\nNext, in your agent:")
|
|
50
|
+
print(f" Follow playbooks/discovery.md on {source}: interview me, one question at a time.")
|
|
51
|
+
print("Then, in another session, have the document challenged (stage 5); the decider decides: "
|
|
52
|
+
"go, clarify or kill.")
|
|
53
|
+
return 0
|
|
@@ -15,8 +15,9 @@ Rules:
|
|
|
15
15
|
L3 pre-commit hooks installed
|
|
16
16
|
L4 PRODUCT.md absent: that is NapkinStack's own development context (R6)
|
|
17
17
|
L5 README personalised: the presentation sentence is written
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
L6 CODEOWNERS starts with a default owner: the code owner review covers every path
|
|
19
|
+
G1-G12 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
|
|
20
|
+
repository, and on a private one G1-G5 and G12 name the GitHub plan or option required
|
|
20
21
|
|
|
21
22
|
Usage : nstack doctor [--root ROOT]
|
|
22
23
|
Output: 0 when everything is verified and compliant, 1 otherwise.
|
|
@@ -43,14 +44,15 @@ from napkinstack.project import ANSWERS
|
|
|
43
44
|
OK, GAP, UNKNOWN, NOT_APPLICABLE = "OK", "FAIL", "NOT VERIFIED", "NOT APPLICABLE"
|
|
44
45
|
API_VERSION = "2026-03-10"
|
|
45
46
|
PLACEHOLDER = "<One sentence: what this project does.>"
|
|
46
|
-
JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets")
|
|
47
|
+
JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle")
|
|
47
48
|
THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skeleton workflows
|
|
48
|
-
LABELS = ("cross-module", "over-budget")
|
|
49
|
+
LABELS = ("cross-module", "over-budget", "out-of-cycle")
|
|
49
50
|
PUBLISHED = re.compile(r"v\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?")
|
|
50
51
|
|
|
51
52
|
RULESET = "Settings → Rules → Rulesets, main branch"
|
|
52
53
|
SECURITY = "Settings → Advanced Security"
|
|
53
54
|
ACTIONS = "Settings → Actions → General"
|
|
55
|
+
CODEOWNERS = Path(".github") / "CODEOWNERS"
|
|
54
56
|
|
|
55
57
|
CHECKLIST = [ # (rule, setting, action)
|
|
56
58
|
("G1", "Pull request required: no direct push to main",
|
|
@@ -70,15 +72,17 @@ CHECKLIST = [ # (rule, setting, action)
|
|
|
70
72
|
f"{ACTIONS}: require approval for all external contributors"),
|
|
71
73
|
("G10", "Workflow token read-only; Actions neither creates nor approves pull requests",
|
|
72
74
|
f"{ACTIONS}: workflow permissions read-only, with no pull request creation or approval"),
|
|
73
|
-
("G11", "Labels " + "
|
|
74
|
-
"Issues → Labels: create " + "
|
|
75
|
+
("G11", "Labels " + ", ".join(f"`{label}`" for label in LABELS[:-1]) + f" and `{LABELS[-1]}`",
|
|
76
|
+
"Issues → Labels: create " + ", ".join(LABELS[:-1]) + f" and {LABELS[-1]}"),
|
|
77
|
+
("G12", "Bypass list empty: nobody merges around the rules, administrators included",
|
|
78
|
+
f"{RULESET}: remove every bypass actor"),
|
|
75
79
|
]
|
|
76
80
|
|
|
77
81
|
# Settings specific to public repositories, and settings a private one pays for
|
|
78
82
|
# (GitHub documentation, 2026-09-15).
|
|
79
83
|
PUBLIC_ONLY = {"G6": "private vulnerability reporting only exists for a public repository; "
|
|
80
84
|
"state an internal channel in SECURITY.md"}
|
|
81
|
-
PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4"),
|
|
85
|
+
PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12"),
|
|
82
86
|
"Private repository: rulesets require the GitHub Team plan (organisation) "
|
|
83
87
|
"or Pro (personal account); without it, nothing blocks the merge.")
|
|
84
88
|
PRIVATE_PLAN["G5"] = ("Private repository: Secret Protection is a paid option; without it, only the "
|
|
@@ -155,6 +159,20 @@ def _workflows(client: GitHub) -> bool:
|
|
|
155
159
|
and permissions.get("can_approve_pull_request_reviews") is False)
|
|
156
160
|
|
|
157
161
|
|
|
162
|
+
def _no_bypass(client: GitHub) -> bool:
|
|
163
|
+
"""G12: every ruleset applying to main has an empty bypass list (ADR-0004)."""
|
|
164
|
+
rulesets = {rule["ruleset_id"] for rule in client.get("/rules/branches/main") if rule.get("ruleset_id")}
|
|
165
|
+
if not rulesets:
|
|
166
|
+
return False
|
|
167
|
+
for ruleset in sorted(rulesets):
|
|
168
|
+
actors = client.get(f"/rulesets/{ruleset}?includes_parents=true").get("bypass_actors")
|
|
169
|
+
if actors is None:
|
|
170
|
+
raise NotVerified("bypass list not visible: the token lacks the Administration: read permission")
|
|
171
|
+
if actors:
|
|
172
|
+
return False
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
158
176
|
CHECKS: dict[str, Callable[[GitHub], bool]] = {
|
|
159
177
|
"G1": lambda c: _rule(c, "pull_request") is not None,
|
|
160
178
|
"G2": lambda c: _parameters(c, "pull_request").get("required_approving_review_count", 0) >= 1,
|
|
@@ -169,9 +187,23 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
|
|
|
169
187
|
"approval_policy") == "all_external_contributors",
|
|
170
188
|
"G10": _workflows,
|
|
171
189
|
"G11": lambda c: all(c.get(f"/labels/{label}", missing=True) is not None for label in LABELS),
|
|
190
|
+
"G12": _no_bypass,
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
|
|
194
|
+
def _default_owner(root: Path) -> tuple[str, str]:
|
|
195
|
+
"""L6: `*` first, so that the code owner review covers every path (ADR-0004)."""
|
|
196
|
+
path = root / CODEOWNERS
|
|
197
|
+
if not path.is_file():
|
|
198
|
+
return GAP, f"{CODEOWNERS} missing.\nAction: create it, starting with `* @<owner>`."
|
|
199
|
+
rules = [line.split() for line in path.read_text(encoding="utf-8").splitlines()
|
|
200
|
+
if line.strip() and not line.lstrip().startswith("#")]
|
|
201
|
+
if rules and rules[0][0] == "*" and len(rules[0]) > 1:
|
|
202
|
+
return OK, ""
|
|
203
|
+
return GAP, (f"The first rule of {CODEOWNERS} is not a default owner.\nAction: make `* @<owner>` "
|
|
204
|
+
"its first rule: the code owner review then covers every path (ADR-0004).")
|
|
205
|
+
|
|
206
|
+
|
|
175
207
|
def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
|
|
176
208
|
commit = str(answers.get("_commit") or "")
|
|
177
209
|
project = commit.removeprefix("v")
|
|
@@ -212,6 +244,8 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
|
|
|
212
244
|
results.append(("L5", "README personalised", GAP if untouched else OK,
|
|
213
245
|
f'README.md still contains "{PLACEHOLDER}".\n'
|
|
214
246
|
"Action: write the sentence that presents the project." if untouched else ""))
|
|
247
|
+
|
|
248
|
+
results.append(("L6", "CODEOWNERS starts with a default owner", *_default_owner(root)))
|
|
215
249
|
return results
|
|
216
250
|
|
|
217
251
|
|
|
@@ -15,6 +15,7 @@ Rules:
|
|
|
15
15
|
M7 standard verbs declared (check / test at least)
|
|
16
16
|
M8 runbook required when criticality >= high
|
|
17
17
|
M9 complete file envelope (AGENTS.md, README.md, tests/)
|
|
18
|
+
M10 user_facing declared: true when a user sees the module (docs/os/05-workflow.md §7)
|
|
18
19
|
|
|
19
20
|
Usage : nstack manifests [--root ROOT]
|
|
20
21
|
Output: 0 if everything passes, 1 otherwise. Every failure explains the rule broken.
|
|
@@ -164,6 +165,11 @@ def check_manifest(path: Path, today: datetime.date) -> None:
|
|
|
164
165
|
if not (path.parent / "tests").is_dir() and criticality != "prototype":
|
|
165
166
|
fail(rel, "M9", "tests/ folder missing")
|
|
166
167
|
|
|
168
|
+
# M10 - a user-visible surface declared: it decides the test sheet (docs/os/05-workflow.md §7)
|
|
169
|
+
if not isinstance(mod.get("user_facing"), bool):
|
|
170
|
+
fail(rel, "M10", "module.user_facing must be true or false: does a user see this module? "
|
|
171
|
+
"true requires a test sheet on its pull requests (docs/os/05-workflow.md §7)")
|
|
172
|
+
|
|
167
173
|
|
|
168
174
|
def run(root: Path) -> int:
|
|
169
175
|
failures.clear()
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fitness function 4 — The project's charter and cycles (PDR-0002).
|
|
3
|
+
|
|
4
|
+
A project is framed by a charter and bounded by cycles, written in docs/project/. Their
|
|
5
|
+
front matter, a YAML block between two --- lines at the top of the file, is what the
|
|
6
|
+
checks read; the prose below it is for humans and agents.
|
|
7
|
+
|
|
8
|
+
Rules:
|
|
9
|
+
C1 front matter readable: a YAML mapping at the top of the file
|
|
10
|
+
C2 charter: status proposed or accepted, a decider, at least one success criterion
|
|
11
|
+
C3 cycle: a goal, a known status, appetite_weeks, start and end, end = start + appetite
|
|
12
|
+
C4 deliverables: ids D1, D2… unique, a title, a known state, acceptance criteria once ready
|
|
13
|
+
C5 at most one accepted cycle, and only under an accepted charter
|
|
14
|
+
C6 a closed or stopped cycle records its outcome and the date it ended
|
|
15
|
+
C7 discovery: a known decision, its decider and date once decided, a challenger for a go;
|
|
16
|
+
a charter follows a go
|
|
17
|
+
|
|
18
|
+
Templates, whose file name starts with "_", are not checked.
|
|
19
|
+
|
|
20
|
+
Usage : nstack plan [--root ROOT]
|
|
21
|
+
Output: 0 when the charter and the cycles are valid, or absent; 1 otherwise.
|
|
22
|
+
A root without docs/project/, such as the NapkinStack repository itself, is out of scope.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import datetime
|
|
28
|
+
import re
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
|
|
33
|
+
PROJECT = Path("docs") / "project"
|
|
34
|
+
CHARTER = PROJECT / "charter.md"
|
|
35
|
+
CYCLES = PROJECT / "cycles"
|
|
36
|
+
DISCOVERY = PROJECT / "discovery.md"
|
|
37
|
+
DECISIONS = {"proposed", "go", "clarify", "kill"}
|
|
38
|
+
CHARTER_STATUSES = {"proposed", "accepted"}
|
|
39
|
+
CYCLE_STATUSES = {"proposed", "accepted", "closed", "stopped"}
|
|
40
|
+
DELIVERABLE_STATES = {"proposed", "ready", "in-progress", "accepted", "deferred", "dropped"}
|
|
41
|
+
WITHOUT_CRITERIA = {"proposed", "deferred", "dropped"} # acceptance criteria not required yet
|
|
42
|
+
OUTCOMES = {"closed": {"completed", "shipped"}, "stopped": {"reframed", "stopped"}}
|
|
43
|
+
DELIVERABLE_ID = re.compile(r"D[1-9][0-9]*")
|
|
44
|
+
FRONT_MATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_front_matter(path: Path) -> tuple[dict | None, str]:
|
|
48
|
+
"""The front matter as a mapping, or None and the reason it cannot be read (C1)."""
|
|
49
|
+
match = FRONT_MATTER.match(path.read_text(encoding="utf-8"))
|
|
50
|
+
if not match:
|
|
51
|
+
return None, "no front matter: the file must start with a YAML block between two --- lines"
|
|
52
|
+
try:
|
|
53
|
+
data = yaml.safe_load(match[1])
|
|
54
|
+
except yaml.YAMLError as exc:
|
|
55
|
+
return None, f"front matter unreadable: {exc}"
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
return None, "front matter must be a YAML mapping"
|
|
58
|
+
return data, ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def as_date(value) -> datetime.date | None:
|
|
62
|
+
if isinstance(value, datetime.datetime):
|
|
63
|
+
return value.date()
|
|
64
|
+
if isinstance(value, datetime.date):
|
|
65
|
+
return value
|
|
66
|
+
try:
|
|
67
|
+
return datetime.date.fromisoformat(str(value))
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cycle_files(root: Path) -> list[Path]:
|
|
73
|
+
folder = root / CYCLES
|
|
74
|
+
return sorted(p for p in folder.glob("*.md") if not p.name.startswith("_")) if folder.is_dir() else []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def charter_accepted(root: Path) -> bool:
|
|
78
|
+
path = root / CHARTER
|
|
79
|
+
data = read_front_matter(path)[0] if path.is_file() else None
|
|
80
|
+
return bool(data) and data.get("status") == "accepted"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def accepted_cycle(root: Path) -> tuple[Path, dict] | None:
|
|
84
|
+
"""The cycle in progress: the one accepted cycle, when its front matter is readable."""
|
|
85
|
+
for path in cycle_files(root):
|
|
86
|
+
data = read_front_matter(path)[0]
|
|
87
|
+
if data and data.get("status") == "accepted":
|
|
88
|
+
return path, data
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_charter(path: Path, fail) -> bool:
|
|
93
|
+
data, reason = read_front_matter(path)
|
|
94
|
+
if data is None:
|
|
95
|
+
fail("C1", path, reason)
|
|
96
|
+
return False
|
|
97
|
+
if data.get("status") not in CHARTER_STATUSES:
|
|
98
|
+
fail("C2", path, f"status '{data.get('status')}': expected one of {sorted(CHARTER_STATUSES)}")
|
|
99
|
+
if not str(data.get("decider") or "").strip():
|
|
100
|
+
fail("C2", path, "decider missing: the GitHub handle of the human who validates the charter "
|
|
101
|
+
"and the cycles, and decides at the circuit breaker")
|
|
102
|
+
criteria = data.get("success_criteria")
|
|
103
|
+
if not isinstance(criteria, list) or not [c for c in criteria if str(c or "").strip()]:
|
|
104
|
+
fail("C2", path, "success_criteria: at least one, they say when the project itself ends")
|
|
105
|
+
return data.get("status") == "accepted"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_deliverables(path: Path, deliverables, fail) -> None:
|
|
109
|
+
if not isinstance(deliverables, list) or not deliverables:
|
|
110
|
+
fail("C4", path, "deliverables: a finite, non-empty list — what the cycle delivers")
|
|
111
|
+
return
|
|
112
|
+
seen: set[str] = set()
|
|
113
|
+
for index, item in enumerate(deliverables, start=1):
|
|
114
|
+
if not isinstance(item, dict):
|
|
115
|
+
fail("C4", path, f"deliverable no. {index}: expected a mapping (id, title, state, acceptance)")
|
|
116
|
+
continue
|
|
117
|
+
ident = str(item.get("id") or "")
|
|
118
|
+
where = ident or f"no. {index}"
|
|
119
|
+
if not DELIVERABLE_ID.fullmatch(ident):
|
|
120
|
+
fail("C4", path, f"deliverable {where}: id expected as D1, D2…")
|
|
121
|
+
elif ident in seen:
|
|
122
|
+
fail("C4", path, f"deliverable {ident}: id used twice")
|
|
123
|
+
seen.add(ident)
|
|
124
|
+
if not str(item.get("title") or "").strip():
|
|
125
|
+
fail("C4", path, f"deliverable {where}: title missing")
|
|
126
|
+
state = item.get("state")
|
|
127
|
+
if state not in DELIVERABLE_STATES:
|
|
128
|
+
fail("C4", path, f"deliverable {where}: state '{state}', expected one of {sorted(DELIVERABLE_STATES)}")
|
|
129
|
+
criteria = item.get("acceptance")
|
|
130
|
+
filled = isinstance(criteria, list) and [c for c in criteria if str(c or "").strip()]
|
|
131
|
+
if state in DELIVERABLE_STATES - WITHOUT_CRITERIA and not filled:
|
|
132
|
+
fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria — "
|
|
133
|
+
"the definition of ready, and the source of its test sheet (PDR-0003)")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_cycle(path: Path, fail) -> str | None:
|
|
137
|
+
data, reason = read_front_matter(path)
|
|
138
|
+
if data is None:
|
|
139
|
+
fail("C1", path, reason)
|
|
140
|
+
return None
|
|
141
|
+
if not str(data.get("goal") or "").strip():
|
|
142
|
+
fail("C3", path, "goal missing: one sentence")
|
|
143
|
+
status = data.get("status")
|
|
144
|
+
if status not in CYCLE_STATUSES:
|
|
145
|
+
fail("C3", path, f"status '{status}': expected one of {sorted(CYCLE_STATUSES)}")
|
|
146
|
+
weeks = data.get("appetite_weeks")
|
|
147
|
+
if isinstance(weeks, bool) or not isinstance(weeks, int) or weeks < 1:
|
|
148
|
+
fail("C3", path, "appetite_weeks: a whole number of weeks, decided rather than estimated")
|
|
149
|
+
weeks = None
|
|
150
|
+
start, end = as_date(data.get("start")), as_date(data.get("end"))
|
|
151
|
+
if start is None or end is None:
|
|
152
|
+
fail("C3", path, "start and end: dates, YYYY-MM-DD")
|
|
153
|
+
elif weeks is not None and end != start + datetime.timedelta(weeks=weeks):
|
|
154
|
+
fail("C3", path, f"end {end}: the appetite sets it, start + {weeks} week(s) = "
|
|
155
|
+
f"{start + datetime.timedelta(weeks=weeks)}")
|
|
156
|
+
_check_deliverables(path, data.get("deliverables"), fail)
|
|
157
|
+
if status in OUTCOMES:
|
|
158
|
+
if data.get("outcome") not in OUTCOMES[status]:
|
|
159
|
+
fail("C6", path, f"a {status} cycle records its outcome: one of {sorted(OUTCOMES[status])}")
|
|
160
|
+
if as_date(data.get("ended_on")) is None:
|
|
161
|
+
fail("C6", path, f"a {status} cycle records ended_on, the date it ended")
|
|
162
|
+
return status if isinstance(status, str) else None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _check_discovery(path: Path, fail) -> str | None:
|
|
166
|
+
data, reason = read_front_matter(path)
|
|
167
|
+
if data is None:
|
|
168
|
+
fail("C1", path, reason)
|
|
169
|
+
return None
|
|
170
|
+
decision = data.get("decision")
|
|
171
|
+
if decision not in DECISIONS:
|
|
172
|
+
fail("C7", path, f"decision '{decision}': expected one of {sorted(DECISIONS)}")
|
|
173
|
+
elif decision != "proposed":
|
|
174
|
+
if not str(data.get("decider") or "").strip():
|
|
175
|
+
fail("C7", path, f"a decision ({decision}) names its decider")
|
|
176
|
+
if as_date(data.get("decided_on")) is None:
|
|
177
|
+
fail("C7", path, f"a decision ({decision}) records decided_on, YYYY-MM-DD")
|
|
178
|
+
if decision == "go" and not str(data.get("challenger") or "").strip():
|
|
179
|
+
fail("C7", path, "a go names its challenger: another session, or a human, challenged the "
|
|
180
|
+
"document first (playbooks/discovery.md, stage 5)")
|
|
181
|
+
return decision if isinstance(decision, str) else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def run(root: Path) -> int:
|
|
185
|
+
failures: list[str] = []
|
|
186
|
+
|
|
187
|
+
def fail(rule: str, path: Path, message: str) -> None:
|
|
188
|
+
failures.append(f"[{rule}] {path.relative_to(root)}\n {message}")
|
|
189
|
+
|
|
190
|
+
if not (root / PROJECT).is_dir():
|
|
191
|
+
print(f"Plan: not applicable, no {PROJECT}/ in {root}.")
|
|
192
|
+
return 0
|
|
193
|
+
charter = root / CHARTER
|
|
194
|
+
discovery = root / DISCOVERY
|
|
195
|
+
cycles = cycle_files(root)
|
|
196
|
+
if not charter.is_file() and not cycles and not discovery.is_file():
|
|
197
|
+
print(f"Plan: no charter and no cycle in {PROJECT}/ — the project is not framed yet "
|
|
198
|
+
"(playbooks/framing.md).")
|
|
199
|
+
return 0
|
|
200
|
+
decision = _check_discovery(discovery, fail) if discovery.is_file() else None
|
|
201
|
+
accepted_charter = _check_charter(charter, fail) if charter.is_file() else False
|
|
202
|
+
if accepted_charter and discovery.is_file() and decision != "go":
|
|
203
|
+
fail("C7", charter, f"the charter is accepted, but the discovery's decision is '{decision}': "
|
|
204
|
+
"a charter follows a go (docs/project/discovery.md)")
|
|
205
|
+
accepted = [path for path in cycles if _check_cycle(path, fail) == "accepted"]
|
|
206
|
+
if len(accepted) > 1:
|
|
207
|
+
names = ", ".join(path.name for path in accepted)
|
|
208
|
+
fail("C5", root / CYCLES, f"{len(accepted)} accepted cycles ({names}): one cycle at a time — "
|
|
209
|
+
"close or stop the others")
|
|
210
|
+
if accepted and not accepted_charter:
|
|
211
|
+
fail("C5", accepted[0], "an accepted cycle requires an accepted charter "
|
|
212
|
+
f"({CHARTER}, status: accepted)")
|
|
213
|
+
|
|
214
|
+
print(f"Plan: charter {'present' if charter.is_file() else 'absent'}, {len(cycles)} cycle(s).")
|
|
215
|
+
for failure in failures:
|
|
216
|
+
print(f" FAIL {failure}")
|
|
217
|
+
if failures:
|
|
218
|
+
print(f"\n{len(failures)} violation(s). See docs/project/README.md and playbooks/framing.md.")
|
|
219
|
+
return 1
|
|
220
|
+
print("Plan: compliant.")
|
|
221
|
+
return 0
|
|
@@ -21,8 +21,9 @@ from napkinstack.fitness.manifests import find_manifests
|
|
|
21
21
|
|
|
22
22
|
TEMPLATE = Path(__file__).resolve().parent / "templates" / "module"
|
|
23
23
|
NAME = re.compile(r"[a-z][a-z0-9-]*")
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
OWNER = re.compile( # same rule as copier.yml: organisation/team, or a GitHub user
|
|
25
|
+
r"[A-Za-z0-9-]+/[A-Za-z0-9._-]+|(?=[A-Za-z0-9-]{1,39}$)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*")
|
|
26
|
+
OPTIONAL = {"bootstrap": "nothing to prepare", "e2e": "no end-to-end scenario"} # undeclared: skipped
|
|
26
27
|
NEEDS_RUNBOOK = {"high", "critical"} # M8, kept in step with CRITICALITIES
|
|
27
28
|
|
|
28
29
|
RUNBOOK = """# Runbook - {name}
|
|
@@ -46,13 +47,14 @@ RUNBOOK = """# Runbook - {name}
|
|
|
46
47
|
"""
|
|
47
48
|
|
|
48
49
|
|
|
49
|
-
def create(root: Path, name: str, owner: str, criticality: str) -> int:
|
|
50
|
+
def create(root: Path, name: str, owner: str, criticality: str, user_facing: bool = False) -> int:
|
|
50
51
|
if not NAME.fullmatch(name):
|
|
51
52
|
print(f"FAIL [new-module] invalid name '{name}': kebab-case expected, for example billing.")
|
|
52
53
|
return 1
|
|
53
|
-
if not
|
|
54
|
+
if not OWNER.fullmatch(owner):
|
|
54
55
|
print(f"FAIL [new-module] invalid owner '{owner}': a GitHub team, organisation/team, "
|
|
55
|
-
"for example acme/billing
|
|
56
|
+
"or a user when the project has no organisation, for example acme/billing "
|
|
57
|
+
"(CODEOWNERS, docs/os/07-governance.md §7).")
|
|
56
58
|
return 1
|
|
57
59
|
folder = root / "modules" / name
|
|
58
60
|
if folder.exists():
|
|
@@ -67,6 +69,11 @@ def create(root: Path, name: str, owner: str, criticality: str) -> int:
|
|
|
67
69
|
text = text.replace(marker, value)
|
|
68
70
|
file_.write_text(text, encoding="utf-8")
|
|
69
71
|
|
|
72
|
+
if user_facing:
|
|
73
|
+
manifest = folder / "MANIFEST.yaml"
|
|
74
|
+
manifest.write_text(re.sub(r"^( *)user_facing: false", r"\1user_facing: true",
|
|
75
|
+
manifest.read_text(encoding="utf-8"), flags=re.M), encoding="utf-8")
|
|
76
|
+
|
|
70
77
|
runbook = criticality in NEEDS_RUNBOOK
|
|
71
78
|
if runbook:
|
|
72
79
|
(folder / "docs").mkdir(exist_ok=True)
|
|
@@ -86,10 +93,10 @@ def create(root: Path, name: str, owner: str, criticality: str) -> int:
|
|
|
86
93
|
print(f"WARNING: .github/CODEOWNERS missing; add \"{line} @{owner}\" to it.")
|
|
87
94
|
|
|
88
95
|
print(f"Module created: modules/{name} (owner {owner}, criticality {criticality})"
|
|
89
|
-
+ (", runbook to fill in" if runbook else "") + ".")
|
|
96
|
+
+ (", user-facing" if user_facing else "") + (", runbook to fill in" if runbook else "") + ".")
|
|
90
97
|
print("\nNext steps:")
|
|
91
98
|
print(" 1. Creation ADR in docs/adr/: capability, boundary, alternatives")
|
|
92
|
-
print(" 2. MANIFEST.yaml: responsibility in ONE sentence, then the stack's check and test commands")
|
|
99
|
+
print(" 2. MANIFEST.yaml: responsibility in ONE sentence, user_facing, then the stack's check and test commands")
|
|
93
100
|
print(f" 3. modules/{name}/AGENTS.md: what is specific to the module, never the kernel")
|
|
94
101
|
print(f" 4. nstack fitness, then nstack check {name} and nstack test {name}")
|
|
95
102
|
return 0
|
|
@@ -120,7 +127,7 @@ def run_verb(root: Path, verb: str, name: str | None) -> int:
|
|
|
120
127
|
command = commands.get(verb)
|
|
121
128
|
if not command:
|
|
122
129
|
if verb in OPTIONAL:
|
|
123
|
-
print(f"-> {target}: {verb} not declared,
|
|
130
|
+
print(f"-> {target}: {verb} not declared, {OPTIONAL[verb]}.")
|
|
124
131
|
continue
|
|
125
132
|
print(f"FAIL [{verb}] module '{target}': commands.{verb} not declared in {manifest}.\n"
|
|
126
133
|
" Action: declare the module stack's command there (docs/os/09-platform.md §2).")
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pull request rules read from its description: the test sheet (PDR-0003) and the cycle
|
|
3
|
+
(PDR-0002).
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
T1 a test sheet when the pull request touches a user-facing module, or one of
|
|
7
|
+
criticality high or critical
|
|
8
|
+
T2 a verifier named, and every scenario row filled in: id, given · when · then, a kind
|
|
9
|
+
(automated, explored, human only — reason), a result (passed, failed, not verified)
|
|
10
|
+
T3 no scenario passed without its evidence and the commit it was verified on
|
|
11
|
+
T4 evidence produced on the pull request's head commit: the others are to run again
|
|
12
|
+
T5 no scenario failed; none left not verified, unless it is human only
|
|
13
|
+
K1 delivery work needs an accepted charter and an accepted cycle
|
|
14
|
+
K2 delivery work stops once the cycle is past its end date: the circuit breaker
|
|
15
|
+
K3 delivery work names a ready or in-progress deliverable of the cycle
|
|
16
|
+
K4 the out-of-cycle label carries its justification
|
|
17
|
+
|
|
18
|
+
Delivery work: a pull request that changes a module — a folder holding a MANIFEST.yaml.
|
|
19
|
+
The out-of-cycle label lifts K1 to K3, visibly and countably (docs/os/10-measurement.md).
|
|
20
|
+
|
|
21
|
+
Usage : nstack pr-check [--root ROOT] [--base BASE] [--body-file FILE]
|
|
22
|
+
In CI : PR_BODY, PR_LABELS and PR_HEAD_SHA come from the pull_request event.
|
|
23
|
+
Output: 0 when every applicable rule passes, 1 otherwise.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import datetime
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import subprocess
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
from napkinstack.fitness import plan
|
|
38
|
+
from napkinstack.fitness.manifests import find_manifests
|
|
39
|
+
|
|
40
|
+
LABEL = "out-of-cycle"
|
|
41
|
+
DELIVERABLE = re.compile(r"^Deliverable:[ \t]*(D[1-9][0-9]*)\b", re.I | re.M)
|
|
42
|
+
JUSTIFICATION = re.compile(r"^Out of cycle:[ \t]*(\S.*)$", re.I | re.M)
|
|
43
|
+
SHEET_CRITICALITIES = {"high", "critical"}
|
|
44
|
+
COLUMNS = ("#", "given · when · then", "kind", "result", "evidence", "commit")
|
|
45
|
+
KINDS = {"automated", "explored"}
|
|
46
|
+
RESULTS = {"passed", "failed", "not verified"}
|
|
47
|
+
SECTION = re.compile(r"^##[ \t]+Test sheet[ \t]*$", re.I | re.M)
|
|
48
|
+
NEXT_SECTION = re.compile(r"^#{1,2}[ \t]", re.M)
|
|
49
|
+
VERIFIER = re.compile(r"^Verifier:[ \t]*(.*)$", re.I | re.M)
|
|
50
|
+
HUMAN_ONLY = re.compile(r"human only[ \t]*[—–-][ \t]*(\S.*)", re.I)
|
|
51
|
+
PLACEHOLDER = re.compile(r"<[^<>]*>")
|
|
52
|
+
EMPTY = {"", "—", "-"}
|
|
53
|
+
SHA = re.compile(r"[0-9a-f]{7,40}")
|
|
54
|
+
|
|
55
|
+
Fail = Callable[[str, str], None]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
59
|
+
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def touched_modules(root: Path, files: list[str]) -> dict[str, dict]:
|
|
63
|
+
"""Folder, relative to the root, to manifest, for every module the files change."""
|
|
64
|
+
touched = {}
|
|
65
|
+
for manifest in find_manifests(root):
|
|
66
|
+
folder = manifest.parent.relative_to(root).as_posix()
|
|
67
|
+
if any(path.startswith(f"{folder}/") for path in files):
|
|
68
|
+
try:
|
|
69
|
+
data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
|
|
70
|
+
except yaml.YAMLError:
|
|
71
|
+
data = {}
|
|
72
|
+
touched[folder] = data if isinstance(data, dict) else {}
|
|
73
|
+
return touched
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sheet_reason(folder: str, manifest: dict) -> str | None:
|
|
77
|
+
"""Why a module requires a test sheet (T1), or None."""
|
|
78
|
+
module = manifest.get("module") if isinstance(manifest.get("module"), dict) else {}
|
|
79
|
+
if module.get("user_facing") is True:
|
|
80
|
+
return f"{folder} (user-facing)"
|
|
81
|
+
if module.get("criticality") in SHEET_CRITICALITIES:
|
|
82
|
+
return f"{folder} (criticality {module['criticality']})"
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cells(line: str) -> list[str]:
|
|
87
|
+
inner = line.strip().removeprefix("|").removesuffix("|")
|
|
88
|
+
return [cell.strip().replace("\\|", "|") for cell in re.split(r"(?<!\\)\|", inner)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_sheet(body: str) -> tuple[str, list[str], list[dict[str, str]]]:
|
|
92
|
+
"""(verifier, header, filled rows) of the "Test sheet" section; empty when absent."""
|
|
93
|
+
match = SECTION.search(body)
|
|
94
|
+
if not match:
|
|
95
|
+
return "", [], []
|
|
96
|
+
section = body[match.end():]
|
|
97
|
+
if following := NEXT_SECTION.search(section):
|
|
98
|
+
section = section[:following.start()]
|
|
99
|
+
found = VERIFIER.search(section)
|
|
100
|
+
verifier = found[1].strip() if found else ""
|
|
101
|
+
verifier = "" if PLACEHOLDER.fullmatch(verifier) else verifier
|
|
102
|
+
lines = [line for line in section.splitlines() if line.strip().startswith("|")]
|
|
103
|
+
if len(lines) < 2:
|
|
104
|
+
return verifier, [], []
|
|
105
|
+
header = [" ".join(cell.lower().split()) for cell in _cells(lines[0])]
|
|
106
|
+
rows = []
|
|
107
|
+
for line in lines[2:]:
|
|
108
|
+
values = _cells(line)
|
|
109
|
+
if all(value in EMPTY or PLACEHOLDER.fullmatch(value) for value in values[1:]):
|
|
110
|
+
continue # the template's example row
|
|
111
|
+
rows.append(dict(zip(header, values)))
|
|
112
|
+
return verifier, header, rows
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _result(cell: str) -> str:
|
|
116
|
+
return re.split(r"[ \t]+[—–-][ \t]+", cell.replace("*", "").strip(), maxsplit=1)[0].lower()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def check_sheet(body: str, head: str, reasons: list[str], fail: Fail) -> list[str]:
|
|
120
|
+
"""T1 to T5; returns the human-only scenarios, listed apart for the approver."""
|
|
121
|
+
verifier, header, rows = read_sheet(body)
|
|
122
|
+
if reasons and not rows:
|
|
123
|
+
fail("T1", f"Test sheet missing: this pull request touches {', '.join(reasons)}.\n"
|
|
124
|
+
" Action: fill in the \"Test sheet\" section of the description — scenarios "
|
|
125
|
+
"from the acceptance criteria, run by a verifier who is not the author "
|
|
126
|
+
"(docs/os/05-workflow.md §7).")
|
|
127
|
+
return []
|
|
128
|
+
if not rows:
|
|
129
|
+
return []
|
|
130
|
+
missing = [column for column in COLUMNS if column not in header]
|
|
131
|
+
if missing:
|
|
132
|
+
fail("T2", f"Test sheet: columns missing: {', '.join(missing)}.\n"
|
|
133
|
+
f" Expected: | {' | '.join(COLUMNS)} |")
|
|
134
|
+
return []
|
|
135
|
+
if not verifier:
|
|
136
|
+
fail("T2", "Test sheet: no verifier named.\n Action: \"Verifier: <agent session "
|
|
137
|
+
"or @human>\", someone other than the author of the change.")
|
|
138
|
+
human_only, rerun = [], []
|
|
139
|
+
for row in rows:
|
|
140
|
+
ident = row["#"] or "?"
|
|
141
|
+
kind = row["kind"]
|
|
142
|
+
reason = HUMAN_ONLY.fullmatch(kind)
|
|
143
|
+
result = _result(row["result"])
|
|
144
|
+
if row["given · when · then"] in EMPTY or PLACEHOLDER.fullmatch(row["given · when · then"]):
|
|
145
|
+
fail("T2", f"scenario {ident}: given · when · then missing")
|
|
146
|
+
if kind.lower() not in KINDS and not reason:
|
|
147
|
+
fail("T2", f"scenario {ident}: kind '{kind}', expected automated, explored, "
|
|
148
|
+
"or human only — <reason>")
|
|
149
|
+
if result not in RESULTS:
|
|
150
|
+
fail("T2", f"scenario {ident}: result '{row['result']}', expected passed, failed or "
|
|
151
|
+
"not verified")
|
|
152
|
+
continue
|
|
153
|
+
commit = row["commit"].strip().strip("`").lower()
|
|
154
|
+
if result == "passed" and (row["evidence"] in EMPTY or not SHA.fullmatch(commit)):
|
|
155
|
+
fail("T3", f"scenario {ident}: passed without evidence and the commit verified.\n"
|
|
156
|
+
" Action: link the screenshot, video, trace or log, and give the commit.")
|
|
157
|
+
elif result in {"passed", "failed"} and SHA.fullmatch(commit) and not head.startswith(commit):
|
|
158
|
+
rerun.append(ident)
|
|
159
|
+
if result == "failed":
|
|
160
|
+
fail("T5", f"scenario {ident}: failed.\n Action: fix the change, or have the decider "
|
|
161
|
+
"change the expected result, visibly in the sheet's history.")
|
|
162
|
+
elif result == "not verified" and reason:
|
|
163
|
+
human_only.append(f"{ident} — {reason[1]}")
|
|
164
|
+
elif result == "not verified":
|
|
165
|
+
fail("T5", f"scenario {ident}: not verified.\n Action: run it, or mark it "
|
|
166
|
+
"human only — <reason>.")
|
|
167
|
+
if rerun:
|
|
168
|
+
fail("T4", f"scenarios verified on another commit than the head {head[:7]}: "
|
|
169
|
+
f"{', '.join(rerun)}.\n Action: run them again on the head commit.")
|
|
170
|
+
return human_only
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, fail: Fail) -> None:
|
|
174
|
+
"""K1 to K4, for delivery work."""
|
|
175
|
+
if LABEL in labels:
|
|
176
|
+
if not JUSTIFICATION.search(body):
|
|
177
|
+
fail("K4", f"label {LABEL} without its justification.\n Action: add "
|
|
178
|
+
"\"Out of cycle: <reason>\" to the description — an incident, a production defect.")
|
|
179
|
+
return
|
|
180
|
+
cycle = plan.accepted_cycle(root)
|
|
181
|
+
if not plan.charter_accepted(root) or cycle is None:
|
|
182
|
+
fail("K1", "the project is not framed: no accepted charter and cycle in docs/project/.\n"
|
|
183
|
+
" Action: frame it with your agent (playbooks/framing.md), or add the "
|
|
184
|
+
f"{LABEL} label with a justification.")
|
|
185
|
+
return
|
|
186
|
+
path, data = cycle
|
|
187
|
+
end = plan.as_date(data.get("end"))
|
|
188
|
+
if end is not None and today > end:
|
|
189
|
+
fail("K2", f"circuit breaker: {path.name} ended on {end}, with no automatic extension.\n"
|
|
190
|
+
" Action: the decider chooses — ship what is accepted (status: closed), "
|
|
191
|
+
"frame a new cycle with a new appetite, or stop the project (status: stopped).")
|
|
192
|
+
return
|
|
193
|
+
match = DELIVERABLE.search(body)
|
|
194
|
+
states = {str(item.get("id")): item.get("state") for item in data.get("deliverables") or []
|
|
195
|
+
if isinstance(item, dict)}
|
|
196
|
+
if not match:
|
|
197
|
+
fail("K3", f"no deliverable named.\n Action: \"Deliverable: D<n>\" in the description, "
|
|
198
|
+
f"a deliverable of {path.name}.")
|
|
199
|
+
elif match[1] not in states:
|
|
200
|
+
fail("K3", f"deliverable {match[1]} is not in {path.name}.\n Action: name one of "
|
|
201
|
+
f"{', '.join(states) or 'its deliverables'}, or re-frame the cycle with the decider.")
|
|
202
|
+
elif states[match[1]] not in {"ready", "in-progress"}:
|
|
203
|
+
fail("K3", f"deliverable {match[1]} is '{states[match[1]]}': work starts on a ready "
|
|
204
|
+
"deliverable (definition of ready).")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run(root: Path, base: str, body_file: Path | None = None) -> int:
|
|
208
|
+
if body_file is not None:
|
|
209
|
+
body = body_file.read_text(encoding="utf-8")
|
|
210
|
+
elif "PR_BODY" in os.environ:
|
|
211
|
+
body = os.environ["PR_BODY"]
|
|
212
|
+
else:
|
|
213
|
+
print("Pull request description not provided (PR_BODY or --body-file): not checked.")
|
|
214
|
+
return 0
|
|
215
|
+
if _git(root, "rev-parse", "--verify", "--quiet", base).returncode:
|
|
216
|
+
print(f"Base '{base}' not found — check skipped.")
|
|
217
|
+
return 0
|
|
218
|
+
files = _git(root, "diff", "--name-only", f"{base}...HEAD").stdout.split()
|
|
219
|
+
head = (os.environ.get("PR_HEAD_SHA") or _git(root, "rev-parse", "HEAD").stdout).strip().lower()
|
|
220
|
+
modules = touched_modules(root, files)
|
|
221
|
+
labels = {label.strip() for label in os.environ.get("PR_LABELS", "").split(",") if label.strip()}
|
|
222
|
+
|
|
223
|
+
failures: list[str] = []
|
|
224
|
+
|
|
225
|
+
def fail(rule: str, message: str) -> None:
|
|
226
|
+
failures.append(f"[{rule}] {message}")
|
|
227
|
+
|
|
228
|
+
reasons = [reason for folder, data in modules.items() if (reason := sheet_reason(folder, data))]
|
|
229
|
+
human_only = check_sheet(body, head, reasons, fail)
|
|
230
|
+
if modules:
|
|
231
|
+
check_cycle(root, body, labels, datetime.date.today(), fail)
|
|
232
|
+
|
|
233
|
+
print(f"Modules touched : {len(modules)}" + "".join(f"\n - {folder}" for folder in modules))
|
|
234
|
+
print(f"Test sheet : {'required' if reasons else 'not required'}")
|
|
235
|
+
for scenario in human_only:
|
|
236
|
+
print(f" For the approver, human only: {scenario}")
|
|
237
|
+
for failure in failures:
|
|
238
|
+
print(f"FAIL {failure}")
|
|
239
|
+
if failures:
|
|
240
|
+
return 1
|
|
241
|
+
print("Pull request rules: compliant.")
|
|
242
|
+
return 0
|
|
@@ -9,7 +9,7 @@ module:
|
|
|
9
9
|
responsibility: >
|
|
10
10
|
TODO - describe this module's business capability in one sentence.
|
|
11
11
|
|
|
12
|
-
owner: "{{OWNER}}" # a GitHub
|
|
12
|
+
owner: "{{OWNER}}" # a GitHub team, organisation/team; a user only without an organisation
|
|
13
13
|
contact: "@{{OWNER}}"
|
|
14
14
|
|
|
15
15
|
# proposed | active | maintenance | deprecated | retired (docs/os/02-modules.md §6)
|
|
@@ -18,6 +18,10 @@ module:
|
|
|
18
18
|
# prototype | standard | high | critical (docs/os/07-governance.md §6)
|
|
19
19
|
criticality: "{{CRITICALITY}}"
|
|
20
20
|
|
|
21
|
+
# Does a user see this module? true requires a test sheet on its pull requests
|
|
22
|
+
# (docs/os/05-workflow.md §7).
|
|
23
|
+
user_facing: false
|
|
24
|
+
|
|
21
25
|
# The module's name in the code, when it differs from the folder (package, namespace).
|
|
22
26
|
# Used by boundary detection.
|
|
23
27
|
# code_name: {{MODULE_NAME}}
|
|
@@ -63,6 +67,7 @@ commands:
|
|
|
63
67
|
test: "echo 'commands.test to be declared in MANIFEST.yaml' >&2; exit 1" # NEVER starts another module
|
|
64
68
|
# bootstrap: # optional: make the module usable from a fresh clone
|
|
65
69
|
# run: # optional: local start, with doubles for the dependencies
|
|
70
|
+
# e2e: # optional: end-to-end scenarios; evidence written to .evidence/
|
|
66
71
|
|
|
67
72
|
# Review budget. Inherited from the project when absent (docs/os/05-workflow.md §4).
|
|
68
73
|
review_budget:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|