@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,1148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * polish_tickets.js — deterministic "where to look" tickets for the POLISH phase
4
+ * of generate-illustrated-article (Phase 4), and the matching --report.
5
+ *
6
+ * The model never looks at its own render. Every input-side gate (the fidelity
7
+ * lint, the copy lint) reads text, and a mockup can pass all of them while the
8
+ * PNG is missing whole regions: a sidebar copied faithfully from source with a
9
+ * `hidden lg:flex` class pair scores 4/4 shell labels and paints 0 px at the
10
+ * capture width. This script turns the structured signals nobody consumes —
11
+ * lint_report.json metrics, block diagnostics (render score, evidence and probe
12
+ * visibility), the project map's app_shell / layouts[].chrome, and the block's
13
+ * own source files — into per-block QUESTIONS that tell the model where to look
14
+ * in the PNG and which files to compare against. Tickets are where to look,
15
+ * never text to transcribe: the model answers each question against the PNG
16
+ * and the sources and repairs by ADDING what is missing.
17
+ *
18
+ * Usage:
19
+ * polish_tickets.js <article_dir> [project_root] → <article_dir>/polish_tickets.json
20
+ * polish_tickets.js <article_dir> [project_root] --restore → …and copy each ticketed block's
21
+ * .html.pre (raw authored HTML) back
22
+ * over its injected .html for editing
23
+ * polish_tickets.js <article_dir> [project_root] --report → <article_dir>/polish_report.json
24
+ *
25
+ * Always exits 0 and writes ONLY its own output file (plus the --restore
26
+ * copies, which only ever put the model's own authored HTML back). Missing or
27
+ * unparsable inputs degrade to fewer questions, never to a failure — the phase
28
+ * is additive and must not be able to break the pipeline.
29
+ *
30
+ * Gate (script-side, like watermark.js): RTFM_POLISH in off/0/false/no writes
31
+ * {"enabled": false} and prints `polish: disabled`. Caps: RTFM_POLISH_MAX_EDITS
32
+ * (per block, default 6) and RTFM_POLISH_MAX_BLOCKS (0 = all).
33
+ *
34
+ * Every question carries a machine re-checkable `check` (or {type:"manual"});
35
+ * --report re-evaluates them against the re-lint, the fresh diagnostics, and
36
+ * the edited HTML, and records per block whether the HTML/PNG changed and how
37
+ * the render score and chrome mean moved. `html_changed` (raw authored HTML
38
+ * sha) is the "the model edited this block" signal; `png_changed` is byte-level
39
+ * and a plain re-render is NOT always byte-identical (font/CDN timing), so it
40
+ * only says "re-rendered", never "visibly different". The chrome mean is the
41
+ * mean of the six chrome ratios that apply, matching the offline scorer. None of this feeds an error or an exit code.
42
+ */
43
+ 'use strict';
44
+
45
+ const fs = require('fs');
46
+ const path = require('path');
47
+ const crypto = require('crypto');
48
+ const includeCensus = require('./include_census.js');
49
+
50
+ const SCHEMA_VERSION = 1;
51
+ const CHROME_KEYS = ['shell_nav_labels', 'chrome_files_expanded', 'runtime_ui_strings',
52
+ 'runtime_regions_rendered', 'root_classes_html', 'layout_root_classes'];
53
+ const T = {
54
+ shell_labels: 0.8, shell_labels_blocking: 0.5,
55
+ chrome_files: 1, runtime_strings: 0.8,
56
+ styled_coverage: 0.6, styled_min_total: 10,
57
+ render_score: 80,
58
+ };
59
+ const CAPS = { components: 6, labels: 12, form_fields: 12, nav: 12, read_order: 10, scan_files: 8 };
60
+ const FILE_READ_CAP = 400 * 1024;
61
+ const CONTENT_DEDUCTION_RE = /blank|few visible|cut off|geometry/i;
62
+ const LOCALE_PATH_RE = /(?:^|\/)(?:locales?|i18n|lang|translations?|messages|l10n)(?:\/|\.)/i;
63
+ const LOCALE_EXT_RE = /\.(?:json|ya?ml|arb)$/i;
64
+ // PascalCase tags that are UI primitives, not screen regions — a mockup never
65
+ // "corresponds" to a <Button>, so they never earn a component ticket.
66
+ const UI_PRIMITIVES = new Set(('Button Icon Tooltip Dialog Link Input Select Form Label Badge Avatar Skeleton Trans ' +
67
+ 'Head Meta Fragment Suspense Provider Image Img Text Box Flex Grid Stack Card Container Divider Spinner ' +
68
+ 'Loader Portal Popover Menu MenuItem DropdownMenu Checkbox Switch Radio Textarea TextField Alert Toast ' +
69
+ 'Modal Sheet Tabs Tab Table Th Td Tr Slot Transition Motion ErrorBoundary Script Layout Component Title ' +
70
+ 'Description Separator Toaster Helmet Router Routes Route Outlet NavLink Anchor Heading Paragraph Span ' +
71
+ 'Center Group Row Col Column Section Main Header Footer Nav Aside Article Wrapper Root Content Trigger ' +
72
+ 'Item List ListItem Field FormField FormItem FormLabel FormControl FormMessage Kbd Code Pre Strong Em Small').split(' '));
73
+ // Suffixes/prefixes that mark a PascalCase tag as a primitive, an icon, or a
74
+ // TypeScript type used in a generic (<Props>, <Params>) rather than a region.
75
+ const PRIMITIVE_SUFFIX_RE = /(?:Provider|Context|Boundary|Icon|Trigger|Portal|Props|Params|Button|Link)$/;
76
+ const ICON_PREFIX_RE = /^(?:Icon|Lucide|Tabler|Fa|Md|Hi|Bi|Ri|Io)[A-Z]/;
77
+ // Labels are short and un-punctuated; sentences ending in . ! ? are messages
78
+ // (errors, toasts, confirmations) that no screen shows in its default state.
79
+ const MESSAGE_LABEL_RE = /[.!?]$|^(?:sorry|error|invalid|please|you (?:are|have|need|do not|don't|can(?:not|'t))|are you sure|something went wrong|failed to|unable to)\b/i;
80
+ const MAX_LABEL_WORDS = 6;
81
+ // Paths the model sometimes lists as sources but which are never screen sources.
82
+ const NON_SOURCE_PATH_RE = /(?:^|\/)(?:\.rtfm(?:-branding)?|\.rtfm-trace|output|node_modules|\.git)\//;
83
+ // Only literal labels can be checked for presence; prose descriptions
84
+ // ("nav links: Home, Groups, …", "'Howdy, <name>' account menu") ride along
85
+ // as context for the compare question instead.
86
+ function isLiteralLabel(s) {
87
+ const t = norm(s);
88
+ if (t.length < 2 || t.length > 40) return false;
89
+ if (/[():<>"'—–\[\]{}|]/.test(t)) return false;
90
+ if (!/^[A-Z0-9]/.test(t)) return false; // descriptions start lowercase ("updates badge with count")
91
+ return t.split(' ').length <= 4;
92
+ }
93
+ // Overlays are hidden in a screen's default state; a block that depicts one
94
+ // names its file as primary_view, so an overlay reached through a layout or
95
+ // chrome file is never a missing region.
96
+ const OVERLAY_NAME_RE = /modal|dialog|drawer|toast|tooltip|popover|snackbar|lightbox|flash/i;
97
+ // A shell-carrying layout is one the map ties the shell to; a layout whose own
98
+ // description says it has no sidebar / is fullscreen does not grow one.
99
+ const SHELL_ABSENT_RE = /\bno (?:left |right |app |persistent )?(?:sidebar|side bar|side-bar|app shell|shell|navigation)\b|without (?:the |a )?(?:sidebar|shell)|\b(?:fullscreen|full-screen|full screen)\b|hides? the (?:app )?shell|shell(?: is)? hidden/i;
100
+
101
+ // ─── small utilities ─────────────────────────────────────────────────────────
102
+ function polishEnabled(env = process.env) {
103
+ const v = String(env.RTFM_POLISH ?? 'on').trim().toLowerCase();
104
+ return !(v === 'off' || v === '0' || v === 'false' || v === 'no');
105
+ }
106
+ function intEnv(name, dflt, env = process.env) {
107
+ const n = parseInt(String(env[name] ?? ''), 10);
108
+ return Number.isFinite(n) && n >= 0 ? n : dflt;
109
+ }
110
+ function readJson(p) {
111
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
112
+ }
113
+ function readText(p, cap = FILE_READ_CAP) {
114
+ try {
115
+ const st = fs.statSync(p);
116
+ if (!st.isFile()) return '';
117
+ const fd = fs.openSync(p, 'r');
118
+ try {
119
+ const len = Math.min(st.size, cap);
120
+ const buf = Buffer.alloc(len);
121
+ fs.readSync(fd, buf, 0, len, 0);
122
+ return buf.toString('utf8');
123
+ } finally { fs.closeSync(fd); }
124
+ } catch { return ''; }
125
+ }
126
+ function sha256File(p) {
127
+ try { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } catch { return null; }
128
+ }
129
+ function isFile(p) { try { return fs.statSync(p).isFile(); } catch { return false; } }
130
+ function norm(s) { return String(s).replace(/\s+/g, ' ').trim(); }
131
+ function uniq(arr) { return [...new Set(arr)]; }
132
+ function ratioOf(m) {
133
+ return m && typeof m === 'object' && typeof m.ratio === 'number' ? m.ratio : null;
134
+ }
135
+ function countOf(m) {
136
+ return m && typeof m === 'object' && typeof m.count === 'number' ? m.count : null;
137
+ }
138
+ function chromeMean(metrics) {
139
+ if (!metrics || typeof metrics !== 'object') return null;
140
+ const vals = CHROME_KEYS.map(k => ratioOf(metrics[k])).filter(v => v !== null);
141
+ if (!vals.length) return null;
142
+ return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 1000) / 1000;
143
+ }
144
+ function extractVisibleText(html) {
145
+ let s = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ');
146
+ s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
147
+ s = s.replace(/<!--[\s\S]*?-->/g, ' ');
148
+ s = s.replace(/<[^>]+>/g, ' ');
149
+ s = s.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&');
150
+ return s.replace(/\s+/g, ' ').trim();
151
+ }
152
+ // Lower-cased tokens from id / class / data-* attribute values — the mockup's
153
+ // structural vocabulary (a region named after a component "corresponds").
154
+ function attrTokens(html) {
155
+ const out = new Set();
156
+ for (const m of html.matchAll(/\b(?:id|class|data-[a-z0-9-]+)\s*=\s*["']([^"']*)["']/gi)) {
157
+ for (const tok of m[1].split(/[\s,;|]+/)) {
158
+ const t = tok.trim().toLowerCase();
159
+ if (t) out.add(t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''));
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+ function nameTokens(name) {
165
+ const kebab = name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
166
+ return uniq([name.toLowerCase(), kebab, kebab.replace(/-/g, '_'), kebab.replace(/-/g, '')]);
167
+ }
168
+ function countFormControls(html) {
169
+ let n = 0;
170
+ for (const m of html.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
171
+ if (m[1].toLowerCase() === 'input' && /\btype\s*=\s*["']?hidden\b/i.test(m[2])) continue;
172
+ n++;
173
+ }
174
+ for (const m of html.matchAll(/\brole\s*=\s*["'](combobox|switch|checkbox|radio|textbox|spinbutton|slider)["']/gi)) {
175
+ if (m) n++;
176
+ }
177
+ return n;
178
+ }
179
+
180
+ // ─── inputs ──────────────────────────────────────────────────────────────────
181
+ function loadProjectMap(root) {
182
+ for (const d of ['.rtfm', '.rtfm-branding']) {
183
+ const pm = readJson(path.join(root, d, 'project_map.json'));
184
+ if (pm) return pm;
185
+ }
186
+ return null;
187
+ }
188
+ function shellLabels(items) {
189
+ const out = [];
190
+ const walk = list => {
191
+ for (const it of Array.isArray(list) ? list : []) {
192
+ if (!it) continue;
193
+ const label = typeof it === 'string' ? it : it.label;
194
+ if (typeof label === 'string' && label.trim().length >= 3) out.push(label.trim());
195
+ if (it && Array.isArray(it.children)) walk(it.children);
196
+ }
197
+ };
198
+ walk(items);
199
+ return uniq(out);
200
+ }
201
+ function shellSummary(pm) {
202
+ const shell = pm && pm.app_shell;
203
+ if (!shell || typeof shell !== 'object') return null;
204
+ // The lint's shell_nav_labels metric uses TOP-LEVEL nav labels only; the
205
+ // probe and the tickets look one level deeper too, but the metric-mirroring
206
+ // list must match the lint so a ticket names the labels the metric counted.
207
+ const topLevel = (shell.nav_items || []).map(n => n && n.label)
208
+ .filter(l => typeof l === 'string' && l.length >= 3);
209
+ const topBar = shell.top_bar && typeof shell.top_bar === 'object' ? shell.top_bar : null;
210
+ return {
211
+ type: shell.type || null,
212
+ layout: shell.layout || null,
213
+ nav_labels: topLevel,
214
+ nav_labels_all: shellLabels(shell.nav_items),
215
+ footer_labels: shellLabels(shell.footer_items),
216
+ account_area: typeof shell.account_area === 'string' ? shell.account_area
217
+ : (shell.account_area ? JSON.stringify(shell.account_area) : null),
218
+ top_bar: topBar ? {
219
+ items_left: shellLabels(topBar.items_left), items_right: shellLabels(topBar.items_right),
220
+ } : null,
221
+ notes: typeof shell.notes === 'string' ? shell.notes : null,
222
+ };
223
+ }
224
+ function layoutFor(pm, ref) {
225
+ if (!pm || !ref || !Array.isArray(pm.layouts)) return null;
226
+ const want = String(ref).trim();
227
+ const hit = pm.layouts.find(l => l && (l.name === want || l.path === want))
228
+ || pm.layouts.find(l => l && typeof l.path === 'string' && (l.path.endsWith('/' + want) || want.endsWith('/' + l.path)));
229
+ if (!hit) return null;
230
+ return {
231
+ name: hit.name || null,
232
+ path: typeof hit.path === 'string' ? hit.path : null,
233
+ chrome: Array.isArray(hit.chrome) ? hit.chrome.filter(c => typeof c === 'string') : null,
234
+ runtime_chrome: hit.runtime_chrome && typeof hit.runtime_chrome === 'object' ? hit.runtime_chrome : null,
235
+ root_classes: hit.root_classes || null,
236
+ area: typeof hit.area === 'string' ? hit.area : null,
237
+ };
238
+ }
239
+ function blockEntries(vs) {
240
+ const raw = (vs && (vs.blocks || vs.steps)) || [];
241
+ return raw.map((entry, position) => {
242
+ if (!entry || typeof entry !== 'object') return null;
243
+ const legacy = entry.block_id === undefined;
244
+ const key = legacy ? String(entry.index ?? position) : String(entry.block_id);
245
+ const base = legacy ? `step_${key}` : `block_${key}`;
246
+ return {
247
+ key, legacy, position,
248
+ block_id: legacy ? null : String(entry.block_id),
249
+ index: Number.isInteger(entry.index) ? entry.index : position,
250
+ base,
251
+ html: `${base}.html`, pre: `${base}.html.pre`, png: `${base}.png`,
252
+ diagnostics: `${base}_diagnostics.json`,
253
+ entry,
254
+ };
255
+ }).filter(Boolean);
256
+ }
257
+ function lintEntryFor(lint, block) {
258
+ const list = (lint && (lint.blocks || lint.steps)) || [];
259
+ return list.find(e => e && (block.block_id !== null
260
+ ? String(e.block_id) === block.block_id
261
+ : Number(e.index) === Number(block.index))) || null;
262
+ }
263
+ function evidenceStrings(entry) {
264
+ return (entry.verbatim_evidence || [])
265
+ .map(e => (typeof e === 'string' ? e : (e && e.string) || ''))
266
+ .map(norm).filter(Boolean);
267
+ }
268
+ function absenceStrings(entry) {
269
+ const out = [];
270
+ for (const a of Array.isArray(entry.default_user_assumptions) ? entry.default_user_assumptions : []) {
271
+ for (const s of (a && a.markup_absence_check) || []) if (typeof s === 'string') out.push(s.toLowerCase());
272
+ }
273
+ return out;
274
+ }
275
+ function actionTargetsFor(vs, block) {
276
+ const keys = new Set([block.key]);
277
+ if (block.block_id !== null) keys.add(String(block.index));
278
+ const out = [];
279
+ for (const e of Array.isArray(vs && vs.action_coverage) ? vs.action_coverage : []) {
280
+ if (!e) continue;
281
+ const k = e.screenshot_block_id ?? e.screenshot_step_index;
282
+ if (k !== undefined && keys.has(String(k)) && typeof e.target === 'string' && e.target.trim()) {
283
+ out.push({ target: norm(e.target), kind: e.kind || null });
284
+ }
285
+ }
286
+ return out;
287
+ }
288
+
289
+ // ─── source scanners (generic, regex-only, bounded) ─────────────────────────
290
+ const TEMPLATE_NOISE_RE = /[{}<>]|<%|\$\{|#\{|%}|=>|\bt\(|\bI18n\b|__\(|\bthis\.|\bprops\.|\bstate\./;
291
+ function cleanLabel(s) {
292
+ let t = String(s).replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
293
+ if (t.length < 3 || t.length > 60) return null;
294
+ if (TEMPLATE_NOISE_RE.test(t)) return null;
295
+ if (!/[A-Za-z]/.test(t)) return null;
296
+ if (/^[a-z][A-Za-z0-9_]*$/.test(t) && /[A-Z_]/.test(t)) return null; // identifier, not copy
297
+ if (/^[\w.-]+\.[\w.-]+$/.test(t)) return null; // dotted key / filename
298
+ if (/%\d*\$?[sd]|&#|\\"|\{\{|\}\}|\$\w/.test(t)) return null; // printf / entity / mustache / $var placeholders
299
+ return t;
300
+ }
301
+ function isScreenLabel(s) {
302
+ return !MESSAGE_LABEL_RE.test(s) && s.split(' ').length <= MAX_LABEL_WORDS;
303
+ }
304
+ // opts.nav: also harvest link text and label:/title: object literals (layout and
305
+ // chrome files). opts.visibleOnly: drop aria-label/title/alt — attributes that
306
+ // never paint, so their absence is not something a PNG comparison can show.
307
+ function extractLabels(text, opts = {}) {
308
+ const out = [];
309
+ const push = s => { const c = cleanLabel(s); if (c) out.push(c); };
310
+ for (const m of text.matchAll(/<(label|button|th|h1|h2|h3|legend|summary|option)\b[^>]*>([\s\S]{0,400}?)<\/\1>/gi)) push(m[2]);
311
+ // Text children of components: <Button>Save</Button>, <Trans>…</Trans>, <Badge>New</Badge>.
312
+ for (const m of text.matchAll(/<([A-Z][A-Za-z0-9.]*)\b[^>]*>([^<{}]{3,60})<\/\1>/g)) push(m[2]);
313
+ for (const m of text.matchAll(/\bplaceholder\s*=\s*["']([^"'{}<>]{3,60})["']/gi)) push(m[1]);
314
+ if (!opts.visibleOnly) {
315
+ for (const m of text.matchAll(/\b(?:aria-label|title|alt)\s*=\s*["']([^"'{}<>]{3,60})["']/gi)) push(m[1]);
316
+ }
317
+ for (const m of text.matchAll(/\b(?:__|_e|esc_html__|esc_html_e|esc_attr__|esc_attr_e|_x|gettext|dgettext|ngettext)\(\s*['"]([^'"]{3,60})['"]/g)) push(m[1]);
318
+ // Rails helpers: link_to "Home", button_to "Save", f.submit "Create", f.label :x, "Label", submit_tag "Go".
319
+ for (const m of text.matchAll(/\b(?:link_to|button_to|submit_tag|button_tag|label_tag)\s*\(?\s*["']([^"'{}<>#]{3,60})["']/g)) push(m[1]);
320
+ for (const m of text.matchAll(/\b\w+\.(?:submit|button|label)\s*\(?\s*(?::\w+\s*,\s*)?["']([^"'{}<>#]{3,60})["']/g)) push(m[1]);
321
+ if (opts.nav) {
322
+ for (const m of text.matchAll(/<a\b[^>]*>([\s\S]{0,300}?)<\/a>/gi)) push(m[1]);
323
+ for (const m of text.matchAll(/\b(?:label|title|name|text)\s*:\s*["']([^"'{}<>]{3,60})["']/g)) push(m[1]);
324
+ }
325
+ return uniq(out);
326
+ }
327
+ function extractI18nKeys(text) {
328
+ const keys = [];
329
+ for (const re of [
330
+ /(?:^|[^\w$.])t\(\s*['"`]([\w.:-]+)['"`]/g,
331
+ /\{\{\s*\$?t\s*\(\s*['"]([\w.:-]+)['"]/g,
332
+ /\bI18n\.t\(\s*['"]([\w.:-]+)['"]/g,
333
+ /\$t\(\s*['"]([\w.:-]+)['"]/g,
334
+ /\btranslate\(\s*['"]([\w.:-]+)['"]/g,
335
+ ]) for (const m of text.matchAll(re)) keys.push(m[1]);
336
+ return uniq(keys);
337
+ }
338
+ // Minimal locale index: JSON (nested → dotted keys) and simple indented YAML
339
+ // (`key: value` / `key:` lines). Enough for common.json / en.yml lookups; a key
340
+ // the index cannot resolve is simply not a candidate.
341
+ class LocaleIndex {
342
+ constructor() { this.map = new Map(); this.files = []; }
343
+ addJson(text, file) {
344
+ let obj; try { obj = JSON.parse(text); } catch { return; }
345
+ this.files.push(file);
346
+ const walk = (node, prefix) => {
347
+ if (node && typeof node === 'object' && !Array.isArray(node)) {
348
+ for (const [k, v] of Object.entries(node)) walk(v, prefix ? `${prefix}.${k}` : k);
349
+ } else if (typeof node === 'string') this.map.set(prefix, node);
350
+ };
351
+ walk(obj, '');
352
+ }
353
+ addYaml(text, file) {
354
+ this.files.push(file);
355
+ const stack = []; // [{indent, key}]
356
+ for (const raw of text.split(/\r?\n/)) {
357
+ if (!raw.trim() || /^\s*#/.test(raw)) continue;
358
+ const m = raw.match(/^(\s*)([\w.-]+)\s*:\s*(.*)$/);
359
+ if (!m) continue;
360
+ const indent = m[1].length;
361
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
362
+ const keyPath = [...stack.map(s => s.key), m[2]].join('.');
363
+ let val = m[3].trim();
364
+ if (val === '' || val === '|' || val === '>') { stack.push({ indent, key: m[2] }); continue; }
365
+ if (/^["'].*["']$/.test(val)) val = val.slice(1, -1);
366
+ if (val.startsWith('[') || val.startsWith('{') || val.startsWith('&') || val.startsWith('*')) continue;
367
+ this.map.set(keyPath, val);
368
+ }
369
+ }
370
+ lookup(key, lazyPrefix) {
371
+ if (!key) return null;
372
+ const candidates = [];
373
+ if (key.startsWith('.') && lazyPrefix) candidates.push(`${lazyPrefix}${key}`, `en.${lazyPrefix}${key}`);
374
+ else candidates.push(key, `en.${key}`, key.replace(/:/g, '.'), `en.${key.replace(/:/g, '.')}`);
375
+ for (const c of candidates) { const v = this.map.get(c); if (typeof v === 'string') return v; }
376
+ // colon-namespaced keys (i18next "common:save") → try the tail alone
377
+ const tail = key.includes(':') ? key.split(':').pop() : null;
378
+ if (tail && this.map.has(tail)) return this.map.get(tail);
379
+ return null;
380
+ }
381
+ }
382
+ function isLocaleFile(rel) {
383
+ return LOCALE_EXT_RE.test(rel) && LOCALE_PATH_RE.test(rel);
384
+ }
385
+ function buildLocaleIndex(root, partials) {
386
+ const idx = new LocaleIndex();
387
+ for (const rel of partials) {
388
+ if (!isLocaleFile(rel)) continue;
389
+ const text = readText(path.join(root, rel));
390
+ if (!text) continue;
391
+ if (/\.json$/i.test(rel) || /\.arb$/i.test(rel)) idx.addJson(text, rel);
392
+ else idx.addYaml(text, rel);
393
+ }
394
+ return idx;
395
+ }
396
+ function lazyPrefixFor(rel) {
397
+ // Rails lazy lookup: app/views/users/reset_password.html.erb → users.reset_password
398
+ const m = rel.match(/(?:^|\/)app\/views\/(.+?)\.[a-z]+(?:\.[a-z]+)?$/);
399
+ if (!m) return null;
400
+ return m[1].split('/').map(seg => seg.replace(/^_/, '')).join('.');
401
+ }
402
+ function resolveModule(src, fromDir, root) {
403
+ const bases = [];
404
+ if (src.startsWith('.')) bases.push(path.resolve(fromDir, src));
405
+ else if (src.startsWith('@/') || src.startsWith('~/')) {
406
+ for (const pre of ['src', 'app', 'resources/js', '']) bases.push(path.join(root, pre, src.slice(2)));
407
+ } else if (src.startsWith('$lib/')) bases.push(path.join(root, 'src/lib', src.slice(5)));
408
+ else if (src.startsWith('/')) bases.push(path.join(root, src));
409
+ else return null; // bare package / workspace alias — not resolvable here
410
+ const exts = ['', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.vue', '.svelte', '.astro'];
411
+ for (const b of bases) {
412
+ for (const e of exts) if (isFile(b + e)) return b + e;
413
+ for (const e of exts.slice(1)) if (isFile(path.join(b, 'index' + e))) return path.join(b, 'index' + e);
414
+ }
415
+ return null;
416
+ }
417
+ function findUnder(root, relDirs, tail) {
418
+ for (const d of relDirs) {
419
+ const p = path.join(root, d, tail);
420
+ if (isFile(p)) return p;
421
+ }
422
+ return null;
423
+ }
424
+ function extractComponentRefs(text, relFile, root) {
425
+ const refs = new Map();
426
+ const abs = path.join(root, relFile);
427
+ const dir = path.dirname(abs);
428
+ const rel = p => path.relative(root, p).split(path.sep).join('/');
429
+ const add = (name, file) => { if (!refs.has(name)) refs.set(name, { name, file: file ? rel(file) : null }); };
430
+ const ext = path.extname(relFile).toLowerCase();
431
+
432
+ // JSX / TSX / Vue / Svelte / Astro: PascalCase tags resolved through imports.
433
+ if (['.tsx', '.jsx', '.js', '.ts', '.mjs', '.vue', '.svelte', '.astro'].includes(ext)) {
434
+ const imports = new Map();
435
+ for (const m of text.matchAll(/import\s+(?:type\s+)?(?:(\w+)|\{([^}]*)\}|(\w+)\s*,\s*\{([^}]*)\})\s+from\s+['"]([^'"]+)['"]/g)) {
436
+ const names = [];
437
+ if (m[1]) names.push(m[1]);
438
+ if (m[3]) names.push(m[3]);
439
+ for (const g of [m[2], m[4]]) if (g) for (const part of g.split(',')) {
440
+ const n = part.trim().split(/\s+as\s+/).pop().trim();
441
+ if (n) names.push(n);
442
+ }
443
+ for (const n of names) imports.set(n, m[5]);
444
+ }
445
+ // JSX tags only: a generic (useState<Props>, FC<Params>) has an identifier
446
+ // or a closing bracket right before the `<`; a JSX tag never does.
447
+ for (const m of text.matchAll(/(?<![A-Za-z0-9_$\])])<([A-Z][A-Za-z0-9]*)(?:\.[A-Z][A-Za-z0-9]*)*(?=[\s/>])/g)) {
448
+ const name = m[1];
449
+ if (UI_PRIMITIVES.has(name) || PRIMITIVE_SUFFIX_RE.test(name) || ICON_PREFIX_RE.test(name)) continue;
450
+ const src = imports.get(name);
451
+ add(name, src ? resolveModule(src, dir, root) : null);
452
+ }
453
+ }
454
+ // Rails partials + ViewComponent.
455
+ if (/\.(?:erb|haml|slim)$/.test(ext) || ext === '.rb') {
456
+ for (const m of text.matchAll(/render\s*\(?\s*(?:partial:\s*)?['"]([\w\/.-]+)['"]/g)) {
457
+ const name = m[1];
458
+ let file = null;
459
+ const base = name.split('/').pop();
460
+ const viewsRoot = abs.includes('/app/views/') ? abs.slice(0, abs.indexOf('/app/views/') + '/app/views/'.length) : null;
461
+ const dirs = name.includes('/') && viewsRoot ? [path.join(viewsRoot, path.dirname(name))] : [dir];
462
+ for (const d of dirs) for (const e of ['.html.erb', '.html.haml', '.html.slim', '.turbo_stream.erb']) {
463
+ const p = path.join(d, `_${base}${e}`);
464
+ if (isFile(p)) { file = p; break; }
465
+ }
466
+ add(name, file);
467
+ }
468
+ for (const m of text.matchAll(/render\s*\(?\s*([A-Z][A-Za-z0-9:]*Component)\.new/g)) {
469
+ const snake = m[1].replace(/::/g, '/').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
470
+ add(m[1], findUnder(root, ['app/components'], `${snake}.html.erb`) || findUnder(root, ['app/components'], `${snake}.rb`));
471
+ }
472
+ }
473
+ // Laravel Blade / Livewire.
474
+ if (ext === '.php' && /\.blade\.php$/.test(relFile)) {
475
+ for (const m of text.matchAll(/@include(?:If|When|Unless)?\(\s*(?:[^,]+,\s*)?['"]([\w.-]+)['"]/g)) {
476
+ add(m[1], findUnder(root, ['resources/views'], m[1].replace(/\./g, '/') + '.blade.php'));
477
+ }
478
+ for (const m of text.matchAll(/<x-([\w.-]+)[\s/>]/g)) {
479
+ add(`x-${m[1]}`, findUnder(root, ['resources/views/components'], m[1].replace(/\./g, '/') + '.blade.php'));
480
+ }
481
+ for (const m of text.matchAll(/<livewire:([\w.-]+)[\s/>]/g)) {
482
+ add(`livewire:${m[1]}`, findUnder(root, ['resources/views/livewire'], m[1].replace(/\./g, '/') + '.blade.php'));
483
+ }
484
+ }
485
+ // Plain PHP (WordPress admin): require/include of another admin file.
486
+ if (ext === '.php' && !/\.blade\.php$/.test(relFile)) {
487
+ for (const m of text.matchAll(/\b(?:require|include)(?:_once)?\s*\(?\s*(?:ABSPATH\s*\.\s*)?['"]([\w\/.-]+\.php)['"]/g)) {
488
+ const p = isFile(path.join(root, m[1])) ? path.join(root, m[1]) : (isFile(path.join(dir, m[1])) ? path.join(dir, m[1]) : null);
489
+ add(m[1], p);
490
+ }
491
+ for (const m of text.matchAll(/\bget_template_part\(\s*['"]([\w\/.-]+)['"]/g)) add(m[1], null);
492
+ }
493
+ // Django / Jinja / Twig / Nunjucks / Liquid includes.
494
+ for (const m of text.matchAll(/\{%-?\s*(?:include|extends|embed)\s+['"]([\w\/.-]+)['"]/g)) {
495
+ let p = null;
496
+ for (let d = dir; d.startsWith(root) && !p; d = path.dirname(d)) {
497
+ const cand = path.join(d, m[1]);
498
+ if (isFile(cand)) p = cand;
499
+ else if (isFile(path.join(d, 'templates', m[1]))) p = path.join(d, 'templates', m[1]);
500
+ if (d === root) break;
501
+ }
502
+ add(m[1], p);
503
+ }
504
+ // Razor partials.
505
+ if (ext === '.cshtml') {
506
+ for (const m of text.matchAll(/(?:<partial\s+name\s*=\s*["']([\w.-]+)["']|Partial(?:Async)?\(\s*["']([\w.-]+)["'])/g)) {
507
+ const name = m[1] || m[2];
508
+ add(name, findUnder(root, [rel(dir), 'Views/Shared', 'Areas/Admin/Views/Shared', 'Pages/Shared'], `${name}.cshtml`));
509
+ }
510
+ }
511
+ // Angular custom elements and Phoenix function components: name-only.
512
+ if (ext === '.html' || ext === '.heex' || ext === '.eex') {
513
+ for (const m of text.matchAll(/<(app-[a-z0-9-]+|[a-z][a-z0-9]*-[a-z0-9-]+)[\s/>]/g)) add(m[1], null);
514
+ for (const m of text.matchAll(/<\.([a-z_][a-z0-9_]*)[\s/>]/g)) add(`.${m[1]}`, null);
515
+ for (const m of text.matchAll(/<([A-Z][\w.]*\.[a-z_][a-z0-9_]*)[\s/>]/g)) add(m[1], null);
516
+ }
517
+ return [...refs.values()];
518
+ }
519
+ function extractFormFields(text) {
520
+ const names = [];
521
+ for (const m of text.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
522
+ if (m[1].toLowerCase() === 'input' && /\btype\s*=\s*["']?hidden\b/i.test(m[2])) continue;
523
+ const n = m[2].match(/\bname\s*=\s*["']([^"']+)["']/i);
524
+ if (n) names.push(n[1]);
525
+ }
526
+ for (const re of [
527
+ /\bf(?:orm)?\.(?:text_field|email_field|password_field|number_field|text_area|select|collection_select|check_box|date_field|file_field|url_field|telephone_field|rich_text_area|search_field)\s*\(?\s*:(\w+)/g,
528
+ /\bregister\(\s*['"]([\w.\[\]]+)['"]/g,
529
+ /<(?:FormField|Field|Form\.Item|TextField|Input|Select|Textarea|Checkbox|Switch|InputField|SelectField|Controller)\b[^>]*\bname\s*=\s*["'{]([^"'}]+)["'}]/g,
530
+ /\bwire:model(?:\.\w+)*\s*=\s*["']([\w.]+)["']/g,
531
+ /\bv-model\s*=\s*["']([\w.]+)["']/g,
532
+ /\bformControlName\s*=\s*["']([\w.]+)["']/g,
533
+ /<\.input\b[^>]*\bfield=\{[^}]*\[:(\w+)\]/g,
534
+ /\basp-for\s*=\s*["']([\w.]+)["']/g,
535
+ ]) for (const m of text.matchAll(re)) names.push(m[1]);
536
+ return uniq(names.map(n => n.replace(/\[\]$/, '')).filter(n => /^[\w.\[\]-]{2,60}$/.test(n)));
537
+ }
538
+ function humanize(name) {
539
+ const last = name.split(/[.\[\]]/).filter(Boolean).pop() || name;
540
+ return last.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim()
541
+ .replace(/^\w/, c => c.toUpperCase());
542
+ }
543
+
544
+ // ─── ticket assembly ─────────────────────────────────────────────────────────
545
+ function buildTicket(ctx, block) {
546
+ const { root, articleDir, vs, lint, pm, shell, maxEdits } = ctx;
547
+ const entry = block.entry;
548
+ const htmlPath = path.join(articleDir, block.html);
549
+ const html = readText(htmlPath, 4 * 1024 * 1024);
550
+ const htmlText = extractVisibleText(html);
551
+ const htmlTextLower = htmlText.toLowerCase();
552
+ const tokens = attrTokens(html);
553
+ const htmlLower = html.toLowerCase();
554
+ const inHtml = s => html.includes(s);
555
+ // Presence for source-derived labels: visible text OR any attribute value
556
+ // (placeholders paint; a copied aria-label proves the markup was copied).
557
+ const inText = s => htmlLower.includes(String(s).toLowerCase());
558
+ const lintEntry = lintEntryFor(lint, block);
559
+ const metrics = (lintEntry && lintEntry.metrics) || {};
560
+ const errors = (lintEntry && lintEntry.errors) || [];
561
+ const warnings = (lintEntry && lintEntry.warnings) || [];
562
+ const diag = readJson(path.join(articleDir, block.diagnostics)) || {};
563
+ const dm = diag.metrics || {};
564
+ const quality = diag.qualityScore || {};
565
+ const evidenceVis = Array.isArray(dm.evidenceVisibility) ? dm.evidenceVisibility : [];
566
+ const probeVis = Array.isArray(dm.probeVisibility) ? dm.probeVisibility : [];
567
+ const visibility = new Map();
568
+ for (const v of [...evidenceVis, ...probeVis]) if (v && typeof v.string === 'string') visibility.set(norm(v.string), Boolean(v.visible));
569
+ const layout = layoutFor(pm, entry.layout);
570
+ const chromeFiles = ((layout && layout.chrome) || []).filter(f => !NON_SOURCE_PATH_RE.test(f));
571
+ const partials = Array.isArray(entry.partials_expanded)
572
+ ? entry.partials_expanded.filter(p => typeof p === 'string' && !NON_SOURCE_PATH_RE.test(p)) : [];
573
+ const primary = typeof entry.primary_view === 'string' && !NON_SOURCE_PATH_RE.test(entry.primary_view) ? entry.primary_view : null;
574
+ const absence = absenceStrings(entry);
575
+ const targets = actionTargetsFor(vs, block);
576
+
577
+ const questions = [];
578
+ let qn = 0;
579
+ const ask = (kind, severity, source, text, check, extra) => {
580
+ questions.push({ id: `${block.key}#${++qn}`, kind, severity, source, text, check, ...(extra || {}) });
581
+ };
582
+
583
+ // 1. Lint errors — verbatim; the Phase 3 loop should have cleared them, but a
584
+ // run that finished with a red lint still gets the pointer.
585
+ for (const e of errors) ask('lint_error', 'blocking', 'lint_report.json', e, { type: 'lint_errors_zero' });
586
+
587
+ // 2. Shell labels. The shell applies only to layouts the map ties it to: skip
588
+ // when the block claims a layout the map records as shell-hiding
589
+ // (chrome: [] + runtime_chrome — the fullscreen-editor case the lint's
590
+ // mode-contradiction check would reject anyway), a layout other than
591
+ // app_shell.layout when the map names one, or a layout whose own
592
+ // description says it has no sidebar. A block claiming a layout the map
593
+ // does not know is treated as shell-less too — a sidebar added on a
594
+ // guess is the wrong-mode failure, not polish.
595
+ let shellHidden = false;
596
+ let shellHiddenWhy = null;
597
+ if (layout && Array.isArray(layout.chrome) && layout.chrome.length === 0 && layout.runtime_chrome) {
598
+ shellHidden = true; shellHiddenWhy = 'layout records chrome: [] with a runtime_chrome recipe';
599
+ } else if (layout && shell && shell.layout && layout.name && shell.layout !== layout.name && shell.layout !== layout.path) {
600
+ shellHidden = true; shellHiddenWhy = `app_shell belongs to layout '${shell.layout}', this block claims '${layout.name}'`;
601
+ } else if (layout && SHELL_ABSENT_RE.test(`${layout.area || ''} ${layout.name || ''}`)) {
602
+ shellHidden = true; shellHiddenWhy = `layout '${layout.name || layout.path}' describes itself as shell-less`;
603
+ } else if (!layout && entry.layout) {
604
+ shellHidden = true; shellHiddenWhy = `claimed layout '${entry.layout}' is not in the project map`;
605
+ } else if (!layout && !entry.layout && (!pm || (pm.app_type || 'web') === 'web') && Array.isArray(pm && pm.layouts) && pm.layouts.length) {
606
+ // Web blocks name their layout; one that names none (a fullscreen editor,
607
+ // a modal-only state) did not claim the shell's layout. Non-web modes omit
608
+ // `layout` by contract, so their shell (tab bar, status bar) still applies.
609
+ shellHidden = true; shellHiddenWhy = 'block claims no layout in a web app whose shell is layout-bound';
610
+ }
611
+ const navRatio = ratioOf(metrics.shell_nav_labels);
612
+ if (shell && !shellHidden && shell.nav_labels.length >= 2) {
613
+ const missing = shell.nav_labels.filter(l => !inHtml(l));
614
+ const present = shell.nav_labels.filter(l => inHtml(l));
615
+ if (navRatio !== null && navRatio < T.shell_labels && missing.length) {
616
+ const severity = navRatio < T.shell_labels_blocking ? 'blocking' : 'advisory';
617
+ ask('shell_labels_missing', severity, 'lint_report.json metrics.shell_nav_labels',
618
+ `The map's app shell (${shell.type || 'shell'}) lists ${shell.nav_labels.length} navigation items; ` +
619
+ `${missing.length} are absent from this mockup's text: ${missing.map(s => JSON.stringify(s)).join(', ')}. ` +
620
+ `Open the PNG: if the real screen shows the shell here, add the missing items from the layout's chrome ` +
621
+ `files${chromeFiles.length ? ` (${chromeFiles.join(', ')})` : ''} — every item, with its icon and ` +
622
+ `badge where the map records one. If this surface genuinely hides the shell in this state, say so and leave it.`,
623
+ { type: 'metric_ratio_min', key: 'shell_nav_labels', min: T.shell_labels },
624
+ { missing, present });
625
+ }
626
+ // 2b. The render-visibility class: labels ARE in the HTML but paint nothing.
627
+ const measured = present.filter(l => visibility.has(l));
628
+ if (measured.length >= 2) {
629
+ const hidden = measured.filter(l => visibility.get(l) === false);
630
+ if (hidden.length * 2 > measured.length) {
631
+ ask('shell_not_painting', 'blocking', `${block.diagnostics} metrics.probeVisibility`,
632
+ `The shell is in the HTML but not in the screenshot: ${hidden.length}/${measured.length} navigation labels ` +
633
+ `(${hidden.map(s => JSON.stringify(s)).join(', ')}) paint 0 px in the PNG. A copied class pair like ` +
634
+ `"hidden lg:flex" or a fixed/absolute sidebar the content flow never accounts for hides it at the capture width. ` +
635
+ `Make the authored shell VISIBLE (drop the hiding class, add an inline display, give the content the sidebar's ` +
636
+ `offset) — do not re-author it.`,
637
+ { type: 'probe_visible_majority', strings: measured }, { hidden });
638
+ } else if (hidden.length) {
639
+ ask('shell_item_hidden', 'advisory', `${block.diagnostics} metrics.probeVisibility`,
640
+ `${hidden.length} shell label(s) present in the HTML paint 0 px in the PNG: ` +
641
+ `${hidden.map(s => JSON.stringify(s)).join(', ')}. Check whether a class or clip hides them and make them visible.`,
642
+ { type: 'strings_visible', strings: hidden }, { hidden });
643
+ }
644
+ }
645
+ // 2c. Footer / top-bar items the shell records — literal labels only, and
646
+ // only when the mockup renders the shell at all (majority of nav labels).
647
+ const rendersShell = present.length * 2 >= shell.nav_labels.length;
648
+ const footerLiteral = shell.footer_labels.filter(isLiteralLabel);
649
+ const footerMissing = footerLiteral.filter(l => !inHtml(l));
650
+ if (rendersShell && footerMissing.length) {
651
+ ask('shell_footer_missing', 'advisory', 'project_map.json app_shell.footer_items',
652
+ `The shell records ${footerLiteral.length} footer/bottom item(s); ${footerMissing.length} are absent: ` +
653
+ `${footerMissing.map(s => JSON.stringify(s)).join(', ')}. The mockup renders the shell, so its bottom section ` +
654
+ `should carry these too${shell.account_area ? ` (account area: ${shell.account_area})` : ''}.`,
655
+ { type: 'html_contains_any', strings: footerMissing, min: Math.ceil(footerMissing.length / 2) });
656
+ }
657
+ if (shell.top_bar && rendersShell) {
658
+ const items = [...shell.top_bar.items_left, ...shell.top_bar.items_right];
659
+ const literal = items.filter(isLiteralLabel);
660
+ const tbMissing = literal.filter(l => !inHtml(l));
661
+ if (tbMissing.length) {
662
+ ask('shell_top_bar_missing', 'advisory', 'project_map.json app_shell.top_bar',
663
+ `The shell's top bar records ${items.length} item(s); ${tbMissing.length} literal label(s) are absent: ` +
664
+ `${tbMissing.map(s => JSON.stringify(s)).join(', ')}. Render the bar at item level (badges and bubbles included).`,
665
+ { type: 'html_contains_any', strings: tbMissing, min: Math.ceil(tbMissing.length / 2) });
666
+ }
667
+ }
668
+ }
669
+
670
+ // 3. Chrome files the layout records but this block never expanded.
671
+ const cfRatio = ratioOf(metrics.chrome_files_expanded);
672
+ if (cfRatio !== null && cfRatio < T.chrome_files && chromeFiles.length) {
673
+ const unexpanded = chromeFiles.filter(f => !partials.includes(f));
674
+ if (unexpanded.length) {
675
+ ask('chrome_file_unexpanded', errors.length ? 'advisory' : 'blocking', 'lint_report.json metrics.chrome_files_expanded',
676
+ `Layout '${layout.name || layout.path}' renders these chrome files around the content on every page, and this ` +
677
+ `block never expanded them: ${unexpanded.join(', ')}. Open each one, compare the region it emits with the PNG, ` +
678
+ `add what is missing, and list the file in partials_expanded.`,
679
+ { type: 'metric_ratio_min', key: 'chrome_files_expanded', min: T.chrome_files }, { unexpanded });
680
+ }
681
+ }
682
+
683
+ // 4. Runtime-widget chrome strings (fullscreen editors and other JS-drawn surfaces).
684
+ const rsRatio = ratioOf(metrics.runtime_ui_strings);
685
+ if (rsRatio !== null && rsRatio < T.runtime_strings && layout && layout.runtime_chrome) {
686
+ const rc = layout.runtime_chrome;
687
+ const strings = uniq([...(rc.ui_strings || []), ...((rc.regions || []).flatMap(r => (r && r.ui_strings) || []))]
688
+ .filter(s => typeof s === 'string' && s.length >= 2));
689
+ const missing = strings.filter(s => !inHtml(s));
690
+ ask('runtime_strings_missing', 'advisory', 'lint_report.json metrics.runtime_ui_strings',
691
+ `Layout '${layout.name || layout.path}' records the embedded app's chrome (${(rc.regions || []).length} region(s)); ` +
692
+ `${missing.length}/${strings.length} recorded UI strings are absent: ` +
693
+ `${missing.slice(0, 12).map(s => JSON.stringify(s)).join(', ')}${missing.length > 12 ? ', …' : ''}. ` +
694
+ `Compare the PNG against the recipe's regions in their recorded order and add the regions that are missing.`,
695
+ { type: 'metric_ratio_min', key: 'runtime_ui_strings', min: T.runtime_strings }, { missing: missing.slice(0, 40) });
696
+ }
697
+
698
+ // 5. Render diagnostics: content deductions only (failed external resources are not the model's).
699
+ const deductions = Array.isArray(quality.deductions) ? quality.deductions : [];
700
+ const contentDeductions = deductions.filter(d => CONTENT_DEDUCTION_RE.test(String(d)));
701
+ if (contentDeductions.length || quality.rating === 'poor') {
702
+ ask('render_quality', quality.rating === 'poor' ? 'blocking' : 'advisory', `${block.diagnostics} qualityScore`,
703
+ `The render scored ${quality.score ?? '?'} (${quality.rating || '?'}): ${contentDeductions.join('; ') || deductions.join('; ')}. ` +
704
+ `Open the PNG — the content is blank, collapsed, or cut off. Fix the layout so the subject paints inside the frame.`,
705
+ { type: 'render_deductions_absent' });
706
+ }
707
+
708
+ // 6. Evidence and action targets that paint nothing.
709
+ const evInvisible = evidenceVis.filter(v => v && v.visible === false).map(v => norm(v.string));
710
+ if (evInvisible.length) {
711
+ ask('evidence_invisible', evInvisible.length * 2 > evidenceVis.length ? 'blocking' : 'advisory',
712
+ `${block.diagnostics} metrics.evidenceVisibility`,
713
+ `${evInvisible.length}/${evidenceVis.length} evidence labels have no visible text or matching visible control in the PNG: ` +
714
+ `${evInvisible.map(s => JSON.stringify(s)).join(', ')}. Clipped out of frame or collapsed — depict the state where they are visible.`,
715
+ { type: 'strings_visible', strings: evInvisible });
716
+ }
717
+ const targetInvisible = targets.filter(t => visibility.get(t.target) === false).map(t => t.target);
718
+ if (targetInvisible.length) {
719
+ ask('action_target_invisible', 'blocking', `${block.diagnostics} metrics.probeVisibility`,
720
+ `This screenshot must show where the reader acts, and the action target(s) ` +
721
+ `${targetInvisible.map(s => JSON.stringify(s)).join(', ')} have no visible matching control or text in the PNG. Make the control visible in its action-ready state.`,
722
+ { type: 'strings_visible', strings: targetInvisible });
723
+ }
724
+
725
+ // 7. Lint warnings that name concrete gaps.
726
+ const inventedCount = countOf(metrics.invented_copy);
727
+ if (inventedCount) {
728
+ const w = warnings.find(x => /do not appear in any listed source file/.test(x));
729
+ const quoted = w ? (w.match(/"([^"]+)"/g) || []).join(', ') : '';
730
+ ask('invented_copy', 'advisory', 'lint_report.json metrics.invented_copy',
731
+ `${inventedCount} multi-word phrase(s) in the mockup exist in no listed source file${quoted ? ` (${quoted})` : ''}. ` +
732
+ `Replace each with the real string from the source or locale file — copy is never paraphrased.`,
733
+ { type: 'metric_count_max', key: 'invented_copy', max: 0 });
734
+ }
735
+ const ppRatio = ratioOf(metrics.partials_present);
736
+ if (ppRatio !== null && ppRatio < 1) {
737
+ const missing = partials.filter(p => !isFile(path.join(root, p)));
738
+ ask('partial_missing', 'advisory', 'lint_report.json metrics.partials_present',
739
+ `partials_expanded lists file(s) that do not exist: ${missing.join(', ')}. Correct the path(s) in view_sources.json ` +
740
+ `(and open the real file — a partial you could not open is a region you could not copy).`,
741
+ { type: 'metric_ratio_min', key: 'partials_present', min: 1 });
742
+ }
743
+ const scRatio = ratioOf(metrics.styled_coverage);
744
+ const scTotal = metrics.styled_coverage && metrics.styled_coverage.total;
745
+ if (scRatio !== null && scRatio < T.styled_coverage && scTotal >= T.styled_min_total) {
746
+ const w = warnings.find(x => /unmatched:/.test(x));
747
+ const sample = w ? (w.match(/unmatched:\s*([^)]+)\)/) || [])[1] : null;
748
+ ask('styled_coverage_low', 'advisory', 'lint_report.json metrics.styled_coverage',
749
+ `Only ${Math.round(scRatio * 100)}% of this mockup's class/id tokens match a selector in branding.css/mockup.css` +
750
+ `${sample ? ` (unmatched sample: ${sample})` : ''}. Open the PNG: regions that look bare are usually classes the ` +
751
+ `bundle never styles. Where a region looks unstyled, prefer classes that exist in branding.css/mockup.css; ` +
752
+ `leave regions that already look right alone.`,
753
+ { type: 'metric_ratio_min', key: 'styled_coverage', min: T.styled_coverage });
754
+ }
755
+
756
+ // 8. Source-derived questions — independent of the map's chrome inventory, so
757
+ // thin maps still get a pointer. Bounded file set, bounded output.
758
+ const scanFiles = uniq([primary, ...chromeFiles].filter(Boolean)).filter(f => isFile(path.join(root, f))).slice(0, CAPS.scan_files);
759
+ const componentFiles = uniq([primary, layout && layout.path, ...chromeFiles].filter(Boolean)).filter(f => isFile(path.join(root, f))).slice(0, CAPS.scan_files);
760
+ const locale = buildLocaleIndex(root, partials);
761
+ const sourceTexts = new Map();
762
+ const textOf = f => { if (!sourceTexts.has(f)) sourceTexts.set(f, readText(path.join(root, f))); return sourceTexts.get(f); };
763
+
764
+ // 8a. Components / partials the rendered files include that leave no trace in the mockup.
765
+ const unrendered = [];
766
+ const seenComponents = new Set();
767
+ for (const f of componentFiles) {
768
+ for (const ref of extractComponentRefs(textOf(f), f, root)) {
769
+ if (seenComponents.has(ref.name)) continue;
770
+ seenComponents.add(ref.name);
771
+ if (OVERLAY_NAME_RE.test(ref.name) || (ref.file && OVERLAY_NAME_RE.test(path.basename(ref.file)))) continue;
772
+ const toks = nameTokens(ref.name.replace(/^[.@]|^x-|^livewire:/, '').replace(/^_/, ''));
773
+ if (toks.some(t => t && (tokens.has(t) || [...tokens].some(x => x.includes(t) && t.length >= 5)))) continue;
774
+ let labels = [];
775
+ if (ref.file) {
776
+ const ft = textOf(ref.file);
777
+ labels = extractLabels(ft, { nav: true });
778
+ const keys = extractI18nKeys(ft).map(k => locale.lookup(k, lazyPrefixFor(ref.file))).filter(Boolean);
779
+ labels = uniq([...labels, ...keys]);
780
+ if (labels.some(l => inText(l))) continue;
781
+ } else if (!/[.\-_:]/.test(ref.name) && ref.name.length < 6) {
782
+ continue; // short unresolved PascalCase tags are too noisy to ticket
783
+ }
784
+ unrendered.push({ name: ref.name, file: ref.file, rendered_by: f, labels: labels.slice(0, 4) });
785
+ if (unrendered.length >= CAPS.components) break;
786
+ }
787
+ if (unrendered.length >= CAPS.components) break;
788
+ }
789
+ if (unrendered.length) {
790
+ ask('source_component_unrendered', 'advisory', 'source files',
791
+ `These components/partials are rendered by the files this screen is built from, and nothing in the mockup ` +
792
+ `corresponds to them: ${unrendered.map(u => `${u.name}${u.file ? ` (${u.file})` : ''} ← ${u.rendered_by}`).join('; ')}. ` +
793
+ `Open each one: if it draws a region the real screen shows in this state, add that region; if it is conditional ` +
794
+ `or empty for the default user, skip it.`,
795
+ { type: 'html_contains_any', strings: uniq(unrendered.flatMap(u => [u.name, ...u.labels])), min: 1 }, { components: unrendered });
796
+ }
797
+
798
+ // 8a'. Screen closure: pieces the primary view renders UNCONDITIONALLY
799
+ // (includes + its own inline sections) that leave no trace in the mockup.
800
+ // Sharper than 8a because the census classifies by render condition —
801
+ // overlays and guarded pieces are exempt, so every item here is one the
802
+ // real screen shows in this state. Mirrors the lint's warn-only
803
+ // screen_closure check; modal steps (overlay primary_view) are skipped.
804
+ if (primary && !includeCensus.OVERLAY_NAME_RE.test(path.basename(primary))) {
805
+ const cen = includeCensus.census(root, primary);
806
+ const pieces = [];
807
+ for (const inc of cen.includes) if (inc.kind === 'unconditional' && inc.markers.length) pieces.push({ label: `${inc.name}${inc.file ? ` (${inc.file})` : ''}`, markers: inc.markers });
808
+ for (const sec of cen.inline_sections) if (!sec.guarded && sec.markers.length) pieces.push({ label: `inline section "${sec.heading}"`, markers: sec.markers });
809
+ const missing = pieces.filter(p => !p.markers.some(inText));
810
+ if (missing.length) {
811
+ // Blocking whenever ANY unconditional piece is missing: by construction every
812
+ // item here is something the real screen shows in this state (overlays and
813
+ // guarded pieces are already excluded), so there is no "advisory" reading.
814
+ ask('screen_piece_missing', 'blocking', `source ${primary}`,
815
+ `${primary} renders ${missing.length} piece(s) on every load that the mockup does not show: ` +
816
+ `${missing.map(p => `${p.label} [e.g. ${p.markers.slice(0, 2).map(s => JSON.stringify(s)).join(', ')}]`).join('; ')}. ` +
817
+ `The screen is the primary view plus every unconditional include and inline section; open the PNG and add each missing ` +
818
+ `piece where the source places it. Overlays and guarded pieces are already excluded from this list.`,
819
+ { type: 'html_contains_any', strings: uniq(missing.flatMap(p => p.markers.slice(0, 3))), min: missing.length }, { pieces: missing });
820
+ }
821
+ }
822
+
823
+ // 8b. Labels the primary view and chrome files carry that the mockup lacks.
824
+ // Visible labels only, screen-shaped only (short, no sentence punctuation).
825
+ const labelCandidates = [];
826
+ for (const f of scanFiles) {
827
+ const ft = textOf(f);
828
+ const direct = extractLabels(ft, { nav: f !== primary, visibleOnly: true });
829
+ const resolved = extractI18nKeys(ft).map(k => locale.lookup(k, lazyPrefixFor(f))).map(v => v && cleanLabel(v)).filter(Boolean);
830
+ for (const l of uniq([...direct, ...resolved])) {
831
+ if (l.length < 3 || l.length > 60 || !isScreenLabel(l)) continue;
832
+ if (absence.some(a => l.toLowerCase().includes(a))) continue;
833
+ if (inText(l)) continue;
834
+ labelCandidates.push(l);
835
+ }
836
+ }
837
+ const labelsMissing = uniq(labelCandidates);
838
+ if (labelsMissing.length) {
839
+ const shown = labelsMissing.slice(0, CAPS.labels);
840
+ ask('source_labels_missing', 'advisory', 'source files',
841
+ `${labelsMissing.length} label(s) from the source files this screen renders do not appear in the mockup` +
842
+ `${labelsMissing.length > shown.length ? ` (first ${shown.length})` : ''}: ${shown.map(s => JSON.stringify(s)).join(', ')}. ` +
843
+ `Many belong to other states or branches — open the PNG and add only the ones the real screen shows in this block's ` +
844
+ `depicted state (headings, column headers, buttons, field labels, placeholders).`,
845
+ { type: 'html_contains_any', strings: shown, min: Math.max(1, Math.ceil(shown.length / 2)) }, { labels: shown, total: labelsMissing.length });
846
+ }
847
+
848
+ // 8c. Form fields: the primary view declares N controls, the mockup renders far fewer.
849
+ if (primary) {
850
+ const fields = extractFormFields(textOf(primary));
851
+ const mockupControls = countFormControls(html);
852
+ if (fields.length >= 3 && mockupControls < Math.ceil(fields.length / 2)) {
853
+ const missing = fields.filter(n => !inText(humanize(n)) && !tokens.has(n.toLowerCase())).slice(0, CAPS.form_fields);
854
+ ask('source_form_fields_missing', 'advisory', `source ${primary}`,
855
+ `${primary} declares ${fields.length} form field(s) and the mockup renders ${mockupControls} control(s). ` +
856
+ `Absent by name: ${missing.map(n => JSON.stringify(humanize(n))).join(', ')}. Open the PNG and add the fields the ` +
857
+ `real form shows in this state, each with its label, placeholder and control type from the source.`,
858
+ { type: 'html_control_count_min', min: Math.ceil(fields.length / 2) }, { fields: missing });
859
+ }
860
+ }
861
+
862
+ // 8d. Navigation from the layout/chrome files when the map's shell is thin.
863
+ if ((!shell || shell.nav_labels.length < 2) && (layout || chromeFiles.length)) {
864
+ const navFiles = uniq([layout && layout.path, ...chromeFiles].filter(Boolean)).filter(f => isFile(path.join(root, f))).slice(0, CAPS.scan_files);
865
+ const navLabels = [];
866
+ for (const f of navFiles) {
867
+ const ft = textOf(f);
868
+ for (const m of ft.matchAll(/<nav\b[\s\S]{0,6000}?<\/nav>/gi)) navLabels.push(...extractLabels(m[0], { nav: true }));
869
+ navLabels.push(...extractI18nKeys(ft).map(k => locale.lookup(k, lazyPrefixFor(f))).filter(Boolean));
870
+ }
871
+ const missing = uniq(navLabels).filter(l => !inText(l)).slice(0, CAPS.nav);
872
+ if (missing.length >= 2) {
873
+ ask('source_nav_missing', 'advisory', 'layout / chrome source files',
874
+ `The map records no shell navigation, but the layout's own markup carries navigation labels the mockup lacks: ` +
875
+ `${missing.map(s => JSON.stringify(s)).join(', ')}. Open the PNG: if the real page shows this navigation, add it.`,
876
+ { type: 'html_contains_any', strings: missing, min: Math.ceil(missing.length / 2) });
877
+ }
878
+ }
879
+
880
+ // 9. Always: the open comparison.
881
+ const readOrder = uniq([primary, layout && layout.path, ...chromeFiles, ...partials].filter(Boolean))
882
+ .filter(f => isFile(path.join(root, f)));
883
+ const ordered = [...readOrder.filter(f => !isLocaleFile(f)), ...readOrder.filter(isLocaleFile)].slice(0, CAPS.read_order);
884
+ ask('compare', 'advisory', 'PNG + source files',
885
+ `Open ${block.png}, then each file in sources.read_order. List what the real screen shows that the PNG does not — ` +
886
+ `regions, rows and their columns, controls, labels, badges, footers — and add it. If nothing is missing, say so.`,
887
+ { type: 'manual' });
888
+
889
+ const blocking = questions.filter(q => q.severity === 'blocking').length;
890
+ return {
891
+ block_id: block.block_id, index: block.index, key: block.key,
892
+ html: block.html,
893
+ html_raw: isFile(path.join(articleDir, block.pre)) ? block.pre : null,
894
+ png: block.png,
895
+ sha256: {
896
+ html: sha256File(htmlPath),
897
+ html_raw: sha256File(path.join(articleDir, block.pre)),
898
+ png: sha256File(path.join(articleDir, block.png)),
899
+ },
900
+ url_or_route: entry.url_or_route || null,
901
+ depicted_state: typeof entry.depicted_state === 'string' ? entry.depicted_state : null,
902
+ sources: {
903
+ primary_view: primary,
904
+ layout: layout ? { name: layout.name, path: layout.path, area: layout.area, shell_hidden: shellHidden } : (entry.layout || null),
905
+ chrome_files: chromeFiles,
906
+ partials_expanded: partials,
907
+ read_order: ordered,
908
+ },
909
+ shell: shell && !shellHidden ? {
910
+ type: shell.type, nav_items: shell.nav_labels_all, footer_items: shell.footer_labels,
911
+ account_area: shell.account_area, top_bar: shell.top_bar, notes: shell.notes,
912
+ } : null,
913
+ action_targets: targets,
914
+ before: {
915
+ metrics: Object.keys(metrics).length ? metrics : null,
916
+ chrome: chromeMean(metrics),
917
+ render_score: typeof quality.score === 'number' ? quality.score : null,
918
+ render_rating: quality.rating || null,
919
+ deductions,
920
+ errors, warnings,
921
+ },
922
+ questions,
923
+ counts: { questions: questions.length, blocking, advisory: questions.length - blocking },
924
+ budget: { max_edits: maxEdits },
925
+ };
926
+ }
927
+
928
+ function buildTickets(articleDir, root, env = process.env) {
929
+ const maxEdits = intEnv('RTFM_POLISH_MAX_EDITS', 6, env);
930
+ const maxBlocks = intEnv('RTFM_POLISH_MAX_BLOCKS', 0, env);
931
+ const vs = readJson(path.join(articleDir, 'view_sources.json'));
932
+ const lint = readJson(path.join(articleDir, 'lint_report.json'));
933
+ const pm = loadProjectMap(root);
934
+ const shell = shellSummary(pm);
935
+ const ctx = { root, articleDir, vs, lint, pm, shell, maxEdits };
936
+ const out = {
937
+ schema_version: SCHEMA_VERSION, enabled: true,
938
+ generated_at: new Date().toISOString(),
939
+ article_dir: path.resolve(articleDir), project_root: path.resolve(root),
940
+ app_type: (pm && pm.app_type) || 'web',
941
+ inputs: {
942
+ view_sources: Boolean(vs), lint_report: Boolean(lint), project_map: Boolean(pm),
943
+ app_shell: Boolean(shell),
944
+ },
945
+ budget: { max_edits: maxEdits, max_blocks: maxBlocks },
946
+ blocks: [], skipped: [],
947
+ };
948
+ if (!vs) {
949
+ out.skipped.push({ reason: 'no-view-sources' });
950
+ return out;
951
+ }
952
+ const tickets = [];
953
+ for (const block of blockEntries(vs)) {
954
+ if (block.entry.external_surface) { out.skipped.push({ block: block.key, reason: 'external-surface' }); continue; }
955
+ if (!isFile(path.join(articleDir, block.html))) { out.skipped.push({ block: block.key, reason: 'no-html' }); continue; }
956
+ if (!isFile(path.join(articleDir, block.png))) { out.skipped.push({ block: block.key, reason: 'no-png' }); continue; }
957
+ tickets.push(buildTicket(ctx, block));
958
+ }
959
+ // Worst first: blocking desc, advisory desc, then authored order.
960
+ const order = new Map(tickets.map((t, i) => [t.key, i]));
961
+ tickets.sort((a, b) => (b.counts.blocking - a.counts.blocking)
962
+ || (b.counts.advisory - a.counts.advisory) || (order.get(a.key) - order.get(b.key)));
963
+ if (maxBlocks > 0 && tickets.length > maxBlocks) {
964
+ for (const t of tickets.slice(maxBlocks)) out.skipped.push({ block: t.key, reason: 'over-block-cap' });
965
+ out.blocks = tickets.slice(0, maxBlocks);
966
+ } else out.blocks = tickets;
967
+ out.totals = {
968
+ blocks_ticketed: out.blocks.length,
969
+ questions: out.blocks.reduce((n, t) => n + t.counts.questions, 0),
970
+ blocking: out.blocks.reduce((n, t) => n + t.counts.blocking, 0),
971
+ };
972
+ return out;
973
+ }
974
+
975
+ // ─── report ──────────────────────────────────────────────────────────────────
976
+ function evaluateCheck(check, after) {
977
+ if (!check || typeof check !== 'object') return null;
978
+ const m = after.metrics || {};
979
+ switch (check.type) {
980
+ case 'manual': return null;
981
+ case 'lint_errors_zero': return after.errors.length === 0;
982
+ case 'metric_ratio_min': { const r = ratioOf(m[check.key]); return r === null ? null : r >= check.min; }
983
+ case 'metric_count_max': { const c = countOf(m[check.key]); return c === null ? true : c <= check.max; }
984
+ case 'render_deductions_absent':
985
+ return after.rating !== 'poor' && !after.deductions.some(d => CONTENT_DEDUCTION_RE.test(String(d)));
986
+ case 'strings_visible':
987
+ return (check.strings || []).every(s => after.visibility.get(norm(s)) === true);
988
+ case 'probe_visible_majority': {
989
+ const vis = (check.strings || []).filter(s => after.visibility.get(norm(s)) === true).length;
990
+ return vis * 2 > (check.strings || []).length;
991
+ }
992
+ case 'html_contains_all': return (check.strings || []).every(s => after.html.includes(s));
993
+ case 'html_contains_any': {
994
+ const n = (check.strings || []).filter(s => after.html.includes(s) || after.textLower.includes(String(s).toLowerCase())).length;
995
+ return n >= (check.min || 1);
996
+ }
997
+ case 'html_control_count_min': return countFormControls(after.html) >= check.min;
998
+ default: return null;
999
+ }
1000
+ }
1001
+ function buildReport(articleDir, root) {
1002
+ const tickets = readJson(path.join(articleDir, 'polish_tickets.json'));
1003
+ const report = { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), enabled: Boolean(tickets && tickets.enabled) };
1004
+ if (!tickets) { report.error = 'polish_tickets.json missing or unreadable'; report.blocks = []; report.totals = null; return report; }
1005
+ if (!tickets.enabled) { report.blocks = []; report.totals = null; return report; }
1006
+ const lint = readJson(path.join(articleDir, 'lint_report.json'));
1007
+ const vs = readJson(path.join(articleDir, 'view_sources.json'));
1008
+ const blocks = blockEntries(vs || {});
1009
+ report.blocks = [];
1010
+ for (const t of tickets.blocks || []) {
1011
+ const block = blocks.find(b => b.key === t.key) || {
1012
+ key: t.key, block_id: t.block_id, index: t.index, html: t.html, pre: `${t.html}.pre`, png: t.png,
1013
+ diagnostics: `${t.html.replace(/\.html$/, '')}_diagnostics.json`,
1014
+ };
1015
+ const lintEntry = lintEntryFor(lint, block);
1016
+ const diag = readJson(path.join(articleDir, block.diagnostics)) || {};
1017
+ const dm = diag.metrics || {};
1018
+ const visibility = new Map();
1019
+ for (const v of [...(dm.evidenceVisibility || []), ...(dm.probeVisibility || [])]) {
1020
+ if (v && typeof v.string === 'string') visibility.set(norm(v.string), Boolean(v.visible));
1021
+ }
1022
+ const html = readText(path.join(articleDir, block.html), 4 * 1024 * 1024);
1023
+ const after = {
1024
+ metrics: (lintEntry && lintEntry.metrics) || {},
1025
+ errors: (lintEntry && lintEntry.errors) || [],
1026
+ warnings: (lintEntry && lintEntry.warnings) || [],
1027
+ rating: (diag.qualityScore || {}).rating || null,
1028
+ score: typeof (diag.qualityScore || {}).score === 'number' ? diag.qualityScore.score : null,
1029
+ deductions: (diag.qualityScore || {}).deductions || [],
1030
+ visibility, html, textLower: extractVisibleText(html).toLowerCase(),
1031
+ };
1032
+ const results = (t.questions || []).map(q => ({
1033
+ id: q.id, kind: q.kind, severity: q.severity, check: q.check && q.check.type, resolved: evaluateCheck(q.check, after),
1034
+ }));
1035
+ const checkable = results.filter(r => r.resolved !== null);
1036
+ const rawSha = sha256File(path.join(articleDir, block.pre));
1037
+ const htmlSha = sha256File(path.join(articleDir, block.html));
1038
+ const beforeRaw = t.sha256 && t.sha256.html_raw;
1039
+ const htmlChanged = beforeRaw && rawSha ? rawSha !== beforeRaw : (t.sha256 ? htmlSha !== t.sha256.html : null);
1040
+ const pngSha = sha256File(path.join(articleDir, block.png));
1041
+ report.blocks.push({
1042
+ key: t.key, block_id: t.block_id, index: t.index,
1043
+ questions_total: results.length,
1044
+ blocking: results.filter(r => r.severity === 'blocking').length,
1045
+ checkable: checkable.length,
1046
+ resolved: checkable.filter(r => r.resolved === true).length,
1047
+ unresolved: checkable.filter(r => r.resolved === false).map(r => ({ id: r.id, kind: r.kind, severity: r.severity })),
1048
+ questions: results,
1049
+ html_changed: htmlChanged,
1050
+ png_changed: t.sha256 && t.sha256.png && pngSha ? pngSha !== t.sha256.png : null,
1051
+ render_before: (t.before || {}).render_score ?? null,
1052
+ render_after: after.score,
1053
+ chrome_before: (t.before || {}).chrome ?? null,
1054
+ chrome_after: chromeMean(after.metrics),
1055
+ errors_after: after.errors.length,
1056
+ warnings_after: after.warnings.length,
1057
+ });
1058
+ }
1059
+ const b = report.blocks;
1060
+ report.totals = {
1061
+ blocks_ticketed: b.length,
1062
+ questions: b.reduce((n, x) => n + x.questions_total, 0),
1063
+ blocking: b.reduce((n, x) => n + x.blocking, 0),
1064
+ checkable: b.reduce((n, x) => n + x.checkable, 0),
1065
+ resolved: b.reduce((n, x) => n + x.resolved, 0),
1066
+ blocks_html_changed: b.filter(x => x.html_changed === true).length,
1067
+ blocks_png_changed: b.filter(x => x.png_changed === true).length,
1068
+ blocks_with_errors_after: b.filter(x => x.errors_after > 0).length,
1069
+ };
1070
+ return report;
1071
+ }
1072
+
1073
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
1074
+ function main(argv = process.argv.slice(2), env = process.env) {
1075
+ const positional = argv.filter(a => !a.startsWith('--'));
1076
+ const reportMode = argv.includes('--report');
1077
+ const articleDir = positional[0];
1078
+ if (!articleDir || !fs.existsSync(articleDir)) {
1079
+ console.log('Usage: polish_tickets.js <article_dir> [project_root] [--report]');
1080
+ return 0;
1081
+ }
1082
+ const lintForRoot = readJson(path.join(articleDir, 'lint_report.json'));
1083
+ const rootArg = positional[1] || (lintForRoot && typeof lintForRoot.project_dir === 'string' && lintForRoot.project_dir !== '.' ? lintForRoot.project_dir : null);
1084
+ const root = path.resolve(rootArg || process.cwd());
1085
+ const outFile = path.join(articleDir, reportMode ? 'polish_report.json' : 'polish_tickets.json');
1086
+
1087
+ if (!polishEnabled(env)) {
1088
+ const disabled = reportMode
1089
+ ? { schema_version: SCHEMA_VERSION, enabled: false, blocks: [], totals: null }
1090
+ : { schema_version: SCHEMA_VERSION, enabled: false, blocks: [], skipped: [] };
1091
+ fs.writeFileSync(outFile, JSON.stringify(disabled, null, 2));
1092
+ console.log('polish: disabled');
1093
+ return 0;
1094
+ }
1095
+ if (reportMode) {
1096
+ const report = buildReport(articleDir, root);
1097
+ fs.writeFileSync(outFile, JSON.stringify(report, null, 2));
1098
+ if (!report.enabled) { console.log('polish: disabled'); return 0; }
1099
+ const tt = report.totals || {};
1100
+ console.log(`polish: ${tt.blocks_ticketed || 0} block(s) ticketed, ${tt.questions || 0} question(s) ` +
1101
+ `(${tt.checkable || 0} checkable) — resolved ${tt.resolved || 0}/${tt.checkable || 0}; ` +
1102
+ `HTML changed ${tt.blocks_html_changed || 0}/${tt.blocks_ticketed || 0}, PNG changed ${tt.blocks_png_changed || 0}/${tt.blocks_ticketed || 0}` +
1103
+ `${tt.blocks_with_errors_after ? `; ${tt.blocks_with_errors_after} block(s) still have lint errors` : ''}`);
1104
+ for (const b of report.blocks) {
1105
+ const label = b.block_id !== null && b.block_id !== undefined ? `block_${b.block_id}` : `step_${b.index}`;
1106
+ console.log(` ${label}: resolved ${b.resolved}/${b.checkable}` +
1107
+ `${b.unresolved.length ? ` (open: ${b.unresolved.map(u => u.kind).join(', ')})` : ''}` +
1108
+ `; html ${b.html_changed === null ? '?' : (b.html_changed ? 'changed' : 'unchanged')}` +
1109
+ `, png ${b.png_changed === null ? '?' : (b.png_changed ? 'changed' : 'unchanged')}` +
1110
+ `; render ${b.render_before ?? '?'}→${b.render_after ?? '?'}; chrome ${b.chrome_before ?? '–'}→${b.chrome_after ?? '–'}`);
1111
+ }
1112
+ return 0;
1113
+ }
1114
+ const tickets = buildTickets(articleDir, root, env);
1115
+ fs.writeFileSync(outFile, JSON.stringify(tickets, null, 2));
1116
+ const tt = tickets.totals || { blocks_ticketed: 0, questions: 0, blocking: 0 };
1117
+ console.log(`polish: ${tt.blocks_ticketed} block(s) ticketed, ${tt.questions} question(s), ${tt.blocking} blocking ` +
1118
+ `(edit budget ${tickets.budget.max_edits}/block)${tickets.skipped.length ? `; skipped ${tickets.skipped.length}` : ''}`);
1119
+ for (const t of tickets.blocks) {
1120
+ console.log(` ${t.html}: ${t.counts.questions} question(s), ${t.counts.blocking} blocking — Read ${t.png}, then ` +
1121
+ `${t.sources.read_order.length ? t.sources.read_order.join(', ') : '(no readable sources)'}`);
1122
+ }
1123
+ for (const s of tickets.skipped) console.log(` skipped ${s.block || ''}: ${s.reason}`);
1124
+ if (argv.includes('--restore') && tickets.blocks.length) {
1125
+ let restored = 0;
1126
+ for (const t of tickets.blocks) {
1127
+ if (!t.html_raw) continue;
1128
+ try { fs.copyFileSync(path.join(articleDir, t.html_raw), path.join(articleDir, t.html)); restored++; } catch { /* keep injected */ }
1129
+ }
1130
+ console.log(`polish: ${restored} block(s) restored to their raw authored HTML (INJECT_CSS marker + {{img}} placeholders back) — ` +
1131
+ `edit those files; render_ready.js re-injects per block`);
1132
+ }
1133
+ if (!tickets.blocks.length) console.log('POLISH: nothing to do — skip the polish loop; the closing block below is still run.');
1134
+ else console.log(`POLISH: ${tickets.blocks.length} block(s) need a second look — for each, Read the PNG, then the sources, answer every question (blocking first), ADD what is missing, re-publish with render_ready.js.`);
1135
+ return 0;
1136
+ }
1137
+
1138
+ if (require.main === module) {
1139
+ try { process.exitCode = main(); } catch (err) {
1140
+ console.log(`polish: tickets unavailable (${err.message})`);
1141
+ process.exitCode = 0;
1142
+ }
1143
+ }
1144
+
1145
+ module.exports = {
1146
+ buildTickets, buildReport, evaluateCheck, polishEnabled, chromeMean,
1147
+ extractLabels, extractI18nKeys, extractComponentRefs, extractFormFields, LocaleIndex, shellSummary, layoutFor,
1148
+ };