@invisibleloop/pulse 0.20.1 → 0.20.3
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/.claude/commands/pulse-start.md +11 -0
- package/.claude/commands/pulse-stop.md +5 -0
- package/.claude/commands/verify.md +17 -11
- package/docs/.claude/coverage-check.js +153 -84
- package/examples/.claude/coverage-check.js +153 -84
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +1 -1
- package/scripts/css-purge.js +65 -3
- package/scripts/css-purge.test.js +16 -0
- package/scripts/strip-server.test.js +11 -1
- package/src/agent/commands/pulse-start.md +6 -4
- package/src/agent/commands/pulse-stop.md +1 -1
- package/src/agent/commands/verify.md +17 -11
- package/src/agent/coverage-check.js +154 -90
- package/src/agent/guide-components.md +2 -2
- package/src/agent/identity.md +2 -2
- package/src/agent/workflow.md +21 -4
- package/src/cli/cli.test.js +72 -0
- package/src/cli/dev.js +23 -3
- package/src/cli/index.js +5 -2
- package/src/cli/scaffold.js +2 -2
- package/src/mcp/mcp.test.js +44 -0
- package/src/mcp/server.js +523 -33
- package/src/mcp/validate-worker.js +3 -0
- package/src/runtime/index.js +9 -0
- package/src/server/index.js +21 -30
package/src/cli/cli.test.js
CHANGED
|
@@ -158,6 +158,78 @@ console.log('\nStore auto-discovery\n')
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
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
|
+
|
|
161
233
|
// ---------------------------------------------------------------------------
|
|
162
234
|
|
|
163
235
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/cli/dev.js
CHANGED
|
@@ -118,6 +118,15 @@ if (specs.length === 0) {
|
|
|
118
118
|
process.exit(1)
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Warn if a production build exists — public/dist/ is ignored by the dev server
|
|
122
|
+
// (manifest: {} forces source-file hydration) but its presence can cause confusion
|
|
123
|
+
// because static files in public/dist/ ARE served by the static file handler.
|
|
124
|
+
// If the user ran pulse build and sees stale styles, deleting public/dist/ fixes it.
|
|
125
|
+
const DIST_DIR = path.join(ROOT, 'public', 'dist')
|
|
126
|
+
if (fs.existsSync(path.join(DIST_DIR, 'manifest.json'))) {
|
|
127
|
+
console.warn(`\n ⚠ A production build exists in public/dist/\n Dev mode ignores the manifest and always serves source files, but if\n styles look wrong after a build, run: rm -rf public/dist/\n`)
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
// ---------------------------------------------------------------------------
|
|
122
131
|
// Hot reload — SSE clients + file watcher
|
|
123
132
|
// ---------------------------------------------------------------------------
|
|
@@ -257,6 +266,14 @@ const { updateSpecs, updateStore } = await createServer(specs, {
|
|
|
257
266
|
onRequest(req, res) {
|
|
258
267
|
const url = req.url.split('?')[0]
|
|
259
268
|
|
|
269
|
+
// MCP hot-reload trigger — POST to this instead of killing the process
|
|
270
|
+
if (url === '/_pulse/trigger-reload' && req.method === 'POST') {
|
|
271
|
+
triggerReload('MCP trigger')
|
|
272
|
+
res.writeHead(204)
|
|
273
|
+
res.end()
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
|
|
260
277
|
// SSE endpoint — browser connects here to receive reload events
|
|
261
278
|
if (url === '/_pulse/reload') {
|
|
262
279
|
res.writeHead(200, {
|
|
@@ -275,11 +292,14 @@ const { updateSpecs, updateStore } = await createServer(specs, {
|
|
|
275
292
|
// Strip server-only imports that can't resolve in the browser:
|
|
276
293
|
// - node:* built-ins (fs, path, etc.)
|
|
277
294
|
// - JSON files imported with `with { type: 'json' }` (e.g. package.json for version)
|
|
278
|
-
//
|
|
279
|
-
//
|
|
295
|
+
// - the package ROOT (@invisibleloop/pulse) — that export is the server
|
|
296
|
+
// itself (createServer, pushStore); the closing quote anchors the match
|
|
297
|
+
// so subpaths like @invisibleloop/pulse/ui are untouched
|
|
298
|
+
// These are only used inside spec.server/submit functions or module-level
|
|
299
|
+
// server init; the client runtime never calls those code paths.
|
|
280
300
|
let content = fs.readFileSync(filePath, 'utf-8')
|
|
281
301
|
content = content.replace(
|
|
282
|
-
/^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
|
|
302
|
+
/^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json|@invisibleloop\/pulse)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
|
|
283
303
|
''
|
|
284
304
|
)
|
|
285
305
|
res.writeHead(200, {
|
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
|
-
|
|
273
|
-
|
|
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
|
|
package/src/cli/scaffold.js
CHANGED
|
@@ -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 —
|
|
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.
|
|
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/mcp/mcp.test.js
CHANGED
|
@@ -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
|