vibecheck-ai 2.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (456) hide show
  1. package/bin/.generated +25 -0
  2. package/bin/_deprecations.js +463 -0
  3. package/bin/_router.js +46 -0
  4. package/bin/cli-hygiene.js +241 -0
  5. package/bin/dev/run-v2-torture.js +30 -0
  6. package/bin/registry.js +656 -0
  7. package/bin/runners/CLI_REFACTOR_SUMMARY.md +229 -0
  8. package/bin/runners/ENHANCEMENT_GUIDE.md +121 -0
  9. package/bin/runners/REPORT_AUDIT.md +64 -0
  10. package/bin/runners/cli-utils.js +1070 -0
  11. package/bin/runners/context/ai-task-decomposer.js +337 -0
  12. package/bin/runners/context/analyzer.js +513 -0
  13. package/bin/runners/context/api-contracts.js +427 -0
  14. package/bin/runners/context/context-diff.js +342 -0
  15. package/bin/runners/context/context-pruner.js +291 -0
  16. package/bin/runners/context/dependency-graph.js +414 -0
  17. package/bin/runners/context/generators/claude.js +107 -0
  18. package/bin/runners/context/generators/codex.js +108 -0
  19. package/bin/runners/context/generators/copilot.js +119 -0
  20. package/bin/runners/context/generators/cursor-enhanced.js +2525 -0
  21. package/bin/runners/context/generators/cursor.js +514 -0
  22. package/bin/runners/context/generators/mcp.js +169 -0
  23. package/bin/runners/context/generators/windsurf.js +180 -0
  24. package/bin/runners/context/git-context.js +304 -0
  25. package/bin/runners/context/index.js +1110 -0
  26. package/bin/runners/context/insights.js +173 -0
  27. package/bin/runners/context/mcp-server/generate-rules.js +337 -0
  28. package/bin/runners/context/mcp-server/index.js +1176 -0
  29. package/bin/runners/context/mcp-server/package.json +24 -0
  30. package/bin/runners/context/memory.js +200 -0
  31. package/bin/runners/context/monorepo.js +215 -0
  32. package/bin/runners/context/multi-repo-federation.js +404 -0
  33. package/bin/runners/context/patterns.js +253 -0
  34. package/bin/runners/context/proof-context.js +1264 -0
  35. package/bin/runners/context/security-scanner.js +541 -0
  36. package/bin/runners/context/semantic-search.js +350 -0
  37. package/bin/runners/context/shared.js +264 -0
  38. package/bin/runners/context/team-conventions.js +336 -0
  39. package/bin/runners/lib/__tests__/entitlements-v2.test.js +295 -0
  40. package/bin/runners/lib/agent-firewall/ai/false-positive-analyzer.js +474 -0
  41. package/bin/runners/lib/agent-firewall/change-packet/builder.js +488 -0
  42. package/bin/runners/lib/agent-firewall/change-packet/schema.json +228 -0
  43. package/bin/runners/lib/agent-firewall/change-packet/store.js +200 -0
  44. package/bin/runners/lib/agent-firewall/claims/claim-types.js +21 -0
  45. package/bin/runners/lib/agent-firewall/claims/extractor.js +303 -0
  46. package/bin/runners/lib/agent-firewall/claims/patterns.js +24 -0
  47. package/bin/runners/lib/agent-firewall/critic/index.js +151 -0
  48. package/bin/runners/lib/agent-firewall/critic/judge.js +432 -0
  49. package/bin/runners/lib/agent-firewall/critic/prompts.js +305 -0
  50. package/bin/runners/lib/agent-firewall/enforcement/gateway.js +1059 -0
  51. package/bin/runners/lib/agent-firewall/enforcement/index.js +98 -0
  52. package/bin/runners/lib/agent-firewall/enforcement/mode.js +318 -0
  53. package/bin/runners/lib/agent-firewall/enforcement/orchestrator.js +484 -0
  54. package/bin/runners/lib/agent-firewall/enforcement/proof-artifact.js +418 -0
  55. package/bin/runners/lib/agent-firewall/enforcement/schemas/change-event.schema.json +173 -0
  56. package/bin/runners/lib/agent-firewall/enforcement/schemas/intent.schema.json +181 -0
  57. package/bin/runners/lib/agent-firewall/enforcement/schemas/verdict.schema.json +222 -0
  58. package/bin/runners/lib/agent-firewall/enforcement/verdict-v2.js +333 -0
  59. package/bin/runners/lib/agent-firewall/evidence/auth-evidence.js +88 -0
  60. package/bin/runners/lib/agent-firewall/evidence/contract-evidence.js +75 -0
  61. package/bin/runners/lib/agent-firewall/evidence/env-evidence.js +127 -0
  62. package/bin/runners/lib/agent-firewall/evidence/resolver.js +102 -0
  63. package/bin/runners/lib/agent-firewall/evidence/route-evidence.js +213 -0
  64. package/bin/runners/lib/agent-firewall/evidence/side-effect-evidence.js +145 -0
  65. package/bin/runners/lib/agent-firewall/fs-hook/daemon.js +19 -0
  66. package/bin/runners/lib/agent-firewall/fs-hook/installer.js +87 -0
  67. package/bin/runners/lib/agent-firewall/fs-hook/watcher.js +184 -0
  68. package/bin/runners/lib/agent-firewall/git-hook/pre-commit.js +163 -0
  69. package/bin/runners/lib/agent-firewall/ide-extension/cursor.js +107 -0
  70. package/bin/runners/lib/agent-firewall/ide-extension/vscode.js +68 -0
  71. package/bin/runners/lib/agent-firewall/ide-extension/windsurf.js +66 -0
  72. package/bin/runners/lib/agent-firewall/index.js +200 -0
  73. package/bin/runners/lib/agent-firewall/integration/index.js +20 -0
  74. package/bin/runners/lib/agent-firewall/integration/ship-gate.js +437 -0
  75. package/bin/runners/lib/agent-firewall/intent/alignment-engine.js +634 -0
  76. package/bin/runners/lib/agent-firewall/intent/auto-detect.js +426 -0
  77. package/bin/runners/lib/agent-firewall/intent/index.js +102 -0
  78. package/bin/runners/lib/agent-firewall/intent/schema.js +352 -0
  79. package/bin/runners/lib/agent-firewall/intent/store.js +283 -0
  80. package/bin/runners/lib/agent-firewall/interception/fs-interceptor.js +502 -0
  81. package/bin/runners/lib/agent-firewall/interception/index.js +23 -0
  82. package/bin/runners/lib/agent-firewall/interceptor/base.js +308 -0
  83. package/bin/runners/lib/agent-firewall/interceptor/cursor.js +35 -0
  84. package/bin/runners/lib/agent-firewall/interceptor/vscode.js +35 -0
  85. package/bin/runners/lib/agent-firewall/interceptor/windsurf.js +34 -0
  86. package/bin/runners/lib/agent-firewall/lawbook/distributor.js +465 -0
  87. package/bin/runners/lib/agent-firewall/lawbook/evaluator.js +604 -0
  88. package/bin/runners/lib/agent-firewall/lawbook/index.js +304 -0
  89. package/bin/runners/lib/agent-firewall/lawbook/registry.js +514 -0
  90. package/bin/runners/lib/agent-firewall/lawbook/schema.js +420 -0
  91. package/bin/runners/lib/agent-firewall/logger.js +141 -0
  92. package/bin/runners/lib/agent-firewall/policy/default-policy.json +90 -0
  93. package/bin/runners/lib/agent-firewall/policy/engine.js +103 -0
  94. package/bin/runners/lib/agent-firewall/policy/loader.js +451 -0
  95. package/bin/runners/lib/agent-firewall/policy/rules/auth-drift.js +50 -0
  96. package/bin/runners/lib/agent-firewall/policy/rules/contract-drift.js +50 -0
  97. package/bin/runners/lib/agent-firewall/policy/rules/fake-success.js +79 -0
  98. package/bin/runners/lib/agent-firewall/policy/rules/ghost-env.js +227 -0
  99. package/bin/runners/lib/agent-firewall/policy/rules/ghost-route.js +191 -0
  100. package/bin/runners/lib/agent-firewall/policy/rules/scope.js +93 -0
  101. package/bin/runners/lib/agent-firewall/policy/rules/unsafe-side-effect.js +57 -0
  102. package/bin/runners/lib/agent-firewall/policy/schema.json +183 -0
  103. package/bin/runners/lib/agent-firewall/policy/verdict.js +54 -0
  104. package/bin/runners/lib/agent-firewall/proposal/extractor.js +394 -0
  105. package/bin/runners/lib/agent-firewall/proposal/index.js +212 -0
  106. package/bin/runners/lib/agent-firewall/proposal/schema.js +251 -0
  107. package/bin/runners/lib/agent-firewall/proposal/validator.js +386 -0
  108. package/bin/runners/lib/agent-firewall/reality/index.js +332 -0
  109. package/bin/runners/lib/agent-firewall/reality/state.js +625 -0
  110. package/bin/runners/lib/agent-firewall/reality/watcher.js +322 -0
  111. package/bin/runners/lib/agent-firewall/risk/index.js +173 -0
  112. package/bin/runners/lib/agent-firewall/risk/scorer.js +328 -0
  113. package/bin/runners/lib/agent-firewall/risk/thresholds.js +322 -0
  114. package/bin/runners/lib/agent-firewall/risk/vectors.js +421 -0
  115. package/bin/runners/lib/agent-firewall/session/collector.js +451 -0
  116. package/bin/runners/lib/agent-firewall/session/index.js +26 -0
  117. package/bin/runners/lib/agent-firewall/simulator/diff-simulator.js +472 -0
  118. package/bin/runners/lib/agent-firewall/simulator/import-resolver.js +346 -0
  119. package/bin/runners/lib/agent-firewall/simulator/index.js +181 -0
  120. package/bin/runners/lib/agent-firewall/simulator/route-validator.js +380 -0
  121. package/bin/runners/lib/agent-firewall/time-machine/incident-correlator.js +661 -0
  122. package/bin/runners/lib/agent-firewall/time-machine/index.js +267 -0
  123. package/bin/runners/lib/agent-firewall/time-machine/replay-engine.js +436 -0
  124. package/bin/runners/lib/agent-firewall/time-machine/state-reconstructor.js +490 -0
  125. package/bin/runners/lib/agent-firewall/time-machine/timeline-builder.js +530 -0
  126. package/bin/runners/lib/agent-firewall/truthpack/index.js +67 -0
  127. package/bin/runners/lib/agent-firewall/truthpack/loader.js +137 -0
  128. package/bin/runners/lib/agent-firewall/unblock/planner.js +337 -0
  129. package/bin/runners/lib/agent-firewall/utils/ignore-checker.js +118 -0
  130. package/bin/runners/lib/ai-bridge.js +416 -0
  131. package/bin/runners/lib/analysis-core.js +309 -0
  132. package/bin/runners/lib/analyzers.js +2500 -0
  133. package/bin/runners/lib/api-client.js +269 -0
  134. package/bin/runners/lib/approve-output.js +235 -0
  135. package/bin/runners/lib/artifact-envelope.js +540 -0
  136. package/bin/runners/lib/assets/vibecheck-logo.png +0 -0
  137. package/bin/runners/lib/audit-bridge.js +391 -0
  138. package/bin/runners/lib/auth-shared.js +977 -0
  139. package/bin/runners/lib/auth-truth.js +193 -0
  140. package/bin/runners/lib/auth.js +215 -0
  141. package/bin/runners/lib/authority-badge.js +425 -0
  142. package/bin/runners/lib/backup.js +62 -0
  143. package/bin/runners/lib/billing.js +107 -0
  144. package/bin/runners/lib/checkpoint.js +941 -0
  145. package/bin/runners/lib/claims.js +118 -0
  146. package/bin/runners/lib/classify-output.js +204 -0
  147. package/bin/runners/lib/cleanup/engine.js +571 -0
  148. package/bin/runners/lib/cleanup/index.js +53 -0
  149. package/bin/runners/lib/cleanup/output.js +375 -0
  150. package/bin/runners/lib/cleanup/rules.js +1060 -0
  151. package/bin/runners/lib/cli-output.js +400 -0
  152. package/bin/runners/lib/cli-ui.js +540 -0
  153. package/bin/runners/lib/compliance-bridge-new.js +0 -0
  154. package/bin/runners/lib/compliance-bridge.js +165 -0
  155. package/bin/runners/lib/contracts/auth-contract.js +202 -0
  156. package/bin/runners/lib/contracts/env-contract.js +181 -0
  157. package/bin/runners/lib/contracts/external-contract.js +206 -0
  158. package/bin/runners/lib/contracts/guard.js +168 -0
  159. package/bin/runners/lib/contracts/index.js +89 -0
  160. package/bin/runners/lib/contracts/plan-validator.js +311 -0
  161. package/bin/runners/lib/contracts/route-contract.js +199 -0
  162. package/bin/runners/lib/contracts.js +804 -0
  163. package/bin/runners/lib/default-config.js +127 -0
  164. package/bin/runners/lib/detect.js +89 -0
  165. package/bin/runners/lib/detectors-v2.js +622 -0
  166. package/bin/runners/lib/doctor/autofix.js +254 -0
  167. package/bin/runners/lib/doctor/diagnosis-receipt.js +454 -0
  168. package/bin/runners/lib/doctor/failure-signatures.js +526 -0
  169. package/bin/runners/lib/doctor/fix-script.js +336 -0
  170. package/bin/runners/lib/doctor/index.js +37 -0
  171. package/bin/runners/lib/doctor/modules/build-tools.js +453 -0
  172. package/bin/runners/lib/doctor/modules/dependencies.js +325 -0
  173. package/bin/runners/lib/doctor/modules/index.js +105 -0
  174. package/bin/runners/lib/doctor/modules/network.js +250 -0
  175. package/bin/runners/lib/doctor/modules/os-quirks.js +706 -0
  176. package/bin/runners/lib/doctor/modules/project.js +312 -0
  177. package/bin/runners/lib/doctor/modules/repo-integrity.js +485 -0
  178. package/bin/runners/lib/doctor/modules/runtime.js +224 -0
  179. package/bin/runners/lib/doctor/modules/security.js +350 -0
  180. package/bin/runners/lib/doctor/modules/system.js +213 -0
  181. package/bin/runners/lib/doctor/modules/vibecheck.js +394 -0
  182. package/bin/runners/lib/doctor/reporter.js +262 -0
  183. package/bin/runners/lib/doctor/safe-repair.js +384 -0
  184. package/bin/runners/lib/doctor/service.js +262 -0
  185. package/bin/runners/lib/doctor/types.js +113 -0
  186. package/bin/runners/lib/doctor/ui.js +263 -0
  187. package/bin/runners/lib/doctor-enhanced.js +233 -0
  188. package/bin/runners/lib/doctor-output.js +226 -0
  189. package/bin/runners/lib/doctor-v2.js +608 -0
  190. package/bin/runners/lib/drift.js +425 -0
  191. package/bin/runners/lib/enforcement.js +72 -0
  192. package/bin/runners/lib/engine/ast-cache.js +210 -0
  193. package/bin/runners/lib/engine/auth-extractor.js +211 -0
  194. package/bin/runners/lib/engine/billing-extractor.js +112 -0
  195. package/bin/runners/lib/engine/enforcement-extractor.js +100 -0
  196. package/bin/runners/lib/engine/env-extractor.js +207 -0
  197. package/bin/runners/lib/engine/express-extractor.js +208 -0
  198. package/bin/runners/lib/engine/extractors.js +849 -0
  199. package/bin/runners/lib/engine/index.js +207 -0
  200. package/bin/runners/lib/engine/repo-index.js +514 -0
  201. package/bin/runners/lib/engine/types.js +124 -0
  202. package/bin/runners/lib/engines/accessibility-engine.js +190 -0
  203. package/bin/runners/lib/engines/api-consistency-engine.js +162 -0
  204. package/bin/runners/lib/engines/ast-cache.js +99 -0
  205. package/bin/runners/lib/engines/attack-detector.js +1192 -0
  206. package/bin/runners/lib/engines/code-quality-engine.js +255 -0
  207. package/bin/runners/lib/engines/console-logs-engine.js +115 -0
  208. package/bin/runners/lib/engines/cross-file-analysis-engine.js +268 -0
  209. package/bin/runners/lib/engines/dead-code-engine.js +198 -0
  210. package/bin/runners/lib/engines/deprecated-api-engine.js +226 -0
  211. package/bin/runners/lib/engines/empty-catch-engine.js +150 -0
  212. package/bin/runners/lib/engines/file-filter.js +131 -0
  213. package/bin/runners/lib/engines/hardcoded-secrets-engine.js +251 -0
  214. package/bin/runners/lib/engines/mock-data-engine.js +272 -0
  215. package/bin/runners/lib/engines/parallel-processor.js +71 -0
  216. package/bin/runners/lib/engines/performance-issues-engine.js +265 -0
  217. package/bin/runners/lib/engines/security-vulnerabilities-engine.js +243 -0
  218. package/bin/runners/lib/engines/todo-fixme-engine.js +115 -0
  219. package/bin/runners/lib/engines/type-aware-engine.js +152 -0
  220. package/bin/runners/lib/engines/unsafe-regex-engine.js +225 -0
  221. package/bin/runners/lib/engines/vibecheck-engines/README.md +53 -0
  222. package/bin/runners/lib/engines/vibecheck-engines/index.js +15 -0
  223. package/bin/runners/lib/engines/vibecheck-engines/lib/ast-cache.js +164 -0
  224. package/bin/runners/lib/engines/vibecheck-engines/lib/code-quality-engine.js +291 -0
  225. package/bin/runners/lib/engines/vibecheck-engines/lib/console-logs-engine.js +83 -0
  226. package/bin/runners/lib/engines/vibecheck-engines/lib/dead-code-engine.js +198 -0
  227. package/bin/runners/lib/engines/vibecheck-engines/lib/deprecated-api-engine.js +275 -0
  228. package/bin/runners/lib/engines/vibecheck-engines/lib/empty-catch-engine.js +167 -0
  229. package/bin/runners/lib/engines/vibecheck-engines/lib/file-filter.js +217 -0
  230. package/bin/runners/lib/engines/vibecheck-engines/lib/hardcoded-secrets-engine.js +139 -0
  231. package/bin/runners/lib/engines/vibecheck-engines/lib/mock-data-engine.js +140 -0
  232. package/bin/runners/lib/engines/vibecheck-engines/lib/parallel-processor.js +164 -0
  233. package/bin/runners/lib/engines/vibecheck-engines/lib/performance-issues-engine.js +234 -0
  234. package/bin/runners/lib/engines/vibecheck-engines/lib/type-aware-engine.js +217 -0
  235. package/bin/runners/lib/engines/vibecheck-engines/lib/unsafe-regex-engine.js +78 -0
  236. package/bin/runners/lib/engines/vibecheck-engines/package.json +13 -0
  237. package/bin/runners/lib/enterprise-detect.js +603 -0
  238. package/bin/runners/lib/enterprise-init.js +942 -0
  239. package/bin/runners/lib/entitlements-v2.js +265 -0
  240. package/bin/runners/lib/entitlements.generated.js +0 -0
  241. package/bin/runners/lib/entitlements.js +340 -0
  242. package/bin/runners/lib/env-resolver.js +417 -0
  243. package/bin/runners/lib/env-template.js +66 -0
  244. package/bin/runners/lib/env.js +189 -0
  245. package/bin/runners/lib/error-handler.js +368 -0
  246. package/bin/runners/lib/error-messages.js +289 -0
  247. package/bin/runners/lib/evidence-pack.js +684 -0
  248. package/bin/runners/lib/exit-codes.js +275 -0
  249. package/bin/runners/lib/extractors/client-calls.js +990 -0
  250. package/bin/runners/lib/extractors/fastify-route-dump.js +573 -0
  251. package/bin/runners/lib/extractors/fastify-routes.js +426 -0
  252. package/bin/runners/lib/extractors/index.js +363 -0
  253. package/bin/runners/lib/extractors/next-routes.js +524 -0
  254. package/bin/runners/lib/extractors/proof-graph.js +431 -0
  255. package/bin/runners/lib/extractors/route-matcher.js +451 -0
  256. package/bin/runners/lib/extractors/truthpack-v2.js +377 -0
  257. package/bin/runners/lib/extractors/ui-bindings.js +547 -0
  258. package/bin/runners/lib/finding-id.js +69 -0
  259. package/bin/runners/lib/finding-sorter.js +89 -0
  260. package/bin/runners/lib/findings-schema.js +281 -0
  261. package/bin/runners/lib/fingerprint.js +377 -0
  262. package/bin/runners/lib/firewall-prompt.js +50 -0
  263. package/bin/runners/lib/fix-output.js +228 -0
  264. package/bin/runners/lib/global-flags.js +250 -0
  265. package/bin/runners/lib/graph/graph-builder.js +265 -0
  266. package/bin/runners/lib/graph/html-renderer.js +413 -0
  267. package/bin/runners/lib/graph/index.js +32 -0
  268. package/bin/runners/lib/graph/runtime-collector.js +215 -0
  269. package/bin/runners/lib/graph/static-extractor.js +518 -0
  270. package/bin/runners/lib/help-formatter.js +413 -0
  271. package/bin/runners/lib/html-proof-report.js +913 -0
  272. package/bin/runners/lib/html-report.js +650 -0
  273. package/bin/runners/lib/init-wizard.js +601 -0
  274. package/bin/runners/lib/interactive-menu.js +1496 -0
  275. package/bin/runners/lib/json-output.js +76 -0
  276. package/bin/runners/lib/llm.js +75 -0
  277. package/bin/runners/lib/logger.js +38 -0
  278. package/bin/runners/lib/meter.js +61 -0
  279. package/bin/runners/lib/missions/briefing.js +427 -0
  280. package/bin/runners/lib/missions/checkpoint.js +753 -0
  281. package/bin/runners/lib/missions/evidence.js +126 -0
  282. package/bin/runners/lib/missions/hardening.js +851 -0
  283. package/bin/runners/lib/missions/plan.js +648 -0
  284. package/bin/runners/lib/missions/safety-gates.js +645 -0
  285. package/bin/runners/lib/missions/schema.js +478 -0
  286. package/bin/runners/lib/missions/templates.js +317 -0
  287. package/bin/runners/lib/next-action.js +560 -0
  288. package/bin/runners/lib/packs/bundle.js +675 -0
  289. package/bin/runners/lib/packs/evidence-pack.js +671 -0
  290. package/bin/runners/lib/packs/pack-factory.js +837 -0
  291. package/bin/runners/lib/packs/permissions-pack.js +686 -0
  292. package/bin/runners/lib/packs/proof-graph-pack.js +779 -0
  293. package/bin/runners/lib/patch.js +40 -0
  294. package/bin/runners/lib/permissions/auth-model.js +213 -0
  295. package/bin/runners/lib/permissions/idor-prover.js +205 -0
  296. package/bin/runners/lib/permissions/index.js +45 -0
  297. package/bin/runners/lib/permissions/matrix-builder.js +198 -0
  298. package/bin/runners/lib/pkgjson.js +28 -0
  299. package/bin/runners/lib/policy.js +295 -0
  300. package/bin/runners/lib/polish/accessibility.js +62 -0
  301. package/bin/runners/lib/polish/analyzer.js +93 -0
  302. package/bin/runners/lib/polish/backend.js +87 -0
  303. package/bin/runners/lib/polish/configuration.js +83 -0
  304. package/bin/runners/lib/polish/documentation.js +83 -0
  305. package/bin/runners/lib/polish/frontend.js +817 -0
  306. package/bin/runners/lib/polish/index.js +27 -0
  307. package/bin/runners/lib/polish/infrastructure.js +80 -0
  308. package/bin/runners/lib/polish/internationalization.js +85 -0
  309. package/bin/runners/lib/polish/libraries.js +180 -0
  310. package/bin/runners/lib/polish/observability.js +75 -0
  311. package/bin/runners/lib/polish/performance.js +64 -0
  312. package/bin/runners/lib/polish/privacy.js +110 -0
  313. package/bin/runners/lib/polish/resilience.js +92 -0
  314. package/bin/runners/lib/polish/security.js +78 -0
  315. package/bin/runners/lib/polish/seo.js +71 -0
  316. package/bin/runners/lib/polish/styles.js +62 -0
  317. package/bin/runners/lib/polish/utils.js +104 -0
  318. package/bin/runners/lib/preflight.js +142 -0
  319. package/bin/runners/lib/prerequisites.js +149 -0
  320. package/bin/runners/lib/prove-output.js +220 -0
  321. package/bin/runners/lib/reality/correlation-detectors.js +359 -0
  322. package/bin/runners/lib/reality/index.js +318 -0
  323. package/bin/runners/lib/reality/request-hashing.js +416 -0
  324. package/bin/runners/lib/reality/request-mapper.js +453 -0
  325. package/bin/runners/lib/reality/safety-rails.js +463 -0
  326. package/bin/runners/lib/reality/semantic-snapshot.js +408 -0
  327. package/bin/runners/lib/reality/toast-detector.js +393 -0
  328. package/bin/runners/lib/reality-findings.js +84 -0
  329. package/bin/runners/lib/reality-output.js +231 -0
  330. package/bin/runners/lib/receipts.js +179 -0
  331. package/bin/runners/lib/redact.js +29 -0
  332. package/bin/runners/lib/replay/capsule-manager.js +154 -0
  333. package/bin/runners/lib/replay/index.js +263 -0
  334. package/bin/runners/lib/replay/player.js +348 -0
  335. package/bin/runners/lib/replay/recorder.js +331 -0
  336. package/bin/runners/lib/report-engine.js +626 -0
  337. package/bin/runners/lib/report-html.js +1233 -0
  338. package/bin/runners/lib/report-output.js +366 -0
  339. package/bin/runners/lib/report-templates.js +967 -0
  340. package/bin/runners/lib/report.js +135 -0
  341. package/bin/runners/lib/route-detection.js +1209 -0
  342. package/bin/runners/lib/route-truth.js +1322 -0
  343. package/bin/runners/lib/safelist/index.js +96 -0
  344. package/bin/runners/lib/safelist/integration.js +334 -0
  345. package/bin/runners/lib/safelist/matcher.js +696 -0
  346. package/bin/runners/lib/safelist/schema.js +948 -0
  347. package/bin/runners/lib/safelist/store.js +438 -0
  348. package/bin/runners/lib/sandbox/index.js +59 -0
  349. package/bin/runners/lib/sandbox/proof-chain.js +399 -0
  350. package/bin/runners/lib/sandbox/sandbox-runner.js +205 -0
  351. package/bin/runners/lib/sandbox/worktree.js +174 -0
  352. package/bin/runners/lib/scan-cache.js +330 -0
  353. package/bin/runners/lib/scan-output-schema.js +344 -0
  354. package/bin/runners/lib/scan-output.js +631 -0
  355. package/bin/runners/lib/scan-runner.js +135 -0
  356. package/bin/runners/lib/schema-validator.js +350 -0
  357. package/bin/runners/lib/schemas/ajv-validator.js +464 -0
  358. package/bin/runners/lib/schemas/contracts.schema.json +160 -0
  359. package/bin/runners/lib/schemas/error-envelope.schema.json +105 -0
  360. package/bin/runners/lib/schemas/finding-v3.schema.json +151 -0
  361. package/bin/runners/lib/schemas/finding.schema.json +100 -0
  362. package/bin/runners/lib/schemas/mission-pack.schema.json +206 -0
  363. package/bin/runners/lib/schemas/proof-graph.schema.json +176 -0
  364. package/bin/runners/lib/schemas/reality-report.schema.json +162 -0
  365. package/bin/runners/lib/schemas/report-artifact.schema.json +120 -0
  366. package/bin/runners/lib/schemas/run-request.schema.json +108 -0
  367. package/bin/runners/lib/schemas/share-pack.schema.json +180 -0
  368. package/bin/runners/lib/schemas/ship-manifest.schema.json +251 -0
  369. package/bin/runners/lib/schemas/ship-report.schema.json +117 -0
  370. package/bin/runners/lib/schemas/truthpack-v2.schema.json +303 -0
  371. package/bin/runners/lib/schemas/validator.js +465 -0
  372. package/bin/runners/lib/schemas/verdict.schema.json +140 -0
  373. package/bin/runners/lib/score-history.js +282 -0
  374. package/bin/runners/lib/security-bridge.js +249 -0
  375. package/bin/runners/lib/server-usage.js +513 -0
  376. package/bin/runners/lib/share-pack.js +239 -0
  377. package/bin/runners/lib/ship-gate.js +832 -0
  378. package/bin/runners/lib/ship-manifest.js +1153 -0
  379. package/bin/runners/lib/ship-output-enterprise.js +239 -0
  380. package/bin/runners/lib/ship-output.js +1128 -0
  381. package/bin/runners/lib/snippets.js +67 -0
  382. package/bin/runners/lib/status-output.js +340 -0
  383. package/bin/runners/lib/terminal-ui.js +356 -0
  384. package/bin/runners/lib/truth.js +1691 -0
  385. package/bin/runners/lib/ui.js +562 -0
  386. package/bin/runners/lib/unified-cli-output.js +947 -0
  387. package/bin/runners/lib/unified-output.js +197 -0
  388. package/bin/runners/lib/upsell.js +410 -0
  389. package/bin/runners/lib/usage.js +153 -0
  390. package/bin/runners/lib/validate-patch.js +156 -0
  391. package/bin/runners/lib/verdict-engine.js +628 -0
  392. package/bin/runners/lib/verification.js +345 -0
  393. package/bin/runners/lib/why-tree.js +650 -0
  394. package/bin/runners/reality/engine.js +917 -0
  395. package/bin/runners/reality/flows.js +122 -0
  396. package/bin/runners/reality/report.js +378 -0
  397. package/bin/runners/reality/session.js +193 -0
  398. package/bin/runners/runAIAgent.js +229 -0
  399. package/bin/runners/runAgent.d.ts +5 -0
  400. package/bin/runners/runAgent.js +161 -0
  401. package/bin/runners/runAllowlist.js +418 -0
  402. package/bin/runners/runApprove.js +320 -0
  403. package/bin/runners/runAudit.js +692 -0
  404. package/bin/runners/runAuth.js +731 -0
  405. package/bin/runners/runCI.js +353 -0
  406. package/bin/runners/runCheckpoint.js +530 -0
  407. package/bin/runners/runClassify.js +928 -0
  408. package/bin/runners/runCleanup.js +343 -0
  409. package/bin/runners/runContext.d.ts +4 -0
  410. package/bin/runners/runContext.js +175 -0
  411. package/bin/runners/runDoctor.js +877 -0
  412. package/bin/runners/runEvidencePack.js +362 -0
  413. package/bin/runners/runFirewall.d.ts +5 -0
  414. package/bin/runners/runFirewall.js +134 -0
  415. package/bin/runners/runFirewallHook.d.ts +5 -0
  416. package/bin/runners/runFirewallHook.js +56 -0
  417. package/bin/runners/runFix.js +1355 -0
  418. package/bin/runners/runForge.js +451 -0
  419. package/bin/runners/runGuard.js +262 -0
  420. package/bin/runners/runInit.js +1927 -0
  421. package/bin/runners/runIntent.js +906 -0
  422. package/bin/runners/runKickoff.js +878 -0
  423. package/bin/runners/runLabs.js +424 -0
  424. package/bin/runners/runLaunch.js +2000 -0
  425. package/bin/runners/runLink.js +785 -0
  426. package/bin/runners/runMcp.js +1875 -0
  427. package/bin/runners/runPacks.js +2089 -0
  428. package/bin/runners/runPolish.d.ts +4 -0
  429. package/bin/runners/runPolish.js +390 -0
  430. package/bin/runners/runPromptFirewall.js +211 -0
  431. package/bin/runners/runProve.js +1411 -0
  432. package/bin/runners/runQuickstart.js +531 -0
  433. package/bin/runners/runReality.js +2260 -0
  434. package/bin/runners/runReport.js +726 -0
  435. package/bin/runners/runRuntime.js +110 -0
  436. package/bin/runners/runSafelist.js +1190 -0
  437. package/bin/runners/runScan.js +688 -0
  438. package/bin/runners/runShield.js +1282 -0
  439. package/bin/runners/runShip.js +1660 -0
  440. package/bin/runners/runTruth.d.ts +5 -0
  441. package/bin/runners/runTruth.js +101 -0
  442. package/bin/runners/runValidate.js +179 -0
  443. package/bin/runners/runWatch.js +478 -0
  444. package/bin/runners/utils.js +360 -0
  445. package/bin/scan.js +617 -0
  446. package/bin/vibecheck.js +1617 -0
  447. package/dist/guardrail/index.d.ts +2405 -0
  448. package/dist/guardrail/index.js +9747 -0
  449. package/dist/guardrail/index.js.map +1 -0
  450. package/dist/scanner/index.d.ts +282 -0
  451. package/dist/scanner/index.js +3395 -0
  452. package/dist/scanner/index.js.map +1 -0
  453. package/package.json +123 -104
  454. package/README.md +0 -491
  455. package/dist/index.js +0 -99711
  456. package/dist/index.js.map +0 -1
