@invisibleloop/pulse 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/pulse-start.md +11 -0
- package/.claude/commands/pulse-stop.md +5 -0
- package/CLAUDE.md +4 -2
- package/docs/.claude/coverage-check.js +153 -84
- package/docs/src/pages/store.js +4 -5
- package/examples/.claude/coverage-check.js +153 -84
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +30 -10
- package/scripts/strip-server.test.js +31 -2
- 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/guide-server.md +3 -5
- package/src/agent/workflow.md +16 -2
- package/src/cli/cli.test.js +125 -0
- package/src/cli/dev.js +26 -5
- package/src/cli/discover.js +26 -0
- package/src/cli/index.js +5 -2
- package/src/cli/scaffold.js +2 -2
- package/src/cli/start.js +5 -1
- package/src/mcp/mcp.test.js +44 -0
- package/src/mcp/server.js +406 -21
- package/src/runtime/ssr.js +5 -2
- package/src/server/index.js +19 -7
- package/src/server/server.test.js +24 -0
- package/types/server.d.ts +3 -0
package/scripts/build.js
CHANGED
|
@@ -34,7 +34,10 @@ import { extractUsedClasses, purgeCss, minifyCss } from './css-purge.js'
|
|
|
34
34
|
* from being shipped to and evaluated in the browser
|
|
35
35
|
* - avoids bundling errors when server imports use Node-only modules
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
// NOTE: spec.submit is also server-only but must NOT be listed here — the key
|
|
38
|
+
// scanner is line-anchored at any nesting depth, and `submit:` is the most
|
|
39
|
+
// common ACTION name (actions: { submit: {…} }), which would get stripped too.
|
|
40
|
+
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* Pulse subpath exports that are server-only (use Node built-ins).
|
|
@@ -42,7 +45,7 @@ const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentTy
|
|
|
42
45
|
* top-level variable declarations whose right-hand side calls the imported names.
|
|
43
46
|
* All `node:*` built-ins are also stripped automatically via the import regex.
|
|
44
47
|
*/
|
|
45
|
-
const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
|
|
48
|
+
const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
|
|
46
49
|
|
|
47
50
|
/**
|
|
48
51
|
* Strip all server-only property declarations from a spec source string.
|
|
@@ -351,17 +354,22 @@ function scanTemplateLiteral(src, pos) {
|
|
|
351
354
|
|
|
352
355
|
/**
|
|
353
356
|
* esbuild plugin — strips server-only spec properties before bundling.
|
|
354
|
-
*
|
|
357
|
+
* Transforms files under src/pages/ plus the global store file (pulse.store.js
|
|
358
|
+
* carries server fetchers that must never reach the browser) — never touches
|
|
359
|
+
* runtime/server code.
|
|
355
360
|
*
|
|
356
|
-
* @param {string} pagesDir
|
|
361
|
+
* @param {string} pagesDir Absolute path to src/pages/
|
|
362
|
+
* @param {string|null} storePath Absolute path to pulse.store.js, if it exists
|
|
357
363
|
*/
|
|
358
|
-
function createStripServerPlugin(pagesDir) {
|
|
364
|
+
function createStripServerPlugin(pagesDir, storePath = null) {
|
|
359
365
|
return {
|
|
360
366
|
name: 'pulse-strip-server',
|
|
361
367
|
setup(build) {
|
|
362
368
|
build.onLoad({ filter: /\.js$/ }, (args) => {
|
|
363
|
-
|
|
364
|
-
|
|
369
|
+
const isPage = args.path.startsWith(pagesDir + path.sep) ||
|
|
370
|
+
args.path === pagesDir.replace(/\/$/, '') + '.js'
|
|
371
|
+
const isStore = storePath && args.path === storePath
|
|
372
|
+
if (!isPage && !isStore) return undefined
|
|
365
373
|
|
|
366
374
|
const source = fs.readFileSync(args.path, 'utf8')
|
|
367
375
|
const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
|
|
@@ -435,6 +443,11 @@ const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pat
|
|
|
435
443
|
|
|
436
444
|
const PAGES_DIR = path.join(ROOT, 'src', 'pages')
|
|
437
445
|
|
|
446
|
+
// Global store — bundled into boot files (server fetchers stripped) when present
|
|
447
|
+
const STORE_PATH = fs.existsSync(path.join(ROOT, 'pulse.store.js'))
|
|
448
|
+
? path.join(ROOT, 'pulse.store.js')
|
|
449
|
+
: null
|
|
450
|
+
|
|
438
451
|
const bootstrapFiles = pages.flatMap(({ filePath }) => {
|
|
439
452
|
// Strip server-only keys/imports to check what's left for the client.
|
|
440
453
|
// Skip specs with no view — these are API routes or raw content specs
|
|
@@ -453,15 +466,22 @@ const bootstrapFiles = pages.flatMap(({ filePath }) => {
|
|
|
453
466
|
const relRuntime = path.relative(TMP_DIR, RUNTIME_PATH)
|
|
454
467
|
const relNavigate = path.relative(TMP_DIR, NAVIGATE_PATH)
|
|
455
468
|
|
|
469
|
+
// Global store — when pulse.store.js exists, every boot bundle imports it and
|
|
470
|
+
// passes it to mount() so client store mutations register in production.
|
|
471
|
+
// The strip plugin removes its server fetchers from the browser bundle;
|
|
472
|
+
// esbuild's shared-chunk splitting keeps it from being duplicated per page.
|
|
473
|
+
const storeImport = STORE_PATH ? `\nimport store from '${path.relative(TMP_DIR, STORE_PATH)}'` : ''
|
|
474
|
+
const mountOpts = STORE_PATH ? `{ ssr: true, store }` : `{ ssr: true }`
|
|
475
|
+
|
|
456
476
|
fs.writeFileSync(bootstrapPath, `\
|
|
457
477
|
import spec from '${relSpec}'
|
|
458
478
|
import { mount } from '${relRuntime}'
|
|
459
|
-
import { initNavigation } from '${relNavigate}'
|
|
479
|
+
import { initNavigation } from '${relNavigate}'${storeImport}
|
|
460
480
|
|
|
461
481
|
const root = document.getElementById('pulse-root')
|
|
462
482
|
if (root && !root.dataset.pulseMounted) {
|
|
463
483
|
root.dataset.pulseMounted = '1'
|
|
464
|
-
mount(spec, root, window.__PULSE_SERVER__ || {}, {
|
|
484
|
+
mount(spec, root, window.__PULSE_SERVER__ || {}, ${mountOpts})
|
|
465
485
|
initNavigation(root, mount)
|
|
466
486
|
}
|
|
467
487
|
|
|
@@ -488,7 +508,7 @@ const result = await esbuild.build({
|
|
|
488
508
|
metafile: true,
|
|
489
509
|
sourcemap: false,
|
|
490
510
|
treeShaking: true,
|
|
491
|
-
plugins: [createStripServerPlugin(PAGES_DIR)],
|
|
511
|
+
plugins: [createStripServerPlugin(PAGES_DIR, STORE_PATH)],
|
|
492
512
|
define: {
|
|
493
513
|
'process.env.NODE_ENV': '"production"'
|
|
494
514
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// Inline the scanner functions (duplicated here to keep test self-contained)
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
|
|
11
|
-
const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
|
|
10
|
+
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
|
|
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)
|
|
@@ -398,5 +398,34 @@ devTest('leaves module-level constants intact',
|
|
|
398
398
|
|
|
399
399
|
// ---------------------------------------------------------------------------
|
|
400
400
|
|
|
401
|
+
console.log('\nStore + new spec fields\n')
|
|
402
|
+
|
|
403
|
+
test('sitemap enumerator is stripped (server-only)',
|
|
404
|
+
`export default {\n route: '/blog/:slug',\n sitemap: async () => ['/blog/a'],\n view: () => '',\n}`,
|
|
405
|
+
`export default {\n route: '/blog/:slug',\n view: () => '',\n}`
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
test('submit is NOT stripped — it collides with the common submit action name',
|
|
409
|
+
`export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`,
|
|
410
|
+
`export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
test('store definition: server fetchers stripped, state and mutations survive',
|
|
414
|
+
`export default {\n state: { basket: [] },\n server: {\n nav: async () => fetchNav(),\n },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`,
|
|
415
|
+
`export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
|
|
416
|
+
)
|
|
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
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
401
430
|
console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
|
|
402
431
|
if (fail > 0) process.exit(1)
|
|
@@ -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.
|
|
@@ -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
|
}
|
|
@@ -18,12 +18,10 @@ Only declared keys are saved. Restored values that differ from the spec default
|
|
|
18
18
|
|
|
19
19
|
The global store is a shared data layer. Server fetchers run per request; mutations run on the client and broadcast to all subscribed pages without a server round-trip.
|
|
20
20
|
|
|
21
|
-
**Define the store** in `pulse.store.js
|
|
21
|
+
**Define the store** in `pulse.store.js` at the project root — that's the whole setup. The CLI auto-discovers it (like pages in `src/pages/`), loads it server-side, serves it to the browser, and hot-reloads it on edit:
|
|
22
22
|
```js
|
|
23
23
|
// pulse.store.js
|
|
24
24
|
export default {
|
|
25
|
-
hydrate: '/pulse.store.js', // browser-importable path — required for mutations
|
|
26
|
-
|
|
27
25
|
state: { // default/fallback values
|
|
28
26
|
user: null,
|
|
29
27
|
settings: { theme: 'dark', lang: 'en' },
|
|
@@ -45,10 +43,10 @@ export default {
|
|
|
45
43
|
}
|
|
46
44
|
```
|
|
47
45
|
|
|
48
|
-
**
|
|
46
|
+
**No registration step is needed** with `pulse dev` / `pulse start` — `pulse.store.js` is auto-discovered. Only when you call `createServer` yourself do you pass it manually:
|
|
49
47
|
```js
|
|
50
48
|
import store from './pulse.store.js'
|
|
51
|
-
createServer([...specs], { store })
|
|
49
|
+
createServer([...specs], { store }) // manual server entry only
|
|
52
50
|
```
|
|
53
51
|
|
|
54
52
|
**Use store data in a page** — declare `spec.store` with the keys needed. They appear in the view's `server` argument:
|
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
|
@@ -105,6 +105,131 @@ for (const skill of requiredSkills) {
|
|
|
105
105
|
})
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Store auto-discovery (loadStore)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Regression: pulse.store.js was documented but never loaded by the CLI —
|
|
112
|
+
// dev/start servers got no store, so spec.store keys were always empty.
|
|
113
|
+
|
|
114
|
+
console.log('\nStore auto-discovery\n')
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs')
|
|
118
|
+
const { tmpdir } = await import('node:os')
|
|
119
|
+
const { loadStore } = await import('./discover.js')
|
|
120
|
+
|
|
121
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'pulse-store-'))
|
|
122
|
+
try {
|
|
123
|
+
// No store file → null
|
|
124
|
+
const none = await loadStore(dir)
|
|
125
|
+
test('loadStore returns null when pulse.store.js is absent', () => {
|
|
126
|
+
if (none !== null) throw new Error(`Expected null, got ${JSON.stringify(none)}`)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Valid store file → definition with browser hydrate path set
|
|
130
|
+
writeFileSync(path.join(dir, 'pulse.store.js'),
|
|
131
|
+
`export default { state: { basket: [] }, server: { nav: async () => ['Home'] }, mutations: { add: (s, i) => ({ basket: [...s.basket, i] }) } }`)
|
|
132
|
+
const def = await loadStore(dir)
|
|
133
|
+
test('loadStore loads pulse.store.js and sets the browser hydrate path', () => {
|
|
134
|
+
if (!def) throw new Error('Expected a store definition')
|
|
135
|
+
if (def.hydrate !== '/pulse.store.js') throw new Error(`Expected hydrate /pulse.store.js, got ${def.hydrate}`)
|
|
136
|
+
if (typeof def.server?.nav !== 'function') throw new Error('server fetchers missing')
|
|
137
|
+
if (typeof def.mutations?.add !== 'function') throw new Error('mutations missing')
|
|
138
|
+
if (!Array.isArray(def.state?.basket)) throw new Error('state missing')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Cache-busted re-import returns the edited module (dev hot reload)
|
|
142
|
+
writeFileSync(path.join(dir, 'pulse.store.js'),
|
|
143
|
+
`export default { state: { basket: [], promo: true }, mutations: {} }`)
|
|
144
|
+
const fresh = await loadStore(dir, Date.now())
|
|
145
|
+
test('loadStore cache-busts for hot reload', () => {
|
|
146
|
+
if (fresh?.state?.promo !== true) throw new Error('Expected fresh store definition after edit')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Bad export → clear error
|
|
150
|
+
writeFileSync(path.join(dir, 'pulse.store.js'), `export default 42`)
|
|
151
|
+
let threw = false
|
|
152
|
+
try { await loadStore(dir, Date.now() + 1) } catch (e) { threw = /store object/.test(e.message) }
|
|
153
|
+
test('loadStore throws a clear error for a non-object export', () => {
|
|
154
|
+
if (!threw) throw new Error('Expected a descriptive throw for bad default export')
|
|
155
|
+
})
|
|
156
|
+
} finally {
|
|
157
|
+
rmSync(dir, { recursive: true, force: true })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
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
|
+
|
|
108
233
|
// ---------------------------------------------------------------------------
|
|
109
234
|
|
|
110
235
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|