@warlock.js/core 5.34.3 → 5.36.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 (281) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/esm/cli/cli-commands.manager.mjs +1 -1
  3. package/esm/cli/cli-commands.manager.mjs.map +1 -1
  4. package/esm/cli/commands/add.command.mjs +1 -0
  5. package/esm/cli/commands/add.command.mjs.map +1 -1
  6. package/esm/cli/commands/build.command.mjs +30 -3
  7. package/esm/cli/commands/build.command.mjs.map +1 -1
  8. package/esm/cli/commands/check/check-command.action.mjs +1 -0
  9. package/esm/cli/commands/check/check-command.action.mjs.map +1 -1
  10. package/esm/cli/commands/check.command.mjs +5 -0
  11. package/esm/cli/commands/check.command.mjs.map +1 -1
  12. package/esm/cli/commands/explain/explain-command.action.mjs +21 -0
  13. package/esm/cli/commands/explain/explain-command.action.mjs.map +1 -1
  14. package/esm/cli/commands/explain.command.mjs +14 -4
  15. package/esm/cli/commands/explain.command.mjs.map +1 -1
  16. package/esm/cli/commands/generate/generate.command.mjs +19 -1
  17. package/esm/cli/commands/generate/generate.command.mjs.map +1 -1
  18. package/esm/cli/commands/generate/generators/endpoint.generator.mjs +1 -1
  19. package/esm/cli/commands/generate/generators/endpoint.generator.mjs.map +1 -1
  20. package/esm/cli/commands/generate/generators/web.generator.mjs +169 -0
  21. package/esm/cli/commands/generate/generators/web.generator.mjs.map +1 -0
  22. package/esm/cli/commands/generate/templates/web-stubs.mjs +231 -0
  23. package/esm/cli/commands/generate/templates/web-stubs.mjs.map +1 -0
  24. package/esm/cli/commands/inspect/inspect-command.action.mjs +4 -3
  25. package/esm/cli/commands/inspect/inspect-command.action.mjs.map +1 -1
  26. package/esm/cli/commands/inspect.command.mjs +13 -8
  27. package/esm/cli/commands/inspect.command.mjs.map +1 -1
  28. package/esm/cli/commands/mcp/mcp-command.action.mjs +1 -1
  29. package/esm/cli/commands/mcp/mcp-command.action.mjs.map +1 -1
  30. package/esm/config/config-loader.d.mts.map +1 -1
  31. package/esm/config/config-loader.mjs +2 -0
  32. package/esm/config/config-loader.mjs.map +1 -1
  33. package/esm/config/load-config-files.mjs +4 -0
  34. package/esm/config/load-config-files.mjs.map +1 -1
  35. package/esm/dev-server/file-event-handler.mjs +11 -1
  36. package/esm/dev-server/file-event-handler.mjs.map +1 -1
  37. package/esm/dev-server/file-manager.d.mts +1 -1
  38. package/esm/dev-server/file-manager.d.mts.map +1 -1
  39. package/esm/dev-server/file-manager.mjs.map +1 -1
  40. package/esm/dev-server/files-orchestrator.mjs +16 -1
  41. package/esm/dev-server/files-orchestrator.mjs.map +1 -1
  42. package/esm/dev-server/files-watcher.mjs +13 -4
  43. package/esm/dev-server/files-watcher.mjs.map +1 -1
  44. package/esm/dev-server/loader/loader-port-acks.mjs +21 -9
  45. package/esm/dev-server/loader/loader-port-acks.mjs.map +1 -1
  46. package/esm/dev-server/loader/register-loader.mjs +31 -12
  47. package/esm/dev-server/loader/register-loader.mjs.map +1 -1
  48. package/esm/dev-server/module-loader.mjs +15 -1
  49. package/esm/dev-server/module-loader.mjs.map +1 -1
  50. package/esm/dev-server/parse-imports.mjs +1 -1
  51. package/esm/dev-server/special-file-patterns.mjs +4 -1
  52. package/esm/dev-server/special-file-patterns.mjs.map +1 -1
  53. package/esm/dev-server/type-generator.mjs +1 -1
  54. package/esm/e2e/cdp.d.mts.map +1 -1
  55. package/esm/e2e/cdp.mjs +9 -2
  56. package/esm/e2e/cdp.mjs.map +1 -1
  57. package/esm/e2e/index.d.mts +2 -2
  58. package/esm/e2e/page.d.mts +14 -1
  59. package/esm/e2e/page.d.mts.map +1 -1
  60. package/esm/e2e/page.mjs +81 -8
  61. package/esm/e2e/page.mjs.map +1 -1
  62. package/esm/e2e/types.d.mts +49 -0
  63. package/esm/e2e/types.d.mts.map +1 -1
  64. package/esm/e2e/types.mjs.map +1 -1
  65. package/esm/errors/error-codes.d.mts +72 -0
  66. package/esm/errors/error-codes.d.mts.map +1 -1
  67. package/esm/errors/error-codes.mjs +72 -0
  68. package/esm/errors/error-codes.mjs.map +1 -1
  69. package/esm/errors/index.d.mts +2 -2
  70. package/esm/errors/index.mjs +2 -2
  71. package/esm/generations/add-command.action.mjs +2 -0
  72. package/esm/generations/add-command.action.mjs.map +1 -1
  73. package/esm/generations/design-system-payload.mjs +53 -0
  74. package/esm/generations/design-system-payload.mjs.map +1 -0
  75. package/esm/generations/features/design-system.feature.mjs +38 -0
  76. package/esm/generations/features/design-system.feature.mjs.map +1 -0
  77. package/esm/generations/features/index.mjs +3 -1
  78. package/esm/generations/features/index.mjs.map +1 -1
  79. package/esm/generations/features/notifications.feature.mjs +13 -2
  80. package/esm/generations/features/notifications.feature.mjs.map +1 -1
  81. package/esm/generations/features/test.feature.mjs +48 -13
  82. package/esm/generations/features/test.feature.mjs.map +1 -1
  83. package/esm/generations/features/web.feature.mjs +3 -3
  84. package/esm/generations/features/web.feature.mjs.map +1 -1
  85. package/esm/generations/stubs.mjs +9 -5
  86. package/esm/generations/stubs.mjs.map +1 -1
  87. package/esm/http/error-mapper.d.mts +1 -1
  88. package/esm/http/error-mapper.mjs +3 -3
  89. package/esm/http/error-mapper.mjs.map +1 -1
  90. package/esm/http/error-response.d.mts +3 -0
  91. package/esm/http/error-response.d.mts.map +1 -1
  92. package/esm/http/error-response.mjs +8 -2
  93. package/esm/http/error-response.mjs.map +1 -1
  94. package/esm/http/errors/errors.d.mts +8 -0
  95. package/esm/http/errors/errors.d.mts.map +1 -1
  96. package/esm/http/errors/errors.mjs +13 -1
  97. package/esm/http/errors/errors.mjs.map +1 -1
  98. package/esm/http/index.d.mts +3 -2
  99. package/esm/http/index.mjs +3 -2
  100. package/esm/http/list-endpoint.d.mts +35 -0
  101. package/esm/http/list-endpoint.d.mts.map +1 -0
  102. package/esm/http/list-endpoint.mjs +28 -0
  103. package/esm/http/list-endpoint.mjs.map +1 -0
  104. package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
  105. package/esm/http/middleware/inject-request-context.mjs +12 -2
  106. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  107. package/esm/http/server.mjs +1 -1
  108. package/esm/index.d.mts +10 -6
  109. package/esm/index.mjs +5 -2
  110. package/esm/localization/index.mjs +3 -1
  111. package/esm/localization/translation-usage-graph.d.mts +49 -0
  112. package/esm/localization/translation-usage-graph.d.mts.map +1 -0
  113. package/esm/localization/translation-usage-graph.mjs +128 -0
  114. package/esm/localization/translation-usage-graph.mjs.map +1 -0
  115. package/esm/localization/translation-usage.d.mts +85 -0
  116. package/esm/localization/translation-usage.d.mts.map +1 -0
  117. package/esm/localization/translation-usage.mjs +307 -0
  118. package/esm/localization/translation-usage.mjs.map +1 -0
  119. package/esm/openapi/security.mjs +12 -4
  120. package/esm/openapi/security.mjs.map +1 -1
  121. package/esm/openapi/types.d.mts +2 -0
  122. package/esm/openapi/types.d.mts.map +1 -1
  123. package/esm/production/build-budgets.d.mts +64 -0
  124. package/esm/production/build-budgets.d.mts.map +1 -0
  125. package/esm/production/build-budgets.mjs +109 -0
  126. package/esm/production/build-budgets.mjs.map +1 -0
  127. package/esm/production/production-builder.mjs +13 -1
  128. package/esm/production/production-builder.mjs.map +1 -1
  129. package/esm/production/resolve-build-config.d.mts +1 -1
  130. package/esm/production/resolve-build-config.d.mts.map +1 -1
  131. package/esm/production/resolve-build-config.mjs.map +1 -1
  132. package/esm/production/route-registration-snapshot.mjs +5 -3
  133. package/esm/production/route-registration-snapshot.mjs.map +1 -1
  134. package/esm/restful/restful.d.mts +1 -1
  135. package/esm/router/router.d.mts +2 -0
  136. package/esm/router/router.d.mts.map +1 -1
  137. package/esm/router/router.mjs +4 -1
  138. package/esm/router/router.mjs.map +1 -1
  139. package/esm/router/types.d.mts +19 -0
  140. package/esm/router/types.d.mts.map +1 -1
  141. package/esm/tests/fakes/storage-fake.d.mts +3 -1
  142. package/esm/tests/fakes/storage-fake.d.mts.map +1 -1
  143. package/esm/tests/fakes/storage-fake.mjs +3 -3
  144. package/esm/tests/fakes/storage-fake.mjs.map +1 -1
  145. package/esm/tests/fakes/test-fakes.d.mts +3 -1
  146. package/esm/tests/fakes/test-fakes.d.mts.map +1 -1
  147. package/esm/tests/fakes/test-fakes.mjs +3 -2
  148. package/esm/tests/fakes/test-fakes.mjs.map +1 -1
  149. package/esm/tests/index.d.mts +4 -2
  150. package/esm/tests/index.mjs +3 -1
  151. package/esm/tests/load-unit-config.mjs +24 -0
  152. package/esm/tests/load-unit-config.mjs.map +1 -0
  153. package/esm/tests/response-contract.d.mts +32 -0
  154. package/esm/tests/response-contract.d.mts.map +1 -0
  155. package/esm/tests/response-contract.mjs +116 -0
  156. package/esm/tests/response-contract.mjs.map +1 -0
  157. package/esm/tests/start-http-development-server.d.mts.map +1 -1
  158. package/esm/tests/start-http-development-server.mjs +10 -2
  159. package/esm/tests/start-http-development-server.mjs.map +1 -1
  160. package/esm/tests/test-lifecycle-state.mjs.map +1 -1
  161. package/esm/tests/test-locales.mjs +33 -0
  162. package/esm/tests/test-locales.mjs.map +1 -0
  163. package/esm/tests/test-runtime-scope.d.mts +15 -0
  164. package/esm/tests/test-runtime-scope.d.mts.map +1 -0
  165. package/esm/tests/test-runtime-scope.mjs +80 -0
  166. package/esm/tests/test-runtime-scope.mjs.map +1 -0
  167. package/esm/tests/vitest-setup.d.mts +13 -3
  168. package/esm/tests/vitest-setup.d.mts.map +1 -1
  169. package/esm/tests/vitest-setup.mjs +146 -18
  170. package/esm/tests/vitest-setup.mjs.map +1 -1
  171. package/esm/tooling/check/affected-files.d.mts +5 -0
  172. package/esm/tooling/check/affected-files.d.mts.map +1 -0
  173. package/esm/tooling/check/affected-files.mjs +56 -0
  174. package/esm/tooling/check/affected-files.mjs.map +1 -0
  175. package/esm/tooling/check/api-route-references.d.mts +16 -0
  176. package/esm/tooling/check/api-route-references.d.mts.map +1 -0
  177. package/esm/tooling/check/api-route-references.mjs +146 -0
  178. package/esm/tooling/check/api-route-references.mjs.map +1 -0
  179. package/esm/tooling/check/changed-files.d.mts +2 -0
  180. package/esm/tooling/check/changed-files.d.mts.map +1 -1
  181. package/esm/tooling/check/changed-files.mjs +2 -2
  182. package/esm/tooling/check/changed-files.mjs.map +1 -1
  183. package/esm/tooling/check/conventions-step.d.mts +7 -3
  184. package/esm/tooling/check/conventions-step.d.mts.map +1 -1
  185. package/esm/tooling/check/conventions-step.mjs +59 -8
  186. package/esm/tooling/check/conventions-step.mjs.map +1 -1
  187. package/esm/tooling/check/fixes.d.mts +8 -0
  188. package/esm/tooling/check/fixes.d.mts.map +1 -0
  189. package/esm/tooling/check/fixes.mjs +31 -0
  190. package/esm/tooling/check/fixes.mjs.map +1 -0
  191. package/esm/tooling/check/index.d.mts +12 -0
  192. package/esm/tooling/check/index.mjs +3 -2
  193. package/esm/tooling/check/run-check.d.mts +2 -0
  194. package/esm/tooling/check/run-check.d.mts.map +1 -1
  195. package/esm/tooling/check/run-check.mjs +23 -5
  196. package/esm/tooling/check/run-check.mjs.map +1 -1
  197. package/esm/tooling/check/run-tests.mjs +1 -1
  198. package/esm/tooling/check/source-ast.d.mts +1 -0
  199. package/esm/tooling/check/translation-conventions.mjs +32 -0
  200. package/esm/tooling/check/translation-conventions.mjs.map +1 -0
  201. package/esm/tooling/check/web-conventions.d.mts +10 -0
  202. package/esm/tooling/check/web-conventions.d.mts.map +1 -0
  203. package/esm/tooling/check/web-conventions.mjs +126 -0
  204. package/esm/tooling/check/web-conventions.mjs.map +1 -0
  205. package/esm/tooling/index.d.mts +7 -2
  206. package/esm/tooling/index.mjs +4 -2
  207. package/esm/tooling/inspect/collect-files.mjs +172 -0
  208. package/esm/tooling/inspect/collect-files.mjs.map +1 -0
  209. package/esm/tooling/inspect/collect-pages.mjs +3 -2
  210. package/esm/tooling/inspect/collect-pages.mjs.map +1 -1
  211. package/esm/tooling/inspect/collect-routes.mjs +1 -1
  212. package/esm/tooling/inspect/collect-routes.mjs.map +1 -1
  213. package/esm/tooling/inspect/explain-file.d.mts +12 -0
  214. package/esm/tooling/inspect/explain-file.d.mts.map +1 -0
  215. package/esm/tooling/inspect/explain-file.mjs +45 -0
  216. package/esm/tooling/inspect/explain-file.mjs.map +1 -0
  217. package/esm/tooling/inspect/index.d.mts +3 -2
  218. package/esm/tooling/inspect/index.mjs +2 -1
  219. package/esm/tooling/inspect/inspect-app.d.mts.map +1 -1
  220. package/esm/tooling/inspect/inspect-app.mjs +9 -3
  221. package/esm/tooling/inspect/inspect-app.mjs.map +1 -1
  222. package/esm/tooling/inspect/inspect-report.schema.mjs +34 -1
  223. package/esm/tooling/inspect/inspect-report.schema.mjs.map +1 -1
  224. package/esm/tooling/inspect/inspect-report.types.d.mts +17 -1
  225. package/esm/tooling/inspect/inspect-report.types.d.mts.map +1 -1
  226. package/esm/tooling/inspect/inspect-report.types.mjs +2 -1
  227. package/esm/tooling/inspect/inspect-report.types.mjs.map +1 -1
  228. package/esm/tooling/mcp/mcp-tools.d.mts.map +1 -1
  229. package/esm/tooling/mcp/mcp-tools.mjs +8 -2
  230. package/esm/tooling/mcp/mcp-tools.mjs.map +1 -1
  231. package/esm/tooling/problem.d.mts +4 -0
  232. package/esm/tooling/problem.d.mts.map +1 -1
  233. package/esm/tooling/problem.mjs +1 -0
  234. package/esm/tooling/problem.mjs.map +1 -1
  235. package/esm/tooling/syntax/ast.d.mts +2 -0
  236. package/esm/tooling/typecheck/run-typecheck.mjs +5 -1
  237. package/esm/tooling/typecheck/run-typecheck.mjs.map +1 -1
  238. package/esm/upgrade/codemods/index.mjs +2 -0
  239. package/esm/upgrade/codemods/index.mjs.map +1 -1
  240. package/esm/upgrade/codemods/vite-vitest.mjs +170 -0
  241. package/esm/upgrade/codemods/vite-vitest.mjs.map +1 -0
  242. package/esm/upgrade/notes.mjs +4 -0
  243. package/esm/upgrade/notes.mjs.map +1 -1
  244. package/esm/vite/index.d.mts +3 -1
  245. package/esm/vite/index.mjs +3 -1
  246. package/esm/vite/test-setup-reporter.d.mts +16 -0
  247. package/esm/vite/test-setup-reporter.d.mts.map +1 -0
  248. package/esm/vite/test-setup-reporter.mjs +55 -0
  249. package/esm/vite/test-setup-reporter.mjs.map +1 -0
  250. package/esm/vite/test-workers.d.mts +5 -0
  251. package/esm/vite/test-workers.d.mts.map +1 -0
  252. package/esm/vite/test-workers.mjs +38 -0
  253. package/esm/vite/test-workers.mjs.map +1 -0
  254. package/esm/warlock-config/index.d.mts +2 -1
  255. package/esm/warlock-config/types.d.mts +3 -0
  256. package/esm/warlock-config/types.d.mts.map +1 -1
  257. package/llms-full.txt +874 -9
  258. package/llms.txt +2 -0
  259. package/package.json +15 -14
  260. package/schemas/inspect-report.schema.json +47 -1
  261. package/skills/build-budgets/SKILL.md +51 -0
  262. package/skills/build-restful/SKILL.md +56 -0
  263. package/skills/configure-app/SKILL.md +7 -0
  264. package/skills/create-module/SKILL.md +9 -0
  265. package/skills/generate-web-ui/SKILL.md +71 -0
  266. package/skills/index.md +2 -2
  267. package/skills/register-route/SKILL.md +13 -0
  268. package/skills/run-app/SKILL.md +1 -1
  269. package/skills/send-mail/SKILL.md +25 -0
  270. package/skills/send-response/SKILL.md +50 -0
  271. package/skills/test-browser/SKILL.md +53 -0
  272. package/skills/test-http/SKILL.md +91 -0
  273. package/skills/test-service/SKILL.md +161 -3
  274. package/skills/upgrade-from-5-29/SKILL.md +109 -0
  275. package/skills/use-localization/SKILL.md +25 -0
  276. package/skills/warlock-check/SKILL.md +75 -1
  277. package/skills/warlock-inspect/SKILL.md +45 -2
  278. package/skills/warlock-mcp/SKILL.md +4 -0
  279. package/skills/write-cli-command/SKILL.md +18 -0
  280. package/esm/production/build-app-production.mjs +0 -14
  281. package/esm/production/build-app-production.mjs.map +0 -1
