napkinstack 0.7.6__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.
Files changed (35) hide show
  1. {napkinstack-0.7.6 → napkinstack-0.7.7}/PKG-INFO +4 -4
  2. {napkinstack-0.7.6 → napkinstack-0.7.7}/README.md +3 -3
  3. {napkinstack-0.7.6 → napkinstack-0.7.7}/pyproject.toml +1 -1
  4. {napkinstack-0.7.6 → napkinstack-0.7.7}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/cli.py +30 -7
  6. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/doctor.py +49 -6
  7. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/fitness/hygiene.py +15 -0
  8. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/fitness/manifests.py +102 -29
  9. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/modules.py +6 -3
  10. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/MANIFEST.yaml +5 -3
  11. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/module/MANIFEST.yaml +2 -0
  12. {napkinstack-0.7.6 → napkinstack-0.7.7}/LICENSE +0 -0
  13. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/__init__.py +0 -0
  14. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/assurance.py +0 -0
  15. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/compat.py +0 -0
  16. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/discovery.py +0 -0
  17. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/fitness/__init__.py +0 -0
  18. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/fitness/boundaries.py +0 -0
  19. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/fitness/plan.py +0 -0
  20. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/fitness/pr_scope.py +0 -0
  21. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/landed.py +0 -0
  22. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/project.py +0 -0
  23. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/provenance.py +0 -0
  24. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/pull_request.py +0 -0
  25. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/settings.py +0 -0
  26. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/skills.py +0 -0
  27. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  28. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/README.md +0 -0
  29. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  30. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  31. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/module/AGENTS.md +0 -0
  32. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/module/README.md +0 -0
  33. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
  34. {napkinstack-0.7.6 → napkinstack-0.7.7}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  35. {napkinstack-0.7.6 → 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.6
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.6, published** on [PyPI](https://pypi.org/project/napkinstack/).
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.6" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.6"
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.6, published** on [PyPI](https://pypi.org/project/napkinstack/).
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.6" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.6"
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.7.6"
3
+ version = "0.7.7"
4
4
  description = "Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.7.6"
3
+ version = "0.7.7"
4
4
  description = "Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -8,7 +8,7 @@ import sys
8
8
  from pathlib import Path
9
9
 
10
10
  from napkinstack import assurance
11
- from napkinstack import (__version__, compat, discovery, doctor, landed, modules, provenance, settings,
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="version", version=f"nstack {__version__}")
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: modules.run_verb(a.root, "run", a.module))
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, tag vX.Y.Z (default: the one of nstack)")
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, tag vX.Y.Z (default: the one of nstack)")
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
- l1 = (GAP, f"The project is pinned to an unpublished framework ({commit or 'unknown'}): its "
289
- "verdicts come from rules nobody has published.\nAction: once the change it "
290
- "waits for is released, nstack update --ref vX.Y.Z.")
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__:
@@ -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
- failures.append(f"[{rule}] {module}\n {message}")
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(path.read_text(encoding="utf-8")) or {}
181
+ data = yaml.safe_load(text) or {}
163
182
  except yaml.YAMLError as exc:
164
- fail(rel, "M2", f"MANIFEST.yaml unreadable: {exc}")
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
- "Finish the contraction (.nstack/handbook/03-contracts.md §4).")
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
- # Declared with the first contract: check and test are required once this folder holds one
19
- # (fitness M7), and compat once a version is consumed or stable (nstack compat, V1). The
20
- # tools are the project's, named in .nstack/tooling-profile.md.
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