@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.
@@ -0,0 +1,11 @@
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
+
3
+ First ensure a build exists (`public/dist/` — run `pulse build` if not). Then:
4
+
5
+ ```bash
6
+ pulse start --port 3001
7
+ ```
8
+
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.
@@ -0,0 +1,5 @@
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
+
3
+ ```bash
4
+ pulse stop
5
+ ```
package/CLAUDE.md CHANGED
@@ -75,8 +75,10 @@ export const mySpec = {
75
75
  // async () => [...] → enumerate a dynamic :param route's URLs
76
76
  sitemap: async () => (await db.posts.list()).map(p => `/blog/${p.slug}`),
77
77
 
78
- // Global store keys this page subscribes to — appears in view's server arg
79
- // Store mutations update all subscribed pages without a server round-trip
78
+ // Global store keys this page subscribes to — appears in view's server arg.
79
+ // The store is defined in pulse.store.js at the project root — AUTO-DISCOVERED
80
+ // by pulse dev/start (no registration step). Mutations update all subscribed
81
+ // pages without a server round-trip.
80
82
  store: ['user', 'cart'],
81
83
 
82
84
  // Initial client state — deep cloned on mount, never mutated directly
@@ -4,87 +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'
29
+ import { pathToFileURL } from 'url'
15
30
 
16
- // No test files → nothing to check
17
- const hasTests = fs.existsSync('src/pages') && (function scan(d) {
18
- for (const f of fs.readdirSync(d)) {
19
- const p = `${d}/${f}`
20
- if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
21
- else if (f.endsWith('.test.js')) return true
22
- }
23
- return false
24
- })('src/pages')
25
-
26
- if (!hasTests) process.exit(0)
27
-
28
- let out = ''
29
- try {
30
- out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
31
- } catch (e) {
32
- out = e.stdout || e.message
33
- }
34
-
35
- // Parse the coverage report — look for lines with uncovered line numbers
36
- const lines = out.split('\n')
37
- let inReport = false
38
- const rawGaps = []
39
-
40
- for (const line of lines) {
41
- if (line.includes('start of coverage report')) { inReport = true; continue }
42
- if (!inReport) continue
43
-
44
- // Match: # filename.js | line% | branch% | func% | 30-36 50
45
- const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
46
- if (m) {
47
- const uncov = m[2].trim()
48
- if (uncov && uncov !== 'uncovered lines') {
49
- rawGaps.push({ file: m[1].trim(), uncovered: uncov })
50
- }
51
- }
52
- }
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
+ ]
53
41
 
54
42
  /**
55
- * Returns true if all uncovered line numbers in the given file fall inside
56
- * a `server:` property block or an action `run:` function — both of which
57
- * 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>}
58
48
  */
59
- function allLinesExempt(filePath, uncoveredSpec) {
60
- let src
61
- try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
49
+ export function computeExemptLines(src) {
62
50
  const srcLines = src.split('\n')
63
-
64
- // Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
65
- const lineNums = []
66
- for (const part of uncoveredSpec.split(/\s+/)) {
67
- const range = part.match(/^(\d+)-(\d+)$/)
68
- if (range) {
69
- for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
70
- } else if (/^\d+$/.test(part)) {
71
- lineNums.push(parseInt(part))
72
- }
73
- }
74
-
75
- // Build a set of exempt line numbers: lines inside `server:` blocks or action `run:` functions.
76
- // Strategy: scan the source tracking brace depth relative to the start of each exempt block.
77
51
  const exemptLines = new Set()
52
+
53
+ // 1. Exempt property blocks — scan tracking brace/paren depth from block start
78
54
  let i = 0
79
55
  while (i < srcLines.length) {
80
56
  const trimmed = srcLines[i].trimStart()
81
- // Detect start of `server:` property or action `run:` function
82
- const isExemptStart = /^server\s*:/.test(trimmed) || /^run\s*:/.test(trimmed) || /^run\s*\(/.test(trimmed)
83
- if (isExemptStart) {
84
- // Mark lines from here until the block closes (depth reaches 0 after opening)
57
+ if (EXEMPT_BLOCK_STARTS.some(re => re.test(trimmed))) {
85
58
  let depth = 0
86
59
  let started = false
87
- const startLine = i
88
60
  while (i < srcLines.length) {
89
61
  exemptLines.add(i + 1) // 1-based
90
62
  for (const ch of srcLines[i]) {
@@ -98,30 +70,127 @@ function allLinesExempt(filePath, uncoveredSpec) {
98
70
  i++
99
71
  }
100
72
 
101
- 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
102
91
  }
103
92
 
104
- const gaps = []
105
- for (const { file, uncovered } of rawGaps) {
106
- // Try to resolve relative to cwd (coverage reports use relative paths)
107
- const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
108
- 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
+ }
109
114
 
110
- if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
115
+ const exemptLines = computeExemptLines(src)
116
+ return lineNums.every(n => exemptLines.has(n))
117
+ }
111
118
 
112
- gaps.push(` ${file} — uncovered lines: ${uncovered}`)
119
+ // ---------------------------------------------------------------------------
120
+ // Main — only when executed directly (the exports above are unit-tested)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
124
+ main()
113
125
  }
114
126
 
115
- if (gaps.length) {
116
- process.stdout.write(JSON.stringify({
117
- decision: 'block',
118
- reason: [
119
- 'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
120
- ...gaps,
121
- '',
122
- 'Run `npm run test:coverage` to see the full report.',
123
- 'Exempt from this rule: async action run() functions and server fetchers that call real APIs.',
124
- 'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
125
- ].join('\n'),
126
- }))
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
+ }
127
196
  }
@@ -50,16 +50,15 @@ export default {
50
50
  }`, 'js'))}
51
51
 
52
52
  ${section('registering', 'Registering the store')}
53
- <p>Pass your store to <code>createServer</code> via the <code>store</code> option. The store is validated at startup — bad fetchers throw before the server accepts connections:</p>
53
+ <p><strong>There is no registration step.</strong> <code>pulse dev</code> and <code>pulse start</code> auto-discover <code>pulse.store.js</code> from the project root — the same way pages are discovered from <code>src/pages/</code>. The store is loaded server-side, served to the browser for mutations, validated at startup (bad fetchers fail the boot), and hot-reloaded in dev when you edit it.</p>
54
+ <p>Only when you run your own server entry do you pass it manually:</p>
54
55
  ${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
55
56
  import store from './pulse.store.js'
56
- import { dashboardSpec } from './src/pages/dashboard.js'
57
- import { settingsSpec } from './src/pages/settings.js'
58
57
 
59
- createServer([dashboardSpec, settingsSpec], {
58
+ createServer(pages, {
60
59
  port: 3000,
61
60
  staticDir: 'public',
62
- store, // ← register the global store
61
+ store, // ← manual server entry only
63
62
  })`, 'js'))}
64
63
 
65
64
  ${section('using', 'Using store data in a page')}
@@ -4,87 +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'
29
+ import { pathToFileURL } from 'url'
15
30
 
16
- // No test files → nothing to check
17
- const hasTests = fs.existsSync('src/pages') && (function scan(d) {
18
- for (const f of fs.readdirSync(d)) {
19
- const p = `${d}/${f}`
20
- if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
21
- else if (f.endsWith('.test.js')) return true
22
- }
23
- return false
24
- })('src/pages')
25
-
26
- if (!hasTests) process.exit(0)
27
-
28
- let out = ''
29
- try {
30
- out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
31
- } catch (e) {
32
- out = e.stdout || e.message
33
- }
34
-
35
- // Parse the coverage report — look for lines with uncovered line numbers
36
- const lines = out.split('\n')
37
- let inReport = false
38
- const rawGaps = []
39
-
40
- for (const line of lines) {
41
- if (line.includes('start of coverage report')) { inReport = true; continue }
42
- if (!inReport) continue
43
-
44
- // Match: # filename.js | line% | branch% | func% | 30-36 50
45
- const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
46
- if (m) {
47
- const uncov = m[2].trim()
48
- if (uncov && uncov !== 'uncovered lines') {
49
- rawGaps.push({ file: m[1].trim(), uncovered: uncov })
50
- }
51
- }
52
- }
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
+ ]
53
41
 