@@ -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":"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"}
@@ -22,6 +22,8 @@ export declare function listChangedFiles(options: {
22
22
  cwd: string;
23
23
  base?: string;
24
24
  git?: GitRunner;
25
+ /** Keep deleted paths as seeds for reverse dependency selection. */
26
+ includeDeleted?: boolean;
25
27
  }): Promise<{
26
28
  files: string[];
27
29
  } | {
@@ -1 +1 @@
1
- {"version":3,"file":"changed-files.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/changed-files.ts"],"mappings":";;;YAMY;EAAc;EAAc;EAAgB;;;YAG5C,aAAa,yBAAyB,gBAAgB,QAAQ;;qBAG7D,QAAQ;;;;;;;;;wBAoCC,iBAAiB;EACrC;EACA;EACA,MAAM;IACJ;EAAU;;EAAsB,SAAS"}
1
+ {"version":3,"file":"changed-files.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/changed-files.ts"],"mappings":";;;YAMY;EAAc;EAAc;EAAgB;;;YAG5C,aAAa,yBAAyB,gBAAgB,QAAQ;;qBAG7D,QAAQ;;;;;;;;;wBAoCC,iBAAiB;EACrC;EACA;EACA,MAAM;;EAEN;IACE;EAAU;;EAAsB,SAAS"}
@@ -55,7 +55,7 @@ async function listChangedFiles(options) {
55
55
  "diff",
56
56
  "--name-only",
57
57
  "--relative",
58
- "--diff-filter=ACMR",
58
+ options.includeDeleted ? "--diff-filter=ACMRD" : "--diff-filter=ACMR",
59
59
  ref
60
60
  ], options.cwd);
