napkinstack 0.7.3__tar.gz → 0.7.4__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.3 → napkinstack-0.7.4}/PKG-INFO +5 -5
  2. {napkinstack-0.7.3 → napkinstack-0.7.4}/README.md +4 -4
  3. {napkinstack-0.7.3 → napkinstack-0.7.4}/pyproject.toml +1 -1
  4. {napkinstack-0.7.3 → napkinstack-0.7.4}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/doctor.py +46 -0
  6. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/pull_request.py +40 -5
  7. {napkinstack-0.7.3 → napkinstack-0.7.4}/LICENSE +0 -0
  8. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/__init__.py +0 -0
  9. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/assurance.py +0 -0
  10. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/cli.py +0 -0
  11. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/compat.py +0 -0
  12. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/discovery.py +0 -0
  13. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/fitness/__init__.py +0 -0
  14. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/fitness/boundaries.py +0 -0
  15. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/fitness/hygiene.py +0 -0
  16. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/fitness/manifests.py +0 -0
  17. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/fitness/plan.py +0 -0
  18. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/fitness/pr_scope.py +0 -0
  19. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/landed.py +0 -0
  20. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/modules.py +0 -0
  21. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/project.py +0 -0
  22. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/provenance.py +0 -0
  23. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/settings.py +0 -0
  24. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/skills.py +0 -0
  25. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  26. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  27. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/contracts/README.md +0 -0
  28. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  29. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  30. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/module/AGENTS.md +0 -0
  31. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  32. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/module/README.md +0 -0
  33. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
  34. {napkinstack-0.7.3 → napkinstack-0.7.4}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  35. {napkinstack-0.7.3 → napkinstack-0.7.4}/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
