@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
@@ -93,6 +93,15 @@ You understand what already exists before creating anything — inspect the proj
93
93
 
94
94
  You narrate your progress as you go. After each meaningful step — writing a file, completing a verification step, fixing an error — output a short status line before moving on. Do not run all your tool calls silently and then summarise at the end. Examples: `✓ Page written — running syntax check...`, `✓ No console errors — fetching SSR output...`, `✓ SSR looks good — building for production...`, `✗ Lighthouse accessibility 94 — fixing missing label on email input...`. One line is enough. Keep it factual and move on.
95
95
 
96
+ **Always report Lighthouse results in this exact fixed format — never paraphrase the scores into prose** (not "Desktop 100 across the board", not a slash-joined string of all four numbers run together). Some hosts render this line specially (e.g. as score cards); paraphrasing breaks that and hides the real per-category numbers from the user. One line per device, right after that device's `lighthouse_audit` call returns:
97
+
98
+ ```
99
+ Lighthouse desktop: Accessibility 100, Best Practices 100, SEO 100
100
+ Lighthouse mobile: Accessibility 100, Best Practices 100, SEO 96
101
+ ```
102
+
103
+ Always all three categories, always in that order, always `Category N` (not `N%`, not `N/100`). Include Performance only when it was actually gated for this page (ad-free pages only — see the Performance Baseline table) as a fourth `Performance N` pair, same line. If a score is missing/unavailable from the tool response, write `Category —` rather than omitting the category.
104
+
96
105
  You validate after you write. Fix every error AND every warning before moving on. Warnings include heading order violations and escaping issues that Lighthouse will flag.
97
106
 
98
107
  You write tests for every page you create. A minimal page test looks like this:
