@ontrails/warden 1.0.0-beta.0 → 1.0.0-beta.10

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 (153) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +159 -0
  3. package/README.md +57 -77
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +1 -4
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/rules/ast.d.ts +15 -8
  12. package/dist/rules/ast.d.ts.map +1 -1
  13. package/dist/rules/ast.js +99 -44
  14. package/dist/rules/ast.js.map +1 -1
  15. package/dist/rules/context-no-surface-types.js +1 -1
  16. package/dist/rules/context-no-surface-types.js.map +1 -1
  17. package/dist/rules/follow-declarations.d.ts +13 -0
  18. package/dist/rules/follow-declarations.d.ts.map +1 -0
  19. package/dist/rules/follow-declarations.js +264 -0
  20. package/dist/rules/follow-declarations.js.map +1 -0
  21. package/dist/rules/implementation-returns-result.d.ts +1 -1
  22. package/dist/rules/implementation-returns-result.d.ts.map +1 -1
  23. package/dist/rules/implementation-returns-result.js +52 -6
  24. package/dist/rules/implementation-returns-result.js.map +1 -1
  25. package/dist/rules/index.d.ts +2 -8
  26. package/dist/rules/index.d.ts.map +1 -1
  27. package/dist/rules/index.js +4 -8
  28. package/dist/rules/index.js.map +1 -1
  29. package/dist/rules/no-direct-impl-in-route.d.ts +4 -4
  30. package/dist/rules/no-direct-impl-in-route.d.ts.map +1 -1
  31. package/dist/rules/no-direct-impl-in-route.js +15 -14
  32. package/dist/rules/no-direct-impl-in-route.js.map +1 -1
  33. package/dist/rules/no-direct-implementation-call.d.ts +3 -3
  34. package/dist/rules/no-direct-implementation-call.js +7 -7
  35. package/dist/rules/no-direct-implementation-call.js.map +1 -1
  36. package/dist/rules/no-sync-result-assumption.d.ts +1 -1
  37. package/dist/rules/no-sync-result-assumption.js +5 -5
  38. package/dist/rules/no-sync-result-assumption.js.map +1 -1
  39. package/dist/rules/no-throw-in-detour-target.js +2 -2
  40. package/dist/rules/no-throw-in-detour-target.js.map +1 -1
  41. package/dist/rules/no-throw-in-implementation.d.ts +1 -1
  42. package/dist/rules/no-throw-in-implementation.js +3 -3
  43. package/dist/rules/no-throw-in-implementation.js.map +1 -1
  44. package/dist/rules/specs.d.ts +1 -1
  45. package/dist/rules/specs.d.ts.map +1 -1
  46. package/dist/rules/specs.js +2 -2
  47. package/dist/rules/specs.js.map +1 -1
  48. package/dist/trails/context-no-surface-types.trail.d.ts +13 -0
  49. package/dist/trails/context-no-surface-types.trail.d.ts.map +1 -0
  50. package/dist/trails/context-no-surface-types.trail.js +21 -0
  51. package/dist/trails/context-no-surface-types.trail.js.map +1 -0
  52. package/dist/trails/follow-declarations.trail.d.ts +13 -0
  53. package/dist/trails/follow-declarations.trail.d.ts.map +1 -0
  54. package/dist/trails/follow-declarations.trail.js +22 -0
  55. package/dist/trails/follow-declarations.trail.js.map +1 -0
  56. package/dist/trails/implementation-returns-result.trail.d.ts +13 -0
  57. package/dist/trails/implementation-returns-result.trail.d.ts.map +1 -0
  58. package/dist/trails/implementation-returns-result.trail.js +20 -0
  59. package/dist/trails/implementation-returns-result.trail.js.map +1 -0
  60. package/dist/trails/index.d.ts +14 -0
  61. package/dist/trails/index.d.ts.map +1 -0
  62. package/dist/trails/index.js +13 -0
  63. package/dist/trails/index.js.map +1 -0
  64. package/dist/trails/no-direct-impl-in-route.trail.d.ts +13 -0
  65. package/dist/trails/no-direct-impl-in-route.trail.d.ts.map +1 -0
  66. package/dist/trails/no-direct-impl-in-route.trail.js +22 -0
  67. package/dist/trails/no-direct-impl-in-route.trail.js.map +1 -0
  68. package/dist/trails/no-direct-implementation-call.trail.d.ts +13 -0
  69. package/dist/trails/no-direct-implementation-call.trail.d.ts.map +1 -0
  70. package/dist/trails/no-direct-implementation-call.trail.js +16 -0
  71. package/dist/trails/no-direct-implementation-call.trail.js.map +1 -0
  72. package/dist/trails/no-sync-result-assumption.trail.d.ts +13 -0
  73. package/dist/trails/no-sync-result-assumption.trail.d.ts.map +1 -0
  74. package/dist/trails/no-sync-result-assumption.trail.js +19 -0
  75. package/dist/trails/no-sync-result-assumption.trail.js.map +1 -0
  76. package/dist/trails/no-throw-in-detour-target.trail.d.ts +14 -0
  77. package/dist/trails/no-throw-in-detour-target.trail.d.ts.map +1 -0
  78. package/dist/trails/no-throw-in-detour-target.trail.js +20 -0
  79. package/dist/trails/no-throw-in-detour-target.trail.js.map +1 -0
  80. package/dist/trails/no-throw-in-implementation.trail.d.ts +13 -0
  81. package/dist/trails/no-throw-in-implementation.trail.d.ts.map +1 -0
  82. package/dist/trails/no-throw-in-implementation.trail.js +20 -0
  83. package/dist/trails/no-throw-in-implementation.trail.js.map +1 -0
  84. package/dist/trails/prefer-schema-inference.trail.d.ts +13 -0
  85. package/dist/trails/prefer-schema-inference.trail.d.ts.map +1 -0
  86. package/dist/trails/prefer-schema-inference.trail.js +21 -0
  87. package/dist/trails/prefer-schema-inference.trail.js.map +1 -0
  88. package/dist/trails/run.d.ts +16 -0
  89. package/dist/trails/run.d.ts.map +1 -0
  90. package/dist/trails/run.js +30 -0
  91. package/dist/trails/run.js.map +1 -0
  92. package/dist/trails/schema.d.ts +52 -0
  93. package/dist/trails/schema.d.ts.map +1 -0
  94. package/dist/trails/schema.js +38 -0
  95. package/dist/trails/schema.js.map +1 -0
  96. package/dist/trails/topo.d.ts +3 -0
  97. package/dist/trails/topo.d.ts.map +1 -0
  98. package/dist/trails/topo.js +5 -0
  99. package/dist/trails/topo.js.map +1 -0
  100. package/dist/trails/valid-describe-refs.trail.d.ts +14 -0
  101. package/dist/trails/valid-describe-refs.trail.d.ts.map +1 -0
  102. package/dist/trails/valid-describe-refs.trail.js +18 -0
  103. package/dist/trails/valid-describe-refs.trail.js.map +1 -0
  104. package/dist/trails/valid-detour-refs.trail.d.ts +14 -0
  105. package/dist/trails/valid-detour-refs.trail.d.ts.map +1 -0
  106. package/dist/trails/valid-detour-refs.trail.js +24 -0
  107. package/dist/trails/valid-detour-refs.trail.js.map +1 -0
  108. package/dist/trails/wrap-rule.d.ts +29 -0
  109. package/dist/trails/wrap-rule.d.ts.map +1 -0
  110. package/dist/trails/wrap-rule.js +43 -0
  111. package/dist/trails/wrap-rule.js.map +1 -0
  112. package/package.json +5 -4
  113. package/src/__tests__/cli.test.ts +7 -7
  114. package/src/__tests__/drift.test.ts +1 -1
  115. package/src/__tests__/follow-declarations.test.ts +303 -0
  116. package/src/__tests__/implementation-returns-result.test.ts +60 -6
  117. package/src/__tests__/no-direct-implementation-call.test.ts +8 -8
  118. package/src/__tests__/no-sync-result-assumption.test.ts +6 -6
  119. package/src/__tests__/no-throw-in-detour-target.test.ts +6 -6
  120. package/src/__tests__/prefer-schema-inference.test.ts +4 -4
  121. package/src/__tests__/rules.test.ts +59 -20
  122. package/src/__tests__/trails.test.ts +19 -0
  123. package/src/__tests__/valid-describe-refs.test.ts +4 -4
  124. package/src/cli.ts +1 -4
  125. package/src/index.ts +21 -0
  126. package/src/rules/ast.ts +126 -57
  127. package/src/rules/context-no-surface-types.ts +1 -1
  128. package/src/rules/follow-declarations.ts +380 -0
  129. package/src/rules/implementation-returns-result.ts +63 -6
  130. package/src/rules/index.ts +4 -8
  131. package/src/rules/no-direct-impl-in-route.ts +20 -16
  132. package/src/rules/no-direct-implementation-call.ts +7 -7
  133. package/src/rules/no-sync-result-assumption.ts +5 -5
  134. package/src/rules/no-throw-in-detour-target.ts +2 -2
  135. package/src/rules/no-throw-in-implementation.ts +3 -3
  136. package/src/rules/specs.ts +5 -5
  137. package/src/trails/context-no-surface-types.trail.ts +21 -0
  138. package/src/trails/follow-declarations.trail.ts +22 -0
  139. package/src/trails/implementation-returns-result.trail.ts +20 -0
  140. package/src/trails/index.ts +14 -0
  141. package/src/trails/no-direct-impl-in-route.trail.ts +22 -0
  142. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  143. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  144. package/src/trails/no-throw-in-detour-target.trail.ts +20 -0
  145. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  146. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  147. package/src/trails/run.ts +40 -0
  148. package/src/trails/schema.ts +46 -0
  149. package/src/trails/topo.ts +6 -0
  150. package/src/trails/valid-describe-refs.trail.ts +18 -0
  151. package/src/trails/valid-detour-refs.trail.ts +24 -0
  152. package/src/trails/wrap-rule.ts +84 -0
  153. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Validates that `ctx.follow()` calls match the declared `follow` array.
