@supportpages.io/wtfm 0.0.0-stage → 0.3.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.
Files changed (275) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +23 -0
  3. package/README.md +392 -2
  4. package/dist/actions.d.ts +98 -0
  5. package/dist/actions.js +86 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/agent-settings.d.ts +22 -0
  8. package/dist/agent-settings.js +35 -0
  9. package/dist/agent-settings.js.map +1 -0
  10. package/dist/api.d.ts +14 -0
  11. package/dist/api.js +159 -0
  12. package/dist/api.js.map +1 -0
  13. package/dist/article-link.d.ts +16 -0
  14. package/dist/article-link.js +25 -0
  15. package/dist/article-link.js.map +1 -0
  16. package/dist/artifacts.d.ts +45 -0
  17. package/dist/artifacts.js +144 -0
  18. package/dist/artifacts.js.map +1 -0
  19. package/dist/brand.d.ts +4 -0
  20. package/dist/brand.js +9 -0
  21. package/dist/brand.js.map +1 -0
  22. package/dist/bridge.d.ts +2964 -0
  23. package/dist/bridge.js +1036 -0
  24. package/dist/bridge.js.map +1 -0
  25. package/dist/capacity.d.ts +5 -0
  26. package/dist/capacity.js +11 -0
  27. package/dist/capacity.js.map +1 -0
  28. package/dist/credentials.d.ts +13 -0
  29. package/dist/credentials.js +88 -0
  30. package/dist/credentials.js.map +1 -0
  31. package/dist/development-tls.d.ts +8 -0
  32. package/dist/development-tls.js +38 -0
  33. package/dist/development-tls.js.map +1 -0
  34. package/dist/errors.d.ts +15 -0
  35. package/dist/errors.js +18 -0
  36. package/dist/errors.js.map +1 -0
  37. package/dist/export.d.ts +11 -0
  38. package/dist/export.js +91 -0
  39. package/dist/export.js.map +1 -0
  40. package/dist/hosted-operations.d.ts +108 -0
  41. package/dist/hosted-operations.js +120 -0
  42. package/dist/hosted-operations.js.map +1 -0
  43. package/dist/hosting-benefits.d.ts +66 -0
  44. package/dist/hosting-benefits.js +67 -0
  45. package/dist/hosting-benefits.js.map +1 -0
  46. package/dist/index.d.ts +2 -0
  47. package/dist/index.js +49 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/local-inventory.d.ts +22 -0
  50. package/dist/local-inventory.js +59 -0
  51. package/dist/local-inventory.js.map +1 -0
  52. package/dist/local-setup.d.ts +208 -0
  53. package/dist/local-setup.js +140 -0
  54. package/dist/local-setup.js.map +1 -0
  55. package/dist/pairing.d.ts +71 -0
  56. package/dist/pairing.js +235 -0
  57. package/dist/pairing.js.map +1 -0
  58. package/dist/preferences.d.ts +18 -0
  59. package/dist/preferences.js +44 -0
  60. package/dist/preferences.js.map +1 -0
  61. package/dist/progress.d.ts +205 -0
  62. package/dist/progress.js +224 -0
  63. package/dist/progress.js.map +1 -0
  64. package/dist/reminders.d.ts +31 -0
  65. package/dist/reminders.js +73 -0
  66. package/dist/reminders.js.map +1 -0
  67. package/dist/replace-connection.d.ts +6 -0
  68. package/dist/replace-connection.js +77 -0
  69. package/dist/replace-connection.js.map +1 -0
  70. package/dist/repository-actions.d.ts +18 -0
  71. package/dist/repository-actions.js +9 -0
  72. package/dist/repository-actions.js.map +1 -0
  73. package/dist/repository-benefits.d.ts +96 -0
  74. package/dist/repository-benefits.js +64 -0
  75. package/dist/repository-benefits.js.map +1 -0
  76. package/dist/run-update.d.ts +17 -0
  77. package/dist/run-update.js +27 -0
  78. package/dist/run-update.js.map +1 -0
  79. package/dist/runs.d.ts +541 -0
  80. package/dist/runs.js +146 -0
  81. package/dist/runs.js.map +1 -0
  82. package/dist/runtime.d.ts +12 -0
  83. package/dist/runtime.js +58 -0
  84. package/dist/runtime.js.map +1 -0
  85. package/dist/schema.d.ts +277 -0
  86. package/dist/schema.js +66 -0
  87. package/dist/schema.js.map +1 -0
  88. package/dist/server.d.ts +4 -0
  89. package/dist/server.js +298 -0
  90. package/dist/server.js.map +1 -0
  91. package/dist/session.d.ts +1714 -0
  92. package/dist/session.js +619 -0
  93. package/dist/session.js.map +1 -0
  94. package/dist/settings.d.ts +9 -0
  95. package/dist/settings.js +21 -0
  96. package/dist/settings.js.map +1 -0
  97. package/dist/sync.d.ts +495 -0
  98. package/dist/sync.js +191 -0
  99. package/dist/sync.js.map +1 -0
  100. package/dist/telemetry-scrub.d.ts +13 -0
  101. package/dist/telemetry-scrub.js +51 -0
  102. package/dist/telemetry-scrub.js.map +1 -0
  103. package/dist/telemetry.d.ts +73 -0
  104. package/dist/telemetry.js +173 -0
  105. package/dist/telemetry.js.map +1 -0
  106. package/dist/walkthroughs.d.ts +224 -0
  107. package/dist/walkthroughs.js +109 -0
  108. package/dist/walkthroughs.js.map +1 -0
  109. package/dist/workspace.d.ts +14 -0
  110. package/dist/workspace.js +127 -0
  111. package/dist/workspace.js.map +1 -0
  112. package/dist/writer-agent.d.ts +21 -0
  113. package/dist/writer-agent.js +27 -0
  114. package/dist/writer-agent.js.map +1 -0
  115. package/dist/writer-entry.d.ts +171 -0
  116. package/dist/writer-entry.js +233 -0
  117. package/dist/writer-entry.js.map +1 -0
  118. package/dist/writing-style.d.ts +7 -0
  119. package/dist/writing-style.js +52 -0
  120. package/dist/writing-style.js.map +1 -0
  121. package/engine/SYNC.json +4 -0
  122. package/engine/VERSION +1 -0
  123. package/engine/detect-project/README.md +141 -0
  124. package/engine/detect-project/SKILL.md +1421 -0
  125. package/engine/detect-project/assets/desktop/desktop-frame.css +428 -0
  126. package/engine/detect-project/assets/game/game-frame.css +132 -0
  127. package/engine/detect-project/assets/macosui/LICENSE-puppertino.txt +21 -0
  128. package/engine/detect-project/assets/macosui/VERSIONS.txt +1 -0
  129. package/engine/detect-project/assets/macosui/fonts.css +15 -0
  130. package/engine/detect-project/assets/macosui/macos-frame.css +481 -0
  131. package/engine/detect-project/assets/macosui/puppertino.css +2153 -0
  132. package/engine/detect-project/assets/mobileui/LICENSE-fonts.txt +13 -0
  133. package/engine/detect-project/assets/mobileui/LICENSE-framework7.txt +52 -0
  134. package/engine/detect-project/assets/mobileui/VERSIONS.txt +6 -0
  135. package/engine/detect-project/assets/mobileui/device-frame.css +316 -0
  136. package/engine/detect-project/assets/mobileui/f7-color-theme.mjs +1345 -0
  137. package/engine/detect-project/assets/mobileui/f7-icons-names.json +1254 -0
  138. package/engine/detect-project/assets/mobileui/fonts.css +16 -0
  139. package/engine/detect-project/assets/mobileui/framework7-components.css +39 -0
  140. package/engine/detect-project/assets/mobileui/framework7-core.css +5245 -0
  141. package/engine/detect-project/assets/mobileui/icons.css +31 -0
  142. package/engine/detect-project/assets/mobileui/md3-defaults.css +89 -0
  143. package/engine/detect-project/assets/mobileui/platforms.json +46 -0
  144. package/engine/detect-project/assets/tailwind-fallback.css +1729 -0
  145. package/engine/detect-project/assets/webtui/LICENSE-webtui.txt +28 -0
  146. package/engine/detect-project/assets/webtui/VERSIONS.txt +7 -0
  147. package/engine/detect-project/assets/webtui/terminal-frame.css +195 -0
  148. package/engine/detect-project/assets/webtui/theme-catppuccin.css +1 -0
  149. package/engine/detect-project/assets/webtui/theme-everforest.css +1 -0
  150. package/engine/detect-project/assets/webtui/theme-gruvbox.css +1 -0
  151. package/engine/detect-project/assets/webtui/theme-nord.css +1 -0
  152. package/engine/detect-project/assets/webtui/theme-vitesse.css +1 -0
  153. package/engine/detect-project/assets/webtui/themes.json +37 -0
  154. package/engine/detect-project/assets/webtui/webtui-core.css +1 -0
  155. package/engine/detect-project/assets/win32ui/7css.css +2 -0
  156. package/engine/detect-project/assets/win32ui/LICENSE-7css.txt +21 -0
  157. package/engine/detect-project/assets/win32ui/VERSIONS.txt +1 -0
  158. package/engine/detect-project/assets/win32ui/win32-frame.css +278 -0
  159. package/engine/detect-project/package-lock.json +12 -0
  160. package/engine/detect-project/package.json +10 -0
  161. package/engine/detect-project/scripts/apply_runtime_profiles.js +313 -0
  162. package/engine/detect-project/scripts/check_css_health.js +412 -0
  163. package/engine/detect-project/scripts/check_project_map.js +150 -0
  164. package/engine/detect-project/scripts/check_runtime_coverage.js +311 -0
  165. package/engine/detect-project/scripts/check_runtime_recipe_quality.js +184 -0
  166. package/engine/detect-project/scripts/classify_app_type.sh +246 -0
  167. package/engine/detect-project/scripts/classify_surface.sh +95 -0
  168. package/engine/detect-project/scripts/classify_workspace.js +39 -0
  169. package/engine/detect-project/scripts/compile_css.sh +447 -0
  170. package/engine/detect-project/scripts/detect_static.js +645 -0
  171. package/engine/detect-project/scripts/detect_structure.js +187 -0
  172. package/engine/detect-project/scripts/include_census.js +451 -0
  173. package/engine/detect-project/scripts/json_get.js +142 -0
  174. package/engine/detect-project/scripts/merge_json.js +52 -0
  175. package/engine/detect-project/scripts/recommend_model_tier.js +252 -0
  176. package/engine/detect-project/scripts/resolve_route_chains.js +135 -0
  177. package/engine/detect-project/scripts/run_css_build.sh +40 -0
  178. package/engine/detect-project/scripts/sanitize_css.js +83 -0
  179. package/engine/detect-project/scripts/test_classify_surface.js +108 -0
  180. package/engine/detect-project/scripts/test_node_helpers.js +142 -0
  181. package/engine/detect-project/scripts/test_recommend_model_tier.js +119 -0
  182. package/engine/detect-project/scripts/test_resolve_route_chains.js +182 -0
  183. package/engine/detect-project/scripts/test_runtime_coverage.js +323 -0
  184. package/engine/detect-project/scripts/test_runtime_recipe_quality.js +187 -0
  185. package/engine/detect-project/scripts/theme_overrides.js +169 -0
  186. package/engine/detect-project/scripts/write_branding.js +129 -0
  187. package/engine/generate-illustrated-article/SKILL.md +461 -0
  188. package/engine/generate-illustrated-article/contracts/desktop.md +90 -0
  189. package/engine/generate-illustrated-article/contracts/game.md +38 -0
  190. package/engine/generate-illustrated-article/contracts/label-evidence.md +38 -0
  191. package/engine/generate-illustrated-article/contracts/macos.md +79 -0
  192. package/engine/generate-illustrated-article/contracts/mobile.md +40 -0
  193. package/engine/generate-illustrated-article/contracts/terminal.md +29 -0
  194. package/engine/generate-illustrated-article/contracts/win32.md +43 -0
  195. package/engine/generate-illustrated-article/package-lock.json +366 -0
  196. package/engine/generate-illustrated-article/package.json +15 -0
  197. package/engine/generate-illustrated-article/scripts/article_blocks.js +34 -0
  198. package/engine/generate-illustrated-article/scripts/check_article_json.js +105 -0
  199. package/engine/generate-illustrated-article/scripts/emit_walkthrough_signals.js +100 -0
  200. package/engine/generate-illustrated-article/scripts/extract_images.js +187 -0
  201. package/engine/generate-illustrated-article/scripts/generate_content_images.js +376 -0
  202. package/engine/generate-illustrated-article/scripts/include_census.js +451 -0
  203. package/engine/generate-illustrated-article/scripts/inject_assets.js +1691 -0
  204. package/engine/generate-illustrated-article/scripts/jit_mockup_css.js +191 -0
  205. package/engine/generate-illustrated-article/scripts/label_evidence.js +87 -0
  206. package/engine/generate-illustrated-article/scripts/lint_article_copy.js +252 -0
  207. package/engine/generate-illustrated-article/scripts/lint_mockup_fidelity.js +2403 -0
  208. package/engine/generate-illustrated-article/scripts/polish_tickets.js +1148 -0
  209. package/engine/generate-illustrated-article/scripts/related_repos.sh +52 -0
  210. package/engine/generate-illustrated-article/scripts/render_all.js +177 -0
  211. package/engine/generate-illustrated-article/scripts/render_mockup.js +665 -0
  212. package/engine/generate-illustrated-article/scripts/render_ready.js +164 -0
  213. package/engine/generate-illustrated-article/scripts/resolve_workspace.sh +86 -0
  214. package/engine/generate-illustrated-article/scripts/runtime_region_geometry.js +55 -0
  215. package/engine/generate-illustrated-article/scripts/source_paths.js +49 -0
  216. package/engine/generate-illustrated-article/scripts/test_control_visibility.js +30 -0
  217. package/engine/generate-illustrated-article/scripts/test_emit_walkthrough_signals.js +157 -0
  218. package/engine/generate-illustrated-article/scripts/test_generate_content_images.js +168 -0
  219. package/engine/generate-illustrated-article/scripts/test_include_census.js +126 -0
  220. package/engine/generate-illustrated-article/scripts/test_label_evidence.js +49 -0
  221. package/engine/generate-illustrated-article/scripts/test_lint_article_copy.js +138 -0
  222. package/engine/generate-illustrated-article/scripts/test_lint_mockup_fidelity.js +653 -0
  223. package/engine/generate-illustrated-article/scripts/test_node_ports.js +152 -0
  224. package/engine/generate-illustrated-article/scripts/test_polish_tickets.js +481 -0
  225. package/engine/generate-illustrated-article/scripts/test_related_repos.js +64 -0
  226. package/engine/generate-illustrated-article/scripts/test_render_ready.js +85 -0
  227. package/engine/generate-illustrated-article/scripts/test_source_paths.js +68 -0
  228. package/engine/generate-illustrated-article/scripts/trace_hook.js +79 -0
  229. package/engine/generate-illustrated-article/scripts/trace_hook.sh +4 -0
  230. package/engine/generate-illustrated-article/scripts/validate_html.js +114 -0
  231. package/engine/generate-illustrated-article/scripts/watermark.js +69 -0
  232. package/install.sh +14 -0
  233. package/package.json +57 -4
  234. package/scripts/auth.mjs +33 -0
  235. package/scripts/auto-update.mjs +7 -0
  236. package/scripts/build-plugin.mjs +58 -0
  237. package/scripts/build-release.mjs +94 -0
  238. package/scripts/check-release.mjs +40 -0
  239. package/scripts/check-runtime.mjs +10 -0
  240. package/scripts/cli.mjs +8 -0
  241. package/scripts/install.mjs +64 -0
  242. package/scripts/lib/agent-runner.mjs +181 -0
  243. package/scripts/lib/agent-settings.mjs +215 -0
  244. package/scripts/lib/article-skills.mjs +82 -0
  245. package/scripts/lib/auto-update.mjs +50 -0
  246. package/scripts/lib/brand.mjs +9 -0
  247. package/scripts/lib/browser.mjs +12 -0
  248. package/scripts/lib/claude-connection.mjs +33 -0
  249. package/scripts/lib/claude-permissions.mjs +47 -0
  250. package/scripts/lib/claude-plugin.mjs +15 -0
  251. package/scripts/lib/claude-writer.mjs +10 -0
  252. package/scripts/lib/cli-main.mjs +85 -0
  253. package/scripts/lib/cli.mjs +888 -0
  254. package/scripts/lib/codex-config.mjs +51 -0
  255. package/scripts/lib/codex-integration.mjs +61 -0
  256. package/scripts/lib/codex-skill.mjs +34 -0
  257. package/scripts/lib/harness-models.mjs +109 -0
  258. package/scripts/lib/install.mjs +269 -0
  259. package/scripts/lib/managed-writer.mjs +38 -0
  260. package/scripts/lib/planning.mjs +263 -0
  261. package/scripts/lib/prepare-update.mjs +85 -0
  262. package/scripts/lib/refresh-writers.mjs +12 -0
  263. package/scripts/lib/remove.mjs +167 -0
  264. package/scripts/lib/renderer.mjs +34 -0
  265. package/scripts/lib/terminal.mjs +252 -0
  266. package/scripts/lib/uninit.mjs +72 -0
  267. package/scripts/lib/update.mjs +55 -0
  268. package/scripts/lib/writer-recovery.mjs +35 -0
  269. package/scripts/lib/yolo.mjs +362 -0
  270. package/scripts/plugin-session.mjs +65 -0
  271. package/scripts/prepare-update.mjs +12 -0
  272. package/scripts/publish-release.mjs +92 -0
  273. package/scripts/skills/supportpages/SKILL.md +120 -0
  274. package/scripts/smoke-release.mjs +101 -0
  275. package/server.json +27 -0
