@ontrails/warden 1.0.0-beta.3 → 1.0.0-beta.32

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 (260) hide show
  1. package/CHANGELOG.md +674 -9
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +28 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +137 -0
  7. package/src/cli.ts +1451 -104
  8. package/src/command.ts +966 -0
  9. package/src/config.ts +193 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +122 -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 +217 -14
  16. package/src/project-context.ts +192 -0
  17. package/src/project-rules.ts +290 -0
  18. package/src/resolve.ts +531 -0
  19. package/src/rules/activation-orphan.ts +97 -0
  20. package/src/rules/ast.ts +4008 -86
  21. package/src/rules/circular-refs.ts +154 -0
  22. package/src/rules/cli-command-route-coherence.ts +177 -0
  23. package/src/rules/composes-declarations.ts +837 -0
  24. package/src/rules/context-no-surface-types.ts +78 -15
  25. package/src/rules/contour-exists.ts +251 -0
  26. package/src/rules/contour-ids.ts +15 -0
  27. package/src/rules/dead-internal-trail.ts +161 -0
  28. package/src/rules/dead-public-trail.ts +258 -0
  29. package/src/rules/draft-file-marking.ts +160 -0
  30. package/src/rules/draft-visible-debt.ts +87 -0
  31. package/src/rules/duplicate-public-contract.ts +91 -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 +735 -0
  35. package/src/rules/implementation-returns-result.ts +1409 -166
  36. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  37. package/src/rules/incomplete-crud.ts +581 -0
  38. package/src/rules/index.ts +235 -18
  39. package/src/rules/intent-propagation.ts +127 -0
  40. package/src/rules/layer-field-name-drift.ts +102 -0
  41. package/src/rules/library-projection-coherence.ts +100 -0
  42. package/src/rules/metadata.ts +755 -0
  43. package/src/rules/missing-reconcile.ts +98 -0
  44. package/src/rules/missing-visibility.ts +110 -0
  45. package/src/rules/no-destructured-compose.ts +194 -0
  46. package/src/rules/no-dev-permit-in-source.ts +99 -0
  47. package/src/rules/no-direct-implementation-call.ts +7 -7
  48. package/src/rules/no-legacy-layer-imports.ts +211 -0
  49. package/src/rules/no-native-error-result.ts +118 -0
  50. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  51. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  52. package/src/rules/no-sync-result-assumption.ts +1135 -98
  53. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  54. package/src/rules/no-throw-in-implementation.ts +10 -9
  55. package/src/rules/no-top-level-surface.ts +371 -0
  56. package/src/rules/on-references-exist.ts +194 -0
  57. package/src/rules/orphaned-signal.ts +150 -0
  58. package/src/rules/owner-projection-parity.ts +143 -0
  59. package/src/rules/permit-governance.ts +25 -0
  60. package/src/rules/public-export-example-coverage.ts +562 -0
  61. package/src/rules/public-internal-deep-imports.ts +457 -0
  62. package/src/rules/public-output-schema.ts +29 -0
  63. package/src/rules/public-union-output-discriminants.ts +150 -0
  64. package/src/rules/read-intent-fires.ts +187 -0
  65. package/src/rules/reference-exists.ts +98 -0
  66. package/src/rules/registry-names.ts +155 -0
  67. package/src/rules/resolved-import-boundary.ts +146 -0
  68. package/src/rules/resource-declarations.ts +693 -0
  69. package/src/rules/resource-exists.ts +179 -0
  70. package/src/rules/resource-id-grammar.ts +65 -0
  71. package/src/rules/resource-mock-coverage.ts +115 -0
  72. package/src/rules/scan.ts +38 -25
  73. package/src/rules/scheduled-destroy-intent.ts +44 -0
  74. package/src/rules/signal-graph-coaching.ts +191 -0
  75. package/src/rules/specs.ts +9 -5
  76. package/src/rules/static-resource-accessor-preference.ts +649 -0
  77. package/src/rules/surface-facet-coherence.ts +362 -0
  78. package/src/rules/trail-fork-coaching.ts +616 -0
  79. package/src/rules/trail-versioning-source.ts +1072 -0
  80. package/src/rules/trail-versioning-topo.ts +172 -0
  81. package/src/rules/types.ts +297 -8
  82. package/src/rules/unmaterialized-activation-source.ts +84 -0
  83. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  84. package/src/rules/valid-describe-refs.ts +162 -32
  85. package/src/rules/valid-detour-contract.ts +78 -0
  86. package/src/rules/warden-export-symmetry.ts +540 -0
  87. package/src/rules/warden-rules-use-ast.ts +1114 -0
  88. package/src/rules/webhook-route-collision.ts +243 -0
  89. package/src/trails/activation-orphan.trail.ts +84 -0
  90. package/src/trails/circular-refs.trail.ts +29 -0
  91. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  92. package/src/trails/composes-declarations.trail.ts +22 -0
  93. package/src/trails/context-no-surface-types.trail.ts +21 -0
  94. package/src/trails/contour-exists.trail.ts +21 -0
  95. package/src/trails/dead-internal-trail.trail.ts +26 -0
  96. package/src/trails/dead-public-trail.trail.ts +31 -0
  97. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  98. package/src/trails/draft-file-marking.trail.ts +16 -0
  99. package/src/trails/draft-visible-debt.trail.ts +16 -0
  100. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  101. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  102. package/src/trails/example-valid.trail.ts +25 -0
  103. package/src/trails/fires-declarations.trail.ts +23 -0
  104. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  105. package/src/trails/implementation-returns-result.trail.ts +20 -0
  106. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  107. package/src/trails/incomplete-crud.trail.ts +39 -0
  108. package/src/trails/index.ts +83 -0
  109. package/src/trails/intent-propagation.trail.ts +30 -0
  110. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  111. package/src/trails/library-projection-coherence.trail.ts +43 -0
  112. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  113. package/src/trails/missing-reconcile.trail.ts +33 -0
  114. package/src/trails/missing-visibility.trail.ts +22 -0
  115. package/src/trails/no-destructured-compose.trail.ts +44 -0
  116. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  117. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  118. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  119. package/src/trails/no-native-error-result.trail.ts +18 -0
  120. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  121. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  122. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  123. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  124. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  125. package/src/trails/no-top-level-surface.trail.ts +43 -0
  126. package/src/trails/on-references-exist.trail.ts +21 -0
  127. package/src/trails/orphaned-signal.trail.ts +36 -0
  128. package/src/trails/owner-projection-parity.trail.ts +26 -0
  129. package/src/trails/pending-force.trail.ts +21 -0
  130. package/src/trails/permit-governance.trail.ts +51 -0
  131. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  132. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  133. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  134. package/src/trails/public-output-schema.trail.ts +55 -0
  135. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  136. package/src/trails/read-intent-fires.trail.ts +20 -0
  137. package/src/trails/reference-exists.trail.ts +25 -0
  138. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  139. package/src/trails/resource-declarations.trail.ts +25 -0
  140. package/src/trails/resource-exists.trail.ts +27 -0
  141. package/src/trails/resource-id-grammar.trail.ts +39 -0
  142. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  143. package/src/trails/run.ts +162 -0
  144. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  145. package/src/trails/schema.ts +198 -0
  146. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  147. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  148. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  149. package/src/trails/topo.ts +6 -0
  150. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  151. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  152. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  153. package/src/trails/valid-describe-refs.trail.ts +18 -0
  154. package/src/trails/valid-detour-contract.trail.ts +71 -0
  155. package/src/trails/version-gap.trail.ts +35 -0
  156. package/src/trails/version-pinned-compose.trail.ts +23 -0
  157. package/src/trails/version-without-examples.trail.ts +38 -0
  158. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  159. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  160. package/src/trails/webhook-route-collision.trail.ts +50 -0
  161. package/src/trails/wrap-rule.ts +214 -0
  162. package/src/workspaces.ts +199 -0
  163. package/.turbo/turbo-build.log +0 -1
  164. package/.turbo/turbo-lint.log +0 -3
  165. package/.turbo/turbo-typecheck.log +0 -1
  166. package/dist/cli.d.ts +0 -46
  167. package/dist/cli.d.ts.map +0 -1
  168. package/dist/cli.js +0 -221
  169. package/dist/cli.js.map +0 -1
  170. package/dist/drift.d.ts +0 -26
  171. package/dist/drift.d.ts.map +0 -1
  172. package/dist/drift.js +0 -27
  173. package/dist/drift.js.map +0 -1
  174. package/dist/formatters.d.ts +0 -29
  175. package/dist/formatters.d.ts.map +0 -1
  176. package/dist/formatters.js +0 -87
  177. package/dist/formatters.js.map +0 -1
  178. package/dist/index.d.ts +0 -26
  179. package/dist/index.d.ts.map +0 -1
  180. package/dist/index.js +0 -26
  181. package/dist/index.js.map +0 -1
  182. package/dist/rules/ast.d.ts +0 -41
  183. package/dist/rules/ast.d.ts.map +0 -1
  184. package/dist/rules/ast.js +0 -163
  185. package/dist/rules/ast.js.map +0 -1
  186. package/dist/rules/context-no-surface-types.d.ts +0 -12
  187. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  188. package/dist/rules/context-no-surface-types.js +0 -96
  189. package/dist/rules/context-no-surface-types.js.map +0 -1
  190. package/dist/rules/implementation-returns-result.d.ts +0 -13
  191. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  192. package/dist/rules/implementation-returns-result.js +0 -277
  193. package/dist/rules/implementation-returns-result.js.map +0 -1
  194. package/dist/rules/index.d.ts +0 -22
  195. package/dist/rules/index.d.ts.map +0 -1
  196. package/dist/rules/index.js +0 -41
  197. package/dist/rules/index.js.map +0 -1
  198. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  199. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  200. package/dist/rules/no-direct-impl-in-route.js +0 -46
  201. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  202. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  203. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  204. package/dist/rules/no-direct-implementation-call.js +0 -39
  205. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  206. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  207. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  208. package/dist/rules/no-sync-result-assumption.js +0 -98
  209. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  210. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  211. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  212. package/dist/rules/no-throw-in-detour-target.js +0 -87
  213. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  214. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  215. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  216. package/dist/rules/no-throw-in-implementation.js +0 -34
  217. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  218. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  219. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  220. package/dist/rules/prefer-schema-inference.js +0 -86
  221. package/dist/rules/prefer-schema-inference.js.map +0 -1
  222. package/dist/rules/scan.d.ts +0 -8
  223. package/dist/rules/scan.d.ts.map +0 -1
  224. package/dist/rules/scan.js +0 -32
  225. package/dist/rules/scan.js.map +0 -1
  226. package/dist/rules/specs.d.ts +0 -29
  227. package/dist/rules/specs.d.ts.map +0 -1
  228. package/dist/rules/specs.js +0 -192
  229. package/dist/rules/specs.js.map +0 -1
  230. package/dist/rules/structure.d.ts +0 -13
  231. package/dist/rules/structure.d.ts.map +0 -1
  232. package/dist/rules/structure.js +0 -142
  233. package/dist/rules/structure.js.map +0 -1
  234. package/dist/rules/types.d.ts +0 -52
  235. package/dist/rules/types.d.ts.map +0 -1
  236. package/dist/rules/types.js +0 -2
  237. package/dist/rules/types.js.map +0 -1
  238. package/dist/rules/valid-describe-refs.d.ts +0 -7
  239. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  240. package/dist/rules/valid-describe-refs.js +0 -51
  241. package/dist/rules/valid-describe-refs.js.map +0 -1
  242. package/dist/rules/valid-detour-refs.d.ts +0 -6
  243. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  244. package/dist/rules/valid-detour-refs.js +0 -116
  245. package/dist/rules/valid-detour-refs.js.map +0 -1
  246. package/src/__tests__/cli.test.ts +0 -198
  247. package/src/__tests__/drift.test.ts +0 -74
  248. package/src/__tests__/formatters.test.ts +0 -157
  249. package/src/__tests__/implementation-returns-result.test.ts +0 -128
  250. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  251. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  252. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  253. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  254. package/src/__tests__/rules.test.ts +0 -215
  255. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  256. package/src/rules/no-direct-impl-in-route.ts +0 -77
  257. package/src/rules/no-throw-in-detour-target.ts +0 -150
  258. package/src/rules/valid-detour-refs.ts +0 -187
  259. package/tsconfig.json +0 -9
  260. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Validates that registered surface error mappers cover every error category.
