napkinstack 0.3.1__tar.gz → 0.5.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 (30) hide show
  1. {napkinstack-0.3.1 → napkinstack-0.5.0}/PKG-INFO +33 -9
  2. {napkinstack-0.3.1 → napkinstack-0.5.0}/README.md +32 -8
  3. {napkinstack-0.3.1 → napkinstack-0.5.0}/pyproject.toml +1 -1
  4. {napkinstack-0.3.1 → napkinstack-0.5.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/cli.py +37 -14
  6. napkinstack-0.5.0/src/napkinstack/compat.py +188 -0
  7. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/doctor.py +74 -12
  8. napkinstack-0.5.0/src/napkinstack/fitness/boundaries.py +348 -0
  9. napkinstack-0.5.0/src/napkinstack/fitness/hygiene.py +97 -0
  10. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/fitness/manifests.py +71 -5
  11. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/fitness/plan.py +19 -6
  12. napkinstack-0.5.0/src/napkinstack/fitness/pr_scope.py +89 -0
  13. napkinstack-0.5.0/src/napkinstack/landed.py +122 -0
  14. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/modules.py +58 -9
  15. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/project.py +1 -1
  16. napkinstack-0.5.0/src/napkinstack/provenance.py +70 -0
  17. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/pull_request.py +89 -23
  18. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/templates/module/MANIFEST.yaml +10 -8
  19. napkinstack-0.3.1/src/napkinstack/fitness/boundaries.py +0 -222
  20. napkinstack-0.3.1/src/napkinstack/fitness/pr_scope.sh +0 -79
  21. {napkinstack-0.3.1 → napkinstack-0.5.0}/LICENSE +0 -0
  22. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/__init__.py +0 -0
  23. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/discovery.py +0 -0
  24. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/fitness/__init__.py +0 -0
  25. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/skills.py +0 -0
  26. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  27. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/templates/module/README.md +0 -0
  28. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
  29. {napkinstack-0.3.1 → napkinstack-0.5.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  30. {napkinstack-0.3.1 → napkinstack-0.5.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.1
3
+ Version: 0.5.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,18 @@ 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.3.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): test sheet,
24
- > framing and bounded cycles. A first pilot project, private, starts from it and puts it to
25
- > the test before 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. Proved on the project that exposed the defects: eight probes
28
+ > replayed, each refused or accepted as announced. A first pilot project, private, starts from
29
+ > v0.5.0, which closes a way around the contract check found while proving it (D44) and says
30
+ > what holds on a repository no plan lets the forge guard (PDR-0006).
31
+ > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
26
32
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
27
33
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
34
+ > [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
28
35
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
29
36
 
30
37
  ## A project's journey
@@ -75,10 +82,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
75
82
  nstack init my-project
76
83
  ```
77
84
 
78
- Each project then pins its version and changes it through `nstack update`. Every published
79
- version carries a provenance attestation, visible on PyPI, tying it to the workflow and
80
- the commit of this repository
81
- ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md)).
85
+ To try it without installing anything, or to install it from this repository instead of the
86
+ registry — always pinned to a release tag:
87
+
88
+ ```bash
89
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.4.0" nstack init my-project
90
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.4.0"
91
+ ```
92
+
93
+ **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
94
+ `vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
95
+ repository. The forge distributes that same tag's source, which is a supported way in and
96
+ never a published one — a tag can be moved, and carries no attestation — so a run installed
97
+ that way says where its rules came from, on every line that judges
98
+ ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
99
+ [PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
100
+ unaffected: created from either channel at the same tag, it records the same version and its
101
+ CI installs from the registry.
102
+
103
+ Each project then pins its version and changes it through `nstack update`.
82
104
 
83
105
  ## The commands
84
106
 
@@ -90,8 +112,10 @@ the commit of this repository
90
112
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
91
113
  | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
92
114
  | `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 |
115
+ | `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene |
94
116
  | `nstack pr-scope` | One PR = one module, review budget |
117
+ | `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
118
+ | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
95
119
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
96
120
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
97
121
  | `nstack skills` | Exposes the playbooks as skills for the agent |
@@ -5,11 +5,18 @@ 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.3.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): test sheet,
9
- > framing and bounded cycles. A first pilot project, private, starts from it and puts it to
10
- > the test before 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. Proved on the project that exposed the defects: eight probes
13
+ > replayed, each refused or accepted as announced. A first pilot project, private, starts from
14
+ > v0.5.0, which closes a way around the contract check found while proving it (D44) and says
15
+ > what holds on a repository no plan lets the forge guard (PDR-0006).
16
+ > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
11
17
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
12
18
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
19
+ > [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
13
20
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
14
21
 
15
22
  ## A project's journey
@@ -60,10 +67,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
60
67
  nstack init my-project
61
68
  ```
62
69
 
63
- Each project then pins its version and changes it through `nstack update`. Every published
64
- version carries a provenance attestation, visible on PyPI, tying it to the workflow and
65
- the commit of this repository
66
- ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md)).
70
+ To try it without installing anything, or to install it from this repository instead of the
71
+ registry — always pinned to a release tag:
72
+
73
+ ```bash
74
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.4.0" nstack init my-project
75
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.4.0"
76
+ ```
77
+
78
+ **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
79
+ `vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
80
+ repository. The forge distributes that same tag's source, which is a supported way in and
81
+ never a published one — a tag can be moved, and carries no attestation — so a run installed
82
+ that way says where its rules came from, on every line that judges
83
+ ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
84
+ [PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
85
+ unaffected: created from either channel at the same tag, it records the same version and its
86
+ CI installs from the registry.
87
+
88
+ Each project then pins its version and changes it through `nstack update`.
67
89
 
68
90
  ## The commands
69
91
 
@@ -75,8 +97,10 @@ the commit of this repository
75
97
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
76
98
  | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
77
99
  | `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 |
100
+ | `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene |
79
101
  | `nstack pr-scope` | One PR = one module, review budget |
102
+ | `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
103
+ | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
80
104
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
81
105
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
82
106
  | `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.1"
3
+ version = "0.5.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.1"
3
+ version = "0.5.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,12 @@
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, landed, modules, provenance,
10
+ pull_request, skills)
11
+ from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
14
12
 
15
13
 
16
14
  def _root(value: str) -> Path:
@@ -20,13 +18,19 @@ def _root(value: str) -> Path:
20
18
  return root
21
19
 
22
20
 
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
21
+ def _modules(args: argparse.Namespace) -> int:
22
+ found = modules.listing(args.root, args.changed_since)
23
+ if found is None:
24
+ print(f"FAIL [modules] base '{args.changed_since}' not found in {args.root}.\n"
25
+ " Action: fetch the history (fetch-depth: 0), or pass an existing commit.")
26
+ return 1
27
+ print(json.dumps(found) if args.json else "\n".join(f"{m['name']}\t{m['folder']}" for m in found))
28
+ return 0
26
29
 
27
30
 
28
31
  def _fitness(root: Path) -> int:
29
- results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True), plan.run(root)]
32
+ results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True),
33
+ plan.run(root), hygiene.run(root)]
30
34
  return 1 if any(results) else 0
31
35
 
32
36
 
@@ -57,15 +61,17 @@ def build_parser() -> argparse.ArgumentParser:
57
61
  parser = argparse.ArgumentParser(prog="nstack", description="NapkinStack engine.")
58
62
  parser.add_argument("--version", action="version", version=f"nstack {__version__}")
59
63
  sub = parser.add_subparsers(dest="command", required=True, metavar="command")
60
- _add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M9)",
64
+ _add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M10)",
61
65
  lambda a: manifests.run(a.root))
62
- _add(sub, "boundaries", "declared graph against real graph (B1-B5)",
66
+ _add(sub, "boundaries", "declared graph against real graph (B1-B7)",
63
67
  lambda a: boundaries.run(a.root))
64
68
  _add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
65
69
  sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
66
70
  lambda a: skills.run(a.root, check_only=a.check))
67
71
  sk.add_argument("--check", action="store_true", help="check without writing")
68
- _add(sub, "fitness", "manifests + boundaries + skills + plan",
72
+ _add(sub, "hygiene", "no path from one person's machine in a tracked file (H1)",
73
+ lambda a: hygiene.run(a.root))
74
+ _add(sub, "fitness", "manifests + boundaries + skills + plan + hygiene",
69
75
  lambda a: _fitness(a.root))
70
76
  _add(sub, "doctor", "diagnoses the workstation and the GitHub settings, read-only (PDR-0001)",
71
77
  lambda a: doctor.run(a.root))
@@ -83,16 +89,27 @@ def build_parser() -> argparse.ArgumentParser:
83
89
  ("e2e", "end-to-end scenarios of one module, or of all (commands.e2e)")):
84
90
  vb = _add(sub, verb, help_text, lambda a, v=verb: modules.run_verb(a.root, v, a.module))
85
91
  vb.add_argument("module", nargs="?", help="module name (default: all)")
92
+ cp = _add(sub, "compat", "a frozen contract version changes only with a proof (V1, commands.compat)",
93
+ lambda a: compat.run(a.root, a.module, a.base))
94
+ cp.add_argument("module", nargs="?", help="module holding the contracts (default: all)")
95
+ cp.add_argument("--base", default="origin/main")
86
96
  rn = _add(sub, "run", "starts a module locally (commands.run)",
87
97
  lambda a: modules.run_verb(a.root, "run", a.module))
88
98
  rn.add_argument("module")
99
+ md = _add(sub, "modules", "the project's modules, or those with a file changed since a base", _modules)
100
+ md.add_argument("--changed-since", metavar="BASE", help="only the modules with a file changed since BASE")
101
+ md.add_argument("--json", action="store_true", help="a JSON list, for CI")
89
102
  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))
103
+ lambda a: pr_scope.run(a.root, a.base))
91
104
  ps.add_argument("--base", default="origin/main")
92
105
  pc = _add(sub, "pr-check", "test sheet and cycle, read from the pull request description (T1-T5, K1-K4)",
93
106
  lambda a: pull_request.run(a.root, a.base, a.body_file))
94
107
  pc.add_argument("--base", default="origin/main")
95
108
  pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
109
+ ld = _add(sub, "landed", "what reached this branch outside a pull request, recorded (W1)",
110
+ lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
111
+ ld.add_argument("--span", default="HEAD~1..HEAD",
112
+ help="the commits to read, BEFORE..AFTER (default: the last commit)")
96
113
  ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent (PDR-0002)",
97
114
  lambda a: discovery.run(a.root, a.idea))
98
115
  ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
@@ -111,6 +128,12 @@ def build_parser() -> argparse.ArgumentParser:
111
128
  return parser
112
129
 
113
130
 
131
+ JUDGES = {"manifests", "boundaries", "plan", "skills", "hygiene", "fitness", "doctor", "pr-scope",
132
+ "pr-check", "compat", "landed"} # the commands whose verdict depends on the framework's rules
133
+
134
+
114
135
  def main(argv: list[str] | None = None) -> int:
115
136
  args = build_parser().parse_args(argv)
137
+ if args.command in JUDGES:
138
+ print(provenance.judged_by(args.root), flush=True)
116
139
  return args.func(args)
@@ -0,0 +1,188 @@
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 _base_tree(root: Path, base: str, into: Path) -> Path:
102
+ """The whole repository as it is at the base. The proof runs here, so the change it judges
103
+ cannot rewrite what judges it — a script, a fixture, anything it reads (D44). Measured on
104
+ 2026-09-20: 7 ms for 1.6 MB."""
105
+ archive = subprocess.run(["git", "archive", "--format=tar", base], cwd=root,
106
+ capture_output=True, check=True).stdout
107
+ with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
108
+ tar.extractall(into, filter="data")
109
+ return into
110
+
111
+
112
+ def run(root: Path, module: str | None, base: str) -> int:
113
+ if _git(root, "rev-parse", "--verify", "--quiet", f"{base}^{{commit}}").returncode:
114
+ print(f"FAIL [V1] base '{base}' not found: the merged contract versions cannot be read.\n"
115
+ " Action: fetch the history (fetch-depth: 0), or pass an existing commit.")
116
+ return 1
117
+ fork = _git(root, "merge-base", base, "HEAD").stdout.strip() or base
118
+ changed = changed_files(root, fork)
119
+ now = provided_now(root)
120
+ failures, checked = [], []
121
+ for (contract, version), (path, why) in sorted(frozen_versions(root, fork).items()):
122
+ target = now.get((contract, version))
123
+ if not any(file == place or file.startswith(f"{place}/") for file in changed
124
+ for place in {path, target} if place):
125
+ continue
126
+ label = f"{contract} {version} ({why})"
127
+ if target is None:
128
+ checked.append(f"{label}: no longer provided — its consumers are checked by B6")
129
+ continue
130
+ if target != path:
131
+ label += f", moved to {target}"
132
+ holder = _holder(root, target)
133
+ if holder is None:
134
+ failures.append(f"[V1] {label} changed at '{target}', which no module holds: nothing declares "
135
+ "how its versions are compared.\n Action: keep contracts under contracts/ "
136
+ "(docs/os/03-contracts.md §2).")
137
+ continue
138
+ if module is not None and holder[0] != module:
139
+ continue
140
+ if not (root / target).exists():
141
+ failures.append(f"[V1] {label}: provided at '{target}', which holds no document (B7).\n"
142
+ " Action: commit the version there, or correct provides[].path.")
143
+ continue
144
+ if not _git(root, "ls-tree", "-r", "--name-only", fork, "--", path).stdout.strip():
145
+ checked.append(f"{label}: no document at the base, nothing merged to compare")
146
+ continue
147
+ name, folder = holder
148
+ where = f"{folder.relative_to(root).as_posix()}/MANIFEST.yaml"
149
+ commands = _load(_git(root, "show", f"{fork}:{where}").stdout).get("commands")
150
+ command = commands.get("compat") if isinstance(commands, dict) else None
151
+ if not command:
152
+ failures.append(f"[V1] {label} changed, and no merged compatibility command proves the "
153
+ f"change compatible.\n Action: declare commands.compat in {where} in a "
154
+ "pull request of its own (docs/tooling-profile.md), or publish the change as "
155
+ "a new version beside it (docs/os/03-contracts.md §4).")
156
+ continue
157
+ with tempfile.TemporaryDirectory() as temporary:
158
+ tree = _base_tree(root, fork, Path(temporary))
159
+ where_it_runs = tree / folder.relative_to(root)
160
+ if not where_it_runs.is_dir():
161
+ failures.append(f"[V1] {label}: module '{name}' has no folder at the base, so its "
162
+ f"compatibility command cannot be run as merged.\n Action: "
163
+ "declare the command where the module now lives, in a pull request "
164
+ "of its own (docs/os/03-contracts.md §4).")
165
+ continue
166
+ env = {**os.environ, "NSTACK_CONTRACT": contract, "NSTACK_VERSION": version,
167
+ "NSTACK_BASE_PATH": str(tree / path), "NSTACK_HEAD_PATH": str(root / target)}
168
+ print(f"-> {name}: {command}", flush=True)
169
+ code = subprocess.run(command, shell=True, cwd=where_it_runs, env=env).returncode
170
+ if code:
171
+ failures.append(f"[V1] {label}: the compatibility command of module '{name}' ({where}) "
172
+ f"did not prove the change compatible (exit {code}).\n"
173
+ " Action: read the comparator's output above. If the change is "
174
+ "breaking, publish it as a new version beside this one and migrate its "
175
+ "consumers (expand/contract, docs/os/03-contracts.md §4). If the command "
176
+ "could not run — a missing tool, no network — fix the command: V1 cannot "
177
+ "pass without a proof.")
178
+ else:
179
+ checked.append(f"{label}: proven compatible")
180
+ for line in checked:
181
+ print(f"Contract version {line}.")
182
+ for failure in failures:
183
+ print(f"FAIL {failure}")
184
+ if failures:
185
+ return 1
186
+ if not checked:
187
+ print("No frozen contract version changed.")
188
+ 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,22 @@ 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"
47
+ OUT_OF_REACH = "OUT OF REACH" # the plan forbids it: a state, not a fault (PDR-0006)
48
+ # The settings that make the forge refuse a merge. A repository is guarded when they are
49
+ # all in force; the others matter and none of them stops a merge.
50
+ BLOCKING = ("G1", "G2", "G3", "G4", "G12", "G13")
45
51
  API_VERSION = "2026-03-10"
46
52
  PLACEHOLDER = "<One sentence: what this project does.>"
47
- JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle")
53
+ JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
54
+ "Module checks")
48
55
  THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skeleton workflows
49
56
  LABELS = ("cross-module", "over-budget", "out-of-cycle")
50
- PUBLISHED = re.compile(r"v\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?")
57
+ PUBLISHED = provenance.PUBLISHED # a published version is a vX.Y.Z tag (ADR-0002), as CI installs it
58
+ REMOTE = re.compile(r"https://|ssh://|git@[^:/]+:|gh:|gl:") # as .nstack/install-engine.sh reads them
51
59
 
52
60
  RULESET = "Settings → Rules → Rulesets, main branch"
53
61
  SECURITY = "Settings → Advanced Security"
@@ -60,7 +68,8 @@ CHECKLIST = [ # (rule, setting, action)
60
68
  ("G2", "At least 1 approving review", f"{RULESET}: at least 1 approval required"),
61
69
  ("G3", "Code owner review required", f"{RULESET}: require Code Owners review"),
62
70
  ("G4", "Required checks: " + ", ".join(f"`{job}`" for job in JOBS),
63
- f"{RULESET}: require these status checks"),
71
+ f"{RULESET}: require these status checks. GitHub offers a check only once it has run: "
72
+ "open a first pull request, then select them"),
64
73
  ("G5", "Secret Protection and push protection",
65
74
  f"{SECURITY}: enable Secret Protection and push protection"),
66
75
  ("G6", "Private vulnerability reporting, public repository (the `SECURITY.md` channel)",
@@ -76,13 +85,15 @@ CHECKLIST = [ # (rule, setting, action)
76
85
  "Issues → Labels: create " + ", ".join(LABELS[:-1]) + f" and {LABELS[-1]}"),
77
86
  ("G12", "Bypass list empty: nobody merges around the rules, administrators included",
78
87
  f"{RULESET}: remove every bypass actor"),
88
+ ("G13", "Stale approvals dismissed when new commits are pushed",
89
+ f"{RULESET}: dismiss stale pull request approvals when new commits are pushed"),
79
90
  ]
80
91
 
81
92
  # Settings specific to public repositories, and settings a private one pays for
82
93
  # (GitHub documentation, 2026-09-15).
83
94
  PUBLIC_ONLY = {"G6": "private vulnerability reporting only exists for a public repository; "
84
95
  "state an internal channel in SECURITY.md"}
85
- PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12"),
96
+ PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12", "G13"),
86
97
  "Private repository: rulesets require the GitHub Team plan (organisation) "
87
98
  "or Pro (personal account); without it, nothing blocks the merge.")
88
99
  PRIVATE_PLAN["G5"] = ("Private repository: Secret Protection is a paid option; without it, only the "
@@ -93,6 +104,10 @@ class NotVerified(Exception):
93
104
  """Setting unreadable: token, permission or network."""
94
105
 
95
106
 
107
+ class Gap(Exception):
108
+ """Setting missing, with an action more precise than the checklist's."""
109
+
110
+
96
111
  class GitHub:
97
112
  """Reads of the GitHub REST API, cached; never a write."""
98
113
 
@@ -163,7 +178,8 @@ def _no_bypass(client: GitHub) -> bool:
163
178
  """G12: every ruleset applying to main has an empty bypass list (ADR-0004)."""
164
179
  rulesets = {rule["ruleset_id"] for rule in client.get("/rules/branches/main") if rule.get("ruleset_id")}
165
180
  if not rulesets:
166
- return False
181
+ raise Gap(f"no ruleset applies to main, so there is no bypass list to empty. {RULESET}: "
182
+ "create the ruleset first (G1)")
167
183
  for ruleset in sorted(rulesets):
168
184
  actors = client.get(f"/rulesets/{ruleset}?includes_parents=true").get("bypass_actors")
169
185
  if actors is None:
@@ -188,6 +204,7 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
188
204
  "G10": _workflows,
189
205
  "G11": lambda c: all(c.get(f"/labels/{label}", missing=True) is not None for label in LABELS),
190
206
  "G12": _no_bypass,
207
+ "G13": lambda c: _parameters(c, "pull_request").get("dismiss_stale_reviews_on_push") is True,
191
208
  }
192
209
 
193
210
 
@@ -210,8 +227,13 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
210
227
  install = f'uv tool install "napkinstack=={project}" --with-executables-from pre-commit'
211
228
  results = []
212
229
 
230
+ _, origin = provenance.engine()
213
231
  if not PUBLISHED.fullmatch(commit):
214
- l1 = (UNKNOWN, f"Reason: the project comes from an unpublished version ({commit or 'unknown'}).")
232
+ l1 = (GAP, f"The project is pinned to an unpublished framework ({commit or 'unknown'}): its "
233
+ "verdicts come from rules nobody has published (PDR-0005).\nAction: once the change it "
234
+ "waits for is released, nstack update --ref vX.Y.Z.")
235
+ elif origin is not None:
236
+ l1 = (GAP, f"The nstack running is unpublished ({origin}) (PDR-0005).\nAction: {install}")
215
237
  elif project != __version__:
216
238
  l1 = (GAP, f"nstack {__version__} installed, project on {project} (PDR-0001 R3).\nAction: {install}")
217
239
  else:
@@ -246,6 +268,12 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
246
268
  "Action: write the sentence that presents the project." if untouched else ""))
247
269
 
248
270
  results.append(("L6", "CODEOWNERS starts with a default owner", *_default_owner(root)))
271
+
272
+ source = str(answers.get("_src_path") or "")
273
+ local = not REMOTE.match(source)
274
+ results.append(("L7", "Template source reachable by anyone", GAP if local else OK,
275
+ f"The project was generated from '{source}', a path on one machine: nobody else can "
276
+ "update it (PDR-0005).\nAction: nstack update, from the published source." if local else ""))
249
277
  return results
250
278
 
251
279
 
@@ -263,6 +291,35 @@ def _display(rule: str, setting: str, status: str, detail: str) -> None:
263
291
  print(f" {line}")
264
292
 
265
293
 
294
+ def _state(results: list[tuple[str, str, str, str]]) -> None:
295
+ """Guarded when every setting that refuses a merge is in force; unguarded otherwise, with
296
+ what a plan forbids kept apart from what is not yet done; never guarded on what could not
297
+ be read (PDR-0006)."""
298
+ status_of = {rule: status for rule, _, status, _ in results}
299
+ blocking = [status_of.get(rule, UNKNOWN) for rule in BLOCKING]
300
+ if any(status == UNKNOWN for status in blocking):
301
+ print("\nThis repository: not verified — what cannot be read is never reported as guarded.")
302
+ return
303
+ if all(status == OK for status in blocking):
304
+ print("\nThis repository: guarded — the forge refuses what the checklist asks it to refuse.")
305
+ return
306
+ groups = {name: [rule for rule, _, status, _ in results
307
+ if status == name and rule.startswith("G")]
308
+ for name in (OK, GAP, OUT_OF_REACH)}
309
+ print("\nThis repository: unguarded — nothing here refuses a merge.")
310
+ for label, name in (("In force", OK), ("Not yet in place", GAP),
311
+ ("Out of reach on this plan", OUT_OF_REACH)):
312
+ if groups[name]:
313
+ print(f" {label:<26}: {', '.join(groups[name])}")
314
+ if groups[OUT_OF_REACH]:
315
+ print(" What it would take : make the repository public, where these work at no "
316
+ "cost on any plan;\n or move the private repository to a "
317
+ "plan that enforces rules;\n or keep working here, "
318
+ "knowing that nothing refuses.")
319
+ if groups[GAP]:
320
+ print(" Each setting not yet in place carries its action above.")
321
+
322
+
266
323
  def run(root: Path) -> int:
267
324
  if not (root / ANSWERS).is_file():
268
325
  print(f"FAIL [doctor] {ANSWERS} not found in {root}: this folder is not a project "
@@ -297,17 +354,22 @@ def run(root: Path) -> int:
297
354
  status, detail = (OK, "") if CHECKS[rule](client) else (GAP, f"Action: {action}")
298
355
  except NotVerified as reason:
299
356
  status, detail = UNKNOWN, f"Reason: {reason}"
357
+ except Gap as action:
358
+ status, detail = GAP, f"Action: {action}"
300
359
  if private and status != OK and rule in PRIVATE_PLAN:
301
- detail += f"\n{PRIVATE_PLAN[rule]}"
360
+ status, detail = OUT_OF_REACH, f"Reason: {PRIVATE_PLAN[rule]}"
302
361
  results.append((rule, setting, status, detail))
303
362
  _display(rule, setting, status, detail)
304
363
 
305
364
  gaps = sum(status == GAP for _, _, status, _ in results)
306
365
  unknown = sum(status == UNKNOWN for _, _, status, _ in results)
307
366
  skipped = sum(status == NOT_APPLICABLE for _, _, status, _ in results)
367
+ out_of_reach = sum(status == OUT_OF_REACH for _, _, status, _ in results)
368
+ _state(results)
308
369
  suffix = f", {skipped} not applicable" if skipped else ""
309
370
  if not gaps and not unknown:
310
- print(f"\nnstack doctor: compliant{suffix}.")
371
+ reach = f"; {out_of_reach} setting(s) out of reach on this plan" if out_of_reach else ""
372
+ print(f"\nnstack doctor: compliant{suffix}{reach}.")
311
373
  return 0
312
374
  print(f"\nnstack doctor: {gaps} gap(s), {unknown} not verified{suffix}.\nWorkflows inform; "
313
375
  "it is the GitHub settings that block, and they are not copied with the project.")