61
61
  if (diff.code !== 0) return { problem: gitProblem(`git diff against ${ref} failed: ${diff.stderr.trim() || `exit ${diff.code}`}`) };
@@ -65,7 +65,7 @@ async function listChangedFiles(options) {
65
65
  "--exclude-standard"
66
66
  ], options.cwd);
67
67
  if (untracked.code !== 0) return { problem: gitProblem(`git ls-files failed: ${untracked.stderr.trim() || `exit ${untracked.code}`}`) };
68
- return { files: [.../* @__PURE__ */ new Set([...lines(diff.stdout), ...lines(untracked.stdout)])].map((file) => file.split(path.sep).join("/")).filter((file) => SOURCE_FILE.test(file) && existsSync(path.join(options.cwd, file))).sort() };
68
+ return { files: [.../* @__PURE__ */ new Set([...lines(diff.stdout), ...lines(untracked.stdout)])].map((file) => file.split(path.sep).join("/")).filter((file) => SOURCE_FILE.test(file) && (options.includeDeleted || existsSync(path.join(options.cwd, file)))).sort() };
69
69
  }
70
70
 
71
71
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"changed-files.mjs","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/changed-files.ts"],"sourcesContent":["import { execFile } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { createProblem, type ToolingProblem } from \"../problem\";\n\n/** The result of one git invocation. */\nexport type GitResult = { code: number; stdout: string; stderr: string };\n\n/** Runs `git <args>` in `cwd`. Injectable so tests need no repository. */\nexport type GitRunner = (args: readonly string[], cwd: string) => Promise<GitResult>;\n\n/** The default {@link GitRunner}: the `git` on PATH. */\nexport const runGit: GitRunner = (args, cwd) =>\n new Promise((resolve) => {\n execFile(\n \"git\",\n [...args],\n { cwd, encoding: \"utf8\", windowsHide: true, maxBuffer: 16 * 1024 * 1024 },\n (error, stdout, stderr) => {\n const code = error === null ? 0 : typeof error.code === \"number\" ? error.code : 127;\n\n resolve({ code, stdout, stderr: stderr || (error?.message ?? \"\") });\n },\n );\n });\n\n/** Files `warlock check --changed` considers: JavaScript and TypeScript sources. */\nconst SOURCE_FILE = /\\.(?:[cm]?[jt]sx?)$/i;\n\nfunction lines(output: string): string[] {\n return output\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n}\n\nfunction gitProblem(message: string): ToolingProblem {\n return createProblem({ code: \"WL8006\", source: \"environment\", message });\n}\n\n/**\n * The source files changed in the working tree — tracked changes against\n * `base` (or HEAD) plus untracked files — relative to `cwd`, existing only.\n *\n * With `base`, the comparison point is `git merge-base <base> HEAD`, so a\n * branch is compared with where it left `base` rather than with `base`'s tip.\n * A git failure is a `WL8006` environment problem.\n */\nexport async function listChangedFiles(options: {\n cwd: string;\n base?: string;\n git?: GitRunner;\n}): Promise<{ files: string[] } | { problem: ToolingProblem }> {\n const git = options.git ?? runGit;\n let ref = \"HEAD\";\n\n if (options.base) {\n const mergeBase = await git([\"merge-base\", options.base, \"HEAD\"], options.cwd);\n\n if (mergeBase.code !== 0) {\n return {\n problem: gitProblem(\n `git merge-base ${options.base} HEAD failed: ${mergeBase.stderr.trim() || `exit ${mergeBase.code}`}`,\n ),\n };\n }\n\n ref = mergeBase.stdout.trim();\n }\n\n // `--relative` makes the paths relative to cwd and limits them to it.\n const diff = await git(\n [\"diff\", \"--name-only\", \"--relative\", \"--diff-filter=ACMR\", ref],\n options.cwd,\n );\n\n if (diff.code !== 0) {\n return {\n problem: gitProblem(\n `git diff against ${ref} failed: ${diff.stderr.trim() || `exit ${diff.code}`}`,\n ),\n };\n }\n\n const untracked = await git([\"ls-files\", \"--others\", \"--exclude-standard\"], options.cwd);\n\n if (untracked.code !== 0) {\n return {\n problem: gitProblem(\n `git ls-files failed: ${untracked.stderr.trim() || `exit ${untracked.code}`}`,\n ),\n };\n }\n\n const files = [...new Set([...lines(diff.stdout), ...lines(untracked.stdout)])]\n .map((file) => file.split(path.sep).join(\"/\"))\n .filter((file) => SOURCE_FILE.test(file) && existsSync(path.join(options.cwd, file)))\n .sort();\n\n return { files };\n}\n"],"mappings":";;;;;;;AAYA,MAAa,UAAqB,MAAM,QACtC,IAAI,SAAS,YAAY;CACvB,SACE,OACA,CAAC,GAAG,IAAI,GACR;EAAE;EAAK,UAAU;EAAQ,aAAa;EAAM,WAAW;CAAiB,IACvE,OAAO,QAAQ,WAAW;EAGzB,QAAQ;GAAE,MAFG,UAAU,OAAO,IAAI,OAAO,MAAM,SAAS,WAAW,MAAM,OAAO;GAEhE;GAAQ,QAAQ,WAAW,OAAO,WAAW;EAAI,CAAC;CACpE,CACF;AACF,CAAC;;AAGH,MAAM,cAAc;AAEpB,SAAS,MAAM,QAA0B;CACvC,OAAO,OACJ,MAAM,OAAO,CAAC,CACd,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,CAC1B,QAAQ,SAAS,KAAK,SAAS,CAAC;AACrC;AAEA,SAAS,WAAW,SAAiC;CACnD,OAAO,cAAc;EAAE,MAAM;EAAU,QAAQ;EAAe;CAAQ,CAAC;AACzE;;;;;;;;;AAUA,eAAsB,iBAAiB,SAIwB;CAC7D,MAAM,MAAM,QAAQ,OAAO;CAC3B,IAAI,MAAM;CAEV,IAAI,QAAQ,MAAM;EAChB,MAAM,YAAY,MAAM,IAAI;GAAC;GAAc,QAAQ;GAAM;EAAM,GAAG,QAAQ,GAAG;EAE7E,IAAI,UAAU,SAAS,GACrB,OAAO,EACL,SAAS,WACP,kBAAkB,QAAQ,KAAK,gBAAgB,UAAU,OAAO,KAAK,KAAK,QAAQ,UAAU,QAC9F,EACF;EAGF,MAAM,UAAU,OAAO,KAAK;CAC9B;CAGA,MAAM,OAAO,MAAM,IACjB;EAAC;EAAQ;EAAe;EAAc;EAAsB;CAAG,GAC/D,QAAQ,GACV;CAEA,IAAI,KAAK,SAAS,GAChB,OAAO,EACL,SAAS,WACP,oBAAoB,IAAI,WAAW,KAAK,OAAO,KAAK,KAAK,QAAQ,KAAK,QACxE,EACF;CAGF,MAAM,YAAY,MAAM,IAAI;EAAC;EAAY;EAAY;CAAoB,GAAG,QAAQ,GAAG;CAEvF,IAAI,UAAU,SAAS,GACrB,OAAO,EACL,SAAS,WACP,wBAAwB,UAAU,OAAO,KAAK,KAAK,QAAQ,UAAU,QACvE,EACF;CAQF,OAAO,EAAE,OALK,CAAC,mBAAG,IAAI,IAAI,CAAC,GAAG,MAAM,KAAK,MAAM,GAAG,GAAG,MAAM,UAAU,MAAM,CAAC,CAAC,CAAC,CAAC,CAC5E,KAAK,SAAS,KAAK,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAC7C,QAAQ,SAAS,YAAY,KAAK,IAAI,KAAK,WAAW,KAAK,KAAK,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,CACpF,KAEU,EAAE;AACjB"}
1
+ {"version":3,"file":"changed-files.mjs","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/changed-files.ts"],"sourcesContent":["import { execFile } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { createProblem, type ToolingProblem } from \"../problem\";\n\n/** The result of one git invocation. */\nexport type GitResult = { code: number; stdout: string; stderr: string };\n\n/** Runs `git <args>` in `cwd`. Injectable so tests need no repository. */\nexport type GitRunner = (args: readonly string[], cwd: string) => Promise<GitResult>;\n\n/** The default {@link GitRunner}: the `git` on PATH. */\nexport const runGit: GitRunner = (args, cwd) =>\n new Promise((resolve) => {\n execFile(\n \"git\",\n [...args],\n { cwd, encoding: \"utf8\", windowsHide: true, maxBuffer: 16 * 1024 * 1024 },\n (error, stdout, stderr) => {\n const code = error === null ? 0 : typeof error.code === \"number\" ? error.code : 127;\n\n resolve({ code, stdout, stderr: stderr || (error?.message ?? \"\") });\n },\n );\n });\n\n/** Files `warlock check --changed` considers: JavaScript and TypeScript sources. */\nconst SOURCE_FILE = /\\.(?:[cm]?[jt]sx?)$/i;\n\nfunction lines(output: string): string[] {\n return output\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n}\n\nfunction gitProblem(message: string): ToolingProblem {\n return createProblem({ code: \"WL8006\", source: \"environment\", message });\n}\n\n/**\n * The source files changed in the working tree — tracked changes against\n * `base` (or HEAD) plus untracked files — relative to `cwd`, existing only.\n *\n * With `base`, the comparison point is `git merge-base <base> HEAD`, so a\n * branch is compared with where it left `base` rather than with `base`'s tip.\n * A git failure is a `WL8006` environment problem.\n */\nexport async function listChangedFiles(options: {\n cwd: string;\n base?: string;\n git?: GitRunner;\n /** Keep deleted paths as seeds for reverse dependency selection. */\n includeDeleted?: boolean;\n}): Promise<{ files: string[] } | { problem: ToolingProblem }> {\n const git = options.git ?? runGit;\n let ref = \"HEAD\";\n\n if (options.base) {\n const mergeBase = await git([\"merge-base\", options.base, \"HEAD\"], options.cwd);\n\n if (mergeBase.code !== 0) {\n return {\n problem: gitProblem(\n `git merge-base ${options.base} HEAD failed: ${mergeBase.stderr.trim() || `exit ${mergeBase.code}`}`,\n ),\n };\n }\n\n ref = mergeBase.stdout.trim();\n }\n\n // `--relative` makes the paths relative to cwd and limits them to it.\n const diff = await git(\n [\n \"diff\",\n \"--name-only\",\n \"--relative\",\n options.includeDeleted ? \"--diff-filter=ACMRD\" : \"--diff-filter=ACMR\",\n ref,\n ],\n options.cwd,\n );\n\n if (diff.code !== 0) {\n return {\n problem: gitProblem(\n `git diff against ${ref} failed: ${diff.stderr.trim() || `exit ${diff.code}`}`,\n ),\n };\n }\n\n const untracked = await git([\"ls-files\", \"--others\", \"--exclude-standard\"], options.cwd);\n\n if (untracked.code !== 0) {\n return {\n problem: gitProblem(\n `git ls-files failed: ${untracked.stderr.trim() || `exit ${untracked.code}`}`,\n ),\n };\n }\n\n const files = [...new Set([...lines(diff.stdout), ...lines(untracked.stdout)])]\n .map((file) => file.split(path.sep).join(\"/\"))\n .filter(\n (file) =>\n SOURCE_FILE.test(file) &&\n (options.includeDeleted || existsSync(path.join(options.cwd, file))),\n )\n .sort();\n\n return { files };\n}\n"],"mappings":";;;;;;;AAYA,MAAa,UAAqB,MAAM,QACtC,IAAI,SAAS,YAAY;CACvB,SACE,OACA,CAAC,GAAG,IAAI,GACR;EAAE;EAAK,UAAU;EAAQ,aAAa;EAAM,WAAW;CAAiB,IACvE,OAAO,QAAQ,WAAW;EAGzB,QAAQ;GAAE,MAFG,UAAU,OAAO,IAAI,OAAO,MAAM,SAAS,WAAW,MAAM,OAAO;GAEhE;GAAQ,QAAQ,WAAW,OAAO,WAAW;EAAI,CAAC;CACpE,CACF;AACF,CAAC;;AAGH,MAAM,cAAc;AAEpB,SAAS,MAAM,QAA0B;CACvC,OAAO,OACJ,MAAM,OAAO,CAAC,CACd,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,CAC1B,QAAQ,SAAS,KAAK,SAAS,CAAC;AACrC;AAEA,SAAS,WAAW,SAAiC;CACnD,OAAO,cAAc;EAAE,MAAM;EAAU,QAAQ;EAAe;CAAQ,CAAC;AACzE;;;;;;;;;AAUA,eAAsB,iBAAiB,SAMwB;CAC7D,MAAM,MAAM,QAAQ,OAAO;CAC3B,IAAI,MAAM;CAEV,IAAI,QAAQ,MAAM;EAChB,MAAM,YAAY,MAAM,IAAI;GAAC;GAAc,QAAQ;GAAM;EAAM,GAAG,QAAQ,GAAG;EAE7E,IAAI,UAAU,SAAS,GACrB,OAAO,EACL,SAAS,WACP,kBAAkB,QAAQ,KAAK,gBAAgB,UAAU,OAAO,KAAK,KAAK,QAAQ,UAAU,QAC9F,EACF;EAGF,MAAM,UAAU,OAAO,KAAK;CAC9B;CAGA,MAAM,OAAO,MAAM,IACjB;EACE;EACA;EACA;EACA,QAAQ,iBAAiB,wBAAwB;EACjD;CACF,GACA,QAAQ,GACV;CAEA,IAAI,KAAK,SAAS,GAChB,OAAO,EACL,SAAS,WACP,oBAAoB,IAAI,WAAW,KAAK,OAAO,KAAK,KAAK,QAAQ,KAAK,QACxE,EACF;CAGF,MAAM,YAAY,MAAM,IAAI;EAAC;EAAY;EAAY;CAAoB,GAAG,QAAQ,GAAG;CAEvF,IAAI,UAAU,SAAS,GACrB,OAAO,EACL,SAAS,WACP,wBAAwB,UAAU,OAAO,KAAK,KAAK,QAAQ,UAAU,QACvE,EACF;CAYF,OAAO,EAAE,OATK,CAAC,mBAAG,IAAI,IAAI,CAAC,GAAG,MAAM,KAAK,MAAM,GAAG,GAAG,MAAM,UAAU,MAAM,CAAC,CAAC,CAAC,CAAC,CAC5E,KAAK,SAAS,KAAK,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAC7C,QACE,SACC,YAAY,KAAK,IAAI,MACpB,QAAQ,kBAAkB,WAAW,KAAK,KAAK,QAAQ,KAAK,IAAI,CAAC,EACtE,CAAC,CACA,KAEU,EAAE;AACjB"}
@@ -1,4 +1,6 @@
1
1
  import { ToolingProblem } from "../problem.mjs";
