@invisibleloop/pulse 0.20.0 → 0.20.2

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/scripts/build.js CHANGED
@@ -34,7 +34,10 @@ import { extractUsedClasses, purgeCss, minifyCss } from './css-purge.js'
34
34
  * from being shipped to and evaluated in the browser
35
35
  * - avoids bundling errors when server imports use Node-only modules
36
36
  */
37
- const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
37
+ // NOTE: spec.submit is also server-only but must NOT be listed here — the key
38
+ // scanner is line-anchored at any nesting depth, and `submit:` is the most
39
+ // common ACTION name (actions: { submit: {…} }), which would get stripped too.
40
+ const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
38
41
 
39
42
  /**
40
43
  * Pulse subpath exports that are server-only (use Node built-ins).
@@ -42,7 +45,7 @@ const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentTy
42
45
  * top-level variable declarations whose right-hand side calls the imported names.
43
46
  * All `node:*` built-ins are also stripped automatically via the import regex.
44
47
  */
45
- const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
48
+ const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
46
49
 
47
50
  /**
48
51
  * Strip all server-only property declarations from a spec source string.
@@ -351,17 +354,22 @@ function scanTemplateLiteral(src, pos) {
351
354
 
352
355
  /**
353
356
  * esbuild plugin — strips server-only spec properties before bundling.
354
- * Only transforms files under src/pages/ to avoid touching runtime/server code.
357
+ * Transforms files under src/pages/ plus the global store file (pulse.store.js
358
+ * carries server fetchers that must never reach the browser) — never touches
359
+ * runtime/server code.
355
360
  *
356
- * @param {string} pagesDir Absolute path to src/pages/
361
+ * @param {string} pagesDir Absolute path to src/pages/
362
+ * @param {string|null} storePath Absolute path to pulse.store.js, if it exists
357
363
  */