3
+ *
4
+ * Statically analyzes trail run functions to find `ctx.follow('trailId', ...)`
5
+ * calls and compares them against the `follow: [...]` declaration in the trail
6
+ * config. Reports errors for undeclared follows and warnings for unused ones.
7
+ */
8
+
9
+ import {
10
+ findConfigProperty,
11
+ findRunBodies,
12
+ findTrailDefinitions,
13
+ offsetToLine,
14
+ parse,
15
+ walk,
16
+ } from './ast.js';
17
+ import type { AstNode } from './ast.js';
18
+ import { isTestFile } from './scan.js';
19
+ import type { WardenDiagnostic, WardenRule } from './types.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Shared identifier helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Get the name of an Identifier node, or null. */
26
+ const identifierName = (node: AstNode | undefined): string | null => {
27
+ if (node?.type !== 'Identifier') {
28
+ return null;
29
+ }
30
+ return (node as unknown as { name?: string }).name ?? null;
31
+ };
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // String literal helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Check if a node is a string literal (covers `StringLiteral` and `Literal` with string value). */
38
+ const isStringLiteral = (node: AstNode): boolean => {
39
+ if (node.type === 'StringLiteral') {
40
+ return true;
41
+ }
42
+ if (node.type === 'Literal') {
43
+ return typeof (node as unknown as { value?: unknown }).value === 'string';
44
+ }
45
+ return false;
46
+ };
47
+
48
+ /** Extract the string value from a string literal node. */
49
+ const getStringValue = (node: AstNode): string | null => {
50
+ const val = (node as unknown as { value?: unknown }).value;
51
+ return typeof val === 'string' ? val : null;
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Const identifier resolution
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Best-effort resolution of `const NAME = 'value'` declarations via regex.
60
+ *
61
+ * Returns the string value if a simple `const <name> = '...'` or `"..."` is
62
+ * found in the source. Returns null for anything more complex.
63
+ */
64
+ const resolveConstString = (
65
+ name: string,
66
+ sourceCode: string
67
+ ): string | null => {
68
+ const pattern = new RegExp(
69
+ `const\\s+${name}\\s*=\\s*(?:'([^']*)'|"([^"]*)")`
70
+ );
71
+ const match = pattern.exec(sourceCode);
72
+ if (!match) {
73
+ return null;
74
+ }
75
+ return match[1] ?? match[2] ?? null;
76
+ };
77
+
78
+ /** Try to resolve an Identifier element to a string via const declaration. */
79
+ const resolveIdentifierElement = (
80
+ el: AstNode,
81
+ sourceCode: string
82
+ ): string | null => {
83
+ const name = identifierName(el);
84
+ if (!name) {
85
+ return null;
86
+ }
87
+ return resolveConstString(name, sourceCode);
88
+ };
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Declared follow extraction
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** Extract the ArrayExpression elements from a config's `follow` property. */
95
+ const getFollowElements = (config: AstNode): readonly AstNode[] | null => {
96
+ const followProp = findConfigProperty(config, 'follow');
97
+ if (!followProp) {
98
+ return null;
99
+ }
100
+
101
+ const arrayNode = followProp.value;
102
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
103
+ return null;
104
+ }
105
+
106
+ const elements = (arrayNode as AstNode)['elements'] as
107
+ | readonly AstNode[]
108
+ | undefined;
109
+ return elements ?? null;
110
+ };
111
+
112
+ /** Collect string IDs from array elements, resolving identifiers when possible. */
113
+ const collectStringIds = (
114
+ elements: readonly AstNode[],
115
+ sourceCode: string
116
+ ): Set<string> => {
117
+ const ids = new Set<string>();
118
+ for (const el of elements) {
119
+ if (isStringLiteral(el)) {
120
+ const val = getStringValue(el);
121
+ if (val) {
122
+ ids.add(val);
123
+ }
124
+ } else if (el.type === 'Identifier') {
125
+ const resolved = resolveIdentifierElement(el, sourceCode);
126
+ if (resolved) {
127
+ ids.add(resolved);
128
+ }
129
+ }
130
+ }
131
+ return ids;
132
+ };
133
+
134
+ /** Extract string literal elements from a `follow: [...]` array property. */
135
+ const extractDeclaredFollows = (
136
+ config: AstNode,
137
+ sourceCode: string
138
+ ): ReadonlySet<string> => {
139
+ const elements = getFollowElements(config);
140
+ return elements ? collectStringIds(elements, sourceCode) : new Set();
141
+ };
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Called follow extraction — member expression helpers
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
148
+
149
+ /** Extract object and property Identifier names from a MemberExpression. */
150
+ const extractMemberPair = (
151
+ callee: AstNode
152
+ ): { objName: string; propName: string } | null => {
153
+ if (!MEMBER_TYPES.has(callee.type)) {
154
+ return null;
155
+ }
156
+
157
+ const objName = identifierName(
158
+ (callee as unknown as { object?: AstNode }).object
159
+ );
160
+ const propName = identifierName(
161
+ (callee as unknown as { property?: AstNode }).property
162
+ );
163
+
164
+ return objName && propName ? { objName, propName } : null;
165
+ };
166
+
167
+ /** Extract the first argument string from a CallExpression's arguments list. */
168
+ const extractFirstStringArg = (node: AstNode): string | null => {
169
+ const args = node['arguments'] as readonly AstNode[] | undefined;
170
+ if (!args || args.length === 0) {
171
+ return null;
172
+ }
173
+
174
+ const [firstArg] = args;
175
+ if (!firstArg || !isStringLiteral(firstArg)) {
176
+ // Dynamic ID — cannot resolve statically
177
+ return null;
178
+ }
179
+
180
+ return getStringValue(firstArg);
181
+ };
182
+
183
+ /**
184
+ * Extract the second parameter name from a run function node.
185
+ *
186
+ * Handles `(input, ctx) => ...` and `async (input, context) => ...` and
187
+ * `function(input, ctx) { ... }` forms.
188
+ */
189
+ const extractContextParamName = (runBody: AstNode): string | null => {
190
+ const params = runBody['params'] as readonly AstNode[] | undefined;
191
+ if (!params || params.length < 2) {
192
+ return null;
193
+ }
194
+ return identifierName(params[1]);
195
+ };
196
+
197
+ /** Check if a callee is a member-style follow call: <ctxName>.follow(...). */
198
+ const isMemberFollowCall = (
199
+ callee: AstNode,
200
+ ctxNames: ReadonlySet<string>
201
+ ): boolean => {
202
+ const pair = extractMemberPair(callee);
203
+ return !!pair && ctxNames.has(pair.objName) && pair.propName === 'follow';
204
+ };
205
+
206
+ /**
207
+ * Check if a node is a `<ctxName>.follow(...)` call and return the string trail ID.
208
+ *
209
+ * Also matches bare `follow(...)` calls (destructured pattern).
210
+ */
211
+ const extractFollowCallId = (
212
+ node: AstNode,
213
+ ctxNames: ReadonlySet<string>
214
+ ): string | null => {
215
+ if (node.type !== 'CallExpression') {
216
+ return null;
217
+ }
218
+
219
+ const callee = node['callee'] as AstNode | undefined;
220
+ if (!callee) {
221
+ return null;
222
+ }
223
+
224
+ if (isMemberFollowCall(callee, ctxNames)) {
225
+ return extractFirstStringArg(node);
226
+ }
227
+
228
+ // Match bare follow(...) — destructured pattern
229
+ if (identifierName(callee) === 'follow') {
230
+ return extractFirstStringArg(node);
231
+ }
232
+
233
+ return null;
234
+ };
235
+
236
+ /** Build the set of context parameter names to match against. */
237
+ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
238
+ const ctxNames = new Set(['ctx', 'context']);
239
+ const paramName = extractContextParamName(body);
240
+ if (paramName) {
241
+ ctxNames.add(paramName);
242
+ }
243
+ return ctxNames;
244
+ };
245
+
246
+ /** Walk run bodies and collect all statically resolvable ctx.follow() trail IDs. */
247
+ const extractCalledFollows = (config: AstNode): ReadonlySet<string> => {
248
+ const ids = new Set<string>();
249
+
250
+ for (const body of findRunBodies(config)) {
251
+ const ctxNames = buildCtxNames(body);
252
+
253
+ walk(body, (node) => {
254
+ const id = extractFollowCallId(node, ctxNames);
255
+ if (id) {
256
+ ids.add(id);
257
+ }
258
+ });
259
+ }
260
+
261
+ return ids;
262
+ };
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Diagnostic builders
266
+ // ---------------------------------------------------------------------------
267
+
268
+ const buildUndeclaredDiagnostic = (
269
+ trailId: string,
270
+ followId: string,
271
+ filePath: string,
272
+ line: number
273
+ ): WardenDiagnostic => ({
274
+ filePath,
275
+ line,
276
+ message: `Trail "${trailId}": ctx.follow('${followId}') called but '${followId}' is not declared in follow`,
277
+ rule: 'follow-declarations',
278
+ severity: 'error',
279
+ });
280
+
281
+ const buildUnusedDiagnostic = (
282
+ trailId: string,
283
+ followId: string,
284
+ filePath: string,
285
+ line: number
286
+ ): WardenDiagnostic => ({
287
+ filePath,
288
+ line,
289
+ message: `Trail "${trailId}": '${followId}' declared in follow but ctx.follow('${followId}') never called`,
290
+ rule: 'follow-declarations',
291
+ severity: 'warn',
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Comparison
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /** Emit error for each called ID not present in declared set. */
299
+ const reportUndeclared = (
300
+ called: ReadonlySet<string>,
301
+ declared: ReadonlySet<string>,
302
+ ctx: { trailId: string; filePath: string; line: number },
303
+ diagnostics: WardenDiagnostic[]
304
+ ): void => {
305
+ for (const id of called) {
306
+ if (!declared.has(id)) {
307
+ diagnostics.push(
308
+ buildUndeclaredDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
309
+ );
310
+ }
311
+ }
312
+ };
313
+
314
+ /** Emit warning for each declared ID not present in called set. */
315
+ const reportUnused = (
316
+ declared: ReadonlySet<string>,
317
+ called: ReadonlySet<string>,
318
+ ctx: { trailId: string; filePath: string; line: number },
319
+ diagnostics: WardenDiagnostic[]
320
+ ): void => {
321
+ for (const id of declared) {
322
+ if (!called.has(id)) {
323
+ diagnostics.push(
324
+ buildUnusedDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
325
+ );
326
+ }
327
+ }
328
+ };
329
+
330
+ const checkTrailDefinition = (
331
+ def: { id: string; config: AstNode; start: number },
332
+ filePath: string,
333
+ sourceCode: string,
334
+ diagnostics: WardenDiagnostic[]
335
+ ): void => {
336
+ const declared = extractDeclaredFollows(def.config, sourceCode);
337
+ const called = extractCalledFollows(def.config);
338
+
339
+ if (declared.size === 0 && called.size === 0) {
340
+ return;
341
+ }
342
+
343
+ const line = offsetToLine(sourceCode, def.start);
344
+ const ctx = { filePath, line, trailId: def.id };
345
+
346
+ reportUndeclared(called, declared, ctx, diagnostics);
347
+ reportUnused(declared, called, ctx, diagnostics);
348
+ };
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Rule
352
+ // ---------------------------------------------------------------------------
353
+
354
+ /**
355
+ * Validates that `ctx.follow()` calls align with declared `follow` arrays.
356
+ */
357
+ export const followDeclarations: WardenRule = {
358
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
359
+ if (isTestFile(filePath)) {
360
+ return [];
361
+ }
362
+
363
+ const ast = parse(filePath, sourceCode);
364
+ if (!ast) {
365
+ return [];
366
+ }
367
+
368
+ const diagnostics: WardenDiagnostic[] = [];
369
+
370
+ for (const def of findTrailDefinitions(ast)) {
371
+ checkTrailDefinition(def, filePath, sourceCode, diagnostics);
372
+ }
373
+
374
+ return diagnostics;
375
+ },
376
+ description:
377
+ 'Ensure ctx.follow() calls match the declared follow array in trail definitions.',
378
+ name: 'follow-declarations',
379
+ severity: 'error',
380
+ };
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Finds implementations that return raw values instead of `Result`.
3
3
  *
4
- * Uses AST parsing to find `implementation:` bodies and check that
4
+ * Uses AST parsing to find `run:` bodies and check that
5
5
  * every return statement returns Result.ok(), Result.err(), ctx.follow(),
6
6
  * or a tracked Result-typed variable.
7
7
  */
8
8
 
9
9
  import {
10
- findImplementationBodies,
10
+ findRunBodies,
11
11
  findTrailDefinitions,
12
12
  offsetToLine,
13
13
  parse,
@@ -63,7 +63,7 @@ const isResultMemberCall = (callee: AstNode): boolean => {
63
63
  if (objName === 'ctx' && propName === 'follow') {
64
64
  return true;
65
65
  }
66
- return propName === 'implementation';
66
+ return propName === 'run';
67
67
  };
68
68
 
69
69
  // ---------------------------------------------------------------------------
@@ -158,6 +158,63 @@ const trackResultVariable = (node: AstNode, resultVars: Set<string>): void => {
158
158
  }
159
159
  };
160
160
 
161
+ // ---------------------------------------------------------------------------
162
+ // Shallow walk (stops at nested function boundaries)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ const FUNCTION_BOUNDARY_TYPES = new Set([
166
+ 'ArrowFunctionExpression',
167
+ 'FunctionExpression',
168
+ 'FunctionDeclaration',
169
+ ]);
170
+
171
+ /** Check if a value is a function-boundary AST node that should not be recursed into. */
172
+ const isFunctionBoundary = (val: unknown): boolean =>
173
+ !!val &&
174
+ typeof val === 'object' &&
175
+ FUNCTION_BOUNDARY_TYPES.has((val as AstNode).type);
176
+
177
+ /** Recurse into a single AST property value, skipping function boundaries. */
178
+ const visitValue = (
179
+ val: unknown,
180
+ visit: (node: AstNode) => void,
181
+ recurse: (node: unknown, visit: (node: AstNode) => void) => void
182
+ ): void => {
183
+ if (Array.isArray(val)) {
184
+ for (const item of val) {
185
+ if (!isFunctionBoundary(item)) {
186
+ recurse(item, visit);
187
+ }
188
+ }
189
+ } else if (
190
+ val &&
191
+ typeof val === 'object' &&
192
+ (val as AstNode).type &&
193
+ !isFunctionBoundary(val)
194
+ ) {
195
+ recurse(val, visit);
196
+ }
197
+ };
198
+
199
+ /**
200
+ * Walk an AST node tree without recursing into nested function bodies.
201
+ *
202
+ * This ensures that return statements inside `.map()`, `.filter()`, `.then()`
203
+ * callbacks etc. are not mistakenly checked as implementation-level returns.
204
+ */
205
+ const walkShallow = (node: unknown, visit: (node: AstNode) => void): void => {
206
+ if (!node || typeof node !== 'object') {
207
+ return;
208
+ }
209
+ const n = node as AstNode;
210
+ if (n.type) {
211
+ visit(n);
212
+ }
213
+ for (const val of Object.values(n)) {
214
+ visitValue(val, visit, walkShallow);
215
+ }
216
+ };
217
+
161
218
  // ---------------------------------------------------------------------------
