@invisibleloop/pulse 0.20.1 → 0.20.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/pulse-start.md +11 -0
- package/.claude/commands/pulse-stop.md +5 -0
- package/.claude/commands/verify.md +17 -11
- package/docs/.claude/coverage-check.js +153 -84
- package/examples/.claude/coverage-check.js +153 -84
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +1 -1
- package/scripts/css-purge.js +65 -3
- package/scripts/css-purge.test.js +16 -0
- package/scripts/strip-server.test.js +11 -1
- package/src/agent/commands/pulse-start.md +6 -4
- package/src/agent/commands/pulse-stop.md +1 -1
- package/src/agent/commands/verify.md +17 -11
- package/src/agent/coverage-check.js +154 -90
- package/src/agent/guide-components.md +2 -2
- package/src/agent/identity.md +2 -2
- package/src/agent/workflow.md +21 -4
- package/src/cli/cli.test.js +72 -0
- package/src/cli/dev.js +23 -3
- package/src/cli/index.js +5 -2
- package/src/cli/scaffold.js +2 -2
- package/src/mcp/mcp.test.js +44 -0
- package/src/mcp/server.js +523 -33
- package/src/mcp/validate-worker.js +3 -0
- package/src/runtime/index.js +9 -0
- package/src/server/index.js +21 -30
|
@@ -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.
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# Verify
|
|
2
2
|
|
|
3
|
-
Run the
|
|
3
|
+
Run the verification loop on the current page or the page specified in $ARGUMENTS.
|
|
4
|
+
|
|
5
|
+
**Modes:**
|
|
6
|
+
- `/verify` — full loop including Lighthouse (desktop + mobile), perf trace, and mobile layout check. Use when the user approves the design or signals they're ready to ship.
|
|
7
|
+
- `/verify --quick` — skips Lighthouse and perf trace; just validate → screenshot → console → review → stamp. Use during iterative building: design rounds, debugging visual issues, mid-build checkpoints. Fast enough to run between every change.
|
|
8
|
+
|
|
9
|
+
If `--quick` appears anywhere in $ARGUMENTS, run in quick mode (steps 6, 7, 8, and 9 are skipped). The route/file path is whatever remains in $ARGUMENTS after removing `--quick`.
|
|
4
10
|
|
|
5
11
|
## Steps
|
|
6
12
|
|
|
7
13
|
### 1. Identify the target
|
|
8
14
|
|
|
9
|
-
If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
|
|
15
|
+
If $ARGUMENTS is provided, use it (minus `--quick` if present) as the file path or route. Otherwise, identify the most recently edited spec file from context.
|
|
10
16
|
|
|
11
17
|
### 2. Design approval (new builds only)
|
|
12
18
|
|
|
@@ -30,7 +36,7 @@ Use `pulse_fetch_page` to confirm the server is responding for the route. If it
|
|
|
30
36
|
|
|
31
37
|
Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chrome-devtools__take_screenshot` to capture the result. Describe what you see — layout, content, any obvious rendering issues.
|
|
32
38
|
|
|
33
|
-
### 6. Lighthouse — desktop
|
|
39
|
+
### 6. Lighthouse — desktop *(skip in quick mode)*
|
|
34
40
|
|
|
35
41
|
**Pre-flight (required before every Lighthouse run):**
|
|
36
42
|
1. Call `pulse_build` to produce a production build and start the production server on port 3001.
|
|
@@ -40,7 +46,7 @@ Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`
|
|
|
40
46
|
|
|
41
47
|
**Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 3.
|
|
42
48
|
|
|
43
|
-
### 7. Lighthouse — mobile
|
|
49
|
+
### 7. Lighthouse — mobile *(skip in quick mode)*
|
|
44
50
|
|
|
45
51
|
The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
|
|
46
52
|
|
|
@@ -48,7 +54,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
|
|
|
48
54
|
|
|
49
55
|
**Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
|
|
50
56
|
|
|
51
|
-
### 8. Mobile layout check
|
|
57
|
+
### 8. Mobile layout check *(skip in quick mode)*
|
|
52
58
|
|
|
53
59
|
Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and layout issues before they become Lighthouse failures.
|
|
54
60
|
|
|
@@ -70,7 +76,7 @@ Fix any issues, then reset to desktop before continuing:
|
|
|
70
76
|
mcp__chrome-devtools__emulate viewport: "1440x900x1"
|
|
71
77
|
```
|
|
72
78
|
|
|
73
|
-
### 9. Performance
|
|
79
|
+
### 9. Performance *(skip in quick mode)*
|
|
74
80
|
|
|
75
81
|
**Run the trace against the production server, not dev.** Navigate to `http://localhost:3001/<route>` first — if the production server is no longer running, run `pulse_build` again. Tracing the dev server (port 3000) reports misleading LCP/CLS. Then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
|
|
76
82
|
|
|
@@ -102,11 +108,11 @@ Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is m
|
|
|
102
108
|
|
|
103
109
|
Summarise:
|
|
104
110
|
- Validation: pass or fail (with errors if any)
|
|
105
|
-
- Visual: desktop screenshot
|
|
106
|
-
- Lighthouse desktop: scores for Accessibility, Best Practices, SEO
|
|
107
|
-
- Lighthouse mobile: scores for Accessibility, Best Practices, SEO
|
|
108
|
-
- Performance: LCP and CLS values, any flagged insights
|
|
111
|
+
- Visual: desktop screenshot — any issues found and fixed
|
|
109
112
|
- Console: any errors
|
|
110
113
|
- Review: pass or issues found and fixed
|
|
114
|
+
- **Full mode only:** Lighthouse desktop/mobile scores, mobile layout check, LCP and CLS values
|
|
115
|
+
|
|
116
|
+
In quick mode, end with: "Quick check passed — run `/verify` when you're ready for the full Lighthouse audit."
|
|
111
117
|
|
|
112
|
-
|
|
118
|
+
In full mode, only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
|
|
@@ -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.3
|
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.
|