napkinstack 0.19.3__tar.gz → 0.20.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.19.3 → napkinstack-0.20.0}/PKG-INFO +4 -4
- {napkinstack-0.19.3 → napkinstack-0.20.0}/README.md +3 -3
- {napkinstack-0.19.3 → napkinstack-0.20.0}/pyproject.toml +1 -1
- {napkinstack-0.19.3 → napkinstack-0.20.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/doctor.py +16 -8
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/fitness/boundaries.py +2 -1
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/fitness/manifests.py +4 -3
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/fitness/pr_scope.py +2 -1
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/hold.py +87 -8
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/modules.py +2 -2
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/owners.py +1 -1
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/pull_request.py +51 -11
- {napkinstack-0.19.3 → napkinstack-0.20.0}/LICENSE +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/attest.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/board.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/cli.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/evidence.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/project.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/settings.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.19.3 → napkinstack-0.20.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.20.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
|
|
@@ -21,7 +21,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
21
21
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
22
22
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
23
23
|
|
|
24
|
-
> **Status: v0.
|
|
24
|
+
> **Status: v0.20.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
25
25
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
26
26
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
27
27
|
|
|
@@ -85,8 +85,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
85
85
|
registry — always pinned to a release tag:
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
89
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
88
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.20.0" nstack init my-project
|
|
89
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.20.0"
|
|
90
90
|
```
|
|
91
91
|
|
|
92
92
|
**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.20.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.20.0" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.20.0"
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -154,7 +154,7 @@ CHECKLIST = [ # (rule, setting, action)
|
|
|
154
154
|
("G20", "Hold branches `nstack/hold/**/*` cannot be force-pushed nor deleted",
|
|
155
155
|
"Settings → Rules → Rulesets, a ruleset on the branches nstack/hold/**/* — not nstack/hold/**, "
|
|
156
156
|
"which stops at a slash — with nobody on its bypass list: block force pushes and restrict deletions"),
|
|
157
|
-
# G21: where another
|
|
157
|
+
# G21: where another agent approves (PDR-0021), the one who pushed last never
|
|
158
158
|
# approves what they pushed — the author's own fix included.
|
|
159
159
|
("G21", "The most recent push approved by someone other than its pusher (approval: agent)",
|
|
160
160
|
f"{RULESET}: require approval of the most recent reviewable push"),
|
|
@@ -361,7 +361,7 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
|
|
|
361
361
|
|
|
362
362
|
def _default_owner(root: Path) -> tuple[str, str]:
|
|
363
363
|
"""L6: `*` first, so that the code owner review covers every path (ADR-0004) — or, where
|
|
364
|
-
another
|
|
364
|
+
another agent approves (PDR-0021), a human named for what one must approve and
|
|
365
365
|
no default owner, so that the rest is the agents'."""
|
|
366
366
|
from napkinstack.project import answer, forge, ownership
|
|
367
367
|
|
|
@@ -395,7 +395,7 @@ def _kept_for_a_human(root: Path, shown: str, text: str) -> tuple[str, str]:
|
|
|
395
395
|
default, missing = owners.unowned(root, text)
|
|
396
396
|
if default:
|
|
397
397
|
return GAP, (f"{shown} names a default owner: on approval agent, it leaves no change another "
|
|
398
|
-
"
|
|
398
|
+
"agent may approve.\nAction: remove the `*` rule; name a human for what "
|
|
399
399
|
"one must approve (.nstack/handbook/07-governance.md §7).")
|
|
400
400
|
if missing:
|
|
401
401
|
return GAP, (f"{shown} names no owner for " + ", ".join(f"`{path}`" for path in missing)
|
|
@@ -409,16 +409,24 @@ def _kept_for_a_human(root: Path, shown: str, text: str) -> tuple[str, str]:
|
|
|
409
409
|
if len(agents) < 2:
|
|
410
410
|
return OK, (f"The history shows {len(agents) or 'no'} agent identit{'y' if len(agents) == 1 else 'ies'}"
|
|
411
411
|
+ (f" ({agents[0]})" if agents else "") + ": an agent never approves what its own identity "
|
|
412
|
-
"pushed, so until another
|
|
412
|
+
"pushed, so until another agent works under an identity of its own, every "
|
|
413
413
|
"change waits for a human, as on approval human.")
|
|
414
414
|
return OK, ""
|
|
415
415
|
|
|
416
416
|
|
|
417
417
|
def _agent_identities(root: Path) -> list[str]:
|
|
418
|
-
"""The bot identities the recent history's commits name, as GitHub's noreply
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
418
|
+
"""The bot identities of the recent history's commits that name a session, as GitHub's noreply
|
|
419
|
+
addresses say them. The line is what the kernel asks of an agent's commit: a forge's own bot,
|
|
420
|
+
Dependabot's, carries none and neither verifies nor approves (#310)."""
|
|
421
|
+
shown = subprocess.run(["git", "log", "-n", "500", "--format=%ae%x1f%ce%x1f%B%x1e"],
|
|
422
|
+
cwd=root, capture_output=True, text=True)
|
|
423
|
+
found = []
|
|
424
|
+
for record in shown.stdout.split("\x1e"):
|
|
425
|
+
author, committer, message = (record.strip().split("\x1f") + ["", ""])[:3]
|
|
426
|
+
# In the whole message: a squash keeps it above a last paragraph of co-authors.
|
|
427
|
+
if re.search(r"^Agent-Session:[ \t]*\S", message, re.M | re.I):
|
|
428
|
+
found += [address.lower() for address in (author, committer)
|
|
429
|
+
if address.lower().endswith("[bot]@users.noreply.github.com")]
|
|
422
430
|
return list(dict.fromkeys(found))
|
|
423
431
|
|
|
424
432
|
|
|
@@ -211,7 +211,8 @@ def check_contracts(root: Path, modules: dict[str, dict]) -> dict[str, dict[str,
|
|
|
211
211
|
offered = ", ".join(f"{k[0]} {k[1]} by {v}" for k, v in sorted(provided.items(), key=str)) or "none"
|
|
212
212
|
if producer is None:
|
|
213
213
|
fail("B6", name, f"consumes {key[0]} {key[1]}, which no module provides (provided: {offered}).\n"
|
|
214
|
-
" Action: consume a provided version, or have its producer declare it
|
|
214
|
+
" Action: consume a provided version, or have its producer declare it first, "
|
|
215
|
+
"in a pull request of its own (.nstack/handbook/03-contracts.md §4).")
|
|
215
216
|
elif c.get("module") not in (None, producer):
|
|
216
217
|
fail("B6", name, f"consumes {key[0]} {key[1]} from '{c.get('module')}', which is "
|
|
217
218
|
f"provided by '{producer}'.\n Action: name the producer.")
|
|
@@ -130,7 +130,8 @@ def unfilled(value) -> bool:
|
|
|
130
130
|
def is_description(path: str) -> bool:
|
|
131
131
|
"""A module's description — its manifest, AGENTS.md, README.md, docs/ — or an empty
|
|
132
132
|
placeholder, `path` relative to the module's folder. Changing it changes no behaviour
|
|
133
|
-
(D24); a module holding nothing else has nothing to check yet (D33).
|
|
133
|
+
(D24); a module holding nothing else has nothing to check yet (D33). The one exception is read
|
|
134
|
+
from the manifests' values, not their path: pull_request.touched_modules (D184)."""
|
|
134
135
|
# A page at any depth describes; it runs nothing. A contract's own page —
|
|
135
136
|
# contracts/<name>/v1/README.md — is the case that asked: the module that holds it must
|
|
136
137
|
# stay green until the contract gains a schema or a test (PDR-0012). Once a module consumes
|
|
@@ -471,7 +472,7 @@ def assembled(manifests: list[Path], root: Path) -> None:
|
|
|
471
472
|
|
|
472
473
|
|
|
473
474
|
def kept_for_a_human(root: Path) -> None:
|
|
474
|
-
"""M13: where another
|
|
475
|
+
"""M13: where another agent approves, what a human must approve is named in the
|
|
475
476
|
code owners file. The forge reads that file from the base branch, so a pull request raising a
|
|
476
477
|
module's criticality without its line would be approved by an agent: refused here, in CI."""
|
|
477
478
|
from napkinstack import owners
|
|
@@ -489,7 +490,7 @@ def kept_for_a_human(root: Path) -> None:
|
|
|
489
490
|
default, missing = owners.unowned(root, path.read_text(encoding="utf-8"))
|
|
490
491
|
if default:
|
|
491
492
|
fail("code owners", "M13", f"{shown} names a default owner: on approval agent, no change is left "
|
|
492
|
-
"for another
|
|
493
|
+
"for another agent.\n Action: remove the `*` rule.")
|
|
493
494
|
for kept in missing:
|
|
494
495
|
fail("code owners", "M13", f"{shown} ends `{kept}` on no owner: on approval agent, a human "
|
|
495
496
|
f"approves it.\n {action}")
|
|
@@ -6,7 +6,8 @@ Fitness function 3 — Pull request scope and size.
|
|
|
6
6
|
|
|
7
7
|
A module is touched when the pull request changes it beyond its description — the same
|
|
8
8
|
count as `nstack pr-check`'s (D34): editing a manifest, an AGENTS.md, a README or docs/
|
|
9
|
-
changes no module,
|
|
9
|
+
changes no module, unless the manifest moves its criticality or its user-facing surface (D184),
|
|
10
|
+
and contracts/ is a module like any other.
|
|
10
11
|
|
|
11
12
|
The `cross-module` label lifts P1, the `over-budget` label documents P2: both are visible,
|
|
12
13
|
so the exceptions are countable (.nstack/handbook/10-measurement.md §3).
|
|
@@ -10,6 +10,10 @@ from it: the refusal is git's, on any forge. A hold is never merged; its work is
|
|
|
10
10
|
Usage : nstack claim D<n> [--session S] [--person P] [--take-over]
|
|
11
11
|
nstack unclaim D<n> [--session S] [--person P]
|
|
12
12
|
Output: 0 when the hold is taken, renewed or released; 1 when refused — saying who holds it.
|
|
13
|
+
|
|
14
|
+
Two sessions of one person are two holders (D181, D185): a session renews only a hold it is on; a
|
|
15
|
+
person's other session takes it with --take-over, on that person's word. A person with no session
|
|
16
|
+
has nothing to tell apart, and their name answers.
|
|
13
17
|
"""
|
|
14
18
|
|
|
15
19
|
from __future__ import annotations
|
|
@@ -360,7 +364,7 @@ def _show(root: Path, name: str | None, ident: str, title: str, person: str, bra
|
|
|
360
364
|
said: list[str] = []
|
|
361
365
|
try:
|
|
362
366
|
if branch is None:
|
|
363
|
-
since = (f" from {previous.person},
|
|
367
|
+
since = (f" from {previous.person}, last worked on {when(previous.worked)}"
|
|
364
368
|
if previous is not None else "")
|
|
365
369
|
for number in _open_pulls(root, forge, ident):
|
|
366
370
|
_call(forge, "POST", f"/issues/{number}/comments", {"body": (
|
|
@@ -387,7 +391,7 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
|
|
|
387
391
|
print(f"FAIL [hold] {found}")
|
|
388
392
|
return 1
|
|
389
393
|
cycle, title = found
|
|
390
|
-
person = _person(root, person)
|
|
394
|
+
person, session = _person(root, person), (session or "").strip() or None
|
|
391
395
|
if any(not value.isprintable() for value in (person, session or "")):
|
|
392
396
|
print("FAIL [hold] a person or a session holds a line break or a control character: it would "
|
|
393
397
|
"write the record's lines.\n Action: name them on one line.")
|
|
@@ -402,11 +406,13 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
|
|
|
402
406
|
days = limit(root)
|
|
403
407
|
stale_at = current.worked + days * DAY
|
|
404
408
|
previous = None
|
|
409
|
+
mine = same(current.person, person)
|
|
410
|
+
other_session = _another_session(current, session)
|
|
405
411
|
if not current.held:
|
|
406
412
|
event = "claim"
|
|
407
|
-
elif
|
|
413
|
+
elif mine and not other_session:
|
|
408
414
|
event = "renew"
|
|
409
|
-
elif take_over and time.time() >= stale_at:
|
|
415
|
+
elif take_over and (mine or time.time() >= stale_at): # a person moves their own hold on their word
|
|
410
416
|
event, previous = "take-over", current
|
|
411
417
|
elif take_over:
|
|
412
418
|
print(f"FAIL [hold] {ident} is {said(current)}: it is not stale — work on it is younger than "
|
|
@@ -419,7 +425,9 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
|
|
|
419
425
|
print(f"FAIL [hold] {ident} is {said(current)}" + (f" — stale, no work for {days} days." if stale else ".")
|
|
420
426
|
+ (f"\n Action: {again} — recorded, with its previous holder."
|
|
421
427
|
if stale else "\n Action: take another ready deliverable — nstack plan lists them; "
|
|
422
|
-
"or ask its holder to give it back (nstack unclaim)."
|
|
428
|
+
"or ask its holder to give it back (nstack unclaim)."
|
|
429
|
+
+ (f"\n Another session of the same person holds it: only if that person asks, "
|
|
430
|
+
f"{again} — recorded, with the session it leaves." if mine else "")))
|
|
423
431
|
return 1
|
|
424
432
|
sha = _event(root, current.tip, ident, event, person, session, previous)
|
|
425
433
|
ok, where = _record(root, name, cycle, ident, sha)
|
|
@@ -436,7 +444,9 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
|
|
|
436
444
|
else:
|
|
437
445
|
print(f"FAIL [hold] {ident}: the remote refused the hold.\n {where}")
|
|
438
446
|
return 1
|
|
439
|
-
|
|
447
|
+
left = f"{previous.person}, session {', '.join(previous.sessions)}" if previous and previous.sessions \
|
|
448
|
+
else (previous.person if previous else "")
|
|
449
|
+
done = {"claim": "taken", "renew": "renewed", "take-over": f"taken over from {left}"}
|
|
440
450
|
print(f"{ident} {done[event]}: held by {person}" + (f", session {session}" if session else "") + f"; {where}.")
|
|
441
451
|
if event != "renew":
|
|
442
452
|
made, branch = _work_branch(root, name, ident, title, session)
|
|
@@ -445,6 +455,12 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
|
|
|
445
455
|
return 0
|
|
446
456
|
|
|
447
457
|
|
|
458
|
+
def _another_session(current: Hold, session: str | None) -> bool:
|
|
459
|
+
"""Named, and on none of the hold's events. A person with no session has nothing to tell apart."""
|
|
460
|
+
named = (session or "").strip().casefold()
|
|
461
|
+
return bool(named and current.sessions and named not in {held.casefold() for held in current.sessions})
|
|
462
|
+
|
|
463
|
+
|
|
448
464
|
def _ended(root: Path, ident: str, current: str | None) -> str | None:
|
|
449
465
|
"""Where a hold of an ended cycle is still open: a hold lasts its cycle (#280). Nothing it
|
|
450
466
|
guarded can be delivered any more, and a new cycle's hold is a branch of its own."""
|
|
@@ -459,7 +475,7 @@ def _ended(root: Path, ident: str, current: str | None) -> str | None:
|
|
|
459
475
|
def release(root: Path, ident: str, session: str | None = None, person: str | None = None) -> int:
|
|
460
476
|
cycle = plan.accepted_cycle(root)
|
|
461
477
|
stem = cycle[0].stem if cycle else None
|
|
462
|
-
person = _person(root, person)
|
|
478
|
+
person, session = _person(root, person), (session or "").strip() or None
|
|
463
479
|
if any(not value.isprintable() for value in (person, session or "")):
|
|
464
480
|
print("FAIL [hold] a person or a session holds a line break or a control character.\n"
|
|
465
481
|
" Action: name them on one line.")
|
|
@@ -481,7 +497,13 @@ def release(root: Path, ident: str, session: str | None = None, person: str | No
|
|
|
481
497
|
charter = plan.read_front_matter(root / plan.CHARTER)[0] if (root / plan.CHARTER).is_file() else None
|
|
482
498
|
decider = str((charter or {}).get("decider") or "")
|
|
483
499
|
stale = time.time() >= current.worked + limit(root) * DAY
|
|
484
|
-
|
|
500
|
+
is_decider = bool(decider and same(decider, person))
|
|
501
|
+
if same(current.person, person) and _another_session(current, session) and not stale and not is_decider:
|
|
502
|
+
print(f"FAIL [hold] {ident} is {said(current)}: another session of the same person does not give it "
|
|
503
|
+
f"back.\n Action: only if that person asks, nstack claim {ident} --take-over --person {person} "
|
|
504
|
+
f"--session {session}.")
|
|
505
|
+
return 1
|
|
506
|
+
if not (same(current.person, person) or is_decider or stale):
|
|
485
507
|
print(f"FAIL [hold] {ident} is {said(current)}: only its holder or the decider ({decider or 'none named'}) "
|
|
486
508
|
"releases a hold that is not stale.\n Action: ask them.")
|
|
487
509
|
return 1
|
|
@@ -529,9 +551,66 @@ def show(root: Path) -> int:
|
|
|
529
551
|
where = f"from {name}" if name else "local only: no remote"
|
|
530
552
|
print(f"\nHolds in {path.stem}: {held} held, of which {stale} stale ({where}; stale after {days} days without work):")
|
|
531
553
|
print("\n".join(lines) or " no ready deliverable")
|
|
554
|
+
left = _empty_starts(root, name, items if isinstance(items, list) else [])
|
|
555
|
+
if left:
|
|
556
|
+
print("\nEmpty start branches left — one empty commit each, nothing to lose, and git branch -d refuses them:")
|
|
557
|
+
print("\n".join(left))
|
|
532
558
|
return 0
|
|
533
559
|
|
|
534
560
|
|
|
561
|
+
def _empty_starts(root: Path, name: str | None, items: list) -> list[str]:
|
|
562
|
+
"""What to run for each branch `claim` started that its deliverable no longer needs (#316): its
|
|
563
|
+
state moved on, or its work landed from other branches — the default branch names it since —
|
|
564
|
+
or it belongs to another cycle. An id no cycle here knows may be newer than this checkout, and
|
|
565
|
+
is left alone. Only such a branch: the claim's own subject, one empty commit on the default
|
|
566
|
+
branch, here or on the remote."""
|
|
567
|
+
states = {str(item.get("id")).lower(): item.get("state") for item in items if isinstance(item, dict)}
|
|
568
|
+
known = set()
|
|
569
|
+
for path in plan.cycle_files(root):
|
|
570
|
+
listed = (plan.read_front_matter(path)[0] or {}).get("deliverables")
|
|
571
|
+
known |= {str(item.get("id")).lower() for item in (listed if isinstance(listed, list) else []) if isinstance(item, dict)}
|
|
572
|
+
base = f"{MIRROR}/base" if name else "refs/heads/main"
|
|
573
|
+
branches: dict[str, set[str]] = {}
|
|
574
|
+
for where, prefix, strip in (("remote", f"{MIRROR}/work", 3), ("local", "refs/heads", 2)):
|
|
575
|
+
if where == "remote" and not name:
|
|
576
|
+
continue
|
|
577
|
+
shown = _git(root, "for-each-ref", f"--format=%(refname:lstrip={strip})%09%(refname)%09%(subject)", f"{prefix}/d*").stdout
|
|
578
|
+
for line in shown.splitlines():
|
|
579
|
+
branch, ref, subject = (line.split("\t", 2) + ["", ""])[:3]
|
|
580
|
+
ident = branch.split("-")[0]
|
|
581
|
+
if (ident not in known or not subject.startswith(f"Start {ident.upper()} — ")
|
|
582
|
+
or _git(root, "rev-list", "--count", f"{base}..{ref}").stdout.strip() != "1"
|
|
583
|
+
or _git(root, "rev-parse", f"{ref}^{{tree}}").stdout != _git(root, "rev-parse", f"{ref}~1^{{tree}}").stdout):
|
|
584
|
+
continue
|
|
585
|
+
if ident in states and plan.one_of(states[ident], TAKEABLE) and not _landed(root, ident, ref, base):
|
|
586
|
+
continue
|
|
587
|
+
if ident not in states and _held_in_a_live_cycle(root, ident.upper()):
|
|
588
|
+
continue
|
|
589
|
+
branches.setdefault(branch, set()).add(where)
|
|
590
|
+
return [f" {branch} " + "; ".join(([f"git push {name} --delete {branch}"] if "remote" in places else [])
|
|
591
|
+
+ ([f"git branch -D {branch}"] if "local" in places else []))
|
|
592
|
+
for branch, places in sorted(branches.items())]
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _landed(root: Path, ident: str, ref: str, base: str) -> bool:
|
|
596
|
+
"""The default branch records the deliverable's delivery since the start branch left it: the
|
|
597
|
+
`Deliverable:` line of a pull request, or its id in parentheses or opening a subject — a commit
|
|
598
|
+
merely speaking of it is no delivery, and a live hold's branch is not named for it."""
|
|
599
|
+
name = ident.upper()
|
|
600
|
+
pattern = rf"\({name}\)|^Deliverable:[ \t]*{name}([^0-9]|$)|^{name} [—–-]"
|
|
601
|
+
return bool(_git(root, "log", "-1", "--format=%H", "--extended-regexp", f"--grep={pattern}", f"{ref}~1..{base}").stdout.strip())
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _held_in_a_live_cycle(root: Path, ident: str) -> bool:
|
|
605
|
+
"""A hold on the remote this checkout's cycle files do not explain: its id moved on a commit not
|
|
606
|
+
pulled here. A hold of a closed cycle ended with it."""
|
|
607
|
+
prefix = f"{MIRROR}/hold" if remote(root) else f"refs/heads/{PREFIX}"
|
|
608
|
+
closed = {path.stem for path in plan.cycle_files(root)
|
|
609
|
+
if plan.one_of((plan.read_front_matter(path)[0] or {}).get("status"), plan.OUTCOMES)}
|
|
610
|
+
cycles = _git(root, "for-each-ref", "--format=%(refname)", f"{prefix}/*/{ident}").stdout.split()
|
|
611
|
+
return any(ref.split("/")[-2] not in closed and read(root, ref.split("/")[-2], ident).held for ref in cycles)
|
|
612
|
+
|
|
613
|
+
|
|
535
614
|
def judge(root: Path, ident: str) -> Hold | str | None:
|
|
536
615
|
"""The hold of a deliverable of the accepted cycle for `pr-check`: None when nobody holds
|
|
537
616
|
it, the reason when it cannot be read."""
|
|
@@ -103,8 +103,8 @@ def create(root: Path, name: str, owner: str, criticality: str, user_facing: boo
|
|
|
103
103
|
codeowners = ownership(root)
|
|
104
104
|
line = f"/modules/{name}/"
|
|
105
105
|
if answer(root, "approval") == "agent" and criticality in ("prototype", "standard"):
|
|
106
|
-
# Another
|
|
107
|
-
print(f"No code owner line for {line}: on approval agent, another
|
|
106
|
+
# Another agent approves it (PDR-0021): no code owner, whose review a human's alone satisfies.
|
|
107
|
+
print(f"No code owner line for {line}: on approval agent, another agent approves a "
|
|
108
108
|
f"{criticality} module.")
|
|
109
109
|
elif codeowners.is_file():
|
|
110
110
|
content = codeowners.read_text(encoding="utf-8")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Who must approve a change, read from the code owners file as the forge reads it (PDR-0021).
|
|
3
3
|
|
|
4
|
-
On `approval: agent`, another
|
|
4
|
+
On `approval: agent`, another agent approves what the code owners file names no
|
|
5
5
|
owner for; a human, what it names. So every path a human must approve — the project's rules,
|
|
6
6
|
its records, its answers, the code owners file itself, every module neither `prototype` nor
|
|
7
7
|
`standard` — must end on a rule with an owner. A pattern is read as a gitignore pattern, the
|
|
@@ -8,7 +8,7 @@ Rules:
|
|
|
8
8
|
head, the stricter
|
|
9
9
|
T2 a verifier named who is not an author of the change, and every scenario row filled
|
|
10
10
|
in: id, given · when · then, a kind (automated, explored, human only — reason), a
|
|
11
|
-
result (passed, failed, not verified)
|
|
11
|
+
result (passed, failed, not verified); a | that splits a row is said (D187)
|
|
12
12
|
T3 no scenario passed without its evidence and the commit it was verified on
|
|
13
13
|
T4 evidence at the head commit: an automated scenario is run again, one explored by hand
|
|
14
14
|
is confirmed at the head instead — and at criticality critical, run again like the rest
|
|
@@ -22,7 +22,8 @@ Rules:
|
|
|
22
22
|
sessions, or its person's account (`nstack claim`)
|
|
23
23
|
|
|
24
24
|
Delivery work: a pull request that changes a module — a folder holding a MANIFEST.yaml —
|
|
25
|
-
beyond its description: its manifest, AGENTS.md, README.md and docs/ (D24)
|
|
25
|
+
beyond its description: its manifest, AGENTS.md, README.md and docs/ (D24) — or whose manifest
|
|
26
|
+
moves its criticality or its user-facing surface, which is the decider's call (D184). A NapkinStack
|
|
26
27
|
update or a documentation fix is neither delivery work nor a reason for a sheet.
|
|
27
28
|
The out-of-cycle label lifts K1 to K3 and K5, visibly and countably (.nstack/handbook/10-measurement.md).
|
|
28
29
|
|
|
@@ -66,6 +67,8 @@ COLUMNS = ("#", "given · when · then", "kind", "result", "evidence", "commit")
|
|
|
66
67
|
# Optional seventh column, read only when a scenario needs it: the head commit and what
|
|
67
68
|
# changed since. A sheet no fix has disturbed never has to carry it (T4, D71).
|
|
68
69
|
CONFIRMED = "confirmed"
|
|
70
|
+
CELLS = "cells read" # how many a row held
|
|
71
|
+
PIPED = "a pipe in a code span" # a bare |: the table ends the cell there, as GitHub does
|
|
69
72
|
KINDS = {"automated", "explored"}
|
|
70
73
|
RESULTS = {"passed", "failed", "not verified"}
|
|
71
74
|
SECTION = re.compile(r"^##[ \t]+Test sheet[ \t]*$", re.I | re.M)
|
|
@@ -99,7 +102,8 @@ def _manifest(text: str) -> dict:
|
|
|
99
102
|
|
|
100
103
|
def touched_modules(root: Path, base: str, files: list[str]) -> dict[str, list[dict]]:
|
|
101
104
|
"""Folder, relative to the root, to its manifests — at the head and at the base — for
|
|
102
|
-
every module whose behaviour the files may change, a deleted module included
|
|
105
|
+
every module whose behaviour the files may change, a deleted module included, or whose
|
|
106
|
+
manifest moved what it owes."""
|
|
103
107
|
manifests = {path.relative_to(root).as_posix(): [_manifest(path.read_text(encoding="utf-8"))]
|
|
104
108
|
for path in find_manifests(root)}
|
|
105
109
|
listed = _git(root, "ls-tree", "-r", "--name-only", base).stdout.split()
|
|
@@ -112,11 +116,19 @@ def touched_modules(root: Path, base: str, files: list[str]) -> dict[str, list[d
|
|
|
112
116
|
for path, found in sorted(manifests.items()):
|
|
113
117
|
folder = path.removesuffix("/MANIFEST.yaml")
|
|
114
118
|
if any(file.startswith(f"{folder}/") and (not is_description(file[len(folder) + 1:]) or file in relied)
|
|
115
|
-
for file in files):
|
|
119
|
+
for file in files) or _owes_differently(found):
|
|
116
120
|
touched[folder] = found
|
|
117
121
|
return touched
|
|
118
122
|
|
|
119
123
|
|
|
124
|
+
def _owes_differently(manifests: list[dict]) -> bool:
|
|
125
|
+
"""True when the manifest moved what its module owes (.nstack/handbook/05-workflow.md §7): the
|
|
126
|
+
rest of a manifest is a description, these two say which checks the module answers to (D184)."""
|
|
127
|
+
owed = [(module.get("criticality"), module.get("user_facing") is True)
|
|
128
|
+
for module in (data.get("module") for data in manifests) if isinstance(module, dict)]
|
|
129
|
+
return len(set(owed)) > 1
|
|
130
|
+
|
|
131
|
+
|
|
120
132
|
CONTRACT_PAGE = re.compile(r"(?P<path>.+/(?P<contract>[^/]+)/(?P<version>v\d+))/README\.md")
|
|
121
133
|
|
|
122
134
|
|
|
@@ -193,7 +205,8 @@ def read_sheet(body: str) -> tuple[str, list[str], list[dict[str, str]]]:
|
|
|
193
205
|
values = _cells(line)
|
|
194
206
|
if all(value in EMPTY or PLACEHOLDER.fullmatch(value) for value in values[1:]):
|
|
195
207
|
continue # the template's example row
|
|
196
|
-
|
|
208
|
+
piped = any("|" in span for span in re.findall(r"`[^`]*`", line.replace("\\|", "")))
|
|
209
|
+
rows.append({**dict(zip(header, values + [""] * (len(header) - len(values)))), CELLS: len(values), PIPED: piped})
|
|
197
210
|
return verifier, header, rows
|
|
198
211
|
|
|
199
212
|
|
|
@@ -272,6 +285,18 @@ def _result(cell: str) -> str:
|
|
|
272
285
|
return re.split(r"[ \t]+[—–-][ \t]+", cell.replace("*", "").strip(), maxsplit=1)[0].lower()
|
|
273
286
|
|
|
274
287
|
|
|
288
|
+
def _split(row: dict, width: int) -> bool:
|
|
289
|
+
"""A bare | moved the row's columns. A row that reads well has not moved, whatever it holds:
|
|
290
|
+
cells beyond the header are ignored, as GitHub ignores them, and backticks prove nothing."""
|
|
291
|
+
result = _result(row["result"])
|
|
292
|
+
commit = SHA.fullmatch(row["commit"].strip().strip("`").lower())
|
|
293
|
+
kind = row["kind"].lower() in KINDS or HUMAN_ONLY.fullmatch(row["kind"])
|
|
294
|
+
if kind and result in RESULTS and (commit or result == "not verified"):
|
|
295
|
+
return False
|
|
296
|
+
late = SHA.fullmatch(row.get(CONFIRMED, "").strip().strip("`").lower()) # one cell after the commit's
|
|
297
|
+
return bool(row[PIPED] or late or row[CELLS] > width)
|
|
298
|
+
|
|
299
|
+
|
|
275
300
|
def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
|
|
276
301
|
authorship: tuple[set[str], set[str], list[str]] = (set(), set(), []),
|
|
277
302
|
strict: bool = False, unchanged: Callable[[str], bool] = lambda commit: False) -> list[str]:
|
|
@@ -299,6 +324,11 @@ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
|
|
|
299
324
|
human_only, rerun, unconfirmed = [], [], []
|
|
300
325
|
for row in rows:
|
|
301
326
|
ident = row["#"] or "?"
|
|
327
|
+
if _split(row, len(header)):
|
|
328
|
+
fail("T2", f"scenario {ident}: a | splits its row ({row[CELLS]} cells, the header {len(header)}): its "
|
|
329
|
+
"columns cannot be read.\n Action: a | in a cell ends it, in a code span too: "
|
|
330
|
+
"write it \\|.")
|
|
331
|
+
continue
|
|
302
332
|
kind = row["kind"]
|
|
303
333
|
reason = HUMAN_ONLY.fullmatch(kind)
|
|
304
334
|
result = _result(row["result"])
|
|
@@ -400,10 +430,20 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
|
|
|
400
430
|
elif not plan.one_of(states[match[1]], {"ready", "in-progress"}):
|
|
401
431
|
fail("K3", f"deliverable {match[1]} is '{states[match[1]]}': work starts on a ready "
|
|
402
432
|
"deliverable (definition of ready).")
|
|
403
|
-
elif states[match[1]] == "ready":
|
|
404
|
-
# Said, not refused
|
|
405
|
-
|
|
406
|
-
|
|
433
|
+
elif states[match[1]] == "ready" and not _held(root, match[1]):
|
|
434
|
+
# Said, not refused. The hold says the work started; the cycle file stays the decider's
|
|
435
|
+
# record, never changed by a delivery — under `docs/`, a human would have to approve it.
|
|
436
|
+
warn(f"[K3] deliverable {match[1]} is still 'ready' and nobody holds it while this pull request "
|
|
437
|
+
f"delivers it.\n Action: nstack claim {match[1]} — the hold says it is in progress; "
|
|
438
|
+
f"{path.name} changes in the plan's own pull requests, never in a delivery's.")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _held(root: Path, ident: str) -> bool:
|
|
442
|
+
"""Whether someone holds the deliverable; holds that cannot be read count as none (K5 says so)."""
|
|
443
|
+
from napkinstack import hold
|
|
444
|
+
|
|
445
|
+
found = hold.judge(root, ident)
|
|
446
|
+
return found is not None and not isinstance(found, str)
|
|
407
447
|
|
|
408
448
|
|
|
409
449
|
def identities(root: Path, base: str, head: str) -> set[str]:
|
|
@@ -435,8 +475,8 @@ def check_hold(root: Path, body: str, labels: set[str], authorship: tuple[set[st
|
|
|
435
475
|
return
|
|
436
476
|
fail("K5", f"{ident} is {hold.said(found)}: this pull request comes from none of its sessions "
|
|
437
477
|
f"and not from its person.\n Action: the holder gives it back (nstack unclaim {ident}), "
|
|
438
|
-
f"or once stale nstack claim {ident} --take-over. The holder
|
|
439
|
-
f"
|
|
478
|
+
f"or once stale nstack claim {ident} --take-over. The holder's person moves it to another "
|
|
479
|
+
f"session on their word: nstack claim {ident} --take-over --person {found.person} --session <it>.")
|
|
440
480
|
|
|
441
481
|
|
|
442
482
|
def charter_without_pdr(root: Path, base: str, files: list[str]) -> list[str]:
|
|
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
|
|
File without changes
|
{napkinstack-0.19.3 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/docs/runbook.md
RENAMED
|
File without changes
|
{napkinstack-0.19.3 → napkinstack-0.20.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
|