@ontrails/warden 1.0.0-beta.0

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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +21 -0
  5. package/README.md +132 -0
  6. package/dist/cli.d.ts +46 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +221 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/drift.d.ts +26 -0
  11. package/dist/drift.d.ts.map +1 -0
  12. package/dist/drift.js +27 -0
  13. package/dist/drift.js.map +1 -0
  14. package/dist/formatters.d.ts +29 -0
  15. package/dist/formatters.d.ts.map +1 -0
  16. package/dist/formatters.js +87 -0
  17. package/dist/formatters.js.map +1 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +26 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/rules/ast.d.ts +41 -0
  23. package/dist/rules/ast.d.ts.map +1 -0
  24. package/dist/rules/ast.js +163 -0
  25. package/dist/rules/ast.js.map +1 -0
  26. package/dist/rules/context-no-surface-types.d.ts +12 -0
  27. package/dist/rules/context-no-surface-types.d.ts.map +1 -0
  28. package/dist/rules/context-no-surface-types.js +96 -0
  29. package/dist/rules/context-no-surface-types.js.map +1 -0
  30. package/dist/rules/implementation-returns-result.d.ts +13 -0
  31. package/dist/rules/implementation-returns-result.d.ts.map +1 -0
  32. package/dist/rules/implementation-returns-result.js +231 -0
  33. package/dist/rules/implementation-returns-result.js.map +1 -0
  34. package/dist/rules/index.d.ts +22 -0
  35. package/dist/rules/index.d.ts.map +1 -0
  36. package/dist/rules/index.js +41 -0
  37. package/dist/rules/index.js.map +1 -0
  38. package/dist/rules/no-direct-impl-in-route.d.ts +12 -0
  39. package/dist/rules/no-direct-impl-in-route.d.ts.map +1 -0
  40. package/dist/rules/no-direct-impl-in-route.js +46 -0
  41. package/dist/rules/no-direct-impl-in-route.js.map +1 -0
  42. package/dist/rules/no-direct-implementation-call.d.ts +12 -0
  43. package/dist/rules/no-direct-implementation-call.d.ts.map +1 -0
  44. package/dist/rules/no-direct-implementation-call.js +39 -0
  45. package/dist/rules/no-direct-implementation-call.js.map +1 -0
  46. package/dist/rules/no-sync-result-assumption.d.ts +6 -0
  47. package/dist/rules/no-sync-result-assumption.d.ts.map +1 -0
  48. package/dist/rules/no-sync-result-assumption.js +98 -0
  49. package/dist/rules/no-sync-result-assumption.js.map +1 -0
  50. package/dist/rules/no-throw-in-detour-target.d.ts +12 -0
  51. package/dist/rules/no-throw-in-detour-target.d.ts.map +1 -0
  52. package/dist/rules/no-throw-in-detour-target.js +87 -0
  53. package/dist/rules/no-throw-in-detour-target.js.map +1 -0
  54. package/dist/rules/no-throw-in-implementation.d.ts +9 -0
  55. package/dist/rules/no-throw-in-implementation.d.ts.map +1 -0
  56. package/dist/rules/no-throw-in-implementation.js +34 -0
  57. package/dist/rules/no-throw-in-implementation.js.map +1 -0
  58. package/dist/rules/prefer-schema-inference.d.ts +7 -0
  59. package/dist/rules/prefer-schema-inference.d.ts.map +1 -0
  60. package/dist/rules/prefer-schema-inference.js +86 -0
  61. package/dist/rules/prefer-schema-inference.js.map +1 -0
  62. package/dist/rules/scan.d.ts +8 -0
  63. package/dist/rules/scan.d.ts.map +1 -0
  64. package/dist/rules/scan.js +32 -0
  65. package/dist/rules/scan.js.map +1 -0
  66. package/dist/rules/specs.d.ts +29 -0
  67. package/dist/rules/specs.d.ts.map +1 -0
  68. package/dist/rules/specs.js +192 -0
  69. package/dist/rules/specs.js.map +1 -0
  70. package/dist/rules/structure.d.ts +13 -0
  71. package/dist/rules/structure.d.ts.map +1 -0
  72. package/dist/rules/structure.js +142 -0
  73. package/dist/rules/structure.js.map +1 -0
  74. package/dist/rules/types.d.ts +52 -0
  75. package/dist/rules/types.d.ts.map +1 -0
  76. package/dist/rules/types.js +2 -0
  77. package/dist/rules/types.js.map +1 -0
  78. package/dist/rules/valid-describe-refs.d.ts +7 -0
  79. package/dist/rules/valid-describe-refs.d.ts.map +1 -0
  80. package/dist/rules/valid-describe-refs.js +51 -0
  81. package/dist/rules/valid-describe-refs.js.map +1 -0
  82. package/dist/rules/valid-detour-refs.d.ts +6 -0
  83. package/dist/rules/valid-detour-refs.d.ts.map +1 -0
  84. package/dist/rules/valid-detour-refs.js +116 -0
  85. package/dist/rules/valid-detour-refs.js.map +1 -0
  86. package/package.json +25 -0
  87. package/src/__tests__/cli.test.ts +198 -0
  88. package/src/__tests__/drift.test.ts +74 -0
  89. package/src/__tests__/formatters.test.ts +157 -0
  90. package/src/__tests__/implementation-returns-result.test.ts +75 -0
  91. package/src/__tests__/no-direct-implementation-call.test.ts +83 -0
  92. package/src/__tests__/no-sync-result-assumption.test.ts +85 -0
  93. package/src/__tests__/no-throw-in-detour-target.test.ts +78 -0
  94. package/src/__tests__/prefer-schema-inference.test.ts +84 -0
  95. package/src/__tests__/rules.test.ts +188 -0
  96. package/src/__tests__/valid-describe-refs.test.ts +60 -0
  97. package/src/cli.ts +343 -0
  98. package/src/drift.ts +50 -0
  99. package/src/formatters.ts +113 -0
  100. package/src/index.ts +47 -0
  101. package/src/rules/ast.ts +217 -0
  102. package/src/rules/context-no-surface-types.ts +150 -0
  103. package/src/rules/implementation-returns-result.ts +343 -0
  104. package/src/rules/index.ts +54 -0
  105. package/src/rules/no-direct-impl-in-route.ts +77 -0
  106. package/src/rules/no-direct-implementation-call.ts +47 -0
  107. package/src/rules/no-sync-result-assumption.ts +156 -0
  108. package/src/rules/no-throw-in-detour-target.ts +150 -0
  109. package/src/rules/no-throw-in-implementation.ts +41 -0
  110. package/src/rules/prefer-schema-inference.ts +141 -0
  111. package/src/rules/scan.ts +46 -0
  112. package/src/rules/specs.ts +384 -0
  113. package/src/rules/structure.ts +234 -0
  114. package/src/rules/types.ts +62 -0
  115. package/src/rules/valid-describe-refs.ts +94 -0
  116. package/src/rules/valid-detour-refs.ts +187 -0
  117. package/tsconfig.json +9 -0
  118. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { noDirectImplementationCall } from '../rules/no-direct-implementation-call.js';