2
+ import { ConventionOptions } from "./web-conventions.mjs";
3
+ import { ApiRouteFacts } from "./api-route-references.mjs";
2
4
  //#region ../../packages/core/src/tooling/check/conventions-step.d.ts
3
5
  /** Matches a project-relative path of a file the framework loads by itself. */
4
6
  export declare function isAutoLoadedFile(relativePath: string): boolean;
@@ -15,18 +17,20 @@ export declare function isAutoLoadedFile(relativePath: string): boolean;
15
17
  * and no `public: true`, a redirect target from request input, sensitive
16
18
  * values in logs, unbounded regexes on request input;
17
19
  * - `WL9010`–`WL9012` warnings: `await log.*()`, a second controller in one
18
- * file, a `kind` discriminator.
20
+ * file, a `kind` discriminator;
21
+ * - `WL9019` (warning) and `WL9020` (error) the browser route references
22
+ * (see `apiRouteReferenceFindings`).
19
23
  *
20
24
  * A `// warlock-check-ignore WL9006` comment on the problem's line or the line
21
25
  * above silences that code there; without codes it silences every one.
22
26
  */
23
- export declare function checkFileConventions(relativePath: string, source: string, cwd: string): ToolingProblem[];
27
+ export declare function checkFileConventions(relativePath: string, source: string, cwd: string, options?: ConventionOptions, apiRouteFacts?: ApiRouteFacts): ToolingProblem[];
24
28
  /** `WL9004` when the project tsconfig turns on legacy decorators. */
