napkinstack 0.18.0__tar.gz → 0.19.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.18.0 → napkinstack-0.19.0}/PKG-INFO +8 -7
- {napkinstack-0.18.0 → napkinstack-0.19.0}/README.md +6 -6
- {napkinstack-0.18.0 → napkinstack-0.19.0}/pyproject.toml +2 -1
- {napkinstack-0.18.0 → napkinstack-0.19.0}/pyproject.toml.orig +2 -2
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/attest.py +25 -5
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/cli.py +4 -4
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/doctor.py +59 -10
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/manifests.py +30 -1
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/modules.py +6 -2
- napkinstack-0.19.0/src/napkinstack/owners.py +78 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/project.py +5 -3
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/settings.py +5 -5
- {napkinstack-0.18.0 → napkinstack-0.19.0}/LICENSE +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/board.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/evidence.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/hold.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/pull_request.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/tests/.gitkeep +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: napkinstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.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
|
|
7
7
|
Requires-Dist: pyyaml>=6.0
|
|
8
8
|
Requires-Dist: copier==9.18.2
|
|
9
|
+
Requires-Dist: pathspec>=0.12
|
|
9
10
|
Requires-Dist: pre-commit==4.6.2
|
|
10
11
|
Requires-Python: >=3.12
|
|
11
12
|
Project-URL: Homepage, https://github.com/NapkinStack/engineering-os
|
|
@@ -20,7 +21,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
20
21
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
21
22
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
22
23
|
|
|
23
|
-
> **Status: v0.
|
|
24
|
+
> **Status: v0.19.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
24
25
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
25
26
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
26
27
|
|
|
@@ -84,8 +85,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
84
85
|
registry — always pinned to a release tag:
|
|
85
86
|
|
|
86
87
|
```bash
|
|
87
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
88
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
88
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.19.0" nstack init my-project
|
|
89
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.19.0"
|
|
89
90
|
```
|
|
90
91
|
|
|
91
92
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -111,16 +112,16 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
111
112
|
| `nstack new-contract <name>` | Creates a contract and, the first time, the module that holds them all |
|
|
112
113
|
| `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
|
|
113
114
|
| `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
|
|
114
|
-
| `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures; then the project across its cycles, and who holds what |
|
|
115
|
+
| `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures, and the decisions due at a closure; then the project across its cycles, and who holds what |
|
|
115
116
|
| `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene — each also runs alone: `manifests`, `boundaries`, `skills`, `plan`, `hygiene` |
|
|
116
117
|
| `nstack pr-scope` | One PR = one module, review budget |
|
|
117
118
|
| `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
|
|
118
119
|
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
119
|
-
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
120
|
+
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared — one run at a time on a machine |
|
|
120
121
|
| `nstack release [module] [args]` | Makes each module's artefacts into `dist/<module>/`, each after what it assembles; what follows a named module goes to its command |
|
|
121
122
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
122
123
|
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
123
|
-
| `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks` |
|
|
124
|
+
| `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks`; attesting the same commit again keeps the module checks it passed (`--again` runs them) |
|
|
124
125
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
125
126
|
| `nstack update [--session <id>] [--forge <name>]` | Lays the new version on a branch to review; an agent's session as the commit's `Agent-Session` trailer; `--forge` moves the project to another forge, its own lines kept |
|
|
126
127
|
|
|
@@ -5,7 +5,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
|
|
|
5
5
|
the project, which then receives new versions on demand; no application stack is imposed.
|
|
6
6
|
Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
|
|
7
7
|
|
|
8
|
-
> **Status: v0.
|
|
8
|
+
> **Status: v0.19.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
9
9
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
10
10
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
11
11
|
|
|
@@ -69,8 +69,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
69
69
|
registry — always pinned to a release tag:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
73
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
72
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.19.0" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.19.0"
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -96,16 +96,16 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
96
96
|
| `nstack new-contract <name>` | Creates a contract and, the first time, the module that holds them all |
|
|
97
97
|
| `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
|
|
98
98
|
| `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
|
|
99
|
-
| `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures; then the project across its cycles, and who holds what |
|
|
99
|
+
| `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures, and the decisions due at a closure; then the project across its cycles, and who holds what |
|
|
100
100
|
| `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene — each also runs alone: `manifests`, `boundaries`, `skills`, `plan`, `hygiene` |
|
|
101
101
|
| `nstack pr-scope` | One PR = one module, review budget |
|
|
102
102
|
| `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
|
|
103
103
|
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
104
|
-
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
104
|
+
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared — one run at a time on a machine |
|
|
105
105
|
| `nstack release [module] [args]` | Makes each module's artefacts into `dist/<module>/`, each after what it assembles; what follows a named module goes to its command |
|
|
106
106
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
107
107
|
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
108
|
-
| `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks` |
|
|
108
|
+
| `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks`; attesting the same commit again keeps the module checks it passed (`--again` runs them) |
|
|
109
109
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
110
110
|
| `nstack update [--session <id>] [--forge <name>]` | Lays the new version on a branch to review; an agent's session as the commit's `Agent-Session` trailer; `--forge` moves the project to another forge, its own lines kept |
|
|
111
111
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "napkinstack"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.19.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"
|
|
@@ -9,6 +9,7 @@ readme = "README.md"
|
|
|
9
9
|
dependencies = [
|
|
10
10
|
"PyYAML>=6.0",
|
|
11
11
|
"copier==9.18.2",
|
|
12
|
+
"pathspec>=0.12",
|
|
12
13
|
"pre-commit==4.6.2",
|
|
13
14
|
]
|
|
14
15
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "napkinstack"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.19.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"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
8
8
|
readme = "README.md"
|
|
9
|
-
dependencies = ["PyYAML>=6.0", "copier==9.18.2", "pre-commit==4.6.2"]
|
|
9
|
+
dependencies = ["PyYAML>=6.0", "copier==9.18.2", "pathspec>=0.12", "pre-commit==4.6.2"]
|
|
10
10
|
|
|
11
11
|
[project.urls]
|
|
12
12
|
Homepage = "https://github.com/NapkinStack/engineering-os"
|
|
@@ -78,15 +78,35 @@ def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProc
|
|
|
78
78
|
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=check)
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
MARK = 48 # bytes a cut's mark may take: `[… N bytes cut …]` and its newlines
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cut(data: bytes, room: int) -> str:
|
|
85
|
+
"""Whole up to room bytes, else a quarter of it from the head, the rest from the tail."""
|
|
86
|
+
if len(data) <= room:
|
|
87
|
+
return data.decode("utf-8", "ignore")
|
|
88
|
+
head = max(room - MARK, 0) // 4
|
|
89
|
+
tail = max(room - MARK, 0) - head
|
|
90
|
+
return (data[:head].decode("utf-8", "ignore") + f"\n[… {len(data) - head - tail} bytes cut …]\n"
|
|
91
|
+
+ (data[-tail:].decode("utf-8", "ignore") if tail else ""))
|
|
92
|
+
|
|
93
|
+
|
|
81
94
|
def capped(text: str) -> str:
|
|
82
|
-
"""A step's output as the record keeps it: whole up to KEPT bytes, else
|
|
95
|
+
"""A step's output as the record keeps it: whole up to KEPT bytes, else cut log group by log
|
|
96
|
+
group — one per module in Module checks — so that each keeps its commands and its results,
|
|
97
|
+
the room a small one leaves going to the others (#295). No group: one head and one tail."""
|
|
83
98
|
data = text.encode("utf-8", "replace")
|
|
84
99
|
if len(data) <= KEPT:
|
|
85
100
|
return text
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
starts = [found.start() for found in re.finditer(rb"^::group::", data, re.M)]
|
|
102
|
+
bounds = [0, *(start for start in starts if start), len(data)]
|
|
103
|
+
parts = [data[start:end] for start, end in zip(bounds, bounds[1:])]
|
|
104
|
+
room, given = KEPT, {}
|
|
105
|
+
for index in sorted(range(len(parts)), key=lambda index: len(parts[index])):
|
|
106
|
+
share = room // (len(parts) - len(given))
|
|
107
|
+
given[index] = min(len(parts[index]), share)
|
|
108
|
+
room -= given[index]
|
|
109
|
+
return "".join(_cut(part, given[index]) for index, part in enumerate(parts))
|
|
90
110
|
|
|
91
111
|
|
|
92
112
|
def unplaced(value, root: Path):
|
|
@@ -96,7 +96,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
96
96
|
parser = argparse.ArgumentParser(prog="nstack", description="NapkinStack engine.")
|
|
97
97
|
parser.add_argument("--version", action=_Version, nargs=0, help="the engine's version, and where it came from")
|
|
98
98
|
sub = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
99
|
-
_add(sub, "manifests", "manifests, lifecycles, deprecations, what a release assembles (M1-
|
|
99
|
+
_add(sub, "manifests", "manifests, lifecycles, deprecations, what a release assembles, evidence, who approves (M1-M13)",
|
|
100
100
|
lambda a: manifests.run(a.root))
|
|
101
101
|
_add(sub, "boundaries", "declared graph against real graph, release artefacts included (B1-B9)",
|
|
102
102
|
lambda a: boundaries.run(a.root))
|
|
@@ -143,7 +143,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
143
143
|
lambda a: compat.run(a.root, a.module, a.base))
|
|
144
144
|
cp.add_argument("module", nargs="?", help="module holding the contracts (default: all)")
|
|
145
145
|
cp.add_argument("--base", default="origin/main")
|
|
146
|
-
rl = _add(sub, "release", "makes one module's artefacts, or all of them, each into dist/<module
|
|
146
|
+
rl = _add(sub, "release", "makes one module's artefacts, or all of them, each into dist/<module>, emptied first "
|
|
147
147
|
"(commands.release), with what follows a named module passed to it",
|
|
148
148
|
lambda a: _release(a))
|
|
149
149
|
rl.add_argument("module", nargs="?", help="module name (default: all, each after what it assembles)")
|
|
@@ -165,7 +165,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
165
165
|
lambda a: modules.create_contract(a.root, a.name, a.owner))
|
|
166
166
|
nc.add_argument("name", help="the contract's name, kebab-case")
|
|
167
167
|
nc.add_argument("--owner", help="owner of the contracts module (default: the foundation's owner)")
|
|
168
|
-
pc = _add(sub, "pr-check", "test sheet and
|
|
168
|
+
pc = _add(sub, "pr-check", "test sheet, cycle and attested checks, read from the pull request description (T1-T5, K1-K5, A1-A2, C9)",
|
|
169
169
|
lambda a: pull_request.run(a.root, a.base, a.body_file, a.labels))
|
|
170
170
|
pc.add_argument("--base", default="origin/main")
|
|
171
171
|
pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
|
|
@@ -191,7 +191,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
191
191
|
cl.add_argument("--person", help="the person accountable, such as @alice (default: git's user)")
|
|
192
192
|
cl.add_argument("--take-over", action="store_true",
|
|
193
193
|
help="take a hold nobody has worked on for hold_days — recorded, with its previous holder")
|
|
194
|
-
rs = _add(sub, "unclaim", "gives a held deliverable back: by its holder, the decider, or anyone once stale",
|
|
194
|
+
rs = _add(sub, "unclaim", "gives a held deliverable back: by its holder, the decider, or anyone once stale; a closed cycle's hold has ended",
|
|
195
195
|
lambda a: hold.release(a.root, a.deliverable, a.session, a.person))
|
|
196
196
|
rs.add_argument("deliverable", help="the deliverable's identifier, D<n>")
|
|
197
197
|
rs.add_argument("--session", help="the agent's session, written on the release")
|
|
@@ -37,9 +37,9 @@ Rules:
|
|
|
37
37
|
known public forge is said, never judged: a mirror is a choice
|
|
38
38
|
N1 information, never a gap (D91): a published version newer than the project's, read from
|
|
39
39
|
the registry — the one place a project learns of it. No other command opens a connection
|
|
40
|
-
G1-
|
|
41
|
-
repository, and on a private one G1-G5, G12, G13, G17
|
|
42
|
-
or option required
|
|
40
|
+
G1-G21 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
|
|
41
|
+
repository, G21 outside `approval: agent`, and on a private one G1-G5, G12, G13, G17,
|
|
42
|
+
G18, G20 and G21 name the GitHub plan or option required
|
|
43
43
|
|
|
44
44
|
Off GitHub (the project's `forge` answer, ADR-0010) no G rule is read: the forge-neutral
|
|
45
45
|
PROTECTIONS are said as information — the same list as INSTALL.md's, bound by a test — and the
|
|
@@ -77,7 +77,7 @@ SOURCES = "SOURCES" # a contributor's checkout holding the project's version: s
|
|
|
77
77
|
OUT_OF_REACH = "OUT OF REACH" # the plan forbids it: a state, not a fault (PDR-0006)
|
|
78
78
|
# The settings that make the forge refuse a merge. A repository is guarded when they are
|
|
79
79
|
# all in force; the others matter and none of them stops a merge.
|
|
80
|
-
BLOCKING = ("G1", "G2", "G3", "G4", "G12", "G13")
|
|
80
|
+
BLOCKING = ("G1", "G2", "G3", "G4", "G12", "G13", "G21")
|
|
81
81
|
API_VERSION = "2026-03-10"
|
|
82
82
|
JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
|
|
83
83
|
"Module checks")
|
|
@@ -154,13 +154,29 @@ CHECKLIST = [ # (rule, setting, action)
|
|
|
154
154
|
("G20", "Hold branches `nstack/hold/**/*` cannot be force-pushed nor deleted",
|
|
155
155
|
"Settings → Rules → Rulesets, a ruleset on the branches nstack/hold/**/* — not nstack/hold/**, "
|
|
156
156
|
"which stops at a slash — with nobody on its bypass list: block force pushes and restrict deletions"),
|
|
157
|
+
# G21: where another contributor's agent approves (PDR-0021), the one who pushed last never
|
|
158
|
+
# approves what they pushed — the author's own fix included.
|
|
159
|
+
("G21", "The most recent push approved by someone other than its pusher (approval: agent)",
|
|
160
|
+
f"{RULESET}: require approval of the most recent reviewable push"),
|
|
157
161
|
]
|
|
158
162
|
|
|
163
|
+
# Asked of a project only on one of its answers: elsewhere not applicable, and said why.
|
|
164
|
+
ANSWERED = {"G21": ("approval", "agent", "the project's approval is human: a code owner approves every change")}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def unasked(rule: str, answers: dict) -> str | None:
|
|
168
|
+
"""Why a rule is not asked of this project, or None when it is."""
|
|
169
|
+
if rule not in ANSWERED:
|
|
170
|
+
return None
|
|
171
|
+
key, value, reason = ANSWERED[rule]
|
|
172
|
+
return None if answers.get(key) == value else reason
|
|
173
|
+
|
|
174
|
+
|
|
159
175
|
# Settings specific to public repositories, and settings a private one pays for
|
|
160
176
|
# (GitHub documentation, 2026-09-15).
|
|
161
177
|
PUBLIC_ONLY = {"G6": "private vulnerability reporting only exists for a public repository; "
|
|
162
178
|
"state an internal channel in SECURITY.md"}
|
|
163
|
-
PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12", "G13", "G17", "G18", "G20"),
|
|
179
|
+
PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12", "G13", "G17", "G18", "G20", "G21"),
|
|
164
180
|
"Private repository: rulesets require the GitHub Team plan (organisation) "
|
|
165
181
|
"or Pro (personal account); without it, nothing blocks the merge.")
|
|
166
182
|
PRIVATE_PLAN["G5"] = ("Private repository: Secret Protection is a paid option; without it, only the "
|
|
@@ -339,19 +355,25 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
|
|
|
339
355
|
"G18": lambda c: _rule(c, "non_fast_forward") is not None,
|
|
340
356
|
"G19": lambda c: _merges(c, "default_branch").get("default_branch") == "main",
|
|
341
357
|
"G20": _holds_kept,
|
|
358
|
+
"G21": lambda c: _parameters(c, "pull_request").get("require_last_push_approval") is True,
|
|
342
359
|
}
|
|
343
360
|
|
|
344
361
|
|
|
345
362
|
def _default_owner(root: Path) -> tuple[str, str]:
|
|
346
|
-
"""L6: `*` first, so that the code owner review covers every path (ADR-0004)
|
|
347
|
-
|
|
363
|
+
"""L6: `*` first, so that the code owner review covers every path (ADR-0004) — or, where
|
|
364
|
+
another contributor's agent approves (PDR-0021), a human named for what one must approve and
|
|
365
|
+
no default owner, so that the rest is the agents'."""
|
|
366
|
+
from napkinstack.project import answer, forge, ownership
|
|
348
367
|
|
|
349
368
|
path = ownership(root) # where the project's forge reads it (ADR-0010)
|
|
350
369
|
shown = path.relative_to(root).as_posix()
|
|
370
|
+
agent = answer(root, "approval") == "agent"
|
|
351
371
|
if not path.is_file():
|
|
352
|
-
return GAP, f"{shown} missing.\nAction: create it, starting with `* @<owner>`."
|
|
372
|
+
return GAP, f"{shown} missing.\nAction: create it" + ("." if agent else ", starting with `* @<owner>`.")
|
|
353
373
|
rules = [line.split() for line in path.read_text(encoding="utf-8").splitlines()
|
|
354
374
|
if line.strip() and not line.lstrip().startswith("#")]
|
|
375
|
+
if agent:
|
|
376
|
+
return _kept_for_a_human(root, shown, path.read_text(encoding="utf-8"))
|
|
355
377
|
if rules and rules[0][0] == "*" and len(rules[0]) > 1:
|
|
356
378
|
name = forge(root)
|
|
357
379
|
if name in UNREAD_OWNERSHIP:
|
|
@@ -364,6 +386,28 @@ def _default_owner(root: Path) -> tuple[str, str]:
|
|
|
364
386
|
"its first rule: the code owner review then covers every path (.nstack/handbook/07-governance.md §7).")
|
|
365
387
|
|
|
366
388
|
|
|
389
|
+
def _kept_for_a_human(root: Path, shown: str, text: str) -> tuple[str, str]:
|
|
390
|
+
"""L6 on `approval: agent`: no default owner, and every path a human must approve ends on a
|
|
391
|
+
rule with an owner (`owners.py`; in CI, rule M13)."""
|
|
392
|
+
from napkinstack import owners
|
|
393
|
+
from napkinstack.project import forge
|
|
394
|
+
|
|
395
|
+
default, missing = owners.unowned(root, text)
|
|
396
|
+
if default:
|
|
397
|
+
return GAP, (f"{shown} names a default owner: on approval agent, it leaves no change another "
|
|
398
|
+
"contributor's agent may approve.\nAction: remove the `*` rule; name a human for what "
|
|
399
|
+
"one must approve (.nstack/handbook/07-governance.md §7).")
|
|
400
|
+
if missing:
|
|
401
|
+
return GAP, (f"{shown} names no owner for " + ", ".join(f"`{path}`" for path in missing)
|
|
402
|
+
+ ": on approval agent, a human approves them.\nAction: end each on a line "
|
|
403
|
+
"`/<path> @<owner>`, the owner a team of people (.nstack/handbook/07-governance.md §7).")
|
|
404
|
+
name = forge(root)
|
|
405
|
+
if name in UNREAD_OWNERSHIP or name == "none":
|
|
406
|
+
return OK, ("No forge reads it here: who approves what is for people to read, and nothing refuses "
|
|
407
|
+
"an agent's approval of the rest.")
|
|
408
|
+
return OK, ""
|
|
409
|
+
|
|
410
|
+
|
|
367
411
|
def _forge(path: str) -> dict | None:
|
|
368
412
|
"""JSON of a public repository's page on the forge, read without a token; None when it does
|
|
369
413
|
not answer."""
|
|
@@ -448,7 +492,9 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
|
|
|
448
492
|
l3 = (OK, "") if installed else (GAP, "Action: pre-commit install")
|
|
449
493
|
results.append(("L3", "pre-commit hooks installed", *l3))
|
|
450
494
|
|
|
451
|
-
results.append(("L6", "CODEOWNERS
|
|
495
|
+
results.append(("L6", "CODEOWNERS names a human where one approves (approval: agent)"
|
|
496
|
+
if answers.get("approval") == "agent" else "CODEOWNERS starts with a default owner",
|
|
497
|
+
*_default_owner(root)))
|
|
452
498
|
|
|
453
499
|
source = str(answers.get("_src_path") or "")
|
|
454
500
|
local = not REMOTE.match(source)
|
|
@@ -540,6 +586,7 @@ def _state(results: list[tuple[str, str, str, str]]) -> None:
|
|
|
540
586
|
be read (PDR-0006)."""
|
|
541
587
|
status_of = {rule: status for rule, _, status, _ in results}
|
|
542
588
|
blocking = [status_of.get(rule, UNKNOWN) for rule in BLOCKING]
|
|
589
|
+
blocking = [status for status in blocking if status != NOT_APPLICABLE] # G21 off `approval: agent`
|
|
543
590
|
if any(status == UNKNOWN for status in blocking):
|
|
544
591
|
print("\nThis repository: not verified — what cannot be read is never reported as guarded.")
|
|
545
592
|
return
|
|
@@ -629,7 +676,9 @@ def run(root: Path) -> int:
|
|
|
629
676
|
"Administration: read permission, then run again.")
|
|
630
677
|
private = client is not None and _private(client)
|
|
631
678
|
for rule, setting, action in CHECKLIST:
|
|
632
|
-
if
|
|
679
|
+
if unasked(rule, answers):
|
|
680
|
+
status, detail = NOT_APPLICABLE, f"Reason: {unasked(rule, answers)}."
|
|
681
|
+
elif client is None:
|
|
633
682
|
status, detail = UNKNOWN, ""
|
|
634
683
|
elif private and rule in PUBLIC_ONLY:
|
|
635
684
|
status, detail = NOT_APPLICABLE, f"Reason: {PUBLIC_ONLY[rule]}."
|
|
@@ -21,6 +21,9 @@ Rules:
|
|
|
21
21
|
(.nstack/handbook/09-platform.md §2, D127)
|
|
22
22
|
M12 evidence declares what the module's end-to-end scenarios provoke: patterns that compile and
|
|
23
23
|
check something, a level, a scenario (ADR-0009)
|
|
24
|
+
M13 on `approval: agent`, the code owners file names a human for every module neither
|
|
25
|
+
`prototype` nor `standard`, and for the project's rules, with no default owner (PDR-0021) —
|
|
26
|
+
in the same pull request that raises a module's criticality
|
|
24
27
|
|
|
25
28
|
Usage : nstack manifests [--root ROOT]
|
|
26
29
|
Output: 0 if everything passes, 1 otherwise. Every failure explains the rule broken.
|
|
@@ -467,13 +470,39 @@ def assembled(manifests: list[Path], root: Path) -> None:
|
|
|
467
470
|
" Action: one of them stops assembling the other.", places[name])
|
|
468
471
|
|
|
469
472
|
|
|
473
|
+
def kept_for_a_human(root: Path) -> None:
|
|
474
|
+
"""M13: where another contributor's agent approves, what a human must approve is named in the
|
|
475
|
+
code owners file. The forge reads that file from the base branch, so a pull request raising a
|
|
476
|
+
module's criticality without its line would be approved by an agent: refused here, in CI."""
|
|
477
|
+
from napkinstack import owners
|
|
478
|
+
from napkinstack.project import answer, ownership
|
|
479
|
+
|
|
480
|
+
if answer(root, "approval") != "agent":
|
|
481
|
+
return
|
|
482
|
+
path = ownership(root)
|
|
483
|
+
shown = path.relative_to(root).as_posix()
|
|
484
|
+
action = ("Action: end each on a line `/<path> @<owner>`, the owner a team of people, in this pull request "
|
|
485
|
+
"(.nstack/handbook/07-governance.md §7).")
|
|
486
|
+
if not path.is_file():
|
|
487
|
+
fail("code owners", "M13", f"{shown} missing: on approval agent, nothing keeps a change for a human.\n {action}")
|
|
488
|
+
return
|
|
489
|
+
default, missing = owners.unowned(root, path.read_text(encoding="utf-8"))
|
|
490
|
+
if default:
|
|
491
|
+
fail("code owners", "M13", f"{shown} names a default owner: on approval agent, no change is left "
|
|
492
|
+
"for another contributor's agent.\n Action: remove the `*` rule.")
|
|
493
|
+
for kept in missing:
|
|
494
|
+
fail("code owners", "M13", f"{shown} ends `{kept}` on no owner: on approval agent, a human "
|
|
495
|
+
f"approves it.\n {action}")
|
|
496
|
+
|
|
497
|
+
|
|
470
498
|
def run(root: Path) -> int:
|
|
471
499
|
failures.clear()
|
|
472
500
|
warnings.clear()
|
|
473
501
|
today = datetime.date.today()
|
|
474
502
|
manifests = find_manifests(root)
|
|
475
503
|
orphans = find_orphans(root)
|
|
476
|
-
|
|
504
|
+
kept_for_a_human(root)
|
|
505
|
+
if not manifests and not orphans and not failures:
|
|
477
506
|
print("No MANIFEST.yaml found. Nothing to validate.")
|
|
478
507
|
return 0
|
|
479
508
|
for orphan in orphans:
|
|
@@ -20,7 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
import yaml
|
|
21
21
|
|
|
22
22
|
from napkinstack import assurance
|
|
23
|
-
from napkinstack.project import forge, ownership
|
|
23
|
+
from napkinstack.project import answer, forge, ownership
|
|
24
24
|
from napkinstack.fitness.manifests import (changed_files, contract_entries, find_manifests,
|
|
25
25
|
module_content)
|
|
26
26
|
|
|
@@ -102,7 +102,11 @@ def create(root: Path, name: str, owner: str, criticality: str, user_facing: boo
|
|
|
102
102
|
|
|
103
103
|
codeowners = ownership(root)
|
|
104
104
|
line = f"/modules/{name}/"
|
|
105
|
-
if
|
|
105
|
+
if answer(root, "approval") == "agent" and criticality in ("prototype", "standard"):
|
|
106
|
+
# Another contributor's agent approves it (PDR-0021): no code owner, whose review a human's alone satisfies.
|
|
107
|
+
print(f"No code owner line for {line}: on approval agent, another contributor's agent approves a "
|
|
108
|
+
f"{criticality} module.")
|
|
109
|
+
elif codeowners.is_file():
|
|
106
110
|
content = codeowners.read_text(encoding="utf-8")
|
|
107
111
|
if not any(existing.split()[:1] == [line] for existing in content.splitlines()):
|
|
108
112
|
codeowners.write_text(content.rstrip("\n") + f"\n{line:<31}@{owner}\n", encoding="utf-8")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Who must approve a change, read from the code owners file as the forge reads it (PDR-0021).
|
|
3
|
+
|
|
4
|
+
On `approval: agent`, another contributor's agent approves what the code owners file names no
|
|
5
|
+
owner for; a human, what it names. So every path a human must approve — the project's rules,
|
|
6
|
+
its records, its answers, the code owners file itself, every module neither `prototype` nor
|
|
7
|
+
`standard` — must end on a rule with an owner. A pattern is read as a gitignore pattern, the
|
|
8
|
+
syntax the forge documents for the file, and the last rule that matches a path decides it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pathspec
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
# What a human approves whatever the project answers: its rules, its barriers, its records, its answers.
|
|
19
|
+
KEPT_FOR_A_HUMAN = ("AGENTS.md", "CONTRIBUTING.md", ".nstack/", ".copier-answers.yml", "docs/",
|
|
20
|
+
".pre-commit-config.yaml", ".yamllint.yaml", "SECURITY.md")
|
|
21
|
+
FORGE_FOLDER = {"github": ".github/", "gitlab": ".gitlab/", "forgejo": ".gitea/", "gitea": ".gitea/"}
|
|
22
|
+
NOBODY = "nstack-no-file-is-named-so" # a path no rule names but a default owner
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def rules(text: str) -> list[tuple[pathspec.PathSpec | None, list[str]]]:
|
|
26
|
+
"""Each rule of the file, in order: its pattern and its owners — none after a `#`."""
|
|
27
|
+
found = []
|
|
28
|
+
for line in text.splitlines():
|
|
29
|
+
words = line.split("#", 1)[0].split()
|
|
30
|
+
if not words:
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
spec = pathspec.PathSpec.from_lines("gitignore", [words[0]])
|
|
34
|
+
except (ValueError, TypeError):
|
|
35
|
+
spec = None # a pattern the forge would refuse covers nothing
|
|
36
|
+
found.append((spec, words[1:]))
|
|
37
|
+
return found
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def owned(read: list[tuple[pathspec.PathSpec | None, list[str]]], path: str) -> bool:
|
|
41
|
+
"""Whether the last rule matching the path names an owner."""
|
|
42
|
+
last = [owners for spec, owners in read if spec is not None and spec.match_file(path)]
|
|
43
|
+
return bool(last and last[-1])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def wanted(root: Path) -> list[str]:
|
|
47
|
+
"""The paths a human must approve on `approval: agent`, folders ending with `/`."""
|
|
48
|
+
from napkinstack.fitness.manifests import find_manifests
|
|
49
|
+
from napkinstack.project import forge, ownership
|
|
50
|
+
|
|
51
|
+
paths = [*KEPT_FOR_A_HUMAN, ownership(root).relative_to(root).as_posix()]
|
|
52
|
+
if forge(root) in FORGE_FOLDER:
|
|
53
|
+
paths.append(FORGE_FOLDER[forge(root)])
|
|
54
|
+
for manifest in find_manifests(root):
|
|
55
|
+
try:
|
|
56
|
+
data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
|
|
57
|
+
except (OSError, yaml.YAMLError):
|
|
58
|
+
data = {}
|
|
59
|
+
module = data.get("module") if isinstance(data, dict) else None
|
|
60
|
+
# A value the engine does not know, or a manifest it cannot read, is judged as critical.
|
|
61
|
+
if not isinstance(module, dict) or module.get("criticality") not in ("prototype", "standard"):
|
|
62
|
+
paths.append(manifest.parent.relative_to(root).as_posix() + "/")
|
|
63
|
+
return list(dict.fromkeys(paths))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def unowned(root: Path, text: str) -> tuple[bool, list[str]]:
|
|
67
|
+
"""Whether the file names a default owner, and the paths a human must approve that end on a
|
|
68
|
+
rule with none — a folder by its own name and by every file it holds."""
|
|
69
|
+
read = rules(text)
|
|
70
|
+
missing = []
|
|
71
|
+
for path in wanted(root):
|
|
72
|
+
probes = [path + NOBODY] if path.endswith("/") else [path]
|
|
73
|
+
folder = root / path
|
|
74
|
+
if path.endswith("/") and folder.is_dir():
|
|
75
|
+
probes += [file.relative_to(root).as_posix() for file in folder.rglob("*") if file.is_file()]
|
|
76
|
+
if not all(owned(read, probe) for probe in probes):
|
|
77
|
+
missing.append(path)
|
|
78
|
+
return owned(read, NOBODY), missing
|
|
@@ -201,7 +201,7 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
|
|
|
201
201
|
import copier
|
|
202
202
|
from copier.errors import CopierError
|
|
203
203
|
|
|
204
|
-
from napkinstack.doctor import CHECKLIST
|
|
204
|
+
from napkinstack.doctor import CHECKLIST, unasked
|
|
205
205
|
|
|
206
206
|
destination = destination.resolve()
|
|
207
207
|
if destination.exists() and not destination.is_dir():
|
|
@@ -235,6 +235,7 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
|
|
|
235
235
|
# was asked by Copier itself.
|
|
236
236
|
data.setdefault("checks", "forge" if named == "github" else "attested")
|
|
237
237
|
data.setdefault("hold_days", 7) # a hold goes stale after a week without work (ADR-0007)
|
|
238
|
+
data.setdefault("approval", "human") # a code owner approves, until the decider says otherwise (PDR-0021)
|
|
238
239
|
try:
|
|
239
240
|
copier.run_copy(source, destination, data=data, vcs_ref=ref, quiet=True, unsafe=False)
|
|
240
241
|
except (CopierError, ValueError, OSError) as exc:
|
|
@@ -261,8 +262,9 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
|
|
|
261
262
|
if named == "github":
|
|
262
263
|
print(f" 2. Create the GitHub repository {created.get('github_repo')}, push main to it, then apply "
|
|
263
264
|
"these settings:")
|
|
264
|
-
for
|
|
265
|
-
|
|
265
|
+
for rule, setting, _ in CHECKLIST:
|
|
266
|
+
if not unasked(rule, created): # G21 on `approval: agent` alone
|
|
267
|
+
print(f" - [ ] {setting}")
|
|
266
268
|
print(" 3. Check the workstation and GitHub, read-only: nstack doctor (token: see .nstack/handbook/INSTALL.md)")
|
|
267
269
|
return 0
|
|
268
270
|
where = (f"Create the repository {created.get('repository')} at {named}, push main to it"
|
|
@@ -46,8 +46,8 @@ from napkinstack.project import ANSWERS
|
|
|
46
46
|
|
|
47
47
|
RULESET_NAME = "nstack-checklist"
|
|
48
48
|
HOLDS_RULESET = "nstack-holds" # the holds of deliverables: never forced, never deleted (G20)
|
|
49
|
-
IN_RULESET = ("G1", "G2", "G3", "G4", "G13", "G17", "G18")
|
|
50
|
-
PULL_REQUEST = ("G1", "G2", "G3", "G13")
|
|
49
|
+
IN_RULESET = ("G1", "G2", "G3", "G4", "G13", "G17", "G18", "G21")
|
|
50
|
+
PULL_REQUEST = ("G1", "G2", "G3", "G13", "G21")
|
|
51
51
|
LABEL_COLOURS = {"cross-module": "d93f0b", "over-budget": "fbca04", "out-of-cycle": "5319e7",
|
|
52
52
|
"request": "0e8a16", "bug": "d73a4a", "architecture": "1d76db", "debt": "c5def5",
|
|
53
53
|
"acceptance": "bfdadc"}
|
|
@@ -74,7 +74,7 @@ def ruleset(missing: set[str]) -> dict:
|
|
|
74
74
|
if missing & set(PULL_REQUEST):
|
|
75
75
|
rules.append({"type": "pull_request", "parameters": {
|
|
76
76
|
"required_approving_review_count": 1, "require_code_owner_review": True,
|
|
77
|
-
"dismiss_stale_reviews_on_push": True, "require_last_push_approval":
|
|
77
|
+
"dismiss_stale_reviews_on_push": True, "require_last_push_approval": "G21" in missing,
|
|
78
78
|
"required_review_thread_resolution": False}})
|
|
79
79
|
if "G4" in missing:
|
|
80
80
|
rules.append({"type": "required_status_checks", "parameters": {
|
|
@@ -329,7 +329,7 @@ def run(root: Path, apply: bool = False) -> int:
|
|
|
329
329
|
if client is None:
|
|
330
330
|
# G12 is not planned blind: the ruleset planned below has nobody on its bypass list, and
|
|
331
331
|
# an older one that has is a step by hand, which only a read can find.
|
|
332
|
-
missing = {rule for rule, _, _ in doctor.CHECKLIST} - {"G12"}
|
|
332
|
+
missing = {rule for rule, _, _ in doctor.CHECKLIST if not doctor.unasked(rule, answers)} - {"G12"}
|
|
333
333
|
print(f"Settings of {repo}: not read — no token (GH_TOKEN or GITHUB_TOKEN). The whole checklist is "
|
|
334
334
|
"planned below; a call for a setting already in place changes nothing, except a ruleset's: "
|
|
335
335
|
f"skip it if a ruleset named {RULESET_NAME} exists, and the G20 one if {HOLDS_RULESET} does.\n"
|
|
@@ -346,7 +346,7 @@ def run(root: Path, apply: bool = False) -> int:
|
|
|
346
346
|
"a token to print the whole checklist.")
|
|
347
347
|
return 1
|
|
348
348
|
for rule, _, _ in doctor.CHECKLIST:
|
|
349
|
-
if private and rule in doctor.PUBLIC_ONLY:
|
|
349
|
+
if private and rule in doctor.PUBLIC_ONLY or doctor.unasked(rule, answers):
|
|
350
350
|
continue
|
|
351
351
|
try:
|
|
352
352
|
compliant = doctor.CHECKS[rule](client)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/docs/runbook.md
RENAMED
|
File without changes
|
{napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/tests/.gitkeep
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|