napkinstack 0.13.0__tar.gz → 0.14.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.
Files changed (38) hide show
  1. {napkinstack-0.13.0 → napkinstack-0.14.0}/PKG-INFO +4 -4
  2. {napkinstack-0.13.0 → napkinstack-0.14.0}/README.md +3 -3
  3. {napkinstack-0.13.0 → napkinstack-0.14.0}/pyproject.toml +1 -1
  4. {napkinstack-0.13.0 → napkinstack-0.14.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/attest.py +19 -1
  6. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/board.py +7 -3
  7. napkinstack-0.14.0/src/napkinstack/evidence.py +365 -0
  8. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/manifests.py +61 -1
  9. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/modules.py +18 -0
  10. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/module/MANIFEST.yaml +9 -1
  11. {napkinstack-0.13.0 → napkinstack-0.14.0}/LICENSE +0 -0
  12. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/__init__.py +0 -0
  13. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/assurance.py +0 -0
  14. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/cli.py +0 -0
  15. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/compat.py +0 -0
  16. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/discovery.py +0 -0
  17. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/doctor.py +0 -0
  18. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/__init__.py +0 -0
  19. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/boundaries.py +0 -0
  20. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/hygiene.py +0 -0
  21. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/plan.py +0 -0
  22. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  23. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/hold.py +0 -0
  24. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/landed.py +0 -0
  25. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/project.py +0 -0
  26. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/provenance.py +0 -0
  27. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/pull_request.py +0 -0
  28. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/settings.py +0 -0
  29. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/skills.py +0 -0
  30. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  31. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  32. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/README.md +0 -0
  33. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  34. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  35. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  36. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/module/README.md +0 -0
  37. {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  38. {napkinstack-0.13.0 → napkinstack-0.14.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.13.0
3
+ Version: 0.14.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.13.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
23
+ > **Status: v0.14.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.13.0" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.13.0"
87
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.14.0" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.14.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.13.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.14.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.13.0" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.13.0"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.14.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.14.0"
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.13.0"
3
+ version = "0.14.0"
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.13.0"
3
+ version = "0.14.0"
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"
@@ -153,6 +153,24 @@ def _show(root: Path, commit: str, passed: int, total: int) -> str:
153
153
  return f"not shown on GitHub ({error})"
154
154
 
155
155
 
156
+ def evidence_of(root: Path, commit: str) -> list[dict]:
157
+ """The audits of the end-to-end runs made on this commit (ADR-0009): each module's folder,
158
+ relative to the project — never a path naming a person's home in notes pushed."""
159
+ found = []
160
+ for path in sorted(root.rglob(".evidence/e2e/audit.json")):
161
+ try:
162
+ audit = json.loads(path.read_text(encoding="utf-8"))
163
+ except (OSError, ValueError):
164
+ continue
165
+ if isinstance(audit, dict) and audit.get("commit") == commit:
166
+ kept = {key: value for key, value in audit.items() if key not in ("commit", "module")}
167
+ # A log line may carry a path or a name: ten at most travel in notes pushed to a remote.
168
+ undeclared = kept.get("undeclared") if isinstance(kept.get("undeclared"), list) else []
169
+ kept.update(undeclared=undeclared[:10], undeclared_count=len(undeclared))
170
+ found.append({"module": audit.get("module"), "folder": path.parent.relative_to(root).as_posix(), "audit": kept})
171
+ return found
172
+
173
+
156
174
  def run(root: Path, base: str, body_file: Path | None, labels: str) -> int:
157
175
  print(provenance.judged_by(root), flush=True)
158
176
  if _git(root, "status", "--porcelain", "--untracked-files=normal").stdout.strip():
@@ -191,7 +209,7 @@ def run(root: Path, base: str, body_file: Path | None, labels: str) -> int:
191
209
  record = {"record": RECORD, "commit": commit,
192
210
  "at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="microseconds"),
193
211
  "engine": engine, "published": origin is None, "by": who, "session": _session(root, commit),
194
- "base": base, "checks": results}
212
+ "base": base, "checks": results, "evidence": evidence_of(root, commit)}
195
213
  remote = fetch(root)
196
214
  before = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
197
215
  lines = (before.stdout.rstrip("\n") + "\n" if before.returncode == 0 and before.stdout.strip() else "")
@@ -7,7 +7,7 @@ objects carry a marker in their body; an object without one is never touched. Wh
7
7
  holds decides nothing but whether the display must be rewritten.
8
8
 
9
9
  Usage : nstack board [--root ROOT] [--dry-run]
10
- Output: 0 whatever the forge answers — what was made, changed, restored or not shown is said.
10
+ Output: 0 whatever the forge answers — what was made, updated or not shown is said.
11
11
  """
12
12
 
13
13
  from __future__ import annotations
@@ -296,7 +296,7 @@ def _apply(forge, shown: list[Shown], milestones: list[dict], issues: list[dict]
296
296
  changed.append("due date")
297
297
  if changed:
298
298
  call("PATCH", f"/milestones/{have['number']}", want)
299
- said.append(f"restored milestone {item.title}: {', '.join(changed)} — the plan is edited in the cycle file")
299
+ said.append(f"updated milestone {item.title}: {', '.join(changed)}")
300
300
  ours = sum(1 for issue in own_issues.values()
301
301
  if isinstance(issue.get("milestone"), dict) and issue["milestone"].get("number") == have.get("number"))
302
302
  theirs = int(have.get("open_issues") or 0) + int(have.get("closed_issues") or 0) - ours
@@ -349,11 +349,15 @@ def _apply(forge, shown: list[Shown], milestones: list[dict], issues: list[dict]
349
349
  want.update(state="closed", state_reason=item.reason)
350
350
  if changed:
351
351
  call("PATCH", f"/issues/{have['number']}", want)
352
- said.append(f"restored issue {item.title}: {', '.join(changed)}")
352
+ said.append(f"updated issue {item.title}: {', '.join(changed)}")
353
353
 
354
354
  wanted = {item.key for item in shown if item.kind == "deliverable"}
355
355
  for key, have in own_issues.items():
356
356
  if key not in wanted and have.get("state") == "open" and have.get("number"):
357
357
  call("PATCH", f"/issues/{have['number']}", {"state": "closed", "state_reason": "not_planned"})
358
358
  said.append(f"closed issue {have.get('title')}: its deliverable is no longer in a shown cycle")
359
+ if any(line.startswith("updated ") for line in said):
360
+ # The run cannot tell the plan moving from a hand edit; it says what either means.
361
+ said.append("an update follows the cycle files and the pull requests, and undoes a change made here by "
362
+ "hand: the plan is edited in the cycle files, in a pull request")
359
363
  return said
@@ -0,0 +1,365 @@
1
+ """
2
+ What a module's end-to-end run said (PDR-0017, ADR-0009).
3
+
4
+ `nstack e2e` gives the run its folder, `NSTACK_EVIDENCE`, keeps the command's output there, and
5
+ then reads the log files the run wrote into it: a warning or an error the module did not declare
6
+ — in its manifest's `evidence.provoked` — is said, and judged by the module's criticality. The
7
+ version that brings the audit only reports; the project's next minor version judges.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import select
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+
24
+ FIRST = (0, 14) # the first version carrying the audit: a project recording it reports, the next minor judges
25
+ FOLDER = Path(".evidence") / "e2e"
26
+ READ = (".log", ".jsonl")
27
+ KEPT = ("output.txt", "audit.json", ".running")
28
+ WIDTH = 64 * 1024 # a line read up to its first 64 KiB
29
+ DRAIN = 2.0 # seconds a pipe is drained after the command exits
30
+ STRICT = {"high", "critical"} # where an undeclared warning fails
31
+ SHOWN = 10
32
+
33
+ WORDS = {"warn": "warning", "warning": "warning",
34
+ "error": "error", "err": "error", "fatal": "error", "critical": "error", "panic": "error",
35
+ "alert": "error", "emergency": "error",
36
+ "info": "below", "information": "below", "debug": "below", "trace": "below", "notice": "below",
37
+ "verbose": "below", "fine": "below"}
38
+ KEYS = ("level", "levelname", "severity", "lvl", "@l")
39
+ PINO = {40: "warning", 50: "error", 60: "error"}
40
+ COLOUR = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
41
+ LOGFMT = re.compile(r"^\S+=\S*(?:\s+\S+=\S*)*?\s*\blevel=\"?([A-Za-z]+)") # a line of key=value pairs
42
+ STAMP = r"(?=\S*\d)(?=\S*[:./T-])[\d:.,/TZ+\-]+" # a timestamp: a digit and a separator, never a lone number or dash
43
+ UPPER = "|".join(sorted((word.upper() for word in WORDS), key=len, reverse=True))
44
+ LOWER = "|".join(sorted(WORDS, key=len, reverse=True))
45
+ LEADING = re.compile(
46
+ rf"^\s*\[?(?:{STAMP}\s+){{0,2}}\[?\s*"
47
+ rf"(?:(?P<up>{UPPER})(?=[\s:\]])|(?P<any>(?i:{LOWER}))(?=:|\]))")
48
+ GLIB = re.compile(r"[\w.-]+-(WARNING|CRITICAL|MESSAGE|DEBUG|INFO) \*\*:")
49
+
50
+
51
+ def compile_levels(expression: str | None) -> re.Pattern | None:
52
+ return re.compile(expression) if expression else None
53
+
54
+
55
+ def level_of(line: str, own: re.Pattern | None = None) -> str | None:
56
+ """`warning`, `error`, `below` — or None when the line does not say its level."""
57
+ if own is not None:
58
+ found = own.search(line)
59
+ if found and found.groupdict().get("level"):
60
+ return WORDS.get(found["level"].lower())
61
+ text = line.strip()
62
+ if text.startswith("{"):
63
+ try:
64
+ data = json.loads(text)
65
+ except ValueError:
66
+ data = None
67
+ if isinstance(data, dict):
68
+ for key in KEYS:
69
+ value = data.get(key)
70
+ if isinstance(value, str):
71
+ return WORDS.get(value.strip().lower(), "below")
72
+ if isinstance(value, int) and not isinstance(value, bool):
73
+ return PINO.get(value, "below")
74
+ if value is not None:
75
+ return "below"
76
+ return None
77
+ plain = COLOUR.sub("", text)
78
+ found = LEADING.match(plain) # a leading word first: a message may hold "level=…"
79
+ if found:
80
+ return WORDS[(found["up"] or found["any"]).lower()]
81
+ found = GLIB.search(plain)
82
+ if found:
83
+ return {"WARNING": "warning", "CRITICAL": "error"}.get(found[1], "below")
84
+ found = LOGFMT.match(plain)
85
+ if found:
86
+ return WORDS.get(found[1].lower(), "below")
87
+ return None
88
+
89
+
90
+ def _message(line: str) -> str:
91
+ text = line.strip()
92
+ if text.startswith("{"):
93
+ try:
94
+ data = json.loads(text)
95
+ except ValueError:
96
+ data = None
97
+ if isinstance(data, dict):
98
+ for key in ("message", "msg", "event"):
99
+ if isinstance(data.get(key), str):
100
+ return data[key]
101
+ plain = COLOUR.sub("", text)
102
+ found = LEADING.match(plain)
103
+ return plain[found.end():].strip(" :]") if found else plain
104
+
105
+
106
+ def suggest(line: str) -> str:
107
+ """A declaration's pattern that matches this line, and its kin, again: the message, escaped,
108
+ its numbers and hexadecimal identifiers made general."""
109
+ general = (r"(?i:\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b" # a UUID
110
+ r"|\b0x[0-9a-f]+\b|\b(?=[0-9a-f]*\d)[0-9a-f]{8,}\b)|\d+")
111
+ parts = re.split(f"({general})", _message(line)[:200])
112
+ out = []
113
+ for index, part in enumerate(parts):
114
+ if index % 2:
115
+ out.append(r"\d+" if part.isdigit() else "(?i:(?:0x)?[0-9a-f-]+)")
116
+ else:
117
+ out.append(re.escape(part))
118
+ return "".join(out).strip() or re.escape(line.strip()[:80])
119
+
120
+
121
+ def _files(folder: Path) -> list[Path]:
122
+ """The log files a run wrote into its folder: regular files, links never followed."""
123
+ found = []
124
+ for path in sorted(folder.rglob("*")):
125
+ if path.name in KEPT or path.suffix not in READ:
126
+ continue
127
+ if path.is_symlink() or not path.is_file():
128
+ continue
129
+ found.append(path)
130
+ return found
131
+
132
+
133
+ def _lines(path: Path):
134
+ """(number, line) of a file, each line its first WIDTH characters — never a whole huge line held."""
135
+ with open(path, encoding="utf-8", errors="replace", newline=None) as source:
136
+ number = 0
137
+ while True:
138
+ chunk = source.readline(WIDTH)
139
+ if not chunk:
140
+ return
141
+ number += 1
142
+ line, ended = chunk.rstrip("\n"), chunk.endswith("\n")
143
+ while not ended:
144
+ rest = source.readline(WIDTH)
145
+ if not rest:
146
+ break
147
+ ended = rest.endswith("\n")
148
+ yield number, line
149
+
150
+
151
+ def audit(folder: Path, declared: list[dict], criticality: str | None, step: str,
152
+ levels: str | None = None) -> dict:
153
+ """The audit of a run's folder: what was read, what nobody declared, the verdict."""
154
+ own = compile_levels(levels)
155
+ patterns = [(item, re.compile(item["pattern"])) for item in declared]
156
+ matched: set[str] = set()
157
+ undeclared, read, not_read = [], [], []
158
+ for path in _files(folder):
159
+ relative = path.relative_to(folder).as_posix()
160
+ classified = False
161
+ for number, line in _lines(path):
162
+ if not line.strip():
163
+ continue
164
+ level = level_of(line, own)
165
+ if level is None:
166
+ continue
167
+ classified = True
168
+ if level == "below":
169
+ continue
170
+ plain = COLOUR.sub("", line) # searched as the suggestion was built: without colour
171
+ hit = [item for item, pattern in patterns if item["level"] == level and pattern.search(plain)]
172
+ matched.update(item["pattern"] for item in hit)
173
+ if not hit:
174
+ undeclared.append({"file": relative, "line": number, "level": level,
175
+ "text": line[:500], "suggest": suggest(line)})
176
+ (read if classified else not_read).append(relative)
177
+ strict = criticality not in {"prototype", "standard"} # undeclared or unknown: judged as critical
178
+ failing = [item for item in undeclared if item["level"] == "error" or strict]
179
+ if step == "judge" and failing:
180
+ verdict = "failed"
181
+ elif undeclared:
182
+ verdict = "reported"
183
+ else:
184
+ verdict = "passed"
185
+ return {"step": step, "verdict": verdict, "read": [name for name in read if name not in not_read],
186
+ "not_read": not_read, "undeclared": undeclared,
187
+ "stale": [item["pattern"] for item in declared if item["pattern"] not in matched]}
188
+
189
+
190
+ # --- the project's step (§6) ------------------------------------------------------------------
191
+
192
+ VERSION = re.compile(r"v?(\d+)\.(\d+)")
193
+
194
+
195
+ def _minor(text: str) -> tuple[int, int] | None:
196
+ try:
197
+ data = yaml.safe_load(text) or {}
198
+ except yaml.YAMLError:
199
+ return None
200
+ found = VERSION.match(str(data.get("_commit") or "")) if isinstance(data, dict) else None
201
+ return (int(found[1]), int(found[2])) if found else None
202
+
203
+
204
+ def step(root: Path) -> str:
205
+ """`judge` once the project's own history recorded a version carrying the audit at a minor below
206
+ the one it is at; `report` otherwise — and without that history."""
207
+ answers = root / ".copier-answers.yml"
208
+ current = _minor(answers.read_text(encoding="utf-8")) if answers.is_file() else None
209
+ if current is None:
210
+ return "report"
211
+ log = subprocess.run(["git", "log", "--format=%H", "--", ".copier-answers.yml"], cwd=root,
212
+ capture_output=True, text=True)
213
+ for commit in log.stdout.split():
214
+ shown = subprocess.run(["git", "show", f"{commit}:.copier-answers.yml"], cwd=root,
215
+ capture_output=True, text=True)
216
+ past = _minor(shown.stdout) if shown.returncode == 0 else None
217
+ if past is not None and FIRST <= past < current:
218
+ return "judge"
219
+ return "report"
220
+
221
+
222
+ # --- the run (§1, §2, §5, §7) ------------------------------------------------------------------
223
+
224
+ def _alive(pid: int) -> bool:
225
+ try:
226
+ os.kill(pid, 0)
227
+ except (OSError, ValueError):
228
+ return False
229
+ return True
230
+
231
+
232
+ def prepare(root: Path, module: Path) -> Path | str:
233
+ """The run's folder, emptied — or why it cannot be."""
234
+ base, folder = module / ".evidence", module / FOLDER
235
+ odd = next((path for path in (base, folder) if path.is_symlink() or (path.exists() and not path.is_dir())), None)
236
+ if odd is not None:
237
+ return (f"{odd.relative_to(root)} is a link or a file, not a folder of the module's own: nothing is "
238
+ "emptied through it.\n Action: remove it, then run again.")
239
+ running = folder / ".running"
240
+ if running.is_file():
241
+ try:
242
+ pid = int(running.read_text(encoding="utf-8").strip() or 0)
243
+ except (OSError, ValueError):
244
+ pid = 0
245
+ if pid and _alive(pid):
246
+ return f"another end-to-end run of this module is in progress (process {pid}).\n Action: wait for it."
247
+ folder.mkdir(parents=True, exist_ok=True)
248
+ for path in sorted(folder.rglob("*"), reverse=True):
249
+ try:
250
+ if path.is_dir() and not path.is_symlink():
251
+ path.rmdir()
252
+ else:
253
+ path.unlink()
254
+ except OSError as error:
255
+ return f"{path.relative_to(root)} cannot be removed ({error.strerror}).\n Action: remove it, then run again."
256
+ (folder / ".running").write_text(str(os.getpid()), encoding="utf-8")
257
+ return folder
258
+
259
+
260
+ def run(command: str, cwd: Path, environment: dict, folder: Path) -> tuple[int, str | None]:
261
+ """The command, its output shown live and kept as it comes, in bytes; the process waited for,
262
+ the pipe drained two seconds after. Returns its exit code, and what still held the pipe."""
263
+ out = sys.stdout.buffer if hasattr(sys.stdout, "buffer") else None
264
+ with open(folder / "output.txt", "wb") as kept:
265
+ process = subprocess.Popen(command, shell=True, cwd=cwd, env=environment,
266
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
267
+ source = process.stdout.fileno()
268
+
269
+ def copy(timeout: float) -> bool:
270
+ ready, _, _ = select.select([source], [], [], timeout)
271
+ if not ready:
272
+ return True
273
+ chunk = os.read(source, 65536)
274
+ if not chunk:
275
+ return False
276
+ kept.write(chunk)
277
+ kept.flush()
278
+ if out is not None:
279
+ out.write(chunk)
280
+ out.flush()
281
+ else:
282
+ print(chunk.decode("utf-8", "replace"), end="", flush=True)
283
+ return True
284
+
285
+ open_pipe = True
286
+ try:
287
+ while process.poll() is None and open_pipe:
288
+ open_pipe = copy(0.2)
289
+ process.wait()
290
+ except KeyboardInterrupt:
291
+ process.send_signal(signal.SIGINT)
292
+ previous = signal.signal(signal.SIGINT, signal.SIG_IGN) # a second interrupt: the audit still runs
293
+ try:
294
+ process.wait()
295
+ finally:
296
+ signal.signal(signal.SIGINT, previous)
297
+ deadline = time.monotonic() + DRAIN
298
+ while open_pipe and time.monotonic() < deadline:
299
+ open_pipe = copy(max(0.0, deadline - time.monotonic()))
300
+ process.stdout.close()
301
+ held = ("a process the command started still holds its output, after it exited: what it writes now "
302
+ "is not kept") if open_pipe else None
303
+ return process.returncode, held
304
+
305
+
306
+ def declarations(manifest: Path) -> tuple[list[dict], str | None, str | None]:
307
+ """The module's declared lines, its own level expression, its criticality."""
308
+ try:
309
+ data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
310
+ except (OSError, yaml.YAMLError):
311
+ return [], None, None
312
+ section = data.get("evidence") if isinstance(data.get("evidence"), dict) else {}
313
+ provoked = [item for item in section.get("provoked") or [] if isinstance(item, dict) and item.get("pattern")]
314
+ criticality = (data.get("module") or {}).get("criticality") if isinstance(data.get("module"), dict) else None
315
+ levels = section.get("levels") if isinstance(section.get("levels"), str) else None
316
+ return provoked, levels, criticality
317
+
318
+
319
+ def say(root: Path, name: str, folder: Path, found: dict) -> list[str]:
320
+ """The audit in a few lines."""
321
+ where = folder.relative_to(root).as_posix()
322
+ head = {"passed": "nothing undeclared", "reported": f"{len(found['undeclared'])} undeclared line(s), reported",
323
+ "failed": f"{len(found['undeclared'])} undeclared line(s)"}[found["verdict"]]
324
+ lines = [f"Evidence of {name}: {where} — {head} ({found['step']}; read: {', '.join(found['read']) or 'no log file'})"]
325
+ for item in found["undeclared"][:SHOWN]:
326
+ lines.append(f" {item['level'].upper():8} {item['file']}:{item['line']}: {item['text'][:200]}")
327
+ lines.append(f" declare it: - {{pattern: {json.dumps(item['suggest'])}, level: {item['level']}, "
328
+ "scenario: <the scenario that provokes it>}")
329
+ if len(found["undeclared"]) > SHOWN:
330
+ lines.append(f" … and {len(found['undeclared']) - SHOWN} more, in {where}/audit.json")
331
+ for name_ in found["not_read"]:
332
+ lines.append(f" not read: {name_} — no line says its level in a form the engine knows; declare "
333
+ "evidence.levels, an expression with a group named level")
334
+ for pattern in found["stale"]:
335
+ lines.append(f" declared, matched nothing: {json.dumps(pattern)} — the failure it stood for did not happen")
336
+ if found["step"] == "report" and found["undeclared"]:
337
+ lines.append(" Reported, not judged: this project's next minor version of NapkinStack fails on what is "
338
+ "undeclared then (an error at any criticality, a warning from high).")
339
+ return lines
340
+
341
+
342
+ def finish(root: Path, name: str, module: Path, folder: Path, code: int, held: str | None) -> bool:
343
+ """The audit after the command, whatever its exit code: True when the logs pass. The running
344
+ mark is removed whatever happens."""
345
+ try:
346
+ declared, levels, criticality = declarations(module / "MANIFEST.yaml")
347
+ commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True).stdout.strip()
348
+ try:
349
+ found = audit(folder, declared, criticality, step(root), levels)
350
+ except (re.error, TypeError) as error:
351
+ (folder / "audit.json").write_text(json.dumps({"commit": commit, "module": name, "exit": code,
352
+ "verdict": "unread", "error": str(error)}), encoding="utf-8")
353
+ print(f"FAIL [e2e] module '{name}': a declaration cannot be read ({error}).\n Action: nstack manifests.")
354
+ return False
355
+ record = {"commit": commit, "module": name, "exit": code, **found}
356
+ (folder / "audit.json").write_text(json.dumps(record, indent=1, ensure_ascii=False), encoding="utf-8")
357
+ for line in say(root, name, folder, found) + ([f" {held}"] if held else []):
358
+ print(line, flush=True)
359
+ if found["verdict"] == "failed":
360
+ print(f"FAIL [e2e] module '{name}': its run logged what it does not declare — see above.\n"
361
+ " Action: fix the cause, or declare the line in evidence.provoked with the scenario that provokes it.")
362
+ return False
363
+ return True
364
+ finally:
365
+ (folder / ".running").unlink(missing_ok=True)
@@ -19,6 +19,8 @@ Rules:
19
19
  M10 user_facing declared: true when a user sees the module (.nstack/handbook/05-workflow.md §7)
20
20
  M11 assembles names other modules, as a list, and no module waits for itself through it
21
21
  (.nstack/handbook/09-platform.md §2, D127)
22
+ M12 evidence declares what the module's end-to-end scenarios provoke: patterns that compile and
23
+ check something, a level, a scenario (ADR-0009)
22
24
 
23
25
  Usage : nstack manifests [--root ROOT]
24
26
  Output: 0 if everything passes, 1 otherwise. Every failure explains the rule broken.
@@ -42,7 +44,7 @@ REQUIRED_COMMANDS = ["check", "test"]
42
44
  MODULE_DIRS = ["modules", "services", "apps", "packages", "contracts", "platform"]
43
45
  MODULE_BASES = ["modules", "services", "apps", "packages"] # bases where every folder is a module
44
46
  SECTIONS = {"module": dict, "provides": list, "consumes": list, "data": dict,
45
- "commands": dict, "docs": dict, "dependencies": list}
47
+ "commands": dict, "docs": dict, "dependencies": list, "evidence": dict}
46
48
  TYPES = {dict: "mapping", list: "list"}
47
49
  DESCRIPTION = {"MANIFEST.yaml", "AGENTS.md", "README.md"}
48
50
  STABILITIES = {"experimental", "stable", "deprecated"}
@@ -361,6 +363,63 @@ def comparator_owed(root: Path, manifests: list[Path]) -> None:
361
363
  "(.nstack/handbook/03-contracts.md §3).")
