@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
|
@@ -4,92 +4,59 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Called by the Stop hook in .claude/settings.json to enforce test coverage.
|
|
6
6
|
* Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
|
|
7
|
-
* has uncovered lines — except
|
|
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'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
return false
|
|
29
|
-
})('src/pages')
|
|
30
|
-
|
|
31
|
-
if (!hasTests) process.exit(0)
|
|
32
|
-
|
|
33
|
-
let out = ''
|
|
34
|
-
try {
|
|
35
|
-
out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
|
|
36
|
-
} catch (e) {
|
|
37
|
-
out = e.stdout || e.message
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Parse the coverage report — look for lines with uncovered line numbers
|
|
41
|
-
const lines = out.split('\n')
|
|
42
|
-
let inReport = false
|
|
43
|
-
const rawGaps = []
|
|
44
|
-
|
|
45
|
-
for (const line of lines) {
|
|
46
|
-
if (line.includes('start of coverage report')) { inReport = true; continue }
|
|
47
|
-
if (!inReport) continue
|
|
48
|
-
|
|
49
|
-
// Match: # filename.js | line% | branch% | func% | 30-36 50
|
|
50
|
-
const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
|
|
51
|
-
if (m) {
|
|
52
|
-
const uncov = m[2].trim()
|
|
53
|
-
if (uncov && uncov !== 'uncovered lines') {
|
|
54
|
-
rawGaps.push({ file: m[1].trim(), uncovered: uncov })
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
29
|
+
import { pathToFileURL } from 'url'
|
|
30
|
+
|
|
31
|
+
// Property blocks that are exempt — all server-side-only code paths that call
|
|
32
|
+
// real external APIs and cannot run in unit tests.
|
|
33
|
+
const EXEMPT_BLOCK_STARTS = [
|
|
34
|
+
/^server\s*:/,
|
|
35
|
+
/^run\s*:/, /^run\s*\(/,
|
|
36
|
+
/^guard\s*:/,
|
|
37
|
+
/^submit\s*:/,
|
|
38
|
+
/^sitemap\s*:/,
|
|
39
|
+
/^meta\s*:/,
|
|
40
|
+
]
|
|
58
41
|
|
|
59
42
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
43
|
+
* Build the set of exempt (1-based) line numbers for a source string:
|
|
44
|
+
* lines inside exempt property blocks, plus c8-ignore-annotated lines.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} src
|
|
47
|
+
* @returns {Set<number>}
|
|
63
48
|
*/
|
|
64
|
-
function
|
|
65
|
-
let src
|
|
66
|
-
try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
|
|
49
|
+
export function computeExemptLines(src) {
|
|
67
50
|
const srcLines = src.split('\n')
|
|
68
|
-
|
|
69
|
-
// Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
|
|
70
|
-
const lineNums = []
|
|
71
|
-
for (const part of uncoveredSpec.split(/\s+/)) {
|
|
72
|
-
const range = part.match(/^(\d+)-(\d+)$/)
|
|
73
|
-
if (range) {
|
|
74
|
-
for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
|
|
75
|
-
} else if (/^\d+$/.test(part)) {
|
|
76
|
-
lineNums.push(parseInt(part))
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Build a set of exempt line numbers: lines inside `server:` blocks or action `run:` functions.
|
|
81
|
-
// Strategy: scan the source tracking brace depth relative to the start of each exempt block.
|
|
82
51
|
const exemptLines = new Set()
|
|
52
|
+
|
|
53
|
+
// 1. Exempt property blocks — scan tracking brace/paren depth from block start
|
|
83
54
|
let i = 0
|
|
84
55
|
while (i < srcLines.length) {
|
|
85
56
|
const trimmed = srcLines[i].trimStart()
|
|
86
|
-
|
|
87
|
-
const isExemptStart = /^server\s*:/.test(trimmed) || /^run\s*:/.test(trimmed) || /^run\s*\(/.test(trimmed)
|
|
88
|
-
if (isExemptStart) {
|
|
89
|
-
// Mark lines from here until the block closes (depth reaches 0 after opening)
|
|
57
|
+
if (EXEMPT_BLOCK_STARTS.some(re => re.test(trimmed))) {
|
|
90
58
|
let depth = 0
|
|
91
59
|
let started = false
|
|
92
|
-
const startLine = i
|
|
93
60
|
while (i < srcLines.length) {
|
|
94
61
|
exemptLines.add(i + 1) // 1-based
|
|
95
62
|
for (const ch of srcLines[i]) {
|
|
@@ -103,30 +70,127 @@ function allLinesExempt(filePath, uncoveredSpec) {
|
|
|
103
70
|
i++
|
|
104
71
|
}
|
|
105
72
|
|
|
106
|
-
|
|
73
|
+
// 2. c8-style ignore comments (Node's built-in coverage ignores these, so we
|
|
74
|
+
// honour them here — the marker line itself is always exempt too)
|
|
75
|
+
const IGNORE = /(?:c8 ignore|coverage-ignore)(?:\s+(next|start|stop))?(?:\s+(\d+))?/
|
|
76
|
+
let rangeOpen = false
|
|
77
|
+
for (let n = 0; n < srcLines.length; n++) {
|
|
78
|
+
if (rangeOpen) exemptLines.add(n + 1)
|
|
79
|
+
const m = srcLines[n].match(IGNORE)
|
|
80
|
+
if (!m) continue
|
|
81
|
+
exemptLines.add(n + 1)
|
|
82
|
+
const keyword = m[1]
|
|
83
|
+
if (keyword === 'start') { rangeOpen = true; continue }
|
|
84
|
+
if (keyword === 'stop') { rangeOpen = false; continue }
|
|
85
|
+
// 'next' (or bare marker) — exempt the following N lines (default 1)
|
|
86
|
+
const count = m[2] ? parseInt(m[2], 10) : 1
|
|
87
|
+
for (let k = 1; k <= count; k++) exemptLines.add(n + 1 + k)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return exemptLines
|
|
107
91
|
}
|
|
108
92
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Returns true if all uncovered line numbers fall on exempt lines.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} filePath
|
|
97
|
+
* @param {string} uncoveredSpec - e.g. "30-36 50"
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
export function allLinesExempt(filePath, uncoveredSpec) {
|
|
101
|
+
let src
|
|
102
|
+
try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
|
|
103
|
+
|
|
104
|
+
// Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
|
|
105
|
+
const lineNums = []
|
|
106
|
+
for (const part of uncoveredSpec.split(/\s+/)) {
|
|
107
|
+
const range = part.match(/^(\d+)-(\d+)$/)
|
|
108
|
+
if (range) {
|
|
109
|
+
for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
|
|
110
|
+
} else if (/^\d+$/.test(part)) {
|
|
111
|
+
lineNums.push(parseInt(part))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const exemptLines = computeExemptLines(src)
|
|
116
|
+
return lineNums.every(n => exemptLines.has(n))
|
|
117
|
+
}
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Main — only when executed directly (the exports above are unit-tested)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
116
122
|
|
|
117
|
-
|
|
123
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
124
|
+
main()
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
function main() {
|
|
128
|
+
// Agent is ending its turn to wait for the user's answer (approval gate or a
|
|
129
|
+
// mid-flow pause) — let the turn end. The marker is consumed on the user's
|
|
130
|
+
// next prompt, so the gates return in full force afterwards.
|
|
131
|
+
if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
|
|
132
|
+
|
|
133
|
+
// No test files → nothing to check
|
|
134
|
+
const hasTests = fs.existsSync('src/pages') && (function scan(d) {
|
|
135
|
+
for (const f of fs.readdirSync(d)) {
|
|
136
|
+
const p = `${d}/${f}`
|
|
137
|
+
if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
|
|
138
|
+
else if (f.endsWith('.test.js')) return true
|
|
139
|
+
}
|
|
140
|
+
return false
|
|
141
|
+
})('src/pages')
|
|
142
|
+
|
|
143
|
+
if (!hasTests) process.exit(0)
|
|
144
|
+
|
|
145
|
+
let out = ''
|
|
146
|
+
try {
|
|
147
|
+
out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
|
|
148
|
+
} catch (e) {
|
|
149
|
+
out = e.stdout || e.message
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Parse the coverage report — look for lines with uncovered line numbers
|
|
153
|
+
const lines = out.split('\n')
|
|
154
|
+
let inReport = false
|
|
155
|
+
const rawGaps = []
|
|
156
|
+
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
if (line.includes('start of coverage report')) { inReport = true; continue }
|
|
159
|
+
if (!inReport) continue
|
|
160
|
+
|
|
161
|
+
// Match: # filename.js | line% | branch% | func% | 30-36 50
|
|
162
|
+
const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
|
|
163
|
+
if (m) {
|
|
164
|
+
const uncov = m[2].trim()
|
|
165
|
+
if (uncov && uncov !== 'uncovered lines') {
|
|
166
|
+
rawGaps.push({ file: m[1].trim(), uncovered: uncov })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const gaps = []
|
|
172
|
+
for (const { file, uncovered } of rawGaps) {
|
|
173
|
+
// Try to resolve relative to cwd (coverage reports use relative paths)
|
|
174
|
+
const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
|
|
175
|
+
const resolved = candidates.find(p => fs.existsSync(p))
|
|
176
|
+
|
|
177
|
+
if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
|
|
178
|
+
|
|
179
|
+
gaps.push(` ${file} — uncovered lines: ${uncovered}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (gaps.length) {
|
|
183
|
+
process.stdout.write(JSON.stringify({
|
|
184
|
+
decision: 'block',
|
|
185
|
+
reason: [
|
|
186
|
+
'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
|
|
187
|
+
...gaps,
|
|
188
|
+
'',
|
|
189
|
+
'Run `npm run test:coverage` to see the full report.',
|
|
190
|
+
'Auto-exempt: server fetchers, action run(), guard, submit, sitemap, and meta blocks — these call real APIs and need no tests.',
|
|
191
|
+
'For anything else that genuinely cannot be tested, annotate it: /* c8 ignore next */ (or next N, or start/stop for a range) — this hook honours those comments even though Node\'s built-in coverage does not.',
|
|
192
|
+
'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
|
|
193
|
+
].join('\n'),
|
|
194
|
+
}))
|
|
195
|
+
}
|
|
132
196
|
}
|
package/src/agent/workflow.md
CHANGED
|
@@ -165,6 +165,20 @@ If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop
|
|
|
165
165
|
**Mode B — Creative override (raw HTML throughout)**
|
|
166
166
|
When the design intent calls for a more expressive, unconventional, or typographically-driven layout that components would constrain, you may build with raw HTML throughout. This is a deliberate design decision, not a shortcut.
|
|
167
167
|
|
|
168
|
+
**When to choose Mode B — use the component, unless:**
|
|
169
|
+
- The layout requires **full-viewport height or edge-to-edge bleed** that the component doesn't support (e.g. `hero()` doesn't fill the viewport)
|
|
170
|
+
- The design uses **custom gradient glows, parallax, or large-scale image fills** that the component would wrap in unwanted structure
|
|
171
|
+
- The vibe is **brutalist, retro, neon, paper, or typographic-editorial** and the structural feel would be actively fought by the component's default padding/spacing/radius
|
|
172
|
+
- The typography is **clamp-scaled display type** (e.g. `clamp(4rem, 10vw, 12rem)`) that breaks inside a component's internal box model
|
|
173
|
+
- The layout is **asymmetric or zone-based** (e.g. 70/30 split, overlapping elements, staggered grid) that no single component supports
|
|
174
|
+
- A **Figma/reference design was provided** that clearly departs from the component's output
|
|
175
|
+
|
|
176
|
+
**When to use the component (not a valid reason for override):**
|
|
177
|
+
- "The component doesn't have a perfect prop for this" — add a utility class or wrapper div instead
|
|
178
|
+
- "I want a slightly different colour or size" — override with CSS tokens
|
|
179
|
+
- "I'm not sure what the component looks like" — check the guide first
|
|
180
|
+
- "It's easier to write raw HTML" — this is never a valid reason
|
|
181
|
+
|
|
168
182
|
Rules for Mode B:
|
|
169
183
|
- **Declare it in a comment at the top of the spec file:** `// component-free — creative override: <reason>`. The review tool detects creative override by reading this comment from the source — without it, `pulse_review` will flag every component pattern as a violation even though you chose Mode B deliberately. Announcing the mode in chat is not enough; the comment must be in the file.
|
|
170
184
|
- Load `/pulse-ui.css` in `meta.styles` — the token system is still required
|
|
@@ -206,9 +220,9 @@ After the Write tool completes, call `pulse_create_page(name)` to register the p
|
|
|
206
220
|
✓ src/pages/my-page.js written and registered.
|
|
207
221
|
```
|
|
208
222
|
|
|
209
|
-
### 3d — Suggest (
|
|
223
|
+
### 3d — Suggest (recommended mid-build checkpoint)
|
|
210
224
|
|
|
211
|
-
After writing the first draft, call `pulse_suggest(content)` before running the hard validator. It catches obvious omissions collaboratively — a second opinion, not a gate.
|
|
225
|
+
After writing the first draft, call `pulse_suggest(content)` before running the hard validator. It catches obvious omissions collaboratively — a second opinion, not a gate. Then call `pulse_review(file, { quick: true })` for a lightweight structural pass (main landmark, data-event on inputs, missing onError, hex in view). Neither is a substitute for the full review, but catching issues here is faster than discovering them at Lighthouse.
|
|
212
226
|
|
|
213
227
|
**Rule: never output `✓` before the tool call that confirms the result.**
|
|
214
228
|
|
package/src/cli/cli.test.js
CHANGED
|
@@ -158,6 +158,78 @@ console.log('\nStore auto-discovery\n')
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Coverage-check exemptions
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Regression: the stop hook blocked every session on uncovered guard/submit/
|
|
165
|
+
// sitemap/meta lines (server-only, untestable), and c8 ignore comments were
|
|
166
|
+
// not honoured because Node's built-in coverage ignores them.
|
|
167
|
+
|
|
168
|
+
console.log('\nCoverage-check exemptions\n')
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
const { computeExemptLines } = await import('../agent/coverage-check.js')
|
|
172
|
+
|
|
173
|
+
const src = [
|
|
174
|
+
/* 1 */ `export default {`,
|
|
175
|
+
/* 2 */ ` route: '/x',`,
|
|
176
|
+
/* 3 */ ` guard: async (ctx) => {`,
|
|
177
|
+
/* 4 */ ` if (!ctx.cookies.session) return { redirect: '/login' }`,
|
|
178
|
+
/* 5 */ ` },`,
|
|
179
|
+
/* 6 */ ` submit: async (ctx) => {`,
|
|
180
|
+
/* 7 */ ` return { redirect: '/done' }`,
|
|
181
|
+
/* 8 */ ` },`,
|
|
182
|
+
/* 9 */ ` sitemap: async () => ['/a'],`,
|
|
183
|
+
/* 10 */ ` meta: {`,
|
|
184
|
+
/* 11 */ ` title: async (ctx) => fetchTitle(ctx),`,
|
|
185
|
+
/* 12 */ ` },`,
|
|
186
|
+
/* 13 */ ` view: (state) => state.n,`,
|
|
187
|
+
/* 14 */ ` helpers: () => {`,
|
|
188
|
+
/* 15 */ ` /* c8 ignore next 2 */`,
|
|
189
|
+
/* 16 */ ` impossibleBranch()`,
|
|
190
|
+
/* 17 */ ` alsoImpossible()`,
|
|
191
|
+
/* 18 */ ` normalCode()`,
|
|
192
|
+
/* 19 */ ` },`,
|
|
193
|
+
/* 20 */ `}`,
|
|
194
|
+
].join('\n')
|
|
195
|
+
|
|
196
|
+
const exempt = computeExemptLines(src)
|
|
197
|
+
|
|
198
|
+
test('guard, submit, sitemap, and meta blocks are exempt', () => {
|
|
199
|
+
for (const n of [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) {
|
|
200
|
+
if (!exempt.has(n)) throw new Error(`Expected line ${n} to be exempt`)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('view and ordinary helper code are NOT exempt', () => {
|
|
205
|
+
for (const n of [13, 18]) {
|
|
206
|
+
if (exempt.has(n)) throw new Error(`Line ${n} must not be exempt`)
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('c8 ignore next N exempts exactly the following N lines', () => {
|
|
211
|
+
for (const n of [15, 16, 17]) {
|
|
212
|
+
if (!exempt.has(n)) throw new Error(`Expected line ${n} to be exempt via c8 ignore`)
|
|
213
|
+
}
|
|
214
|
+
if (exempt.has(18)) throw new Error('Line 18 is beyond the ignore count and must not be exempt')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('c8 ignore start/stop exempts the whole range', () => {
|
|
218
|
+
const ranged = computeExemptLines([
|
|
219
|
+
`const a = 1`, // 1
|
|
220
|
+
`/* c8 ignore start */`, // 2
|
|
221
|
+
`untestable1()`, // 3
|
|
222
|
+
`untestable2()`, // 4
|
|
223
|
+
`/* c8 ignore stop */`, // 5
|
|
224
|
+
`const b = 2`, // 6
|
|
225
|
+
].join('\n'))
|
|
226
|
+
for (const n of [2, 3, 4, 5]) {
|
|
227
|
+
if (!ranged.has(n)) throw new Error(`Expected line ${n} exempt in range`)
|
|
228
|
+
}
|
|
229
|
+
if (ranged.has(6)) throw new Error('Line after stop must not be exempt')
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
161
233
|
// ---------------------------------------------------------------------------
|
|
162
234
|
|
|
163
235
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/cli/dev.js
CHANGED
|
@@ -275,11 +275,14 @@ const { updateSpecs, updateStore } = await createServer(specs, {
|
|
|
275
275
|
// Strip server-only imports that can't resolve in the browser:
|
|
276
276
|
// - node:* built-ins (fs, path, etc.)
|
|
277
277
|
// - JSON files imported with `with { type: 'json' }` (e.g. package.json for version)
|
|
278
|
-
//
|
|
279
|
-
//
|
|
278
|
+
// - the package ROOT (@invisibleloop/pulse) — that export is the server
|
|
279
|
+
// itself (createServer, pushStore); the closing quote anchors the match
|
|
280
|
+
// so subpaths like @invisibleloop/pulse/ui are untouched
|
|
281
|
+
// These are only used inside spec.server/submit functions or module-level
|
|
282
|
+
// server init; the client runtime never calls those code paths.
|
|
280
283
|
let content = fs.readFileSync(filePath, 'utf-8')
|
|
281
284
|
content = content.replace(
|
|
282
|
-
/^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
|
|
285
|
+
/^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json|@invisibleloop\/pulse)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
|
|
283
286
|
''
|
|
284
287
|
)
|
|
285
288
|
res.writeHead(200, {
|
package/src/cli/index.js
CHANGED
|
@@ -269,8 +269,11 @@ async function runStop(root) {
|
|
|
269
269
|
} catch { /* use default */ }
|
|
270
270
|
}
|
|
271
271
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
// Kill the dev server AND the local production server (dev port + 1) —
|
|
273
|
+
// pulse_build spawns the prod server detached, so without this it lingers
|
|
274
|
+
// after the session and blocks the next build's Lighthouse run.
|
|
275
|
+
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null; lsof -ti:${port + 1} | xargs kill -9 2>/dev/null; true`, { stdio: 'inherit' })
|
|
276
|
+
console.log(`\n⚡ Servers on ports ${port} (dev) and ${port + 1} (prod) stopped.\n`)
|
|
274
277
|
} catch { /* nothing was running */ }
|
|
275
278
|
}
|
|
276
279
|
|
package/src/cli/scaffold.js
CHANGED
|
@@ -207,7 +207,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
|
|
|
207
207
|
hooks: [
|
|
208
208
|
{
|
|
209
209
|
type: 'command',
|
|
210
|
-
command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let changed=new Set();let hasGit=false;try{execSync('git rev-parse --is-inside-work-tree',{stdio:'pipe'});hasGit=true;execSync('git status --porcelain',{encoding:'utf8'}).split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f)changed.add(f);});}catch{}if(!hasGit&&fs.existsSync('src/pages')){for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))changed.add('src/pages/'+f);}}const specs=[...changed].filter(f=>f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'));const missing=specs.filter(f=>{const t=f.replace(/\\.js$/,'.test.js');return!fs.existsSync(t)&&!changed.has(t);});if(missing.length){process.stdout.write(JSON.stringify({decision:'block',reason:'TESTS MISSING: '+missing.join(', ')+' — you must write tests before finishing. Use renderSync/render from @invisibleloop/pulse/testing to test view output, mutations, and any extracted logic functions. Run the tests with node to confirm they pass.\\n\\nEXCEPTION —
|
|
210
|
+
command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let changed=new Set();let hasGit=false;try{execSync('git rev-parse --is-inside-work-tree',{stdio:'pipe'});hasGit=true;execSync('git status --porcelain',{encoding:'utf8'}).split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f)changed.add(f);});}catch{}if(!hasGit&&fs.existsSync('src/pages')){for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))changed.add('src/pages/'+f);}}const specs=[...changed].filter(f=>f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'));const missing=specs.filter(f=>{const t=f.replace(/\\.js$/,'.test.js');return!fs.existsSync(t)&&!changed.has(t);});if(missing.length){process.stdout.write(JSON.stringify({decision:'block',reason:'TESTS MISSING: '+missing.join(', ')+' — you must write tests before finishing. Use renderSync/render from @invisibleloop/pulse/testing to test view output, mutations, and any extracted logic functions. Run the tests with node to confirm they pass.\\n\\nEXCEPTION — pausing for the user: if you are ending this turn to wait for the user\\'s answer (design-approval gate) OR to report progress mid-verification (e.g. between Lighthouse runs across multiple pages), call pulse_await_approval first, then end your turn. Do not write tests or run /verify on an unapproved design.'}));}"`,
|
|
211
211
|
statusMessage: 'Checking for missing tests...',
|
|
212
212
|
},
|
|
213
213
|
{
|
|
@@ -217,7 +217,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
|
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
type: 'command',
|
|
220
|
-
command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nIMPORTANT — check first: are you ending this turn to wait for the user\\'s answer (e.g.
|
|
220
|
+
command: `node -e "const fs=require('fs');if(fs.existsSync('.pulse-awaiting-approval'))process.exit(0);const{execSync}=require('child_process');let specs=[];try{const out=execSync('git status --porcelain',{encoding:'utf8'});out.split('\\n').forEach(l=>{const f=l.slice(3).trim().split(' -> ').pop();if(f&&f.match(/^src\\/pages\\/.+\\.js$/)&&!f.endsWith('.test.js'))specs.push(f);});}catch{if(fs.existsSync('src/pages'))for(const f of fs.readdirSync('src/pages')){if(f.endsWith('.js')&&!f.endsWith('.test.js'))specs.push('src/pages/'+f);}}if(!specs.length)process.exit(0);const stamp='.pulse-verified';let stampMtime=0;try{stampMtime=fs.statSync(stamp).mtimeMs;}catch{}const stale=specs.filter(f=>{try{return fs.statSync(f).mtimeMs>stampMtime;}catch{return true;}});if(stale.length){process.stdout.write(JSON.stringify({decision:'block',reason:'VERIFY REQUIRED: These pages have not been verified since last edit:\\n'+stale.map(f=>' '+f).join('\\n')+'\\n\\nIMPORTANT — check first: are you ending this turn to wait for the user\\'s answer (design-approval question) or to report progress mid-verification (e.g. between Lighthouse runs across multiple pages)? If so, do NOT rush /verify — call pulse_await_approval and end your turn; this gate will let you stop, and the marker is consumed when the user replies.\\n\\nOtherwise run /verify for each changed page. /verify runs Lighthouse (desktop + mobile), performance trace, console check, and code review — then writes the stamp that clears this check.'}));}"`,
|
|
221
221
|
statusMessage: 'Checking verification stamp...',
|
|
222
222
|
}
|
|
223
223
|
]
|
package/src/mcp/mcp.test.js
CHANGED
|
@@ -114,6 +114,8 @@ const SYNC_PAIRS = [
|
|
|
114
114
|
['src/agent/checklist.md', 'docs/.claude/pulse-checklist.md'],
|
|
115
115
|
['src/agent/checklist.md', 'examples/.claude/pulse-checklist.md'],
|
|
116
116
|
['src/agent/commands/verify.md', '.claude/commands/verify.md'],
|
|
117
|
+
['src/agent/coverage-check.js', 'docs/.claude/coverage-check.js'],
|
|
118
|
+
['src/agent/coverage-check.js', 'examples/.claude/coverage-check.js'],
|
|
117
119
|
]
|
|
118
120
|
|
|
119
121
|
for (const [a, b] of SYNC_PAIRS) {
|
|
@@ -277,6 +279,48 @@ test('approval pause mechanism is wired end to end', () => {
|
|
|
277
279
|
`Instruct: always call pulse_await_approval before asking, whichever way the question is asked.`)
|
|
278
280
|
})
|
|
279
281
|
|
|
282
|
+
test('pulse_validate prop-alias check excludes attrs blocks (false-positive fix)', () => {
|
|
283
|
+
// Regression: input({ attrs: { autocomplete } }) — the RECOMMENDED pattern —
|
|
284
|
+
// was flagged as a wrong top-level prop because the match crossed into the
|
|
285
|
+
// attrs object. The exclusion regex must be present in the alias loop.
|
|
286
|
+
assert(serverSrc.includes('attrs\\s*:\\s*\\{[^}]*$'),
|
|
287
|
+
'The PROP_ALIASES loop must exclude matches that fall inside an open attrs: { … } block')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('pulse_validate CSP check matches config by host, not full URL', () => {
|
|
291
|
+
// Regression: CSP sources are valid without a scheme (images.unsplash.com),
|
|
292
|
+
// but the config check required https?:// — flagging correctly configured
|
|
293
|
+
// projects on every validate.
|
|
294
|
+
assert(serverSrc.includes('configImgSrc.includes(host)'),
|
|
295
|
+
'External-image CSP check must test the config for the bare host')
|
|
296
|
+
assert(serverSrc.includes(`host: 'images.unsplash.com'`),
|
|
297
|
+
'External image host entries must carry a bare host field')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('validator resolves relative imports from the spec file\'s own directory', () => {
|
|
301
|
+
// Regression: the temp validation file was always written into src/pages/
|
|
302
|
+
// root, so subdirectory pages (src/pages/news/index.js) had their relative
|
|
303
|
+
// imports (../../components/layout.js) resolve from the wrong depth.
|
|
304
|
+
assert(/async function validateContent\(content, tmpDir = PAGES_DIR\)/.test(serverSrc),
|
|
305
|
+
'validateContent must accept a tmpDir parameter')
|
|
306
|
+
assert(serverSrc.includes('validateContent(content, path.dirname(fullPath))'),
|
|
307
|
+
'pulse_create_page must validate from the file\'s own directory')
|
|
308
|
+
assert(serverSrc.includes('validateContent(content, path.dirname(file))'),
|
|
309
|
+
'pulse_validate file mode must validate from the file\'s own directory')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('package-root import is stripped from client bundles in build and dev', () => {
|
|
313
|
+
// Regression: import { pushStore } from '@invisibleloop/pulse' (the documented
|
|
314
|
+
// live-push pattern) pulled the entire server — http, fs, zlib — into the
|
|
315
|
+
// browser bundle and broke `pulse build`.
|
|
316
|
+
const buildSrc = fs.readFileSync(path.join(ROOT, 'scripts/build.js'), 'utf8')
|
|
317
|
+
const devSrc = fs.readFileSync(path.join(ROOT, 'src/cli/dev.js'), 'utf8')
|
|
318
|
+
assert(buildSrc.includes(`'@invisibleloop/pulse/md', '@invisibleloop/pulse'`),
|
|
319
|
+
'build.js SERVER_ONLY_IMPORTS must include the package root')
|
|
320
|
+
assert(devSrc.includes(`@invisibleloop\\/pulse)['"]`),
|
|
321
|
+
'dev.js serveFile must strip package-root imports before serving specs to the browser')
|
|
322
|
+
})
|
|
323
|
+
|
|
280
324
|
test('pulse_check_contrast extracts variables from [data-theme="light"] blocks', () => {
|
|
281
325
|
// Regression: the light-theme block pattern was an ungrouped alternation
|
|
282
326
|
// (a|b) + '\\s*\\{([^}]+)\\}' — the body matcher bound only to the second
|