mosaic-headless 1.2.1 → 1.4.0

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.
package/tools/mo.py ADDED
@@ -0,0 +1,715 @@
1
+ #!/usr/bin/env python3
2
+ """mo - the front door to the Mosaic surface.
3
+
4
+ python tools/mo.py stats
5
+ python tools/mo.py type accordion
6
+ python tools/mo.py types --edition pro --safe
7
+ python tools/mo.py check accordion-content text div
8
+
9
+ Why this exists rather than `grep data/*.csv`:
10
+
11
+ The skill's one rule is "never write a node type, property name, enum value or style
12
+ key from memory - look it up in `data/`". That rule needs something to look it up
13
+ WITH. Grep answers the question you typed; it does not answer the question you have.
14
+ Ask grep about `accordion-content` and it tells you the type exists. It does not tell
15
+ you that placing one committed cleanly and then reduced the entire public page to a
16
+ 54-byte error string, which is the only thing about that type worth knowing.
17
+
18
+ So every lookup here joins the source tables to the live sweeps and leads with the
19
+ measured verdict. The data files are 25 CSVs that cross-reference each other through five
20
+ different keys; this is the query.
21
+
22
+ Reads only `data/*.csv`. No dependencies, no network.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import csv
28
+ import json
29
+ import os
30
+ import re
31
+ import sys
32
+
33
+ DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data")
34
+
35
+ # A type whose sweep outcome is one of these must not be committed: the first two
36
+ # kill the request, the third kills the page for every visitor until you delete the
37
+ # row. `mo.py check` exits non-zero on them so a build script can refuse.
38
+ UNSAFE = {"COMMIT_500", "COMMIT_502", "BROKE_PAGE"}
39
+
40
+ OUTCOME_NOTE = {
41
+ "RENDERED": "committed and reached the delivered HTML",
42
+ "COMMITTED": "row exists, nothing reached the page - inert on its own",
43
+ "COMMIT_500": "PHP fatal during commit; the request dies",
44
+ "COMMIT_502": "gateway error during commit; the request dies",
45
+ "BROKE_PAGE": "committed, then the whole public page became an error string",
46
+ }
47
+
48
+
49
+ # ── loading ───────────────────────────────────────────────────────────────────
50
+
51
+ _cache: dict[str, list[dict]] = {}
52
+
53
+
54
+ def rows(name: str) -> list[dict]:
55
+ """A data file, as a list of dicts. Missing file is a hard error, not an empty
56
+ list: silently answering from no data is the failure this skill argues against."""
57
+ if name not in _cache:
58
+ path = os.path.join(DATA, name + ".csv")
59
+ if not os.path.exists(path):
60
+ sys.exit("missing data file: %s" % os.path.normpath(path))
61
+ with open(path, encoding="utf-8", newline="") as fh:
62
+ _cache[name] = list(csv.DictReader(fh))
63
+ return _cache[name]
64
+
65
+
66
+ def index(name: str, key: str) -> dict[str, dict]:
67
+ return {r[key]: r for r in rows(name)}
68
+
69
+
70
+ def split(value: str) -> list[str]:
71
+ """The CSVs pack multi-values with `|`."""
72
+ return [v for v in (value or "").split("|") if v]
73
+
74
+
75
+ # ── output ────────────────────────────────────────────────────────────────────
76
+
77
+ ARGS = argparse.Namespace(json=False)
78
+
79
+
80
+ def emit(payload, render):
81
+ if ARGS.json:
82
+ json.dump(payload, sys.stdout, indent=1, ensure_ascii=False)
83
+ sys.stdout.write("\n")
84
+ else:
85
+ render()
86
+
87
+
88
+ def head(text):
89
+ print(text)
90
+ print("-" * len(text))
91
+
92
+
93
+ def table(headers, body, gap=2):
94
+ if not body:
95
+ print(" (nothing matched)")
96
+ return
97
+ body = [[("" if c is None else str(c)) for c in r] for r in body]
98
+ width = [max(len(str(h)), *(len(r[i]) for r in body))
99
+ for i, h in enumerate(headers)]
100
+ sep = " " * gap
101
+ print(sep.join(str(h).ljust(width[i]) for i, h in enumerate(headers)).rstrip())
102
+ print(sep.join("-" * w for w in width))
103
+ for r in body:
104
+ print(sep.join(c.ljust(width[i]) for i, c in enumerate(r)).rstrip())
105
+
106
+
107
+ def matches(text: str, needle: str | None) -> bool:
108
+ return needle is None or needle.lower() in (text or "").lower()
109
+
110
+
111
+ # ── node types ────────────────────────────────────────────────────────────────
112
+
113
+ def type_record(name: str) -> dict:
114
+ """One node type, joined across every table that says something about it."""
115
+ t = index("node-types", "type").get(name)
116
+ if not t:
117
+ near = [r["type"] for r in rows("node-types") if name in r["type"]]
118
+ sys.exit("no such node type: %s%s" % (
119
+ name, ("\ndid you mean: " + ", ".join(near[:8])) if near else ""))
120
+
121
+ v = index("node-verification", "type").get(name, {})
122
+ p = index("placement-rules", "type").get(name, {})
123
+ d = index("default-children", "type").get(name, {})
124
+
125
+ # Properties are owned by the data class and inherited up the `extends` chain.
126
+ # Resolving the chain is the whole reason this is a tool: `node-properties.csv`
127
+ # is keyed on `owner_class` and cannot be joined to a type name by eye. The chain
128
+ # is walked through `data-class-hierarchy.csv` rather than through the property
129
+ # rows themselves, because a class that declares nothing writes no property row
130
+ # and would end the walk early - which is how `accordion-content` once reported
131
+ # zero properties while in fact carrying the nine every element has.
132
+ parent = {r["class"]: r["extends"] for r in rows("data-class-hierarchy")}
133
+ by_owner: dict[str, list[dict]] = {}
134
+ for r in rows("node-properties"):
135
+ by_owner.setdefault(r["owner_class"], []).append(r)
136
+
137
+ props, seen, cls, own_class = [], set(), t.get("data_class"), t.get("data_class")
138
+ while cls and cls not in seen:
139
+ seen.add(cls)
140
+ for r in by_owner.get(cls, []):
141
+ props.append(dict(r, inherited_from=("" if cls == own_class else cls)))
142
+ cls = parent.get(cls, "")
143
+ chain_complete = bool(own_class)
144
+
145
+ return {
146
+ "type": name,
147
+ "label": t.get("label"),
148
+ "edition": t.get("edition"),
149
+ "outcome": v.get("outcome"),
150
+ "outcome_note": OUTCOME_NOTE.get(v.get("outcome"), ""),
151
+ "safe_to_commit": v.get("outcome") not in UNSAFE,
152
+ "detail": v.get("detail"),
153
+ "rendered_tag": v.get("rendered_tag"),
154
+ "rendered_classes": v.get("rendered_classes"),
155
+ "can_be_parent": t.get("can_be_parent") == "true",
156
+ "placement_rule": p.get("rule"),
157
+ "allowed_children": split(p.get("allowed_children", "")),
158
+ "default_children": split(d.get("default_children", "")),
159
+ "data_class": t.get("data_class"),
160
+ "data_class_known": chain_complete,
161
+ "alias_types": split(t.get("alias_types", "")),
162
+ "file": t.get("file"),
163
+ "properties": [
164
+ {"property": r["property"],
165
+ "validators": split(r["validators"]),
166
+ "accepted_values": split(r["accepted_values"]),
167
+ "supports_inherit": r["supports_inherit"] == "true",
168
+ "inherited_from": r["inherited_from"]}
169
+ for r in props],
170
+ }
171
+
172
+
173
+ def parents_of(name: str) -> dict:
174
+ """Which types may CONTAIN this one.
175
+
176
+ Nothing stores this - it is the placement table read backwards, and it is the
177
+ question you actually have when a node refuses to go where you put it.
178
+
179
+ The distinction that matters: a type that NAMES this one in an allow-list is a
180
+ real, intended parent, and for the composite types it is usually the only one
181
+ that works at runtime. A container whose rule is `any` will accept the node
182
+ structurally and may still fatal - `accordion-content` goes into any div as far
183
+ as the table is concerned, and takes the whole public page down when it does.
184
+ Listing the eighty permissive containers alongside the one correct parent would
185
+ bury the answer, so they are counted, not enumerated."""
186
+ named, permissive = [], 0
187
+ for r in rows("placement-rules"):
188
+ allowed = split(r["allowed_children"])
189
+ if r["rule"] == "allow" and name in allowed:
190
+ named.append(r["type"])
191
+ elif r["rule"] == "any" or (r["rule"] == "deny" and name not in allowed):
192
+ permissive += 1
193
+ return {"named_parents": named, "permissive_containers": permissive}
194
+
195
+
196
+ def cmd_type(a):
197
+ rec = type_record(a.name)
198
+ prop_status = {r["property"]: r["status"]
199
+ for r in rows("node-property-verification")}
200
+
201
+ def render():
202
+ head("%s%s" % (rec["type"],
203
+ " - " + rec["label"] if rec["label"] else ""))
204
+ flag = "SAFE" if rec["safe_to_commit"] else "UNSAFE - DO NOT COMMIT"
205
+ print("edition : %s" % rec["edition"])
206
+ print("swept : %s (%s)" % (rec["outcome"], rec["outcome_note"]))
207
+ print("verdict : %s" % flag)
208
+ if rec["detail"]:
209
+ print("failure : %s" % rec["detail"])
210
+ if rec["rendered_tag"]:
211
+ print("renders as: <%s>%s" % (
212
+ rec["rendered_tag"],
213
+ (" class=%s" % rec["rendered_classes"]) if rec["rendered_classes"]
214
+ else ""))
215
+ if rec["alias_types"]:
216
+ print("aliases : %s" % ", ".join(rec["alias_types"]))
217
+
218
+ print("\nchildren : rule=%s" % (rec["placement_rule"] or "n/a"))
219
+ if rec["allowed_children"]:
220
+ print(" %s" % ", ".join(rec["allowed_children"]))
221
+ if rec["default_children"]:
222
+ print(" heal() inserts on commit: %s"
223
+ % ", ".join(rec["default_children"]))
224
+ par = parents_of(rec["type"])
225
+ if par["named_parents"]:
226
+ print("goes inside: %s (named explicitly - use these)"
227
+ % ", ".join(par["named_parents"]))
228
+ else:
229
+ print("goes inside: no type names it; %d permissive containers accept it "
230
+ "structurally" % par["permissive_containers"])
231
+
232
+ print("\nproperties (%d)%s" % (
233
+ len(rec["properties"]),
234
+ "" if rec["data_class_known"]
235
+ else " - declares no data class; the source says nothing"))
236
+ table(["property", "shared", "verified", "accepted values"],
237
+ [[p["property"],
238
+ "yes" if p["inherited_from"] else "",
239
+ prop_status.get(p["property"], ""),
240
+ ", ".join(p["accepted_values"])[:56]]
241
+ for p in rec["properties"]])
242
+ print("\nsource: %s" % rec["file"])
243
+
244
+ emit(rec, render)
245
+
246
+
247
+ def cmd_types(a):
248
+ ver = index("node-verification", "type")
249
+ out = []
250
+ for r in rows("node-types"):
251
+ v = ver.get(r["type"], {})
252
+ if a.edition and r["edition"] != a.edition:
253
+ continue
254
+ if a.outcome and v.get("outcome") != a.outcome:
255
+ continue
256
+ if a.safe and v.get("outcome") in UNSAFE:
257
+ continue
258
+ if a.unsafe and v.get("outcome") not in UNSAFE:
259
+ continue
260
+ if not (matches(r["type"], a.grep) or matches(r["label"], a.grep)):
261
+ continue
262
+ out.append({"type": r["type"], "label": r["label"], "edition": r["edition"],
263
+ "outcome": v.get("outcome"),
264
+ "safe": v.get("outcome") not in UNSAFE,
265
+ "renders_as": v.get("rendered_tag", "")})
266
+ emit(out, lambda: (
267
+ table(["type", "label", "edition", "swept", "renders as"],
268
+ [[r["type"], r["label"], r["edition"], r["outcome"],
269
+ r["renders_as"] or ""] for r in out]),
270
+ print("\n%d types" % len(out))))
271
+
272
+
273
+ def cmd_check(a):
274
+ """Exit non-zero if any named type is measured unsafe. For build scripts."""
275
+ ver = index("node-verification", "type")
276
+ known = index("node-types", "type")
277
+ bad = []
278
+ for name in a.names:
279
+ if name not in known:
280
+ bad.append((name, "NO SUCH TYPE", "not in data/node-types.csv"))
281
+ continue
282
+ o = ver.get(name, {}).get("outcome")
283
+ if o in UNSAFE:
284
+ bad.append((name, o, ver.get(name, {}).get("detail")
285
+ or OUTCOME_NOTE.get(o, "")))
286
+ emit({"checked": a.names, "unsafe": [b[0] for b in bad],
287
+ "detail": [{"type": b[0], "outcome": b[1], "why": b[2]} for b in bad]},
288
+ lambda: (table(["type", "outcome", "why"], bad) if bad
289
+ else print("all %d types are safe to commit" % len(a.names))))
290
+ sys.exit(1 if bad else 0)
291
+
292
+
293
+ # ── properties ────────────────────────────────────────────────────────────────
294
+
295
+ def cmd_props(a):
296
+ ver = {r["property"]: r for r in rows("node-property-verification")}
297
+ seen, out = set(), []
298
+ for r in rows("node-properties"):
299
+ if not matches(r["property"], a.grep):
300
+ continue
301
+ if a.status and ver.get(r["property"], {}).get("status") != a.status:
302
+ continue
303
+ key = (r["property"], r["owner_class"])
304
+ if key in seen:
305
+ continue
306
+ seen.add(key)
307
+ out.append({"property": r["property"], "owner": r["owner_class"],
308
+ "validators": split(r["validators"]),
309
+ "accepted_values": split(r["accepted_values"]),
310
+ "verified": ver.get(r["property"], {}).get("status", ""),
311
+ "evidence": ver.get(r["property"], {}).get("evidence", "")})
312
+ if a.shared:
313
+ out = [r for r in out if "Abstract" in r["owner"]]
314
+ emit(out, lambda: (
315
+ table(["property", "owner", "verified", "accepted values"],
316
+ [[r["property"], r["owner"].replace("MResourceData", ""),
317
+ r["verified"], ", ".join(r["accepted_values"])[:50]] for r in out]),
318
+ print("\n%d rows" % len(out))))
319
+
320
+
321
+ def cmd_prop(a):
322
+ decls = [r for r in rows("node-properties") if r["property"] == a.name]
323
+ if not decls:
324
+ sys.exit("no such node property: %s" % a.name)
325
+ v = {r["property"]: r for r in rows("node-property-verification")}.get(a.name, {})
326
+ classes = {r["data_class"]: r["type"] for r in rows("node-types")}
327
+ rec = {
328
+ "property": a.name,
329
+ "verified": v.get("status"),
330
+ "probed_on": v.get("probed_on"),
331
+ "sent": v.get("sent"),
332
+ "evidence": v.get("evidence"),
333
+ "declared_by": sorted({d["owner_class"] for d in decls}),
334
+ "types": sorted({classes[d["owner_class"]] for d in decls
335
+ if d["owner_class"] in classes}),
336
+ "validators": split(decls[0]["validators"]),
337
+ "accepted_values": split(decls[0]["accepted_values"]),
338
+ "supports_inherit": decls[0]["supports_inherit"] == "true",
339
+ "shared": any("Abstract" in d["owner_class"] for d in decls),
340
+ }
341
+
342
+ def render():
343
+ head(a.name)
344
+ print("verified : %s" % (rec["verified"] or "not probed"))
345
+ if rec["sent"]:
346
+ print("probed : sent %s on %s" % (rec["sent"], rec["probed_on"]))
347
+ if rec["evidence"]:
348
+ print("evidence : %s" % rec["evidence"])
349
+ print("validators: %s" % " | ".join(rec["validators"]))
350
+ if rec["accepted_values"]:
351
+ print("values : %s" % ", ".join(rec["accepted_values"]))
352
+ print("inherit : %s" % ("supported" if rec["supports_inherit"] else "no"))
353
+ if rec["shared"]:
354
+ print("scope : shared - every element has it")
355
+ else:
356
+ print("on types : %s" % (", ".join(rec["types"]) or "(abstract only)"))
357
+
358
+ emit(rec, render)
359
+
360
+
361
+ # ── style ─────────────────────────────────────────────────────────────────────
362
+
363
+ def cmd_style(a):
364
+ ver = index("style-verification", "property")
365
+ shapes = index("style-value-shapes", "property")
366
+ out = []
367
+ for r in rows("style-properties"):
368
+ if not matches(r["property"], a.grep):
369
+ continue
370
+ v = ver.get(r["property"], {})
371
+ if a.status and v.get("status") != a.status:
372
+ continue
373
+ if a.grouped and not r["group"]:
374
+ continue
375
+ out.append({"property": r["property"], "group": r["group"],
376
+ "css": v.get("css_property", ""),
377
+ "status": v.get("status", ""),
378
+ "compiled": v.get("compiled", ""),
379
+ "shape": shapes.get(r["property"], {}).get("shape", ""),
380
+ "tokenable": r["tokenable"] == "true",
381
+ "accepted_values": split(r["accepted_values"])})
382
+
383
+ def render():
384
+ table(["property", "group", "css", "swept", "shape"],
385
+ [[r["property"], r["group"], r["css"], r["status"], r["shape"][:34]]
386
+ for r in out])
387
+ n_group = sum(1 for r in out if r["group"])
388
+ print("\n%d properties%s" % (len(out), (
389
+ " - %d belong to a group and are INERT set on their own"
390
+ % n_group) if n_group else ""))
391
+
392
+ emit(out, render)
393
+
394
+
395
+ def cmd_css(a):
396
+ """Reverse lookup: which Mosaic style property drives this CSS property.
397
+
398
+ The names mostly transliterate, but not always, and `radius` -> `border-radius`
399
+ is not something to guess at 2am."""
400
+ want = a.property.lower()
401
+ out = [r for r in rows("style-verification")
402
+ if want in (r["css_property"] or "").lower()
403
+ or want in r["property"].lower()]
404
+ props = index("style-properties", "property")
405
+ emit([dict(r, group=props.get(r["property"], {}).get("group", "")) for r in out],
406
+ lambda: table(["css property", "set in Mosaic as", "group", "swept"],
407
+ [[r["css_property"], r["property"],
408
+ props.get(r["property"], {}).get("group", ""),
409
+ r["status"]] for r in out]))
410
+
411
+
412
+ def cmd_states(a):
413
+ """A state joined to whether it was ever seen to compile.
414
+
415
+ `style-states.csv` is what the source declares. `--verified` is the view that
416
+ answers the question you actually have when you are about to write one: a state
417
+ whose only host takes the whole page down is not a style you can use."""
418
+ ver = index("style-state-verification", "state")
419
+ out = []
420
+ for r in rows("style-states"):
421
+ if not (matches(r["state"], a.grep)
422
+ or matches(r["selector_template"], a.grep)):
423
+ continue
424
+ v = ver.get(r["state"], {})
425
+ status = v.get("status", "base state" if r["state"] == "&" else "")
426
+ if a.verified and status != "COMPILED":
427
+ continue
428
+ out.append({"state": r["state"], "selector": r["selector_template"],
429
+ "scope": r["scope"], "host": v.get("host", ""),
430
+ "status": status, "compiled_to": v.get("evidence", "")})
431
+
432
+ def render():
433
+ table(["state", "scope", "host", "swept", "selector"],
434
+ [[r["state"], r["scope"], r["host"], r["status"], r["selector"][:50]]
435
+ for r in out])
436
+ usable = [r for r in out
437
+ if r["scope"] == "global" and r["status"] == "COMPILED"]
438
+ print("\n%d states. `&` is the base state; the breakpoint axis is separate "
439
+ "(_ / _t / _m)." % len(out))
440
+ if usable:
441
+ print("%d go on ANY element: %s"
442
+ % (len(usable), ", ".join(r["state"] for r in usable)))
443
+ print("The pseudo-class is emitted UPPERCASE (`.M_EL9:HOVER`), so grepping a "
444
+ "stylesheet\nfor `:hover` finds nothing.")
445
+
446
+ emit(out, render)
447
+
448
+
449
+ def cmd_classes(a):
450
+ out = [r for r in rows("element-classes")
451
+ if matches(r["name"], a.grep) or matches(r["selectors"], a.grep)]
452
+ emit(out, lambda: (
453
+ table(["name", "selectors", "id"],
454
+ [[r["name"], r["selectors"][:40], r["id"]] for r in out]),
455
+ print("\n%d classes. These are THEME-GLOBAL: styling `Heading 1` restyles "
456
+ "every h1 on the install." % len(out))))
457
+
458
+
459
+ # ── dynamic content, interactions, conditions ─────────────────────────────────
460
+
461
+ def cmd_vars(a):
462
+ out = [r for r in rows("dynamic-variables")
463
+ if matches(r["expression"], a.grep)
464
+ and (not a.namespace or r["namespace"] == a.namespace)]
465
+ emit(out, lambda: (
466
+ table(["expression", "label"],
467
+ [[r["expression"], r["label"]] for r in out]),
468
+ print("\n%d variables. A misspelt one renders as literal text, not an error."
469
+ % len(out))))
470
+
471
+
472
+ def cmd_fns(a):
473
+ out = rows("evaluator-functions")
474
+ emit(out, lambda: table(["function", "max args", "variadic", "note"],
475
+ [[r["id"], r["max_args"], r["variadic"], r["note"]]
476
+ for r in out]))
477
+
478
+
479
+ def cmd_interactions(a):
480
+ out = rows("interaction-types")
481
+ emit(out, lambda: (
482
+ table(["id", "family", "label"],
483
+ [[r["id"], r["family"], r["label"]] for r in out]),
484
+ print("\nTrigger and timing reach the browser; per-keyframe property binding "
485
+ "is UNSOLVED - see references/interactions.md.")))
486
+
487
+
488
+ def cmd_conditions(a):
489
+ out = [r for r in rows("condition-subjects")
490
+ if (not a.context or r["context"] == a.context)
491
+ and matches(r["label"] + r["id"], a.grep)]
492
+ emit(out, lambda: (
493
+ table(["context", "id", "label", "group"],
494
+ [[r["context"], r["id"], r["label"], r["group_label"]] for r in out]),
495
+ print("\n%d subjects. Committing a condition has NOT been driven end to end."
496
+ % len(out))))
497
+
498
+
499
+ # ── infrastructure ────────────────────────────────────────────────────────────
500
+
501
+ def cmd_routes(a):
502
+ out = [r for r in rows("rest-routes") if matches(r["route"], a.grep)]
503
+ emit(out, lambda: (
504
+ table(["route", "methods"], [[r["route"], r["methods"]] for r in out]),
505
+ print("\n%d routes. The namespace carries the PLUGIN VERSION - read it from "
506
+ "mosaicOptions.rest_api_url, never hardcode." % len(out))))
507
+
508
+
509
+ def cmd_tables(a):
510
+ cols = rows("db-columns")
511
+ if a.name:
512
+ out = [r for r in cols if r["table"] == a.name or r["table"].endswith(a.name)]
513
+ if not out:
514
+ sys.exit("no such table: %s" % a.name)
515
+ emit(out, lambda: table(["column", "type"],
516
+ [[r["column"], r["type"]] for r in out]))
517
+ return
518
+ agg: dict[str, int] = {}
519
+ for r in cols:
520
+ agg[r["table"]] = agg.get(r["table"], 0) + 1
521
+ out = [{"table": t, "columns": n} for t, n in sorted(agg.items())]
522
+ emit(out, lambda: (
523
+ table(["table", "columns"], [[r["table"], r["columns"]] for r in out]),
524
+ print("\n%d tables, %d columns. A page is rows in these, not post_content."
525
+ % (len(out), len(cols)))))
526
+
527
+
528
+ def cmd_placement(a):
529
+ rec = type_record(a.name)
530
+ payload = {"type": a.name, "rule": rec["placement_rule"],
531
+ "allowed_children": rec["allowed_children"],
532
+ "default_children": rec["default_children"],
533
+ "safe_to_commit": rec["safe_to_commit"], **parents_of(a.name)}
534
+ emit(payload, lambda: (
535
+ head("placement: %s" % a.name),
536
+ print("rule : %s" % payload["rule"]),
537
+ print("accepts children: %s" % (", ".join(payload["allowed_children"])
538
+ or "none")),
539
+ print("heal() inserts : %s" % (", ".join(payload["default_children"])
540
+ or "nothing")),
541
+ print("named parents : %s" % (", ".join(payload["named_parents"])
542
+ or "none - no type asks for it by name")),
543
+ print("also accepted by: %d permissive containers (rule=any / deny-list)"
544
+ % payload["permissive_containers"]),
545
+ print("\nThe table is what the SOURCE declares, and it does not know that a "
546
+ "type\nfatals on commit: %s is %s. Ask `mo.py type %s`."
547
+ % (a.name, "SAFE" if payload["safe_to_commit"] else "UNSAFE TO COMMIT",
548
+ a.name))))
549
+
550
+
551
+ def cmd_skeleton(a):
552
+ """A minimal spec that build_site.py accepts, using only measured-safe types."""
553
+ spec = {
554
+ "site": "example",
555
+ "theme": {"variables": {"--ink": {"type": "color",
556
+ "value": "rgb(22,24,28)"}}},
557
+ "pages": [{
558
+ "slug": "example", "title": "Example",
559
+ "tree": {
560
+ "type": "div",
561
+ "data": {"attrID": "root"},
562
+ "style": {"&": {"_": {"paddingTop": "64px",
563
+ "paddingBottom": "64px"},
564
+ "_m": {"paddingTop": "40px",
565
+ "paddingBottom": "40px"}}},
566
+ "children": [
567
+ {"type": "text",
568
+ "data": {"tagName": "h1", "attrID": "title"},
569
+ "style": {"&": {"_": {"fontSize": "48px",
570
+ "color": {"token": "--ink"}},
571
+ "_m": {"fontSize": "30px"}}},
572
+ "text": "Example"},
573
+ ],
574
+ },
575
+ }],
576
+ }
577
+ json.dump(spec, sys.stdout, indent=1, ensure_ascii=False)
578
+ sys.stdout.write("\n")
579
+
580
+
581
+ def cmd_stats(a):
582
+ ver = rows("node-verification")
583
+ counts: dict[str, int] = {}
584
+ for r in ver:
585
+ counts[r["outcome"]] = counts.get(r["outcome"], 0) + 1
586
+ sv: dict[str, int] = {}
587
+ for r in rows("style-verification"):
588
+ sv[r["status"]] = sv.get(r["status"], 0) + 1
589
+ np: dict[str, int] = {}
590
+ for r in rows("node-property-verification"):
591
+ np[r["status"]] = np.get(r["status"], 0) + 1
592
+ payload = {
593
+ "node_types": len(rows("node-types")),
594
+ "node_type_outcomes": counts,
595
+ "unsafe_types": sorted(r["type"] for r in ver if r["outcome"] in UNSAFE),
596
+ "node_properties": len(rows("node-property-verification")),
597
+ "node_property_outcomes": np,
598
+ "style_properties": len(rows("style-properties")),
599
+ "style_outcomes": sv,
600
+ "style_states": len(rows("style-states")),
601
+ "element_classes": len(rows("element-classes")),
602
+ "dynamic_variables": len(rows("dynamic-variables")),
603
+ "rest_routes": len(rows("rest-routes")),
604
+ "tables": len({r["table"] for r in rows("db-columns")}),
605
+ "rwd_declarations": len(rows("rwd-verification")),
606
+ }
607
+
608
+ def render():
609
+ head("mosaic surface")
610
+ print("node types %3d %s" % (
611
+ payload["node_types"],
612
+ " ".join("%s %d" % (k, v) for k, v in sorted(counts.items()))))
613
+ print(" UNSAFE to commit: %s" % ", ".join(payload["unsafe_types"]))
614
+ print("node properties %3d %s" % (
615
+ payload["node_properties"],
616
+ " ".join("%s %d" % (k, v) for k, v in sorted(np.items()))))
617
+ print("style properties %3d %s" % (
618
+ payload["style_properties"],
619
+ " ".join("%s %d" % (k, v) for k, v in sorted(sv.items()))))
620
+ print("style states %3d" % payload["style_states"])
621
+ print("element classes %3d (theme-global)" % payload["element_classes"])
622
+ print("dynamic variables %3d" % payload["dynamic_variables"])
623
+ print("REST routes %3d" % payload["rest_routes"])
624
+ print("tables %3d %d columns"
625
+ % (payload["tables"], len(rows("db-columns"))))
626
+ print("rwd declarations %3d verified against the served stylesheet"
627
+ % payload["rwd_declarations"])
628
+ print("\nSKIPPED / NO_HOST / INCONCLUSIVE are blind spots, never passes.")
629
+
630
+ emit(payload, render)
631
+
632
+
633
+ # ── cli ───────────────────────────────────────────────────────────────────────
634
+
635
+ def main():
636
+ ap = argparse.ArgumentParser(
637
+ prog="mo.py", description="query the measured Mosaic surface")
638
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
639
+ sub = ap.add_subparsers(dest="cmd", required=True)
640
+
641
+ def add(name, fn, help_):
642
+ p = sub.add_parser(name, help=help_)
643
+ p.set_defaults(fn=fn)
644
+ return p
645
+
646
+ add("stats", cmd_stats, "what is in here, and what is unsafe")
647
+
648
+ p = add("types", cmd_types, "list node types")
649
+ p.add_argument("--grep")
650
+ p.add_argument("--edition", choices=["free", "pro"])
651
+ p.add_argument("--outcome", choices=sorted(OUTCOME_NOTE))
652
+ p.add_argument("--safe", action="store_true", help="only types safe to commit")
653
+ p.add_argument("--unsafe", action="store_true", help="only the dangerous ones")
654
+
655
+ p = add("type", cmd_type, "one node type, fully joined")
656
+ p.add_argument("name")
657
+
658
+ p = add("check", cmd_check, "exit 1 if any named type is unsafe or unknown")
659
+ p.add_argument("names", nargs="+")
660
+
661
+ p = add("placement", cmd_placement, "what may go inside what")
662
+ p.add_argument("name")
663
+
664
+ p = add("props", cmd_props, "node properties")
665
+ p.add_argument("--grep")
666
+ p.add_argument("--status")
667
+ p.add_argument("--shared", action="store_true",
668
+ help="only the ones every element has")
669
+
670
+ p = add("prop", cmd_prop, "one node property")
671
+ p.add_argument("name")
672
+
673
+ p = add("style", cmd_style, "style properties and how they swept")
674
+ p.add_argument("--grep")
675
+ p.add_argument("--status")
676
+ p.add_argument("--grouped", action="store_true",
677
+ help="only grouped ones - all inert set alone")
678
+
679
+ p = add("css", cmd_css, "which style property drives this CSS property")
680
+ p.add_argument("property")
681
+
682
+ p = add("states", cmd_states, "style states and their selectors")
683
+ p.add_argument("--grep")
684
+ p.add_argument("--verified", action="store_true",
685
+ help="only states measured to compile to their promised selector")
686
+
687
+ p = add("classes", cmd_classes, "element classes (theme-global)")
688
+ p.add_argument("--grep")
689
+
690
+ p = add("vars", cmd_vars, "@VAR() dynamic variables")
691
+ p.add_argument("--grep")
692
+ p.add_argument("--namespace")
693
+
694
+ add("fns", cmd_fns, "evaluator functions")
695
+ add("interactions", cmd_interactions, "interaction trigger types")
696
+
697
+ p = add("conditions", cmd_conditions, "condition subjects")
698
+ p.add_argument("--grep")
699
+ p.add_argument("--context")
700
+
701
+ p = add("routes", cmd_routes, "REST routes")
702
+ p.add_argument("--grep")
703
+
704
+ p = add("tables", cmd_tables, "database tables and columns")
705
+ p.add_argument("name", nargs="?")
706
+
707
+ add("skeleton", cmd_skeleton, "a minimal valid page spec")
708
+
709
+ global ARGS
710
+ ARGS = ap.parse_args()
711
+ ARGS.fn(ARGS)
712
+
713
+
714
+ if __name__ == "__main__":
715
+ main()