@ontrails/warden 1.0.0-beta.4 → 1.0.0-beta.42

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 (279) hide show
  1. package/CHANGELOG.md +837 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,306 @@
1
+ import {
2
+ shouldIncludeTrailForSurface,
3
+ webhookPathPatternsOverlap,
4
+ } from '@ontrails/core';
5
+ import type { Topo, Trail } from '@ontrails/core';
6
+
7
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
8
+
9
+ const RULE_NAME = 'webhook-route-collision';
10
+ const TOPO_FILE = '<topo>';
11
+
12
+ type RouteMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
13
+
14
+ interface RouteClaim {
15
+ readonly method: RouteMethod;
16
+ readonly parse?: unknown;
17
+ readonly path: string;
18
+ readonly sourceId?: string | undefined;
19
+ readonly trailId: string;
20
+ readonly type: 'derived-trail' | 'webhook';
21
+ readonly verify?: unknown;
22
+ }
23
+
24
+ const methodByIntent = {
25
+ destroy: 'DELETE',
26
+ read: 'GET',
27
+ write: 'POST',
28
+ } as const satisfies Record<string, RouteMethod>;
29
+
30
+ const derivedTrailPath = (trailId: string): string =>
31
+ `/${trailId.replaceAll('.', '/')}`;
32
+
33
+ const derivedTrailMethod = (
34
+ trail: Trail<unknown, unknown, unknown>
35
+ ): RouteMethod =>
36
+ (methodByIntent as Partial<Record<string, RouteMethod>>)[trail.intent] ??
37
+ 'POST';
38
+
39
+ const routeKey = ({ method, path }: Pick<RouteClaim, 'method' | 'path'>) =>
40
+ `${method} ${path}`;
41
+
42
+ const sortedClaims = (claims: readonly RouteClaim[]): readonly RouteClaim[] =>
43
+ [...claims].toSorted((a, b) => {
44
+ const byType = a.type.localeCompare(b.type);
45
+ if (byType !== 0) {
46
+ return byType;
47
+ }
48
+ const byTrail = a.trailId.localeCompare(b.trailId);
49
+ if (byTrail !== 0) {
50
+ return byTrail;
51
+ }
52
+ return (a.sourceId ?? '').localeCompare(b.sourceId ?? '');
53
+ });
54
+
55
+ /**
56
+ * Mirror the HTTP builder's default materialization policy. A derived trail
57
+ * route is only emitted when {@link shouldIncludeTrailForSurface} accepts the
58
+ * trail under empty options — internal trails are excluded unless callers
59
+ * explicitly include them by id, which the warden cannot anticipate. Without
60
+ * this filter the rule reports collisions against routes that HTTP would never
61
+ * materialize. See `packages/http/src/build.ts` (`eligibleTrails`).
62
+ */
63
+ const collectDerivedTrailClaims = (topo: Topo): readonly RouteClaim[] =>
64
+ topo
65
+ .list()
66
+ .filter((trail) => shouldIncludeTrailForSurface(trail))
67
+ .map((trail) => ({
68
+ method: derivedTrailMethod(trail),
69
+ path: derivedTrailPath(trail.id),
70
+ trailId: trail.id,
71
+ type: 'derived-trail' as const,
72
+ }));
73
+
74
+ const webhookMethod = (method: string | undefined): RouteMethod =>
75
+ (method?.trim().toUpperCase() as RouteMethod | undefined) ?? 'POST';
76
+
77
+ /**
78
+ * Mirror the HTTP builder's default materialization policy for webhook
79
+ * consumers. HTTP's `eligibleWebhookTrails` skips internal trails unless
80
+ * callers explicitly include them by id, which the warden cannot anticipate.
81
+ * Without this filter the rule reports collisions against webhook routes that
82
+ * HTTP would never materialize. See `packages/http/src/build.ts`
83
+ * (`eligibleWebhookTrails`, `isInternalTrail`).
84
+ *
85
+ * `shouldIncludeTrailForSurface` cannot be reused here because it short-
86
+ * circuits on any trail with `activationSources.length > 0` — those are exactly
87
+ * the webhook consumer trails we need to inspect. Replicate the visibility
88
+ * shape inline: `visibility: 'internal'` and the legacy `meta.internal === true`
89
+ * convention both opt out of default materialization.
90
+ */
91
+ const isInternalConsumer = (trail: Trail<unknown, unknown, unknown>): boolean =>
92
+ trail.visibility === 'internal' || trail.meta?.['internal'] === true;
93
+
94
+ const collectWebhookClaims = (topo: Topo): readonly RouteClaim[] => {
95
+ const claims: RouteClaim[] = [];
96
+
97
+ for (const trail of topo.list()) {
98
+ if (isInternalConsumer(trail)) {
99
+ continue;
100
+ }
101
+ for (const activation of trail.activationSources) {
102
+ if (
103
+ activation.source.kind !== 'webhook' ||
104
+ typeof activation.source.path !== 'string'
105
+ ) {
106
+ continue;
107
+ }
108
+ claims.push({
109
+ method: webhookMethod(activation.source.method),
110
+ parse: activation.source.parse,
111
+ path: activation.source.path.trim(),
112
+ sourceId: activation.source.id,
113
+ trailId: trail.id,
114
+ type: 'webhook',
115
+ verify: activation.source.verify,
116
+ });
117
+ }
118
+ }
119
+
120
+ return claims;
121
+ };
122
+
123
+ const claimLabel = (claim: RouteClaim): string =>
124
+ claim.type === 'webhook'
125
+ ? `webhook source "${claim.sourceId}" on trail "${claim.trailId}"`
126
+ : `derived trail route "${claim.trailId}"`;
127
+
128
+ const buildDiagnostic = (
129
+ key: string,
130
+ claims: readonly RouteClaim[]
131
+ ): WardenDiagnostic => ({
132
+ filePath: TOPO_FILE,
133
+ line: 1,
134
+ message: `HTTP webhook route collision on ${key}: ${sortedClaims(claims)
135
+ .map(claimLabel)
136
+ .join(
137
+ ', '
138
+ )}. Give each webhook source a distinct method/path pair or move the direct trail route before materializing the HTTP surface.`,
139
+ rule: RULE_NAME,
140
+ severity: 'error',
141
+ });
142
+
143
+ const buildPolicyMismatchDiagnostic = (
144
+ key: string,
145
+ sourceId: string | undefined,
146
+ field: 'parse' | 'verifier',
147
+ claims: readonly RouteClaim[]
148
+ ): WardenDiagnostic => {
149
+ const labels = sortedClaims(claims).map(claimLabel).join(', ');
150
+ const remediation =
151
+ field === 'verifier'
152
+ ? 'Reuse the same WebhookSource object so both consumers run under one verifier.'
153
+ : 'Reuse the same WebhookSource object so both consumers parse payloads under one contract.';
154
+ return {
155
+ filePath: TOPO_FILE,
156
+ line: 1,
157
+ message: `HTTP webhook route collision on ${key}: trails sharing webhook source "${sourceId ?? '<unknown>'}" declare a mismatched ${field} policy (${labels}). ${remediation}`,
158
+ rule: RULE_NAME,
159
+ severity: 'error',
160
+ };
161
+ };
162
+
163
+ /**
164
+ * Within a group of webhook claims that share the same `sourceId`, surface a
165
+ * diagnostic when the underlying source declarations diverge on `verify` or
166
+ * `parse`. The HTTP builder rejects the same combination at runtime in
167
+ * {@link mergeWebhookRoutes}; flagging it here surfaces the failure at lint
168
+ * time instead of waiting for surface construction. Reference equality matches
169
+ * the runtime check (a shared `WebhookSource` always passes; two separately
170
+ * declared schemas or callbacks are treated as distinct policies).
171
+ */
172
+ const collectPolicyMismatchDiagnostics = (
173
+ key: string,
174
+ webhookClaims: readonly RouteClaim[]
175
+ ): WardenDiagnostic[] => {
176
+ const claimsBySourceId = new Map<string, RouteClaim[]>();
177
+ for (const claim of webhookClaims) {
178
+ const id = claim.sourceId ?? '';
179
+ const current = claimsBySourceId.get(id) ?? [];
180
+ current.push(claim);
181
+ claimsBySourceId.set(id, current);
182
+ }
183
+
184
+ const diagnostics: WardenDiagnostic[] = [];
185
+ for (const [id, grouped] of claimsBySourceId) {
186
+ if (grouped.length < 2) {
187
+ continue;
188
+ }
189
+ const verifyRefs = new Set(grouped.map((claim) => claim.verify));
190
+ if (verifyRefs.size > 1) {
191
+ diagnostics.push(
192
+ buildPolicyMismatchDiagnostic(key, id, 'verifier', grouped)
193
+ );
194
+ continue;
195
+ }
196
+ const parseRefs = new Set(grouped.map((claim) => claim.parse));
197
+ if (parseRefs.size > 1) {
198
+ diagnostics.push(
199
+ buildPolicyMismatchDiagnostic(key, id, 'parse', grouped)
200
+ );
201
+ }
202
+ }
203
+ return diagnostics;
204
+ };
205
+
206
+ const hasDynamicSegment = (path: string): boolean => path.includes('/:');
207
+
208
+ /**
209
+ * Flag distinct route keys whose path patterns can both match one concrete
210
+ * request. Exact-key collisions are handled by the grouping pass; this pass
211
+ * extends detection to dynamic-segment patterns (`/hooks/:endpoint` vs
212
+ * `/hooks/github`), where at least one webhook claim participates.
213
+ */
214
+ const collectPatternOverlapDiagnostics = (
215
+ claimsByRoute: ReadonlyMap<string, readonly RouteClaim[]>
216
+ ): WardenDiagnostic[] => {
217
+ const diagnostics: WardenDiagnostic[] = [];
218
+ const keys = [...claimsByRoute.keys()].toSorted();
219
+
220
+ for (const [index, leftKey] of keys.entries()) {
221
+ for (const rightKey of keys.slice(index + 1)) {
222
+ const leftClaims = claimsByRoute.get(leftKey) ?? [];
223
+ const rightClaims = claimsByRoute.get(rightKey) ?? [];
224
+ const [leftFirst] = leftClaims;
225
+ const [rightFirst] = rightClaims;
226
+ if (leftFirst === undefined || rightFirst === undefined) {
227
+ continue;
228
+ }
229
+ if (leftFirst.method !== rightFirst.method) {
230
+ continue;
231
+ }
232
+ if (
233
+ !(
234
+ hasDynamicSegment(leftFirst.path) ||
235
+ hasDynamicSegment(rightFirst.path)
236
+ )
237
+ ) {
238
+ continue;
239
+ }
240
+ const participants = [...leftClaims, ...rightClaims];
241
+ if (!participants.some((claim) => claim.type === 'webhook')) {
242
+ continue;
243
+ }
244
+ if (!webhookPathPatternsOverlap(leftFirst.path, rightFirst.path)) {
245
+ continue;
246
+ }
247
+ diagnostics.push({
248
+ filePath: TOPO_FILE,
249
+ line: 1,
250
+ message: `HTTP webhook route pattern overlap between ${leftKey} and ${rightKey}: ${sortedClaims(
251
+ participants
252
+ )
253
+ .map(claimLabel)
254
+ .join(
255
+ ', '
256
+ )}. Both patterns can match one request path; make the patterns disjoint or merge the sources.`,
257
+ rule: RULE_NAME,
258
+ severity: 'error',
259
+ });
260
+ }
261
+ }
262
+ return diagnostics;
263
+ };
264
+
265
+ const buildDiagnostics = (claims: readonly RouteClaim[]) => {
266
+ const claimsByRoute = new Map<string, RouteClaim[]>();
267
+ for (const claim of claims) {
268
+ const key = routeKey(claim);
269
+ const current = claimsByRoute.get(key) ?? [];
270
+ current.push(claim);
271
+ claimsByRoute.set(key, current);
272
+ }
273
+
274
+ const diagnostics: WardenDiagnostic[] = [];
275
+ for (const [key, grouped] of claimsByRoute) {
276
+ const webhookClaims = grouped.filter((claim) => claim.type === 'webhook');
277
+ if (webhookClaims.length === 0) {
278
+ continue;
279
+ }
280
+ const webhookSourceIds = new Set(
281
+ webhookClaims.map((claim) => claim.sourceId)
282
+ );
283
+ if (
284
+ webhookSourceIds.size > 1 ||
285
+ grouped.some((claim) => claim.type === 'derived-trail')
286
+ ) {
287
+ diagnostics.push(buildDiagnostic(key, grouped));
288
+ continue;
289
+ }
290
+ diagnostics.push(...collectPolicyMismatchDiagnostics(key, webhookClaims));
291
+ }
292
+ diagnostics.push(...collectPatternOverlapDiagnostics(claimsByRoute));
293
+ return diagnostics;
294
+ };
295
+
296
+ export const webhookRouteCollision: TopoAwareWardenRule = {
297
+ checkTopo: (topo) =>
298
+ buildDiagnostics([
299
+ ...collectDerivedTrailClaims(topo),
300
+ ...collectWebhookClaims(topo),
301
+ ]),
302
+ description:
303
+ 'Reject webhook source method/path pairs that collide with each other or derived HTTP trail routes.',
304
+ name: RULE_NAME,
305
+ severity: 'error',
306
+ };
@@ -0,0 +1,84 @@
1
+ import { Result, schedule, signal, topo, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import { activationOrphan } from '../rules/activation-orphan.js';
5
+ import { wrapTopoRule } from './wrap-rule.js';
6
+
7
+ const orphanSignal = signal('invoice.paid', {
8
+ payload: z.object({ invoiceId: z.string() }),
9
+ });
10
+
11
+ const orphanConsumer = trail('invoice.audit', {
12
+ implementation: () => Result.ok({ ok: true }),
13
+ input: z.object({ invoiceId: z.string() }),
14
+ on: [orphanSignal],
15
+ output: z.object({ ok: z.boolean() }),
16
+ });
17
+
18
+ const producedSignal = signal('invoice.created', {
19
+ from: ['invoice.create'],
20
+ payload: z.object({ invoiceId: z.string() }),
21
+ });
22
+
23
+ const producerTrail = trail('invoice.create', {
24
+ fires: [producedSignal],
25
+ implementation: async (_input, ctx) => {
26
+ await ctx.fire?.(producedSignal, { invoiceId: 'inv_1' });
27
+ return Result.ok({ invoiceId: 'inv_1' });
28
+ },
29
+ input: z.object({}),
30
+ output: z.object({ invoiceId: z.string() }),
31
+ });
32
+
33
+ const producedConsumer = trail('invoice.index', {
34
+ implementation: () => Result.ok({ ok: true }),
35
+ input: z.object({ invoiceId: z.string() }),
36
+ on: [producedSignal],
37
+ output: z.object({ ok: z.boolean() }),
38
+ });
39
+
40
+ const scheduledConsumer = trail('invoice.reconcile', {
41
+ implementation: () => Result.ok({ ok: true }),
42
+ input: z.object({}),
43
+ on: [schedule('schedule.invoice.reconcile', { cron: '0 * * * *' })],
44
+ output: z.object({ ok: z.boolean() }),
45
+ });
46
+
47
+ export const activationOrphanTrail = wrapTopoRule({
48
+ examples: [
49
+ {
50
+ expected: {
51
+ diagnostics: [
52
+ {
53
+ filePath: '<topo>',
54
+ line: 1,
55
+ message:
56
+ 'Signal activation source "invoice.paid" activates trail "invoice.audit" but has no producer declaration in the topo. Add a trail fires: declaration, add signal from: producer metadata, or remove the unused activation source.',
57
+ rule: 'activation-orphan',
58
+ severity: 'warn',
59
+ },
60
+ ],
61
+ },
62
+ input: {
63
+ topo: topo('trl-452-activation-orphan', {
64
+ orphanConsumer,
65
+ orphanSignal,
66
+ }),
67
+ },
68
+ name: 'Signal activation consumers need producer declarations',
69
+ },
70
+ {
71
+ expected: { diagnostics: [] },
72
+ input: {
73
+ topo: topo('trl-452-activation-clean', {
74
+ producedConsumer,
75
+ producedSignal,
76
+ producerTrail,
77
+ scheduledConsumer,
78
+ }),
79
+ },
80
+ name: 'Produced signals and schedules are not activation orphans',
81
+ },
82
+ ],
83
+ rule: activationOrphan,
84
+ });
@@ -0,0 +1,108 @@
1
+ import { capturedKernel } from '../rules/captured-kernel.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const capturedKernelTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: {
8
+ diagnostics: [
9
+ {
10
+ filePath: 'packages/core/src/kernel.ts',
11
+ guidance: {
12
+ steps: [
13
+ 'Review whether the public subpath should become an owned package surface, move back behind the package root, or be split into a better-owned package.',
14
+ 'If the imported capability is reusable source-code machinery, serves at least two independently owned toolchain capabilities, exposes one genuinely shared contract, and owns no verdict, migration plan, graph query, or surface rendering, consider relocating it to @ontrails/source.',
15
+ 'Otherwise, preserve the current owner or choose another doctrinal owner.',
16
+ ],
17
+ summary:
18
+ 'Review ownership before an internal re-exported kernel hardens into a public package seam.',
19
+ },
20
+ line: 1,
21
+ message:
22
+ '@ontrails/core export target "@ontrails/core/kernel" re-exports internal target "./internal/kernel.js" and is consumed by external production packages @ontrails/cli, @ontrails/store. Review ownership of the exported subpath and its captured kernel before it becomes a durable public seam.',
23
+ rule: 'captured-kernel',
24
+ severity: 'warn',
25
+ },
26
+ ],
27
+ },
28
+ input: {
29
+ filePath: 'packages/core/src/kernel.ts',
30
+ importResolutionsByFile: {
31
+ 'packages/cli/src/index.ts': [
32
+ {
33
+ crossesPackageBoundary: true,
34
+ importSource: '@ontrails/core/kernel',
35
+ importerPath: 'packages/cli/src/index.ts',
36
+ isInternalTarget: false,
37
+ line: 1,
38
+ packageName: '@ontrails/core',
39
+ packageRoot: 'packages/core',
40
+ resolvedPath: 'packages/core/src/kernel.ts',
41
+ usesPublicExport: true,
42
+ },
43
+ ],
44
+ 'packages/core/src/kernel.ts': [
45
+ {
46
+ crossesPackageBoundary: false,
47
+ importSource: './internal/kernel.js',
48
+ importerPath: 'packages/core/src/kernel.ts',
49
+ isInternalTarget: true,
50
+ line: 1,
51
+ packageName: '@ontrails/core',
52
+ packageRoot: 'packages/core',
53
+ resolvedPath: 'packages/core/src/internal/kernel.ts',
54
+ usesPublicExport: false,
55
+ },
56
+ ],
57
+ 'packages/store/src/index.ts': [
58
+ {
59
+ crossesPackageBoundary: true,
60
+ importSource: '@ontrails/core/kernel',
61
+ importerPath: 'packages/store/src/index.ts',
62
+ isInternalTarget: false,
63
+ line: 1,
64
+ packageName: '@ontrails/core',
65
+ packageRoot: 'packages/core',
66
+ resolvedPath: 'packages/core/src/kernel.ts',
67
+ usesPublicExport: true,
68
+ },
69
+ ],
70
+ },
71
+ knownTrailIds: [],
72
+ publicWorkspaces: {
73
+ '@ontrails/cli': {
74
+ exportTargets: {
75
+ '@ontrails/cli': 'packages/cli/src/index.ts',
76
+ },
77
+ hasExports: true,
78
+ name: '@ontrails/cli',
79
+ packageJsonPath: 'packages/cli/package.json',
80
+ rootDir: 'packages/cli',
81
+ },
82
+ '@ontrails/core': {
83
+ exportTargets: {
84
+ '@ontrails/core': 'packages/core/src/index.ts',
85
+ '@ontrails/core/kernel': 'packages/core/src/kernel.ts',
86
+ },
87
+ hasExports: true,
88
+ name: '@ontrails/core',
89
+ packageJsonPath: 'packages/core/package.json',
90
+ rootDir: 'packages/core',
91
+ },
92
+ '@ontrails/store': {
93
+ exportTargets: {
94
+ '@ontrails/store': 'packages/store/src/index.ts',
95
+ },
96
+ hasExports: true,
97
+ name: '@ontrails/store',
98
+ packageJsonPath: 'packages/store/package.json',
99
+ rootDir: 'packages/store',
100
+ },
101
+ },
102
+ sourceCode: "export { kernel } from './internal/kernel.js';\n",
103
+ },
104
+ name: 'Flags captured kernels consumed by multiple production packages',
105
+ },
106
+ ],
107
+ rule: capturedKernel,
108
+ });
@@ -0,0 +1,29 @@
1
+ import { circularRefs } from '../rules/circular-refs.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const circularRefsTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ entityReferencesByName: {
10
+ gist: ['user'],
11
+ user: [],
12
+ },
13
+ filePath: 'entities.ts',
14
+ knownEntityIds: ['gist', 'user'],
15
+ knownTrailIds: [],
16
+ sourceCode: `const user = entity("user", {
17
+ id: z.string().uuid(),
18
+ }, { identity: "id" });
19
+
20
+ const gist = entity("gist", {
21
+ id: z.string().uuid(),
22
+ ownerId: user.id(),
23
+ }, { identity: "id" });`,
24
+ },
25
+ name: 'Acyclic entity references stay clean',
26
+ },
27
+ ],
28
+ rule: circularRefs,
29
+ });
@@ -0,0 +1,47 @@
1
+ import { Result, topo, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import { cliCommandRouteCoherence } from '../rules/cli-command-route-coherence.js';
5
+ import { wrapTopoRule } from './wrap-rule.js';
6
+
7
+ const searchTrail = trail('wayfind.search', {
8
+ cli: {
9
+ aliases: ['find'],
10
+ },
11
+ implementation: () => Result.ok([]),
12
+ input: z.object({ query: z.string() }),
13
+ output: z.array(z.string()),
14
+ });
15
+
16
+ const collidingTrail = trail('wayfind.find', {
17
+ implementation: () => Result.ok([]),
18
+ input: z.object({ query: z.string() }),
19
+ output: z.array(z.string()),
20
+ });
21
+
22
+ export const cliCommandRouteCoherenceTrail = wrapTopoRule({
23
+ examples: [
24
+ {
25
+ expected: {
26
+ diagnostics: [
27
+ {
28
+ filePath: '<topo>',
29
+ line: 1,
30
+ message:
31
+ 'CLI command route collision on "wayfind find": canonical route for trail "wayfind.find" (derived), alias route for trail "wayfind.search" (trail). Rename or remove one CLI alias so every accepted command path normalizes into exactly one trail contract.',
32
+ rule: 'cli-command-route-coherence',
33
+ severity: 'error',
34
+ },
35
+ ],
36
+ },
37
+ input: {
38
+ topo: topo('cli-command-route-coherence', {
39
+ collidingTrail,
40
+ searchTrail,
41
+ }),
42
+ },
43
+ name: 'CLI alias colliding with another command route',
44
+ },
45
+ ],
46
+ rule: cliCommandRouteCoherence,
47
+ });
@@ -0,0 +1,22 @@
1
+ import { composesDeclarations } from '../rules/composes-declarations.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const composesDeclarationsTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'clean.ts',
10
+ sourceCode: `trail("entity.onboard", {
11
+ composes: ["entity.create"],
12
+ implementation: async (input, ctx) => {
13
+ const result = await ctx.compose("entity.create", input);
14
+ return Result.ok(result);
15
+ }
16
+ })`,
17
+ },
18
+ name: 'Matched composing declarations and calls',
19
+ },
20
+ ],
21
+ rule: composesDeclarations,
22
+ });
@@ -0,0 +1,21 @@
1
+ import { contextNoSurfaceTypes } from '../rules/context-no-surface-types.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const contextNoSurfaceTypesTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'clean.ts',
10
+ sourceCode: `import { trail, Result } from "@ontrails/core";
11
+ trail("entity.show", {
12
+ implementation: async (input, ctx) => {
13
+ return Result.ok({ name: "test" });
14
+ }
15
+ })`,
16
+ },
17
+ name: 'Clean trail without surface imports',
18
+ },
19
+ ],
20
+ rule: contextNoSurfaceTypes,
21
+ });
@@ -0,0 +1,26 @@
1
+ import { deadInternalTrail } from '../rules/dead-internal-trail.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const deadInternalTrailTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ composeTargetTrailIds: ['entity.sync'],
10
+ filePath: 'clean.ts',
11
+ knownTrailIds: ['entity.public', 'entity.sync'],
12
+ sourceCode: `trail('entity.public', {
13
+ composes: ['entity.sync'],
14
+ implementation: async (_input, ctx) => ctx.compose('entity.sync', {}),
15
+ });
16
+
17
+ trail('entity.sync', {
18
+ visibility: 'internal',
19
+ implementation: async () => Result.ok({}),
20
+ });`,
21
+ },
22
+ name: 'Internal trails stay clean when another trail composes them',
23
+ },
24
+ ],
25
+ rule: deadInternalTrail,
26
+ });
@@ -0,0 +1,31 @@
1
+ import { deadPublicTrail } from '../rules/dead-public-trail.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const deadPublicTrailTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: {
8
+ diagnostics: [
9
+ {
10
+ filePath: 'packages/regrade/src/downstream/report.ts',
11
+ line: 1,
12
+ message:
13
+ 'Exported public trail "regrade.downstream.report" is not registered in a configured app topo, composed by another trail, or activated by on:. Anchor the contract in a topo, compose it, mark it internal, or remove the public export.',
14
+ rule: 'dead-public-trail',
15
+ severity: 'warn',
16
+ },
17
+ ],
18
+ },
19
+ input: {
20
+ filePath: 'packages/regrade/src/downstream/report.ts',
21
+ knownTrailIds: ['regrade.downstream.report', 'regrade'],
22
+ sourceCode: `export const regradeReportTrail = trail('regrade.downstream.report', {
23
+ implementation: async () => Result.ok({}),
24
+ });`,
25
+ topoTrailIds: ['regrade'],
26
+ },
27
+ name: 'Exported package trail missing from registered app topos',
28
+ },
29
+ ],
30
+ rule: deadPublicTrail,
31
+ });