362
364
 
363
365
 
366
+ EVIDENCE_LEVELS = ("warning", "error")
367
+ DECLARED_KEYS = {"pattern", "level", "scenario"}
368
+
369
+
370
+ def declared_evidence(manifests: list[Path], root: Path) -> None:
371
+ """M12: what a module's end-to-end scenarios provoke is declared so that it checks something —
372
+ a pattern that compiles and does not accept every line, a level, the scenario."""
373
+ for manifest in manifests:
374
+ text = manifest.read_text(encoding="utf-8")
375
+ try:
376
+ data = yaml.safe_load(text) or {}
377
+ except yaml.YAMLError:
378
+ continue # M2 says it
379
+ section = data.get("evidence") if isinstance(data, dict) else None
380
+ if not isinstance(section, dict):
381
+ continue # absent, or not a mapping: M2 says it
382
+ name, at = manifest.parent.name, f"{manifest.relative_to(root).as_posix()}:{line_of(text, 'evidence')}"
383
+ action = " Action: evidence.provoked — a list of {pattern, level: warning|error, scenario} (.nstack/playbooks/tests.md)."
384
+ for key in sorted(set(section) - {"provoked", "levels"}):
385
+ fail(name, "M12", f"evidence has an unknown key '{key}': it declares nothing.\n{action}", at)
386
+ provoked = section.get("provoked", [])
387
+ if not isinstance(provoked, list):
388
+ fail(name, "M12", f"evidence.provoked must be a list.\n{action}", at)
389
+ provoked = []
390
+ for item in provoked:
391
+ if not isinstance(item, dict) or set(item) != DECLARED_KEYS:
392
+ fail(name, "M12", f"each declaration holds exactly pattern, level and scenario: {item!r}.\n{action}", at)
393
+ continue
394
+ if not isinstance(item["pattern"], str):
395
+ fail(name, "M12", f"pattern {item['pattern']!r} must be text, a regular expression.\n{action}", at)
396
+ continue
397
+ try:
398
+ compiled = re.compile(item["pattern"])
399
+ except re.error as error:
400
+ fail(name, "M12", f"pattern {item['pattern']!r} does not compile ({error}).\n{action}", at)
401
+ continue
402
+ if compiled.search(""):
403
+ fail(name, "M12", f"pattern {item['pattern']!r} matches an empty line: it would accept every line, "
404
+ f"and check nothing.\n{action}", at)
405
+ if item["level"] not in EVIDENCE_LEVELS:
406
+ fail(name, "M12", f"level {item['level']!r}: warning or error.\n{action}", at)
407
+ if not isinstance(item["scenario"], str) or not item["scenario"].strip():
408
+ fail(name, "M12", f"the declaration of {item['pattern']!r} names no scenario: say which provokes it.\n{action}", at)
409
+ if "levels" in section and not isinstance(section["levels"], str):
410
+ fail(name, "M12", "evidence.levels must be text, a regular expression.\n"
411
+ " Action: an expression whose group named level captures the line's level word.", at)
412
+ elif "levels" in section:
413
+ try:
414
+ levels = re.compile(section["levels"])
415
+ if "level" not in levels.groupindex:
416
+ fail(name, "M12", "evidence.levels needs a group named level: (?P<level>…).\n"
417
+ " Action: an expression whose group named level captures the line's level word.", at)
418
+ except re.error as error:
419
+ fail(name, "M12", f"evidence.levels does not compile ({error}).\n"
420
+ " Action: a Python regular expression with a group named level.", at)
421
+
422
+
364
423
  def assembled(manifests: list[Path], root: Path) -> None:
