@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/src/cli/dev.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import path from 'path'
12
12
  import fs from 'fs'
13
13
  import { createServer } from '../server/index.js'
14
- import { loadPages } from './discover.js'
14
+ import { loadPages, loadStore } from './discover.js'
15
15
  import * as log from './logger.js'
16
16
 
17
17
  // ---------------------------------------------------------------------------
@@ -109,6 +109,10 @@ const PUBLIC_DIR = path.join(ROOT, 'public')
109
109
 
110
110
  const specs = await loadPages(ROOT)
111
111
 
112
+ // Auto-discover the global store — pulse.store.js in the project root.
113
+ // Mirrors page discovery; no custom server entry needed.
114
+ const store = await loadStore(ROOT)
115
+
112
116
  if (specs.length === 0) {
113
117
  log.error('No pages found in src/pages/. Create a page to get started.')
114
118
  process.exit(1)
@@ -192,6 +196,9 @@ async function triggerReload(label = 'File changed') {
192
196
  try {
193
197
  const fresh = await loadPages(ROOT, now)
194
198
  updateSpecs(fresh)
199
+ // Hot-swap the store too — pulse.store.js edits take effect like spec edits
200
+ const freshStore = await loadStore(ROOT, now)
201
+ updateStore(freshStore)
195
202
  log.info(`${label} — specs reloaded`)
196
203
  checkUndefinedClasses(fresh)
197
204
  } catch (err) {
@@ -204,6 +211,11 @@ async function triggerReload(label = 'File changed') {
204
211
  // Watch src/ for changes to existing files (spec edits, component edits)
205
212
  fs.watch(path.join(ROOT, 'src'), { recursive: true }, () => triggerReload('File changed'))
206
213
 
214
+ // Watch the store file — it lives in the project root, outside src/
215
+ if (fs.existsSync(path.join(ROOT, 'pulse.store.js'))) {
216
+ fs.watch(path.join(ROOT, 'pulse.store.js'), () => triggerReload('Store changed'))
217
+ }
218
+
207
219
  // Also watch the pages directory explicitly so macOS reliably fires on new file creation.
208
220
  // On macOS, fs.watch with recursive=true watches subdirectory contents but may not fire
209
221
  // for the parent dir entry when a brand-new file is created. Watching the dir non-recursively
@@ -227,7 +239,7 @@ const reloadScript = (nonce) => `<script nonce="${nonce}">
227
239
  })();
228
240
  </script>`
229
241
 
230
- const { updateSpecs } = await createServer(specs, {
242
+ const { updateSpecs, updateStore } = await createServer(specs, {
231
243
  port: PORT,
232
244
  stream: true,
233
245
  staticDir: fs.existsSync(PUBLIC_DIR) ? PUBLIC_DIR : null,
@@ -235,6 +247,7 @@ const { updateSpecs } = await createServer(specs, {
235
247
  extraBody: reloadScript,
236
248
  dev: true,
237
249
  agentMode: !!process.env.PULSE_AGENT_MODE,
250
+ ...(store ? { store } : {}),
238
251
  ...(_config.csp ? { csp: _config.csp } : {}),
239
252
  // Server options forwarded verbatim from pulse.config.js so dev behaves like
240
253
  // production — keep in sync with start.js PASSTHROUGH_OPTIONS
@@ -262,11 +275,14 @@ const { updateSpecs } = await createServer(specs, {
262
275
  // Strip server-only imports that can't resolve in the browser:
263
276
  // - node:* built-ins (fs, path, etc.)
264
277
  // - JSON files imported with `with { type: 'json' }` (e.g. package.json for version)
265
- // These are only used inside spec.server functions or module-level server init;
266
- // the client runtime never calls those code paths.
278
+ // - the package ROOT (@invisibleloop/pulse) — that export is the server
279
+ // itself (createServer, pushStore); the closing quote anchors the match
280
+ // so subpaths like @invisibleloop/pulse/ui are untouched
281
+ // These are only used inside spec.server/submit functions or module-level
282
+ // server init; the client runtime never calls those code paths.
267
283
  let content = fs.readFileSync(filePath, 'utf-8')
268
284
  content = content.replace(
269
- /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
285
+ /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json|@invisibleloop\/pulse)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
270
286
  ''
271
287
  )
272
288
  res.writeHead(200, {
@@ -285,6 +301,11 @@ const { updateSpecs } = await createServer(specs, {
285
301
  return serveFile(filePath)
286
302
  }
287
303
 
304
+ // Global store — pulse.store.js lives in the project root (not src/), and the
305
+ // dev hydrate bootstrap imports it so client store mutations register.
306
+ // serveFile strips node:/server-only imports, same treatment as page specs.
307
+ if (url === '/pulse.store.js' && serveFile(path.join(ROOT, 'pulse.store.js'))) return false
308
+
288
309
  // Project source — /src/pages/, /src/components/, /src/lib/, etc.
289
310
  // Falls back to framework source for imports like /src/ui/ that sub-projects
290
311
  // resolve from relative paths (e.g. docs component pages → ../../../../src/ui/).
@@ -111,3 +111,29 @@ export async function loadPages(projectRoot, bust = 0) {
111
111
 
112
112
  return specs
113
113
  }
114
+
115
+ /**
116
+ * Auto-discover the global store definition from pulse.store.js in the
117
+ * project root. Returns the store definition with `hydrate` set to its
118
+ * browser-importable path (used by the dev inline bootstrap), or null when
119
+ * the file doesn't exist.
120
+ *
121
+ * Mirrors page auto-discovery: pulse.store.js is the documented filename
122
+ * convention, so CLI-managed projects (pulse dev / pulse start) get the
123
+ * store without writing a custom server entry.
124
+ *
125
+ * @param {string} projectRoot
126
+ * @returns {Promise<object|null>}
127
+ */
128
+ export async function loadStore(projectRoot, cacheBust = null) {
129
+ const storePath = path.join(projectRoot, 'pulse.store.js')
130
+ if (!fs.existsSync(storePath)) return null
131
+
132
+ const url = 'file://' + storePath + (cacheBust ? `?t=${cacheBust}` : '')
133
+ const mod = await import(url)
134
+ const def = mod.default
135
+ if (!def || typeof def !== 'object') {
136
+ throw new Error('pulse.store.js must export a default store object ({ state?, server?, mutations? })')
137
+ }
138
+ return { ...def, hydrate: def.hydrate || '/pulse.store.js' }
139
+ }
package/src/cli/index.js CHANGED
@@ -269,8 +269,11 @@ async function runStop(root) {
269
269
  } catch { /* use default */ }
270
270
  }
271
271
  try {
272
- execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null; true`, { stdio: 'inherit' })
273
- console.log(`\n⚡ Dev server on port ${port} stopped.\n`)
272
+ // Kill the dev server AND the local production server (dev port + 1) —
273
+ // pulse_build spawns the prod server detached, so without this it lingers
274
+ // after the session and blocks the next build's Lighthouse run.
275
+ execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null; lsof -ti:${port + 1} | xargs kill -9 2>/dev/null; true`, { stdio: 'inherit' })
276
+ console.log(`\n⚡ Servers on ports ${port} (dev) and ${port + 1} (prod) stopped.\n`)
274
277
  } catch { /* nothing was running */ }
275
278
  }
276
279
 
@@ -207,7 +207,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
207
207
  hooks: [
208
208
  {
209
209
  type: 'command',
210
- command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let changed=new Set();let hasGit=false;try{execSync('git rev-parse --is-inside-work-tree',{stdio:'pipe'});hasGit=true;execSync('git status --porcelain',{encoding:'utf8'}).split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f)changed.add(f);});}catch{}if(!hasGit&&fs.existsSync('src/pages')){for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))changed.add('src/pages/'+f);}}const specs=[...changed].filter(f=>f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'));const missing=specs.filter(f=>{const t=f.replace(/\\.js$/,'.test.js');return!fs.existsSync(t)&&!changed.has(t);});if(missing.length){process.stdout.write(JSON.stringify({decision:'block',reason:'TESTS MISSING: '+missing.join(', ')+' — you must write tests before finishing. Use renderSync/render from @invisibleloop/pulse/testing to test view output, mutations, and any extracted logic functions. Run the tests with node to confirm they pass.\\n\\nEXCEPTION — waiting on the user: if you are ending this turn to wait for the user\\'s answer (e.g. the design-approval gate), call pulse_await_approval first, then ask and end your turn. Do not write tests or run /verify on an unapproved design.'}));}"`,
210
+ command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let changed=new Set();let hasGit=false;try{execSync('git rev-parse --is-inside-work-tree',{stdio:'pipe'});hasGit=true;execSync('git status --porcelain',{encoding:'utf8'}).split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f)changed.add(f);});}catch{}if(!hasGit&&fs.existsSync('src/pages')){for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))changed.add('src/pages/'+f);}}const specs=[...changed].filter(f=>f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'));const missing=specs.filter(f=>{const t=f.replace(/\\.js$/,'.test.js');return!fs.existsSync(t)&&!changed.has(t);});if(missing.length){process.stdout.write(JSON.stringify({decision:'block',reason:'TESTS MISSING: '+missing.join(', ')+' — you must write tests before finishing. Use renderSync/render from @invisibleloop/pulse/testing to test view output, mutations, and any extracted logic functions. Run the tests with node to confirm they pass.\\n\\nEXCEPTION — pausing for the user: if you are ending this turn to wait for the user\\'s answer (design-approval gate) OR to report progress mid-verification (e.g. between Lighthouse runs across multiple pages), call pulse_await_approval first, then end your turn. Do not write tests or run /verify on an unapproved design.'}));}"`,
211
211
  statusMessage: 'Checking for missing tests...',
