create-cmp-cli 0.4.0 → 0.6.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.
@@ -27,22 +27,47 @@ class ArchitectureConformanceTest {
27
27
  private fun imports(file: File): List<String> =
28
28
  file.readLines().filter { it.trimStart().startsWith("import ") }.map { it.trim() }
29
29
 
30
+ /**
31
+ * Source lines with comment lines stripped. Layer-boundary rules scan these for BOTH
32
+ * `import x.y.` statements AND fully-qualified inline references (`x.y.Type(...)`) —
33
+ * import-only matching leaves a one-edit evasion open: delete the import, qualify the
34
+ * name inline, and the gate goes green while the violation remains.
35
+ */
36
+ private fun nonCommentLines(file: File): List<String> =
37
+ file.readLines().filterNot {
38
+ val t = it.trimStart()
39
+ t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")
40
+ }
41
+
42
+ private fun bannedReference(file: File, banned: List<String>): Boolean =
43
+ nonCommentLines(file).any { line -> banned.any { line.contains(it) } }
44
+
30
45
  private fun under(file: File, segment: String): Boolean =
31
46
  file.path.replace(File.separatorChar, '/').contains("/$segment/")
32
47
 
48
+ /**
49
+ * True when the file sits in a feature subpackage (presentation/<feature>/…) rather than
50
+ * at the presentation root (App.kt) — the scope for the composable-file rules.
51
+ */
52
+ private fun inPresentationFeatureDir(file: File): Boolean {
53
+ val rel = file.path.replace(File.separatorChar, '/').substringAfter("/presentation/", "")
54
+ return rel.isNotEmpty() && rel.contains('/')
55
+ }
56
+
33
57
  private fun violation(clause: String, rule: String, offenders: List<String>, fix: String): String =
34
58
  "[$clause] $rule\n Offending: ${offenders.joinToString("\n ")}\n Fix: $fix"
35
59
 
36
60
  // SPEC: ARCH-01
37
61
  @Test
38
- fun `ARCH-01 presentation never imports the data layer`() {
62
+ fun `ARCH-01 presentation never references the data layer`() {
39
63
  val offenders = sources(commonMain)
40
64
  .filter { under(it, "presentation") }
41
- .filter { file -> imports(file).any { it.startsWith("import __PACKAGE__.data.") } }
65
+ .filter { bannedReference(it, listOf("__PACKAGE__.data.")) }
42
66
  .map { it.path }
43
67
  if (offenders.isNotEmpty()) fail(
44
68
  violation(
45
- "ARCH-01", "presentation depends on domain only — it never imports the data layer.",
69
+ "ARCH-01", "presentation depends on domain only — it never references the data layer " +
70
+ "(neither imports nor fully-qualified inline names).",
46
71
  offenders,
47
72
  "depend on a domain interface (domain/repository) and let di/ wire the data implementation.",
48
73
  )
@@ -53,16 +78,17 @@ class ArchitectureConformanceTest {
53
78
  @Test
54
79
  fun `ARCH-02 domain is pure - no app layers, no frameworks`() {
55
80
  val banned = listOf(
56
- "import __PACKAGE__.presentation.", "import __PACKAGE__.data.", "import __PACKAGE__.di.",
57
- "import androidx.compose.", "import org.koin.",
81
+ "__PACKAGE__.presentation.", "__PACKAGE__.data.", "__PACKAGE__.di.",
82
+ "androidx.compose.", "org.koin.",
58
83
  )
59
84
  val offenders = sources(commonMain)
60
85
  .filter { under(it, "domain") }
61
- .filter { file -> imports(file).any { imp -> banned.any { imp.startsWith(it) } } }
86
+ .filter { bannedReference(it, banned) }
62
87
  .map { it.path }
63
88
  if (offenders.isNotEmpty()) fail(
64
89
  violation(
65
- "ARCH-02", "domain imports nothing app-internal and no UI/DI frameworks.",
90
+ "ARCH-02", "domain references nothing app-internal and no UI/DI frameworks " +
91
+ "(neither imports nor fully-qualified inline names).",
66
92
  offenders,
67
93
  "move framework-touching code out to presentation/data; domain stays pure Kotlin.",
68
94
  )
@@ -88,17 +114,23 @@ class ArchitectureConformanceTest {
88
114
 
89
115
  // SPEC: ARCH-04
90
116
  @Test
91
- fun `ARCH-04 every Screen composable declares a testTag`() {
117
+ fun `ARCH-04 every feature composable file declares a testTag`() {
118
+ // Scoped by CONTENT (contains @Composable), not by *Screen.kt filename: real apps
119
+ // split features into Screen.kt (often ViewModel-only) and Content.kt (the UI).
120
+ // Filename scoping produced both false negatives (untagged FooContent.kt slid
121
+ // through) and false positives (VM-only FooScreen.kt was flagged) in the field.
92
122
  val offenders = sources(commonMain)
93
- .filter { it.name != "Screen.kt" && it.name.endsWith("Screen.kt") }
94
- .filterNot { under(it, "components") || under(it, "navigation") }
123
+ .filter { inPresentationFeatureDir(it) }
124
+ .filterNot { under(it, "components") || under(it, "navigation") || under(it, "theme") }
125
+ .filter { it.readText().contains("@Composable") }
95
126
  .filterNot { it.readText().contains("testTag") }
96
127
  .map { it.path }
97
128
  if (offenders.isNotEmpty()) fail(
98
129
  violation(
99
- "ARCH-04", "every screen is automation-reachable: *Screen files declare at least one testTag.",
130
+ "ARCH-04", "every feature UI file is automation-reachable: files containing a " +
131
+ "@Composable declare at least one testTag.",
100
132
  offenders,
101
- "add Modifier.semantics { testTag = \"<feature>_<element>\" } to the screen's key nodes.",
133
+ "add Modifier.semantics { testTag = \"<feature>_<element>\" } to the file's key nodes.",
102
134
  )
103
135
  )
104
136
  }
@@ -120,6 +152,45 @@ class ArchitectureConformanceTest {
120
152
  )
121
153
  }
122
154
 
155
+ // SPEC: SHELL-05
156
+ @Test
157
+ fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
158
+ // SHELL-03 bans direct inset-API calls, but a destination that simply never handles
159
+ // insets at all (bare Column at the nav layer) passes that rule while rendering
160
+ // under the status bar. Tab screens are exempt — AppShell wraps them — so the rule
161
+ // targets exactly the destinations registered directly on the NavHost.
162
+ val navHost = sources(commonMain).firstOrNull { it.name == "AppNavHost.kt" } ?: return
163
+ val text = navHost.readText()
164
+ val screenCall = Regex("""([A-Z][A-Za-z0-9]*Screen)\s*\(""")
165
+ // A call with only a trailing lambda has no paren — `BaseScreen { … }` — so match both.
166
+ val baseScreenCall = Regex("""BaseScreen\s*[({]""")
167
+ val allSources = sources(commonMain)
168
+
169
+ val offenders = mutableListOf<String>()
170
+ val chunks = text.split("composable(").drop(1)
171
+ for (chunk in chunks) {
172
+ if (chunk.contains("AppShell(")) continue // shell destination: tabs inherit BaseScreen
173
+ for (m in screenCall.findAll(chunk)) {
174
+ val name = m.groupValues[1]
175
+ if (name == "BaseScreen") continue
176
+ val defining = allSources.firstOrNull { f ->
177
+ Regex("""fun\s+$name\s*\(""").containsMatchIn(f.readText())
178
+ } ?: continue
179
+ if (!baseScreenCall.containsMatchIn(defining.readText())) {
180
+ offenders.add("${defining.path} ($name is a NavHost destination without BaseScreen)")
181
+ }
182
+ }
183
+ }
184
+ if (offenders.isNotEmpty()) fail(
185
+ violation(
186
+ "SHELL-05", "every screen registered directly on the NavHost composes inside " +
187
+ "BaseScreen — otherwise it renders edge-to-edge with no inset handling.",
188
+ offenders.distinct(),
189
+ "wrap the destination's content in BaseScreen { … } (see DetailScreen).",
190
+ )
191
+ )
192
+ }
193
+
123
194
  // SPEC: SHELL-03
124
195
  @Test
125
196
  fun `SHELL-03 insets are owned by BaseScreen - screens never touch inset APIs`() {
@@ -94,9 +94,11 @@
94
94
  "enabledByDefault": true,
95
95
  "paths": [
96
96
  "composeApp/src/androidDebug/kotlin/com/example/app/inspector",
97
- "composeApp/src/androidRelease/kotlin/com/example/app/inspector"
97
+ "composeApp/src/androidRelease/kotlin/com/example/app/inspector",
98
+ "composeApp/src/desktopMain/kotlin/com/example/app/inspector",
99
+ "qa/preview-gallery.mjs"
98
100
  ],
99
- "notes": "Live on-device inspector: debug-only loopback HTTP server on 127.0.0.1:9500 (GET /inspect/health|tree|design-system) serving the semantics tree + design-token catalog to the cmp-inspector MCP via `adb forward tcp:9500 tcp:9500`. The androidRelease dir holds only the no-op startInspector() twin — release builds contain no inspector code structurally. When off: delete both inspector dirs AND strip the `inspector` marker blocks in AppApplication.kt (import + startInspector() call) and composeApp/src/androidDebug/AndroidManifest.xml (INTERNET permission). Zero dependencies (java.net.ServerSocket + kotlinx-serialization from commonMain); no build-file markers needed."
101
+ "notes": "Two loops in one feature. (1) Live on-device inspector: debug-only loopback HTTP server on 127.0.0.1:9500 (GET /inspect/health|tree|design-system) serving the semantics tree + design-token catalog to the cmp-inspector MCP via `adb forward tcp:9500 tcp:9500`. The androidRelease dir holds only the no-op startInspector() twin — release builds contain no inspector code structurally. (2) Headless preview harness (tier 0): the desktopMain inspector dir (PreviewRegistry/PreviewHarness/PreviewSemanticsJson) + the :composeApp:renderScreens task render every registered screen to PNG + contract tree JSON with no device; qa/preview-gallery.mjs builds a self-contained index.html from the output (vendored pure-logic render libs live in qa/lib and stay when the feature is off — they are inert without the harness). The harness starts its OWN Koin (independent of the dev-client feature's DesktopModule) and its PreviewRegistry tab entries are regenerated from the configured `tabs` by the scaffolder (pipeline step b.3). When off: delete all three inspector dirs + qa/preview-gallery.mjs, strip the `inspector` marker blocks in AppApplication.kt (import + startInspector() call), composeApp/src/androidDebug/AndroidManifest.xml (INTERNET permission), and composeApp/build.gradle.kts (desktopMain compose.uiTest dep + the renderScreens task). On-device server: zero dependencies (java.net.ServerSocket + kotlinx-serialization from commonMain)."
100
102
  },
101
103
  "dev-client": {
102
104
  "enabledByDefault": true,
@@ -0,0 +1,104 @@
1
+ // a11y.mjs — accessibility audit over the tree contract.
2
+ // Pure logic only — no fs, no MCP imports; unit-testable.
3
+ //
4
+ // Rules (violations):
5
+ // touch-target-too-small — a clickable node whose width or height is below the
6
+ // minimum touch target (default 48px; the harness dumps
7
+ // at density 1 so px == dp there — pass a different
8
+ // minTouchTargetPx for device-density trees).
9
+ // missing-label — a clickable node with no text, no contentDescription,
10
+ // and no descendant text: nothing for a screen reader.
11
+ // Rules (warnings):
12
+ // empty-content-description — contentDescription === "" (redundant/empty; either
13
+ // label it or drop the attribute).
14
+ //
15
+ // Trees produced before the role/clickable/disabled contract extension are handled
16
+ // gracefully: nodes without `clickable` are simply skipped, never crashed on.
17
+
18
+ import { walk } from "./tree.mjs";
19
+
20
+ /**
21
+ * Audit a tree for accessibility faults.
22
+ *
23
+ * @param {object} tree a full tree ({root}) or bare node.
24
+ * @param {{minTouchTargetPx?: number}} [opts]
25
+ * @returns {{
26
+ * violations: Array<{path:string, testTag:string|null, rule:string, detail:string, bounds:object|null}>,
27
+ * warnings: Array<{path:string, testTag:string|null, rule:string, detail:string, bounds:object|null}>,
28
+ * warningCount: number,
29
+ * passCount: number
30
+ * }} passCount = clickable nodes that passed every check.
31
+ */
32
+ export function auditA11y(tree, opts = {}) {
33
+ const minTouchTargetPx =
34
+ typeof opts.minTouchTargetPx === "number" && opts.minTouchTargetPx > 0
35
+ ? opts.minTouchTargetPx
36
+ : 48;
37
+
38
+ const violations = [];
39
+ const warnings = [];
40
+ let passCount = 0;
41
+
42
+ for (const { node, path } of walk(tree)) {
43
+ const entryBase = {
44
+ path,
45
+ testTag: node.testTag ?? null,
46
+ bounds: node.bounds ?? null,
47
+ };
48
+
49
+ // Warn on redundant/empty contentDescription regardless of clickability.
50
+ if (node.contentDescription === "") {
51
+ warnings.push({
52
+ ...entryBase,
53
+ rule: "empty-content-description",
54
+ detail: 'contentDescription is an empty string ("") — either label the node or drop the attribute',
55
+ });
56
+ }
57
+
58
+ // Interactive checks only apply to nodes that self-report clickable:true.
59
+ // Old trees without the optional field are skipped gracefully.
60
+ if (node.clickable !== true) continue;
61
+
62
+ let violated = false;
63
+
64
+ const b = node.bounds;
65
+ if (
66
+ b &&
67
+ typeof b.width === "number" &&
68
+ typeof b.height === "number" &&
69
+ (b.width < minTouchTargetPx || b.height < minTouchTargetPx)
70
+ ) {
71
+ violations.push({
72
+ ...entryBase,
73
+ rule: "touch-target-too-small",
74
+ detail: `clickable node is ${b.width}x${b.height}px; minimum touch target is ${minTouchTargetPx}x${minTouchTargetPx}px`,
75
+ });
76
+ violated = true;
77
+ }
78
+
79
+ const hasOwnLabel =
80
+ (node.text != null && node.text !== "") ||
81
+ (node.contentDescription != null && node.contentDescription !== "");
82
+ if (!hasOwnLabel && !hasDescendantText(node)) {
83
+ violations.push({
84
+ ...entryBase,
85
+ rule: "missing-label",
86
+ detail: "clickable node has no text, no contentDescription, and no descendant text — invisible to screen readers",
87
+ });
88
+ violated = true;
89
+ }
90
+
91
+ if (!violated) passCount++;
92
+ }
93
+
94
+ return { violations, warnings, warningCount: warnings.length, passCount };
95
+ }
96
+
97
+ function hasDescendantText(node) {
98
+ for (const child of node.children || []) {
99
+ if (child.text != null && child.text !== "") return true;
100
+ if (child.contentDescription != null && child.contentDescription !== "") return true;
101
+ if (hasDescendantText(child)) return true;
102
+ }
103
+ return false;
104
+ }
@@ -0,0 +1,254 @@
1
+ // render.mjs — deterministic SVG wireframe of a CMP inspector tree.
2
+ //
3
+ // The "structural twin" of a pixel preview: every node with a non-zero visual
4
+ // footprint becomes a rect; token-annotated nodes are visually distinct and carry
5
+ // a small chip with their resolved values ("radius 16 · pad 16"); clickable nodes
6
+ // get a distinct outline; testTags render as small mono labels; text nodes show
7
+ // their text. An optional a11y audit result overlays violations in a danger style.
8
+ //
9
+ // SVG is structured TEXT, not pixels — safe for model context, works for ANY
10
+ // source (file / live / uiautomator). Output is fully deterministic: no dates,
11
+ // no randomness; the same tree + opts always yields byte-identical SVG.
12
+ //
13
+ // Pure logic only — no fs, no MCP imports; the server wires file I/O around it.
14
+
15
+ import { walk } from "./tree.mjs";
16
+
17
+ const FIT_WIDTH = 740; // target drawing width when no explicit scale is given
18
+ const MARGIN = 16;
19
+ const LEGEND_H = 30;
20
+ const FOOTER_H = 24;
21
+
22
+ const STYLE = {
23
+ plain: { fill: "none", stroke: "#9CA3AF", strokeWidth: 1, dash: null },
24
+ tokenized: { fill: "rgba(0,185,107,0.10)", stroke: "#00B96B", strokeWidth: 1.5, dash: null },
25
+ clickableStroke: "#2563EB",
26
+ dangerStroke: "#DC2626",
27
+ chipFill: "#0A2540",
28
+ chipText: "#FFFFFF",
29
+ tagText: "#6B7280",
30
+ nodeText: "#1A1A1A",
31
+ footerText: "#6B7280",
32
+ };
33
+
34
+ /**
35
+ * Render a tree (full {root} document or bare node) as an SVG wireframe string.
36
+ *
37
+ * @param {object} tree
38
+ * @param {object} [opts]
39
+ * @param {object} [opts.a11y] an auditA11y() result — its violations are overlaid
40
+ * in the danger style (matched to nodes by path).
41
+ * @param {number} [opts.maxDepth] only draw nodes up to this depth (root = 0).
42
+ * @param {number} [opts.scale] explicit px scale; default fits root width to ~740.
43
+ * @returns {string} the SVG document.
44
+ */
45
+ export function renderTreeSvg(tree, opts = {}) {
46
+ const root = tree && tree.root ? tree.root : tree;
47
+ if (!root || typeof root !== "object") {
48
+ throw new Error("renderTreeSvg: tree has no root node.");
49
+ }
50
+ const schemaVersion = (tree && tree.schemaVersion) ?? 1;
51
+ const source = (tree && tree.source) ?? "unknown";
52
+
53
+ const rootW = boundsDim(root.bounds, "width") || 360;
54
+ const rootH = boundsDim(root.bounds, "height") || 640;
55
+ const scale = typeof opts.scale === "number" && opts.scale > 0 ? opts.scale : FIT_WIDTH / rootW;
56
+ const maxDepth =
57
+ typeof opts.maxDepth === "number" && opts.maxDepth >= 0 ? opts.maxDepth : Infinity;
58
+
59
+ // Violations by node path (danger overlay).
60
+ const violationsByPath = new Map();
61
+ if (opts.a11y && Array.isArray(opts.a11y.violations)) {
62
+ for (const v of opts.a11y.violations) {
63
+ if (!violationsByPath.has(v.path)) violationsByPath.set(v.path, []);
64
+ violationsByPath.get(v.path).push(v.rule);
65
+ }
66
+ }
67
+
68
+ const drawW = rootW * scale;
69
+ const drawH = rootH * scale;
70
+ const svgW = Math.ceil(drawW + MARGIN * 2);
71
+ const svgH = Math.ceil(LEGEND_H + drawH + FOOTER_H + MARGIN * 2);
72
+ const originX = MARGIN;
73
+ const originY = LEGEND_H + MARGIN / 2;
74
+
75
+ let nodeCount = 0;
76
+ const body = [];
77
+
78
+ for (const { node, path } of walk(root)) {
79
+ nodeCount++;
80
+ if (depthOf(path) > maxDepth) continue;
81
+ const b = node.bounds;
82
+ const w = boundsDim(b, "width");
83
+ const h = boundsDim(b, "height");
84
+ if (!(w > 0 && h > 0)) continue; // zero-footprint nodes have nothing to draw
85
+
86
+ const x = originX + (b.x || 0) * scale;
87
+ const y = originY + (b.y || 0) * scale;
88
+ const sw = w * scale;
89
+ const sh = h * scale;
90
+ const tokenized = node.designToken != null;
91
+ const clickable = node.clickable === true;
92
+ const rules = violationsByPath.get(path);
93
+
94
+ const base = tokenized ? STYLE.tokenized : STYLE.plain;
95
+ body.push(
96
+ `<rect x="${fmt(x)}" y="${fmt(y)}" width="${fmt(sw)}" height="${fmt(sh)}" ` +
97
+ `fill="${base.fill}" stroke="${base.stroke}" stroke-width="${base.strokeWidth}"` +
98
+ `${tokenized ? ` class="tokenized"` : ""} data-path="${esc(path)}"/>`
99
+ );
100
+ if (clickable) {
101
+ // Distinct clickable outline, drawn just inside the node rect.
102
+ body.push(
103
+ `<rect x="${fmt(x + 1.5)}" y="${fmt(y + 1.5)}" width="${fmt(Math.max(sw - 3, 1))}" ` +
104
+ `height="${fmt(Math.max(sh - 3, 1))}" fill="none" stroke="${STYLE.clickableStroke}" ` +
105
+ `stroke-width="2" stroke-dasharray="5 3" class="clickable"/>`
106
+ );
107
+ }
108
+ if (rules && rules.length > 0) {
109
+ // Danger overlay + rule label for a11y violations.
110
+ body.push(
111
+ `<rect x="${fmt(x - 2)}" y="${fmt(y - 2)}" width="${fmt(sw + 4)}" height="${fmt(sh + 4)}" ` +
112
+ `fill="rgba(220,38,38,0.08)" stroke="${STYLE.dangerStroke}" stroke-width="2" class="a11y-violation"/>`
113
+ );
114
+ body.push(
115
+ `<text x="${fmt(x)}" y="${fmt(y - 4)}" font-family="monospace" font-size="8" ` +
116
+ `fill="${STYLE.dangerStroke}" class="a11y-label">! ${esc([...rules].sort().join(", "))}</text>`
117
+ );
118
+ }
119
+ if (node.testTag) {
120
+ body.push(
121
+ `<text x="${fmt(x + 3)}" y="${fmt(y + 9)}" font-family="monospace" font-size="8" ` +
122
+ `fill="${STYLE.tagText}" class="test-tag">${esc(node.testTag)}</text>`
123
+ );
124
+ }
125
+ if (node.text) {
126
+ body.push(
127
+ `<text x="${fmt(x + 3)}" y="${fmt(y + sh / 2 + 3)}" font-family="sans-serif" font-size="10" ` +
128
+ `fill="${STYLE.nodeText}" class="node-text">${esc(truncate(node.text, 48))}</text>`
129
+ );
130
+ }
131
+ if (tokenized) {
132
+ const chip = tokenChip(node.designToken);
133
+ if (chip) {
134
+ const chipW = chip.length * 4.6 + 8;
135
+ const chipY = y + sh - 12;
136
+ body.push(
137
+ `<rect x="${fmt(x + 2)}" y="${fmt(chipY)}" width="${fmt(chipW)}" height="11" rx="5" ` +
138
+ `fill="${STYLE.chipFill}" opacity="0.85" class="token-chip"/>`
139
+ );
140
+ body.push(
141
+ `<text x="${fmt(x + 6)}" y="${fmt(chipY + 8.5)}" font-family="monospace" font-size="7.5" ` +
142
+ `fill="${STYLE.chipText}" class="token-chip-text">${esc(chip)}</text>`
143
+ );
144
+ }
145
+ }
146
+ }
147
+
148
+ const legend = legendRow(opts.a11y != null);
149
+ const footer =
150
+ `<text x="${MARGIN}" y="${svgH - 8}" font-family="monospace" font-size="10" ` +
151
+ `fill="${STYLE.footerText}" class="footer">${nodeCount} nodes · ${esc(source)} · schemaVersion ${schemaVersion}</text>`;
152
+
153
+ return [
154
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">`,
155
+ `<rect x="0" y="0" width="${svgW}" height="${svgH}" fill="#F7F9FC"/>`,
156
+ legend,
157
+ ...body,
158
+ footer,
159
+ `</svg>`,
160
+ ``,
161
+ ].join("\n");
162
+ }
163
+
164
+ // --- helpers ----------------------------------------------------------------
165
+
166
+ // Legend row across the top: what each visual style means.
167
+ function legendRow(withA11y) {
168
+ const items = [];
169
+ let x = MARGIN;
170
+ const y = 8;
171
+ const swatch = (fill, stroke, dash, label, cls) => {
172
+ const parts = [
173
+ `<rect x="${fmt(x)}" y="${y}" width="14" height="10" fill="${fill}" stroke="${stroke}" ` +
174
+ `stroke-width="1.5"${dash ? ` stroke-dasharray="${dash}"` : ""} class="legend-${cls}"/>`,
175
+ `<text x="${fmt(x + 18)}" y="${y + 9}" font-family="sans-serif" font-size="9" fill="#1A1A1A">${label}</text>`,
176
+ ];
177
+ x += 18 + label.length * 5.2 + 14;
178
+ items.push(...parts);
179
+ };
180
+ swatch("none", STYLE.plain.stroke, null, "node", "node");
181
+ swatch(STYLE.tokenized.fill, STYLE.tokenized.stroke, null, "tokenized", "tokenized");
182
+ swatch("none", STYLE.clickableStroke, "5 3", "clickable", "clickable");
183
+ if (withA11y) swatch("rgba(220,38,38,0.08)", STYLE.dangerStroke, null, "a11y violation", "a11y");
184
+ return `<g class="legend">${items.join("")}</g>`;
185
+ }
186
+
187
+ // "radius 16 · pad 16" — compact resolved-values chip, sorted keys for determinism.
188
+ function tokenChip(dt) {
189
+ if (!dt || !dt.resolved || typeof dt.resolved !== "object") return null;
190
+ const keys = Object.keys(dt.resolved).sort();
191
+ if (keys.length === 0) return null;
192
+ const parts = keys.map((k) => {
193
+ const v = String(dt.resolved[k]).replace(/(dp|sp)$/i, "");
194
+ return `${abbrev(k)} ${v}`.trim();
195
+ });
196
+ return truncate(parts.join(" · "), 64);
197
+ }
198
+
199
+ const ABBREV = {
200
+ padding: "pad",
201
+ elevation: "elev",
202
+ fontSize: "font",
203
+ height: "h",
204
+ width: "w",
205
+ statusBarPadding: "statusBar",
206
+ navBarPadding: "navBar",
207
+ };
208
+ function abbrev(key) {
209
+ return ABBREV[key] ?? key;
210
+ }
211
+
212
+ function depthOf(path) {
213
+ return (path.match(/\.children\[/g) || []).length;
214
+ }
215
+
216
+ function boundsDim(b, key) {
217
+ return b && typeof b[key] === "number" ? b[key] : 0;
218
+ }
219
+
220
+ function fmt(n) {
221
+ // Fixed one-decimal formatting: deterministic and diff-friendly.
222
+ return (Math.round(n * 10) / 10).toString();
223
+ }
224
+
225
+ function truncate(s, max) {
226
+ const str = String(s);
227
+ return str.length <= max ? str : str.slice(0, max - 1) + "…";
228
+ }
229
+
230
+ function esc(s) {
231
+ return String(s)
232
+ .replace(/&/g, "&amp;")
233
+ .replace(/</g, "&lt;")
234
+ .replace(/>/g, "&gt;")
235
+ .replace(/"/g, "&quot;");
236
+ }
237
+
238
+ /**
239
+ * Count the nodes renderTreeSvg would draw as rects (non-zero footprint within
240
+ * maxDepth) plus the total node count — used by the tool result.
241
+ */
242
+ export function countRenderable(tree, opts = {}) {
243
+ const root = tree && tree.root ? tree.root : tree;
244
+ const maxDepth =
245
+ typeof opts.maxDepth === "number" && opts.maxDepth >= 0 ? opts.maxDepth : Infinity;
246
+ let total = 0;
247
+ let drawn = 0;
248
+ for (const { node, path } of walk(root)) {
249
+ total++;
250
+ if (depthOf(path) > maxDepth) continue;
251
+ if (boundsDim(node.bounds, "width") > 0 && boundsDim(node.bounds, "height") > 0) drawn++;
252
+ }
253
+ return { total, drawn };
254
+ }
@@ -0,0 +1,108 @@
1
+ // tree.mjs — pure helpers for loading and walking a CMP inspector tree.
2
+ // No MCP imports here: everything is unit-testable in isolation.
3
+ //
4
+ // The JSON tree contract (schemaVersion 1):
5
+ // { schemaVersion, source, root: <Node> }
6
+ // Node = { testTag, text, contentDescription, bounds:{x,y,width,height},
7
+ // designToken: { tokens:string[], resolved:{[k]:string} } | null,
8
+ // children: Node[] }
9
+ //
10
+ // Additive optional fields (still schemaVersion 1 — absent on old trees, so every
11
+ // consumer must treat them as optional):
12
+ // role: string|null — semantics Role (e.g. "Button", "Checkbox")
13
+ // clickable: boolean — presence of the OnClick semantics action
14
+ // disabled: boolean — presence of the Disabled semantics property
15
+
16
+ import { readFileSync } from "node:fs";
17
+
18
+ /**
19
+ * Load a tree from a filesystem path, a JSON string, or an already-parsed object.
20
+ * Validates the minimal shape (schemaVersion + root) and throws a clear,
21
+ * caller-facing Error (never a raw fs/JSON stack) on failure.
22
+ *
23
+ * @param {string|object} pathOrObj
24
+ * @returns {object} the parsed tree ({ schemaVersion, source, root })
25
+ */
26
+ export function loadTree(pathOrObj) {
27
+ if (pathOrObj == null) {
28
+ throw new Error("loadTree: no tree provided (path or object is null/undefined).");
29
+ }
30
+
31
+ let tree;
32
+ if (typeof pathOrObj === "object") {
33
+ tree = pathOrObj;
34
+ } else if (typeof pathOrObj === "string") {
35
+ const raw = readOrParse(pathOrObj);
36
+ tree = raw;
37
+ } else {
38
+ throw new Error(`loadTree: unsupported input type '${typeof pathOrObj}'.`);
39
+ }
40
+
41
+ if (!tree || typeof tree !== "object") {
42
+ throw new Error("loadTree: tree is not an object.");
43
+ }
44
+ if (!tree.root || typeof tree.root !== "object") {
45
+ throw new Error("loadTree: tree has no 'root' node (expected { schemaVersion, source, root }).");
46
+ }
47
+ return tree;
48
+ }
49
+
50
+ // If the string looks like a JSON document, parse it directly; otherwise treat
51
+ // it as a filesystem path and read+parse. This lets callers pass either.
52
+ function readOrParse(str) {
53
+ const trimmed = str.trim();
54
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
55
+ try {
56
+ return JSON.parse(trimmed);
57
+ } catch (err) {
58
+ throw new Error(`loadTree: input looked like JSON but failed to parse: ${err.message}`);
59
+ }
60
+ }
61
+ let contents;
62
+ try {
63
+ contents = readFileSync(str, "utf8");
64
+ } catch (err) {
65
+ if (err.code === "ENOENT") {
66
+ throw new Error(`loadTree: tree file not found: ${str}`);
67
+ }
68
+ throw new Error(`loadTree: could not read tree file '${str}': ${err.message}`);
69
+ }
70
+ try {
71
+ return JSON.parse(contents);
72
+ } catch (err) {
73
+ throw new Error(`loadTree: tree file '${str}' is not valid JSON: ${err.message}`);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Depth-first walk yielding every node with a stable, dotted path.
79
+ * Root's path is "root"; children are "root.children[0]", etc.
80
+ *
81
+ * @param {object} tree a full tree ({root}) OR a bare node.
82
+ * @yields {{ node: object, path: string }}
83
+ */
84
+ export function* walk(tree) {
85
+ const root = tree && tree.root ? tree.root : tree;
86
+ if (!root || typeof root !== "object") return;
87
+ yield* walkNode(root, "root");
88
+ }
89
+
90
+ function* walkNode(node, path) {
91
+ yield { node, path };
92
+ const children = Array.isArray(node.children) ? node.children : [];
93
+ for (let i = 0; i < children.length; i++) {
94
+ yield* walkNode(children[i], `${path}.children[${i}]`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Find the first node with the given testTag. Returns { node, path } or null.
100
+ * @param {object} tree
101
+ * @param {string} tag
102
+ */
103
+ export function findByTestTag(tree, tag) {
104
+ for (const entry of walk(tree)) {
105
+ if (entry.node.testTag === tag) return entry;
106
+ }
107
+ return null;
108
+ }