4
+
5
+ describe('no-direct-implementation-call', () => {
6
+ test('flags direct implementation access in application code', () => {
7
+ const code = `
8
+ import { trail, Result } from "@ontrails/core";
9
+
10
+ const entityShow = trail("entity.show", {
11
+ implementation: async (input, ctx) => Result.ok({ id: input.id }),
12
+ });
13
+
14
+ async function run() {
15
+ const result = await entityShow.implementation({ id: "1" }, ctx);
16
+ return result;
17
+ }`;
18
+
19
+ const diagnostics = noDirectImplementationCall.check(code, 'src/app.ts');
20
+
21
+ expect(diagnostics).toHaveLength(1);
22
+ expect(diagnostics[0]?.rule).toBe('no-direct-implementation-call');
23
+ expect(diagnostics[0]?.severity).toBe('warn');
24
+ expect(diagnostics[0]?.message).toContain('ctx.follow');
25
+ });
26
+
27
+ test('allows ctx.follow() calls', () => {
28
+ const code = `
29
+ hike("entity.onboard", {
30
+ follows: ["entity.create"],
31
+ implementation: async (input, ctx) => {
32
+ const result = await ctx.follow("entity.create", input);
33
+ return Result.ok(result);
34
+ },
35
+ });
36
+ `;
37
+
38
+ const diagnostics = noDirectImplementationCall.check(code, 'src/app.ts');
39
+
40
+ expect(diagnostics).toHaveLength(0);
41
+ });
42
+
43
+ test('ignores test files', () => {
44
+ const code = `
45
+ async function run() {
46
+ return await entityShow.implementation({ id: "1" }, ctx);
47
+ }`;
48
+
49
+ const diagnostics = noDirectImplementationCall.check(
50
+ code,
51
+ 'src/__tests__/app.test.ts'
52
+ );
53
+
54
+ expect(diagnostics).toHaveLength(0);
55
+ });
56
+
57
+ test('ignores framework internals that intentionally call implementations', () => {
58
+ const code = `
59
+ export async function run() {
60
+ return await entityShow.implementation({ id: "1" }, ctx);
61
+ }`;
62
+
63
+ const diagnostics = noDirectImplementationCall.check(
64
+ code,
65
+ '/repo/packages/testing/src/trail.ts'
66
+ );
67
+
68
+ expect(diagnostics).toHaveLength(0);
69
+ });
70
+
71
+ test('ignores implementation references inside template strings', () => {
72
+ const code = `
73
+ const generated = \`const result = await entityShow.implementation({ id: "1" }, ctx);\`;
74
+ `;
75
+
76
+ const diagnostics = noDirectImplementationCall.check(
77
+ code,
78
+ 'src/new-trail.ts'
79
+ );
80
+
81
+ expect(diagnostics).toHaveLength(0);
82
+ });
83
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { noSyncResultAssumption } from '../rules/no-sync-result-assumption.js';
4
+
5
+ describe('no-sync-result-assumption', () => {
6
+ test('flags direct result access on implementation calls', () => {
7
+ const code = `
8
+ async function run() {
9
+ const isOk = entityShow.implementation({ id: "1" }, ctx).isOk();
10
+ return isOk;
11
+ }`;
12
+
13
+ const diagnostics = noSyncResultAssumption.check(code, 'src/app.ts');
14
+
15
+ expect(diagnostics).toHaveLength(1);
16
+ expect(diagnostics[0]?.rule).toBe('no-sync-result-assumption');
17
+ expect(diagnostics[0]?.severity).toBe('error');
18
+ expect(diagnostics[0]?.message).toContain('Missing await');
19
+ });
20
+
21
+ test('flags a stored implementation result that is used synchronously', () => {
22
+ const code = `
23
+ const result = entityShow.implementation({ id: "1" }, ctx);
24
+
25
+ if (result.isOk()) {
26
+ console.log("ok");
27
+ }`;
28
+
29
+ const diagnostics = noSyncResultAssumption.check(code, 'src/app.ts');
30
+
31
+ expect(diagnostics).toHaveLength(1);
32
+ expect(diagnostics[0]?.line).toBe(4);
33
+ });
34
+
35
+ test('allows awaited implementation calls before result access', () => {
36
+ const code = `
37
+ async function run() {
38
+ const result = await entityShow.implementation({ id: "1" }, ctx);
39
+ return result.isOk();
40
+ }`;
41
+
42
+ const diagnostics = noSyncResultAssumption.check(code, 'src/app.ts');
43
+
44
+ expect(diagnostics).toHaveLength(0);
45
+ });
46
+
47
+ test('allows awaited implementation calls when the property access is chained', () => {
48
+ const code = `
49
+ async function run() {
50
+ return (await entityShow.implementation({ id: "1" }, ctx)).isOk();
51
+ }`;
52
+
53
+ const diagnostics = noSyncResultAssumption.check(code, 'src/app.ts');
54
+
55
+ expect(diagnostics).toHaveLength(0);
56
+ });
57
+
58
+ test('ignores test files', () => {
59
+ const code = `
60
+ const result = entityShow.implementation({ id: "1" }, ctx);
61
+ result.isOk();
62
+ `;
63
+
64
+ const diagnostics = noSyncResultAssumption.check(
65
+ code,
66
+ 'src/__tests__/app.test.ts'
67
+ );
68
+
69
+ expect(diagnostics).toHaveLength(0);
70
+ });
71
+
72
+ test('ignores framework internals that intentionally call implementations', () => {
73
+ const code = `
74
+ const result = entityShow.implementation({ id: "1" }, ctx);
75
+ result.isOk();
76
+ `;
77
+
78
+ const diagnostics = noSyncResultAssumption.check(
79
+ code,
80
+ '/repo/packages/testing/src/trail.ts'
81
+ );
82
+
83
+ expect(diagnostics).toHaveLength(0);
84
+ });
85
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { noThrowInDetourTarget } from '../rules/no-throw-in-detour-target.js';
4
+
5
+ const TEST_FILE = 'test.ts';
6
+
7
+ describe('no-throw-in-detour-target', () => {
8
+ test('flags throw inside a detour target implementation', () => {
9
+ const code = `
10
+ trail("entity.show", {
11
+ detours: { NotFoundError: ["entity.fallback"] },
12
+ implementation: async (input, ctx) => Result.ok({ id: "123" })
13
+ })
14
+
15
+ trail("entity.fallback", {
16
+ implementation: async (input, ctx) => {
17
+ throw new Error("boom");
18
+ }
19
+ })`;
20
+
21
+ const diagnostics = noThrowInDetourTarget.check(code, TEST_FILE);
22
+
23
+ expect(diagnostics.length).toBe(1);
24
+ expect(diagnostics[0]?.rule).toBe('no-throw-in-detour-target');
25
+ expect(diagnostics[0]?.severity).toBe('error');
26
+ });
27
+
28
+ test('allows throw in implementations that are not detour targets', () => {
29
+ const code = `
30
+ trail("entity.show", {
31
+ implementation: async (input, ctx) => {
32
+ throw new Error("boom");
33
+ }
34
+ })`;
35
+
36
+ const diagnostics = noThrowInDetourTarget.check(code, TEST_FILE);
37
+
38
+ expect(diagnostics.length).toBe(0);
39
+ });
40
+
41
+ test('flags concise detour target implementations that throw inline', () => {
42
+ const code = `
43
+ trail("entity.show", {
44
+ detours: { NotFoundError: ["entity.fallback"] },
45
+ implementation: async (input, ctx) => Result.ok({ id: "123" })
46
+ })
47
+
48
+ trail("entity.fallback", {
49
+ implementation: async (input, ctx) => { throw new Error("boom"); }
50
+ })`;
51
+
52
+ const diagnostics = noThrowInDetourTarget.check(code, TEST_FILE);
53
+
54
+ expect(diagnostics.length).toBe(1);
55
+ expect(diagnostics[0]?.message).toContain('entity.fallback');
56
+ });
57
+
58
+ test('uses project context when the detour target is defined in another file', () => {
59
+ const code = `
60
+ trail("entity.fallback", {
61
+ implementation: async (input, ctx) => {
62
+ throw new Error("boom");
63
+ }
64
+ })`;
65
+
66
+ const diagnostics = noThrowInDetourTarget.checkWithContext(
67
+ code,
68
+ TEST_FILE,
69
+ {
70
+ detourTargetTrailIds: new Set(['entity.fallback']),
71
+ knownTrailIds: new Set(['entity.show', 'entity.fallback']),
72
+ }
73
+ );
74
+
75
+ expect(diagnostics).toHaveLength(1);
76
+ expect(diagnostics[0]?.rule).toBe('no-throw-in-detour-target');
77
+ });
78
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { preferSchemaInference } from '../rules/prefer-schema-inference.js';
4
+
5
+ describe('prefer-schema-inference', () => {
6
+ test('warns when a fields label only repeats the derived humanized label', () => {
7
+ const code = `
8
+ trail("entity.show", {
9
+ input: z.object({ firstName: z.string() }),
10
+ fields: {
11
+ firstName: { label: "First Name" },
12
+ },
13
+ implementation: (input) => Result.ok(input),
14
+ })`;
15
+
16
+ const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
17
+
18
+ expect(diagnostics).toHaveLength(1);
19
+ expect(diagnostics[0]?.rule).toBe('prefer-schema-inference');
20
+ expect(diagnostics[0]?.message).toContain('firstName');
21
+ });
22
+
23
+ test('warns when enum options only repeat schema-derived values', () => {
24
+ const code = `
25
+ trail("entity.paint", {
26
+ input: z.object({
27
+ color: z.enum(["red", "green"]),
28
+ }),
29
+ fields: {
30
+ color: {
31
+ options: [{ value: "red" }, { value: "green" }],
32
+ },
33
+ },
34
+ implementation: (input) => Result.ok(input),
35
+ })`;
36
+
37
+ const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
38
+
39
+ expect(diagnostics).toHaveLength(1);
40
+ expect(diagnostics[0]?.message).toContain('schema-derived options');
41
+ });
42
+
43
+ test('allows custom labels and enriched enum options', () => {
44
+ const code = `
45
+ trail("entity.paint", {
46
+ input: z.object({
47
+ color: z.enum(["red", "green"]),
48
+ displayName: z.string().describe("Display name"),
49
+ }),
50
+ fields: {
51
+ color: {
52
+ options: [
53
+ { value: "red", label: "Red" },
54
+ { value: "green", hint: "Safe default" },
55
+ ],
56
+ },
57
+ displayName: { label: "Public name" },
58
+ },
59
+ implementation: (input) => Result.ok(input),
60
+ })`;
61
+
62
+ const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
63
+
64
+ expect(diagnostics).toHaveLength(0);
65
+ });
66
+
67
+ test('does not warn when the override carries other field metadata', () => {
68
+ const code = `
69
+ trail("entity.show", {
70
+ input: z.object({ firstName: z.string() }),
71
+ fields: {
72
+ firstName: {
73
+ label: "First Name",
74
+ message: "Who should we greet?",
75
+ },
76
+ },
77
+ implementation: (input) => Result.ok(input),
78
+ })`;
79
+
80
+ const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
81
+
82
+ expect(diagnostics).toHaveLength(0);
83
+ });
84
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { contextNoSurfaceTypes } from '../rules/context-no-surface-types.js';
4
+ import { noDirectImplInRoute } from '../rules/no-direct-impl-in-route.js';
5
+ import { noThrowInImplementation } from '../rules/no-throw-in-implementation.js';
6
+ import { validDetourRefs } from '../rules/valid-detour-refs.js';
7
+
8
+ const TEST_FILE = 'test.ts';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // no-throw-in-implementation
12
+ // ---------------------------------------------------------------------------
13
+ describe('no-throw-in-implementation', () => {
14
+ test('flags throw inside implementation body', () => {
15
+ const code = `
16
+ trail("entity.show", {
17
+ implementation: async (input, ctx) => {
18
+ throw new Error("boom");
19
+ }
20
+ })`;
21
+ const diagnostics = noThrowInImplementation.check(code, TEST_FILE);
22
+ expect(diagnostics.length).toBe(1);
23
+ expect(diagnostics[0]?.rule).toBe('no-throw-in-implementation');
24
+ expect(diagnostics[0]?.severity).toBe('error');
25
+ });
26
+
27
+ test('allows Result.err() in implementation', () => {
28
+ const code = `
29
+ trail("entity.show", {
30
+ implementation: async (input, ctx) => {
31
+ return Result.err(new NotFoundError("not found"));
32
+ }
33
+ })`;
34
+ const diagnostics = noThrowInImplementation.check(code, TEST_FILE);
35
+ expect(diagnostics.length).toBe(0);
36
+ });
37
+
38
+ test('does not flag throw outside implementation', () => {
39
+ const code = `
40
+ function helper() {
41
+ throw new Error("boom");
42
+ }
43
+
44
+ trail("entity.show", {
45
+ implementation: async (input, ctx) => {
46
+ return Result.ok(data);
47
+ }
48
+ })`;
49
+ const diagnostics = noThrowInImplementation.check(code, TEST_FILE);
50
+ expect(diagnostics.length).toBe(0);
51
+ });
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // context-no-surface-types
56
+ // ---------------------------------------------------------------------------
57
+ describe('context-no-surface-types', () => {
58
+ test('flags express import in trail file', () => {
59
+ const code = `
60
+ import { Request, Response } from "express";
61
+ trail("entity.show", {
62
+ implementation: async (input, ctx) => {
63
+ return Result.ok(data);
64
+ }
65
+ })`;
66
+ const diagnostics = contextNoSurfaceTypes.check(code, TEST_FILE);
67
+ expect(diagnostics.length).toBe(1);
68
+ expect(diagnostics[0]?.rule).toBe('context-no-surface-types');
69
+ expect(diagnostics[0]?.message).toContain('express');
70
+ });
71
+
72
+ test('flags McpSession import in trail file', () => {
73
+ const code = `
74
+ import type { McpSession } from "@modelcontextprotocol/sdk";
75
+ trail("entity.show", {
76
+ implementation: async (input, ctx) => {
77
+ return Result.ok(data);
78
+ }
79
+ })`;
80
+ const diagnostics = contextNoSurfaceTypes.check(code, TEST_FILE);
81
+ expect(diagnostics.length).toBe(1);
82
+ });
83
+
84
+ test('allows @ontrails/core imports in trail file', () => {
85
+ const code = `
86
+ import { trail, Result } from "@ontrails/core";
87
+ trail("entity.show", {
88
+ implementation: async (input, ctx) => {
89
+ return Result.ok(data);
90
+ }
91
+ })`;
92
+ const diagnostics = contextNoSurfaceTypes.check(code, TEST_FILE);
93
+ expect(diagnostics.length).toBe(0);
94
+ });
95
+
96
+ test('ignores files without trail() calls', () => {
97
+ const code = `
98
+ import { Request, Response } from "express";
99
+ export function handleRequest(req: Request, res: Response) {}`;
100
+ const diagnostics = contextNoSurfaceTypes.check(code, TEST_FILE);
101
+ expect(diagnostics.length).toBe(0);
102
+ });
103
+ });
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // valid-detour-refs
107
+ // ---------------------------------------------------------------------------
108
+ describe('valid-detour-refs', () => {
109
+ test('flags detour target that does not exist', () => {
110
+ const code = `
111
+ trail("entity.show", {
112
+ detours: [{ target: "entity.edit" }],
113
+ implementation: async (input, ctx) => Result.ok(data)
114
+ })`;
115
+ const diagnostics = validDetourRefs.check(code, TEST_FILE);
116
+ expect(diagnostics.length).toBe(1);
117
+ expect(diagnostics[0]?.message).toContain('entity.edit');
118
+ });
119
+
120
+ test('passes when detour target exists', () => {
121
+ const code = `
122
+ trail("entity.edit", {
123
+ implementation: async (input, ctx) => Result.ok(data)
124
+ })
125
+
126
+ trail("entity.show", {
127
+ detours: [{ target: "entity.edit" }],
128
+ implementation: async (input, ctx) => Result.ok(data)
129
+ })`;
130
+ const diagnostics = validDetourRefs.check(code, TEST_FILE);
131
+ expect(diagnostics.length).toBe(0);
132
+ });
133
+
134
+ test('uses project context when available', () => {
135
+ const code = `
136
+ trail("entity.show", {
137
+ detours: [{ target: "entity.edit" }],
138
+ implementation: async (input, ctx) => Result.ok(data)
139
+ })`;
140
+ const context = { knownTrailIds: new Set(['entity.show', 'entity.edit']) };
141
+ const diagnostics = validDetourRefs.checkWithContext(
142
+ code,
143
+ TEST_FILE,
144
+ context
145
+ );
146
+ expect(diagnostics.length).toBe(0);
147
+ });
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // no-direct-impl-in-route
152
+ // ---------------------------------------------------------------------------
153
+ describe('no-direct-impl-in-route', () => {
154
+ test('warns on direct .implementation() call in route', () => {
155
+ const code = `
156
+ hike("entity.onboard", {
157
+ follows: ["entity.create"],
158
+ implementation: async (input, ctx) => {
159
+ const result = await entityCreate.implementation(data);
160
+ return Result.ok(result);
161
+ }
162
+ })`;
163
+ const diagnostics = noDirectImplInRoute.check(code, TEST_FILE);
164
+ expect(diagnostics.length).toBe(1);
165
+ expect(diagnostics[0]?.severity).toBe('warn');
166
+ expect(diagnostics[0]?.message).toContain('ctx.follow');
167
+ });
168
+
169
+ test('allows ctx.follow() calls', () => {
170
+ const code = `
171
+ hike("entity.onboard", {
172
+ follows: ["entity.create"],
173
+ implementation: async (input, ctx) => {
174
+ const result = await ctx.follow("entity.create", data);
175
+ return Result.ok(result);
176
+ }
177
+ })`;
178
+ const diagnostics = noDirectImplInRoute.check(code, TEST_FILE);
179
+ expect(diagnostics.length).toBe(0);
180
+ });
181
+
182
+ test('ignores files without hike() calls', () => {
183
+ const code = `
184
+ const result = await someTrail.implementation(data);`;
185
+ const diagnostics = noDirectImplInRoute.check(code, TEST_FILE);
186
+ expect(diagnostics.length).toBe(0);
187
+ });
188
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { validDescribeRefs } from '../rules/valid-describe-refs.js';
4
+
5
+ describe('valid-describe-refs', () => {
6
+ test('warns when a describe @see tag points to a missing trail', () => {
7
+ const code = `
8
+ trail("entity.show", {
9
+ input: z.object({
10
+ query: z.string().describe("Search query. @see entity.search"),
11
+ }),
12
+ implementation: (input) => Result.ok(input),
13
+ })`;
14
+
15
+ const diagnostics = validDescribeRefs.check(code, 'src/entity.ts');
16
+
17
+ expect(diagnostics).toHaveLength(1);
18
+ expect(diagnostics[0]?.rule).toBe('valid-describe-refs');
19
+ expect(diagnostics[0]?.message).toContain('entity.search');
20
+ });
21
+
22
+ test('allows local @see references that resolve in the same file', () => {
23
+ const code = `
24
+ trail("entity.search", {
25
+ input: z.object({ query: z.string() }),
26
+ implementation: (input) => Result.ok(input),
27
+ })
28
+
29
+ trail("entity.show", {
30
+ input: z.object({
31
+ query: z.string().describe("Search query. @see entity.search"),
32
+ }),
33
+ implementation: (input) => Result.ok(input),
34
+ })`;
35
+
36
+ const diagnostics = validDescribeRefs.check(code, 'src/entity.ts');
37
+
38
+ expect(diagnostics).toHaveLength(0);
39
+ });
40
+
41
+ test('uses project context for cross-file @see references', () => {
42
+ const code = `
43
+ trail("entity.show", {
44
+ input: z.object({
45
+ query: z.string().describe("Search query. @see entity.search"),
46
+ }),
47
+ implementation: (input) => Result.ok(input),
48
+ })`;
49
+
50
+ const diagnostics = validDescribeRefs.checkWithContext(
51
+ code,
52
+ 'src/entity.ts',
53
+ {
54
+ knownTrailIds: new Set(['entity.search', 'entity.show']),
55
+ }
56
+ );
57
+
58
+ expect(diagnostics).toHaveLength(0);
59
+ });
60
+ });