3
+ Version: 0.7.4
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.3, published** on [PyPI](https://pypi.org/project/napkinstack/).
23
+ > **Status: v0.7.4, 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.3" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.3"
87
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.7.4" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.4"
89
89
  ```
90
90
 
91
91
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -105,7 +105,7 @@ Each project then pins its version and changes it through `nstack update`.
105
105
  | Command | Role |
106
106
  |---|---|
107
107
  | `nstack init <folder>` | Creates the project: skeleton, git repository, initial commit, GitHub checklist |
108
- | `nstack doctor` | Checks the workstation and the GitHub settings, read-only |
108
+ | `nstack doctor` | Checks the workstation and the GitHub settings, read-only; names a newer published version |
109
109
  | `nstack settings` | Prints the calls that would bring the GitHub settings to the checklist; changes nothing |
110
110
  | `nstack new-module <name> <owner> <criticality>` | Creates a module, with no imposed stack |
111
111
  | `nstack new-contract <name>` | Creates a contract and, the first time, the module that holds them all |
@@ -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.3, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.7.4, 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.3" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.3"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.7.4" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.4"
74
74
  ```
75
75
 
76
76
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -90,7 +90,7 @@ Each project then pins its version and changes it through `nstack update`.
90
90
  | Command | Role |
91
91
  |---|---|
92
92
  | `nstack init <folder>` | Creates the project: skeleton, git repository, initial commit, GitHub checklist |
93
- | `nstack doctor` | Checks the workstation and the GitHub settings, read-only |
93
+ | `nstack doctor` | Checks the workstation and the GitHub settings, read-only; names a newer published version |
94
94
  | `nstack settings` | Prints the calls that would bring the GitHub settings to the checklist; changes nothing |
95
95
  | `nstack new-module <name> <owner> <criticality>` | Creates a module, with no imposed stack |
96
96
  | `nstack new-contract <name>` | Creates a contract and, the first time, the module that holds them all |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.7.3"
3
+ version = "0.7.4"
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.3"
3
+ version = "0.7.4"
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"
@@ -5,6 +5,9 @@ Workflows inform; it is the GitHub settings that block, and they are not copied
5
5
  project. CHECKLIST is printed by nstack init, checked here, and repeated word for word in
6
6
  the foundation manual, skeleton/.nstack/handbook/INSTALL.md.jinja (a test verifies it).
7
7
 
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.
10
+
8
11
  GitHub is read with a token supplied by a human (GH_TOKEN, otherwise GITHUB_TOKEN):
9
12
  fine-grained, limited to the repository, Administration: read permission. Without a token,
10
13
  or when the API refuses a read, the setting is "not verified", never compliant. No write.
@@ -20,6 +23,8 @@ Rules:
20
23
  first minute. The plan check asks for it at the discovery's go (C8). Not reused.
21
24
  L6 CODEOWNERS starts with a default owner: the code owner review covers every path
22
25
  L7 the template source reachable by anyone: a repository, not a path on one machine
26
+ N1 information, never a gap (D91): a published version newer than the project's, read from
27
+ the registry — the one place a project learns of it. No other command opens a connection
23
28
  G1-G18 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
24
29
  repository, and on a private one G1-G5, G12, G13, G17 and G18 name the GitHub plan
25
30
  or option required
@@ -318,6 +323,45 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
318
323
  return results
319
324
 
320
325
 
326
+ def _latest() -> str | None:
327
+ """The latest version the registry publishes, or None when it did not answer."""
328
+ registry = (os.environ.get("NSTACK_REGISTRY_URL") or "https://pypi.org").rstrip("/")
329
+ request = urllib.request.Request(f"{registry}/pypi/napkinstack/json",
330
+ headers={"Accept": "application/json", "User-Agent": f"nstack/{__version__}"})
331
+ try:
332
+ with urllib.request.urlopen(request, timeout=5) as response:
333
+ version = str(json.load(response)["info"]["version"])
334
+ except (OSError, ValueError, KeyError, TypeError):
335
+ return None
336
+ return version if PUBLISHED.fullmatch(f"v{version}") else None
337
+
338
+
339
+ def _number(version: str) -> tuple[int, ...]:
340
+ return tuple(int(part) for part in version.split("."))
341
+
342
+
343
+ def _newer(answers: dict) -> None:
344
+ """Says whether a version newer than the project's is published (N1). Information: taking
345
+ a version is the team's decision, at a cycle's boundary, and nothing here counts it as a gap."""
346
+ commit = str(answers.get("_commit") or "")
347
+ if not PUBLISHED.fullmatch(commit):
348
+ return # L1 already says the project is on no published version
349
+ print("\nNapkinStack")
350
+ latest = _latest()
351
+ if latest is None:
352
+ print(" NOT READ the registry did not answer: whether a newer version is published is not known")
353
+ return
354
+ if _number(latest) <= _number(commit.removeprefix("v")):
355
+ print(f" LATEST the project is on {commit}, the latest published version")
356
+ return
357
+ from napkinstack.project import release_notes
358
+
359
+ print(f" NEWER VERSION v{latest} is published; the project is on {commit} — information, not a gap\n"
360
+ f" What it changes: {release_notes(str(answers.get('_src_path') or ''), commit, f'v{latest}')}\n"
361
+ f" To take it, at a cycle's boundary: uv tool install \"napkinstack=={latest}\" "
362
+ "--with-executables-from pre-commit, then nstack update")
363
+
364
+
321
365
  def _private(client: GitHub) -> bool:
322
366
  """Repository not public (private or internal); visibility unreadable: treated as public."""
323
367
  try:
@@ -374,6 +418,8 @@ def run(root: Path) -> int:
374
418
  for result in results:
375
419
  _display(*result)
376
420
 
421
+ _newer(answers)
422
+
377
423
  if plan.stage(root) == plan.UNFRAMED:
378
424
  # Said here and nowhere else, once: every other run names the stage in one line and
379
425
  # leaves it there. A stage is not a gap, and nothing below counts it as one.
@@ -185,6 +185,33 @@ def authors(root: Path, base: str, head: str, opener: str) -> tuple[set[str], se
185
185
  return logins, sessions, unnamed
186
186
 
187
187
 
188
+ # A later commit does not name the earlier ones: the trailer is read on every commit an agent
189
+ # made (D93). Said as two ways out, because rewriting a reviewed branch is not always allowed.
190
+ UNNAMED_ACTION = (f"every commit an agent made carries \"{SESSION_TRAILER}: <id>\" (AGENTS.md §0) — "
191
+ "a commit added now does not name the earlier ones: recommit them, on a new branch "
192
+ "where the history is not rewritten; or have a person run the sheet.")
193
+
194
+
195
+ def summary(body: str, authorship: tuple[set[str], set[str], list[str]]) -> list[str]:
196
+ """What the check read of a sheet, in a line, and what stands in a session's way before one
197
+ has done the work (D93): a verdict of one word told a verifier nothing of either."""
198
+ verifier, _, rows = read_sheet(body)
199
+ if not rows:
200
+ return []
201
+ counts: dict[str, int] = {}
202
+ for row in rows:
203
+ result = _result(row.get("result", ""))
204
+ result = result if result in RESULTS else "unreadable"
205
+ counts[result] = counts.get(result, 0) + 1
206
+ said = ", ".join(f"{number} {result}" for result, number in counts.items())
207
+ lines = [f" Read: {len(rows)} scenario(s) — {said}; verifier: {verifier or 'not named'}, "
208
+ f"compared with {len(authorship[0])} account(s) and {len(authorship[1])} session(s) of the change"]
209
+ if authorship[2] and not SESSION.match(verifier):
210
+ lines.append(f" NOTE: an agent's commits name no session ({', '.join(authorship[2])}): as it stands, "
211
+ f"only a person can verify this change.\n To let a session verify it: {UNNAMED_ACTION}")
212
+ return lines
213
+
214
+
188
215
  def check_verifier(verifier: str, authorship: tuple[set[str], set[str], list[str]], fail: Fail) -> None:
189
216
  """T2: the verifier is named — people as @handle anywhere on the line, an agent session as
190
217
  `session <id>` opening it — and none of the names is an author of the change."""
@@ -206,8 +233,7 @@ def check_verifier(verifier: str, authorship: tuple[set[str], set[str], list[str
206
233
  "with a fresh context (.nstack/playbooks/verification.md).")
207
234
  elif session and unnamed:
208
235
  fail("T2", f"Test sheet: an agent's commits name no session ({', '.join(unnamed)}): the verifier "
209
- f"cannot be told from their author.\n Action: the authoring session commits with "
210
- f"the trailer \"{SESSION_TRAILER}: <id>\" (AGENTS.md §0), then the sheet is run again.")
236
+ f"cannot be told from their author.\n Action: {UNNAMED_ACTION}")
211
237
 
212
238
 
213
239
  def _result(cell: str) -> str:
@@ -216,7 +242,7 @@ def _result(cell: str) -> str:
216
242
 
217
243
  def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
218
244
  authorship: tuple[set[str], set[str], list[str]] = (set(), set(), []),
219
- strict: bool = False) -> list[str]:
245
+ strict: bool = False, unchanged: Callable[[str], bool] = lambda commit: False) -> list[str]:
220
246
  """T1 to T5; returns the human-only scenarios, listed apart for the approver."""
221
247
  verifier, header, rows = read_sheet(body)
222
248
  if reasons and not rows:
@@ -257,7 +283,8 @@ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
257
283
  if result == "passed" and (row["evidence"] in EMPTY or not SHA.fullmatch(commit)):
258
284
  fail("T3", f"scenario {ident}: passed without evidence and the commit verified.\n"
259
285
  " Action: link the screenshot, video, trace or log, and give the commit.")
260
- elif result in {"passed", "failed"} and SHA.fullmatch(commit) and not head.startswith(commit):
286
+ elif (result in {"passed", "failed"} and SHA.fullmatch(commit) and not head.startswith(commit)
287
+ and not unchanged(commit)):
261
288
  # T4 split by kind (D71). An automated scenario is cheap and CI replays it when e2e is
262
289
  # declared, so it is re-run. One that was explored by hand costs a round: its evidence keeps the commit
263
290
  # it was produced on, and the verifier confirms at the head, naming what moved since.
@@ -362,12 +389,20 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
362
389
 
363
390
  reasons = [reason for folder, found in modules.items() if (reason := sheet_reason(folder, found))]
364
391
  authorship = authors(root, base, head, os.environ.get("PR_AUTHOR", ""))
365
- human_only = check_sheet(body, head, reasons, fail, authorship, rerun_at_head(modules))
392
+ # A commit whose tree is the head's is the head, for a sheet (D93): merging the default
393
+ # branch into a pull request that already carried it changes a hash and no file.
394
+ def tree_of(commit: str) -> str:
395
+ return _git(root, "rev-parse", "--verify", "--quiet", f"{commit}^{{tree}}").stdout.strip()
396
+
397
+ human_only = check_sheet(body, head, reasons, fail, authorship, rerun_at_head(modules),
398
+ lambda commit: bool(tree_of(commit)) and tree_of(commit) == tree_of(head))
366
399
  if modules:
367
400
  check_cycle(root, body, labels, datetime.date.today(), fail)
368
401
 
369
402
  print(f"Modules touched : {len(modules)}" + "".join(f"\n - {folder}" for folder in modules))
370
403
  print(f"Test sheet : {'required' if reasons else 'not required'}")
404
+ for line in summary(body, authorship) if reasons else []:
405
+ print(line)
371
406
  for scenario in human_only:
372
407
  print(f" For the approver, human only: {scenario}")
373
408
  for failure in failures:
File without changes