@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,616 @@
1
+ import {
2
+ extractStringOrTemplateLiteral,
3
+ findBlazeBodies,
4
+ findConfigProperty,
5
+ findTrailDefinitions,
6
+ getNodeArgument,
7
+ getNodeArguments,
8
+ getNodeCallee,
9
+ getNodeDiscriminant,
10
+ getNodeElements,
11
+ getNodeExpression,
12
+ getNodeId,
13
+ getNodeInit,
14
+ getNodeKey,
15
+ getNodeLeft,
16
+ getNodeObject,
17
+ getNodeOperator,
18
+ getNodeParams,
19
+ getNodeProperties,
20
+ getNodeProperty,
21
+ getNodeRight,
22
+ getNodeTest,
23
+ getNodeValueNode,
24
+ getPropertyName,
25
+ identifierName,
26
+ offsetToLine,
27
+ parse,
28
+ walkScope,
29
+ } from './ast.js';
30
+ import type { AstNode } from './ast.js';
31
+ import type { WardenDiagnostic, WardenRule } from './types.js';
32
+
33
+ const RULE_NAME = 'trail-fork-coaching';
34
+
35
+ const CONTROL_FIELD_NAMES = new Set(['action', 'operation']);
36
+
37
+ interface ControlField {
38
+ readonly name: string;
39
+ readonly node: AstNode;
40
+ readonly options: readonly string[];
41
+ }
42
+
43
+ interface SchemaBindingRecord {
44
+ readonly initializer: AstNode | undefined;
45
+ readonly scopeEnd: number;
46
+ readonly scopeStart: number;
47
+ readonly start: number;
48
+ }
49
+
50
+ type SchemaBindings = ReadonlyMap<string, readonly SchemaBindingRecord[]>;
51
+
52
+ interface BlazeInputBindings {
53
+ readonly aliases: ReadonlyMap<string, string>;
54
+ readonly inputParamName: string | null;
55
+ }
56
+
57
+ const LEXICAL_SCOPE_TYPES = new Set([
58
+ 'ArrowFunctionExpression',
59
+ 'BlockStatement',
60
+ 'CatchClause',
61
+ 'ClassStaticBlock',
62
+ 'ForInStatement',
63
+ 'ForOfStatement',
64
+ 'ForStatement',
65
+ 'FunctionBody',
66
+ 'FunctionDeclaration',
67
+ 'FunctionExpression',
68
+ 'Program',
69
+ 'StaticBlock',
70
+ 'SwitchStatement',
71
+ ]);
72
+
73
+ const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
74
+ let current = node;
75
+ while (
76
+ current?.type === 'TSAsExpression' ||
77
+ current?.type === 'TSSatisfiesExpression'
78
+ ) {
79
+ current = getNodeExpression(current) ?? getNodeArgument(current);
80
+ }
81
+ return current;
82
+ };
83
+
84
+ const callArguments = (node: AstNode): readonly AstNode[] =>
85
+ getNodeArguments(node) ?? [];
86
+
87
+ const callName = (node: AstNode | undefined): string | null => {
88
+ const unwrapped = unwrapExpression(node);
89
+ if (unwrapped?.type !== 'CallExpression') {
90
+ return null;
91
+ }
92
+ const callee = getNodeCallee(unwrapped);
93
+ if (callee?.type !== 'MemberExpression') {
94
+ return identifierName(callee);
95
+ }
96
+ return getPropertyName(getNodeProperty(callee));
97
+ };
98
+
99
+ const callObject = (node: AstNode): AstNode | undefined => {
100
+ const callee = getNodeCallee(node);
101
+ if (callee?.type !== 'MemberExpression') {
102
+ return undefined;
103
+ }
104
+ return getNodeObject(callee);
105
+ };
106
+
107
+ const schemaBindingInitializer = (
108
+ schemaBindings: SchemaBindings,
109
+ name: string,
110
+ referenceStart: number
111
+ ): AstNode | undefined => {
112
+ const records = schemaBindings.get(name) ?? [];
113
+ for (const record of records) {
114
+ if (
115
+ record.start < referenceStart &&
116
+ record.scopeStart <= referenceStart &&
117
+ referenceStart <= record.scopeEnd
118
+ ) {
119
+ return record.initializer;
120
+ }
121
+ }
122
+ return undefined;
123
+ };
124
+
125
+ const resolveSchemaReference = (
126
+ node: AstNode | undefined,
127
+ schemaBindings: SchemaBindings,
128
+ seenBindings = new Set<string>()
129
+ ): AstNode | undefined => {
130
+ const current = unwrapExpression(node);
131
+ const name = identifierName(current);
132
+ if (!name || seenBindings.has(name)) {
133
+ return current;
134
+ }
135
+
136
+ const initializer = schemaBindingInitializer(
137
+ schemaBindings,
138
+ name,
139
+ current?.start ?? 0
140
+ );
141
+ if (!initializer) {
142
+ return current;
143
+ }
144
+
145
+ seenBindings.add(name);
146
+ return resolveSchemaReference(initializer, schemaBindings, seenBindings);
147
+ };
148
+
149
+ const unwrapZodDecorators = (
150
+ node: AstNode | undefined,
151
+ schemaBindings: SchemaBindings
152
+ ): AstNode | undefined => {
153
+ let current = resolveSchemaReference(node, schemaBindings);
154
+ while (current?.type === 'CallExpression') {
155
+ const name = callName(current);
156
+ if (
157
+ name !== 'default' &&
158
+ name !== 'describe' &&
159
+ name !== 'optional' &&
160
+ name !== 'nullable' &&
161
+ name !== 'nullish' &&
162
+ name !== 'passthrough' &&
163
+ name !== 'readonly' &&
164
+ name !== 'strict' &&
165
+ name !== 'strip'
166
+ ) {
167
+ break;
168
+ }
169
+ const next = callObject(current);
170
+ if (!next) {
171
+ break;
172
+ }
173
+ current = resolveSchemaReference(next, schemaBindings);
174
+ }
175
+ return current;
176
+ };
177
+
178
+ const objectProperties = (node: AstNode): readonly AstNode[] =>
179
+ node.type === 'ObjectExpression' || node.type === 'ObjectPattern'
180
+ ? (getNodeProperties(node) ?? [])
181
+ : [];
182
+
183
+ const propertyValue = (property: AstNode): AstNode | undefined =>
184
+ property.type === 'Property' ? getNodeValueNode(property) : undefined;
185
+
186
+ const arrayElements = (node: AstNode | undefined): readonly AstNode[] => {
187
+ const unwrapped = unwrapExpression(node);
188
+ if (unwrapped?.type !== 'ArrayExpression') {
189
+ return [];
190
+ }
191
+ return getNodeElements(unwrapped).filter(
192
+ (element): element is AstNode => element !== null
193
+ );
194
+ };
195
+
196
+ const literalOptionsFromEnum = (node: AstNode): readonly string[] => {
197
+ const [options] = callArguments(node);
198
+ return arrayElements(options)
199
+ .map((element) => extractStringOrTemplateLiteral(element))
200
+ .filter((value): value is string => value !== null);
201
+ };
202
+
203
+ const literalOptionFromLiteral = (node: AstNode): string | null => {
204
+ const [literal] = callArguments(node);
205
+ return extractStringOrTemplateLiteral(literal);
206
+ };
207
+
208
+ const literalOptionsFromUnion = (node: AstNode): readonly string[] => {
209
+ const [branches] = callArguments(node);
210
+ return arrayElements(branches)
211
+ .map((branch) => {
212
+ const base = unwrapZodDecorators(branch, new Map());
213
+ return base?.type === 'CallExpression' && callName(base) === 'literal'
214
+ ? literalOptionFromLiteral(base)
215
+ : null;
216
+ })
217
+ .filter((value): value is string => value !== null);
218
+ };
219
+
220
+ const literalOptionsForSchema = (
221
+ schemaNode: AstNode | undefined,
222
+ schemaBindings: SchemaBindings
223
+ ): readonly string[] => {
224
+ const base = unwrapZodDecorators(schemaNode, schemaBindings);
225
+ if (base?.type !== 'CallExpression') {
226
+ return [];
227
+ }
228
+
229
+ if (callName(base) === 'enum') {
230
+ return literalOptionsFromEnum(base);
231
+ }
232
+ if (callName(base) === 'union') {
233
+ return literalOptionsFromUnion(base);
234
+ }
235
+ return [];
236
+ };
237
+
238
+ const astChildNodes = (node: AstNode): readonly AstNode[] => {
239
+ const children: AstNode[] = [];
240
+ for (const value of Object.values(node)) {
241
+ if (Array.isArray(value)) {
242
+ children.push(
243
+ ...value.filter(
244
+ (entry): entry is AstNode =>
245
+ typeof entry === 'object' &&
246
+ entry !== null &&
247
+ typeof (entry as AstNode).type === 'string'
248
+ )
249
+ );
250
+ continue;
251
+ }
252
+ if (
253
+ typeof value === 'object' &&
254
+ value !== null &&
255
+ typeof (value as AstNode).type === 'string'
256
+ ) {
257
+ children.push(value as AstNode);
258
+ }
259
+ }
260
+ return children;
261
+ };
262
+
263
+ const variableDeclaratorName = (node: AstNode): string | undefined => {
264
+ if (node.type !== 'VariableDeclarator') {
265
+ return undefined;
266
+ }
267
+ const id = getNodeId(node);
268
+ return identifierName(id) ?? undefined;
269
+ };
270
+
271
+ const collectSchemaBindings = (ast: AstNode): SchemaBindings => {
272
+ const bindings = new Map<string, SchemaBindingRecord[]>();
273
+
274
+ const visit = (
275
+ node: AstNode,
276
+ scope: { readonly end: number; readonly start: number }
277
+ ): void => {
278
+ const nextScope = LEXICAL_SCOPE_TYPES.has(node.type)
279
+ ? { end: node.end, start: node.start }
280
+ : scope;
281
+ const name = variableDeclaratorName(node);
282
+ if (name !== undefined) {
283
+ const records = bindings.get(name) ?? [];
284
+ records.push({
285
+ initializer: getNodeInit(node) ?? undefined,
286
+ scopeEnd: nextScope.end,
287
+ scopeStart: nextScope.start,
288
+ start: node.start,
289
+ });
290
+ bindings.set(name, records);
291
+ }
292
+
293
+ for (const child of astChildNodes(node)) {
294
+ visit(child, nextScope);
295
+ }
296
+ };
297
+
298
+ visit(ast, { end: ast.end, start: ast.start });
299
+ return bindings;
300
+ };
301
+
302
+ const inputShapeObject = (
303
+ config: AstNode,
304
+ schemaBindings: SchemaBindings
305
+ ): AstNode | null => {
306
+ const input = findConfigProperty(config, 'input');
307
+ const value = unwrapZodDecorators(
308
+ propertyValue(input ?? config),
309
+ schemaBindings
310
+ );
311
+ if (value?.type !== 'CallExpression' || callName(value) !== 'object') {
312
+ return null;
313
+ }
314
+
315
+ const [shape] = callArguments(value);
316
+ const unwrappedShape = unwrapExpression(shape);
317
+ return unwrappedShape?.type === 'ObjectExpression' ? unwrappedShape : null;
318
+ };
319
+
320
+ const findControlFields = (
321
+ config: AstNode,
322
+ schemaBindings: SchemaBindings
323
+ ): readonly ControlField[] => {
324
+ const shape = inputShapeObject(config, schemaBindings);
325
+ if (!shape) {
326
+ return [];
327
+ }
328
+
329
+ const fields: ControlField[] = [];
330
+ for (const property of objectProperties(shape)) {
331
+ const name = getPropertyName(getNodeKey(property));
332
+ if (!name || !CONTROL_FIELD_NAMES.has(name)) {
333
+ continue;
334
+ }
335
+
336
+ const options = literalOptionsForSchema(
337
+ propertyValue(property),
338
+ schemaBindings
339
+ );
340
+ if (options.length >= 2) {
341
+ fields.push({ name, node: property, options });
342
+ }
343
+ }
344
+ return fields;
345
+ };
346
+
347
+ const blazeParams = (blaze: AstNode): readonly AstNode[] =>
348
+ getNodeParams(blaze) ?? [];
349
+
350
+ const memberFieldName = (
351
+ node: AstNode | undefined,
352
+ inputParamName: string | null
353
+ ): string | null => {
354
+ if (!inputParamName) {
355
+ return null;
356
+ }
357
+ const unwrapped = unwrapExpression(node);
358
+ if (unwrapped?.type !== 'MemberExpression') {
359
+ return null;
360
+ }
361
+ const object = getNodeObject(unwrapped);
362
+ if (identifierName(object) !== inputParamName) {
363
+ return null;
364
+ }
365
+ return getPropertyName(getNodeProperty(unwrapped));
366
+ };
367
+
368
+ const patternAliasName = (node: AstNode | undefined): string | null => {
369
+ const unwrapped = unwrapExpression(node);
370
+ if (unwrapped?.type === 'AssignmentPattern') {
371
+ return identifierName(getNodeLeft(unwrapped));
372
+ }
373
+ return identifierName(unwrapped);
374
+ };
375
+
376
+ const collectDestructuredFieldAliasesFromPattern = (
377
+ pattern: AstNode | undefined,
378
+ fields: ReadonlySet<string>
379
+ ): ReadonlyMap<string, string> => {
380
+ const aliases = new Map<string, string>();
381
+ if (pattern?.type !== 'ObjectPattern') {
382
+ return aliases;
383
+ }
384
+
385
+ for (const property of objectProperties(pattern)) {
386
+ if (property.type === 'RestElement') {
387
+ continue;
388
+ }
389
+ const fieldName = getPropertyName(getNodeKey(property));
390
+ if (!fieldName || !fields.has(fieldName)) {
391
+ continue;
392
+ }
393
+ const alias = patternAliasName(propertyValue(property)) ?? fieldName;
394
+ aliases.set(alias, fieldName);
395
+ }
396
+ return aliases;
397
+ };
398
+
399
+ const collectDestructuredFieldAliases = (
400
+ blaze: AstNode,
401
+ inputParamName: string,
402
+ fields: ReadonlySet<string>
403
+ ): ReadonlyMap<string, string> => {
404
+ const aliases = new Map<string, string>();
405
+
406
+ walkScope(blaze, (node) => {
407
+ if (node.type !== 'VariableDeclarator') {
408
+ return;
409
+ }
410
+ const id = getNodeId(node);
411
+ const init = getNodeInit(node);
412
+ if (
413
+ identifierName(init) !== inputParamName ||
414
+ id?.type !== 'ObjectPattern'
415
+ ) {
416
+ return;
417
+ }
418
+
419
+ for (const [alias, fieldName] of collectDestructuredFieldAliasesFromPattern(
420
+ id,
421
+ fields
422
+ )) {
423
+ aliases.set(alias, fieldName);
424
+ }
425
+ });
426
+
427
+ return aliases;
428
+ };
429
+
430
+ const collectBlazeInputBindings = (
431
+ blaze: AstNode,
432
+ fields: ReadonlySet<string>
433
+ ): BlazeInputBindings | null => {
434
+ const [inputParam] = blazeParams(blaze);
435
+ const inputParamName = identifierName(inputParam);
436
+ if (inputParamName) {
437
+ return {
438
+ aliases: collectDestructuredFieldAliases(blaze, inputParamName, fields),
439
+ inputParamName,
440
+ };
441
+ }
442
+
443
+ if (inputParam?.type === 'ObjectPattern') {
444
+ return {
445
+ aliases: collectDestructuredFieldAliasesFromPattern(inputParam, fields),
446
+ inputParamName: null,
447
+ };
448
+ }
449
+
450
+ return null;
451
+ };
452
+
453
+ const branchFieldName = (
454
+ node: AstNode | undefined,
455
+ inputParamName: string | null,
456
+ aliases: ReadonlyMap<string, string>
457
+ ): string | null => {
458
+ const memberName = memberFieldName(node, inputParamName);
459
+ if (memberName) {
460
+ return memberName;
461
+ }
462
+ const identifier = identifierName(unwrapExpression(node));
463
+ return identifier ? (aliases.get(identifier) ?? null) : null;
464
+ };
465
+
466
+ const comparisonBranchesOnField = (
467
+ node: AstNode | undefined,
468
+ inputParamName: string | null,
469
+ fieldName: string,
470
+ aliases: ReadonlyMap<string, string>
471
+ ): AstNode | null => {
472
+ const unwrapped = unwrapExpression(node);
473
+ if (!unwrapped) {
474
+ return null;
475
+ }
476
+
477
+ if (unwrapped.type === 'LogicalExpression') {
478
+ const left = getNodeLeft(unwrapped);
479
+ const right = getNodeRight(unwrapped);
480
+ return (
481
+ comparisonBranchesOnField(left, inputParamName, fieldName, aliases) ??
482
+ comparisonBranchesOnField(right, inputParamName, fieldName, aliases)
483
+ );
484
+ }
485
+
486
+ if (unwrapped.type === 'UnaryExpression') {
487
+ const argument = getNodeArgument(unwrapped);
488
+ return comparisonBranchesOnField(
489
+ argument,
490
+ inputParamName,
491
+ fieldName,
492
+ aliases
493
+ );
494
+ }
495
+
496
+ if (unwrapped.type !== 'BinaryExpression') {
497
+ return null;
498
+ }
499
+
500
+ const left = getNodeLeft(unwrapped);
501
+ const operator = getNodeOperator(unwrapped);
502
+ const right = getNodeRight(unwrapped);
503
+ if (operator !== '===' && operator !== '!==') {
504
+ return null;
505
+ }
506
+
507
+ return branchFieldName(left, inputParamName, aliases) === fieldName ||
508
+ branchFieldName(right, inputParamName, aliases) === fieldName
509
+ ? unwrapped
510
+ : null;
511
+ };
512
+
513
+ const branchesOnField = (
514
+ blaze: AstNode,
515
+ inputParamName: string | null,
516
+ fieldName: string,
517
+ aliases: ReadonlyMap<string, string>
518
+ ): AstNode | null => {
519
+ let found: AstNode | null = null;
520
+ walkScope(blaze, (node) => {
521
+ if (found) {
522
+ return;
523
+ }
524
+
525
+ if (node.type === 'SwitchStatement') {
526
+ const discriminant = getNodeDiscriminant(node);
527
+ if (
528
+ branchFieldName(discriminant, inputParamName, aliases) === fieldName
529
+ ) {
530
+ found = node;
531
+ }
532
+ return;
533
+ }
534
+
535
+ if (node.type !== 'IfStatement' && node.type !== 'ConditionalExpression') {
536
+ return;
537
+ }
538
+ const test = getNodeTest(node);
539
+ found = comparisonBranchesOnField(test, inputParamName, fieldName, aliases);
540
+ });
541
+ return found;
542
+ };
543
+
544
+ const diagnosticForField = (
545
+ sourceCode: string,
546
+ filePath: string,
547
+ trailId: string,
548
+ field: ControlField,
549
+ branchNode: AstNode
550
+ ): WardenDiagnostic => ({
551
+ filePath,
552
+ line: offsetToLine(sourceCode, branchNode.start),
553
+ message: `Trail "${trailId}" branches on input.${field.name} (${field.options
554
+ .map((option) => `"${option}"`)
555
+ .join(
556
+ ', '
557
+ )}). This may be a trail fork hidden as a surface accommodation. If branches change semantics (intent, permits, errors, outputs, lifecycle, or side effects) or structure (selected trail identity), split them into distinct trails, a composing trail, or a trailhead that preserves member identity.`,
558
+ rule: RULE_NAME,
559
+ severity: 'warn',
560
+ });
561
+
562
+ export const trailForkCoaching: WardenRule = {
563
+ check(sourceCode, filePath) {
564
+ const ast = parse(filePath, sourceCode);
565
+ if (!ast) {
566
+ return [];
567
+ }
568
+
569
+ const schemaBindings = collectSchemaBindings(ast);
570
+ const diagnostics: WardenDiagnostic[] = [];
571
+ for (const definition of findTrailDefinitions(ast)) {
572
+ if (definition.kind !== 'trail') {
573
+ continue;
574
+ }
575
+
576
+ const fields = findControlFields(definition.config, schemaBindings);
577
+ if (fields.length === 0) {
578
+ continue;
579
+ }
580
+ const fieldNames = new Set(fields.map((field) => field.name));
581
+
582
+ for (const blaze of findBlazeBodies(definition.config)) {
583
+ const inputBindings = collectBlazeInputBindings(blaze, fieldNames);
584
+ if (!inputBindings) {
585
+ continue;
586
+ }
587
+
588
+ for (const field of fields) {
589
+ const branchNode = branchesOnField(
590
+ blaze,
591
+ inputBindings.inputParamName,
592
+ field.name,
593
+ inputBindings.aliases
594
+ );
595
+ if (branchNode) {
596
+ diagnostics.push(
597
+ diagnosticForField(
598
+ sourceCode,
599
+ filePath,
600
+ definition.id,
601
+ field,
602
+ branchNode
603
+ )
604
+ );
605
+ }
606
+ }
607
+ }
608
+ }
609
+
610
+ return diagnostics;
611
+ },
612
+ description:
613
+ 'Coach trails away from hiding several capabilities behind one branching action or operation input.',
614
+ name: RULE_NAME,
615
+ severity: 'warn',
616
+ };