napkinstack 0.5.0__tar.gz → 0.6.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.5.0 → napkinstack-0.6.0}/PKG-INFO +11 -7
- {napkinstack-0.5.0 → napkinstack-0.6.0}/README.md +10 -6
- {napkinstack-0.5.0 → napkinstack-0.6.0}/pyproject.toml +1 -1
- {napkinstack-0.5.0 → napkinstack-0.6.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/cli.py +5 -2
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/boundaries.py +14 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/manifests.py +29 -1
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/plan.py +11 -8
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/modules.py +41 -5
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/project.py +15 -3
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -6
- {napkinstack-0.5.0 → napkinstack-0.6.0}/LICENSE +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/doctor.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/pull_request.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/tests/.gitkeep +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: napkinstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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,18 +20,21 @@ 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.
|
|
23
|
+
> **Status: v0.5.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
|
|
24
24
|
> the framework announces refuses what it claims to — the boundaries read the contracts a
|
|
25
25
|
> module uses, a contract version someone relies on changes only with a proof, the module
|
|
26
26
|
> checks and stale approvals are enforced, a verifier is not an author, and every verdict names
|
|
27
27
|
> the framework that gave it. Proved on the project that exposed the defects: eight probes
|
|
28
|
-
> replayed, each refused or accepted as announced.
|
|
29
|
-
>
|
|
30
|
-
>
|
|
28
|
+
> replayed, each refused or accepted as announced. The contract proof now runs in the base's
|
|
29
|
+
> tree, so a change cannot rewrite what judges it; the diagnosis says whether anything refuses
|
|
30
|
+
> at all; and a record names what reached the default branch outside a pull request — it
|
|
31
|
+
> records, it never refuses. A first pilot project, private, starts from here.
|
|
31
32
|
> Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
32
33
|
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
33
34
|
> [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
|
|
34
35
|
> [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
|
|
36
|
+
> [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
|
|
37
|
+
> [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
|
|
35
38
|
> [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
36
39
|
|
|
37
40
|
## A project's journey
|
|
@@ -86,8 +89,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
86
89
|
registry — always pinned to a release tag:
|
|
87
90
|
|
|
88
91
|
```bash
|
|
89
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
90
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
92
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
|
|
93
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
|
|
91
94
|
```
|
|
92
95
|
|
|
93
96
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -118,6 +121,7 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
118
121
|
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
119
122
|
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
120
123
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
124
|
+
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
121
125
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
122
126
|
| `nstack update` | Lays the new version on a branch to review |
|
|
123
127
|
|
|
@@ -5,18 +5,21 @@ 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.5.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
|
|
9
9
|
> the framework announces refuses what it claims to — the boundaries read the contracts a
|
|
10
10
|
> module uses, a contract version someone relies on changes only with a proof, the module
|
|
11
11
|
> checks and stale approvals are enforced, a verifier is not an author, and every verdict names
|
|
12
12
|
> the framework that gave it. Proved on the project that exposed the defects: eight probes
|
|
13
|
-
> replayed, each refused or accepted as announced.
|
|
14
|
-
>
|
|
15
|
-
>
|
|
13
|
+
> replayed, each refused or accepted as announced. The contract proof now runs in the base's
|
|
14
|
+
> tree, so a change cannot rewrite what judges it; the diagnosis says whether anything refuses
|
|
15
|
+
> at all; and a record names what reached the default branch outside a pull request — it
|
|
16
|
+
> records, it never refuses. A first pilot project, private, starts from here.
|
|
16
17
|
> Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
|
|
17
18
|
> [move to English](docs/governance/plans/2026-09-16-english-migration.md),
|
|
18
19
|
> [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
|
|
19
20
|
> [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
|
|
21
|
+
> [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
|
|
22
|
+
> [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
|
|
20
23
|
> [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
21
24
|
|
|
22
25
|
## A project's journey
|
|
@@ -71,8 +74,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
71
74
|
registry — always pinned to a release tag:
|
|
72
75
|
|
|
73
76
|
```bash
|
|
74
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
75
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
77
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
|
|
78
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
|
|
76
79
|
```
|
|
77
80
|
|
|
78
81
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -103,6 +106,7 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
103
106
|
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
104
107
|
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
105
108
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
109
|
+
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
106
110
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
107
111
|
| `nstack update` | Lays the new version on a branch to review |
|
|
108
112
|
|
|
@@ -19,7 +19,7 @@ def _root(value: str) -> Path:
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _modules(args: argparse.Namespace) -> int:
|
|
22
|
-
found = modules.listing(args.root, args.changed_since)
|
|
22
|
+
found = modules.listing(args.root, args.changed_since, args.with_contract_sides)
|
|
23
23
|
if found is None:
|
|
24
24
|
print(f"FAIL [modules] base '{args.changed_since}' not found in {args.root}.\n"
|
|
25
25
|
" Action: fetch the history (fetch-depth: 0), or pass an existing commit.")
|
|
@@ -63,7 +63,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
63
63
|
sub = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
64
64
|
_add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M10)",
|
|
65
65
|
lambda a: manifests.run(a.root))
|
|
66
|
-
_add(sub, "boundaries", "declared graph against real graph (B1-
|
|
66
|
+
_add(sub, "boundaries", "declared graph against real graph (B1-B8)",
|
|
67
67
|
lambda a: boundaries.run(a.root))
|
|
68
68
|
_add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
|
|
69
69
|
sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
|
|
@@ -98,6 +98,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
98
98
|
rn.add_argument("module")
|
|
99
99
|
md = _add(sub, "modules", "the project's modules, or those with a file changed since a base", _modules)
|
|
100
100
|
md.add_argument("--changed-since", metavar="BASE", help="only the modules with a file changed since BASE")
|
|
101
|
+
md.add_argument("--with-contract-sides", action="store_true",
|
|
102
|
+
help="also the producer and the declared consumers of a contract version "
|
|
103
|
+
"this change touches (with --changed-since)")
|
|
101
104
|
md.add_argument("--json", action="store_true", help="a JSON list, for CI")
|
|
102
105
|
ps = _add(sub, "pr-scope", "one PR = one module, review budget (P1-P2)",
|
|
103
106
|
lambda a: pr_scope.run(a.root, a.base))
|
|
@@ -16,6 +16,8 @@ Rules:
|
|
|
16
16
|
B5 no direct access to another module's data (tables declared elsewhere)
|
|
17
17
|
B6 a consumed contract is provided: its contract, version and module match a provides entry
|
|
18
18
|
B7 a provided contract exists: its path holds its document, inside a module's folder
|
|
19
|
+
B8 a contract consumed from a deprecated module (warning): a deprecated module takes no
|
|
20
|
+
new consumer, and the existing ones migrate before its removal date
|
|
19
21
|
|
|
20
22
|
DETECTION — textual and deliberately simple, with no stack assumed.
|
|
21
23
|
- A contract is read where a module's file names its path (contracts/billing-api/v1),
|
|
@@ -87,10 +89,13 @@ def load_modules(root: Path) -> dict[str, dict]:
|
|
|
87
89
|
owns = section.get("owns") if isinstance(section.get("owns"), list) else []
|
|
88
90
|
name = mod.get("name") if isinstance(mod.get("name"), str) and mod["name"] else manifest.parent.name
|
|
89
91
|
code_name = mod.get("code_name")
|
|
92
|
+
deprecation = mod.get("deprecation") if isinstance(mod.get("deprecation"), dict) else {}
|
|
90
93
|
modules[name] = {
|
|
91
94
|
"path": manifest.parent,
|
|
92
95
|
"dirname": manifest.parent.name,
|
|
93
96
|
"code_name": code_name if isinstance(code_name, str) and code_name else name,
|
|
97
|
+
"lifecycle": mod.get("lifecycle") if isinstance(mod.get("lifecycle"), str) else None,
|
|
98
|
+
"removal": deprecation.get("removal_date"),
|
|
94
99
|
"provides": contract_entries(data, "provides"),
|
|
95
100
|
"consumes": contract_entries(data, "consumes"),
|
|
96
101
|
"owns_data": {table for table in owns if isinstance(table, str)},
|
|
@@ -204,6 +209,15 @@ def check_contracts(root: Path, modules: dict[str, dict]) -> dict[str, dict[str,
|
|
|
204
209
|
elif c.get("module") not in (None, producer):
|
|
205
210
|
fail("B6", name, f"consumes {key[0]} {key[1]} from '{c.get('module')}', which is "
|
|
206
211
|
f"provided by '{producer}'.\n Action: name the producer.")
|
|
212
|
+
# B8 - a deprecated producer. Reported, not refused: what is already declared is
|
|
213
|
+
# bounded by the removal date, which M5 turns red once it has passed.
|
|
214
|
+
elif modules[producer]["lifecycle"] == "deprecated":
|
|
215
|
+
when = modules[producer]["removal"]
|
|
216
|
+
warn("B8", name, f"consumes {key[0]} {key[1]} from '{producer}', which is deprecated"
|
|
217
|
+
+ (f" (removal {when})" if when else "")
|
|
218
|
+
+ ": a deprecated module takes no new consumer.\n Action: "
|
|
219
|
+
"migrate to the module that replaces it before that date "
|
|
220
|
+
"(docs/os/02-modules.md §6); M5 turns red once it has passed.")
|
|
207
221
|
|
|
208
222
|
patterns = contract_patterns(modules)
|
|
209
223
|
reads: dict[str, dict[str, set[str]]] = {name: {} for name in modules}
|
|
@@ -25,6 +25,7 @@ Output: 0 if everything passes, 1 otherwise. Every failure explains the rule br
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
import sys
|
|
27
27
|
import datetime
|
|
28
|
+
import re
|
|
28
29
|
import subprocess
|
|
29
30
|
from pathlib import Path
|
|
30
31
|
|
|
@@ -86,6 +87,24 @@ def contract_entries(data: dict, section: str) -> list[dict]:
|
|
|
86
87
|
and all(isinstance(entry.get(field), (str, type(None))) for field in CONTRACT_FIELDS[section])]
|
|
87
88
|
|
|
88
89
|
|
|
90
|
+
UNFILLED = re.compile(r"<[^<>\s][^<>]*>") # the angle-bracket placeholder of a template
|
|
91
|
+
TODO = re.compile(r"^TODO\b", re.I)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def unfilled(value) -> bool:
|
|
95
|
+
"""Whether a declaration still holds the template's words: empty, carrying a placeholder
|
|
96
|
+
such as <github-handle>, or opening with TODO (D51).
|
|
97
|
+
|
|
98
|
+
A *declaration* is unfilled as soon as it carries a placeholder — nobody writes a success
|
|
99
|
+
criterion around one. A *cell* of a test sheet is unfilled only when it is nothing but a
|
|
100
|
+
placeholder, which is the template's example row: that one is `pull_request.PLACEHOLDER`,
|
|
101
|
+
and the two meanings are deliberately different. A placeholder opens on a non-space, so
|
|
102
|
+
"stays < 200 ms" is a comparison, not a placeholder.
|
|
103
|
+
"""
|
|
104
|
+
text = str(value or "").strip()
|
|
105
|
+
return not text or bool(UNFILLED.search(text)) or bool(TODO.match(text))
|
|
106
|
+
|
|
107
|
+
|
|
89
108
|
def is_description(path: str) -> bool:
|
|
90
109
|
"""A module's description — its manifest, AGENTS.md, README.md, docs/ — or an empty
|
|
91
110
|
placeholder, `path` relative to the module's folder. Changing it changes no behaviour
|
|
@@ -208,9 +227,18 @@ def check_manifest(path: Path, today: datetime.date) -> None:
|
|
|
208
227
|
fail(rel, "M6", f"contract {name}: removal date passed ({removal}). "
|
|
209
228
|
"Finish the contraction (docs/os/03-contracts.md §4).")
|
|
210
229
|
|
|
211
|
-
# M7 - standard verbs, once there is something to check (D33)
|
|
212
230
|
commands = data.get("commands") or {}
|
|
213
231
|
content = module_content(path.parent)
|
|
232
|
+
|
|
233
|
+
# M2 - the responsibility still the template's, once the module holds more than its
|
|
234
|
+
# description: a module is green as created (D33) and says what it does from its first
|
|
235
|
+
# file of code.
|
|
236
|
+
if content and unfilled(resp):
|
|
237
|
+
fail(rel, "M2", f'module.responsibility is still the template\'s: "{resp}"\n'
|
|
238
|
+
" Action: one sentence naming the capability this module covers, "
|
|
239
|
+
"in MANIFEST.yaml (docs/os/02-modules.md §5).")
|
|
240
|
+
|
|
241
|
+
# M7 - standard verbs, once there is something to check (D33)
|
|
214
242
|
for verb in REQUIRED_COMMANDS:
|
|
215
243
|
if content and not commands.get(verb):
|
|
216
244
|
fail(rel, "M7", f"standard verb missing: commands.{verb}, and the module holds more "
|
|
@@ -31,6 +31,7 @@ from pathlib import Path
|
|
|
31
31
|
|
|
32
32
|
import yaml
|
|
33
33
|
|
|
34
|
+
from napkinstack.fitness.manifests import unfilled
|
|
34
35
|
from napkinstack.modules import HANDLE
|
|
35
36
|
|
|
36
37
|
PROJECT = Path("docs") / "project"
|
|
@@ -114,8 +115,9 @@ def _check_charter(path: Path, fail) -> bool:
|
|
|
114
115
|
fail("C2", path, f"status '{data.get('status')}': expected one of {sorted(CHARTER_STATUSES)}")
|
|
115
116
|
check_decider("C2", path, data.get("decider"), fail)
|
|
116
117
|
criteria = data.get("success_criteria")
|
|
117
|
-
if not isinstance(criteria, list) or not [c for c in criteria if
|
|
118
|
-
fail("C2", path, "success_criteria: at least one, they say when the project
|
|
118
|
+
if not isinstance(criteria, list) or not [c for c in criteria if not unfilled(c)]:
|
|
119
|
+
fail("C2", path, "success_criteria: at least one, filled in — they say when the project "
|
|
120
|
+
"itself ends. The template's example is not one")
|
|
119
121
|
return data.get("status") == "accepted"
|
|
120
122
|
|
|
121
123
|
|
|
@@ -135,16 +137,17 @@ def _check_deliverables(path: Path, deliverables, fail) -> None:
|
|
|
135
137
|
elif ident in seen:
|
|
136
138
|
fail("C4", path, f"deliverable {ident}: id used twice")
|
|
137
139
|
seen.add(ident)
|
|
138
|
-
if
|
|
139
|
-
fail("C4", path, f"deliverable {where}: title missing")
|
|
140
|
+
if unfilled(item.get("title")):
|
|
141
|
+
fail("C4", path, f"deliverable {where}: title missing, or still the template's")
|
|
140
142
|
state = item.get("state")
|
|
141
143
|
if state not in DELIVERABLE_STATES:
|
|
142
144
|
fail("C4", path, f"deliverable {where}: state '{state}', expected one of {sorted(DELIVERABLE_STATES)}")
|
|
143
145
|
criteria = item.get("acceptance")
|
|
144
|
-
filled = isinstance(criteria, list) and [c for c in criteria if
|
|
146
|
+
filled = isinstance(criteria, list) and [c for c in criteria if not unfilled(c)]
|
|
145
147
|
if state in DELIVERABLE_STATES - WITHOUT_CRITERIA and not filled:
|
|
146
|
-
fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria
|
|
147
|
-
"the definition of ready, and the source of its test
|
|
148
|
+
fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria "
|
|
149
|
+
"filled in — the definition of ready, and the source of its test "
|
|
150
|
+
"sheet (PDR-0003)")
|
|
148
151
|
|
|
149
152
|
|
|
150
153
|
def _check_cycle(path: Path, fail) -> str | None:
|
|
@@ -188,7 +191,7 @@ def _check_discovery(path: Path, fail) -> str | None:
|
|
|
188
191
|
check_decider("C7", path, data.get("decider"), fail)
|
|
189
192
|
if as_date(data.get("decided_on")) is None:
|
|
190
193
|
fail("C7", path, f"a decision ({decision}) records decided_on, YYYY-MM-DD")
|
|
191
|
-
if decision == "go" and
|
|
194
|
+
if decision == "go" and unfilled(data.get("challenger")):
|
|
192
195
|
fail("C7", path, "a go names its challenger: another session, or a human, challenged the "
|
|
193
196
|
"document first (playbooks/discovery.md, stage 5)")
|
|
194
197
|
return decision if isinstance(decision, str) else None
|
|
@@ -17,7 +17,8 @@ from pathlib import Path
|
|
|
17
17
|
|
|
18
18
|
import yaml
|
|
19
19
|
|
|
20
|
-
from napkinstack.fitness.manifests import changed_files, find_manifests,
|
|
20
|
+
from napkinstack.fitness.manifests import (changed_files, contract_entries, find_manifests,
|
|
21
|
+
module_content)
|
|
21
22
|
|
|
22
23
|
TEMPLATE = Path(__file__).resolve().parent / "templates" / "module"
|
|
23
24
|
NAME = re.compile(r"[a-z][a-z0-9-]*")
|
|
@@ -134,9 +135,40 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
|
|
|
134
135
|
return steps
|
|
135
136
|
|
|
136
137
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
def _load(manifest: Path) -> dict:
|
|
139
|
+
try:
|
|
140
|
+
data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
|
|
141
|
+
except yaml.YAMLError:
|
|
142
|
+
return {} # reported by nstack manifests (M2)
|
|
143
|
+
return data if isinstance(data, dict) else {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _contract_sides(root: Path, changed: list[str]) -> set[str]:
|
|
147
|
+
"""The folders of the modules on either side of a contract version the change touches: the
|
|
148
|
+
module that provides it, and those that declare it in consumes. The handbook asks for the
|
|
149
|
+
checks of both sides (docs/os/03-contracts.md §5); the declared graph says who they are, so
|
|
150
|
+
one repository needs no broker to know (D53)."""
|
|
151
|
+
provided: dict[tuple[str, str], tuple[str, str]] = {}
|
|
152
|
+
declared: list[tuple[str, list[dict]]] = []
|
|
153
|
+
for manifest in find_manifests(root):
|
|
154
|
+
data = _load(manifest)
|
|
155
|
+
folder = manifest.parent.relative_to(root).as_posix()
|
|
156
|
+
for entry in contract_entries(data, "provides"):
|
|
157
|
+
key, path = (entry.get("contract"), entry.get("version")), entry.get("path")
|
|
158
|
+
if isinstance(path, str) and all(isinstance(part, str) for part in key):
|
|
159
|
+
provided[key] = (path.strip("/"), folder)
|
|
160
|
+
declared.append((folder, contract_entries(data, "consumes")))
|
|
161
|
+
touched = {key: folder for key, (path, folder) in provided.items()
|
|
162
|
+
if any(file == path or file.startswith(f"{path}/") for file in changed)}
|
|
163
|
+
return set(touched.values()) | {folder for folder, consumes in declared for entry in consumes
|
|
164
|
+
if (entry.get("contract"), entry.get("version")) in touched}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def listing(root: Path, base: str | None = None,
|
|
168
|
+
with_contract_sides: bool = False) -> list[dict[str, str]] | None:
|
|
169
|
+
"""Every module — a folder holding a MANIFEST.yaml, contracts/ and platform/ included — or,
|
|
170
|
+
given a base, those with a file changed since it, and with `with_contract_sides` both sides
|
|
171
|
+
of a contract version the change touches (D53); None when the base is unknown."""
|
|
140
172
|
found = [{"name": manifest.parent.name, "folder": manifest.parent.relative_to(root).as_posix()}
|
|
141
173
|
for manifest in find_manifests(root)]
|
|
142
174
|
if base is None:
|
|
@@ -145,7 +177,11 @@ def listing(root: Path, base: str | None = None) -> list[dict[str, str]] | None:
|
|
|
145
177
|
capture_output=True).returncode:
|
|
146
178
|
return None
|
|
147
179
|
changed = changed_files(root, base)
|
|
148
|
-
|
|
180
|
+
folders = {module["folder"] for module in found
|
|
181
|
+
if any(path.startswith(f"{module['folder']}/") for path in changed)}
|
|
182
|
+
if with_contract_sides:
|
|
183
|
+
folders |= _contract_sides(root, changed)
|
|
184
|
+
return [module for module in found if module["folder"] in folders]
|
|
149
185
|
|
|
150
186
|
|
|
151
187
|
def _modules(root: Path) -> dict[str, Path]:
|
|
@@ -55,8 +55,12 @@ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -
|
|
|
55
55
|
return (f"FAIL [{command}] Working tree modified in {where}: an update starts from a "
|
|
56
56
|
f"committed state (PDR-0001).\n{action}commit or stash (git stash), then run again.")
|
|
57
57
|
if match := DOWNGRADE.search(text):
|
|
58
|
+
# The action is a command, not a description of one: an install pinned to an exact
|
|
59
|
+
# version is not moved by `uv tool upgrade`, which is what a reader tries first.
|
|
58
60
|
return (f"FAIL [{command}] Target version {match[2]} older than the project version ({match[1]}): "
|
|
59
|
-
f"no going back (PDR-0001).\n{action}
|
|
61
|
+
f"no going back (PDR-0001).\n{action}move this workstation forward first — "
|
|
62
|
+
'uv tool install "napkinstack@latest" --with-executables-from pre-commit — '
|
|
63
|
+
f"or pin the version you want, {match[1]} or newer.")
|
|
60
64
|
if text.startswith("Updating is only supported in git-tracked subprojects"):
|
|
61
65
|
return (f"FAIL [{command}] {where} is not a git repository: the merge relies on "
|
|
62
66
|
f"history.\n{action}git init, commit, then run again.")
|
|
@@ -64,9 +68,17 @@ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -
|
|
|
64
68
|
return (f"FAIL [{command}] The project does not come from a published version (_commit in {ANSWERS}): "
|
|
65
69
|
f"no merge base.\n{action}create the project from a vX.Y.Z tag.")
|
|
66
70
|
if isinstance(exc, OSError) or text == "Local template must be a directory.":
|
|
67
|
-
|
|
71
|
+
lines = [line.split("|", 1)[-1].strip() for line in text.splitlines() if line.strip()]
|
|
72
|
+
# git writes the cause on an `error:` line and the outcome on a `fatal:` one; keeping
|
|
73
|
+
# the last line alone drops the only one that says what happened (D54).
|
|
74
|
+
detail = " — ".join(line for line in lines if line.startswith(("error:", "fatal:"))) or lines[-1]
|
|
75
|
+
# A git `error:` line means git refused something local, so the action is local too:
|
|
76
|
+
# Copier copies the template's uncommitted state into its clone before reading it.
|
|
77
|
+
local = ("git refused a file of the template's working tree, which Copier copies as it "
|
|
78
|
+
"is: commit or stash it, or remove the file git names above.")
|
|
79
|
+
remote = "check --source and --ref (a vX.Y.Z tag), and network access."
|
|
68
80
|
return (f"FAIL [{command}] Template {source} at version {ref} unreachable: {detail}\n"
|
|
69
|
-
|
|
81
|
+
+ action + (local if detail.startswith("error:") else remote))
|
|
70
82
|
return f"FAIL [{command}] Copier: {text}"
|
|
71
83
|
|
|
72
84
|
|
|
@@ -71,12 +71,6 @@ commands:
|
|
|
71
71
|
# run: # optional: local start, with doubles for the dependencies
|
|
72
72
|
# e2e: # optional: end-to-end scenarios; evidence written to .evidence/
|
|
73
73
|
|
|
74
|
-
# Review budget. Inherited from the project when absent (docs/os/05-workflow.md §4).
|
|
75
|
-
review_budget:
|
|
76
|
-
max_lines: 400
|
|
77
|
-
max_files: 15
|
|
78
|
-
max_modules: 1 # not adjustable
|
|
79
|
-
|
|
80
74
|
docs:
|
|
81
75
|
readme: README.md
|
|
82
76
|
agents: AGENTS.md
|
|
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
|