@event4u/agent-config 1.25.0 → 1.27.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.
@@ -0,0 +1,383 @@
1
+ # iOS Simulator Guide
2
+
3
+ > Decision matrix and reference modules for driving the iOS Simulator
4
+ > from the command line — `simctl`, `idb`, accessibility-driven
5
+ > testing, and known troubleshooting paths.
6
+
7
+ ## Scope and audience
8
+
9
+ - Reference material for any work touching the iOS Simulator on macOS
10
+ hosts: smoke tests, accessibility audits, visual regressions, bug
11
+ capture, multi-device test sweeps.
12
+ - Intended companions: `react-native-setup` skill (environment),
13
+ `mobile-e2e-strategy` skill (framework selection), `playwright-testing`
14
+ / `e2e-plan` skills (cross-platform E2E strategy).
15
+ - **macOS-only:** Xcode + simctl + (optional) idb require a macOS host.
16
+ On Linux/Windows this guideline is reference-only — no implementation
17
+ recipes are portable.
18
+
19
+ ## When to consult this guideline
20
+
21
+ - Picking a simulator interaction surface (simctl vs idb vs xcodebuild).
22
+ - Auditing iOS UI accessibility for a release.
23
+ - Driving the simulator from CI for smoke or visual regression tests.
24
+ - Diagnosing a stuck simulator, missing target, or empty accessibility tree.
25
+
26
+ ## Decision matrix — interaction surface
27
+
28
+ | Surface | Use when | Avoid when |
29
+ |---|---|---|
30
+ | `xcrun simctl` | Boot/install/launch/screenshot/log capture; default for everything CLI-driven | Need accessibility tree or precise UI coordinates |
31
+ | `idb` (Facebook iOS Debug Bridge) | Accessibility-tree dumps, coordinate taps/swipes/text input, point-level inspection | Plain boot/launch tasks (simctl is lighter) |
32
+ | `xcodebuild` / `xcodebuild test` | Compile, sign, and run XCTest / XCUITest suites; CI integration | Ad-hoc scripted interaction (slow, heavyweight) |
33
+ | Direct UI Automation (XCUITest) | Native iOS app E2E with full Apple toolchain support | Cross-platform E2E (use Detox / Appium / Maestro — see `mobile-e2e-strategy`) |
34
+
35
+ **Rule of thumb:** start with `simctl`; reach for `idb` only when you
36
+ need accessibility-tree introspection or coordinate-level UI control.
37
+
38
+ ## Authoritative upstream
39
+
40
+ This guideline inlines five reference modules **verbatim** from the
41
+ upstream `conorluddy/ios-simulator-skill` repository. The 21 Python
42
+ helper scripts that ship with the upstream skill (~8500 LOC, macOS-
43
+ and Xcode-bound) are **not forked** — script references inside the
44
+ modules below resolve against the upstream tree, not this suite.
45
+
46
+ - Upstream repo: `https://github.com/conorluddy/ios-simulator-skill`
47
+ - Pinned SHA: `3acd0717a1b571b1d051559c01ff230d6da28a05`
48
+ - Last checked: 2026-05-08
49
+ - Refresh trigger: quarterly review or sooner if any link 404s in CI.
50
+
51
+ When you need an upstream Python helper (`accessibility_audit.py`,
52
+ `visual_diff.py`, `app_state_capture.py`, `test_recorder`) clone the
53
+ upstream repo at the pinned SHA, run the helper from there, do **not**
54
+ copy it into a consumer project.
55
+
56
+ ---
57
+
58
+ ## Module 1 — iOS Accessibility Checklist
59
+
60
+ _Verbatim from `references/accessibility_checklist.md` at the pinned SHA above._
61
+
62
+ ### Critical Rules (Must Fix)
63
+
64
+ #### 1. Interactive elements need labels
65
+ **Check:** `accessibilityLabel != nil`
66
+ **Fix:** Add descriptive label
67
+
68
+ #### 2. Buttons need text
69
+ **Check:** `label || value != ""`
70
+ **Fix:** Set button title or accessibilityLabel
71
+
72
+ #### 3. Images need descriptions
73
+ **Check:** `isImage && accessibilityLabel`
74
+ **Fix:** Add alt text via accessibilityLabel
75
+
76
+ ### Warnings (Should Fix)
77
+
78
+ #### 4. Complex controls need hints
79
+ **Check:** `accessibilityHint for custom controls`
80
+ **Fix:** Explain what happens on activation
81
+
82
+ #### 5. Grouped elements need containers
83
+ **Check:** `isAccessibilityElement on containers`
84
+ **Fix:** Group related elements
85
+
86
+ #### 6. Text fields need placeholders
87
+ **Check:** `placeholder || accessibilityLabel`
88
+ **Fix:** Add placeholder text
89
+
90
+ ### Info (Nice to Have)
91
+
92
+ #### 7. Automation identifiers
93
+ **Check:** `accessibilityIdentifier != nil`
94
+ **Fix:** Add for UI testing
95
+
96
+ #### 8. Trait specification
97
+ **Check:** `accessibilityTraits set correctly`
98
+ **Fix:** Use .button, .link, .header appropriately
99
+
100
+ #### 9. Frame size adequate
101
+ **Check:** `frame.width >= 44 && frame.height >= 44`
102
+ **Fix:** Minimum touch target 44x44pt
103
+
104
+ ### Quick Audit Command
105
+
106
+ ```bash
107
+ python scripts/accessibility_audit.py
108
+ ```
109
+
110
+ ### iOS Code Fixes
111
+
112
+ ```swift
113
+ // Label
114
+ button.accessibilityLabel = "Submit form"
115
+
116
+ // Hint
117
+ slider.accessibilityHint = "Adjusts volume"
118
+
119
+ // Identifier
120
+ view.accessibilityIdentifier = "login-button"
121
+
122
+ // Traits
123
+ label.accessibilityTraits = .header
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Module 2 — IDB Quick Reference
129
+
130
+ _Verbatim from `references/idb_quick.md` at the pinned SHA above._
131
+
132
+ ### UI Automation Commands
133
+
134
+ #### ui describe-all
135
+ **Usage:** `idb ui describe-all --json --nested`
136
+ **Output:** Complete accessibility tree
137
+ **Key:** Foundation for accessibility auditing
138
+
139
+ #### ui tap
140
+ **Usage:** `idb ui tap <x> <y>`
141
+ **Output:** None (success) or error
142
+
143
+ #### ui swipe
144
+ **Usage:** `idb ui swipe <x1> <y1> <x2> <y2>`
145
+ **Output:** None (success) or error
146
+
147
+ #### ui text
148
+ **Usage:** `idb ui text "<text>"`
149
+ **Output:** None (success) or error
150
+
151
+ #### ui describe-point
152
+ **Usage:** `idb ui describe-point <x> <y> --json`
153
+ **Output:** Element at coordinates
154
+
155
+ ### Other Essential Commands
156
+
157
+ #### list-targets
158
+ **Usage:** `idb list-targets`
159
+ **Output:** Available simulators with UDIDs
160
+
161
+ #### screenshot
162
+ **Usage:** `idb screenshot --udid <udid> output.png`
163
+ **Output:** PNG file saved
164
+
165
+ #### list-apps
166
+ **Usage:** `idb list-apps --udid <udid>`
167
+ **Output:** Installed apps with bundle IDs
168
+
169
+ ### Common Patterns
170
+
171
+ ```bash
172
+ # Get accessibility tree
173
+ idb ui describe-all --json --nested > tree.json
174
+
175
+ # Basic interaction
176
+ idb ui tap 200 400
177
+ idb ui text "username@example.com"
178
+ idb ui tap 200 500 # Submit button
179
+ ```
180
+
181
+ ### Troubleshooting
182
+ See Module 5 below.
183
+
184
+ ---
185
+
186
+ ## Module 3 — simctl Quick Reference
187
+
188
+ _Verbatim from `references/simctl_quick.md` at the pinned SHA above._
189
+
190
+ ### Essential Commands Only
191
+
192
+ #### list devices
193
+ **Usage:** `xcrun simctl list devices`
194
+ **Output:** Device list with UDIDs and states
195
+ **Key:** Use `booted` as UDID for current device
196
+
197
+ #### boot
198
+ **Usage:** `xcrun simctl boot <device-udid>`
199
+ **Output:** None (success) or error
200
+
201
+ #### launch
202
+ **Usage:** `xcrun simctl launch booted <bundle-id>`
203
+ **Output:** PID of launched app
204
+
205
+ #### install
206
+ **Usage:** `xcrun simctl install booted <app-path>`
207
+ **Output:** None (success) or error
208
+
209
+ #### io screenshot
210
+ **Usage:** `xcrun simctl io booted screenshot <file.png>`
211
+ **Output:** PNG file saved
212
+ **Options:** `--type=png|jpeg` (default: png)
213
+
214
+ #### io recordVideo
215
+ **Usage:** `xcrun simctl io booted recordVideo <file.mp4>`
216
+ **Output:** Video file (Ctrl+C to stop)
217
+ **Options:** `--codec=h264|hevc` (default: hevc)
218
+
219
+ #### get_app_container
220
+ **Usage:** `xcrun simctl get_app_container booted <bundle-id> data`
221
+ **Output:** Path to app's data directory
222
+
223
+ #### spawn log
224
+ **Usage:** `xcrun simctl spawn booted log stream --predicate 'process == "<app>"'`
225
+ **Output:** Live log stream
226
+
227
+ ### Common Patterns
228
+
229
+ ```bash
230
+ # Get booted device UDID
231
+ xcrun simctl list devices | grep Booted
232
+
233
+ # Quick app test
234
+ xcrun simctl boot <udid>
235
+ xcrun simctl install booted app.app
236
+ xcrun simctl launch booted com.example.app
237
+ xcrun simctl io booted screenshot test.png
238
+ ```
239
+
240
+ ### Troubleshooting
241
+ See Module 5 below.
242
+
243
+ ---
244
+
245
+ ## Module 4 — Test Patterns
246
+
247
+ _Verbatim from `references/test_patterns.md` at the pinned SHA above._
248
+
249
+ ### Smoke Test
250
+ ```bash
251
+ xcrun simctl boot <udid>
252
+ xcrun simctl launch booted <bundle-id>
253
+ python scripts/accessibility_audit.py
254
+ xcrun simctl io booted screenshot smoke.png
255
+ ```
256
+
257
+ ### Visual Regression
258
+ ```bash
259
+ # Baseline
260
+ xcrun simctl io booted screenshot baseline.png
261
+
262
+ # After changes
263
+ xcrun simctl io booted screenshot current.png
264
+ python scripts/visual_diff.py baseline.png current.png
265
+ ```
266
+
267
+ ### Full Accessibility Audit
268
+ ```bash
269
+ # Each screen
270
+ for screen in home login settings; do
271
+ # Navigate to screen (app-specific)
272
+ python scripts/accessibility_audit.py --output $screen.json
273
+ done
274
+ ```
275
+
276
+ ### Bug Report Capture
277
+ ```bash
278
+ python scripts/app_state_capture.py \
279
+ --app-bundle-id com.example.app \
280
+ --output bug-report/
281
+ ```
282
+
283
+ ### Multi-Device Test
284
+ ```bash
285
+ for device in "iPhone 15" "iPad Pro"; do
286
+ udid=$(xcrun simctl create test-$device "$device")
287
+ xcrun simctl boot $udid
288
+ xcrun simctl install $udid app.app
289
+ xcrun simctl launch $udid com.example.app
290
+ xcrun simctl io $udid screenshot $device.png
291
+ xcrun simctl delete $udid
292
+ done
293
+ ```
294
+
295
+ ### Performance Baseline
296
+ ```bash
297
+ # Capture initial state
298
+ xcrun simctl io booted screenshot perf-before.png
299
+ # Run performance test
300
+ xcrun simctl launch booted com.example.app
301
+ sleep 5
302
+ xcrun simctl io booted screenshot perf-after.png
303
+ python scripts/visual_diff.py perf-before.png perf-after.png
304
+ ```
305
+
306
+ ### Login Flow Test
307
+ ```python
308
+ from scripts.test_recorder import TestRecorder
309
+
310
+ rec = TestRecorder("Login Test")
311
+ rec.step("Launch app")
312
+ # idb ui tap 200 400 # Login button
313
+ rec.step("Enter credentials")
314
+ # idb ui text "user@example.com"
315
+ rec.step("Submit")
316
+ # idb ui tap 200 500
317
+ rec.generate_report()
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Module 5 — Troubleshooting
323
+
324
+ _Verbatim from `references/troubleshooting.md` at the pinned SHA above._
325
+
326
+ ### Problem → Solution Format
327
+
328
+ #### Simulator won't boot
329
+ **Fix:** `killall Simulator && xcrun simctl erase <udid>`
330
+
331
+ #### IDB not connecting
332
+ **Fix:** `idb kill && idb companion --boot-status-check`
333
+
334
+ #### App won't launch
335
+ **Fix:** `xcrun simctl terminate booted <bundle-id> && xcrun simctl launch booted <bundle-id>`
336
+
337
+ #### Screenshot fails
338
+ **Fix:** Ensure simulator booted: `xcrun simctl boot <udid>`
339
+
340
+ #### "No booted devices"
341
+ **Fix:** `open -a Simulator` or `xcrun simctl boot <udid>`
342
+
343
+ #### IDB "Target not found"
344
+ **Fix:** `idb list-targets` to verify UDID
345
+
346
+ #### Permission denied
347
+ **Fix:** `chmod +x scripts/*.sh`
348
+
349
+ #### Python module not found
350
+ **Fix:** `pip3 install pillow` (for visual_diff.py)
351
+
352
+ #### Accessibility tree empty
353
+ **Fix:** App must be in foreground: `xcrun simctl launch booted <bundle-id>`
354
+
355
+ #### Video recording hangs
356
+ **Fix:** Ctrl+C to stop recording, file saves on interrupt
357
+
358
+ #### Logs not showing
359
+ **Fix:** Use correct app name: `xcrun simctl spawn booted log stream --predicate 'process == "AppName"'`
360
+
361
+ #### Device storage full
362
+ **Fix:** `xcrun simctl erase <udid>` (warning: deletes all data)
363
+
364
+ ### Quick Diagnostics
365
+
366
+ ```bash
367
+ # Check simulator state
368
+ xcrun simctl list devices | grep Booted
369
+
370
+ # Verify IDB connection
371
+ idb list-targets
372
+
373
+ # Test basic interaction
374
+ xcrun simctl io booted screenshot test.png
375
+ ```
376
+
377
+ ## Source attribution
378
+
379
+ Modules 1–5 above are reproduced verbatim from
380
+ `conorluddy/ios-simulator-skill` (MIT License) at SHA
381
+ `3acd0717a1b571b1d051559c01ff230d6da28a05`. Header levels were
382
+ demoted by one to integrate with this guideline's outline; module
383
+ content (text, code, command examples) is unchanged.
@@ -33,10 +33,14 @@ Size is a signal — not the goal.
33
33
  - Acceptable: **< 100–120 lines**
