@warlock.js/core 5.0.1 → 5.1.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 (120) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/esm/cli/cli-commands.manager.mjs +36 -1
  3. package/esm/cli/cli-commands.manager.mjs.map +1 -1
  4. package/esm/cli/commands/dev-server.command.mjs +1 -1
  5. package/esm/cli/commands/doctor/boot-for-diagnostics.mjs +207 -0
  6. package/esm/cli/commands/doctor/boot-for-diagnostics.mjs.map +1 -0
  7. package/esm/cli/commands/doctor/checks/config.check.mjs +5 -2
  8. package/esm/cli/commands/doctor/checks/config.check.mjs.map +1 -1
  9. package/esm/cli/commands/doctor/checks/connectors.check.mjs +38 -12
  10. package/esm/cli/commands/doctor/checks/connectors.check.mjs.map +1 -1
  11. package/esm/cli/commands/doctor/checks/handler-signature.check.mjs +8 -4
  12. package/esm/cli/commands/doctor/checks/handler-signature.check.mjs.map +1 -1
  13. package/esm/cli/commands/doctor/checks/health.check.mjs +77 -11
  14. package/esm/cli/commands/doctor/checks/health.check.mjs.map +1 -1
  15. package/esm/cli/commands/doctor/checks/index.mjs +5 -0
  16. package/esm/cli/commands/doctor/checks/index.mjs.map +1 -1
  17. package/esm/cli/commands/doctor/checks/optional-peers.check.mjs +116 -53
  18. package/esm/cli/commands/doctor/checks/optional-peers.check.mjs.map +1 -1
  19. package/esm/cli/commands/doctor/checks/release-hygiene.check.mjs +27 -9
  20. package/esm/cli/commands/doctor/checks/release-hygiene.check.mjs.map +1 -1
  21. package/esm/cli/commands/doctor/checks/routes.check.mjs +51 -12
  22. package/esm/cli/commands/doctor/checks/routes.check.mjs.map +1 -1
  23. package/esm/cli/commands/doctor/doctor-command.action.mjs +16 -6
  24. package/esm/cli/commands/doctor/doctor-command.action.mjs.map +1 -1
  25. package/esm/cli/commands/doctor/run-checks.mjs +15 -6
  26. package/esm/cli/commands/doctor/run-checks.mjs.map +1 -1
  27. package/esm/cli/commands/doctor.command.mjs +17 -9
  28. package/esm/cli/commands/doctor.command.mjs.map +1 -1
  29. package/esm/connectors/describe-server-address.mjs +89 -6
  30. package/esm/connectors/describe-server-address.mjs.map +1 -1
  31. package/esm/connectors/http-connector.d.mts +18 -0
  32. package/esm/connectors/http-connector.d.mts.map +1 -1
  33. package/esm/connectors/http-connector.mjs +36 -3
  34. package/esm/connectors/http-connector.mjs.map +1 -1
  35. package/esm/dev-server/dev-logger.mjs +1 -4
  36. package/esm/dev-server/dev-logger.mjs.map +1 -1
  37. package/esm/dev-server/development-server.mjs +3 -7
  38. package/esm/dev-server/development-server.mjs.map +1 -1
  39. package/esm/dev-server/module-loader.mjs +1 -1
  40. package/esm/dev-server/ready-block.mjs +62 -0
  41. package/esm/dev-server/ready-block.mjs.map +1 -0
  42. package/esm/generations/add-command.action.mjs +2 -575
  43. package/esm/generations/add-command.action.mjs.map +1 -1
  44. package/esm/generations/features/access.feature.mjs +76 -0
  45. package/esm/generations/features/access.feature.mjs.map +1 -0
  46. package/esm/generations/features/ai-anthropic.feature.mjs +10 -0
  47. package/esm/generations/features/ai-anthropic.feature.mjs.map +1 -0
  48. package/esm/generations/features/ai-bedrock.feature.mjs +10 -0
  49. package/esm/generations/features/ai-bedrock.feature.mjs.map +1 -0
  50. package/esm/generations/features/ai-google.feature.mjs +10 -0
  51. package/esm/generations/features/ai-google.feature.mjs.map +1 -0
  52. package/esm/generations/features/ai-ollama.feature.mjs +10 -0
  53. package/esm/generations/features/ai-ollama.feature.mjs.map +1 -0
  54. package/esm/generations/features/ai-openai.feature.mjs +10 -0
  55. package/esm/generations/features/ai-openai.feature.mjs.map +1 -0
  56. package/esm/generations/features/ai-panoptic.feature.mjs +13 -0
  57. package/esm/generations/features/ai-panoptic.feature.mjs.map +1 -0
  58. package/esm/generations/features/ai-tools.feature.mjs +13 -0
  59. package/esm/generations/features/ai-tools.feature.mjs.map +1 -0
  60. package/esm/generations/features/ai-workspace.feature.mjs +13 -0
  61. package/esm/generations/features/ai-workspace.feature.mjs.map +1 -0
  62. package/esm/generations/features/ai.feature.mjs +15 -0
  63. package/esm/generations/features/ai.feature.mjs.map +1 -0
  64. package/esm/generations/features/herald.feature.mjs +19 -0
  65. package/esm/generations/features/herald.feature.mjs.map +1 -0
  66. package/esm/generations/features/image.feature.mjs +9 -0
  67. package/esm/generations/features/image.feature.mjs.map +1 -0
  68. package/esm/generations/features/index.mjs +72 -0
  69. package/esm/generations/features/index.mjs.map +1 -0
  70. package/esm/generations/features/mail.feature.mjs +10 -0
  71. package/esm/generations/features/mail.feature.mjs.map +1 -0
  72. package/esm/generations/features/mongodb.feature.mjs +9 -0
  73. package/esm/generations/features/mongodb.feature.mjs.map +1 -0
  74. package/esm/generations/features/mysql.feature.mjs +9 -0
  75. package/esm/generations/features/mysql.feature.mjs.map +1 -0
  76. package/esm/generations/features/notifications.feature.mjs +43 -0
  77. package/esm/generations/features/notifications.feature.mjs.map +1 -0
  78. package/esm/generations/features/postgres.feature.mjs +9 -0
  79. package/esm/generations/features/postgres.feature.mjs.map +1 -0
  80. package/esm/generations/features/react-email.feature.mjs +70 -0
  81. package/esm/generations/features/react-email.feature.mjs.map +1 -0
  82. package/esm/generations/features/react.feature.mjs +16 -0
  83. package/esm/generations/features/react.feature.mjs.map +1 -0
  84. package/esm/generations/features/redis.feature.mjs +9 -0
  85. package/esm/generations/features/redis.feature.mjs.map +1 -0
  86. package/esm/generations/features/s3.feature.mjs +13 -0
  87. package/esm/generations/features/s3.feature.mjs.map +1 -0
  88. package/esm/generations/features/scheduler.feature.mjs +9 -0
  89. package/esm/generations/features/scheduler.feature.mjs.map +1 -0
  90. package/esm/generations/features/ses.feature.mjs +9 -0
  91. package/esm/generations/features/ses.feature.mjs.map +1 -0
  92. package/esm/generations/features/shadcn.feature.mjs +397 -0
  93. package/esm/generations/features/shadcn.feature.mjs.map +1 -0
  94. package/esm/generations/features/shared/link-ai-package-import.mjs +37 -0
  95. package/esm/generations/features/shared/link-ai-package-import.mjs.map +1 -0
  96. package/esm/generations/features/shared/migration-timestamp.mjs +17 -0
  97. package/esm/generations/features/shared/migration-timestamp.mjs.map +1 -0
  98. package/esm/generations/features/socket.feature.mjs +15 -0
  99. package/esm/generations/features/socket.feature.mjs.map +1 -0
  100. package/esm/generations/features/tailwind.feature.mjs +174 -0
  101. package/esm/generations/features/tailwind.feature.mjs.map +1 -0
  102. package/esm/generations/features/test.feature.mjs +96 -0
  103. package/esm/generations/features/test.feature.mjs.map +1 -0
  104. package/esm/generations/features/web.feature.mjs +179 -0
  105. package/esm/generations/features/web.feature.mjs.map +1 -0
  106. package/esm/generations/stubs.mjs +95 -8
  107. package/esm/generations/stubs.mjs.map +1 -1
  108. package/esm/http/index.d.mts +1 -0
  109. package/esm/http/index.mjs +1 -0
  110. package/esm/http/ready-report.d.mts +56 -0
  111. package/esm/http/ready-report.d.mts.map +1 -0
  112. package/esm/http/ready-report.mjs +24 -0
  113. package/esm/http/ready-report.mjs.map +1 -0
  114. package/esm/index.d.mts +2 -1
  115. package/esm/index.mjs +2 -1
  116. package/llms-full.txt +322 -1
  117. package/llms.txt +2 -0
  118. package/package.json +12 -12
  119. package/skills/use-request-locals/SKILL.md +24 -1
  120. package/skills/warlock-conventions/SKILL.md +5 -1
