@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.
- package/.claude/commands/pulse-start.md +11 -0
- package/.claude/commands/pulse-stop.md +5 -0
- 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/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/coverage-check.js +154 -90
- package/src/agent/workflow.md +16 -2
- package/src/cli/cli.test.js +72 -0
- package/src/cli/dev.js +6 -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 +406 -21
|
@@ -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.
|
|
@@ -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
|
|
8
|
-
*
|
|
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
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
115
|
+
const exemptLines = computeExemptLines(src)
|
|
116
|
+
return lineNums.every(n => exemptLines.has(n))
|
|
117
|
+
}
|
|
111
118
|
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
8
|
-
*
|
|
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
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
115
|
+
const exemptLines = computeExemptLines(src)
|
|
116
|
+
return lineNums.every(n => exemptLines.has(n))
|
|
117
|
+
}
|
|
111
118
|
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
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
|
|
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
|
-
|
|
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.
|