@warlock.js/core 5.35.0 → 5.37.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 (133) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/esm/cli/commands/migrate.command.mjs +10 -0
  3. package/esm/cli/commands/migrate.command.mjs.map +1 -1
  4. package/esm/cli/commands/release/release-command.action.mjs +245 -0
  5. package/esm/cli/commands/release/release-command.action.mjs.map +1 -0
  6. package/esm/cli/commands/release.command.mjs +66 -0
  7. package/esm/cli/commands/release.command.mjs.map +1 -0
  8. package/esm/cli/framework-cli-commands.mjs +2 -0
  9. package/esm/cli/framework-cli-commands.mjs.map +1 -1
  10. package/esm/database/migrate-action.mjs +24 -2
  11. package/esm/database/migrate-action.mjs.map +1 -1
  12. package/esm/dev-server/loader/load-hook.d.mts.map +1 -1
  13. package/esm/dev-server/loader/load-hook.mjs +6 -0
  14. package/esm/dev-server/loader/load-hook.mjs.map +1 -1
  15. package/esm/dev-server/module-loader.mjs +1 -1
  16. package/esm/dev-server/parse-imports.mjs +1 -1
  17. package/esm/dev-server/type-generator.mjs +1 -1
  18. package/esm/e2e/cdp.d.mts.map +1 -1
  19. package/esm/e2e/cdp.mjs +9 -2
  20. package/esm/e2e/cdp.mjs.map +1 -1
  21. package/esm/e2e/page.d.mts +9 -1
  22. package/esm/e2e/page.d.mts.map +1 -1
  23. package/esm/e2e/page.mjs +45 -7
  24. package/esm/e2e/page.mjs.map +1 -1
  25. package/esm/e2e/types.d.mts +18 -0
  26. package/esm/e2e/types.d.mts.map +1 -1
  27. package/esm/e2e/types.mjs.map +1 -1
  28. package/esm/errors/error-codes.d.mts +24 -0
  29. package/esm/errors/error-codes.d.mts.map +1 -1
  30. package/esm/errors/error-codes.mjs +24 -0
  31. package/esm/errors/error-codes.mjs.map +1 -1
  32. package/esm/generations/features/test.feature.mjs +7 -7
  33. package/esm/generations/features/test.feature.mjs.map +1 -1
  34. package/esm/generations/features/web.feature.mjs +3 -3
  35. package/esm/generations/features/web.feature.mjs.map +1 -1
  36. package/esm/index.d.mts +6 -3
  37. package/esm/index.mjs +4 -2
  38. package/esm/localization/index.mjs +3 -1
  39. package/esm/localization/translation-usage-graph.d.mts +49 -0
  40. package/esm/localization/translation-usage-graph.d.mts.map +1 -0
  41. package/esm/localization/translation-usage-graph.mjs +128 -0
  42. package/esm/localization/translation-usage-graph.mjs.map +1 -0
  43. package/esm/localization/translation-usage.d.mts +85 -0
  44. package/esm/localization/translation-usage.d.mts.map +1 -0
  45. package/esm/localization/translation-usage.mjs +307 -0
  46. package/esm/localization/translation-usage.mjs.map +1 -0
  47. package/esm/production/build-budgets.d.mts +18 -0
  48. package/esm/production/build-budgets.d.mts.map +1 -1
  49. package/esm/production/build-budgets.mjs.map +1 -1
  50. package/esm/production/route-registration-snapshot.mjs +12 -3
  51. package/esm/production/route-registration-snapshot.mjs.map +1 -1
  52. package/esm/release/activate.mjs +261 -0
  53. package/esm/release/activate.mjs.map +1 -0
  54. package/esm/release/candidate.mjs +107 -0
  55. package/esm/release/candidate.mjs.map +1 -0
  56. package/esm/release/cascade-migration-provider.mjs +32 -0
  57. package/esm/release/cascade-migration-provider.mjs.map +1 -0
  58. package/esm/release/config.mjs +60 -0
  59. package/esm/release/config.mjs.map +1 -0
  60. package/esm/release/gate.mjs +67 -0
  61. package/esm/release/gate.mjs.map +1 -0
  62. package/esm/release/migrations-proof.mjs +100 -0
  63. package/esm/release/migrations-proof.mjs.map +1 -0
  64. package/esm/release/prune.mjs +42 -0
  65. package/esm/release/prune.mjs.map +1 -0
  66. package/esm/release/report.mjs +25 -0
  67. package/esm/release/report.mjs.map +1 -0
  68. package/esm/release/runner.mjs +55 -0
  69. package/esm/release/runner.mjs.map +1 -0
  70. package/esm/release/semver.mjs +53 -0
  71. package/esm/release/semver.mjs.map +1 -0
  72. package/esm/release/stage.mjs +122 -0
  73. package/esm/release/stage.mjs.map +1 -0
  74. package/esm/release/store.mjs +131 -0
  75. package/esm/release/store.mjs.map +1 -0
  76. package/esm/release/types.d.mts +102 -0
  77. package/esm/release/types.d.mts.map +1 -0
  78. package/esm/release/verify.mjs +94 -0
  79. package/esm/release/verify.mjs.map +1 -0
  80. package/esm/router/index.d.mts +2 -2
  81. package/esm/router/index.mjs +2 -2
  82. package/esm/router/router.d.mts +4 -0
  83. package/esm/router/router.d.mts.map +1 -1
  84. package/esm/router/router.mjs +14 -3
  85. package/esm/router/router.mjs.map +1 -1
  86. package/esm/router/types.d.mts +45 -0
  87. package/esm/router/types.d.mts.map +1 -1
  88. package/esm/router/unguarded-route-diagnostics.d.mts +6 -0
  89. package/esm/router/unguarded-route-diagnostics.d.mts.map +1 -1
  90. package/esm/router/unguarded-route-diagnostics.mjs +10 -2
  91. package/esm/router/unguarded-route-diagnostics.mjs.map +1 -1
  92. package/esm/tooling/check/api-route-references.d.mts +16 -0
  93. package/esm/tooling/check/api-route-references.d.mts.map +1 -0
  94. package/esm/tooling/check/api-route-references.mjs +146 -0
  95. package/esm/tooling/check/api-route-references.mjs.map +1 -0
  96. package/esm/tooling/check/conventions-step.d.mts +6 -3
  97. package/esm/tooling/check/conventions-step.d.mts.map +1 -1
  98. package/esm/tooling/check/conventions-step.mjs +19 -10
  99. package/esm/tooling/check/conventions-step.mjs.map +1 -1
  100. package/esm/tooling/check/run-check.d.mts.map +1 -1
  101. package/esm/tooling/check/run-check.mjs +4 -2
  102. package/esm/tooling/check/run-check.mjs.map +1 -1
  103. package/esm/tooling/check/security-conventions.mjs +19 -1
  104. package/esm/tooling/check/security-conventions.mjs.map +1 -1
  105. package/esm/tooling/check/translation-conventions.mjs +32 -0
  106. package/esm/tooling/check/translation-conventions.mjs.map +1 -0
  107. package/esm/tooling/inspect/collect-files.mjs +1 -1
  108. package/esm/tooling/inspect/collect-routes.mjs +3 -2
  109. package/esm/tooling/inspect/collect-routes.mjs.map +1 -1
  110. package/esm/upgrade/codemods/index.mjs +2 -0
  111. package/esm/upgrade/codemods/index.mjs.map +1 -1
  112. package/esm/upgrade/codemods/vite-vitest.mjs +170 -0
  113. package/esm/upgrade/codemods/vite-vitest.mjs.map +1 -0
  114. package/esm/upgrade/notes.mjs +4 -0
  115. package/esm/upgrade/notes.mjs.map +1 -1
  116. package/esm/validation/init.mjs +11 -4
  117. package/esm/validation/init.mjs.map +1 -1
  118. package/esm/validation/translate-rule.mjs +63 -0
  119. package/esm/validation/translate-rule.mjs.map +1 -0
  120. package/esm/warlock-config/index.d.mts +3 -2
  121. package/esm/warlock-config/types.d.mts +8 -0
  122. package/esm/warlock-config/types.d.mts.map +1 -1
  123. package/llms-full.txt +221 -18
  124. package/llms.txt +1 -0
  125. package/package.json +13 -13
  126. package/skills/deploy-release/SKILL.md +76 -0
  127. package/skills/index.md +1 -1
  128. package/skills/register-route/SKILL.md +25 -0
  129. package/skills/test-service/SKILL.md +8 -8
  130. package/skills/upgrade-from-5-29/SKILL.md +91 -0
  131. package/skills/use-localization/SKILL.md +4 -0
  132. package/skills/warlock-check/SKILL.md +11 -8
  133. package/skills/warlock-conventions/SKILL.md +1 -1
@@ -323,6 +323,31 @@ export interface RouteOptions {
323
323
  * router.post("/auth/login", loginController, { public: true });
324
324
  */
325
325
  public?: boolean;
326
+ /**
327
+ * Whether this named API route may be embedded in a page for browser use.
328
+ *
329
+ * Defaults to `true`. Set `false` for an endpoint that must never be
330
+ * reachable through `apiHref()` or `useSubmitForm({ route })` from client
331
+ * code (an internal webhook, an admin-only job trigger). Such a route is never
332
+ * written into any page's browser route table, and a client reference to its
333
+ * name is a `warlock check` error (`WL9020`). Server-side authorization still
334
+ * decides who may call the endpoint.
335
+ *
336
+ * @example
337
+ * router.post("/internal/reindex", reindexController, { name: "internal.reindex", clientVisible: false });
338
+ */
339
+ clientVisible?: boolean;
340
+ /**
341
+ * The sites (`web.sites` keys) a multi-site app offers this named API route
342
+ * to in the browser. Absent means the route is shared: every site may embed
343
+ * it. A route registered inside a `router.group({ sites })` inherits the
344
+ * group's list unless it declares its own. This scopes browser route tables
345
+ * only; the endpoint itself still answers on every host.
346
+ *
347
+ * @example
348
+ * router.post("/api/admin/users", createUser, { name: "admin.users.create", sites: ["admin"] });
349
+ */
350
+ sites?: readonly string[];
326
351
  }
327
352
  export type RequestMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD" | "all";
328
353
  /** Browser-safe snapshot metadata for a named application API route. */
@@ -337,6 +362,19 @@ export type NamedApiRoute = Readonly<{
337
362
  * (paths relative to `.warlock/typings/`), object literal types and arrays.
338
363
  */
339
364
  response?: Readonly<Record<string, string>>;
365
+ /**
366
+ * Present only as `false`, and only on a route that opted out with
367
+ * `clientVisible: false`. Absent means the route may be embedded in pages.
368
+ */
369
+ clientVisible?: false;
370
+ /** The route's declared `sites`, present only when it declared some. */
371
+ sites?: readonly string[];
372
+ /**
373
+ * Present only as `true`: a required auth guard (see `markAuthGuard`) is in
374
+ * the route's middleware chain, group middleware included. Browser tables
375
+ * leave such a route out for guest viewers.
376
+ */
377
+ guarded?: true;
340
378
  }>;
341
379
  /** Options for `router.getNamedApiRoutes()`. */