365
424
  """M11: what a module's release assembles — other modules' artefacts — names them, and never
366
425
  leads back to the module itself: `nstack release` runs each after what it assembles."""
@@ -424,6 +483,7 @@ def run(root: Path) -> int:
424
483
  for manifest in manifests:
425
484
  check_manifest(manifest, today, root)
426
485
  assembled(manifests, root)
486
+ declared_evidence(manifests, root)
427
487
  comparator_owed(root, manifests)
428
488
 
429
489
  print(f"Manifests checked: {len(manifests)}")
@@ -369,6 +369,24 @@ def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | Non
369
369
  shutil.rmtree(dist, ignore_errors=True)
370
370
  dist.mkdir(parents=True)
371
371
  environment = {**os.environ, "NSTACK_DIST": str(dist.resolve())}
372
+ if verb == "e2e":
373
+ # Its evidence: a folder of its own, its output kept, its logs read after (ADR-0009).
374
+ from napkinstack import evidence
375
+
376
+ folder = evidence.prepare(root, known[target])
377
+ if isinstance(folder, str):
378
+ print(f"FAIL [e2e] module '{target}': {folder}")
379
+ return 1
380
+ print(f"-> {target}: {command}", flush=True)
381
+ code, held = evidence.run(command, known[target], {**os.environ, "NSTACK_EVIDENCE": str(folder.resolve())},
382
+ folder)
383
+ passed = evidence.finish(root, target, known[target], folder, code, held)
384
+ if code:
385
+ print(f"FAIL [{verb}] module '{target}': `{command}` exited with {code}.")
386
+ return code
387
+ if not passed:
388
+ return 1
389
+ continue
372
390
  print(f"-> {target}: {command}", flush=True)
