@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 @@
1
+ {"version":3,"file":"wrap-rule.d.ts","sourceRoot":"","sources":["../../src/trails/wrap-rule.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAE5C,OAAO,KAAK,EAAE,sBAAsB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAE5E,OAAO,KAAK,EAAE,qBAAqB,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEhF,UAAU,eAAe;IACvB,wCAAwC;IACxC,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC,UAAU,CAAC,CAAC;CAC7D;AAED,UAAU,2BAA2B;IACnC,sDAAsD;IACtD,QAAQ,CAAC,IAAI,EAAE,sBAAsB,CAAC;IACtC,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,qBAAqB,EAAE,UAAU,CAAC,CAAC,UAAU,CAAC,CAAC;CACzE;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CACtB,OAAO,EAAE,2BAA2B,GACnC,KAAK,CAAC,qBAAqB,EAAE,UAAU,CAAC,CAAC;AAC5C,wBAAgB,QAAQ,CACtB,OAAO,EAAE,eAAe,GACvB,KAAK,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Factory that wraps a WardenRule as a trail.
3
+ *
4
+ * Keeps each rule trail file minimal — just the import + examples.
5
+ */
6
+ import { trail, Result } from '@ontrails/core';
7
+ import { projectAwareRuleInput, ruleInput, ruleOutput } from './schema.js';
8
+ export function wrapRule(options) {
9
+ const { rule, examples } = options;
10
+ const isProjectAware = 'checkWithContext' in rule;
11
+ if (isProjectAware) {
12
+ const projectAwareRule = rule;
13
+ return trail(`warden.rule.${rule.name}`, {
14
+ description: rule.description,
15
+ examples: examples,
16
+ input: projectAwareRuleInput,
17
+ intent: 'read',
18
+ metadata: { category: 'governance', severity: rule.severity },
19
+ output: ruleOutput,
20
+ run: (input) => {
21
+ const diagnostics = projectAwareRule.checkWithContext(input.sourceCode, input.filePath, {
22
+ knownTrailIds: input.knownTrailIds
23
+ ? new Set(input.knownTrailIds)
24
+ : new Set(),
25
+ });
26
+ return Result.ok({ diagnostics: [...diagnostics] });
27
+ },
28
+ });
29
+ }
30
+ return trail(`warden.rule.${rule.name}`, {
31
+ description: rule.description,
32
+ examples: examples,
33
+ input: ruleInput,
34
+ intent: 'read',
35
+ metadata: { category: 'governance', severity: rule.severity },
36
+ output: ruleOutput,
37
+ run: (input) => {
38
+ const diagnostics = rule.check(input.sourceCode, input.filePath);
39
+ return Result.ok({ diagnostics: [...diagnostics] });
40
+ },
41
+ });
42
+ }
43
+ //# sourceMappingURL=wrap-rule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wrap-rule.js","sourceRoot":"","sources":["../../src/trails/wrap-rule.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAI/C,OAAO,EAAE,qBAAqB,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AA4B3E,MAAM,UAAU,QAAQ,CACtB,OAAsD;IAEtD,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IACnC,MAAM,cAAc,GAAG,kBAAkB,IAAI,IAAI,CAAC;IAElD,IAAI,cAAc,EAAE,CAAC;QACnB,MAAM,gBAAgB,GAAG,IAA8B,CAAC;QACxD,OAAO,KAAK,CAAC,eAAe,IAAI,CAAC,IAAI,EAAE,EAAE;YACvC,WAAW,EAAE,IAAI,CAAC,WAAW;YAC7B,QAAQ,EAAE,QAGG;YACb,KAAK,EAAE,qBAAqB;YAC5B,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE;YAC7D,MAAM,EAAE,UAAU;YAClB,GAAG,EAAE,CAAC,KAA4B,EAAE,EAAE;gBACpC,MAAM,WAAW,GAAG,gBAAgB,CAAC,gBAAgB,CACnD,KAAK,CAAC,UAAU,EAChB,KAAK,CAAC,QAAQ,EACd;oBACE,aAAa,EAAE,KAAK,CAAC,aAAa;wBAChC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,aAAa,CAAC;wBAC9B,CAAC,CAAC,IAAI,GAAG,EAAU;iBACtB,CACF,CAAC;gBACF,OAAO,MAAM,CAAC,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;YACtD,CAAC;SACF,CAAC,CAAC;IACL,CAAC;IAED,OAAO,KAAK,CAAC,eAAe,IAAI,CAAC,IAAI,EAAE,EAAE;QACvC,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,QAAQ,EAAE,QAAoD;QAC9D,KAAK,EAAE,SAAS;QAChB,MAAM,EAAE,MAAM;QACd,QAAQ,EAAE,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE;QAC7D,MAAM,EAAE,UAAU;QAClB,GAAG,EAAE,CAAC,KAAgB,EAAE,EAAE;YACxB,MAAM,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC;YACjE,OAAO,MAAM,CAAC,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;QACtD,CAAC;KACF,CAAC,CAAC;AACL,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/warden",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.10",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -14,12 +14,13 @@
14
14
  "clean": "rm -rf dist *.tsbuildinfo"
15
15
  },
16
16
  "devDependencies": {
17
+ "@ontrails/testing": "^1.0.0-beta.8",
17
18
  "@oxc-project/types": "^0.122.0",
18
19
  "oxc-parser": "^0.121.0",
19
- "zod": "catalog:"
20
+ "zod": "^4.3.5"
20
21
  },
21
22
  "peerDependencies": {
22
- "@ontrails/core": "workspace:*",
23
- "@ontrails/schema": "workspace:*"
23
+ "@ontrails/core": "^1.0.0-beta.8",
24
+ "@ontrails/schema": "^1.0.0-beta.8"
24
25
  }
25
26
  }