3
+ *
4
+ * Scans `createSurfaceErrorMapper(...)` calls, then resolves simple object
5
+ * literals, identifier bindings, and object-property references in the same
6
+ * file so incomplete mapper registrations are caught before they ship.
7
+ */
8
+
9
+ import { codesByCategory, errorClasses } from '@ontrails/core';
10
+ import type { ErrorCategory } from '@ontrails/core';
11
+
12
+ import {
13
+ getNodeKey,
14
+ getNodeValueNode,
15
+ getStringValue,
16
+ identifierName,
17
+ isStringLiteral,
18
+ offsetToLine,
19
+ parse,
20
+ walk,
21
+ } from './ast.js';
22
+ import type { AstNode } from './ast.js';
23
+ import { isTestFile } from './scan.js';
24
+ import type { WardenDiagnostic, WardenRule } from './types.js';
25
+
26
+ const MEMBER_EXPRESSION_TYPES = new Set([
27
+ 'MemberExpression',
28
+ 'StaticMemberExpression',
29
+ ]);
30
+
31
+ const MAPPER_FACTORY_NAMES = new Set(['createSurfaceErrorMapper']);
32
+
33
+ const mappedErrorClassCategories = new Set(
34
+ errorClasses.flatMap((entry) =>
35
+ entry.category === 'dynamic' ? [] : [entry.category]
36
+ )
37
+ );
38
+
39
+ const requiredErrorCategories = (
40
+ Object.keys(codesByCategory) as ErrorCategory[]
41
+ ).filter((category) => mappedErrorClassCategories.has(category));
42
+
43
+ const getPropertyName = (node: AstNode | undefined): string | null => {
44
+ if (!node) {
45
+ return null;
46
+ }
47
+
48
+ return (
49
+ identifierName(node) ??
50
+ (isStringLiteral(node) ? getStringValue(node) : null)
51
+ );
52
+ };
53
+
54
+ const collectObjectBindings = (ast: AstNode): ReadonlyMap<string, AstNode> => {
55
+ const bindings = new Map<string, AstNode>();
56
+
57
+ walk(ast, (node) => {
58
+ if (node.type !== 'VariableDeclarator') {
59
+ return;
60
+ }
61
+
62
+ const { id, init } = node as { id?: AstNode; init?: AstNode };
63
+ const bindingName = identifierName(id);
64
+
65
+ if (bindingName && init?.type === 'ObjectExpression') {
66
+ bindings.set(bindingName, init);
67
+ }
68
+ });
69
+
70
+ return bindings;
71
+ };
72
+
73
+ const getObjectProperties = (objectNode: AstNode): readonly AstNode[] =>
74
+ objectNode.type === 'ObjectExpression'
75
+ ? ((objectNode['properties'] as readonly AstNode[] | undefined) ?? [])
76
+ : [];
77
+
78
+ const findObjectPropertyValue = (
79
+ objectNode: AstNode,
80
+ propertyName: string
81
+ ): AstNode | null => {
82
+ for (const property of getObjectProperties(objectNode)) {
83
+ if (property.type !== 'Property') {
84
+ continue;
85
+ }
86
+
87
+ const key = getPropertyName(getNodeKey(property));
88
+ if (key === propertyName) {
89
+ return getNodeValueNode(property) ?? null;
90
+ }
91
+ }
92
+
93
+ return null;
94
+ };
95
+
96
+ const resolveIdentifierObject = (
97
+ node: AstNode,
98
+ bindings: ReadonlyMap<string, AstNode>
99
+ ): AstNode | null =>
100
+ bindings.get((node as { name?: string }).name ?? '') ?? null;
101
+
102
+ const resolveMemberObject = (
103
+ node: AstNode,
104
+ bindings: ReadonlyMap<string, AstNode>,
105
+ depth: number,
106
+ resolve: (
107
+ node: AstNode | undefined,
108
+ bindings: ReadonlyMap<string, AstNode>,
109
+ depth?: number
110
+ ) => AstNode | null
111
+ ): AstNode | null => {
112
+ const { object, property } = node as { object?: AstNode; property?: AstNode };
113
+ const propertyName = getPropertyName(property);
114
+ if (!propertyName) {
115
+ return null;
116
+ }
117
+
118
+ const objectNode = resolve(object, bindings, depth + 1);
119
+ return objectNode
120
+ ? resolve(
121
+ findObjectPropertyValue(objectNode, propertyName) ?? undefined,
122
+ bindings,
123
+ depth + 1
124
+ )
125
+ : null;
126
+ };
127
+
128
+ const resolveObjectExpression = function resolveObjectExpression(
129
+ node: AstNode | undefined,
130
+ bindings: ReadonlyMap<string, AstNode>,
131
+ depth = 0
132
+ ): AstNode | null {
133
+ if (!node || depth > 4) {
134
+ return null;
135
+ }
136
+
137
+ if (node.type === 'ObjectExpression') {
138
+ return node;
139
+ }
140
+
141
+ if (node.type === 'Identifier') {
142
+ return resolveIdentifierObject(node, bindings);
143
+ }
144
+
145
+ return MEMBER_EXPRESSION_TYPES.has(node.type)
146
+ ? resolveMemberObject(node, bindings, depth, resolveObjectExpression)
147
+ : null;
148
+ };
149
+
150
+ const addMappedCategory = (
151
+ categories: Set<string>,
152
+ property: AstNode
153
+ ): boolean => {
154
+ if (property.type === 'SpreadElement') {
155
+ return false;
156
+ }
157
+
158
+ if (property.type !== 'Property') {
159
+ return true;
160
+ }
161
+
162
+ const key = getPropertyName(getNodeKey(property));
163
+ if (key) {
164
+ categories.add(key);
165
+ }
166
+
167
+ return true;
168
+ };
169
+
170
+ const collectMappedCategories = (
171
+ mapperObject: AstNode
172
+ ): ReadonlySet<string> | null => {
173
+ if (mapperObject.type !== 'ObjectExpression') {
174
+ return null;
175
+ }
176
+
177
+ const categories = new Set<string>();
178
+ for (const property of getObjectProperties(mapperObject)) {
179
+ if (!addMappedCategory(categories, property)) {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ return categories;
185
+ };
186
+
187
+ const createDiagnostic = (
188
+ filePath: string,
189
+ line: number,
190
+ missingCategories: readonly string[]
191
+ ): WardenDiagnostic => ({
192
+ filePath,
193
+ line,
194
+ message: `Surface error mapper is missing mappings for: ${missingCategories.join(', ')}. Registered createSurfaceErrorMapper() calls must cover every ErrorCategory.`,
195
+ rule: 'error-mapping-completeness',
196
+ severity: 'error',
197
+ });
198
+
199
+ const getCallArgs = (node: AstNode): readonly AstNode[] =>
200
+ (node as { arguments?: readonly AstNode[] }).arguments ?? [];
201
+
202
+ const getCallCallee = (node: AstNode): AstNode | undefined =>
203
+ (node as { callee?: AstNode }).callee;
204
+
205
+ const isMapperFactoryCall = (node: AstNode): boolean =>
206
+ node.type === 'CallExpression' &&
207
+ MAPPER_FACTORY_NAMES.has(identifierName(getCallCallee(node)) ?? '');
208
+
209
+ const findMissingCategories = (
210
+ mappedCategories: ReadonlySet<string>
211
+ ): readonly string[] =>
212
+ requiredErrorCategories.filter((category) => !mappedCategories.has(category));
213
+
214
+ const resolveMappedCategories = (
215
+ node: AstNode,
216
+ bindings: ReadonlyMap<string, AstNode>
217
+ ): ReadonlySet<string> | null => {
218
+ const [firstArg] = getCallArgs(node);
219
+ const mapperObject = resolveObjectExpression(firstArg, bindings);
220
+ return mapperObject ? collectMappedCategories(mapperObject) : null;
221
+ };
222
+
223
+ const inspectMapperCall = (
224
+ node: AstNode,
225
+ bindings: ReadonlyMap<string, AstNode>,
226
+ filePath: string,
227
+ sourceCode: string
228
+ ): WardenDiagnostic | null => {
229
+ if (!isMapperFactoryCall(node)) {
230
+ return null;
231
+ }
232
+
233
+ const mappedCategories = resolveMappedCategories(node, bindings);
234
+ if (!mappedCategories) {
235
+ return null;
236
+ }
237
+
238
+ const missingCategories = findMissingCategories(mappedCategories);
239
+ if (missingCategories.length === 0) {
240
+ return null;
241
+ }
242
+
243
+ return createDiagnostic(
244
+ filePath,
245
+ offsetToLine(sourceCode, node.start),
246
+ missingCategories
247
+ );
248
+ };
249
+
250
+ /**
251
+ * Flags registered surface error mapper calls that omit error categories.
252
+ */
253
+ export const errorMappingCompleteness: WardenRule = {
254
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
255
+ if (
256
+ isTestFile(filePath) ||
257
+ ![...MAPPER_FACTORY_NAMES].some((factoryName) =>
258
+ sourceCode.includes(factoryName)
259
+ )
260
+ ) {
261
+ return [];
262
+ }
263
+
264
+ const ast = parse(filePath, sourceCode);
265
+ if (!ast) {
266
+ return [];
267
+ }
268
+
269
+ const bindings = collectObjectBindings(ast);
270
+ const diagnostics: WardenDiagnostic[] = [];
271
+
272
+ walk(ast, (node) => {
273
+ const diagnostic = inspectMapperCall(
274
+ node,
275
+ bindings,
276
+ filePath,
277
+ sourceCode
278
+ );
279
+ if (diagnostic) {
280
+ diagnostics.push(diagnostic);
281
+ }
282
+ });
283
+
284
+ return diagnostics;
285
+ },
286
+ description:
287
+ 'Require registered surface error mappers to cover every ErrorCategory.',
288
+ name: 'error-mapping-completeness',
289
+ severity: 'error',
290
+ };
@@ -0,0 +1,395 @@
1
+ import { contour } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import {
5
+ extractStringLiteral,
6
+ findConfigProperty,
7
+ findContourDefinitions,
8
+ getNodeExpression,
9
+ getNodeObject,
10
+ getNodeProperty,
11
+ getStringValue,
12
+ identifierName,
13
+ offsetToLine,
14
+ parse,
15
+ } from './ast.js';
16
+ import type { AstNode, ContourDefinition } from './ast.js';
17
+ import { isTestFile } from './scan.js';
18
+ import type { WardenDiagnostic, WardenRule } from './types.js';
19
+
20
+ class UnsupportedContourEvaluationError extends Error {}
21
+
22
+ type ContourEvaluationEnvironment = ReadonlyMap<string, unknown>;
23
+
24
+ const buildInvalidExampleDiagnostic = (
25
+ contourName: string,
26
+ message: string,
27
+ filePath: string,
28
+ line: number
29
+ ): WardenDiagnostic => ({
30
+ filePath,
31
+ line,
32
+ message: `Contour "${contourName}" has invalid examples: ${message}`,
33
+ rule: 'example-valid',
34
+ severity: 'error',
35
+ });
36
+
37
+ const getPropertyName = (node: unknown): string | null => {
38
+ if (typeof node !== 'object' || node === null) {
39
+ return null;
40
+ }
41
+
42
+ const { name } = node as { readonly name?: unknown };
43
+ if (typeof name === 'string') {
44
+ return name;
45
+ }
46
+
47
+ return extractStringLiteral(node as AstNode);
48
+ };
49
+
50
+ const requireNode = (node: AstNode | undefined): AstNode => {
51
+ if (!node) {
52
+ throw new UnsupportedContourEvaluationError(
53
+ 'Missing node in contour evaluation.'
54
+ );
55
+ }
56
+
57
+ return node;
58
+ };
59
+
60
+ type ContourNodeEvaluator = (
61
+ node: AstNode,
62
+ env: ContourEvaluationEnvironment
63
+ ) => unknown;
64
+
65
+ // The evaluator table is declared before `evaluateNode` so the recursive
66
+ // dispatch can read it at call time without any forward references. Entries
67
+ // are registered below once each per-type evaluator is defined.
68
+ const contourNodeEvaluators = new Map<string, ContourNodeEvaluator>();
69
+
70
+ const evaluateNode: ContourNodeEvaluator = (
71
+ node: AstNode,
72
+ env: ContourEvaluationEnvironment
73
+ ): unknown => {
74
+ const evaluator = contourNodeEvaluators.get(node.type);
75
+ if (evaluator) {
76
+ return evaluator(node, env);
77
+ }
78
+
79
+ throw new UnsupportedContourEvaluationError(
80
+ `Unsupported AST node "${node.type}" in contour evaluation.`
81
+ );
82
+ };
83
+
84
+ const evaluateArrayExpression: ContourNodeEvaluator = (
85
+ node: AstNode,
86
+ env: ContourEvaluationEnvironment
87
+ ): readonly unknown[] => {
88
+ const elements = node['elements'] as readonly AstNode[] | undefined;
89
+ return (elements ?? []).map((element) => evaluateNode(element, env));
90
+ };
91
+
92
+ const evaluateLiteralExpression: ContourNodeEvaluator = (
93
+ node: AstNode
94
+ ): unknown => getStringValue(node) ?? node.value;
95
+
96
+ const evaluateIdentifierExpression: ContourNodeEvaluator = (
97
+ node: AstNode,
98
+ env: ContourEvaluationEnvironment
99
+ ): unknown => {
100
+ const name = identifierName(node);
101
+ if (name === 'undefined') {
102
+ return undefined;
103
+ }
104
+
105
+ if (name === 'z') {
106
+ return z;
107
+ }
108
+
109
+ if (!name || !env.has(name)) {
110
+ throw new UnsupportedContourEvaluationError(
111
+ `Unknown identifier "${name ?? '<unknown>'}" in contour evaluation.`
112
+ );
113
+ }
114
+
115
+ return env.get(name);
116
+ };
117
+
118
+ const evaluateWrappedExpression: ContourNodeEvaluator = (
119
+ node: AstNode,
120
+ env: ContourEvaluationEnvironment
121
+ ): unknown => evaluateNode(requireNode(getNodeExpression(node)), env);
122
+
123
+ const evaluateNullExpression: ContourNodeEvaluator = (): null => null;
124
+
125
+ const evaluateObjectExpression: ContourNodeEvaluator = (
126
+ node: AstNode,
127
+ env: ContourEvaluationEnvironment
128
+ ): Record<string, unknown> => {
129
+ const properties = node['properties'] as readonly AstNode[] | undefined;
130
+ const value: Record<string, unknown> = {};
131
+
132
+ for (const property of properties ?? []) {
133
+ if (property.type !== 'Property') {
134
+ throw new UnsupportedContourEvaluationError(
135
+ `Unsupported object property type "${property.type}".`
136
+ );
137
+ }
138
+
139
+ const propertyName = getPropertyName(property.key);
140
+ if (!propertyName) {
141
+ throw new UnsupportedContourEvaluationError(
142
+ 'Unsupported object property key in contour evaluation.'
143
+ );
144
+ }
145
+
146
+ value[propertyName] = evaluateNode(
147
+ requireNode(property.value as AstNode | undefined),
148
+ env
149
+ );
150
+ }
151
+
152
+ return value;
153
+ };
154
+
155
+ const evaluateCallArguments = (
156
+ node: AstNode,
157
+ env: ContourEvaluationEnvironment
158
+ ): readonly unknown[] =>
159
+ ((node['arguments'] as readonly AstNode[] | undefined) ?? []).map((arg) =>
160
+ evaluateNode(arg, env)
161
+ );
162
+
163
+ const resolveContourFallbackOptions = (
164
+ shape: unknown
165
+ ): Parameters<typeof contour>[2] | null =>
166
+ typeof shape === 'object' &&
167
+ shape !== null &&
168
+ Object.hasOwn(shape, 'id') &&
169
+ (shape as z.ZodRawShape)['id'] !== undefined
170
+ ? ({ identity: 'id' } as Parameters<typeof contour>[2])
171
+ : null;
172
+
173
+ const resolveContourOptions = (
174
+ shape: unknown,
175
+ options: unknown
176
+ ): Parameters<typeof contour>[2] => {
177
+ if (options === undefined) {
178
+ const fallbackOptions = resolveContourFallbackOptions(shape);
179
+ if (fallbackOptions !== null) {
180
+ return fallbackOptions;
181
+ }
182
+
183
+ throw new UnsupportedContourEvaluationError(
184
+ 'Contour evaluator requires literal options, or an `id` field when options are omitted.'
185
+ );
186
+ }
187
+
188
+ if (typeof options !== 'object' || options === null) {
189
+ throw new UnsupportedContourEvaluationError(
190
+ 'Contour evaluator requires literal options when provided.'
191
+ );
192
+ }
193
+
194
+ return options as Parameters<typeof contour>[2];
195
+ };
196
+
197
+ const evaluateContourCall = (args: readonly unknown[]): unknown => {
198
+ const [name, shape, options] = args;
199
+ if (typeof name !== 'string') {
200
+ throw new UnsupportedContourEvaluationError(
201
+ 'Contour evaluator requires a literal name.'
202
+ );
203
+ }
204
+
205
+ return contour(
206
+ name,
207
+ shape as z.ZodRawShape,
208
+ resolveContourOptions(shape, options)
209
+ );
210
+ };
211
+
212
+ const evaluateMemberCall = (
213
+ callee: AstNode,
214
+ args: readonly unknown[],
215
+ env: ContourEvaluationEnvironment
216
+ ): unknown => {
217
+ const receiver = evaluateNode(requireNode(getNodeObject(callee)), env);
218
+ const propertyName = getPropertyName(getNodeProperty(callee));
219
+ if (!propertyName) {
220
+ throw new UnsupportedContourEvaluationError(
221
+ 'Unsupported member property in contour evaluation.'
222
+ );
223
+ }
224
+
225
+ const method = (receiver as Record<string, unknown>)[propertyName];
226
+ if (typeof method !== 'function') {
227
+ throw new UnsupportedContourEvaluationError(
228
+ `Contour evaluator could not call "${propertyName}".`
229
+ );
230
+ }
231
+
232
+ return Reflect.apply(method, receiver, args);
233
+ };
234
+
235
+ const evaluateCallExpression: ContourNodeEvaluator = (
236
+ node: AstNode,
237
+ env: ContourEvaluationEnvironment
238
+ ): unknown => {
239
+ const args = evaluateCallArguments(node, env);
240
+ const callee = requireNode(node['callee'] as AstNode | undefined);
241
+
242
+ if (callee.type === 'Identifier') {
243
+ const calleeName = identifierName(callee);
244
+ if (calleeName !== 'contour') {
245
+ throw new UnsupportedContourEvaluationError(
246
+ `Unsupported contour evaluator call "${calleeName ?? '<unknown>'}".`
247
+ );
248
+ }
249
+
250
+ return evaluateContourCall(args);
251
+ }
252
+
253
+ if (
254
+ callee.type !== 'MemberExpression' &&
255
+ callee.type !== 'StaticMemberExpression'
256
+ ) {
257
+ throw new UnsupportedContourEvaluationError(
258
+ `Unsupported callee type "${callee.type}".`
259
+ );
260
+ }
261
+
262
+ return evaluateMemberCall(callee, args, env);
263
+ };
264
+
265
+ const EVALUATOR_REGISTRATIONS: readonly (readonly [
266
+ string,
267
+ ContourNodeEvaluator,
268
+ ])[] = [
269
+ ['ArrayExpression', evaluateArrayExpression],
270
+ ['BooleanLiteral', evaluateLiteralExpression],
271
+ ['Literal', evaluateLiteralExpression],
272
+ ['NumericLiteral', evaluateLiteralExpression],
273
+ ['StringLiteral', evaluateLiteralExpression],
274
+ ['CallExpression', evaluateCallExpression],
275
+ ['Identifier', evaluateIdentifierExpression],
276
+ ['NullLiteral', evaluateNullExpression],
277
+ ['ObjectExpression', evaluateObjectExpression],
278
+ ['ParenthesizedExpression', evaluateWrappedExpression],
279
+ ['TSAsExpression', evaluateWrappedExpression],
280
+ ['TSSatisfiesExpression', evaluateWrappedExpression],
281
+ ];
282
+
283
+ for (const [type, evaluator] of EVALUATOR_REGISTRATIONS) {
284
+ contourNodeEvaluators.set(type, evaluator);
285
+ }
286
+
287
+ const hasExamples = (definition: ContourDefinition): boolean =>
288
+ definition.options !== null &&
289
+ findConfigProperty(definition.options, 'examples') !== null;
290
+
291
+ const evaluateContourDefinition = (
292
+ definition: ContourDefinition,
293
+ env: ContourEvaluationEnvironment
294
+ ): unknown => evaluateNode(definition.call, env);
295
+
296
+ const buildContourExampleErrorDiagnostic = (
297
+ definition: ContourDefinition,
298
+ error: unknown,
299
+ sourceCode: string,
300
+ filePath: string
301
+ ): WardenDiagnostic | null => {
302
+ if (
303
+ error instanceof UnsupportedContourEvaluationError ||
304
+ !hasExamples(definition) ||
305
+ !(error instanceof Error)
306
+ ) {
307
+ return null;
308
+ }
309
+
310
+ return buildInvalidExampleDiagnostic(
311
+ definition.name,
312
+ error.message,
313
+ filePath,
314
+ offsetToLine(sourceCode, definition.start)
315
+ );
316
+ };
317
+
318
+ const evaluateContourExamples = (
319
+ definition: ContourDefinition,
320
+ env: Map<string, unknown>,
321
+ sourceCode: string,
322
+ filePath: string
323
+ ): WardenDiagnostic | null => {
324
+ try {
325
+ const value = evaluateContourDefinition(definition, env);
326
+ if (definition.bindingName) {
327
+ env.set(definition.bindingName, value);
328
+ }
329
+
330
+ return null;
331
+ } catch (error) {
332
+ return buildContourExampleErrorDiagnostic(
333
+ definition,
334
+ error,
335
+ sourceCode,
336
+ filePath
337
+ );
338
+ }
339
+ };
340
+
341
+ const collectContourExampleDiagnostics = (
342
+ definitions: readonly ContourDefinition[],
343
+ sourceCode: string,
344
+ filePath: string
345
+ ): readonly WardenDiagnostic[] => {
346
+ const diagnostics: WardenDiagnostic[] = [];
347
+ const env = new Map<string, unknown>();
348
+
349
+ for (const definition of definitions) {
350
+ const diagnostic = evaluateContourExamples(
351
+ definition,
352
+ env,
353
+ sourceCode,
354
+ filePath
355
+ );
356
+ if (diagnostic) {
357
+ diagnostics.push(diagnostic);
358
+ }
359
+ }
360
+
361
+ return diagnostics;
362
+ };
363
+
364
+ const checkContourExamples = (
365
+ sourceCode: string,
366
+ filePath: string
367
+ ): readonly WardenDiagnostic[] => {
368
+ if (isTestFile(filePath)) {
369
+ return [];
370
+ }
371
+
372
+ const ast = parse(filePath, sourceCode);
373
+ if (!ast) {
374
+ return [];
375
+ }
376
+
377
+ return collectContourExampleDiagnostics(
378
+ findContourDefinitions(ast),
379
+ sourceCode,
380
+ filePath
381
+ );
382
+ };
383
+
384
+ /**
385
+ * Checks that contour examples validate against their schema.
386
+ */
387
+ export const exampleValid: WardenRule = {
388
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
389
+ return checkContourExamples(sourceCode, filePath);
390
+ },
391
+ description:
392
+ 'Ensure every contour example validates against the declared contour schema.',
393
+ name: 'example-valid',
394
+ severity: 'error',
395
+ };