358
- function createStripServerPlugin(pagesDir) {
364
+ function createStripServerPlugin(pagesDir, storePath = null) {
359
365
  return {
360
366
  name: 'pulse-strip-server',
361
367
  setup(build) {
362
368
  build.onLoad({ filter: /\.js$/ }, (args) => {
363
- if (!args.path.startsWith(pagesDir + path.sep) &&
364
- args.path !== pagesDir.replace(/\/$/, '') + '.js') return undefined
369
+ const isPage = args.path.startsWith(pagesDir + path.sep) ||
370
+ args.path === pagesDir.replace(/\/$/, '') + '.js'
371
+ const isStore = storePath && args.path === storePath
372
+ if (!isPage && !isStore) return undefined
365
373
 
366
374
  const source = fs.readFileSync(args.path, 'utf8')
367
375
  const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
@@ -435,6 +443,11 @@ const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pat
435
443
 
436
444
  const PAGES_DIR = path.join(ROOT, 'src', 'pages')
437
445
 
446
+ // Global store — bundled into boot files (server fetchers stripped) when present
447
+ const STORE_PATH = fs.existsSync(path.join(ROOT, 'pulse.store.js'))
448
+ ? path.join(ROOT, 'pulse.store.js')
449
+ : null
450
+
438
451
  const bootstrapFiles = pages.flatMap(({ filePath }) => {
439
452
  // Strip server-only keys/imports to check what's left for the client.
440
453
  // Skip specs with no view — these are API routes or raw content specs
@@ -453,15 +466,22 @@ const bootstrapFiles = pages.flatMap(({ filePath }) => {
453
466
  const relRuntime = path.relative(TMP_DIR, RUNTIME_PATH)
454
467
  const relNavigate = path.relative(TMP_DIR, NAVIGATE_PATH)
455
468
 
469
+ // Global store — when pulse.store.js exists, every boot bundle imports it and
470
+ // passes it to mount() so client store mutations register in production.
471
+ // The strip plugin removes its server fetchers from the browser bundle;
472
+ // esbuild's shared-chunk splitting keeps it from being duplicated per page.
473
+ const storeImport = STORE_PATH ? `\nimport store from '${path.relative(TMP_DIR, STORE_PATH)}'` : ''
474
+ const mountOpts = STORE_PATH ? `{ ssr: true, store }` : `{ ssr: true }`
475
+
456
476
  fs.writeFileSync(bootstrapPath, `\
457
477
  import spec from '${relSpec}'
458
478
  import { mount } from '${relRuntime}'
459
- import { initNavigation } from '${relNavigate}'
479
+ import { initNavigation } from '${relNavigate}'${storeImport}
460
480
 
461
481
  const root = document.getElementById('pulse-root')
462
482
  if (root && !root.dataset.pulseMounted) {
463
483
  root.dataset.pulseMounted = '1'
464
- mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
484
+ mount(spec, root, window.__PULSE_SERVER__ || {}, ${mountOpts})
465
485
  initNavigation(root, mount)
466
486
  }
467
487
 
@@ -488,7 +508,7 @@ const result = await esbuild.build({
488
508
  metafile: true,
489
509
  sourcemap: false,
490
510
  treeShaking: true,
491
- plugins: [createStripServerPlugin(PAGES_DIR)],
511
+ plugins: [createStripServerPlugin(PAGES_DIR, STORE_PATH)],
492
512
  define: {
493
513
  'process.env.NODE_ENV': '"production"'
494
514
  }
@@ -7,8 +7,8 @@
7
7
  // Inline the scanner functions (duplicated here to keep test self-contained)
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
- const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
11
- const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
10
+ const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
11
+ const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
12
12
 
13
13
  function stripServerOnlyKeys(source) {
14
14
  for (const key of SERVER_ONLY_KEYS) source = removeObjectKey(source, key)
@@ -398,5 +398,34 @@ devTest('leaves module-level constants intact',
398
398
 
399
399
  // ---------------------------------------------------------------------------
400
400
 
401
+ console.log('\nStore + new spec fields\n')
402
+
403
+ test('sitemap enumerator is stripped (server-only)',
404
+ `export default {\n route: '/blog/:slug',\n sitemap: async () => ['/blog/a'],\n view: () => '',\n}`,
405
+ `export default {\n route: '/blog/:slug',\n view: () => '',\n}`
406
+ )
407
+
408
+ test('submit is NOT stripped — it collides with the common submit action name',
409
+ `export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`,
410
+ `export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`
411
+ )
412
+
413
+ test('store definition: server fetchers stripped, state and mutations survive',
414
+ `export default {\n state: { basket: [] },\n server: {\n nav: async () => fetchNav(),\n },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`,
415
+ `export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
416
+ )
417
+
418
+ test('package-root import (pushStore) is stripped — the root export is the server itself',
419
+ `import { pushStore } from '@invisibleloop/pulse'\nexport default {\n route: '/staff',\n view: () => '',\n}`,
420
+ `export default {\n route: '/staff',\n view: () => '',\n}`
421
+ )
422
+
423
+ test('subpath imports (/ui) are NOT stripped by the root entry',
424
+ `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`,
425
+ `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`
426
+ )
427
+
428
+ // ---------------------------------------------------------------------------
429
+
401
430
  console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
402
431
  if (fail > 0) process.exit(1)
@@ -1,9 +1,11 @@
1
- Start the Pulse production server for this project.
1
+ Start the Pulse production server for this project — **locally, on the production port (dev port + 1, normally 3001)** so it can run alongside the dev server on 3000.
2
2
 
3
- First ensure a build exists (`public/dist/`). Then run:
3
+ First ensure a build exists (`public/dist/` — run `pulse build` if not). Then:
4
4
 
5
5
  ```bash
6
- pulse start
6
+ pulse start --port 3001
7
7
  ```
8
8
 
9
- The production server uses the port from `pulse.config.js` (defaults to 3000).
9
+ If `pulse.config.js` sets a custom `port`, use that value + 1 instead.
10
+
11
+ > **Why the flag:** plain `pulse start` binds the configured port (3000) because in real deployment it is the only server and platforms inject `PORT`. Locally that collides with the dev server and breaks the convention used by `pulse_build`, Lighthouse, and the performance trace — production is always dev port + 1. Never use `--port` in actual deployment.
@@ -1,4 +1,4 @@
1
- Stop the Pulse dev server for this project.
1
+ Stop the Pulse servers for this project — both the dev server (port 3000) and the local production server (port 3001, started by `pulse_build` or `/pulse-start`).
2
2
 
3
3
  ```bash
4
4
  pulse stop
@@ -4,92 +4,59 @@
4
4
  *
5
5
  * Called by the Stop hook in .claude/settings.json to enforce test coverage.
6
6
  * Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
7
- * has uncovered lines — except async action run() and server fetcher functions
8
- * which cannot be tested without mocking real APIs.
7
+ * has uncovered lines — except code that cannot be unit-tested without mocking
8
+ * real APIs:
9
+ *
10
+ * - server fetchers (`server:` blocks) and store fetchers
11
+ * - action `run:` functions
12
+ * - `guard:`, `submit:`, and `sitemap:` handlers
13
+ * - `meta:` blocks (async meta functions hit real data sources)
14
+ *
15
+ * Also honours c8-style ignore comments, which Node's built-in coverage
16
+ * (--experimental-test-coverage) does NOT support natively. In a block or line
17
+ * comment, write:
18
+ *
19
+ * "c8 ignore next" — exempts the following line
20
+ * "c8 ignore next 3" — exempts the following 3 lines
21
+ * "c8 ignore start" ... "c8 ignore stop" — exempts the range
22
+ * ("coverage-ignore" works as a synonym for "c8 ignore")
9
23
  *
10
24
  * Outputs a Claude Code hook JSON decision to stdout.
11
25
  */
12
26
 
13
27
  import { execSync } from 'child_process'
14
28
  import fs from 'fs'
15
-
16
- // Agent is ending its turn to wait for the user's answer (design-approval gate) —
17
- // let the turn end. The marker is consumed on the user's next prompt, so the
18
- // gates return in full force afterwards.
19
- if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
20
-
21
- // No test files → nothing to check
22
- const hasTests = fs.existsSync('src/pages') && (function scan(d) {
23
- for (const f of fs.readdirSync(d)) {
24
- const p = `${d}/${f}`
25
- if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
26
- else if (f.endsWith('.test.js')) return true
27
- }
28
- return false
29
- })('src/pages')
30
-
31
- if (!hasTests) process.exit(0)
32
-
33
- let out = ''
34
- try {
35
- out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
36
- } catch (e) {
37
- out = e.stdout || e.message
38
- }
39
-
40
- // Parse the coverage report — look for lines with uncovered line numbers
41
- const lines = out.split('\n')
42
- let inReport = false
43
- const rawGaps = []
44
-
45
- for (const line of lines) {
46
- if (line.includes('start of coverage report')) { inReport = true; continue }
47
- if (!inReport) continue
48
-
49
- // Match: # filename.js | line% | branch% | func% | 30-36 50
50
- const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
51
- if (m) {
52
- const uncov = m[2].trim()
53
- if (uncov && uncov !== 'uncovered lines') {
54
- rawGaps.push({ file: m[1].trim(), uncovered: uncov })
55
- }
56
- }
57
- }
29
+ import { pathToFileURL } from 'url'
30
+
31
+ // Property blocks that are exempt — all server-side-only code paths that call
32
+ // real external APIs and cannot run in unit tests.
33
+ const EXEMPT_BLOCK_STARTS = [
34
+ /^server\s*:/,
35
+ /^run\s*:/, /^run\s*\(/,
36
+ /^guard\s*:/,
37
+ /^submit\s*:/,
38
+ /^sitemap\s*:/,
39
+ /^meta\s*:/,
40
+ ]
58
41
 
59
42
  /**
60
- * Returns true if all uncovered line numbers in the given file fall inside
61
- * a `server:` property block or an action `run:` function — both of which
62
- * call real external APIs and are explicitly exempt from coverage requirements.
43
+ * Build the set of exempt (1-based) line numbers for a source string:
44
+ * lines inside exempt property blocks, plus c8-ignore-annotated lines.
45
+ *
46
+ * @param {string} src
47
+ * @returns {Set<number>}
63
48
  */
64
- function allLinesExempt(filePath, uncoveredSpec) {
65
- let src
66
- try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
49
+ export function computeExemptLines(src) {
67
50
  const srcLines = src.split('\n')
68
-
69
- // Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
70
- const lineNums = []
71
- for (const part of uncoveredSpec.split(/\s+/)) {
72
- const range = part.match(/^(\d+)-(\d+)$/)
73
- if (range) {
74
- for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
75
- } else if (/^\d+$/.test(part)) {
76
- lineNums.push(parseInt(part))
77
- }
78
- }
79
-
80
- // Build a set of exempt line numbers: lines inside `server:` blocks or action `run:` functions.
81
- // Strategy: scan the source tracking brace depth relative to the start of each exempt block.
82
51
  const exemptLines = new Set()
52
+
53
+ // 1. Exempt property blocks — scan tracking brace/paren depth from block start
83
54
  let i = 0
84
55
  while (i < srcLines.length) {
85
56
  const trimmed = srcLines[i].trimStart()
86
- // Detect start of `server:` property or action `run:` function
87
- const isExemptStart = /^server\s*:/.test(trimmed) || /^run\s*:/.test(trimmed) || /^run\s*\(/.test(trimmed)
88
- if (isExemptStart) {
89
- // Mark lines from here until the block closes (depth reaches 0 after opening)
57
+ if (EXEMPT_BLOCK_STARTS.some(re => re.test(trimmed))) {
90
58
  let depth = 0
91
59
  let started = false
92
- const startLine = i
93
60
  while (i < srcLines.length) {
94
61
  exemptLines.add(i + 1) // 1-based
95
62
  for (const ch of srcLines[i]) {
@@ -103,30 +70,127 @@ function allLinesExempt(filePath, uncoveredSpec) {
103
70
  i++
104
71
  }
105
72
 
106
- return lineNums.every(n => exemptLines.has(n))
73
+ // 2. c8-style ignore comments (Node's built-in coverage ignores these, so we
74
+ // honour them here — the marker line itself is always exempt too)
75
+ const IGNORE = /(?:c8 ignore|coverage-ignore)(?:\s+(next|start|stop))?(?:\s+(\d+))?/
76
+ let rangeOpen = false
77
+ for (let n = 0; n < srcLines.length; n++) {
78
+ if (rangeOpen) exemptLines.add(n + 1)
79
+ const m = srcLines[n].match(IGNORE)
80
+ if (!m) continue
81
+ exemptLines.add(n + 1)
82
+ const keyword = m[1]
83
+ if (keyword === 'start') { rangeOpen = true; continue }
84
+ if (keyword === 'stop') { rangeOpen = false; continue }
85
+ // 'next' (or bare marker) — exempt the following N lines (default 1)
86
+ const count = m[2] ? parseInt(m[2], 10) : 1
87
+ for (let k = 1; k <= count; k++) exemptLines.add(n + 1 + k)
88
+ }
89
+
90
+ return exemptLines
107
91
  }
108
92
 
109
- const gaps = []
110
- for (const { file, uncovered } of rawGaps) {
111
- // Try to resolve relative to cwd (coverage reports use relative paths)
112
- const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
113
- const resolved = candidates.find(p => fs.existsSync(p))
93
+ /**
94
+ * Returns true if all uncovered line numbers fall on exempt lines.
95
+ *
96
+ * @param {string} filePath
97
+ * @param {string} uncoveredSpec - e.g. "30-36 50"
98
+ * @returns {boolean}
99
+ */
100
+ export function allLinesExempt(filePath, uncoveredSpec) {
101
+ let src
102
+ try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
103
+
104
+ // Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
105
+ const lineNums = []
106
+ for (const part of uncoveredSpec.split(/\s+/)) {
107
+ const range = part.match(/^(\d+)-(\d+)$/)
108
+ if (range) {
109
+ for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
110
+ } else if (/^\d+$/.test(part)) {
111
+ lineNums.push(parseInt(part))
112
+ }
113
+ }
114
+
115
+ const exemptLines = computeExemptLines(src)
116
+ return lineNums.every(n => exemptLines.has(n))
117
+ }
114
118
 
115
- if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
119
+ // ---------------------------------------------------------------------------
120
+ // Main — only when executed directly (the exports above are unit-tested)
121
+ // ---------------------------------------------------------------------------
116
122
 
117
- gaps.push(` ${file} — uncovered lines: ${uncovered}`)
123
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
124
+ main()
118
125
  }
119
126
 
120
- if (gaps.length) {
121
- process.stdout.write(JSON.stringify({
122
- decision: 'block',
123
- reason: [
124
- 'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
125
- ...gaps,
126
- '',
127
- 'Run `npm run test:coverage` to see the full report.',
128
- 'Exempt from this rule: async action run() functions and server fetchers that call real APIs.',
129
- 'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
130
- ].join('\n'),
131
- }))
127
+ function main() {
128
+ // Agent is ending its turn to wait for the user's answer (approval gate or a
129
+ // mid-flow pause) — let the turn end. The marker is consumed on the user's
130
+ // next prompt, so the gates return in full force afterwards.
131
+ if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
132
+
133
+ // No test files → nothing to check
134
+ const hasTests = fs.existsSync('src/pages') && (function scan(d) {
135
+ for (const f of fs.readdirSync(d)) {
136
+ const p = `${d}/${f}`
137
+ if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
138
+ else if (f.endsWith('.test.js')) return true
139
+ }
140
+ return false
141
+ })('src/pages')
142
+
143
+ if (!hasTests) process.exit(0)
144
+
145
+ let out = ''
146
+ try {
147
+ out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
148
+ } catch (e) {
149
+ out = e.stdout || e.message
150
+ }
151
+
152
+ // Parse the coverage report — look for lines with uncovered line numbers
153
+ const lines = out.split('\n')
154
+ let inReport = false
155
+ const rawGaps = []
156
+
157
+ for (const line of lines) {
158
+ if (line.includes('start of coverage report')) { inReport = true; continue }
159
+ if (!inReport) continue
160
+
161
+ // Match: # filename.js | line% | branch% | func% | 30-36 50
162
+ const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
163
+ if (m) {
164
+ const uncov = m[2].trim()
165
+ if (uncov && uncov !== 'uncovered lines') {
166
+ rawGaps.push({ file: m[1].trim(), uncovered: uncov })
167
+ }
168
+ }
169
+ }
170
+
171
+ const gaps = []
172
+ for (const { file, uncovered } of rawGaps) {
173
+ // Try to resolve relative to cwd (coverage reports use relative paths)
174
+ const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
175
+ const resolved = candidates.find(p => fs.existsSync(p))
176
+
177
+ if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
178
+
179
+ gaps.push(` ${file} — uncovered lines: ${uncovered}`)
180
+ }
181
+
182
+ if (gaps.length) {
183
+ process.stdout.write(JSON.stringify({
184
+ decision: 'block',
185
+ reason: [
186
+ 'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
187
+ ...gaps,
188
+ '',
189
+ 'Run `npm run test:coverage` to see the full report.',
190
+ 'Auto-exempt: server fetchers, action run(), guard, submit, sitemap, and meta blocks — these call real APIs and need no tests.',
191
+ 'For anything else that genuinely cannot be tested, annotate it: /* c8 ignore next */ (or next N, or start/stop for a range) — this hook honours those comments even though Node\'s built-in coverage does not.',
192
+ 'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
193
+ ].join('\n'),
194
+ }))
195
+ }
132
196
  }
@@ -18,12 +18,10 @@ Only declared keys are saved. Restored values that differ from the spec default
18
18
 
19
19
  The global store is a shared data layer. Server fetchers run per request; mutations run on the client and broadcast to all subscribed pages without a server round-trip.
20
20
 
21
- **Define the store** in `pulse.store.js`:
21
+ **Define the store** in `pulse.store.js` at the project root — that's the whole setup. The CLI auto-discovers it (like pages in `src/pages/`), loads it server-side, serves it to the browser, and hot-reloads it on edit:
22
22
  ```js
23
23
  // pulse.store.js
24
24
  export default {
25
- hydrate: '/pulse.store.js', // browser-importable path — required for mutations
26
-
27
25
  state: { // default/fallback values
28
26
  user: null,
29
27
  settings: { theme: 'dark', lang: 'en' },
@@ -45,10 +43,10 @@ export default {
45
43
  }
46
44
  ```
47
45
 
48
- **Register the store** in your server file:
46
+ **No registration step is needed** with `pulse dev` / `pulse start` — `pulse.store.js` is auto-discovered. Only when you call `createServer` yourself do you pass it manually:
49
47
  ```js
50
48
  import store from './pulse.store.js'
51
- createServer([...specs], { store })
49
+ createServer([...specs], { store }) // manual server entry only
52
50
  ```
53
51
 
54
52
  **Use store data in a page** — declare `spec.store` with the keys needed. They appear in the view's `server` argument:
@@ -165,6 +165,20 @@ If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop
165
165
  **Mode B — Creative override (raw HTML throughout)**
166
166
  When the design intent calls for a more expressive, unconventional, or typographically-driven layout that components would constrain, you may build with raw HTML throughout. This is a deliberate design decision, not a shortcut.
167
167
 
168
+ **When to choose Mode B — use the component, unless:**
169
+ - The layout requires **full-viewport height or edge-to-edge bleed** that the component doesn't support (e.g. `hero()` doesn't fill the viewport)
170
+ - The design uses **custom gradient glows, parallax, or large-scale image fills** that the component would wrap in unwanted structure
171
+ - The vibe is **brutalist, retro, neon, paper, or typographic-editorial** and the structural feel would be actively fought by the component's default padding/spacing/radius
172
+ - The typography is **clamp-scaled display type** (e.g. `clamp(4rem, 10vw, 12rem)`) that breaks inside a component's internal box model
173
+ - The layout is **asymmetric or zone-based** (e.g. 70/30 split, overlapping elements, staggered grid) that no single component supports
174
+ - A **Figma/reference design was provided** that clearly departs from the component's output
175
+
176
+ **When to use the component (not a valid reason for override):**
177
+ - "The component doesn't have a perfect prop for this" — add a utility class or wrapper div instead
178
+ - "I want a slightly different colour or size" — override with CSS tokens
179
+ - "I'm not sure what the component looks like" — check the guide first
180
+ - "It's easier to write raw HTML" — this is never a valid reason
181
+
168
182
  Rules for Mode B:
169
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.
170
184
  - Load `/pulse-ui.css` in `meta.styles` — the token system is still required
@@ -206,9 +220,9 @@ After the Write tool completes, call `pulse_create_page(name)` to register the p
206
220
  ✓ src/pages/my-page.js written and registered.
207
221
  ```
208
222
 
209
- ### 3d — Suggest (optional but recommended)
223
+ ### 3d — Suggest (recommended mid-build checkpoint)
210
224
 
211
- After writing the first draft, call `pulse_suggest(content)` before running the hard validator. It catches obvious omissions collaboratively — a second opinion, not a gate.
225
+ After writing the first draft, call `pulse_suggest(content)` before running the hard validator. It catches obvious omissions collaboratively — a second opinion, not a gate. Then call `pulse_review(file, { quick: true })` for a lightweight structural pass (main landmark, data-event on inputs, missing onError, hex in view). Neither is a substitute for the full review, but catching issues here is faster than discovering them at Lighthouse.
212
226
 
213
227
  **Rule: never output `✓` before the tool call that confirms the result.**
214
228
 
@@ -105,6 +105,131 @@ for (const skill of requiredSkills) {
105
105
  })
106
106
  }
107
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // Store auto-discovery (loadStore)
110
+ // ---------------------------------------------------------------------------
111
+ // Regression: pulse.store.js was documented but never loaded by the CLI —
112
+ // dev/start servers got no store, so spec.store keys were always empty.
113
+
114
+ console.log('\nStore auto-discovery\n')
115
+
116
+ {
117
+ const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs')
118
+ const { tmpdir } = await import('node:os')
119
+ const { loadStore } = await import('./discover.js')
120
+
121
+ const dir = mkdtempSync(path.join(tmpdir(), 'pulse-store-'))
122
+ try {
123
+ // No store file → null
124
+ const none = await loadStore(dir)
125
+ test('loadStore returns null when pulse.store.js is absent', () => {
126
+ if (none !== null) throw new Error(`Expected null, got ${JSON.stringify(none)}`)
127
+ })
128
+
129
+ // Valid store file → definition with browser hydrate path set
130
+ writeFileSync(path.join(dir, 'pulse.store.js'),
131
+ `export default { state: { basket: [] }, server: { nav: async () => ['Home'] }, mutations: { add: (s, i) => ({ basket: [...s.basket, i] }) } }`)
132
+ const def = await loadStore(dir)
133
+ test('loadStore loads pulse.store.js and sets the browser hydrate path', () => {
134
+ if (!def) throw new Error('Expected a store definition')
135
+ if (def.hydrate !== '/pulse.store.js') throw new Error(`Expected hydrate /pulse.store.js, got ${def.hydrate}`)
136
+ if (typeof def.server?.nav !== 'function') throw new Error('server fetchers missing')
137
+ if (typeof def.mutations?.add !== 'function') throw new Error('mutations missing')
138
+ if (!Array.isArray(def.state?.basket)) throw new Error('state missing')
139
+ })
140
+
141
+ // Cache-busted re-import returns the edited module (dev hot reload)
142
+ writeFileSync(path.join(dir, 'pulse.store.js'),
143
+ `export default { state: { basket: [], promo: true }, mutations: {} }`)
144
+ const fresh = await loadStore(dir, Date.now())
145
+ test('loadStore cache-busts for hot reload', () => {
146
+ if (fresh?.state?.promo !== true) throw new Error('Expected fresh store definition after edit')
147
+ })
148
+
149
+ // Bad export → clear error
150
+ writeFileSync(path.join(dir, 'pulse.store.js'), `export default 42`)
151
+ let threw = false
152
+ try { await loadStore(dir, Date.now() + 1) } catch (e) { threw = /store object/.test(e.message) }
153
+ test('loadStore throws a clear error for a non-object export', () => {
154
+ if (!threw) throw new Error('Expected a descriptive throw for bad default export')
155
+ })
156
+ } finally {
157
+ rmSync(dir, { recursive: true, force: true })
158
+ }
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Coverage-check exemptions
163
+ // ---------------------------------------------------------------------------
164
+ // Regression: the stop hook blocked every session on uncovered guard/submit/
165
+ // sitemap/meta lines (server-only, untestable), and c8 ignore comments were
166
+ // not honoured because Node's built-in coverage ignores them.
167
+
168
+ console.log('\nCoverage-check exemptions\n')
169
+
170
+ {
171
+ const { computeExemptLines } = await import('../agent/coverage-check.js')
172
+
173
+ const src = [
174
+ /* 1 */ `export default {`,
175
+ /* 2 */ ` route: '/x',`,
176
+ /* 3 */ ` guard: async (ctx) => {`,
177
+ /* 4 */ ` if (!ctx.cookies.session) return { redirect: '/login' }`,
178
+ /* 5 */ ` },`,
179
+ /* 6 */ ` submit: async (ctx) => {`,
180
+ /* 7 */ ` return { redirect: '/done' }`,
181
+ /* 8 */ ` },`,
182
+ /* 9 */ ` sitemap: async () => ['/a'],`,
183
+ /* 10 */ ` meta: {`,
184
+ /* 11 */ ` title: async (ctx) => fetchTitle(ctx),`,
185
+ /* 12 */ ` },`,
186
+ /* 13 */ ` view: (state) => state.n,`,
187
+ /* 14 */ ` helpers: () => {`,
188
+ /* 15 */ ` /* c8 ignore next 2 */`,
189
+ /* 16 */ ` impossibleBranch()`,
190
+ /* 17 */ ` alsoImpossible()`,
191
+ /* 18 */ ` normalCode()`,
192
+ /* 19 */ ` },`,
193
+ /* 20 */ `}`,
194
+ ].join('\n')
195
+
196
+ const exempt = computeExemptLines(src)
197
+
198
+ test('guard, submit, sitemap, and meta blocks are exempt', () => {
199
+ for (const n of [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) {
200
+ if (!exempt.has(n)) throw new Error(`Expected line ${n} to be exempt`)
201
+ }
202
+ })
203
+
204
+ test('view and ordinary helper code are NOT exempt', () => {
205
+ for (const n of [13, 18]) {
206
+ if (exempt.has(n)) throw new Error(`Line ${n} must not be exempt`)
207
+ }
208
+ })
209
+
210
+ test('c8 ignore next N exempts exactly the following N lines', () => {
211
+ for (const n of [15, 16, 17]) {
212
+ if (!exempt.has(n)) throw new Error(`Expected line ${n} to be exempt via c8 ignore`)
213
+ }
214
+ if (exempt.has(18)) throw new Error('Line 18 is beyond the ignore count and must not be exempt')
215
+ })
216
+
217
+ test('c8 ignore start/stop exempts the whole range', () => {
218
+ const ranged = computeExemptLines([
219
+ `const a = 1`, // 1
220
+ `/* c8 ignore start */`, // 2
221
+ `untestable1()`, // 3
222
+ `untestable2()`, // 4
223
+ `/* c8 ignore stop */`, // 5
224
+ `const b = 2`, // 6
225
+ ].join('\n'))
226
+ for (const n of [2, 3, 4, 5]) {
227
+ if (!ranged.has(n)) throw new Error(`Expected line ${n} exempt in range`)
228
+ }
229
+ if (ranged.has(6)) throw new Error('Line after stop must not be exempt')
230
+ })
231
+ }
232
+
108
233
  // ---------------------------------------------------------------------------
109
234
 
110
235
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)