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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +835 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,625 @@
1
+ import {
2
+ extractStringOrTemplateLiteral,
3
+ findConfigProperty,
4
+ findImplementationBodies,
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 '@ontrails/source';
30
+ import type { AstNode } from '@ontrails/source';
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 ImplementationInputBindings {
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 implementationParams = (implementation: AstNode): readonly AstNode[] =>
348
+ getNodeParams(implementation) ?? [];
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
+ implementation: AstNode,
401
+ inputParamName: string,
402
+ fields: ReadonlySet<string>
403
+ ): ReadonlyMap<string, string> => {
404
+ const aliases = new Map<string, string>();
405
+
406
+ walkScope(implementation, (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 collectImplementationInputBindings = (
431
+ implementation: AstNode,
432
+ fields: ReadonlySet<string>
433
+ ): ImplementationInputBindings | null => {
434
+ const [inputParam] = implementationParams(implementation);
435
+ const inputParamName = identifierName(inputParam);
436
+ if (inputParamName) {
437
+ return {
438
+ aliases: collectDestructuredFieldAliases(
439
+ implementation,
440
+ inputParamName,
441
+ fields
442
+ ),
443
+ inputParamName,
444
+ };
445
+ }
446
+
447
+ if (inputParam?.type === 'ObjectPattern') {
448
+ return {
449
+ aliases: collectDestructuredFieldAliasesFromPattern(inputParam, fields),
450
+ inputParamName: null,
451
+ };
452
+ }
453
+
454
+ return null;
455
+ };
456
+
457
+ const branchFieldName = (
458
+ node: AstNode | undefined,
459
+ inputParamName: string | null,
460
+ aliases: ReadonlyMap<string, string>
461
+ ): string | null => {
462
+ const memberName = memberFieldName(node, inputParamName);
463
+ if (memberName) {
464
+ return memberName;
465
+ }
466
+ const identifier = identifierName(unwrapExpression(node));
467
+ return identifier ? (aliases.get(identifier) ?? null) : null;
468
+ };
469
+
470
+ const comparisonBranchesOnField = (
471
+ node: AstNode | undefined,
472
+ inputParamName: string | null,
473
+ fieldName: string,
474
+ aliases: ReadonlyMap<string, string>
475
+ ): AstNode | null => {
476
+ const unwrapped = unwrapExpression(node);
477
+ if (!unwrapped) {
478
+ return null;
479
+ }
480
+
481
+ if (unwrapped.type === 'LogicalExpression') {
482
+ const left = getNodeLeft(unwrapped);
483
+ const right = getNodeRight(unwrapped);
484
+ return (
485
+ comparisonBranchesOnField(left, inputParamName, fieldName, aliases) ??
486
+ comparisonBranchesOnField(right, inputParamName, fieldName, aliases)
487
+ );
488
+ }
489
+
490
+ if (unwrapped.type === 'UnaryExpression') {
491
+ const argument = getNodeArgument(unwrapped);
492
+ return comparisonBranchesOnField(
493
+ argument,
494
+ inputParamName,
495
+ fieldName,
496
+ aliases
497
+ );
498
+ }
499
+
500
+ if (unwrapped.type !== 'BinaryExpression') {
501
+ return null;
502
+ }
503
+
504
+ const left = getNodeLeft(unwrapped);
505
+ const operator = getNodeOperator(unwrapped);
506
+ const right = getNodeRight(unwrapped);
507
+ if (operator !== '===' && operator !== '!==') {
508
+ return null;
509
+ }
510
+
511
+ return branchFieldName(left, inputParamName, aliases) === fieldName ||
512
+ branchFieldName(right, inputParamName, aliases) === fieldName
513
+ ? unwrapped
514
+ : null;
515
+ };
516
+
517
+ const branchesOnField = (
518
+ implementation: AstNode,
519
+ inputParamName: string | null,
520
+ fieldName: string,
521
+ aliases: ReadonlyMap<string, string>
522
+ ): AstNode | null => {
523
+ let found: AstNode | null = null;
524
+ walkScope(implementation, (node) => {
525
+ if (found) {
526
+ return;
527
+ }
528
+
529
+ if (node.type === 'SwitchStatement') {
530
+ const discriminant = getNodeDiscriminant(node);
531
+ if (
532
+ branchFieldName(discriminant, inputParamName, aliases) === fieldName
533
+ ) {
534
+ found = node;
535
+ }
536
+ return;
537
+ }
538
+
539
+ if (node.type !== 'IfStatement' && node.type !== 'ConditionalExpression') {
540
+ return;
541
+ }
542
+ const test = getNodeTest(node);
543
+ found = comparisonBranchesOnField(test, inputParamName, fieldName, aliases);
544
+ });
545
+ return found;
546
+ };
547
+
548
+ const diagnosticForField = (
549
+ sourceCode: string,
550
+ filePath: string,
551
+ trailId: string,
552
+ field: ControlField,
553
+ branchNode: AstNode
554
+ ): WardenDiagnostic => ({
555
+ filePath,
556
+ line: offsetToLine(sourceCode, branchNode.start),
557
+ message: `Trail "${trailId}" branches on input.${field.name} (${field.options
558
+ .map((option) => `"${option}"`)
559
+ .join(
560
+ ', '
561
+ )}). 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.`,
562
+ rule: RULE_NAME,
563
+ severity: 'warn',
564
+ });
565
+
566
+ export const trailForkCoaching: WardenRule = {
567
+ check(sourceCode, filePath) {
568
+ const ast = parse(filePath, sourceCode);
569
+ if (!ast) {
570
+ return [];
571
+ }
572
+
573
+ const schemaBindings = collectSchemaBindings(ast);
574
+ const diagnostics: WardenDiagnostic[] = [];
575
+ for (const definition of findTrailDefinitions(ast)) {
576
+ if (definition.kind !== 'trail') {
577
+ continue;
578
+ }
579
+
580
+ const fields = findControlFields(definition.config, schemaBindings);
581
+ if (fields.length === 0) {
582
+ continue;
583
+ }
584
+ const fieldNames = new Set(fields.map((field) => field.name));
585
+
586
+ for (const implementation of findImplementationBodies(
587
+ definition.config
588
+ )) {
589
+ const inputBindings = collectImplementationInputBindings(
590
+ implementation,
591
+ fieldNames
592
+ );
593
+ if (!inputBindings) {
594
+ continue;
595
+ }
596
+
597
+ for (const field of fields) {
598
+ const branchNode = branchesOnField(
599
+ implementation,
600
+ inputBindings.inputParamName,
601
+ field.name,
602
+ inputBindings.aliases
603
+ );
604
+ if (branchNode) {
605
+ diagnostics.push(
606
+ diagnosticForField(
607
+ sourceCode,
608
+ filePath,
609
+ definition.id,
610
+ field,
611
+ branchNode
612
+ )
613
+ );
614
+ }
615
+ }
616
+ }
617
+ }
618
+
619
+ return diagnostics;
620
+ },
621
+ description:
622
+ 'Coach trails away from hiding several capabilities behind one branching action or operation input.',
623
+ name: RULE_NAME,
624
+ severity: 'warn',
625
+ };