@@ -1 +1 @@
1
- {"version":3,"file":"connectors.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/connectors.check.ts"],"sourcesContent":["import { connectorsManager } from \"../../../../connectors/connectors-manager\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * Reports which connectors are registered and which are currently active. This\n * is informational: a registered-but-inactive connector is normal (it simply\n * has no config wired), so the check passes as long as the manager can be\n * enumerated and reports its active set.\n *\n * Read-only: calls `connectorsManager.list()` and each connector's\n * `isActive()` (a pure getter) only.\n */\nexport const connectorsCheck: DoctorCheck = {\n name: \"connectors\",\n run: () => {\n const connectors = connectorsManager.list();\n const active = connectors.filter((connector) => connector.isActive());\n const activeNames = active.map((connector) => connector.name);\n\n const detail =\n activeNames.length > 0\n ? `${connectors.length} registered, active: ${activeNames.join(\", \")}`\n : `${connectors.length} registered, none active`;\n\n return {\n name: \"connectors\",\n status: \"ok\",\n detail,\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;AAYA,MAAa,kBAA+B;CAC1C,MAAM;CACN,WAAW;EACT,MAAM,aAAa,kBAAkB,KAAK;EAE1C,MAAM,cADS,WAAW,QAAQ,cAAc,UAAU,SAAS,CAC1C,CAAC,CAAC,KAAK,cAAc,UAAU,IAAI;EAO5D,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAPA,YAAY,SAAS,IACjB,GAAG,WAAW,OAAO,uBAAuB,YAAY,KAAK,IAAI,MACjE,GAAG,WAAW,OAAO;EAM3B;CACF;AACF"}
1
+ {"version":3,"file":"connectors.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/connectors.check.ts"],"sourcesContent":["import type { DoctorCheck } from \"../check.types\";\n\n/**\n * Reports whether the app's connectors survive boot.\n *\n * NEEDS A BOOTED APP: the boot pass runs every connector's `boot()` and no\n * `start()`, and this check reads the outcome.\n *\n * WHAT THIS REPLACED, AND WHY: the previous version printed\n * `11 registered, none active`. Both halves were tautologies. Eleven is the\n * number of built-in connectors the manager constructs when its module is\n * imported it is the same on every project that has ever existed and says\n * nothing about the app. And \"none active\" was guaranteed, because `active` is\n * set by `start()` and doctor deliberately never starts anything: the warning\n * could not be absent from a healthy app, so it could not mean anything on a\n * broken one.\n *\n * What is reported instead is a fact that varies with the project and can be\n * wrong: the connectors the app itself configured, and whether each of them\n * boots. A connector that throws on boot is a boot-time crash the user has not\n * hit yet.\n *\n * Verdicts:\n * - `warlock.config.ts > connectors` refused (duplicate or reserved name) →\n * `fail`. The next real boot would throw the same error;\n * - a connector's `boot()` threw `fail`, named, with the message;\n * - otherwise → `ok`, listing what booted.\n */\nexport const connectorsCheck: DoctorCheck = {\n name: \"connectors\",\n run: (context) => {\n const { connectors } = context;\n\n if (connectors.registrationError) {\n return {\n name: \"connectors\",\n status: \"fail\",\n detail: `warlock.config.ts > connectors was refused: ${connectors.registrationError}`,\n };\n }\n\n if (connectors.failures.length > 0) {\n return {\n name: \"connectors\",\n status: \"fail\",\n detail:\n `${connectors.failures.length} connector(s) failed to boot:\\n` +\n connectors.failures\n .map((failure) => ` - ${failure.name}: ${failure.message}`)\n .join(\"\\n\"),\n };\n }\n\n if (!context.booted) {\n return undefined;\n }\n\n const skipped = connectors.skipped\n .map((entry) => `; skipped ${entry.name} (${entry.reason})`)\n .join(\"\");\n\n const configured =\n connectors.configured.length > 0\n ? `${connectors.configured.join(\", \")} from warlock.config.ts + ` +\n `${connectors.registered.length - connectors.configured.length} built-in`\n : `${connectors.registered.length} built-in, none configured in warlock.config.ts`;\n\n return {\n name: \"connectors\",\n status: \"ok\",\n detail: `all booted without error (${configured})${skipped}`,\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAa,kBAA+B;CAC1C,MAAM;CACN,MAAM,YAAY;EAChB,MAAM,EAAE,eAAe;EAEvB,IAAI,WAAW,mBACb,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,+CAA+C,WAAW;EACpE;EAGF,IAAI,WAAW,SAAS,SAAS,GAC/B,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,GAAG,WAAW,SAAS,OAAO,mCAC9B,WAAW,SACR,KAAK,YAAY,OAAO,QAAQ,KAAK,IAAI,QAAQ,SAAS,CAAC,CAC3D,KAAK,IAAI;EAChB;EAGF,IAAI,CAAC,QAAQ,QACX;EAGF,MAAM,UAAU,WAAW,QACxB,KAAK,UAAU,aAAa,MAAM,KAAK,IAAI,MAAM,OAAO,EAAE,CAAC,CAC3D,KAAK,EAAE;EAQV,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,6BARR,WAAW,WAAW,SAAS,IAC3B,GAAG,WAAW,WAAW,KAAK,IAAI,EAAE,4BACjC,WAAW,WAAW,SAAS,WAAW,WAAW,OAAO,aAC/D,GAAG,WAAW,WAAW,OAAO,iDAKY,GAAG;EACrD;CACF;AACF"}
@@ -14,13 +14,17 @@ import { describePositionalHandlerSuspect, listPositionalHandlerSuspects } from
14
14
  * `warn`, never `fail`: the detection is a heuristic on the handler's declared
15
15
  * parameters, so a false positive is possible and must not break a build.
16
16
  *
17
- * Read-only: reads the list the router collected while routes registered. It
18
- * therefore reports on the routes this process has loaded a run before route
19
- * registration sees nothing.
17
+ * NEEDS A BOOTED APP: the router collects suspects as routes register, so this
18
+ * reads a list the boot pass filled. Before that pass existed the list was
19
+ * always empty and this check always passed — it was green because it had
20
+ * nothing to look at, which is indistinguishable from green because the app is
21
+ * fine. It stays silent when there are no routes for the same reason: a check
22
+ * with no input reports nothing rather than a clean bill of health.
20
23
  */
