@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
@@ -9,11 +9,11 @@ describe('no-throw-in-detour-target', () => {
9
9
  const code = `
10
10
  trail("entity.show", {
11
11
  detours: { NotFoundError: ["entity.fallback"] },
12
- implementation: async (input, ctx) => Result.ok({ id: "123" })
12
+ run: async (input, ctx) => Result.ok({ id: "123" })
13
13
  })
14
14
 
15
15
  trail("entity.fallback", {
16
- implementation: async (input, ctx) => {
16
+ run: async (input, ctx) => {
17
17
  throw new Error("boom");
18
18
  }
19
19
  })`;
@@ -28,7 +28,7 @@ trail("entity.fallback", {
28
28
  test('allows throw in implementations that are not detour targets', () => {
29
29
  const code = `
30
30
  trail("entity.show", {
31
- implementation: async (input, ctx) => {
31
+ run: async (input, ctx) => {
32
32
  throw new Error("boom");
33
33
  }
34
34
  })`;
@@ -42,11 +42,11 @@ trail("entity.show", {
42
42
  const code = `
43
43
  trail("entity.show", {
44
44
  detours: { NotFoundError: ["entity.fallback"] },
45
- implementation: async (input, ctx) => Result.ok({ id: "123" })
45
+ run: async (input, ctx) => Result.ok({ id: "123" })
46
46
  })
47
47
 
48
48
  trail("entity.fallback", {
49
- implementation: async (input, ctx) => { throw new Error("boom"); }
49
+ run: async (input, ctx) => { throw new Error("boom"); }
50
50
  })`;
51
51
 
52
52
  const diagnostics = noThrowInDetourTarget.check(code, TEST_FILE);
@@ -58,7 +58,7 @@ trail("entity.fallback", {
58
58
  test('uses project context when the detour target is defined in another file', () => {
59
59
  const code = `
60
60
  trail("entity.fallback", {
61
- implementation: async (input, ctx) => {
61
+ run: async (input, ctx) => {
62
62
  throw new Error("boom");
63
63
  }
64
64
  })`;
@@ -10,7 +10,7 @@ trail("entity.show", {
10
10
  fields: {
11
11
  firstName: { label: "First Name" },
12
12
  },
13
- implementation: (input) => Result.ok(input),
13
+ run: (input) => Result.ok(input),
14
14
  })`;
15
15
 
16
16
  const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
@@ -31,7 +31,7 @@ trail("entity.paint", {
31
31
  options: [{ value: "red" }, { value: "green" }],
32
32
  },
33
33
  },
34
- implementation: (input) => Result.ok(input),
34
+ run: (input) => Result.ok(input),
35
35
  })`;
36
36
 
37
37
  const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
@@ -56,7 +56,7 @@ trail("entity.paint", {
56
56
  },
57
57
  displayName: { label: "Public name" },
58
58
  },
59
- implementation: (input) => Result.ok(input),
59
+ run: (input) => Result.ok(input),
60
60
  })`;
61
61
 
62
62
  const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
@@ -74,7 +74,7 @@ trail("entity.show", {
74
74
  message: "Who should we greet?",
75
75
  },
76
76
  },
77
- implementation: (input) => Result.ok(input),
77
+ run: (input) => Result.ok(input),
78
78
  })`;
79
79
 
80
80
  const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
@@ -14,7 +14,7 @@ describe('no-throw-in-implementation', () => {
14
14
  test('flags throw inside implementation body', () => {
15
15
  const code = `
16
16
  trail("entity.show", {
17
- implementation: async (input, ctx) => {
17
+ run: async (input, ctx) => {
18
18
  throw new Error("boom");
19
19
  }
20
20
  })`;
@@ -27,7 +27,7 @@ trail("entity.show", {
27
27
  test('allows Result.err() in implementation', () => {
28
28
  const code = `
29
29
  trail("entity.show", {
30
- implementation: async (input, ctx) => {
30
+ run: async (input, ctx) => {
31
31
  return Result.err(new NotFoundError("not found"));
32
32
  }
33
33
  })`;
@@ -42,7 +42,7 @@ function helper() {
42
42
  }
43
43
 
44
44
  trail("entity.show", {
45
- implementation: async (input, ctx) => {
45
+ run: async (input, ctx) => {
46
46
  return Result.ok(data);
47
47
  }
48
48
  })`;
@@ -59,7 +59,7 @@ describe('context-no-surface-types', () => {
59
59
  const code = `
60
60
  import { Request, Response } from "express";
61
61
  trail("entity.show", {
62
- implementation: async (input, ctx) => {
62
+ run: async (input, ctx) => {
63
63
  return Result.ok(data);
64
64
  }
65
65
  })`;
@@ -73,7 +73,7 @@ trail("entity.show", {
73
73
  const code = `
74
74
  import type { McpSession } from "@modelcontextprotocol/sdk";
75
75
  trail("entity.show", {
76
- implementation: async (input, ctx) => {
76
+ run: async (input, ctx) => {
77
77
  return Result.ok(data);
78
78
  }
79
79
  })`;
@@ -85,7 +85,7 @@ trail("entity.show", {
85
85
  const code = `
86
86
  import { trail, Result } from "@ontrails/core";
87
87
  trail("entity.show", {
88
- implementation: async (input, ctx) => {
88
+ run: async (input, ctx) => {
89
89
  return Result.ok(data);
90
90
  }
91
91
  })`;
@@ -110,7 +110,7 @@ describe('valid-detour-refs', () => {
110
110
  const code = `
111
111
  trail("entity.show", {
112
112
  detours: [{ target: "entity.edit" }],
113
- implementation: async (input, ctx) => Result.ok(data)
113
+ run: async (input, ctx) => Result.ok(data)
114
114
  })`;
115
115
  const diagnostics = validDetourRefs.check(code, TEST_FILE);
116
116
  expect(diagnostics.length).toBe(1);
@@ -120,12 +120,12 @@ trail("entity.show", {
120
120
  test('passes when detour target exists', () => {
121
121
  const code = `
122
122
  trail("entity.edit", {
123
- implementation: async (input, ctx) => Result.ok(data)
123
+ run: async (input, ctx) => Result.ok(data)
124
124
  })
125
125
 
126
126
  trail("entity.show", {
127
127
  detours: [{ target: "entity.edit" }],
128
- implementation: async (input, ctx) => Result.ok(data)
128
+ run: async (input, ctx) => Result.ok(data)
129
129
  })`;
130
130
  const diagnostics = validDetourRefs.check(code, TEST_FILE);
131
131
  expect(diagnostics.length).toBe(0);
@@ -135,7 +135,7 @@ trail("entity.show", {
135
135
  const code = `
136
136
  trail("entity.show", {
137
137
  detours: [{ target: "entity.edit" }],
138
- implementation: async (input, ctx) => Result.ok(data)
138
+ run: async (input, ctx) => Result.ok(data)
139
139
  })`;
140
140
  const context = { knownTrailIds: new Set(['entity.show', 'entity.edit']) };
141
141
  const diagnostics = validDetourRefs.checkWithContext(
@@ -145,18 +145,45 @@ trail("entity.show", {
145
145
  );
146
146
  expect(diagnostics.length).toBe(0);
147
147
  });
148
+
149
+ test('flags detour target in trail with follow that does not exist', () => {
150
+ const code = `
151
+ trail("entity.onboard", {
152
+ detours: [{ target: "entity.missing" }],
153
+ follow: ["entity.create"],
154
+ run: async (input, ctx) => Result.ok(data)
155
+ })`;
156
+ const diagnostics = validDetourRefs.check(code, TEST_FILE);
157
+ expect(diagnostics.length).toBe(1);
158
+ expect(diagnostics[0]?.message).toContain('entity.missing');
159
+ });
160
+
161
+ test('passes when trail with follow detour target exists', () => {
162
+ const code = `
163
+ trail("entity.fallback", {
164
+ run: async (input, ctx) => Result.ok(data)
165
+ })
166
+
167
+ trail("entity.onboard", {
168
+ detours: [{ target: "entity.fallback" }],
169
+ follow: ["entity.create"],
170
+ run: async (input, ctx) => Result.ok(data)
171
+ })`;
172
+ const diagnostics = validDetourRefs.check(code, TEST_FILE);
173
+ expect(diagnostics.length).toBe(0);
174
+ });
148
175
  });
149
176
 
150
177
  // ---------------------------------------------------------------------------
151
178
  // no-direct-impl-in-route
152
179
  // ---------------------------------------------------------------------------
153
180
  describe('no-direct-impl-in-route', () => {
154
- test('warns on direct .implementation() call in route', () => {
181
+ test('warns on direct .run() call in trail with follow', () => {
155
182
  const code = `
156
- hike("entity.onboard", {
157
- follows: ["entity.create"],
158
- implementation: async (input, ctx) => {
159
- const result = await entityCreate.implementation(data);
183
+ trail("entity.onboard", {
184
+ follow: ["entity.create"],
185
+ run: async (input, ctx) => {
186
+ const result = await entityCreate.run(data);
160
187
  return Result.ok(result);
161
188
  }
162
189
  })`;
@@ -168,9 +195,9 @@ hike("entity.onboard", {
168
195
 
169
196
  test('allows ctx.follow() calls', () => {
170
197
  const code = `
171
- hike("entity.onboard", {
172
- follows: ["entity.create"],
173
- implementation: async (input, ctx) => {
198
+ trail("entity.onboard", {
199
+ follow: ["entity.create"],
200
+ run: async (input, ctx) => {
174
201
  const result = await ctx.follow("entity.create", data);
175
202
  return Result.ok(result);
176
203
  }
@@ -179,9 +206,21 @@ hike("entity.onboard", {
179
206
  expect(diagnostics.length).toBe(0);
180
207
  });
181
208
 
182
- test('ignores files without hike() calls', () => {
209
+ test('ignores trails without follow', () => {
210
+ const code = `
211
+ trail("entity.show", {
212
+ run: async (input, ctx) => {
213
+ const result = await someTrail.run(data);
214
+ return Result.ok(result);
215
+ }
216
+ })`;
217
+ const diagnostics = noDirectImplInRoute.check(code, TEST_FILE);
218
+ expect(diagnostics.length).toBe(0);
219
+ });
220
+
221
+ test('ignores files without trail() calls', () => {
183
222
  const code = `
184
- const result = await someTrail.implementation(data);`;
223
+ const result = await someTrail.run(data);`;
185
224
  const diagnostics = noDirectImplInRoute.check(code, TEST_FILE);
186
225
  expect(diagnostics.length).toBe(0);
187
226
  });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { testAll } from '@ontrails/testing';
3
+
4
+ import { wardenTopo } from '../trails/topo.js';
5
+
6
+ // oxlint-disable-next-line jest/require-hook -- testAll generates describe/test blocks, not setup code
7
+ testAll(wardenTopo);
8
+
9
+ describe('wardenTopo', () => {
10
+ test('contains all 11 rule trails', () => {
11
+ expect(wardenTopo.count).toBe(11);
12
+ });
13
+
14
+ test('all trail IDs follow warden.rule.* naming', () => {
15
+ for (const id of wardenTopo.ids()) {
16
+ expect(id).toMatch(/^warden\.rule\./);
17
+ }
18
+ });
19
+ });
@@ -9,7 +9,7 @@ trail("entity.show", {
9
9
  input: z.object({
10
10
  query: z.string().describe("Search query. @see entity.search"),
11
11
  }),
12
- implementation: (input) => Result.ok(input),
12
+ run: (input) => Result.ok(input),
13
13
  })`;
14
14
 
15
15
  const diagnostics = validDescribeRefs.check(code, 'src/entity.ts');
@@ -23,14 +23,14 @@ trail("entity.show", {
23
23
  const code = `
24
24
  trail("entity.search", {
25
25
  input: z.object({ query: z.string() }),
26
- implementation: (input) => Result.ok(input),
26
+ run: (input) => Result.ok(input),
27
27
  })
28
28
 
29
29
  trail("entity.show", {
30
30
  input: z.object({
31
31
  query: z.string().describe("Search query. @see entity.search"),
32
32
  }),
33
- implementation: (input) => Result.ok(input),
33
+ run: (input) => Result.ok(input),
34
34
  })`;
35
35
 
36
36
  const diagnostics = validDescribeRefs.check(code, 'src/entity.ts');
@@ -44,7 +44,7 @@ trail("entity.show", {
44
44
  input: z.object({
45
45
  query: z.string().describe("Search query. @see entity.search"),
46
46
  }),
47
- implementation: (input) => Result.ok(input),
47
+ run: (input) => Result.ok(input),
48
48
  })`;
49
49
 
50
50
  const diagnostics = validDescribeRefs.checkWithContext(
package/src/cli.ts CHANGED
@@ -152,10 +152,7 @@ const loadSourceFiles = async (
152
152
  };
153
153
 
154
154
  const buildProjectContextFromTopo = (appTopo: Topo): ProjectContext => {
155
- const knownTrailIds = new Set<string>([
156
- ...appTopo.trails.keys(),
157
- ...appTopo.hikes.keys(),
158
- ]);
155
+ const knownTrailIds = new Set<string>(appTopo.trails.keys());
159
156
 
160
157
  const detourTargetTrailIds = new Set<string>();
161
158
  for (const t of appTopo.trails.values()) {
package/src/index.ts CHANGED
@@ -45,3 +45,24 @@ export {
45
45
  // Drift detection
46
46
  export type { DriftResult } from './drift.js';
47
47
  export { checkDrift } from './drift.js';
48
+
49
+ // Trail layer
50
+ export { wardenTopo } from './trails/topo.js';
51
+ export { runWardenTrails } from './trails/run.js';
52
+ export {
53
+ contextNoSurfaceTypesTrail,
54
+ diagnosticSchema,
55
+ followDeclarationsTrail,
56
+ implementationReturnsResultTrail,
57
+ noDirectImplInRouteTrail,
58
+ noDirectImplementationCallTrail,
59
+ noSyncResultAssumptionTrail,
60
+ noThrowInDetourTargetTrail,
61
+ noThrowInImplementationTrail,
62
+ preferSchemaInferenceTrail,
63
+ ruleInput,
64
+ ruleOutput,
65
+ validDescribeRefsTrail,
66
+ validDetourRefsTrail,
67
+ } from './trails/index.js';
68
+ export type { RuleInput, RuleOutput } from './trails/index.js';
package/src/rules/ast.ts CHANGED
@@ -74,38 +74,52 @@ export const offsetToLine = (sourceCode: string, offset: number): number => {
74
74
  return line;
75
75
  };
76
76
 
77
- /** Find all `implementation:` property values in an AST. */
78
- export const findImplementationBodies = (ast: AstNode): AstNode[] => {
79
- const bodies: AstNode[] = [];
80
- walk(ast, (node) => {
81
- if (
82
- node.type === 'Property' &&
83
- node.key?.name === 'implementation' &&
84
- node.value
85
- ) {
86
- bodies.push(node.value);
77
+ // ---------------------------------------------------------------------------
78
+ // Config property extraction helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** Find a Property node by key name inside an ObjectExpression config. */
82
+ export const findConfigProperty = (
83
+ config: AstNode,
84
+ propertyName: string
85
+ ): AstNode | null => {
86
+ if (config.type !== 'ObjectExpression') {
87
+ return null;
88
+ }
89
+ const properties = config['properties'] as readonly AstNode[] | undefined;
90
+ if (!properties) {
91
+ return null;
92
+ }
93
+ for (const prop of properties) {
94
+ if (prop.type === 'Property' && prop.key?.name === propertyName) {
95
+ return prop;
87
96
  }
88
- });
89
- return bodies;
97
+ }
98
+ return null;
90
99
  };
91
100
 
101
+ // ---------------------------------------------------------------------------
102
+ // Trail definition extraction
103
+ // ---------------------------------------------------------------------------
104
+
92
105
  export interface TrailDefinition {
93
106
  /** Trail ID string, e.g. "entity.show" */
94
107
  readonly id: string;
95
- /** "trail" or "hike" */
108
+ /** "trail" or "event" */
96
109
  readonly kind: string;
97
- /** The config object argument (second arg to trail/hike call) */
110
+ /** The config object argument (second arg to trail() call) */
98
111
  readonly config: AstNode;
99
112
  /** Start offset of the call expression */
100
113
  readonly start: number;
101
114
  }
102
115
 
103
116
  /**
104
- * Find all `trail("id", { ... })` and `hike("id", { ... })` call sites.
117
+ * Find all `trail("id", { ... })`, `trail({ id: "x", ... })`, and
118
+ * `event("id", { ... })` call sites.
105
119
  *
106
120
  * Returns the trail ID, kind, and config object node for each definition.
107
121
  */
108
- const TRAIL_CALLEE_NAMES = new Set(['trail', 'hike']);
122
+ const TRAIL_CALLEE_NAMES = new Set(['trail', 'event']);
109
123
 
110
124
  const getTrailCalleeName = (node: AstNode): string | null => {
111
125
  if (node.type !== 'CallExpression') {
@@ -119,18 +133,49 @@ const getTrailCalleeName = (node: AstNode): string | null => {
119
133
  return name && TRAIL_CALLEE_NAMES.has(name) ? name : null;
120
134
  };
121
135
 
136
+ /** Extract args from a trail() call, handling both two-arg and single-object forms. */
122
137
  const extractTrailArgs = (
123
138
  node: AstNode
124
- ): { idArg: AstNode; configArg: AstNode } | null => {
139
+ ): { idArg: AstNode | null; configArg: AstNode } | null => {
125
140
  const args = node['arguments'] as readonly AstNode[] | undefined;
126
- if (!args || args.length < 2) {
141
+ if (!args || args.length === 0) {
142
+ return null;
143
+ }
144
+
145
+ const [firstArg, secondArg] = args;
146
+ if (!firstArg) {
127
147
  return null;
128
148
  }
129
- const [idArg, configArg] = args;
130
- if (!idArg || !configArg) {
149
+
150
+ // Two-arg form: trail('id', { ... })
151
+ if (secondArg && firstArg.type !== 'ObjectExpression') {
152
+ return { configArg: secondArg, idArg: firstArg };
153
+ }
154
+
155
+ // Single-object form: trail({ id: 'x', ... })
156
+ return firstArg.type === 'ObjectExpression'
157
+ ? { configArg: firstArg, idArg: null }
158
+ : null;
159
+ };
160
+
161
+ /** Extract the string value from an `id` property inside a config ObjectExpression. */
162
+ const extractIdFromConfig = (config: AstNode): string | null => {
163
+ const idProp = findConfigProperty(config, 'id');
164
+ if (!idProp || !idProp.value) {
131
165
  return null;
132
166
  }
133
- return { configArg, idArg };
167
+ const val = (idProp.value as unknown as { value?: unknown }).value;
168
+ return typeof val === 'string' ? val : null;
169
+ };
170
+
171
+ const extractTrailId = (trailArgs: {
172
+ idArg: AstNode | null;
173
+ configArg: AstNode;
174
+ }): string | null => {
175
+ if (trailArgs.idArg) {
176
+ return (trailArgs.idArg as unknown as { value?: string }).value ?? null;
177
+ }
178
+ return extractIdFromConfig(trailArgs.configArg);
134
179
  };
135
180
 
136
181
  const extractTrailDefinition = (node: AstNode): TrailDefinition | null => {
@@ -144,7 +189,7 @@ const extractTrailDefinition = (node: AstNode): TrailDefinition | null => {
144
189
  return null;
145
190
  }
146
191
 
147
- const trailId = (trailArgs.idArg as unknown as { value?: string }).value;
192
+ const trailId = extractTrailId(trailArgs);
148
193
  if (!trailId) {
149
194
  return null;
150
195
  }
@@ -157,28 +202,6 @@ const extractTrailDefinition = (node: AstNode): TrailDefinition | null => {
157
202
  };
158
203
  };
159
204
 
160
- /** Check if a node is a call to `.implementation()` on some object. */
161
- export const isImplementationCall = (node: AstNode): boolean => {
162
- if (node.type !== 'CallExpression') {
163
- return false;
164
- }
165
- const callee = node['callee'] as AstNode | undefined;
166
- if (!callee) {
167
- return false;
168
- }
169
- if (
170
- callee.type !== 'StaticMemberExpression' &&
171
- callee.type !== 'MemberExpression'
172
- ) {
173
- return false;
174
- }
175
- const prop = (callee as unknown as { property?: AstNode }).property;
176
- return (
177
- prop?.type === 'Identifier' &&
178
- (prop as unknown as { name: string }).name === 'implementation'
179
- );
180
- };
181
-
182
205
  export const findTrailDefinitions = (ast: AstNode): TrailDefinition[] => {
183
206
  const definitions: TrailDefinition[] = [];
184
207
 
@@ -193,25 +216,71 @@ export const findTrailDefinitions = (ast: AstNode): TrailDefinition[] => {
193
216
  };
194
217
 
195
218
  // ---------------------------------------------------------------------------
196
- // Config property extraction helpers
219
+ // Run body extraction
197
220
  // ---------------------------------------------------------------------------
198
221
 
199
- /** Find a Property node by key name inside an ObjectExpression config. */
200
- export const findConfigProperty = (
201
- config: AstNode,
202
- propertyName: string
203
- ): AstNode | null => {
204
- if (config.type !== 'ObjectExpression') {
205
- return null;
206
- }
222
+ /**
223
+ * Extract top-level `run:` property values from an ObjectExpression's direct properties.
224
+ *
225
+ * Does not recurse into nested objects, so `metadata: { run: ... }` is ignored.
226
+ */
227
+ const extractRunFromConfig = (config: AstNode): AstNode[] => {
228
+ const bodies: AstNode[] = [];
207
229
  const properties = config['properties'] as readonly AstNode[] | undefined;
208
230
  if (!properties) {
209
- return null;
231
+ return bodies;
210
232
  }
211
233
  for (const prop of properties) {
212
- if (prop.type === 'Property' && prop.key?.name === propertyName) {
213
- return prop;
234
+ if (prop.type === 'Property' && prop.key?.name === 'run' && prop.value) {
235
+ bodies.push(prop.value);
214
236
  }
215
237
  }
216
- return null;
238
+ return bodies;
239
+ };
240
+
241
+ /**
242
+ * Find `run:` property values.
243
+ *
244
+ * When given an ObjectExpression (trail config), returns only its direct `run:`
245
+ * properties. When given a full AST, finds trail definitions first and extracts
246
+ * `run:` from each config — in both cases ignoring nested `run:` properties
247
+ * (e.g. `metadata: { run: ... }`).
248
+ */
249
+ export const findRunBodies = (node: AstNode): AstNode[] => {
250
+ if (node.type === 'ObjectExpression') {
251
+ return extractRunFromConfig(node);
252
+ }
253
+
254
+ // Full AST — find trail definitions and extract run from their configs
255
+ const bodies: AstNode[] = [];
256
+ for (const def of findTrailDefinitions(node)) {
257
+ bodies.push(...extractRunFromConfig(def.config));
258
+ }
259
+ return bodies;
260
+ };
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Misc helpers
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /** Check if a node is a call to `.run()` on some object. */
267
+ export const isRunCall = (node: AstNode): boolean => {
268
+ if (node.type !== 'CallExpression') {
269
+ return false;
270
+ }
271
+ const callee = node['callee'] as AstNode | undefined;
272
+ if (!callee) {
273
+ return false;
274
+ }
275
+ if (
276
+ callee.type !== 'StaticMemberExpression' &&
277
+ callee.type !== 'MemberExpression'
278
+ ) {
279
+ return false;
280
+ }
281
+ const prop = (callee as unknown as { property?: AstNode }).property;
282
+ return (
283
+ prop?.type === 'Identifier' &&
284
+ (prop as unknown as { name: string }).name === 'run'
285
+ );
217
286
  };
@@ -123,7 +123,7 @@ const classifyImport = (
123
123
  */
124
124
  export const contextNoSurfaceTypes: WardenRule = {
125
125
  check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
126
- if (!/\b(?:trail|hike)\s*\(/.test(sourceCode)) {
126
+ if (!/\btrail\s*\(/.test(sourceCode)) {
127
127
  return [];
128
128
  }
129
129