@invisibleloop/pulse 0.20.11 → 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 +13 -6
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +162 -9
- 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 -350
- 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
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — Spec review (shared logic)
|
|
3
|
+
*
|
|
4
|
+
* The single source of truth behind pulse_review (MCP tool, for an agent)
|
|
5
|
+
* and `pulse review` (CLI command, for a human). Both modes — quick and
|
|
6
|
+
* full — return plain data here; formatting is split into an agent-facing
|
|
7
|
+
* formatter (instructional framing: "you are now a reviewer, fix every
|
|
8
|
+
* issue") and a human-facing one (a plain report), since the MCP tool's
|
|
9
|
+
* existing output talks directly to an agent in a way that reads wrong at
|
|
10
|
+
* a terminal.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs'
|
|
14
|
+
import path from 'path'
|
|
15
|
+
import os from 'os'
|
|
16
|
+
import { execFileSync } from 'child_process'
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Quick mode — lightweight structural checks, no validator subprocess, no
|
|
20
|
+
// checklist. Same regex checks pulse_review({ quick: true }) has always run.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} source - spec file source text
|
|
25
|
+
* @param {string|null} file - absolute path to the spec file, or null (content-only mode)
|
|
26
|
+
* @returns {Promise<{ issues: string[], warnings: string[], confirms: string[] }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function runQuickReview(source, file) {
|
|
29
|
+
const issues = []
|
|
30
|
+
const warnings = []
|
|
31
|
+
|
|
32
|
+
let renderedHtml = ''
|
|
33
|
+
if (file) {
|
|
34
|
+
try {
|
|
35
|
+
const mod = await import(`${file}?quick=${Date.now()}`)
|
|
36
|
+
const spec = mod.default
|
|
37
|
+
if (spec && typeof spec.view === 'function') {
|
|
38
|
+
renderedHtml = spec.view(spec.state || {}, {})
|
|
39
|
+
}
|
|
40
|
+
} catch { /* ignore — server data dependency */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (renderedHtml) {
|
|
44
|
+
if (!/<main[^>]*id=["']?main-content/.test(renderedHtml))
|
|
45
|
+
issues.push('✗ Missing `<main id="main-content">` landmark')
|
|
46
|
+
if (/<input[^>]*data-event/.test(renderedHtml))
|
|
47
|
+
issues.push('✗ `data-event` on `<input>` — destroys focus on every keystroke, use FormData in onStart instead')
|
|
48
|
+
if (/(className|htmlFor|onClick)=/.test(renderedHtml))
|
|
49
|
+
issues.push('✗ React patterns found (className / htmlFor / onClick) — use class, for, data-event')
|
|
50
|
+
if (/tabindex=["']?([1-9]\d*)/.test(renderedHtml))
|
|
51
|
+
issues.push('✗ Positive tabindex found — remove, reorder DOM instead')
|
|
52
|
+
const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
|
|
53
|
+
if (rawNavInSource > 0)
|
|
54
|
+
issues.push(`✗ Raw <nav> tag in spec source — nav() renders <nav> internally; wrapping it in another <nav> creates duplicate landmarks that fail Lighthouse accessibility. Remove the outer <nav>.`)
|
|
55
|
+
const viewBlockStripped = renderedHtml
|
|
56
|
+
.replace(/href=["']#[^"']*["']/g, 'href="#"')
|
|
57
|
+
.replace(/id=["'][^"']*["']/g, 'id=""')
|
|
58
|
+
.replace(/style="([^"]*)"/g, (_, v) => `style="${v.replace(/--[a-zA-Z][^:]*:[^;"]*/g, '')}"`)
|
|
59
|
+
if (/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped))
|
|
60
|
+
warnings.push('⚠ Possible hex colour in rendered HTML — use var(--ui-*) tokens')
|
|
61
|
+
|
|
62
|
+
if (!/component.free|creative\s+override/i.test(source)) {
|
|
63
|
+
if (/<[^>]+class="[^"]*\bhero\b/.test(renderedHtml) && !source.includes('hero('))
|
|
64
|
+
warnings.push('⚠ `.hero` class in HTML but no `hero()` component — use the component or declare creative override')
|
|
65
|
+
if (/<[^>]+class="[^"]*\bcard\b/.test(renderedHtml) && !source.includes('card('))
|
|
66
|
+
warnings.push('⚠ `.card` class in HTML but no `card()` component — use the component or declare creative override')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const hasActionsBlock = /^\s{0,4}actions\s*:/m.test(source)
|
|
71
|
+
if (hasActionsBlock && !source.includes('onError:'))
|
|
72
|
+
issues.push('✗ Action missing `onError` — required, will throw at runtime')
|
|
73
|
+
if (hasActionsBlock && !source.includes('onSuccess:'))
|
|
74
|
+
issues.push('✗ Action missing `onSuccess` — required')
|
|
75
|
+
if (source.includes('hydrate:'))
|
|
76
|
+
issues.push('✗ `hydrate` is set manually — remove it, the framework sets it automatically')
|
|
77
|
+
if (/meta\s*:\s*async/.test(source))
|
|
78
|
+
issues.push('✗ `meta` is an async function — `meta` must be a plain object; make individual fields async instead')
|
|
79
|
+
if (/state\.modalOpen|modalOpen\s*:/.test(source))
|
|
80
|
+
issues.push('✗ `modalOpen`-style state found — never conditionally render a `<dialog>`; always render it unconditionally and open it with `data-dialog-open`')
|
|
81
|
+
if (/\bsubmit\s*:\s*async/.test(source) && /<form[^>]*method=["']POST["']/i.test(source) && !source.includes('server.csrf'))
|
|
82
|
+
issues.push('✗ `<form method="POST">` on a page with `submit` but no `${server.csrf}` in the form — POSTs will get 403')
|
|
83
|
+
if (/_storeUpdate\s*:\s*(true|false|\d|['"`])/.test(source))
|
|
84
|
+
issues.push('✗ `_storeUpdate` must be an object merged into the store, e.g. `_storeUpdate: { settings: { theme } }` — not a primitive')
|
|
85
|
+
|
|
86
|
+
const confirms = []
|
|
87
|
+
if (renderedHtml) {
|
|
88
|
+
confirms.push(/<main[^>]*id=["']?main-content/.test(renderedHtml) ? '✓ `<main id="main-content">` landmark present' : null)
|
|
89
|
+
const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
|
|
90
|
+
if (rawNavInSource === 0) confirms.push('✓ No raw <nav> in spec source — nav() component handles landmarks correctly')
|
|
91
|
+
const headings = [...renderedHtml.matchAll(/<h([1-6])[^>]*>/g)].map(m => parseInt(m[1]))
|
|
92
|
+
if (headings.length > 0) {
|
|
93
|
+
const orderOk = headings.every((h, i) => i === 0 || h <= headings[i - 1] + 1)
|
|
94
|
+
confirms.push(orderOk ? `✓ Heading order correct (${headings.map(h => `h${h}`).join(' → ')})` : `⚠ Heading order may be skipping levels (${headings.map(h => `h${h}`).join(' → ')})`)
|
|
95
|
+
}
|
|
96
|
+
const interactiveWithoutLabel = [...renderedHtml.matchAll(/<button(?![^>]*aria-label)[^>]*>\s*<\/button>/g)]
|
|
97
|
+
confirms.push(interactiveWithoutLabel.length === 0 ? '✓ No empty buttons without aria-label detected' : `⚠ ${interactiveWithoutLabel.length} button(s) appear empty — check aria-label`)
|
|
98
|
+
const dataEvents = [...renderedHtml.matchAll(/data-event=/g)].length
|
|
99
|
+
if (dataEvents > 0) confirms.push(`✓ ${dataEvents} data-event binding(s) found`)
|
|
100
|
+
const creativeOverride = /component.free|creative\s+override/i.test(source)
|
|
101
|
+
if (creativeOverride) confirms.push('✓ Creative override declared — component pattern checks are advisory')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { issues, warnings, confirms: confirms.filter(Boolean) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatQuickReview(result, { agentFacing = false } = {}) {
|
|
108
|
+
const { issues, warnings, confirms } = result
|
|
109
|
+
const lines = ['## Quick review\n']
|
|
110
|
+
|
|
111
|
+
if (confirms.length > 0) {
|
|
112
|
+
lines.push('### Structural checks\n')
|
|
113
|
+
for (const c of confirms) lines.push(c)
|
|
114
|
+
lines.push('')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (issues.length === 0 && warnings.length === 0) {
|
|
118
|
+
lines.push(agentFacing
|
|
119
|
+
? '✓ No issues found. Run `pulse_validate` next, then `/verify`.'
|
|
120
|
+
: '✓ No issues found. Run `pulse validate` next, then a full review.')
|
|
121
|
+
} else {
|
|
122
|
+
if (issues.length) {
|
|
123
|
+
lines.push('### Fix before proceeding\n')
|
|
124
|
+
for (const i of issues) lines.push(i)
|
|
125
|
+
lines.push('')
|
|
126
|
+
}
|
|
127
|
+
if (warnings.length) {
|
|
128
|
+
lines.push('### Warnings (check these)\n')
|
|
129
|
+
for (const w of warnings) lines.push(w)
|
|
130
|
+
lines.push('')
|
|
131
|
+
}
|
|
132
|
+
lines.push(agentFacing
|
|
133
|
+
? '---\nFix issues, then run `pulse_validate` → `/verify --quick`.'
|
|
134
|
+
: '---\nFix issues, then run `pulse validate` again.')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return lines.join('\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Full mode — validator subprocess + rendered HTML + mechanized checklist.
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} source
|
|
146
|
+
* @param {string|null} file - absolute path, or null for content-only mode
|
|
147
|
+
* @returns {Promise<{
|
|
148
|
+
* validationResult: string,
|
|
149
|
+
* renderedHtml: string,
|
|
150
|
+
* renderNote: string,
|
|
151
|
+
* autoChecks: string[],
|
|
152
|
+
* }>}
|
|
153
|
+
*/
|
|
154
|
+
export async function runFullReview(source, file) {
|
|
155
|
+
// Run the validator in a child process (same as pulse_validate/pulse validate)
|
|
156
|
+
let validationResult = '(could not run validator)'
|
|
157
|
+
const validatorScript = new URL('../mcp/validate-worker.js', import.meta.url).pathname
|
|
158
|
+
if (file) {
|
|
159
|
+
try {
|
|
160
|
+
validationResult = execFileSync(process.execPath, [validatorScript, file], {
|
|
161
|
+
timeout: 10_000,
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
}).trim()
|
|
164
|
+
} catch (err) {
|
|
165
|
+
validationResult = err.stdout?.trim() || err.message
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
const tmpFile = path.join(os.tmpdir(), `pulse-review-${Date.now()}.js`)
|
|
169
|
+
fs.writeFileSync(tmpFile, source, 'utf8')
|
|
170
|
+
try {
|
|
171
|
+
validationResult = execFileSync(process.execPath, [validatorScript, tmpFile], {
|
|
172
|
+
timeout: 10_000,
|
|
173
|
+
encoding: 'utf8',
|
|
174
|
+
}).trim()
|
|
175
|
+
} catch (err) {
|
|
176
|
+
validationResult = err.stdout?.trim() || err.message
|
|
177
|
+
} finally {
|
|
178
|
+
try { fs.unlinkSync(tmpFile) } catch {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Try to render the view with initial state
|
|
183
|
+
let renderedHtml = ''
|
|
184
|
+
let renderNote = ''
|
|
185
|
+
try {
|
|
186
|
+
const mod = await import(`${file}?review=${Date.now()}`)
|
|
187
|
+
const spec = mod.default
|
|
188
|
+
if (spec && typeof spec.view === 'function') {
|
|
189
|
+
renderedHtml = spec.view(spec.state || {}, {})
|
|
190
|
+
} else if (spec && typeof spec.view === 'object') {
|
|
191
|
+
const segments = Object.entries(spec.view)
|
|
192
|
+
.map(([k, fn]) => `<!-- segment: ${k} -->\n${typeof fn === 'function' ? fn(spec.state || {}, {}) : ''}`)
|
|
193
|
+
.join('\n')
|
|
194
|
+
renderedHtml = segments
|
|
195
|
+
renderNote = '(streamed spec — segments rendered individually)'
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
renderNote = '(view could not be rendered — may depend on server data)'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const autoChecks = []
|
|
202
|
+
|
|
203
|
+
if (renderedHtml) {
|
|
204
|
+
const posTabindex = /tabindex=["']?([1-9]\d*)/.test(renderedHtml)
|
|
205
|
+
autoChecks.push(posTabindex ? '✗ **Positive tabindex found** — remove tabindex > 0, reorder DOM instead' : '✓ No positive tabindex')
|
|
206
|
+
|
|
207
|
+
const dataEventInput = /<input[^>]*data-event/.test(renderedHtml)
|
|
208
|
+
autoChecks.push(dataEventInput ? '✗ **data-event on <input>** — this destroys focus on every keystroke' : '✓ No data-event on text inputs')
|
|
209
|
+
|
|
210
|
+
const reactPatterns = /(className|htmlFor|onClick)=/.test(renderedHtml)
|
|
211
|
+
autoChecks.push(reactPatterns ? '✗ **React patterns found** — use class, for, data-event instead' : '✓ No React patterns (className/htmlFor/onClick)')
|
|
212
|
+
|
|
213
|
+
const htmlNoSvg = renderedHtml
|
|
214
|
+
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
|
215
|
+
.replace(/data:[^"']+/g, '')
|
|
216
|
+
.replace(/<[^>]+aria-hidden=["']true["'][^>]*>[\s\S]*?<\/[^>]+>/gi, '')
|
|
217
|
+
const emojiRegex = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}]/u
|
|
218
|
+
const hasEmoji = emojiRegex.test(htmlNoSvg)
|
|
219
|
+
autoChecks.push(hasEmoji ? '⚠ **Emoji in HTML** — use icon components or aria-label instead (verify: may be intentional icon)' : '✓ No emoji in view HTML')
|
|
220
|
+
|
|
221
|
+
const hasMain = /<main[^>]*id=["']?main-content/.test(renderedHtml)
|
|
222
|
+
autoChecks.push(hasMain ? '✓ <main id="main-content"> present' : '✗ **Missing main landmark** — add <main id="main-content">')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const viewBlock = source.slice(source.indexOf('view:'))
|
|
226
|
+
const viewBlockStripped = viewBlock
|
|
227
|
+
.replace(/href=["']#[^"']*["']/g, 'href="#"')
|
|
228
|
+
.replace(/id=["'][^"']*["']/g, 'id=""')
|
|
229
|
+
.replace(/\$\{[^}]*\}/g, '${…}')
|
|
230
|
+
const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
|
|
231
|
+
autoChecks.push(hexInView
|
|
232
|
+
? '⚠ **Possible hex colour in view** — use var(--ui-*) tokens only'
|
|
233
|
+
: '✓ No obvious hex colours in view')
|
|
234
|
+
|
|
235
|
+
if (file) {
|
|
236
|
+
const testFile = file.replace(/\.js$/, '.test.js')
|
|
237
|
+
let xssNote = ''
|
|
238
|
+
if (fs.existsSync(testFile)) {
|
|
239
|
+
const testSrc = fs.readFileSync(testFile, 'utf8')
|
|
240
|
+
const hasXssAssertion = testSrc.includes('<script>') || testSrc.includes('xss') || testSrc.includes('script>alert')
|
|
241
|
+
xssNote = hasXssAssertion
|
|
242
|
+
? '✓ XSS test assertion present'
|
|
243
|
+
: '⚠ **No XSS test found** — test file exists but has no `<script>alert` assertion. Add at least one test that passes a `\'<script>alert(1)</script>\'` string as a user-controlled input and asserts it does not appear unescaped in the output.'
|
|
244
|
+
} else {
|
|
245
|
+
xssNote = '○ No test file found — create one at ' + path.basename(testFile)
|
|
246
|
+
}
|
|
247
|
+
autoChecks.push(xssNote)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const hasModalOpenState = /state\.modalOpen|modalOpen\s*:/.test(source)
|
|
251
|
+
autoChecks.push(hasModalOpenState
|
|
252
|
+
? '✗ **`modalOpen`-style state found** — never conditionally render a `<dialog>`; always render it unconditionally and open it with `data-dialog-open`'
|
|
253
|
+
: '✓ No conditional-modal state pattern found')
|
|
254
|
+
|
|
255
|
+
const hasSubmit = /\bsubmit\s*:\s*async/.test(source)
|
|
256
|
+
if (hasSubmit) {
|
|
257
|
+
const hasPostForm = /<form[^>]*method=["']POST["']/i.test(source)
|
|
258
|
+
const hasCsrfToken = source.includes('server.csrf')
|
|
259
|
+
autoChecks.push(hasPostForm && !hasCsrfToken
|
|
260
|
+
? '✗ **`<form method="POST">` missing `${server.csrf}`** — POSTs will get 403. (Not applicable if `csrf: false` is deliberately set for a self-authenticated endpoint.)'
|
|
261
|
+
: '✓ CSRF token present in POST form (or no POST form found)')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const badStoreUpdate = /_storeUpdate\s*:\s*(true|false|\d|['"`])/.test(source)
|
|
265
|
+
autoChecks.push(badStoreUpdate
|
|
266
|
+
? '✗ **`_storeUpdate` is not an object** — it must be a partial store object, e.g. `_storeUpdate: { settings: { theme } }`'
|
|
267
|
+
: '✓ No malformed `_storeUpdate` found')
|
|
268
|
+
|
|
269
|
+
const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
|
|
270
|
+
autoChecks.push(creativeOverride
|
|
271
|
+
? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop + mobile) plus CLS 0.00 is the pass bar.'
|
|
272
|
+
: '○ No creative override declared — component checks apply')
|
|
273
|
+
|
|
274
|
+
return { validationResult, renderedHtml, renderNote, autoChecks }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The static checklist text — invariant reference material, not a mechanized
|
|
278
|
+
// check. Shared verbatim by both formatters; only the surrounding framing differs.
|
|
279
|
+
const CHECKLIST_MD = `### Structure
|
|
280
|
+
- [ ] \`route\` is set explicitly — not left to auto-discovery
|
|
281
|
+
- [ ] \`hydrate\` is NOT set manually — the framework injects it automatically. Remove it if present.
|
|
282
|
+
- [ ] \`state\` shape is consistent — no fields that flip between null/string/boolean
|
|
283
|
+
- [ ] \`meta.title\` is meaningful and unique to this page
|
|
284
|
+
- [ ] \`meta.description\` is a real description, not "Built with Pulse"
|
|
285
|
+
|
|
286
|
+
### Mutations & actions
|
|
287
|
+
- [ ] Every mutation returns a plain partial object — no side effects, no fetch, no DOM access
|
|
288
|
+
- [ ] \`constraints\` are used for bounds instead of conditional logic inside mutations
|
|
289
|
+
- [ ] \`disabled\` in the view matches the constraint bounds — but check: is it redundant with the constraint, or does it serve a UX purpose?
|
|
290
|
+
- [ ] Actions read user input from FormData in \`onStart\`, not from mirrored state
|
|
291
|
+
- [ ] \`onStart\` sets a loading status, \`onSuccess\`/\`onError\` resolve it
|
|
292
|
+
- [ ] A single \`status\` field is used instead of multiple boolean flags
|
|
293
|
+
|
|
294
|
+
### Components & HTML
|
|
295
|
+
- [ ] Components from the UI library are used — no hand-written \`<button>\`, \`<input>\`, \`<table>\` etc where a component exists
|
|
296
|
+
- [ ] **Component patterns check** — grep the rendered HTML for these class names:
|
|
297
|
+
- \`.hero\`, \`.-hero\`, \`__hero\` → should use \`hero()\` component
|
|
298
|
+
- \`.card\`, \`.product-card\`, \`.service-card\` → should use \`card()\` component
|
|
299
|
+
- Any two-column image + text layout → should use \`media()\` component
|
|
300
|
+
- \`.feature\`, \`.feature-card\` → should use \`feature()\` component
|
|
301
|
+
|
|
302
|
+
**Before flagging a pattern match**, ask: *can the \`hero()\` (or relevant) component actually reproduce this layout?* If the design uses full-viewport height, custom gradient glows, clamp-scaled display type, asymmetric layout, or other features the component doesn't support — that is a **creative override**, not a violation. Check the auto-checked items above: if "creative override declared" is shown, these checks are advisory only and Lighthouse is the pass bar.
|
|
303
|
+
|
|
304
|
+
If no creative override is declared and a component *could* reproduce the design, refactor to use it. Custom utility classes on top of components are fine (\`hero({ ... })\` + override CSS) — but do not write the entire structure from scratch when the component supports the layout.
|
|
305
|
+
|
|
306
|
+
- [ ] **Creative override** (fill in if applicable): *State the override reason here — e.g. "full-viewport gradient hero not achievable with hero() component"*. Confirm Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 before closing the review.
|
|
307
|
+
- [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
|
|
308
|
+
- [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
|
|
309
|
+
- [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
|
|
310
|
+
- [ ] No emoji in view HTML (decorative emoji without accessible text is a fail; emoji with \`aria-label\` or inside \`<span aria-hidden="true">\` is acceptable)
|
|
311
|
+
- [ ] **CTA must be wrapped in section/container** — grep the rendered HTML for \`<div class="ui-cta"\`. If it appears as a direct child of \`<main>\` or \`<div id="app">\` without a section or container wrapper, wrap it. CTA has no padding of its own.
|
|
312
|
+
|
|
313
|
+
### Accessibility
|
|
314
|
+
- [ ] \`<main id="main-content">\` is present
|
|
315
|
+
- [ ] Icon-only buttons have \`aria-label\`
|
|
316
|
+
- [ ] \`aria-live\` and \`aria-label\` are NOT on the same element
|
|
317
|
+
- [ ] Heading hierarchy is correct — no skipped levels, starts at h1
|
|
318
|
+
- [ ] Disabled state uses the \`disabled\` attribute, not just CSS or opacity
|
|
319
|
+
- [ ] **Keyboard focusability** — every element carrying \`data-event\`, \`data-store-event\`, \`data-dialog-open\`, or \`data-dialog-close\` is either a natively interactive element (\`button\`, \`a\`, \`input\`, \`select\`, \`textarea\`, \`summary\`) or has \`tabindex="0"\`. Scan the rendered HTML above — a \`<div>\`, \`<span>\`, \`<li>\`, or any other non-interactive tag with one of these attributes is a keyboard accessibility failure. Prefer \`<button>\` over a div + tabindex.
|
|
320
|
+
- [ ] **Purpose** — every interactive element has a clear accessible name. Buttons have visible text or \`aria-label\`. Links use descriptive text — flag generic labels ("click here", "here", "read more", "more"). Form inputs have an associated \`<label for="id">\` or \`aria-label\` — \`placeholder\` alone is not a label (it disappears on focus and is not read by all screen readers).
|
|
321
|
+
- [ ] **State** — interactive elements communicate their current state via ARIA:
|
|
322
|
+
- Toggle controls (open/close, show/hide, expand/collapse) have \`aria-expanded="true|false"\` or \`aria-pressed="true|false"\`
|
|
323
|
+
- While an action is running, the trigger button has \`aria-busy="true"\` or its visible label changes (e.g. "Saving…") — a spinner alone is not sufficient
|
|
324
|
+
- Active navigation items have \`aria-current="page"\`
|
|
325
|
+
- Selected items in a list, tab set, or option group have \`aria-selected="true"\`
|
|
326
|
+
- [ ] **Tab order** — scan the rendered HTML for these failures:
|
|
327
|
+
- No \`tabindex\` value greater than 0. \`tabindex="1"\` and above override the natural DOM order and almost always create a broken, unpredictable tab sequence. The only valid values are \`0\` (add to natural order) and \`-1\` (remove from order). If you find a positive tabindex, remove it and reorder the DOM instead.
|
|
328
|
+
- Off-screen or visually hidden interactive content is removed from the tab order. Elements that are hidden via CSS alone (e.g. \`opacity:0\`, \`visibility:hidden\` without \`display:none\`, off-canvas menus, collapsed panels) but remain in the DOM must have \`tabindex="-1"\` or \`inert\` so keyboard users cannot tab into invisible controls.
|
|
329
|
+
- DOM order matches the visual reading order. When CSS flexbox \`order\` or grid placement is used to visually reposition elements, the tab sequence follows the DOM — not the visual layout. Ensure the DOM is authored in the order a sighted user would read and interact with the page.
|
|
330
|
+
|
|
331
|
+
### Defensive coding
|
|
332
|
+
- [ ] Any \`fetch\` in actions or server fetchers checks \`res.ok\` before calling \`.json()\`
|
|
333
|
+
- [ ] Fetch errors use the safe pattern — NOT \`throw new Error(await res.text())\` which exposes raw HTML in toasts:
|
|
334
|
+
\`\`\`js
|
|
335
|
+
if (!res.ok) {
|
|
336
|
+
let message = \`Request failed: \${res.status}\`
|
|
337
|
+
try { const j = await res.json(); message = j.message || j.error || message } catch {}
|
|
338
|
+
throw new Error(message)
|
|
339
|
+
}
|
|
340
|
+
\`\`\`
|
|
341
|
+
- [ ] Optional chaining used for any data from external sources
|
|
342
|
+
- [ ] URL params validated before use
|
|
343
|
+
- [ ] \`onViewError\` defined if the view could crash on bad or missing data`
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Format the full review result.
|
|
347
|
+
*
|
|
348
|
+
* @param {{ validationResult, renderedHtml, renderNote, autoChecks }} result
|
|
349
|
+
* @param {{ source: string, file: string|null, agentFacing?: boolean }} opts
|
|
350
|
+
*/
|
|
351
|
+
export function formatFullReview(result, { source, file, agentFacing = false }) {
|
|
352
|
+
const { validationResult, renderNote, autoChecks } = result
|
|
353
|
+
|
|
354
|
+
const intro = agentFacing
|
|
355
|
+
? `# Pulse Code Review\n\nYou are now a **senior code reviewer**. Read the spec, find every problem, and fix them all before reporting back to the user.`
|
|
356
|
+
: `# Pulse Code Review\n\nSpec: ${file || '(content-only mode)'}`
|
|
357
|
+
|
|
358
|
+
const specDump = validationResult.includes('✓') ? '' : `\n## Spec source (validation failed — showing for debugging)\n\n\`\`\`js\n${source}\n\`\`\`\n`
|
|
359
|
+
const renderErrorDump = renderNote.includes('could not') ? `\n## Render error\n\n${renderNote}\n\n\`\`\`js\n${source}\n\`\`\`\n` : ''
|
|
360
|
+
|
|
361
|
+
const workOrder = agentFacing
|
|
362
|
+
? 'Work through every item. Fix anything that fails. Refer to the spec source at ' + file + ' as needed — do not ask me to paste it.'
|
|
363
|
+
: 'Work through every item and fix anything that fails.'
|
|
364
|
+
|
|
365
|
+
const closing = agentFacing
|
|
366
|
+
? '\nFix every issue you find. Then confirm what was changed.\n\n**After confirming fixes: you are back in builder mode. Continue to the verification workflow — navigate to the page in the browser, take a screenshot, run Lighthouse desktop audit, run Lighthouse mobile audit. Do not stop at the review.**'
|
|
367
|
+
: '\nFix every issue found above, then re-run this review.'
|
|
368
|
+
|
|
369
|
+
return `${intro}
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## Validator output
|
|
374
|
+
|
|
375
|
+
${validationResult}
|
|
376
|
+
${specDump}${renderErrorDump}
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## Auto-checked items
|
|
380
|
+
|
|
381
|
+
${autoChecks.join('\n')}
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Review checklist
|
|
386
|
+
|
|
387
|
+
${workOrder}
|
|
388
|
+
|
|
389
|
+
${CHECKLIST_MD}
|
|
390
|
+
${closing}`
|
|
391
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — review.js tests
|
|
3
|
+
* run: node src/cli/review.test.js
|
|
4
|
+
*
|
|
5
|
+
* Shared logic behind pulse_review (MCP tool) and `pulse review` (CLI
|
|
6
|
+
* command). The MCP tool's output talks directly to an agent ("you are now
|
|
7
|
+
* a senior code reviewer") — these tests specifically confirm that framing
|
|
8
|
+
* does NOT leak into the CLI's human-facing output, since that was the main
|
|
9
|
+
* risk in this extraction.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import os from 'node:os'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { runQuickReview, formatQuickReview, runFullReview, formatFullReview } from './review.js'
|
|
18
|
+
|
|
19
|
+
function tmpProject() {
|
|
20
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-review-test-'))
|
|
21
|
+
fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
|
|
22
|
+
return root
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeSpec(root, name, content) {
|
|
26
|
+
const file = path.join(root, 'src', 'pages', name)
|
|
27
|
+
fs.writeFileSync(file, content)
|
|
28
|
+
return file
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Quick mode ────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
test('quick review flags a modalOpen state pattern', async () => {
|
|
34
|
+
const root = tmpProject()
|
|
35
|
+
const file = writeSpec(root, 'bad.js',
|
|
36
|
+
`export default {\n route: '/x',\n state: { modalOpen: false },\n view: (state) => \`<main id="main-content">\${state.modalOpen ? 'open' : ''}</main>\`,\n}`)
|
|
37
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
38
|
+
const result = await runQuickReview(source, file)
|
|
39
|
+
assert.equal(result.issues.some(i => i.includes('modalOpen')), true)
|
|
40
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('quick review flags a hex colour in rendered HTML', async () => {
|
|
44
|
+
const root = tmpProject()
|
|
45
|
+
const file = writeSpec(root, 'bad.js',
|
|
46
|
+
`export default {\n route: '/x',\n state: {},\n view: () => '<main id="main-content"><h1 style="color:#ff0000">hi</h1></main>',\n}`)
|
|
47
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
48
|
+
const result = await runQuickReview(source, file)
|
|
49
|
+
assert.equal(result.warnings.some(w => w.includes('hex')), true)
|
|
50
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('quick review reports no issues for a clean spec', async () => {
|
|
54
|
+
const root = tmpProject()
|
|
55
|
+
const file = writeSpec(root, 'good.js',
|
|
56
|
+
`export default {\n route: '/x',\n state: {},\n view: () => '<main id="main-content"><h1>hi</h1></main>',\n}`)
|
|
57
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
58
|
+
const result = await runQuickReview(source, file)
|
|
59
|
+
assert.equal(result.issues.length, 0)
|
|
60
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('formatQuickReview agentFacing:true tells the agent to run pulse_validate then /verify', () => {
|
|
64
|
+
const output = formatQuickReview({ issues: [], warnings: [], confirms: [] }, { agentFacing: true })
|
|
65
|
+
assert.match(output, /pulse_validate/)
|
|
66
|
+
assert.match(output, /\/verify/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('formatQuickReview agentFacing:false (CLI default) uses plain human phrasing', () => {
|
|
70
|
+
const output = formatQuickReview({ issues: [], warnings: [], confirms: [] }, { agentFacing: false })
|
|
71
|
+
assert.match(output, /pulse validate/)
|
|
72
|
+
assert.doesNotMatch(output, /pulse_validate/, 'CLI output must not reference the MCP tool name pulse_validate')
|
|
73
|
+
assert.doesNotMatch(output, /\/verify/, 'CLI output must not reference the /verify slash command')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ── Full mode ─────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
test('full review runs the schema validator and reports auto-checks', async () => {
|
|
79
|
+
const root = tmpProject()
|
|
80
|
+
const file = writeSpec(root, 'good.js',
|
|
81
|
+
`export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content"><h1>hi</h1></main>',\n}`)
|
|
82
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
83
|
+
const result = await runFullReview(source, file)
|
|
84
|
+
assert.match(result.validationResult, /Valid ✓/)
|
|
85
|
+
assert.equal(result.autoChecks.length > 0, true)
|
|
86
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('full review auto-checks flag modalOpen and malformed _storeUpdate', async () => {
|
|
90
|
+
const root = tmpProject()
|
|
91
|
+
const file = writeSpec(root, 'bad.js',
|
|
92
|
+
`export default {\n route: '/x',\n state: { modalOpen: false },\n actions: {\n save: {\n onSuccess: (state) => ({ _storeUpdate: true }),\n onError: (state, err) => ({ error: err.message }),\n run: async () => {},\n },\n },\n view: () => '<main id="main-content">x</main>',\n}`)
|
|
93
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
94
|
+
const result = await runFullReview(source, file)
|
|
95
|
+
assert.equal(result.autoChecks.some(c => c.includes('modalOpen')), true)
|
|
96
|
+
assert.equal(result.autoChecks.some(c => c.includes('_storeUpdate')), true)
|
|
97
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('formatFullReview agentFacing:true opens with "you are now a senior code reviewer"', async () => {
|
|
101
|
+
const root = tmpProject()
|
|
102
|
+
const file = writeSpec(root, 'good.js',
|
|
103
|
+
`export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
|
|
104
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
105
|
+
const result = await runFullReview(source, file)
|
|
106
|
+
const output = formatFullReview(result, { source, file, agentFacing: true })
|
|
107
|
+
assert.match(output, /you are now a \*\*senior code reviewer\*\*/i)
|
|
108
|
+
assert.match(output, /Continue to the verification workflow/)
|
|
109
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('formatFullReview agentFacing:false (CLI default) never says "you are now a reviewer" or references agent-only workflow', async () => {
|
|
113
|
+
const root = tmpProject()
|
|
114
|
+
const file = writeSpec(root, 'good.js',
|
|
115
|
+
`export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
|
|
116
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
117
|
+
const result = await runFullReview(source, file)
|
|
118
|
+
const output = formatFullReview(result, { source, file, agentFacing: false })
|
|
119
|
+
assert.doesNotMatch(output, /you are now a/i,
|
|
120
|
+
'CLI output must not carry the agent-directive "you are now a reviewer" framing')
|
|
121
|
+
assert.doesNotMatch(output, /Continue to the verification workflow/,
|
|
122
|
+
'CLI output must not tell a human to "continue to the verification workflow" — that instruction is for an agent session')
|
|
123
|
+
assert.match(output, new RegExp(`Spec: ${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
|
124
|
+
'CLI output should plainly name the file being reviewed')
|
|
125
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('the checklist reference material is identical in both agentFacing modes', async () => {
|
|
129
|
+
const root = tmpProject()
|
|
130
|
+
const file = writeSpec(root, 'good.js',
|
|
131
|
+
`export default {\n route: '/x',\n meta: { title: 't', description: 'd', theme: 'dark' },\n view: () => '<main id="main-content">x</main>',\n}`)
|
|
132
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
133
|
+
const result = await runFullReview(source, file)
|
|
134
|
+
const agentOutput = formatFullReview(result, { source, file, agentFacing: true })
|
|
135
|
+
const humanOutput = formatFullReview(result, { source, file, agentFacing: false })
|
|
136
|
+
// Both must contain the same checklist section headers — only the framing
|
|
137
|
+
// around them (intro/closing) should differ, never the checklist itself.
|
|
138
|
+
for (const heading of ['### Structure', '### Mutations & actions', '### Components & HTML', '### Accessibility', '### Defensive coding']) {
|
|
139
|
+
assert.equal(agentOutput.includes(heading), true, `agent output missing ${heading}`)
|
|
140
|
+
assert.equal(humanOutput.includes(heading), true, `human output missing ${heading}`)
|
|
141
|
+
}
|
|
142
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
143
|
+
})
|
package/src/cli/scaffold.js
CHANGED
|
@@ -38,8 +38,11 @@ export async function scaffold(targetDir, options = {}) {
|
|
|
38
38
|
dev: 'pulse dev',
|
|
39
39
|
build: 'pulse build',
|
|
40
40
|
start: 'pulse start',
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// Also picks up a root-level pulse.store.test.js — pulse.store.js itself
|
|
42
|
+
// lives at the project root (not under src/), so its test does too, and
|
|
43
|
+
// the src/-only glob used to silently exclude it (found via dogfooding).
|
|
44
|
+
test: '(find src -name "*.test.js"; find . -maxdepth 1 -name "*.test.js") | xargs node --test',
|
|
45
|
+
'test:coverage': '(find src -name "*.test.js"; find . -maxdepth 1 -name "*.test.js") | xargs node --test --experimental-test-coverage --test-coverage-include=\'src/pages/!(*.test).js\'',
|
|
43
46
|
},
|
|
44
47
|
engines: {
|
|
45
48
|
node: '>=22',
|
|
@@ -52,7 +55,8 @@ export async function scaffold(targetDir, options = {}) {
|
|
|
52
55
|
// pulse.config.js — write agent if specified, otherwise leave as commented hint
|
|
53
56
|
write(targetDir, 'pulse.config.js',
|
|
54
57
|
`export default {
|
|
55
|
-
${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` agent: '${agent}',\n` : ` // agent: 'copilot', // uncomment to use GitHub Copilot CLI instead of Claude\n`}
|
|
58
|
+
${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` agent: '${agent}',\n` : ` // agent: 'copilot', // uncomment to use GitHub Copilot CLI instead of Claude\n`} // design: 'freeform', // uncomment for hand-rolled HTML/CSS on every page — no src/ui/* components, no intake/sketch
|
|
59
|
+
}
|
|
56
60
|
`)
|
|
57
61
|
|
|
58
62
|
// Home page + tests — working counter proves the app runs
|
|
@@ -452,7 +456,8 @@ public/intake/ ← drop design reference images here before starting (not com
|
|
|
452
456
|
|
|
453
457
|
1. Run \`pulse_list_structure\` to see what already exists
|
|
454
458
|
2. Read \`pulse://guide\` from MCP — the complete reference for spec format, components, verification workflow, CSS rules, and patterns
|
|
455
|
-
3. If
|
|
459
|
+
3. **If the user is requesting a new page or site, ask before planning anything:** "Do you have any design inspiration — a site you love, a screenshot, a mood board image? Drop images into \`public/intake/\` or paste a URL and I will extract the design intent before we start." This is not optional — do not present a plan or write code first. (A name mentioned in passing with nothing to look at — no URL, no image, no site you actually know — isn't inspiration to extract; treat it as color for tone and move on to \`pulse_intake\` with what the user actually described.)
|
|
460
|
+
4. If images are present in \`public/intake/\`, call \`pulse_extract_inspiration\` on each before \`pulse_intake\` — the session hook will remind you if you forget
|
|
456
461
|
|
|
457
462
|
The MCP guide is the single source of truth. Follow it for all technical decisions, component usage, and the mandatory verification workflow.
|
|
458
463
|
|