@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.22

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 (249) hide show
  1. package/CHANGELOG.md +508 -6
  2. package/README.md +77 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +27 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +28 -0
  7. package/src/cli.ts +1374 -103
  8. package/src/command.ts +953 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +106 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +206 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/resolve.ts +530 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/ast.ts +3176 -85
  20. package/src/rules/circular-refs.ts +154 -0
  21. package/src/rules/composes-declarations.ts +704 -0
  22. package/src/rules/context-no-surface-types.ts +68 -8
  23. package/src/rules/contour-exists.ts +251 -0
  24. package/src/rules/contour-ids.ts +15 -0
  25. package/src/rules/dead-internal-trail.ts +154 -0
  26. package/src/rules/draft-file-marking.ts +160 -0
  27. package/src/rules/draft-visible-debt.ts +87 -0
  28. package/src/rules/error-mapping-completeness.ts +288 -0
  29. package/src/rules/example-valid.ts +401 -0
  30. package/src/rules/fires-declarations.ts +758 -0
  31. package/src/rules/implementation-returns-result.ts +1265 -95
  32. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  33. package/src/rules/incomplete-crud.ts +580 -0
  34. package/src/rules/index.ts +219 -18
  35. package/src/rules/intent-propagation.ts +127 -0
  36. package/src/rules/layer-field-name-drift.ts +96 -0
  37. package/src/rules/metadata.ts +654 -0
  38. package/src/rules/missing-reconcile.ts +98 -0
  39. package/src/rules/missing-visibility.ts +110 -0
  40. package/src/rules/no-destructured-compose.ts +192 -0
  41. package/src/rules/no-dev-permit-in-source.ts +99 -0
  42. package/src/rules/no-direct-implementation-call.ts +7 -7
  43. package/src/rules/no-legacy-layer-imports.ts +211 -0
  44. package/src/rules/no-native-error-result.ts +111 -0
  45. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  46. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  47. package/src/rules/no-sync-result-assumption.ts +1134 -99
  48. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  49. package/src/rules/no-throw-in-implementation.ts +10 -9
  50. package/src/rules/no-top-level-surface.ts +389 -0
  51. package/src/rules/on-references-exist.ts +194 -0
  52. package/src/rules/orphaned-signal.ts +150 -0
  53. package/src/rules/owner-projection-parity.ts +146 -0
  54. package/src/rules/permit-governance.ts +25 -0
  55. package/src/rules/public-export-example-coverage.ts +553 -0
  56. package/src/rules/public-internal-deep-imports.ts +517 -0
  57. package/src/rules/public-output-schema.ts +29 -0
  58. package/src/rules/public-union-output-discriminants.ts +150 -0
  59. package/src/rules/read-intent-fires.ts +187 -0
  60. package/src/rules/reference-exists.ts +98 -0
  61. package/src/rules/registry-names.ts +145 -0
  62. package/src/rules/resolved-import-boundary.ts +146 -0
  63. package/src/rules/resource-declarations.ts +704 -0
  64. package/src/rules/resource-exists.ts +179 -0
  65. package/src/rules/resource-id-grammar.ts +65 -0
  66. package/src/rules/resource-mock-coverage.ts +115 -0
  67. package/src/rules/scan.ts +38 -25
  68. package/src/rules/scheduled-destroy-intent.ts +44 -0
  69. package/src/rules/signal-graph-coaching.ts +191 -0
  70. package/src/rules/specs.ts +9 -5
  71. package/src/rules/static-resource-accessor-preference.ts +657 -0
  72. package/src/rules/surface-facet-coherence.ts +370 -0
  73. package/src/rules/trail-versioning-source.ts +1094 -0
  74. package/src/rules/trail-versioning-topo.ts +172 -0
  75. package/src/rules/types.ts +270 -6
  76. package/src/rules/unmaterialized-activation-source.ts +84 -0
  77. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  78. package/src/rules/valid-describe-refs.ts +160 -32
  79. package/src/rules/valid-detour-contract.ts +78 -0
  80. package/src/rules/warden-export-symmetry.ts +533 -0
  81. package/src/rules/warden-rules-use-ast.ts +996 -0
  82. package/src/rules/webhook-route-collision.ts +243 -0
  83. package/src/trails/activation-orphan.trail.ts +84 -0
  84. package/src/trails/circular-refs.trail.ts +29 -0
  85. package/src/trails/composes-declarations.trail.ts +22 -0
  86. package/src/trails/context-no-surface-types.trail.ts +21 -0
  87. package/src/trails/contour-exists.trail.ts +21 -0
  88. package/src/trails/dead-internal-trail.trail.ts +26 -0
  89. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  90. package/src/trails/draft-file-marking.trail.ts +16 -0
  91. package/src/trails/draft-visible-debt.trail.ts +16 -0
  92. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  93. package/src/trails/example-valid.trail.ts +25 -0
  94. package/src/trails/fires-declarations.trail.ts +23 -0
  95. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  96. package/src/trails/implementation-returns-result.trail.ts +20 -0
  97. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  98. package/src/trails/incomplete-crud.trail.ts +39 -0
  99. package/src/trails/index.ts +78 -0
  100. package/src/trails/intent-propagation.trail.ts +30 -0
  101. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  102. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  103. package/src/trails/missing-reconcile.trail.ts +33 -0
  104. package/src/trails/missing-visibility.trail.ts +22 -0
  105. package/src/trails/no-destructured-compose.trail.ts +44 -0
  106. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  107. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  108. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  109. package/src/trails/no-native-error-result.trail.ts +18 -0
  110. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  111. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  112. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  113. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  114. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  115. package/src/trails/no-top-level-surface.trail.ts +43 -0
  116. package/src/trails/on-references-exist.trail.ts +21 -0
  117. package/src/trails/orphaned-signal.trail.ts +36 -0
  118. package/src/trails/owner-projection-parity.trail.ts +26 -0
  119. package/src/trails/pending-force.trail.ts +21 -0
  120. package/src/trails/permit-governance.trail.ts +51 -0
  121. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  122. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  123. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  124. package/src/trails/public-output-schema.trail.ts +55 -0
  125. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  126. package/src/trails/read-intent-fires.trail.ts +20 -0
  127. package/src/trails/reference-exists.trail.ts +25 -0
  128. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  129. package/src/trails/resource-declarations.trail.ts +25 -0
  130. package/src/trails/resource-exists.trail.ts +27 -0
  131. package/src/trails/resource-id-grammar.trail.ts +39 -0
  132. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  133. package/src/trails/run.ts +162 -0
  134. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  135. package/src/trails/schema.ts +194 -0
  136. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  137. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  138. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  139. package/src/trails/topo.ts +6 -0
  140. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  141. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  142. package/src/trails/valid-describe-refs.trail.ts +18 -0
  143. package/src/trails/valid-detour-contract.trail.ts +71 -0
  144. package/src/trails/version-gap.trail.ts +35 -0
  145. package/src/trails/version-pinned-compose.trail.ts +23 -0
  146. package/src/trails/version-without-examples.trail.ts +38 -0
  147. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  148. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  149. package/src/trails/webhook-route-collision.trail.ts +50 -0
  150. package/src/trails/wrap-rule.ts +213 -0
  151. package/src/workspaces.ts +238 -0
  152. package/.turbo/turbo-build.log +0 -1
  153. package/.turbo/turbo-lint.log +0 -3
  154. package/.turbo/turbo-typecheck.log +0 -1
  155. package/dist/cli.d.ts +0 -46
  156. package/dist/cli.d.ts.map +0 -1
  157. package/dist/cli.js +0 -221
  158. package/dist/cli.js.map +0 -1
  159. package/dist/drift.d.ts +0 -26
  160. package/dist/drift.d.ts.map +0 -1
  161. package/dist/drift.js +0 -27
  162. package/dist/drift.js.map +0 -1
  163. package/dist/formatters.d.ts +0 -29
  164. package/dist/formatters.d.ts.map +0 -1
  165. package/dist/formatters.js +0 -87
  166. package/dist/formatters.js.map +0 -1
  167. package/dist/index.d.ts +0 -26
  168. package/dist/index.d.ts.map +0 -1
  169. package/dist/index.js +0 -26
  170. package/dist/index.js.map +0 -1
  171. package/dist/rules/ast.d.ts +0 -41
  172. package/dist/rules/ast.d.ts.map +0 -1
  173. package/dist/rules/ast.js +0 -163
  174. package/dist/rules/ast.js.map +0 -1
  175. package/dist/rules/context-no-surface-types.d.ts +0 -12
  176. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  177. package/dist/rules/context-no-surface-types.js +0 -96
  178. package/dist/rules/context-no-surface-types.js.map +0 -1
  179. package/dist/rules/implementation-returns-result.d.ts +0 -13
  180. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  181. package/dist/rules/implementation-returns-result.js +0 -231
  182. package/dist/rules/implementation-returns-result.js.map +0 -1
  183. package/dist/rules/index.d.ts +0 -22
  184. package/dist/rules/index.d.ts.map +0 -1
  185. package/dist/rules/index.js +0 -41
  186. package/dist/rules/index.js.map +0 -1
  187. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  188. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  189. package/dist/rules/no-direct-impl-in-route.js +0 -46
  190. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  191. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  192. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  193. package/dist/rules/no-direct-implementation-call.js +0 -39
  194. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  195. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  196. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  197. package/dist/rules/no-sync-result-assumption.js +0 -98
  198. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  199. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  200. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  201. package/dist/rules/no-throw-in-detour-target.js +0 -87
  202. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  203. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  204. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  205. package/dist/rules/no-throw-in-implementation.js +0 -34
  206. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  207. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  208. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  209. package/dist/rules/prefer-schema-inference.js +0 -86
  210. package/dist/rules/prefer-schema-inference.js.map +0 -1
  211. package/dist/rules/scan.d.ts +0 -8
  212. package/dist/rules/scan.d.ts.map +0 -1
  213. package/dist/rules/scan.js +0 -32
  214. package/dist/rules/scan.js.map +0 -1
  215. package/dist/rules/specs.d.ts +0 -29
  216. package/dist/rules/specs.d.ts.map +0 -1
  217. package/dist/rules/specs.js +0 -192
  218. package/dist/rules/specs.js.map +0 -1
  219. package/dist/rules/structure.d.ts +0 -13
  220. package/dist/rules/structure.d.ts.map +0 -1
  221. package/dist/rules/structure.js +0 -142
  222. package/dist/rules/structure.js.map +0 -1
  223. package/dist/rules/types.d.ts +0 -52
  224. package/dist/rules/types.d.ts.map +0 -1
  225. package/dist/rules/types.js +0 -2
  226. package/dist/rules/types.js.map +0 -1
  227. package/dist/rules/valid-describe-refs.d.ts +0 -7
  228. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  229. package/dist/rules/valid-describe-refs.js +0 -51
  230. package/dist/rules/valid-describe-refs.js.map +0 -1
  231. package/dist/rules/valid-detour-refs.d.ts +0 -6
  232. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  233. package/dist/rules/valid-detour-refs.js +0 -116
  234. package/dist/rules/valid-detour-refs.js.map +0 -1
  235. package/src/__tests__/cli.test.ts +0 -198
  236. package/src/__tests__/drift.test.ts +0 -74
  237. package/src/__tests__/formatters.test.ts +0 -157
  238. package/src/__tests__/implementation-returns-result.test.ts +0 -75
  239. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  240. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  241. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  242. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  243. package/src/__tests__/rules.test.ts +0 -188
  244. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  245. package/src/rules/no-direct-impl-in-route.ts +0 -77
  246. package/src/rules/no-throw-in-detour-target.ts +0 -150
  247. package/src/rules/valid-detour-refs.ts +0 -187
  248. package/tsconfig.json +0 -9
  249. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,243 @@
