@ontrails/warden 1.0.0-beta.14 → 1.0.0-beta.16

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 (351) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +77 -34
  3. package/bin/warden.ts +22 -0
  4. package/package.json +26 -6
  5. package/src/ast.ts +28 -0
  6. package/src/cli.ts +1169 -116
  7. package/src/command.ts +927 -0
  8. package/src/config.ts +184 -0
  9. package/src/drift.ts +76 -34
  10. package/src/formatters.ts +54 -7
  11. package/src/guide.ts +234 -0
  12. package/src/index.ts +151 -38
  13. package/src/project-context.ts +163 -0
  14. package/src/resolve.ts +530 -0
  15. package/src/rules/activation-orphan.ts +97 -0
  16. package/src/rules/ast.ts +2884 -124
  17. package/src/rules/circular-refs.ts +154 -0
  18. package/src/rules/{context-no-trailhead-types.ts → context-no-surface-types.ts} +72 -12
  19. package/src/rules/contour-exists.ts +251 -0
  20. package/src/rules/contour-ids.ts +15 -0
  21. package/src/rules/cross-declarations.ts +277 -69
  22. package/src/rules/dead-internal-trail.ts +141 -0
  23. package/src/rules/draft-file-marking.ts +51 -3
  24. package/src/rules/draft-visible-debt.ts +40 -15
  25. package/src/rules/error-mapping-completeness.ts +288 -0
  26. package/src/rules/example-valid.ts +401 -0
  27. package/src/rules/fires-declarations.ts +758 -0
  28. package/src/rules/implementation-returns-result.ts +1042 -122
  29. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  30. package/src/rules/incomplete-crud.ts +580 -0
  31. package/src/rules/index.ts +156 -19
  32. package/src/rules/intent-propagation.ts +127 -0
  33. package/src/rules/layer-field-name-drift.ts +96 -0
  34. package/src/rules/metadata.ts +508 -0
  35. package/src/rules/missing-reconcile.ts +98 -0
  36. package/src/rules/missing-visibility.ts +110 -0
  37. package/src/rules/no-dev-permit-in-source.ts +99 -0
  38. package/src/rules/no-direct-implementation-call.ts +1 -1
  39. package/src/rules/no-legacy-layer-imports.ts +193 -0
  40. package/src/rules/no-native-error-result.ts +111 -0
  41. package/src/rules/no-sync-result-assumption.ts +1131 -96
  42. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  43. package/src/rules/no-throw-in-implementation.ts +6 -4
  44. package/src/rules/on-references-exist.ts +194 -0
  45. package/src/rules/orphaned-signal.ts +150 -0
  46. package/src/rules/owner-projection-parity.ts +146 -0
  47. package/src/rules/permit-governance.ts +25 -0
  48. package/src/rules/public-internal-deep-imports.ts +517 -0
  49. package/src/rules/public-output-schema.ts +29 -0
  50. package/src/rules/public-union-output-discriminants.ts +150 -0
  51. package/src/rules/read-intent-fires.ts +187 -0
  52. package/src/rules/reference-exists.ts +98 -0
  53. package/src/rules/registry-names.ts +113 -0
  54. package/src/rules/resolved-import-boundary.ts +146 -0
  55. package/src/rules/{provision-declarations.ts → resource-declarations.ts} +208 -138
  56. package/src/rules/{provision-exists.ts → resource-exists.ts} +46 -51
  57. package/src/rules/resource-id-grammar.ts +65 -0
  58. package/src/rules/scan.ts +0 -26
  59. package/src/rules/scheduled-destroy-intent.ts +44 -0
  60. package/src/rules/signal-graph-coaching.ts +191 -0
  61. package/src/rules/specs.ts +5 -1
  62. package/src/rules/static-resource-accessor-preference.ts +657 -0
  63. package/src/rules/types.ts +187 -7
  64. package/src/rules/unmaterialized-activation-source.ts +84 -0
  65. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  66. package/src/rules/valid-describe-refs.ts +160 -32
  67. package/src/rules/valid-detour-contract.ts +78 -0
  68. package/src/rules/warden-export-symmetry.ts +533 -0
  69. package/src/rules/warden-rules-use-ast.ts +996 -0
  70. package/src/rules/webhook-route-collision.ts +243 -0
  71. package/src/trails/activation-orphan.trail.ts +81 -0
  72. package/src/trails/circular-refs.trail.ts +29 -0
  73. package/src/trails/{context-no-trailhead-types.trail.ts → context-no-surface-types.trail.ts} +4 -4
  74. package/src/trails/contour-exists.trail.ts +21 -0
  75. package/src/trails/dead-internal-trail.trail.ts +26 -0
  76. package/src/trails/draft-file-marking.trail.ts +16 -0
  77. package/src/trails/draft-visible-debt.trail.ts +16 -0
  78. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  79. package/src/trails/example-valid.trail.ts +25 -0
  80. package/src/trails/fires-declarations.trail.ts +23 -0
  81. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  82. package/src/trails/incomplete-crud.trail.ts +39 -0
  83. package/src/trails/index.ts +56 -8
  84. package/src/trails/intent-propagation.trail.ts +30 -0
  85. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  86. package/src/trails/missing-reconcile.trail.ts +33 -0
  87. package/src/trails/missing-visibility.trail.ts +22 -0
  88. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  89. package/src/trails/no-legacy-layer-imports.trail.ts +35 -0
  90. package/src/trails/no-native-error-result.trail.ts +18 -0
  91. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  92. package/src/trails/on-references-exist.trail.ts +21 -0
  93. package/src/trails/orphaned-signal.trail.ts +36 -0
  94. package/src/trails/owner-projection-parity.trail.ts +26 -0
  95. package/src/trails/permit-governance.trail.ts +51 -0
  96. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  97. package/src/trails/public-output-schema.trail.ts +55 -0
  98. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  99. package/src/trails/read-intent-fires.trail.ts +20 -0
  100. package/src/trails/reference-exists.trail.ts +25 -0
  101. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  102. package/src/trails/{provision-declarations.trail.ts → resource-declarations.trail.ts} +6 -6
  103. package/src/trails/{provision-exists.trail.ts → resource-exists.trail.ts} +7 -7
  104. package/src/trails/resource-id-grammar.trail.ts +39 -0
  105. package/src/trails/run.ts +133 -24
  106. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  107. package/src/trails/schema.ts +122 -4
  108. package/src/trails/signal-graph-coaching.trail.ts +74 -0
  109. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  110. package/src/trails/unmaterialized-activation-source.trail.ts +69 -0
  111. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  112. package/src/trails/valid-detour-contract.trail.ts +71 -0
  113. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  114. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  115. package/src/trails/webhook-route-collision.trail.ts +50 -0
  116. package/src/trails/wrap-rule.ts +133 -14
  117. package/src/workspaces.ts +238 -0
  118. package/.turbo/turbo-build.log +0 -1
  119. package/.turbo/turbo-lint.log +0 -3
  120. package/.turbo/turbo-typecheck.log +0 -1
  121. package/dist/cli.d.ts +0 -46
  122. package/dist/cli.d.ts.map +0 -1
  123. package/dist/cli.js +0 -247
  124. package/dist/cli.js.map +0 -1
  125. package/dist/draft.d.ts +0 -5
  126. package/dist/draft.d.ts.map +0 -1
  127. package/dist/draft.js +0 -16
  128. package/dist/draft.js.map +0 -1
  129. package/dist/drift.d.ts +0 -29
  130. package/dist/drift.d.ts.map +0 -1
  131. package/dist/drift.js +0 -61
  132. package/dist/drift.js.map +0 -1
  133. package/dist/formatters.d.ts +0 -30
  134. package/dist/formatters.d.ts.map +0 -1
  135. package/dist/formatters.js +0 -98
  136. package/dist/formatters.js.map +0 -1
  137. package/dist/index.d.ts +0 -37
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js +0 -38
  140. package/dist/index.js.map +0 -1
  141. package/dist/rules/ast.d.ts +0 -82
  142. package/dist/rules/ast.d.ts.map +0 -1
  143. package/dist/rules/ast.js +0 -363
  144. package/dist/rules/ast.js.map +0 -1
  145. package/dist/rules/context-no-surface-types.d.ts +0 -12
  146. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  147. package/dist/rules/context-no-surface-types.js +0 -96
  148. package/dist/rules/context-no-surface-types.js.map +0 -1
  149. package/dist/rules/context-no-trailhead-types.d.ts +0 -12
  150. package/dist/rules/context-no-trailhead-types.d.ts.map +0 -1
  151. package/dist/rules/context-no-trailhead-types.js +0 -96
  152. package/dist/rules/context-no-trailhead-types.js.map +0 -1
  153. package/dist/rules/cross-declarations.d.ts +0 -13
  154. package/dist/rules/cross-declarations.d.ts.map +0 -1
  155. package/dist/rules/cross-declarations.js +0 -264
  156. package/dist/rules/cross-declarations.js.map +0 -1
  157. package/dist/rules/draft-file-marking.d.ts +0 -6
  158. package/dist/rules/draft-file-marking.d.ts.map +0 -1
  159. package/dist/rules/draft-file-marking.js +0 -65
  160. package/dist/rules/draft-file-marking.js.map +0 -1
  161. package/dist/rules/draft-visible-debt.d.ts +0 -12
  162. package/dist/rules/draft-visible-debt.d.ts.map +0 -1
  163. package/dist/rules/draft-visible-debt.js +0 -45
  164. package/dist/rules/draft-visible-debt.js.map +0 -1
  165. package/dist/rules/follow-declarations.d.ts +0 -13
  166. package/dist/rules/follow-declarations.d.ts.map +0 -1
  167. package/dist/rules/follow-declarations.js +0 -264
  168. package/dist/rules/follow-declarations.js.map +0 -1
  169. package/dist/rules/implementation-returns-result.d.ts +0 -13
  170. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  171. package/dist/rules/implementation-returns-result.js +0 -277
  172. package/dist/rules/implementation-returns-result.js.map +0 -1
  173. package/dist/rules/index.d.ts +0 -20
  174. package/dist/rules/index.d.ts.map +0 -1
  175. package/dist/rules/index.js +0 -49
  176. package/dist/rules/index.js.map +0 -1
  177. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  178. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  179. package/dist/rules/no-direct-impl-in-route.js +0 -47
  180. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  181. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  182. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  183. package/dist/rules/no-direct-implementation-call.js +0 -39
  184. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  185. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  186. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  187. package/dist/rules/no-sync-result-assumption.js +0 -98
  188. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  189. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  190. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  191. package/dist/rules/no-throw-in-detour-target.js +0 -87
  192. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  193. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  194. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  195. package/dist/rules/no-throw-in-implementation.js +0 -34
  196. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  197. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  198. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  199. package/dist/rules/prefer-schema-inference.js +0 -86
  200. package/dist/rules/prefer-schema-inference.js.map +0 -1
  201. package/dist/rules/provision-declarations.d.ts +0 -14
  202. package/dist/rules/provision-declarations.d.ts.map +0 -1
  203. package/dist/rules/provision-declarations.js +0 -344
  204. package/dist/rules/provision-declarations.js.map +0 -1
  205. package/dist/rules/provision-exists.d.ts +0 -6
  206. package/dist/rules/provision-exists.d.ts.map +0 -1
  207. package/dist/rules/provision-exists.js +0 -90
  208. package/dist/rules/provision-exists.js.map +0 -1
  209. package/dist/rules/scan.d.ts +0 -8
  210. package/dist/rules/scan.d.ts.map +0 -1
  211. package/dist/rules/scan.js +0 -32
  212. package/dist/rules/scan.js.map +0 -1
  213. package/dist/rules/service-declarations.d.ts +0 -16
  214. package/dist/rules/service-declarations.d.ts.map +0 -1
  215. package/dist/rules/service-declarations.js +0 -346
  216. package/dist/rules/service-declarations.js.map +0 -1
  217. package/dist/rules/service-exists.d.ts +0 -8
  218. package/dist/rules/service-exists.d.ts.map +0 -1
  219. package/dist/rules/service-exists.js +0 -91
  220. package/dist/rules/service-exists.js.map +0 -1
  221. package/dist/rules/specs.d.ts +0 -29
  222. package/dist/rules/specs.d.ts.map +0 -1
  223. package/dist/rules/specs.js +0 -192
  224. package/dist/rules/specs.js.map +0 -1
  225. package/dist/rules/structure.d.ts +0 -13
  226. package/dist/rules/structure.d.ts.map +0 -1
  227. package/dist/rules/structure.js +0 -142
  228. package/dist/rules/structure.js.map +0 -1
  229. package/dist/rules/types.d.ts +0 -54
  230. package/dist/rules/types.d.ts.map +0 -1
  231. package/dist/rules/types.js +0 -2
  232. package/dist/rules/types.js.map +0 -1
  233. package/dist/rules/valid-describe-refs.d.ts +0 -7
  234. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  235. package/dist/rules/valid-describe-refs.js +0 -51
  236. package/dist/rules/valid-describe-refs.js.map +0 -1
  237. package/dist/rules/valid-detour-refs.d.ts +0 -6
  238. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  239. package/dist/rules/valid-detour-refs.js +0 -117
  240. package/dist/rules/valid-detour-refs.js.map +0 -1
  241. package/dist/trails/context-no-surface-types.trail.d.ts +0 -13
  242. package/dist/trails/context-no-surface-types.trail.d.ts.map +0 -1
  243. package/dist/trails/context-no-surface-types.trail.js +0 -21
  244. package/dist/trails/context-no-surface-types.trail.js.map +0 -1
  245. package/dist/trails/context-no-trailhead-types.trail.d.ts +0 -13
  246. package/dist/trails/context-no-trailhead-types.trail.d.ts.map +0 -1
  247. package/dist/trails/context-no-trailhead-types.trail.js +0 -21
  248. package/dist/trails/context-no-trailhead-types.trail.js.map +0 -1
  249. package/dist/trails/cross-declarations.trail.d.ts +0 -13
  250. package/dist/trails/cross-declarations.trail.d.ts.map +0 -1
  251. package/dist/trails/cross-declarations.trail.js +0 -22
  252. package/dist/trails/cross-declarations.trail.js.map +0 -1
  253. package/dist/trails/follow-declarations.trail.d.ts +0 -13
  254. package/dist/trails/follow-declarations.trail.d.ts.map +0 -1
  255. package/dist/trails/follow-declarations.trail.js +0 -22
  256. package/dist/trails/follow-declarations.trail.js.map +0 -1
  257. package/dist/trails/implementation-returns-result.trail.d.ts +0 -13
  258. package/dist/trails/implementation-returns-result.trail.d.ts.map +0 -1
  259. package/dist/trails/implementation-returns-result.trail.js +0 -20
  260. package/dist/trails/implementation-returns-result.trail.js.map +0 -1
  261. package/dist/trails/index.d.ts +0 -16
  262. package/dist/trails/index.d.ts.map +0 -1
  263. package/dist/trails/index.js +0 -15
  264. package/dist/trails/index.js.map +0 -1
  265. package/dist/trails/no-direct-impl-in-route.trail.d.ts +0 -13
  266. package/dist/trails/no-direct-impl-in-route.trail.d.ts.map +0 -1
  267. package/dist/trails/no-direct-impl-in-route.trail.js +0 -22
  268. package/dist/trails/no-direct-impl-in-route.trail.js.map +0 -1
  269. package/dist/trails/no-direct-implementation-call.trail.d.ts +0 -13
  270. package/dist/trails/no-direct-implementation-call.trail.d.ts.map +0 -1
  271. package/dist/trails/no-direct-implementation-call.trail.js +0 -16
  272. package/dist/trails/no-direct-implementation-call.trail.js.map +0 -1
  273. package/dist/trails/no-sync-result-assumption.trail.d.ts +0 -13
  274. package/dist/trails/no-sync-result-assumption.trail.d.ts.map +0 -1
  275. package/dist/trails/no-sync-result-assumption.trail.js +0 -19
  276. package/dist/trails/no-sync-result-assumption.trail.js.map +0 -1
  277. package/dist/trails/no-throw-in-detour-target.trail.d.ts +0 -15
  278. package/dist/trails/no-throw-in-detour-target.trail.d.ts.map +0 -1
  279. package/dist/trails/no-throw-in-detour-target.trail.js +0 -20
  280. package/dist/trails/no-throw-in-detour-target.trail.js.map +0 -1
  281. package/dist/trails/no-throw-in-implementation.trail.d.ts +0 -13
  282. package/dist/trails/no-throw-in-implementation.trail.d.ts.map +0 -1
  283. package/dist/trails/no-throw-in-implementation.trail.js +0 -20
  284. package/dist/trails/no-throw-in-implementation.trail.js.map +0 -1
  285. package/dist/trails/prefer-schema-inference.trail.d.ts +0 -13
  286. package/dist/trails/prefer-schema-inference.trail.d.ts.map +0 -1
  287. package/dist/trails/prefer-schema-inference.trail.js +0 -21
  288. package/dist/trails/prefer-schema-inference.trail.js.map +0 -1
  289. package/dist/trails/provision-declarations.trail.d.ts +0 -13
  290. package/dist/trails/provision-declarations.trail.d.ts.map +0 -1
  291. package/dist/trails/provision-declarations.trail.js +0 -25
  292. package/dist/trails/provision-declarations.trail.js.map +0 -1
  293. package/dist/trails/provision-exists.trail.d.ts +0 -15
  294. package/dist/trails/provision-exists.trail.d.ts.map +0 -1
  295. package/dist/trails/provision-exists.trail.js +0 -27
  296. package/dist/trails/provision-exists.trail.js.map +0 -1
  297. package/dist/trails/run.d.ts +0 -17
  298. package/dist/trails/run.d.ts.map +0 -1
  299. package/dist/trails/run.js +0 -39
  300. package/dist/trails/run.js.map +0 -1
  301. package/dist/trails/schema.d.ts +0 -53
  302. package/dist/trails/schema.d.ts.map +0 -1
  303. package/dist/trails/schema.js +0 -42
  304. package/dist/trails/schema.js.map +0 -1
  305. package/dist/trails/service-declarations.trail.d.ts +0 -26
  306. package/dist/trails/service-declarations.trail.d.ts.map +0 -1
  307. package/dist/trails/service-declarations.trail.js +0 -27
  308. package/dist/trails/service-declarations.trail.js.map +0 -1
  309. package/dist/trails/service-exists.trail.d.ts +0 -32
  310. package/dist/trails/service-exists.trail.d.ts.map +0 -1
  311. package/dist/trails/service-exists.trail.js +0 -29
  312. package/dist/trails/service-exists.trail.js.map +0 -1
  313. package/dist/trails/topo.d.ts +0 -3
  314. package/dist/trails/topo.d.ts.map +0 -1
  315. package/dist/trails/topo.js +0 -5
  316. package/dist/trails/topo.js.map +0 -1
  317. package/dist/trails/valid-describe-refs.trail.d.ts +0 -15
  318. package/dist/trails/valid-describe-refs.trail.d.ts.map +0 -1
  319. package/dist/trails/valid-describe-refs.trail.js +0 -18
  320. package/dist/trails/valid-describe-refs.trail.js.map +0 -1
  321. package/dist/trails/valid-detour-refs.trail.d.ts +0 -15
  322. package/dist/trails/valid-detour-refs.trail.d.ts.map +0 -1
  323. package/dist/trails/valid-detour-refs.trail.js +0 -24
  324. package/dist/trails/valid-detour-refs.trail.js.map +0 -1
  325. package/dist/trails/wrap-rule.d.ts +0 -29
  326. package/dist/trails/wrap-rule.d.ts.map +0 -1
  327. package/dist/trails/wrap-rule.js +0 -47
  328. package/dist/trails/wrap-rule.js.map +0 -1
  329. package/src/__tests__/cli.test.ts +0 -311
  330. package/src/__tests__/cross-declarations.test.ts +0 -303
  331. package/src/__tests__/drift.test.ts +0 -144
  332. package/src/__tests__/formatters.test.ts +0 -157
  333. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  334. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  335. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  336. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  337. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  338. package/src/__tests__/provision-declarations.test.ts +0 -318
  339. package/src/__tests__/provision-exists.test.ts +0 -122
  340. package/src/__tests__/rules.test.ts +0 -227
  341. package/src/__tests__/trails.test.ts +0 -19
  342. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  343. package/src/__tests__/wrap-rule.test.ts +0 -41
  344. package/src/rules/no-direct-impl-in-route.ts +0 -81
  345. package/src/rules/no-throw-in-detour-target.ts +0 -150
  346. package/src/rules/valid-detour-refs.ts +0 -189
  347. package/src/trails/no-direct-impl-in-route.trail.ts +0 -22
  348. package/src/trails/no-throw-in-detour-target.trail.ts +0 -20
  349. package/src/trails/valid-detour-refs.trail.ts +0 -24
  350. package/tsconfig.json +0 -9
  351. package/tsconfig.tsbuildinfo +0 -1
