@invisibleloop/pulse 0.20.11 → 0.20.20

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.
Files changed (266) hide show
  1. package/package.json +6 -5
  2. package/public/.pulse-ui-version +1 -1
  3. package/scripts/build.js +26 -0
  4. package/scripts/release-version.js +17 -8
  5. package/scripts/strip-server.test.js +60 -0
  6. package/src/agent/checklist.md +8 -12
  7. package/src/agent/commands/pulse-start.md +2 -0
  8. package/src/agent/commands/verify.md +30 -8
  9. package/src/agent/guide-components.md +7 -25
  10. package/src/agent/guide-routing.md +0 -12
  11. package/src/agent/guide-server.md +102 -1
  12. package/src/agent/guide-spec.md +8 -1
  13. package/src/agent/guide-styles.md +23 -0
  14. package/src/agent/identity.md +9 -0
  15. package/src/agent/workflow.md +4 -23
  16. package/src/cli/check-bundles.js +113 -0
  17. package/src/cli/check-bundles.test.js +137 -0
  18. package/src/cli/cli.test.js +105 -0
  19. package/src/cli/dev.js +48 -3
  20. package/src/cli/diagnose.js +105 -0
  21. package/src/cli/diagnose.test.js +124 -0
  22. package/src/cli/discover.js +9 -2
  23. package/src/cli/index.js +171 -9
  24. package/src/cli/process-utils.js +57 -0
  25. package/src/cli/process-utils.test.js +114 -0
  26. package/src/cli/review.js +391 -0
  27. package/src/cli/review.test.js +143 -0
  28. package/src/cli/scaffold.js +9 -4
  29. package/src/cli/stamp.js +102 -0
  30. package/src/cli/stamp.test.js +149 -0
  31. package/src/cli/start.js +15 -2
  32. package/src/cli/validate.js +184 -0
  33. package/src/cli/validate.test.js +153 -0
  34. package/src/mcp/mcp.test.js +261 -30
  35. package/src/mcp/server.js +207 -515
  36. package/src/mcp/validate-worker.js +18 -1
  37. package/src/runtime/index.js +144 -11
  38. package/src/runtime/runtime.test.js +199 -0
  39. package/src/runtime/ssr.js +13 -6
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +162 -9
  42. package/src/server/server.test.js +225 -2
  43. package/src/spec/schema.js +1 -1
  44. package/src/ui/index.js +0 -5
  45. package/src/ui/ui.test.js +0 -75
  46. package/types/ui.d.ts +0 -21
  47. package/.claude/commands/build-page.md +0 -63
  48. package/.claude/commands/new-doc-page.md +0 -45
  49. package/.claude/commands/pulse-start.md +0 -11
  50. package/.claude/commands/pulse-stop.md +0 -5
  51. package/.claude/commands/verify.md +0 -118
  52. package/.claude/pulse-checklist.md +0 -189
  53. package/.github/workflows/ci.yml +0 -25
  54. package/.github/workflows/publish.yml +0 -45
  55. package/.pulse/load-reports/home/1773432711417.json +0 -22
  56. package/.pulse-verified +0 -1
  57. package/AGENTS.md +0 -697
  58. package/CLAUDE.md +0 -697
  59. package/README.md +0 -95
  60. package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
  61. package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
  62. package/benchmark/public/dist/manifest.json +0 -5
  63. package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
  64. package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
  65. package/benchmark/results/2026-03-27-09-20.json +0 -188
  66. package/benchmark/results/2026-03-27-09-21.json +0 -188
  67. package/benchmark/results/2026-03-27-09-29.json +0 -188
  68. package/benchmark/results/2026-03-27-10-14.json +0 -188
  69. package/benchmark/results/2026-03-27-10-18.json +0 -232
  70. package/benchmark/src/pages/counter.js +0 -37
  71. package/benchmark/src/pages/home.js +0 -21
  72. package/docs/.claude/commands/pulse-build.md +0 -7
  73. package/docs/.claude/commands/pulse-dev.md +0 -7
  74. package/docs/.claude/commands/pulse-start.md +0 -9
  75. package/docs/.claude/commands/pulse-stop.md +0 -5
  76. package/docs/.claude/commands/verify.md +0 -84
  77. package/docs/.claude/coverage-check.js +0 -196
  78. package/docs/.claude/pulse-checklist.md +0 -189
  79. package/docs/public/.pulse-ui-version +0 -1
  80. package/docs/public/andypixel.css +0 -237
  81. package/docs/public/bookworms.css +0 -422
  82. package/docs/public/coutumes.css +0 -561
  83. package/docs/public/docs.css +0 -2388
  84. package/docs/public/favicon.svg +0 -4
  85. package/docs/public/home-brut.css +0 -982
  86. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  87. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  88. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  89. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  90. package/docs/public/images/journey/178.jpg +0 -0
  91. package/docs/public/images/journey/29.jpg +0 -0
  92. package/docs/public/images/retrowheels/hero.jpg +0 -0
  93. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  94. package/docs/public/images/suzukibento/1015.jpg +0 -0
  95. package/docs/public/images/suzukibento/1025.jpg +0 -0
  96. package/docs/public/images/suzukibento/1043.jpg +0 -0
  97. package/docs/public/images/suzukibento/1059.jpg +0 -0
  98. package/docs/public/images/suzukibento/1062.jpg +0 -0
  99. package/docs/public/images/suzukibento/1084.jpg +0 -0
  100. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  101. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  102. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  103. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  104. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  105. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  106. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  107. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  108. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  109. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  110. package/docs/public/img/heic1501a.jpg +0 -0
  111. package/docs/public/img/placeholder-landscape.svg +0 -18
  112. package/docs/public/img/placeholder-square.svg +0 -14
  113. package/docs/public/img/placeholder-tall.svg +0 -22
  114. package/docs/public/journey.css +0 -528
  115. package/docs/public/menu.js +0 -327
  116. package/docs/public/pulse-ui.css +0 -3028
  117. package/docs/public/pulse-ui.js +0 -257
  118. package/docs/public/retrowheels.css +0 -425
  119. package/docs/public/shop.css +0 -269
  120. package/docs/public/sparkys.css +0 -382
  121. package/docs/public/suzukibento.css +0 -517
  122. package/docs/public/swish.css +0 -394
  123. package/docs/public/theme.css +0 -52
  124. package/docs/public/themes/lumio.css +0 -50
  125. package/docs/public/themes/observatory.css +0 -104
  126. package/docs/server.js +0 -161
  127. package/docs/src/lib/component-page.js +0 -49
  128. package/docs/src/lib/highlight.js +0 -2
  129. package/docs/src/lib/layout.js +0 -190
  130. package/docs/src/lib/metrics-store.js +0 -6
  131. package/docs/src/lib/nav.js +0 -171
  132. package/docs/src/lib/stats.js +0 -81
  133. package/docs/src/pages/accessibility.js +0 -157
  134. package/docs/src/pages/actions.js +0 -194
  135. package/docs/src/pages/auth.js +0 -176
  136. package/docs/src/pages/caching.js +0 -95
  137. package/docs/src/pages/components/accordion.js +0 -48
  138. package/docs/src/pages/components/alert.js +0 -35
  139. package/docs/src/pages/components/app-badge.js +0 -41
  140. package/docs/src/pages/components/avatar.js +0 -35
  141. package/docs/src/pages/components/badge.js +0 -36
  142. package/docs/src/pages/components/breadcrumbs.js +0 -94
  143. package/docs/src/pages/components/button.js +0 -84
  144. package/docs/src/pages/components/card.js +0 -225
  145. package/docs/src/pages/components/charts.js +0 -283
  146. package/docs/src/pages/components/checkbox.js +0 -129
  147. package/docs/src/pages/components/cluster.js +0 -47
  148. package/docs/src/pages/components/container.js +0 -40
  149. package/docs/src/pages/components/cta.js +0 -54
  150. package/docs/src/pages/components/divider.js +0 -37
  151. package/docs/src/pages/components/empty.js +0 -36
  152. package/docs/src/pages/components/feature.js +0 -60
  153. package/docs/src/pages/components/fieldset.js +0 -65
  154. package/docs/src/pages/components/fileupload.js +0 -128
  155. package/docs/src/pages/components/footer.js +0 -58
  156. package/docs/src/pages/components/grid.js +0 -169
  157. package/docs/src/pages/components/heading.js +0 -107
  158. package/docs/src/pages/components/hero.js +0 -123
  159. package/docs/src/pages/components/icons.js +0 -410
  160. package/docs/src/pages/components/image.js +0 -71
  161. package/docs/src/pages/components/input.js +0 -51
  162. package/docs/src/pages/components/media.js +0 -51
  163. package/docs/src/pages/components/modal.js +0 -111
  164. package/docs/src/pages/components/nav.js +0 -136
  165. package/docs/src/pages/components/phone-frame.js +0 -74
  166. package/docs/src/pages/components/progress.js +0 -102
  167. package/docs/src/pages/components/prose.js +0 -111
  168. package/docs/src/pages/components/pullquote.js +0 -71
  169. package/docs/src/pages/components/radio.js +0 -194
  170. package/docs/src/pages/components/rating.js +0 -106
  171. package/docs/src/pages/components/search.js +0 -61
  172. package/docs/src/pages/components/section.js +0 -98
  173. package/docs/src/pages/components/segmented.js +0 -121
  174. package/docs/src/pages/components/select.js +0 -45
  175. package/docs/src/pages/components/slider.js +0 -115
  176. package/docs/src/pages/components/spinner.js +0 -73
  177. package/docs/src/pages/components/stack.js +0 -48
  178. package/docs/src/pages/components/stat.js +0 -55
  179. package/docs/src/pages/components/stepper.js +0 -66
  180. package/docs/src/pages/components/table.js +0 -45
  181. package/docs/src/pages/components/textarea.js +0 -31
  182. package/docs/src/pages/components/toggle.js +0 -84
  183. package/docs/src/pages/components/tooltip.js +0 -48
  184. package/docs/src/pages/components.js +0 -200
  185. package/docs/src/pages/config.js +0 -193
  186. package/docs/src/pages/constraints.js +0 -100
  187. package/docs/src/pages/deploy.js +0 -233
  188. package/docs/src/pages/error-pages.js +0 -99
  189. package/docs/src/pages/extending.js +0 -198
  190. package/docs/src/pages/faq.js +0 -99
  191. package/docs/src/pages/forms.js +0 -129
  192. package/docs/src/pages/getting-started.js +0 -147
  193. package/docs/src/pages/guard.js +0 -120
  194. package/docs/src/pages/home.js +0 -350
  195. package/docs/src/pages/how-it-works.js +0 -244
  196. package/docs/src/pages/hydration.js +0 -108
  197. package/docs/src/pages/images.js +0 -120
  198. package/docs/src/pages/markdown.js +0 -156
  199. package/docs/src/pages/meta.js +0 -193
  200. package/docs/src/pages/mutations.js +0 -107
  201. package/docs/src/pages/navigation.js +0 -85
  202. package/docs/src/pages/performance.js +0 -157
  203. package/docs/src/pages/persist.js +0 -89
  204. package/docs/src/pages/project-structure.js +0 -91
  205. package/docs/src/pages/prompt-examples.js +0 -186
  206. package/docs/src/pages/raw-responses.js +0 -122
  207. package/docs/src/pages/routing.js +0 -120
  208. package/docs/src/pages/server-api.js +0 -291
  209. package/docs/src/pages/server-data.js +0 -203
  210. package/docs/src/pages/slash-commands.js +0 -55
  211. package/docs/src/pages/spec.js +0 -207
  212. package/docs/src/pages/state.js +0 -102
  213. package/docs/src/pages/store.js +0 -203
  214. package/docs/src/pages/streaming.js +0 -107
  215. package/docs/src/pages/stripe.js +0 -194
  216. package/docs/src/pages/supabase.js +0 -324
  217. package/docs/src/pages/templates/andypixel.js +0 -260
  218. package/docs/src/pages/templates/blog-post.js +0 -226
  219. package/docs/src/pages/templates/bookworms.js +0 -227
  220. package/docs/src/pages/templates/coutumes.js +0 -237
  221. package/docs/src/pages/templates/journey.js +0 -186
  222. package/docs/src/pages/templates/mobile-app.js +0 -371
  223. package/docs/src/pages/templates/retrowheels.js +0 -355
  224. package/docs/src/pages/templates/shop.js +0 -268
  225. package/docs/src/pages/templates/sparkys.js +0 -264
  226. package/docs/src/pages/templates/suzukibento.js +0 -200
  227. package/docs/src/pages/templates/swish.js +0 -313
  228. package/docs/src/pages/templates.js +0 -534
  229. package/docs/src/pages/testing.js +0 -199
  230. package/docs/src/pages/validation.js +0 -138
  231. package/examples/.claude/commands/pulse-build.md +0 -7
  232. package/examples/.claude/commands/pulse-dev.md +0 -7
  233. package/examples/.claude/commands/pulse-start.md +0 -9
  234. package/examples/.claude/commands/pulse-stop.md +0 -5
  235. package/examples/.claude/commands/verify.md +0 -84
  236. package/examples/.claude/coverage-check.js +0 -196
  237. package/examples/.claude/pulse-checklist.md +0 -189
  238. package/examples/andypixel.css +0 -237
  239. package/examples/andypixel.js +0 -258
  240. package/examples/bookworms.css +0 -422
  241. package/examples/bookworms.js +0 -225
  242. package/examples/contact.js +0 -166
  243. package/examples/counter.js +0 -94
  244. package/examples/dev.server.js +0 -110
  245. package/examples/examples.test.js +0 -394
  246. package/examples/journey.css +0 -528
  247. package/examples/journey.js +0 -186
  248. package/examples/pricing.js +0 -262
  249. package/examples/products.js +0 -190
  250. package/examples/public/.pulse-ui-version +0 -1
  251. package/examples/public/pulse-ui.css +0 -3028
  252. package/examples/public/pulse-ui.js +0 -257
  253. package/examples/quiz.js +0 -208
  254. package/examples/retrowheels.css +0 -425
  255. package/examples/retrowheels.js +0 -353
  256. package/examples/shared.js +0 -84
  257. package/examples/suzukibento.css +0 -513
  258. package/examples/suzukibento.js +0 -200
  259. package/examples/swish.css +0 -394
  260. package/examples/swish.js +0 -311
  261. package/examples/todos.js +0 -162
  262. package/src/ui/fieldset.js +0 -37
  263. package/src/ui/phone-frame.js +0 -40
  264. package/src/ui/search.js +0 -78
  265. package/src/ui/segmented.js +0 -50
  266. package/src/ui/tooltip.js +0 -28