342
380
  export type NamedApiRoutesOptions = Readonly<{
@@ -414,6 +452,11 @@ export type GroupedRoutesOptions = {
414
452
  * opts it back out.
415
453
  */
416
454
  public?: boolean;
455
+ /**
456
+ * Offer every named API route in the group only to these sites (see
457
+ * `RouteOptions.sites`). A route's own `sites` replaces it.
458
+ */
459
+ sites?: readonly string[];
417
460
  };
418
461
  /** Route resource */
419
462
  export type RouteResource = {
@@ -476,6 +519,8 @@ export type RouterStacks = {
476
519
  * is open default to `public: true`.
477
520
  */
478
521
  public?: number;
522
+ /** Open `router.group({ sites })` scopes, innermost last. */
523
+ sites?: (readonly string[])[];
479
524
  /**
480
525
  * Source file path for routes being added (for HMR route tracking)
481
526
  */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../../packages/core/src/router/types.ts"],"mappings":";;;;;;;;;;;;;YAUY,qBAAqB;;;;;;;YAQrB,WAAW,0BAA0B,UAAU;GACxD,SAAS,YAAY,qBAAqB;;YAGjC;YAEA,qBAAqB,uBAAuB;;;;YAK5C;YAEA,oBAAoB,gBAAgB;YAEpC,yBAAyB,iBAAiB,UAAU;;;;EAI9D,WAAW,WAAW;;;;;;EAMtB;;;;EAIA,SAAS;;;;;;;;EAQT,SAAS;;;;;;;;;;;;;;;;;;;EAmBT,YAAY,yBAAyB;;;;;YAM3B;;;;EAIV;;;;EAIA;;;;;YAMU,yBAAyB,iBAAiB,UAAU;EAC9D,SAAS;EACT,UAAU;;;;EAIV,OAAO;;;;;YAMG,yBAAyB,iBAAiB,UAAU,YAC9D,QAAQ,kBACR,SAAS,yBAAyB,cAEhC,+CAGA,QAAQ;iBAEK;;;;EAIf,SAAS;;;;EAIT,UAAU;;;;EAIV,WAAW,QAAQ;;;;EAInB;;;;EAIA,mBAAmB,QAAQ,sBAAsB;;;;;;;;;YAUvC,YAAY,iBAAiB,UAAU;;;;EAIjD,SAAS;;;;EAIT,UAAU;;YAGA,eAAe,iBAAiB,UAAU;;;;GAInD,SAAS,YAAY,YAAY;;;;EAKlC,aAAa,yBAAyB;;;;EAKtC;;;;;EAMA,iBAAiB;;;;;;YAOP;;;;EAIV;;;;EAIA;;;;EAIA;;;;;;YAOU;;;;;;;EAOV,YAAY;;iBAGG;;;;EAIf,aAAa;;;;EAIb;;;;EAIA;;;;;EAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2BA,gBAAgB;;;;;;EAMhB;;;;EAIA;;;;EAIA;;;;;;;;;;;;;;;;EAgBA,YAAY;;;;;;;;;;;;;;;;;;;;;;;EAuBZ;;;;;;;;;;;;;;EAcA,aAAa;;;;;;;;;;;EAWb,UAAU;;;;;;;;;;;;EAYV;;YAGU;;YAWA,gBAAgB;EAC1B;EACA;EACA,QAAQ;;;;;;;EAOR,WAAW,SAAS;;;YAIV,wBAAwB;;;;;EAKlC,mBACE,QAAQ,gBACR,OAAO,UACJ,SAAS;;;;;YAMJ,QAAQ;;;;EAIlB,QAAQ;;;;EAIR;;;;EAIA;;;;;EAKA,SAAS;;;;;;EAMT;;;;;;EAMA;;;;EAIA,YAAY;;;;;;EAMZ;;YAGU,YAAY,GAAG,gBAAgB,KAAK,KAAK,GAAG,KAAK,QAAQ,KAAK,GAAG;;;;YAKjE;;;;EAIV,aAAa;;;;EAIb;;;;;EAKA;;;;;;EAMA;;;YAIU;;;;EAIV,OAAO;;;;EAIP,MAAM;;;;EAIN,SAAS;;;;EAIT,SAAS;;;;EAIT,QAAQ;;;;EAIR,SAAS;;;;EAIT,aAAa;;;;EAIb;;;;IAIE,MAAM;;;;IAIN,SAAS;;;;IAIT,SAAS;;;;IAIT,QAAQ;;;YAIA;EACV;EACA;EACA,YAAY;;;;;EAKZ;;;;EAIA"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../../packages/core/src/router/types.ts"],"mappings":";;;;;;;;;;;;;YAUY,qBAAqB;;;;;;;YAQrB,WAAW,0BAA0B,UAAU;GACxD,SAAS,YAAY,qBAAqB;;YAGjC;YAEA,qBAAqB,uBAAuB;;;;YAK5C;YAEA,oBAAoB,gBAAgB;YAEpC,yBAAyB,iBAAiB,UAAU;;;;EAI9D,WAAW,WAAW;;;;;;EAMtB;;;;EAIA,SAAS;;;;;;;;EAQT,SAAS;;;;;;;;;;;;;;;;;;;EAmBT,YAAY,yBAAyB;;;;;YAM3B;;;;EAIV;;;;EAIA;;;;;YAMU,yBAAyB,iBAAiB,UAAU;EAC9D,SAAS;EACT,UAAU;;;;EAIV,OAAO;;;;;YAMG,yBAAyB,iBAAiB,UAAU,YAC9D,QAAQ,kBACR,SAAS,yBAAyB,cAEhC,+CAGA,QAAQ;iBAEK;;;;EAIf,SAAS;;;;EAIT,UAAU;;;;EAIV,WAAW,QAAQ;;;;EAInB;;;;EAIA,mBAAmB,QAAQ,sBAAsB;;;;;;;;;YAUvC,YAAY,iBAAiB,UAAU;;;;EAIjD,SAAS;;;;EAIT,UAAU;;YAGA,eAAe,iBAAiB,UAAU;;;;GAInD,SAAS,YAAY,YAAY;;;;EAKlC,aAAa,yBAAyB;;;;EAKtC;;;;;EAMA,iBAAiB;;;;;;YAOP;;;;EAIV;;;;EAIA;;;;EAIA;;;;;;YAOU;;;;;;;EAOV,YAAY;;iBAGG;;;;EAIf,aAAa;;;;EAIb;;;;EAIA;;;;;EAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2BA,gBAAgB;;;;;;EAMhB;;;;EAIA;;;;EAIA;;;;;;;;;;;;;;;;EAgBA,YAAY;;;;;;;;;;;;;;;;;;;;;;;EAuBZ;;;;;;;;;;;;;;EAcA,aAAa;;;;;;;;;;;EAWb,UAAU;;;;;;;;;;;;EAYV;;;;;;;;;;;;;;EAcA;;;;;;;;;;;EAWA;;YAGU;;YAWA,gBAAgB;EAC1B;EACA;EACA,QAAQ;;;;;;;EAOR,WAAW,SAAS;;;;;EAKpB;;EAEA;;;;;;EAMA;;;YAIU,wBAAwB;;;;;EAKlC,mBACE,QAAQ,gBACR,OAAO,UACJ,SAAS;;;;;YAMJ,QAAQ;;;;EAIlB,QAAQ;;;;EAIR;;;;EAIA;;;;;EAKA,SAAS;;;;;;EAMT;;;;;;EAMA;;;;EAIA,YAAY;;;;;;EAMZ;;YAGU,YAAY,GAAG,gBAAgB,KAAK,KAAK,GAAG,KAAK,QAAQ,KAAK,GAAG;;;;YAKjE;;;;EAIV,aAAa;;;;EAIb;;;;;EAKA;;;;;;EAMA;;;;;EAKA;;;YAIU;;;;EAIV,OAAO;;;;EAIP,MAAM;;;;EAIN,SAAS;;;;EAIT,SAAS;;;;EAIT,QAAQ;;;;EAIR,SAAS;;;;EAIT,aAAa;;;;EAIb;;;;IAIE,MAAM;;;;IAIN,SAAS;;;;IAIT,SAAS;;;;IAIT,QAAQ;;;YAIA;EACV;EACA;EACA,YAAY;;;;;EAKZ;;EAEA;;;;EAIA"}
@@ -25,6 +25,12 @@ export declare function markAuthGuard<T extends (...args: never[]) => unknown>(m
25
25
  * `guard`.
26
26
  */
27
27
  export declare function isAuthGuardMiddleware(middleware: unknown): boolean;
28
+ /**
29
+ * Whether an auth guard is in the route's middleware chain. `route.middleware`
30
+ * already holds the middleware of every enclosing `router.group`, so a guard
31
+ * declared once on a group counts for each route inside it.
32
+ */
33
+ export declare function isRequiredGuardRoute(route: Pick<Route, "middleware">): boolean;
28
34
  /** Whether a route mutates state, is not a page route, and is neither guarded nor public. */
29
35
  export declare function isUnguardedMutatingRoute(route: Route): boolean;
30
36
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"unguarded-route-diagnostics.d.mts","names":[],"sources":["../../../../../../../../packages/core/src/router/unguarded-route-diagnostics.ts"],"mappings":";;;;;;;;qBAuBa;;;;;;;;;;;;wBAaG,cAAc,cAAc,2BAC1C,YAAY,GACZ,oBACC;;;;;;wBAea,sBAAsB;;wBAUtB,yBAAyB,OAAO;;;;;wBAoBhC,8BACd,iBAAiB,SACjB,SAAQ,YAAY,eACnB;;wBAsCa"}
1
+ {"version":3,"file":"unguarded-route-diagnostics.d.mts","names":[],"sources":["../../../../../../../../packages/core/src/router/unguarded-route-diagnostics.ts"],"mappings":";;;;;;;;qBAuBa;;;;;;;;;;;;wBAaG,cAAc,cAAc,2BAC1C,YAAY,GACZ,oBACC;;;;;;wBAea,sBAAsB;;;;;;wBActB,qBAAqB,OAAO,KAAK;;wBAKjC,yBAAyB,OAAO;;;;;wBAoBhC,8BACd,iBAAiB,SACjB,SAAQ,YAAY,eACnB;;wBAsCa"}
@@ -51,12 +51,20 @@ function isAuthGuardMiddleware(middleware) {
51
51
  if (typeof marker === "boolean") return marker;
52
52
  return isAuthGuardName(middleware.name);
53
53
  }
54
+ /**
55
+ * Whether an auth guard is in the route's middleware chain. `route.middleware`
56
+ * already holds the middleware of every enclosing `router.group`, so a guard
57
+ * declared once on a group counts for each route inside it.
58
+ */
59
+ function isRequiredGuardRoute(route) {
60
+ return (route.middleware ?? []).some(isAuthGuardMiddleware);
61
+ }
54
62
  /** Whether a route mutates state, is not a page route, and is neither guarded nor public. */
55
63
  function isUnguardedMutatingRoute(route) {
56
64
  if (!isMutatingHttpMethod(String(route.method))) return false;
57
65
  if (route.isPage) return false;
58
66
  if (route.public === true) return false;
59
- return !(route.middleware ?? []).some(isAuthGuardMiddleware);
67
+ return !isRequiredGuardRoute(route);
60
68
  }
61
69
  /** Routes already reported in this process, so an HMR reload does not repeat them. */
62
70
  const reported = /* @__PURE__ */ new Set();
@@ -91,5 +99,5 @@ function resetUnguardedRouteReports() {
91
99
  }
92
100
 
93
101
  //#endregion
94
- export { AUTH_GUARD_MARKER, isAuthGuardMiddleware, isUnguardedMutatingRoute, markAuthGuard, reportUnguardedMutatingRoutes, resetUnguardedRouteReports };
102
+ export { AUTH_GUARD_MARKER, isAuthGuardMiddleware, isRequiredGuardRoute, isUnguardedMutatingRoute, markAuthGuard, reportUnguardedMutatingRoutes, resetUnguardedRouteReports };
95
103
  //# sourceMappingURL=unguarded-route-diagnostics.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"unguarded-route-diagnostics.mjs","names":[],"sources":["../../../../../../../../packages/core/src/router/unguarded-route-diagnostics.ts"],"sourcesContent":["/**\n * Development warning for mutating routes that nobody guarded.\n *\n * A POST/PUT/PATCH/DELETE route without auth middleware is reachable by anyone\n * who can reach the server. Sometimes that is the point (a login, a webhook, a\n * contact form) and sometimes it is a forgotten `guarded()` block. The router\n * cannot tell the two apart, so the app says which with `public: true`, and\n * `warlock dev` lists every mutating route that has neither.\n *\n * Warning only: routes register and behave exactly as before, nothing here\n * throws, and production never runs it. `warlock check` reports the same\n * routes from source as `WL9006`, before the app runs.\n */\n\nimport { log } from \"@warlock.js/logger\";\nimport { isAuthGuardName, isMutatingHttpMethod } from \"./route-guard-names\";\nimport type { Route } from \"./types\";\n\n/**\n * Marks a middleware as an authentication guard. A registry symbol, so a copy\n * of core loaded twice (a linked package next to an installed one) still\n * recognises guards the other copy marked.\n */\nexport const AUTH_GUARD_MARKER: unique symbol = Symbol.for(\"warlock.js.authGuard\");\n\n/**\n * Declare whether a middleware authenticates the request. `authMiddleware()`\n * from `@warlock.js/auth` marks itself (and marks `{ optional: true }` as not\n * a guard); use this for a hand-written guard whose name does not contain\n * `auth` or `guard`.\n *\n * @example\n * export const requireApiKey = markAuthGuard(async ({ request, response }) => {\n * if (request.headers[\"x-api-key\"] !== env(\"API_KEY\")) return response.unauthorized();\n * });\n */\nexport function markAuthGuard<T extends (...args: never[]) => unknown>(\n middleware: T,\n isGuard = true,\n): T {\n Object.defineProperty(middleware, AUTH_GUARD_MARKER, {\n value: isGuard,\n configurable: true,\n enumerable: false,\n });\n\n return middleware;\n}\n\n/**\n * Whether a middleware authenticates the request: its {@link markAuthGuard}\n * marker when it has one, otherwise a function name containing `auth` or\n * `guard`.\n */\nexport function isAuthGuardMiddleware(middleware: unknown): boolean {\n if (typeof middleware !== \"function\") return false;\n\n const marker: unknown = Reflect.get(middleware, AUTH_GUARD_MARKER);\n if (typeof marker === \"boolean\") return marker;\n\n return isAuthGuardName(middleware.name);\n}\n\n/** Whether a route mutates state, is not a page route, and is neither guarded nor public. */\nexport function isUnguardedMutatingRoute(route: Route): boolean {\n if (!isMutatingHttpMethod(String(route.method))) return false;\n // Page actions are authorized by the page itself (`requireUser()`).\n if (route.isPage) return false;\n if (route.public === true) return false;\n\n return !(route.middleware ?? []).some(isAuthGuardMiddleware);\n}\n\n/** Routes already reported in this process, so an HMR reload does not repeat them. */\nconst reported = new Set<string>();\n\nfunction routeKey(route: Route): string {\n return `${String(route.method).toUpperCase()} ${route.path}`;\n}\n\n/**\n * Warn, once per route per process, about every mutating route with no auth\n * middleware and no `public: true`. Returns the routes it reported.\n */\nexport function reportUnguardedMutatingRoutes(\n routes: readonly Route[],\n logger: Pick<typeof log, \"warn\"> = log,\n): Route[] {\n const fresh = routes.filter(\n (route) => isUnguardedMutatingRoute(route) && !reported.has(routeKey(route)),\n );\n\n if (fresh.length === 0) return fresh;\n\n for (const route of fresh) reported.add(routeKey(route));\n\n const headline =\n fresh.length === 1\n ? \"1 mutating route has no auth middleware and is not marked public.\"\n : `${fresh.length} mutating routes have no auth middleware and are not marked public.`;\n\n logger.warn({\n module: \"router\",\n action: \"unguardedRoute\",\n message:\n `${headline} Anyone who can reach the server can call them. Add auth middleware ` +\n `(e.g. register them inside a guarded group), or pass { public: true } if they are ` +\n `meant to be open:\\n` +\n fresh\n .map((route) => ` - ${routeKey(route)}${route.sourceFile ? ` (${route.sourceFile})` : \"\"}`)\n .join(\"\\n\"),\n context: {\n routes: fresh.map((route) => ({\n method: route.method,\n path: route.path,\n name: route.name,\n sourceFile: route.sourceFile,\n })),\n },\n });\n\n return fresh;\n}\n\n/** Forget what was reported (tests). */\nexport function resetUnguardedRouteReports(): void {\n reported.clear();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAa,oBAAmC,OAAO,IAAI,sBAAsB;;;;;;;;;;;;AAajF,SAAgB,cACd,YACA,UAAU,MACP;CACH,OAAO,eAAe,YAAY,mBAAmB;EACnD,OAAO;EACP,cAAc;EACd,YAAY;CACd,CAAC;CAED,OAAO;AACT;;;;;;AAOA,SAAgB,sBAAsB,YAA8B;CAClE,IAAI,OAAO,eAAe,YAAY,OAAO;CAE7C,MAAM,SAAkB,QAAQ,IAAI,YAAY,iBAAiB;CACjE,IAAI,OAAO,WAAW,WAAW,OAAO;CAExC,OAAO,gBAAgB,WAAW,IAAI;AACxC;;AAGA,SAAgB,yBAAyB,OAAuB;CAC9D,IAAI,CAAC,qBAAqB,OAAO,MAAM,MAAM,CAAC,GAAG,OAAO;CAExD,IAAI,MAAM,QAAQ,OAAO;CACzB,IAAI,MAAM,WAAW,MAAM,OAAO;CAElC,OAAO,EAAE,MAAM,cAAc,CAAC,EAAC,CAAE,KAAK,qBAAqB;AAC7D;;AAGA,MAAM,2BAAW,IAAI,IAAY;AAEjC,SAAS,SAAS,OAAsB;CACtC,OAAO,GAAG,OAAO,MAAM,MAAM,CAAC,CAAC,YAAY,EAAE,GAAG,MAAM;AACxD;;;;;AAMA,SAAgB,8BACd,QACA,SAAmC,KAC1B;CACT,MAAM,QAAQ,OAAO,QAClB,UAAU,yBAAyB,KAAK,KAAK,CAAC,SAAS,IAAI,SAAS,KAAK,CAAC,CAC7E;CAEA,IAAI,MAAM,WAAW,GAAG,OAAO;CAE/B,KAAK,MAAM,SAAS,OAAO,SAAS,IAAI,SAAS,KAAK,CAAC;CAEvD,MAAM,WACJ,MAAM,WAAW,IACb,sEACA,GAAG,MAAM,OAAO;CAEtB,OAAO,KAAK;EACV,QAAQ;EACR,QAAQ;EACR,SACE,GAAG,SAAS,6KAGZ,MACG,KAAK,UAAU,OAAO,SAAS,KAAK,IAAI,MAAM,aAAa,KAAK,MAAM,WAAW,KAAK,IAAI,CAAC,CAC3F,KAAK,IAAI;EACd,SAAS,EACP,QAAQ,MAAM,KAAK,WAAW;GAC5B,QAAQ,MAAM;GACd,MAAM,MAAM;GACZ,MAAM,MAAM;GACZ,YAAY,MAAM;EACpB,EAAE,EACJ;CACF,CAAC;CAED,OAAO;AACT;;AAGA,SAAgB,6BAAmC;CACjD,SAAS,MAAM;AACjB"}
1
+ {"version":3,"file":"unguarded-route-diagnostics.mjs","names":[],"sources":["../../../../../../../../packages/core/src/router/unguarded-route-diagnostics.ts"],"sourcesContent":["/**\n * Development warning for mutating routes that nobody guarded.\n *\n * A POST/PUT/PATCH/DELETE route without auth middleware is reachable by anyone\n * who can reach the server. Sometimes that is the point (a login, a webhook, a\n * contact form) and sometimes it is a forgotten `guarded()` block. The router\n * cannot tell the two apart, so the app says which with `public: true`, and\n * `warlock dev` lists every mutating route that has neither.\n *\n * Warning only: routes register and behave exactly as before, nothing here\n * throws, and production never runs it. `warlock check` reports the same\n * routes from source as `WL9006`, before the app runs.\n */\n\nimport { log } from \"@warlock.js/logger\";\nimport { isAuthGuardName, isMutatingHttpMethod } from \"./route-guard-names\";\nimport type { Route } from \"./types\";\n\n/**\n * Marks a middleware as an authentication guard. A registry symbol, so a copy\n * of core loaded twice (a linked package next to an installed one) still\n * recognises guards the other copy marked.\n */\nexport const AUTH_GUARD_MARKER: unique symbol = Symbol.for(\"warlock.js.authGuard\");\n\n/**\n * Declare whether a middleware authenticates the request. `authMiddleware()`\n * from `@warlock.js/auth` marks itself (and marks `{ optional: true }` as not\n * a guard); use this for a hand-written guard whose name does not contain\n * `auth` or `guard`.\n *\n * @example\n * export const requireApiKey = markAuthGuard(async ({ request, response }) => {\n * if (request.headers[\"x-api-key\"] !== env(\"API_KEY\")) return response.unauthorized();\n * });\n */\nexport function markAuthGuard<T extends (...args: never[]) => unknown>(\n middleware: T,\n isGuard = true,\n): T {\n Object.defineProperty(middleware, AUTH_GUARD_MARKER, {\n value: isGuard,\n configurable: true,\n enumerable: false,\n });\n\n return middleware;\n}\n\n/**\n * Whether a middleware authenticates the request: its {@link markAuthGuard}\n * marker when it has one, otherwise a function name containing `auth` or\n * `guard`.\n */\nexport function isAuthGuardMiddleware(middleware: unknown): boolean {\n if (typeof middleware !== \"function\") return false;\n\n const marker: unknown = Reflect.get(middleware, AUTH_GUARD_MARKER);\n if (typeof marker === \"boolean\") return marker;\n\n return isAuthGuardName(middleware.name);\n}\n\n/**\n * Whether an auth guard is in the route's middleware chain. `route.middleware`\n * already holds the middleware of every enclosing `router.group`, so a guard\n * declared once on a group counts for each route inside it.\n */\nexport function isRequiredGuardRoute(route: Pick<Route, \"middleware\">): boolean {\n return (route.middleware ?? []).some(isAuthGuardMiddleware);\n}\n\n/** Whether a route mutates state, is not a page route, and is neither guarded nor public. */\nexport function isUnguardedMutatingRoute(route: Route): boolean {\n if (!isMutatingHttpMethod(String(route.method))) return false;\n // Page actions are authorized by the page itself (`requireUser()`).\n if (route.isPage) return false;\n if (route.public === true) return false;\n\n return !isRequiredGuardRoute(route);\n}\n\n/** Routes already reported in this process, so an HMR reload does not repeat them. */\nconst reported = new Set<string>();\n\nfunction routeKey(route: Route): string {\n return `${String(route.method).toUpperCase()} ${route.path}`;\n}\n\n/**\n * Warn, once per route per process, about every mutating route with no auth\n * middleware and no `public: true`. Returns the routes it reported.\n */\nexport function reportUnguardedMutatingRoutes(\n routes: readonly Route[],\n logger: Pick<typeof log, \"warn\"> = log,\n): Route[] {\n const fresh = routes.filter(\n (route) => isUnguardedMutatingRoute(route) && !reported.has(routeKey(route)),\n );\n\n if (fresh.length === 0) return fresh;\n\n for (const route of fresh) reported.add(routeKey(route));\n\n const headline =\n fresh.length === 1\n ? \"1 mutating route has no auth middleware and is not marked public.\"\n : `${fresh.length} mutating routes have no auth middleware and are not marked public.`;\n\n logger.warn({\n module: \"router\",\n action: \"unguardedRoute\",\n message:\n `${headline} Anyone who can reach the server can call them. Add auth middleware ` +\n `(e.g. register them inside a guarded group), or pass { public: true } if they are ` +\n `meant to be open:\\n` +\n fresh\n .map((route) => ` - ${routeKey(route)}${route.sourceFile ? ` (${route.sourceFile})` : \"\"}`)\n .join(\"\\n\"),\n context: {\n routes: fresh.map((route) => ({\n method: route.method,\n path: route.path,\n name: route.name,\n sourceFile: route.sourceFile,\n })),\n },\n });\n\n return fresh;\n}\n\n/** Forget what was reported (tests). */\nexport function resetUnguardedRouteReports(): void {\n reported.clear();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAa,oBAAmC,OAAO,IAAI,sBAAsB;;;;;;;;;;;;AAajF,SAAgB,cACd,YACA,UAAU,MACP;CACH,OAAO,eAAe,YAAY,mBAAmB;EACnD,OAAO;EACP,cAAc;EACd,YAAY;CACd,CAAC;CAED,OAAO;AACT;;;;;;AAOA,SAAgB,sBAAsB,YAA8B;CAClE,IAAI,OAAO,eAAe,YAAY,OAAO;CAE7C,MAAM,SAAkB,QAAQ,IAAI,YAAY,iBAAiB;CACjE,IAAI,OAAO,WAAW,WAAW,OAAO;CAExC,OAAO,gBAAgB,WAAW,IAAI;AACxC;;;;;;AAOA,SAAgB,qBAAqB,OAA2C;CAC9E,QAAQ,MAAM,cAAc,CAAC,EAAC,CAAE,KAAK,qBAAqB;AAC5D;;AAGA,SAAgB,yBAAyB,OAAuB;CAC9D,IAAI,CAAC,qBAAqB,OAAO,MAAM,MAAM,CAAC,GAAG,OAAO;CAExD,IAAI,MAAM,QAAQ,OAAO;CACzB,IAAI,MAAM,WAAW,MAAM,OAAO;CAElC,OAAO,CAAC,qBAAqB,KAAK;AACpC;;AAGA,MAAM,2BAAW,IAAI,IAAY;AAEjC,SAAS,SAAS,OAAsB;CACtC,OAAO,GAAG,OAAO,MAAM,MAAM,CAAC,CAAC,YAAY,EAAE,GAAG,MAAM;AACxD;;;;;AAMA,SAAgB,8BACd,QACA,SAAmC,KAC1B;CACT,MAAM,QAAQ,OAAO,QAClB,UAAU,yBAAyB,KAAK,KAAK,CAAC,SAAS,IAAI,SAAS,KAAK,CAAC,CAC7E;CAEA,IAAI,MAAM,WAAW,GAAG,OAAO;CAE/B,KAAK,MAAM,SAAS,OAAO,SAAS,IAAI,SAAS,KAAK,CAAC;CAEvD,MAAM,WACJ,MAAM,WAAW,IACb,sEACA,GAAG,MAAM,OAAO;CAEtB,OAAO,KAAK;EACV,QAAQ;EACR,QAAQ;EACR,SACE,GAAG,SAAS,6KAGZ,MACG,KAAK,UAAU,OAAO,SAAS,KAAK,IAAI,MAAM,aAAa,KAAK,MAAM,WAAW,KAAK,IAAI,CAAC,CAC3F,KAAK,IAAI;EACd,SAAS,EACP,QAAQ,MAAM,KAAK,WAAW;GAC5B,QAAQ,MAAM;GACd,MAAM,MAAM;GACZ,MAAM,MAAM;GACZ,YAAY,MAAM;EACpB,EAAE,EACJ;CACF,CAAC;CAED,OAAO;AACT;;AAGA,SAAgB,6BAAmC;CACjD,SAAS,MAAM;AACjB"}
@@ -0,0 +1,16 @@
1
+ import "../syntax/ast.mjs";
2
+ import "./source-ast.mjs";
3
+ //#region ../../packages/core/src/tooling/check/api-route-references.d.ts
4
+ /**
5
+ * Project-wide facts the browser-reference checks need. Gathered once per run
6
+ * (`runConventions`) because a route is registered in one file and referenced
7
+ * from another.
8
+ */
9
+ export type ApiRouteFacts = {
10
+ /** Names of routes registered with `clientVisible: false`. */
11
+ readonly clientHiddenNames: ReadonlySet<string>;
12
+ /** Whether any page, layout or root setup file declares `config.apiRoutes`. */
13
+ readonly declaresApiRoutes: boolean;
14
+ };
15
+ //#endregion
16
+ //# sourceMappingURL=api-route-references.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-route-references.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/api-route-references.ts"],"mappings":";;;;;;;;YAUY;;WAED,mBAAmB;;WAEnB"}
@@ -0,0 +1,146 @@
1
+ import { keyName, parseSourceFile } from "../syntax/ast.mjs";
2
+ import { calleeName, literalString, unwrap, walk } from "./source-ast.mjs";
3
+ import path from "node:path";
4
+ import { readFileSync } from "node:fs";
5
+
6
+ //#region ../../packages/core/src/tooling/check/api-route-references.ts
7
+ const EMPTY_API_ROUTE_FACTS = {
8
+ clientHiddenNames: /* @__PURE__ */ new Set(),
9
+ declaresApiRoutes: false
10
+ };
11
+ /** Browser-side route-taking helpers from `@warlock.js/web`. */
12
+ const ROUTE_CALLS = /* @__PURE__ */ new Set(["apiHref", "useSubmitForm"]);
13
+ /** The value of a literal key on an object literal argument, when there is one. */
14
+ function objectProperty(object, name) {
15
+ const value = unwrap(object);
16
+ if (value.type !== "ObjectExpression") return void 0;
17
+ for (const property of value.properties) {
18
+ if (property.type !== "Property") continue;
19
+ if (keyName(property.key) === name) return property.value;
20
+ }
21
+ }
22
+ /**
23
+ * Names of the routes a source file registers with `clientVisible: false`
24
+ * inside an options object that also carries a literal `name`. A group prefix
25
+ * is not applied here, so a route whose name comes only from a group is not
26
+ * recognised; name such a route with a literal `name` option.
27
+ */
28
+ function clientHiddenRouteNamesOf(source) {
29
+ const names = [];
30
+ walk(source, (node) => {
31
+ if (node.type !== "CallExpression") return;
32
+ for (const argument of node.arguments) {
33
+ const hidden = objectProperty(argument, "clientVisible");
34
+ const flag = hidden === void 0 ? void 0 : unwrap(hidden);
35
+ const name = literalString(objectProperty(argument, "name"));
36
+ if (flag?.type === "Literal" && flag.value === false && name !== void 0) names.push(name);
37
+ }
38
+ }, true);
39
+ return names;
40
+ }
41
+ /** Whether a setup file's exported `config` object declares an `apiRoutes` key. */
42
+ function declaresApiRoutesOf(source) {
43
+ let declared = false;
44
+ walk(source, (node) => {
45
+ if (declared) return false;
46
+ if (node.type !== "VariableDeclarator" || keyName(node.id) !== "config") return;
47
+ if (node.init === null || node.init === void 0) return;
48
+ if (objectProperty(node.init, "apiRoutes") !== void 0) declared = true;
49
+ }, true);
50
+ return declared;
51
+ }
52
+ /**
53
+ * The static name a route-taking call receives: a string literal, a template
54
+ * with no substitutions, or the static prefix of a template. `undefined` means
55
+ * the name is only known at run time.
56
+ */
57
+ function staticNameOf(node) {
58
+ const value = unwrap(node);
59
+ const literal = literalString(value);
60
+ if (literal !== void 0) return {
61
+ name: literal,
62
+ prefix: false
63
+ };
64
+ if (value.type === "TemplateLiteral" && value.expressions.length > 0) {
65
+ const head = value.quasis[0]?.value.cooked ?? "";
66
+ return head.length > 0 ? {
67
+ name: head,
68
+ prefix: true
69
+ } : void 0;
70
+ }
71
+ }
72
+ /** The route-name argument of an `apiHref(name)` or `useSubmitForm({ route })` call. */
73
+ function routeArgumentOf(call) {
74
+ const first = call.arguments[0];
75
+ if (first === void 0) return void 0;
76
+ if (calleeName(call) === "apiHref") return first;
77
+ return objectProperty(first, "route");
78
+ }
79
+ /**
80
+ * The browser route-reference conventions:
81
+ *
82
+ * - `WL9020` (error) a web module passes a name of a `clientVisible: false`
83
+ * route to `apiHref()` or `useSubmitForm({ route })`, literally or as a
84
+ * template prefix;
85
+ * - `WL9019` (warning) such a call takes a name that cannot be read statically
86
+ * and no page declares `config.apiRoutes`, so the page's route table cannot
87
+ * be computed. The declaration is checked app-wide, not per page.
88
+ *
89
+ * Applies to `src/web/**` modules, outside test files.
90
+ */
91
+ function apiRouteReferenceFindings(source, facts = EMPTY_API_ROUTE_FACTS) {
92
+ if (!/^src\/web\/.*\.[cm]?[jt]sx?$/.test(source.fileName)) return [];
93
+ if (/\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(source.fileName)) return [];
94
+ const findings = [];
95
+ walk(source, (node) => {
96
+ if (node.type !== "CallExpression") return;
97
+ if (!ROUTE_CALLS.has(calleeName(node) ?? "")) return;
98
+ const argument = routeArgumentOf(node);
99
+ if (argument === void 0) return;
100
+ const target = staticNameOf(argument);
101
+ if (target === void 0) {
102
+ if (!facts.declaresApiRoutes) findings.push({
103
+ code: "WL9019",
104
+ node: argument,
105
+ severity: "warning",
106
+ message: "This route name is only known at run time, so the page's browser route table cannot include it. Pass a string literal, or declare the names with config.apiRoutes in the page's setup file."
107
+ });
108
+ return;
109
+ }
110
+ const hidden = [...facts.clientHiddenNames].find((name) => target.prefix ? name.startsWith(target.name) : name === target.name);
111
+ if (hidden !== void 0) findings.push({
112
+ code: "WL9020",
113
+ node: argument,
114
+ severity: "error",
115
+ message: `Route "${hidden}" is registered with clientVisible: false and cannot be referenced from browser code.`
116
+ });
117
+ }, true);
118
+ return findings;
119
+ }
120
+ /**
121
+ * Gathers the project-wide facts from `files` (project-relative). Unreadable or
122
+ * unparsable files contribute nothing, as the per-file checks report them.
123
+ */
124
+ function collectApiRouteFacts(cwd, files) {
125
+ const clientHiddenNames = /* @__PURE__ */ new Set();
126
+ let declaresApiRoutes = false;
127
+ for (const file of files) {
128
+ if (!/^src\/.*\.[cm]?[jt]sx?$/.test(file)) continue;
129
+ let sourceFile;
130
+ try {
131
+ sourceFile = parseSourceFile(file, readFileSync(path.join(cwd, file), "utf8"));
132
+ } catch {
133
+ continue;
134
+ }
135
+ for (const name of clientHiddenRouteNamesOf(sourceFile)) clientHiddenNames.add(name);
136
+ if (/^src\/web\/.*\.setup\.[cm]?[jt]sx?$/.test(file) && declaresApiRoutesOf(sourceFile)) declaresApiRoutes = true;
137
+ }
138
+ return {
139
+ clientHiddenNames,
140
+ declaresApiRoutes
141
+ };
142
+ }
143
+
144
+ //#endregion
145
+ export { EMPTY_API_ROUTE_FACTS, apiRouteReferenceFindings, clientHiddenRouteNamesOf, collectApiRouteFacts, declaresApiRoutesOf };
146
+ //# sourceMappingURL=api-route-references.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-route-references.mjs","names":["ast.keyName","ast.parseSourceFile"],"sources":["../../../../../../../../../packages/core/src/tooling/check/api-route-references.ts"],"sourcesContent":["import { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport * as ast from \"../syntax/ast\";\nimport { calleeName, literalString, unwrap, walk, type SourceFinding } from \"./source-ast\";\n\n/**\n * Project-wide facts the browser-reference checks need. Gathered once per run\n * (`runConventions`) because a route is registered in one file and referenced\n * from another.\n */\nexport type ApiRouteFacts = {\n /** Names of routes registered with `clientVisible: false`. */\n readonly clientHiddenNames: ReadonlySet<string>;\n /** Whether any page, layout or root setup file declares `config.apiRoutes`. */\n readonly declaresApiRoutes: boolean;\n};\n\nexport const EMPTY_API_ROUTE_FACTS: ApiRouteFacts = {\n clientHiddenNames: new Set(),\n declaresApiRoutes: false,\n};\n\n/** Browser-side route-taking helpers from `@warlock.js/web`. */\nconst ROUTE_CALLS = new Set([\"apiHref\", \"useSubmitForm\"]);\n\n/** The value of a literal key on an object literal argument, when there is one. */\nfunction objectProperty(object: ast.Node, name: string): ast.Node | undefined {\n const value = unwrap(object);\n if (value.type !== \"ObjectExpression\") return undefined;\n for (const property of value.properties) {\n if (property.type !== \"Property\") continue;\n if (ast.keyName(property.key) === name) return property.value;\n }\n return undefined;\n}\n\n/**\n * Names of the routes a source file registers with `clientVisible: false`\n * inside an options object that also carries a literal `name`. A group prefix\n * is not applied here, so a route whose name comes only from a group is not\n * recognised; name such a route with a literal `name` option.\n */\nexport function clientHiddenRouteNamesOf(source: ast.SourceFile): string[] {\n const names: string[] = [];\n\n walk(\n source,\n (node) => {\n if (node.type !== \"CallExpression\") return;\n for (const argument of node.arguments) {\n const hidden = objectProperty(argument, \"clientVisible\");\n const flag = hidden === undefined ? undefined : unwrap(hidden);\n const name = literalString(objectProperty(argument, \"name\"));\n if (flag?.type === \"Literal\" && flag.value === false && name !== undefined)\n names.push(name);\n }\n },\n true,\n );\n\n return names;\n}\n\n/** Whether a setup file's exported `config` object declares an `apiRoutes` key. */\nexport function declaresApiRoutesOf(source: ast.SourceFile): boolean {\n let declared = false;\n\n walk(\n source,\n (node) => {\n if (declared) return false;\n if (node.type !== \"VariableDeclarator\" || ast.keyName(node.id) !== \"config\") return;\n if (node.init === null || node.init === undefined) return;\n if (objectProperty(node.init, \"apiRoutes\") !== undefined) declared = true;\n },\n true,\n );\n\n return declared;\n}\n\n/**\n * The static name a route-taking call receives: a string literal, a template\n * with no substitutions, or the static prefix of a template. `undefined` means\n * the name is only known at run time.\n */\nfunction staticNameOf(node: ast.Node): { name: string; prefix: boolean } | undefined {\n const value = unwrap(node);\n const literal = literalString(value);\n if (literal !== undefined) return { name: literal, prefix: false };\n if (value.type === \"TemplateLiteral\" && value.expressions.length > 0) {\n const head = value.quasis[0]?.value.cooked ?? \"\";\n return head.length > 0 ? { name: head, prefix: true } : undefined;\n }\n return undefined;\n}\n\n/** The route-name argument of an `apiHref(name)` or `useSubmitForm({ route })` call. */\nfunction routeArgumentOf(call: ast.CallExpression): ast.Node | undefined {\n const first = call.arguments[0];\n if (first === undefined) return undefined;\n if (calleeName(call) === \"apiHref\") return first;\n return objectProperty(first, \"route\");\n}\n\n/**\n * The browser route-reference conventions:\n *\n * - `WL9020` (error) a web module passes a name of a `clientVisible: false`\n * route to `apiHref()` or `useSubmitForm({ route })`, literally or as a\n * template prefix;\n * - `WL9019` (warning) such a call takes a name that cannot be read statically\n * and no page declares `config.apiRoutes`, so the page's route table cannot\n * be computed. The declaration is checked app-wide, not per page.\n *\n * Applies to `src/web/**` modules, outside test files.\n */\nexport function apiRouteReferenceFindings(\n source: ast.SourceFile,\n facts: ApiRouteFacts = EMPTY_API_ROUTE_FACTS,\n): SourceFinding[] {\n if (!/^src\\/web\\/.*\\.[cm]?[jt]sx?$/.test(source.fileName)) return [];\n if (/\\.(?:test|spec)\\.[cm]?[jt]sx?$/i.test(source.fileName)) return [];\n\n const findings: SourceFinding[] = [];\n\n walk(\n source,\n (node) => {\n if (node.type !== \"CallExpression\") return;\n if (!ROUTE_CALLS.has(calleeName(node) ?? \"\")) return;\n const argument = routeArgumentOf(node);\n if (argument === undefined) return;\n const target = staticNameOf(argument);\n\n if (target === undefined) {\n if (!facts.declaresApiRoutes)\n findings.push({\n code: \"WL9019\",\n node: argument,\n severity: \"warning\",\n message:\n \"This route name is only known at run time, so the page's browser route table cannot include it. Pass a string literal, or declare the names with config.apiRoutes in the page's setup file.\",\n });\n return;\n }\n\n const hidden = [...facts.clientHiddenNames].find((name) =>\n target.prefix ? name.startsWith(target.name) : name === target.name,\n );\n\n if (hidden !== undefined)\n findings.push({\n code: \"WL9020\",\n node: argument,\n severity: \"error\",\n message: `Route \"${hidden}\" is registered with clientVisible: false and cannot be referenced from browser code.`,\n });\n },\n true,\n );\n\n return findings;\n}\n\n/**\n * Gathers the project-wide facts from `files` (project-relative). Unreadable or\n * unparsable files contribute nothing, as the per-file checks report them.\n */\nexport function collectApiRouteFacts(cwd: string, files: readonly string[]): ApiRouteFacts {\n const clientHiddenNames = new Set<string>();\n let declaresApiRoutes = false;\n\n for (const file of files) {\n if (!/^src\\/.*\\.[cm]?[jt]sx?$/.test(file)) continue;\n\n let sourceFile: ast.SourceFile;\n\n try {\n sourceFile = ast.parseSourceFile(file, readFileSync(path.join(cwd, file), \"utf8\"));\n } catch {\n continue;\n }\n\n for (const name of clientHiddenRouteNamesOf(sourceFile)) clientHiddenNames.add(name);\n if (/^src\\/web\\/.*\\.setup\\.[cm]?[jt]sx?$/.test(file) && declaresApiRoutesOf(sourceFile))\n declaresApiRoutes = true;\n }\n\n return { clientHiddenNames, declaresApiRoutes };\n}\n"],"mappings":";;;;;;AAiBA,MAAa,wBAAuC;CAClD,mCAAmB,IAAI,IAAI;CAC3B,mBAAmB;AACrB;;AAGA,MAAM,8BAAc,IAAI,IAAI,CAAC,WAAW,eAAe,CAAC;;AAGxD,SAAS,eAAe,QAAkB,MAAoC;CAC5E,MAAM,QAAQ,OAAO,MAAM;CAC3B,IAAI,MAAM,SAAS,oBAAoB,OAAO;CAC9C,KAAK,MAAM,YAAY,MAAM,YAAY;EACvC,IAAI,SAAS,SAAS,YAAY;EAClC,IAAIA,QAAY,SAAS,GAAG,MAAM,MAAM,OAAO,SAAS;CAC1D;AAEF;;;;;;;AAQA,SAAgB,yBAAyB,QAAkC;CACzE,MAAM,QAAkB,CAAC;CAEzB,KACE,SACC,SAAS;EACR,IAAI,KAAK,SAAS,kBAAkB;EACpC,KAAK,MAAM,YAAY,KAAK,WAAW;GACrC,MAAM,SAAS,eAAe,UAAU,eAAe;GACvD,MAAM,OAAO,WAAW,SAAY,SAAY,OAAO,MAAM;GAC7D,MAAM,OAAO,cAAc,eAAe,UAAU,MAAM,CAAC;GAC3D,IAAI,MAAM,SAAS,aAAa,KAAK,UAAU,SAAS,SAAS,QAC/D,MAAM,KAAK,IAAI;EACnB;CACF,GACA,IACF;CAEA,OAAO;AACT;;AAGA,SAAgB,oBAAoB,QAAiC;CACnE,IAAI,WAAW;CAEf,KACE,SACC,SAAS;EACR,IAAI,UAAU,OAAO;EACrB,IAAI,KAAK,SAAS,wBAAwBA,QAAY,KAAK,EAAE,MAAM,UAAU;EAC7E,IAAI,KAAK,SAAS,QAAQ,KAAK,SAAS,QAAW;EACnD,IAAI,eAAe,KAAK,MAAM,WAAW,MAAM,QAAW,WAAW;CACvE,GACA,IACF;CAEA,OAAO;AACT;;;;;;AAOA,SAAS,aAAa,MAA+D;CACnF,MAAM,QAAQ,OAAO,IAAI;CACzB,MAAM,UAAU,cAAc,KAAK;CACnC,IAAI,YAAY,QAAW,OAAO;EAAE,MAAM;EAAS,QAAQ;CAAM;CACjE,IAAI,MAAM,SAAS,qBAAqB,MAAM,YAAY,SAAS,GAAG;EACpE,MAAM,OAAO,MAAM,OAAO,EAAE,EAAE,MAAM,UAAU;EAC9C,OAAO,KAAK,SAAS,IAAI;GAAE,MAAM;GAAM,QAAQ;EAAK,IAAI;CAC1D;AAEF;;AAGA,SAAS,gBAAgB,MAAgD;CACvE,MAAM,QAAQ,KAAK,UAAU;CAC7B,IAAI,UAAU,QAAW,OAAO;CAChC,IAAI,WAAW,IAAI,MAAM,WAAW,OAAO;CAC3C,OAAO,eAAe,OAAO,OAAO;AACtC;;;;;;;;;;;;;AAcA,SAAgB,0BACd,QACA,QAAuB,uBACN;CACjB,IAAI,CAAC,+BAA+B,KAAK,OAAO,QAAQ,GAAG,OAAO,CAAC;CACnE,IAAI,kCAAkC,KAAK,OAAO,QAAQ,GAAG,OAAO,CAAC;CAErE,MAAM,WAA4B,CAAC;CAEnC,KACE,SACC,SAAS;EACR,IAAI,KAAK,SAAS,kBAAkB;EACpC,IAAI,CAAC,YAAY,IAAI,WAAW,IAAI,KAAK,EAAE,GAAG;EAC9C,MAAM,WAAW,gBAAgB,IAAI;EACrC,IAAI,aAAa,QAAW;EAC5B,MAAM,SAAS,aAAa,QAAQ;EAEpC,IAAI,WAAW,QAAW;GACxB,IAAI,CAAC,MAAM,mBACT,SAAS,KAAK;IACZ,MAAM;IACN,MAAM;IACN,UAAU;IACV,SACE;GACJ,CAAC;GACH;EACF;EAEA,MAAM,SAAS,CAAC,GAAG,MAAM,iBAAiB,CAAC,CAAC,MAAM,SAChD,OAAO,SAAS,KAAK,WAAW,OAAO,IAAI,IAAI,SAAS,OAAO,IACjE;EAEA,IAAI,WAAW,QACb,SAAS,KAAK;GACZ,MAAM;GACN,MAAM;GACN,UAAU;GACV,SAAS,UAAU,OAAO;EAC5B,CAAC;CACL,GACA,IACF;CAEA,OAAO;AACT;;;;;AAMA,SAAgB,qBAAqB,KAAa,OAAyC;CACzF,MAAM,oCAAoB,IAAI,IAAY;CAC1C,IAAI,oBAAoB;CAExB,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,CAAC,0BAA0B,KAAK,IAAI,GAAG;EAE3C,IAAI;EAEJ,IAAI;GACF,aAAaC,gBAAoB,MAAM,aAAa,KAAK,KAAK,KAAK,IAAI,GAAG,MAAM,CAAC;EACnF,QAAQ;GACN;EACF;EAEA,KAAK,MAAM,QAAQ,yBAAyB,UAAU,GAAG,kBAAkB,IAAI,IAAI;EACnF,IAAI,sCAAsC,KAAK,IAAI,KAAK,oBAAoB,UAAU,GACpF,oBAAoB;CACxB;CAEA,OAAO;EAAE;EAAmB;CAAkB;AAChD"}
@@ -1,5 +1,6 @@
1
1
  import { ToolingProblem } from "../problem.mjs";
2
2
  import { ConventionOptions } from "./web-conventions.mjs";
3
+ import { ApiRouteFacts } from "./api-route-references.mjs";
3
4
  //#region ../../packages/core/src/tooling/check/conventions-step.d.ts
4
5
  /** Matches a project-relative path of a file the framework loads by itself. */
5
6
  export declare function isAutoLoadedFile(relativePath: string): boolean;
@@ -16,18 +17,20 @@ export declare function isAutoLoadedFile(relativePath: string): boolean;
16
17
  * and no `public: true`, a redirect target from request input, sensitive
17
18
  * values in logs, unbounded regexes on request input;
18
19
  * - `WL9010`–`WL9012` warnings: `await log.*()`, a second controller in one
19
- * file, a `kind` discriminator.
20
+ * file, a `kind` discriminator;
21
+ * - `WL9019` (warning) and `WL9020` (error) the browser route references
22
+ * (see `apiRouteReferenceFindings`).
20
23
  *
21
24
  * A `// warlock-check-ignore WL9006` comment on the problem's line or the line
22
25
  * above silences that code there; without codes it silences every one.
23
26
  */
24
- export declare function checkFileConventions(relativePath: string, source: string, cwd: string, options?: ConventionOptions): ToolingProblem[];
27
+ export declare function checkFileConventions(relativePath: string, source: string, cwd: string, options?: ConventionOptions, apiRouteFacts?: ApiRouteFacts): ToolingProblem[];
25
28
  /** `WL9004` when the project tsconfig turns on legacy decorators. */
26
29
  export declare function checkTsconfigConventions(cwd: string, project?: string): ToolingProblem[];
27
30
  /**
28
31
  * Run every convention check over `files` (project-relative) plus the
29
32
  * project tsconfig.
30
33
  */
31
- export declare function runConventions(cwd: string, files: readonly string[], project?: string): ToolingProblem[];
34
+ export declare function runConventions(cwd: string, files: readonly string[], project?: string, apiRouteFacts?: ApiRouteFacts): ToolingProblem[];
32
35
  //#endregion
33
36
  //# sourceMappingURL=conventions-step.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"conventions-step.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/conventions-step.ts"],"mappings":";;;;wBAoBgB,iBAAiB;;;;;;;;;;;;;;;;;;;wBAuKjB,qBACd,sBACA,gBACA,aACA,UAAS,oBACR;;wBAgCa,yBAAyB,aAAa,mBAA4B;;;;;wBAyClE,eACd,aACA,0BACA,mBACC"}
1
+ {"version":3,"file":"conventions-step.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/conventions-step.ts"],"mappings":";;;;;wBA2BgB,iBAAiB;;;;;;;;;;;;;;;;;;;;;wBAyKjB,qBACd,sBACA,gBACA,aACA,UAAS,mBACT,gBAAe,gBACd;;wBAkCa,yBAAyB,aAAa,mBAA4B;;;;;wBAyClE,eACd,aACA,0BACA,kBACA,gBAAe,gBACd"}
@@ -7,6 +7,8 @@ import { positionOf } from "./source-ast.mjs";
7
7
  import { codeStyleFindings } from "./code-style-conventions.mjs";
8
8
  import { securityFindings } from "./security-conventions.mjs";
9
9
  import { responseSchemaFindings, webFindings } from "./web-conventions.mjs";
10
+ import { EMPTY_API_ROUTE_FACTS, apiRouteReferenceFindings, collectApiRouteFacts } from "./api-route-references.mjs";
11
+ import { translationProblems } from "./translation-conventions.mjs";
10
12
  import path from "node:path";
11
13
  import { existsSync, readFileSync } from "node:fs";
12
14
  import { parse } from "jsonc-parser";
@@ -123,19 +125,26 @@ function findingProblem(sourceFile, relativePath, finding) {
123
125
  * and no `public: true`, a redirect target from request input, sensitive
124
126
  * values in logs, unbounded regexes on request input;
125
127
  * - `WL9010`–`WL9012` warnings: `await log.*()`, a second controller in one
126
- * file, a `kind` discriminator.
128
+ * file, a `kind` discriminator;
129
+ * - `WL9019` (warning) and `WL9020` (error) the browser route references
130
+ * (see `apiRouteReferenceFindings`).
127
131
  *
128
132
  * A `// warlock-check-ignore WL9006` comment on the problem's line or the line
129
133
  * above silences that code there; without codes it silences every one.
130
134
  */
131
- function checkFileConventions(relativePath, source, cwd, options = {}) {
135
+ function checkFileConventions(relativePath, source, cwd, options = {}, apiRouteFacts = EMPTY_API_ROUTE_FACTS) {
132
136
  const sourceFile = parseSourceFile(relativePath, source);
133
- const problems = [...importProblems(sourceFile, relativePath, cwd), ...[
134
- ...securityFindings(sourceFile),
135
- ...codeStyleFindings(sourceFile),
136
- ...webFindings(sourceFile, options),
137
- ...responseSchemaFindings(sourceFile)
138
- ].map((finding) => findingProblem(sourceFile, relativePath, finding))];
137
+ const problems = [
138
+ ...importProblems(sourceFile, relativePath, cwd),
139
+ ...translationProblems(relativePath, source),
140
+ ...[
141
+ ...securityFindings(sourceFile),
142
+ ...codeStyleFindings(sourceFile),
143
+ ...webFindings(sourceFile, options),
144
+ ...responseSchemaFindings(sourceFile),
145
+ ...apiRouteReferenceFindings(sourceFile, apiRouteFacts)
146
+ ].map((finding) => findingProblem(sourceFile, relativePath, finding))
147
+ ];
139
148
  const lines = source.split(/\r?\n/);
140
149
  return problems.filter((problem) => options.rules?.[problem.code] !== "off").map((problem) => ({
141
150
  ...problem,
@@ -167,7 +176,7 @@ function checkTsconfigConventions(cwd, project = "tsconfig.json") {
167
176
  * Run every convention check over `files` (project-relative) plus the
168
177
  * project tsconfig.
169
178
  */
170
- function runConventions(cwd, files, project) {
179
+ function runConventions(cwd, files, project, apiRouteFacts = collectApiRouteFacts(cwd, files)) {
171
180
  const options = readConventionOptions(cwd);
172
181
  const problems = checkTsconfigConventions(cwd, project).filter((problem) => options.rules?.[problem.code] !== "off").map((problem) => {
173
182
  const severity = options.rules?.[problem.code];
@@ -183,7 +192,7 @@ function runConventions(cwd, files, project) {
183
192
  } catch {
184
193
  continue;
185
194
  }
186
- problems.push(...checkFileConventions(file, source, cwd, options));
195
+ problems.push(...checkFileConventions(file, source, cwd, options, apiRouteFacts));
187
196
  }
188
197
  return problems;
189
198
  }
@@ -1 +1 @@
1
- {"version":3,"file":"conventions-step.mjs","names":["ast.parseSourceFile"],"sources":["../../../../../../../../../packages/core/src/tooling/check/conventions-step.ts"],"sourcesContent":["import { isWarlockErrorCode } from \"../../errors/error-codes\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { parse } from \"jsonc-parser\";\nimport { importSpecifiers } from \"../syntax/imports\";\nimport * as ast from \"../syntax/ast\";\nimport { createProblem, type ToolingProblem } from \"../problem\";\nimport { importFixes } from \"./fixes\";\nimport { codeStyleFindings } from \"./code-style-conventions\";\nimport { securityFindings } from \"./security-conventions\";\nimport { webFindings, responseSchemaFindings, type ConventionOptions } from \"./web-conventions\";\nimport { positionOf, type SourceFinding } from \"./source-ast\";\n\n/** Extensions tried when resolving an extension-less import. */\nconst RESOLVE_EXTENSIONS = [\".ts\", \".tsx\", \".js\", \".jsx\", \"/index.ts\", \"/index.tsx\"];\n\n/** Test files may import anything on purpose. */\nconst TEST_FILE = /\\.(?:test|spec)\\.[cm]?[jt]sx?$/i;\n\n/** Matches a project-relative path of a file the framework loads by itself. */\nexport function isAutoLoadedFile(relativePath: string): boolean {\n return (\n /^src\\/app\\/main\\.tsx?$/.test(relativePath) ||\n /^src\\/app\\/[^/]+\\/(?:routes|main|worker)\\.tsx?$/.test(relativePath) ||\n /^src\\/app\\/.+\\/events\\/[^/]+\\.tsx?$/.test(relativePath)\n );\n}\n\n/** The module folder (`src/app/<module>`) a project-relative path lives in, if any. */\nfunction moduleOf(relativePath: string): string | undefined {\n return /^src\\/app\\/([^/]+)\\//.exec(relativePath)?.[1];\n}\n\nfunction toPosix(file: string): string {\n return file.split(path.sep).join(\"/\");\n}\n\n/** Resolve a relative or `app/...` specifier to a project-relative file, if it exists. */\nexport function resolveSpecifier(\n specifier: string,\n importer: string,\n cwd: string,\n): string | undefined {\n let base: string;\n\n if (specifier.startsWith(\".\")) {\n base = path.resolve(path.dirname(path.join(cwd, importer)), specifier);\n } else if (specifier.startsWith(\"app/\")) {\n base = path.join(cwd, \"src\", specifier);\n } else {\n return undefined;\n }\n\n const candidates = [base, ...RESOLVE_EXTENSIONS.map((extension) => `${base}${extension}`)];\n const withoutJs = base.replace(/\\.jsx?$/, \"\");\n\n if (withoutJs !== base) {\n candidates.push(...RESOLVE_EXTENSIONS.map((extension) => `${withoutJs}${extension}`));\n }\n\n const found = candidates.find(\n (candidate) => existsSync(candidate) && path.extname(candidate) !== \"\",\n );\n\n return found ? toPosix(path.relative(cwd, found)) : undefined;\n}\n\n/** Every module specifier a source file imports, with its position. */\nfunction importsOf(sourceFile: ast.SourceFile): { specifier: string; node: ast.Node }[] {\n return importSpecifiers(sourceFile.fileName, sourceFile.text);\n}\n\n/** `// warlock-check-ignore [WLxxxx …]` on a problem's line or the line above it. */\nconst IGNORE_COMMENT = /\\/[/*]\\s*warlock-check-ignore\\b(.*)/;\n\n/** Whether an ignore comment on `line` (1-based) or the line before covers `code`. */\nfunction isIgnored(lines: readonly string[], line: number, code: string): boolean {\n for (const text of [lines[line - 1], lines[line - 2]]) {\n const match = text === undefined ? null : IGNORE_COMMENT.exec(text);\n\n if (!match) continue;\n\n const codes = match[1]?.match(/WL\\d{4}/g);\n\n if (!codes || codes.includes(code)) return true;\n }\n\n return false;\n}\n\n/** The import conventions (`WL9001`–`WL9003`) of one parsed file. */\nfunction importProblems(\n sourceFile: ast.SourceFile,\n relativePath: string,\n cwd: string,\n): ToolingProblem[] {\n const problems: ToolingProblem[] = [];\n const fixes = importFixes(relativePath, sourceFile.text);\n const importerModule = moduleOf(relativePath);\n\n for (const { specifier, node } of importsOf(sourceFile)) {\n const at = { file: relativePath, ...positionOf(sourceFile, node) };\n\n if (specifier.startsWith(\".\") && /\\.jsx?$/.test(specifier)) {\n problems.push(\n createProblem({\n code: \"WL9002\",\n fix: fixes.find((fix) => fix.end === node.end - 1),\n source: \"convention\",\n message: `Import \"${specifier}\" ends in a .js extension.`,\n ...at,\n }),\n );\n }\n\n const target = resolveSpecifier(specifier, relativePath, cwd);\n\n if (!target) continue;\n\n if (!TEST_FILE.test(relativePath) && isAutoLoadedFile(target)) {\n problems.push(\n createProblem({\n code: \"WL9001\",\n source: \"convention\",\n message: `\"${specifier}\" imports ${target}, which the framework loads by itself.`,\n ...at,\n }),\n );\n }\n\n const targetModule = moduleOf(target);\n\n if (\n specifier.startsWith(\".\") &&\n importerModule !== undefined &&\n targetModule !== undefined &&\n importerModule !== targetModule\n ) {\n const alias = `app/${target.replace(/^src\\/app\\//, \"\").replace(/\\.[cm]?[jt]sx?$/, \"\")}`;\n\n problems.push(\n createProblem({\n code: \"WL9003\",\n source: \"convention\",\n message: `\"${specifier}\" reaches from module \"${importerModule}\" into \"${targetModule}\"; import \"${alias}\" instead.`,\n ...at,\n }),\n );\n }\n }\n\n return problems;\n}\n\nfunction findingProblem(\n sourceFile: ast.SourceFile,\n relativePath: string,\n finding: SourceFinding,\n): ToolingProblem {\n return createProblem({\n code: finding.code,\n source: \"convention\",\n severity: finding.severity ?? \"error\",\n message: finding.message,\n file: relativePath,\n ...positionOf(sourceFile, finding.node),\n });\n}\n\n/**\n * The convention problems in one source file, in source order:\n *\n * - `WL9001` an import of a file the framework loads by itself (`routes.ts`,\n * `main.ts`, `worker.ts`, `events/*`) — importing it registers it twice;\n * - `WL9002` a relative import ending in `.js`;\n * - `WL9003` a relative import from one `src/app/<module>` into another, which\n * should use the `app/<module>/...` alias;\n * - `WL9005`–`WL9009` the security conventions (see `securityFindings`):\n * `request.all()` into a query or service, a mutating route with no guard\n * and no `public: true`, a redirect target from request input, sensitive\n * values in logs, unbounded regexes on request input;\n * - `WL9010`–`WL9012` warnings: `await log.*()`, a second controller in one\n * file, a `kind` discriminator.\n *\n * A `// warlock-check-ignore WL9006` comment on the problem's line or the line\n * above silences that code there; without codes it silences every one.\n */\nexport function checkFileConventions(\n relativePath: string,\n source: string,\n cwd: string,\n options: ConventionOptions = {},\n): ToolingProblem[] {\n const sourceFile = ast.parseSourceFile(relativePath, source);\n const problems = [\n ...importProblems(sourceFile, relativePath, cwd),\n ...[\n ...securityFindings(sourceFile),\n ...codeStyleFindings(sourceFile),\n ...webFindings(sourceFile, options),\n ...responseSchemaFindings(sourceFile),\n ].map((finding) => findingProblem(sourceFile, relativePath, finding)),\n ];\n const lines = source.split(/\\r?\\n/);\n\n return problems\n .filter((problem) => options.rules?.[problem.code] !== \"off\")\n .map((problem) => ({\n ...problem,\n severity:\n options.rules?.[problem.code] === \"error\"\n ? (\"error\" as const)\n : options.rules?.[problem.code] === \"warning\"\n ? (\"warning\" as const)\n : problem.severity,\n }))\n .filter((problem) => problem.line === null || !isIgnored(lines, problem.line, problem.code))\n .sort(\n (first, second) =>\n (first.line ?? 0) - (second.line ?? 0) || (first.column ?? 0) - (second.column ?? 0),\n );\n}\n\n/** `WL9004` when the project tsconfig turns on legacy decorators. */\nexport function checkTsconfigConventions(cwd: string, project = \"tsconfig.json\"): ToolingProblem[] {\n const file = path.join(cwd, project);\n\n if (!existsSync(file)) return [];\n\n const text = readFileSync(file, \"utf8\");\n const config: unknown = parse(text);\n const options: unknown =\n typeof config === \"object\" && config !== null\n ? Reflect.get(config, \"compilerOptions\")\n : undefined;\n\n if (\n typeof options !== \"object\" ||\n options === null ||\n Reflect.get(options, \"experimentalDecorators\") !== true\n ) {\n return [];\n }\n\n const index = text.indexOf(\"experimentalDecorators\");\n const before = index === -1 ? \"\" : text.slice(0, index);\n const line = index === -1 ? null : before.split(\"\\n\").length;\n const column = index === -1 ? null : index - before.lastIndexOf(\"\\n\");\n\n return [\n createProblem({\n code: \"WL9004\",\n source: \"convention\",\n message: '\"experimentalDecorators\": true switches TypeScript to legacy decorators.',\n file: toPosix(project),\n line,\n column,\n }),\n ];\n}\n\n/**\n * Run every convention check over `files` (project-relative) plus the\n * project tsconfig.\n */\nexport function runConventions(\n cwd: string,\n files: readonly string[],\n project?: string,\n): ToolingProblem[] {\n const options = readConventionOptions(cwd);\n const problems = checkTsconfigConventions(cwd, project)\n .filter((problem) => options.rules?.[problem.code] !== \"off\")\n .map((problem) => {\n const severity = options.rules?.[problem.code];\n return severity === \"error\" || severity === \"warning\" ? { ...problem, severity } : problem;\n });\n\n for (const file of files) {\n let source: string;\n\n try {\n source = readFileSync(path.join(cwd, file), \"utf8\");\n } catch {\n continue;\n }\n\n problems.push(...checkFileConventions(file, source, cwd, options));\n }\n\n return problems;\n}\n\n/** Optional JSON configuration; malformed configuration is reported by the check step. */\nexport function readConventionOptions(cwd: string): ConventionOptions {\n const file = path.join(cwd, \".warlock-check.json\");\n if (!existsSync(file)) return {};\n const value: unknown = JSON.parse(readFileSync(file, \"utf8\"));\n if (!value || typeof value !== \"object\")\n throw new Error(\".warlock-check.json must be an object.\");\n const limit: unknown = Reflect.get(value, \"componentLineLimit\");\n if (limit !== undefined && (typeof limit !== \"number\" || !Number.isInteger(limit) || limit < 1))\n throw new Error(\"componentLineLimit must be a positive integer.\");\n const rules: unknown = Reflect.get(value, \"rules\");\n const overrides: NonNullable<ConventionOptions[\"rules\"]> = {};\n if (rules !== undefined) {\n if (!rules || typeof rules !== \"object\" || Array.isArray(rules))\n throw new Error(\"rules must be an object.\");\n for (const [code, severity] of Object.entries(rules)) {\n if (\n !isWarlockErrorCode(code) ||\n (severity !== \"off\" && severity !== \"warning\" && severity !== \"error\")\n )\n throw new Error(\"rules must map registered WL codes to off, warning or error.\");\n overrides[code] = severity;\n }\n }\n return {\n ...(typeof limit === \"number\" ? { componentLineLimit: limit } : {}),\n rules: overrides,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"conventions-step.mjs","names":["ast.parseSourceFile"],"sources":["../../../../../../../../../packages/core/src/tooling/check/conventions-step.ts"],"sourcesContent":["import { isWarlockErrorCode } from \"../../errors/error-codes\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { parse } from \"jsonc-parser\";\nimport { importSpecifiers } from \"../syntax/imports\";\nimport * as ast from \"../syntax/ast\";\nimport { createProblem, type ToolingProblem } from \"../problem\";\nimport { importFixes } from \"./fixes\";\nimport { codeStyleFindings } from \"./code-style-conventions\";\nimport { securityFindings } from \"./security-conventions\";\nimport { webFindings, responseSchemaFindings, type ConventionOptions } from \"./web-conventions\";\nimport {\n apiRouteReferenceFindings,\n collectApiRouteFacts,\n EMPTY_API_ROUTE_FACTS,\n type ApiRouteFacts,\n} from \"./api-route-references\";\nimport { translationProblems } from \"./translation-conventions\";\nimport { positionOf, type SourceFinding } from \"./source-ast\";\n\n/** Extensions tried when resolving an extension-less import. */\nconst RESOLVE_EXTENSIONS = [\".ts\", \".tsx\", \".js\", \".jsx\", \"/index.ts\", \"/index.tsx\"];\n\n/** Test files may import anything on purpose. */\nconst TEST_FILE = /\\.(?:test|spec)\\.[cm]?[jt]sx?$/i;\n\n/** Matches a project-relative path of a file the framework loads by itself. */\nexport function isAutoLoadedFile(relativePath: string): boolean {\n return (\n /^src\\/app\\/main\\.tsx?$/.test(relativePath) ||\n /^src\\/app\\/[^/]+\\/(?:routes|main|worker)\\.tsx?$/.test(relativePath) ||\n /^src\\/app\\/.+\\/events\\/[^/]+\\.tsx?$/.test(relativePath)\n );\n}\n\n/** The module folder (`src/app/<module>`) a project-relative path lives in, if any. */\nfunction moduleOf(relativePath: string): string | undefined {\n return /^src\\/app\\/([^/]+)\\//.exec(relativePath)?.[1];\n}\n\nfunction toPosix(file: string): string {\n return file.split(path.sep).join(\"/\");\n}\n\n/** Resolve a relative or `app/...` specifier to a project-relative file, if it exists. */\nexport function resolveSpecifier(\n specifier: string,\n importer: string,\n cwd: string,\n): string | undefined {\n let base: string;\n\n if (specifier.startsWith(\".\")) {\n base = path.resolve(path.dirname(path.join(cwd, importer)), specifier);\n } else if (specifier.startsWith(\"app/\")) {\n base = path.join(cwd, \"src\", specifier);\n } else {\n return undefined;\n }\n\n const candidates = [base, ...RESOLVE_EXTENSIONS.map((extension) => `${base}${extension}`)];\n const withoutJs = base.replace(/\\.jsx?$/, \"\");\n\n if (withoutJs !== base) {\n candidates.push(...RESOLVE_EXTENSIONS.map((extension) => `${withoutJs}${extension}`));\n }\n\n const found = candidates.find(\n (candidate) => existsSync(candidate) && path.extname(candidate) !== \"\",\n );\n\n return found ? toPosix(path.relative(cwd, found)) : undefined;\n}\n\n/** Every module specifier a source file imports, with its position. */\nfunction importsOf(sourceFile: ast.SourceFile): { specifier: string; node: ast.Node }[] {\n return importSpecifiers(sourceFile.fileName, sourceFile.text);\n}\n\n/** `// warlock-check-ignore [WLxxxx …]` on a problem's line or the line above it. */\nconst IGNORE_COMMENT = /\\/[/*]\\s*warlock-check-ignore\\b(.*)/;\n\n/** Whether an ignore comment on `line` (1-based) or the line before covers `code`. */\nfunction isIgnored(lines: readonly string[], line: number, code: string): boolean {\n for (const text of [lines[line - 1], lines[line - 2]]) {\n const match = text === undefined ? null : IGNORE_COMMENT.exec(text);\n\n if (!match) continue;\n\n const codes = match[1]?.match(/WL\\d{4}/g);\n\n if (!codes || codes.includes(code)) return true;\n }\n\n return false;\n}\n\n/** The import conventions (`WL9001`–`WL9003`) of one parsed file. */\nfunction importProblems(\n sourceFile: ast.SourceFile,\n relativePath: string,\n cwd: string,\n): ToolingProblem[] {\n const problems: ToolingProblem[] = [];\n const fixes = importFixes(relativePath, sourceFile.text);\n const importerModule = moduleOf(relativePath);\n\n for (const { specifier, node } of importsOf(sourceFile)) {\n const at = { file: relativePath, ...positionOf(sourceFile, node) };\n\n if (specifier.startsWith(\".\") && /\\.jsx?$/.test(specifier)) {\n problems.push(\n createProblem({\n code: \"WL9002\",\n fix: fixes.find((fix) => fix.end === node.end - 1),\n source: \"convention\",\n message: `Import \"${specifier}\" ends in a .js extension.`,\n ...at,\n }),\n );\n }\n\n const target = resolveSpecifier(specifier, relativePath, cwd);\n\n if (!target) continue;\n\n if (!TEST_FILE.test(relativePath) && isAutoLoadedFile(target)) {\n problems.push(\n createProblem({\n code: \"WL9001\",\n source: \"convention\",\n message: `\"${specifier}\" imports ${target}, which the framework loads by itself.`,\n ...at,\n }),\n );\n }\n\n const targetModule = moduleOf(target);\n\n if (\n specifier.startsWith(\".\") &&\n importerModule !== undefined &&\n targetModule !== undefined &&\n importerModule !== targetModule\n ) {\n const alias = `app/${target.replace(/^src\\/app\\//, \"\").replace(/\\.[cm]?[jt]sx?$/, \"\")}`;\n\n problems.push(\n createProblem({\n code: \"WL9003\",\n source: \"convention\",\n message: `\"${specifier}\" reaches from module \"${importerModule}\" into \"${targetModule}\"; import \"${alias}\" instead.`,\n ...at,\n }),\n );\n }\n }\n\n return problems;\n}\n\nfunction findingProblem(\n sourceFile: ast.SourceFile,\n relativePath: string,\n finding: SourceFinding,\n): ToolingProblem {\n return createProblem({\n code: finding.code,\n source: \"convention\",\n severity: finding.severity ?? \"error\",\n message: finding.message,\n file: relativePath,\n ...positionOf(sourceFile, finding.node),\n });\n}\n\n/**\n * The convention problems in one source file, in source order:\n *\n * - `WL9001` an import of a file the framework loads by itself (`routes.ts`,\n * `main.ts`, `worker.ts`, `events/*`) — importing it registers it twice;\n * - `WL9002` a relative import ending in `.js`;\n * - `WL9003` a relative import from one `src/app/<module>` into another, which\n * should use the `app/<module>/...` alias;\n * - `WL9005`–`WL9009` the security conventions (see `securityFindings`):\n * `request.all()` into a query or service, a mutating route with no guard\n * and no `public: true`, a redirect target from request input, sensitive\n * values in logs, unbounded regexes on request input;\n * - `WL9010`–`WL9012` warnings: `await log.*()`, a second controller in one\n * file, a `kind` discriminator;\n * - `WL9019` (warning) and `WL9020` (error) the browser route references\n * (see `apiRouteReferenceFindings`).\n *\n * A `// warlock-check-ignore WL9006` comment on the problem's line or the line\n * above silences that code there; without codes it silences every one.\n */\nexport function checkFileConventions(\n relativePath: string,\n source: string,\n cwd: string,\n options: ConventionOptions = {},\n apiRouteFacts: ApiRouteFacts = EMPTY_API_ROUTE_FACTS,\n): ToolingProblem[] {\n const sourceFile = ast.parseSourceFile(relativePath, source);\n const problems = [\n ...importProblems(sourceFile, relativePath, cwd),\n ...translationProblems(relativePath, source),\n ...[\n ...securityFindings(sourceFile),\n ...codeStyleFindings(sourceFile),\n ...webFindings(sourceFile, options),\n ...responseSchemaFindings(sourceFile),\n ...apiRouteReferenceFindings(sourceFile, apiRouteFacts),\n ].map((finding) => findingProblem(sourceFile, relativePath, finding)),\n ];\n const lines = source.split(/\\r?\\n/);\n\n return problems\n .filter((problem) => options.rules?.[problem.code] !== \"off\")\n .map((problem) => ({\n ...problem,\n severity:\n options.rules?.[problem.code] === \"error\"\n ? (\"error\" as const)\n : options.rules?.[problem.code] === \"warning\"\n ? (\"warning\" as const)\n : problem.severity,\n }))\n .filter((problem) => problem.line === null || !isIgnored(lines, problem.line, problem.code))\n .sort(\n (first, second) =>\n (first.line ?? 0) - (second.line ?? 0) || (first.column ?? 0) - (second.column ?? 0),\n );\n}\n\n/** `WL9004` when the project tsconfig turns on legacy decorators. */\nexport function checkTsconfigConventions(cwd: string, project = \"tsconfig.json\"): ToolingProblem[] {\n const file = path.join(cwd, project);\n\n if (!existsSync(file)) return [];\n\n const text = readFileSync(file, \"utf8\");\n const config: unknown = parse(text);\n const options: unknown =\n typeof config === \"object\" && config !== null\n ? Reflect.get(config, \"compilerOptions\")\n : undefined;\n\n if (\n typeof options !== \"object\" ||\n options === null ||\n Reflect.get(options, \"experimentalDecorators\") !== true\n ) {\n return [];\n }\n\n const index = text.indexOf(\"experimentalDecorators\");\n const before = index === -1 ? \"\" : text.slice(0, index);\n const line = index === -1 ? null : before.split(\"\\n\").length;\n const column = index === -1 ? null : index - before.lastIndexOf(\"\\n\");\n\n return [\n createProblem({\n code: \"WL9004\",\n source: \"convention\",\n message: '\"experimentalDecorators\": true switches TypeScript to legacy decorators.',\n file: toPosix(project),\n line,\n column,\n }),\n ];\n}\n\n/**\n * Run every convention check over `files` (project-relative) plus the\n * project tsconfig.\n */\nexport function runConventions(\n cwd: string,\n files: readonly string[],\n project?: string,\n apiRouteFacts: ApiRouteFacts = collectApiRouteFacts(cwd, files),\n): ToolingProblem[] {\n const options = readConventionOptions(cwd);\n const problems = checkTsconfigConventions(cwd, project)\n .filter((problem) => options.rules?.[problem.code] !== \"off\")\n .map((problem) => {\n const severity = options.rules?.[problem.code];\n return severity === \"error\" || severity === \"warning\" ? { ...problem, severity } : problem;\n });\n\n for (const file of files) {\n let source: string;\n\n try {\n source = readFileSync(path.join(cwd, file), \"utf8\");\n } catch {\n continue;\n }\n\n problems.push(...checkFileConventions(file, source, cwd, options, apiRouteFacts));\n }\n\n return problems;\n}\n\n/** Optional JSON configuration; malformed configuration is reported by the check step. */\nexport function readConventionOptions(cwd: string): ConventionOptions {\n const file = path.join(cwd, \".warlock-check.json\");\n if (!existsSync(file)) return {};\n const value: unknown = JSON.parse(readFileSync(file, \"utf8\"));\n if (!value || typeof value !== \"object\")\n throw new Error(\".warlock-check.json must be an object.\");\n const limit: unknown = Reflect.get(value, \"componentLineLimit\");\n if (limit !== undefined && (typeof limit !== \"number\" || !Number.isInteger(limit) || limit < 1))\n throw new Error(\"componentLineLimit must be a positive integer.\");\n const rules: unknown = Reflect.get(value, \"rules\");\n const overrides: NonNullable<ConventionOptions[\"rules\"]> = {};\n if (rules !== undefined) {\n if (!rules || typeof rules !== \"object\" || Array.isArray(rules))\n throw new Error(\"rules must be an object.\");\n for (const [code, severity] of Object.entries(rules)) {\n if (\n !isWarlockErrorCode(code) ||\n (severity !== \"off\" && severity !== \"warning\" && severity !== \"error\")\n )\n throw new Error(\"rules must map registered WL codes to off, warning or error.\");\n overrides[code] = severity;\n }\n }\n return {\n ...(typeof limit === \"number\" ? { componentLineLimit: limit } : {}),\n rules: overrides,\n };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"run-check.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/run-check.ts"],"mappings":";;;;;;YAgCY,mBACV,SAAS,qBACN,QAAQ,mBAAmB;;YAGpB;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA,gBAAgB;;EAEhB;;EAEA,MAAM;;EAEN,YAAY;;EAEZ,SAAS;;EAET;;EAEA;;;;;;;;;;;;wBA4BoB,SAAS,UAAS,eAAoB,QAAQ"}
1
+ {"version":3,"file":"run-check.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/run-check.ts"],"mappings":";;;;;;YAiCY,mBACV,SAAS,qBACN,QAAQ,mBAAmB;;YAGpB;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA,gBAAgB;;EAEhB;;EAEA,MAAM;;EAEN,YAAY;;EAEZ,SAAS;;EAET;;EAEA;;;;;;;;;;;;wBA4BoB,SAAS,UAAS,eAAoB,QAAQ"}
@@ -1,6 +1,7 @@
1
1
  import { CHECK_STEPS, checkExitCode } from "./check-report.types.mjs";
2
2
  import { createProblem } from "../problem.mjs";
3
3
  import { applyConventionFixes } from "./fixes.mjs";
4
+ import { collectApiRouteFacts } from "./api-route-references.mjs";
4
5
  import { runConventions } from "./conventions-step.mjs";
5
6
  import { listSourceFiles, listTestFiles } from "./source-files.mjs";
6
7
  import { affectedFiles } from "./affected-files.mjs";
@@ -106,7 +107,8 @@ async function runCheck(options = {}) {
106
107
  });
107
108
  const targets = async () => files ?? await listSourceFiles(cwd);
108
109
  if (options.fix && !filesBlocked && !skip.has("conventions")) try {
109
- applyConventionFixes(cwd, runConventions(cwd, await targets(), options.project));
110
+ const fixTargets = await targets();
111
+ applyConventionFixes(cwd, runConventions(cwd, fixTargets, options.project, collectApiRouteFacts(cwd, await listSourceFiles(cwd))));
110
112
  } catch (error) {
111
113
  problems.push(createProblem({
112
114
  code: "WL8007",
@@ -182,7 +184,7 @@ async function runCheck(options = {}) {
182
184
  const conventionFiles = await targets();
183
185
  return {
184
186
  detail: `${conventionFiles.length} file(s) checked`,
185
- problems: runConventions(cwd, conventionFiles, options.project)
187
+ problems: runConventions(cwd, conventionFiles, options.project, collectApiRouteFacts(cwd, await listSourceFiles(cwd)))
186
188
  };
187
189
  });
188
190
  steps.sort((first, second) => CHECK_STEPS.indexOf(first.name) - CHECK_STEPS.indexOf(second.name));