napkinstack 0.9.4__tar.gz → 0.9.6__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.9.4 → napkinstack-0.9.6}/PKG-INFO +5 -4
- {napkinstack-0.9.4 → napkinstack-0.9.6}/README.md +4 -3
- {napkinstack-0.9.4 → napkinstack-0.9.6}/pyproject.toml +1 -1
- {napkinstack-0.9.4 → napkinstack-0.9.6}/pyproject.toml.orig +1 -1
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/cli.py +25 -2
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/boundaries.py +55 -1
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/manifests.py +50 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/modules.py +43 -5
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/module/MANIFEST.yaml +8 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/LICENSE +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/doctor.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/project.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/pull_request.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/settings.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.9.4 → napkinstack-0.9.6}/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.9.
|
|
3
|
+
Version: 0.9.6
|
|
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,7 +20,7 @@ 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.9.
|
|
23
|
+
> **Status: v0.9.6, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
24
24
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
25
25
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
26
26
|
|
|
@@ -84,8 +84,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
84
84
|
registry — always pinned to a release tag:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.9.
|
|
88
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.
|
|
87
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.9.6" nstack init my-project
|
|
88
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.6"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -117,6 +117,7 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
117
117
|
| `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
|
|
118
118
|
| `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
|
|
119
119
|
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
120
|
+
| `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 |
|
|
120
121
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
121
122
|
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
122
123
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
@@ -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.9.
|
|
8
|
+
> **Status: v0.9.6, 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.9.
|
|
73
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.
|
|
72
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.9.6" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.6"
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -102,6 +102,7 @@ Each project then pins its version and changes it through `nstack update`.
|
|
|
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
104
|
| `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
|
|
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 |
|
|
105
106
|
| `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
|
|
106
107
|
| `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
|
|
107
108
|
| `nstack skills` | Exposes the playbooks as skills for the agent |
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import sys
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -73,9 +74,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
73
74
|
parser = argparse.ArgumentParser(prog="nstack", description="NapkinStack engine.")
|
|
74
75
|
parser.add_argument("--version", action=_Version, nargs=0, help="the engine's version, and where it came from")
|
|
75
76
|
sub = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
76
|
-
_add(sub, "manifests", "manifests, lifecycles, deprecations (M1-
|
|
77
|
+
_add(sub, "manifests", "manifests, lifecycles, deprecations, what a release assembles (M1-M11)",
|
|
77
78
|
lambda a: manifests.run(a.root))
|
|
78
|
-
_add(sub, "boundaries", "declared graph against real graph (B1-
|
|
79
|
+
_add(sub, "boundaries", "declared graph against real graph, release artefacts included (B1-B9)",
|
|
79
80
|
lambda a: boundaries.run(a.root))
|
|
80
81
|
_add(sub, "plan", "the discovery, the charter and the cycles (C1-C8)", lambda a: plan.run(a.root))
|
|
81
82
|
sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
|
|
@@ -115,6 +116,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
115
116
|
lambda a: compat.run(a.root, a.module, a.base))
|
|
116
117
|
cp.add_argument("module", nargs="?", help="module holding the contracts (default: all)")
|
|
117
118
|
cp.add_argument("--base", default="origin/main")
|
|
119
|
+
rl = _add(sub, "release", "makes one module's artefacts, or all of them, each into dist/<module> "
|
|
120
|
+
"(commands.release), with what follows a named module passed to it",
|
|
121
|
+
lambda a: _release(a))
|
|
122
|
+
rl.add_argument("module", nargs="?", help="module name (default: all, each after what it assembles)")
|
|
123
|
+
rl.add_argument("arguments", nargs=argparse.REMAINDER, help="passed to the named module's command")
|
|
118
124
|
rn = _add(sub, "run", "starts a module locally (commands.run), with what follows passed to it",
|
|
119
125
|
lambda a: _run(a))
|
|
120
126
|
rn.add_argument("module")
|
|
@@ -173,6 +179,15 @@ def _named_root(argv: list[str]) -> Path:
|
|
|
173
179
|
return Path(".")
|
|
174
180
|
|
|
175
181
|
|
|
182
|
+
def _release(a) -> int:
|
|
183
|
+
"""`release` passes what follows a named module to its command, as `run` does."""
|
|
184
|
+
root, arguments = a.root, list(a.arguments)
|
|
185
|
+
if "--root" in arguments[:-1]:
|
|
186
|
+
at = arguments.index("--root")
|
|
187
|
+
root, arguments = _root(arguments[at + 1]), arguments[:at] + arguments[at + 2:]
|
|
188
|
+
return modules.run_verb(root, "release", a.module, arguments)
|
|
189
|
+
|
|
190
|
+
|
|
176
191
|
def _run(a) -> int:
|
|
177
192
|
"""`run` passes what follows the module to its command; `--root`, written there as the
|
|
178
193
|
other verbs take it, is still the root."""
|
|
@@ -183,8 +198,16 @@ def _run(a) -> int:
|
|
|
183
198
|
return modules.run_verb(root, "run", a.module, arguments)
|
|
184
199
|
|
|
185
200
|
|
|
201
|
+
# What git hands a hook to name its repository (D126): a hook of a linked worktree gets GIT_DIR,
|
|
202
|
+
# and every git the engine runs, in a module or a checkout of its own, would read that one.
|
|
203
|
+
# The engine names its project with --root; git finds the rest from where it runs.
|
|
204
|
+
GIT_LOCATION = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR")
|
|
205
|
+
|
|
206
|
+
|
|
186
207
|
def main(argv: list[str] | None = None) -> int:
|
|
187
208
|
argv = sys.argv[1:] if argv is None else argv
|
|
209
|
+
for name in GIT_LOCATION:
|
|
210
|
+
os.environ.pop(name, None)
|
|
188
211
|
if late := provenance.behind(_named_root(argv)):
|
|
189
212
|
print(late, file=sys.stderr, flush=True)
|
|
190
213
|
args = build_parser().parse_args(argv)
|
|
@@ -18,6 +18,8 @@ Rules:
|
|
|
18
18
|
B7 a provided contract exists: its path holds its document, inside a module's folder
|
|
19
19
|
B8 a contract consumed from a deprecated module (warning): a deprecated module takes no
|
|
20
20
|
new consumer, and the existing ones migrate before its removal date
|
|
21
|
+
B9 another module's release artefacts — its folder of the project's dist/ — read only by a
|
|
22
|
+
module that declares it in `assembles` (D127)
|
|
21
23
|
|
|
22
24
|
DETECTION — textual and deliberately simple, with no stack assumed.
|
|
23
25
|
- A contract is read where a module's file names its path (contracts/billing-api/v1),
|
|
@@ -99,6 +101,10 @@ def load_modules(root: Path) -> dict[str, dict]:
|
|
|
99
101
|
"provides": contract_entries(data, "provides"),
|
|
100
102
|
"consumes": contract_entries(data, "consumes"),
|
|
101
103
|
"owns_data": {table for table in owns if isinstance(table, str)},
|
|
104
|
+
"assembles": {other for other in (data.get("assembles") or []) if isinstance(other, str)}
|
|
105
|
+
if isinstance(data.get("assembles"), list) else set(),
|
|
106
|
+
"commands": {verb: command for verb, command in (data.get("commands") or {}).items()
|
|
107
|
+
if isinstance(command, str)} if isinstance(data.get("commands"), dict) else {},
|
|
102
108
|
}
|
|
103
109
|
return modules
|
|
104
110
|
|
|
@@ -256,17 +262,65 @@ def check_contracts(root: Path, modules: dict[str, dict]) -> dict[str, dict[str,
|
|
|
256
262
|
return reads
|
|
257
263
|
|
|
258
264
|
|
|
265
|
+
# Another module's artefacts, as a release reads them: from its own artefacts folder, whatever
|
|
266
|
+
# the shell writes the variable ("$NSTACK_DIST", ${NSTACK_DIST}, %NSTACK_DIST%, $(NSTACK_DIST)…),
|
|
267
|
+
# or by a relative path that lands in the project's dist/ — never a dist/ of the module's own.
|
|
268
|
+
FROM_OWN = re.compile(r"NSTACK_DIST[\"'%)}\]]?/\.\./([A-Za-z0-9._-]+)")
|
|
269
|
+
RELATIVE = re.compile(r"(?<![\w.-])((?:\.\./)+)dist/([A-Za-z0-9._-]+)")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def artefact_reads(root: Path, mod: dict, relative: str, text: str) -> tuple[set[str], str]:
|
|
273
|
+
"""The module folders whose artefacts a line reads, and the line without those reads."""
|
|
274
|
+
read: set[str] = set()
|
|
275
|
+
|
|
276
|
+
def taken(match: re.Match) -> str:
|
|
277
|
+
read.add(match.group(match.lastindex))
|
|
278
|
+
return " "
|
|
279
|
+
|
|
280
|
+
text = FROM_OWN.sub(taken, text)
|
|
281
|
+
|
|
282
|
+
def landed(match: re.Match) -> str:
|
|
283
|
+
# From the file, or from the module's folder, where its commands run.
|
|
284
|
+
for start in ((mod["path"] / relative).parent, mod["path"]):
|
|
285
|
+
where = start / match.group(1) / "dist" / match.group(2)
|
|
286
|
+
if Path(posixpath.normpath(where.as_posix())) == root / "dist" / match.group(2):
|
|
287
|
+
read.add(match.group(2))
|
|
288
|
+
return " "
|
|
289
|
+
return match.group(0)
|
|
290
|
+
|
|
291
|
+
return read, RELATIVE.sub(landed, text)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def unassembled(root: Path, name: str, mod: dict, read: set[str], by_folder: dict[str, str], where: str) -> None:
|
|
295
|
+
"""B9: the artefacts read of a module this one does not assemble."""
|
|
296
|
+
for folder in sorted(read):
|
|
297
|
+
other_name = by_folder.get(folder)
|
|
298
|
+
if other_name and other_name != name and folder not in mod["assembles"]:
|
|
299
|
+
fail("B9", where, f"'{name}' reads the release artefacts of '{folder}' without assembling it.\n"
|
|
300
|
+
f" Action: add {folder} to assembles in its MANIFEST "
|
|
301
|
+
"(.nstack/handbook/09-platform.md §2), or stop reading them.")
|
|
302
|
+
|
|
303
|
+
|
|
259
304
|
def check_code(root: Path, modules: dict[str, dict]) -> dict[str, set[str]]:
|
|
260
|
-
"""B2 and
|
|
305
|
+
"""B2, B5 and B9. Returns module -> modules whose code it references."""
|
|
261
306
|
code: dict[str, set[str]] = {name: set() for name in modules}
|
|
262
307
|
others = {name: code_patterns(mod) for name, mod in modules.items()}
|
|
308
|
+
by_folder = {mod["dirname"]: name for name, mod in modules.items()}
|
|
263
309
|
contracts = [str(p.get("path")).strip("/") for mod in modules.values() for p in mod["provides"] if p.get("path")]
|
|
264
310
|
for name, mod in modules.items():
|
|
311
|
+
# The commands its manifest declares, a release's among them, run from its folder.
|
|
312
|
+
for verb, command in mod["commands"].items():
|
|
313
|
+
read, _ = artefact_reads(root, mod, "MANIFEST.yaml", command.replace("\\", "/"))
|
|
314
|
+
unassembled(root, name, mod, read, by_folder, f"{mod['path'].relative_to(root)}/MANIFEST.yaml (commands.{verb})")
|
|
265
315
|
for relative, lines in read_files(mod):
|
|
266
316
|
source = Path(relative).suffix in SOURCE_SUFFIXES
|
|
267
317
|
for lineno, line in enumerate(lines, 1):
|
|
268
318
|
imports = source and IMPORT_HINTS.search(line)
|
|
269
319
|
text = line.replace("\\", "/")
|
|
320
|
+
# B9 - another module's artefacts: read here, they are its artefacts, not its code;
|
|
321
|
+
# what is left of the line is still B2's.
|
|
322
|
+
read, text = artefact_reads(root, mod, relative, text)
|
|
323
|
+
unassembled(root, name, mod, read, by_folder, f"{mod['path'].relative_to(root) / relative}:{lineno}")
|
|
270
324
|
for other_name, (code_import, reach, block) in others.items():
|
|
271
325
|
if other_name == name:
|
|
272
326
|
continue
|
|
@@ -17,6 +17,8 @@ Rules:
|
|
|
17
17
|
M8 runbook required when criticality >= high
|
|
18
18
|
M9 complete file envelope (AGENTS.md, README.md, tests/)
|
|
19
19
|
M10 user_facing declared: true when a user sees the module (.nstack/handbook/05-workflow.md §7)
|
|
20
|
+
M11 assembles names other modules, as a list, and no module waits for itself through it
|
|
21
|
+
(.nstack/handbook/09-platform.md §2, D127)
|
|
20
22
|
|
|
21
23
|
Usage : nstack manifests [--root ROOT]
|
|
22
24
|
Output: 0 if everything passes, 1 otherwise. Every failure explains the rule broken.
|
|
@@ -357,6 +359,53 @@ def comparator_owed(root: Path, manifests: list[Path]) -> None:
|
|
|
357
359
|
"(.nstack/handbook/03-contracts.md §3).")
|
|
358
360
|
|
|
359
361
|
|
|
362
|
+
def assembled(manifests: list[Path], root: Path) -> None:
|
|
363
|
+
"""M11: what a module's release assembles — other modules' artefacts — names them, and never
|
|
364
|
+
leads back to the module itself: `nstack release` runs each after what it assembles."""
|
|
365
|
+
wants: dict[str, list[str]] = {}
|
|
366
|
+
places: dict[str, str] = {}
|
|
367
|
+
for manifest in manifests:
|
|
368
|
+
text = manifest.read_text(encoding="utf-8")
|
|
369
|
+
places[manifest.parent.name] = f"{manifest.relative_to(root).as_posix()}:{line_of(text, 'assembles')}"
|
|
370
|
+
try:
|
|
371
|
+
data = yaml.safe_load(text) or {}
|
|
372
|
+
except yaml.YAMLError:
|
|
373
|
+
continue # M2 says it
|
|
374
|
+
listed = data.get("assembles") if isinstance(data, dict) else None
|
|
375
|
+
if listed is None:
|
|
376
|
+
continue
|
|
377
|
+
name = manifest.parent.name
|
|
378
|
+
if not isinstance(listed, list) or not all(isinstance(other, str) for other in listed):
|
|
379
|
+
fail(name, "M11", "assembles must be a list of module names.\n"
|
|
380
|
+
" Action: assembles: [<module>, …] — the modules whose release artefacts this one's release reads.",
|
|
381
|
+
places[name])
|
|
382
|
+
continue
|
|
383
|
+
wants[name] = listed
|
|
384
|
+
known = {manifest.parent.name for manifest in manifests}
|
|
385
|
+
for name, listed in wants.items():
|
|
386
|
+
for other in listed:
|
|
387
|
+
if other == name or other not in known:
|
|
388
|
+
fail(name, "M11", f"assembles '{other}', " + ("itself" if other == name else "a module that does not exist") + ".\n"
|
|
389
|
+
" Action: name another module of this project, or remove the entry (.nstack/handbook/09-platform.md §2).",
|
|
390
|
+
places[name])
|
|
391
|
+
|
|
392
|
+
def back(name: str, path: list[str]) -> list[str] | None:
|
|
393
|
+
for other in wants.get(name, []):
|
|
394
|
+
if other == path[0]:
|
|
395
|
+
return path + [other]
|
|
396
|
+
if other not in path and (found := back(other, path + [other])):
|
|
397
|
+
return found
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
seen: set[frozenset[str]] = set()
|
|
401
|
+
for name in sorted(wants):
|
|
402
|
+
loop = back(name, [name])
|
|
403
|
+
if loop and frozenset(loop) not in seen:
|
|
404
|
+
seen.add(frozenset(loop))
|
|
405
|
+
fail(name, "M11", f"releases that wait for each other: {' → '.join(loop)}.\n"
|
|
406
|
+
" Action: one of them stops assembling the other.", places[name])
|
|
407
|
+
|
|
408
|
+
|
|
360
409
|
def run(root: Path) -> int:
|
|
361
410
|
failures.clear()
|
|
362
411
|
warnings.clear()
|
|
@@ -372,6 +421,7 @@ def run(root: Path) -> int:
|
|
|
372
421
|
|
|
373
422
|
for manifest in manifests:
|
|
374
423
|
check_manifest(manifest, today, root)
|
|
424
|
+
assembled(manifests, root)
|
|
375
425
|
comparator_owed(root, manifests)
|
|
376
426
|
|
|
377
427
|
print(f"Manifests checked: {len(manifests)}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""
|
|
2
2
|
A project's modules: creation (nstack new-module) and standard verbs (nstack bootstrap,
|
|
3
|
-
check, test, run, e2e, clean), with no imposed stack (PRODUCT.md P1, PDR-0001 R5).
|
|
3
|
+
check, test, run, e2e, clean, release), with no imposed stack (PRODUCT.md P1, PDR-0001 R5).
|
|
4
4
|
|
|
5
5
|
Each verb runs the command declared in the `commands` section of the module's
|
|
6
6
|
MANIFEST.yaml, from its folder, through the system shell. NapkinStack never assumes a
|
|
@@ -10,6 +10,7 @@ borrowed from Nx (`nx test <project>`) and moon (`moon run project:task`).
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import os
|
|
13
14
|
import re
|
|
14
15
|
import shutil
|
|
15
16
|
import shlex
|
|
@@ -31,6 +32,8 @@ LOGIN = r"[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}" # the same rule, unanchored: a ha
|
|
|
31
32
|
OWNER = re.compile( # same rule as copier.yml: organisation/team, or a GitHub user
|
|
32
33
|
rf"[A-Za-z0-9-]+/[A-Za-z0-9._-]+|{HANDLE}")
|
|
33
34
|
OPTIONAL = {"bootstrap": "nothing to prepare", "e2e": "no end-to-end scenario", "clean": "nothing to clean"} # undeclared: skipped
|
|
35
|
+
# Undeclared, skipped when every module is released; asked of one module by name, it is owed.
|
|
36
|
+
ALL_ONLY = {"release": "nothing to release"}
|
|
34
37
|
# What a criticality requires is the matrix of .nstack/handbook/05-workflow.md §7, read once (assurance).
|
|
35
38
|
|
|
36
39
|
RUNBOOK = """# Runbook - {name}
|
|
@@ -301,13 +304,36 @@ def _modules(root: Path) -> dict[str, Path]:
|
|
|
301
304
|
return {manifest.parent.name: manifest.parent for manifest in find_manifests(root)}
|
|
302
305
|
|
|
303
306
|
|
|
307
|
+
def release_order(known: dict[str, Path]) -> list[str]:
|
|
308
|
+
"""Every module after those its release assembles (D127); otherwise in their order."""
|
|
309
|
+
wants = {}
|
|
310
|
+
for target, folder in known.items():
|
|
311
|
+
try:
|
|
312
|
+
listed = (yaml.safe_load((folder / "MANIFEST.yaml").read_text(encoding="utf-8")) or {}).get("assembles")
|
|
313
|
+
except (yaml.YAMLError, AttributeError):
|
|
314
|
+
listed = None
|
|
315
|
+
wants[target] = [other for other in listed if other in known] if isinstance(listed, list) else []
|
|
316
|
+
order: list[str] = []
|
|
317
|
+
|
|
318
|
+
def place(target: str, path: tuple[str, ...]) -> None:
|
|
319
|
+
if target in order or target in path: # a loop is M11's to refuse
|
|
320
|
+
return
|
|
321
|
+
for other in wants[target]:
|
|
322
|
+
place(other, path + (target,))
|
|
323
|
+
order.append(target)
|
|
324
|
+
|
|
325
|
+
for target in known:
|
|
326
|
+
place(target, ())
|
|
327
|
+
return order
|
|
328
|
+
|
|
329
|
+
|
|
304
330
|
def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | None = None) -> int:
|
|
305
331
|
known = _modules(root)
|
|
306
332
|
if name is not None and name not in known:
|
|
307
333
|
print(f"FAIL [{verb}] module '{name}' not found in {root}: no MANIFEST.yaml under that name.\n"
|
|
308
334
|
f" Known modules: {', '.join(known) or 'none'}.")
|
|
309
335
|
return 1
|
|
310
|
-
targets = [name] if name is not None else list(known)
|
|
336
|
+
targets = [name] if name is not None else (release_order(known) if verb == "release" else list(known))
|
|
311
337
|
if not targets:
|
|
312
338
|
print(f"No module in {root}: nothing to run.")
|
|
313
339
|
return 0
|
|
@@ -323,16 +349,28 @@ def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | Non
|
|
|
323
349
|
return 1
|
|
324
350
|
command = commands.get(verb)
|
|
325
351
|
if not command:
|
|
326
|
-
if verb in OPTIONAL:
|
|
327
|
-
print(f"-> {target}: {verb} not declared, {OPTIONAL[verb]}.")
|
|
352
|
+
if verb in OPTIONAL or (verb in ALL_ONLY and name is None):
|
|
353
|
+
print(f"-> {target}: {verb} not declared, {OPTIONAL.get(verb) or ALL_ONLY[verb]}.")
|
|
328
354
|
continue
|
|
329
355
|
print(f"FAIL [{verb}] module '{target}': commands.{verb} not declared in {manifest}.\n"
|
|
330
356
|
" Action: declare the module stack's command there (.nstack/handbook/09-platform.md §2).")
|
|
331
357
|
return 1
|
|
332
358
|
if arguments:
|
|
333
359
|
command = " ".join([command, *map(shlex.quote, arguments)])
|
|
360
|
+
environment = None
|
|
361
|
+
if verb == "release":
|
|
362
|
+
# Its artefacts, and nothing left from an earlier release: dist/<module>, emptied first.
|
|
363
|
+
dist = root / "dist" / target
|
|
364
|
+
odd = next((path for path in (dist.parent, dist) if path.is_symlink() or (path.exists() and not path.is_dir())), None)
|
|
365
|
+
if odd is not None:
|
|
366
|
+
print(f"FAIL [release] {odd.relative_to(root)} is a link or a file, not a folder of the project's own: "
|
|
367
|
+
"nothing is emptied through it.\n Action: remove it, then release again.")
|
|
368
|
+
return 1
|
|
369
|
+
shutil.rmtree(dist, ignore_errors=True)
|
|
370
|
+
dist.mkdir(parents=True)
|
|
371
|
+
environment = {**os.environ, "NSTACK_DIST": str(dist.resolve())}
|
|
334
372
|
print(f"-> {target}: {command}", flush=True)
|
|
335
|
-
code = subprocess.run(command, shell=True, cwd=known[target]).returncode
|
|
373
|
+
code = subprocess.run(command, shell=True, cwd=known[target], env=environment).returncode
|
|
336
374
|
if code:
|
|
337
375
|
print(f"FAIL [{verb}] module '{target}': `{command}` exited with {code}.")
|
|
338
376
|
return code
|
|
@@ -50,6 +50,11 @@ consumes: []
|
|
|
50
50
|
# version: v1
|
|
51
51
|
# module: identity
|
|
52
52
|
|
|
53
|
+
# Modules whose release artefacts this module's release assembles — what a user installs,
|
|
54
|
+
# made of theirs (.nstack/handbook/09-platform.md §2). Read in dist/<module>/, never in their
|
|
55
|
+
# folders; `nstack release` runs them first (M11, B9).
|
|
56
|
+
# assembles: []
|
|
57
|
+
|
|
53
58
|
# Data owned. Nobody else accesses it directly.
|
|
54
59
|
data:
|
|
55
60
|
owns: []
|
|
@@ -75,6 +80,9 @@ commands:
|
|
|
75
80
|
# e2e: # optional: end-to-end scenarios; evidence written to .evidence/
|
|
76
81
|
# clean: # optional: remove what the commands above leave in this folder
|
|
77
82
|
# # (build outputs); CI runs it after every job on a self-hosted runner
|
|
83
|
+
# release: # optional: the module's shippable artefacts, written into
|
|
84
|
+
# # $NSTACK_DIST (dist/<module>/); `nstack release <module> …`
|
|
85
|
+
# # passes the platform or target to it
|
|
78
86
|
|
|
79
87
|
docs:
|
|
80
88
|
readme: README.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|