@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.
- package/package.json +6 -5
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +26 -0
- package/scripts/release-version.js +17 -8
- package/scripts/strip-server.test.js +60 -0
- package/src/agent/checklist.md +8 -12
- package/src/agent/commands/pulse-start.md +2 -0
- package/src/agent/commands/verify.md +30 -8
- package/src/agent/guide-components.md +7 -25
- package/src/agent/guide-routing.md +0 -12
- package/src/agent/guide-server.md +102 -1
- package/src/agent/guide-spec.md +8 -1
- package/src/agent/guide-styles.md +23 -0
- package/src/agent/identity.md +9 -0
- package/src/agent/workflow.md +4 -23
- package/src/cli/check-bundles.js +113 -0
- package/src/cli/check-bundles.test.js +137 -0
- package/src/cli/cli.test.js +105 -0
- package/src/cli/dev.js +48 -3
- package/src/cli/diagnose.js +105 -0
- package/src/cli/diagnose.test.js +124 -0
- package/src/cli/discover.js +9 -2
- package/src/cli/index.js +171 -9
- package/src/cli/process-utils.js +57 -0
- package/src/cli/process-utils.test.js +114 -0
- package/src/cli/review.js +391 -0
- package/src/cli/review.test.js +143 -0
- package/src/cli/scaffold.js +9 -4
- package/src/cli/stamp.js +102 -0
- package/src/cli/stamp.test.js +149 -0
- package/src/cli/start.js +15 -2
- package/src/cli/validate.js +184 -0
- package/src/cli/validate.test.js +153 -0
- package/src/mcp/mcp.test.js +261 -30
- package/src/mcp/server.js +207 -515
- package/src/mcp/validate-worker.js +18 -1
- package/src/runtime/index.js +144 -11
- package/src/runtime/runtime.test.js +199 -0
- package/src/runtime/ssr.js +14 -7
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +163 -10
- package/src/server/server.test.js +225 -2
- package/src/spec/schema.js +1 -1
- package/src/ui/index.js +0 -5
- package/src/ui/ui.test.js +0 -75
- package/types/ui.d.ts +0 -21
- package/.claude/commands/build-page.md +0 -63
- package/.claude/commands/new-doc-page.md +0 -45
- package/.claude/commands/pulse-start.md +0 -11
- package/.claude/commands/pulse-stop.md +0 -5
- package/.claude/commands/verify.md +0 -118
- package/.claude/pulse-checklist.md +0 -189
- package/.github/workflows/ci.yml +0 -25
- package/.github/workflows/publish.yml +0 -45
- package/.pulse/load-reports/home/1773432711417.json +0 -22
- package/.pulse-verified +0 -1
- package/AGENTS.md +0 -697
- package/CLAUDE.md +0 -697
- package/README.md +0 -95
- package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
- package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
- package/benchmark/public/dist/manifest.json +0 -5
- package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
- package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/benchmark/results/2026-03-27-09-20.json +0 -188
- package/benchmark/results/2026-03-27-09-21.json +0 -188
- package/benchmark/results/2026-03-27-09-29.json +0 -188
- package/benchmark/results/2026-03-27-10-14.json +0 -188
- package/benchmark/results/2026-03-27-10-18.json +0 -232
- package/benchmark/src/pages/counter.js +0 -37
- package/benchmark/src/pages/home.js +0 -21
- package/docs/.claude/commands/pulse-build.md +0 -7
- package/docs/.claude/commands/pulse-dev.md +0 -7
- package/docs/.claude/commands/pulse-start.md +0 -9
- package/docs/.claude/commands/pulse-stop.md +0 -5
- package/docs/.claude/commands/verify.md +0 -84
- package/docs/.claude/coverage-check.js +0 -196
- package/docs/.claude/pulse-checklist.md +0 -189
- package/docs/public/.pulse-ui-version +0 -1
- package/docs/public/andypixel.css +0 -237
- package/docs/public/bookworms.css +0 -422
- package/docs/public/coutumes.css +0 -561
- package/docs/public/docs.css +0 -2388
- package/docs/public/favicon.svg +0 -4
- package/docs/public/home-brut.css +0 -982
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/img/heic1501a.jpg +0 -0
- package/docs/public/img/placeholder-landscape.svg +0 -18
- package/docs/public/img/placeholder-square.svg +0 -14
- package/docs/public/img/placeholder-tall.svg +0 -22
- package/docs/public/journey.css +0 -528
- package/docs/public/menu.js +0 -327
- package/docs/public/pulse-ui.css +0 -3028
- package/docs/public/pulse-ui.js +0 -257
- package/docs/public/retrowheels.css +0 -425
- package/docs/public/shop.css +0 -269
- package/docs/public/sparkys.css +0 -382
- package/docs/public/suzukibento.css +0 -517
- package/docs/public/swish.css +0 -394
- package/docs/public/theme.css +0 -52
- package/docs/public/themes/lumio.css +0 -50
- package/docs/public/themes/observatory.css +0 -104
- package/docs/server.js +0 -161
- package/docs/src/lib/component-page.js +0 -49
- package/docs/src/lib/highlight.js +0 -2
- package/docs/src/lib/layout.js +0 -190
- package/docs/src/lib/metrics-store.js +0 -6
- package/docs/src/lib/nav.js +0 -171
- package/docs/src/lib/stats.js +0 -81
- package/docs/src/pages/accessibility.js +0 -157
- package/docs/src/pages/actions.js +0 -194
- package/docs/src/pages/auth.js +0 -176
- package/docs/src/pages/caching.js +0 -95
- package/docs/src/pages/components/accordion.js +0 -48
- package/docs/src/pages/components/alert.js +0 -35
- package/docs/src/pages/components/app-badge.js +0 -41
- package/docs/src/pages/components/avatar.js +0 -35
- package/docs/src/pages/components/badge.js +0 -36
- package/docs/src/pages/components/breadcrumbs.js +0 -94
- package/docs/src/pages/components/button.js +0 -84
- package/docs/src/pages/components/card.js +0 -225
- package/docs/src/pages/components/charts.js +0 -283
- package/docs/src/pages/components/checkbox.js +0 -129
- package/docs/src/pages/components/cluster.js +0 -47
- package/docs/src/pages/components/container.js +0 -40
- package/docs/src/pages/components/cta.js +0 -54
- package/docs/src/pages/components/divider.js +0 -37
- package/docs/src/pages/components/empty.js +0 -36
- package/docs/src/pages/components/feature.js +0 -60
- package/docs/src/pages/components/fieldset.js +0 -65
- package/docs/src/pages/components/fileupload.js +0 -128
- package/docs/src/pages/components/footer.js +0 -58
- package/docs/src/pages/components/grid.js +0 -169
- package/docs/src/pages/components/heading.js +0 -107
- package/docs/src/pages/components/hero.js +0 -123
- package/docs/src/pages/components/icons.js +0 -410
- package/docs/src/pages/components/image.js +0 -71
- package/docs/src/pages/components/input.js +0 -51
- package/docs/src/pages/components/media.js +0 -51
- package/docs/src/pages/components/modal.js +0 -111
- package/docs/src/pages/components/nav.js +0 -136
- package/docs/src/pages/components/phone-frame.js +0 -74
- package/docs/src/pages/components/progress.js +0 -102
- package/docs/src/pages/components/prose.js +0 -111
- package/docs/src/pages/components/pullquote.js +0 -71
- package/docs/src/pages/components/radio.js +0 -194
- package/docs/src/pages/components/rating.js +0 -106
- package/docs/src/pages/components/search.js +0 -61
- package/docs/src/pages/components/section.js +0 -98
- package/docs/src/pages/components/segmented.js +0 -121
- package/docs/src/pages/components/select.js +0 -45
- package/docs/src/pages/components/slider.js +0 -115
- package/docs/src/pages/components/spinner.js +0 -73
- package/docs/src/pages/components/stack.js +0 -48
- package/docs/src/pages/components/stat.js +0 -55
- package/docs/src/pages/components/stepper.js +0 -66
- package/docs/src/pages/components/table.js +0 -45
- package/docs/src/pages/components/textarea.js +0 -31
- package/docs/src/pages/components/toggle.js +0 -84
- package/docs/src/pages/components/tooltip.js +0 -48
- package/docs/src/pages/components.js +0 -200
- package/docs/src/pages/config.js +0 -193
- package/docs/src/pages/constraints.js +0 -100
- package/docs/src/pages/deploy.js +0 -233
- package/docs/src/pages/error-pages.js +0 -99
- package/docs/src/pages/extending.js +0 -198
- package/docs/src/pages/faq.js +0 -99
- package/docs/src/pages/forms.js +0 -129
- package/docs/src/pages/getting-started.js +0 -147
- package/docs/src/pages/guard.js +0 -120
- package/docs/src/pages/home.js +0 -345
- package/docs/src/pages/how-it-works.js +0 -244
- package/docs/src/pages/hydration.js +0 -108
- package/docs/src/pages/images.js +0 -120
- package/docs/src/pages/markdown.js +0 -156
- package/docs/src/pages/meta.js +0 -193
- package/docs/src/pages/mutations.js +0 -107
- package/docs/src/pages/navigation.js +0 -85
- package/docs/src/pages/performance.js +0 -157
- package/docs/src/pages/persist.js +0 -89
- package/docs/src/pages/project-structure.js +0 -91
- package/docs/src/pages/prompt-examples.js +0 -186
- package/docs/src/pages/raw-responses.js +0 -122
- package/docs/src/pages/routing.js +0 -120
- package/docs/src/pages/server-api.js +0 -291
- package/docs/src/pages/server-data.js +0 -203
- package/docs/src/pages/slash-commands.js +0 -55
- package/docs/src/pages/spec.js +0 -207
- package/docs/src/pages/state.js +0 -102
- package/docs/src/pages/store.js +0 -203
- package/docs/src/pages/streaming.js +0 -107
- package/docs/src/pages/stripe.js +0 -194
- package/docs/src/pages/supabase.js +0 -324
- package/docs/src/pages/templates/andypixel.js +0 -260
- package/docs/src/pages/templates/blog-post.js +0 -226
- package/docs/src/pages/templates/bookworms.js +0 -227
- package/docs/src/pages/templates/coutumes.js +0 -237
- package/docs/src/pages/templates/journey.js +0 -186
- package/docs/src/pages/templates/mobile-app.js +0 -371
- package/docs/src/pages/templates/retrowheels.js +0 -355
- package/docs/src/pages/templates/shop.js +0 -268
- package/docs/src/pages/templates/sparkys.js +0 -264
- package/docs/src/pages/templates/suzukibento.js +0 -200
- package/docs/src/pages/templates/swish.js +0 -313
- package/docs/src/pages/templates.js +0 -534
- package/docs/src/pages/testing.js +0 -199
- package/docs/src/pages/validation.js +0 -138
- package/examples/.claude/commands/pulse-build.md +0 -7
- package/examples/.claude/commands/pulse-dev.md +0 -7
- package/examples/.claude/commands/pulse-start.md +0 -9
- package/examples/.claude/commands/pulse-stop.md +0 -5
- package/examples/.claude/commands/verify.md +0 -84
- package/examples/.claude/coverage-check.js +0 -196
- package/examples/.claude/pulse-checklist.md +0 -189
- package/examples/andypixel.css +0 -237
- package/examples/andypixel.js +0 -258
- package/examples/bookworms.css +0 -422
- package/examples/bookworms.js +0 -225
- package/examples/contact.js +0 -166
- package/examples/counter.js +0 -94
- package/examples/dev.server.js +0 -110
- package/examples/examples.test.js +0 -394
- package/examples/journey.css +0 -528
- package/examples/journey.js +0 -186
- package/examples/pricing.js +0 -262
- package/examples/products.js +0 -190
- package/examples/public/.pulse-ui-version +0 -1
- package/examples/public/pulse-ui.css +0 -3028
- package/examples/public/pulse-ui.js +0 -257
- package/examples/quiz.js +0 -208
- package/examples/retrowheels.css +0 -425
- package/examples/retrowheels.js +0 -353
- package/examples/shared.js +0 -84
- package/examples/suzukibento.css +0 -513
- package/examples/suzukibento.js +0 -200
- package/examples/swish.css +0 -394
- package/examples/swish.js +0 -311
- package/examples/todos.js +0 -162
- package/src/ui/fieldset.js +0 -37
- package/src/ui/phone-frame.js +0 -40
- package/src/ui/search.js +0 -78
- package/src/ui/segmented.js +0 -50
- package/src/ui/tooltip.js +0 -28
package/src/agent/identity.md
CHANGED
|
@@ -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:
|
package/src/agent/workflow.md
CHANGED
|
@@ -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.
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
})
|
package/src/cli/cli.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|