napkinstack 0.3.1__tar.gz → 0.4.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.3.1 → napkinstack-0.4.0}/PKG-INFO +10 -5
- {napkinstack-0.3.1 → napkinstack-0.4.0}/README.md +9 -4
- {napkinstack-0.3.1 → napkinstack-0.4.0}/pyproject.toml +1 -1
- {napkinstack-0.3.1 → napkinstack-0.4.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/cli.py +32 -14
- napkinstack-0.4.0/src/napkinstack/compat.py +174 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/doctor.py +36 -10
- napkinstack-0.4.0/src/napkinstack/fitness/boundaries.py +348 -0
- napkinstack-0.4.0/src/napkinstack/fitness/hygiene.py +97 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/fitness/manifests.py +71 -5
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/fitness/plan.py +19 -6
- napkinstack-0.4.0/src/napkinstack/fitness/pr_scope.py +89 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/modules.py +53 -9
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/project.py +1 -1
- napkinstack-0.4.0/src/napkinstack/provenance.py +62 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/pull_request.py +78 -20
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/templates/module/MANIFEST.yaml +10 -8
- napkinstack-0.3.1/src/napkinstack/fitness/boundaries.py +0 -222
- napkinstack-0.3.1/src/napkinstack/fitness/pr_scope.sh +0 -79
- {napkinstack-0.3.1 → napkinstack-0.4.0}/LICENSE +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.3.1 → napkinstack-0.4.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.4.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,11 +20,14 @@ 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.
|
|
24
|
-
>
|
|
25
|
-
>
|
|
23
|
+
> **Status: v0.4.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
|
|
24
|
+
> the framework announces refuses what it claims to — the boundaries read the contracts a
|
|
25
|
+
> module uses, a contract version someone relies on changes only with a proof, the module
|
|
26
|
+
> checks and stale approvals are enforced, a verifier is not an author, and every verdict names
|
|
27
|
+
> the framework that gave it. A first pilot project, private, starts from it. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
26
28
|
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
27
29
|
> [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
|
|
30
|
+
> [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
|
|
28
31
|
> [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
29
32
|
|
|
30
33
|
## A project's journey
|
|
@@ -90,8 +93,10 @@ the commit of this repository
|
|
|
90
93
|
| `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
|
|
91
94
|
| `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
|
|
92
95
|
| `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
|
|
93
|
-
| `nstack fitness` | Manifests, boundaries between modules, skills, plan |
|
|
96
|
+
| `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene |
|
|
94
97
|
| `nstack pr-scope` | One PR = one module, review budget |
|
|
98
|
+
| `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
|
|
99
|
+
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
95
100
|
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
96
101
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
97
102
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
@@ -5,11 +5,14 @@ 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.
|
|
9
|
-
>
|
|
10
|
-
>
|
|
8
|
+
> **Status: v0.4.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
|
|
9
|
+
> the framework announces refuses what it claims to — the boundaries read the contracts a
|
|
10
|
+
> module uses, a contract version someone relies on changes only with a proof, the module
|
|
11
|
+
> checks and stale approvals are enforced, a verifier is not an author, and every verdict names
|
|
12
|
+
> the framework that gave it. A first pilot project, private, starts from it. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
11
13
|
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
12
14
|
> [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
|
|
15
|
+
> [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
|
|
13
16
|
> [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
14
17
|
|
|
15
18
|
## A project's journey
|
|
@@ -75,8 +78,10 @@ the commit of this repository
|
|
|
75
78
|
| `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
|
|
76
79
|
| `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
|
|
77
80
|
| `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
|
|
78
|
-
| `nstack fitness` | Manifests, boundaries between modules, skills, plan |
|
|
81
|
+
| `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene |
|
|
79
82
|
| `nstack pr-scope` | One PR = one module, review budget |
|
|
83
|
+
| `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
|
|
84
|
+
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
80
85
|
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
81
86
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
82
87
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
@@ -3,14 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
-
import
|
|
7
|
-
import subprocess
|
|
6
|
+
import json
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
|
|
10
|
-
from napkinstack import __version__, discovery, doctor, modules, pull_request, skills
|
|
11
|
-
from napkinstack.fitness import boundaries, manifests, plan
|
|
12
|
-
|
|
13
|
-
PACKAGE = Path(__file__).resolve().parent
|
|
9
|
+
from napkinstack import __version__, compat, discovery, doctor, modules, provenance, pull_request, skills
|
|
10
|
+
from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
|
|
14
11
|
|
|
15
12
|
|
|
16
13
|
def _root(value: str) -> Path:
|
|
@@ -20,13 +17,19 @@ def _root(value: str) -> Path:
|
|
|
20
17
|
return root
|
|
21
18
|
|
|
22
19
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
def _modules(args: argparse.Namespace) -> int:
|
|
21
|
+
found = modules.listing(args.root, args.changed_since)
|
|
22
|
+
if found is None:
|
|
23
|
+
print(f"FAIL [modules] base '{args.changed_since}' not found in {args.root}.\n"
|
|
24
|
+
" Action: fetch the history (fetch-depth: 0), or pass an existing commit.")
|
|
25
|
+
return 1
|
|
26
|
+
print(json.dumps(found) if args.json else "\n".join(f"{m['name']}\t{m['folder']}" for m in found))
|
|
27
|
+
return 0
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
def _fitness(root: Path) -> int:
|
|
29
|
-
results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True),
|
|
31
|
+
results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True),
|
|
32
|
+
plan.run(root), hygiene.run(root)]
|
|
30
33
|
return 1 if any(results) else 0
|
|
31
34
|
|
|
32
35
|
|
|
@@ -57,15 +60,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
57
60
|
parser = argparse.ArgumentParser(prog="nstack", description="NapkinStack engine.")
|
|
58
61
|
parser.add_argument("--version", action="version", version=f"nstack {__version__}")
|
|
59
62
|
sub = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
60
|
-
_add(sub, "manifests", "manifests, lifecycles, deprecations (M1-
|
|
63
|
+
_add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M10)",
|
|
61
64
|
lambda a: manifests.run(a.root))
|
|
62
|
-
_add(sub, "boundaries", "declared graph against real graph (B1-
|
|
65
|
+
_add(sub, "boundaries", "declared graph against real graph (B1-B7)",
|
|
63
66
|
lambda a: boundaries.run(a.root))
|
|
64
67
|
_add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
|
|
65
68
|
sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
|
|
66
69
|
lambda a: skills.run(a.root, check_only=a.check))
|
|
67
70
|
sk.add_argument("--check", action="store_true", help="check without writing")
|
|
68
|
-
_add(sub, "
|
|
71
|
+
_add(sub, "hygiene", "no path from one person's machine in a tracked file (H1)",
|
|
72
|
+
lambda a: hygiene.run(a.root))
|
|
73
|
+
_add(sub, "fitness", "manifests + boundaries + skills + plan + hygiene",
|
|
69
74
|
lambda a: _fitness(a.root))
|
|
70
75
|
_add(sub, "doctor", "diagnoses the workstation and the GitHub settings, read-only (PDR-0001)",
|
|
71
76
|
lambda a: doctor.run(a.root))
|
|
@@ -83,11 +88,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
83
88
|
("e2e", "end-to-end scenarios of one module, or of all (commands.e2e)")):
|
|
84
89
|
vb = _add(sub, verb, help_text, lambda a, v=verb: modules.run_verb(a.root, v, a.module))
|
|
85
90
|
vb.add_argument("module", nargs="?", help="module name (default: all)")
|
|
91
|
+
cp = _add(sub, "compat", "a frozen contract version changes only with a proof (V1, commands.compat)",
|
|
92
|
+
lambda a: compat.run(a.root, a.module, a.base))
|
|
93
|
+
cp.add_argument("module", nargs="?", help="module holding the contracts (default: all)")
|
|
94
|
+
cp.add_argument("--base", default="origin/main")
|
|
86
95
|
rn = _add(sub, "run", "starts a module locally (commands.run)",
|
|
87
96
|
lambda a: modules.run_verb(a.root, "run", a.module))
|
|
88
97
|
rn.add_argument("module")
|
|
98
|
+
md = _add(sub, "modules", "the project's modules, or those with a file changed since a base", _modules)
|
|
99
|
+
md.add_argument("--changed-since", metavar="BASE", help="only the modules with a file changed since BASE")
|
|
100
|
+
md.add_argument("--json", action="store_true", help="a JSON list, for CI")
|
|
89
101
|
ps = _add(sub, "pr-scope", "one PR = one module, review budget (P1-P2)",
|
|
90
|
-
lambda a:
|
|
102
|
+
lambda a: pr_scope.run(a.root, a.base))
|
|
91
103
|
ps.add_argument("--base", default="origin/main")
|
|
92
104
|
pc = _add(sub, "pr-check", "test sheet and cycle, read from the pull request description (T1-T5, K1-K4)",
|
|
93
105
|
lambda a: pull_request.run(a.root, a.base, a.body_file))
|
|
@@ -111,6 +123,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
111
123
|
return parser
|
|
112
124
|
|
|
113
125
|
|
|
126
|
+
JUDGES = {"manifests", "boundaries", "plan", "skills", "hygiene", "fitness", "doctor", "pr-scope",
|
|
127
|
+
"pr-check", "compat"} # the commands whose verdict depends on the framework's rules
|
|
128
|
+
|
|
129
|
+
|
|
114
130
|
def main(argv: list[str] | None = None) -> int:
|
|
115
131
|
args = build_parser().parse_args(argv)
|
|
132
|
+
if args.command in JUDGES:
|
|
133
|
+
print(provenance.judged_by(args.root), flush=True)
|
|
116
134
|
return args.func(args)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contract versions (D30): a merged version that someone relies on changes only with a proof
|
|
3
|
+
that the change is additive (docs/os/03-contracts.md §3 and §4).
|
|
4
|
+
|
|
5
|
+
Rule:
|
|
6
|
+
V1 a frozen contract version changes only when the compatibility command of the module
|
|
7
|
+
holding it, commands.compat, proves the change compatible
|
|
8
|
+
|
|
9
|
+
A version is frozen when, at the base, a module consumes it or its producer declares it
|
|
10
|
+
stable or deprecated. Both the freeze and the proof are read at the base: a pull request can
|
|
11
|
+
neither thaw what it changes nor replace the command that judges it — a new proof is merged
|
|
12
|
+
on its own first.
|
|
13
|
+
An experimental version nobody consumes is free to change — nobody can break. Its files are
|
|
14
|
+
those under provides[].path; a new version beside it changes nothing merged. A version still
|
|
15
|
+
provided at the head is judged wherever it now lives: moving it is no removal (D36).
|
|
16
|
+
|
|
17
|
+
The command is the project's (P1: no format assumed): an OpenAPI, protobuf or JSON Schema
|
|
18
|
+
comparator, named in docs/tooling-profile.md. It runs from the holding module's folder with
|
|
19
|
+
NSTACK_CONTRACT the contract's name catalog-api
|
|
20
|
+
NSTACK_VERSION the version v1
|
|
21
|
+
NSTACK_BASE_PATH the version as merged, extracted to a temporary folder
|
|
22
|
+
NSTACK_HEAD_PATH the version as changed, in the working tree
|
|
23
|
+
and exits 0 when the change is compatible.
|
|
24
|
+
|
|
25
|
+
Usage : nstack compat [module] [--root ROOT] [--base BASE]
|
|
26
|
+
Output: 0 when every changed frozen version is proven compatible, 1 otherwise.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import io
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import tarfile
|
|
35
|
+
import tempfile
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import yaml
|
|
39
|
+
|
|
40
|
+
from napkinstack.fitness.manifests import MODULE_DIRS, changed_files, contract_entries, find_manifests
|
|
41
|
+
|
|
42
|
+
FROZEN_STABILITIES = {"stable", "deprecated"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
46
|
+
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load(text: str) -> dict:
|
|
50
|
+
try:
|
|
51
|
+
data = yaml.safe_load(text) or {}
|
|
52
|
+
except yaml.YAMLError:
|
|
53
|
+
return {}
|
|
54
|
+
return data if isinstance(data, dict) else {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def frozen_versions(root: Path, base: str) -> dict[tuple[str, str], tuple[str, str]]:
|
|
58
|
+
"""(contract, version) -> (path, why it is frozen), from the manifests at the base."""
|
|
59
|
+
manifests = [_load(_git(root, "show", f"{base}:{path}").stdout)
|
|
60
|
+
for path in _git(root, "ls-tree", "-r", "--name-only", base).stdout.split()
|
|
61
|
+
if path.endswith("/MANIFEST.yaml") and path.split("/")[0] in MODULE_DIRS
|
|
62
|
+
and len(path.split("/")) in (2, 3)]
|
|
63
|
+
consumers: dict[tuple[str, str], list[str]] = {}
|
|
64
|
+
for data in manifests:
|
|
65
|
+
name = (data.get("module") or {}).get("name") if isinstance(data.get("module"), dict) else None
|
|
66
|
+
for entry in contract_entries(data, "consumes"):
|
|
67
|
+
consumers.setdefault((entry.get("contract"), entry.get("version")), []).append(str(name))
|
|
68
|
+
frozen = {}
|
|
69
|
+
for data in manifests:
|
|
70
|
+
for entry in contract_entries(data, "provides"):
|
|
71
|
+
key, path = (entry.get("contract"), entry.get("version")), entry.get("path")
|
|
72
|
+
if not isinstance(path, str) or not all(isinstance(part, str) for part in key):
|
|
73
|
+
continue
|
|
74
|
+
if key in consumers:
|
|
75
|
+
frozen[key] = (path.strip("/"), f"consumed by {', '.join(sorted(consumers[key]))}")
|
|
76
|
+
elif entry.get("stability") in FROZEN_STABILITIES:
|
|
77
|
+
frozen[key] = (path.strip("/"), entry["stability"])
|
|
78
|
+
return frozen
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def provided_now(root: Path) -> dict[tuple[str, str], str]:
|
|
82
|
+
"""(contract, version) -> path, from the manifests at the head."""
|
|
83
|
+
now = {}
|
|
84
|
+
for manifest in find_manifests(root):
|
|
85
|
+
for entry in contract_entries(_load(manifest.read_text(encoding="utf-8")), "provides"):
|
|
86
|
+
key, path = (entry.get("contract"), entry.get("version")), entry.get("path")
|
|
87
|
+
if isinstance(path, str) and all(isinstance(part, str) for part in key):
|
|
88
|
+
now[key] = path.strip("/")
|
|
89
|
+
return now
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _holder(root: Path, path: str) -> tuple[str, Path] | None:
|
|
93
|
+
"""The module whose folder holds `path`: its name and folder."""
|
|
94
|
+
for manifest in sorted(find_manifests(root), key=lambda m: -len(m.parent.parts)):
|
|
95
|
+
folder = manifest.parent.relative_to(root).as_posix()
|
|
96
|
+
if path == folder or path.startswith(f"{folder}/"):
|
|
97
|
+
return manifest.parent.name, manifest.parent
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _extract(root: Path, base: str, path: str, into: Path) -> Path:
|
|
102
|
+
archive = subprocess.run(["git", "archive", "--format=tar", base, "--", path], cwd=root,
|
|
103
|
+
capture_output=True, check=True).stdout
|
|
104
|
+
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
|
|
105
|
+
tar.extractall(into, filter="data")
|
|
106
|
+
return into / path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run(root: Path, module: str | None, base: str) -> int:
|
|
110
|
+
if _git(root, "rev-parse", "--verify", "--quiet", f"{base}^{{commit}}").returncode:
|
|
111
|
+
print(f"FAIL [V1] base '{base}' not found: the merged contract versions cannot be read.\n"
|
|
112
|
+
" Action: fetch the history (fetch-depth: 0), or pass an existing commit.")
|
|
113
|
+
return 1
|
|
114
|
+
fork = _git(root, "merge-base", base, "HEAD").stdout.strip() or base
|
|
115
|
+
changed = changed_files(root, fork)
|
|
116
|
+
now = provided_now(root)
|
|
117
|
+
failures, checked = [], []
|
|
118
|
+
for (contract, version), (path, why) in sorted(frozen_versions(root, fork).items()):
|
|
119
|
+
target = now.get((contract, version))
|
|
120
|
+
if not any(file == place or file.startswith(f"{place}/") for file in changed
|
|
121
|
+
for place in {path, target} if place):
|
|
122
|
+
continue
|
|
123
|
+
label = f"{contract} {version} ({why})"
|
|
124
|
+
if target is None:
|
|
125
|
+
checked.append(f"{label}: no longer provided — its consumers are checked by B6")
|
|
126
|
+
continue
|
|
127
|
+
if target != path:
|
|
128
|
+
label += f", moved to {target}"
|
|
129
|
+
holder = _holder(root, target)
|
|
130
|
+
if holder is None:
|
|
131
|
+
failures.append(f"[V1] {label} changed at '{target}', which no module holds: nothing declares "
|
|
132
|
+
"how its versions are compared.\n Action: keep contracts under contracts/ "
|
|
133
|
+
"(docs/os/03-contracts.md §2).")
|
|
134
|
+
continue
|
|
135
|
+
if module is not None and holder[0] != module:
|
|
136
|
+
continue
|
|
137
|
+
if not (root / target).exists():
|
|
138
|
+
failures.append(f"[V1] {label}: provided at '{target}', which holds no document (B7).\n"
|
|
139
|
+
" Action: commit the version there, or correct provides[].path.")
|
|
140
|
+
continue
|
|
141
|
+
if not _git(root, "ls-tree", "-r", "--name-only", fork, "--", path).stdout.strip():
|
|
142
|
+
checked.append(f"{label}: no document at the base, nothing merged to compare")
|
|
143
|
+
continue
|
|
144
|
+
name, folder = holder
|
|
145
|
+
where = f"{folder.relative_to(root).as_posix()}/MANIFEST.yaml"
|
|
146
|
+
commands = _load(_git(root, "show", f"{fork}:{where}").stdout).get("commands")
|
|
147
|
+
command = commands.get("compat") if isinstance(commands, dict) else None
|
|
148
|
+
if not command:
|
|
149
|
+
failures.append(f"[V1] {label} changed, and no merged compatibility command proves the "
|
|
150
|
+
f"change compatible.\n Action: declare commands.compat in {where} in a "
|
|
151
|
+
"pull request of its own (docs/tooling-profile.md), or publish the change as "
|
|
152
|
+
"a new version beside it (docs/os/03-contracts.md §4).")
|
|
153
|
+
continue
|
|
154
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
155
|
+
env = {**os.environ, "NSTACK_CONTRACT": contract, "NSTACK_VERSION": version,
|
|
156
|
+
"NSTACK_BASE_PATH": str(_extract(root, fork, path, Path(temporary))),
|
|
157
|
+
"NSTACK_HEAD_PATH": str(root / target)}
|
|
158
|
+
print(f"-> {name}: {command}", flush=True)
|
|
159
|
+
code = subprocess.run(command, shell=True, cwd=folder, env=env).returncode
|
|
160
|
+
if code:
|
|
161
|
+
failures.append(f"[V1] {label}: `{command}` exited with {code}, a breaking change.\n"
|
|
162
|
+
" Action: publish it as a new version beside it, then migrate its "
|
|
163
|
+
"consumers (expand/contract, docs/os/03-contracts.md §4).")
|
|
164
|
+
else:
|
|
165
|
+
checked.append(f"{label}: proven compatible")
|
|
166
|
+
for line in checked:
|
|
167
|
+
print(f"Contract version {line}.")
|
|
168
|
+
for failure in failures:
|
|
169
|
+
print(f"FAIL {failure}")
|
|
170
|
+
if failures:
|
|
171
|
+
return 1
|
|
172
|
+
if not checked:
|
|
173
|
+
print("No frozen contract version changed.")
|
|
174
|
+
return 0
|
|
@@ -10,14 +10,16 @@ fine-grained, limited to the repository, Administration: read permission. Withou
|
|
|
10
10
|
or when the API refuses a read, the setting is "not verified", never compliant. No write.
|
|
11
11
|
|
|
12
12
|
Rules:
|
|
13
|
-
L1 nstack installed at the project version (_commit in .copier-answers.yml)
|
|
13
|
+
L1 nstack installed at the project version (_commit in .copier-answers.yml), both
|
|
14
|
+
published: an unpublished framework is a gap, never compliance (PDR-0005)
|
|
14
15
|
L2 git and pre-commit available
|
|
15
16
|
L3 pre-commit hooks installed
|
|
16
17
|
L4 PRODUCT.md absent: that is NapkinStack's own development context (R6)
|
|
17
18
|
L5 README personalised: the presentation sentence is written
|
|
18
19
|
L6 CODEOWNERS starts with a default owner: the code owner review covers every path
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
L7 the template source reachable by anyone: a repository, not a path on one machine
|
|
21
|
+
G1-G13 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
|
|
22
|
+
repository, and on a private one G1-G5, G12 and G13 name the GitHub plan or option required
|
|
21
23
|
|
|
22
24
|
Usage : nstack doctor [--root ROOT]
|
|
23
25
|
Output: 0 when everything is verified and compliant, 1 otherwise.
|
|
@@ -38,16 +40,18 @@ from pathlib import Path
|
|
|
38
40
|
|
|
39
41
|
import yaml
|
|
40
42
|
|
|
41
|
-
from napkinstack import __version__
|
|
43
|
+
from napkinstack import __version__, provenance
|
|
42
44
|
from napkinstack.project import ANSWERS
|
|
43
45
|
|
|
44
46
|
OK, GAP, UNKNOWN, NOT_APPLICABLE = "OK", "FAIL", "NOT VERIFIED", "NOT APPLICABLE"
|
|
45
47
|
API_VERSION = "2026-03-10"
|
|
46
48
|
PLACEHOLDER = "<One sentence: what this project does.>"
|
|
47
|
-
JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle"
|
|
49
|
+
JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
|
|
50
|
+
"Module checks")
|
|
48
51
|
THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skeleton workflows
|
|
49
52
|
LABELS = ("cross-module", "over-budget", "out-of-cycle")
|
|
50
|
-
PUBLISHED =
|
|
53
|
+
PUBLISHED = provenance.PUBLISHED # a published version is a vX.Y.Z tag (ADR-0002), as CI installs it
|
|
54
|
+
REMOTE = re.compile(r"https://|ssh://|git@[^:/]+:|gh:|gl:") # as .nstack/install-engine.sh reads them
|
|
51
55
|
|
|
52
56
|
RULESET = "Settings → Rules → Rulesets, main branch"
|
|
53
57
|
SECURITY = "Settings → Advanced Security"
|
|
@@ -60,7 +64,8 @@ CHECKLIST = [ # (rule, setting, action)
|
|
|
60
64
|
("G2", "At least 1 approving review", f"{RULESET}: at least 1 approval required"),
|
|
61
65
|
("G3", "Code owner review required", f"{RULESET}: require Code Owners review"),
|
|
62
66
|
("G4", "Required checks: " + ", ".join(f"`{job}`" for job in JOBS),
|
|
63
|
-
f"{RULESET}: require these status checks"
|
|
67
|
+
f"{RULESET}: require these status checks. GitHub offers a check only once it has run: "
|
|
68
|
+
"open a first pull request, then select them"),
|
|
64
69
|
("G5", "Secret Protection and push protection",
|
|
65
70
|
f"{SECURITY}: enable Secret Protection and push protection"),
|
|
66
71
|
("G6", "Private vulnerability reporting, public repository (the `SECURITY.md` channel)",
|
|
@@ -76,13 +81,15 @@ CHECKLIST = [ # (rule, setting, action)
|
|
|
76
81
|
"Issues → Labels: create " + ", ".join(LABELS[:-1]) + f" and {LABELS[-1]}"),
|
|
77
82
|
("G12", "Bypass list empty: nobody merges around the rules, administrators included",
|
|
78
83
|
f"{RULESET}: remove every bypass actor"),
|
|
84
|
+
("G13", "Stale approvals dismissed when new commits are pushed",
|
|
85
|
+
f"{RULESET}: dismiss stale pull request approvals when new commits are pushed"),
|
|
79
86
|
]
|
|
80
87
|
|
|
81
88
|
# Settings specific to public repositories, and settings a private one pays for
|
|
82
89
|
# (GitHub documentation, 2026-09-15).
|
|
83
90
|
PUBLIC_ONLY = {"G6": "private vulnerability reporting only exists for a public repository; "
|
|
84
91
|
"state an internal channel in SECURITY.md"}
|
|
85
|
-
PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12"),
|
|
92
|
+
PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12", "G13"),
|
|
86
93
|
"Private repository: rulesets require the GitHub Team plan (organisation) "
|
|
87
94
|
"or Pro (personal account); without it, nothing blocks the merge.")
|
|
88
95
|
PRIVATE_PLAN["G5"] = ("Private repository: Secret Protection is a paid option; without it, only the "
|
|
@@ -93,6 +100,10 @@ class NotVerified(Exception):
|
|
|
93
100
|
"""Setting unreadable: token, permission or network."""
|
|
94
101
|
|
|
95
102
|
|
|
103
|
+
class Gap(Exception):
|
|
104
|
+
"""Setting missing, with an action more precise than the checklist's."""
|
|
105
|
+
|
|
106
|
+
|
|
96
107
|
class GitHub:
|
|
97
108
|
"""Reads of the GitHub REST API, cached; never a write."""
|
|
98
109
|
|
|
@@ -163,7 +174,8 @@ def _no_bypass(client: GitHub) -> bool:
|
|
|
163
174
|
"""G12: every ruleset applying to main has an empty bypass list (ADR-0004)."""
|
|
164
175
|
rulesets = {rule["ruleset_id"] for rule in client.get("/rules/branches/main") if rule.get("ruleset_id")}
|
|
165
176
|
if not rulesets:
|
|
166
|
-
|
|
177
|
+
raise Gap(f"no ruleset applies to main, so there is no bypass list to empty. {RULESET}: "
|
|
178
|
+
"create the ruleset first (G1)")
|
|
167
179
|
for ruleset in sorted(rulesets):
|
|
168
180
|
actors = client.get(f"/rulesets/{ruleset}?includes_parents=true").get("bypass_actors")
|
|
169
181
|
if actors is None:
|
|
@@ -188,6 +200,7 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
|
|
|
188
200
|
"G10": _workflows,
|
|
189
201
|
"G11": lambda c: all(c.get(f"/labels/{label}", missing=True) is not None for label in LABELS),
|
|
190
202
|
"G12": _no_bypass,
|
|
203
|
+
"G13": lambda c: _parameters(c, "pull_request").get("dismiss_stale_reviews_on_push") is True,
|
|
191
204
|
}
|
|
192
205
|
|
|
193
206
|
|
|
@@ -210,8 +223,13 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
|
|
|
210
223
|
install = f'uv tool install "napkinstack=={project}" --with-executables-from pre-commit'
|
|
211
224
|
results = []
|
|
212
225
|
|
|
226
|
+
_, origin = provenance.engine()
|
|
213
227
|
if not PUBLISHED.fullmatch(commit):
|
|
214
|
-
l1 = (
|
|
228
|
+
l1 = (GAP, f"The project is pinned to an unpublished framework ({commit or 'unknown'}): its "
|
|
229
|
+
"verdicts come from rules nobody has published (PDR-0005).\nAction: once the change it "
|
|
230
|
+
"waits for is released, nstack update --ref vX.Y.Z.")
|
|
231
|
+
elif origin is not None:
|
|
232
|
+
l1 = (GAP, f"The nstack running is unpublished ({origin}) (PDR-0005).\nAction: {install}")
|
|
215
233
|
elif project != __version__:
|
|
216
234
|
l1 = (GAP, f"nstack {__version__} installed, project on {project} (PDR-0001 R3).\nAction: {install}")
|
|
217
235
|
else:
|
|
@@ -246,6 +264,12 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
|
|
|
246
264
|
"Action: write the sentence that presents the project." if untouched else ""))
|
|
247
265
|
|
|
248
266
|
results.append(("L6", "CODEOWNERS starts with a default owner", *_default_owner(root)))
|
|
267
|
+
|
|
268
|
+
source = str(answers.get("_src_path") or "")
|
|
269
|
+
local = not REMOTE.match(source)
|
|
270
|
+
results.append(("L7", "Template source reachable by anyone", GAP if local else OK,
|
|
271
|
+
f"The project was generated from '{source}', a path on one machine: nobody else can "
|
|
272
|
+
"update it (PDR-0005).\nAction: nstack update, from the published source." if local else ""))
|
|
249
273
|
return results
|
|
250
274
|
|
|
251
275
|
|
|
@@ -297,6 +321,8 @@ def run(root: Path) -> int:
|
|
|
297
321
|
status, detail = (OK, "") if CHECKS[rule](client) else (GAP, f"Action: {action}")
|
|
298
322
|
except NotVerified as reason:
|
|
299
323
|
status, detail = UNKNOWN, f"Reason: {reason}"
|
|
324
|
+
except Gap as action:
|
|
325
|
+
status, detail = GAP, f"Action: {action}"
|
|
300
326
|
if private and status != OK and rule in PRIVATE_PLAN:
|
|
301
327
|
detail += f"\n{PRIVATE_PLAN[rule]}"
|
|
302
328
|
results.append((rule, setting, status, detail))
|