@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.
@@ -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
+ ```
@@ -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
  }
@@ -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.1",
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.1
1
+ 0.20.2
package/scripts/build.js CHANGED
@@ -45,7 +45,7 @@ const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentTy
45
45
  * top-level variable declarations whose right-hand side calls the imported names.
46
46
  * All `node:*` built-ins are also stripped automatically via the import regex.
47
47
  */
48
- const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
48
+ const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
49
49
 
50
50
  /**
51
51
  * Strip all server-only property declarations from a spec source string.
@@ -8,7 +8,7 @@
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
11
- const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
11
+ const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
12
12
 
13
13
  function stripServerOnlyKeys(source) {
14
14
  for (const key of SERVER_ONLY_KEYS) source = removeObjectKey(source, key)
@@ -415,6 +415,16 @@ test('store definition: server fetchers stripped, state and mutations survive',
415
415
  `export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
416
416
  )
417
417
 
418
+ test('package-root import (pushStore) is stripped — the root export is the server itself',
419
+ `import { pushStore } from '@invisibleloop/pulse'\nexport default {\n route: '/staff',\n view: () => '',\n}`,
420
+ `export default {\n route: '/staff',\n view: () => '',\n}`
421
+ )
422
+
423
+ test('subpath imports (/ui) are NOT stripped by the root entry',
424
+ `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`,
425
+ `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`
426
+ )
427
+
418
428
  // ---------------------------------------------------------------------------
419
429
 
420
430
  console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
@@ -1,9 +1,11 @@
1
- Start the Pulse production server for this project.
1
+ Start the Pulse production server for this project — **locally, on the production port (dev port + 1, normally 3001)** so it can run alongside the dev server on 3000.
2
2
 
3
- First ensure a build exists (`public/dist/`). Then run:
3
+ First ensure a build exists (`public/dist/` — run `pulse build` if not). Then:
4
4
 
5
5
  ```bash
6
- pulse start
6
+ pulse start --port 3001
7
7
  ```
8
8
 
9
- The production server uses the port from `pulse.config.js` (defaults to 3000).
9
+ If `pulse.config.js` sets a custom `port`, use that value + 1 instead.
10
+
11
+ > **Why the flag:** plain `pulse start` binds the configured port (3000) because in real deployment it is the only server and platforms inject `PORT`. Locally that collides with the dev server and breaks the convention used by `pulse_build`, Lighthouse, and the performance trace — production is always dev port + 1. Never use `--port` in actual deployment.
@@ -1,4 +1,4 @@
1
- Stop the Pulse dev server for this project.
1
+ Stop the Pulse servers for this project — both the dev server (port 3000) and the local production server (port 3001, started by `pulse_build` or `/pulse-start`).
2
2
 
3
3
  ```bash
4
4
  pulse stop