@@ -32,6 +32,7 @@ For any **new page, landing page, or branded site**, run the intake sequence fir
32
32
  ```
33
33
 
34
34
  - **Step 0 — Ask for inspiration first (mandatory):** Before calling any tool or presenting any plan, ask the user: *"Do you have any design inspiration — a site you love, a screenshot, or a mood board image? Drop images into `public/intake/` or paste a URL and I'll extract the design intent before we start."* Wait for the answer. Do not skip this question, even if the user's brief already seems detailed. Check `public/intake/` for any images already dropped there.
35
+ - **Internal/functional tools (a staff dashboard, an admin screen, an internal work tool — not something customers see):** the question itself is still mandatory, but the aesthetic framing lands oddly ("a site you love" doesn't map onto "a work screen an employee glances at"). Ask instead: *"Do you have any reference for how this should look, or should I go with a clean, functional style typical of internal tools? Also — what's the most important thing to see at a glance, and how much will typically be on screen at once?"* This still catches a real visual reference if one exists, while asking what actually matters for this kind of build (information density, priority) instead of forcing a mood-board answer nobody has. A "no, we don't have anything" answer here is common and not a reason to skip `pulse_intake`'s theme/vibe/anti-style questions that follow — fold the answer in and move on, same as any other build.
35
36
  - **`pulse_extract_inspiration`** — call this after step 0 if the user supplies a URL, site name, or image they like. Also call it for any images already present in `public/intake/`. Gives you a structured extraction template: visit the URL or analyse the image with your vision tools, extract colours, layout, typography, and feel, then feed those findings into pulse_intake. If the user explicitly says they have no reference, skip this step.
36
37
  - **`pulse_intake`** — captures the real content (name, pitch, features, palette, theme, vibe, what it should NOT look like). Returns a brief with copy-ready content and early contrast warnings. Always ask the user one question at a time — never multi-choice for open-ended questions. **Always ask light or dark** — Pulse renders dark when `meta.theme` is unset, so an unstated light design ships dark.
37
38
  - **`pulse_sketch`** — generates 3 structurally distinct layout directions (full-bleed, asymmetric split, typography-only, editorial, dense grid, story scroll, content-first). **Call this before writing any code.** Prevents defaulting to centred hero + three-column features every time.
@@ -163,29 +164,9 @@ If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop
163
164
  **Reuse across specs (both modes):** one spec = one page; a site is many specs sharing code. Before writing a new section, check `src/components/` — and if a section you're writing already exists in another spec, extract it to `src/components/` *now* and import it from both, rather than pasting a copy. Same for CSS: a shared section's styles go in the shared `app.css` (listed in `meta.styles` by every spec that uses it), never duplicated into per-page stylesheets.
164
165
 
165
166
  **Mode B — Creative override (raw HTML throughout)**
166
- When the design intent calls for a more expressive, unconventional, or typographically-driven layout that components would constrain, you may build with raw HTML throughout. This is a deliberate design decision, not a shortcut.
167
-
168
- **When to choose Mode B — use the component, unless:**
169
- - The layout requires **full-viewport height or edge-to-edge bleed** that the component doesn't support (e.g. `hero()` doesn't fill the viewport)
170
- - The design uses **custom gradient glows, parallax, or large-scale image fills** that the component would wrap in unwanted structure
171
- - The vibe is **brutalist, retro, neon, paper, or typographic-editorial** and the structural feel would be actively fought by the component's default padding/spacing/radius
172
- - The typography is **clamp-scaled display type** (e.g. `clamp(4rem, 10vw, 12rem)`) that breaks inside a component's internal box model
173
- - The layout is **asymmetric or zone-based** (e.g. 70/30 split, overlapping elements, staggered grid) that no single component supports
174
- - A **Figma/reference design was provided** that clearly departs from the component's output
175
-
176
- **When to use the component (not a valid reason for override):**
177
- - "The component doesn't have a perfect prop for this" — add a utility class or wrapper div instead
178
- - "I want a slightly different colour or size" — override with CSS tokens
179
- - "I'm not sure what the component looks like" — check the guide first
180
- - "It's easier to write raw HTML" — this is never a valid reason
181
-
182
- Rules for Mode B:
183
- - **Declare it in a comment at the top of the spec file:** `// component-free — creative override: <reason>`. The review tool detects creative override by reading this comment from the source — without it, `pulse_review` will flag every component pattern as a violation even though you chose Mode B deliberately. Announcing the mode in chat is not enough; the comment must be in the file.
184
- - Load `/pulse-ui.css` in `meta.styles` — the token system is still required
185
- - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason
186
- - The quality gate is Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 — this replaces the component checklist as the pass bar. Performance is reported but not gated.
187
-
188
- Announce Mode B explicitly in the build brief (*"Mode B — component-free, raw HTML for creative control"*) **and** write the override comment into the spec.
167
+ When the design intent calls for a more expressive, unconventional, or typographically-driven layout that components would constrain, you may build with raw HTML throughout. Full criteria for when this is (and isn't) warranted, and the required spec comment, are in `checklist.md`'s "Components first — or creative override" section — read that before choosing Mode B. In short: it's a deliberate design decision for cases components structurally can't express (full-viewport bleed, brutalist/neon/editorial vibes, clamp-scaled display type, asymmetric layouts) — never a shortcut for "easier to write raw HTML."
168
+
169
+ Announce Mode B explicitly in the build brief (*"Mode B — component-free, raw HTML for creative control"*) **and** write `// component-free — creative override: <reason>` at the top of the spec file — `pulse_review` only detects Mode B by reading that comment from the source, not from chat.
189
170
 
190
171
  ### 3b — Announce before writing
191
172
 
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pulse — Bundle content inspection (shared logic)
3
+ *
4
+ * The single source of truth behind pulse_check_bundles (MCP tool, for an
5
+ * agent) and `pulse check-bundles` (CLI command, for a human). Lighthouse
6
+ * checks scores; this checks what's actually inside the generated
7
+ * public/dist/ bundle files after a production build — a boot bundle
8
+ * generated for a page that doesn't need one, or server-only code that
9
+ * leaked through stripping. Neither of those shows up as a score failure.
10
+ */
11
+
12
+ import fs from 'fs'
13
+ import path from 'path'
14
+ import { discoverPages } from './discover.js'
15
+
16
+ const NODE_BUILTINS = ['node:fs', 'node:path', 'node:crypto', 'node:os', 'node:child_process', 'node:http', 'node:https', 'node:net', 'node:dns']
17
+
18
+ /**
19
+ * Inspect public/dist/ after a production build.
20
+ *
21
+ * @param {string} root - project root
22
+ * @returns {{
23
+ * error: string|null,
24
+ * issues: string[],
25
+ * confirms: string[],
26
+ * }}
27
+ */
28
+ export function checkBundles(root) {
29
+ const distDir = path.join(root, 'public', 'dist')
30
+ const manifestPath = path.join(distDir, 'manifest.json')
31
+ if (!fs.existsSync(manifestPath)) {
32
+ return { error: 'No public/dist/manifest.json found — run a production build first.', issues: [], confirms: [] }
33
+ }
34
+
35
+ let manifest
36
+ try {
37
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
38
+ } catch (err) {
39
+ return { error: `Could not parse manifest.json: ${err.message}`, issues: [], confirms: [] }
40
+ }
41
+
42
+ const issues = []
43
+ const confirms = []
44
+
45
+ // Read each page's raw source directly rather than importing the module —
46
+ // no need to re-execute project code just to check for hydration need.
47
+ // needsHydration mirrors discover.js's own check exactly: mutations ||
48
+ // actions || persist. The manifest key for a page is always
49
+ // /src/pages/<relative path> — that's how loadPages/build.js both derive
50
+ // it, so no fuzzy route matching is needed.
51
+ let pageFiles = []
52
+ try {
53
+ pageFiles = discoverPages(root)
54
+ } catch { /* fall through — bundle-content checks below still run */ }
55
+
56
+ for (const { filePath, derivedRoute } of pageFiles) {
57
+ const source = fs.readFileSync(filePath, 'utf8')
58
+ const needsHydration = /\b(mutations|actions|persist)\s*:/.test(source) || /\bhydrate\s*:/.test(source)
59
+ if (needsHydration) continue // genuinely needs hydration — nothing to flag
60
+ const manifestKey = '/src/pages/' + path.relative(path.join(root, 'src', 'pages'), filePath)
61
+ const bundled = manifest[manifestKey]
62
+ if (bundled) {
63
+ // discoverPages' derivedRoute is a filename-based fallback (spec.route
64
+ // wins at runtime, e.g. for a dynamic /:code route on a file literally
65
+ // named redirect.js) — read the spec's real declared route from source
66
+ // when present, so the message names the route that's actually
67
+ // registered, not just what the filename implies.
68
+ const routeMatch = source.match(/\broute\s*:\s*['"]([^'"]+)['"]/)
69
+ const route = routeMatch ? routeMatch[1] : derivedRoute
70
+ issues.push(`✗ Route "${route}" (${manifestKey}) has no mutations/actions/persist but a boot bundle exists (${bundled}) — should ship zero client JS. If this page imports a local server-only helper (e.g. using node:fs), the build may be one dependency away from failing outright.`)
71
+ }
72
+ }
73
+
74
+ // Scan every generated boot file for a literal Node built-in reference —
75
+ // the strongest, cheapest signal that server-only code leaked through.
76
+ let bootFiles = []
77
+ try {
78
+ bootFiles = fs.readdirSync(distDir).filter(f => f.includes('.boot-') && f.endsWith('.js'))
79
+ } catch { /* dist dir read failure — nothing more to check */ }
80
+
81
+ for (const file of bootFiles) {
82
+ const content = fs.readFileSync(path.join(distDir, file), 'utf8')
83
+ const leaked = NODE_BUILTINS.filter(b => content.includes(b))
84
+ if (leaked.length > 0) {
85
+ issues.push(`✗ ${file} contains a literal reference to ${leaked.join(', ')} — server-only code leaked into a client bundle. This will throw at runtime in the browser (these modules don't exist there) even if the build itself succeeded.`)
86
+ } else {
87
+ confirms.push(`✓ ${file} — no Node built-in references found`)
88
+ }
89
+ }
90
+
91
+ if (bootFiles.length === 0) confirms.push('✓ No boot bundles generated — consistent with a purely server-rendered project, if that\'s expected')
92
+
93
+ return { error: null, issues, confirms }
94
+ }
95
+
96
+ export function formatBundleCheck(result) {
97
+ if (result.error) return result.error
98
+
99
+ const lines = ['## Bundle content check\n']
100
+ if (result.issues.length === 0) {
101
+ lines.push('✓ All clear — no unnecessary bundles, no leaked server-only code.\n')
102
+ } else {
103
+ lines.push('### Issues found\n')
104
+ for (const i of result.issues) lines.push(i)
105
+ lines.push('')
106
+ }
107
+ if (result.confirms.length > 0) {
108
+ lines.push('### Checked\n')
109
+ for (const c of result.confirms) lines.push(c)
110
+ }
111
+
112
+ return lines.join('\n')
113
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Pulse — check-bundles.js tests
3
+ * run: node src/cli/check-bundles.test.js
4
+ *
5
+ * Shared logic behind pulse_check_bundles (MCP tool) and `pulse check-bundles`
6
+ * (CLI command). These tests build against a real manifest.json + real boot
7
+ * file content (not a mocked build) since the whole point of this check is
8
+ * reading what a real build actually produced.
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 { checkBundles, formatBundleCheck } from './check-bundles.js'
17
+
18
+ function tmpProject() {
19
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-check-bundles-test-'))
20
+ fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
21
+ fs.mkdirSync(path.join(root, 'public', 'dist'), { recursive: true })
22
+ return root
23
+ }
24
+
25
+ function writeManifest(root, entries) {
26
+ fs.writeFileSync(path.join(root, 'public', 'dist', 'manifest.json'), JSON.stringify(entries, null, 2))
27
+ }
28
+
29
+ function writeBootFile(root, name, content = 'var x = 1;') {
30
+ fs.writeFileSync(path.join(root, 'public', 'dist', name), content)
31
+ }
32
+
33
+ test('reports an error when no manifest.json exists', () => {
34
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-check-bundles-test-'))
35
+ const result = checkBundles(root)
36
+ assert.match(result.error, /No public\/dist\/manifest\.json found/)
37
+ fs.rmSync(root, { recursive: true, force: true })
38
+ })
39
+
40
+ test('clean pass: a page with mutations correctly has a bundle', () => {
41
+ const root = tmpProject()
42
+ fs.writeFileSync(path.join(root, 'src', 'pages', 'counter.js'),
43
+ `export default {\n route: '/counter',\n state: { count: 0 },\n mutations: { inc: (s) => ({ count: s.count + 1 }) },\n view: (state) => \`<button>\${state.count}</button>\`,\n}`)
44
+ writeManifest(root, { '/src/pages/counter.js': '/dist/counter.boot-ABC123.js' })
45
+ writeBootFile(root, 'counter.boot-ABC123.js')
46
+
47
+ const result = checkBundles(root)
48
+ assert.equal(result.error, null)
49
+ assert.equal(result.issues.length, 0, `Expected no issues, got: ${JSON.stringify(result.issues)}`)
50
+ assert.equal(result.confirms.length, 1)
51
+ fs.rmSync(root, { recursive: true, force: true })
52
+ })
53
+
54
+ test('flags a boot bundle for a page with no mutations/actions/persist', () => {
55
+ const root = tmpProject()
56
+ fs.writeFileSync(path.join(root, 'src', 'pages', 'redirect.js'),
57
+ `export default {\n route: '/:code',\n guard: async (ctx) => { if (!ctx.params.code) return { status: 404 } },\n view: () => '<main id="main-content">redirecting</main>',\n}`)
58
+ // Simulate the exact historical bug: a bundle exists for a page that needs none.
59
+ writeManifest(root, { '/src/pages/redirect.js': '/dist/redirect.boot-XYZ789.js' })
60
+ writeBootFile(root, 'redirect.boot-XYZ789.js')
61
+
62
+ const result = checkBundles(root)
63
+ assert.equal(result.issues.length, 1)
64
+ assert.match(result.issues[0], /\/:code/)
65
+ assert.match(result.issues[0], /no mutations\/actions\/persist/)
66
+ fs.rmSync(root, { recursive: true, force: true })
67
+ })
68
+
69
+ test('issue message uses the spec\'s real declared route, not the filename-derived fallback', () => {
70
+ // Regression: discoverPages' derivedRoute is filename-based only (it doesn't
71
+ // parse spec.route) — a file named redirect.js with route: '/:code' was
72
+ // reported as route "/redirect" instead of the route actually registered.
73
+ const root = tmpProject()
74
+ fs.writeFileSync(path.join(root, 'src', 'pages', 'redirect.js'),
75
+ `export default {\n route: '/:code',\n guard: async (ctx) => { if (!ctx.params.code) return { status: 404 } },\n view: () => '<main id="main-content">redirecting</main>',\n}`)
76
+ writeManifest(root, { '/src/pages/redirect.js': '/dist/redirect.boot-XYZ789.js' })
77
+ writeBootFile(root, 'redirect.boot-XYZ789.js')
78
+
79
+ const result = checkBundles(root)
80
+ assert.equal(result.issues.length, 1)
81
+ assert.match(result.issues[0], /Route "\/:code"/,
82
+ `Expected the real declared route /:code, not the filename-derived /redirect — got: ${result.issues[0]}`)
83
+ fs.rmSync(root, { recursive: true, force: true })
84
+ })
85
+
86
+ test('does not flag a page with no mutations/actions/persist that correctly has no bundle', () => {
87
+ const root = tmpProject()
88
+ fs.writeFileSync(path.join(root, 'src', 'pages', 'redirect.js'),
89
+ `export default {\n route: '/:code',\n guard: async (ctx) => { if (!ctx.params.code) return { status: 404 } },\n view: () => '<main id="main-content">redirecting</main>',\n}`)
90
+ // No manifest entry for this page — correct behaviour, nothing to flag.
91
+ writeManifest(root, {})
92
+
93
+ const result = checkBundles(root)
94
+ assert.equal(result.issues.length, 0)
95
+ fs.rmSync(root, { recursive: true, force: true })
96
+ })
97
+
98
+ test('flags a boot bundle containing a literal Node built-in reference', () => {
99
+ const root = tmpProject()
100
+ writeManifest(root, {})
101
+ writeBootFile(root, 'leaky.boot-DEF456.js', `var x=require("node:fs");x.readFileSync("/tmp/y")`)
102
+
103
+ const result = checkBundles(root)
104
+ assert.equal(result.issues.length, 1)
105
+ assert.match(result.issues[0], /leaky\.boot-DEF456\.js/)
106
+ assert.match(result.issues[0], /node:fs/)
107
+ fs.rmSync(root, { recursive: true, force: true })
108
+ })
109
+
110
+ test('a clean boot file with no Node built-ins is confirmed, not flagged', () => {
111
+ const root = tmpProject()
112
+ writeManifest(root, {})
113
+ writeBootFile(root, 'clean.boot-GHI999.js', `var mount=function(){};mount()`)
114
+
115
+ const result = checkBundles(root)
116
+ assert.equal(result.issues.length, 0)
117
+ assert.equal(result.confirms.some(c => c.includes('clean.boot-GHI999.js')), true)
118
+ fs.rmSync(root, { recursive: true, force: true })
119
+ })
120
+
121
+ test('formatBundleCheck reports all-clear when there are no issues', () => {
122
+ const root = tmpProject()
123
+ writeManifest(root, {})
124
+ const output = formatBundleCheck(checkBundles(root))
125
+ assert.match(output, /All clear/)
126
+ fs.rmSync(root, { recursive: true, force: true })
127
+ })
128
+
129
+ test('formatBundleCheck lists issues under their own heading', () => {
130
+ const root = tmpProject()
131
+ writeManifest(root, {})
132
+ writeBootFile(root, 'leaky.boot-JKL111.js', `require("node:crypto")`)
133
+ const output = formatBundleCheck(checkBundles(root))
134
+ assert.match(output, /### Issues found/)
135
+ assert.match(output, /node:crypto/)
136
+ fs.rmSync(root, { recursive: true, force: true })
137
+ })
@@ -10,6 +10,9 @@
10
10
 
11
11
  import { existsSync, readFileSync } from 'fs'
12
12
  import path from 'path'
13
+ import { fileURLToPath } from 'url'
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
13
16
 
14
17
  let passed = 0
15
18
  let failed = 0
@@ -79,6 +82,27 @@ test('discover: nested pages get unique names (collision prevention)', () => {
79
82
  }
80
83
  })
81
84
 
85
+ test('discover: a store-only spec (no local mutations/actions/persist) still gets a hydrate URL — regression: mount() is what wires up data-store-event dispatch AND the live SSE store-push subscription; without this a store-only page silently ships zero JS and never receives pushStore() broadcasts', async () => {
86
+ const fs = await import('fs')
87
+ const os = await import('os')
88
+ const path = await import('path')
89
+ const { loadPages } = modules[1].value
90
+
91
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-discover-store-test-'))
92
+ fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
93
+ fs.writeFileSync(
94
+ path.join(root, 'src', 'pages', 'storey.js'),
95
+ `export default {\n route: '/storey',\n store: ['count'],\n state: {},\n view: (state, server) => \`\${server.count}\`,\n}\n`
96
+ )
97
+
98
+ const specs = await loadPages(root)
99
+ const storey = specs.find(s => s.route === '/storey')
100
+ fs.rmSync(root, { recursive: true, force: true })
101
+
102
+ if (!storey) throw new Error('loadPages did not find the /storey spec')
103
+ if (!storey.hydrate) throw new Error('store-only spec should have gotten a hydrate URL, got: ' + storey.hydrate)
104
+ })
105
+
82
106
  // ---------------------------------------------------------------------------
83
107
  // Skill source files
84
108
 
@@ -231,6 +255,87 @@ console.log('\nCoverage-check exemptions\n')
231
255
  }
232
256
 
233
257
  // ---------------------------------------------------------------------------
258
+ // pulse start — PORT env var override warning
259
+ // ---------------------------------------------------------------------------
260
+ // Regression: found via a real dogfooding session. A `PORT` env var set on the
261
+ // local machine for unrelated reasons (common in sandboxes/CI) silently
262
+ // overrode --port with no explanation, even though this exact --port usage is
263
+ // the documented local /verify convention (pulse-start.md). The behavior
264
+ // (PORT always wins — correct for real PaaS deployment) is unchanged; this
265
+ // only adds a visible warning when the override actually happens.
266
+
267
+ {
268
+ console.log('\npulse start — PORT env var override\n')
269
+
270
+ const { spawnSync } = await import('child_process')
271
+ const os = (await import('os')).default
272
+ const fsMod = await import('fs')
273
+
274
+ function scaffoldMinimalBuiltProject() {
275
+ const dir = fsMod.mkdtempSync(path.join(os.tmpdir(), 'pulse-start-test-'))
276
+ fsMod.mkdirSync(path.join(dir, 'src', 'pages'), { recursive: true })
277
+ fsMod.mkdirSync(path.join(dir, 'public', 'dist'), { recursive: true })
278
+ fsMod.writeFileSync(path.join(dir, 'src', 'pages', 'home.js'),
279
+ `export default { route: '/', state: {}, view: () => '<main id="main-content">hi</main>' }`)
280
+ // start.js only needs public/dist/ + manifest.json to exist to pass its
281
+ // pre-flight checks — a real build isn't necessary to test port selection.
282
+ fsMod.writeFileSync(path.join(dir, 'public', 'dist', 'manifest.json'), '{}')
283
+ return dir
284
+ }
285
+
286
+ const startScriptPath = path.join(__dirname, 'start.js')
287
+
288
+ function runStart(dir, portFlag, env) {
289
+ const args = ['--root', dir]
290
+ if (portFlag) args.push('--port', String(portFlag))
291
+ return spawnSync(process.execPath, [startScriptPath, ...args], {
292
+ encoding: 'utf8',
293
+ timeout: 5000,
294
+ env: { ...process.env, ...env },
295
+ })
296
+ }
297
+
298
+ test('warns to stderr when PORT env var overrides an explicit --port flag', () => {
299
+ const dir = scaffoldMinimalBuiltProject()
300
+ try {
301
+ const result = runStart(dir, 3001, { PORT: '9999' })
302
+ if (!result.stderr.includes('PORT=9999') || !result.stderr.includes('overrides --port 3001'))
303
+ throw new Error(`Expected an override warning naming both ports, got stderr: ${result.stderr}`)
304
+ } finally {
305
+ cleanupTmpDir(dir)
306
+ }
307
+ })
308
+
309
+ test('no warning when PORT env var matches the requested --port', () => {
310
+ const dir = scaffoldMinimalBuiltProject()
311
+ try {
312
+ const result = runStart(dir, 3001, { PORT: '3001' })
313
+ if (result.stderr.includes('overrides --port'))
314
+ throw new Error(`Should not warn when PORT matches --port, got stderr: ${result.stderr}`)
315
+ } finally {
316
+ cleanupTmpDir(dir)
317
+ }
318
+ })
319
+
320
+ test('no warning when PORT is unset and only --port is used', () => {
321
+ const dir = scaffoldMinimalBuiltProject()
322
+ const env = { ...process.env }
323
+ delete env.PORT
324
+ try {
325
+ const result = spawnSync(process.execPath, [startScriptPath, '--root', dir, '--port', '3001'], {
326
+ encoding: 'utf8', timeout: 5000, env,
327
+ })
328
+ if (result.stderr.includes('overrides --port'))
329
+ throw new Error(`Should not warn when PORT is unset, got stderr: ${result.stderr}`)
330
+ } finally {
331
+ cleanupTmpDir(dir)
332
+ }
333
+ })
334
+
335
+ function cleanupTmpDir(dir) {
336
+ fsMod.rmSync(dir, { recursive: true, force: true })
337
+ }
338
+ }
234
339
 
235
340
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
236
341
  if (failed > 0) process.exit(1)
package/src/cli/dev.js CHANGED
@@ -217,12 +217,57 @@ async function triggerReload(label = 'File changed') {
217
217
  }, 200)
