@invisibleloop/pulse 0.20.1 → 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.
@@ -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
  }
@@ -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
 
@@ -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
@@ -275,11 +275,14 @@ const { updateSpecs, updateStore } = await createServer(specs, {
275
275
  // Strip server-only imports that can't resolve in the browser:
276
276
  // - node:* built-ins (fs, path, etc.)
277
277
  // - JSON files imported with `with { type: 'json' }` (e.g. package.json for version)
278
- // These are only used inside spec.server functions or module-level server init;
279
- // 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.
280
283
  let content = fs.readFileSync(filePath, 'utf-8')
281
284
  content = content.replace(
282
- /^[ \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,
283
286
  ''
284
287
  )
285
288
  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
- 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
  ]
@@ -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