34
34
  - Hard limit: **< 200 lines**
35
35
 
36
- Linter (council review 2026-05-06): the > 40 / > 60 line warnings are
37
- **density-gated** — rules with ≥ 30 % fenced content (verbatim Iron-Law
38
- blocks, worked-example fences) are exempt from the line-count warning.
39
- The 200-line hard error stays unconditional.
36
+ Linter (structural model, 2026-05-08 see
37
+ [`docs/contracts/linter-structural-model.md`](../../contracts/linter-structural-model.md)):
38
+ the long-rule warning fires only when the rule is **> 60 non-empty
39
+ lines AND density < 0.50 AND ships no Iron-Law block**. Rules whose
40
+ body is a verbatim ALL-CAPS imperative (`commit-policy`,
41
+ `ask-when-uncertain`, `direct-answers`) are auto-exempt — no
42
+ frontmatter flag required. The 200-line hard error stays
43
+ unconditional.
40
44
 
41
45
  Reason:
42
46
  - Loaded frequently
@@ -48,10 +52,11 @@ Reason:
48
52
  ## Skills
49
53
 
50
54
  - Target: **300–900 words**
51
- - Warning: **> 400 lines** (raised from 300, council review 2026-05-06)
52
- - Strong split signal: reference-rich skills (analyzer, quality-tool
53
- catalog, council orchestration) may legitimately sit between 300 and
54
- 400 lines without being split-candidates
55
+ - Warning: **> 400 lines AND (density < 0.60 OR 2 `## Procedure`
56
+ blocks)** structural model, 2026-05-08
57
+ - Reference-rich skills with high density (`quality-tools` at 0.83,
58
+ catalogue-style skills) pass without splitting; the multi-procedure
59
+ trigger flags genuine cluster-split candidates regardless of size
55
60
 