package/src/cli.ts CHANGED
@@ -8,38 +8,128 @@
8
8
  import { resolve } from 'node:path';
9
9
 
10
10
  import type { Topo } from '@ontrails/core';
11
+ import { getContourReferences } from '@ontrails/core';
11
12
 
13
+ import type {
14
+ EffectiveWardenConfig,
15
+ WardenConfigInput,
16
+ WardenConfigLayer,
17
+ WardenDepth,
18
+ WardenFailOn,
19
+ WardenFormat,
20
+ WardenLockMode,
21
+ } from './config.js';
22
+ import { resolveWardenConfig } from './config.js';
23
+ import { isDraftMarkedFile } from './draft.js';
12
24
  import type { DriftResult } from './drift.js';
13
25
  import { checkDrift } from './drift.js';
14
26
  import {
15
- collectProvisionDefinitionIds,
16
- findConfigProperty,
27
+ collectProjectDocumentationImportResolutions,
28
+ collectProjectImportResolutions,
29
+ collectPublicWorkspaces,
30
+ } from './project-context.js';
31
+ import {
32
+ collectContourDefinitionIds,
33
+ collectContourReferenceTargetsByName,
34
+ collectCrudTableIds as collectCrudTableIdsFromAst,
35
+ collectCrossTargetTrailIds,
36
+ collectOnTargetSignalIds as collectOnTargetSignalIdsFromAst,
37
+ collectReconcileTableIds as collectReconcileTableIdsFromAst,
38
+ collectResourceDefinitionIds,
39
+ collectSignalDefinitionIds,
40
+ collectTrailIntentsById,
17
41
  findTrailDefinitions,
18
42
  parse,
19
- walk,
20
43
  } from './rules/ast.js';
