@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,217 @@
1
+ /**
2
+ * Shared AST utilities for warden rules.
3
+ *
4
+ * Uses oxc-parser for native-speed TypeScript parsing. Provides a lightweight
5
+ * walker and helpers for finding trail implementation bodies.
6
+ */
7
+
8
+ import { parseSync } from 'oxc-parser';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types (minimal, avoiding full @oxc-project/types dep)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface AstNode {
15
+ readonly type: string;
16
+ readonly start: number;
17
+ readonly end: number;
18
+ readonly key?: { readonly name?: string };
19
+ readonly value?: AstNode;
20
+ readonly body?: AstNode | readonly AstNode[];
21
+ readonly [key: string]: unknown;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Parser
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Parse TypeScript source into an AST. Returns null on parse failure. */
29
+ export const parse = (filePath: string, sourceCode: string): AstNode | null => {
30
+ try {
31
+ const result = parseSync(filePath, sourceCode, { sourceType: 'module' });
32
+ return result.program as unknown as AstNode;
33
+ } catch {
34
+ return null;
35
+ }
36
+ };
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Walker
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** Walk an AST node tree, calling `visit` on every node. */
43
+ export const walk = (node: unknown, visit: (node: AstNode) => void): void => {
44
+ if (!node || typeof node !== 'object') {
45
+ return;
46
+ }
47
+ const n = node as AstNode;
48
+ if (n.type) {
49
+ visit(n);
50
+ }
51
+ for (const val of Object.values(n)) {
52
+ if (Array.isArray(val)) {
53
+ for (const item of val) {
54
+ walk(item, visit);
55
+ }
56
+ } else if (val && typeof val === 'object' && (val as AstNode).type) {
57
+ walk(val, visit);
58
+ }
59
+ }
60
+ };
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /** Find the byte offset's line number (1-based) in source code. */
67
+ export const offsetToLine = (sourceCode: string, offset: number): number => {
68
+ let line = 1;
69
+ for (let i = 0; i < offset && i < sourceCode.length; i += 1) {
70
+ if (sourceCode[i] === '\n') {
71
+ line += 1;
72
+ }
73
+ }
74
+ return line;
75
+ };
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);
87
+ }
88
+ });
89
+ return bodies;
90
+ };
91
+
92
+ export interface TrailDefinition {
93
+ /** Trail ID string, e.g. "entity.show" */
94
+ readonly id: string;
95
+ /** "trail" or "hike" */
96
+ readonly kind: string;
97
+ /** The config object argument (second arg to trail/hike call) */
98
+ readonly config: AstNode;
99
+ /** Start offset of the call expression */
100
+ readonly start: number;
101
+ }
102
+
103
+ /**
104
+ * Find all `trail("id", { ... })` and `hike("id", { ... })` call sites.
105
+ *
106
+ * Returns the trail ID, kind, and config object node for each definition.
107
+ */
108
+ const TRAIL_CALLEE_NAMES = new Set(['trail', 'hike']);
109
+
110
+ const getTrailCalleeName = (node: AstNode): string | null => {
111
+ if (node.type !== 'CallExpression') {
112
+ return null;
113
+ }
114
+ const callee = node['callee'] as AstNode | undefined;
115
+ if (!callee || callee.type !== 'Identifier') {
116
+ return null;
117
+ }
118
+ const { name } = callee as unknown as { name?: string };
119
+ return name && TRAIL_CALLEE_NAMES.has(name) ? name : null;
120
+ };
121
+
122
+ const extractTrailArgs = (
123
+ node: AstNode
124
+ ): { idArg: AstNode; configArg: AstNode } | null => {
125
+ const args = node['arguments'] as readonly AstNode[] | undefined;
126
+ if (!args || args.length < 2) {
127
+ return null;
128
+ }
129
+ const [idArg, configArg] = args;
130
+ if (!idArg || !configArg) {
131
+ return null;
132
+ }
133
+ return { configArg, idArg };
134
+ };
135
+
136
+ const extractTrailDefinition = (node: AstNode): TrailDefinition | null => {
137
+ const calleeName = getTrailCalleeName(node);
138
+ if (!calleeName) {
139
+ return null;
140
+ }
141
+
142
+ const trailArgs = extractTrailArgs(node);
143
+ if (!trailArgs) {
144
+ return null;
145
+ }
146
+
147
+ const trailId = (trailArgs.idArg as unknown as { value?: string }).value;
148
+ if (!trailId) {
149
+ return null;
150
+ }
151
+
152
+ return {
153
+ config: trailArgs.configArg,
154
+ id: trailId,
155
+ kind: calleeName,
156
+ start: node.start,
157
+ };
158
+ };
159
+
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
+ export const findTrailDefinitions = (ast: AstNode): TrailDefinition[] => {
183
+ const definitions: TrailDefinition[] = [];
184
+
185
+ walk(ast, (node) => {
186
+ const def = extractTrailDefinition(node);
187
+ if (def) {
188
+ definitions.push(def);
189
+ }
190
+ });
191
+
192
+ return definitions;
193
+ };
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Config property extraction helpers
197
+ // ---------------------------------------------------------------------------
198
+
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
+ }
207
+ const properties = config['properties'] as readonly AstNode[] | undefined;
208
+ if (!properties) {
209
+ return null;
210
+ }
211
+ for (const prop of properties) {
212
+ if (prop.type === 'Property' && prop.key?.name === propertyName) {
213
+ return prop;
214
+ }
215
+ }
216
+ return null;
217
+ };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Detects imports of surface-specific modules and types in trail files.
3
+ *
4
+ * Uses AST parsing for accurate detection — no false positives from
5
+ * imports in comments or strings.
6
+ */
7
+
8
+ import { offsetToLine, parse, walk } from './ast.js';
9
+ import type { WardenDiagnostic, WardenRule } from './types.js';
10
+
11
+ const SURFACE_MODULES = new Set([
12
+ 'express',
13
+ 'hono',
14
+ 'fastify',
15
+ '@modelcontextprotocol/sdk',
16
+ 'node:http',
17
+ 'node:https',
18
+ '@hono/node-server',
19
+ 'koa',
20
+ ]);
21
+
22
+ const SURFACE_TYPE_NAMES = new Set([
23
+ 'Request',
24
+ 'Response',
25
+ 'NextFunction',
26
+ 'McpSession',
27
+ 'McpCallToolRequest',
28
+ 'IncomingMessage',
29
+ 'ServerResponse',
30
+ ]);
31
+
32
+ interface AstNode {
33
+ readonly type: string;
34
+ readonly start: number;
35
+ readonly [key: string]: unknown;
36
+ }
37
+
38
+ interface ImportSpecifier {
39
+ readonly local?: { readonly name?: string };
40
+ readonly imported?: { readonly name?: string };
41
+ }
42
+
43
+ const makeDiag = (
44
+ filePath: string,
45
+ sourceCode: string,
46
+ node: AstNode,
47
+ message: string
48
+ ): WardenDiagnostic => ({
49
+ filePath,
50
+ line: offsetToLine(sourceCode, node.start),
51
+ message,
52
+ rule: 'context-no-surface-types',
53
+ severity: 'error',
54
+ });
55
+
56
+ const findSurfaceTypeName = (
57
+ specifiers: readonly ImportSpecifier[]
58
+ ): string | undefined => {
59
+ for (const spec of specifiers) {
60
+ const name = spec.imported?.name ?? spec.local?.name;
61
+ if (name && SURFACE_TYPE_NAMES.has(name)) {
62
+ return name;
63
+ }
64
+ }
65
+ return undefined;
66
+ };
67
+
68
+ const getImportModuleName = (node: AstNode): string | null => {
69
+ if (node.type !== 'ImportDeclaration') {
70
+ return null;
71
+ }
72
+ const source = node['source'] as { readonly value?: string } | undefined;
73
+ return source?.value ?? null;
74
+ };
75
+
76
+ const checkSpecifiersForSurfaceTypes = (
77
+ node: AstNode,
78
+ filePath: string,
79
+ sourceCode: string
80
+ ): WardenDiagnostic | undefined => {
81
+ const specifiers = node['specifiers'] as
82
+ | readonly ImportSpecifier[]
83
+ | undefined;
84
+ if (!specifiers) {
85
+ return undefined;
86
+ }
87
+ const typeName = findSurfaceTypeName(specifiers);
88
+ if (!typeName) {
89
+ return undefined;
90
+ }
91
+ return makeDiag(
92
+ filePath,
93
+ sourceCode,
94
+ node,
95
+ `Do not import surface type "${typeName}" in trail implementation files.`
96
+ );
97
+ };
98
+
99
+ const classifyImport = (
100
+ node: AstNode,
101
+ filePath: string,
102
+ sourceCode: string
103
+ ): WardenDiagnostic | undefined => {
104
+ const moduleName = getImportModuleName(node);
105
+ if (!moduleName) {
106
+ return undefined;
107
+ }
108
+
109
+ if (SURFACE_MODULES.has(moduleName)) {
110
+ return makeDiag(
111
+ filePath,
112
+ sourceCode,
113
+ node,
114
+ `Do not import from surface module "${moduleName}" in trail implementation files.`
115
+ );
116
+ }
117
+
118
+ return checkSpecifiersForSurfaceTypes(node, filePath, sourceCode);
119
+ };
120
+
121
+ /**
122
+ * Detects imports of surface-specific types in trail implementation files.
123
+ */
124
+ export const contextNoSurfaceTypes: WardenRule = {
125
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
126
+ if (!/\b(?:trail|hike)\s*\(/.test(sourceCode)) {
127
+ return [];
128
+ }
129
+
130
+ const ast = parse(filePath, sourceCode);
131
+ if (!ast) {
132
+ return [];
133
+ }
134
+
135
+ const diagnostics: WardenDiagnostic[] = [];
136
+ walk(ast, (node) => {
137
+ const diag = classifyImport(node as AstNode, filePath, sourceCode);
138
+ if (diag) {
139
+ diagnostics.push(diag);
140
+ }
141
+ });
142
+
143
+ return diagnostics;
144
+ },
145
+ description:
146
+ 'Disallow surface-specific type imports (Request, Response, McpSession, etc.) in trail implementation files.',
147
+ name: 'context-no-surface-types',
148
+
149
+ severity: 'error',
150
+ };
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Finds implementations that return raw values instead of `Result`.
3
+ *
4
+ * Uses AST parsing to find `implementation:` bodies and check that
5
+ * every return statement returns Result.ok(), Result.err(), ctx.follow(),
6
+ * or a tracked Result-typed variable.
7
+ */
8
+
9
+ import {
10
+ findImplementationBodies,
11
+ findTrailDefinitions,
12
+ offsetToLine,
13
+ parse,
14
+ walk,
15
+ } from './ast.js';
16
+ import { isTestFile } from './scan.js';
17
+ import type { WardenDiagnostic, WardenRule } from './types.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ interface AstNode {
24
+ readonly type: string;
25
+ readonly start: number;
26
+ readonly end: number;
27
+ readonly [key: string]: unknown;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Member expression helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Extract object.property names from a MemberExpression callee. */
35
+ const extractMemberNames = (
36
+ callee: AstNode
37
+ ): { objName: string | undefined; propName: string | undefined } => {
38
+ const obj = (callee as unknown as { object?: AstNode }).object;
39
+ const prop = (callee as unknown as { property?: AstNode }).property;
40
+ const objName =
41
+ obj?.type === 'Identifier'
42
+ ? (obj as unknown as { name: string }).name
43
+ : undefined;
44
+ const propName =
45
+ prop?.type === 'Identifier'
46
+ ? (prop as unknown as { name: string }).name
47
+ : undefined;
48
+ return { objName, propName };
49
+ };
50
+
51
+ const isMemberExpression = (callee: AstNode): boolean =>
52
+ callee.type === 'StaticMemberExpression' ||
53
+ callee.type === 'MemberExpression';
54
+
55
+ const isResultMemberCall = (callee: AstNode): boolean => {
56
+ if (!isMemberExpression(callee)) {
57
+ return false;
58
+ }
59
+ const { objName, propName } = extractMemberNames(callee);
60
+ if (objName === 'Result' && (propName === 'ok' || propName === 'err')) {
61
+ return true;
62
+ }
63
+ if (objName === 'ctx' && propName === 'follow') {
64
+ return true;
65
+ }
66
+ return propName === 'implementation';
67
+ };
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Expression classification
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** Check if an expression node is an allowed Result-returning expression. */
74
+ const isResultExpression = (node: AstNode): boolean => {
75
+ if (node.type === 'CallExpression') {
76
+ const callee = node['callee'] as AstNode | undefined;
77
+ if (!callee) {
78
+ return false;
79
+ }
80
+ return isResultMemberCall(callee);
81
+ }
82
+
83
+ if (node.type === 'AwaitExpression') {
84
+ const arg = (node as unknown as { argument?: AstNode }).argument;
85
+ return arg ? isResultExpression(arg) : false;
86
+ }
87
+
88
+ return false;
89
+ };
90
+
91
+ /** Check if a node is a call to a known Result-returning helper. */
92
+ const isHelperCall = (
93
+ node: AstNode,
94
+ helperNames: ReadonlySet<string>
95
+ ): boolean => {
96
+ const target =
97
+ node.type === 'AwaitExpression'
98
+ ? ((node as unknown as { argument?: AstNode }).argument ?? null)
99
+ : node;
100
+
101
+ if (!target || target.type !== 'CallExpression') {
102
+ return false;
103
+ }
104
+
105
+ const callee = target['callee'] as AstNode | undefined;
106
+ if (callee?.type === 'Identifier') {
107
+ const { name } = callee as unknown as { name: string };
108
+ return helperNames.has(name);
109
+ }
110
+
111
+ return false;
112
+ };
113
+
114
+ /** Unwrap an optional AwaitExpression to get the inner identifier name. */
115
+ const resolveIdentifierName = (node: AstNode): string | null => {
116
+ if (node.type === 'Identifier') {
117
+ return (node as unknown as { name: string }).name;
118
+ }
119
+ if (node.type === 'AwaitExpression') {
120
+ const inner = (node as unknown as { argument?: AstNode }).argument;
121
+ if (inner?.type === 'Identifier') {
122
+ return (inner as unknown as { name: string }).name;
123
+ }
124
+ }
125
+ return null;
126
+ };
127
+
128
+ /** Check if a return argument is an allowed Result value. */
129
+ const isAllowedReturnArgument = (
130
+ argument: AstNode,
131
+ helperNames: ReadonlySet<string>,
132
+ resultVars: ReadonlySet<string>
133
+ ): boolean => {
134
+ if (isResultExpression(argument)) {
135
+ return true;
136
+ }
137
+ if (isHelperCall(argument, helperNames)) {
138
+ return true;
139
+ }
140
+
141
+ const varName = resolveIdentifierName(argument);
142
+ return varName !== null && resultVars.has(varName);
143
+ };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Variable tracking
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /** Track a VariableDeclarator, adding to resultVars if it produces a Result. */
150
+ const trackResultVariable = (node: AstNode, resultVars: Set<string>): void => {
151
+ const { init } = node as unknown as { init?: AstNode };
152
+ const { id } = node as unknown as { id?: AstNode };
153
+ if (init && id?.type === 'Identifier') {
154
+ const { name } = id as unknown as { name: string };
155
+ if (isResultExpression(init)) {
156
+ resultVars.add(name);
157
+ }
158
+ }
159
+ };
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Return statement checking
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** Check return statements in a block body for non-Result returns. */
166
+ const checkReturnStatements = (
167
+ blockBody: AstNode,
168
+ trailInfo: { id: string; label: string },
169
+ filePath: string,
170
+ sourceCode: string,
171
+ helperNames: ReadonlySet<string>,
172
+ diagnostics: WardenDiagnostic[]
173
+ ): void => {
174
+ const resultVars = new Set<string>();
175
+
176
+ walk(blockBody, (node) => {
177
+ if (node.type === 'VariableDeclarator') {
178
+ trackResultVariable(node, resultVars);
179
+ }
180
+
181
+ if (node.type !== 'ReturnStatement') {
182
+ return;
183
+ }
184
+
185
+ const { argument } = node as unknown as { argument?: AstNode };
186
+ // Bare return — not a value return
187
+ if (!argument) {
188
+ return;
189
+ }
190
+
191
+ if (isAllowedReturnArgument(argument, helperNames, resultVars)) {
192
+ return;
193
+ }
194
+
195
+ diagnostics.push({
196
+ filePath,
197
+ line: offsetToLine(sourceCode, node.start),
198
+ message: `${trailInfo.label} "${trailInfo.id}" implementation must return Result.ok(...) or Result.err(...), not a raw value.`,
199
+ rule: 'implementation-returns-result',
200
+ severity: 'error',
201
+ });
202
+ });
203
+ };
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Result helper name collection
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /** Check if a return type annotation mentions Result. */
210
+ const hasResultReturnType = (node: AstNode, sourceCode: string): boolean => {
211
+ const { returnType } = node as unknown as { returnType?: AstNode };
212
+ if (!returnType) {
213
+ return false;
214
+ }
215
+ const annotationText = sourceCode.slice(returnType.start, returnType.end);
216
+ return /\bResult\s*</.test(annotationText);
217
+ };
218
+
219
+ const isFunctionLikeExpression = (node: AstNode): boolean =>
220
+ node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression';
221
+
222
+ /** Collect names of top-level functions/consts with explicit Result return types. */
223
+ const collectResultHelperNames = (
224
+ ast: AstNode,
225
+ sourceCode: string
226
+ ): ReadonlySet<string> => {
227
+ const names = new Set<string>();
228
+
229
+ walk(ast, (node) => {
230
+ if (node.type === 'VariableDeclarator') {
231
+ const { id } = node as unknown as { id?: AstNode };
232
+ const { init } = node as unknown as { init?: AstNode };
233
+ if (
234
+ id?.type === 'Identifier' &&
235
+ init &&
236
+ isFunctionLikeExpression(init) &&
237
+ hasResultReturnType(init, sourceCode)
238
+ ) {
239
+ names.add((id as unknown as { name: string }).name);
240
+ }
241
+ }
242
+
243
+ if (node.type === 'FunctionDeclaration') {
244
+ const { id } = node as unknown as { id?: AstNode };
245
+ if (id?.type === 'Identifier' && hasResultReturnType(node, sourceCode)) {
246
+ names.add((id as unknown as { name: string }).name);
247
+ }
248
+ }
249
+ });
250
+
251
+ return names;
252
+ };
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Per-implementation checking
256
+ // ---------------------------------------------------------------------------
257
+
258
+ const checkImplementation = (
259
+ implValue: AstNode,
260
+ info: { id: string; label: string },
261
+ filePath: string,
262
+ sourceCode: string,
263
+ helperNames: ReadonlySet<string>,
264
+ diagnostics: WardenDiagnostic[]
265
+ ): void => {
266
+ const fnBody = (implValue as unknown as { body?: AstNode }).body;
267
+ if (!fnBody) {
268
+ return;
269
+ }
270
+
271
+ if (fnBody.type === 'BlockStatement' || fnBody.type === 'FunctionBody') {
272
+ checkReturnStatements(
273
+ fnBody,
274
+ info,
275
+ filePath,
276
+ sourceCode,
277
+ helperNames,
278
+ diagnostics
279
+ );
280
+ return;
281
+ }
282
+
283
+ if (!isResultExpression(fnBody) && !isHelperCall(fnBody, helperNames)) {
284
+ diagnostics.push({
285
+ filePath,
286
+ line: offsetToLine(sourceCode, implValue.start),
287
+ message: `${info.label} "${info.id}" implementation must return Result.ok(...) or Result.err(...), not a raw value.`,
288
+ rule: 'implementation-returns-result',
289
+ severity: 'error',
290
+ });
291
+ }
292
+ };
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Rule
296
+ // ---------------------------------------------------------------------------
297
+
298
+ const checkAllDefinitions = (
299
+ ast: AstNode,
300
+ filePath: string,
301
+ sourceCode: string
302
+ ): WardenDiagnostic[] => {
303
+ const diagnostics: WardenDiagnostic[] = [];
304
+ const helperNames = collectResultHelperNames(ast, sourceCode);
305
+
306
+ 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)) {
309
+ checkImplementation(
310
+ implValue,
311
+ info,
312
+ filePath,
313
+ sourceCode,
314
+ helperNames,
315
+ diagnostics
316
+ );
317
+ }
318
+ }
319
+
320
+ return diagnostics;
321
+ };
322
+
323
+ /**
324
+ * Finds implementations that return raw values instead of `Result`.
325
+ */
326
+ export const implementationReturnsResult: WardenRule = {
327
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
328
+ if (isTestFile(filePath)) {
329
+ return [];
330
+ }
331
+
332
+ const ast = parse(filePath, sourceCode);
333
+ if (!ast) {
334
+ return [];
335
+ }
336
+
337
+ return checkAllDefinitions(ast as AstNode, filePath, sourceCode);
338
+ },
339
+ description:
340
+ 'Disallow implementations that return raw values instead of Result.ok() or Result.err().',
341
+ name: 'implementation-returns-result',
342
+ severity: 'error',
343
+ };