@@ -0,0 +1,2260 @@
1
+ /**
2
+ * Reality Mode v2 - Two-Pass Auth Verification + Dead UI Crawler + Fake Data Detection
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════════
5
+ * ENTERPRISE EDITION - World-Class Terminal Experience
6
+ * ═══════════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * TIER ENFORCEMENT:
9
+ * - FREE: Preview mode (5 pages, 20 clicks, no auth boundary)
10
+ * - STARTER: Full budgets + basic auth verification
11
+ * - PRO: Advanced auth boundary (multi-role, 2-pass) + fake data detection
12
+ *
13
+ * Pass A (anon): crawl + click, record which routes look protected
14
+ * Pass B (auth): crawl same routes using storageState, verify protected routes accessible
15
+ *
16
+ * Findings:
17
+ * - Dead UI (clicks that do nothing)
18
+ * - HTTP errors (4xx/5xx)
19
+ * - Auth coverage (protected route reachable anonymously = BLOCK)
20
+ * - Fake domain detection (localhost, jsonplaceholder, ngrok, mockapi.io)
21
+ * - Fake response detection (demo IDs, test keys, placeholder data)
22
+ * - Mock status codes (418, 999, etc.)
23
+ * - Route coverage stats
24
+ */
25
+
26
+ "use strict";
27
+
28
+ const fs = require("fs");
29
+ const path = require("path");
30
+ const crypto = require("crypto");
31
+ const { parseGlobalFlags, shouldShowBanner } = require("./lib/global-flags");
32
+
33
+ // Entitlements enforcement
34
+ const entitlements = require("./lib/entitlements-v2");
35
+ const upsell = require("./lib/upsell");
36
+
37
+ let chromium;
38
+ let playwrightError = null;
39
+ try {
40
+ chromium = require("playwright").chromium;
41
+ } catch (e) {
42
+ chromium = null;
43
+ playwrightError = e.message;
44
+ }
45
+
46
+ // ═══════════════════════════════════════════════════════════════════════════════
47
+ // ADVANCED TERMINAL - ANSI CODES & UTILITIES
48
+ // ═══════════════════════════════════════════════════════════════════════════════
49
+
50
+ const c = {
51
+ reset: '\x1b[0m',
52
+ bold: '\x1b[1m',
53
+ dim: '\x1b[2m',
54
+ italic: '\x1b[3m',
55
+ underline: '\x1b[4m',
56
+ blink: '\x1b[5m',
57
+ inverse: '\x1b[7m',
58
+ hidden: '\x1b[8m',
59
+ strike: '\x1b[9m',
60
+ // Colors
61
+ black: '\x1b[30m',
62
+ red: '\x1b[31m',
63
+ green: '\x1b[32m',
64
+ yellow: '\x1b[33m',
65
+ blue: '\x1b[34m',
66
+ magenta: '\x1b[35m',
67
+ cyan: '\x1b[36m',
68
+ white: '\x1b[37m',
69
+ // Bright colors
70
+ gray: '\x1b[90m',
71
+ brightRed: '\x1b[91m',
72
+ brightGreen: '\x1b[92m',
73
+ brightYellow: '\x1b[93m',
74
+ brightBlue: '\x1b[94m',
75
+ brightMagenta: '\x1b[95m',
76
+ brightCyan: '\x1b[96m',
77
+ brightWhite: '\x1b[97m',
78
+ // Background
79
+ bgBlack: '\x1b[40m',
80
+ bgRed: '\x1b[41m',
81
+ bgGreen: '\x1b[42m',
82
+ bgYellow: '\x1b[43m',
83
+ bgBlue: '\x1b[44m',
84
+ bgMagenta: '\x1b[45m',
85
+ bgCyan: '\x1b[46m',
86
+ bgWhite: '\x1b[47m',
87
+ // Cursor control
88
+ cursorUp: (n = 1) => `\x1b[${n}A`,
89
+ cursorDown: (n = 1) => `\x1b[${n}B`,
90
+ cursorRight: (n = 1) => `\x1b[${n}C`,
91
+ cursorLeft: (n = 1) => `\x1b[${n}D`,
92
+ clearLine: '\x1b[2K',
93
+ clearScreen: '\x1b[2J',
94
+ saveCursor: '\x1b[s',
95
+ restoreCursor: '\x1b[u',
96
+ hideCursor: '\x1b[?25l',
97
+ showCursor: '\x1b[?25h',
98
+ };
99
+
100
+ // True color support
101
+ const rgb = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
102
+ const bgRgb = (r, g, b) => `\x1b[48;2;${r};${g};${b}m`;
103
+
104
+ // Premium color palette (orange/coral theme for "reality" - testing/verification)
105
+ const colors = {
106
+ // Gradient for banner
107
+ gradient1: rgb(255, 150, 100), // Light coral
108
+ gradient2: rgb(255, 130, 80), // Coral
109
+ gradient3: rgb(255, 110, 60), // Orange-coral
110
+ gradient4: rgb(255, 90, 50), // Orange
111
+ gradient5: rgb(255, 70, 40), // Deep orange
112
+ gradient6: rgb(255, 50, 30), // Red-orange
113
+
114
+ // Pass colors
115
+ anon: rgb(150, 200, 255), // Blue for anonymous
116
+ auth: rgb(100, 255, 180), // Green for authenticated
117
+
118
+ // Category colors
119
+ deadUI: rgb(255, 100, 100), // Red for dead UI
120
+ authCoverage: rgb(255, 180, 100), // Orange for auth issues
121
+ httpError: rgb(255, 150, 50), // Amber for HTTP errors
122
+ coverage: rgb(100, 200, 255), // Blue for coverage
123
+
124
+ // Status colors
125
+ success: rgb(0, 255, 150),
126
+ warning: rgb(255, 200, 0),
127
+ error: rgb(255, 80, 80),
128
+ info: rgb(100, 200, 255),
129
+
130
+ // UI colors
131
+ accent: rgb(255, 150, 100),
132
+ muted: rgb(140, 120, 100),
133
+ subtle: rgb(100, 80, 60),
134
+ highlight: rgb(255, 255, 255),
135
+ };
136
+
137
+ // ═══════════════════════════════════════════════════════════════════════════════
138
+ // PREMIUM BANNER
139
+ // ═══════════════════════════════════════════════════════════════════════════════
140
+
141
+ const REALITY_BANNER = `
142
+ ${rgb(255, 160, 120)} ██████╗ ███████╗ █████╗ ██╗ ██╗████████╗██╗ ██╗${c.reset}
143
+ ${rgb(255, 140, 100)} ██╔══██╗██╔════╝██╔══██╗██║ ██║╚══██╔══╝╚██╗ ██╔╝${c.reset}
144
+ ${rgb(255, 120, 80)} ██████╔╝█████╗ ███████║██║ ██║ ██║ ╚████╔╝ ${c.reset}
145
+ ${rgb(255, 100, 60)} ██╔══██╗██╔══╝ ██╔══██║██║ ██║ ██║ ╚██╔╝ ${c.reset}
146
+ ${rgb(255, 80, 40)} ██║ ██║███████╗██║ ██║███████╗██║ ██║ ██║ ${c.reset}
147
+ ${rgb(255, 60, 20)} ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ${c.reset}
148
+ `;
149
+
150
+ const BANNER_FULL = `
151
+ ${rgb(255, 160, 120)} ██╗ ██╗██╗██████╗ ███████╗ ██████╗██╗ ██╗███████╗ ██████╗██╗ ██╗${c.reset}
152
+ ${rgb(255, 140, 100)} ██║ ██║██║██╔══██╗██╔════╝██╔════╝██║ ██║██╔════╝██╔════╝██║ ██╔╝${c.reset}
153
+ ${rgb(255, 120, 80)} ██║ ██║██║██████╔╝█████╗ ██║ ███████║█████╗ ██║ █████╔╝ ${c.reset}
154
+ ${rgb(255, 100, 60)} ╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██║ ██╔══██║██╔══╝ ██║ ██╔═██╗ ${c.reset}
155
+ ${rgb(255, 80, 40)} ╚████╔╝ ██║██████╔╝███████╗╚██████╗██║ ██║███████╗╚██████╗██║ ██╗${c.reset}
156
+ ${rgb(255, 60, 20)} ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝ ╚═╝${c.reset}
157
+
158
+ ${c.dim} ┌─────────────────────────────────────────────────────────────────────┐${c.reset}
159
+ ${c.dim} │${c.reset} ${rgb(255, 150, 100)}🎭${c.reset} ${c.bold}REALITY${c.reset} ${c.dim}•${c.reset} ${rgb(200, 200, 200)}Dead UI${c.reset} ${c.dim}•${c.reset} ${rgb(150, 150, 150)}Fake Data${c.reset} ${c.dim}•${c.reset} ${rgb(100, 200, 255)}Auth Coverage${c.reset} ${c.dim}│${c.reset}
160
+ ${c.dim} └─────────────────────────────────────────────────────────────────────┘${c.reset}
161
+ `;
162
+
163
+ // ═══════════════════════════════════════════════════════════════════════════════
164
+ // FAKE DATA DETECTION PATTERNS (from reality-mode/reality-scanner.ts)
165
+ // ═══════════════════════════════════════════════════════════════════════════════
166
+
167
+ // ═══════════════════════════════════════════════════════════════════════════════
168
+ // FAKE DETECTION PATTERNS WITH CONFIDENCE SCORING
169
+ // Each pattern has a confidence level to reduce false positives
170
+ // ═══════════════════════════════════════════════════════════════════════════════
171
+
172
+ const FAKE_DOMAIN_PATTERNS = [
173
+ // CRITICAL: These are almost certainly fake backends (confidence: 0.95+)
174
+ { pattern: /jsonplaceholder\.typicode\.com/i, name: "JSONPlaceholder mock API", confidence: 0.99, severity: 'BLOCK' },
175
+ { pattern: /reqres\.in/i, name: "ReqRes mock API", confidence: 0.99, severity: 'BLOCK' },
176
+ { pattern: /mockapi\.io/i, name: "MockAPI.io", confidence: 0.99, severity: 'BLOCK' },
177
+ { pattern: /mocky\.io/i, name: "Mocky.io", confidence: 0.99, severity: 'BLOCK' },
178
+ { pattern: /httpbin\.org/i, name: "HTTPBin testing API", confidence: 0.95, severity: 'BLOCK' },
179
+ { pattern: /api\.example\.com/i, name: "Example.com API", confidence: 0.95, severity: 'BLOCK' },
180
+ { pattern: /fake\.api/i, name: "Fake API pattern", confidence: 0.95, severity: 'BLOCK' },
181
+ { pattern: /demo\.api/i, name: "Demo API pattern", confidence: 0.90, severity: 'BLOCK' },
182
+
183
+ // HIGH: Likely development/testing (confidence: 0.7-0.9)
184
+ // NOTE: These could be legitimate in dev/CI contexts
185
+ { pattern: /localhost:\d+/i, name: "Localhost API", confidence: 0.75, severity: 'WARN', devContextOk: true },
186
+ { pattern: /127\.0\.0\.1:\d+/i, name: "Loopback API", confidence: 0.75, severity: 'WARN', devContextOk: true },
187
+ { pattern: /\.ngrok\.io/i, name: "Ngrok tunnel", confidence: 0.80, severity: 'WARN', devContextOk: true },
188
+ { pattern: /\.ngrok-free\.app/i, name: "Ngrok free tunnel", confidence: 0.80, severity: 'WARN', devContextOk: true },
189
+
190
+ // MEDIUM: Could be legitimate staging (confidence: 0.5-0.7)
191
+ // NOTE: Many organizations have legitimate staging environments
192
+ { pattern: /staging\.[^/]+\/api/i, name: "Staging API endpoint", confidence: 0.60, severity: 'WARN', stagingContextOk: true },
193
+ { pattern: /\.local\//i, name: "Local domain", confidence: 0.50, severity: 'WARN', devContextOk: true },
194
+ { pattern: /\.test\//i, name: "Test domain", confidence: 0.50, severity: 'WARN', devContextOk: true },
195
+ ];
196
+
197
+ const FAKE_RESPONSE_PATTERNS = [
198
+ // CRITICAL: Test API keys exposed (security issue)
199
+ { pattern: /sk_test_[a-zA-Z0-9]{20,}/i, name: "Test Stripe secret key", confidence: 0.99, severity: 'BLOCK' },
200
+ { pattern: /pk_test_[a-zA-Z0-9]{20,}/i, name: "Test Stripe public key", confidence: 0.95, severity: 'WARN' },
201
+
202
+ // HIGH: Clearly fake IDs/data
203
+ { pattern: /inv_demo_[a-zA-Z0-9]+/i, name: "Demo invoice ID", confidence: 0.95, severity: 'BLOCK' },
204
+ { pattern: /user_demo_[a-zA-Z0-9]+/i, name: "Demo user ID", confidence: 0.95, severity: 'BLOCK' },
205
+ { pattern: /cus_demo_[a-zA-Z0-9]+/i, name: "Demo customer ID", confidence: 0.95, severity: 'BLOCK' },
206
+ { pattern: /sub_demo_[a-zA-Z0-9]+/i, name: "Demo subscription ID", confidence: 0.95, severity: 'BLOCK' },
207
+ { pattern: /"mock":\s*true/i, name: "Mock flag enabled", confidence: 0.95, severity: 'BLOCK' },
208
+ { pattern: /"isDemo":\s*true/i, name: "Demo mode flag", confidence: 0.95, severity: 'BLOCK' },
209
+ { pattern: /"status":\s*"simulated"/i, name: "Simulated status", confidence: 0.90, severity: 'BLOCK' },
210
+
211
+ // MEDIUM: Placeholder content (could be legitimate in docs/examples)
212
+ // NOTE: Need context awareness - these are fine in documentation/help pages
213
+ { pattern: /lorem\s+ipsum\s+dolor/i, name: "Lorem ipsum placeholder", confidence: 0.70, severity: 'WARN', docsContextOk: true },
214
+ { pattern: /john\.doe@/i, name: "John Doe placeholder email", confidence: 0.65, severity: 'WARN', docsContextOk: true },
215
+ { pattern: /jane\.doe@/i, name: "Jane Doe placeholder email", confidence: 0.65, severity: 'WARN', docsContextOk: true },
216
+ { pattern: /user@example\.com/i, name: "Example.com email", confidence: 0.50, severity: 'WARN', docsContextOk: true },
217
+ { pattern: /placeholder\.(com|jpg|png)/i, name: "Placeholder domain/image", confidence: 0.60, severity: 'WARN', docsContextOk: true },
218
+
219
+ // LOWER: Could have many false positives
220
+ { pattern: /"id":\s*"demo"/i, name: "Demo ID value", confidence: 0.70, severity: 'WARN' },
221
+ { pattern: /"id":\s*"test"/i, name: "Test ID value", confidence: 0.60, severity: 'WARN' },
222
+ { pattern: /"success":\s*true[^}]*"demo"/i, name: "Demo success response", confidence: 0.75, severity: 'WARN' },
223
+ ];
224
+
225
+ // URLs that are allowed and should skip detection
226
+ const FAKE_DETECTION_ALLOWLIST = [
227
+ /\/docs?\//i, // Documentation pages
228
+ /\/help\//i, // Help pages
229
+ /\/examples?\//i, // Example pages
230
+ /\/demo\//i, // Demo pages (intentional)
231
+ /\/playground\//i, // Playground/sandbox
232
+ /\/api-docs?\//i, // API documentation
233
+ /\/swagger/i, // Swagger docs
234
+ /\/openapi/i, // OpenAPI docs
235
+ /readme/i, // README content
236
+ /changelog/i, // Changelog
237
+ ];
238
+
239
+ /**
240
+ * Classify a network request/response for fake data patterns
241
+ * Returns null if clean, or an object with detection details
242
+ *
243
+ * Enhanced with:
244
+ * - Confidence scoring to reduce false positives
245
+ * - Context awareness (dev, staging, docs)
246
+ * - Allowlist for legitimate use cases
247
+ */
248
+ function classifyNetworkTraffic(url, responseBody, status, context = {}) {
249
+ // Skip static assets (images, fonts, stylesheets, scripts)
250
+ if (/\.(js|css|png|jpg|jpeg|svg|ico|woff|woff2|ttf|eot|gif|webp|mp4|webm|pdf)(\?|$)/i.test(url)) {
251
+ return null;
252
+ }
253
+
254
+ // Check allowlist - skip detection for documentation/example URLs
255
+ for (const allowPattern of FAKE_DETECTION_ALLOWLIST) {
256
+ if (allowPattern.test(url)) {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ const detections = [];
262
+ const isDev = context.isDev || process.env.NODE_ENV === 'development';
263
+ const isStaging = context.isStaging || /staging|stg|preprod/i.test(url);
264
+ const isDocsPage = context.isDocsPage || /docs?|help|example|readme/i.test(url);
265
+
266
+ // Check for fake domain patterns
267
+ for (const { pattern, name, confidence, severity, devContextOk, stagingContextOk } of FAKE_DOMAIN_PATTERNS) {
268
+ if (pattern.test(url)) {
269
+ // Skip if this pattern is OK in current context
270
+ if (devContextOk && isDev) continue;
271
+ if (stagingContextOk && isStaging) continue;
272
+
273
+ detections.push({
274
+ type: 'fake-domain',
275
+ severity,
276
+ evidence: `URL matches fake domain pattern: ${name}`,
277
+ url,
278
+ confidence,
279
+ pattern: pattern.source
280
+ });
281
+ break; // One domain match is enough
282
+ }
283
+ }
284
+
285
+ // Check response body for fake data patterns
286
+ if (responseBody && typeof responseBody === 'string') {
287
+ // Skip very short responses (likely not meaningful data)
288
+ if (responseBody.length < 20) {
289
+ return detections.length > 0 ? detections : null;
290
+ }
291
+
292
+ for (const { pattern, name, confidence, severity, docsContextOk } of FAKE_RESPONSE_PATTERNS) {
293
+ // Skip patterns that are OK in docs context
294
+ if (docsContextOk && isDocsPage) continue;
295
+
296
+ if (pattern.test(responseBody)) {
297
+ detections.push({
298
+ type: 'fake-response',
299
+ severity,
300
+ evidence: `Response contains ${name}`,
301
+ url,
302
+ confidence,
303
+ pattern: pattern.source
304
+ });
305
+ }
306
+ }
307
+ }
308
+
309
+ // Check for suspicious status codes (with lower confidence)
310
+ if (status === 418 || status === 999 || status === 0) {
311
+ detections.push({
312
+ type: 'mock-status',
313
+ severity: 'WARN',
314
+ evidence: `Suspicious HTTP status code: ${status}`,
315
+ url,
316
+ confidence: 0.60 // Lower confidence - could be legitimate
317
+ });
318
+ }
319
+
320
+ // Filter out low-confidence detections if we have high-confidence ones
321
+ const highConfidence = detections.filter(d => d.confidence >= 0.80);
322
+ if (highConfidence.length > 0 && detections.length > highConfidence.length) {
323
+ // Return only high-confidence detections to reduce noise
324
+ return highConfidence;
325
+ }
326
+
327
+ return detections.length > 0 ? detections : null;
328
+ }
329
+
330
+ // ═══════════════════════════════════════════════════════════════════════════════
331
+ // ICONS & SYMBOLS
332
+ // ═══════════════════════════════════════════════════════════════════════════════
333
+
334
+ const ICONS = {
335
+ // Main
336
+ reality: '🎭',
337
+ browser: '🌐',
338
+ crawl: '🕷️',
339
+
340
+ // Status
341
+ check: '✓',
342
+ cross: '✗',
343
+ warning: '⚠',
344
+ info: 'ℹ',
345
+ arrow: '→',
346
+ bullet: '•',
347
+
348
+ // Passes
349
+ anon: '👤',
350
+ auth: '🔑',
351
+ pass: '✅',
352
+ fail: '❌',
353
+
354
+ // Categories
355
+ deadUI: '💀',
356
+ click: '👆',
357
+ link: '🔗',
358
+ http: '📡',
359
+ coverage: '📊',
360
+ shield: '🛡️',
361
+
362
+ // Actions
363
+ running: '▶',
364
+ complete: '●',
365
+ pending: '○',
366
+ skip: '◌',
367
+
368
+ // Objects
369
+ page: '📄',
370
+ screenshot: '📸',
371
+ clock: '⏱',
372
+ lightning: '⚡',
373
+ sparkle: '✨',
374
+ target: '🎯',
375
+ eye: '👁️',
376
+ };
377
+
378
+ // ═══════════════════════════════════════════════════════════════════════════════
379
+ // BOX DRAWING
380
+ // ═══════════════════════════════════════════════════════════════════════════════
381
+
382
+ const BOX = {
383
+ topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
384
+ horizontal: '─', vertical: '│',
385
+ teeRight: '├', teeLeft: '┤', teeDown: '┬', teeUp: '┴',
386
+ cross: '┼',
387
+ // Double line
388
+ dTopLeft: '╔', dTopRight: '╗', dBottomLeft: '╚', dBottomRight: '╝',
389
+ dHorizontal: '═', dVertical: '║',
390
+ // Heavy
391
+ hTopLeft: '┏', hTopRight: '┓', hBottomLeft: '┗', hBottomRight: '┛',
392
+ hHorizontal: '━', hVertical: '┃',
393
+ };
394
+
395
+ // ═══════════════════════════════════════════════════════════════════════════════
396
+ // SPINNER & PROGRESS
397
+ // ═══════════════════════════════════════════════════════════════════════════════
398
+
399
+ const SPINNER_DOTS = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
400
+ const SPINNER_CRAWL = ['🕷️ ', ' 🕷️', ' 🕷️', ' 🕷️', ' 🕷️', ' 🕷️'];
401
+
402
+ let spinnerIndex = 0;
403
+ let spinnerInterval = null;
404
+ let spinnerStartTime = null;
405
+
406
+ function formatDuration(ms) {
407
+ if (ms < 1000) return `${ms}ms`;
408
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
409
+ const mins = Math.floor(ms / 60000);
410
+ const secs = Math.floor((ms % 60000) / 1000);
411
+ return `${mins}m ${secs}s`;
412
+ }
413
+
414
+ function formatNumber(num) {
415
+ return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
416
+ }
417
+
418
+ function truncate(str, len) {
419
+ if (!str) return '';
420
+ if (str.length <= len) return str;
421
+ return str.slice(0, len - 3) + '...';
422
+ }
423
+
424
+ function padCenter(str, width) {
425
+ const padding = Math.max(0, width - str.length);
426
+ const left = Math.floor(padding / 2);
427
+ const right = padding - left;
428
+ return ' '.repeat(left) + str + ' '.repeat(right);
429
+ }
430
+
431
+ function progressBar(percent, width = 30, opts = {}) {
432
+ const filled = Math.round((percent / 100) * width);
433
+ const empty = width - filled;
434
+
435
+ let filledColor = opts.color || colors.accent;
436
+ if (!opts.color) {
437
+ if (percent >= 80) filledColor = colors.success;
438
+ else if (percent >= 50) filledColor = colors.warning;
439
+ else filledColor = colors.error;
440
+ }
441
+
442
+ const filledChar = opts.filled || '█';
443
+ const emptyChar = opts.empty || '░';
444
+
445
+ return `${filledColor}${filledChar.repeat(filled)}${c.dim}${emptyChar.repeat(empty)}${c.reset}`;
446
+ }
447
+
448
+ function startSpinner(message, color = colors.accent) {
449
+ spinnerStartTime = Date.now();
450
+ process.stdout.write(c.hideCursor);
451
+
452
+ spinnerInterval = setInterval(() => {
453
+ const elapsed = formatDuration(Date.now() - spinnerStartTime);
454
+ process.stdout.write(`\r${c.clearLine} ${color}${SPINNER_DOTS[spinnerIndex]}${c.reset} ${message} ${c.dim}${elapsed}${c.reset}`);
455
+ spinnerIndex = (spinnerIndex + 1) % SPINNER_DOTS.length;
456
+ }, 80);
457
+ }
458
+
459
+ function stopSpinner(message, success = true) {
460
+ if (spinnerInterval) {
461
+ clearInterval(spinnerInterval);
462
+ spinnerInterval = null;
463
+ }
464
+ const elapsed = spinnerStartTime ? formatDuration(Date.now() - spinnerStartTime) : '';
465
+ const icon = success ? `${colors.success}${ICONS.check}${c.reset}` : `${colors.error}${ICONS.cross}${c.reset}`;
466
+ process.stdout.write(`\r${c.clearLine} ${icon} ${message} ${c.dim}${elapsed}${c.reset}\n`);
467
+ process.stdout.write(c.showCursor);
468
+ spinnerStartTime = null;
469
+ }
470
+
471
+ function updateSpinnerMessage(message) {
472
+ // Update message while spinner keeps running
473
+ }
474
+
475
+ // ═══════════════════════════════════════════════════════════════════════════════
476
+ // SECTION HEADERS
477
+ // ═══════════════════════════════════════════════════════════════════════════════
478
+
479
+ function printBanner() {
480
+ console.log(BANNER_FULL);
481
+ }
482
+
483
+ function printCompactBanner() {
484
+ console.log(REALITY_BANNER);
485
+ }
486
+
487
+ function printDivider(char = '─', width = 69, color = c.dim) {
488
+ console.log(`${color} ${char.repeat(width)}${c.reset}`);
489
+ }
490
+
491
+ function printSection(title, icon = '◆') {
492
+ console.log();
493
+ console.log(` ${colors.accent}${icon}${c.reset} ${c.bold}${title}${c.reset}`);
494
+ printDivider();
495
+ }
496
+
497
+ // ═══════════════════════════════════════════════════════════════════════════════
498
+ // PASS DISPLAY - Two-Pass Visualization
499
+ // ═══════════════════════════════════════════════════════════════════════════════
500
+
501
+ function printPassHeader(passType, url = null) {
502
+ const isAnon = passType === 'anon' || passType === 'ANON';
503
+ const config = isAnon
504
+ ? { icon: ICONS.anon, name: 'PASS A: ANONYMOUS', color: colors.anon, desc: 'Crawling without authentication' }
505
+ : { icon: ICONS.auth, name: 'PASS B: AUTHENTICATED', color: colors.auth, desc: 'Crawling with session state' };
506
+
507
+ console.log();
508
+ console.log(` ${config.color}${BOX.hTopLeft}${BOX.hHorizontal.repeat(3)}${c.reset} ${config.icon} ${c.bold}${config.name}${c.reset}`);
509
+ console.log(` ${config.color}${BOX.hVertical}${c.reset} ${c.dim}${config.desc}${c.reset}`);
510
+ if (url) {
511
+ console.log(` ${config.color}${BOX.hVertical}${c.reset} ${colors.accent}${url}${c.reset}`);
512
+ }
513
+ console.log(` ${config.color}${BOX.hBottomLeft}${BOX.hHorizontal.repeat(60)}${c.reset}`);
514
+ }
515
+
516
+ function printPassResult(passType, result) {
517
+ const isAnon = passType === 'anon' || passType === 'ANON';
518
+ const config = isAnon
519
+ ? { icon: ICONS.anon, color: colors.anon }
520
+ : { icon: ICONS.auth, color: colors.auth };
521
+
522
+ const pages = result.pagesVisited?.length || 0;
523
+ const findings = result.findings?.length || 0;
524
+ const blocks = result.findings?.filter(f => f.severity === 'BLOCK').length || 0;
525
+ const warns = result.findings?.filter(f => f.severity === 'WARN').length || 0;
526
+
527
+ console.log();
528
+ console.log(` ${config.color}${config.icon}${c.reset} ${c.bold}${isAnon ? 'Anonymous' : 'Authenticated'} Pass Complete${c.reset}`);
529
+ console.log(` ${c.dim}Pages visited:${c.reset} ${colors.info}${pages}${c.reset}`);
530
+ console.log(` ${c.dim}Findings:${c.reset} ${findings} ${c.dim}(${c.reset}${blocks > 0 ? colors.error : colors.success}${blocks} blockers${c.reset}${c.dim},${c.reset} ${warns > 0 ? colors.warning : colors.success}${warns} warnings${c.reset}${c.dim})${c.reset}`);
531
+ }
532
+
533
+ // ═══════════════════════════════════════════════════════════════════════════════
534
+ // COVERAGE DISPLAY
535
+ // ═══════════════════════════════════════════════════════════════════════════════
536
+
537
+ function printCoverageCard(coverage, anonPages, authPages, maxPages) {
538
+ if (!coverage) return;
539
+
540
+ printSection('COVERAGE', ICONS.coverage);
541
+ console.log();
542
+
543
+ // UI Path Coverage
544
+ const pct = coverage.percent || 0;
545
+ const pctColor = pct >= 80 ? colors.success : pct >= 50 ? colors.warning : colors.error;
546
+
547
+ console.log(` ${c.bold}UI Path Coverage${c.reset}`);
548
+ console.log(` ${progressBar(pct, 40)} ${pctColor}${c.bold}${pct}%${c.reset}`);
549
+ console.log(` ${c.dim}${coverage.hit}/${coverage.total} paths visited${c.reset}`);
550
+
551
+ // Pages visited breakdown
552
+ console.log();
553
+ console.log(` ${c.bold}Pages Crawled${c.reset}`);
554
+
555
+ const anonPct = Math.round((anonPages / maxPages) * 100);
556
+ console.log(` ${ICONS.anon} ${c.dim}Anonymous:${c.reset} ${progressBar(anonPct, 25, { color: colors.anon })} ${anonPages}/${maxPages}`);
557
+
558
+ if (authPages !== null && authPages !== undefined) {
559
+ const authPct = Math.round((authPages / maxPages) * 100);
560
+ console.log(` ${ICONS.auth} ${c.dim}Authenticated:${c.reset} ${progressBar(authPct, 25, { color: colors.auth })} ${authPages}/${maxPages}`);
561
+ }
562
+
563
+ // Missed paths
564
+ if (coverage.missed && coverage.missed.length > 0) {
565
+ console.log();
566
+ console.log(` ${c.dim}Missed paths (${coverage.missed.length}):${c.reset}`);
567
+ for (const missed of coverage.missed.slice(0, 5)) {
568
+ console.log(` ${colors.warning}${ICONS.warning}${c.reset} ${c.dim}${truncate(missed, 50)}${c.reset}`);
569
+ }
570
+ if (coverage.missed.length > 5) {
571
+ console.log(` ${c.dim}... and ${coverage.missed.length - 5} more${c.reset}`);
572
+ }
573
+ }
574
+ }
575
+
576
+ // ═══════════════════════════════════════════════════════════════════════════════
577
+ // FINDINGS DISPLAY
578
+ // ═══════════════════════════════════════════════════════════════════════════════
579
+
580
+ function getSeverityStyle(severity) {
581
+ const styles = {
582
+ BLOCK: { color: colors.error, bg: bgRgb(80, 20, 20), icon: '●', label: 'BLOCKER' },
583
+ WARN: { color: colors.warning, bg: bgRgb(80, 60, 0), icon: '◐', label: 'WARNING' },
584
+ INFO: { color: colors.info, bg: bgRgb(20, 40, 60), icon: '○', label: 'INFO' },
585
+ };
586
+ return styles[severity] || styles.INFO;
587
+ }
588
+
589
+ function getCategoryIcon(category) {
590
+ const icons = {
591
+ 'DeadUI': ICONS.deadUI,
592
+ 'AuthCoverage': ICONS.shield,
593
+ 'HTTPError': ICONS.http,
594
+ // Fake data detection categories
595
+ 'FakeDomain': '🔗',
596
+ 'FakeResponse': '🎭',
597
+ 'MockStatus': '📡',
598
+ };
599
+ return icons[category] || ICONS.bullet;
600
+ }
601
+
602
+ function getCategoryColor(category) {
603
+ const categoryColors = {
604
+ 'DeadUI': colors.deadUI,
605
+ 'AuthCoverage': colors.authCoverage,
606
+ 'HTTPError': colors.httpError,
607
+ // Fake data detection categories - all critical (red/orange)
608
+ 'FakeDomain': rgb(255, 80, 80), // Red - critical
609
+ 'FakeResponse': rgb(255, 100, 60), // Orange-red
610
+ 'MockStatus': rgb(255, 150, 50), // Amber
611
+ };
612
+ return categoryColors[category] || colors.accent;
613
+ }
614
+
615
+ function printFindingsBreakdown(findings) {
616
+ if (!findings || findings.length === 0) {
617
+ printSection('FINDINGS', ICONS.check);
618
+ console.log();
619
+ console.log(` ${colors.success}${c.bold}${ICONS.sparkle} No issues found! UI is responsive.${c.reset}`);
620
+ return;
621
+ }
622
+
623
+ // Group by category
624
+ const byCategory = {};
625
+ for (const f of findings) {
626
+ const cat = f.category || 'Other';
627
+ if (!byCategory[cat]) byCategory[cat] = [];
628
+ byCategory[cat].push(f);
629
+ }
630
+
631
+ const blocks = findings.filter(f => f.severity === 'BLOCK');
632
+ const warns = findings.filter(f => f.severity === 'WARN');
633
+
634
+ printSection(`FINDINGS (${blocks.length} blockers, ${warns.length} warnings)`, ICONS.target);
635
+ console.log();
636
+
637
+ // Summary by category
638
+ for (const [category, catFindings] of Object.entries(byCategory)) {
639
+ const catBlocks = catFindings.filter(f => f.severity === 'BLOCK').length;
640
+ const catWarns = catFindings.filter(f => f.severity === 'WARN').length;
641
+ const icon = getCategoryIcon(category);
642
+ const catColor = getCategoryColor(category);
643
+
644
+ const statusIcon = catBlocks > 0 ? ICONS.cross : catWarns > 0 ? ICONS.warning : ICONS.check;
645
+ const statusColor = catBlocks > 0 ? colors.error : catWarns > 0 ? colors.warning : colors.success;
646
+
647
+ console.log(` ${statusColor}${statusIcon}${c.reset} ${icon} ${c.bold}${category.padEnd(18)}${c.reset} ${catBlocks > 0 ? `${colors.error}${catBlocks} blockers${c.reset} ` : ''}${catWarns > 0 ? `${colors.warning}${catWarns} warnings${c.reset}` : ''}`);
648
+ }
649
+ }
650
+
651
+ function printBlockerDetails(findings, maxShow = 6) {
652
+ const blockers = findings.filter(f => f.severity === 'BLOCK');
653
+
654
+ if (blockers.length === 0) return;
655
+
656
+ printSection(`BLOCKERS (${blockers.length})`, '🚨');
657
+ console.log();
658
+
659
+ for (const blocker of blockers.slice(0, maxShow)) {
660
+ const style = getSeverityStyle(blocker.severity);
661
+ const icon = getCategoryIcon(blocker.category);
662
+
663
+ // Severity badge
664
+ console.log(` ${style.bg}${c.bold} ${style.label} ${c.reset} ${icon} ${c.bold}${truncate(blocker.title, 45)}${c.reset}`);
665
+
666
+ // Page URL
667
+ if (blocker.page) {
668
+ console.log(` ${' '.repeat(10)} ${colors.info}${ICONS.page} ${truncate(blocker.page, 50)}${c.reset}`);
669
+ }
670
+
671
+ // Reason
672
+ if (blocker.reason) {
673
+ console.log(` ${' '.repeat(10)} ${c.dim}${truncate(blocker.reason, 50)}${c.reset}`);
674
+ }
675
+
676
+ // Screenshot
677
+ if (blocker.screenshot) {
678
+ console.log(` ${' '.repeat(10)} ${colors.accent}${ICONS.screenshot} ${truncate(blocker.screenshot, 45)}${c.reset}`);
679
+ }
680
+
681
+ console.log();
682
+ }
683
+
684
+ if (blockers.length > maxShow) {
685
+ console.log(` ${c.dim}... and ${blockers.length - maxShow} more blockers (see full report)${c.reset}`);
686
+ console.log();
687
+ }
688
+ }
689
+
690
+ // ═══════════════════════════════════════════════════════════════════════════════
691
+ // VERDICT DISPLAY
692
+ // ═══════════════════════════════════════════════════════════════════════════════
693
+
694
+ function getVerdictConfig(blocks, warns) {
695
+ if (blocks === 0 && warns === 0) {
696
+ return {
697
+ verdict: 'CLEAN',
698
+ icon: '✅',
699
+ headline: 'REALITY VERIFIED',
700
+ tagline: 'All UI elements are responsive and functional!',
701
+ color: colors.success,
702
+ bgColor: bgRgb(0, 80, 50),
703
+ borderColor: rgb(0, 200, 120),
704
+ };
705
+ }
706
+
707
+ if (blocks === 0) {
708
+ return {
709
+ verdict: 'WARN',
710
+ icon: '⚠️',
711
+ headline: 'MINOR ISSUES',
712
+ tagline: `${warns} warning${warns !== 1 ? 's' : ''} found - review recommended`,
713
+ color: colors.warning,
714
+ bgColor: bgRgb(80, 60, 0),
715
+ borderColor: rgb(200, 160, 0),
716
+ };
717
+ }
718
+
719
+ return {
720
+ verdict: 'BLOCK',
721
+ icon: '🛑',
722
+ headline: 'DEAD UI DETECTED',
723
+ tagline: `${blocks} blocker${blocks !== 1 ? 's' : ''} must be fixed`,
724
+ color: colors.error,
725
+ bgColor: bgRgb(80, 20, 20),
726
+ borderColor: rgb(200, 60, 60),
727
+ };
728
+ }
729
+
730
+ function printVerdictCard(blocks, warns, duration) {
731
+ const config = getVerdictConfig(blocks, warns);
732
+ const w = 68;
733
+
734
+ console.log();
735
+ console.log();
736
+
737
+ // Top border
738
+ console.log(` ${config.borderColor}${BOX.dTopLeft}${BOX.dHorizontal.repeat(w)}${BOX.dTopRight}${c.reset}`);
739
+
740
+ // Empty line
741
+ console.log(` ${config.borderColor}${BOX.dVertical}${c.reset}${' '.repeat(w)}${config.borderColor}${BOX.dVertical}${c.reset}`);
742
+
743
+ // Icon and headline
744
+ const headlineText = `${config.icon} ${config.headline}`;
745
+ const headlinePadded = padCenter(headlineText, w);
746
+ console.log(` ${config.borderColor}${BOX.dVertical}${c.reset}${config.color}${c.bold}${headlinePadded}${c.reset}${config.borderColor}${BOX.dVertical}${c.reset}`);
747
+
748
+ // Tagline
749
+ const taglinePadded = padCenter(config.tagline, w);
750
+ console.log(` ${config.borderColor}${BOX.dVertical}${c.reset}${c.dim}${taglinePadded}${c.reset}${config.borderColor}${BOX.dVertical}${c.reset}`);
751
+
752
+ // Empty line
753
+ console.log(` ${config.borderColor}${BOX.dVertical}${c.reset}${' '.repeat(w)}${config.borderColor}${BOX.dVertical}${c.reset}`);
754
+
755
+ // Stats row
756
+ const stats = `Blockers: ${blocks} • Warnings: ${warns} • Duration: ${formatDuration(duration)}`;
757
+ const statsPadded = padCenter(stats, w);
758
+ console.log(` ${config.borderColor}${BOX.dVertical}${c.reset}${c.dim}${statsPadded}${c.reset}${config.borderColor}${BOX.dVertical}${c.reset}`);
759
+
760
+ // Empty line
761
+ console.log(` ${config.borderColor}${BOX.dVertical}${c.reset}${' '.repeat(w)}${config.borderColor}${BOX.dVertical}${c.reset}`);
762
+
763
+ // Bottom border
764
+ console.log(` ${config.borderColor}${BOX.dBottomLeft}${BOX.dHorizontal.repeat(w)}${BOX.dBottomRight}${c.reset}`);
765
+
766
+ console.log();
767
+ }
768
+
769
+ // ═══════════════════════════════════════════════════════════════════════════════
770
+ // TIER WARNING DISPLAY
771
+ // ═══════════════════════════════════════════════════════════════════════════════
772
+
773
+ function printTierWarning(tier, limits, originalMaxPages, appliedMaxPages, verifyAuthRequested, verifyAuthApplied) {
774
+ if (tier !== 'free') return;
775
+
776
+ console.log();
777
+ console.log(` ${colors.warning}${ICONS.warning}${c.reset} ${c.bold}FREE TIER: Preview Mode${c.reset}`);
778
+
779
+ if (originalMaxPages > appliedMaxPages) {
780
+ console.log(` ${c.dim}Pages capped:${c.reset} ${appliedMaxPages} ${c.dim}(requested ${originalMaxPages})${c.reset}`);
781
+ }
782
+
783
+ if (verifyAuthRequested && !verifyAuthApplied) {
784
+ console.log(` ${c.dim}Auth boundary:${c.reset} ${colors.error}disabled${c.reset} ${c.dim}(requires STARTER+)${c.reset}`);
785
+ }
786
+
787
+ console.log(` ${colors.accent}Upgrade:${c.reset} ${c.dim}https://vibecheckai.dev/pricing${c.reset}`);
788
+ console.log();
789
+ }
790
+
791
+ // ═══════════════════════════════════════════════════════════════════════════════
792
+ // HELP DISPLAY
793
+ // ═══════════════════════════════════════════════════════════════════════════════
794
+
795
+ function printHelp(opts = {}) {
796
+ if (shouldShowBanner(opts)) {
797
+ console.log(BANNER_FULL);
798
+ }
799
+ console.log(`
800
+ ${c.bold}Usage:${c.reset} vibecheck reality --url <url> [options]
801
+
802
+ ${c.bold}Runtime UI Verification${c.reset} — Prove your UI actually works.
803
+
804
+ ${c.bold}Two-Pass Architecture:${c.reset}
805
+ ${colors.anon}${ICONS.anon} Pass A (Anon)${c.reset} Crawl without auth, record protected routes
806
+ ${colors.auth}${ICONS.auth} Pass B (Auth)${c.reset} Re-crawl with session, verify access
807
+
808
+ ${c.bold}What It Detects:${c.reset}
809
+ ${colors.deadUI}${ICONS.deadUI} Dead UI${c.reset} Clicks that do nothing
810
+ ${colors.authCoverage}${ICONS.shield} Auth Gaps${c.reset} Protected routes accessible anonymously
811
+ ${colors.httpError}${ICONS.http} HTTP Errors${c.reset} 4xx/5xx responses
812
+
813
+ ${c.bold}Options:${c.reset}
814
+ ${colors.accent}--url, -u <url>${c.reset} Base URL for testing ${c.dim}(required)${c.reset}
815
+ ${colors.accent}--auth <email:pass>${c.reset} Login credentials for auth verification
816
+ ${colors.accent}--storage-state <path>${c.reset} Playwright session state file
817
+ ${colors.accent}--save-storage-state <p>${c.reset} Save session after login
818
+ ${colors.accent}--truthpack <path>${c.reset} Custom truthpack path
819
+ ${colors.accent}--verify-auth${c.reset} Enable two-pass auth verification
820
+ ${colors.accent}--headed${c.reset} Run browser visible ${c.dim}(for debugging)${c.reset}
821
+ ${colors.accent}--danger${c.reset} Allow clicking destructive elements
822
+ ${colors.accent}--max-pages <n>${c.reset} Max pages to crawl ${c.dim}(default: 18)${c.reset}
823
+ ${colors.accent}--max-depth <n>${c.reset} Max crawl depth ${c.dim}(default: 2)${c.reset}
824
+ ${colors.accent}--timeout <ms>${c.reset} Page timeout ${c.dim}(default: 15000)${c.reset}
825
+ ${colors.accent}--help, -h${c.reset} Show this help
826
+
827
+ ${c.bold}Visual Artifacts:${c.reset}
828
+ ${colors.accent}--video, --record-video${c.reset} Record video of browser sessions
829
+ ${colors.accent}--trace, --record-trace${c.reset} Record Playwright trace (viewable in trace.playwright.dev)
830
+ ${colors.accent}--har, --record-har${c.reset} Record HAR network traffic
831
+
832
+ ${c.bold}Flakiness Reduction:${c.reset}
833
+ ${colors.accent}--retries <n>${c.reset} Retry failed nav/clicks ${c.dim}(default: 2)${c.reset}
834
+ ${colors.accent}--stable-wait <ms>${c.reset} Wait after actions ${c.dim}(default: 500ms)${c.reset}
835
+ ${colors.accent}--stability-runs <n>${c.reset} Run N times for stability check ${c.dim}(default: 1)${c.reset}
836
+ ${colors.accent}--flaky-threshold <f>${c.reset} Min occurrence rate to report ${c.dim}(default: 0.66)${c.reset}
837
+
838
+ ${c.bold}Tier Limits:${c.reset}
839
+ ${c.dim}FREE${c.reset} 5 pages, no auth boundary
840
+ ${c.dim}STARTER${c.reset} Full budgets + basic auth
841
+ ${c.dim}PRO${c.reset} Advanced auth (multi-role)
842
+
843
+ ${c.bold}Exit Codes:${c.reset}
844
+ ${colors.success}0${c.reset} CLEAN — No issues found
845
+ ${colors.warning}1${c.reset} WARN — Warnings found
846
+ ${colors.error}2${c.reset} BLOCK — Blockers found (dead UI, auth gaps)
847
+
848
+ ${c.bold}Examples:${c.reset}
849
+ ${c.dim}# Basic crawl${c.reset}
850
+ vibecheck reality --url http://localhost:3000
851
+
852
+ ${c.dim}# With auth verification${c.reset}
853
+ vibecheck reality --url http://localhost:3000 --verify-auth --auth user@test.com:pass
854
+
855
+ ${c.dim}# Debug mode (visible browser)${c.reset}
856
+ vibecheck reality --url http://localhost:3000 --headed
857
+
858
+ ${c.dim}# Allow destructive actions${c.reset}
859
+ vibecheck reality --url http://localhost:3000 --danger
860
+ `);
861
+ }
862
+
863
+ // ═══════════════════════════════════════════════════════════════════════════════
864
+ // UTILITY FUNCTIONS (preserved from original)
865
+ // ═══════════════════════════════════════════════════════════════════════════════
866
+
867
+ function ensureDir(p) {
868
+ fs.mkdirSync(p, { recursive: true });
869
+ }
870
+
871
+ function stamp() {
872
+ const d = new Date();
873
+ const z = (n) => String(n).padStart(2, "0");
874
+ return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}_${z(d.getHours())}${z(d.getMinutes())}${z(d.getSeconds())}`;
875
+ }
876
+
877
+ function sha1(s) {
878
+ return crypto.createHash("sha1").update(String(s)).digest("hex");
879
+ }
880
+
881
+ function normalizeUrl(u) {
882
+ try {
883
+ const url = new URL(u);
884
+ url.hash = "";
885
+ return url.toString();
886
+ } catch {
887
+ return u;
888
+ }
889
+ }
890
+
891
+ function sameOrigin(a, b) {
892
+ try {
893
+ return new URL(a).origin === new URL(b).origin;
894
+ } catch {
895
+ return false;
896
+ }
897
+ }
898
+
899
+ function pathFromUrl(u) {
900
+ try {
901
+ return new URL(u).pathname || "/";
902
+ } catch {
903
+ return "/";
904
+ }
905
+ }
906
+
907
+ function looksRisky(text) {
908
+ const t = String(text || "").toLowerCase();
909
+ return /\b(delete|remove|destroy|wipe|purge|drop|cancel\s+plan|unsubscribe|terminate)\b/.test(t);
910
+ }
911
+
912
+ function looksAuthAction(text) {
913
+ const t = String(text || "").toLowerCase();
914
+ return /\b(logout|sign\s*out)\b/.test(t);
915
+ }
916
+
917
+ function loadTruthpack(repoRoot, truthpackRel) {
918
+ const rel = truthpackRel || path.join(".vibecheck", "truth", "truthpack.json");
919
+ const abs = path.isAbsolute(rel) ? rel : path.join(repoRoot, rel);
920
+ if (!fs.existsSync(abs)) return null;
921
+ try {
922
+ return JSON.parse(fs.readFileSync(abs, "utf8"));
923
+ } catch {
924
+ return null;
925
+ }
926
+ }
927
+
928
+ function compileNextMatcher(pattern) {
929
+ const p = String(pattern || "").trim();
930
+ if (!p) return null;
931
+ const norm = p.startsWith("/") ? p : `/${p}`;
932
+ const esc = norm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
933
+ if (norm.includes(":path*")) {
934
+ const base = esc.replace(/\\:path\\\*/g, "");
935
+ return new RegExp(`^${base}(\\/.*)?$`, "i");
936
+ }
937
+ return new RegExp(`^${esc}$`, "i");
938
+ }
939
+
940
+ function getProtectedMatchersFromTruthpack(truthpack) {
941
+ const patterns = truthpack?.auth?.nextMatcherPatterns || [];
942
+ const out = [];
943
+ for (const p of patterns) {
944
+ const rx = compileNextMatcher(p);
945
+ if (rx) out.push({ pattern: p, rx });
946
+ }
947
+ return out;
948
+ }
949
+
950
+ async function isLoginPage(page) {
951
+ try {
952
+ return await page.evaluate(() => {
953
+ const hasPass = !!document.querySelector("input[type='password']");
954
+ const txt = (document.body?.innerText || "").toLowerCase();
955
+ const signInWords = /(sign in|log in|login|welcome back|forgot password)/.test(txt);
956
+ const urlLooks = /(login|signin|auth)/.test(location.pathname.toLowerCase());
957
+ return hasPass || signInWords || urlLooks;
958
+ });
959
+ } catch {
960
+ return false;
961
+ }
962
+ }
963
+
964
+ async function pageSignature(page) {
965
+ return page.evaluate(() => {
966
+ const c = document.querySelectorAll("a,button,input,select,textarea,[role='button']").length;
967
+ const t = document.body?.innerText?.length || 0;
968
+ return `${location.href}|${c}|${t}`;
969
+ });
970
+ }
971
+
972
+ async function clickOutcome(page, locator, opts = {}) {
973
+ const beforeSig = await pageSignature(page);
974
+ const beforeUrl = page.url();
975
+ const beforeReq = opts.reqCounter.value;
976
+
977
+ // Enhanced mutation observer that detects more changes including:
978
+ // - DOM structure changes (childList, subtree)
979
+ // - Attribute changes (class, style, aria-*, data-*)
980
+ // - CSS visibility/display changes
981
+ const domPromise = page.evaluate(() => {
982
+ return new Promise((resolve) => {
983
+ let changeCount = 0;
984
+ let attributeChanges = [];
985
+
986
+ const obs = new MutationObserver((mutations) => {
987
+ for (const mutation of mutations) {
988
+ changeCount++;
989
+ if (mutation.type === 'attributes') {
990
+ attributeChanges.push({
991
+ attr: mutation.attributeName,
992
+ target: mutation.target.tagName
993
+ });
994
+ }
995
+ }
996
+ });
997
+
998
+ obs.observe(document.documentElement, {
999
+ childList: true,
1000
+ subtree: true,
1001
+ attributes: true,
1002
+ attributeFilter: ['class', 'style', 'aria-expanded', 'aria-hidden', 'aria-selected',
1003
+ 'data-state', 'hidden', 'open', 'data-open', 'data-closed']
1004
+ });
1005
+
1006
+ setTimeout(() => {
1007
+ try { obs.disconnect(); } catch {}
1008
+ resolve({
1009
+ changed: changeCount > 0,
1010
+ changeCount,
1011
+ attributeChanges: attributeChanges.slice(0, 10) // Limit for performance
1012
+ });
1013
+ }, 900);
1014
+ });
1015
+ });
1016
+
1017
+ // Also track CSS visibility changes via computed styles
1018
+ const beforeVisibility = await page.evaluate(() => {
1019
+ const modals = document.querySelectorAll('[role="dialog"], .modal, .dropdown, .popover, [data-state]');
1020
+ return Array.from(modals).slice(0, 20).map(el => ({
1021
+ visible: getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden',
1022
+ state: el.getAttribute('data-state')
1023
+ }));
1024
+ }).catch(() => []);
1025
+
1026
+ const navPromise = page.waitForNavigation({ timeout: 1200 }).then(() => true).catch(() => false);
1027
+ const clickRes = await locator.click({ timeout: 1200 }).then(() => ({ ok: true })).catch((e) => ({ ok: false, error: String(e?.message || e) }));
1028
+
1029
+ const navRes = await navPromise;
1030
+ const domRes = await domPromise;
1031
+ await page.waitForTimeout(300); // Slightly longer wait for CSS transitions
1032
+
1033
+ const afterSig = await pageSignature(page);
1034
+ const afterUrl = page.url();
1035
+
1036
+ // Check CSS visibility changes
1037
+ const afterVisibility = await page.evaluate(() => {
1038
+ const modals = document.querySelectorAll('[role="dialog"], .modal, .dropdown, .popover, [data-state]');
1039
+ return Array.from(modals).slice(0, 20).map(el => ({
1040
+ visible: getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden',
1041
+ state: el.getAttribute('data-state')
1042
+ }));
1043
+ }).catch(() => []);
1044
+
1045
+ // Detect visibility state changes (modal open/close, dropdown toggle, etc.)
1046
+ const visibilityChanged = JSON.stringify(beforeVisibility) !== JSON.stringify(afterVisibility);
1047
+
1048
+ return {
1049
+ clickOk: clickRes.ok,
1050
+ clickError: clickRes.error || null,
1051
+ navHappened: !!navRes,
1052
+ urlChanged: normalizeUrl(afterUrl) !== normalizeUrl(beforeUrl),
1053
+ domChanged: !!domRes?.changed || afterSig !== beforeSig,
1054
+ visibilityChanged, // NEW: Tracks CSS visibility/state changes
1055
+ changeCount: domRes?.changeCount || 0, // NEW: Number of mutations detected
1056
+ reqDelta: Math.max(0, opts.reqCounter.value - beforeReq),
1057
+ beforeUrl,
1058
+ afterUrl
1059
+ };
1060
+ }
1061
+
1062
+ async function collectLinks(page, baseUrl) {
1063
+ const links = await page.evaluate(() => Array.from(document.querySelectorAll("a[href]")).map(a => a.getAttribute("href")).filter(Boolean));
1064
+ return links.map(href => { try { return new URL(href, baseUrl).toString(); } catch { return null; } }).filter(Boolean);
1065
+ }
1066
+
1067
+ async function collectInteractives(page) {
1068
+ return page.evaluate(() => {
1069
+ const nodes = Array.from(document.querySelectorAll("button, a[href], input[type='submit'], [role='button'], [onclick]"));
1070
+ return nodes.slice(0, 80).map((el, idx) => {
1071
+ const text = (el.getAttribute("aria-label") || el.innerText || "").trim().slice(0, 80).toLowerCase();
1072
+ const classList = Array.from(el.classList).join(' ').toLowerCase();
1073
+ const id = (el.id || "").toLowerCase();
1074
+ const dataTestId = el.getAttribute("data-testid") || "";
1075
+
1076
+ // Detect element context for false positive reduction
1077
+ const isInsideModal = !!el.closest('[role="dialog"], [role="alertdialog"], .modal, .dialog, [data-radix-dialog-content]');
1078
+ const isInsideDropdown = !!el.closest('[role="menu"], [role="listbox"], .dropdown, .popover, [data-radix-menu-content]');
1079
+ const isInsideAccordion = !!el.closest('[role="region"], .accordion, [data-state], [data-radix-accordion-content]');
1080
+ const isInsideTooltip = !!el.closest('[role="tooltip"], .tooltip');
1081
+
1082
+ // Detect button intent for false positive reduction
1083
+ const looksLikeClose = /close|dismiss|cancel|x|×|✕|✖/i.test(text) || /close|dismiss/i.test(classList);
1084
+ const looksLikeToggle = /toggle|expand|collapse|show|hide|menu|hamburger|more/i.test(text) || /toggle|accordion|collaps/i.test(classList);
1085
+ const looksLikeCopy = /copy|clipboard/i.test(text) || /copy/i.test(classList);
1086
+ const looksLikeTheme = /theme|dark|light|mode/i.test(text) || /theme/i.test(classList);
1087
+ const looksLikeTab = el.getAttribute("role") === "tab" || /tab/i.test(classList);
1088
+ const looksLikeSort = /sort|order|filter/i.test(text);
1089
+
1090
+ // Elements that legitimately may not trigger detectable changes
1091
+ const isLikelyFalsePositive = looksLikeClose || looksLikeToggle || looksLikeCopy ||
1092
+ looksLikeTheme || looksLikeTab || looksLikeSort ||
1093
+ isInsideModal || isInsideDropdown || isInsideTooltip;
1094
+
1095
+ return {
1096
+ idx,
1097
+ tag: el.tagName.toLowerCase(),
1098
+ role: el.getAttribute("role") || "",
1099
+ href: el.tagName === "A" ? el.getAttribute("href") || "" : "",
1100
+ text: (el.getAttribute("aria-label") || el.innerText || "").trim().slice(0, 80),
1101
+ id: el.id || "",
1102
+ disabled: !!(el.disabled || el.getAttribute("aria-disabled") === "true"),
1103
+ key: `${el.tagName}|${el.id}|${idx}`,
1104
+ // Context for false positive reduction
1105
+ context: {
1106
+ isInsideModal,
1107
+ isInsideDropdown,
1108
+ isInsideAccordion,
1109
+ isInsideTooltip,
1110
+ looksLikeClose,
1111
+ looksLikeToggle,
1112
+ looksLikeCopy,
1113
+ looksLikeTheme,
1114
+ looksLikeTab,
1115
+ isLikelyFalsePositive
1116
+ }
1117
+ };
1118
+ });
1119
+ });
1120
+ }
1121
+
1122
+ async function attemptLogin(page, { auth }) {
1123
+ if (!auth) return { did: false, ok: false };
1124
+ const [email, pass] = String(auth).split(":");
1125
+ if (!email || !pass) return { did: false, ok: false };
1126
+
1127
+ try {
1128
+ const emailLoc = page.locator("input[type='email'], input[name*='email' i], input[placeholder*='email' i]").first();
1129
+ const passLoc = page.locator("input[type='password']").first();
1130
+ if ((await emailLoc.count()) === 0 || (await passLoc.count()) === 0) return { did: false, ok: false };
1131
+
1132
+ await emailLoc.fill(email, { timeout: 1200 });
1133
+ await passLoc.fill(pass, { timeout: 1200 });
1134
+
1135
+ const submit = page.locator("button[type='submit'], input[type='submit']").first();
1136
+ const fallback = page.locator("button:has-text('Log in'), button:has-text('Sign in')").first();
1137
+ const before = page.url();
1138
+
1139
+ if ((await submit.count()) > 0) await submit.click({ timeout: 1200 });
1140
+ else if ((await fallback.count()) > 0) await fallback.click({ timeout: 1200 });
1141
+ else return { did: true, ok: false };
1142
+
1143
+ await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
1144
+ const stillLogin = await isLoginPage(page);
1145
+ return { did: true, ok: normalizeUrl(page.url()) !== normalizeUrl(before) || !stillLogin };
1146
+ } catch {
1147
+ return { did: true, ok: false };
1148
+ }
1149
+ }
1150
+
1151
+ async function runSinglePass({ label, baseUrl, context, shotsDir, danger, maxPages, maxDepth, timeoutMs, root, onProgress, retries = 2, stableWait = 500 }) {
1152
+ const page = await context.newPage();
1153
+ page.setDefaultTimeout(timeoutMs);
1154
+
1155
+ // Helper for flaky-resistant navigation with retries
1156
+ async function safeGoto(targetUrl, opts = {}) {
1157
+ for (let attempt = 1; attempt <= retries; attempt++) {
1158
+ try {
1159
+ const res = await page.goto(targetUrl, { waitUntil: "domcontentloaded", ...opts });
1160
+ // Wait for stability to reduce flakiness
1161
+ if (stableWait > 0) {
1162
+ await page.waitForTimeout(stableWait);
1163
+ }
1164
+ await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
1165
+ return res;
1166
+ } catch (err) {
1167
+ if (attempt === retries) throw err;
1168
+ await page.waitForTimeout(500 * attempt); // Exponential backoff
1169
+ }
1170
+ }
1171
+ return null;
1172
+ }
1173
+
1174
+ // Helper for flaky-resistant clicks with retries
1175
+ async function safeClick(locator, opts = {}) {
1176
+ for (let attempt = 1; attempt <= retries; attempt++) {
1177
+ try {
1178
+ await locator.click({ timeout: timeoutMs / 2, ...opts });
1179
+ if (stableWait > 0) {
1180
+ await page.waitForTimeout(stableWait);
1181
+ }
1182
+ return { success: true };
1183
+ } catch (err) {
1184
+ if (attempt === retries) return { success: false, error: err.message };
1185
+ await page.waitForTimeout(300 * attempt);
1186
+ }
1187
+ }
1188
+ return { success: false, error: 'Max retries exceeded' };
1189
+ }
1190
+
1191
+ const reqCounter = { value: 0 };
1192
+ const netErrors = [];
1193
+ const consoleErrors = [];
1194
+ const findings = [];
1195
+ const pagesVisited = [];
1196
+ const fakeDataDetections = []; // Track fake data detections
1197
+ const processedUrls = new Set(); // Avoid duplicate detections
1198
+
1199
+ page.on("requestfinished", () => { reqCounter.value += 1; });
1200
+ page.on("requestfailed", (req) => { netErrors.push({ url: req.url(), failure: req.failure()?.errorText || "unknown" }); });
1201
+ page.on("console", (msg) => { if (msg.type() === "error") consoleErrors.push({ text: msg.text().slice(0, 500) }); });
1202
+ page.on("pageerror", (err) => { consoleErrors.push({ text: String(err?.message || err).slice(0, 500) }); });
1203
+
1204
+ // Intercept responses for fake data detection
1205
+ page.on("response", async (response) => {
1206
+ try {
1207
+ const url = response.url();
1208
+ const status = response.status();
1209
+
1210
+ // Skip already processed URLs and static assets
1211
+ if (processedUrls.has(url)) return;
1212
+ if (/\.(js|css|png|jpg|svg|ico|woff|woff2|ttf|gif|webp)(\?|$)/i.test(url)) return;
1213
+
1214
+ // Only check API-like endpoints
1215
+ if (!url.includes('/api/') && !url.includes('/graphql') && !url.includes('/trpc') &&
1216
+ !response.headers()['content-type']?.includes('application/json')) {
1217
+ return;
1218
+ }
1219
+
1220
+ processedUrls.add(url);
1221
+
1222
+ let body = '';
1223
+ try {
1224
+ body = await response.text();
1225
+ } catch {
1226
+ // Some responses can't be read
1227
+ }
1228
+
1229
+ const detections = classifyNetworkTraffic(url, body, status);
1230
+ if (detections && detections.length > 0) {
1231
+ fakeDataDetections.push(...detections);
1232
+ }
1233
+ } catch {
1234
+ // Ignore errors in response processing
1235
+ }
1236
+ });
1237
+
1238
+ const visited = new Set();
1239
+ const queue = [{ url: baseUrl, depth: 0 }];
1240
+
1241
+ while (queue.length && pagesVisited.length < maxPages) {
1242
+ const item = queue.shift();
1243
+ if (!item) break;
1244
+
1245
+ const targetUrl = normalizeUrl(item.url);
1246
+ if (!sameOrigin(baseUrl, targetUrl) || visited.has(targetUrl)) continue;
1247
+ if (!danger && looksAuthAction(targetUrl)) continue;
1248
+
1249
+ visited.add(targetUrl);
1250
+
1251
+ // Progress callback
1252
+ if (onProgress) {
1253
+ onProgress({ page: pagesVisited.length + 1, maxPages, url: targetUrl });
1254
+ }
1255
+
1256
+ const res = await safeGoto(targetUrl).catch(() => null);
1257
+
1258
+ const status = res ? res.status() : null;
1259
+ const loginLike = await isLoginPage(page);
1260
+ pagesVisited.push({ url: page.url(), depth: item.depth, status, loginLike });
1261
+
1262
+ if (status && status >= 400) {
1263
+ const shot = path.join(shotsDir, `${label}_http_${status}_${sha1(targetUrl)}.png`);
1264
+ await page.screenshot({ path: shot, fullPage: true }).catch(() => {});
1265
+ findings.push({
1266
+ id: `R_${label}_HTTP_${status}_${sha1(targetUrl).slice(0, 8)}`,
1267
+ severity: status >= 500 ? "BLOCK" : "WARN",
1268
+ category: "DeadUI",
1269
+ title: `[${label}] HTTP ${status} at ${targetUrl}`,
1270
+ page: targetUrl,
1271
+ reason: "Navigation reached an error status",
1272
+ screenshot: path.relative(root, shot).replace(/\\/g, "/")
1273
+ });
1274
+ }
1275
+
1276
+ if (item.depth < maxDepth) {
1277
+ for (const l of await collectLinks(page, baseUrl)) {
1278
+ const abs = normalizeUrl(l);
1279
+ if (sameOrigin(baseUrl, abs) && !visited.has(abs) && (danger || !looksAuthAction(abs))) {
1280
+ queue.push({ url: abs, depth: item.depth + 1 });
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ for (const el of await collectInteractives(page)) {
1286
+ if (el.disabled || (looksRisky(el.text) && !danger)) continue;
1287
+
1288
+ let locator;
1289
+ try {
1290
+ if (el.tag === "a") locator = page.locator("a[href]").nth(el.idx);
1291
+ else if (el.tag === "button") locator = page.locator("button").nth(el.idx);
1292
+ else if (el.role === "button") locator = page.locator("[role='button']").nth(el.idx);
1293
+ else continue;
1294
+ } catch { continue; }
1295
+
1296
+ const out = await clickOutcome(page, locator, { reqCounter });
1297
+
1298
+ if (!out.clickOk) {
1299
+ // Skip click failures for elements that are likely intentionally not clickable
1300
+ // (e.g., visually hidden close buttons, buttons behind overlays)
1301
+ if (el.context?.isLikelyFalsePositive) continue;
1302
+
1303
+ const shot = path.join(shotsDir, `${label}_click_fail_${sha1(el.key)}.png`);
1304
+ await page.screenshot({ path: shot }).catch(() => {});
1305
+ findings.push({
1306
+ id: `R_${label}_CLICK_FAIL_${sha1(el.key).slice(0, 8)}`,
1307
+ severity: "WARN",
1308
+ category: "DeadUI",
1309
+ title: `[${label}] Click failed: ${el.text || el.tag}`,
1310
+ page: page.url(),
1311
+ reason: out.clickError || "click failed",
1312
+ screenshot: path.relative(root, shot).replace(/\\/g, "/")
1313
+ });
1314
+ continue;
1315
+ }
1316
+
1317
+ // Enhanced Dead UI detection with false positive reduction
1318
+ // An element is considered "dead" if clicking produced NO observable effect:
1319
+ // - No navigation
1320
+ // - No URL change
1321
+ // - No DOM mutations
1322
+ // - No CSS visibility/state changes
1323
+ // - No network requests
1324
+ const noEffect = !out.navHappened && !out.urlChanged && !out.domChanged &&
1325
+ !out.visibilityChanged && out.reqDelta === 0;
1326
+
1327
+ if (noEffect) {
1328
+ // Apply false positive reduction based on element context
1329
+ const ctx = el.context || {};
1330
+
1331
+ // Skip elements that are KNOWN to not produce observable changes
1332
+ // These are legitimate UI patterns that don't need fixes
1333
+ if (ctx.looksLikeClose && ctx.isInsideModal) {
1334
+ // Close button inside a modal - the modal itself may have closed
1335
+ continue;
1336
+ }
1337
+ if (ctx.looksLikeCopy) {
1338
+ // Copy buttons work via clipboard API, no DOM change expected
1339
+ continue;
1340
+ }
1341
+ if (ctx.looksLikeTheme) {
1342
+ // Theme toggles may only change CSS custom properties
1343
+ continue;
1344
+ }
1345
+
1346
+ // Downgrade severity for likely false positives
1347
+ // Instead of BLOCK, use WARN for elements in contexts that commonly have no-op behavior
1348
+ let severity = "BLOCK";
1349
+ let reason = "Click produced no navigation, no network activity, and no DOM change";
1350
+
1351
+ if (ctx.isLikelyFalsePositive) {
1352
+ severity = "WARN";
1353
+ reason = `Click produced no observable change (possible false positive: ${
1354
+ ctx.looksLikeToggle ? 'toggle button' :
1355
+ ctx.looksLikeTab ? 'tab element' :
1356
+ ctx.looksLikeSort ? 'sort control' :
1357
+ ctx.isInsideDropdown ? 'inside dropdown' :
1358
+ ctx.isInsideAccordion ? 'inside accordion' :
1359
+ 'contextual element'
1360
+ })`;
1361
+ }
1362
+
1363
+ // Always skip tooltip-related elements as they are purely visual
1364
+ if (ctx.isInsideTooltip) continue;
1365
+
1366
+ const shot = path.join(shotsDir, `${label}_dead_${sha1(el.key)}.png`);
1367
+ await page.screenshot({ path: shot }).catch(() => {});
1368
+ findings.push({
1369
+ id: `R_${label}_DEAD_${sha1(el.key).slice(0, 8)}`,
1370
+ severity,
1371
+ category: "DeadUI",
1372
+ title: `[${label}] Dead UI: ${el.text || el.tag}`,
1373
+ page: page.url(),
1374
+ reason,
1375
+ screenshot: path.relative(root, shot).replace(/\\/g, "/"),
1376
+ confidence: ctx.isLikelyFalsePositive ? 0.5 : 0.9, // Add confidence score
1377
+ context: ctx // Include context for debugging
1378
+ });
1379
+ }
1380
+ }
1381
+ }
1382
+
1383
+ await page.close();
1384
+
1385
+ // Convert fake data detections to findings with confidence-based filtering
1386
+ const seenFakeUrls = new Set();
1387
+
1388
+ // Sort by confidence (highest first) to prioritize most reliable detections
1389
+ const sortedDetections = [...fakeDataDetections].sort((a, b) =>
1390
+ (b.confidence || 0.5) - (a.confidence || 0.5)
1391
+ );
1392
+
1393
+ for (const detection of sortedDetections) {
1394
+ // Dedupe by URL + type + pattern to avoid near-duplicates
1395
+ const key = `${detection.url}:${detection.type}:${detection.pattern || ''}`;
1396
+ if (seenFakeUrls.has(key)) continue;
1397
+ seenFakeUrls.add(key);
1398
+
1399
+ // Skip very low confidence detections (likely false positives)
1400
+ const confidence = detection.confidence || 0.5;
1401
+ if (confidence < 0.50) continue;
1402
+
1403
+ // Downgrade severity for medium confidence detections
1404
+ let severity = detection.severity;
1405
+ if (confidence < 0.70 && severity === 'BLOCK') {
1406
+ severity = 'WARN';
1407
+ }
1408
+
1409
+ findings.push({
1410
+ id: `R_${label}_FAKE_${sha1(key).slice(0, 8)}`,
1411
+ severity,
1412
+ category: detection.type === 'fake-domain' ? 'FakeDomain' :
1413
+ detection.type === 'fake-response' ? 'FakeResponse' : 'MockStatus',
1414
+ title: `[${label}] Fake Data: ${detection.evidence}`,
1415
+ page: detection.url,
1416
+ reason: detection.evidence,
1417
+ confidence, // Include confidence score for transparency
1418
+ pattern: detection.pattern // Include pattern for debugging
1419
+ });
1420
+ }
1421
+
1422
+ return {
1423
+ label,
1424
+ pagesVisited,
1425
+ findings,
1426
+ consoleErrors: consoleErrors.slice(0, 50),
1427
+ networkErrors: netErrors.slice(0, 50),
1428
+ fakeDataDetections: fakeDataDetections.slice(0, 100)
1429
+ };
1430
+ }
1431
+
1432
+ function buildAuthCoverageFindings({ baseUrl, matchers, anonPass, authPass }) {
1433
+ const findings = [];
1434
+ const anonByPath = new Map((anonPass.pagesVisited || []).map(p => [pathFromUrl(p.url), p]));
1435
+ const authByPath = new Map((authPass?.pagesVisited || []).map(p => [pathFromUrl(p.url), p]));
1436
+
1437
+ for (const [pathKey, anon] of anonByPath) {
1438
+ const isProtected = matchers.some(m => m.rx.test(pathKey));
1439
+ if (!isProtected) continue;
1440
+
1441
+ const anonLooksBlocked = anon.loginLike || anon.status === 401 || anon.status === 403;
1442
+ const authed = authByPath.get(pathKey);
1443
+ const authedLooksBlocked = authed?.loginLike || authed?.status === 401 || authed?.status === 403;
1444
+
1445
+ if (!anonLooksBlocked) {
1446
+ findings.push({
1447
+ id: `R_AUTH_ANON_ACCESS_${sha1(pathKey).slice(0, 8)}`,
1448
+ severity: "BLOCK",
1449
+ category: "AuthCoverage",
1450
+ title: `Protected route reachable anonymously: ${pathKey}`,
1451
+ page: new URL(pathKey, baseUrl).toString(),
1452
+ reason: "Matcher marks route protected, but anon session did not redirect or deny."
1453
+ });
1454
+ }
1455
+
1456
+ if (authed && authedLooksBlocked) {
1457
+ findings.push({
1458
+ id: `R_AUTH_BLOCKED_${sha1(pathKey).slice(0, 8)}`,
1459
+ severity: "BLOCK",
1460
+ category: "AuthCoverage",
1461
+ title: `Protected route blocked after login: ${pathKey}`,
1462
+ page: new URL(pathKey, baseUrl).toString(),
1463
+ reason: "Authed session still looks like login/401/403 on a protected route."
1464
+ });
1465
+ }
1466
+ }
1467
+
1468
+ return findings;
1469
+ }
1470
+
1471
+ function coverageFromTruthpack({ truthpack, visitedUrls }) {
1472
+ if (!truthpack) return null;
1473
+ const refs = truthpack?.routes?.clientRefs || [];
1474
+ const uiPaths = new Set(refs.map(r => r?.path || pathFromUrl(r?.url || r)).filter(Boolean));
1475
+ const visitedPaths = new Set(visitedUrls.map(pathFromUrl));
1476
+ const total = uiPaths.size;
1477
+ const hit = Array.from(uiPaths).filter(p => visitedPaths.has(p)).length;
1478
+ return { total, hit, percent: total ? Math.round((hit / total) * 100) : 0, missed: Array.from(uiPaths).filter(p => !visitedPaths.has(p)).slice(0, 50) };
1479
+ }
1480
+
1481
+ // ═══════════════════════════════════════════════════════════════════════════════
1482
+ // REPLAY DATA BUILDERS (for API/dashboard)
1483
+ // ═══════════════════════════════════════════════════════════════════════════════
1484
+
1485
+ /**
1486
+ * Build timeline events from pass results for replay
1487
+ */
1488
+ function buildTimeline(anonPass, authPass) {
1489
+ const timeline = [];
1490
+ const baseTimestamp = Date.now();
1491
+
1492
+ // Add anon pass pages as timeline events
1493
+ if (anonPass?.pagesVisited) {
1494
+ for (let i = 0; i < anonPass.pagesVisited.length; i++) {
1495
+ const page = anonPass.pagesVisited[i];
1496
+ timeline.push({
1497
+ action: 'navigate',
1498
+ url: page.url,
1499
+ selector: null,
1500
+ timestamp: new Date(baseTimestamp + (i * 1000)).toISOString(),
1501
+ screenshot: null,
1502
+ pass: 'anon',
1503
+ status: page.status,
1504
+ });
1505
+ }
1506
+ }
1507
+
1508
+ // Add auth pass pages
1509
+ if (authPass?.pagesVisited) {
1510
+ const offset = (anonPass?.pagesVisited?.length || 0) * 1000;
1511
+ for (let i = 0; i < authPass.pagesVisited.length; i++) {
1512
+ const page = authPass.pagesVisited[i];
1513
+ timeline.push({
1514
+ action: 'navigate',
1515
+ url: page.url,
1516
+ selector: null,
1517
+ timestamp: new Date(baseTimestamp + offset + (i * 1000)).toISOString(),
1518
+ screenshot: null,
1519
+ pass: 'auth',
1520
+ status: page.status,
1521
+ });
1522
+ }
1523
+ }
1524
+
1525
+ return timeline;
1526
+ }
1527
+
1528
+ /**
1529
+ * Build network timeline from pass results
1530
+ */
1531
+ function buildNetworkTimeline(anonPass, authPass) {
1532
+ const network = [];
1533
+ const baseTimestamp = Date.now();
1534
+ let idx = 0;
1535
+
1536
+ // Extract network requests from page visits
1537
+ if (anonPass?.pagesVisited) {
1538
+ for (const page of anonPass.pagesVisited) {
1539
+ network.push({
1540
+ method: 'GET',
1541
+ url: page.url,
1542
+ status: page.status || 200,
1543
+ timestamp: new Date(baseTimestamp + (idx++ * 500)).toISOString(),
1544
+ });
1545
+ }
1546
+ }
1547
+
1548
+ // Add network errors as requests
1549
+ if (anonPass?.networkErrors) {
1550
+ for (const err of anonPass.networkErrors) {
1551
+ network.push({
1552
+ method: 'GET',
1553
+ url: err.url,
1554
+ status: 0,
1555
+ timestamp: new Date(baseTimestamp + (idx++ * 500)).toISOString(),
1556
+ error: err.failure,
1557
+ });
1558
+ }
1559
+ }
1560
+
1561
+ if (authPass?.networkErrors) {
1562
+ for (const err of authPass.networkErrors) {
1563
+ network.push({
1564
+ method: 'GET',
1565
+ url: err.url,
1566
+ status: 0,
1567
+ timestamp: new Date(baseTimestamp + (idx++ * 500)).toISOString(),
1568
+ error: err.failure,
1569
+ });
1570
+ }
1571
+ }
1572
+
1573
+ return network;
1574
+ }
1575
+
1576
+ /**
1577
+ * Build screenshots list from findings
1578
+ */
1579
+ function buildScreenshotsList(anonPass, authPass, root) {
1580
+ const screenshots = [];
1581
+ const allFindings = [...(anonPass?.findings || []), ...(authPass?.findings || [])];
1582
+
1583
+ for (const finding of allFindings) {
1584
+ if (finding.screenshot) {
1585
+ screenshots.push({
1586
+ url: finding.page || '',
1587
+ timestamp: new Date().toISOString(),
1588
+ path: finding.screenshot,
1589
+ });
1590
+ }
1591
+ }
1592
+
1593
+ return screenshots;
1594
+ }
1595
+
1596
+ // ═══════════════════════════════════════════════════════════════════════════════
1597
+ // FLAKINESS & STABILITY VERIFICATION
1598
+ // ═══════════════════════════════════════════════════════════════════════════════
1599
+
1600
+ /**
1601
+ * Aggregate findings from multiple stability runs
1602
+ * Only returns findings that appear in at least `threshold` of runs
1603
+ * @param {Array<Array<Object>>} runFindings - Array of findings arrays from each run
1604
+ * @param {number} threshold - Minimum occurrence rate (0-1) to include a finding
1605
+ * @returns {Array<Object>} Deduplicated findings with flakiness scores
1606
+ */
1607
+ function aggregateStabilityFindings(runFindings, threshold = 0.66) {
1608
+ const totalRuns = runFindings.length;
1609
+ if (totalRuns === 0) return [];
1610
+ if (totalRuns === 1) return runFindings[0] || [];
1611
+
1612
+ // Group findings by their unique key (category + normalized title/reason)
1613
+ const findingCounts = new Map();
1614
+
1615
+ for (const findings of runFindings) {
1616
+ for (const finding of findings) {
1617
+ // Create a stable key for deduplication
1618
+ const key = `${finding.category}|${finding.title?.replace(/\[ANON\]|\[AUTH\]/g, '').trim()}|${finding.page || ''}`;
1619
+
1620
+ if (!findingCounts.has(key)) {
1621
+ findingCounts.set(key, {
1622
+ finding: { ...finding },
1623
+ count: 0,
1624
+ occurrences: []
1625
+ });
1626
+ }
1627
+
1628
+ const entry = findingCounts.get(key);
1629
+ entry.count++;
1630
+ entry.occurrences.push(finding);
1631
+ }
1632
+ }
1633
+
1634
+ // Filter to findings that meet the threshold and add flakiness score
1635
+ const aggregated = [];
1636
+
1637
+ for (const [key, data] of findingCounts) {
1638
+ const occurrenceRate = data.count / totalRuns;
1639
+
1640
+ if (occurrenceRate >= threshold) {
1641
+ // Calculate flakiness score (1 = always occurs, 0 = never)
1642
+ const flakinessScore = 1 - occurrenceRate;
1643
+
1644
+ // Merge the finding with flakiness metadata
1645
+ const aggregatedFinding = {
1646
+ ...data.finding,
1647
+ stability: {
1648
+ occurrenceRate: Math.round(occurrenceRate * 100) / 100,
1649
+ appearedInRuns: data.count,
1650
+ totalRuns,
1651
+ flakinessScore: Math.round(flakinessScore * 100) / 100,
1652
+ isFlaky: flakinessScore > 0.1, // More than 10% variance = flaky
1653
+ }
1654
+ };
1655
+
1656
+ // If finding appeared in all runs, it's stable
1657
+ // If it appeared in some runs, mark as potentially flaky
1658
+ if (data.count < totalRuns) {
1659
+ aggregatedFinding.reason = `${aggregatedFinding.reason || ''} (appeared ${data.count}/${totalRuns} runs)`.trim();
1660
+ }
1661
+
1662
+ aggregated.push(aggregatedFinding);
1663
+ }
1664
+ }
1665
+
1666
+ return aggregated;
1667
+ }
1668
+
1669
+ /**
1670
+ * Print stability verification results
1671
+ */
1672
+ function printStabilityResults(totalRuns, stableFindings, filteredCount) {
1673
+ if (totalRuns <= 1) return;
1674
+
1675
+ console.log();
1676
+ console.log(` ${colors.info}${ICONS.target}${c.reset} ${c.bold}Stability Verification${c.reset}`);
1677
+ console.log(` ${c.dim}Total runs:${c.reset} ${totalRuns}`);
1678
+ console.log(` ${c.dim}Stable findings:${c.reset} ${stableFindings} ${c.dim}(appeared in majority of runs)${c.reset}`);
1679
+
1680
+ if (filteredCount > 0) {
1681
+ console.log(` ${c.dim}Filtered (flaky):${c.reset} ${colors.success}${filteredCount}${c.reset} ${c.dim}(inconsistent across runs)${c.reset}`);
1682
+ }
1683
+ }
1684
+
1685
+ // ═══════════════════════════════════════════════════════════════════════════════
1686
+ // MAIN REALITY FUNCTION
1687
+ // ═══════════════════════════════════════════════════════════════════════════════
1688
+
1689
+ async function runReality(argsOrOpts = {}) {
1690
+ // Handle array args from CLI
1691
+ let globalOpts = { noBanner: false, json: false, quiet: false, ci: false };
1692
+ if (Array.isArray(argsOrOpts)) {
1693
+ const { flags } = parseGlobalFlags(argsOrOpts);
1694
+ globalOpts = { ...globalOpts, ...flags };
1695
+ if (globalOpts.help) {
1696
+ printHelp(globalOpts);
1697
+ return 0;
1698
+ }
1699
+ // Parse args to options
1700
+ const getArg = (flags) => {
1701
+ for (const f of flags) {
1702
+ const idx = argsOrOpts.indexOf(f);
1703
+ if (idx !== -1 && idx < argsOrOpts.length - 1) return argsOrOpts[idx + 1];
1704
+ }
1705
+ return undefined;
1706
+ };
1707
+ argsOrOpts = {
1708
+ url: getArg(["--url", "-u"]),
1709
+ auth: getArg(["--auth"]),
1710
+ storageState: getArg(["--storage-state"]),
1711
+ saveStorageState: getArg(["--save-storage-state"]),
1712
+ truthpack: getArg(["--truthpack"]),
1713
+ verifyAuth: argsOrOpts.includes("--verify-auth"),
1714
+ headed: argsOrOpts.includes("--headed"),
1715
+ danger: argsOrOpts.includes("--danger"),
1716
+ // Visual artifacts options
1717
+ recordVideo: argsOrOpts.includes("--record-video") || argsOrOpts.includes("--video"),
1718
+ recordTrace: argsOrOpts.includes("--record-trace") || argsOrOpts.includes("--trace"),
1719
+ recordHar: argsOrOpts.includes("--record-har") || argsOrOpts.includes("--har"),
1720
+ // Flakiness reduction options
1721
+ retries: parseInt(getArg(["--retries"]) || "2", 10),
1722
+ stableWait: parseInt(getArg(["--stable-wait"]) || "500", 10),
1723
+ maxPages: parseInt(getArg(["--max-pages"]) || "18", 10),
1724
+ maxDepth: parseInt(getArg(["--max-depth"]) || "2", 10),
1725
+ timeoutMs: parseInt(getArg(["--timeout"]) || "15000", 10),
1726
+ ...globalOpts,
1727
+ };
1728
+ }
1729
+
1730
+ let {
1731
+ repoRoot,
1732
+ url,
1733
+ auth,
1734
+ storageState,
1735
+ saveStorageState,
1736
+ truthpack,
1737
+ verifyAuth = false,
1738
+ headed = false,
1739
+ maxPages = 18,
1740
+ maxDepth = 2,
1741
+ danger = false,
1742
+ timeoutMs = 15000,
1743
+ // Visual artifacts (videos, traces, HAR)
1744
+ recordVideo = false,
1745
+ recordTrace = false,
1746
+ recordHar = false,
1747
+ // Flakiness reduction
1748
+ retries = 2,
1749
+ stableWait = 500,
1750
+ stabilityRuns = 1,
1751
+ flakyThreshold = 0.66
1752
+ } = argsOrOpts;
1753
+
1754
+ if (!url) {
1755
+ printHelp(argsOrOpts);
1756
+ console.log(`\n ${colors.error}${ICONS.cross}${c.reset} ${c.bold}Error:${c.reset} --url is required\n`);
1757
+ return 1;
1758
+ }
1759
+
1760
+ const root = repoRoot || process.cwd();
1761
+ const projectName = path.basename(root);
1762
+ const startTime = Date.now();
1763
+ const originalMaxPages = maxPages;
1764
+ const originalVerifyAuth = verifyAuth;
1765
+
1766
+ // TIER ENFORCEMENT
1767
+ let tierInfo = { tier: 'free', limits: {} };
1768
+ try {
1769
+ const access = await entitlements.enforce("reality", {
1770
+ projectPath: root,
1771
+ silent: true,
1772
+ });
1773
+
1774
+ tierInfo = access;
1775
+ const limits = access.limits || entitlements.getLimits(access.tier);
1776
+
1777
+ // Apply tier-based caps
1778
+ if (access.downgrade === "reality.preview" || access.tier === "free") {
1779
+ const previewMax = limits.realityMaxPages || 5;
1780
+ if (maxPages > previewMax) {
1781
+ maxPages = previewMax;
1782
+ }
1783
+
1784
+ if (verifyAuth && !limits.realityAuthBoundary) {
1785
+ verifyAuth = false;
1786
+ }
1787
+ }
1788
+ } catch (e) {
1789
+ // Continue with defaults if entitlements unavailable
1790
+ }
1791
+
1792
+ // Print banner
1793
+ if (shouldShowBanner(argsOrOpts)) {
1794
+ printBanner();
1795
+ }
1796
+
1797
+ console.log(` ${c.dim}Project:${c.reset} ${c.bold}${projectName}${c.reset}`);
1798
+ console.log(` ${c.dim}URL:${c.reset} ${colors.accent}${url}${c.reset}`);
1799
+ console.log(` ${c.dim}Mode:${c.reset} ${verifyAuth ? `${colors.auth}Two-Pass (Auth)${c.reset}` : `${colors.anon}Single-Pass (Anon)${c.reset}`}`);
1800
+ console.log(` ${c.dim}Budget:${c.reset} ${maxPages} pages, depth ${maxDepth}`);
1801
+ if (stabilityRuns > 1) {
1802
+ console.log(` ${c.dim}Stability:${c.reset} ${colors.info}${stabilityRuns} runs${c.reset}, threshold ${Math.round(flakyThreshold * 100)}%`);
1803
+ }
1804
+
1805
+ // Tier warning if applicable
1806
+ if (tierInfo.tier === 'free' && (originalMaxPages > maxPages || (originalVerifyAuth && !verifyAuth))) {
1807
+ printTierWarning(tierInfo.tier, tierInfo.limits, originalMaxPages, maxPages, originalVerifyAuth, verifyAuth);
1808
+ }
1809
+
1810
+ // Playwright check
1811
+ if (!chromium) {
1812
+ const hint = playwrightError?.includes("Cannot find module")
1813
+ ? "Run: npm i -D playwright && npx playwright install chromium"
1814
+ : `Playwright error: ${playwrightError || "unknown"}`;
1815
+ console.log();
1816
+ console.log(` ${colors.error}${ICONS.cross}${c.reset} ${c.bold}Playwright not available${c.reset}`);
1817
+ console.log(` ${c.dim}${hint}${c.reset}`);
1818
+ console.log();
1819
+ return 1;
1820
+ }
1821
+
1822
+ const baseUrl = normalizeUrl(url);
1823
+ const outBase = path.join(root, ".vibecheck", "reality", stamp());
1824
+ const shotsDir = path.join(outBase, "screenshots");
1825
+ const videosDir = path.join(outBase, "videos");
1826
+ const tracesDir = path.join(outBase, "traces");
1827
+ const harDir = path.join(outBase, "har");
1828
+ ensureDir(shotsDir);
1829
+ if (recordVideo) ensureDir(videosDir);
1830
+ if (recordTrace) ensureDir(tracesDir);
1831
+ if (recordHar) ensureDir(harDir);
1832
+
1833
+ const tp = loadTruthpack(root, truthpack);
1834
+ const matchers = getProtectedMatchersFromTruthpack(tp);
1835
+
1836
+ if (tp) {
1837
+ console.log(` ${c.dim}Truthpack:${c.reset} ${colors.success}${ICONS.check}${c.reset} loaded (${matchers.length} protected patterns)`);
1838
+ }
1839
+
1840
+ // Launch browser
1841
+ console.log();
1842
+ startSpinner('Launching browser...', colors.accent);
1843
+ const browser = await chromium.launch({ headless: !headed });
1844
+ stopSpinner('Browser launched', true);
1845
+
1846
+ // ═══════════════════════════════════════════════════════════════════════════
1847
+ // STABILITY RUNS (multiple passes for flakiness detection)
1848
+ // ═══════════════════════════════════════════════════════════════════════════
1849
+
1850
+ const allRunFindings = [];
1851
+ let lastAnonPass = null;
1852
+ let lastAuthPass = null;
1853
+ let anonVideoPath = null;
1854
+ let authVideoPath = null;
1855
+ let anonTracePath = null;
1856
+ let authTracePath = null;
1857
+ let savedStatePath = null;
1858
+
1859
+ for (let runNum = 1; runNum <= stabilityRuns; runNum++) {
1860
+ const isFirstRun = runNum === 1;
1861
+ const isLastRun = runNum === stabilityRuns;
1862
+
1863
+ if (stabilityRuns > 1) {
1864
+ console.log();
1865
+ console.log(` ${colors.info}${BOX.hHorizontal.repeat(3)}${c.reset} ${c.bold}Stability Run ${runNum}/${stabilityRuns}${c.reset}`);
1866
+ }
1867
+
1868
+ // ═══════════════════════════════════════════════════════════════════════════
1869
+ // PASS A: ANONYMOUS
1870
+ // ═══════════════════════════════════════════════════════════════════════════
1871
+ printPassHeader('anon', baseUrl);
1872
+
1873
+ startSpinner('Crawling anonymously...', colors.anon);
1874
+
1875
+ // Build context options for video/HAR recording (only on last run to save resources)
1876
+ const anonContextOpts = {};
1877
+ if (recordVideo && isLastRun) {
1878
+ anonContextOpts.recordVideo = {
1879
+ dir: videosDir,
1880
+ size: { width: 1280, height: 720 }
1881
+ };
1882
+ }
1883
+ if (recordHar && isLastRun) {
1884
+ anonContextOpts.recordHar = {
1885
+ path: path.join(harDir, 'anon-traffic.har'),
1886
+ mode: 'full'
1887
+ };
1888
+ }
1889
+
1890
+ const anonContext = await browser.newContext(anonContextOpts);
1891
+
1892
+ // Start trace recording if enabled (only on last run)
1893
+ if (recordTrace && isLastRun) {
1894
+ await anonContext.tracing.start({
1895
+ screenshots: true,
1896
+ snapshots: true,
1897
+ sources: false
1898
+ });
1899
+ }
1900
+
1901
+ const anonPass = await runSinglePass({
1902
+ label: "ANON",
1903
+ baseUrl,
1904
+ context: anonContext,
1905
+ shotsDir: isLastRun ? shotsDir : path.join(outBase, `run${runNum}`, 'screenshots'),
1906
+ danger,
1907
+ maxPages,
1908
+ maxDepth,
1909
+ timeoutMs,
1910
+ root,
1911
+ retries,
1912
+ stableWait,
1913
+ onProgress: ({ page, maxPages: mp, url: currentUrl }) => {
1914
+ // Could update spinner here if desired
1915
+ }
1916
+ });
1917
+
1918
+ // Ensure shot dir exists for intermediate runs
1919
+ if (!isLastRun) {
1920
+ ensureDir(path.join(outBase, `run${runNum}`, 'screenshots'));
1921
+ }
1922
+
1923
+ // Save trace if enabled (only last run)
1924
+ if (recordTrace && isLastRun) {
1925
+ anonTracePath = path.join(tracesDir, 'anon-trace.zip');
1926
+ await anonContext.tracing.stop({ path: anonTracePath });
1927
+ }
1928
+
1929
+ // Get video path before closing context (only last run)
1930
+ if (recordVideo && isLastRun && anonPass.pagesVisited.length > 0) {
1931
+ const pages = anonContext.pages();
1932
+ if (pages.length > 0) {
1933
+ const video = pages[0].video();
1934
+ if (video) {
1935
+ try {
1936
+ anonVideoPath = await video.path();
1937
+ } catch {}
1938
+ }
1939
+ }
1940
+ }
1941
+
1942
+ await anonContext.close();
1943
+ stopSpinner(`Crawled ${anonPass.pagesVisited.length} pages`, true);
1944
+
1945
+ printPassResult('anon', anonPass);
1946
+ lastAnonPass = anonPass;
1947
+
1948
+ // ═══════════════════════════════════════════════════════════════════════════
1949
+ // PASS B: AUTHENTICATED (optional)
1950
+ // ═══════════════════════════════════════════════════════════════════════════
1951
+ let authPass = null;
1952
+ let authFindings = [];
1953
+
1954
+ if (verifyAuth) {
1955
+ printPassHeader('auth', baseUrl);
1956
+
1957
+ startSpinner('Setting up authenticated session...', colors.auth);
1958
+ const ctxOpts = storageState ? { storageState } : {};
1959
+
1960
+ // Add video/HAR recording options (only last run)
1961
+ if (recordVideo && isLastRun) {
1962
+ ctxOpts.recordVideo = {
1963
+ dir: videosDir,
1964
+ size: { width: 1280, height: 720 }
1965
+ };
1966
+ }
1967
+ if (recordHar && isLastRun) {
1968
+ ctxOpts.recordHar = {
1969
+ path: path.join(harDir, 'auth-traffic.har'),
1970
+ mode: 'full'
1971
+ };
1972
+ }
1973
+
1974
+ const authContext = await browser.newContext(ctxOpts);
1975
+
1976
+ // Start trace recording if enabled (only last run)
1977
+ if (recordTrace && isLastRun) {
1978
+ await authContext.tracing.start({
1979
+ screenshots: true,
1980
+ snapshots: true,
1981
+ sources: false
1982
+ });
1983
+ }
1984
+ const authPage = await authContext.newPage();
1985
+ await authPage.goto(baseUrl, { waitUntil: "domcontentloaded" }).catch(() => {});
1986
+ await authPage.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
1987
+
1988
+ if (!storageState && auth && isFirstRun) {
1989
+ stopSpinner('Attempting login...', true);
1990
+ startSpinner('Logging in...', colors.auth);
1991
+
1992
+ const loginRes = await attemptLogin(authPage, { auth });
1993
+
1994
+ if (loginRes.ok) {
1995
+ stopSpinner('Login successful', true);
1996
+ if (saveStorageState) {
1997
+ const dest = path.isAbsolute(saveStorageState) ? saveStorageState : path.join(root, saveStorageState);
1998
+ ensureDir(path.dirname(dest));
1999
+ await authContext.storageState({ path: dest }).catch(() => {});
2000
+ savedStatePath = dest;
2001
+ console.log(` ${colors.success}${ICONS.check}${c.reset} Session saved: ${c.dim}${path.relative(root, dest)}${c.reset}`);
2002
+ }
2003
+ } else {
2004
+ stopSpinner('Login failed - continuing without auth', false);
2005
+ }
2006
+ } else {
2007
+ stopSpinner('Using existing session', true);
2008
+ }
2009
+
2010
+ await authPage.close();
2011
+
2012
+ startSpinner('Crawling with authentication...', colors.auth);
2013
+ authPass = await runSinglePass({
2014
+ label: "AUTH",
2015
+ baseUrl,
2016
+ context: authContext,
2017
+ shotsDir: isLastRun ? shotsDir : path.join(outBase, `run${runNum}`, 'screenshots'),
2018
+ danger,
2019
+ maxPages,
2020
+ maxDepth,
2021
+ timeoutMs,
2022
+ root,
2023
+ retries,
2024
+ stableWait
2025
+ });
2026
+
2027
+ // Save trace if enabled (only last run)
2028
+ if (recordTrace && isLastRun) {
2029
+ authTracePath = path.join(tracesDir, 'auth-trace.zip');
2030
+ await authContext.tracing.stop({ path: authTracePath });
2031
+ }
2032
+
2033
+ // Get video path before closing context (only last run)
2034
+ if (recordVideo && isLastRun && authPass.pagesVisited.length > 0) {
2035
+ const pages = authContext.pages();
2036
+ if (pages.length > 0) {
2037
+ const video = pages[0].video();
2038
+ if (video) {
2039
+ try {
2040
+ authVideoPath = await video.path();
2041
+ } catch {}
2042
+ }
2043
+ }
2044
+ }
2045
+
2046
+ await authContext.close();
2047
+ stopSpinner(`Crawled ${authPass.pagesVisited.length} pages`, true);
2048
+
2049
+ printPassResult('auth', authPass);
2050
+ lastAuthPass = authPass;
2051
+
2052
+ // Build auth coverage findings
2053
+ if (matchers.length) {
2054
+ startSpinner('Analyzing auth coverage...', colors.authCoverage);
2055
+ authFindings = buildAuthCoverageFindings({ baseUrl, matchers, anonPass, authPass });
2056
+ stopSpinner(`Found ${authFindings.length} auth issues`, authFindings.length === 0);
2057
+ }
2058
+ }
2059
+
2060
+ // Collect findings from this run
2061
+ const runFindings = [...anonPass.findings, ...(authPass?.findings || []), ...authFindings];
2062
+ allRunFindings.push(runFindings);
2063
+ }
2064
+
2065
+ await browser.close();
2066
+
2067
+ // Use last pass results for page/coverage data
2068
+ const anonPass = lastAnonPass;
2069
+ const authPass = lastAuthPass;
2070
+
2071
+ // ═══════════════════════════════════════════════════════════════════════════
2072
+ // ANALYSIS & RESULTS
2073
+ // ═══════════════════════════════════════════════════════════════════════════
2074
+
2075
+ const allVisited = [...anonPass.pagesVisited.map(p => p.url), ...(authPass?.pagesVisited || []).map(p => p.url)];
2076
+ const coverage = coverageFromTruthpack({ truthpack: tp, visitedUrls: allVisited });
2077
+
2078
+ // Aggregate findings from stability runs (filters out flaky findings)
2079
+ let findings;
2080
+ let filteredFlakyCount = 0;
2081
+
2082
+ if (stabilityRuns > 1) {
2083
+ // Count total unique findings across all runs before filtering
2084
+ const allFindingsFlat = allRunFindings.flat();
2085
+ const uniqueBeforeFilter = new Set(allFindingsFlat.map(f =>
2086
+ `${f.category}|${f.title?.replace(/\[ANON\]|\[AUTH\]/g, '').trim()}|${f.page || ''}`
2087
+ )).size;
2088
+
2089
+ findings = aggregateStabilityFindings(allRunFindings, flakyThreshold);
2090
+ filteredFlakyCount = uniqueBeforeFilter - findings.length;
2091
+
2092
+ printStabilityResults(stabilityRuns, findings.length, filteredFlakyCount);
2093
+ } else {
2094
+ // Single run - use findings directly
2095
+ findings = allRunFindings[0] || [];
2096
+ }
2097
+
2098
+ const blocks = findings.filter(f => f.severity === "BLOCK").length;
2099
+ const warns = findings.filter(f => f.severity === "WARN").length;
2100
+
2101
+ // Build artifact manifest
2102
+ const artifacts = {
2103
+ screenshots: shotsDir ? path.relative(root, shotsDir).replace(/\\/g, "/") : null,
2104
+ videos: recordVideo ? {
2105
+ directory: path.relative(root, videosDir).replace(/\\/g, "/"),
2106
+ anon: anonVideoPath ? path.relative(root, anonVideoPath).replace(/\\/g, "/") : null,
2107
+ auth: authVideoPath ? path.relative(root, authVideoPath).replace(/\\/g, "/") : null
2108
+ } : null,
2109
+ traces: recordTrace ? {
2110
+ directory: path.relative(root, tracesDir).replace(/\\/g, "/"),
2111
+ anon: anonTracePath ? path.relative(root, anonTracePath).replace(/\\/g, "/") : null,
2112
+ auth: authTracePath ? path.relative(root, authTracePath).replace(/\\/g, "/") : null
2113
+ } : null,
2114
+ har: recordHar ? {
2115
+ directory: path.relative(root, harDir).replace(/\\/g, "/"),
2116
+ anon: path.join(harDir, 'anon-traffic.har'),
2117
+ auth: path.join(harDir, 'auth-traffic.har')
2118
+ } : null
2119
+ };
2120
+
2121
+ // Build report
2122
+ const report = {
2123
+ meta: {
2124
+ startedAt: new Date(startTime).toISOString(),
2125
+ finishedAt: new Date().toISOString(),
2126
+ durationMs: Date.now() - startTime,
2127
+ baseUrl,
2128
+ verifyAuth,
2129
+ maxPages,
2130
+ maxDepth,
2131
+ truthpackLoaded: !!tp,
2132
+ protectedMatcherCount: matchers.length,
2133
+ savedStorageState: savedStatePath ? path.relative(root, savedStatePath).replace(/\\/g, "/") : null,
2134
+ recordVideo,
2135
+ recordTrace,
2136
+ recordHar,
2137
+ // Flakiness/stability metadata
2138
+ stabilityRuns,
2139
+ flakyThreshold,
2140
+ filteredFlakyCount: stabilityRuns > 1 ? filteredFlakyCount : 0
2141
+ },
2142
+ artifacts,
2143
+ coverage,
2144
+ passes: { anon: anonPass, auth: authPass },
2145
+ findings,
2146
+ consoleErrors: [...anonPass.consoleErrors, ...(authPass?.consoleErrors || [])].slice(0, 50),
2147
+ networkErrors: [...anonPass.networkErrors, ...(authPass?.networkErrors || [])].slice(0, 50)
2148
+ };
2149
+
2150
+ // Write reports
2151
+ fs.writeFileSync(path.join(outBase, "reality_report.json"), JSON.stringify(report, null, 2), "utf8");
2152
+
2153
+ const latestDir = path.join(root, ".vibecheck", "reality");
2154
+ ensureDir(latestDir);
2155
+ fs.writeFileSync(path.join(latestDir, "latest.json"), JSON.stringify({ latest: path.relative(root, outBase).replace(/\\/g, "/") }, null, 2));
2156
+ fs.writeFileSync(path.join(latestDir, "last_reality.json"), JSON.stringify(report, null, 2), "utf8");
2157
+
2158
+ const duration = Date.now() - startTime;
2159
+
2160
+ // ═══════════════════════════════════════════════════════════════════════════
2161
+ // SEND TO API (for dashboard replay)
2162
+ // ═══════════════════════════════════════════════════════════════════════════
2163
+
2164
+ // Build reality result for API (timeline, network, screenshots, deadUI, fakeSuccess)
2165
+ const realityResult = {
2166
+ timeline: buildTimeline(anonPass, authPass),
2167
+ network: buildNetworkTimeline(anonPass, authPass),
2168
+ screenshots: buildScreenshotsList(anonPass, authPass, root),
2169
+ deadUI: findings.filter(f => f.category === 'DeadUI').map((f, idx) => ({
2170
+ stepIndex: idx,
2171
+ selector: f.title || '',
2172
+ reason: f.reason || '',
2173
+ })),
2174
+ fakeSuccess: findings.filter(f => f.category === 'FakeResponse' || f.category === 'FakeDomain').map((f, idx) => ({
2175
+ stepIndex: idx,
2176
+ message: f.title || f.reason || '',
2177
+ actualStatus: 0, // Could be extracted from evidence
2178
+ })),
2179
+ };
2180
+
2181
+ // Try to send to API (non-blocking, doesn't fail the run)
2182
+ try {
2183
+ const { sendRunToApi, extractFindings, calculateScore } = require('../../packages/cli/dist/runtime/api-run-sender.js');
2184
+
2185
+ const runId = crypto.randomUUID ? crypto.randomUUID() : `reality-${Date.now()}`;
2186
+ const apiFindings = extractFindings ? extractFindings({ findings }) : findings.map(f => ({
2187
+ severity: f.severity?.toLowerCase() || 'medium',
2188
+ category: f.category || 'general',
2189
+ message: f.title || f.reason || 'Unknown issue',
2190
+ file: f.page,
2191
+ }));
2192
+
2193
+ const score = calculateScore ? calculateScore(verdict, apiFindings) : (verdict === 'SHIP' ? 100 : verdict === 'WARN' ? 50 : 0);
2194
+
2195
+ sendRunToApi({
2196
+ runId,
2197
+ command: 'reality',
2198
+ status: 'completed',
2199
+ verdict,
2200
+ score,
2201
+ exitCode: blocks ? 2 : warns ? 1 : 0,
2202
+ startTime: new Date(startTime).toISOString(),
2203
+ endTime: new Date().toISOString(),
2204
+ duration,
2205
+ projectPath: root,
2206
+ findings: apiFindings,
2207
+ metadata: {
2208
+ baseUrl,
2209
+ pagesVisited: anonPass.pagesVisited.length + (authPass?.pagesVisited?.length || 0),
2210
+ coverage: coverage?.percent,
2211
+ },
2212
+ realityResult,
2213
+ videoUrl: anonVideoPath ? path.relative(root, anonVideoPath).replace(/\\/g, "/") : undefined,
2214
+ traceUrl: anonTracePath ? path.relative(root, anonTracePath).replace(/\\/g, "/") : undefined,
2215
+ }).catch(() => {
2216
+ // Silently ignore API errors - CLI works offline
2217
+ });
2218
+ } catch (e) {
2219
+ // Module not available or other error - continue without API sync
2220
+ }
2221
+
2222
+ // ═══════════════════════════════════════════════════════════════════════════
2223
+ // OUTPUT
2224
+ // ═══════════════════════════════════════════════════════════════════════════
2225
+
2226
+ // Determine verdict
2227
+ const verdict = blocks > 0 ? 'BLOCK' : warns > 0 ? 'WARN' : 'SHIP';
2228
+
2229
+ // Use Mission Control format
2230
+ if (!argsOrOpts.json && !argsOrOpts.quiet) {
2231
+ const { formatRealityOutput } = require('./lib/reality-output');
2232
+
2233
+ // Build test results from findings
2234
+ const tests = findings.map(f => ({
2235
+ name: f.title || f.category || 'Test',
2236
+ route: f.page || f.url || '',
2237
+ passed: f.severity !== 'BLOCK',
2238
+ }));
2239
+
2240
+ console.log(formatRealityOutput({
2241
+ verdict,
2242
+ tests,
2243
+ passed: tests.filter(t => t.passed).length,
2244
+ failed: tests.filter(t => !t.passed).length,
2245
+ warnings: warns,
2246
+ coverage: coverage.percent || 0,
2247
+ duration,
2248
+ url: baseUrl,
2249
+ success: verdict === 'SHIP',
2250
+ }, { projectPath: root, version: 'v3.5.5' }));
2251
+
2252
+ // Show report path
2253
+ console.log(`\n ${colors.accent}Report:${c.reset} ${path.relative(root, outBase)}/reality_report.json`);
2254
+ }
2255
+
2256
+ process.exitCode = blocks ? 2 : warns ? 1 : 0;
2257
+ return process.exitCode;
2258
+ }
2259
+
2260
+ module.exports = { runReality };