@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,481 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * polish_tickets.js tests — fixture builder + one probe per question generator,
4
+ * the thresholds at their boundaries, the env gate, the block cap, the report,
5
+ * and the degrade-to-fewer-questions posture on missing inputs.
6
+ */
7
+ const assert = require('assert');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawnSync } = require('child_process');
12
+
13
+ const SCRIPT = path.join(__dirname, 'polish_tickets.js');
14
+ const { buildTickets, buildReport } = require(SCRIPT);
15
+
16
+ function write(file, contents) {
17
+ fs.mkdirSync(path.dirname(file), { recursive: true });
18
+ fs.writeFileSync(file, typeof contents === 'string' ? contents : JSON.stringify(contents, null, 2));
19
+ }
20
+ const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
21
+
22
+ /** Build a web project + article dir; `mutate(ctx)` adjusts the pieces before they are written. */
23
+ function fixture(mutate) {
24
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rtfm-polish-'));
25
+ const out = path.join(root, 'output', 'articles', 'how-to-manage-event-types');
26
+ const ctx = {
27
+ root, out,
28
+ projectMap: {
29
+ schema_version: 5, app_type: 'web', framework: 'nextjs',
30
+ app_shell: {
31
+ type: 'left-sidebar', layout: 'main',
32
+ nav_items: [
33
+ { label: 'Dashboard', href: '/' }, { label: 'Bookings', href: '/bookings' },
34
+ { label: 'Availability', href: '/availability' }, { label: 'Apps', href: '/apps' },
35
+ { label: 'Settings', href: '/settings', children: [{ label: 'Profile', href: '/settings/profile' }] },
36
+ ],
37
+ footer_items: [{ label: 'Help centre', href: '/help' }],
38
+ account_area: 'avatar + name at the top of the sidebar',
39
+ top_bar: { items_left: ['Logo (links home)'], items_right: ['Notifications'] },
40
+ },
41
+ layouts: [
42
+ { name: 'main', path: 'src/layouts/main.tsx', chrome: ['src/shell/Sidebar.tsx', 'src/shell/Topbar.tsx'], area: 'main authenticated shell' },
43
+ { name: 'editor', path: 'src/layouts/editor.tsx', chrome: [], area: 'fullscreen editor',
44
+ runtime_chrome: { regions: [{ name: 'toolbar', ui_strings: ['Publish', 'Save draft'] }], ui_strings: ['Publish', 'Save draft', 'Add title'] } },
45
+ ],
46
+ },
47
+ sources: {
48
+ 'src/pages/List.tsx': [
49
+ 'import { useState } from "react";',
50
+ 'import RowMenu from "./RowMenu";',
51
+ 'import { Button } from "@ui/button";',
52
+ 'import { IconPlus } from "@tabler/icons-react";',
53
+ 'export default function List(props: ListProps) {',
54
+ ' const [q] = useState<ListProps>(null);',
55
+ ' return (<div className="list">',
56
+ ' <h1>Event types</h1>',
57
+ ' <input placeholder="Search" />',
58
+ ' <Button><IconPlus />New</Button>',
59
+ ' <table><thead><tr><th>Name</th><th>Duration</th></tr></thead></table>',
60
+ ' <p>Sorry, you are not allowed.</p>',
61
+ ' <span>{t("hide_from_profile")}</span>',
62
+ ' <RowMenu />',
63
+ ' </div>);',
64
+ '}',
65
+ ].join('\n'),
66
+ 'src/pages/RowMenu.tsx': 'export default function RowMenu() { return <menu><button>Duplicate</button></menu>; }',
67
+ 'src/pages/Editor.tsx': 'export default function Editor() { return <div><button>Publish</button><button>Save draft</button><input placeholder="Add title" /></div>; }',
68
+ 'src/pages/Form.tsx': '<form><input name="title" /><input name="slug" /><input name="length" /><select name="location"></select><textarea name="description"></textarea><input name="price" /></form>',
69
+ 'src/layouts/main.tsx': 'import Sidebar from "../shell/Sidebar"; export default ({children}) => <div><Sidebar />{children}</div>;',
70
+ 'src/layouts/editor.tsx': 'export default ({children}) => <main>{children}</main>;',
71
+ 'src/shell/Sidebar.tsx': '<nav><a href="/">Dashboard</a><a href="/bookings">Bookings</a><a href="/availability">Availability</a><a href="/apps">Apps</a><a href="/settings">Settings</a></nav>',
72
+ 'src/shell/Topbar.tsx': '<header><span>Notifications</span></header>',
73
+ 'locales/en/common.json': { hide_from_profile: 'Hide from profile', duration_label: 'Duration' },
74
+ },
75
+ viewSources: {
76
+ framework: 'nextjs',
77
+ action_coverage: [
78
+ { article_block_id: 'list', screenshot_block_id: 'list', kind: 'click', target: 'New', state: 'action-ready' },
79
+ ],
80
+ blocks: [
81
+ { block_id: 'list', index: 0, layout: 'main', primary_view: 'src/pages/List.tsx',
82
+ partials_expanded: ['src/layouts/main.tsx', 'src/shell/Sidebar.tsx', 'locales/en/common.json'],
83
+ verbatim_evidence: ['Event types', 'New'], depicted_state: 'listing with the New button visible',
84
+ default_user_assumptions: [{ assumption: 'not read-only', markup_absence_check: ['read only'] }] },
85
+ { block_id: 'editor', index: 1, layout: 'editor', primary_view: 'src/pages/Editor.tsx',
86
+ partials_expanded: [], verbatim_evidence: ['Publish'] },
87
+ { block_id: 'ext', index: 2, external_surface: true, primary_view: 'src/pages/List.tsx', partials_expanded: [] },
88
+ { block_id: 'nopng', index: 3, layout: 'main', primary_view: 'src/pages/List.tsx', partials_expanded: [] },
89
+ ],
90
+ },
91
+ html: {
92
+ list: '<!doctype html><html class="notranslate"><head><!-- INJECT_CSS --></head><body>' +
93
+ '<aside class="hidden lg:flex"><nav><a>Dashboard</a><a>Bookings</a><a>Availability</a></nav></aside>' +
94
+ '<main><h1>Event types</h1><input placeholder="Filter" /><button data-rtfm-action-target="list">New</button>' +
95
+ '<table><tr><th>Name</th></tr></table></main></body></html>',
96
+ editor: '<!doctype html><html><body><div class="editor"><button>Publish</button></div></body></html>',
97
+ nopng: '<!doctype html><html><body><p>no png</p></body></html>',
98
+ },
99
+ pre: { list: true },
100
+ png: { list: true, editor: true },
101
+ lint: {
102
+ framework: 'nextjs', project_dir: '.', global_errors: [], global_warnings: [], all_passed: true,
103
+ metrics: { errors_total: 0, warnings_total: 1 },
104
+ blocks: [
105
+ { index: 0, block_id: 'list', primary_view: 'src/pages/List.tsx', errors: [],
106
+ warnings: ['only 5/12 (41%) of this mockup\'s class/id tokens match a selector in branding.css/mockup.css (unmatched: .foo, .bar). The branding bundle probably misses the stylesheet(s) for this surface.'],
107
+ metrics: {
108
+ shell_nav_labels: { hits: 3, total: 5, ratio: 0.6 },
109
+ chrome_files_expanded: { hits: 1, total: 2, ratio: 0.5 },
110
+ runtime_ui_strings: null, runtime_regions_rendered: null,
111
+ root_classes_html: { hits: 1, total: 1, ratio: 1 }, layout_root_classes: null,
112
+ verbatim_evidence_source: { hits: 2, total: 2, ratio: 1 }, verbatim_evidence_html: { hits: 2, total: 2, ratio: 1 },
113
+ partials_present: { hits: 3, total: 3, ratio: 1 }, body_class_tokens: null,
114
+ styled_coverage: { hits: 5, total: 12, ratio: 0.417 }, inline_classes_verified: null,
115
+ invented_colours: { count: 0 }, invented_copy: { count: 0 },
116
+ } },
117
+ { index: 1, block_id: 'editor', primary_view: 'src/pages/Editor.tsx', errors: [], warnings: [],
118
+ metrics: { shell_nav_labels: { hits: 0, total: 5, ratio: 0 }, runtime_ui_strings: { hits: 1, total: 3, ratio: 0.333 },
119
+ invented_copy: { count: 0 } } },
120
+ ],
121
+ },
122
+ diagnostics: {
123
+ list: { qualityScore: { score: 97, rating: 'good', deductions: ['-3: 1 failed resource(s)'] },
124
+ metrics: { evidenceVisibility: [{ string: 'Event types', visible: true, visible_px: 900 }, { string: 'New', visible: true, visible_px: 300 }],
125
+ probeVisibility: [{ string: 'Dashboard', visible: false, visible_px: 0 }, { string: 'Bookings', visible: false, visible_px: 0 },
126
+ { string: 'Availability', visible: true, visible_px: 400 }, { string: 'Help centre', visible: false, visible_px: 0 }] } },
127
+ editor: { qualityScore: { score: 45, rating: 'poor', deductions: ['-50: Appears blank'] }, metrics: { evidenceVisibility: [{ string: 'Publish', visible: true, visible_px: 200 }] } },
128
+ },
129
+ env: { RTFM_POLISH: 'on' },
130
+ };
131
+ if (mutate) mutate(ctx);
132
+ write(path.join(root, '.rtfm', 'project_map.json'), ctx.projectMap);
133
+ for (const [rel, contents] of Object.entries(ctx.sources)) write(path.join(root, rel), contents);
134
+ if (ctx.viewSources) write(path.join(out, 'view_sources.json'), ctx.viewSources);
135
+ if (ctx.lint) write(path.join(out, 'lint_report.json'), ctx.lint);
136
+ for (const [id, html] of Object.entries(ctx.html)) {
137
+ write(path.join(out, `block_${id}.html`), html);
138
+ if (ctx.pre[id]) write(path.join(out, `block_${id}.html.pre`), html.replace('<body>', '<body data-raw="1">'));
139
+ if (ctx.png[id]) fs.writeFileSync(path.join(out, `block_${id}.png`), PNG);
140
+ }
141
+ for (const [id, diag] of Object.entries(ctx.diagnostics)) write(path.join(out, `block_${id}_diagnostics.json`), diag);
142
+ return ctx;
143
+ }
144
+ function kinds(ticket) { return ticket.questions.map(q => q.kind); }
145
+ function q(ticket, kind) { return ticket.questions.find(x => x.kind === kind); }
146
+ function byKey(tickets, key) { return tickets.blocks.find(b => b.key === key); }
147
+ function cli(args, env) {
148
+ return spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8', env: { ...process.env, ...env } });
149
+ }
150
+
151
+ // ── 1. The default fixture: every generator fires exactly where designed ──────
152
+ {
153
+ const ctx = fixture();
154
+ const t = buildTickets(ctx.out, ctx.root, ctx.env);
155
+ assert.strictEqual(t.enabled, true);
156
+ assert.deepStrictEqual(t.inputs, { view_sources: true, lint_report: true, project_map: true, app_shell: true });
157
+ assert.deepStrictEqual(t.blocks.map(b => b.key).sort(), ['editor', 'list']);
158
+ assert.deepStrictEqual(t.skipped.map(s => `${s.block}:${s.reason}`).sort(), ['ext:external-surface', 'nopng:no-png']);
159
+ assert(t.blocks[0].counts.blocking >= t.blocks[1].counts.blocking, 'worst block first');
160
+ assert.strictEqual(t.budget.max_edits, 6);
161
+
162
+ const list = byKey(t, 'list');
163
+ assert.strictEqual(list.html, 'block_list.html');
164
+ assert.strictEqual(list.html_raw, 'block_list.html.pre', 'raw HTML named when a .pre exists');
165
+ assert.strictEqual(list.png, 'block_list.png');
166
+ assert(list.sha256.html && list.sha256.html_raw && list.sha256.png);
167
+ assert.strictEqual(list.before.render_score, 97);
168
+ assert.strictEqual(list.before.chrome, 0.7, 'mean of shell 0.6, chrome files 0.5, root classes 1');
169
+ assert.deepStrictEqual(list.sources.read_order.slice(0, 1), ['src/pages/List.tsx'], 'primary view first');
170
+ assert(list.sources.read_order.includes('src/shell/Topbar.tsx'), 'unexpanded chrome file still in read order');
171
+ assert.strictEqual(list.sources.read_order[list.sources.read_order.length - 1], 'locales/en/common.json', 'locale files last');
172
+ assert.deepStrictEqual(list.shell.nav_items, ['Dashboard', 'Bookings', 'Availability', 'Apps', 'Settings', 'Profile']);
173
+ assert.deepStrictEqual(list.action_targets, [{ target: 'New', kind: 'click' }]);
174
+
175
+ const k = kinds(list);
176
+ assert.strictEqual(k.filter(x => x === 'compare').length, 1, 'exactly one compare question');
177
+ assert.strictEqual(list.questions[list.questions.length - 1].kind, 'compare', 'compare question last');
178
+
179
+ const shell = q(list, 'shell_labels_missing');
180
+ assert(shell, 'shell labels 0.6 < 0.8 fires');
181
+ assert.strictEqual(shell.severity, 'advisory', '0.6 ≥ 0.5 stays advisory');
182
+ assert.deepStrictEqual(shell.missing, ['Apps', 'Settings'], 'names the ACTUAL missing top-level labels');
183
+ assert(shell.text.includes('src/shell/Sidebar.tsx'), 'points at the layout chrome files');
184
+ assert.deepStrictEqual(shell.check, { type: 'metric_ratio_min', key: 'shell_nav_labels', min: 0.8 });
185
+
186
+ const paint = q(list, 'shell_not_painting');
187
+ assert(paint, 'labels in the HTML but invisible in the probe → shell_not_painting');
188
+ assert.strictEqual(paint.severity, 'blocking');
189
+ assert.deepStrictEqual(paint.hidden, ['Dashboard', 'Bookings']);
190
+ assert(paint.text.includes('paint 0 px'), paint.text);
191
+ assert.deepStrictEqual(paint.check, { type: 'probe_visible_majority', strings: ['Dashboard', 'Bookings', 'Availability'] });
192
+ assert(!q(list, 'shell_item_hidden'), 'majority hidden is the blocking kind, not the advisory one');
193
+
194
+ const chrome = q(list, 'chrome_file_unexpanded');
195
+ assert(chrome && chrome.severity === 'blocking');
196
+ assert.deepStrictEqual(chrome.unexpanded, ['src/shell/Topbar.tsx']);
197
+
198
+ const footer = q(list, 'shell_footer_missing');
199
+ assert(footer && footer.check.strings.includes('Help centre'));
200
+ const topBar = q(list, 'shell_top_bar_missing');
201
+ assert(topBar, 'literal top-bar label absent → ticket');
202
+ assert.deepStrictEqual(topBar.check.strings, ['Notifications'], 'prose top-bar items are never checked');
203
+
204
+ const styled = q(list, 'styled_coverage_low');
205
+ assert(styled && styled.text.includes('.foo'), 'quotes the lint\'s unmatched sample');
206
+ assert(!q(list, 'render_quality'), 'failed-resource deductions alone never ticket');
207
+ assert(!q(list, 'evidence_invisible') && !q(list, 'action_target_invisible'));
208
+
209
+ const comp = q(list, 'source_component_unrendered');
210
+ assert(comp, 'RowMenu leaves no trace → ticket');
211
+ const names = comp.components.map(c => c.name);
212
+ assert(names.includes('RowMenu'), names);
213
+ assert.strictEqual(comp.components.find(c => c.name === 'RowMenu').file, 'src/pages/RowMenu.tsx', 'relative import resolved');
214
+ for (const noise of ['ListProps', 'IconPlus', 'Button']) assert(!names.includes(noise), `${noise} must not be a component ticket`);
215
+
216
+ const labels = q(list, 'source_labels_missing');
217
+ assert(labels, 'labels ticket');
218
+ for (const l of ['Duration', 'Search', 'Hide from profile']) assert(labels.labels.includes(l), `${l} expected in ${labels.labels}`);
219
+ for (const l of ['Event types', 'Name', 'Sorry, you are not allowed.']) assert(!labels.labels.includes(l), `${l} must not be listed`);
220
+ assert.strictEqual(labels.check.type, 'html_contains_any');
221
+ assert(!q(list, 'source_form_fields_missing'), 'a list view with 1 source input never tickets form fields');
222
+ assert(!q(list, 'source_nav_missing'), 'a rich app_shell suppresses the source-nav fallback');
223
+
224
+ const editor = byKey(t, 'editor');
225
+ assert(!q(editor, 'shell_labels_missing'), 'chrome: [] + runtime_chrome layout never grows a shell');
226
+ assert.strictEqual(editor.shell, null);
227
+ assert.strictEqual(editor.sources.layout.shell_hidden, true);
228
+ const rs = q(editor, 'runtime_strings_missing');
229
+ assert(rs && rs.severity === 'advisory');
230
+ assert.deepStrictEqual(rs.missing, ['Save draft', 'Add title']);
231
+ const rq = q(editor, 'render_quality');
232
+ assert(rq && rq.severity === 'blocking' && rq.text.includes('Appears blank'));
233
+ assert.deepStrictEqual(rq.check, { type: 'render_deductions_absent' });
234
+ fs.rmSync(ctx.root, { recursive: true, force: true });
235
+ }
236
+
237
+ // ── 2. Thresholds at their boundaries ────────────────────────────────────────
238
+ {
239
+ const at = (mutator) => { const c = fixture(mutator); const t = buildTickets(c.out, c.root, c.env); fs.rmSync(c.root, { recursive: true, force: true }); return t; };
240
+ const setList = (ctx, fn) => fn(ctx.lint.blocks[0].metrics);
241
+ assert(!q(byKey(at(c => setList(c, m => { m.shell_nav_labels = { hits: 4, total: 5, ratio: 0.8 }; })), 'list'), 'shell_labels_missing'), '0.8 does not fire');
242
+ assert(q(byKey(at(c => setList(c, m => { m.shell_nav_labels = { hits: 4, total: 5, ratio: 0.79 }; })), 'list'), 'shell_labels_missing'), '0.79 fires');
243
+ assert.strictEqual(q(byKey(at(c => setList(c, m => { m.shell_nav_labels = { hits: 2, total: 5, ratio: 0.4 }; })), 'list'), 'shell_labels_missing').severity, 'blocking', '< 0.5 is blocking');
244
+ assert(!q(byKey(at(c => setList(c, m => { m.styled_coverage = { hits: 6, total: 10, ratio: 0.6 }; })), 'list'), 'styled_coverage_low'), '0.6 does not fire');
245
+ assert(q(byKey(at(c => setList(c, m => { m.styled_coverage = { hits: 6, total: 10, ratio: 0.59 }; })), 'list'), 'styled_coverage_low'), '0.59 fires');
246
+ assert(!q(byKey(at(c => setList(c, m => { m.styled_coverage = { hits: 3, total: 9, ratio: 0.333 }; })), 'list'), 'styled_coverage_low'), 'total < 10 never fires');
247
+ assert(!q(byKey(at(c => { c.diagnostics.list.qualityScore = { score: 60, rating: 'fair', deductions: ['-40: 8 failed resource(s)'] }; }), 'list'), 'render_quality'), 'a low score from failed resources is not the model\'s');
248
+ const cut = q(byKey(at(c => { c.diagnostics.list.qualityScore = { score: 85, rating: 'good', deductions: ['-15: Content taller than screenshot cap — bottom cut off'] }; }), 'list'), 'render_quality');
249
+ assert(cut && cut.severity === 'advisory', 'a content deduction fires even at a good score');
250
+ const chrome1 = at(c => setList(c, m => { m.chrome_files_expanded = { hits: 2, total: 2, ratio: 1 }; }));
251
+ assert(!q(byKey(chrome1, 'list'), 'chrome_file_unexpanded'));
252
+ const hiddenMinor = at(c => { c.diagnostics.list.metrics.probeVisibility[1].visible = true; });
253
+ assert(!q(byKey(hiddenMinor, 'list'), 'shell_not_painting'), '1/3 hidden is not a majority');
254
+ assert.deepStrictEqual(q(byKey(hiddenMinor, 'list'), 'shell_item_hidden').hidden, ['Dashboard']);
255
+ const invisible = at(c => {
256
+ c.diagnostics.list.metrics.evidenceVisibility = [{ string: 'Event types', visible: false }, { string: 'New', visible: false }];
257
+ c.diagnostics.list.metrics.probeVisibility.push({ string: 'New', visible: false });
258
+ });
259
+ assert.strictEqual(q(byKey(invisible, 'list'), 'evidence_invisible').severity, 'blocking');
260
+ assert.deepStrictEqual(q(byKey(invisible, 'list'), 'action_target_invisible').check.strings, ['New']);
261
+ const lintErr = at(c => { c.lint.blocks[0].errors = ['this step claims layout \'main\' but never expanded its recorded chrome file(s): src/shell/Topbar.tsx.']; });
262
+ assert.strictEqual(q(byKey(lintErr, 'list'), 'lint_error').severity, 'blocking');
263
+ assert.strictEqual(byKey(lintErr, 'list').questions[0].kind, 'lint_error', 'lint errors come first');
264
+ const invented = at(c => { c.lint.blocks[0].metrics.invented_copy = { count: 2 }; c.lint.blocks[0].warnings.push('2 multi-word phrase(s) in step_0.html do not appear in any listed source file or locale: "made up sentence here", "another one entirely"'); });
265
+ assert(q(byKey(invented, 'list'), 'invented_copy').text.includes('"made up sentence here"'));
266
+ const missingPartial = at(c => { c.lint.blocks[0].metrics.partials_present = { hits: 2, total: 3, ratio: 0.667 }; c.viewSources.blocks[0].partials_expanded.push('src/shell/Nope.tsx'); });
267
+ assert(q(byKey(missingPartial, 'list'), 'partial_missing').text.includes('src/shell/Nope.tsx'));
268
+ }
269
+
270
+ // ── 3. Shell gating by layout, form fields, source-nav fallback, legacy steps ─
271
+ {
272
+ const c1 = fixture(ctx => { ctx.projectMap.app_shell.layout = 'other'; });
273
+ const t1 = buildTickets(c1.out, c1.root, c1.env);
274
+ assert(!q(byKey(t1, 'list'), 'shell_labels_missing'), 'shell bound to another layout → no shell ticket');
275
+ assert(byKey(t1, 'list').sources.layout.shell_hidden === true);
276
+ fs.rmSync(c1.root, { recursive: true, force: true });
277
+
278
+ const c2 = fixture(ctx => { ctx.projectMap.layouts[0].area = '60px top header ONLY — no left sidebar'; });
279
+ const t2 = buildTickets(c2.out, c2.root, c2.env);
280
+ assert(!q(byKey(t2, 'list'), 'shell_labels_missing'), 'layout prose says no sidebar → no shell ticket');
281
+ fs.rmSync(c2.root, { recursive: true, force: true });
282
+
283
+ const c3 = fixture(ctx => { ctx.viewSources.blocks[0].layout = undefined; });
284
+ const t3 = buildTickets(c3.out, c3.root, c3.env);
285
+ assert(!q(byKey(t3, 'list'), 'shell_labels_missing'), 'web block claiming no layout → no shell ticket');
286
+ fs.rmSync(c3.root, { recursive: true, force: true });
287
+
288
+ const c4 = fixture(ctx => { ctx.projectMap.app_type = 'mobile'; ctx.projectMap.layouts = []; ctx.viewSources.blocks[0].layout = undefined; });
289
+ const t4 = buildTickets(c4.out, c4.root, c4.env);
290
+ assert(q(byKey(t4, 'list'), 'shell_labels_missing'), 'non-web modes omit layout by contract — the shell still applies');
291
+ fs.rmSync(c4.root, { recursive: true, force: true });
292
+
293
+ const c5 = fixture(ctx => {
294
+ ctx.viewSources.blocks.push({ block_id: 'form', index: 4, layout: 'main', primary_view: 'src/pages/Form.tsx', partials_expanded: [], verbatim_evidence: [] });
295
+ ctx.html.form = '<html><body><form><input placeholder="Title" /></form></body></html>';
296
+ ctx.png.form = true;
297
+ });
298
+ const t5 = buildTickets(c5.out, c5.root, c5.env);
299
+ const ff = q(byKey(t5, 'form'), 'source_form_fields_missing');
300
+ assert(ff, '6 source fields vs 1 rendered control → ticket');
301
+ assert(ff.fields.includes('slug') && !ff.fields.includes('title'), `title is present by humanized name: ${ff.fields}`);
302
+ assert.deepStrictEqual(ff.check, { type: 'html_control_count_min', min: 3 });
303
+ fs.rmSync(c5.root, { recursive: true, force: true });
304
+
305
+ const c6 = fixture(ctx => { ctx.projectMap.app_shell = null; });
306
+ const t6 = buildTickets(c6.out, c6.root, c6.env);
307
+ assert.strictEqual(t6.inputs.app_shell, false);
308
+ const nav = q(byKey(t6, 'list'), 'source_nav_missing');
309
+ assert(nav, 'thin map → navigation mined from the layout\'s own <nav>');
310
+ assert(nav.check.strings.includes('Apps') && nav.check.strings.includes('Settings'));
311
+ assert(!nav.check.strings.includes('Dashboard'), 'present labels are not listed');
312
+ fs.rmSync(c6.root, { recursive: true, force: true });
313
+
314
+ const c7 = fixture(ctx => {
315
+ ctx.viewSources = { framework: 'rails', action_coverage: [{ article_step_index: 0, screenshot_step_index: 0, kind: 'click', target: 'New' }],
316
+ steps: [{ index: 0, layout: 'main', primary_view: 'src/pages/List.tsx', partials_expanded: [], verbatim_evidence: ['Event types'] }] };
317
+ ctx.lint.blocks = undefined; ctx.lint.steps = [{ index: 0, errors: [], warnings: [], metrics: { shell_nav_labels: { hits: 3, total: 5, ratio: 0.6 } } }];
318
+ ctx.html = { }; ctx.pre = {}; ctx.png = {}; ctx.diagnostics = {};
319
+ });
320
+ write(path.join(c7.out, 'step_0.html'), c7.html.list || '<html><body><h1>Event types</h1><button>New</button></body></html>');
321
+ fs.writeFileSync(path.join(c7.out, 'step_0.png'), PNG);
322
+ const t7 = buildTickets(c7.out, c7.root, c7.env);
323
+ assert.strictEqual(t7.blocks.length, 1);
324
+ assert.strictEqual(t7.blocks[0].key, '0');
325
+ assert.strictEqual(t7.blocks[0].html, 'step_0.html');
326
+ assert.strictEqual(t7.blocks[0].block_id, null);
327
+ assert.deepStrictEqual(t7.blocks[0].action_targets, [{ target: 'New', kind: 'click' }], 'index-keyed action coverage resolves in legacy mode');
328
+ fs.rmSync(c7.root, { recursive: true, force: true });
329
+ }
330
+
331
+ // ── 4. Env gate, caps, missing inputs, CLI ───────────────────────────────────
332
+ {
333
+ const c = fixture();
334
+ const off = cli([c.out, c.root], { RTFM_POLISH: 'off' });
335
+ assert.strictEqual(off.status, 0, off.stderr);
336
+ assert(off.stdout.includes('polish: disabled'));
337
+ const disabled = JSON.parse(fs.readFileSync(path.join(c.out, 'polish_tickets.json'), 'utf8'));
338
+ assert.deepStrictEqual(disabled, { schema_version: 1, enabled: false, blocks: [], skipped: [] });
339
+ assert(!fs.existsSync(path.join(c.out, 'polish_report.json')));
340
+ const offReport = cli([c.out, c.root, '--report'], { RTFM_POLISH: '0' });
341
+ assert(offReport.stdout.includes('polish: disabled'));
342
+ assert.strictEqual(JSON.parse(fs.readFileSync(path.join(c.out, 'polish_report.json'), 'utf8')).enabled, false);
343
+
344
+ const capped = buildTickets(c.out, c.root, { RTFM_POLISH_MAX_BLOCKS: '1', RTFM_POLISH_MAX_EDITS: '3' });
345
+ assert.strictEqual(capped.blocks.length, 1);
346
+ assert.strictEqual(capped.budget.max_edits, 3);
347
+ assert.strictEqual(capped.blocks[0].budget.max_edits, 3);
348
+ assert(capped.skipped.some(s => s.reason === 'over-block-cap'));
349
+
350
+ const on = cli([c.out, c.root], { RTFM_POLISH: 'on' });
351
+ assert.strictEqual(on.status, 0, on.stderr);
352
+ assert(/polish: 2 block\(s\) ticketed, \d+ question\(s\), \d+ blocking/.test(on.stdout), on.stdout);
353
+ assert(on.stdout.includes('Read block_list.png, then src/pages/List.tsx'), on.stdout);
354
+ assert(on.stdout.includes('skipped ext: external-surface'));
355
+ assert(on.stdout.includes('POLISH: 2 block(s) need a second look'), on.stdout);
356
+ assert(!fs.readFileSync(path.join(c.out, 'block_list.html'), 'utf8').includes('data-raw="1"'), 'no --restore → injected HTML untouched');
357
+ const restore = cli([c.out, c.root, '--restore'], { RTFM_POLISH: 'on' });
358
+ assert.strictEqual(restore.status, 0, restore.stderr);
359
+ assert(restore.stdout.includes('polish: 1 block(s) restored'), restore.stdout);
360
+ assert(fs.readFileSync(path.join(c.out, 'block_list.html'), 'utf8').includes('data-raw="1"'), 'ticketed block with a .pre is restored to raw');
361
+ assert(!fs.readFileSync(path.join(c.out, 'block_editor.html'), 'utf8').includes('data-raw'), 'a block without a .pre is left as is');
362
+ const tAfterRestore = JSON.parse(fs.readFileSync(path.join(c.out, 'polish_tickets.json'), 'utf8'));
363
+ assert.strictEqual(tAfterRestore.blocks.find(b => b.key === 'list').sha256.html_raw, byKey(tAfterRestore, 'list').sha256.html_raw);
364
+ fs.writeFileSync(path.join(c.out, 'block_list.html'), c.html.list); // back to the injected form for the report checks below
365
+
366
+ // Report before any edit: nothing changed, checkable questions evaluated, compare excluded.
367
+ const r0 = buildReport(c.out, c.root);
368
+ assert.strictEqual(r0.enabled, true);
369
+ const l0 = r0.blocks.find(b => b.key === 'list');
370
+ assert.strictEqual(l0.html_changed, false);
371
+ assert.strictEqual(l0.png_changed, false);
372
+ assert(l0.checkable < l0.questions_total, 'the manual compare question is not checkable');
373
+ assert(l0.questions.find(x => x.kind === 'compare').resolved === null);
374
+ assert(l0.unresolved.some(u => u.kind === 'shell_labels_missing'));
375
+ assert(l0.unresolved.some(u => u.kind === 'shell_not_painting'));
376
+ assert.strictEqual(l0.render_before, 97);
377
+ assert.strictEqual(l0.chrome_before, 0.7);
378
+
379
+ // Simulate the polish: labels added to the raw + injected HTML, re-lint metrics up,
380
+ // probe now sees the sidebar, a fresh PNG.
381
+ const html = fs.readFileSync(path.join(c.out, 'block_list.html'), 'utf8').replace('<a>Availability</a>', '<a>Availability</a><a>Apps</a><a>Settings</a><a>Help centre</a>');
382
+ fs.writeFileSync(path.join(c.out, 'block_list.html'), html);
383
+ fs.writeFileSync(path.join(c.out, 'block_list.html.pre'), html.replace('<body>', '<body data-raw="1">'));
384
+ fs.writeFileSync(path.join(c.out, 'block_list.png'), Buffer.concat([PNG, Buffer.from('new')]));
385
+ const lint = JSON.parse(fs.readFileSync(path.join(c.out, 'lint_report.json'), 'utf8'));
386
+ lint.blocks[0].metrics.shell_nav_labels = { hits: 5, total: 5, ratio: 1 };
387
+ lint.blocks[0].metrics.chrome_files_expanded = { hits: 2, total: 2, ratio: 1 };
388
+ write(path.join(c.out, 'lint_report.json'), lint);
389
+ const diag = JSON.parse(fs.readFileSync(path.join(c.out, 'block_list_diagnostics.json'), 'utf8'));
390
+ for (const p of diag.metrics.probeVisibility) p.visible = true;
391
+ diag.qualityScore.score = 98;
392
+ write(path.join(c.out, 'block_list_diagnostics.json'), diag);
393
+
394
+ const rep = cli([c.out, c.root, '--report'], { RTFM_POLISH: 'on' });
395
+ assert.strictEqual(rep.status, 0, rep.stderr);
396
+ assert(/polish: 2 block\(s\) ticketed, \d+ question\(s\) \(\d+ checkable\) — resolved \d+\/\d+; HTML changed 1\/2, PNG changed 1\/2/.test(rep.stdout), rep.stdout);
397
+ const r1 = JSON.parse(fs.readFileSync(path.join(c.out, 'polish_report.json'), 'utf8'));
398
+ const l1 = r1.blocks.find(b => b.key === 'list');
399
+ assert.strictEqual(l1.html_changed, true, 'raw HTML sha moved');
400
+ assert.strictEqual(l1.png_changed, true);
401
+ assert.strictEqual(l1.render_after, 98);
402
+ assert.strictEqual(l1.chrome_after, 1);
403
+ for (const kind of ['shell_labels_missing', 'shell_not_painting', 'chrome_file_unexpanded', 'shell_footer_missing']) {
404
+ assert.strictEqual(l1.questions.find(x => x.kind === kind).resolved, true, `${kind} resolved after the edit`);
405
+ }
406
+ assert.strictEqual(l1.questions.find(x => x.kind === 'styled_coverage_low').resolved, false, 'untouched metric stays unresolved');
407
+ assert(r1.totals.resolved > r0.totals.resolved);
408
+ assert.strictEqual(r1.totals.blocks_html_changed, 1);
409
+ fs.rmSync(c.root, { recursive: true, force: true });
410
+
411
+ // Missing inputs degrade, never fail.
412
+ const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'rtfm-polish-bare-'));
413
+ const noVs = cli([bare, bare], { RTFM_POLISH: 'on' });
414
+ assert.strictEqual(noVs.status, 0);
415
+ const tb = JSON.parse(fs.readFileSync(path.join(bare, 'polish_tickets.json'), 'utf8'));
416
+ assert.strictEqual(tb.enabled, true);
417
+ assert.deepStrictEqual(tb.blocks, []);
418
+ assert.deepStrictEqual(tb.skipped, [{ reason: 'no-view-sources' }]);
419
+ const noTickets = cli([bare, bare, '--report'], { RTFM_POLISH: 'on' });
420
+ assert.strictEqual(noTickets.status, 0);
421
+ fs.rmSync(path.join(bare, 'polish_tickets.json'));
422
+ const orphanReport = cli([bare, bare, '--report'], { RTFM_POLISH: 'on' });
423
+ assert.strictEqual(orphanReport.status, 0);
424
+ assert(JSON.parse(fs.readFileSync(path.join(bare, 'polish_report.json'), 'utf8')).error);
425
+ fs.rmSync(bare, { recursive: true, force: true });
426
+ const nowhere = cli([path.join(os.tmpdir(), 'rtfm-polish-does-not-exist')], { RTFM_POLISH: 'on' });
427
+ assert.strictEqual(nowhere.status, 0);
428
+ assert(nowhere.stdout.includes('Usage'));
429
+
430
+ // No project map at all: no shell questions, source questions still fire.
431
+ const c8 = fixture();
432
+ fs.rmSync(path.join(c8.root, '.rtfm'), { recursive: true, force: true });
433
+ const t8 = buildTickets(c8.out, c8.root, c8.env);
434
+ assert.strictEqual(t8.inputs.project_map, false);
435
+ assert(!q(byKey(t8, 'list'), 'shell_labels_missing'));
436
+ assert(q(byKey(t8, 'list'), 'source_labels_missing'));
437
+ assert(q(byKey(t8, 'list'), 'compare'));
438
+ fs.rmSync(c8.root, { recursive: true, force: true });
439
+ }
440
+
441
+ // ── 5. Screen closure ticket: unconditional pieces of the primary view that the mockup lacks ──
442
+ {
443
+ const c = fixture(ctx => {
444
+ ctx.sources['src/pages/Form.erb'] = [
445
+ '<%= render partial: "shared/header_bar" %>',
446
+ '<h3>Session details</h3><label>Session title</label>',
447
+ '<h3>Session options</h3><label>Record session</label>',
448
+ '<% if @premium %><h3>Premium extras</h3><label>Priority support</label><% end %>',
449
+ '<%= render partial: "modals/pick_contact_modal" %>',
450
+ ].join('\n');
451
+ ctx.sources['src/shared/_header_bar.html.erb'] = '<button>Save draft</button>';
452
+ ctx.sources['src/modals/_pick_contact_modal.html.erb'] = '<div class="modal"><h5>Pick a contact</h5></div>';
453
+ ctx.viewSources.blocks.push({ block_id: 'form', index: 4, layout: 'main', primary_view: 'src/pages/Form.erb', partials_expanded: [], verbatim_evidence: [] });
454
+ ctx.html.form = '<html><body><main><h3>Session details</h3><label>Session title</label></main></body></html>';
455
+ ctx.png.form = true;
456
+ });
457
+ const t = buildTickets(c.out, c.root, c.env);
458
+ const tk = q(byKey(t, 'form'), 'screen_piece_missing');
459
+ assert(tk, 'missing unconditional pieces → ticket');
460
+ const labels = tk.pieces.map(p => p.label);
461
+ assert(labels.some(l => l.includes('Session options')) && labels.some(l => l.includes('shared/header_bar')), labels);
462
+ assert(!labels.some(l => l.includes('Premium extras')) && !labels.some(l => l.includes('pick_contact')), 'guarded + overlay pieces are exempt');
463
+ assert.strictEqual(tk.severity, 'blocking', 'any missing unconditional piece is blocking');
464
+ assert.strictEqual(tk.check.type, 'html_contains_any');
465
+ fs.rmSync(c.root, { recursive: true, force: true });
466
+
467
+ // One of three missing is still blocking — the list only ever holds pieces the real screen shows.
468
+ const c2 = fixture(ctx => {
469
+ ctx.sources['src/pages/Form.erb'] = '<%= render partial: "shared/header_bar" %>\n<h3>Session details</h3><label>Session title</label>\n<h3>Session options</h3><label>Record session</label>';
470
+ ctx.sources['src/shared/_header_bar.html.erb'] = '<button>Save draft</button>';
471
+ ctx.viewSources.blocks.push({ block_id: 'form', index: 4, layout: 'main', primary_view: 'src/pages/Form.erb', partials_expanded: [], verbatim_evidence: [] });
472
+ ctx.html.form = '<html><body><button>Save draft</button><h3>Session details</h3>Session title</body></html>';
473
+ ctx.png.form = true;
474
+ });
475
+ const t2 = buildTickets(c2.out, c2.root, c2.env);
476
+ const tk2 = q(byKey(t2, 'form'), 'screen_piece_missing');
477
+ assert(tk2 && tk2.severity === 'blocking' && tk2.pieces.length === 1 && tk2.pieces[0].label.includes('Session options'), JSON.stringify(tk2));
478
+ fs.rmSync(c2.root, { recursive: true, force: true });
479
+ }
480
+
481
+ console.log('polish_tickets tests passed');
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * related_repos.sh — the sibling-repository listing of a multi-repo product:
4
+ * silent when RTFM_REPOS_ROOT is unset or holds only the anchor, one `../<dir>`
5
+ * line per other checkout (non-repo dirs skipped, the anchor excluded), roles
6
+ * and surfaces joined from RTFM_REPO_RELATIONSHIPS, and a broken relationships file never
7
+ * failing the listing.
8
+ */
9
+ 'use strict';
10
+ const assert = require('assert');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const { execFileSync } = require('child_process');
15
+
16
+ const SCRIPT = path.join(__dirname, 'related_repos.sh');
17
+
18
+ function repo(root, dir) { fs.mkdirSync(path.join(root, dir, '.git'), { recursive: true }); return path.join(root, dir); }
19
+ function run(env, cwd) {
20
+ const base = { ...process.env };
21
+ delete base.RTFM_REPOS_ROOT; delete base.RTFM_WORKSPACE; delete base.RTFM_REPO_RELATIONSHIPS;
22
+ return execFileSync('bash', [SCRIPT], { encoding: 'utf8', cwd, env: { ...base, ...env } });
23
+ }
24
+
25
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'related-repos-'));
26
+ const web = repo(root, 'acme-web');
27
+
28
+ // Single repo: nothing to list, with or without RTFM_REPOS_ROOT.
29
+ assert.strictEqual(run({}, web), '');
30
+ assert.strictEqual(run({ RTFM_REPOS_ROOT: root, RTFM_WORKSPACE: web }, root), '');
31
+
32
+ // Three repos plus a stray non-repo dir: the two siblings, sorted, anchor excluded.
33
+ repo(root, 'acme-api');
34
+ repo(root, 'acme-mobile');
35
+ fs.mkdirSync(path.join(root, 'scratch'));
36
+ assert.strictEqual(run({ RTFM_REPOS_ROOT: root, RTFM_WORKSPACE: web }, root), '../acme-api\n../acme-mobile\n');
37
+
38
+ // Anchor defaults to cwd when RTFM_WORKSPACE is unset.
39
+ assert.strictEqual(run({ RTFM_REPOS_ROOT: root }, web), '../acme-api\n../acme-mobile\n');
40
+
41
+ // Roles from the relationships file; a repo it doesn't describe prints bare.
42
+ const rels = path.join(root, 'rels.json');
43
+ fs.writeFileSync(rels, JSON.stringify({
44
+ repositories: [
45
+ { directory: 'acme-web', role: 'Web app' },
46
+ { directory: 'acme-api', role: 'Backend API\nserver', surface: 'none' },
47
+ { directory: 'acme-mobile', surface: 'mobile' },
48
+ ],
49
+ }));
50
+ assert.strictEqual(
51
+ run({ RTFM_REPOS_ROOT: root, RTFM_WORKSPACE: web, RTFM_REPO_RELATIONSHIPS: rels }, root),
52
+ '../acme-api\tBackend API server\tnone\n../acme-mobile\t\tmobile\n'
53
+ );
54
+
55
+ // A corrupt or missing relationships file degrades to bare lines.
56
+ fs.writeFileSync(rels, '{not json');
57
+ assert.strictEqual(run({ RTFM_REPOS_ROOT: root, RTFM_WORKSPACE: web, RTFM_REPO_RELATIONSHIPS: rels }, root), '../acme-api\n../acme-mobile\n');
58
+ assert.strictEqual(run({ RTFM_REPOS_ROOT: root, RTFM_WORKSPACE: web, RTFM_REPO_RELATIONSHIPS: '/nope.json' }, root), '../acme-api\n../acme-mobile\n');
59
+
60
+ // A root that doesn't exist is silent, not an error.
61
+ assert.strictEqual(run({ RTFM_REPOS_ROOT: path.join(root, 'missing'), RTFM_WORKSPACE: web }, root), '');
62
+
63
+ fs.rmSync(root, { recursive: true, force: true });
64
+ console.log('test_related_repos: ok');
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ const assert = require('assert');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rtfm-render-ready-'));
9
+ const out = path.join(root, 'output', 'articles', 'progressive-article');
10
+ const cache = path.join(root, '.rtfm');
11
+ fs.mkdirSync(out, { recursive: true });
12
+ fs.mkdirSync(cache, { recursive: true });
13
+
14
+ // The shell nav carries one visible label and one the mockup hides with
15
+ // display:none — the ungated probe must report the first visible and the
16
+ // second not (the cal.com "sidebar in the HTML, 0 px in the PNG" class).
17
+ const html = '<!doctype html><html><head><meta charset="utf-8"><!-- INJECT_CSS --></head>' +
18
+ '<body data-viewport="wide" style="margin:0;background:#fff;color:#111">' +
19
+ '<nav><a href="#">Dashboard</a><a href="#" style="display:none">Hidden Reports</a></nav>' +
20
+ '<main style="width:640px;padding:48px"><h1>Progress ready</h1>' +
21
+ '<button data-rtfm-action-target="0">Publish</button></main></body></html>';
22
+ fs.writeFileSync(path.join(out, 'step_0.html'), html);
23
+ fs.writeFileSync(path.join(out, 'view_sources.json'), JSON.stringify({
24
+ framework: 'test',
25
+ action_coverage: [{ article_step_index: 0, screenshot_step_index: 0, target: 'Publish', kind: 'click' }],
26
+ steps: [{ index: 0, verbatim_evidence: ['Progress ready', 'Publish'] }],
27
+ }));
28
+ fs.writeFileSync(path.join(cache, 'project_map.json'), JSON.stringify({
29
+ app_type: 'web',
30
+ app_shell: {
31
+ type: 'sidebar',
32
+ nav_items: [{ label: 'Dashboard', href: '/' }, { label: 'Hidden Reports', href: '/reports' }],
33
+ footer_items: [{ label: 'Help centre' }],
34
+ },
35
+ }));
36
+ fs.writeFileSync(path.join(cache, 'images_base64.json'), JSON.stringify({ images: {} }));
37
+ fs.writeFileSync(path.join(cache, 'branding.json'), JSON.stringify({
38
+ framework: 'terminal',
39
+ default_colors: { primary: '#111111', background: '#ffffff' },
40
+ compiled_css_path: 'branding.css',
41
+ }));
42
+ fs.writeFileSync(path.join(cache, 'branding.css'), 'body{font-family:Arial,sans-serif}button{padding:8px 12px}');
43
+
44
+ const script = path.join(__dirname, 'render_ready.js');
45
+ const result = spawnSync(process.execPath, [script, out, '0', root], {
46
+ encoding: 'utf8',
47
+ env: { ...process.env, RTFM_WATERMARK: 'off' },
48
+ maxBuffer: 10 * 1024 * 1024,
49
+ });
50
+ assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
51
+ assert.strictEqual(fs.readFileSync(path.join(out, 'step_0.html'), 'utf8'), html,
52
+ 'progress render must restore authorable HTML');
53
+ const png = fs.readFileSync(path.join(out, 'step_0.png'));
54
+ assert.strictEqual(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
55
+ assert(fs.existsSync(path.join(out, 'step_0_diagnostics.json')));
56
+ const diagnostics = JSON.parse(fs.readFileSync(path.join(out, 'step_0_diagnostics.json'), 'utf8'));
57
+ const probe = Object.fromEntries((diagnostics.metrics.probeVisibility || []).map(p => [p.string, p]));
58
+ assert.strictEqual(probe['Dashboard'] && probe['Dashboard'].visible, true, 'visible nav label must probe visible');
59
+ assert.strictEqual(probe['Hidden Reports'] && probe['Hidden Reports'].visible, false, 'display:none nav label must probe invisible');
60
+ assert.strictEqual(probe['Help centre'] && probe['Help centre'].visible, false, 'absent footer label must probe invisible');
61
+ assert(!('Publish' in probe), 'evidence strings are measured by the evidence gate, not the probe');
62
+ assert.strictEqual(diagnostics.metrics.evidenceVisibility.every(e => e.visible), true);
63
+ assert(fs.existsSync(path.join(out, 'step_0_validation.json')));
64
+ assert(fs.existsSync(path.join(out, 'step_0_progress.json')));
65
+ assert(!fs.readdirSync(out).some(name => name.includes('.rendering-')));
66
+
67
+ const blockOut = path.join(root, 'output', 'articles', 'concept-article');
68
+ fs.mkdirSync(blockOut, { recursive: true });
69
+ const blockHtml = html.replace('data-rtfm-action-target="0"', 'data-rtfm-action-target="how-it-works"');
70
+ fs.writeFileSync(path.join(blockOut, 'block_how-it-works.html'), blockHtml);
71
+ fs.writeFileSync(path.join(blockOut, 'view_sources.json'), JSON.stringify({
72
+ framework: 'test', action_coverage: [],
73
+ blocks: [{ block_id: 'how-it-works', verbatim_evidence: ['Progress ready', 'Publish'] }],
74
+ }));
75
+ const blockResult = spawnSync(process.execPath, [script, blockOut, 'how-it-works', root], {
76
+ encoding: 'utf8', env: { ...process.env, RTFM_WATERMARK: 'off' }, maxBuffer: 10 * 1024 * 1024,
77
+ });
78
+ assert.strictEqual(blockResult.status, 0, `${blockResult.stdout}\n${blockResult.stderr}`);
79
+ assert.strictEqual(fs.readFileSync(path.join(blockOut, 'block_how-it-works.html'), 'utf8'), blockHtml);
80
+ assert(fs.existsSync(path.join(blockOut, 'block_how-it-works.png')));
81
+ const progress = JSON.parse(fs.readFileSync(path.join(blockOut, 'block_how-it-works_progress.json'), 'utf8'));
82
+ assert.strictEqual(progress.block_id, 'how-it-works');
83
+
84
+ fs.rmSync(root, { recursive: true, force: true });
85
+ console.log('render_ready progressive publication test passed');