napkinstack 0.9.6__tar.gz → 0.11.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.9.6 → napkinstack-0.11.0}/PKG-INFO +6 -5
- {napkinstack-0.9.6 → napkinstack-0.11.0}/README.md +5 -4
- {napkinstack-0.9.6 → napkinstack-0.11.0}/pyproject.toml +1 -1
- {napkinstack-0.9.6 → napkinstack-0.11.0}/pyproject.toml.orig +1 -1
- napkinstack-0.11.0/src/napkinstack/attest.py +207 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/cli.py +27 -6
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/doctor.py +5 -1
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/manifests.py +3 -1
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/project.py +5 -2
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/pull_request.py +70 -1
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/settings.py +10 -2
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/AGENTS.md +3 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/LICENSE +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/modules.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.9.6 → napkinstack-0.11.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.11.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,7 +20,7 @@ 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.11.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
24
24
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
25
25
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
26
26
|
|
|
@@ -84,8 +84,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
84
84
|
registry — always pinned to a release tag:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
88
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
87
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.11.0" nstack init my-project
|
|
88
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.11.0"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -120,8 +120,9 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
120
120
|
| `nstack release [module] [args]` | Makes each module's artefacts into `dist/<module>/`, each after what it assembles; what follows a named module goes to its command |
|
|
121
121
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
122
122
|
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
123
|
+
| `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks` |
|
|
123
124
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
124
|
-
| `nstack update` | Lays the new version on a branch to review |
|
|
125
|
+
| `nstack update [--session <id>]` | Lays the new version on a branch to review; an agent's session as the commit's `Agent-Session` trailer |
|
|
125
126
|
|
|
126
127
|
**Prerequisites**: uv and git. The guardrails really block on a public GitHub repository,
|
|
127
128
|
or on a private one under the Team or Pro plan; on a private repository on the Free plan
|
|
@@ -5,7 +5,7 @@ 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.11.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
9
9
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
10
10
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
11
11
|
|
|
@@ -69,8 +69,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
69
69
|
registry — always pinned to a release tag:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
73
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
72
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.11.0" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.11.0"
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -105,8 +105,9 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
105
105
|
| `nstack release [module] [args]` | Makes each module's artefacts into `dist/<module>/`, each after what it assembles; what follows a named module goes to its command |
|
|
106
106
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
107
107
|
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
108
|
+
| `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks` |
|
|
108
109
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
109
|
-
| `nstack update` | Lays the new version on a branch to review |
|
|
110
|
+
| `nstack update [--session <id>]` | Lays the new version on a branch to review; an agent's session as the commit's `Agent-Session` trailer |
|
|
110
111
|
|
|
111
112
|
**Prerequisites**: uv and git. The guardrails really block on a public GitHub repository,
|
|
112
113
|
or on a private one under the Team or Pro plan; on a private repository on the Free plan
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checks run where the team works, recorded in git (PDR-0013, ADR-0006).
|
|
3
|
+
|
|
4
|
+
When the forge cannot run a pull request's checks, the team runs them on a workstation: this
|
|
5
|
+
command runs every check the project's CI runs — from one list, bound to the workflows by a
|
|
6
|
+
test — on a clean tree at a commit, and appends the run to that commit's note under
|
|
7
|
+
`refs/notes/nstack/checks`, one JSON line per run. The note travels with the branch's remote
|
|
8
|
+
and is merged line by line; the latest line for a commit is its record. On GitHub, with a token
|
|
9
|
+
that may write statuses, the run is also shown as `nstack / local checks` — a name no CI job
|
|
10
|
+
has. A record is *attested*, never *checked*: it makes a claim traceable, not true.
|
|
11
|
+
|
|
12
|
+
Usage : nstack attest [--root ROOT] [--base BASE] [--body-file FILE] [--labels a,b]
|
|
13
|
+
Output: 0 when every check passed, 1 otherwise — the record is written either way.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import urllib.request
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from napkinstack import provenance
|
|
30
|
+
|
|
31
|
+
NOTES = "nstack/checks"
|
|
32
|
+
RECORD = "attested-checks/v1"
|
|
33
|
+
CONTEXT = "nstack / local checks"
|
|
34
|
+
KEPT = 64 * 1024 # bytes of a step's output kept in the record; the whole is hashed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Step:
|
|
39
|
+
signature: str # what the job's own `run` holds: the test binding the list to the workflows reads it
|
|
40
|
+
label: str
|
|
41
|
+
command: str # bash, at the root; NSTACK_BASE, NSTACK_BODY, NSTACK_TMP and PR_LABELS are set
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Check:
|
|
46
|
+
name: str # the CI job's name
|
|
47
|
+
steps: tuple[Step, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The project's CI, job by job, as a workstation runs it (skeleton/.github/workflows/). A job or a
|
|
51
|
+
# step in one place and not the other fails platform/tests/test_attest.py.
|
|
52
|
+
CHECKS: tuple[Check, ...] = (
|
|
53
|
+
Check("Fitness functions", (
|
|
54
|
+
Step("nstack fitness", "Manifests, boundaries, skills, plan, hygiene", "nstack fitness --root ."),)),
|
|
55
|
+
Check("PR scope and review budget", (
|
|
56
|
+
Step("nstack pr-scope", "One PR = one module", 'nstack pr-scope --root . --base "$NSTACK_BASE"'),)),
|
|
57
|
+
Check("Hooks and secrets", (
|
|
58
|
+
Step("pre-commit run --all-files", "Hooks on every file",
|
|
59
|
+
"SKIP=gitleaks pre-commit run --all-files --show-diff-on-failure"),
|
|
60
|
+
Step("pre-commit run gitleaks-history", "Secrets in the whole git history",
|
|
61
|
+
"pre-commit run gitleaks-history --hook-stage manual --all-files"),
|
|
62
|
+
Step("pre-commit run no-session-link", "No private session link in a message",
|
|
63
|
+
'{ cat "$NSTACK_BODY"; git log --format=%B "$NSTACK_BASE..HEAD"; } > "$NSTACK_TMP/said.txt" '
|
|
64
|
+
'&& pre-commit run no-session-link --hook-stage commit-msg --commit-msg-filename "$NSTACK_TMP/said.txt"'),
|
|
65
|
+
)),
|
|
66
|
+
Check("Test sheet and cycle", (
|
|
67
|
+
Step("nstack pr-check", "Test sheet and cycle",
|
|
68
|
+
'nstack pr-check --root . --base "$NSTACK_BASE" --body-file "$NSTACK_BODY"'),)),
|
|
69
|
+
Check("Module checks", (
|
|
70
|
+
Step("bash .nstack/check-modules.sh", "Every changed module",
|
|
71
|
+
'BASE="$NSTACK_BASE" bash .nstack/check-modules.sh'),)),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
76
|
+
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=check)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def capped(text: str) -> str:
|
|
80
|
+
"""A step's output as the record keeps it: whole up to KEPT bytes, else its head and its tail."""
|
|
81
|
+
data = text.encode("utf-8", "replace")
|
|
82
|
+
if len(data) <= KEPT:
|
|
83
|
+
return text
|
|
84
|
+
half = KEPT // 2
|
|
85
|
+
cut = len(data) - KEPT
|
|
86
|
+
return (data[:half].decode("utf-8", "ignore") + f"\n[… {cut} bytes cut …]\n"
|
|
87
|
+
+ data[-half:].decode("utf-8", "ignore"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def latest(root: Path, commit: str) -> dict | None:
|
|
91
|
+
"""The record of a commit: the latest run its note holds, or None."""
|
|
92
|
+
shown = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
|
|
93
|
+
if shown.returncode != 0:
|
|
94
|
+
return None
|
|
95
|
+
runs = []
|
|
96
|
+
for line in shown.stdout.splitlines():
|
|
97
|
+
try:
|
|
98
|
+
run = json.loads(line)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
continue
|
|
101
|
+
if isinstance(run, dict) and run.get("record") == RECORD and run.get("commit") == commit:
|
|
102
|
+
runs.append(run)
|
|
103
|
+
return max(runs, key=lambda run: str(run.get("at", "")), default=None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _remote(root: Path) -> str | None:
|
|
107
|
+
"""The branch's remote, else `origin`, else none."""
|
|
108
|
+
branch = _git(root, "symbolic-ref", "--quiet", "--short", "HEAD", check=False).stdout.strip()
|
|
109
|
+
if branch:
|
|
110
|
+
named = _git(root, "config", f"branch.{branch}.remote", check=False).stdout.strip()
|
|
111
|
+
if named:
|
|
112
|
+
return named
|
|
113
|
+
remotes = _git(root, "remote", check=False).stdout.split()
|
|
114
|
+
return "origin" if "origin" in remotes else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def fetch(root: Path) -> str | None:
|
|
118
|
+
"""Brings the remote's notes in, merged line by line with ours; returns the remote's name."""
|
|
119
|
+
remote = _remote(root)
|
|
120
|
+
if remote is None:
|
|
121
|
+
return None
|
|
122
|
+
theirs = f"refs/notes/nstack-remote/{NOTES.split('/')[-1]}"
|
|
123
|
+
got = _git(root, "fetch", "--quiet", remote, f"+refs/notes/{NOTES}:{theirs}", check=False)
|
|
124
|
+
if got.returncode == 0:
|
|
125
|
+
_git(root, "notes", f"--ref={NOTES}", "merge", "--quiet", "--strategy=cat_sort_uniq", theirs, check=False)
|
|
126
|
+
return remote
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _session(root: Path, commit: str) -> str | None:
|
|
130
|
+
trailers = _git(root, "log", "-1", "--format=%(trailers:key=Agent-Session,valueonly)", commit).stdout
|
|
131
|
+
return trailers.strip().splitlines()[0] if trailers.strip() else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _show(root: Path, commit: str, passed: int, total: int) -> str:
|
|
135
|
+
"""The forge's display, when there is one and a token: never a judgement, a view of the record."""
|
|
136
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
137
|
+
url = _git(root, "remote", "get-url", _remote(root) or "origin", check=False).stdout.strip()
|
|
138
|
+
from napkinstack.doctor import ON_GITHUB # the doctor's reading of a GitHub remote
|
|
139
|
+
|
|
140
|
+
found = ON_GITHUB.search(url)
|
|
141
|
+
if not token or not found:
|
|
142
|
+
return "not shown on a forge (no GitHub remote, or no token)"
|
|
143
|
+
api = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
|
|
144
|
+
body = json.dumps({"state": "success" if passed == total else "failure", "context": CONTEXT,
|
|
145
|
+
"description": f"attested: {passed} of {total}"}).encode()
|
|
146
|
+
request = urllib.request.Request(f"{api}/repos/{found.group(1)}/statuses/{commit}", data=body, method="POST",
|
|
147
|
+
headers={"Accept": "application/vnd.github+json",
|
|
148
|
+
"Authorization": f"Bearer {token}"})
|
|
149
|
+
try:
|
|
150
|
+
with urllib.request.urlopen(request, timeout=10):
|
|
151
|
+
return f"shown on GitHub as '{CONTEXT}'"
|
|
152
|
+
except (OSError, ValueError) as error:
|
|
153
|
+
return f"not shown on GitHub ({error})"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run(root: Path, base: str, body_file: Path | None, labels: str) -> int:
|
|
157
|
+
print(provenance.judged_by(root), flush=True)
|
|
158
|
+
if _git(root, "status", "--porcelain", "--untracked-files=normal").stdout.strip():
|
|
159
|
+
print("FAIL [attest] the tree holds changes: a record names one commit, and this tree is none.\n"
|
|
160
|
+
" Action: commit or stash them, then run nstack attest again.")
|
|
161
|
+
return 1
|
|
162
|
+
commit = _git(root, "rev-parse", "HEAD").stdout.strip()
|
|
163
|
+
engine, origin = provenance.engine()
|
|
164
|
+
env = dict(os.environ, PR_LABELS=labels)
|
|
165
|
+
# The engine that runs this command runs the checks: its folder first on the path.
|
|
166
|
+
env["PATH"] = os.pathsep.join([str(Path(sys.argv[0]).resolve().parent), env.get("PATH", "")])
|
|
167
|
+
results = []
|
|
168
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
169
|
+
body = body_file or Path(tmp, "body.md")
|
|
170
|
+
if body_file is None:
|
|
171
|
+
body.write_text("", encoding="utf-8")
|
|
172
|
+
# NSTACK_ATTESTING: pr-check, run as one of the checks, judges the pull request's rules
|
|
173
|
+
# without asking for the record this run is making (A1, A2) — found rehearsing v0.10.0.
|
|
174
|
+
env.update(NSTACK_BASE=base, NSTACK_BODY=str(Path(body).resolve()), NSTACK_TMP=tmp,
|
|
175
|
+
NSTACK_ATTESTING=commit)
|
|
176
|
+
for check in CHECKS:
|
|
177
|
+
steps = []
|
|
178
|
+
for step in check.steps:
|
|
179
|
+
done = subprocess.run(["bash", "-c", step.command], cwd=root, env=env,
|
|
180
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
181
|
+
steps.append({"label": step.label, "command": step.command, "exit": done.returncode,
|
|
182
|
+
"output": capped(done.stdout),
|
|
183
|
+
"sha256": hashlib.sha256(done.stdout.encode("utf-8", "replace")).hexdigest()})
|
|
184
|
+
passed = all(step["exit"] == 0 for step in steps)
|
|
185
|
+
results.append({"name": check.name, "passed": passed, "steps": steps})
|
|
186
|
+
failed = "; ".join(f"{s['label']} exited {s['exit']}" for s in steps if s["exit"])
|
|
187
|
+
print(f"FAIL [attest] {check.name}: {failed}" if failed else f"ok [attest] {check.name}", flush=True)
|
|
188
|
+
# Who ran it, as git would sign a commit made here — its environment and its configuration.
|
|
189
|
+
ident = _git(root, "var", "GIT_COMMITTER_IDENT", check=False).stdout.strip()
|
|
190
|
+
who = ident.rsplit(" ", 2)[0] if ident.count(" ") >= 2 else ident
|
|
191
|
+
record = {"record": RECORD, "commit": commit,
|
|
192
|
+
"at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="microseconds"),
|
|
193
|
+
"engine": engine, "published": origin is None, "by": who, "session": _session(root, commit),
|
|
194
|
+
"base": base, "checks": results}
|
|
195
|
+
remote = fetch(root)
|
|
196
|
+
before = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
|
|
197
|
+
lines = (before.stdout.rstrip("\n") + "\n" if before.returncode == 0 and before.stdout.strip() else "")
|
|
198
|
+
lines += json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n"
|
|
199
|
+
subprocess.run(["git", "notes", f"--ref={NOTES}", "add", "--force", "--file=-", commit], cwd=root,
|
|
200
|
+
input=lines, text=True, capture_output=True, check=True)
|
|
201
|
+
passed = sum(1 for result in results if result["passed"])
|
|
202
|
+
where = "local only: no remote"
|
|
203
|
+
if remote is not None:
|
|
204
|
+
pushed = _git(root, "push", "--quiet", remote, f"refs/notes/{NOTES}", check=False)
|
|
205
|
+
where = f"pushed to {remote}" if pushed.returncode == 0 else f"NOT pushed to {remote}: {pushed.stderr.strip()}"
|
|
206
|
+
print(f"Record of {commit[:12]}: attested: {passed} of {len(results)}; {where}; {_show(root, commit, passed, len(results))}.")
|
|
207
|
+
return 0 if passed == len(results) else 1
|
|
@@ -8,10 +8,19 @@ import os
|
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from napkinstack import assurance
|
|
11
|
+
from napkinstack import assurance, attest
|
|
12
12
|
from napkinstack import (compat, discovery, doctor, landed, modules, provenance, settings,
|
|
13
13
|
pull_request, skills)
|
|
14
14
|
from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
|
|
15
|
+
from napkinstack.project import ANSWERS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _here() -> Path:
|
|
19
|
+
"""The project the current folder belongs to: the nearest folder, this one or above, holding
|
|
20
|
+
the project's answers — as git and cargo find their root (D130). Outside any project, the
|
|
21
|
+
current folder, as before."""
|
|
22
|
+
here = Path.cwd()
|
|
23
|
+
return next((folder for folder in (here, *here.parents) if (folder / ANSWERS).is_file()), here)
|
|
15
24
|
|
|
16
25
|
|
|
17
26
|
def _root(value: str) -> Path:
|
|
@@ -39,8 +48,9 @@ def _fitness(root: Path) -> int:
|
|
|
39
48
|
|
|
40
49
|
def _add(sub, name: str, help_: str, func) -> argparse.ArgumentParser:
|
|
41
50
|
parser = sub.add_parser(name, help=help_)
|
|
42
|
-
parser.add_argument("--root", type=_root, default=
|
|
43
|
-
help="project root (default:
|
|
51
|
+
parser.add_argument("--root", type=_root, default=_here(),
|
|
52
|
+
help="project root (default: the nearest folder holding .copier-answers.yml, "
|
|
53
|
+
"this one or above; else the current folder)")
|
|
44
54
|
parser.set_defaults(func=func)
|
|
45
55
|
return parser
|
|
46
56
|
|
|
@@ -57,7 +67,7 @@ def _init(args: argparse.Namespace) -> int:
|
|
|
57
67
|
def _update(args: argparse.Namespace) -> int:
|
|
58
68
|
from napkinstack import project
|
|
59
69
|
|
|
60
|
-
return project.update(args.root, args.ref or project.default_ref())
|
|
70
|
+
return project.update(args.root, args.ref or project.default_ref(), args.session)
|
|
61
71
|
|
|
62
72
|
|
|
63
73
|
class _Version(argparse.Action):
|
|
@@ -146,6 +156,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
146
156
|
lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
|
|
147
157
|
ld.add_argument("--span", default="HEAD~1..HEAD",
|
|
148
158
|
help="the commits to read, BEFORE..AFTER (default: the last commit)")
|
|
159
|
+
at = _add(sub, "attest", "runs the CI's checks here and records the run on the commit, in git",
|
|
160
|
+
lambda a: attest.run(a.root, a.base, a.body_file, a.labels))
|
|
161
|
+
at.add_argument("--base", default="origin/main")
|
|
162
|
+
at.add_argument("--body-file", type=Path, help="the pull request's description, for its checks")
|
|
163
|
+
at.add_argument("--labels", default="", help="the pull request's labels, comma-separated")
|
|
149
164
|
ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent",
|
|
150
165
|
lambda a: discovery.run(a.root, a.idea))
|
|
151
166
|
ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
|
|
@@ -161,6 +176,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
161
176
|
up = _add(sub, "update", "merges a NapkinStack version onto a branch to review",
|
|
162
177
|
_update)
|
|
163
178
|
up.add_argument("--ref", help="target version: a tag vX.Y.Z, or a commit (default: the one of nstack)")
|
|
179
|
+
up.add_argument("--session", help="the agent's session, written as the commit's Agent-Session trailer")
|
|
164
180
|
return parser
|
|
165
181
|
|
|
166
182
|
|
|
@@ -176,7 +192,7 @@ def _named_root(argv: list[str]) -> Path:
|
|
|
176
192
|
return Path(argv[index + 1])
|
|
177
193
|
if word.startswith("--root="):
|
|
178
194
|
return Path(word.removeprefix("--root="))
|
|
179
|
-
return
|
|
195
|
+
return _here()
|
|
180
196
|
|
|
181
197
|
|
|
182
198
|
def _release(a) -> int:
|
|
@@ -215,4 +231,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
215
231
|
print(provenance.judged_by(args.root), flush=True)
|
|
216
232
|
if said := plan.stage_line(args.root):
|
|
217
233
|
print(said, flush=True)
|
|
218
|
-
|
|
234
|
+
code = args.func(args)
|
|
235
|
+
if code and args.command in JUDGES | {"attest"}:
|
|
236
|
+
# Where a person reads what the codes and words above mean (D131).
|
|
237
|
+
print("A rule's code is explained in .nstack/handbook/RULES.md; a word of the framework in "
|
|
238
|
+
".nstack/handbook/glossary.md.", flush=True)
|
|
239
|
+
return code
|
|
@@ -69,7 +69,11 @@ API_VERSION = "2026-03-10"
|
|
|
69
69
|
JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
|
|
70
70
|
"Module checks")
|
|
71
71
|
THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skeleton workflows
|
|
72
|
-
|
|
72
|
+
# The pull requests' three exceptions, then what the shipped issue templates put on an issue: a
|
|
73
|
+
# label that does not exist is dropped by GitHub without a word (D128).
|
|
74
|
+
EXCEPTIONS = ("cross-module", "over-budget", "out-of-cycle")
|
|
75
|
+
ISSUES = ("request", "bug", "architecture", "debt", "acceptance")
|
|
76
|
+
LABELS = EXCEPTIONS + ISSUES
|
|
73
77
|
PUBLISHED = provenance.PUBLISHED # a published version is a vX.Y.Z tag (ADR-0002), as CI installs it
|
|
74
78
|
REMOTE = re.compile(r"https://|ssh://|git@[^:/]+:|gh:|gl:") # as .nstack/install-engine.sh reads them
|
|
75
79
|
ON_GITHUB = re.compile(r"(?:https://github\.com/|ssh://git@github\.com/|git@github\.com:|gh:)([^/]+/[^/]+?)(?:\.git)?/?$")
|
|
@@ -128,7 +128,9 @@ def is_description(path: str) -> bool:
|
|
|
128
128
|
(D24); a module holding nothing else has nothing to check yet (D33)."""
|
|
129
129
|
# A page at any depth describes; it runs nothing. A contract's own page —
|
|
130
130
|
# contracts/<name>/v1/README.md — is the case that asked: the module that holds it must
|
|
131
|
-
# stay green until the contract gains a schema or a test (PDR-0012).
|
|
131
|
+
# stay green until the contract gains a schema or a test (PDR-0012). Once a module consumes
|
|
132
|
+
# the version, or it holds a schema, its page is the contract, and a pull request changing it
|
|
133
|
+
# changes its module (pull_request.relied_on_pages, D133).
|
|
132
134
|
return (path in DESCRIPTION or path.startswith("docs/")
|
|
133
135
|
or path.rsplit("/", 1)[-1] in {".gitkeep", "README.md"})
|
|
134
136
|
|
|
@@ -140,6 +140,7 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
|
|
|
140
140
|
f"folder inside it — nstack init {destination}/<project>.")
|
|
141
141
|
return 1
|
|
142
142
|
data = {question: value for question, value in answers.items() if value is not None}
|
|
143
|
+
data.setdefault("checks", "forge") # a project starts judged on the forge; the decider may change it
|
|
143
144
|
try:
|
|
144
145
|
copier.run_copy(source, destination, data=data, vcs_ref=ref, quiet=True, unsafe=False)
|
|
145
146
|
except (CopierError, ValueError, OSError) as exc:
|
|
@@ -325,7 +326,7 @@ def _readdress(root: Path) -> tuple[list[str], list[str]]:
|
|
|
325
326
|
return rewritten, left
|
|
326
327
|
|
|
327
328
|
|
|
328
|
-
def update(root: Path, ref: str) -> int:
|
|
329
|
+
def update(root: Path, ref: str, session: str | None = None) -> int:
|
|
329
330
|
import copier
|
|
330
331
|
from copier.errors import CopierError
|
|
331
332
|
|
|
@@ -388,7 +389,9 @@ def update(root: Path, ref: str) -> int:
|
|
|
388
389
|
"then git add --all && git commit.\n The check-merge-conflict hook and CI "
|
|
389
390
|
"reject any remaining marker.")
|
|
390
391
|
return 1
|
|
391
|
-
|
|
392
|
+
# An agent's update carries the kernel's trailer, as every commit it makes (D135).
|
|
393
|
+
message = f"NapkinStack {previous} -> {current}" + (f"\n\nAgent-Session: {session}" if session else "")
|
|
394
|
+
for args in (("add", "--all"), ("commit", "--quiet", "--message", message)):
|
|
392
395
|
result = _git(root, *args)
|
|
393
396
|
if result.returncode:
|
|
394
397
|
print(f"FAIL [update] Update laid on {branch}, but `git {args[0]}` failed:\n"
|
|
@@ -51,6 +51,7 @@ from napkinstack.fitness import plan
|
|
|
51
51
|
from napkinstack import assurance
|
|
52
52
|
from napkinstack.fitness.manifests import MODULE_DIRS, changed_files, find_manifests, is_description
|
|
53
53
|
from napkinstack.modules import LOGIN
|
|
54
|
+
from napkinstack.project import ANSWERS
|
|
54
55
|
|
|
55
56
|
CHARTER = plan.CHARTER.as_posix() # docs/project/charter.md, as the diff names it
|
|
56
57
|
|
|
@@ -103,14 +104,40 @@ def touched_modules(root: Path, base: str, files: list[str]) -> dict[str, list[d
|
|
|
103
104
|
parts = path.split("/")
|
|
104
105
|
if parts[-1] == "MANIFEST.yaml" and parts[0] in MODULE_DIRS and len(parts) in (2, 3):
|
|
105
106
|
manifests.setdefault(path, []).append(_manifest(_git(root, "show", f"{base}:{path}").stdout))
|
|
107
|
+
relied = relied_on_pages(root, manifests)
|
|
106
108
|
touched = {}
|
|
107
109
|
for path, found in sorted(manifests.items()):
|
|
108
110
|
folder = path.removesuffix("/MANIFEST.yaml")
|
|
109
|
-
if any(file.startswith(f"{folder}/") and not is_description(file[len(folder) + 1:])
|
|
111
|
+
if any(file.startswith(f"{folder}/") and (not is_description(file[len(folder) + 1:]) or file in relied)
|
|
112
|
+
for file in files):
|
|
110
113
|
touched[folder] = found
|
|
111
114
|
return touched
|
|
112
115
|
|
|
113
116
|
|
|
117
|
+
CONTRACT_PAGE = re.compile(r"(?P<path>.+/(?P<contract>[^/]+)/(?P<version>v\d+))/README\.md")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def relied_on_pages(root: Path, manifests: dict[str, list[dict]]) -> set[str]:
|
|
121
|
+
"""The pages of the contract versions someone relies on — a module consumes the version, or
|
|
122
|
+
it holds a schema — relative to the root (D133). Such a page is the contract: its prose is
|
|
123
|
+
held by review and by nothing else (03-contracts.md), so changing it changes its module. A
|
|
124
|
+
version nobody relies on yet keeps PDR-0012's relief: its page is a description."""
|
|
125
|
+
consumed = {(str(entry.get("contract")), str(entry.get("version")))
|
|
126
|
+
for found in manifests.values() for data in found[:1]
|
|
127
|
+
for entry in (data.get("consumes") or []) if isinstance(entry, dict)}
|
|
128
|
+
pages = set()
|
|
129
|
+
for data in (found[0] for found in manifests.values() if found):
|
|
130
|
+
for entry in data.get("provides") or []:
|
|
131
|
+
if not isinstance(entry, dict) or not entry.get("path"):
|
|
132
|
+
continue
|
|
133
|
+
page = f"{str(entry['path']).rstrip('/')}/README.md"
|
|
134
|
+
match = CONTRACT_PAGE.fullmatch(page)
|
|
135
|
+
if match and ((match["contract"], match["version"]) in consumed
|
|
136
|
+
or any((root / match["path"]).glob("*.schema.json"))):
|
|
137
|
+
pages.add(page)
|
|
138
|
+
return pages
|
|
139
|
+
|
|
140
|
+
|
|
114
141
|
def sheet_reason(folder: str, manifests: list[dict]) -> str | None:
|
|
115
142
|
"""Why a module requires a test sheet (T1), or None: the stricter of its manifests, so
|
|
116
143
|
that a pull request cannot lower its own requirement. What each criticality requires is read
|
|
@@ -388,6 +415,46 @@ def charter_without_pdr(root: Path, base: str, files: list[str]) -> list[str]:
|
|
|
388
415
|
"correction that decides nothing needs none (.nstack/playbooks/framing.md, Decisions)."]
|
|
389
416
|
|
|
390
417
|
|
|
418
|
+
def check_attested(root: Path, head: str, fail) -> str:
|
|
419
|
+
"""Where the checks are judged, as the decider's answer `checks` says (A1, A2): on the forge's
|
|
420
|
+
CI, or attested on a workstation by `nstack attest` — then a complete, passed record of the
|
|
421
|
+
head is required. Returns the line the summary prints."""
|
|
422
|
+
from napkinstack import attest # the record's reader, and the one list of checks
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
answers = yaml.safe_load((root / ANSWERS).read_text(encoding="utf-8")) or {}
|
|
426
|
+
except (OSError, yaml.YAMLError):
|
|
427
|
+
answers = {}
|
|
428
|
+
attest.fetch(root)
|
|
429
|
+
record = attest.latest(root, head)
|
|
430
|
+
if answers.get("checks", "forge") != "attested":
|
|
431
|
+
shown = ""
|
|
432
|
+
if record:
|
|
433
|
+
passed = sum(1 for check in record["checks"] if check["passed"])
|
|
434
|
+
shown = f"; a record of the head: attested {passed} of {len(record['checks'])} (information)"
|
|
435
|
+
return "the forge's CI" + shown
|
|
436
|
+
if os.environ.get("NSTACK_ATTESTING") == head:
|
|
437
|
+
return "attested — this run is the record being made"
|
|
438
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
439
|
+
# CI's checkout keeps no credential to fetch the notes: where the checks are attested, the
|
|
440
|
+
# record is read where the merge is decided, never failed here for want of a fetch.
|
|
441
|
+
return "attested — judged where nstack attest runs, not in CI"
|
|
442
|
+
if record is None:
|
|
443
|
+
fail("A1", f"no record of the head {head[:7]}: this project's checks are attested, and none ran on "
|
|
444
|
+
"this commit.\n Action: run nstack attest on the head, then push.")
|
|
445
|
+
return "attested — no record of the head"
|
|
446
|
+
names = [check["name"] for check in record["checks"]]
|
|
447
|
+
missing = [check.name for check in attest.CHECKS if check.name not in names]
|
|
448
|
+
failed = [check["name"] for check in record["checks"] if not check["passed"]]
|
|
449
|
+
if missing or failed:
|
|
450
|
+
said = "; ".join(part for part in (f"missing: {', '.join(missing)}" if missing else "",
|
|
451
|
+
f"failed: {', '.join(failed)}" if failed else "") if part)
|
|
452
|
+
fail("A2", f"the record of the head {head[:7]} is not complete and passed ({said}).\n"
|
|
453
|
+
" Action: fix what failed, commit, and run nstack attest again.")
|
|
454
|
+
return "attested — the record of the head does not pass"
|
|
455
|
+
return f"attested on a workstation, by {record['by']}, at {record['at']}"
|
|
456
|
+
|
|
457
|
+
|
|
391
458
|
def run(root: Path, base: str, body_file: Path | None = None) -> int:
|
|
392
459
|
if body_file is not None:
|
|
393
460
|
body = body_file.read_text(encoding="utf-8")
|
|
@@ -423,9 +490,11 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
|
|
|
423
490
|
lambda commit: bool(tree_of(commit)) and tree_of(commit) == tree_of(head))
|
|
424
491
|
if modules:
|
|
425
492
|
check_cycle(root, body, labels, datetime.date.today(), fail, warnings.append)
|
|
493
|
+
checks = check_attested(root, head, fail)
|
|
426
494
|
|
|
427
495
|
print(f"Modules touched : {len(modules)}" + "".join(f"\n - {folder}" for folder in modules))
|
|
428
496
|
print(f"Test sheet : {'required' if reasons else 'not required'}")
|
|
497
|
+
print(f"Checks : {checks}")
|
|
429
498
|
for line in summary(body, authorship) if reasons else []:
|
|
430
499
|
print(line)
|
|
431
500
|
for scenario in human_only:
|
|
@@ -39,10 +39,18 @@ from napkinstack.project import ANSWERS
|
|
|
39
39
|
RULESET_NAME = "nstack-checklist"
|
|
40
40
|
IN_RULESET = ("G1", "G2", "G3", "G4", "G13", "G17", "G18")
|
|
41
41
|
PULL_REQUEST = ("G1", "G2", "G3", "G13")
|
|
42
|
-
LABEL_COLOURS = {"cross-module": "d93f0b", "over-budget": "fbca04", "out-of-cycle": "5319e7"
|
|
42
|
+
LABEL_COLOURS = {"cross-module": "d93f0b", "over-budget": "fbca04", "out-of-cycle": "5319e7",
|
|
43
|
+
"request": "0e8a16", "bug": "d73a4a", "architecture": "1d76db", "debt": "c5def5",
|
|
44
|
+
"acceptance": "bfdadc"}
|
|
45
|
+
# An issue label means what its template says: the same words, the template being the source.
|
|
43
46
|
LABEL_MEANING = {"cross-module": "A pull request touching several modules, with its justification",
|
|
44
47
|
"over-budget": "A pull request beyond the review budget, with its justification",
|
|
45
|
-
"out-of-cycle": "Work outside the current cycle, with its justification"
|
|
48
|
+
"out-of-cycle": "Work outside the current cycle, with its justification",
|
|
49
|
+
"request": "Something asked for that no cycle plans yet",
|
|
50
|
+
"bug": "Incorrect behaviour in production or in test",
|
|
51
|
+
"architecture": "Boundary, contract, coupling, structuring decision",
|
|
52
|
+
"debt": "Identified debt, with its cost and its trigger",
|
|
53
|
+
"acceptance": "A deliverable tried by a person — what to run, what was seen"}
|
|
46
54
|
Call = tuple[str, str, str, dict | None] # (rules closed, method, path under the repository, body)
|
|
47
55
|
|
|
48
56
|
|
|
@@ -13,6 +13,9 @@ Defines and versions the interface contracts between modules.
|
|
|
13
13
|
- A version a module consumes, or marked stable, changes only with the merged `compat`
|
|
14
14
|
command's approval (V1).
|
|
15
15
|
- Contract tests run on **both sides**: producer and consumer.
|
|
16
|
+
- A version's page (`<name>/v<n>/README.md`) is the contract once a module consumes the
|
|
17
|
+
version or it holds a schema: a pull request changing it changes this module, and this
|
|
18
|
+
module's test sheet is due — its prose is held by review and by nothing else.
|
|
16
19
|
|
|
17
20
|
## The main trap
|
|
18
21
|
|
|
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
|
{napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/docs/runbook.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|