napkinstack 0.3.0__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.
Files changed (29) hide show
  1. {napkinstack-0.3.0 → napkinstack-0.4.0}/PKG-INFO +11 -5
  2. {napkinstack-0.3.0 → napkinstack-0.4.0}/README.md +10 -4
  3. {napkinstack-0.3.0 → napkinstack-0.4.0}/pyproject.toml +1 -1
  4. {napkinstack-0.3.0 → napkinstack-0.4.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/cli.py +32 -14
  6. napkinstack-0.4.0/src/napkinstack/compat.py +174 -0
  7. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/doctor.py +36 -10
  8. napkinstack-0.4.0/src/napkinstack/fitness/boundaries.py +348 -0
  9. napkinstack-0.4.0/src/napkinstack/fitness/hygiene.py +97 -0
  10. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/fitness/manifests.py +71 -5
  11. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/fitness/plan.py +19 -6
  12. napkinstack-0.4.0/src/napkinstack/fitness/pr_scope.py +89 -0
  13. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/modules.py +53 -9
  14. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/project.py +1 -1
  15. napkinstack-0.4.0/src/napkinstack/provenance.py +62 -0
  16. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/pull_request.py +115 -33
  17. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/templates/module/MANIFEST.yaml +10 -8
  18. napkinstack-0.3.0/src/napkinstack/fitness/boundaries.py +0 -222
  19. napkinstack-0.3.0/src/napkinstack/fitness/pr_scope.sh +0 -79
  20. {napkinstack-0.3.0 → napkinstack-0.4.0}/LICENSE +0 -0
  21. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/__init__.py +0 -0
  22. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/discovery.py +0 -0
  23. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/fitness/__init__.py +0 -0
  24. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/skills.py +0 -0
  25. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  26. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/templates/module/README.md +0 -0
  27. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
  28. {napkinstack-0.3.0 → napkinstack-0.4.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  29. {napkinstack-0.3.0 → 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.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,10 +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.2.0, published** ([PyPI](https://pypi.org/project/napkinstack/)), English
24
- > throughout. A first pilot project, private, starts from it and puts it to the test before
25
- > the rest. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
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),
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),
27
31
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
28
32
 
29
33
  ## A project's journey
@@ -89,8 +93,10 @@ the commit of this repository
89
93
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
90
94
  | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
91
95
  | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
92
- | `nstack fitness` | Manifests, boundaries between modules, skills, plan |
96
+ | `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene |
93
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 |
94
100
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
95
101
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
96
102
  | `nstack skills` | Exposes the playbooks as skills for the agent |
@@ -5,10 +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.2.0, published** ([PyPI](https://pypi.org/project/napkinstack/)), English
9
- > throughout. A first pilot project, private, starts from it and puts it to the test before
10
- > the rest. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
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),
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),
12
16
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
13
17
 
14
18
  ## A project's journey
@@ -74,8 +78,10 @@ the commit of this repository
74
78
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
75
79
  | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
76
80
  | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
77
- | `nstack fitness` | Manifests, boundaries between modules, skills, plan |
81
+ | `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene |
78
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 |
79
85
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
80
86
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
81
87
  | `nstack skills` | Exposes the playbooks as skills for the agent |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -3,14 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
- import os
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 _script(relative: str, *args: str, root: Path) -> int:
24
- env = {**os.environ, "NSTACK_ROOT": str(root)}
25
- return subprocess.run(["bash", str(PACKAGE / relative), *args], cwd=root, env=env).returncode
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), plan.run(root)]
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-M9)",
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-B5)",
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, "fitness", "manifests + boundaries + skills + plan",
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: _script("fitness/pr_scope.sh", a.base, root=a.root))
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
- G1-G12 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
20
- repository, and on a private one G1-G5 and G12 name the GitHub plan or option required
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 = re.compile(r"v\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?")
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
- return False
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 = (UNKNOWN, f"Reason: the project comes from an unpublished version ({commit or 'unknown'}).")
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))