212
212
  },
213
213
  {
@@ -217,7 +217,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
217
217
  },
218
218
  {
219
219
  type: 'command',
220
- command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nIMPORTANT — check first: are you ending this turn to wait for the user\\'s answer (e.g. the design-approval question)? If so, do NOT run /verify — the user has not approved the design yet. Call pulse_await_approval, re-ask your question, and end your turn; this gate will let you stop.\\n\\nOtherwise run /verify for each changed page. /verify runs Lighthouse (desktop + mobile), performance trace, console check, and code review — then writes the stamp that clears this check.'}));}"`,
220
+ command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nIMPORTANT — check first: are you ending this turn to wait for the user\\'s answer (design-approval question) or to report progress mid-verification (e.g. between Lighthouse runs across multiple pages)? If so, do NOT rush /verify — call pulse_await_approval and end your turn; this gate will let you stop, and the marker is consumed when the user replies.\\n\\nOtherwise run /verify for each changed page. /verify runs Lighthouse (desktop + mobile), performance trace, console check, and code review — then writes the stamp that clears this check.'}));}"`,
221
221
  statusMessage: 'Checking verification stamp...',
222
222
  }
223
223
  ]
package/src/cli/start.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import path from 'path'
12
12
  import fs from 'fs'
13
13
  import { createServer } from '../server/index.js'