21
24
  const handlerSignatureCheck = {
22
25
  name: "handler-signature",
23
- run: () => {
26
+ run: (context) => {
27
+ if (context.totalRoutes === 0) return;
24
28
  const suspects = listPositionalHandlerSuspects();
25
29
  if (suspects.length === 0) return {
26
30
  name: "handler-signature",
@@ -1 +1 @@
1
- {"version":3,"file":"handler-signature.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/handler-signature.check.ts"],"sourcesContent":["import {\n describePositionalHandlerSuspect,\n listPositionalHandlerSuspects,\n} from \"../../../../router/positional-handler-diagnostics\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * Surfaces route handlers still written against the v4 positional signature,\n * `(request, response)`. v5 calls a handler with a single context object, so\n * the second parameter is always `undefined` and the route fails on its first\n * request with a `TypeError` that names nothing.\n *\n * Verdicts:\n * - one or more suspects → `warn`;\n * - otherwise → `ok`.\n *\n * `warn`, never `fail`: the detection is a heuristic on the handler's declared\n * parameters, so a false positive is possible and must not break a build.\n *\n * Read-only: reads the list the router collected while routes registered. It\n * therefore reports on the routes this process has loadeda run before route\n * registration sees nothing.\n */\nexport const handlerSignatureCheck: DoctorCheck = {\n name: \"handler-signature\",\n run: () => {\n const suspects = listPositionalHandlerSuspects();\n\n if (suspects.length === 0) {\n return {\n name: \"handler-signature\",\n status: \"ok\",\n detail: \"no handlers look like the v4 positional signature\",\n };\n }\n\n const headline =\n suspects.length === 1\n ? \"1 handler looks like the v4 positional signature\"\n : `${suspects.length} handlers look like the v4 positional signature`;\n\n return {\n name: \"handler-signature\",\n status: \"warn\",\n detail:\n `${headline}:\\n` +\n suspects.map((suspect) => ` - ${describePositionalHandlerSuspect(suspect)}`).join(\"\\n\"),\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAuBA,MAAa,wBAAqC;CAChD,MAAM;CACN,WAAW;EACT,MAAM,WAAW,8BAA8B;EAE/C,IAAI,SAAS,WAAW,GACtB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAQF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,GARF,SAAS,WAAW,IAChB,qDACA,GAAG,SAAS,OAAO,iDAMT,OACZ,SAAS,KAAK,YAAY,OAAO,iCAAiC,OAAO,GAAG,CAAC,CAAC,KAAK,IAAI;EAC3F;CACF;AACF"}
1
+ {"version":3,"file":"handler-signature.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/handler-signature.check.ts"],"sourcesContent":["import {\n describePositionalHandlerSuspect,\n listPositionalHandlerSuspects,\n} from \"../../../../router/positional-handler-diagnostics\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * Surfaces route handlers still written against the v4 positional signature,\n * `(request, response)`. v5 calls a handler with a single context object, so\n * the second parameter is always `undefined` and the route fails on its first\n * request with a `TypeError` that names nothing.\n *\n * Verdicts:\n * - one or more suspects → `warn`;\n * - otherwise → `ok`.\n *\n * `warn`, never `fail`: the detection is a heuristic on the handler's declared\n * parameters, so a false positive is possible and must not break a build.\n *\n * NEEDS A BOOTED APP: the router collects suspects as routes register, so this\n * reads a list the boot pass filled. Before that pass existed the list was\n * always empty and this check always passedit was green because it had\n * nothing to look at, which is indistinguishable from green because the app is\n * fine. It stays silent when there are no routes for the same reason: a check\n * with no input reports nothing rather than a clean bill of health.\n */\nexport const handlerSignatureCheck: DoctorCheck = {\n name: \"handler-signature\",\n run: (context) => {\n if (context.totalRoutes === 0) {\n return undefined;\n }\n\n const suspects = listPositionalHandlerSuspects();\n\n if (suspects.length === 0) {\n return {\n name: \"handler-signature\",\n status: \"ok\",\n detail: \"no handlers look like the v4 positional signature\",\n };\n }\n\n const headline =\n suspects.length === 1\n ? \"1 handler looks like the v4 positional signature\"\n : `${suspects.length} handlers look like the v4 positional signature`;\n\n return {\n name: \"handler-signature\",\n status: \"warn\",\n detail:\n `${headline}:\\n` +\n suspects.map((suspect) => ` - ${describePositionalHandlerSuspect(suspect)}`).join(\"\\n\"),\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA0BA,MAAa,wBAAqC;CAChD,MAAM;CACN,MAAM,YAAY;EAChB,IAAI,QAAQ,gBAAgB,GAC1B;EAGF,MAAM,WAAW,8BAA8B;EAE/C,IAAI,SAAS,WAAW,GACtB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAQF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,GARF,SAAS,WAAW,IAChB,qDACA,GAAG,SAAS,OAAO,iDAMT,OACZ,SAAS,KAAK,YAAY,OAAO,iCAAiC,OAAO,GAAG,CAAC,CAAC,KAAK,IAAI;EAC3F;CACF;AACF"}
@@ -1,31 +1,97 @@
1
1
  import { config } from "../../../../config/config-getter.mjs";
2
+ import { container } from "../../../../container/index.mjs";
3
+ import { router } from "../../../../router/router.mjs";
2
4
 
3
5
  //#region ../core/src/cli/commands/doctor/checks/health.check.ts
4
6
  /**
5
- * Confirms the built-in liveness (`/health`) and readiness (`/ready`)
6
- * endpoints will be exposed. The HTTP connector registers them automatically
7
- * unless `http.health.enabled` is explicitly `false`, so this check reads that
8
- * config flag and the (overridable) paths.
7
+ * Ask the booted Fastify instance whether a GET route exists at this path.
9
8
  *
10
- * Disabling the probes is a deliberate, supported choice but a common
11
- * misconfiguration in container/orchestrated deployments, so it is surfaced as
12
- * a `warn` rather than a pass.
9
+ * `hasRoute` is the instance's own answer, not a re-derivation of the config
10
+ * that was supposed to produce it — which is the entire point of asking it.
11
+ * Guarded because it is the one part of this check that depends on a Fastify
12
+ * API: if the instance cannot answer, the check says so instead of inventing a
13
+ * verdict from the config flag it is meant to be verifying.
14
+ */
15
+ function fastifyHasGet(path) {
16
+ const http = container.has("http.server") ? container.get("http.server") : void 0;
17
+ if (!http || typeof http.hasRoute !== "function") return void 0;
18
+ try {
19
+ return Boolean(http.hasRoute({
20
+ method: "GET",
21
+ url: path
22
+ }));
23
+ } catch {
24
+ return;
25
+ }
26
+ }
27
+ /**
28
+ * Whether an app route already claims this path. The HTTP connector puts the
29
+ * probes on the Fastify instance directly and the router scans app routes onto
30
+ * the same instance at `start()`, so a collision is a duplicate registration:
31
+ * Fastify refuses it and the server does not start.
32
+ */
33
+ function appRouteClaims(path) {
34
+ return router.list().some((route) => {
35
+ const method = String(route.method).toUpperCase();
36
+ return route.path === path && (method === "GET" || method === "ALL");
37
+ });
38
+ }
39
+ /**
40
+ * Confirms the liveness (`/health`) and readiness (`/ready`) endpoints are
41
+ * really there.
42
+ *
43
+ * NEEDS A BOOTED APP: the HTTP connector registers the probes during `boot()`,
44
+ * which the boot pass runs. Previously this check read `http.health.enabled`
45
+ * and reported the endpoints as "registered" on that basis alone — in the same
46
+ * report where `routes` said zero, because nothing had been booted at all. It
47
+ * was reporting an intention as a fact, and the two lines contradicted each
48
+ * other in the owner's own run.
13
49
  *
14
- * Read-only: inspects `config.get("http.health")` only.
50
+ * Verdicts:
51
+ * - no `http` config → not applicable, no line;
52
+ * - `http.health.enabled = false` → `ok`. Turning the probes off is a supported
53
+ * choice, and a warning that fires on a deliberate setting every single run
54
+ * is not read by the time it matters;
55
+ * - an app route already claims a probe's path → `fail`: Fastify will refuse
56
+ * the duplicate and the server will not start;
57
+ * - the instance says the route is absent → `fail`;
58
+ * - otherwise → `ok`, with the paths that are actually registered.
15
59
  */
16
60
  const healthCheck = {
17
61
  name: "health",
18
- run: () => {
62
+ run: (context) => {
63
+ if (!config.get("http")) return;
19
64
  const healthConfig = config.get("http.health");
20
65
  if (healthConfig?.enabled === false) return {
66
+ name: "health",
67
+ status: "ok",
68
+ detail: "probes disabled by config (http.health.enabled = false)"
69
+ };
70
+ const livenessPath = healthConfig?.path ?? "/health";
71
+ const readinessPath = healthConfig?.readinessPath ?? "/ready";
72
+ const collisions = [livenessPath, readinessPath].filter(appRouteClaims);
73
+ if (collisions.length > 0) return {
74
+ name: "health",
75
+ status: "fail",
76
+ detail: `an app route already claims ${collisions.join(" and ")} — Fastify refuses the duplicate registration and the server will not start. Move the app route, or rename the probe via http.health.path / http.health.readinessPath`
77
+ };
78
+ if (!context.connectors.booted.includes("http")) return;
79
+ const registered = [livenessPath, readinessPath].map(fastifyHasGet);
80
+ if (registered.some((answer) => answer === void 0)) return {
21
81
  name: "health",
22
82
  status: "warn",
23
- detail: "health endpoints disabled (http.health.enabled = false)"
83
+ detail: `could not read the http server's route table — ${livenessPath} + ${readinessPath} unverified`
84
+ };
85
+ const missing = [livenessPath, readinessPath].filter((_path, index) => !registered[index]);
86
+ if (missing.length > 0) return {
87
+ name: "health",
88
+ status: "fail",
89
+ detail: `enabled in config but not registered on the http server: ${missing.join(", ")}`
24
90
  };
25
91
  return {
26
92
  name: "health",
27
93
  status: "ok",
28
- detail: `liveness ${healthConfig?.path ?? "/health"} + readiness ${healthConfig?.readinessPath ?? "/ready"} registered`
94
+ detail: `liveness ${livenessPath} + readiness ${readinessPath} registered`
29
95
  };
30
96
  }
31
97
  };
@@ -1 +1 @@
1
- {"version":3,"file":"health.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/health.check.ts"],"sourcesContent":["import { config } from \"../../../../config/config-getter\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * Confirms the built-in liveness (`/health`) and readiness (`/ready`)\n * endpoints will be exposed. The HTTP connector registers them automatically\n * unless `http.health.enabled` is explicitly `false`, so this check reads that\n * config flag and the (overridable) paths.\n *\n * Disabling the probes is a deliberate, supported choice but a common\n * misconfiguration in container/orchestrated deployments, so it is surfaced as\n * a `warn` rather than a pass.\n *\n * Read-only: inspects `config.get(\"http.health\")` only.\n */\nexport const healthCheck: DoctorCheck = {\n name: \"health\",\n run: () => {\n const healthConfig = config.get(\"http.health\");\n\n if (healthConfig?.enabled === false) {\n return {\n name: \"health\",\n status: \"warn\",\n detail: \"health endpoints disabled (http.health.enabled = false)\",\n };\n }\n\n const livenessPath = healthConfig?.path ?? \"/health\";\n const readinessPath = healthConfig?.readinessPath ?? \"/ready\";\n\n return {\n name: \"health\",\n status: \"ok\",\n detail: `liveness ${livenessPath} + readiness ${readinessPath} registered`,\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;;;;AAeA,MAAa,cAA2B;CACtC,MAAM;CACN,WAAW;EACT,MAAM,eAAe,OAAO,IAAI,aAAa;EAE7C,IAAI,cAAc,YAAY,OAC5B,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAMF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,YANW,cAAc,QAAQ,UAMR,eALb,cAAc,iBAAiB,SAKW;EAChE;CACF;AACF"}
1
+ {"version":3,"file":"health.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/health.check.ts"],"sourcesContent":["import { config } from \"../../../../config/config-getter\";\nimport { container } from \"../../../../container\";\nimport { router } from \"../../../../router/router\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * Ask the booted Fastify instance whether a GET route exists at this path.\n *\n * `hasRoute` is the instance's own answer, not a re-derivation of the config\n * that was supposed to produce it — which is the entire point of asking it.\n * Guarded because it is the one part of this check that depends on a Fastify\n * API: if the instance cannot answer, the check says so instead of inventing a\n * verdict from the config flag it is meant to be verifying.\n */\nfunction fastifyHasGet(path: string): boolean | undefined {\n const http = container.has(\"http.server\") ? container.get<any>(\"http.server\") : undefined;\n\n if (!http || typeof http.hasRoute !== \"function\") return undefined;\n\n try {\n return Boolean(http.hasRoute({ method: \"GET\", url: path }));\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether an app route already claims this path. The HTTP connector puts the\n * probes on the Fastify instance directly and the router scans app routes onto\n * the same instance at `start()`, so a collision is a duplicate registration:\n * Fastify refuses it and the server does not start.\n */\nfunction appRouteClaims(path: string): boolean {\n return router.list().some((route) => {\n const method = String(route.method).toUpperCase();\n\n return route.path === path && (method === \"GET\" || method === \"ALL\");\n });\n}\n\n/**\n * Confirms the liveness (`/health`) and readiness (`/ready`) endpoints are\n * really there.\n *\n * NEEDS A BOOTED APP: the HTTP connector registers the probes during `boot()`,\n * which the boot pass runs. Previously this check read `http.health.enabled`\n * and reported the endpoints as \"registered\" on that basis alone — in the same\n * report where `routes` said zero, because nothing had been booted at all. It\n * was reporting an intention as a fact, and the two lines contradicted each\n * other in the owner's own run.\n *\n * Verdicts:\n * - no `http` config → not applicable, no line;\n * - `http.health.enabled = false` → `ok`. Turning the probes off is a supported\n * choice, and a warning that fires on a deliberate setting every single run\n * is not read by the time it matters;\n * - an app route already claims a probe's path → `fail`: Fastify will refuse\n * the duplicate and the server will not start;\n * - the instance says the route is absent → `fail`;\n * - otherwise `ok`, with the paths that are actually registered.\n */\nexport const healthCheck: DoctorCheck = {\n name: \"health\",\n run: (context) => {\n if (!config.get(\"http\")) {\n return undefined;\n }\n\n const healthConfig = config.get(\"http.health\");\n\n if (healthConfig?.enabled === false) {\n return {\n name: \"health\",\n status: \"ok\",\n detail: \"probes disabled by config (http.health.enabled = false)\",\n };\n }\n\n const livenessPath = healthConfig?.path ?? \"/health\";\n const readinessPath = healthConfig?.readinessPath ?? \"/ready\";\n\n const collisions = [livenessPath, readinessPath].filter(appRouteClaims);\n\n if (collisions.length > 0) {\n return {\n name: \"health\",\n status: \"fail\",\n detail:\n `an app route already claims ${collisions.join(\" and \")} — Fastify refuses the ` +\n \"duplicate registration and the server will not start. Move the app route, or \" +\n \"rename the probe via http.health.path / http.health.readinessPath\",\n };\n }\n\n // The HTTP connector is what registers the probes. If it did not boot, the\n // connectors check already says so with the reason — reporting the same\n // defect a second time here as a health failure would inflate one problem\n // into two.\n if (!context.connectors.booted.includes(\"http\")) {\n return undefined;\n }\n\n const registered = [livenessPath, readinessPath].map(fastifyHasGet);\n\n if (registered.some((answer) => answer === undefined)) {\n return {\n name: \"health\",\n status: \"warn\",\n detail:\n \"could not read the http server's route table — \" +\n `${livenessPath} + ${readinessPath} unverified`,\n };\n }\n\n const missing = [livenessPath, readinessPath].filter((_path, index) => !registered[index]);\n\n if (missing.length > 0) {\n return {\n name: \"health\",\n status: \"fail\",\n detail: `enabled in config but not registered on the http server: ${missing.join(\", \")}`,\n };\n }\n\n return {\n name: \"health\",\n status: \"ok\",\n detail: `liveness ${livenessPath} + readiness ${readinessPath} registered`,\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;;;AAcA,SAAS,cAAc,MAAmC;CACxD,MAAM,OAAO,UAAU,IAAI,aAAa,IAAI,UAAU,IAAS,aAAa,IAAI;CAEhF,IAAI,CAAC,QAAQ,OAAO,KAAK,aAAa,YAAY,OAAO;CAEzD,IAAI;EACF,OAAO,QAAQ,KAAK,SAAS;GAAE,QAAQ;GAAO,KAAK;EAAK,CAAC,CAAC;CAC5D,QAAQ;EACN;CACF;AACF;;;;;;;AAQA,SAAS,eAAe,MAAuB;CAC7C,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,UAAU;EACnC,MAAM,SAAS,OAAO,MAAM,MAAM,CAAC,CAAC,YAAY;EAEhD,OAAO,MAAM,SAAS,SAAS,WAAW,SAAS,WAAW;CAChE,CAAC;AACH;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAa,cAA2B;CACtC,MAAM;CACN,MAAM,YAAY;EAChB,IAAI,CAAC,OAAO,IAAI,MAAM,GACpB;EAGF,MAAM,eAAe,OAAO,IAAI,aAAa;EAE7C,IAAI,cAAc,YAAY,OAC5B,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAGF,MAAM,eAAe,cAAc,QAAQ;EAC3C,MAAM,gBAAgB,cAAc,iBAAiB;EAErD,MAAM,aAAa,CAAC,cAAc,aAAa,CAAC,CAAC,OAAO,cAAc;EAEtE,IAAI,WAAW,SAAS,GACtB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,+BAA+B,WAAW,KAAK,OAAO,EAAE;EAG5D;EAOF,IAAI,CAAC,QAAQ,WAAW,OAAO,SAAS,MAAM,GAC5C;EAGF,MAAM,aAAa,CAAC,cAAc,aAAa,CAAC,CAAC,IAAI,aAAa;EAElE,IAAI,WAAW,MAAM,WAAW,WAAW,MAAS,GAClD,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,kDACG,aAAa,KAAK,cAAc;EACvC;EAGF,MAAM,UAAU,CAAC,cAAc,aAAa,CAAC,CAAC,QAAQ,OAAO,UAAU,CAAC,WAAW,MAAM;EAEzF,IAAI,QAAQ,SAAS,GACnB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,4DAA4D,QAAQ,KAAK,IAAI;EACvF;EAGF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,YAAY,aAAa,eAAe,cAAc;EAChE;CACF;AACF"}
@@ -11,6 +11,11 @@ import { routesCheck } from "./routes.check.mjs";
11
11
  * The default, ordered set of checks `warlock doctor` runs. Ordering controls
12
12
  * the report layout: runtime-surface checks first (routes, handler signatures,
13
13
  * config, connectors, peers, health), release hygiene last.
14
+ *
15
+ * Every check is handed the same boot context, and any of them may return
16
+ * `undefined` to opt out of a project it does not apply to — so this list is
17
+ * the set of checks that COULD run, not the set of lines a given project will
18
+ * print.
14
19
  */
15
20
  const defaultDoctorChecks = [
16
21
  routesCheck,
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/index.ts"],"sourcesContent":["import type { DoctorCheck } from \"../check.types\";\nimport { configCheck } from \"./config.check\";\nimport { connectorsCheck } from \"./connectors.check\";\nimport { handlerSignatureCheck } from \"./handler-signature.check\";\nimport { healthCheck } from \"./health.check\";\nimport { optionalPeersCheck } from \"./optional-peers.check\";\nimport { releaseHygieneCheck } from \"./release-hygiene.check\";\nimport { routesCheck } from \"./routes.check\";\n\n/**\n * The default, ordered set of checks `warlock doctor` runs. Ordering controls\n * the report layout: runtime-surface checks first (routes, handler signatures,\n * config, connectors, peers, health), release hygiene last.\n */\nexport const defaultDoctorChecks: DoctorCheck[] = [\n routesCheck,\n handlerSignatureCheck,\n configCheck,\n connectorsCheck,\n optionalPeersCheck,\n healthCheck,\n releaseHygieneCheck,\n];\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAa,sBAAqC;CAChD;CACA;CACA;CACA;CACA;CACA;CACA;AACF"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/index.ts"],"sourcesContent":["import type { DoctorCheck } from \"../check.types\";\nimport { configCheck } from \"./config.check\";\nimport { connectorsCheck } from \"./connectors.check\";\nimport { handlerSignatureCheck } from \"./handler-signature.check\";\nimport { healthCheck } from \"./health.check\";\nimport { optionalPeersCheck } from \"./optional-peers.check\";\nimport { releaseHygieneCheck } from \"./release-hygiene.check\";\nimport { routesCheck } from \"./routes.check\";\n\n/**\n * The default, ordered set of checks `warlock doctor` runs. Ordering controls\n * the report layout: runtime-surface checks first (routes, handler signatures,\n * config, connectors, peers, health), release hygiene last.\n *\n * Every check is handed the same boot context, and any of them may return\n * `undefined` to opt out of a project it does not apply to — so this list is\n * the set of checks that COULD run, not the set of lines a given project will\n * print.\n */\nexport const defaultDoctorChecks: DoctorCheck[] = [\n routesCheck,\n handlerSignatureCheck,\n configCheck,\n connectorsCheck,\n optionalPeersCheck,\n healthCheck,\n releaseHygieneCheck,\n];\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAmBA,MAAa,sBAAqC;CAChD;CACA;CACA;CACA;CACA;CACA;CACA;AACF"}
@@ -1,50 +1,8 @@
1
+ import { config } from "../../../../config/config-getter.mjs";
1
2
  import { createRequire } from "node:module";
2
3
 
3
4
  //#region ../core/src/cli/commands/doctor/checks/optional-peers.check.ts
4
5
  /**
5
- * The optional peers core knows how to use. Kept in sync with the install
6
- * targets in the `warlock add` feature registry (sharp, nodemailer, socket.io,
7
- * the AWS SDK family, redis, the Cascade DB drivers, …).
8
- */
9
- const OPTIONAL_PEERS = [
10
- {
11
- package: "sharp",
12
- feature: "image processing"
13
- },
14
- {
15
- package: "nodemailer",
16
- feature: "mail (SMTP) channel"
17
- },
18
- {
19
- package: "socket.io",
20
- feature: "realtime socket server"
21
- },
22
- {
23
- package: "@aws-sdk/client-s3",
24
- feature: "S3 cloud storage"
25
- },
26
- {
27
- package: "@aws-sdk/client-sesv2",
28
- feature: "Amazon SES mail channel"
29
- },
30
- {
31
- package: "redis",
32
- feature: "Redis cache driver"
33
- },
34
- {
35
- package: "mongodb",
36
- feature: "MongoDB database driver"
37
- },
38
- {
39
- package: "pg",
40
- feature: "Postgres database driver"
41
- },
42
- {
43
- package: "mysql2",
44
- feature: "MySQL database driver"
45
- }
46
- ];
47
- /**
48
6
  * Resolve from the project's working directory, not from core's own
49
7
  * `node_modules`, so the probe reflects what the *consuming app* has installed.
50
8
  */
@@ -62,25 +20,130 @@ function isInstalled(packageName) {
62
20
  }
63
21
  }
64
22
  /**
65
- * Detects which optional peer dependencies are installed and which are not,
66
- * reporting the features that are consequently unavailable. Missing peers are
67
- * a `warn` (the feature is simply off), never a `fail`.
23
+ * A non-empty string, or `undefined`. Config values arrive from `env(...)` and
24
+ * an unset variable yields `undefined` but a `.env` line with nothing after
25
+ * the `=` yields `""`, which is "not configured" just as much.
26
+ */
27
+ function text(value) {
28
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
29
+ }
30
+ /**
31
+ * The database driver's package. `driver` is a closed union in cascade
32
+ * (`mongodb | postgres | mysql`), so an unrecognised value means the config is
33
+ * wrong in a way this check is not the right one to report.
34
+ */
35
+ function databasePeer() {
36
+ const driver = text(config.get("database.driver"));
37
+ const packageName = driver === "mongodb" ? "mongodb" : driver === "postgres" ? "pg" : driver === "mysql" ? "mysql2" : void 0;
38
+ return packageName ? {
39
+ package: packageName,
40
+ because: `database.driver = "${driver}"`
41
+ } : void 0;
42
+ }
43
+ /**
44
+ * The selected cache driver. Only the DEFAULT matters: `cache.drivers` is a
45
+ * menu the scaffold fills in for every driver the framework ships, and warning
46
+ * about a menu entry nobody selected is the false positive this whole check was
47
+ * rewritten to remove.
48
+ */
49
+ function cachePeer() {
50
+ const selected = text(config.get("cache.default"));
51
+ if (!selected || !/redis/i.test(selected)) return void 0;
52
+ return {
53
+ package: "redis",
54
+ because: `cache.default = "${selected}"`
55
+ };
56
+ }
57
+ /**
58
+ * The selected storage driver, read through its canonical `driver` field
59
+ * (`local` / `s3` / `r2` / `spaces`) rather than the key the app happened to
60
+ * name it, so a driver called "cdn" is still recognised as S3.
61
+ */
62
+ function storagePeer() {
63
+ const selected = text(config.get("storage.default"));
64
+ if (!selected) return void 0;
65
+ const driver = text(config.get(`storage.drivers.${selected}.driver`));
66
+ if (!driver || driver === "local") return void 0;
67
+ return {
68
+ package: "@aws-sdk/client-s3",
69
+ because: `storage.default = "${selected}" (driver "${driver}")`
70
+ };
71
+ }
72
+ /**
73
+ * The mail transport. A `mail` config with no host and no driver is the
74
+ * scaffold's placeholder — every field is an unset env var — so it asks for
75
+ * nothing and this stays quiet.
76
+ */
77
+ function mailPeer() {
78
+ if (text(config.get("mail.driver")) === "ses") return {
79
+ package: "@aws-sdk/client-sesv2",
80
+ because: `mail.driver = "ses"`
81
+ };
82
+ const host = text(config.get("mail.host"));
83
+ if (!host) return void 0;
84
+ return {
85
+ package: "nodemailer",
86
+ because: `mail.host = "${host}"`
87
+ };
88
+ }
89
+ /**
90
+ * Realtime sockets. The connector no-ops without a `socket` config, so the
91
+ * presence of that config is exactly the request for the peer.
92
+ */
93
+ function socketPeer() {
94
+ if (!config.get("socket")) return void 0;
95
+ return {
96
+ package: "socket.io",
97
+ because: "a socket config is present"
98
+ };
99
+ }
100
+ /**
101
+ * Every peer THIS project's configuration asks for.
102
+ */
103
+ function requiredPeers() {
104
+ return [
105
+ databasePeer(),
106
+ cachePeer(),
107
+ storagePeer(),
108
+ mailPeer(),
109
+ socketPeer()
110
+ ].filter((peer) => Boolean(peer));
111
+ }
112
+ /**
113
+ * Checks that every optional peer the project's own configuration selects is
114
+ * actually installed.
115
+ *
116
+ * NEEDS NO BOOTED APP — it reads config and resolves module paths. It runs after
117
+ * the boot pass only because config is loaded by then.
118
+ *
119
+ * WHAT THIS REPLACED, AND WHY: the previous version probed nine packages and
120
+ * warned about every one that was absent. Every one of them is an OPTIONAL
121
+ * peer — a fresh app installs none of them by design — so the check warned, at
122
+ * length, about the framework's own default state. "No MySQL driver installed"
123
+ * is not a diagnosis for someone who never wanted MySQL; it is noise that
124
+ * teaches the reader to skim past the line where a real finding will one day
125
+ * appear.
68
126
  *
69
- * Read-only: resolves module paths only; never imports or executes the peers.
127
+ * Inverted, it becomes a genuine one: a project whose config selects a driver
128
+ * whose package is missing is broken — it will throw the first time that
129
+ * feature is used — so a missing peer here is a `fail`, not a warning.
70
130
  */
71
131
  const optionalPeersCheck = {
72
132
  name: "optional-peers",
73
133
  run: () => {
74
- const missing = OPTIONAL_PEERS.filter((peer) => !isInstalled(peer.package));
75
- if (missing.length === 0) return {
134
+ const required = requiredPeers();
135
+ if (required.length === 0) return;
136
+ const missing = required.filter((peer) => !isInstalled(peer.package));
137
+ const status = missing.length > 0 ? "fail" : "ok";
138
+ if (missing.length > 0) return {
76
139
  name: "optional-peers",
77
- status: "ok",
78
- detail: `all ${OPTIONAL_PEERS.length} known optional peers installed`
140
+ status,
141
+ detail: `${missing.length} configured feature(s) have no driver installed:\n` + missing.map((peer) => ` - ${peer.package} required by ${peer.because}`).join("\n")
79
142
  };
80
143
  return {
81
144
  name: "optional-peers",
82
- status: "warn",
83
- detail: `not installed unavailable: ${missing.map((peer) => `${peer.package} (${peer.feature})`).join("; ")}`
145
+ status,
146
+ detail: `every configured driver is installed (${required.map((peer) => peer.package).join(", ")})`
84
147
  };
85
148
  }
86
149
  };
@@ -1 +1 @@
1
- {"version":3,"file":"optional-peers.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/optional-peers.check.ts"],"sourcesContent":["import { createRequire } from \"node:module\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * A single optional peer dependency and the feature it unlocks. These are NOT\n * bundled by core each is loaded lazily by the connector/driver that needs\n * it, so a missing peer simply means that feature is unavailable (a `warn`),\n * never a failure.\n */\ntype OptionalPeer = {\n /** The npm package name to probe via `require.resolve`. */\n package: string;\n\n /** The framework feature this peer enables. */\n feature: string;\n};\n\n/**\n * The optional peers core knows how to use. Kept in sync with the install\n * targets in the `warlock add` feature registry (sharp, nodemailer, socket.io,\n * the AWS SDK family, redis, the Cascade DB drivers, …).\n */\nconst OPTIONAL_PEERS: OptionalPeer[] = [\n { package: \"sharp\", feature: \"image processing\" },\n { package: \"nodemailer\", feature: \"mail (SMTP) channel\" },\n { package: \"socket.io\", feature: \"realtime socket server\" },\n { package: \"@aws-sdk/client-s3\", feature: \"S3 cloud storage\" },\n { package: \"@aws-sdk/client-sesv2\", feature: \"Amazon SES mail channel\" },\n { package: \"redis\", feature: \"Redis cache driver\" },\n { package: \"mongodb\", feature: \"MongoDB database driver\" },\n { package: \"pg\", feature: \"Postgres database driver\" },\n { package: \"mysql2\", feature: \"MySQL database driver\" },\n];\n\n/**\n * Resolve from the project's working directory, not from core's own\n * `node_modules`, so the probe reflects what the *consuming app* has installed.\n */\nconst projectRequire = createRequire(`${process.cwd()}/package.json`);\n\n/**\n * Whether a package can be resolved from the project. A failed resolve (the\n * normal \"not installed\" case) is swallowed and reported as missing.\n */\nfunction isInstalled(packageName: string): boolean {\n try {\n projectRequire.resolve(packageName);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Detects which optional peer dependencies are installed and which are not,\n * reporting the features that are consequently unavailable. Missing peers are\n * a `warn` (the feature is simply off), never a `fail`.\n *\n * Read-only: resolves module paths only; never imports or executes the peers.\n */\nexport const optionalPeersCheck: DoctorCheck = {\n name: \"optional-peers\",\n run: () => {\n const missing = OPTIONAL_PEERS.filter((peer) => !isInstalled(peer.package));\n\n if (missing.length === 0) {\n return {\n name: \"optional-peers\",\n status: \"ok\",\n detail: `all ${OPTIONAL_PEERS.length} known optional peers installed`,\n };\n }\n\n const unavailable = missing\n .map((peer) => `${peer.package} (${peer.feature})`)\n .join(\"; \");\n\n return {\n name: \"optional-peers\",\n status: \"warn\",\n detail: `not installed unavailable: ${unavailable}`,\n };\n },\n};\n"],"mappings":";;;;;;;;AAsBA,MAAM,iBAAiC;CACrC;EAAE,SAAS;EAAS,SAAS;CAAmB;CAChD;EAAE,SAAS;EAAc,SAAS;CAAsB;CACxD;EAAE,SAAS;EAAa,SAAS;CAAyB;CAC1D;EAAE,SAAS;EAAsB,SAAS;CAAmB;CAC7D;EAAE,SAAS;EAAyB,SAAS;CAA0B;CACvE;EAAE,SAAS;EAAS,SAAS;CAAqB;CAClD;EAAE,SAAS;EAAW,SAAS;CAA0B;CACzD;EAAE,SAAS;EAAM,SAAS;CAA2B;CACrD;EAAE,SAAS;EAAU,SAAS;CAAwB;AACxD;;;;;AAMA,MAAM,iBAAiB,cAAc,GAAG,QAAQ,IAAI,EAAE,cAAc;;;;;AAMpE,SAAS,YAAY,aAA8B;CACjD,IAAI;EACF,eAAe,QAAQ,WAAW;EAClC,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;AASA,MAAa,qBAAkC;CAC7C,MAAM;CACN,WAAW;EACT,MAAM,UAAU,eAAe,QAAQ,SAAS,CAAC,YAAY,KAAK,OAAO,CAAC;EAE1E,IAAI,QAAQ,WAAW,GACrB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,OAAO,eAAe,OAAO;EACvC;EAOF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,gCAPU,QACjB,KAAK,SAAS,GAAG,KAAK,QAAQ,IAAI,KAAK,QAAQ,EAAE,CAAC,CAClD,KAAK,IAK4C;EACpD;CACF;AACF"}
1
+ {"version":3,"file":"optional-peers.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/optional-peers.check.ts"],"sourcesContent":["import { createRequire } from \"node:module\";\nimport { config } from \"../../../../config/config-getter\";\nimport type { CheckStatus, DoctorCheck } from \"../check.types\";\n\n/**\n * A package this project's own configuration requires, and what asked for it.\n */\ntype RequiredPeer = {\n /** The npm package name to probe via `require.resolve`. */\n package: string;\n\n /** The config that asked for it, quoted back to the user verbatim. */\n because: string;\n};\n\n/**\n * Resolve from the project's working directory, not from core's own\n * `node_modules`, so the probe reflects what the *consuming app* has installed.\n */\nconst projectRequire = createRequire(`${process.cwd()}/package.json`);\n\n/**\n * Whether a package can be resolved from the project. A failed resolve (the\n * normal \"not installed\" case) is swallowed and reported as missing.\n */\nfunction isInstalled(packageName: string): boolean {\n try {\n projectRequire.resolve(packageName);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * A non-empty string, or `undefined`. Config values arrive from `env(...)` and\n * an unset variable yields `undefined` — but a `.env` line with nothing after\n * the `=` yields `\"\"`, which is \"not configured\" just as much.\n */\nfunction text(value: unknown): string | undefined {\n return typeof value === \"string\" && value.trim().length > 0 ? value.trim() : undefined;\n}\n\n/**\n * The database driver's package. `driver` is a closed union in cascade\n * (`mongodb | postgres | mysql`), so an unrecognised value means the config is\n * wrong in a way this check is not the right one to report.\n */\nfunction databasePeer(): RequiredPeer | undefined {\n const driver = text(config.get(\"database.driver\"));\n\n const packageName =\n driver === \"mongodb\"\n ? \"mongodb\"\n : driver === \"postgres\"\n ? \"pg\"\n : driver === \"mysql\"\n ? \"mysql2\"\n : undefined;\n\n return packageName ? { package: packageName, because: `database.driver = \"${driver}\"` } : undefined;\n}\n\n/**\n * The selected cache driver. Only the DEFAULT matters: `cache.drivers` is a\n * menu the scaffold fills in for every driver the framework ships, and warning\n * about a menu entry nobody selected is the false positive this whole check was\n * rewritten to remove.\n */\nfunction cachePeer(): RequiredPeer | undefined {\n const selected = text(config.get(\"cache.default\"));\n\n if (!selected || !/redis/i.test(selected)) return undefined;\n\n return { package: \"redis\", because: `cache.default = \"${selected}\"` };\n}\n\n/**\n * The selected storage driver, read through its canonical `driver` field\n * (`local` / `s3` / `r2` / `spaces`) rather than the key the app happened to\n * name it, so a driver called \"cdn\" is still recognised as S3.\n */\nfunction storagePeer(): RequiredPeer | undefined {\n const selected = text(config.get(\"storage.default\"));\n\n if (!selected) return undefined;\n\n const driver = text(config.get(`storage.drivers.${selected}.driver`));\n\n if (!driver || driver === \"local\") return undefined;\n\n return {\n package: \"@aws-sdk/client-s3\",\n because: `storage.default = \"${selected}\" (driver \"${driver}\")`,\n };\n}\n\n/**\n * The mail transport. A `mail` config with no host and no driver is the\n * scaffold's placeholder — every field is an unset env var — so it asks for\n * nothing and this stays quiet.\n */\nfunction mailPeer(): RequiredPeer | undefined {\n const driver = text(config.get(\"mail.driver\"));\n\n if (driver === \"ses\") {\n return { package: \"@aws-sdk/client-sesv2\", because: `mail.driver = \"ses\"` };\n }\n\n const host = text(config.get(\"mail.host\"));\n\n if (!host) return undefined;\n\n return { package: \"nodemailer\", because: `mail.host = \"${host}\"` };\n}\n\n/**\n * Realtime sockets. The connector no-ops without a `socket` config, so the\n * presence of that config is exactly the request for the peer.\n */\nfunction socketPeer(): RequiredPeer | undefined {\n if (!config.get(\"socket\")) return undefined;\n\n return { package: \"socket.io\", because: \"a socket config is present\" };\n}\n\n/**\n * Every peer THIS project's configuration asks for.\n */\nfunction requiredPeers(): RequiredPeer[] {\n return [databasePeer(), cachePeer(), storagePeer(), mailPeer(), socketPeer()].filter(\n (peer): peer is RequiredPeer => Boolean(peer),\n );\n}\n\n/**\n * Checks that every optional peer the project's own configuration selects is\n * actually installed.\n *\n * NEEDS NO BOOTED APP — it reads config and resolves module paths. It runs after\n * the boot pass only because config is loaded by then.\n *\n * WHAT THIS REPLACED, AND WHY: the previous version probed nine packages and\n * warned about every one that was absent. Every one of them is an OPTIONAL\n * peer — a fresh app installs none of them by design — so the check warned, at\n * length, about the framework's own default state. \"No MySQL driver installed\"\n * is not a diagnosis for someone who never wanted MySQL; it is noise that\n * teaches the reader to skim past the line where a real finding will one day\n * appear.\n *\n * Inverted, it becomes a genuine one: a project whose config selects a driver\n * whose package is missing is broken — it will throw the first time that\n * feature is used — so a missing peer here is a `fail`, not a warning.\n */\nexport const optionalPeersCheck: DoctorCheck = {\n name: \"optional-peers\",\n run: () => {\n const required = requiredPeers();\n\n if (required.length === 0) {\n return undefined;\n }\n\n const missing = required.filter((peer) => !isInstalled(peer.package));\n\n const status: CheckStatus = missing.length > 0 ? \"fail\" : \"ok\";\n\n if (missing.length > 0) {\n return {\n name: \"optional-peers\",\n status,\n detail:\n `${missing.length} configured feature(s) have no driver installed:\\n` +\n missing.map((peer) => ` - ${peer.package} — required by ${peer.because}`).join(\"\\n\"),\n };\n }\n\n return {\n name: \"optional-peers\",\n status,\n detail: `every configured driver is installed (${required\n .map((peer) => peer.package)\n .join(\", \")})`,\n };\n },\n};\n"],"mappings":";;;;;;;;AAmBA,MAAM,iBAAiB,cAAc,GAAG,QAAQ,IAAI,EAAE,cAAc;;;;;AAMpE,SAAS,YAAY,aAA8B;CACjD,IAAI;EACF,eAAe,QAAQ,WAAW;EAClC,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;AAOA,SAAS,KAAK,OAAoC;CAChD,OAAO,OAAO,UAAU,YAAY,MAAM,KAAK,CAAC,CAAC,SAAS,IAAI,MAAM,KAAK,IAAI;AAC/E;;;;;;AAOA,SAAS,eAAyC;CAChD,MAAM,SAAS,KAAK,OAAO,IAAI,iBAAiB,CAAC;CAEjD,MAAM,cACJ,WAAW,YACP,YACA,WAAW,aACT,OACA,WAAW,UACT,WACA;CAEV,OAAO,cAAc;EAAE,SAAS;EAAa,SAAS,sBAAsB,OAAO;CAAG,IAAI;AAC5F;;;;;;;AAQA,SAAS,YAAsC;CAC7C,MAAM,WAAW,KAAK,OAAO,IAAI,eAAe,CAAC;CAEjD,IAAI,CAAC,YAAY,CAAC,SAAS,KAAK,QAAQ,GAAG,OAAO;CAElD,OAAO;EAAE,SAAS;EAAS,SAAS,oBAAoB,SAAS;CAAG;AACtE;;;;;;AAOA,SAAS,cAAwC;CAC/C,MAAM,WAAW,KAAK,OAAO,IAAI,iBAAiB,CAAC;CAEnD,IAAI,CAAC,UAAU,OAAO;CAEtB,MAAM,SAAS,KAAK,OAAO,IAAI,mBAAmB,SAAS,QAAQ,CAAC;CAEpE,IAAI,CAAC,UAAU,WAAW,SAAS,OAAO;CAE1C,OAAO;EACL,SAAS;EACT,SAAS,sBAAsB,SAAS,aAAa,OAAO;CAC9D;AACF;;;;;;AAOA,SAAS,WAAqC;CAG5C,IAFe,KAAK,OAAO,IAAI,aAAa,CAEnC,MAAM,OACb,OAAO;EAAE,SAAS;EAAyB,SAAS;CAAsB;CAG5E,MAAM,OAAO,KAAK,OAAO,IAAI,WAAW,CAAC;CAEzC,IAAI,CAAC,MAAM,OAAO;CAElB,OAAO;EAAE,SAAS;EAAc,SAAS,gBAAgB,KAAK;CAAG;AACnE;;;;;AAMA,SAAS,aAAuC;CAC9C,IAAI,CAAC,OAAO,IAAI,QAAQ,GAAG,OAAO;CAElC,OAAO;EAAE,SAAS;EAAa,SAAS;CAA6B;AACvE;;;;AAKA,SAAS,gBAAgC;CACvC,OAAO;EAAC,aAAa;EAAG,UAAU;EAAG,YAAY;EAAG,SAAS;EAAG,WAAW;CAAC,CAAC,CAAC,QAC3E,SAA+B,QAAQ,IAAI,CAC9C;AACF;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,qBAAkC;CAC7C,MAAM;CACN,WAAW;EACT,MAAM,WAAW,cAAc;EAE/B,IAAI,SAAS,WAAW,GACtB;EAGF,MAAM,UAAU,SAAS,QAAQ,SAAS,CAAC,YAAY,KAAK,OAAO,CAAC;EAEpE,MAAM,SAAsB,QAAQ,SAAS,IAAI,SAAS;EAE1D,IAAI,QAAQ,SAAS,GACnB,OAAO;GACL,MAAM;GACN;GACA,QACE,GAAG,QAAQ,OAAO,sDAClB,QAAQ,KAAK,SAAS,OAAO,KAAK,QAAQ,iBAAiB,KAAK,SAAS,CAAC,CAAC,KAAK,IAAI;EACxF;EAGF,OAAO;GACL,MAAM;GACN;GACA,QAAQ,yCAAyC,SAC9C,KAAK,SAAS,KAAK,OAAO,CAAC,CAC3B,KAAK,IAAI,EAAE;EAChB;CACF;AACF"}
@@ -16,29 +16,47 @@ function parseTopHeading(changelog) {
16
16
  };
17
17
  }
18
18
  /**
19
- * Checks that the project's `package.json` version matches the top-most
20
- * `## x.y.z` heading in `CHANGELOG.md` — the same version↔changelog invariant
21
- * the release-hygiene unit guard enforces, surfaced as a pre-release doctor
22
- * check.
19
+ * Whether this project is something anyone could `npm publish`.
20
+ *
21
+ * THE GATE, and the reason it exists: a changelog is a promise to the people
22
+ * who install your package. An application has no such people. Running this
23
+ * check on every project projected the framework monorepo's own release
24
+ * practice onto users building an app, who were told — on every single run,
25
+ * forever — that their private application was missing a CHANGELOG.md it had no
26
+ * reason to have. `"private": true` is exactly the field npm itself reads to
27
+ * refuse a publish, so it is the honest test of whether the concern applies.
28
+ */
29
+ function isPublishable(manifest) {
30
+ return manifest.private !== true && typeof manifest.name === "string";
31
+ }
32
+ /**
33
+ * Checks that a PUBLISHABLE package's `package.json` version matches the
34
+ * top-most `## x.y.z` heading in `CHANGELOG.md` — the same version↔changelog
35
+ * invariant the release-hygiene unit guard enforces, surfaced as a pre-release
36
+ * doctor check.
37
+ *
38
+ * NEEDS NO BOOTED APP — it reads two files from the project root.
23
39
  *
24
40
  * Verdicts:
25
- * - no `CHANGELOG.md``warn` (a changelog is recommended, not mandatory);
41
+ * - `"private": true`, or no package name not applicable, no line at all;
42
+ * - no `CHANGELOG.md` → `warn` (recommended for a published package, not
43
+ * mandatory);
26
44
  * - no parseable heading → `warn`;
27
45
  * - heading version ≠ package.json version → `fail`;
28
46
  * - otherwise → `ok`.
29
- *
30
- * Read-only: reads `package.json` and `CHANGELOG.md` from the project root.
31
47
  */
32
48
  const releaseHygieneCheck = {
33
49
  name: "release-hygiene",
34
50
  run: () => {
35
51
  const packageJsonPath = rootPath("package.json");
36
52
  const changelogPath = rootPath("CHANGELOG.md");
37
- const pkgVersion = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
53
+ const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
54
+ if (!isPublishable(manifest)) return;
55
+ const pkgVersion = manifest.version;
38
56
  if (typeof pkgVersion !== "string") return {
39
57
  name: "release-hygiene",
40
58
  status: "fail",
41
- detail: "package.json has no string version field"
59
+ detail: "package.json is publishable but has no string version field"
42
60
  };
43
61
  if (!existsSync(changelogPath)) return {
44
62
  name: "release-hygiene",
@@ -1 +1 @@
1
- {"version":3,"file":"release-hygiene.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/release-hygiene.check.ts"],"sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { rootPath } from \"../../../../utils/paths\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * A `## x.y.z` changelog heading with an optional ` - YYYY-MM-DD` suffix.\n * Mirrors the shape parsed by the tier-0 release-hygiene unit guard; inlined\n * here because that guard lives in the test tree and is not importable from\n * `src`.\n */\ntype ChangelogHeading = {\n version: string;\n date?: string;\n};\n\n/**\n * Parse the top-most `## x.y.z[ - YYYY-MM-DD]` heading out of a CHANGELOG body.\n * Returns `undefined` when no version heading is present.\n */\nfunction parseTopHeading(changelog: string): ChangelogHeading | undefined {\n const match = changelog.match(\n /^##\\s+(\\d+)\\.(\\d+)\\.(\\d+)(?:\\s*[-–]\\s*(\\d{4}-\\d{2}-\\d{2}))?\\s*$/m,\n );\n\n if (!match) {\n return undefined;\n }\n\n const [, major, minor, patch, date] = match;\n\n return {\n version: `${major}.${minor}.${patch}`,\n date,\n };\n}\n\n/**\n * Checks that the project's `package.json` version matches the top-most\n * `## x.y.z` heading in `CHANGELOG.md` — the same version↔changelog invariant\n * the release-hygiene unit guard enforces, surfaced as a pre-release doctor\n * check.\n *\n * Verdicts:\n * - no `CHANGELOG.md` → `warn` (a changelog is recommended, not mandatory);\n * - no parseable heading → `warn`;\n * - heading version ≠ package.json version → `fail`;\n * - otherwise → `ok`.\n *\n * Read-only: reads `package.json` and `CHANGELOG.md` from the project root.\n */\nexport const releaseHygieneCheck: DoctorCheck = {\n name: \"release-hygiene\",\n run: () => {\n const packageJsonPath = rootPath(\"package.json\");\n const changelogPath = rootPath(\"CHANGELOG.md\");\n\n const pkgVersion: unknown = JSON.parse(readFileSync(packageJsonPath, \"utf8\")).version;\n\n if (typeof pkgVersion !== \"string\") {\n return {\n name: \"release-hygiene\",\n status: \"fail\",\n detail: \"package.json has no string version field\",\n };\n }\n\n if (!existsSync(changelogPath)) {\n return {\n name: \"release-hygiene\",\n status: \"warn\",\n detail: `no CHANGELOG.md at project root (package.json is ${pkgVersion})`,\n };\n }\n\n const changelog = readFileSync(changelogPath, \"utf8\");\n const heading = parseTopHeading(changelog);\n\n if (!heading) {\n return {\n name: \"release-hygiene\",\n status: \"warn\",\n detail: \"CHANGELOG.md has no parseable `## x.y.z` heading\",\n };\n }\n\n if (heading.version !== pkgVersion) {\n return {\n name: \"release-hygiene\",\n status: \"fail\",\n detail: `package.json ${pkgVersion} ≠ top CHANGELOG heading ${heading.version}`,\n };\n }\n\n return {\n name: \"release-hygiene\",\n status: \"ok\",\n detail: `package.json and CHANGELOG agree on ${pkgVersion}`,\n };\n },\n};\n"],"mappings":";;;;;;;;AAmBA,SAAS,gBAAgB,WAAiD;CACxE,MAAM,QAAQ,UAAU,MACtB,kEACF;CAEA,IAAI,CAAC,OACH;CAGF,MAAM,GAAG,OAAO,OAAO,OAAO,QAAQ;CAEtC,OAAO;EACL,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG;EAC9B;CACF;AACF;;;;;;;;;;;;;;;AAgBA,MAAa,sBAAmC;CAC9C,MAAM;CACN,WAAW;EACT,MAAM,kBAAkB,SAAS,cAAc;EAC/C,MAAM,gBAAgB,SAAS,cAAc;EAE7C,MAAM,aAAsB,KAAK,MAAM,aAAa,iBAAiB,MAAM,CAAC,CAAC,CAAC;EAE9E,IAAI,OAAO,eAAe,UACxB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAGF,IAAI,CAAC,WAAW,aAAa,GAC3B,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,oDAAoD,WAAW;EACzE;EAIF,MAAM,UAAU,gBADE,aAAa,eAAe,MACN,CAAC;EAEzC,IAAI,CAAC,SACH,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAGF,IAAI,QAAQ,YAAY,YACtB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,gBAAgB,WAAW,2BAA2B,QAAQ;EACxE;EAGF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,uCAAuC;EACjD;CACF;AACF"}
1
+ {"version":3,"file":"release-hygiene.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/release-hygiene.check.ts"],"sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { rootPath } from \"../../../../utils/paths\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * A `## x.y.z` changelog heading with an optional ` - YYYY-MM-DD` suffix.\n * Mirrors the shape parsed by the tier-0 release-hygiene unit guard; inlined\n * here because that guard lives in the test tree and is not importable from\n * `src`.\n */\ntype ChangelogHeading = {\n version: string;\n date?: string;\n};\n\n/**\n * The parts of `package.json` this check reads.\n */\ntype ProjectManifest = {\n version?: unknown;\n private?: unknown;\n name?: unknown;\n};\n\n/**\n * Parse the top-most `## x.y.z[ - YYYY-MM-DD]` heading out of a CHANGELOG body.\n * Returns `undefined` when no version heading is present.\n */\nfunction parseTopHeading(changelog: string): ChangelogHeading | undefined {\n const match = changelog.match(\n /^##\\s+(\\d+)\\.(\\d+)\\.(\\d+)(?:\\s*[-–]\\s*(\\d{4}-\\d{2}-\\d{2}))?\\s*$/m,\n );\n\n if (!match) {\n return undefined;\n }\n\n const [, major, minor, patch, date] = match;\n\n return {\n version: `${major}.${minor}.${patch}`,\n date,\n };\n}\n\n/**\n * Whether this project is something anyone could `npm publish`.\n *\n * THE GATE, and the reason it exists: a changelog is a promise to the people\n * who install your package. An application has no such people. Running this\n * check on every project projected the framework monorepo's own release\n * practice onto users building an app, who were told — on every single run,\n * forever — that their private application was missing a CHANGELOG.md it had no\n * reason to have. `\"private\": true` is exactly the field npm itself reads to\n * refuse a publish, so it is the honest test of whether the concern applies.\n */\nfunction isPublishable(manifest: ProjectManifest): boolean {\n return manifest.private !== true && typeof manifest.name === \"string\";\n}\n\n/**\n * Checks that a PUBLISHABLE package's `package.json` version matches the\n * top-most `## x.y.z` heading in `CHANGELOG.md` — the same version↔changelog\n * invariant the release-hygiene unit guard enforces, surfaced as a pre-release\n * doctor check.\n *\n * NEEDS NO BOOTED APP — it reads two files from the project root.\n *\n * Verdicts:\n * - `\"private\": true`, or no package name → not applicable, no line at all;\n * - no `CHANGELOG.md` → `warn` (recommended for a published package, not\n * mandatory);\n * - no parseable heading → `warn`;\n * - heading version ≠ package.json version → `fail`;\n * - otherwise → `ok`.\n */\nexport const releaseHygieneCheck: DoctorCheck = {\n name: \"release-hygiene\",\n run: () => {\n const packageJsonPath = rootPath(\"package.json\");\n const changelogPath = rootPath(\"CHANGELOG.md\");\n\n const manifest: ProjectManifest = JSON.parse(readFileSync(packageJsonPath, \"utf8\"));\n\n if (!isPublishable(manifest)) {\n return undefined;\n }\n\n const pkgVersion = manifest.version;\n\n if (typeof pkgVersion !== \"string\") {\n return {\n name: \"release-hygiene\",\n status: \"fail\",\n detail: \"package.json is publishable but has no string version field\",\n };\n }\n\n if (!existsSync(changelogPath)) {\n return {\n name: \"release-hygiene\",\n status: \"warn\",\n detail: `no CHANGELOG.md at project root (package.json is ${pkgVersion})`,\n };\n }\n\n const changelog = readFileSync(changelogPath, \"utf8\");\n const heading = parseTopHeading(changelog);\n\n if (!heading) {\n return {\n name: \"release-hygiene\",\n status: \"warn\",\n detail: \"CHANGELOG.md has no parseable `## x.y.z` heading\",\n };\n }\n\n if (heading.version !== pkgVersion) {\n return {\n name: \"release-hygiene\",\n status: \"fail\",\n detail: `package.json ${pkgVersion} ≠ top CHANGELOG heading ${heading.version}`,\n };\n }\n\n return {\n name: \"release-hygiene\",\n status: \"ok\",\n detail: `package.json and CHANGELOG agree on ${pkgVersion}`,\n };\n },\n};\n"],"mappings":";;;;;;;;AA4BA,SAAS,gBAAgB,WAAiD;CACxE,MAAM,QAAQ,UAAU,MACtB,kEACF;CAEA,IAAI,CAAC,OACH;CAGF,MAAM,GAAG,OAAO,OAAO,OAAO,QAAQ;CAEtC,OAAO;EACL,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG;EAC9B;CACF;AACF;;;;;;;;;;;;AAaA,SAAS,cAAc,UAAoC;CACzD,OAAO,SAAS,YAAY,QAAQ,OAAO,SAAS,SAAS;AAC/D;;;;;;;;;;;;;;;;;AAkBA,MAAa,sBAAmC;CAC9C,MAAM;CACN,WAAW;EACT,MAAM,kBAAkB,SAAS,cAAc;EAC/C,MAAM,gBAAgB,SAAS,cAAc;EAE7C,MAAM,WAA4B,KAAK,MAAM,aAAa,iBAAiB,MAAM,CAAC;EAElF,IAAI,CAAC,cAAc,QAAQ,GACzB;EAGF,MAAM,aAAa,SAAS;EAE5B,IAAI,OAAO,eAAe,UACxB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAGF,IAAI,CAAC,WAAW,aAAa,GAC3B,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,oDAAoD,WAAW;EACzE;EAIF,MAAM,UAAU,gBADE,aAAa,eAAe,MACN,CAAC;EAEzC,IAAI,CAAC,SACH,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAGF,IAAI,QAAQ,YAAY,YACtB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,gBAAgB,WAAW,2BAA2B,QAAQ;EACxE;EAGF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,uCAAuC;EACjD;CACF;AACF"}
@@ -1,26 +1,65 @@
1
- import { router } from "../../../../router/router.mjs";
2
-
3
1
  //#region ../core/src/cli/commands/doctor/checks/routes.check.ts
4
2
  /**
5
- * Reports the number of registered routes. A booted app with zero routes
6
- * almost always means a route module failed to register silently, so this
7
- * warns (rather than passing) when the route table is empty.
3
+ * Describe the failed modules as an indented block, one per line, cause first.
4
+ * The file that failed is the only thing that makes this actionable, so it is
5
+ * never summarised away.
6
+ */
7
+ function describeFailures(context) {
8
+ return context.moduleFailures.map((failure) => ` - ${failure.file}: ${failure.message}`).join("\n");
9
+ }
10
+ /**
11
+ * Reports the route surface the app will actually serve.
8
12
  *
9
- * Read-only: introspects the router via `routeCount()` only.
13
+ * NEEDS A BOOTED APP — and now gets one. Routes exist only as the side effect of
14
+ * importing the module that declares them, so a doctor that did not import app
15
+ * code reported `0 routes` for an app the dev server served fourteen routes
16
+ * for. The boot pass imports the same modules, in the same order, and boots
17
+ * (never starts) the same connectors, so the number printed here is the number
18
+ * `warlock dev` prints for the same project — the two are read off the same
19
+ * router after the same registrations.
20
+ *
21
+ * Verdicts:
22
+ * - a module failed to import → `fail`, naming every file and its cause. This
23
+ * is the finding doctor exists for: a route file that throws on import is
24
+ * invisible at runtime, it just 404s;
25
+ * - route modules on disk but nothing registered → `fail` (they loaded and
26
+ * registered nothing, which is not a state a working app is in);
27
+ * - no route modules at all → `ok`, stated plainly. A worker or CLI-only app is
28
+ * a legitimate shape and must not be nagged;
29
+ * - otherwise → `ok` with the count, split into what app code registered and
30
+ * what connectors added.
10
31
  */
11
32
  const routesCheck = {
12
33
  name: "routes",
13
- run: () => {
14
- const count = router.routeCount();
15
- if (count === 0) return {
34
+ run: (context) => {
35
+ if (context.moduleFailures.length > 0) return {
36
+ name: "routes",
37
+ status: "fail",
38
+ detail: `${context.moduleFailures.length} app module(s) failed to load — the routes they declare will 404:\n` + describeFailures(context)
39
+ };
40
+ if (!context.booted) return {
16
41
  name: "routes",
17
- status: "warn",
18
- detail: "0 routes registered did a route module fail to load?"
42
+ status: "fail",
43
+ detail: "app modules could not be discovered nothing to report on"
19
44
  };
45
+ if (context.totalRoutes === 0) {
46
+ if (context.routeModules === 0) return {
47
+ name: "routes",
48
+ status: "ok",
49
+ detail: "no route modules found (src/app/*/routes.ts) — this app serves no HTTP routes"
50
+ };
51
+ return {
52
+ name: "routes",
53
+ status: "fail",
54
+ detail: `${context.routeModules} route module(s) loaded without error but registered 0 routes — every request will 404`
55
+ };
56
+ }
57
+ const fromConnectors = context.totalRoutes - context.appRoutes;
58
+ const breakdown = fromConnectors > 0 ? `${context.appRoutes} from ${context.routeModules} route module(s), ${fromConnectors} from connectors` : `from ${context.routeModules} route module(s)`;
20
59
  return {
21
60
  name: "routes",
22
61
  status: "ok",
23
- detail: `${count} registered`
62
+ detail: `${context.totalRoutes} registered (${breakdown})`
24
63
  };
25
64
  }
26
65
  };