162
219
  // Return statement checking
163
220
  // ---------------------------------------------------------------------------
@@ -173,7 +230,7 @@ const checkReturnStatements = (
173
230
  ): void => {
174
231
  const resultVars = new Set<string>();
175
232
 
176
- walk(blockBody, (node) => {
233
+ walkShallow(blockBody, (node) => {
177
234
  if (node.type === 'VariableDeclarator') {
178
235
  trackResultVariable(node, resultVars);
179
236
  }
@@ -304,8 +361,8 @@ const checkAllDefinitions = (
304
361
  const helperNames = collectResultHelperNames(ast, sourceCode);
305
362
 
306
363
  for (const def of findTrailDefinitions(ast)) {
307
- const info = { id: def.id, label: def.kind === 'hike' ? 'Hike' : 'Trail' };
308
- for (const implValue of findImplementationBodies(def.config as AstNode)) {
364
+ const info = { id: def.id, label: 'Trail' };
365
+ for (const implValue of findRunBodies(def.config as AstNode)) {
309
366
  checkImplementation(
310
367
  implValue,
311
368
  info,
@@ -1,4 +1,5 @@
1
1
  import { contextNoSurfaceTypes } from './context-no-surface-types.js';
2
+ import { followDeclarations } from './follow-declarations.js';
2
3
  import { implementationReturnsResult } from './implementation-returns-result.js';
3
4
  import { noDirectImplInRoute } from './no-direct-impl-in-route.js';
4
5
  import { noDirectImplementationCall } from './no-direct-implementation-call.js';
@@ -20,6 +21,7 @@ export type {
20
21
 
21
22
  export { noThrowInImplementation } from './no-throw-in-implementation.js';
22
23
  export { contextNoSurfaceTypes } from './context-no-surface-types.js';
24
+ export { followDeclarations } from './follow-declarations.js';
23
25
  export { validDetourRefs } from './valid-detour-refs.js';
24
26
  export { noDirectImplInRoute } from './no-direct-impl-in-route.js';
25
27
  export { noDirectImplementationCall } from './no-direct-implementation-call.js';
@@ -29,20 +31,14 @@ export { noThrowInDetourTarget } from './no-throw-in-detour-target.js';
29
31
  export { preferSchemaInference } from './prefer-schema-inference.js';
30
32
  export { validDescribeRefs } from './valid-describe-refs.js';
31
33
 
32
- /**
33
- * All built-in warden rules, keyed by rule name.
34
- *
35
- * Rules that duplicate validateTopo checks (follows-trails-exist,
36
- * no-recursive-follows, event-origins-exist, examples-match-schema,
37
- * require-output-schema) and follows-matches-calls (now covered by
38
- * testExamples follows coverage) have been removed.
39
- */
34
+ /** All built-in warden rules, keyed by rule name. */
40
35
  export const wardenRules: ReadonlyMap<string, WardenRule> = new Map<
41
36
  string,
42
37
  WardenRule
43
38
  >([
44
39
  [noThrowInImplementation.name, noThrowInImplementation],
45
40
  [contextNoSurfaceTypes.name, contextNoSurfaceTypes],
41
+ [followDeclarations.name, followDeclarations],
46
42
  [preferSchemaInference.name, preferSchemaInference],
47
43
  [validDescribeRefs.name, validDescribeRefs],
48
44
  [validDetourRefs.name, validDetourRefs],
@@ -1,14 +1,15 @@
1
1
  /**
2
- * Detects hike implementations that call `.implementation()` directly.
2
+ * Detects trail implementations with `follow` that call `.run()` directly.
3
3
  *
4
- * Uses AST parsing to find hike definition bodies and check for
5
- * `.implementation()` call expressions.
4
+ * Uses AST parsing to find trail definitions that declare `follow` and check for
5
+ * `.run()` call expressions in their bodies.
6
6
  */
7
7
 
8
8
  import {
9
- findImplementationBodies,
9
+ findConfigProperty,
10
+ findRunBodies,
10
11
  findTrailDefinitions,
11
- isImplementationCall,
12
+ isRunCall,
12
13
  offsetToLine,
13
14
  parse,
14
15
  walk,
@@ -22,20 +23,20 @@ interface AstNode {
22
23
  readonly [key: string]: unknown;
23
24
  }
24
25
 
25
- const findImplCallsInHike = (
26
+ const findImplCallsInTrailWithFollow = (
26
27
  def: { readonly config: AstNode },
27
28
  filePath: string,
28
29
  sourceCode: string,
29
30
  diagnostics: WardenDiagnostic[]
30
31
  ): void => {
31
- for (const body of findImplementationBodies(def.config as AstNode)) {
32
+ for (const body of findRunBodies(def.config as AstNode)) {
32
33
  walk(body, (node) => {
33
- if (isImplementationCall(node as AstNode)) {
34
+ if (isRunCall(node as AstNode)) {
34
35
  diagnostics.push({
35
36
  filePath,
36
37
  line: offsetToLine(sourceCode, node.start),
37
38
  message:
38
- 'Use ctx.follow("trailId", input) instead of direct .implementation() calls. ctx.follow() validates input and propagates tracing.',
39
+ 'Use ctx.follow("trailId", input) instead of direct .run() calls. ctx.follow() validates input and propagates tracing.',
39
40
  rule: 'no-direct-impl-in-route',
40
41
  severity: 'warn',
41
42
  });
@@ -44,12 +45,15 @@ const findImplCallsInHike = (
44
45
  }
45
46
  };
46
47
 
48
+ const hasFollowProperty = (config: AstNode): boolean =>
49
+ findConfigProperty(config as AstNode, 'follow') !== null;
50
+
47
51
  /**
48
- * Detects routes that call another trail's `.implementation()` directly.
52
+ * Detects trails with `follow` that call another trail's `.run()` directly.
49
53
  */
50
54
  export const noDirectImplInRoute: WardenRule = {
51
55
  check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
52
- if (!/\bhike\s*\(/.test(sourceCode)) {
56
+ if (!/\btrail\s*\(/.test(sourceCode)) {
53
57
  return [];
54
58
  }
55
59
 
@@ -59,18 +63,18 @@ export const noDirectImplInRoute: WardenRule = {
59
63
  }
60
64
 
61
65
  const diagnostics: WardenDiagnostic[] = [];
62
- const hikeDefs = findTrailDefinitions(ast as AstNode).filter(
63
- (d) => d.kind === 'hike'
66
+ const followDefs = findTrailDefinitions(ast as AstNode).filter((d) =>
67
+ hasFollowProperty(d.config as AstNode)
64
68
  );
65
69
 
66
- for (const def of hikeDefs) {
67
- findImplCallsInHike(def, filePath, sourceCode, diagnostics);
70
+ for (const def of followDefs) {
71
+ findImplCallsInTrailWithFollow(def, filePath, sourceCode, diagnostics);
68
72
  }
69
73
 
70
74
  return diagnostics;
71
75
  },
72
76
  description:
73
- 'Prefer ctx.follow() over direct .implementation() calls in route bodies.',
77
+ 'Prefer ctx.follow() over direct .run() calls in trail bodies with follow.',
74
78
  name: 'no-direct-impl-in-route',
75
79
 
76
80
  severity: 'warn',
@@ -1,16 +1,16 @@
1
1
  /**
2
- * Flags direct `.implementation()` calls in application code.
2
+ * Flags direct `.run()` calls in application code.
3
3
  *
4
- * Uses AST parsing to find `.implementation()` call expressions,
4
+ * Uses AST parsing to find `.run()` call expressions,
5
5
  * ignoring occurrences in strings and comments.
6
6
  */
7
7
 
8
- import { isImplementationCall, offsetToLine, parse, walk } from './ast.js';
8
+ import { isRunCall, offsetToLine, parse, walk } from './ast.js';
9
9
  import { isFrameworkInternalFile, isTestFile } from './scan.js';
10
10
  import type { WardenDiagnostic, WardenRule } from './types.js';
11
11
 
12
12
  /**
13
- * Flags direct `.implementation()` calls in application code.
13
+ * Flags direct `.run()` calls in application code.
14
14
  */
15
15
  export const noDirectImplementationCall: WardenRule = {
16
16
  check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
@@ -26,12 +26,12 @@ export const noDirectImplementationCall: WardenRule = {
26
26
  const diagnostics: WardenDiagnostic[] = [];
27
27
 
28
28
  walk(ast, (node) => {
29
- if (isImplementationCall(node)) {
29
+ if (isRunCall(node)) {
30
30
  diagnostics.push({
31
31
  filePath,
32
32
  line: offsetToLine(sourceCode, node.start),
33
33
  message:
34
- 'Use ctx.follow("trailId", input) instead of direct .implementation() calls. Direct implementation access bypasses validation, tracing, and layers.',
34
+ 'Use ctx.follow("trailId", input) instead of direct .run() calls. Direct implementation access bypasses validation, tracing, and layers.',
35
35
  rule: 'no-direct-implementation-call',
36
36
  severity: 'warn',
37
37
  });
@@ -41,7 +41,7 @@ export const noDirectImplementationCall: WardenRule = {
41
41
  return diagnostics;
42
42
  },
43
43
  description:
44
- 'Disallow direct .implementation() calls in application code. Use ctx.follow() instead.',
44
+ 'Disallow direct .run() calls in application code. Use ctx.follow() instead.',
45
45
  name: 'no-direct-implementation-call',
46
46
  severity: 'warn',
47
47
  };
@@ -7,10 +7,10 @@ import {
7
7
 
8
8
  const RESULT_ACCESS_PATTERN =
9
9
  /\.(?:isOk|isErr|match|map)\s*\(|\.(?:value|error)\b/;
10
- const IMPLEMENTATION_CALL_PATTERN = /\.implementation\s*\(/;
10
+ const IMPLEMENTATION_CALL_PATTERN = /\.run\s*\(/;
11
11
 
12
12
  const isAwaitedImplementationCall = (line: string): boolean => {
13
- const callIndex = line.indexOf('.implementation(');
13
+ const callIndex = line.indexOf('.run(');
14
14
  if (callIndex === -1) {
15
15
  return false;
16
16
  }
@@ -39,7 +39,7 @@ interface PendingCall {
39
39
  }
40
40
 
41
41
  const MISSING_AWAIT_MESSAGE =
42
- 'Missing await: .implementation() returns Promise<Result> after normalization. Use `const result = await trail.implementation(input, ctx)`.';
42
+ 'Missing await: .run() returns Promise<Result> after normalization. Use `const result = await trail.run(input, ctx)`.';
43
43
 
44
44
  const createMissingAwaitDiagnostic = (
45
45
  filePath: string,
@@ -140,7 +140,7 @@ const scanSourceCode = (
140
140
  };
141
141
 
142
142
  /**
143
- * Flags code that assumes `.implementation()` returns a synchronous result.
143
+ * Flags code that assumes `.run()` returns a synchronous result.
144
144
  */
145
145
  export const noSyncResultAssumption: WardenRule = {
146
146
  check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
@@ -150,7 +150,7 @@ export const noSyncResultAssumption: WardenRule = {
150
150
  return scanSourceCode(stripQuotedContent(sourceCode), filePath);
151
151
  },
152
152
  description:
153
- 'Disallow treating .implementation() as synchronous after normalization. Always await the returned Promise<Result>.',
153
+ 'Disallow treating .run() as synchronous after normalization. Always await the returned Promise<Result>.',
154
154
  name: 'no-sync-result-assumption',
155
155
  severity: 'error',
156
156
  };