54
42
  /**
55
- * Returns true if all uncovered line numbers in the given file fall inside
56
- * a `server:` property block or an action `run:` function — both of which
57
- * 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>}
58
48
  */
59
- function allLinesExempt(filePath, uncoveredSpec) {
60
- let src
61
- try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
49
+ export function computeExemptLines(src) {
62
50
  const srcLines = src.split('\n')
63
-
64
- // Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
65
- const lineNums = []
66
- for (const part of uncoveredSpec.split(/\s+/)) {
67
- const range = part.match(/^(\d+)-(\d+)$/)
68
- if (range) {
69
- for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
70
- } else if (/^\d+$/.test(part)) {
71
- lineNums.push(parseInt(part))
72
- }
73
- }
74
-
75
- // Build a set of exempt line numbers: lines inside `server:` blocks or action `run:` functions.
76
- // Strategy: scan the source tracking brace depth relative to the start of each exempt block.
77
51
  const exemptLines = new Set()
52
+
53
+ // 1. Exempt property blocks — scan tracking brace/paren depth from block start
78
54
  let i = 0
79
55
  while (i < srcLines.length) {
80
56
  const trimmed = srcLines[i].trimStart()
81
- // Detect start of `server:` property or action `run:` function
82
- const isExemptStart = /^server\s*:/.test(trimmed) || /^run\s*:/.test(trimmed) || /^run\s*\(/.test(trimmed)
83
- if (isExemptStart) {
84
- // Mark lines from here until the block closes (depth reaches 0 after opening)
57
+ if (EXEMPT_BLOCK_STARTS.some(re => re.test(trimmed))) {
85
58
  let depth = 0
86
59
  let started = false
87
- const startLine = i
88
60
  while (i < srcLines.length) {
89
61
  exemptLines.add(i + 1) // 1-based
90
62
  for (const ch of srcLines[i]) {
@@ -98,30 +70,127 @@ function allLinesExempt(filePath, uncoveredSpec) {
98
70
  i++
99
71
  }
100
72
 
101
- 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
102
91
  }
103
92
 
104
- const gaps = []
105
- for (const { file, uncovered } of rawGaps) {
106
- // Try to resolve relative to cwd (coverage reports use relative paths)
107
- const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
108
- 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
+ }
109
114
 
110
- if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
115
+ const exemptLines = computeExemptLines(src)
116
+ return lineNums.every(n => exemptLines.has(n))
117
+ }
111
118
 
112
- gaps.push(` ${file} — uncovered lines: ${uncovered}`)
119
+ // ---------------------------------------------------------------------------
120
+ // Main — only when executed directly (the exports above are unit-tested)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
124
+ main()
113
125
  }
114
126
 
115
- if (gaps.length) {
116
- process.stdout.write(JSON.stringify({
117
- decision: 'block',
118
- reason: [
119
- 'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
120
- ...gaps,
121
- '',
122
- 'Run `npm run test:coverage` to see the full report.',
123
- 'Exempt from this rule: async action run() functions and server fetchers that call real APIs.',
124
- 'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
125
- ].join('\n'),
126
- }))
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
+ }
127
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.0
1
+ 0.20.2