218
218
  }
219
219
 
220
+ // fs.watch's returned FSWatcher emits 'error' rather than throwing — with no
221
+ // listener, that error is unhandled and crashes the whole dev server (a real,
222
+ // confirmed failure: EMFILE from accumulated file descriptors, e.g. orphaned
223
+ // dev-server processes left running from earlier sessions, took down the
224
+ // process with a raw stack trace instead of a readable message). Wrap every
225
+ // fs.watch call so exhaustion degrades to a warning — hot reload stops
226
+ // working, but the dev server and the rest of the session stay up.
227
+ //
228
+ // EMFILE here isn't always this process's own fd usage — confirmed on a real
229
+ // machine where this fired with the dev server holding only ~20 fds total,
230
+ // while macOS's shared FSEvents/kqueue watcher infrastructure was under
231
+ // system-wide pressure from unrelated apps (VS Code/Electron windows, etc.).
232
+ // That kind of pressure is often transient, so retry once after a short
233
+ // delay before giving up for the rest of the session — recovers
234
+ // automatically if the spike clears, same "disabled until restart" fallback
235
+ // as before if it doesn't.
236
+ const WATCH_RETRY_DELAY_MS = 3000
237
+
238
+ function watchSafely(target, options, onChange, isRetry = false) {
239
+ try {
240
+ const watcher = fs.watch(target, options, onChange)
241
+ watcher.on('error', (err) => {
242
+ if ((err.code === 'EMFILE' || err.code === 'ENOSPC') && !isRetry) {
243
+ log.error(
244
+ `File watching hit ${err.code} for ${target} — retrying in ${WATCH_RETRY_DELAY_MS / 1000}s ` +
245
+ `in case it was a transient spike (another app's file watchers, etc.)...`
246
+ )
247
+ setTimeout(() => watchSafely(target, options, onChange, true), WATCH_RETRY_DELAY_MS)
248
+ return
249
+ }
250
+ if (err.code === 'EMFILE' || err.code === 'ENOSPC') {
251
+ log.error(
252
+ `File watching disabled for ${target} (${err.code}: too many open file watchers, retry also failed). ` +
253
+ `Hot reload won't work until you restart — check for other running dev servers/processes ` +
254
+ `holding file watchers open and close them, or raise your OS file descriptor limit.`
255
+ )
256
+ } else {
257
+ log.error(`File watcher error on ${target}: ${err.message}`)
258
+ }
259
+ })
260
+ } catch (err) {
261
+ log.error(`Could not start file watching for ${target}: ${err.message}`)
262
+ }
263
+ }
264
+
220
265
  // Watch src/ for changes to existing files (spec edits, component edits)
