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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +837 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,225 @@
1
+ import {
2
+ findConfigProperty,
3
+ findTrailDefinitions,
4
+ identifierName,
5
+ offsetToLine,
6
+ parse,
7
+ walk,
8
+ walkScope,
9
+ } from '@ontrails/source';
10
+ import type { AstNode } from '@ontrails/source';
11
+ import { isTestFile } from './scan.js';
12
+ import type { WardenDiagnostic, WardenRule } from './types.js';
13
+
14
+ const TRANSPARENT_WRAPPER_TYPES = new Set([
15
+ 'ParenthesizedExpression',
16
+ 'TSAsExpression',
17
+ 'TSSatisfiesExpression',
18
+ 'TSNonNullExpression',
19
+ 'TSTypeAssertion',
20
+ ]);
21
+
22
+ const FUNCTION_TYPES = new Set([
23
+ 'ArrowFunctionExpression',
24
+ 'FunctionDeclaration',
25
+ 'FunctionExpression',
26
+ ]);
27
+
28
+ const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
29
+ let current = node;
30
+ while (current && TRANSPARENT_WRAPPER_TYPES.has(current.type)) {
31
+ current = current['expression'] as AstNode | undefined;
32
+ }
33
+ return current;
34
+ };
35
+
36
+ const getDetourElements = (config: AstNode): readonly (AstNode | null)[] => {
37
+ const detoursProp = findConfigProperty(config, 'detours');
38
+ if (!detoursProp) {
39
+ return [];
40
+ }
41
+
42
+ const detoursValue = unwrapExpression(
43
+ detoursProp.value as AstNode | undefined
44
+ );
45
+ if (!detoursValue || detoursValue.type !== 'ArrayExpression') {
46
+ return [];
47
+ }
48
+
49
+ return (
50
+ ((detoursValue as AstNode)['elements'] as readonly (AstNode | null)[]) ?? []
51
+ );
52
+ };
53
+
54
+ const getFunctionBody = (node: AstNode | undefined): AstNode | undefined => {
55
+ if (!node || !FUNCTION_TYPES.has(node.type)) {
56
+ return undefined;
57
+ }
58
+
59
+ const { body } = node;
60
+ return Array.isArray(body) ? undefined : (body as AstNode | undefined);
61
+ };
62
+
63
+ interface RecoverBodyMatch {
64
+ readonly index: number;
65
+ readonly trailId: string;
66
+ readonly body: AstNode;
67
+ }
68
+
69
+ const collectFunctionRecoverBinding = (
70
+ bindings: Map<string, AstNode>,
71
+ node: AstNode
72
+ ): boolean => {
73
+ if (node.type !== 'FunctionDeclaration') {
74
+ return false;
75
+ }
76
+
77
+ const name = identifierName(node['id'] as AstNode | undefined);
78
+ if (name) {
79
+ bindings.set(name, node);
80
+ }
81
+ return true;
82
+ };
83
+
84
+ const collectVariableRecoverBinding = (
85
+ bindings: Map<string, AstNode>,
86
+ node: AstNode
87
+ ): void => {
88
+ if (node.type !== 'VariableDeclarator') {
89
+ return;
90
+ }
91
+
92
+ const name = identifierName(node['id'] as AstNode | undefined);
93
+ const init = unwrapExpression(node['init'] as AstNode | undefined);
94
+ if (!name || !init || !FUNCTION_TYPES.has(init.type)) {
95
+ return;
96
+ }
97
+
98
+ bindings.set(name, init);
99
+ };
100
+
101
+ const collectRecoverBinding = (
102
+ bindings: Map<string, AstNode>,
103
+ node: AstNode
104
+ ): void => {
105
+ if (collectFunctionRecoverBinding(bindings, node)) {
106
+ return;
107
+ }
108
+
109
+ collectVariableRecoverBinding(bindings, node);
110
+ };
111
+
112
+ const collectRecoverBindings = (ast: AstNode): ReadonlyMap<string, AstNode> => {
113
+ const bindings = new Map<string, AstNode>();
114
+
115
+ walk(ast, (node) => {
116
+ collectRecoverBinding(bindings, node);
117
+ });
118
+
119
+ return bindings;
120
+ };
121
+
122
+ const resolveRecoverBody = (
123
+ node: AstNode | undefined,
124
+ bindings: ReadonlyMap<string, AstNode>
125
+ ): AstNode | undefined => {
126
+ const unwrapped = unwrapExpression(node);
127
+ if (!unwrapped) {
128
+ return undefined;
129
+ }
130
+
131
+ const inlineBody = getFunctionBody(unwrapped);
132
+ if (inlineBody) {
133
+ return inlineBody;
134
+ }
135
+
136
+ const bindingName = identifierName(unwrapped);
137
+ if (!bindingName) {
138
+ return undefined;
139
+ }
140
+
141
+ return getFunctionBody(bindings.get(bindingName));
142
+ };
143
+
144
+ const resolveRecoverBodyFromElement = (
145
+ element: AstNode | null,
146
+ bindings: ReadonlyMap<string, AstNode>
147
+ ): AstNode | undefined => {
148
+ if (!element || element.type !== 'ObjectExpression') {
149
+ return undefined;
150
+ }
151
+
152
+ const recoverProp = findConfigProperty(element, 'recover');
153
+ return resolveRecoverBody(
154
+ recoverProp?.value as AstNode | undefined,
155
+ bindings
156
+ );
157
+ };
158
+
159
+ const appendRecoverBodies = (
160
+ bodies: RecoverBodyMatch[],
161
+ definition: { readonly config: AstNode; readonly id: string },
162
+ bindings: ReadonlyMap<string, AstNode>
163
+ ): void => {
164
+ const detourElements = getDetourElements(definition.config);
165
+ for (const [index, element] of detourElements.entries()) {
166
+ const body = resolveRecoverBodyFromElement(element, bindings);
167
+ if (!body) {
168
+ continue;
169
+ }
170
+
171
+ bodies.push({ body, index, trailId: definition.id });
172
+ }
173
+ };
174
+
175
+ const findRecoverBodies = (ast: AstNode): readonly RecoverBodyMatch[] => {
176
+ const bindings = collectRecoverBindings(ast);
177
+ const bodies: RecoverBodyMatch[] = [];
178
+
179
+ for (const definition of findTrailDefinitions(ast)) {
180
+ if (definition.kind !== 'trail') {
181
+ continue;
182
+ }
183
+
184
+ appendRecoverBodies(bodies, definition, bindings);
185
+ }
186
+
187
+ return bodies;
188
+ };
189
+
190
+ export const noThrowInDetourRecover: WardenRule = {
191
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
192
+ if (isTestFile(filePath)) {
193
+ return [];
194
+ }
195
+
196
+ const ast = parse(filePath, sourceCode);
197
+ if (!ast) {
198
+ return [];
199
+ }
200
+
201
+ const diagnostics: WardenDiagnostic[] = [];
202
+
203
+ for (const recover of findRecoverBodies(ast)) {
204
+ walkScope(recover.body, (node) => {
205
+ if (node.type !== 'ThrowStatement') {
206
+ return;
207
+ }
208
+
209
+ diagnostics.push({
210
+ filePath,
211
+ line: offsetToLine(sourceCode, node.start),
212
+ message: `Trail "${recover.trailId}" detour[${recover.index}] recover must not throw. Return Result.err() instead.`,
213
+ rule: 'no-throw-in-detour-recover',
214
+ severity: 'error',
215
+ });
216
+ });
217
+ }
218
+
219
+ return diagnostics;
220
+ },
221
+ description:
222
+ 'Disallow throw statements inside detour recover functions. Use Result.err() instead.',
223
+ name: 'no-throw-in-detour-recover',
224
+ severity: 'error',
225
+ };
@@ -1,11 +1,18 @@
1
1
  /**
2
- * Finds `throw` statements inside `run:` function bodies.
2
+ * Finds `throw` statements inside `implementation:` function bodies.
3
3
  *
4
- * Uses AST parsing for accurate detection no false positives from
5
- * throw in comments, strings, or nested non-implementation functions.
4
+ * Uses scope-aware AST walking so throws inside nested callbacks
5
+ * (e.g. `.map()`, `.filter()`, inner helpers) are not attributed to
6
+ * the implementation body itself. ADR-0007 requires this class of false positive
7
+ * to be avoided — only throws in the implementation body scope should be flagged.
6
8
  */