1
+ import { shouldIncludeTrailForSurface } from '@ontrails/core';
2
+ import type { Topo, Trail } from '@ontrails/core';
3
+
4
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
5
+
6
+ const RULE_NAME = 'webhook-route-collision';
7
+ const TOPO_FILE = '<topo>';
8
+
9
+ type RouteMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
10
+
11
+ interface RouteClaim {
12
+ readonly method: RouteMethod;
13
+ readonly parse?: unknown;
14
+ readonly path: string;
15
+ readonly sourceId?: string | undefined;
16
+ readonly trailId: string;
17
+ readonly type: 'derived-trail' | 'webhook';
18
+ readonly verify?: unknown;
19
+ }
20
+
21
+ const methodByIntent = {
22
+ destroy: 'DELETE',
23
+ read: 'GET',
24
+ write: 'POST',
25
+ } as const satisfies Record<string, RouteMethod>;
26
+
27
+ const derivedTrailPath = (trailId: string): string =>
28
+ `/${trailId.replaceAll('.', '/')}`;
29
+
30
+ const derivedTrailMethod = (
31
+ trail: Trail<unknown, unknown, unknown>
32
+ ): RouteMethod =>
33
+ (methodByIntent as Partial<Record<string, RouteMethod>>)[trail.intent] ??
34
+ 'POST';
35
+
36
+ const routeKey = ({ method, path }: Pick<RouteClaim, 'method' | 'path'>) =>
37
+ `${method} ${path}`;
38
+
39
+ const sortedClaims = (claims: readonly RouteClaim[]): readonly RouteClaim[] =>
40
+ [...claims].toSorted((a, b) => {
41
+ const byType = a.type.localeCompare(b.type);
42
+ if (byType !== 0) {
43
+ return byType;
44
+ }
45
+ const byTrail = a.trailId.localeCompare(b.trailId);
46
+ if (byTrail !== 0) {
47
+ return byTrail;
48
+ }
49
+ return (a.sourceId ?? '').localeCompare(b.sourceId ?? '');
50
+ });
51
+
52
+ /**
53
+ * Mirror the HTTP builder's default materialization policy. A derived trail
54
+ * route is only emitted when {@link shouldIncludeTrailForSurface} accepts the
55
+ * trail under empty options — internal trails are excluded unless callers
56
+ * explicitly include them by id, which the warden cannot anticipate. Without
57
+ * this filter the rule reports collisions against routes that HTTP would never
58
+ * materialize. See `packages/http/src/build.ts` (`eligibleTrails`).
59
+ */
60
+ const collectDerivedTrailClaims = (topo: Topo): readonly RouteClaim[] =>
61
+ topo
62
+ .list()
63
+ .filter((trail) => shouldIncludeTrailForSurface(trail))
64
+ .map((trail) => ({
65
+ method: derivedTrailMethod(trail),
66
+ path: derivedTrailPath(trail.id),
67
+ trailId: trail.id,
68
+ type: 'derived-trail' as const,
69
+ }));
70
+
71
+ const webhookMethod = (method: string | undefined): RouteMethod =>
72
+ (method?.trim().toUpperCase() as RouteMethod | undefined) ?? 'POST';
73
+
74
+ /**
75
+ * Mirror the HTTP builder's default materialization policy for webhook
76
+ * consumers. HTTP's `eligibleWebhookTrails` skips internal trails unless
77
+ * callers explicitly include them by id, which the warden cannot anticipate.
78
+ * Without this filter the rule reports collisions against webhook routes that
79
+ * HTTP would never materialize. See `packages/http/src/build.ts`
80
+ * (`eligibleWebhookTrails`, `isInternalTrail`).
81
+ *
82
+ * `shouldIncludeTrailForSurface` cannot be reused here because it short-
83
+ * circuits on any trail with `activationSources.length > 0` — those are exactly
84
+ * the webhook consumer trails we need to inspect. Replicate the visibility
85
+ * shape inline: `visibility: 'internal'` and the legacy `meta.internal === true`
86
+ * convention both opt out of default materialization.
87
+ */
88
+ const isInternalConsumer = (trail: Trail<unknown, unknown, unknown>): boolean =>
89
+ trail.visibility === 'internal' || trail.meta?.['internal'] === true;
90
+
91
+ const collectWebhookClaims = (topo: Topo): readonly RouteClaim[] => {
92
+ const claims: RouteClaim[] = [];
93
+
94
+ for (const trail of topo.list()) {
95
+ if (isInternalConsumer(trail)) {
96
+ continue;
97
+ }
98
+ for (const activation of trail.activationSources) {
99
+ if (
100
+ activation.source.kind !== 'webhook' ||
101
+ typeof activation.source.path !== 'string'
102
+ ) {
103
+ continue;
104
+ }
105
+ claims.push({
106
+ method: webhookMethod(activation.source.method),
107
+ parse: activation.source.parse,
108
+ path: activation.source.path.trim(),
109
+ sourceId: activation.source.id,
110
+ trailId: trail.id,
111
+ type: 'webhook',
112
+ verify: activation.source.verify,
113
+ });
114
+ }
115
+ }
116
+
117
+ return claims;
118
+ };
119
+
120
+ const claimLabel = (claim: RouteClaim): string =>
121
+ claim.type === 'webhook'
122
+ ? `webhook source "${claim.sourceId}" on trail "${claim.trailId}"`
123
+ : `derived trail route "${claim.trailId}"`;
124
+
125
+ const buildDiagnostic = (
126
+ key: string,
127
+ claims: readonly RouteClaim[]
128
+ ): WardenDiagnostic => ({
129
+ filePath: TOPO_FILE,
130
+ line: 1,
131
+ message: `HTTP webhook route collision on ${key}: ${sortedClaims(claims)
132
+ .map(claimLabel)
133
+ .join(
134
+ ', '
135
+ )}. Give each webhook source a distinct method/path pair or move the direct trail route before materializing the HTTP surface.`,
136
+ rule: RULE_NAME,
137
+ severity: 'error',
138
+ });
139
+
140
+ const buildPolicyMismatchDiagnostic = (
141
+ key: string,
142
+ sourceId: string | undefined,
143
+ field: 'parse' | 'verifier',
144
+ claims: readonly RouteClaim[]
145
+ ): WardenDiagnostic => {
146
+ const labels = sortedClaims(claims).map(claimLabel).join(', ');
147
+ const remediation =
148
+ field === 'verifier'
149
+ ? 'Reuse the same WebhookSource object so both consumers run under one verifier.'
150
+ : 'Reuse the same WebhookSource object so both consumers parse payloads under one contract.';
151
+ return {
152
+ filePath: TOPO_FILE,
153
+ line: 1,
154
+ message: `HTTP webhook route collision on ${key}: trails sharing webhook source "${sourceId ?? '<unknown>'}" declare a mismatched ${field} policy (${labels}). ${remediation}`,
155
+ rule: RULE_NAME,
156
+ severity: 'error',
157
+ };
158
+ };
159
+
160
+ /**
161
+ * Within a group of webhook claims that share the same `sourceId`, surface a
162
+ * diagnostic when the underlying source declarations diverge on `verify` or
163
+ * `parse`. The HTTP builder rejects the same combination at runtime in
164
+ * {@link mergeWebhookRoutes}; flagging it here surfaces the failure at lint
165
+ * time instead of waiting for surface construction. Reference equality matches
166
+ * the runtime check (a shared `WebhookSource` always passes; two separately
167
+ * declared schemas or callbacks are treated as distinct policies).
168
+ */
169
+ const collectPolicyMismatchDiagnostics = (
170
+ key: string,
171
+ webhookClaims: readonly RouteClaim[]
172
+ ): WardenDiagnostic[] => {
173
+ const claimsBySourceId = new Map<string, RouteClaim[]>();
174
+ for (const claim of webhookClaims) {
175
+ const id = claim.sourceId ?? '';
176
+ const current = claimsBySourceId.get(id) ?? [];
177
+ current.push(claim);
178
+ claimsBySourceId.set(id, current);
179
+ }
180
+
181
+ const diagnostics: WardenDiagnostic[] = [];
182
+ for (const [id, grouped] of claimsBySourceId) {
183
+ if (grouped.length < 2) {
184
+ continue;
185
+ }
186
+ const verifyRefs = new Set(grouped.map((claim) => claim.verify));
187
+ if (verifyRefs.size > 1) {
188
+ diagnostics.push(
189
+ buildPolicyMismatchDiagnostic(key, id, 'verifier', grouped)
190
+ );
191
+ continue;
192
+ }
193
+ const parseRefs = new Set(grouped.map((claim) => claim.parse));
194
+ if (parseRefs.size > 1) {
195
+ diagnostics.push(
196
+ buildPolicyMismatchDiagnostic(key, id, 'parse', grouped)
197
+ );
198
+ }
199
+ }
200
+ return diagnostics;
201
+ };
202
+
203
+ const buildDiagnostics = (claims: readonly RouteClaim[]) => {
204
+ const claimsByRoute = new Map<string, RouteClaim[]>();
205
+ for (const claim of claims) {
206
+ const key = routeKey(claim);
207
+ const current = claimsByRoute.get(key) ?? [];
208
+ current.push(claim);
209
+ claimsByRoute.set(key, current);
210
+ }
211
+
212
+ const diagnostics: WardenDiagnostic[] = [];
213
+ for (const [key, grouped] of claimsByRoute) {
214
+ const webhookClaims = grouped.filter((claim) => claim.type === 'webhook');
215
+ if (webhookClaims.length === 0) {
216
+ continue;
217
+ }
218
+ const webhookSourceIds = new Set(
219
+ webhookClaims.map((claim) => claim.sourceId)
220
+ );
221
+ if (
222
+ webhookSourceIds.size > 1 ||
223
+ grouped.some((claim) => claim.type === 'derived-trail')
224
+ ) {
225
+ diagnostics.push(buildDiagnostic(key, grouped));
226
+ continue;
227
+ }
228
+ diagnostics.push(...collectPolicyMismatchDiagnostics(key, webhookClaims));
229
+ }
230
+ return diagnostics;
231
+ };
232
+
233
+ export const webhookRouteCollision: TopoAwareWardenRule = {
234
+ checkTopo: (topo) =>
235
+ buildDiagnostics([
236
+ ...collectDerivedTrailClaims(topo),
237
+ ...collectWebhookClaims(topo),
238
+ ]),
239
+ description:
240
+ 'Reject webhook source method/path pairs that collide with each other or derived HTTP trail routes.',
241
+ name: RULE_NAME,
242
+ severity: 'error',
243
+ };
@@ -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
+ blaze: () => 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
+ blaze: async (_input, ctx) => {
25
+ await ctx.fire?.(producedSignal, { invoiceId: 'inv_1' });
26
+ return Result.ok({ invoiceId: 'inv_1' });
27
+ },
28
+ fires: [producedSignal],
29
+ input: z.object({}),
30
+ output: z.object({ invoiceId: z.string() }),
31
+ });
32
+
33
+ const producedConsumer = trail('invoice.index', {
34
+ blaze: () => 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
+ blaze: () => 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,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
+ contourReferencesByName: {
10
+ gist: ['user'],
11
+ user: [],
12
+ },
13
+ filePath: 'contours.ts',
14
+ knownContourIds: ['gist', 'user'],
15
+ knownTrailIds: [],
16
+ sourceCode: `const user = contour("user", {
17
+ id: z.string().uuid(),
18
+ }, { identity: "id" });
19
+
20
+ const gist = contour("gist", {
21
+ id: z.string().uuid(),
22
+ ownerId: user.id(),
23
+ }, { identity: "id" });`,
24
+ },
25
+ name: 'Acyclic contour references stay clean',
26
+ },
27
+ ],
28
+ rule: circularRefs,
29
+ });
@@ -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
+ blaze: 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
+ blaze: 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,21 @@
1
+ import { contourExists } from '../rules/contour-exists.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const contourExistsTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'entity.ts',
10
+ knownContourIds: ['user'],
11
+ knownTrailIds: ['user.create'],
12
+ sourceCode: `trail("user.create", {
13
+ contours: [user],
14
+ blaze: async (input, ctx) => Result.ok({ ok: true }),
15
+ })`,
16
+ },
17
+ name: 'Declared contours resolve to known project contours',
18
+ },
19
+ ],
20
+ rule: contourExists,
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
+ blaze: async (_input, ctx) => ctx.compose('entity.sync', {}),
15
+ });
16
+
17
+ trail('entity.sync', {
18
+ visibility: 'internal',
19
+ blaze: 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,21 @@
1
+ import { topo } from '@ontrails/core';
2
+ import { deriveTopoGraph } from '@ontrails/topographer';
3
+
4
+ import { deprecationWithoutGuidance } from '../rules/trail-versioning-topo.js';
5
+ import { wrapTopoRule } from './wrap-rule.js';
6
+
7
+ const emptyTopo = topo('deprecation-without-guidance-clean', {});
8
+
9
+ export const deprecationWithoutGuidanceTrail = wrapTopoRule({
10
+ examples: [
11
+ {
12
+ expected: { diagnostics: [] },
13
+ input: {
14
+ graph: deriveTopoGraph(emptyTopo),
15
+ topo: emptyTopo,
16
+ },
17
+ name: 'No deprecated version entries passes',
18
+ },
19
+ ],
20
+ rule: deprecationWithoutGuidance,
21
+ });
@@ -0,0 +1,16 @@
1
+ import { draftFileMarking } from '../rules/draft-file-marking.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const draftFileMarkingTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'clean.ts',
10
+ sourceCode: `export const id = "notes.list";`,
11
+ },
12
+ name: 'File without draft ids passes',
13
+ },
14
+ ],
15
+ rule: draftFileMarking,
16
+ });
@@ -0,0 +1,16 @@
1
+ import { draftVisibleDebt } from '../rules/draft-visible-debt.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const draftVisibleDebtTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'clean.ts',
10
+ sourceCode: `export const id = "notes.list";`,
11
+ },
12
+ name: 'File without draft ids emits no visible-debt diagnostics',
13
+ },
14
+ ],
15
+ rule: draftVisibleDebt,
16
+ });
@@ -0,0 +1,29 @@
1
+ import { errorMappingCompleteness } from '../rules/error-mapping-completeness.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const errorMappingCompletenessTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'surface-error-map.ts',
10
+ sourceCode: `import { createSurfaceErrorMapper } from "@ontrails/core";
11
+
12
+ const cliMapper = createSurfaceErrorMapper({
13
+ auth: 9,
14
+ cancelled: 130,
15
+ conflict: 3,
16
+ internal: 8,
17
+ network: 7,
18
+ not_found: 2,
19
+ permission: 4,
20
+ rate_limit: 6,
21
+ timeout: 5,
22
+ validation: 1,
23
+ });`,
24
+ },
25
+ name: 'Complete surface error mapper',
26
+ },
27
+ ],
28
+ rule: errorMappingCompleteness,
29
+ });
@@ -0,0 +1,25 @@
1
+ import { exampleValid } from '../rules/example-valid.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const exampleValidTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'contours.ts',
10
+ sourceCode: `const user = contour("user", {
11
+ id: z.string().uuid(),
12
+ name: z.string(),
13
+ }, {
14
+ examples: [{
15
+ id: "550e8400-e29b-41d4-a716-446655440000",
16
+ name: "Ada",
17
+ }],
18
+ identity: "id",
19
+ });`,
20
+ },
21
+ name: 'Contour examples validate against their schema',
22
+ },
23
+ ],
24
+ rule: exampleValid,
25
+ });
@@ -0,0 +1,23 @@
1
+ import { firesDeclarations } from '../rules/fires-declarations.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const firesDeclarationsTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'clean.ts',
10
+ sourceCode: `const entityCreated = signal("entity.created", { payload: z.object({}) });
11
+ trail("entity.onboard", {
12
+ fires: [entityCreated],
13
+ blaze: async (input, ctx) => {
14
+ await ctx.fire(entityCreated, { id: input.id });
15
+ return Result.ok({});
16
+ }
17
+ })`,
18
+ },
19
+ name: 'Matched fires declarations and calls',
20
+ },
21
+ ],
22
+ rule: firesDeclarations,
23
+ });
@@ -0,0 +1,31 @@
1
+ import { forkWithoutPreservedBlaze } from '../rules/trail-versioning-source.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const forkWithoutPreservedBlazeTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'src/trails/versioned.ts',
10
+ sourceCode: `
11
+ trail('versioned.clean', {
12
+ version: 2,
13
+ versions: {
14
+ 1: {
15
+ input: z.object({ name: z.string() }),
16
+ output: z.object({ message: z.string() }),
17
+ transpose: {
18
+ input: ({ input }) => input,
19
+ output: ({ output }) => output,
20
+ },
21
+ },
22
+ },
23
+ blaze: async () => Result.ok({ message: 'ok' }),
24
+ });
25
+ `,
26
+ },
27
+ name: 'Revision entries use transpose',
28
+ },
29
+ ],
30
+ rule: forkWithoutPreservedBlaze,
31
+ });
@@ -0,0 +1,20 @@
1
+ import { implementationReturnsResult } from '../rules/implementation-returns-result.js';
2
+ import { wrapRule } from './wrap-rule.js';
3
+
4
+ export const implementationReturnsResultTrail = wrapRule({
5
+ examples: [
6
+ {
7
+ expected: { diagnostics: [] },
8
+ input: {
9
+ filePath: 'clean.ts',
10
+ sourceCode: `trail("entity.show", {
11
+ blaze: async (input, ctx) => {
12
+ return Result.ok({ name: "test" });
13
+ }
14
+ })`,
15
+ },
16
+ name: 'Implementation returning Result.ok()',
17
+ },
18
+ ],
19
+ rule: implementationReturnsResult,
20
+ });