napkinstack 0.4.0__tar.gz → 0.5.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.5.0}/PKG-INFO +25 -6
- {napkinstack-0.4.0 → napkinstack-0.5.0}/README.md +24 -5
- {napkinstack-0.4.0 → napkinstack-0.5.0}/pyproject.toml +1 -1
- {napkinstack-0.4.0 → napkinstack-0.5.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/cli.py +7 -2
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/compat.py +23 -9
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/doctor.py +38 -2
- napkinstack-0.5.0/src/napkinstack/landed.py +122 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/modules.py +8 -3
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/provenance.py +10 -2
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/pull_request.py +11 -3
- {napkinstack-0.4.0 → napkinstack-0.5.0}/LICENSE +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/manifests.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/project.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.4.0 → napkinstack-0.5.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.5.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
|
|
@@ -24,7 +24,11 @@ Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
|
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. A first pilot project, private, starts from
|
|
29
|
+
> v0.5.0, which closes a way around the contract check found while proving it (D44) and says
|
|
30
|
+
> what holds on a repository no plan lets the forge guard (PDR-0006).
|
|
31
|
+
> Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
28
32
|
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
29
33
|
> [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
|
|
30
34
|
> [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
|
|
@@ -78,10 +82,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
|
|
|
78
82
|
nstack init my-project
|
|
79
83
|
```
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
To try it without installing anything, or to install it from this repository instead of the
|
|
86
|
+
registry — always pinned to a release tag:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.4.0" nstack init my-project
|
|
90
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.4.0"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
94
|
+
`vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
|
|
95
|
+
repository. The forge distributes that same tag's source, which is a supported way in and
|
|
96
|
+
never a published one — a tag can be moved, and carries no attestation — so a run installed
|
|
97
|
+
that way says where its rules came from, on every line that judges
|
|
98
|
+
([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
|
|
99
|
+
[PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
|
|
100
|
+
unaffected: created from either channel at the same tag, it records the same version and its
|
|
101
|
+
CI installs from the registry.
|
|
102
|
+
|
|
103
|
+
Each project then pins its version and changes it through `nstack update`.
|
|
85
104
|
|
|
86
105
|
## The commands
|
|
87
106
|
|
|
@@ -9,7 +9,11 @@ Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
|
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. A first pilot project, private, starts from
|
|
14
|
+
> v0.5.0, which closes a way around the contract check found while proving it (D44) and says
|
|
15
|
+
> what holds on a repository no plan lets the forge guard (PDR-0006).
|
|
16
|
+
> Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
13
17
|
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
14
18
|
> [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
|
|
15
19
|
> [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
|
|
@@ -63,10 +67,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
|
|
|
63
67
|
nstack init my-project
|
|
64
68
|
```
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
To try it without installing anything, or to install it from this repository instead of the
|
|
71
|
+
registry — always pinned to a release tag:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.4.0" nstack init my-project
|
|
75
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.4.0"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
79
|
+
`vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
|
|
80
|
+
repository. The forge distributes that same tag's source, which is a supported way in and
|
|
81
|
+
never a published one — a tag can be moved, and carries no attestation — so a run installed
|
|
82
|
+
that way says where its rules came from, on every line that judges
|
|
83
|
+
([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
|
|
84
|
+
[PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
|
|
85
|
+
unaffected: created from either channel at the same tag, it records the same version and its
|
|
86
|
+
CI installs from the registry.
|
|
87
|
+
|
|
88
|
+
Each project then pins its version and changes it through `nstack update`.
|
|
70
89
|
|
|
71
90
|
## The commands
|
|
72
91
|
|
|
@@ -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
|
|
|
@@ -105,6 +106,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
105
106
|
lambda a: pull_request.run(a.root, a.base, a.body_file))
|
|
106
107
|
pc.add_argument("--base", default="origin/main")
|
|
107
108
|
pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
|
|
109
|
+
ld = _add(sub, "landed", "what reached this branch outside a pull request, recorded (W1)",
|
|
110
|
+
lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
|
|
111
|
+
ld.add_argument("--span", default="HEAD~1..HEAD",
|
|
112
|
+
help="the commits to read, BEFORE..AFTER (default: the last commit)")
|
|
108
113
|
ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent (PDR-0002)",
|
|
109
114
|
lambda a: discovery.run(a.root, a.idea))
|
|
110
115
|
ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
|
|
@@ -124,7 +129,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
124
129
|
|
|
125
130
|
|
|
126
131
|
JUDGES = {"manifests", "boundaries", "plan", "skills", "hygiene", "fitness", "doctor", "pr-scope",
|
|
127
|
-
"pr-check", "compat"} # the commands whose verdict depends on the framework's rules
|
|
132
|
+
"pr-check", "compat", "landed"} # the commands whose verdict depends on the framework's rules
|
|
128
133
|
|
|
129
134
|
|
|
130
135
|
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.")
|
|
@@ -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")
|
|
@@ -118,10 +118,15 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
|
|
|
118
118
|
why = "user-facing" if user_facing else f"criticality {criticality}"
|
|
119
119
|
steps.append(f"Every pull request that changes its behaviour carries a test sheet ({why}), "
|
|
120
120
|
"run by a verifier who is not its author (T1–T5, docs/os/05-workflow.md §7)")
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
|
|
121
|
+
framed = plan.charter_accepted(root)
|
|
122
|
+
cycle = plan.accepted_cycle(root) if framed else None
|
|
123
|
+
if not framed:
|
|
124
|
+
steps.append("The project is not framed: delivery work needs an accepted charter (K1), "
|
|
124
125
|
"or the out-of-cycle label with its justification (K4)")
|
|
126
|
+
elif cycle is None:
|
|
127
|
+
# The charter is accepted: only the next cycle is missing (D47).
|
|
128
|
+
steps.append("No accepted cycle: delivery work needs one (K1), or the out-of-cycle label "
|
|
129
|
+
"with its justification (K4)")
|
|
125
130
|
else:
|
|
126
131
|
steps.append(f"Delivery work names a ready deliverable of {cycle[0].name} (K3), "
|
|
127
132
|
"or carries the out-of-cycle label with its justification (K4)")
|
|
@@ -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:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|