woolpack 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.
woolpack/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from woolpack.contract import DEFAULT_ENVIRONMENT, PackEnvironment
2
+ from woolpack.validation import ValidatedPack, validate_pack
3
+
4
+ __all__ = ["DEFAULT_ENVIRONMENT", "PackEnvironment", "ValidatedPack", "validate_pack"]
woolpack/cli.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ from woolpack import lint, render, scaffold
7
+
8
+
9
+ def _package_version() -> str:
10
+ try:
11
+ return version("woolpack")
12
+ except PackageNotFoundError:
13
+ return "0.1.0"
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(prog="woolpack", description="Woolpack author tools")
18
+ parser.add_argument("--version", action="version", version=f"%(prog)s {_package_version()}")
19
+ commands = parser.add_subparsers(dest="command")
20
+ commands.add_parser("new", add_help=False, help="scaffold a pack from the Pebble template")
21
+ commands.add_parser("render", add_help=False, help="render a static visual review board")
22
+ commands.add_parser("lint", add_help=False, help="validate and lint a pack")
23
+ args, command_argv = parser.parse_known_args(argv)
24
+
25
+ if args.command == "new":
26
+ return scaffold.main(command_argv, prog="woolpack new")
27
+ if args.command == "render":
28
+ return render.main(command_argv, prog="woolpack render")
29
+ if args.command == "lint":
30
+ return lint.main(command_argv, prog="woolpack lint")
31
+ parser.print_help()
32
+ return 2
33
+
34
+
35
+ __all__ = ["main"]
36
+
37
+
38
+ if __name__ == "__main__":
39
+ raise SystemExit(main())
woolpack/contract.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class PackEnvironment:
8
+ fx_vocab_version: int
9
+ fx_modes: frozenset[str]
10
+ quirk_emit_types: frozenset[str]
11
+ action_ids: frozenset[str]
12
+ spot_ids: frozenset[str]
13
+ condition_ids: frozenset[str]
14
+ pose_keys: frozenset[str]
15
+ pose_write_ops: frozenset[str]
16
+ species_ids: frozenset[str]
17
+ overlay_ids: frozenset[str]
18
+ quirk_ids: frozenset[str]
19
+ coat_label_ids: frozenset[str]
20
+
21
+
22
+ DEFAULT_ENVIRONMENT = PackEnvironment(
23
+ fx_vocab_version=1,
24
+ fx_modes=frozenset(
25
+ {
26
+ "greet",
27
+ "petting",
28
+ "kibble",
29
+ "leash_tug",
30
+ "call_ring",
31
+ "message_ping",
32
+ "zoomie",
33
+ "flinch_away",
34
+ "petting_melt",
35
+ "head_tilt",
36
+ "sigh_settle",
37
+ "threshold_refusal",
38
+ "stash",
39
+ "carry",
40
+ "lean_in",
41
+ "side_eye",
42
+ "ignored",
43
+ "petting_head",
44
+ "petting_ear",
45
+ "petting_tail",
46
+ "petting_belly",
47
+ "warm_spot",
48
+ "brushed_coat",
49
+ "bowl",
50
+ "leash",
51
+ "phone_glow",
52
+ "rumpled_rug",
53
+ }
54
+ ),
55
+ quirk_emit_types=frozenset({"response"}),
56
+ action_ids=frozenset({"greet", "feed", "pet", "walk", "call", "message", "play"}),
57
+ spot_ids=frozenset({"body", "head", "ear", "tail", "belly"}),
58
+ condition_ids=frozenset(
59
+ {
60
+ "action_in",
61
+ "state_in",
62
+ "state_not_in",
63
+ "valence_gte",
64
+ "valence_lt",
65
+ "arousal_gte",
66
+ "arousal_lt",
67
+ "old_arousal_lt",
68
+ "old_valence_lt",
69
+ "enters_state",
70
+ "fact_day_gate",
71
+ "fact_exists",
72
+ "any",
73
+ }
74
+ ),
75
+ pose_keys=frozenset(
76
+ {"body_lean", "head_shift_y", "ear_angle", "eye_style", "tail_motion", "focus_target"}
77
+ ),
78
+ pose_write_ops=frozenset({"set", "min", "set_if_default"}),
79
+ species_ids=frozenset({"cat"}),
80
+ overlay_ids=frozenset(),
81
+ quirk_ids=frozenset(
82
+ {
83
+ "hides_small_things",
84
+ "threshold_refuser",
85
+ "one_eye_napper",
86
+ "lean_in_greeter",
87
+ "content_sigher",
88
+ "fixated_watcher",
89
+ "zoomie_initiator",
90
+ "side_eye_judge",
91
+ }
92
+ ),
93
+ coat_label_ids=frozenset({"tuxedo", "marmalade", "ash"}),
94
+ )
woolpack/lint.py ADDED
@@ -0,0 +1,533 @@
1
+ """Pack-format validation plus author-facing quality checks.
2
+
3
+ Validation runs through :func:`woolpack.validation.validate_pack`; a refused
4
+ pack is the first ERROR and stops the run. Lint then checks the authoring
5
+ quality contract that the runtime gates deliberately do not:
6
+
7
+ - rig class contract on the sanitized figure — the classes/ids the wool
8
+ rig animates (figures.js header, style.css consumers): structure
9
+ (`.tailg`/`.headg`/`.earg`, exactly one `#dog-eyes`), all five eye-state
10
+ subgroups (the room hides `.eyes-open` in the happy/side-eye/sleep
11
+ poses — a missing subgroup renders the figure EYELESS there, so it is an
12
+ ERROR, not a suggestion), and the `.coat`/`.cream`/`.point` palette
13
+ hooks the inline `--dog-*` coat vars land on. The ambient layers
14
+ (`.brushstreak`/`.touchfibers`/`.paw-dream`/`.dog-contact`) are
15
+ nice-to-have: missing is a WARN.
16
+ - sanitizer drops — the elements/attributes `sanitize_svg` WOULD strip
17
+ from the raw figure, named non-destructively, so an author is not
18
+ confused when the loaded figure is thinner than the file they drew.
19
+ - geometry sanity — hitbox thresholds ordered, inside the 400×520 scene
20
+ frame, and reachable from the room's `#dogzone` pettable rect
21
+ (128,270,144,188). pack_render's hitbox overlay remains the eyeball
22
+ check; lint catches the zones that CANNOT work.
23
+ - phrase overlay shape — per-cell fall-through is the designed mechanism
24
+ for body/action/spot, so sparsity there is informational (WARN only when
25
+ a species has no overlay, or an overlay pins zero body cells and its
26
+ ambient voice is the cat's). The `tiny` table is the exception: the
27
+ engine swaps it WHOLE (`body_language.py` `contextual_message_phrase` /
28
+ `fallback_phrase` index `overlay["tiny"][valence]` directly), so a
29
+ missing table, a missing valence bucket, or a bucket with no speakable
30
+ (non-asterisk) line is a runtime crash, not a fall-through — ERROR.
31
+ - voice coverage — every pack coat id has a `coat_labels` entry, every
32
+ pack quirk has `previews`/`moods` copy (the adopt screens fall back to
33
+ generic lines without them) — WARN.
34
+ - manifest polish — author/license present (loader-required, so this PASS
35
+ documents them; v1 has no `description` key — unknown manifest keys are
36
+ a loader error, so the pack's paragraph lives in its README/comments).
37
+
38
+ Lint never mutates: the pack directory is only read and validation is pure,
39
+ so ``lint_pack()`` is safe to call in-process.
40
+
41
+ CLI: `woolpack lint <pack-dir> [--strict]` — exit 1 on any ERROR
42
+ (and on WARN with `--strict`), 0 otherwise.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import xml.etree.ElementTree as ET
49
+ from dataclasses import dataclass, field
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ from woolpack.contract import PackEnvironment
54
+ from woolpack.sanitize import ALLOWED_ELEMENTS, _local
55
+ from woolpack.validation import VALENCE_BUCKETS, PackError, validate_pack
56
+
57
+ PASS = "PASS"
58
+ WARN = "WARN"
59
+ ERROR = "ERROR"
60
+
61
+ # The wool rig class contract (figures.js header; style.css flips the
62
+ # eye-state opacities and animates the structure classes). Rig checks run
63
+ # on the SANITIZED figure — the fragment the room actually injects.
64
+ RIG_STRUCTURE = {"tailg", "headg", "earg"}
65
+ RIG_EYE_STATES = {"eyes-open", "eyes-happy", "eyes-side", "nap-eyes", "one-eye-eyes"}
66
+ RIG_PALETTE = {"coat", "cream", "point"}
67
+ RIG_EXTRAS = {"brushstreak", "touchfibers", "paw-dream", "dog-contact"}
68
+ EYE_SINGLETON = "dog-eyes"
69
+
70
+ # The scene frame (wool.js pointer math) and the room's pettable rect
71
+ # (#dogzone, app/static/index.html:530) — the plausibility envelope for
72
+ # hitbox thresholds. Duplicated in the render command's overlay.
73
+ SCENE_W, SCENE_H = 400, 520
74
+ DOGZONE = (128, 270, 144, 188) # x, y, w, h
75
+
76
+
77
+ @dataclass
78
+ class LintFinding:
79
+ """One check's outcome: severity + a one-line reason naming the why."""
80
+
81
+ check: str
82
+ severity: str
83
+ message: str
84
+
85
+
86
+ @dataclass
87
+ class LintReport:
88
+ """Every finding from one lint run, in check order."""
89
+
90
+ path: Path
91
+ name: str | None = None # manifest name/version, when the load passed
92
+ version: str | None = None
93
+ findings: list[LintFinding] = field(default_factory=list)
94
+
95
+ def add(self, check: str, severity: str, message: str) -> None:
96
+ self.findings.append(LintFinding(check, severity, message))
97
+
98
+ def count(self, severity: str) -> int:
99
+ return sum(1 for f in self.findings if f.severity == severity)
100
+
101
+ def exit_code(self, *, strict: bool = False) -> int:
102
+ if self.count(ERROR):
103
+ return 1
104
+ if strict and self.count(WARN):
105
+ return 1
106
+ return 0
107
+
108
+
109
+ # ────────── figure introspection (sanitized fragment) ──────────
110
+
111
+
112
+ def _figure_handles(figure: str) -> tuple[set[str], list[str]]:
113
+ """Every class handle and every id in the sanitized figure fragment."""
114
+ classes: set[str] = set()
115
+ ids: list[str] = []
116
+ for el in ET.fromstring(figure).iter():
117
+ classes.update(el.get("class", "").split())
118
+ if el.get("id"):
119
+ ids.append(el.get("id", ""))
120
+ return classes, ids
121
+
122
+
123
+ def _sanitize_drops(text: str) -> tuple[list[str], list[str]]:
124
+ """What sanitize_svg would drop from the raw figure, non-destructively.
125
+
126
+ Returns (dropped element tags, stripped attribute descriptions). The
127
+ decisions mirror `sanitize._clean_element` exactly — the allowlist and
128
+ `_local` come from the sanitizer itself, and the lint test cross-checks
129
+ this list against the sanitizer's real output so the two never drift.
130
+ """
131
+ dropped: list[str] = []
132
+ stripped: list[str] = []
133
+
134
+ def walk(el: ET.Element) -> None:
135
+ tag = _local(el.tag)
136
+ if tag not in ALLOWED_ELEMENTS:
137
+ dropped.append(tag) # dropped WITH its subtree — do not descend
138
+ return
139
+ for key, value in el.attrib.items():
140
+ name = _local(key)
141
+ if name.startswith("on"):
142
+ stripped.append(f"{name} on <{tag}>")
143
+ elif name == "href":
144
+ stripped.append(f"href on <{tag}>")
145
+ elif name == "style" and "url(" in value.lower().replace(" ", ""):
146
+ stripped.append(f"style url(...) on <{tag}>")
147
+ for child in el:
148
+ walk(child)
149
+
150
+ walk(ET.fromstring(text))
151
+ return dropped, stripped
152
+
153
+
154
+ # ────────── the checks (pure functions over gathered load results) ──────────
155
+
156
+
157
+ def _check_rig(report: LintReport, species_id: str, figure: str) -> None:
158
+ classes, ids = _figure_handles(figure)
159
+
160
+ missing = sorted(RIG_STRUCTURE - classes)
161
+ singleton = ids.count(EYE_SINGLETON)
162
+ problems = []
163
+ if missing:
164
+ problems.append(f"missing {' '.join(f'.{c}' for c in missing)}")
165
+ if singleton == 0:
166
+ problems.append(
167
+ f"no #{EYE_SINGLETON} — the gaze binding and the pose eye-transforms "
168
+ "own that singleton id (weird name and all)"
169
+ )
170
+ elif singleton > 1:
171
+ problems.append(
172
+ f"#{EYE_SINGLETON} appears {singleton}x — it is a singleton: the visitor "
173
+ "deidentify strips only the first, so a guest copy would duplicate the id"
174
+ )
175
+ if problems:
176
+ report.add(f"rig structure [{species_id}]", ERROR, "; ".join(problems))
177
+ else:
178
+ report.add(
179
+ f"rig structure [{species_id}]",
180
+ PASS,
181
+ ".tailg .headg .earg present, exactly one #dog-eyes",
182
+ )
183
+
184
+ missing = sorted(RIG_EYE_STATES - classes)
185
+ if missing:
186
+ report.add(
187
+ f"rig eye states [{species_id}]",
188
+ ERROR,
189
+ f"missing {' '.join(f'.{c}' for c in missing)} — style.css hides .eyes-open in the "
190
+ "happy/side-eye/sleep/one-eye poses, so the figure renders EYELESS there",
191
+ )
192
+ else:
193
+ report.add(
194
+ f"rig eye states [{species_id}]",
195
+ PASS,
196
+ "all five eye-state subgroups (open/happy/side/nap/one-eye)",
197
+ )
198
+
199
+ missing = sorted(RIG_PALETTE - classes)
200
+ if missing:
201
+ report.add(
202
+ f"rig palette [{species_id}]",
203
+ ERROR,
204
+ f"missing {' '.join(f'.{c}' for c in missing)} — the inline --dog-body/--dog-belly/"
205
+ "--dog-point coat vars land on those classes; a missing slot paints that coat "
206
+ "color nowhere (every coat renders alike)",
207
+ )
208
+ else:
209
+ report.add(
210
+ f"rig palette [{species_id}]",
211
+ PASS,
212
+ ".coat .cream .point all used — every coat slot paints",
213
+ )
214
+
215
+ missing = sorted(RIG_EXTRAS - classes)
216
+ if missing:
217
+ report.add(
218
+ f"rig extras [{species_id}]",
219
+ WARN,
220
+ f"no {' '.join(f'.{c}' for c in missing)} — optional layers (grooming streaks, "
221
+ "touch fibers, the sleep paw-twitch, the contact shadow) won't render",
222
+ )
223
+ else:
224
+ report.add(
225
+ f"rig extras [{species_id}]",
226
+ PASS,
227
+ ".brushstreak .touchfibers .paw-dream .dog-contact all present",
228
+ )
229
+
230
+ stray = sorted({i for i in ids if i != EYE_SINGLETON})
231
+ if stray:
232
+ report.add(
233
+ f"figure ids [{species_id}]",
234
+ WARN,
235
+ f"unexpected id(s) {' '.join(f'#{i}' for i in stray)} — builtin figures carry no id "
236
+ f"but #{EYE_SINGLETON}; a stray id can collide with the room's own (#dogzone, #wool-scene)",
237
+ )
238
+ else:
239
+ report.add(f"figure ids [{species_id}]", PASS, f"no id but #{EYE_SINGLETON}")
240
+
241
+
242
+ def _check_sanitizer_drops(report: LintReport, species_id: str, raw_svg: str) -> None:
243
+ dropped, stripped = _sanitize_drops(raw_svg)
244
+ if not dropped and not stripped:
245
+ report.add(
246
+ f"sanitizer drops [{species_id}]",
247
+ PASS,
248
+ f"nothing in species/{species_id}.svg is stripped — the file is what loads",
249
+ )
250
+ return
251
+ parts = []
252
+ if dropped:
253
+ unique = " ".join(f"<{t}>" for t in sorted(set(dropped)))
254
+ parts.append(f"elements {unique} (dropped with their subtrees)")
255
+ if stripped:
256
+ parts.append(f"attributes {', '.join(sorted(set(stripped)))}")
257
+ report.add(
258
+ f"sanitizer drops [{species_id}]",
259
+ WARN,
260
+ f"the sanitizer strips {'; '.join(parts)} — the loaded figure is thinner "
261
+ "than the file you drew",
262
+ )
263
+
264
+
265
+ def _check_geometry(report: LintReport, species_id: str, g: dict[str, Any]) -> None:
266
+ zx, zy, zw, zh = DOGZONE
267
+ zx2, zy2 = zx + zw, zy + zh
268
+ ear, head = g["earBelow"], g["headBelow"]
269
+ tail, belly = g["tail"], g["belly"]
270
+ problems = []
271
+ if ear >= head:
272
+ problems.append(f"earBelow {ear} >= headBelow {head} — the ear zone is empty")
273
+ if belly["xAbove"] >= belly["xBelow"]:
274
+ problems.append(
275
+ f"belly xAbove {belly['xAbove']} >= xBelow {belly['xBelow']} — the belly zone is empty"
276
+ )
277
+ for label, value, hi in (
278
+ ("earBelow", ear, SCENE_H),
279
+ ("headBelow", head, SCENE_H),
280
+ ("tail.yAbove", tail["yAbove"], SCENE_H),
281
+ ("tail.xAbove", tail["xAbove"], SCENE_W),
282
+ ("belly.yAbove", belly["yAbove"], SCENE_H),
283
+ ("belly.xAbove", belly["xAbove"], SCENE_W),
284
+ ("belly.xBelow", belly["xBelow"], SCENE_W),
285
+ ):
286
+ if not 0 <= value <= hi:
287
+ problems.append(f"{label} {value} lies outside the {SCENE_W}x{SCENE_H} scene frame")
288
+ if ear <= zy:
289
+ problems.append(
290
+ f"earBelow {ear} is at/above the pettable zone's top (y={zy}) — no touch can land on an ear"
291
+ )
292
+ if head <= zy:
293
+ problems.append(
294
+ f"headBelow {head} is at/above the pettable zone's top (y={zy}) — the head zone is unreachable"
295
+ )
296
+ if tail["xAbove"] >= zx2 or tail["yAbove"] >= zy2:
297
+ problems.append(
298
+ f"the tail zone starts at/beyond the pettable zone's far edge "
299
+ f"(x>={tail['xAbove']} vs {zx2}, y>={tail['yAbove']} vs {zy2}) — the tail is unpettable"
300
+ )
301
+ if belly["yAbove"] >= zy2 or belly["xAbove"] >= zx2 or belly["xBelow"] <= zx:
302
+ problems.append(
303
+ "the belly rect misses the pettable zone entirely — the belly is untouchable"
304
+ )
305
+ if problems:
306
+ report.add(f"geometry [{species_id}]", WARN, "; ".join(problems))
307
+ else:
308
+ report.add(
309
+ f"geometry [{species_id}]",
310
+ PASS,
311
+ "zones ordered, inside the 400x520 frame, and reachable from the #dogzone rect "
312
+ "(woolpack render's hitbox overlay is the eyeball check)",
313
+ )
314
+
315
+
316
+ def _check_overlay(
317
+ report: LintReport,
318
+ species_id: str,
319
+ overlay: dict[str, Any] | None,
320
+ environment: PackEnvironment,
321
+ ) -> None:
322
+ if overlay is None:
323
+ report.add(
324
+ f"phrase overlay [{species_id}]",
325
+ WARN,
326
+ "no phrase overlay — every line falls through to the base tables, so the "
327
+ "species speaks with the cat's voice (fine for a draft; a voice is what "
328
+ "makes a species itself)",
329
+ )
330
+ return
331
+
332
+ # tiny completeness — the one table with NO per-cell fall-through.
333
+ tiny = overlay.get("tiny")
334
+ if tiny is None:
335
+ report.add(
336
+ f"overlay tiny [{species_id}]",
337
+ ERROR,
338
+ "the overlay carries no tiny table — the engine swaps the tiny table WHOLE "
339
+ '(body_language.py indexes overlay["tiny"][valence] directly), so the first '
340
+ "message reply raises KeyError instead of falling through",
341
+ )
342
+ else:
343
+ missing = sorted(VALENCE_BUCKETS - set(tiny))
344
+ unspeakable = sorted(
345
+ v
346
+ for v, lines in tiny.items()
347
+ if all(line.strip().startswith("*") and line.strip().endswith("*") for line in lines)
348
+ )
349
+ problems = []
350
+ if missing:
351
+ problems.append(
352
+ f"missing valence bucket(s) {' '.join(missing)} — tiny swaps whole, so a "
353
+ f"{'/'.join(missing)} message reply raises KeyError"
354
+ )
355
+ if unspeakable:
356
+ problems.append(
357
+ f"bucket(s) {' '.join(unspeakable)} have no speakable (non-*...*) line — the "
358
+ "utterance path filters asterisk body lines and would divide by zero"
359
+ )
360
+ if problems:
361
+ report.add(f"overlay tiny [{species_id}]", ERROR, "; ".join(problems))
362
+ else:
363
+ report.add(
364
+ f"overlay tiny [{species_id}]",
365
+ PASS,
366
+ "tiny carries all three valence buckets, each with a speakable line "
367
+ "(the one table that swaps whole)",
368
+ )
369
+
370
+ # sparsity — informational; body/action/spot fall through per cell by design.
371
+ pinned = len(overlay.get("body", {}))
372
+ pinned += sum(len(cells) for cells in overlay.get("action", {}).values())
373
+ pinned += sum(len(cells) for cells in overlay.get("spot", {}).values())
374
+ pinned += len(tiny or {})
375
+ universe = 9 + 9 * len(environment.action_ids) + 9 * len(environment.spot_ids) + 3
376
+ if not overlay.get("body"):
377
+ report.add(
378
+ f"overlay sparsity [{species_id}]",
379
+ WARN,
380
+ "pins no body cells — the ambient fallback voice (the lines heard most) is "
381
+ "entirely the base dog tables",
382
+ )
383
+ else:
384
+ report.add(
385
+ f"overlay sparsity [{species_id}]",
386
+ PASS,
387
+ f"pins {pinned} of {universe} phrase cells; the rest fall through to the base "
388
+ "tables by design",
389
+ )
390
+
391
+
392
+ # ────────── the run ──────────
393
+
394
+
395
+ def lint_pack(path: str | Path) -> LintReport:
396
+ """Lint one pack directory without mutating it or application state."""
397
+ report = LintReport(path=Path(path))
398
+ try:
399
+ validated = validate_pack(path)
400
+ except PackError as exc:
401
+ report.add(
402
+ "load",
403
+ ERROR,
404
+ f"{type(exc).__name__}: {exc} — the loader refuses this pack; fix the gate "
405
+ "before any authoring check can run",
406
+ )
407
+ return report
408
+
409
+ record = validated.record
410
+ report.name = record.name
411
+ report.version = record.version
412
+ report.add(
413
+ "load",
414
+ PASS,
415
+ f"{record.name} v{record.version} passes every loader gate "
416
+ f"(species: {', '.join(record.species) or 'none'}; "
417
+ f"quirks: {', '.join(record.quirks) or 'none'}; "
418
+ f"overlays: {', '.join(record.overlays) or 'none'})",
419
+ )
420
+ report.add(
421
+ "manifest",
422
+ PASS,
423
+ f"author {record.author} · license {record.license} · fx vocab "
424
+ f"v{record.fx_vocab_version} (v1 has no description key — unknown manifest "
425
+ "keys are a loader error)",
426
+ )
427
+
428
+ per_species = validated.species
429
+ raw_svgs = validated.raw_svgs
430
+ overlays = {
431
+ sid: validated.overlays.get(sid, {}).get("tables")
432
+ for sid in set(record.species) | set(record.overlays)
433
+ }
434
+ voice = validated.voice or {}
435
+ pack_coat_labels = voice.get("coat_labels", {})
436
+ if not isinstance(pack_coat_labels, dict):
437
+ pack_coat_labels = {}
438
+ coat_labels = validated.environment.coat_label_ids | frozenset(pack_coat_labels)
439
+ missing_labels = [
440
+ f"{sid}:{coat}"
441
+ for sid in record.species
442
+ for coat in per_species[sid]["coats"]
443
+ if coat not in coat_labels
444
+ ]
445
+ quirk_voice = voice.get("quirks", {})
446
+ if not isinstance(quirk_voice, dict):
447
+ quirk_voice = {}
448
+ previews = quirk_voice.get("previews", {})
449
+ moods = quirk_voice.get("moods", {})
450
+ if not isinstance(previews, dict):
451
+ previews = {}
452
+ if not isinstance(moods, dict):
453
+ moods = {}
454
+ missing_previews = [q for q in record.quirks if q not in previews]
455
+ missing_moods = [q for q in record.quirks if q not in moods]
456
+
457
+ for sid in record.species:
458
+ _check_rig(report, sid, per_species[sid]["figure"])
459
+ _check_sanitizer_drops(report, sid, raw_svgs[sid])
460
+ _check_geometry(report, sid, per_species[sid]["geometry"])
461
+ for sid in sorted(overlays):
462
+ _check_overlay(report, sid, overlays[sid], validated.environment)
463
+
464
+ if missing_labels:
465
+ report.add(
466
+ "voice coats",
467
+ WARN,
468
+ f"no coat_labels entry for {', '.join(missing_labels)} — the coat picker "
469
+ "shows the raw id",
470
+ )
471
+ else:
472
+ report.add("voice coats", PASS, "every pack coat id has a coat_labels entry")
473
+ quirk_gaps = []
474
+ if missing_previews:
475
+ quirk_gaps.append(f"no preview copy for {', '.join(missing_previews)}")
476
+ if missing_moods:
477
+ quirk_gaps.append(f"no mood label for {', '.join(missing_moods)}")
478
+ if quirk_gaps:
479
+ report.add(
480
+ "voice quirks",
481
+ WARN,
482
+ "; ".join(quirk_gaps) + " — the adopt screens fall back to the generic habit lines",
483
+ )
484
+ else:
485
+ report.add(
486
+ "voice quirks",
487
+ PASS,
488
+ "every pack quirk has preview + mood copy" if record.quirks else "no pack quirks",
489
+ )
490
+ return report
491
+
492
+
493
+ def main(argv: list[str] | None = None, *, prog: str | None = None) -> int:
494
+ parser = argparse.ArgumentParser(
495
+ prog=prog,
496
+ description="lint a content pack (pack format v1) — validation plus authoring checks"
497
+ )
498
+ parser.add_argument("pack", type=Path, help="pack directory (e.g. packs/purl)")
499
+ parser.add_argument(
500
+ "--strict",
501
+ action="store_true",
502
+ help="exit 1 on WARN as well as ERROR",
503
+ )
504
+ args = parser.parse_args(argv)
505
+
506
+ report = lint_pack(args.pack.expanduser())
507
+ for finding in report.findings:
508
+ print(f"{finding.severity:<5} {finding.check} — {finding.message}")
509
+
510
+ counts = {severity: report.count(severity) for severity in (PASS, WARN, ERROR)}
511
+ name = f"{report.name} v{report.version}" if report.name else str(args.pack)
512
+ if counts[ERROR]:
513
+ verdict = "FAILS lint"
514
+ elif counts[WARN]:
515
+ verdict = "lints, with warnings"
516
+ else:
517
+ verdict = "lints clean"
518
+ print(
519
+ f"{name}: {counts[PASS]} PASS · {counts[WARN]} WARN · "
520
+ f"{counts[ERROR]} ERROR — {verdict}"
521
+ )
522
+ return report.exit_code(strict=args.strict)
523
+
524
+
525
+ __all__ = [
526
+ "ERROR",
527
+ "PASS",
528
+ "WARN",
529
+ "LintFinding",
530
+ "LintReport",
531
+ "lint_pack",
532
+ "main",
533
+ ]