napkinstack 0.4.0__tar.gz → 0.6.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.4.0 → napkinstack-0.6.0}/PKG-INFO +30 -7
- {napkinstack-0.4.0 → napkinstack-0.6.0}/README.md +29 -6
- {napkinstack-0.4.0 → napkinstack-0.6.0}/pyproject.toml +1 -1
- {napkinstack-0.4.0 → napkinstack-0.6.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/cli.py +12 -4
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/compat.py +23 -9
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/doctor.py +38 -2
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/boundaries.py +14 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/manifests.py +29 -1
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/plan.py +11 -8
- napkinstack-0.6.0/src/napkinstack/landed.py +122 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/modules.py +49 -8
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/project.py +15 -3
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/provenance.py +10 -2
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/pull_request.py +11 -3
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -6
- {napkinstack-0.4.0 → napkinstack-0.6.0}/LICENSE +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.4.0 → napkinstack-0.6.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.6.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,14 +20,21 @@ 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.
|
|
23
|
+
> **Status: v0.5.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
|
|
24
24
|
> the framework announces refuses what it claims to — the boundaries read the contracts a
|
|
25
25
|
> module uses, a contract version someone relies on changes only with a proof, the module
|
|
26
26
|
> checks and stale approvals are enforced, a verifier is not an author, and every verdict names
|
|
27
|
-
> the framework that gave it.
|
|
27
|
+
> the framework that gave it. Proved on the project that exposed the defects: eight probes
|
|
28
|
+
> replayed, each refused or accepted as announced. The contract proof now runs in the base's
|
|
29
|
+
> tree, so a change cannot rewrite what judges it; the diagnosis says whether anything refuses
|
|
30
|
+
> at all; and a record names what reached the default branch outside a pull request — it
|
|
31
|
+
> records, it never refuses. A first pilot project, private, starts from here.
|
|
32
|
+
> Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
28
33
|
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
29
34
|
> [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
|
|
30
35
|
> [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
|
|
36
|
+
> [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
|
|
37
|
+
> [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
|
|
31
38
|
> [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
32
39
|
|
|
33
40
|
## A project's journey
|
|
@@ -78,10 +85,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
|
|
|
78
85
|
nstack init my-project
|
|
79
86
|
```
|
|
80
87
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
To try it without installing anything, or to install it from this repository instead of the
|
|
89
|
+
registry — always pinned to a release tag:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
|
|
93
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
97
|
+
`vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
|
|
98
|
+
repository. The forge distributes that same tag's source, which is a supported way in and
|
|
99
|
+
never a published one — a tag can be moved, and carries no attestation — so a run installed
|
|
100
|
+
that way says where its rules came from, on every line that judges
|
|
101
|
+
([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
|
|
102
|
+
[PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
|
|
103
|
+
unaffected: created from either channel at the same tag, it records the same version and its
|
|
104
|
+
CI installs from the registry.
|
|
105
|
+
|
|
106
|
+
Each project then pins its version and changes it through `nstack update`.
|
|
85
107
|
|
|
86
108
|
## The commands
|
|
87
109
|
|
|
@@ -99,6 +121,7 @@ the commit of this repository
|
|
|
99
121
|
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
100
122
|
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
101
123
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
124
|
+
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
102
125
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
103
126
|
| `nstack update` | Lays the new version on a branch to review |
|
|
104
127
|
|
|
@@ -5,14 +5,21 @@ 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.
|
|
8
|
+
> **Status: v0.5.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
|
|
9
9
|
> the framework announces refuses what it claims to — the boundaries read the contracts a
|
|
10
10
|
> module uses, a contract version someone relies on changes only with a proof, the module
|
|
11
11
|
> checks and stale approvals are enforced, a verifier is not an author, and every verdict names
|
|
12
|
-
> the framework that gave it.
|
|
12
|
+
> the framework that gave it. Proved on the project that exposed the defects: eight probes
|
|
13
|
+
> replayed, each refused or accepted as announced. The contract proof now runs in the base's
|
|
14
|
+
> tree, so a change cannot rewrite what judges it; the diagnosis says whether anything refuses
|
|
15
|
+
> at all; and a record names what reached the default branch outside a pull request — it
|
|
16
|
+
> records, it never refuses. A first pilot project, private, starts from here.
|
|
17
|
+
> Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
13
18
|
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
14
19
|
> [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
|
|
15
20
|
> [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
|
|
21
|
+
> [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
|
|
22
|
+
> [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
|
|
16
23
|
> [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
17
24
|
|
|
18
25
|
## A project's journey
|
|
@@ -63,10 +70,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
|
|
|
63
70
|
nstack init my-project
|
|
64
71
|
```
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
To try it without installing anything, or to install it from this repository instead of the
|
|
74
|
+
registry — always pinned to a release tag:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
|
|
78
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
82
|
+
`vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
|
|
83
|
+
repository. The forge distributes that same tag's source, which is a supported way in and
|
|
84
|
+
never a published one — a tag can be moved, and carries no attestation — so a run installed
|
|
85
|
+
that way says where its rules came from, on every line that judges
|
|
86
|
+
([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
|
|
87
|
+
[PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
|
|
88
|
+
unaffected: created from either channel at the same tag, it records the same version and its
|
|
89
|
+
CI installs from the registry.
|
|
90
|
+
|
|
91
|
+
Each project then pins its version and changes it through `nstack update`.
|
|
70
92
|
|
|
71
93
|
## The commands
|
|
72
94
|
|
|
@@ -84,6 +106,7 @@ the commit of this repository
|
|
|
84
106
|
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
85
107
|
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
86
108
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
109
|
+
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
87
110
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
88
111
|
| `nstack update` | Lays the new version on a branch to review |
|
|
89
112
|
|
|
@@ -6,7 +6,8 @@ import argparse
|
|
|
6
6
|
import json
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
-
from napkinstack import __version__, compat, discovery, doctor, modules, provenance,
|
|
9
|
+
from napkinstack import (__version__, compat, discovery, doctor, landed, modules, provenance,
|
|
10
|
+
pull_request, skills)
|
|
10
11
|
from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
|
|
11
12
|
|
|
12
13
|
|
|
@@ -18,7 +19,7 @@ def _root(value: str) -> Path:
|
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def _modules(args: argparse.Namespace) -> int:
|
|
21
|
-
found = modules.listing(args.root, args.changed_since)
|
|
22
|
+
found = modules.listing(args.root, args.changed_since, args.with_contract_sides)
|
|
22
23
|
if found is None:
|
|
23
24
|
print(f"FAIL [modules] base '{args.changed_since}' not found in {args.root}.\n"
|
|
24
25
|
" Action: fetch the history (fetch-depth: 0), or pass an existing commit.")
|
|
@@ -62,7 +63,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
62
63
|
sub = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
63
64
|
_add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M10)",
|
|
64
65
|
lambda a: manifests.run(a.root))
|
|
65
|
-
_add(sub, "boundaries", "declared graph against real graph (B1-
|
|
66
|
+
_add(sub, "boundaries", "declared graph against real graph (B1-B8)",
|
|
66
67
|
lambda a: boundaries.run(a.root))
|
|
67
68
|
_add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
|
|
68
69
|
sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
|
|
@@ -97,6 +98,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
97
98
|
rn.add_argument("module")
|
|
98
99
|
md = _add(sub, "modules", "the project's modules, or those with a file changed since a base", _modules)
|
|
99
100
|
md.add_argument("--changed-since", metavar="BASE", help="only the modules with a file changed since BASE")
|
|
101
|
+
md.add_argument("--with-contract-sides", action="store_true",
|
|
102
|
+
help="also the producer and the declared consumers of a contract version "
|
|
103
|
+
"this change touches (with --changed-since)")
|
|
100
104
|
md.add_argument("--json", action="store_true", help="a JSON list, for CI")
|
|
101
105
|
ps = _add(sub, "pr-scope", "one PR = one module, review budget (P1-P2)",
|
|
102
106
|
lambda a: pr_scope.run(a.root, a.base))
|
|
@@ -105,6 +109,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
105
109
|
lambda a: pull_request.run(a.root, a.base, a.body_file))
|
|
106
110
|
pc.add_argument("--base", default="origin/main")
|
|
107
111
|
pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
|
|
112
|
+
ld = _add(sub, "landed", "what reached this branch outside a pull request, recorded (W1)",
|
|
113
|
+
lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
|
|
114
|
+
ld.add_argument("--span", default="HEAD~1..HEAD",
|
|
115
|
+
help="the commits to read, BEFORE..AFTER (default: the last commit)")
|
|
108
116
|
ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent (PDR-0002)",
|
|
109
117
|
lambda a: discovery.run(a.root, a.idea))
|
|
110
118
|
ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
|
|
@@ -124,7 +132,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
124
132
|
|
|
125
133
|
|
|
126
134
|
JUDGES = {"manifests", "boundaries", "plan", "skills", "hygiene", "fitness", "doctor", "pr-scope",
|
|
127
|
-
"pr-check", "compat"} # the commands whose verdict depends on the framework's rules
|
|
135
|
+
"pr-check", "compat", "landed"} # the commands whose verdict depends on the framework's rules
|
|
128
136
|
|
|
129
137
|
|
|
130
138
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -98,12 +98,15 @@ def _holder(root: Path, path: str) -> tuple[str, Path] | None:
|
|
|
98
98
|
return None
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
def
|
|
102
|
-
|
|
101
|
+
def _base_tree(root: Path, base: str, into: Path) -> Path:
|
|
102
|
+
"""The whole repository as it is at the base. The proof runs here, so the change it judges
|
|
103
|
+
cannot rewrite what judges it — a script, a fixture, anything it reads (D44). Measured on
|
|
104
|
+
2026-09-20: 7 ms for 1.6 MB."""
|
|
105
|
+
archive = subprocess.run(["git", "archive", "--format=tar", base], cwd=root,
|
|
103
106
|
capture_output=True, check=True).stdout
|
|
104
107
|
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
|
|
105
108
|
tar.extractall(into, filter="data")
|
|
106
|
-
return into
|
|
109
|
+
return into
|
|
107
110
|
|
|
108
111
|
|
|
109
112
|
def run(root: Path, module: str | None, base: str) -> int:
|
|
@@ -152,15 +155,26 @@ def run(root: Path, module: str | None, base: str) -> int:
|
|
|
152
155
|
"a new version beside it (docs/os/03-contracts.md §4).")
|
|
153
156
|
continue
|
|
154
157
|
with tempfile.TemporaryDirectory() as temporary:
|
|
158
|
+
tree = _base_tree(root, fork, Path(temporary))
|
|
159
|
+
where_it_runs = tree / folder.relative_to(root)
|
|
160
|
+
if not where_it_runs.is_dir():
|
|
161
|
+
failures.append(f"[V1] {label}: module '{name}' has no folder at the base, so its "
|
|
162
|
+
f"compatibility command cannot be run as merged.\n Action: "
|
|
163
|
+
"declare the command where the module now lives, in a pull request "
|
|
164
|
+
"of its own (docs/os/03-contracts.md §4).")
|
|
165
|
+
continue
|
|
155
166
|
env = {**os.environ, "NSTACK_CONTRACT": contract, "NSTACK_VERSION": version,
|
|
156
|
-
"NSTACK_BASE_PATH": str(
|
|
157
|
-
"NSTACK_HEAD_PATH": str(root / target)}
|
|
167
|
+
"NSTACK_BASE_PATH": str(tree / path), "NSTACK_HEAD_PATH": str(root / target)}
|
|
158
168
|
print(f"-> {name}: {command}", flush=True)
|
|
159
|
-
code = subprocess.run(command, shell=True, cwd=
|
|
169
|
+
code = subprocess.run(command, shell=True, cwd=where_it_runs, env=env).returncode
|
|
160
170
|
if code:
|
|
161
|
-
failures.append(f"[V1] {label}:
|
|
162
|
-
"
|
|
163
|
-
"
|
|
171
|
+
failures.append(f"[V1] {label}: the compatibility command of module '{name}' ({where}) "
|
|
172
|
+
f"did not prove the change compatible (exit {code}).\n"
|
|
173
|
+
" Action: read the comparator's output above. If the change is "
|
|
174
|
+
"breaking, publish it as a new version beside this one and migrate its "
|
|
175
|
+
"consumers (expand/contract, docs/os/03-contracts.md §4). If the command "
|
|
176
|
+
"could not run — a missing tool, no network — fix the command: V1 cannot "
|
|
177
|
+
"pass without a proof.")
|
|
164
178
|
else:
|
|
165
179
|
checked.append(f"{label}: proven compatible")
|
|
166
180
|
for line in checked:
|
|
@@ -44,6 +44,10 @@ from napkinstack import __version__, provenance
|
|
|
44
44
|
from napkinstack.project import ANSWERS
|
|
45
45
|
|
|
46
46
|
OK, GAP, UNKNOWN, NOT_APPLICABLE = "OK", "FAIL", "NOT VERIFIED", "NOT APPLICABLE"
|
|
47
|
+
OUT_OF_REACH = "OUT OF REACH" # the plan forbids it: a state, not a fault (PDR-0006)
|
|
48
|
+
# The settings that make the forge refuse a merge. A repository is guarded when they are
|
|
49
|
+
# all in force; the others matter and none of them stops a merge.
|
|
50
|
+
BLOCKING = ("G1", "G2", "G3", "G4", "G12", "G13")
|
|
47
51
|
API_VERSION = "2026-03-10"
|
|
48
52
|
PLACEHOLDER = "<One sentence: what this project does.>"
|
|
49
53
|
JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
|
|
@@ -287,6 +291,35 @@ def _display(rule: str, setting: str, status: str, detail: str) -> None:
|
|
|
287
291
|
print(f" {line}")
|
|
288
292
|
|
|
289
293
|
|
|
294
|
+
def _state(results: list[tuple[str, str, str, str]]) -> None:
|
|
295
|
+
"""Guarded when every setting that refuses a merge is in force; unguarded otherwise, with
|
|
296
|
+
what a plan forbids kept apart from what is not yet done; never guarded on what could not
|
|
297
|
+
be read (PDR-0006)."""
|
|
298
|
+
status_of = {rule: status for rule, _, status, _ in results}
|
|
299
|
+
blocking = [status_of.get(rule, UNKNOWN) for rule in BLOCKING]
|
|
300
|
+
if any(status == UNKNOWN for status in blocking):
|
|
301
|
+
print("\nThis repository: not verified — what cannot be read is never reported as guarded.")
|
|
302
|
+
return
|
|
303
|
+
if all(status == OK for status in blocking):
|
|
304
|
+
print("\nThis repository: guarded — the forge refuses what the checklist asks it to refuse.")
|
|
305
|
+
return
|
|
306
|
+
groups = {name: [rule for rule, _, status, _ in results
|
|
307
|
+
if status == name and rule.startswith("G")]
|
|
308
|
+
for name in (OK, GAP, OUT_OF_REACH)}
|
|
309
|
+
print("\nThis repository: unguarded — nothing here refuses a merge.")
|
|
310
|
+
for label, name in (("In force", OK), ("Not yet in place", GAP),
|
|
311
|
+
("Out of reach on this plan", OUT_OF_REACH)):
|
|
312
|
+
if groups[name]:
|
|
313
|
+
print(f" {label:<26}: {', '.join(groups[name])}")
|
|
314
|
+
if groups[OUT_OF_REACH]:
|
|
315
|
+
print(" What it would take : make the repository public, where these work at no "
|
|
316
|
+
"cost on any plan;\n or move the private repository to a "
|
|
317
|
+
"plan that enforces rules;\n or keep working here, "
|
|
318
|
+
"knowing that nothing refuses.")
|
|
319
|
+
if groups[GAP]:
|
|
320
|
+
print(" Each setting not yet in place carries its action above.")
|
|
321
|
+
|
|
322
|
+
|
|
290
323
|
def run(root: Path) -> int:
|
|
291
324
|
if not (root / ANSWERS).is_file():
|
|
292
325
|
print(f"FAIL [doctor] {ANSWERS} not found in {root}: this folder is not a project "
|
|
@@ -324,16 +357,19 @@ def run(root: Path) -> int:
|
|
|
324
357
|
except Gap as action:
|
|
325
358
|
status, detail = GAP, f"Action: {action}"
|
|
326
359
|
if private and status != OK and rule in PRIVATE_PLAN:
|
|
327
|
-
detail
|
|
360
|
+
status, detail = OUT_OF_REACH, f"Reason: {PRIVATE_PLAN[rule]}"
|
|
328
361
|
results.append((rule, setting, status, detail))
|
|
329
362
|
_display(rule, setting, status, detail)
|
|
330
363
|
|
|
331
364
|
gaps = sum(status == GAP for _, _, status, _ in results)
|
|
332
365
|
unknown = sum(status == UNKNOWN for _, _, status, _ in results)
|
|
333
366
|
skipped = sum(status == NOT_APPLICABLE for _, _, status, _ in results)
|
|
367
|
+
out_of_reach = sum(status == OUT_OF_REACH for _, _, status, _ in results)
|
|
368
|
+
_state(results)
|
|
334
369
|
suffix = f", {skipped} not applicable" if skipped else ""
|
|
335
370
|
if not gaps and not unknown:
|
|
336
|
-
|
|
371
|
+
reach = f"; {out_of_reach} setting(s) out of reach on this plan" if out_of_reach else ""
|
|
372
|
+
print(f"\nnstack doctor: compliant{suffix}{reach}.")
|
|
337
373
|
return 0
|
|
338
374
|
print(f"\nnstack doctor: {gaps} gap(s), {unknown} not verified{suffix}.\nWorkflows inform; "
|
|
339
375
|
"it is the GitHub settings that block, and they are not copied with the project.")
|
|
@@ -16,6 +16,8 @@ Rules:
|
|
|
16
16
|
B5 no direct access to another module's data (tables declared elsewhere)
|
|
17
17
|
B6 a consumed contract is provided: its contract, version and module match a provides entry
|
|
18
18
|
B7 a provided contract exists: its path holds its document, inside a module's folder
|
|
19
|
+
B8 a contract consumed from a deprecated module (warning): a deprecated module takes no
|
|
20
|
+
new consumer, and the existing ones migrate before its removal date
|
|
19
21
|
|
|
20
22
|
DETECTION — textual and deliberately simple, with no stack assumed.
|
|
21
23
|
- A contract is read where a module's file names its path (contracts/billing-api/v1),
|
|
@@ -87,10 +89,13 @@ def load_modules(root: Path) -> dict[str, dict]:
|
|
|
87
89
|
owns = section.get("owns") if isinstance(section.get("owns"), list) else []
|
|
88
90
|
name = mod.get("name") if isinstance(mod.get("name"), str) and mod["name"] else manifest.parent.name
|
|
89
91
|
code_name = mod.get("code_name")
|
|
92
|
+
deprecation = mod.get("deprecation") if isinstance(mod.get("deprecation"), dict) else {}
|
|
90
93
|
modules[name] = {
|
|
91
94
|
"path": manifest.parent,
|
|
92
95
|
"dirname": manifest.parent.name,
|
|
93
96
|
"code_name": code_name if isinstance(code_name, str) and code_name else name,
|
|
97
|
+
"lifecycle": mod.get("lifecycle") if isinstance(mod.get("lifecycle"), str) else None,
|
|
98
|
+
"removal": deprecation.get("removal_date"),
|
|
94
99
|
"provides": contract_entries(data, "provides"),
|
|
95
100
|
"consumes": contract_entries(data, "consumes"),
|
|
96
101
|
"owns_data": {table for table in owns if isinstance(table, str)},
|
|
@@ -204,6 +209,15 @@ def check_contracts(root: Path, modules: dict[str, dict]) -> dict[str, dict[str,
|
|
|
204
209
|
elif c.get("module") not in (None, producer):
|
|
205
210
|
fail("B6", name, f"consumes {key[0]} {key[1]} from '{c.get('module')}', which is "
|
|
206
211
|
f"provided by '{producer}'.\n Action: name the producer.")
|
|
212
|
+
# B8 - a deprecated producer. Reported, not refused: what is already declared is
|
|
213
|
+
# bounded by the removal date, which M5 turns red once it has passed.
|
|
214
|
+
elif modules[producer]["lifecycle"] == "deprecated":
|
|
215
|
+
when = modules[producer]["removal"]
|
|
216
|
+
warn("B8", name, f"consumes {key[0]} {key[1]} from '{producer}', which is deprecated"
|
|
217
|
+
+ (f" (removal {when})" if when else "")
|
|
218
|
+
+ ": a deprecated module takes no new consumer.\n Action: "
|
|
219
|
+
"migrate to the module that replaces it before that date "
|
|
220
|
+
"(docs/os/02-modules.md §6); M5 turns red once it has passed.")
|
|
207
221
|
|
|
208
222
|
patterns = contract_patterns(modules)
|
|
209
223
|
reads: dict[str, dict[str, set[str]]] = {name: {} for name in modules}
|
|
@@ -25,6 +25,7 @@ Output: 0 if everything passes, 1 otherwise. Every failure explains the rule br
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
import sys
|
|
27
27
|
import datetime
|
|
28
|
+
import re
|
|
28
29
|
import subprocess
|
|
29
30
|
from pathlib import Path
|
|
30
31
|
|
|
@@ -86,6 +87,24 @@ def contract_entries(data: dict, section: str) -> list[dict]:
|
|
|
86
87
|
and all(isinstance(entry.get(field), (str, type(None))) for field in CONTRACT_FIELDS[section])]
|
|
87
88
|
|
|
88
89
|
|
|
90
|
+
UNFILLED = re.compile(r"<[^<>\s][^<>]*>") # the angle-bracket placeholder of a template
|
|
91
|
+
TODO = re.compile(r"^TODO\b", re.I)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def unfilled(value) -> bool:
|
|
95
|
+
"""Whether a declaration still holds the template's words: empty, carrying a placeholder
|
|
96
|
+
such as <github-handle>, or opening with TODO (D51).
|
|
97
|
+
|
|
98
|
+
A *declaration* is unfilled as soon as it carries a placeholder — nobody writes a success
|
|
99
|
+
criterion around one. A *cell* of a test sheet is unfilled only when it is nothing but a
|
|
100
|
+
placeholder, which is the template's example row: that one is `pull_request.PLACEHOLDER`,
|
|
101
|
+
and the two meanings are deliberately different. A placeholder opens on a non-space, so
|
|
102
|
+
"stays < 200 ms" is a comparison, not a placeholder.
|
|
103
|
+
"""
|
|
104
|
+
text = str(value or "").strip()
|
|
105
|
+
return not text or bool(UNFILLED.search(text)) or bool(TODO.match(text))
|
|
106
|
+
|
|
107
|
+
|
|
89
108
|
def is_description(path: str) -> bool:
|
|
90
109
|
"""A module's description — its manifest, AGENTS.md, README.md, docs/ — or an empty
|
|
91
110
|
placeholder, `path` relative to the module's folder. Changing it changes no behaviour
|
|
@@ -208,9 +227,18 @@ def check_manifest(path: Path, today: datetime.date) -> None:
|
|
|
208
227
|
fail(rel, "M6", f"contract {name}: removal date passed ({removal}). "
|
|
209
228
|
"Finish the contraction (docs/os/03-contracts.md §4).")
|
|
210
229
|
|
|
211
|
-
# M7 - standard verbs, once there is something to check (D33)
|
|
212
230
|
commands = data.get("commands") or {}
|
|
213
231
|
content = module_content(path.parent)
|
|
232
|
+
|
|
233
|
+
# M2 - the responsibility still the template's, once the module holds more than its
|
|
234
|
+
# description: a module is green as created (D33) and says what it does from its first
|
|
235
|
+
# file of code.
|
|
236
|
+
if content and unfilled(resp):
|
|
237
|
+
fail(rel, "M2", f'module.responsibility is still the template\'s: "{resp}"\n'
|
|
238
|
+
" Action: one sentence naming the capability this module covers, "
|
|
239
|
+
"in MANIFEST.yaml (docs/os/02-modules.md §5).")
|
|
240
|
+
|
|
241
|
+
# M7 - standard verbs, once there is something to check (D33)
|
|
214
242
|
for verb in REQUIRED_COMMANDS:
|
|
215
243
|
if content and not commands.get(verb):
|
|
216
244
|
fail(rel, "M7", f"standard verb missing: commands.{verb}, and the module holds more "
|
|
@@ -31,6 +31,7 @@ from pathlib import Path
|
|
|
31
31
|
|
|
32
32
|
import yaml
|
|
33
33
|
|
|
34
|
+
from napkinstack.fitness.manifests import unfilled
|
|
34
35
|
from napkinstack.modules import HANDLE
|
|
35
36
|
|
|
36
37
|
PROJECT = Path("docs") / "project"
|
|
@@ -114,8 +115,9 @@ def _check_charter(path: Path, fail) -> bool:
|
|
|
114
115
|
fail("C2", path, f"status '{data.get('status')}': expected one of {sorted(CHARTER_STATUSES)}")
|
|
115
116
|
check_decider("C2", path, data.get("decider"), fail)
|
|
116
117
|
criteria = data.get("success_criteria")
|
|
117
|
-
if not isinstance(criteria, list) or not [c for c in criteria if
|
|
118
|
-
fail("C2", path, "success_criteria: at least one, they say when the project
|
|
118
|
+
if not isinstance(criteria, list) or not [c for c in criteria if not unfilled(c)]:
|
|
119
|
+
fail("C2", path, "success_criteria: at least one, filled in — they say when the project "
|
|
120
|
+
"itself ends. The template's example is not one")
|
|
119
121
|
return data.get("status") == "accepted"
|
|
120
122
|
|
|
121
123
|
|
|
@@ -135,16 +137,17 @@ def _check_deliverables(path: Path, deliverables, fail) -> None:
|
|
|
135
137
|
elif ident in seen:
|
|
136
138
|
fail("C4", path, f"deliverable {ident}: id used twice")
|
|
137
139
|
seen.add(ident)
|
|
138
|
-
if
|
|
139
|
-
fail("C4", path, f"deliverable {where}: title missing")
|
|
140
|
+
if unfilled(item.get("title")):
|
|
141
|
+
fail("C4", path, f"deliverable {where}: title missing, or still the template's")
|
|
140
142
|
state = item.get("state")
|
|
141
143
|
if state not in DELIVERABLE_STATES:
|
|
142
144
|
fail("C4", path, f"deliverable {where}: state '{state}', expected one of {sorted(DELIVERABLE_STATES)}")
|
|
143
145
|
criteria = item.get("acceptance")
|
|
144
|
-
filled = isinstance(criteria, list) and [c for c in criteria if
|
|
146
|
+
filled = isinstance(criteria, list) and [c for c in criteria if not unfilled(c)]
|
|
145
147
|
if state in DELIVERABLE_STATES - WITHOUT_CRITERIA and not filled:
|
|
146
|
-
fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria
|
|
147
|
-
"the definition of ready, and the source of its test
|
|
148
|
+
fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria "
|
|
149
|
+
"filled in — the definition of ready, and the source of its test "
|
|
150
|
+
"sheet (PDR-0003)")
|
|
148
151
|
|
|
149
152
|
|
|
150
153
|
def _check_cycle(path: Path, fail) -> str | None:
|
|
@@ -188,7 +191,7 @@ def _check_discovery(path: Path, fail) -> str | None:
|
|
|
188
191
|
check_decider("C7", path, data.get("decider"), fail)
|
|
189
192
|
if as_date(data.get("decided_on")) is None:
|
|
190
193
|
fail("C7", path, f"a decision ({decision}) records decided_on, YYYY-MM-DD")
|
|
191
|
-
if decision == "go" and
|
|
194
|
+
if decision == "go" and unfilled(data.get("challenger")):
|
|
192
195
|
fail("C7", path, "a go names its challenger: another session, or a human, challenged the "
|
|
193
196
|
"document first (playbooks/discovery.md, stage 5)")
|
|
194
197
|
return decision if isinstance(decision, str) else None
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What reached the default branch (PDR-0006).
|
|
3
|
+
|
|
4
|
+
Rule:
|
|
5
|
+
W1 a commit on the default branch arrived through a pull request
|
|
6
|
+
|
|
7
|
+
This is a record, not a barrier. Where the forge can refuse — a ruleset requiring a pull
|
|
8
|
+
request — nothing reaches the default branch any other way, and this says so. Where it
|
|
9
|
+
cannot, because the repository's plan does not allow it, a commit pushed straight to the
|
|
10
|
+
branch is the one thing nobody would otherwise be told about. A record that fires late is
|
|
11
|
+
worth more than a silence.
|
|
12
|
+
|
|
13
|
+
It says nothing about what predates it: the commit that created the repository, and
|
|
14
|
+
everything the repository already held when the record arrived, are not reported. A record
|
|
15
|
+
that greets a project by failing on its own arrival teaches people to ignore it.
|
|
16
|
+
|
|
17
|
+
The forge is read with a token supplied by the run (GH_TOKEN, otherwise GITHUB_TOKEN), which
|
|
18
|
+
needs no write access of any kind: the project's automation token stays read-only (G10).
|
|
19
|
+
Without a token, or when the span cannot be read, nothing is claimed.
|
|
20
|
+
|
|
21
|
+
Usage : nstack landed [--root ROOT] [--span BEFORE..AFTER]
|
|
22
|
+
Output: 0 when every commit of the span arrived through a pull request, or when nothing
|
|
23
|
+
could be verified; 1 when one did not.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from napkinstack.doctor import GitHub, NotVerified
|
|
35
|
+
from napkinstack.project import ANSWERS
|
|
36
|
+
|
|
37
|
+
# The workflow that runs this record in a generated project. Its own arrival is what the
|
|
38
|
+
# record treats as its beginning; platform/tests/run.sh checks the two names match.
|
|
39
|
+
RECORD = ".github/workflows/commits-on-main.yml"
|
|
40
|
+
EMPTY = "0" * 40
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
44
|
+
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _commits(root: Path, span: str) -> list[str] | None:
|
|
48
|
+
"""The commits of the span, newest first; None when the span cannot be read."""
|
|
49
|
+
before, _, after = span.partition("..")
|
|
50
|
+
if not after or before in ("", EMPTY):
|
|
51
|
+
after, before = (after or before), "" # a branch's first push has no before
|
|
52
|
+
read = (_git(root, "rev-list", f"{before}..{after}") if before
|
|
53
|
+
else _git(root, "rev-list", "-1", after))
|
|
54
|
+
return None if read.returncode else read.stdout.split()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _beginning(root: Path) -> str | None:
|
|
58
|
+
"""The commit that brought the record into the repository, if it is there."""
|
|
59
|
+
read = _git(root, "log", "--diff-filter=A", "--format=%H", "--", RECORD)
|
|
60
|
+
return read.stdout.split()[-1] if read.returncode == 0 and read.stdout.split() else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _exempt(root: Path, commit: str, beginning: str | None) -> str | None:
|
|
64
|
+
"""Why this commit is not recorded, or None when it is to be checked."""
|
|
65
|
+
if _git(root, "rev-parse", "--verify", "--quiet", f"{commit}^").returncode:
|
|
66
|
+
return "the first commit of the repository"
|
|
67
|
+
if beginning and _git(root, "merge-base", "--is-ancestor", commit, beginning).returncode == 0:
|
|
68
|
+
return "older than the record itself"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _described(root: Path, commit: str) -> str:
|
|
73
|
+
return _git(root, "show", "-s", "--format=%h (%an, %ad)", "--date=short", commit).stdout.strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run(root: Path, span: str, forge: GitHub | None) -> int:
|
|
77
|
+
commits = _commits(root, span)
|
|
78
|
+
if commits is None:
|
|
79
|
+
print(f"[W1] the commits of '{span}' cannot be read: not verified.\n"
|
|
80
|
+
" Action: fetch the history (fetch-depth: 0), or pass an existing range.")
|
|
81
|
+
return 0
|
|
82
|
+
if forge is None:
|
|
83
|
+
print("[W1] no token (GH_TOKEN or GITHUB_TOKEN): how each commit arrived is not verified.\n"
|
|
84
|
+
" Action: supply a token with read access to the repository's pull requests.")
|
|
85
|
+
return 0
|
|
86
|
+
beginning = _beginning(root)
|
|
87
|
+
outside, checked = [], 0
|
|
88
|
+
for commit in commits:
|
|
89
|
+
why = _exempt(root, commit, beginning)
|
|
90
|
+
if why:
|
|
91
|
+
print(f"Commit {_described(root, commit)}: not recorded, {why}.")
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
pulls = forge.get(f"/commits/{commit}/pulls")
|
|
95
|
+
except NotVerified as reason:
|
|
96
|
+
print(f"[W1] commit {commit[:12]}: not verified. Reason: {reason}")
|
|
97
|
+
continue
|
|
98
|
+
checked += 1
|
|
99
|
+
if pulls:
|
|
100
|
+
print(f"Commit {_described(root, commit)}: arrived through a pull request.")
|
|
101
|
+
else:
|
|
102
|
+
outside.append(commit)
|
|
103
|
+
for commit in outside:
|
|
104
|
+
print(f"FAIL [W1] commit {_described(root, commit)} reached this branch outside a pull "
|
|
105
|
+
"request.\n This run records it; it does not refuse it — on this repository "
|
|
106
|
+
"nothing could.\n Action: read the commit. If it was not meant to land this "
|
|
107
|
+
"way, revert it through a pull request.")
|
|
108
|
+
if outside:
|
|
109
|
+
return 1
|
|
110
|
+
if checked:
|
|
111
|
+
print(f"Every commit of this push arrived through a pull request ({checked} checked).")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def from_environment(root: Path) -> GitHub | None:
|
|
116
|
+
"""The forge, read-only, from the project's own answers and the run's token."""
|
|
117
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
118
|
+
if not token:
|
|
119
|
+
return None
|
|
120
|
+
answers = yaml.safe_load((root / ANSWERS).read_text(encoding="utf-8")) or {}
|
|
121
|
+
return GitHub(str(answers.get("github_repo")), token,
|
|
122
|
+
os.environ.get("GITHUB_API_URL") or "https://api.github.com")
|
|
@@ -17,7 +17,8 @@ from pathlib import Path
|
|
|
17
17
|
|
|
18
18
|
import yaml
|
|
19
19
|
|
|
20
|
-
from napkinstack.fitness.manifests import changed_files, find_manifests,
|
|
20
|
+
from napkinstack.fitness.manifests import (changed_files, contract_entries, find_manifests,
|
|
21
|
+
module_content)
|
|
21
22
|
|
|
22
23
|
TEMPLATE = Path(__file__).resolve().parent / "templates" / "module"
|
|
23
24
|
NAME = re.compile(r"[a-z][a-z0-9-]*")
|
|
@@ -118,10 +119,15 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
|
|
|
118
119
|
why = "user-facing" if user_facing else f"criticality {criticality}"
|
|
119
120
|
steps.append(f"Every pull request that changes its behaviour carries a test sheet ({why}), "
|
|
120
121
|
"run by a verifier who is not its author (T1–T5, docs/os/05-workflow.md §7)")
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
|
|
122
|
+
framed = plan.charter_accepted(root)
|
|
123
|
+
cycle = plan.accepted_cycle(root) if framed else None
|
|
124
|
+
if not framed:
|
|
125
|
+
steps.append("The project is not framed: delivery work needs an accepted charter (K1), "
|
|
124
126
|
"or the out-of-cycle label with its justification (K4)")
|
|
127
|
+
elif cycle is None:
|
|
128
|
+
# The charter is accepted: only the next cycle is missing (D47).
|
|
129
|
+
steps.append("No accepted cycle: delivery work needs one (K1), or the out-of-cycle label "
|
|
130
|
+
"with its justification (K4)")
|
|
125
131
|
else:
|
|
126
132
|
steps.append(f"Delivery work names a ready deliverable of {cycle[0].name} (K3), "
|
|
127
133
|
"or carries the out-of-cycle label with its justification (K4)")
|
|
@@ -129,9 +135,40 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
|
|
|
129
135
|
return steps
|
|
130
136
|
|
|
131
137
|
|
|
132
|
-
def
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
def _load(manifest: Path) -> dict:
|
|
139
|
+
try:
|
|
140
|
+
data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
|
|
141
|
+
except yaml.YAMLError:
|
|
142
|
+
return {} # reported by nstack manifests (M2)
|
|
143
|
+
return data if isinstance(data, dict) else {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _contract_sides(root: Path, changed: list[str]) -> set[str]:
|
|
147
|
+
"""The folders of the modules on either side of a contract version the change touches: the
|
|
148
|
+
module that provides it, and those that declare it in consumes. The handbook asks for the
|
|
149
|
+
checks of both sides (docs/os/03-contracts.md §5); the declared graph says who they are, so
|
|
150
|
+
one repository needs no broker to know (D53)."""
|
|
151
|
+
provided: dict[tuple[str, str], tuple[str, str]] = {}
|
|
152
|
+
declared: list[tuple[str, list[dict]]] = []
|
|
153
|
+
for manifest in find_manifests(root):
|
|
154
|
+
data = _load(manifest)
|
|
155
|
+
folder = manifest.parent.relative_to(root).as_posix()
|
|
156
|
+
for entry in contract_entries(data, "provides"):
|
|
157
|
+
key, path = (entry.get("contract"), entry.get("version")), entry.get("path")
|
|
158
|
+
if isinstance(path, str) and all(isinstance(part, str) for part in key):
|
|
159
|
+
provided[key] = (path.strip("/"), folder)
|
|
160
|
+
declared.append((folder, contract_entries(data, "consumes")))
|
|
161
|
+
touched = {key: folder for key, (path, folder) in provided.items()
|
|
162
|
+
if any(file == path or file.startswith(f"{path}/") for file in changed)}
|
|
163
|
+
return set(touched.values()) | {folder for folder, consumes in declared for entry in consumes
|
|
164
|
+
if (entry.get("contract"), entry.get("version")) in touched}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def listing(root: Path, base: str | None = None,
|
|
168
|
+
with_contract_sides: bool = False) -> list[dict[str, str]] | None:
|
|
169
|
+
"""Every module — a folder holding a MANIFEST.yaml, contracts/ and platform/ included — or,
|
|
170
|
+
given a base, those with a file changed since it, and with `with_contract_sides` both sides
|
|
171
|
+
of a contract version the change touches (D53); None when the base is unknown."""
|
|
135
172
|
found = [{"name": manifest.parent.name, "folder": manifest.parent.relative_to(root).as_posix()}
|
|
136
173
|
for manifest in find_manifests(root)]
|
|
137
174
|
if base is None:
|
|
@@ -140,7 +177,11 @@ def listing(root: Path, base: str | None = None) -> list[dict[str, str]] | None:
|
|
|
140
177
|
capture_output=True).returncode:
|
|
141
178
|
return None
|
|
142
179
|
changed = changed_files(root, base)
|
|
143
|
-
|
|
180
|
+
folders = {module["folder"] for module in found
|
|
181
|
+
if any(path.startswith(f"{module['folder']}/") for path in changed)}
|
|
182
|
+
if with_contract_sides:
|
|
183
|
+
folders |= _contract_sides(root, changed)
|
|
184
|
+
return [module for module in found if module["folder"] in folders]
|
|
144
185
|
|
|
145
186
|
|
|
146
187
|
def _modules(root: Path) -> dict[str, Path]:
|
|
@@ -55,8 +55,12 @@ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -
|
|
|
55
55
|
return (f"FAIL [{command}] Working tree modified in {where}: an update starts from a "
|
|
56
56
|
f"committed state (PDR-0001).\n{action}commit or stash (git stash), then run again.")
|
|
57
57
|
if match := DOWNGRADE.search(text):
|
|
58
|
+
# The action is a command, not a description of one: an install pinned to an exact
|
|
59
|
+
# version is not moved by `uv tool upgrade`, which is what a reader tries first.
|
|
58
60
|
return (f"FAIL [{command}] Target version {match[2]} older than the project version ({match[1]}): "
|
|
59
|
-
f"no going back (PDR-0001).\n{action}
|
|
61
|
+
f"no going back (PDR-0001).\n{action}move this workstation forward first — "
|
|
62
|
+
'uv tool install "napkinstack@latest" --with-executables-from pre-commit — '
|
|
63
|
+
f"or pin the version you want, {match[1]} or newer.")
|
|
60
64
|
if text.startswith("Updating is only supported in git-tracked subprojects"):
|
|
61
65
|
return (f"FAIL [{command}] {where} is not a git repository: the merge relies on "
|
|
62
66
|
f"history.\n{action}git init, commit, then run again.")
|
|
@@ -64,9 +68,17 @@ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -
|
|
|
64
68
|
return (f"FAIL [{command}] The project does not come from a published version (_commit in {ANSWERS}): "
|
|
65
69
|
f"no merge base.\n{action}create the project from a vX.Y.Z tag.")
|
|
66
70
|
if isinstance(exc, OSError) or text == "Local template must be a directory.":
|
|
67
|
-
|
|
71
|
+
lines = [line.split("|", 1)[-1].strip() for line in text.splitlines() if line.strip()]
|
|
72
|
+
# git writes the cause on an `error:` line and the outcome on a `fatal:` one; keeping
|
|
73
|
+
# the last line alone drops the only one that says what happened (D54).
|
|
74
|
+
detail = " — ".join(line for line in lines if line.startswith(("error:", "fatal:"))) or lines[-1]
|
|
75
|
+
# A git `error:` line means git refused something local, so the action is local too:
|
|
76
|
+
# Copier copies the template's uncommitted state into its clone before reading it.
|
|
77
|
+
local = ("git refused a file of the template's working tree, which Copier copies as it "
|
|
78
|
+
"is: commit or stash it, or remove the file git names above.")
|
|
79
|
+
remote = "check --source and --ref (a vX.Y.Z tag), and network access."
|
|
68
80
|
return (f"FAIL [{command}] Template {source} at version {ref} unreachable: {detail}\n"
|
|
69
|
-
|
|
81
|
+
+ action + (local if detail.startswith("error:") else remote))
|
|
70
82
|
return f"FAIL [{command}] Copier: {text}"
|
|
71
83
|
|
|
72
84
|
|
|
@@ -3,7 +3,9 @@ Which framework judges a project (PDR-0005): a published version, or something n
|
|
|
3
3
|
published — said out loud on every run that judges, never only in a file.
|
|
4
4
|
|
|
5
5
|
The engine's origin is what its installer recorded (PEP 610, direct_url.json): nothing for a
|
|
6
|
-
version from the registry; a repository
|
|
6
|
+
version from the registry; a repository with the ref it was asked for and the commit that ref
|
|
7
|
+
resolved to, or a local checkout, otherwise. A repository is a supported channel and never a
|
|
8
|
+
published one: a tag can be moved, and carries no attestation (ADR-0002).
|
|
7
9
|
"""
|
|
8
10
|
|
|
9
11
|
from __future__ import annotations
|
|
@@ -30,7 +32,13 @@ def engine() -> tuple[str, str | None]:
|
|
|
30
32
|
return distribution.version, None
|
|
31
33
|
data = json.loads(record)
|
|
32
34
|
if "vcs_info" in data:
|
|
33
|
-
|
|
35
|
+
# The installer records the ref it was asked for beside the commit (PEP 610). Naming it
|
|
36
|
+
# says more than a bare sha — and no less: a tag can be moved, so this is still not the
|
|
37
|
+
# published artefact (ADR-0002, clarified 2026-09-20).
|
|
38
|
+
commit = str(data["vcs_info"].get("commit_id", ""))[:12]
|
|
39
|
+
wanted = str(data["vcs_info"].get("requested_revision") or "")
|
|
40
|
+
return distribution.version, (f"{data['url']} at {wanted} (commit {commit})" if wanted
|
|
41
|
+
else f"{data['url']}@{commit}")
|
|
34
42
|
if "archive_info" in data:
|
|
35
43
|
return distribution.version, "an archive, not the registry"
|
|
36
44
|
path = Path(url2pathname(urlparse(data.get("url", "")).path))
|
|
@@ -11,7 +11,8 @@ Rules:
|
|
|
11
11
|
T3 no scenario passed without its evidence and the commit it was verified on
|
|
12
12
|
T4 evidence produced on the pull request's head commit: the others are to run again
|
|
13
13
|
T5 no scenario failed; none left not verified, unless it is human only
|
|
14
|
-
K1 delivery work needs an accepted charter and an accepted cycle
|
|
14
|
+
K1 delivery work needs an accepted charter and an accepted cycle, and says which of
|
|
15
|
+
the two is missing
|
|
15
16
|
K2 delivery work stops once the cycle is past its end date: the circuit breaker
|
|
16
17
|
K3 delivery work names a ready or in-progress deliverable of the cycle
|
|
17
18
|
K4 the out-of-cycle label carries its justification
|
|
@@ -257,11 +258,18 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
|
|
|
257
258
|
"\"Out of cycle: <reason>\" to the description — an incident, a production defect.")
|
|
258
259
|
return
|
|
259
260
|
cycle = plan.accepted_cycle(root)
|
|
260
|
-
if not plan.charter_accepted(root)
|
|
261
|
-
fail("K1", "the project is not framed: no accepted charter
|
|
261
|
+
if not plan.charter_accepted(root):
|
|
262
|
+
fail("K1", "the project is not framed: no accepted charter in docs/project/.\n"
|
|
262
263
|
" Action: frame it with your agent (playbooks/framing.md), or add the "
|
|
263
264
|
f"{LABEL} label with a justification.")
|
|
264
265
|
return
|
|
266
|
+
if cycle is None:
|
|
267
|
+
# The charter is accepted: sending the reader back to framing would be false (D47).
|
|
268
|
+
fail("K1", "no accepted cycle: the charter is accepted, and no cycle is open.\n"
|
|
269
|
+
" Action: open the next cycle with your agent "
|
|
270
|
+
"(docs/project/cycles/_TEMPLATE.md), or add the "
|
|
271
|
+
f"{LABEL} label with a justification.")
|
|
272
|
+
return
|
|
265
273
|
path, data = cycle
|
|
266
274
|
end = plan.as_date(data.get("end"))
|
|
267
275
|
if end is not None and today > end:
|
|
@@ -71,12 +71,6 @@ commands:
|
|
|
71
71
|
# run: # optional: local start, with doubles for the dependencies
|
|
72
72
|
# e2e: # optional: end-to-end scenarios; evidence written to .evidence/
|
|
73
73
|
|
|
74
|
-
# Review budget. Inherited from the project when absent (docs/os/05-workflow.md §4).
|
|
75
|
-
review_budget:
|
|
76
|
-
max_lines: 400
|
|
77
|
-
max_files: 15
|
|
78
|
-
max_modules: 1 # not adjustable
|
|
79
|
-
|
|
80
74
|
docs:
|
|
81
75
|
readme: README.md
|
|
82
76
|
agents: AGENTS.md
|
|
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
|