everydeck-check 0.1.0__py3-none-any.whl
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.
- everydeck_check/__init__.py +713 -0
- everydeck_check/befund.py +716 -0
- everydeck_check/change.py +124 -0
- everydeck_check/cli.py +258 -0
- everydeck_check/coverage.py +198 -0
- everydeck_check/detect.py +236 -0
- everydeck_check/reads.py +208 -0
- everydeck_check/reanchor.py +146 -0
- everydeck_check/rules/everydeck-rules.mjs +14 -0
- everydeck_check/rules.py +35 -0
- everydeck_check/schemas/befund.schema.json +1374 -0
- everydeck_check/schemas/change.schema.json +92 -0
- everydeck_check/schemas/manifest.schema.json +2347 -0
- everydeck_check-0.1.0.dist-info/METADATA +34 -0
- everydeck_check-0.1.0.dist-info/RECORD +19 -0
- everydeck_check-0.1.0.dist-info/WHEEL +5 -0
- everydeck_check-0.1.0.dist-info/entry_points.txt +2 -0
- everydeck_check-0.1.0.dist-info/licenses/LICENSE +21 -0
- everydeck_check-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"""everydeck-check: deterministischer Prüf-/Synchronisierungsschritt im CI-/Buildweg des Kunden (docs/02).
|
|
2
|
+
|
|
3
|
+
- validate: Manifest gegen Schema 1.0-r0 und Referenzen (Signale, Fähigkeiten, Regeln).
|
|
4
|
+
- drift: Fundstellen (path, symbol) müssen existieren und im Symbol einen Aufruf `.signal("<name>", …)` (bzw. error/log)
|
|
5
|
+
enthalten. Ein Lockfile (`everydeck-manifest.lock.json`) hält die Zahl der Aufrufstellen je (Signal, Symbol) fest;
|
|
6
|
+
sinkt sie, ist das Drift (P1-Grenze O11). Neuer Commit ≠ Drift, nur eine neue Revision.
|
|
7
|
+
- publish: POST /api/projects/<id>/manifests mit Publish-Token; Netzausfall von everydeck darf den Build nicht brechen
|
|
8
|
+
(Exit 0 mit Warnung, Artefakt bleibt).
|
|
9
|
+
- change: `everydeck-change.json` (B9, Modul `change`): Form, Fähigkeits-/Datei-/Symbolreferenzen, Bindung an HEAD und Manifest-Digest;
|
|
10
|
+
optional POST /api/projects/<id>/changes. Die Beschreibung stammt vom Coding-Agenten, everydeck-check prüft nur mechanisch.
|
|
11
|
+
Python-Symbole (Funktionen, Klassen, Methoden) werden per AST geprüft; andere Sprachen sind nicht unterstützt.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from importlib import resources
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
26
|
+
|
|
27
|
+
__all__ = ["validate_manifest", "reference_errors", "digest", "check_drift", "freeze_lock", "DriftReport", "Finding", "split_proposal", "reverses_of", "every_ok", "iso_duration_seconds"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def split_proposal(doc: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
31
|
+
"""B20: Der Vorschlag ist `{ "manifest": …, "local_check": … }` (genau der Body von POST …/proposals); ein nacktes Manifest bleibt erlaubt."""
|
|
32
|
+
if isinstance(doc, dict) and isinstance(doc.get("manifest"), dict) and "schema_version" not in doc:
|
|
33
|
+
lc = doc.get("local_check")
|
|
34
|
+
return doc["manifest"], lc if isinstance(lc, dict) else None
|
|
35
|
+
return doc, None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def coverage_of(doc: dict[str, Any]) -> Any:
|
|
39
|
+
"""B22: `coverage[]` des Vorschlags (None bei nacktem Manifest oder ohne Tabelle)."""
|
|
40
|
+
if isinstance(doc, dict) and isinstance(doc.get("manifest"), dict) and "schema_version" not in doc:
|
|
41
|
+
return doc.get("coverage")
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def reverses_of(action: dict[str, Any]) -> list[str]:
|
|
46
|
+
"""B.reverses = ["A"]: B macht A rückgängig; Einzelwert (bis 1.8-r0) oder Liste (ab 1.9-r0)."""
|
|
47
|
+
r = action.get("reverses")
|
|
48
|
+
return [] if r is None else list(r) if isinstance(r, list) else [r]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def every_ok(every: str) -> bool:
|
|
52
|
+
"""Takt eines Jobs im Manifest: Dauer (15m, 1h, 1d) oder Cron mit fünf Feldern. B32: dieselbe Regel wie everydeck (jobPeriodSeconds aus
|
|
53
|
+
packages/contracts über everydeck_check/rules/everydeck-rules.mjs), kein Nachbau in Python."""
|
|
54
|
+
from .rules import run_rule
|
|
55
|
+
return run_rule("cadence", {"expressions": [str(every)]})["results"][0]["manifest_period_seconds"] is not None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_ISO_DURATION = re.compile(r"^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def iso_duration_seconds(d: Any) -> int | None:
|
|
62
|
+
"""ISO-8601-Dauer (Wochen, Tage, Stunden, Minuten) in Sekunden, wie `isoDurationSeconds` in packages/contracts; ungültig oder 0 → None."""
|
|
63
|
+
if not isinstance(d, str) or d in ("P", "PT"):
|
|
64
|
+
return None
|
|
65
|
+
m = _ISO_DURATION.match(d)
|
|
66
|
+
if not m:
|
|
67
|
+
return None
|
|
68
|
+
w, dd, h, mi = (int(x or 0) for x in m.groups())
|
|
69
|
+
s = w * 604800 + dd * 86400 + h * 3600 + mi * 60
|
|
70
|
+
return s if s > 0 else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _schema() -> dict[str, Any]:
|
|
74
|
+
return json.loads(resources.files("everydeck_check").joinpath("schemas/manifest.schema.json").read_text(encoding="utf-8"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# B15: Adapter-Merkmale, die keine Ablauf-ID sind (server_errors = zentraler Serverfehlerpfad angebunden)
|
|
78
|
+
ADAPTER_FEATURES = ("server_errors", "browser_actions", "messages") # B22: messages = Nachrichten-Eckdaten (everydeck.messageSent)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_manifest(manifest: dict[str, Any]) -> list[str]:
|
|
82
|
+
v = Draft202012Validator(_schema(), format_checker=FormatChecker())
|
|
83
|
+
return [f"{'/'.join(str(p) for p in e.path) or '$'}: {e.message}" for e in v.iter_errors(manifest)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
LOCKED_ROLES = ("ref", "person", "limit", "amount")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def field_editable(o: dict[str, Any], f: dict[str, Any]) -> bool:
|
|
90
|
+
"""Wirksame Bearbeitbarkeit (Spiegel von contracts fieldEdit): Standard ja, sobald die Objektart einen Verwaltungssatz hat; gesperrt durch Rolle, managed_in oder den Agenten."""
|
|
91
|
+
if f.get("role") in LOCKED_ROLES or f.get("managed_in") or not o.get("manage"):
|
|
92
|
+
return False
|
|
93
|
+
ed = f.get("edit")
|
|
94
|
+
return not (ed and not ed.get("editable"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def description_path_errors(m: Any) -> list[str]:
|
|
98
|
+
"""B35 Punkt 4: Beschreibungstexte, die eine Datei nennen, die das Manifest als Fundstelle führt – dieselbe Regel wie descriptionPathErrors (TS)."""
|
|
99
|
+
import re as _re
|
|
100
|
+
paths: list[str] = []; texts: list[tuple[str, str]] = []
|
|
101
|
+
|
|
102
|
+
def walk(x: Any, at: str) -> None:
|
|
103
|
+
if isinstance(x, list):
|
|
104
|
+
for i, y in enumerate(x): walk(y, f"{at}[{i}]")
|
|
105
|
+
elif isinstance(x, dict):
|
|
106
|
+
for k, v in x.items():
|
|
107
|
+
here = f"{at}.{k}" if at else k
|
|
108
|
+
if k == "path" and isinstance(v, str) and v != "database" and len(v) > 3:
|
|
109
|
+
if v not in paths: paths.append(v)
|
|
110
|
+
elif k == "description" and isinstance(v, str): texts.append((here, v))
|
|
111
|
+
else: walk(v, here)
|
|
112
|
+
walk(m, "")
|
|
113
|
+
errs: list[str] = []
|
|
114
|
+
for at, text in texts:
|
|
115
|
+
for p in paths:
|
|
116
|
+
i = text.find(p)
|
|
117
|
+
if i >= 0 and not _re.match(r"[\w./-]", text[i - 1] if i > 0 else "") and not _re.match(r"[\w-]", text[i + len(p)] if i + len(p) < len(text) else ""):
|
|
118
|
+
errs.append(f"{at} names the file {p} – places belong in evidence"); break
|
|
119
|
+
return errs
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def reference_errors(m: dict[str, Any]) -> list[str]:
|
|
123
|
+
errs: list[str] = []
|
|
124
|
+
caps = [c["id"] for c in m.get("capabilities", [])]
|
|
125
|
+
if len(caps) != len(set(caps)):
|
|
126
|
+
errs.append("duplicate capability id")
|
|
127
|
+
sig = {}
|
|
128
|
+
for s in m.get("signals", []):
|
|
129
|
+
if s["name"] in sig:
|
|
130
|
+
errs.append(f"duplicate signal {s['name']}")
|
|
131
|
+
if s["capability_id"] not in caps:
|
|
132
|
+
errs.append(f"signal {s['name']}: unknown capability {s['capability_id']}")
|
|
133
|
+
sig[s["name"]] = s["capability_id"]
|
|
134
|
+
rules = set()
|
|
135
|
+
for r in m.get("rules", []):
|
|
136
|
+
if r["id"] in rules:
|
|
137
|
+
errs.append(f"duplicate rule {r['id']}")
|
|
138
|
+
rules.add(r["id"])
|
|
139
|
+
if r["capability_id"] not in caps:
|
|
140
|
+
errs.append(f"rule {r['id']}: unknown capability {r['capability_id']}")
|
|
141
|
+
if r["trigger"] == r["expect"]:
|
|
142
|
+
errs.append(f"rule {r['id']}: trigger equals expect")
|
|
143
|
+
for name in (r["trigger"], r["expect"]):
|
|
144
|
+
if name not in sig:
|
|
145
|
+
errs.append(f"rule {r['id']}: unknown signal {name}")
|
|
146
|
+
elif sig[name] != r["capability_id"]:
|
|
147
|
+
errs.append(f"rule {r['id']}: signal {name} belongs to {sig[name]}")
|
|
148
|
+
# 1.1-r0: Schritte und Nachfolger (nur innerhalb desselben Ablaufs), Bausteine (Abläufe und Abhängigkeiten auflösbar) – wie packages/contracts
|
|
149
|
+
for c in m.get("capabilities", []):
|
|
150
|
+
steps = c.get("steps") or []
|
|
151
|
+
ids = [st["id"] for st in steps]
|
|
152
|
+
if len(ids) != len(set(ids)):
|
|
153
|
+
errs.append(f"capability {c['id']}: duplicate step id")
|
|
154
|
+
used: set[str] = set()
|
|
155
|
+
for st in steps:
|
|
156
|
+
sname = st.get("signal")
|
|
157
|
+
if sname is not None:
|
|
158
|
+
if sname not in sig:
|
|
159
|
+
errs.append(f"capability {c['id']}: step {st['id']} references unknown signal {sname}")
|
|
160
|
+
elif sig[sname] != c["id"]:
|
|
161
|
+
errs.append(f"capability {c['id']}: step {st['id']} references signal {sname} of {sig[sname]}")
|
|
162
|
+
if sname in used:
|
|
163
|
+
errs.append(f"capability {c['id']}: signal {sname} used by two steps")
|
|
164
|
+
used.add(sname)
|
|
165
|
+
for n in st.get("next") or []:
|
|
166
|
+
if n == st["id"]:
|
|
167
|
+
errs.append(f"capability {c['id']}: step {st['id']} points to itself")
|
|
168
|
+
elif n not in ids:
|
|
169
|
+
errs.append(f"capability {c['id']}: step {st['id']} references unknown next step {n}")
|
|
170
|
+
comps = [k["id"] for k in m.get("components") or []]
|
|
171
|
+
if len(comps) != len(set(comps)):
|
|
172
|
+
errs.append("duplicate component id")
|
|
173
|
+
for k in m.get("components") or []:
|
|
174
|
+
for cid in k.get("capabilities") or []:
|
|
175
|
+
if cid not in caps:
|
|
176
|
+
errs.append(f"component {k['id']}: unknown capability {cid}")
|
|
177
|
+
for d in k.get("depends_on") or []:
|
|
178
|
+
if d == k["id"]:
|
|
179
|
+
errs.append(f"component {k['id']} depends on itself")
|
|
180
|
+
elif d not in comps:
|
|
181
|
+
errs.append(f"component {k['id']}: unknown dependency {d}")
|
|
182
|
+
if m.get("schema_version") == "1.0-r0" and (m.get("people") or m.get("app") or m.get("components") or any(c.get("steps") or c.get("description") for c in m.get("capabilities", []))):
|
|
183
|
+
errs.append("1.1-r0 fields (app, people, components, description, steps) require schema_version 1.1-r0")
|
|
184
|
+
# 1.2-r0 (B11): Objekttypen und Adapter eindeutig, Adapter nur auf bekannte Abläufe, Version passend
|
|
185
|
+
seen_obj: set[str] = set()
|
|
186
|
+
for o in m.get("objects", []) or []:
|
|
187
|
+
if o["type"] in seen_obj:
|
|
188
|
+
errs.append(f"duplicate object type {o['type']}")
|
|
189
|
+
seen_obj.add(o["type"])
|
|
190
|
+
seen_status: set[str] = set()
|
|
191
|
+
for st in o.get("statuses", []):
|
|
192
|
+
if st["value"] in seen_status:
|
|
193
|
+
errs.append(f"object {o['type']}: duplicate status {st['value']}")
|
|
194
|
+
seen_status.add(st["value"])
|
|
195
|
+
seen_ad: set[str] = set()
|
|
196
|
+
for a in m.get("adapters", []) or []:
|
|
197
|
+
if a["id"] in seen_ad:
|
|
198
|
+
errs.append(f"duplicate adapter {a['id']}")
|
|
199
|
+
seen_ad.add(a["id"])
|
|
200
|
+
for cid in a.get("capabilities", []) or []:
|
|
201
|
+
if cid not in caps and cid not in ADAPTER_FEATURES:
|
|
202
|
+
errs.append(f"adapter {a['id']}: unknown capability {cid}")
|
|
203
|
+
versions = list(_schema()["properties"]["schema_version"]["enum"]) # eine Quelle: das ausgelieferte Schema (B20: vorher fehlten 1.8/1.9)
|
|
204
|
+
def at_least(v: str) -> bool:
|
|
205
|
+
have = m.get("schema_version")
|
|
206
|
+
return have in versions and versions.index(have) >= versions.index(v)
|
|
207
|
+
if m.get("objects") and not at_least("1.2-r0"):
|
|
208
|
+
errs.append("objects require schema_version 1.2-r0")
|
|
209
|
+
if m.get("adapters") and not at_least("1.2-r0"):
|
|
210
|
+
errs.append("adapters require schema_version 1.2-r0")
|
|
211
|
+
if (m.get("people") or {}).get("source") == "read_bridge" and not at_least("1.2-r0"):
|
|
212
|
+
errs.append("people.source read_bridge requires schema_version 1.2-r0")
|
|
213
|
+
# 1.4-r0 (B13): Auswertungen und Aktionen nur auf deklarierte Objekte, Felder, Status, Abläufe (Spiegel von contracts)
|
|
214
|
+
objs = {o.get("type"): o for o in m.get("objects", []) or []}
|
|
215
|
+
cap_ids = {c.get("id") for c in m.get("capabilities", [])}
|
|
216
|
+
def field_kind(o, f):
|
|
217
|
+
if f == "status":
|
|
218
|
+
return "status"
|
|
219
|
+
return next((x.get("kind") for x in o.get("fields", []) or [] if x.get("name") == f), None)
|
|
220
|
+
seen_an = set()
|
|
221
|
+
for a in m.get("analytics", []) or []:
|
|
222
|
+
aid = a.get("id")
|
|
223
|
+
if aid in seen_an:
|
|
224
|
+
errs.append(f"duplicate analytic {aid}")
|
|
225
|
+
seen_an.add(aid)
|
|
226
|
+
kind, src = a.get("kind"), a.get("source", {}) or {}
|
|
227
|
+
if kind in ("inventory_count", "inventory_breakdown"):
|
|
228
|
+
o = objs.get(src.get("object_type"))
|
|
229
|
+
if not o:
|
|
230
|
+
errs.append(f"analytic {aid}: unknown object type {src.get('object_type') or '(none)'}")
|
|
231
|
+
continue
|
|
232
|
+
for fl in a.get("filter", []) or []:
|
|
233
|
+
k = field_kind(o, fl.get("field"))
|
|
234
|
+
if not k:
|
|
235
|
+
errs.append(f"analytic {aid}: unknown field {fl.get('field')}")
|
|
236
|
+
continue
|
|
237
|
+
if fl.get("op") in ("is_true", "is_false") and k != "boolean":
|
|
238
|
+
errs.append(f"analytic {aid}: {fl.get('op')} needs a boolean field ({fl.get('field')})")
|
|
239
|
+
if k == "status":
|
|
240
|
+
vals = fl.get("value") if isinstance(fl.get("value"), list) else [fl.get("value")]
|
|
241
|
+
for v in vals:
|
|
242
|
+
if not any(x.get("value") == v for x in o.get("statuses", [])):
|
|
243
|
+
errs.append(f"analytic {aid}: unknown status {v}")
|
|
244
|
+
if kind == "inventory_breakdown" and (not a.get("group_by") or not field_kind(o, a.get("group_by")) or field_kind(o, a.get("group_by")) == "date"):
|
|
245
|
+
errs.append(f"analytic {aid}: group_by must be status or a declared non-date field")
|
|
246
|
+
if a.get("created_field") and field_kind(o, a.get("created_field")) != "date":
|
|
247
|
+
errs.append(f"analytic {aid}: created_field must be a declared date field")
|
|
248
|
+
else:
|
|
249
|
+
if kind == "run_count" and src.get("capability_id") not in cap_ids:
|
|
250
|
+
errs.append(f"analytic {aid}: unknown capability {src.get('capability_id') or '(none)'}")
|
|
251
|
+
if kind == "people_count" and src.get("with") == "errors" and (a.get("window_days") or 7) > 7:
|
|
252
|
+
errs.append(f"analytic {aid}: error events are kept 7 days; window_days must be <= 7")
|
|
253
|
+
if not a.get("window_days"):
|
|
254
|
+
errs.append(f"analytic {aid}: {kind} needs window_days")
|
|
255
|
+
ac_ids = {x.get("id") for x in m.get("actions", []) or []}
|
|
256
|
+
for x in m.get("actions", []) or []:
|
|
257
|
+
t = x.get("target", {}) or {}
|
|
258
|
+
if t.get("kind") == "object":
|
|
259
|
+
o = objs.get(t.get("object_type"))
|
|
260
|
+
if not o:
|
|
261
|
+
errs.append(f"action {x.get('id')}: unknown object type {t.get('object_type')}")
|
|
262
|
+
continue
|
|
263
|
+
if not str(x.get("capability", "")).startswith(f"object.{t.get('object_type')}."):
|
|
264
|
+
errs.append(f"action {x.get('id')}: capability must be object.{t.get('object_type')}.<name>")
|
|
265
|
+
for st in (x.get("from_statuses") or []) + ([x["to_status"]] if x.get("to_status") else []):
|
|
266
|
+
if not any(v.get("value") == st for v in o.get("statuses", [])):
|
|
267
|
+
errs.append(f"action {x.get('id')}: unknown status {st}")
|
|
268
|
+
else:
|
|
269
|
+
cap = str(x.get("capability", ""))
|
|
270
|
+
if not cap.startswith("access.") and not cap.startswith("person."):
|
|
271
|
+
errs.append(f"action {x.get('id')}: person actions use access.* or person.* capabilities")
|
|
272
|
+
if cap.startswith("person.") and not at_least("1.9-r0"):
|
|
273
|
+
errs.append(f"action {x.get('id')}: person.* actions require schema_version 1.9-r0")
|
|
274
|
+
if x.get("from_statuses") or x.get("to_status"):
|
|
275
|
+
errs.append(f"action {x.get('id')}: person actions have no object statuses")
|
|
276
|
+
for r in reverses_of(x):
|
|
277
|
+
if r not in ac_ids:
|
|
278
|
+
errs.append(f"action {x.get('id')}: reverses unknown action {r}")
|
|
279
|
+
elif r == x.get("id"):
|
|
280
|
+
errs.append(f"action {x.get('id')}: reverses itself")
|
|
281
|
+
if isinstance(x.get("reverses"), list) and not at_least("1.9-r0"):
|
|
282
|
+
errs.append(f"action {x.get('id')}: reverses as a list requires schema_version 1.9-r0")
|
|
283
|
+
if (m.get("analytics") or m.get("actions")) and not at_least("1.4-r0"):
|
|
284
|
+
errs.append("analytics/actions require schema_version 1.4-r0")
|
|
285
|
+
# 1.5-r0 (B15): belegte Statusgruppe
|
|
286
|
+
if any(st.get("lifecycle") for o in m.get("objects", []) or [] for st in o.get("statuses", []) or []) and not at_least("1.5-r0"):
|
|
287
|
+
errs.append("status lifecycle requires schema_version 1.5-r0")
|
|
288
|
+
# 1.6-r0 (B16): Darstellungshinweis für das Zeichen der Objektart
|
|
289
|
+
if any(o.get("icon") for o in m.get("objects", []) or []) and not at_least("1.6-r0"):
|
|
290
|
+
errs.append("object icon requires schema_version 1.6-r0")
|
|
291
|
+
# 1.7-r0 (B16 Schnitt B): Bedienstufe und Rückgängig-Fenster von Aktionen
|
|
292
|
+
actions = m.get("actions", []) or []
|
|
293
|
+
if any(a.get("risk") or "undo_seconds" in a for a in actions) and not at_least("1.7-r0"):
|
|
294
|
+
errs.append("action risk/undo_seconds require schema_version 1.7-r0")
|
|
295
|
+
for a in actions:
|
|
296
|
+
if "undo_seconds" in a and (a.get("risk") or "amber") != "amber":
|
|
297
|
+
errs.append(f"action {a.get('id')}: undo_seconds only applies to amber actions")
|
|
298
|
+
# 1.9-r0 (B20): Jobs – eindeutige Kennung, lesbarer Takt, bekannte Komponente (Spiegel von contracts)
|
|
299
|
+
jobs = m.get("jobs", []) or []
|
|
300
|
+
if jobs and not at_least("1.9-r0"):
|
|
301
|
+
errs.append("jobs require schema_version 1.9-r0")
|
|
302
|
+
comp_ids = {c.get("id") for c in m.get("components", []) or []}
|
|
303
|
+
seen_jobs: set[str] = set()
|
|
304
|
+
for j in jobs:
|
|
305
|
+
if j.get("id") in seen_jobs:
|
|
306
|
+
errs.append(f"duplicate job {j.get('id')}")
|
|
307
|
+
seen_jobs.add(j.get("id"))
|
|
308
|
+
try: readable = every_ok(str(j.get("every", "")))
|
|
309
|
+
except RuntimeError as e: errs.append(f"job {j.get('id')}: everydeck rules: {e}"); continue
|
|
310
|
+
if not readable:
|
|
311
|
+
errs.append(f"job {j.get('id')}: every \"{j.get('every')}\" is neither a duration (15m, 1h, 1d) nor a five-field cron expression")
|
|
312
|
+
if j.get("component") and j.get("component") not in comp_ids:
|
|
313
|
+
errs.append(f"job {j.get('id')}: unknown component {j.get('component')}")
|
|
314
|
+
# 1.9-r0 (B20 Schnitt B): mehr Objektarten, Meldegründe, Personenaktionen, lange Abläufe, Browser-Fehlschläge (Spiegel von contracts)
|
|
315
|
+
objects = m.get("objects", []) or []
|
|
316
|
+
if len(objects) > 10 and not at_least("1.9-r0"):
|
|
317
|
+
errs.append("more than 10 object types require schema_version 1.9-r0")
|
|
318
|
+
if any(str(a.get("capability", "")).startswith("person.") for a in actions) and any(o.get("type") == "person" for o in objects):
|
|
319
|
+
errs.append('object type "person" is reserved when person.* actions are declared')
|
|
320
|
+
# 1.12-r0 (B29 Abnahme): keine Grenze von 20 Objektarten / 24 Feldern mehr (Schutz 200 / 100 im Schema); Verknüpfungstabellen als relations[] (Spiegel von contracts)
|
|
321
|
+
if len(objects) > 20 and not at_least("1.12-r0"):
|
|
322
|
+
errs.append("more than 20 object types require schema_version 1.12-r0")
|
|
323
|
+
for o in objects:
|
|
324
|
+
if len(o.get("fields", []) or []) > 24 and not at_least("1.12-r0"):
|
|
325
|
+
errs.append(f"object {o['type']}: more than 24 fields require schema_version 1.12-r0")
|
|
326
|
+
relations = m.get("relations", []) or []
|
|
327
|
+
if relations and not at_least("1.12-r0"):
|
|
328
|
+
errs.append("relations require schema_version 1.12-r0")
|
|
329
|
+
if any(o.get("technical") for o in objects) and not at_least("1.12-r0"):
|
|
330
|
+
errs.append("object technical requires schema_version 1.12-r0")
|
|
331
|
+
seen_rel: set[str] = set()
|
|
332
|
+
for r in relations:
|
|
333
|
+
rid = r.get("id")
|
|
334
|
+
if rid in seen_rel:
|
|
335
|
+
errs.append(f"duplicate relation {rid}")
|
|
336
|
+
seen_rel.add(rid)
|
|
337
|
+
if rid in objs:
|
|
338
|
+
errs.append(f"relation {rid}: id collides with an object type")
|
|
339
|
+
sides = r.get("sides", []) or []
|
|
340
|
+
if len(sides) == 2 and sides[0].get("field") == sides[1].get("field"):
|
|
341
|
+
errs.append(f"relation {rid}: both sides name the same field")
|
|
342
|
+
for sd in sides:
|
|
343
|
+
if sd.get("object_type") == "person":
|
|
344
|
+
if not m.get("people"):
|
|
345
|
+
errs.append(f"relation {rid}: side {sd.get('field')} names person, but the manifest has no people block")
|
|
346
|
+
elif sd.get("object_type") not in objs:
|
|
347
|
+
errs.append(f"relation {rid}: side {sd.get('field')} names unknown object type {sd.get('object_type')}")
|
|
348
|
+
for o in objects:
|
|
349
|
+
for f in o.get("fields", []) or []:
|
|
350
|
+
reasons = f.get("reasons")
|
|
351
|
+
if reasons is not None:
|
|
352
|
+
if f.get("role") != "moderation.signal":
|
|
353
|
+
errs.append(f"object {o['type']}: field {f['name']} has reasons without role moderation.signal")
|
|
354
|
+
if not at_least("1.9-r0"):
|
|
355
|
+
errs.append(f"object {o['type']}: field {f['name']} reasons require schema_version 1.9-r0")
|
|
356
|
+
values = [r.get("value") for r in reasons]
|
|
357
|
+
if len(values) != len(set(values)):
|
|
358
|
+
errs.append(f"object {o['type']}: field {f['name']} has duplicate reason values")
|
|
359
|
+
if any(g not in ("duplicate", "fraud", "other") for g in (f.get("signals") or {}).values()) and not at_least("1.9-r0"):
|
|
360
|
+
errs.append(f"object {o['type']}: field {f['name']} signal groups beyond duplicate/fraud/other require schema_version 1.9-r0")
|
|
361
|
+
browser_errors: set[str] = set()
|
|
362
|
+
for c in m.get("capabilities", []):
|
|
363
|
+
if "expected_duration" in c:
|
|
364
|
+
if not at_least("1.9-r0"):
|
|
365
|
+
errs.append(f"capability {c['id']}: expected_duration requires schema_version 1.9-r0")
|
|
366
|
+
if iso_duration_seconds(c.get("expected_duration")) is None:
|
|
367
|
+
errs.append(f"capability {c['id']}: expected_duration {c.get('expected_duration')} is not an ISO 8601 duration")
|
|
368
|
+
for st in c.get("steps", []) or []:
|
|
369
|
+
be = st.get("browser_error")
|
|
370
|
+
if be is None:
|
|
371
|
+
continue
|
|
372
|
+
if not at_least("1.9-r0"):
|
|
373
|
+
errs.append(f"capability {c['id']}: step {st['id']} browser_error requires schema_version 1.9-r0")
|
|
374
|
+
if st.get("role") != "failure" or st.get("signal"):
|
|
375
|
+
errs.append(f"capability {c['id']}: browser_error only on a failure step without a server signal ({st['id']})")
|
|
376
|
+
if be in browser_errors:
|
|
377
|
+
errs.append(f"browser_error {be} used by two steps")
|
|
378
|
+
browser_errors.add(be)
|
|
379
|
+
# 1.11-r0 (B22 Schnitt C): Feldtypen, Regeln, Bearbeitbarkeit, Verwaltungssatz (Spiegel von contracts)
|
|
380
|
+
v111 = at_least("1.11-r0")
|
|
381
|
+
locked_roles = ("ref", "person", "limit", "amount")
|
|
382
|
+
for o in objects:
|
|
383
|
+
field_names = {f.get("name") for f in o.get("fields", []) or []}
|
|
384
|
+
status_values = {st.get("value") for st in o.get("statuses", []) or []}
|
|
385
|
+
editable = {f.get("name") for f in o.get("fields", []) or [] if field_editable(o, f)} # Standard bearbeitbar; gesperrt durch Rolle, managed_in, fehlenden Satz oder den Agenten
|
|
386
|
+
for f in o.get("fields", []) or []:
|
|
387
|
+
at = f"object {o.get('type')}: field {f.get('name')}"
|
|
388
|
+
kind = f.get("kind")
|
|
389
|
+
if kind not in ("string", "number", "boolean", "date") and not v111:
|
|
390
|
+
errs.append(f"{at}: kind {kind} requires schema_version 1.11-r0")
|
|
391
|
+
if kind == "enum" and not f.get("values"):
|
|
392
|
+
errs.append(f"{at}: kind enum needs values from the code")
|
|
393
|
+
if kind != "enum" and f.get("values") is not None:
|
|
394
|
+
errs.append(f"{at}: values only with kind enum")
|
|
395
|
+
if f.get("values") and len({v.get("value") for v in f["values"]}) != len(f["values"]):
|
|
396
|
+
errs.append(f"{at}: duplicate enum value")
|
|
397
|
+
if kind == "money" and not f.get("currency") and not f.get("currency_field"):
|
|
398
|
+
errs.append(f"{at}: kind money needs currency (ISO 4217) or currency_field")
|
|
399
|
+
if kind != "money" and (f.get("currency") or f.get("currency_field")):
|
|
400
|
+
errs.append(f"{at}: currency only with kind money")
|
|
401
|
+
if f.get("currency") and f.get("currency_field"):
|
|
402
|
+
errs.append(f"{at}: currency or currency_field, not both")
|
|
403
|
+
if f.get("currency_field") and f["currency_field"] not in field_names:
|
|
404
|
+
errs.append(f"{at}: currency_field {f['currency_field']} is not a declared field")
|
|
405
|
+
if (f.get("edit") or f.get("managed_in")) and not v111:
|
|
406
|
+
errs.append(f"{at}: edit/managed_in require schema_version 1.11-r0")
|
|
407
|
+
ed = f.get("edit") or {}
|
|
408
|
+
if ed.get("editable") and f.get("role") in locked_roles:
|
|
409
|
+
errs.append(f"{at}: a field with role {f.get('role')} is never editable (identifiers, references, money outside an app function)")
|
|
410
|
+
if ed.get("editable") and f.get("managed_in"):
|
|
411
|
+
errs.append(f"{at}: managed_in and editable exclude each other")
|
|
412
|
+
if ed.get("editable") and not o.get("manage"):
|
|
413
|
+
errs.append(f"{at}: editable needs objects[].manage (the door the change goes through)")
|
|
414
|
+
if ed and not ed.get("editable") and not ed.get("reason"):
|
|
415
|
+
errs.append(f"{at}: locking a field (editable: false) needs a reason")
|
|
416
|
+
mg = o.get("manage")
|
|
417
|
+
if not mg:
|
|
418
|
+
continue
|
|
419
|
+
if not v111:
|
|
420
|
+
errs.append(f"object {o.get('type')}: manage requires schema_version 1.11-r0")
|
|
421
|
+
|
|
422
|
+
def names(what: str, lst, allowed: set, noun: str) -> None:
|
|
423
|
+
for n in lst or []:
|
|
424
|
+
if n not in allowed:
|
|
425
|
+
errs.append(f"object {o.get('type')}: manage.{what} names {noun} {n} that is not declared")
|
|
426
|
+
names("create.fields", (mg.get("create") or {}).get("fields"), field_names, "field")
|
|
427
|
+
names("bulk.fields", (mg.get("bulk") or {}).get("fields"), editable, "editable field")
|
|
428
|
+
names("bulk.statuses", (mg.get("bulk") or {}).get("statuses"), status_values, "status")
|
|
429
|
+
names("export.fields", (mg.get("export") or {}).get("fields"), field_names, "field")
|
|
430
|
+
names("import.fields", (mg.get("import") or {}).get("fields"), field_names, "field")
|
|
431
|
+
if mg.get("archive") and mg["archive"].get("to_status") not in status_values:
|
|
432
|
+
errs.append(f"object {o.get('type')}: manage.archive.to_status {mg['archive'].get('to_status')} is not a declared status")
|
|
433
|
+
if mg.get("bulk") and not mg["bulk"].get("fields") and not mg["bulk"].get("statuses"):
|
|
434
|
+
errs.append(f"object {o.get('type')}: manage.bulk needs fields or statuses it may set")
|
|
435
|
+
if mg.get("create") and not editable and not mg["create"].get("fields"):
|
|
436
|
+
errs.append(f"object {o.get('type')}: manage.create needs editable fields or create.fields")
|
|
437
|
+
for k, op in mg.items():
|
|
438
|
+
if k != "door" and isinstance(op, dict) and (op.get("door") or {}).get("kind") == "built" and not op["door"].get("test"):
|
|
439
|
+
errs.append(f"object {o.get('type')}: manage.{k} door built needs a test in the app")
|
|
440
|
+
if (mg.get("door") or {}).get("kind") == "built" and not mg["door"].get("test"):
|
|
441
|
+
errs.append(f"object {o.get('type')}: manage.door built needs a test in the app (test.path)")
|
|
442
|
+
pm = (m.get("people") or {}).get("manage")
|
|
443
|
+
if pm:
|
|
444
|
+
if not v111:
|
|
445
|
+
errs.append("people.manage requires schema_version 1.11-r0")
|
|
446
|
+
pfields = (m.get("people") or {}).get("fields") or []
|
|
447
|
+
efields = (pm.get("edit") or {}).get("fields") or []
|
|
448
|
+
for f in efields:
|
|
449
|
+
if f not in pfields:
|
|
450
|
+
errs.append(f"people.manage.edit names field {f} that is not declared")
|
|
451
|
+
if f in ("provider_user_id", "email", "login_method"):
|
|
452
|
+
errs.append(f"people.manage.edit: {f} is login data – never editable directly (email only through support.change_email)")
|
|
453
|
+
for f in (pm.get("bulk") or {}).get("fields") or []:
|
|
454
|
+
if f not in efields:
|
|
455
|
+
errs.append(f"people.manage.bulk names field {f} that is not editable")
|
|
456
|
+
if (pm.get("door") or {}).get("kind") == "built" and not pm["door"].get("test"):
|
|
457
|
+
errs.append("people.manage.door built needs a test in the app (test.path)")
|
|
458
|
+
if pm.get("support") and (m.get("people") or {}).get("provider") != "supabase_auth" and not (m.get("people") or {}).get("note"):
|
|
459
|
+
errs.append("people.manage.support needs provider supabase_auth or a note naming the login provider that offers these actions")
|
|
460
|
+
# 1.10-r0 (B23): Plattformen eindeutig; Abläufe und Aktionen nennen nur deklarierte Plattformen (Spiegel von contracts)
|
|
461
|
+
platforms = m.get("platforms", []) or []
|
|
462
|
+
declared_pl = {p.get("id") for p in platforms}
|
|
463
|
+
if platforms and not at_least("1.10-r0"):
|
|
464
|
+
errs.append("platforms require schema_version 1.10-r0")
|
|
465
|
+
if len(declared_pl) != len(platforms):
|
|
466
|
+
errs.append("duplicate platform")
|
|
467
|
+
for p in platforms:
|
|
468
|
+
if p.get("store_status") and p.get("id") not in ("ios", "android", "desktop", "extension"):
|
|
469
|
+
errs.append(f"platform {p.get('id')}: store_status only for app stores (ios, android, desktop, extension)")
|
|
470
|
+
for what, lst in [(f"capability {c.get('id')}", c.get("platforms")) for c in m.get("capabilities", [])] + [(f"action {a.get('id')}", a.get("platforms")) for a in actions]:
|
|
471
|
+
if lst is None:
|
|
472
|
+
continue
|
|
473
|
+
if not at_least("1.10-r0"):
|
|
474
|
+
errs.append(f"{what}: platforms require schema_version 1.10-r0")
|
|
475
|
+
for pl in lst:
|
|
476
|
+
if pl not in declared_pl:
|
|
477
|
+
errs.append(f"{what}: platform {pl} is not declared in platforms")
|
|
478
|
+
# 1.10-r0 (B23): API-Muster eindeutig je Methode, Parameter je Muster eindeutig (Spiegel von contracts)
|
|
479
|
+
api_routes = m.get("api_routes", []) or []
|
|
480
|
+
if api_routes and not at_least("1.10-r0"):
|
|
481
|
+
errs.append("api_routes require schema_version 1.10-r0")
|
|
482
|
+
seen_api: set[str] = set()
|
|
483
|
+
for r in api_routes:
|
|
484
|
+
k = f"{r.get('method') or '*'} {r.get('pattern', '')}"
|
|
485
|
+
if k in seen_api:
|
|
486
|
+
errs.append(f"duplicate api route {k}")
|
|
487
|
+
seen_api.add(k)
|
|
488
|
+
params = [x for x in r.get("pattern", "").split("/") if x.startswith(":")]
|
|
489
|
+
if len(set(params)) != len(params):
|
|
490
|
+
errs.append(f"api route {r.get('pattern')}: a parameter name appears twice")
|
|
491
|
+
# 1.10-r0 (B23): Seitenmuster eindeutig, Parameter je Muster eindeutig, Plattformen deklariert (Spiegel von contracts)
|
|
492
|
+
screens = m.get("screens", []) or []
|
|
493
|
+
if screens and not at_least("1.10-r0"):
|
|
494
|
+
errs.append("screens require schema_version 1.10-r0")
|
|
495
|
+
seen_screens: set[str] = set()
|
|
496
|
+
for sc in screens:
|
|
497
|
+
pat = sc.get("pattern", "")
|
|
498
|
+
if pat in seen_screens:
|
|
499
|
+
errs.append(f"duplicate screen {pat}")
|
|
500
|
+
seen_screens.add(pat)
|
|
501
|
+
params = [x for x in pat.split("/") if x.startswith(":")]
|
|
502
|
+
if len(set(params)) != len(params):
|
|
503
|
+
errs.append(f"screen {pat}: a parameter name appears twice")
|
|
504
|
+
for pl in sc.get("platforms", []) or []:
|
|
505
|
+
if pl not in declared_pl:
|
|
506
|
+
errs.append(f"screen {pat}: platform {pl} is not declared in platforms")
|
|
507
|
+
# 1.3-r0 (O30): deklarierter Alternativausgang – braucht ein Serversignal
|
|
508
|
+
for c in m.get("capabilities", []):
|
|
509
|
+
for st in c.get("steps", []) or []:
|
|
510
|
+
if st.get("role") != "alternative":
|
|
511
|
+
continue
|
|
512
|
+
if not at_least("1.3-r0"):
|
|
513
|
+
errs.append(f"capability {c.get('id')}: step role alternative requires schema_version 1.3-r0")
|
|
514
|
+
if not st.get("signal"):
|
|
515
|
+
errs.append(f"capability {c.get('id')}: alternative step {st.get('id')} needs a server signal")
|
|
516
|
+
errs += description_path_errors(m) # B35 Punkt 4: wie descriptionPathErrors in packages/contracts
|
|
517
|
+
return errs
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def canonical(v: Any) -> str:
|
|
521
|
+
"""Kanonische Form wie `canonicalJson` in packages/contracts: sortierte Schlüssel, keine Leerzeichen, Unicode unverändert
|
|
522
|
+
(JSON.stringify escaped keine Umlaute – `ensure_ascii=False`, sonst weicht der Digest vom Server ab)."""
|
|
523
|
+
if isinstance(v, list):
|
|
524
|
+
return "[" + ",".join(canonical(x) for x in v) + "]"
|
|
525
|
+
if isinstance(v, dict):
|
|
526
|
+
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(v[k]) for k in sorted(v)) + "}"
|
|
527
|
+
return json.dumps(v, ensure_ascii=False)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def digest(manifest: dict[str, Any]) -> str:
|
|
531
|
+
"""SHA-256 über die kanonische JSON-Form; identisch mit `canonicalJson` in packages/contracts."""
|
|
532
|
+
return hashlib.sha256(canonical(manifest).encode("utf-8")).hexdigest()
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@dataclass
|
|
536
|
+
class Finding:
|
|
537
|
+
kind: str # missing_file | missing_symbol | signal_not_in_symbol | call_sites_decreased | new_build | job_not_in_heartbeat | screen_not_in_code | api_route_not_in_code
|
|
538
|
+
signal: str
|
|
539
|
+
where: str
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@dataclass
|
|
543
|
+
class DriftReport:
|
|
544
|
+
head: str | None
|
|
545
|
+
findings: list[Finding] = field(default_factory=list)
|
|
546
|
+
call_sites: dict[str, int] = field(default_factory=dict)
|
|
547
|
+
|
|
548
|
+
@property
|
|
549
|
+
def status(self) -> str:
|
|
550
|
+
if not self.findings:
|
|
551
|
+
return "ok"
|
|
552
|
+
if all(f.kind == "new_build" for f in self.findings):
|
|
553
|
+
return "revision_only"
|
|
554
|
+
return "drift"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _find_symbol(tree: ast.AST, symbol: str) -> ast.AST | None:
|
|
558
|
+
node: ast.AST = tree
|
|
559
|
+
for part in symbol.split("."):
|
|
560
|
+
found = None
|
|
561
|
+
children = ast.walk(node) if node is tree else ast.iter_child_nodes(node)
|
|
562
|
+
for child in children:
|
|
563
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and child.name == part:
|
|
564
|
+
found = child
|
|
565
|
+
break
|
|
566
|
+
if found is None:
|
|
567
|
+
return None
|
|
568
|
+
node = found
|
|
569
|
+
return node
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _symbol_in_text(text: str, symbol: str) -> bool:
|
|
573
|
+
import re as _re
|
|
574
|
+
for part in symbol.split("."):
|
|
575
|
+
if not _re.search(r"(?<![A-Za-z0-9_])" + _re.escape(part) + r"(?![A-Za-z0-9_])", text):
|
|
576
|
+
return False
|
|
577
|
+
return True
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _count_calls(fn: ast.AST, name: str) -> int:
|
|
581
|
+
n = 0
|
|
582
|
+
for x in ast.walk(fn):
|
|
583
|
+
if isinstance(x, ast.Call) and isinstance(x.func, ast.Attribute) and x.func.attr in ("signal", "error", "log") and x.args:
|
|
584
|
+
a = x.args[0]
|
|
585
|
+
if isinstance(a, ast.Constant) and a.value == name:
|
|
586
|
+
n += 1
|
|
587
|
+
return n
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def git_head(repo: Path) -> str | None:
|
|
591
|
+
try:
|
|
592
|
+
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip()
|
|
593
|
+
except Exception:
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def check_drift(repo: Path, manifest: dict[str, Any], lock: dict[str, int] | None = None) -> DriftReport:
|
|
598
|
+
rep = DriftReport(head=git_head(repo))
|
|
599
|
+
for s in manifest.get("signals", []):
|
|
600
|
+
loc = s["location"]
|
|
601
|
+
key = f"{s['name']}@{loc['path']}::{loc['symbol']}"
|
|
602
|
+
f = repo / loc["path"]
|
|
603
|
+
if not f.exists():
|
|
604
|
+
rep.findings.append(Finding("missing_file", s["name"], loc["path"]))
|
|
605
|
+
continue
|
|
606
|
+
if f.suffix != ".py":
|
|
607
|
+
# B11: sprachunabhängige Prüfung für andere Dateien (TypeScript, SQL, Deno): Symbol als Wort vorhanden, Signalname als
|
|
608
|
+
# Literal in der Datei; Aufrufstellen = Vorkommen des Literals. Weniger scharf als der Python-AST-Weg, aber mechanische Drift
|
|
609
|
+
# (Datei weg, Symbol umbenannt, Signal entfernt) wird erkannt.
|
|
610
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
611
|
+
if not _symbol_in_text(text, loc["symbol"]):
|
|
612
|
+
rep.findings.append(Finding("missing_symbol", s["name"], f"{loc['path']}::{loc['symbol']}"))
|
|
613
|
+
continue
|
|
614
|
+
n = text.count(f"'{s['name']}'") + text.count(f'"{s["name"]}"') + text.count(f"`{s['name']}`")
|
|
615
|
+
rep.call_sites[key] = n
|
|
616
|
+
if n == 0:
|
|
617
|
+
rep.findings.append(Finding("signal_not_in_symbol", s["name"], f"{loc['path']}::{loc['symbol']}"))
|
|
618
|
+
continue
|
|
619
|
+
if lock is not None and key in lock and n < lock[key]:
|
|
620
|
+
rep.findings.append(Finding("call_sites_decreased", s["name"], f"{loc['path']}::{loc['symbol']} {lock[key]}→{n}"))
|
|
621
|
+
if rep.head and loc["commit"] != rep.head:
|
|
622
|
+
rep.findings.append(Finding("new_build", s["name"], f"manifest@{loc['commit'][:7]} repo@{rep.head[:7]}"))
|
|
623
|
+
continue
|
|
624
|
+
try:
|
|
625
|
+
tree = ast.parse(f.read_text(encoding="utf-8"))
|
|
626
|
+
except SyntaxError as e:
|
|
627
|
+
rep.findings.append(Finding("missing_symbol", s["name"], f"{loc['path']}: syntax error {e.lineno}"))
|
|
628
|
+
continue
|
|
629
|
+
node = _find_symbol(tree, loc["symbol"])
|
|
630
|
+
if node is None:
|
|
631
|
+
rep.findings.append(Finding("missing_symbol", s["name"], f"{loc['path']}::{loc['symbol']}"))
|
|
632
|
+
continue
|
|
633
|
+
n = _count_calls(node, s["name"])
|
|
634
|
+
rep.call_sites[key] = n
|
|
635
|
+
if n == 0:
|
|
636
|
+
rep.findings.append(Finding("signal_not_in_symbol", s["name"], f"{loc['path']}::{loc['symbol']}"))
|
|
637
|
+
continue
|
|
638
|
+
if lock is not None and key in lock and n < lock[key]:
|
|
639
|
+
rep.findings.append(Finding("call_sites_decreased", s["name"], f"{loc['path']}::{loc['symbol']} {lock[key]}→{n}"))
|
|
640
|
+
if rep.head and loc["commit"] != rep.head:
|
|
641
|
+
rep.findings.append(Finding("new_build", s["name"], f"manifest@{loc['commit'][:7]} repo@{rep.head[:7]}"))
|
|
642
|
+
check_jobs(repo, manifest, rep)
|
|
643
|
+
check_screens(repo, manifest, rep)
|
|
644
|
+
return rep
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _relay_in_repo(repo: Path) -> bool:
|
|
648
|
+
"""Ein Relay der App (Kopie aus dem Outbox-Paket) ruft relayOnce( – nicht im Bündel unter vendor/, nicht in node_modules."""
|
|
649
|
+
skip = {".git", "node_modules", "vendor", "dist", "build", ".next", "__pycache__", ".venv"}
|
|
650
|
+
for f in repo.rglob("*"):
|
|
651
|
+
if f.suffix not in {".mjs", ".js", ".ts"} or any(p in skip for p in f.relative_to(repo).parts): continue
|
|
652
|
+
try:
|
|
653
|
+
if "relayOnce(" in f.read_text(encoding="utf-8", errors="replace"): return True
|
|
654
|
+
except OSError:
|
|
655
|
+
continue
|
|
656
|
+
return False
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def check_jobs(repo: Path, manifest: dict[str, Any], rep: DriftReport) -> None:
|
|
660
|
+
"""B20: jede Fundstelle eines Jobs muss einen Heartbeat-Aufruf mit genau dieser Job-Kennung enthalten (alle Sprachen, Textprüfung)."""
|
|
661
|
+
import re as _re
|
|
662
|
+
for j in manifest.get("jobs", []) or []:
|
|
663
|
+
jid = j.get("id", "")
|
|
664
|
+
call = _re.compile(r"heartbeat\w*\s*\([^)]*?[\"'`]" + _re.escape(jid) + r"[\"'`]", _re.S)
|
|
665
|
+
for ev in j.get("evidence", []) or []:
|
|
666
|
+
f = repo / ev["path"]
|
|
667
|
+
if not f.exists():
|
|
668
|
+
rep.findings.append(Finding("missing_file", f"job:{jid}", ev["path"]))
|
|
669
|
+
continue
|
|
670
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
671
|
+
if ev.get("symbol") and not _symbol_in_text(text, ev["symbol"]):
|
|
672
|
+
rep.findings.append(Finding("missing_symbol", f"job:{jid}", f"{ev['path']}::{ev['symbol']}"))
|
|
673
|
+
continue
|
|
674
|
+
# B20 Schnitt C: das Outbox-Relay sendet den Schlag in relayOnce (Standardkennung everydeck_outbox_relay oder job: '<id>')
|
|
675
|
+
relay = _re.search(r"relayOnce\s*\(", text) and (jid == "everydeck_outbox_relay" or _re.search(r"job\s*:\s*[\"'`]" + _re.escape(jid) + r"[\"'`]", text))
|
|
676
|
+
# B31: die Fundstelle des Relay-Jobs ist oft der Zeitplan (pg_cron → Edge Function) – der Schlag steht dann im Relay der App
|
|
677
|
+
if not relay and jid == "everydeck_outbox_relay": relay = _relay_in_repo(repo)
|
|
678
|
+
if not call.search(text) and not relay:
|
|
679
|
+
rep.findings.append(Finding("job_not_in_heartbeat", f"job:{jid}", ev["path"]))
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
_SCREEN_SKIP = {".git", "node_modules", "vendor", "dist", "build", ".next", ".expo", "everydeck", "coverage", "__pycache__", ".venv"}
|
|
683
|
+
_SCREEN_EXT = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte", ".dart", ".json", ".py", ".html"}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def check_screens(repo: Path, manifest: dict[str, Any], rep: DriftReport) -> None:
|
|
687
|
+
"""B23: jedes deklarierte Seiten- und API-Muster steht wörtlich im Code der App – dort, wo es dem Browser-SDK übergeben wird
|
|
688
|
+
(`initEverydeckBrowser({ screens, apiRoutes })`). Fehlt es, bildet das SDK diese Adressen nur auf den ersten Abschnitt ab: kein Leck, aber ein
|
|
689
|
+
ungenauer Weg vor dem Fehler bzw. ungenaue Anfragen. API-Muster einer App ohne Browser-SDK nutzt nur everydeck beim Empfang – keine Prüfung."""
|
|
690
|
+
wanted = [("screen", s.get("pattern", "")) for s in manifest.get("screens", []) or []] + [("api", r.get("pattern", "")) for r in manifest.get("api_routes", []) or []]
|
|
691
|
+
if not wanted:
|
|
692
|
+
return
|
|
693
|
+
found: set[str] = set(); browser = False
|
|
694
|
+
for f in repo.rglob("*"):
|
|
695
|
+
if any(part in _SCREEN_SKIP for part in f.relative_to(repo).parts) or not f.is_file() or f.suffix not in _SCREEN_EXT:
|
|
696
|
+
continue
|
|
697
|
+
try:
|
|
698
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
699
|
+
except OSError:
|
|
700
|
+
continue
|
|
701
|
+
browser = browser or "initEverydeckBrowser" in text
|
|
702
|
+
for _, p in wanted:
|
|
703
|
+
if p not in found and (f"'{p}'" in text or f'"{p}"' in text or f"`{p}`" in text):
|
|
704
|
+
found.add(p)
|
|
705
|
+
for kind, p in wanted:
|
|
706
|
+
if p in found or (kind == "api" and not browser):
|
|
707
|
+
continue
|
|
708
|
+
rep.findings.append(Finding("screen_not_in_code" if kind == "screen" else "api_route_not_in_code", f"{kind}:{p}",
|
|
709
|
+
f"not passed to the SDK (initEverydeckBrowser({{ {'screens' if kind == 'screen' else 'apiRoutes'} }}))"))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def freeze_lock(report: DriftReport) -> dict[str, Any]:
|
|
713
|
+
return {"version": 1, "head": report.head, "call_sites": dict(sorted(report.call_sites.items()))}
|