221
- fs.watch(path.join(ROOT, 'src'), { recursive: true }, () => triggerReload('File changed'))
266
+ watchSafely(path.join(ROOT, 'src'), { recursive: true }, () => triggerReload('File changed'))
222
267
 
223
268
  // Watch the store file — it lives in the project root, outside src/
224
269
  if (fs.existsSync(path.join(ROOT, 'pulse.store.js'))) {
225
- fs.watch(path.join(ROOT, 'pulse.store.js'), () => triggerReload('Store changed'))
270
+ watchSafely(path.join(ROOT, 'pulse.store.js'), {}, () => triggerReload('Store changed'))
226
271
  }
227
272
 
228
273
  // Also watch the pages directory explicitly so macOS reliably fires on new file creation.
@@ -231,7 +276,7 @@ if (fs.existsSync(path.join(ROOT, 'pulse.store.js'))) {
231
276
  // catches the "new entry added to this directory" event.
232
277
  const pagesDir = path.join(ROOT, 'src', 'pages')
233
278
  if (fs.existsSync(pagesDir)) {
234
- fs.watch(pagesDir, { recursive: false }, () => triggerReload('New page detected'))
279
+ watchSafely(pagesDir, { recursive: false }, () => triggerReload('New page detected'))
235
280
  }
236
281
 
237
282
  // Tiny script injected into every page — connects to SSE and reloads on change
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Pulse — Error journal diagnostics (shared logic)
3
+ *
4
+ * The single source of truth for reading and resolving .pulse/errors.json.
5
+ * Used by both pulse_diagnose/pulse_resolve_error (MCP tools, for an agent)
6
+ * and `pulse diagnose`/`pulse resolve-error` (CLI commands, for a human).
7
+ * Neither wraps the other. Returns plain data — no MCP protocol coupling.
8
+ */
9
+
10
+ import fs from 'fs'
11
+ import path from 'path'
12
+
13
+ function journalPathFor(root) {
14
+ return path.join(root, '.pulse', 'errors.json')
15
+ }
16
+
17
+ /**
18
+ * Read and filter the error journal.
19
+ *
20
+ * @param {string} root - project root
21
+ * @param {{ route?: string, includeResolved?: boolean }} [opts]
22
+ * @returns {{ exists: boolean, entries: object[], error: string|null }}
23
+ */
24
+ export function readJournal(root, { route, includeResolved = false } = {}) {
25
+ const journalPath = journalPathFor(root)
26
+ if (!fs.existsSync(journalPath)) {
27
+ return { exists: false, entries: [], error: null }
28
+ }
29
+
30
+ let entries
31
+ try {
32
+ entries = JSON.parse(fs.readFileSync(journalPath, 'utf8'))
33
+ } catch (err) {
34
+ return { exists: true, entries: [], error: `Error journal exists but could not be parsed: ${err.message}` }
35
+ }
36
+
37
+ let filtered = entries
38
+ if (route) filtered = filtered.filter(e => e.route === route)
39
+ if (!includeResolved) filtered = filtered.filter(e => !e.resolved)
40
+
41
+ return { exists: true, entries: filtered, error: null }
42
+ }
43
+
44
+ /**
45
+ * Format a journal read result as human-readable text.
46
+ */
47
+ export function formatJournal(result, { route, includeResolved = false } = {}) {
48
+ if (result.error) return result.error
49
+ if (!result.exists) {
50
+ return 'No errors recorded — .pulse/errors.json does not exist. Either nothing has thrown this session, or the dev server has not been started since it was added.'
51
+ }
52
+ if (result.entries.length === 0) {
53
+ return route
54
+ ? `No unresolved errors for route "${route}".${includeResolved ? '' : ' (Pass includeResolved: true to see resolved entries too.)'}`
55
+ : 'No unresolved errors recorded.'
56
+ }
57
+
58
+ const entries = result.entries
59
+ const lines = [`## ${entries.length} error${entries.length === 1 ? '' : 's'}${route ? ` on ${route}` : ''}\n`]
60
+ for (const e of entries.slice().reverse()) {
61
+ lines.push(`### ${e.phase} — ${e.route || '(unknown route)'} — ${e.ts}`)
62
+ lines.push(`\`${e.id}\`${e.resolved ? ' (resolved)' : ''}`)
63
+ lines.push(`\n${e.message}\n`)
64
+ if (e.stack) lines.push('```\n' + e.stack + '\n```\n')
65
+ }
66
+ return lines.join('\n')
67
+ }
68
+
69
+ /**
70
+ * Mark journal entries resolved by id or by route.
71
+ *
72
+ * @param {string} root - project root
73
+ * @param {{ id?: string, route?: string }} opts
74
+ * @returns {{ error: string|null, count: number }}
75
+ */
76
+ export function resolveEntries(root, { id, route } = {}) {
77
+ if (!id && !route) return { error: 'Error: pass either id or route.', count: 0 }
78
+
79
+ const journalPath = journalPathFor(root)
80
+ if (!fs.existsSync(journalPath)) return { error: 'No error journal exists — nothing to resolve.', count: 0 }
81
+
82
+ let entries
83
+ try {
84
+ entries = JSON.parse(fs.readFileSync(journalPath, 'utf8'))
85
+ } catch (err) {
86
+ return { error: `Error journal could not be parsed: ${err.message}`, count: 0 }
87
+ }
88
+
89
+ let count = 0
90
+ for (const e of entries) {
91
+ if (e.resolved) continue
92
+ if (id && e.id === id) { e.resolved = true; count++ }
93
+ if (route && e.route === route) { e.resolved = true; count++ }
94
+ }
95
+
96
+ fs.writeFileSync(journalPath, JSON.stringify(entries, null, 2), 'utf8')
97
+ return { error: null, count }
98
+ }
99
+
100
+ export function formatResolveResult(result) {
101
+ if (result.error) return result.error
102
+ return result.count > 0
103
+ ? `✓ Resolved ${result.count} entr${result.count === 1 ? 'y' : 'ies'}.`
104
+ : 'No matching unresolved entries found.'
105
+ }