napkinstack 0.7.6__tar.gz → 0.8.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.7.6 → napkinstack-0.8.0}/PKG-INFO +4 -4
- {napkinstack-0.7.6 → napkinstack-0.8.0}/README.md +3 -3
- {napkinstack-0.7.6 → napkinstack-0.8.0}/pyproject.toml +1 -1
- {napkinstack-0.7.6 → napkinstack-0.8.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/cli.py +30 -7
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/doctor.py +55 -7
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/fitness/hygiene.py +15 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/fitness/manifests.py +102 -29
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/landed.py +30 -1
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/modules.py +8 -4
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/pull_request.py +22 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/settings.py +8 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +5 -3
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/templates/module/MANIFEST.yaml +2 -1
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/templates/module/README.md +1 -1
- napkinstack-0.7.6/src/napkinstack/templates/module/tests/.gitkeep +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/LICENSE +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/project.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.7.6 → napkinstack-0.8.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.7.6/src/napkinstack/templates/module/docs/adr → napkinstack-0.8.0/src/napkinstack/templates/module/src}/.gitkeep +0 -0
- {napkinstack-0.7.6/src/napkinstack/templates/module/src → napkinstack-0.8.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.8.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,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.
|
|
23
|
+
> **Status: v0.8.0, 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.
|
|
88
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
87
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.8.0" nstack init my-project
|
|
88
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.8.0"
|
|
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.
|
|
8
|
+
> **Status: v0.8.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
|
|
9
9
|
> What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
|
|
10
10
|
> What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
|
|
11
11
|
|
|
@@ -69,8 +69,8 @@ To try it without installing anything, or to install it from this repository ins
|
|
|
69
69
|
registry — always pinned to a release tag:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.
|
|
73
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
72
|
+
uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.8.0" nstack init my-project
|
|
73
|
+
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.8.0"
|
|
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
|
|
@@ -25,7 +30,7 @@ Rules:
|
|
|
25
30
|
L7 the template source reachable by anyone: a repository, not a path on one machine
|
|
26
31
|
N1 information, never a gap (D91): a published version newer than the project's, read from
|
|
27
32
|
the registry — the one place a project learns of it. No other command opens a connection
|
|
28
|
-
G1-
|
|
33
|
+
G1-G19 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
|
|
29
34
|
repository, and on a private one G1-G5, G12, G13, G17 and G18 name the GitHub plan
|
|
30
35
|
or option required
|
|
31
36
|
|
|
@@ -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"
|
|
@@ -111,6 +117,10 @@ CHECKLIST = [ # (rule, setting, action)
|
|
|
111
117
|
# main, and said nothing of main being deleted or rewritten under them.
|
|
112
118
|
("G17", "Main branch cannot be deleted", f"{RULESET}: restrict deletions"),
|
|
113
119
|
("G18", "Main branch history cannot be rewritten: no force push", f"{RULESET}: block force pushes"),
|
|
120
|
+
# G19: every workflow and ruleset here watches `main`. A repository whose default branch is
|
|
121
|
+
# another one triggers none of them, and its record of commits on main never runs.
|
|
122
|
+
("G19", "Default branch `main`: the branch every workflow and ruleset here watches",
|
|
123
|
+
"Settings → General → Default branch: rename it main"),
|
|
114
124
|
]
|
|
115
125
|
|
|
116
126
|
# Settings specific to public repositories, and settings a private one pays for
|
|
@@ -261,6 +271,7 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
|
|
|
261
271
|
"G16": lambda c: _merges(c, "delete_branch_on_merge").get("delete_branch_on_merge") is True,
|
|
262
272
|
"G17": lambda c: _rule(c, "deletion") is not None,
|
|
263
273
|
"G18": lambda c: _rule(c, "non_fast_forward") is not None,
|
|
274
|
+
"G19": lambda c: _merges(c, "default_branch").get("default_branch") == "main",
|
|
264
275
|
}
|
|
265
276
|
|
|
266
277
|
|
|
@@ -277,17 +288,54 @@ def _default_owner(root: Path) -> tuple[str, str]:
|
|
|
277
288
|
"its first rule: the code owner review then covers every path (.nstack/handbook/07-governance.md §7).")
|
|
278
289
|
|
|
279
290
|
|
|
291
|
+
def _forge(path: str) -> dict | None:
|
|
292
|
+
"""JSON of a public repository's page on the forge, read without a token; None when it does
|
|
293
|
+
not answer."""
|
|
294
|
+
api = (os.environ.get("GITHUB_API_URL") or "https://api.github.com").rstrip("/")
|
|
295
|
+
request = urllib.request.Request(f"{api}{path}", headers={
|
|
296
|
+
"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": API_VERSION,
|
|
297
|
+
"User-Agent": f"nstack/{__version__}"})
|
|
298
|
+
try:
|
|
299
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
300
|
+
data = json.load(response)
|
|
301
|
+
except (OSError, ValueError):
|
|
302
|
+
return None
|
|
303
|
+
return data if isinstance(data, dict) else None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _left_behind(answers: dict) -> str | None:
|
|
307
|
+
"""The framework's default branch, when it does not hold the pinned commit; None when it
|
|
308
|
+
does, or when the forge could not say. A fix may be pinned while it is reviewed; its squash
|
|
309
|
+
then gives the default branch another commit, and leaves this one on no branch."""
|
|
310
|
+
commit = str(answers.get("_commit") or "")
|
|
311
|
+
repository = ON_GITHUB.match(str(answers.get("_src_path") or ""))
|
|
312
|
+
if "-g" not in commit or not repository:
|
|
313
|
+
return None
|
|
314
|
+
sha, repo = commit.rsplit("-g", 1)[1], repository.group(1)
|
|
315
|
+
branch = (_forge(f"/repos/{repo}") or {}).get("default_branch")
|
|
316
|
+
status = (_forge(f"/repos/{repo}/compare/{branch}...{sha}") or {}).get("status") if branch else None
|
|
317
|
+
return branch if status in ("ahead", "diverged") else None
|
|
318
|
+
|
|
319
|
+
|
|
280
320
|
def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
|
|
281
321
|
commit = str(answers.get("_commit") or "")
|
|
282
322
|
project = commit.removeprefix("v")
|
|
283
|
-
install = f'uv tool install "napkinstack=={project}" --with-executables-from pre-commit'
|
|
323
|
+
install = (f'uv tool install "napkinstack=={project}" --with-executables-from pre-commit'
|
|
324
|
+
if PUBLISHED.fullmatch(commit) else "bash .nstack/install-engine.sh")
|
|
284
325
|
results = []
|
|
285
326
|
|
|
286
327
|
_, origin = provenance.engine()
|
|
287
328
|
if not PUBLISHED.fullmatch(commit):
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
329
|
+
branch = _left_behind(answers)
|
|
330
|
+
if branch:
|
|
331
|
+
l1 = (GAP, f"The project is pinned to an unpublished framework ({commit}), a commit the "
|
|
332
|
+
f"framework's {branch} does not hold: a pull request's head, which its squash leaves "
|
|
333
|
+
f"on no branch.\nAction: once it is merged, nstack update --ref <the commit {branch} "
|
|
334
|
+
"received for that change>, or --ref vX.Y.Z, a release that carries it.")
|
|
335
|
+
else:
|
|
336
|
+
l1 = (GAP, f"The project is pinned to an unpublished framework ({commit or 'unknown'}): its "
|
|
337
|
+
"verdicts come from rules nobody has published.\nAction: once the change it "
|
|
338
|
+
"waits for is released, nstack update --ref vX.Y.Z.")
|
|
291
339
|
elif origin is not None:
|
|
292
340
|
l1 = (GAP, f"The nstack running is unpublished ({origin}).\nAction: {install}")
|
|
293
341
|
elif project != __version__:
|
|
@@ -64,6 +64,16 @@ def tracked_files(root: Path) -> list[Path] | None:
|
|
|
64
64
|
return [root / name for name in names if name]
|
|
65
65
|
|
|
66
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
|
+
|
|
67
77
|
def run(root: Path) -> int:
|
|
68
78
|
files = tracked_files(root)
|
|
69
79
|
if files is None:
|
|
@@ -88,6 +98,11 @@ def run(root: Path) -> int:
|
|
|
88
98
|
f"directory — {ACTION}")
|
|
89
99
|
|
|
90
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.")
|
|
91
106
|
for failure in failures:
|
|
92
107
|
print(f" FAIL {failure}")
|
|
93
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:
|
|
@@ -14,6 +14,10 @@ It says nothing about what predates it: the commit that created the repository,
|
|
|
14
14
|
everything the repository already held when the record arrived, are not reported. A record
|
|
15
15
|
that greets a project by failing on its own arrival teaches people to ignore it.
|
|
16
16
|
|
|
17
|
+
It reads from the last commit it recorded, not from the push alone: a push whose run never
|
|
18
|
+
started — CI out of minutes, a runner down — is read by the next run that does. Its last
|
|
19
|
+
success is read from the forge (actions: read); a record that never succeeded reads the push.
|
|
20
|
+
|
|
17
21
|
The forge is read with a token supplied by the run (GH_TOKEN, otherwise GITHUB_TOKEN), which
|
|
18
22
|
needs no write access of any kind: the project's automation token stays read-only (G10).
|
|
19
23
|
Without a token, or when the span cannot be read, nothing is claimed.
|
|
@@ -73,8 +77,28 @@ def _described(root: Path, commit: str) -> str:
|
|
|
73
77
|
return _git(root, "show", "-s", "--format=%h (%an, %ad)", "--date=short", commit).stdout.strip()
|
|
74
78
|
|
|
75
79
|
|
|
80
|
+
def _since_last_record(root: Path, span: str, forge: GitHub) -> tuple[str, str | None]:
|
|
81
|
+
"""The span widened to start at the last commit the record recorded, and that commit; the
|
|
82
|
+
span as given, and None, when the push already starts there or the forge cannot say."""
|
|
83
|
+
try:
|
|
84
|
+
runs = forge.get(f"/actions/workflows/{Path(RECORD).name}/runs?branch=main&status=success&per_page=1")
|
|
85
|
+
except NotVerified:
|
|
86
|
+
return span, None
|
|
87
|
+
found = (runs or {}).get("workflow_runs") or []
|
|
88
|
+
last = str(found[0].get("head_sha") or "") if found else ""
|
|
89
|
+
before, _, after = span.partition("..")
|
|
90
|
+
if not last or not after or last == before or _git(root, "cat-file", "-e", f"{last}^{{commit}}").returncode:
|
|
91
|
+
return span, None
|
|
92
|
+
if _git(root, "merge-base", "--is-ancestor", last, after).returncode:
|
|
93
|
+
return span, None # history rewritten: the push is all that is known
|
|
94
|
+
if before not in ("", EMPTY) and _git(root, "merge-base", "--is-ancestor", before, last).returncode == 0:
|
|
95
|
+
return span, None # the push starts after the last record already
|
|
96
|
+
return f"{last}..{after}", last
|
|
97
|
+
|
|
98
|
+
|
|
76
99
|
def run(root: Path, span: str, forge: GitHub | None) -> int:
|
|
77
|
-
|
|
100
|
+
pushed = _commits(root, span)
|
|
101
|
+
commits = pushed
|
|
78
102
|
if commits is None:
|
|
79
103
|
print(f"[W1] the commits of '{span}' cannot be read: not verified.\n"
|
|
80
104
|
" Action: fetch the history (fetch-depth: 0), or pass an existing range.")
|
|
@@ -83,6 +107,11 @@ def run(root: Path, span: str, forge: GitHub | None) -> int:
|
|
|
83
107
|
print("[W1] no token (GH_TOKEN or GITHUB_TOKEN): how each commit arrived is not verified.\n"
|
|
84
108
|
" Action: supply a token with read access to the repository's pull requests.")
|
|
85
109
|
return 0
|
|
110
|
+
span, last = _since_last_record(root, span, forge)
|
|
111
|
+
if last:
|
|
112
|
+
commits = _commits(root, span) or pushed
|
|
113
|
+
print(f"Read since the last commit it recorded, {last[:12]}: {len(commits) - len(pushed)} commit(s) "
|
|
114
|
+
"no run had read.")
|
|
86
115
|
beginning = _beginning(root)
|
|
87
116
|
outside, checked = [], 0
|
|
88
117
|
for commit in commits:
|
|
@@ -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}
|
|
@@ -217,7 +218,8 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
|
|
|
217
218
|
"""What the module's first pull requests will be asked, from facts nstack holds (D33)."""
|
|
218
219
|
from napkinstack.fitness import plan # here: plan reads HANDLE from this module
|
|
219
220
|
|
|
220
|
-
steps = ["Creation ADR in docs/adr
|
|
221
|
+
steps = [f"Creation ADR in the project's docs/adr/, Scope: module {name}: capability, boundary, "
|
|
222
|
+
"alternatives — and the criticality "
|
|
221
223
|
f"with one sentence saying why {criticality} and not the value below or above; it is "
|
|
222
224
|
"the decider who settles it",
|
|
223
225
|
"MANIFEST.yaml: the responsibility in ONE sentence",
|
|
@@ -299,7 +301,7 @@ def _modules(root: Path) -> dict[str, Path]:
|
|
|
299
301
|
return {manifest.parent.name: manifest.parent for manifest in find_manifests(root)}
|
|
300
302
|
|
|
301
303
|
|
|
302
|
-
def run_verb(root: Path, verb: str, name: str | None) -> int:
|
|
304
|
+
def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | None = None) -> int:
|
|
303
305
|
known = _modules(root)
|
|
304
306
|
if name is not None and name not in known:
|
|
305
307
|
print(f"FAIL [{verb}] module '{name}' not found in {root}: no MANIFEST.yaml under that name.\n"
|
|
@@ -327,6 +329,8 @@ def run_verb(root: Path, verb: str, name: str | None) -> int:
|
|
|
327
329
|
print(f"FAIL [{verb}] module '{target}': commands.{verb} not declared in {manifest}.\n"
|
|
328
330
|
" Action: declare the module stack's command there (.nstack/handbook/09-platform.md §2).")
|
|
329
331
|
return 1
|
|
332
|
+
if arguments:
|
|
333
|
+
command = " ".join([command, *map(shlex.quote, arguments)])
|
|
330
334
|
print(f"-> {target}: {command}", flush=True)
|
|
331
335
|
code = subprocess.run(command, shell=True, cwd=known[target]).returncode
|
|
332
336
|
if code:
|
|
@@ -52,6 +52,8 @@ from napkinstack import assurance
|
|
|
52
52
|
from napkinstack.fitness.manifests import MODULE_DIRS, changed_files, find_manifests, is_description
|
|
53
53
|
from napkinstack.modules import LOGIN
|
|
54
54
|
|
|
55
|
+
CHARTER = plan.CHARTER.as_posix() # docs/project/charter.md, as the diff names it
|
|
56
|
+
|
|
55
57
|
LABEL = "out-of-cycle"
|
|
56
58
|
DELIVERABLE = re.compile(r"^Deliverable:[ \t]*(D[1-9][0-9]*)\b", re.I | re.M)
|
|
57
59
|
JUSTIFICATION = re.compile(r"^Out of cycle:[ \t]*(\S.*)$", re.I | re.M)
|
|
@@ -364,6 +366,23 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
|
|
|
364
366
|
"deliverable (definition of ready).")
|
|
365
367
|
|
|
366
368
|
|
|
369
|
+
def charter_without_pdr(root: Path, base: str, files: list[str]) -> list[str]:
|
|
370
|
+
"""C9: an accepted charter changes only through a PDR (it is the project's founding
|
|
371
|
+
decision). Said as a warning where the change is — a typo in it is not a decision — and
|
|
372
|
+
never while the charter is still proposed, which is when the framing writes it."""
|
|
373
|
+
if CHARTER not in files:
|
|
374
|
+
return []
|
|
375
|
+
before = _git(root, "show", f"{base}:{CHARTER}")
|
|
376
|
+
status = re.search(r"^status:\s*['\"]?(\w+)", before.stdout.split("\n---", 1)[0], re.M) if before.returncode == 0 else None
|
|
377
|
+
if not status or status.group(1) != "accepted":
|
|
378
|
+
return []
|
|
379
|
+
if any(path.startswith("docs/pdr/") and not path.endswith("README.md") for path in files):
|
|
380
|
+
return []
|
|
381
|
+
return [f"[C9] {CHARTER}: the accepted charter changes, and no decision record comes with it.\n"
|
|
382
|
+
" Action: write the PDR that decides the change, and cite it in the charter; a "
|
|
383
|
+
"correction that decides nothing needs none (.nstack/playbooks/framing.md, Decisions)."]
|
|
384
|
+
|
|
385
|
+
|
|
367
386
|
def run(root: Path, base: str, body_file: Path | None = None) -> int:
|
|
368
387
|
if body_file is not None:
|
|
369
388
|
body = body_file.read_text(encoding="utf-8")
|
|
@@ -383,6 +402,7 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
|
|
|
383
402
|
labels = {label.strip() for label in os.environ.get("PR_LABELS", "").split(",") if label.strip()}
|
|
384
403
|
|
|
385
404
|
failures: list[str] = []
|
|
405
|
+
warnings = charter_without_pdr(root, base, files)
|
|
386
406
|
|
|
387
407
|
def fail(rule: str, message: str) -> None:
|
|
388
408
|
failures.append(f"[{rule}] {message}")
|
|
@@ -405,6 +425,8 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
|
|
|
405
425
|
print(line)
|
|
406
426
|
for scenario in human_only:
|
|
407
427
|
print(f" For the approver, human only: {scenario}")
|
|
428
|
+
for warning in warnings:
|
|
429
|
+
print(f"WARNING {warning}")
|
|
408
430
|
for failure in failures:
|
|
409
431
|
print(f"FAIL {failure}")
|
|
410
432
|
if failures:
|
|
@@ -142,6 +142,14 @@ def plan(client: doctor.GitHub | None, missing: set[str]) -> tuple[list[Call], l
|
|
|
142
142
|
merges["delete_branch_on_merge"] = True
|
|
143
143
|
if merges:
|
|
144
144
|
calls.append((", ".join(rule for rule in ("G14", "G15", "G16") if rule in missing), "PATCH", "", merges))
|
|
145
|
+
if "G19" in missing:
|
|
146
|
+
current = "<the default branch>"
|
|
147
|
+
if client is not None:
|
|
148
|
+
try:
|
|
149
|
+
current = str(client.get("").get("default_branch") or current)
|
|
150
|
+
except doctor.NotVerified:
|
|
151
|
+
pass
|
|
152
|
+
calls.append(("G19", "POST", f"/branches/{current}/rename", {"new_name": "main"}))
|
|
145
153
|
return calls, notes
|
|
146
154
|
|
|
147
155
|
|
|
@@ -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,9 +73,10 @@ 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
|
|
79
81
|
agents: AGENTS.md
|
|
80
|
-
adr: docs/adr/
|
|
81
82
|
# runbook: docs/runbook.md # required when criticality >= high
|
|
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
|