napkinstack 0.17.0__tar.gz → 0.18.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.17.0 → napkinstack-0.18.0}/PKG-INFO +4 -4
- {napkinstack-0.17.0 → napkinstack-0.18.0}/README.md +3 -3
- {napkinstack-0.17.0 → napkinstack-0.18.0}/pyproject.toml +1 -1
- {napkinstack-0.17.0 → napkinstack-0.18.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/attest.py +58 -5
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/cli.py +5 -3
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/evidence.py +50 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/fitness/plan.py +62 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/hold.py +20 -7
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/modules.py +14 -8
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/provenance.py +4 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/pull_request.py +8 -4
- {napkinstack-0.17.0 → napkinstack-0.18.0}/LICENSE +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/board.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/doctor.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/fitness/manifests.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/project.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/settings.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.17.0 → napkinstack-0.18.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.18.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.18.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.18.0" nstack init my-project
|
|
88
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.18.0"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -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.18.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.18.0" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.18.0"
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -46,6 +46,7 @@ class Step:
|
|
|
46
46
|
class Check:
|
|
47
47
|
name: str # the CI job's name
|
|
48
48
|
steps: tuple[Step, ...]
|
|
49
|
+
kept: bool = False # reads only the tree and the base: a pass on the same commit, base and engine stands (#275)
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
# The project's CI, job by job, as a workstation runs it (skeleton/.github/workflows/). A job or a
|
|
@@ -69,7 +70,7 @@ CHECKS: tuple[Check, ...] = (
|
|
|
69
70
|
'nstack pr-check --root . --base "$NSTACK_BASE" --body-file "$NSTACK_BODY"'),)),
|
|
70
71
|
Check("Module checks", (
|
|
71
72
|
Step("bash .nstack/check-modules.sh", "Every changed module",
|
|
72
|
-
'BASE="$NSTACK_BASE" bash .nstack/check-modules.sh'),)),
|
|
73
|
+
'BASE="$NSTACK_BASE" bash .nstack/check-modules.sh'),), kept=True),
|
|
73
74
|
)
|
|
74
75
|
|
|
75
76
|
|
|
@@ -88,6 +89,23 @@ def capped(text: str) -> str:
|
|
|
88
89
|
+ data[-half:].decode("utf-8", "ignore"))
|
|
89
90
|
|
|
90
91
|
|
|
92
|
+
def unplaced(value, root: Path):
|
|
93
|
+
"""What a record keeps, said nowhere on the machine that attested: the checkout's root as
|
|
94
|
+
`<root>`, the home folder as `~` (#277). The notes are pushed, and public with the repository;
|
|
95
|
+
a path names a person and their folders. Strings inside lists and mappings too."""
|
|
96
|
+
if isinstance(value, str):
|
|
97
|
+
for folder, said in ((root.resolve(), "<root>"), (root.absolute(), "<root>"),
|
|
98
|
+
(Path.home().resolve(), "~"), (Path.home(), "~")):
|
|
99
|
+
if str(folder) not in ("", "/"): # a whole folder only: never a neighbour, never inside a longer path
|
|
100
|
+
value = re.sub(r"(?<![\w.-])" + re.escape(str(folder)) + r"(?=$|[/\s'\"`)\]}>:,;])", said, value)
|
|
101
|
+
return value
|
|
102
|
+
if isinstance(value, list):
|
|
103
|
+
return [unplaced(item, root) for item in value]
|
|
104
|
+
if isinstance(value, dict):
|
|
105
|
+
return {key: unplaced(item, root) for key, item in value.items()}
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
91
109
|
def latest(root: Path, commit: str) -> dict | None:
|
|
92
110
|
"""The record of a commit: the latest run its note holds, or None."""
|
|
93
111
|
shown = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
|
|
@@ -207,7 +225,31 @@ def why(output: str) -> list[str]:
|
|
|
207
225
|
return said or [line for line in lines if line.strip()][-REASON:]
|
|
208
226
|
|
|
209
227
|
|
|
210
|
-
|
|
228
|
+
# An engine whose code a string names: published, a forge's commit, or a checkout's own commit.
|
|
229
|
+
NAMED = re.compile(r".* \(commit [0-9a-f]{7,}\)|.*@[0-9a-f]{7,}|a local checkout at (?!an unknown commit)\S+(?<!-dirty)")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _kept(previous: dict | None, check: Check, engine: str, origin: str | None, base_commit: str | None,
|
|
233
|
+
who: str, said: str | None = None) -> dict | None:
|
|
234
|
+
"""The check as the commit's record last passed it, when what it reads is the same: the commit,
|
|
235
|
+
the base commit, the engine — published, or one whose commit names its code — and the person who
|
|
236
|
+
ran it. A sheet filled in between changes the description, which this check never reads (#275).
|
|
237
|
+
Not compared: what the tree's ignored files, the environment or the tools outside it hold."""
|
|
238
|
+
said = origin if said is None else said # the origin as the record wrote it, its paths unplaced (D162)
|
|
239
|
+
if not check.kept or previous is None or base_commit is None or not (origin is None or NAMED.fullmatch(origin)):
|
|
240
|
+
return None
|
|
241
|
+
if (previous.get("engine"), previous.get("origin"), previous.get("published"), previous.get("base_commit"),
|
|
242
|
+
previous.get("by")) != (engine, said, origin is None, base_commit, who):
|
|
243
|
+
return None
|
|
244
|
+
checks = previous.get("checks") if isinstance(previous.get("checks"), list) else []
|
|
245
|
+
found = next((c for c in checks if isinstance(c, dict) and c.get("name") == check.name), None)
|
|
246
|
+
if not found or found.get("passed") is not True:
|
|
247
|
+
return None
|
|
248
|
+
return {**found, "kept_from": found.get("kept_from") or previous.get("at")}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def run(root: Path, base: str, body_file: Path | None, labels: str | None, accept_identity: bool = False,
|
|
252
|
+
again: bool = False) -> int:
|
|
211
253
|
print(provenance.judged_by(root), flush=True)
|
|
212
254
|
who, address = _identity(root)
|
|
213
255
|
remote = _remote(root)
|
|
@@ -233,6 +275,8 @@ def run(root: Path, base: str, body_file: Path | None, labels: str | None, accep
|
|
|
233
275
|
return 1
|
|
234
276
|
commit = _git(root, "rev-parse", "HEAD").stdout.strip()
|
|
235
277
|
engine, origin = provenance.engine()
|
|
278
|
+
base_commit = _git(root, "rev-parse", "--verify", "--quiet", f"{base}^{{commit}}", check=False).stdout.strip() or None
|
|
279
|
+
previous = None if again else latest(root, commit)
|
|
236
280
|
# The option, else PR_LABELS as the caller set it: one way for attest and pr-check (D147).
|
|
237
281
|
env = dict(os.environ, PR_LABELS=os.environ.get("PR_LABELS", "") if labels is None else labels)
|
|
238
282
|
# The engine that runs this command runs the checks: its folder first on the path.
|
|
@@ -247,6 +291,12 @@ def run(root: Path, base: str, body_file: Path | None, labels: str | None, accep
|
|
|
247
291
|
env.update(NSTACK_BASE=base, NSTACK_BODY=str(Path(body).resolve()), NSTACK_TMP=tmp,
|
|
248
292
|
NSTACK_ATTESTING=commit)
|
|
249
293
|
for check in CHECKS:
|
|
294
|
+
kept = _kept(previous, check, engine, origin, base_commit, who, unplaced(origin, root))
|
|
295
|
+
if kept is not None:
|
|
296
|
+
results.append(kept)
|
|
297
|
+
print(f"ok [attest] {check.name}: kept from the record of {kept['kept_from']} — same commit, base "
|
|
298
|
+
"and engine (nstack attest --again runs it)", flush=True)
|
|
299
|
+
continue
|
|
250
300
|
steps = []
|
|
251
301
|
for step in check.steps:
|
|
252
302
|
done = subprocess.run(["bash", "-c", step.command], cwd=root, env=env,
|
|
@@ -263,12 +313,15 @@ def run(root: Path, base: str, body_file: Path | None, labels: str | None, accep
|
|
|
263
313
|
print("\n".join(f" {line}" for line in why(step["output"])), flush=True)
|
|
264
314
|
record = {"record": RECORD, "commit": commit,
|
|
265
315
|
"at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="microseconds"),
|
|
266
|
-
"engine": engine, "published": origin is None, "by": who, "session": _session(root, commit),
|
|
267
|
-
"base": base, "
|
|
316
|
+
"engine": engine, "origin": origin, "published": origin is None, "by": who, "session": _session(root, commit),
|
|
317
|
+
"base": base, "base_commit": base_commit, "checks": results,
|
|
318
|
+
# The audits of the run the module checks come from: kept with them, never read again (#275).
|
|
319
|
+
"evidence": (previous.get("evidence") if isinstance(previous.get("evidence"), list) else [])
|
|
320
|
+
if previous and any(r.get("kept_from") for r in results) else evidence_of(root, commit)}
|
|
268
321
|
remote = fetch(root)
|
|
269
322
|
before = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
|
|
270
323
|
lines = (before.stdout.rstrip("\n") + "\n" if before.returncode == 0 and before.stdout.strip() else "")
|
|
271
|
-
lines += json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n"
|
|
324
|
+
lines += json.dumps(unplaced(record, root), separators=(",", ":"), ensure_ascii=False) + "\n"
|
|
272
325
|
subprocess.run(["git", "notes", f"--ref={NOTES}", "add", "--force", "--file=-", commit], cwd=root,
|
|
273
326
|
input=lines, text=True, capture_output=True, check=True)
|
|
274
327
|
passed = sum(1 for result in results if result["passed"])
|
|
@@ -100,8 +100,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
100
100
|
lambda a: manifests.run(a.root))
|
|
101
101
|
_add(sub, "boundaries", "declared graph against real graph, release artefacts included (B1-B9)",
|
|
102
102
|
lambda a: boundaries.run(a.root))
|
|
103
|
-
_add(sub, "plan", "the discovery, the charter and the cycles (C1-C8), the
|
|
104
|
-
"and who holds what", lambda a: plan.run(a.root) | plan.show(a.root) | hold.show(a.root))
|
|
103
|
+
_add(sub, "plan", "the discovery, the charter and the cycles (C1-C8), the decisions due at a closure "
|
|
104
|
+
"(C10), the project across its cycles, and who holds what", lambda a: plan.run(a.root) | plan.show(a.root) | hold.show(a.root))
|
|
105
105
|
sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
|
|
106
106
|
lambda a: skills.run(a.root, check_only=a.check))
|
|
107
107
|
sk.add_argument("--check", action="store_true", help="check without writing")
|
|
@@ -175,12 +175,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
175
175
|
ld.add_argument("--span", default="HEAD~1..HEAD",
|
|
176
176
|
help="the commits to read, BEFORE..AFTER (default: the last commit)")
|
|
177
177
|
at = _add(sub, "attest", "runs the CI's checks here and records the run on the commit, in git",
|
|
178
|
-
lambda a: attest.run(a.root, a.base, a.body_file, a.labels, a.accept_identity))
|
|
178
|
+
lambda a: attest.run(a.root, a.base, a.body_file, a.labels, a.accept_identity, a.again))
|
|
179
179
|
at.add_argument("--base", default="origin/main")
|
|
180
180
|
at.add_argument("--body-file", type=Path, help="the pull request's description, for its checks")
|
|
181
181
|
at.add_argument("--labels", help="the pull request's labels, comma-separated (default: PR_LABELS)")
|
|
182
182
|
at.add_argument("--accept-identity", action="store_true",
|
|
183
183
|
help="record under an identity the project's history does not show yet — a first record of yours")
|
|
184
|
+
at.add_argument("--again", action="store_true",
|
|
185
|
+
help="runs every check, even the module checks this commit's record passed with the same base and engine")
|
|
184
186
|
cl = _add(sub, "claim", "takes a ready deliverable for a session, before its first change; "
|
|
185
187
|
"the shared remote refuses a second taker",
|
|
186
188
|
lambda a: hold.claim(a.root, a.deliverable, a.session, a.person, a.take_over))
|
|
@@ -9,6 +9,8 @@ version that brings the audit only reports; the project's next minor version jud
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import contextlib
|
|
13
|
+
import datetime
|
|
12
14
|
import json
|
|
13
15
|
import os
|
|
14
16
|
import re
|
|
@@ -16,6 +18,7 @@ import select
|
|
|
16
18
|
import signal
|
|
17
19
|
import subprocess
|
|
18
20
|
import sys
|
|
21
|
+
import tempfile
|
|
19
22
|
import time
|
|
20
23
|
from pathlib import Path
|
|
21
24
|
|
|
@@ -262,6 +265,53 @@ def prepare(root: Path, module: Path) -> Path | str:
|
|
|
262
265
|
return folder
|
|
263
266
|
|
|
264
267
|
|
|
268
|
+
HELD = "NSTACK_E2E_HELD" # set for what the run starts: an e2e inside an e2e waits for nothing
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@contextlib.contextmanager
|
|
272
|
+
def one_at_a_time(root: Path, target: str):
|
|
273
|
+
"""One e2e at a time on this machine, for this user, whichever project or command starts it —
|
|
274
|
+
`nstack e2e`, or `nstack attest` through it: what an end-to-end run drives (a sound server, a
|
|
275
|
+
display, a port) is the machine's, and two runs reading it see each other's (#287). An advisory
|
|
276
|
+
lock, `flock(2)`, released by the system when the holder exits however it exits; the one that
|
|
277
|
+
waits says for whom."""
|
|
278
|
+
try:
|
|
279
|
+
import fcntl
|
|
280
|
+
except ImportError: # no flock on this system: the runs are not kept apart
|
|
281
|
+
yield
|
|
282
|
+
return
|
|
283
|
+
if os.environ.get(HELD):
|
|
284
|
+
yield
|
|
285
|
+
return
|
|
286
|
+
folder = os.environ.get("XDG_RUNTIME_DIR")
|
|
287
|
+
path = Path(folder) / "nstack-e2e.lock" if folder else Path(tempfile.gettempdir()) / f"nstack-e2e-{os.getuid()}.lock"
|
|
288
|
+
try: # never through a link: in a shared temporary folder, another user could have placed one
|
|
289
|
+
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
290
|
+
except OSError as error:
|
|
291
|
+
print(f"-> {target}: not kept apart from another end-to-end run: {path} cannot be opened ({error.strerror})",
|
|
292
|
+
flush=True)
|
|
293
|
+
yield
|
|
294
|
+
return
|
|
295
|
+
with open(descriptor, "r+", encoding="utf-8") as handle:
|
|
296
|
+
try:
|
|
297
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
298
|
+
except BlockingIOError:
|
|
299
|
+
handle.seek(0)
|
|
300
|
+
holder = handle.read().strip() or "another run"
|
|
301
|
+
print(f"-> {target}: waits for the end-to-end run already on this machine — {holder}", flush=True)
|
|
302
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
303
|
+
handle.seek(0)
|
|
304
|
+
handle.truncate()
|
|
305
|
+
started = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
306
|
+
handle.write(f"{target}, in {root.resolve()}, process {os.getpid()}, since {started}\n")
|
|
307
|
+
handle.flush()
|
|
308
|
+
try:
|
|
309
|
+
yield
|
|
310
|
+
finally:
|
|
311
|
+
handle.seek(0)
|
|
312
|
+
handle.truncate()
|
|
313
|
+
|
|
314
|
+
|
|
265
315
|
def run(command: str, cwd: Path, environment: dict, folder: Path) -> tuple[int, str | None]:
|
|
266
316
|
"""The command, its output shown live and kept as it comes, in bytes; the process waited for,
|
|
267
317
|
the pipe drained two seconds after. Returns its exit code, and what still held the pipe."""
|
|
@@ -19,6 +19,9 @@ Rules:
|
|
|
19
19
|
C8 a seat is written when its moment comes: README.md holds its sentence once the
|
|
20
20
|
discovery's decision is go, and none of its form's words once the charter is accepted;
|
|
21
21
|
modules/README.md none of its own once a module holds more than its description
|
|
22
|
+
C10 (a warning) a closure reads the decisions whose criterion is due: an accepted record of
|
|
23
|
+
docs/pdr/ or docs/adr/ whose success criterion names a closed or stopped cycle, or a date
|
|
24
|
+
its end has reached, says so in a reading line (PDR-0020)
|
|
22
25
|
|
|
23
26
|
Templates, whose file name starts with "_", are not checked. `nstack plan` then says the project
|
|
24
27
|
across its cycles (`project`), a display that never fails.
|
|
@@ -514,6 +517,63 @@ def _check_discovery(path: Path, fail) -> str | None:
|
|
|
514
517
|
return decision if isinstance(decision, str) else None
|
|
515
518
|
|
|
516
519
|
|
|
520
|
+
RECORDS = (Path("docs") / "pdr", Path("docs") / "adr")
|
|
521
|
+
CRITERION = re.compile(r"^## Success criterion[^\n]*\n(.*?)(?=^## |\Z)", re.S | re.M)
|
|
522
|
+
READING = re.compile(r"Read at `?(.+?)`?'s closure, (\d{4}-\d{2}-\d{2}): (?:met|missed|superseded)\b", re.I)
|
|
523
|
+
DAY = re.compile(r"(?<![\d-])\d{4}-\d{2}-\d{2}(?![\d-])")
|
|
524
|
+
NUMBERED = re.compile(r"\bcycle\s+0*(\d+)\b", re.I)
|
|
525
|
+
HEADER = re.compile(r"^[-*\s]*\**(Status|Date)\**\s*:\**\s*(\S+)", re.M | re.I)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _names(text: str, stem: str) -> bool:
|
|
529
|
+
"""Whether `text` names the cycle of file `stem`: by its file (`03-the-window`), or as `cycle 03`."""
|
|
530
|
+
number = re.match(r"\d+", stem)
|
|
531
|
+
return bool(re.search(rf"(?<![\w-]){re.escape(stem)}(?![\w-])", text)
|
|
532
|
+
or number and int(number[0]) in {int(n) for n in NUMBERED.findall(text)})
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _due(root: Path, read: list[tuple[Path, dict | None]]) -> list[str]:
|
|
536
|
+
"""C10: each accepted decision record whose success criterion is due at a closure and says no
|
|
537
|
+
reading of it (PDR-0020). Only the criterion section is read: a cycle named by its file or as
|
|
538
|
+
*cycle NN*, a date written YYYY-MM-DD. A named cycle is due at its closure; a date at the first
|
|
539
|
+
closure that reaches it — read once, a later date waits for a later closure, and a reading on or
|
|
540
|
+
after a date answers it, even at a cycle that stopped before it. A closure before the record's own
|
|
541
|
+
date is not one it could be read at. Said, never refused."""
|
|
542
|
+
closures = sorted(((ended, path.stem) for path, data in read
|
|
543
|
+
if data and one_of(data.get("status"), OUTCOMES)
|
|
544
|
+
and (ended := as_date(data.get("ended_on")))))
|
|
545
|
+
if not closures:
|
|
546
|
+
return []
|
|
547
|
+
said = []
|
|
548
|
+
for path in sorted(p for folder in RECORDS if (root / folder).is_dir()
|
|
549
|
+
for p in (root / folder).glob("*.md")
|
|
550
|
+
if p.is_file() and p.name != "README.md" and not p.name.startswith("_")):
|
|
551
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
552
|
+
header = {key.lower(): value.strip("*,.") for key, value in HEADER.findall(text.split("\n## ", 1)[0])}
|
|
553
|
+
section = CRITERION.search(text)
|
|
554
|
+
if header.get("status", "").lower() != "accepted" or not section:
|
|
555
|
+
continue
|
|
556
|
+
criterion = re.sub(r"<!--.*?-->", "", section[1], flags=re.S)
|
|
557
|
+
readings = [(name, day) for name, on in READING.findall(criterion) if (day := as_date(on))]
|
|
558
|
+
criterion = "\n".join(line for line in criterion.splitlines() if not READING.search(line))
|
|
559
|
+
since = as_date(header.get("date", ""))
|
|
560
|
+
open_ = [(ended, stem) for ended, stem in closures if since is None or ended >= since]
|
|
561
|
+
due = {closure for closure in open_ if _names(criterion, closure[1])}
|
|
562
|
+
for day in filter(None, map(as_date, DAY.findall(criterion))):
|
|
563
|
+
# A reading on or after the date has seen it pass, at whichever closure it was written.
|
|
564
|
+
reached = next((closure for closure in open_ if closure[0] >= day), None)
|
|
565
|
+
if reached and not any(on >= day for _, on in readings):
|
|
566
|
+
due.add(reached)
|
|
567
|
+
for ended, stem in sorted(due):
|
|
568
|
+
if not any(_names(name, stem) and on >= ended for name, on in readings):
|
|
569
|
+
said.append(f"[C10] {path.relative_to(root)}\n its success criterion is due at {stem}'s "
|
|
570
|
+
f"closure ({ended}), and holds no line saying it was read.\n Action: read it, and "
|
|
571
|
+
f"write in its success criterion: \"Read at {stem}'s closure, <YYYY-MM-DD>: met | "
|
|
572
|
+
"missed | superseded\" — a missed one is the decider's to answer, by a decision of its "
|
|
573
|
+
"own (.nstack/playbooks/framing.md, Decisions).")
|
|
574
|
+
return said
|
|
575
|
+
|
|
576
|
+
|
|
517
577
|
def run(root: Path) -> int:
|
|
518
578
|
failures: list[str] = []
|
|
519
579
|
|
|
@@ -549,6 +609,8 @@ def run(root: Path) -> int:
|
|
|
549
609
|
print(f"Plan: charter {'present' if charter.is_file() else 'absent'}, {len(cycles)} cycle(s).")
|
|
550
610
|
if len(accepted) == 1 and accepted_charter:
|
|
551
611
|
print(progress(root))
|
|
612
|
+
for warning in _due(root, [(path, read_front_matter(path)[0]) for path in cycles]):
|
|
613
|
+
print(f" WARNING {warning}")
|
|
552
614
|
for failure in failures:
|
|
553
615
|
print(f" FAIL {failure}")
|
|
554
616
|
if failures:
|
|
@@ -445,12 +445,20 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
|
|
|
445
445
|
return 0
|
|
446
446
|
|
|
447
447
|
|
|
448
|
+
def _ended(root: Path, ident: str, current: str | None) -> str | None:
|
|
449
|
+
"""Where a hold of an ended cycle is still open: a hold lasts its cycle (#280). Nothing it
|
|
450
|
+
guarded can be delivered any more, and a new cycle's hold is a branch of its own."""
|
|
451
|
+
for path in plan.cycle_files(root):
|
|
452
|
+
status = (plan.read_front_matter(path)[0] or {}).get("status")
|
|
453
|
+
if path.stem != current and plan.one_of(status, plan.OUTCOMES) and read(root, path.stem, ident).held:
|
|
454
|
+
return (f"{ident}'s hold ended with {path.stem} ({status}): a hold lasts its cycle, nothing to release. "
|
|
455
|
+
f"Its branch {PREFIX}/{path.stem}/{ident} is kept, the hold's record.")
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
448
459
|
def release(root: Path, ident: str, session: str | None = None, person: str | None = None) -> int:
|
|
449
460
|
cycle = plan.accepted_cycle(root)
|
|
450
|
-
if cycle
|
|
451
|
-
print("FAIL [hold] no accepted cycle: there is no hold to release.")
|
|
452
|
-
return 1
|
|
453
|
-
stem = cycle[0].stem
|
|
461
|
+
stem = cycle[0].stem if cycle else None
|
|
454
462
|
person = _person(root, person)
|
|
455
463
|
if any(not value.isprintable() for value in (person, session or "")):
|
|
456
464
|
print("FAIL [hold] a person or a session holds a line break or a control character.\n"
|
|
@@ -461,9 +469,14 @@ def release(root: Path, ident: str, session: str | None = None, person: str | No
|
|
|
461
469
|
except Unreachable as error:
|
|
462
470
|
print(f"FAIL [hold] the remote cannot be read.\n {error}\n Action: check it, then again.")
|
|
463
471
|
return 1
|
|
464
|
-
current = read(root, stem, ident)
|
|
472
|
+
current = read(root, stem, ident) if stem else Hold()
|
|
465
473
|
if not current.held:
|
|
466
|
-
|
|
474
|
+
ended = _ended(root, ident, stem)
|
|
475
|
+
if ended:
|
|
476
|
+
print(ended)
|
|
477
|
+
return 0
|
|
478
|
+
print(f"FAIL [hold] {ident} is not held: nothing to release." if stem else
|
|
479
|
+
"FAIL [hold] no accepted cycle: there is no hold to release.")
|
|
467
480
|
return 1
|
|
468
481
|
charter = plan.read_front_matter(root / plan.CHARTER)[0] if (root / plan.CHARTER).is_file() else None
|
|
469
482
|
decider = str((charter or {}).get("decider") or "")
|
|
@@ -504,7 +517,7 @@ def show(root: Path) -> int:
|
|
|
504
517
|
continue
|
|
505
518
|
if not found.held:
|
|
506
519
|
lines.append(f" {ident} free")
|
|
507
|
-
elif
|
|
520
|
+
elif not plan.one_of(state, TAKEABLE):
|
|
508
521
|
lines.append(f" {ident} ended — {state}; was {said(found)}")
|
|
509
522
|
elif time.time() >= found.worked + days * DAY:
|
|
510
523
|
held, stale = held + 1, stale + 1
|
|
@@ -376,6 +376,11 @@ def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | Non
|
|
|
376
376
|
print(f"FAIL [release] {odd.relative_to(root)} is a link or a file, not a folder of the project's own: "
|
|
377
377
|
"nothing is emptied through it.\n Action: remove it, then release again.")
|
|
378
378
|
return 1
|
|
379
|
+
left = sorted(path.relative_to(dist).as_posix() for path in dist.rglob("*") if not path.is_dir()) if dist.is_dir() else []
|
|
380
|
+
if left: # never silent: another target's package may be among them (#276)
|
|
381
|
+
named = ", ".join(left[:5]) + (f", and {len(left) - 5} more" if len(left) > 5 else "")
|
|
382
|
+
print(f"-> {target}: {dist.relative_to(root).as_posix()} emptied first, "
|
|
383
|
+
f"{len(left)} file{'s' if len(left) > 1 else ''}: {named}", flush=True)
|
|
379
384
|
shutil.rmtree(dist, ignore_errors=True)
|
|
380
385
|
dist.mkdir(parents=True)
|
|
381
386
|
environment = {**os.environ, "NSTACK_DIST": str(dist.resolve())}
|
|
@@ -383,14 +388,15 @@ def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | Non
|
|
|
383
388
|
# Its evidence: a folder of its own, its output kept, its logs read after (ADR-0009).
|
|
384
389
|
from napkinstack import evidence
|
|
385
390
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
391
|
+
with evidence.one_at_a_time(root, target):
|
|
392
|
+
folder = evidence.prepare(root, known[target])
|
|
393
|
+
if isinstance(folder, str):
|
|
394
|
+
print(f"FAIL [e2e] module '{target}': {folder}")
|
|
395
|
+
return 1
|
|
396
|
+
print(f"-> {target}: {command}", flush=True)
|
|
397
|
+
code, held = evidence.run(command, known[target], {**os.environ, evidence.HELD: "1",
|
|
398
|
+
"NSTACK_EVIDENCE": str(folder.resolve())}, folder)
|
|
399
|
+
passed = evidence.finish(root, target, known[target], folder, code, held)
|
|
394
400
|
if code == evidence.NOT_RUN and passed:
|
|
395
401
|
print(f"-> {target}: e2e not run here: its output says why, in {folder.relative_to(root)}/output.txt.")
|
|
396
402
|
continue
|
|
@@ -62,6 +62,10 @@ def engine() -> tuple[str, str | None]:
|
|
|
62
62
|
path = Path(url2pathname(urlparse(data.get("url", "")).path))
|
|
63
63
|
commit = subprocess.run(["git", "describe", "--always", "--dirty"], cwd=path,
|
|
64
64
|
capture_output=True, text=True).stdout.strip() if path.is_dir() else ""
|
|
65
|
+
if commit and not commit.endswith("-dirty") and subprocess.run(
|
|
66
|
+
["git", "status", "--porcelain", "--untracked-files=normal"], cwd=path,
|
|
67
|
+
capture_output=True, text=True).stdout.strip():
|
|
68
|
+
commit += "-dirty" # a file git does not track yet runs as surely as one it does
|
|
65
69
|
return version(), f"a local checkout at {commit or 'an unknown commit'}"
|
|
66
70
|
|
|
67
71
|
|
|
@@ -321,10 +321,13 @@ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
|
|
|
321
321
|
# declared, so it is re-run. One that was explored by hand costs a round: its evidence keeps the commit
|
|
322
322
|
# it was produced on, and the verifier confirms at the head, naming what moved since.
|
|
323
323
|
# At `critical` nothing stands in for a re-run: that is what the top value buys.
|
|
324
|
-
|
|
324
|
+
# Each clause of the cell starts with the commit it confirms: a later confirmation
|
|
325
|
+
# joins an earlier one after a `;`, never replacing it (#288).
|
|
326
|
+
confirmed = [clause.strip().split(" ", 1)[0].strip("`").lower()
|
|
327
|
+
for clause in row.get(CONFIRMED, "").split(";")]
|
|
325
328
|
if kind.lower() == "automated" or strict:
|
|
326
329
|
rerun.append(ident)
|
|
327
|
-
elif not (SHA.fullmatch(
|
|
330
|
+
elif not any(SHA.fullmatch(commit) and head.startswith(commit) for commit in confirmed):
|
|
328
331
|
unconfirmed.append(ident)
|
|
329
332
|
if result == "failed":
|
|
330
333
|
fail("T5", f"scenario {ident}: failed.\n Action: fix the change, or have the decider "
|
|
@@ -346,8 +349,9 @@ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
|
|
|
346
349
|
fail("T4", f"scenarios explored before the head {head[:7]} and not confirmed on it: "
|
|
347
350
|
f"{', '.join(unconfirmed)}.\n Action: re-read what changed since, then "
|
|
348
351
|
f"add the head commit in a Confirmed column: `{head[:7]} — <what moved, and why "
|
|
349
|
-
"it leaves this scenario's result standing
|
|
350
|
-
"again is the other way, and it is not
|
|
352
|
+
"it leaves this scenario's result standing>`, after a `;` when the cell holds an "
|
|
353
|
+
"earlier one.\n Running the whole sheet again is the other way, and it is not "
|
|
354
|
+
"required: a confirmation is not a round "
|
|
351
355
|
"(.nstack/handbook/05-workflow.md §7).")
|
|
352
356
|
return human_only
|
|
353
357
|
|
|
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
|
|
File without changes
|
|
File without changes
|
{napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/docs/runbook.md
RENAMED
|
File without changes
|
{napkinstack-0.17.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/tests/.gitkeep
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|