@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,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * jit_mockup_css.js — Render-time local Tailwind JIT.
4
+ *
5
+ * The project's compiled branding.css is Tailwind-purged (it contains only the
6
+ * classes the REAL app uses), so a mockup's own authored classes — arbitrary
7
+ * values (`size-[18px]`), responsive variants (`lg:grid-cols-3`), plugin/component
8
+ * classes — usually aren't in it. That's why inject_assets otherwise falls back to
9
+ * the Tailwind Play CDN, which is unreliable in the headless Puppeteer render.
10
+ *
11
+ * This script instead compiles Tailwind LOCALLY against the MOCKUP HTML as the
12
+ * content source, using the project's cached `css_build` recipe (its real entry +
13
+ * config + plugins, so the theme/brand tokens apply) — producing exactly the
14
+ * classes the mockups use. Output: <out_dir>/mockup.css. inject_assets.js links it
15
+ * and drops the Play CDN when present.
16
+ *
17
+ * Usage: node jit_mockup_css.js <out_dir> <project_dir> <project_map.json>
18
+ * Exit: 0 = mockup.css written; 3 = skipped (no usable recipe / not tailwind);
19
+ * 1 = attempted but the local compile failed.
20
+ * SAFETY: on any skip/failure the caller keeps its current CDN/fallback behaviour,
21
+ * so this never regresses the pipeline.
22
+ */
23
+ 'use strict';
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const path = require('path');
27
+ const { execFileSync } = require('child_process');
28
+
29
+ function log(m) { console.error(' jit: ' + m); }
30
+ function skip(m) { log(m); process.exit(3); }
31
+ function fail(m) { log(m); process.exit(1); }
32
+
33
+ let [outDir, projectDir, projectMapPath] = process.argv.slice(2);
34
+ if (!outDir || !projectDir) skip('missing args (out_dir project_dir project_map.json)');
35
+ if (!fs.existsSync(outDir) || !fs.existsSync(projectDir)) skip('out_dir or project_dir missing');
36
+ // Resolve to ABSOLUTE now — callers pass a relative out_dir (e.g. output/articles/<slug>)
37
+ // but we run the compile from a different cwd (the assets/node_modules dir), so any
38
+ // relative -o / --content path would resolve against the wrong base and emit 0 bytes.
39
+ outDir = path.resolve(outDir);
40
+ projectDir = path.resolve(projectDir);
41
+
42
+ // --- Load the css_build recipe -------------------------------------------------
43
+ let recipe = null;
44
+ try { recipe = JSON.parse(fs.readFileSync(projectMapPath, 'utf8')).css_build; } catch (_) {}
45
+ if (!recipe) skip('no css_build recipe in project_map — keeping CDN');
46
+ if (recipe.method === 'fallback_synthesis') skip('recipe is fallback_synthesis — keeping CDN');
47
+ const twV = recipe.tw_version === 3 || recipe.tw_version === 4 ? recipe.tw_version : null;
48
+ if (!twV) skip('recipe is not a Tailwind build — keeping CDN');
49
+
50
+ // --- Gather the mockup HTML as the content to scan -----------------------------
51
+ const stepHtml = fs.readdirSync(outDir)
52
+ .filter(f => /^(?:step_\d+|block_[a-z0-9]+(?:-[a-z0-9]+)*)\.html$/.test(f))
53
+ .map(f => path.join(outDir, f));
54
+ if (!stepHtml.length) skip('no article mockup HTML to scan');
55
+
56
+ const outCss = path.join(outDir, 'mockup.css');
57
+ const entryRel = recipe.entry || '';
58
+ const entryAbs = entryRel ? path.resolve(projectDir, entryRel) : '';
59
+
60
+ // Run from the nearest ancestor of the entry (or projectDir) that has a
61
+ // node_modules — that's where detect-project installed tailwindcss + plugins, so
62
+ // `@import "tailwindcss"` / `@plugin` resolve. Falls back to projectDir.
63
+ function findCwd() {
64
+ let dir = entryAbs ? path.dirname(entryAbs) : projectDir;
65
+ const root = path.parse(dir).root;
66
+ while (dir && dir !== root) {
67
+ if (fs.existsSync(path.join(dir, 'node_modules', 'tailwindcss')) ||
68
+ fs.existsSync(path.join(dir, 'node_modules', '@tailwindcss'))) return dir;
69
+ dir = path.dirname(dir);
70
+ }
71
+ return projectDir;
72
+ }
73
+ const cwd = findCwd();
74
+
75
+ // Prefer a locally-installed Tailwind binary (detect-project installs one) so the
76
+ // render never depends on npx fetching @tailwindcss/cli over the network — the
77
+ // failure mode that silently drops back to the flaky Play CDN in constrained envs.
78
+ function twCli(v) {
79
+ const localBin = path.join(cwd, 'node_modules', '.bin', 'tailwindcss');
80
+ if (fs.existsSync(localBin)) return JSON.stringify(localBin);
81
+ return v === 4 ? 'npx --yes @tailwindcss/cli@4' : 'npx --yes tailwindcss@3';
82
+ }
83
+
84
+ // --- Build the compile command from recipe fields (not string-munging) ---------
85
+ let cmd;
86
+ let tmpCfg = null; // v3 temp config path — hoisted so the retry can rewrite it
87
+ if (twV === 4) {
88
+ // v4 CLI: the real entry supplies @import "tailwindcss" + @plugin (theme/plugins);
89
+ // repeated --content points scanning at the MOCKUP html (overrides source(none)).
90
+ if (!entryAbs || !fs.existsSync(entryAbs)) skip('v4 recipe has no usable entry file');
91
+ const content = stepHtml.map(h => `--content ${JSON.stringify(h)}`).join(' ');
92
+ cmd = `${twCli(4)} -i ${JSON.stringify(entryAbs)} -o ${JSON.stringify(outCss)} ${content}`;
93
+ } else {
94
+ // v3: spread the real config but override `content` to the mockup html, so the
95
+ // theme/plugins from the real config apply while only the mockup's classes emit.
96
+ const cfgRel = recipe.config;
97
+ tmpCfg = path.join(outDir, '.jit_tw.config.js');
98
+ if (cfgRel) {
99
+ const cfgAbs = path.resolve(projectDir, cfgRel);
100
+ fs.writeFileSync(tmpCfg,
101
+ `const base = require(${JSON.stringify(cfgAbs)});\n` +
102
+ `module.exports = { ...base, content: ${JSON.stringify(stepHtml)} };\n`);
103
+ } else {
104
+ fs.writeFileSync(tmpCfg, `module.exports = { content: ${JSON.stringify(stepHtml)} };\n`);
105
+ }
106
+ // v3 needs an entry with @tailwind directives; synthesize one if the recipe's
107
+ // entry isn't a plain v3 entry.
108
+ const tmpIn = path.join(outDir, '.jit_tw_in.css');
109
+ fs.writeFileSync(tmpIn, '@tailwind base;\n@tailwind components;\n@tailwind utilities;\n');
110
+ cmd = `${twCli(3)} -c ${JSON.stringify(tmpCfg)} -i ${JSON.stringify(tmpIn)} -o ${JSON.stringify(outCss)}`;
111
+ }
112
+
113
+ // --- Run it (timed) ------------------------------------------------------------
114
+ // The render container is typically network-isolated, so the "keep the CDN"
115
+ // fallback is a mirage — a local compile is the ONLY thing that can work. The
116
+ // usual reason a first attempt fails is an unresolvable plugin: the real config
117
+ // `require()`s a plugin (`tailwind-scrollbar`, a v4 `@plugin`) that isn't in the
118
+ // render env's node_modules (detect-time installs don't always persist, pnpm
119
+ // symlinks dangle). A missing PLUGIN must not sink the whole compile — the
120
+ // mockup's own utility classes (`size-[18px]`, `grid-cols-3`, responsive
121
+ // variants) are theme/plugin-independent and the brand theme is already carried
122
+ // by the separately-linked `branding.css`. So on failure, retry once with a
123
+ // minimal, plugin-free config/entry rather than surrendering to the CDN.
124
+ log(`(cwd=${cwd}, tw v${twV}) compiling mockup classes -> ${path.basename(outCss)}`);
125
+ const buildLog = path.join(outDir, 'mockup_build.log');
126
+ function runCompile(c) {
127
+ return execFileSync('bash', ['-c', `cd ${JSON.stringify(cwd)} && ${c} 2>&1`],
128
+ { timeout: 120000, encoding: 'utf8' });
129
+ }
130
+ let out = '', ok = false;
131
+ try { out = runCompile(cmd); ok = true; }
132
+ catch (e) { out = (e.stdout || '') + (e.stderr || '') + '\n' + String(e.message || e); }
133
+
134
+ if (!ok && twV !== 4 && recipe.config) {
135
+ // v3: drop the real config's theme+plugins; keep only the mockup content.
136
+ log('config load failed (likely a missing plugin) — retrying with a minimal plugin-free config');
137
+ fs.writeFileSync(tmpCfg, `module.exports = { content: ${JSON.stringify(stepHtml)} };\n`);
138
+ try { out += '\n--- retry (minimal config) ---\n' + runCompile(cmd); ok = true; }
139
+ catch (e) { out += '\n--- retry (minimal config) FAILED ---\n' + (e.stdout || '') + (e.stderr || '') + '\n' + String(e.message || e); }
140
+ } else if (!ok && twV === 4 && entryAbs) {
141
+ // v4: the real entry's `@plugin`/`@import` lines can fail to resolve; retry
142
+ // with a bare `@import "tailwindcss"` entry (default theme, no plugins).
143
+ log('v4 entry compile failed (likely a missing @plugin) — retrying with a bare tailwindcss entry');
144
+ const bareIn = path.join(outDir, '.jit_tw_in.css');
145
+ fs.writeFileSync(bareIn, '@import "tailwindcss";\n');
146
+ const content = stepHtml.map(h => `--content ${JSON.stringify(h)}`).join(' ');
147
+ const bareCmd = `${twCli(4)} -i ${JSON.stringify(bareIn)} -o ${JSON.stringify(outCss)} ${content}`;
148
+ try { out += '\n--- retry (bare v4 entry) ---\n' + runCompile(bareCmd); ok = true; }
149
+ catch (e) { out += '\n--- retry (bare v4 entry) FAILED ---\n' + (e.stdout || '') + (e.stderr || '') + '\n' + String(e.message || e); }
150
+ }
151
+
152
+ if (!ok && twV === 4) {
153
+ // Last resort: even the bare `@import "tailwindcss"` needs the tailwindcss
154
+ // PACKAGE resolvable from the filesystem, and workspace monorepos (bun/pnpm)
155
+ // often have no usable node_modules at render time at all. Scratch-install
156
+ // tailwindcss@4 into a tmp cache (persists across runs on the same machine)
157
+ // and import it by absolute path — mirroring the isolated build detect-time
158
+ // compiles already use. Network-dependent like the npx CLI fetch above; in a
159
+ // fully offline env this fails harmlessly into the existing CDN fallback.
160
+ const scratch = path.join(os.tmpdir(), 'rtfm-jit-tw4');
161
+ const pkgCss = path.join(scratch, 'node_modules', 'tailwindcss', 'index.css');
162
+ try {
163
+ if (!fs.existsSync(pkgCss)) {
164
+ log('tailwindcss package unresolvable — scratch-installing tailwindcss@4 (cached in tmp)');
165
+ execFileSync('bash', ['-c',
166
+ `npm install --prefix ${JSON.stringify(scratch)} --no-save --no-audit --no-fund --ignore-scripts tailwindcss@4 2>&1`],
167
+ { timeout: 120000, encoding: 'utf8' });
168
+ }
169
+ if (fs.existsSync(pkgCss)) {
170
+ const absIn = path.join(outDir, '.jit_tw_in.css');
171
+ fs.writeFileSync(absIn, `@import ${JSON.stringify(pkgCss)};\n`);
172
+ const content = stepHtml.map(h => `--content ${JSON.stringify(h)}`).join(' ');
173
+ const absCmd = `${twCli(4)} -i ${JSON.stringify(absIn)} -o ${JSON.stringify(outCss)} ${content}`;
174
+ out += '\n--- retry (scratch-installed tailwindcss) ---\n' + runCompile(absCmd);
175
+ ok = true;
176
+ }
177
+ } catch (e) { out += '\n--- retry (scratch install) FAILED ---\n' + (e.stdout || '') + (e.stderr || '') + '\n' + String(e.message || e); }
178
+ }
179
+ try { fs.writeFileSync(buildLog, `CMD: ${cmd}\nCWD: ${cwd}\nok=${ok}\n--- output ---\n${out}\n`); } catch (_) {}
180
+ if (!ok) fail('local Tailwind compile failed — keeping CDN\n' + out.split('\n').slice(-8).join('\n'));
181
+
182
+ // Clean temp files
183
+ for (const t of ['.jit_tw.config.js', '.jit_tw_in.css']) {
184
+ try { fs.unlinkSync(path.join(outDir, t)); } catch (_) {}
185
+ }
186
+
187
+ let bytes = 0;
188
+ try { bytes = fs.statSync(outCss).size; } catch (_) {}
189
+ if (bytes < 2000) fail(`mockup.css too small (${bytes}b) — keeping CDN`);
190
+ log(`wrote ${path.basename(outCss)} (${bytes} bytes)`);
191
+ process.exit(0);
@@ -0,0 +1,87 @@
1
+ // Shared source grounding for literal labels and a deliberately small Rails subset.
2
+ // No application code is executed, and free-text derivations are never evidence.
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { resolveSourcePath } = require('./source_paths.js');
6
+ const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ const uncomment = s => s.split('\n').filter(line => !/^\s*#/.test(line)).join('\n');
8
+ // Project-relative, or ../<repo>/<path> into a related repository (source_paths.js).
9
+ function source(root, file) {
10
+ return fs.readFileSync(resolveSourcePath(root, file), 'utf8');
11
+ }
12
+ function filesBelow(root, dir) {
13
+ const at = path.join(root, dir);
14
+ if (!fs.existsSync(at)) return [];
15
+ return fs.readdirSync(at, { withFileTypes: true }).flatMap(item => {
16
+ if (item.isSymbolicLink()) throw Error('symlinked locale/config files need a runtime resolver');
17
+ const file = path.join(dir, item.name);
18
+ return item.isDirectory() ? filesBelow(root, file) : [file];
19
+ });
20
+ }
21
+ function railsSubmit(root, entry) {
22
+ const g = entry.generated;
23
+ if (g.kind !== 'rails_submit' || g.locale !== 'en' || !['create', 'update'].includes(g.action)) throw Error('supported generated evidence is rails_submit with locale en and action create/update');
24
+ if (!/^@[a-z][a-z0-9_]*$/.test(g.binding || '')) throw Error('cite the form model instance variable as binding');
25
+ const template = source(root, entry.found_in).replace(/<%#[\s\S]*?%>/g, '');
26
+ const forms = [...template.matchAll(/<%=\s*form_with\s*\(?([\s\S]*?)\s+do\s+\|(\w+)\|\s*%>/g)];
27
+ if (forms.length !== 1 || !new RegExp(`\\bmodel:\\s*${escape(g.binding)}(?=\\s*[,)]|\\s*$)`).test(forms[0][1]) || /\b(?:builder|scope):/.test(forms[0][1])) throw Error('cite a single form_with bound to this model, without a custom builder or scope');
28
+ const builder = escape(forms[0][2]);
29
+ const submits = [...template.matchAll(new RegExp(`<%=\\s*${builder}\\.submit\\b([\\s\\S]*?)%>`, 'g'))];
30
+ // Only no argument or a literal CSS class: reject explicit labels, value overrides and dynamic options.
31
+ if (submits.length !== 1 || !/^\s*(?:class:\s*(?:"[^"\n]*"|'[^'\n]*'))?\s*$/.test(submits[0][1]) || submits[0].index < forms[0].index) throw Error('cite a default submit helper with no explicit label or dynamic options');
32
+ const model = uncomment(source(root, g.model_source));
33
+ const declaration = model.match(/^\s*class\s+([A-Z]\w*)\s*<\s*(?:ApplicationRecord|ActiveRecord::Base)\s*$/m);
34
+ if (!declaration || /\b(?:def|delegate|define_method|include|extend|prepend)\b|\b(?:model_name|human_attribute_name|persisted\?|i18n_scope)\b/.test(model)) throw Error('custom model behavior needs a runtime resolver');
35
+ const name = declaration[1];
36
+ const bindings = uncomment(source(root, g.binding_source));
37
+ const assignments = [...bindings.matchAll(new RegExp(`^\\s*${escape(g.binding)}\\s*=(?!=)\\s*([^\\n]+)`, 'gm'))];
38
+ if (assignments.length !== 1) throw Error('cite an unambiguous model assignment proving create/update state');
39
+ const assignment = assignments[0][1].trim();
40
+ const expectedAssignment = g.action === 'create'
41
+ ? new RegExp(`^${escape(name)}\\.new(?:\\(.*\\))?\\s*$`)
42
+ : new RegExp(`^${escape(name)}\\.find\\([^\\n]+\\)\\s*$`);
43
+ if (!expectedAssignment.test(assignment)) throw Error('model assignment does not prove the requested create/update state');
44
+ // Refuse custom naming/translation paths rather than guessing Rails runtime behavior.
45
+ const configFiles = ['config/application.rb', ...filesBelow(root, 'config/initializers'), ...filesBelow(root, 'config/environments'), ...filesBelow(root, 'config/locales'), 'app/models/application_record.rb', 'app/controllers/application_controller.rb', 'app/helpers/application_helper.rb', g.binding_source];
46
+ let inflections = '';
47
+ for (const file of configFiles) {
48
+ if (!fs.existsSync(path.join(root, file))) continue;
49
+ const text = uncomment(source(root, file));
50
+ if (file === 'config/initializers/inflections.rb') { inflections = text; continue; }
51
+ if (/\b(?:helpers\s*[:.]|activerecord\s*:|activemodel\s*:|model_name|i18n_scope|persisted\?|default_form_builder|field_error_proc)|I18n\.|i18n\.(?:default_locale|load_path)|Inflector\.inflections|FormBuilder|def\s+(?:submit|model_name)/.test(text)) throw Error(`custom locale, model naming or form behavior in ${file} needs a runtime resolver; cite a literal translation when available`);
52
+ }
53
+ // Only default inflections plus literal English acronym declarations are supported.
54
+ const acronyms = new Map();
55
+ const remainder = inflections.replace(/ActiveSupport::Inflector\.inflections\(:en\)\s+do\s+\|(\w+)\|([\s\S]*?)\bend\b/g, (_, receiver, body) => {
56
+ const rest = body.replace(new RegExp(`${escape(receiver)}\\.acronym\\s+['"]([A-Za-z]+)['"]`, 'g'), (__, word) => { acronyms.set(word.toLowerCase(), word); return ''; });
57
+ return rest;
58
+ });
59
+ if (remainder.trim()) throw Error('custom inflections need a runtime resolver');
60
+ let underscored = name;
61
+ if (acronyms.size) {
62
+ const pattern = new RegExp(`(?:(?<=([A-Za-z\\d]))|\\b)(${[...acronyms.values()].map(escape).join('|')})(?=\\b|[^a-z])`, 'g');
63
+ underscored = underscored.replace(pattern, (_, prefix, acronym) => `${prefix ? '_' : ''}${acronym.toLowerCase()}`);
64
+ }
65
+ const words = underscored.replace(/([A-Z])(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/g, (_, upper, lower) => (upper || lower) + '_').toLowerCase().replace(/_id$/, '').split('_');
66
+ let human = words.map(word => acronyms.get(word) || word).join(' ');
67
+ human = human[0].toUpperCase() + human.slice(1);
68
+ const label = `${g.action === 'create' ? 'Create' : 'Update'} ${human}`;
69
+ return label;
70
+ }
71
+ function verifyLabelEvidence(entry, primary, root) {
72
+ const string = typeof entry === 'string' ? entry : entry && entry.string;
73
+ const file = entry && typeof entry === 'object' && entry.found_in || primary;
74
+ if (typeof string !== 'string' || !string.trim()) return { ok: false, error: 'missing evidence string' };
75
+ try {
76
+ if (entry && entry.generated) {
77
+ const expected = railsSubmit(root, entry);
78
+ return expected === string ? { ok: true, kind: 'generated' } : { ok: false, error: `generated label contradicts source: expected "${expected}", received "${string}"` };
79
+ }
80
+ if (!file) return { ok: true, kind: 'legacy' }; // preserve legacy source-less terminal evidence
81
+ if (source(root, file).includes(string)) return { ok: true, kind: 'literal' };
82
+ return { ok: false, error: `verbatim_evidence "${string}" claimed in ${file} but absent from that file; if generated, supply supported structured generated evidence (a derivation note alone is not verified)` };
83
+ } catch (error) {
84
+ return { ok: false, error: `cannot verify label "${string}": ${error.code ? 'source file unavailable' : error.message}` };
85
+ }
86
+ }
87
+ module.exports = { verifyLabelEvidence };
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { verifyLabelEvidence } = require('./label_evidence.js');
5
+ const { ARTICLE_TYPES, PRESENTATIONS, blocks, articleType } = require('./article_blocks');
6
+
7
+ function typeOf(value) {
8
+ if (Array.isArray(value)) return 'array';
9
+ if (value === null) return 'null';
10
+ return typeof value;
11
+ }
12
+
13
+ // ---- Markdown code scanner -------------------------------------------------
14
+ // Line-based and CommonMark-shaped: a line whose first non-blank run is three
15
+ // or more backticks or tildes opens a fence with an optional info string (a
16
+ // backtick fence takes no backtick in its info string, so "```x```" stays
17
+ // inline); the same marker character, at least as long, alone on a line closes
18
+ // it. `prose` is the text with fence bodies and inline spans removed, so the
19
+ // sentence cap and bold extraction never see code; `inline` holds the span
20
+ // texts and `fences` the blocks, each with its language tag and closed flag.
21
+ function scanCode(text) {
22
+ const prose = [], inline = [], fences = [];
23
+ let open = null;
24
+ for (const line of String(text || '').split('\n')) {
25
+ if (open) {
26
+ const close = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/);
27
+ if (close && close[1][0] === open.marker[0] && close[1].length >= open.marker.length) { open.closed = true; open = null; }
28
+ else open.body.push(line);
29
+ continue;
30
+ }
31
+ const start = line.match(/^[ \t]*(`{3,}|~{3,})(.*)$/);
32
+ if (start && !(start[1][0] === '`' && start[2].includes('`'))) {
33
+ open = { marker: start[1], lang: start[2].trim().split(/\s+/)[0] || '', body: [], closed: false };
34
+ fences.push(open);
35
+ continue;
36
+ }
37
+ prose.push(line.replace(/(`+)([^`]+?)\1/g, (_, __, span) => { inline.push(span.trim()); return ' '; }));
38
+ }
39
+ return { prose: prose.join('\n'), inline, fences: fences.map(f => ({ lang: f.lang, body: f.body.join('\n'), closed: f.closed })) };
40
+ }
41
+
42
+ function checkSchema(article) {
43
+ const errors = [];
44
+ const keys = Object.keys(article).sort();
45
+ const allowed = ['article_type', 'blocks', 'schema_version', 'title'];
46
+ if (article.schema_version !== 2) errors.push('"schema_version" must be 2');
47
+ if (typeof article.title !== 'string' || !article.title.trim()) errors.push('"title" must be a non-empty string');
48
+ if (!ARTICLE_TYPES.includes(article.article_type)) errors.push(`"article_type" must be one of ${ARTICLE_TYPES.join('|')}`);
49
+ if (!Array.isArray(article.blocks) || !article.blocks.length) errors.push('"blocks" must be a non-empty array');
50
+ for (const key of keys) if (!allowed.includes(key)) errors.push(`unknown top-level key "${key}" — schema v2 uses blocks only`);
51
+
52
+ const ids = new Set();
53
+ for (const [index, block] of (article.blocks || []).entries()) {
54
+ const at = `blocks[${index}]`;
55
+ if (!block || typeof block !== 'object' || Array.isArray(block)) { errors.push(`${at} must be an object`); continue; }
56
+ if (typeof block.id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(block.id)) errors.push(`${at}.id must be URL-safe kebab-case`);
57
+ else if (ids.has(block.id)) errors.push(`${at}.id "${block.id}" is duplicated`);
58
+ else ids.add(block.id);
59
+ if (!Object.hasOwn(PRESENTATIONS, block.type)) { errors.push(`${at}.type must be prose|section|list`); continue; }
60
+ if (!PRESENTATIONS[block.type].includes(block.presentation)) errors.push(`${at}.presentation is invalid for ${block.type}`);
61
+ const allowedKeys = {
62
+ prose: ['id', 'type', 'presentation', 'title', 'content'],
63
+ section: ['id', 'type', 'presentation', 'title', 'content', 'has_image'],
64
+ list: ['id', 'type', 'presentation', 'title', 'items'],
65
+ }[block.type];
66
+ for (const key of Object.keys(block)) if (!allowedKeys.includes(key)) errors.push(`${at} has unknown key "${key}"`);
67
+ if (block.type === 'prose' && typeof block.content !== 'string') errors.push(`${at}.content must be a string`);
68
+ if (block.type === 'section') {
69
+ if (typeof block.title !== 'string' || !block.title.trim()) errors.push(`${at}.title must be non-empty`);
70
+ if (typeof block.content !== 'string') errors.push(`${at}.content must be a string`);
71
+ if (typeof block.has_image !== 'boolean') errors.push(`${at}.has_image must be a boolean`);
72
+ }
73
+ if (block.type === 'list') {
74
+ if (typeof block.title !== 'string' || !block.title.trim()) errors.push(`${at}.title must be non-empty`);
75
+ if (!Array.isArray(block.items) || block.items.some(item => typeof item !== 'string')) errors.push(`${at}.items must be an array of strings`);
76
+ }
77
+ // Fence placement is part of the rendering contract: the app renders the
78
+ // lead and list items inline-only, so a fence there reaches readers as raw
79
+ // backticks, and an unclosed fence swallows the rest of the block.
80
+ if (block.type === 'prose' && block.presentation === 'lead' && typeof block.content === 'string') {
81
+ if (scanCode(block.content).fences.length) errors.push(`${at} (lead prose) must not contain a fenced code block — the lead renders inline-only; move the snippet to a section`);
82
+ } else if ((block.type === 'prose' || block.type === 'section') && typeof block.content === 'string') {
83
+ if (scanCode(block.content).fences.some(fence => !fence.closed)) errors.push(`${at} has an unclosed fenced code block`);
84
+ }
85
+ if (block.type === 'list' && Array.isArray(block.items)) {
86
+ block.items.forEach((item, position) => {
87
+ if (typeof item === 'string' && scanCode(item).fences.length) errors.push(`${at}.items[${position}] must not contain a fenced code block — list items render inline-only`);
88
+ });
89
+ }
90
+ }
91
+ return errors;
92
+ }
93
+
94
+ function loadFileOrEmpty(file) { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } }
95
+ function makeCorpus(text) {
96
+ const raw = text.toLowerCase();
97
+ return { raw, flat: text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').toLowerCase(), compact: raw.replace(/[-_]/g, '') };
98
+ }
99
+ function corpusHas(corpus, term) { const value = term.toLowerCase(); return corpus.raw.includes(value) || corpus.flat.includes(value.replace(/\s+/g, ' ')); }
100
+ // Flags and identifiers rarely appear in the sources spelled the way the user
101
+ // types them (a derive-based CLI declares `send_message` and exposes
102
+ // `--send-message`), so code tokens also match with dashes/underscores removed.
103
+ function corpusHasCode(corpus, token) { return corpusHas(corpus, token) || corpus.compact.includes(token.toLowerCase().replace(/^-+/, '').replace(/[-_]/g, '')); }
104
+ function extractBoldTerms(text) {
105
+ const terms = []; const re = /\*\*([^*\n]+?)\*\*/g; let match;
106
+ while ((match = re.exec(text || '')) !== null) {
107
+ const term = match[1].trim().replace(/^['"`“”‘’]+|['"`“”‘’]+$/g, '').replace(/[.,:;!?]+$/g, '');
108
+ if (term && term.length <= 80 && /[\p{L}\p{N}]/u.test(term)) terms.push(term);
109
+ }
110
+ return terms;
111
+ }
112
+ function segments(term) { const parts = term.split(/\s*[›»→]\s*|\s+>\s+/).filter(Boolean); return parts.length > 1 ? parts : [term]; }
113
+
114
+ const FLAG_RE = /(?<![\w-])--?[a-z][\w-]*/gi;
115
+ // The code tokens worth checking against the sources: single-token spans that
116
+ // look like a flag, path or identifier (plain words such as `clear` are too
117
+ // generic to ground), plus every flag inside multi-token spans and fence bodies.
118
+ function codeTokens(scan) {
119
+ const tokens = new Set();
120
+ for (const span of scan.inline) {
121
+ if (!span || /^<[^>]+>$/.test(span) || /^["'].*["']$/.test(span) || /…|\.\.\./.test(span)) continue;
122
+ if (!/\s/.test(span)) { if (/[a-z]/i.test(span) && /^-|[/._-]|^~/.test(span)) tokens.add(span); continue; }
123
+ for (const flag of span.match(FLAG_RE) || []) tokens.add(flag);
124
+ }
125
+ for (const fence of scan.fences) for (const flag of fence.body.match(FLAG_RE) || []) tokens.add(flag);
126
+ return [...tokens];
127
+ }
128
+ // A bold span that is really a command, flag or path — the observed failure on
129
+ // CLI projects before inline code existed in the prose rules.
130
+ function looksLikeCode(term, binary) {
131
+ if (/^-{1,2}[a-z]/i.test(term)) return 'flag';
132
+ if (/^(~|\.{1,2})?\/[\w.-]/.test(term) || /^[\w-]+(\/[\w.-]+)+\.\w{1,5}$/.test(term)) return 'path';
133
+ if (/\s--?[a-z][\w-]*/i.test(term)) return 'command';
134
+ if (binary) {
135
+ const lower = term.toLowerCase(), name = binary.toLowerCase();
136
+ if (lower === name || lower.startsWith(name + ' ')) return 'command';
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function buildCorpora(viewSources, projectDir) {
142
+ const perId = new Map(); let union = '';
143
+ for (const entry of (viewSources && (viewSources.blocks || viewSources.steps)) || []) {
144
+ const files = [entry.primary_view, entry.layout, ...(entry.partials_expanded || [])].filter(Boolean);
145
+ let text = files.map(file => loadFileOrEmpty(path.join(projectDir, file))).join('\n');
146
+ text += '\n' + (entry.verbatim_evidence || []).filter(item => verifyLabelEvidence(item, entry.primary_view, projectDir).ok).map(item => typeof item === 'object' ? item.string : item).join('\n');
147
+ const id = entry.block_id || (typeof entry.index === 'number' ? `step-${entry.index + 1}` : null);
148
+ if (id) perId.set(id, makeCorpus(text));
149
+ union += text;
150
+ }
151
+ return { perId, union: makeCorpus(union) };
152
+ }
153
+
154
+ // A terminal mockup that shows only the typed prompt line duplicates the
155
+ // article's fenced block without being copyable; the slot belongs to output the
156
+ // reader must read, or to a TUI state.
157
+ function redundantCommandMockup(block, outputDir) {
158
+ const html = loadFileOrEmpty(path.join(outputDir, `block_${block.id}.html`));
159
+ if (!html || !/\bterminal-cmd\b/.test(html) || /\btui-screen\b/.test(html)) return false;
160
+ const outputs = [...html.matchAll(/class="[^"]*\bterminal-output\b[^"]*"[^>]*>([\s\S]*?)<\/span>/g)]
161
+ .map(match => match[1].replace(/<[^>]*>/g, '').trim()).filter(Boolean);
162
+ return outputs.length === 0;
163
+ }
164
+
165
+ function checkFidelity(article, corpora, options) {
166
+ const { warnMode, outputDir, appType, binary } = options;
167
+ const hasSources = corpora.union.raw.trim().length > 0;
168
+ const reports = [], metrics = { inline_code_spans: 0, fenced_blocks: 0, untagged_fences: 0 };
169
+ for (const block of blocks(article)) {
170
+ const errors = [], warnings = [];
171
+ const content = typeof block.content === 'string' ? block.content : '';
172
+ const scan = scanCode(content);
173
+ metrics.inline_code_spans += scan.inline.length;
174
+ metrics.fenced_blocks += scan.fences.length;
175
+ if (block.type === 'prose' || block.type === 'section') {
176
+ for (const fence of scan.fences) if (!fence.lang) { metrics.untagged_fences += 1; warnings.push(`block "${block.id}" has a fenced code block without a language tag (bash, json, toml, text)`); }
177
+ }
178
+ if (block.type !== 'section') { reports.push({ block_id: block.id, errors, warnings }); continue; }
179
+ if (/^\s*(`{3,}|~{3,})/.test(content)) warnings.push(`section "${block.id}" starts with a code block — lead with the instruction sentence`);
180
+ if (/\*\*\s*`[^`\n]+`\s*\*\*/.test(content)) warnings.push(`section "${block.id}" bolds inline code — drop the bold, inline code stands on its own`);
181
+ const own = corpora.perId.get(block.id);
182
+ for (const term of extractBoldTerms(scan.prose)) {
183
+ const kind = looksLikeCode(term, binary);
184
+ if (kind) { warnings.push(`bold "${term}" looks like a ${kind} — bold is for on-screen labels; use inline code`); continue; }
185
+ const parts = segments(term);
186
+ if (own && parts.every(part => corpusHas(own, part))) continue;
187
+ if (parts.every(part => corpusHas(corpora.union, part))) { warnings.push(`bold UI name "${term}" is not in this block's own sources`); continue; }
188
+ const message = `bold UI name "${term}" appears in none of the resolved source files`;
189
+ if (block.has_image && own && !warnMode) errors.push(message); else warnings.push(message);
190
+ }
191
+ if (hasSources) {
192
+ for (const token of codeTokens(scan)) {
193
+ if (own && corpusHasCode(own, token)) continue;
194
+ if (corpusHasCode(corpora.union, token)) { warnings.push(`inline code "${token}" is not in this block's own sources`); continue; }
195
+ warnings.push(`inline code "${token}" appears in none of the resolved source files — invented flag or path?`);
196
+ }
197
+ }
198
+ if (appType === 'terminal' && block.has_image && outputDir && redundantCommandMockup(block, outputDir)) {
199
+ warnings.push(`section "${block.id}" spends a screenshot on a typed command with no output — the fenced block already carries it; set has_image:false or show the output the reader must read`);
200
+ }
201
+ reports.push({ block_id: block.id, errors, warnings });
202
+ }
203
+ return { reports, metrics };
204
+ }
205
+
206
+ function sentenceCount(text) { const prose = scanCode(text).prose.replace(/\*\*/g, ''); const found = prose.match(/[.!?]+(?=\s|$)/g); return found ? found.length : (prose.trim() ? 1 : 0); }
207
+ function styleWarnings(article) {
208
+ const warnings = [], type = articleType(article);
209
+ if (type === 'how-to' && !/^how\s+to\b/i.test(article.title || '')) warnings.push('how-to title should start with "How to"');
210
+ if (type === 'faq' && !(article.title || '').trim().endsWith('?')) warnings.push('faq title should end with "?"');
211
+ for (const block of blocks(article)) {
212
+ const cap = type === 'concept' ? 4 : 3;
213
+ if (block.type === 'section' && sentenceCount(block.content) > cap) warnings.push(`block "${block.id}" exceeds the ${cap}-sentence guidance`);
214
+ if (block.type === 'prose' && block.presentation === 'lead' && sentenceCount(block.content) > 2) warnings.push('lead prose should be 1-2 sentences');
215
+ }
216
+ return warnings;
217
+ }
218
+
219
+ function main() {
220
+ const outputDir = process.argv[2], projectDir = process.argv[3] || '.';
221
+ if (!outputDir) process.exit(2);
222
+ let article;
223
+ try { article = JSON.parse(fs.readFileSync(path.join(outputDir, 'article.json'), 'utf8')); }
224
+ catch (error) { console.error(`cannot read article.json: ${error.message}`); process.exit(2); }
225
+ let viewSources = null;
226
+ try { viewSources = JSON.parse(fs.readFileSync(path.join(outputDir, 'view_sources.json'), 'utf8')); } catch {}
227
+ let projectMap = {};
228
+ try { projectMap = JSON.parse(fs.readFileSync(path.join(projectDir, '.rtfm', 'project_map.json'), 'utf8')) || {}; } catch {}
229
+ const appType = projectMap.app_type || (viewSources && viewSources.framework === 'cli' ? 'terminal' : null);
230
+ const binary = projectMap.cli_metadata && typeof projectMap.cli_metadata.binary === 'string' ? projectMap.cli_metadata.binary : null;
231
+ const warnMode = (process.env.RTFM_LINT_MODE || '').toLowerCase() === 'warn';
232
+ const fidelity = checkFidelity(article, buildCorpora(viewSources, projectDir), { warnMode, outputDir, appType, binary });
233
+ const report = {
234
+ all_passed: true,
235
+ mode: warnMode ? 'warn' : 'fail',
236
+ global_errors: checkSchema(article),
237
+ global_warnings: styleWarnings(article),
238
+ blocks: fidelity.reports,
239
+ metrics: fidelity.metrics,
240
+ };
241
+ report.all_passed = !report.global_errors.length && !report.blocks.some(block => block.errors.length);
242
+ fs.writeFileSync(path.join(outputDir, 'article_lint_report.json'), JSON.stringify(report, null, 2));
243
+ for (const error of report.global_errors) console.log(`[FAIL] (schema) ERROR: ${error}`);
244
+ for (const warning of report.global_warnings) console.log(`[WARN] (article) WARN: ${warning}`);
245
+ for (const block of report.blocks) {
246
+ for (const error of block.errors) console.log(`[FAIL] block ${block.block_id}: ${error}`);
247
+ for (const warning of block.warnings) console.log(`[WARN] block ${block.block_id}: ${warning}`);
248
+ }
249
+ if (!report.all_passed) process.exit(1);
250
+ console.log('article.json passed the copy lint.');
251
+ }
252
+ main();