14
- import { loadPages } from './discover.js'
14
+ import { loadPages, loadStore } from './discover.js'
15
15
 
16
16
  const args = process.argv.slice(2)
17
17
  const rootArg = args.indexOf('--root')
@@ -71,6 +71,9 @@ port = parseInt(process.env.PORT, 10) || port || 3000
71
71
 
72
72
  const specs = await loadPages(ROOT)
73
73
 
74
+ // Auto-discover the global store — pulse.store.js in the project root
75
+ const store = await loadStore(ROOT)
76
+
74
77
  if (specs.length === 0) {
75
78
  console.error('No pages found in src/pages/.')
76
79
  process.exit(1)
@@ -81,6 +84,7 @@ createServer(specs, {
81
84
  stream: true,
82
85
  staticDir: PUBLIC_DIR,
83
86
  defaultCache,
87
+ ...(store ? { store } : {}),
84
88
  ...(csp ? { csp } : {}),
85
89
  ...passthrough,
86
90
  })
@@ -114,6 +114,8 @@ const SYNC_PAIRS = [
114
114
  ['src/agent/checklist.md', 'docs/.claude/pulse-checklist.md'],
115
115
  ['src/agent/checklist.md', 'examples/.claude/pulse-checklist.md'],
116
116
  ['src/agent/commands/verify.md', '.claude/commands/verify.md'],
117
+ ['src/agent/coverage-check.js', 'docs/.claude/coverage-check.js'],
118
+ ['src/agent/coverage-check.js', 'examples/.claude/coverage-check.js'],
117
119
  ]
118
120
 
119
121
  for (const [a, b] of SYNC_PAIRS) {
@@ -277,6 +279,48 @@ test('approval pause mechanism is wired end to end', () => {
277
279
  `Instruct: always call pulse_await_approval before asking, whichever way the question is asked.`)
278
280
  })
279
281
 
282
+ test('pulse_validate prop-alias check excludes attrs blocks (false-positive fix)', () => {
283
+ // Regression: input({ attrs: { autocomplete } }) — the RECOMMENDED pattern —
284
+ // was flagged as a wrong top-level prop because the match crossed into the
285
+ // attrs object. The exclusion regex must be present in the alias loop.
286
+ assert(serverSrc.includes('attrs\\s*:\\s*\\{[^}]*$'),
287
+ 'The PROP_ALIASES loop must exclude matches that fall inside an open attrs: { … } block')
288
+ })
289
+
290
+ test('pulse_validate CSP check matches config by host, not full URL', () => {
291
+ // Regression: CSP sources are valid without a scheme (images.unsplash.com),
292
+ // but the config check required https?:// — flagging correctly configured
293
+ // projects on every validate.
294
+ assert(serverSrc.includes('configImgSrc.includes(host)'),
295
+ 'External-image CSP check must test the config for the bare host')
296
+ assert(serverSrc.includes(`host: 'images.unsplash.com'`),
297
+ 'External image host entries must carry a bare host field')
298
+ })
299
+
300
+ test('validator resolves relative imports from the spec file\'s own directory', () => {
301
+ // Regression: the temp validation file was always written into src/pages/
302
+ // root, so subdirectory pages (src/pages/news/index.js) had their relative
303
+ // imports (../../components/layout.js) resolve from the wrong depth.
304
+ assert(/async function validateContent\(content, tmpDir = PAGES_DIR\)/.test(serverSrc),
305
+ 'validateContent must accept a tmpDir parameter')
306
+ assert(serverSrc.includes('validateContent(content, path.dirname(fullPath))'),
307
+ 'pulse_create_page must validate from the file\'s own directory')
308
+ assert(serverSrc.includes('validateContent(content, path.dirname(file))'),
309
+ 'pulse_validate file mode must validate from the file\'s own directory')
310
+ })
311
+
312
+ test('package-root import is stripped from client bundles in build and dev', () => {
313
+ // Regression: import { pushStore } from '@invisibleloop/pulse' (the documented
314
+ // live-push pattern) pulled the entire server — http, fs, zlib — into the
315
+ // browser bundle and broke `pulse build`.
316
+ const buildSrc = fs.readFileSync(path.join(ROOT, 'scripts/build.js'), 'utf8')
317
+ const devSrc = fs.readFileSync(path.join(ROOT, 'src/cli/dev.js'), 'utf8')
318
+ assert(buildSrc.includes(`'@invisibleloop/pulse/md', '@invisibleloop/pulse'`),
319
+ 'build.js SERVER_ONLY_IMPORTS must include the package root')
320
+ assert(devSrc.includes(`@invisibleloop\\/pulse)['"]`),
321
+ 'dev.js serveFile must strip package-root imports before serving specs to the browser')
322
+ })
323
+
280
324
  test('pulse_check_contrast extracts variables from [data-theme="light"] blocks', () => {
281
325
  // Regression: the light-theme block pattern was an ungrouped alternation
282
326
  // (a|b) + '\\s*\\{([^}]+)\\}' — the body matcher bound only to the second