25
29
  export declare function checkTsconfigConventions(cwd: string, project?: string): ToolingProblem[];
26
30
  /**
27
31
  * Run every convention check over `files` (project-relative) plus the
28
32
  * project tsconfig.
29
33
  */
30
- 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[];
31
35
  //#endregion
32
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":";;;wBAiBgB,iBAAiB;;;;;;;;;;;;;;;;;;;wBAiKjB,qBACd,sBACA,gBACA,cACC;;wBAmBa,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"}
@@ -1,9 +1,14 @@
1
+ import { isWarlockErrorCode } from "../../errors/error-codes.mjs";
1
2
  import { parseSourceFile } from "../syntax/ast.mjs";
2
3
  import { importSpecifiers } from "../syntax/imports.mjs";
3
4
  import { createProblem } from "../problem.mjs";
5
+ import { importFixes } from "./fixes.mjs";
4
6
  import { positionOf } from "./source-ast.mjs";
5
7
  import { codeStyleFindings } from "./code-style-conventions.mjs";
6
8
  import { securityFindings } from "./security-conventions.mjs";
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";
7
12
  import path from "node:path";
8
13
  import { existsSync, readFileSync } from "node:fs";
9
14
  import { parse } from "jsonc-parser";
@@ -62,6 +67,7 @@ function isIgnored(lines, line, code) {
62
67
  /** The import conventions (`WL9001`–`WL9003`) of one parsed file. */
63
68
  function importProblems(sourceFile, relativePath, cwd) {
64
69
  const problems = [];
70
+ const fixes = importFixes(relativePath, sourceFile.text);
65
71
  const importerModule = moduleOf(relativePath);
66
72
  for (const { specifier, node } of importsOf(sourceFile)) {
67
73
  const at = {
@@ -70,6 +76,7 @@ function importProblems(sourceFile, relativePath, cwd) {
70
76
  };
71
77
  if (specifier.startsWith(".") && /\.jsx?$/.test(specifier)) problems.push(createProblem({
72
78
  code: "WL9002",
79
+ fix: fixes.find((fix) => fix.end === node.end - 1),
73
80
  source: "convention",
74
81
  message: `Import "${specifier}" ends in a .js extension.`,
75
82
  ...at
@@ -118,16 +125,31 @@ function findingProblem(sourceFile, relativePath, finding) {
118
125
  * and no `public: true`, a redirect target from request input, sensitive
119
126
  * values in logs, unbounded regexes on request input;
120
127
  * - `WL9010`–`WL9012` warnings: `await log.*()`, a second controller in one
121
- * file, a `kind` discriminator.
128
+ * file, a `kind` discriminator;
129
+ * - `WL9019` (warning) and `WL9020` (error) the browser route references
130
+ * (see `apiRouteReferenceFindings`).
122
131
  *
123
132
  * A `// warlock-check-ignore WL9006` comment on the problem's line or the line
124
133
  * above silences that code there; without codes it silences every one.
125
134
  */
126
- function checkFileConventions(relativePath, source, cwd) {
135
+ function checkFileConventions(relativePath, source, cwd, options = {}, apiRouteFacts = EMPTY_API_ROUTE_FACTS) {
127
136
  const sourceFile = parseSourceFile(relativePath, source);
128
- const problems = [...importProblems(sourceFile, relativePath, cwd), ...[...securityFindings(sourceFile), ...codeStyleFindings(sourceFile)].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
+ ];
129
148
  const lines = source.split(/\r?\n/);
130
- return problems.filter((problem) => problem.line === null || !isIgnored(lines, problem.line, problem.code)).sort((first, second) => (first.line ?? 0) - (second.line ?? 0) || (first.column ?? 0) - (second.column ?? 0));
149
+ return problems.filter((problem) => options.rules?.[problem.code] !== "off").map((problem) => ({
150
+ ...problem,
151
+ severity: options.rules?.[problem.code] === "error" ? "error" : options.rules?.[problem.code] === "warning" ? "warning" : problem.severity
152
+ })).filter((problem) => problem.line === null || !isIgnored(lines, problem.line, problem.code)).sort((first, second) => (first.line ?? 0) - (second.line ?? 0) || (first.column ?? 0) - (second.column ?? 0));
131
153
  }
132
154
  /** `WL9004` when the project tsconfig turns on legacy decorators. */
133
155
  function checkTsconfigConventions(cwd, project = "tsconfig.json") {
@@ -154,8 +176,15 @@ function checkTsconfigConventions(cwd, project = "tsconfig.json") {
154
176
  * Run every convention check over `files` (project-relative) plus the
155
177
  * project tsconfig.
156
178
  */
157
- function runConventions(cwd, files, project) {
158
- const problems = checkTsconfigConventions(cwd, project);
179
+ function runConventions(cwd, files, project, apiRouteFacts = collectApiRouteFacts(cwd, files)) {
180
+ const options = readConventionOptions(cwd);
181
+ const problems = checkTsconfigConventions(cwd, project).filter((problem) => options.rules?.[problem.code] !== "off").map((problem) => {
182
+ const severity = options.rules?.[problem.code];
183
+ return severity === "error" || severity === "warning" ? {
184
+ ...problem,
185
+ severity
186
+ } : problem;
187
+ });
159
188
  for (const file of files) {
160
189
  let source;
161
190
  try {
@@ -163,11 +192,33 @@ function runConventions(cwd, files, project) {
163
192
  } catch {
164
193
  continue;
165
194
  }
166
- problems.push(...checkFileConventions(file, source, cwd));
195
+ problems.push(...checkFileConventions(file, source, cwd, options, apiRouteFacts));
167
196
  }
168
197
  return problems;
169
198
  }
199
+ /** Optional JSON configuration; malformed configuration is reported by the check step. */
200
+ function readConventionOptions(cwd) {
201
+ const file = path.join(cwd, ".warlock-check.json");
202
+ if (!existsSync(file)) return {};
203
+ const value = JSON.parse(readFileSync(file, "utf8"));
204
+ if (!value || typeof value !== "object") throw new Error(".warlock-check.json must be an object.");
205
+ const limit = Reflect.get(value, "componentLineLimit");
206
+ if (limit !== void 0 && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1)) throw new Error("componentLineLimit must be a positive integer.");
207
+ const rules = Reflect.get(value, "rules");
208
+ const overrides = {};
209
+ if (rules !== void 0) {
210
+ if (!rules || typeof rules !== "object" || Array.isArray(rules)) throw new Error("rules must be an object.");
211
+ for (const [code, severity] of Object.entries(rules)) {
212
+ if (!isWarlockErrorCode(code) || severity !== "off" && severity !== "warning" && severity !== "error") throw new Error("rules must map registered WL codes to off, warning or error.");
213
+ overrides[code] = severity;
214
+ }
215
+ }
216
+ return {
217
+ ...typeof limit === "number" ? { componentLineLimit: limit } : {},
218
+ rules: overrides
219
+ };
220
+ }
170
221
 
171
222
  //#endregion
172
- export { checkFileConventions, checkTsconfigConventions, isAutoLoadedFile, runConventions };
223
+ export { checkFileConventions, checkTsconfigConventions, isAutoLoadedFile, readConventionOptions, resolveSpecifier, runConventions };
173
224
  //# sourceMappingURL=conventions-step.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"conventions-step.mjs","names":["ast.parseSourceFile"],"sources":["../../../../../../../../../packages/core/src/tooling/check/conventions-step.ts"],"sourcesContent":["import { 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 { codeStyleFindings } from \"./code-style-conventions\";\nimport { securityFindings } from \"./security-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. */\nfunction resolveSpecifier(specifier: string, importer: string, cwd: string): 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 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 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): ToolingProblem[] {\n const sourceFile = ast.parseSourceFile(relativePath, source);\n const problems = [\n ...importProblems(sourceFile, relativePath, cwd),\n ...[...securityFindings(sourceFile), ...codeStyleFindings(sourceFile)].map((finding) =>\n findingProblem(sourceFile, relativePath, finding),\n ),\n ];\n const lines = source.split(/\\r?\\n/);\n\n return problems\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 problems = checkTsconfigConventions(cwd, project);\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));\n }\n\n return problems;\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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"}
@@ -0,0 +1,8 @@
1
+ //#region ../../packages/core/src/tooling/check/fixes.d.ts
2
+ export type ProblemFix = {
3
+ start: number;
4
+ end: number;
5
+ text: string;
6
+ };
7
+ //#endregion
8
+ //# sourceMappingURL=fixes.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fixes.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/fixes.ts"],"mappings":";YAKY;EAAe;EAAe;EAAa"}
@@ -0,0 +1,31 @@
1
+ import { importSpecifiers } from "../syntax/imports.mjs";
2
+ import path from "node:path";
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
+
5
+ //#region ../../packages/core/src/tooling/check/fixes.ts
6
+ /** Only relative .js imports: removing the suffix preserves TypeScript resolution. */
7
+ function importFixes(file, source) {
8
+ return importSpecifiers(file, source).filter(({ specifier, node }) => specifier.startsWith(".") && specifier.endsWith(".js") && /\.js['"]$/.test(source.slice(node.start, node.end))).map(({ node }) => ({
9
+ start: node.end - 4,
10
+ end: node.end - 1,
11
+ text: ""
12
+ }));
13
+ }
14
+ function applyConventionFixes(cwd, problems) {
15
+ const files = /* @__PURE__ */ new Map();
16
+ for (const problem of problems) if (problem.file && problem.fix) {
17
+ const fixes = files.get(problem.file) ?? [];
18
+ fixes.push(problem.fix);
19
+ files.set(problem.file, fixes);
20
+ }
21
+ for (const [file, fixes] of files) {
22
+ const target = path.join(cwd, file);
23
+ let source = readFileSync(target, "utf8");
24
+ for (const fix of [...fixes].sort((a, b) => b.start - a.start)) source = source.slice(0, fix.start) + fix.text + source.slice(fix.end);
25
+ writeFileSync(target, source);
26
+ }
27
+ }
28
+
29
+ //#endregion
30
+ export { applyConventionFixes, importFixes };
31
+ //# sourceMappingURL=fixes.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fixes.mjs","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/fixes.ts"],"sourcesContent":["import { readFileSync, writeFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { ToolingProblem } from \"../problem\";\nimport { importSpecifiers } from \"../syntax/imports\";\n\nexport type ProblemFix = { start: number; end: number; text: string };\n\n/** Only relative .js imports: removing the suffix preserves TypeScript resolution. */\nexport function importFixes(file: string, source: string): ProblemFix[] {\n return importSpecifiers(file, source)\n .filter(\n ({ specifier, node }) =>\n specifier.startsWith(\".\") &&\n specifier.endsWith(\".js\") &&\n /\\.js['\"]$/.test(source.slice(node.start, node.end)),\n )\n .map(({ node }) => ({ start: node.end - 4, end: node.end - 1, text: \"\" }));\n}\n\nexport function applyConventionFixes(cwd: string, problems: readonly ToolingProblem[]): void {\n const files = new Map<string, ProblemFix[]>();\n for (const problem of problems)\n if (problem.file && problem.fix) {\n const fixes = files.get(problem.file) ?? [];\n fixes.push(problem.fix);\n files.set(problem.file, fixes);\n }\n for (const [file, fixes] of files) {\n const target = path.join(cwd, file);\n let source = readFileSync(target, \"utf8\");\n for (const fix of [...fixes].sort((a, b) => b.start - a.start))\n source = source.slice(0, fix.start) + fix.text + source.slice(fix.end);\n writeFileSync(target, source);\n }\n}\n"],"mappings":";;;;;;AAQA,SAAgB,YAAY,MAAc,QAA8B;CACtE,OAAO,iBAAiB,MAAM,MAAM,CAAC,CAClC,QACE,EAAE,WAAW,WACZ,UAAU,WAAW,GAAG,KACxB,UAAU,SAAS,KAAK,KACxB,YAAY,KAAK,OAAO,MAAM,KAAK,OAAO,KAAK,GAAG,CAAC,CACvD,CAAC,CACA,KAAK,EAAE,YAAY;EAAE,OAAO,KAAK,MAAM;EAAG,KAAK,KAAK,MAAM;EAAG,MAAM;CAAG,EAAE;AAC7E;AAEA,SAAgB,qBAAqB,KAAa,UAA2C;CAC3F,MAAM,wBAAQ,IAAI,IAA0B;CAC5C,KAAK,MAAM,WAAW,UACpB,IAAI,QAAQ,QAAQ,QAAQ,KAAK;EAC/B,MAAM,QAAQ,MAAM,IAAI,QAAQ,IAAI,KAAK,CAAC;EAC1C,MAAM,KAAK,QAAQ,GAAG;EACtB,MAAM,IAAI,QAAQ,MAAM,KAAK;CAC/B;CACF,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO;EACjC,MAAM,SAAS,KAAK,KAAK,KAAK,IAAI;EAClC,IAAI,SAAS,aAAa,QAAQ,MAAM;EACxC,KAAK,MAAM,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK,GAC3D,SAAS,OAAO,MAAM,GAAG,IAAI,KAAK,IAAI,IAAI,OAAO,OAAO,MAAM,IAAI,GAAG;EACvE,cAAc,QAAQ,MAAM;CAC9B;AACF"}
@@ -0,0 +1,12 @@
1
+ import { ProblemFix } from "./fixes.mjs";
2
+ import { GitResult, GitRunner, listChangedFiles, runGit } from "./changed-files.mjs";
3
+ import { CHECK_EXIT_CODES, CHECK_STEPS, CheckExitCode, CheckReport, CheckStep, CheckStepName, CheckStepStatus, checkExitCode } from "./check-report.types.mjs";
4
+ import { ConventionOptions } from "./web-conventions.mjs";
5
+ import { checkFileConventions, checkTsconfigConventions, isAutoLoadedFile, runConventions } from "./conventions-step.mjs";
6
+ import { MIN_NODE_VERSION, checkNodeVersion } from "./environment-step.mjs";
7
+ import { LintOutcome, runLint } from "./lint-step.mjs";
8
+ import { VitestRun, VitestRunner, findVitest, parseVitestReport, runVitest } from "./tests-step.mjs";
9
+ import { CheckOptions, TypecheckRunner, runCheck } from "./run-check.mjs";
10
+ import { RunTestsOptions, TestRunReport, runTests, testRunArguments } from "./run-tests.mjs";
11
+ import { affectedFiles } from "./affected-files.mjs";
12
+ export { CHECK_EXIT_CODES, CHECK_STEPS, type CheckExitCode, type CheckOptions, type CheckReport, type CheckStep, type CheckStepName, type CheckStepStatus, type ConventionOptions, type GitResult, type GitRunner, type LintOutcome, MIN_NODE_VERSION, type ProblemFix, type RunTestsOptions, type TestRunReport, type TypecheckRunner, type VitestRun, type VitestRunner, affectedFiles, checkExitCode, checkFileConventions, checkNodeVersion, checkTsconfigConventions, findVitest, isAutoLoadedFile, listChangedFiles, parseVitestReport, runCheck, runConventions, runGit, runLint, runTests, runVitest, testRunArguments };
@@ -1,10 +1,11 @@
1
1
  import { CHECK_EXIT_CODES, CHECK_STEPS, checkExitCode } from "./check-report.types.mjs";
2
- import { listChangedFiles, runGit } from "./changed-files.mjs";
3
2
  import { checkFileConventions, checkTsconfigConventions, isAutoLoadedFile, runConventions } from "./conventions-step.mjs";
3
+ import { affectedFiles } from "./affected-files.mjs";
4
+ import { listChangedFiles, runGit } from "./changed-files.mjs";
4
5
  import { MIN_NODE_VERSION, checkNodeVersion } from "./environment-step.mjs";
5
6
  import { runLint } from "./lint-step.mjs";
6
7
  import { findVitest, parseVitestReport, runVitest } from "./tests-step.mjs";
7
8
  import { runCheck } from "./run-check.mjs";
8
9
  import { runTests, testRunArguments } from "./run-tests.mjs";
9
10
 
10
- export { CHECK_EXIT_CODES, CHECK_STEPS, MIN_NODE_VERSION, checkExitCode, checkFileConventions, checkNodeVersion, checkTsconfigConventions, findVitest, isAutoLoadedFile, listChangedFiles, parseVitestReport, runCheck, runConventions, runGit, runLint, runTests, runVitest, testRunArguments };
11
+ export { CHECK_EXIT_CODES, CHECK_STEPS, MIN_NODE_VERSION, affectedFiles, checkExitCode, checkFileConventions, checkNodeVersion, checkTsconfigConventions, findVitest, isAutoLoadedFile, listChangedFiles, parseVitestReport, runCheck, runConventions, runGit, runLint, runTests, runVitest, testRunArguments };
@@ -13,6 +13,8 @@ export type CheckOptions = {
13
13
  lint?: "oxlint";
14
14
  /** Limit every step to the files changed against `base` (default HEAD) plus untracked files. */
15
15
  changed?: boolean;
16
+ /** Apply safe convention fixes before checking. */
17
+ fix?: boolean;
16
18
  /** Compare with `git merge-base <base> HEAD` instead of HEAD. Implies `changed`. */
17
19
  base?: string;
18
20
  /** Steps not to run. */
@@ -1 +1 @@
1
- {"version":3,"file":"run-check.d.mts","names":[],"sources":["../../../../../../../../../packages/core/src/tooling/check/run-check.ts"],"mappings":";;;;;;YA8BY,mBACV,SAAS,qBACN,QAAQ,mBAAmB;;YAGpB;;EAEV;;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,11 +1,14 @@
1
1
  import { CHECK_STEPS, checkExitCode } from "./check-report.types.mjs";
2
2
  import { createProblem } from "../problem.mjs";
3
+ import { applyConventionFixes } from "./fixes.mjs";
4
+ import { collectApiRouteFacts } from "./api-route-references.mjs";
5
+ import { runConventions } from "./conventions-step.mjs";
6
+ import { listSourceFiles, listTestFiles } from "./source-files.mjs";
7
+ import { affectedFiles } from "./affected-files.mjs";
3
8
  import { runTypecheck } from "../typecheck/run-typecheck.mjs";
4
9
  import { listChangedFiles } from "./changed-files.mjs";
5
- import { runConventions } from "./conventions-step.mjs";
6
10
  import { checkNodeVersion } from "./environment-step.mjs";
7
11
  import { runLint } from "./lint-step.mjs";
8
- import { listSourceFiles, listTestFiles } from "./source-files.mjs";
9
12
  import { findVitest, parseVitestReport, runVitest, vitestArguments, vitestRunFailure } from "./tests-step.mjs";
10
13
  import path from "node:path";
11
14
 
@@ -85,12 +88,17 @@ async function runCheck(options = {}) {
85
88
  const listed = await listChangedFiles({
86
89
  cwd,
87
90
  base: options.base,
88
- git: options.git
91
+ git: options.git,
92
+ includeDeleted: true
89
93
  });
90
94
  if ("problem" in listed) {
91
95
  filesBlocked = true;
92
96
  found.push(listed.problem);
93
- } else files = listed.files;
97
+ } else {
98
+ filesBlocked = true;
99
+ files = await affectedFiles(cwd, listed.files, options.project);
100
+ filesBlocked = false;
101
+ }
94
102
  }
95
103
  return {
96
104
  detail: `Node ${options.nodeVersion ?? process.versions.node}${files ? `, ${files.length} changed file(s)` : ""}`,
@@ -98,6 +106,16 @@ async function runCheck(options = {}) {
98
106
  };
99
107
  });
100
108
  const targets = async () => files ?? await listSourceFiles(cwd);
109
+ if (options.fix && !filesBlocked && !skip.has("conventions")) try {
110
+ const fixTargets = await targets();
111
+ applyConventionFixes(cwd, runConventions(cwd, fixTargets, options.project, collectApiRouteFacts(cwd, await listSourceFiles(cwd))));
112
+ } catch (error) {
113
+ problems.push(createProblem({
114
+ code: "WL8007",
115
+ source: "environment",
116
+ message: `Could not apply fixes: ${errorMessage(error)}`
117
+ }));
118
+ }
101
119
  await run("typecheck", async () => {
102
120
  if (files !== null && files.length === 0) return {
103
121
  status: "skipped",
@@ -166,7 +184,7 @@ async function runCheck(options = {}) {
166
184
  const conventionFiles = await targets();
167
185
  return {
168
186
  detail: `${conventionFiles.length} file(s) checked`,
169
- problems: runConventions(cwd, conventionFiles, options.project)
187
+ problems: runConventions(cwd, conventionFiles, options.project, collectApiRouteFacts(cwd, await listSourceFiles(cwd)))
170
188
  };
171
189
  });
172
190
  steps.sort((first, second) => CHECK_STEPS.indexOf(first.name) - CHECK_STEPS.indexOf(second.name));