373
391
  code = subprocess.run(command, shell=True, cwd=known[target], env=environment).returncode
374
392
  if code:
@@ -77,13 +77,21 @@ commands:
77
77
  # test: # NEVER starts another module
78
78
  # bootstrap: # optional: make the module usable from a fresh clone
79
79
  # run: # optional: local start, with doubles for the dependencies
80
- # e2e: # optional: end-to-end scenarios; evidence written to .evidence/
80
+ # e2e: # optional: end-to-end scenarios; logs and screenshots written
81
+ # # to $NSTACK_EVIDENCE, read after the run (.nstack/playbooks/tests.md)
81
82
  # clean: # optional: remove what the commands above leave in this folder
82
83
  # # (build outputs); CI runs it after every job on a self-hosted runner
83
84
  # release: # optional: the module's shippable artefacts, written into
84
85
  # # $NSTACK_DIST (dist/<module>/); `nstack release <module> …`
85
86
  # # passes the platform or target to it
86
87
 
88
+ # evidence: # optional, with e2e: what its scenarios provoke on purpose (M12)
89
+ # provoked: # a warning or an error not declared here is said after the run:
90
+ # - pattern: "key refused" # an error fails it, a warning fails from criticality high
91
+ # level: warning # warning | error
92
+ # scenario: S3 # the scenario that provokes it, for the reader
93
+ # levels: '^(?P<level>\w+)\|' # only if the logs say their level in an uncommon form
94
+
87
95
  docs:
88
96
  readme: README.md
89
97
  agents: AGENTS.md
File without changes