@@ -0,0 +1,2403 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint_mockup_fidelity.js
4
+ *
5
+ * Verifies each step_N.html in <output_dir> was authored from real project
6
+ * view files rather than invented. Reads view_sources.json (produced by the
7
+ * SKILL before the mockups are written) and runs six checks per step:
8
+ *
9
+ * 1. primary_view exists in the project
10
+ * 2. inline <style> blocks don't define classes that are absent from the
11
+ * listed source files + branding.css
12
+ * 2b. colour values in inline style attributes, model-authored <style>
13
+ * blocks, SVG fill/stroke attrs, and Tailwind arbitrary values exist in
14
+ * branding.css or the step's source files (neutrals always allowed)
15
+ * 3. verbatim_evidence strings appear in both the named source file and
16
+ * the mockup HTML; multi-word phrases in the mockup that look like
17
+ * invented copy are warned
18
+ * 4. default_user_assumptions with markup_absence_check substrings hold
19
+ * 5. <body> class in mockup contains every token from the layout <body>
20
+ * 6. (walkthroughs) step 0 depicts the default landing route
21
+ * 7. (walkthroughs) interaction wiring: exactly one data-walkthrough-action=
22
+ * "advance" per non-final step (none on the last) linking to the next
23
+ * step; every data-walkthrough-type-into is a real <input>/<textarea>
24
+ * with non-empty type-text; every data-walkthrough-do pre-action is a
25
+ * kind the recorder handles (scroll-to/hover/click/select/check/swipe) with a
26
+ * sane order/value; and (when narration.json is present) the spoken
27
+ * narration doesn't promise an interaction the mockup never performs.
28
+ * Catches the "cursor clicks the wrong element" class of bug, which
29
+ * nothing else guards. Gated on actions.json existing next to the
30
+ * mockups, so articles skip it.
31
+ * 9. (articles) zero-based article/view/file indices agree, and every
32
+ * imperative UI-action step has an action_coverage entry whose selected
33
+ * mockup visibly marks the source-grounded control in its action-ready
34
+ * state. A post-action result cannot substitute for a missing target.
35
+ *
36
+ * Exits 0 if all steps pass, 1 if any step has errors, 2 on bad input.
37
+ * Always writes <output_dir>/lint_report.json so the SKILL can feed
38
+ * structured findings into a regeneration prompt.
39
+ *
40
+ * Deterministic metrics (additive, benchmark-facing). Every step/block entry
41
+ * carries a `metrics` object and the report carries a report-level `metrics`
42
+ * object. Each value mirrors a count a check above already makes:
43
+ * `{hits, total, ratio}` per check, `{count}` for invented colours / invented
44
+ * copy / undefined vars, and `null` when that check's data gate was not met
45
+ * (never 0/0). Per-step keys, in order: shell_nav_labels, chrome_files_expanded,
46
+ * runtime_ui_strings, runtime_regions_rendered, root_classes_html,
47
+ * layout_root_classes, verbatim_evidence_source, verbatim_evidence_html,
48
+ * partials_present, body_class_tokens, styled_coverage, inline_classes_verified,
49
+ * invented_colours, invented_copy. Report-level keys: action_coverage,
50
+ * runtime_states_selected, undefined_css_vars, errors_total, warnings_total.
51
+ * Per-layout counters SUM across a step's claimed layouts; runtime_ui_strings
52
+ * is measured on every schema but only enforced (warning) on legacy maps;
53
+ * invented_* counts are uncapped while the messages keep their 10-entry cap.
54
+ * Metrics never influence errors, warnings, message text, or the exit code —
55
+ * An offline scorer reads them to measure chrome completeness without a judge.
56
+ *
57
+ * Usage: lint_mockup_fidelity.js <output_dir> [<project_dir>]
58
+ * project_dir defaults to cwd.
59
+ */
60
+
61
+ const fs = require('fs');
62
+ const path = require('path');
63
+ const {
64
+ ALLOWED_KINDS: GENERATED_KINDS,
65
+ ALLOWED_EXTERNAL_SURFACES: EXTERNAL_SURFACES,
66
+ ALLOWED_SOURCE_BASES: EXTERNAL_SOURCE_BASES,
67
+ } = require('./generate_content_images.js');
68
+ const includeCensus = require('./include_census.js');
69
+ const { verifyLabelEvidence } = require('./label_evidence.js');
70
+
71
+ // Project-relative, or ../<repo>/<path> into a related repository.
72
+ const { isSafeSourcePath: isSafeProjectRelativePath, sourceExists } = require('./source_paths.js');
73
+
74
+ function loadFileOrEmpty(p) {
75
+ try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
76
+ }
77
+
78
+ function escapeRegex(s) {
79
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
80
+ }
81
+
82
+ function dataAttrTags(html, attr, value = null) {
83
+ const out = [];
84
+ const attrRe = new RegExp(`\\b${escapeRegex(attr)}\\s*=\\s*["']([^"']+)["']`, 'i');
85
+ for (const m of html.matchAll(/<[a-zA-Z][^>]*>/g)) {
86
+ const hit = m[0].match(attrRe);
87
+ if (hit && (value == null || hit[1] === value)) out.push({ tag: m[0], value: hit[1] });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function sameStringSet(a, b) {
93
+ const aa = [...new Set((a || []).filter(v => typeof v === 'string'))].sort();
94
+ const bb = [...new Set((b || []).filter(v => typeof v === 'string'))].sort();
95
+ return aa.length === bb.length && aa.every((v, i) => v === bb[i]);
96
+ }
97
+
98
+ function sameStringArray(a, b) {
99
+ return Array.isArray(a) && Array.isArray(b)
100
+ && a.length === b.length && a.every((value, index) => value === b[index]);
101
+ }
102
+
103
+ // ─── Deterministic metrics (additive, benchmark-facing) ─────────────────────
104
+ // See the header comment. Keys are pre-seeded to null so the JSON key order is
105
+ // stable regardless of which data-gated checks fire for a given step.
106
+ const STEP_METRIC_KEYS = [
107
+ 'shell_nav_labels', 'chrome_files_expanded', 'runtime_ui_strings', 'runtime_regions_rendered',
108
+ 'root_classes_html', 'layout_root_classes', 'verbatim_evidence_source', 'verbatim_evidence_html',
109
+ 'partials_present', 'body_class_tokens', 'styled_coverage', 'inline_classes_verified',
110
+ 'invented_colours', 'invented_copy', 'screen_closure',
111
+ ];
112
+ function emptyStepMetrics() {
113
+ return Object.fromEntries(STEP_METRIC_KEYS.map(k => [k, null]));
114
+ }
115
+ function metricRatio(hits, total) { // callers guarantee total > 0
116
+ return { hits, total, ratio: Math.round((hits / total) * 1000) / 1000 };
117
+ }
118
+
119
+ // Emoji / pictographic glyphs must never stand in for UI icons — real products
120
+ // use SVG or icon-font icons, so an emoji reads as unprofessional and off-brand.
121
+ // Weaker models take this shortcut on icon-dense screens (e.g. 🔗 📋 ⚙ for
122
+ // copy-link / clipboard / settings). Flags both literal emoji AND their numeric
123
+ // or hex HTML entities (e.g. &#128279; / &#x1F517;). Allowlists the check/cross
124
+ // marks (✓ ✔ ✖ ✗ ✘), which are common legit UI glyphs.
125
+ const EMOJI_ALLOW = new Set([0x2713, 0x2714, 0x2716, 0x2717, 0x2718]);
126
+ function isEmojiCodepoint(cp) {
127
+ if (EMOJI_ALLOW.has(cp)) return false;
128
+ return (
129
+ (cp >= 0x1F000 && cp <= 0x1FAFF) || // emoji & pictographs (🔗 📋 …)
130
+ (cp >= 0x2600 && cp <= 0x26FF) || // misc symbols (⚙ ☀ ⭐ …)
131
+ (cp >= 0x2700 && cp <= 0x27BF) || // dingbats (✂ ✏ ➜ …)
132
+ cp === 0xFE0F // emoji variation selector
133
+ );
134
+ }
135
+ function findEmojiIcons(html) {
136
+ const found = new Set();
137
+ for (const ch of html) { // iterates by code point
138
+ if (isEmojiCodepoint(ch.codePointAt(0))) found.add(ch);
139
+ }
140
+ for (const m of html.matchAll(/&#(x?)([0-9a-fA-F]+);/g)) {
141
+ const cp = parseInt(m[2], m[1] ? 16 : 10);
142
+ if (Number.isFinite(cp) && isEmojiCodepoint(cp)) found.add(String.fromCodePoint(cp));
143
+ }
144
+ return [...found];
145
+ }
146
+
147
+ function extractInlineStyleClassSelectors(html) {
148
+ // Skip <style> blocks injected by the post-processor (branding, polish,
149
+ // caption strip). Those carry data-walkthrough-* marker attributes and
150
+ // are NOT authored by the model.
151
+ const selectors = new Set();
152
+ const styleBlocks = [...html.matchAll(/<style\b([^>]*)>([\s\S]*?)<\/style>/gi)];
153
+ for (const [, attrs, body] of styleBlocks) {
154
+ if (/data-walkthrough-/.test(attrs)) continue;
155
+ const cleaned = body.replace(/\/\*[\s\S]*?\*\//g, '');
156
+ const classMatches = cleaned.matchAll(/(?:^|[\s,>+~])\.([_a-zA-Z][\w-]*)/g);
157
+ for (const [, name] of classMatches) selectors.add(name);
158
+ }
159
+ return [...selectors];
160
+ }
161
+
162
+ function classDefinedInBranding(brandingCss, className) {
163
+ if (!brandingCss) return false;
164
+ const re = new RegExp('\\.' + escapeRegex(className) + '(?![\\w-])');
165
+ return re.test(brandingCss);
166
+ }
167
+
168
+ function classMentionedInProject(projectDir, sourceFiles, className) {
169
+ const needle = new RegExp(
170
+ '(?:class|className)\\s*=\\s*["\'`][^"\'`]*\\b' + escapeRegex(className) + '\\b',
171
+ ''
172
+ );
173
+ for (const rel of sourceFiles) {
174
+ const text = loadFileOrEmpty(path.join(projectDir, rel));
175
+ if (text && needle.test(text)) return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ // ─── Invented-colour detection (check 2b) ────────────────────────────────────
181
+
182
+ const COLOUR_HEX_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9a-fA-F])/g;
183
+ const COLOUR_FUNC_RE = /\b(rgba?|hsla?)\(\s*([^)]+)\)/gi;
184
+
185
+ // data: URIs and url(#fragment) references contain hex-like noise — blank
186
+ // url() payloads before tokenizing any text for colours.
187
+ function stripUrls(text) {
188
+ return text.replace(/url\(\s*[^)]*\)/gi, 'url()');
189
+ }
190
+
191
+ function hslToRgb(h, s, l) {
192
+ const c = (1 - Math.abs(2 * l - 1)) * s;
193
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
194
+ const m = l - c / 2;
195
+ let rgb;
196
+ if (h < 60) rgb = [c, x, 0];
197
+ else if (h < 120) rgb = [x, c, 0];
198
+ else if (h < 180) rgb = [0, c, x];
199
+ else if (h < 240) rgb = [0, x, c];
200
+ else if (h < 300) rgb = [x, 0, c];
201
+ else rgb = [c, 0, x];
202
+ return rgb.map(v => Math.round((v + m) * 255));
203
+ }
204
+
205
+ function parseHexColour(hex) {
206
+ let h = hex.slice(1);
207
+ let alpha = 1;
208
+ if (h.length === 3 || h.length === 4) h = [...h].map(c => c + c).join('');
209
+ if (h.length === 8) { alpha = parseInt(h.slice(6, 8), 16) / 255; h = h.slice(0, 6); }
210
+ if (h.length !== 6) return null;
211
+ const n = parseInt(h, 16);
212
+ if (Number.isNaN(n)) return null;
213
+ return { rgb: [n >> 16, (n >> 8) & 0xff, n & 0xff], alpha };
214
+ }
215
+
216
+ function parseColourFunc(fn, args) {
217
+ // Handles comma and space/slash syntax, decimals (sass emits
218
+ // rgb(232.6, ...)), and % channels.
219
+ const parts = args.trim().split(/[,\s/]+/).filter(Boolean);
220
+ if (parts.length < 3) return null;
221
+ let alpha = 1;
222
+ if (parts.length >= 4) {
223
+ const a = parseFloat(parts[3]);
224
+ if (!Number.isNaN(a)) alpha = parts[3].includes('%') ? a / 100 : a;
225
+ }
226
+ if (fn.startsWith('rgb')) {
227
+ const ch = parts.slice(0, 3).map(p => {
228
+ const v = parseFloat(p);
229
+ if (Number.isNaN(v)) return null;
230
+ return p.includes('%') ? v * 2.55 : v;
231
+ });
232
+ if (ch.some(v => v == null)) return null;
233
+ return { rgb: ch.map(v => Math.round(Math.min(255, Math.max(0, v)))), alpha };
234
+ }
235
+ const h = parseFloat(parts[0]);
236
+ let s = parseFloat(parts[1]);
237
+ let l = parseFloat(parts[2]);
238
+ if ([h, s, l].some(Number.isNaN)) return null;
239
+ if (parts[1].includes('%') || s > 1) s /= 100;
240
+ if (parts[2].includes('%') || l > 1) l /= 100;
241
+ return { rgb: hslToRgb(((h % 360) + 360) % 360, Math.min(1, s), Math.min(1, l)), alpha };
242
+ }
243
+
244
+ // Returns [{ raw, rgb: [r,g,b], alpha }] for every colour token in the text.
245
+ function extractColourTokens(text) {
246
+ const out = [];
247
+ for (const m of text.matchAll(COLOUR_HEX_RE)) {
248
+ const parsed = parseHexColour(m[0]);
249
+ if (parsed) out.push({ raw: m[0], ...parsed });
250
+ }
251
+ for (const m of text.matchAll(COLOUR_FUNC_RE)) {
252
+ const parsed = parseColourFunc(m[1].toLowerCase(), m[2]);
253
+ if (parsed) out.push({ raw: m[0], ...parsed });
254
+ }
255
+ return out;
256
+ }
257
+
258
+ function colourKey(rgb) { return rgb.join(','); }
259
+
260
+ // Greys and near-greys (incl. cool/warm UI greys like a tinted slate), near-whites,
261
+ // and near-blacks are never worth flagging — invented *brand* colours are mid-range
262
+ // and saturated. We gate on HSV saturation rather than raw channel spread so that
263
+ // common framework neutral greys (whose channels are slightly tinted across the
264
+ // brightness range) read as neutral; saturated brand colours stay flagged.
265
+ function isNeutralColour(rgb) {
266
+ const max = Math.max(...rgb), min = Math.min(...rgb);
267
+ if (min >= 240 || max <= 48) return true; // near-white / near-black
268
+ const chroma = max - min;
269
+ const sat = max === 0 ? 0 : chroma / max; // HSV saturation
270
+ // Low-saturation greys (bright/mid, incl. tinted framework neutrals) OR
271
+ // low-chroma greys (dark neutrals, where HSV saturation inflates as the
272
+ // brightness drops). Saturated brand colours clear both bars and stay flagged.
273
+ return sat <= 0.22 || chroma <= 40;
274
+ }
275
+
276
+ function buildColourWhitelist(cssText) {
277
+ const exact = new Set();
278
+ const list = [];
279
+ if (cssText) {
280
+ for (const { rgb } of extractColourTokens(stripUrls(cssText))) {
281
+ const key = colourKey(rgb);
282
+ if (!exact.has(key)) { exact.add(key); list.push(rgb); }
283
+ }
284
+ }
285
+ return { exact, list };
286
+ }
287
+
288
+ // ±tol per channel absorbs sass rounding / compilation drift.
289
+ function nearAnyColour(rgb, list, tol) {
290
+ for (const w of list) {
291
+ if (Math.abs(rgb[0] - w[0]) <= tol && Math.abs(rgb[1] - w[1]) <= tol && Math.abs(rgb[2] - w[2]) <= tol) return true;
292
+ }
293
+ return false;
294
+ }
295
+
296
+ function colourAllowed(rgb, alpha, whitelists) {
297
+ if (alpha === 0) return true; // standard gradient/shadow endpoint
298
+ if (isNeutralColour(rgb)) return true;
299
+ const key = colourKey(rgb);
300
+ for (const wl of whitelists) {
301
+ if (wl.exact.has(key) || nearAnyColour(rgb, wl.list, 3)) return true;
302
+ }
303
+ return false;
304
+ }
305
+
306
+ // Collect the model-authored fragments that may carry colour values: inline
307
+ // style attributes, SVG presentation attributes, Tailwind arbitrary values,
308
+ // and non-injected <style> blocks. Tags/blocks carrying data-walkthrough
309
+ // markers were injected by the post-processor and are skipped.
310
+ function extractAuthoredColourChunks(html) {
311
+ const chunks = [];
312
+ const cleaned = html.replace(/<!--[\s\S]*?-->/g, ' ');
313
+
314
+ for (const tagMatch of cleaned.matchAll(/<[a-zA-Z][^>]*>/g)) {
315
+ const tag = tagMatch[0];
316
+ if (/data-walkthrough/.test(tag)) continue;
317
+ const style = tag.match(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
318
+ if (style) {
319
+ const value = style[1] ?? style[2] ?? '';
320
+ chunks.push({ text: value, context: `style="${value.slice(0, 80)}"` });
321
+ }
322
+ for (const svg of tag.matchAll(/\b(?:fill|stroke|stop-color|flood-color)\s*=\s*["']([^"']*)["']/gi)) {
323
+ chunks.push({ text: svg[1], context: tag.slice(0, 80) });
324
+ }
325
+ const cls = tag.match(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
326
+ const clsVal = cls ? (cls[1] ?? cls[2] ?? '') : '';
327
+ for (const arb of clsVal.matchAll(/\[(#[0-9a-fA-F]{3,8})\]/g)) {
328
+ chunks.push({ text: arb[1], context: `class="…${arb[0]}…"` });
329
+ }
330
+ }
331
+
332
+ for (const [, attrs, body] of cleaned.matchAll(/<style\b([^>]*)>([\s\S]*?)<\/style>/gi)) {
333
+ if (/data-walkthrough-/.test(attrs)) continue;
334
+ chunks.push({ text: body, context: '<style> block' });
335
+ }
336
+ return chunks;
337
+ }
338
+
339
+ // Returns [{ colour, context }], deduped by canonical rgb, capped at `limit`
340
+ // (10 for the reported messages; Infinity when counting for metrics).
341
+ function findInventedColours(html, whitelists, limit = 10) {
342
+ const found = [];
343
+ const seen = new Set();
344
+ for (const { text, context } of extractAuthoredColourChunks(html)) {
345
+ for (const token of extractColourTokens(stripUrls(text))) {
346
+ const key = colourKey(token.rgb);
347
+ if (seen.has(key)) continue;
348
+ if (colourAllowed(token.rgb, token.alpha, whitelists)) continue;
349
+ seen.add(key);
350
+ found.push({ colour: token.raw, context });
351
+ if (found.length >= limit) return found;
352
+ }
353
+ }
354
+ return found;
355
+ }
356
+
357
+ function extractHtmlClass(html) {
358
+ const m = html.match(/<html\b[^>]*\bclass\s*=\s*["']([^"']*)["']/i);
359
+ return m ? m[1] : '';
360
+ }
361
+
362
+ function extractBodyClass(html) {
363
+ const m = html.match(/<body\b[^>]*\bclass\s*=\s*["']([^"']*)["']/i);
364
+ return m ? m[1].trim() : null;
365
+ }
366
+
367
+ function extractVisibleText(html) {
368
+ let s = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ');
369
+ s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
370
+ s = s.replace(/<!--[\s\S]*?-->/g, ' ');
371
+ s = s.replace(/<[^>]+>/g, ' ');
372
+ return s.replace(/\s+/g, ' ').trim();
373
+ }
374
+
375
+ // ─── Article action-target coverage (hard error) ────────────────────────────
376
+ // A screenshot of the result cannot teach the reader where the control that
377
+ // produced it lives. The article skill records every imperative UI action in a
378
+ // top-level action_coverage ledger and marks the real target element in the
379
+ // action-ready mockup. The linter independently spots common imperative action
380
+ // sentences so omitting the ledger is not a way around the contract.
381
+ const ACTION_KINDS = new Set(['click', 'type', 'select', 'toggle', 'drag', 'upload', 'keyboard']);
382
+ const UI_ACTION_VERBS = [
383
+ 'click', 'select', 'choose', 'press', 'tap', 'open', 'enter', 'type', 'fill(?:\\s+in)?',
384
+ 'check', 'uncheck', 'enable', 'disable', 'turn\\s+on', 'turn\\s+off', 'toggle', 'drag',
385
+ 'drop', 'upload', 'paste', 'save', 'submit', 'send', 'delete', 'add', 'remove', 'edit',
386
+ 'change', 'set', 'configure', 'pause', 'resume', 'stop', 'start', 'go\\s+to', 'navigate\\s+to',
387
+ ].join('|');
388
+ const UI_ACTION_SENTENCE_RE = new RegExp(
389
+ `(?:^|[.!?]\\s+)(?:(?:from|in|on|under|within|at|to begin|when ready|next|then)` +
390
+ `[^.!?]{0,80},\\s*)?(?:${UI_ACTION_VERBS})\\b`, 'i'
391
+ );
392
+
393
+ function articleStepRequiresUiAction(step, articleType) {
394
+ if (articleType === 'concept' || !step || typeof step.content !== 'string') return false;
395
+ // Code is never a UI action: drop fenced blocks and inline spans whole
396
+ // rather than just their backticks, or `start app` in a fence reads as
397
+ // "start" something in the interface.
398
+ const plain = step.content
399
+ .replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '$1')
400
+ .replace(/`[^`\n]*`/g, ' ')
401
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
402
+ .replace(/[*_]/g, '')
403
+ .trim();
404
+ return UI_ACTION_SENTENCE_RE.test(plain);
405
+ }
406
+
407
+ function decodeBasicHtmlEntities(value) {
408
+ return String(value || '')
409
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
410
+ .replace(/&#([0-9]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
411
+ .replace(/&(nbsp|amp|quot|apos|lt|gt);/gi, (_, name) => ({
412
+ nbsp: ' ', amp: '&', quot: '"', apos: "'", lt: '<', gt: '>',
413
+ })[name.toLowerCase()]);
414
+ }
415
+
416
+ function normalizeActionText(value) {
417
+ return decodeBasicHtmlEntities(value).replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
418
+ }
419
+
420
+ function actionMarkerFragments(html, articleStepIndex) {
421
+ const fragments = [];
422
+ const marker = new RegExp(
423
+ `\\bdata-rtfm-action-target\\s*=\\s*["']${articleStepIndex}["']`, 'i'
424
+ );
425
+ const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
426
+ 'link', 'meta', 'param', 'source', 'track', 'wbr']);
427
+ for (const match of html.matchAll(/<([a-zA-Z][a-zA-Z0-9:-]*)\b[^>]*>/g)) {
428
+ if (!marker.test(match[0])) continue;
429
+ const tag = match[1].toLowerCase();
430
+ let fragment = match[0];
431
+ if (!voidTags.has(tag)) {
432
+ const close = html.toLowerCase().indexOf(`</${tag}>`, match.index + match[0].length);
433
+ if (close !== -1) fragment = html.slice(match.index, Math.min(close + tag.length + 3, match.index + 4000));
434
+ }
435
+ fragments.push(fragment);
436
+ }
437
+ return fragments;
438
+ }
439
+
440
+ function fragmentShowsActionTarget(fragment, target) {
441
+ const strings = [extractVisibleText(fragment)];
442
+ for (const match of fragment.matchAll(
443
+ /\b(?:aria-label|placeholder|title|alt|value)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi
444
+ )) strings.push(match[1] ?? match[2] ?? '');
445
+ const needle = normalizeActionText(target);
446
+ return Boolean(needle) && strings.some(value => normalizeActionText(value).includes(needle));
447
+ }
448
+
449
+ function evidenceStrings(step) {
450
+ return (step && Array.isArray(step.verbatim_evidence) ? step.verbatim_evidence : [])
451
+ .map(value => (typeof value === 'object' && value !== null ? value.string : value))
452
+ .filter(value => typeof value === 'string');
453
+ }
454
+
455
+ /**
456
+ * Hard screenshot limit for articles: the max_images skill argument, passed to
457
+ * this lint as RTFM_MAX_IMAGES, else 3. Walkthroughs are unaffected (their
458
+ * steps carry no has_image, so action coverage does not run for them).
459
+ */
460
+ function maxImages(env = process.env) {
461
+ const value = String(env.RTFM_MAX_IMAGES ?? '').trim();
462
+ return /^[1-9]\d*$/.test(value) ? Number(value) : 3;
463
+ }
464
+
465
+ function lintArticleActionCoverage(vs, article, outputDir, appType, generatedManifest, limit = maxImages()) {
466
+ const errors = [];
467
+ const warnings = [];
468
+ const metrics = { action_coverage: null };
469
+ const articleSteps = Array.isArray(article && article.steps) ? article.steps : [];
470
+ if (!articleSteps.length || !articleSteps.every(step => typeof step.has_image === 'boolean')) {
471
+ return { errors, warnings, metrics };
472
+ }
473
+
474
+ const illustrated = new Set(articleSteps.map((step, index) => step.has_image ? index : null)
475
+ .filter(index => index !== null));
476
+ if (illustrated.size > limit) errors.push(
477
+ `article has ${illustrated.size} screenshots; the limit is ${limit} (max_images / RTFM_MAX_IMAGES). ` +
478
+ 'Group same-surface actions into one screenshot, keep the main path, and leave conditional steps as text.'
479
+ );
480
+ const sourceSteps = Array.isArray(vs.steps) ? vs.steps : [];
481
+ const sourceByIndex = new Map();
482
+ for (const step of sourceSteps) {
483
+ const index = step && step.index;
484
+ if (!Number.isInteger(index) || index < 0 || index >= articleSteps.length) {
485
+ errors.push(
486
+ `view_sources step index ${JSON.stringify(index)} is outside article.json's zero-based ` +
487
+ `step range 0..${articleSteps.length - 1}`
488
+ );
489
+ continue;
490
+ }
491
+ if (sourceByIndex.has(index)) errors.push(`view_sources.json contains duplicate step index ${index}`);
492
+ sourceByIndex.set(index, step);
493
+ if (!illustrated.has(index)) errors.push(
494
+ `view_sources step ${index} has a mockup, but article.json steps[${index}].has_image is false`
495
+ );
496
+ }
497
+ for (const index of illustrated) {
498
+ if (!sourceByIndex.has(index)) errors.push(
499
+ `article.json steps[${index}].has_image is true, but view_sources.json has no zero-based step ${index}`
500
+ );
501
+ }
502
+
503
+ // A standalone external surface cannot be rendered when its image request
504
+ // fails. Permit that article action to remain text-only only with an
505
+ // explicit omission ledger tied to the generator's failure manifest. This
506
+ // exception is deliberately unavailable to ordinary product UI actions.
507
+ const failures = generatedManifest && generatedManifest.failures &&
508
+ typeof generatedManifest.failures === 'object' && !Array.isArray(generatedManifest.failures)
509
+ ? generatedManifest.failures : {};
510
+ const omittedActionSteps = new Set();
511
+ if (vs.generation_omissions !== undefined && !Array.isArray(vs.generation_omissions)) {
512
+ errors.push('view_sources.json generation_omissions must be an array when present');
513
+ }
514
+ for (const omission of (Array.isArray(vs.generation_omissions) ? vs.generation_omissions : [])) {
515
+ const id = omission && typeof omission.asset_id === 'string' ? omission.asset_id : '';
516
+ const indexes = omission && Array.isArray(omission.article_step_indexes)
517
+ ? omission.article_step_indexes : [];
518
+ const failure = failures[id];
519
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) ||
520
+ typeof omission.reason !== 'string' || !omission.reason.trim() ||
521
+ indexes.length === 0 || indexes.some(index => !Number.isInteger(index))) {
522
+ errors.push('generation_omissions entries require asset_id, article_step_indexes, and reason');
523
+ continue;
524
+ }
525
+ if (!failure || failure.status !== 'failed' || failure.kind !== 'external-surface') {
526
+ errors.push(`generation omission '${id}' must match a failed external-surface request in generated_images.json`);
527
+ continue;
528
+ }
529
+ for (const index of indexes) {
530
+ if (index < 0 || index >= articleSteps.length ||
531
+ !Array.isArray(failure.used_in_steps) || !failure.used_in_steps.includes(index)) {
532
+ errors.push(`generation omission '${id}' has invalid article step index ${index}`);
533
+ continue;
534
+ }
535
+ if (articleSteps[index].has_image !== false || sourceByIndex.has(index)) {
536
+ errors.push(`generation-omitted external step ${index} must be text-only and absent from view_sources.json steps`);
537
+ }
538
+ for (const ext of ['html', 'png']) {
539
+ if (fs.existsSync(path.join(outputDir, `step_${index}.${ext}`))) {
540
+ errors.push(`generation-omitted external step ${index} must not produce step_${index}.${ext}`);
541
+ }
542
+ }
543
+ omittedActionSteps.add(index);
544
+ }
545
+ }
546
+
547
+ // Terminal articles illustrate command/output states, not graphical UI
548
+ // controls, so the action-target marker contract does not apply to them.
549
+ if (appType === 'terminal') return { errors, warnings, metrics };
550
+
551
+ const requiredActionSteps = articleSteps.map((step, index) =>
552
+ articleStepRequiresUiAction(step, article.article_type) && !omittedActionSteps.has(index) ? index : null)
553
+ .filter(index => index !== null);
554
+ const coverage = Array.isArray(vs.action_coverage) ? vs.action_coverage : [];
555
+ if (requiredActionSteps.length && !Array.isArray(vs.action_coverage)) {
556
+ errors.push(
557
+ `view_sources.json requires an action_coverage array: imperative UI-action article step(s) ` +
558
+ `${requiredActionSteps.join(', ')} must show their controls in an action-ready screenshot`
559
+ );
560
+ }
561
+
562
+ const covered = new Set();
563
+ const seen = new Set();
564
+ for (const entry of coverage) {
565
+ if (!entry || typeof entry !== 'object') {
566
+ errors.push('action_coverage entries must be objects');
567
+ continue;
568
+ }
569
+ const articleIndex = entry.article_step_index;
570
+ const screenshotIndex = entry.screenshot_step_index;
571
+ const target = typeof entry.target === 'string' ? entry.target.trim() : '';
572
+ const identity = `${articleIndex}:${screenshotIndex}:${entry.kind}:${target}`;
573
+ if (seen.has(identity)) errors.push(`action_coverage contains duplicate entry ${identity}`);
574
+ seen.add(identity);
575
+ if (!Number.isInteger(articleIndex) || articleIndex < 0 || articleIndex >= articleSteps.length) {
576
+ errors.push(`action_coverage article_step_index ${JSON.stringify(articleIndex)} is outside the zero-based article step range`);
577
+ continue;
578
+ }
579
+ if (!Number.isInteger(screenshotIndex) || !sourceByIndex.has(screenshotIndex)) {
580
+ errors.push(
581
+ `action_coverage for article step ${articleIndex} points to screenshot_step_index ` +
582
+ `${JSON.stringify(screenshotIndex)}, which is not an illustrated zero-based step`
583
+ );
584
+ continue;
585
+ }
586
+ if (!ACTION_KINDS.has(entry.kind)) errors.push(
587
+ `action_coverage for article step ${articleIndex} has invalid kind ${JSON.stringify(entry.kind)}`
588
+ );
589
+ if (entry.state !== 'action-ready') errors.push(
590
+ `action_coverage for article step ${articleIndex} must use state "action-ready"; ` +
591
+ `post-action/result screenshots cannot replace the control the reader must use`
592
+ );
593
+ if (!target || target.length > 160) errors.push(
594
+ `action_coverage for article step ${articleIndex} requires a concise, non-empty target`
595
+ );
596
+
597
+ const screenshotStep = sourceByIndex.get(screenshotIndex);
598
+ const external = screenshotStep && screenshotStep.external_surface;
599
+ if (external) {
600
+ const requiredText = Array.isArray(external.required_text) ? external.required_text : [];
601
+ if (target && !requiredText.some(value => normalizeActionText(value) === normalizeActionText(target))) {
602
+ errors.push(
603
+ `external screenshot step ${screenshotIndex} does not include action target "${target}" ` +
604
+ `in external_surface.required_text`
605
+ );
606
+ }
607
+ } else {
608
+ const html = loadFileOrEmpty(path.join(outputDir, `step_${screenshotIndex}.html`));
609
+ const fragments = actionMarkerFragments(html, articleIndex);
610
+ if (!fragments.length) errors.push(
611
+ `step_${screenshotIndex}.html must mark the control for article step ${articleIndex} with ` +
612
+ `data-rtfm-action-target="${articleIndex}"`
613
+ );
614
+ else if (target && !fragments.some(fragment => fragmentShowsActionTarget(fragment, target))) errors.push(
615
+ `step_${screenshotIndex}.html action-target marker for article step ${articleIndex} does not ` +
616
+ `contain visible/accessibility text "${target}"`
617
+ );
618
+ const evidence = evidenceStrings(screenshotStep);
619
+ if (target && !evidence.some(value => normalizeActionText(value) === normalizeActionText(target))) errors.push(
620
+ `step_${screenshotIndex} action target "${target}" must also appear exactly in verbatim_evidence ` +
621
+ `so source grounding and rendered visibility are checked`
622
+ );
623
+ }
624
+ covered.add(articleIndex);
625
+ }
626
+ const missing = requiredActionSteps.filter(index => !covered.has(index));
627
+ // Once the limit is spent, the remaining actions are text-only by design.
628
+ if (missing.length && illustrated.size >= limit) warnings.push(
629
+ `article step(s) ${missing.join(', ')} are text-only: the ${limit}-screenshot limit is reached. ` +
630
+ 'Name each control in bold and say where it is.'
631
+ );
632
+ else if (missing.length) errors.push(
633
+ `imperative UI-action article step(s) lack action-ready screenshot coverage: ${missing.join(', ')}. ` +
634
+ 'A post-action outcome does not cover the button/input/menu target. ' +
635
+ `${limit - illustrated.size} of ${limit} screenshot(s) remain.`
636
+ );
637
+ if (requiredActionSteps.length) {
638
+ metrics.action_coverage = metricRatio(requiredActionSteps.length - missing.length, requiredActionSteps.length);
639
+ }
640
+ return { errors, warnings, metrics };
641
+ }
642
+
643
+ function loadSourceCorpus(projectDir, sourceFiles) {
644
+ let blob = '';
645
+ for (const rel of sourceFiles) {
646
+ blob += '\n' + loadFileOrEmpty(path.join(projectDir, rel));
647
+ }
648
+ // Also load i18n / locale strings — copy may legitimately come from there.
649
+ const localeDirs = ['config/locales', 'app/javascript/locales', 'lang', 'locales', 'i18n', 'priv/gettext'];
650
+ for (const dir of localeDirs) {
651
+ const abs = path.join(projectDir, dir);
652
+ let entries = [];
653
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { continue; }
654
+ for (const ent of entries) {
655
+ if (ent.isFile() && /\.(ya?ml|json|po|properties)$/i.test(ent.name)) {
656
+ blob += '\n' + loadFileOrEmpty(path.join(abs, ent.name));
657
+ }
658
+ }
659
+ }
660
+ return blob;
661
+ }
662
+
663
+ function findInventedCopy(mockupText, sourceBlob, limit = 10) {
664
+ const sourceLower = sourceBlob.toLowerCase();
665
+ const candidates = mockupText.match(/\b[A-Za-z][A-Za-z'’]+(?:\s+[A-Za-z][A-Za-z'’]+){3,}\b/g) || [];
666
+ const seen = new Set();
667
+ const invented = [];
668
+ for (const phrase of candidates) {
669
+ const norm = phrase.toLowerCase();
670
+ if (seen.has(norm)) continue;
671
+ seen.add(norm);
672
+ if (norm.length < 18) continue;
673
+ const words = norm.split(/\s+/);
674
+ // Allow if ANY 3-word window appears in source
675
+ let matched = false;
676
+ for (let i = 0; i + 3 <= words.length; i++) {
677
+ if (sourceLower.includes(words.slice(i, i + 3).join(' '))) { matched = true; break; }
678
+ }
679
+ if (matched) continue;
680
+ // Allow if >= 70% of content words (>3 letters) appear individually in source
681
+ const content = words.filter(w => w.length > 3);
682
+ if (content.length === 0) continue;
683
+ const present = content.filter(w =>
684
+ new RegExp('\\b' + escapeRegex(w) + '\\b', 'i').test(sourceLower)
685
+ ).length;
686
+ if (present / content.length >= 0.7) continue;
687
+ invented.push(phrase);
688
+ if (invented.length >= limit) break;
689
+ }
690
+ return invented;
691
+ }
692
+
693
+ // Mobile mode renders icons as <i class="f7-icons">name</i> ligatures from the
694
+ // vendored framework7-icons font — an invented name silently renders as raw
695
+ // text. Validate contents against the bundle's names list; when the bundle
696
+ // isn't installed (non-mobile setups) the check is skipped silently.
697
+ let f7IconNamesCache;
698
+ function loadF7IconNames() {
699
+ if (f7IconNamesCache !== undefined) return f7IconNamesCache;
700
+ const candidates = [
701
+ path.join(__dirname, '..', '..', 'detect-project', 'assets', 'mobileui', 'f7-icons-names.json'),
702
+ path.join(require('os').homedir(), '.rtfm-skills', 'detect-project', 'assets', 'mobileui', 'f7-icons-names.json'),
703
+ ];
704
+ f7IconNamesCache = null;
705
+ for (const p of candidates) {
706
+ try {
707
+ f7IconNamesCache = new Set(JSON.parse(fs.readFileSync(p, 'utf8')));
708
+ break;
709
+ } catch { /* try next */ }
710
+ }
711
+ return f7IconNamesCache;
712
+ }
713
+
714
+ function findInvalidF7Icons(html) {
715
+ if (!html.includes('f7-icons')) return [];
716
+ const names = loadF7IconNames();
717
+ if (!names) return [];
718
+ const bad = new Set();
719
+ const re = /<\w+[^>]*class=["'][^"']*\bf7-icons\b[^"']*["'][^>]*>([^<]*)</g;
720
+ let m;
721
+ while ((m = re.exec(html)) !== null) {
722
+ const name = m[1].trim();
723
+ if (name && !names.has(name)) bad.add(name);
724
+ }
725
+ return [...bad];
726
+ }
727
+
728
+ // ─── Desktop macro-layout bone assembly (hard error) ─────────────────────────
729
+ // Deterministic kill for the misassembled-bones failure (meetily / RTFM #511):
730
+ // .desktop-app-rows forces flex-direction:column !important, so a sidebar+main
731
+ // pair placed directly inside it gives the main pane zero height — every
732
+ // screenshot shows chrome next to a blank pane, and because the visible pixels
733
+ // are identical across steps the PNGs come out byte-identical. The legal
734
+ // assembly is fixed (contract variant B): desktop-pane-fixed/desktop-pane-fill
735
+ // ONLY as direct children of .desktop-app-columns, and never a sidebar directly
736
+ // under .desktop-app-rows. A tag-stack scan suffices — only direct parentage
737
+ // matters. <style>/<script> content is skipped so the inlined branding.css
738
+ // (which defines these very classes) can't false-positive. Also reports whether
739
+ // any bone class is USED in markup at all, for the need-gate warning (bones on
740
+ // a live-JIT project override working CSS — contract variant A forbids them).
741
+ const PANE_BONE_RE = /desktop-app-rows|desktop-app-columns|desktop-pane-fixed|desktop-pane-fill/;
742
+
743
+ function scanPaneBones(html) {
744
+ if (!PANE_BONE_RE.test(html)) return { errors: [], used: false };
745
+ const errors = [];
746
+ let used = false;
747
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img',
748
+ 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
749
+ const stack = [];
750
+ const tagRe = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
751
+ const lower = html.toLowerCase();
752
+ let m;
753
+ while ((m = tagRe.exec(html)) !== null) {
754
+ const closing = m[1] === '/';
755
+ const tag = m[2].toLowerCase();
756
+ const attrs = m[3] || '';
757
+ if (!closing && (tag === 'style' || tag === 'script')) {
758
+ const end = lower.indexOf(`</${tag}`, tagRe.lastIndex);
759
+ if (end === -1) break;
760
+ tagRe.lastIndex = end;
761
+ continue;
762
+ }
763
+ if (closing) {
764
+ for (let i = stack.length - 1; i >= 0; i--) {
765
+ if (stack[i].tag === tag) { stack.length = i; break; }
766
+ }
767
+ continue;
768
+ }
769
+ const classMatch = attrs.match(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
770
+ const classes = new Set(((classMatch && (classMatch[1] || classMatch[2])) || '')
771
+ .split(/\s+/).filter(Boolean));
772
+ if (PANE_BONE_RE.test([...classes].join(' '))) used = true;
773
+ const parent = stack.length ? stack[stack.length - 1] : null;
774
+ const paneClass = classes.has('desktop-pane-fixed') ? 'desktop-pane-fixed'
775
+ : (classes.has('desktop-pane-fill') ? 'desktop-pane-fill' : null);
776
+ if (paneClass && !(parent && parent.classes.has('desktop-app-columns'))) {
777
+ errors.push(
778
+ `.${paneClass} must be a DIRECT child of .desktop-app-columns — found under ` +
779
+ `${parent ? `<${parent.tag} class="${[...parent.classes].join(' ')}">` : 'the document root'}. ` +
780
+ `The macro-layout bones are one fixed assembly (desktop-app-rows > desktop-app-columns > panes); ` +
781
+ `a pane elsewhere renders zero-height/invisible.`
782
+ );
783
+ }
784
+ if (tag === 'aside' && parent && parent.classes.has('desktop-app-rows')) {
785
+ errors.push(
786
+ `<aside> is a direct child of .desktop-app-rows — "rows" stacks its children vertically ` +
787
+ `(flex-direction:column !important), so the sidebar consumes the full height and the main ` +
788
+ `pane renders zero-height/invisible. A sidebar beside its main pane needs ` +
789
+ `.desktop-app-columns (or the app's own row-flex classes) as the shared parent.`
790
+ );
791
+ }
792
+ if (!VOID.has(tag) && !/\/\s*$/.test(attrs)) stack.push({ tag, classes });
793
+ }
794
+ return { errors, used };
795
+ }
796
+
797
+ // ─── CSS-health backstop (check 8, WARN-only) ────────────────────────────────
798
+ // Catches a branding.css that is large and valid but does not STYLE the mockup's
799
+ // surface (observed: a WordPress bundle missing the separately-enqueued login.css
800
+ // — real classes, faithful markup, bare-HTML render, and undefined theme vars
801
+ // painting primary buttons white-on-white). Real-but-unstyled classes pass the
802
+ // invented-class check BY DESIGN (the containing-chain rule requires copying
803
+ // them), so this is a separate, warn-level signal. The detect-side gate
804
+ // (detect-project/scripts/check_css_health.js) is the hard enforcement; this
805
+ // backstop covers stale caches from before that gate existed.
806
+
807
+ function stripCssComments(css) {
808
+ return css.replace(/\/\*[\s\S]*?\*\//g, ' ')
809
+ .replace(/url\(\s*(?:'[^']*'|"[^"]*"|[^)]*)\)/gi, 'url()');
810
+ }
811
+
812
+ // Class/id names appearing in selector position, with Tailwind escapes undone.
813
+ function buildSelectorNameSets(cssText) {
814
+ const css = stripCssComments(cssText);
815
+ const classes = new Set();
816
+ const ids = new Set();
817
+ for (const m of css.matchAll(/\.((?:[A-Za-z0-9_-]|\\[^\s])+)/g)) classes.add(m[1].replace(/\\(.)/g, '$1'));
818
+ for (const m of css.matchAll(/#((?:[A-Za-z0-9_-]|\\[^\s])+)/g)) ids.add(m[1].replace(/\\(.)/g, '$1'));
819
+ return { classes, ids };
820
+ }
821
+
822
+ function extractStyleBlocks(html) {
823
+ let out = '';
824
+ for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) out += m[1] + '\n';
825
+ return out;
826
+ }
827
+
828
+ function findUndefinedCssVars(cssText) {
829
+ const css = stripCssComments(cssText);
830
+ const defined = new Set();
831
+ for (const m of css.matchAll(/(?:^|[{;\s])(--[A-Za-z0-9_-]+)\s*:/g)) defined.add(m[1]);
832
+ for (const m of css.matchAll(/@property\s+(--[A-Za-z0-9_-]+)/g)) defined.add(m[1]);
833
+ // Only var() inside CONCRETE declarations counts — a var() inside another
834
+ // custom property's value resolves lazily and is often intentionally
835
+ // undefined (Tailwind v3's --tw-shadow-colored idiom).
836
+ const noFallback = new Map();
837
+ for (const d of css.matchAll(/(?:^|[{;])\s*([A-Za-z-][A-Za-z0-9_-]*)\s*:\s*([^;{}]*)/g)) {
838
+ if (d[1].startsWith('--')) continue;
839
+ for (const m of d[2].matchAll(/var\(\s*(--[A-Za-z0-9_-]+)\s*\)/g)) {
840
+ noFallback.set(m[1], (noFallback.get(m[1]) || 0) + 1);
841
+ }
842
+ }
843
+ return [...noFallback.entries()]
844
+ .filter(([name, uses]) => !defined.has(name) && uses >= 3)
845
+ .sort((a, b) => b[1] - a[1]);
846
+ }
847
+
848
+ function styledCoverage(html, selectorSets) {
849
+ // The mockup's own <style> blocks legitimately style its classes too.
850
+ const own = buildSelectorNameSets(extractStyleBlocks(html));
851
+ const tokens = new Set();
852
+ const idTokens = new Set();
853
+ for (const m of html.matchAll(/\bclass\s*=\s*(["'])([\s\S]*?)\1/gi)) {
854
+ for (const t of m[2].split(/\s+/)) {
855
+ if (t && t.length <= 64 && /^-?[A-Za-z_]/.test(t)) tokens.add(t);
856
+ }
857
+ }
858
+ for (const m of html.matchAll(/\bid\s*=\s*(["'])([^"']*)\1/gi)) {
859
+ if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(m[2])) idTokens.add(m[2]);
860
+ }
861
+ const unmatched = [];
862
+ let matched = 0;
863
+ for (const c of tokens) {
864
+ if (selectorSets.classes.has(c) || own.classes.has(c)) matched++;
865
+ else unmatched.push('.' + c);
866
+ }
867
+ for (const i of idTokens) {
868
+ if (selectorSets.ids.has(i) || own.ids.has(i)) matched++;
869
+ else unmatched.push('#' + i);
870
+ }
871
+ return { total: tokens.size + idTokens.size, matched, unmatched };
872
+ }
873
+
874
+ // ─── Walkthrough interaction wiring (check 7) ────────────────────────────────
875
+ // The recorder blindly trusts these attributes and targets the FIRST
876
+ // data-walkthrough-action="advance" match — a missing, duplicated, or
877
+ // mis-placed trigger makes the cursor move to and click the wrong element.
878
+ // Typing only fires on a real <input>/<textarea> that carries type-text.
879
+ // Nothing else validates any of this, so assert it before spending Puppeteer
880
+ // time recording a broken interaction.
881
+ function lintWalkthroughWiring(step, html, isLast, nextIndex, narrationText) {
882
+ const errors = [];
883
+ const warnings = [];
884
+
885
+ // Fresh literals each call so there is no shared regex lastIndex state.
886
+ const advanceCount = (html.match(
887
+ /<[a-zA-Z][^>]*\bdata-walkthrough-action\s*=\s*["']advance["'][^>]*>/gi
888
+ ) || []).length;
889
+
890
+ if (isLast) {
891
+ if (advanceCount > 0) {
892
+ errors.push(
893
+ `step_${step.index}.html is the final step but carries ${advanceCount} ` +
894
+ `data-walkthrough-action="advance" element(s); the last step must have none ` +
895
+ `(there is no next step to navigate to).`
896
+ );
897
+ }
898
+ } else if (advanceCount === 0) {
899
+ errors.push(
900
+ `step_${step.index}.html has no data-walkthrough-action="advance" element. Every ` +
901
+ `non-final step needs exactly one — without it the recorder has nothing to click to ` +
902
+ `reach step_${nextIndex}.html.`
903
+ );
904
+ } else if (advanceCount > 1) {
905
+ errors.push(
906
+ `step_${step.index}.html has ${advanceCount} data-walkthrough-action="advance" ` +
907
+ `elements. The recorder moves to and clicks only the FIRST match, so the cursor can ` +
908
+ `land on the wrong element. Keep exactly one advance trigger and remove the rest.`
909
+ );
910
+ } else if (!html.includes(`step_${nextIndex}.html`)) {
911
+ warnings.push(
912
+ `step_${step.index}.html advance trigger does not reference step_${nextIndex}.html — ` +
913
+ `it should be or contain <a href="step_${nextIndex}.html"> per the mockup contract.`
914
+ );
915
+ }
916
+
917
+ // Typing fields must be a real <input>/<textarea>, carry non-empty
918
+ // type-text, and use unique positive-integer fill orders.
919
+ const orderCounts = new Map();
920
+ for (const m of html.matchAll(
921
+ /<([a-zA-Z][\w-]*)\b([^>]*\bdata-walkthrough-type-into\s*=\s*["'][^"']*["'][^>]*)>/gi
922
+ )) {
923
+ const tag = m[1].toLowerCase();
924
+ const attrs = m[2];
925
+ if (tag !== 'input' && tag !== 'textarea') {
926
+ errors.push(
927
+ `step_${step.index}.html puts data-walkthrough-type-into on <${tag}> — the ` +
928
+ `recorder only types into <input>/<textarea> and silently skips everything else, ` +
929
+ `so this field is never filled. Move the attribute onto the real input.`
930
+ );
931
+ continue;
932
+ }
933
+ const textMatch = attrs.match(/\bdata-walkthrough-type-text\s*=\s*["']([^"']*)["']/i);
934
+ if (!textMatch || !textMatch[1].trim()) {
935
+ errors.push(
936
+ `step_${step.index}.html has a data-walkthrough-type-into <${tag}> with no ` +
937
+ `(non-empty) data-walkthrough-type-text — the cursor moves there but types nothing.`
938
+ );
939
+ }
940
+ const orderRaw = (attrs.match(/\bdata-walkthrough-type-into\s*=\s*["']([^"']*)["']/i)?.[1] || '').trim();
941
+ const orderNum = parseInt(orderRaw, 10);
942
+ if (!Number.isInteger(orderNum) || orderNum < 1 || String(orderNum) !== orderRaw) {
943
+ warnings.push(
944
+ `step_${step.index}.html data-walkthrough-type-into="${orderRaw}" is not a positive ` +
945
+ `integer; fill order falls back to DOM order.`
946
+ );
947
+ } else {
948
+ orderCounts.set(orderNum, (orderCounts.get(orderNum) || 0) + 1);
949
+ }
950
+ }
951
+ for (const [order, count] of orderCounts) {
952
+ if (count > 1) {
953
+ warnings.push(
954
+ `step_${step.index}.html has ${count} fields sharing data-walkthrough-type-into=` +
955
+ `"${order}"; ties are broken by DOM order, which may fill them out of sequence.`
956
+ );
957
+ }
958
+ }
959
+
960
+ // Pre-actions: data-walkthrough-do must be a kind the recorder handles;
961
+ // data-walkthrough-order (if set) a positive integer; a native <select>
962
+ // select needs a value to know which option to choose.
963
+ const VALID_DO = new Set(['scroll-to', 'hover', 'click', 'select', 'check', 'swipe']);
964
+ const SWIPE_DIRS = new Set(['up', 'down', 'left', 'right']);
965
+ const preOrderCounts = new Map();
966
+ const presentKinds = new Set(); // kinds actually authored on this step (for the narration check)
967
+ for (const m of html.matchAll(
968
+ /<([a-zA-Z][\w-]*)\b([^>]*\bdata-walkthrough-do\s*=\s*["']([^"']*)["'][^>]*)>/gi
969
+ )) {
970
+ const tag = m[1].toLowerCase();
971
+ const attrs = m[2];
972
+ const kind = (m[3] || '').trim().toLowerCase();
973
+ if (VALID_DO.has(kind)) presentKinds.add(kind);
974
+ if (!VALID_DO.has(kind)) {
975
+ errors.push(
976
+ `step_${step.index}.html has data-walkthrough-do="${kind}", which the recorder does not ` +
977
+ `understand — it is skipped, so the interaction never happens. Use one of: ${[...VALID_DO].join(', ')}.`
978
+ );
979
+ continue;
980
+ }
981
+ const orderRaw = (attrs.match(/\bdata-walkthrough-order\s*=\s*["']([^"']*)["']/i)?.[1] || '').trim();
982
+ if (orderRaw) {
983
+ const n = parseInt(orderRaw, 10);
984
+ if (!Number.isInteger(n) || n < 1 || String(n) !== orderRaw) {
985
+ warnings.push(
986
+ `step_${step.index}.html data-walkthrough-order="${orderRaw}" (on a "${kind}" action) is not a ` +
987
+ `positive integer; pre-action order falls back to DOM order.`
988
+ );
989
+ } else {
990
+ preOrderCounts.set(n, (preOrderCounts.get(n) || 0) + 1);
991
+ }
992
+ }
993
+ const valueMatch = attrs.match(/\bdata-walkthrough-value\s*=\s*["']([^"']*)["']/i);
994
+ const hasValue = !!valueMatch;
995
+ if (kind === 'select' && tag === 'select' && !hasValue) {
996
+ warnings.push(
997
+ `step_${step.index}.html has data-walkthrough-do="select" on a native <select> with no ` +
998
+ `data-walkthrough-value — the recorder won't know which option to choose.`
999
+ );
1000
+ }
1001
+ if (kind === 'swipe') {
1002
+ const dir = (valueMatch?.[1] || '').trim().toLowerCase();
1003
+ if (!SWIPE_DIRS.has(dir)) {
1004
+ warnings.push(
1005
+ `step_${step.index}.html has data-walkthrough-do="swipe" with data-walkthrough-value=` +
1006
+ `"${dir}" — expected one of up/down/left/right; the recorder defaults to "up".`
1007
+ );
1008
+ }
1009
+ }
1010
+ }
1011
+ for (const [order, count] of preOrderCounts) {
1012
+ if (count > 1) {
1013
+ warnings.push(
1014
+ `step_${step.index}.html has ${count} pre-actions sharing data-walkthrough-order="${order}"; ` +
1015
+ `ties break by DOM order, which may run them out of sequence.`
1016
+ );
1017
+ }
1018
+ }
1019
+
1020
+ // Narration ↔ interaction consistency. If the step's spoken narration promises an
1021
+ // interaction (scroll / open a menu / hover / select from a dropdown / toggle) but the
1022
+ // mockup has no matching data-walkthrough-do, the video never performs it — a fidelity
1023
+ // miss. Heuristic verb-matching, so WARN (not error): false positives shouldn't fail a
1024
+ // run, and the contract does the heavy lifting. "click" is excluded (that's the advance).
1025
+ const narr = (narrationText || '').toLowerCase();
1026
+ if (narr) {
1027
+ const wants = [
1028
+ { re: /\bscroll(s|ing|ed)?\b/, kinds: ['scroll-to', 'swipe'], label: 'scrolling' },
1029
+ { re: /\bswip(e|es|ing|ed)\b/, kinds: ['swipe', 'scroll-to'], label: 'swiping' },
1030
+ { re: /\b(open|expand|reveal)(s|ing|ed)?\b[^.]*\b(menu|dropdown|drop-down|list|panel|options|picker|actions?)\b/, kinds: ['hover', 'click'], label: 'opening a menu/dropdown' },
1031
+ { re: /\bhover(s|ing|ed)?\b/, kinds: ['hover'], label: 'hovering' },
1032
+ { re: /\b(toggle|tick|untick|switch on|switch off|turn on|turn off|enable|disable)(s|d|ing)?\b/, kinds: ['check', 'click'], label: 'toggling a control' },
1033
+ { re: /\b(select|choose|pick)(s|ing)?\b[^.]*\b(from|dropdown|drop-down|option|menu|list)\b/, kinds: ['select', 'click'], label: 'selecting from a dropdown' },
1034
+ ];
1035
+ for (const w of wants) {
1036
+ if (w.re.test(narr) && !w.kinds.some(k => presentKinds.has(k))) {
1037
+ warnings.push(
1038
+ `step_${step.index} narration describes ${w.label} but the mockup has no matching ` +
1039
+ `data-walkthrough-do (${w.kinds.join('/')}) — the video won't perform it. Author the ` +
1040
+ `pre-action, or drop the phrase from the narration.`
1041
+ );
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ return { errors, warnings };
1047
+ }
1048
+
1049
+ function lintGeneratedContent(step, html, sourceHtml, generatedManifest) {
1050
+ const errors = [];
1051
+ const markers = dataAttrTags(html, 'data-rtfm-generated-asset');
1052
+ const source = sourceHtml || html;
1053
+ const assets = generatedManifest && generatedManifest.assets && typeof generatedManifest.assets === 'object'
1054
+ ? generatedManifest.assets : {};
1055
+ const externalLedger = step.external_surface;
1056
+
1057
+ if (externalLedger) {
1058
+ if (!externalLedger || typeof externalLedger !== 'object' ||
1059
+ typeof externalLedger.asset_id !== 'string' || !EXTERNAL_SURFACES.has(externalLedger.surface) ||
1060
+ !EXTERNAL_SOURCE_BASES.has(externalLedger.source_basis) ||
1061
+ !Array.isArray(externalLedger.source_files) ||
1062
+ externalLedger.source_files.some(source => !isSafeProjectRelativePath(source)) ||
1063
+ !Array.isArray(externalLedger.required_text) || externalLedger.required_text.length === 0 ||
1064
+ externalLedger.required_text.length > 20 || externalLedger.required_text.some(text =>
1065
+ typeof text !== 'string' || !text.trim() || text.length > 500) ||
1066
+ new Set(externalLedger.required_text).size !== externalLedger.required_text.length) {
1067
+ errors.push('view_sources.json external_surface requires asset_id, a valid surface/source_basis, source_files, and required_text');
1068
+ }
1069
+ if (step.url_or_route !== `external:${externalLedger.surface}`) {
1070
+ errors.push(`external_surface step url_or_route must be "external:${externalLedger.surface}"`);
1071
+ }
1072
+ if (step.primary_view || step.layout ||
1073
+ (Array.isArray(step.partials_expanded) && step.partials_expanded.length) ||
1074
+ step.runtime_state_id || (Array.isArray(step.runtime_source_files) && step.runtime_source_files.length)) {
1075
+ errors.push('external_surface steps must omit product primary_view, layout, partials, and runtime-state fields');
1076
+ }
1077
+ }
1078
+
1079
+ for (const marker of markers) {
1080
+ const id = marker.value;
1081
+ if (!/^<img\b/i.test(marker.tag)) {
1082
+ errors.push(`generated content asset '${id}' must appear on an <img> element only; generated pixels may never replace mockup UI or chrome`);
1083
+ continue;
1084
+ }
1085
+ if (!Object.hasOwn(assets, id)) {
1086
+ errors.push(`generated content asset '${id}' is not declared in generated_images.json`);
1087
+ continue;
1088
+ }
1089
+ const entry = assets[id];
1090
+ const isExternal = entry.kind === 'external-surface';
1091
+ if (!Array.isArray(entry.used_in_steps) || !entry.used_in_steps.includes(step.index)) {
1092
+ errors.push(`generated content asset '${id}' does not declare step ${step.index} in used_in_steps`);
1093
+ }
1094
+ const src = marker.tag.match(/\bsrc\s*=\s*["']([^"']+)["']/i);
1095
+ const unresolvedAfterInjection = Boolean(sourceHtml) && src && src[1] === `{{generated:${id}}}`;
1096
+ if (!src || unresolvedAfterInjection ||
1097
+ (!src[1].startsWith('data:image/') && src[1] !== `{{generated:${id}}}`)) {
1098
+ errors.push(`generated content asset '${id}' must use src="{{generated:${id}}}" (or its injected image data URI)`);
1099
+ }
1100
+ if (!isExternal && (/\b(?:data-rtfm-region|data-rtfm-placement|data-walkthrough-stage)\s*=/i.test(marker.tag) ||
1101
+ /\bstyle\s*=\s*["'][^"']*(?:position\s*:\s*fixed|(?:width|height)\s*:\s*100v[wh]|inset\s*:\s*0)/i.test(marker.tag))) {
1102
+ errors.push(`generated content asset '${id}' is attached to a screen/region-sized element; generated pixels must remain leaf content inside the source-grounded mockup`);
1103
+ }
1104
+ const externalAttr = marker.tag.match(/\bdata-rtfm-external-surface\s*=\s*["']([^"']+)["']/i);
1105
+ if (isExternal) {
1106
+ if (!externalLedger || externalLedger.asset_id !== id || externalLedger.surface !== entry.surface ||
1107
+ externalLedger.source_basis !== entry.source_basis ||
1108
+ !sameStringArray(externalLedger.required_text, entry.required_text) ||
1109
+ !sameStringArray(externalLedger.source_files, entry.source_files)) {
1110
+ errors.push(`external surface asset '${id}' does not exactly match its view_sources.json external_surface ledger`);
1111
+ }
1112
+ if (!externalAttr || externalAttr[1] !== entry.surface) {
1113
+ errors.push(`external surface asset '${id}' must declare data-rtfm-external-surface="${entry.surface}" on its <img>`);
1114
+ }
1115
+ if (!/<body\b[^>]*\bdata-rtfm-surface\s*=\s*["']external["']/i.test(source)) {
1116
+ errors.push(`external surface asset '${id}' requires data-rtfm-surface="external" on <body>`);
1117
+ }
1118
+ if (/\bdata-rtfm-(?:region|state)\s*=/i.test(source)) {
1119
+ errors.push(`external surface asset '${id}' must be a standalone external step, not embedded in product UI regions or states`);
1120
+ }
1121
+ } else if (externalAttr) {
1122
+ errors.push(`generated content asset '${id}' may not declare data-rtfm-external-surface; that marker is reserved for kind external-surface`);
1123
+ }
1124
+ }
1125
+
1126
+ const externalMarkers = markers.filter(marker => {
1127
+ const entry = assets[marker.value];
1128
+ return entry && entry.kind === 'external-surface';
1129
+ });
1130
+ if (externalMarkers.length > 1) {
1131
+ errors.push('a step may contain at most one generated external surface');
1132
+ }
1133
+ if (externalLedger && (externalMarkers.length !== 1 || markers.length !== 1)) {
1134
+ errors.push('a view_sources.json external_surface step must contain exactly one generated asset: its matching external surface');
1135
+ }
1136
+
1137
+ const sourceTags = [...source.matchAll(/<img\b[^>]*>/gi)].map(match => match[0]);
1138
+ for (const tag of sourceTags) {
1139
+ const token = tag.match(/\bsrc\s*=\s*["']\{\{generated:([a-z0-9]+(?:-[a-z0-9]+)*)\}\}["']/i);
1140
+ const marker = tag.match(/\bdata-rtfm-generated-asset\s*=\s*["']([^"']+)["']/i);
1141
+ if (token && (!marker || token[1] !== marker[1])) errors.push(
1142
+ `{{generated:${token[1]}}} must be the src of an <img> carrying the matching data-rtfm-generated-asset marker`
1143
+ );
1144
+ }
1145
+ let stripped = source;
1146
+ for (const tag of sourceTags) stripped = stripped.replace(tag, '');
1147
+ if (/\{\{generated:[^}]+\}\}/.test(stripped)) {
1148
+ errors.push('generated image tokens may only appear as <img src> values; CSS backgrounds and mockup-wide generated surfaces are forbidden');
1149
+ }
1150
+
1151
+ for (const [id, entry] of Object.entries(assets)) {
1152
+ if (Array.isArray(entry.used_in_steps) && entry.used_in_steps.includes(step.index)
1153
+ && !markers.some(marker => marker.value === id)) {
1154
+ errors.push(`generated_images.json assigns '${id}' to step ${step.index}, but the mockup has no matching <img> marker`);
1155
+ }
1156
+ }
1157
+ return errors;
1158
+ }
1159
+
1160
+ function lintStep(step, outputDir, projectDir, brandingCss, colourWhitelist, walkthrough, selectorSets,
1161
+ generatedManifest) {
1162
+ const errors = [];
1163
+ const warnings = [];
1164
+ const metrics = emptyStepMetrics();
1165
+ const stepHtmlPath = path.join(outputDir, step.file || `step_${step.index}.html`);
1166
+ const html = loadFileOrEmpty(stepHtmlPath);
1167
+ if (!html) {
1168
+ errors.push(`mockup file not found: step_${step.index}.html`);
1169
+ return { errors, warnings, metrics };
1170
+ }
1171
+
1172
+ const preInjectionHtml = loadFileOrEmpty(stepHtmlPath + '.pre');
1173
+ errors.push(...lintGeneratedContent(step, html, preInjectionHtml, generatedManifest));
1174
+
1175
+ const externalLedger = step.external_surface;
1176
+ const primary = step.primary_view;
1177
+ const partials = Array.isArray(step.partials_expanded) ? step.partials_expanded : [];
1178
+ const externalSources = externalLedger && Array.isArray(externalLedger.source_files)
1179
+ ? externalLedger.source_files : [];
1180
+ const allSources = [primary, ...partials, step.layout, ...externalSources].filter(Boolean);
1181
+
1182
+ // 1. Source-view existence
1183
+ if (externalLedger) {
1184
+ for (const source of externalSources) {
1185
+ if (!isSafeProjectRelativePath(source)) {
1186
+ errors.push(`external_surface source file must be a safe project-relative path: ${source}`);
1187
+ } else if (!sourceExists(projectDir, source)) {
1188
+ errors.push(`external_surface source file does not exist in project: ${source}`);
1189
+ }
1190
+ }
1191
+ } else if (!primary) {
1192
+ errors.push('view_sources.json: missing primary_view');
1193
+ } else if (!fs.existsSync(path.join(projectDir, primary))) {
1194
+ errors.push(`primary_view does not exist in project: ${primary}`);
1195
+ }
1196
+ let partialsPresent = 0;
1197
+ for (const partial of partials) {
1198
+ if (!fs.existsSync(path.join(projectDir, partial))) {
1199
+ warnings.push(`partial listed but file missing: ${partial}`);
1200
+ } else {
1201
+ partialsPresent++;
1202
+ }
1203
+ }
1204
+ if (partials.length) metrics.partials_present = metricRatio(partialsPresent, partials.length);
1205
+
1206
+ // 2. Invented-class check
1207
+ const inlineClasses = extractInlineStyleClassSelectors(html);
1208
+ let inlineVerified = 0;
1209
+ for (const cls of inlineClasses) {
1210
+ if (classDefinedInBranding(brandingCss, cls) || classMentionedInProject(projectDir, allSources, cls)) {
1211
+ inlineVerified++;
1212
+ continue;
1213
+ }
1214
+ errors.push(
1215
+ `step_${step.index}.html defines .${cls} inline but the class is absent from ` +
1216
+ `primary_view, partials, layout, and branding.css. Either expand the missing ` +
1217
+ `partial in partials_expanded, or remove the invented component and rebuild ` +
1218
+ `from the real one in ${primary || '<unknown>'}.`
1219
+ );
1220
+ }
1221
+ if (inlineClasses.length) metrics.inline_classes_verified = metricRatio(inlineVerified, inlineClasses.length);
1222
+
1223
+ // 2b. Emoji-as-icon check
1224
+ const emojiIcons = findEmojiIcons(html);
1225
+ if (emojiIcons.length) {
1226
+ const shown = emojiIcons.slice(0, 8).join(' ') + (emojiIcons.length > 8 ? ' …' : '');
1227
+ errors.push(
1228
+ `step_${step.index}.html uses emoji/pictographic glyphs as icons (${shown}). Real UIs ` +
1229
+ `use SVG or icon-font icons — replace each with an inline <svg> copied from the source's ` +
1230
+ `icon markup (or the project's icon system); never an emoji or its HTML entity.`
1231
+ );
1232
+ }
1233
+
1234
+ // 2c. f7-icons ligature check (mobile mode)
1235
+ const invalidF7 = findInvalidF7Icons(html);
1236
+ if (invalidF7.length) {
1237
+ errors.push(
1238
+ `step_${step.index}.html uses f7-icons ligature name(s) that don't exist in the vendored ` +
1239
+ `icon font (${invalidF7.join(', ')}) — they would render as raw text. Pick real names from ` +
1240
+ `detect-project/assets/mobileui/f7-icons-names.json (e.g. gear, house_fill, chevron_right).`
1241
+ );
1242
+ }
1243
+
1244
+ // 3. Verbatim-string check (positive)
1245
+ const evidence = Array.isArray(step.verbatim_evidence) ? step.verbatim_evidence : [];
1246
+ if (primary && evidence.length < 3) {
1247
+ warnings.push(
1248
+ `fewer than 3 verbatim_evidence entries (got ${evidence.length}) — model may not have read primary_view`
1249
+ );
1250
+ }
1251
+ let evSrcTotal = 0, evSrcHits = 0, evHtmlTotal = 0, evHtmlHits = 0;
1252
+ for (const e of evidence) {
1253
+ const str = typeof e === 'string' ? e : e.string;
1254
+ const src = (typeof e === 'object' && e.found_in) || primary;
1255
+ if (!str) continue;
1256
+ if (src || (e && e.generated)) {
1257
+ evSrcTotal++;
1258
+ const verified = verifyLabelEvidence(e, primary, projectDir);
1259
+ if (!verified.ok) {
1260
+ errors.push(verified.error);
1261
+ } else {
1262
+ evSrcHits++;
1263
+ }
1264
+ }
1265
+ evHtmlTotal++;
1266
+ if (!html.includes(str)) {
1267
+ errors.push(
1268
+ `verbatim_evidence "${str}" missing from step_${step.index}.html ` +
1269
+ `(must appear in the mockup as proof it was sourced from real templates)`
1270
+ );
1271
+ } else {
1272
+ evHtmlHits++;
1273
+ }
1274
+ }
1275
+ if (evSrcTotal) metrics.verbatim_evidence_source = metricRatio(evSrcHits, evSrcTotal);
1276
+ if (evHtmlTotal) metrics.verbatim_evidence_html = metricRatio(evHtmlHits, evHtmlTotal);
1277
+
1278
+ // 3b. Invented-copy negative check (warning only). The message keeps its
1279
+ // 10-entry cap (`invented`); the metric counts the uncapped list.
1280
+ const sourceBlob = loadSourceCorpus(projectDir, allSources);
1281
+ const mockupText = extractVisibleText(html);
1282
+ const inventedAll = findInventedCopy(mockupText, sourceBlob, Infinity);
1283
+ const invented = inventedAll.slice(0, 10);
1284
+ metrics.invented_copy = { count: inventedAll.length };
1285
+ if (invented.length > 0) {
1286
+ warnings.push(
1287
+ `${invented.length} multi-word phrase(s) in step_${step.index}.html do not appear ` +
1288
+ `in any listed source file or locale: ` +
1289
+ invented.slice(0, 3).map(s => `"${s}"`).join(', ') +
1290
+ (invented.length > 3 ? ', …' : '')
1291
+ );
1292
+ }
1293
+
1294
+ // 2b. Invented-colour check — colours in inline styles / authored <style>
1295
+ // blocks / SVG fills must exist in branding.css or this step's sources.
1296
+ const stepColours = buildColourWhitelist(sourceBlob);
1297
+ const inventedColoursAll = findInventedColours(html, [colourWhitelist, stepColours], Infinity);
1298
+ metrics.invented_colours = { count: inventedColoursAll.length };
1299
+ for (const { colour, context } of inventedColoursAll.slice(0, 10)) {
1300
+ errors.push(
1301
+ `step_${step.index}.html uses colour ${colour} (${context}) that appears nowhere ` +
1302
+ `in branding.css or this step's source files. Do not invent a palette — copy ` +
1303
+ `colour values from the step's real templates, or style the element with an ` +
1304
+ `existing class from branding.css.`
1305
+ );
1306
+ }
1307
+
1308
+ // 4. Default-user-assumption check
1309
+ const assumptions = Array.isArray(step.default_user_assumptions) ? step.default_user_assumptions : [];
1310
+ for (const a of assumptions) {
1311
+ const text = typeof a === 'string' ? a : a.text;
1312
+ const checks = (typeof a === 'object' && Array.isArray(a.markup_absence_check)) ? a.markup_absence_check : [];
1313
+ for (const needle of checks) {
1314
+ if (html.includes(needle)) {
1315
+ errors.push(
1316
+ `default_user_assumption "${text || a}" says the mockup should not render ` +
1317
+ `"${needle}", but it appears in step_${step.index}.html. ` +
1318
+ `Remove that region or revise the assumption.`
1319
+ );
1320
+ }
1321
+ }
1322
+ }
1323
+
1324
+ // 5. Body-class check
1325
+ if (step.layout) {
1326
+ const layoutText = loadFileOrEmpty(path.join(projectDir, step.layout));
1327
+ const layoutBodyMatch = layoutText.match(/<body\b[^>]*\bclass\s*=\s*["']([^"']*)["']/i);
1328
+ if (layoutBodyMatch) {
1329
+ const layoutTokens = layoutBodyMatch[1].split(/\s+/).filter(Boolean);
1330
+ const expected = layoutTokens.filter(t =>
1331
+ !t.includes('<%') && !t.includes('%>') && !t.includes('{{') && !t.includes('}}') && !t.includes('${')
1332
+ );
1333
+ const mockupBodyClass = extractBodyClass(html) || '';
1334
+ const mockupTokens = new Set(mockupBodyClass.split(/\s+/).filter(Boolean));
1335
+ const missing = expected.filter(t => !mockupTokens.has(t));
1336
+ if (expected.length) metrics.body_class_tokens = metricRatio(expected.length - missing.length, expected.length);
1337
+ if (missing.length > 0) {
1338
+ errors.push(
1339
+ `<body> class mismatch in step_${step.index}.html: layout (${step.layout}) ` +
1340
+ `defines body class="${layoutBodyMatch[1]}" but mockup is missing token(s): ` +
1341
+ missing.join(', ')
1342
+ );
1343
+ }
1344
+ }
1345
+ }
1346
+
1347
+ // 6b. Screen-closure check (WARN only, data-gated): the screen IS the
1348
+ // primary view plus everything it renders unconditionally. A mockup of
1349
+ // that view must carry a trace of each unconditional include and each
1350
+ // inline section the view paints — dropping one is the primary-view
1351
+ // analogue of dropping a layout chrome file. Overlays (modals/drawers/
1352
+ // menus) and guarded includes are exempt: overlays open only when a step
1353
+ // opens them, guards resolve per default_user_assumptions. A piece with
1354
+ // no usable marker strings is not checkable and is skipped; a block whose
1355
+ // primary_view is itself an overlay (a modal step) is exempt. Warn-only:
1356
+ // the census is regex-derived and a marker can legitimately live in an
1357
+ // i18n key the census cannot resolve.
1358
+ if (primary && !externalLedger && fs.existsSync(path.join(projectDir, primary))
1359
+ && !includeCensus.OVERLAY_NAME_RE.test(path.basename(primary))) {
1360
+ const cen = includeCensus.census(projectDir, primary);
1361
+ const mockupLower = html.toLowerCase();
1362
+ const has = s => mockupLower.includes(String(s).toLowerCase());
1363
+ const pieces = [];
1364
+ for (const inc of cen.includes) {
1365
+ if (inc.kind !== 'unconditional' || !inc.markers.length) continue;
1366
+ pieces.push({ label: `${inc.name}${inc.file ? ` (${inc.file})` : ''}`, markers: inc.markers });
1367
+ }
1368
+ for (const sec of cen.inline_sections) {
1369
+ if (sec.guarded || !sec.markers.length) continue;
1370
+ pieces.push({ label: `inline section "${sec.heading}"`, markers: sec.markers });
1371
+ }
1372
+ if (pieces.length) {
1373
+ const missing = pieces.filter(p => !p.markers.some(has));
1374
+ metrics.screen_closure = metricRatio(pieces.length - missing.length, pieces.length);
1375
+ if (missing.length) {
1376
+ warnings.push(
1377
+ `step_${step.index}.html omits ${missing.length}/${pieces.length} piece(s) the primary view ` +
1378
+ `${primary} renders unconditionally: ${missing.map(p => `${p.label} [e.g. ${p.markers.slice(0, 2).map(s => JSON.stringify(s)).join(', ')}]`).join('; ')}. ` +
1379
+ `The screen is the primary view plus every unconditional include and inline section — render all of them ` +
1380
+ `on every screenshot of this view (overlays open over it; guarded pieces follow default_user_assumptions).`
1381
+ );
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ // 8. Styled-coverage backstop (WARN only). Low coverage means the branding
1387
+ // bundle likely misses the stylesheet(s) that style THIS surface — the
1388
+ // render will be structurally faithful but visually bare.
1389
+ if (selectorSets) {
1390
+ const cov = styledCoverage(html, selectorSets);
1391
+ if (cov.total) metrics.styled_coverage = metricRatio(cov.matched, cov.total);
1392
+ if (cov.total >= 10 && cov.matched / cov.total < 0.5) {
1393
+ const pct = Math.round((cov.matched / cov.total) * 100);
1394
+ const sample = cov.unmatched.slice(0, 8).join(', ') + (cov.unmatched.length > 8 ? ', …' : '');
1395
+ warnings.push(
1396
+ `only ${cov.matched}/${cov.total} (${pct}%) of this mockup's class/id tokens match a ` +
1397
+ `selector in branding.css/mockup.css (unmatched: ${sample}). The branding bundle ` +
1398
+ `probably misses the stylesheet(s) for this surface (per-page/per-area CSS the main ` +
1399
+ `bundle never imports) — expect an unstyled render. Re-run /detect-project to repair ` +
1400
+ `the cache. (May be a false alarm if this project styles via a CDN at render time.)`
1401
+ );
1402
+ }
1403
+ }
1404
+
1405
+ // 7. Walkthrough interaction wiring (walkthrough runs only — gated on
1406
+ // actions.json existing, see main()). Articles pass walkthrough=null.
1407
+ if (walkthrough) {
1408
+ const isLast = step.index === walkthrough.lastIndex;
1409
+ const narrationText = walkthrough.narrationByIndex && walkthrough.narrationByIndex[step.index];
1410
+ const { errors: wErrors, warnings: wWarnings } =
1411
+ lintWalkthroughWiring(step, html, isLast, step.index + 1, narrationText);
1412
+ errors.push(...wErrors);
1413
+ warnings.push(...wWarnings);
1414
+ }
1415
+
1416
+ return { errors, warnings, metrics };
1417
+ }
1418
+
1419
+ function main() {
1420
+ const outputDir = process.argv[2];
1421
+ const projectDir = process.argv[3] || process.cwd();
1422
+ if (!outputDir) {
1423
+ console.error('Usage: lint_mockup_fidelity.js <output_dir> [<project_dir>]');
1424
+ process.exit(2);
1425
+ }
1426
+ const vsPath = path.join(outputDir, 'view_sources.json');
1427
+ if (!fs.existsSync(vsPath)) {
1428
+ console.error(`Missing view_sources.json at ${vsPath}.`);
1429
+ console.error('The skill must produce this artefact before lint can run.');
1430
+ process.exit(2);
1431
+ }
1432
+ let vs;
1433
+ try {
1434
+ vs = JSON.parse(fs.readFileSync(vsPath, 'utf8'));
1435
+ } catch (e) {
1436
+ console.error(`view_sources.json is not valid JSON: ${e.message}`);
1437
+ process.exit(2);
1438
+ }
1439
+ // Schema-v2 articles key source evidence and files by stable block id. The
1440
+ // fidelity engine predates that contract and is intentionally kept shared
1441
+ // with step-based walkthroughs, so adapt blocks to its internal indexed
1442
+ // representation and create short-lived file aliases for this process.
1443
+ const blockMode = Array.isArray(vs.blocks);
1444
+ let blockIndex = null;
1445
+ const temporaryAliases = [];
1446
+ const orphanBlocks = [];
1447
+ if (blockMode) {
1448
+ // Number each block by its section's position in article.json, the positions
1449
+ // the article-side checks use. view_sources lists only the illustrated sections,
1450
+ // so its own order agrees only when those come first. Every section is numbered,
1451
+ // not just illustrated ones: action coverage may name a text-only section. With
1452
+ // no article sections (walkthroughs), view_sources order is the step order.
1453
+ let sections = [];
1454
+ try {
1455
+ const article = JSON.parse(fs.readFileSync(path.join(outputDir, 'article.json'), 'utf8'));
1456
+ if (Array.isArray(article.blocks)) sections = article.blocks.filter(block => block && block.type === 'section');
1457
+ } catch (_) { /* Missing or invalid article.json is reported by the article check. */ }
1458
+ blockIndex = new Map();
1459
+ if (sections.length) {
1460
+ sections.forEach((block, index) => blockIndex.set(block.id, index));
1461
+ // A block naming no section still gets a distinct number, so the other checks
1462
+ // can run; the mismatch is reported once, by name.
1463
+ vs.blocks.forEach(entry => {
1464
+ if (blockIndex.has(entry.block_id)) return;
1465
+ orphanBlocks.push(entry.block_id);
1466
+ blockIndex.set(entry.block_id, sections.length + orphanBlocks.length - 1);
1467
+ });
1468
+ } else vs.blocks.forEach((entry, index) => blockIndex.set(entry.block_id, index));
1469
+ vs.steps = vs.blocks.map(entry => {
1470
+ const index = blockIndex.get(entry.block_id);
1471
+ for (const extension of ['html', 'png']) {
1472
+ const source = path.join(outputDir, `block_${entry.block_id}.${extension}`);
1473
+ const alias = path.join(outputDir, `step_${index}.${extension}`);
1474
+ if (fs.existsSync(source) && !fs.existsSync(alias)) {
1475
+ if (extension === 'html') {
1476
+ let contents = fs.readFileSync(source, 'utf8');
1477
+ for (const [blockId, blockPosition] of blockIndex) {
1478
+ contents = contents.replaceAll(`data-rtfm-action-target="${blockId}"`, `data-rtfm-action-target="${blockPosition}"`);
1479
+ contents = contents.replaceAll(`data-rtfm-action-target='${blockId}'`, `data-rtfm-action-target='${blockPosition}'`);
1480
+ }
1481
+ fs.writeFileSync(alias, contents);
1482
+ } else fs.copyFileSync(source, alias);
1483
+ temporaryAliases.push(alias);
1484
+ }
1485
+ }
1486
+ return { ...entry, index, file: `block_${entry.block_id}.html` };
1487
+ });
1488
+ vs.action_coverage = (vs.action_coverage || []).map(entry => ({
1489
+ ...entry,
1490
+ article_step_index: entry.article_step_index ?? blockIndex.get(entry.article_block_id),
1491
+ screenshot_step_index: entry.screenshot_step_index ?? blockIndex.get(entry.screenshot_block_id),
1492
+ }));
1493
+ vs.generation_omissions = (vs.generation_omissions || []).map(entry => ({
1494
+ ...entry,
1495
+ article_step_indexes: entry.article_step_indexes || (entry.article_block_ids || []).map(id => blockIndex.get(id)),
1496
+ }));
1497
+ process.on('exit', () => temporaryAliases.forEach(file => { try { fs.unlinkSync(file); } catch {} }));
1498
+ }
1499
+ const brandingCss = loadFileOrEmpty(path.join(outputDir, 'branding.css'));
1500
+ // Built once per run — branding.css can be 2MB+ of compiled CSS.
1501
+ const colourWhitelist = buildColourWhitelist(brandingCss);
1502
+
1503
+ // Check-8 corpus: branding.css plus the JIT output when it ran (lint runs
1504
+ // after inject, so mockup.css sits next to the steps for Tailwind projects).
1505
+ const mockupCss = loadFileOrEmpty(path.join(outputDir, 'mockup.css'));
1506
+ const selectorSets = brandingCss ? buildSelectorNameSets(brandingCss + '\n' + mockupCss) : null;
1507
+
1508
+ const report = {
1509
+ framework: vs.framework || null,
1510
+ project_dir: projectDir,
1511
+ global_errors: [],
1512
+ global_warnings: [],
1513
+ steps: [],
1514
+ all_passed: true,
1515
+ metrics: null,
1516
+ };
1517
+ for (const id of orphanBlocks) {
1518
+ report.global_errors.push(`view_sources.json block '${id}' names no section in article.json; use the illustrated section's id as block_id`);
1519
+ }
1520
+
1521
+ let generatedManifest = null;
1522
+ const generatedPath = path.join(outputDir, 'generated_images.json');
1523
+ if (fs.existsSync(generatedPath)) {
1524
+ try {
1525
+ generatedManifest = JSON.parse(fs.readFileSync(generatedPath, 'utf8'));
1526
+ if (blockMode) {
1527
+ for (const collection of ['assets', 'failures']) {
1528
+ for (const entry of Object.values(generatedManifest[collection] || {})) {
1529
+ if (entry && Array.isArray(entry.used_in_blocks) && !Array.isArray(entry.used_in_steps)) {
1530
+ entry.used_in_steps = entry.used_in_blocks.map(id => blockIndex.get(id));
1531
+ }
1532
+ }
1533
+ }
1534
+ }
1535
+ const assets = generatedManifest.assets;
1536
+ const entries = assets && typeof assets === 'object' && !Array.isArray(assets)
1537
+ ? Object.entries(assets) : [];
1538
+ if (generatedManifest.version !== 1 || entries.length > 5 ||
1539
+ generatedManifest.count !== entries.length || generatedManifest.max_assets !== 5) {
1540
+ report.global_errors.push('generated_images.json must use version 1 and contain at most 5 assets');
1541
+ }
1542
+ for (const [id, entry] of entries) {
1543
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || !entry ||
1544
+ !GENERATED_KINDS.has(entry.kind) || typeof entry.reason !== 'string' || !entry.reason.trim() ||
1545
+ !Array.isArray(entry.used_in_steps)) {
1546
+ report.global_errors.push(`generated_images.json asset '${id}' is missing a valid kind, reason, or used_in_steps ledger`);
1547
+ }
1548
+ if (entry && entry.kind === 'external-surface' &&
1549
+ (!EXTERNAL_SURFACES.has(entry.surface) || !EXTERNAL_SOURCE_BASES.has(entry.source_basis) ||
1550
+ !Array.isArray(entry.source_files) || entry.source_files.some(source => !isSafeProjectRelativePath(source)) ||
1551
+ (entry.source_basis === 'repository' && entry.source_files.length === 0) ||
1552
+ !Array.isArray(entry.required_text) ||
1553
+ entry.required_text.length === 0 || !entry.text_fidelity ||
1554
+ entry.text_fidelity.enforcement !== 'prompt-constrained' ||
1555
+ entry.text_fidelity.required_text_count !== entry.required_text.length)) {
1556
+ report.global_errors.push(`generated_images.json external surface '${id}' is missing its surface, source, exact-text, or fidelity ledger`);
1557
+ }
1558
+ }
1559
+ } catch (error) {
1560
+ report.global_errors.push(`generated_images.json is not valid JSON: ${error.message}`);
1561
+ }
1562
+ }
1563
+
1564
+ // 6. Default-landing-route check (walkthroughs only — articles skip if absent)
1565
+ if (vs.default_landing_route) {
1566
+ const landing = vs.default_landing_route;
1567
+ if (!landing.primary_view) {
1568
+ report.global_errors.push('default_landing_route is missing primary_view');
1569
+ } else if (!fs.existsSync(path.join(projectDir, landing.primary_view))) {
1570
+ report.global_errors.push(
1571
+ `default_landing_route.primary_view does not exist: ${landing.primary_view}`
1572
+ );
1573
+ }
1574
+ const step0 = (vs.steps || []).find(s => s.index === 0);
1575
+ if (!step0) {
1576
+ report.global_errors.push('view_sources.json has no step with index 0');
1577
+ } else {
1578
+ if (landing.url_or_route && step0.url_or_route !== landing.url_or_route) {
1579
+ report.global_errors.push(
1580
+ `step 0 url_or_route "${step0.url_or_route}" does not match ` +
1581
+ `default_landing_route "${landing.url_or_route}". Step 0 must depict the page ` +
1582
+ `a default user lands on after sign-in. Reorder the steps so the walkthrough ` +
1583
+ `opens on the landing page and the cursor navigates from there toward the topic.`
1584
+ );
1585
+ }
1586
+ if (landing.primary_view && step0.primary_view !== landing.primary_view) {
1587
+ report.global_errors.push(
1588
+ `step 0 primary_view "${step0.primary_view}" does not match ` +
1589
+ `default_landing_route.primary_view "${landing.primary_view}".`
1590
+ );
1591
+ }
1592
+ }
1593
+ if (report.global_errors.length > 0) report.all_passed = false;
1594
+ }
1595
+
1596
+ // Desktop shell-region enforcement: when detect recorded a persistent
1597
+ // status bar in app_shell, every framed desktop mockup must render it.
1598
+ // Prose contracts alone proved unreliable here — models drop persistent
1599
+ // chrome; this is the deterministic backstop (same rationale as the
1600
+ // emoji-icon check).
1601
+ let desktopShell = null;
1602
+ // css_build recipe presence = the render JIT is live for this project; the
1603
+ // contract's macro-layout need-gate keys on it (bones forbidden on the JIT
1604
+ // path — they can only override working CSS there).
1605
+ let cssBuildRecipe = null;
1606
+ // Root-element class enforcement (any app type): detect can record the
1607
+ // class tokens the document root carries on a standard authenticated page
1608
+ // (app_shell.root_classes — resolved from server-side conditionals ONCE at
1609
+ // detect time, because they're often computed and un-greppable: WordPress
1610
+ // emits <html class="<?php echo $admin_html_class ?>"> from a helper).
1611
+ // Fixed-chrome offsets hang on them (html.wp-toolbar { padding-top: … }
1612
+ // keeps the fixed admin bar off the content), so a mockup that renders the
1613
+ // shell but drops the root class paints the bar OVER the page.
1614
+ let rootShell = null;
1615
+ // Game-mode backstops (same rationale): (1) every game mockup renders inside
1616
+ // exactly one .game-screen — a fixed-size IN-FLOW playfield; content authored
1617
+ // as position:fixed layers over an auto-height body measures a 0-height
1618
+ // content extent and screenshots near-blank (observed failure mode); (2) a
1619
+ // canvas-state mockup must render the persistent HUD app_shell records.
1620
+ let gameShell = null;
1621
+ // Layout-chrome consumption (any app type): detect records, per layout, the
1622
+ // files that layout unconditionally renders AROUND the page content
1623
+ // (layouts[].chrome — top bar, nav/menu header, screen-utility affordances,
1624
+ // footer). A step that claims a layout must EXPAND that layout's chrome
1625
+ // files in partials_expanded — a chrome file never opened is a chrome
1626
+ // region the mockup cannot render (the observed thin-chrome failure: models
1627
+ // render the branch containing the step's action and prune the layout's
1628
+ // other children). Scope: only steps that CLAIM the layout via their
1629
+ // `layout` key (models record either the map's layout name or its path) AND
1630
+ // actually render the shell (≥2 recorded nav labels — the root_classes
1631
+ // gate) — a fullscreen/standalone state that legitimately drops the shell
1632
+ // also drops the shell's chrome, and a step reading a layout file among its
1633
+ // partials without claiming the layout is not held to that layout's chrome.
1634
+ // Enforcement is graduated by what each entry IS, because legacy maps
1635
+ // record heterogeneous values: an entry that resolves to a real file is a
1636
+ // hard error when unexpanded; a name-like token (component / partial
1637
+ // shorthand) degrades to a basename-match warning; a prose note (contains
1638
+ // whitespace) is uncheckable and skipped. Maps without layouts[].chrome
1639
+ // skip entirely (backwards compatible).
1640
+ let layoutChrome = null;
1641
+ let shellNavLabels = [];
1642
+ // All map layouts (regardless of chrome lists): a layouts[] entry may also
1643
+ // record its own root_classes (a fullscreen editor's root state diverges
1644
+ // from the app default — enforced as a REPLACEMENT for the app-shell tokens
1645
+ // on steps claiming that layout) and a runtime_chrome recipe (the embedded
1646
+ // JS-app's chrome inventory, resolved at detect time — sampled by its
1647
+ // recorded UI strings). Both data-gated: absent fields skip.
1648
+ let mapLayouts = [];
1649
+ let schemaV2 = false;
1650
+ let schemaV3 = false;
1651
+ let schemaV4 = false;
1652
+ let schemaV5 = false;
1653
+ let appType = null;
1654
+ try {
1655
+ const pm = JSON.parse(fs.readFileSync(path.join(projectDir, '.rtfm', 'project_map.json'), 'utf8'));
1656
+ appType = pm.app_type || null;
1657
+ schemaV2 = Number(pm.schema_version) >= 2;
1658
+ schemaV3 = Number(pm.schema_version) >= 3;
1659
+ schemaV4 = Number(pm.schema_version) >= 4;
1660
+ schemaV5 = Number(pm.schema_version) >= 5;
1661
+ if (pm.app_type === 'desktop') desktopShell = pm.app_shell || null;
1662
+ if (pm.css_build && typeof pm.css_build === 'object') cssBuildRecipe = pm.css_build;
1663
+ if (pm.app_type === 'game') {
1664
+ gameShell = {
1665
+ appShell: pm.app_shell || null,
1666
+ canvasFile: (pm.game_metadata || {}).canvas_file || null,
1667
+ };
1668
+ }
1669
+ const rc = pm.app_shell && pm.app_shell.root_classes;
1670
+ if (rc) {
1671
+ const toTokens = v => (Array.isArray(v) ? v : String(v || '').split(/\s+/)).filter(Boolean);
1672
+ const htmlTokens = toTokens(rc.html);
1673
+ const navLabels = (pm.app_shell.nav_items || [])
1674
+ .map(n => n && n.label).filter(l => typeof l === 'string' && l.length >= 3);
1675
+ if (htmlTokens.length && navLabels.length >= 2) rootShell = { htmlTokens, navLabels };
1676
+ }
1677
+ mapLayouts = (Array.isArray(pm.layouts) ? pm.layouts : []).filter(l =>
1678
+ l && typeof l === 'object' && (l.name || l.path));
1679
+ const chromeLayouts = mapLayouts.filter(l =>
1680
+ Array.isArray(l.chrome) && l.chrome.length > 0);
1681
+ // Same filter as rootShell.navLabels above — the per-step loop counts
1682
+ // nav-label hits ONCE against this list and every shell gate reads it.
1683
+ shellNavLabels = ((pm.app_shell || {}).nav_items || [])
1684
+ .map(n => n && n.label).filter(l => typeof l === 'string' && l.length >= 3);
1685
+ if (chromeLayouts.length && shellNavLabels.length >= 2) layoutChrome = chromeLayouts;
1686
+ // Stale-cache backstop (WARN, the check-8b pattern): detect's CSS health
1687
+ // gate (check 3) hard-fails a FRESH detect whose app_shell.root_classes
1688
+ // miss a root-scoped fixed-chrome offset class the bundle proves exists
1689
+ // (html.X { padding-top: … }) — but existing caches predate that gate and
1690
+ // staleness is manual-only, so surface the defect here without failing.
1691
+ // Logic mirrors detect-project/scripts/check_css_health.js (source of truth).
1692
+ if (brandingCss && pm.app_shell) {
1693
+ const trivial = v => {
1694
+ const t = v.replace(/!important/gi, '').trim().toLowerCase();
1695
+ return ['initial', 'unset', 'inherit', 'revert', 'auto', ''].includes(t)
1696
+ || /^0(\.0+)?(px|rem|em|%|vh|vw)?$/.test(t) || /^[12](\.\d+)?px$/.test(t);
1697
+ };
1698
+ const offsets = { html: new Set(), body: new Set() };
1699
+ for (const m of brandingCss.matchAll(/([^{};]+)\{([^{}]*)\}/g)) {
1700
+ const pt = m[2].match(/(?:^|;)\s*padding-top\s*:\s*([^;]+)/i);
1701
+ if (!pt || trivial(pt[1])) continue;
1702
+ for (const sm of m[1].matchAll(/(?:^|[,\s])(html|body)\.([A-Za-z0-9_-]+)/g)) {
1703
+ offsets[sm[1]].add(sm[2]);
1704
+ }
1705
+ }
1706
+ const rcAll = pm.app_shell.root_classes || {};
1707
+ for (const root of ['html', 'body']) {
1708
+ const recorded = Array.isArray(rcAll[root]) ? rcAll[root] : [];
1709
+ if (offsets[root].size && !recorded.length) {
1710
+ report.global_warnings.push(
1711
+ `branding.css scopes a fixed-chrome offset to ${root}.` +
1712
+ `${[...offsets[root]].sort().join(` / ${root}.`)} (padding-top rule) but the ` +
1713
+ `cached app_shell.root_classes.${root} is empty — shell mockups may render the ` +
1714
+ `fixed bar over the content. The cache predates the detect-side gate for this; ` +
1715
+ `re-run /detect-project to repair it.`
1716
+ );
1717
+ }
1718
+ }
1719
+ }
1720
+ } catch (_) { /* no project map — nothing to enforce */ }
1721
+
1722
+ // Article-only contract: walkthrough article steps deliberately have no
1723
+ // has_image field, so lintArticleActionCoverage skips them. This also
1724
+ // validates the zero-based article/view/file association that downstream
1725
+ // importers use; a human-facing third-step file named step_3 is orphaned.
1726
+ let actionCoverageMetric = null;
1727
+ const articlePath = path.join(outputDir, 'article.json');
1728
+ if (fs.existsSync(articlePath)) {
1729
+ try {
1730
+ const article = JSON.parse(fs.readFileSync(articlePath, 'utf8'));
1731
+ if (blockMode && Array.isArray(article.blocks)) {
1732
+ article.steps = article.blocks.filter(block => block.type === 'section');
1733
+ }
1734
+ const coverage = lintArticleActionCoverage(vs, article, outputDir, appType, generatedManifest);
1735
+ report.global_errors.push(...coverage.errors);
1736
+ report.global_warnings.push(...coverage.warnings);
1737
+ actionCoverageMetric = coverage.metrics.action_coverage;
1738
+ } catch (error) {
1739
+ report.global_errors.push(`article.json is not valid JSON: ${error.message}`);
1740
+ }
1741
+ }
1742
+
1743
+ // Runtime-app state coverage (data-gated, article+walkthrough): when a
1744
+ // claimed layout's runtime_chrome records `states`, the illustrated set
1745
+ // must cover the recorded working states — prompt-rule allocation proved
1746
+ // unreliable (four benchmark rounds: the model repeatedly skipped a
1747
+ // recorded editing state), so the ledger is enforced here and the retry
1748
+ // loop performs the reallocation. Root/overview and entry states are not
1749
+ // required (they drop first by contract); a state is matched when the
1750
+ // significant words of its name appear in some step's depicted_state.
1751
+ let rsTotal = 0, rsHits = 0; // runtime_states_selected metric (both branches)
1752
+ if (mapLayouts.length && schemaV2) {
1753
+ const placements = new Set(['top', 'left', 'right', 'bottom', 'canvas',
1754
+ 'overlay-left', 'overlay-right', 'overlay-center', 'overlay-bottom']);
1755
+ for (const l of mapLayouts.filter(x => x.runtime_chrome)) {
1756
+ const rcw = l.runtime_chrome;
1757
+ const regions = Array.isArray(rcw.regions) ? rcw.regions : [];
1758
+ const states = Array.isArray(rcw.states) ? rcw.states : [];
1759
+ const regionIds = regions.map(r => r && r.id).filter(Boolean);
1760
+ const stateIds = states.map(s => s && s.id).filter(Boolean);
1761
+ if (!regions.length || !states.length) {
1762
+ report.global_errors.push(`schema-v2 layout '${l.name || l.path}' runtime_chrome requires non-empty regions and states`);
1763
+ continue;
1764
+ }
1765
+ if (!['repo', 'knowledge'].includes(rcw.source_kind)
1766
+ || !Array.isArray(rcw.source_files)
1767
+ || (rcw.source_kind === 'repo' && !rcw.source_files.length)) {
1768
+ report.global_errors.push(
1769
+ `schema-v2 layout '${l.name || l.path}' runtime_chrome requires source_kind and repo-backed source_files`);
1770
+ }
1771
+ if (new Set(regionIds).size !== regionIds.length || regionIds.length !== regions.length) {
1772
+ report.global_errors.push(`schema-v2 layout '${l.name || l.path}' has missing or duplicate runtime region ids`);
1773
+ }
1774
+ if (new Set(stateIds).size !== stateIds.length || stateIds.length !== states.length) {
1775
+ report.global_errors.push(`schema-v2 layout '${l.name || l.path}' has missing or duplicate runtime state ids`);
1776
+ }
1777
+ for (const r of regions) {
1778
+ if (!placements.has(r.placement)) report.global_errors.push(
1779
+ `runtime region '${r.id || '?'}' has invalid placement '${r.placement || ''}'`);
1780
+ if (typeof r.required !== 'boolean' || !Array.isArray(r.required_strings)
1781
+ || !Array.isArray(r.source_files)) report.global_errors.push(
1782
+ `runtime region '${r.id || '?'}' is missing required/required_strings/source_files schema fields`);
1783
+ if (schemaV3 && (typeof r.appearance !== 'string' || !r.appearance.trim())) {
1784
+ report.global_errors.push(`schema-v3 runtime region '${r.id || '?'}' requires non-empty appearance`);
1785
+ }
1786
+ if (schemaV4) {
1787
+ const items = Array.isArray(r.items) ? r.items : [];
1788
+ const itemIds = items.map(item => item && item.id);
1789
+ if (!items.length || items.some(item => !item || typeof item.id !== 'string'
1790
+ || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.id)
1791
+ || typeof item.description !== 'string' || !item.description.trim())
1792
+ || new Set(itemIds).size !== itemIds.length) report.global_errors.push(
1793
+ `schema-v4 runtime region '${r.id || '?'}' requires ordered structured items`);
1794
+ if (schemaV5 && items.some(item => typeof item.kind !== 'string'
1795
+ || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.kind)
1796
+ || !Array.isArray(item.required_strings)
1797
+ || item.required_strings.some(value => typeof value !== 'string' || !value.trim()))) {
1798
+ report.global_errors.push(`schema-v5 runtime region '${r.id || '?'}' items require kind and required_strings`);
1799
+ }
1800
+ if (schemaV5 && r.placement === 'right' && (r.ui_strings || []).length >= 4
1801
+ && items.length < 4) report.global_errors.push(
1802
+ `schema-v5 right-panel region '${r.id || '?'}' requires atomic navigable-row items`);
1803
+ if (schemaV5 && typeof r.placement === 'string' && r.placement.startsWith('overlay-')) {
1804
+ const kinds = new Set(items.map(item => item.kind));
1805
+ if (!kinds.has('group-heading') || !kinds.has('selection-row') || !kinds.has('actions')) {
1806
+ report.global_errors.push(`schema-v5 grouped overlay region '${r.id || '?'}' requires group, selection, and action items`);
1807
+ }
1808
+ }
1809
+ if (r.placement === 'canvas') {
1810
+ if (!['populated', 'empty', 'loading'].includes(r.content_mode)
1811
+ || !['required', 'optional', 'none'].includes(r.media_expectation)
1812
+ || !Array.isArray(r.representative_assets)
1813
+ || !Number.isInteger(r.max_selected_outlines) || r.max_selected_outlines < 0) {
1814
+ report.global_errors.push(`schema-v4 canvas region '${r.id || '?'}' is missing its content/media contract`);
1815
+ }
1816
+ if (r.media_expectation === 'required' && !(r.representative_assets || []).length) {
1817
+ report.global_errors.push(`schema-v4 canvas region '${r.id || '?'}' requires representative assets`);
1818
+ }
1819
+ }
1820
+ }
1821
+ }
1822
+ for (const st of states) {
1823
+ if (!['overview', 'action', 'confirm'].includes(st.kind)
1824
+ || typeof st.screenshot_required !== 'boolean'
1825
+ || !Array.isArray(st.required_regions) || !Array.isArray(st.required_strings)
1826
+ || !Array.isArray(st.source_files)) report.global_errors.push(
1827
+ `runtime state '${st.id || '?'}' is missing the schema-v2 state contract`);
1828
+ const unknown = (st.required_regions || []).filter(id => !regionIds.includes(id));
1829
+ if (unknown.length) report.global_errors.push(
1830
+ `runtime state '${st.id || '?'}' references unknown region(s): ${unknown.join(', ')}`);
1831
+ if (schemaV3) {
1832
+ const visible = Array.isArray(st.visible_regions) ? st.visible_regions : [];
1833
+ const invalidVisible = visible.filter((id, index) => !regionIds.includes(id)
1834
+ || visible.indexOf(id) !== index);
1835
+ const orderedVisible = regions.filter(r => visible.includes(r.id)).map(r => r.id);
1836
+ if (!visible.length || invalidVisible.length || !sameStringArray(visible, orderedVisible)) {
1837
+ report.global_errors.push(
1838
+ `schema-v3 runtime state '${st.id || '?'}' visible_regions must be unique valid region ids in recipe order`);
1839
+ }
1840
+ }
1841
+ if (schemaV4 && (!Array.isArray(st.topic_tags) || !st.topic_tags.length
1842
+ || st.topic_tags.some(tag => typeof tag !== 'string' || !tag.trim())
1843
+ || !['orientation', 'primary-action', 'secondary-action', 'terminal-action']
1844
+ .includes(st.instructional_priority))) report.global_errors.push(
1845
+ `schema-v4 runtime state '${st.id || '?'}' requires topic_tags and instructional_priority`);
1846
+ if (schemaV5) {
1847
+ if (typeof st.canvas_presentation !== 'string' || !st.canvas_presentation.trim()) {
1848
+ report.global_errors.push(`schema-v5 runtime state '${st.id || '?'}' requires canvas_presentation`);
1849
+ }
1850
+ const hasToolbar = Array.isArray(st.visible_regions) && regions.some(region =>
1851
+ st.visible_regions.includes(region.id) && region.placement === 'top');
1852
+ if (hasToolbar && ['action', 'confirm'].includes(st.kind)
1853
+ && (typeof st.context_label !== 'string' || !st.context_label.trim()
1854
+ || !Array.isArray(st.required_strings)
1855
+ || !st.required_strings.includes(st.context_label))) report.global_errors.push(
1856
+ `schema-v5 runtime state '${st.id || '?'}' requires context_label in required_strings`);
1857
+ }
1858
+ if (st.kind === 'overview' && st.screenshot_required) report.global_errors.push(
1859
+ `runtime overview state '${st.id || '?'}' cannot be screenshot_required`);
1860
+ }
1861
+ if (schemaV3) {
1862
+ for (const r of regions) {
1863
+ const universal = states.length > 0 && states.every(st =>
1864
+ Array.isArray(st.visible_regions) && st.visible_regions.includes(r.id));
1865
+ if (r.required !== universal) report.global_errors.push(
1866
+ `schema-v3 runtime region '${r.id || '?'}' required must equal visibility in every state`);
1867
+ }
1868
+ }
1869
+ if (states.filter(st => st.screenshot_required).length > 3) report.global_errors.push(
1870
+ `schema-v2 layout '${l.name || l.path}' has more than three screenshot_required states`);
1871
+ const recipeSources = [rcw.source_files, ...regions.map(r => r.source_files),
1872
+ ...states.map(s => s.source_files)].flat().filter(v => typeof v === 'string');
1873
+ if (rcw.source_kind === 'repo') {
1874
+ for (const rel of new Set(recipeSources)) {
1875
+ if (!fs.existsSync(path.join(projectDir, rel))) report.global_errors.push(
1876
+ `schema-v2 runtime recipe source does not exist: ${rel}`);
1877
+ }
1878
+ }
1879
+ }
1880
+
1881
+ const claimed = mapLayouts.filter(l => l.runtime_chrome && (vs.steps || []).some(
1882
+ s => s && (s.layout === l.path || s.layout === l.name)));
1883
+ for (const l of claimed) {
1884
+ const states = l.runtime_chrome.states || [];
1885
+ const ids = new Set(states.map(s => s.id));
1886
+ const stepIds = (vs.steps || []).filter(s => s && (s.layout === l.path || s.layout === l.name))
1887
+ .map(s => s.runtime_state_id).filter(Boolean);
1888
+ const required = states.filter(s => s.screenshot_required).map(s => s.id);
1889
+ const selection = vs.runtime_state_selection || {};
1890
+ const selected = Array.isArray(selection.selected) ? selection.selected : [];
1891
+ if (selection.layout !== l.name && selection.layout !== l.path) report.global_errors.push(
1892
+ `runtime_state_selection.layout must identify claimed layout '${l.name || l.path}'`);
1893
+ if (!sameStringSet(selected, stepIds)) report.global_errors.push(
1894
+ `runtime_state_selection.selected must exactly match illustrated runtime_state_id values for layout '${l.name || l.path}'`);
1895
+ const missing = required.filter(id => !selected.includes(id));
1896
+ rsTotal += required.length;
1897
+ rsHits += required.length - missing.length;
1898
+ if (missing.length) report.global_errors.push(
1899
+ `layout '${l.name || l.path}' requires screenshot state(s) that were not selected: ${missing.join(', ')}`);
1900
+ const unknown = selected.filter(id => !ids.has(id));
1901
+ if (unknown.length) report.global_errors.push(`runtime_state_selection contains unknown state id(s): ${unknown.join(', ')}`);
1902
+ const dropped = Array.isArray(selection.dropped) ? selection.dropped : [];
1903
+ const droppedIds = dropped.map(d => d && d.id).filter(Boolean);
1904
+ if (new Set(droppedIds).size !== droppedIds.length) report.global_errors.push(
1905
+ 'runtime_state_selection.dropped contains duplicate state ids');
1906
+ for (const d of dropped) {
1907
+ if (!d || !ids.has(d.id) || typeof d.reason !== 'string' || !d.reason.trim()) {
1908
+ report.global_errors.push('runtime_state_selection.dropped entries require a known id and non-empty reason');
1909
+ }
1910
+ if (d && selected.includes(d.id)) report.global_errors.push(`runtime state '${d.id}' is both selected and dropped`);
1911
+ }
1912
+ const unaccounted = states.map(st => st.id)
1913
+ .filter(id => !selected.includes(id) && !droppedIds.includes(id));
1914
+ if (unaccounted.length) report.global_errors.push(
1915
+ `runtime_state_selection must select or explain every state; unaccounted: ${unaccounted.join(', ')}`);
1916
+ }
1917
+ if (report.global_errors.length > 0) report.all_passed = false;
1918
+ } else if (mapLayouts.length) {
1919
+ const claimedLayouts = new Set((vs.steps || []).map(s => s && s.layout).filter(Boolean));
1920
+ for (const l of mapLayouts) {
1921
+ if (!claimedLayouts.has(l.path) && !claimedLayouts.has(l.name)) continue;
1922
+ const states = (l.runtime_chrome && Array.isArray(l.runtime_chrome.states))
1923
+ ? l.runtime_chrome.states : [];
1924
+ const required = states.filter(st => st && typeof st.name === 'string'
1925
+ && !/root|overview|entry|home/i.test(st.name));
1926
+ if (!required.length) continue;
1927
+ const depictedPerStep = (vs.steps || [])
1928
+ .map(s => String((s && s.depicted_state) || '').toLowerCase()).filter(Boolean);
1929
+ const missing = required.filter(st => {
1930
+ // ALL significant words must appear in ONE step's depicted_state.
1931
+ // (Any-word matching let a "styles editing" step satisfy
1932
+ // "template open for editing"; joined-corpus matching let a nav
1933
+ // enumeration mentioning "Templates" supply the missing word.)
1934
+ const words = st.name.toLowerCase().split(/\s+/).filter(w => w.length >= 4);
1935
+ return words.length && !depictedPerStep.some(d => words.every(w => d.includes(w)));
1936
+ }).map(st => st.name);
1937
+ rsTotal += required.length;
1938
+ rsHits += required.length - missing.length;
1939
+ if (missing.length) {
1940
+ report.global_errors.push(
1941
+ `layout '${l.name || l.path}' records runtime working states that no illustrated ` +
1942
+ `step depicts: ${missing.join('; ')}. The recorded states are the coverage ledger — ` +
1943
+ `add or reallocate a step for each (per its states[].shows line), updating ` +
1944
+ `article.json, view_sources.json (depicted_state naming the state), and its mockup.`
1945
+ );
1946
+ }
1947
+ }
1948
+ if (report.global_errors.length > 0) report.all_passed = false;
1949
+ }
1950
+
1951
+ // Walkthrough runs ship an actions.json next to the mockups; its step count
1952
+ // is the recorder's source of truth for which step is last (the last step
1953
+ // has no advance trigger). Articles have no actions.json, so the interaction
1954
+ // checks (check 7) are skipped entirely for them.
1955
+ let walkthrough = null;
1956
+ const actionsPath = path.join(outputDir, 'actions.json');
1957
+ if (fs.existsSync(actionsPath)) {
1958
+ let stepCount = (vs.steps || []).length;
1959
+ try {
1960
+ const actions = JSON.parse(fs.readFileSync(actionsPath, 'utf8'));
1961
+ if (Array.isArray(actions.steps) && actions.steps.length) stepCount = actions.steps.length;
1962
+ } catch { /* unreadable actions.json — fall back to vs.steps length */ }
1963
+ // Also load narration.json (if present) so check 7 can flag narration that
1964
+ // promises an interaction the mockup never performs. Optional — absent/unreadable
1965
+ // narration just skips that sub-check.
1966
+ const narrationByIndex = {};
1967
+ try {
1968
+ const narration = JSON.parse(fs.readFileSync(path.join(outputDir, 'narration.json'), 'utf8'));
1969
+ for (const s of (narration.steps || [])) {
1970
+ if (s && Number.isInteger(s.index) && typeof s.text === 'string') narrationByIndex[s.index] = s.text;
1971
+ }
1972
+ } catch { /* no/unreadable narration.json — narration sub-check is skipped */ }
1973
+ walkthrough = { lastIndex: stepCount - 1, narrationByIndex };
1974
+ }
1975
+
1976
+ // Global check 8b (WARN only): custom properties consumed without a fallback
1977
+ // in the CSS bundle must be defined SOMEWHERE the render can see — the bundle
1978
+ // itself, the JIT output, or a mockup's own <style>/:root block. An undefined
1979
+ // var invalidates its whole declaration (background: var(--theme) + color:#fff
1980
+ // = invisible white-on-white buttons).
1981
+ let undefinedVarsCount = null;
1982
+ if (brandingCss) {
1983
+ let definitionCorpus = brandingCss + '\n' + mockupCss;
1984
+ for (const step of vs.steps || []) {
1985
+ definitionCorpus += '\n' + loadFileOrEmpty(path.join(outputDir, `step_${step.index}.html`));
1986
+ }
1987
+ const definedAnywhere = new Set();
1988
+ for (const m of definitionCorpus.matchAll(/(?:^|[{;\s"'])(--[A-Za-z0-9_-]+)\s*:/g)) definedAnywhere.add(m[1]);
1989
+ for (const m of definitionCorpus.matchAll(/@property\s+(--[A-Za-z0-9_-]+)/g)) definedAnywhere.add(m[1]);
1990
+ const undefinedVars = findUndefinedCssVars(brandingCss + '\n' + mockupCss)
1991
+ .filter(([name]) => !definedAnywhere.has(name));
1992
+ undefinedVarsCount = undefinedVars.length;
1993
+ if (undefinedVars.length) {
1994
+ const shown = undefinedVars.slice(0, 5)
1995
+ .map(([name, uses]) => `${name} (${uses}x)`).join(', ');
1996
+ report.global_warnings.push(
1997
+ `branding.css consumes custom properties without fallbacks that are never defined: ` +
1998
+ `${shown}${undefinedVars.length > 5 ? ', …' : ''}. Every declaration using them is ` +
1999
+ `invalid at render time (e.g. themed button backgrounds silently vanish). The bundle ` +
2000
+ `is likely missing its theme/tokens stylesheet — re-run /detect-project to repair the cache.`
2001
+ );
2002
+ }
2003
+ }
2004
+
2005
+ for (const step of vs.steps || []) {
2006
+ const externalStep = Boolean(step.external_surface);
2007
+ const { errors, warnings, metrics } = lintStep(step, outputDir, projectDir, brandingCss, colourWhitelist,
2008
+ walkthrough, selectorSets, generatedManifest);
2009
+ // One read + one nav-label count per step; every shell gate below reads
2010
+ // these (they used to be recomputed inline at each site — same list, same
2011
+ // file, so the hoist is behaviour-identical).
2012
+ const stepHtml = loadFileOrEmpty(path.join(outputDir, `step_${step.index}.html`));
2013
+ const navHits = stepHtml ? shellNavLabels.filter(l => stepHtml.includes(l)).length : 0;
2014
+ if (stepHtml && shellNavLabels.length >= 2) metrics.shell_nav_labels = metricRatio(navHits, shellNavLabels.length);
2015
+ if (!externalStep && desktopShell && desktopShell.statusbar) {
2016
+ if (stepHtml && stepHtml.includes('desktop-window') && !stepHtml.includes('desktop-statusbar')) {
2017
+ errors.push(
2018
+ 'app_shell records a persistent status bar but this mockup has no .desktop-statusbar — ' +
2019
+ 'render the shell status bar (last child of the app root) with the content the map describes'
2020
+ );
2021
+ }
2022
+ }
2023
+ // Desktop macro-layout bones: misassembly is a hard error (a pane
2024
+ // outside the fixed desktop-app-rows > desktop-app-columns > panes
2025
+ // assembly renders zero-height); bones used at all while the JIT is
2026
+ // live (css_build recipe present) draws a need-gate warning — contract
2027
+ // variant A mandates the app's own classes there. Gated purely on the
2028
+ // classes appearing in markup, so non-desktop mockups skip untouched.
2029
+ if (!externalStep) {
2030
+ if (stepHtml) {
2031
+ const bones = scanPaneBones(stepHtml);
2032
+ errors.push(...bones.errors);
2033
+ if (bones.used && cssBuildRecipe && !bones.errors.length) {
2034
+ warnings.push(
2035
+ 'desktop-app-*/desktop-pane-* macro-layout bones are used, but project_map.css_build ' +
2036
+ 'records a working recipe — the JIT compiles the mockup\'s own utility classes, so the ' +
2037
+ 'app\'s real layout classes are the mandated macro layout (contract variant A); the ' +
2038
+ 'bones\' !important sizing can only override working CSS here.'
2039
+ );
2040
+ }
2041
+ }
2042
+ }
2043
+ // Per-layout root_classes override + runtime_chrome sampling. A step
2044
+ // claiming a layout that records root_classes is held to THAT layout's
2045
+ // tokens instead of the app-shell defaults (fullscreen/zen modes both
2046
+ // add their mode token and drop the hidden top bar's offset class), so
2047
+ // the app-shell root check below is skipped for it.
2048
+ let layoutRootOverride = false;
2049
+ // Per-layout metric accumulators — SUM across the step's claimed layouts.
2050
+ let lrcTotal = 0, lrcMissing = 0, rrTotal = 0, rrHits = 0, uiTotal = 0, uiHits = 0;
2051
+ if (mapLayouts.length && step.layout) {
2052
+ const claimed = mapLayouts.filter(l => step.layout === l.path || step.layout === l.name);
2053
+ for (const l of claimed) {
2054
+ const lrc = l.root_classes;
2055
+ if (lrc && typeof lrc === 'object' && stepHtml) {
2056
+ layoutRootOverride = true;
2057
+ const toTokens = v => (Array.isArray(v) ? v : String(v || '').split(/\s+/)).filter(Boolean);
2058
+ const have = {
2059
+ html: new Set(extractHtmlClass(stepHtml).split(/\s+/).filter(Boolean)),
2060
+ body: new Set(((stepHtml.match(/<body[^>]*\bclass\s*=\s*"([^"]*)"/i) || [, ''])[1] || '')
2061
+ .split(/\s+/).filter(Boolean)),
2062
+ };
2063
+ for (const root of ['html', 'body']) {
2064
+ const expectedTokens = toTokens(lrc[root]);
2065
+ const missing = expectedTokens.filter(t => !have[root].has(t));
2066
+ lrcTotal += expectedTokens.length;
2067
+ lrcMissing += missing.length;
2068
+ if (missing.length) {
2069
+ errors.push(
2070
+ `this step claims layout '${l.name || l.path}', which records its own ` +
2071
+ `root_classes — <${root}> is missing: ${missing.join(', ')}. A layout's ` +
2072
+ `recorded root state REPLACES the app-shell defaults (its mode tokens and ` +
2073
+ `chrome offsets hang on them).`
2074
+ );
2075
+ }
2076
+ }
2077
+ }
2078
+ const rcw = l.runtime_chrome;
2079
+ if (schemaV2 && rcw && stepHtml) {
2080
+ const states = Array.isArray(rcw.states) ? rcw.states : [];
2081
+ const regions = Array.isArray(rcw.regions) ? rcw.regions : [];
2082
+ const state = states.find(s => s.id === step.runtime_state_id);
2083
+ if (!step.runtime_state_id || !state) {
2084
+ errors.push(`schema-v2 runtime step requires a valid runtime_state_id for layout '${l.name || l.path}'`);
2085
+ } else {
2086
+ const bodyStates = dataAttrTags(stepHtml, 'data-rtfm-state');
2087
+ if (bodyStates.length !== 1 || bodyStates[0].value !== state.id
2088
+ || !/^<body\b/i.test(bodyStates[0].tag)) {
2089
+ errors.push(`step_${step.index}.html must put data-rtfm-state="${state.id}" exactly once on <body>`);
2090
+ }
2091
+ const visibleIds = schemaV3
2092
+ ? new Set(state.visible_regions || [])
2093
+ : new Set(regions.filter(r => r.required).map(r => r.id));
2094
+ if (!schemaV3) {
2095
+ for (const id of state.required_regions || []) visibleIds.add(id);
2096
+ }
2097
+ const requiredRegions = regions.filter(r => visibleIds.has(r.id));
2098
+ rrTotal += requiredRegions.length;
2099
+ for (const r of requiredRegions) {
2100
+ const tags = dataAttrTags(stepHtml, 'data-rtfm-region', r.id);
2101
+ if (tags.length !== 1) {
2102
+ errors.push(`runtime region '${r.id}' must appear exactly once in step_${step.index}.html (found ${tags.length})`);
2103
+ continue;
2104
+ }
2105
+ rrHits++;
2106
+ const placements = dataAttrTags(tags[0].tag, 'data-rtfm-placement');
2107
+ if (placements.length !== 1 || placements[0].value !== r.placement) errors.push(
2108
+ `runtime region '${r.id}' must declare data-rtfm-placement="${r.placement}"`);
2109
+ for (const str of r.required_strings || []) {
2110
+ if (!stepHtml.includes(str)) errors.push(`runtime region '${r.id}' requires visible string "${str}"`);
2111
+ }
2112
+ if (schemaV4) {
2113
+ let previous = -1;
2114
+ for (const item of r.items || []) {
2115
+ const marker = `${r.id}:${item.id}`;
2116
+ const itemTags = dataAttrTags(stepHtml, 'data-rtfm-item', marker);
2117
+ if (itemTags.length !== 1) {
2118
+ errors.push(`runtime item '${marker}' must appear exactly once in step_${step.index}.html (found ${itemTags.length})`);
2119
+ continue;
2120
+ }
2121
+ const position = stepHtml.indexOf(itemTags[0].tag, previous + 1);
2122
+ if (position <= previous) errors.push(
2123
+ `runtime item '${marker}' must appear in the recipe's recorded order`);
2124
+ previous = position;
2125
+ if (schemaV5) {
2126
+ for (const str of item.required_strings || []) {
2127
+ if (!stepHtml.includes(str)) errors.push(
2128
+ `runtime item '${marker}' requires visible string "${str}"`);
2129
+ }
2130
+ }
2131
+ }
2132
+ if (schemaV5 && r.placement.startsWith('overlay-')
2133
+ && /\b(?:no changes?|unchanged)\b/i.test(stepHtml)) errors.push(
2134
+ `runtime confirmation must list concrete changed entities, not unchanged/no-change rows`);
2135
+ if (r.placement === 'canvas') {
2136
+ for (const asset of r.representative_assets || []) {
2137
+ const assetTags = dataAttrTags(stepHtml, 'data-rtfm-asset', asset);
2138
+ if (assetTags.length !== 1) errors.push(
2139
+ `runtime canvas asset '${asset}' must appear exactly once in step_${step.index}.html (found ${assetTags.length})`);
2140
+ }
2141
+ const outlineCount = (stepHtml.match(/\boutline\s*:\s*[^;"']+/gi) || []).length;
2142
+ if (outlineCount > r.max_selected_outlines) errors.push(
2143
+ `runtime canvas permits at most ${r.max_selected_outlines} selected outline(s); found ${outlineCount}`);
2144
+ }
2145
+ }
2146
+ }
2147
+ if (schemaV3) {
2148
+ const recipeIds = new Set(regions.map(r => r.id));
2149
+ const renderedIds = dataAttrTags(stepHtml, 'data-rtfm-region')
2150
+ .map(t => t.value).filter(id => recipeIds.has(id));
2151
+ for (const id of renderedIds) {
2152
+ if (!visibleIds.has(id)) errors.push(
2153
+ `runtime region '${id}' is not visible in state '${state.id}' and must not render in step_${step.index}.html`);
2154
+ }
2155
+ }
2156
+ for (const str of state.required_strings || []) {
2157
+ if (!stepHtml.includes(str)) errors.push(`runtime state '${state.id}' requires visible string "${str}"`);
2158
+ }
2159
+ const expectedSources = [...new Set([
2160
+ ...(rcw.source_files || []),
2161
+ ...requiredRegions.flatMap(r => r.source_files || []),
2162
+ ...(state.source_files || []),
2163
+ ])].sort();
2164
+ const declared = Array.isArray(step.runtime_source_files) ? [...new Set(step.runtime_source_files)].sort() : [];
2165
+ if (!sameStringSet(expectedSources, declared)) errors.push(
2166
+ `runtime_source_files must exactly match the selected recipe sources; expected [${expectedSources.join(', ')}], got [${declared.join(', ')}]`);
2167
+ const allStepSources = new Set([step.primary_view, step.layout,
2168
+ ...(step.partials_expanded || [])].filter(Boolean));
2169
+ const omitted = expectedSources.filter(s => !allStepSources.has(s));
2170
+ if (omitted.length) errors.push(`runtime recipe source(s) missing from primary/layout/partials: ${omitted.join(', ')}`);
2171
+ }
2172
+ }
2173
+ const uiStrings = (rcw && Array.isArray(rcw.regions) ? rcw.regions : [])
2174
+ .flatMap(r => (r && Array.isArray(r.ui_strings)) ? r.ui_strings : [])
2175
+ .filter(s => typeof s === 'string' && s.length >= 3);
2176
+ // Measured on every schema (metric); enforced below only on legacy maps.
2177
+ const stepUiHits = (uiStrings.length && stepHtml)
2178
+ ? uiStrings.filter(s => stepHtml.includes(s)).length : null;
2179
+ if (stepUiHits !== null) {
2180
+ uiTotal += uiStrings.length;
2181
+ uiHits += stepUiHits;
2182
+ }
2183
+ if (!schemaV2 && stepUiHits !== null) {
2184
+ // Majority of the recipe's recorded strings (floor 3): a thin
2185
+ // stand-in typically carries the obvious few (Publish, Add
2186
+ // title) while dropping whole regions.
2187
+ const hits = stepUiHits;
2188
+ const need = Math.min(uiStrings.length, Math.max(3, Math.ceil(uiStrings.length / 2)));
2189
+ if (hits < need) {
2190
+ warnings.push(
2191
+ `layout '${l.name || l.path}' records runtime_chrome for its embedded app but ` +
2192
+ `only ${hits}/${uiStrings.length} of its recorded UI strings appear in this ` +
2193
+ `mockup — render the recipe's regions in their recorded order with their real labels.`
2194
+ );
2195
+ }
2196
+ }
2197
+ // Mode contradiction: the layout records NO visible app chrome
2198
+ // (chrome: []) plus a runtime app that draws its own (runtime_chrome),
2199
+ // yet the mockup renders the app shell — the wrong-mode failure
2200
+ // (admin sidebar wrapped around a fullscreen editor). Majority
2201
+ // nav-label gate as elsewhere.
2202
+ if (rcw && Array.isArray(l.chrome) && l.chrome.length === 0
2203
+ && shellNavLabels.length >= 2 && stepHtml) {
2204
+ if (navHits >= Math.max(3, Math.ceil(shellNavLabels.length / 2))) {
2205
+ errors.push(
2206
+ `this step claims layout '${l.name || l.path}', which records no visible app ` +
2207
+ `chrome (chrome: []) and a runtime_chrome recipe — but the mockup renders the ` +
2208
+ `app shell (${navHits}/${shellNavLabels.length} nav labels present). In this ` +
2209
+ `layout's default mode the runtime app draws ALL chrome: remove the sidebar/top ` +
2210
+ `bar and render the recipe's regions instead.`
2211
+ );
2212
+ }
2213
+ }
2214
+ }
2215
+ }
2216
+ if (lrcTotal) metrics.layout_root_classes = metricRatio(lrcTotal - lrcMissing, lrcTotal);
2217
+ if (rrTotal) metrics.runtime_regions_rendered = metricRatio(rrHits, rrTotal);
2218
+ if (uiTotal) metrics.runtime_ui_strings = metricRatio(uiHits, uiTotal);
2219
+ if (!externalStep && rootShell && !layoutRootOverride) {
2220
+ // Enforce only on mockups that actually render the app shell (≥2 of
2221
+ // the recorded nav labels present) — standalone pages (login, public)
2222
+ // legitimately carry no shell and no root state class.
2223
+ const shellShown = navHits >= 2;
2224
+ if (stepHtml && shellShown) {
2225
+ const have = new Set(extractHtmlClass(stepHtml).split(/\s+/).filter(Boolean));
2226
+ const missing = rootShell.htmlTokens.filter(t => !have.has(t));
2227
+ metrics.root_classes_html = metricRatio(rootShell.htmlTokens.length - missing.length, rootShell.htmlTokens.length);
2228
+ if (missing.length) {
2229
+ errors.push(
2230
+ `<html> element is missing class token(s) recorded in app_shell.root_classes: ` +
2231
+ `${missing.join(', ')}. These are load-bearing — fixed-chrome offsets ` +
2232
+ `(html.<class> { padding-top: … }) and theme scoping hang on them; without them ` +
2233
+ `fixed top bars overlap the content. Add them to the mockup's <html> tag.`
2234
+ );
2235
+ }
2236
+ }
2237
+ }
2238
+ if (!externalStep && gameShell) {
2239
+ if (stepHtml) {
2240
+ const screenCount = (stepHtml.match(/class="[^"]*\bgame-screen\b[^"]*"/g) || []).length;
2241
+ if (screenCount !== 1) {
2242
+ errors.push(
2243
+ `game mockups must render inside exactly one .game-screen playfield (found ${screenCount}) — ` +
2244
+ 'content outside a sized in-flow screen collapses to a blank crop'
2245
+ );
2246
+ }
2247
+ // HUD backstop, canvas-state steps only: a step is canvas-drawn exactly
2248
+ // when its primary_view is the draw-code file. (Do NOT infer from a
2249
+ // .game-scene SVG in the markup — UI screens legitimately sit over a
2250
+ // scene backdrop and show no HUD.)
2251
+ const isCanvasStep = gameShell.canvasFile && step.primary_view === gameShell.canvasFile;
2252
+ const shell = gameShell.appShell;
2253
+ const hasHudSpec = shell && (shell.hud_selector
2254
+ || (Array.isArray(shell.hud_fields) && shell.hud_fields.length > 0));
2255
+ if (isCanvasStep && hasHudSpec) {
2256
+ const hudSel = String(shell.hud_selector || '').replace(/^[#.]/, '');
2257
+ const hasHud = stepHtml.includes('game-hud') || (hudSel && stepHtml.includes(hudSel));
2258
+ if (!hasHud) {
2259
+ errors.push(
2260
+ 'app_shell records a persistent in-game HUD but this canvas-state mockup renders ' +
2261
+ "neither the app's real HUD markup nor .game-hud — a gameplay screenshot without " +
2262
+ 'its HUD fails realism'
2263
+ );
2264
+ }
2265
+ }
2266
+ }
2267
+ }
2268
+ if (layoutChrome && step.layout) {
2269
+ // Majority of recorded nav labels (floor 3), not just ≥2: shell labels
2270
+ // are common words ("Pages", "Settings") that appear incidentally in
2271
+ // non-shell content — a rendered sidebar carries most of its items.
2272
+ const shellShown = navHits >= Math.max(3, Math.ceil(shellNavLabels.length / 2));
2273
+ const partials = (step.partials_expanded || []).filter(p => typeof p === 'string');
2274
+ // A step "claims" a layout when its layout key names it — by path OR
2275
+ // by the map's layout name (models record either).
2276
+ const claimed = !shellShown ? [] : layoutChrome.filter(l =>
2277
+ step.layout === l.path || step.layout === l.name);
2278
+ const partialsLower = partials.map(p => p.toLowerCase());
2279
+ const basenames = partials.map(p => path.basename(p).replace(/^_/, '').toLowerCase());
2280
+ let cfTotal = 0, cfHits = 0; // chrome_files_expanded: checkable entries (prose excluded)
2281
+ for (const l of claimed) {
2282
+ const missingFiles = [];
2283
+ const missingNames = [];
2284
+ let checkable = 0;
2285
+ for (const entry of l.chrome) {
2286
+ if (typeof entry !== 'string' || !entry.trim()) continue;
2287
+ if (partials.includes(entry)) { checkable++; continue; }
2288
+ if (fs.existsSync(path.join(projectDir, entry))) {
2289
+ checkable++;
2290
+ missingFiles.push(entry); // real file, never expanded — hard
2291
+ continue;
2292
+ }
2293
+ if (/\s/.test(entry.trim())) continue; // prose note — uncheckable
2294
+ checkable++;
2295
+ const token = entry.split('/').pop().replace(/^_/, '').toLowerCase();
2296
+ const hit = token && (basenames.some(b => b.includes(token))
2297
+ || partialsLower.some(p => p.includes(token)));
2298
+ if (!hit) missingNames.push(entry); // legacy name-like entry — soft
2299
+ }
2300
+ cfTotal += checkable;
2301
+ cfHits += checkable - missingFiles.length - missingNames.length;
2302
+ if (missingFiles.length) {
2303
+ errors.push(
2304
+ `this step claims layout '${l.name || l.path}' but never expanded its recorded ` +
2305
+ `chrome file(s): ${missingFiles.join(', ')}. The layout renders these around the ` +
2306
+ `page content unconditionally — open each one, render the region it emits, and ` +
2307
+ `list it in partials_expanded. A chrome file you didn't open is a chrome region ` +
2308
+ `the mockup cannot render.`
2309
+ );
2310
+ }
2311
+ if (missingNames.length) {
2312
+ warnings.push(
2313
+ `layout '${l.name || l.path}' records chrome ${missingNames.join(', ')} with no ` +
2314
+ `matching entry in partials_expanded — verify the mockup renders those shell ` +
2315
+ `regions (top bar / nav / footer content), not just the branch the step acts on.`
2316
+ );
2317
+ }
2318
+ }
2319
+ if (cfTotal) metrics.chrome_files_expanded = metricRatio(cfHits, cfTotal);
2320
+ }
2321
+ report.steps.push({
2322
+ index: step.index,
2323
+ primary_view: step.primary_view || null,
2324
+ errors, warnings, metrics,
2325
+ });
2326
+ if (errors.length > 0) report.all_passed = false;
2327
+ }
2328
+
2329
+ // Typed-data continuity (walkthroughs only). A value the walkthrough types into a
2330
+ // field should resurface in a LATER step — creating/editing an item and then landing
2331
+ // on a list/calendar/detail that shows none of what was entered is the "empty result"
2332
+ // failure. WARN only, and only when NONE of the typed values reappear anywhere later
2333
+ // (passwords / search terms / sent-and-cleared messages legitimately don't resurface,
2334
+ // so a partial match is fine). Presence-of-string check — no fragile emptiness heuristic.
2335
+ if (walkthrough) {
2336
+ const htmlByIndex = {};
2337
+ const typed = []; // { index, value }
2338
+ for (const step of vs.steps || []) {
2339
+ const h = loadFileOrEmpty(path.join(outputDir, `step_${step.index}.html`));
2340
+ htmlByIndex[step.index] = h;
2341
+ for (const m of h.matchAll(/data-walkthrough-type-text\s*=\s*["']([^"']+)["']/gi)) {
2342
+ const v = m[1].trim();
2343
+ if (v.length >= 3) typed.push({ index: step.index, value: v });
2344
+ }
2345
+ }
2346
+ const indices = Object.keys(htmlByIndex).map(Number);
2347
+ // Only values typed before the last step can be expected to reappear downstream.
2348
+ const trackable = typed.filter(t => t.index < walkthrough.lastIndex);
2349
+ const orphaned = trackable.filter(t =>
2350
+ !indices.some(i => i > t.index && htmlByIndex[i] && htmlByIndex[i].includes(t.value)));
2351
+ if (trackable.length > 0 && orphaned.length === trackable.length) {
2352
+ report.global_warnings.push(
2353
+ `none of the values typed in this walkthrough (${[...new Set(trackable.map(t => `"${t.value}"`))].join(', ')}) ` +
2354
+ `appear in any later step. If the walkthrough creates or edits an item, the outcome step must ` +
2355
+ `display it — labelled with what was entered — not an empty list/calendar/"no results" state. ` +
2356
+ `(Ignore for passwords, search terms, or messages that are sent and cleared.)`
2357
+ );
2358
+ }
2359
+ }
2360
+
2361
+ if (report.global_errors.length > 0) report.all_passed = false;
2362
+ // Report-level metrics — assembled BEFORE the block-mode rename below, which
2363
+ // deletes report.steps.
2364
+ report.metrics = {
2365
+ action_coverage: actionCoverageMetric,
2366
+ runtime_states_selected: rsTotal ? metricRatio(rsHits, rsTotal) : null,
2367
+ undefined_css_vars: undefinedVarsCount === null ? null : { count: undefinedVarsCount },
2368
+ errors_total: report.global_errors.length + report.steps.reduce((n, s) => n + s.errors.length, 0),
2369
+ warnings_total: report.global_warnings.length + report.steps.reduce((n, s) => n + s.warnings.length, 0),
2370
+ };
2371
+ if (blockMode) {
2372
+ report.blocks = report.steps.map(step => ({
2373
+ ...step,
2374
+ block_id: vs.steps.find(entry => entry.index === step.index)?.block_id,
2375
+ }));
2376
+ delete report.steps;
2377
+ }
2378
+ fs.writeFileSync(path.join(outputDir, 'lint_report.json'), JSON.stringify(report, null, 2));
2379
+
2380
+ for (const e of report.global_errors) {
2381
+ console.log(`[FAIL] (global) ERROR: ${e}`);
2382
+ }
2383
+ for (const w of report.global_warnings) {
2384
+ console.log(`[WARN] (global) WARN: ${w}`);
2385
+ }
2386
+ for (const s of report.blocks || report.steps) {
2387
+ const marker = s.errors.length > 0 ? '[FAIL]' : (s.warnings.length > 0 ? '[WARN]' : '[ OK ]');
2388
+ console.log(`${marker} ${s.block_id ? `block_${s.block_id}` : `step_${s.index}`} (${s.primary_view || '<no primary_view>'})`);
2389
+ for (const e of s.errors) console.log(` ERROR: ${e}`);
2390
+ for (const w of s.warnings) console.log(` WARN: ${w}`);
2391
+ }
2392
+
2393
+ if (!report.all_passed) {
2394
+ console.log('');
2395
+ console.log('Mockup-fidelity lint failed. Regenerate the offending step(s) using the ERROR text above.');
2396
+ console.log(`Structured findings: ${path.join(outputDir, 'lint_report.json')}`);
2397
+ process.exit(1);
2398
+ }
2399
+ console.log('\nAll steps passed mockup-fidelity lint.');
2400
+ process.exit(0);
2401
+ }
2402
+
2403
+ main();