@@ -21,7 +21,7 @@ describe('runWarden', () => {
21
21
  writeFileSync(
22
22
  join(dir, 'bad.ts'),
23
23
  `trail("entity.show", {
24
- implementation: async (input, ctx) => {
24
+ run: async (input, ctx) => {
25
25
  throw new Error("boom");
26
26
  }
27
27
  })`
@@ -41,7 +41,7 @@ describe('runWarden', () => {
41
41
  writeFileSync(
42
42
  join(dir, 'good.ts'),
43
43
  `trail("entity.show", {
44
- implementation: async (input, ctx) => {
44
+ run: async (input, ctx) => {
45
45
  return Result.ok(data);
46
46
  }
47
47
  })`
@@ -72,7 +72,7 @@ describe('runWarden', () => {
72
72
  // Even with bad code, driftOnly should produce 0 diagnostics
73
73
  writeFileSync(
74
74
  join(dir, 'bad.ts'),
75
- `trail("x", { implementation: async () => { throw new Error("x"); } })`
75
+ `trail("x", { run: async () => { throw new Error("x"); } })`
76
76
  );
77
77
  const report = await runWarden({ driftOnly: true, rootDir: dir });
78
78
  expect(report.diagnostics.length).toBe(0);
@@ -89,7 +89,7 @@ describe('runWarden', () => {
89
89
  join(dir, 'show.ts'),
90
90
  `trail("entity.show", {
91
91
  detours: { NotFoundError: ["entity.search"] },
92
- implementation: async (input, ctx) => {
92
+ run: async (input, ctx) => {
93
93
  return Result.ok(data);
94
94
  }
95
95
  })`
@@ -97,7 +97,7 @@ describe('runWarden', () => {
97
97
  writeFileSync(
98
98
  join(dir, 'search.ts'),
99
99
  `trail("entity.search", {
100
- implementation: async (input, ctx) => {
100
+ run: async (input, ctx) => {
101
101
  return Result.ok(data);
102
102
  }
103
103
  })`
@@ -121,7 +121,7 @@ describe('runWarden', () => {
121
121
  join(dir, 'show.ts'),
122
122
  `trail("entity.show", {
123
123
  detours: { NotFoundError: ["entity.search"] },
124
- implementation: async (input, ctx) => {
124
+ run: async (input, ctx) => {
125
125
  return Result.ok(data);
126
126
  }
127
127
  })`
@@ -129,7 +129,7 @@ describe('runWarden', () => {
129
129
  writeFileSync(
130
130
  join(dir, 'search.ts'),
131
131
  `trail("entity.search", {
132
- implementation: async (input, ctx) => {
132
+ run: async (input, ctx) => {
133
133
  throw new Error("boom");
134
134
  }
135
135
  })`
@@ -11,9 +11,9 @@ import { checkDrift } from '../drift.js';
11
11
 
12
12
  const makeTopo = () => {
13
13
  const t = trail('test.hello', {
14
- implementation: () => Result.ok({ greeting: 'hi' }),
15
14
  input: z.object({ name: z.string() }),
16
15
  output: z.object({ greeting: z.string() }),
16
+ run: () => Result.ok({ greeting: 'hi' }),
17
17
  });
18
18
  return topo('test-app', { t });
19
19
  };
@@ -0,0 +1,303 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { followDeclarations } from '../rules/follow-declarations.js';
4
+
5
+ const TEST_FILE = 'test.ts';
6
+
7
+ describe('follow-declarations', () => {
8
+ describe('clean cases', () => {
9
+ test('declared and called match exactly', () => {
10
+ const code = `
11
+ import { trail, Result } from '@ontrails/core';
12
+ const t = trail('onboard', {
13
+ follow: ['entity.add', 'search'],
14
+ input: z.object({ name: z.string() }),
15
+ run: async (input, ctx) => {
16
+ await ctx.follow('entity.add', { name: input.name });
17
+ await ctx.follow('search', { query: input.name });
18
+ return Result.ok({});
19
+ },
20
+ });
21
+ `;
22
+
23
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
24
+
25
+ expect(diagnostics.length).toBe(0);
26
+ });
27
+
28
+ test('no follow declaration and no ctx.follow() calls', () => {
29
+ const code = `
30
+ trail('simple', {
31
+ input: z.object({ name: z.string() }),
32
+ run: async (input, ctx) => {
33
+ return Result.ok({ greeting: 'hello ' + input.name });
34
+ },
35
+ });
36
+ `;
37
+
38
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
39
+
40
+ expect(diagnostics.length).toBe(0);
41
+ });
42
+ });
43
+
44
+ describe('error cases', () => {
45
+ test('called but not declared produces error', () => {
46
+ const code = `
47
+ trail('onboard', {
48
+ input: z.object({ name: z.string() }),
49
+ run: async (input, ctx) => {
50
+ await ctx.follow('entity.add', { name: input.name });
51
+ return Result.ok({});
52
+ },
53
+ });
54
+ `;
55
+
56
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
57
+
58
+ expect(diagnostics.length).toBe(1);
59
+ expect(diagnostics[0]?.severity).toBe('error');
60
+ expect(diagnostics[0]?.rule).toBe('follow-declarations');
61
+ expect(diagnostics[0]?.message).toContain("ctx.follow('entity.add')");
62
+ expect(diagnostics[0]?.message).toContain('not declared in follow');
63
+ });
64
+ });
65
+
66
+ describe('warn cases', () => {
67
+ test('declared but not called produces warning', () => {
68
+ const code = `
69
+ trail('onboard', {
70
+ follow: ['entity.add', 'search'],
71
+ run: async (input, ctx) => {
72
+ await ctx.follow('entity.add', { name: input.name });
73
+ return Result.ok({});
74
+ },
75
+ });
76
+ `;
77
+
78
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
79
+
80
+ expect(diagnostics.length).toBe(1);
81
+ expect(diagnostics[0]?.severity).toBe('warn');
82
+ expect(diagnostics[0]?.rule).toBe('follow-declarations');
83
+ expect(diagnostics[0]?.message).toContain("'search' declared in follow");
84
+ expect(diagnostics[0]?.message).toContain('never called');
85
+ });
86
+ });
87
+
88
+ describe('single-object overload', () => {
89
+ test('recognizes trail({ id, follow, run }) form', () => {
90
+ const code = `
91
+ trail({
92
+ id: 'onboard',
93
+ follow: ['entity.add'],
94
+ input: z.object({ name: z.string() }),
95
+ run: async (input, ctx) => {
96
+ await ctx.follow('entity.add', { name: input.name });
97
+ return Result.ok({});
98
+ },
99
+ });
100
+ `;
101
+
102
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
103
+
104
+ expect(diagnostics.length).toBe(0);
105
+ });
106
+
107
+ test('detects undeclared follows in single-object form', () => {
108
+ const code = `
109
+ trail({
110
+ id: 'onboard',
111
+ input: z.object({ name: z.string() }),
112
+ run: async (input, ctx) => {
113
+ await ctx.follow('entity.add', { name: input.name });
114
+ return Result.ok({});
115
+ },
116
+ });
117
+ `;
118
+
119
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
120
+
121
+ expect(diagnostics.length).toBe(1);
122
+ expect(diagnostics[0]?.severity).toBe('error');
123
+ expect(diagnostics[0]?.message).toContain("'entity.add'");
124
+ });
125
+ });
126
+
127
+ describe('context parameter naming', () => {
128
+ test('recognizes context.follow() when second param is named context', () => {
129
+ const code = `
130
+ trail('onboard', {
131
+ follow: ['entity.add'],
132
+ input: z.object({ name: z.string() }),
133
+ run: async (input, context) => {
134
+ await context.follow('entity.add', { name: input.name });
135
+ return Result.ok({});
136
+ },
137
+ });
138
+ `;
139
+
140
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
141
+
142
+ expect(diagnostics.length).toBe(0);
143
+ });
144
+
145
+ test('detects undeclared context.follow() calls', () => {
146
+ const code = `
147
+ trail('onboard', {
148
+ input: z.object({ name: z.string() }),
149
+ run: async (input, context) => {
150
+ await context.follow('entity.add', { name: input.name });
151
+ return Result.ok({});
152
+ },
153
+ });
154
+ `;
155
+
156
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
157
+
158
+ expect(diagnostics.length).toBe(1);
159
+ expect(diagnostics[0]?.severity).toBe('error');
160
+ });
161
+
162
+ test('recognizes destructured follow() calls', () => {
163
+ const code = `
164
+ trail('onboard', {
165
+ follow: ['entity.add'],
166
+ input: z.object({ name: z.string() }),
167
+ run: async (input, ctx) => {
168
+ const { follow } = ctx;
169
+ await follow('entity.add', { name: input.name });
170
+ return Result.ok({});
171
+ },
172
+ });
173
+ `;
174
+
175
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
176
+
177
+ expect(diagnostics.length).toBe(0);
178
+ });
179
+ });
180
+
181
+ describe('nested run false positives', () => {
182
+ test('metadata.run does not trigger false positives', () => {
183
+ const code = `
184
+ trail('onboard', {
185
+ follow: ['entity.add'],
186
+ input: z.object({ name: z.string() }),
187
+ metadata: { run: async () => ctx.follow('phantom') },
188
+ run: async (input, ctx) => {
189
+ await ctx.follow('entity.add', { name: input.name });
190
+ return Result.ok({});
191
+ },
192
+ });
193
+ `;
194
+
195
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
196
+
197
+ expect(diagnostics.length).toBe(0);
198
+ });
199
+ });
200
+
201
+ describe('identifier resolution in follow arrays', () => {
202
+ test('resolves const identifiers in follow array', () => {
203
+ const code = `
204
+ const ENTITY_ADD = 'entity.add';
205
+ trail('onboard', {
206
+ follow: [ENTITY_ADD],
207
+ input: z.object({ name: z.string() }),
208
+ run: async (input, ctx) => {
209
+ await ctx.follow('entity.add', { name: input.name });
210
+ return Result.ok({});
211
+ },
212
+ });
213
+ `;
214
+
215
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
216
+
217
+ expect(diagnostics.length).toBe(0);
218
+ });
219
+
220
+ test('reports error when resolved identifier does not match called follow', () => {
221
+ const code = `
222
+ const ENTITY_ADD = 'entity.add';
223
+ trail('onboard', {
224
+ follow: [ENTITY_ADD],
225
+ input: z.object({ name: z.string() }),
226
+ run: async (input, ctx) => {
227
+ await ctx.follow('search', { name: input.name });
228
+ return Result.ok({});
229
+ },
230
+ });
231
+ `;
232
+
233
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
234
+
235
+ // 'search' called but not declared, 'entity.add' declared but not called
236
+ expect(diagnostics.length).toBe(2);
237
+ });
238
+ });
239
+
240
+ describe('edge cases', () => {
241
+ test('dynamic follow IDs are skipped', () => {
242
+ const code = `
243
+ trail('dispatch', {
244
+ follow: ['entity.add'],
245
+ run: async (input, ctx) => {
246
+ const trailId = input.target;
247
+ await ctx.follow(trailId, input);
248
+ await ctx.follow('entity.add', input);
249
+ return Result.ok({});
250
+ },
251
+ });
252
+ `;
253
+
254
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
255
+
256
+ expect(diagnostics.length).toBe(0);
257
+ });
258
+
259
+ test('multiple trails in one file are validated independently', () => {
260
+ const code = `
261
+ trail('alpha', {
262
+ follow: ['shared'],
263
+ run: async (input, ctx) => {
264
+ await ctx.follow('shared', input);
265
+ return Result.ok({});
266
+ },
267
+ });
268
+
269
+ trail('beta', {
270
+ run: async (input, ctx) => {
271
+ await ctx.follow('undeclared', input);
272
+ return Result.ok({});
273
+ },
274
+ });
275
+ `;
276
+
277
+ const diagnostics = followDeclarations.check(code, TEST_FILE);
278
+
279
+ expect(diagnostics.length).toBe(1);
280
+ expect(diagnostics[0]?.message).toContain('Trail "beta"');
281
+ expect(diagnostics[0]?.message).toContain("'undeclared'");
282
+ expect(diagnostics[0]?.severity).toBe('error');
283
+ });
284
+
285
+ test('skips test files', () => {
286
+ const code = `
287
+ trail('onboard', {
288
+ run: async (input, ctx) => {
289
+ await ctx.follow('entity.add', input);
290
+ return Result.ok({});
291
+ },
292
+ });
293
+ `;
294
+
295
+ const diagnostics = followDeclarations.check(
296
+ code,
297
+ 'src/__tests__/trails.test.ts'
298
+ );
299
+
300
+ expect(diagnostics.length).toBe(0);
301
+ });
302
+ });
303
+ });
@@ -8,7 +8,7 @@ describe('implementation-returns-result', () => {
8
8
  test('flags raw object return in trail implementation', () => {
9
9
  const code = `
10
10
  trail("entity.show", {
11
- implementation: async (input, ctx) => {
11
+ run: async (input, ctx) => {
12
12
  return { name: "foo" };
13
13
  }
14
14
  })`;
@@ -22,15 +22,16 @@ trail("entity.show", {
22
22
 
23
23
  test('allows Result.ok() and returning ctx.follow() results', () => {
24
24
  const code = `
25
- hike("entity.onboard", {
26
- implementation: async (input, ctx) => {
25
+ trail("entity.onboard", {
26
+ follow: ["entity.create"],
27
+ run: async (input, ctx) => {
27
28
  const result = await ctx.follow("entity.create", input);
28
29
  return result;
29
30
  }
30
31
  })
31
32
 
32
33
  trail("entity.create", {
33
- implementation: async (input, ctx) => Result.ok({ id: "123" })
34
+ run: async (input, ctx) => Result.ok({ id: "123" })
34
35
  })`;
35
36
 
36
37
  const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
@@ -41,7 +42,7 @@ trail("entity.create", {
41
42
  test('flags concise raw implementation bodies', () => {
42
43
  const code = `
43
44
  trail("entity.create", {
44
- implementation: async (input, ctx) => ({ id: "123" })
45
+ run: async (input, ctx) => ({ id: "123" })
45
46
  })`;
46
47
 
47
48
  const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
@@ -50,6 +51,59 @@ trail("entity.create", {
50
51
  expect(diagnostics[0]?.message).toContain('entity.create');
51
52
  });
52
53
 
54
+ test('ignores return statements inside nested callbacks like .map()', () => {
55
+ const code = `
56
+ trail("entity.list", {
57
+ run: async (input, ctx) => {
58
+ const items = ["a", "b", "c"];
59
+ const mapped = items.map((item) => {
60
+ return { name: item };
61
+ });
62
+ const filtered = items.filter((item) => {
63
+ return item !== "b";
64
+ });
65
+ return Result.ok(mapped);
66
+ }
67
+ })`;
68
+
69
+ const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
70
+
71
+ expect(diagnostics.length).toBe(0);
72
+ });
73
+
74
+ test('ignores return statements inside .then() callbacks', () => {
75
+ const code = `
76
+ trail("entity.fetch", {
77
+ run: async (input, ctx) => {
78
+ const data = await somePromise.then((res) => {
79
+ return res.json();
80
+ });
81
+ return Result.ok(data);
82
+ }
83
+ })`;
84
+
85
+ const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
86
+
87
+ expect(diagnostics.length).toBe(0);
88
+ });
89
+
90
+ test('still flags raw returns at the implementation level', () => {
91
+ const code = `
92
+ trail("entity.list", {
93
+ run: async (input, ctx) => {
94
+ const items = ["a", "b"].map((item) => {
95
+ return { name: item };
96
+ });
97
+ return items;
98
+ }
99
+ })`;
100
+
101
+ const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
102
+
103
+ expect(diagnostics.length).toBe(1);
104
+ expect(diagnostics[0]?.message).toContain('entity.list');
105
+ });
106
+
53
107
  test('allows returning explicitly Result-typed local helpers', () => {
54
108
  const code = `
55
109
  const buildDetail = (trailId: string): Result<object, Error> =>
@@ -59,7 +113,7 @@ const buildDiff = async (): Promise<Result<object, Error>> =>
59
113
  Result.ok({ breaking: [] });
60
114
 
61
115
  trail("survey", {
62
- implementation: async (input, ctx) => {
116
+ run: async (input, ctx) => {
63
117
  if (input.diff) {
64
118
  return await buildDiff();
65
119
  }
@@ -8,11 +8,11 @@ describe('no-direct-implementation-call', () => {
8
8
  import { trail, Result } from "@ontrails/core";
9
9
 
10
10
  const entityShow = trail("entity.show", {
11
- implementation: async (input, ctx) => Result.ok({ id: input.id }),
11
+ run: async (input, ctx) => Result.ok({ id: input.id }),
12
12
  });
13
13
 
14
14
  async function run() {
15
- const result = await entityShow.implementation({ id: "1" }, ctx);
15
+ const result = await entityShow.run({ id: "1" }, ctx);
16
16
  return result;
17
17
  }`;
18
18
 
@@ -26,9 +26,9 @@ async function run() {
26
26
 
27
27
  test('allows ctx.follow() calls', () => {
28
28
  const code = `
29
- hike("entity.onboard", {
30
- follows: ["entity.create"],
31
- implementation: async (input, ctx) => {
29
+ trail("entity.onboard", {
30
+ follow: ["entity.create"],
31
+ run: async (input, ctx) => {
32
32
  const result = await ctx.follow("entity.create", input);
33
33
  return Result.ok(result);
34
34
  },
@@ -43,7 +43,7 @@ hike("entity.onboard", {
43
43
  test('ignores test files', () => {
44
44
  const code = `
45
45
  async function run() {
46
- return await entityShow.implementation({ id: "1" }, ctx);
46
+ return await entityShow.run({ id: "1" }, ctx);
47
47
  }`;
48
48
 
49
49
  const diagnostics = noDirectImplementationCall.check(
@@ -57,7 +57,7 @@ async function run() {
57
57
  test('ignores framework internals that intentionally call implementations', () => {
58
58
  const code = `
59
59
  export async function run() {
60
- return await entityShow.implementation({ id: "1" }, ctx);
60
+ return await entityShow.run({ id: "1" }, ctx);
61
61
  }`;
62
62
 
63
63
  const diagnostics = noDirectImplementationCall.check(
@@ -70,7 +70,7 @@ export async function run() {
70
70
 
71
71
  test('ignores implementation references inside template strings', () => {
72
72
  const code = `
73
- const generated = \`const result = await entityShow.implementation({ id: "1" }, ctx);\`;
73
+ const generated = \`const result = await entityShow.run({ id: "1" }, ctx);\`;
74
74
  `;
75
75
 
76
76
  const diagnostics = noDirectImplementationCall.check(
@@ -6,7 +6,7 @@ describe('no-sync-result-assumption', () => {
6
6
  test('flags direct result access on implementation calls', () => {
7
7
  const code = `
8
8
  async function run() {
9
- const isOk = entityShow.implementation({ id: "1" }, ctx).isOk();
9
+ const isOk = entityShow.run({ id: "1" }, ctx).isOk();
10
10
  return isOk;
11
11
  }`;
12
12
 
@@ -20,7 +20,7 @@ async function run() {
20
20
 
21
21
  test('flags a stored implementation result that is used synchronously', () => {
22
22
  const code = `
23
- const result = entityShow.implementation({ id: "1" }, ctx);
23
+ const result = entityShow.run({ id: "1" }, ctx);
24
24
 
25
25
  if (result.isOk()) {
26
26
  console.log("ok");
@@ -35,7 +35,7 @@ if (result.isOk()) {
35
35
  test('allows awaited implementation calls before result access', () => {
36
36
  const code = `
37
37
  async function run() {
38
- const result = await entityShow.implementation({ id: "1" }, ctx);
38
+ const result = await entityShow.run({ id: "1" }, ctx);
39
39
  return result.isOk();
40
40
  }`;
41
41
 
@@ -47,7 +47,7 @@ async function run() {
47
47
  test('allows awaited implementation calls when the property access is chained', () => {
48
48
  const code = `
49
49
  async function run() {
50
- return (await entityShow.implementation({ id: "1" }, ctx)).isOk();
50
+ return (await entityShow.run({ id: "1" }, ctx)).isOk();
51
51
  }`;
52
52
 
53
53
  const diagnostics = noSyncResultAssumption.check(code, 'src/app.ts');
@@ -57,7 +57,7 @@ async function run() {
57
57
 
58
58
  test('ignores test files', () => {
59
59
  const code = `
60
- const result = entityShow.implementation({ id: "1" }, ctx);
60
+ const result = entityShow.run({ id: "1" }, ctx);
61
61
  result.isOk();
62
62
  `;
63
63
 
@@ -71,7 +71,7 @@ result.isOk();
71
71
 
72
72
  test('ignores framework internals that intentionally call implementations', () => {
73
73
  const code = `
74
- const result = entityShow.implementation({ id: "1" }, ctx);
74
+ const result = entityShow.run({ id: "1" }, ctx);
75
75
  result.isOk();
76
76
  `;
77
77