napkinstack 0.7.5__tar.gz → 0.7.7__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.7.5 → napkinstack-0.7.7}/PKG-INFO +4 -4
- {napkinstack-0.7.5 → napkinstack-0.7.7}/README.md +3 -3
- {napkinstack-0.7.5 → napkinstack-0.7.7}/pyproject.toml +1 -1
- {napkinstack-0.7.5 → napkinstack-0.7.7}/pyproject.toml.orig +1 -1
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/cli.py +30 -7
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/doctor.py +49 -6
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/fitness/hygiene.py +20 -4
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/fitness/manifests.py +102 -29
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/modules.py +6 -3
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/MANIFEST.yaml +5 -3
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/module/MANIFEST.yaml +2 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/LICENSE +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/project.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/pull_request.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/settings.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {napkinstack-0.7.5 → napkinstack-0.7.7}/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.7.
|
|
3
|
+
Version: 0.7.7
|
|
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.7.
|
|
23
|
+
> **Status: v0.7.7, 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.7.
|
|
88
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.
|
|
87
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.7.7" nstack init my-project
|
|
88
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.7"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -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.7.
|
|
8
|
+
> **Status: v0.7.7, 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.7.
|
|
73
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.
|
|
72
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.7.7" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.7"
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
|
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from napkinstack import assurance
|
|
11
|
-
from napkinstack import (
|
|
11
|
+
from napkinstack import (compat, discovery, doctor, landed, modules, provenance, settings,
|
|
12
12
|
pull_request, skills)
|
|
13
13
|
from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
|
|
14
14
|
|
|
@@ -59,9 +59,19 @@ def _update(args: argparse.Namespace) -> int:
|
|
|
59
59
|
return project.update(args.root, args.ref or project.default_ref())
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
class _Version(argparse.Action):
|
|
63
|
+
"""The version, as the first line of a judging run says it: a version installed from
|
|
64
|
+
anywhere but the registry is no published version (PDR-0005)."""
|
|
65
|
+
|
|
66
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
67
|
+
version, origin = provenance.engine()
|
|
68
|
+
print(f"nstack {version}" + (f" — UNPUBLISHED: {origin}" if origin else ""))
|
|
69
|
+
parser.exit()
|
|
70
|
+
|
|
71
|
+
|
|
62
72
|
def build_parser() -> argparse.ArgumentParser:
|
|
63
73
|
parser = argparse.ArgumentParser(prog="nstack", description="NapkinStack engine.")
|
|
64
|
-
parser.add_argument("--version", action=
|
|
74
|
+
parser.add_argument("--version", action=_Version, nargs=0, help="the engine's version, and where it came from")
|
|
65
75
|
sub = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
66
76
|
_add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M10)",
|
|
67
77
|
lambda a: manifests.run(a.root))
|
|
@@ -96,16 +106,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
96
106
|
for verb, help_text in (("bootstrap", "prepares one module, or all of them (commands.bootstrap)"),
|
|
97
107
|
("check", "format, lint, types of one module, or all (commands.check)"),
|
|
98
108
|
("test", "tests of one module, or of all of them (commands.test)"),
|
|
99
|
-
("e2e", "end-to-end scenarios of one module, or of all (commands.e2e)")
|
|
109
|
+
("e2e", "end-to-end scenarios of one module, or of all (commands.e2e)"),
|
|
110
|
+
("clean", "removes what one module's commands left in its folder, or all "
|
|
111
|
+
"modules' (commands.clean)")):
|
|
100
112
|
vb = _add(sub, verb, help_text, lambda a, v=verb: modules.run_verb(a.root, v, a.module))
|
|
101
113
|
vb.add_argument("module", nargs="?", help="module name (default: all)")
|
|
102
114
|
cp = _add(sub, "compat", "a frozen contract version changes only with a proof (V1, commands.compat)",
|
|
103
115
|
lambda a: compat.run(a.root, a.module, a.base))
|
|
104
116
|
cp.add_argument("module", nargs="?", help="module holding the contracts (default: all)")
|
|
105
117
|
cp.add_argument("--base", default="origin/main")
|
|
106
|
-
rn = _add(sub, "run", "starts a module locally (commands.run)",
|
|
107
|
-
lambda a:
|
|
118
|
+
rn = _add(sub, "run", "starts a module locally (commands.run), with what follows passed to it",
|
|
119
|
+
lambda a: _run(a))
|
|
108
120
|
rn.add_argument("module")
|
|
121
|
+
rn.add_argument("arguments", nargs=argparse.REMAINDER, help="passed to the module's command")
|
|
109
122
|
md = _add(sub, "modules", "the project's modules, or those with a file changed since a base", _modules)
|
|
110
123
|
md.add_argument("--changed-since", metavar="BASE", help="only the modules with a file changed since BASE")
|
|
111
124
|
md.add_argument("--with-contract-sides", action="store_true",
|
|
@@ -137,11 +150,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
137
150
|
ini.add_argument("--owner-team", help="owner of the foundation: organisation/team, or a user "
|
|
138
151
|
"when the project has no organisation (asked when absent)")
|
|
139
152
|
ini.add_argument("--source", help="template: URL or path (default: the NapkinStack repository)")
|
|
140
|
-
ini.add_argument("--ref", help="skeleton version
|
|
153
|
+
ini.add_argument("--ref", help="skeleton version: a tag vX.Y.Z, or a commit (default: the one of nstack)")
|
|
141
154
|
ini.set_defaults(func=_init)
|
|
142
155
|
up = _add(sub, "update", "merges a NapkinStack version onto a branch to review",
|
|
143
156
|
_update)
|
|
144
|
-
up.add_argument("--ref", help="target version
|
|
157
|
+
up.add_argument("--ref", help="target version: a tag vX.Y.Z, or a commit (default: the one of nstack)")
|
|
145
158
|
return parser
|
|
146
159
|
|
|
147
160
|
|
|
@@ -160,6 +173,16 @@ def _named_root(argv: list[str]) -> Path:
|
|
|
160
173
|
return Path(".")
|
|
161
174
|
|
|
162
175
|
|
|
176
|
+
def _run(a) -> int:
|
|
177
|
+
"""`run` passes what follows the module to its command; `--root`, written there as the
|
|
178
|
+
other verbs take it, is still the root."""
|
|
179
|
+
root, arguments = a.root, list(a.arguments)
|
|
180
|
+
if "--root" in arguments[:-1]:
|
|
181
|
+
at = arguments.index("--root")
|
|
182
|
+
root, arguments = _root(arguments[at + 1]), arguments[:at] + arguments[at + 2:]
|
|
183
|
+
return modules.run_verb(root, "run", a.module, arguments)
|
|
184
|
+
|
|
185
|
+
|
|
163
186
|
def main(argv: list[str] | None = None) -> int:
|
|
164
187
|
argv = sys.argv[1:] if argv is None else argv
|
|
165
188
|
if late := provenance.behind(_named_root(argv)):
|
|
@@ -6,7 +6,10 @@ project. CHECKLIST is printed by nstack init, checked here, and repeated word fo
|
|
|
6
6
|
the foundation manual, skeleton/.nstack/handbook/INSTALL.md.jinja (a test verifies it).
|
|
7
7
|
|
|
8
8
|
The registry is asked one question, without a token: which version is the latest published
|
|
9
|
-
(NSTACK_REGISTRY_URL, otherwise https://pypi.org). An answer changes no exit code.
|
|
9
|
+
(NSTACK_REGISTRY_URL, otherwise https://pypi.org). An answer changes no exit code. For a project
|
|
10
|
+
pinned to an unpublished commit, the framework's repository is asked another, without a token
|
|
11
|
+
either: whether its default branch holds that commit (GITHUB_API_URL, otherwise
|
|
12
|
+
https://api.github.com). No answer leaves L1 as it is.
|
|
10
13
|
|
|
11
14
|
GitHub is read with a token supplied by a human (GH_TOKEN, otherwise GITHUB_TOKEN):
|
|
12
15
|
fine-grained, limited to the repository, Administration: read permission. Without a token,
|
|
@@ -14,7 +17,9 @@ or when the API refuses a read, the setting is "not verified", never compliant.
|
|
|
14
17
|
|
|
15
18
|
Rules:
|
|
16
19
|
L1 nstack installed at the project version (_commit in .copier-answers.yml), both
|
|
17
|
-
published: an unpublished framework is a gap, never compliance (PDR-0005)
|
|
20
|
+
published: an unpublished framework is a gap, never compliance (PDR-0005) — and a pinned
|
|
21
|
+
commit the framework's default branch does not hold, a pull request's head, is said with
|
|
22
|
+
the gesture that moves the pin once it is merged
|
|
18
23
|
L2 git and pre-commit available
|
|
19
24
|
L3 pre-commit hooks installed
|
|
20
25
|
L4 retired (D79): it refused a file named PRODUCT.md, which a product may write. The
|
|
@@ -67,6 +72,7 @@ THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skele
|
|
|
67
72
|
LABELS = ("cross-module", "over-budget", "out-of-cycle")
|
|
68
73
|
PUBLISHED = provenance.PUBLISHED # a published version is a vX.Y.Z tag (ADR-0002), as CI installs it
|
|
69
74
|
REMOTE = re.compile(r"https://|ssh://|git@[^:/]+:|gh:|gl:") # as .nstack/install-engine.sh reads them
|
|
75
|
+
ON_GITHUB = re.compile(r"(?:https://github\.com/|ssh://git@github\.com/|git@github\.com:|gh:)([^/]+/[^/]+?)(?:\.git)?/?$")
|
|
70
76
|
|
|
71
77
|
RULESET = "Settings → Rules → Rulesets, main branch"
|
|
72
78
|
SECURITY = "Settings → Advanced Security"
|
|
@@ -277,17 +283,54 @@ def _default_owner(root: Path) -> tuple[str, str]:
|
|
|
277
283
|
"its first rule: the code owner review then covers every path (.nstack/handbook/07-governance.md §7).")
|
|
278
284
|
|
|
279
285
|
|
|
286
|
+
def _forge(path: str) -> dict | None:
|
|
287
|
+
"""JSON of a public repository's page on the forge, read without a token; None when it does
|
|
288
|
+
not answer."""
|
|
289
|
+
api = (os.environ.get("GITHUB_API_URL") or "https://api.github.com").rstrip("/")
|
|
290
|
+
request = urllib.request.Request(f"{api}{path}", headers={
|
|
291
|
+
"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": API_VERSION,
|
|
292
|
+
"User-Agent": f"nstack/{__version__}"})
|
|
293
|
+
try:
|
|
294
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
295
|
+
data = json.load(response)
|
|
296
|
+
except (OSError, ValueError):
|
|
297
|
+
return None
|
|
298
|
+
return data if isinstance(data, dict) else None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _left_behind(answers: dict) -> str | None:
|
|
302
|
+
"""The framework's default branch, when it does not hold the pinned commit; None when it
|
|
303
|
+
does, or when the forge could not say. A fix may be pinned while it is reviewed; its squash
|
|
304
|
+
then gives the default branch another commit, and leaves this one on no branch."""
|
|
305
|
+
commit = str(answers.get("_commit") or "")
|
|
306
|
+
repository = ON_GITHUB.match(str(answers.get("_src_path") or ""))
|
|
307
|
+
if "-g" not in commit or not repository:
|
|
308
|
+
return None
|
|
309
|
+
sha, repo = commit.rsplit("-g", 1)[1], repository.group(1)
|
|
310
|
+
branch = (_forge(f"/repos/{repo}") or {}).get("default_branch")
|
|
311
|
+
status = (_forge(f"/repos/{repo}/compare/{branch}...{sha}") or {}).get("status") if branch else None
|
|
312
|
+
return branch if status in ("ahead", "diverged") else None
|
|
313
|
+
|
|
314
|
+
|
|
280
315
|
def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
|
|
281
316
|
commit = str(answers.get("_commit") or "")
|
|
282
317
|
project = commit.removeprefix("v")
|
|
283
|
-
install = f'uv tool install "napkinstack=={project}" --with-executables-from pre-commit'
|
|
318
|
+
install = (f'uv tool install "napkinstack=={project}" --with-executables-from pre-commit'
|
|
319
|
+
if PUBLISHED.fullmatch(commit) else "bash .nstack/install-engine.sh")
|
|
284
320
|
results = []
|
|
285
321
|
|
|
286
322
|
_, origin = provenance.engine()
|
|
287
323
|
if not PUBLISHED.fullmatch(commit):
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
324
|
+
branch = _left_behind(answers)
|
|
325
|
+
if branch:
|
|
326
|
+
l1 = (GAP, f"The project is pinned to an unpublished framework ({commit}), a commit the "
|
|
327
|
+
f"framework's {branch} does not hold: a pull request's head, which its squash leaves "
|
|
328
|
+
f"on no branch.\nAction: once it is merged, nstack update --ref <the commit {branch} "
|
|
329
|
+
"received for that change>, or --ref vX.Y.Z, a release that carries it.")
|
|
330
|
+
else:
|
|
331
|
+
l1 = (GAP, f"The project is pinned to an unpublished framework ({commit or 'unknown'}): its "
|
|
332
|
+
"verdicts come from rules nobody has published.\nAction: once the change it "
|
|
333
|
+
"waits for is released, nstack update --ref vX.Y.Z.")
|
|
291
334
|
elif origin is not None:
|
|
292
335
|
l1 = (GAP, f"The nstack running is unpublished ({origin}).\nAction: {install}")
|
|
293
336
|
elif project != __version__:
|
|
@@ -12,9 +12,10 @@ Rules:
|
|
|
12
12
|
|
|
13
13
|
Refused: the home of a named user, on any of the three systems, and a tilde followed by a
|
|
14
14
|
real folder. Accepted, because they are true on every machine: a tilde followed by a
|
|
15
|
-
dot-directory (`~/.config`, `~/.cache`, `~/.ssh` — tooling is documented legitimately)
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
dot-directory (`~/.config`, `~/.cache`, `~/.ssh` — tooling is documented legitimately) and
|
|
16
|
+
its macOS counterpart, `~/Library`, where every Mac keeps software's own files and a macOS
|
|
17
|
+
app documents its storage (D97); a tilde inside an address, the repository's placeholders
|
|
18
|
+
(`<workspace>`), environment variables, and the home of an image's own user — `node`, `vscode`, `runner`, `codespace`,
|
|
18
19
|
`gitpod`, `jovyan` — which is the same wherever the image runs (D41).
|
|
19
20
|
|
|
20
21
|
The patterns are written so that this file, the rule's tests and any document describing
|
|
@@ -45,7 +46,7 @@ IMAGE_USERS = r"(?:node|vscode|runner|codespace|gitpod|jovyan)(?![A-Za-z0-9_.-])
|
|
|
45
46
|
SEGMENT = r"[A-Za-z0-9_.-]+"
|
|
46
47
|
HOME_PATH = re.compile(
|
|
47
48
|
rf"(?:/home/(?!{IMAGE_USERS}){SEGMENT}|/Users/{SEGMENT}|[A-Za-z]:\\Users\\{SEGMENT}"
|
|
48
|
-
r"|(?<![\w/:])~(?=/[A-Za-z0-9]))"
|
|
49
|
+
r"|(?<![\w/:])~(?=/[A-Za-z0-9])(?!/Library(?![\w.-])))"
|
|
49
50
|
r"[^\s'\"`,;)\]]*"
|
|
50
51
|
)
|
|
51
52
|
ACTION = ("write what the step needs: a path relative to the repository, a placeholder such "
|
|
@@ -63,6 +64,16 @@ def tracked_files(root: Path) -> list[Path] | None:
|
|
|
63
64
|
return [root / name for name in names if name]
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
def untracked(root: Path) -> int:
|
|
68
|
+
"""How many files git does not track yet and does not ignore."""
|
|
69
|
+
try:
|
|
70
|
+
listed = subprocess.run(["git", "ls-files", "-z", "--others", "--exclude-standard"], cwd=root,
|
|
71
|
+
check=True, capture_output=True)
|
|
72
|
+
except (OSError, subprocess.CalledProcessError):
|
|
73
|
+
return 0
|
|
74
|
+
return len([name for name in listed.stdout.split(b"\0") if name])
|
|
75
|
+
|
|
76
|
+
|
|
66
77
|
def run(root: Path) -> int:
|
|
67
78
|
files = tracked_files(root)
|
|
68
79
|
if files is None:
|
|
@@ -87,6 +98,11 @@ def run(root: Path) -> int:
|
|
|
87
98
|
f"directory — {ACTION}")
|
|
88
99
|
|
|
89
100
|
print(f"Hygiene: {read} tracked text file(s) read.")
|
|
101
|
+
# A file not yet added is read by nobody here and by everyone once committed: said, so
|
|
102
|
+
# that a check passed before `git add` is not taken for one passed on the commit.
|
|
103
|
+
waiting = untracked(root)
|
|
104
|
+
if waiting:
|
|
105
|
+
print(f"Hygiene: {waiting} file(s) not yet added to git, not read — add them and run it again.")
|
|
90
106
|
for failure in failures:
|
|
91
107
|
print(f" FAIL {failure}")
|
|
92
108
|
if failures:
|
|
@@ -50,8 +50,21 @@ failures: list[str] = []
|
|
|
50
50
|
warnings: list[str] = []
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
def fail(module: str, rule: str, message: str) -> None:
|
|
54
|
-
|
|
53
|
+
def fail(module: str, rule: str, message: str, at: str = "") -> None:
|
|
54
|
+
"""A refusal: the rule, the module, the place — `file:line` — and, in the message, the action (P6)."""
|
|
55
|
+
failures.append(f"[{rule}] {module}" + (f" — {at}" if at else "") + f"\n {message}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def line_of(text: str, *keys: str) -> int:
|
|
59
|
+
"""The line of `keys`, each one searched below the one before; the deepest found, or 1."""
|
|
60
|
+
lines, found, start = text.splitlines(), 1, 0
|
|
61
|
+
for key in keys:
|
|
62
|
+
pattern = re.compile(rf"^\s*(?:-\s*)?{re.escape(key)}\s*:")
|
|
63
|
+
hit = next((index for index in range(start, len(lines)) if pattern.match(lines[index])), None)
|
|
64
|
+
if hit is None:
|
|
65
|
+
break
|
|
66
|
+
found, start = hit + 1, hit + 1
|
|
67
|
+
return found
|
|
55
68
|
|
|
56
69
|
|
|
57
70
|
def warn(module: str, rule: str, message: str) -> None:
|
|
@@ -156,42 +169,59 @@ def parse_date(value) -> datetime.date | None:
|
|
|
156
169
|
return None
|
|
157
170
|
|
|
158
171
|
|
|
159
|
-
def check_manifest(path: Path, today: datetime.date) -> None:
|
|
172
|
+
def check_manifest(path: Path, today: datetime.date, root: Path | None = None) -> None:
|
|
160
173
|
rel = path.parent.name
|
|
174
|
+
folder = path.parent.relative_to(root).as_posix() if root else rel
|
|
175
|
+
text = path.read_text(encoding="utf-8")
|
|
176
|
+
|
|
177
|
+
def at(*keys: str) -> str:
|
|
178
|
+
return f"{folder}/MANIFEST.yaml:{line_of(text, *keys)}"
|
|
179
|
+
|
|
161
180
|
try:
|
|
162
|
-
data = yaml.safe_load(
|
|
181
|
+
data = yaml.safe_load(text) or {}
|
|
163
182
|
except yaml.YAMLError as exc:
|
|
164
|
-
|
|
183
|
+
mark = getattr(exc, "problem_mark", None)
|
|
184
|
+
fail(rel, "M2", f"MANIFEST.yaml unreadable: {exc}\n Action: correct the YAML at that line.",
|
|
185
|
+
f"{folder}/MANIFEST.yaml:{mark.line + 1 if mark else 1}")
|
|
165
186
|
return
|
|
166
187
|
if not isinstance(data, dict):
|
|
167
|
-
fail(rel, "M2", "MANIFEST.yaml must be a YAML mapping (sections module, commands, docs...)
|
|
188
|
+
fail(rel, "M2", "MANIFEST.yaml must be a YAML mapping (sections module, commands, docs...).\n"
|
|
189
|
+
" Action: rewrite it as a mapping — nstack new-module writes one to copy.",
|
|
190
|
+
f"{folder}/MANIFEST.yaml:1")
|
|
168
191
|
return
|
|
169
192
|
for section, expected_type in SECTIONS.items():
|
|
170
193
|
if data.get(section) is not None and not isinstance(data[section], expected_type):
|
|
171
194
|
fail(rel, "M2", f"section {section}: expected a {TYPES[expected_type]}, "
|
|
172
|
-
f"found {type(data[section]).__name__}
|
|
195
|
+
f"found {type(data[section]).__name__}.\n"
|
|
196
|
+
f" Action: write {section} as a {TYPES[expected_type]}.", at(section))
|
|
173
197
|
data[section] = expected_type()
|
|
174
198
|
|
|
175
199
|
mod = data.get("module") or {}
|
|
176
200
|
for field in ("name", "code_name"):
|
|
177
201
|
if field in mod and not isinstance(mod[field], str):
|
|
178
|
-
fail(rel, "M2", f"module.{field} {mod[field]!r} must be text."
|
|
202
|
+
fail(rel, "M2", f"module.{field} {mod[field]!r} must be text.\n Action: write it quoted.",
|
|
203
|
+
at("module", field))
|
|
179
204
|
owns = (data.get("data") or {}).get("owns")
|
|
180
205
|
if isinstance(owns, list) and not all(isinstance(table, str) for table in owns):
|
|
181
|
-
fail(rel, "M2", "data.owns: every entry names a table, as text
|
|
206
|
+
fail(rel, "M2", "data.owns: every entry names a table, as text.\n"
|
|
207
|
+
" Action: write each table's name, quoted.", at("data", "owns"))
|
|
182
208
|
|
|
183
209
|
# M2 - required fields
|
|
184
210
|
for field in REQUIRED_FIELDS:
|
|
185
211
|
if not mod.get(field):
|
|
186
|
-
fail(rel, "M2", f"required field missing: module.{field}"
|
|
212
|
+
fail(rel, "M2", f"required field missing: module.{field}\n"
|
|
213
|
+
f" Action: add {field}: under module:.", at("module", field))
|
|
187
214
|
|
|
188
215
|
# M3 - valid values
|
|
189
216
|
lifecycle = mod.get("lifecycle")
|
|
190
217
|
if lifecycle and lifecycle not in LIFECYCLES:
|
|
191
|
-
fail(rel, "M3", f"invalid lifecycle: '{lifecycle}'. Expected one of: {sorted(LIFECYCLES)}"
|
|
218
|
+
fail(rel, "M3", f"invalid lifecycle: '{lifecycle}'. Expected one of: {sorted(LIFECYCLES)}\n"
|
|
219
|
+
" Action: write one of them.", at("module", "lifecycle"))
|
|
192
220
|
criticality = mod.get("criticality")
|
|
193
221
|
if criticality and criticality not in CRITICALITIES:
|
|
194
|
-
fail(rel, "M3", f"invalid criticality: '{criticality}'. Expected one of: {sorted(CRITICALITIES)}"
|
|
222
|
+
fail(rel, "M3", f"invalid criticality: '{criticality}'. Expected one of: {sorted(CRITICALITIES)}\n"
|
|
223
|
+
" Action: write one of them — what each one costs: .nstack/handbook/05-workflow.md §7.",
|
|
224
|
+
at("module", "criticality"))
|
|
195
225
|
|
|
196
226
|
# M4 - responsibility in one sentence
|
|
197
227
|
resp = (mod.get("responsibility") or "").strip()
|
|
@@ -207,10 +237,13 @@ def check_manifest(path: Path, today: datetime.date) -> None:
|
|
|
207
237
|
dep = dep if isinstance(dep, dict) else {}
|
|
208
238
|
removal = parse_date(dep.get("removal_date"))
|
|
209
239
|
if not removal:
|
|
210
|
-
fail(rel, "M5", "module deprecated without a valid module.deprecation.removal_date (YYYY-MM-DD)"
|
|
240
|
+
fail(rel, "M5", "module deprecated without a valid module.deprecation.removal_date (YYYY-MM-DD)\n"
|
|
241
|
+
" Action: add deprecation: with removal_date: YYYY-MM-DD under module:.",
|
|
242
|
+
at("module", "deprecation", "removal_date"))
|
|
211
243
|
elif removal < today:
|
|
212
|
-
fail(rel, "M5", f"removal date passed ({removal}). Permanent intermediate state
|
|
213
|
-
"remove the module or supersede the decision."
|
|
244
|
+
fail(rel, "M5", f"removal date passed ({removal}). Permanent intermediate state.\n"
|
|
245
|
+
" Action: remove the module, or supersede the decision that deprecated it.",
|
|
246
|
+
at("module", "deprecation", "removal_date"))
|
|
214
247
|
|
|
215
248
|
# M2 - the contract entries the boundaries and the versions are judged on (D36)
|
|
216
249
|
for section, fields in CONTRACT_FIELDS.items():
|
|
@@ -219,25 +252,30 @@ def check_manifest(path: Path, today: datetime.date) -> None:
|
|
|
219
252
|
continue # reported below for provides; ignored by the checks that read them
|
|
220
253
|
for field in fields:
|
|
221
254
|
if field in entry and not isinstance(entry[field], str):
|
|
222
|
-
fail(rel, "M2", f"{section}: {field} {entry[field]!r} must be text
|
|
223
|
-
f"write it quoted, for example \"v1\".")
|
|
255
|
+
fail(rel, "M2", f"{section}: {field} {entry[field]!r} must be text.\n"
|
|
256
|
+
f" Action: write it quoted, for example \"v1\".", at(section, field))
|
|
224
257
|
if section == "provides" and entry.get("stability", "experimental") not in STABILITIES:
|
|
225
258
|
fail(rel, "M2", f"provides: stability {entry.get('stability')!r}, expected one of "
|
|
226
|
-
f"{sorted(STABILITIES)}: a stable or deprecated version is frozen (V1)
|
|
259
|
+
f"{sorted(STABILITIES)}: a stable or deprecated version is frozen (V1).\n"
|
|
260
|
+
" Action: write one of them.", at("provides", "stability"))
|
|
227
261
|
|
|
228
262
|
# M6 - deprecation of provided contracts
|
|
229
263
|
for provided in data.get("provides") or []:
|
|
230
264
|
if not isinstance(provided, dict):
|
|
231
|
-
fail(rel, "M2", "entry of provides: expected a mapping (contract, version, stability)
|
|
265
|
+
fail(rel, "M2", "entry of provides: expected a mapping (contract, version, stability).\n"
|
|
266
|
+
" Action: write the entry as contract:, version: and stability:.", at("provides"))
|
|
232
267
|
continue
|
|
233
268
|
if provided.get("stability") == "deprecated":
|
|
234
269
|
name = f"{provided.get('contract')}@{provided.get('version')}"
|
|
235
270
|
removal = parse_date(provided.get("removal_date"))
|
|
236
271
|
if not removal:
|
|
237
|
-
fail(rel, "M6", f"deprecated contract {name} without a removal_date"
|
|
272
|
+
fail(rel, "M6", f"deprecated contract {name} without a removal_date\n"
|
|
273
|
+
" Action: add removal_date: YYYY-MM-DD to that entry of provides.",
|
|
274
|
+
at("provides", "stability"))
|
|
238
275
|
elif removal < today:
|
|
239
|
-
fail(rel, "M6", f"contract {name}: removal date passed ({removal})
|
|
240
|
-
"
|
|
276
|
+
fail(rel, "M6", f"contract {name}: removal date passed ({removal}).\n"
|
|
277
|
+
" Action: finish the contraction (.nstack/handbook/03-contracts.md §4).",
|
|
278
|
+
at("provides", "removal_date"))
|
|
241
279
|
|
|
242
280
|
commands = data.get("commands") or {}
|
|
243
281
|
content = module_content(path.parent)
|
|
@@ -248,14 +286,14 @@ def check_manifest(path: Path, today: datetime.date) -> None:
|
|
|
248
286
|
if content and unfilled(resp):
|
|
249
287
|
fail(rel, "M2", f'module.responsibility is still the template\'s: "{resp}"\n'
|
|
250
288
|
" Action: one sentence naming the capability this module covers, "
|
|
251
|
-
"in MANIFEST.yaml (.nstack/handbook/02-modules.md §5).")
|
|
289
|
+
"in MANIFEST.yaml (.nstack/handbook/02-modules.md §5).", at("module", "responsibility"))
|
|
252
290
|
|
|
253
291
|
# M7 - standard verbs, once there is something to check (D33)
|
|
254
292
|
for verb in REQUIRED_COMMANDS:
|
|
255
293
|
if content and not commands.get(verb):
|
|
256
294
|
fail(rel, "M7", f"standard verb missing: commands.{verb}, and the module holds more "
|
|
257
295
|
f"than its description ({content[0]}).\n Action: declare its stack's "
|
|
258
|
-
"command in the manifest (.nstack/handbook/09-platform.md §2).")
|
|
296
|
+
"command in the manifest (.nstack/handbook/09-platform.md §2).", at("commands"))
|
|
259
297
|
|
|
260
298
|
# M8 - what the criticality requires, read from the matrix of 05-workflow.md §7 and
|
|
261
299
|
# never from a threshold copied here (D67). Each refusal names the value that produced it and
|
|
@@ -269,20 +307,54 @@ def check_manifest(path: Path, today: datetime.date) -> None:
|
|
|
269
307
|
fail(rel, "M8", f"{declared} requires an existing runbook "
|
|
270
308
|
"(.nstack/handbook/05-workflow.md §7; what it holds: .nstack/playbooks/operations.md)"
|
|
271
309
|
f"{_relief(criticality, 'runbook')}\n Action: declare docs.runbook "
|
|
272
|
-
"in MANIFEST.yaml and create that file — nstack new-module writes one."
|
|
310
|
+
"in MANIFEST.yaml and create that file — nstack new-module writes one.",
|
|
311
|
+
at("docs", "runbook") if "runbook" in (data.get("docs") or {}) else at("docs"))
|
|
273
312
|
|
|
274
313
|
# M9 - file envelope
|
|
275
314
|
for expected in ["AGENTS.md", "README.md"]:
|
|
276
315
|
if not (path.parent / expected).is_file():
|
|
277
|
-
fail(rel, "M9", f"envelope file missing: {expected}"
|
|
316
|
+
fail(rel, "M9", f"envelope file missing: {expected}\n Action: create it — "
|
|
317
|
+
"what it holds: .nstack/handbook/02-modules.md.", f"{folder}/{expected}")
|
|
278
318
|
if not (path.parent / "tests").is_dir() and criticality != "prototype":
|
|
279
|
-
fail(rel, "M9", "tests/ folder missing"
|
|
319
|
+
fail(rel, "M9", "tests/ folder missing\n Action: create it, with the module's first test.",
|
|
320
|
+
f"{folder}/tests/")
|
|
280
321
|
|
|
281
322
|
# M10 - a user-visible surface declared: it decides the test sheet (.nstack/handbook/05-workflow.md §7)
|
|
282
323
|
if not isinstance(mod.get("user_facing"), bool):
|
|
283
324
|
fail(rel, "M10", "module.user_facing must be true or false: does a user see this module? "
|
|
284
325
|
"true requires a test sheet on its pull requests from standard up "
|
|
285
|
-
"(.nstack/handbook/05-workflow.md §7)"
|
|
326
|
+
"(.nstack/handbook/05-workflow.md §7)\n"
|
|
327
|
+
" Action: write user_facing: true or false under module:.",
|
|
328
|
+
at("module", "user_facing"))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def comparator_owed(root: Path, manifests: list[Path]) -> None:
|
|
332
|
+
"""V1, said when it can still be met: once a contract is consumed, a change to it is
|
|
333
|
+
refused unless the contracts module's merged comparator proves it compatible — a
|
|
334
|
+
corrected example as much as a renamed field."""
|
|
335
|
+
folder = root / "contracts" / "MANIFEST.yaml"
|
|
336
|
+
if not folder.is_file():
|
|
337
|
+
return
|
|
338
|
+
try:
|
|
339
|
+
commands = (yaml.safe_load(folder.read_text(encoding="utf-8")) or {}).get("commands") or {}
|
|
340
|
+
except yaml.YAMLError:
|
|
341
|
+
return
|
|
342
|
+
if not isinstance(commands, dict) or commands.get("compat"):
|
|
343
|
+
return
|
|
344
|
+
consumers = []
|
|
345
|
+
for manifest in manifests:
|
|
346
|
+
try:
|
|
347
|
+
data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
|
|
348
|
+
except yaml.YAMLError:
|
|
349
|
+
continue
|
|
350
|
+
if contract_entries(data, "consumes"):
|
|
351
|
+
consumers.append(manifest.parent.relative_to(root).as_posix())
|
|
352
|
+
if consumers:
|
|
353
|
+
warn("contracts", "V1", f"contracts are consumed ({', '.join(sorted(consumers))}) and "
|
|
354
|
+
"contracts/MANIFEST.yaml declares no commands.compat: from now on a change "
|
|
355
|
+
"to a consumed version — a corrected example as much as a renamed field — "
|
|
356
|
+
"is refused. Declare the comparator first, in a pull request of its own "
|
|
357
|
+
"(.nstack/handbook/03-contracts.md §3).")
|
|
286
358
|
|
|
287
359
|
|
|
288
360
|
def run(root: Path) -> int:
|
|
@@ -299,7 +371,8 @@ def run(root: Path) -> int:
|
|
|
299
371
|
"Action: create it, or create the module with nstack new-module.")
|
|
300
372
|
|
|
301
373
|
for manifest in manifests:
|
|
302
|
-
check_manifest(manifest, today)
|
|
374
|
+
check_manifest(manifest, today, root)
|
|
375
|
+
comparator_owed(root, manifests)
|
|
303
376
|
|
|
304
377
|
print(f"Manifests checked: {len(manifests)}")
|
|
305
378
|
for w in warnings:
|
|
@@ -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), with no imposed stack (PRODUCT.md P1, PDR-0001 R5).
|
|
3
|
+
check, test, run, e2e, clean), 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
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import re
|
|
14
14
|
import shutil
|
|
15
|
+
import shlex
|
|
15
16
|
import subprocess
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
@@ -29,7 +30,7 @@ HANDLE = r"(?=[A-Za-z0-9-]{1,39}$)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*"
|
|
|
29
30
|
LOGIN = r"[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}" # the same rule, unanchored: a handle inside a sentence
|
|
30
31
|
OWNER = re.compile( # same rule as copier.yml: organisation/team, or a GitHub user
|
|
31
32
|
rf"[A-Za-z0-9-]+/[A-Za-z0-9._-]+|{HANDLE}")
|
|
32
|
-
OPTIONAL = {"bootstrap": "nothing to prepare", "e2e": "no end-to-end scenario"} # undeclared: skipped
|
|
33
|
+
OPTIONAL = {"bootstrap": "nothing to prepare", "e2e": "no end-to-end scenario", "clean": "nothing to clean"} # undeclared: skipped
|
|
33
34
|
# What a criticality requires is the matrix of .nstack/handbook/05-workflow.md §7, read once (assurance).
|
|
34
35
|
|
|
35
36
|
RUNBOOK = """# Runbook - {name}
|
|
@@ -299,7 +300,7 @@ def _modules(root: Path) -> dict[str, Path]:
|
|
|
299
300
|
return {manifest.parent.name: manifest.parent for manifest in find_manifests(root)}
|
|
300
301
|
|
|
301
302
|
|
|
302
|
-
def run_verb(root: Path, verb: str, name: str | None) -> int:
|
|
303
|
+
def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | None = None) -> int:
|
|
303
304
|
known = _modules(root)
|
|
304
305
|
if name is not None and name not in known:
|
|
305
306
|
print(f"FAIL [{verb}] module '{name}' not found in {root}: no MANIFEST.yaml under that name.\n"
|
|
@@ -327,6 +328,8 @@ def run_verb(root: Path, verb: str, name: str | None) -> int:
|
|
|
327
328
|
print(f"FAIL [{verb}] module '{target}': commands.{verb} not declared in {manifest}.\n"
|
|
328
329
|
" Action: declare the module stack's command there (.nstack/handbook/09-platform.md §2).")
|
|
329
330
|
return 1
|
|
331
|
+
if arguments:
|
|
332
|
+
command = " ".join([command, *map(shlex.quote, arguments)])
|
|
330
333
|
print(f"-> {target}: {command}", flush=True)
|
|
331
334
|
code = subprocess.run(command, shell=True, cwd=known[target]).returncode
|
|
332
335
|
if code:
|
|
@@ -15,9 +15,11 @@ data:
|
|
|
15
15
|
shared: []
|
|
16
16
|
dependencies: []
|
|
17
17
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
18
|
+
# compat before any version is consumed: from then on a change to a consumed version — a
|
|
19
|
+
# corrected example as much as a renamed field — passes only with this command as merged
|
|
20
|
+
# (nstack compat, V1), and `nstack manifests` warns while it is missing. check and test are
|
|
21
|
+
# required once this module holds code (fitness M7). The tools are the project's, named in
|
|
22
|
+
# .nstack/tooling-profile.md.
|
|
21
23
|
commands:
|
|
22
24
|
# check: # validates every contract document against its format
|
|
23
25
|
# test: # the contract tests, run on both sides (.nstack/handbook/03-contracts.md §5)
|
|
@@ -73,6 +73,8 @@ commands:
|
|
|
73
73
|
# bootstrap: # optional: make the module usable from a fresh clone
|
|
74
74
|
# run: # optional: local start, with doubles for the dependencies
|
|
75
75
|
# e2e: # optional: end-to-end scenarios; evidence written to .evidence/
|
|
76
|
+
# clean: # optional: remove what the commands above leave in this folder
|
|
77
|
+
# # (build outputs); CI runs it after every job on a self-hosted runner
|
|
76
78
|
|
|
77
79
|
docs:
|
|
78
80
|
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
|