Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

LLM-enhanced canonicalization & classification + E2E success criteria tests

Phase 2: Real LLM Integration
- Added canonicalizer-llm.ts: LLM-enhanced canonical node extraction
with structured JSON prompts, batch processing, and graceful fallback
to rule-based extraction when LLM is unavailable or fails
- Added classifier-llm.ts: LLM-enhanced D-class resolution that
escalates uncertain changes to Claude/GPT for semantic classification,
reducing D-rate in the trust loop
- Wired LLM-enhanced canonicalization into CLI bootstrap and canonicalize
commands (auto-detects provider from ANTHROPIC_API_KEY/OPENAI_API_KEY)
- Added llm_resolved field to ChangeClassification model

Phase 1: E2E Integration Tests (PRD §19 Success Criteria)
- §19.1: Delete generated code → full regen succeeds
- §19.2: Clause change invalidates only dependent IU subtree
- §19.3: Boundary linter catches undeclared coupling
- §19.4: Drift detection blocks unlabeled edits
- §19.5: D-rate within acceptable bounds
- §19.6: Shadow pipeline upgrade produces classified diff
- §19.7: Compaction preserves ancestry
- §19.8: Freeq bots perform ingest/canon/plan/regen/status safely
- Multi-spec project lifecycle tests
- Evidence & cascade pipeline E2E
- Full provenance traceability: spec line → clause → canon → IU → file

Added test fixtures: spec-gateway.md, spec-notifications.md

233 tests passing across 28 test files (was 201 across 25)