56
61
  Focus:
57
62
  - scanability
@@ -64,10 +69,11 @@ Focus:
64
69
 
65
70
  - Target: **200–600 words**
66
71
  - Acceptable: **up to ~1000 words**
67
- - Warning: **> 1000 words AND lacks delegation structure** (< 5
68
- sub-sections OR < 3 code blocks). Well-factored orchestrators with 5
69
- sub-sections AND 3 code blocks are exempt the size reflects
70
- dispatch breadth, not bloat (council review 2026-05-06).
72
+ - Warning: **> 1000 words AND no delegation signal AND density < 0.65**
73
+ structural model, 2026-05-08. A delegation signal is either
74
+ frontmatter (`cluster:` / `routes_to:`) OR 3 markdown links to
75
+ other `.md` files. Well-factored orchestrators pass automatically;
76
+ inlined logic in a non-orchestrator command warns.
71
77
 
72
78
  Commands orchestrate — not implement.
73
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """Measure structural density across the artifact corpus.
3
+
4
+ Phase 1.1 of `agents/roadmaps/road-to-structural-linter-reform.md`.
5
+
6
+ Density score = structured_lines / total_lines, where structured_lines
7
+ sum lines inside fenced blocks + markdown-table rows + bullet lines +
8
+ numbered/ordered-list lines + section-heading lines. Higher = more
9
+ structured (catalogue, orchestrator, Iron-Law block); lower = prose-
10
+ dominant.
11
+
12
+ Companion signals collected per artifact (consumed by Phases 1.2-1.4):
13
+
14
+ - ``multi_workflow`` ≥ 2 ``## Procedure`` (or ``## Procedure: …``)
15
+ blocks in a skill — candidate for cluster split.
16
+ - ``delegation`` command frontmatter has ``cluster:`` or
17
+ ``routes_to:``, or the body links to ≥ 3 other
18
+ commands/skills via ``](...md)``.
19
+ - ``iron_law_block`` ≥ 1 fenced block whose body is ≥ 60 % ALL-CAPS
20
+ across ≥ 3 non-empty lines.
21
+
22
+ Output:
23
+ - Default stdout: per-type distribution buckets + tail (lowest density).
24
+ - ``--json`` deterministic JSON of every artifact.
25
+ - ``--snapshot`` writes JSONL to ``agents/.density-snapshot.jsonl``.
26
+
27
+ Stdlib only; no network. Re-runnable.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import re
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import Any, Dict, List
37
+
38
+ REPO_ROOT = Path(__file__).resolve().parent.parent
39
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
40
+
41
+ from skill_linter import ( # noqa: E402
42
+ detect_artifact_type,
43
+ extract_frontmatter,
44
+ gather_all_candidate_files,
45
+ )
46
+
47
+ SNAPSHOT_FILE = REPO_ROOT / "agents" / ".density-snapshot.jsonl"
48
+
49
+ _TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
50
+ _BULLET = re.compile(r"^\s*[-*]\s+\S")
51
+ _NUMBERED = re.compile(r"^\s*\d+\.\s+\S")
52
+ _HEADING = re.compile(r"^\s{0,3}#{1,6}\s+\S")
53
+ _PROCEDURE = re.compile(r"^##\s+Procedure(\s*:.*)?\s*$", re.MULTILINE)
54
+ _LINK_MD = re.compile(r"\]\([^)]+\.md[^)]*\)")
55
+ _FRONTMATTER_KEY = re.compile(r"^(cluster|routes_to)\s*:", re.MULTILINE)
56
+ _ALLCAPS_LINE = re.compile(r"[A-Z]")
57
+
58
+
59
+ def _classify_lines(text: str) -> Dict[str, int]:
60
+ """Bucket every non-blank line into one structural category."""
61
+ inside_fence = False
62
+ counts = {
63
+ "total": 0,
64
+ "fenced": 0,
65
+ "table": 0,
66
+ "bullet": 0,
67
+ "numbered": 0,
68
+ "heading": 0,
69
+ "prose": 0,
70
+ }
71
+ for raw in text.splitlines():
72
+ stripped = raw.strip()
73
+ if stripped.startswith("```"):
74
+ inside_fence = not inside_fence
75
+ counts["total"] += 1
76
+ counts["fenced"] += 1
77
+ continue
78
+ if not stripped:
79
+ continue
80
+ counts["total"] += 1
81
+ if inside_fence:
82
+ counts["fenced"] += 1
83
+ elif _TABLE_ROW.match(raw):
84
+ counts["table"] += 1
85
+ elif _HEADING.match(raw):
86
+ counts["heading"] += 1
87
+ elif _BULLET.match(raw):
88
+ counts["bullet"] += 1
89
+ elif _NUMBERED.match(raw):
90
+ counts["numbered"] += 1
91
+ else:
92
+ counts["prose"] += 1
93
+ return counts
94
+
95
+
96
+ def _detect_iron_law_blocks(text: str) -> int:
97
+ """Count fenced blocks that look like verbatim Iron-Law imperatives.
98
+
99
+ Heuristic: fenced block with ≥ 1 non-empty line whose alphabetical
100
+ body is ≥ 60 % uppercase AND has ≥ 30 letters total (filters single
101
+ short ALL-CAPS markers like ``OK``). Also matches blockquote-style
102
+ Iron Laws (``> NEVER COMMIT``).
103
+ """
104
+ blocks = 0
105
+ inside = False
106
+ body: list[str] = []
107
+ for raw in text.splitlines():
108
+ if raw.strip().startswith("```"):
109
+ if inside and body:
110
+ non_empty = [b for b in body if b.strip()]
111
+ letters = "".join(non_empty)
112
+ upper = sum(1 for c in letters if c.isalpha() and c.isupper())
113
+ total = sum(1 for c in letters if c.isalpha())
114
+ if total >= 30 and upper / total >= 0.6 and non_empty:
115
+ blocks += 1
116
+ inside = not inside
117
+ body = []
118
+ continue
119
+ if inside:
120
+ body.append(raw)
121
+ return blocks
122
+
123
+
124
+ def _count_procedures(text: str) -> int:
125
+ return len(_PROCEDURE.findall(text))
126
+
127
+
128
+ def _delegation_signal(text: str, frontmatter: str | None) -> Dict[str, Any]:
129
+ fm_keys = bool(frontmatter and _FRONTMATTER_KEY.search(frontmatter))
130
+ md_links = len(_LINK_MD.findall(text))
131
+ return {"frontmatter_routes": fm_keys, "md_links": md_links,
132
+ "has_signal": fm_keys or md_links >= 3}
133
+
134
+
135
+ def measure(path: Path) -> Dict[str, Any]:
136
+ text = path.read_text(encoding="utf-8")
137
+ rel = path.relative_to(REPO_ROOT) if path.is_absolute() else path
138
+ artifact_type = detect_artifact_type(rel, text)
139
+ frontmatter = extract_frontmatter(text)
140
+ counts = _classify_lines(text)
141
+ structured = counts["fenced"] + counts["table"] + counts["bullet"] + \
142
+ counts["numbered"] + counts["heading"]
143
+ density = structured / counts["total"] if counts["total"] else 0.0
144
+ return {
145
+ "file": str(rel),
146
+ "type": artifact_type,
147
+ "lines": counts["total"],
148
+ "words": len(text.split()),
149
+ "density": round(density, 3),
150
+ "fenced": counts["fenced"],
151
+ "table": counts["table"],
152
+ "bullet": counts["bullet"],
153
+ "numbered": counts["numbered"],
154
+ "heading": counts["heading"],
155
+ "prose": counts["prose"],
156
+ "iron_law_blocks": _detect_iron_law_blocks(text),
157
+ "procedures": _count_procedures(text),
158
+ "delegation": _delegation_signal(text, frontmatter),
159
+ }
160
+
161
+
162
+ def collect() -> List[Dict[str, Any]]:
163
+ paths = gather_all_candidate_files(REPO_ROOT)
164
+ return [measure(p) for p in paths]
165
+
166
+
167
+ def _bucketize(values: List[float]) -> Dict[str, int]:
168
+ buckets = {"0.0-0.2": 0, "0.2-0.4": 0, "0.4-0.6": 0,
169
+ "0.6-0.8": 0, "0.8-1.0": 0}
170
+ for v in values:
171
+ if v < 0.2:
172
+ buckets["0.0-0.2"] += 1
173
+ elif v < 0.4:
174
+ buckets["0.2-0.4"] += 1
175
+ elif v < 0.6:
176
+ buckets["0.4-0.6"] += 1
177
+ elif v < 0.8:
178
+ buckets["0.6-0.8"] += 1
179
+ else:
180
+ buckets["0.8-1.0"] += 1
181
+ return buckets
182
+
183
+
184
+ def report(results: List[Dict[str, Any]]) -> str:
185
+ by_type: Dict[str, List[Dict[str, Any]]] = {}
186
+ for r in results:
187
+ by_type.setdefault(r["type"], []).append(r)
188
+ lines: List[str] = ["# Structural Density Snapshot", "",
189
+ f"Total artifacts: {len(results)}", ""]
190
+ for t in sorted(by_type):
191
+ rows = by_type[t]
192
+ densities = [r["density"] for r in rows]
193
+ avg = sum(densities) / len(densities) if densities else 0.0
194
+ med = sorted(densities)[len(densities) // 2] if densities else 0.0
195
+ buckets = _bucketize(densities)
196
+ lines.append(f"## {t} ({len(rows)} artifacts)")
197
+ lines.append(f"avg density={avg:.2f} median={med:.2f}")
198
+ lines.append("buckets " + " ".join(
199
+ f"[{k}]={v}" for k, v in buckets.items()))
200
+ tail = sorted(rows, key=lambda r: r["density"])[:5]
201
+ lines.append("lowest density:")
202
+ for r in tail:
203
+ lines.append(f" {r['density']:.2f} {r['lines']:>4}L "
204
+ f"proc={r['procedures']} "
205
+ f"iron={r['iron_law_blocks']} "
206
+ f"deleg={int(r['delegation']['has_signal'])} "
207
+ f"{r['file']}")
208
+ lines.append("")
209
+ return "\n".join(lines)
210
+
211
+
212
+ def main() -> int:
213
+ p = argparse.ArgumentParser()
214
+ p.add_argument("--json", action="store_true")
215
+ p.add_argument("--snapshot", action="store_true",
216
+ help=f"write JSONL to {SNAPSHOT_FILE.relative_to(REPO_ROOT)}")
217
+ args = p.parse_args()
218
+ results = collect()
219
+ if args.snapshot:
220
+ SNAPSHOT_FILE.parent.mkdir(parents=True, exist_ok=True)
221
+ with SNAPSHOT_FILE.open("w", encoding="utf-8") as fh:
222
+ for r in sorted(results, key=lambda x: x["file"]):
223
+ fh.write(json.dumps(r, sort_keys=True) + "\n")
224
+ if args.json:
225
+ print(json.dumps(results, sort_keys=True, indent=2))
226
+ else:
227
+ print(report(results))
228
+ return 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ raise SystemExit(main())