@ontrails/warden 0.2.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 (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
@@ -0,0 +1,382 @@
1
+ import { getMemberExpression } from './source/stores.js';
2
+ import {
3
+ collectScopeFrameBindings,
4
+ findImplementationBodies,
5
+ findTrailDefinitions,
6
+ getNodeArgument,
7
+ getNodeArguments,
8
+ getNodeBodyNode,
9
+ getNodeCallee,
10
+ getNodeId,
11
+ getNodeInit,
12
+ getNodeLeft,
13
+ getNodeOperator,
14
+ getNodeProperty,
15
+ getNodeRight,
16
+ identifierName,
17
+ isMemberAccessNonComputed,
18
+ offsetToLine,
19
+ parse,
20
+ walkWithParents,
21
+ walkWithScopes,
22
+ } from '@ontrails/source';
23
+ import type { AstNode, AstParentContext } from '@ontrails/source';
24
+ import {
25
+ collectAllResultHelperNames,
26
+ collectDirectResultAssignments,
27
+ collectNamespaceHelperImports,
28
+ collectResultTypeNames,
29
+ findNearestBindingScope,
30
+ isResultProducingExpression,
31
+ trackScopedResultHelperDeclaration,
32
+ } from './implementation-returns-result.js';
33
+ import { isTestFile } from './scan.js';
34
+ import type {
35
+ MutableScopedHelperMap,
36
+ NamespaceHelperMap,
37
+ ScopedHelperMap,
38
+ } from './implementation-returns-result.js';
39
+ import type { WardenDiagnostic, WardenRule } from './types.js';
40
+
41
+ const RULE_NAME = 'no-redundant-result-error-wrap';
42
+
43
+ const getStaticMemberName = (node: AstNode | undefined): string | null => {
44
+ if (!node || !isMemberAccessNonComputed(node)) {
45
+ return null;
46
+ }
47
+ return identifierName(getNodeProperty(node));
48
+ };
49
+
50
+ const isResultObject = (node: AstNode | undefined): boolean => {
51
+ if (!node) {
52
+ return false;
53
+ }
54
+ if (identifierName(node) === 'Result') {
55
+ return true;
56
+ }
57
+ return getStaticMemberName(node) === 'Result';
58
+ };
59
+
60
+ const isResultErrCall = (node: AstNode): boolean => {
61
+ if (node.type !== 'CallExpression') {
62
+ return false;
63
+ }
64
+ const callee = getNodeCallee(node);
65
+ const member = getMemberExpression(callee);
66
+ if (!member || getStaticMemberName(callee) !== 'err') {
67
+ return false;
68
+ }
69
+ return isResultObject(member.object);
70
+ };
71
+
72
+ const getSingleArgument = (node: AstNode): AstNode | null => {
73
+ const args = getNodeArguments(node);
74
+ return args?.length === 1 ? (args[0] ?? null) : null;
75
+ };
76
+
77
+ const getErrorSourceVariable = (node: AstNode | null): string | null => {
78
+ if (!node || getStaticMemberName(node) !== 'error') {
79
+ return null;
80
+ }
81
+ const member = getMemberExpression(node);
82
+ return identifierName(member?.object);
83
+ };
84
+
85
+ type ResultProvenance = Map<ReadonlySet<string>, Set<string>>;
86
+
87
+ const markResultVariable = (
88
+ provenance: ResultProvenance,
89
+ scope: ReadonlySet<string>,
90
+ name: string
91
+ ): void => {
92
+ const names = provenance.get(scope);
93
+ if (names) {
94
+ names.add(name);
95
+ return;
96
+ }
97
+ provenance.set(scope, new Set([name]));
98
+ };
99
+
100
+ const clearResultVariable = (
101
+ provenance: ResultProvenance,
102
+ scope: ReadonlySet<string>,
103
+ name: string
104
+ ): void => {
105
+ provenance.get(scope)?.delete(name);
106
+ };
107
+
108
+ const hasResultProvenance = (
109
+ provenance: ResultProvenance,
110
+ scope: ReadonlySet<string>,
111
+ name: string
112
+ ): boolean => provenance.get(scope)?.has(name) ?? false;
113
+
114
+ const createDiagnostic = (
115
+ filePath: string,
116
+ sourceCode: string,
117
+ node: AstNode,
118
+ ownerLabel: string,
119
+ variableName: string
120
+ ): WardenDiagnostic => ({
121
+ filePath,
122
+ line: offsetToLine(sourceCode, node.start),
123
+ message: `${ownerLabel}: Result.err(${variableName}.error) re-wraps a Result that already carries that error. Return ${variableName} directly to preserve the original Result boundary.`,
124
+ rule: RULE_NAME,
125
+ severity: 'warn',
126
+ });
127
+
128
+ const trackVariableDeclarator = (
129
+ node: AstNode,
130
+ provenance: ResultProvenance,
131
+ helperNames: ReadonlySet<string>,
132
+ namespaceHelpers: NamespaceHelperMap,
133
+ scopedHelpers: ScopedHelperMap,
134
+ scopes: readonly ReadonlySet<string>[]
135
+ ): void => {
136
+ const id = getNodeId(node);
137
+ const init = getNodeInit(node);
138
+ const name = identifierName(id);
139
+ if (!name) {
140
+ return;
141
+ }
142
+ const scope = findNearestBindingScope(name, scopes);
143
+ if (!scope) {
144
+ return;
145
+ }
146
+ if (
147
+ init &&
148
+ isResultProducingExpression(
149
+ init,
150
+ helperNames,
151
+ provenance,
152
+ namespaceHelpers,
153
+ scopes,
154
+ scopedHelpers
155
+ )
156
+ ) {
157
+ markResultVariable(provenance, scope, name);
158
+ return;
159
+ }
160
+ clearResultVariable(provenance, scope, name);
161
+ };
162
+
163
+ const trackAssignmentExpression = (
164
+ node: AstNode,
165
+ provenance: ResultProvenance,
166
+ helperNames: ReadonlySet<string>,
167
+ namespaceHelpers: NamespaceHelperMap,
168
+ scopedHelpers: ScopedHelperMap,
169
+ directAssignments: ReadonlySet<AstNode>,
170
+ scopes: readonly ReadonlySet<string>[]
171
+ ): void => {
172
+ const left = getNodeLeft(node);
173
+ const name = identifierName(left);
174
+ if (!name) {
175
+ return;
176
+ }
177
+ const scope = findNearestBindingScope(name, scopes);
178
+ if (!scope) {
179
+ return;
180
+ }
181
+ const right = getNodeRight(node);
182
+ const resultRhs =
183
+ getNodeOperator(node) === '=' &&
184
+ right &&
185
+ isResultProducingExpression(
186
+ right,
187
+ helperNames,
188
+ provenance,
189
+ namespaceHelpers,
190
+ scopes,
191
+ scopedHelpers
192
+ );
193
+ if (
194
+ resultRhs &&
195
+ (hasResultProvenance(provenance, scope, name) ||
196
+ directAssignments.has(node))
197
+ ) {
198
+ markResultVariable(provenance, scope, name);
199
+ return;
200
+ }
201
+ clearResultVariable(provenance, scope, name);
202
+ };
203
+
204
+ const checkReturnStatement = (
205
+ node: AstNode,
206
+ provenance: ResultProvenance,
207
+ scopes: readonly ReadonlySet<string>[],
208
+ filePath: string,
209
+ sourceCode: string,
210
+ ownerLabel: string,
211
+ diagnostics: WardenDiagnostic[]
212
+ ): void => {
213
+ const argument = getNodeArgument(node);
214
+ if (!argument || !isResultErrCall(argument)) {
215
+ return;
216
+ }
217
+ const variableName = getErrorSourceVariable(getSingleArgument(argument));
218
+ if (!variableName) {
219
+ return;
220
+ }
221
+ const scope = findNearestBindingScope(variableName, scopes);
222
+ if (!scope || !hasResultProvenance(provenance, scope, variableName)) {
223
+ return;
224
+ }
225
+ diagnostics.push(
226
+ createDiagnostic(filePath, sourceCode, argument, ownerLabel, variableName)
227
+ );
228
+ };
229
+
230
+ const functionBody = (node: AstNode): AstNode | null => {
231
+ const body = getNodeBodyNode(node);
232
+ return body &&
233
+ (body.type === 'BlockStatement' || body.type === 'FunctionBody')
234
+ ? body
235
+ : null;
236
+ };
237
+
238
+ const checkFunctionBody = (
239
+ owner: AstNode,
240
+ ownerLabel: string,
241
+ filePath: string,
242
+ sourceCode: string,
243
+ helperNames: ReadonlySet<string>,
244
+ namespaceHelpers: NamespaceHelperMap,
245
+ resultTypeNames: ReadonlySet<string>,
246
+ diagnostics: WardenDiagnostic[]
247
+ ): void => {
248
+ const body = functionBody(owner);
249
+ if (!body) {
250
+ return;
251
+ }
252
+
253
+ const provenance: ResultProvenance = new Map();
254
+ const scopedHelpers: MutableScopedHelperMap = new Map();
255
+ const implScope = collectScopeFrameBindings(owner);
256
+ const initialScopes = implScope.size > 0 ? [implScope] : [];
257
+ const directAssignments = collectDirectResultAssignments(body);
258
+
259
+ walkWithScopes(
260
+ body,
261
+ (node, scopes) => {
262
+ if (node.type === 'VariableDeclarator') {
263
+ trackScopedResultHelperDeclaration(
264
+ node,
265
+ scopes,
266
+ sourceCode,
267
+ resultTypeNames,
268
+ scopedHelpers
269
+ );
270
+ trackVariableDeclarator(
271
+ node,
272
+ provenance,
273
+ helperNames,
274
+ namespaceHelpers,
275
+ scopedHelpers,
276
+ scopes
277
+ );
278
+ return;
279
+ }
280
+ if (node.type === 'AssignmentExpression') {
281
+ trackAssignmentExpression(
282
+ node,
283
+ provenance,
284
+ helperNames,
285
+ namespaceHelpers,
286
+ scopedHelpers,
287
+ directAssignments,
288
+ scopes
289
+ );
290
+ return;
291
+ }
292
+ if (node.type === 'ReturnStatement') {
293
+ checkReturnStatement(
294
+ node,
295
+ provenance,
296
+ scopes,
297
+ filePath,
298
+ sourceCode,
299
+ ownerLabel,
300
+ diagnostics
301
+ );
302
+ }
303
+ },
304
+ { initialScopes, stopAtNestedFunctions: true }
305
+ );
306
+ };
307
+
308
+ const isFunctionLike = (node: AstNode): boolean =>
309
+ node.type === 'FunctionDeclaration' ||
310
+ node.type === 'FunctionExpression' ||
311
+ node.type === 'ArrowFunctionExpression';
312
+
313
+ const functionName = (node: AstNode, context: AstParentContext): string => {
314
+ const declaredName = identifierName(getNodeId(node));
315
+ if (declaredName) {
316
+ return declaredName;
317
+ }
318
+ if (context.parent?.type === 'VariableDeclarator') {
319
+ const assignedName = identifierName(getNodeId(context.parent));
320
+ if (assignedName) {
321
+ return assignedName;
322
+ }
323
+ }
324
+ return 'anonymous function';
325
+ };
326
+
327
+ const functionOwnerLabel = (node: AstNode, context: AstParentContext): string =>
328
+ `Function "${functionName(node, context)}"`;
329
+
330
+ export const noRedundantResultErrorWrap: WardenRule = {
331
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
332
+ if (isTestFile(filePath)) {
333
+ return [];
334
+ }
335
+
336
+ const ast = parse(filePath, sourceCode);
337
+ if (!ast) {
338
+ return [];
339
+ }
340
+
341
+ const diagnostics: WardenDiagnostic[] = [];
342
+ const helperNames = collectAllResultHelperNames(ast, sourceCode, filePath);
343
+ const namespaceHelpers = collectNamespaceHelperImports(ast, filePath);
344
+ const resultTypeNames = collectResultTypeNames(ast);
345
+ const implementationStarts = new Set<number>();
346
+ for (const def of findTrailDefinitions(ast)) {
347
+ for (const implementation of findImplementationBodies(def.config)) {
348
+ implementationStarts.add(implementation.start);
349
+ checkFunctionBody(
350
+ implementation,
351
+ `Trail "${def.id}"`,
352
+ filePath,
353
+ sourceCode,
354
+ helperNames,
355
+ namespaceHelpers,
356
+ resultTypeNames,
357
+ diagnostics
358
+ );
359
+ }
360
+ }
361
+ walkWithParents(ast, (node, context) => {
362
+ if (!isFunctionLike(node) || implementationStarts.has(node.start)) {
363
+ return;
364
+ }
365
+ checkFunctionBody(
366
+ node,
367
+ functionOwnerLabel(node, context),
368
+ filePath,
369
+ sourceCode,
370
+ helperNames,
371
+ namespaceHelpers,
372
+ resultTypeNames,
373
+ diagnostics
374
+ );
375
+ });
376
+ return diagnostics;
377
+ },
378
+ description:
379
+ 'Warn when code re-wraps an existing Result error with Result.err(x.error) instead of returning the Result directly.',
380
+ name: RULE_NAME,
381
+ severity: 'warn',
382
+ };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Flags retired `cross` composition vocabulary after the beta.19 compose cutover.
3
+ *
4
+ * Exact authored terms that have a mechanical successor carry safe
5
+ * `term-rewrite` edits. Larger identifiers and type prefixes remain
6
+ * review-required because the text-only rule cannot prove the intended symbol
7
+ * boundary.
8
+ */
9
+ import { resolve, sep } from 'node:path';
10
+
11
+ import { requireGovernedVocabularyTransition } from './retired-vocabulary.js';
12
+ import type { WardenDiagnostic, WardenFix, WardenRule } from './types.js';
13
+
14
+ const RULE_NAME = 'no-retired-cross-vocabulary';
15
+
16
+ const CROSS_COMPOSE_TRANSITION =
17
+ requireGovernedVocabularyTransition('cross-compose');
18
+
19
+ if (CROSS_COMPOSE_TRANSITION.target.kind !== 'single') {
20
+ throw new Error('cross-compose transition must have a single target.');
21
+ }
22
+
23
+ const COMPOSE_TARGET = CROSS_COMPOSE_TRANSITION.target.to;
24
+
25
+ const SAFE_REWRITES = Object.entries(
26
+ CROSS_COMPOSE_TRANSITION.safeRewriteForms
27
+ ).map(([from, to]) => ({ from, to }));
28
+
29
+ const REVIEW_TERMS = CROSS_COMPOSE_TRANSITION.reviewForms;
30
+
31
+ const IDENTIFIER_CHAR = /[$0-9A-Z_a-z]/u;
32
+
33
+ const ALLOWED_PATH_SUFFIXES: readonly string[] = [
34
+ '/docs/migration/cross-to-compose.md',
35
+ '/docs/releases/beta15-to-beta19.md',
36
+ '/docs/adr/0049-composition-is-compose-not-cross.md',
37
+ '/packages/warden/src/rules/no-retired-cross-vocabulary.ts',
38
+ '/packages/warden/src/rules/metadata.ts',
39
+ '/packages/warden/src/rules/retired-vocabulary.ts',
40
+ '/packages/warden/src/trails/governed-symbol-residue.trail.ts',
41
+ '/packages/warden/src/trails/no-retired-cross-vocabulary.trail.ts',
42
+ ];
43
+
44
+ interface SafeRewriteMatch {
45
+ readonly from: string;
46
+ readonly index: number;
47
+ readonly to: string;
48
+ }
49
+
50
+ interface ReviewMatch {
51
+ readonly index: number;
52
+ readonly term: string;
53
+ }
54
+
55
+ const normalizePath = (filePath: string): string =>
56
+ resolve(filePath).split(sep).join('/');
57
+
58
+ const isAllowedFile = (filePath: string): boolean => {
59
+ const normalized = normalizePath(filePath);
60
+ return ALLOWED_PATH_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
61
+ };
62
+
63
+ const isIdentifierChar = (value: string): boolean =>
64
+ value !== '' && IDENTIFIER_CHAR.test(value);
65
+
66
+ const isReviewPrefix = (
67
+ sourceCode: string,
68
+ index: number,
69
+ term: string
70
+ ): boolean => !(term === 'cross' && sourceCode.startsWith('crosses', index));
71
+
72
+ const isStandaloneSpan = (
73
+ sourceCode: string,
74
+ start: number,
75
+ end: number
76
+ ): boolean => {
77
+ const before = start === 0 ? '' : (sourceCode[start - 1] ?? '');
78
+ const after = sourceCode[end] ?? '';
79
+ return !(isIdentifierChar(before) || isIdentifierChar(after));
80
+ };
81
+
82
+ const findSafeRewriteMatches = (
83
+ sourceCode: string
84
+ ): readonly SafeRewriteMatch[] => {
85
+ const matches: SafeRewriteMatch[] = [];
86
+ for (const rewrite of SAFE_REWRITES) {
87
+ let fromIndex = 0;
88
+ while (fromIndex < sourceCode.length) {
89
+ const index = sourceCode.indexOf(rewrite.from, fromIndex);
90
+ if (index === -1) {
91
+ break;
92
+ }
93
+ const end = index + rewrite.from.length;
94
+ if (isStandaloneSpan(sourceCode, index, end)) {
95
+ matches.push({ ...rewrite, index });
96
+ }
97
+ fromIndex = end;
98
+ }
99
+ }
100
+ return matches.toSorted((a, b) => a.index - b.index);
101
+ };
102
+
103
+ const findReviewMatches = (sourceCode: string): readonly ReviewMatch[] => {
104
+ const safeSpans = findSafeRewriteMatches(sourceCode).map((match) => ({
105
+ end: match.index + match.from.length,
106
+ start: match.index,
107
+ }));
108
+ const isInsideSafeSpan = (index: number): boolean =>
109
+ safeSpans.some((span) => index >= span.start && index < span.end);
110
+
111
+ const matches: ReviewMatch[] = [];
112
+ for (const term of REVIEW_TERMS) {
113
+ let fromIndex = 0;
114
+ while (fromIndex < sourceCode.length) {
115
+ const index = sourceCode.indexOf(term, fromIndex);
116
+ if (index === -1) {
117
+ break;
118
+ }
119
+ const end = index + term.length;
120
+ if (!isInsideSafeSpan(index)) {
121
+ const before = index === 0 ? '' : (sourceCode[index - 1] ?? '');
122
+ const after = sourceCode[end] ?? '';
123
+ if (
124
+ !isIdentifierChar(before) &&
125
+ isIdentifierChar(after) &&
126
+ isReviewPrefix(sourceCode, index, term)
127
+ ) {
128
+ matches.push({ index, term });
129
+ }
130
+ }
131
+ fromIndex = end;
132
+ }
133
+ }
134
+ return matches.toSorted((a, b) => a.index - b.index);
135
+ };
136
+
137
+ const lineForOffset = (sourceCode: string, offset: number): number => {
138
+ let line = 1;
139
+ for (let i = 0; i < offset; i += 1) {
140
+ if (sourceCode.codePointAt(i) === 10) {
141
+ line += 1;
142
+ }
143
+ }
144
+ return line;
145
+ };
146
+
147
+ const safeFix = (match: SafeRewriteMatch): WardenFix => ({
148
+ class: 'term-rewrite',
149
+ edits: [
150
+ {
151
+ end: match.index + match.from.length,
152
+ replacement: match.to,
153
+ start: match.index,
154
+ },
155
+ ],
156
+ reason: `Retired composition vocabulary '${match.from}' has a mechanical beta.19 replacement '${match.to}'.`,
157
+ safety: 'safe',
158
+ });
159
+
160
+ const reviewFix = (term: string): WardenFix => ({
161
+ class: 'term-rewrite',
162
+ reason: `Retired composition vocabulary '${term}' appears in a larger or ambiguous form. Review before migrating to ${COMPOSE_TARGET} vocabulary.`,
163
+ safety: 'review',
164
+ });
165
+
166
+ const safeMessage = (match: SafeRewriteMatch): string =>
167
+ `Retired composition vocabulary '${match.from}' should be '${match.to}' after the beta.19 compose cutover.`;
168
+
169
+ const reviewMessage = (term: string): string =>
170
+ `Retired composition vocabulary '${term}' needs review before migrating to ${COMPOSE_TARGET} vocabulary.`;
171
+
172
+ export const noRetiredCrossVocabulary: WardenRule = {
173
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
174
+ if (isAllowedFile(filePath)) {
175
+ return [];
176
+ }
177
+
178
+ const reviewMatches = findReviewMatches(sourceCode);
179
+ if (reviewMatches.length > 0) {
180
+ return reviewMatches.map((match) => ({
181
+ filePath,
182
+ fix: reviewFix(match.term),
183
+ line: lineForOffset(sourceCode, match.index),
184
+ message: reviewMessage(match.term),
185
+ rule: RULE_NAME,
186
+ severity: 'error',
187
+ }));
188
+ }
189
+
190
+ return findSafeRewriteMatches(sourceCode).map((match) => ({
191
+ filePath,
192
+ fix: safeFix(match),
193
+ line: lineForOffset(sourceCode, match.index),
194
+ message: safeMessage(match),
195
+ rule: RULE_NAME,
196
+ severity: 'error',
197
+ }));
198
+ },
199
+ description:
200
+ 'Disallow retired cross composition vocabulary after the beta.19 compose cutover.',
201
+ name: RULE_NAME,
202
+ severity: 'error',
203
+ };