@invisibleloop/pulse 0.20.10 → 0.20.19

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 (266) hide show
  1. package/package.json +6 -5
  2. package/public/.pulse-ui-version +1 -1
  3. package/scripts/build.js +26 -0
  4. package/scripts/release-version.js +17 -8
  5. package/scripts/strip-server.test.js +60 -0
  6. package/src/agent/checklist.md +8 -12
  7. package/src/agent/commands/pulse-start.md +2 -0
  8. package/src/agent/commands/verify.md +30 -8
  9. package/src/agent/guide-components.md +7 -25
  10. package/src/agent/guide-routing.md +0 -12
  11. package/src/agent/guide-server.md +102 -1
  12. package/src/agent/guide-spec.md +8 -1
  13. package/src/agent/guide-styles.md +23 -0
  14. package/src/agent/identity.md +9 -0
  15. package/src/agent/workflow.md +4 -23
  16. package/src/cli/check-bundles.js +113 -0
  17. package/src/cli/check-bundles.test.js +137 -0
  18. package/src/cli/cli.test.js +105 -0
  19. package/src/cli/dev.js +48 -3
  20. package/src/cli/diagnose.js +105 -0
  21. package/src/cli/diagnose.test.js +124 -0
  22. package/src/cli/discover.js +9 -2
  23. package/src/cli/index.js +171 -9
  24. package/src/cli/process-utils.js +57 -0
  25. package/src/cli/process-utils.test.js +114 -0
  26. package/src/cli/review.js +391 -0
  27. package/src/cli/review.test.js +143 -0
  28. package/src/cli/scaffold.js +9 -4
  29. package/src/cli/stamp.js +102 -0
  30. package/src/cli/stamp.test.js +149 -0
  31. package/src/cli/start.js +15 -2
  32. package/src/cli/validate.js +184 -0
  33. package/src/cli/validate.test.js +153 -0
  34. package/src/mcp/mcp.test.js +261 -30
  35. package/src/mcp/server.js +207 -515
  36. package/src/mcp/validate-worker.js +18 -1
  37. package/src/runtime/index.js +144 -11
  38. package/src/runtime/runtime.test.js +199 -0
  39. package/src/runtime/ssr.js +14 -7
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +163 -10
  42. package/src/server/server.test.js +225 -2
  43. package/src/spec/schema.js +1 -1
  44. package/src/ui/index.js +0 -5
  45. package/src/ui/ui.test.js +0 -75
  46. package/types/ui.d.ts +0 -21
  47. package/.claude/commands/build-page.md +0 -63
  48. package/.claude/commands/new-doc-page.md +0 -45
  49. package/.claude/commands/pulse-start.md +0 -11
  50. package/.claude/commands/pulse-stop.md +0 -5
  51. package/.claude/commands/verify.md +0 -118
  52. package/.claude/pulse-checklist.md +0 -189
  53. package/.github/workflows/ci.yml +0 -25
  54. package/.github/workflows/publish.yml +0 -45
  55. package/.pulse/load-reports/home/1773432711417.json +0 -22
  56. package/.pulse-verified +0 -1
  57. package/AGENTS.md +0 -697
  58. package/CLAUDE.md +0 -697
  59. package/README.md +0 -95
  60. package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
  61. package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
  62. package/benchmark/public/dist/manifest.json +0 -5
  63. package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
  64. package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
  65. package/benchmark/results/2026-03-27-09-20.json +0 -188
  66. package/benchmark/results/2026-03-27-09-21.json +0 -188
  67. package/benchmark/results/2026-03-27-09-29.json +0 -188
  68. package/benchmark/results/2026-03-27-10-14.json +0 -188
  69. package/benchmark/results/2026-03-27-10-18.json +0 -232
  70. package/benchmark/src/pages/counter.js +0 -37
  71. package/benchmark/src/pages/home.js +0 -21
  72. package/docs/.claude/commands/pulse-build.md +0 -7
  73. package/docs/.claude/commands/pulse-dev.md +0 -7
  74. package/docs/.claude/commands/pulse-start.md +0 -9
  75. package/docs/.claude/commands/pulse-stop.md +0 -5
  76. package/docs/.claude/commands/verify.md +0 -84
  77. package/docs/.claude/coverage-check.js +0 -196
  78. package/docs/.claude/pulse-checklist.md +0 -189
  79. package/docs/public/.pulse-ui-version +0 -1
  80. package/docs/public/andypixel.css +0 -237
  81. package/docs/public/bookworms.css +0 -422
  82. package/docs/public/coutumes.css +0 -561
  83. package/docs/public/docs.css +0 -2388
  84. package/docs/public/favicon.svg +0 -4
  85. package/docs/public/home-brut.css +0 -982
  86. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  87. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  88. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  89. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  90. package/docs/public/images/journey/178.jpg +0 -0
  91. package/docs/public/images/journey/29.jpg +0 -0
  92. package/docs/public/images/retrowheels/hero.jpg +0 -0
  93. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  94. package/docs/public/images/suzukibento/1015.jpg +0 -0
  95. package/docs/public/images/suzukibento/1025.jpg +0 -0
  96. package/docs/public/images/suzukibento/1043.jpg +0 -0
  97. package/docs/public/images/suzukibento/1059.jpg +0 -0
  98. package/docs/public/images/suzukibento/1062.jpg +0 -0
  99. package/docs/public/images/suzukibento/1084.jpg +0 -0
  100. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  101. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  102. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  103. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  104. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  105. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  106. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  107. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  108. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  109. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  110. package/docs/public/img/heic1501a.jpg +0 -0
  111. package/docs/public/img/placeholder-landscape.svg +0 -18
  112. package/docs/public/img/placeholder-square.svg +0 -14
  113. package/docs/public/img/placeholder-tall.svg +0 -22
  114. package/docs/public/journey.css +0 -528
  115. package/docs/public/menu.js +0 -327
  116. package/docs/public/pulse-ui.css +0 -3028
  117. package/docs/public/pulse-ui.js +0 -257
  118. package/docs/public/retrowheels.css +0 -425
  119. package/docs/public/shop.css +0 -269
  120. package/docs/public/sparkys.css +0 -382
  121. package/docs/public/suzukibento.css +0 -517
  122. package/docs/public/swish.css +0 -394
  123. package/docs/public/theme.css +0 -52
  124. package/docs/public/themes/lumio.css +0 -50
  125. package/docs/public/themes/observatory.css +0 -104
  126. package/docs/server.js +0 -161
  127. package/docs/src/lib/component-page.js +0 -49
  128. package/docs/src/lib/highlight.js +0 -2
  129. package/docs/src/lib/layout.js +0 -190
  130. package/docs/src/lib/metrics-store.js +0 -6
  131. package/docs/src/lib/nav.js +0 -171
  132. package/docs/src/lib/stats.js +0 -81
  133. package/docs/src/pages/accessibility.js +0 -157
  134. package/docs/src/pages/actions.js +0 -194
  135. package/docs/src/pages/auth.js +0 -176
  136. package/docs/src/pages/caching.js +0 -95
  137. package/docs/src/pages/components/accordion.js +0 -48
  138. package/docs/src/pages/components/alert.js +0 -35
  139. package/docs/src/pages/components/app-badge.js +0 -41
  140. package/docs/src/pages/components/avatar.js +0 -35
  141. package/docs/src/pages/components/badge.js +0 -36
  142. package/docs/src/pages/components/breadcrumbs.js +0 -94
  143. package/docs/src/pages/components/button.js +0 -84
  144. package/docs/src/pages/components/card.js +0 -225
  145. package/docs/src/pages/components/charts.js +0 -283
  146. package/docs/src/pages/components/checkbox.js +0 -129
  147. package/docs/src/pages/components/cluster.js +0 -47
  148. package/docs/src/pages/components/container.js +0 -40
  149. package/docs/src/pages/components/cta.js +0 -54
  150. package/docs/src/pages/components/divider.js +0 -37
  151. package/docs/src/pages/components/empty.js +0 -36
  152. package/docs/src/pages/components/feature.js +0 -60
  153. package/docs/src/pages/components/fieldset.js +0 -65
  154. package/docs/src/pages/components/fileupload.js +0 -128
  155. package/docs/src/pages/components/footer.js +0 -58
  156. package/docs/src/pages/components/grid.js +0 -169
  157. package/docs/src/pages/components/heading.js +0 -107
  158. package/docs/src/pages/components/hero.js +0 -123
  159. package/docs/src/pages/components/icons.js +0 -410
  160. package/docs/src/pages/components/image.js +0 -71
  161. package/docs/src/pages/components/input.js +0 -51
  162. package/docs/src/pages/components/media.js +0 -51
  163. package/docs/src/pages/components/modal.js +0 -111
  164. package/docs/src/pages/components/nav.js +0 -136
  165. package/docs/src/pages/components/phone-frame.js +0 -74
  166. package/docs/src/pages/components/progress.js +0 -102
  167. package/docs/src/pages/components/prose.js +0 -111
  168. package/docs/src/pages/components/pullquote.js +0 -71
  169. package/docs/src/pages/components/radio.js +0 -194
  170. package/docs/src/pages/components/rating.js +0 -106
  171. package/docs/src/pages/components/search.js +0 -61
  172. package/docs/src/pages/components/section.js +0 -98
  173. package/docs/src/pages/components/segmented.js +0 -121
  174. package/docs/src/pages/components/select.js +0 -45
  175. package/docs/src/pages/components/slider.js +0 -115
  176. package/docs/src/pages/components/spinner.js +0 -73
  177. package/docs/src/pages/components/stack.js +0 -48
  178. package/docs/src/pages/components/stat.js +0 -55
  179. package/docs/src/pages/components/stepper.js +0 -66
  180. package/docs/src/pages/components/table.js +0 -45
  181. package/docs/src/pages/components/textarea.js +0 -31
  182. package/docs/src/pages/components/toggle.js +0 -84
  183. package/docs/src/pages/components/tooltip.js +0 -48
  184. package/docs/src/pages/components.js +0 -200
  185. package/docs/src/pages/config.js +0 -193
  186. package/docs/src/pages/constraints.js +0 -100
  187. package/docs/src/pages/deploy.js +0 -233
  188. package/docs/src/pages/error-pages.js +0 -99
  189. package/docs/src/pages/extending.js +0 -198
  190. package/docs/src/pages/faq.js +0 -99
  191. package/docs/src/pages/forms.js +0 -129
  192. package/docs/src/pages/getting-started.js +0 -147
  193. package/docs/src/pages/guard.js +0 -120
  194. package/docs/src/pages/home.js +0 -345
  195. package/docs/src/pages/how-it-works.js +0 -244
  196. package/docs/src/pages/hydration.js +0 -108
  197. package/docs/src/pages/images.js +0 -120
  198. package/docs/src/pages/markdown.js +0 -156
  199. package/docs/src/pages/meta.js +0 -193
  200. package/docs/src/pages/mutations.js +0 -107
  201. package/docs/src/pages/navigation.js +0 -85
  202. package/docs/src/pages/performance.js +0 -157
  203. package/docs/src/pages/persist.js +0 -89
  204. package/docs/src/pages/project-structure.js +0 -91
  205. package/docs/src/pages/prompt-examples.js +0 -186
  206. package/docs/src/pages/raw-responses.js +0 -122
  207. package/docs/src/pages/routing.js +0 -120
  208. package/docs/src/pages/server-api.js +0 -291
  209. package/docs/src/pages/server-data.js +0 -203
  210. package/docs/src/pages/slash-commands.js +0 -55
  211. package/docs/src/pages/spec.js +0 -207
  212. package/docs/src/pages/state.js +0 -102
  213. package/docs/src/pages/store.js +0 -203
  214. package/docs/src/pages/streaming.js +0 -107
  215. package/docs/src/pages/stripe.js +0 -194
  216. package/docs/src/pages/supabase.js +0 -324
  217. package/docs/src/pages/templates/andypixel.js +0 -260
  218. package/docs/src/pages/templates/blog-post.js +0 -226
  219. package/docs/src/pages/templates/bookworms.js +0 -227
  220. package/docs/src/pages/templates/coutumes.js +0 -237
  221. package/docs/src/pages/templates/journey.js +0 -186
  222. package/docs/src/pages/templates/mobile-app.js +0 -371
  223. package/docs/src/pages/templates/retrowheels.js +0 -355
  224. package/docs/src/pages/templates/shop.js +0 -268
  225. package/docs/src/pages/templates/sparkys.js +0 -264
  226. package/docs/src/pages/templates/suzukibento.js +0 -200
  227. package/docs/src/pages/templates/swish.js +0 -313
  228. package/docs/src/pages/templates.js +0 -534
  229. package/docs/src/pages/testing.js +0 -199
  230. package/docs/src/pages/validation.js +0 -138
  231. package/examples/.claude/commands/pulse-build.md +0 -7
  232. package/examples/.claude/commands/pulse-dev.md +0 -7
  233. package/examples/.claude/commands/pulse-start.md +0 -9
  234. package/examples/.claude/commands/pulse-stop.md +0 -5
  235. package/examples/.claude/commands/verify.md +0 -84
  236. package/examples/.claude/coverage-check.js +0 -196
  237. package/examples/.claude/pulse-checklist.md +0 -189
  238. package/examples/andypixel.css +0 -237
  239. package/examples/andypixel.js +0 -258
  240. package/examples/bookworms.css +0 -422
  241. package/examples/bookworms.js +0 -225
  242. package/examples/contact.js +0 -166
  243. package/examples/counter.js +0 -94
  244. package/examples/dev.server.js +0 -110
  245. package/examples/examples.test.js +0 -394
  246. package/examples/journey.css +0 -528
  247. package/examples/journey.js +0 -186
  248. package/examples/pricing.js +0 -262
  249. package/examples/products.js +0 -190
  250. package/examples/public/.pulse-ui-version +0 -1
  251. package/examples/public/pulse-ui.css +0 -3028
  252. package/examples/public/pulse-ui.js +0 -257
  253. package/examples/quiz.js +0 -208
  254. package/examples/retrowheels.css +0 -425
  255. package/examples/retrowheels.js +0 -353
  256. package/examples/shared.js +0 -84
  257. package/examples/suzukibento.css +0 -513
  258. package/examples/suzukibento.js +0 -200
  259. package/examples/swish.css +0 -394
  260. package/examples/swish.js +0 -311
  261. package/examples/todos.js +0 -162
  262. package/src/ui/fieldset.js +0 -37
  263. package/src/ui/phone-frame.js +0 -40
  264. package/src/ui/search.js +0 -78
  265. package/src/ui/segmented.js +0 -50
  266. package/src/ui/tooltip.js +0 -28
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Pulse — verification stamp (shared logic)
3
+ *
4
+ * Writes .pulse-verified to clear the stop-hook verification gate. Used by
5
+ * both pulse_stamp (MCP tool, for an agent) and `pulse stamp` (CLI command,
6
+ * for a human). Neither wraps the other — both call writeStamp().
7
+ *
8
+ * In full mode (route + mode: 'full'), refuses to stamp unless a Lighthouse
9
+ * report was actually saved via `pulse save-report` in the last 10 minutes
10
+ * — a real Lighthouse run that passed the score bar but was never saved
11
+ * leaves nothing for `pulse report-server` or a dashboard to show. This is
12
+ * a mechanized version of a check that used to be prose-only in verify.md
13
+ * and was found (via a real user report) to be silently skipped: /verify
14
+ * completed and reported scores in the conversation, but nothing ever
15
+ * landed in .pulse/reports/.
16
+ */
17
+
18
+ import fs from 'fs'
19
+ import path from 'path'
20
+ import { urlToSlug } from './report.js'
21
+ import { resolveEntries } from './diagnose.js'
22
+
23
+ const STAMP_REPORT_WINDOW_MS = 10 * 60 * 1000 // a report older than this wasn't from this /verify run
24
+
25
+ /**
26
+ * @param {string} root
27
+ * @param {{ route?: string, mode?: 'full'|'quick' }} [opts]
28
+ * @returns {{ stamped: boolean, refused: boolean, reason: string|null, resolvedCount: number, ts: string|null }}
29
+ * stamped: whether .pulse-verified was actually written
30
+ * refused: true if this was a policy refusal (missing report) — false if a real write error
31
+ * reason: why it didn't stamp, when stamped is false (null otherwise)
32
+ * resolvedCount: error-journal entries auto-resolved for `route` (0 if no route passed)
33
+ * ts: the stamp's unix-seconds timestamp, when stamped is true (null otherwise)
34
+ */
35
+ export function writeStamp(root, { route, mode } = {}) {
36
+ if (route && mode === 'full') {
37
+ const slug = urlToSlug(route)
38
+ const reportDir = path.join(root, '.pulse', 'reports', slug)
39
+ let recentReport = false
40
+ try {
41
+ const files = fs.readdirSync(reportDir).filter(f => f.endsWith('.json'))
42
+ recentReport = files.some(f => {
43
+ const stat = fs.statSync(path.join(reportDir, f))
44
+ return Date.now() - stat.mtimeMs < STAMP_REPORT_WINDOW_MS
45
+ })
46
+ } catch { /* reportDir doesn't exist — recentReport stays false */ }
47
+
48
+ if (!recentReport) {
49
+ return {
50
+ stamped: false,
51
+ refused: true,
52
+ reason: `No Lighthouse report saved for "${route}" in the last 10 minutes. Full /verify requires ` +
53
+ `pulse save-report --url "http://localhost:3001${route}" --data '{"scores":{...},"metrics":{...}}' ` +
54
+ `(run from the project root) for BOTH the desktop and mobile Lighthouse runs before stamping.`,
55
+ resolvedCount: 0,
56
+ ts: null,
57
+ }
58
+ }
59
+ }
60
+
61
+ const stampPath = path.join(root, '.pulse-verified')
62
+ const ts = String(Math.floor(Date.now() / 1000))
63
+ try {
64
+ fs.writeFileSync(stampPath, ts, 'utf8')
65
+ } catch (err) {
66
+ return {
67
+ stamped: false,
68
+ refused: false,
69
+ reason: `Error writing .pulse-verified: ${err.message}. Fallback: run \`date +%s > .pulse-verified\` in Bash.`,
70
+ resolvedCount: 0,
71
+ ts: null,
72
+ }
73
+ }
74
+
75
+ let resolvedCount = 0
76
+ if (route) {
77
+ const { count } = resolveEntries(root, { route })
78
+ resolvedCount = count
79
+ }
80
+
81
+ return { stamped: true, refused: false, reason: null, resolvedCount, ts }
82
+ }
83
+
84
+ /**
85
+ * Format a writeStamp() result as human-readable text.
86
+ * @param {ReturnType<typeof writeStamp>} result
87
+ * @param {{ agentFacing?: boolean }} [opts]
88
+ */
89
+ export function formatStampResult(result, { agentFacing = true } = {}) {
90
+ if (!result.stamped) {
91
+ const prefix = result.refused ? 'Refusing to stamp' : 'Failed to stamp'
92
+ return agentFacing && result.refused
93
+ ? `${prefix}: ${result.reason}\n\nRun it now with the real numbers from your Lighthouse results, then call pulse_stamp again.`
94
+ : `${prefix}: ${result.reason}`
95
+ }
96
+ const resolvedNote = result.resolvedCount > 0
97
+ ? ` ${result.resolvedCount} error-journal entr${result.resolvedCount === 1 ? 'y' : 'ies'} marked resolved.`
98
+ : ''
99
+ return agentFacing
100
+ ? `✓ .pulse-verified written (${result.ts}). Stop hook cleared — session can end.${resolvedNote}`
101
+ : `✓ .pulse-verified written (${result.ts}).${resolvedNote}`
102
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Pulse — stamp.js tests
3
+ * run: node src/cli/stamp.test.js
4
+ *
5
+ * Shared logic behind pulse_stamp (MCP tool) and `pulse stamp` (CLI
6
+ * command). Regression coverage: a real Lighthouse run that passed the
7
+ * score bar in a /verify session was found to be silently never saved via
8
+ * `pulse save-report` — prose-only documentation for that step wasn't
9
+ * enough, so full-mode pulse_stamp now refuses to write the stamp unless
10
+ * it finds real evidence a report was saved.
11
+ */
12
+
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import { writeStamp, formatStampResult } from './stamp.js'
19
+
20
+ function tmpProject() {
21
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-stamp-test-'))
22
+ }
23
+
24
+ function writeReport(root, slug, { ageMs = 0 } = {}) {
25
+ const dir = path.join(root, '.pulse', 'reports', slug)
26
+ fs.mkdirSync(dir, { recursive: true })
27
+ const file = path.join(dir, `${Date.now()}.json`)
28
+ fs.writeFileSync(file, JSON.stringify({ timestamp: new Date().toISOString(), scores: {} }))
29
+ if (ageMs > 0) {
30
+ const past = new Date(Date.now() - ageMs)
31
+ fs.utimesSync(file, past, past)
32
+ }
33
+ return file
34
+ }
35
+
36
+ // ── Quick mode / no route ───────────────────────────────────────────────
37
+
38
+ test('writeStamp with no route stamps unconditionally', () => {
39
+ const root = tmpProject()
40
+ const result = writeStamp(root)
41
+ assert.equal(result.stamped, true)
42
+ assert.equal(fs.existsSync(path.join(root, '.pulse-verified')), true)
43
+ fs.rmSync(root, { recursive: true, force: true })
44
+ })
45
+
46
+ test('writeStamp in quick mode stamps even with no saved report', () => {
47
+ const root = tmpProject()
48
+ const result = writeStamp(root, { route: '/dashboard', mode: 'quick' })
49
+ assert.equal(result.stamped, true)
50
+ fs.rmSync(root, { recursive: true, force: true })
51
+ })
52
+
53
+ // ── Full mode — the actual regression this fixes ────────────────────────
54
+
55
+ test('writeStamp in full mode refuses when no report was ever saved for the route', () => {
56
+ const root = tmpProject()
57
+ const result = writeStamp(root, { route: '/dashboard', mode: 'full' })
58
+ assert.equal(result.stamped, false)
59
+ assert.equal(result.refused, true)
60
+ assert.match(result.reason, /No Lighthouse report saved/)
61
+ assert.equal(fs.existsSync(path.join(root, '.pulse-verified')), false, 'must not write the stamp file at all')
62
+ fs.rmSync(root, { recursive: true, force: true })
63
+ })
64
+
65
+ test('writeStamp in full mode refuses when the only report is older than the window', () => {
66
+ const root = tmpProject()
67
+ writeReport(root, 'dashboard', { ageMs: 20 * 60 * 1000 }) // 20 min old
68
+ const result = writeStamp(root, { route: '/dashboard', mode: 'full' })
69
+ assert.equal(result.stamped, false)
70
+ assert.equal(result.refused, true)
71
+ fs.rmSync(root, { recursive: true, force: true })
72
+ })
73
+
74
+ test('writeStamp in full mode stamps when a recent report exists for the route', () => {
75
+ const root = tmpProject()
76
+ writeReport(root, 'dashboard')
77
+ const result = writeStamp(root, { route: '/dashboard', mode: 'full' })
78
+ assert.equal(result.stamped, true)
79
+ assert.equal(result.refused, false)
80
+ assert.equal(fs.existsSync(path.join(root, '.pulse-verified')), true)
81
+ fs.rmSync(root, { recursive: true, force: true })
82
+ })
83
+
84
+ test('writeStamp full mode checks the same slug pulse save-report would use for this route', () => {
85
+ const root = tmpProject()
86
+ // Root route '/' slugs to 'home' — same as report.js's urlToSlug.
87
+ writeReport(root, 'home')
88
+ const result = writeStamp(root, { route: '/', mode: 'full' })
89
+ assert.equal(result.stamped, true)
90
+ fs.rmSync(root, { recursive: true, force: true })
91
+ })
92
+
93
+ test('a report saved for a different route does not satisfy the check', () => {
94
+ const root = tmpProject()
95
+ writeReport(root, 'about') // saved for /about, not /dashboard
96
+ const result = writeStamp(root, { route: '/dashboard', mode: 'full' })
97
+ assert.equal(result.stamped, false)
98
+ fs.rmSync(root, { recursive: true, force: true })
99
+ })
100
+
101
+ // ── Error-journal auto-resolve ───────────────────────────────────────────
102
+
103
+ test('writeStamp with a route marks unresolved error-journal entries for that route resolved', () => {
104
+ const root = tmpProject()
105
+ fs.mkdirSync(path.join(root, '.pulse'), { recursive: true })
106
+ fs.writeFileSync(path.join(root, '.pulse', 'errors.json'), JSON.stringify([
107
+ { id: '1', ts: '2026-01-01T00:00:00.000Z', route: '/dashboard', phase: 'view', message: 'x', stack: '', resolved: false },
108
+ { id: '2', ts: '2026-01-01T00:00:00.000Z', route: '/other', phase: 'view', message: 'y', stack: '', resolved: false },
109
+ ]))
110
+
111
+ const result = writeStamp(root, { route: '/dashboard' })
112
+ assert.equal(result.stamped, true)
113
+ assert.equal(result.resolvedCount, 1)
114
+
115
+ const entries = JSON.parse(fs.readFileSync(path.join(root, '.pulse', 'errors.json'), 'utf8'))
116
+ assert.equal(entries.find(e => e.id === '1').resolved, true)
117
+ assert.equal(entries.find(e => e.id === '2').resolved, false, 'must not resolve entries for a different route')
118
+ fs.rmSync(root, { recursive: true, force: true })
119
+ })
120
+
121
+ // ── formatStampResult ────────────────────────────────────────────────────
122
+
123
+ test('formatStampResult agentFacing:true on refusal tells the agent to retry pulse_stamp', () => {
124
+ const output = formatStampResult({ stamped: false, refused: true, reason: 'no report', resolvedCount: 0, ts: null }, { agentFacing: true })
125
+ assert.match(output, /Refusing to stamp/)
126
+ assert.match(output, /call pulse_stamp again/)
127
+ })
128
+
129
+ test('formatStampResult agentFacing:false (CLI) omits the pulse_stamp-specific retry phrasing', () => {
130
+ const output = formatStampResult({ stamped: false, refused: true, reason: 'no report', resolvedCount: 0, ts: null }, { agentFacing: false })
131
+ assert.match(output, /Refusing to stamp/)
132
+ assert.doesNotMatch(output, /pulse_stamp/, 'CLI output must not reference the MCP tool name pulse_stamp')
133
+ })
134
+
135
+ test('formatStampResult distinguishes a real write failure from a policy refusal', () => {
136
+ const output = formatStampResult({ stamped: false, refused: false, reason: 'disk full', resolvedCount: 0, ts: null }, { agentFacing: true })
137
+ assert.match(output, /Failed to stamp/)
138
+ assert.doesNotMatch(output, /Refusing to stamp/)
139
+ })
140
+
141
+ test('formatStampResult on success reports resolved count when nonzero', () => {
142
+ const output = formatStampResult({ stamped: true, refused: false, reason: null, resolvedCount: 2, ts: '123' }, { agentFacing: true })
143
+ assert.match(output, /2 error-journal entries marked resolved/)
144
+ })
145
+
146
+ test('formatStampResult on success says nothing about resolving when count is zero', () => {
147
+ const output = formatStampResult({ stamped: true, refused: false, reason: null, resolvedCount: 0, ts: '123' }, { agentFacing: true })
148
+ assert.doesNotMatch(output, /marked resolved/)
149
+ })
package/src/cli/start.js CHANGED
@@ -43,6 +43,7 @@ if (!fs.existsSync(manifestPath)) {
43
43
 
44
44
  let port = portArg !== -1 ? parseInt(args[portArg + 1], 10) : null
45
45
  let defaultCache = null
46
+ let staticCache = null
46
47
  let csp = null
47
48
  let passthrough = {}
48
49
 
@@ -55,6 +56,7 @@ if (fs.existsSync(configPath)) {
55
56
  const mod = await import(configPath)
56
57
  port = port || mod.default?.port || null
57
58
  defaultCache = mod.default?.defaultCache ?? null
59
+ staticCache = mod.default?.staticCache ?? null
58
60
  csp = mod.default?.csp ?? null
59
61
  for (const key of PASSTHROUGH_OPTIONS) {
60
62
  if (mod.default?.[key] !== undefined) passthrough[key] = mod.default[key]
@@ -62,8 +64,18 @@ if (fs.existsSync(configPath)) {
62
64
  } catch { /* ignore */ }
63
65
  }
64
66
 
65
- // Render (and other PaaS platforms) inject PORT — always takes precedence
66
- port = parseInt(process.env.PORT, 10) || port || 3000
67
+ // Render (and other PaaS platforms) inject PORT — always takes precedence, even
68
+ // over an explicit --port flag. This is correct for real deployment, but it also
69
+ // means a PORT env var set on a local dev machine for unrelated reasons (common
70
+ // in sandboxes, CI runners, some shell profiles) silently overrides the local
71
+ // convention of `pulse start --port 3001` documented for /verify — with no error,
72
+ // just a server on a port nobody asked for. Surface it instead of staying silent.
73
+ const requestedPort = port
74
+ const envPort = parseInt(process.env.PORT, 10) || null
75
+ port = envPort || port || 3000
76
+ if (envPort && requestedPort && envPort !== requestedPort) {
77
+ console.error(`⚠ PORT=${envPort} in the environment overrides --port ${requestedPort} — starting on ${envPort} instead.\n Unset PORT, or don't rely on --port locally while it's set.\n`)
78
+ }
67
79
 
68
80
  // ---------------------------------------------------------------------------
69
81
  // Start
@@ -84,6 +96,7 @@ createServer(specs, {
84
96
  stream: true,
85
97
  staticDir: PUBLIC_DIR,
86
98
  defaultCache,
99
+ staticCache,
87
100
  ...(store ? { store } : {}),
88
101
  ...(csp ? { csp } : {}),
89
102
  ...passthrough,
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Pulse — Spec validation (shared logic)
3
+ *
4
+ * The single source of truth for validating a Pulse spec's content. Used by
5
+ * both pulse_validate (the MCP tool, for an agent) and `pulse validate` (the
6
+ * CLI command, for a human running it directly). Neither wraps the other —
7
+ * they both call validateContent() here, so the two paths can never drift
8
+ * the way the checklist/workflow docs did before this file existed.
9
+ *
10
+ * Returns plain data — { valid, output, sourceWarnings } — with no MCP
11
+ * protocol coupling (no text()-wrapping) and no audience-specific framing
12
+ * (no "ask the user for design approval" — that's for the MCP tool's own
13
+ * wrapper to add, since a human at the terminal doesn't need it phrased at
14
+ * them that way).
15
+ */
16
+
17
+ import fs from 'fs'
18
+ import path from 'path'
19
+ import { execFileSync } from 'child_process'
20
+
21
+ const PROP_ALIASES = [
22
+ { component: 'nav()', wrong: 'brand', correct: 'logo', note: 'The nav logo is set with the `logo` prop, not `brand`.' },
23
+ { component: 'nav()', wrong: 'actions', correct: 'action', note: 'nav() takes a single `action` string (HTML), not an array.' },
24
+ { component: 'footer()', wrong: 'items', correct: 'links', note: 'footer() top-level links use `links: [{ label, href }]`, not `items`.' },
25
+ { component: 'footer()', wrong: 'nav', correct: 'links', note: 'footer() links prop is `links`, not `nav`.' },
26
+ { component: 'input()', wrong: 'autocomplete', correct: 'attrs: { autocomplete }', note: 'HTML attributes not in the component API (autocomplete, min, max, step, pattern, inputmode, etc.) go inside the `attrs` object: input({ attrs: { autocomplete: "email" } }).' },
27
+ { component: 'input()', wrong: 'maxlength', correct: 'attrs: { maxlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { maxlength: "100" } }).' },
28
+ { component: 'input()', wrong: 'minlength', correct: 'attrs: { minlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { minlength: "2" } }).' },
29
+ { component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
30
+ ]
31
+
32
+ const EXTERNAL_IMG_HOSTS = [
33
+ {
34
+ pattern: /https?:\/\/images\.unsplash\.com/,
35
+ host: 'images.unsplash.com',
36
+ entry: 'https://images.unsplash.com',
37
+ cookieWarning: true,
38
+ name: 'Unsplash',
39
+ },
40
+ {
41
+ pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
42
+ host: 'picsum.photos',
43
+ entry: 'https://picsum.photos https://fastly.picsum.photos',
44
+ cookieWarning: false,
45
+ name: 'picsum',
46
+ },
47
+ {
48
+ pattern: /https?:\/\/res\.cloudinary\.com/,
49
+ host: 'res.cloudinary.com',
50
+ entry: 'https://res.cloudinary.com',
51
+ cookieWarning: true,
52
+ name: 'Cloudinary',
53
+ },
54
+ {
55
+ pattern: /https?:\/\/cdn\.shopify\.com/,
56
+ host: 'cdn.shopify.com',
57
+ entry: 'https://cdn.shopify.com',
58
+ cookieWarning: false,
59
+ name: 'Shopify CDN',
60
+ },
61
+ ]
62
+
63
+ /**
64
+ * Validate spec source content against the schema plus a set of source-level
65
+ * structural checks (wrong component prop names, un-CSP'd external image hosts).
66
+ *
67
+ * @param {string} content - the spec file's source text
68
+ * @param {string} root - project root, used to find pulse.config.js
69
+ * @param {string} [tmpDir] - directory to write the temp validation file into,
70
+ * so relative imports in the spec resolve correctly. Defaults to <root>/src/pages.
71
+ * @returns {Promise<{ valid: boolean, timedOut: boolean, schemaOutput: string, sourceWarnings: string[] }>}
72
+ */
73
+ export async function validateContent(content, root, tmpDir = null) {
74
+ tmpDir = tmpDir || path.join(root, 'src', 'pages')
75
+
76
+ const sourceWarnings = []
77
+
78
+ // Component prop alias checks — flag known wrong prop names
79
+ for (const { component, wrong, correct, note } of PROP_ALIASES) {
80
+ const fnName = component.replace('()', '')
81
+ const re = new RegExp(`${fnName}\\s*\\(\\s*\\{([^}]*)\\b${wrong}\\s*:`, 'g')
82
+ let m
83
+ while ((m = re.exec(content)) !== null) {
84
+ // attrs: { … } is the correct placement for HTML attributes — a match
85
+ // that falls inside an open attrs block is the recommended pattern,
86
+ // not a mistake.
87
+ if (/attrs\s*:\s*\{[^}]*$/.test(m[1])) continue
88
+ sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
89
+ break
90
+ }
91
+ }
92
+
93
+ // External image URL check — warn if the spec references an external image
94
+ // host not whitelisted in pulse.config.js's CSP img-src.
95
+ const configPath = path.join(root, 'pulse.config.js')
96
+ let configImgSrc = ''
97
+ if (fs.existsSync(configPath)) {
98
+ try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
99
+ }
100
+ for (const { pattern, host, entry, cookieWarning, name } of EXTERNAL_IMG_HOSTS) {
101
+ // Config match is by HOST, not full URL — CSP sources are valid without a
102
+ // scheme, so requiring https?:// in the config caused false positives.
103
+ if (pattern.test(content) && !configImgSrc.includes(host)) {
104
+ const cookieNote = cookieWarning
105
+ ? `\n ⚠ ${name} sets tracking cookies that fail Lighthouse Best Practices regardless of CSP. For production, download images to public/images/ instead of linking to ${name} directly.`
106
+ : ''
107
+ sourceWarnings.push(
108
+ `External image host detected (${name}). Add it to csp.img-src in pulse.config.js before running Lighthouse:\n` +
109
+ ` csp: { 'img-src': ['${entry}'] }\n` +
110
+ ` Without this, images will be blocked and Lighthouse Best Practices will fail.${cookieNote}`
111
+ )
112
+ }
113
+ }
114
+
115
+ fs.mkdirSync(tmpDir, { recursive: true })
116
+ const tmpFile = path.join(tmpDir, `.pulse-validate-${Date.now()}.mjs`)
117
+ try {
118
+ fs.writeFileSync(tmpFile, content, 'utf8')
119
+
120
+ const validatorScript = new URL('../mcp/validate-worker.js', import.meta.url).pathname
121
+ let schemaOutput
122
+ let timedOut = false
123
+ try {
124
+ schemaOutput = execFileSync(process.execPath, [validatorScript, tmpFile], {
125
+ timeout: 10_000,
126
+ encoding: 'utf8',
127
+ }).trim()
128
+ } catch (err) {
129
+ timedOut = err.killed || err.signal === 'SIGTERM'
130
+ schemaOutput = timedOut
131
+ ? 'Invalid: validation timed out — spec may have a hanging import or infinite loop'
132
+ : `Invalid: could not parse — ${err.stdout || err.message}`
133
+ }
134
+
135
+ const valid = schemaOutput.startsWith('Valid ✓') && !schemaOutput.includes('Invalid')
136
+
137
+ return { valid, timedOut, schemaOutput, sourceWarnings }
138
+ } finally {
139
+ try { fs.unlinkSync(tmpFile) } catch { /* ignore */ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Validate a spec by file path — reads the file, validates from its own
145
+ * directory so relative imports resolve correctly for pages in subdirectories.
146
+ *
147
+ * @param {string} file - absolute path to the spec file
148
+ * @param {string} root - project root
149
+ * @returns {Promise<{ valid: boolean, timedOut: boolean, schemaOutput: string, sourceWarnings: string[] } | { error: string }>}
150
+ */
151
+ export async function validateFile(file, root) {
152
+ if (!fs.existsSync(file)) {
153
+ return { error: `File not found: ${file}` }
154
+ }
155
+ const content = fs.readFileSync(file, 'utf8')
156
+ return validateContent(content, root, path.dirname(file))
157
+ }
158
+
159
+ /**
160
+ * Format a validation result as human-readable text — shared rendering for
161
+ * both the CLI (printed to stdout) and the MCP tool (wrapped in text()).
162
+ * `nextSteps`, when true, appends the agent-facing browser-check reminder;
163
+ * a human running this from the terminal doesn't need that, so the CLI
164
+ * command omits it.
165
+ */
166
+ export function formatValidationResult(result, { nextSteps = false } = {}) {
167
+ if (result.error) return result.error
168
+
169
+ let output = result.schemaOutput
170
+ if (result.sourceWarnings.length > 0) {
171
+ const propNotes = result.sourceWarnings.map(w => ` ⚠ ${w}`).join('\n')
172
+ if (output.startsWith('Valid ✓')) {
173
+ output = output.replace('Valid ✓', 'Valid ✓ — but fix these issues:') + '\n' + propNotes
174
+ } else {
175
+ output = output + '\n' + propNotes
176
+ }
177
+ }
178
+
179
+ if (nextSteps && result.valid && result.sourceWarnings.length === 0) {
180
+ output += '\n\n---\n**Next: browser check sequence** (do not skip)\n1. `pulse_fetch_page` → screenshot\n2. **New build?** Show screenshot to user and ask for design approval before continuing\n3. Once approved (or edit/fix): `pulse_design_review` (if intake ran) → `pulse_layout_review <url>` → `/verify`\nDo not run Lighthouse before the user approves the design. Do not report done until `/verify` passes.'
181
+ }
182
+
183
+ return output
184
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Pulse — validate.js tests
3
+ * run: node src/cli/validate.test.js
4
+ *
5
+ * This is the shared logic behind both pulse_validate (MCP tool) and
6
+ * `pulse validate` (CLI command) — a real end-to-end test here is the
7
+ * guarantee that a human running the CLI gets the exact same check an
8
+ * agent gets, since both call into this same module.
9
+ */
10
+
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { validateContent, validateFile, formatValidationResult } from './validate.js'
17
+
18
+ function tmpProject() {
19
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-validate-test-'))
20
+ fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
21
+ return root
22
+ }
23
+
24
+ test('a valid spec with no meta.theme passes with a warning', async () => {
25
+ const root = tmpProject()
26
+ const content = `export default {\n route: '/x',\n meta: { title: 't', description: 'd' },\n view: () => '<main id="main-content">x</main>',\n}`
27
+ const result = await validateContent(content, root)
28
+ assert.equal(result.valid, true)
29
+ fs.rmSync(root, { recursive: true, force: true })
30
+ })
31
+
32
+ test('an invalid route (no leading slash) fails validation', async () => {
33
+ const root = tmpProject()
34
+ const content = `export default {\n route: 'no-slash',\n view: () => '',\n}`
35
+ const result = await validateContent(content, root)
36
+ assert.equal(result.valid, false)
37
+ assert.match(result.schemaOutput, /must start with "\/"/)
38
+ fs.rmSync(root, { recursive: true, force: true })
39
+ })
40
+
41
+ test('prop-alias check flags nav({ brand }) and suggests logo', async () => {
42
+ const root = tmpProject()
43
+ const content = `export default {\n route: '/x',\n view: () => nav({ brand: 'Site', links: [] }),\n}`
44
+ const result = await validateContent(content, root)
45
+ assert.equal(result.sourceWarnings.length, 1)
46
+ assert.match(result.sourceWarnings[0], /"brand" is not a recognised prop for nav\(\)/)
47
+ assert.match(result.sourceWarnings[0], /did you mean "logo"/)
48
+ fs.rmSync(root, { recursive: true, force: true })
49
+ })
50
+
51
+ test('prop-alias check does NOT flag autocomplete correctly placed inside attrs', async () => {
52
+ const root = tmpProject()
53
+ const content = `export default {\n route: '/x',\n view: () => input({ attrs: { autocomplete: 'email' } }),\n}`
54
+ const result = await validateContent(content, root)
55
+ assert.equal(result.sourceWarnings.length, 0,
56
+ `Expected no warnings for the recommended attrs pattern, got: ${JSON.stringify(result.sourceWarnings)}`)
57
+ fs.rmSync(root, { recursive: true, force: true })
58
+ })
59
+
60
+ test('external image host warning fires when unlisted in pulse.config.js CSP', async () => {
61
+ const root = tmpProject()
62
+ const content = `export default {\n route: '/x',\n view: () => \`<img src="https://images.unsplash.com/photo-1">\`,\n}`
63
+ const result = await validateContent(content, root)
64
+ assert.equal(result.sourceWarnings.length, 1)
65
+ assert.match(result.sourceWarnings[0], /External image host detected \(Unsplash\)/)
66
+ fs.rmSync(root, { recursive: true, force: true })
67
+ })
68
+
69
+ test('external image host warning is suppressed when the bare host is in pulse.config.js', async () => {
70
+ const root = tmpProject()
71
+ fs.writeFileSync(path.join(root, 'pulse.config.js'),
72
+ `export default { csp: { 'img-src': ['images.unsplash.com'] } }`)
73
+ const content = `export default {\n route: '/x',\n view: () => \`<img src="https://images.unsplash.com/photo-1">\`,\n}`
74
+ const result = await validateContent(content, root)
75
+ assert.equal(result.sourceWarnings.length, 0,
76
+ `Expected no warning when the bare host is already configured, got: ${JSON.stringify(result.sourceWarnings)}`)
77
+ fs.rmSync(root, { recursive: true, force: true })
78
+ })
79
+
80
+ test('inline <script> warning does NOT fire for external scripts with a src', async () => {
81
+ const root = tmpProject()
82
+ const view = [
83
+ '<script src="/menu.js" defer></script>',
84
+ '<script defer src="/a.js"></script>',
85
+ '<script type="module" src=\'/b.js\'></script>',
86
+ ].join('')
87
+ const content = `export default {\n route: '/x',\n view: () => \`<main id="main-content">${view}</main>\`,\n}`
88
+ const result = await validateContent(content, root)
89
+ assert.equal(result.valid, true)
90
+ assert.doesNotMatch(result.schemaOutput, /Inline <script>/,
91
+ `Expected no inline-script warning for external scripts, got: ${result.schemaOutput}`)
92
+ fs.rmSync(root, { recursive: true, force: true })
93
+ })
94
+
95
+ test('inline <script> warning still fires for real inline scripts', async () => {
96
+ const root = tmpProject()
97
+ const content = `export default {\n route: '/x',\n view: () => \`<main id="main-content"><script>alert(1)</script></main>\`,\n}`
98
+ const result = await validateContent(content, root)
99
+ assert.match(result.schemaOutput, /Inline <script> block detected/)
100
+ fs.rmSync(root, { recursive: true, force: true })
101
+ })
102
+
103
+ test('inline <script> warning still fires when only data-src is present', async () => {
104
+ const root = tmpProject()
105
+ const content = `export default {\n route: '/x',\n view: () => \`<main id="main-content"><script data-src="/a.js">alert(1)</script></main>\`,\n}`
106
+ const result = await validateContent(content, root)
107
+ assert.match(result.schemaOutput, /Inline <script> block detected/)
108
+ fs.rmSync(root, { recursive: true, force: true })
109
+ })
110
+
111
+ test('validateFile resolves relative imports from the spec\'s own directory', async () => {
112
+ const root = tmpProject()
113
+ fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true })
114
+ fs.mkdirSync(path.join(root, 'src', 'pages', 'news'), { recursive: true })
115
+ fs.writeFileSync(path.join(root, 'src', 'components', 'layout.js'),
116
+ `export const layout = ({ content }) => content`)
117
+ const specPath = path.join(root, 'src', 'pages', 'news', 'index.js')
118
+ fs.writeFileSync(specPath,
119
+ `import { layout } from '../../components/layout.js'\nexport default {\n route: '/news',\n meta: { title: 't', description: 'd' },\n view: () => layout({ content: '<main id="main-content">news</main>' }),\n}`)
120
+ const result = await validateFile(specPath, root)
121
+ assert.equal(result.valid, true,
122
+ `Expected relative import to resolve from the spec's own directory, got: ${JSON.stringify(result)}`)
123
+ fs.rmSync(root, { recursive: true, force: true })
124
+ })
125
+
126
+ test('validateFile returns an error object for a missing file', async () => {
127
+ const root = tmpProject()
128
+ const result = await validateFile(path.join(root, 'does-not-exist.js'), root)
129
+ assert.match(result.error, /File not found/)
130
+ fs.rmSync(root, { recursive: true, force: true })
131
+ })
132
+
133
+ test('formatValidationResult with nextSteps: true appends the agent browser-check reminder', async () => {
134
+ const root = tmpProject()
135
+ const content = `export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`
136
+ const result = await validateContent(content, root)
137
+ const withNextSteps = formatValidationResult(result, { nextSteps: true })
138
+ const without = formatValidationResult(result, { nextSteps: false })
139
+ assert.match(withNextSteps, /browser check sequence/)
140
+ assert.doesNotMatch(without, /browser check sequence/,
141
+ 'CLI output (nextSteps: false, the default) must not carry agent-only framing like "ask the user for design approval"')
142
+ fs.rmSync(root, { recursive: true, force: true })
143
+ })
144
+
145
+ test('formatValidationResult merges source warnings into a clean schema pass', async () => {
146
+ const root = tmpProject()
147
+ const content = `export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => nav({ brand: 'x', links: [] }),\n}`
148
+ const result = await validateContent(content, root)
149
+ const output = formatValidationResult(result)
150
+ assert.match(output, /Valid ✓ — but fix these issues:/)
151
+ assert.match(output, /did you mean "logo"/)
152
+ fs.rmSync(root, { recursive: true, force: true })
153
+ })