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.
- {napkinstack-0.13.0 → napkinstack-0.14.0}/PKG-INFO +4 -4
- {napkinstack-0.13.0 → napkinstack-0.14.0}/README.md +3 -3
- {napkinstack-0.13.0 → napkinstack-0.14.0}/pyproject.toml +1 -1
- {napkinstack-0.13.0 → napkinstack-0.14.0}/pyproject.toml.orig +1 -1
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/attest.py +19 -1
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/board.py +7 -3
- napkinstack-0.14.0/src/napkinstack/evidence.py +365 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/manifests.py +61 -1
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/modules.py +18 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/module/MANIFEST.yaml +9 -1
- {napkinstack-0.13.0 → napkinstack-0.14.0}/LICENSE +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/assurance.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/cli.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/compat.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/discovery.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/doctor.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/boundaries.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/plan.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/fitness/pr_scope.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/hold.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/project.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/provenance.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/pull_request.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/settings.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/skills.py +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/README.md +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/module/README.md +0 -0
- {napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
- {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.
|
|
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.
|
|
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.
|
|
88
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
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.
|
|
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.
|
|
73
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
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
|
|
@@ -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,
|
|
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"
|
|
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"
|
|
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;
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/docs/runbook.md
RENAMED
|
File without changes
|
{napkinstack-0.13.0 → napkinstack-0.14.0}/src/napkinstack/templates/contracts/tests/.gitkeep
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|