21
- import { wardenRules } from './rules/index.js';
44
+ import { collectFileCrudCoverage } from './rules/incomplete-crud.js';
45
+ import { wardenRules, wardenTopoRules } from './rules/index.js';
46
+ import { getWardenRuleMetadata } from './rules/metadata.js';
22
47
  import type {
23
48
  ProjectAwareWardenRule,
24
49
  ProjectContext,
50
+ TopoAwareWardenRule,
25
51
  WardenDiagnostic,
52
+ WardenGuidanceLink,
26
53
  WardenRule,
54
+ WardenRuleTier,
27
55
  } from './rules/types.js';
56
+ import type { WardenImportResolution } from './resolve.js';
57
+
58
+ /**
59
+ * Resolved topo input for Warden runs that govern multiple apps.
60
+ */
61
+ export interface WardenTopoTarget {
62
+ /** Stable app/topo label used to tag topo-aware diagnostics. */
63
+ readonly name?: string | undefined;
64
+ /** Resolved topo module to inspect. */
65
+ readonly topo: Topo;
66
+ }
28
67
 
29
68
  /**
30
- * Options for the warden CLI runner.
69
+ * Options for the shared Warden runner.
31
70
  */
32
- export interface WardenOptions {
71
+ export interface WardenRunOptions {
33
72
  /** Root directory to scan for TypeScript files. Defaults to cwd. */
34
73
  readonly rootDir?: string | undefined;
74
+ /** Warden config section from `trails.config.ts`, if already loaded. */
75
+ readonly config?: WardenConfigInput | undefined;
76
+ /** CLI/config-layer app names carried through shared resolution. */
77
+ readonly apps?: readonly string[] | undefined;
78
+ /** Cumulative analysis depth for the final M1 surfaces. */
79
+ readonly depth?: WardenDepth | undefined;
80
+ /** Draft-state handling mode for final M1 surfaces. */
81
+ readonly drafts?: EffectiveWardenConfig['drafts'] | undefined;
82
+ /** Failure threshold used to compute `report.passed`. */
83
+ readonly failOn?: WardenFailOn | undefined;
84
+ /** Output format requested by the caller. */
85
+ readonly format?: WardenFormat | undefined;
86
+ /** Lockfile mode requested by the caller. */
87
+ readonly lock?: WardenLockMode | undefined;
88
+ /** Suppress lockfile mutation for CI/pre-push callers. */
89
+ readonly noLockMutation?: boolean | undefined;
90
+ /** Environment layer for config resolution. Pass `process.env` at process boundaries. */
91
+ readonly env?: Record<string, string | undefined> | undefined;
35
92
  /** Only run lint rules, skip drift detection */
36
93
  readonly lintOnly?: boolean | undefined;
37
94
  /** Only run drift detection, skip lint rules */
38
95
  readonly driftOnly?: boolean | undefined;
39
- /** App topology for drift detection. When provided, enables real trailhead lock comparison. */
96
+ /**
97
+ * Run a single Warden tier. Defaults to all lint tiers plus drift.
98
+ *
99
+ * Selecting a non-drift tier skips drift detection; selecting `drift` skips
100
+ * lint rule dispatch. `lintOnly` and `driftOnly` remain compatibility shims.
101
+ */
102
+ readonly tier?: WardenRuleTier | undefined;
103
+ /**
104
+ * App topology for drift detection. When provided, enables real topology
105
+ * drift comparison and unlocks the topo-aware rule dispatch path.
106
+ *
107
+ * @remarks
108
+ * Topo-aware rules (both built-in `wardenTopoRules` and `extraTopoRules`)
109
+ * only fire when a `Topo` is supplied. Runs without a topo silently skip
110
+ * topo-aware dispatch — callers that depend on a topo-aware rule firing
111
+ * must pass `topo` explicitly.
112
+ */
40
113
  readonly topo?: Topo | undefined;
114
+ /**
115
+ * Multiple resolved topos to govern in one invocation.
116
+ *
117
+ * Source/project rules run once; topo-aware rules run once per target.
118
+ */
119
+ readonly topos?: readonly WardenTopoTarget[] | undefined;
120
+ /**
121
+ * Extra topo-aware rules to run in addition to the built-in registry.
122
+ *
123
+ * Primarily a test hook — production callers should register rules via
124
+ * `wardenTopoRules` in `rules/index.ts`. These rules are only invoked
125
+ * when `topo` is also supplied (see `topo` remarks).
126
+ */
127
+ readonly extraTopoRules?: readonly TopoAwareWardenRule[] | undefined;
41
128
  }
42
129
 
130
+ /** Backwards-compatible name for older consumers. */
131
+ export type WardenOptions = WardenRunOptions;
132
+
43
133
  /**
44
134
  * Result of a warden run.
45
135
  */
@@ -54,33 +144,138 @@ export interface WardenReport {
54
144
  readonly drift: DriftResult | null;
55
145
  /** Whether the warden run passed (no errors, no drift) */
56
146
  readonly passed: boolean;
147
+ /** Effective shared config consumed by this run. */
148
+ readonly effectiveConfig?: EffectiveWardenConfig | undefined;
149
+ /** Resolved topo/app labels governed by this run. */
150
+ readonly topoNames?: readonly string[] | undefined;
57
151
  }
58
152
 
59
153
  /**
60
- * Collect all .ts files under a directory, excluding node_modules, dist, and .git.
154
+ * Collect Warden scan targets under a directory, excluding generated and test
155
+ * surfaces that should not contribute most committed-source diagnostics.
61
156
  */
62
- const isSourceFile = (match: string): boolean =>
63
- !match.endsWith('.d.ts') &&
64
- !match.startsWith('node_modules/') &&
65
- !match.startsWith('dist/') &&
66
- !match.startsWith('.git/') &&
67
- !match.includes('__tests__/') &&
68
- !match.includes('__test__/') &&
69
- !match.endsWith('.test.ts') &&
70
- !match.endsWith('.spec.ts');
71
-
72
- const collectTsFiles = (dir: string): readonly string[] => {
157
+ const isInfrastructureScanTarget = (match: string): boolean =>
158
+ match.endsWith('.d.ts') ||
159
+ match.startsWith('node_modules/') ||
160
+ match.startsWith('dist/') ||
161
+ match.startsWith('.git/');
162
+
163
+ const isTestScanTarget = (match: string): boolean =>
164
+ match.includes('__tests__/') ||
165
+ match.includes('__test__/') ||
166
+ match.endsWith('.test.ts') ||
167
+ match.endsWith('.spec.ts');
168
+
169
+ const isAllowedScanTarget = (match: string): boolean =>
170
+ !isInfrastructureScanTarget(match) && !isTestScanTarget(match);
171
+
172
+ const isDevPermitTestScanTarget = (match: string): boolean =>
173
+ !isInfrastructureScanTarget(match) && isTestScanTarget(match);
174
+
175
+ const collectFilesMatching = (
176
+ dir: string,
177
+ pattern: string,
178
+ dot = false
179
+ ): readonly string[] => {
180
+ const glob = new Bun.Glob(pattern);
181
+ let matches: IterableIterator<string>;
182
+ try {
183
+ matches = glob.scanSync({ cwd: dir, dot, onlyFiles: true });
184
+ } catch {
185
+ return [];
186
+ }
187
+
188
+ const files: string[] = [];
189
+ for (const match of matches) {
190
+ if (isAllowedScanTarget(match)) {
191
+ files.push(`${dir}/${match}`);
192
+ }
193
+ }
194
+ return files;
195
+ };
196
+
197
+ const collectTsFiles = (dir: string): readonly string[] =>
198
+ collectFilesMatching(dir, '**/*.ts');
199
+
200
+ const draftModeIncludesFile = (
201
+ filePath: string,
202
+ drafts: EffectiveWardenConfig['drafts']
203
+ ): boolean => {
204
+ const isDraftFile = isDraftMarkedFile(filePath);
205
+ if (drafts === 'exclude') {
206
+ return !isDraftFile;
207
+ }
208
+ if (drafts === 'only') {
209
+ return isDraftFile;
210
+ }
211
+ return true;
212
+ };
213
+
214
+ const filterSourceFilesByDraftMode = (
215
+ sourceFiles: readonly SourceFile[],
216
+ drafts: EffectiveWardenConfig['drafts']
217
+ ): readonly SourceFile[] =>
218
+ drafts === 'include'
219
+ ? sourceFiles
220
+ : sourceFiles.filter((sourceFile) =>
221
+ draftModeIncludesFile(sourceFile.filePath, drafts)
222
+ );
223
+
224
+ const collectDevPermitTestFiles = (dir: string): readonly string[] => {
73
225
  const glob = new Bun.Glob('**/*.ts');
74
226
  let matches: IterableIterator<string>;
75
227
  try {
76
- matches = glob.scanSync({ cwd: dir, dot: false, onlyFiles: true });
228
+ matches = glob.scanSync({ cwd: dir, onlyFiles: true });
77
229
  } catch {
78
230
  return [];
79
231
  }
80
232
 
81
233
  const files: string[] = [];
82
234
  for (const match of matches) {
83
- if (isSourceFile(match)) {
235
+ if (isDevPermitTestScanTarget(match)) {
236
+ files.push(`${dir}/${match}`);
237
+ }
238
+ }
239
+ return files;
240
+ };
241
+
242
+ const collectTextScanFiles = (dir: string): readonly string[] => [
243
+ ...collectFilesMatching(dir, '**/*.sh', true),
244
+ ...collectFilesMatching(dir, '**/*.bash', true),
245
+ ...collectFilesMatching(dir, '**/*.zsh', true),
246
+ ...collectFilesMatching(dir, '**/*.yml', true),
247
+ ...collectFilesMatching(dir, '**/*.yaml', true),
248
+ ...collectFilesMatching(dir, '**/package.json', true),
249
+ ];
250
+
251
+ const isDocumentationScanTarget = (match: string): boolean => {
252
+ if (match === 'README.md') {
253
+ return true;
254
+ }
255
+ if (/^(?:packages|adapters|apps)\/[^/]+\/README\.md$/.test(match)) {
256
+ return true;
257
+ }
258
+ return (
259
+ match.startsWith('docs/') &&
260
+ !match.startsWith('docs/adr/') &&
261
+ !match.startsWith('docs/migration/') &&
262
+ !match.startsWith('docs/releases/') &&
263
+ match.endsWith('.md')
264
+ );
265
+ };
266
+
267
+ const collectDocumentationFiles = (dir: string): readonly string[] => {
268
+ const glob = new Bun.Glob('**/*.md');
269
+ let matches: IterableIterator<string>;
270
+ try {
271
+ matches = glob.scanSync({ cwd: dir, onlyFiles: true });
272
+ } catch {
273
+ return [];
274
+ }
275
+
276
+ const files: string[] = [];
277
+ for (const match of matches) {
278
+ if (isAllowedScanTarget(match) && isDocumentationScanTarget(match)) {
84
279
  files.push(`${dir}/${match}`);
85
280
  }
86
281
  }
@@ -89,9 +284,131 @@ const collectTsFiles = (dir: string): readonly string[] => {
89
284
 
90
285
  interface SourceFile {
91
286
  readonly filePath: string;
287
+ readonly kind: 'documentation' | 'text' | 'typescript';
92
288
  readonly sourceCode: string;
93
289
  }
94
290
 
291
+ interface MutableProjectContext {
292
+ contourReferencesByName: Map<string, Set<string>>;
293
+ crudTableIds: Set<string>;
294
+ crossTargetTrailIds: Set<string>;
295
+ crudCoverageByEntity: Map<string, Set<string>>;
296
+ knownContourIds: Set<string>;
297
+ knownResourceIds: Set<string>;
298
+ knownSignalIds: Set<string>;
299
+ knownTrailIds: Set<string>;
300
+ importResolutionsByFile: Map<string, readonly WardenImportResolution[]>;
301
+ documentedImportResolutionsByFile: Map<
302
+ string,
303
+ readonly WardenImportResolution[]
304
+ >;
305
+ onTargetSignalIds: Set<string>;
306
+ publicWorkspaces: ReturnType<typeof collectPublicWorkspaces>;
307
+ reconcileTableIds: Set<string>;
308
+ trailIntentsById: Map<string, 'destroy' | 'read' | 'write'>;
309
+ }
310
+
311
+ const createMutableProjectContext = (): MutableProjectContext => ({
312
+ contourReferencesByName: new Map<string, Set<string>>(),
313
+ crossTargetTrailIds: new Set<string>(),
314
+ crudCoverageByEntity: new Map<string, Set<string>>(),
315
+ crudTableIds: new Set<string>(),
316
+ documentedImportResolutionsByFile: new Map<
317
+ string,
318
+ readonly WardenImportResolution[]
319
+ >(),
320
+ importResolutionsByFile: new Map<string, readonly WardenImportResolution[]>(),
321
+ knownContourIds: new Set<string>(),
322
+ knownResourceIds: new Set<string>(),
323
+ knownSignalIds: new Set<string>(),
324
+ knownTrailIds: new Set<string>(),
325
+ onTargetSignalIds: new Set<string>(),
326
+ publicWorkspaces: new Map(),
327
+ reconcileTableIds: new Set<string>(),
328
+ trailIntentsById: new Map<string, 'destroy' | 'read' | 'write'>(),
329
+ });
330
+
331
+ const addContourReferenceTargets = (
332
+ context: MutableProjectContext,
333
+ contourName: string,
334
+ targets: readonly string[]
335
+ ): void => {
336
+ const existing = context.contourReferencesByName.get(contourName);
337
+ if (existing) {
338
+ for (const target of targets) {
339
+ existing.add(target);
340
+ }
341
+ return;
342
+ }
343
+
344
+ context.contourReferencesByName.set(contourName, new Set(targets));
345
+ };
346
+
347
+ const toProjectContext = (context: MutableProjectContext): ProjectContext => ({
348
+ ...(context.contourReferencesByName.size > 0
349
+ ? {
350
+ contourReferencesByName: new Map(
351
+ [...context.contourReferencesByName.entries()].map(
352
+ ([name, targets]) => [name, [...targets]]
353
+ )
354
+ ),
355
+ }
356
+ : {}),
357
+ ...(context.crudTableIds.size > 0
358
+ ? { crudTableIds: context.crudTableIds }
359
+ : {}),
360
+ ...(context.crudCoverageByEntity.size > 0
361
+ ? {
362
+ crudCoverageByEntity: new Map(
363
+ [...context.crudCoverageByEntity.entries()].map(
364
+ ([entityId, operations]) => [
365
+ entityId,
366
+ new Set(operations) as ReadonlySet<string>,
367
+ ]
368
+ )
369
+ ),
370
+ }
371
+ : {}),
372
+ crossTargetTrailIds: context.crossTargetTrailIds,
373
+ knownContourIds: context.knownContourIds,
374
+ knownResourceIds: context.knownResourceIds,
375
+ knownSignalIds: context.knownSignalIds,
376
+ knownTrailIds: context.knownTrailIds,
377
+ ...(context.importResolutionsByFile.size > 0
378
+ ? { importResolutionsByFile: context.importResolutionsByFile }
379
+ : {}),
380
+ ...(context.documentedImportResolutionsByFile.size > 0
381
+ ? {
382
+ documentedImportResolutionsByFile:
383
+ context.documentedImportResolutionsByFile,
384
+ }
385
+ : {}),
386
+ ...(context.onTargetSignalIds.size > 0
387
+ ? { onTargetSignalIds: context.onTargetSignalIds }
388
+ : {}),
389
+ ...(context.publicWorkspaces.size > 0
390
+ ? { publicWorkspaces: context.publicWorkspaces }
391
+ : {}),
392
+ ...(context.reconcileTableIds.size > 0
393
+ ? { reconcileTableIds: context.reconcileTableIds }
394
+ : {}),
395
+ trailIntentsById: context.trailIntentsById,
396
+ });
397
+
398
+ const collectKnownContourIds = (
399
+ sourceCode: string,
400
+ filePath: string,
401
+ knownContourIds: Set<string>
402
+ ): void => {
403
+ const ast = parse(filePath, sourceCode);
404
+ if (!ast) {
405
+ return;
406
+ }
407
+ for (const id of collectContourDefinitionIds(ast)) {
408
+ knownContourIds.add(id);
409
+ }
410
+ };
411
+
95
412
  const collectKnownTrailIds = (
96
413
  sourceCode: string,
97
414
  filePath: string,
@@ -106,44 +423,122 @@ const collectKnownTrailIds = (
106
423
  }
107
424
  };
108
425
 
109
- const collectDetourTargetTrailIds = (
426
+ const collectCrossedTrailIds = (
110
427
  sourceCode: string,
111
428
  filePath: string,
112
- detourTargetTrailIds: Set<string>
429
+ crossTargetTrailIds: Set<string>
113
430
  ): void => {
114
431
  const ast = parse(filePath, sourceCode);
115
432
  if (!ast) {
116
433
  return;
117
434
  }
118
- for (const def of findTrailDefinitions(ast)) {
119
- const detoursProp = findConfigProperty(def.config, 'detours');
120
- if (!detoursProp) {
121
- continue;
435
+ for (const id of collectCrossTargetTrailIds(ast, sourceCode)) {
436
+ crossTargetTrailIds.add(id);
437
+ }
438
+ };
439
+
440
+ const collectKnownResourceIds = (
441
+ sourceCode: string,
442
+ filePath: string,
443
+ knownResourceIds: Set<string>
444
+ ): void => {
445
+ const ast = parse(filePath, sourceCode);
446
+ if (!ast) {
447
+ return;
448
+ }
449
+ for (const id of collectResourceDefinitionIds(ast)) {
450
+ knownResourceIds.add(id);
451
+ }
452
+ };
453
+
454
+ const collectKnownSignalIds = (
455
+ sourceCode: string,
456
+ filePath: string,
457
+ knownSignalIds: Set<string>
458
+ ): void => {
459
+ const ast = parse(filePath, sourceCode);
460
+ if (!ast) {
461
+ return;
462
+ }
463
+ for (const id of collectSignalDefinitionIds(ast)) {
464
+ knownSignalIds.add(id);
465
+ }
466
+ };
467
+
468
+ const collectTrailIntents = (
469
+ sourceCode: string,
470
+ filePath: string,
471
+ trailIntentsById: Map<string, 'destroy' | 'read' | 'write'>
472
+ ): void => {
473
+ const ast = parse(filePath, sourceCode);
474
+ if (!ast) {
475
+ return;
476
+ }
477
+ for (const [id, intent] of collectTrailIntentsById(ast)) {
478
+ trailIntentsById.set(id, intent);
479
+ }
480
+ };
481
+
482
+ const collectCrudTableIds = (
483
+ sourceCode: string,
484
+ filePath: string,
485
+ crudTableIds: Set<string>
486
+ ): void => {
487
+ const ast = parse(filePath, sourceCode);
488
+ if (!ast) {
489
+ return;
490
+ }
491
+ for (const id of collectCrudTableIdsFromAst(ast)) {
492
+ crudTableIds.add(id);
493
+ }
494
+ };
495
+
496
+ const collectOnTargetSignalIds = (
497
+ sourceCode: string,
498
+ filePath: string,
499
+ onTargetSignalIds: Set<string>
500
+ ): void => {
501
+ const ast = parse(filePath, sourceCode);
502
+ if (!ast) {
503
+ return;
504
+ }
505
+ for (const id of collectOnTargetSignalIdsFromAst(ast, sourceCode)) {
506
+ onTargetSignalIds.add(id);
507
+ }
508
+ };
509
+
510
+ const collectCrudCoverageByEntity = (
511
+ sourceCode: string,
512
+ filePath: string,
513
+ coverageByEntity: Map<string, Set<string>>
514
+ ): void => {
515
+ const ast = parse(filePath, sourceCode);
516
+ if (!ast) {
517
+ return;
518
+ }
519
+ for (const [entityId, operations] of collectFileCrudCoverage(
520
+ ast,
521
+ sourceCode
522
+ )) {
523
+ const bucket = coverageByEntity.get(entityId) ?? new Set<string>();
524
+ for (const operation of operations) {
525
+ bucket.add(operation);
122
526
  }
123
- // Walk the detours value for string literals that look like trail IDs
124
- walk(detoursProp, (node) => {
125
- if (node.type !== 'Literal') {
126
- return;
127
- }
128
- const val = (node as unknown as { value?: string }).value;
129
- if (val && val.includes('.')) {
130
- detourTargetTrailIds.add(val);
131
- }
132
- });
527
+ coverageByEntity.set(entityId, bucket);
133
528
  }
134
529
  };
135
530
 
136
- const collectKnownProvisionIds = (
531
+ const collectReconcileTableIds = (
137
532
  sourceCode: string,
138
533
  filePath: string,
139
- knownProvisionIds: Set<string>
534
+ reconcileTableIds: Set<string>
140
535
  ): void => {
141
536
  const ast = parse(filePath, sourceCode);
142
537
  if (!ast) {
143
538
  return;
144
539
  }
145
- for (const id of collectProvisionDefinitionIds(ast)) {
146
- knownProvisionIds.add(id);
540
+ for (const id of collectReconcileTableIdsFromAst(ast)) {
541
+ reconcileTableIds.add(id);
147
542
  }
148
543
  };
149
544
 
@@ -156,6 +551,43 @@ const loadSourceFiles = async (
156
551
  try {
157
552
  sourceFiles.push({
158
553
  filePath,
554
+ kind: 'typescript',
555
+ sourceCode: await Bun.file(filePath).text(),
556
+ });
557
+ } catch {
558
+ continue;
559
+ }
560
+ }
561
+
562
+ for (const filePath of collectTextScanFiles(rootDir)) {
563
+ try {
564
+ sourceFiles.push({
565
+ filePath,
566
+ kind: 'text',
567
+ sourceCode: await Bun.file(filePath).text(),
568
+ });
569
+ } catch {
570
+ continue;
571
+ }
572
+ }
573
+
574
+ for (const filePath of collectDocumentationFiles(rootDir)) {
575
+ try {
576
+ sourceFiles.push({
577
+ filePath,
578
+ kind: 'documentation',
579
+ sourceCode: await Bun.file(filePath).text(),
580
+ });
581
+ } catch {
582
+ continue;
583
+ }
584
+ }
585
+
586
+ for (const filePath of collectDevPermitTestFiles(rootDir)) {
587
+ try {
588
+ sourceFiles.push({
589
+ filePath,
590
+ kind: 'text',
159
591
  sourceCode: await Bun.file(filePath).text(),
160
592
  });
161
593
  } catch {
@@ -166,92 +598,407 @@ const loadSourceFiles = async (
166
598
  return sourceFiles;
167
599
  };
168
600
 
169
- const collectTopoDetourTargetTrailIds = (
170
- appTopo: Topo
171
- ): ReadonlySet<string> => {
172
- const detourTargetTrailIds = new Set<string>();
601
+ const collectTopoKnownIds = (
602
+ appTopo: Topo,
603
+ context: MutableProjectContext
604
+ ): void => {
605
+ for (const name of appTopo.contours.keys()) {
606
+ context.knownContourIds.add(name);
607
+ }
608
+
609
+ for (const id of appTopo.trails.keys()) {
610
+ context.knownTrailIds.add(id);
611
+ }
173
612
 
613
+ for (const id of appTopo.resources.keys()) {
614
+ context.knownResourceIds.add(id);
615
+ }
616
+
617
+ for (const id of appTopo.signals.keys()) {
618
+ context.knownSignalIds.add(id);
619
+ }
620
+ };
621
+
622
+ const collectTopoCrossesAndIntents = (
623
+ appTopo: Topo,
624
+ context: MutableProjectContext
625
+ ): void => {
174
626
  for (const trail of appTopo.trails.values()) {
175
- const detours = (trail as unknown as Record<string, unknown>)['detours'] as
176
- | Readonly<Record<string, readonly string[]>>
177
- | undefined;
178
- if (!detours) {
179
- continue;
180
- }
181
- for (const targets of Object.values(detours)) {
182
- for (const id of targets) {
183
- detourTargetTrailIds.add(id);
184
- }
627
+ context.trailIntentsById.set(trail.id, trail.intent);
628
+ for (const crossedTrailId of trail.crosses) {
629
+ context.crossTargetTrailIds.add(crossedTrailId);
185
630
  }
186
631
  }
632
+ };
633
+
634
+ const collectTopoContourReferences = (
635
+ appTopo: Topo,
636
+ context: MutableProjectContext
637
+ ): void => {
638
+ for (const contour of appTopo.listContours()) {
639
+ addContourReferenceTargets(
640
+ context,
641
+ contour.name,
642
+ getContourReferences(contour).map((reference) => reference.contour)
643
+ );
644
+ }
645
+ };
187
646
 
188
- return detourTargetTrailIds;
647
+ const collectTopoTrailContext = (
648
+ appTopo: Topo,
649
+ context: MutableProjectContext
650
+ ): void => {
651
+ collectTopoKnownIds(appTopo, context);
652
+ collectTopoCrossesAndIntents(appTopo, context);
653
+ collectTopoContourReferences(appTopo, context);
189
654
  };
190
655
 
191
- const buildProjectContextFromTopo = (appTopo: Topo): ProjectContext => {
192
- const knownTrailIds = new Set<string>(appTopo.trails.keys());
193
- const knownProvisionIds = new Set<string>(appTopo.provisions.keys());
194
- const detourTargetTrailIds = collectTopoDetourTargetTrailIds(appTopo);
656
+ const collectFileKnownIds = (
657
+ sourceFile: SourceFile,
658
+ context: MutableProjectContext
659
+ ): void => {
660
+ collectKnownContourIds(
661
+ sourceFile.sourceCode,
662
+ sourceFile.filePath,
663
+ context.knownContourIds
664
+ );
665
+ collectKnownTrailIds(
666
+ sourceFile.sourceCode,
667
+ sourceFile.filePath,
668
+ context.knownTrailIds
669
+ );
670
+ collectKnownResourceIds(
671
+ sourceFile.sourceCode,
672
+ sourceFile.filePath,
673
+ context.knownResourceIds
674
+ );
675
+ collectKnownSignalIds(
676
+ sourceFile.sourceCode,
677
+ sourceFile.filePath,
678
+ context.knownSignalIds
679
+ );
680
+ };
195
681
 
196
- return {
197
- detourTargetTrailIds,
198
- knownProvisionIds,
199
- knownTrailIds,
200
- };
682
+ const collectFileTrailRelationships = (
683
+ sourceFile: SourceFile,
684
+ context: MutableProjectContext
685
+ ): void => {
686
+ collectCrossedTrailIds(
687
+ sourceFile.sourceCode,
688
+ sourceFile.filePath,
689
+ context.crossTargetTrailIds
690
+ );
691
+ collectTrailIntents(
692
+ sourceFile.sourceCode,
693
+ sourceFile.filePath,
694
+ context.trailIntentsById
695
+ );
696
+ };
697
+
698
+ const collectFileSupplementalProjectContext = (
699
+ sourceFile: SourceFile,
700
+ context: MutableProjectContext
701
+ ): void => {
702
+ collectCrudTableIds(
703
+ sourceFile.sourceCode,
704
+ sourceFile.filePath,
705
+ context.crudTableIds
706
+ );
707
+ collectOnTargetSignalIds(
708
+ sourceFile.sourceCode,
709
+ sourceFile.filePath,
710
+ context.onTargetSignalIds
711
+ );
712
+ collectReconcileTableIds(
713
+ sourceFile.sourceCode,
714
+ sourceFile.filePath,
715
+ context.reconcileTableIds
716
+ );
717
+ collectCrudCoverageByEntity(
718
+ sourceFile.sourceCode,
719
+ sourceFile.filePath,
720
+ context.crudCoverageByEntity
721
+ );
722
+ };
723
+
724
+ const collectFileProjectContext = (
725
+ sourceFile: SourceFile,
726
+ context: MutableProjectContext
727
+ ): void => {
728
+ collectFileKnownIds(sourceFile, context);
729
+ collectFileTrailRelationships(sourceFile, context);
730
+ collectFileSupplementalProjectContext(sourceFile, context);
731
+ };
732
+
733
+ const collectFileContourReferences = (
734
+ sourceFile: SourceFile,
735
+ context: MutableProjectContext
736
+ ): void => {
737
+ const ast = parse(sourceFile.filePath, sourceFile.sourceCode);
738
+ if (!ast) {
739
+ return;
740
+ }
741
+
742
+ const referencesByName = collectContourReferenceTargetsByName(
743
+ ast,
744
+ context.knownContourIds
745
+ );
746
+ for (const [contourName, targets] of referencesByName) {
747
+ addContourReferenceTargets(context, contourName, targets);
748
+ }
749
+ };
750
+
751
+ const collectFileImportResolutions = (
752
+ rootDir: string,
753
+ sourceFiles: readonly SourceFile[],
754
+ context: MutableProjectContext
755
+ ): void => {
756
+ const resolutionsByFile = collectProjectImportResolutions({
757
+ rootDir,
758
+ sourceFiles,
759
+ });
760
+ for (const [filePath, resolutions] of resolutionsByFile) {
761
+ context.importResolutionsByFile.set(filePath, resolutions);
762
+ }
201
763
  };
202
764
 
203
- const buildProjectContextFromFiles = (
204
- sourceFiles: readonly SourceFile[]
765
+ const collectFileDocumentedImportResolutions = (
766
+ rootDir: string,
767
+ sourceFiles: readonly SourceFile[],
768
+ context: MutableProjectContext
769
+ ): void => {
770
+ const resolutionsByFile = collectProjectDocumentationImportResolutions({
771
+ rootDir,
772
+ sourceFiles,
773
+ });
774
+ for (const [filePath, resolutions] of resolutionsByFile) {
775
+ context.documentedImportResolutionsByFile.set(filePath, resolutions);
776
+ }
777
+ };
778
+
779
+ const buildProjectContext = (
780
+ sourceFiles: readonly SourceFile[],
781
+ rootDir: string,
782
+ appTopos: readonly Topo[] = []
205
783
  ): ProjectContext => {
206
- const knownTrailIds = new Set<string>();
207
- const knownProvisionIds = new Set<string>();
208
- const detourTargetTrailIds = new Set<string>();
784
+ const context = createMutableProjectContext();
785
+ const typeScriptSourceFiles = sourceFiles.filter(
786
+ (sourceFile) => sourceFile.kind === 'typescript'
787
+ );
788
+ const documentationSourceFiles = sourceFiles.filter(
789
+ (sourceFile) => sourceFile.kind === 'documentation'
790
+ );
791
+ context.publicWorkspaces = collectPublicWorkspaces(rootDir);
209
792
 
210
- for (const sourceFile of sourceFiles) {
211
- collectKnownTrailIds(
212
- sourceFile.sourceCode,
213
- sourceFile.filePath,
214
- knownTrailIds
215
- );
216
- collectKnownProvisionIds(
217
- sourceFile.sourceCode,
218
- sourceFile.filePath,
219
- knownProvisionIds
220
- );
221
- collectDetourTargetTrailIds(
222
- sourceFile.sourceCode,
223
- sourceFile.filePath,
224
- detourTargetTrailIds
225
- );
793
+ if (appTopos.length > 0) {
794
+ for (const appTopo of appTopos) {
795
+ collectTopoTrailContext(appTopo, context);
796
+ }
797
+ for (const sourceFile of typeScriptSourceFiles) {
798
+ collectFileSupplementalProjectContext(sourceFile, context);
799
+ }
800
+ } else {
801
+ for (const sourceFile of typeScriptSourceFiles) {
802
+ collectFileProjectContext(sourceFile, context);
803
+ }
226
804
  }
227
805
 
228
- return {
229
- detourTargetTrailIds,
230
- knownProvisionIds,
231
- knownTrailIds,
232
- };
806
+ for (const sourceFile of typeScriptSourceFiles) {
807
+ collectFileContourReferences(sourceFile, context);
808
+ }
809
+ collectFileImportResolutions(rootDir, typeScriptSourceFiles, context);
810
+ collectFileDocumentedImportResolutions(
811
+ rootDir,
812
+ documentationSourceFiles,
813
+ context
814
+ );
815
+
816
+ return toProjectContext(context);
233
817
  };
234
818
 
235
819
  const isProjectAwareRule = (rule: WardenRule): rule is ProjectAwareWardenRule =>
236
820
  'checkWithContext' in rule;
237
821
 
822
+ const createOptionsDiagnostic = (message: string): WardenDiagnostic => ({
823
+ filePath: '<warden-options>',
824
+ line: 1,
825
+ message,
826
+ rule: 'warden-options',
827
+ severity: 'error',
828
+ });
829
+
830
+ interface WardenRuleSelector {
831
+ readonly depth?: WardenDepth | undefined;
832
+ readonly tier?: WardenRuleTier | undefined;
833
+ }
834
+
835
+ const depthIncludesTier = (
836
+ depth: WardenDepth,
837
+ tier: WardenRuleTier
838
+ ): boolean => {
839
+ switch (depth) {
840
+ case 'source': {
841
+ return tier === 'source-static';
842
+ }
843
+ case 'project': {
844
+ return tier === 'source-static' || tier === 'project-static';
845
+ }
846
+ case 'topo': {
847
+ return (
848
+ tier === 'source-static' ||
849
+ tier === 'project-static' ||
850
+ tier === 'topo-aware'
851
+ );
852
+ }
853
+ case 'all': {
854
+ return true;
855
+ }
856
+ default: {
857
+ return false;
858
+ }
859
+ }
860
+ };
861
+
862
+ const ruleMatchesTier = (
863
+ metadata: ReturnType<typeof getWardenRuleMetadata>,
864
+ tier: WardenRuleTier | undefined
865
+ ): boolean => {
866
+ if (!tier) {
867
+ return true;
868
+ }
869
+
870
+ if (!metadata) {
871
+ return false;
872
+ }
873
+
874
+ return tier === 'advisory'
875
+ ? metadata.scope === 'advisory'
876
+ : metadata.tier === tier;
877
+ };
878
+
879
+ const ruleMatchesDepth = (
880
+ metadata: ReturnType<typeof getWardenRuleMetadata>,
881
+ depth: WardenDepth | undefined
882
+ ): boolean => {
883
+ if (!depth) {
884
+ return true;
885
+ }
886
+
887
+ if (!metadata) {
888
+ return false;
889
+ }
890
+
891
+ if (metadata.scope === 'advisory') {
892
+ return depth === 'all';
893
+ }
894
+
895
+ return depthIncludesTier(depth, metadata.tier);
896
+ };
897
+
898
+ const isSelectedRule = (
899
+ rule: WardenRule | TopoAwareWardenRule,
900
+ selector: WardenRuleSelector
901
+ ): boolean => {
902
+ const metadata = getWardenRuleMetadata(rule);
903
+ return selector.tier
904
+ ? ruleMatchesTier(metadata, selector.tier)
905
+ : ruleMatchesDepth(metadata, selector.depth);
906
+ };
907
+
908
+ const isSelectedTopoRule = (
909
+ rule: TopoAwareWardenRule,
910
+ selector: WardenRuleSelector
911
+ ): boolean => {
912
+ const metadata = getWardenRuleMetadata(rule);
913
+ if (selector.tier) {
914
+ return metadata
915
+ ? ruleMatchesTier(metadata, selector.tier)
916
+ : selector.tier === 'topo-aware';
917
+ }
918
+
919
+ return metadata ? ruleMatchesDepth(metadata, selector.depth) : true;
920
+ };
921
+
922
+ const withDiagnosticGuidance = (
923
+ diagnostic: WardenDiagnostic
924
+ ): WardenDiagnostic => {
925
+ if (diagnostic.guidance !== undefined) {
926
+ return diagnostic;
927
+ }
928
+
929
+ const guidance = getWardenRuleMetadata(diagnostic.rule)?.guidance;
930
+ return guidance === undefined ? diagnostic : { ...diagnostic, guidance };
931
+ };
932
+
933
+ const topoRuleFailureDiagnostic = (
934
+ rule: TopoAwareWardenRule,
935
+ error: unknown
936
+ ): WardenDiagnostic => {
937
+ const cause = error instanceof Error ? error : new Error(String(error));
938
+ return {
939
+ filePath: '<topo>',
940
+ line: 1,
941
+ message: `Topo-aware rule "${rule.name}" threw: ${cause.message}`,
942
+ rule: rule.name,
943
+ severity: 'error',
944
+ };
945
+ };
946
+
238
947
  /**
239
- * Lint all files against all warden rules.
948
+ * Run all registered topo-aware rules against the resolved topo.
949
+ *
950
+ * Topo-aware rules fire exactly once per run (not per file) because they
951
+ * inspect the compiled trail graph, not source text.
240
952
  */
241
- const lintFiles = async (
242
- rootDir: string,
243
- appTopo?: Topo | undefined
244
- ): Promise<WardenDiagnostic[]> => {
245
- const allDiagnostics: WardenDiagnostic[] = [];
246
- const sourceFiles = await loadSourceFiles(rootDir);
247
- const context = appTopo
248
- ? buildProjectContextFromTopo(appTopo)
249
- : buildProjectContextFromFiles(sourceFiles);
953
+ const lintTopo = async (
954
+ appTopo: Topo,
955
+ extraTopoRules: readonly TopoAwareWardenRule[],
956
+ selector: WardenRuleSelector
957
+ ): Promise<readonly WardenDiagnostic[]> => {
958
+ const diagnostics: WardenDiagnostic[] = [];
959
+ const rules: readonly TopoAwareWardenRule[] = [
960
+ ...wardenTopoRules.values(),
961
+ ...extraTopoRules,
962
+ ].filter((rule) => isSelectedTopoRule(rule, selector));
963
+ for (const rule of rules) {
964
+ try {
965
+ diagnostics.push(...(await rule.checkTopo(appTopo)));
966
+ } catch (error) {
967
+ diagnostics.push(topoRuleFailureDiagnostic(rule, error));
968
+ }
969
+ }
970
+ return diagnostics;
971
+ };
250
972
 
973
+ const lintSourceFiles = (
974
+ sourceFiles: readonly SourceFile[],
975
+ context: ProjectContext,
976
+ selector: WardenRuleSelector
977
+ ): readonly WardenDiagnostic[] => {
978
+ const diagnostics: WardenDiagnostic[] = [];
251
979
  for (const sourceFile of sourceFiles) {
252
980
  for (const rule of wardenRules.values()) {
981
+ if (
982
+ sourceFile.kind === 'text' &&
983
+ rule.name !== 'no-dev-permit-in-source' &&
984
+ rule.name !== 'public-internal-deep-imports'
985
+ ) {
986
+ continue;
987
+ }
988
+
989
+ if (
990
+ sourceFile.kind === 'documentation' &&
991
+ rule.name !== 'public-internal-deep-imports'
992
+ ) {
993
+ continue;
994
+ }
995
+
996
+ if (!isSelectedRule(rule, selector)) {
997
+ continue;
998
+ }
999
+
253
1000
  if (isProjectAwareRule(rule)) {
254
- allDiagnostics.push(
1001
+ diagnostics.push(
255
1002
  ...rule.checkWithContext(
256
1003
  sourceFile.sourceCode,
257
1004
  sourceFile.filePath,
@@ -260,47 +1007,331 @@ const lintFiles = async (
260
1007
  );
261
1008
  continue;
262
1009
  }
263
-
264
- allDiagnostics.push(
1010
+ diagnostics.push(
265
1011
  ...rule.check(sourceFile.sourceCode, sourceFile.filePath)
266
1012
  );
267
1013
  }
268
1014
  }
1015
+ return diagnostics;
1016
+ };
1017
+
1018
+ const tagTopoDiagnostic = (
1019
+ diagnostic: WardenDiagnostic,
1020
+ topoName: string | undefined
1021
+ ): WardenDiagnostic =>
1022
+ topoName === undefined ? diagnostic : { ...diagnostic, topoName };
1023
+
1024
+ const lintTopoTargets = async (
1025
+ topoTargets: readonly WardenTopoTarget[],
1026
+ extraTopoRules: readonly TopoAwareWardenRule[],
1027
+ selector: WardenRuleSelector,
1028
+ tagDiagnostics: boolean
1029
+ ): Promise<readonly WardenDiagnostic[]> => {
1030
+ const diagnostics: WardenDiagnostic[] = [];
1031
+
1032
+ for (const target of topoTargets) {
1033
+ const topoDiagnostics = await lintTopo(
1034
+ target.topo,
1035
+ extraTopoRules,
1036
+ selector
1037
+ );
1038
+ const topoName = target.name ?? target.topo.name;
1039
+ diagnostics.push(
1040
+ ...(tagDiagnostics
1041
+ ? topoDiagnostics.map((diagnostic) =>
1042
+ tagTopoDiagnostic(diagnostic, topoName)
1043
+ )
1044
+ : topoDiagnostics)
1045
+ );
1046
+ }
1047
+
1048
+ return diagnostics;
1049
+ };
1050
+
1051
+ const selectorIncludesTopoRules = (selector: WardenRuleSelector): boolean => {
1052
+ if (selector.tier) {
1053
+ return selector.tier === 'advisory';
1054
+ }
1055
+
1056
+ return !selector.depth || depthIncludesTier(selector.depth, 'topo-aware');
1057
+ };
1058
+
1059
+ /**
1060
+ * Lint all files against all warden rules.
1061
+ */
1062
+ const lintFiles = async (
1063
+ rootDir: string,
1064
+ drafts: EffectiveWardenConfig['drafts'],
1065
+ topoTargets: readonly WardenTopoTarget[],
1066
+ extraTopoRules: readonly TopoAwareWardenRule[],
1067
+ selector: WardenRuleSelector
1068
+ ): Promise<WardenDiagnostic[]> => {
1069
+ if (selector.tier === 'topo-aware') {
1070
+ return [
1071
+ ...(await lintTopoTargets(topoTargets, extraTopoRules, selector, true)),
1072
+ ];
1073
+ }
1074
+
1075
+ const sourceFiles = filterSourceFilesByDraftMode(
1076
+ await loadSourceFiles(rootDir),
1077
+ drafts
1078
+ );
1079
+ const context = buildProjectContext(
1080
+ sourceFiles,
1081
+ rootDir,
1082
+ topoTargets.map((target) => target.topo)
1083
+ );
1084
+ const allDiagnostics: WardenDiagnostic[] = [
1085
+ ...lintSourceFiles(sourceFiles, context, selector),
1086
+ ];
1087
+
1088
+ if (
1089
+ topoTargets.length > 0 &&
1090
+ (selector.tier === undefined || selector.tier === 'advisory') &&
1091
+ selectorIncludesTopoRules(selector)
1092
+ ) {
1093
+ allDiagnostics.push(
1094
+ ...(await lintTopoTargets(
1095
+ topoTargets,
1096
+ extraTopoRules,
1097
+ selector,
1098
+ topoTargets.length > 1
1099
+ ))
1100
+ );
1101
+ }
269
1102
 
270
1103
  return allDiagnostics;
271
1104
  };
272
1105
 
1106
+ const topoTargetsFromOptions = (
1107
+ options: WardenRunOptions
1108
+ ): readonly WardenTopoTarget[] => {
1109
+ if (options.topos !== undefined && options.topos.length > 0) {
1110
+ return options.topos;
1111
+ }
1112
+
1113
+ return options.topo ? [{ name: options.topo.name, topo: options.topo }] : [];
1114
+ };
1115
+
1116
+ const aggregateDriftHash = (
1117
+ topoTargets: readonly WardenTopoTarget[],
1118
+ driftResults: readonly DriftResult[]
1119
+ ): string => {
1120
+ const currentHashes = new Set(
1121
+ driftResults.map((result) => result.currentHash)
1122
+ );
1123
+ const [onlyHash] = currentHashes;
1124
+ if (currentHashes.size === 1 && onlyHash !== undefined) {
1125
+ return onlyHash;
1126
+ }
1127
+
1128
+ const payload = driftResults
1129
+ .map((result, index) => {
1130
+ const target = topoTargets[index];
1131
+ return {
1132
+ currentHash: result.currentHash,
1133
+ topoName: target?.name ?? target?.topo.name ?? `topo-${String(index)}`,
1134
+ };
1135
+ })
1136
+ .toSorted((left, right) => left.topoName.localeCompare(right.topoName));
1137
+ const hasher = new Bun.CryptoHasher('sha256');
1138
+ hasher.update(JSON.stringify(payload));
1139
+ return hasher.digest('hex');
1140
+ };
1141
+
1142
+ const describeTopoDriftHash = (
1143
+ topoTargets: readonly WardenTopoTarget[],
1144
+ driftResults: readonly DriftResult[]
1145
+ ): string =>
1146
+ driftResults
1147
+ .map((result, index) => {
1148
+ const target = topoTargets[index];
1149
+ const topoName =
1150
+ target?.name ?? target?.topo.name ?? `topo-${String(index)}`;
1151
+ return `${topoName}=${result.committedHash ?? '<none>'}`;
1152
+ })
1153
+ .join(', ');
1154
+
1155
+ const checkDriftForTopoTargets = async (
1156
+ rootDir: string,
1157
+ topoTargets: readonly WardenTopoTarget[]
1158
+ ): Promise<DriftResult> => {
1159
+ if (topoTargets.length <= 1) {
1160
+ return checkDrift(rootDir, topoTargets[0]?.topo);
1161
+ }
1162
+
1163
+ const driftResults = await Promise.all(
1164
+ topoTargets.map((target) => checkDrift(rootDir, target.topo))
1165
+ );
1166
+ const committedHashes = new Set(
1167
+ driftResults.map((result) => result.committedHash)
1168
+ );
1169
+ if (committedHashes.size > 1) {
1170
+ return {
1171
+ blockedReason: `multi-topo drift expected one committed trails.lock hash but found conflicting hashes: ${describeTopoDriftHash(topoTargets, driftResults)}`,
1172
+ committedHash: null,
1173
+ currentHash: 'blocked',
1174
+ stale: true,
1175
+ };
1176
+ }
1177
+ const committedHash = driftResults[0]?.committedHash ?? null;
1178
+ const blockedReasons = driftResults.flatMap((result, index) => {
1179
+ if (result.blockedReason === undefined) {
1180
+ return [];
1181
+ }
1182
+ const target = topoTargets[index];
1183
+ const topoName =
1184
+ target?.name ?? target?.topo.name ?? `topo-${String(index)}`;
1185
+ return [`${topoName}: ${result.blockedReason}`];
1186
+ });
1187
+
1188
+ if (blockedReasons.length > 0) {
1189
+ return {
1190
+ blockedReason: blockedReasons.join('; '),
1191
+ committedHash,
1192
+ currentHash: 'blocked',
1193
+ stale: true,
1194
+ };
1195
+ }
1196
+
1197
+ const currentHash = aggregateDriftHash(topoTargets, driftResults);
1198
+ return {
1199
+ committedHash,
1200
+ currentHash,
1201
+ stale: committedHash !== null && committedHash !== currentHash,
1202
+ };
1203
+ };
1204
+
1205
+ const shouldRunLint = (options: WardenRunOptions): boolean =>
1206
+ options.tier ? options.tier !== 'drift' : !options.driftOnly;
1207
+
1208
+ const shouldRunDrift = (
1209
+ options: WardenRunOptions,
1210
+ effectiveConfig: EffectiveWardenConfig
1211
+ ): boolean => {
1212
+ if (effectiveConfig.lock === 'skip') {
1213
+ return false;
1214
+ }
1215
+
1216
+ if (options.tier) {
1217
+ return options.tier === 'drift';
1218
+ }
1219
+
1220
+ if (options.lintOnly) {
1221
+ return false;
1222
+ }
1223
+
1224
+ return options.driftOnly || effectiveConfig.depth === 'all';
1225
+ };
1226
+
1227
+ const reportPassed = ({
1228
+ drift,
1229
+ errorCount,
1230
+ failOn,
1231
+ warnCount,
1232
+ }: {
1233
+ readonly drift: DriftResult | null;
1234
+ readonly errorCount: number;
1235
+ readonly failOn: WardenFailOn;
1236
+ readonly warnCount: number;
1237
+ }): boolean =>
1238
+ errorCount === 0 &&
1239
+ (failOn === 'error' || warnCount === 0) &&
1240
+ !(drift?.stale ?? false) &&
1241
+ drift?.blockedReason === undefined;
1242
+
1243
+ const buildCliConfigLayer = (options: WardenRunOptions): WardenConfigLayer => ({
1244
+ ...(options.apps ? { apps: [...options.apps] } : {}),
1245
+ ...(options.depth ? { depth: options.depth } : {}),
1246
+ ...(options.drafts ? { drafts: options.drafts } : {}),
1247
+ ...(options.failOn ? { failOn: options.failOn } : {}),
1248
+ ...(options.format ? { format: options.format } : {}),
1249
+ ...(options.lock ? { lock: options.lock } : {}),
1250
+ ...(options.noLockMutation === undefined
1251
+ ? {}
1252
+ : { noLockMutation: options.noLockMutation }),
1253
+ });
1254
+
273
1255
  /**
274
1256
  * Run all warden checks and return a structured report.
275
1257
  */
276
1258
  export const runWarden = async (
277
- options: WardenOptions = {}
1259
+ options: WardenRunOptions = {}
278
1260
  ): Promise<WardenReport> => {
279
1261
  const rootDir = resolve(options.rootDir ?? process.cwd());
280
- const allDiagnostics = options.driftOnly
281
- ? []
282
- : await lintFiles(rootDir, options.topo);
283
- const drift = options.lintOnly
284
- ? null
285
- : await checkDrift(rootDir, options.topo);
1262
+ const { diagnostics: configDiagnostics, effectiveConfig } =
1263
+ resolveWardenConfig({
1264
+ cli: buildCliConfigLayer(options),
1265
+ config: options.config,
1266
+ env: options.env,
1267
+ });
1268
+ const optionDiagnostics =
1269
+ !options.tier && options.lintOnly && options.driftOnly
1270
+ ? [
1271
+ createOptionsDiagnostic(
1272
+ 'lintOnly and driftOnly cannot both be true. Use tier to select a single Warden mode.'
1273
+ ),
1274
+ ]
1275
+ : [];
1276
+ const topoTargets = topoTargetsFromOptions(options);
1277
+ const selector = {
1278
+ depth: options.tier ? undefined : effectiveConfig.depth,
1279
+ tier: options.tier,
1280
+ } satisfies WardenRuleSelector;
1281
+ const runLint = shouldRunLint(options);
1282
+ const runDrift = shouldRunDrift(options, effectiveConfig);
1283
+
1284
+ const rawDiagnostics = [
1285
+ ...configDiagnostics,
1286
+ ...optionDiagnostics,
1287
+ ...(runLint
1288
+ ? await lintFiles(
1289
+ rootDir,
1290
+ effectiveConfig.drafts,
1291
+ topoTargets,
1292
+ options.extraTopoRules ?? [],
1293
+ selector
1294
+ )
1295
+ : []),
1296
+ ];
1297
+ const allDiagnostics = rawDiagnostics.map(withDiagnosticGuidance);
1298
+ const drift = runDrift
1299
+ ? await checkDriftForTopoTargets(rootDir, topoTargets)
1300
+ : null;
286
1301
 
287
1302
  const errorCount = allDiagnostics.filter(
288
1303
  (d) => d.severity === 'error'
289
1304
  ).length;
290
1305
  const warnCount = allDiagnostics.filter((d) => d.severity === 'warn').length;
1306
+ const topoNames =
1307
+ topoTargets.length > 0
1308
+ ? topoTargets.map((target) => target.name ?? target.topo.name)
1309
+ : undefined;
291
1310
 
292
1311
  return {
293
1312
  diagnostics: allDiagnostics,
294
1313
  drift,
1314
+ effectiveConfig,
295
1315
  errorCount,
296
- passed:
297
- errorCount === 0 &&
298
- !(drift?.stale ?? false) &&
299
- drift?.blockedReason === undefined,
1316
+ passed: reportPassed({
1317
+ drift,
1318
+ errorCount,
1319
+ failOn: effectiveConfig.failOn,
1320
+ warnCount,
1321
+ }),
1322
+ ...(topoNames === undefined ? {} : { topoNames }),
300
1323
  warnCount,
301
1324
  };
302
1325
  };
303
1326
 
1327
+ const formatPlainGuidanceLink = (link: WardenGuidanceLink): string => {
1328
+ const target = link.path ?? link.url;
1329
+ if (target === undefined || target === link.label) {
1330
+ return link.label;
1331
+ }
1332
+ return `${link.label} (${target})`;
1333
+ };
1334
+
304
1335
  /**
305
1336
  * Format the lint section of the report.
306
1337
  */
@@ -318,6 +1349,25 @@ const formatLintSection = (report: WardenReport): string[] => {
318
1349
  lines.push(
319
1350
  ` ${d.filePath}:${String(d.line)} [${prefix}] ${d.rule} ${d.message}`
320
1351
  );
1352
+ if (d.guidance !== undefined) {
1353
+ lines.push(` Next: ${d.guidance.summary}`);
1354
+ for (const [index, step] of (d.guidance.steps ?? []).entries()) {
1355
+ lines.push(` ${String(index + 1)}. ${step}`);
1356
+ }
1357
+ if (d.guidance.commands !== undefined) {
1358
+ lines.push(
1359
+ ` Commands: ${d.guidance.commands.map((cmd) => `\`${cmd}\``).join(', ')}`
1360
+ );
1361
+ }
1362
+ if (d.guidance.docs !== undefined) {
1363
+ lines.push(
1364
+ ` Docs: ${d.guidance.docs.map(formatPlainGuidanceLink).join(', ')}`
1365
+ );
1366
+ }
1367
+ if (d.guidance.relatedRules !== undefined) {
1368
+ lines.push(` Related: ${d.guidance.relatedRules.join(', ')}`);
1369
+ }
1370
+ }
321
1371
  }
322
1372
 
323
1373
  return lines;
@@ -334,7 +1384,7 @@ const formatDriftSection = (drift: DriftResult | null): string[] => {
334
1384
  return [`Drift: blocked (${drift.blockedReason})`, ''];
335
1385
  }
336
1386
  const label = drift.stale
337
- ? 'Drift: trails.lock is stale (regenerate with `trails topo export`)'
1387
+ ? 'Drift: trails.lock is stale (regenerate with `trails topo compile`)'
338
1388
  : 'Drift: clean';
339
1389
  return [label, ''];
340
1390
  };
@@ -350,6 +1400,9 @@ const formatResultLine = (report: WardenReport): string => {
350
1400
  if (report.errorCount > 0) {
351
1401
  parts.push(`${report.errorCount} errors`);
352
1402
  }
1403
+ if (report.warnCount > 0 && report.effectiveConfig?.failOn === 'warning') {
1404
+ parts.push(`${report.warnCount} warnings`);
1405
+ }
353
1406
  if (report.drift?.blockedReason !== undefined) {
354
1407
  parts.push('established exports blocked');
355
1408
  } else if (report.drift?.stale) {