7
9
 
8
- import { findRunBodies, offsetToLine, parse, walk } from './ast.js';
10
+ import {
11
+ findImplementationBodies,
12
+ offsetToLine,
13
+ parse,
14
+ walkScope,
15
+ } from '@ontrails/source';
9
16
  import type { WardenDiagnostic, WardenRule } from './types.js';
10
17
 
11
18
  export const noThrowInImplementation: WardenRule = {
@@ -17,14 +24,14 @@ export const noThrowInImplementation: WardenRule = {
17
24
 
18
25
  const diagnostics: WardenDiagnostic[] = [];
19
26
 
20
- for (const body of findRunBodies(ast)) {
21
- walk(body, (node) => {
27
+ for (const body of findImplementationBodies(ast)) {
28
+ walkScope(body, (node) => {
22
29
  if (node.type === 'ThrowStatement') {
23
30
  diagnostics.push({
24
31
  filePath,
25
32
  line: offsetToLine(sourceCode, node.start),
26
33
  message:
27
- 'Do not throw inside implementation. Use Result.err() instead.',
34
+ 'Do not throw inside the implementation. Use Result.err() instead.',
28
35
  rule: 'no-throw-in-implementation',
29
36
  severity: 'error',
30
37
  });
@@ -35,7 +42,7 @@ export const noThrowInImplementation: WardenRule = {
35
42
  return diagnostics;
36
43
  },
37
44
  description:
38
- 'Disallow throw statements inside trail/route implementation bodies. Use Result.err() instead.',
45
+ 'Disallow throw statements inside implementation bodies. Use Result.err() instead.',
39
46
  name: 'no-throw-in-implementation',
40
47
  severity: 'error',
41
48
  };
@@ -0,0 +1,371 @@
1
+ import {
2
+ getNodeArgument,
3
+ getNodeBodyStatements,
4
+ getNodeCallee,
5
+ getNodeComputed,
6
+ getNodeDeclaration,
7
+ getNodeDeclarations,
8
+ getNodeExported,
9
+ getNodeExpression,
10
+ getNodeId,
11
+ getNodeImported,
12
+ getNodeInit,
13
+ getNodeLeft,
14
+ getNodeLocal,
15
+ getNodeObject,
16
+ getNodeProperty,
17
+ getNodeSource,
18
+ getNodeSpecifiers,
19
+ getStringValue,
20
+ identifierName,
21
+ isStringLiteral,
22
+ offsetToLine,
23
+ parse,
24
+ } from '@ontrails/source';
25
+ import type { AstNode } from '@ontrails/source';
26
+ import type { WardenDiagnostic, WardenRule } from './types.js';
27
+
28
+ const RULE_NAME = 'no-top-level-surface';
29
+
30
+ const TOPO_EXPORT_NAMES = new Set(['app', 'graph']);
31
+ const SURFACE_OPEN_CALLEE_NAMES = new Set([
32
+ 'connectStdio',
33
+ 'startServer',
34
+ 'surface',
35
+ ]);
36
+ const TOPO_IMPORT_SOURCES = new Set(['@ontrails/core']);
37
+ const SURFACE_IMPORT_SOURCES = new Set([
38
+ '@ontrails/commander',
39
+ '@ontrails/hono',
40
+ '@ontrails/http',
41
+ '@ontrails/http/bun',
42
+ '@ontrails/mcp',
43
+ ]);
44
+
45
+ const diagnosticMessage =
46
+ 'This module exports a topo and opens a surface at module top level. Trails introspection commands (`survey`, `guide`, `compile`) import topo entry modules, so opening a surface here can trigger sockets or transports during introspection. Move surface-opening to a separate entry/bin and keep the topo-export module side-effect-free.';
47
+
48
+ const unwrapExportDeclaration = (node: AstNode): AstNode =>
49
+ node.type === 'ExportNamedDeclaration' ||
50
+ node.type === 'ExportDefaultDeclaration'
51
+ ? (getNodeDeclaration(node) ?? node)
52
+ : node;
53
+
54
+ interface ImportedBindings {
55
+ readonly named: ReadonlyMap<string, string>;
56
+ readonly namespaces: ReadonlySet<string>;
57
+ }
58
+
59
+ const importSource = (node: AstNode): string | null => {
60
+ const source = getNodeSource(node);
61
+ return source && isStringLiteral(source) ? getStringValue(source) : null;
62
+ };
63
+
64
+ const importedSpecifierName = (node: AstNode | undefined): string | null =>
65
+ identifierName(node) ??
66
+ (node && isStringLiteral(node) ? getStringValue(node) : null);
67
+
68
+ const addFrameworkImportBindings = (
69
+ ast: AstNode,
70
+ sources: ReadonlySet<string>,
71
+ allowedImports: ReadonlySet<string>
72
+ ): ImportedBindings => {
73
+ const named = new Map<string, string>();
74
+ const namespaces = new Set<string>();
75
+ const body = getNodeBodyStatements(ast);
76
+
77
+ for (const statement of body) {
78
+ if (
79
+ statement.type !== 'ImportDeclaration' ||
80
+ !sources.has(importSource(statement) ?? '')
81
+ ) {
82
+ continue;
83
+ }
84
+
85
+ const specifiers = getNodeSpecifiers(statement);
86
+ for (const specifier of specifiers) {
87
+ if (specifier.type === 'ImportNamespaceSpecifier') {
88
+ const localName = identifierName(getNodeLocal(specifier));
89
+ if (localName) {
90
+ namespaces.add(localName);
91
+ }
92
+ continue;
93
+ }
94
+ if (specifier.type !== 'ImportSpecifier') {
95
+ continue;
96
+ }
97
+
98
+ const imported = getNodeImported(specifier);
99
+ const local = getNodeLocal(specifier);
100
+ const importedName = importedSpecifierName(imported);
101
+ const localName = identifierName(local);
102
+ if (importedName && allowedImports.has(importedName) && localName) {
103
+ named.set(localName, importedName);
104
+ }
105
+ }
106
+ }
107
+
108
+ return { named, namespaces };
109
+ };
110
+
111
+ const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
112
+ let current = node;
113
+ while (
114
+ current?.type === 'AwaitExpression' ||
115
+ current?.type === 'ChainExpression' ||
116
+ current?.type === 'TSAsExpression' ||
117
+ current?.type === 'TSSatisfiesExpression'
118
+ ) {
119
+ current = getNodeExpression(current) ?? getNodeArgument(current);
120
+ }
121
+ return current;
122
+ };
123
+
124
+ const memberExpressionParts = (
125
+ node: AstNode | undefined
126
+ ): { objectName: string | null; propertyName: string | null } => {
127
+ if (node?.type !== 'MemberExpression') {
128
+ return { objectName: null, propertyName: null };
129
+ }
130
+ const computed = getNodeComputed(node);
131
+ const property = getNodeProperty(node);
132
+ return {
133
+ objectName: identifierName(getNodeObject(node)),
134
+ propertyName: computed ? null : identifierName(property),
135
+ };
136
+ };
137
+
138
+ const calleeName = (
139
+ node: AstNode | undefined,
140
+ bindings: ImportedBindings
141
+ ): string | null => {
142
+ const callee = unwrapExpression(node);
143
+ if (!callee) {
144
+ return null;
145
+ }
146
+ const directName = identifierName(callee);
147
+ if (directName && bindings.named.has(directName)) {
148
+ return bindings.named.get(directName) ?? null;
149
+ }
150
+
151
+ const { objectName, propertyName } = memberExpressionParts(callee);
152
+ if (
153
+ objectName &&
154
+ propertyName &&
155
+ bindings.namespaces.has(objectName) &&
156
+ (SURFACE_OPEN_CALLEE_NAMES.has(propertyName) || propertyName === 'topo')
157
+ ) {
158
+ return propertyName;
159
+ }
160
+
161
+ return null;
162
+ };
163
+
164
+ const isTopoCall = (
165
+ node: AstNode | undefined,
166
+ bindings: ImportedBindings
167
+ ): boolean =>
168
+ calleeName(getNodeCallee(unwrapExpression(node)), bindings) === 'topo';
169
+
170
+ const isSurfaceOpenCall = (
171
+ node: AstNode | undefined,
172
+ bindings: ImportedBindings
173
+ ): boolean => {
174
+ const expression = unwrapExpression(node);
175
+ if (expression?.type !== 'CallExpression') {
176
+ return false;
177
+ }
178
+
179
+ const callee = getNodeCallee(expression);
180
+ const directName = calleeName(callee, bindings);
181
+ if (directName && SURFACE_OPEN_CALLEE_NAMES.has(directName)) {
182
+ return true;
183
+ }
184
+ const { objectName, propertyName } = memberExpressionParts(callee);
185
+ if (
186
+ objectName &&
187
+ propertyName === 'listen' &&
188
+ bindings.namespaces.has(objectName)
189
+ ) {
190
+ return true;
191
+ }
192
+
193
+ return false;
194
+ };
195
+
196
+ const declarationIdName = (node: AstNode | undefined): string | null =>
197
+ identifierName(node) ?? identifierName(node ? getNodeLeft(node) : undefined);
198
+
199
+ const collectTopLevelTopoBindings = (
200
+ ast: AstNode,
201
+ topoBindings: ImportedBindings
202
+ ): ReadonlySet<string> => {
203
+ const bindings = new Set<string>();
204
+ const body = getNodeBodyStatements(ast);
205
+
206
+ for (const statement of body) {
207
+ const declaration = unwrapExportDeclaration(statement);
208
+ if (declaration.type === 'VariableDeclaration') {
209
+ const declarations = getNodeDeclarations(declaration);
210
+ for (const item of declarations) {
211
+ const id = getNodeId(item);
212
+ const init = getNodeInit(item);
213
+ const name = declarationIdName(id);
214
+ if (name && isTopoCall(init, topoBindings)) {
215
+ bindings.add(name);
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ return bindings;
222
+ };
223
+
224
+ const namedExportCarriesTopo = (
225
+ statement: AstNode,
226
+ topLevelTopoBindings: ReadonlySet<string>
227
+ ): boolean => {
228
+ const specifiers = getNodeSpecifiers(statement) ?? [];
229
+
230
+ for (const specifier of specifiers) {
231
+ const exported = getNodeExported(specifier);
232
+ const local = getNodeLocal(specifier);
233
+ const exportedName = identifierName(exported);
234
+ const localName = identifierName(local);
235
+ if (
236
+ exportedName &&
237
+ TOPO_EXPORT_NAMES.has(exportedName) &&
238
+ localName &&
239
+ topLevelTopoBindings.has(localName)
240
+ ) {
241
+ return true;
242
+ }
243
+ }
244
+
245
+ return false;
246
+ };
247
+
248
+ const moduleExportsTopo = (
249
+ ast: AstNode,
250
+ topoBindings: ImportedBindings
251
+ ): boolean => {
252
+ const topLevelTopoBindings = collectTopLevelTopoBindings(ast, topoBindings);
253
+ const body = getNodeBodyStatements(ast);
254
+
255
+ for (const statement of body) {
256
+ if (statement.type === 'ExportDefaultDeclaration') {
257
+ const declaration = unwrapExportDeclaration(statement);
258
+ if (isTopoCall(declaration, topoBindings)) {
259
+ return true;
260
+ }
261
+ const defaultName = identifierName(declaration);
262
+ if (defaultName && topLevelTopoBindings.has(defaultName)) {
263
+ return true;
264
+ }
265
+ }
266
+
267
+ if (statement.type === 'ExportNamedDeclaration') {
268
+ const declaration = unwrapExportDeclaration(statement);
269
+ if (namedExportCarriesTopo(statement, topLevelTopoBindings)) {
270
+ return true;
271
+ }
272
+ if (declaration.type !== 'VariableDeclaration') {
273
+ continue;
274
+ }
275
+ const declarations = getNodeDeclarations(declaration);
276
+ for (const item of declarations) {
277
+ const id = getNodeId(item);
278
+ const init = getNodeInit(item);
279
+ const name = declarationIdName(id);
280
+ if (
281
+ name &&
282
+ TOPO_EXPORT_NAMES.has(name) &&
283
+ isTopoCall(init, topoBindings)
284
+ ) {
285
+ return true;
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ return false;
292
+ };
293
+
294
+ const topLevelSurfaceOpen = (
295
+ statement: AstNode,
296
+ surfaceBindings: ImportedBindings
297
+ ): AstNode | null => {
298
+ const declaration = unwrapExportDeclaration(statement);
299
+ if (declaration.type === 'ExpressionStatement') {
300
+ const expression = getNodeExpression(declaration);
301
+ const unwrapped = unwrapExpression(expression);
302
+ return isSurfaceOpenCall(unwrapped, surfaceBindings)
303
+ ? (unwrapped ?? null)
304
+ : null;
305
+ }
306
+
307
+ const unwrappedDeclaration = unwrapExpression(declaration);
308
+ if (isSurfaceOpenCall(unwrappedDeclaration, surfaceBindings)) {
309
+ return unwrappedDeclaration ?? null;
310
+ }
311
+
312
+ if (declaration.type !== 'VariableDeclaration') {
313
+ return null;
314
+ }
315
+
316
+ const declarations = getNodeDeclarations(declaration);
317
+ for (const item of declarations) {
318
+ const init = getNodeInit(item);
319
+ const unwrapped = unwrapExpression(init);
320
+ if (isSurfaceOpenCall(unwrapped, surfaceBindings)) {
321
+ return unwrapped ?? null;
322
+ }
323
+ }
324
+
325
+ return null;
326
+ };
327
+
328
+ export const noTopLevelSurface: WardenRule = {
329
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
330
+ const ast = parse(filePath, sourceCode);
331
+ if (!ast) {
332
+ return [];
333
+ }
334
+
335
+ const topoBindings = addFrameworkImportBindings(
336
+ ast,
337
+ TOPO_IMPORT_SOURCES,
338
+ new Set(['topo'])
339
+ );
340
+ if (!moduleExportsTopo(ast, topoBindings)) {
341
+ return [];
342
+ }
343
+ const surfaceBindings = addFrameworkImportBindings(
344
+ ast,
345
+ SURFACE_IMPORT_SOURCES,
346
+ SURFACE_OPEN_CALLEE_NAMES
347
+ );
348
+
349
+ const diagnostics: WardenDiagnostic[] = [];
350
+ const body = getNodeBodyStatements(ast);
351
+ for (const statement of body) {
352
+ const surfaceOpen = topLevelSurfaceOpen(statement, surfaceBindings);
353
+ if (!surfaceOpen) {
354
+ continue;
355
+ }
356
+ diagnostics.push({
357
+ filePath,
358
+ line: offsetToLine(sourceCode, surfaceOpen.start),
359
+ message: diagnosticMessage,
360
+ rule: RULE_NAME,
361
+ severity: 'warn',
362
+ });
363
+ }
364
+
365
+ return diagnostics;
366
+ },
367
+ description:
368
+ 'Coach topo export modules to keep surface-opening side effects out of module top level.',
369
+ name: RULE_NAME,
370
+ severity: 'warn',
371
+ };