@@ -10,9 +10,16 @@
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
+ import { execFileSync } from 'node:child_process'
13
14
 
14
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
16
+ // Package root (packages/pulse/) — canonical source for src/agent/*.
15
17
  const ROOT = path.resolve(__dirname, '../..')
18
+ // Repo root (pulse2/) — where distributed copies live: .claude/, docs/,
19
+ // examples/, CLAUDE.md. Package root moved into packages/pulse/ when the
20
+ // framework became a workspace member; these distributed-copy targets did
21
+ // not move, so they need one extra level up from the package root.
22
+ const REPO_ROOT = path.resolve(ROOT, '..', '..')
16
23
 
17
24
  let passed = 0
18
25
  let failed = 0
@@ -36,6 +43,15 @@ function assert(condition, msg) {
36
43
  // ── Read source files ────────────────────────────────────────────────────────
37
44
 
38
45
  const serverSrc = fs.readFileSync(path.join(__dirname, 'server.js'), 'utf8')
46
+ // validateContent's real logic — extracted from server.js into cli/validate.js
47
+ // so `pulse validate` (CLI) and pulse_validate (MCP) share one implementation.
48
+ const validateSrc = fs.readFileSync(path.join(ROOT, 'src/cli/validate.js'), 'utf8')
49
+ // checkBundles' real logic — extracted into cli/check-bundles.js so
50
+ // `pulse check-bundles` (CLI) and pulse_check_bundles (MCP) share one implementation.
51
+ const checkBundlesSrc = fs.readFileSync(path.join(ROOT, 'src/cli/check-bundles.js'), 'utf8')
52
+ // pulse_review's real logic — extracted into cli/review.js so `pulse review`
53
+ // (CLI) and pulse_review (MCP) share one implementation.
54
+ const reviewSrc = fs.readFileSync(path.join(ROOT, 'src/cli/review.js'), 'utf8')
39
55
  const agentDir = path.join(ROOT, 'src/agent')
40
56
 
41
57
  // All guide-*.md files that exist on disk
@@ -114,14 +130,16 @@ const SYNC_PAIRS = [
114
130
  ['src/agent/checklist.md', 'docs/.claude/pulse-checklist.md'],
115
131
  ['src/agent/checklist.md', 'examples/.claude/pulse-checklist.md'],
116
132
  ['src/agent/commands/verify.md', '.claude/commands/verify.md'],
133
+ ['src/agent/commands/verify.md', 'docs/.claude/commands/verify.md'],
134
+ ['src/agent/commands/verify.md', 'examples/.claude/commands/verify.md'],
117
135
  ['src/agent/coverage-check.js', 'docs/.claude/coverage-check.js'],
118
136
  ['src/agent/coverage-check.js', 'examples/.claude/coverage-check.js'],
119
137
  ]
120
138
 
121
139
  for (const [a, b] of SYNC_PAIRS) {
122
140
  test(`${a} is in sync with ${b}`, () => {
123
- const fa = path.join(ROOT, a)
124
- const fb = path.join(ROOT, b)
141
+ const fa = path.join(ROOT, a) // canonical source — package-relative
142
+ const fb = path.join(REPO_ROOT, b) // distributed copy — repo-root-relative
125
143
  assert(fs.existsSync(fa), `${a} does not exist`)
126
144
  assert(fs.existsSync(fb), `${b} does not exist`)
127
145
  assert(
@@ -143,18 +161,22 @@ for (const [a, b] of SYNC_PAIRS) {
143
161
 
144
162
  console.log('\nAgent doc consistency\n')
145
163
 
164
+ // Each entry is resolved against ROOT (package root) or REPO_ROOT (repo
165
+ // root) depending on where it actually lives — see the ROOT/REPO_ROOT
166
+ // comment above. Package-relative entries carry their own root so callers
167
+ // don't have to guess which base a given path needs.
146
168
  const agentDocs = [
147
- ...fs.readdirSync(agentDir).filter(f => f.endsWith('.md')).map(f => path.join('src/agent', f)),
148
- ...fs.readdirSync(path.join(agentDir, 'commands')).filter(f => f.endsWith('.md')).map(f => path.join('src/agent/commands', f)),
169
+ ...fs.readdirSync(agentDir).filter(f => f.endsWith('.md')).map(f => ({ root: ROOT, file: path.join('src/agent', f) })),
170
+ ...fs.readdirSync(path.join(agentDir, 'commands')).filter(f => f.endsWith('.md')).map(f => ({ root: ROOT, file: path.join('src/agent/commands', f) })),
149
171
  ...fs.readdirSync(path.join(agentDir, 'skills')).flatMap(d => {
150
172
  const p = path.join(agentDir, 'skills', d, 'SKILL.md')
151
- return fs.existsSync(p) ? [path.join('src/agent/skills', d, 'SKILL.md')] : []
173
+ return fs.existsSync(p) ? [{ root: ROOT, file: path.join('src/agent/skills', d, 'SKILL.md') }] : []
152
174
  }),
153
- '.claude/commands/build-page.md',
154
- '.claude/commands/verify.md',
155
- '.claude/commands/new-doc-page.md',
156
- 'CLAUDE.md',
157
- 'README.md',
175
+ { root: REPO_ROOT, file: '.claude/commands/build-page.md' },
176
+ { root: REPO_ROOT, file: '.claude/commands/verify.md' },
177
+ { root: REPO_ROOT, file: '.claude/commands/new-doc-page.md' },
178
+ { root: REPO_ROOT, file: 'CLAUDE.md' },
179
+ { root: REPO_ROOT, file: 'README.md' },
158
180
  ]
159
181
 
160
182
  test('no agent doc states a four-score Lighthouse bar (100/100/100/100)', () => {
@@ -162,10 +184,10 @@ test('no agent doc states a four-score Lighthouse bar (100/100/100/100)', () =>
162
184
  src.includes('100/100/100/100') ||
163
185
  /all four (scores|categories)/i.test(src) ||
164
186
  /Accessibility, Best Practices, SEO,? and Performance[^.\n]*must (all )?be 100/i.test(src)
165
- const offenders = agentDocs.filter(f =>
166
- fs.existsSync(path.join(ROOT, f)) &&
167
- fourScore(fs.readFileSync(path.join(ROOT, f), 'utf8'))
168
- )
187
+ const offenders = agentDocs.filter(({ root, file }) =>
188
+ fs.existsSync(path.join(root, file)) &&
189
+ fourScore(fs.readFileSync(path.join(root, file), 'utf8'))
190
+ ).map(({ file }) => file)
169
191
  if (fourScore(serverSrc)) offenders.push('src/mcp/server.js')
170
192
  assert(
171
193
  offenders.length === 0,
@@ -175,10 +197,10 @@ test('no agent doc states a four-score Lighthouse bar (100/100/100/100)', () =>
175
197
  })
176
198
 
177
199
  test('no agent doc references a literal ask_user tool', () => {
178
- const offenders = agentDocs.filter(f =>
179
- fs.existsSync(path.join(ROOT, f)) &&
180
- /\bask_user\b/.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))
181
- )
200
+ const offenders = agentDocs.filter(({ root, file }) =>
201
+ fs.existsSync(path.join(root, file)) &&
202
+ /\bask_user\b/.test(fs.readFileSync(path.join(root, file), 'utf8'))
203
+ ).map(({ file }) => file)
182
204
  assert(
183
205
  offenders.length === 0,
184
206
  `"ask_user" referenced in: ${offenders.join(', ')}. ` +
@@ -269,10 +291,10 @@ test('approval pause mechanism is wired end to end', () => {
269
291
  // testing showed AskUserQuestion in Claude Code still ends the turn and
270
292
  // fires the Stop hooks. The safe instruction is: always write the marker
271
293
  // first, regardless of how the question is asked.
272
- const claimDocs = agentDocs.filter(f =>
273
- fs.existsSync(path.join(ROOT, f)) &&
274
- /without ending the turn/i.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))
275
- )
294
+ const claimDocs = agentDocs.filter(({ root, file }) =>
295
+ fs.existsSync(path.join(root, file)) &&
296
+ /without ending the turn/i.test(fs.readFileSync(path.join(root, file), 'utf8'))
297
+ ).map(({ file }) => file)
276
298
  if (/without ending the turn.*Stop hooks never fire/is.test(serverSrc)) claimDocs.push('src/mcp/server.js')
277
299
  assert(claimDocs.length === 0,
278
300
  `These docs claim a question tool avoids ending the turn (empirically false in Claude Code): ${claimDocs.join(', ')}. ` +
@@ -283,7 +305,9 @@ test('pulse_validate prop-alias check excludes attrs blocks (false-positive fix)
283
305
  // Regression: input({ attrs: { autocomplete } }) — the RECOMMENDED pattern —
284
306
  // was flagged as a wrong top-level prop because the match crossed into the
285
307
  // attrs object. The exclusion regex must be present in the alias loop.
286
- assert(serverSrc.includes('attrs\\s*:\\s*\\{[^}]*$'),
308
+ // Logic lives in cli/validate.js — shared by pulse_validate (MCP) and
309
+ // `pulse validate` (CLI), not duplicated in server.js.
310
+ assert(validateSrc.includes('attrs\\s*:\\s*\\{[^}]*$'),
287
311
  'The PROP_ALIASES loop must exclude matches that fall inside an open attrs: { … } block')
288
312
  })
289
313
 
@@ -291,9 +315,9 @@ test('pulse_validate CSP check matches config by host, not full URL', () => {
291
315
  // Regression: CSP sources are valid without a scheme (images.unsplash.com),
292
316
  // but the config check required https?:// — flagging correctly configured
293
317
  // projects on every validate.
294
- assert(serverSrc.includes('configImgSrc.includes(host)'),
318
+ assert(validateSrc.includes('configImgSrc.includes(host)'),
295
319
  'External-image CSP check must test the config for the bare host')
296
- assert(serverSrc.includes(`host: 'images.unsplash.com'`),
320
+ assert(validateSrc.includes(`host: 'images.unsplash.com'`),
297
321
  'External image host entries must carry a bare host field')
298
322
  })
299
323
 
@@ -301,12 +325,30 @@ test('validator resolves relative imports from the spec file\'s own directory',
301
325
  // Regression: the temp validation file was always written into src/pages/
302
326
  // root, so subdirectory pages (src/pages/news/index.js) had their relative
303
327
  // imports (../../components/layout.js) resolve from the wrong depth.
304
- assert(/async function validateContent\(content, tmpDir = PAGES_DIR\)/.test(serverSrc),
328
+ assert(/export async function validateContent\(content, root, tmpDir = null\)/.test(validateSrc),
305
329
  'validateContent must accept a tmpDir parameter')
306
- assert(serverSrc.includes('validateContent(content, path.dirname(fullPath))'),
330
+ assert(validateSrc.includes('validateFile(file, root)'),
331
+ 'validateFile must exist and take (file, root)')
332
+ assert(serverSrc.includes('validateContent(content, ROOT, path.dirname(fullPath))'),
307
333
  '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')
334
+ assert(serverSrc.includes('validateFile(file, ROOT)'),
335
+ 'pulse_validate file mode must validate from the file\'s own directory (via validateFile, which uses path.dirname internally)')
336
+ })
337
+
338
+ test('pulse validate CLI command exists and shares validate.js with the MCP tool', () => {
339
+ const cliSrc = fs.readFileSync(path.join(ROOT, 'src/cli/index.js'), 'utf8')
340
+ assert(cliSrc.includes("case 'validate':"), 'pulse validate must be a registered CLI command')
341
+ assert(cliSrc.includes('validateFile(file, root)'), 'the CLI command must call the shared validateFile() logic, not reimplement it')
342
+ })
343
+
344
+ test('pulse diagnose / resolve-error CLI commands exist and share diagnose.js with the MCP tools', () => {
345
+ const cliSrc = fs.readFileSync(path.join(ROOT, 'src/cli/index.js'), 'utf8')
346
+ assert(cliSrc.includes("case 'diagnose':"), 'pulse diagnose must be a registered CLI command')
347
+ assert(cliSrc.includes("case 'resolve-error':"), 'pulse resolve-error must be a registered CLI command')
348
+ assert(cliSrc.includes('readJournal(root'), 'the CLI diagnose command must call the shared readJournal() logic, not reimplement it')
349
+ assert(cliSrc.includes('resolveEntries(root'), 'the CLI resolve-error command must call the shared resolveEntries() logic, not reimplement it')
350
+ assert(serverSrc.includes('readJournal(ROOT'), 'the MCP pulse_diagnose tool must call the shared readJournal() logic')
351
+ assert(serverSrc.includes('resolveEntries(ROOT'), 'the MCP pulse_resolve_error tool must call the shared resolveEntries() logic')
310
352
  })
311
353
 
312
354
  test('package-root import is stripped from client bundles in build and dev', () => {
@@ -350,7 +392,7 @@ test('dark theme default is declared at every spec-writing entry point', () => {
350
392
  const guideSpec = fs.readFileSync(path.join(agentDir, 'guide-spec.md'), 'utf8')
351
393
  const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
352
394
  const checklist = fs.readFileSync(path.join(agentDir, 'checklist.md'), 'utf8')
353
- const claudeMd = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8')
395
+ const claudeMd = fs.readFileSync(path.join(REPO_ROOT, 'CLAUDE.md'), 'utf8')
354
396
 
355
397
  assert(/theme:/.test(guideSpec) && /DEFAULT IS DARK/i.test(guideSpec),
356
398
  'guide-spec.md meta skeleton must include the theme field with the dark-default warning')
@@ -371,6 +413,195 @@ test('identity.md declares the creative override (Design Freedom) carve-out', ()
371
413
  )
372
414
  })
373
415
 
416
+ test('pulse://start reads design:"freeform" from pulse.config.js and ties it to the Mode B override comment', () => {
417
+ // pulse.config.js's `design: 'freeform'` flag is a project-wide default for
418
+ // Mode B (see identity.md's "Design Freedom" rule / workflow.md 3a) — not a
419
+ // separate, unenforced concept. pulse_review still detects Mode B per-file
420
+ // by regexing the `// component-free — creative override` comment out of
421
+ // each spec's source, so the freeform banner must tell the agent to keep
422
+ // writing that comment into every new file even though the mode itself is
423
+ // set once, project-wide.
424
+ assert(/readPulseConfig/.test(serverSrc),
425
+ 'server.js must define/use a readPulseConfig() helper to read pulse.config.js')
426
+ assert(/config\.design === 'freeform'/.test(serverSrc),
427
+ 'pulse://start must check config.design === "freeform" from pulse.config.js')
428
+ assert(/component-free — creative override/.test(serverSrc) && /isFreeform/.test(serverSrc),
429
+ 'the freeform banner must instruct writing the `// component-free — creative override` ' +
430
+ 'comment into each spec file — pulse_review detects Mode B from that comment, not from config')
431
+ })
432
+
433
+ test('scaffolded pulse.config.js documents the design:"freeform" flag', () => {
434
+ const scaffoldSrc = fs.readFileSync(path.join(ROOT, 'src/cli/scaffold.js'), 'utf8')
435
+ assert(/design: 'freeform'/.test(scaffoldSrc),
436
+ 'scaffold.js should mention design: "freeform" in the generated pulse.config.js as a commented hint')
437
+ })
438
+
439
+ // ── pulse_review mechanized checks ────────────────────────────────────────────
440
+ // These three rules used to live only as prose in checklist.md (unenforced).
441
+ // They're now regex-checked in both pulse_review's quick and full modes —
442
+ // tested here against fixture strings, not just "the pattern exists somewhere",
443
+ // so a future edit that narrows/breaks the regex is actually caught.
444
+
445
+ test('modal-state check catches state.modalOpen and misses unrelated state', () => {
446
+ const modalRegex = /state\.modalOpen|modalOpen\s*:/
447
+ assert(modalRegex.test(`state: { modalOpen: false }`), 'must catch modalOpen: in state block')
448
+ assert(modalRegex.test(`\${state.modalOpen ? modal() : ''}`), 'must catch state.modalOpen read in view')
449
+ assert(!modalRegex.test(`state: { dialogId: null }`), 'must not flag unrelated state fields')
450
+ })
451
+
452
+ test('CSRF check catches a POST form missing ${server.csrf} on a submit-handling page', () => {
453
+ const hasSubmit = (src) => /\bsubmit\s*:\s*async/.test(src)
454
+ const hasPostForm = (src) => /<form[^>]*method=["']POST["']/i.test(src)
455
+ const hasCsrf = (src) => src.includes('server.csrf')
456
+
457
+ const missing = `submit: async (ctx) => {}, view: () => \`<form method="POST"><input name="email"></form>\``
458
+ assert(hasSubmit(missing) && hasPostForm(missing) && !hasCsrf(missing),
459
+ 'must flag a submit-page POST form with no ${server.csrf}')
460
+
461
+ const present = `submit: async (ctx) => {}, view: () => \`<form method="POST">\${server.csrf}<input name="email"></form>\``
462
+ assert(hasSubmit(present) && hasPostForm(present) && hasCsrf(present),
463
+ 'must not flag a submit-page POST form that includes ${server.csrf}')
464
+
465
+ const noSubmit = `view: () => \`<form method="POST"><input name="email"></form>\``
466
+ assert(!hasSubmit(noSubmit),
467
+ 'must not run the CSRF check at all on a page with no spec.submit (client-only action forms don\'t need it)')
468
+ })
469
+
470
+ test('_storeUpdate check catches a primitive and misses a real object literal', () => {
471
+ const badStoreUpdate = /_storeUpdate\s*:\s*(true|false|\d|['"`])/
472
+ assert(badStoreUpdate.test(`onSuccess: (state) => ({ _storeUpdate: true })`), 'must catch a boolean')
473
+ assert(badStoreUpdate.test(`onSuccess: (state) => ({ _storeUpdate: 'theme' })`), 'must catch a string literal')
474
+ assert(!badStoreUpdate.test(`onSuccess: (state, theme) => ({ _storeUpdate: { settings: { theme } } })`),
475
+ 'must not flag a correctly-shaped object literal')
476
+ })
477
+
478
+ test('review.js defines all three new auto-checks in both quick and full pulse_review modes', () => {
479
+ // Logic lives in cli/review.js — shared by pulse_review (MCP) and
480
+ // `pulse review` (CLI), not duplicated in server.js.
481
+ const modalCount = (reviewSrc.match(/state\\\.modalOpen\|modalOpen\\s\*:/g) || []).length
482
+ assert(modalCount >= 2, `modal-state check must appear in both quick and full review modes (found ${modalCount})`)
483
+ assert(reviewSrc.split("source.includes('server.csrf')").length - 1 >= 2,
484
+ 'CSRF check must appear in both quick and full review modes')
485
+ assert(reviewSrc.split('_storeUpdate\\s*:\\s*(true|false|\\d').length - 1 >= 2,
486
+ '_storeUpdate check must appear in both quick and full review modes')
487
+ })
488
+
489
+ test('pulse review CLI command exists and shares review.js with the MCP tool', () => {
490
+ const cliSrc = fs.readFileSync(path.join(ROOT, 'src/cli/index.js'), 'utf8')
491
+ assert(cliSrc.includes("case 'review':"), 'pulse review must be a registered CLI command')
492
+ assert(cliSrc.includes('runQuickReview(source, file)'), 'the CLI command must call the shared runQuickReview() logic')
493
+ assert(cliSrc.includes('runFullReview(source, file)'), 'the CLI command must call the shared runFullReview() logic')
494
+ assert(serverSrc.includes('runQuickReview(source, file)'), 'the MCP tool must call the shared runQuickReview() logic')
495
+ assert(serverSrc.includes('runFullReview(source, file)'), 'the MCP tool must call the shared runFullReview() logic')
496
+ })
497
+
498
+ test('the CLI-facing review formatter never carries agent-only framing', () => {
499
+ // The MCP tool's report talks directly to an agent ("you are now a senior
500
+ // code reviewer", "continue to the verification workflow"). That framing
501
+ // must not leak into the CLI output a human reads — formatFullReview and
502
+ // formatQuickReview must branch on agentFacing rather than hardcode it.
503
+ assert(reviewSrc.includes('agentFacing'), 'formatters must accept an agentFacing option')
504
+ assert(reviewSrc.includes("you are now a"), 'the agent-facing intro text must exist somewhere in review.js')
505
+ assert(/agentFacing\s*\?[\s\S]{0,200}you are now a/i.test(reviewSrc),
506
+ 'the "you are now a reviewer" framing must be conditional on agentFacing, not unconditional')
507
+ })
508
+
509
+ // ── validate-worker.js path handling ────────────────────────────────────────
510
+ // Regression: a relative path passed to the worker resolves against the
511
+ // worker's own location (src/mcp/), not the caller's cwd, and fails with a
512
+ // confusing "Cannot find package 'src'" error that reads as a bug in the
513
+ // spec's imports rather than what it actually is — the worker was called
514
+ // wrong. Found via a real dogfooding session where an agent shelled out to
515
+ // the worker directly (bypassing the MCP tool's own absolute-path
516
+ // normalisation) and lost several minutes to the misleading error.
517
+
518
+ const validateWorkerPath = path.join(ROOT, 'src/mcp/validate-worker.js')
519
+
520
+ function runValidateWorker(arg) {
521
+ try {
522
+ return { output: execFileSync(process.execPath, [validateWorkerPath, arg], { encoding: 'utf8' }), code: 0 }
523
+ } catch (err) {
524
+ return { output: err.stdout || err.message, code: err.status ?? 1 }
525
+ }
526
+ }
527
+
528
+ test('validate-worker.js gives a precise error for a relative path, not the raw Node resolver error', () => {
529
+ const { output } = runValidateWorker('src/pages/home.js')
530
+ assert(output.includes('requires an absolute path'),
531
+ `Expected a clear absolute-path error, got: ${output}`)
532
+ // The explanatory text is allowed to mention the phrase for context, but the
533
+ // raw Node stack-trace signature ("imported from ...") must not leak through —
534
+ // that's the actual confusing artifact this fix replaces.
535
+ assert(!output.includes('imported from'),
536
+ `Should not leak the raw Node resolver stack trace to the caller: ${output}`)
537
+ })
538
+
539
+ test('validate-worker.js still gives the file-not-found message for a missing absolute path', () => {
540
+ const { output } = runValidateWorker('/tmp/definitely-does-not-exist-pulse-test.js')
541
+ assert(output.includes('file not found'), `Expected a file-not-found message, got: ${output}`)
542
+ })
543
+
544
+ // ── pulse_intent dashboard-aware inspiration question ───────────────────────
545
+ // Regression: found via dogfooding — the mandatory design-inspiration question
546
+ // ("a site you love, a screenshot, a mood board") has no branch for internal/
547
+ // functional tools, where it lands oddly and risks an agent reinterpreting or
548
+ // skipping a mandatory step. pulse_intent's dashboard archetype now asks a
549
+ // reframed question (visual reference OR clean functional default, plus
550
+ // information-density questions) instead of the generic aesthetic one.
551
+
552
+ test('pulse_intent reframes the inspiration question for the dashboard archetype', () => {
553
+ assert(/if \(key === 'dashboard'\)/.test(serverSrc),
554
+ 'pulse_intent must branch on the dashboard archetype for the inspiration question')
555
+ assert(/reframed for a functional\/internal tool/.test(serverSrc),
556
+ 'the dashboard branch must explain why the question is reframed')
557
+ assert(/is not skippable/.test(serverSrc),
558
+ 'the reframed question must state it remains mandatory, not an excuse to skip a required step')
559
+ })
560
+
561
+ test('workflow.md documents the same internal-tool exception for Step 0', () => {
562
+ const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
563
+ assert(/Internal\/functional tools/.test(workflow),
564
+ 'workflow.md must carry the same internal-tool branch as pulse_intent, since an agent following workflow.md directly (without calling pulse_intent) needs the same guidance')
565
+ assert(/the question itself is still mandatory/.test(workflow),
566
+ 'must be explicit that the internal-tool branch reframes the question, it does not remove the requirement to ask it')
567
+ })
568
+
569
+ // ── pulse_check_bundles ──────────────────────────────────────────────────────
570
+ // Verified live (build a real project, reproduce the exact bug it catches by
571
+ // temporarily reverting the build.js fix, confirm the tool flags it, restore
572
+ // the fix, confirm clean) during development — not re-run here since that
573
+ // needs a full esbuild pass. This locks in the tool's structure so future
574
+ // edits can't silently drop a check without a source-level test noticing.
575
+
576
+ test('pulse_check_bundles checks both bundle-existence and node-builtin-leak', () => {
577
+ assert(/'pulse_check_bundles'/.test(serverSrc), 'pulse_check_bundles must be registered')
578
+ assert(serverSrc.includes("checkBundles(ROOT)"), 'the MCP tool must call the shared checkBundles() logic, not reimplement it')
579
+ assert(/mutations\|actions\|persist/.test(checkBundlesSrc),
580
+ 'must check for mutations/actions/persist — same test as discover.js\'s needsHydration()')
581
+ assert(/NODE_BUILTINS/.test(checkBundlesSrc), 'must scan bundles for leaked Node built-in references')
582
+ assert(/node:fs/.test(checkBundlesSrc.slice(checkBundlesSrc.indexOf('NODE_BUILTINS'))),
583
+ 'node:fs must be in the leak-detection list — the exact module a real persistence layer uses')
584
+ })
585
+
586
+ test('pulse check-bundles CLI command exists and shares check-bundles.js with the MCP tool', () => {
587
+ const cliSrc = fs.readFileSync(path.join(ROOT, 'src/cli/index.js'), 'utf8')
588
+ assert(cliSrc.includes("case 'check-bundles':"), 'pulse check-bundles must be a registered CLI command')
589
+ assert(cliSrc.includes("checkBundles(root)"), 'the CLI command must call the shared checkBundles() logic, not reimplement it')
590
+ })
591
+
592
+ test('verify.md calls pulse_check_bundles as part of the full Lighthouse pre-flight', () => {
593
+ const verify = fs.readFileSync(path.join(agentDir, 'commands', 'verify.md'), 'utf8')
594
+ assert(/pulse_check_bundles/.test(verify), 'verify.md must call pulse_check_bundles')
595
+ // Must appear in the Lighthouse pre-flight section (after pulse_build, before
596
+ // the actual lighthouse_audit call) — not buried somewhere it won't run.
597
+ const buildIdx = verify.indexOf('pulse_build` to produce a production build')
598
+ const checkIdx = verify.indexOf('pulse_check_bundles')
599
+ const auditIdx = verify.indexOf('lighthouse_audit` with `{ "device": "desktop" }')
600
+ assert(buildIdx !== -1 && checkIdx !== -1 && auditIdx !== -1, 'all three anchors must exist in verify.md')
601
+ assert(buildIdx < checkIdx && checkIdx < auditIdx,
602
+ 'pulse_check_bundles must run after pulse_build and before the Lighthouse audit')
603
+ })
604
+
374
605
  // ── Result ───────────────────────────────────────────────────────────────────
375
606
 
376
607
  console.log(`\n${passed} passed, ${failed} failed\n`)