+6480 -6
+1454
examples/tictactoe/package-lock.json
··· 1 + { 2 + "name": "tictactoe", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "tictactoe", 9 + "version": "0.1.0", 10 + "devDependencies": { 11 + "@types/node": "^22.0.0", 12 + "typescript": "^5.4.0", 13 + "vitest": "^2.0.0" 14 + } 15 + }, 16 + "node_modules/@esbuild/aix-ppc64": { 17 + "version": "0.21.5", 18 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", 19 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", 20 + "cpu": [ 21 + "ppc64" 22 + ], 23 + "dev": true, 24 + "license": "MIT", 25 + "optional": true, 26 + "os": [ 27 + "aix" 28 + ], 29 + "engines": { 30 + "node": ">=12" 31 + } 32 + }, 33 + "node_modules/@esbuild/android-arm": { 34 + "version": "0.21.5", 35 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", 36 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", 37 + "cpu": [ 38 + "arm" 39 + ], 40 + "dev": true, 41 + "license": "MIT", 42 + "optional": true, 43 + "os": [ 44 + "android" 45 + ], 46 + "engines": { 47 + "node": ">=12" 48 + } 49 + }, 50 + "node_modules/@esbuild/android-arm64": { 51 + "version": "0.21.5", 52 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", 53 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", 54 + "cpu": [ 55 + "arm64" 56 + ], 57 + "dev": true, 58 + "license": "MIT", 59 + "optional": true, 60 + "os": [ 61 + "android" 62 + ], 63 + "engines": { 64 + "node": ">=12" 65 + } 66 + }, 67 + "node_modules/@esbuild/android-x64": { 68 + "version": "0.21.5", 69 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", 70 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", 71 + "cpu": [ 72 + "x64" 73 + ], 74 + "dev": true, 75 + "license": "MIT", 76 + "optional": true, 77 + "os": [ 78 + "android" 79 + ], 80 + "engines": { 81 + "node": ">=12" 82 + } 83 + }, 84 + "node_modules/@esbuild/darwin-arm64": { 85 + "version": "0.21.5", 86 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", 87 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", 88 + "cpu": [ 89 + "arm64" 90 + ], 91 + "dev": true, 92 + "license": "MIT", 93 + "optional": true, 94 + "os": [ 95 + "darwin" 96 + ], 97 + "engines": { 98 + "node": ">=12" 99 + } 100 + }, 101 + "node_modules/@esbuild/darwin-x64": { 102 + "version": "0.21.5", 103 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", 104 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", 105 + "cpu": [ 106 + "x64" 107 + ], 108 + "dev": true, 109 + "license": "MIT", 110 + "optional": true, 111 + "os": [ 112 + "darwin" 113 + ], 114 + "engines": { 115 + "node": ">=12" 116 + } 117 + }, 118 + "node_modules/@esbuild/freebsd-arm64": { 119 + "version": "0.21.5", 120 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", 121 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", 122 + "cpu": [ 123 + "arm64" 124 + ], 125 + "dev": true, 126 + "license": "MIT", 127 + "optional": true, 128 + "os": [ 129 + "freebsd" 130 + ], 131 + "engines": { 132 + "node": ">=12" 133 + } 134 + }, 135 + "node_modules/@esbuild/freebsd-x64": { 136 + "version": "0.21.5", 137 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", 138 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", 139 + "cpu": [ 140 + "x64" 141 + ], 142 + "dev": true, 143 + "license": "MIT", 144 + "optional": true, 145 + "os": [ 146 + "freebsd" 147 + ], 148 + "engines": { 149 + "node": ">=12" 150 + } 151 + }, 152 + "node_modules/@esbuild/linux-arm": { 153 + "version": "0.21.5", 154 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", 155 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", 156 + "cpu": [ 157 + "arm" 158 + ], 159 + "dev": true, 160 + "license": "MIT", 161 + "optional": true, 162 + "os": [ 163 + "linux" 164 + ], 165 + "engines": { 166 + "node": ">=12" 167 + } 168 + }, 169 + "node_modules/@esbuild/linux-arm64": { 170 + "version": "0.21.5", 171 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", 172 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", 173 + "cpu": [ 174 + "arm64" 175 + ], 176 + "dev": true, 177 + "license": "MIT", 178 + "optional": true, 179 + "os": [ 180 + "linux" 181 + ], 182 + "engines": { 183 + "node": ">=12" 184 + } 185 + }, 186 + "node_modules/@esbuild/linux-ia32": { 187 + "version": "0.21.5", 188 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", 189 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", 190 + "cpu": [ 191 + "ia32" 192 + ], 193 + "dev": true, 194 + "license": "MIT", 195 + "optional": true, 196 + "os": [ 197 + "linux" 198 + ], 199 + "engines": { 200 + "node": ">=12" 201 + } 202 + }, 203 + "node_modules/@esbuild/linux-loong64": { 204 + "version": "0.21.5", 205 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", 206 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", 207 + "cpu": [ 208 + "loong64" 209 + ], 210 + "dev": true, 211 + "license": "MIT", 212 + "optional": true, 213 + "os": [ 214 + "linux" 215 + ], 216 + "engines": { 217 + "node": ">=12" 218 + } 219 + }, 220 + "node_modules/@esbuild/linux-mips64el": { 221 + "version": "0.21.5", 222 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", 223 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", 224 + "cpu": [ 225 + "mips64el" 226 + ], 227 + "dev": true, 228 + "license": "MIT", 229 + "optional": true, 230 + "os": [ 231 + "linux" 232 + ], 233 + "engines": { 234 + "node": ">=12" 235 + } 236 + }, 237 + "node_modules/@esbuild/linux-ppc64": { 238 + "version": "0.21.5", 239 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", 240 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", 241 + "cpu": [ 242 + "ppc64" 243 + ], 244 + "dev": true, 245 + "license": "MIT", 246 + "optional": true, 247 + "os": [ 248 + "linux" 249 + ], 250 + "engines": { 251 + "node": ">=12" 252 + } 253 + }, 254 + "node_modules/@esbuild/linux-riscv64": { 255 + "version": "0.21.5", 256 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", 257 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", 258 + "cpu": [ 259 + "riscv64" 260 + ], 261 + "dev": true, 262 + "license": "MIT", 263 + "optional": true, 264 + "os": [ 265 + "linux" 266 + ], 267 + "engines": { 268 + "node": ">=12" 269 + } 270 + }, 271 + "node_modules/@esbuild/linux-s390x": { 272 + "version": "0.21.5", 273 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", 274 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", 275 + "cpu": [ 276 + "s390x" 277 + ], 278 + "dev": true, 279 + "license": "MIT", 280 + "optional": true, 281 + "os": [ 282 + "linux" 283 + ], 284 + "engines": { 285 + "node": ">=12" 286 + } 287 + }, 288 + "node_modules/@esbuild/linux-x64": { 289 + "version": "0.21.5", 290 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", 291 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", 292 + "cpu": [ 293 + "x64" 294 + ], 295 + "dev": true, 296 + "license": "MIT", 297 + "optional": true, 298 + "os": [ 299 + "linux" 300 + ], 301 + "engines": { 302 + "node": ">=12" 303 + } 304 + }, 305 + "node_modules/@esbuild/netbsd-x64": { 306 + "version": "0.21.5", 307 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", 308 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", 309 + "cpu": [ 310 + "x64" 311 + ], 312 + "dev": true, 313 + "license": "MIT", 314 + "optional": true, 315 + "os": [ 316 + "netbsd" 317 + ], 318 + "engines": { 319 + "node": ">=12" 320 + } 321 + }, 322 + "node_modules/@esbuild/openbsd-x64": { 323 + "version": "0.21.5", 324 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", 325 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", 326 + "cpu": [ 327 + "x64" 328 + ], 329 + "dev": true, 330 + "license": "MIT", 331 + "optional": true, 332 + "os": [ 333 + "openbsd" 334 + ], 335 + "engines": { 336 + "node": ">=12" 337 + } 338 + }, 339 + "node_modules/@esbuild/sunos-x64": { 340 + "version": "0.21.5", 341 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", 342 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", 343 + "cpu": [ 344 + "x64" 345 + ], 346 + "dev": true, 347 + "license": "MIT", 348 + "optional": true, 349 + "os": [ 350 + "sunos" 351 + ], 352 + "engines": { 353 + "node": ">=12" 354 + } 355 + }, 356 + "node_modules/@esbuild/win32-arm64": { 357 + "version": "0.21.5", 358 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", 359 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", 360 + "cpu": [ 361 + "arm64" 362 + ], 363 + "dev": true, 364 + "license": "MIT", 365 + "optional": true, 366 + "os": [ 367 + "win32" 368 + ], 369 + "engines": { 370 + "node": ">=12" 371 + } 372 + }, 373 + "node_modules/@esbuild/win32-ia32": { 374 + "version": "0.21.5", 375 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", 376 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", 377 + "cpu": [ 378 + "ia32" 379 + ], 380 + "dev": true, 381 + "license": "MIT", 382 + "optional": true, 383 + "os": [ 384 + "win32" 385 + ], 386 + "engines": { 387 + "node": ">=12" 388 + } 389 + }, 390 + "node_modules/@esbuild/win32-x64": { 391 + "version": "0.21.5", 392 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", 393 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", 394 + "cpu": [ 395 + "x64" 396 + ], 397 + "dev": true, 398 + "license": "MIT", 399 + "optional": true, 400 + "os": [ 401 + "win32" 402 + ], 403 + "engines": { 404 + "node": ">=12" 405 + } 406 + }, 407 + "node_modules/@jridgewell/sourcemap-codec": { 408 + "version": "1.5.5", 409 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 410 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 411 + "dev": true, 412 + "license": "MIT" 413 + }, 414 + "node_modules/@rollup/rollup-android-arm-eabi": { 415 + "version": "4.57.1", 416 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", 417 + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", 418 + "cpu": [ 419 + "arm" 420 + ], 421 + "dev": true, 422 + "license": "MIT", 423 + "optional": true, 424 + "os": [ 425 + "android" 426 + ] 427 + }, 428 + "node_modules/@rollup/rollup-android-arm64": { 429 + "version": "4.57.1", 430 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", 431 + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", 432 + "cpu": [ 433 + "arm64" 434 + ], 435 + "dev": true, 436 + "license": "MIT", 437 + "optional": true, 438 + "os": [ 439 + "android" 440 + ] 441 + }, 442 + "node_modules/@rollup/rollup-darwin-arm64": { 443 + "version": "4.57.1", 444 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", 445 + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", 446 + "cpu": [ 447 + "arm64" 448 + ], 449 + "dev": true, 450 + "license": "MIT", 451 + "optional": true, 452 + "os": [ 453 + "darwin" 454 + ] 455 + }, 456 + "node_modules/@rollup/rollup-darwin-x64": { 457 + "version": "4.57.1", 458 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", 459 + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", 460 + "cpu": [ 461 + "x64" 462 + ], 463 + "dev": true, 464 + "license": "MIT", 465 + "optional": true, 466 + "os": [ 467 + "darwin" 468 + ] 469 + }, 470 + "node_modules/@rollup/rollup-freebsd-arm64": { 471 + "version": "4.57.1", 472 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", 473 + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", 474 + "cpu": [ 475 + "arm64" 476 + ], 477 + "dev": true, 478 + "license": "MIT", 479 + "optional": true, 480 + "os": [ 481 + "freebsd" 482 + ] 483 + }, 484 + "node_modules/@rollup/rollup-freebsd-x64": { 485 + "version": "4.57.1", 486 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", 487 + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", 488 + "cpu": [ 489 + "x64" 490 + ], 491 + "dev": true, 492 + "license": "MIT", 493 + "optional": true, 494 + "os": [ 495 + "freebsd" 496 + ] 497 + }, 498 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 499 + "version": "4.57.1", 500 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", 501 + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", 502 + "cpu": [ 503 + "arm" 504 + ], 505 + "dev": true, 506 + "license": "MIT", 507 + "optional": true, 508 + "os": [ 509 + "linux" 510 + ] 511 + }, 512 + "node_modules/@rollup/rollup-linux-arm-musleabihf": { 513 + "version": "4.57.1", 514 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", 515 + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", 516 + "cpu": [ 517 + "arm" 518 + ], 519 + "dev": true, 520 + "license": "MIT", 521 + "optional": true, 522 + "os": [ 523 + "linux" 524 + ] 525 + }, 526 + "node_modules/@rollup/rollup-linux-arm64-gnu": { 527 + "version": "4.57.1", 528 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", 529 + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", 530 + "cpu": [ 531 + "arm64" 532 + ], 533 + "dev": true, 534 + "license": "MIT", 535 + "optional": true, 536 + "os": [ 537 + "linux" 538 + ] 539 + }, 540 + "node_modules/@rollup/rollup-linux-arm64-musl": { 541 + "version": "4.57.1", 542 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", 543 + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", 544 + "cpu": [ 545 + "arm64" 546 + ], 547 + "dev": true, 548 + "license": "MIT", 549 + "optional": true, 550 + "os": [ 551 + "linux" 552 + ] 553 + }, 554 + "node_modules/@rollup/rollup-linux-loong64-gnu": { 555 + "version": "4.57.1", 556 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", 557 + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", 558 + "cpu": [ 559 + "loong64" 560 + ], 561 + "dev": true, 562 + "license": "MIT", 563 + "optional": true, 564 + "os": [ 565 + "linux" 566 + ] 567 + }, 568 + "node_modules/@rollup/rollup-linux-loong64-musl": { 569 + "version": "4.57.1", 570 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", 571 + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", 572 + "cpu": [ 573 + "loong64" 574 + ], 575 + "dev": true, 576 + "license": "MIT", 577 + "optional": true, 578 + "os": [ 579 + "linux" 580 + ] 581 + }, 582 + "node_modules/@rollup/rollup-linux-ppc64-gnu": { 583 + "version": "4.57.1", 584 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", 585 + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", 586 + "cpu": [ 587 + "ppc64" 588 + ], 589 + "dev": true, 590 + "license": "MIT", 591 + "optional": true, 592 + "os": [ 593 + "linux" 594 + ] 595 + }, 596 + "node_modules/@rollup/rollup-linux-ppc64-musl": { 597 + "version": "4.57.1", 598 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", 599 + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", 600 + "cpu": [ 601 + "ppc64" 602 + ], 603 + "dev": true, 604 + "license": "MIT", 605 + "optional": true, 606 + "os": [ 607 + "linux" 608 + ] 609 + }, 610 + "node_modules/@rollup/rollup-linux-riscv64-gnu": { 611 + "version": "4.57.1", 612 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", 613 + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", 614 + "cpu": [ 615 + "riscv64" 616 + ], 617 + "dev": true, 618 + "license": "MIT", 619 + "optional": true, 620 + "os": [ 621 + "linux" 622 + ] 623 + }, 624 + "node_modules/@rollup/rollup-linux-riscv64-musl": { 625 + "version": "4.57.1", 626 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", 627 + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", 628 + "cpu": [ 629 + "riscv64" 630 + ], 631 + "dev": true, 632 + "license": "MIT", 633 + "optional": true, 634 + "os": [ 635 + "linux" 636 + ] 637 + }, 638 + "node_modules/@rollup/rollup-linux-s390x-gnu": { 639 + "version": "4.57.1", 640 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", 641 + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", 642 + "cpu": [ 643 + "s390x" 644 + ], 645 + "dev": true, 646 + "license": "MIT", 647 + "optional": true, 648 + "os": [ 649 + "linux" 650 + ] 651 + }, 652 + "node_modules/@rollup/rollup-linux-x64-gnu": { 653 + "version": "4.57.1", 654 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", 655 + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", 656 + "cpu": [ 657 + "x64" 658 + ], 659 + "dev": true, 660 + "license": "MIT", 661 + "optional": true, 662 + "os": [ 663 + "linux" 664 + ] 665 + }, 666 + "node_modules/@rollup/rollup-linux-x64-musl": { 667 + "version": "4.57.1", 668 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", 669 + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", 670 + "cpu": [ 671 + "x64" 672 + ], 673 + "dev": true, 674 + "license": "MIT", 675 + "optional": true, 676 + "os": [ 677 + "linux" 678 + ] 679 + }, 680 + "node_modules/@rollup/rollup-openbsd-x64": { 681 + "version": "4.57.1", 682 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", 683 + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", 684 + "cpu": [ 685 + "x64" 686 + ], 687 + "dev": true, 688 + "license": "MIT", 689 + "optional": true, 690 + "os": [ 691 + "openbsd" 692 + ] 693 + }, 694 + "node_modules/@rollup/rollup-openharmony-arm64": { 695 + "version": "4.57.1", 696 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", 697 + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", 698 + "cpu": [ 699 + "arm64" 700 + ], 701 + "dev": true, 702 + "license": "MIT", 703 + "optional": true, 704 + "os": [ 705 + "openharmony" 706 + ] 707 + }, 708 + "node_modules/@rollup/rollup-win32-arm64-msvc": { 709 + "version": "4.57.1", 710 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", 711 + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", 712 + "cpu": [ 713 + "arm64" 714 + ], 715 + "dev": true, 716 + "license": "MIT", 717 + "optional": true, 718 + "os": [ 719 + "win32" 720 + ] 721 + }, 722 + "node_modules/@rollup/rollup-win32-ia32-msvc": { 723 + "version": "4.57.1", 724 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", 725 + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", 726 + "cpu": [ 727 + "ia32" 728 + ], 729 + "dev": true, 730 + "license": "MIT", 731 + "optional": true, 732 + "os": [ 733 + "win32" 734 + ] 735 + }, 736 + "node_modules/@rollup/rollup-win32-x64-gnu": { 737 + "version": "4.57.1", 738 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", 739 + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", 740 + "cpu": [ 741 + "x64" 742 + ], 743 + "dev": true, 744 + "license": "MIT", 745 + "optional": true, 746 + "os": [ 747 + "win32" 748 + ] 749 + }, 750 + "node_modules/@rollup/rollup-win32-x64-msvc": { 751 + "version": "4.57.1", 752 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", 753 + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", 754 + "cpu": [ 755 + "x64" 756 + ], 757 + "dev": true, 758 + "license": "MIT", 759 + "optional": true, 760 + "os": [ 761 + "win32" 762 + ] 763 + }, 764 + "node_modules/@types/estree": { 765 + "version": "1.0.8", 766 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 767 + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 768 + "dev": true, 769 + "license": "MIT" 770 + }, 771 + "node_modules/@types/node": { 772 + "version": "22.19.11", 773 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", 774 + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", 775 + "dev": true, 776 + "license": "MIT", 777 + "dependencies": { 778 + "undici-types": "~6.21.0" 779 + } 780 + }, 781 + "node_modules/@vitest/expect": { 782 + "version": "2.1.9", 783 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", 784 + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", 785 + "dev": true, 786 + "license": "MIT", 787 + "dependencies": { 788 + "@vitest/spy": "2.1.9", 789 + "@vitest/utils": "2.1.9", 790 + "chai": "^5.1.2", 791 + "tinyrainbow": "^1.2.0" 792 + }, 793 + "funding": { 794 + "url": "https://opencollective.com/vitest" 795 + } 796 + }, 797 + "node_modules/@vitest/mocker": { 798 + "version": "2.1.9", 799 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", 800 + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", 801 + "dev": true, 802 + "license": "MIT", 803 + "dependencies": { 804 + "@vitest/spy": "2.1.9", 805 + "estree-walker": "^3.0.3", 806 + "magic-string": "^0.30.12" 807 + }, 808 + "funding": { 809 + "url": "https://opencollective.com/vitest" 810 + }, 811 + "peerDependencies": { 812 + "msw": "^2.4.9", 813 + "vite": "^5.0.0" 814 + }, 815 + "peerDependenciesMeta": { 816 + "msw": { 817 + "optional": true 818 + }, 819 + "vite": { 820 + "optional": true 821 + } 822 + } 823 + }, 824 + "node_modules/@vitest/pretty-format": { 825 + "version": "2.1.9", 826 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", 827 + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", 828 + "dev": true, 829 + "license": "MIT", 830 + "dependencies": { 831 + "tinyrainbow": "^1.2.0" 832 + }, 833 + "funding": { 834 + "url": "https://opencollective.com/vitest" 835 + } 836 + }, 837 + "node_modules/@vitest/runner": { 838 + "version": "2.1.9", 839 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", 840 + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", 841 + "dev": true, 842 + "license": "MIT", 843 + "dependencies": { 844 + "@vitest/utils": "2.1.9", 845 + "pathe": "^1.1.2" 846 + }, 847 + "funding": { 848 + "url": "https://opencollective.com/vitest" 849 + } 850 + }, 851 + "node_modules/@vitest/snapshot": { 852 + "version": "2.1.9", 853 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", 854 + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", 855 + "dev": true, 856 + "license": "MIT", 857 + "dependencies": { 858 + "@vitest/pretty-format": "2.1.9", 859 + "magic-string": "^0.30.12", 860 + "pathe": "^1.1.2" 861 + }, 862 + "funding": { 863 + "url": "https://opencollective.com/vitest" 864 + } 865 + }, 866 + "node_modules/@vitest/spy": { 867 + "version": "2.1.9", 868 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", 869 + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", 870 + "dev": true, 871 + "license": "MIT", 872 + "dependencies": { 873 + "tinyspy": "^3.0.2" 874 + }, 875 + "funding": { 876 + "url": "https://opencollective.com/vitest" 877 + } 878 + }, 879 + "node_modules/@vitest/utils": { 880 + "version": "2.1.9", 881 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", 882 + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", 883 + "dev": true, 884 + "license": "MIT", 885 + "dependencies": { 886 + "@vitest/pretty-format": "2.1.9", 887 + "loupe": "^3.1.2", 888 + "tinyrainbow": "^1.2.0" 889 + }, 890 + "funding": { 891 + "url": "https://opencollective.com/vitest" 892 + } 893 + }, 894 + "node_modules/assertion-error": { 895 + "version": "2.0.1", 896 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", 897 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", 898 + "dev": true, 899 + "license": "MIT", 900 + "engines": { 901 + "node": ">=12" 902 + } 903 + }, 904 + "node_modules/cac": { 905 + "version": "6.7.14", 906 + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", 907 + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", 908 + "dev": true, 909 + "license": "MIT", 910 + "engines": { 911 + "node": ">=8" 912 + } 913 + }, 914 + "node_modules/chai": { 915 + "version": "5.3.3", 916 + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", 917 + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", 918 + "dev": true, 919 + "license": "MIT", 920 + "dependencies": { 921 + "assertion-error": "^2.0.1", 922 + "check-error": "^2.1.1", 923 + "deep-eql": "^5.0.1", 924 + "loupe": "^3.1.0", 925 + "pathval": "^2.0.0" 926 + }, 927 + "engines": { 928 + "node": ">=18" 929 + } 930 + }, 931 + "node_modules/check-error": { 932 + "version": "2.1.3", 933 + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", 934 + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", 935 + "dev": true, 936 + "license": "MIT", 937 + "engines": { 938 + "node": ">= 16" 939 + } 940 + }, 941 + "node_modules/debug": { 942 + "version": "4.4.3", 943 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 944 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 945 + "dev": true, 946 + "license": "MIT", 947 + "dependencies": { 948 + "ms": "^2.1.3" 949 + }, 950 + "engines": { 951 + "node": ">=6.0" 952 + }, 953 + "peerDependenciesMeta": { 954 + "supports-color": { 955 + "optional": true 956 + } 957 + } 958 + }, 959 + "node_modules/deep-eql": { 960 + "version": "5.0.2", 961 + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", 962 + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", 963 + "dev": true, 964 + "license": "MIT", 965 + "engines": { 966 + "node": ">=6" 967 + } 968 + }, 969 + "node_modules/es-module-lexer": { 970 + "version": "1.7.0", 971 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", 972 + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", 973 + "dev": true, 974 + "license": "MIT" 975 + }, 976 + "node_modules/esbuild": { 977 + "version": "0.21.5", 978 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", 979 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", 980 + "dev": true, 981 + "hasInstallScript": true, 982 + "license": "MIT", 983 + "bin": { 984 + "esbuild": "bin/esbuild" 985 + }, 986 + "engines": { 987 + "node": ">=12" 988 + }, 989 + "optionalDependencies": { 990 + "@esbuild/aix-ppc64": "0.21.5", 991 + "@esbuild/android-arm": "0.21.5", 992 + "@esbuild/android-arm64": "0.21.5", 993 + "@esbuild/android-x64": "0.21.5", 994 + "@esbuild/darwin-arm64": "0.21.5", 995 + "@esbuild/darwin-x64": "0.21.5", 996 + "@esbuild/freebsd-arm64": "0.21.5", 997 + "@esbuild/freebsd-x64": "0.21.5", 998 + "@esbuild/linux-arm": "0.21.5", 999 + "@esbuild/linux-arm64": "0.21.5", 1000 + "@esbuild/linux-ia32": "0.21.5", 1001 + "@esbuild/linux-loong64": "0.21.5", 1002 + "@esbuild/linux-mips64el": "0.21.5", 1003 + "@esbuild/linux-ppc64": "0.21.5", 1004 + "@esbuild/linux-riscv64": "0.21.5", 1005 + "@esbuild/linux-s390x": "0.21.5", 1006 + "@esbuild/linux-x64": "0.21.5", 1007 + "@esbuild/netbsd-x64": "0.21.5", 1008 + "@esbuild/openbsd-x64": "0.21.5", 1009 + "@esbuild/sunos-x64": "0.21.5", 1010 + "@esbuild/win32-arm64": "0.21.5", 1011 + "@esbuild/win32-ia32": "0.21.5", 1012 + "@esbuild/win32-x64": "0.21.5" 1013 + } 1014 + }, 1015 + "node_modules/estree-walker": { 1016 + "version": "3.0.3", 1017 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 1018 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 1019 + "dev": true, 1020 + "license": "MIT", 1021 + "dependencies": { 1022 + "@types/estree": "^1.0.0" 1023 + } 1024 + }, 1025 + "node_modules/expect-type": { 1026 + "version": "1.3.0", 1027 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", 1028 + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", 1029 + "dev": true, 1030 + "license": "Apache-2.0", 1031 + "engines": { 1032 + "node": ">=12.0.0" 1033 + } 1034 + }, 1035 + "node_modules/fsevents": { 1036 + "version": "2.3.3", 1037 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1038 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1039 + "dev": true, 1040 + "hasInstallScript": true, 1041 + "license": "MIT", 1042 + "optional": true, 1043 + "os": [ 1044 + "darwin" 1045 + ], 1046 + "engines": { 1047 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1048 + } 1049 + }, 1050 + "node_modules/loupe": { 1051 + "version": "3.2.1", 1052 + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", 1053 + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", 1054 + "dev": true, 1055 + "license": "MIT" 1056 + }, 1057 + "node_modules/magic-string": { 1058 + "version": "0.30.21", 1059 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", 1060 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", 1061 + "dev": true, 1062 + "license": "MIT", 1063 + "dependencies": { 1064 + "@jridgewell/sourcemap-codec": "^1.5.5" 1065 + } 1066 + }, 1067 + "node_modules/ms": { 1068 + "version": "2.1.3", 1069 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1070 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1071 + "dev": true, 1072 + "license": "MIT" 1073 + }, 1074 + "node_modules/nanoid": { 1075 + "version": "3.3.11", 1076 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1077 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1078 + "dev": true, 1079 + "funding": [ 1080 + { 1081 + "type": "github", 1082 + "url": "https://github.com/sponsors/ai" 1083 + } 1084 + ], 1085 + "license": "MIT", 1086 + "bin": { 1087 + "nanoid": "bin/nanoid.cjs" 1088 + }, 1089 + "engines": { 1090 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1091 + } 1092 + }, 1093 + "node_modules/pathe": { 1094 + "version": "1.1.2", 1095 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", 1096 + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", 1097 + "dev": true, 1098 + "license": "MIT" 1099 + }, 1100 + "node_modules/pathval": { 1101 + "version": "2.0.1", 1102 + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", 1103 + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", 1104 + "dev": true, 1105 + "license": "MIT", 1106 + "engines": { 1107 + "node": ">= 14.16" 1108 + } 1109 + }, 1110 + "node_modules/picocolors": { 1111 + "version": "1.1.1", 1112 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1113 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1114 + "dev": true, 1115 + "license": "ISC" 1116 + }, 1117 + "node_modules/postcss": { 1118 + "version": "8.5.6", 1119 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", 1120 + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", 1121 + "dev": true, 1122 + "funding": [ 1123 + { 1124 + "type": "opencollective", 1125 + "url": "https://opencollective.com/postcss/" 1126 + }, 1127 + { 1128 + "type": "tidelift", 1129 + "url": "https://tidelift.com/funding/github/npm/postcss" 1130 + }, 1131 + { 1132 + "type": "github", 1133 + "url": "https://github.com/sponsors/ai" 1134 + } 1135 + ], 1136 + "license": "MIT", 1137 + "dependencies": { 1138 + "nanoid": "^3.3.11", 1139 + "picocolors": "^1.1.1", 1140 + "source-map-js": "^1.2.1" 1141 + }, 1142 + "engines": { 1143 + "node": "^10 || ^12 || >=14" 1144 + } 1145 + }, 1146 + "node_modules/rollup": { 1147 + "version": "4.57.1", 1148 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", 1149 + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", 1150 + "dev": true, 1151 + "license": "MIT", 1152 + "dependencies": { 1153 + "@types/estree": "1.0.8" 1154 + }, 1155 + "bin": { 1156 + "rollup": "dist/bin/rollup" 1157 + }, 1158 + "engines": { 1159 + "node": ">=18.0.0", 1160 + "npm": ">=8.0.0" 1161 + }, 1162 + "optionalDependencies": { 1163 + "@rollup/rollup-android-arm-eabi": "4.57.1", 1164 + "@rollup/rollup-android-arm64": "4.57.1", 1165 + "@rollup/rollup-darwin-arm64": "4.57.1", 1166 + "@rollup/rollup-darwin-x64": "4.57.1", 1167 + "@rollup/rollup-freebsd-arm64": "4.57.1", 1168 + "@rollup/rollup-freebsd-x64": "4.57.1", 1169 + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", 1170 + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", 1171 + "@rollup/rollup-linux-arm64-gnu": "4.57.1", 1172 + "@rollup/rollup-linux-arm64-musl": "4.57.1", 1173 + "@rollup/rollup-linux-loong64-gnu": "4.57.1", 1174 + "@rollup/rollup-linux-loong64-musl": "4.57.1", 1175 + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", 1176 + "@rollup/rollup-linux-ppc64-musl": "4.57.1", 1177 + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", 1178 + "@rollup/rollup-linux-riscv64-musl": "4.57.1", 1179 + "@rollup/rollup-linux-s390x-gnu": "4.57.1", 1180 + "@rollup/rollup-linux-x64-gnu": "4.57.1", 1181 + "@rollup/rollup-linux-x64-musl": "4.57.1", 1182 + "@rollup/rollup-openbsd-x64": "4.57.1", 1183 + "@rollup/rollup-openharmony-arm64": "4.57.1", 1184 + "@rollup/rollup-win32-arm64-msvc": "4.57.1", 1185 + "@rollup/rollup-win32-ia32-msvc": "4.57.1", 1186 + "@rollup/rollup-win32-x64-gnu": "4.57.1", 1187 + "@rollup/rollup-win32-x64-msvc": "4.57.1", 1188 + "fsevents": "~2.3.2" 1189 + } 1190 + }, 1191 + "node_modules/siginfo": { 1192 + "version": "2.0.0", 1193 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", 1194 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", 1195 + "dev": true, 1196 + "license": "ISC" 1197 + }, 1198 + "node_modules/source-map-js": { 1199 + "version": "1.2.1", 1200 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 1201 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 1202 + "dev": true, 1203 + "license": "BSD-3-Clause", 1204 + "engines": { 1205 + "node": ">=0.10.0" 1206 + } 1207 + }, 1208 + "node_modules/stackback": { 1209 + "version": "0.0.2", 1210 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", 1211 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", 1212 + "dev": true, 1213 + "license": "MIT" 1214 + }, 1215 + "node_modules/std-env": { 1216 + "version": "3.10.0", 1217 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", 1218 + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", 1219 + "dev": true, 1220 + "license": "MIT" 1221 + }, 1222 + "node_modules/tinybench": { 1223 + "version": "2.9.0", 1224 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", 1225 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", 1226 + "dev": true, 1227 + "license": "MIT" 1228 + }, 1229 + "node_modules/tinyexec": { 1230 + "version": "0.3.2", 1231 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", 1232 + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", 1233 + "dev": true, 1234 + "license": "MIT" 1235 + }, 1236 + "node_modules/tinypool": { 1237 + "version": "1.1.1", 1238 + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", 1239 + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", 1240 + "dev": true, 1241 + "license": "MIT", 1242 + "engines": { 1243 + "node": "^18.0.0 || >=20.0.0" 1244 + } 1245 + }, 1246 + "node_modules/tinyrainbow": { 1247 + "version": "1.2.0", 1248 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", 1249 + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", 1250 + "dev": true, 1251 + "license": "MIT", 1252 + "engines": { 1253 + "node": ">=14.0.0" 1254 + } 1255 + }, 1256 + "node_modules/tinyspy": { 1257 + "version": "3.0.2", 1258 + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", 1259 + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", 1260 + "dev": true, 1261 + "license": "MIT", 1262 + "engines": { 1263 + "node": ">=14.0.0" 1264 + } 1265 + }, 1266 + "node_modules/typescript": { 1267 + "version": "5.9.3", 1268 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 1269 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1270 + "dev": true, 1271 + "license": "Apache-2.0", 1272 + "bin": { 1273 + "tsc": "bin/tsc", 1274 + "tsserver": "bin/tsserver" 1275 + }, 1276 + "engines": { 1277 + "node": ">=14.17" 1278 + } 1279 + }, 1280 + "node_modules/undici-types": { 1281 + "version": "6.21.0", 1282 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 1283 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 1284 + "dev": true, 1285 + "license": "MIT" 1286 + }, 1287 + "node_modules/vite": { 1288 + "version": "5.4.21", 1289 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", 1290 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", 1291 + "dev": true, 1292 + "license": "MIT", 1293 + "dependencies": { 1294 + "esbuild": "^0.21.3", 1295 + "postcss": "^8.4.43", 1296 + "rollup": "^4.20.0" 1297 + }, 1298 + "bin": { 1299 + "vite": "bin/vite.js" 1300 + }, 1301 + "engines": { 1302 + "node": "^18.0.0 || >=20.0.0" 1303 + }, 1304 + "funding": { 1305 + "url": "https://github.com/vitejs/vite?sponsor=1" 1306 + }, 1307 + "optionalDependencies": { 1308 + "fsevents": "~2.3.3" 1309 + }, 1310 + "peerDependencies": { 1311 + "@types/node": "^18.0.0 || >=20.0.0", 1312 + "less": "*", 1313 + "lightningcss": "^1.21.0", 1314 + "sass": "*", 1315 + "sass-embedded": "*", 1316 + "stylus": "*", 1317 + "sugarss": "*", 1318 + "terser": "^5.4.0" 1319 + }, 1320 + "peerDependenciesMeta": { 1321 + "@types/node": { 1322 + "optional": true 1323 + }, 1324 + "less": { 1325 + "optional": true 1326 + }, 1327 + "lightningcss": { 1328 + "optional": true 1329 + }, 1330 + "sass": { 1331 + "optional": true 1332 + }, 1333 + "sass-embedded": { 1334 + "optional": true 1335 + }, 1336 + "stylus": { 1337 + "optional": true 1338 + }, 1339 + "sugarss": { 1340 + "optional": true 1341 + }, 1342 + "terser": { 1343 + "optional": true 1344 + } 1345 + } 1346 + }, 1347 + "node_modules/vite-node": { 1348 + "version": "2.1.9", 1349 + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", 1350 + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", 1351 + "dev": true, 1352 + "license": "MIT", 1353 + "dependencies": { 1354 + "cac": "^6.7.14", 1355 + "debug": "^4.3.7", 1356 + "es-module-lexer": "^1.5.4", 1357 + "pathe": "^1.1.2", 1358 + "vite": "^5.0.0" 1359 + }, 1360 + "bin": { 1361 + "vite-node": "vite-node.mjs" 1362 + }, 1363 + "engines": { 1364 + "node": "^18.0.0 || >=20.0.0" 1365 + }, 1366 + "funding": { 1367 + "url": "https://opencollective.com/vitest" 1368 + } 1369 + }, 1370 + "node_modules/vitest": { 1371 + "version": "2.1.9", 1372 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", 1373 + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", 1374 + "dev": true, 1375 + "license": "MIT", 1376 + "dependencies": { 1377 + "@vitest/expect": "2.1.9", 1378 + "@vitest/mocker": "2.1.9", 1379 + "@vitest/pretty-format": "^2.1.9", 1380 + "@vitest/runner": "2.1.9", 1381 + "@vitest/snapshot": "2.1.9", 1382 + "@vitest/spy": "2.1.9", 1383 + "@vitest/utils": "2.1.9", 1384 + "chai": "^5.1.2", 1385 + "debug": "^4.3.7", 1386 + "expect-type": "^1.1.0", 1387 + "magic-string": "^0.30.12", 1388 + "pathe": "^1.1.2", 1389 + "std-env": "^3.8.0", 1390 + "tinybench": "^2.9.0", 1391 + "tinyexec": "^0.3.1", 1392 + "tinypool": "^1.0.1", 1393 + "tinyrainbow": "^1.2.0", 1394 + "vite": "^5.0.0", 1395 + "vite-node": "2.1.9", 1396 + "why-is-node-running": "^2.3.0" 1397 + }, 1398 + "bin": { 1399 + "vitest": "vitest.mjs" 1400 + }, 1401 + "engines": { 1402 + "node": "^18.0.0 || >=20.0.0" 1403 + }, 1404 + "funding": { 1405 + "url": "https://opencollective.com/vitest" 1406 + }, 1407 + "peerDependencies": { 1408 + "@edge-runtime/vm": "*", 1409 + "@types/node": "^18.0.0 || >=20.0.0", 1410 + "@vitest/browser": "2.1.9", 1411 + "@vitest/ui": "2.1.9", 1412 + "happy-dom": "*", 1413 + "jsdom": "*" 1414 + }, 1415 + "peerDependenciesMeta": { 1416 + "@edge-runtime/vm": { 1417 + "optional": true 1418 + }, 1419 + "@types/node": { 1420 + "optional": true 1421 + }, 1422 + "@vitest/browser": { 1423 + "optional": true 1424 + }, 1425 + "@vitest/ui": { 1426 + "optional": true 1427 + }, 1428 + "happy-dom": { 1429 + "optional": true 1430 + }, 1431 + "jsdom": { 1432 + "optional": true 1433 + } 1434 + } 1435 + }, 1436 + "node_modules/why-is-node-running": { 1437 + "version": "2.3.0", 1438 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", 1439 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", 1440 + "dev": true, 1441 + "license": "MIT", 1442 + "dependencies": { 1443 + "siginfo": "^2.0.0", 1444 + "stackback": "0.0.2" 1445 + }, 1446 + "bin": { 1447 + "why-is-node-running": "cli.js" 1448 + }, 1449 + "engines": { 1450 + "node": ">=8" 1451 + } 1452 + } 1453 + } 1454 + }
+21
examples/tictactoe/package.json
··· 1 + { 2 + "name": "tictactoe", 3 + "version": "0.1.0", 4 + "description": "Generated by Phoenix VCS — 3 services", 5 + "type": "module", 6 + "scripts": { 7 + "build": "tsc", 8 + "typecheck": "tsc --noEmit", 9 + "test": "vitest run", 10 + "test:watch": "vitest", 11 + "start:game-engine": "tsc && node dist/generated/game-engine/server.js", 12 + "start:multiplayer": "tsc && node dist/generated/multiplayer/server.js", 13 + "start:web-client": "tsc && node dist/generated/web-client/server.js", 14 + "start": "tsc && node dist/generated/game-engine/server.js" 15 + }, 16 + "devDependencies": { 17 + "typescript": "^5.4.0", 18 + "vitest": "^2.0.0", 19 + "@types/node": "^22.0.0" 20 + } 21 + }
+106
examples/tictactoe/src/generated/game-engine/__tests__/game-engine.test.ts
··· 1 + /** 2 + * Game Engine — Generated Tests 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Tests module structure, server health, and Phoenix traceability. 6 + */ 7 + 8 + import { describe, it, expect, afterAll } from 'vitest'; 9 + import { startServer } from '../server.js'; 10 + 11 + import * as boardState from '../board-state.js'; 12 + import * as gameLifecycle from '../game-lifecycle.js'; 13 + import * as moveValidation from '../move-validation.js'; 14 + import * as winDetection from '../win-detection.js'; 15 + 16 + describe('Game Engine modules', () => { 17 + describe('Board State', () => { 18 + it('exports Phoenix traceability metadata', () => { 19 + expect(boardState._phoenix).toBeDefined(); 20 + expect(boardState._phoenix.name).toBe('Board State'); 21 + expect(boardState._phoenix.risk_tier).toBeTruthy(); 22 + }); 23 + 24 + it('has exported functions', () => { 25 + const exports = Object.keys(boardState).filter(k => k !== '_phoenix'); 26 + expect(exports.length).toBeGreaterThan(0); 27 + }); 28 + }); 29 + 30 + describe('Game Lifecycle', () => { 31 + it('exports Phoenix traceability metadata', () => { 32 + expect(gameLifecycle._phoenix).toBeDefined(); 33 + expect(gameLifecycle._phoenix.name).toBe('Game Lifecycle'); 34 + expect(gameLifecycle._phoenix.risk_tier).toBeTruthy(); 35 + }); 36 + 37 + it('has exported functions', () => { 38 + const exports = Object.keys(gameLifecycle).filter(k => k !== '_phoenix'); 39 + expect(exports.length).toBeGreaterThan(0); 40 + }); 41 + }); 42 + 43 + describe('Move Validation', () => { 44 + it('exports Phoenix traceability metadata', () => { 45 + expect(moveValidation._phoenix).toBeDefined(); 46 + expect(moveValidation._phoenix.name).toBe('Move Validation'); 47 + expect(moveValidation._phoenix.risk_tier).toBeTruthy(); 48 + }); 49 + 50 + it('has exported functions', () => { 51 + const exports = Object.keys(moveValidation).filter(k => k !== '_phoenix'); 52 + expect(exports.length).toBeGreaterThan(0); 53 + }); 54 + }); 55 + 56 + describe('Win Detection', () => { 57 + it('exports Phoenix traceability metadata', () => { 58 + expect(winDetection._phoenix).toBeDefined(); 59 + expect(winDetection._phoenix.name).toBe('Win Detection'); 60 + expect(winDetection._phoenix.risk_tier).toBeTruthy(); 61 + }); 62 + 63 + it('has exported functions', () => { 64 + const exports = Object.keys(winDetection).filter(k => k !== '_phoenix'); 65 + expect(exports.length).toBeGreaterThan(0); 66 + }); 67 + }); 68 + 69 + }); 70 + 71 + describe('Game Engine server', () => { 72 + const instance = startServer(0); // random port 73 + 74 + afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve()))); 75 + 76 + it('GET /health returns 200', async () => { 77 + await instance.ready; 78 + const res = await fetch(`http://localhost:${instance.port}/health`); 79 + expect(res.status).toBe(200); 80 + const body = await res.json() as Record<string, unknown>; 81 + expect(body.status).toBe('ok'); 82 + expect(body.service).toBe('Game Engine'); 83 + }); 84 + 85 + it('GET /metrics returns request counts', async () => { 86 + await instance.ready; 87 + const res = await fetch(`http://localhost:${instance.port}/metrics`); 88 + expect(res.status).toBe(200); 89 + const body = await res.json() as Record<string, unknown>; 90 + expect(typeof body.requests_total).toBe('number'); 91 + }); 92 + 93 + it('GET /modules lists all registered modules', async () => { 94 + await instance.ready; 95 + const res = await fetch(`http://localhost:${instance.port}/modules`); 96 + expect(res.status).toBe(200); 97 + const body = await res.json() as Array<Record<string, unknown>>; 98 + expect(body.length).toBe(4); 99 + }); 100 + 101 + it('GET /unknown returns 404', async () => { 102 + await instance.ready; 103 + const res = await fetch(`http://localhost:${instance.port}/unknown`); 104 + expect(res.status).toBe(404); 105 + }); 106 + });
+134
examples/tictactoe/src/generated/game-engine/board-state.ts
··· 1 + export type CellState = 'empty' | 'x' | 'o'; 2 + 3 + export type BoardPosition = { 4 + row: number; 5 + col: number; 6 + }; 7 + 8 + export type BoardGrid = CellState[][]; 9 + 10 + export class BoardState { 11 + private grid: BoardGrid; 12 + 13 + constructor() { 14 + this.grid = this.createEmptyGrid(); 15 + } 16 + 17 + private createEmptyGrid(): BoardGrid { 18 + return Array.from({ length: 3 }, () => 19 + Array.from({ length: 3 }, () => 'empty' as CellState) 20 + ); 21 + } 22 + 23 + public getCell(position: BoardPosition): CellState { 24 + this.validatePosition(position); 25 + return this.grid[position.row][position.col]; 26 + } 27 + 28 + public setCell(position: BoardPosition, state: CellState): void { 29 + this.validatePosition(position); 30 + this.grid[position.row][position.col] = state; 31 + } 32 + 33 + public getGrid(): BoardGrid { 34 + return this.grid.map(row => [...row]); 35 + } 36 + 37 + public reset(): void { 38 + this.grid = this.createEmptyGrid(); 39 + } 40 + 41 + public serialize(): string { 42 + return this.grid 43 + .flat() 44 + .map(cell => { 45 + switch (cell) { 46 + case 'empty': return '-'; 47 + case 'x': return 'x'; 48 + case 'o': return 'o'; 49 + default: return '-'; 50 + } 51 + }) 52 + .join(''); 53 + } 54 + 55 + public deserialize(serialized: string): void { 56 + if (serialized.length !== 9) { 57 + throw new Error('Serialized board must be exactly 9 characters'); 58 + } 59 + 60 + const validChars = /^[xo\-]+$/; 61 + if (!validChars.test(serialized)) { 62 + throw new Error('Serialized board contains invalid characters. Only x, o, and - are allowed'); 63 + } 64 + 65 + this.grid = []; 66 + for (let row = 0; row < 3; row++) { 67 + this.grid[row] = []; 68 + for (let col = 0; col < 3; col++) { 69 + const index = row * 3 + col; 70 + const char = serialized[index]; 71 + switch (char) { 72 + case 'x': 73 + this.grid[row][col] = 'x'; 74 + break; 75 + case 'o': 76 + this.grid[row][col] = 'o'; 77 + break; 78 + case '-': 79 + this.grid[row][col] = 'empty'; 80 + break; 81 + default: 82 + this.grid[row][col] = 'empty'; 83 + } 84 + } 85 + } 86 + } 87 + 88 + public isEmpty(position: BoardPosition): boolean { 89 + return this.getCell(position) === 'empty'; 90 + } 91 + 92 + public getAllEmptyPositions(): BoardPosition[] { 93 + const positions: BoardPosition[] = []; 94 + for (let row = 0; row < 3; row++) { 95 + for (let col = 0; col < 3; col++) { 96 + if (this.grid[row][col] === 'empty') { 97 + positions.push({ row, col }); 98 + } 99 + } 100 + } 101 + return positions; 102 + } 103 + 104 + public isFull(): boolean { 105 + return this.getAllEmptyPositions().length === 0; 106 + } 107 + 108 + private validatePosition(position: BoardPosition): void { 109 + if (!Number.isInteger(position.row) || position.row < 0 || position.row > 2) { 110 + throw new Error('Row must be an integer between 0 and 2'); 111 + } 112 + if (!Number.isInteger(position.col) || position.col < 0 || position.col > 2) { 113 + throw new Error('Column must be an integer between 0 and 2'); 114 + } 115 + } 116 + } 117 + 118 + export function createBoard(): BoardState { 119 + return new BoardState(); 120 + } 121 + 122 + export function createBoardFromSerialized(serialized: string): BoardState { 123 + const board = new BoardState(); 124 + board.deserialize(serialized); 125 + return board; 126 + } 127 + 128 + /** @internal Phoenix VCS traceability — do not remove. */ 129 + export const _phoenix = { 130 + iu_id: 'bbb5317919f07fbf3ffc3f6a499be434ed4cd8368e12cf4a433bb8c7246c3520', 131 + name: 'Board State', 132 + risk_tier: 'medium', 133 + canon_ids: [4 as const], 134 + } as const;
+135
examples/tictactoe/src/generated/game-engine/game-lifecycle.ts
··· 1 + export type Player = 'x' | 'o'; 2 + 3 + export type GameStatus = 'waiting' | 'in-progress' | 'x-wins' | 'o-wins' | 'draw'; 4 + 5 + export interface Move { 6 + player: Player; 7 + position: number; 8 + timestamp: Date; 9 + } 10 + 11 + export interface GameState { 12 + id: string; 13 + currentPlayer: Player; 14 + status: GameStatus; 15 + moves: Move[]; 16 + createdAt: Date; 17 + } 18 + 19 + export class GameLifecycle { 20 + private games = new Map<string, GameState>(); 21 + 22 + createGame(gameId: string): GameState { 23 + if (this.games.has(gameId)) { 24 + throw new Error(`Game with id ${gameId} already exists`); 25 + } 26 + 27 + const game: GameState = { 28 + id: gameId, 29 + currentPlayer: 'x', 30 + status: 'waiting', 31 + moves: [], 32 + createdAt: new Date(), 33 + }; 34 + 35 + this.games.set(gameId, game); 36 + return { ...game }; 37 + } 38 + 39 + getGame(gameId: string): GameState | null { 40 + const game = this.games.get(gameId); 41 + return game ? { ...game, moves: [...game.moves] } : null; 42 + } 43 + 44 + startGame(gameId: string): GameState { 45 + const game = this.games.get(gameId); 46 + if (!game) { 47 + throw new Error(`Game ${gameId} not found`); 48 + } 49 + 50 + if (game.status !== 'waiting') { 51 + throw new Error(`Game ${gameId} cannot be started from status: ${game.status}`); 52 + } 53 + 54 + game.status = 'in-progress'; 55 + return { ...game, moves: [...game.moves] }; 56 + } 57 + 58 + makeMove(gameId: string, position: number): GameState { 59 + const game = this.games.get(gameId); 60 + if (!game) { 61 + throw new Error(`Game ${gameId} not found`); 62 + } 63 + 64 + if (game.status !== 'in-progress') { 65 + throw new Error(`Cannot make move on game with status: ${game.status}`); 66 + } 67 + 68 + const move: Move = { 69 + player: game.currentPlayer, 70 + position, 71 + timestamp: new Date(), 72 + }; 73 + 74 + game.moves.push(move); 75 + game.currentPlayer = game.currentPlayer === 'x' ? 'o' : 'x'; 76 + 77 + return { ...game, moves: [...game.moves] }; 78 + } 79 + 80 + setGameStatus(gameId: string, status: GameStatus): GameState { 81 + const game = this.games.get(gameId); 82 + if (!game) { 83 + throw new Error(`Game ${gameId} not found`); 84 + } 85 + 86 + const completedStatuses: GameStatus[] = ['x-wins', 'o-wins', 'draw']; 87 + if (completedStatuses.includes(game.status)) { 88 + throw new Error(`Cannot change status of completed game ${gameId}`); 89 + } 90 + 91 + game.status = status; 92 + return { ...game, moves: [...game.moves] }; 93 + } 94 + 95 + getCurrentPlayer(gameId: string): Player | null { 96 + const game = this.games.get(gameId); 97 + return game ? game.currentPlayer : null; 98 + } 99 + 100 + getGameHistory(gameId: string): Move[] { 101 + const game = this.games.get(gameId); 102 + return game ? [...game.moves] : []; 103 + } 104 + 105 + isGameCompleted(gameId: string): boolean { 106 + const game = this.games.get(gameId); 107 + if (!game) return false; 108 + 109 + const completedStatuses: GameStatus[] = ['x-wins', 'o-wins', 'draw']; 110 + return completedStatuses.includes(game.status); 111 + } 112 + 113 + deleteGame(gameId: string): boolean { 114 + return this.games.delete(gameId); 115 + } 116 + 117 + getAllGames(): GameState[] { 118 + return Array.from(this.games.values()).map(game => ({ 119 + ...game, 120 + moves: [...game.moves], 121 + })); 122 + } 123 + } 124 + 125 + export function createGameLifecycle(): GameLifecycle { 126 + return new GameLifecycle(); 127 + } 128 + 129 + /** @internal Phoenix VCS traceability — do not remove. */ 130 + export const _phoenix = { 131 + iu_id: '7ebacd0c987e496ccede9d5bcfafb011fa84d264a6b9a68b354bf37c51be93b1', 132 + name: 'Game Lifecycle', 133 + risk_tier: 'high', 134 + canon_ids: [5 as const], 135 + } as const;
+11
examples/tictactoe/src/generated/game-engine/index.ts
··· 1 + /** 2 + * Game Engine 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Barrel export for all Game Engine modules. 6 + */ 7 + 8 + export * as boardState from './board-state.js'; 9 + export * as gameLifecycle from './game-lifecycle.js'; 10 + export * as moveValidation from './move-validation.js'; 11 + export * as winDetection from './win-detection.js';
+69
examples/tictactoe/src/generated/game-engine/move-validation.ts
··· 1 + export type Player = 'x' | 'o'; 2 + 3 + export type GameState = 'playing' | 'x_wins' | 'o_wins' | 'draw'; 4 + 5 + export interface Move { 6 + row: number; 7 + column: number; 8 + player: Player; 9 + } 10 + 11 + export interface GameBoard { 12 + cells: (Player | null)[][]; 13 + currentPlayer: Player; 14 + state: GameState; 15 + } 16 + 17 + export class MoveValidationError extends Error { 18 + constructor(message: string) { 19 + super(message); 20 + this.name = 'MoveValidationError'; 21 + } 22 + } 23 + 24 + export function validateMove(move: Move, board: GameBoard): void { 25 + // Validate row and column bounds 26 + if (move.row < 0 || move.row > 2) { 27 + throw new MoveValidationError(`Invalid row: ${move.row}. Row must be between 0 and 2.`); 28 + } 29 + 30 + if (move.column < 0 || move.column > 2) { 31 + throw new MoveValidationError(`Invalid column: ${move.column}. Column must be between 0 and 2.`); 32 + } 33 + 34 + // Check if game is already over 35 + if (board.state !== 'playing') { 36 + throw new MoveValidationError(`Game is already over. Current state: ${board.state}`); 37 + } 38 + 39 + // Check if it's the current player's turn 40 + if (move.player !== board.currentPlayer) { 41 + throw new MoveValidationError(`It is not ${move.player}'s turn. Current player: ${board.currentPlayer}`); 42 + } 43 + 44 + // Check if cell is already occupied 45 + if (board.cells[move.row][move.column] !== null) { 46 + throw new MoveValidationError(`Cell at row ${move.row}, column ${move.column} is already occupied by ${board.cells[move.row][move.column]}`); 47 + } 48 + } 49 + 50 + export function isValidMove(move: Move, board: GameBoard): boolean { 51 + try { 52 + validateMove(move, board); 53 + return true; 54 + } catch (error) { 55 + return false; 56 + } 57 + } 58 + 59 + export function createMove(row: number, column: number, player: Player): Move { 60 + return { row, column, player }; 61 + } 62 + 63 + /** @internal Phoenix VCS traceability — do not remove. */ 64 + export const _phoenix = { 65 + iu_id: '286e471a39df056d93ab473eb1d6d370f3e746e37fd128884f2fc81c0752089a', 66 + name: 'Move Validation', 67 + risk_tier: 'high', 68 + canon_ids: [5 as const], 69 + } as const;
+125
examples/tictactoe/src/generated/game-engine/server.ts
··· 1 + /** 2 + * Game Engine — HTTP Server 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Provides health check, metrics, and module endpoints. 6 + */ 7 + 8 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 9 + 10 + import * as boardState from './board-state.js'; 11 + import * as gameLifecycle from './game-lifecycle.js'; 12 + import * as moveValidation from './move-validation.js'; 13 + import * as winDetection from './win-detection.js'; 14 + 15 + // ─── Metrics ───────────────────────────────────────────────────────────────── 16 + 17 + const metrics = { 18 + requests_total: 0, 19 + requests_by_path: {} as Record<string, number>, 20 + errors_total: 0, 21 + uptime_start: Date.now(), 22 + }; 23 + 24 + // ─── Module Registry ───────────────────────────────────────────────────────── 25 + 26 + const modules = { 27 + 'board-state': boardState, 28 + 'game-lifecycle': gameLifecycle, 29 + 'move-validation': moveValidation, 30 + 'win-detection': winDetection, 31 + }; 32 + 33 + // ─── Router ────────────────────────────────────────────────────────────────── 34 + 35 + type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>; 36 + 37 + const routes: Record<string, Handler> = { 38 + '/health': (_req, res) => { 39 + res.writeHead(200, { 'Content-Type': 'application/json' }); 40 + res.end(JSON.stringify({ 41 + status: 'ok', 42 + service: 'Game Engine', 43 + uptime: Math.floor((Date.now() - metrics.uptime_start) / 1000), 44 + modules: Object.keys(modules), 45 + })); 46 + }, 47 + 48 + '/metrics': (_req, res) => { 49 + res.writeHead(200, { 'Content-Type': 'application/json' }); 50 + res.end(JSON.stringify({ 51 + ...metrics, 52 + uptime_seconds: Math.floor((Date.now() - metrics.uptime_start) / 1000), 53 + }, null, 2)); 54 + }, 55 + 56 + '/modules': (_req, res) => { 57 + const info = Object.entries(modules).map(([name, mod]) => { 58 + const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined; 59 + return { 60 + name, 61 + risk_tier: phoenix?.risk_tier ?? 'unknown', 62 + exports: Object.keys(mod).filter(k => k !== '_phoenix'), 63 + }; 64 + }); 65 + res.writeHead(200, { 'Content-Type': 'application/json' }); 66 + res.end(JSON.stringify(info, null, 2)); 67 + }, 68 + }; 69 + 70 + // ─── Server ────────────────────────────────────────────────────────────────── 71 + 72 + function handleRequest(req: IncomingMessage, res: ServerResponse): void { 73 + const url = req.url ?? '/'; 74 + const path = url.split('?')[0]; 75 + 76 + metrics.requests_total++; 77 + metrics.requests_by_path[path] = (metrics.requests_by_path[path] ?? 0) + 1; 78 + 79 + const handler = routes[path]; 80 + if (handler) { 81 + try { 82 + handler(req, res); 83 + } catch (err) { 84 + metrics.errors_total++; 85 + res.writeHead(500, { 'Content-Type': 'application/json' }); 86 + res.end(JSON.stringify({ error: String(err) })); 87 + } 88 + } else { 89 + res.writeHead(404, { 'Content-Type': 'application/json' }); 90 + res.end(JSON.stringify({ 91 + error: 'Not Found', 92 + path, 93 + available: Object.keys(routes), 94 + })); 95 + } 96 + } 97 + 98 + export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } { 99 + const requestedPort = port ?? parseInt(process.env.GAME_ENGINE_PORT ?? process.env.PORT ?? '3000', 10); 100 + const server = createServer(handleRequest); 101 + let actualPort = requestedPort; 102 + 103 + const ready = new Promise<void>(resolve => { 104 + server.listen(requestedPort, () => { 105 + const addr = server.address(); 106 + if (addr && typeof addr === 'object') actualPort = addr.port; 107 + result.port = actualPort; 108 + console.log(`Game Engine listening on http://localhost:${actualPort}`); 109 + console.log(` /health — health check`); 110 + console.log(` /metrics — request metrics`); 111 + console.log(` /modules — registered modules`); 112 + resolve(); 113 + }); 114 + }); 115 + 116 + const result = { server, port: actualPort, ready }; 117 + return result; 118 + } 119 + 120 + // Start when run directly 121 + const isMain = process.argv[1]?.endsWith('/game-engine/server.js') || 122 + process.argv[1]?.endsWith('/game-engine/server.ts'); 123 + if (isMain) { 124 + startServer(); 125 + }
+78
examples/tictactoe/src/generated/game-engine/win-detection.ts
··· 1 + export type Player = 'X' | 'O'; 2 + export type Cell = Player | null; 3 + export type Board = Cell[]; 4 + 5 + export interface GameResult { 6 + winner: Player | null; 7 + isDraw: boolean; 8 + isGameOver: boolean; 9 + winningLine?: number[]; 10 + } 11 + 12 + const WINNING_COMBINATIONS = [ 13 + // Rows 14 + [0, 1, 2], 15 + [3, 4, 5], 16 + [6, 7, 8], 17 + // Columns 18 + [0, 3, 6], 19 + [1, 4, 7], 20 + [2, 5, 8], 21 + // Diagonals 22 + [0, 4, 8], 23 + [2, 4, 6] 24 + ]; 25 + 26 + export function checkWinner(board: Board): Player | null { 27 + for (const combination of WINNING_COMBINATIONS) { 28 + const [a, b, c] = combination; 29 + if (board[a] && board[a] === board[b] && board[a] === board[c]) { 30 + return board[a] as Player; 31 + } 32 + } 33 + return null; 34 + } 35 + 36 + export function getWinningLine(board: Board): number[] | undefined { 37 + for (const combination of WINNING_COMBINATIONS) { 38 + const [a, b, c] = combination; 39 + if (board[a] && board[a] === board[b] && board[a] === board[c]) { 40 + return combination; 41 + } 42 + } 43 + return undefined; 44 + } 45 + 46 + export function isDraw(board: Board): boolean { 47 + const winner = checkWinner(board); 48 + if (winner) { 49 + return false; 50 + } 51 + return board.every(cell => cell !== null); 52 + } 53 + 54 + export function isGameOver(board: Board): boolean { 55 + return checkWinner(board) !== null || isDraw(board); 56 + } 57 + 58 + export function detectWin(board: Board): GameResult { 59 + const winner = checkWinner(board); 60 + const gameIsDraw = isDraw(board); 61 + const gameIsOver = isGameOver(board); 62 + const winningLine = getWinningLine(board); 63 + 64 + return { 65 + winner, 66 + isDraw: gameIsDraw, 67 + isGameOver: gameIsOver, 68 + winningLine 69 + }; 70 + } 71 + 72 + /** @internal Phoenix VCS traceability — do not remove. */ 73 + export const _phoenix = { 74 + iu_id: '92e6ffaf7c4681fb8fd36853cd932a1e314b8feffd7cbc998c0f5a67d0ae842c', 75 + name: 'Win Detection', 76 + risk_tier: 'low', 77 + canon_ids: [2 as const], 78 + } as const;
+15
examples/tictactoe/src/generated/index.ts
··· 1 + /** 2 + * Phoenix VCS — Generated Service Registry 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + */ 6 + 7 + export * as gameEngine from './game-engine/index.js'; 8 + export * as multiplayer from './multiplayer/index.js'; 9 + export * as webClient from './web-client/index.js'; 10 + 11 + export const services = [ 12 + { name: 'Game Engine', dir: 'game-engine', port: 3000, modules: 4 }, 13 + { name: 'Multiplayer', dir: 'multiplayer', port: 3001, modules: 4 }, 14 + { name: 'Web Client', dir: 'web-client', port: 3002, modules: 4 }, 15 + ] as const;
+106
examples/tictactoe/src/generated/multiplayer/__tests__/multiplayer.test.ts
··· 1 + /** 2 + * Multiplayer — Generated Tests 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Tests module structure, server health, and Phoenix traceability. 6 + */ 7 + 8 + import { describe, it, expect, afterAll } from 'vitest'; 9 + import { startServer } from '../server.js'; 10 + 11 + import * as gameRooms from '../game-rooms.js'; 12 + import * as matchmaking from '../matchmaking.js'; 13 + import * as playerManagement from '../player-management.js'; 14 + import * as realTimeCommunication from '../real-time-communication.js'; 15 + 16 + describe('Multiplayer modules', () => { 17 + describe('Game Rooms', () => { 18 + it('exports Phoenix traceability metadata', () => { 19 + expect(gameRooms._phoenix).toBeDefined(); 20 + expect(gameRooms._phoenix.name).toBe('Game Rooms'); 21 + expect(gameRooms._phoenix.risk_tier).toBeTruthy(); 22 + }); 23 + 24 + it('has exported functions', () => { 25 + const exports = Object.keys(gameRooms).filter(k => k !== '_phoenix'); 26 + expect(exports.length).toBeGreaterThan(0); 27 + }); 28 + }); 29 + 30 + describe('Matchmaking', () => { 31 + it('exports Phoenix traceability metadata', () => { 32 + expect(matchmaking._phoenix).toBeDefined(); 33 + expect(matchmaking._phoenix.name).toBe('Matchmaking'); 34 + expect(matchmaking._phoenix.risk_tier).toBeTruthy(); 35 + }); 36 + 37 + it('has exported functions', () => { 38 + const exports = Object.keys(matchmaking).filter(k => k !== '_phoenix'); 39 + expect(exports.length).toBeGreaterThan(0); 40 + }); 41 + }); 42 + 43 + describe('Player Management', () => { 44 + it('exports Phoenix traceability metadata', () => { 45 + expect(playerManagement._phoenix).toBeDefined(); 46 + expect(playerManagement._phoenix.name).toBe('Player Management'); 47 + expect(playerManagement._phoenix.risk_tier).toBeTruthy(); 48 + }); 49 + 50 + it('has exported functions', () => { 51 + const exports = Object.keys(playerManagement).filter(k => k !== '_phoenix'); 52 + expect(exports.length).toBeGreaterThan(0); 53 + }); 54 + }); 55 + 56 + describe('Real-Time Communication', () => { 57 + it('exports Phoenix traceability metadata', () => { 58 + expect(realTimeCommunication._phoenix).toBeDefined(); 59 + expect(realTimeCommunication._phoenix.name).toBe('Real-Time Communication'); 60 + expect(realTimeCommunication._phoenix.risk_tier).toBeTruthy(); 61 + }); 62 + 63 + it('has exported functions', () => { 64 + const exports = Object.keys(realTimeCommunication).filter(k => k !== '_phoenix'); 65 + expect(exports.length).toBeGreaterThan(0); 66 + }); 67 + }); 68 + 69 + }); 70 + 71 + describe('Multiplayer server', () => { 72 + const instance = startServer(0); // random port 73 + 74 + afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve()))); 75 + 76 + it('GET /health returns 200', async () => { 77 + await instance.ready; 78 + const res = await fetch(`http://localhost:${instance.port}/health`); 79 + expect(res.status).toBe(200); 80 + const body = await res.json() as Record<string, unknown>; 81 + expect(body.status).toBe('ok'); 82 + expect(body.service).toBe('Multiplayer'); 83 + }); 84 + 85 + it('GET /metrics returns request counts', async () => { 86 + await instance.ready; 87 + const res = await fetch(`http://localhost:${instance.port}/metrics`); 88 + expect(res.status).toBe(200); 89 + const body = await res.json() as Record<string, unknown>; 90 + expect(typeof body.requests_total).toBe('number'); 91 + }); 92 + 93 + it('GET /modules lists all registered modules', async () => { 94 + await instance.ready; 95 + const res = await fetch(`http://localhost:${instance.port}/modules`); 96 + expect(res.status).toBe(200); 97 + const body = await res.json() as Array<Record<string, unknown>>; 98 + expect(body.length).toBe(4); 99 + }); 100 + 101 + it('GET /unknown returns 404', async () => { 102 + await instance.ready; 103 + const res = await fetch(`http://localhost:${instance.port}/unknown`); 104 + expect(res.status).toBe(404); 105 + }); 106 + });
+299
examples/tictactoe/src/generated/multiplayer/game-rooms.ts
··· 1 + import { EventEmitter } from 'node:events'; 2 + 3 + export interface GameRoom { 4 + id: string; 5 + gameId: string; 6 + players: Set<string>; 7 + createdAt: Date; 8 + lastActivity: Date; 9 + } 10 + 11 + export interface RoomMessage { 12 + type: 'move' | 'join' | 'game-over' | 'player-disconnected' | 'player-reconnected'; 13 + roomId: string; 14 + playerId: string; 15 + timestamp: Date; 16 + data?: any; 17 + } 18 + 19 + export interface MoveMessage extends RoomMessage { 20 + type: 'move'; 21 + data: { 22 + move: string; 23 + gameState?: any; 24 + }; 25 + } 26 + 27 + export interface JoinMessage extends RoomMessage { 28 + type: 'join'; 29 + data: { 30 + playerName: string; 31 + playerCount: number; 32 + }; 33 + } 34 + 35 + export interface GameOverMessage extends RoomMessage { 36 + type: 'game-over'; 37 + data: { 38 + winner?: string; 39 + reason: string; 40 + finalState?: any; 41 + }; 42 + } 43 + 44 + export interface PlayerDisconnectedMessage extends RoomMessage { 45 + type: 'player-disconnected'; 46 + data: { 47 + playerName: string; 48 + remainingPlayers: number; 49 + }; 50 + } 51 + 52 + export interface PlayerReconnectedMessage extends RoomMessage { 53 + type: 'player-reconnected'; 54 + data: { 55 + playerName: string; 56 + currentPlayers: number; 57 + }; 58 + } 59 + 60 + export type TypedRoomMessage = MoveMessage | JoinMessage | GameOverMessage | PlayerDisconnectedMessage | PlayerReconnectedMessage; 61 + 62 + export class GameRoomManager extends EventEmitter { 63 + private rooms = new Map<string, GameRoom>(); 64 + private playerRoomMap = new Map<string, string>(); 65 + 66 + createRoom(gameId: string): GameRoom { 67 + const roomId = `room_${gameId}_${Date.now()}`; 68 + const room: GameRoom = { 69 + id: roomId, 70 + gameId, 71 + players: new Set(), 72 + createdAt: new Date(), 73 + lastActivity: new Date() 74 + }; 75 + 76 + this.rooms.set(roomId, room); 77 + this.emit('room-created', room); 78 + return room; 79 + } 80 + 81 + joinRoom(roomId: string, playerId: string, playerName: string): boolean { 82 + const room = this.rooms.get(roomId); 83 + if (!room) { 84 + return false; 85 + } 86 + 87 + // Remove player from any existing room 88 + this.leaveCurrentRoom(playerId); 89 + 90 + room.players.add(playerId); 91 + room.lastActivity = new Date(); 92 + this.playerRoomMap.set(playerId, roomId); 93 + 94 + const message: JoinMessage = { 95 + type: 'join', 96 + roomId, 97 + playerId, 98 + timestamp: new Date(), 99 + data: { 100 + playerName, 101 + playerCount: room.players.size 102 + } 103 + }; 104 + 105 + this.broadcastToRoom(roomId, message); 106 + return true; 107 + } 108 + 109 + leaveRoom(roomId: string, playerId: string): boolean { 110 + const room = this.rooms.get(roomId); 111 + if (!room || !room.players.has(playerId)) { 112 + return false; 113 + } 114 + 115 + room.players.delete(playerId); 116 + room.lastActivity = new Date(); 117 + this.playerRoomMap.delete(playerId); 118 + 119 + // Clean up empty rooms 120 + if (room.players.size === 0) { 121 + this.rooms.delete(roomId); 122 + this.emit('room-destroyed', room); 123 + } 124 + 125 + return true; 126 + } 127 + 128 + sendMove(roomId: string, playerId: string, move: string, gameState?: any): boolean { 129 + const room = this.rooms.get(roomId); 130 + if (!room || !room.players.has(playerId)) { 131 + return false; 132 + } 133 + 134 + room.lastActivity = new Date(); 135 + 136 + const message: MoveMessage = { 137 + type: 'move', 138 + roomId, 139 + playerId, 140 + timestamp: new Date(), 141 + data: { 142 + move, 143 + gameState 144 + } 145 + }; 146 + 147 + this.broadcastToRoom(roomId, message); 148 + return true; 149 + } 150 + 151 + endGame(roomId: string, winner?: string, reason: string = 'game completed', finalState?: any): boolean { 152 + const room = this.rooms.get(roomId); 153 + if (!room) { 154 + return false; 155 + } 156 + 157 + room.lastActivity = new Date(); 158 + 159 + const message: GameOverMessage = { 160 + type: 'game-over', 161 + roomId, 162 + playerId: winner || '', 163 + timestamp: new Date(), 164 + data: { 165 + winner, 166 + reason, 167 + finalState 168 + } 169 + }; 170 + 171 + this.broadcastToRoom(roomId, message); 172 + return true; 173 + } 174 + 175 + handlePlayerDisconnection(playerId: string, playerName: string): void { 176 + const roomId = this.playerRoomMap.get(playerId); 177 + if (!roomId) { 178 + return; 179 + } 180 + 181 + const room = this.rooms.get(roomId); 182 + if (!room) { 183 + return; 184 + } 185 + 186 + room.lastActivity = new Date(); 187 + 188 + const message: PlayerDisconnectedMessage = { 189 + type: 'player-disconnected', 190 + roomId, 191 + playerId, 192 + timestamp: new Date(), 193 + data: { 194 + playerName, 195 + remainingPlayers: room.players.size - 1 196 + } 197 + }; 198 + 199 + this.broadcastToRoom(roomId, message); 200 + } 201 + 202 + handlePlayerReconnection(playerId: string, playerName: string): void { 203 + const roomId = this.playerRoomMap.get(playerId); 204 + if (!roomId) { 205 + return; 206 + } 207 + 208 + const room = this.rooms.get(roomId); 209 + if (!room) { 210 + return; 211 + } 212 + 213 + room.lastActivity = new Date(); 214 + 215 + const message: PlayerReconnectedMessage = { 216 + type: 'player-reconnected', 217 + roomId, 218 + playerId, 219 + timestamp: new Date(), 220 + data: { 221 + playerName, 222 + currentPlayers: room.players.size 223 + } 224 + }; 225 + 226 + this.broadcastToRoom(roomId, message); 227 + } 228 + 229 + getRoom(roomId: string): GameRoom | undefined { 230 + return this.rooms.get(roomId); 231 + } 232 + 233 + getPlayerRoom(playerId: string): GameRoom | undefined { 234 + const roomId = this.playerRoomMap.get(playerId); 235 + return roomId ? this.rooms.get(roomId) : undefined; 236 + } 237 + 238 + getRoomsByGame(gameId: string): GameRoom[] { 239 + return Array.from(this.rooms.values()).filter(room => room.gameId === gameId); 240 + } 241 + 242 + getAllRooms(): GameRoom[] { 243 + return Array.from(this.rooms.values()); 244 + } 245 + 246 + private leaveCurrentRoom(playerId: string): void { 247 + const currentRoomId = this.playerRoomMap.get(playerId); 248 + if (currentRoomId) { 249 + this.leaveRoom(currentRoomId, playerId); 250 + } 251 + } 252 + 253 + private broadcastToRoom(roomId: string, message: TypedRoomMessage): void { 254 + const room = this.rooms.get(roomId); 255 + if (!room) { 256 + return; 257 + } 258 + 259 + // Emit to all players in the room 260 + for (const playerId of room.players) { 261 + this.emit('message', playerId, message); 262 + } 263 + 264 + // Also emit a general room message event 265 + this.emit('room-message', roomId, message); 266 + } 267 + 268 + cleanupInactiveRooms(maxInactiveMinutes: number = 30): number { 269 + const cutoffTime = new Date(Date.now() - maxInactiveMinutes * 60 * 1000); 270 + let cleanedCount = 0; 271 + 272 + for (const [roomId, room] of this.rooms.entries()) { 273 + if (room.lastActivity < cutoffTime) { 274 + // Remove all players from the room 275 + for (const playerId of room.players) { 276 + this.playerRoomMap.delete(playerId); 277 + } 278 + 279 + this.rooms.delete(roomId); 280 + this.emit('room-destroyed', room); 281 + cleanedCount++; 282 + } 283 + } 284 + 285 + return cleanedCount; 286 + } 287 + } 288 + 289 + export function createGameRoomManager(): GameRoomManager { 290 + return new GameRoomManager(); 291 + } 292 + 293 + /** @internal Phoenix VCS traceability — do not remove. */ 294 + export const _phoenix = { 295 + iu_id: '5095bcec288e546dc75c443ad5837b0a0cd8770db1e2eb331d1d0ffc4eb4e00f', 296 + name: 'Game Rooms', 297 + risk_tier: 'low', 298 + canon_ids: [2 as const], 299 + } as const;
+11
examples/tictactoe/src/generated/multiplayer/index.ts
··· 1 + /** 2 + * Multiplayer 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Barrel export for all Multiplayer modules. 6 + */ 7 + 8 + export * as gameRooms from './game-rooms.js'; 9 + export * as matchmaking from './matchmaking.js'; 10 + export * as playerManagement from './player-management.js'; 11 + export * as realTimeCommunication from './real-time-communication.js';
+188
examples/tictactoe/src/generated/multiplayer/matchmaking.ts
··· 1 + import { EventEmitter } from 'node:events'; 2 + 3 + export interface GameSession { 4 + id: string; 5 + creatorId: string; 6 + joinerId?: string; 7 + status: 'waiting' | 'in-progress' | 'completed'; 8 + createdAt: Date; 9 + startedAt?: Date; 10 + } 11 + 12 + export interface Player { 13 + id: string; 14 + symbol: 'x' | 'o'; 15 + } 16 + 17 + export interface MatchmakingEvents { 18 + gameCreated: (session: GameSession) => void; 19 + playerJoined: (session: GameSession, joinerId: string) => void; 20 + gameStarted: (session: GameSession) => void; 21 + error: (error: Error) => void; 22 + } 23 + 24 + export class MatchmakingService extends EventEmitter { 25 + private sessions = new Map<string, GameSession>(); 26 + private playerSessions = new Map<string, string>(); 27 + 28 + constructor() { 29 + super(); 30 + } 31 + 32 + createGame(playerId: string): GameSession { 33 + if (this.playerSessions.has(playerId)) { 34 + throw new Error('Player is already in a game session'); 35 + } 36 + 37 + const gameId = this.generateGameId(); 38 + const session: GameSession = { 39 + id: gameId, 40 + creatorId: playerId, 41 + status: 'waiting', 42 + createdAt: new Date(), 43 + }; 44 + 45 + this.sessions.set(gameId, session); 46 + this.playerSessions.set(playerId, gameId); 47 + 48 + this.emit('gameCreated', session); 49 + return session; 50 + } 51 + 52 + joinGame(gameId: string, playerId: string): GameSession { 53 + if (this.playerSessions.has(playerId)) { 54 + throw new Error('Player is already in a game session'); 55 + } 56 + 57 + const session = this.sessions.get(gameId); 58 + if (!session) { 59 + throw new Error('Game session not found'); 60 + } 61 + 62 + if (session.status !== 'waiting') { 63 + throw new Error('Game is not available for joining'); 64 + } 65 + 66 + if (session.creatorId === playerId) { 67 + throw new Error('Cannot join your own game'); 68 + } 69 + 70 + if (session.joinerId) { 71 + throw new Error('Game is already full'); 72 + } 73 + 74 + session.joinerId = playerId; 75 + session.status = 'in-progress'; 76 + session.startedAt = new Date(); 77 + 78 + this.playerSessions.set(playerId, gameId); 79 + 80 + this.emit('playerJoined', session, playerId); 81 + this.emit('gameStarted', session); 82 + 83 + return session; 84 + } 85 + 86 + getWaitingGames(): GameSession[] { 87 + return Array.from(this.sessions.values()).filter( 88 + session => session.status === 'waiting' 89 + ); 90 + } 91 + 92 + getGameSession(gameId: string): GameSession | undefined { 93 + return this.sessions.get(gameId); 94 + } 95 + 96 + getPlayerSession(playerId: string): GameSession | undefined { 97 + const gameId = this.playerSessions.get(playerId); 98 + return gameId ? this.sessions.get(gameId) : undefined; 99 + } 100 + 101 + getPlayerRole(gameId: string, playerId: string): Player | null { 102 + const session = this.sessions.get(gameId); 103 + if (!session) { 104 + return null; 105 + } 106 + 107 + if (session.creatorId === playerId) { 108 + return { id: playerId, symbol: 'x' }; 109 + } 110 + 111 + if (session.joinerId === playerId) { 112 + return { id: playerId, symbol: 'o' }; 113 + } 114 + 115 + return null; 116 + } 117 + 118 + leaveGame(playerId: string): boolean { 119 + const gameId = this.playerSessions.get(playerId); 120 + if (!gameId) { 121 + return false; 122 + } 123 + 124 + const session = this.sessions.get(gameId); 125 + if (!session) { 126 + return false; 127 + } 128 + 129 + this.playerSessions.delete(playerId); 130 + 131 + if (session.creatorId === playerId) { 132 + // Creator left, remove the entire session 133 + if (session.joinerId) { 134 + this.playerSessions.delete(session.joinerId); 135 + } 136 + this.sessions.delete(gameId); 137 + } else if (session.joinerId === playerId) { 138 + // Joiner left, reset to waiting 139 + session.joinerId = undefined; 140 + session.status = 'waiting'; 141 + session.startedAt = undefined; 142 + } 143 + 144 + return true; 145 + } 146 + 147 + completeGame(gameId: string): boolean { 148 + const session = this.sessions.get(gameId); 149 + if (!session || session.status !== 'in-progress') { 150 + return false; 151 + } 152 + 153 + session.status = 'completed'; 154 + 155 + // Clean up player sessions 156 + this.playerSessions.delete(session.creatorId); 157 + if (session.joinerId) { 158 + this.playerSessions.delete(session.joinerId); 159 + } 160 + 161 + return true; 162 + } 163 + 164 + private generateGameId(): string { 165 + return Math.random().toString(36).substring(2, 15) + 166 + Math.random().toString(36).substring(2, 15); 167 + } 168 + 169 + getSessionCount(): number { 170 + return this.sessions.size; 171 + } 172 + 173 + getActivePlayerCount(): number { 174 + return this.playerSessions.size; 175 + } 176 + } 177 + 178 + export function createMatchmakingService(): MatchmakingService { 179 + return new MatchmakingService(); 180 + } 181 + 182 + /** @internal Phoenix VCS traceability — do not remove. */ 183 + export const _phoenix = { 184 + iu_id: 'd76ffd80fe3dc51ca9486800851150af95957393b39d14d714dd70299f502ea8', 185 + name: 'Matchmaking', 186 + risk_tier: 'high', 187 + canon_ids: [5 as const], 188 + } as const;
+140
examples/tictactoe/src/generated/multiplayer/player-management.ts
··· 1 + import { randomUUID } from 'node:crypto'; 2 + import { EventEmitter } from 'node:events'; 3 + 4 + export interface Player { 5 + readonly id: string; 6 + readonly displayName: string; 7 + readonly joinedAt: Date; 8 + } 9 + 10 + export interface PlayerJoinRequest { 11 + displayName: string; 12 + } 13 + 14 + export interface PlayerEvents { 15 + playerJoined: (player: Player) => void; 16 + playerLeft: (playerId: string) => void; 17 + displayNameChanged: (playerId: string, oldName: string, newName: string) => void; 18 + } 19 + 20 + export class PlayerManager extends EventEmitter { 21 + private players = new Map<string, Player>(); 22 + private displayNames = new Set<string>(); 23 + 24 + constructor() { 25 + super(); 26 + } 27 + 28 + addPlayer(request: PlayerJoinRequest): Player { 29 + const trimmedName = request.displayName.trim(); 30 + 31 + if (!trimmedName) { 32 + throw new Error('Display name cannot be empty'); 33 + } 34 + 35 + if (trimmedName.length > 50) { 36 + throw new Error('Display name cannot exceed 50 characters'); 37 + } 38 + 39 + if (this.displayNames.has(trimmedName.toLowerCase())) { 40 + throw new Error('Display name is already taken'); 41 + } 42 + 43 + const player: Player = { 44 + id: randomUUID(), 45 + displayName: trimmedName, 46 + joinedAt: new Date() 47 + }; 48 + 49 + this.players.set(player.id, player); 50 + this.displayNames.add(trimmedName.toLowerCase()); 51 + 52 + this.emit('playerJoined', player); 53 + return player; 54 + } 55 + 56 + removePlayer(playerId: string): boolean { 57 + const player = this.players.get(playerId); 58 + if (!player) { 59 + return false; 60 + } 61 + 62 + this.players.delete(playerId); 63 + this.displayNames.delete(player.displayName.toLowerCase()); 64 + 65 + this.emit('playerLeft', playerId); 66 + return true; 67 + } 68 + 69 + getPlayer(playerId: string): Player | undefined { 70 + return this.players.get(playerId); 71 + } 72 + 73 + getAllPlayers(): Player[] { 74 + return Array.from(this.players.values()); 75 + } 76 + 77 + getPlayerCount(): number { 78 + return this.players.size; 79 + } 80 + 81 + isDisplayNameTaken(displayName: string): boolean { 82 + return this.displayNames.has(displayName.trim().toLowerCase()); 83 + } 84 + 85 + updateDisplayName(playerId: string, newDisplayName: string): boolean { 86 + const player = this.players.get(playerId); 87 + if (!player) { 88 + throw new Error('Player not found'); 89 + } 90 + 91 + const trimmedName = newDisplayName.trim(); 92 + 93 + if (!trimmedName) { 94 + throw new Error('Display name cannot be empty'); 95 + } 96 + 97 + if (trimmedName.length > 50) { 98 + throw new Error('Display name cannot exceed 50 characters'); 99 + } 100 + 101 + if (trimmedName.toLowerCase() === player.displayName.toLowerCase()) { 102 + return false; // No change needed 103 + } 104 + 105 + if (this.displayNames.has(trimmedName.toLowerCase())) { 106 + throw new Error('Display name is already taken'); 107 + } 108 + 109 + const oldName = player.displayName; 110 + this.displayNames.delete(oldName.toLowerCase()); 111 + this.displayNames.add(trimmedName.toLowerCase()); 112 + 113 + const updatedPlayer: Player = { 114 + ...player, 115 + displayName: trimmedName 116 + }; 117 + 118 + this.players.set(playerId, updatedPlayer); 119 + this.emit('displayNameChanged', playerId, oldName, trimmedName); 120 + return true; 121 + } 122 + 123 + clear(): void { 124 + const playerIds = Array.from(this.players.keys()); 125 + this.players.clear(); 126 + this.displayNames.clear(); 127 + 128 + for (const playerId of playerIds) { 129 + this.emit('playerLeft', playerId); 130 + } 131 + } 132 + } 133 + 134 + /** @internal Phoenix VCS traceability — do not remove. */ 135 + export const _phoenix = { 136 + iu_id: 'c616dc939f58968258c300b75aa9cbfb7c949faff800410e4e409d7ecd794207', 137 + name: 'Player Management', 138 + risk_tier: 'low', 139 + canon_ids: [3 as const], 140 + } as const;
+389
examples/tictactoe/src/generated/multiplayer/real-time-communication.ts
··· 1 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 2 + import { EventEmitter } from 'node:events'; 3 + import { createHash } from 'node:crypto'; 4 + 5 + export interface GameMessage { 6 + type: 'move' | 'join' | 'end' | 'disconnect' | 'state'; 7 + gameId: string; 8 + playerId: string; 9 + data?: any; 10 + timestamp: number; 11 + } 12 + 13 + export interface PlayerConnection { 14 + playerId: string; 15 + gameId: string; 16 + ws: WebSocketConnection; 17 + lastPing: number; 18 + } 19 + 20 + export interface GameState { 21 + gameId: string; 22 + players: string[]; 23 + status: 'waiting' | 'active' | 'ended'; 24 + moves: any[]; 25 + result?: { 26 + winner?: string; 27 + reason: string; 28 + }; 29 + } 30 + 31 + interface WebSocketConnection { 32 + send(data: string): void; 33 + close(code?: number, reason?: string): void; 34 + readyState: number; 35 + ping(): void; 36 + on(event: string, listener: (...args: any[]) => void): void; 37 + } 38 + 39 + const WEBSOCKET_MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; 40 + 41 + export class RealTimeCommunication extends EventEmitter { 42 + private connections = new Map<string, PlayerConnection>(); 43 + private gameConnections = new Map<string, Set<string>>(); 44 + private server: ReturnType<typeof createServer>; 45 + private pingInterval: NodeJS.Timeout | null = null; 46 + 47 + constructor(port: number = 8080) { 48 + super(); 49 + this.server = createServer(); 50 + this.setupWebSocketServer(); 51 + this.server.listen(port); 52 + this.startPingMonitoring(); 53 + } 54 + 55 + private setupWebSocketServer(): void { 56 + this.server.on('upgrade', (request: IncomingMessage, socket: any, head: Buffer) => { 57 + const url = new URL(request.url || '', `http://${request.headers.host}`); 58 + const playerId = url.searchParams.get('playerId'); 59 + const gameId = url.searchParams.get('gameId'); 60 + 61 + if (!playerId || !gameId) { 62 + socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); 63 + return; 64 + } 65 + 66 + const key = request.headers['sec-websocket-key']; 67 + if (!key) { 68 + socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); 69 + return; 70 + } 71 + 72 + const acceptKey = createHash('sha1') 73 + .update(key + WEBSOCKET_MAGIC_STRING) 74 + .digest('base64'); 75 + 76 + const responseHeaders = [ 77 + 'HTTP/1.1 101 Switching Protocols', 78 + 'Upgrade: websocket', 79 + 'Connection: Upgrade', 80 + `Sec-WebSocket-Accept: ${acceptKey}`, 81 + '\r\n' 82 + ].join('\r\n'); 83 + 84 + socket.write(responseHeaders); 85 + 86 + const ws = this.createWebSocketConnection(socket); 87 + this.handleConnection(ws, playerId, gameId); 88 + }); 89 + } 90 + 91 + private createWebSocketConnection(socket: any): WebSocketConnection { 92 + const connection: WebSocketConnection = { 93 + send: (data: string) => { 94 + if (socket.writable) { 95 + const buffer = Buffer.from(data); 96 + const frame = Buffer.allocUnsafe(2 + buffer.length); 97 + frame[0] = 0x81; // FIN + text frame 98 + frame[1] = buffer.length; 99 + buffer.copy(frame, 2); 100 + socket.write(frame); 101 + } 102 + }, 103 + close: (code?: number, reason?: string) => { 104 + if (socket.writable) { 105 + socket.end(); 106 + } 107 + }, 108 + readyState: 1, 109 + ping: () => { 110 + if (socket.writable) { 111 + const frame = Buffer.from([0x89, 0x00]); // ping frame 112 + socket.write(frame); 113 + } 114 + }, 115 + on: (event: string, listener: (...args: any[]) => void) => { 116 + if (event === 'message') { 117 + socket.on('data', (data: Buffer) => { 118 + if (data.length >= 2) { 119 + const opcode = data[0] & 0x0f; 120 + if (opcode === 0x01) { // text frame 121 + const payloadLength = data[1] & 0x7f; 122 + const maskStart = 2; 123 + const dataStart = maskStart + 4; 124 + if (data.length >= dataStart + payloadLength) { 125 + const mask = data.slice(maskStart, dataStart); 126 + const payload = Buffer.allocUnsafe(payloadLength); 127 + for (let i = 0; i < payloadLength; i++) { 128 + payload[i] = data[dataStart + i] ^ mask[i % 4]; 129 + } 130 + listener(payload.toString()); 131 + } 132 + } else if (opcode === 0x0a) { // pong frame 133 + listener(); 134 + } 135 + } 136 + }); 137 + } else if (event === 'pong') { 138 + socket.on('data', (data: Buffer) => { 139 + if (data.length >= 1 && (data[0] & 0x0f) === 0x0a) { 140 + listener(); 141 + } 142 + }); 143 + } else if (event === 'close' || event === 'error') { 144 + socket.on(event, listener); 145 + } 146 + } 147 + }; 148 + 149 + return connection; 150 + } 151 + 152 + private handleConnection(ws: WebSocketConnection, playerId: string, gameId: string): void { 153 + const connectionId = this.generateConnectionId(playerId, gameId); 154 + 155 + // Remove existing connection if any 156 + this.removeConnection(connectionId); 157 + 158 + const connection: PlayerConnection = { 159 + playerId, 160 + gameId, 161 + ws, 162 + lastPing: Date.now() 163 + }; 164 + 165 + this.connections.set(connectionId, connection); 166 + 167 + if (!this.gameConnections.has(gameId)) { 168 + this.gameConnections.set(gameId, new Set()); 169 + } 170 + this.gameConnections.get(gameId)!.add(connectionId); 171 + 172 + ws.on('message', (data: string) => { 173 + try { 174 + const message: GameMessage = JSON.parse(data); 175 + this.handleMessage(connectionId, message); 176 + } catch (error) { 177 + ws.send(JSON.stringify({ error: 'Invalid message format' })); 178 + } 179 + }); 180 + 181 + ws.on('pong', () => { 182 + connection.lastPing = Date.now(); 183 + }); 184 + 185 + ws.on('close', () => { 186 + this.handleDisconnection(connectionId); 187 + }); 188 + 189 + ws.on('error', () => { 190 + this.handleDisconnection(connectionId); 191 + }); 192 + 193 + // Notify about player joining 194 + this.broadcastToGame(gameId, { 195 + type: 'join', 196 + gameId, 197 + playerId, 198 + timestamp: Date.now() 199 + }, connectionId); 200 + 201 + this.emit('playerJoined', { gameId, playerId }); 202 + } 203 + 204 + private handleMessage(connectionId: string, message: GameMessage): void { 205 + const connection = this.connections.get(connectionId); 206 + if (!connection) return; 207 + 208 + message.timestamp = Date.now(); 209 + message.playerId = connection.playerId; 210 + message.gameId = connection.gameId; 211 + 212 + switch (message.type) { 213 + case 'move': 214 + this.handleMove(connection, message); 215 + break; 216 + case 'state': 217 + this.handleStateRequest(connection, message); 218 + break; 219 + default: 220 + connection.ws.send(JSON.stringify({ error: 'Unknown message type' })); 221 + } 222 + } 223 + 224 + private handleMove(connection: PlayerConnection, message: GameMessage): void { 225 + // Broadcast move to all other players in the game 226 + this.broadcastToGame(connection.gameId, message, this.generateConnectionId(connection.playerId, connection.gameId)); 227 + this.emit('move', message); 228 + } 229 + 230 + private handleStateRequest(connection: PlayerConnection, message: GameMessage): void { 231 + this.emit('stateRequest', { 232 + gameId: connection.gameId, 233 + playerId: connection.playerId, 234 + respond: (state: GameState) => { 235 + connection.ws.send(JSON.stringify({ 236 + type: 'state', 237 + gameId: connection.gameId, 238 + data: state, 239 + timestamp: Date.now() 240 + })); 241 + } 242 + }); 243 + } 244 + 245 + private handleDisconnection(connectionId: string): void { 246 + const connection = this.connections.get(connectionId); 247 + if (!connection) return; 248 + 249 + this.removeConnection(connectionId); 250 + 251 + // Notify other players about disconnection 252 + this.broadcastToGame(connection.gameId, { 253 + type: 'disconnect', 254 + gameId: connection.gameId, 255 + playerId: connection.playerId, 256 + timestamp: Date.now() 257 + }); 258 + 259 + this.emit('playerDisconnected', { 260 + gameId: connection.gameId, 261 + playerId: connection.playerId 262 + }); 263 + } 264 + 265 + private removeConnection(connectionId: string): void { 266 + const connection = this.connections.get(connectionId); 267 + if (!connection) return; 268 + 269 + this.connections.delete(connectionId); 270 + 271 + const gameConnections = this.gameConnections.get(connection.gameId); 272 + if (gameConnections) { 273 + gameConnections.delete(connectionId); 274 + if (gameConnections.size === 0) { 275 + this.gameConnections.delete(connection.gameId); 276 + } 277 + } 278 + 279 + if (connection.ws.readyState === 1) { 280 + connection.ws.close(); 281 + } 282 + } 283 + 284 + private broadcastToGame(gameId: string, message: GameMessage, excludeConnectionId?: string): void { 285 + const gameConnections = this.gameConnections.get(gameId); 286 + if (!gameConnections) return; 287 + 288 + const messageStr = JSON.stringify(message); 289 + 290 + for (const connectionId of gameConnections) { 291 + if (connectionId === excludeConnectionId) continue; 292 + 293 + const connection = this.connections.get(connectionId); 294 + if (connection && connection.ws.readyState === 1) { 295 + connection.ws.send(messageStr); 296 + } 297 + } 298 + } 299 + 300 + private startPingMonitoring(): void { 301 + this.pingInterval = setInterval(() => { 302 + const now = Date.now(); 303 + const staleConnections: string[] = []; 304 + 305 + for (const [connectionId, connection] of this.connections) { 306 + if (now - connection.lastPing > 5000) { 307 + staleConnections.push(connectionId); 308 + } else if (connection.ws.readyState === 1) { 309 + connection.ws.ping(); 310 + } 311 + } 312 + 313 + // Remove stale connections 314 + for (const connectionId of staleConnections) { 315 + this.handleDisconnection(connectionId); 316 + } 317 + }, 2500); 318 + } 319 + 320 + private generateConnectionId(playerId: string, gameId: string): string { 321 + return createHash('sha256').update(`${playerId}:${gameId}`).digest('hex').substring(0, 16); 322 + } 323 + 324 + public notifyMove(gameId: string, playerId: string, moveData: any): void { 325 + this.broadcastToGame(gameId, { 326 + type: 'move', 327 + gameId, 328 + playerId, 329 + data: moveData, 330 + timestamp: Date.now() 331 + }); 332 + } 333 + 334 + public notifyGameJoin(gameId: string, gameState: GameState): void { 335 + this.broadcastToGame(gameId, { 336 + type: 'state', 337 + gameId, 338 + playerId: '', 339 + data: gameState, 340 + timestamp: Date.now() 341 + }); 342 + } 343 + 344 + public notifyGameEnd(gameId: string, result: { winner?: string; reason: string }): void { 345 + this.broadcastToGame(gameId, { 346 + type: 'end', 347 + gameId, 348 + playerId: '', 349 + data: result, 350 + timestamp: Date.now() 351 + }); 352 + } 353 + 354 + public getActiveConnections(gameId: string): number { 355 + return this.gameConnections.get(gameId)?.size || 0; 356 + } 357 + 358 + public isPlayerConnected(gameId: string, playerId: string): boolean { 359 + const connectionId = this.generateConnectionId(playerId, gameId); 360 + return this.connections.has(connectionId); 361 + } 362 + 363 + public close(): void { 364 + if (this.pingInterval) { 365 + clearInterval(this.pingInterval); 366 + this.pingInterval = null; 367 + } 368 + 369 + for (const connection of this.connections.values()) { 370 + connection.ws.close(); 371 + } 372 + 373 + this.connections.clear(); 374 + this.gameConnections.clear(); 375 + this.server.close(); 376 + } 377 + } 378 + 379 + export function createRealTimeCommunication(port?: number): RealTimeCommunication { 380 + return new RealTimeCommunication(port); 381 + } 382 + 383 + /** @internal Phoenix VCS traceability — do not remove. */ 384 + export const _phoenix = { 385 + iu_id: 'afe25dcfd068b869440fcfcf14db07eb17aebe3f03d9700ab321cbb847c7008c', 386 + name: 'Real-Time Communication', 387 + risk_tier: 'medium', 388 + canon_ids: [5 as const], 389 + } as const;
+125
examples/tictactoe/src/generated/multiplayer/server.ts
··· 1 + /** 2 + * Multiplayer — HTTP Server 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Provides health check, metrics, and module endpoints. 6 + */ 7 + 8 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 9 + 10 + import * as gameRooms from './game-rooms.js'; 11 + import * as matchmaking from './matchmaking.js'; 12 + import * as playerManagement from './player-management.js'; 13 + import * as realTimeCommunication from './real-time-communication.js'; 14 + 15 + // ─── Metrics ───────────────────────────────────────────────────────────────── 16 + 17 + const metrics = { 18 + requests_total: 0, 19 + requests_by_path: {} as Record<string, number>, 20 + errors_total: 0, 21 + uptime_start: Date.now(), 22 + }; 23 + 24 + // ─── Module Registry ───────────────────────────────────────────────────────── 25 + 26 + const modules = { 27 + 'game-rooms': gameRooms, 28 + 'matchmaking': matchmaking, 29 + 'player-management': playerManagement, 30 + 'real-time-communication': realTimeCommunication, 31 + }; 32 + 33 + // ─── Router ────────────────────────────────────────────────────────────────── 34 + 35 + type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>; 36 + 37 + const routes: Record<string, Handler> = { 38 + '/health': (_req, res) => { 39 + res.writeHead(200, { 'Content-Type': 'application/json' }); 40 + res.end(JSON.stringify({ 41 + status: 'ok', 42 + service: 'Multiplayer', 43 + uptime: Math.floor((Date.now() - metrics.uptime_start) / 1000), 44 + modules: Object.keys(modules), 45 + })); 46 + }, 47 + 48 + '/metrics': (_req, res) => { 49 + res.writeHead(200, { 'Content-Type': 'application/json' }); 50 + res.end(JSON.stringify({ 51 + ...metrics, 52 + uptime_seconds: Math.floor((Date.now() - metrics.uptime_start) / 1000), 53 + }, null, 2)); 54 + }, 55 + 56 + '/modules': (_req, res) => { 57 + const info = Object.entries(modules).map(([name, mod]) => { 58 + const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined; 59 + return { 60 + name, 61 + risk_tier: phoenix?.risk_tier ?? 'unknown', 62 + exports: Object.keys(mod).filter(k => k !== '_phoenix'), 63 + }; 64 + }); 65 + res.writeHead(200, { 'Content-Type': 'application/json' }); 66 + res.end(JSON.stringify(info, null, 2)); 67 + }, 68 + }; 69 + 70 + // ─── Server ────────────────────────────────────────────────────────────────── 71 + 72 + function handleRequest(req: IncomingMessage, res: ServerResponse): void { 73 + const url = req.url ?? '/'; 74 + const path = url.split('?')[0]; 75 + 76 + metrics.requests_total++; 77 + metrics.requests_by_path[path] = (metrics.requests_by_path[path] ?? 0) + 1; 78 + 79 + const handler = routes[path]; 80 + if (handler) { 81 + try { 82 + handler(req, res); 83 + } catch (err) { 84 + metrics.errors_total++; 85 + res.writeHead(500, { 'Content-Type': 'application/json' }); 86 + res.end(JSON.stringify({ error: String(err) })); 87 + } 88 + } else { 89 + res.writeHead(404, { 'Content-Type': 'application/json' }); 90 + res.end(JSON.stringify({ 91 + error: 'Not Found', 92 + path, 93 + available: Object.keys(routes), 94 + })); 95 + } 96 + } 97 + 98 + export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } { 99 + const requestedPort = port ?? parseInt(process.env.MULTIPLAYER_PORT ?? process.env.PORT ?? '3001', 10); 100 + const server = createServer(handleRequest); 101 + let actualPort = requestedPort; 102 + 103 + const ready = new Promise<void>(resolve => { 104 + server.listen(requestedPort, () => { 105 + const addr = server.address(); 106 + if (addr && typeof addr === 'object') actualPort = addr.port; 107 + result.port = actualPort; 108 + console.log(`Multiplayer listening on http://localhost:${actualPort}`); 109 + console.log(` /health — health check`); 110 + console.log(` /metrics — request metrics`); 111 + console.log(` /modules — registered modules`); 112 + resolve(); 113 + }); 114 + }); 115 + 116 + const result = { server, port: actualPort, ready }; 117 + return result; 118 + } 119 + 120 + // Start when run directly 121 + const isMain = process.argv[1]?.endsWith('/multiplayer/server.js') || 122 + process.argv[1]?.endsWith('/multiplayer/server.ts'); 123 + if (isMain) { 124 + startServer(); 125 + }
+117
examples/tictactoe/src/generated/web-client/__tests__/web-client.test.ts
··· 1 + /** 2 + * Web Client — Generated Tests 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Tests module structure, server health, and Phoenix traceability. 6 + */ 7 + 8 + import { describe, it, expect, afterAll } from 'vitest'; 9 + import { startServer } from '../server.js'; 10 + 11 + import * as gameBoardUi from '../game-board-ui.js'; 12 + import * as gameStatusDisplay from '../game-status-display.js'; 13 + import * as lobby from '../lobby.js'; 14 + import * as styling from '../styling.js'; 15 + 16 + describe('Web Client modules', () => { 17 + describe('Game Board UI', () => { 18 + it('exports Phoenix traceability metadata', () => { 19 + expect(gameBoardUi._phoenix).toBeDefined(); 20 + expect(gameBoardUi._phoenix.name).toBe('Game Board UI'); 21 + expect(gameBoardUi._phoenix.risk_tier).toBeTruthy(); 22 + }); 23 + 24 + it('has exported functions', () => { 25 + const exports = Object.keys(gameBoardUi).filter(k => k !== '_phoenix'); 26 + expect(exports.length).toBeGreaterThan(0); 27 + }); 28 + }); 29 + 30 + describe('Game Status Display', () => { 31 + it('exports Phoenix traceability metadata', () => { 32 + expect(gameStatusDisplay._phoenix).toBeDefined(); 33 + expect(gameStatusDisplay._phoenix.name).toBe('Game Status Display'); 34 + expect(gameStatusDisplay._phoenix.risk_tier).toBeTruthy(); 35 + }); 36 + 37 + it('has exported functions', () => { 38 + const exports = Object.keys(gameStatusDisplay).filter(k => k !== '_phoenix'); 39 + expect(exports.length).toBeGreaterThan(0); 40 + }); 41 + }); 42 + 43 + describe('Lobby', () => { 44 + it('exports Phoenix traceability metadata', () => { 45 + expect(lobby._phoenix).toBeDefined(); 46 + expect(lobby._phoenix.name).toBe('Lobby'); 47 + expect(lobby._phoenix.risk_tier).toBeTruthy(); 48 + }); 49 + 50 + it('has exported functions', () => { 51 + const exports = Object.keys(lobby).filter(k => k !== '_phoenix'); 52 + expect(exports.length).toBeGreaterThan(0); 53 + }); 54 + }); 55 + 56 + describe('Styling', () => { 57 + it('exports Phoenix traceability metadata', () => { 58 + expect(styling._phoenix).toBeDefined(); 59 + expect(styling._phoenix.name).toBe('Styling'); 60 + expect(styling._phoenix.risk_tier).toBeTruthy(); 61 + }); 62 + 63 + it('has exported functions', () => { 64 + const exports = Object.keys(styling).filter(k => k !== '_phoenix'); 65 + expect(exports.length).toBeGreaterThan(0); 66 + }); 67 + }); 68 + 69 + }); 70 + 71 + describe('Web Client server', () => { 72 + const instance = startServer(0); // random port 73 + 74 + afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve()))); 75 + 76 + it('GET /health returns 200', async () => { 77 + await instance.ready; 78 + const res = await fetch(`http://localhost:${instance.port}/health`); 79 + expect(res.status).toBe(200); 80 + const body = await res.json() as Record<string, unknown>; 81 + expect(body.status).toBe('ok'); 82 + expect(body.service).toBe('Web Client'); 83 + }); 84 + 85 + it('GET /metrics returns request counts', async () => { 86 + await instance.ready; 87 + const res = await fetch(`http://localhost:${instance.port}/metrics`); 88 + expect(res.status).toBe(200); 89 + const body = await res.json() as Record<string, unknown>; 90 + expect(typeof body.requests_total).toBe('number'); 91 + }); 92 + 93 + it('GET /modules lists all registered modules', async () => { 94 + await instance.ready; 95 + const res = await fetch(`http://localhost:${instance.port}/modules`); 96 + expect(res.status).toBe(200); 97 + const body = await res.json() as Array<Record<string, unknown>>; 98 + expect(body.length).toBe(4); 99 + }); 100 + 101 + it('GET /unknown returns 404', async () => { 102 + await instance.ready; 103 + const res = await fetch(`http://localhost:${instance.port}/unknown`); 104 + expect(res.status).toBe(404); 105 + }); 106 + 107 + it('GET / serves HTML page', async () => { 108 + await instance.ready; 109 + const res = await fetch(`http://localhost:${instance.port}/`); 110 + expect(res.status).toBe(200); 111 + const ct = res.headers.get('content-type') ?? ''; 112 + expect(ct).toContain('text/html'); 113 + const body = await res.text(); 114 + expect(body).toContain('<!DOCTYPE html>'); 115 + expect(body).toContain('<title>Web Client</title>'); 116 + }); 117 + });
+134
examples/tictactoe/src/generated/web-client/game-board-ui.ts
··· 1 + export interface GameState { 2 + board: ('X' | 'O' | null)[]; 3 + currentPlayer: 'X' | 'O'; 4 + winner: 'X' | 'O' | 'draw' | null; 5 + winningLine: number[] | null; 6 + isPlayerTurn: boolean; 7 + } 8 + 9 + export interface MoveHandler { 10 + (cellIndex: number): void; 11 + } 12 + 13 + export class GameBoardUI { 14 + private gameState: GameState; 15 + private onMove: MoveHandler | null = null; 16 + 17 + constructor() { 18 + this.gameState = { 19 + board: Array(9).fill(null), 20 + currentPlayer: 'X', 21 + winner: null, 22 + winningLine: null, 23 + isPlayerTurn: true, 24 + }; 25 + } 26 + 27 + public setMoveHandler(handler: MoveHandler): void { 28 + this.onMove = handler; 29 + } 30 + 31 + public updateGameState(state: Partial<GameState>): void { 32 + this.gameState = { ...this.gameState, ...state }; 33 + } 34 + 35 + public generateHTML(): string { 36 + const cells = this.gameState.board.map((cell, index) => { 37 + const cellValue = cell || ''; 38 + const isClickable = this.isCellClickable(index); 39 + const isWinningCell = this.gameState.winningLine?.includes(index) || false; 40 + 41 + const cellClass = [ 42 + 'game-cell', 43 + isClickable ? 'clickable' : 'disabled', 44 + isWinningCell ? 'winning-cell' : '', 45 + cellValue.toLowerCase() 46 + ].filter(Boolean).join(' '); 47 + 48 + return `<div class="${cellClass}" data-cell-index="${index}" onclick="handleCellClick(${index})">${cellValue}</div>`; 49 + }).join(''); 50 + 51 + return ` 52 + <div class="game-board"> 53 + ${cells} 54 + </div> 55 + `; 56 + } 57 + 58 + public handleCellClick(cellIndex: number): void { 59 + if (!this.isCellClickable(cellIndex)) return; 60 + if (!this.onMove) return; 61 + 62 + this.onMove(cellIndex); 63 + } 64 + 65 + private isCellClickable(cellIndex: number): boolean { 66 + // Cell must be empty 67 + if (this.gameState.board[cellIndex] !== null) return false; 68 + 69 + // Game must not be over 70 + if (this.gameState.winner !== null) return false; 71 + 72 + // Must be player's turn 73 + if (!this.gameState.isPlayerTurn) return false; 74 + 75 + return true; 76 + } 77 + 78 + public reset(): void { 79 + this.gameState = { 80 + board: Array(9).fill(null), 81 + currentPlayer: 'X', 82 + winner: null, 83 + winningLine: null, 84 + isPlayerTurn: true, 85 + }; 86 + } 87 + 88 + public getGameState(): GameState { 89 + return { ...this.gameState }; 90 + } 91 + } 92 + 93 + export function createGameBoard(): GameBoardUI { 94 + return new GameBoardUI(); 95 + } 96 + 97 + export function calculateWinningLine(board: ('X' | 'O' | null)[]): number[] | null { 98 + const winPatterns = [ 99 + [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows 100 + [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns 101 + [0, 4, 8], [2, 4, 6] // diagonals 102 + ]; 103 + 104 + for (const pattern of winPatterns) { 105 + const [a, b, c] = pattern; 106 + if (board[a] && board[a] === board[b] && board[a] === board[c]) { 107 + return pattern; 108 + } 109 + } 110 + 111 + return null; 112 + } 113 + 114 + export function checkGameWinner(board: ('X' | 'O' | null)[]): 'X' | 'O' | 'draw' | null { 115 + const winningLine = calculateWinningLine(board); 116 + if (winningLine) { 117 + return board[winningLine[0]] as 'X' | 'O'; 118 + } 119 + 120 + // Check for draw 121 + if (board.every(cell => cell !== null)) { 122 + return 'draw'; 123 + } 124 + 125 + return null; 126 + } 127 + 128 + /** @internal Phoenix VCS traceability — do not remove. */ 129 + export const _phoenix = { 130 + iu_id: '7d36f47fe6080845a9c19e42491b8d549fdfe229119d5136091b38eab02d4783', 131 + name: 'Game Board UI', 132 + risk_tier: 'high', 133 + canon_ids: [6 as const], 134 + } as const;
+154
examples/tictactoe/src/generated/web-client/game-status-display.ts
··· 1 + export interface GameStatusNotification { 2 + type: 'turn' | 'result' | 'waiting' | 'connection'; 3 + data: { 4 + isYourTurn?: boolean; 5 + playerName?: string; 6 + result?: 'win' | 'lose' | 'draw'; 7 + isWaiting?: boolean; 8 + connectionStatus?: 'connected' | 'disconnected' | 'connecting'; 9 + }; 10 + } 11 + 12 + export interface GameStatusResult { 13 + html: string; 14 + className: string; 15 + } 16 + 17 + export class GameStatusDisplay { 18 + private currentStatus: GameStatusNotification | null = null; 19 + 20 + updateStatus(notification: GameStatusNotification): GameStatusResult { 21 + this.currentStatus = notification; 22 + return this.render(); 23 + } 24 + 25 + private render(): GameStatusResult { 26 + if (!this.currentStatus) { 27 + return { 28 + html: '<div class="game-status">Ready to play</div>', 29 + className: 'status-ready' 30 + }; 31 + } 32 + 33 + const { type, data } = this.currentStatus; 34 + 35 + switch (type) { 36 + case 'turn': 37 + return this.renderTurnStatus(data); 38 + case 'result': 39 + return this.renderGameResult(data); 40 + case 'waiting': 41 + return this.renderWaitingStatus(); 42 + case 'connection': 43 + return this.renderConnectionStatus(data); 44 + default: 45 + return { 46 + html: '<div class="game-status">Unknown status</div>', 47 + className: 'status-unknown' 48 + }; 49 + } 50 + } 51 + 52 + private renderTurnStatus(data: GameStatusNotification['data']): GameStatusResult { 53 + const isYourTurn = data.isYourTurn ?? false; 54 + const playerName = data.playerName || 'Player'; 55 + 56 + if (isYourTurn) { 57 + return { 58 + html: '<div class="game-status turn-yours">Your turn</div>', 59 + className: 'status-your-turn' 60 + }; 61 + } else { 62 + return { 63 + html: `<div class="game-status turn-opponent">${playerName}'s turn</div>`, 64 + className: 'status-opponent-turn' 65 + }; 66 + } 67 + } 68 + 69 + private renderGameResult(data: GameStatusNotification['data']): GameStatusResult { 70 + const result = data.result; 71 + 72 + switch (result) { 73 + case 'win': 74 + return { 75 + html: '<div class="game-status result-win">🎉 You Win!</div>', 76 + className: 'status-win' 77 + }; 78 + case 'lose': 79 + return { 80 + html: '<div class="game-status result-lose">😔 You Lose</div>', 81 + className: 'status-lose' 82 + }; 83 + case 'draw': 84 + return { 85 + html: '<div class="game-status result-draw">🤝 Draw</div>', 86 + className: 'status-draw' 87 + }; 88 + default: 89 + return { 90 + html: '<div class="game-status result-unknown">Game ended</div>', 91 + className: 'status-game-ended' 92 + }; 93 + } 94 + } 95 + 96 + private renderWaitingStatus(): GameStatusResult { 97 + return { 98 + html: '<div class="game-status waiting">⏳ Waiting for opponent...</div>', 99 + className: 'status-waiting' 100 + }; 101 + } 102 + 103 + private renderConnectionStatus(data: GameStatusNotification['data']): GameStatusResult { 104 + const status = data.connectionStatus || 'disconnected'; 105 + 106 + switch (status) { 107 + case 'connected': 108 + return { 109 + html: '<div class="game-status connection-ok">🟢 Connected</div>', 110 + className: 'status-connected' 111 + }; 112 + case 'connecting': 113 + return { 114 + html: '<div class="game-status connection-pending">🟡 Connecting...</div>', 115 + className: 'status-connecting' 116 + }; 117 + case 'disconnected': 118 + return { 119 + html: '<div class="game-status connection-error">🔴 Disconnected</div>', 120 + className: 'status-disconnected' 121 + }; 122 + default: 123 + return { 124 + html: '<div class="game-status connection-unknown">❓ Connection status unknown</div>', 125 + className: 'status-connection-unknown' 126 + }; 127 + } 128 + } 129 + 130 + getCurrentStatus(): GameStatusNotification | null { 131 + return this.currentStatus; 132 + } 133 + 134 + reset(): void { 135 + this.currentStatus = null; 136 + } 137 + } 138 + 139 + export function createGameStatusDisplay(): GameStatusDisplay { 140 + return new GameStatusDisplay(); 141 + } 142 + 143 + export function renderGameStatus(notification: GameStatusNotification): GameStatusResult { 144 + const display = new GameStatusDisplay(); 145 + return display.updateStatus(notification); 146 + } 147 + 148 + /** @internal Phoenix VCS traceability — do not remove. */ 149 + export const _phoenix = { 150 + iu_id: '93a59f6c07719076dda9b8b06c6e2792461ca8d9769cef1e635d0409afcbecd2', 151 + name: 'Game Status Display', 152 + risk_tier: 'medium', 153 + canon_ids: [4 as const], 154 + } as const;
+11
examples/tictactoe/src/generated/web-client/index.ts
··· 1 + /** 2 + * Web Client 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Barrel export for all Web Client modules. 6 + */ 7 + 8 + export * as gameBoardUi from './game-board-ui.js'; 9 + export * as gameStatusDisplay from './game-status-display.js'; 10 + export * as lobby from './lobby.js'; 11 + export * as styling from './styling.js';
+210
examples/tictactoe/src/generated/web-client/lobby.ts
··· 1 + import { EventEmitter } from 'node:events'; 2 + 3 + export interface GameInfo { 4 + id: string; 5 + creatorName: string; 6 + playerCount: number; 7 + maxPlayers: number; 8 + status: 'waiting' | 'in-progress' | 'full'; 9 + } 10 + 11 + export interface LobbyState { 12 + playerName: string; 13 + availableGames: GameInfo[]; 14 + isConnected: boolean; 15 + } 16 + 17 + export interface LobbyEvents { 18 + gameCreated: (game: GameInfo) => void; 19 + gameUpdated: (game: GameInfo) => void; 20 + gameRemoved: (gameId: string) => void; 21 + playerNameChanged: (name: string) => void; 22 + connectionChanged: (connected: boolean) => void; 23 + } 24 + 25 + export class Lobby extends EventEmitter { 26 + private state: LobbyState = { 27 + playerName: '', 28 + availableGames: [], 29 + isConnected: false 30 + }; 31 + 32 + constructor() { 33 + super(); 34 + } 35 + 36 + public getState(): Readonly<LobbyState> { 37 + return { ...this.state }; 38 + } 39 + 40 + public setPlayerName(name: string): void { 41 + const trimmedName = name.trim(); 42 + if (trimmedName.length === 0) { 43 + throw new Error('Player name cannot be empty'); 44 + } 45 + if (trimmedName.length > 50) { 46 + throw new Error('Player name cannot exceed 50 characters'); 47 + } 48 + 49 + this.state.playerName = trimmedName; 50 + this.emit('playerNameChanged', trimmedName); 51 + } 52 + 53 + public canCreateGame(): boolean { 54 + return this.state.playerName.length > 0 && this.state.isConnected; 55 + } 56 + 57 + public canJoinGame(gameId: string): boolean { 58 + if (!this.state.playerName || !this.state.isConnected) { 59 + return false; 60 + } 61 + 62 + const game = this.state.availableGames.find(g => g.id === gameId); 63 + return game?.status === 'waiting' && game.playerCount < game.maxPlayers; 64 + } 65 + 66 + public createGame(): void { 67 + if (!this.canCreateGame()) { 68 + throw new Error('Cannot create game: player name required and must be connected'); 69 + } 70 + 71 + const gameId = this.generateGameId(); 72 + const newGame: GameInfo = { 73 + id: gameId, 74 + creatorName: this.state.playerName, 75 + playerCount: 1, 76 + maxPlayers: 2, 77 + status: 'waiting' 78 + }; 79 + 80 + this.addGame(newGame); 81 + this.emit('gameCreated', newGame); 82 + } 83 + 84 + public joinGame(gameId: string): void { 85 + if (!this.canJoinGame(gameId)) { 86 + throw new Error('Cannot join game: requirements not met'); 87 + } 88 + 89 + const game = this.state.availableGames.find(g => g.id === gameId); 90 + if (!game) { 91 + throw new Error('Game not found'); 92 + } 93 + 94 + const updatedGame: GameInfo = { 95 + ...game, 96 + playerCount: game.playerCount + 1, 97 + status: game.playerCount + 1 >= game.maxPlayers ? 'full' : 'waiting' 98 + }; 99 + 100 + this.updateGame(updatedGame); 101 + this.emit('gameUpdated', updatedGame); 102 + } 103 + 104 + public setConnectionStatus(connected: boolean): void { 105 + this.state.isConnected = connected; 106 + this.emit('connectionChanged', connected); 107 + } 108 + 109 + public addGame(game: GameInfo): void { 110 + const existingIndex = this.state.availableGames.findIndex(g => g.id === game.id); 111 + if (existingIndex >= 0) { 112 + this.state.availableGames[existingIndex] = game; 113 + } else { 114 + this.state.availableGames.push(game); 115 + } 116 + } 117 + 118 + public updateGame(game: GameInfo): void { 119 + const index = this.state.availableGames.findIndex(g => g.id === game.id); 120 + if (index >= 0) { 121 + this.state.availableGames[index] = game; 122 + } 123 + } 124 + 125 + public removeGame(gameId: string): void { 126 + this.state.availableGames = this.state.availableGames.filter(g => g.id !== gameId); 127 + this.emit('gameRemoved', gameId); 128 + } 129 + 130 + public renderHTML(): string { 131 + const nameInputDisabled = !this.state.isConnected ? 'disabled' : ''; 132 + const createButtonDisabled = !this.canCreateGame() ? 'disabled' : ''; 133 + 134 + const gamesHTML = this.state.availableGames 135 + .filter(game => game.status === 'waiting') 136 + .map(game => { 137 + const joinDisabled = !this.canJoinGame(game.id) ? 'disabled' : ''; 138 + return ` 139 + <div class="game-item" data-game-id="${game.id}"> 140 + <div class="game-info"> 141 + <span class="creator-name">${this.escapeHtml(game.creatorName)}</span> 142 + <span class="player-count">${game.playerCount}/${game.maxPlayers} players</span> 143 + </div> 144 + <button class="join-button" data-game-id="${game.id}" ${joinDisabled}> 145 + Join Game 146 + </button> 147 + </div> 148 + `; 149 + }) 150 + .join(''); 151 + 152 + return ` 153 + <div class="lobby"> 154 + <div class="lobby-header"> 155 + <h1>Game Lobby</h1> 156 + <div class="connection-status ${this.state.isConnected ? 'connected' : 'disconnected'}"> 157 + ${this.state.isConnected ? 'Connected' : 'Disconnected'} 158 + </div> 159 + </div> 160 + 161 + <div class="player-setup"> 162 + <label for="player-name">Display Name:</label> 163 + <input 164 + type="text" 165 + id="player-name" 166 + value="${this.escapeHtml(this.state.playerName)}" 167 + placeholder="Enter your display name" 168 + maxlength="50" 169 + ${nameInputDisabled} 170 + /> 171 + <button id="create-game" ${createButtonDisabled}> 172 + Create Game 173 + </button> 174 + </div> 175 + 176 + <div class="available-games"> 177 + <h2>Available Games</h2> 178 + <div class="games-list"> 179 + ${gamesHTML || '<div class="no-games">No games available</div>'} 180 + </div> 181 + </div> 182 + </div> 183 + `; 184 + } 185 + 186 + private generateGameId(): string { 187 + return Date.now().toString(36) + Math.random().toString(36).substring(2); 188 + } 189 + 190 + private escapeHtml(text: string): string { 191 + return text 192 + .replace(/&/g, '&amp;') 193 + .replace(/</g, '&lt;') 194 + .replace(/>/g, '&gt;') 195 + .replace(/"/g, '&quot;') 196 + .replace(/'/g, '&#39;'); 197 + } 198 + } 199 + 200 + export function createLobby(): Lobby { 201 + return new Lobby(); 202 + } 203 + 204 + /** @internal Phoenix VCS traceability — do not remove. */ 205 + export const _phoenix = { 206 + iu_id: '64213760b6aa34c15211b16e1e65ac318ccfce4fdea2ca50f9efd0738812036f', 207 + name: 'Lobby', 208 + risk_tier: 'medium', 209 + canon_ids: [5 as const], 210 + } as const;
+241
examples/tictactoe/src/generated/web-client/server.ts
··· 1 + /** 2 + * Web Client — Web Server 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Serves the web client HTML, plus health/metrics/modules endpoints. 6 + */ 7 + 8 + import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 9 + 10 + import * as gameBoardUi from './game-board-ui.js'; 11 + import * as gameStatusDisplay from './game-status-display.js'; 12 + import * as lobby from './lobby.js'; 13 + import * as styling from './styling.js'; 14 + 15 + // ─── Metrics ───────────────────────────────────────────────────────────────── 16 + 17 + const metrics = { 18 + requests_total: 0, 19 + requests_by_path: {} as Record<string, number>, 20 + errors_total: 0, 21 + uptime_start: Date.now(), 22 + }; 23 + 24 + // ─── Module Registry ───────────────────────────────────────────────────────── 25 + 26 + const modules = { 27 + 'game-board-ui': gameBoardUi, 28 + 'game-status-display': gameStatusDisplay, 29 + 'lobby': lobby, 30 + 'styling': styling, 31 + }; 32 + 33 + // ─── HTML Renderer ─────────────────────────────────────────────────────────── 34 + 35 + function renderPage(): string { 36 + // Collect CSS from style modules 37 + let css = ''; 38 + try { 39 + const styleMod = styling as Record<string, unknown>; 40 + for (const key of Object.keys(styleMod)) { 41 + const val = styleMod[key]; 42 + if (typeof val === 'function' && /css|style/i.test(key)) { 43 + const result = (val as Function)(); 44 + if (typeof result === 'string') css += result; 45 + else if (result && typeof result === 'object' && 'generateCSS' in result) { 46 + css += (result as { generateCSS: () => string }).generateCSS(); 47 + } 48 + } 49 + } 50 + } catch { /* style module may not have expected exports */ } 51 + 52 + // Collect HTML from UI modules 53 + const sections: string[] = []; 54 + try { 55 + const uiMod = gameBoardUi as Record<string, unknown>; 56 + for (const key of Object.keys(uiMod)) { 57 + const val = uiMod[key]; 58 + // Look for factory functions that return objects with render/renderHTML 59 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 60 + try { 61 + const instance = (val as Function)(); 62 + if (typeof instance === 'string' && instance.includes('<')) { 63 + sections.push(instance); 64 + } else if (instance && typeof instance === 'object') { 65 + const obj = instance as Record<string, unknown>; 66 + if (typeof obj.render === 'function') { 67 + const html = (obj.render as Function)(); 68 + if (typeof html === 'string') sections.push(html); 69 + } else if (typeof obj.renderHTML === 'function') { 70 + const html = (obj.renderHTML as Function)(); 71 + if (typeof html === 'string') sections.push(html); 72 + } 73 + } 74 + } catch { /* factory may require args */ } 75 + } 76 + } 77 + } catch { /* module may not have renderable exports */ } 78 + try { 79 + const uiMod = gameStatusDisplay as Record<string, unknown>; 80 + for (const key of Object.keys(uiMod)) { 81 + const val = uiMod[key]; 82 + // Look for factory functions that return objects with render/renderHTML 83 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 84 + try { 85 + const instance = (val as Function)(); 86 + if (typeof instance === 'string' && instance.includes('<')) { 87 + sections.push(instance); 88 + } else if (instance && typeof instance === 'object') { 89 + const obj = instance as Record<string, unknown>; 90 + if (typeof obj.render === 'function') { 91 + const html = (obj.render as Function)(); 92 + if (typeof html === 'string') sections.push(html); 93 + } else if (typeof obj.renderHTML === 'function') { 94 + const html = (obj.renderHTML as Function)(); 95 + if (typeof html === 'string') sections.push(html); 96 + } 97 + } 98 + } catch { /* factory may require args */ } 99 + } 100 + } 101 + } catch { /* module may not have renderable exports */ } 102 + try { 103 + const uiMod = lobby as Record<string, unknown>; 104 + for (const key of Object.keys(uiMod)) { 105 + const val = uiMod[key]; 106 + // Look for factory functions that return objects with render/renderHTML 107 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 108 + try { 109 + const instance = (val as Function)(); 110 + if (typeof instance === 'string' && instance.includes('<')) { 111 + sections.push(instance); 112 + } else if (instance && typeof instance === 'object') { 113 + const obj = instance as Record<string, unknown>; 114 + if (typeof obj.render === 'function') { 115 + const html = (obj.render as Function)(); 116 + if (typeof html === 'string') sections.push(html); 117 + } else if (typeof obj.renderHTML === 'function') { 118 + const html = (obj.renderHTML as Function)(); 119 + if (typeof html === 'string') sections.push(html); 120 + } 121 + } 122 + } catch { /* factory may require args */ } 123 + } 124 + } 125 + } catch { /* module may not have renderable exports */ } 126 + 127 + return `<!DOCTYPE html> 128 + <html lang="en"> 129 + <head> 130 + <meta charset="utf-8"> 131 + <meta name="viewport" content="width=device-width, initial-scale=1"> 132 + <title>Web Client</title> 133 + <style>${css}</style> 134 + </head> 135 + <body> 136 + <div class="game-container"> 137 + ${sections.join('\n')} 138 + </div> 139 + </body> 140 + </html>`; 141 + } 142 + 143 + // ─── Router ────────────────────────────────────────────────────────────────── 144 + 145 + type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>; 146 + 147 + const routes: Record<string, Handler> = { 148 + '/': (_req, res) => { 149 + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 150 + res.end(renderPage()); 151 + }, 152 + 153 + '/health': (_req, res) => { 154 + res.writeHead(200, { 'Content-Type': 'application/json' }); 155 + res.end(JSON.stringify({ 156 + status: 'ok', 157 + service: 'Web Client', 158 + uptime: Math.floor((Date.now() - metrics.uptime_start) / 1000), 159 + modules: Object.keys(modules), 160 + })); 161 + }, 162 + 163 + '/metrics': (_req, res) => { 164 + res.writeHead(200, { 'Content-Type': 'application/json' }); 165 + res.end(JSON.stringify({ 166 + ...metrics, 167 + uptime_seconds: Math.floor((Date.now() - metrics.uptime_start) / 1000), 168 + }, null, 2)); 169 + }, 170 + 171 + '/modules': (_req, res) => { 172 + const info = Object.entries(modules).map(([name, mod]) => { 173 + const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined; 174 + return { 175 + name, 176 + risk_tier: phoenix?.risk_tier ?? 'unknown', 177 + exports: Object.keys(mod).filter(k => k !== '_phoenix'), 178 + }; 179 + }); 180 + res.writeHead(200, { 'Content-Type': 'application/json' }); 181 + res.end(JSON.stringify(info, null, 2)); 182 + }, 183 + }; 184 + 185 + // ─── Server ────────────────────────────────────────────────────────────────── 186 + 187 + function handleRequest(req: IncomingMessage, res: ServerResponse): void { 188 + const url = req.url ?? '/'; 189 + const path = url.split('?')[0]; 190 + 191 + metrics.requests_total++; 192 + metrics.requests_by_path[path] = (metrics.requests_by_path[path] ?? 0) + 1; 193 + 194 + const handler = routes[path]; 195 + if (handler) { 196 + try { 197 + handler(req, res); 198 + } catch (err) { 199 + metrics.errors_total++; 200 + res.writeHead(500, { 'Content-Type': 'application/json' }); 201 + res.end(JSON.stringify({ error: String(err) })); 202 + } 203 + } else { 204 + res.writeHead(404, { 'Content-Type': 'application/json' }); 205 + res.end(JSON.stringify({ 206 + error: 'Not Found', 207 + path, 208 + available: Object.keys(routes), 209 + })); 210 + } 211 + } 212 + 213 + export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } { 214 + const requestedPort = port ?? parseInt(process.env.WEB_CLIENT_PORT ?? process.env.PORT ?? '3002', 10); 215 + const server = createServer(handleRequest); 216 + let actualPort = requestedPort; 217 + 218 + const ready = new Promise<void>(resolve => { 219 + server.listen(requestedPort, () => { 220 + const addr = server.address(); 221 + if (addr && typeof addr === 'object') actualPort = addr.port; 222 + result.port = actualPort; 223 + console.log(`Web Client listening on http://localhost:${actualPort}`); 224 + console.log(` / — web client`); 225 + console.log(` /health — health check`); 226 + console.log(` /metrics — request metrics`); 227 + console.log(` /modules — registered modules`); 228 + resolve(); 229 + }); 230 + }); 231 + 232 + const result = { server, port: actualPort, ready }; 233 + return result; 234 + } 235 + 236 + // Start when run directly 237 + const isMain = process.argv[1]?.endsWith('/web-client/server.js') || 238 + process.argv[1]?.endsWith('/web-client/server.ts'); 239 + if (isMain) { 240 + startServer(); 241 + }
+428
examples/tictactoe/src/generated/web-client/styling.ts
··· 1 + export interface StyleConfig { 2 + boardSize: number; 3 + cellSize: number; 4 + gap: number; 5 + borderRadius: number; 6 + fontSize: number; 7 + lineWidth: number; 8 + } 9 + 10 + export interface ResponsiveBreakpoints { 11 + mobile: number; 12 + tablet: number; 13 + desktop: number; 14 + } 15 + 16 + export class StylingManager { 17 + private readonly breakpoints: ResponsiveBreakpoints = { 18 + mobile: 768, 19 + tablet: 1024, 20 + desktop: 1200 21 + }; 22 + 23 + private currentConfig: StyleConfig; 24 + 25 + constructor() { 26 + this.currentConfig = this.calculateResponsiveConfig(); 27 + this.setupResponsiveListener(); 28 + } 29 + 30 + private calculateResponsiveConfig(): StyleConfig { 31 + const screenWidth = 1024; 32 + const screenHeight = 768; 33 + const minDimension = Math.min(screenWidth, screenHeight); 34 + 35 + if (screenWidth <= this.breakpoints.mobile) { 36 + return { 37 + boardSize: Math.min(minDimension * 0.85, 300), 38 + cellSize: Math.min(minDimension * 0.25, 90), 39 + gap: 4, 40 + borderRadius: 8, 41 + fontSize: 48, 42 + lineWidth: 4 43 + }; 44 + } else if (screenWidth <= this.breakpoints.tablet) { 45 + return { 46 + boardSize: Math.min(minDimension * 0.7, 400), 47 + cellSize: Math.min(minDimension * 0.2, 120), 48 + gap: 6, 49 + borderRadius: 12, 50 + fontSize: 64, 51 + lineWidth: 5 52 + }; 53 + } else { 54 + return { 55 + boardSize: Math.min(minDimension * 0.6, 480), 56 + cellSize: Math.min(minDimension * 0.15, 150), 57 + gap: 8, 58 + borderRadius: 16, 59 + fontSize: 80, 60 + lineWidth: 6 61 + }; 62 + } 63 + } 64 + 65 + private setupResponsiveListener(): void { 66 + // No-op for server-side rendering 67 + } 68 + 69 + public generateCSS(): string { 70 + const config = this.currentConfig; 71 + 72 + return ` 73 + * { 74 + box-sizing: border-box; 75 + margin: 0; 76 + padding: 0; 77 + } 78 + 79 + body { 80 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 81 + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); 82 + min-height: 100vh; 83 + display: flex; 84 + align-items: center; 85 + justify-content: center; 86 + padding: 20px; 87 + } 88 + 89 + .game-container { 90 + display: flex; 91 + flex-direction: column; 92 + align-items: center; 93 + gap: 30px; 94 + max-width: 100%; 95 + } 96 + 97 + .game-board { 98 + display: grid; 99 + grid-template-columns: repeat(3, ${config.cellSize}px); 100 + grid-template-rows: repeat(3, ${config.cellSize}px); 101 + gap: ${config.gap}px; 102 + background: #ffffff; 103 + padding: 20px; 104 + border-radius: ${config.borderRadius}px; 105 + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); 106 + border: 2px solid #e1e5e9; 107 + } 108 + 109 + .game-cell { 110 + width: ${config.cellSize}px; 111 + height: ${config.cellSize}px; 112 + background: #f8f9fa; 113 + border: 2px solid #dee2e6; 114 + border-radius: ${config.borderRadius / 2}px; 115 + display: flex; 116 + align-items: center; 117 + justify-content: center; 118 + font-size: ${config.fontSize}px; 119 + font-weight: bold; 120 + cursor: pointer; 121 + transition: all 0.2s ease; 122 + user-select: none; 123 + } 124 + 125 + .game-cell:hover { 126 + background: #e9ecef; 127 + border-color: #adb5bd; 128 + transform: scale(1.02); 129 + } 130 + 131 + .game-cell.disabled { 132 + cursor: not-allowed; 133 + opacity: 0.7; 134 + } 135 + 136 + .game-cell.disabled:hover { 137 + background: #f8f9fa; 138 + border-color: #dee2e6; 139 + transform: none; 140 + } 141 + 142 + .game-cell .mark-x { 143 + color: #007bff; 144 + text-shadow: 2px 2px 4px rgba(0, 123, 255, 0.3); 145 + } 146 + 147 + .game-cell .mark-o { 148 + color: #dc3545; 149 + text-shadow: 2px 2px 4px rgba(220, 53, 69, 0.3); 150 + } 151 + 152 + .game-cell.winning { 153 + background: #28a745 !important; 154 + border-color: #1e7e34 !important; 155 + animation: pulse-win 0.6s ease-in-out; 156 + } 157 + 158 + .game-cell.winning .mark-x, 159 + .game-cell.winning .mark-o { 160 + color: #ffffff !important; 161 + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); 162 + } 163 + 164 + @keyframes pulse-win { 165 + 0% { transform: scale(1); } 166 + 50% { transform: scale(1.05); } 167 + 100% { transform: scale(1); } 168 + } 169 + 170 + .game-status { 171 + text-align: center; 172 + font-size: 24px; 173 + font-weight: 600; 174 + color: #495057; 175 + min-height: 32px; 176 + display: flex; 177 + align-items: center; 178 + justify-content: center; 179 + } 180 + 181 + .game-status.winner { 182 + color: #28a745; 183 + font-size: 28px; 184 + } 185 + 186 + .game-status.draw { 187 + color: #ffc107; 188 + font-size: 28px; 189 + } 190 + 191 + .game-controls { 192 + display: flex; 193 + gap: 15px; 194 + flex-wrap: wrap; 195 + justify-content: center; 196 + } 197 + 198 + .btn { 199 + padding: 12px 24px; 200 + border: none; 201 + border-radius: ${config.borderRadius / 2}px; 202 + font-size: 16px; 203 + font-weight: 600; 204 + cursor: pointer; 205 + transition: all 0.2s ease; 206 + text-decoration: none; 207 + display: inline-block; 208 + text-align: center; 209 + } 210 + 211 + .btn-primary { 212 + background: #007bff; 213 + color: white; 214 + } 215 + 216 + .btn-primary:hover { 217 + background: #0056b3; 218 + transform: translateY(-2px); 219 + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3); 220 + } 221 + 222 + .btn-secondary { 223 + background: #6c757d; 224 + color: white; 225 + } 226 + 227 + .btn-secondary:hover { 228 + background: #545b62; 229 + transform: translateY(-2px); 230 + box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3); 231 + } 232 + 233 + .lobby-container { 234 + max-width: 600px; 235 + width: 100%; 236 + background: white; 237 + border-radius: ${config.borderRadius}px; 238 + padding: 30px; 239 + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); 240 + border: 2px solid #e1e5e9; 241 + } 242 + 243 + .lobby-title { 244 + font-size: 32px; 245 + font-weight: 700; 246 + text-align: center; 247 + color: #495057; 248 + margin-bottom: 30px; 249 + } 250 + 251 + .lobby-section { 252 + margin-bottom: 25px; 253 + } 254 + 255 + .lobby-section h3 { 256 + font-size: 20px; 257 + font-weight: 600; 258 + color: #495057; 259 + margin-bottom: 15px; 260 + } 261 + 262 + .input-group { 263 + display: flex; 264 + gap: 10px; 265 + margin-bottom: 15px; 266 + } 267 + 268 + .form-input { 269 + flex: 1; 270 + padding: 12px 16px; 271 + border: 2px solid #dee2e6; 272 + border-radius: ${config.borderRadius / 2}px; 273 + font-size: 16px; 274 + transition: border-color 0.2s ease; 275 + } 276 + 277 + .form-input:focus { 278 + outline: none; 279 + border-color: #007bff; 280 + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); 281 + } 282 + 283 + .game-list { 284 + list-style: none; 285 + padding: 0; 286 + } 287 + 288 + .game-item { 289 + display: flex; 290 + justify-content: space-between; 291 + align-items: center; 292 + padding: 15px; 293 + background: #f8f9fa; 294 + border: 1px solid #dee2e6; 295 + border-radius: ${config.borderRadius / 2}px; 296 + margin-bottom: 10px; 297 + } 298 + 299 + .game-item:hover { 300 + background: #e9ecef; 301 + } 302 + 303 + @media (max-width: ${this.breakpoints.mobile}px) { 304 + .game-container { 305 + gap: 20px; 306 + } 307 + 308 + .game-board { 309 + padding: 15px; 310 + } 311 + 312 + .lobby-container { 313 + padding: 20px; 314 + margin: 10px; 315 + } 316 + 317 + .lobby-title { 318 + font-size: 28px; 319 + } 320 + 321 + .input-group { 322 + flex-direction: column; 323 + } 324 + 325 + .game-controls { 326 + flex-direction: column; 327 + width: 100%; 328 + } 329 + 330 + .btn { 331 + width: 100%; 332 + } 333 + } 334 + 335 + @media (max-width: 480px) { 336 + body { 337 + padding: 10px; 338 + } 339 + 340 + .game-status { 341 + font-size: 20px; 342 + } 343 + 344 + .game-status.winner, 345 + .game-status.draw { 346 + font-size: 24px; 347 + } 348 + } 349 + `; 350 + } 351 + 352 + public applyStyles(): void { 353 + // No-op for server-side rendering 354 + } 355 + 356 + public getConfig(): StyleConfig { 357 + return { ...this.currentConfig }; 358 + } 359 + 360 + public getCellClass(mark: 'X' | 'O' | null, isWinning: boolean, isDisabled: boolean): string { 361 + const classes = ['game-cell']; 362 + 363 + if (isWinning) classes.push('winning'); 364 + if (isDisabled) classes.push('disabled'); 365 + 366 + return classes.join(' '); 367 + } 368 + 369 + public getMarkClass(mark: 'X' | 'O'): string { 370 + return mark === 'X' ? 'mark-x' : 'mark-o'; 371 + } 372 + 373 + public getStatusClass(status: 'playing' | 'winner' | 'draw'): string { 374 + const classes = ['game-status']; 375 + 376 + if (status === 'winner') classes.push('winner'); 377 + if (status === 'draw') classes.push('draw'); 378 + 379 + return classes.join(' '); 380 + } 381 + } 382 + 383 + export function createStylingManager(): StylingManager { 384 + return new StylingManager(); 385 + } 386 + 387 + export function generateInlineStyles(config: StyleConfig): Record<string, string> { 388 + return { 389 + gameBoard: ` 390 + display: grid; 391 + grid-template-columns: repeat(3, ${config.cellSize}px); 392 + grid-template-rows: repeat(3, ${config.cellSize}px); 393 + gap: ${config.gap}px; 394 + background: #ffffff; 395 + padding: 20px; 396 + border-radius: ${config.borderRadius}px; 397 + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); 398 + border: 2px solid #e1e5e9; 399 + `, 400 + gameCell: ` 401 + width: ${config.cellSize}px; 402 + height: ${config.cellSize}px; 403 + background: #f8f9fa; 404 + border: 2px solid #dee2e6; 405 + border-radius: ${config.borderRadius / 2}px; 406 + display: flex; 407 + align-items: center; 408 + justify-content: center; 409 + font-size: ${config.fontSize}px; 410 + font-weight: bold; 411 + cursor: pointer; 412 + transition: all 0.2s ease; 413 + user-select: none; 414 + `, 415 + markX: 'color: #007bff; text-shadow: 2px 2px 4px rgba(0, 123, 255, 0.3);', 416 + markO: 'color: #dc3545; text-shadow: 2px 2px 4px rgba(220, 53, 69, 0.3);', 417 + winningCell: 'background: #28a745 !important; border-color: #1e7e34 !important;', 418 + winningMark: 'color: #ffffff !important; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);' 419 + }; 420 + } 421 + 422 + /** @internal Phoenix VCS traceability — do not remove. */ 423 + export const _phoenix = { 424 + iu_id: 'b032e839d230050d790cdd029bcba6dba9f68d18afb93b2060152271b2b434ef', 425 + name: 'Styling', 426 + risk_tier: 'medium', 427 + canon_ids: [6 as const], 428 + } as const;
+23
examples/tictactoe/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "declaration": true, 7 + "outDir": "dist", 8 + "rootDir": "src", 9 + "strict": true, 10 + "esModuleInterop": true, 11 + "skipLibCheck": true, 12 + "forceConsistentCasingInFileNames": true, 13 + "resolveJsonModule": true, 14 + "sourceMap": true 15 + }, 16 + "include": [ 17 + "src/**/*" 18 + ], 19 + "exclude": [ 20 + "node_modules", 21 + "dist" 22 + ] 23 + }
+7
examples/tictactoe/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + 3 + export default defineConfig({ 4 + test: { 5 + include: ['src/**/__tests__/**/*.test.ts'], 6 + }, 7 + });
+225
src/canonicalizer-llm.ts
··· 1 + /** 2 + * LLM-Enhanced Canonicalization 3 + * 4 + * When an LLM provider is available, uses it to extract richer 5 + * canonical nodes from clauses. Falls back to rule-based extraction 6 + * when no provider is configured. 7 + * 8 + * The LLM extracts structured JSON: type, statement, tags, and 9 + * cross-references between nodes — producing a higher-quality 10 + * canonical graph than regex patterns alone. 11 + */ 12 + 13 + import type { Clause } from './models/clause.js'; 14 + import type { CanonicalNode } from './models/canonical.js'; 15 + import { CanonicalType } from './models/canonical.js'; 16 + import type { LLMProvider } from './llm/provider.js'; 17 + import { sha256 } from './semhash.js'; 18 + import { extractCanonicalNodes as extractRuleBased } from './canonicalizer.js'; 19 + 20 + const CANON_SYSTEM_PROMPT = `You are a requirements engineer extracting structured canonical nodes from specification text. 21 + 22 + For each meaningful statement, extract a JSON object with: 23 + - type: one of REQUIREMENT, CONSTRAINT, INVARIANT, DEFINITION 24 + - statement: the normalized canonical statement (clear, unambiguous, one idea) 25 + - tags: array of key domain terms (lowercase, no stop words) 26 + 27 + Rules: 28 + - REQUIREMENT: something the system must do (capabilities, features) 29 + - CONSTRAINT: something the system must NOT do, or limits/bounds 30 + - INVARIANT: something that must ALWAYS or NEVER hold 31 + - DEFINITION: defines a term or concept 32 + 33 + Output a JSON array of objects. No markdown fences, no explanation. 34 + Only extract nodes where there is a clear, actionable statement. 35 + Skip headings, meta-text, and filler.`; 36 + 37 + interface LLMCanonNode { 38 + type: string; 39 + statement: string; 40 + tags: string[]; 41 + } 42 + 43 + /** 44 + * Extract canonical nodes using LLM when available, falling back to rules. 45 + */ 46 + export async function extractCanonicalNodesLLM( 47 + clauses: Clause[], 48 + llm: LLMProvider | null, 49 + ): Promise<CanonicalNode[]> { 50 + if (!llm) { 51 + return extractRuleBased(clauses); 52 + } 53 + 54 + try { 55 + const nodes = await extractWithLLM(clauses, llm); 56 + // Fall back if LLM produced nothing useful 57 + if (nodes.length === 0) { 58 + return extractRuleBased(clauses); 59 + } 60 + return nodes; 61 + } catch (err) { 62 + // Fall back to rule-based on any LLM failure 63 + return extractRuleBased(clauses); 64 + } 65 + } 66 + 67 + async function extractWithLLM( 68 + clauses: Clause[], 69 + llm: LLMProvider, 70 + ): Promise<CanonicalNode[]> { 71 + // Batch clauses into groups to avoid token limits 72 + const BATCH_SIZE = 20; 73 + const allNodes: CanonicalNode[] = []; 74 + 75 + for (let i = 0; i < clauses.length; i += BATCH_SIZE) { 76 + const batch = clauses.slice(i, i + BATCH_SIZE); 77 + const batchNodes = await extractBatch(batch, llm); 78 + allNodes.push(...batchNodes); 79 + } 80 + 81 + // Link nodes that share terms 82 + linkNodesByTerms(allNodes); 83 + 84 + return allNodes; 85 + } 86 + 87 + async function extractBatch( 88 + clauses: Clause[], 89 + llm: LLMProvider, 90 + ): Promise<CanonicalNode[]> { 91 + // Build prompt with clause text 92 + const prompt = buildCanonPrompt(clauses); 93 + 94 + const response = await llm.generate(prompt, { 95 + system: CANON_SYSTEM_PROMPT, 96 + temperature: 0.1, 97 + maxTokens: 4096, 98 + }); 99 + 100 + // Parse LLM response 101 + const parsed = parseLLMResponse(response); 102 + 103 + // Convert to CanonicalNodes with provenance 104 + return parsed.map((item, idx) => { 105 + const clauseIdx = Math.min(idx, clauses.length - 1); 106 + const sourceClause = findBestSourceClause(item, clauses) ?? clauses[clauseIdx]; 107 + 108 + const type = parseCanonType(item.type); 109 + const canonId = sha256([type, item.statement, sourceClause.clause_id].join('\x00')); 110 + 111 + return { 112 + canon_id: canonId, 113 + type, 114 + statement: item.statement, 115 + source_clause_ids: [sourceClause.clause_id], 116 + linked_canon_ids: [], 117 + tags: item.tags || [], 118 + }; 119 + }); 120 + } 121 + 122 + function buildCanonPrompt(clauses: Clause[]): string { 123 + const lines: string[] = []; 124 + lines.push('Extract canonical nodes from the following spec clauses:'); 125 + lines.push(''); 126 + 127 + for (const clause of clauses) { 128 + const section = clause.section_path.join(' > '); 129 + lines.push(`--- Clause [${section}] ---`); 130 + lines.push(clause.raw_text.trim()); 131 + lines.push(''); 132 + } 133 + 134 + lines.push('Output a JSON array of canonical nodes.'); 135 + return lines.join('\n'); 136 + } 137 + 138 + function parseLLMResponse(raw: string): LLMCanonNode[] { 139 + let text = raw.trim(); 140 + 141 + // Strip markdown fences 142 + const fenceMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/); 143 + if (fenceMatch) { 144 + text = fenceMatch[1]; 145 + } 146 + 147 + // Find JSON array 148 + const arrayStart = text.indexOf('['); 149 + const arrayEnd = text.lastIndexOf(']'); 150 + if (arrayStart === -1 || arrayEnd === -1) return []; 151 + 152 + try { 153 + const parsed = JSON.parse(text.slice(arrayStart, arrayEnd + 1)); 154 + if (!Array.isArray(parsed)) return []; 155 + 156 + return parsed.filter((item: unknown): item is LLMCanonNode => { 157 + if (!item || typeof item !== 'object') return false; 158 + const obj = item as Record<string, unknown>; 159 + return typeof obj.type === 'string' && 160 + typeof obj.statement === 'string' && 161 + obj.statement.length > 0; 162 + }).map(item => ({ 163 + type: item.type, 164 + statement: item.statement, 165 + tags: Array.isArray(item.tags) ? item.tags.filter((t: unknown) => typeof t === 'string') : [], 166 + })); 167 + } catch { 168 + return []; 169 + } 170 + } 171 + 172 + function parseCanonType(raw: string): CanonicalType { 173 + const upper = raw.toUpperCase().trim(); 174 + switch (upper) { 175 + case 'REQUIREMENT': return CanonicalType.REQUIREMENT; 176 + case 'CONSTRAINT': return CanonicalType.CONSTRAINT; 177 + case 'INVARIANT': return CanonicalType.INVARIANT; 178 + case 'DEFINITION': return CanonicalType.DEFINITION; 179 + default: return CanonicalType.REQUIREMENT; 180 + } 181 + } 182 + 183 + /** 184 + * Find the clause that best matches a canonical node by term overlap. 185 + */ 186 + function findBestSourceClause(node: LLMCanonNode, clauses: Clause[]): Clause | null { 187 + let bestClause: Clause | null = null; 188 + let bestScore = 0; 189 + 190 + const nodeTerms = new Set( 191 + (node.tags || []).concat( 192 + node.statement.toLowerCase().split(/\s+/).filter(w => w.length > 3) 193 + ) 194 + ); 195 + 196 + for (const clause of clauses) { 197 + const clauseTerms = clause.normalized_text.toLowerCase().split(/\s+/); 198 + const overlap = clauseTerms.filter(t => nodeTerms.has(t)).length; 199 + if (overlap > bestScore) { 200 + bestScore = overlap; 201 + bestClause = clause; 202 + } 203 + } 204 + 205 + return bestClause; 206 + } 207 + 208 + /** 209 + * Link canonical nodes that share significant terms. 210 + */ 211 + function linkNodesByTerms(nodes: CanonicalNode[]): void { 212 + for (let i = 0; i < nodes.length; i++) { 213 + for (let j = i + 1; j < nodes.length; j++) { 214 + const shared = nodes[i].tags.filter(t => nodes[j].tags.includes(t)); 215 + if (shared.length >= 2) { 216 + if (!nodes[i].linked_canon_ids.includes(nodes[j].canon_id)) { 217 + nodes[i].linked_canon_ids.push(nodes[j].canon_id); 218 + } 219 + if (!nodes[j].linked_canon_ids.includes(nodes[i].canon_id)) { 220 + nodes[j].linked_canon_ids.push(nodes[i].canon_id); 221 + } 222 + } 223 + } 224 + } 225 + }
+144
src/classifier-llm.ts
··· 1 + /** 2 + * LLM-Enhanced Change Classifier 3 + * 4 + * When the rule-based classifier produces a D (uncertain) classification, 5 + * optionally escalates to an LLM for a more informed decision. 6 + * 7 + * This reduces the D-rate by providing semantic understanding that 8 + * heuristics alone cannot achieve. 9 + */ 10 + 11 + import type { ClauseDiff } from './models/clause.js'; 12 + import type { CanonicalNode } from './models/canonical.js'; 13 + import type { ChangeClassification } from './models/classification.js'; 14 + import { ChangeClass } from './models/classification.js'; 15 + import type { LLMProvider } from './llm/provider.js'; 16 + import { classifyChange } from './classifier.js'; 17 + 18 + const CLASSIFY_SYSTEM_PROMPT = `You are a change classification expert for a version control system. 19 + 20 + Classify the following spec change into exactly one category: 21 + - A: Trivial change (formatting, whitespace, rewording with identical meaning) 22 + - B: Local semantic change (meaning changed but only affects this clause) 23 + - C: Contextual semantic shift (change affects meaning of related clauses/requirements) 24 + - D: Truly uncertain (cannot determine impact without more context) 25 + 26 + Respond with ONLY a single letter: A, B, C, or D. 27 + Be conservative: prefer B over D when there's reasonable clarity. 28 + Only use D when the change is genuinely ambiguous.`; 29 + 30 + export interface LLMClassifierOptions { 31 + /** LLM provider for D-class resolution. */ 32 + llm: LLMProvider; 33 + /** Only escalate D-class to LLM. Default: true */ 34 + dClassOnly?: boolean; 35 + } 36 + 37 + /** 38 + * Classify a change, optionally using LLM for uncertain (D) results. 39 + */ 40 + export async function classifyChangeWithLLM( 41 + diff: ClauseDiff, 42 + canonBefore: CanonicalNode[], 43 + canonAfter: CanonicalNode[], 44 + warmBefore: string | undefined, 45 + warmAfter: string | undefined, 46 + options?: LLMClassifierOptions, 47 + ): Promise<ChangeClassification> { 48 + // First: rule-based classification 49 + const result = classifyChange(diff, canonBefore, canonAfter, warmBefore, warmAfter); 50 + 51 + // If no LLM or not D-class, return as-is 52 + if (!options?.llm) return result; 53 + if (options.dClassOnly !== false && result.change_class !== ChangeClass.D) { 54 + return result; 55 + } 56 + 57 + // Escalate to LLM 58 + try { 59 + const llmClass = await resolveWithLLM(diff, options.llm); 60 + return { 61 + ...result, 62 + change_class: llmClass, 63 + confidence: llmClass === ChangeClass.D ? result.confidence : Math.max(result.confidence, 0.75), 64 + llm_resolved: true, 65 + }; 66 + } catch { 67 + // LLM failed — keep the rule-based result 68 + return result; 69 + } 70 + } 71 + 72 + /** 73 + * Batch-classify changes, escalating D-class to LLM. 74 + */ 75 + export async function classifyChangesWithLLM( 76 + diffs: ClauseDiff[], 77 + canonBefore: CanonicalNode[], 78 + canonAfter: CanonicalNode[], 79 + warmBefore: Map<string, string> | undefined, 80 + warmAfter: Map<string, string> | undefined, 81 + options?: LLMClassifierOptions, 82 + ): Promise<ChangeClassification[]> { 83 + const results: ChangeClassification[] = []; 84 + 85 + for (const diff of diffs) { 86 + const wb = diff.clause_id_before ? warmBefore?.get(diff.clause_id_before) : undefined; 87 + const wa = diff.clause_id_after ? warmAfter?.get(diff.clause_id_after) : undefined; 88 + results.push(await classifyChangeWithLLM(diff, canonBefore, canonAfter, wb, wa, options)); 89 + } 90 + 91 + return results; 92 + } 93 + 94 + async function resolveWithLLM(diff: ClauseDiff, llm: LLMProvider): Promise<ChangeClass> { 95 + const prompt = buildClassifyPrompt(diff); 96 + 97 + const response = await llm.generate(prompt, { 98 + system: CLASSIFY_SYSTEM_PROMPT, 99 + temperature: 0, 100 + maxTokens: 8, 101 + }); 102 + 103 + const letter = response.trim().toUpperCase().charAt(0); 104 + 105 + switch (letter) { 106 + case 'A': return ChangeClass.A; 107 + case 'B': return ChangeClass.B; 108 + case 'C': return ChangeClass.C; 109 + case 'D': return ChangeClass.D; 110 + default: return ChangeClass.D; // Unrecognized → stay uncertain 111 + } 112 + } 113 + 114 + function buildClassifyPrompt(diff: ClauseDiff): string { 115 + const lines: string[] = []; 116 + 117 + lines.push('Classify the following spec change:'); 118 + lines.push(''); 119 + 120 + if (diff.clause_before) { 121 + lines.push('## Before:'); 122 + lines.push(`Section: ${diff.section_path_before?.join(' > ') || '(root)'}`); 123 + lines.push(diff.clause_before.raw_text.trim()); 124 + lines.push(''); 125 + } 126 + 127 + if (diff.clause_after) { 128 + lines.push('## After:'); 129 + lines.push(`Section: ${diff.section_path_after?.join(' > ') || '(root)'}`); 130 + lines.push(diff.clause_after.raw_text.trim()); 131 + lines.push(''); 132 + } 133 + 134 + if (!diff.clause_before) { 135 + lines.push('This is a NEW clause (added).'); 136 + } else if (!diff.clause_after) { 137 + lines.push('This clause was REMOVED.'); 138 + } 139 + 140 + lines.push(''); 141 + lines.push('Respond with A, B, C, or D.'); 142 + 143 + return lines.join('\n'); 144 + }
+17 -6
src/cli.ts
··· 22 22 23 23 // Phase B 24 24 import { extractCanonicalNodes } from './canonicalizer.js'; 25 + import { extractCanonicalNodesLLM } from './canonicalizer-llm.js'; 25 26 import { computeWarmHashes } from './warm-hasher.js'; 26 27 import { classifyChanges } from './classifier.js'; 28 + import { classifyChangesWithLLM } from './classifier-llm.js'; 27 29 import { DRateTracker } from './d-rate.js'; 28 30 import { BootstrapStateMachine } from './bootstrap.js'; 29 31 ··· 287 289 console.log(); 288 290 289 291 // Step 2: Canonicalization 290 - console.log(` ${dim('Phase B:')} Canonicalization + warm context hashing`); 292 + const llmEarly = resolveProvider(phoenixDir); 293 + if (llmEarly) { 294 + console.log(` ${dim('Phase B:')} Canonicalization + warm context hashing ${dim(`(LLM: ${llmEarly.name}/${llmEarly.model})`)}`); 295 + } else { 296 + console.log(` ${dim('Phase B:')} Canonicalization + warm context hashing ${dim('(rule-based)')}`); 297 + } 291 298 292 299 // Collect all clauses 293 300 const allClauses: Clause[] = []; ··· 296 303 allClauses.push(...specStore.getClauses(docId)); 297 304 } 298 305 299 - // Extract canonical nodes 300 - const canonNodes = extractCanonicalNodes(allClauses); 306 + // Extract canonical nodes (LLM-enhanced when available) 307 + const canonNodes = await extractCanonicalNodesLLM(allClauses, llmEarly); 301 308 canonStore.saveNodes(canonNodes); 302 309 console.log(` ${green('✔')} ${canonNodes.length} canonical nodes extracted`); 303 310 ··· 955 962 } 956 963 } 957 964 958 - function cmdCanonicalize(): void { 965 + async function cmdCanonicalize(): Promise<void> { 959 966 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 960 967 const specStore = new SpecStore(phoenixDir); 961 968 const canonStore = new CanonicalStore(phoenixDir); ··· 972 979 return; 973 980 } 974 981 982 + const llm = resolveProvider(phoenixDir); 975 983 console.log(bold('📐 Canonicalization')); 984 + if (llm) { 985 + console.log(` ${dim(`LLM: ${llm.name}/${llm.model}`)}`); 986 + } 976 987 console.log(); 977 988 978 - const canonNodes = extractCanonicalNodes(allClauses); 989 + const canonNodes = await extractCanonicalNodesLLM(allClauses, llm); 979 990 canonStore.saveNodes(canonNodes); 980 991 981 992 console.log(` ${green('✔')} ${canonNodes.length} canonical nodes extracted from ${allClauses.length} clauses`); ··· 1201 1212 break; 1202 1213 case 'canonicalize': 1203 1214 case 'canon-extract': 1204 - cmdCanonicalize(); 1215 + await cmdCanonicalize(); 1205 1216 break; 1206 1217 case 'canon': 1207 1218 cmdCanon();
+2
src/index.ts
··· 28 28 29 29 // Phase B 30 30 export { extractCanonicalNodes, extractTerms } from './canonicalizer.js'; 31 + export { extractCanonicalNodesLLM } from './canonicalizer-llm.js'; 31 32 export { contextSemhashWarm, computeWarmHashes } from './warm-hasher.js'; 32 33 export { classifyChange, classifyChanges } from './classifier.js'; 34 + export { classifyChangeWithLLM, classifyChangesWithLLM } from './classifier-llm.js'; 33 35 export { DRateTracker } from './d-rate.js'; 34 36 export { BootstrapStateMachine } from './bootstrap.js'; 35 37
+2
src/models/classification.ts
··· 38 38 /** Clause IDs involved */ 39 39 clause_id_before?: string; 40 40 clause_id_after?: string; 41 + /** Whether an LLM was used to resolve a D-class classification */ 42 + llm_resolved?: boolean; 41 43 } 42 44 43 45 export enum DRateLevel {
+287
src/scaffold.ts
··· 127 127 return lines.join('\n'); 128 128 } 129 129 130 + // ─── Web Client Detection ──────────────────────────────────────────────────── 131 + 132 + const WEB_HINTS = ['ui', 'client', 'frontend', 'web', 'page', 'view', 'html', 'css', 'style', 'lobby', 'dashboard', 'display']; 133 + 134 + /** 135 + * Detect whether a service is a web client (should serve HTML). 136 + * Heuristic: service dir or module names contain web-related terms. 137 + */ 138 + function isWebClient(svc: ServiceDescriptor): boolean { 139 + const dirWords = svc.dir.toLowerCase().split('-'); 140 + if (dirWords.some(w => WEB_HINTS.includes(w))) return true; 141 + 142 + const moduleWords = svc.modules.flatMap(m => m.replace(/\.ts$/, '').toLowerCase().split('-')); 143 + const webModuleCount = moduleWords.filter(w => WEB_HINTS.includes(w)).length; 144 + return webModuleCount >= 2; 145 + } 146 + 130 147 // ─── Service Server ────────────────────────────────────────────────────────── 131 148 132 149 function generateServiceServer(svc: ServiceDescriptor): string { 150 + if (isWebClient(svc)) { 151 + return generateWebClientServer(svc); 152 + } 153 + return generateApiServer(svc); 154 + } 155 + 156 + function generateApiServer(svc: ServiceDescriptor): string { 133 157 const lines: string[] = []; 134 158 const portVar = `${svc.dir.toUpperCase().replace(/-/g, '_')}_PORT`; 135 159 ··· 278 302 return lines.join('\n'); 279 303 } 280 304 305 + // ─── Web Client Server ────────────────────────────────────────────────────── 306 + 307 + function generateWebClientServer(svc: ServiceDescriptor): string { 308 + const portVar = `${svc.dir.toUpperCase().replace(/-/g, '_')}_PORT`; 309 + 310 + // Categorize modules by role 311 + const styleModules = svc.modules.filter(m => /styl/i.test(m) || /css/i.test(m) || /theme/i.test(m)); 312 + const uiModules = svc.modules.filter(m => !styleModules.includes(m)); 313 + 314 + const imports = svc.modules.map(mod => { 315 + const modName = mod.replace(/\.ts$/, ''); 316 + const importName = toCamelCase(modName); 317 + return { modName, importName }; 318 + }); 319 + 320 + const lines: string[] = []; 321 + 322 + lines.push(`/**`); 323 + lines.push(` * ${svc.name} — Web Server`); 324 + lines.push(` *`); 325 + lines.push(` * AUTO-GENERATED by Phoenix VCS`); 326 + lines.push(` * Serves the web client HTML, plus health/metrics/modules endpoints.`); 327 + lines.push(` */`); 328 + lines.push(''); 329 + lines.push(`import { createServer, IncomingMessage, ServerResponse } from 'node:http';`); 330 + lines.push(''); 331 + 332 + for (const { modName, importName } of imports) { 333 + lines.push(`import * as ${importName} from './${modName}.js';`); 334 + } 335 + lines.push(''); 336 + 337 + // Metrics 338 + lines.push(`// ─── Metrics ─────────────────────────────────────────────────────────────────`); 339 + lines.push(''); 340 + lines.push(`const metrics = {`); 341 + lines.push(` requests_total: 0,`); 342 + lines.push(` requests_by_path: {} as Record<string, number>,`); 343 + lines.push(` errors_total: 0,`); 344 + lines.push(` uptime_start: Date.now(),`); 345 + lines.push(`};`); 346 + lines.push(''); 347 + 348 + // Module registry 349 + lines.push(`// ─── Module Registry ─────────────────────────────────────────────────────────`); 350 + lines.push(''); 351 + lines.push(`const modules = {`); 352 + for (const { modName, importName } of imports) { 353 + lines.push(` '${modName}': ${importName},`); 354 + } 355 + lines.push(`};`); 356 + lines.push(''); 357 + 358 + // HTML renderer 359 + lines.push(`// ─── HTML Renderer ───────────────────────────────────────────────────────────`); 360 + lines.push(''); 361 + lines.push(`function renderPage(): string {`); 362 + lines.push(` // Collect CSS from style modules`); 363 + lines.push(` let css = '';`); 364 + 365 + for (const mod of styleModules) { 366 + const importName = toCamelCase(mod.replace(/\.ts$/, '')); 367 + // Try common patterns: generateCSS(), getCSS(), css, styles 368 + lines.push(` try {`); 369 + lines.push(` const styleMod = ${importName} as Record<string, unknown>;`); 370 + lines.push(` for (const key of Object.keys(styleMod)) {`); 371 + lines.push(` const val = styleMod[key];`); 372 + lines.push(` if (typeof val === 'function' && /css|style/i.test(key)) {`); 373 + lines.push(` const result = (val as Function)();`); 374 + lines.push(` if (typeof result === 'string') css += result;`); 375 + lines.push(` else if (result && typeof result === 'object' && 'generateCSS' in result) {`); 376 + lines.push(` css += (result as { generateCSS: () => string }).generateCSS();`); 377 + lines.push(` }`); 378 + lines.push(` }`); 379 + lines.push(` }`); 380 + lines.push(` } catch { /* style module may not have expected exports */ }`); 381 + } 382 + 383 + if (styleModules.length === 0) { 384 + lines.push(` css = 'body { font-family: system-ui, sans-serif; margin: 2rem; }';`); 385 + } 386 + lines.push(''); 387 + 388 + lines.push(` // Collect HTML from UI modules`); 389 + lines.push(` const sections: string[] = [];`); 390 + 391 + for (const mod of uiModules) { 392 + const importName = toCamelCase(mod.replace(/\.ts$/, '')); 393 + const displayName = mod.replace(/\.ts$/, '').split('-').map((w: string) => w[0].toUpperCase() + w.slice(1)).join(' '); 394 + lines.push(` try {`); 395 + lines.push(` const uiMod = ${importName} as Record<string, unknown>;`); 396 + lines.push(` for (const key of Object.keys(uiMod)) {`); 397 + lines.push(` const val = uiMod[key];`); 398 + lines.push(` // Look for factory functions that return objects with render/renderHTML`); 399 + lines.push(` if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) {`); 400 + lines.push(` try {`); 401 + lines.push(` const instance = (val as Function)();`); 402 + lines.push(` if (typeof instance === 'string' && instance.includes('<')) {`); 403 + lines.push(` sections.push(instance);`); 404 + lines.push(` } else if (instance && typeof instance === 'object') {`); 405 + lines.push(` const obj = instance as Record<string, unknown>;`); 406 + lines.push(` if (typeof obj.render === 'function') {`); 407 + lines.push(` const html = (obj.render as Function)();`); 408 + lines.push(` if (typeof html === 'string') sections.push(html);`); 409 + lines.push(` } else if (typeof obj.renderHTML === 'function') {`); 410 + lines.push(` const html = (obj.renderHTML as Function)();`); 411 + lines.push(` if (typeof html === 'string') sections.push(html);`); 412 + lines.push(` }`); 413 + lines.push(` }`); 414 + lines.push(` } catch { /* factory may require args */ }`); 415 + lines.push(` }`); 416 + lines.push(` }`); 417 + lines.push(` } catch { /* module may not have renderable exports */ }`); 418 + } 419 + lines.push(''); 420 + 421 + lines.push(` return \`<!DOCTYPE html>`); 422 + lines.push(`<html lang="en">`); 423 + lines.push(`<head>`); 424 + lines.push(` <meta charset="utf-8">`); 425 + lines.push(` <meta name="viewport" content="width=device-width, initial-scale=1">`); 426 + lines.push(` <title>${svc.name}</title>`); 427 + lines.push(` <style>\${css}</style>`); 428 + lines.push(`</head>`); 429 + lines.push(`<body>`); 430 + lines.push(` <div class="game-container">`); 431 + lines.push(` \${sections.join('\\n')}`); 432 + lines.push(` </div>`); 433 + lines.push(`</body>`); 434 + lines.push(`</html>\`;`); 435 + lines.push(`}`); 436 + lines.push(''); 437 + 438 + // Router 439 + lines.push(`// ─── Router ──────────────────────────────────────────────────────────────────`); 440 + lines.push(''); 441 + lines.push(`type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;`); 442 + lines.push(''); 443 + lines.push(`const routes: Record<string, Handler> = {`); 444 + 445 + // HTML index 446 + lines.push(` '/': (_req, res) => {`); 447 + lines.push(` res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });`); 448 + lines.push(` res.end(renderPage());`); 449 + lines.push(` },`); 450 + lines.push(''); 451 + 452 + // Health 453 + lines.push(` '/health': (_req, res) => {`); 454 + lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 455 + lines.push(` res.end(JSON.stringify({`); 456 + lines.push(` status: 'ok',`); 457 + lines.push(` service: '${svc.name}',`); 458 + lines.push(` uptime: Math.floor((Date.now() - metrics.uptime_start) / 1000),`); 459 + lines.push(` modules: Object.keys(modules),`); 460 + lines.push(` }));`); 461 + lines.push(` },`); 462 + lines.push(''); 463 + lines.push(` '/metrics': (_req, res) => {`); 464 + lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 465 + lines.push(` res.end(JSON.stringify({`); 466 + lines.push(` ...metrics,`); 467 + lines.push(` uptime_seconds: Math.floor((Date.now() - metrics.uptime_start) / 1000),`); 468 + lines.push(` }, null, 2));`); 469 + lines.push(` },`); 470 + lines.push(''); 471 + lines.push(` '/modules': (_req, res) => {`); 472 + lines.push(` const info = Object.entries(modules).map(([name, mod]) => {`); 473 + lines.push(` const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined;`); 474 + lines.push(` return {`); 475 + lines.push(` name,`); 476 + lines.push(` risk_tier: phoenix?.risk_tier ?? 'unknown',`); 477 + lines.push(` exports: Object.keys(mod).filter(k => k !== '_phoenix'),`); 478 + lines.push(` };`); 479 + lines.push(` });`); 480 + lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 481 + lines.push(` res.end(JSON.stringify(info, null, 2));`); 482 + lines.push(` },`); 483 + lines.push(`};`); 484 + lines.push(''); 485 + 486 + // Server factory 487 + lines.push(`// ─── Server ──────────────────────────────────────────────────────────────────`); 488 + lines.push(''); 489 + lines.push(`function handleRequest(req: IncomingMessage, res: ServerResponse): void {`); 490 + lines.push(` const url = req.url ?? '/';`); 491 + lines.push(` const path = url.split('?')[0];`); 492 + lines.push(''); 493 + lines.push(` metrics.requests_total++;`); 494 + lines.push(` metrics.requests_by_path[path] = (metrics.requests_by_path[path] ?? 0) + 1;`); 495 + lines.push(''); 496 + lines.push(` const handler = routes[path];`); 497 + lines.push(` if (handler) {`); 498 + lines.push(` try {`); 499 + lines.push(` handler(req, res);`); 500 + lines.push(` } catch (err) {`); 501 + lines.push(` metrics.errors_total++;`); 502 + lines.push(` res.writeHead(500, { 'Content-Type': 'application/json' });`); 503 + lines.push(` res.end(JSON.stringify({ error: String(err) }));`); 504 + lines.push(` }`); 505 + lines.push(` } else {`); 506 + lines.push(` res.writeHead(404, { 'Content-Type': 'application/json' });`); 507 + lines.push(` res.end(JSON.stringify({`); 508 + lines.push(` error: 'Not Found',`); 509 + lines.push(` path,`); 510 + lines.push(` available: Object.keys(routes),`); 511 + lines.push(` }));`); 512 + lines.push(` }`); 513 + lines.push(`}`); 514 + lines.push(''); 515 + 516 + lines.push(`export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } {`); 517 + lines.push(` const requestedPort = port ?? parseInt(process.env.${portVar} ?? process.env.PORT ?? '${svc.port}', 10);`); 518 + lines.push(` const server = createServer(handleRequest);`); 519 + lines.push(` let actualPort = requestedPort;`); 520 + lines.push(''); 521 + lines.push(` const ready = new Promise<void>(resolve => {`); 522 + lines.push(` server.listen(requestedPort, () => {`); 523 + lines.push(` const addr = server.address();`); 524 + lines.push(` if (addr && typeof addr === 'object') actualPort = addr.port;`); 525 + lines.push(` result.port = actualPort;`); 526 + lines.push(` console.log(\`${svc.name} listening on http://localhost:\${actualPort}\`);`); 527 + lines.push(` console.log(\` / — web client\`);`); 528 + lines.push(` console.log(\` /health — health check\`);`); 529 + lines.push(` console.log(\` /metrics — request metrics\`);`); 530 + lines.push(` console.log(\` /modules — registered modules\`);`); 531 + lines.push(` resolve();`); 532 + lines.push(` });`); 533 + lines.push(` });`); 534 + lines.push(''); 535 + lines.push(` const result = { server, port: actualPort, ready };`); 536 + lines.push(` return result;`); 537 + lines.push(`}`); 538 + lines.push(''); 539 + 540 + // Main 541 + lines.push(`// Start when run directly`); 542 + lines.push(`const isMain = process.argv[1]?.endsWith('/${svc.dir}/server.js') ||`); 543 + lines.push(` process.argv[1]?.endsWith('/${svc.dir}/server.ts');`); 544 + lines.push(`if (isMain) {`); 545 + lines.push(` startServer();`); 546 + lines.push(`}`); 547 + lines.push(''); 548 + 549 + return lines.join('\n'); 550 + } 551 + 281 552 // ─── Service Tests ─────────────────────────────────────────────────────────── 282 553 283 554 function generateServiceTests(svc: ServiceDescriptor): string { ··· 363 634 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/unknown\`);`); 364 635 lines.push(` expect(res.status).toBe(404);`); 365 636 lines.push(` });`); 637 + 638 + // Web client: test GET / serves HTML 639 + if (isWebClient(svc)) { 640 + lines.push(''); 641 + lines.push(` it('GET / serves HTML page', async () => {`); 642 + lines.push(` await instance.ready;`); 643 + lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/\`);`); 644 + lines.push(` expect(res.status).toBe(200);`); 645 + lines.push(` const ct = res.headers.get('content-type') ?? '';`); 646 + lines.push(` expect(ct).toContain('text/html');`); 647 + lines.push(` const body = await res.text();`); 648 + lines.push(` expect(body).toContain('<!DOCTYPE html>');`); 649 + lines.push(` expect(body).toContain('<title>${svc.name}</title>');`); 650 + lines.push(` });`); 651 + } 652 + 366 653 lines.push(`});`); 367 654 lines.push(''); 368 655
+717
tests/e2e/success-criteria.test.ts
··· 1 + /** 2 + * End-to-End Integration Tests 3 + * 4 + * Tests the full Phoenix pipeline from init → bootstrap → status, 5 + * validating PRD Section 19 Success Criteria: 6 + * 7 + * 1. Delete generated code → full regen succeeds 8 + * 2. Clause change invalidates only dependent IU subtree 9 + * 3. Boundary linter catches undeclared coupling 10 + * 4. Drift detection blocks unlabeled edits 11 + * 5. D-rate within acceptable bounds 12 + * 6. Shadow pipeline upgrade produces classified diff 13 + * 7. Compaction preserves ancestry 14 + * 8. Freeq bots perform ingest/canon/plan/regen/status safely 15 + */ 16 + import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 17 + import { 18 + mkdtempSync, mkdirSync, writeFileSync, readFileSync, 19 + existsSync, rmSync, readdirSync, cpSync, 20 + } from 'node:fs'; 21 + import { join, relative } from 'node:path'; 22 + import { tmpdir } from 'node:os'; 23 + import { execSync } from 'node:child_process'; 24 + 25 + // Core pipeline imports 26 + import { parseSpec } from '../../src/spec-parser.js'; 27 + import { diffClauses } from '../../src/diff.js'; 28 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 29 + import { computeWarmHashes } from '../../src/warm-hasher.js'; 30 + import { classifyChanges } from '../../src/classifier.js'; 31 + import { planIUs } from '../../src/iu-planner.js'; 32 + import { generateIU, generateAll } from '../../src/regen.js'; 33 + import { ManifestManager } from '../../src/manifest.js'; 34 + import { detectDrift } from '../../src/drift.js'; 35 + import { extractDependencies } from '../../src/dep-extractor.js'; 36 + import { validateBoundary, detectBoundaryChanges } from '../../src/boundary-validator.js'; 37 + import { evaluatePolicy, evaluateAllPolicies } from '../../src/policy-engine.js'; 38 + import { computeCascade } from '../../src/cascade.js'; 39 + import { runShadowPipeline } from '../../src/shadow-pipeline.js'; 40 + import { runCompaction, identifyCandidates, shouldTriggerCompaction } from '../../src/compaction.js'; 41 + import { DRateTracker } from '../../src/d-rate.js'; 42 + import { BootstrapStateMachine } from '../../src/bootstrap.js'; 43 + import { parseCommand, routeCommand, getAllCommands } from '../../src/bot-router.js'; 44 + import { deriveServices, generateScaffold } from '../../src/scaffold.js'; 45 + 46 + // Stores 47 + import { SpecStore } from '../../src/store/spec-store.js'; 48 + import { CanonicalStore } from '../../src/store/canonical-store.js'; 49 + import { EvidenceStore } from '../../src/store/evidence-store.js'; 50 + 51 + // Models 52 + import { CanonicalType } from '../../src/models/canonical.js'; 53 + import { ChangeClass, BootstrapState, DRateLevel } from '../../src/models/classification.js'; 54 + import { DriftStatus } from '../../src/models/manifest.js'; 55 + import { EvidenceKind, EvidenceStatus } from '../../src/models/evidence.js'; 56 + import type { Clause } from '../../src/models/clause.js'; 57 + import type { CanonicalNode } from '../../src/models/canonical.js'; 58 + import type { ImplementationUnit } from '../../src/models/iu.js'; 59 + import type { BotCommand } from '../../src/models/bot.js'; 60 + 61 + const fixturesDir = join(import.meta.dirname, '..', 'fixtures'); 62 + 63 + /** 64 + * Helper: sets up a complete Phoenix project in a temp directory 65 + * with spec files, runs the full bootstrap pipeline, and returns all state. 66 + */ 67 + function bootstrapProject(specFiles: string[]) { 68 + const projectRoot = mkdtempSync(join(tmpdir(), 'phoenix-e2e-')); 69 + const phoenixDir = join(projectRoot, '.phoenix'); 70 + const specDir = join(projectRoot, 'spec'); 71 + 72 + // Init 73 + mkdirSync(join(phoenixDir, 'store', 'objects'), { recursive: true }); 74 + mkdirSync(join(phoenixDir, 'graphs'), { recursive: true }); 75 + mkdirSync(join(phoenixDir, 'manifests'), { recursive: true }); 76 + mkdirSync(specDir, { recursive: true }); 77 + 78 + // Copy spec files 79 + for (const src of specFiles) { 80 + const dest = join(specDir, src.split('/').pop()!); 81 + cpSync(src, dest); 82 + } 83 + 84 + // Ingest 85 + const specStore = new SpecStore(phoenixDir); 86 + const allClauses: Clause[] = []; 87 + const specFilesList = readdirSync(specDir).filter(f => f.endsWith('.md')).map(f => join(specDir, f)); 88 + for (const sf of specFilesList) { 89 + const result = specStore.ingestDocument(sf, projectRoot); 90 + allClauses.push(...result.clauses); 91 + } 92 + 93 + // Canonicalize 94 + const canonStore = new CanonicalStore(phoenixDir); 95 + const canonNodes = extractCanonicalNodes(allClauses); 96 + canonStore.saveNodes(canonNodes); 97 + 98 + // Warm hashes 99 + const warmHashes = computeWarmHashes(allClauses, canonNodes); 100 + 101 + // Plan IUs 102 + const ius = planIUs(canonNodes, allClauses); 103 + writeFileSync(join(phoenixDir, 'graphs', 'ius.json'), JSON.stringify(ius, null, 2)); 104 + 105 + // Bootstrap state 106 + const machine = new BootstrapStateMachine(); 107 + machine.markWarmPassComplete(); 108 + writeFileSync(join(phoenixDir, 'state.json'), JSON.stringify(machine.toJSON(), null, 2)); 109 + 110 + return { 111 + projectRoot, 112 + phoenixDir, 113 + specStore, 114 + canonStore, 115 + allClauses, 116 + canonNodes, 117 + warmHashes, 118 + ius, 119 + machine, 120 + }; 121 + } 122 + 123 + /** 124 + * Helper: generate code and write to disk, returning manifest. 125 + */ 126 + async function generateAndWrite( 127 + projectRoot: string, 128 + phoenixDir: string, 129 + ius: ImplementationUnit[], 130 + ) { 131 + const manifestManager = new ManifestManager(phoenixDir); 132 + const results = await generateAll(ius); 133 + 134 + for (const result of results) { 135 + for (const [filePath, content] of result.files) { 136 + const fullPath = join(projectRoot, filePath); 137 + mkdirSync(join(fullPath, '..'), { recursive: true }); 138 + writeFileSync(fullPath, content, 'utf8'); 139 + } 140 + manifestManager.recordIU(result.manifest); 141 + } 142 + 143 + return { results, manifestManager }; 144 + } 145 + 146 + // ───────────────────────────────────────────────────────────────────────────── 147 + // SUCCESS CRITERIA TESTS (PRD §19) 148 + // ───────────────────────────────────────────────────────────────────────────── 149 + 150 + describe('E2E: Success Criteria §19.1 — Delete generated code → full regen succeeds', () => { 151 + it('regenerates all code from scratch after deletion', async () => { 152 + const ctx = bootstrapProject([ 153 + join(fixturesDir, 'spec-auth-v1.md'), 154 + join(fixturesDir, 'spec-gateway.md'), 155 + ]); 156 + 157 + // Generate initial code 158 + const { results, manifestManager } = await generateAndWrite( 159 + ctx.projectRoot, ctx.phoenixDir, ctx.ius, 160 + ); 161 + 162 + // Verify files exist 163 + for (const result of results) { 164 + for (const [filePath] of result.files) { 165 + expect(existsSync(join(ctx.projectRoot, filePath))).toBe(true); 166 + } 167 + } 168 + 169 + // Verify drift is clean 170 + const manifest1 = manifestManager.load(); 171 + const report1 = detectDrift(manifest1, ctx.projectRoot); 172 + expect(report1.drifted_count).toBe(0); 173 + expect(report1.missing_count).toBe(0); 174 + 175 + // DELETE all generated code 176 + const genDir = join(ctx.projectRoot, 'src', 'generated'); 177 + if (existsSync(genDir)) { 178 + rmSync(genDir, { recursive: true }); 179 + } 180 + 181 + // Verify files are gone → drift detects missing 182 + const report2 = detectDrift(manifest1, ctx.projectRoot); 183 + expect(report2.missing_count).toBeGreaterThan(0); 184 + 185 + // REGENERATE from scratch 186 + const manifestManager2 = new ManifestManager(ctx.phoenixDir); 187 + const results2 = await generateAll(ctx.ius); 188 + for (const result of results2) { 189 + for (const [filePath, content] of result.files) { 190 + const fullPath = join(ctx.projectRoot, filePath); 191 + mkdirSync(join(fullPath, '..'), { recursive: true }); 192 + writeFileSync(fullPath, content, 'utf8'); 193 + } 194 + manifestManager2.recordIU(result.manifest); 195 + } 196 + 197 + // Verify regenerated files exist and are clean 198 + const report3 = detectDrift(manifestManager2.load(), ctx.projectRoot); 199 + expect(report3.drifted_count).toBe(0); 200 + expect(report3.missing_count).toBe(0); 201 + expect(report3.clean_count).toBeGreaterThan(0); 202 + }); 203 + }); 204 + 205 + describe('E2E: Success Criteria §19.2 — Clause change invalidates only dependent IU subtree', () => { 206 + it('changing one spec section only affects the corresponding IU', () => { 207 + const ctx = bootstrapProject([ 208 + join(fixturesDir, 'spec-gateway.md'), 209 + ]); 210 + 211 + // Record original IU state 212 + const originalIUs = [...ctx.ius]; 213 + expect(originalIUs.length).toBeGreaterThan(1); 214 + 215 + // Modify only the Rate Limiting section 216 + const specPath = join(ctx.projectRoot, 'spec', 'spec-gateway.md'); 217 + const original = readFileSync(specPath, 'utf8'); 218 + const modified = original.replace( 219 + '- All endpoints must be rate-limited to 100 requests per minute per client', 220 + '- All endpoints must be rate-limited to 200 requests per minute per client', 221 + ); 222 + writeFileSync(specPath, modified, 'utf8'); 223 + 224 + // Re-ingest 225 + const specStore2 = new SpecStore(ctx.phoenixDir); 226 + specStore2.ingestDocument(specPath, ctx.projectRoot); 227 + const docId = relative(ctx.projectRoot, specPath); 228 + const newClauses = specStore2.getClauses(docId); 229 + 230 + // Re-canonicalize 231 + const newCanon = extractCanonicalNodes(newClauses); 232 + 233 + // Re-plan 234 + const newIUs = planIUs(newCanon, newClauses); 235 + 236 + // Find which IUs actually changed (different canon_ids) 237 + const originalIUMap = new Map(originalIUs.map(iu => [iu.name, new Set(iu.source_canon_ids)])); 238 + const changedIUs: string[] = []; 239 + const unchangedIUs: string[] = []; 240 + 241 + for (const iu of newIUs) { 242 + const origCanonIds = originalIUMap.get(iu.name); 243 + if (!origCanonIds) { 244 + changedIUs.push(iu.name); 245 + continue; 246 + } 247 + const newCanonIds = new Set(iu.source_canon_ids); 248 + const same = origCanonIds.size === newCanonIds.size && 249 + [...origCanonIds].every(id => newCanonIds.has(id)); 250 + if (same) unchangedIUs.push(iu.name); 251 + else changedIUs.push(iu.name); 252 + } 253 + 254 + // Only rate-limiting related IU should change, not all of them 255 + expect(changedIUs.length).toBeGreaterThan(0); 256 + // With a multi-section spec, at least some IUs should be unchanged 257 + // (If the planner merges all into one IU, this is still valid but trivially true) 258 + if (newIUs.length > 1) { 259 + expect(unchangedIUs.length).toBeGreaterThan(0); 260 + } 261 + }); 262 + }); 263 + 264 + describe('E2E: Success Criteria §19.3 — Boundary linter catches undeclared coupling', () => { 265 + it('detects forbidden imports and undeclared side channels', async () => { 266 + const ctx = bootstrapProject([ 267 + join(fixturesDir, 'spec-gateway.md'), 268 + ]); 269 + 270 + // Generate code 271 + await generateAndWrite(ctx.projectRoot, ctx.phoenixDir, ctx.ius); 272 + 273 + // Now inject bad code into a generated file 274 + const iu = ctx.ius[0]; 275 + const filePath = iu.output_files[0]; 276 + const fullPath = join(ctx.projectRoot, filePath); 277 + 278 + const badCode = ` 279 + import axios from 'axios'; 280 + import { adminSecret } from '../internal/admin.js'; 281 + const dbUrl = process.env.SECRET_DB_URL; 282 + export function badHandler() { return axios.get(dbUrl!); } 283 + export const _phoenix = { iu_id: '${iu.iu_id}', name: '${iu.name}', risk_tier: '${iu.risk_tier}', canon_ids: [0 as const] } as const; 284 + `; 285 + writeFileSync(fullPath, badCode, 'utf8'); 286 + 287 + // Set up forbidden boundary policy 288 + const iuWithPolicy = { 289 + ...iu, 290 + boundary_policy: { 291 + code: { 292 + allowed_ius: [], 293 + allowed_packages: [], 294 + forbidden_ius: [], 295 + forbidden_packages: ['axios'], 296 + forbidden_paths: ['../internal/**'], 297 + }, 298 + side_channels: { 299 + databases: [], queues: [], caches: [], 300 + config: [], external_apis: [], files: [], 301 + }, 302 + }, 303 + }; 304 + 305 + const source = readFileSync(fullPath, 'utf8'); 306 + const depGraph = extractDependencies(source, filePath); 307 + const diagnostics = validateBoundary(depGraph, iuWithPolicy); 308 + 309 + // Should catch forbidden package, forbidden path, undeclared side channel 310 + expect(diagnostics.length).toBeGreaterThanOrEqual(2); 311 + const categories = diagnostics.map(d => d.category); 312 + expect(categories).toContain('dependency_violation'); 313 + }); 314 + }); 315 + 316 + describe('E2E: Success Criteria §19.4 — Drift detection blocks unlabeled edits', () => { 317 + it('detects manual edits and reports drift', async () => { 318 + const ctx = bootstrapProject([ 319 + join(fixturesDir, 'spec-auth-v1.md'), 320 + ]); 321 + 322 + const { manifestManager } = await generateAndWrite( 323 + ctx.projectRoot, ctx.phoenixDir, ctx.ius, 324 + ); 325 + 326 + // Clean baseline 327 + const report1 = detectDrift(manifestManager.load(), ctx.projectRoot); 328 + expect(report1.drifted_count).toBe(0); 329 + 330 + // Make a manual edit to a generated file 331 + const firstIU = ctx.ius[0]; 332 + const filePath = firstIU.output_files[0]; 333 + const fullPath = join(ctx.projectRoot, filePath); 334 + const original = readFileSync(fullPath, 'utf8'); 335 + writeFileSync(fullPath, '// MANUAL EDIT\n' + original, 'utf8'); 336 + 337 + // Drift detection catches it 338 + const report2 = detectDrift(manifestManager.load(), ctx.projectRoot); 339 + expect(report2.drifted_count).toBe(1); 340 + expect(report2.summary).toContain('DRIFT DETECTED'); 341 + 342 + // The drifted entry should point to the correct file 343 + const drifted = report2.entries.filter(e => e.status === DriftStatus.DRIFTED); 344 + expect(drifted.length).toBe(1); 345 + expect(drifted[0].file_path).toBe(filePath); 346 + }); 347 + 348 + it('detects missing files as drift', async () => { 349 + const ctx = bootstrapProject([ 350 + join(fixturesDir, 'spec-auth-v1.md'), 351 + ]); 352 + 353 + const { manifestManager } = await generateAndWrite( 354 + ctx.projectRoot, ctx.phoenixDir, ctx.ius, 355 + ); 356 + 357 + // Delete a generated file 358 + const filePath = ctx.ius[0].output_files[0]; 359 + const fullPath = join(ctx.projectRoot, filePath); 360 + rmSync(fullPath); 361 + 362 + const report = detectDrift(manifestManager.load(), ctx.projectRoot); 363 + expect(report.missing_count).toBe(1); 364 + const missing = report.entries.filter(e => e.status === DriftStatus.MISSING); 365 + expect(missing[0].file_path).toBe(filePath); 366 + }); 367 + }); 368 + 369 + describe('E2E: Success Criteria §19.5 — D-rate within acceptable bounds', () => { 370 + it('standard spec changes produce D-rate within target', () => { 371 + // v1 → v2 evolution 372 + const v1 = readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'); 373 + const v2 = readFileSync(join(fixturesDir, 'spec-auth-v2.md'), 'utf8'); 374 + 375 + const clauses1 = parseSpec(v1, 'spec/auth.md'); 376 + const clauses2 = parseSpec(v2, 'spec/auth.md'); 377 + const canon1 = extractCanonicalNodes(clauses1); 378 + const canon2 = extractCanonicalNodes(clauses2); 379 + const warm1 = computeWarmHashes(clauses1, canon1); 380 + const warm2 = computeWarmHashes(clauses2, canon2); 381 + const diffs = diffClauses(clauses1, clauses2); 382 + const classifications = classifyChanges(diffs, canon1, canon2, warm1, warm2); 383 + 384 + // Track D-rate 385 + const tracker = new DRateTracker(100); 386 + for (const c of classifications) { 387 + tracker.recordOne(c.change_class); 388 + } 389 + 390 + const status = tracker.getStatus(); 391 + // D-rate should be ≤15% (ALARM threshold) for a well-structured spec change 392 + expect(status.rate).toBeLessThanOrEqual(0.15); 393 + }); 394 + 395 + it('bootstrap state machine transitions correctly with good D-rate', () => { 396 + const machine = new BootstrapStateMachine(); 397 + expect(machine.getState()).toBe(BootstrapState.BOOTSTRAP_COLD); 398 + expect(machine.shouldSuppressAlarms()).toBe(true); 399 + 400 + machine.markWarmPassComplete(); 401 + expect(machine.getState()).toBe(BootstrapState.BOOTSTRAP_WARMING); 402 + expect(machine.shouldDowngradeSeverity()).toBe(true); 403 + 404 + // Good D-rate → transition to STEADY_STATE 405 + const tracker = new DRateTracker(50); 406 + for (let i = 0; i < 45; i++) tracker.recordOne(ChangeClass.B); 407 + for (let i = 0; i < 5; i++) tracker.recordOne(ChangeClass.A); 408 + 409 + machine.evaluateTransition(tracker.getStatus()); 410 + expect(machine.getState()).toBe(BootstrapState.STEADY_STATE); 411 + expect(machine.shouldSuppressAlarms()).toBe(false); 412 + expect(machine.shouldDowngradeSeverity()).toBe(false); 413 + }); 414 + }); 415 + 416 + describe('E2E: Success Criteria §19.6 — Shadow pipeline upgrade produces classified diff', () => { 417 + it('classifies identical pipelines as SAFE', () => { 418 + const clauses = parseSpec(readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'), 'auth.md'); 419 + const canon = extractCanonicalNodes(clauses); 420 + 421 + const oldP = { pipeline_id: 'v1', model_id: 'gpt-4', promptpack_version: '1.0', extraction_rules_version: '1', diff_policy_version: '1' }; 422 + const newP = { pipeline_id: 'v2', model_id: 'gpt-4o', promptpack_version: '1.1', extraction_rules_version: '1', diff_policy_version: '1' }; 423 + 424 + const result = runShadowPipeline(oldP, newP, canon, canon); 425 + expect(result.classification).toBe('SAFE'); 426 + expect(result.metrics.node_change_pct).toBe(0); 427 + expect(result.metrics.orphan_nodes).toBe(0); 428 + }); 429 + 430 + it('classifies major changes as COMPACTION_EVENT or REJECT', () => { 431 + const v1 = parseSpec(readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'), 'auth.md'); 432 + const canonV1 = extractCanonicalNodes(v1); 433 + 434 + // Simulate a drastically different canonical graph from new pipeline 435 + const canonV2 = canonV1.slice(0, 1); // keep only first node 436 + 437 + const oldP = { pipeline_id: 'v1', model_id: 'gpt-4', promptpack_version: '1', extraction_rules_version: '1', diff_policy_version: '1' }; 438 + const newP = { pipeline_id: 'v2', model_id: 'claude-4', promptpack_version: '2', extraction_rules_version: '2', diff_policy_version: '2' }; 439 + 440 + const result = runShadowPipeline(oldP, newP, canonV1, canonV2); 441 + // Should not be SAFE since we lost most nodes 442 + expect(['COMPACTION_EVENT', 'REJECT']).toContain(result.classification); 443 + expect(result.metrics.node_change_pct).toBeGreaterThan(0); 444 + }); 445 + }); 446 + 447 + describe('E2E: Success Criteria §19.7 — Compaction preserves ancestry', () => { 448 + it('never deletes node headers, provenance edges, approvals, or signatures', () => { 449 + const objects = [ 450 + // Should be preserved (critical types) 451 + { object_id: 'n1', object_type: 'node_header', age_days: 90, size_bytes: 100, preserve: true }, 452 + { object_id: 'p1', object_type: 'provenance_edge', age_days: 90, size_bytes: 50, preserve: true }, 453 + { object_id: 'a1', object_type: 'approval', age_days: 90, size_bytes: 200, preserve: true }, 454 + { object_id: 's1', object_type: 'signature', age_days: 90, size_bytes: 300, preserve: true }, 455 + // Should be compacted (old blobs) 456 + { object_id: 'b1', object_type: 'clause_body', age_days: 60, size_bytes: 5000, preserve: false }, 457 + { object_id: 'b2', object_type: 'generated_blob', age_days: 60, size_bytes: 10000, preserve: false }, 458 + // Should be kept (recent) 459 + { object_id: 'b3', object_type: 'clause_body', age_days: 10, size_bytes: 3000, preserve: false }, 460 + ]; 461 + 462 + const event = runCompaction(objects, 'size_threshold', 30); 463 + 464 + // Only old non-preserved blobs compacted 465 + expect(event.nodes_compacted).toBe(2); // b1 and b2 466 + expect(event.bytes_freed).toBe(15000); 467 + 468 + // All critical types preserved 469 + expect(event.preserved.node_headers).toBe(1); 470 + expect(event.preserved.provenance_edges).toBe(1); 471 + expect(event.preserved.approvals).toBe(1); 472 + expect(event.preserved.signatures).toBe(1); 473 + }); 474 + }); 475 + 476 + describe('E2E: Success Criteria §19.8 — Freeq bots perform operations safely', () => { 477 + it('SpecBot: ingest is mutating and requires confirmation', () => { 478 + const parsed = parseCommand('SpecBot: ingest spec/gateway.md'); 479 + expect('error' in parsed).toBe(false); 480 + const cmd = parsed as BotCommand; 481 + expect(cmd.bot).toBe('SpecBot'); 482 + expect(cmd.action).toBe('ingest'); 483 + 484 + const resp = routeCommand(cmd); 485 + expect(resp.mutating).toBe(true); 486 + expect(resp.confirm_id).toBeTruthy(); 487 + expect(resp.intent).toContain('spec/gateway.md'); 488 + }); 489 + 490 + it('ImplBot: regen is mutating', () => { 491 + const resp = routeCommand(parseCommand('ImplBot: regen iu=AuthIU') as BotCommand); 492 + expect(resp.mutating).toBe(true); 493 + expect(resp.confirm_id).toBeTruthy(); 494 + }); 495 + 496 + it('PolicyBot: status is read-only (no confirmation needed)', () => { 497 + const resp = routeCommand(parseCommand('PolicyBot: status') as BotCommand); 498 + expect(resp.mutating).toBe(false); 499 + expect(resp.confirm_id).toBeUndefined(); 500 + }); 501 + 502 + it('all bots expose help and commands', () => { 503 + const commands = getAllCommands(); 504 + expect(Object.keys(commands).length).toBeGreaterThanOrEqual(3); 505 + 506 + for (const bot of ['SpecBot', 'ImplBot', 'PolicyBot']) { 507 + const helpResp = routeCommand({ bot: bot as any, action: 'help', args: {}, raw: `${bot}: help` }); 508 + expect(helpResp.message).toBeTruthy(); 509 + 510 + const cmdsResp = routeCommand({ bot: bot as any, action: 'commands', args: {}, raw: `${bot}: commands` }); 511 + expect(cmdsResp.message).toBeTruthy(); 512 + 513 + const verResp = routeCommand({ bot: bot as any, action: 'version', args: {}, raw: `${bot}: version` }); 514 + expect(verResp.message).toBeTruthy(); 515 + } 516 + }); 517 + 518 + it('invalid bot commands produce errors', () => { 519 + const parsed = parseCommand('UnknownBot: do_stuff'); 520 + expect('error' in parsed).toBe(true); 521 + }); 522 + }); 523 + 524 + // ───────────────────────────────────────────────────────────────────────────── 525 + // MULTI-SPEC PROJECT E2E 526 + // ───────────────────────────────────────────────────────────────────────────── 527 + 528 + describe('E2E: Multi-Spec Project Lifecycle', () => { 529 + it('bootstraps a project with multiple spec files and produces correct service structure', async () => { 530 + const ctx = bootstrapProject([ 531 + join(fixturesDir, 'spec-auth-v1.md'), 532 + join(fixturesDir, 'spec-gateway.md'), 533 + join(fixturesDir, 'spec-notifications.md'), 534 + ]); 535 + 536 + // Should have multiple IUs from different specs 537 + expect(ctx.ius.length).toBeGreaterThanOrEqual(3); 538 + 539 + // Each IU should have unique output files 540 + const allOutputFiles = ctx.ius.flatMap(iu => iu.output_files); 541 + expect(new Set(allOutputFiles).size).toBe(allOutputFiles.length); 542 + 543 + // Canon nodes should span all spec files 544 + expect(ctx.canonNodes.length).toBeGreaterThan(10); 545 + 546 + // Generate code 547 + const { results, manifestManager } = await generateAndWrite( 548 + ctx.projectRoot, ctx.phoenixDir, ctx.ius, 549 + ); 550 + 551 + // All IUs should produce code 552 + expect(results.length).toBe(ctx.ius.length); 553 + for (const result of results) { 554 + expect(result.files.size).toBeGreaterThan(0); 555 + } 556 + 557 + // Drift should be clean 558 + const report = detectDrift(manifestManager.load(), ctx.projectRoot); 559 + expect(report.drifted_count).toBe(0); 560 + expect(report.missing_count).toBe(0); 561 + 562 + // Service scaffold 563 + const services = deriveServices(ctx.ius); 564 + expect(services.length).toBeGreaterThanOrEqual(2); 565 + 566 + const scaffold = generateScaffold(services, 'test-project'); 567 + expect(scaffold.files.has('package.json')).toBe(true); 568 + expect(scaffold.files.has('tsconfig.json')).toBe(true); 569 + 570 + // Each service should have an index and server 571 + for (const svc of services) { 572 + expect(scaffold.files.has(`src/generated/${svc.dir}/index.ts`)).toBe(true); 573 + expect(scaffold.files.has(`src/generated/${svc.dir}/server.ts`)).toBe(true); 574 + } 575 + }); 576 + 577 + it('handles spec evolution: add new spec, re-bootstrap, verify incremental', async () => { 578 + // Start with one spec 579 + const ctx = bootstrapProject([ 580 + join(fixturesDir, 'spec-auth-v1.md'), 581 + ]); 582 + const initialIUCount = ctx.ius.length; 583 + 584 + await generateAndWrite(ctx.projectRoot, ctx.phoenixDir, ctx.ius); 585 + 586 + // Add a new spec 587 + cpSync( 588 + join(fixturesDir, 'spec-gateway.md'), 589 + join(ctx.projectRoot, 'spec', 'spec-gateway.md'), 590 + ); 591 + 592 + // Re-ingest all 593 + const specStore2 = new SpecStore(ctx.phoenixDir); 594 + const newClauses: Clause[] = []; 595 + const specDir = join(ctx.projectRoot, 'spec'); 596 + for (const f of readdirSync(specDir).filter(f => f.endsWith('.md'))) { 597 + const result = specStore2.ingestDocument(join(specDir, f), ctx.projectRoot); 598 + newClauses.push(...result.clauses); 599 + } 600 + 601 + // Re-canonicalize and plan 602 + const newCanon = extractCanonicalNodes(newClauses); 603 + const newIUs = planIUs(newCanon, newClauses); 604 + 605 + // Should have more IUs now 606 + expect(newIUs.length).toBeGreaterThan(initialIUCount); 607 + 608 + // Generate new code 609 + const manifestManager2 = new ManifestManager(ctx.phoenixDir); 610 + const results2 = await generateAll(newIUs); 611 + for (const result of results2) { 612 + for (const [filePath, content] of result.files) { 613 + const fullPath = join(ctx.projectRoot, filePath); 614 + mkdirSync(join(fullPath, '..'), { recursive: true }); 615 + writeFileSync(fullPath, content, 'utf8'); 616 + } 617 + manifestManager2.recordIU(result.manifest); 618 + } 619 + 620 + // Clean drift 621 + const report = detectDrift(manifestManager2.load(), ctx.projectRoot); 622 + expect(report.drifted_count).toBe(0); 623 + }); 624 + }); 625 + 626 + describe('E2E: Evidence & Cascade Pipeline', () => { 627 + it('full lifecycle: no evidence → incomplete → pass → fail → cascade block', async () => { 628 + const ctx = bootstrapProject([ 629 + join(fixturesDir, 'spec-auth-v1.md'), 630 + ]); 631 + await generateAndWrite(ctx.projectRoot, ctx.phoenixDir, ctx.ius); 632 + 633 + const iu = ctx.ius[0]; 634 + const evidenceStore = new EvidenceStore(ctx.phoenixDir); 635 + 636 + // Step 1: No evidence → INCOMPLETE 637 + const eval1 = evaluatePolicy(iu, []); 638 + expect(eval1.verdict).toBe('INCOMPLETE'); 639 + expect(eval1.missing.length).toBeGreaterThan(0); 640 + 641 + // Step 2: Submit all required evidence → PASS 642 + const passingRecords = iu.evidence_policy.required.map((kind, i) => ({ 643 + evidence_id: `ev-pass-${i}`, 644 + kind: kind as EvidenceKind, 645 + status: EvidenceStatus.PASS, 646 + iu_id: iu.iu_id, 647 + canon_ids: iu.source_canon_ids, 648 + timestamp: new Date().toISOString(), 649 + })); 650 + evidenceStore.addRecords(passingRecords); 651 + 652 + const eval2 = evaluatePolicy(iu, evidenceStore.getAll()); 653 + expect(eval2.verdict).toBe('PASS'); 654 + 655 + // Step 3: Submit a failing typecheck → FAIL 656 + evidenceStore.addRecord({ 657 + evidence_id: 'ev-fail', 658 + kind: EvidenceKind.TYPECHECK, 659 + status: EvidenceStatus.FAIL, 660 + iu_id: iu.iu_id, 661 + canon_ids: [], 662 + message: 'TS2345: Argument not assignable', 663 + timestamp: new Date(Date.now() + 1000).toISOString(), 664 + }); 665 + 666 + const eval3 = evaluatePolicy(iu, evidenceStore.getAll()); 667 + expect(eval3.verdict).toBe('FAIL'); 668 + expect(eval3.failed).toContain('typecheck'); 669 + 670 + // Step 4: Cascade should block this IU 671 + const allEvals = evaluateAllPolicies(ctx.ius, evidenceStore.getAll()); 672 + const cascadeEvents = computeCascade(allEvals, ctx.ius); 673 + expect(cascadeEvents.length).toBeGreaterThan(0); 674 + 675 + const blockActions = cascadeEvents.flatMap(e => e.actions).filter(a => a.action === 'BLOCK'); 676 + expect(blockActions.length).toBeGreaterThan(0); 677 + }); 678 + }); 679 + 680 + describe('E2E: Provenance Traceability', () => { 681 + it('traces from spec line → clause → canon node → IU → generated file', async () => { 682 + const ctx = bootstrapProject([ 683 + join(fixturesDir, 'spec-auth-v1.md'), 684 + ]); 685 + const { results } = await generateAndWrite(ctx.projectRoot, ctx.phoenixDir, ctx.ius); 686 + 687 + // Pick a requirement from the spec 688 + const authRequirement = ctx.canonNodes.find(n => 689 + n.statement.toLowerCase().includes('authenticate') && n.type === CanonicalType.REQUIREMENT, 690 + ); 691 + expect(authRequirement).toBeDefined(); 692 + 693 + // Trace to source clause 694 + expect(authRequirement!.source_clause_ids.length).toBeGreaterThan(0); 695 + const sourceClauseId = authRequirement!.source_clause_ids[0]; 696 + const sourceClause = ctx.allClauses.find(c => c.clause_id === sourceClauseId); 697 + expect(sourceClause).toBeDefined(); 698 + expect(sourceClause!.source_doc_id).toContain('auth'); 699 + 700 + // Trace to IU 701 + const containingIU = ctx.ius.find(iu => 702 + iu.source_canon_ids.includes(authRequirement!.canon_id), 703 + ); 704 + expect(containingIU).toBeDefined(); 705 + 706 + // Trace to generated file 707 + expect(containingIU!.output_files.length).toBeGreaterThan(0); 708 + const outputFile = containingIU!.output_files[0]; 709 + const fullPath = join(ctx.projectRoot, outputFile); 710 + expect(existsSync(fullPath)).toBe(true); 711 + 712 + // Generated file should contain Phoenix traceability metadata 713 + const content = readFileSync(fullPath, 'utf8'); 714 + expect(content).toContain('_phoenix'); 715 + expect(content).toContain(containingIU!.iu_id); 716 + }); 717 + });
+38
tests/fixtures/spec-gateway.md
··· 1 + # API Gateway Service 2 + 3 + The API Gateway handles all incoming HTTP requests, routing, authentication, and rate limiting. 4 + 5 + ## Authentication 6 + 7 + - The gateway must validate JWT tokens on all protected endpoints 8 + - Invalid or expired tokens must be rejected with 401 status 9 + - Token validation must check the RS256 signature 10 + - The gateway must support token refresh without full re-authentication 11 + 12 + ## Rate Limiting 13 + 14 + - All endpoints must be rate-limited to 100 requests per minute per client 15 + - Rate limiting must use a sliding window algorithm 16 + - Exceeded rate limits must return 429 status with Retry-After header 17 + - Rate limit configuration must be adjustable per route 18 + 19 + ## Request Routing 20 + 21 + - The gateway must route requests to backend services based on path prefix 22 + - Route configuration must be loaded from a declarative config file 23 + - Unknown routes must return 404 with available route listing 24 + - The gateway must support path parameter extraction 25 + 26 + ## Logging 27 + 28 + - All requests must be logged with timestamp, method, path, status, and duration 29 + - Failed requests must include error details in the log 30 + - Log format must be structured JSON 31 + - The gateway must support configurable log levels 32 + 33 + ## Security Constraints 34 + 35 + - All traffic must use HTTPS (TLS 1.2+) 36 + - CORS headers must be configurable per route 37 + - Request body size must be limited to 10MB 38 + - SQL injection patterns in query parameters must be rejected
+29
tests/fixtures/spec-notifications.md
··· 1 + # Notification Service 2 + 3 + The notification service handles sending messages to users across multiple channels. 4 + 5 + ## Delivery Channels 6 + 7 + - The service must support email delivery via SMTP 8 + - The service must support push notifications 9 + - The service must support in-app notification storage 10 + - Channel preference must be configurable per user 11 + 12 + ## Templates 13 + 14 + - Notifications must be rendered from named templates 15 + - Templates must support variable interpolation 16 + - Missing template variables must produce a clear error, not silent blanks 17 + - Template rendering must be locale-aware 18 + 19 + ## Retry Logic 20 + 21 + - Failed deliveries must be retried up to 3 times with exponential backoff 22 + - Permanently failed deliveries must be marked and archived 23 + - Retry status must be queryable per notification 24 + 25 + ## Security Constraints 26 + 27 + - Email content must never include raw user passwords 28 + - Push notification payloads must be limited to 4KB 29 + - All notification content must be sanitized against XSS
+119
tests/unit/canonicalizer-llm.test.ts
··· 1 + /** 2 + * Tests for LLM-enhanced canonicalization. 3 + */ 4 + import { describe, it, expect, vi } from 'vitest'; 5 + import { extractCanonicalNodesLLM } from '../../src/canonicalizer-llm.js'; 6 + import { parseSpec } from '../../src/spec-parser.js'; 7 + import { CanonicalType } from '../../src/models/canonical.js'; 8 + import type { LLMProvider, GenerateOptions } from '../../src/llm/provider.js'; 9 + 10 + function makeMockLLM(response: string): LLMProvider { 11 + return { 12 + name: 'mock', 13 + model: 'mock-1', 14 + generate: vi.fn().mockResolvedValue(response), 15 + }; 16 + } 17 + 18 + const SPEC = `# Auth Service 19 + 20 + ## Requirements 21 + 22 + - Users must authenticate with email and password 23 + - Sessions expire after 24 hours 24 + - Failed login attempts are rate-limited to 5 per minute 25 + 26 + ## Security Constraints 27 + 28 + - All endpoints must use HTTPS 29 + - Tokens must be signed with RS256`; 30 + 31 + describe('LLM-Enhanced Canonicalizer', () => { 32 + const clauses = parseSpec(SPEC, 'spec/auth.md'); 33 + 34 + it('falls back to rule-based when no LLM provided', async () => { 35 + const nodes = await extractCanonicalNodesLLM(clauses, null); 36 + expect(nodes.length).toBeGreaterThan(0); 37 + // Should still work — this is the rule-based fallback 38 + const reqs = nodes.filter(n => n.type === CanonicalType.REQUIREMENT); 39 + expect(reqs.length).toBeGreaterThan(0); 40 + }); 41 + 42 + it('uses LLM response to build canonical nodes', async () => { 43 + const llmResponse = JSON.stringify([ 44 + { type: 'REQUIREMENT', statement: 'Users must authenticate with email and password', tags: ['authentication', 'email', 'password'] }, 45 + { type: 'REQUIREMENT', statement: 'Sessions expire after 24 hours', tags: ['sessions', 'expiration'] }, 46 + { type: 'CONSTRAINT', statement: 'Rate limit login attempts to 5 per minute', tags: ['rate-limit', 'login'] }, 47 + { type: 'CONSTRAINT', statement: 'All endpoints must use HTTPS', tags: ['https', 'security'] }, 48 + { type: 'CONSTRAINT', statement: 'Tokens must be signed with RS256', tags: ['tokens', 'rs256', 'signing'] }, 49 + ]); 50 + 51 + const llm = makeMockLLM(llmResponse); 52 + const nodes = await extractCanonicalNodesLLM(clauses, llm); 53 + 54 + expect(nodes.length).toBe(5); 55 + expect(nodes.filter(n => n.type === CanonicalType.REQUIREMENT).length).toBe(2); 56 + expect(nodes.filter(n => n.type === CanonicalType.CONSTRAINT).length).toBe(3); 57 + 58 + // Each node should have provenance back to a clause 59 + for (const node of nodes) { 60 + expect(node.source_clause_ids.length).toBeGreaterThan(0); 61 + expect(node.tags.length).toBeGreaterThan(0); 62 + } 63 + }); 64 + 65 + it('handles LLM returning markdown-fenced JSON', async () => { 66 + const llmResponse = '```json\n[\n { "type": "REQUIREMENT", "statement": "Users must authenticate", "tags": ["auth"] }\n]\n```'; 67 + const llm = makeMockLLM(llmResponse); 68 + const nodes = await extractCanonicalNodesLLM(clauses, llm); 69 + expect(nodes.length).toBe(1); 70 + expect(nodes[0].statement).toBe('Users must authenticate'); 71 + }); 72 + 73 + it('falls back to rule-based on LLM error', async () => { 74 + const llm: LLMProvider = { 75 + name: 'mock', 76 + model: 'mock-1', 77 + generate: vi.fn().mockRejectedValue(new Error('API timeout')), 78 + }; 79 + 80 + const nodes = await extractCanonicalNodesLLM(clauses, llm); 81 + // Should still produce nodes via fallback 82 + expect(nodes.length).toBeGreaterThan(0); 83 + }); 84 + 85 + it('falls back on invalid JSON response', async () => { 86 + const llm = makeMockLLM('Sorry, I cannot help with that.'); 87 + const nodes = await extractCanonicalNodesLLM(clauses, llm); 88 + // Falls back to rule-based since LLM response is empty after parse 89 + expect(nodes.length).toBeGreaterThan(0); 90 + }); 91 + 92 + it('links nodes with shared terms', async () => { 93 + const llmResponse = JSON.stringify([ 94 + { type: 'REQUIREMENT', statement: 'User authentication via email login', tags: ['authentication', 'email', 'user', 'login'] }, 95 + { type: 'CONSTRAINT', statement: 'Authentication login tokens must use RS256', tags: ['authentication', 'login', 'tokens', 'rs256'] }, 96 + ]); 97 + 98 + const llm = makeMockLLM(llmResponse); 99 + const nodes = await extractCanonicalNodesLLM(clauses, llm); 100 + 101 + // Both nodes share "authentication" and "login" tags (2+ shared) — should be linked 102 + const linked = nodes.filter(n => n.linked_canon_ids.length > 0); 103 + expect(linked.length).toBe(2); 104 + }); 105 + 106 + it('calls LLM with system prompt and low temperature', async () => { 107 + const llmResponse = JSON.stringify([ 108 + { type: 'REQUIREMENT', statement: 'test', tags: ['test'] }, 109 + ]); 110 + const llm = makeMockLLM(llmResponse); 111 + await extractCanonicalNodesLLM(clauses, llm); 112 + 113 + expect(llm.generate).toHaveBeenCalledTimes(1); 114 + const callArgs = (llm.generate as ReturnType<typeof vi.fn>).mock.calls[0]; 115 + const options = callArgs[1] as GenerateOptions; 116 + expect(options.system).toBeTruthy(); 117 + expect(options.temperature).toBe(0.1); 118 + }); 119 + });
+169
tests/unit/classifier-llm.test.ts
··· 1 + /** 2 + * Tests for LLM-enhanced change classifier. 3 + */ 4 + import { describe, it, expect, vi } from 'vitest'; 5 + import { classifyChangeWithLLM, classifyChangesWithLLM } from '../../src/classifier-llm.js'; 6 + import { parseSpec } from '../../src/spec-parser.js'; 7 + import { diffClauses } from '../../src/diff.js'; 8 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 9 + import { computeWarmHashes } from '../../src/warm-hasher.js'; 10 + import { ChangeClass, DRateLevel } from '../../src/models/classification.js'; 11 + import { DiffType } from '../../src/models/clause.js'; 12 + import type { LLMProvider } from '../../src/llm/provider.js'; 13 + 14 + function makeMockLLM(response: string): LLMProvider { 15 + return { 16 + name: 'mock', 17 + model: 'mock-1', 18 + generate: vi.fn().mockResolvedValue(response), 19 + }; 20 + } 21 + 22 + const SPEC_V1 = `# Service 23 + 24 + ## Features 25 + 26 + - The system must handle requests 27 + - Rate limiting is enforced`; 28 + 29 + const SPEC_V2 = `# Service 30 + 31 + ## Features 32 + 33 + - The system must handle HTTP requests and WebSocket connections 34 + - Rate limiting is enforced with sliding window algorithm 35 + - New feature: audit logging must record all mutations`; 36 + 37 + describe('LLM-Enhanced Classifier', () => { 38 + const clauses1 = parseSpec(SPEC_V1, 'spec/svc.md'); 39 + const clauses2 = parseSpec(SPEC_V2, 'spec/svc.md'); 40 + const canon1 = extractCanonicalNodes(clauses1); 41 + const canon2 = extractCanonicalNodes(clauses2); 42 + const warm1 = computeWarmHashes(clauses1, canon1); 43 + const warm2 = computeWarmHashes(clauses2, canon2); 44 + const diffs = diffClauses(clauses1, clauses2); 45 + 46 + it('returns rule-based result when no LLM provided', async () => { 47 + const diff = diffs[0]; 48 + const result = await classifyChangeWithLLM(diff, canon1, canon2, undefined, undefined); 49 + expect(Object.values(ChangeClass)).toContain(result.change_class); 50 + expect(result.llm_resolved).toBeUndefined(); 51 + }); 52 + 53 + it('escalates D-class changes to LLM', async () => { 54 + // Create a synthetic D-class diff (high edit distance, high term delta) 55 + const synthDiff = { 56 + diff_type: DiffType.MODIFIED, 57 + clause_id_before: clauses1[0]?.clause_id, 58 + clause_id_after: clauses2[0]?.clause_id, 59 + clause_before: { 60 + ...clauses1[0], 61 + normalized_text: 'completely different text about something unrelated', 62 + clause_semhash: 'aaaa', 63 + }, 64 + clause_after: { 65 + ...clauses2[0], 66 + normalized_text: 'entirely new concept with no overlap whatsoever in terminology', 67 + clause_semhash: 'bbbb', 68 + }, 69 + section_path_before: clauses1[0]?.section_path, 70 + section_path_after: clauses2[0]?.section_path, 71 + }; 72 + 73 + const llm = makeMockLLM('B'); 74 + const result = await classifyChangeWithLLM( 75 + synthDiff, canon1, canon2, undefined, undefined, 76 + { llm }, 77 + ); 78 + 79 + // If it was originally D, LLM resolves it to B 80 + if (result.llm_resolved) { 81 + expect(result.change_class).toBe(ChangeClass.B); 82 + expect(result.confidence).toBeGreaterThanOrEqual(0.75); 83 + } 84 + }); 85 + 86 + it('does not escalate non-D changes by default', async () => { 87 + // Unchanged clause → class A 88 + const unchangedDiff = { 89 + diff_type: DiffType.UNCHANGED as const, 90 + clause_id_before: clauses1[0]?.clause_id, 91 + clause_id_after: clauses1[0]?.clause_id, 92 + clause_before: clauses1[0], 93 + clause_after: clauses1[0], 94 + section_path_before: clauses1[0]?.section_path, 95 + section_path_after: clauses1[0]?.section_path, 96 + }; 97 + 98 + const llm = makeMockLLM('C'); 99 + const result = await classifyChangeWithLLM( 100 + unchangedDiff, canon1, canon2, undefined, undefined, 101 + { llm, dClassOnly: true }, 102 + ); 103 + 104 + // Should be A (trivial), LLM not called 105 + expect(result.change_class).toBe(ChangeClass.A); 106 + expect(result.llm_resolved).toBeUndefined(); 107 + expect(llm.generate).not.toHaveBeenCalled(); 108 + }); 109 + 110 + it('handles LLM errors gracefully', async () => { 111 + const llm: LLMProvider = { 112 + name: 'mock', 113 + model: 'mock-1', 114 + generate: vi.fn().mockRejectedValue(new Error('rate limited')), 115 + }; 116 + 117 + const diff = diffs[0]; 118 + const result = await classifyChangeWithLLM( 119 + diff, canon1, canon2, undefined, undefined, 120 + { llm }, 121 + ); 122 + 123 + // Should return the rule-based result, not throw 124 + expect(Object.values(ChangeClass)).toContain(result.change_class); 125 + }); 126 + 127 + it('batch classifies with LLM escalation', async () => { 128 + const llm = makeMockLLM('B'); 129 + const results = await classifyChangesWithLLM( 130 + diffs, canon1, canon2, warm1, warm2, 131 + { llm }, 132 + ); 133 + 134 + expect(results.length).toBe(diffs.length); 135 + for (const r of results) { 136 + expect(Object.values(ChangeClass)).toContain(r.change_class); 137 + } 138 + }); 139 + 140 + it('LLM response parsing handles various formats', async () => { 141 + // Test different response formats 142 + for (const response of ['A', ' B ', 'C\n', ' D ', 'A - trivial change']) { 143 + const llm = makeMockLLM(response); 144 + const synthDiff = { 145 + diff_type: DiffType.MODIFIED, 146 + clause_id_before: clauses1[0]?.clause_id, 147 + clause_id_after: clauses2[0]?.clause_id, 148 + clause_before: { 149 + ...clauses1[0], 150 + normalized_text: 'completely different text about xyz', 151 + clause_semhash: 'aaaa', 152 + }, 153 + clause_after: { 154 + ...clauses2[0], 155 + normalized_text: 'entirely new concept with no overlap in terms', 156 + clause_semhash: 'bbbb', 157 + }, 158 + section_path_before: clauses1[0]?.section_path, 159 + section_path_after: clauses2[0]?.section_path, 160 + }; 161 + 162 + const result = await classifyChangeWithLLM( 163 + synthDiff, canon1, canon2, undefined, undefined, 164 + { llm, dClassOnly: false }, 165 + ); 166 + expect(Object.values(ChangeClass)).toContain(result.change_class); 167 + } 168 + }); 169 + });